@am_shork/attest 0.7.4 → 0.9.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 +1449 -157
- package/README.md +3 -2
- package/dist/cli/index.js +11 -8
- package/dist/cli/json.d.ts +26 -1
- package/dist/cli/json.js +28 -2
- package/dist/cli/report.js +9 -1
- package/dist/core/apply.d.ts +18 -1
- package/dist/core/apply.js +19 -2
- package/dist/core/gate.js +3 -3
- package/dist/core/locate.d.ts +19 -4
- package/dist/core/locate.js +90 -40
- package/dist/core/merge.js +218 -72
- package/dist/core/pipeline.d.ts +17 -1
- package/dist/core/pipeline.js +149 -35
- package/dist/core/red-record.d.ts +14 -5
- package/dist/core/red-record.js +82 -24
- package/dist/core/registry-issues.d.ts +30 -0
- package/dist/core/registry-issues.js +26 -0
- package/dist/core/registry.d.ts +12 -5
- package/dist/core/registry.js +10 -8
- package/dist/core/runner.js +21 -9
- package/dist/core/schema.d.ts +32 -27
- package/dist/core/schema.js +33 -5
- package/dist/core/skill.js +101 -27
- package/dist/core/splice.d.ts +62 -3
- package/dist/core/splice.js +297 -27
- package/dist/core/static-registry.d.ts +42 -0
- package/dist/core/static-registry.js +136 -21
- package/dist/core/status.js +2 -2
- package/dist/core/terminal.js +5 -2
- package/dist/core/types.d.ts +39 -10
- package/dist/core/validator.d.ts +1 -0
- package/dist/core/validator.js +22 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,873 @@ 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.9.0] - 2026-08-12
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **`--apply` writes MODIFIED requirements back, at the granularity of a value
|
|
21
|
+
rather than an entry.** `merge.ts` refused any delta carrying `modified` for
|
|
22
|
+
five releases, and what that refusal cost is on the record rather than
|
|
23
|
+
estimated: in one adopting project **5 of 19 changes carried `modified`**, each
|
|
24
|
+
merged by hand with a script the author rewrote five times in a session, which
|
|
25
|
+
located ` 'AUTH-7': { … },` in the registry **by regex**. `[0.6.0]` cites
|
|
26
|
+
eight hand-written Python scripts — one of them that same regex locator — as
|
|
27
|
+
the evidence that this step must not be left to the adopter, so the refusal had
|
|
28
|
+
reproduced the tool it was meant to retire, inside a project that had `--apply`
|
|
29
|
+
available.
|
|
30
|
+
**The argument the refusal rested on was subtly wrong, and correcting it is
|
|
31
|
+
what made this buildable.** `--apply` is allowed to edit a hand-written
|
|
32
|
+
registry because "the result is checkable by re-reading it", and the entry
|
|
33
|
+
proposing this narrowed on the wrong axis in consequence — accept an entry only
|
|
34
|
+
when its whole span is comment-free, which lowers the *rate* of destruction and
|
|
35
|
+
not its *size*. A re-read compares values and is blind to what was **lost**: a
|
|
36
|
+
replacement that ate a comment passes one cleanly. So the unit of the edit is
|
|
37
|
+
the value, not the entry. `statement` and `rationale` replace a string
|
|
38
|
+
literal's span, where a comment cannot be present by construction; a `params`
|
|
39
|
+
key replaces that key's value span and a new key is a pure insertion;
|
|
40
|
+
`outOfScope` replaces the array literal. Everything else in the entry — the
|
|
41
|
+
keys, the commas, the layout, every comment on a field that did not move — is
|
|
42
|
+
bytes the edit never addresses.
|
|
43
|
+
**Refused, still whole, when a span that would be overwritten carries a
|
|
44
|
+
comment**, as `apply-unsupported-delta` naming the requirement and the field.
|
|
45
|
+
Measured over this repository's own registry before it was built, on
|
|
46
|
+
2026-08-11: **75 entries, 3 carrying a comment anywhere inside them, all three
|
|
47
|
+
inside a `params` value**, and none at all inside a statement or a rationale.
|
|
48
|
+
So the refusal is the rare case rather than the categorical one, and every
|
|
49
|
+
refusal is still a merge nobody has to trust.
|
|
50
|
+
**RENAMED and REMOVED are deliberately still refused, and the granularity is
|
|
51
|
+
the reason they are now separable.** "REMOVED cannot say which comments
|
|
52
|
+
belonged to the entry it deletes" is the sentence the two shared, and it
|
|
53
|
+
survives untouched: deleting an entry has no smaller span to fall back to,
|
|
54
|
+
while the same question one level in is answered by not replacing that level.
|
|
55
|
+
**Two things this brought with it.** The re-read is now performed rather than
|
|
56
|
+
argued — after writing, the registry is read back and every id the edit was
|
|
57
|
+
answerable for is compared against what the gate proved, ADDED included, since
|
|
58
|
+
a check covering only the newer half would leave the older claim exactly as it
|
|
59
|
+
was. And every registry edit is computed before any file is written, so
|
|
60
|
+
"refused whole" is true of the write-back and not only of the delta's shape;
|
|
61
|
+
before this, the one thing that could fail during text generation — a
|
|
62
|
+
`__proto__` param key — failed one file at a time.
|
|
63
|
+
Held as `ATX-76`, whose scenarios read the registry as **text**: a merge that
|
|
64
|
+
regenerated the entry would produce a file reading back as exactly the same
|
|
65
|
+
requirement, so nothing about the parsed end state can tell a value-sized edit
|
|
66
|
+
from a rewritten one. `ATX-54` keeps the refusal half over what is left.
|
|
67
|
+
*One limit, recorded so nobody assumes otherwise:* a patch cannot express
|
|
68
|
+
**deleting** a `params` key — `applyDelta` merges keys and never drops one — so
|
|
69
|
+
write-back does not cover that, and no amount of span work would.
|
|
70
|
+
|
|
71
|
+
- **The workflow document now describes an inner loop for stage 2.** Its only
|
|
72
|
+
instruction there was `attest archive`, which applies the delta and runs the
|
|
73
|
+
base suite, so an agent iterating toward green paid the full gate after every
|
|
74
|
+
edit — and `status` cannot help, its two obligations both being settled at the
|
|
75
|
+
end of stage 1. The addition needs no engine change: run the proposed spec file
|
|
76
|
+
directly while you are still making it pass, keep the gate for the verdict.
|
|
77
|
+
Measured before it was written — a `*.proposed.spec.ts` runs under a plain
|
|
78
|
+
Vitest invocation, delta import included — and it carries the two things that
|
|
79
|
+
make it safe and the one that makes it not a verdict: nothing but the gate ever
|
|
80
|
+
writes `first-run.json`, `status` still answers the readable half, and a green
|
|
81
|
+
file does not predict the gate, because the direct run gets the project's
|
|
82
|
+
environment and the gate's child run is isolated. That last point is the
|
|
83
|
+
`tests-red` section's existing subject, so it is pointed at rather than
|
|
84
|
+
restated.
|
|
85
|
+
|
|
86
|
+
- **`status` applies the change's delta and refuses the report when it does not
|
|
87
|
+
fit, which no static command checked at all.** `applyDelta` produces six
|
|
88
|
+
diagnostics — a rename whose target exists or whose source does not, a
|
|
89
|
+
modification of an absent id or one leaving the requirement invalid, an
|
|
90
|
+
addition that is invalid or conflicts — and nothing reached them without
|
|
91
|
+
running the suite first. `check` reads deltas, but only to attribute proposed
|
|
92
|
+
specs; it never applies one. So a delta wrong in any of those six ways was
|
|
93
|
+
first heard from `archive`, in the workflow position where the specs and the
|
|
94
|
+
implementation are already written, about a fact that was available the moment
|
|
95
|
+
the delta was saved.
|
|
96
|
+
Refusal rather than a row, because there is no row that could be right: the
|
|
97
|
+
states this command reports are progress toward an *applied* registry, and a
|
|
98
|
+
delta that cannot be applied has none to be progress toward — an `unproven`
|
|
99
|
+
against it is a true-looking sentence about a change that cannot exist.
|
|
100
|
+
Held as `ATX-78` rather than under `ATX-33`, which the cheap implementation
|
|
101
|
+
makes clear: `ATX-33` constrains what an ERROR from this command *means*, and
|
|
102
|
+
an implementation that never detects an unapplicable delta satisfies it
|
|
103
|
+
completely, vacuously, having no ERROR to constrain. This is the detection —
|
|
104
|
+
the same split `ATX-69` records against `ATX-65`.
|
|
105
|
+
**Behaviour change, and the reader it costs is not the one being fixed.**
|
|
106
|
+
Applying a delta needs the registry, which this command did not read before, so
|
|
107
|
+
a project whose registry is not a literal now gets `registry-not-static` and no
|
|
108
|
+
report where it got a report, and gets it back only under `--eval`. That is the
|
|
109
|
+
trade `ATX-16` already makes for `check`, `cover` and `render`, arriving at the
|
|
110
|
+
fourth static command. The registry is read through whichever reader the
|
|
111
|
+
command was asked for, so `--eval` moves both halves together rather than
|
|
112
|
+
quietly evaluating one to make the other work.
|
|
113
|
+
|
|
114
|
+
### Fixed
|
|
115
|
+
|
|
116
|
+
- **`status` told the reader to write a scenario that was already on disk.**
|
|
117
|
+
`changeMergedPlan` returns the specs neither parse could read, and says in
|
|
118
|
+
place why they block: an unreadable proposed spec is a scenario the gate would
|
|
119
|
+
otherwise report as absent. `archive` acts on that list before it runs
|
|
120
|
+
anything, on the argument that a plan known to be short cannot grade a change
|
|
121
|
+
(ATX-65). `status` took the plan from the same call and destructured only
|
|
122
|
+
`merged`, dropping the issues — and every state it prints is computed *from*
|
|
123
|
+
that plan, so a claimed `*.proposed.spec.ts` that failed to parse left its
|
|
124
|
+
requirement with no scenarios in the plan and the row came back
|
|
125
|
+
`no-scenario`, with `issues` empty and `ok` true.
|
|
126
|
+
**Not an under-report but the inverse of one**, which is why it is a fix rather
|
|
127
|
+
than a widening: the command whose stated contract is to be a projection of the
|
|
128
|
+
gate answered the opposite of the gate about the same change, and the advice it
|
|
129
|
+
produced — write the scenario — is work the reader has already done. The one
|
|
130
|
+
thing that makes it recoverable is that `status` writes nothing.
|
|
131
|
+
**Behaviour change, and it moves an exit code.** A change in that state now
|
|
132
|
+
fails with `unreadable-file` and exits 1 where it printed a table and exited 0.
|
|
133
|
+
Anything wrapping `status` in a loop sees red on input that used to pass — but
|
|
134
|
+
the input is a spec file that does not parse, and the table it used to print
|
|
135
|
+
was wrong about it.
|
|
136
|
+
Held under `ATX-33` rather than as a new requirement: the rule it is an
|
|
137
|
+
exception to is untouched — an unmet obligation is still a row, never a verdict
|
|
138
|
+
— and what moved is which input that exception covers.
|
|
139
|
+
**Stating it exposed that the exception could not be enumerated, so it is now a
|
|
140
|
+
class and the code list is gone rather than extended.** `ATX-33` had named the
|
|
141
|
+
one code that could make `status` fail, and adding a second is what showed the
|
|
142
|
+
shape: the roster it can *actually* fail with was already longer than the list
|
|
143
|
+
on the day the list was written — a rejected change name and an unusable
|
|
144
|
+
compiler were never in it — and it grows with every input the command learns to
|
|
145
|
+
read. A param extended per failure path is a name for the implementation rather
|
|
146
|
+
than a contract, and it fails in the direction that hides, since a code missing
|
|
147
|
+
from it makes the requirement quietly false rather than red. What the statement
|
|
148
|
+
carries instead is the property worth preserving: **an ERROR from `status`
|
|
149
|
+
always means there is no report**, so a reader who sees one never has to ask
|
|
150
|
+
whether progress was being scored. That is **the first `params` key ever
|
|
151
|
+
removed from this registry**, against 57 added and one value changed; the
|
|
152
|
+
removal is the safe direction for the same reason those additions were, since
|
|
153
|
+
what the scenarios assert is broader than what the list named, not narrower.
|
|
154
|
+
**The measurement suite priced it in two places and one of them was a
|
|
155
|
+
finding.** The restated sentence is universally quantified, so
|
|
156
|
+
`tests/intent-rule-candidates.spec.ts` demanded a `QUANTIFIED` row and got the
|
|
157
|
+
`sites` shape — the members are early returns in one function, so nothing
|
|
158
|
+
enumerates them and no scenario can iterate them, which is the `ATX-37`
|
|
159
|
+
position that table exists to record. Two sites were unreached; one is closed
|
|
160
|
+
here by a scenario, and **the compiler path stays open as a measured gap,
|
|
161
|
+
taking that table from one to two**. It was not created here: `status` could
|
|
162
|
+
always fail that way with nothing attesting it, and naming the failing half as
|
|
163
|
+
a class is what made the site visible at all.
|
|
164
|
+
No `SCHEMA_VERSION` bump: `issues[]` already carries this code and the envelope
|
|
165
|
+
is unchanged. `unreadable-file` is not a new code either — what is new is a
|
|
166
|
+
fourth command that can raise it, which is why the troubleshooting section now
|
|
167
|
+
says which commands stop on it and which keep going.
|
|
168
|
+
|
|
169
|
+
- **The workflow document said a proposed spec stays out of a plain `vitest run`,
|
|
170
|
+
and it does not.** `*.proposed.spec.ts` ends in `.spec.ts`, so an ordinary
|
|
171
|
+
`**/*.spec.ts` include — Vitest's own default among them — matches it;
|
|
172
|
+
confirmed by running one. Only *Attest's* scope excludes it. The claim was
|
|
173
|
+
wrong in the direction that surprises: during stage 1 those scenarios are red
|
|
174
|
+
by construction, so an adopter following this document is told their own
|
|
175
|
+
`npm test` will be unaffected and then watches it go red, at the exact moment
|
|
176
|
+
the framework is asking them to trust a workflow they have just met. The
|
|
177
|
+
sentence now claims only what is true, and the optional `exclude` is shown —
|
|
178
|
+
as the reader's decision about their own suite, which is not one Attest makes
|
|
179
|
+
for them.
|
|
180
|
+
*That sample spreads `configDefaults.exclude`, and the reason is worth the
|
|
181
|
+
line it costs.* Vitest's `exclude` **replaces** its defaults rather than
|
|
182
|
+
extending them, so the obvious one-liner silently un-excludes
|
|
183
|
+
`**/node_modules/**` — measured, not inferred: a bare
|
|
184
|
+
`exclude: ['**/*.proposed.spec.ts']` collects and runs a `*.spec.ts` planted
|
|
185
|
+
inside `node_modules`. This repository's own `vitest.config.ts` has always
|
|
186
|
+
re-added those globs by hand, so the trap was already known here and would
|
|
187
|
+
still have shipped in advice written for someone else. A sample in a document
|
|
188
|
+
is executed by whoever copies it, and this one was two lines from turning a
|
|
189
|
+
cosmetic complaint into a broken suite.
|
|
190
|
+
|
|
191
|
+
- **The workflow document contradicted itself about `apply-unsupported-delta`,
|
|
192
|
+
and the half an agent branches on was the stale half.** The commit that taught
|
|
193
|
+
`--apply` to write MODIFIED back edited the prose in `skill.ts` and left the
|
|
194
|
+
code table eleven lines above it reading `--apply` writes back ADDED only, and
|
|
195
|
+
this delta carries more. So an agent meeting that code looked it up, concluded
|
|
196
|
+
the delta carried something unsupported, and would have gone off splitting
|
|
197
|
+
MODIFIED out of it — when the actual cause is now most often the other one the
|
|
198
|
+
same code covers, a comment sitting inside the span a MODIFIED value would be
|
|
199
|
+
written over. **A wrong cause costs more than a missing one**, because it sends
|
|
200
|
+
the reader to a repair that cannot work; the row now names both.
|
|
201
|
+
*What this says about the gate that exists.* `ATX-57` holds that every code the
|
|
202
|
+
workflow names is one the engine can emit, and its rationale records the
|
|
203
|
+
one-directionality as deliberate — the document has no business naming every
|
|
204
|
+
code, so the obligation is only that what it names exists. That is untouched
|
|
205
|
+
here and still right: the code existed, and what moved was its *meaning*.
|
|
206
|
+
`skill.ts`'s own note said "read this file when a release adds a diagnostic",
|
|
207
|
+
which is the rule that would not have caught this. It now says *or changes what
|
|
208
|
+
one means*, and to grep the body for the code rather than trusting that the
|
|
209
|
+
section just edited was its only mention.
|
|
210
|
+
*No `ATX-n`.* The obligation this would state — every description in the
|
|
211
|
+
workflow matches what the engine does — is the one nothing can check, which is
|
|
212
|
+
why `ATX-57` stops where it does. Adding a requirement that no scenario can
|
|
213
|
+
falsify would be a green tick over an unguarded property.
|
|
214
|
+
|
|
215
|
+
- **`attest status`'s documented surface was wider than the command, in three
|
|
216
|
+
places at once.** All three say some version of "`status` reports what the gate
|
|
217
|
+
still wants, and the one thing it cannot see is whether the tests pass". It is
|
|
218
|
+
a strict subset of that: `statusRows` projects the *uncovered* half of gate
|
|
219
|
+
check 1 and the whole of check 4, both restricted to the ids a change ADDs.
|
|
220
|
+
Check 3 reads `runtimeCoverage`, which only the suite produces — so a scenario
|
|
221
|
+
left `skip`ped blocks `archive` with the suite fully green and `status` fully
|
|
222
|
+
clean, which is exactly the conclusion the sentence invites a reader to rule
|
|
223
|
+
out. Orphans and unbound params are not projected either. Corrected in design
|
|
224
|
+
§8 (both languages), in the workflow document, in that document's command
|
|
225
|
+
table, and in the README's command list — four copies of one gloss, which is
|
|
226
|
+
the count worth recording: the phrase was not repeated because anyone reasoned
|
|
227
|
+
it four times, and correcting three of them would have left the repo saying two
|
|
228
|
+
different things about one command.
|
|
229
|
+
**Design §8's sentence was wrong on the day it was written, not stale, and the
|
|
230
|
+
difference was worth checking rather than assuming.** The tidy explanation was
|
|
231
|
+
that `declared-not-run` had been inserted as check 3 and renumbered the list
|
|
232
|
+
under a sentence nobody re-read — this file's own recurring lesson, and it fits
|
|
233
|
+
so well it was nearly written down. `git log -S` says otherwise: the numbering
|
|
234
|
+
has had check 3 reading `runtimeCoverage` since 2026-07-21, and the sentence
|
|
235
|
+
claiming check 3 is decidable without running anything arrived six days later.
|
|
236
|
+
So the mechanism here is not drift at all. **A sentence that enumerates a list
|
|
237
|
+
written a few lines above it is not checked against that list by anyone,
|
|
238
|
+
including its author**, and no gate reads prose.
|
|
239
|
+
*Separately, `status`'s History table was missing `0.8.0`.* Moving the
|
|
240
|
+
first-run record to `version: 2` — filed against `archive`, where the change
|
|
241
|
+
was made — also moved `status`'s output, because the two read that file with
|
|
242
|
+
the same predicate. Measured: the same change reports `✓ AUTH-7 2 scenarios,
|
|
243
|
+
seen red / 1 ready to archive` on a version-2 record and `● AUTH-7 2 never
|
|
244
|
+
run / 0 ready to archive` on the version-1 record a 0.7.x change carries. The
|
|
245
|
+
row is owed by the rule that a History table is the only thing that can tell a
|
|
246
|
+
reader on an older build which half of the page applies to them, and it is the
|
|
247
|
+
shape that hides: `status` exits 0 either way, so nothing anywhere turned red.
|
|
248
|
+
|
|
249
|
+
- **The workflow document never said a delta must be a literal.** It states the
|
|
250
|
+
rule for `*.reqs.ts` and stops there, while `check` and `status` read
|
|
251
|
+
`requirements.delta.ts` through the same static reader — so an agent lifting a
|
|
252
|
+
`const` out of a delta, which is the ordinary thing to do when two params share
|
|
253
|
+
a value, meets `registry-not-static` pointing at a file the rule it was given
|
|
254
|
+
never mentioned. Confirmed by running it rather than by reading the reader:
|
|
255
|
+
`attest status` on a delta whose param is an identifier reports
|
|
256
|
+
`registry-not-static` at that line. `changes/` is also the more exposed of the
|
|
257
|
+
two files, being by definition the one still under review, so it is the worse
|
|
258
|
+
of the two to leave undocumented.
|
|
259
|
+
*The same roster was short in `CLAUDE.md`*, which named three commands that
|
|
260
|
+
execute nothing where `ATX-16.staticCommands` names four — `status` being the
|
|
261
|
+
one missing, for the same reason: what it reads statically is a delta rather
|
|
262
|
+
than a registry, so it falls outside a sentence scoped to `*.reqs.ts` while
|
|
263
|
+
being squarely inside the security property that sentence exists to teach.
|
|
264
|
+
|
|
265
|
+
- **`check` had no `empty-spec` guard for four releases, and the reference said
|
|
266
|
+
it did.** A root with zero requirements answered `✓ No issues.` and exit 0,
|
|
267
|
+
while `verify` reported it as an ERROR. Reported by an adoption on 0.8.0,
|
|
268
|
+
which reached it the way it will usually be reached: `check` is the cheap CI
|
|
269
|
+
pre-check, so it is the command most likely to be pointed at a directory a
|
|
270
|
+
moved registry or a wrong `dir` argument has emptied — and the expensive gate
|
|
271
|
+
that would have caught it runs minutes later, or on a matrix leg that does not
|
|
272
|
+
run at all. What makes it a defect rather than a proposal is that the contract
|
|
273
|
+
was already written down and the implementation was what disagreed with it:
|
|
274
|
+
`cli-reference.md` lists `empty-spec` among the codes **`check`** reports, and
|
|
275
|
+
its own History row credits `0.2.0` with adding it, so the `0.2.0` change
|
|
276
|
+
landed on one of the two commands the reference describes it on and nothing
|
|
277
|
+
compared them since. `troubleshooting.md` closes the loop by telling a reader
|
|
278
|
+
who meets this to run `attest check .` — advice to a command that could not
|
|
279
|
+
say it.
|
|
280
|
+
**The fix is a layer move, not a branch added to a second command**, and that
|
|
281
|
+
is what settles the ambiguity the entry was filed with. The rule needs nothing
|
|
282
|
+
executed to decide it — the registry alone answers it — so it belongs to
|
|
283
|
+
structural validation, which is where `validateStructure` already lives and
|
|
284
|
+
which both commands already call with the same three arguments. Both design
|
|
285
|
+
documents said this twice and did not agree: the sentence stating the rule sat
|
|
286
|
+
in **§5.4**, under "what a run runs, and what `passed` may not mean", while
|
|
287
|
+
the account of what `init` writes cited **§5.3** for the same code in a
|
|
288
|
+
context where nothing has run at all. §5.3 is now the one place it is stated,
|
|
289
|
+
which makes that citation correct for the first time.
|
|
290
|
+
**Re-measured before a line was written, and the re-measure found a case the
|
|
291
|
+
entry had not counted** — the section's own discipline, and the third time it
|
|
292
|
+
has paid on the *reachability* question rather than on a number. The reported
|
|
293
|
+
half reproduced exactly. What the walk added is that `verify` fires
|
|
294
|
+
`empty-spec` **beside** `registry-invalid` when the registry was found and
|
|
295
|
+
failed to load, and its message tells the reader to point Attest at the
|
|
296
|
+
directory holding the `*.reqs.ts` files — a file that is sitting right there,
|
|
297
|
+
where the fix is the load error already in the report and following the hint
|
|
298
|
+
would move a path that was correct. So the obvious implementation, "make
|
|
299
|
+
`check` agree with `verify`", would have imported a latent misdiagnosis into
|
|
300
|
+
the command adopters run first. `empty-spec` is now withdrawn when a registry
|
|
301
|
+
file failed to load, on both commands: the same withdrawal `validateStructure`
|
|
302
|
+
already makes for `orphan-test`, against the same input, for the same reason.
|
|
303
|
+
A registry that could not be read is not an absent one, and the two take
|
|
304
|
+
opposite repairs.
|
|
305
|
+
**Stated beside the findings it explains, not instead of them.** The louder
|
|
306
|
+
symptom in the report was measured at 135 `orphan-test` ERRORs, one distinct
|
|
307
|
+
code, and the actual diagnosis — there is no registry under this root — not
|
|
308
|
+
among them. Each orphan is true of the root as given, so the alternative is
|
|
309
|
+
rejected explicitly rather than by omission: what was missing is the single
|
|
310
|
+
line saying why they are all true at once. `verify` already had it this way,
|
|
311
|
+
which answered the question by measurement rather than by argument.
|
|
312
|
+
Held as `ATX-18`, widened from `verify` to the layer, and `ATX-77` for the
|
|
313
|
+
withdrawal — kept apart because the two are falsifiable in opposite
|
|
314
|
+
directions, and both were observed doing exactly that before the branch was
|
|
315
|
+
written: reporting nothing satisfies `ATX-77` and fails `ATX-18`'s four
|
|
316
|
+
scenarios, reporting unconditionally satisfies `ATX-18` and fails `ATX-77`'s
|
|
317
|
+
two.
|
|
318
|
+
*A root that reported success now reports an error, which this file's
|
|
319
|
+
Versioning section makes a **minor** bump.*
|
|
320
|
+
|
|
321
|
+
- **`--apply` skipped the delta import it is supposed to repoint whenever the
|
|
322
|
+
specifier carried no file extension, and reported success.** `[0.6.0]` records
|
|
323
|
+
this exact failure being found and fixed — a merged spec left importing a path
|
|
324
|
+
step 3 has just moved to `archive/`, `check` silent, only the merged suite
|
|
325
|
+
showing it — and the fix covered the two spellings *this* repository writes.
|
|
326
|
+
`resolvesTo` (`core/splice.ts`) accepted the delta's own path or the NodeNext
|
|
327
|
+
`.js`-for-`.ts` spelling of it, and nothing else. A project on bundler
|
|
328
|
+
resolution writes neither: every import in it is extensionless, including the
|
|
329
|
+
one this is looking for. Reported by an adoption that is extensionless
|
|
330
|
+
throughout — application modules and all 35 spec files — so it hit this on
|
|
331
|
+
**every** applied change, nine in a row, and after the first one pre-empted it
|
|
332
|
+
by hand. That is the shape worth recording rather than the bug: the workaround
|
|
333
|
+
is cheap enough to become habit, so the defect stops producing reports while
|
|
334
|
+
continuing to happen.
|
|
335
|
+
**Re-measured before a line was written, and the entry's own account of the
|
|
336
|
+
repair was wrong.** The reading half reproduced exactly as filed, against the
|
|
337
|
+
current source rather than the `dist/` the report used. The writing half was
|
|
338
|
+
filed as needing nothing — "extensionless in, extensionless out falls out of
|
|
339
|
+
the empty case" — and the empty case sat behind `if (ext)`, so it left the
|
|
340
|
+
target's `.ts` in place. That guard was correct only while the empty case was
|
|
341
|
+
unreachable, which is precisely what widening the match changes. Fixing the
|
|
342
|
+
reported half alone would have written an extension into a project that writes
|
|
343
|
+
none: the reading half repaired, the writing half newly broken, and the
|
|
344
|
+
uniformity the whole fix exists to respect broken by the fix for it. This is
|
|
345
|
+
the second time an entry that named its own fix had the fix turn out to be the
|
|
346
|
+
wrong half, after the C1 stripping in `[0.8.0]`, and it is the case least
|
|
347
|
+
likely to be re-examined for exactly that reason.
|
|
348
|
+
Held as `ATX-75` with a scenario per spelling rather than by widening
|
|
349
|
+
`ATX-52`, which attests that the merged project verifies clean: a merged
|
|
350
|
+
project whose spec never imported the delta verifies clean either way. The
|
|
351
|
+
assertions are on the specifier the command wrote, not on the health of what
|
|
352
|
+
surrounds it, which is what makes the two separately falsifiable in the
|
|
353
|
+
direction that matters.
|
|
354
|
+
|
|
355
|
+
- **`ATX-72`'s second scenario could not run on Windows at all, and the
|
|
356
|
+
requirement's own rationale said it could.** The injection needs the registry
|
|
357
|
+
file's name to carry the *specifier's own* quote — that is what makes the two
|
|
358
|
+
quote styles two scenarios rather than a loop — and the rationale argued the
|
|
359
|
+
name was "legal on Windows too since neither is among the characters it
|
|
360
|
+
reserves". True of the apostrophe, and false of the double quote, which is in
|
|
361
|
+
the set that same sentence enumerates. So `writeFile` threw before the emitter
|
|
362
|
+
was reached and `verify self` was red on win32, from `[0.8.0]`.
|
|
363
|
+
**What hid it is worth more than the fix.** CI is Linux, where both names are
|
|
364
|
+
legal, so no pipeline could have reported it — `CLAUDE.md`'s two-platform rule
|
|
365
|
+
arriving on a requirement that had already written the platform argument down
|
|
366
|
+
and got it half right. The argument was written while this requirement covered
|
|
367
|
+
one quote; widening it to two left the reasoning for the first standing over
|
|
368
|
+
both, unre-examined. A rationale is not re-read when the thing it justifies
|
|
369
|
+
grows.
|
|
370
|
+
The obligation is unchanged and holds on both platforms, because it is about
|
|
371
|
+
what the emitter writes. What is platform-limited is the end-to-end route to
|
|
372
|
+
it, so the win32 run now reaches the same assertion through `repointImport`
|
|
373
|
+
directly rather than skipping — a skipped scenario is `declared-not-run`
|
|
374
|
+
(`ATX-71`), which would trade a red suite for a red gate. The honest reading,
|
|
375
|
+
recorded in the requirement: a repository holding that name cannot be checked
|
|
376
|
+
out on Windows at all, so the exposure the end-to-end case stands for is
|
|
377
|
+
POSIX-only.
|
|
378
|
+
|
|
379
|
+
### Changed
|
|
380
|
+
|
|
381
|
+
- **`status`'s closing line names `check` as well as `archive`.** It said what
|
|
382
|
+
this report is short of on one side — the verdict — and nothing about the
|
|
383
|
+
other: a proposed spec no delta claims, or one whose merged name is already
|
|
384
|
+
taken, is a static fact about this change that `check` already reports. Naming
|
|
385
|
+
it is deliberately the whole of the fix. Reporting those here too would put a
|
|
386
|
+
second answer to one question in the tree, which is the arrangement this
|
|
387
|
+
project takes apart everywhere else, and the reader who wanted one command was
|
|
388
|
+
going to run `archive` anyway.
|
|
389
|
+
|
|
390
|
+
- **`divergent-param` re-keyed a fourth time, and this one carries something the
|
|
391
|
+
first three did not.** `ATX-77` writes `ATX-18` out of the `code` group by
|
|
392
|
+
carrying the same `empty-spec`, on the rule's usual arithmetic — one id per
|
|
393
|
+
distinct value, later writer wins. The churn itself is now unremarkable and
|
|
394
|
+
that is the point of counting it. What is new is *which* pair it hid: `ATX-18`
|
|
395
|
+
and `ATX-77` were split deliberately, because each is falsifiable in a
|
|
396
|
+
direction the other is blind to, which makes them the most tightly related
|
|
397
|
+
pair in this registry — and the group can show only one of them. That is the
|
|
398
|
+
second time the one real relationship in this group has been the invisible
|
|
399
|
+
one, after `ATX-66`/`ATX-73`, and twice makes the mechanism legible rather
|
|
400
|
+
than the coincidence: two ids are related when they name the **same**
|
|
401
|
+
constant, which is exactly the case the deduplication discards. The rule is
|
|
402
|
+
not blind to relatedness by omission — its key is built by throwing the
|
|
403
|
+
evidence of it away.
|
|
404
|
+
|
|
405
|
+
- **`divergent-param`'s finding for `flag` had to be re-keyed a second time**, on
|
|
406
|
+
a requirement carrying the same `--apply` as the member it displaced. The
|
|
407
|
+
divergence itself did not change — the same three unrelated constants under
|
|
408
|
+
the same param name — but the grouping keeps one id per distinct *value* and
|
|
409
|
+
the later writer wins, so the row's identity moved and a human had to re-judge
|
|
410
|
+
a finding about which nothing was new. Once was a curiosity worth a line; twice
|
|
411
|
+
makes it the candidate rule's behaviour, and names a maintenance cost paid per
|
|
412
|
+
addition on top of the zero precision already recorded. Note which additions
|
|
413
|
+
trigger it: the ones that **agree** with an existing member.
|
|
414
|
+
|
|
415
|
+
- **`findFiles` opened one directory per directory in the tree; it now opens
|
|
416
|
+
32.** The recursion was `Promise.all(subdirs.map(walk))`, so every sibling at a
|
|
417
|
+
level was in flight at once and each of their children after them. The walk is
|
|
418
|
+
now a bounded pool a level at a time, which makes the peak a constant — the
|
|
419
|
+
same figure and the same shape as `parseSpecs`, whose bound `[0.7.0]` added.
|
|
420
|
+
*The entry this closes was half stale, and that is the part worth recording.*
|
|
421
|
+
`[0.7.0]` fixed the `parseSpecs` half and wrote down, in this file, that the
|
|
422
|
+
`findFiles` half was deliberately left; the `Under consideration` entry was
|
|
423
|
+
never narrowed to match, so it went on describing `parseSpecs` as reading every
|
|
424
|
+
source into memory for four releases after that stopped being true. An entry
|
|
425
|
+
whose other half ships is not re-read either — the same shape as a rationale
|
|
426
|
+
not re-read when the thing it justifies grows (`ATX-72`, above), arriving on
|
|
427
|
+
the section whose own discipline is to re-measure before building. Re-measuring
|
|
428
|
+
is what found it: the memory arithmetic the entry rested on was the half
|
|
429
|
+
already fixed.
|
|
430
|
+
*What retires the blocker is that it named the wrong failure.* `[0.7.0]` held
|
|
431
|
+
this back on "no report, and no demonstrated failure anywhere", where the
|
|
432
|
+
failure it meant was descriptor exhaustion — unreachable on either development
|
|
433
|
+
platform, and on Windows not even governed by `ulimit -n`, since Node uses
|
|
434
|
+
Win32 handles there. That is still true and still unmeasured. But it is not the
|
|
435
|
+
only cost: the peak itself is portable arithmetic, which is the standard the
|
|
436
|
+
`parseSpecs` half was accepted on. Measured in `tests/locate-fanout.spec.ts`,
|
|
437
|
+
which now counts in-flight `readdir` as well as in-flight `readFile`, on a
|
|
438
|
+
64-wide tree two levels deep: **64 before, 32 after** — and 200 before on a
|
|
439
|
+
200-wide one, which is the point. Unbounded, the figure is the width of the
|
|
440
|
+
level; the tree is the attacker's to choose, and `check` is the command this
|
|
441
|
+
project tells people to run first on an untrusted fork MR.
|
|
442
|
+
*The 200-wide tree is not what ships, and why is worth a line.* Building it
|
|
443
|
+
timed this file's `beforeAll` out on win32 under the parallel suite,
|
|
444
|
+
intermittently — two runs in three — and the failure arrived in the shape
|
|
445
|
+
`CLAUDE.md` records for `tests/consumer.spec.ts`: a throw in a hook reports
|
|
446
|
+
every test in the file as **skipped**, so the count moves from 385 passed to
|
|
447
|
+
383 passed and 2 skipped, and nothing says the property went unchecked. The
|
|
448
|
+
tree is now 64 wide, built concurrently, with an explicit hook timeout. A test
|
|
449
|
+
that is sometimes not run is worth less than a smaller one that always is.
|
|
450
|
+
*Both fan-outs now go through one `forEachBounded`*, extracted at the second
|
|
451
|
+
call site rather than the first: `parseSpecs` had the pool written inline, and
|
|
452
|
+
a shape written twice is the one a later fix reaches only one copy of — which
|
|
453
|
+
is, in miniature, what this entry is about. `parseSpecs` keeps its own
|
|
454
|
+
constant rather than sharing one, because the two bound different resources
|
|
455
|
+
and a shared figure could not be tuned for either. Its peak is unchanged at 32,
|
|
456
|
+
re-measured through the helper rather than assumed.
|
|
457
|
+
*A level at a time rather than one pool over a growing queue*, because a queue
|
|
458
|
+
fed by its own workers has to keep them alive while it is momentarily empty and
|
|
459
|
+
another worker may still push — a termination condition worth not owning for a
|
|
460
|
+
barrier paid in tree *depth*, which the filesystem bounds, against a fan-out
|
|
461
|
+
paid in tree *width*, which nothing does.
|
|
462
|
+
No behaviour change and no `ATX-n`, on the precedent `[0.7.0]` set for the
|
|
463
|
+
other half: the returned list is byte-identical, sorted the same way, with
|
|
464
|
+
`SKIP_DIRS` excluded the same way — all three asserted beside the peak — so
|
|
465
|
+
there is no contract here a consumer could branch on.
|
|
466
|
+
|
|
467
|
+
## [0.8.0] - 2026-08-09
|
|
468
|
+
|
|
469
|
+
### Security
|
|
470
|
+
|
|
471
|
+
- **The `--json` document escaped every C0 control and no C1, while every other
|
|
472
|
+
surface stripped both.** `control()` (`core/terminal.ts`) covers
|
|
473
|
+
`\x00-\x1f`, DEL and `\x80-\x9f`, the last of those held deliberately for
|
|
474
|
+
8-bit CSI; the machine path rested on `JSON.stringify`, and the claim written
|
|
475
|
+
above it — that serialising to JSON escapes the same characters — is true of
|
|
476
|
+
C0 and of nothing else. Re-measured before a line was written, as this file's
|
|
477
|
+
discipline asks: `U+009B` and `U+007F` both leave `renderJson` verbatim.
|
|
478
|
+
Reachable from every `--json` command including `check --json`, which executes
|
|
479
|
+
nothing and still quotes scenario names, file paths and reqIds out of the
|
|
480
|
+
repository being checked; under `verify` and `archive` a thrown message joins
|
|
481
|
+
them.
|
|
482
|
+
**Filed at the strength the evidence carries, and that has not changed.**
|
|
483
|
+
Exploiting it needs a terminal that treats UTF-8-decoded C1 as control —
|
|
484
|
+
believed to split VTE from xterm's default, still **not measured on either** —
|
|
485
|
+
and a reader looking at the JSON rather than parsing it. What makes it worth
|
|
486
|
+
closing is not the size of that window but the asymmetry: design §9.1 is
|
|
487
|
+
stated as a property of every artifact Attest writes, and one emitter opting
|
|
488
|
+
out through a true-but-narrower guarantee is exactly how two `catch` blocks
|
|
489
|
+
came to print a raw stack beside sanitised diagnostics once already.
|
|
490
|
+
**Re-measuring moved where the fix goes.** The entry proposed a map over
|
|
491
|
+
`issues`, on the `withDocs` model. Walking the report showed that is the wrong
|
|
492
|
+
half: `change`, `outFile`, `outFiles` and — two levels down — a status row's
|
|
493
|
+
scenario name and file all carry text from the project under test, and none of
|
|
494
|
+
them passes through the envelope. Enumerating them is the arrangement §9.1
|
|
495
|
+
already names as the cause of the defect, so the rule sits at serialisation
|
|
496
|
+
instead, which is the *document* arrangement the section describes — one point
|
|
497
|
+
of assembly, before any of it is seen, covering a field nobody has added yet.
|
|
498
|
+
`outFiles` and `docsUrl` were both added after the envelope and would each
|
|
499
|
+
have needed remembering. `reqId` needed none of this and is worth recording as
|
|
500
|
+
the case that is safe by construction: `RegistryIdSchema` holds every id to
|
|
501
|
+
`^[A-Z]+-\d+$` on both readers, which is the same argument `render` uses for
|
|
502
|
+
not sanitising ids.
|
|
503
|
+
No `SCHEMA_VERSION` bump: the shape is unchanged, and the field values a
|
|
504
|
+
consumer parses differ only where they previously carried a control character.
|
|
505
|
+
Attested as `ATX-74` rather than by widening `ATX-37`, which claimed this
|
|
506
|
+
surface was exempt — the two are separately falsifiable in the direction that
|
|
507
|
+
matters, since `ATX-37` holds at every print site today while this one failed.
|
|
508
|
+
That rationale, and the same claim in `core/terminal.ts`, are corrected in
|
|
509
|
+
place.
|
|
510
|
+
|
|
511
|
+
- **`render --out` wrote through a committed symlink, because the per-segment
|
|
512
|
+
resolution `init` performs had exactly one caller.** `resolveDest` resolves
|
|
513
|
+
every segment of an `init` destination and refuses with `unsafe-target-path`;
|
|
514
|
+
the `--out` path was `resolve(cwd, opts.out)` and went straight to
|
|
515
|
+
`writeAtomic`. Measured before a line was written, as this file's discipline
|
|
516
|
+
asks, and the filed claim held: with `docs` committed as a symlink to a
|
|
517
|
+
sibling directory, `attest render <dir> --out <dir>/docs/SPEC.md` reported
|
|
518
|
+
`✓ Wrote …` and the document appeared outside the root. The victim's `--out`
|
|
519
|
+
is the one their own README or CI documents, and the content redirected is the
|
|
520
|
+
rendered document — the statements and rationales their repository wrote.
|
|
521
|
+
**The leaf was already safe, which is why the gap was exactly the directory
|
|
522
|
+
segments.** `writeAtomic` lands by `rename`, which replaces a symlink instead
|
|
523
|
+
of following it — measured: a `SPEC.md` symlinked at a file outside the root
|
|
524
|
+
left that file's bytes untouched. `wx` does not reach this either and was
|
|
525
|
+
never scoped to: it guards the temporary path, and the temporary path is
|
|
526
|
+
`dirname(dest)`, i.e. already inside whatever directory the link chose. So
|
|
527
|
+
both held perfectly while the whole write happened somewhere it should not.
|
|
528
|
+
**The scope is the part that is not inherited from `init`.** `init` owns its
|
|
529
|
+
destinations, so containment there is unconditional; `--out` is an argument,
|
|
530
|
+
and `--out ../site/SPEC.md` is a destination the caller can see they chose.
|
|
531
|
+
Refusing it would be refusing the flag. The check therefore applies exactly
|
|
532
|
+
where the argument stops being evidence of where the file goes: a path that
|
|
533
|
+
reads as staying inside the project. It is asked against the root in both
|
|
534
|
+
spellings, lexical and resolved, because they differ whenever the project
|
|
535
|
+
itself sits under a link — testing only the resolved one would skip the check
|
|
536
|
+
for every such checkout, failing open on precisely the layout that has links
|
|
537
|
+
in it.
|
|
538
|
+
**`--check` goes through the same resolution**, which the filed entry did not
|
|
539
|
+
cover. It reads the destination rather than writing it, and a freshness gate
|
|
540
|
+
comparing a fresh document against a file outside the project answers about
|
|
541
|
+
the wrong document — quietly, since both verdicts it can reach are ones it
|
|
542
|
+
reports normally.
|
|
543
|
+
So this is `resolveDest` gaining its second caller rather than a new
|
|
544
|
+
indirection; by this repository's own rule it was a hypothetical seam with one
|
|
545
|
+
adapter and is now a real one. `changes/<name>/` remains the candidate third
|
|
546
|
+
and stays unmeasured.
|
|
547
|
+
**The repair moved the write, not just the check, and the reason is the shape
|
|
548
|
+
of the defect rather than tidiness.** `render`'s `writeAtomic` was the only
|
|
549
|
+
call to it outside `core/` — `runInit` writes in the pipeline, `merge.ts`
|
|
550
|
+
writes in the pipeline, and this one write sat in the CLI, which is exactly
|
|
551
|
+
how it reached the filesystem without the resolution `runInit` had performed
|
|
552
|
+
since `[0.7.3]`. Guarding it in the shell would have left that intact: the
|
|
553
|
+
obligation to call the check would still belong to a caller who can forget it,
|
|
554
|
+
which is the situation `pipeline.spec.ts`'s compiler-guard gate already exists
|
|
555
|
+
to catch elsewhere ("delete the line and every scenario stays green"). So
|
|
556
|
+
`runRender` takes the destination and owns the write, `runRenderCheck`
|
|
557
|
+
resolves the path it reads, and the resolution is **private to the module** —
|
|
558
|
+
there is no longer an entry point that writes a document without resolving
|
|
559
|
+
where it goes. The CLI lost its `writeAtomic` import, and with it the last
|
|
560
|
+
policy decision it was making; `hasError` before the write is now what stops a
|
|
561
|
+
broken registry overwriting a good committed document, stated where the write
|
|
562
|
+
is. No existing test changed: `runRenderCheck` keeps its signature, because
|
|
563
|
+
`target` already carried the spelling a diagnostic echoes and asking a caller
|
|
564
|
+
for it twice is a clump waiting to disagree with itself.
|
|
565
|
+
Two things the move surfaced that a guard bolted onto the shell would have
|
|
566
|
+
hidden. **The compiler guard has to stay the first statement**, which
|
|
567
|
+
delegation gave `runRenderCheck` for free and a resolution placed above it
|
|
568
|
+
quietly took away — an unsupported TypeScript would have come back as a path
|
|
569
|
+
refusal. Both entry points call `compilerIssue` first now, and
|
|
570
|
+
`runRenderCheck` leaves the exemption table in `tests/pipeline.spec.ts`, which
|
|
571
|
+
is what that table is for: a list of exemptions exists to be decided about
|
|
572
|
+
rather than to drift. And **"never overwrite a good document with the output
|
|
573
|
+
of a broken registry" was a comment with nothing behind it** — the write lived
|
|
574
|
+
in the CLI, where no test could reach it. It is asserted now, where the write
|
|
575
|
+
is, and is red without the guard: a half-loaded registry renders a document
|
|
576
|
+
that silently omits requirements, so overwriting the committed one destroys
|
|
577
|
+
the only correct copy and the next `--check` passes against the damage. The ATX-73 scenarios drive
|
|
578
|
+
the binary, so they are what proves the move changed no behaviour, and three
|
|
579
|
+
mutants confirm they still bite: writing without resolving, refusing every
|
|
580
|
+
path that leaves the root, and `--check` skipping the resolution are each red.
|
|
581
|
+
Stated as `ATX-73` with three scenarios: the write path and the `--check` path
|
|
582
|
+
both driven through the real CLI against a project carrying a planted link —
|
|
583
|
+
spawned rather than called, because the defect was never in the resolution but
|
|
584
|
+
in the write not going through one, so a scenario calling `resolveOutFile`
|
|
585
|
+
directly would have passed against the broken build — and a third holding the
|
|
586
|
+
scope, that a destination spelled outside the project is still written where
|
|
587
|
+
it says. All three are red against the previous build.
|
|
588
|
+
One further behaviour change falls out of resolving rather than checking, and
|
|
589
|
+
it is a repair rather than a cost: a link *inside* the project is now
|
|
590
|
+
**followed** instead of replaced. `rename` used to overwrite `docs/SPEC.md ->
|
|
591
|
+
build/SPEC.md` with a regular file while `--check` read straight through it,
|
|
592
|
+
so the two halves of the gate disagreed about which file the document was;
|
|
593
|
+
measured, the link now survives and `build/SPEC.md` receives the bytes.
|
|
594
|
+
**This turns a passing run red for anyone whose `--out` currently resolves
|
|
595
|
+
through a link out of the project**, which is the behaviour change that
|
|
596
|
+
matters and is the point of it. No
|
|
597
|
+
`SCHEMA_VERSION` bump: `unsafe-target-path` is an existing code reported by an
|
|
598
|
+
additional command, `src/cli/json.ts` is untouched, and no field moves.
|
|
599
|
+
|
|
600
|
+
- **`--apply` wrote the registry's own file name into a spec's import as text
|
|
601
|
+
rather than as a string, so a registry whose name carried a quote closed the
|
|
602
|
+
literal and everything after it became code.** `splice.ts` states that every
|
|
603
|
+
generated byte is escaped — `tsString` for statement, rationale and
|
|
604
|
+
`outOfScope`, `keySource` for param keys, a `throw` rather than a guess for
|
|
605
|
+
`__proto__` — and `repointImport` was the one site that interpolated instead.
|
|
606
|
+
What it interpolated is the value the *checked repository* names: `to` is the
|
|
607
|
+
registry file `--apply` decided the ids belong in, and `isReqsFile` tests only
|
|
608
|
+
the suffix.
|
|
609
|
+
**The deletion test says what this was worth, and it is not execution.**
|
|
610
|
+
`archive --apply` runs the suite in the same invocation, so the attacker
|
|
611
|
+
already executes there. What the injection added is **persistence**: the
|
|
612
|
+
payload lands in a `*.spec.ts` that the merge then commits, and runs on every
|
|
613
|
+
later clone and CI job, inside a diff `--apply` advertises as a rename plus one
|
|
614
|
+
specifier. **Not platform-scoped** — Windows rejects `\ / : * ? " < > |` in a
|
|
615
|
+
file name and neither `'` nor `;` is among them.
|
|
616
|
+
Re-measured before a line was written, as this file's discipline asks, and the
|
|
617
|
+
filed claim held with one correction it did not have: the payload must carry
|
|
618
|
+
**the file's own quote character**, so the double-quoted spec survives a `'`
|
|
619
|
+
and vice versa. That is why the repair is not "escape `'`". `tsString` splits
|
|
620
|
+
into a `tsStringBody(value, quote)` that is *told* which quote it writes
|
|
621
|
+
between, and the specifier edit passes the quote already in the file — the
|
|
622
|
+
file's quote style stays exactly as it was, which is the property that ruled
|
|
623
|
+
out `JSON.stringify` here in the first place. No refusal path was needed:
|
|
624
|
+
escaping has an answer for every name, since a specifier is a string and
|
|
625
|
+
`tsStringBody` can spell any string as one, so `repointImport` still cannot
|
|
626
|
+
throw and `merge.ts` keeps the one `UnwritableValue` site it had.
|
|
627
|
+
The regression is asserted as a **round-trip** rather than against escaped
|
|
628
|
+
bytes — the file still parses, carries its original four statements, and the
|
|
629
|
+
specifier reads back as the path — under both quote styles, because escaping
|
|
630
|
+
the wrong quote is as wrong as escaping neither. Both cases are red against
|
|
631
|
+
the previous emitter.
|
|
632
|
+
Stated as `ATX-72` with a scenario per quote style, driving the real
|
|
633
|
+
`--apply` over a project whose registry file is named with the payload. Its
|
|
634
|
+
first draft is worth recording because a gate caught it and not a reviewer:
|
|
635
|
+
written as a quantifier over every value the emitter writes, the `sites` table
|
|
636
|
+
in `tests/intent-rule-candidates.spec.ts` demanded a scenario per site and
|
|
637
|
+
found three the sentence claimed which no `self/` scenario reaches. The hole
|
|
638
|
+
was in the *statement*, not the coverage — the other three sites are attested
|
|
639
|
+
in `tests/splice.spec.ts` and were never part of this change — so the repair
|
|
640
|
+
was to narrow the sentence to the one value the checked repository names.
|
|
641
|
+
§7 gains the general form, since it already carried the emitter's rule ("it
|
|
642
|
+
may emit no text whose evaluation differs from the value it was handed") and
|
|
643
|
+
carried it as a rule about the *registry*: `--apply` writes a second file, and
|
|
644
|
+
a file name is not something a tool is in a position to restrict, so it is
|
|
645
|
+
something the writer is obliged to escape.
|
|
646
|
+
No `SCHEMA_VERSION` bump: `src/cli/json.ts` is untouched, no code or field
|
|
647
|
+
moves, and no valid input changes behaviour — for any registry not named to
|
|
648
|
+
attack, the bytes `--apply` writes are the bytes it wrote before.
|
|
649
|
+
|
|
650
|
+
- **A registry file that swaps its prototype was read as the requirements the
|
|
651
|
+
prototype carried, under the reader that evaluates.** `{ '__proto__': { … } }`
|
|
652
|
+
written as a literal is a swap, not a key: the object has **no own keys at
|
|
653
|
+
all**, so what the loader reported never depended on the swap but on what
|
|
654
|
+
happened to be behind it. The static reader refuses such a source outright as
|
|
655
|
+
`registry-not-static` and always did — the two readers agreeing is a security
|
|
656
|
+
property, not a convenience — so this was the two of them disagreeing about
|
|
657
|
+
whether a file is a registry at all.
|
|
658
|
+
**A fixture for exactly this existed and was passing for the wrong reason.**
|
|
659
|
+
`tests/static-registry.spec.ts` put *malformed* ids on the prototype, and zod
|
|
660
|
+
v3's record enumerates with `for…in`, which walks the chain — so those
|
|
661
|
+
inherited keys were tested against the id grammar and failed it. The red was
|
|
662
|
+
incidental to the swap, and the whole guarantee rested on it.
|
|
663
|
+
Put a **well-formed** id on the prototype instead and the same shape is
|
|
664
|
+
**accepted**, with the entire registry built out of a prototype the source
|
|
665
|
+
does not declare. That is measured on the zod this release ships with, not
|
|
666
|
+
inferred: the new differential case is red without the guard and the old
|
|
667
|
+
fixture stays green beside it, which is the defect and its camouflage in one
|
|
668
|
+
run. Found while evaluating the move to zod v4 — which enumerates own keys
|
|
669
|
+
only, so the swapped object is simply empty and even the malformed fixture
|
|
670
|
+
goes green — but the acceptance is reachable today and is fixed here, ahead of
|
|
671
|
+
that port and independently of it.
|
|
672
|
+
Refused now by the `isPlainObject` guard `params` has carried since a param
|
|
673
|
+
became any JSON value, applied one level up to the registry itself — **stated
|
|
674
|
+
over the shape rather than over the name `__proto__`**, because a blocklist of
|
|
675
|
+
names is a second answer to "is this data" and the guard is already the first.
|
|
676
|
+
A class instance and a `Date` fail it for the same reason. `registry-invalid`
|
|
677
|
+
rather than a code of its own: the obligation is identical to every other way
|
|
678
|
+
the schema refuses a registry, and so is the reader's situation.
|
|
679
|
+
Stated as `ATX-70` with three scenarios, and the fixture the suite was missing
|
|
680
|
+
— the well-formed-id swap — is now the one the differential suite runs.
|
|
681
|
+
|
|
682
|
+
### Changed
|
|
683
|
+
|
|
684
|
+
- **The guidance now says which quantifier to write, where before it only said
|
|
685
|
+
what a scenario must read.** `[0.7.0]` added the `QUANTIFIED` table after
|
|
686
|
+
`ATX-37` — "**everything** the CLI writes to a terminal MUST replace every
|
|
687
|
+
control character" — was found to have scenarios for the two formatters that
|
|
688
|
+
existed the day it was written, while `render` built a document by
|
|
689
|
+
concatenation that never went past the sanitiser. What the table taught was
|
|
690
|
+
never written down anywhere an author would meet it, and it is the more useful
|
|
691
|
+
half of that work: **which surface a quantifier ranges over decides whether
|
|
692
|
+
scenarios can keep up with it**, and there are three, not the two the original
|
|
693
|
+
proposal assumed. Over *values* one site processes, representative inputs
|
|
694
|
+
settle it. Over a *roster* the code enumerates, a scenario looping that roster
|
|
695
|
+
covers a new member by construction. Over *sites* in the implementation, each
|
|
696
|
+
needing a hand-written scenario, a new site is a new obligation and nothing
|
|
697
|
+
enumerates them — the only one of the three that can silently stop being true,
|
|
698
|
+
and what `ATX-37` was. So the rule is *prefer a quantifier a scenario can
|
|
699
|
+
iterate*.
|
|
700
|
+
*Where it landed.* Design §11 as a residual weakness, beside the two `params`
|
|
701
|
+
bullets it completes — those say what an assertion must do with a value it
|
|
702
|
+
read; this says what the statement above them should quantify over in the first
|
|
703
|
+
place. The `init` skill gets the adopter-facing form in the same model section
|
|
704
|
+
as those two rules, so a project adopting Attest is told before it writes the
|
|
705
|
+
statement rather than after the gap is found by reading. Both languages, one
|
|
706
|
+
commit, per the reason `[0.5.0]` records for doing this shape of change in one
|
|
707
|
+
pass.
|
|
708
|
+
*No engine change, and the entry stays open for the one that is still not
|
|
709
|
+
proposed.* Which surface a sentence names is prose judgement — the ground truth
|
|
710
|
+
§0 refuses to compute for someone else's requirements — and the detector that
|
|
711
|
+
exists flags the quantifier only, missing the article form ("A registry that
|
|
712
|
+
cannot be read MUST …"). What ships here is counter-pressure, which is what §11
|
|
713
|
+
now says: a hand-judged table for this registry, plus a rule an author can
|
|
714
|
+
follow. The carve-out is carried with it, because it is the failure mode of any
|
|
715
|
+
such table: rewriting a statement to name its surfaces takes it back out of the
|
|
716
|
+
flagged set, so the table admits rows for ids nothing flags.
|
|
717
|
+
|
|
718
|
+
- **Zod moves from v3 to v4 (`zod@^3.23.0` → `^4.4.3`).** The reason is supply,
|
|
719
|
+
not speed: v3 is in maintenance, so fixes land on v4. The port of the schema as
|
|
720
|
+
it stood was the **one API change** the plan predicted — `errorMap` → `error` —
|
|
721
|
+
with `z.custom().pipe()`, `z.record`, `z.lazy` and the recursive `paramValue`
|
|
722
|
+
all carrying over unedited, and `red-record.ts`'s `z.ZodType` untouched. A
|
|
723
|
+
`as z.ZodType<ParamValue>` on `paramValue` became unnecessary and is gone. The
|
|
724
|
+
count is one *for the schema the plan measured*: the prototype guard added in
|
|
725
|
+
the commit before this one brought a second options bag with it, so the port
|
|
726
|
+
actually touched two — which is the ordinary way a plan's cost estimate goes
|
|
727
|
+
stale, by the code moving under it rather than by the estimate being wrong.
|
|
728
|
+
**The plan's "no `package.json` change" framing was about the evaluation, and
|
|
729
|
+
taking it as the destination would have shipped a broken package.** It rested
|
|
730
|
+
on `zod@3.25.76` exporting a `./v4` subpath — true of the *installed* version
|
|
731
|
+
and not of the *declared range*: `zod@3.23.8` is inside `^3.23.0` and exports
|
|
732
|
+
no `./v4` at all, so importing `zod/v4` while keeping that range yields a
|
|
733
|
+
package that fails to resolve on a legitimate install of its own dependency.
|
|
734
|
+
It is a real bump or it is nothing.
|
|
735
|
+
*Re-measured here, and the entry's central type claim does not survive.* The
|
|
736
|
+
plan recorded instantiations as **unchanged** (27,681 → 27,671). On this
|
|
737
|
+
codebase they go **36,211 → 89,831**, about 2.5×, with build check time
|
|
738
|
+
0.58 s → 0.71 s. Controlled for the prototype guard, which accounts for 292 of
|
|
739
|
+
that — the growth is the port. The consumer-side `.d.ts` footprint is worse
|
|
740
|
+
than predicted too: **14 files / 68 KiB → 80 files / 217 KiB** (the plan said
|
|
741
|
+
66 / 181 KiB), and a consumer's own instantiations 71,935 → 131,044. Runtime
|
|
742
|
+
goes the other way and by less than advertised: parsing this registry is
|
|
743
|
+
**0.873 ms → 0.143 ms**, 6.1× rather than 13×, and worth ~0.7 ms against the
|
|
744
|
+
hundreds `attest check self` takes. None of it blocks a migration whose reason
|
|
745
|
+
is supply, and all of it is recorded because "v4 is also faster to typecheck"
|
|
746
|
+
is the sentence that would otherwise reach the commit message — and on this
|
|
747
|
+
schema it is false in a way the plan did not have: the plan called the type
|
|
748
|
+
side neutral, and it is a cost.
|
|
749
|
+
*One diagnosis regression, found and repaired.* A v4 record reports a bad key
|
|
750
|
+
as "Invalid key in record" and does not surface the key schema's own message,
|
|
751
|
+
so a mistyped requirement id — the commonest way to fail this schema — stopped
|
|
752
|
+
being told the grammar it had missed. The record now carries an explicit
|
|
753
|
+
`error`, which restores it.
|
|
754
|
+
|
|
755
|
+
- **`RegistryValidationError.issues` has a shape of this project's own, and no
|
|
756
|
+
longer exposes `z.ZodIssue`.** Zod was otherwise fully absorbed — `Requirement`
|
|
757
|
+
and `Registry` are `z.infer` aliases, so a consumer resolves them to plain
|
|
758
|
+
object types and never names the library. This field was the single exception,
|
|
759
|
+
and it was the load-bearing one: it is reached through the `./define` subpath
|
|
760
|
+
that `defineRequirements` is imported from, and `ZodIssue` is a discriminated
|
|
761
|
+
union zod reshaped between v3 and v4. So a dependency bump this project should
|
|
762
|
+
have been free to make was a **breaking change for anyone who had destructured
|
|
763
|
+
the field**. The element type is now `RegistryValidationIssue` — `path:
|
|
764
|
+
PropertyKey[]` and `message: string`, exported from the same subpath — which is
|
|
765
|
+
the same boundary `Issue.message` already holds, with the difference that
|
|
766
|
+
`Issue` was designed with the boundary and this was not.
|
|
767
|
+
**Breaking, for the narrow surface that read past those two fields**, which is
|
|
768
|
+
why it is a minor under 0.x. Nothing in this repository was in that surface:
|
|
769
|
+
re-measured before a line was written, the only two readers are `formatIssues`
|
|
770
|
+
in `registry.ts` and `firstMessage` in `apply.ts`, and **nothing anywhere reads
|
|
771
|
+
`code`, `expected`, `received` or `fatal`** — which is what made this a rename
|
|
772
|
+
rather than a redesign. A consumer that only formats what it is given, as both
|
|
773
|
+
of those do, sees no change at all.
|
|
774
|
+
*`path` is `PropertyKey[]` rather than `(string | number)[]` deliberately.* It
|
|
775
|
+
is the wider of the two spellings zod has used, so a validator failure assigns
|
|
776
|
+
to it under either — which is the whole point, since the release that moves
|
|
777
|
+
that type is the one this change exists for.
|
|
778
|
+
*One thing the plan glossed, found by doing it.* The two formatters were said
|
|
779
|
+
to read the same two fields, and they did — but not the same way: `apply.ts`
|
|
780
|
+
spelled the path `.map(String).join('.')` and `registry.ts` spelled it
|
|
781
|
+
`.join('.')`. Under `(string | number)[]` those are identical; under
|
|
782
|
+
`PropertyKey[]` the second **throws** on a symbol key, because `join` cannot
|
|
783
|
+
convert one. The shared formatter uses the safe spelling. `ATX-44` is what made
|
|
784
|
+
the rest checkable rather than hopeful: the `a.b.c` path spelling is a stated
|
|
785
|
+
requirement with a scenario on it, so the property that had to survive already
|
|
786
|
+
had something asserting it.
|
|
787
|
+
|
|
788
|
+
- **`changes/<name>/first-run.json` moves to `version: 2`, and a version-1 file
|
|
789
|
+
is discarded whole.** The record now nests the scenario name under the spec
|
|
790
|
+
file, so the old shape cannot be read as the new one. Discarding is the safe
|
|
791
|
+
direction — an empty record makes every obligation "never observed", which the
|
|
792
|
+
gate blocks on — and the cost is one stage-1 run to re-observe, for a change
|
|
793
|
+
in flight across this upgrade only. The version is *checked* rather than
|
|
794
|
+
inferred from the old shape failing the schema: it would fail it anyway, but
|
|
795
|
+
"discarded because the format moved" and "discarded because the file is
|
|
796
|
+
corrupt" are different facts, and reading one as the other is how a format
|
|
797
|
+
change happens without anyone deciding to make it. Unrelated to the report
|
|
798
|
+
`schemaVersion`, which does not move — `src/cli/json.ts` is untouched, and no
|
|
799
|
+
envelope, field or `code` changed.
|
|
800
|
+
|
|
801
|
+
### Fixed
|
|
802
|
+
|
|
803
|
+
- **A scenario that never ran was reported as attested, when another spec file
|
|
804
|
+
declared a scenario of the same name under the same `requirement()`.** Runtime
|
|
805
|
+
coverage was keyed `(reqId, scenario name)` with no file, so two spec files
|
|
806
|
+
declaring `scenario('x')` under `requirement('APP-1')` were one entry, and
|
|
807
|
+
whichever of them executed vouched for both. `declared-not-run` — the check
|
|
808
|
+
whose whole job is to catch a declared scenario that did not execute — was
|
|
809
|
+
answering about the wrong scenario, and answering "fine".
|
|
810
|
+
Reproduced on 2026-08-09 with no flag, no `--eval` and default configuration:
|
|
811
|
+
a `real.spec.ts` whose `scenario('x')` runs beside a `ghost.spec.ts` whose
|
|
812
|
+
`scenario('x')` sits in a branch that never registers gives
|
|
813
|
+
`✓ No issues. — 1 requirement / 2 scenarios`. Renaming the ghost to `'y'`
|
|
814
|
+
produces the `declared-not-run` naming it, so the collision was the whole of
|
|
815
|
+
the difference — the check was present and was looking at the wrong key.
|
|
816
|
+
**This is the failure this tool exists to refuse**, which is what put it ahead
|
|
817
|
+
of everything else in flight: a report that says a scenario attested a
|
|
818
|
+
requirement when the scenario did not execute.
|
|
819
|
+
**The same key reached the first-run record, and there it is worse.** The
|
|
820
|
+
record is monotonic toward `fail`, so two scenarios of one added requirement
|
|
821
|
+
sharing a name meant one going red satisfied the other's `never-red`
|
|
822
|
+
obligation — mechanism 2 certifying a discrimination that never happened.
|
|
823
|
+
That half was inferred from the shared key when this was filed and is measured
|
|
824
|
+
here: both halves go red independently under control, which is why `ATX-71` is
|
|
825
|
+
two obligations rather than one.
|
|
826
|
+
Fixed by carrying the file into the key rather than by adding a check. `walk`
|
|
827
|
+
already descends from the file task, which is the only level holding a path,
|
|
828
|
+
and `ParsedScenario` has carried `file` since it existed — so both sides of
|
|
829
|
+
design §5.4's one cross-process channel had the field and only the maps
|
|
830
|
+
forgot it. It goes through `relativePath` for the reason `unloadedFiles` does:
|
|
831
|
+
the key is compared against a plan built on POSIX paths, and a native
|
|
832
|
+
separator on Windows would turn this false green into a false red.
|
|
833
|
+
Nested (`reqId -> file -> name`) rather than a composite key string, at both
|
|
834
|
+
ends: no separator has to be chosen that a path or a scenario name could
|
|
835
|
+
contain, and `first-run.json` stays a file a reviewer reads.
|
|
836
|
+
**What the fix cost, and the alternative that was rejected.** Keyed by the
|
|
837
|
+
name on disk, the record broke `--apply`'s resume: step 2 renames
|
|
838
|
+
`x.proposed.spec.ts` to `x.spec.ts`, so a resume after that rename looked up a
|
|
839
|
+
key written under the proposed name, found nothing, and blocked a change whose
|
|
840
|
+
scenarios *had* been observed red — the gate refusing its own evidence, caught
|
|
841
|
+
by `ATX-53`'s two resume scenarios. Rewriting the record beside the rename was
|
|
842
|
+
the obvious repair and is worse: two files that must move together, in a
|
|
843
|
+
design whose entire resume story is that each step is separately idempotent.
|
|
844
|
+
So the record keys by the **merged** spelling from the start, which the rename
|
|
845
|
+
cannot move. It still separates two spec files, which is the collapse the key
|
|
846
|
+
exists to prevent; what it deliberately does not separate is a file from its
|
|
847
|
+
own proposed name, because those are the same file.
|
|
848
|
+
Not claimed: that a scenario name is unique within one file. It need not be,
|
|
849
|
+
and this key does not make two same-named scenarios in one file
|
|
850
|
+
distinguishable. What it closes is the cross-*file* case, which is the one
|
|
851
|
+
reachable by ordinary means — two authors adding a scenario to the same
|
|
852
|
+
requirement from two files is how a requirement grows, while a duplicate name
|
|
853
|
+
inside one file is visible in the file being read.
|
|
854
|
+
Measured on Linux; **Windows is unmeasured**, and the path-shaped half of the
|
|
855
|
+
fix is exactly what differs there.
|
|
856
|
+
|
|
857
|
+
- **An error class was being used as a formatter, at two sites that never threw
|
|
858
|
+
it.** `locate.ts` and `static-registry.ts` each constructed a
|
|
859
|
+
`RegistryValidationError` purely to borrow its `.message`, because both report
|
|
860
|
+
the same `registry-invalid` as the throwing path and the message has to be
|
|
861
|
+
identical rather than merely similar. The formatter is what those sites wanted
|
|
862
|
+
and it is now the thing they can ask for — `registryValidationMessage` in the
|
|
863
|
+
new `core/registry-issues.ts`, which all three sites share. That sharing is
|
|
864
|
+
what makes the messages structurally identical instead of a coincidence
|
|
865
|
+
maintained by hand.
|
|
866
|
+
The module is separate from `registry.ts` rather than added to it because
|
|
867
|
+
`./define` maps to the whole of `registry.ts`, so everything that file exports
|
|
868
|
+
is a public contract. The issue *type* has to be public; the formatter does
|
|
869
|
+
not, and both of its callers are internal.
|
|
870
|
+
|
|
871
|
+
### Added
|
|
872
|
+
|
|
873
|
+
- **`tests/registry-error-types.spec-d.ts` pins the shape that was just
|
|
874
|
+
narrowed.** The property is entirely type-level — it has no runtime footprint,
|
|
875
|
+
and the engine reads registries from the AST — so a `.spec-d.ts` is the only
|
|
876
|
+
thing that can catch a regression in it, and a green suite would otherwise
|
|
877
|
+
carry one through to users untouched. It pins both halves: that the element
|
|
878
|
+
type is exactly the two fields (`toEqualTypeOf`, so a future edit widening it
|
|
879
|
+
back to a union fails), and that a real parse failure still assigns to it with
|
|
880
|
+
no cast. Confirmed to go red on the exact regression it exists to catch by
|
|
881
|
+
reverting the field to `z.ZodIssue[]` and re-running it.
|
|
882
|
+
|
|
16
883
|
## [0.7.4] - 2026-08-08
|
|
17
884
|
|
|
18
885
|
### Added
|
|
@@ -3875,15 +4742,139 @@ checked.** The discipline aims at keeping the *argument*, not at re-testing the
|
|
|
3875
4742
|
evidence under it, so the first act of building one is re-measuring what it
|
|
3876
4743
|
claims rather than implementing what it proposes. That has paid three times:
|
|
3877
4744
|
once killing an entry outright, when the spans it rested on turned out not to be
|
|
3878
|
-
spans;
|
|
3879
|
-
a call-site count that a regex had inflated,
|
|
3880
|
-
diagnostic" that the corpus put at one fixture
|
|
4745
|
+
spans; four times correcting an entry's own numbers or scope before a line of it
|
|
4746
|
+
was written — a call-site count that a regex had inflated, a cost filed as "a
|
|
4747
|
+
fixture per diagnostic" that the corpus put at one fixture, an injection whose
|
|
4748
|
+
payload turned out to need the target file's *own* quote character, which is the
|
|
4749
|
+
difference between escaping one quote and escaping the right one, and a fix
|
|
4750
|
+
filed as a map over `issues` that the report's own shape moved to the
|
|
4751
|
+
serialiser, four fields and one nesting level further out. **What a release costs its adopters is not known on the day it is cut**
|
|
3881
4752
|
— several entries arrived from one adoption report, about shapes the tool made
|
|
3882
4753
|
someone work around without ever erroring. And **the fixes with no observable
|
|
3883
4754
|
symptom are worth batching**: individually none justifies a commit, together they
|
|
3884
4755
|
cost about an hour, and they are the group no gate could have found, since having
|
|
3885
4756
|
no symptom is the definition of what a gate cannot see.
|
|
3886
4757
|
|
|
4758
|
+
*The five entries this section carried before the ones below* were built and are
|
|
4759
|
+
recorded under `[0.8.0]`. The second is where this preamble's own
|
|
4760
|
+
discipline paid again: it went in on an entry whose headline type
|
|
4761
|
+
measurement — instantiations unchanged — was false on re-measure, and the
|
|
4762
|
+
re-measure is the only reason the release notes do not repeat it. The fourth is
|
|
4763
|
+
the other outcome the discipline has, and worth a line because the first three
|
|
4764
|
+
were all corrections: the `render --out` symlink measurement reproduced exactly
|
|
4765
|
+
as filed, down to the leaf being safe for the reason given. What re-measuring
|
|
4766
|
+
added was not a correction but a **surface the entry had not counted** —
|
|
4767
|
+
`--check` reads the same destination, so the same link makes a freshness gate
|
|
4768
|
+
date a document outside the project. Re-measuring an entry is therefore not only
|
|
4769
|
+
a test of the number it quotes; walking the code the claim is about is what
|
|
4770
|
+
shows the claim's own edges. The fifth is that lesson arriving where an entry
|
|
4771
|
+
had already named its own fix: the C1 measurement reproduced exactly, and the
|
|
4772
|
+
repair it proposed — a map over `issues` — was still the wrong half, because
|
|
4773
|
+
four more fields and a nested scenario name carry the same untrusted text and
|
|
4774
|
+
never pass the envelope. An entry that ends with a fix is the one least likely
|
|
4775
|
+
to have its fix re-examined, which is the case worth naming.
|
|
4776
|
+
|
|
4777
|
+
*That has now happened twice, so it is a pattern rather than an incident.* The
|
|
4778
|
+
extensionless-import entry likewise reproduced exactly on its reported half and
|
|
4779
|
+
was wrong about its own repair — it recorded that the writing half needed
|
|
4780
|
+
nothing, and the writing half was guarded by a condition that was correct only
|
|
4781
|
+
while the reading half stayed broken. Both times the error had the same shape: an
|
|
4782
|
+
entry reasons about the code path it can see failing, and the repair makes a
|
|
4783
|
+
second path reachable that nobody has looked at because nothing could reach it.
|
|
4784
|
+
So the re-measure that matters is not "does the reported symptom reproduce" —
|
|
4785
|
+
both did, exactly — but **what does the proposed fix make reachable for the first
|
|
4786
|
+
time**.
|
|
4787
|
+
|
|
4788
|
+
*The first time that question was asked in advance, it paid, and how it paid is
|
|
4789
|
+
worth a line because it is not what the two cases above would predict.* The
|
|
4790
|
+
`check` `empty-spec` entry — `[Unreleased]`, the third entry to reproduce exactly
|
|
4791
|
+
on its reported half — was not wrong about its own repair. Asking the question
|
|
4792
|
+
anyway found the defect one step to the side: the fix it proposed was correct,
|
|
4793
|
+
and the *obvious implementation of it* — make `check` agree with `verify` —
|
|
4794
|
+
would have copied a misdiagnosis `verify` had been emitting unnoticed, because
|
|
4795
|
+
the reference implementation an entry points at is not itself re-read. So the
|
|
4796
|
+
question generalises past the shape it was learned from. It is not only about a
|
|
4797
|
+
path the repair makes reachable; it is about **everything the repair would make
|
|
4798
|
+
`check` agree with**, and an entry that names the right fix can still be
|
|
4799
|
+
implemented wrongly by copying the half nobody has audited.
|
|
4800
|
+
|
|
4801
|
+
- **Trusted publishing, which would delete `NPM_TOKEN` rather than add to it.**
|
|
4802
|
+
The provenance work in `[0.7.2]` signs the release but does not change
|
|
4803
|
+
what authorises it: a long-lived automation token, masked and protected, that
|
|
4804
|
+
publishes as us to anyone holding it. npm's trusted publishing exchanges the
|
|
4805
|
+
same OIDC identity the attestation already uses for a short-lived credential,
|
|
4806
|
+
which would remove the stored secret entirely and grant provenance as a
|
|
4807
|
+
property of the flow rather than a flag on the command. The deletion test
|
|
4808
|
+
points the right way for once: what it removes is the only long-lived
|
|
4809
|
+
credential this repository has.
|
|
4810
|
+
**The blocker this entry was filed with is retired, and it was the wrong
|
|
4811
|
+
question** — checked against npm's documentation on 2026-08-08. It asked
|
|
4812
|
+
whether npm's trusted publishers accept a **gitlab.com-hosted** project on the
|
|
4813
|
+
plan this package publishes under, and both halves come back clean: GitLab
|
|
4814
|
+
CI/CD is a first-class provider alongside GitHub Actions, and no plan
|
|
4815
|
+
requirement is stated anywhere in the documentation. The four fields it wants
|
|
4816
|
+
are `Pseudorca`, `attest`, the top-level CI file path (`.gitlab-ci.yml`, and it
|
|
4817
|
+
must carry the extension) and the allowed actions; environment name is optional
|
|
4818
|
+
and this project uses no GitLab environments. Shared runners are the only ones
|
|
4819
|
+
supported and the publish job carries no `tags:`, so it is already on them. The
|
|
4820
|
+
`repository.url` cross-check needs no separate verification — provenance
|
|
4821
|
+
already passes it with the same identity.
|
|
4822
|
+
Two things the entry did not anticipate, both of which change the shape of the
|
|
4823
|
+
change rather than blocking it. **`SIGSTORE_ID_TOKEN` is not replaced**: the
|
|
4824
|
+
documented GitLab snippet declares `NPM_ID_TOKEN` with
|
|
4825
|
+
`aud: "npm:registry.npmjs.org"` *beside* it, so this is an addition to the
|
|
4826
|
+
`id_tokens` block, not a swap. And **the YAML is not what deletes the
|
|
4827
|
+
credential** — enabling a trusted publisher leaves the automation token just as
|
|
4828
|
+
able to publish; what retires it is `Require two-factor authentication and
|
|
4829
|
+
disallow tokens` on the package, which npm states does not affect trusted
|
|
4830
|
+
publishers. That setting is the one that closes the loop this job's own comment
|
|
4831
|
+
opens, where an automation token is used *because* a granular token fails
|
|
4832
|
+
against 2FA on writes: trusted publishing does not bypass the prompt, it stops
|
|
4833
|
+
being a token.
|
|
4834
|
+
*What blocks the decision now is `pnpm`, and it is not a version to wait for.*
|
|
4835
|
+
**pnpm does not implement the OIDC exchange at all.** `pnpm/pnpm#11526` — the
|
|
4836
|
+
fix for the pnpm 11 regression that made this look like a supported path —
|
|
4837
|
+
shows what its "OIDC support" is: `actions/setup-node` writes
|
|
4838
|
+
`_authToken=${NODE_AUTH_TOKEN}` into `.npmrc`, pnpm used to pass the unresolved
|
|
4839
|
+
placeholder through verbatim, and the fix treats it as empty so that **npm's**
|
|
4840
|
+
credential exchange can be the sole auth source. pnpm gets out of the way; npm
|
|
4841
|
+
does the work. That matches npm's own wording, which requires publishing
|
|
4842
|
+
through npm directly or indirectly, and it matches the reports: every account
|
|
4843
|
+
of pnpm publishing under OIDC is GitHub Actions, and there is **no record of
|
|
4844
|
+
pnpm + GitLab + OIDC succeeding**. Stated at the strength the evidence carries:
|
|
4845
|
+
this is a strong negative inference, not a documented refusal.
|
|
4846
|
+
So the real cost is a tooling swap — `pnpm publish` back to `npm publish` in
|
|
4847
|
+
this one job — and it is a swap this file has already priced once in the other
|
|
4848
|
+
direction. The comment above records that moving to pnpm took
|
|
4849
|
+
`COREPACK_ENABLE_STRICT` with it, that override having existed only so a bare
|
|
4850
|
+
`npm` could run under a `packageManager` pinning pnpm; going back re-adds it.
|
|
4851
|
+
It also needs npm ≥ 11.5.1 where `node:22-slim` ships 10.x, so the job gains an
|
|
4852
|
+
npm upgrade step. None of that is prohibitive. It is simply a different
|
|
4853
|
+
decision from the one this entry was filed as, which is why it stays here.
|
|
4854
|
+
*One correction to the entry's own risk framing.* "It cannot be tested except
|
|
4855
|
+
by publishing" is half wrong: registering a trusted publisher on npmjs.com is
|
|
4856
|
+
free and reversible, and `NPM_TOKEN` can stay in place while it is tried. What
|
|
4857
|
+
decides whether this can be rolled out incrementally is whether a failed OIDC
|
|
4858
|
+
exchange falls back to the `_authToken` already in `.npmrc` or fails the
|
|
4859
|
+
publish outright — unmeasured, and the thing to establish before touching the
|
|
4860
|
+
job.
|
|
4861
|
+
*Method note, because it is the reusable part.* The pinned pnpm bundle was the
|
|
4862
|
+
obvious place to look and was the wrong one — `pnpm publish --help` on 10.28.0
|
|
4863
|
+
omits the flags it accepts, exactly as this job's comment already records for
|
|
4864
|
+
`--provenance`, so neither the help text nor the shipped bundle would have
|
|
4865
|
+
answered it. The answer was in the upstream pull request.
|
|
4866
|
+
**Promoted from `Under consideration` on 2026-08-08**, and what moved it is
|
|
4867
|
+
that the question it was filed as is answered. It asked whether npm's trusted
|
|
4868
|
+
publishers would accept this project at all; they will. What is left is a
|
|
4869
|
+
known tooling swap with a priced cost, which is a decision to build rather
|
|
4870
|
+
than a question — the preamble's own line, *a decision with an argument
|
|
4871
|
+
attached*. One thing stays unmeasured and is the first act of building it, per
|
|
4872
|
+
the discipline three paragraphs up: whether a failed OIDC exchange falls back
|
|
4873
|
+
to the `_authToken` already in `.npmrc` or fails the publish outright. That
|
|
4874
|
+
decides whether this rolls out incrementally beside the existing token or in
|
|
4875
|
+
one cut, and it is cheap to establish because registering a trusted publisher
|
|
4876
|
+
is free and reversible.
|
|
4877
|
+
|
|
3887
4878
|
## Under consideration
|
|
3888
4879
|
|
|
3889
4880
|
Candidates with **no decision yet**, between the two sections either side of it
|
|
@@ -3896,51 +4887,6 @@ deletion. What each entry has to carry is the evidence currently blocking the
|
|
|
3896
4887
|
decision, because that is what a re-proposal a year from now would otherwise
|
|
3897
4888
|
have to gather again.
|
|
3898
4889
|
|
|
3899
|
-
- **Trusted publishing, which would delete `NPM_TOKEN` rather than add to it.**
|
|
3900
|
-
The provenance work in `[0.7.2]` signs the release but does not change
|
|
3901
|
-
what authorises it: a long-lived automation token, masked and protected, that
|
|
3902
|
-
publishes as us to anyone holding it. npm's trusted publishing exchanges the
|
|
3903
|
-
same OIDC identity the attestation already uses for a short-lived credential,
|
|
3904
|
-
which would remove the stored secret entirely and grant provenance as a
|
|
3905
|
-
property of the flow rather than a flag on the command. The deletion test
|
|
3906
|
-
points the right way for once: what it removes is the only long-lived
|
|
3907
|
-
credential this repository has.
|
|
3908
|
-
*What blocks the decision, and it is evidence rather than principle.* Whether
|
|
3909
|
-
npm's trusted publishers accept a **gitlab.com-hosted** project on the plan
|
|
3910
|
-
this package publishes under has not been verified against this project — it
|
|
3911
|
-
was not checked when the provenance job was written, and an entry claiming it
|
|
3912
|
-
works would be exactly the unverified confidence the rest of this file avoids.
|
|
3913
|
-
It also cannot be tested except by publishing: as with provenance, there is no
|
|
3914
|
-
dry run, so a failed experiment is a release. The cheap next step is reading
|
|
3915
|
-
npm's current trusted-publisher documentation for GitLab support before
|
|
3916
|
-
writing any YAML.
|
|
3917
|
-
|
|
3918
|
-
- **Bounding the fan-out in `findFiles` and `parseSpecs`.** Both walk the
|
|
3919
|
-
project with no concurrency limit: `findFiles` recurses through
|
|
3920
|
-
`Promise.all(subdirs.map(walk))`, so every directory in the tree is opened at
|
|
3921
|
-
once, and `parseSpecs` does `Promise.all(files.map(readFile))`, which reads
|
|
3922
|
-
every spec file into memory before parsing any of them. Neither is bounded by
|
|
3923
|
-
anything, and `check` is the command this repository tells people to run first
|
|
3924
|
-
in a pipeline on an untrusted fork MR — so the input size is not ours to
|
|
3925
|
-
assume.
|
|
3926
|
-
*The evidence, and why it is not enough to act on.* Measured on a synthetic
|
|
3927
|
-
tree of 6000 spec files: the fan-out is real and unbounded — peak in-flight
|
|
3928
|
-
`readFile` calls came back as exactly 6000 — and `attest check` finished in
|
|
3929
|
-
1.99 s without erroring. It could not be made to fail. Windows is why: Node
|
|
3930
|
-
uses Win32 handles there rather than POSIX descriptors, so the `ulimit -n` of
|
|
3931
|
-
3200 that the shell reports does not govern the process, and the one platform
|
|
3932
|
-
where descriptor exhaustion is plausible is the one CI runs and this
|
|
3933
|
-
measurement could not reach (`CLAUDE.md`'s two-platform rule, pointing the
|
|
3934
|
-
unusual way round). What *is* portable is the memory: `parseSpecs` holds every
|
|
3935
|
-
source at once, which at a realistic 8 KiB per spec file is ~47 MiB at this
|
|
3936
|
-
count — noticeable, not fatal.
|
|
3937
|
-
So the fix is known and cheap — a concurrency limit of ~32 at both sites, and
|
|
3938
|
-
parsing each source as it arrives rather than after all of them, which makes
|
|
3939
|
-
the peak constant — and it is held back by this file's own standard: no report,
|
|
3940
|
-
and no demonstrated failure on any platform. Recorded here so that the day
|
|
3941
|
-
someone hits an `EMFILE` from `attest check`, the diagnosis is already written
|
|
3942
|
-
down rather than rediscovered.
|
|
3943
|
-
|
|
3944
4890
|
- **An assertion that recomputes its expectation from the param the code under
|
|
3945
4891
|
test just read is a tautology, and nothing says so.** Reported by
|
|
3946
4892
|
`mine-capablanca`, which probed it by mutating the registry and running the
|
|
@@ -4116,115 +5062,6 @@ have to gather again.
|
|
|
4116
5062
|
out below* — the entry is kept whole here because the two were measured in one
|
|
4117
5063
|
pass and separating them is the finding.
|
|
4118
5064
|
|
|
4119
|
-
- **Whether `check` and `verify` should refuse a half-loaded registry the way
|
|
4120
|
-
`cover` and `render` do.** Those two return early on `hasError(loadIssues)`,
|
|
4121
|
-
and the argument is recorded at both call sites: a report computed from a
|
|
4122
|
-
half-loaded registry is a lie, and a lie that reads as a pass is the worst
|
|
4123
|
-
shape it can take — `cover` in particular used to answer `ok: true` on a repo
|
|
4124
|
-
whose intent layer had stopped being readable, because an empty registry is
|
|
4125
|
-
fully covered. `check` and `verify` carry on regardless.
|
|
4126
|
-
*What that produces, measured* on a project with one unreadable `*.reqs.ts`:
|
|
4127
|
-
a `registry-invalid`, then an `orphan-test` for **every** scenario belonging to
|
|
4128
|
-
that file's requirements, plus `empty-spec`, `tests-red` and a
|
|
4129
|
-
`declared-not-run` per scenario — six derived findings from one cause. Worse,
|
|
4130
|
-
`orphan-test`'s fix hint ("Add it to the registry, or fix the id") is *wrong*
|
|
4131
|
-
for an id already sitting in the registry that failed to load, so the loudest
|
|
4132
|
-
advice in the report points at work that must not be done. In a multi-file
|
|
4133
|
-
registry it scales with the broken file's scenario count, and the one true
|
|
4134
|
-
finding is at the top where a wall of red pushes it out of view.
|
|
4135
|
-
*Why it is not simply the same call as `cover` and `render`.* Each of those
|
|
4136
|
-
produces exactly **one** artifact, and refusing to produce it is the whole of
|
|
4137
|
-
the decision. `check` is the command a pipeline runs first and its contract is
|
|
4138
|
-
breadth — returning three findings instead of nine because one of the nine was
|
|
4139
|
-
a load failure makes it quieter about problems it can still see perfectly well,
|
|
4140
|
-
which is the opposite of what it is for. `verify` has the same shape with a run
|
|
4141
|
-
attached.
|
|
4142
|
-
*What blocks the decision.* The narrower move — keep reporting everything
|
|
4143
|
-
except the findings **derived from** the missing registry, i.e. suppress
|
|
4144
|
-
`orphan-test` for ids whose prefix belongs to a file that failed to load —
|
|
4145
|
-
needs a prefix→file mapping for a file that never parsed, and that is exactly
|
|
4146
|
-
the file `loadRegistry` has no ids from. Recovering the mapping means reading
|
|
4147
|
-
the prefix out of something other than the registry's own keys (the file name,
|
|
4148
|
-
or a partial AST read that survives the failure), and whether either is
|
|
4149
|
-
trustworthy enough to gate a suppression on is unmeasured. Suppressing on the
|
|
4150
|
-
weaker signal "some registry file failed to load, so suppress all
|
|
4151
|
-
`orphan-test`" is the other candidate and is a strictly worse `check` on a
|
|
4152
|
-
multi-file registry, where the other files are fine and their orphans are real.
|
|
4153
|
-
**The wrong *advice* was separated out and fixed in `[0.7.0]`; the noise is
|
|
4154
|
-
fixed under `[Unreleased]`, and the blocker above was the wrong question.**
|
|
4155
|
-
The mapping this entry called for could not be recovered, and did not have to
|
|
4156
|
-
be: what a suppression needs is not the broken file's *prefix* but the ids it
|
|
4157
|
-
declares, and those are written in its source. Reading them back is a source
|
|
4158
|
-
read that returns ids and builds nothing, so the failure modes that matter —
|
|
4159
|
-
a truncated file, a missing default export, a non-literal value, a
|
|
4160
|
-
schema-invalid entry, a module that throws at import — all give them up. The
|
|
4161
|
-
six derived findings from one cause are now two ERRORs and one WARNING, and
|
|
4162
|
-
the collapse is per id rather than per prefix, which is strictly more precise
|
|
4163
|
-
than what was blocked: a genuine typo sharing a prefix with the broken file
|
|
4164
|
-
stays reported. **The lesson is the entry, not the fix.** It sat here for two
|
|
4165
|
-
releases behind a blocker stated as "the mapping is unavailable", which was
|
|
4166
|
-
true and was about a mapping nothing needed; the evidence it carried was about
|
|
4167
|
-
the right problem and the mechanism it proposed was not, and nothing rereads a
|
|
4168
|
-
blocker that has already been written down. That is the same failure the
|
|
4169
|
-
section's own preamble records about a figure nobody re-checks, one level up:
|
|
4170
|
-
the *shape* of the proposal goes stale as quietly as its numbers do.
|
|
4171
|
-
*What is left of this entry is only the original question* — whether `check`
|
|
4172
|
-
and `verify` should refuse a half-loaded registry outright, the way `cover`
|
|
4173
|
-
and `render` do — **and the evidence that motivated it has been spent.** The
|
|
4174
|
-
argument for refusing was never the load failure itself; it was the wall of
|
|
4175
|
-
derived findings burying it, and that wall is gone. What remains is the
|
|
4176
|
-
breadth argument recorded above, which points the other way for both commands.
|
|
4177
|
-
It stays here rather than moving down because "the reason to do it was
|
|
4178
|
-
removed" is not the same as "it was decided against", and the case would come
|
|
4179
|
-
back the moment another finding is found to scale with a broken registry
|
|
4180
|
-
rather than with the number of problems.
|
|
4181
|
-
|
|
4182
|
-
- **A statement that quantifies over a surface, with scenarios covering only the
|
|
4183
|
-
part that existed when it was written.** Found by reading, and it had already
|
|
4184
|
-
cost something: `ATX-37` says "**everything** the CLI writes to a terminal —
|
|
4185
|
-
a report, and the stack of a crash — MUST replace every control character",
|
|
4186
|
-
and `self/report.spec.ts` covers `formatIssues` and `formatCrash`. Those were
|
|
4187
|
-
the two functions that existed when it was written. `render` builds a document
|
|
4188
|
-
by concatenation that never went past the sanitiser, and a registry could drive
|
|
4189
|
-
the reviewer's terminal from `attest render` with no flag at all — shipped
|
|
4190
|
-
under `[0.7.0]` as the fix, but the *shape* is not fixed and is not
|
|
4191
|
-
specific to that requirement.
|
|
4192
|
-
The shape: a universal quantifier in the statement, a finite set of scenarios,
|
|
4193
|
-
and nothing anywhere that holds the two together. It reads as covered — the id
|
|
4194
|
-
has scenarios, `cover` is green, `check` is green — and the quantifier is the
|
|
4195
|
-
part no gate is looking at. Every one of the three mechanisms this project
|
|
4196
|
-
sells operates *below* it: coverage asks whether an id has a scenario,
|
|
4197
|
-
never-red asks whether that scenario discriminated, drift asks whether it reads
|
|
4198
|
-
the params. None asks whether the scenarios span what the sentence claims.
|
|
4199
|
-
*What blocks the decision.* Detecting it means knowing which surface a
|
|
4200
|
-
statement names, which is prose judgement — the same judgement
|
|
4201
|
-
`tests/intent-rule-candidates.spec.ts` records as the reason obligation
|
|
4202
|
-
counting can never be an engine rule ("asserting a ground truth Attest cannot
|
|
4203
|
-
compute for someone else's requirements, which is exactly what §0 refuses").
|
|
4204
|
-
So the shippable form may not exist.
|
|
4205
|
-
**The repo half has shipped, and this entry is now only about the engine
|
|
4206
|
-
half.** `[0.7.0]` adds the `QUANTIFIED` table it described — hand-judged,
|
|
4207
|
-
a row per flagged statement, every named scenario asserted to exist — and it
|
|
4208
|
-
closed the way an entry built from a strict read is supposed to: it found a
|
|
4209
|
-
live hole on `ATX-62`, the one requirement written specifically to avoid this
|
|
4210
|
-
shape, where the archive gate path was claimed by the statement and asserted by
|
|
4211
|
-
nothing. Two things came back that the entry did not predict. The useful one is
|
|
4212
|
-
that "names a surface" splits three ways rather than two — `values`, `roster`,
|
|
4213
|
-
`sites` — and only `sites` carries the risk, because a `roster` scenario
|
|
4214
|
-
iterates the set the code owns and grows with it. That is a sharper statement
|
|
4215
|
-
of the defect than "the scenarios do not span the sentence", and it is a rule
|
|
4216
|
-
an author can act on: **prefer a quantifier a scenario can iterate.** The other
|
|
4217
|
-
is that the remedy — rewriting the statement to name its surfaces, as `ATX-62`
|
|
4218
|
-
did — takes it out of the flagged set, so the table has to allow rows for ids
|
|
4219
|
-
the detector does not flag or it loses the binding exactly when the author does
|
|
4220
|
-
the right thing.
|
|
4221
|
-
*What stays open is the engine half, and nothing above touches it.* The three
|
|
4222
|
-
kinds are prose judgement, the detector misses the article form ("A registry
|
|
4223
|
-
that cannot be read MUST …") which is a universal too, and no rule can tell
|
|
4224
|
-
which surface a sentence claims without being told. The repo half is a
|
|
4225
|
-
counter-pressure, not a detector, and it holds for one registry whose authors
|
|
4226
|
-
agreed to maintain it.
|
|
4227
|
-
|
|
4228
5065
|
- **Whether a requirement can ever be retired, and what it costs that none can.**
|
|
4229
5066
|
The registry is at **69 requirements and 192 scenarios, 2.78 each**, and
|
|
4230
5067
|
every one is a permanent obligation: `uncovered-requirement` is an ERROR, so a
|
|
@@ -4272,6 +5109,138 @@ have to gather again.
|
|
|
4272
5109
|
falling, which is the premise the whole entry rests on, and it is the first
|
|
4273
5110
|
time this file has had two measurements of it to compare.
|
|
4274
5111
|
|
|
5112
|
+
- **Running `verify` over a subset of the scenarios, and what a partial run may
|
|
5113
|
+
be allowed to say.** `verify` is all-or-nothing and `check` executes nothing,
|
|
5114
|
+
so there is no middle. Reported by an adoption where `verify` takes **7m33s**
|
|
5115
|
+
wall clock — three requirements are strength gates that play twelve long games
|
|
5116
|
+
each — with the consequence stated as a change in *when* the tool gets used:
|
|
5117
|
+
`verify` became a run-at-the-end command, and `archive` became the everyday
|
|
5118
|
+
one, paying the full suite on every invocation because its verdict was the one
|
|
5119
|
+
needed. The ask is a selector — `--only`/`--skip` over ids or tags, or simply
|
|
5120
|
+
honouring a Vitest name filter — with the partial scope reported loudly enough
|
|
5121
|
+
that a subset pass cannot be read as a pass.
|
|
5122
|
+
*The cost is not Attest's, and that is the first thing the decision turns on.*
|
|
5123
|
+
Every expensive scenario in that report is behavioural: the engine's own
|
|
5124
|
+
overhead is not what makes 7m33s. So this is a selector over someone else's
|
|
5125
|
+
suite, and Vitest already has one — the question is whether Attest reporting
|
|
5126
|
+
*over* a filtered run is worth owning, not whether the run can be filtered.
|
|
5127
|
+
*What blocks it — three, and they are about the report rather than the run.*
|
|
5128
|
+
**(1)** Coverage is computed against the whole registry: under a filter every
|
|
5129
|
+
unselected requirement has no scenario that ran, which is `uncovered-requirement`
|
|
5130
|
+
and `declared-not-run` firing on requirements that are fine. Either those codes
|
|
5131
|
+
gain a meaning that depends on which flags the run carried — and a `code` is
|
|
5132
|
+
the one thing in a report consumers are told to branch on — or the report has to carry its scope
|
|
5133
|
+
explicitly and the codes have to be computed within it. **(2)** `archive`'s
|
|
5134
|
+
gate cannot accept a partial run at all: "done" having a hard definition is the
|
|
5135
|
+
claim the tool is for. So the flag has to be refused there, which means two
|
|
5136
|
+
shapes of `verify` and a rule for which commands may carry it. **(3)** `--json`
|
|
5137
|
+
is where a partial run does its damage — a consumer branching on `ok` must not
|
|
5138
|
+
read a subset pass as a pass, so this needs a field naming the scope and a
|
|
5139
|
+
decision about `SCHEMA_VERSION`, and that is the cheapest part to price first
|
|
5140
|
+
because it decides whether the feature is additive at all.
|
|
5141
|
+
*What would move it.* A second report of the same shape, ideally one where the
|
|
5142
|
+
slow half is smaller, so the question is a selector's ergonomics rather than
|
|
5143
|
+
one project's gates; and a scored answer to whether a scope-carrying report can
|
|
5144
|
+
be stated in a way that a `--json` consumer written before it cannot misread.
|
|
5145
|
+
**Measured on 2026-08-12, before building anything, per this section's
|
|
5146
|
+
discipline. The second of those two is now answered, and the answer is a
|
|
5147
|
+
refusal; the first blocker was wrong; and the whole feature turns out to be a
|
|
5148
|
+
different, much cheaper one.**
|
|
5149
|
+
*The cost claim reproduces on a second corpus.* `verify self`: the static half
|
|
5150
|
+
— scan, registry load, `parseSpecs` — is **79 ms of 30,539 ms, 0.26%**. So a
|
|
5151
|
+
selector cannot save engine work anywhere; it can only save scenarios, and the
|
|
5152
|
+
entry was right that this is a selector over someone else's suite.
|
|
5153
|
+
*Blocker **(1)** conflates two codes with different inputs, and no arrangement
|
|
5154
|
+
fires both.* `uncovered-requirement` reads registry × plan and is purely
|
|
5155
|
+
static; `declared-not-run` reads plan × runtime coverage. Measured over 77
|
|
5156
|
+
requirements and 214 scenarios, selecting one requirement: filtering the **run
|
|
5157
|
+
scope** while leaving the plan whole gives 209–213 spurious `declared-not-run`
|
|
5158
|
+
and **zero** `uncovered-requirement`; filtering the **plan** gives 76
|
|
5159
|
+
`uncovered-requirement` and **zero** `declared-not-run`. The choice of where
|
|
5160
|
+
the filter goes decides which one misfires, and the entry priced a case that
|
|
5161
|
+
cannot occur.
|
|
5162
|
+
*And on the run-scope arrangement it does not misfire at all, which is the
|
|
5163
|
+
finding that collapses the rest.* A run that did not execute a declared
|
|
5164
|
+
scenario really has not attested it, so `declared-not-run` is **true** under a
|
|
5165
|
+
filter — only its message guesses the wrong cause ("skipped, or excluded by an
|
|
5166
|
+
`.only`?"). The premise under "the codes have to be computed within [the
|
|
5167
|
+
scope]" is therefore false: the report is already honest, and a subset run is
|
|
5168
|
+
already loudly not a pass. 209 ERRORs on a one-requirement filter is the
|
|
5169
|
+
loudness the entry asked to build.
|
|
5170
|
+
*Blocker **(3)**, scored, comes back **impossible rather than expensive**.* The
|
|
5171
|
+
contract `cli-reference.md` publishes to consumers is that "a consumer can
|
|
5172
|
+
always parse stdout and branch on `ok`"; the next paragraph describes
|
|
5173
|
+
`schemaVersion` as a producer-side promise — what the emitter bumps — and
|
|
5174
|
+
checked across the whole tree rather than that page, every other mention of it
|
|
5175
|
+
is producer-side too, `ATX-5` included, which obliges the report to *declare*
|
|
5176
|
+
it. Nothing tells a consumer to gate on it before trusting `ok`. A consumer
|
|
5177
|
+
written before a `scope` field does not read new fields — that is what makes it
|
|
5178
|
+
a consumer written before them — so no
|
|
5179
|
+
additive shape can protect it, and a `SCHEMA_VERSION` bump is a signal to a
|
|
5180
|
+
reader who is not looking. Exactly one design is safe: a subset run never
|
|
5181
|
+
reports `ok: true`. That is what already happens, for free, by the paragraph
|
|
5182
|
+
above. So the correct answer to "does this need a scope field and a schema
|
|
5183
|
+
decision" is **neither**.
|
|
5184
|
+
*Blocker **(2)** dissolves into that.* `archive` refusing a partial run stops
|
|
5185
|
+
being a rule to design and becomes a consequence: a partial run cannot be
|
|
5186
|
+
`ok`, and the gate already refuses what is not.
|
|
5187
|
+
*What the measurement also shows is that the feature as asked would not have
|
|
5188
|
+
helped the reporter.* Their report says `archive` became the everyday command
|
|
5189
|
+
**because its verdict was the one needed**. A subset run cannot carry a
|
|
5190
|
+
verdict — that is the whole of blocker (3) — so a selector would have given
|
|
5191
|
+
them a fast non-verdict, which they already have by running Vitest directly.
|
|
5192
|
+
*The argument that survives is one the entry does not make, and it is the
|
|
5193
|
+
strongest available.* The child run is `config: false` — no aliases, no DOM, no
|
|
5194
|
+
plugins — so `npx vitest run one.spec.ts` executes in a **different
|
|
5195
|
+
environment** from the one the verdict comes from, and a green there does not
|
|
5196
|
+
predict `verify`. Owning a selector is the only way to run a subset in Attest's
|
|
5197
|
+
environment. That moves the justification off speed, where Vitest is the
|
|
5198
|
+
incumbent and wins, and onto parity between the fast loop and the verdict,
|
|
5199
|
+
where nothing else can serve.
|
|
5200
|
+
*So the thing to build, if a second report arrives, is not what this entry
|
|
5201
|
+
proposed.* Not a scoped verdict but a **development-loop command**: runs a
|
|
5202
|
+
subset in Attest's environment, reports what did not run as `declared-not-run`
|
|
5203
|
+
because that is true, and exits non-zero always. It claims no pass, so it never
|
|
5204
|
+
touches the impossibility above; it needs no field, no `SCHEMA_VERSION`
|
|
5205
|
+
decision, and no code whose meaning depends on a flag.
|
|
5206
|
+
*A second argument arrived on 2026-08-12, from the author's reason for building
|
|
5207
|
+
`status` rather than from an adoption, and it says a hole exists at all — which
|
|
5208
|
+
is the half the parity argument above does not supply.* `status` was added so
|
|
5209
|
+
that when collaborating with an agent there would be a fast way to know where a
|
|
5210
|
+
change stands without paying `verify` every turn. It delivers that for **stage
|
|
5211
|
+
1 only**, and structurally rather than by omission: its two obligations — has
|
|
5212
|
+
each ADDed id a scenario, has each been recorded failing — are both settled by
|
|
5213
|
+
the end of stage 1 and never move again, so it reads all-`proven` for the whole
|
|
5214
|
+
of stage 2. Stage 2 is the long half, and the workflow document tells the agent
|
|
5215
|
+
to run `attest archive` there every iteration, correctly, because
|
|
5216
|
+
`*.proposed.spec.ts` is deliberately outside `verify`'s scope: the gate is the
|
|
5217
|
+
only Attest command that runs a change's own scenarios at all. So the everyday
|
|
5218
|
+
full-gate run in that adoption report was not a habit — it is what this
|
|
5219
|
+
project's own shipped instructions require.
|
|
5220
|
+
**The cost curve is per-turn, not per-CI-run**, which is what makes this
|
|
5221
|
+
different from the report the entry was filed on. A saving that looks small
|
|
5222
|
+
against one pipeline run is multiplied by every iteration of a conversation, and
|
|
5223
|
+
the agent is the consumer this framework writes a document *for*.
|
|
5224
|
+
*Labelled for what it is:* not the second adoption report this entry asks for,
|
|
5225
|
+
but a structural argument from this repository's own workflow, which is a
|
|
5226
|
+
different kind of evidence — no user was surveyed, and nothing here measures how
|
|
5227
|
+
much stage 2 actually costs anyone. What it does is narrow the thing to build
|
|
5228
|
+
one more notch. The development-loop command above is a **stage-2** tool, and
|
|
5229
|
+
its user is mostly an agent.
|
|
5230
|
+
*The cheap interim was written into the workflow document rather than left for
|
|
5231
|
+
this entry to deliver*, since it needs no engine change: run the proposed spec
|
|
5232
|
+
file directly for the inner loop, keep the gate for the verdict. Measured
|
|
5233
|
+
first — a `*.proposed.spec.ts` runs under a plain Vitest invocation, delta
|
|
5234
|
+
import and all — and it is safe in the one way that matters, because only the
|
|
5235
|
+
gate writes `first-run.json`. It is *not* a substitute, for the parity reason
|
|
5236
|
+
above, and that is why the entry stays open: the interim buys speed by leaving
|
|
5237
|
+
Attest's environment, which is precisely what a real selector would not do.
|
|
5238
|
+
*Still unmeasured, and both need the reporter's repo rather than this one:*
|
|
5239
|
+
whether their fast loop and their verdict actually diverge under `config:
|
|
5240
|
+
false`, which is what decides whether the surviving argument is real for them;
|
|
5241
|
+
and the second report of the same shape, which this measurement does not
|
|
5242
|
+
supply and does not replace.
|
|
5243
|
+
|
|
4275
5244
|
## Considered and rejected
|
|
4276
5245
|
|
|
4277
5246
|
Decisions **not** to build something, kept where they can be found before the
|
|
@@ -4282,6 +5251,327 @@ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
|
|
|
4282
5251
|
sinking one version deeper each time a release was cut above it, and a rejection
|
|
4283
5252
|
filed under a version reads as belonging to it.
|
|
4284
5253
|
|
|
5254
|
+
### Widening `status` to the rest of the gate's static half
|
|
5255
|
+
|
|
5256
|
+
Scoped and decided on 2026-08-12, in the session that gave `status` its two
|
|
5257
|
+
refusals (an input it could not read, and a delta that will not apply). The
|
|
5258
|
+
question behind both was whether `status` should report *everything* about a
|
|
5259
|
+
change that is decidable without running anything — the gate has a static half,
|
|
5260
|
+
and `status` was projecting two of its checks. Four candidates were priced. Two
|
|
5261
|
+
shipped; these two did not, for opposite reasons, and the reasons are worth
|
|
5262
|
+
keeping because the shape of the proposal will recur.
|
|
5263
|
+
|
|
5264
|
+
*`validateStructure`'s ERRORs over the merged plan — already decided, and not
|
|
5265
|
+
here.* The proposal was to project the gate's first check whole: `orphan-test`,
|
|
5266
|
+
duplicate ids, and `uncovered-requirement` for every id rather than only the
|
|
5267
|
+
ADDed ones. Design §8 had settled it before the proposal was written — `status`
|
|
5268
|
+
projects "the *uncovered* half of check 1 and the whole of check 4, both
|
|
5269
|
+
restricted to the ids the change ADDs", and orphans and unbound params are named
|
|
5270
|
+
as **not** projected, "being facts about the whole applied registry rather than
|
|
5271
|
+
progress on an added id". So this was not a new decision to make but an old one
|
|
5272
|
+
the proposal had not read, and the finding is procedural rather than technical:
|
|
5273
|
+
the design document answers scoping questions about `status` that a reading of
|
|
5274
|
+
`status.ts` alone will not. Rebuilding it means changing §8 first and arguing
|
|
5275
|
+
against that sentence, which nothing here does.
|
|
5276
|
+
|
|
5277
|
+
*The proposed-spec findings — the only genuinely new shape, rejected on
|
|
5278
|
+
duplication.* `proposed-spec-unclaimed`, `proposed-spec-name-taken` and
|
|
5279
|
+
`spec-in-change-dir` are static facts about *this change*, and unlike the
|
|
5280
|
+
refusals that shipped they leave the report perfectly computable — every row
|
|
5281
|
+
stays correct. So they are neither progress nor a failure to compute, which is
|
|
5282
|
+
the one case the existing `issues` contract has no room for, and pricing that
|
|
5283
|
+
room was expected to be the expensive part. It never had to be priced: **`check`
|
|
5284
|
+
already reports all three**, and already reads each change's delta to attribute
|
|
5285
|
+
proposed specs, so `status` reporting them would put a second answer to one
|
|
5286
|
+
question in the tree — the arrangement this project takes apart everywhere else,
|
|
5287
|
+
and the reason `declaredNotRunIssues` and `hasError` are each one function. What
|
|
5288
|
+
was built instead is one line: `status`'s closing line now names `check`
|
|
5289
|
+
alongside `archive`. The cost is real and is not hidden — an agent in stage 2
|
|
5290
|
+
runs two commands where it wanted one — and naming it is the whole of the
|
|
5291
|
+
compensation.
|
|
5292
|
+
|
|
5293
|
+
*The consequence worth recording is that the `--json` question dissolved.* The
|
|
5294
|
+
open design decision that had been sequenced ahead of all four candidates was
|
|
5295
|
+
what shape new `status` findings take in the report: whether `issues` widens
|
|
5296
|
+
from "the report could not be computed" to "here are findings", or a second
|
|
5297
|
+
field arrives, and whether either moves `SCHEMA_VERSION`. After these two
|
|
5298
|
+
rejections there is nothing left to decide, because everything that survives is
|
|
5299
|
+
either a refusal — existing `issues` semantics, `ok: false`, no new field — or
|
|
5300
|
+
`check`'s to report. A blocking decision that evaporates once the candidates
|
|
5301
|
+
either side of it are settled is a sequencing error, not a hard question, and
|
|
5302
|
+
that is the transferable part: the shape of a report is decided by what is left
|
|
5303
|
+
to put in it, so the scoping goes first.
|
|
5304
|
+
|
|
5305
|
+
*What would reopen either.* For the first, an argument against design §8's
|
|
5306
|
+
sentence, not a fresh proposal that has not met it. For the second, a measured
|
|
5307
|
+
cost of the two-command loop — the same evidence the `Under consideration` entry
|
|
5308
|
+
on a stage-2 development-loop command is waiting for, and it would arrive from
|
|
5309
|
+
the same place.
|
|
5310
|
+
|
|
5311
|
+
### A trailing-dot-and-space clause in `isSafeChangeName`, for a Windows normalisation that does not happen
|
|
5312
|
+
|
|
5313
|
+
Filed under `Under consideration` on 2026-08-09 and rejected on 2026-08-11 by
|
|
5314
|
+
the measurement it was waiting for. The premise was that Win32 strips trailing
|
|
5315
|
+
dots and spaces from a path component below the API, so `'.. '` — which passes
|
|
5316
|
+
every clause the guard has, being neither `''`, `'.'`, `'..'`, a separator nor
|
|
5317
|
+
NUL — would name `changes/`'s parent, and under `--eval` or `archive` that name
|
|
5318
|
+
reaches a module path that is then executed. The entry recorded that no Linux
|
|
5319
|
+
measurement could answer it and named both outcomes in advance: a fourth clause
|
|
5320
|
+
if it reproduced, or a note about the clauses' character if it did not.
|
|
5321
|
+
|
|
5322
|
+
*It does not reproduce.* Measured on Windows 11 26200, Node v22.23.2, NTFS.
|
|
5323
|
+
`mkdir` of `changes/.. ` creates a **literal directory named `.. `** —
|
|
5324
|
+
`readdir` returns `[".. "]` — and a write through the name lands inside it,
|
|
5325
|
+
leaving a file planted at the project root untouched. End to end through the
|
|
5326
|
+
CLI with a payload delta at the root, all of `status '.. '`, `status '.. '
|
|
5327
|
+
--eval` and `archive '.. '` fail with `change-not-found` naming
|
|
5328
|
+
`changes/.. /requirements.delta.ts`, and the payload never executes. Both
|
|
5329
|
+
readers were exercised deliberately, because they resolve differently and only
|
|
5330
|
+
one of them is the dangerous path: the static read and the Vite loader keep the
|
|
5331
|
+
trailing space alike. `'.. '`, `'.. .'`, `'...'` and `'. '` behave the same.
|
|
5332
|
+
|
|
5333
|
+
*The neighbouring win32-specific vectors were measured in the same pass*, since
|
|
5334
|
+
the guard's clauses are POSIX-shaped and this was the moment to ask what else
|
|
5335
|
+
they cannot see. `'C:'`, `'C:x'` and `'a:b'` pass the guard, and `join` splices
|
|
5336
|
+
no drive: every one stays under `changes/` and fails `ENOENT` at the colon.
|
|
5337
|
+
`'CON'` and `'NUL'` create ordinary directories. `'..x'` and `'x..'` are
|
|
5338
|
+
literal. Nothing escaped, and the root file was intact after all of them.
|
|
5339
|
+
|
|
5340
|
+
**What the entry's own fallback asks to keep, now earned rather than assumed:
|
|
5341
|
+
the clauses are the POSIX spelling of path safety, and on this platform that is
|
|
5342
|
+
the complete spelling.** The guard's doc comment says the test is path safety
|
|
5343
|
+
rather than a character whitelist — that claim survives the measurement, and it
|
|
5344
|
+
survives it on the platform most likely to have broken it.
|
|
5345
|
+
|
|
5346
|
+
*Rejected at the strength the evidence carries.* This is one Windows build and
|
|
5347
|
+
one Node version, so what is refuted is the general premise, not every future
|
|
5348
|
+
libuv. **The reach was always one level** — the name eaten gives the project
|
|
5349
|
+
root, and going above it needs a second `..`, which needs a separator the guard
|
|
5350
|
+
already refuses — so even a reproduction would have meant executing a file
|
|
5351
|
+
elsewhere *inside* the project. That bound is why a negative result closes this
|
|
5352
|
+
rather than merely deferring it: the cost of being wrong is bounded and the
|
|
5353
|
+
reopen condition is cheap, being a single `mkdir` on a machine where it behaves
|
|
5354
|
+
differently.
|
|
5355
|
+
|
|
5356
|
+
### Refusing a half-loaded registry in `check` and `verify`, the way `cover` and `render` do
|
|
5357
|
+
|
|
5358
|
+
`cover` and `render` return early on `hasError(loadIssues)`,
|
|
5359
|
+
and the argument is recorded at both call sites: a report computed from a
|
|
5360
|
+
half-loaded registry is a lie, and a lie that reads as a pass is the worst
|
|
5361
|
+
shape it can take — `cover` in particular used to answer `ok: true` on a repo
|
|
5362
|
+
whose intent layer had stopped being readable, because an empty registry is
|
|
5363
|
+
fully covered. `check` and `verify` carry on regardless.
|
|
5364
|
+
*What that produces, measured* on a project with one unreadable `*.reqs.ts`:
|
|
5365
|
+
a `registry-invalid`, then an `orphan-test` for **every** scenario belonging to
|
|
5366
|
+
that file's requirements, plus `empty-spec`, `tests-red` and a
|
|
5367
|
+
`declared-not-run` per scenario — six derived findings from one cause. Worse,
|
|
5368
|
+
`orphan-test`'s fix hint ("Add it to the registry, or fix the id") is *wrong*
|
|
5369
|
+
for an id already sitting in the registry that failed to load, so the loudest
|
|
5370
|
+
advice in the report points at work that must not be done. In a multi-file
|
|
5371
|
+
registry it scales with the broken file's scenario count, and the one true
|
|
5372
|
+
finding is at the top where a wall of red pushes it out of view.
|
|
5373
|
+
*Why it is not simply the same call as `cover` and `render`.* Each of those
|
|
5374
|
+
produces exactly **one** artifact, and refusing to produce it is the whole of
|
|
5375
|
+
the decision. `check` is the command a pipeline runs first and its contract is
|
|
5376
|
+
breadth — returning three findings instead of nine because one of the nine was
|
|
5377
|
+
a load failure makes it quieter about problems it can still see perfectly well,
|
|
5378
|
+
which is the opposite of what it is for. `verify` has the same shape with a run
|
|
5379
|
+
attached.
|
|
5380
|
+
*What blocks the decision.* The narrower move — keep reporting everything
|
|
5381
|
+
except the findings **derived from** the missing registry, i.e. suppress
|
|
5382
|
+
`orphan-test` for ids whose prefix belongs to a file that failed to load —
|
|
5383
|
+
needs a prefix→file mapping for a file that never parsed, and that is exactly
|
|
5384
|
+
the file `loadRegistry` has no ids from. Recovering the mapping means reading
|
|
5385
|
+
the prefix out of something other than the registry's own keys (the file name,
|
|
5386
|
+
or a partial AST read that survives the failure), and whether either is
|
|
5387
|
+
trustworthy enough to gate a suppression on is unmeasured. Suppressing on the
|
|
5388
|
+
weaker signal "some registry file failed to load, so suppress all
|
|
5389
|
+
`orphan-test`" is the other candidate and is a strictly worse `check` on a
|
|
5390
|
+
multi-file registry, where the other files are fine and their orphans are real.
|
|
5391
|
+
**The wrong *advice* was separated out and fixed in `[0.7.0]`; the noise is
|
|
5392
|
+
fixed under `[Unreleased]`, and the blocker above was the wrong question.**
|
|
5393
|
+
The mapping this entry called for could not be recovered, and did not have to
|
|
5394
|
+
be: what a suppression needs is not the broken file's *prefix* but the ids it
|
|
5395
|
+
declares, and those are written in its source. Reading them back is a source
|
|
5396
|
+
read that returns ids and builds nothing, so the failure modes that matter —
|
|
5397
|
+
a truncated file, a missing default export, a non-literal value, a
|
|
5398
|
+
schema-invalid entry, a module that throws at import — all give them up. The
|
|
5399
|
+
six derived findings from one cause are now two ERRORs and one WARNING, and
|
|
5400
|
+
the collapse is per id rather than per prefix, which is strictly more precise
|
|
5401
|
+
than what was blocked: a genuine typo sharing a prefix with the broken file
|
|
5402
|
+
stays reported. **The lesson is the entry, not the fix.** It sat here for two
|
|
5403
|
+
releases behind a blocker stated as "the mapping is unavailable", which was
|
|
5404
|
+
true and was about a mapping nothing needed; the evidence it carried was about
|
|
5405
|
+
the right problem and the mechanism it proposed was not, and nothing rereads a
|
|
5406
|
+
blocker that has already been written down. That is the same failure the
|
|
5407
|
+
section's own preamble records about a figure nobody re-checks, one level up:
|
|
5408
|
+
the *shape* of the proposal goes stale as quietly as its numbers do.
|
|
5409
|
+
*What is left of this entry is only the original question* — whether `check`
|
|
5410
|
+
and `verify` should refuse a half-loaded registry outright, the way `cover`
|
|
5411
|
+
and `render` do — **and the evidence that motivated it has been spent.** The
|
|
5412
|
+
argument for refusing was never the load failure itself; it was the wall of
|
|
5413
|
+
derived findings burying it, and that wall is gone. What remains is the
|
|
5414
|
+
breadth argument recorded above, which points the other way for both commands.
|
|
5415
|
+
*What rejected it is that the argument for it was entirely the noise, and the
|
|
5416
|
+
noise is gone.* Refusing was never justified by the load failure itself — it was
|
|
5417
|
+
justified by the wall of derived findings burying it, and the collapse under
|
|
5418
|
+
`[Unreleased]` removed the wall without touching either command's breadth. What
|
|
5419
|
+
is left on the table is the breadth argument alone, and it points the other way
|
|
5420
|
+
for both: `check` is the command a pipeline runs first, its contract is seeing
|
|
5421
|
+
everything it can still see, and answering with three findings instead of nine
|
|
5422
|
+
because one of the nine was a load failure makes it quieter about problems it
|
|
5423
|
+
diagnoses perfectly well. `verify` is the same shape with a run attached.
|
|
5424
|
+
|
|
5425
|
+
*What would reopen it*, stated because a rejection whose condition is unwritten
|
|
5426
|
+
gets re-proposed on a hunch: another finding found to scale with a **broken
|
|
5427
|
+
registry** rather than with the number of real problems. That is the shape that
|
|
5428
|
+
justified this once and could again; a merely noisy report is not.
|
|
5429
|
+
|
|
5430
|
+
### An engine rule for a statement whose scenarios do not span its quantifier
|
|
5431
|
+
|
|
5432
|
+
The shape was found by reading, and it had already
|
|
5433
|
+
cost something: `ATX-37` says "**everything** the CLI writes to a terminal —
|
|
5434
|
+
a report, and the stack of a crash — MUST replace every control character",
|
|
5435
|
+
and `self/report.spec.ts` covers `formatIssues` and `formatCrash`. Those were
|
|
5436
|
+
the two functions that existed when it was written. `render` builds a document
|
|
5437
|
+
by concatenation that never went past the sanitiser, and a registry could drive
|
|
5438
|
+
the reviewer's terminal from `attest render` with no flag at all — shipped
|
|
5439
|
+
under `[0.7.0]` as the fix, but the *shape* is not fixed and is not
|
|
5440
|
+
specific to that requirement.
|
|
5441
|
+
The shape: a universal quantifier in the statement, a finite set of scenarios,
|
|
5442
|
+
and nothing anywhere that holds the two together. It reads as covered — the id
|
|
5443
|
+
has scenarios, `cover` is green, `check` is green — and the quantifier is the
|
|
5444
|
+
part no gate is looking at. Every one of the three mechanisms this project
|
|
5445
|
+
sells operates *below* it: coverage asks whether an id has a scenario,
|
|
5446
|
+
never-red asks whether that scenario discriminated, drift asks whether it reads
|
|
5447
|
+
the params. None asks whether the scenarios span what the sentence claims.
|
|
5448
|
+
**Everything this entry established about the shape has left it, and the two
|
|
5449
|
+
halves left by different doors.** The repo half shipped in `[0.7.0]` as the
|
|
5450
|
+
`QUANTIFIED` table it described — hand-judged, a row per flagged statement,
|
|
5451
|
+
every named scenario asserted to exist — and it closed the way an entry built
|
|
5452
|
+
from a strict read is supposed to: it found a live hole on `ATX-62`, the one
|
|
5453
|
+
requirement written specifically to avoid this shape, where the archive gate
|
|
5454
|
+
path was claimed by the statement and asserted by nothing. What that table
|
|
5455
|
+
taught is now **design §11**, both languages, because it is a permanent
|
|
5456
|
+
property of the design rather than a candidate: which surface a quantifier
|
|
5457
|
+
ranges over — `values`, `roster`, `sites` — decides whether scenarios can keep
|
|
5458
|
+
up with it, only `sites` can silently stop being true, and the rule an author
|
|
5459
|
+
can act on is *prefer a quantifier a scenario can iterate*. The adopter-facing
|
|
5460
|
+
half of that rule is in the `init` skill beside the two `params` rules it
|
|
5461
|
+
completes. Neither was predicted here: the entry proposed a detector and
|
|
5462
|
+
expected a two-way split, and what came back was a three-way one and an
|
|
5463
|
+
authoring rule, which is the more useful object and needed no engine at all.
|
|
5464
|
+
The table's own trap is recorded with it — the remedy of rewriting a statement
|
|
5465
|
+
to name its surfaces takes it out of the flagged set, so the table must admit
|
|
5466
|
+
rows for ids nothing flags, or it loses the binding exactly when the author
|
|
5467
|
+
does the right thing.
|
|
5468
|
+
*What stays here is the engine half alone, and it is undecided rather than
|
|
5469
|
+
blocked-with-evidence.* Detecting this means knowing which surface a statement
|
|
5470
|
+
names, which is prose judgement — the same judgement
|
|
5471
|
+
`tests/intent-rule-candidates.spec.ts` records as the reason obligation
|
|
5472
|
+
counting can never be an engine rule ("asserting a ground truth Attest cannot
|
|
5473
|
+
compute for someone else's requirements, which is exactly what §0 refuses").
|
|
5474
|
+
The detector that exists flags the quantifier and nothing more, and it misses
|
|
5475
|
+
the article form ("A registry that cannot be read MUST …") which is a universal
|
|
5476
|
+
too. So the shippable form may not exist, and what §11 ships instead is a
|
|
5477
|
+
counter-pressure that holds for one registry whose authors agreed to maintain
|
|
5478
|
+
it. *What rejected it is a measurement that was already in the repository when this
|
|
5479
|
+
entry was trimmed, and went unread.* The inline snapshot in
|
|
5480
|
+
`tests/intent-rule-candidates.spec.ts` scores the detector against the real
|
|
5481
|
+
corpus: it flags **30 of 70** requirements, and of those 30, **21 are `values`
|
|
5482
|
+
or `roster`** — correct by construction, nothing for an author to do. A shipped
|
|
5483
|
+
WARNING would therefore fire on 43% of this registry and be wrong about 70% of
|
|
5484
|
+
what it fired on, with no way for the author to discharge it except by rewording
|
|
5485
|
+
the statement.
|
|
5486
|
+
|
|
5487
|
+
*And rewording is both the evasion and the legitimate fix*, which is what makes
|
|
5488
|
+
the precision figure fatal rather than merely bad. `ATX-62` was improved by
|
|
5489
|
+
being rewritten to name its surfaces, and that rewrite takes it out of the
|
|
5490
|
+
flagged set by exactly the same mechanism a lazy reword would. The detector
|
|
5491
|
+
cannot tell them apart, so it grades the wording rather than the coverage. That
|
|
5492
|
+
is the failure design §11 records for every intent-layer detector tried — each
|
|
5493
|
+
"either saw almost nothing, or was cleared by deleting the word that triggered
|
|
5494
|
+
it" — and this one is squarely the second: `compound-requirement` sits
|
|
5495
|
+
at the other pole, never wrong and nearly blind, and the two together are the
|
|
5496
|
+
measured range of what a token detector over prose can do here.
|
|
5497
|
+
|
|
5498
|
+
*What survives is not this proposal.* The three-way classification and the
|
|
5499
|
+
authoring rule are design §11, the adopter-facing form is in the `init` skill,
|
|
5500
|
+
and the hand-judged table is the counter-pressure that did survive measurement.
|
|
5501
|
+
Reopening this needs a signal that is not the quantifier token — the reach limit
|
|
5502
|
+
is the detector's input, not its threshold, so tuning it is the measurement
|
|
5503
|
+
bending to the corpus.
|
|
5504
|
+
|
|
5505
|
+
### A precomputed analysis written to disk for the child run to read
|
|
5506
|
+
|
|
5507
|
+
`@ark/attest` analyses the whole project once, writes it to
|
|
5508
|
+
`.attest/assertions/*.json`, and has the test process do position lookups
|
|
5509
|
+
against that file instead of a compiler — because its assertions need a full
|
|
5510
|
+
`ts.Program`, which cannot be afforded per test. The shape maps onto `verify`
|
|
5511
|
+
exactly: the parent parses registries and specs, then `runAndCollect` spawns a
|
|
5512
|
+
Vitest run that currently shares nothing with that work.
|
|
5513
|
+
*What rejected it is that the case for it was never made and the case against it
|
|
5514
|
+
is structural.* There is **no
|
|
5515
|
+
measurement** saying parsing costs anything here — this reader is
|
|
5516
|
+
`createSourceFile` per file with no type checking, which is the cheap half of
|
|
5517
|
+
what arktype pays for, and the fan-out measurement — filed as an entry in
|
|
5518
|
+
`Under consideration`, and now shipped as the two concurrency bounds in
|
|
5519
|
+
`[0.7.0]` and `[Unreleased]` — clocked `attest check` over 6000 spec files at
|
|
5520
|
+
1.99 s total. Worse, a cache is a correctness hazard
|
|
5521
|
+
of precisely the kind this tool exists to detect: a stale analysis makes a
|
|
5522
|
+
**drift detector** report drift that has already been fixed, or miss drift
|
|
5523
|
+
that has just appeared, and it would do so silently. Any version of this needs
|
|
5524
|
+
an invalidation story stronger than a mtime before it is worth the seconds it
|
|
5525
|
+
saves, and the repo already has one cautionary precedent in the same
|
|
5526
|
+
direction — `bin/attest.js` loading `dist/` rather than `src/` is a cache with
|
|
5527
|
+
no invalidation at all, and it is in `CLAUDE.md`'s Gotchas because of how
|
|
5528
|
+
quietly it misleads. Note also what arktype gets wrong in the same file and
|
|
5529
|
+
what not to copy with it: the assertion cache and the queue of pending
|
|
5530
|
+
snapshot writes share one JSON document, so two things with unrelated
|
|
5531
|
+
lifetimes are invalidated together.
|
|
5532
|
+
|
|
5533
|
+
*What would reopen it* is a measurement, and the entry names which one: a
|
|
5534
|
+
parse cost large enough to be worth a cache, on a real project rather than a
|
|
5535
|
+
synthetic tree. Until that exists this is a correctness hazard bought with
|
|
5536
|
+
seconds nobody has shown are being spent, and the invalidation story would have
|
|
5537
|
+
to be stronger than an mtime before the trade is even worth pricing.
|
|
5538
|
+
|
|
5539
|
+
### Zod Mini (`zod/v4-mini`) for the registry schema
|
|
5540
|
+
|
|
5541
|
+
Evaluated alongside v4 (which shipped under `[Unreleased]`), on the same
|
|
5542
|
+
schema and the same 69-requirement registry, and it wins both numbers it is
|
|
5543
|
+
supposed to win:
|
|
5544
|
+
**0.249 ms** per parse against v3's 3.47 ms, and — the one v4 does not deliver —
|
|
5545
|
+
type instantiations down from **27,681 to 6,581**, check time 0.25 s → 0.14 s.
|
|
5546
|
+
|
|
5547
|
+
*What rejected it is that both winners are collected by the wrong party.* Mini
|
|
5548
|
+
exists for tree-shaken bundle size in a browser. This ships as a Node CLI loaded
|
|
5549
|
+
from `dist/`, where bundle size is not a constraint on anything — apply the
|
|
5550
|
+
deletion test to mini's benefit and nothing in this repository changes. The
|
|
5551
|
+
instantiation saving is real but it is paid to **this project's own
|
|
5552
|
+
`pnpm typecheck`**, not to any adopter, and it is 0.11 s off a build already
|
|
5553
|
+
measured in seconds. The runtime saving is 3.2 ms against a 390 ms command.
|
|
5554
|
+
|
|
5555
|
+
*And the cost lands on the file least able to afford it.* Mini's API is
|
|
5556
|
+
functional rather than chained:
|
|
5557
|
+
|
|
5558
|
+
```ts
|
|
5559
|
+
params: z.record(z.string(), paramValue).default({}) // v3 / v4
|
|
5560
|
+
params: z._default(z.record(z.string(), paramValue), {}) // mini
|
|
5561
|
+
```
|
|
5562
|
+
|
|
5563
|
+
`src/core/schema.ts` describes itself as the single source of truth for the
|
|
5564
|
+
shape of the intent layer, and it is written as one — the comments on
|
|
5565
|
+
`isPlainObject` and on the `params` union are longer than the code they sit
|
|
5566
|
+
over, because the file is read as a document by anyone deciding what a
|
|
5567
|
+
requirement may contain. Trading its legibility, and accepting an
|
|
5568
|
+
underscore-prefixed `_default` in it, buys 3 ms on a command nobody is waiting
|
|
5569
|
+
for.
|
|
5570
|
+
|
|
5571
|
+
This reopens only if something makes bundle size a constraint here — a browser
|
|
5572
|
+
or edge build of the registry reader, which nothing currently proposes — and not
|
|
5573
|
+
on a faster benchmark, since the benchmark was never what was in doubt.
|
|
5574
|
+
|
|
4285
5575
|
### Region-marked samples, so a partial quote could be bound too
|
|
4286
5576
|
|
|
4287
5577
|
Decided **yes** and filed under `Planned`, then killed by building the count it
|
|
@@ -4636,6 +5926,8 @@ requirement, and human review at propose is still the whole answer. What is
|
|
|
4636
5926
|
rejected is grouping as a way to assist it, and this reopens only on a
|
|
4637
5927
|
contradiction that grouping would have caught.
|
|
4638
5928
|
|
|
5929
|
+
[0.9.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.9.0
|
|
5930
|
+
[0.8.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.8.0
|
|
4639
5931
|
[0.7.4]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.4
|
|
4640
5932
|
[0.7.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.3
|
|
4641
5933
|
[0.7.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.2
|