@am_shork/attest 0.6.0 → 0.7.1
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 +1084 -87
- package/README.md +4 -4
- package/dist/cli/index.js +2 -18
- package/dist/cli/json.js +6 -1
- package/dist/cli/report.d.ts +18 -0
- package/dist/cli/report.js +41 -0
- package/dist/core/apply.js +7 -10
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +2 -0
- package/dist/core/gate.d.ts +48 -2
- package/dist/core/gate.js +73 -14
- package/dist/core/loader.js +13 -0
- package/dist/core/locate.d.ts +23 -0
- package/dist/core/locate.js +35 -6
- package/dist/core/merge.js +48 -10
- package/dist/core/order.d.ts +17 -0
- package/dist/core/order.js +25 -0
- package/dist/core/pipeline.js +144 -35
- package/dist/core/render.js +174 -19
- package/dist/core/req-suite.d.ts +5 -0
- package/dist/core/req-suite.js +27 -0
- package/dist/core/runner.js +24 -8
- package/dist/core/schema.d.ts +13 -6
- package/dist/core/schema.js +54 -18
- package/dist/core/skill.js +6 -2
- package/dist/core/splice.d.ts +13 -12
- package/dist/core/splice.js +59 -18
- package/dist/core/static-registry.js +6 -0
- package/dist/core/status.js +16 -3
- package/dist/core/terminal.d.ts +12 -3
- package/dist/core/terminal.js +14 -12
- package/dist/core/types.d.ts +1 -1
- package/dist/core/validator.d.ts +6 -1
- package/dist/core/validator.js +60 -2
- package/dist/runtime.d.ts +20 -0
- package/dist/runtime.js +43 -15
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,727 @@ input, removes/renames a public API or `--json` field, or changes a default
|
|
|
13
13
|
runtime behavior an existing invocation relies on — diagnostic message text is
|
|
14
14
|
not API.
|
|
15
15
|
|
|
16
|
+
## [0.7.1] - 2026-08-05
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **`params` takes any JSON value, and the constraint moved to the place it was
|
|
21
|
+
actually protecting.** A param was a scalar or a list of scalars. The argument
|
|
22
|
+
for admitting lists — the values most likely to drift are the composite ones,
|
|
23
|
+
and rejecting them left exactly those outside the one mechanism built to pin
|
|
24
|
+
them — does not stop at a list: a `kind -> payload` table drifts harder than a
|
|
25
|
+
flat list of names, and it was the one shape still excluded. `null` comes with
|
|
26
|
+
it, because refusing it forces a sentinel (`''`, `-1`) that means something
|
|
27
|
+
else to every later reader.
|
|
28
|
+
What the old union was defending was not the registry but the **rendering**:
|
|
29
|
+
`{payloadKinds}` interpolated into a statement as `[object Object]`, in a
|
|
30
|
+
document reviewers and audit read as the system's promise. That is a property
|
|
31
|
+
of the interpolation point, so it is stated there — a new
|
|
32
|
+
**`non-scalar-interpolation`** ERROR from `check` and `verify`. Narrower and
|
|
33
|
+
truer: it refuses what actually breaks and admits the data a scenario reads,
|
|
34
|
+
which the schema rule could not tell apart. What the schema keeps is what does
|
|
35
|
+
not survive being written down — a function, a `Date`, a class instance, an
|
|
36
|
+
object whose prototype was swapped.
|
|
37
|
+
*The part that is not a schema edit.* Depth. Four obligations had exactly one
|
|
38
|
+
level of reach, which is correct only while a param cannot nest, and none of
|
|
39
|
+
them fails loudly: the canonical form behind `add-conflict` (an identical copy
|
|
40
|
+
written with its inner keys in another order would have conflicted with
|
|
41
|
+
itself), the ATX-58 control-character stripping (nested strings would have
|
|
42
|
+
gone unsanitised, reopening the hole `[0.7.0]` closed), the key order behind a
|
|
43
|
+
byte-stable rendering, and `splice.ts`, which writes a requirement back out as
|
|
44
|
+
TypeScript source and would have committed `[object Object]` into a
|
|
45
|
+
`*.reqs.ts` through `archive --apply`. Three of the four are ordering, and are
|
|
46
|
+
now one shared `sortDeep` in `order.ts` rather than three sorts a level deep —
|
|
47
|
+
they wanted the same guarantee for the same reason, which is what makes it one
|
|
48
|
+
function and not a helper each.
|
|
49
|
+
*Two divergences between the readers, which is what the differential suite is
|
|
50
|
+
for.* `null` is a keyword rather than a literal node, so the AST reader fell
|
|
51
|
+
off the end of `literalValue` and reported `registry-not-static` for the whole
|
|
52
|
+
file while the evaluating reader accepted it. And `{ __proto__: … }` swaps the
|
|
53
|
+
prototype instead of creating a key, so `z.record` copies own keys into a fresh
|
|
54
|
+
object and the taint is invisible one step later — the static reader refuses
|
|
55
|
+
that source at any depth, and the schema now refuses the object the evaluator
|
|
56
|
+
builds from it, both spellings (a literal `__proto__:` and the own key
|
|
57
|
+
`JSON.parse` produces).
|
|
58
|
+
*Rendering.* A structured param gets a fenced JSON block below the params
|
|
59
|
+
table, keys sorted at every depth (ATX-10: the same registry renders the same
|
|
60
|
+
bytes) with the fence measured against backtick runs in the value, as `code()`
|
|
61
|
+
already did. It is below the table rather than in it because a fence cannot
|
|
62
|
+
live in a table cell. And `render` defends the document on its own: it reads
|
|
63
|
+
the registry with no spec parse, so no `AttestPlan` and no `validateStructure`
|
|
64
|
+
— `attest render` runs to completion on a registry `check` refuses. Leaning on
|
|
65
|
+
the diagnostic would have left the document defended by a command nobody is
|
|
66
|
+
obliged to run first.
|
|
67
|
+
*The writing side of the `__proto__` rule.* `splice.ts` turns a requirement
|
|
68
|
+
back into TypeScript source for `--apply`, and it is the only site that does.
|
|
69
|
+
Extending it to depth meant it would have emitted a `__proto__` key as a bare
|
|
70
|
+
property — text whose *evaluation* swaps a prototype rather than being the
|
|
71
|
+
value it was handed. Unreachable through any command (the schema rejects a
|
|
72
|
+
nested `__proto__` and `z.record` drops a top-level one), and refused anyway
|
|
73
|
+
with a new `UnwritableValue`, reported as `internal-error` by the merge with
|
|
74
|
+
its account of what it had already written intact. Quoting is not the repair —
|
|
75
|
+
a quoted `__proto__` swaps the prototype in a literal exactly as the bare form
|
|
76
|
+
does, and the bracketed form that would create an own property is a computed
|
|
77
|
+
key no static reader will read. The reader's guard is worth nothing if the
|
|
78
|
+
writer can produce the file it exists to refuse.
|
|
79
|
+
*Not breaking, with one edge worth naming.* No previously-green registry goes
|
|
80
|
+
red — a structured param was `registry-invalid` before, so nothing that
|
|
81
|
+
compiled and passed now fails — and an all-scalar registry renders
|
|
82
|
+
byte-identical, so a committed `render --check` stays green across the
|
|
83
|
+
upgrade. What did widen is the exported type in a **read** position:
|
|
84
|
+
`Requirement['params'][string]` is now the recursive JSON value. The const
|
|
85
|
+
generic is untouched, so `reqs['AUTH-3'].params.idleTimeoutMin` is still the
|
|
86
|
+
literal `30` and every anti-drift test keeps its narrow type. A consumer only
|
|
87
|
+
notices if they assign an *undeclared* key to the old scalar-or-list union
|
|
88
|
+
(`Type 'null' is not assignable`), or wrote their own handler over that type
|
|
89
|
+
and does `String(v)` in it — which is the `[object Object]` defect this entry
|
|
90
|
+
fixes in `render` and `splice`, seen from outside.
|
|
91
|
+
*This overturns a standing rejection, and not on its stated terms.* *Nested
|
|
92
|
+
objects in `params`* sat under **Considered and rejected** since `[0.4.2]`,
|
|
93
|
+
raised by `mine-capablanca`, and that entry is removed by this release. Its
|
|
94
|
+
argument was that a param has two properties a table lacks: rendered as a value
|
|
95
|
+
a human checks at a glance, and read by a scenario as the one place the value
|
|
96
|
+
lives. The first is answered rather than waived — `non-scalar-interpolation`
|
|
97
|
+
keeps a table out of the *sentence*, so what a reader takes in at a glance is
|
|
98
|
+
still glance-sized, and the table is rendered below it as a block. The second
|
|
99
|
+
is answered by the criterion that admitted arrays in the first place: a
|
|
100
|
+
`kind -> payload` table drifts *harder* than a flat list, so the reason arrays
|
|
101
|
+
were let in is the reason this shape should have been.
|
|
102
|
+
**The reopening condition it named was never met, and that is worth stating
|
|
103
|
+
plainly.** The entry said it reopens on "a report where the value is genuinely
|
|
104
|
+
promised — a table an adopter owes their users"; none has been seen. This
|
|
105
|
+
shipped on the strength of the argument alone. What changed is the recognition
|
|
106
|
+
that the schema was answering the wrong question: *params for promises, plain
|
|
107
|
+
constants for tuning* is the rule, and a type can only ever check shape, so it
|
|
108
|
+
refused a promised table while admitting a list of tuning knobs. Design §11
|
|
109
|
+
now carries that as a judgement rather than a type, which is the permanent half
|
|
110
|
+
— and it is weaker than what it replaces, because nothing enforces a judgement.
|
|
111
|
+
New requirements: `ATX-7` rewritten, `ATX-63` added.
|
|
112
|
+
|
|
113
|
+
### Changed
|
|
114
|
+
|
|
115
|
+
- **Rationale that only a comment held now has a design section, and the comment
|
|
116
|
+
cites it.** Measured over the diffs in three eras split at `[0.3.0]` and
|
|
117
|
+
`[0.5.0]`: comment blocks got better by every specificity signal — naming a
|
|
118
|
+
symbol 50 → 60%, a measured number 3 → 13%, a causal argument 26 → 54% — and
|
|
119
|
+
roughly doubled per line of code, while the share citing a `§` went 15 → 12 →
|
|
120
|
+
11% and the design document stopped growing (sixteen sections since `[0.3.0]`,
|
|
121
|
+
105 lines against `src/`'s 2,862). **The anchoring did not fall; it never
|
|
122
|
+
scaled**, and a `§` is the only part of a comment any gate reads.
|
|
123
|
+
Nineteen blocks written since `[0.5.0]` carried the cost — ten lines or more,
|
|
124
|
+
fully argued, unanchored — and they split three ways. *Six were properties a
|
|
125
|
+
rewrite would have to preserve*, and now sit in the document: a new **§9.1**
|
|
126
|
+
for the artifacts Attest writes, an extension to **§8** (`--apply` acts on this
|
|
127
|
+
run's verdict), and one to **§5.4** (the two readers of one plan must descend
|
|
128
|
+
the same way). *Four were history* — a quadratic backtrack, a 27–84x constant
|
|
129
|
+
factor, a 47 MiB peak, an indexing cost filed as invisible — and were
|
|
130
|
+
compressed to the invariant a maintainer would otherwise re-break; every
|
|
131
|
+
measurement in them was already under `[0.7.0]`, so this deleted duplicates
|
|
132
|
+
rather than moving anything. *Ten were correct as they stood and were left
|
|
133
|
+
alone*, which is the half worth naming: `§` is low because the design document
|
|
134
|
+
stalled, not because comments are too long, so a pass that shortened comments
|
|
135
|
+
and left it at one in nine would have moved the wrong quantity and called it
|
|
136
|
+
done. Net `src/`: 29 lines removed, all of them comments and no code touched,
|
|
137
|
+
which is also how the deletion was checked.
|
|
138
|
+
**One of the six corrected the document rather than extending it.** §8
|
|
139
|
+
summarised a merge as renaming a proposed spec "in place", which is true of its
|
|
140
|
+
location and not of its imports — the delta it reads its proposed params from
|
|
141
|
+
is what moves to `archive/`, so the sentence hid the one specifier the merge
|
|
142
|
+
has to repoint.
|
|
143
|
+
*What the measurement cost.* A first pass reported the `§` share as halving,
|
|
144
|
+
25 → 12%, an artefact of bucketing by `git blame` over the current tree: that
|
|
145
|
+
sees only surviving lines, and `§`-citing comments survive better, being tied
|
|
146
|
+
to sections that do not move, so the early era was measured on its most durable
|
|
147
|
+
residue. Re-measuring from the diffs before building is what caught it.
|
|
148
|
+
The routing test this produced is in `pre-commit-check` §6, and the obligation
|
|
149
|
+
a new diagnostic code owes the CLI reference is now in §2.
|
|
150
|
+
|
|
151
|
+
## [0.7.0] - 2026-08-04
|
|
152
|
+
|
|
153
|
+
### Added
|
|
154
|
+
|
|
155
|
+
- **A universally quantified statement now has to say what it quantifies over,
|
|
156
|
+
and the answer is checked.** `tests/intent-rule-candidates.spec.ts` gains a
|
|
157
|
+
`QUANTIFIED` table beside `OBLIGATIONS`: every statement carrying an open
|
|
158
|
+
quantifier is hand-judged into one of three kinds, and a statement flagged with
|
|
159
|
+
no row fails. 23 of the 62 requirements are flagged today.
|
|
160
|
+
*The shape it exists for.* A universal quantifier in the statement, a finite
|
|
161
|
+
set of scenarios under it, and nothing holding the two together. It reads as
|
|
162
|
+
covered — the id has scenarios, `cover` is green, `check` is green — because
|
|
163
|
+
all three mechanisms this project sells operate *below* the quantifier:
|
|
164
|
+
coverage asks whether an id has a scenario, never-red asks whether that
|
|
165
|
+
scenario discriminated, drift asks whether it reads the params. None asks
|
|
166
|
+
whether the scenarios span what the sentence claims. It had already cost
|
|
167
|
+
something — `ATX-37` said "everything the CLI writes to a terminal" while
|
|
168
|
+
`render` built a document by concatenation that never went past the sanitiser,
|
|
169
|
+
which shipped as a fix earlier under this heading without the *shape* being
|
|
170
|
+
addressed.
|
|
171
|
+
**The classification is the part worth keeping, because it predicts which
|
|
172
|
+
statements carry the risk.** `values` — the quantifier ranges over inputs one
|
|
173
|
+
site processes, so growth cannot escape it, there being one site. `roster` — it
|
|
174
|
+
ranges over a set the code itself enumerates and the scenario iterates that
|
|
175
|
+
same set, so a new member is covered by construction. `sites` — it ranges over
|
|
176
|
+
places in the implementation, discharged by one hand-written scenario each, and
|
|
177
|
+
**this is the only kind that can silently stop being true**. Today 14, 2 and 8
|
|
178
|
+
across 24 rows — one more than the 23 flagged, for the reason two paragraphs
|
|
179
|
+
down.
|
|
180
|
+
`ATX-37` was a `sites` row and `render` was the new site, which is the whole
|
|
181
|
+
argument in one line. The `roster` shape is the one to reach for and the repo
|
|
182
|
+
had already found it once without naming it — `self/targets.spec.ts` says "a
|
|
183
|
+
target added without a scenario would otherwise be a target nothing checks".
|
|
184
|
+
**It found a live hole on its first run, on the requirement written to avoid
|
|
185
|
+
exactly this.** `ATX-62` names its surfaces rather than quantifying over them
|
|
186
|
+
("both commands that execute the suite — `verify` and the archive gate"), and
|
|
187
|
+
its rationale explains that choice in these words. Of the three sites it names,
|
|
188
|
+
the archive gate path had **no scenario asserting the diagnosis is emitted** —
|
|
189
|
+
only one asserting it *stands down* for a file `added-id-unmerged` already
|
|
190
|
+
claimed, and one asserting the withdrawal it enables. Both negative.
|
|
191
|
+
*Measured by mutation, because the first statement of this was wrong and the
|
|
192
|
+
correction is the useful part.* Deleting the `specLoadFailedIssues` call
|
|
193
|
+
outright does **not** survive: it sits inside `notRunIssues`, which both
|
|
194
|
+
commands share, so the `verify` scenario kills it. The mutant that isolates the
|
|
195
|
+
gate is the one that matters — `evaluateGate` filtering the diagnosis out of
|
|
196
|
+
what it pushes, which is a gate that drops the finding on its own path while
|
|
197
|
+
`verify` keeps it. That is the *exact* historical shape `ATX-62` exists to
|
|
198
|
+
answer, its rationale naming "the rule holding on one path and not the other".
|
|
199
|
+
Against the old spec that mutant runs **green, 0 errors**; against the new one
|
|
200
|
+
it is red. `self/gate.spec.ts` gains the missing scenario, and the run is now
|
|
201
|
+
62 requirements / 160 scenarios.
|
|
202
|
+
*Two design decisions the table fails without.* **Rows are allowed for ids the
|
|
203
|
+
detector does not flag**, which is why `ATX-62` has one at all: naming the
|
|
204
|
+
surfaces is the *remedy*, and it takes the statement out of the flagged set, so
|
|
205
|
+
a table keyed strictly on the detector would drop the binding at the moment the
|
|
206
|
+
author did the right thing. **Every named scenario is asserted to exist** in
|
|
207
|
+
the specs, by name, for that id — without it the table is one more hand-written
|
|
208
|
+
list that goes on reading as covered after a rename, which is the failure mode
|
|
209
|
+
every other check in that file is written to avoid.
|
|
210
|
+
*And one deliberate gap, carried rather than hidden.* A site the statement
|
|
211
|
+
claims that nothing reaches is recorded as `null` and lands in the snapshot,
|
|
212
|
+
on the same footing as the compound rule's `missed` list. There is one:
|
|
213
|
+
`ATX-53`'s "some of several specs renamed, the rest not" — a state no fixture
|
|
214
|
+
can construct, because every change fixture carries a single spec.
|
|
215
|
+
The detector is the same kind of instrument as `compoundByKeyword` and has the
|
|
216
|
+
same limit, stated rather than tuned away: it catches the declared universal
|
|
217
|
+
and misses the article form ("A registry that cannot be read MUST …"), which is
|
|
218
|
+
a universal too. Two of the 23 rows are the idiom "at all" rather than a
|
|
219
|
+
quantifier, kept as rows because a regex tuned until it stops flagging is the
|
|
220
|
+
measurement bending to the corpus.
|
|
221
|
+
No behaviour change, no new `Issue` code, and no `ATX-n` — repo
|
|
222
|
+
self-discipline, for the reason at the top of that file: knowing which surface
|
|
223
|
+
a sentence names is prose judgement, the ground truth §0 refuses to compute for
|
|
224
|
+
someone else's requirements.
|
|
225
|
+
|
|
226
|
+
- **The eight `sites` rows re-read against `src/`, which is what the table was
|
|
227
|
+
built to make possible.** The rows were hand-judged in one pass, and the value
|
|
228
|
+
of the whole table is concentrated in that kind — so the first use of it is a
|
|
229
|
+
strict read asking one question per row: *is this list of sites complete?* Five
|
|
230
|
+
claimed-but-unattested sites came back, one row was retired, and one was wrong
|
|
231
|
+
in the good direction.
|
|
232
|
+
**`ATX-15` was short by two.** "Every ordering Attest commits to disk" is wider
|
|
233
|
+
than the rendering: `red-record.ts` sorts ids and scenario names into
|
|
234
|
+
`first-run.json`, and `splice.ts` sorts ids and param keys into the registry
|
|
235
|
+
file `--apply` writes. Both are committed and neither is attested. The property
|
|
236
|
+
holds everywhere — every `.sort` in `src/` goes through `byCodeUnit` and no
|
|
237
|
+
`localeCompare` survives — which is precisely the `ATX-37` position: held at
|
|
238
|
+
every site, enumerated at none.
|
|
239
|
+
**`ATX-37` was short by three.** `formatStatus`, `formatCoverage` and
|
|
240
|
+
`formatArchiveVerdict` each interpolate a reqId, a change name or a path taken
|
|
241
|
+
from the project under test, and the statement says *everything* the CLI
|
|
242
|
+
writes. All three call `inline()` today; none is attested. That is the state
|
|
243
|
+
`render` was in on the day `ATX-58` had to be written.
|
|
244
|
+
**`ATX-58` was recorded as weaker than it is, and the correction is the useful
|
|
245
|
+
part.** It was filed as four field-sites carrying `ATX-37`'s growth risk.
|
|
246
|
+
`sanitised()` rebuilds a whole `Requirement` as one typed object literal, so a
|
|
247
|
+
fifth field on that type is a **compile error**, not a silent gap — the axis is
|
|
248
|
+
closed by the compiler rather than by anyone remembering. Five sites collapse
|
|
249
|
+
to two, and this is the strongest of the eight rather than a repeat of the
|
|
250
|
+
weakest.
|
|
251
|
+
**`ATX-53`'s open gap was retired by building it.** The recorded `null` was
|
|
252
|
+
"some of several specs renamed, the rest not", said to be unconstructible
|
|
253
|
+
because every change fixture carries one spec. Constructing it showed the state
|
|
254
|
+
does not exist: a renamed spec is no longer a *proposed* spec, so it leaves
|
|
255
|
+
`claimedSpecs` the moment it is done, and a partial rename re-runs as the state
|
|
256
|
+
already covered with a shorter list. Measured rather than argued — a two-spec
|
|
257
|
+
project with one merged by hand passes against a merge mutated to rename only
|
|
258
|
+
the first claimed spec. So the scenario was **not** kept: it discriminates
|
|
259
|
+
nothing, and design §11 is explicit that a scenario which cannot fail is worse
|
|
260
|
+
than the comment recording why. `self/merge.spec.ts` carries that comment, and
|
|
261
|
+
the two resumption scenarios now share one `spliceByHand` helper instead of two
|
|
262
|
+
copies of the same insertion literal.
|
|
263
|
+
`ATX-40`, `ATX-41` and `ATX-60` came back complete. `ATX-60` with a caveat
|
|
264
|
+
worth its line: every verdict in `src/` goes through `hasError` and the two
|
|
265
|
+
bare spellings left are not verdicts, so the sites are right — but "never by a
|
|
266
|
+
second spelling" is a claim about the whole of `src/` discharged by two
|
|
267
|
+
examples, and nothing sweeps for a fifth.
|
|
268
|
+
*What the read is actually saying, across five of the eight.* The recurring
|
|
269
|
+
answer is **a sweep or a type**. `ATX-58` shows what closed looks like: the
|
|
270
|
+
compiler refuses the new site. `ATX-34` and `ATX-57` show the other closed
|
|
271
|
+
shape: a scenario iterating the roster the code owns. `ATX-15`, `ATX-37` and
|
|
272
|
+
`ATX-60` want one and have neither, which is filed under
|
|
273
|
+
`Under consideration` rather than built here — a rule that fires on correct
|
|
274
|
+
usage is the shape this file has rejected before.
|
|
275
|
+
Bookkeeping only: no behaviour change, no new `Issue` code, and the snapshot
|
|
276
|
+
now carries five unattested sites where it carried one.
|
|
277
|
+
|
|
278
|
+
- **A sketch in the design document is now bound to the file it says it is
|
|
279
|
+
from.** `tests/docs-consistency.spec.ts` takes every fenced `ts` block whose
|
|
280
|
+
first line names a `src/` path — the convention those documents already
|
|
281
|
+
follow — and asserts that every name the block puts at **module scope** still
|
|
282
|
+
exists at module scope in that file. Six blocks today, three per language. The
|
|
283
|
+
scan is over every hand-written page rather than the design document, through
|
|
284
|
+
the same enumeration the other checks use: a sketch is not something only one
|
|
285
|
+
file may hold, and a hardcoded list has already stopped covering a page here
|
|
286
|
+
once, on the day the CLI reference was split out of the README.
|
|
287
|
+
*Why this one, of the three candidates recorded with the entry.* Deleting the
|
|
288
|
+
sketches costs the design document the thing it is for; marking them
|
|
289
|
+
illustrative leaves an argument free to cite state that is gone. The symbol
|
|
290
|
+
check was the only candidate with a hit rate, and the hit rate is now measured
|
|
291
|
+
rather than estimated: replaying the pre-`a5899ac` document against today's
|
|
292
|
+
`src/runtime.ts` reproduces the original failure exactly — `currentReqId`,
|
|
293
|
+
named at module scope by the sketch and nowhere in the file. That replay is
|
|
294
|
+
what turned "would have caught it" into something checked.
|
|
295
|
+
**It found a third instance on its first run**, which is the fourth way this
|
|
296
|
+
file records an entry arriving. §5.4's `runner.ts` sketch declared
|
|
297
|
+
`const REQ_SUITE = /^\[(.+)\]$/` and read it with `task.name.match(...)`; the
|
|
298
|
+
regex moved to `core/req-suite.ts` and `runner.ts` has imported
|
|
299
|
+
`requirementIdOf` from it since. The sketch was not merely stale, it showed the
|
|
300
|
+
design document contradicting the module whose entire reason for existing is
|
|
301
|
+
that *"two spellings of one encoding are two encodings the moment either is
|
|
302
|
+
edited"* — a reader following §5.4 would have written the second spelling. Both
|
|
303
|
+
languages are corrected here.
|
|
304
|
+
*Three scoping decisions, each of which the check fails without.* **Module
|
|
305
|
+
scope, not every identifier the sketch names**: `runtime.ts` explains the
|
|
306
|
+
deleted `currentReqId` in two comments, so a substring scan over the source
|
|
307
|
+
text would have called the stale sketch fine — both sides are reduced to
|
|
308
|
+
declarations, imports and re-exports through the compiler API. **The `src/`
|
|
309
|
+
prefix, not file existence**: existence would make a typo'd path a silently
|
|
310
|
+
skipped block, the failure mode this file guards against everywhere else, so a
|
|
311
|
+
`src/` path that does not resolve fails instead and `requirements/auth.reqs.ts`
|
|
312
|
+
is left alone as what it is, a path in the reader's project. **Fenced blocks,
|
|
313
|
+
not prose**: prose may name a dead symbol legitimately, and §11 now does so on
|
|
314
|
+
purpose, as history.
|
|
315
|
+
**What it does not reach, because the honest count is one of two.** §11's
|
|
316
|
+
ESM-only bullet — the worse of the two instances, since it was an *argument*
|
|
317
|
+
resting on state that no longer existed — is prose, and stays outside. This
|
|
318
|
+
catches the sketch; the reader who comes to fix the sketch is what catches the
|
|
319
|
+
paragraph citing it. A sketch whose module scope survives while its body drifts
|
|
320
|
+
also passes, which is the same limit the byte-equality check already records
|
|
321
|
+
about the prose around a sample.
|
|
322
|
+
No behaviour change, no new `Issue` code, and no `ATX-n`: this is repo
|
|
323
|
+
self-discipline about this repository's own documentation, in the file that
|
|
324
|
+
already holds every other coupling cheap enough to assert.
|
|
325
|
+
|
|
326
|
+
### Changed
|
|
327
|
+
|
|
328
|
+
- **`parseSpecs` held every spec file in memory at once; it now holds 32.** The
|
|
329
|
+
read was `Promise.all(files.map(readFile))`, so every source in the project
|
|
330
|
+
was resolved before the first one was parsed. Each source is now parsed at the
|
|
331
|
+
point it arrives, through a fixed pool, which makes the peak a constant rather
|
|
332
|
+
than the size of the input — the merged plan is the only thing that still grows
|
|
333
|
+
with the tree, and it is the output.
|
|
334
|
+
*Measured, both before and after.* On a synthetic tree of 6000 spec files the
|
|
335
|
+
old shape held ~47 MiB of source at once at a realistic 8 KiB per file;
|
|
336
|
+
`tests/locate-fanout.spec.ts` counts in-flight reads through the filesystem and
|
|
337
|
+
measures 200 before the change and 32 after, on a 200-file input. That test
|
|
338
|
+
lives in its own file because it mocks a Node builtin, and it reads the
|
|
339
|
+
property at `fs` rather than through `parseSpecs`, which is the honest seam:
|
|
340
|
+
peak in-flight reads is a fact about how the function uses the filesystem.
|
|
341
|
+
*What is deliberately **not** changed, and why the two halves are not one
|
|
342
|
+
decision.* `findFiles` fans out over sibling directories with no limit either,
|
|
343
|
+
and it stays that way. The failure it invites is descriptor exhaustion, and no
|
|
344
|
+
measurement on either development platform could produce one — on Windows, Node
|
|
345
|
+
uses Win32 handles rather than POSIX descriptors, so the shell's `ulimit -n`
|
|
346
|
+
does not govern the process, and the one platform where it is plausible is the
|
|
347
|
+
one this measurement could not reach. That half stays under
|
|
348
|
+
`Under consideration` on this file's own standard: no report, and no
|
|
349
|
+
demonstrated failure anywhere. The memory needed no such evidence — it is
|
|
350
|
+
arithmetic, and it is portable.
|
|
351
|
+
No behaviour change and no `ATX-n`: the plan is byte-identical either way, and
|
|
352
|
+
there is no contract here a consumer could branch on. `check` being the command
|
|
353
|
+
this project tells people to run first on an untrusted fork MR is what makes it
|
|
354
|
+
worth doing anyway — the input size is not ours to assume.
|
|
355
|
+
|
|
356
|
+
- **Every static command loaded the test runner it can never start.**
|
|
357
|
+
`pipeline.ts` imported `createLoader` from `loader.ts` and
|
|
358
|
+
`runAndCollect`/`BASE_EXCLUDE` from `runner.ts` at the top level, and
|
|
359
|
+
`cli/index.ts` imports `pipeline.ts` whole — so `vite`, `vitest/node` and
|
|
360
|
+
`vitest/config` were resolved and evaluated before `commander` had parsed an
|
|
361
|
+
argument. `check`, `cover`, `render`, `status` without `--eval`, and `init`
|
|
362
|
+
call none of them. The three are now reached through `await import()` at the
|
|
363
|
+
four `createLoader` sites and the two runner sites, all of which were already
|
|
364
|
+
in `async` functions.
|
|
365
|
+
*Measured on this repository, before and after, on one machine* — the earlier
|
|
366
|
+
figures recorded with this as a plan were taken in a different session and are
|
|
367
|
+
not comparable, which is the whole reason the baseline was re-taken here rather
|
|
368
|
+
than quoted. Importing `dist/core/pipeline.js`: **2133 ms → 1111 ms**.
|
|
369
|
+
`attest --version` end to end: 1700–2219 ms → 954–1220 ms. `attest check .`:
|
|
370
|
+
1991–2215 ms → 1300–1478 ms. Roughly a second, on every invocation of a
|
|
371
|
+
command that could not have used it.
|
|
372
|
+
**This is not the core/adapter package split, and does not substitute for
|
|
373
|
+
one.** `tests/import-boundary.spec.ts` states its own gap in its own words — it
|
|
374
|
+
gates **direct** imports, not reachability from the static commands, because
|
|
375
|
+
`pipeline.ts` holds both halves of the engine — and files the answer under the
|
|
376
|
+
split §11 defers as not earned by one package at this size. That deferral
|
|
377
|
+
stands and that gap is unchanged: what moved is *when* the cost is paid, not
|
|
378
|
+
which module holds what.
|
|
379
|
+
**The one property this could have silently dropped was the boundary itself**,
|
|
380
|
+
and it did not: that gate reads `import(...)` and `require(...)` from the AST
|
|
381
|
+
alongside static imports, and has since it was written, precisely so it could
|
|
382
|
+
not be defeated this way. Both specifiers stay relative, so `ALLOWED` is
|
|
383
|
+
untouched and the reachability walk still traverses them. The comment there
|
|
384
|
+
saying dynamic import was unused in `src/` is now false and has been corrected;
|
|
385
|
+
a gate whose commentary describes a codebase that no longer exists is the
|
|
386
|
+
defect this file's *"six places where the source states a guarantee it does not
|
|
387
|
+
provide"* entry is about.
|
|
388
|
+
*What this does not buy, stated so the next reader does not expect it.* The
|
|
389
|
+
larger single item is `typescript` at ~936 ms, and `check`, `cover` and
|
|
390
|
+
`render` genuinely read the AST, so it stays on their real path — visible in
|
|
391
|
+
`--version` still costing ~1 s. Only `init` and `--version`/`--help` pay it for
|
|
392
|
+
nothing, and separating those means splitting `pipeline.ts` rather than moving
|
|
393
|
+
an import. So this closes the avoidable half and leaves the unavoidable one
|
|
394
|
+
measured.
|
|
395
|
+
**No behaviour change and no new requirement.** No command's output, exit code
|
|
396
|
+
or JSON shape moves, and the property worth stating — *a static command must
|
|
397
|
+
not load the runner* — is deliberately **not** filed as an `ATX-n`: it is not
|
|
398
|
+
falsifiable from inside the engine today, for the same reason the import
|
|
399
|
+
boundary gate cannot assert reachability, and a requirement that cannot go red
|
|
400
|
+
is the defect `archive`'s never-red gate exists to catch. It stays repo
|
|
401
|
+
discipline, in the gate that already holds it.
|
|
402
|
+
|
|
403
|
+
### Fixed
|
|
404
|
+
|
|
405
|
+
- **The workflow `attest init` writes named the special case of a load failure
|
|
406
|
+
and not the general one.** `spec-load-failed` shipped in this cycle, after the
|
|
407
|
+
skill body was last edited, so its stage 2 table listed only
|
|
408
|
+
`added-id-unmerged` — which fires just when the change *also* adds an id the
|
|
409
|
+
registry lacks. An agent hitting a typo in a specifier therefore met an ERROR
|
|
410
|
+
absent from the table it was told to branch on, while the row that used to
|
|
411
|
+
appear, `declared-not-run`, had been withdrawn for exactly that file. The three
|
|
412
|
+
rows now say how they relate: the general case, the specific case that
|
|
413
|
+
supersedes it, and the withdrawal that keeps one file to one finding.
|
|
414
|
+
`ATX-57` could not have caught it. It holds one-directionally — every code the
|
|
415
|
+
workflow names must be emittable — which refuses a misspelling and is silent
|
|
416
|
+
about an omission, and that asymmetry is deliberate: the workflow has no
|
|
417
|
+
business naming every code. The other direction is not a rule anyone can write.
|
|
418
|
+
|
|
419
|
+
- **`check --eval` started a Vite dev server per change, plus one for the
|
|
420
|
+
registry.** `unclaimedProposedSpecIssues` reads every change's delta in a loop,
|
|
421
|
+
and `readDelta` created and closed its own loader on each iteration — so the
|
|
422
|
+
cost was 1 + N servers for a command that needs one. Measured on a project
|
|
423
|
+
with six changes: **7 loaders → 1**, and the command went from ~3.3–3.6 s to
|
|
424
|
+
~1.7–1.9 s. Both readers now borrow a loader when the caller holds one, and
|
|
425
|
+
`runCheck` owns exactly one for the whole command through a `withLoader`
|
|
426
|
+
helper. Ownership sits at the command because neither reader knows how many
|
|
427
|
+
times it is about to be called.
|
|
428
|
+
Static `check` is unaffected in the way that matters: with no `--eval` it
|
|
429
|
+
passes `undefined` down and starts nothing at all, which is the property that
|
|
430
|
+
command is documented on (design §5.1).
|
|
431
|
+
|
|
432
|
+
- **`terminal.ts`'s `control` allocated three times the input to sanitise it.**
|
|
433
|
+
It spread the string into a per-code-point array, mapped, and rejoined, where
|
|
434
|
+
a character class lets the engine scan and — in the case every real registry
|
|
435
|
+
is — return the input untouched. Measured over 120,000 characters: **27×**
|
|
436
|
+
faster on plain ASCII, **54×** with newlines, **84×** on CJK. A payload that
|
|
437
|
+
is *entirely* control bytes is a wash at 0.9×, since then there is nothing to
|
|
438
|
+
fast-path and both spellings build a new string; both are linear either way,
|
|
439
|
+
so this is a constant factor rather than a second `ATX-59`.
|
|
440
|
+
Worth changing because the call count scales with the registry while the
|
|
441
|
+
length scales with whatever the registry chose: `render`'s `sanitised` calls
|
|
442
|
+
it once per statement, rationale, param key, param value and out-of-scope
|
|
443
|
+
entry, on the same reachable `render --check` path `ATX-59` came off.
|
|
444
|
+
**Byte-identical to the spelling it replaces**, which is the only thing that
|
|
445
|
+
mattered here: verified exhaustively over every code unit in the BMP, and over
|
|
446
|
+
200,000 randomised strings mixing control bytes, CJK, astral characters and
|
|
447
|
+
lone surrogates — surrogates being the case the spread existed to get right.
|
|
448
|
+
Newlines survive as they always did, by not being in the class.
|
|
449
|
+
|
|
450
|
+
- **Four comments describing a property the code beside them did not have.**
|
|
451
|
+
This repository keeps its decision record in prose, so a comment claiming
|
|
452
|
+
something the code does not do is the same class of defect as a diagnostic
|
|
453
|
+
pointing at the wrong line: it is what the next reader believes *instead of*
|
|
454
|
+
reading. None of these produced a wrong answer today, and each is now true.
|
|
455
|
+
- `merge.ts`'s `archivePath` is documented as "one function because two places
|
|
456
|
+
ask … a date computed twice could straddle midnight and disagree with
|
|
457
|
+
itself", and called `new Date()` on **every** invocation — four times per
|
|
458
|
+
merge. A merge crossing midnight would have refused against one directory
|
|
459
|
+
and written to another. The stamp is now read once in `applyMerge` and
|
|
460
|
+
passed down, which makes `archivePath` a pure function of its arguments —
|
|
461
|
+
the property that turns "the two cannot disagree" into something the code
|
|
462
|
+
holds rather than something the clock does.
|
|
463
|
+
- `pipeline.ts`'s `claimedSpecsOf` is documented as "built from the plan
|
|
464
|
+
rather than by re-parsing, so the files `--apply` touches are exactly the
|
|
465
|
+
files the gate ran", and its caller handed it a fresh `parseSpecs` over
|
|
466
|
+
every proposed spec — the second such parse in that function, with
|
|
467
|
+
`changeMergedPlan` having done the first. The two agreed because the same
|
|
468
|
+
two functions ran twice, which is agreement by transcription: the thing this
|
|
469
|
+
codebase extracts `declaredNotRunIssues` and `uncoveredIssues` to stop
|
|
470
|
+
relying on. `changeMergedPlan` now hands back the claimed half it already
|
|
471
|
+
built, so there is one parse, one split and one answer — and the archive
|
|
472
|
+
path does one less full parse of every proposed spec.
|
|
473
|
+
- `pipeline.ts`'s `runCover` ordered ids with a bare `.sort()` where the rest
|
|
474
|
+
of the engine goes through `byCodeUnit`. The result is identical on ids that
|
|
475
|
+
match the grammar, which is exactly why it was worth changing: `order.ts`
|
|
476
|
+
exists so ordering has one spelling, and that is a property which survives an
|
|
477
|
+
edit where an identical result does not.
|
|
478
|
+
- `status.ts`'s `statusRows` filtered the whole plan once per added id, the
|
|
479
|
+
pattern `validator.ts`'s `detectPotentialDrift` carries a comment about
|
|
480
|
+
having removed — O(requirements × scenarios), and the plan here is the
|
|
481
|
+
*merged* one, so the inner term is the whole repository's scenario count.
|
|
482
|
+
Indexed once now. The cost was genuinely invisible (20 ids against 2000
|
|
483
|
+
scenarios is 40k comparisons, well under a millisecond), so this lands as
|
|
484
|
+
the inconsistency it was rather than as a slow path; scenario order within a
|
|
485
|
+
row is unchanged, still file then line.
|
|
486
|
+
|
|
487
|
+
- **`orphan-test`'s fix hint was wrong for the case that produces the most of
|
|
488
|
+
them.** "Add it to the registry, or fix the id" is right for an id that is
|
|
489
|
+
genuinely absent and **wrong** for one already sitting in a registry file that
|
|
490
|
+
failed to load: the id is there, the fix is the load error, and following the
|
|
491
|
+
advice adds a duplicate. One unreadable `*.reqs.ts` orphans every scenario of
|
|
492
|
+
every requirement it declared, so the wrong advice was also the loudest thing
|
|
493
|
+
in the report, with `registry-invalid` — the one true finding — pushed to the
|
|
494
|
+
top and out of view.
|
|
495
|
+
**The finding stays; only the advice changes**, and that is the whole of the
|
|
496
|
+
decision. Suppressing `orphan-test` needs a prefix→file mapping for a file
|
|
497
|
+
that never parsed, which is exactly the file no ids are available from; the
|
|
498
|
+
weaker signal (some registry file failed to load, so suppress all of them) is
|
|
499
|
+
a strictly worse `check` on a multi-file registry where the other files are
|
|
500
|
+
fine and their orphans are real. Both remain open under *Under consideration*.
|
|
501
|
+
What is shippable today is that a command which cannot tell the two cases
|
|
502
|
+
apart should say so rather than guess — so when any registry file fails to
|
|
503
|
+
load, the hint names the uncertainty and the order to work in. `Issue.message`
|
|
504
|
+
is prose and never API, so this costs no consumer anything.
|
|
505
|
+
*"A registry file failed to load" is not "loading produced an ERROR", and the
|
|
506
|
+
first draft of this had it wrong.* `duplicate-prefix` and
|
|
507
|
+
`duplicate-requirement` are ERRORs raised **after** a successful read, and the
|
|
508
|
+
ids they are about are present — so keying the new wording on `hasError` told
|
|
509
|
+
a project with a prefix collision that a file had failed to load, which is
|
|
510
|
+
false, about a report where the original advice was correct. `loadRegistry`
|
|
511
|
+
now answers the question it is the only thing in a position to answer: which
|
|
512
|
+
files contributed no ids, taken from the outcome it already branches on rather
|
|
513
|
+
than re-derived from a list of codes a caller would have to keep in step.
|
|
514
|
+
|
|
515
|
+
- **The Vite logger wrapper reported that it had never warned, however much it
|
|
516
|
+
had.** `sanitisedLogger` builds its wrapper by spreading the base logger, and
|
|
517
|
+
`hasWarned` is the one member of Vite's `Logger` that is state rather than
|
|
518
|
+
behaviour — so the spread copied it **by value** at construction. Measured:
|
|
519
|
+
after one `warn` through the wrapper, the base reads `true` and the wrapper
|
|
520
|
+
still reads `false`. An accessor pair makes the wrapper a view of the base
|
|
521
|
+
rather than a snapshot of it, which is what every other member already was.
|
|
522
|
+
|
|
523
|
+
- **`render`'s id comparator was non-transitive on an id it could not take
|
|
524
|
+
apart.** `compareIds` split on `lastIndexOf('-')` with no guard, so an id with
|
|
525
|
+
no dash silently lost its last character (`AUTH` → prefix `AUT`) and produced
|
|
526
|
+
`NaN` for its number. The consequence is worse than a wrong tie: measured on
|
|
527
|
+
`['AUTH-3','AUTH-abc','AUTH-2']` the sort returned `AUTH-3, AUTH-abc, AUTH-2`,
|
|
528
|
+
which is not a sorted permutation of anything. The comparator is now total by
|
|
529
|
+
construction, and falls back to the whole id rather than to the engine's sort
|
|
530
|
+
stability — `renderMarkdown` feeds `--check`, so an order that depends on
|
|
531
|
+
anything but the ids is a freshness gate that can disagree with the run that
|
|
532
|
+
wrote the file.
|
|
533
|
+
**Robustness, not a live defect, and the distinction is worth keeping.** A
|
|
534
|
+
malformed id cannot reach here through any command: `RequirementIdSchema`
|
|
535
|
+
rejects it and `render` returns early on a registry that failed to load —
|
|
536
|
+
confirmed by trying it. What was actually wrong is that two functions held two
|
|
537
|
+
beliefs about one input space: `locate.ts`'s `idPrefix` handles a dashless id
|
|
538
|
+
and records why ("this also runs over ids a delta proposed"), and this one
|
|
539
|
+
did not. They agree now.
|
|
540
|
+
|
|
541
|
+
- **A spec file that failed to load was reported as a file full of skipped
|
|
542
|
+
scenarios, and the file itself was named nowhere.** `verify` on a two-file
|
|
543
|
+
project with one bad import produced three ERRORs: `tests-red`, and one
|
|
544
|
+
`declared-not-run` per scenario in the broken file, each saying *"skipped, or
|
|
545
|
+
excluded by an `.only`?"*. That is a guess at a cause and the wrong one — it
|
|
546
|
+
sends the reader to audit a correct file for a `skip` that is not there, while
|
|
547
|
+
the import error explaining everything is named in no finding at all. The gate
|
|
548
|
+
had a diagnosis for one shape of this (`added-id-unmerged`, when the change
|
|
549
|
+
also adds an unmerged id); every other way a file fails to import had none, on
|
|
550
|
+
either command.
|
|
551
|
+
**The obvious repair was the wrong one, and `gate.ts` had already written down
|
|
552
|
+
why** — three lines from where it would have gone. Suppressing
|
|
553
|
+
`declared-not-run` for unloaded files produces a red report with nothing
|
|
554
|
+
pointing at the file: silence in place of a wrong message, and worse than what
|
|
555
|
+
it replaces. So the replacement comes first. `spec-load-failed` (`ATX-62`)
|
|
556
|
+
names the file on both commands that run a suite, and stands down where a more
|
|
557
|
+
specific diagnosis already named it — one file, one finding, and the one
|
|
558
|
+
carrying a fix wins. It deliberately does **not** restate the import error:
|
|
559
|
+
the run output above the report already has it, and a copy is a second place
|
|
560
|
+
for it to be wrong. No `reqId`, because a file that never loaded has no
|
|
561
|
+
established relation to any requirement.
|
|
562
|
+
**Both commands now ask one function**, `notRunIssues`, rather than each
|
|
563
|
+
spelling out the order themselves — the cause before the absences it caused,
|
|
564
|
+
and never the absences alone. That is how the two came to differ at all: the
|
|
565
|
+
gate learned to withdraw a misleading line and `verify` did not, so one run
|
|
566
|
+
produced a different diagnosis depending on which command asked. It is the
|
|
567
|
+
same argument that extracted `declaredNotRunIssues` from those two commands,
|
|
568
|
+
arriving one level up, and it is what stops this particular divergence from
|
|
569
|
+
being re-openable by an edit to either caller.
|
|
570
|
+
**`ATX-50` reaches `verify` now, and its line did not move.** That requirement
|
|
571
|
+
withdraws the absences a load-failure diagnosis replaces, and its recorded
|
|
572
|
+
condition was always *"a correct diagnosis exists for this file"* rather than
|
|
573
|
+
*"the file failed to load"* — it was scoped to the gate because the gate was
|
|
574
|
+
the only place that condition could hold. It now holds on both. The "and no
|
|
575
|
+
further" half is what an over-broad fix would have cost, and is separately
|
|
576
|
+
attested: a scenario that really was skipped, in a file that loaded, is still
|
|
577
|
+
reported.
|
|
578
|
+
**Behaviour change, for a project that has a spec file failing to load.**
|
|
579
|
+
`verify` and `archive` report one `spec-load-failed` per unloaded file instead
|
|
580
|
+
of a `declared-not-run` per scenario in it. Both are ERRORs and both runs were
|
|
581
|
+
already red, so no green becomes red and no red becomes green; what changes is
|
|
582
|
+
which finding is in the report and what it says. Anything branching on
|
|
583
|
+
`declared-not-run` for such a file — no consumer is known to — sees the new
|
|
584
|
+
code instead. Additive to the JSON surface (a new `code` value), so
|
|
585
|
+
`SCHEMA_VERSION` is unchanged.
|
|
586
|
+
|
|
587
|
+
- **`attest render` let the registry it was reading write to the terminal
|
|
588
|
+
reading it.** ATX-37 put every byte the CLI prints through a sanitiser, and
|
|
589
|
+
the scope line of that requirement says "everything the CLI writes to a
|
|
590
|
+
terminal" — but `render` builds a *document* by string concatenation that
|
|
591
|
+
never went past it. The static reader hands back the **cooked** string, so an
|
|
592
|
+
escape written as an escape in a `*.reqs.ts` is a real control byte by the
|
|
593
|
+
time it is concatenated. *Measured on the built CLI*, with no `--eval` and no
|
|
594
|
+
flag at all: a statement carrying `ESC [2K CR` erases the reviewer's line and
|
|
595
|
+
repaints a verdict, and `ESC ]0;` retitles their window. On a fork merge
|
|
596
|
+
request the author is not the reviewer, and a registry — not a test, not a
|
|
597
|
+
script — is the whole payload.
|
|
598
|
+
**The obvious fix was the wrong one, and the loop said so before it was
|
|
599
|
+
written.** Sanitising the terminal write would have left `--out` putting the
|
|
600
|
+
same bytes in a file that is committed, served, and read again later by `cat`,
|
|
601
|
+
by `less -R`, or by a static site generator. The artifact outlives the run, so
|
|
602
|
+
the run is the wrong place to defend: the obligation is now over the document
|
|
603
|
+
(`ATX-58`), which is also what keeps `--check` honest, since both sides of
|
|
604
|
+
that comparison are built from one function. Newlines survive, because a
|
|
605
|
+
rendered rationale is Markdown prose that may legitimately span lines — and
|
|
606
|
+
they are *not* indented the way ATX-37 indents them, because four spaces in
|
|
607
|
+
Markdown is a code block.
|
|
608
|
+
**Behaviour change, for one shape of project.** A registry that actually
|
|
609
|
+
contains a control character renders to different bytes than before, so a
|
|
610
|
+
committed rendering goes `stale-spec-doc` once and is fixed by regenerating.
|
|
611
|
+
A registry without one is byte-identical: this repo's own `SPEC.md` changed by
|
|
612
|
+
insertion only.
|
|
613
|
+
|
|
614
|
+
- **A statement padded with whitespace cost `render` quadratic time, and
|
|
615
|
+
`render --check` is the gate people put in CI.** The table cell collapsed a
|
|
616
|
+
multi-line statement with a pattern whose leading quantifier is followed by a
|
|
617
|
+
required character, so a whitespace run containing no newline was consumed,
|
|
618
|
+
failed, and re-tried one character shorter from every position in it.
|
|
619
|
+
*Measured through the CLI*: 120,000 spaces in one statement cost 8.6 seconds
|
|
620
|
+
of pure CPU, 320,000 cost 58 — under the **static** reader, so a fork merge
|
|
621
|
+
request can burn a runner without executing a line of anything.
|
|
622
|
+
**The first repair measured slower than the defect**, which is the part worth
|
|
623
|
+
keeping: `[^\S\n]*\n[^\S\n]*`, which stops the class matching the newline,
|
|
624
|
+
is just as quadratic, because the backtracking was never about which
|
|
625
|
+
characters the class held — it was about the quantifier having something after
|
|
626
|
+
it. Matching each maximal whitespace run once and deciding in a callback has
|
|
627
|
+
nothing after the quantifier to fail into. Byte-identical to the old pattern,
|
|
628
|
+
asserted over 200,000 randomised inputs and confirmed on the real corpus, and
|
|
629
|
+
now bounded by `ATX-59` at a budget three orders of magnitude looser than a
|
|
630
|
+
linear implementation needs — a defect detector, not a machine-speed detector.
|
|
631
|
+
|
|
632
|
+
- **Grouping scenarios under a `describe` took the whole spec file down, and
|
|
633
|
+
the static half called that file fully covered.** Vitest defers a nested
|
|
634
|
+
collector until after the parent's callback returns — measured: at the end of
|
|
635
|
+
the outer callback the inner one has not run — so the module-level
|
|
636
|
+
`currentReqId` that `requirement()` set and restored around its body was
|
|
637
|
+
already back to `null` when a grouped `scenario()` ran. It threw, and a throw
|
|
638
|
+
during collection is a *file* error: nothing in that file was collected.
|
|
639
|
+
**The blast radius is the file, not the group.** A correct, ungrouped scenario
|
|
640
|
+
belonging to a *different* requirement in the same file came back
|
|
641
|
+
`declared-not-run` too, blamed on "skipped, or excluded by an `.only`?" —
|
|
642
|
+
sending the reader to audit their spec for something that is not there.
|
|
643
|
+
**And the static commands were not merely misdirected, they were wrong.**
|
|
644
|
+
`check` answered `✓ No issues` and `cover` answered `0 uncovered` for a file
|
|
645
|
+
that could not execute a single line. Those are the two commands documented as
|
|
646
|
+
the fast pre-check that is safe to run first on an untrusted fork, so a
|
|
647
|
+
pipeline running only `check` — a use this reference recommends — would pass a
|
|
648
|
+
change whose specs never run. That is a false green, which is the one failure
|
|
649
|
+
class this tool exists to prevent.
|
|
650
|
+
**Fixed at both ends, because it was one seam disagreeing with itself.**
|
|
651
|
+
`parser.ts` has always recursed to any depth while the task-tree walk counted
|
|
652
|
+
only *direct* `test` children of a `[reqId]` suite, so even a runtime that
|
|
653
|
+
stopped throwing would have reported a grouped scenario that ran and passed as
|
|
654
|
+
never run. The module-level variable is **deleted** rather than repaired — it
|
|
655
|
+
was a second answer to a question the suite name already answers — and both
|
|
656
|
+
sides now go through one `req-suite.ts` that owns the `[id]` encoding.
|
|
657
|
+
**The narrower fix was measured and rejected.** Having the parser refuse a
|
|
658
|
+
`scenario()` that is not a direct child looked smaller and would have closed
|
|
659
|
+
the false green too, but a *synchronous* wrapper — `forEach`, or a local
|
|
660
|
+
helper — works correctly today at both ends, and no static rule separates one
|
|
661
|
+
from a deferred callback. It would have rejected working code to describe a
|
|
662
|
+
runtime property.
|
|
663
|
+
*Behaviour change, two of them.* The nesting contract is now enforced when a
|
|
664
|
+
scenario **runs** rather than when it is collected, so a scenario genuinely
|
|
665
|
+
outside any `requirement()` fails as one test instead of taking its file down.
|
|
666
|
+
Nothing that passed before fails now.
|
|
667
|
+
The second is smaller and out of contract, and is stated because it is silent:
|
|
668
|
+
`scenario` used to hand its function to `it` directly, so Vitest called it with
|
|
669
|
+
the test context, and it is now invoked with no arguments. The declared type
|
|
670
|
+
has always been `() => void | Promise<void>` and the design document has always
|
|
671
|
+
shown it that way, so TypeScript rejected any use of that argument; a
|
|
672
|
+
JavaScript consumer who reached for `ctx.skip()` inside a scenario would find
|
|
673
|
+
it gone. Preserved deliberately as-declared rather than threaded through: the
|
|
674
|
+
contract is the zero-argument one, and adding a parameter to keep an
|
|
675
|
+
undeclared behaviour alive is a hook for a need no requirement has. `docs/*/attest-design.md` §3's runtime sample is
|
|
676
|
+
updated, as is the ESM-only note in §11, whose stated hazard was the very
|
|
677
|
+
module-level state this removes.
|
|
678
|
+
|
|
679
|
+
- **Four verdict predicates that were not `hasError`, one of which would have
|
|
680
|
+
reported a failed gate after the merge had already written the files.**
|
|
681
|
+
`types.ts` states the invariant in as many words — `hasError` is "the single
|
|
682
|
+
predicate the pipeline verdict, the JSON `ok`, and the CLI exit code all read
|
|
683
|
+
from, so those three can never disagree" — and four sites spelled it by hand:
|
|
684
|
+
`archive`'s report and its human rendering both asked `blocking.length === 0`,
|
|
685
|
+
and the merge path asked `issues.some(i => i.level === 'ERROR')` twice.
|
|
686
|
+
**The `archive` one had a consequence its own neighbour predicts.**
|
|
687
|
+
`runArchiveApply` deliberately keeps the gate's non-blocking output, with the
|
|
688
|
+
comment that dropping a WARNING "would make `--apply` quieter than the same
|
|
689
|
+
command without it" — so a WARNING reaching `blocking` is not hypothetical, it
|
|
690
|
+
is planned for. The first one to arrive made the report answer `ok: false`, the
|
|
691
|
+
process exit 1, and the terminal print `✗ Gate failed` **after `--apply` had
|
|
692
|
+
written the registry, renamed the specs and moved the change folder**.
|
|
693
|
+
Reporting failure after a destructive step succeeded is the worst shape a
|
|
694
|
+
verdict can take, and the only thing preventing it was that every issue those
|
|
695
|
+
paths construct happens to carry a literal `ERROR`.
|
|
696
|
+
Now `ATX-60`, stated over the predicate rather than over `archive`, because the
|
|
697
|
+
defect is that the question had more than one spelling and any command could
|
|
698
|
+
grow a fifth — and stated to cover the human verdict as well as the machine
|
|
699
|
+
one, since both were wrong here independently and fixing either alone leaves a
|
|
700
|
+
command whose two surfaces disagree about whether it failed. Distinct from
|
|
701
|
+
`ATX-5`, which promises the implication one way only: `ok` false whenever there
|
|
702
|
+
is an ERROR says nothing about `ok` false when there is not, which is the half
|
|
703
|
+
that broke. No behaviour change today, which is the argument for doing it
|
|
704
|
+
before something has been reported wrong rather than after. **No
|
|
705
|
+
`schemaVersion` bump**: the envelope keeps every field it had and the `Issue`
|
|
706
|
+
interface is untouched — what changed is the value `ok` takes in a case
|
|
707
|
+
nothing can currently produce, and a consumer branching on `ok` and
|
|
708
|
+
`issues[].code` as documented reads the same report it always did.
|
|
709
|
+
*Found by reading, and the count was wrong when it was filed:* the note that
|
|
710
|
+
queued this named three sites; the rendering in `cli/index.ts` was the fourth,
|
|
711
|
+
and it is the one a human would actually have seen.
|
|
712
|
+
|
|
713
|
+
- **Sweeping for a second backtracking site found something that is not
|
|
714
|
+
backtracking.** Every regex `src/` executes was measured against 21
|
|
715
|
+
adversarial input shapes at doubling sizes, with the old table-cell pattern
|
|
716
|
+
left in as a positive control — it was the only superlinear hit, which is what
|
|
717
|
+
makes the other eighteen being linear evidence rather than silence. The
|
|
718
|
+
structural reason is worth keeping: that pattern began with a quantifier that
|
|
719
|
+
can match nothing, so **every index** was a valid start; every other regex here
|
|
720
|
+
begins with a literal or a required class, which caps the starts at how often
|
|
721
|
+
that character occurs.
|
|
722
|
+
What the sweep turned up instead was the code-span fence, which measured the
|
|
723
|
+
longest run of backticks with `Math.max(0, ...runs)` — one stack argument per
|
|
724
|
+
run, so a param holding a few hundred thousand of them exhausted it. Same
|
|
725
|
+
reachable path as the quadratic one (`attest render` and `render --check`,
|
|
726
|
+
static reader, no `--eval`), same shape of defect — a registry choosing what
|
|
727
|
+
rendering costs the person reading it — and a different mechanism entirely.
|
|
728
|
+
`ATX-59` now states both and is counted as two obligations, because a
|
|
729
|
+
quadratic pattern and an unbounded spread regress independently.
|
|
730
|
+
A third candidate was measured and **rejected**: a deeply nested literal does
|
|
731
|
+
overflow the stack, but TypeScript's own parser gives out around depth 1,000
|
|
732
|
+
and this project's AST walk not until 2,000, so the limit belongs to the
|
|
733
|
+
compiler Attest is built on. It also lands where the contract already puts it —
|
|
734
|
+
a well-formed `internal-error` envelope under `--json`, and a sanitised stack
|
|
735
|
+
on the side people read.
|
|
736
|
+
|
|
16
737
|
## [0.6.0] - 2026-08-03
|
|
17
738
|
|
|
18
739
|
### Added
|
|
@@ -2436,57 +3157,137 @@ feedback reports are triaged into issues or `Unreleased` and deliberately not
|
|
|
2436
3157
|
kept in this tree; that rule stands, and holds for anything with a reporter, a
|
|
2437
3158
|
repro, or a state to track. What is kept here is the narrower thing this file
|
|
2438
3159
|
already keeps one section of: a **decision with an argument attached**, where
|
|
2439
|
-
losing the argument would cost more than losing the task.
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
- **
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
3160
|
+
losing the argument would cost more than losing the task.
|
|
3161
|
+
|
|
3162
|
+
**Where an entry went is recorded under the version that shipped it.** This
|
|
3163
|
+
section keeps no ledger of its own departures — one was maintained here by hand
|
|
3164
|
+
for several releases, and a count kept in prose with nothing to check it is a
|
|
3165
|
+
claim that goes wrong quietly. What is worth keeping is what the section learned
|
|
3166
|
+
about itself, which is three things.
|
|
3167
|
+
|
|
3168
|
+
**An entry can sit here for releases on the strength of a figure nobody has
|
|
3169
|
+
checked.** The discipline aims at keeping the *argument*, not at re-testing the
|
|
3170
|
+
evidence under it, so the first act of building one is re-measuring what it
|
|
3171
|
+
claims rather than implementing what it proposes. That has paid twice: once
|
|
3172
|
+
killing an entry outright, when the spans it rested on turned out not to be
|
|
3173
|
+
spans; once correcting an entry's headline number before a line of it was
|
|
3174
|
+
written. **What a release costs its adopters is not known on the day it is cut**
|
|
3175
|
+
— several entries arrived from one adoption report, about shapes the tool made
|
|
3176
|
+
someone work around without ever erroring. And **the fixes with no observable
|
|
3177
|
+
symptom are worth batching**: individually none justifies a commit, together they
|
|
3178
|
+
cost about an hour, and they are the group no gate could have found, since having
|
|
3179
|
+
no symptom is the definition of what a gate cannot see.
|
|
3180
|
+
|
|
3181
|
+
- **Binding the `✗` samples to fixtures whose rejection is asserted.** A sample
|
|
3182
|
+
showing input the engine must *reject* can be bound the way a runnable one is,
|
|
3183
|
+
to a fixture whose rejection is the thing under test — which is what
|
|
3184
|
+
`fixtures/eval-invalid-registry` already is. This is the more valuable half of
|
|
3185
|
+
what was once one entry with the region-marking below, because a counterexample
|
|
3186
|
+
that quietly stopped being a counterexample is the one kind of stale sample
|
|
3187
|
+
that actively teaches the wrong thing: a reader shown "this fails" about input
|
|
3188
|
+
the engine now accepts is worse off than one shown nothing. It is also the
|
|
3189
|
+
expensive half, needing a fixture per diagnostic, which is why it was never the
|
|
3190
|
+
free part of that entry and is now the only part left in it.
|
|
3191
|
+
*The mirror rule caps what can be claimed on the Chinese side.* `docs/zh/**`
|
|
3192
|
+
matches `docs/en/**` down to the comments inside its code samples, so
|
|
3193
|
+
byte-equality holds on one side only and the zh blocks stay translated prose
|
|
3194
|
+
around the same code — the parity gate in `[0.4.3]` covers them, and it covers
|
|
3195
|
+
structure, not meaning. The residual risk recorded with the whole-file half in
|
|
3196
|
+
`[0.4.3]` — that the gate and the run proving the fixture still works live in
|
|
3197
|
+
different suites — applies here identically.
|
|
3198
|
+
|
|
3199
|
+
- **The child run's stdout is not Attest's, and `--json` promises it is.**
|
|
3200
|
+
`runAndCollect` starts Vitest with the parent's fd 1 inherited, so a spec file
|
|
3201
|
+
in the project under test writes straight to the same stream the report goes
|
|
3202
|
+
to. `quiet: true` does not close it: `silent: true` suppresses Vitest's
|
|
3203
|
+
*console interception*, and a bare `process.stdout.write` in a spec — at
|
|
3204
|
+
collection time or inside a scenario body, both measured — goes past it
|
|
3205
|
+
untouched. Neither `core/terminal.ts` nor `sanitisedLogger` is on that path at
|
|
3206
|
+
all, because no Attest code is.
|
|
3207
|
+
*The machine surface is the half that is actually new.* `cli/index.ts` opens
|
|
3208
|
+
by stating that under `--json` "the only thing written to stdout is one
|
|
3209
|
+
machine-readable report", and §9.1 records that this path "was never exposed —
|
|
3210
|
+
`JSON.stringify` escapes every C0 character". Both hold for `check`, `cover`,
|
|
3211
|
+
`render` and `status`, and neither holds for `verify` or `archive`: measured,
|
|
3212
|
+
a spec writing `{"ok":true,"POLLUTION":"…"}` puts that document **first** on
|
|
3213
|
+
stdout, ahead of the real report, so `JSON.parse` of the whole stream fails
|
|
3214
|
+
(position 46) while anything reading line-wise or taking the first object
|
|
3215
|
+
reads the attacker's verdict. The terminal half is the same hole and the
|
|
3216
|
+
smaller finding — `ESC[2K CR` plus a forged `ERROR` header at column 0 and an
|
|
3217
|
+
`OSC 0` window retitle all arrive intact — but `verify` executes project code
|
|
3218
|
+
by design, so repainting a terminal is not a capability that path lacked.
|
|
3219
|
+
*The fix belongs to `runAction`, not to `runner.ts`.* That function is already
|
|
3220
|
+
the single convergence point for "one output, one exit code", which is the
|
|
3221
|
+
same argument that put `formatCrash` in `cli/report.ts` rather than leaving a
|
|
3222
|
+
copy in each `catch`: `runner.ts` would be made to hold a contract it does not
|
|
3223
|
+
own, while `runAction` is the only place the promise is made.
|
|
3224
|
+
|
|
3225
|
+
- **`render`'s document can carry raw HTML and a forged requirement section.**
|
|
3226
|
+
`sanitised` is the entry §9.1 names and it strips control characters and
|
|
3227
|
+
nothing else, so `statement` and `rationale` reach the Markdown by
|
|
3228
|
+
concatenation exactly as written. Measured: a `rationale` containing newlines
|
|
3229
|
+
and `## ATX-999` renders a whole section — heading, statement, `**Why:**` —
|
|
3230
|
+
for a requirement no registry holds, sitting between two real ones and worded
|
|
3231
|
+
identically; `<script>` and `<img onerror>` survive the same way. `check` and
|
|
3232
|
+
`cover` see only the real id, and `render --check` cannot object, because that
|
|
3233
|
+
*is* what the registry renders to now.
|
|
3234
|
+
*Not an accepted risk, which is the part worth pinning.* §9.1 and the
|
|
3235
|
+
reference both scope their guarantee to control characters, and the
|
|
3236
|
+
reference's heading over that paragraph is "Safe to read, and safe to keep",
|
|
3237
|
+
with "a site generator" named in the threat it describes. What *is* a decision
|
|
3238
|
+
is that prose may be marked up — `plain()` escapes a param value precisely
|
|
3239
|
+
because it is data while the statement around it is not — and that decision
|
|
3240
|
+
has never been separated from allowing raw HTML or a heading. So the entry is
|
|
3241
|
+
as much about stating which of the three is intended as about the escaping,
|
|
3242
|
+
and if the answer is that Markdown stays and HTML and headings go, that is a
|
|
3243
|
+
fourth obligation in §9.1 rather than a patch at an emitter.
|
|
3244
|
+
|
|
3245
|
+
- **`attest init` follows a symlink out of the project root.** `runInit` does
|
|
3246
|
+
`mkdir(dirname(dest), { recursive: true })` and then `writeAtomic`, and
|
|
3247
|
+
neither asks whether `dest` is still under `root`. A `.claude/skills/attest`
|
|
3248
|
+
planted in the repository as a link to anywhere the user can write is followed
|
|
3249
|
+
by both: measured on Windows with a junction, a file at the target holding
|
|
3250
|
+
`USER OWNED FILE - DO NOT OVERWRITE` came back holding the skill. The report
|
|
3251
|
+
prints the relative path, so nothing on screen says where it went.
|
|
3252
|
+
*`write.ts` is not the gap and neither is `targets.ts`'s content.* The `wx`
|
|
3253
|
+
flag defends the *temporary* path against a planted link and does exactly
|
|
3254
|
+
that; the destination's directory was never in its scope. And `workflowBody()`
|
|
3255
|
+
is a pure function of nothing, so no byte of the checked project reaches the
|
|
3256
|
+
file — the exposure is the path, not the content, which is `targets.ts`'s
|
|
3257
|
+
first rule ("Attest owns the path") being an assumption rather than a check.
|
|
3258
|
+
The check belongs beside `resolveTargets`, which is already all-or-nothing for
|
|
3259
|
+
the same reason: a name known to be unwritable must not leave half a set
|
|
3260
|
+
behind.
|
|
3261
|
+
|
|
3262
|
+
- **A deeply nested literal takes `check` down before either reader sees it.**
|
|
3263
|
+
40 KB of `[[[…]]]` in a `params` value exhausts the stack inside
|
|
3264
|
+
`ts.createSourceFile` — TypeScript's own recursive-descent parser, so
|
|
3265
|
+
`literalValue`'s recursion is never reached and hardening it would change
|
|
3266
|
+
nothing. `parser.ts` opens with the same call, so `verify` takes it too, and
|
|
3267
|
+
`loadRegistry` reads with `Promise.all`, so one file ends the whole command.
|
|
3268
|
+
*It fails closed, which is why this is a repair and not an emergency.*
|
|
3269
|
+
Measured: `internal-error`, `ok: false`, exit 1, and a well-formed `--json`
|
|
3270
|
+
envelope. What it costs is that the command this project tells people to run
|
|
3271
|
+
first on an untrusted fork MR can be made to say nothing but that, forever.
|
|
3272
|
+
The shape of the fix is already in `locate.ts`: `unreadableFiles` exists to
|
|
3273
|
+
say "this file could not be read" without ending the run, and a `RangeError`
|
|
3274
|
+
out of a single `read` or `parseSpecFile` is that, so a hostile file scraps
|
|
3275
|
+
only itself and `check`'s breadth contract survives it.
|
|
3276
|
+
|
|
3277
|
+
- **`status` prints one line the sanitiser never sees.** `cli/index.ts` writes
|
|
3278
|
+
`Change: ${result.change}` bare, while `formatStatus` puts the same value
|
|
3279
|
+
through `inline` twice, three lines apart in the same output. Measured with a
|
|
3280
|
+
directory named with U+009B — the C1 CSI the class in `control` exists for,
|
|
3281
|
+
and one NTFS permits — the header carried `c2 9b` raw while the closing
|
|
3282
|
+
`Not a verdict:` line carried a space. A change name is a directory name in
|
|
3283
|
+
the repository under test, and the workflow `init` writes tells an agent to
|
|
3284
|
+
run `attest status <name>`, so the value is the project's to choose.
|
|
3285
|
+
*One `inline()` call, and the entry is here for the reason rather than the
|
|
3286
|
+
fix.* §9.1 says a stream has no entry, only writes, so the obligation sits on
|
|
3287
|
+
every write or not at all — and this is the second time a single missed write
|
|
3288
|
+
has been the whole defect, after the two `catch` blocks that printed a raw
|
|
3289
|
+
stack beside sanitised diagnostics. The sweep filed above under `ATX-37` is
|
|
3290
|
+
what would have found it; this is one more measurement in favour of it.
|
|
2490
3291
|
|
|
2491
3292
|
## Under consideration
|
|
2492
3293
|
|
|
@@ -2500,6 +3301,41 @@ deletion. What each entry has to carry is the evidence currently blocking the
|
|
|
2500
3301
|
decision, because that is what a re-proposal a year from now would otherwise
|
|
2501
3302
|
have to gather again.
|
|
2502
3303
|
|
|
3304
|
+
- **A static sweep for the two properties claimed over the whole of `src/`.**
|
|
3305
|
+
The quantifier table's first strict read left three rows wanting the same
|
|
3306
|
+
thing, and the repo already owns the idiom: `tests/import-boundary.spec.ts` is
|
|
3307
|
+
a sweep over the import graph asserting a cross-cutting property no runtime
|
|
3308
|
+
test can see, written for the same reason — "only the graph shows it".
|
|
3309
|
+
*The two candidates, and they are not equally shippable.* `ATX-15` is the easy
|
|
3310
|
+
one: **no `.sort` outside `byCodeUnit`, and no `localeCompare`.** Measured over
|
|
3311
|
+
`src/` today — 21 `.sort` call sites, every one reaching `byCodeUnit`, but only
|
|
3312
|
+
19 naming it on the call line: `render.ts` goes through its own `compareIds`,
|
|
3313
|
+
and `merge.ts` through a comparator that spans two lines. The only four
|
|
3314
|
+
occurrences of `localeCompare` are comments explaining why it is not used. A sweep would have **zero false positives on the current corpus**,
|
|
3315
|
+
which is the bar `divergent-param` failed and `compound-requirement` cleared —
|
|
3316
|
+
and those two exceptions are already the warning that this needs the AST rather
|
|
3317
|
+
than a line-wise regex, plus a rule for a comparator that delegates. `ATX-37` is the hard one: what it needs is "every project-derived
|
|
3318
|
+
string interpolated into a terminal line goes through `inline`", and that is
|
|
3319
|
+
not a token scan. The shape that would work is a scenario driving every
|
|
3320
|
+
exported `format*` in `src/cli/report.ts` with hostile input — a real roster,
|
|
3321
|
+
since the module's exports are what a new formatter joins — but each takes a
|
|
3322
|
+
different argument type, so it needs a hostile fixture per signature rather
|
|
3323
|
+
than one loop.
|
|
3324
|
+
*What blocks the decision.* Two things, one per candidate. The `ATX-15` sweep
|
|
3325
|
+
is cheap and precise and would be **the first rule in this repository that
|
|
3326
|
+
polices how `src/` is written rather than what it does** — a lint rule wearing
|
|
3327
|
+
a scenario's clothes, and the question is whether that belongs in `self/` (as
|
|
3328
|
+
an obligation `ATX-15` already carries) or in `tests/` beside the import
|
|
3329
|
+
boundary. Filing it under `ATX-15` makes it a permanent obligation with a
|
|
3330
|
+
scenario; filing it in `tests/` makes it deletable in a green diff, which
|
|
3331
|
+
`ATX-57`'s rationale argues against in as many words. The `ATX-37` roster is
|
|
3332
|
+
blocked on cost rather than principle: eight formatters, eight fixtures, and
|
|
3333
|
+
the honest count of what it buys is three currently-correct sites.
|
|
3334
|
+
*Why neither is urgent.* Both properties hold everywhere today, verified by
|
|
3335
|
+
reading. What is missing is not correctness but the thing that keeps it — and
|
|
3336
|
+
the measurement above is what a re-proposal would otherwise have to gather
|
|
3337
|
+
again.
|
|
3338
|
+
|
|
2503
3339
|
- **Bounding the fan-out in `findFiles` and `parseSpecs`.** Both walk the
|
|
2504
3340
|
project with no concurrency limit: `findFiles` recurses through
|
|
2505
3341
|
`Promise.all(subdirs.map(walk))`, so every directory in the tree is opened at
|
|
@@ -2604,6 +3440,161 @@ have to gather again.
|
|
|
2604
3440
|
here rather than moving to `Considered and rejected`: documenting a trap is
|
|
2605
3441
|
not the same as deciding not to detect it.
|
|
2606
3442
|
|
|
3443
|
+
- **Whether `check` and `verify` should refuse a half-loaded registry the way
|
|
3444
|
+
`cover` and `render` do.** Those two return early on `hasError(loadIssues)`,
|
|
3445
|
+
and the argument is recorded at both call sites: a report computed from a
|
|
3446
|
+
half-loaded registry is a lie, and a lie that reads as a pass is the worst
|
|
3447
|
+
shape it can take — `cover` in particular used to answer `ok: true` on a repo
|
|
3448
|
+
whose intent layer had stopped being readable, because an empty registry is
|
|
3449
|
+
fully covered. `check` and `verify` carry on regardless.
|
|
3450
|
+
*What that produces, measured* on a project with one unreadable `*.reqs.ts`:
|
|
3451
|
+
a `registry-invalid`, then an `orphan-test` for **every** scenario belonging to
|
|
3452
|
+
that file's requirements, plus `empty-spec`, `tests-red` and a
|
|
3453
|
+
`declared-not-run` per scenario — six derived findings from one cause. Worse,
|
|
3454
|
+
`orphan-test`'s fix hint ("Add it to the registry, or fix the id") is *wrong*
|
|
3455
|
+
for an id already sitting in the registry that failed to load, so the loudest
|
|
3456
|
+
advice in the report points at work that must not be done. In a multi-file
|
|
3457
|
+
registry it scales with the broken file's scenario count, and the one true
|
|
3458
|
+
finding is at the top where a wall of red pushes it out of view.
|
|
3459
|
+
*Why it is not simply the same call as `cover` and `render`.* Each of those
|
|
3460
|
+
produces exactly **one** artifact, and refusing to produce it is the whole of
|
|
3461
|
+
the decision. `check` is the command a pipeline runs first and its contract is
|
|
3462
|
+
breadth — returning three findings instead of nine because one of the nine was
|
|
3463
|
+
a load failure makes it quieter about problems it can still see perfectly well,
|
|
3464
|
+
which is the opposite of what it is for. `verify` has the same shape with a run
|
|
3465
|
+
attached.
|
|
3466
|
+
*What blocks the decision.* The narrower move — keep reporting everything
|
|
3467
|
+
except the findings **derived from** the missing registry, i.e. suppress
|
|
3468
|
+
`orphan-test` for ids whose prefix belongs to a file that failed to load —
|
|
3469
|
+
needs a prefix→file mapping for a file that never parsed, and that is exactly
|
|
3470
|
+
the file `loadRegistry` has no ids from. Recovering the mapping means reading
|
|
3471
|
+
the prefix out of something other than the registry's own keys (the file name,
|
|
3472
|
+
or a partial AST read that survives the failure), and whether either is
|
|
3473
|
+
trustworthy enough to gate a suppression on is unmeasured. Suppressing on the
|
|
3474
|
+
weaker signal "some registry file failed to load, so suppress all
|
|
3475
|
+
`orphan-test`" is the other candidate and is a strictly worse `check` on a
|
|
3476
|
+
multi-file registry, where the other files are fine and their orphans are real.
|
|
3477
|
+
**The wrong *advice* has been separated out and fixed; the noise has not.**
|
|
3478
|
+
`[0.7.0]` changes `orphan-test`'s fix hint when any registry file failed
|
|
3479
|
+
to load, so the loudest line in that report stops telling the reader to add an
|
|
3480
|
+
id that already exists. That needed no mapping, because it claims nothing about
|
|
3481
|
+
*which* orphans came from the broken file — only that the command cannot tell,
|
|
3482
|
+
and what order to work in. It is worth separating because the two halves were
|
|
3483
|
+
filed as one problem and are not: **being wrong** was fixable today at the cost
|
|
3484
|
+
of a sentence, and **being loud** is the part still blocked on the mapping
|
|
3485
|
+
above. The entry stays open on exactly that, with one blocker retired: the
|
|
3486
|
+
measured six derived findings from one cause are still six, and only one of
|
|
3487
|
+
them has stopped pointing at work that must not be done.
|
|
3488
|
+
|
|
3489
|
+
- **A statement that quantifies over a surface, with scenarios covering only the
|
|
3490
|
+
part that existed when it was written.** Found by reading, and it had already
|
|
3491
|
+
cost something: `ATX-37` says "**everything** the CLI writes to a terminal —
|
|
3492
|
+
a report, and the stack of a crash — MUST replace every control character",
|
|
3493
|
+
and `self/report.spec.ts` covers `formatIssues` and `formatCrash`. Those were
|
|
3494
|
+
the two functions that existed when it was written. `render` builds a document
|
|
3495
|
+
by concatenation that never went past the sanitiser, and a registry could drive
|
|
3496
|
+
the reviewer's terminal from `attest render` with no flag at all — shipped
|
|
3497
|
+
under `[0.7.0]` as the fix, but the *shape* is not fixed and is not
|
|
3498
|
+
specific to that requirement.
|
|
3499
|
+
The shape: a universal quantifier in the statement, a finite set of scenarios,
|
|
3500
|
+
and nothing anywhere that holds the two together. It reads as covered — the id
|
|
3501
|
+
has scenarios, `cover` is green, `check` is green — and the quantifier is the
|
|
3502
|
+
part no gate is looking at. Every one of the three mechanisms this project
|
|
3503
|
+
sells operates *below* it: coverage asks whether an id has a scenario,
|
|
3504
|
+
never-red asks whether that scenario discriminated, drift asks whether it reads
|
|
3505
|
+
the params. None asks whether the scenarios span what the sentence claims.
|
|
3506
|
+
*What blocks the decision.* Detecting it means knowing which surface a
|
|
3507
|
+
statement names, which is prose judgement — the same judgement
|
|
3508
|
+
`tests/intent-rule-candidates.spec.ts` records as the reason obligation
|
|
3509
|
+
counting can never be an engine rule ("asserting a ground truth Attest cannot
|
|
3510
|
+
compute for someone else's requirements, which is exactly what §0 refuses").
|
|
3511
|
+
So the shippable form may not exist.
|
|
3512
|
+
**The repo half has shipped, and this entry is now only about the engine
|
|
3513
|
+
half.** `[0.7.0]` adds the `QUANTIFIED` table it described — hand-judged,
|
|
3514
|
+
a row per flagged statement, every named scenario asserted to exist — and it
|
|
3515
|
+
closed the way an entry built from a strict read is supposed to: it found a
|
|
3516
|
+
live hole on `ATX-62`, the one requirement written specifically to avoid this
|
|
3517
|
+
shape, where the archive gate path was claimed by the statement and asserted by
|
|
3518
|
+
nothing. Two things came back that the entry did not predict. The useful one is
|
|
3519
|
+
that "names a surface" splits three ways rather than two — `values`, `roster`,
|
|
3520
|
+
`sites` — and only `sites` carries the risk, because a `roster` scenario
|
|
3521
|
+
iterates the set the code owns and grows with it. That is a sharper statement
|
|
3522
|
+
of the defect than "the scenarios do not span the sentence", and it is a rule
|
|
3523
|
+
an author can act on: **prefer a quantifier a scenario can iterate.** The other
|
|
3524
|
+
is that the remedy — rewriting the statement to name its surfaces, as `ATX-62`
|
|
3525
|
+
did — takes it out of the flagged set, so the table has to allow rows for ids
|
|
3526
|
+
the detector does not flag or it loses the binding exactly when the author does
|
|
3527
|
+
the right thing.
|
|
3528
|
+
*What stays open is the engine half, and nothing above touches it.* The three
|
|
3529
|
+
kinds are prose judgement, the detector misses the article form ("A registry
|
|
3530
|
+
that cannot be read MUST …") which is a universal too, and no rule can tell
|
|
3531
|
+
which surface a sentence claims without being told. The repo half is a
|
|
3532
|
+
counter-pressure, not a detector, and it holds for one registry whose authors
|
|
3533
|
+
agreed to maintain it.
|
|
3534
|
+
|
|
3535
|
+
- **Whether a requirement can ever be retired, and what it costs that none can.**
|
|
3536
|
+
The registry is at 62 requirements and 160 scenarios, roughly 2.6 each, and
|
|
3537
|
+
every one is a permanent obligation: `uncovered-requirement` is an ERROR, so a
|
|
3538
|
+
requirement without a scenario cannot exist, and nothing ever removes one.
|
|
3539
|
+
`delta.removed` exists and `applyDelta` applies it, but `--apply` refuses to
|
|
3540
|
+
write it back, deliberately and with the reason recorded — REMOVED "cannot say
|
|
3541
|
+
which comments belonged to the entry it deletes". So retiring one is a manual
|
|
3542
|
+
registry edit, which is fine as a mechanism and says nothing about whether it
|
|
3543
|
+
is ever the right move.
|
|
3544
|
+
*What the growth actually costs, measured on this session rather than
|
|
3545
|
+
estimated.* One small fix took: a requirement, its scenarios, a hand-judged
|
|
3546
|
+
row in the `OBLIGATIONS` table, two inline snapshots, a `[Unreleased]` entry,
|
|
3547
|
+
History rows in two languages, and a regenerated `SPEC.md`. That is
|
|
3548
|
+
proportionate for a spec framework that has to dogfood — the ceremony *is* the
|
|
3549
|
+
product being used on itself — and it is also a number that only goes up.
|
|
3550
|
+
*What blocks the decision, and it is not the mechanism.* It is that "retire"
|
|
3551
|
+
has at least three meanings this file has never had to separate: a requirement
|
|
3552
|
+
that turned out **wrong** (which should leave a record of being wrong, and
|
|
3553
|
+
`Considered and rejected` is where that already goes); one still **true** but
|
|
3554
|
+
no longer worth a permanently-run scenario; and one **subsumed** by a later
|
|
3555
|
+
requirement, where the scenario should move rather than go. Only the second is
|
|
3556
|
+
really about cost, and it is the one where deleting the intent to save the
|
|
3557
|
+
scenario inverts what the whole tool claims — that intent outlives its tests.
|
|
3558
|
+
Nothing here is urgent while the number is 61. It is filed now because the
|
|
3559
|
+
moment to write the argument down is while the reasoning is available, and
|
|
3560
|
+
because a project whose requirement count only rises should have decided in
|
|
3561
|
+
advance what would make one leave.
|
|
3562
|
+
|
|
3563
|
+
- **The `cmd.exe` quoting in `tests/consumer.spec.ts` guards one character of
|
|
3564
|
+
several.** `shellArg` asserts the argument holds no `"` and then quotes only
|
|
3565
|
+
when it holds whitespace, so an argument with none goes to the shell bare —
|
|
3566
|
+
and `&`, `|`, `^`, `<`, `>`, `(` are metacharacters there that the assertion
|
|
3567
|
+
does not name. The fix is one character class, which is why the entry is not
|
|
3568
|
+
about the fix.
|
|
3569
|
+
*What blocks the decision is that no input reaches it.* Both arguments are
|
|
3570
|
+
either a literal flag or a path from `mkdtemp`, `NEEDS_SHELL` is win32 only
|
|
3571
|
+
and CI is Linux, and the one path an attacker could plausibly influence — the
|
|
3572
|
+
checkout directory on a fork MR pipeline — comes from a GitLab project path,
|
|
3573
|
+
whose character set excludes every metacharacter above. So this is an
|
|
3574
|
+
observation with no attack path, and the honest question is not whether to
|
|
3575
|
+
widen the class but whether the comment above it should stop claiming more
|
|
3576
|
+
than the assertion covers: it says "the assertion keeps that true" about
|
|
3577
|
+
arguments generally, where what it checks is one character. Widening the class
|
|
3578
|
+
and leaving the prose is the change that would look like a fix and settle
|
|
3579
|
+
nothing.
|
|
3580
|
+
|
|
3581
|
+
- **A loader that fails to start leaves its scratch directory behind.**
|
|
3582
|
+
`createLoader` calls `mkdtempSync` and writes the stub *before* `createServer`,
|
|
3583
|
+
and the only `rm` of that directory is inside the `close` of the object
|
|
3584
|
+
`createServer` returns — so a throw from it leaks one `attest-loader-*` per
|
|
3585
|
+
invocation, which on a CI runner is one per build. That is the exact failure
|
|
3586
|
+
shape the comment on `close` records as already fixed, surviving on the other
|
|
3587
|
+
path.
|
|
3588
|
+
*What blocks it is that nothing has been made to throw there.* With
|
|
3589
|
+
`configFile: false` Vite still reads the `package.json` at the root to decide
|
|
3590
|
+
`type`, so a malformed one in the checked project is the obvious candidate and
|
|
3591
|
+
it is the project's to write — but it was not tried, and an entry claiming a
|
|
3592
|
+
trigger it has not seen is the thing this section exists to not accumulate.
|
|
3593
|
+
The repair is three lines (a `try` around the two calls that `rm`s and
|
|
3594
|
+
rethrows) and is not what the decision turns on; measuring whether the throw
|
|
3595
|
+
is reachable at all is, because if it is not, this is a leak with no input
|
|
3596
|
+
that produces it.
|
|
3597
|
+
|
|
2607
3598
|
## Considered and rejected
|
|
2608
3599
|
|
|
2609
3600
|
Decisions **not** to build something, kept where they can be found before the
|
|
@@ -2614,6 +3605,45 @@ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
|
|
|
2614
3605
|
sinking one version deeper each time a release was cut above it, and a rejection
|
|
2615
3606
|
filed under a version reads as belonging to it.
|
|
2616
3607
|
|
|
3608
|
+
### Region-marked samples, so a partial quote could be bound too
|
|
3609
|
+
|
|
3610
|
+
Decided **yes** and filed under `Planned`, then killed by building the count it
|
|
3611
|
+
rested on. The whole-file half shipped in `[0.4.3]` — the README's two
|
|
3612
|
+
getting-started blocks are byte-equal to `fixtures/consumer/` — and the proposal
|
|
3613
|
+
was to raise that with `// #region readme:registry` markers inside the fixture,
|
|
3614
|
+
asserting a doc block against the dedented span rather than the whole file. The
|
|
3615
|
+
mechanism is sound and cheap. What it has no supply of is spans to bind.
|
|
3616
|
+
|
|
3617
|
+
**The entry named its own two candidates, and both fail on inspection.** It said
|
|
3618
|
+
`troubleshooting.md`'s single-source pair is "two verbatim lines of
|
|
3619
|
+
`fixtures/consumer/session.spec.ts`": they are lines 8 and 11, so no span
|
|
3620
|
+
contains them, and line 8 reads `// single source, typed \`30\` — no cast` in the
|
|
3621
|
+
fixture against `// ✓ the single source` on the page. It said the `AUTH-3` block
|
|
3622
|
+
is "the registry entry at a different indent": the fixture wraps `statement:`
|
|
3623
|
+
onto a second line and the page does not, and the page's comment carries a `✓`
|
|
3624
|
+
the fixture has no reason to. **Both candidates carry a `✓`, which the same entry
|
|
3625
|
+
excludes by rule** — "anything carrying a `✓`/`✗` is authored commentary and
|
|
3626
|
+
stays a fragment". The entry's estimate contradicted the entry's own rule, and
|
|
3627
|
+
neither half was checked against the files until now.
|
|
3628
|
+
|
|
3629
|
+
Swept the rest rather than stopping at the two: of the 23 fenced `ts` blocks in
|
|
3630
|
+
the README and `docs/en/**`, 2 are already bound whole, 3 are `src/` sketches the
|
|
3631
|
+
module-scope check in `[0.7.0]` covers, and the remaining 18 are elisions
|
|
3632
|
+
(`{ /* … */ }`), `✓`/`✗` fragments, input the engine must reject, or teaching
|
|
3633
|
+
sketches of the *reader's* project carrying inline commentary that is the point
|
|
3634
|
+
of them. **Zero spans.** So building it would ship a gate binding nothing, which
|
|
3635
|
+
this project treats as worse than the comment recording it — the argument §11
|
|
3636
|
+
makes and `ATX-43` and `ATX-44` both decline a promise on.
|
|
3637
|
+
|
|
3638
|
+
What that leaves is the honest general form: **the reach of a byte-equality gate
|
|
3639
|
+
over documentation is set by how the documentation is written, not by the
|
|
3640
|
+
granularity the gate can address.** Raising it would mean rewriting the pages to
|
|
3641
|
+
be quotable, which is the tail wagging the dog — those `✓`/`✗` annotations and
|
|
3642
|
+
elisions are what makes a sample teach. The counterexample half was never
|
|
3643
|
+
dependent on this and stays under `Planned` as its own entry. If a passage is
|
|
3644
|
+
ever written that *is* a verbatim span of a fixture, this is three lines of
|
|
3645
|
+
`fencedBlocks` away and the reasoning above is what to re-read first.
|
|
3646
|
+
|
|
2617
3647
|
### `typescript` as a peer dependency instead of a bundled one
|
|
2618
3648
|
|
|
2619
3649
|
The open question the matrix entry was queued to settle (see `[0.4.2]`), and
|
|
@@ -2889,42 +3919,9 @@ requirement, and human review at propose is still the whole answer. What is
|
|
|
2889
3919
|
rejected is grouping as a way to assist it, and this reopens only on a
|
|
2890
3920
|
contradiction that grouping would have caught.
|
|
2891
3921
|
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
why it is recorded with its own answer rather than left as a complaint about a
|
|
2896
|
-
message. A `params` value is a scalar or a list of scalars; a table — piece kind
|
|
2897
|
-
to weight, tier to limit — is refused, and the only way to register one is to
|
|
2898
|
-
split it across parallel arrays, which reads worse in the rendered spec than it
|
|
2899
|
-
buys. *What rejected it: the two properties a param has, and a table has
|
|
2900
|
-
neither.* A param is **rendered into `SPEC.md` as a value a human checks at a
|
|
2901
|
-
glance**, and it is **read by a scenario as the one place that value lives**. A
|
|
2902
|
-
nested object fails the first by construction — there is no reading of a
|
|
2903
|
-
five-row table that a reviewer verifies the way they verify `30`. It fails the
|
|
2904
|
-
second more quietly: a table has many readers by definition, so "the one place"
|
|
2905
|
-
becomes "the one file", which is what an ordinary module already is. The
|
|
2906
|
-
schema's own comment records where the line was drawn and why arrays were let
|
|
2907
|
-
in — list constants are the most drift-prone values, and an array still has one
|
|
2908
|
-
owner read by one scenario. A table does not clear that bar; the reason arrays
|
|
2909
|
-
did is exactly the reason it does not.
|
|
2910
|
-
*The reporter's own resolution is the argument's strongest form.* The table went
|
|
2911
|
-
to a shared module, is not registry-owned, and **nothing was lost** — because a
|
|
2912
|
-
piece-value table is tuning, not a promise. That is the rule design §11 now
|
|
2913
|
-
states — *params for promises, plain constants for tuning* — derived
|
|
2914
|
-
independently by the same adopter before it was written anywhere. Under it this
|
|
2915
|
-
candidate is not a limitation of `params` at all: it is a value that was never a
|
|
2916
|
-
requirement's to hold, and §11 carries this rejection's own argument in the same
|
|
2917
|
-
paragraph, as the rule stated over the type rather than over the value.
|
|
2918
|
-
*What ships instead*, in `[0.5.0]`: the refusal now says what is accepted
|
|
2919
|
-
instead of `Invalid input`, and `troubleshooting.md` states the constraint and
|
|
2920
|
-
where such a value belongs — the reporter's actual ask, which was for the
|
|
2921
|
-
decision to be visible rather than discovered by reading the shipped `.d.ts`.
|
|
2922
|
-
The permanent half goes to design §11 with the params rule, not here.
|
|
2923
|
-
*This reopens on* a report where the value is genuinely promised — a table an
|
|
2924
|
-
adopter owes their users, whose rows a reviewer would want in the rendered spec.
|
|
2925
|
-
None has been seen; the one that raised it was tuning.
|
|
2926
|
-
|
|
2927
|
-
[Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.6.0...main
|
|
3922
|
+
[Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.7.1...main
|
|
3923
|
+
[0.7.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.1
|
|
3924
|
+
[0.7.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.0
|
|
2928
3925
|
[0.6.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.6.0
|
|
2929
3926
|
[0.5.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.5.0
|
|
2930
3927
|
[0.4.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.3
|