@am_shork/attest 0.9.4 → 0.10.0

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,527 @@ 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.10.0] - 2026-08-22
17
+
18
+ ### Changed
19
+
20
+ - **The same mutation pass over the verdict and its evidence: 24 mutations of
21
+ `gate.ts` and `red-record.ts`, 21 dead, and two of the three survivors closed.**
22
+ Every step of `evaluateGate` dies when removed, including scoping
23
+ `open-unresolved` back to the added ids — the cut that separates it from
24
+ `never-red`. So do both directions of `keepOutcome`: last-write-wins, where the
25
+ green run at the end erases the red it exists to remember, and first-write-wins,
26
+ where a recorded pass can never be corrected. The 46 lines at the top of
27
+ `red-record.ts` arguing for that asymmetry turn out to be held from both sides.
28
+
29
+ Two gaps were real, and both are cases where an existing test passes with the
30
+ guard removed:
31
+
32
+ - **A record whose version is not 2 was discarded by the *schema*, not by the
33
+ version check.** The case that covers this uses a version-1 record keyed
34
+ `reqId -> name`, which fails the shape as well — so the verdict is identical
35
+ either way, exactly as the guard's own comment predicts. Isolating the
36
+ version needs a record the schema would take: a valid version-2 shape under
37
+ version 3. Without that, "discarded because the format moved" and "discarded
38
+ because the file is corrupt" were the same observation, which the comment
39
+ calls out as how a format change gets made without anyone deciding to.
40
+ - **Nothing noticed if the record invented a `pass` for a scenario that never
41
+ executed.** `ATX-30` says the file records *how each scenario ended on its
42
+ first run*; a skipped scenario has no ending, so a `pass` there is an
43
+ observation no run produced — in a file that is evidence a reviewer reads and
44
+ that cannot be recomputed once the change is green. The gate still blocks
45
+ either way, so no verdict moves; what moves is the advice, since `never-red`
46
+ picks between "rewrite the assertion" and "run the gate before implementing"
47
+ on exactly this distinction.
48
+
49
+ The third survivor is left alone: `open-unresolved` emitting one error per
50
+ requirement rather than per question breaks nothing anybody promised. `ATX-81`
51
+ says the static commands report **each one**, and is pinned per question;
52
+ `ATX-82` says the gate **blocks**, and says nothing about granularity. Two paths
53
+ of one shape, one held and one not, and the difference is the two sentences.
54
+
55
+ *The harness failed twice more, and the second failure is the one worth
56
+ keeping.* Mutating a **writer** rather than a reader damages files outside the
57
+ source it was applied to: `archive` writes `first-run.json` into the e2e
58
+ fixtures, so a mutant that flipped `keepOutcome` left a committed fixture
59
+ saying `pass` where it had said `fail`, and every mutant after it read as
60
+ killed for that reason rather than its own. Restoring tracked fixtures fixed
61
+ the leak that had been found; a blanket check that the working tree is clean
62
+ after every mutant then immediately found the one that had not been — an
63
+ *untracked* record a mutant created, which `git checkout` cannot remove. The
64
+ broad guard earned its place over the narrow fix by catching what the narrow
65
+ fix was not written for.
66
+
67
+ - **Two guards in the registry reader had nothing holding them, found by
68
+ mutating every decision in `static-registry.ts` and watching what stayed
69
+ green.** Twenty mutations of the module's semantically load-bearing branches;
70
+ fourteen died to `tests/static-registry.spec.ts` and `tests/splice.spec.ts`,
71
+ and six survived the whole suite *and* `self/`. Two of the six were worth
72
+ closing, and both make the two readers answer **differently** rather than
73
+ merely reporting differently:
74
+
75
+ - `let reqs = defineRequirements({…}); reqs = …; export default reqs` — with
76
+ the `const` check removed the static reader answers the first registry while
77
+ the evaluator answers the second. A wrong answer rather than a refusal, which
78
+ is worse than any code it could have reported.
79
+ - `export = defineRequirements({…})` — with the `isExportEquals` check removed
80
+ the static reader accepts it, while the evaluator cannot run it at all.
81
+
82
+ Both now have a case in *"a registry that is not a literal"*, and both mutants
83
+ die to it. The other four survivors are left alone deliberately: one guard is
84
+ defence-in-depth behind a refusal that already happened upstream (`__proto__`
85
+ as an entry id, which `readRegistrySource` rejects before a merge can reach the
86
+ layout), one is a deliberate looseness whose comment says why, and two forbid
87
+ shapes that cannot compile in the first place. A test contrived to reach them
88
+ would assert that something upstream is still doing its job.
89
+
90
+ *The harness had to be fixed before any of it could be believed, and that is
91
+ the part worth carrying forward.* Its first run reported all six survivors as
92
+ killed. The cause was the mutation runner inheriting a working directory with
93
+ no test files in it, so `vitest` exited non-zero with "No test files found" and
94
+ every mutant read as covered — a green instrument reporting on nothing, which
95
+ is the exact failure `ATX-15`'s sweep guards against in as many words: *"a walk
96
+ that found no files satisfies 'no site orders by locale' while proving the
97
+ opposite of what it claims."* The runner now pins its working directory and
98
+ refuses to report at all unless the unmutated tree passes first.
99
+
100
+ - **The differential corpus now drives the two registry *writers*, not only the
101
+ two readers.** `tests/static-registry.spec.ts` exists because reading a
102
+ registry from the AST instead of evaluating it needs the two readers held
103
+ against each other — but the literal they walk has a third and a fourth
104
+ consumer, `registryInsertionPoint` and `registryEntryLayouts`, and the table
105
+ was never pointed at them.
106
+
107
+ *Every registry-source defect in this release had its trigger already sitting
108
+ in that table.* `as-const`, `satisfies` and `parenthesised` are the three
109
+ spellings whose literal the writers could not locate; `key-forms` carries the
110
+ duplicate key whose span they located at the wrong offset, under a comment
111
+ that has said *"a duplicate key: last wins"* since the row was written. The
112
+ corpus knew about all four inputs. Nothing asked the writers about them.
113
+ Reverting either fix now turns the table red — measured: the unwrap on all
114
+ three wrapper rows, the layout guard on `key-forms`.
115
+
116
+ **What it asserts is not that every case is writable.** A source the readers
117
+ accept may be one the writers must refuse, and `key-forms` now is. The
118
+ property is that a writer's answer is always *located* or *refused for a
119
+ stated reason*, never the third thing — located, wrongly — and which cases are
120
+ refused is a table someone has to edit rather than a branch that can drift.
121
+
122
+ *Widened while pointing it: the `satisfies` row did not cover the spelling
123
+ that broke.* It applied the keyword to a param value, where the value reader
124
+ sees it, and not to the registry argument, where the walk that finds the
125
+ literal does — so it stayed green through a defect named after it. It now
126
+ carries both positions, as the `as-const` row beside it always did.
127
+
128
+ - **`readArchivedChanges`' fan-out is bounded — the fourth one, and the first
129
+ outside `locate.ts`.** `archive.ts` read every archived first-run record
130
+ through a `Promise.all(names.map(…))`, so a project with N merged changes held
131
+ N reads and N parsed `RedRecord`s at once. It is the same shape as the other
132
+ three against an input nobody here chooses — an `archive/` grows by one folder
133
+ per change merged and is never pruned, so it is the longest-lived of the four
134
+ lists a project's shape decides the length of. Measured in
135
+ `tests/locate-fanout.spec.ts`, which now counts a fourth pool: **64 before, 32
136
+ after**, on 64 archived changes. Unbounded, the figure is the count of the
137
+ folders; bounded, it is the constant.
138
+
139
+ *Why it hid is that the pool's own doc comment names the rule and this caller
140
+ is not in the file that has it.* `forEachBounded` says "anything here that
141
+ reads a list the project's tree decides the length of belongs in this pool,
142
+ whatever the function around it is called" — and `readArchivedChanges` is
143
+ exactly that, one module over. The nearest comment to it reasons carefully
144
+ about concurrency and still says nothing about a bound: `inspectRedEvidence`
145
+ explains why it runs the archive read *after* `inspectProject` rather than
146
+ beside it — "overlapping them buys nothing worth a second fan-out" — which is
147
+ a correct answer about sequencing two reads and not a claim about what happens
148
+ inside either. That is the third time in this catalogue a considered comment
149
+ about concurrency has stood where a bound was missing. `forEachBounded` is now
150
+ exported for it, with a fourth constant on the reason `WALK_CONCURRENCY`
151
+ already gives.
152
+
153
+ **The failure this invites is memory, not descriptors — and that corrects the
154
+ three entries above.** Each of those closed by calling descriptor exhaustion
155
+ the invited failure and leaving it unmeasured. Measured now, on Linux: it is
156
+ not reachable through this shape at all. `fs.promises.readFile` does
157
+ open-read-close inside one libuv threadpool work item, so concurrent
158
+ *descriptors* are bounded by the threadpool and never by the fan-out — 3,000
159
+ unbounded in-flight reads peaked at **53** entries in `/proc/self/fd`, and the
160
+ read completed cleanly under `ulimit -n 256`, and again under `ulimit -n 128`
161
+ with `UV_THREADPOOL_SIZE=1024`. What does track the input is heap: on 2,000
162
+ changes of a 45 KB record, peak `heapUsed` was **352 MB unbounded against 177
163
+ MB bounded**, over interleaved runs — and the unbounded figure wandered from
164
+ 329 to 580 MB across rounds while the bounded one held 177–181. The stable
165
+ number is the point; the retained result is the floor, and what the bound
166
+ removes is the transient pile on top of it. This is the portable-arithmetic
167
+ standard the three earlier halves were accepted on, so it needs no incident
168
+ report — but the *reason* is memory, and future sweeps should stop reaching
169
+ for `ulimit`, which does not govern this on either platform: Windows uses
170
+ Win32 handles, and on Linux the threadpool gets there first.
171
+
172
+ Reached only from `inspectRedEvidence` and so from `@am_shork/attest/inspect`,
173
+ the published subpath — no CLI command reads the archive, which is why the
174
+ static-command roster is untouched by this. No behaviour change and no
175
+ `ATX-n`, on the precedent of the other three: the changes, their order, their
176
+ paths and the merged record are identical, asserted beside the peak. The
177
+ result is built by preallocate-and-index rather than by arrival, which is what
178
+ keeps the sorted folder order true through a pool that completes out of order.
179
+
180
+ - **The publish job runs on protected tags only, and it says so in the file
181
+ rather than in a settings page.** `rules` was `if: $CI_COMMIT_TAG`; it is now
182
+ `if: $CI_COMMIT_TAG && $CI_COMMIT_REF_PROTECTED == "true"`.
183
+
184
+ *The reason it needed to be in the file is that the comment beside it claimed
185
+ a defence that does not exist.* It said the reason to protect release tags had
186
+ changed — from exposing a masked variable to `id_tokens` being "issued to
187
+ protected refs only". That is not how GitLab issues them. Every job gets an ID
188
+ token, and the protection state travels *inside* it as the `ref_protected`
189
+ claim; a design that reports protection as a payload field is one where an
190
+ unprotected ref is handed a token too. Nothing on npm's end narrows it either:
191
+ a GitLab trusted publisher is namespace + project + top-level CI file path +
192
+ optional environment, with no ref condition available to configure.
193
+
194
+ **So the entire ref authorisation was the `rules` line — evaluated from the
195
+ `.gitlab-ci.yml` at the pushed tag, which is a file the pusher had just
196
+ written.** "Can push a tag" was therefore "can publish `@am_shork/attest` with
197
+ a valid provenance attestation", and the `NPM_ID_TOKEN` guard added in the
198
+ entry below cannot narrow it: that guard asks whether a token exists, never
199
+ who caused one to be minted. Protected tags in *Settings -> Repository* were
200
+ the only thing standing there, described by the comment as a belt-and-braces
201
+ extra.
202
+
203
+ This is the shape this repository keeps finding — a defence that reads as
204
+ present and is not, alongside `bin/attest.js`'s static import, the `hasWarned`
205
+ spread and `hmr: false` — arriving this time in the sentence explaining a
206
+ defence rather than in the code implementing one. Keep the protected-tag rule
207
+ in Settings as well: only that side stops the tag being created, and this side
208
+ only declines to publish once it has been. *Found by review, not by a run —
209
+ no pipeline was executed to confirm it, and the two vendor behaviours above
210
+ are read off GitLab's ID-token documentation and npm's trusted-publisher
211
+ documentation rather than measured here.*
212
+
213
+ - **The publish job has no stored credential left: `NPM_TOKEN` and the
214
+ `_authToken` line are gone, and the guard is on `NPM_ID_TOKEN` instead.**
215
+ Completes what `[0.9.4]` set up. The deletion test pointed here from the
216
+ start — what it removes is the only long-lived credential this repository had.
217
+
218
+ *It went in on a measurement, and the measurement is the part worth keeping.*
219
+ The v0.9.4 release published through the trusted publisher, not the token:
220
+ `_npmUser` on that version is
221
+ `{"name":"GitLab CI/CD","trustedPublisher":{"id":"gitlab","oidcConfigId":"oidc:…"}}`,
222
+ by npm 11.19.0. The entry that proposed this recorded that **no account existed
223
+ anywhere of publishing from GitLab under OIDC** — every report was GitHub
224
+ Actions — and stated the inference at the strength the evidence carried. That
225
+ release is the first record. The token was present and unused: `oidc()`
226
+ overwrites the auth key on success, so the `.npmrc` line never applied.
227
+
228
+ **The job's own log could not have told us, and that is why the fallback had
229
+ to go.** Every path in npm's `oidc.js` logs at `verbose` or `silly` while npm's
230
+ default level is `notice`, so a successful exchange and a failed one produce
231
+ byte-identical job output — the v0.9.4 log contains no OIDC line of either
232
+ kind. Combine that with a fallback written never to throw and the failure mode
233
+ is exact: an exchange that stops working keeps publishing, on the credential
234
+ the exchange exists to retire, silently, with the difference visible only in
235
+ registry metadata nobody reads. **The property that made the rollout cheap is
236
+ the wrong one to keep**, and this is the first time in this file that a
237
+ mechanism has been removed for succeeding rather than for failing.
238
+
239
+ So the check moves in front of the irreversible step, which is the rule the
240
+ removed post-publish attestation check failed and this file already argued:
241
+ verification belongs where it can act on the answer. A missing `NPM_ID_TOKEN`
242
+ now fails before anything is published; a failed exchange fails the publish
243
+ outright, loudly and recoverably, because nothing was written.
244
+
245
+ *What is not in this repository, and is not done by this entry.* One act on
246
+ npmjs.com: *Require two-factor authentication and disallow tokens* on the
247
+ package, which is what stops a leaked token publishing it and which npm states
248
+ does not affect trusted publishers. Not a commit and not verifiable from here.
249
+
250
+ *The `NPM_TOKEN` CI variable stays, deliberately, and the reasoning is worth
251
+ recording because the first version of this entry had it wrong.* Deleting it
252
+ was filed beside the npm setting as though the two were equal acts. They are
253
+ not: the setting removes the **capability** — a token can no longer publish this
254
+ package — while deleting the variable removes **one copy** of a credential that
255
+ goes on existing in the npm account either way. Revoking it there is the act
256
+ that destroys it; removing it from GitLab is housekeeping. The variable is
257
+ masked, protected, exposed only to protected refs, and now read by no job, so
258
+ what it costs to keep is nothing. What the setting does not cover is measured
259
+ rather than assumed: it is per-package, and `@am_shork/attest` is the only
260
+ package in the scope, so today it covers the account's entire published
261
+ surface exactly. The gap it leaves is future-conditional — a second package
262
+ would start without the setting — and that is an obligation attached to
263
+ publishing a new package, not a reason to move a variable. *And the next tagged release is the test of this change*, the same way
264
+ v0.9.4 was the test of the last one — except that this time a regression fails
265
+ the release instead of quietly publishing. That is the intended trade.
266
+
267
+ ### Added
268
+
269
+ - **`ATX-83` states the three states of that field.** What was missing was never
270
+ the field; it was a sentence the suite could falsify about *when* the field is
271
+ there. A requirement saying only "the report carries the paths the merge wrote"
272
+ is satisfied completely by an implementation that always emits `[]`, and that
273
+ implementation is wrong in exactly the case the field is reached for — a re-run
274
+ after a completed merge.
275
+
276
+ *Written straight into the registry, not through `changes/`.* That is how
277
+ existing behaviour is described here, and it is the reading `ATX-80`'s
278
+ `unarchived` state exists for: `never-red` asks for a recorded failing run only
279
+ of the ids a change is currently ADDing, so a requirement catching up to
280
+ behaviour that already shipped carries no such obligation and gains nothing
281
+ from a workflow whose point is to make a scenario fail before the code exists.
282
+ The scenario lives beside the other `archiveReport` ones in
283
+ `self/json.spec.ts`, where a reader looking at that function finds all of them.
284
+ Nothing records that it discriminates, which is what `unarchived` costs and is
285
+ worth saying rather than leaving implicit — so it was checked by hand instead:
286
+ with the one line that spreads the field into the report removed, the scenario
287
+ fails, and `verify self` reports `tests-red`.
288
+
289
+ The measurement in `tests/intent-rule-candidates.spec.ts` moved by exactly one
290
+ requirement — corpus 93 → 94, universal quantifiers 40 → 41 — and no verdict
291
+ in it flipped. It also produced the `flag:` divergence key's **fourth**
292
+ re-keying in four releases, every one triggered by an addition that agrees
293
+ with a member already there, and the `field:` key's first *extension* rather
294
+ than re-keying; both are noted where the rule is judged, because which of the
295
+ two an addition causes turns on vocabulary rather than on how related the
296
+ requirements are.
297
+
298
+ - **`archive --apply --json` reports the paths the merge wrote.** The human
299
+ rendering has listed them since `--apply` shipped, on the grounds that this is
300
+ the command that edits files the user cannot regenerate and the reader needs to
301
+ know which ones to look at before committing. The `--json` reader — the one
302
+ most likely to be automating around the merge — got the change name and nothing
303
+ else. `written[]` closes that, and both renderings are now handed the same
304
+ bundle from one place in the shell rather than each being told separately,
305
+ which is the split that let the verdict disagree with itself in `[0.7.0]`.
306
+
307
+ **Absent without `--apply`, `[]` with it.** The two are different facts — no
308
+ merge was attempted, against a merge that ran and had nothing left to write,
309
+ which is the ordinary state of a re-run after a completed one — and a field
310
+ that spelled both `[]` could not say which. Additive, so no `SCHEMA_VERSION`
311
+ bump: a consumer that has never heard of the field reads the report it always
312
+ did. ATX-74's sweep covers it like every other string a report quotes out of
313
+ the checked project.
314
+
315
+ ### Fixed
316
+
317
+ - **`first-run.json` committed different bytes for the same evidence, depending
318
+ on how many runs it took to gather.** `mergeRedRecord` rebuilds the record so
319
+ key order is a function of the data rather than of insertion — and did that at
320
+ two of its three levels. The rebuild sorts the requirement ids the record
321
+ already held; the loop after it *appends* the ids this run is the first to
322
+ observe, in plan order; and the pass at the bottom re-sorted the file and
323
+ scenario levels while walking the ids in whatever order they were in, because
324
+ reassigning `record[id]` does not move a key.
325
+
326
+ So a change that grew its record over several stage-1 runs committed
327
+ `{"Z-9": …, "A-1": …}` where the same observations reached in one run committed
328
+ `{"A-1": …, "Z-9": …}`. Nothing about the outcomes differs; the file does. That
329
+ matters twice over for this particular file: it is evidence a reviewer reads in
330
+ a diff, and the gate has to reach the same verdict on a CI checkout as on the
331
+ author's machine. It also went unnoticed once written — `changed` is decided by
332
+ the outcomes alone, so an unchanged record is never rewritten and a
333
+ wrongly-ordered one stays that way.
334
+
335
+ *ATX-15 already said this and could not catch it.* The requirement quantifies
336
+ over "every ordering Attest commits to disk", and the sweep attesting it is
337
+ deliberately narrower than its own statement: it checks *how* a site orders —
338
+ every `.sort` in `src/` through `byCodeUnit` — and says so, *"a `red-record.ts`
339
+ that stopped sorting at all is still green here, and the scenarios above are
340
+ what speak to the result"*. There was no such scenario for this file. There is
341
+ now, in `self/red-record.spec.ts`: it reaches the same evidence by both routes
342
+ and compares the serialised bytes. `archive.ts`'s `sortRecord` had all three
343
+ levels right and is what this now matches.
344
+
345
+ - **`--apply` refused a registry whose literal is written with `as const`,
346
+ `satisfies`, or a bare pair of parentheses.** The reader takes all three —
347
+ `unwrap` strips the type-level wrappers before `literalValue` reads the object,
348
+ because none of them changes the value — so the gate read such a file, proved
349
+ the change green, and handed it to a merge that asked
350
+ `ts.isObjectLiteralExpression` of the wrapper and got `false`. Both writers
351
+ refused: the whole change stopped with `internal-error`, whose own message says
352
+ the file *"read as a registry for the gate but not for the merge"* and whose
353
+ comment calls the condition unreachable through the command. It was reachable
354
+ by writing `defineRequirements(({ … }))`.
355
+
356
+ *The fix is where the two answers diverged, not where the symptom showed.*
357
+ `authoringCall` is the single function both sides use to find the registry
358
+ literal — the module's opening note says a second walker would be a second
359
+ answer to "is this a literal" — and it handed back the argument node as
360
+ written, leaving each caller to decide whether to unwrap. The reader did and
361
+ the writers did not, so the disagreement the shared function exists to prevent
362
+ was inside it. It now unwraps once, and `literalValue`'s own call becomes the
363
+ no-op it should always have been.
364
+
365
+ The parenthesis is the reachable spelling and the reason this is a fix rather
366
+ than a nicety: `as const` and `satisfies` are opted into, while nothing about
367
+ `(({ … }))` looks like a type annotation anyone chose. No `SCHEMA_VERSION`
368
+ bump and no new code — a change that used to fail now succeeds, which is the
369
+ benign direction for an exit code, and the History row is what tells a reader
370
+ on an older build which half of the page applies to them.
371
+
372
+ - **A registry key written twice made `archive --apply` write bytes outside the
373
+ value it was replacing — and, measured, outside the registry literal
374
+ altogether.** `registryEntryLayouts` keys each value's source span by name in
375
+ a `Map`, and `spliceModifications` walks that map to get its edits in
376
+ ascending offset order, which is what lets the pass at the bottom be a
377
+ `reverse()` rather than a sort. `Map.set` on a key it already holds keeps the
378
+ **first** insertion's position and takes the **last** call's value — so an
379
+ entry writing `params: { limit: 1, other: 'x', limit: 1 }` describes `limit`
380
+ at position one carrying the span of the occurrence *after* `other`, and the
381
+ offsets stop ascending. The earlier edit then moves the bytes under the later
382
+ one. On a registry the gate had just proved green, changing both values landed
383
+ the second one past the file's closing `});`.
384
+
385
+ *Nothing upstream refuses such a file, which is why this reached the one
386
+ command that writes.* A duplicate key is a TypeScript **semantic** error, and
387
+ `parseSource` asks only the syntactic question (`[0.9.0]`, ATX-69) — so both
388
+ registry readers take the file and hand back the last occurrence, `check` is
389
+ silent, and the gate passes. The reachable route is an ordinary copy-paste in
390
+ a hand-written registry, in a project that does not typecheck its
391
+ `*.reqs.ts`.
392
+
393
+ **Refused rather than described, at all three levels.** An entry whose keys
394
+ repeat is left out of the layout with the repeated key's path recorded beside
395
+ it, and `spliceModifications` turns that into an `apply-unsupported-delta`
396
+ refusal — whole, with nothing written — before a single edit is pushed. The
397
+ id level is not the mild one and is refused for the same reason rather than a
398
+ weaker one: two entries under a single id put a *later* entry's spans at an
399
+ *earlier* entry's position, so the disorder is between entries rather than
400
+ inside one. The guard stays exactly as wide as the danger — an unrelated id in
401
+ the same file is still written — because a merge that stopped over something
402
+ it was not asked to touch is the thing `--apply` refuses to do everywhere
403
+ else.
404
+
405
+ *The sibling defect is already in this file and did not generalise on its
406
+ own.* `[0.8.0]` fixed an integer-like `params` key for the identical
407
+ invariant — JavaScript hoists it to the front of every object, so a plain
408
+ object could not carry the file's order — and the repair was to move all three
409
+ levels to `Map`s. That answered the container and left the *keys*: a `Map`
410
+ keeps insertion order for every key type, and the one thing it still cannot
411
+ represent is one key written twice. The test added then, `rewrites two params
412
+ when one of them has an integer-like key`, is the shape of the three added
413
+ here, down to needing two changed values of different lengths — one edit
414
+ cannot be out of order with itself.
415
+
416
+ `verifyWritten` did catch the result and stop the merge, so the failure was
417
+ never silent; the file had already been overwritten by then, which is the
418
+ "destructive on a file the user cannot regenerate" shape the `AGENTS.md` merge
419
+ tool was rejected for and the reason a post-hoc check is not where this
420
+ belongs.
421
+
422
+ **Breaking, and narrowly so — this is the entry that decides the version
423
+ number.** The common case is not: a repeated key usually made the merge write
424
+ outside the value and the re-read then failed it, so the run already exited 1
425
+ and only the `code` moves — `internal-error`, raised after the registry had
426
+ been overwritten, becomes `apply-unsupported-delta`, raised before anything is
427
+ written.
428
+
429
+ But the offsets only go wrong when an earlier edit *changes length*. A
430
+ modification that replaces a value with one the same size shifts nothing, so
431
+ on `[0.9.4]` such a merge completed and completed **correctly**. Measured on a
432
+ registry with a duplicated `statement` and a `budgetMb: 1` → `2` patch: 0.9.4
433
+ merges it and it reads back as `{"budgetMb":2}`; today it refuses. That is
434
+ previously-valid input now rejected, which is this file's own definition of
435
+ breaking, so it takes the **minor** rather than the patch.
436
+
437
+ The refusal is still right — the same file with a length-changing patch is the
438
+ corruption above, and which of the two a merge gets is not something the author
439
+ chose. What the narrowness costs is that an adopter whose registry happens to
440
+ carry a duplicate key sees a working command start refusing, so it belongs in a
441
+ minor with the reason stated rather than in a patch nobody reads.
442
+
443
+ No `SCHEMA_VERSION` bump: the envelope, the `Issue` interface and the roster in
444
+ `core/docs.ts` are all unchanged — the refusal reuses the code `--apply`
445
+ already raises for a delta it will not write.
446
+
447
+ **`ATX-76` is widened to say what it always meant**, through this
448
+ repository's own workflow — `self/archive/2026-08-21-widen-atx-76`, the second
449
+ change to go through it and the first carrying a MODIFIED. Its refusal clause
450
+ named one instance of a class: *"when a span it would overwrite carries a
451
+ comment"*. Refusing a comment inside a span is not a rule about comments, it is
452
+ the rule that a replacement may only ever write the value it was asked to
453
+ write, stated at the one place that value's boundary was known to be in doubt.
454
+ A repeated name puts it in doubt a second way, so the clause now reads
455
+ *"wherever the file leaves that span uncertain"* and lists both. What is
456
+ **not** stated is the refusal as a mechanism: refusing is how *this*
457
+ implementation keeps the first clause, and a rewrite could keep it by ordering
458
+ the edits instead — a requirement pinned to the remedy would forbid that repair
459
+ for no reason.
460
+
461
+ *The defect was never that the rule was absent.* The first clause — the unit of
462
+ the edit is the value — has said this since `[0.9.0]`. What was missing was a
463
+ sentence the suite could falsify in this direction: both existing scenarios
464
+ assert something about a **comment**, and neither can go red when the failure
465
+ is that the span located was the wrong one. `self/merge.spec.ts` now carries a
466
+ third, looping the three sites a name can repeat with the extent pinned beside
467
+ the loop. Measured with the fix reverted, at the `spliceModifications` seam:
468
+ all three wrote without refusing; with it, all three refuse.
469
+
470
+ ### Documentation
471
+
472
+ - **The CLI reference told readers the `--json` guarantee rests on
473
+ `JSON.stringify`, which is the reasoning `[0.8.0]` retired.** *Output is safe
474
+ to read* closed with "`--json` was never exposed: serialising escapes the same
475
+ characters" — true of C0 and of nothing else, so DEL and the C1 range were
476
+ never covered by it. The code has been right since `[0.8.0]`, where the
477
+ replacer in `renderJson` began stripping every string in the document as it
478
+ serialises one; the page kept the retired argument, in both languages, three
479
+ hundred lines above its own History row saying the opposite.
480
+
481
+ *The comment at the crash site in `cli/action.ts` said it too*, and that copy
482
+ is the one worth naming: it sat directly above `renderJson(errorReport(…))`
483
+ and read as a settled argument for why sanitising there was unnecessary. The
484
+ replacer it argues against is what makes that line safe. Nothing was
485
+ exploitable — this is a false rationale standing beside a working defence,
486
+ which is the configuration that gets a defence deleted by someone tidying up.
487
+
488
+ Found by an attack-surface review rather than by a gate, and no gate could
489
+ have found it: the suite checks that a `§n` a comment cites exists and that a
490
+ version a History table names is a heading here, never whether a sentence is
491
+ true. Three copies of one retired claim is what that gap looks like from
492
+ inside. No behaviour changed and no version moved, so there is no new History
493
+ row — the `[0.8.0]` row was correct all along and is what the prose now agrees
494
+ with.
495
+
496
+ - **The method page says which shape a composite param is best at, and the
497
+ sample is a fixture that runs.** `intent-quality.md` already carried both
498
+ hazards a list-valued param has — an expectation recomputed from the param it
499
+ is checking, and a loop whose extent nobody pinned — as the third and fourth
500
+ of its five findings, named and left there with no shape attached. A **decision table**, rules pairing conditions with the
501
+ outputs the requirement promises, is where both arrive at once and where the
502
+ single source pays most, so it is now named. The section states the property
503
+ the shape actually rests on, which is narrower than "use a table": the
504
+ implementation reads only the *condition* columns, which leaves the output
505
+ columns an independent term — and it stops being true the moment the system
506
+ under test is itself driven by the same rules.
507
+
508
+ *The sample is `fixtures/consumer/requirements/cart.reqs.ts` and
509
+ `fixtures/consumer/cart.spec.ts`*, added here and registered in
510
+ `PUBLISHED_SAMPLES`, because every `ts` block this page publishes was already
511
+ a file the packaging test installs from a tarball and runs, and half-real
512
+ samples teach a reader to check which — after which they stop. **Making it
513
+ real is what found the constraint the section now documents**: the statement
514
+ cannot interpolate the table. A `{placeholder}` resolves to a scalar or a list
515
+ of scalars, so `{rules}` is `non-scalar-interpolation`, and the statement has
516
+ to name the behaviour in prose while the rules stay a param the scenario reads.
517
+ A draft written only as prose had it wrong and nothing would have said so.
518
+
519
+ It also landed on an existing row rather than needing a new rule:
520
+ `intent-rule-candidates.spec.ts` classifies it `over: 'roster'`,
521
+ `owner: 'registry'`, which is the branch that owes a `pin` — a registry-owned
522
+ list is data an author can shorten, and every assertion inside the loop still
523
+ passes over the shorter one. The published scenario carries that pin
524
+ (`expect(rules).toHaveLength(6)`), so a reader who copies the block copies the
525
+ counter-pressure with it.
526
+
527
+ *It was written into the README first, and has moved.* The README is the
528
+ pitch, the five-minute start and navigation; a hundred lines on how to write a
529
+ param that pins something is the method page's job, beside the two findings it
530
+ repairs. The README keeps one paragraph pointing at it. **The move is also
531
+ what showed `PUBLISHED_SAMPLES` could not say where a sample is published** —
532
+ it read the README and only the README, which was right while every sample
533
+ lived there and an obstacle the moment one did not. Each entry now carries its
534
+ page, so moving a sample between pages is a roster edit rather than a silent
535
+ loss of the gate that keeps it runnable.
536
+
16
537
  ## [0.9.4] - 2026-08-19
17
538
 
18
539
  ### Added
@@ -5437,33 +5958,6 @@ path the repair makes reachable; it is about **everything the repair would make
5437
5958
  `check` agree with**, and an entry that names the right fix can still be
5438
5959
  implemented wrongly by copying the half nobody has audited.
5439
5960
 
5440
- - **Retiring `NPM_TOKEN`, which is now two settings on npmjs.com rather than
5441
- anything in this repository.** The mechanism landed under `[0.9.4]`: the
5442
- publish job requests an npm OIDC token and publishes through `npm`, which is
5443
- the only client that performs the exchange. What that did **not** do is remove
5444
- the credential, and the distinction is the point — enabling a trusted publisher
5445
- leaves an automation token just as able to publish. Two acts remain, neither of
5446
- them a commit:
5447
- **(1)** register the trusted publisher on the package — `Pseudorca`, `attest`,
5448
- `.gitlab-ci.yml` with the extension, the allowed actions; environment name is
5449
- optional and this project uses none, and shared runners are the only supported
5450
- kind, which the job already uses because it carries no `tags:`. Free and
5451
- reversible, and safe to do at any time: a failed exchange falls back to the
5452
- token rather than failing the publish (measured — see the `[0.9.4]`
5453
- entry). **(2)** set *Require two-factor authentication and disallow tokens* on
5454
- the package, which npm states does not affect trusted publishers. That is the
5455
- one that closes the loop the job's own comment opens, where an automation token
5456
- is used *because* a granular token fails against 2FA on writes: trusted
5457
- publishing does not bypass the prompt, it stops being a token.
5458
- *What is unmeasured, and it is not measurable from here.* Whether the exchange
5459
- actually succeeds against a gitlab.com project. Every documented ingredient
5460
- checks out and no plan requirement is stated anywhere, but there is no record
5461
- of anyone publishing from GitLab under OIDC — every account is GitHub Actions.
5462
- The first tagged release after the `[0.9.4]` change is the test, and the
5463
- fallback is what makes running it cheap: if the exchange fails, the release
5464
- still publishes and the job's verbose log says why. **Do not delete the token
5465
- before a release has been observed publishing without it.**
5466
-
5467
5961
  ## Under consideration
5468
5962
 
5469
5963
  Candidates with **no decision yet**, between the two sections either side of it
@@ -6245,6 +6739,45 @@ have to gather again.
6245
6739
  release costs its adopters is not known on the day it is cut**. There is no
6246
6740
  measurement to date of anyone adopting either export.
6247
6741
 
6742
+ - **When one requirement carries several scenarios, some of those sets are
6743
+ differential evidence and nothing in the model says which.** Prompted by an
6744
+ external write-up (`columns.chicken-house.net`, 2025-11-10, "AI-First Testing
6745
+ Workflow"), which ran one test case against a system's REST API and against its
6746
+ web UI and found the empty-basket check implemented in the UI and absent from
6747
+ the API. Neither run failed against its own expectation; the finding was that
6748
+ two runs of the *same* case disagreed.
6749
+
6750
+ Attest already permits the arrangement — a `reqId` takes any number of
6751
+ attesting scenarios, and `cover` asks only for ≥1. What it cannot see is the
6752
+ difference between three scenarios covering three edges of one requirement and
6753
+ three scenarios crossing three interfaces at the *same* edge. The second set
6754
+ can be asserted to agree; the first cannot, and there is nothing to compare.
6755
+ This repository runs the pattern on itself — `tests/static-registry.spec.ts`
6756
+ asserts the two registry readers agree — so the structure is known to work
6757
+ where the two adapters are known to be two adapters.
6758
+
6759
+ *What blocks it, in the order that decides it.* **The seam is unmeasured.** One
6760
+ adapter is a hypothetical seam and two is a real one; nothing has been walked
6761
+ to establish whether any adopted registry has a `reqId` whose scenarios
6762
+ genuinely cross interfaces rather than covering one surface repeatedly. Until
6763
+ that is counted this is a pattern observed in somebody else's codebase, and it
6764
+ is cheap to count. **The fact is not in the model.** Which interface a scenario
6765
+ crosses is recorded nowhere the engine reads, so supplying it means new
6766
+ registry or spec surface — a per-scenario axis — paid for by every adopter
6767
+ including all of the ones with a single interface, in the shape of a thing they
6768
+ must learn not to write. Whatever carried it would also have to be a literal in
6769
+ the AST, because `cover` executes nothing and that is not negotiable for it.
6770
+ **There is no verdict to emit.** The engine cannot perform the comparison;
6771
+ agreement between two runs is an assertion and assertions belong to the test.
6772
+ The most it could report is that a requirement has scenarios on more than one
6773
+ axis, which is information with no pass or fail attached — a new output
6774
+ category rather than a new check. **And the prose alternative is untried.**
6775
+ Apply the deletion test before the field: if the workflow document `init`
6776
+ writes (§9) simply says that two scenarios crossing one requirement at
6777
+ different interfaces should assert the same expectations, does the practice
6778
+ happen anyway? If it does, the engine gained nothing and charged everyone a
6779
+ field for it.
6780
+
6248
6781
  ## Considered and rejected
6249
6782
 
6250
6783
  Decisions **not** to build something, kept where they can be found before the
@@ -6255,6 +6788,41 @@ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
6255
6788
  sinking one version deeper each time a release was cut above it, and a rejection
6256
6789
  filed under a version reads as belonging to it.
6257
6790
 
6791
+ ### Rewriting the placeholder scan in `prose` to drop its tail slice
6792
+
6793
+ Priced on 2026-08-21, during a standing sweep for more of the ATX-59 shapes, and
6794
+ rejected by the measurement that was supposed to confirm it.
6795
+
6796
+ `prose` (`core/render.ts`) walks a statement character by character, and at every
6797
+ `{` it runs `/^\{\w+\}/.exec(text.slice(i))`. Read as source that is the §2
6798
+ quadratic-scan shape exactly: one fresh copy of the remaining string per `{`, so
6799
+ a statement of n braces copies n²/2 bytes — and a statement is prose a fork
6800
+ contributor writes and `render --check` reads under the static reader, which is
6801
+ the class that has actually shipped defects here. The fix was to be a sticky
6802
+ `/\{\w+\}/y` with `lastIndex = i`, which allocates nothing.
6803
+
6804
+ **It is not quadratic, because V8 does not copy.** `String.prototype.slice` on a
6805
+ string past a small threshold returns a *sliced string* — a view onto the parent
6806
+ with an offset, made in constant time — and an anchored regex over it reads only
6807
+ the few characters it needs, so the copy the source implies never happens.
6808
+ Measured through `renderMarkdown` on the built output, three adversarial shapes
6809
+ (all braces; alternating `{a`; a brace run followed by a word run), at 100k
6810
+ through 800k characters: every one of them scaled at a ratio of **1.77–1.89 per
6811
+ doubling**, i.e. linear, and 800,000 braces rendered in **106 ms** against
6812
+ ATX-59's 1,000 ms budget. A quadratic implementation at that size would be tens
6813
+ of seconds; this is the same order as the linear cases beside it.
6814
+
6815
+ So there is no defect and no change. What the sticky rewrite would buy is a
6816
+ constant factor nobody has shown a need for, against §5's rule that
6817
+ micro-optimisation with no named input is out of scope — the input *is* named
6818
+ here, and it is already absorbed. Filed rather than dropped because the site
6819
+ reads like the defect on inspection and will be proposed again by the next sweep
6820
+ that greps for `.slice(` near a loop: the answer is that V8's representation is
6821
+ load-bearing, the measurement is the evidence, and the thing to re-run is the
6822
+ ratio rather than the reasoning. Note the limit of that claim — it rests on a V8
6823
+ representation rather than on anything the language promises, which is safe for a
6824
+ Node CLI and would not transfer to a runtime that flattens eagerly.
6825
+
6258
6826
  ### Deferring the `typescript` import so `init` and `--version` do not pay it
6259
6827
 
6260
6828
  Priced on 2026-08-13, in the session that enabled the compile cache above, and
@@ -7149,6 +7717,7 @@ rather than a scorer, which ranks.
7149
7717
 
7150
7718
  ## Releases
7151
7719
 
7720
+ [0.10.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.10.0
7152
7721
  [0.9.4]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.4
7153
7722
  [0.9.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.3
7154
7723
  [0.9.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.2
package/README.md CHANGED
@@ -16,28 +16,6 @@ anyone when a tuning knob changes. A param may be any JSON value, so a
16
16
  composite constant — a vendor blacklist, a `kind -> payload` table — gets the
17
17
  same single source as a lone number, which is where drift is worst.
18
18
 
19
- What that does not buy is a warning when you change the value. `check` runs
20
- nothing, so editing a param leaves it at `✓ No issues` — nothing became unbound,
21
- nothing became uncovered. The value cannot *diverge* from the assertion, which is
22
- the stronger property; noticing that it *moved* is `verify`'s job, and only when
23
- a scenario asserts on the value it read from `params`.
24
-
25
- Reading the param is necessary and **not sufficient**, which is worth knowing
26
- before you rely on it: an assertion that recomputes its expectation from the same
27
- param the code under test just read has no independent term, so both sides move
28
- together and the test stays green through any edit. Pin the expectation to
29
- something that does not move with the param — a fixture, a literal in the test,
30
- or a second independently derived value.
31
-
32
- A composite param has a second failure of its own, and it runs the other way. When
33
- a test **loops over** a list it read from `params`, that list is the set of cases
34
- the run covers: drop a member and every assertion inside the loop still passes
35
- over what is left, so the suite quietly tests less with nothing to show for it.
36
- Pin the extent beside the loop — the members against a literal when their identity
37
- is the promise, the length when the size is. That literal is not the copy the
38
- single source exists to prevent: it is not what the system is measured against,
39
- it is what the intent claimed to cover.
40
-
41
19
  ## Prerequisites
42
20
 
43
21
  - Node ≥ 20.19
@@ -134,7 +112,14 @@ for exactly one machine-readable document on stdout. Flags, per-command
134
112
  behaviour and the JSON shape are in the
135
113
  [CLI reference](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/cli-reference.md).
136
114
 
137
- ### Working with an agent
115
+ Reading a param is necessary and **not sufficient** — an assertion that
116
+ recomputes its expectation from the same param the code just read has no
117
+ independent term, and a scenario that loops over a list param covers exactly
118
+ that list. Both have a known shape and a known repair, and both are in
119
+ [Judging your own intent layer](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/intent-quality.md#writing-a-param-that-pins-something),
120
+ along with the decision table — the shape a composite param is best at.
121
+
122
+ ## Working with an agent
138
123
 
139
124
  The engine above is only half the framework. The other half is the workflow —
140
125
  agree on intent, write the delta, drive the scenarios red, then implement to
@@ -159,7 +144,7 @@ found or pasted. See
159
144
  [`attest init`](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/cli-reference.md#attest-init)
160
145
  for the targets and what `init` deliberately does not write.
161
146
 
162
- ### When something goes wrong
147
+ ## When something goes wrong
163
148
 
164
149
  Every diagnostic carries a `code`, and every code has a section in
165
150
  **Troubleshooting** — which the diagnostic itself links to:
@@ -167,7 +152,7 @@ Every diagnostic carries a `code`, and every code has a section in
167
152
  ```
168
153
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
169
154
  Value is not a literal.
170
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.9.4/docs/en/troubleshooting.md#registry-not-static
155
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.10.0/docs/en/troubleshooting.md#registry-not-static
171
156
  ```
172
157
 
173
158
  The anchor **is** the code, so the link cannot point somewhere the section
@@ -73,10 +73,14 @@ export async function runAction(command, opts, action) {
73
73
  }
74
74
  catch (err) {
75
75
  if (opts.json) {
76
- // Unsanitised on purpose: `JSON.stringify` escapes every C0 character,
77
- // so these bytes cannot carry one out (see report.ts). That is a claim
78
- // about what this line writes and nothing wider what the project under
79
- // test writes is the divert's business, above.
76
+ // Sanitised, though not on this line: `renderJson` strips every string in
77
+ // the document as it serialises it (ATX-74), so the crash path is covered
78
+ // by going through the same function every other report goes through.
79
+ //
80
+ // Not by `JSON.stringify`, which is what this comment used to claim. That
81
+ // escapes C0 and nothing else — DEL and the C1 range survive it — so the
82
+ // replacer in `renderJson` is load-bearing rather than belt and braces.
83
+ // What the project under test writes is still the divert's business, above.
80
84
  console.log(renderJson(errorReport(VERSION, command, err)));
81
85
  }
82
86
  else {
package/dist/cli/index.js CHANGED
@@ -261,10 +261,15 @@ program
261
261
  const { issues: blocking, written } = await run(root(dir), change, {
262
262
  vitestConfig: vitestConfig(opts),
263
263
  });
264
+ // Built once and handed to both renderings, rather than each being told
265
+ // separately what the merge did. Two renderings of one result agreeing by
266
+ // transcription is what let the verdict disagree with itself (ATX-60),
267
+ // and the paths are the other thing this command has to say.
268
+ const merge = { applied: opts.apply === true, written };
264
269
  return {
265
- report: archiveReport(VERSION, change, blocking),
270
+ report: archiveReport(VERSION, change, blocking, merge),
266
271
  human: () => {
267
- console.log(formatArchiveVerdict(change, blocking, { applied: opts.apply === true, written }));
272
+ console.log(formatArchiveVerdict(change, blocking, merge));
268
273
  },
269
274
  };
270
275
  }));
@@ -33,6 +33,22 @@ export interface JsonReport {
33
33
  };
34
34
  /** `archive` and `status`: the change being gated or reported on. */
35
35
  change?: string;
36
+ /**
37
+ * `archive --apply` only: every path the merge touched, relative to the
38
+ * project root, in the order it touched them.
39
+ *
40
+ * Additive, so no SCHEMA_VERSION bump. **Absent** when `--apply` was not
41
+ * asked for — no merge was attempted, and there is nothing to report — while
42
+ * an **empty array** means the merge ran and had nothing left to write, which
43
+ * is the ordinary state of a re-run after a completed one. Folding the two
44
+ * into `[]` would make the field unable to say which happened.
45
+ *
46
+ * The human rendering has printed these since `--apply` shipped, on the
47
+ * grounds that this is the command that edits files the user cannot
48
+ * regenerate and they need to know which ones to read before committing. That
49
+ * argument does not stop at the reader with a terminal.
50
+ */
51
+ written?: string[];
36
52
  /** `status` only: one row per requirement the change adds. */
37
53
  progress?: StatusRow[];
38
54
  /** `status` only: roll-up of the rows by obligation state. */
@@ -83,7 +99,18 @@ export declare function renderReport(version: string, issues: Issue[], outFile?:
83
99
  * was added for, to say something the array already says.
84
100
  */
85
101
  export declare function initReport(version: string, issues: Issue[], outFiles: string[]): JsonReport;
86
- export declare function archiveReport(version: string, change: string, blocking: Issue[]): JsonReport;
102
+ /**
103
+ * `archive`, and — when `--apply` was asked for — what the merge wrote.
104
+ *
105
+ * `merge` is the same bundle `formatArchiveVerdict` takes, passed to both from
106
+ * one place in the shell. The two renderings of one result had already grown
107
+ * apart once over the verdict (ATX-60); handing them the same object is what
108
+ * stops the paths from being the second thing they disagree about.
109
+ */
110
+ export declare function archiveReport(version: string, change: string, blocking: Issue[], merge?: {
111
+ applied: boolean;
112
+ written: readonly string[];
113
+ }): JsonReport;
87
114
  /**
88
115
  * `status`. The progress rows never touch the verdict: `ok` comes from the same
89
116
  * `hasError` predicate as everywhere else, over an `issues` array that carries
package/dist/cli/json.js CHANGED
@@ -109,7 +109,15 @@ export function initReport(version, issues, outFiles) {
109
109
  const [only] = outFiles;
110
110
  return outFiles.length === 1 && only !== undefined ? { ...report, outFile: only } : report;
111
111
  }
112
- export function archiveReport(version, change, blocking) {
112
+ /**
113
+ * `archive`, and — when `--apply` was asked for — what the merge wrote.
114
+ *
115
+ * `merge` is the same bundle `formatArchiveVerdict` takes, passed to both from
116
+ * one place in the shell. The two renderings of one result had already grown
117
+ * apart once over the verdict (ATX-60); handing them the same object is what
118
+ * stops the paths from being the second thing they disagree about.
119
+ */
120
+ export function archiveReport(version, change, blocking, merge) {
113
121
  return {
114
122
  ...envelope({
115
123
  version,
@@ -123,6 +131,7 @@ export function archiveReport(version, change, blocking) {
123
131
  issues: blocking,
124
132
  }),
125
133
  change,
134
+ ...(merge?.applied ? { written: [...merge.written] } : {}),
126
135
  };
127
136
  }
128
137
  /**
@@ -35,11 +35,26 @@
35
35
  // present without a given scenario is one that did and then drifted.
36
36
  import { readdir } from 'node:fs/promises';
37
37
  import { join } from 'node:path';
38
+ import { forEachBounded } from './locate.js';
38
39
  import { byCodeUnit } from './order.js';
39
40
  import { relativePath } from './paths.js';
40
41
  import { keepOutcome, readRedRecordIn } from './red-record.js';
41
42
  /** The directory archived changes are moved into, under the project root. */
42
43
  const ARCHIVE_DIR = 'archive';
44
+ /**
45
+ * How many archived first-run records are read at once. Its own constant for
46
+ * the reason `WALK_CONCURRENCY` gives, and a fourth resource: what this bounds
47
+ * is the *parsed records held at once*, not descriptors. `readFile` does
48
+ * open-read-close inside one libuv threadpool work item, so concurrent
49
+ * descriptors track the threadpool and never the fan-out — measured, 3,000
50
+ * unbounded reads peaked at 53 open handles. Heap is what tracks the input, and
51
+ * a project's archive grows by one folder per change merged, forever, so the
52
+ * list is as much the project's shape as a spec tree is.
53
+ * `Promise.all(names.map(...))` put every record in flight at once: measured at
54
+ * the `fs` seam, 64 changes peaked at 64 concurrent reads, and 2,000 records of
55
+ * 45 KB peaked at 352 MB of heap against 177 MB bounded.
56
+ */
57
+ const ARCHIVE_CONCURRENCY = 32;
43
58
  /**
44
59
  * Read every archived change folder under `root`.
45
60
  *
@@ -67,10 +82,18 @@ export async function readArchivedChanges(root) {
67
82
  .filter((e) => e.isDirectory())
68
83
  .map((e) => e.name)
69
84
  .sort(byCodeUnit);
70
- return Promise.all(names.map(async (name) => {
85
+ // Preallocated and written by `index`, which is how a `forEachBounded` caller
86
+ // keeps a stable result: the pool calls in input order but completes in
87
+ // whatever order the disk answers, and `names` is already sorted above.
88
+ const changes = new Array(names.length);
89
+ await forEachBounded(names, ARCHIVE_CONCURRENCY, async (name, i) => {
90
+ // `readRedRecordIn` catches its own read and parse, so nothing here can
91
+ // throw and abandon the rest — the same thing `readGuarded` is for on the
92
+ // registry pool, and what keeps every slot of the array above filled.
71
93
  const path = join(dir, name);
72
- return { name, path: relativePath(root, path), firstRun: await readRedRecordIn(path) };
73
- }));
94
+ changes[i] = { name, path: relativePath(root, path), firstRun: await readRedRecordIn(path) };
95
+ });
96
+ return changes;
74
97
  }
75
98
  /**
76
99
  * The archive's first-run evidence, merged across every archived change.
@@ -20,6 +20,33 @@ export declare const isProposedSpecFile: (name: string) => boolean;
20
20
  * of them until its gate passes.
21
21
  */
22
22
  export declare const isSpecFile: (name: string) => boolean;
23
+ /**
24
+ * Run `fn` over every item with at most `limit` of them in flight.
25
+ *
26
+ * Extracted at the second call site rather than the first, because a shape
27
+ * written N times is one a later fix reaches only some copies of.
28
+ *
29
+ * **There are three, and the third is the one to look for.** `findFiles` and
30
+ * `parseSpecs` are named for what they fan out over; `loadRegistry` is named for
31
+ * merging, and its concurrency sits under a comment about *ordering* that
32
+ * answers a different question convincingly. Anything here that reads a list the
33
+ * project's tree decides the length of belongs in this pool, whatever the
34
+ * function around it is called.
35
+ *
36
+ * The order `fn` is *called* in is the input order; the order it *completes* in
37
+ * is not, so a caller that needs a stable result either indexes into a
38
+ * preallocated array by `index` or sorts afterwards. All three callers here do
39
+ * one of those, deliberately — as does `readArchivedChanges`, the caller
40
+ * outside this file, which preallocates and writes by `index`.
41
+ *
42
+ * No result is collected and none is needed — every caller writes into something
43
+ * it already owns, and a version returning `T[]` would have to choose an
44
+ * ordering on their behalf. A throw from `fn` propagates and abandons the rest,
45
+ * which is the existing behaviour at all three sites: `parseSpecs` and
46
+ * `loadRegistry` catch per file so that one hostile source scraps only itself
47
+ * (ATX-65), and a failed `readdir` really does end the walk.
48
+ */
49
+ export declare function forEachBounded<T>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<void>): Promise<void>;
23
50
  /**
24
51
  * Recursively find files under root whose basename matches `match`.
25
52
  *
@@ -56,7 +56,8 @@ export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpec
56
56
  * The order `fn` is *called* in is the input order; the order it *completes* in
57
57
  * is not, so a caller that needs a stable result either indexes into a
58
58
  * preallocated array by `index` or sorts afterwards. All three callers here do
59
- * one of those, deliberately.
59
+ * one of those, deliberately — as does `readArchivedChanges`, the caller
60
+ * outside this file, which preallocates and writes by `index`.
60
61
  *
61
62
  * No result is collected and none is needed — every caller writes into something
62
63
  * it already owns, and a version returning `T[]` would have to choose an
@@ -65,7 +66,7 @@ export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpec
65
66
  * `loadRegistry` catch per file so that one hostile source scraps only itself
66
67
  * (ATX-65), and a failed `readdir` really does end the walk.
67
68
  */
68
- async function forEachBounded(items, limit, fn) {
69
+ export async function forEachBounded(items, limit, fn) {
69
70
  let cursor = 0;
70
71
  const worker = async () => {
71
72
  for (let i = cursor++; i < items.length; i = cursor++) {
@@ -319,6 +319,7 @@ function modifyRefusal(root, file, refusal) {
319
319
  const where = refusal.field ? `${refusal.reqId}.${refusal.field}` : refusal.reqId;
320
320
  const why = {
321
321
  comment: `a comment sits inside the value it would replace, and which side of that edit the comment belongs to is not something --apply can decide`,
322
+ 'duplicate-key': `that name is written more than once in the file, so the span --apply located is not the one the registry's readers evaluate`,
322
323
  'entry-not-found': `the registry file that owns its prefix holds no entry for it`,
323
324
  'not-a-literal': `the value it would replace is not written as a literal`,
324
325
  'param-dropped': `the proved end state does not carry that param, and a delta has no way to say one was removed`,
@@ -320,20 +320,30 @@ export function mergeRedRecord(existing, plan, run, addedIds) {
320
320
  forFile[s.name] = kept;
321
321
  changed = true;
322
322
  }
323
- // Sort within each requirement, and within each file, for the same
324
- // byte-stability reason.
325
- for (const id of Object.keys(record)) {
323
+ // Sort at every level, for the same byte-stability reason the ids included.
324
+ //
325
+ // The rebuild above sorts only the ids the record already held; the loop after
326
+ // it *appends* the ones this run is the first to observe, in plan order. So
327
+ // the ids are unsorted by the time this runs, and this is the level that has
328
+ // to put them in order.
329
+ //
330
+ // A rebuild rather than an in-place sort, which is the trap: reassigning
331
+ // `record[id]` writes a value and does not move a key, so a version of this
332
+ // that sorted the two inner levels while walking the ids as they came would
333
+ // leave the outer order exactly as it found it.
334
+ const sorted = emptyMap();
335
+ for (const id of Object.keys(record).sort(byCodeUnit)) {
326
336
  const sortedFiles = emptyMap();
327
337
  for (const file of Object.keys(record[id]).sort(byCodeUnit)) {
328
- const sorted = emptyMap();
338
+ const names = emptyMap();
329
339
  for (const name of Object.keys(record[id][file]).sort(byCodeUnit)) {
330
- sorted[name] = record[id][file][name];
340
+ names[name] = record[id][file][name];
331
341
  }
332
- sortedFiles[file] = sorted;
342
+ sortedFiles[file] = names;
333
343
  }
334
- record[id] = sortedFiles;
344
+ sorted[id] = sortedFiles;
335
345
  }
336
- return { record, changed };
346
+ return { record: sorted, changed };
337
347
  }
338
348
  /** Serialise the record. A trailing newline, so the file is a well-formed text file. */
339
349
  export function serialiseRedRecord(changeName, record) {
@@ -65,7 +65,7 @@ export interface Modification {
65
65
  export interface ModifyRefusal {
66
66
  reqId: string;
67
67
  field?: string;
68
- reason: 'comment' | 'entry-not-found' | 'not-a-literal' | 'param-dropped';
68
+ reason: 'comment' | 'duplicate-key' | 'entry-not-found' | 'not-a-literal' | 'param-dropped';
69
69
  }
70
70
  export type ModifyResult = {
71
71
  ok: true;
@@ -252,9 +252,18 @@ export function spliceModifications(file, source, changes) {
252
252
  const edits = [];
253
253
  const wanted = new Map(changes.map((change) => [change.id, change]));
254
254
  // Ids the file does not hold, first and in code-unit order: they have no
255
- // position to be reported at, and the walk below is driven by positions.
255
+ // position to be reported at, and the walk below is driven by positions. An
256
+ // entry the reader refused to describe is reported as *why* rather than as
257
+ // absent — a file that writes one key twice does hold the entry, and
258
+ // `entry-not-found` would send its reader looking for something that is
259
+ // there. Both are decided before a single edit is pushed, because a refusal
260
+ // arriving mid-walk would leave `edits` half-built for an entry whose offsets
261
+ // are exactly the ones not to be trusted.
256
262
  for (const id of [...wanted.keys()].sort(byCodeUnit)) {
257
- if (!layouts.has(id))
263
+ const twice = layouts.repeated.get(id);
264
+ if (twice)
265
+ refusals.push({ reqId: id, ...twice, reason: 'duplicate-key' });
266
+ else if (!layouts.byId.has(id))
258
267
  refusals.push({ reqId: id, reason: 'entry-not-found' });
259
268
  }
260
269
  // **The file front to back, not the delta.** Driving the walk from the layout
@@ -268,7 +277,7 @@ export function spliceModifications(file, source, changes) {
268
277
  // It is also why the layout is `Map`s rather than objects at every level; the
269
278
  // container is what carries the order, and `registryEntryLayouts` says why an
270
279
  // object cannot.
271
- for (const [id, layout] of layouts) {
280
+ for (const [id, layout] of layouts.byId) {
272
281
  const change = wanted.get(id);
273
282
  if (!change)
274
283
  continue;
@@ -135,6 +135,31 @@ export interface RegistryEntryLayout {
135
135
  /** Where a new field goes, inside the entry's own body. */
136
136
  fieldInsertion: RegistryInsertion;
137
137
  }
138
+ /**
139
+ * The key an entry wrote twice, as the path the author would say it —
140
+ * `statement`, `params.limit`.
141
+ *
142
+ * Absent when what repeats is the entry's **own id**, which no field path
143
+ * names: `params.limit` is a place inside a requirement, and a second entry
144
+ * under one id is not inside anything.
145
+ */
146
+ export interface RepeatedKey {
147
+ field?: string;
148
+ }
149
+ /** Every entry's layout, and the entries this reader refuses to describe. */
150
+ export interface RegistryLayouts {
151
+ /**
152
+ * Value spans per requirement id, in the order the file writes them.
153
+ *
154
+ * Named `byId` rather than `entries` so nothing reads `layouts.entries` as
155
+ * the `Map` method of that name — the order this carries is the contract,
156
+ * and a reader who thinks they are calling `Map.prototype.entries` is a
157
+ * reader who has stopped seeing it.
158
+ */
159
+ byId: Map<string, RegistryEntryLayout>;
160
+ /** Ids left out of {@link RegistryLayouts.byId} because a key repeats. */
161
+ repeated: Map<string, RepeatedKey>;
162
+ }
138
163
  /**
139
164
  * The layout of every entry in a registry file, by requirement id.
140
165
  *
@@ -154,6 +179,26 @@ export interface RegistryEntryLayout {
154
179
  * schema's `z.record(z.string(), …)` accepts the result. A `Map` keeps insertion
155
180
  * order for every key type, so the property holds by construction rather than by
156
181
  * the keys happening not to be numbers.
182
+ *
183
+ * **A key written twice defeats that pairing, which is why it is refused rather
184
+ * than described.** `Map.set` on a key it already holds keeps the *first*
185
+ * insertion's position and takes the *last* call's value — so an entry writing
186
+ * `params: { limit: 1, other: 'x', limit: 1 }` yields `limit` at position one
187
+ * carrying the span of the occurrence that sits after `other`, and the walk's
188
+ * offsets stop ascending — so an earlier edit moves the bytes under a later one
189
+ * and the replacement lands outside the value it was addressing.
190
+ *
191
+ * Nothing upstream refuses such a file, which is why the guard is here: a
192
+ * duplicate key is a TypeScript *semantic* error and `parseSource` asks only
193
+ * the syntactic question, so both readers take the file and hand back the last
194
+ * occurrence, and `--apply` is the one command that writes.
195
+ *
196
+ * Refused at every level for one reason, and the id level is not the mild one:
197
+ * two entries under a single id put a *later* entry's spans at an *earlier*
198
+ * entry's position, so the disorder is between entries rather than inside one.
199
+ * Nothing narrower is worth the reach — an entry whose keys are ambiguous is
200
+ * one no verdict describes, and the file is a defect to fix rather than one to
201
+ * edit around.
157
202
  */
158
- export declare function registryEntryLayouts(file: string, source: string): Map<string, RegistryEntryLayout> | undefined;
203
+ export declare function registryEntryLayouts(file: string, source: string): RegistryLayouts | undefined;
159
204
  //# sourceMappingURL=static-registry.d.ts.map
@@ -295,11 +295,23 @@ function constInitializer(sf, name) {
295
295
  * The callee is matched against the local name the import bound — an alias or a
296
296
  * namespace import is the same call — rather than against any single-argument
297
297
  * call, so `buildRegistry({…})` is not mistaken for a registry.
298
+ *
299
+ * **Unwrapped here, once, rather than by each caller.** `as const`, `satisfies`
300
+ * and a bare pair of parentheses do not change the value, so `literalValue`
301
+ * strips them before it reads — while the write side asks
302
+ * `ts.isObjectLiteralExpression` of whatever this hands back. A wrapper returned
303
+ * from here is therefore a registry the readers accept and the writers cannot
304
+ * locate, on the one command that edits a `*.reqs.ts`. Both sides come through
305
+ * this function precisely so they cannot disagree about which literal is the
306
+ * registry; leaving the unwrap to each caller is what puts the disagreement
307
+ * inside it.
298
308
  */
299
309
  function authoringCall(expr, sf, fn) {
300
310
  if (!ts.isCallExpression(expr) || expr.arguments.length !== 1)
301
311
  return undefined;
302
- return callsAuthoringFn(expr, localNames(sf, fn), fn) ? expr.arguments[0] : undefined;
312
+ if (!callsAuthoringFn(expr, localNames(sf, fn), fn))
313
+ return undefined;
314
+ return unwrap(expr.arguments[0]);
303
315
  }
304
316
  /**
305
317
  * Whether a call is a call of `fn`, by the local names the imports bound to it.
@@ -520,31 +532,66 @@ function objectInsertion(sf, source, obj) {
520
532
  * schema's `z.record(z.string(), …)` accepts the result. A `Map` keeps insertion
521
533
  * order for every key type, so the property holds by construction rather than by
522
534
  * the keys happening not to be numbers.
535
+ *
536
+ * **A key written twice defeats that pairing, which is why it is refused rather
537
+ * than described.** `Map.set` on a key it already holds keeps the *first*
538
+ * insertion's position and takes the *last* call's value — so an entry writing
539
+ * `params: { limit: 1, other: 'x', limit: 1 }` yields `limit` at position one
540
+ * carrying the span of the occurrence that sits after `other`, and the walk's
541
+ * offsets stop ascending — so an earlier edit moves the bytes under a later one
542
+ * and the replacement lands outside the value it was addressing.
543
+ *
544
+ * Nothing upstream refuses such a file, which is why the guard is here: a
545
+ * duplicate key is a TypeScript *semantic* error and `parseSource` asks only
546
+ * the syntactic question, so both readers take the file and hand back the last
547
+ * occurrence, and `--apply` is the one command that writes.
548
+ *
549
+ * Refused at every level for one reason, and the id level is not the mild one:
550
+ * two entries under a single id put a *later* entry's spans at an *earlier*
551
+ * entry's position, so the disorder is between entries rather than inside one.
552
+ * Nothing narrower is worth the reach — an entry whose keys are ambiguous is
553
+ * one no verdict describes, and the file is a defect to fix rather than one to
554
+ * edit around.
523
555
  */
524
556
  export function registryEntryLayouts(file, source) {
525
557
  const found = registryLiteral(file, source);
526
558
  if (!found)
527
559
  return undefined;
528
560
  const { sf, literal } = found;
529
- const layouts = new Map();
561
+ const byId = new Map();
562
+ const repeated = new Map();
530
563
  for (const entry of literal.properties) {
531
564
  if (!ts.isPropertyAssignment(entry))
532
565
  continue;
533
566
  const id = staticName(entry.name);
534
567
  if (id === undefined || id === '__proto__')
535
568
  continue;
569
+ if (byId.has(id) || repeated.has(id)) {
570
+ // Deleted, not left as the first occurrence: both readers take the *last*
571
+ // one, so describing the first would hand the writer a span whose value
572
+ // nothing evaluates.
573
+ byId.delete(id);
574
+ repeated.set(id, {});
575
+ continue;
576
+ }
536
577
  const body = unwrap(entry.initializer);
537
578
  if (!ts.isObjectLiteralExpression(body))
538
579
  continue;
539
580
  const fields = new Map();
540
581
  const paramKeys = new Map();
541
582
  let params;
583
+ /** The first repeated key seen, as the path the refusal names. */
584
+ let twice;
542
585
  for (const field of body.properties) {
543
586
  if (!ts.isPropertyAssignment(field))
544
587
  continue;
545
588
  const name = staticName(field.name);
546
589
  if (name === undefined || name === '__proto__')
547
590
  continue;
591
+ if (fields.has(name)) {
592
+ twice ??= name;
593
+ continue;
594
+ }
548
595
  // The unwrapped value, so an `as const` or a parenthesis stays outside the
549
596
  // span and survives the replacement it wraps.
550
597
  const value = unwrap(field.initializer);
@@ -559,18 +606,26 @@ export function registryEntryLayouts(file, source) {
559
606
  const key = staticName(param.name);
560
607
  if (key === undefined || key === '__proto__')
561
608
  continue;
609
+ if (paramKeys.has(key)) {
610
+ twice ??= `params.${key}`;
611
+ continue;
612
+ }
562
613
  const value = unwrap(param.initializer);
563
614
  paramKeys.set(key, { start: value.getStart(sf), end: value.getEnd() });
564
615
  }
565
616
  }
566
- layouts.set(id, {
617
+ if (twice !== undefined) {
618
+ repeated.set(id, { field: twice });
619
+ continue;
620
+ }
621
+ byId.set(id, {
567
622
  fields,
568
623
  paramKeys,
569
624
  ...(params ? { paramsInsertion: objectInsertion(sf, source, params) } : {}),
570
625
  fieldInsertion: objectInsertion(sf, source, body),
571
626
  });
572
627
  }
573
- return layouts;
628
+ return { byId, repeated };
574
629
  }
575
630
  /**
576
631
  * The `defineRequirements({ … })` literal of a registry file, with the source
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@am_shork/attest",
3
- "version": "0.9.4",
3
+ "version": "0.10.0",
4
4
  "description": "TDD-native spec framework: tests are the source of truth for verification, ID-bound requirements the source of truth for intent.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.28.0",