@am_shork/attest 0.4.1 → 0.4.3
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 +982 -225
- package/README.md +25 -4
- package/dist/cli/index.js +8 -4
- package/dist/cli/report.d.ts +20 -0
- package/dist/cli/report.js +25 -51
- package/dist/core/apply.js +49 -1
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +1 -0
- package/dist/core/loader.d.ts +22 -0
- package/dist/core/loader.js +35 -1
- package/dist/core/parser.js +0 -0
- package/dist/core/pipeline.js +2 -3
- package/dist/core/red-record.d.ts +9 -1
- package/dist/core/red-record.js +67 -9
- package/dist/core/runner.d.ts +11 -0
- package/dist/core/runner.js +12 -1
- package/dist/core/schema.d.ts +12 -0
- package/dist/core/schema.js +15 -1
- package/dist/core/skill.js +13 -2
- package/dist/core/terminal.d.ts +24 -0
- package/dist/core/terminal.js +59 -0
- package/dist/core/write.js +37 -5
- package/package.json +5 -3
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/index.js.map +0 -1
- package/dist/cli/json.d.ts.map +0 -1
- package/dist/cli/json.js.map +0 -1
- package/dist/cli/report.d.ts.map +0 -1
- package/dist/cli/report.js.map +0 -1
- package/dist/core/apply.d.ts.map +0 -1
- package/dist/core/apply.js.map +0 -1
- package/dist/core/docs.d.ts.map +0 -1
- package/dist/core/docs.js.map +0 -1
- package/dist/core/gate.d.ts.map +0 -1
- package/dist/core/gate.js.map +0 -1
- package/dist/core/loader.d.ts.map +0 -1
- package/dist/core/loader.js.map +0 -1
- package/dist/core/locate.d.ts.map +0 -1
- package/dist/core/locate.js.map +0 -1
- package/dist/core/order.d.ts.map +0 -1
- package/dist/core/order.js.map +0 -1
- package/dist/core/parser.d.ts.map +0 -1
- package/dist/core/parser.js.map +0 -1
- package/dist/core/paths.d.ts.map +0 -1
- package/dist/core/paths.js.map +0 -1
- package/dist/core/pipeline.d.ts.map +0 -1
- package/dist/core/pipeline.js.map +0 -1
- package/dist/core/red-record.d.ts.map +0 -1
- package/dist/core/red-record.js.map +0 -1
- package/dist/core/registry.d.ts.map +0 -1
- package/dist/core/registry.js.map +0 -1
- package/dist/core/render.d.ts.map +0 -1
- package/dist/core/render.js.map +0 -1
- package/dist/core/runner.d.ts.map +0 -1
- package/dist/core/runner.js.map +0 -1
- package/dist/core/schema.d.ts.map +0 -1
- package/dist/core/schema.js.map +0 -1
- package/dist/core/skill.d.ts.map +0 -1
- package/dist/core/skill.js.map +0 -1
- package/dist/core/static-registry.d.ts.map +0 -1
- package/dist/core/static-registry.js.map +0 -1
- package/dist/core/status.d.ts.map +0 -1
- package/dist/core/status.js.map +0 -1
- package/dist/core/targets.d.ts.map +0 -1
- package/dist/core/targets.js.map +0 -1
- package/dist/core/types.d.ts.map +0 -1
- package/dist/core/types.js.map +0 -1
- package/dist/core/validator.d.ts.map +0 -1
- package/dist/core/validator.js.map +0 -1
- package/dist/core/version.d.ts.map +0 -1
- package/dist/core/version.js.map +0 -1
- package/dist/core/write.d.ts.map +0 -1
- package/dist/core/write.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/runtime.d.ts.map +0 -1
- package/dist/runtime.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,607 @@ 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.4.3] - 2026-08-01
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **The README's two getting-started samples are now the fixture the packaging
|
|
21
|
+
test installs and runs.** `tests/docs-consistency.spec.ts` opened by naming
|
|
22
|
+
this hole: every `*.md` outside the generated `SPEC.md` is hand-written prose
|
|
23
|
+
that no mechanism binds to the code, "the framework's own blind spot (design
|
|
24
|
+
§11)". Every check in that file until now gated a version string or a name;
|
|
25
|
+
this is the first that gates *meaning* — a registry or a spec on the page that
|
|
26
|
+
the engine would now reject was undetectable, which for a tool whose whole
|
|
27
|
+
claim is reporting drift between intent and what the tests execute is the
|
|
28
|
+
failure that costs the most.
|
|
29
|
+
The mechanism is a naming split rather than new machinery, and most of it was
|
|
30
|
+
already there and unnamed: `fixtures/consumer/` imports by bare specifier from
|
|
31
|
+
`@am_shork/attest` and `@am_shork/attest/define` because it is installed from a
|
|
32
|
+
real tarball outside the repo — which is exactly what a reader has to be able
|
|
33
|
+
to paste. So the two files lost their fixture-specific commentary, gained the
|
|
34
|
+
README's teaching comments, and the README now quotes them; the new assertion
|
|
35
|
+
is byte-equality between the fenced block and the file, newline-normalised so a
|
|
36
|
+
CRLF checkout cannot call a clean tree stale. The fixture is the original and
|
|
37
|
+
the page is the quote: breaking either side fails.
|
|
38
|
+
*What moved in the README:* the spec sample is now `session.spec.ts` beside
|
|
39
|
+
`session.ts` rather than `auth/session.spec.ts`, matching the layout the
|
|
40
|
+
packaging test actually runs, and it shows both of the requirement's scenarios
|
|
41
|
+
because the installed run asserts `scenarioCount: 2`.
|
|
42
|
+
*Limits, both deliberate:* the gate covers 2 of the 23 fenced `ts` blocks in
|
|
43
|
+
the README and `docs/en/**` — the rest are elided, ✓/✗-annotated, or
|
|
44
|
+
deliberately rejected by the engine, and none of those can equal a runnable
|
|
45
|
+
file. And the two claims are joined only on a release: this gate runs in
|
|
46
|
+
`pnpm test` and proves the page and the fixture agree, while the run that
|
|
47
|
+
proves the fixture still *works* is `pnpm test:consumer`, which needs a network
|
|
48
|
+
and a tarball. Both remaining halves are in `Planned`.
|
|
49
|
+
|
|
50
|
+
- **The `docs/en` ↔ `docs/zh` mirror rule is now a gate rather than a paragraph
|
|
51
|
+
in `CLAUDE.md`.** Two assertions in `tests/docs-consistency.spec.ts`, both
|
|
52
|
+
scoped to what is comparable without comparing prose. The file set: a page
|
|
53
|
+
added on one side only is not a stale translation, it is a page that does not
|
|
54
|
+
exist, and nothing in the repository would have noticed. And the structure of
|
|
55
|
+
each pair — the heading tree as levels in document order, and the sequence of
|
|
56
|
+
fence info strings — which catches a section dropped or reordered in
|
|
57
|
+
translation, a code sample present in one language only, and a ```ts that
|
|
58
|
+
became ```bash.
|
|
59
|
+
*The parse is the part with a trap in it.* A `#` at the start of a line inside
|
|
60
|
+
a ```bash sample is a shell comment, and these pages are full of them, so the
|
|
61
|
+
reader tracks fences and skips their contents; a closing fence must repeat the
|
|
62
|
+
character that opened it, or a ``` quoted inside a ~~~ block ends the wrong
|
|
63
|
+
thing. An unterminated fence is reported rather than ignored, because it hides
|
|
64
|
+
every heading after it — two documents can agree by being truncated. All three
|
|
65
|
+
are asserted directly, on the standard this file already applies to its other
|
|
66
|
+
parses: a check whose parser can silently match nothing is not a check.
|
|
67
|
+
*One third of the queued entry turned out to be already covered and was not
|
|
68
|
+
written.* It asked for the `##` issue-code headings of `troubleshooting.md` to
|
|
69
|
+
be held equal between languages; the roster check that predates it already
|
|
70
|
+
asserts each language against `ISSUE_CODES` in both directions, so their
|
|
71
|
+
equality follows and a third assertion would catch nothing they let through.
|
|
72
|
+
*The limit, stated because it is not small:* a page whose structure held while
|
|
73
|
+
its meaning went stale passes everything here. Translation is not a diff. Same
|
|
74
|
+
shape as the limit the History gate carries — it can check that a row cites a
|
|
75
|
+
real version, not that the row is true.
|
|
76
|
+
|
|
77
|
+
### Documentation
|
|
78
|
+
|
|
79
|
+
- **The agent-facing documents gained a diagnosis discipline, a review pass, and
|
|
80
|
+
a design vocabulary**, adapted from an external skill collection rather than
|
|
81
|
+
copied from it — the parts that survived are the ones this repository's own
|
|
82
|
+
defect history had already paid for.
|
|
83
|
+
*New skill, `.claude/skills/diagnose/`.* Its one rule is that no hypothesis may
|
|
84
|
+
be formed before a command exists that has been run at least once and can go
|
|
85
|
+
**red** on the bug in hand. Every defect this repository has recorded failed
|
|
86
|
+
silently rather than loudly, so that bar is higher here than the phrase
|
|
87
|
+
suggests, and the skill's local content is the questions that tell a green run
|
|
88
|
+
apart from one that never happened: read the test count rather than the colour,
|
|
89
|
+
since a `beforeAll` throw reports a file as *skipped* (`[0.4.2]`); confirm the
|
|
90
|
+
file you care about actually ran, since a glob that matched nothing is a clean
|
|
91
|
+
run over code that never loaded; and name which half a command covers before
|
|
92
|
+
trusting it. The ways that goes wrong are the Gotchas in `CLAUDE.md`, pointed
|
|
93
|
+
at rather than restated — a second copy of them would only be a second copy to
|
|
94
|
+
drift. The remainder is the standard loop: minimise until every element is
|
|
95
|
+
load-bearing, three to five falsifiable hypotheses before testing any,
|
|
96
|
+
`[DEBUG-…]`-tagged probes, and "no correct seam is itself the finding".
|
|
97
|
+
*`pre-commit-check` gained §8, a reading pass on two axes*, kept unmerged and
|
|
98
|
+
unranked against each other because a change can pass either and fail the
|
|
99
|
+
other. The Spec axis is mostly machine-checked here and nowhere else — `check`,
|
|
100
|
+
`cover` and `verify` *are* drift detection — so it reads only for what those
|
|
101
|
+
cannot reach: whether an `ATX-n`'s text states the contract, whether `params`
|
|
102
|
+
pin the values the scenario should be pinned to, and whether a green scenario
|
|
103
|
+
attests its requirement or merely passes. The Standards axis carries a twelve-
|
|
104
|
+
smell baseline (Fowler, *Refactoring* ch.3) as labelled heuristics that any
|
|
105
|
+
documented rule here overrides, with two weighted for this repository:
|
|
106
|
+
duplicated path-shaped code means a second copy that did not go through
|
|
107
|
+
`src/core/paths.ts`, and speculative generality is the other face of §1's rule
|
|
108
|
+
that every requirement costs a scenario forever.
|
|
109
|
+
*`CLAUDE.md` gained a design vocabulary* — module, interface, depth, seam,
|
|
110
|
+
adapter, and the three tests that settle most arguments — which exists because
|
|
111
|
+
the two structures it names were already these things and had no word for it.
|
|
112
|
+
`src/core/paths.ts` is the repository's deepest module, and the deletion test
|
|
113
|
+
is not hypothetical for it: every silent path defect in the Gotchas is what
|
|
114
|
+
this codebase looked like when that knowledge lived at the call sites. The two
|
|
115
|
+
registry readers are two adapters at one real seam, which is what makes the
|
|
116
|
+
differential suite possible — and the entry marks where the analogy stops,
|
|
117
|
+
since which adapter runs is fixed by the command and never a fallback, that
|
|
118
|
+
being a security property rather than a design preference.
|
|
119
|
+
*And a section on where a decision goes*, which is this file. It is stated in
|
|
120
|
+
`CLAUDE.md` rather than only in `pre-commit-check` because the moment to write
|
|
121
|
+
one is when the decision happens; a design evaluated and dropped mid-session is
|
|
122
|
+
what is most likely to be lost, and by commit time the reasoning that killed it
|
|
123
|
+
is already gone.
|
|
124
|
+
*The candidate that did not survive is the reason the section exists.* The
|
|
125
|
+
proposal was a `docs/adr/` directory holding three decisions lifted out of the
|
|
126
|
+
Gotchas — why `fixtures/nested-spec/` may not be flattened, why `render
|
|
127
|
+
--check` compares the document rather than the bytes, why one `spawn` helper.
|
|
128
|
+
All three were already recorded here, in more detail than the proposal would
|
|
129
|
+
have carried, so the directory's whole effect would have been to split a store
|
|
130
|
+
this file keeps deliberately single. Recorded rather than filed as work, since
|
|
131
|
+
no such directory exists: had it been put under `docs/en/`, the mirror gate
|
|
132
|
+
added above would not have seen it at all — `pages()` is a non-recursive
|
|
133
|
+
`readdir` filtered to `*.md`, so a subdirectory is invisible to the file-set
|
|
134
|
+
check, the structure check, and the version-bearing sample scan alike. A
|
|
135
|
+
documentation subdirectory is therefore an unmirrored one, silently, which is
|
|
136
|
+
this repository's signature failure shape wearing documentation's clothes.
|
|
137
|
+
|
|
138
|
+
### Fixed
|
|
139
|
+
|
|
140
|
+
- **The packaging test installed into a directory Windows spells two ways, and
|
|
141
|
+
read the result as the package being broken.** `tests/consumer.spec.ts` staged
|
|
142
|
+
its throwaway project under `os.tmpdir()`, which on Windows reads `TEMP` — set
|
|
143
|
+
by the OS to the 8.3 short form, `C:\Users\JOHN_C~1\...`. Vite resolves the
|
|
144
|
+
peer `vitest` from the *long* form of the same directory, so the consumer's
|
|
145
|
+
spec file and the installed `@am_shork/attest` imported two module instances of
|
|
146
|
+
it; `describe` ran against an uninitialised runner and the spec died at import
|
|
147
|
+
with `Cannot read properties of undefined (reading 'config')`, before a
|
|
148
|
+
scenario executed. What that surfaced as is the part worth recording: `attest
|
|
149
|
+
verify` reported `tests-red` plus `declared-not-run` for every scenario —
|
|
150
|
+
a report indistinguishable from a genuinely broken package, produced by a
|
|
151
|
+
package that is fine. Staging through `realpath` fixes it.
|
|
152
|
+
This is `CLAUDE.md`'s first failure family one form further out — not a
|
|
153
|
+
separator inside a path, but two spellings of one directory — and it was
|
|
154
|
+
unreachable until `[0.4.2]` made this suite run on Windows at all. CI is Linux,
|
|
155
|
+
where `os.tmpdir()` has one spelling, so the pipeline was never going to say
|
|
156
|
+
otherwise.
|
|
157
|
+
|
|
158
|
+
- **The AST parser's own source file was binary, so every change to it since the
|
|
159
|
+
initial commit was unreviewable.** `src/core/parser.ts` separated the two
|
|
160
|
+
halves of its param-ref dedupe key with a **raw NUL byte** rather than the
|
|
161
|
+
`\x00` escape. One character no editor renders reclassified the whole file:
|
|
162
|
+
`git diff` answered `Binary files a/… and b/… differ` for every change to it,
|
|
163
|
+
`git grep` and `grep` skipped it in silence, and `.gitattributes`' `eol=lf`
|
|
164
|
+
never applied, because `text=auto` tests for binary first.
|
|
165
|
+
*What it cost is the reviewing, not the running.* A string escape is resolved
|
|
166
|
+
by the compiler, so the key is the same three code units on every platform —
|
|
167
|
+
no behaviour changes, and no requirement, scenario or schema moves. What was
|
|
168
|
+
lost is that every text sweep over this repository silently excluded the one
|
|
169
|
+
module that turns spec source into the plan every command reads. A pre-commit
|
|
170
|
+
pass grepping `src/` for a rule — a `path.relative` outside `paths.ts`, a
|
|
171
|
+
stray `spawn` — was answering about 27 of 28 files and reporting no gap, which
|
|
172
|
+
is worse than not having run it.
|
|
173
|
+
*And it shipped.* `files` carries `dist`, and `tsc` copies a template
|
|
174
|
+
literal's contents verbatim, so `dist/core/parser.js` put the byte into every
|
|
175
|
+
installed copy.
|
|
176
|
+
Recorded at this length because the defect is not the byte but its invisibility:
|
|
177
|
+
this is `CLAUDE.md`'s silent-failure shape with no platform in it at all, and
|
|
178
|
+
the tools anyone would bring to look for it are the ones it disables. The last
|
|
179
|
+
thing it costs is its own diff — the old side of this commit is still the
|
|
180
|
+
binary blob, so this entry is the only readable account of the change.
|
|
181
|
+
|
|
182
|
+
## [0.4.2] - 2026-07-30
|
|
183
|
+
|
|
184
|
+
### Added
|
|
185
|
+
|
|
186
|
+
- **The engine ↔ runner boundary is now a gate over the import graph, and it
|
|
187
|
+
had already been crossed.** Design §11 records the widest limit this design
|
|
188
|
+
has: `typescript` is a runtime dependency and `vite`/`vitest` are hard peers,
|
|
189
|
+
so the addressable set of repositories is a subset of one ecosystem's. The
|
|
190
|
+
answer that escapes it is a package split — a core that does not know a runner
|
|
191
|
+
exists plus one thin adapter per runner — and **that split is deliberately not
|
|
192
|
+
done**: a monorepo's build matrix, version coupling and CI fan-out are not
|
|
193
|
+
earned by one package at 0.4.x, and the dogfooding loop is what would pay,
|
|
194
|
+
since `bin/attest.js` loads `dist/` and `self/` is verified through it.
|
|
195
|
+
What ships is the cheap half. `tests/import-boundary.spec.ts` asserts that
|
|
196
|
+
exactly three modules may name `vite`/`vitest` — `src/runtime.ts` (the shipped
|
|
197
|
+
runtime, which is Vitest's `describe`/`it` under Attest's names and executes
|
|
198
|
+
*inside* the child run), `src/core/loader.ts` (the Vite loader) and
|
|
199
|
+
`src/core/runner.ts` (the only module that starts Vitest). It reads the
|
|
200
|
+
specifiers from the AST rather than matching text, so a comment naming
|
|
201
|
+
`vitest/node` is not an import and a dynamic `import()` is one.
|
|
202
|
+
**The boundary was not intact when the gate was written**, which is the whole
|
|
203
|
+
argument for having it: `pipeline.ts` imported `configDefaults` from
|
|
204
|
+
`vitest/config` for one exclude list, putting the peer dependency on the module
|
|
205
|
+
every static command goes through — `check`, `cover` and `render` included.
|
|
206
|
+
Nothing was wrong with the behaviour and no behavioural test could have been
|
|
207
|
+
wrong about it. Only the graph shows it, and a boundary is lost exactly this
|
|
208
|
+
way: one convenient import, in a module nobody thought of as being on the
|
|
209
|
+
runner's side of the line.
|
|
210
|
+
*What it cannot do:* gate **reachability** from the static commands, which is
|
|
211
|
+
not assertable today — `pipeline.ts` holds both halves of the engine, so it
|
|
212
|
+
reaches `runner.ts` unconditionally. That is the same fact the deferred split
|
|
213
|
+
would fix. A second test narrows the reachability claim to where it is both
|
|
214
|
+
meaningful and true: no path from `static-registry.ts` or `parser.ts` — the two
|
|
215
|
+
modules that must read a file without executing it — reaches a runner through
|
|
216
|
+
any module.
|
|
217
|
+
|
|
218
|
+
- **A positive control on the non-execution tests, which were passing without
|
|
219
|
+
one.** The tests asserting that `check`, `cover`, `render` and `status` never
|
|
220
|
+
execute the project work by writing a sentinel file at a registry's module
|
|
221
|
+
scope and expecting it *absent* afterwards. Every one of those assertions is
|
|
222
|
+
equally satisfied by an instrument that never worked: a `writeFileSync` to a
|
|
223
|
+
path that could not be written, or a registry the loader never reached, reads as
|
|
224
|
+
"absent" — indistinguishable from the guarantee being kept. `runVerify` now
|
|
225
|
+
runs last against the same project and the same sentinel, and the sentinel must
|
|
226
|
+
be *present*, which is what turns four absences into evidence. Added in the
|
|
227
|
+
default suite (`tests/pipeline.spec.ts`) and dogfooded under `ATX-16`
|
|
228
|
+
(`self/registry-reader.spec.ts`), since `vitest run` does not run `self/`.
|
|
229
|
+
*Not a new requirement:* this states nothing new about behaviour, it makes
|
|
230
|
+
`ATX-16`'s existing claim falsifiable — so it belongs to that scenario as its
|
|
231
|
+
control rather than to an `ATX-n` of its own.
|
|
232
|
+
*What is still not covered:* `archive` has no positive control of its own. It
|
|
233
|
+
evaluates the delta through the same `loader.load` the `--eval` escape hatch
|
|
234
|
+
uses, and that path is controlled under `ATX-32`, but nothing asserts that
|
|
235
|
+
`archive` specifically would notice if it stopped evaluating.
|
|
236
|
+
|
|
237
|
+
- **The `typescript` range is now run at both ends.** `[0.4.0]` collapsed the
|
|
238
|
+
peer range to `vite ^8` / `vitest ^4` on the argument that advertising a
|
|
239
|
+
version no job runs end to end moves the failure into the adopter's
|
|
240
|
+
repository. That test had never been applied to the third dependency it fits:
|
|
241
|
+
`typescript` is a runtime `dependencies`, and the range was a claim about every
|
|
242
|
+
5.x while CI resolved exactly one of them — 5.9.3, four minors above the floor
|
|
243
|
+
the manifest advertised, which had therefore never been run at all.
|
|
244
|
+
It matters more here than for the peers, because a caret on a direct
|
|
245
|
+
dependency is not this repo's to resolve: a package manager may dedupe it
|
|
246
|
+
against the adopter's own `typescript`, so `parser.ts` and `static-registry.ts`
|
|
247
|
+
— the only two import sites, and both on the path that must read an AST and
|
|
248
|
+
never evaluate it — execute on whichever compiler in the range that project
|
|
249
|
+
already had. TypeScript is also not distributed under SemVer but as
|
|
250
|
+
`{marketing}.{major}.{minor}`, so a "minor" inside a major is free to change
|
|
251
|
+
parse behaviour.
|
|
252
|
+
The new `typescript-matrix` job installs each end and re-runs the differential
|
|
253
|
+
suite (`tests/static-registry.spec.ts`, the one extractor changes were already
|
|
254
|
+
required to go through) plus `tests/parser.spec.ts`. With the lockfile at 5.9.3
|
|
255
|
+
that is three points: 5.5.4, 5.9.3, 6.0.3. **Measured green at all three**, and
|
|
256
|
+
the floor is the one that had never been checked.
|
|
257
|
+
*What it cannot do:* prove the extractor against syntax this repo's corpus does
|
|
258
|
+
not contain — the same limit the History gate carries.
|
|
259
|
+
*No scheduled `typescript@rc` job*, which the queued entry had asked for. It is
|
|
260
|
+
rejected on measurement rather than skipped: `rc` and `latest` are 7.x, which
|
|
261
|
+
exposes no compiler API (see `Planned`), so the job would be red forever — the
|
|
262
|
+
decoration the `audit` job's comment refuses.
|
|
263
|
+
|
|
264
|
+
- **The README's dependency versions are now asserted against the manifest.**
|
|
265
|
+
Found by running the pre-commit list over this change rather than by reading the
|
|
266
|
+
diff: the Prerequisites section makes version claims about all three
|
|
267
|
+
dependencies, the manifest is the only place any of them is true, and nothing
|
|
268
|
+
bound the two. The existing gates in `tests/docs-consistency.spec.ts` pin
|
|
269
|
+
Attest's *own* version into samples and pinned docs URLs; the peer range had
|
|
270
|
+
been unbound prose since `[0.4.0]` collapsed it, which means the sentence
|
|
271
|
+
claiming the range is "exactly the one CI runs" was the one line in it nothing
|
|
272
|
+
ran. This change would have widened that hole by adding a second such claim, so
|
|
273
|
+
it closes it instead.
|
|
274
|
+
The coupling to wording is the cost, and it is narrower than it looks: the
|
|
275
|
+
string matched **is** the claim, unlike an `Issue.message`, which is prose
|
|
276
|
+
precisely so it can be reworded. Rephrasing the sentence is free as long as the
|
|
277
|
+
version stays in it, which is what the failure says.
|
|
278
|
+
|
|
279
|
+
### Changed
|
|
280
|
+
|
|
281
|
+
- **`typescript` widened to `^5.5.0 || ^6.0.0`, and stays a `dependency`.** The
|
|
282
|
+
open question the queued entry raised was whether it should be a **peer**
|
|
283
|
+
instead: a runtime dependency is what buys determinism in principle, dedupe is
|
|
284
|
+
what removes it in practice, so the manifest stated a guarantee the resolver
|
|
285
|
+
does not honour. Measuring it answered the question in the opposite direction
|
|
286
|
+
from the way it was posed.
|
|
287
|
+
**A peer is not available, because the API is not.** TypeScript's `latest` is
|
|
288
|
+
now 7.x, whose npm package is the native port: its main export is
|
|
289
|
+
`{ version, versionMajorMinor }` and the AST moved behind
|
|
290
|
+
`typescript/unstable/ast*` — explicitly unstable. A peer range would therefore
|
|
291
|
+
either exclude 7 and so warn every adopter on the current release into
|
|
292
|
+
installing an older compiler explicitly, or admit one that cannot parse
|
|
293
|
+
anything. Bundling is the only shape that guarantees the API exists at all, so
|
|
294
|
+
what looked like a hedge is the load-bearing decision. Recorded under
|
|
295
|
+
`Considered and rejected` with the measurement.
|
|
296
|
+
The ceiling moved because 6.0.3 measures green on the full API surface the two
|
|
297
|
+
readers use and on both typecheck projects, and because the ceiling had become
|
|
298
|
+
a claim about a major that has since shipped and works. A TS-6 adopter now
|
|
299
|
+
dedupes to one copy running the compiler that matches their own syntax instead
|
|
300
|
+
of installing a second 5.9.
|
|
301
|
+
*Not a bump under this file's Versioning rule, and the reason is worth being
|
|
302
|
+
exact about.* No input that was valid is rejected and no exit code moves. An
|
|
303
|
+
already-installed adopter is untouched, because their lockfile pins what they
|
|
304
|
+
resolved. A **fresh** install genuinely can differ — in a repository already on
|
|
305
|
+
TypeScript 6 the resolver may now dedupe there where it previously fetched a
|
|
306
|
+
5.x of its own, so the compiler behind the AST readers changes. That is a real
|
|
307
|
+
change and not a hidden one: it is the entire purpose of widening the range,
|
|
308
|
+
both ends are gated by the job above, and the widening only ever admits a
|
|
309
|
+
compiler this repo runs the differential suite against. What would make it a
|
|
310
|
+
breaking change is a range whose ends are *asserted* rather than run, which is
|
|
311
|
+
the state this entry ends. This repo's own resolution did not move: the lockfile
|
|
312
|
+
still holds 5.9.3.
|
|
313
|
+
|
|
314
|
+
- **`pipeline.ts` no longer imports `vitest/config`.** `runArchive` needed
|
|
315
|
+
Vitest's default exclusions to compose its own exclude set; the list is now
|
|
316
|
+
re-exported as `BASE_EXCLUDE` from `runner.ts`, which already had the import
|
|
317
|
+
and is one of the three modules allowed to. *Not a behaviour change* — the
|
|
318
|
+
composed exclude set is identical, and `BASE_EXCLUDE` is internal, not part of
|
|
319
|
+
the package's exports.
|
|
320
|
+
|
|
321
|
+
### Documentation
|
|
322
|
+
|
|
323
|
+
- **§11's language-binding bullet now records an answer deferred rather than no
|
|
324
|
+
answer.** It stated the limit — one ecosystem's repositories, no more — and
|
|
325
|
+
said nothing about what holds the line in the meantime. It now says that the
|
|
326
|
+
dependency is confined to three modules and that the confinement is gated on
|
|
327
|
+
the import graph rather than left to review, that the confinement had already
|
|
328
|
+
failed once, and that the package split which actually escapes the limit is
|
|
329
|
+
deferred on cost grounds rather than unconsidered.
|
|
330
|
+
It also states the part of the binding that is **narrower** than "an
|
|
331
|
+
ecosystem", and which has already moved: the AST layer needs the TypeScript
|
|
332
|
+
*compiler API*, and TypeScript 7 does not ship one. So what Attest installs
|
|
333
|
+
into is a repository whose compiler is of a particular generation — which is
|
|
334
|
+
why the range has a ceiling and why the compiler is bundled rather than a peer.
|
|
335
|
+
§0 bought the parse layer with a dependency on one vendor's internal API, and
|
|
336
|
+
"never regex" is what makes that worth paying; the point now written down is
|
|
337
|
+
that the bill arrives again every time the API moves, on a schedule nobody here
|
|
338
|
+
controls. Both languages.
|
|
339
|
+
|
|
340
|
+
- **The README says which TypeScript versions are supported, and that yours is
|
|
341
|
+
not involved.** Prerequisites already explained the peer range and why it is
|
|
342
|
+
exactly what CI runs; the bundled compiler was not mentioned at all, so an
|
|
343
|
+
adopter on TypeScript 7 seeing a second `typescript` in their tree had nothing
|
|
344
|
+
to read. It now states the range, that both ends run in CI, that a TypeScript
|
|
345
|
+
5-or-6 project dedupes to one copy, and that their own compiler version stays
|
|
346
|
+
their choice.
|
|
347
|
+
|
|
348
|
+
- **Said out loud that the archive gate trusts `first-run.json` and cannot check
|
|
349
|
+
it.** The reasoning had been written down since 0.3.0 — in a comment at the top
|
|
350
|
+
of `red-record.ts`, where no adopter reads it. What they read instead was that
|
|
351
|
+
`archive` is an executable gate and that this file is "the evidence", which
|
|
352
|
+
over-sells what running it buys: `hasRecordedRed` compares against `'fail'` and
|
|
353
|
+
nothing else, so a hand-written record clears `never-red` having proved
|
|
354
|
+
nothing.
|
|
355
|
+
The distinction worth stating, and now stated, is between the two kinds of file
|
|
356
|
+
Attest writes. The workflow document and a rendering are *derivable* — a pure
|
|
357
|
+
function of nothing, and a pure function of the registry — so a checker can
|
|
358
|
+
regenerate them and compare, which is exactly what `render --check` does. The
|
|
359
|
+
first-run record is not, and cannot be: it records that a scenario failed
|
|
360
|
+
**once**, and by the time a change is archivable everything is green, so there
|
|
361
|
+
is nothing left to recompute it from. That is inherent to what the file is
|
|
362
|
+
rather than a gap left open — if the state could be derived at archive time,
|
|
363
|
+
the record would not need to exist at all. So it is the one thing Attest writes
|
|
364
|
+
that cannot be checked by regenerating it, and what stands in for that check is
|
|
365
|
+
a reviewer. (The workflow document has no gate either, but that is a decision —
|
|
366
|
+
`init --check` was rejected, see below — not an impossibility.)
|
|
367
|
+
Landed in `troubleshooting.md#never-red` and under `attest archive` in the CLI
|
|
368
|
+
reference (both languages), and in the workflow document `attest init` writes —
|
|
369
|
+
where it also became a fourth entry in "things you must not do", beside editing
|
|
370
|
+
a scenario to match the implementation. Hand-writing the record belongs in that
|
|
371
|
+
list on the list's own terms: it turns the gate green without changing the
|
|
372
|
+
system. **Re-run `attest init` to pick it up.**
|
|
373
|
+
*Not in the README*, which links to the reference for this and stays the pitch
|
|
374
|
+
and the five-minute start. *Not a behaviour change*: nothing about what the
|
|
375
|
+
gate accepts moved, and binding the record to a CI run — the fix that would
|
|
376
|
+
make it checkable — is a separate decision, because it would stop `archive`
|
|
377
|
+
being a command that works offline.
|
|
378
|
+
|
|
379
|
+
### Fixed
|
|
380
|
+
|
|
381
|
+
- **The packaging test could not run on Windows, and said so by reporting eleven
|
|
382
|
+
skipped tests.** `pnpm test:consumer` is the only thing that exercises the
|
|
383
|
+
published surface — the `files` allowlist, the `exports` map, the `bin` launcher,
|
|
384
|
+
peer resolution from a foreign `node_modules` — and it is what stops another
|
|
385
|
+
0.1.1-style "green in-repo, broken on install" release. On Windows it had never
|
|
386
|
+
run at all. Three separate defects, each of which had to be fixed to see the
|
|
387
|
+
next one:
|
|
388
|
+
- *`npm` cannot be spawned by that name.* The file is `npm.cmd`, so `npm` is
|
|
389
|
+
ENOENT — and `npm.cmd` is EINVAL, because Node stopped spawning `.cmd` directly
|
|
390
|
+
when it closed the argument-injection hole in 22.x. The same applies to the
|
|
391
|
+
`.bin/attest` shim the install writes, so both the package manager and the CLI
|
|
392
|
+
under test were unspawnable. Both now go through one `spawn` helper that adds a
|
|
393
|
+
shell on win32 only, so POSIX — the platform CI gates on — spawns exactly as it
|
|
394
|
+
did before, and quotes its arguments where the shell is used.
|
|
395
|
+
- *The tarball path was substituted raw into JSON.* A Windows path's backslashes
|
|
396
|
+
are escape characters there, so `npm install` refused the generated manifest
|
|
397
|
+
with EJSONPARSE before reading a single dependency. This is the "path crossing
|
|
398
|
+
a boundary" shape `CLAUDE.md` already records, with JSON as the boundary rather
|
|
399
|
+
than a glob — so the fix is the one that rule already prescribes: the spec now
|
|
400
|
+
goes through `relativePath`, which answers both halves at once, POSIX
|
|
401
|
+
separators and a path short enough to have none. Spelling the `../` by hand
|
|
402
|
+
would have worked and was the first attempt, but it encodes the staging layout
|
|
403
|
+
as a constant: move the consumer project one level deeper and the literal is
|
|
404
|
+
wrong *while still being valid JSON and a valid specifier*, so the next failure
|
|
405
|
+
would arrive as a mystery ENOENT somewhere else.
|
|
406
|
+
- *The `bin` launcher's absence was untestable.* A missing shim surfaced only as
|
|
407
|
+
a spawn error from the helper, which reads as a broken test rather than a
|
|
408
|
+
broken `bin` field, so it now has an assertion of its own.
|
|
409
|
+
**The failure shape is the reason this survived four releases.** The throw was in
|
|
410
|
+
`beforeAll`, so Vitest reported the file's tests as *skipped* — the run said
|
|
411
|
+
nothing was wrong while executing none of it, and CI is Linux, so the pipeline
|
|
412
|
+
was never going to disagree. A third defect shape is now recorded in `CLAUDE.md`
|
|
413
|
+
beside the two it already carried.
|
|
414
|
+
*What is verified where:* all eleven tests pass on Windows, running the real
|
|
415
|
+
`npm` and the real `.bin/attest.cmd`. The POSIX branch is unchanged by
|
|
416
|
+
construction — no shell, same command name, arguments passed through untouched —
|
|
417
|
+
and its one new behaviour is the relative `file:` spec; that half is left to CI,
|
|
418
|
+
which runs this job on Linux on every push.
|
|
419
|
+
|
|
420
|
+
- **The crash path printed the checked project's text to the terminal with
|
|
421
|
+
nothing removed.** 0.4.0 closed terminal injection by putting every diagnostic
|
|
422
|
+
through a sanitiser, and left the two `catch` blocks in the CLI printing
|
|
423
|
+
`err.stack` raw beside it. That is not a lesser path: under `verify`,
|
|
424
|
+
`archive` or `--eval` the thing that throws is a module out of the repository
|
|
425
|
+
under test, so the message on that stack is that repository's text — and
|
|
426
|
+
throwing is the cheapest thing a hostile registry can do, cheaper than getting
|
|
427
|
+
a scenario name into a report. On a fork merge request the reader is not the
|
|
428
|
+
author, and the payload is the same one as before: erase the lines above,
|
|
429
|
+
repaint a red verdict green, retitle the window.
|
|
430
|
+
**Both `catch` blocks now go through one formatter**, which is `block` — the
|
|
431
|
+
same function the diagnostics use, so a stack's frames indent by four more
|
|
432
|
+
spaces and no line of an injected message can occupy the column a real `ERROR`
|
|
433
|
+
header sits in. One function rather than a copy each, because a copy each is
|
|
434
|
+
how the first one came to be missed; ATX-37 is restated over everything the
|
|
435
|
+
CLI prints rather than over "the report", since the narrow wording is what the
|
|
436
|
+
narrow implementation matched. `--json` is untouched and was never exposed —
|
|
437
|
+
serialising escapes the same characters, which is exactly why this had to be
|
|
438
|
+
fixed on the side people read.
|
|
439
|
+
**And the loader, which prints before Attest ever sees the error.** Driving
|
|
440
|
+
the built CLI at a registry that throws escape sequences is what turned this
|
|
441
|
+
up, after the `catch` blocks were already fixed: `logLevel: 'error'` quiets
|
|
442
|
+
Vite's chatter and not its errors, and when a `*.reqs.ts` throws, Vite writes
|
|
443
|
+
`Error when evaluating SSR module …: <the message>` to stderr on its own
|
|
444
|
+
account. So the `registry-invalid` diagnostic built from that very exception
|
|
445
|
+
was being sanitised while the raw bytes had already gone past it — a forged
|
|
446
|
+
`ERROR` header at column 0 and a `\r` to erase the line above it, from a file
|
|
447
|
+
the reviewer only ran. The loader now passes a `customLogger` that puts every
|
|
448
|
+
message through the same function. The whole message is treated as untrusted,
|
|
449
|
+
which costs Vite its own colour on that path: by the time it arrives the
|
|
450
|
+
project's text has been interpolated into it, so there is no half left to
|
|
451
|
+
trust.
|
|
452
|
+
*Where the sanitiser lives changed with it.* `block` / `inline` / `control`
|
|
453
|
+
moved from `cli/report.ts` to `core/terminal.ts`, because the loader is below
|
|
454
|
+
the CLI and cannot import from it — and a second copy of this decision is
|
|
455
|
+
precisely what the crash path had been.
|
|
456
|
+
|
|
457
|
+
- **A failed write left a scratch file in a version-controlled directory, and
|
|
458
|
+
named it something another process could take first.** `writeAtomic` is what
|
|
459
|
+
keeps a rendering, the agent skill and a change's first-run record from ever
|
|
460
|
+
being committed half-written, and the temporary file it renames from was doing
|
|
461
|
+
damage on both of the paths it could fail on. Nothing removed it when the
|
|
462
|
+
rename failed, so `.attest.<pid>.tmp` stayed next to the file it failed to
|
|
463
|
+
become — either committed, or a dirty working tree in `archive`, the one
|
|
464
|
+
command that takes care not to produce one. And the pid it was named for
|
|
465
|
+
separates two *runs*, not two *writes*: `init` writing several targets is the
|
|
466
|
+
second shape, and it was safe only by happening to be sequential, which makes
|
|
467
|
+
the safety a property of that loop rather than of the write.
|
|
468
|
+
**The scratch file now takes a random name, is created with `wx`, and is
|
|
469
|
+
removed when anything between the write and the rename throws.** `wx` is
|
|
470
|
+
`O_CREAT | O_EXCL`, so an existing path at that name — a symlink included — is
|
|
471
|
+
refused rather than followed; with an unpredictable name nothing can aim at
|
|
472
|
+
the path in the first place, and `wx` is what makes a guess useless rather
|
|
473
|
+
than merely unlikely. No `mode` is set: the temporary file *becomes* the
|
|
474
|
+
destination, so tightening it would quietly change the permissions of a
|
|
475
|
+
document the user commits and serves. (ATX-43)
|
|
476
|
+
*What ATX-43 does not state.* The exclusive creation is in the code and not in
|
|
477
|
+
the requirement, because an unpredictable name means no scenario can
|
|
478
|
+
pre-create the path to observe it — and a requirement whose scenario cannot
|
|
479
|
+
fail is worse than the comment that records it (design §11). The two
|
|
480
|
+
properties that *are* observable, and are stated, are that no scratch file
|
|
481
|
+
survives either outcome and that two writes into one directory cannot collide.
|
|
482
|
+
|
|
483
|
+
*Behaviour change.* Neither entry changes an exit code, a `--json` field or a
|
|
484
|
+
diagnostic. The visible differences are that a stack printed on stderr is
|
|
485
|
+
indented under its first line, that a `*.reqs.ts` which throws under `--eval`
|
|
486
|
+
now has Vite's report of it printed without colour, and that a temporary file
|
|
487
|
+
is named `.attest.<uuid>.tmp` rather than `.attest.<pid>.tmp` — anything
|
|
488
|
+
matching the old name in a `.gitignore` should become `.attest.*.tmp`, though
|
|
489
|
+
after this there is nothing left for it to match.
|
|
490
|
+
|
|
491
|
+
- **A delta could bring a requirement into the registry under an id the registry
|
|
492
|
+
itself would refuse, and one spelling of that id was not a key at all.** A
|
|
493
|
+
delta introduces an id in two places — an ADDED key, and the target of a
|
|
494
|
+
RENAME — and `applyDelta` checked neither: it validated the requirement a
|
|
495
|
+
delta adds while never looking at the key it files it under, and it called
|
|
496
|
+
`map.set(to, …)` without looking at `to`. So `^[A-Z]+-\d+$` — the grammar
|
|
497
|
+
`defineRequirements` holds every registry key to — did not apply on the one
|
|
498
|
+
other path that creates them. The mild form is an inconsistency: a change
|
|
499
|
+
could archive under `auth-7`, an id nobody could then write into the
|
|
500
|
+
`*.reqs.ts` the change is supposed to merge into, so the gate passed on an end
|
|
501
|
+
state the intent layer cannot represent.
|
|
502
|
+
The form that made it a defect is `__proto__`. Added ids do not stop at the
|
|
503
|
+
registry — `addedIds` carries them into the first-run record — and there
|
|
504
|
+
`record[id] ??= {}` never fired for that name, because reading it yields the
|
|
505
|
+
shared prototype rather than `undefined`. The outcome was then written onto
|
|
506
|
+
`Object.prototype`: one spec file in one change, and every object in the
|
|
507
|
+
process carries the key.
|
|
508
|
+
**Every id a delta introduces now goes through the registry's own schema
|
|
509
|
+
first, in one function both sites call.** ADDED reuses `add-invalid`, because
|
|
510
|
+
the reader's situation and the file to go and edit are unchanged and a second
|
|
511
|
+
code would ask them to learn a distinction that alters nothing they do. RENAME
|
|
512
|
+
gets a new one, `rename-target-invalid`, because the existing pair are both
|
|
513
|
+
about *existence* — a target that is taken, a source that is gone — and
|
|
514
|
+
neither can say the id is unusable; it is reported ahead of the source so a
|
|
515
|
+
delta with both ends wrong names the half the author has to retype. (ATX-41)
|
|
516
|
+
*This repository's own example was one of the offenders.* `renamed: [{ from:
|
|
517
|
+
'AUTH-2', to: 'AUTH-2-login' }]` stood in both design documents, in the
|
|
518
|
+
workflow document `attest init` writes into every adopter's repo, and in these
|
|
519
|
+
tests — so the shape being refused is the shape we had been teaching. All of
|
|
520
|
+
them now read `to: 'SESSION-1'`, which is the better example anyway: it shows
|
|
521
|
+
why a rename happens at all (re-homing an id under its own prefix, per
|
|
522
|
+
`duplicate-prefix`) rather than decorating the old one.
|
|
523
|
+
|
|
524
|
+
- **The archive gate could be satisfied by evidence that was never recorded.**
|
|
525
|
+
`changes/<name>/first-run.json` is read into a plain object, and `JSON.parse`
|
|
526
|
+
makes `__proto__` an ordinary own property — so a record carrying that key
|
|
527
|
+
re-pointed the *record's* prototype, and `hasRecordedRed` then answered `true`
|
|
528
|
+
for a requirement the file says nothing about. The file serialises back as
|
|
529
|
+
`{}`, so the never-red obligation — the one mechanism whose entire purpose is
|
|
530
|
+
to hold a fact about history — was met by a file containing no history, and
|
|
531
|
+
the diff a reviewer reads showed an empty record. Reaching it needed nothing
|
|
532
|
+
but a text editor.
|
|
533
|
+
**The record is now built and read as a map with no prototype**, at both
|
|
534
|
+
levels, and `recordedOutcome` looks up own properties only rather than
|
|
535
|
+
`record[reqId]?.[scenario]` — so the guarantee does not depend on who
|
|
536
|
+
constructed the object the gate was handed. Prototype-free rather than a check
|
|
537
|
+
for the handful of names that misbehave: a blocklist is a second answer to
|
|
538
|
+
"is this a data key", and having no prototype is the first one. This also
|
|
539
|
+
stops the answer from resting on how the schema library happens to treat
|
|
540
|
+
`__proto__`, which today is what was discarding it before the gate saw it.
|
|
541
|
+
(ATX-42)
|
|
542
|
+
|
|
543
|
+
*Behaviour change, and who it turns red.* The first entry rejects input that
|
|
544
|
+
used to pass, on both halves: a delta that ADDs an id outside `^[A-Z]+-\d+$`
|
|
545
|
+
now fails `archive` with `add-invalid`, and one whose rename target is outside
|
|
546
|
+
it fails with `rename-target-invalid`, where both previously merged. The
|
|
547
|
+
second half is the one likelier to bite, because the pattern it refuses is the
|
|
548
|
+
one our own documentation showed. Both are real CI breaks for anyone who has
|
|
549
|
+
such a delta in flight, and both are deliberate — the alternative is archiving
|
|
550
|
+
a state that cannot be written back into a registry. The fix is to retype the
|
|
551
|
+
id in `requirements.delta.ts`; nothing else in the change moves, and a
|
|
552
|
+
requirement already merged under a bad id is unaffected, since this checks
|
|
553
|
+
deltas rather than registries. The second entry loosens nothing and tightens
|
|
554
|
+
nothing a well-formed record could notice: a record whose keys are real ids
|
|
555
|
+
reads and serialises exactly as before.
|
|
556
|
+
`rename-target-invalid` is a new `code` value and therefore additive — no
|
|
557
|
+
`SCHEMA_VERSION` bump, and a consumer branching on `issues[].code` sees a code
|
|
558
|
+
it has not heard of exactly as it would any other new diagnostic.
|
|
559
|
+
|
|
560
|
+
- **The package shipped 54 source maps and none of the sources they point at.**
|
|
561
|
+
`sourceMap` and `declarationMap` have both been on since the first build, and
|
|
562
|
+
every map tsc emits names its origin as `../../src/*.ts` — a path outside
|
|
563
|
+
everything the `files` allowlist carried, which was `dist`, `bin` and this
|
|
564
|
+
document. So each map in an installed copy resolved to nothing: a debugger
|
|
565
|
+
that will not step into the engine, and a *Go to Definition* that stops at the
|
|
566
|
+
`.d.ts` instead of the declaration behind it. Nothing errored, which is how it
|
|
567
|
+
survived four minors — every tool that reads a map ignores an unresolvable one
|
|
568
|
+
rather than failing on it, so this was a feature quietly not working rather
|
|
569
|
+
than a thing anyone could notice breaking.
|
|
570
|
+
**The maps are now excluded from the package rather than repaired.** `files`
|
|
571
|
+
grows a negation (`!dist/**/*.map`); they stay in `dist/` for this repo's own
|
|
572
|
+
use, where `src/` is present and they resolve, and the tarball drops to
|
|
573
|
+
119.7 kB across 59 files — smaller than the 147 kB that shipped broken.
|
|
574
|
+
*Why not the other two shapes.* Adding `src` to `files` also works and was
|
|
575
|
+
the first fix written: it makes both maps resolve, at 195 kB and 148 files, to
|
|
576
|
+
serve stepping into the engine from an adopter's `node_modules` — which
|
|
577
|
+
nothing has asked for, and which anyone auditing the reader would do from the
|
|
578
|
+
repository instead. This file has rejected `init --check`, `divergent-param`
|
|
579
|
+
and countable obligations for costing more than a demonstrated need, and that
|
|
580
|
+
standard does not bend for a packaging decision. Turning the two compiler
|
|
581
|
+
options off is the cleanest published output, since it leaves no dangling
|
|
582
|
+
`sourceMappingURL` comment, but it also removes the maps from `dist/` here,
|
|
583
|
+
where `verify self` runs through the built CLI and they are the only thing
|
|
584
|
+
that maps a crash back to a line of source. (`inlineSources` is not a third
|
|
585
|
+
option: it embeds text into the `.js.map` and does nothing for the
|
|
586
|
+
`.d.ts.map`.)
|
|
587
|
+
*What is gated, and where.* `tests/consumer.spec.ts` asserts the installed
|
|
588
|
+
tree carries no `*.map` and no `.ts`. The pairing is the thing that can come
|
|
589
|
+
apart silently — drop the negation, and the package is back to shipping maps
|
|
590
|
+
that resolve to nothing — and the installed tree is the only place the
|
|
591
|
+
question exists, since in-repo `src/` is always there. Reversing the decision
|
|
592
|
+
means inverting that assertion, not deleting it, which the test says in
|
|
593
|
+
place.
|
|
594
|
+
|
|
595
|
+
- **`build` never removed `dist/`, so a module deleted from `src/` kept being
|
|
596
|
+
emitted.** `tsc` overwrites its outputs and deletes nothing, so a renamed or
|
|
597
|
+
removed module leaves its last emit behind indefinitely. Found by the check
|
|
598
|
+
above rather than by looking: `dist/cli/version.js` and `dist/core/guide.js`
|
|
599
|
+
were still present, both orphaned by `[0.4.1]`, one of them by the
|
|
600
|
+
`packageVersion()` move that release records.
|
|
601
|
+
**What it was not is a correctness hazard, and the distinction is worth
|
|
602
|
+
keeping.** `dist/` mirrors the import graph of `src/`, and a dangling import
|
|
603
|
+
in `src/` fails `tsc`, so an orphan is unreachable by construction — dead
|
|
604
|
+
weight rather than something `bin/attest.js` could load. No published tarball
|
|
605
|
+
carried one either: `dist/` is gitignored and the publish job builds from a
|
|
606
|
+
fresh checkout. What is left is a working tree where `dist/` is not the output
|
|
607
|
+
of any single build, which is worth one line to remove given that every
|
|
608
|
+
`attest …` command in this repo runs from it, and `CLAUDE.md` already has to
|
|
609
|
+
warn that those commands reflect the last build rather than `src/`.
|
|
610
|
+
`build` now runs a `clean` script first. `tsc -b --clean` was tried and does
|
|
611
|
+
not do this job — it deletes the outputs it can derive from the current
|
|
612
|
+
sources, which is every file except the orphan. The remaining shape without a
|
|
613
|
+
new dependency is a `node -e` one-liner; `rimraf` would read better in the
|
|
614
|
+
manifest and pulls a transitive tree in exchange, which is the trade the
|
|
615
|
+
`audit` job and the `brace-expansion` override exist to keep an eye on.
|
|
616
|
+
|
|
16
617
|
## [0.4.1] - 2026-07-29
|
|
17
618
|
|
|
18
619
|
Every diagnostic now links to a section that explains it, and the README is a
|
|
@@ -1428,6 +2029,124 @@ Initial release.
|
|
|
1428
2029
|
(MIT), whose four-stage engine and diff-first change model Attest's
|
|
1429
2030
|
architecture is adapted from (re-implemented from scratch, no source copied).
|
|
1430
2031
|
|
|
2032
|
+
## Planned
|
|
2033
|
+
|
|
2034
|
+
Decisions **to** build something, not yet built. The mirror of `Considered and
|
|
2035
|
+
rejected` at the foot of this file, and the three sections from here down live
|
|
2036
|
+
together for the same two reasons: nothing in them has shipped, so nothing in
|
|
2037
|
+
them belongs to a release, and a block pinned above the version history sinks
|
|
2038
|
+
one release deeper every time one is cut. They run by decreasing commitment —
|
|
2039
|
+
decided yes, undecided, decided no. An entry leaves this one by being
|
|
2040
|
+
implemented — at which point it is rewritten as an `Added` bullet under its
|
|
2041
|
+
version — or by being rejected, at which point it moves down with what killed
|
|
2042
|
+
it. Neither move is a deletion.
|
|
2043
|
+
|
|
2044
|
+
This is not the issue tracker and does not replace it. `[0.1.6]` records that
|
|
2045
|
+
feedback reports are triaged into issues or `Unreleased` and deliberately not
|
|
2046
|
+
kept in this tree; that rule stands, and holds for anything with a reporter, a
|
|
2047
|
+
repro, or a state to track. What is kept here is the narrower thing this file
|
|
2048
|
+
already keeps one section of: a **decision with an argument attached**, where
|
|
2049
|
+
losing the argument would cost more than losing the task. The last below came out
|
|
2050
|
+
of reading another repository against this one — `msw`. The first came out of
|
|
2051
|
+
building something queued here, which is the other way an entry arrives: a gate
|
|
2052
|
+
that finds a defect on its first run files the defect.
|
|
2053
|
+
|
|
2054
|
+
- **A diagnosable refusal when the resolved compiler has no AST API.**
|
|
2055
|
+
Discovered by building the `typescript` matrix (see `[0.4.2]`), which is
|
|
2056
|
+
what a matrix is for. `typescript@7` is the native port: its npm package's main
|
|
2057
|
+
export is `{ version, versionMajorMinor }`, and the AST moved behind
|
|
2058
|
+
`typescript/unstable/ast*` — so `ts.createSourceFile` and every `ts.isX` guard
|
|
2059
|
+
the two readers call is `undefined`. Measured: `attest check` on it dies with
|
|
2060
|
+
`TypeError: Cannot read properties of undefined (reading 'Latest')` and a raw
|
|
2061
|
+
stack, on the crash path this release just finished sanitising. No issue code,
|
|
2062
|
+
no fix hint, and nothing saying which compiler it wanted.
|
|
2063
|
+
**The range excludes 7, and that is not the same as being safe from it.** A
|
|
2064
|
+
caret is a request, not a constraint the adopter cannot override: `pnpm.overrides`
|
|
2065
|
+
and Yarn `resolutions` both pin a transitive dependency across the whole tree,
|
|
2066
|
+
and a repo standardising on one compiler is exactly the repo that uses them —
|
|
2067
|
+
this one has an `overrides` block of its own. So the reachable case is a
|
|
2068
|
+
deliberate, reasonable act by an adopter, answered with a stack trace.
|
|
2069
|
+
What is planned is a check of the API surface where the readers are entered,
|
|
2070
|
+
reported as an ERROR naming the resolved version and the supported range. Cheap,
|
|
2071
|
+
and it converts the one dependency failure this tool cannot survive from a crash
|
|
2072
|
+
into a diagnosis. *Deliberately not folded into the matrix commit*: a new ERROR
|
|
2073
|
+
is a behaviour change, it needs an issue code, a troubleshooting section in both
|
|
2074
|
+
languages and an `ATX-n` with a scenario, and none of that belongs in a commit
|
|
2075
|
+
whose whole claim is that nothing about behaviour moved.
|
|
2076
|
+
*What it cannot do:* make Attest work on TypeScript 7. That is a migration to
|
|
2077
|
+
`typescript/unstable/ast`, against an API whose own name says not to depend on
|
|
2078
|
+
it yet — a separate decision, with the honest answer for now being that the
|
|
2079
|
+
supported range has a ceiling and says so.
|
|
2080
|
+
|
|
2081
|
+
- **Region-marked samples, so a partial quote can be bound too.** The whole-file
|
|
2082
|
+
half of this shipped in `[0.4.3]`: the README's two getting-started blocks
|
|
2083
|
+
are now byte-equal to `fixtures/consumer/`. That buys 2 of the 23 fenced `ts`
|
|
2084
|
+
blocks in the README and `docs/en/**`, and the ceiling is structural rather
|
|
2085
|
+
than lazy — the other 21 are elided (`{ /* … */ }`), annotated (`// ✓ the single
|
|
2086
|
+
source`, `// ✗ a literal that can drift`), or deliberately rejected by the engine
|
|
2087
|
+
(`params: { maxMb: MAX_MB }`), and none of those can equal a runnable file.
|
|
2088
|
+
**What raises it is marking spans, not a second mechanism.** A
|
|
2089
|
+
`// #region readme:registry` and its close inside the fixture, with the
|
|
2090
|
+
assertion running block against dedented span, lets one file feed several
|
|
2091
|
+
passages and makes a partial quote bindable by construction. Where that
|
|
2092
|
+
actually reaches, counted rather than hoped: `troubleshooting.md`'s
|
|
2093
|
+
single-source pair is two verbatim lines of `fixtures/consumer/session.spec.ts`
|
|
2094
|
+
and its `AUTH-3` block is the registry entry at a different indent — so a
|
|
2095
|
+
handful more, not the remainder. Anything carrying a `✓`/`✗` is authored
|
|
2096
|
+
commentary and stays a fragment; that is what it is for.
|
|
2097
|
+
*The ✗ blocks are a separate question, and worth not conflating.* A sample
|
|
2098
|
+
showing input the engine must reject could be bound to a fixture whose
|
|
2099
|
+
*rejection* is asserted — which is what `fixtures/eval-invalid-registry`
|
|
2100
|
+
already is. Binding those is the more valuable half, because a counterexample
|
|
2101
|
+
that stopped being a counterexample is the one kind of stale sample that
|
|
2102
|
+
actively teaches the wrong thing. It is also the one that needs a fixture per
|
|
2103
|
+
diagnostic, so it is not free the way the regions are.
|
|
2104
|
+
*The mirror rule still caps what can be claimed on the Chinese side.*
|
|
2105
|
+
`docs/zh/**` matches `docs/en/**` down to the comments inside its code samples,
|
|
2106
|
+
so byte-equality holds on one side only and the zh blocks stay translated prose
|
|
2107
|
+
around the same code — the parity gate in `[0.4.3]` is what covers them now,
|
|
2108
|
+
and it covers structure, not meaning. Unchanged from the whole-file half: the residual
|
|
2109
|
+
risk recorded with it in `[0.4.3]` — that the gate and the run proving the
|
|
2110
|
+
fixture still works live in different suites — applies here identically.
|
|
2111
|
+
|
|
2112
|
+
## Under consideration
|
|
2113
|
+
|
|
2114
|
+
Candidates with **no decision yet**, between the two sections either side of it
|
|
2115
|
+
and at the foot of the file for the reason both of those are: nothing here has
|
|
2116
|
+
shipped, so nothing here belongs to a release, and an open question filed under
|
|
2117
|
+
a version reads as something that version settled. An entry leaves by being
|
|
2118
|
+
decided — up into `Planned` and then into an `Added` bullet under its version,
|
|
2119
|
+
or down into `Considered and rejected` with what killed it. Neither move is a
|
|
2120
|
+
deletion. What each entry has to carry is the evidence currently blocking the
|
|
2121
|
+
decision, because that is what a re-proposal a year from now would otherwise
|
|
2122
|
+
have to gather again.
|
|
2123
|
+
|
|
2124
|
+
- **Bounding the fan-out in `findFiles` and `parseSpecs`.** Both walk the
|
|
2125
|
+
project with no concurrency limit: `findFiles` recurses through
|
|
2126
|
+
`Promise.all(subdirs.map(walk))`, so every directory in the tree is opened at
|
|
2127
|
+
once, and `parseSpecs` does `Promise.all(files.map(readFile))`, which reads
|
|
2128
|
+
every spec file into memory before parsing any of them. Neither is bounded by
|
|
2129
|
+
anything, and `check` is the command this repository tells people to run first
|
|
2130
|
+
in a pipeline on an untrusted fork MR — so the input size is not ours to
|
|
2131
|
+
assume.
|
|
2132
|
+
*The evidence, and why it is not enough to act on.* Measured on a synthetic
|
|
2133
|
+
tree of 6000 spec files: the fan-out is real and unbounded — peak in-flight
|
|
2134
|
+
`readFile` calls came back as exactly 6000 — and `attest check` finished in
|
|
2135
|
+
1.99 s without erroring. It could not be made to fail. Windows is why: Node
|
|
2136
|
+
uses Win32 handles there rather than POSIX descriptors, so the `ulimit -n` of
|
|
2137
|
+
3200 that the shell reports does not govern the process, and the one platform
|
|
2138
|
+
where descriptor exhaustion is plausible is the one CI runs and this
|
|
2139
|
+
measurement could not reach (`CLAUDE.md`'s two-platform rule, pointing the
|
|
2140
|
+
unusual way round). What *is* portable is the memory: `parseSpecs` holds every
|
|
2141
|
+
source at once, which at a realistic 8 KiB per spec file is ~47 MiB at this
|
|
2142
|
+
count — noticeable, not fatal.
|
|
2143
|
+
So the fix is known and cheap — a concurrency limit of ~32 at both sites, and
|
|
2144
|
+
parsing each source as it arrives rather than after all of them, which makes
|
|
2145
|
+
the peak constant — and it is held back by this file's own standard: no report,
|
|
2146
|
+
and no demonstrated failure on any platform. Recorded here so that the day
|
|
2147
|
+
someone hits an `EMFILE` from `attest check`, the diagnosis is already written
|
|
2148
|
+
down rather than rediscovered.
|
|
2149
|
+
|
|
1431
2150
|
## Considered and rejected
|
|
1432
2151
|
|
|
1433
2152
|
Decisions **not** to build something, kept where they can be found before the
|
|
@@ -1438,231 +2157,269 @@ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
|
|
|
1438
2157
|
sinking one version deeper each time a release was cut above it, and a rejection
|
|
1439
2158
|
filed under a version reads as belonging to it.
|
|
1440
2159
|
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
2160
|
+
### `typescript` as a peer dependency instead of a bundled one
|
|
2161
|
+
|
|
2162
|
+
The open question the matrix entry was queued to settle (see `[0.4.2]`), and
|
|
2163
|
+
it is worth keeping because the reasoning that made it attractive is sound and
|
|
2164
|
+
still wrong. A runtime `dependencies` on a caret is a guarantee the resolver does
|
|
2165
|
+
not honour — a package manager may dedupe it against the adopter's own compiler,
|
|
2166
|
+
so the version that actually parses their registry is not this repo's to choose.
|
|
2167
|
+
A peer makes that explicit instead of pretending otherwise, which is the shape
|
|
2168
|
+
this repository chose for `vite` and `vitest` in `[0.4.0]` for exactly that
|
|
2169
|
+
reason.
|
|
2170
|
+
|
|
2171
|
+
**What killed it is that the API a peer would supply does not exist.**
|
|
2172
|
+
TypeScript's `latest` is 7.x, whose npm package is the native port: the main
|
|
2173
|
+
export is `{ version, versionMajorMinor }` and the compiler API moved behind
|
|
2174
|
+
`typescript/unstable/ast*`. So a peer range has two spellings and both are worse
|
|
2175
|
+
than bundling. Admit 7 and the peer is satisfiable by a package with no
|
|
2176
|
+
`createSourceFile` at all — the dependency declaration would state a requirement
|
|
2177
|
+
it cannot check, and the failure lands as a crash inside the reader that decides a
|
|
2178
|
+
security property. Exclude 7 and every adopter on the current TypeScript release
|
|
2179
|
+
gets a peer warning telling them to install an older compiler *explicitly*, in
|
|
2180
|
+
service of a tool that could have carried its own — which is the ceiling-hurting-
|
|
2181
|
+
adopters failure `msw` reached the no-upper-bound answer from, arriving here from
|
|
2182
|
+
the opposite direction.
|
|
2183
|
+
|
|
2184
|
+
The asymmetry with `vite`/`vitest` is the whole of it, and it is not about
|
|
2185
|
+
stability: those two are peers because Attest must run *inside the adopter's own
|
|
2186
|
+
runner* — a second copy would be a second test run, which is not a thing that
|
|
2187
|
+
works. Nothing about reading an AST needs the adopter's compiler; the reader wants
|
|
2188
|
+
*a* parser for TypeScript syntax, not *theirs*. A bundled one is therefore the
|
|
2189
|
+
only shape that can guarantee the API exists, and the duplicate install on a
|
|
2190
|
+
TypeScript 7 project is a real cost paid knowingly, not an oversight — one extra
|
|
2191
|
+
`node_modules` entry, against a tool that otherwise cannot start.
|
|
2192
|
+
|
|
2193
|
+
Reconsider only if the compiler API becomes something a peer range can honestly
|
|
2194
|
+
describe again: `typescript/unstable/ast` reaching stability under a name that is
|
|
2195
|
+
not `unstable`, with a version range across which the AST shape is contractual.
|
|
2196
|
+
Until then the ceiling is the honest statement, and the range is run at both ends
|
|
2197
|
+
rather than asserted. *Not a reason to revisit:* an adopter reporting two copies of
|
|
2198
|
+
`typescript` in their tree. That is the decision working, and the README says so
|
|
2199
|
+
under Prerequisites.
|
|
2200
|
+
|
|
2201
|
+
### A requirement's id prefix must match its registry file's name
|
|
2202
|
+
|
|
2203
|
+
The other half of the rule that shipped as `duplicate-prefix` (see `[0.4.0]`),
|
|
2204
|
+
and the half that does not pay for itself. What killed it is that the corpus
|
|
2205
|
+
already conforms: every fixture registry matches (`auth.reqs.ts` → `AUTH-`,
|
|
2206
|
+
`core.reqs.ts` → `CORE-`), so the rule would detect nothing anywhere it was
|
|
2207
|
+
measured except one file — `self/requirements/attest.reqs.ts`, which holds
|
|
2208
|
+
`ATX-*`. Its first act would therefore be to rename this repo's own registry and
|
|
2209
|
+
every `self/*.spec.ts` that imports it, and its first act in an adopter's
|
|
2210
|
+
repository would be the same thing to theirs. That is rejecting input valid
|
|
2211
|
+
today, a minor bump under this file's Versioning rule, in exchange for making
|
|
2212
|
+
the prefix *guessable from the path* — which matters only to a reader already
|
|
2213
|
+
looking at the file, and who could have read the ids in it. The collision rule
|
|
2214
|
+
delivers what the gap is actually about, which is two owners of one space, and
|
|
2215
|
+
it delivers it without a rename because it fires on a relationship between files
|
|
2216
|
+
rather than on a spelling. Reconsider only with evidence that a reader or a tool
|
|
2217
|
+
needs the prefix before opening the file — a naming *convention* stays the right
|
|
2218
|
+
shape for this, and the convention is what `init` and the docs already teach.
|
|
2219
|
+
What is not a reason to revisit it: the objection that stalled the collision
|
|
2220
|
+
rule while both were queued together — that it guards a convention nothing had
|
|
2221
|
+
yet violated — was answered by shipping it, since a rule that costs no migration
|
|
2222
|
+
and no concept is worth having before the first collision rather than after it.
|
|
2223
|
+
That argument does not transfer here, because this rule's cost is a migration by
|
|
2224
|
+
construction.
|
|
2225
|
+
|
|
2226
|
+
### Nothing resists a bloated requirement, and nothing resists a duplicated one
|
|
2227
|
+
|
|
2228
|
+
These two gaps in the intent layer are themselves described in design §11, which
|
|
2229
|
+
is where a permanent property of the design belongs. What is kept here is the
|
|
2230
|
+
list of mechanisms tried and what killed each, so the same three are not
|
|
2231
|
+
proposed again without new evidence. All three were scored against every
|
|
2232
|
+
registry this repo ships *before* any was written into the engine — the order
|
|
2233
|
+
`divergent-param` was originally designed in the wrong way round. The
|
|
2234
|
+
measurement is runnable in `tests/intent-rule-candidates.spec.ts` and fails when
|
|
2235
|
+
a requirement is added without a hand-labelled obligation count, because the
|
|
2236
|
+
reach figure once quoted here went stale exactly that way. The guard makes the
|
|
2237
|
+
*suite* re-count; it cannot make this file re-read, so every figure below is a
|
|
2238
|
+
quotation of that snapshot and is only ever as fresh as the last edit here —
|
|
2239
|
+
read the snapshot when the number is what matters. *`compound-requirement`
|
|
2240
|
+
(WARNING) — rejected.* More than one RFC-2119 keyword in one `statement` is
|
|
2241
|
+
objectively more than one obligation. Measured (figures re-read from the suite,
|
|
2242
|
+
which is the only place they are not prose): of the 8 requirements in the corpus
|
|
2243
|
+
carrying more than one, it flags 3 (`ATX-10`, `ATX-21`, `ATX-26`) and misses
|
|
2244
|
+
`ATX-5`, `ATX-9`, `ATX-13`, `ATX-14`, `ATX-17` — every one that packs clauses
|
|
2245
|
+
under a single keyword, which is the form the coverage incentive actually
|
|
2246
|
+
rewards. A wider variant (keyword plus a coordinating `and`/`while`/`;`) reaches
|
|
2247
|
+
7 of 8, at 3 wrong hits (`ATX-6`, `ATX-22`, `ATX-23`) rather than the 1 recorded
|
|
2248
|
+
when it was first scored — so reach is not the deciding argument, and the
|
|
2249
|
+
precision it trades away grows with the registry. This is: both variants are
|
|
2250
|
+
silenced by deleting the word that triggered them, and neither can tell that
|
|
2251
|
+
deletion from a real split. `SHALL do A and MUST do B` clears the warning as
|
|
2252
|
+
`SHALL do A and do B` — same two obligations, one fewer normative keyword. A
|
|
2253
|
+
rule whose cheapest fix degrades the artifact it protects does not ship.
|
|
2254
|
+
*`divergent-param` (WARNING) — rejected.* One param name declared with different
|
|
2255
|
+
values in two requirements sharing an id prefix. Measured: it fires twice on
|
|
2256
|
+
this repo and both firings are wrong — `idleTimeoutMin` (`15` in `ATX-3`, `30`
|
|
2257
|
+
in `ATX-10`), unrelated illustrative fixtures, and `code` (`registry-not-static`
|
|
2258
|
+
in `ATX-17`, `empty-spec` in `ATX-18`, `rationale-placeholder` in `ATX-21`),
|
|
2259
|
+
diagnostics that could not possibly agree. The second arrived on its own when
|
|
2260
|
+
`ATX-18` was added and grew a third member on its own again when `ATX-21` was,
|
|
2261
|
+
then `ATX-25`, then `ATX-26`, which is the more damning half: a generic param
|
|
2262
|
+
name collides more often as a registry grows, so the false-positive rate rises
|
|
2263
|
+
with adoption — the opposite of what a shippable rule does. `ATX-26` added the
|
|
2264
|
+
sharper version of the same point: it names the *same* diagnostic as `ATX-17`,
|
|
2265
|
+
so the two now agree by coincidence and `ATX-17` drops out of the set the rule
|
|
2266
|
+
reports — a rule that reads agreement as safety falls silent on a pair it had
|
|
2267
|
+
been flagging, for a reason that has nothing to do with either requirement. **A
|
|
2268
|
+
second param name now collides, and that is what closes the last defence of the
|
|
2269
|
+
rule.** `file` names a path Attest writes — the agent skill in `ATX-27`, a
|
|
2270
|
+
change's first-run record in `ATX-30` — two unrelated constants, a third wrong
|
|
2271
|
+
firing. Until it appeared, every false positive had been on the single name
|
|
2272
|
+
`code`, which left "pick a less generic param name" open as a repo-side
|
|
2273
|
+
workaround and therefore left the rule arguably salvageable. It is not the word:
|
|
2274
|
+
two requirements owning unrelated constants of the same *kind* is the structure,
|
|
2275
|
+
and any registry large enough to be worth linting has it. Still zero true
|
|
2276
|
+
positives, now across a 34-entry corpus (the `corpus` figure in the suite's
|
|
2277
|
+
snapshot, which counts a fixture id once per registry file that declares it).
|
|
2278
|
+
The escape hatch it was blocked on has since been designed and does not rescue
|
|
2279
|
+
it: with no config file the only workable shape is an additive
|
|
2280
|
+
`independentParams?: string[]` on the requirement (a CLI flag is per-invocation
|
|
2281
|
+
for what is a permanent property of two requirements; changing the `params`
|
|
2282
|
+
shape rejects every existing registry). Viable, but it is permanent schema
|
|
2283
|
+
surface for a rule with no demonstrated true positive, and in practice it would
|
|
2284
|
+
be written *after* the warning fires — a one-line silencer. *Countable
|
|
2285
|
+
obligations (breaking) — rejected.* An `obligations: string[]` per requirement,
|
|
2286
|
+
with coverage counted per obligation, removing the lumping incentive at the
|
|
2287
|
+
source instead of detecting it afterwards. Rejected because the **id is already
|
|
2288
|
+
that unit**: splitting into two ids costs two scenarios and buys the same
|
|
2289
|
+
incentive with no new concept (`ATX-11`/`ATX-12` are exactly that split). It
|
|
2290
|
+
would touch the parser, coverage, `render`, `SPEC.md`, delta apply and the
|
|
2291
|
+
`--json` schema, and it relocates the incentive rather than removing it —
|
|
2292
|
+
nothing can force the array to be complete. *Not considered:* similarity
|
|
2293
|
+
matching between statements (token overlap, embeddings). That reintroduces the
|
|
2294
|
+
fuzzy comparison design §0 exists to remove; real semantic duplication needs
|
|
2295
|
+
judgement and belongs to human review at the propose stage.
|
|
2296
|
+
|
|
2297
|
+
### A freshness gate on the file `attest init` writes (`init --check`)
|
|
2298
|
+
|
|
2299
|
+
`render --check` is the obvious precedent and is the opposite case. A rendering
|
|
2300
|
+
is a function of the *user's registry*, so its staleness means the user moved
|
|
2301
|
+
intent without regenerating — a real signal, with the fix in their hands. The
|
|
2302
|
+
skill is a function of the *installed version*, so a gate on it turns red on a
|
|
2303
|
+
change the user did not make, and is cleared by a commit containing no decision.
|
|
2304
|
+
That is a chore wearing a diagnostic's clothes, and a gate that cannot be about
|
|
2305
|
+
anything the reader did is worse than no gate — the same standard 0.1.6 set when
|
|
2306
|
+
it gave `render` no formatting options at all. An earlier draft of `attest init`
|
|
2307
|
+
spent a second command, this gate and a second self-requirement on it. What that
|
|
2308
|
+
would have bought is immunity to one honest and small exposure: an old file
|
|
2309
|
+
describes an old workflow to an agent that trusts it. The backstop is that every
|
|
2310
|
+
mistake it can cause is already a diagnostic with a fix hint — a registry
|
|
2311
|
+
written the old way is `registry-not-static` — so the agent corrects itself from
|
|
2312
|
+
the report, and `init` is idempotent, which makes re-running it the entire
|
|
2313
|
+
update mechanism. Recorded here rather than left in the 0.3.0 entry that shipped
|
|
2314
|
+
`init`, because the queue above depends on it: a gate is the first thing
|
|
2315
|
+
proposed each time a new agent target is considered, and the answer must not
|
|
2316
|
+
need re-deciding per target.
|
|
2317
|
+
|
|
2318
|
+
### A marked block in the adopter's `AGENTS.md` / `CLAUDE.md`
|
|
2319
|
+
|
|
2320
|
+
The cross-tool convention Codex, Cursor and Copilot read, and the reason it
|
|
2321
|
+
looks like the obvious next target after the skill is that it reaches the most
|
|
2322
|
+
agents per unit of work. Rejected on three counts, none of which is about
|
|
2323
|
+
effort. **It is the only file `init` would write that Attest does not own.**
|
|
2324
|
+
`.claude/skills/attest/SKILL.md` exists because Attest exists; `AGENTS.md`
|
|
2325
|
+
exists because the *project* does, and what is in it is prose its maintainer
|
|
2326
|
+
wrote and curated. Writing a block into it means an idempotent read-modify-write
|
|
2327
|
+
on someone else's document — find the markers, preserve everything outside them,
|
|
2328
|
+
and then handle the markers being absent, duplicated, hand-edited, moved, the
|
|
2329
|
+
file missing, the file being `CLAUDE.md` instead, or both existing. That is a
|
|
2330
|
+
merge tool, and each of its failure modes is destructive on a file the user
|
|
2331
|
+
cannot regenerate. **It would remove the property that makes `init` safe to
|
|
2332
|
+
automate.** `skillContent()` takes no argument — the content is a pure function
|
|
2333
|
+
of nothing at all — so `init` is one atomic write and its idempotence holds *by
|
|
2334
|
+
construction*, which is what lets "re-run it after upgrading" be the whole
|
|
2335
|
+
update mechanism and be safe in a script. A marked block makes the output a
|
|
2336
|
+
function of the file already there, demoting idempotence from a property of the
|
|
2337
|
+
design to a behaviour that needs test coverage, on the one path where being
|
|
2338
|
+
wrong destroys input. **It is a worse carrier for the same text.** An
|
|
2339
|
+
`AGENTS.md` block is unconditionally resident in the agent's context, where a
|
|
2340
|
+
skill body is loaded when its description matches — so every unrelated request
|
|
2341
|
+
in that repo pays for a workflow it will not use. It was never a port of the
|
|
2342
|
+
skill; it is a second, weaker capability wearing the same words. The interaction
|
|
2343
|
+
with the entry above is what settles it: a user-edited block cannot be replaced
|
|
2344
|
+
on upgrade without discarding the edit, so this shape needs either the freshness
|
|
2345
|
+
gate rejected above or a three-way merge. *What replaces it*, for an adopter
|
|
2346
|
+
whose agent reads `AGENTS.md`: one line they add themselves, pointing at the
|
|
2347
|
+
skill file. A pointer is cheap enough to be resident, it is their edit in their
|
|
2348
|
+
file, and it cannot go stale — the file it names is the one `init` keeps
|
|
2349
|
+
current.
|
|
2350
|
+
|
|
2351
|
+
### A Codex target for `attest init`
|
|
2352
|
+
|
|
2353
|
+
Evaluated when `init` grew `--target` and shipped for Claude Code, Cursor and
|
|
2354
|
+
Copilot; rejected because the tool offers nothing to write, not because of what
|
|
2355
|
+
writing it would cost. Codex's project-scoped instruction surface is
|
|
2356
|
+
`AGENTS.md`, which the entry above rejects permanently, and its custom prompts
|
|
2357
|
+
live in `~/.codex/prompts` — per machine, outside the project root `init` is
|
|
2358
|
+
pointed at, and not a thing a project can commit or a CI checkout can carry.
|
|
2359
|
+
That leaves no path that is both **Attest's own** and **read by the tool**, so
|
|
2360
|
+
the two rules a target has to satisfy are not merely unmet here, they are
|
|
2361
|
+
unmeetable with what exists: any Codex-shaped implementation would be either the
|
|
2362
|
+
rejected merge or a file nothing reads, and the second is worse, because it
|
|
2363
|
+
looks like coverage. Filed here rather than queued as a candidate, since nothing
|
|
2364
|
+
about the effort or the evidence is what is holding it — the pointer line above
|
|
2365
|
+
is the whole answer for a Codex user, and this reopens only if Codex grows a
|
|
2366
|
+
project-scoped path of its own.
|
|
2367
|
+
|
|
2368
|
+
### A committed snapshot of the params
|
|
2369
|
+
|
|
2370
|
+
Design §11 records the measurement this answers: editing a param (`'main'` →
|
|
2371
|
+
`'trunk'`, one entry dropped from a nine-element list) leaves `attest check` at
|
|
2372
|
+
`✓ No issues`, because the single source makes a value impossible to *diverge*,
|
|
2373
|
+
which is not the claim that it is flagged when it *moves*. The candidate was the
|
|
2374
|
+
shape `render --check` already proves — a `params.lock` beside the registry, or
|
|
2375
|
+
`check --params-check` — and the mechanism would have worked. *What rejected it:
|
|
2376
|
+
the precedent is the objection.* §9 states when a freshness gate earns its
|
|
2377
|
+
place, and a rendering earns it because it is a function of the *user's
|
|
2378
|
+
registry*, so staleness means the user moved intent without regenerating — a
|
|
2379
|
+
real signal with the fix in their hands. A params snapshot is a function of the
|
|
2380
|
+
registry too, but the population it fires on is different: **every legitimate
|
|
2381
|
+
param edit turns it red, and the fix is a commit containing no decision.** That
|
|
2382
|
+
is precisely the chore §9 declined to ship for `init`, and it is worse here
|
|
2383
|
+
because of frequency — intent is rendered rarely, while params move whenever a
|
|
2384
|
+
value moves, which is the entire reason they are params. The gate would spend
|
|
2385
|
+
its whole life red about correct edits, and a gate that is usually wrong about
|
|
2386
|
+
staleness is the failure mode §9 names as worse than no gate. *What the gap
|
|
2387
|
+
keeps instead.* §6's mechanism 3 already exists to make this exact absence
|
|
2388
|
+
visible: `possible-drift` fires when a requirement owns params that no covering
|
|
2389
|
+
scenario reads, which is the only condition under which a moved param goes
|
|
2390
|
+
unnoticed. The mechanism that guards a param is a scenario reading it, not a
|
|
2391
|
+
file recording it. This reopens only on an adoption report where a param moved,
|
|
2392
|
+
no scenario caught it, and `possible-drift` was **not** already printing —
|
|
2393
|
+
because if it was printing, what failed was reading the report, and a second
|
|
2394
|
+
gate does not fix that.
|
|
2395
|
+
|
|
2396
|
+
### Grouping `render`'s output by prefix
|
|
2397
|
+
|
|
2398
|
+
The *detection* half of this is already rejected above (every automatic
|
|
2399
|
+
candidate either saw almost nothing or was a similarity matcher, which is what
|
|
2400
|
+
§0 exists to remove), leaving human review at propose (§7) as the answer — and
|
|
2401
|
+
the idea was to help that review by putting requirements about one subject side
|
|
2402
|
+
by side in the one document a human reads end to end. *What rejected it: the
|
|
2403
|
+
ordering already ships, and the adjacency it buys is not where the gap is.*
|
|
2404
|
+
`compareIds` (`render.ts`) has ordered the document by prefix first and number
|
|
2405
|
+
second since `render` shipped, so same-prefix requirements are **already**
|
|
2406
|
+
adjacent; the candidate was never an ordering change, only a heading over an
|
|
2407
|
+
ordering that already holds. And §11's own example of the gap is `AUTH-3`
|
|
2408
|
+
expiring a session against `SESS-7` saying sessions never expire — a
|
|
2409
|
+
contradiction **across** prefixes, which prefix grouping pushes further apart
|
|
2410
|
+
rather than closer. What remains is a reader aid for same-prefix contradictions,
|
|
2411
|
+
which are the pairs already adjacent and therefore the ones review is least
|
|
2412
|
+
likely to miss, bought at the price of changing the bytes of every committed
|
|
2413
|
+
rendering and turning `render --check` red across every adopting repo on upgrade
|
|
2414
|
+
— for a change the user did not make. *The gap itself stays open* and stays
|
|
2415
|
+
where it belongs, in design §11: nothing resists a duplicated or contradictory
|
|
2416
|
+
requirement, and human review at propose is still the whole answer. What is
|
|
2417
|
+
rejected is grouping as a way to assist it, and this reopens only on a
|
|
2418
|
+
contradiction that grouping would have caught.
|
|
2419
|
+
|
|
2420
|
+
[Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.4.3...main
|
|
2421
|
+
[0.4.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.3
|
|
2422
|
+
[0.4.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.2
|
|
1666
2423
|
[0.4.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.1
|
|
1667
2424
|
[0.4.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.0
|
|
1668
2425
|
[0.3.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.3.0
|