@am_shork/attest 0.1.7 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,118 +5,378 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## Versioning
9
+
10
+ Under 0.x SemVer a breaking change bumps the **minor**. "Breaking" means it
11
+ changes the exit code of an existing valid setup, rejects previously-valid
12
+ input, removes/renames a public API or `--json` field, or changes a default
13
+ runtime behavior an existing invocation relies on — diagnostic message text is
14
+ not API.
15
+
8
16
  ## [Unreleased]
9
17
 
10
- ### Under consideration (non-breaking, not implemented)
11
-
12
- - **`attest check` is described as static validation, but it executes every
13
- `*.reqs.ts` it finds.** Reading a registry means evaluating the module, via
14
- Vite, anywhere under the project root. That is the same threat model as
15
- `vitest run` except `check` is advertised as "static structural validation
16
- (fast CI pre-check)", which is exactly the wording that gets it scheduled
17
- ahead of, or outside, whatever sandbox the test run is given. On a fork MR,
18
- a `*.reqs.ts` with a top-level `fetch` is enough.
19
- *The candidate fix, and why it is more than a patch:* read the registry from
20
- the AST instead the compiler API is already the parser layer's tool — for
21
- `check`, `cover` and `render`, keeping the loader for `verify` and `archive`,
22
- which run the suite anyway and so gain nothing from avoiding evaluation.
23
- This makes "a registry is a literal" a design contract rather than a
24
- limitation: `params: { maxMb: MAX_MB }` imported from application code
25
- already violates the single-source rule the framework exists to enforce,
26
- since the value's real owner is elsewhere. It would also turn `render`'s
27
- purity from a convention into something structurally guaranteed — today a
28
- `Date.now()` in a registry makes `--check` fail forever.
29
- *What blocks it:* a non-literal registry must become an ERROR, never a silent
30
- fallback to evaluation, or the guarantee is worth nothing — so this is a
31
- breaking change for repos that compute their registry, and needs an explicit
32
- opt-out (`--eval`) rather than a warning period, since a warning period
33
- spends the whole transition still evaluating. The real risk is extractor
34
- fidelity: `-1` is a unary expression, `1_000` is not `Number(text)`,
35
- `as const` needs unwrapping, and any of those misread would break a repo the
36
- change is supposed to leave untouched. Differential testing against the
37
- current loader over every fixture is the prerequisite.
18
+ ### Under consideration
19
+
20
+ Queued from the skills-manager adoption report (0.2.0, 41 requirements / 106
21
+ scenarios, plain-TS). **Nothing below is built.** An entry here is a decision to
22
+ ship, not a claim that it ships; each moves into `Added` / `Changed` / `Fixed`
23
+ when it does, and this heading is deliberately not one of those, so no reader
24
+ can take the section for a release note. The report's other proposals were
25
+ declined and are not recorded.
26
+
27
+ - **A report of N issues sharing one `code` should say so once.** The 0.1.1 →
28
+ 0.2.0 upgrade printed six `registry-not-static` ERRORs in a row one per
29
+ registry file, every one caused by the same prose written as `'…' + '…'` and
30
+ left the reader to work out that they were one cause and one mechanical fix.
31
+ A summary line will name the shared cause when a code repeats; the per-file
32
+ lines stay exactly as they are, because they are what makes a diagnostic
33
+ clickable. Human output only a summary that became an `Issue` would carry a
34
+ `code` into `--json`, `summary` and `hasError`, which is a report-contract
35
+ change and not what this is.
36
+
37
+ ## [0.2.1] - 2026-07-26
38
+
39
+ The type layer stopped charging for the one behaviour the tool rewards, and
40
+ three limits an adoption had to find on its own are now written down.
41
+
42
+ ### Added
43
+
44
+ - **Reading a param cost a cast, and the cast was charged on the one behaviour
45
+ the tool rewards.** `defineRequirements` returned `Registry`, so every param
46
+ was the full `string | number | boolean | Array<…>` union and
47
+ `reqs[id].params.x` had to be converged at each read site — the reporting
48
+ adopter wrote `str()` / `strList()` helpers to do it. `possible-drift` goes
49
+ quiet precisely when a scenario reads `.params`, so the type layer was taxing
50
+ the behaviour the anti-drift heuristic exists to reward. It is now generic
51
+ over its argument with a `const` type parameter: `params.idleTimeoutMin` is
52
+ `30`, `params.vendorDirs` is an array of the strings written there, and a
53
+ declared ID is known to exist, so `reqs['AUTH-3']` needs no `!` under
54
+ `noUncheckedIndexedAccess`. Removing the casts from Attest's own `self/`
55
+ specs closed 21 latent type errors there. No runtime behaviour and no `--json`
56
+ change: since 0.2.0 the analysing commands read the registry from the AST, so
57
+ this type has no bearing on anything the engine sees.
58
+ **A widening, deliberately.** A `const` type parameter would infer array
59
+ literals as tuples — `readonly` ones under a constraint that admits them —
60
+ and neither is assignable to the mutable `Array<number | string | boolean>`
61
+ the schema parses to, which would have broken `const reqs: Registry =
62
+ defineRequirements({…})` and every `const xs: string[] = req.params.foo`; the
63
+ mapped type collapses them back to `E[]`. The result is still intersected with
64
+ `Registry`, so its index signature survives and `reqs[id]` with an `id: string`
65
+ — a shared test helper — still resolves, as does a param the registry does not
66
+ declare. **What that costs**, against the intent recorded when this was queued:
67
+ a mistyped literal `reqs['TYPO-9']` is *not* rejected outright. Rejecting an
68
+ unknown key and accepting a `string` one are the same question asked of one
69
+ index signature, and it cannot answer both ways; the fallback is what keeps
70
+ this non-breaking, so it wins. The weaker half is real — an unknown ID misses
71
+ the declared keys and resolves through the index signature, so under
72
+ `noUncheckedIndexedAccess` reading through it is an error where `reqs['AUTH-3']`
73
+ no longer is — and a wrong ID is an `unknown-req-id` ERROR from `check`
74
+ regardless, which reads registry and specs together. The type layer is the
75
+ second reader here, not the only one.
76
+ - **Three things an adoption had to discover for itself.** Documentation only —
77
+ no behaviour changed, and each is a property the design deliberately has.
78
+ **`check` has no detection power over a params change**: editing a value
79
+ (`'main'` → `'trunk'`, an entry dropped from a nine-element list) leaves it at
80
+ `✓ No issues`, because `check` executes nothing and nothing became unbound or
81
+ uncovered. The single source makes the value impossible to *diverge*, which is
82
+ a different and stronger claim than announcing that it *moved* — but "params
83
+ are the single source for values that change" reads easily as "`check` guards
84
+ them", and what actually guards them is a scenario asserting on the value it
85
+ read. Now stated where params are introduced (README, design §2, the prompt
86
+ guide) and recorded as a limit in design §11, next to the others.
87
+ **`verify` does not read your `vitest.config.ts`**: the child run starts
88
+ isolated, so a verdict never depends on ambient configuration — which also
89
+ means framework code gets no transforms, no DOM and no aliases until
90
+ `--vitest-config` points it at one. The flag was documented in the README; the
91
+ trade-off behind it now sits in design §5.4, where the run scope is described,
92
+ because that is where a reader meets the isolation.
93
+ **A template literal keeps its whitespace**: one with no substitution reads
94
+ statically and is accepted, but preserves every newline and space of
95
+ indentation, and `render` copies the statement into the Markdown as written —
96
+ so editor-friendly wrapping lands in the generated document. This is the
97
+ reason registry prose is written as one long single-line string, which was
98
+ folklore until now.
99
+ - **A codemod for the 0.2.0 `registry-not-static` migration.** Collapsing
100
+ multi-line `'…' + '…'` prose into one literal is mechanical and was done by
101
+ hand across 21 requirements in the first reported adoption. The 0.2.0 entry
102
+ now carries a compiler-API script that does it, tested on the shapes that make
103
+ it awkward: mixed quote styles, an apostrophe inside the prose, a template
104
+ literal in the chain, and a numeric `1 + 2` it must leave alone.
105
+ - **Type tests, and a suite that runs them.** The repo had none, and this change
106
+ is entirely type-level: no runtime test and no `verify self` run could observe
107
+ a regression in it. `tests/registry-types.spec-d.ts` pins the narrowing, both
108
+ compatibility promises and the trade-off above; `vitest run` now typechecks in
109
+ the same pass (`tsconfig.typecheck.json`, which unlike the build project covers
110
+ `tests/` and `self/` — neither had ever been through a compiler). `DefinedRegistry`
111
+ is exported for consumers that need to name the type.
112
+
113
+ - **A `{placeholder}` in a `rationale` reached the document unread.** `render`
114
+ interpolates `statement` and nothing else, and `unbound-param` only scans
115
+ statements, so braces written into a rationale passed through every check and
116
+ landed verbatim in the generated Markdown. The mistyped placeholder is the
117
+ obvious case; the one that misleads is the **correctly spelled** one, where
118
+ the author declared the param and simply picked the wrong field — nothing was
119
+ unbound, so nothing fired, and the document reviewers and audit read showed a
120
+ placeholder where a value was meant. A new `rationale-placeholder` WARNING now
121
+ reports any `{name}` in a rationale, bound or not.
122
+ Deliberately not the two alternatives. Extending `unbound-param` to rationales
123
+ would cover only the undeclared half — the half that misleads least. And
124
+ making `render` interpolate rationales would change the bytes of every
125
+ committed rendering, turning `render --check` red on an upgrade where the user
126
+ moved no intent, which this file already treats as breaking for that surface.
127
+ **A placeholder inside a Markdown code span is not reported.** That exclusion
128
+ came from dogfooding: the first run against Attest's own registry flagged
129
+ ATX-14, whose rationale explains that `` `{toString}` `` *looks* bound — prose
130
+ about a placeholder, not a misfiled one, and the only way to write a
131
+ requirement whose subject is the syntax itself. A rationale is Markdown and
132
+ `render` leaves a code span exactly as written, so a backticked token is
133
+ already marked as quoted.
134
+ WARNING sits below the `hasError` predicate that `ok`, `--json` and the exit
135
+ code all read from, so an existing green registry with braces in a rationale
136
+ gains a line of output and keeps its verdict. A new `code` on the existing
137
+ `Issue` shape is additive: `schemaVersion` stays `1`.
138
+
139
+ ## [0.2.0] - 2026-07-26
140
+
141
+ Three things that could each end a run with the wrong verdict: a registry read
142
+ by executing it, a green that attested nothing, and a red about code Attest was
143
+ never pointed at.
144
+
145
+ ### Security
146
+
147
+ - **`attest check` described itself as static validation while executing every
148
+ `*.reqs.ts` it found.** Reading a registry meant evaluating the module, via
149
+ Vite, anywhere under the project root — the same threat model as
150
+ `vitest run`, behind wording ("static structural validation (fast CI
151
+ pre-check)") that is exactly what gets a command scheduled ahead of, or
152
+ outside, whatever sandbox the test run is given. On a fork MR, a `*.reqs.ts`
153
+ with a top-level `fetch` was enough to reach CI secrets.
154
+ `check`, `cover` and `render` now read the registry from the **AST** — the
155
+ compiler API was already the parser layer's tool — and run no line of the
156
+ project. `verify` and `archive` still evaluate: they run the whole suite
157
+ anyway, so declining to run one more module would buy them nothing.
158
+ The extracted object literal is handed to the same `RegistrySchema`
159
+ `defineRequirements` calls, so a literal registry keeps its behaviour, its
160
+ codes and its diagnostic wording unchanged. That equivalence is not an
161
+ assertion: a differential suite reads every `*.reqs.ts` in the repo, and every
162
+ literal form that could be misread (`-1`, `1_000`, `0x10`, `30.0`, `1e3`, a
163
+ template literal with no substitution, `as const`, `satisfies`), through both
164
+ readers and requires the two registries to be deeply equal — down to key
165
+ order.
166
+ It also turns `render`'s purity from a convention into a structural
167
+ guarantee: a registry can no longer contain a `Date.now()` that makes
168
+ `render --check` fail forever.
169
+
170
+ ### Changed
171
+
172
+ - **BREAKING: a registry must be a literal.** A registry `check`, `cover` or
173
+ `render` cannot read from the source is a new `registry-not-static` ERROR,
174
+ pointing at the line that stopped it. There is deliberately no fallback to
175
+ evaluation — a reader that quietly runs the file when extraction fails
176
+ guarantees nothing — and deliberately no warning period, which would spend
177
+ the whole transition still evaluating. `--eval` is the named way back, on all
178
+ three commands, and restores the old behaviour exactly.
179
+ This makes "a registry is a literal" a design contract rather than a tooling
180
+ limitation: `params: { maxMb: MAX_MB }` imported from application code already
181
+ violates the single-source rule the framework exists to enforce, because the
182
+ value's real owner is elsewhere. Affected repos have two migrations, and
183
+ inlining the value is the one that fixes the underlying problem.
184
+ A registry file may still be written as `const reqs = defineRequirements({…});
185
+ export default reqs;`, and may import `defineRequirements` under any alias or
186
+ through a namespace.
187
+ **Migrating concatenated prose.** The common shape is a statement or rationale
188
+ wrapped across lines as `'…' + '…'`, which is a computation and so no longer
189
+ reads. Collapsing it is purely mechanical — 21 requirements by hand in the
190
+ first reported adoption — so it is worth a codemod. Save this at the project
191
+ root and run it over the registry files; it rewrites only concatenations whose
192
+ every operand is a string (or substitution-free template) literal, leaving
193
+ `1 + 2` and anything with an identifier in it alone:
194
+
195
+ ```js
196
+ // flatten-reqs.mjs — node flatten-reqs.mjs requirements/*.reqs.ts
197
+ import ts from 'typescript';
198
+ import { readFileSync, writeFileSync } from 'node:fs';
199
+
200
+ const flat = (n) =>
201
+ ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.PlusToken
202
+ ? [flat(n.left), flat(n.right)].every(Boolean) && flat(n.left) + flat(n.right)
203
+ : (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) && n.text;
204
+
205
+ for (const file of process.argv.slice(2)) {
206
+ const src = readFileSync(file, 'utf8');
207
+ const sf = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true);
208
+ const edits = [];
209
+ const visit = (n) => {
210
+ const text = flat(n);
211
+ // the whole chain is one edit, so do not descend into it
212
+ if (text !== false && ts.isBinaryExpression(n)) return void edits.push([n.getStart(sf), n.getEnd(), text]);
213
+ n.forEachChild(visit);
214
+ };
215
+ visit(sf);
216
+ let out = src;
217
+ for (const [start, end, text] of edits.reverse())
218
+ out = `${out.slice(0, start)}${JSON.stringify(text)}${out.slice(end)}`;
219
+ if (edits.length) writeFileSync(file, out), console.log(`${file}: ${edits.length} collapsed`);
220
+ }
221
+ ```
222
+
223
+ It emits double-quoted strings (`JSON.stringify` does the escaping, so prose
224
+ containing an apostrophe survives); run the formatter afterwards. Write the
225
+ result as one long line rather than re-wrapping it — a template literal reads
226
+ statically but keeps its newlines and indentation, and `render` copies the
227
+ statement into the Markdown exactly as written.
228
+ The `--json` envelope is unchanged: `registry-not-static` is a new `code` on
229
+ the existing `Issue` shape, which is additive, so `schemaVersion` stays `1`.
230
+ `vite` remains a peer dependency — `verify` and `archive` run the suite through
231
+ it — but `check`, `cover` and `render` no longer start a Vite server at all.
232
+ - **BREAKING: a vacuous green stops passing.** `attest verify` on a root with no
233
+ requirements printed `✓ No issues.` and exited `0`, because `passed` is "no
234
+ failing tests" and a run with nothing in it has none. It is now an `empty-spec`
235
+ ERROR, so `ok` becomes false and the exit code follows the existing `hasError`
236
+ path. The empty directory is the harmless version; the one that matters is the
237
+ repo whose registry stopped being found — a renamed folder, a moved root — where
238
+ the tests still run, still pass, and the report is indistinguishable from a real
239
+ green. Deliberately narrower than "no scenario executed": with at least one
240
+ requirement, a missing scenario is already `uncovered-requirement` and a declared
241
+ scenario that never ran is already `declared-not-run`, so firing on those would
242
+ only double-report a run that is red anyway. *Breaking:* a root that attests
243
+ nothing flips from exit 0 to exit 1 — which is the point, since it attested
244
+ nothing.
245
+ - **BREAKING: a run executes only the spec files that declare a `requirement()`.**
246
+ `verify` swept every `*.spec.ts` under the root into a child run with no
247
+ aliases, no DOM and no plugins, so pointing it at the root of a repo that
248
+ already had a suite produced a guaranteed red about code Attest was never asked
249
+ to attest. That was the first thing a mid-project adopter saw, and it was not a
250
+ finding about their intent layer at all. The run scope is now derived from the
251
+ static plan, which already records which files declare intent. `archive` gets
252
+ the same scope, for the same reason: a change must not fail its gate because an
253
+ unrelated suite shares its root. *Breaking:* a repo relying on the sweep to run
254
+ requirement-less specs sees a different set execute — and `attest` was never the
255
+ right thing to run them with. Unchanged for `check`, `cover` and `render`, which
256
+ only ever counted `requirement()`/`scenario()` calls and so never had the bug.
257
+
258
+ ### Added
259
+
260
+ - **`verify` reports what it looked at.** The human summary leads with
261
+ `— 20 requirements / 41 scenarios, 0 error, …` and is preceded by the run
262
+ scope (`Running 12 spec files`, plus how many were located and skipped for
263
+ declaring no `requirement()`). `--json` carries the same four numbers under
264
+ `counts`. This is the companion the change above needs: scoping the run means a
265
+ file whose `requirement()` the parser fails to see is now dropped from the run
266
+ rather than failing in it, and the gap between "located" and "in scope" is where
267
+ that would show. It is also a second line of defence against a vacuous green — a
268
+ report that says `0 requirements` cannot be misread as a full pass. `counts` is
269
+ a new optional field on an existing envelope, so `schemaVersion` stays `1`.
270
+
271
+ ### Fixed
272
+
273
+ - **The design document described three anti-drift mechanisms; two exist.**
274
+ §6's mechanism 2, "red/green expectation tracking" — a new requirement's
275
+ scenarios must be red while a change is in progress and green at archive time,
276
+ with the framework blocking on a mismatch — was written as shipped behaviour,
277
+ with a strength rating, next to two mechanisms that are real. It was never
278
+ built. What ships is the weaker half: §8's gate requires green at archive time,
279
+ but nothing records or requires the earlier red, so a scenario that was green
280
+ from the start — asserting nothing — passes it. Anyone who budgeted their own
281
+ review against three mechanisms was covered by two. Now marked
282
+ **(not implemented)** inline, with the gap named. Same treatment for
283
+ `/atx:propose` and `/atx:apply` in §9, which are likewise designed and unbuilt;
284
+ the manual path (`changes/<name>/` + `attest archive`) is spelled out beside
285
+ them.
286
+ - **Documentation described an API that does not exist.** §1's architecture
287
+ diagram — the first thing a reader meets — showed `@covers('AUTH-3')` and a
288
+ `covers(id)` back-link, an early spelling of what shipped as
289
+ `requirement(id)` / `scenario(name)`, with coverage reconstructed from the
290
+ `[reqId]` suite name. §5.4 already described the real mechanism, so the
291
+ document contradicted itself. The registry and spec samples also imported from
292
+ a bare `'attest'`, and §10 declared the package as `attest` exporting only
293
+ `.` — the published package is `@am_shork/attest` and has exported `./define`
294
+ since 0.1.2, which is the import path README tells users to write. These are
295
+ samples meant to be copied, and this repo's docs are also fed to agents, so a
296
+ stale name becomes generated code.
297
+ - **Smaller documentation corrections.** §1 called its own three-row table "the
298
+ two core reports"; the Coverage row attributed coverage to runtime collection
299
+ alone, when `check`/`cover` read it statically. §4 listed the SHALL/MUST rule
300
+ under structural validation, where it is enforced by the schema one layer up.
301
+ §5.3's `Issue` sample omitted the `reqId` field the same snippet sets. §7
302
+ cited "§6" for a section of OpenSpec's analysis, not this document's §6.
303
+ The README's `--json` sample claimed `"version": "0.1.2"`, and its
304
+ prerequisites section was headed "Requirements" — a term this project already
305
+ uses for the intent layer.
306
+ - **The feedback template and the GitLab issue template each claimed to be the
307
+ other.** They are not identical, and should not be: `outcome:` is for
308
+ maintainer-authored reports and `verdict:` for outside ones, so the issue form
309
+ is deliberately the outside-reporter subset. Both now say so, the key rule is
310
+ stated where it can be read, and the issue form gained the mid-project
311
+ follow-up its own `adoption: brownfield` field was asking for and never posed.
312
+
313
+ Two checks now hold the parts of this that can be held mechanically, in
314
+ `tests/docs-consistency.spec.ts`: the source cites design sections by number in
315
+ 58 places, and every citation must resolve in both languages, so inserting a
316
+ section can no longer silently repoint them; and any JSON sample in the README
317
+ that identifies itself as attest output must carry the current `version` and
318
+ `schemaVersion`. Neither can catch a section that describes something unbuilt —
319
+ that is what the inline marker is for.
320
+
321
+ ## Considered and rejected
322
+
323
+ Decisions **not** to build something, kept where they can be found before the
324
+ same candidate is proposed again. Nothing here shipped, so nothing here belongs
325
+ to a release — this section is deliberately outside the version history and does
326
+ not move when one is cut.
327
+
38
328
  - **Two gaps in the intent layer: nothing resists a bloated requirement, and
39
- nothing resists a duplicated one.** Recorded as a design note, with the
40
- evidence that currently blocks each candidate fix.
41
- *Against bloat, what already works:* `uncovered-requirement` being an ERROR is
42
- the real mechanism every requirement costs an executable scenario, so "the
43
- system should be maintainable" cannot be written and left standing. That is a
44
- genuine cost function on the intent layer, which prose SDD does not have.
45
- *What doesn't:* coverage is binary (≥1 scenario), so five sharp requirements
46
- cost five mandatory scenarios while one requirement lumping five obligations
47
- together costs one. **The rule as designed rewards lumping**, and the engine
48
- has no notion of "one requirement = one obligation". Attest's own registry
49
- shows it: `ATX-5` carries two obligations under one SHALL, `ATX-9` three
50
- clauses, `ATX-10` two.
51
- *Against duplication and contradiction:* effectively nothing. Every
52
- cross-requirement check in the engine is keyed on the **id** —
53
- `duplicate-requirement` (same id in two registry files), `rename-target-exists`,
54
- and `add-conflict`, which content-compares only when ids collide. Two
55
- requirements under different ids that contradict each other (`AUTH-3` expires a
56
- session after 30 minutes, `SESS-7` says sessions never expire) pass schema,
57
- `check`, `verify` and the archive gate, each green under its own scenario.
58
- `params` does not help, and the reason is structural: it is an
59
- **intra-requirement** mechanism. It pins one requirement's statement to its own
60
- assertions; two requirements may each own a `params` entry of the same name with
61
- different values and both stay green. Design §11 admits semantic drift needs
62
- human review semantic *duplication* is the same category and is not mentioned
63
- at all. This matters most in the workflow the package exists for: an agent asked
64
- for a new behaviour is additive by default, and will add `SESS-7` rather than
65
- `modified: { 'AUTH-3': }`. The delta workflow provides the correct path; it
66
- does not force it, and the one counter-pressure (a new requirement costs a
67
- scenario) does not deter an agent that will happily write one.
68
- *Candidate 1 `divergent-param` (WARNING):* the same param name declared in
69
- more than one requirement with different values, scoped to a shared id prefix
70
- (`AUTH-*` vs `AUTH-*`) to limit collisions between unrelated domains. It would
71
- not detect abstract contradiction, only its most common concrete form: one named
72
- constant pinned twice, differently. **Blocked, and by this repo's own registry:**
73
- `idleTimeoutMin` is `15` in `ATX-3` and `30` in `ATX-10`, both under the same
74
- prefix, and both legitimate unrelated illustrative fixtures. So the false
75
- positive is not hypothetical, and prefix scoping does not save a repo that uses
76
- one prefix throughout. The mechanism needs a way to declare two params
77
- independent before it can ship, and that escape hatch is undesigned.
78
- *Candidate 2`compound-requirement` (WARNING):* more than one RFC-2119
79
- keyword in a single `statement` is objectively more than one obligation, which
80
- directly opposes the lumping incentive above. Cheap and deterministic, but its
81
- reach is narrow: over the 12 self requirements it flags exactly one (`ATX-10`,
82
- `SHALL and MUST …`) and misses both `ATX-5` and `ATX-9`, which pack multiple
83
- clauses under a single keyword.
84
- *Explicitly rejected:* similarity matching between statements (token overlap,
85
- embeddings). That reintroduces the fuzzy AI comparison design §0 exists to
86
- remove. Real semantic duplication needs judgement and belongs to human review
87
- at the propose stage; the engine should not pretend to prove it.
88
- Both candidates are WARNING-level, so neither would change the exit code of an
89
- existing valid setup (only ERROR fails a run) — they are listed here rather than
90
- under 0.2.0 for that reason.
91
-
92
- The breaking items below are held for **0.2.0** (under 0.x SemVer a breaking
93
- change bumps the minor). "Breaking" means it changes the exit code of an existing
94
- valid setup, rejects previously-valid input, removes/renames a public API or
95
- `--json` field, or changes a default runtime behavior an existing invocation
96
- relies on — diagnostic message text is not API.
97
-
98
- ### Planned for 0.2.0 (breaking)
99
-
100
- - **A vacuous green stops passing.** Today `attest verify` on a spec that ran no
101
- scenarios (an empty or requirement-less directory) prints
102
- `✓ No issues. — 0 error, …` and exits `0`, because `runAndCollect` reads "no
103
- failing tests" as `passed`. `verify` will instead emit a new `empty-spec`
104
- ERROR when zero scenarios executed (or the registry has zero requirements),
105
- so `ok` becomes `false` and the exit code follows the existing `hasError`
106
- path. Wires up `hasSpecs()` (present in `pipeline.ts`, never called today).
107
- *Breaking:* an "empty directory → exit 0" run flips to exit 1 — which is the
108
- point, since that run attests nothing. *Non-breaking companion (may land
109
- earlier in 0.1.x):* requirement/scenario counts in the summary line
110
- (`— 33 requirements / 59 scenarios, 0 error, …`).
111
- - **Default spec discovery ignores files with no `requirement()`.** `verify` at
112
- a repo root currently sweeps every `**/*.spec.ts` — including a pre-existing
113
- suite — into a runner with no aliases/DOM, a guaranteed red unrelated to the
114
- code. The default will change to run only spec files that declare at least one
115
- `requirement()`, so an incumbent suite is left untouched without forcing a
116
- directory layout. *Breaking:* a repo relying on the root scan to pick up
117
- requirement-less specs sees a different set run. *Non-breaking companion (may
118
- land earlier in 0.1.x):* a pre-run report of how many spec files were located
119
- and how many contain a `requirement()` call.
329
+ nothing resists a duplicated one.** The gaps themselves are described in design
330
+ §11, which is where a permanent property of the design belongs. What is kept
331
+ here is the list of mechanisms tried and what killed each, so the same three
332
+ are not proposed again without new evidence. All three were scored against
333
+ every registry this repo ships *before* any was written into the engine the
334
+ order `divergent-param` was originally designed in the wrong way round. The
335
+ measurement is runnable in `tests/intent-rule-candidates.spec.ts` and fails
336
+ when a requirement is added without a hand-labelled obligation count, because
337
+ the reach figure once quoted here went stale exactly that way.
338
+ *`compound-requirement` (WARNING) rejected.* More than one RFC-2119 keyword
339
+ in one `statement` is objectively more than one obligation. Measured: of the 7
340
+ requirements in the corpus carrying more than one, it flags 2 (`ATX-10`,
341
+ `ATX-21`) and misses `ATX-5`, `ATX-9`, `ATX-13`, `ATX-14`, `ATX-17` — every
342
+ one that packs clauses under a single keyword, which is the form the coverage
343
+ incentive actually rewards. A wider variant (keyword plus a coordinating
344
+ `and`/`while`/`;`) reaches 6 of 7 with one wrong hit, so reach is not the
345
+ deciding argument.
346
+ This is: both variants are silenced by deleting the word that triggered them,
347
+ and neither can tell that deletion from a real split. `SHALL do A and MUST do
348
+ B` clears the warning as `SHALL do A and do B` same two obligations, one
349
+ fewer normative keyword. A rule whose cheapest fix degrades the artifact it
350
+ protects does not ship.
351
+ *`divergent-param` (WARNING) rejected.* One param name declared with
352
+ different values in two requirements sharing an id prefix. Measured: it fires
353
+ twice on this repo and both firings are wrong `idleTimeoutMin` (`15` in
354
+ `ATX-3`, `30` in `ATX-10`), unrelated illustrative fixtures, and `code`
355
+ (`registry-not-static` in `ATX-17`, `empty-spec` in `ATX-18`,
356
+ `rationale-placeholder` in `ATX-21`), diagnostics that could not possibly
357
+ agree. The second arrived on its own when `ATX-18` was added and grew a third
358
+ member on its own again when `ATX-21` was, which is the more damning half: a
359
+ generic param name collides more often as a registry grows, so the
360
+ false-positive rate rises with adoption the opposite of what a shippable rule
361
+ does. Still zero true positives across 25 requirements. The escape hatch it was blocked on has since been designed
362
+ and does not rescue it: with no config file the only workable shape is an
363
+ additive `independentParams?: string[]` on the requirement (a CLI flag is
364
+ per-invocation for what is a permanent property of two requirements; changing
365
+ the `params` shape rejects every existing registry). Viable, but it is
366
+ permanent schema surface for a rule with no demonstrated true positive, and in
367
+ practice it would be written *after* the warning fires — a one-line silencer.
368
+ *Countable obligations (breaking) rejected.* An `obligations: string[]` per
369
+ requirement, with coverage counted per obligation, removing the lumping
370
+ incentive at the source instead of detecting it afterwards. Rejected because
371
+ the **id is already that unit**: splitting into two ids costs two scenarios and
372
+ buys the same incentive with no new concept (`ATX-11`/`ATX-12` are exactly that
373
+ split). It would touch the parser, coverage, `render`, `SPEC.md`, delta apply
374
+ and the `--json` schema, and it relocates the incentive rather than removing
375
+ it nothing can force the array to be complete.
376
+ *Not considered:* similarity matching between statements (token overlap,
377
+ embeddings). That reintroduces the fuzzy comparison design §0 exists to remove;
378
+ real semantic duplication needs judgement and belongs to human review at the
379
+ propose stage.
120
380
 
121
381
  ## [0.1.7] - 2026-07-26
122
382
 
@@ -438,7 +698,9 @@ Initial release.
438
698
  (MIT), whose four-stage engine and diff-first change model Attest's
439
699
  architecture is adapted from (re-implemented from scratch, no source copied).
440
700
 
441
- [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.1.7...main
701
+ [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.2.1...main
702
+ [0.2.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.2.1
703
+ [0.2.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.2.0
442
704
  [0.1.7]: https://gitlab.com/Pseudorca/attest/-/tags/v0.1.7
443
705
  [0.1.6]: https://gitlab.com/Pseudorca/attest/-/tags/v0.1.6
444
706
  [0.1.5]: https://gitlab.com/Pseudorca/attest/-/tags/v0.1.5