@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 +596 -27
- package/README.md +10 -25
- package/dist/cli/action.js +8 -4
- package/dist/cli/index.js +7 -2
- package/dist/cli/json.d.ts +28 -1
- package/dist/cli/json.js +10 -1
- package/dist/core/archive.js +26 -3
- package/dist/core/locate.d.ts +27 -0
- package/dist/core/locate.js +3 -2
- package/dist/core/merge.js +1 -0
- package/dist/core/red-record.js +18 -8
- package/dist/core/splice.d.ts +1 -1
- package/dist/core/splice.js +12 -3
- package/dist/core/static-registry.d.ts +46 -1
- package/dist/core/static-registry.js +59 -4
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
package/dist/cli/action.js
CHANGED
|
@@ -73,10 +73,14 @@ export async function runAction(command, opts, action) {
|
|
|
73
73
|
}
|
|
74
74
|
catch (err) {
|
|
75
75
|
if (opts.json) {
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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,
|
|
272
|
+
console.log(formatArchiveVerdict(change, blocking, merge));
|
|
268
273
|
},
|
|
269
274
|
};
|
|
270
275
|
}));
|
package/dist/cli/json.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/dist/core/archive.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
package/dist/core/locate.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/core/locate.js
CHANGED
|
@@ -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++) {
|
package/dist/core/merge.js
CHANGED
|
@@ -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`,
|
package/dist/core/red-record.js
CHANGED
|
@@ -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
|
|
324
|
-
//
|
|
325
|
-
|
|
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
|
|
338
|
+
const names = emptyMap();
|
|
329
339
|
for (const name of Object.keys(record[id][file]).sort(byCodeUnit)) {
|
|
330
|
-
|
|
340
|
+
names[name] = record[id][file][name];
|
|
331
341
|
}
|
|
332
|
-
sortedFiles[file] =
|
|
342
|
+
sortedFiles[file] = names;
|
|
333
343
|
}
|
|
334
|
-
|
|
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) {
|
package/dist/core/splice.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/splice.js
CHANGED
|
@@ -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
|
-
|
|
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):
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|