@am_shork/attest 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -13,6 +13,232 @@ 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.4] - 2026-08-08
17
+
18
+ ### Added
19
+
20
+ - **`ATX-37`'s three unattested sites are closed, and the sweep that was going
21
+ to close them is rejected instead.** The requirement says *everything* the CLI
22
+ writes to a terminal replaces the control characters in text it quotes from
23
+ the project under test, and the `QUANTIFIED` table had carried four `null`
24
+ rows against it since `[0.7.0]`: the status rows, the coverage table, the
25
+ archive verdict, and the path `--out` echoes. Three of those are formatters
26
+ interpolating a reqId, a change name or a written path — correct by reading,
27
+ attested by nothing, which is the state a `sites` quantifier is the only kind
28
+ able to reach in silence. Three scenarios now drive them with hostile input,
29
+ covering six interpolations, and each was **proven able to go red by injection
30
+ before being believed**: `inline` removed from the status row's id, from the
31
+ merged path, from the archive verdict's change name, and from the coverage
32
+ row's id — four defects, each failing exactly one scenario and no other. The
33
+ fourth `null` stays: `--out` needs a filename holding a control character,
34
+ which NTFS refuses and Linux permits, and an unmeasured half is what a `null`
35
+ records.
36
+ **A fifth injection is why the archive verdict is asserted three times.**
37
+ Reading the diff caught what the site list does not resolve to: `change` is
38
+ interpolated by three separate expressions in that one function — merged,
39
+ archivable, and gate-failed — and the first draft covered two of them, leaving
40
+ `inline` deletable from the third with the whole suite green. A `sites` row
41
+ names sites at the granularity a sentence uses, and a branch is finer than
42
+ that; the injection is what tells the two apart.
43
+ **Re-measuring the proposal before building it is what split it in two**, and
44
+ the figures moved in both directions. `src/cli/report.ts` has 8 exports of
45
+ which **7** are `format*` — the eighth is `summarize`, so the entry's "eight
46
+ formatters" was already one off its own wording. The "eight fixtures, one per
47
+ signature" was worse: there are 7 distinct parameter types, `Issue[]` is
48
+ shared by three functions, and the honest cost of the fix turned out to be two
49
+ fixtures extending ones `self/report.spec.ts` already builds. And the entry
50
+ **undercounted its own benefit** — "three currently-correct sites" is three
51
+ *formatters* carrying six interpolations.
52
+
53
+ - **The `✗` samples are bound to a fixture whose rejection the suite asserts.**
54
+ A page showing input the engine must *reject* was the one kind of sample
55
+ nothing could see going stale: it carries no version, names no `src/` symbol,
56
+ and keeps its page's structure perfectly, so every gate in
57
+ `tests/docs-consistency.spec.ts` passed while the claim underneath it aged.
58
+ The failure that mattered is asymmetric — a stale runnable sample stops
59
+ working when a reader pastes it, while a stale counterexample is a reader
60
+ taught a rule that is no longer a rule, which is worse than teaching nothing.
61
+ A `✗` annotated with a code the engine can emit is now bound to a fixture,
62
+ byte-equal the way `fixtures/consumer/` is bound to the README, and the
63
+ fixture is **run through the reader the documented command uses** — the code
64
+ and the line it produces are compared with the code and the line the page
65
+ shows. The line is the half that catches the shape a code-only assertion would
66
+ not: a fixture still rejected, for a different reason, having quietly stopped
67
+ demonstrating what it is quoted for.
68
+ **Re-measuring the entry before building it moved its cost by a factor.** It
69
+ was filed as the expensive half, "a fixture per diagnostic". Of the 33 fenced
70
+ `ts` blocks in the README and `docs/en/**`, 8 carry a `✗`, 3 name a code the
71
+ engine can emit, and 1 of those 3 is elided (`duplicate-prefix`, taught as two
72
+ one-line registries whose bodies are `{ /* … */ }`) — leaving one sample,
73
+ quoted on two pages, bound to one fixture. The count was never the reason it
74
+ sat: what it was really waiting for is the thing that makes the roster hold,
75
+ which is that the binding is a **sweep** rather than a list. A new `✗ code`
76
+ block on any English page is bound to a fixture or fails as `unbound`, so the
77
+ next diagnostic pays a fixture because it is obliged to, not because someone
78
+ remembered the roster existed.
79
+ **Proven able to go red before being believed**, since a gate over samples
80
+ that are all currently correct has never failed and cannot be trusted until it
81
+ does: four mutations, one per way the binding can rot — the page edited away
82
+ from the fixture, the fixture made static so the engine accepts it, a `✗`
83
+ sample added bound to nothing, and the defect moved to a line the annotation
84
+ does not mark. Each was reported naming the page or the fixture.
85
+ *Two limits, both structural.* **English only**: `docs/zh/**` matches
86
+ `docs/en/**` down to the comments inside its samples, so byte-equality is a
87
+ claim only one side can carry, and what covers the Chinese side is the parity
88
+ gate from `[0.4.3]` holding the code-sample sequences equal — structure, not
89
+ meaning. And an **elided block is excluded by rule**, not by omission from the
90
+ roster: `{ /* … */ }` is what makes that sample readable and also what makes it
91
+ unrunnable. The residual risk recorded in `[0.4.3]` does *not* apply here —
92
+ the byte gate and the run proving the fixture still fails are in the same
93
+ file, so a fixture that stopped being rejected cannot pass by living in a
94
+ suite nobody connected to the page.
95
+
96
+ - **`ATX-15` is swept over `src/` from the AST, rather than attested one site at
97
+ a time.** The requirement quantifies over *sites* — every ordering committed to
98
+ disk or compared for equality — and `sites` is the one shape the quantifier
99
+ table records as able to stop being true in silence: a new site is a new
100
+ obligation and nothing enumerates them. Two of its five sites were `null`:
101
+ `red-record.ts` sorts ids and scenario names into `first-run.json`, and
102
+ `splice.ts` sorts ids and param keys into the registry file `--apply` writes.
103
+ Both are committed to disk, both were correct, and neither had a scenario. A
104
+ scenario per site would have closed those two and left the shape untouched,
105
+ because site six would have been unattested the day it was added. The sweep
106
+ instead makes the roster one the **code** owns — every `.sort`/`.toSorted` in
107
+ `src/` is a member — so a new ordering site is covered by construction. The
108
+ sanctioned comparator, the sort methods and the banned APIs are `ATX-15`
109
+ params, read by the scenario, pinned against literals for the reason the
110
+ domain table states: shrinking a list a scenario iterates removes a case and
111
+ everything left still passes.
112
+ **Stated narrowly enough to be true: it constrains how a site orders, not that
113
+ it orders.** A `red-record.ts` that stopped sorting altogether is still green
114
+ here, and the three hand-written scenarios are what speak to the result. That
115
+ is the honest limit of what a sweep can prove, and the table records it rather
116
+ than letting the row read as fully discharged.
117
+ **It was proven able to go red against the real corpus before being believed** —
118
+ three injected defects, one per shape the rule admits: a `localeCompare`
119
+ comparator in `red-record.ts`, a bare `.sort()` in `splice.ts`, and a
120
+ hand-rolled comparator in `merge.ts` that never reaches `byCodeUnit`. Each was
121
+ reported with its file, line and offending source. A gate asserting a property
122
+ that already holds everywhere has never been red and cannot become so until it
123
+ fires, so a negative control is not optional for it; a second scenario keeps
124
+ one in the suite permanently, including the case a line-wise scan gets wrong.
125
+ **Re-measuring the proposal's figures before building it changed two of
126
+ them**, which is the section's own rule and the reason it is worth restating:
127
+ the entry claimed 21 `.sort` call sites (there are **20** — the 21st was a
128
+ comment, which a regex counts and an AST does not), and two exceptions to
129
+ "names `byCodeUnit` on the call line" (there are **six**: one delegating to a
130
+ local `compareIds`, five inline arrows). The exception count being 3× what was
131
+ written is what made following local delegation part of the rule rather than a
132
+ footnote. The `localeCompare` figure moved the same way and in the direction
133
+ that matters most: **both occurrences in `src/` are comments explaining why it
134
+ is not used**, so a line-wise regex would have opened at 100% false positives.
135
+ Reading from the AST was the entry's own recommendation; it turns out to have
136
+ been the difference between a gate and a nuisance.
137
+ One shape the rule admits was not in the proposal and is worth naming:
138
+ `report.ts` sorts by `b.count - a.count || byCodeUnit(…)`. A numeric term is
139
+ legitimate, so the rule is "every comparator **reaches** the sanctioned one",
140
+ never "sorts only by code unit" — a stricter rule would have been red on
141
+ correct code the day it was written.
142
+
143
+ ### Fixed
144
+
145
+ - **`attest check` answered `✓ No issues.` for a `*.reqs.ts` that does not
146
+ compile.** TypeScript's parser is error-tolerant by design: handed a file
147
+ truncated before its closing `});` it recovers the object literal and returns a
148
+ tree anyway, so the static reader read it as a *complete* registry and reported
149
+ nothing at all — while `--eval` on the same bytes said `registry-invalid`. Two
150
+ readers of one file disagreeing about whether it is even a file is the
151
+ condition the differential suite exists to prevent, and that suite could not
152
+ have caught it: it runs over registries that all compile. Nothing was looking
153
+ at `parseDiagnostics`, which is the one field that says so.
154
+ **The reach is three readers, not one**, because the tolerance belongs to the
155
+ compiler rather than to any reader: `parser.ts`, `static-registry.ts` and
156
+ `splice.ts` all open with the same `createSourceFile` call. The same half-saved
157
+ file was also a spec whose scenarios above the break still counted as coverage,
158
+ a delta whose ids `status` still reported, and — the site that writes rather
159
+ than reports — an insertion offset `attest apply` would have spliced a new
160
+ requirement into. All four now refuse it: the three readers as
161
+ `unreadable-file`, and the insertion point by returning the `undefined` it
162
+ already documents as its refusal to guess, because a write path that throws
163
+ mid-merge is worse than one that declines.
164
+ The diagnostic carries the **line** the syntax error is on, through both
165
+ shapes of refusal — the readers return it, and the spec parser carries it
166
+ through the throw its guard already catches. Without that second half the same
167
+ code would have had a line for a registry and none for a spec, for one
168
+ condition.
169
+ `unreadable-file` rather than a new code: `ATX-65` already states this
170
+ obligation for a file the parser cannot read, and the reader's situation is
171
+ identical — nothing in the file was checked, everything else in the run still
172
+ was. What `ATX-65` did not state is the *detection*, which is the whole of what
173
+ was missing; it was written for a parser that throws, and a parser that quietly
174
+ succeeds needs a different question asked of it.
175
+ **Syntactic only, and that is what makes it safe rather than merely narrow.**
176
+ `parseDiagnostics` is the parser's own list, so a type error, an unresolved
177
+ import and a name that does not exist are all absent from it — Attest does not
178
+ typecheck an adopter's project and this does not start. Measured over the 129
179
+ TypeScript files in this repository with every fixture included: zero carry a
180
+ parse diagnostic, so nothing valid today starts failing. The one reader left
181
+ deliberately tolerant is the id recovery added above, which runs only on files
182
+ a reader has already refused.
183
+
184
+ - **Two scenarios named by a template literal were invisible to the static
185
+ plan.** `self/half-loaded.spec.ts` generated one scenario per command, and the
186
+ parser reads names from the AST — so `cover` counted one scenario where two
187
+ ran, and `archive`'s never-red gate could never have seen the second. Found
188
+ while binding the new requirement's roster to a scenario the plan declares.
189
+ Both are now one scenario looping the roster, which is the shape `ATX-64`
190
+ already used; the label on each `expect` is what the generated name was buying,
191
+ and it survives a failure. **The engine is not changed**: a name a static
192
+ reader cannot resolve is not a defect in the reader, and the failure it
193
+ produces on a requirement covered *only* that way is `uncovered-requirement`,
194
+ which is loud.
195
+
196
+ ### Changed
197
+
198
+ - **One unreadable `*.reqs.ts` reported a finding per scenario that attested it,
199
+ and the finding was about the file.** The ids that file declares are missing
200
+ from the run, so every scenario covering one of them was an `orphan-test` —
201
+ measured on a registry of three requirements: six ERRORs, of which one was the
202
+ cause and five were its shadow, with the true finding at the top where the wall
203
+ of red pushes it out of view. Those scenarios are now reported once per
204
+ registry file as `orphan-from-failed-registry` (WARNING), naming the file, the
205
+ count and the ids; the same case is now two ERRORs and one WARNING. A WARNING
206
+ because the load failure is already an ERROR and is present whenever this is,
207
+ so no verdict moves and this cannot make a broken registry pass.
208
+ **What unblocked it was that the question was framed wrongly**, and the entry
209
+ under `Under consideration` had carried that framing for two releases: it asked
210
+ for a prefix→file mapping, which is exactly what a file that never parsed
211
+ cannot give — and could only have been recovered from the file *name*, which
212
+ this repository is its own counterexample to (`attest.reqs.ts` holds `ATX-*`,
213
+ the evidence that killed the prefix-matches-filename rule). The **ids** are
214
+ written in the source, and reading them back needs no naming convention that
215
+ nothing enforces. It is also strictly more precise than the prefix would have
216
+ been: a genuine typo sharing a prefix with the broken file stays reported.
217
+ Recall is partial and the direction of the miss is why that is acceptable — an
218
+ id not recovered keeps its `orphan-test`, so the report stays noisy and never
219
+ wrong. Measured over the ways a registry fails: a truncated file, a missing
220
+ default export, a non-literal value, a schema-invalid entry, a module that
221
+ throws at import and a spread from another module all yield their ids; only a
222
+ registry the file *computes* yields none, and that source contains no id to
223
+ find. The recovery is a source read that returns ids and nothing else — it
224
+ builds no registry, so it is not a second reader and cannot become the
225
+ evaluation fallback `registry-not-static` refuses.
226
+ Stated as a replacement rather than a suppression for the reason `ATX-62`
227
+ records about `spec-load-failed`: suppressing with nothing in its place trades
228
+ a wrong message for silence, and a wrong message beats silence.
229
+
230
+ - **The hedged `orphan-test` advice added in `[0.7.0]` was applied to every
231
+ orphan in the report, including the ones it does not describe.** Any registry
232
+ file failing to load switched *all* of them to "a registry file failed to load
233
+ — fix that first, and add this id only if it is still unknown afterwards",
234
+ so a genuine typo was told to wait for work that has nothing to do with it.
235
+ The hedge now fires only where the uncertainty is real: when a failed file's
236
+ ids could not be recovered. When they all could, an orphan none of them claims
237
+ is genuinely unknown and gets the plain advice back.
238
+
239
+ `SCHEMA_VERSION` is unchanged: a new `code` value is additive, and no envelope
240
+ field moved.
241
+
16
242
  ## [0.7.3] - 2026-08-07
17
243
 
18
244
  ### Fixed
@@ -3647,34 +3873,17 @@ about itself, which is three things.
3647
3873
  **An entry can sit here for releases on the strength of a figure nobody has
3648
3874
  checked.** The discipline aims at keeping the *argument*, not at re-testing the
3649
3875
  evidence under it, so the first act of building one is re-measuring what it
3650
- claims rather than implementing what it proposes. That has paid twice: once
3651
- killing an entry outright, when the spans it rested on turned out not to be
3652
- spans; once correcting an entry's headline number before a line of it was
3653
- written. **What a release costs its adopters is not known on the day it is cut**
3876
+ claims rather than implementing what it proposes. That has paid three times:
3877
+ once killing an entry outright, when the spans it rested on turned out not to be
3878
+ spans; twice correcting an entry's own numbers before a line of it was written —
3879
+ a call-site count that a regex had inflated, and a cost filed as "a fixture per
3880
+ diagnostic" that the corpus put at one fixture. **What a release costs its adopters is not known on the day it is cut**
3654
3881
  — several entries arrived from one adoption report, about shapes the tool made
3655
3882
  someone work around without ever erroring. And **the fixes with no observable
3656
3883
  symptom are worth batching**: individually none justifies a commit, together they
3657
3884
  cost about an hour, and they are the group no gate could have found, since having
3658
3885
  no symptom is the definition of what a gate cannot see.
3659
3886
 
3660
- - **Binding the `✗` samples to fixtures whose rejection is asserted.** A sample
3661
- showing input the engine must *reject* can be bound the way a runnable one is,
3662
- to a fixture whose rejection is the thing under test — which is what
3663
- `fixtures/eval-invalid-registry` already is. This is the more valuable half of
3664
- what was once one entry with the region-marking below, because a counterexample
3665
- that quietly stopped being a counterexample is the one kind of stale sample
3666
- that actively teaches the wrong thing: a reader shown "this fails" about input
3667
- the engine now accepts is worse off than one shown nothing. It is also the
3668
- expensive half, needing a fixture per diagnostic, which is why it was never the
3669
- free part of that entry and is now the only part left in it.
3670
- *The mirror rule caps what can be claimed on the Chinese side.* `docs/zh/**`
3671
- matches `docs/en/**` down to the comments inside its code samples, so
3672
- byte-equality holds on one side only and the zh blocks stay translated prose
3673
- around the same code — the parity gate in `[0.4.3]` covers them, and it covers
3674
- structure, not meaning. The residual risk recorded with the whole-file half in
3675
- `[0.4.3]` — that the gate and the run proving the fixture still works live in
3676
- different suites — applies here identically.
3677
-
3678
3887
  ## Under consideration
3679
3888
 
3680
3889
  Candidates with **no decision yet**, between the two sections either side of it
@@ -3706,41 +3915,6 @@ have to gather again.
3706
3915
  npm's current trusted-publisher documentation for GitLab support before
3707
3916
  writing any YAML.
3708
3917
 
3709
- - **A static sweep for the two properties claimed over the whole of `src/`.**
3710
- The quantifier table's first strict read left three rows wanting the same
3711
- thing, and the repo already owns the idiom: `tests/import-boundary.spec.ts` is
3712
- a sweep over the import graph asserting a cross-cutting property no runtime
3713
- test can see, written for the same reason — "only the graph shows it".
3714
- *The two candidates, and they are not equally shippable.* `ATX-15` is the easy
3715
- one: **no `.sort` outside `byCodeUnit`, and no `localeCompare`.** Measured over
3716
- `src/` today — 21 `.sort` call sites, every one reaching `byCodeUnit`, but only
3717
- 19 naming it on the call line: `render.ts` goes through its own `compareIds`,
3718
- and `merge.ts` through a comparator that spans two lines. The only four
3719
- occurrences of `localeCompare` are comments explaining why it is not used. A sweep would have **zero false positives on the current corpus**,
3720
- which is the bar `divergent-param` failed and `compound-requirement` cleared —
3721
- and those two exceptions are already the warning that this needs the AST rather
3722
- 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
3723
- string interpolated into a terminal line goes through `inline`", and that is
3724
- not a token scan. The shape that would work is a scenario driving every
3725
- exported `format*` in `src/cli/report.ts` with hostile input — a real roster,
3726
- since the module's exports are what a new formatter joins — but each takes a
3727
- different argument type, so it needs a hostile fixture per signature rather
3728
- than one loop.
3729
- *What blocks the decision.* Two things, one per candidate. The `ATX-15` sweep
3730
- is cheap and precise and would be **the first rule in this repository that
3731
- polices how `src/` is written rather than what it does** — a lint rule wearing
3732
- a scenario's clothes, and the question is whether that belongs in `self/` (as
3733
- an obligation `ATX-15` already carries) or in `tests/` beside the import
3734
- boundary. Filing it under `ATX-15` makes it a permanent obligation with a
3735
- scenario; filing it in `tests/` makes it deletable in a green diff, which
3736
- `ATX-57`'s rationale argues against in as many words. The `ATX-37` roster is
3737
- blocked on cost rather than principle: eight formatters, eight fixtures, and
3738
- the honest count of what it buys is three currently-correct sites.
3739
- *Why neither is urgent.* Both properties hold everywhere today, verified by
3740
- reading. What is missing is not correctness but the thing that keeps it — and
3741
- the measurement above is what a re-proposal would otherwise have to gather
3742
- again.
3743
-
3744
3918
  - **Bounding the fan-out in `findFiles` and `parseSpecs`.** Both walk the
3745
3919
  project with no concurrency limit: `findFiles` recurses through
3746
3920
  `Promise.all(subdirs.map(walk))`, so every directory in the tree is opened at
@@ -3976,17 +4150,34 @@ have to gather again.
3976
4150
  weaker signal "some registry file failed to load, so suppress all
3977
4151
  `orphan-test`" is the other candidate and is a strictly worse `check` on a
3978
4152
  multi-file registry, where the other files are fine and their orphans are real.
3979
- **The wrong *advice* has been separated out and fixed; the noise has not.**
3980
- `[0.7.0]` changes `orphan-test`'s fix hint when any registry file failed
3981
- to load, so the loudest line in that report stops telling the reader to add an
3982
- id that already exists. That needed no mapping, because it claims nothing about
3983
- *which* orphans came from the broken file only that the command cannot tell,
3984
- and what order to work in. It is worth separating because the two halves were
3985
- filed as one problem and are not: **being wrong** was fixable today at the cost
3986
- of a sentence, and **being loud** is the part still blocked on the mapping
3987
- above. The entry stays open on exactly that, with one blocker retired: the
3988
- measured six derived findings from one cause are still six, and only one of
3989
- them has stopped pointing at work that must not be done.
4153
+ **The wrong *advice* was separated out and fixed in `[0.7.0]`; the noise is
4154
+ fixed under `[Unreleased]`, and the blocker above was the wrong question.**
4155
+ The mapping this entry called for could not be recovered, and did not have to
4156
+ be: what a suppression needs is not the broken file's *prefix* but the ids it
4157
+ declares, and those are written in its source. Reading them back is a source
4158
+ read that returns ids and builds nothing, so the failure modes that matter
4159
+ a truncated file, a missing default export, a non-literal value, a
4160
+ schema-invalid entry, a module that throws at import all give them up. The
4161
+ six derived findings from one cause are now two ERRORs and one WARNING, and
4162
+ the collapse is per id rather than per prefix, which is strictly more precise
4163
+ than what was blocked: a genuine typo sharing a prefix with the broken file
4164
+ stays reported. **The lesson is the entry, not the fix.** It sat here for two
4165
+ releases behind a blocker stated as "the mapping is unavailable", which was
4166
+ true and was about a mapping nothing needed; the evidence it carried was about
4167
+ the right problem and the mechanism it proposed was not, and nothing rereads a
4168
+ blocker that has already been written down. That is the same failure the
4169
+ section's own preamble records about a figure nobody re-checks, one level up:
4170
+ the *shape* of the proposal goes stale as quietly as its numbers do.
4171
+ *What is left of this entry is only the original question* — whether `check`
4172
+ and `verify` should refuse a half-loaded registry outright, the way `cover`
4173
+ and `render` do — **and the evidence that motivated it has been spent.** The
4174
+ argument for refusing was never the load failure itself; it was the wall of
4175
+ derived findings burying it, and that wall is gone. What remains is the
4176
+ breadth argument recorded above, which points the other way for both commands.
4177
+ It stays here rather than moving down because "the reason to do it was
4178
+ removed" is not the same as "it was decided against", and the case would come
4179
+ back the moment another finding is found to scale with a broken registry
4180
+ rather than with the number of problems.
3990
4181
 
3991
4182
  - **A statement that quantifies over a surface, with scenarios covering only the
3992
4183
  part that existed when it was written.** Found by reading, and it had already
@@ -4035,7 +4226,7 @@ have to gather again.
4035
4226
  agreed to maintain it.
4036
4227
 
4037
4228
  - **Whether a requirement can ever be retired, and what it costs that none can.**
4038
- The registry is at 62 requirements and 160 scenarios, roughly 2.6 each, and
4229
+ The registry is at **69 requirements and 192 scenarios, 2.78 each**, and
4039
4230
  every one is a permanent obligation: `uncovered-requirement` is an ERROR, so a
4040
4231
  requirement without a scenario cannot exist, and nothing ever removes one.
4041
4232
  `delta.removed` exists and `applyDelta` applies it, but `--apply` refuses to
@@ -4057,10 +4248,29 @@ have to gather again.
4057
4248
  requirement, where the scenario should move rather than go. Only the second is
4058
4249
  really about cost, and it is the one where deleting the intent to save the
4059
4250
  scenario inverts what the whole tool claims — that intent outlives its tests.
4060
- Nothing here is urgent while the number is 61. It is filed now because the
4061
- moment to write the argument down is while the reasoning is available, and
4062
- because a project whose requirement count only rises should have decided in
4063
- advance what would make one leave.
4251
+ Nothing here is urgent at 69. It is filed now because the moment to write the
4252
+ argument down is while the reasoning is available, and because a project whose
4253
+ requirement count only rises should have decided in advance what would make
4254
+ one leave.
4255
+ *The figures above were restated on 2026-08-08, and how they were wrong is
4256
+ itself the section's own warning happening.* As filed on 2026-08-03 this entry
4257
+ said "62 requirements and 160 scenarios, roughly 2.6 each" while closing on
4258
+ "the number is 61" — an inconsistency inside one entry, and the closing half
4259
+ was the right one. Re-measured at the entry's own commit with the current
4260
+ parser: **61 requirements, 156 scenarios**, so both opening figures were wrong
4261
+ on the day they were written, and only the ratio survived. Measured the same
4262
+ way today it is 69 and 192, and the method is the same one `verify self`
4263
+ prints — the control is that today's `self/` counted this way returns exactly
4264
+ what the runner does, which is what makes the 2026-08-03 number trustworthy. The
4265
+ cheap way — `grep -c 'scenario('` — answers 211 against today's true 192, and
4266
+ is what would have been reached for: comments and prose count, which is the
4267
+ `ATX-15` error again.
4268
+ **What moved in five days sharpens the argument rather than dating it.** +8
4269
+ requirements and +36 scenarios, concentrated in three sessions rather than
4270
+ accrued at a rate, so nothing here is a trend line. The part worth keeping is
4271
+ the ratio: 2.56 → 2.78. The cost of a requirement is not flat and it is not
4272
+ falling, which is the premise the whole entry rests on, and it is the first
4273
+ time this file has had two measurements of it to compare.
4064
4274
 
4065
4275
  ## Considered and rejected
4066
4276
 
@@ -4302,6 +4512,46 @@ skill file. A pointer is cheap enough to be resident, it is their edit in their
4302
4512
  file, and it cannot go stale — the file it names is the one `init` keeps
4303
4513
  current.
4304
4514
 
4515
+ ### A sweep driving every exported `format*` with hostile input
4516
+
4517
+ The mechanism `ATX-37` sat under `Under consideration` waiting for, kept there
4518
+ on cost, and killed by measuring that cost rather than by paying it. The three
4519
+ sites it was aimed at are closed under `[Unreleased]` by hand-written scenarios;
4520
+ what is rejected is the roster, and the argument is that **`src/cli/report.ts`'s
4521
+ exports are the wrong roster in both directions at once.**
4522
+
4523
+ *Too wide.* Three of the eight exports cannot carry project-derived text at all:
4524
+ `summarize` and `formatScope` take `VerifyCounts`, which is four numbers, and
4525
+ `formatRepeats` prints a count, a `Level` and an `IssueCode` — both fixed
4526
+ rosters. A hostile fixture for those has nothing hostile to put in it, so a loop
4527
+ over the exports either forces three members that **cannot go red**, which is
4528
+ the defect this project ships a gate against, or carries an exclusion list —
4529
+ and an exclusion list is precisely what `ATX-15`'s sweep avoided by making the
4530
+ roster one the code owns.
4531
+
4532
+ *Too narrow.* The statement is over everything the CLI writes to a terminal, and
4533
+ every site that has escaped it so far was outside this module: the `status`
4534
+ header was built and printed in `cli/index.ts`, the loader's writes are in
4535
+ `core/loader.ts`, and the `render` document that `ATX-58` was carved out for is
4536
+ in `core/render.ts`. A roster of formatters is neither necessary nor sufficient
4537
+ for the sentence, and the reading that made it look sufficient — that the
4538
+ formatters are the whole surface — is the exact assumption the `status` header
4539
+ falsified when it was found.
4540
+
4541
+ *And the general form is the §0 wall.* "Every project-derived string
4542
+ interpolated into a terminal line goes through `inline`" needs to know which
4543
+ values are project-derived, which is prose judgement over a data flow. That is
4544
+ the ground truth `tests/intent-rule-candidates.spec.ts` records as the reason
4545
+ none of this can ever be an engine rule, and it does not become computable by
4546
+ being asked about this repository instead of someone else's.
4547
+
4548
+ *What keeps the property instead* is the `QUANTIFIED` row itself — hand-judged,
4549
+ one entry per site, every named scenario asserted to exist. That is a
4550
+ counter-pressure rather than a detector, it holds for one registry whose authors
4551
+ agreed to maintain it, and after this change exactly one of its `ATX-37` sites
4552
+ is open. This reopens only if a site escapes that a roster of formatters would
4553
+ have caught, which none of the four so far would have been.
4554
+
4305
4555
  ### A Codex target for `attest init`
4306
4556
 
4307
4557
  Evaluated when `init` grew `--target` and shipped for Claude Code, Cursor and
@@ -4386,6 +4636,7 @@ requirement, and human review at propose is still the whole answer. What is
4386
4636
  rejected is grouping as a way to assist it, and this reopens only on a
4387
4637
  contradiction that grouping would have caught.
4388
4638
 
4639
+ [0.7.4]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.4
4389
4640
  [0.7.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.3
4390
4641
  [0.7.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.2
4391
4642
  [0.7.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.1
package/README.md CHANGED
@@ -166,7 +166,7 @@ Every diagnostic carries a `code`, and every code has a section in
166
166
  ```
167
167
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
168
168
  Value is not a literal.
169
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.7.3/docs/en/troubleshooting.md#registry-not-static
169
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.7.4/docs/en/troubleshooting.md#registry-not-static
170
170
  ```
171
171
 
172
172
  The anchor **is** the code, so the link cannot point somewhere the section
@@ -1,3 +1,4 @@
1
+ import ts from 'typescript';
1
2
  import type { Issue } from './types.js';
2
3
  /**
3
4
  * The range the readers are written against.
@@ -36,5 +37,36 @@ interface CompilerSurface {
36
37
  * requirement and every file in the project is equally unreadable because of it.
37
38
  */
38
39
  export declare function compilerIssue(compiler?: CompilerSurface): Issue | undefined;
40
+ /**
41
+ * A source that does not compile, thrown where a reader has no failure channel
42
+ * of its own.
43
+ *
44
+ * `parseSpecFile` returns a plan and nothing else, so its refusal has to be a
45
+ * throw — and `parseSpecs` already catches every throw per file (ATX-65). The
46
+ * class exists so the *line* survives that trip: without it the position is
47
+ * prose inside the message, and `unreadable-file` would carry `line` when a
48
+ * registry failed to compile and not when a spec did, for one condition.
49
+ */
50
+ export declare class SourceNotCompiled extends Error {
51
+ readonly line?: number | undefined;
52
+ constructor(message: string, line?: number | undefined);
53
+ }
54
+ /**
55
+ * `ts.createSourceFile`, refusing a source that does not compile.
56
+ *
57
+ * Every reader that turns source into a *value* goes through this. The one that
58
+ * deliberately does not is `declaredIdsFromSource`, which runs only on files a
59
+ * reader has already refused — recovering the ids of a broken registry is the
60
+ * whole of its job, and a strict parse there would take the diagnostic that
61
+ * names them away again.
62
+ */
63
+ export declare function parseSource(file: string, source: string): {
64
+ sf: ts.SourceFile;
65
+ } | {
66
+ error: {
67
+ message: string;
68
+ line?: number;
69
+ };
70
+ };
39
71
  export {};
40
72
  //# sourceMappingURL=compiler.d.ts.map
@@ -61,4 +61,82 @@ export function compilerIssue(compiler = ts) {
61
61
  `If a resolution or override pins the compiler for the whole tree, exclude Attest from it or move that pin back into the supported range.`,
62
62
  };
63
63
  }
64
+ /**
65
+ * The syntax error a source carries, or `undefined` when it compiles.
66
+ *
67
+ * **`ts.createSourceFile` recovers**: handed a file that does not compile it
68
+ * still returns a tree, built from what the parser guessed the author meant. So
69
+ * a tree is not evidence that a file compiles, and every reader that turns
70
+ * source into a value has to ask separately (ATX-69).
71
+ *
72
+ * Lives here because this module already owns what the compiler does and does
73
+ * not promise — it names `parser.ts`, `static-registry.ts` and `splice.ts` as
74
+ * the three that open with `createSourceFile`, which is exactly the set that has
75
+ * to ask this question.
76
+ *
77
+ * **Syntactic only, and that is the whole of what keeps it safe.**
78
+ * `parseDiagnostics` is populated by the parser, so a type error, an unresolved
79
+ * import and a name that does not exist are all *absent* from it — checking it
80
+ * cannot turn Attest into a typechecker of someone's project, which it is not
81
+ * and must not become.
82
+ *
83
+ * What "does not compile" means is "does not compile under the TypeScript Attest
84
+ * resolved", which is the same limit ATX-65 records about parser depth: the
85
+ * bound belongs to the compiler this is built on, not to a promise this project
86
+ * is in a position to make.
87
+ */
88
+ function syntaxError(sf) {
89
+ // `parseDiagnostics` is not on the public `SourceFile` type — it is
90
+ // TypeScript's own field, which is why nothing here found it by reading the
91
+ // signature. Declared *optional* so reaching for it stays honest: a compiler
92
+ // that stops populating it degrades to reading the recovered tree rather than
93
+ // throwing, which is the same fail-open `compilerIssue` above is written for.
94
+ const diagnostics = sf.parseDiagnostics;
95
+ const first = diagnostics?.[0];
96
+ if (!first)
97
+ return undefined;
98
+ // The first error only. A syntax error cascades — a stray token measured four
99
+ // of them, where the truncation this was found on measured one — and the ones
100
+ // after the first are artefacts of the parser's recovery from it, so listing
101
+ // them describes the recovery rather than the file.
102
+ const message = ts.flattenDiagnosticMessageText(first.messageText, ' ');
103
+ const count = diagnostics.length;
104
+ const suffix = count > 1 ? ` (and ${count - 1} more)` : '';
105
+ return {
106
+ message: `${message}${suffix}`,
107
+ line: sf.getLineAndCharacterOfPosition(first.start).line + 1,
108
+ };
109
+ }
110
+ /**
111
+ * A source that does not compile, thrown where a reader has no failure channel
112
+ * of its own.
113
+ *
114
+ * `parseSpecFile` returns a plan and nothing else, so its refusal has to be a
115
+ * throw — and `parseSpecs` already catches every throw per file (ATX-65). The
116
+ * class exists so the *line* survives that trip: without it the position is
117
+ * prose inside the message, and `unreadable-file` would carry `line` when a
118
+ * registry failed to compile and not when a spec did, for one condition.
119
+ */
120
+ export class SourceNotCompiled extends Error {
121
+ line;
122
+ constructor(message, line) {
123
+ super(message);
124
+ this.line = line;
125
+ this.name = 'SourceNotCompiled';
126
+ }
127
+ }
128
+ /**
129
+ * `ts.createSourceFile`, refusing a source that does not compile.
130
+ *
131
+ * Every reader that turns source into a *value* goes through this. The one that
132
+ * deliberately does not is `declaredIdsFromSource`, which runs only on files a
133
+ * reader has already refused — recovering the ids of a broken registry is the
134
+ * whole of its job, and a strict parse there would take the diagnostic that
135
+ * names them away again.
136
+ */
137
+ export function parseSource(file, source) {
138
+ const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
139
+ const error = syntaxError(sf);
140
+ return error ? { error } : { sf };
141
+ }
64
142
  //# sourceMappingURL=compiler.js.map
@@ -8,7 +8,7 @@
8
8
  * and the `##` headings of both language documents, so landing here cannot
9
9
  * produce a dead link.
10
10
  */
11
- export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "non-scalar-interpolation", "orphan-test", "possible-drift", "proposed-spec-name-taken", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "spec-in-change-dir", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target", "unreadable-file", "unsafe-target-path"];
11
+ export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "non-scalar-interpolation", "orphan-from-failed-registry", "orphan-test", "possible-drift", "proposed-spec-name-taken", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "spec-in-change-dir", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target", "unreadable-file", "unsafe-target-path"];
12
12
  export type IssueCode = (typeof ISSUE_CODES)[number];
13
13
  /**
14
14
  * The page explaining `code`, or `undefined` when nothing explains it.
package/dist/core/docs.js CHANGED
@@ -37,6 +37,7 @@ export const ISSUE_CODES = [
37
37
  'modify-missing',
38
38
  'never-red',
39
39
  'non-scalar-interpolation',
40
+ 'orphan-from-failed-registry',
40
41
  'orphan-test',
41
42
  'possible-drift',
42
43
  'proposed-spec-name-taken',
@@ -91,6 +91,20 @@ export declare function evalReader(loader: Loader): RegistryReader;
91
91
  * execute user code — and were advertised as static while doing exactly that.
92
92
  */
93
93
  export declare function staticReader(): RegistryReader;
94
+ /**
95
+ * A registry file that contributed no ids, and the ids its source still names.
96
+ *
97
+ * `ids` is empty when nothing could be recovered — a registry the file computes
98
+ * rather than writes out, or a source that could not be read at all — and an
99
+ * empty list is the honest answer, not a failure: it says the ids of that file
100
+ * are unknown, which is a different thing from that file declaring none.
101
+ */
102
+ export interface UnreadableRegistry {
103
+ /** Path relative to the project root, as every `Issue.file` is. */
104
+ readonly file: string;
105
+ /** Requirement ids recovered from the source; possibly none. */
106
+ readonly ids: readonly string[];
107
+ }
94
108
  /**
95
109
  * Read and merge every `*.reqs.ts` registry under root, with the given reader.
96
110
  * A registry the reader rejects becomes its own ERROR; a duplicate id across
@@ -110,7 +124,8 @@ export declare function loadRegistry(root: string, reader: RegistryReader, files
110
124
  issues: Issue[];
111
125
  prefixOwners: Record<string, string>;
112
126
  /**
113
- * Registry files that contributed **no ids**, relative to `root`.
127
+ * Registry files that contributed **no ids**, relative to `root`, each with
128
+ * whatever ids its source still says it declares.
114
129
  *
115
130
  * Not the same question as "did loading produce an ERROR": `duplicate-prefix`
116
131
  * and `duplicate-requirement` are ERRORs raised *after* a successful read, and
@@ -119,8 +134,13 @@ export declare function loadRegistry(root: string, reader: RegistryReader, files
119
134
  * re-derived that from the issue codes would be maintaining a second answer
120
135
  * to a question this loop already knows — the mistake `prefixOwners` is here
121
136
  * to avoid one shape of.
137
+ *
138
+ * The ids come with the file for the same reason: `validateStructure` has to
139
+ * separate a scenario attesting a *broken* file's requirement from one
140
+ * attesting an id that does not exist, and re-deriving which file an id
141
+ * belongs to somewhere else would be that same second answer.
122
142
  */
123
- unreadableFiles: string[];
143
+ unreadable: UnreadableRegistry[];
124
144
  }>;
125
145
  /**
126
146
  * The part of an id that names the space it lives in — `AUTH` of `AUTH-3`.
@@ -4,10 +4,11 @@ import { readdir, readFile } from 'node:fs/promises';
4
4
  import { basename, join } from 'node:path';
5
5
  import { relativePath } from './paths.js';
6
6
  import { parseSpecFile } from './parser.js';
7
- import { readRegistrySource } from './static-registry.js';
7
+ import { declaredIdsFromSource, readRegistrySource } from './static-registry.js';
8
8
  import { RegistryValidationError } from './registry.js';
9
9
  import { RegistrySchema } from './schema.js';
10
10
  import { byCodeUnit } from './order.js';
11
+ import { SourceNotCompiled } from './compiler.js';
11
12
  // Proposed changes and archived changes are excluded from normal scanning:
12
13
  // a change's requirements/specs only count once its gate passes and it is
13
14
  // merged (design §7, §8). `attest archive <name>` includes them explicitly.
@@ -107,11 +108,17 @@ function unreadableIssue(err) {
107
108
  // the run, and narrowing would make that promise true only of the one trigger
108
109
  // that has been measured.
109
110
  const detail = err instanceof Error ? err.message : String(err);
111
+ // The one throw that carries a position: a source that does not compile knows
112
+ // which line stopped it, and a syntax error without a line is most of the
113
+ // diagnostic gone. Narrowing to enrich, never to decide — everything still
114
+ // arrives here and still becomes this issue, which is the promise above.
115
+ const line = err instanceof SourceNotCompiled ? err.line : undefined;
110
116
  return {
111
117
  level: 'ERROR',
112
118
  code: 'unreadable-file',
113
119
  message: `Could not be read, so nothing in it was checked: ${detail}. ` +
114
120
  `Everything else in this run was still reported.`,
121
+ ...(line === undefined ? {} : { line }),
115
122
  };
116
123
  }
117
124
  /**
@@ -226,6 +233,18 @@ export function staticReader() {
226
233
  },
227
234
  };
228
235
  }
236
+ /** Ids recoverable from a registry file that would not load; none if it will not even open. */
237
+ async function declaredIds(absPath) {
238
+ try {
239
+ return declaredIdsFromSource(basename(absPath), await readFile(absPath, 'utf8'));
240
+ }
241
+ catch {
242
+ // The file that failed to load is already reported. A second failure
243
+ // reading it changes nothing about that diagnosis and must not replace it
244
+ // with a crash — this path only ever *adds* precision to another finding.
245
+ return [];
246
+ }
247
+ }
229
248
  /**
230
249
  * Read and merge every `*.reqs.ts` registry under root, with the given reader.
231
250
  * A registry the reader rejects becomes its own ERROR; a duplicate id across
@@ -247,7 +266,7 @@ export async function loadRegistry(root, reader, files) {
247
266
  const loaded = await Promise.all(paths.map(async (file) => ({ file, outcome: await readGuarded(reader, file) })));
248
267
  const registry = {};
249
268
  const issues = [];
250
- const unreadableFiles = [];
269
+ const unreadable = [];
251
270
  // Which file first claimed each id prefix. The prefix is the only unit above
252
271
  // the requirement (design §11) and nothing allocates it, so two files
253
272
  // claiming one is the collision no command would otherwise report — the ids
@@ -263,7 +282,13 @@ export async function loadRegistry(root, reader, files) {
263
282
  const { outcome } = entry;
264
283
  if ('issue' in outcome) {
265
284
  issues.push({ ...outcome.issue, file: display });
266
- unreadableFiles.push(display);
285
+ // Read the source again for the ids alone. Both readers land here, and the
286
+ // evaluating one has not read the text at all — a file that threw on
287
+ // import produced an exception and no bytes. One extra read of a file that
288
+ // is already an ERROR is not a cost worth arranging around, and sharing
289
+ // the reader's source instead would mean the two readers passing different
290
+ // things to one diagnostic.
291
+ unreadable.push({ file: display, ids: await declaredIds(entry.file) });
267
292
  continue;
268
293
  }
269
294
  // One issue per colliding prefix rather than per requirement: the fact is
@@ -301,7 +326,7 @@ export async function loadRegistry(root, reader, files) {
301
326
  }
302
327
  }
303
328
  }
304
- return { registry, issues, prefixOwners: Object.fromEntries(prefixOwner), unreadableFiles };
329
+ return { registry, issues, prefixOwners: Object.fromEntries(prefixOwner), unreadable };
305
330
  }
306
331
  /**
307
332
  * The part of an id that names the space it lives in — `AUTH` of `AUTH-3`.
@@ -1,3 +1,15 @@
1
1
  import type { AttestPlan } from './types.js';
2
+ /**
3
+ * Throws when `source` does not compile, rather than returning a plan built out
4
+ * of what the parser recovered from it — a spec truncated mid-file was handing
5
+ * back the scenarios above the break, so the file counted as coverage while not
6
+ * compiling. `parseSpecs` turns the throw into the per-file `unreadable-file`
7
+ * its guard already produces (ATX-65), which is why this can throw at all: the
8
+ * loop that owns "one file's failure is not the run's failure" is already there.
9
+ *
10
+ * A throw rather than a result type, unlike the registry readers, because this
11
+ * returns a plan and has no failure channel of its own; adding one would change
12
+ * every caller to say what the guard above them already says.
13
+ */
2
14
  export declare function parseSpecFile(file: string, source: string): AttestPlan;
3
15
  //# sourceMappingURL=parser.d.ts.map
@@ -3,9 +3,25 @@
3
3
  // requirement()/scenario() calls with literal ids, and grabs the line number
4
4
  // at parse time — OpenSpec's "parse into strong types, capture the line now".
5
5
  import ts from 'typescript';
6
+ import { parseSource, SourceNotCompiled } from './compiler.js';
7
+ /**
8
+ * Throws when `source` does not compile, rather than returning a plan built out
9
+ * of what the parser recovered from it — a spec truncated mid-file was handing
10
+ * back the scenarios above the break, so the file counted as coverage while not
11
+ * compiling. `parseSpecs` turns the throw into the per-file `unreadable-file`
12
+ * its guard already produces (ATX-65), which is why this can throw at all: the
13
+ * loop that owns "one file's failure is not the run's failure" is already there.
14
+ *
15
+ * A throw rather than a result type, unlike the registry readers, because this
16
+ * returns a plan and has no failure channel of its own; adding one would change
17
+ * every caller to say what the guard above them already says.
18
+ */
6
19
  export function parseSpecFile(file, source) {
7
- const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest,
8
- /* setParentNodes */ true);
20
+ const parsed = parseSource(file, source);
21
+ if ('error' in parsed) {
22
+ throw new SourceNotCompiled(`this file does not compile (${parsed.error.message})`, parsed.error.line);
23
+ }
24
+ const sf = parsed.sf;
9
25
  const scenarios = [];
10
26
  const paramRefs = [];
11
27
  const seenRefs = new Set(); // dedupe: one ref per (reqId, scenario)
@@ -108,7 +108,7 @@ export async function runCheck(root, options = {}) {
108
108
  // command that reads both the registry and every change's delta, so it is the
109
109
  // only one where that distinction is worth anything.
110
110
  return withLoader(options, async (loader) => {
111
- const { registry, issues, unreadableFiles } = await readRegistry(root, options, scan.reqsFiles, loader);
111
+ const { registry, issues, unreadable } = await readRegistry(root, options, scan.reqsFiles, loader);
112
112
  const { plan, issues: unreadableSpecs } = await parseSpecs(scan.specFiles, root);
113
113
  return [
114
114
  ...issues,
@@ -121,7 +121,7 @@ export async function runCheck(root, options = {}) {
121
121
  // that *did* load are all still true. What it must not do is advise work
122
122
  // that the load failure makes wrong — see `orphan-test` in
123
123
  // `validateStructure`.
124
- ...validateStructure(registry, plan, unreadableFiles.length > 0),
124
+ ...validateStructure(registry, plan, unreadable),
125
125
  ...detectPotentialDrift(registry, plan, plan.paramRefs),
126
126
  ...(await unclaimedProposedSpecIssues(root, scan, options, loader)),
127
127
  ...(await changeDirSpecIssues(root)),
@@ -291,13 +291,13 @@ export async function runVerify(root, options = {}) {
291
291
  const loader = await createLoader();
292
292
  let registry;
293
293
  let plan;
294
- let unreadableFiles;
294
+ let unreadable;
295
295
  const issues = [];
296
296
  try {
297
297
  const loaded = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
298
298
  registry = loaded.registry;
299
299
  issues.push(...loaded.issues);
300
- unreadableFiles = loaded.unreadableFiles;
300
+ unreadable = loaded.unreadable;
301
301
  const parsedSpecs = await parseSpecs(scan.specFiles, root);
302
302
  plan = parsedSpecs.plan;
303
303
  issues.push(...parsedSpecs.issues);
@@ -307,7 +307,7 @@ export async function runVerify(root, options = {}) {
307
307
  }
308
308
  // Same as `check`: everything the loaded half supports is still reported, and
309
309
  // only the advice that a load failure would make wrong is withdrawn.
310
- issues.push(...validateStructure(registry, plan, unreadableFiles.length > 0));
310
+ issues.push(...validateStructure(registry, plan, unreadable));
311
311
  issues.push(...detectPotentialDrift(registry, plan, plan.paramRefs));
312
312
  const attesting = attestingFiles(plan);
313
313
  const counts = {
@@ -1,7 +1,7 @@
1
1
  import type { RegistryDelta } from './registry.js';
2
2
  import type { Registry } from './types.js';
3
3
  /** Codes this reader can produce; each is also produced by the evaluating path. */
4
- export type StaticReadCode = 'registry-invalid' | 'registry-no-default' | 'registry-not-static';
4
+ export type StaticReadCode = 'registry-invalid' | 'registry-no-default' | 'registry-not-static' | 'unreadable-file';
5
5
  export type StaticReadResult = {
6
6
  ok: true;
7
7
  registry: Registry;
@@ -13,7 +13,7 @@ export type StaticReadResult = {
13
13
  };
14
14
  export declare function readRegistrySource(file: string, source: string): StaticReadResult;
15
15
  /** Codes the delta reader can produce; both are codes `archive` already emits. */
16
- export type DeltaReadCode = 'change-not-found' | 'registry-not-static';
16
+ export type DeltaReadCode = 'change-not-found' | 'registry-not-static' | 'unreadable-file';
17
17
  export type DeltaReadResult = {
18
18
  ok: true;
19
19
  delta: RegistryDelta;
@@ -39,6 +39,38 @@ export type DeltaReadResult = {
39
39
  * (write the value inline, or `--eval`) is the same sentence either way.
40
40
  */
41
41
  export declare function readDeltaSource(file: string, source: string): DeltaReadResult;
42
+ /**
43
+ * The requirement ids a source *declares*, read from a file that could not be
44
+ * read as a registry (design §5.3).
45
+ *
46
+ * This is not a second registry reader and cannot become one: it returns ids and
47
+ * nothing else, it validates none of them, and no command builds a `Registry`
48
+ * from what it finds. What it exists for is the question `orphan-test` cannot
49
+ * otherwise answer — is this scenario attesting an id that a *broken* file
50
+ * declares, or one that genuinely does not exist — and the answer decides
51
+ * whether a finding is a fact or fallout.
52
+ *
53
+ * It reads the ids rather than the file's id *prefix*, which is what this was
54
+ * first framed as needing. The prefix cannot be recovered from a path: this
55
+ * repository's own registry is `attest.reqs.ts` holding `ATX-*`, which is the
56
+ * evidence that killed the prefix-matches-filename rule (see CHANGELOG,
57
+ * `Considered and rejected`). The ids are in the source, so nothing has to be
58
+ * inferred from a naming convention that nothing enforces.
59
+ *
60
+ * Recall is partial by construction, and the direction of the miss is the point:
61
+ * an id it does not find keeps its `orphan-test`, so the report stays noisy —
62
+ * never wrong. Measured over the failure modes that produce an unreadable
63
+ * registry: a truncated file, a missing default export, a non-literal *value*, a
64
+ * schema-invalid entry, a module that throws at import, and a spread of ids from
65
+ * another module all yield the ids written in this file; only a registry built
66
+ * by a call — `export default buildReqs()` — yields none, and that file contains
67
+ * no id to find.
68
+ *
69
+ * `ts.createSourceFile` is deliberately error-tolerant, which is what lets the
70
+ * first of those cases work at all: the parser recovers an object literal from a
71
+ * file that does not compile.
72
+ */
73
+ export declare function declaredIdsFromSource(file: string, source: string): string[];
42
74
  /**
43
75
  * Where a new entry may be written into a registry file's literal, as an offset
44
76
  * into its source (design §7).
@@ -20,6 +20,7 @@
20
20
  // value is fixed by the source text — a second walker would be a second answer
21
21
  // to "is this a literal", and those two answers must not be able to disagree.
22
22
  import ts from 'typescript';
23
+ import { parseSource } from './compiler.js';
23
24
  import { RegistryValidationError, withProposedRequirements } from './registry.js';
24
25
  import { RegistrySchema } from './schema.js';
25
26
  /** The authoring function a registry file must default-export the result of. */
@@ -45,7 +46,20 @@ class NotStatic extends Error {
45
46
  * has to be run — and only the prose around it differs.
46
47
  */
47
48
  function extractLiteralExport(file, source, fn, noun) {
48
- const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
49
+ // Strict: a source that does not compile is refused here rather than read out
50
+ // of whatever the parser recovered from it. Before this, a registry truncated
51
+ // before its closing `});` came back as a complete registry.
52
+ const parsed = parseSource(file, source);
53
+ if ('error' in parsed) {
54
+ return {
55
+ ok: false,
56
+ reason: 'syntax',
57
+ message: `This ${noun} does not compile (${parsed.error.message}), so nothing in it was checked. ` +
58
+ `Fix the syntax error and run again.`,
59
+ ...(parsed.error.line === undefined ? {} : { line: parsed.error.line }),
60
+ };
61
+ }
62
+ const sf = parsed.sf;
49
63
  const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
50
64
  const exported = defaultExportExpression(sf);
51
65
  if (!exported) {
@@ -91,10 +105,25 @@ function failure(extracted, code) {
91
105
  ...(extracted.line === undefined ? {} : { line: extracted.line }),
92
106
  };
93
107
  }
108
+ /**
109
+ * What this reader calls the three ways extraction fails; `DELTA_CODE` below is
110
+ * the other half.
111
+ *
112
+ * A table per reader rather than a ternary in each, because only one row
113
+ * differs in kind: `syntax` is the same answer for both — a file that does not
114
+ * compile is not a registry problem or a delta problem, it is not a file — and
115
+ * two readers spelling that differently is exactly the disagreement this module
116
+ * opens by refusing to allow.
117
+ */
118
+ const REGISTRY_CODE = {
119
+ syntax: 'unreadable-file',
120
+ 'no-default': 'registry-no-default',
121
+ 'not-static': 'registry-not-static',
122
+ };
94
123
  export function readRegistrySource(file, source) {
95
124
  const extracted = extractLiteralExport(file, source, DEFINE, 'registry');
96
125
  if (!extracted.ok) {
97
- return failure(extracted, extracted.reason === 'no-default' ? 'registry-no-default' : 'registry-not-static');
126
+ return failure(extracted, REGISTRY_CODE[extracted.reason]);
98
127
  }
99
128
  const result = RegistrySchema.safeParse(extracted.value);
100
129
  if (!result.success) {
@@ -109,6 +138,12 @@ export function readRegistrySource(file, source) {
109
138
  }
110
139
  return { ok: true, registry: result.data };
111
140
  }
141
+ /** The delta reader's half of the table above `readRegistrySource`. */
142
+ const DELTA_CODE = {
143
+ syntax: 'unreadable-file',
144
+ 'no-default': 'change-not-found',
145
+ 'not-static': 'registry-not-static',
146
+ };
112
147
  /**
113
148
  * Read a change's `delta({ … })` from its source (design §7).
114
149
  *
@@ -127,7 +162,7 @@ export function readRegistrySource(file, source) {
127
162
  export function readDeltaSource(file, source) {
128
163
  const extracted = extractLiteralExport(file, source, DELTA, 'delta');
129
164
  if (!extracted.ok) {
130
- return failure(extracted, extracted.reason === 'no-default' ? 'change-not-found' : 'registry-not-static');
165
+ return failure(extracted, DELTA_CODE[extracted.reason]);
131
166
  }
132
167
  const value = extracted.value;
133
168
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
@@ -148,6 +183,78 @@ export function readDeltaSource(file, source) {
148
183
  // thing that would notice, and it should have nothing to notice.
149
184
  return { ok: true, delta: withProposedRequirements(value) };
150
185
  }
186
+ /**
187
+ * The requirement ids a source *declares*, read from a file that could not be
188
+ * read as a registry (design §5.3).
189
+ *
190
+ * This is not a second registry reader and cannot become one: it returns ids and
191
+ * nothing else, it validates none of them, and no command builds a `Registry`
192
+ * from what it finds. What it exists for is the question `orphan-test` cannot
193
+ * otherwise answer — is this scenario attesting an id that a *broken* file
194
+ * declares, or one that genuinely does not exist — and the answer decides
195
+ * whether a finding is a fact or fallout.
196
+ *
197
+ * It reads the ids rather than the file's id *prefix*, which is what this was
198
+ * first framed as needing. The prefix cannot be recovered from a path: this
199
+ * repository's own registry is `attest.reqs.ts` holding `ATX-*`, which is the
200
+ * evidence that killed the prefix-matches-filename rule (see CHANGELOG,
201
+ * `Considered and rejected`). The ids are in the source, so nothing has to be
202
+ * inferred from a naming convention that nothing enforces.
203
+ *
204
+ * Recall is partial by construction, and the direction of the miss is the point:
205
+ * an id it does not find keeps its `orphan-test`, so the report stays noisy —
206
+ * never wrong. Measured over the failure modes that produce an unreadable
207
+ * registry: a truncated file, a missing default export, a non-literal *value*, a
208
+ * schema-invalid entry, a module that throws at import, and a spread of ids from
209
+ * another module all yield the ids written in this file; only a registry built
210
+ * by a call — `export default buildReqs()` — yields none, and that file contains
211
+ * no id to find.
212
+ *
213
+ * `ts.createSourceFile` is deliberately error-tolerant, which is what lets the
214
+ * first of those cases work at all: the parser recovers an object literal from a
215
+ * file that does not compile.
216
+ */
217
+ export function declaredIdsFromSource(file, source) {
218
+ const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
219
+ const names = localNames(sf, DEFINE);
220
+ const ids = new Set();
221
+ // Any `defineRequirements({ … })` in the file, not only the exported one: the
222
+ // failure being diagnosed is often that the call is not where it should be —
223
+ // `export const registry = …` is a whole failure mode — and the ids are no
224
+ // less declared for it. Scoped to that call's argument all the same, so an
225
+ // id-shaped key in unrelated data is not mistaken for a requirement.
226
+ const visit = (node) => {
227
+ // `arguments.length > 0` rather than `=== 1`, which is what the reader
228
+ // demands: this runs on files the reader has already refused, so being
229
+ // stricter than it here could only lose ids it is not deciding anything by.
230
+ if (ts.isCallExpression(node) && node.arguments.length > 0 && callsAuthoringFn(node, names, DEFINE)) {
231
+ const arg = unwrap(node.arguments[0]);
232
+ if (ts.isObjectLiteralExpression(arg)) {
233
+ for (const prop of arg.properties) {
234
+ const name = prop.name;
235
+ if (!name)
236
+ continue;
237
+ if (!ts.isStringLiteral(name) && !ts.isIdentifier(name))
238
+ continue;
239
+ if (REQUIREMENT_ID.test(name.text))
240
+ ids.add(name.text);
241
+ }
242
+ }
243
+ }
244
+ ts.forEachChild(node, visit);
245
+ };
246
+ visit(sf);
247
+ return [...ids];
248
+ }
249
+ /**
250
+ * The id shape, as a bare regex rather than through `RequirementIdSchema`.
251
+ *
252
+ * Nothing is being validated here — a key that does not look like an id is
253
+ * simply not one, and there is no author to tell. Kept identical to the schema's
254
+ * pattern by `tests/static-registry.spec.ts`, which is the only place the two
255
+ * spellings can be held together.
256
+ */
257
+ const REQUIREMENT_ID = /^[A-Z]+-\d+$/;
151
258
  /**
152
259
  * The expression a file default-exports, following one level of local binding.
153
260
  *
@@ -191,10 +298,20 @@ function constInitializer(sf, name) {
191
298
  function authoringCall(expr, sf, fn) {
192
299
  if (!ts.isCallExpression(expr) || expr.arguments.length !== 1)
193
300
  return undefined;
194
- const callee = expr.expression;
195
- const named = (ts.isIdentifier(callee) && localNames(sf, fn).has(callee.text)) ||
196
- (ts.isPropertyAccessExpression(callee) && callee.name.text === fn);
197
- return named ? expr.arguments[0] : undefined;
301
+ return callsAuthoringFn(expr, localNames(sf, fn), fn) ? expr.arguments[0] : undefined;
302
+ }
303
+ /**
304
+ * Whether a call is a call of `fn`, by the local names the imports bound to it.
305
+ *
306
+ * One predicate for both readers of this question — the extraction above and the
307
+ * id recovery below — because they must not be able to disagree about what
308
+ * counts as the authoring call. An alias rule taught to one and not the other
309
+ * would make recovery silently miss exactly the files the reader refuses.
310
+ */
311
+ function callsAuthoringFn(call, names, fn) {
312
+ const callee = call.expression;
313
+ return ((ts.isIdentifier(callee) && names.has(callee.text)) ||
314
+ (ts.isPropertyAccessExpression(callee) && callee.name.text === fn));
198
315
  }
199
316
  /** Local names bound to the imported `fn`, including aliases. */
200
317
  function localNames(sf, fn) {
@@ -329,7 +446,15 @@ function numericValue(node) {
329
446
  return value;
330
447
  }
331
448
  export function registryInsertionPoint(file, source) {
332
- const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
449
+ // The write site, and the one where a recovered AST does damage rather than
450
+ // merely misreports: an offset taken from a file that does not compile would
451
+ // splice a new requirement into it. `undefined` is this function's documented
452
+ // refusal to guess, and a source that will not parse is the clearest case of
453
+ // one there is.
454
+ const parsed = parseSource(file, source);
455
+ if ('error' in parsed)
456
+ return undefined;
457
+ const sf = parsed.sf;
333
458
  const exported = defaultExportExpression(sf);
334
459
  if (!exported)
335
460
  return undefined;
@@ -1,3 +1,4 @@
1
+ import type { UnreadableRegistry } from './locate.js';
1
2
  import type { AttestPlan, Issue, ParamRef, Registry } from './types.js';
2
3
  /**
3
4
  * Uncovered requirements: intent exists but no scenario attests it (design §5.3).
@@ -20,11 +21,15 @@ export declare function uncoveredIssues(registry: Registry, plan: AttestPlan): I
20
21
  * and one WARNING:
21
22
  * - rationale-placeholder: a `{name}` in a rationale, which is never interpolated
22
23
  *
23
- * `registryIncomplete` says that at least one `*.reqs.ts` failed to load, so
24
- * `registry` is known to be missing whatever was in it. It changes no verdict —
25
- * only what `orphan-test` advises, for the reason below.
24
+ * and one more WARNING, which exists only when a registry file failed to load:
25
+ * - orphan-from-failed-registry: the scenarios attesting ids that file declares
26
+ *
27
+ * `unreadable` is the `*.reqs.ts` files that failed to load, each with the ids
28
+ * its source still names (see `loadRegistry`). It changes no verdict — only
29
+ * which findings are stated per scenario and which are stated once, for the
30
+ * reason below.
26
31
  */
27
- export declare function validateStructure(registry: Registry, plan: AttestPlan, registryIncomplete?: boolean): Issue[];
32
+ export declare function validateStructure(registry: Registry, plan: AttestPlan, unreadable?: readonly UnreadableRegistry[]): Issue[];
28
33
  /**
29
34
  * Weak anti-drift heuristic (design §6, mechanism 3). Evaluated per requirement,
30
35
  * not per scenario: a requirement that owns params is quiet as soon as *any* one
@@ -1,5 +1,6 @@
1
1
  // Structural validation + anti-drift heuristic (design §5.3, §6).
2
2
  // Graded reporting: ERROR / WARNING / INFO, each with file + line + fix hint.
3
+ import { byCodeUnit } from './order.js';
3
4
  /**
4
5
  * Uncovered requirements: intent exists but no scenario attests it (design §5.3).
5
6
  *
@@ -35,11 +36,15 @@ export function uncoveredIssues(registry, plan) {
35
36
  * and one WARNING:
36
37
  * - rationale-placeholder: a `{name}` in a rationale, which is never interpolated
37
38
  *
38
- * `registryIncomplete` says that at least one `*.reqs.ts` failed to load, so
39
- * `registry` is known to be missing whatever was in it. It changes no verdict —
40
- * only what `orphan-test` advises, for the reason below.
39
+ * and one more WARNING, which exists only when a registry file failed to load:
40
+ * - orphan-from-failed-registry: the scenarios attesting ids that file declares
41
+ *
42
+ * `unreadable` is the `*.reqs.ts` files that failed to load, each with the ids
43
+ * its source still names (see `loadRegistry`). It changes no verdict — only
44
+ * which findings are stated per scenario and which are stated once, for the
45
+ * reason below.
41
46
  */
42
- export function validateStructure(registry, plan, registryIncomplete = false) {
47
+ export function validateStructure(registry, plan, unreadable = []) {
43
48
  const issues = [];
44
49
  const knownIds = new Set(Object.keys(registry));
45
50
  // orphan-test: covers a requirement that does not exist.
@@ -50,27 +55,75 @@ export function validateStructure(registry, plan, registryIncomplete = false) {
50
55
  // following the hint would add a duplicate. That case is not rare when it
51
56
  // happens — one unreadable `*.reqs.ts` orphans every scenario of every
52
57
  // requirement it declared, so the wrong advice is also the loudest thing in
53
- // the report.
58
+ // the report. Measured on one broken registry of three requirements: six
59
+ // ERRORs, of which one was the cause and five were its shadow.
60
+ //
61
+ // Which of the two a given orphan is *can* be decided here, because
62
+ // `loadRegistry` brings back the ids the broken file's source still names. So
63
+ // the scenarios attesting those ids are not reported one by one: they are
64
+ // fallout from a finding already in the report, and the fallout is stated once
65
+ // per file that caused it. That is the same move `duplicate-prefix` makes —
66
+ // the fact is about the file, not about each requirement that reveals it — and
67
+ // the same order ATX-62 records for `spec-load-failed`: the replacement comes
68
+ // first, the withdrawal follows it, because suppressing with nothing in its
69
+ // place trades a wrong message for silence.
54
70
  //
55
- // Which of the two this is cannot be decided here: the ids of a file that
56
- // never parsed are exactly what is unavailable. So the finding stands and the
57
- // *advice* names the uncertainty and the order to work in a report that is
58
- // quieter about problems it can still see would be the worse trade for a
59
- // command whose contract is breadth.
60
- const orphanFix = (id) => registryIncomplete
61
- ? `A registry file failed to load, so ids it declares are missing here — fix that first, and add "${id}" only if it is still unknown afterwards.`
71
+ // Nothing is lost by collapsing them. The replacement names the file, the
72
+ // count and the ids, so a reader can still see which scenarios are affected;
73
+ // the load failure is already an ERROR, so the verdict cannot move; and the
74
+ // scenarios themselves are not defective they will be verified normally the
75
+ // moment the registry loads.
76
+ const swallowed = new Map(unreadable.map((u) => [u.file, []]));
77
+ const ownerOf = new Map();
78
+ for (const u of unreadable)
79
+ for (const id of u.ids)
80
+ ownerOf.set(id, u.file);
81
+ // An unreadable file whose ids could not be recovered leaves the question open
82
+ // for every orphan that is not claimed by another one, and the hedged advice
83
+ // is what an open question sounds like. When every unreadable file gave up its
84
+ // ids, an orphan none of them claims is genuinely unknown — and gets the plain
85
+ // advice it deserves, which the hedge had been withdrawing from correct
86
+ // findings too.
87
+ const someFileOpaque = unreadable.some((u) => u.ids.length === 0);
88
+ const orphanFix = (id) => someFileOpaque
89
+ ? `A registry file failed to load and its ids could not be read, so "${id}" may be one of them — fix that first, and add it only if it is still unknown afterwards.`
62
90
  : `Add it to the registry, or fix the id.`;
63
91
  for (const s of plan.scenarios) {
64
- if (!knownIds.has(s.reqId)) {
65
- issues.push({
66
- level: 'ERROR',
67
- code: 'orphan-test',
68
- reqId: s.reqId,
69
- file: s.file,
70
- line: s.line,
71
- message: `scenario "${s.name}" attests unknown requirement "${s.reqId}". ${orphanFix(s.reqId)}`,
72
- });
92
+ if (knownIds.has(s.reqId))
93
+ continue;
94
+ const owner = ownerOf.get(s.reqId);
95
+ if (owner !== undefined) {
96
+ swallowed.get(owner).push(s.reqId);
97
+ continue;
73
98
  }
99
+ issues.push({
100
+ level: 'ERROR',
101
+ code: 'orphan-test',
102
+ reqId: s.reqId,
103
+ file: s.file,
104
+ line: s.line,
105
+ message: `scenario "${s.name}" attests unknown requirement "${s.reqId}". ${orphanFix(s.reqId)}`,
106
+ });
107
+ }
108
+ // WARNING, not ERROR: the defect is the load failure, which is an ERROR of its
109
+ // own and always present when this is. Reporting it a second time at the same
110
+ // level would say a broken registry is two problems.
111
+ //
112
+ // No `reqId`: the finding is about a file and the set of ids it took down with
113
+ // it, and no single requirement is implicated — the same reason
114
+ // `duplicate-prefix` and `spec-load-failed` carry none.
115
+ for (const u of unreadable) {
116
+ const ids = swallowed.get(u.file);
117
+ if (ids.length === 0)
118
+ continue;
119
+ const unique = [...new Set(ids)].sort(byCodeUnit);
120
+ issues.push({
121
+ level: 'WARNING',
122
+ code: 'orphan-from-failed-registry',
123
+ file: u.file,
124
+ message: `${ids.length} ${ids.length === 1 ? 'scenario attests' : 'scenarios attest'} ids ${u.file} declares (${unique.join(', ')}), and it failed to load — so those ids are missing from this run. ` +
125
+ `They are not orphans: fix the load failure reported above and they will be verified as usual.`,
126
+ });
74
127
  }
75
128
  // uncovered-requirement: intent exists but no scenario attests it. Shared
76
129
  // with `cover`, so the two commands cannot drift apart.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@am_shork/attest",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "TDD-native spec framework: tests are the source of truth for verification, ID-bound requirements the source of truth for intent.",
5
5
  "type": "module",
6
6
  "engines": {