@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 +324 -73
- package/README.md +1 -1
- package/dist/core/compiler.d.ts +32 -0
- package/dist/core/compiler.js +78 -0
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +1 -0
- package/dist/core/locate.d.ts +22 -2
- package/dist/core/locate.js +29 -4
- package/dist/core/parser.d.ts +12 -0
- package/dist/core/parser.js +18 -2
- package/dist/core/pipeline.js +5 -5
- package/dist/core/static-registry.d.ts +34 -2
- package/dist/core/static-registry.js +133 -8
- package/dist/core/validator.d.ts +9 -4
- package/dist/core/validator.js +74 -21
- package/package.json +1 -1
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
|
|
3651
|
-
killing an entry outright, when the spans it rested on turned out not to be
|
|
3652
|
-
spans;
|
|
3653
|
-
|
|
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*
|
|
3980
|
-
`[
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
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
|
|
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
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
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.
|
|
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
|
package/dist/core/compiler.d.ts
CHANGED
|
@@ -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
|
package/dist/core/compiler.js
CHANGED
|
@@ -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
|
package/dist/core/docs.d.ts
CHANGED
|
@@ -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
package/dist/core/locate.d.ts
CHANGED
|
@@ -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
|
-
|
|
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`.
|
package/dist/core/locate.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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),
|
|
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`.
|
package/dist/core/parser.d.ts
CHANGED
|
@@ -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
|
package/dist/core/parser.js
CHANGED
|
@@ -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
|
|
8
|
-
|
|
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)
|
package/dist/core/pipeline.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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;
|
package/dist/core/validator.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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,
|
|
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
|
package/dist/core/validator.js
CHANGED
|
@@ -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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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,
|
|
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
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
const
|
|
61
|
-
|
|
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 (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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