@am_shork/attest 0.7.1 → 0.7.3

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,485 @@ 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.3] - 2026-08-07
17
+
18
+ ### Fixed
19
+
20
+ - **A loader that never opened left its scratch directory behind — one per
21
+ `attest` invocation, i.e. one per CI build.** `createLoader` creates the
22
+ directory and writes the stub, and the only `rm` of it lives inside the `close`
23
+ of the object it returns — so nothing between those two points can be cleaned
24
+ up by anyone, because until the caller holds that object no `close` exists to
25
+ call. It is the exact leak the comment on `close` records as already fixed,
26
+ surviving on the other side of the same function. The window is now closed by
27
+ the function that opened it.
28
+ *Stated over the window rather than over `createServer`, which is where it was
29
+ found.* `writeFileSync` is inside it too, and a full disk or a temp directory
30
+ that turns read-only between the two calls reaches it with no project
31
+ involved — so scoping the repair to the call it was noticed at would have left
32
+ half of it.
33
+ *The trigger this was filed on does not reproduce, and that is the useful
34
+ half.* The entry proposed a malformed `package.json` at the root, on the
35
+ reasoning that `configFile: false` still leaves Vite reading it for `type`.
36
+ Measured: it starts a server cleanly, as do eight other shapes — a
37
+ `package.json` holding an array, one that is a directory, an unknown `type`, a
38
+ `node_modules` occupied by a file, a `node_modules/.vite` occupied by a file, a
39
+ `.env` that is a directory, a deleted root, and a well-formed control. Nine
40
+ candidates, zero throws. **`configFile: false` is why**, and it is worth
41
+ recording as a property rather than an accident: the option set chosen to stop
42
+ the loader reading a project's Vite config, and to stop it opening a socket,
43
+ also leaves almost no project I/O that can fail hard. The throw surface was
44
+ closed as a side effect of decisions taken for other reasons.
45
+ *So the repair is justified by ownership rather than by a symptom*, which is a
46
+ weaker case than this file usually accepts and is why the alternative was to
47
+ reject the entry outright. What decides it is the asymmetry of cost: the fan-out
48
+ entry below stays open because bounding concurrency changes how every run
49
+ behaves, whereas a `try` around a window nothing currently reaches changes
50
+ nothing observable on any path that exists. A resource whose ownership transfer
51
+ is conditional on nothing throwing is a shape, and the shape is what recurs.
52
+ *A cleanup that fails must not replace the error that caused it*, so the `rm`
53
+ swallows its own failure and the original throw propagates unchanged. The
54
+ regression test asserts the identity of what comes out, not its wording.
55
+ **`close` had the same hazard and now makes the same choice, which is a change
56
+ to an existing path rather than a new one.** Its `rm` runs in a `finally`, so a
57
+ failed cleanup replaced whatever `close` was doing — and all five callers in
58
+ `pipeline.ts` call `close` from a `finally` of their own, where the damage is
59
+ larger than symmetry: a real error on the way out is lost, and a *successful*
60
+ command becomes a crash about a temp directory. What that reader is handed is a
61
+ misdiagnosis pointing at their own correct files, which this project already
62
+ treats as worse than the gap it fills — the argument `ATX-49` records against
63
+ reporting a load failure as a wall of skipped scenarios, in a much smaller
64
+ place. Given up with it: the one signal a failed cleanup could have raised,
65
+ which no caller could have acted on.
66
+ *The test goes red without the fix, which is the only reason it is worth
67
+ having.* Verified by reverting `src/` and re-running: `expected [
68
+ 'attest-loader-z7jhoK' ] to deeply equal []`, the leaked directory named in the
69
+ failure. It injects the throw at the module boundary — no project input could
70
+ produce one — and mocks `tmpdir` to a directory of its own, because on the
71
+ throwing path there is no loader to ask for its `scratchDir` and counting
72
+ `attest-loader-*` in the shared temp directory is the flake the lifecycle test
73
+ beside it already documents.
74
+ *It is its own file*, which is a rule this repo already wrote down and which
75
+ the first draft broke: `tests/locate-fanout.spec.ts` is separate because
76
+ `vi.mock` is file-scoped and it mocks a Node builtin, and the four lifecycle
77
+ tests boot the *real* Vite loader — sharing a module registry with a
78
+ `createServer` that throws is exactly what that convention exists to prevent.
79
+ The first attempt scoped the mocks with `doMock` and `resetModules` inside the
80
+ shared file instead, which is machinery bought to work around a constraint the
81
+ repo had already decided how to solve. Following it deleted the machinery.
82
+ *No new requirement, and the precedent cuts the other way, so it is worth
83
+ saying why.* `ATX-36` is the sibling property — what a live loader leaves on the
84
+ user's machine, stated as a count, with a scenario in `self/` — and "leaves no
85
+ scratch directory" is the same shape. What decides against filing one here is
86
+ that the success-path half has never had a requirement either, so a new one
87
+ covering only the throwing path would state half a contract and cost a
88
+ permanent scenario for the half nothing reaches. If it is ever filed it should
89
+ be filed whole, and that is a larger change than this fix.
90
+
91
+ - **The `cmd.exe` guard in `tests/consumer.spec.ts` checked one character of a
92
+ class, and the comment above it claimed the quoting was doing work it cannot
93
+ do here.** `shellArg` asserted the argument held no `"` and then quoted only
94
+ when it held whitespace, so an argument with neither went to the shell bare
95
+ with `&`, `|`, `^`, `<`, `>`, `(` unexamined. Measured against the twelve
96
+ shapes: the old assertion let **11 of 12** through.
97
+ *What decided it, and it is the thing the entry this replaces did not have.*
98
+ That entry framed the choice as "widen the class, or fix the prose", and
99
+ argued that widening while leaving the prose is the change that looks like a
100
+ fix and settles nothing. Both halves turn on a fact neither named: **every
101
+ command this suite spawns is a `.cmd`**, which is the whole reason
102
+ `NEEDS_SHELL` exists, and a batch file expands `%*` into its own line *before*
103
+ anything parses it — so a metacharacter survives double quotes and is acted on
104
+ at the far end. That is the argument-injection hole Node closed in 22.x,
105
+ arrived at the long way round. So the quoting is not a defence that the
106
+ assertion merely backstops; **the assertion is the only defence**, and a
107
+ defence that covers one character of its class is the defect rather than the
108
+ prose being generous. Both are fixed together: the class is named, and the
109
+ comment now says the quotes do whitespace and nothing else.
110
+ *`%` and `!` are in the class for a different reason* — expansion rather than
111
+ syntax, and `!` only under delayed expansion, which the spawned shell does not
112
+ enable but a batch file can enable for itself. Neither costs anything to
113
+ refuse.
114
+ *The cost, which is real and is the right cost.* Windows permits `&`, `(` and
115
+ `%` in a user name, and `os.tmpdir()` sits under it — so on such a machine this
116
+ suite now stops with *"argument would need escaping, not quoting"* instead of
117
+ running. That is correct rather than a false red: through a batch target those
118
+ arguments genuinely are not safe, and the invariant the assertion guards is
119
+ that every argument is a literal flag or a path from `mkdtemp`. Stopping is
120
+ right even where quoting would have survived it, because the value is then not
121
+ the kind of value the helper accepts.
122
+ *What is measured, and what is not.* `pnpm test:consumer` was run on Windows,
123
+ where `NEEDS_SHELL` is true and the changed path actually executes: 11 tests
124
+ passed, none skipped — the distinction that matters, since the failure this
125
+ file's own comment records reported ten tests as *skipped* at zero run. CI is
126
+ Linux, where `shellArg` returns before the assertion, so the pipeline says
127
+ nothing about this either way. Not measured end to end: an argument carrying a
128
+ metacharacter actually reaching the guard, because no call site can produce
129
+ one — the class is verified as a predicate over the twelve shapes above and
130
+ the sixteen arguments the suite really passes, not by driving the suite red.
131
+
132
+ - **A statement or rationale can no longer become HTML, or open a section of
133
+ its own.** `sanitised` is the entry §9.1 names and it strips control
134
+ characters and nothing else, so `statement` and `rationale` reached the
135
+ Markdown by concatenation exactly as written. Measured: a `rationale`
136
+ containing a newline and `## ATX-999` rendered a whole section — heading,
137
+ statement, `**Why:**` — for a requirement no registry holds, sitting between
138
+ two real ones and worded identically, with the overview table above it listing
139
+ three ids and the body carrying four sections. `<script>` and `<img onerror>`
140
+ survived the same way into a file that is committed and served. `check` and
141
+ `cover` saw nothing, because the id is in no registry, and `render --check`
142
+ could not object, because that *was* what the registry rendered to.
143
+ *Which of the three is intended, which is the part the fix had to decide.*
144
+ Marking prose up stays — `plain` escapes a param value precisely because the
145
+ value is data and the sentence around it is not — and that decision had never
146
+ been separated from allowing raw HTML or a heading. It is now a **fifth
147
+ obligation in design §9.1** rather than a patch at an emitter: prose may be
148
+ marked up, it may not become a tag, and it may not open a section. Stated as a
149
+ count over the document — one heading per requirement id — because what a
150
+ reader trusts is that a section means a requirement (`ATX-67`).
151
+ *Escaped rather than refused, on an asymmetry.* A rule that misses something
152
+ ships the document it ships today; a rule that fires wrongly makes a
153
+ legitimate rationale a hard error in the one command whose value is that it
154
+ always produces the document — the `ATX-29` argument about `--check` seen from
155
+ the other side. So there is no new diagnostic and no exit code moves.
156
+ *And escaped for the position, which is what keeps it lossless.* A code span
157
+ is literal text in every renderer, so `&lt;` written inside one is **visible**
158
+ — Markdown does not decode entities there, and this repository documents
159
+ `attest status <change>` in a statement. The scan therefore tracks spans and
160
+ leaves their contents alone, conservatively: a backtick run opens a span only
161
+ if a run of exactly that length closes it, and an unmatched run is text with
162
+ the defence still applied after it.
163
+ *One rendering change beyond the fix, and it is a repair.* A param
164
+ substituted inside the author's own code span was emitted as `**value**`,
165
+ which in a code span is two asterisks around the value; knowing the position
166
+ is what removes them. `SPEC.md` in this repository moves by 12 lines, all of
167
+ that shape — `` `**changes/<name>/first-run.json**` `` becomes
168
+ `` `changes/<name>/first-run.json` `` — and no `&lt;` appears anywhere in it,
169
+ which is the measurement that the span exception works on real prose.
170
+ *The defence had the defect it was written next to, and `ATX-59` gained a
171
+ third shape because of it.* The first code-span scan searched forward from
172
+ each backtick run for a run of the same length, so a rationale whose runs are
173
+ all of *different* lengths paid a scan to the end of the string for every one
174
+ of them: 500 KB cost 1.6 s and 2 MB cost 15 s, from `attest render` with no
175
+ flag, on text the registry chooses — the same "a registry must not decide what
176
+ rendering costs" defect the padded statement and the backtick-run param
177
+ already stand for. The runs are now read once and indexed by length, each
178
+ length holding a forward-only cursor, which is linear: 40 ms at 2 MB. It is
179
+ filed under `ATX-59` rather than here because the obligation is that one, and
180
+ it is worth saying where the site came from — the *defence another requirement
181
+ added*, which is the direction the next one will arrive from too.
182
+ *This leaves `Planned`.* The forged section was reproduced first, then the
183
+ four scenarios were run against the pre-fix renderer: three went red, and the
184
+ fourth — the setext underline, which heads the line *above* it — went green,
185
+ because counting `##` lines cannot see a heading that has no `##` in it. That
186
+ scenario was rewritten to assert the neutralised underline instead. A scenario
187
+ that cannot fail is the defect `archive` exists to catch, and it was caught
188
+ here only by running it against the old code.
189
+
190
+ - **`attest init` no longer follows a symbolic link out of the project, and
191
+ refuses the whole run when a destination leads there.** `runInit` did
192
+ `join(root, target.file)`, `mkdir -p` and then the write, and nothing asked
193
+ whether the path was still under `root` once the filesystem had had its say.
194
+ A `.claude/skills/attest` planted in a repository as a link to anywhere the
195
+ user can write was followed by both: measured on Windows with a junction, a
196
+ file at the far end holding `USER OWNED FILE - DO NOT OVERWRITE` came back
197
+ holding the skill, and the report printed the relative path, so nothing on
198
+ screen said where it had gone. Every destination is now resolved segment by
199
+ segment before the first byte of any of them is written, and one that escapes
200
+ is an `unsafe-target-path` ERROR that writes nothing at all (`ATX-66`).
201
+ *What the exposure was, stated precisely, because two nearby things are not
202
+ it.* `writeAtomic`'s exclusive creation is not the gap and was never scoped to
203
+ be one: it defends the *temporary* path against a planted link, and it does
204
+ that perfectly in whichever directory the destination turns out to be. And
205
+ `workflowBody()` is a pure function of nothing, so no byte of the checked
206
+ project reaches the file — there is nothing to exfiltrate. What was exposed is
207
+ the **path**, which is `targets.ts`'s first scoping rule ("Attest owns the
208
+ path") having been an assumption rather than a check.
209
+ *Why the check is a resolution rather than a comparison.* `join` normalises
210
+ `..`, so a lexical containment test was already satisfied — the escape is a
211
+ segment that *is* a link, which only `realpath` can see. Containment itself
212
+ goes through `isInside` in `src/core/paths.ts`, asked as a relative path
213
+ rather than a string prefix: `/repo-backup` starts with `/repo`, and on
214
+ Windows a path on another drive has no relative spelling at all, so
215
+ `path.relative` answers with an absolute one instead of a chain of `..`. Both
216
+ read as contained under `startsWith`.
217
+ *The limit, recorded rather than papered over.* A link planted between the
218
+ resolution and the write is not caught, and no check outside the filesystem
219
+ can catch it. The exposure this closes is one committed to a repository, which
220
+ is the one a fresh clone and every merge-request checkout hands you.
221
+ *Behaviour change:* an `init` that used to write through such a link now
222
+ fails with exit 1. The only input that reaches it is a path that was already
223
+ leaving the project. The JSON surface is unchanged beyond one more `code`
224
+ value, which is additive — no `SCHEMA_VERSION` bump.
225
+ *This leaves `Planned`*, where it was filed with the measurement above; the
226
+ first act of building it was re-running that measurement, and it reproduced.
227
+
228
+ ### Changed
229
+
230
+ - **The guidance now draws the line between a literal that is drift and a literal
231
+ that is a pin — the shipped text forbade the only defence against a shortened
232
+ domain.** `[0.7.2]` fixed the repo half: every param a scenario iterates gained
233
+ a literal pinning its extent, after three of that release's four green
234
+ mutations turned out to be a domain silently losing cases rather than the
235
+ tautology the experiment was looking for. The user-facing half was held back,
236
+ and it was not merely absent. The `init` skill's "Four things you must not do"
237
+ says **"Do not hardcode a value that lives in `params`"**, and the pin every one
238
+ of those fixes adds is, read literally, exactly that. An adopter who writes a
239
+ roster param — which the design actively pushes them toward, since a quantifier
240
+ a scenario can iterate is the shape `QUANTIFIED` recommends — was being handed
241
+ an instruction that forbids the only thing standing between them and a run that
242
+ covers less every time someone edits the list.
243
+ *The distinction is real and one sentence long*, and it is now drawn in all four
244
+ places the claim is made: the rule is about the **expectation** the system is
245
+ measured against, and the pin asserts what the **intent** is. A list a scenario
246
+ loops over is not a value under test — it is the set of cases the run covers,
247
+ so dropping a member removes a case while every assertion inside still passes,
248
+ with the expectation as independent as it ever was. That is why the `[0.5.0]`
249
+ rule (*the expectation must not be a function of the param the code under test
250
+ consumed*) does not reach it, and why a literal is the **only** independent term
251
+ available: anything derived from the list shrinks with it, so a length assertion
252
+ written as a function of the list is true at every length.
253
+ *Where it landed, and why it is four documents rather than one line appended.*
254
+ README §"killer move", the `init` skill (a sibling bullet in the model section
255
+ and a carve-out inside rule 3 itself, where the contradiction was), the
256
+ `possible-drift` troubleshooting section with a worked ✓/✗ pair, and design §11
257
+ as a residual weakness beside the tautology bullet it shares a gap with — both
258
+ languages. `[0.5.0]` records the reason this shape of change is done in one
259
+ deliberate pass rather than incrementally: it is the central claim of the tool
260
+ being reworded, and the last time it was split the halves disagreed.
261
+ *No engine change, and none is proposed here.* Nothing in this is detectable —
262
+ which surface a statement claims is prose judgement, the same judgement §11
263
+ already records as the reason obligation counting can never be an engine rule.
264
+ The counter-pressure that exists is a hand-judged table, and §11 now says so,
265
+ including what it cannot reach: a domain whose size is carried by a scalar.
266
+ *This leaves `Planned`*, where it was filed with the measurement above; the
267
+ measurement was already taken, and what it needed was the pass.
268
+
269
+ - **The release publishes through `pnpm` again, and the check that read the
270
+ attestation back is gone.** Two corrections to the provenance job shipped in
271
+ `[0.7.2]`, both from what the first real tag pipeline showed.
272
+ *`pnpm publish --provenance` works on the pinned 10.28.0; the claim that it
273
+ did not was read off `pnpm publish --help`, where the flag is absent.* The
274
+ parser knows it — `--provenanceX` is rejected with *"Did you mean
275
+ 'provenance'?"*, and `--provenance --dry-run` runs clean. A help text is a
276
+ document, not the interface; checking a flag means asking the parser. So the
277
+ job is pnpm like every other job here, `--no-git-checks` comes back with it
278
+ (pnpm inspects the branch, CI builds tags in detached HEAD), and
279
+ `COREPACK_ENABLE_STRICT=0` goes away, since it existed only so a bare `npm`
280
+ could run under a `packageManager` that pins pnpm.
281
+ *The post-publish check failed the 0.7.2 release, and the publish it failed
282
+ was correctly signed.* Six attempts over a minute all got a 404 from the
283
+ registry's attestation endpoint; the same endpoint now serves
284
+ `slsa.dev/provenance/v0.2` for `0.7.2`. Lengthening the budget was the wrong
285
+ repair, and the entry it replaces already contains the reason without drawing
286
+ the conclusion: the check runs *after* an irreversible step, so its red cannot
287
+ be cleared — the republish that would rerun it fails `EPUBLISHCONFLICT` on a
288
+ version that now exists. Every failure it can reach is therefore either a
289
+ false one or an unfixable one, on a job nobody can make green again, which
290
+ makes it a machine for teaching that red is routine — the exact habit the
291
+ `audit` job's comment refuses. A gate has to sit where the answer can still
292
+ change what happens. Verification moves to where it can: `npm audit
293
+ signatures` in an installing project, or the package page's Provenance panel.
294
+ *What is unchanged:* the attestation itself, the three preconditions, and the
295
+ fact that npm rejects the publish rather than degrading to an unsigned one if
296
+ any is missing. `0.7.2` is signed, and shipped signed, despite the red job.
297
+
298
+ ## [0.7.2] - 2026-08-07
299
+
300
+ ### Added
301
+
302
+ - **Releases carry a provenance attestation, and the publish job proves it was
303
+ served rather than trusting its own exit code.** `npm publish --provenance`
304
+ exchanges a GitLab OIDC token for a Sigstore certificate and records, in the
305
+ public transparency log, that this tarball was built by this pipeline from
306
+ this commit — checkable by anyone with `npm audit signatures` or the package
307
+ page, without trusting us. Nothing about the package's *contents* changes:
308
+ same `files`, same tarball, same `prepack`/`prepublishOnly` gate.
309
+ *It is `npm` in a repository whose every other job is `pnpm`, and that is not
310
+ drift.* pnpm's publish has no provenance support at all — checked against the
311
+ pinned 10.28.0, there is no such flag — so the choice was npm for this one
312
+ command or no attestation. Two consequences worth writing down. `--no-git-checks`
313
+ disappears with it: that was a pnpm flag, needed only because pnpm inspects
314
+ the branch and CI builds tags in detached HEAD. And `COREPACK_ENABLE_STRICT=0`
315
+ is now required to call `npm` at all — `corepack enable` shims npm too, and
316
+ `packageManager` pins pnpm, so a bare `npm` aborts with "this project is
317
+ configured to use pnpm". Job-local rather than narrowing the shim in `.base`,
318
+ because every other job wants exactly that strictness.
319
+ *The check after the publish, and the one that looked right and was worthless.*
320
+ The obvious step is `npm audit signatures`, and here it would have been
321
+ decoration of precisely the kind the `audit` job's comment refuses: run in this
322
+ repository it audits *our own installed dependency tree*, which never contains
323
+ `@am_shork/attest` — green while attesting nothing. What runs instead reads the
324
+ registry's attestation endpoint for the version just published and fails unless
325
+ a `slsa.dev/provenance/` predicate is actually there. That is the consumer's
326
+ view, and it is what separates "npm accepted the tarball" from "the statement
327
+ is served". It retries for propagation, because it runs *after* an
328
+ irreversible step: the publish has happened, and a rerun cannot clear a red
329
+ since the republish fails `EPUBLISHCONFLICT` on a version that now exists — so
330
+ a registry that had not caught up would pin the release job red with nothing
331
+ wrong. A served response missing the predicate fails immediately; that answer
332
+ will not change. Both paths were run against the live registry — green on a
333
+ package known to carry provenance, red on `0.7.1`, which does not.
334
+ *Three preconditions, and npm rejects the publish rather than quietly shipping
335
+ unsigned if any is missing.* An `id_tokens` audience of exactly `sigstore`; a
336
+ `repository.url` matching `CI_PROJECT_URL`, so a repository rename fails here
337
+ first; and a public package on registry.npmjs.org, which
338
+ `publishConfig.access=public` already settles. The token is still an npm
339
+ automation token — the OIDC exchange signs the release, it does not authorise
340
+ it (see *Under consideration*).
341
+ *What is unmeasured, stated rather than implied.* There is no dry run for
342
+ provenance — `--dry-run` mints no token exchange — so none of this has executed
343
+ against a real tag pipeline. The next release is its first run. The commit SHA,
344
+ tag, pipeline URL and repository path become public record; all four are
345
+ already public here.
346
+
347
+ ### Fixed
348
+
349
+ - **One unreadable file took the whole of `check` down with it.** A `params`
350
+ value nested 20,000 deep exhausts the call stack inside `ts.createSourceFile`
351
+ — TypeScript's own recursive-descent parser, so `literalValue`'s recursion is
352
+ never reached and hardening it would have changed nothing. Measured: `attest
353
+ check` answered a single `internal-error`, exit 1. It failed *closed*, which
354
+ is why this is a repair and not an emergency — but `check` is the command this
355
+ project tells people to run first, in a pipeline, on a merge request whose
356
+ contents they do not control, and one file an attacker owns could reduce the
357
+ entire report to that one line, permanently. Breadth is what `check` trades
358
+ for running no project code; a report of one crash is not a degraded `check`,
359
+ it is a different command.
360
+ Now a file that cannot be parsed is reported as **`unreadable-file`** and
361
+ skipped, and everything else in the run is still reported.
362
+ *Two sites, and a guard on one of them fixes nothing.* `static-registry.ts`
363
+ and `parser.ts` both open with the same call, so the spec parse takes it too —
364
+ measured separately, and a registry-only guard leaves `attest check` dying on
365
+ a hostile `*.spec.ts` exactly as before. The registry half sits at the loop
366
+ that reads the files rather than inside either reader, because what is being
367
+ kept is a property of the *run* — one file's failure is not the run's failure
368
+ — and both readers need it; `readRegistry` folds them with `Promise.all`,
369
+ where one rejection ends the command.
370
+ *Its own code, not `registry-invalid`.* A file that blew the parser's stack
371
+ did not fail schema validation, and a diagnostic saying it did sends the
372
+ reader to the wrong page. The archive gate refuses to run at all when a spec
373
+ is unreadable, rather than grading a change against a plan known to be short.
374
+ *A third read site turned up while reading the diff, and it was answering
375
+ wrongly rather than crashing.* An unreadable **proposed** spec declares no
376
+ scenarios, so the unclaimed check found it claimed by nothing and reported
377
+ `proposed-spec-unclaimed` — a true sentence about a file whose actual problem
378
+ is that it could not be read, and a fix hint pointing at work that must not be
379
+ done. That is the shape `[0.7.0]` fixed for `orphan-test` after a registry
380
+ fails to load, arriving again at a different site. It now reports
381
+ `unreadable-file` and stands down.
382
+ `parseSpecs` now returns its issues alongside the plan; `parseAllSpecFiles`
383
+ was deleted in passing, having had no callers.
384
+ **No `SCHEMA_VERSION` bump.** A new `code` value is additive to the `--json`
385
+ envelope, and a consumer that branches on codes it knows is unaffected — the
386
+ rule this project already applies to every new diagnostic.
387
+
388
+ - **`attest status` printed one line the sanitiser never saw.** The `Change:`
389
+ header was built and printed in the CLI shell, three lines above a closing
390
+ line that put the same value through `inline()` — so a change name holding
391
+ control characters repainted the terminal from the header while the footer was
392
+ clean. A change name is a directory name in the repository under test, and the
393
+ workflow `init` writes tells an agent to run `attest status <name>`.
394
+ The header moved into `formatStatus`, which is a fix rather than a tidy-up: a
395
+ write that lives in `cli/index.ts` cannot be attested at all, because that
396
+ module runs the CLI at import — so the site was not merely unsanitised, it was
397
+ unreachable by any scenario. `ATX-37`'s site list gained the kind that had
398
+ been missing from it, **a write in the shell**, and the two `--out` echoes are
399
+ the remainder: sanitised now, and still carried as unattested, because the
400
+ trigger needs a filename holding a control character — which NTFS refuses and
401
+ Linux permits, so that half is unmeasured here.
402
+
403
+ - **A param that is a scenario's *domain* could be shortened, and the run just
404
+ covered less.** Nothing in this repo executes here — it is the self-registry
405
+ and its measurement suite — but three of the holes were real, and one of them
406
+ was a security check. Found by re-measuring the tautology entry below after
407
+ `[0.7.1]`: twelve mutations of this registry, each followed by the whole gate.
408
+ Four went green, and three of those were **not** the tautology that experiment
409
+ was looking for. When a scenario loops over a list it read from `params`, that
410
+ list is the set of cases the run covers, so removing a member removes a case
411
+ and every assertion inside the loop still passes over what is left. The
412
+ expectation stays perfectly independent; what moved is the quantifier's
413
+ domain, which is why the rule shipped in `[0.5.0]` — *the expectation must not
414
+ be a function of the param the code under test consumed* — does not cover it.
415
+ What each one cost, measured: dropping `../../evil` from `ATX-13.rejectedNames`
416
+ stopped testing the change-name guard against path traversal; `ATX-40.languages: 1`
417
+ stopped checking `docs/zh/troubleshooting.md` **entirely**, which is the
418
+ mirror rule `CLAUDE.md` calls mandatory losing its only gate; and
419
+ `ATX-64.childRunCommands: ['verify']` stopped checking `archive` for the
420
+ stdout hole `ATX-64` exists to close — filed in the same release, three
421
+ scenarios deep, and silent.
422
+ *The repo had already invented the defence and not named it.* `ATX-16` asserts
423
+ its list against a literal and its comment says why — "so the spec cannot
424
+ quietly cover fewer of them than the intent promises" — and it was one of the
425
+ two lists that went red. A literal is the only independent term available when
426
+ what is at risk is the **size** of the set rather than a value in it, which is
427
+ also why this reads as a violation of "do not hardcode a value that lives in
428
+ `params`" and is not one: the pin asserts what the intent *is*, not what the
429
+ system does.
430
+ Every iterated list now carries that line. `ATX-40` needed both halves and
431
+ that is the instructive one: `toHaveLength(languages)` already caught the
432
+ count going *up* without a document, and could never catch it going down,
433
+ because the slice shrinks with it and the assertion stays true.
434
+ *The counter-pressure, and one thing it cannot reach.* A `DOMAINS` table in
435
+ `tests/intent-rule-candidates.spec.ts` now judges every list-valued param as
436
+ `iterated` or `inert`, with completeness swept from the registry so a new list
437
+ fails until someone decides, and each `iterated` row naming the pin that
438
+ guards it. The `QUANTIFIED` taxonomy gained the distinction this turned on:
439
+ a `roster` owned by the **code** grows with the code and is safe by
440
+ construction, a roster owned by the **registry** is data an author can shorten
441
+ — `ATX-64` was filed as the former and was the latter. What the sweep does not
442
+ reach is `ATX-40`, whose domain size is a *scalar*; it is judged by hand, and
443
+ a future scalar used that way would not be forced into the table by anything.
444
+ *The rot check needed a second attempt, which is worth recording.* Written
445
+ first as "the pin appears somewhere in `self/`", it passed with `ATX-64`'s pin
446
+ deleted — two requirements pin a list read into a local named `commands`, and
447
+ the grep matched the other one. It asserts **exactly one** match now. A check
448
+ satisfiable by an unrelated line is the failure it exists to prevent.
449
+
450
+ - **A project under test could put its own verdict on stdout ahead of Attest's,
451
+ under `--json`.** `runAndCollect` starts Vitest with the parent's fd 1 in
452
+ play, and `quiet: true` does not close it: `silent: true` suppresses Vitest's
453
+ *console interception*, and a bare `process.stdout.write` in a spec never
454
+ enters it — measured at collection time and inside a scenario body, both. So
455
+ a spec writing `{"ok":true,…}` put that document **first** on the stream: a
456
+ consumer calling `JSON.parse` on the whole of stdout got a parse error at
457
+ position 46, and one reading line-wise or taking the first object got the
458
+ attacker's verdict instead of the report. `check`, `cover`, `render` and
459
+ `status` were never exposed — no Attest code was on the path at all, which is
460
+ precisely why `core/terminal.ts` and `sanitisedLogger` did not help.
461
+ *Why this is worse than the terminal half of the same hole.* Repainting a
462
+ terminal — `ESC[2K CR`, a forged `ERROR` header at column 0, an `OSC 0` window
463
+ retitle, all of which also arrive intact — is not a capability `verify`
464
+ lacked, because it executes project code by design. Forging the *machine*
465
+ verdict is different in kind: it inverts what the report is for, since the
466
+ commands carrying the risk are exactly the ones that run the repository being
467
+ gated. §9.1 recorded the machine surface as never exposed because
468
+ `JSON.stringify` escapes every C0 character — true, and about the bytes Attest
469
+ writes; it says nothing about bytes Attest does not write.
470
+ Under `--json`, stdout is now diverted to stderr for the duration of the
471
+ command and released before the report is rendered. Diverted rather than
472
+ dropped: a human debugging a red pipeline still needs the run output, and
473
+ stderr is the stream no consumer parses. Without `--json` nothing changes —
474
+ the promise is the flag's, and diverting unconditionally would take the child
475
+ run's output away from the reader it exists for.
476
+ *The fix is `runAction`'s, not `runner.ts`'s* — the same argument that put
477
+ `formatCrash` in `cli/report.ts` rather than a copy in each `catch`.
478
+ `runAction` is already the single convergence point for one output and one
479
+ exit code; `runner.ts` would have been made to hold a contract it does not
480
+ own. It moved to **`src/cli/action.ts`** to be testable at all: `cli/index.ts`
481
+ calls `program.parseAsync()` at import time, so importing it to cross the seam
482
+ would run the CLI. No behaviour of `index.ts` moved with it.
483
+ *What the regression test had to be, because the first one was wrong.* The
484
+ scenarios under **`ATX-64`** spawn the real CLI and read its pipes. The
485
+ in-process version looks equivalent and is not: they run inside a Vitest
486
+ worker, which patches `console` and re-routes worker output, so intercepting
487
+ `process.stdout.write` there observed neither the report nor the pollution and
488
+ went green against the unfixed code for three of three scenarios. What the
489
+ requirement is about is the bytes on the pipe, and only a real pipe has them.
490
+ The statement names its commands in `params` rather than quantifying over
491
+ "every command that executes project code" — a `roster` row rather than a
492
+ `sites` one, so a third such command cannot join without joining the
493
+ assertion.
494
+
16
495
  ## [0.7.1] - 2026-08-05
17
496
 
18
497
  ### Added
@@ -3196,99 +3675,6 @@ no symptom is the definition of what a gate cannot see.
3196
3675
  `[0.4.3]` — that the gate and the run proving the fixture still works live in
3197
3676
  different suites — applies here identically.
3198
3677
 
3199
- - **The child run's stdout is not Attest's, and `--json` promises it is.**
3200
- `runAndCollect` starts Vitest with the parent's fd 1 inherited, so a spec file
3201
- in the project under test writes straight to the same stream the report goes
3202
- to. `quiet: true` does not close it: `silent: true` suppresses Vitest's
3203
- *console interception*, and a bare `process.stdout.write` in a spec — at
3204
- collection time or inside a scenario body, both measured — goes past it
3205
- untouched. Neither `core/terminal.ts` nor `sanitisedLogger` is on that path at
3206
- all, because no Attest code is.
3207
- *The machine surface is the half that is actually new.* `cli/index.ts` opens
3208
- by stating that under `--json` "the only thing written to stdout is one
3209
- machine-readable report", and §9.1 records that this path "was never exposed —
3210
- `JSON.stringify` escapes every C0 character". Both hold for `check`, `cover`,
3211
- `render` and `status`, and neither holds for `verify` or `archive`: measured,
3212
- a spec writing `{"ok":true,"POLLUTION":"…"}` puts that document **first** on
3213
- stdout, ahead of the real report, so `JSON.parse` of the whole stream fails
3214
- (position 46) while anything reading line-wise or taking the first object
3215
- reads the attacker's verdict. The terminal half is the same hole and the
3216
- smaller finding — `ESC[2K CR` plus a forged `ERROR` header at column 0 and an
3217
- `OSC 0` window retitle all arrive intact — but `verify` executes project code
3218
- by design, so repainting a terminal is not a capability that path lacked.
3219
- *The fix belongs to `runAction`, not to `runner.ts`.* That function is already
3220
- the single convergence point for "one output, one exit code", which is the
3221
- same argument that put `formatCrash` in `cli/report.ts` rather than leaving a
3222
- copy in each `catch`: `runner.ts` would be made to hold a contract it does not
3223
- own, while `runAction` is the only place the promise is made.
3224
-
3225
- - **`render`'s document can carry raw HTML and a forged requirement section.**
3226
- `sanitised` is the entry §9.1 names and it strips control characters and
3227
- nothing else, so `statement` and `rationale` reach the Markdown by
3228
- concatenation exactly as written. Measured: a `rationale` containing newlines
3229
- and `## ATX-999` renders a whole section — heading, statement, `**Why:**` —
3230
- for a requirement no registry holds, sitting between two real ones and worded
3231
- identically; `<script>` and `<img onerror>` survive the same way. `check` and
3232
- `cover` see only the real id, and `render --check` cannot object, because that
3233
- *is* what the registry renders to now.
3234
- *Not an accepted risk, which is the part worth pinning.* §9.1 and the
3235
- reference both scope their guarantee to control characters, and the
3236
- reference's heading over that paragraph is "Safe to read, and safe to keep",
3237
- with "a site generator" named in the threat it describes. What *is* a decision
3238
- is that prose may be marked up — `plain()` escapes a param value precisely
3239
- because it is data while the statement around it is not — and that decision
3240
- has never been separated from allowing raw HTML or a heading. So the entry is
3241
- as much about stating which of the three is intended as about the escaping,
3242
- and if the answer is that Markdown stays and HTML and headings go, that is a
3243
- fourth obligation in §9.1 rather than a patch at an emitter.
3244
-
3245
- - **`attest init` follows a symlink out of the project root.** `runInit` does
3246
- `mkdir(dirname(dest), { recursive: true })` and then `writeAtomic`, and
3247
- neither asks whether `dest` is still under `root`. A `.claude/skills/attest`
3248
- planted in the repository as a link to anywhere the user can write is followed
3249
- by both: measured on Windows with a junction, a file at the target holding
3250
- `USER OWNED FILE - DO NOT OVERWRITE` came back holding the skill. The report
3251
- prints the relative path, so nothing on screen says where it went.
3252
- *`write.ts` is not the gap and neither is `targets.ts`'s content.* The `wx`
3253
- flag defends the *temporary* path against a planted link and does exactly
3254
- that; the destination's directory was never in its scope. And `workflowBody()`
3255
- is a pure function of nothing, so no byte of the checked project reaches the
3256
- file — the exposure is the path, not the content, which is `targets.ts`'s
3257
- first rule ("Attest owns the path") being an assumption rather than a check.
3258
- The check belongs beside `resolveTargets`, which is already all-or-nothing for
3259
- the same reason: a name known to be unwritable must not leave half a set
3260
- behind.
3261
-
3262
- - **A deeply nested literal takes `check` down before either reader sees it.**
3263
- 40 KB of `[[[…]]]` in a `params` value exhausts the stack inside
3264
- `ts.createSourceFile` — TypeScript's own recursive-descent parser, so
3265
- `literalValue`'s recursion is never reached and hardening it would change
3266
- nothing. `parser.ts` opens with the same call, so `verify` takes it too, and
3267
- `loadRegistry` reads with `Promise.all`, so one file ends the whole command.
3268
- *It fails closed, which is why this is a repair and not an emergency.*
3269
- Measured: `internal-error`, `ok: false`, exit 1, and a well-formed `--json`
3270
- envelope. What it costs is that the command this project tells people to run
3271
- first on an untrusted fork MR can be made to say nothing but that, forever.
3272
- The shape of the fix is already in `locate.ts`: `unreadableFiles` exists to
3273
- say "this file could not be read" without ending the run, and a `RangeError`
3274
- out of a single `read` or `parseSpecFile` is that, so a hostile file scraps
3275
- only itself and `check`'s breadth contract survives it.
3276
-
3277
- - **`status` prints one line the sanitiser never sees.** `cli/index.ts` writes
3278
- `Change: ${result.change}` bare, while `formatStatus` puts the same value
3279
- through `inline` twice, three lines apart in the same output. Measured with a
3280
- directory named with U+009B — the C1 CSI the class in `control` exists for,
3281
- and one NTFS permits — the header carried `c2 9b` raw while the closing
3282
- `Not a verdict:` line carried a space. A change name is a directory name in
3283
- the repository under test, and the workflow `init` writes tells an agent to
3284
- run `attest status <name>`, so the value is the project's to choose.
3285
- *One `inline()` call, and the entry is here for the reason rather than the
3286
- fix.* §9.1 says a stream has no entry, only writes, so the obligation sits on
3287
- every write or not at all — and this is the second time a single missed write
3288
- has been the whole defect, after the two `catch` blocks that printed a raw
3289
- stack beside sanitised diagnostics. The sweep filed above under `ATX-37` is
3290
- what would have found it; this is one more measurement in favour of it.
3291
-
3292
3678
  ## Under consideration
3293
3679
 
3294
3680
  Candidates with **no decision yet**, between the two sections either side of it
@@ -3301,6 +3687,25 @@ deletion. What each entry has to carry is the evidence currently blocking the
3301
3687
  decision, because that is what a re-proposal a year from now would otherwise
3302
3688
  have to gather again.
3303
3689
 
3690
+ - **Trusted publishing, which would delete `NPM_TOKEN` rather than add to it.**
3691
+ The provenance work in `[0.7.2]` signs the release but does not change
3692
+ what authorises it: a long-lived automation token, masked and protected, that
3693
+ publishes as us to anyone holding it. npm's trusted publishing exchanges the
3694
+ same OIDC identity the attestation already uses for a short-lived credential,
3695
+ which would remove the stored secret entirely and grant provenance as a
3696
+ property of the flow rather than a flag on the command. The deletion test
3697
+ points the right way for once: what it removes is the only long-lived
3698
+ credential this repository has.
3699
+ *What blocks the decision, and it is evidence rather than principle.* Whether
3700
+ npm's trusted publishers accept a **gitlab.com-hosted** project on the plan
3701
+ this package publishes under has not been verified against this project — it
3702
+ was not checked when the provenance job was written, and an entry claiming it
3703
+ works would be exactly the unverified confidence the rest of this file avoids.
3704
+ It also cannot be tested except by publishing: as with provenance, there is no
3705
+ dry run, so a failed experiment is a release. The cheap next step is reading
3706
+ npm's current trusted-publisher documentation for GitLab support before
3707
+ writing any YAML.
3708
+
3304
3709
  - **A static sweep for the two properties claimed over the whole of `src/`.**
3305
3710
  The quantifier table's first strict read left three rows wanting the same
3306
3711
  thing, and the repo already owns the idiom: `tests/import-boundary.spec.ts` is
@@ -3439,6 +3844,103 @@ have to gather again.
3439
3844
  assertion is still green, still silent, and still reachable — so this stays
3440
3845
  here rather than moving to `Considered and rejected`: documenting a trap is
3441
3846
  not the same as deciding not to detect it.
3847
+ **Re-measured after `[0.7.1]` widened `params`, on this registry rather than
3848
+ the reporter's — the first time this experiment has been run here.** Twelve
3849
+ mutations, each followed by the whole gate. Two results matter, and they point
3850
+ opposite ways.
3851
+ *The shape is now reachable here, and the widening is why.* `self/render.spec.ts`
3852
+ asserts `expect(md).toContain(JSON.stringify(kinds))` over the same
3853
+ `payloadKinds` it feeds to the renderer — the expectation is a function of the
3854
+ value under test, verbatim row 3, and it could not have been written before
3855
+ the widening because the param could not be an object. The mechanism is
3856
+ sharper than a frequency count and is the strongest thing this entry has
3857
+ gained: **the prescribed remedy costs in proportion to the value's
3858
+ complexity.** Same repo, adjacent files, same value shape — `ATX-7` pins two
3859
+ numbers with literals and goes red; `ATX-63` would have had to hand-write a
3860
+ rendered JSON block, so it echoed. Depth raises the defect rate and the cost
3861
+ of avoiding it along one axis.
3862
+ *And its cost here is nil, which is the honest other half.* Both composite
3863
+ params are fixtures for the renderer and the validator; the numbers stand for
3864
+ nothing, so the green is arguably correct. Blocker **(1)** is therefore only
3865
+ half retired — the shape is reproduced in a second, mature repo, and the count
3866
+ of *harmful* instances is still zero. Blocker **(2)** is reinforced rather than
3867
+ weakened: all four green mutations were on params `src/` never reads, so the
3868
+ reporter's "read from both a spec and a non-spec file" detector would have
3869
+ flagged none of them. Blocker **(3)** is untouched.
3870
+ *The frequency is no longer unmeasured, and it points away from building
3871
+ anything.* Blocker **(1)** rested on 8 commits of one greenfield repo. Parsed
3872
+ at every one of the **38** revisions that have touched this registry — all 38
3873
+ readable by the current static reader, so nothing was skipped — the history is
3874
+ **57 param keys added, 0 removed, and exactly 1 value changed**: `ATX-16.staticCommands`
3875
+ gaining `status`. That single edit is a roster *widening*, the safe direction,
3876
+ and it is on the one list that was already pinned, so it could not have been
3877
+ made without updating the pin. Two independent codebases, one greenfield and
3878
+ one at 64 requirements, and the event this whole entry is about has happened
3879
+ zero times in either. What that argues is not that the hole is unreal — it is
3880
+ reachable, demonstrated above — but that it is **rare and silent**, which is
3881
+ the profile `divergent-param` was rejected on rather than the profile that
3882
+ justifies an engine rule.
3883
+ *The same number says the split-out fix below has also never been triggered.*
3884
+ 0 removals ever means no domain has ever been shortened here. Those pins close
3885
+ a live *exposure*, not a live *loss*, and the case for them is that they cost
3886
+ one line each against a failure with no symptom — not that anything was
3887
+ currently broken.
3888
+ *Blocker **(3)** assumed mutation testing was the only separator. A cheaper
3889
+ candidate existed, has now been scored, and does not survive it.* The idea was
3890
+ a **syntactic** discriminator over one test body — flag an assertion whose
3891
+ expected value derives from a param that also reached the arrange/act part —
3892
+ which is exactly the difference between the two cases the widening produced:
3893
+ `renderMarkdown({params: {payloadKinds: kinds}})` … `toContain(JSON.stringify(kinds))`
3894
+ against `expect(kinds.json.maxBytes).toBe(1_048_576)`. Scored the way
3895
+ `divergent-param` should have been and was not: implemented in
3896
+ `tests/intent-rule-candidates.spec.ts`, run over the specs that attest this
3897
+ repo, and then **every param it fires on mutated with the whole gate run —
3898
+ fifteen of them, one gate run each. Four went green; eleven went red.**
3899
+ *Then the detector was found to be wrong, which is the more useful half.* Its
3900
+ first implementation counted an identifier in any position as a read of a
3901
+ local with that name, so `i.code` in `issues.map((i) => i.code)` looked like a
3902
+ use of a param named `code` — and `code` is this repo's commonest param name.
3903
+ Corrected to count reference positions only, it flags **8, not 15**, and the
3904
+ seven it drops are exactly seven the mutations had already shown to be pinned
3905
+ by `src/`. The honest score is therefore **4 of 8, not 4 of 15** — precision
3906
+ 50%, not 27%. The conclusion does not move, because it never rested on the
3907
+ precision figure; what moves is how much of the original number was the
3908
+ candidate failing and how much was the measurement being wrong, and that is
3909
+ worth writing down rather than quietly restating.
3910
+ The remaining false positives are not near-misses. They are independent terms
3911
+ the detector cannot see: **the runtime is the term** — `ATX-14`'s
3912
+ `inheritedKey` is `'toString'`, and a mutated spelling is not a prototype key
3913
+ at all, so the behaviour under test changes; **`src/` is the term** —
3914
+ `ATX-51`'s `code` is a second copy of a string the engine emits as a literal;
3915
+ and **arithmetic is the term** — `threshold + 3` builds a derived expectation
3916
+ that mutation breaks.
3917
+ And the four true positives fail the second test, which is the one that
3918
+ decides it: **none is harmful.** `ATX-10` and `ATX-3` assert that a value was
3919
+ substituted or applied, where the identity of the number is not the
3920
+ obligation; `ATX-51`'s is a path prefix and `ATX-63`'s a rendering fixture.
3921
+ Telling those from a mine-density band that must not move is a judgement about
3922
+ whether the value *means* anything — the ground truth §0 refuses to compute
3923
+ for someone else's registry. **50% precision on the shape, 0% on the harm.**
3924
+ Two limits on that number, stated because a score with an unstated method is
3925
+ what this file exists to prevent. The mutation has **one direction per kind**
3926
+ — numbers +1, lists drop-last, strings suffixed — and direction can decide the
3927
+ verdict: `ATX-40.languages` is red at 2→3 and was green at 2→1 before it was
3928
+ pinned, so the true-positive count is a floor. And **recall is unmeasured**:
3929
+ the eight params mutated earlier that this detector does not flag all went
3930
+ red, which is eight correct negatives and not a recall figure.
3931
+ The detector and its verdict table are kept in the measurement suite, with the
3932
+ reach recomputed on every run and the table asserted to cover exactly what
3933
+ fires — because the reach figure quoted for `compound-requirement` went stale
3934
+ in this very file for want of that.
3935
+ *A scoping limit on all of the above.* This repo cannot settle blocker **(2)**.
3936
+ `src/` reads no param at all — every `reqs[…].params` in it is a comment or a
3937
+ doc string — so params here are test vocabulary, while in the reporter's repo
3938
+ they are application constants the app itself consumes. The reporter's
3939
+ detector keys on exactly that difference, so it is inert here and this corpus
3940
+ is evidence neither for nor against it.
3941
+ *What the re-measurement did find is a different defect, and it has been split
3942
+ out below* — the entry is kept whole here because the two were measured in one
3943
+ pass and separating them is the finding.
3442
3944
 
3443
3945
  - **Whether `check` and `verify` should refuse a half-loaded registry the way
3444
3946
  `cover` and `render` do.** Those two return early on `hasError(loadIssues)`,
@@ -3560,41 +4062,6 @@ have to gather again.
3560
4062
  because a project whose requirement count only rises should have decided in
3561
4063
  advance what would make one leave.
3562
4064
 
3563
- - **The `cmd.exe` quoting in `tests/consumer.spec.ts` guards one character of
3564
- several.** `shellArg` asserts the argument holds no `"` and then quotes only
3565
- when it holds whitespace, so an argument with none goes to the shell bare —
3566
- and `&`, `|`, `^`, `<`, `>`, `(` are metacharacters there that the assertion
3567
- does not name. The fix is one character class, which is why the entry is not
3568
- about the fix.
3569
- *What blocks the decision is that no input reaches it.* Both arguments are
3570
- either a literal flag or a path from `mkdtemp`, `NEEDS_SHELL` is win32 only
3571
- and CI is Linux, and the one path an attacker could plausibly influence — the
3572
- checkout directory on a fork MR pipeline — comes from a GitLab project path,
3573
- whose character set excludes every metacharacter above. So this is an
3574
- observation with no attack path, and the honest question is not whether to
3575
- widen the class but whether the comment above it should stop claiming more
3576
- than the assertion covers: it says "the assertion keeps that true" about
3577
- arguments generally, where what it checks is one character. Widening the class
3578
- and leaving the prose is the change that would look like a fix and settle
3579
- nothing.
3580
-
3581
- - **A loader that fails to start leaves its scratch directory behind.**
3582
- `createLoader` calls `mkdtempSync` and writes the stub *before* `createServer`,
3583
- and the only `rm` of that directory is inside the `close` of the object
3584
- `createServer` returns — so a throw from it leaks one `attest-loader-*` per
3585
- invocation, which on a CI runner is one per build. That is the exact failure
3586
- shape the comment on `close` records as already fixed, surviving on the other
3587
- path.
3588
- *What blocks it is that nothing has been made to throw there.* With
3589
- `configFile: false` Vite still reads the `package.json` at the root to decide
3590
- `type`, so a malformed one in the checked project is the obvious candidate and
3591
- it is the project's to write — but it was not tried, and an entry claiming a
3592
- trigger it has not seen is the thing this section exists to not accumulate.
3593
- The repair is three lines (a `try` around the two calls that `rm`s and
3594
- rethrows) and is not what the decision turns on; measuring whether the throw
3595
- is reachable at all is, because if it is not, this is a leak with no input
3596
- that produces it.
3597
-
3598
4065
  ## Considered and rejected
3599
4066
 
3600
4067
  Decisions **not** to build something, kept where they can be found before the
@@ -3919,7 +4386,8 @@ requirement, and human review at propose is still the whole answer. What is
3919
4386
  rejected is grouping as a way to assist it, and this reopens only on a
3920
4387
  contradiction that grouping would have caught.
3921
4388
 
3922
- [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.7.1...main
4389
+ [0.7.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.3
4390
+ [0.7.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.2
3923
4391
  [0.7.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.1
3924
4392
  [0.7.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.0
3925
4393
  [0.6.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.6.0