@am_shork/attest 0.9.0 → 0.9.2
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 +371 -15
- package/README.md +2 -1
- package/bin/attest.js +42 -1
- package/dist/cli/report.js +10 -4
- package/dist/core/locate.js +41 -18
- package/dist/core/splice.js +12 -9
- package/dist/core/static-registry.d.ts +18 -5
- package/dist/core/static-registry.js +17 -7
- package/dist/inspect.d.ts +54 -0
- package/dist/inspect.js +68 -0
- package/package.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,208 @@ input, removes/renames a public API or `--json` field, or changes a default
|
|
|
13
13
|
runtime behavior an existing invocation relies on — diagnostic message text is
|
|
14
14
|
not API.
|
|
15
15
|
|
|
16
|
+
## [0.9.2] - 2026-08-17
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **`@am_shork/attest/inspect`: read what a project declares, without executing
|
|
21
|
+
it and without acquiring the runner peer.** One function,
|
|
22
|
+
`inspectProject(root)`, answering the requirements a project declares, the
|
|
23
|
+
scenarios declared for each, and the issues that stopped a file being read.
|
|
24
|
+
Nothing about the quality of any of it — that is the ground truth this project
|
|
25
|
+
refuses to compute for someone else's requirements (design §0).
|
|
26
|
+
|
|
27
|
+
*Why it exists rather than what it does.* Everything this repository knows
|
|
28
|
+
about the quality of its own intent layer is a hand-judged table in
|
|
29
|
+
`tests/intent-rule-candidates.spec.ts`, and the check that keeps such a table
|
|
30
|
+
from rotting is that a scenario a row names is one a spec really declares. An
|
|
31
|
+
adopter could not write that check: the package exported the authoring API and
|
|
32
|
+
nothing else, so `parseSpecs` and the static registry read were internal and
|
|
33
|
+
**the scenario names of their own project were unreachable** — not
|
|
34
|
+
undocumented, unreachable. This is the enabling half of the judgement-table
|
|
35
|
+
proposal under *Under consideration*, and deliberately only that half: it
|
|
36
|
+
ships no schema and decides nothing, and it is the only thing that can produce
|
|
37
|
+
the second table that proposal is waiting for.
|
|
38
|
+
|
|
39
|
+
*One function, not the four internals it composes,* because `loadRegistry`
|
|
40
|
+
takes a reader and which of the two runs is a security property fixed by the
|
|
41
|
+
command, never chosen by a caller (design §5.1). The static reader is welded
|
|
42
|
+
in and no parameter can change it.
|
|
43
|
+
|
|
44
|
+
*And its import closure names neither runner peer,* so a consumer that only
|
|
45
|
+
analyses acquires neither `vite` nor `vitest` — the same argument `./define`
|
|
46
|
+
was added on (design §10). Measured on the built entry: ten modules, and the
|
|
47
|
+
only bare specifiers in the closure are `typescript`, `zod` and two node
|
|
48
|
+
builtins. That claim is gated over the **built** package rather than the
|
|
49
|
+
source, because `locate.ts` names `loader.ts` in a type-only import that the
|
|
50
|
+
emit erases: the two graphs differ by exactly that edge, and it is the edge
|
|
51
|
+
the promise is about. `tests/import-boundary.spec.ts` holds the source side,
|
|
52
|
+
where a type-only mention still counts as *naming* the runner, and its
|
|
53
|
+
reachability walk now follows runtime edges only for the same reason.
|
|
54
|
+
|
|
55
|
+
Additive: no command, flag, exit code or `--json` field changes, and nothing
|
|
56
|
+
that used to pass now fails.
|
|
57
|
+
|
|
58
|
+
- **"Judging your own intent layer" (`docs/{en,zh}/intent-quality.md`): the four
|
|
59
|
+
things no gate is looking at, and a method for building your own
|
|
60
|
+
counter-pressure out of them.** For an existing adopter, on the back of the
|
|
61
|
+
export above.
|
|
62
|
+
|
|
63
|
+
*A method with no template, and the omission is the point.* The page states
|
|
64
|
+
the four **findings** — coverage is binary so lumping is free, a quantifier is
|
|
65
|
+
unread, an iterated `params` list is a run's domain, an assertion can echo
|
|
66
|
+
rather than pin — and the two **mechanical properties** that make a
|
|
67
|
+
hand-judged record a counter-pressure rather than a note: completeness
|
|
68
|
+
(something enumerable must force a row) and rot-binding (a scenario a row
|
|
69
|
+
names must be asserted to exist). Both generalise without assuming a single
|
|
70
|
+
column. What it withholds is this repository's own answer — the four tables
|
|
71
|
+
in `tests/intent-rule-candidates.spec.ts` — and it asks a reader to reach that
|
|
72
|
+
file only after doing the hand pass, saying why rather than hiding it: there
|
|
73
|
+
is one such table in existence and its author wrote both the schema and every
|
|
74
|
+
answer in it, so a second one built from someone else's corpus is evidence
|
|
75
|
+
while one built from ours is an echo. That is the fourth finding turned on the
|
|
76
|
+
question itself.
|
|
77
|
+
The worked example is deliberately **one column**, so the shape that comes
|
|
78
|
+
back is not a copy of the shape that went out, and the report the page asks
|
|
79
|
+
for names the thing worth most: *the column you needed that we do not have.*
|
|
80
|
+
|
|
81
|
+
## [0.9.1] - 2026-08-13
|
|
82
|
+
|
|
83
|
+
### Changed
|
|
84
|
+
|
|
85
|
+
- **Every command starts 11–28% faster, from three lines in `bin/attest.js`.**
|
|
86
|
+
`typescript` is the fixed toll on an `attest` invocation and nothing about the
|
|
87
|
+
design can move it: measured, the CLI's dependency graph costs ~790 ms to load
|
|
88
|
+
and the compiler is ~672 ms of that, against ~28 ms for commander, ~17 ms for
|
|
89
|
+
chalk and ~70 ms for zod. It is also not deferrable — every command except
|
|
90
|
+
`init` reads a registry or a spec through the AST, so the import is needed and
|
|
91
|
+
not merely eager. What *is* movable is the price of the same load, and Node's
|
|
92
|
+
compile cache moves it: V8 keeps its compilation output between runs. Two
|
|
93
|
+
independent A/B rounds, nine interleaved runs each, median — `check self`
|
|
94
|
+
1153 → 970 and 1106 → 971 ms, `cover self` 1122 → 951 and 1346 → 1195 ms,
|
|
95
|
+
`status` 1110 → 923 ms, `init` 904 → 711 and 1365 → 984 ms. The absolute
|
|
96
|
+
numbers move with the machine; the direction did not, in either round or on
|
|
97
|
+
any command.
|
|
98
|
+
**The launcher's import had to become dynamic, and that is the load-bearing
|
|
99
|
+
half.** A static `import` is hoisted and its whole graph evaluated before any
|
|
100
|
+
statement in the file runs, so with one the cache is enabled *after*
|
|
101
|
+
`typescript` has already been compiled and does nothing whatever — while still
|
|
102
|
+
reporting success and leaving every command working. Measured: the static
|
|
103
|
+
spelling came back at 873 ms against an unpatched 897 ms, inside the noise,
|
|
104
|
+
where the dynamic one is 669 ms. A defence that reads as present and is not is
|
|
105
|
+
this repository's dominant failure shape, and it fits in a three-line file.
|
|
106
|
+
No behaviour changes: same output, same exit codes, same files. The cache is
|
|
107
|
+
optional-chained, because `engines` allows Node 20 and the API arrived in
|
|
108
|
+
22.1 — on an older runtime the speed-up is simply absent. It never throws, and
|
|
109
|
+
a cache directory that cannot be written returns a status this ignores, since
|
|
110
|
+
the only thing a failure costs is the saving. It is written under the OS temp
|
|
111
|
+
directory and never into the project, which is the one place every other file
|
|
112
|
+
Attest writes belongs.
|
|
113
|
+
|
|
114
|
+
- **`loadRegistry`'s fan-out is bounded — the third one, where `[0.9.0]` closed
|
|
115
|
+
"both".** That entry is not wrong about what it did; it is wrong about how many
|
|
116
|
+
there were. `locate.ts` reads registry files through a
|
|
117
|
+
`Promise.all(paths.map(…))` of its own, so `check` on a tree carrying N
|
|
118
|
+
`*.reqs.ts` held N reads and N `SourceFile`s at once — the same shape, on the
|
|
119
|
+
same command, against the same input nobody here chooses. Measured in
|
|
120
|
+
`tests/locate-fanout.spec.ts`, which now counts a third pool: **64 before, 32
|
|
121
|
+
after**, on 64 registry files. Unbounded, the figure is the count of the files;
|
|
122
|
+
bounded, it is the constant.
|
|
123
|
+
*Why it hid is the part worth keeping.* The other two live in functions named
|
|
124
|
+
for walking and parsing, and both had a comment about concurrency saying
|
|
125
|
+
nothing about a bound. This one has a comment that reads as a considered
|
|
126
|
+
decision — "read the files concurrently, then fold the results in sorted file
|
|
127
|
+
order: the issue list stays deterministic regardless of which one finished
|
|
128
|
+
first" — and it is a correct, load-bearing answer to a *different* question.
|
|
129
|
+
Something that has visibly been thought about does not get thought about again,
|
|
130
|
+
which is what put a third copy of one shape past a fix that named itself as
|
|
131
|
+
covering all of them.
|
|
132
|
+
*The reader is what makes it more than arithmetic here.* `parseSpecs` bounds
|
|
133
|
+
sources; this bounds whatever the reader holds, which under `--eval`,
|
|
134
|
+
`verify` and `archive` is a Vite SSR module evaluation rather than a string.
|
|
135
|
+
Hence a third constant rather than sharing one, on the reason `WALK_CONCURRENCY`
|
|
136
|
+
already gives: the three bound different resources and cannot be tuned
|
|
137
|
+
together.
|
|
138
|
+
Descriptor exhaustion is still the failure it invites and still unmeasured —
|
|
139
|
+
Windows uses Win32 handles rather than POSIX descriptors, so `ulimit -n` does
|
|
140
|
+
not govern it there, and Linux is the half CI runs. The peak is portable
|
|
141
|
+
arithmetic, which is the standard both earlier halves were accepted on.
|
|
142
|
+
No behaviour change and no `ATX-n`, on that same precedent: the registry, the
|
|
143
|
+
issue list, its order and `prefixOwners` are identical, all asserted beside the
|
|
144
|
+
peak. The fold is now driven by index rather than by arrival, which is what
|
|
145
|
+
keeps "sorted file order" true through a pool that completes out of order —
|
|
146
|
+
the same indexing `parseSpecs` uses, for the same reason.
|
|
147
|
+
|
|
148
|
+
### Fixed
|
|
149
|
+
|
|
150
|
+
- **`--apply` wrote a `*.reqs.ts` that no longer parses, when a MODIFIED entry
|
|
151
|
+
changed two `params` and one of them had an integer-like key.** The write-back
|
|
152
|
+
rests on an ordering property stated in `splice.ts` and nowhere enforced: the
|
|
153
|
+
walk emits its edits in ascending offset order, so the pass at the bottom can
|
|
154
|
+
be a `reverse()` rather than a numeric sort — "an ordering that holds by
|
|
155
|
+
construction beats one restored after the fact". It did not hold by
|
|
156
|
+
construction. `registryEntryLayouts` handed the layout back as plain objects,
|
|
157
|
+
and **JavaScript hoists an integer-like key to the front of every object**, so
|
|
158
|
+
`params: { budgetMb: 12, 10: 'ten' }` was described in an order the file never
|
|
159
|
+
wrote. A `params` key genuinely can be a number: `staticName` spells a numeric
|
|
160
|
+
literal key as `String(value)` and the schema's `z.record(z.string(), …)`
|
|
161
|
+
accepts the result, on both reader paths.
|
|
162
|
+
The consequence is the one shape this command must not have. With the order
|
|
163
|
+
reversed, the lower edit was applied first and every offset after it shifted
|
|
164
|
+
underneath the spans still to be written — measured, changing `budgetMb`
|
|
165
|
+
`12 → 4096` alongside `10: 'ten' → 'a'` produced `params: { budgetMb: 4096,
|
|
166
|
+
10'a'n' },`, off by exactly the two bytes the first replacement grew. The
|
|
167
|
+
registry was **already on disk** by then: `applyMerge` writes each file before
|
|
168
|
+
`verifyWritten` re-reads it, so the re-read caught it and stopped the merge —
|
|
169
|
+
which is what that check is for — but what it stopped at was a hand-written,
|
|
170
|
+
hand-commented registry left unparseable, and the merge's own account of the
|
|
171
|
+
failure is an `internal-error` naming the file.
|
|
172
|
+
The fix is the containers, not a sort. `fields`, `paramKeys` and the layout map
|
|
173
|
+
itself are `Map`s, which keep insertion order for every key type, so the
|
|
174
|
+
property is back to holding by construction rather than by the keys happening
|
|
175
|
+
not to be numbers — the standard `keySource` and `compareIds` are already
|
|
176
|
+
written to, arriving at the one place that had been resting on an upstream
|
|
177
|
+
grammar. Contained to `splice.ts`, which is the only consumer.
|
|
178
|
+
No new requirement: ATX-76 already says the write-back replaces *the source
|
|
179
|
+
span of each value that changed*, and this was a defect against that sentence
|
|
180
|
+
rather than a gap in it. The regression sits in `tests/splice.spec.ts`, at the
|
|
181
|
+
seam the invariant lives on; reaching it through a `self/` scenario would need
|
|
182
|
+
a whole fixture project to express one numeric key, for a weaker signal.
|
|
183
|
+
|
|
184
|
+
- **The package shipped a file its own `exports` map forbade reading.** npm puts
|
|
185
|
+
`package.json` in every tarball whatever `files` says, and the map named only
|
|
186
|
+
`.` and `./define` — so `@am_shork/attest/package.json`, which tooling reaches
|
|
187
|
+
for to read a dependency's version, came back
|
|
188
|
+
`ERR_PACKAGE_PATH_NOT_EXPORTED`. Measured against a real installed tarball,
|
|
189
|
+
not reasoned about. Fixed by naming it, which is the whole change.
|
|
190
|
+
*The test that should have caught it was checking the wrong half.* "Ships the
|
|
191
|
+
entry points the exports map promises" asserted that `dist/index.js`,
|
|
192
|
+
`dist/core/registry.js` and `bin/attest.js` **exist** in the install — which an
|
|
193
|
+
`exports` map blocking every subpath would leave exactly as true. Existence and
|
|
194
|
+
reachability are decided by different fields, `files` shipping the bytes and
|
|
195
|
+
`exports` deciding what may address them, so one cannot stand in for the other.
|
|
196
|
+
`tests/consumer.spec.ts` now resolves each promised specifier from inside the
|
|
197
|
+
consumer project, asserting line by line so the diff names the one that
|
|
198
|
+
stopped. It went red on `./package.json` and green on the other two, which is
|
|
199
|
+
what says the new test measures the thing rather than the fix.
|
|
200
|
+
|
|
201
|
+
- **`cli/report.ts` was carrying a false reason for a defence, and it
|
|
202
|
+
contradicted `render.ts`.** The comment on `formatCoverage` justified
|
|
203
|
+
sanitising a requirement id with "under `--eval` the registry is whatever the
|
|
204
|
+
module exported, and `evalReader` checks that it is an object and nothing
|
|
205
|
+
further" — true when it was written, and untrue since ATX-38 made the
|
|
206
|
+
evaluating reader validate against the same schema as the static one. So
|
|
207
|
+
`^[A-Z]+-\d+$` holds on both paths, `render.ts` says so explicitly, and the two
|
|
208
|
+
modules read as disagreeing about whether a registry id can carry a control
|
|
209
|
+
character.
|
|
210
|
+
The sanitising stays and the reason is corrected: the grammar is held
|
|
211
|
+
somewhere else, `CoverageRow.reqId` is a `string`, and the function is
|
|
212
|
+
exported — a defence that lapses when the other one does is not a defence,
|
|
213
|
+
which is the standard `splice.ts`'s `keySource` is already written to. No
|
|
214
|
+
behaviour change; the call was, and remains, one that cannot fire through any
|
|
215
|
+
command. Recorded because in this repository a comment is the decision record,
|
|
216
|
+
and a false one is read as evidence the next time the question comes up.
|
|
217
|
+
|
|
16
218
|
## [0.9.0] - 2026-08-12
|
|
17
219
|
|
|
18
220
|
### Added
|
|
@@ -4945,7 +5147,21 @@ have to gather again.
|
|
|
4945
5147
|
far past the two registry readers' current surface. **(3)** No mechanism is yet
|
|
4946
5148
|
known that separates "the assertion pins this independently" from "the assertion
|
|
4947
5149
|
echoes it" without executing the scenario twice under different values — which
|
|
4948
|
-
is mutation testing, a much larger thing to own.
|
|
5150
|
+
is mutation testing, a much larger thing to own. *One has since been built and
|
|
5151
|
+
scored, and it does not lift this blocker.* The syntactic candidate — flag a
|
|
5152
|
+
scenario whose expectation is derived from the param the code under test
|
|
5153
|
+
consumed, decided inside one body without executing anything — flags 9 params
|
|
5154
|
+
here, of which **4 are real echoes and 5 are pinned by something outside the
|
|
5155
|
+
body it cannot see**. Under half precision, and the failure is the one
|
|
5156
|
+
predicted above rather than a tuning problem: what pins a value is often a
|
|
5157
|
+
fixture or a second derivation in another file, so a single-body detector is
|
|
5158
|
+
looking in the wrong place by construction. The newest false positive is the
|
|
5159
|
+
clearest instance — `ATX-79.subpath` is compared against `package.json`, which
|
|
5160
|
+
is not the code under test and does not read the registry, so the independent
|
|
5161
|
+
term is in another file entirely. Precision has fallen on every addition
|
|
5162
|
+
measured so far, which is the direction that matters. The score is quoted from the
|
|
5163
|
+
suite's snapshot and re-read with the judgement-table entry above, which is
|
|
5164
|
+
where the same measurement is used for a different decision.
|
|
4949
5165
|
**The guidance half has shipped; this entry is now only about the detector.**
|
|
4950
5166
|
"Read the param inside the assertion" was insufficient and half-wrong as
|
|
4951
5167
|
written, and `[0.5.0]` replaces it everywhere it appeared — README, the
|
|
@@ -5241,6 +5457,98 @@ have to gather again.
|
|
|
5241
5457
|
and the second report of the same shape, which this measurement does not
|
|
5242
5458
|
supply and does not replace.
|
|
5243
5459
|
|
|
5460
|
+
- **An outward-facing form of the judgement tables, so an adopter can run this
|
|
5461
|
+
counter-pressure on their own registry.**
|
|
5462
|
+
`tests/intent-rule-candidates.spec.ts` maintains four hand-judged tables over
|
|
5463
|
+
this repo's registry and enforces their completeness. Design §11 records the
|
|
5464
|
+
properties they defend and that nothing enforces any of them; what the tables
|
|
5465
|
+
add is a counter-pressure holding for **one** registry whose authors agreed to
|
|
5466
|
+
maintain it. An adopter got none of it, and the reason was sharper
|
|
5467
|
+
than "nobody wrote it for them": the package exported `requirement`,
|
|
5468
|
+
`scenario`, `defineRequirements`, `delta` and types, and nothing else.
|
|
5469
|
+
`parseSpecs`, `findFiles` and `readRegistrySource` were internal, so an
|
|
5470
|
+
adopter **could not enumerate their own scenario names** — the table's central
|
|
5471
|
+
rot check, that a claimed scenario is one a spec really declares, was not
|
|
5472
|
+
merely unwritten downstream but unreachable. **That half shipped under
|
|
5473
|
+
`[0.9.2]` as `@am_shork/attest/inspect`** and is recorded at the foot of
|
|
5474
|
+
this entry; what stays here is the schema built on top of it, and the three
|
|
5475
|
+
blockers below are untouched by the export.
|
|
5476
|
+
*The shape that makes it shippable at all is a split, not a feature.* The
|
|
5477
|
+
engine can compute which ids exist, which statements match the universal
|
|
5478
|
+
quantifier regex, which params are list-valued, and whether a named scenario
|
|
5479
|
+
is declared — all four already, all statically. It cannot compute how many
|
|
5480
|
+
obligations a requirement carries, what a quantifier ranges over, or whether a
|
|
5481
|
+
scenario iterates a param. So the shippable object is **the enforcement, never
|
|
5482
|
+
the judgement**: an opt-in `*.judge.ts` read by the same static reader and
|
|
5483
|
+
under the same literal-only rule the registry is (it is metadata about
|
|
5484
|
+
requirements under review, so `check` must execute nothing to read it), whose
|
|
5485
|
+
contents are entirely the adopter's. Opting in is creating the file; once it
|
|
5486
|
+
exists, `requirement-unjudged`, `orphan-judgement`, `unbound-site` and
|
|
5487
|
+
`quantifier-unjudged` make it complete and keep it from rotting. The
|
|
5488
|
+
asymmetry is the only way this can exist without Attest asserting a ground
|
|
5489
|
+
truth it refuses to compute (§0), and it is the same asymmetry `never-red`
|
|
5490
|
+
already runs on: the archive gate would owe judgement only for ids a change
|
|
5491
|
+
ADDs, so a brownfield adoption is not blocked wholesale.
|
|
5492
|
+
*One part of the existing table cannot ship in any form.* `DOMAINS.pin` comes
|
|
5493
|
+
in two kinds — a scenario name, checkable against the AST, and a **source-text
|
|
5494
|
+
fragment** grepped out of the spec file. The second is asserting on the
|
|
5495
|
+
wording of somebody's test, which is the `Issue.message` category error under
|
|
5496
|
+
another name. Only the scenario-name kind is exportable, and what that costs
|
|
5497
|
+
is real: the grep is what caught a deleted pin that `toContain` had passed.
|
|
5498
|
+
*Why no detector ships with it, and this is the part that is already
|
|
5499
|
+
measured.* Three rule families were built and scored against the whole corpus
|
|
5500
|
+
(86 entries, counting a fixture id once per registry file) before any was
|
|
5501
|
+
written into the engine, which is the order `divergent-param` was originally
|
|
5502
|
+
designed in the wrong way round. Every figure below is a quotation of the
|
|
5503
|
+
suite's inline snapshots; the snapshot is the fresh copy, and this paragraph
|
|
5504
|
+
is only ever as fresh as its last edit.
|
|
5505
|
+
**(1) `compound-requirement`** — 34 requirements carry more than one
|
|
5506
|
+
obligation, it flags 16, is **wrong about none**, and misses 18. Never wrong
|
|
5507
|
+
and nearly blind, and the 18 are the form the coverage incentive actually
|
|
5508
|
+
rewards: extra clauses under a single keyword. **(2) The widened variant** —
|
|
5509
|
+
29 correct, but 14 wrong and still 5 missed, and it is silenced by deleting an
|
|
5510
|
+
"and". A rule whose cheapest fix degrades the artifact it protects does not
|
|
5511
|
+
ship, and its precision falls as the registry grows. **(3) `divergent-param`**
|
|
5512
|
+
— 5 divergences on this repo, **all 5 judged wrong**, zero true positives ever
|
|
5513
|
+
recorded. The `code` group now spans 19 ids, and the failure is structural
|
|
5514
|
+
rather than a matter of naming: the key is built by discarding the evidence of
|
|
5515
|
+
relatedness, so the one genuinely paired divergence in the group is the one it
|
|
5516
|
+
cannot show, and the key has churned three times under `flag` on additions
|
|
5517
|
+
that changed no verdict. A fourth candidate — the echo detector, scored at
|
|
5518
|
+
half precision — belongs to the tautology entry above and is not re-quoted
|
|
5519
|
+
here, because a figure stated in two places goes stale in one. Taken together
|
|
5520
|
+
these are the measured range of what a token or single-body detector over
|
|
5521
|
+
prose can do here, and they are why the proposal is enforcement-only.
|
|
5522
|
+
*What blocks the decision — three things.* **(1) One adapter.** This
|
|
5523
|
+
repository's table is the only one that exists, and its author wrote both the
|
|
5524
|
+
schema and every answer in it. By this project's own rule a second table is
|
|
5525
|
+
what makes the seam real, and freezing a `Judgement` type now is deciding for
|
|
5526
|
+
everyone from 34 requirements written by one person. **(2) The breaking form
|
|
5527
|
+
of this was already rejected** — see *Countable obligations* below — and one
|
|
5528
|
+
of its three reasons survives the separate-file shape intact: **nothing can
|
|
5529
|
+
force the count to be true.** The other two do not survive, which is what is
|
|
5530
|
+
new here: a separate artifact touches no `--json` field, no delta apply and no
|
|
5531
|
+
`render` output, and completeness *across ids* is computable in a way
|
|
5532
|
+
completeness *within* an obligation array never was. So this is a re-proposal
|
|
5533
|
+
with one of three objections answered and the sharpest one standing.
|
|
5534
|
+
**(3) `quantifier-unjudged` reuses a rejected detector as its trigger.**
|
|
5535
|
+
Firing on the quantifier token was rejected below at 43% reach and 70% wrong;
|
|
5536
|
+
the claim here is that the same regex is acceptable when it asks a question an
|
|
5537
|
+
author discharges in one line (`over: 'values'`) rather than issuing a
|
|
5538
|
+
verdict. That claim is untested on anyone who did not also write the regex.
|
|
5539
|
+
*The half that is not blocked has shipped, under `[0.9.2]`.*
|
|
5540
|
+
`@am_shork/attest/inspect` is one function answering what a project declares —
|
|
5541
|
+
AST-only, no runner peer, landing on the existing static/executing boundary.
|
|
5542
|
+
It ships no schema and decides nothing, which is why it could go ahead of the
|
|
5543
|
+
three blockers rather than waiting on them. What it changes about this entry
|
|
5544
|
+
is the *kind* of thing that is now missing: the mechanism is no longer
|
|
5545
|
+
unreachable, only unwritten, so a second table is something an adopter can
|
|
5546
|
+
build rather than something only this repository could.
|
|
5547
|
+
*What would decide it:* one judgement table maintained by someone who did not
|
|
5548
|
+
design it. If it comes back the same shape, the seam is real and the schema
|
|
5549
|
+
follows; if it comes back different, `inspect` was the whole answer and the
|
|
5550
|
+
schema never needs to exist.
|
|
5551
|
+
|
|
5244
5552
|
## Considered and rejected
|
|
5245
5553
|
|
|
5246
5554
|
Decisions **not** to build something, kept where they can be found before the
|
|
@@ -5251,6 +5559,40 @@ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
|
|
|
5251
5559
|
sinking one version deeper each time a release was cut above it, and a rejection
|
|
5252
5560
|
filed under a version reads as belonging to it.
|
|
5253
5561
|
|
|
5562
|
+
### Deferring the `typescript` import so `init` and `--version` do not pay it
|
|
5563
|
+
|
|
5564
|
+
Priced on 2026-08-13, in the session that enabled the compile cache above, and
|
|
5565
|
+
rejected on the size of what it actually reaches. The proposal was the move
|
|
5566
|
+
`pipeline.ts` already makes for the runner half — `vite`/`vitest` behind an
|
|
5567
|
+
`await import(...)`, on the argument that a short-lived process pays cold start
|
|
5568
|
+
as the whole bill — applied a second time to the AST half.
|
|
5569
|
+
|
|
5570
|
+
The two are not the same shape, and the measurement is why. The runner lazy-load
|
|
5571
|
+
took a cost off five of seven commands, because `check`, `cover`, `render`,
|
|
5572
|
+
`status` and `init` genuinely cannot start a run. The AST half has one
|
|
5573
|
+
beneficiary: **every command except `init` reads a registry or a spec through the
|
|
5574
|
+
compiler**, so for `check`, `cover`, `render`, `status`, `verify` and `archive`
|
|
5575
|
+
deferring the import moves when the 672 ms is paid and not whether. What is left
|
|
5576
|
+
is `init`, which is run once per project, and `--version` / `--help`. Against the
|
|
5577
|
+
compile cache — which took 11–28% off *all* of them for three lines — the
|
|
5578
|
+
remaining prize is roughly a further 260 ms on `init` alone.
|
|
5579
|
+
|
|
5580
|
+
The structural price is not small either, and it is concentrated in the wrong
|
|
5581
|
+
place. Everything funnels into the compiler through `pipeline.ts`'s static graph,
|
|
5582
|
+
including two edges that are easy to miss: `status` reaches it via
|
|
5583
|
+
`red-record.ts` → `merge.ts` → `splice.ts`, for the single path function
|
|
5584
|
+
`mergedSpecPath`. So deferring it is not one thunk beside the existing two; it is
|
|
5585
|
+
`runInit` and its destination resolution leaving `pipeline.ts`, and
|
|
5586
|
+
`resolveOutFile`'s stated property — private to that module "rather than
|
|
5587
|
+
something a shell is trusted to call" — weakening to pay for it. A once-per-project
|
|
5588
|
+
command is not worth that.
|
|
5589
|
+
|
|
5590
|
+
Worth reopening if either half of the arithmetic moves: a `typescript` whose AST
|
|
5591
|
+
surface can be imported without the checker (the `typescript/unstable/ast*` entry
|
|
5592
|
+
points `core/compiler.ts` already names, once they stop saying unstable), or a
|
|
5593
|
+
second command that needs none of it. `mergedSpecPath` sitting in the heavy
|
|
5594
|
+
module is independently worth fixing whether or not this is.
|
|
5595
|
+
|
|
5254
5596
|
### Widening `status` to the rest of the gate's static half
|
|
5255
5597
|
|
|
5256
5598
|
Scoped and decided on 2026-08-12, in the session that gave `status` its two
|
|
@@ -5693,24 +6035,27 @@ quotation of that snapshot and is only ever as fresh as the last edit here —
|
|
|
5693
6035
|
read the snapshot when the number is what matters. *`compound-requirement`
|
|
5694
6036
|
(WARNING) — rejected.* More than one RFC-2119 keyword in one `statement` is
|
|
5695
6037
|
objectively more than one obligation. Measured (figures re-read from the suite,
|
|
5696
|
-
which is the only place they are not prose): of the
|
|
5697
|
-
carrying more than one, it flags
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
|
|
6038
|
+
which is the only place they are not prose): of the 34 requirements in the
|
|
6039
|
+
corpus carrying more than one, it flags 16 and misses 18 — every one of those 18
|
|
6040
|
+
packs clauses under a single keyword, which is the form the coverage incentive
|
|
6041
|
+
actually rewards. A wider variant (keyword plus a coordinating `and`/`while`/`;`)
|
|
6042
|
+
reaches 29 of 34, at 14 wrong hits rather than the 3 quoted here when the corpus
|
|
6043
|
+
was half this size and the 1 recorded when it was first scored — so reach is not
|
|
6044
|
+
the deciding argument, and the precision it trades away grows with the registry,
|
|
6045
|
+
which three successive re-readings of this paragraph have now measured rather
|
|
6046
|
+
than predicted. This is: both variants are
|
|
5704
6047
|
silenced by deleting the word that triggered them, and neither can tell that
|
|
5705
6048
|
deletion from a real split. `SHALL do A and MUST do B` clears the warning as
|
|
5706
6049
|
`SHALL do A and do B` — same two obligations, one fewer normative keyword. A
|
|
5707
6050
|
rule whose cheapest fix degrades the artifact it protects does not ship.
|
|
5708
6051
|
*`divergent-param` (WARNING) — rejected.* One param name declared with different
|
|
5709
|
-
values in two requirements sharing an id prefix. Measured: it fires
|
|
5710
|
-
this repo and
|
|
5711
|
-
in `ATX-10`), unrelated illustrative fixtures, and `code`
|
|
5712
|
-
in `ATX-17`, `empty-spec` in `ATX-18`,
|
|
5713
|
-
diagnostics that could not possibly agree.
|
|
6052
|
+
values in two requirements sharing an id prefix. Measured: it fires 5 times on
|
|
6053
|
+
this repo and **every firing is judged wrong** — `idleTimeoutMin` (`15` in
|
|
6054
|
+
`ATX-3`, `30` in `ATX-10`), unrelated illustrative fixtures, and `code`
|
|
6055
|
+
(`registry-not-static` in `ATX-17`, `empty-spec` in `ATX-18`,
|
|
6056
|
+
`rationale-placeholder` in `ATX-21`), diagnostics that could not possibly agree.
|
|
6057
|
+
That second group has since grown to span **19 ids**, and two more param names
|
|
6058
|
+
have joined it on the same structure (`flag`, `payloadKinds`). The second arrived on its own when
|
|
5714
6059
|
`ATX-18` was added and grew a third member on its own again when `ATX-21` was,
|
|
5715
6060
|
then `ATX-25`, then `ATX-26`, which is the more damning half: a generic param
|
|
5716
6061
|
name collides more often as a registry grows, so the false-positive rate rises
|
|
@@ -5727,8 +6072,19 @@ firing. Until it appeared, every false positive had been on the single name
|
|
|
5727
6072
|
workaround and therefore left the rule arguably salvageable. It is not the word:
|
|
5728
6073
|
two requirements owning unrelated constants of the same *kind* is the structure,
|
|
5729
6074
|
and any registry large enough to be worth linting has it. Still zero true
|
|
5730
|
-
positives, now across
|
|
6075
|
+
positives, now across an 86-entry corpus (the `corpus` figure in the suite's
|
|
5731
6076
|
snapshot, which counts a fixture id once per registry file that declares it).
|
|
6077
|
+
**Two failure modes have been measured since, and both are about the key rather
|
|
6078
|
+
than the threshold.** The grouping keeps one id per distinct value, so two
|
|
6079
|
+
requirements naming the *same* constant — the case a reader would most want
|
|
6080
|
+
compared — are the pair the key deduplicates away; that has now happened twice,
|
|
6081
|
+
most sharply on `ATX-18`/`ATX-77`, a pair split deliberately because each is
|
|
6082
|
+
falsifiable in a direction the other is blind to. And the key's *identity*
|
|
6083
|
+
moves when a requirement agreeing with an existing member is added, so a finding
|
|
6084
|
+
has to be re-judged by hand while nothing about it changed — three re-keyings
|
|
6085
|
+
across three releases under `flag` alone, none of which moved a verdict. A rule
|
|
6086
|
+
whose output churns on additions that agree with it is one whose maintenance
|
|
6087
|
+
cost is a function of registry growth, on top of the zero precision above.
|
|
5732
6088
|
The escape hatch it was blocked on has since been designed and does not rescue
|
|
5733
6089
|
it: with no config file the only workable shape is an additive
|
|
5734
6090
|
`independentParams?: string[]` on the requirement (a CLI flag is per-invocation
|
package/README.md
CHANGED
|
@@ -167,7 +167,7 @@ Every diagnostic carries a `code`, and every code has a section in
|
|
|
167
167
|
```
|
|
168
168
|
ERROR registry-not-static (requirements/upload.reqs.ts:5)
|
|
169
169
|
Value is not a literal.
|
|
170
|
-
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.9.
|
|
170
|
+
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.9.2/docs/en/troubleshooting.md#registry-not-static
|
|
171
171
|
```
|
|
172
172
|
|
|
173
173
|
The anchor **is** the code, so the link cannot point somewhere the section
|
|
@@ -180,6 +180,7 @@ isn't. In `--json` the same link is on each issue as `docsUrl`.
|
|
|
180
180
|
| **CLI reference** — every command, flag and JSON field | [en](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/cli-reference.md) | [中文](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/zh/cli-reference.md) |
|
|
181
181
|
| **Troubleshooting** — one section per issue code | [en](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/troubleshooting.md) | [中文](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/zh/troubleshooting.md) |
|
|
182
182
|
| **Design** — the authoritative design of the framework | [en](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/attest-design.md) | [中文](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/zh/attest-design.md) |
|
|
183
|
+
| **Judging your own intent layer** — what no gate checks, and a method for it | [en](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/intent-quality.md) | [中文](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/zh/intent-quality.md) |
|
|
183
184
|
| **Feedback template** — report how adoption actually went | [en](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/feedback.template.md) | [中文](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/zh/feedback.template.md) |
|
|
184
185
|
|
|
185
186
|
The change workflow is not here: it is what `attest init` writes into your
|
package/bin/attest.js
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Thin launcher: delegates to the built CLI shell (src/cli -> dist/cli).
|
|
3
|
-
|
|
3
|
+
//
|
|
4
|
+
// **The compile cache is enabled here because here is the only place it can
|
|
5
|
+
// be.** `typescript` is the fixed toll on every invocation: measured on this
|
|
6
|
+
// machine, the CLI's whole dependency graph costs ~790 ms to load and the
|
|
7
|
+
// compiler is ~672 ms of it, against ~28 ms for commander, ~17 ms for chalk and
|
|
8
|
+
// ~70 ms for zod. Nothing can move that by loading it later, either — every
|
|
9
|
+
// command except `init` reads a registry or a spec through the AST, so the
|
|
10
|
+
// import is not merely eager, it is *needed*. What a code cache changes is the
|
|
11
|
+
// price of the same load: V8 keeps its compilation output between runs, and the
|
|
12
|
+
// saving lands on the commands a pipeline actually repeats. Measured over two
|
|
13
|
+
// independent A/B rounds, nine interleaved runs each, median: **11% to 28% off
|
|
14
|
+
// every command** — `check self` 1153 -> 970 and 1106 -> 971 ms, `cover self`
|
|
15
|
+
// 1122 -> 951 and 1346 -> 1195 ms, `status` 1110 -> 923 ms, `init` 904 -> 711
|
|
16
|
+
// and 1365 -> 984 ms. Quoted as a range because the absolute figures move with
|
|
17
|
+
// whatever else the machine is doing; the direction did not, across either
|
|
18
|
+
// round or any command in them.
|
|
19
|
+
//
|
|
20
|
+
// **The import below has to stay dynamic, and this is the whole reason.** A
|
|
21
|
+
// static `import` is hoisted and its module graph is evaluated *before* any
|
|
22
|
+
// statement in this file runs — so with one, the call above happens after
|
|
23
|
+
// `typescript` has already been compiled and the cache does nothing at all. It
|
|
24
|
+
// still returns success, and every command still works, so there is nothing to
|
|
25
|
+
// notice: measured, the static spelling came back at 873 ms against an
|
|
26
|
+
// unpatched 897 ms, i.e. inside the noise, while the dynamic one is 669 ms.
|
|
27
|
+
// That is this repository's dominant failure shape — a defence that reads as
|
|
28
|
+
// present and is not — arriving in a three-line file.
|
|
29
|
+
//
|
|
30
|
+
// Optional-chained because `engines` allows Node 20 and this API arrived in
|
|
31
|
+
// 22.1: on an older runtime the speed-up is absent, which is the right
|
|
32
|
+
// behaviour for an optimisation and not a reason to refuse to start. It never
|
|
33
|
+
// throws either — a cache directory that cannot be written comes back as a
|
|
34
|
+
// status this deliberately ignores, since the only thing a failure costs is the
|
|
35
|
+
// saving above.
|
|
36
|
+
//
|
|
37
|
+
// The cache lands under the OS temp directory, never in the project: every
|
|
38
|
+
// other file Attest writes is one the user commits (see `core/write.ts`), and
|
|
39
|
+
// this is the one that must not be.
|
|
40
|
+
import module from 'node:module';
|
|
41
|
+
|
|
42
|
+
module.enableCompileCache?.();
|
|
43
|
+
|
|
44
|
+
await import('../dist/cli/index.js');
|
package/dist/cli/report.js
CHANGED
|
@@ -250,10 +250,16 @@ export function formatCoverage(rows) {
|
|
|
250
250
|
const detail = r.covered
|
|
251
251
|
? chalk.dim(`${r.scenarioCount} scenario${r.scenarioCount === 1 ? '' : 's'}`)
|
|
252
252
|
: chalk.red('no scenario');
|
|
253
|
-
// Sanitised
|
|
254
|
-
// `^[A-Z]+-\d
|
|
255
|
-
//
|
|
256
|
-
//
|
|
253
|
+
// Sanitised even though a registry id cannot carry a control character:
|
|
254
|
+
// `^[A-Z]+-\d+$` holds on both reader paths — the static one by
|
|
255
|
+
// construction, the evaluating one since ATX-38 — so this can never fire.
|
|
256
|
+
// It stays because the grammar is held somewhere else, and a defence that
|
|
257
|
+
// lapses when the other one does is not a defence; `CoverageRow.reqId` is
|
|
258
|
+
// a `string`, and this function is exported. Same standard as
|
|
259
|
+
// `splice.ts`'s `keySource`, and the reason it is not an inconsistency
|
|
260
|
+
// with `render.ts` deciding the opposite: that one states it need *not*
|
|
261
|
+
// sanitise ids, which is true, and builds its container prototype-free
|
|
262
|
+
// anyway for exactly this reason.
|
|
257
263
|
return ` ${mark} ${chalk.bold(inline(r.reqId))} ${detail}`;
|
|
258
264
|
})
|
|
259
265
|
.join('\n');
|
package/dist/core/locate.js
CHANGED
|
@@ -43,22 +43,27 @@ export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpec
|
|
|
43
43
|
/**
|
|
44
44
|
* Run `fn` over every item with at most `limit` of them in flight.
|
|
45
45
|
*
|
|
46
|
-
* Extracted at the second call site rather than the first
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
46
|
+
* Extracted at the second call site rather than the first, because a shape
|
|
47
|
+
* written N times is one a later fix reaches only some copies of.
|
|
48
|
+
*
|
|
49
|
+
* **There are three, and the third is the one to look for.** `findFiles` and
|
|
50
|
+
* `parseSpecs` are named for what they fan out over; `loadRegistry` is named for
|
|
51
|
+
* merging, and its concurrency sits under a comment about *ordering* that
|
|
52
|
+
* answers a different question convincingly. Anything here that reads a list the
|
|
53
|
+
* project's tree decides the length of belongs in this pool, whatever the
|
|
54
|
+
* function around it is called.
|
|
50
55
|
*
|
|
51
56
|
* The order `fn` is *called* in is the input order; the order it *completes* in
|
|
52
57
|
* is not, so a caller that needs a stable result either indexes into a
|
|
53
|
-
* preallocated array by `index` or sorts afterwards.
|
|
54
|
-
* those, deliberately.
|
|
58
|
+
* preallocated array by `index` or sorts afterwards. All three callers here do
|
|
59
|
+
* one of those, deliberately.
|
|
55
60
|
*
|
|
56
|
-
* No result is collected and none is needed —
|
|
57
|
-
*
|
|
61
|
+
* No result is collected and none is needed — every caller writes into something
|
|
62
|
+
* it already owns, and a version returning `T[]` would have to choose an
|
|
58
63
|
* ordering on their behalf. A throw from `fn` propagates and abandons the rest,
|
|
59
|
-
* which is the existing behaviour at
|
|
60
|
-
* so that one hostile
|
|
61
|
-
* really does end the walk.
|
|
64
|
+
* which is the existing behaviour at all three sites: `parseSpecs` and
|
|
65
|
+
* `loadRegistry` catch per file so that one hostile source scraps only itself
|
|
66
|
+
* (ATX-65), and a failed `readdir` really does end the walk.
|
|
62
67
|
*/
|
|
63
68
|
async function forEachBounded(items, limit, fn) {
|
|
64
69
|
let cursor = 0;
|
|
@@ -299,6 +304,14 @@ async function declaredIds(absPath) {
|
|
|
299
304
|
return [];
|
|
300
305
|
}
|
|
301
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* How many registry files are read at once. A third figure equal to the other
|
|
309
|
+
* two and, for the reason `WALK_CONCURRENCY` gives, a third constant: what this
|
|
310
|
+
* bounds is neither directories nor spec sources but whatever the *reader*
|
|
311
|
+
* holds — a source and a `SourceFile` under `staticReader`, a Vite SSR module
|
|
312
|
+
* evaluation under `evalReader` — and those are not tunable together.
|
|
313
|
+
*/
|
|
314
|
+
const REGISTRY_CONCURRENCY = 32;
|
|
302
315
|
/**
|
|
303
316
|
* Read and merge every `*.reqs.ts` registry under root, with the given reader.
|
|
304
317
|
* A registry the reader rejects becomes its own ERROR; a duplicate id across
|
|
@@ -317,7 +330,16 @@ export async function loadRegistry(root, reader, files) {
|
|
|
317
330
|
const paths = files ?? (await scanProject(root)).reqsFiles;
|
|
318
331
|
// Read the files concurrently, then fold the results in sorted file order:
|
|
319
332
|
// the issue list stays deterministic regardless of which one finished first.
|
|
320
|
-
|
|
333
|
+
//
|
|
334
|
+
// Indexed rather than appended, exactly as `parseSpecs` does it and for the
|
|
335
|
+
// same reason: `forEachBounded` *calls* in input order and *completes* in
|
|
336
|
+
// whatever order the reads finish, so the fold below is driven by position.
|
|
337
|
+
const outcomes = new Array(paths.length);
|
|
338
|
+
await forEachBounded(paths, REGISTRY_CONCURRENCY, async (file, i) => {
|
|
339
|
+
// `readGuarded` catches, so nothing here can throw and abandon the rest —
|
|
340
|
+
// which is the one thing the pool does not do for its caller.
|
|
341
|
+
outcomes[i] = await readGuarded(reader, file);
|
|
342
|
+
});
|
|
321
343
|
const registry = {};
|
|
322
344
|
const issues = [];
|
|
323
345
|
const unreadable = [];
|
|
@@ -331,9 +353,10 @@ export async function loadRegistry(root, reader, files) {
|
|
|
331
353
|
// have to turn a display path back into one; the messages below relativise at
|
|
332
354
|
// the point they are built, which is where the reader's path belongs anyway.
|
|
333
355
|
const prefixOwner = new Map();
|
|
334
|
-
for (
|
|
335
|
-
const
|
|
336
|
-
const
|
|
356
|
+
for (let i = 0; i < paths.length; i += 1) {
|
|
357
|
+
const file = paths[i];
|
|
358
|
+
const outcome = outcomes[i];
|
|
359
|
+
const display = relativePath(root, file);
|
|
337
360
|
if ('issue' in outcome) {
|
|
338
361
|
issues.push({ ...outcome.issue, file: display });
|
|
339
362
|
// Read the source again for the ids alone. Both readers land here, and the
|
|
@@ -342,7 +365,7 @@ export async function loadRegistry(root, reader, files) {
|
|
|
342
365
|
// is already an ERROR is not a cost worth arranging around, and sharing
|
|
343
366
|
// the reader's source instead would mean the two readers passing different
|
|
344
367
|
// things to one diagnostic.
|
|
345
|
-
unreadable.push({ file: display, ids: await declaredIds(
|
|
368
|
+
unreadable.push({ file: display, ids: await declaredIds(file) });
|
|
346
369
|
continue;
|
|
347
370
|
}
|
|
348
371
|
// One issue per colliding prefix rather than per requirement: the fact is
|
|
@@ -353,9 +376,9 @@ export async function loadRegistry(root, reader, files) {
|
|
|
353
376
|
const prefix = idPrefix(id);
|
|
354
377
|
const owner = prefixOwner.get(prefix);
|
|
355
378
|
if (owner === undefined) {
|
|
356
|
-
prefixOwner.set(prefix,
|
|
379
|
+
prefixOwner.set(prefix, file);
|
|
357
380
|
}
|
|
358
|
-
else if (owner !==
|
|
381
|
+
else if (owner !== file && !reported.has(prefix)) {
|
|
359
382
|
reported.add(prefix);
|
|
360
383
|
// No `reqId`: this is about two files, not about any one of the
|
|
361
384
|
// requirements that happen to reveal it.
|
package/dist/core/splice.js
CHANGED
|
@@ -239,7 +239,7 @@ export function spliceModifications(file, source, changes) {
|
|
|
239
239
|
// Ids the file does not hold, first and in code-unit order: they have no
|
|
240
240
|
// position to be reported at, and the walk below is driven by positions.
|
|
241
241
|
for (const id of [...wanted.keys()].sort(byCodeUnit)) {
|
|
242
|
-
if (!layouts
|
|
242
|
+
if (!layouts.has(id))
|
|
243
243
|
refusals.push({ reqId: id, reason: 'entry-not-found' });
|
|
244
244
|
}
|
|
245
245
|
// **The file front to back, not the delta.** Driving the walk from the layout
|
|
@@ -250,7 +250,10 @@ export function spliceModifications(file, source, changes) {
|
|
|
250
250
|
// bottom be a `reverse()` rather than a numeric sort, which ATX-15 does not
|
|
251
251
|
// allow in `src/` and would be the wrong shape anyway — an ordering that holds
|
|
252
252
|
// by construction beats one restored after the fact.
|
|
253
|
-
|
|
253
|
+
// It is also why the layout is `Map`s rather than objects at every level; the
|
|
254
|
+
// container is what carries the order, and `registryEntryLayouts` says why an
|
|
255
|
+
// object cannot.
|
|
256
|
+
for (const [id, layout] of layouts) {
|
|
254
257
|
const change = wanted.get(id);
|
|
255
258
|
if (!change)
|
|
256
259
|
continue;
|
|
@@ -280,7 +283,7 @@ export function spliceModifications(file, source, changes) {
|
|
|
280
283
|
const openField = (name, value) => {
|
|
281
284
|
opened.push(`${layout.fieldInsertion.indent}${name}: ${value}`);
|
|
282
285
|
};
|
|
283
|
-
for (const [name, span] of
|
|
286
|
+
for (const [name, span] of layout.fields) {
|
|
284
287
|
if (name === 'statement' || name === 'rationale') {
|
|
285
288
|
if (before[name] !== after[name])
|
|
286
289
|
out.at(name, span, tsString(after[name]));
|
|
@@ -296,18 +299,18 @@ export function spliceModifications(file, source, changes) {
|
|
|
296
299
|
// Any other field is one the schema does not define, and not this
|
|
297
300
|
// module's to rewrite or to remove.
|
|
298
301
|
}
|
|
299
|
-
if (!layout.fields
|
|
302
|
+
if (!layout.fields.has('statement') && before.statement !== after.statement) {
|
|
300
303
|
openField('statement', tsString(after.statement));
|
|
301
304
|
}
|
|
302
|
-
if (!layout.fields
|
|
305
|
+
if (!layout.fields.has('rationale') && before.rationale !== after.rationale) {
|
|
303
306
|
openField('rationale', tsString(after.rationale));
|
|
304
307
|
}
|
|
305
|
-
if (!layout.fields
|
|
308
|
+
if (!layout.fields.has('params')) {
|
|
306
309
|
const fresh = freshParams(before, after);
|
|
307
310
|
if (fresh.length > 0)
|
|
308
311
|
openField('params', `{ ${fresh.join(', ')} }`);
|
|
309
312
|
}
|
|
310
|
-
if (!layout.fields
|
|
313
|
+
if (!layout.fields.has('outOfScope') && !sameValue(before.outOfScope, after.outOfScope)) {
|
|
311
314
|
openField('outOfScope', outOfScopeSource(after.outOfScope));
|
|
312
315
|
}
|
|
313
316
|
if (opened.length > 0) {
|
|
@@ -360,11 +363,11 @@ function editParams(layout, before, after, out) {
|
|
|
360
363
|
// Named rather than skipped, so the guard does not depend on something
|
|
361
364
|
// upstream holding.
|
|
362
365
|
for (const key of Object.keys(before.params).sort(byCodeUnit)) {
|
|
363
|
-
if (!layout.paramKeys
|
|
366
|
+
if (!layout.paramKeys.has(key) && !sameValue(before.params[key], after.params[key])) {
|
|
364
367
|
out.refuse(`params.${key}`, 'not-a-literal');
|
|
365
368
|
}
|
|
366
369
|
}
|
|
367
|
-
for (const [key, span] of
|
|
370
|
+
for (const [key, span] of layout.paramKeys) {
|
|
368
371
|
if (!Object.hasOwn(after.params, key))
|
|
369
372
|
continue;
|
|
370
373
|
const value = after.params[key];
|
|
@@ -123,10 +123,13 @@ export interface ValueSpan {
|
|
|
123
123
|
* this hands back, so they cannot be written over by anything using it.
|
|
124
124
|
*/
|
|
125
125
|
export interface RegistryEntryLayout {
|
|
126
|
-
/** Value span of each field the entry writes, by field name. */
|
|
127
|
-
fields:
|
|
128
|
-
/**
|
|
129
|
-
|
|
126
|
+
/** Value span of each field the entry writes, by field name, in file order. */
|
|
127
|
+
fields: Map<string, ValueSpan>;
|
|
128
|
+
/**
|
|
129
|
+
* Value span of each key of `params`, when the entry writes one as a literal,
|
|
130
|
+
* in file order.
|
|
131
|
+
*/
|
|
132
|
+
paramKeys: Map<string, ValueSpan>;
|
|
130
133
|
/** Where a new `params` key goes — absent when the entry writes no `params`. */
|
|
131
134
|
paramsInsertion?: RegistryInsertion;
|
|
132
135
|
/** Where a new field goes, inside the entry's own body. */
|
|
@@ -141,6 +144,16 @@ export interface RegistryEntryLayout {
|
|
|
141
144
|
* this cannot describe (a computed key, a value built by a call) are absent from
|
|
142
145
|
* the result rather than partially described, which the caller reads as a
|
|
143
146
|
* refusal for that id: an entry nothing can locate is not one to edit.
|
|
147
|
+
*
|
|
148
|
+
* **Maps at all three levels, because the iteration order is the contract.**
|
|
149
|
+
* `spliceModifications` walks this in file order to get its edits in ascending
|
|
150
|
+
* offset order, which is what lets it apply them with a `reverse()` rather than
|
|
151
|
+
* a sort. A plain object cannot carry that order: an *integer-like* key is
|
|
152
|
+
* hoisted to the front of every JavaScript object, and a `params` key can be
|
|
153
|
+
* one — `staticName` spells a numeric literal key as `String(value)`, and the
|
|
154
|
+
* schema's `z.record(z.string(), …)` accepts the result. A `Map` keeps insertion
|
|
155
|
+
* order for every key type, so the property holds by construction rather than by
|
|
156
|
+
* the keys happening not to be numbers.
|
|
144
157
|
*/
|
|
145
|
-
export declare function registryEntryLayouts(file: string, source: string):
|
|
158
|
+
export declare function registryEntryLayouts(file: string, source: string): Map<string, RegistryEntryLayout> | undefined;
|
|
146
159
|
//# sourceMappingURL=static-registry.d.ts.map
|
|
@@ -510,13 +510,23 @@ function objectInsertion(sf, source, obj) {
|
|
|
510
510
|
* this cannot describe (a computed key, a value built by a call) are absent from
|
|
511
511
|
* the result rather than partially described, which the caller reads as a
|
|
512
512
|
* refusal for that id: an entry nothing can locate is not one to edit.
|
|
513
|
+
*
|
|
514
|
+
* **Maps at all three levels, because the iteration order is the contract.**
|
|
515
|
+
* `spliceModifications` walks this in file order to get its edits in ascending
|
|
516
|
+
* offset order, which is what lets it apply them with a `reverse()` rather than
|
|
517
|
+
* a sort. A plain object cannot carry that order: an *integer-like* key is
|
|
518
|
+
* hoisted to the front of every JavaScript object, and a `params` key can be
|
|
519
|
+
* one — `staticName` spells a numeric literal key as `String(value)`, and the
|
|
520
|
+
* schema's `z.record(z.string(), …)` accepts the result. A `Map` keeps insertion
|
|
521
|
+
* order for every key type, so the property holds by construction rather than by
|
|
522
|
+
* the keys happening not to be numbers.
|
|
513
523
|
*/
|
|
514
524
|
export function registryEntryLayouts(file, source) {
|
|
515
525
|
const found = registryLiteral(file, source);
|
|
516
526
|
if (!found)
|
|
517
527
|
return undefined;
|
|
518
528
|
const { sf, literal } = found;
|
|
519
|
-
const layouts =
|
|
529
|
+
const layouts = new Map();
|
|
520
530
|
for (const entry of literal.properties) {
|
|
521
531
|
if (!ts.isPropertyAssignment(entry))
|
|
522
532
|
continue;
|
|
@@ -526,8 +536,8 @@ export function registryEntryLayouts(file, source) {
|
|
|
526
536
|
const body = unwrap(entry.initializer);
|
|
527
537
|
if (!ts.isObjectLiteralExpression(body))
|
|
528
538
|
continue;
|
|
529
|
-
const fields =
|
|
530
|
-
const paramKeys =
|
|
539
|
+
const fields = new Map();
|
|
540
|
+
const paramKeys = new Map();
|
|
531
541
|
let params;
|
|
532
542
|
for (const field of body.properties) {
|
|
533
543
|
if (!ts.isPropertyAssignment(field))
|
|
@@ -538,7 +548,7 @@ export function registryEntryLayouts(file, source) {
|
|
|
538
548
|
// The unwrapped value, so an `as const` or a parenthesis stays outside the
|
|
539
549
|
// span and survives the replacement it wraps.
|
|
540
550
|
const value = unwrap(field.initializer);
|
|
541
|
-
fields
|
|
551
|
+
fields.set(name, { start: value.getStart(sf), end: value.getEnd() });
|
|
542
552
|
if (name === 'params' && ts.isObjectLiteralExpression(value))
|
|
543
553
|
params = value;
|
|
544
554
|
}
|
|
@@ -550,15 +560,15 @@ export function registryEntryLayouts(file, source) {
|
|
|
550
560
|
if (key === undefined || key === '__proto__')
|
|
551
561
|
continue;
|
|
552
562
|
const value = unwrap(param.initializer);
|
|
553
|
-
paramKeys
|
|
563
|
+
paramKeys.set(key, { start: value.getStart(sf), end: value.getEnd() });
|
|
554
564
|
}
|
|
555
565
|
}
|
|
556
|
-
layouts
|
|
566
|
+
layouts.set(id, {
|
|
557
567
|
fields,
|
|
558
568
|
paramKeys,
|
|
559
569
|
...(params ? { paramsInsertion: objectInsertion(sf, source, params) } : {}),
|
|
560
570
|
fieldInsertion: objectInsertion(sf, source, body),
|
|
561
|
-
};
|
|
571
|
+
});
|
|
562
572
|
}
|
|
563
573
|
return layouts;
|
|
564
574
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Issue, ParsedScenario, Registry } from './core/types.js';
|
|
2
|
+
export type { Issue, ParsedScenario, Registry, Requirement } from './core/types.js';
|
|
3
|
+
/** What a project declares, read without executing any of it. */
|
|
4
|
+
export interface Inspection {
|
|
5
|
+
/**
|
|
6
|
+
* Every requirement declared under the root, merged across registry files.
|
|
7
|
+
*
|
|
8
|
+
* A registry file that could not be read statically contributes nothing here
|
|
9
|
+
* and one entry to {@link Inspection.issues} — so an empty result never means
|
|
10
|
+
* "this project has no requirements" without the issues saying why.
|
|
11
|
+
*/
|
|
12
|
+
readonly registry: Registry;
|
|
13
|
+
/**
|
|
14
|
+
* The scenarios each requirement id has, in declaration order.
|
|
15
|
+
*
|
|
16
|
+
* A `Map` rather than a record, and not only for the key type: a record keyed
|
|
17
|
+
* by ids from a file on disk is the `Object.prototype` hazard ATX-42 exists
|
|
18
|
+
* for, and a `Map` has no prototype chain to read a requirement out of. The
|
|
19
|
+
* cost is that `JSON.stringify` renders it as `{}` — a caller serialising
|
|
20
|
+
* this owes `Object.fromEntries`, and the type is a `Map` so that the
|
|
21
|
+
* conversion is a decision rather than a surprise.
|
|
22
|
+
*
|
|
23
|
+
* An id with no scenarios is absent rather than present-and-empty — the same
|
|
24
|
+
* distinction `cover` reports, and the one a coverage check is asking about.
|
|
25
|
+
*/
|
|
26
|
+
readonly scenarios: ReadonlyMap<string, readonly ParsedScenario[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Everything that stopped a *file* being read, in the shape every command
|
|
29
|
+
* reports: one unreadable file scraps itself and nothing else (ATX-65), which
|
|
30
|
+
* is what lets a caller report on the rest of the project.
|
|
31
|
+
*
|
|
32
|
+
* Not a promise that nothing throws. A root that cannot be walked at all is a
|
|
33
|
+
* caller's mistake rather than a finding about a project, and rejects.
|
|
34
|
+
*/
|
|
35
|
+
readonly issues: readonly Issue[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Read the requirements and scenarios declared under `root`, executing nothing.
|
|
39
|
+
*
|
|
40
|
+
* `root` is the project directory, resolved here so a relative one behaves the
|
|
41
|
+
* same as an absolute one rather than half-working: the walk would find the
|
|
42
|
+
* files either way, and only the relative paths in the result would be wrong,
|
|
43
|
+
* which is the shape of bug that survives a demo.
|
|
44
|
+
*
|
|
45
|
+
* Every `file` in the result is relative to that root and POSIX-spelled on
|
|
46
|
+
* every platform (`paths.ts`) — the spelling a caller can compare against and
|
|
47
|
+
* store, on a value that would otherwise carry the host's separator.
|
|
48
|
+
*
|
|
49
|
+
* The registry read and the spec parse are independent, so they run
|
|
50
|
+
* concurrently — each already bounds its own fan-out over a tree whose size is
|
|
51
|
+
* not ours to choose.
|
|
52
|
+
*/
|
|
53
|
+
export declare function inspectProject(root: string): Promise<Inspection>;
|
|
54
|
+
//# sourceMappingURL=inspect.d.ts.map
|
package/dist/inspect.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The static reading layer, as a second public entry point (design §10).
|
|
2
|
+
//
|
|
3
|
+
// Why it exists: everything this repository knows about the quality of its own
|
|
4
|
+
// requirements is a hand-judged table in `tests/intent-rule-candidates.spec.ts`,
|
|
5
|
+
// and the check that keeps such a table from rotting is "the scenario this row
|
|
6
|
+
// names is one a spec really declares". An adopter could not write that check.
|
|
7
|
+
// The package exported the authoring API and nothing else, so the ids, the
|
|
8
|
+
// statements and above all the *scenario names* of their own project were
|
|
9
|
+
// unreachable from outside — not merely undocumented, unreachable. That is what
|
|
10
|
+
// this entry point answers, and the deliberate limit is that it answers only
|
|
11
|
+
// that: it reports what a project declares, and judges none of it (design §0).
|
|
12
|
+
//
|
|
13
|
+
// **One function rather than the four internals it composes**, and the reason is
|
|
14
|
+
// a security property rather than convenience. `loadRegistry` takes a
|
|
15
|
+
// `RegistryReader`, and which reader runs is fixed by the command — never chosen
|
|
16
|
+
// by a caller, never a fallback — because one of the two executes the project's
|
|
17
|
+
// code and the other does not (design §5.1). Exporting the readers would hand
|
|
18
|
+
// that choice to an adopter, which is the one thing the two-adapter seam must
|
|
19
|
+
// never allow. What is exported therefore has the static reader welded in, and
|
|
20
|
+
// there is no parameter that could change it.
|
|
21
|
+
//
|
|
22
|
+
// The same rule decides the import graph: nothing here may reach `vite` or
|
|
23
|
+
// `vitest`, so a consumer that only analyses does not acquire the runner peer.
|
|
24
|
+
// `tests/import-boundary.spec.ts` gates that rather than leaving it to review —
|
|
25
|
+
// this module is on the short list of entry points whose closure is walked.
|
|
26
|
+
import { resolve } from 'node:path';
|
|
27
|
+
import { loadRegistry, parseSpecs, scanProject, staticReader } from './core/locate.js';
|
|
28
|
+
/**
|
|
29
|
+
* Read the requirements and scenarios declared under `root`, executing nothing.
|
|
30
|
+
*
|
|
31
|
+
* `root` is the project directory, resolved here so a relative one behaves the
|
|
32
|
+
* same as an absolute one rather than half-working: the walk would find the
|
|
33
|
+
* files either way, and only the relative paths in the result would be wrong,
|
|
34
|
+
* which is the shape of bug that survives a demo.
|
|
35
|
+
*
|
|
36
|
+
* Every `file` in the result is relative to that root and POSIX-spelled on
|
|
37
|
+
* every platform (`paths.ts`) — the spelling a caller can compare against and
|
|
38
|
+
* store, on a value that would otherwise carry the host's separator.
|
|
39
|
+
*
|
|
40
|
+
* The registry read and the spec parse are independent, so they run
|
|
41
|
+
* concurrently — each already bounds its own fan-out over a tree whose size is
|
|
42
|
+
* not ours to choose.
|
|
43
|
+
*/
|
|
44
|
+
export async function inspectProject(root) {
|
|
45
|
+
const projectRoot = resolve(root);
|
|
46
|
+
const { reqsFiles, specFiles } = await scanProject(projectRoot);
|
|
47
|
+
const [loaded, parsed] = await Promise.all([
|
|
48
|
+
loadRegistry(projectRoot, staticReader(), reqsFiles),
|
|
49
|
+
parseSpecs(specFiles, projectRoot),
|
|
50
|
+
]);
|
|
51
|
+
const scenarios = new Map();
|
|
52
|
+
for (const scenario of parsed.plan.scenarios) {
|
|
53
|
+
const existing = scenarios.get(scenario.reqId);
|
|
54
|
+
if (existing === undefined)
|
|
55
|
+
scenarios.set(scenario.reqId, [scenario]);
|
|
56
|
+
else
|
|
57
|
+
existing.push(scenario);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
registry: loaded.registry,
|
|
61
|
+
scenarios,
|
|
62
|
+
// Registry issues before spec issues, and each already in sorted file
|
|
63
|
+
// order: the list is a function of the project rather than of which read
|
|
64
|
+
// finished first, so two runs over one tree answer identically.
|
|
65
|
+
issues: [...loaded.issues, ...parsed.issues],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=inspect.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@am_shork/attest",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
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": {
|
|
@@ -25,7 +25,12 @@
|
|
|
25
25
|
"./define": {
|
|
26
26
|
"types": "./dist/core/registry.d.ts",
|
|
27
27
|
"default": "./dist/core/registry.js"
|
|
28
|
-
}
|
|
28
|
+
},
|
|
29
|
+
"./inspect": {
|
|
30
|
+
"types": "./dist/inspect.d.ts",
|
|
31
|
+
"default": "./dist/inspect.js"
|
|
32
|
+
},
|
|
33
|
+
"./package.json": "./package.json"
|
|
29
34
|
},
|
|
30
35
|
"files": [
|
|
31
36
|
"dist",
|