@am_shork/attest 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -13,6 +13,203 @@ input, removes/renames a public API or `--json` field, or changes a default
13
13
  runtime behavior an existing invocation relies on — diagnostic message text is
14
14
  not API.
15
15
 
16
+ ## [0.7.2] - 2026-08-07
17
+
18
+ ### Added
19
+
20
+ - **Releases carry a provenance attestation, and the publish job proves it was
21
+ served rather than trusting its own exit code.** `npm publish --provenance`
22
+ exchanges a GitLab OIDC token for a Sigstore certificate and records, in the
23
+ public transparency log, that this tarball was built by this pipeline from
24
+ this commit — checkable by anyone with `npm audit signatures` or the package
25
+ page, without trusting us. Nothing about the package's *contents* changes:
26
+ same `files`, same tarball, same `prepack`/`prepublishOnly` gate.
27
+ *It is `npm` in a repository whose every other job is `pnpm`, and that is not
28
+ drift.* pnpm's publish has no provenance support at all — checked against the
29
+ pinned 10.28.0, there is no such flag — so the choice was npm for this one
30
+ command or no attestation. Two consequences worth writing down. `--no-git-checks`
31
+ disappears with it: that was a pnpm flag, needed only because pnpm inspects
32
+ the branch and CI builds tags in detached HEAD. And `COREPACK_ENABLE_STRICT=0`
33
+ is now required to call `npm` at all — `corepack enable` shims npm too, and
34
+ `packageManager` pins pnpm, so a bare `npm` aborts with "this project is
35
+ configured to use pnpm". Job-local rather than narrowing the shim in `.base`,
36
+ because every other job wants exactly that strictness.
37
+ *The check after the publish, and the one that looked right and was worthless.*
38
+ The obvious step is `npm audit signatures`, and here it would have been
39
+ decoration of precisely the kind the `audit` job's comment refuses: run in this
40
+ repository it audits *our own installed dependency tree*, which never contains
41
+ `@am_shork/attest` — green while attesting nothing. What runs instead reads the
42
+ registry's attestation endpoint for the version just published and fails unless
43
+ a `slsa.dev/provenance/` predicate is actually there. That is the consumer's
44
+ view, and it is what separates "npm accepted the tarball" from "the statement
45
+ is served". It retries for propagation, because it runs *after* an
46
+ irreversible step: the publish has happened, and a rerun cannot clear a red
47
+ since the republish fails `EPUBLISHCONFLICT` on a version that now exists — so
48
+ a registry that had not caught up would pin the release job red with nothing
49
+ wrong. A served response missing the predicate fails immediately; that answer
50
+ will not change. Both paths were run against the live registry — green on a
51
+ package known to carry provenance, red on `0.7.1`, which does not.
52
+ *Three preconditions, and npm rejects the publish rather than quietly shipping
53
+ unsigned if any is missing.* An `id_tokens` audience of exactly `sigstore`; a
54
+ `repository.url` matching `CI_PROJECT_URL`, so a repository rename fails here
55
+ first; and a public package on registry.npmjs.org, which
56
+ `publishConfig.access=public` already settles. The token is still an npm
57
+ automation token — the OIDC exchange signs the release, it does not authorise
58
+ it (see *Under consideration*).
59
+ *What is unmeasured, stated rather than implied.* There is no dry run for
60
+ provenance — `--dry-run` mints no token exchange — so none of this has executed
61
+ against a real tag pipeline. The next release is its first run. The commit SHA,
62
+ tag, pipeline URL and repository path become public record; all four are
63
+ already public here.
64
+
65
+ ### Fixed
66
+
67
+ - **One unreadable file took the whole of `check` down with it.** A `params`
68
+ value nested 20,000 deep exhausts the call stack inside `ts.createSourceFile`
69
+ — TypeScript's own recursive-descent parser, so `literalValue`'s recursion is
70
+ never reached and hardening it would have changed nothing. Measured: `attest
71
+ check` answered a single `internal-error`, exit 1. It failed *closed*, which
72
+ is why this is a repair and not an emergency — but `check` is the command this
73
+ project tells people to run first, in a pipeline, on a merge request whose
74
+ contents they do not control, and one file an attacker owns could reduce the
75
+ entire report to that one line, permanently. Breadth is what `check` trades
76
+ for running no project code; a report of one crash is not a degraded `check`,
77
+ it is a different command.
78
+ Now a file that cannot be parsed is reported as **`unreadable-file`** and
79
+ skipped, and everything else in the run is still reported.
80
+ *Two sites, and a guard on one of them fixes nothing.* `static-registry.ts`
81
+ and `parser.ts` both open with the same call, so the spec parse takes it too —
82
+ measured separately, and a registry-only guard leaves `attest check` dying on
83
+ a hostile `*.spec.ts` exactly as before. The registry half sits at the loop
84
+ that reads the files rather than inside either reader, because what is being
85
+ kept is a property of the *run* — one file's failure is not the run's failure
86
+ — and both readers need it; `readRegistry` folds them with `Promise.all`,
87
+ where one rejection ends the command.
88
+ *Its own code, not `registry-invalid`.* A file that blew the parser's stack
89
+ did not fail schema validation, and a diagnostic saying it did sends the
90
+ reader to the wrong page. The archive gate refuses to run at all when a spec
91
+ is unreadable, rather than grading a change against a plan known to be short.
92
+ *A third read site turned up while reading the diff, and it was answering
93
+ wrongly rather than crashing.* An unreadable **proposed** spec declares no
94
+ scenarios, so the unclaimed check found it claimed by nothing and reported
95
+ `proposed-spec-unclaimed` — a true sentence about a file whose actual problem
96
+ is that it could not be read, and a fix hint pointing at work that must not be
97
+ done. That is the shape `[0.7.0]` fixed for `orphan-test` after a registry
98
+ fails to load, arriving again at a different site. It now reports
99
+ `unreadable-file` and stands down.
100
+ `parseSpecs` now returns its issues alongside the plan; `parseAllSpecFiles`
101
+ was deleted in passing, having had no callers.
102
+ **No `SCHEMA_VERSION` bump.** A new `code` value is additive to the `--json`
103
+ envelope, and a consumer that branches on codes it knows is unaffected — the
104
+ rule this project already applies to every new diagnostic.
105
+
106
+ - **`attest status` printed one line the sanitiser never saw.** The `Change:`
107
+ header was built and printed in the CLI shell, three lines above a closing
108
+ line that put the same value through `inline()` — so a change name holding
109
+ control characters repainted the terminal from the header while the footer was
110
+ clean. A change name is a directory name in the repository under test, and the
111
+ workflow `init` writes tells an agent to run `attest status <name>`.
112
+ The header moved into `formatStatus`, which is a fix rather than a tidy-up: a
113
+ write that lives in `cli/index.ts` cannot be attested at all, because that
114
+ module runs the CLI at import — so the site was not merely unsanitised, it was
115
+ unreachable by any scenario. `ATX-37`'s site list gained the kind that had
116
+ been missing from it, **a write in the shell**, and the two `--out` echoes are
117
+ the remainder: sanitised now, and still carried as unattested, because the
118
+ trigger needs a filename holding a control character — which NTFS refuses and
119
+ Linux permits, so that half is unmeasured here.
120
+
121
+ - **A param that is a scenario's *domain* could be shortened, and the run just
122
+ covered less.** Nothing in this repo executes here — it is the self-registry
123
+ and its measurement suite — but three of the holes were real, and one of them
124
+ was a security check. Found by re-measuring the tautology entry below after
125
+ `[0.7.1]`: twelve mutations of this registry, each followed by the whole gate.
126
+ Four went green, and three of those were **not** the tautology that experiment
127
+ was looking for. When a scenario loops over a list it read from `params`, that
128
+ list is the set of cases the run covers, so removing a member removes a case
129
+ and every assertion inside the loop still passes over what is left. The
130
+ expectation stays perfectly independent; what moved is the quantifier's
131
+ domain, which is why the rule shipped in `[0.5.0]` — *the expectation must not
132
+ be a function of the param the code under test consumed* — does not cover it.
133
+ What each one cost, measured: dropping `../../evil` from `ATX-13.rejectedNames`
134
+ stopped testing the change-name guard against path traversal; `ATX-40.languages: 1`
135
+ stopped checking `docs/zh/troubleshooting.md` **entirely**, which is the
136
+ mirror rule `CLAUDE.md` calls mandatory losing its only gate; and
137
+ `ATX-64.childRunCommands: ['verify']` stopped checking `archive` for the
138
+ stdout hole `ATX-64` exists to close — filed in the same release, three
139
+ scenarios deep, and silent.
140
+ *The repo had already invented the defence and not named it.* `ATX-16` asserts
141
+ its list against a literal and its comment says why — "so the spec cannot
142
+ quietly cover fewer of them than the intent promises" — and it was one of the
143
+ two lists that went red. A literal is the only independent term available when
144
+ what is at risk is the **size** of the set rather than a value in it, which is
145
+ also why this reads as a violation of "do not hardcode a value that lives in
146
+ `params`" and is not one: the pin asserts what the intent *is*, not what the
147
+ system does.
148
+ Every iterated list now carries that line. `ATX-40` needed both halves and
149
+ that is the instructive one: `toHaveLength(languages)` already caught the
150
+ count going *up* without a document, and could never catch it going down,
151
+ because the slice shrinks with it and the assertion stays true.
152
+ *The counter-pressure, and one thing it cannot reach.* A `DOMAINS` table in
153
+ `tests/intent-rule-candidates.spec.ts` now judges every list-valued param as
154
+ `iterated` or `inert`, with completeness swept from the registry so a new list
155
+ fails until someone decides, and each `iterated` row naming the pin that
156
+ guards it. The `QUANTIFIED` taxonomy gained the distinction this turned on:
157
+ a `roster` owned by the **code** grows with the code and is safe by
158
+ construction, a roster owned by the **registry** is data an author can shorten
159
+ — `ATX-64` was filed as the former and was the latter. What the sweep does not
160
+ reach is `ATX-40`, whose domain size is a *scalar*; it is judged by hand, and
161
+ a future scalar used that way would not be forced into the table by anything.
162
+ *The rot check needed a second attempt, which is worth recording.* Written
163
+ first as "the pin appears somewhere in `self/`", it passed with `ATX-64`'s pin
164
+ deleted — two requirements pin a list read into a local named `commands`, and
165
+ the grep matched the other one. It asserts **exactly one** match now. A check
166
+ satisfiable by an unrelated line is the failure it exists to prevent.
167
+
168
+ - **A project under test could put its own verdict on stdout ahead of Attest's,
169
+ under `--json`.** `runAndCollect` starts Vitest with the parent's fd 1 in
170
+ play, and `quiet: true` does not close it: `silent: true` suppresses Vitest's
171
+ *console interception*, and a bare `process.stdout.write` in a spec never
172
+ enters it — measured at collection time and inside a scenario body, both. So
173
+ a spec writing `{"ok":true,…}` put that document **first** on the stream: a
174
+ consumer calling `JSON.parse` on the whole of stdout got a parse error at
175
+ position 46, and one reading line-wise or taking the first object got the
176
+ attacker's verdict instead of the report. `check`, `cover`, `render` and
177
+ `status` were never exposed — no Attest code was on the path at all, which is
178
+ precisely why `core/terminal.ts` and `sanitisedLogger` did not help.
179
+ *Why this is worse than the terminal half of the same hole.* Repainting a
180
+ terminal — `ESC[2K CR`, a forged `ERROR` header at column 0, an `OSC 0` window
181
+ retitle, all of which also arrive intact — is not a capability `verify`
182
+ lacked, because it executes project code by design. Forging the *machine*
183
+ verdict is different in kind: it inverts what the report is for, since the
184
+ commands carrying the risk are exactly the ones that run the repository being
185
+ gated. §9.1 recorded the machine surface as never exposed because
186
+ `JSON.stringify` escapes every C0 character — true, and about the bytes Attest
187
+ writes; it says nothing about bytes Attest does not write.
188
+ Under `--json`, stdout is now diverted to stderr for the duration of the
189
+ command and released before the report is rendered. Diverted rather than
190
+ dropped: a human debugging a red pipeline still needs the run output, and
191
+ stderr is the stream no consumer parses. Without `--json` nothing changes —
192
+ the promise is the flag's, and diverting unconditionally would take the child
193
+ run's output away from the reader it exists for.
194
+ *The fix is `runAction`'s, not `runner.ts`'s* — the same argument that put
195
+ `formatCrash` in `cli/report.ts` rather than a copy in each `catch`.
196
+ `runAction` is already the single convergence point for one output and one
197
+ exit code; `runner.ts` would have been made to hold a contract it does not
198
+ own. It moved to **`src/cli/action.ts`** to be testable at all: `cli/index.ts`
199
+ calls `program.parseAsync()` at import time, so importing it to cross the seam
200
+ would run the CLI. No behaviour of `index.ts` moved with it.
201
+ *What the regression test had to be, because the first one was wrong.* The
202
+ scenarios under **`ATX-64`** spawn the real CLI and read its pipes. The
203
+ in-process version looks equivalent and is not: they run inside a Vitest
204
+ worker, which patches `console` and re-routes worker output, so intercepting
205
+ `process.stdout.write` there observed neither the report nor the pollution and
206
+ went green against the unfixed code for three of three scenarios. What the
207
+ requirement is about is the bytes on the pipe, and only a real pipe has them.
208
+ The statement names its commands in `params` rather than quantifying over
209
+ "every command that executes project code" — a `roster` row rather than a
210
+ `sites` one, so a third such command cannot join without joining the
211
+ assertion.
212
+
16
213
  ## [0.7.1] - 2026-08-05
17
214
 
18
215
  ### Added
@@ -3178,6 +3375,26 @@ symptom are worth batching**: individually none justifies a commit, together the
3178
3375
  cost about an hour, and they are the group no gate could have found, since having
3179
3376
  no symptom is the definition of what a gate cannot see.
3180
3377
 
3378
+ - **Telling adopters about the domain trap, which the shipped guidance
3379
+ currently walks them into.** The repo half landed under `[Unreleased]`; the
3380
+ user-facing half has not, and it is not merely absent. `skill.ts`'s "Four
3381
+ things you must not do" says **"Do not hardcode a value that lives in
3382
+ `params`"**, and the pin every fix in that entry adds is, read literally,
3383
+ exactly that. An adopter who writes a roster param — which the design
3384
+ actively pushes them toward, since a quantifier a scenario can iterate is the
3385
+ shape `QUANTIFIED` recommends — gets an instruction that forbids the only
3386
+ defence against shortening it. The distinction is real and one sentence long:
3387
+ the rule is about the **expectation** the system is measured against, and the
3388
+ pin asserts what the **intent** is. Nothing shipped draws it.
3389
+ *Why it is filed rather than done in the same pass.* It reaches README §"killer
3390
+ move", the `init` skill, `possible-drift`'s troubleshooting section and design
3391
+ §11, in both languages — the same surface `[0.5.0]` rewrote, and that entry
3392
+ records the lesson: rewording the central claim of the tool is not a line
3393
+ appended, and it was held back once precisely so it could be done deliberately
3394
+ and in one pass. The measurement it needs is already taken; what it needs next
3395
+ is the pass. *Not* a `possible-drift` change: nothing here is detectable by
3396
+ the engine, for the reason the entry below gives about prose judgement.
3397
+
3181
3398
  - **Binding the `✗` samples to fixtures whose rejection is asserted.** A sample
3182
3399
  showing input the engine must *reject* can be bound the way a runnable one is,
3183
3400
  to a fixture whose rejection is the thing under test — which is what
@@ -3196,32 +3413,6 @@ no symptom is the definition of what a gate cannot see.
3196
3413
  `[0.4.3]` — that the gate and the run proving the fixture still works live in
3197
3414
  different suites — applies here identically.
3198
3415
 
3199
- - **The child run's stdout is not Attest's, and `--json` promises it is.**
3200
- `runAndCollect` starts Vitest with the parent's fd 1 inherited, so a spec file
3201
- in the project under test writes straight to the same stream the report goes
3202
- to. `quiet: true` does not close it: `silent: true` suppresses Vitest's
3203
- *console interception*, and a bare `process.stdout.write` in a spec — at
3204
- collection time or inside a scenario body, both measured — goes past it
3205
- untouched. Neither `core/terminal.ts` nor `sanitisedLogger` is on that path at
3206
- all, because no Attest code is.
3207
- *The machine surface is the half that is actually new.* `cli/index.ts` opens
3208
- by stating that under `--json` "the only thing written to stdout is one
3209
- machine-readable report", and §9.1 records that this path "was never exposed —
3210
- `JSON.stringify` escapes every C0 character". Both hold for `check`, `cover`,
3211
- `render` and `status`, and neither holds for `verify` or `archive`: measured,
3212
- a spec writing `{"ok":true,"POLLUTION":"…"}` puts that document **first** on
3213
- stdout, ahead of the real report, so `JSON.parse` of the whole stream fails
3214
- (position 46) while anything reading line-wise or taking the first object
3215
- reads the attacker's verdict. The terminal half is the same hole and the
3216
- smaller finding — `ESC[2K CR` plus a forged `ERROR` header at column 0 and an
3217
- `OSC 0` window retitle all arrive intact — but `verify` executes project code
3218
- by design, so repainting a terminal is not a capability that path lacked.
3219
- *The fix belongs to `runAction`, not to `runner.ts`.* That function is already
3220
- the single convergence point for "one output, one exit code", which is the
3221
- same argument that put `formatCrash` in `cli/report.ts` rather than leaving a
3222
- copy in each `catch`: `runner.ts` would be made to hold a contract it does not
3223
- own, while `runAction` is the only place the promise is made.
3224
-
3225
3416
  - **`render`'s document can carry raw HTML and a forged requirement section.**
3226
3417
  `sanitised` is the entry §9.1 names and it strips control characters and
3227
3418
  nothing else, so `statement` and `rationale` reach the Markdown by
@@ -3259,36 +3450,6 @@ no symptom is the definition of what a gate cannot see.
3259
3450
  the same reason: a name known to be unwritable must not leave half a set
3260
3451
  behind.
3261
3452
 
3262
- - **A deeply nested literal takes `check` down before either reader sees it.**
3263
- 40 KB of `[[[…]]]` in a `params` value exhausts the stack inside
3264
- `ts.createSourceFile` — TypeScript's own recursive-descent parser, so
3265
- `literalValue`'s recursion is never reached and hardening it would change
3266
- nothing. `parser.ts` opens with the same call, so `verify` takes it too, and
3267
- `loadRegistry` reads with `Promise.all`, so one file ends the whole command.
3268
- *It fails closed, which is why this is a repair and not an emergency.*
3269
- Measured: `internal-error`, `ok: false`, exit 1, and a well-formed `--json`
3270
- envelope. What it costs is that the command this project tells people to run
3271
- first on an untrusted fork MR can be made to say nothing but that, forever.
3272
- The shape of the fix is already in `locate.ts`: `unreadableFiles` exists to
3273
- say "this file could not be read" without ending the run, and a `RangeError`
3274
- out of a single `read` or `parseSpecFile` is that, so a hostile file scraps
3275
- only itself and `check`'s breadth contract survives it.
3276
-
3277
- - **`status` prints one line the sanitiser never sees.** `cli/index.ts` writes
3278
- `Change: ${result.change}` bare, while `formatStatus` puts the same value
3279
- through `inline` twice, three lines apart in the same output. Measured with a
3280
- directory named with U+009B — the C1 CSI the class in `control` exists for,
3281
- and one NTFS permits — the header carried `c2 9b` raw while the closing
3282
- `Not a verdict:` line carried a space. A change name is a directory name in
3283
- the repository under test, and the workflow `init` writes tells an agent to
3284
- run `attest status <name>`, so the value is the project's to choose.
3285
- *One `inline()` call, and the entry is here for the reason rather than the
3286
- fix.* §9.1 says a stream has no entry, only writes, so the obligation sits on
3287
- every write or not at all — and this is the second time a single missed write
3288
- has been the whole defect, after the two `catch` blocks that printed a raw
3289
- stack beside sanitised diagnostics. The sweep filed above under `ATX-37` is
3290
- what would have found it; this is one more measurement in favour of it.
3291
-
3292
3453
  ## Under consideration
3293
3454
 
3294
3455
  Candidates with **no decision yet**, between the two sections either side of it
@@ -3301,6 +3462,25 @@ deletion. What each entry has to carry is the evidence currently blocking the
3301
3462
  decision, because that is what a re-proposal a year from now would otherwise
3302
3463
  have to gather again.
3303
3464
 
3465
+ - **Trusted publishing, which would delete `NPM_TOKEN` rather than add to it.**
3466
+ The provenance work in `[Unreleased]` signs the release but does not change
3467
+ what authorises it: a long-lived automation token, masked and protected, that
3468
+ publishes as us to anyone holding it. npm's trusted publishing exchanges the
3469
+ same OIDC identity the attestation already uses for a short-lived credential,
3470
+ which would remove the stored secret entirely and grant provenance as a
3471
+ property of the flow rather than a flag on the command. The deletion test
3472
+ points the right way for once: what it removes is the only long-lived
3473
+ credential this repository has.
3474
+ *What blocks the decision, and it is evidence rather than principle.* Whether
3475
+ npm's trusted publishers accept a **gitlab.com-hosted** project on the plan
3476
+ this package publishes under has not been verified against this project — it
3477
+ was not checked when the provenance job was written, and an entry claiming it
3478
+ works would be exactly the unverified confidence the rest of this file avoids.
3479
+ It also cannot be tested except by publishing: as with provenance, there is no
3480
+ dry run, so a failed experiment is a release. The cheap next step is reading
3481
+ npm's current trusted-publisher documentation for GitLab support before
3482
+ writing any YAML.
3483
+
3304
3484
  - **A static sweep for the two properties claimed over the whole of `src/`.**
3305
3485
  The quantifier table's first strict read left three rows wanting the same
3306
3486
  thing, and the repo already owns the idiom: `tests/import-boundary.spec.ts` is
@@ -3439,6 +3619,103 @@ have to gather again.
3439
3619
  assertion is still green, still silent, and still reachable — so this stays
3440
3620
  here rather than moving to `Considered and rejected`: documenting a trap is
3441
3621
  not the same as deciding not to detect it.
3622
+ **Re-measured after `[0.7.1]` widened `params`, on this registry rather than
3623
+ the reporter's — the first time this experiment has been run here.** Twelve
3624
+ mutations, each followed by the whole gate. Two results matter, and they point
3625
+ opposite ways.
3626
+ *The shape is now reachable here, and the widening is why.* `self/render.spec.ts`
3627
+ asserts `expect(md).toContain(JSON.stringify(kinds))` over the same
3628
+ `payloadKinds` it feeds to the renderer — the expectation is a function of the
3629
+ value under test, verbatim row 3, and it could not have been written before
3630
+ the widening because the param could not be an object. The mechanism is
3631
+ sharper than a frequency count and is the strongest thing this entry has
3632
+ gained: **the prescribed remedy costs in proportion to the value's
3633
+ complexity.** Same repo, adjacent files, same value shape — `ATX-7` pins two
3634
+ numbers with literals and goes red; `ATX-63` would have had to hand-write a
3635
+ rendered JSON block, so it echoed. Depth raises the defect rate and the cost
3636
+ of avoiding it along one axis.
3637
+ *And its cost here is nil, which is the honest other half.* Both composite
3638
+ params are fixtures for the renderer and the validator; the numbers stand for
3639
+ nothing, so the green is arguably correct. Blocker **(1)** is therefore only
3640
+ half retired — the shape is reproduced in a second, mature repo, and the count
3641
+ of *harmful* instances is still zero. Blocker **(2)** is reinforced rather than
3642
+ weakened: all four green mutations were on params `src/` never reads, so the
3643
+ reporter's "read from both a spec and a non-spec file" detector would have
3644
+ flagged none of them. Blocker **(3)** is untouched.
3645
+ *The frequency is no longer unmeasured, and it points away from building
3646
+ anything.* Blocker **(1)** rested on 8 commits of one greenfield repo. Parsed
3647
+ at every one of the **38** revisions that have touched this registry — all 38
3648
+ readable by the current static reader, so nothing was skipped — the history is
3649
+ **57 param keys added, 0 removed, and exactly 1 value changed**: `ATX-16.staticCommands`
3650
+ gaining `status`. That single edit is a roster *widening*, the safe direction,
3651
+ and it is on the one list that was already pinned, so it could not have been
3652
+ made without updating the pin. Two independent codebases, one greenfield and
3653
+ one at 64 requirements, and the event this whole entry is about has happened
3654
+ zero times in either. What that argues is not that the hole is unreal — it is
3655
+ reachable, demonstrated above — but that it is **rare and silent**, which is
3656
+ the profile `divergent-param` was rejected on rather than the profile that
3657
+ justifies an engine rule.
3658
+ *The same number says the split-out fix below has also never been triggered.*
3659
+ 0 removals ever means no domain has ever been shortened here. Those pins close
3660
+ a live *exposure*, not a live *loss*, and the case for them is that they cost
3661
+ one line each against a failure with no symptom — not that anything was
3662
+ currently broken.
3663
+ *Blocker **(3)** assumed mutation testing was the only separator. A cheaper
3664
+ candidate existed, has now been scored, and does not survive it.* The idea was
3665
+ a **syntactic** discriminator over one test body — flag an assertion whose
3666
+ expected value derives from a param that also reached the arrange/act part —
3667
+ which is exactly the difference between the two cases the widening produced:
3668
+ `renderMarkdown({params: {payloadKinds: kinds}})` … `toContain(JSON.stringify(kinds))`
3669
+ against `expect(kinds.json.maxBytes).toBe(1_048_576)`. Scored the way
3670
+ `divergent-param` should have been and was not: implemented in
3671
+ `tests/intent-rule-candidates.spec.ts`, run over the specs that attest this
3672
+ repo, and then **every param it fires on mutated with the whole gate run —
3673
+ fifteen of them, one gate run each. Four went green; eleven went red.**
3674
+ *Then the detector was found to be wrong, which is the more useful half.* Its
3675
+ first implementation counted an identifier in any position as a read of a
3676
+ local with that name, so `i.code` in `issues.map((i) => i.code)` looked like a
3677
+ use of a param named `code` — and `code` is this repo's commonest param name.
3678
+ Corrected to count reference positions only, it flags **8, not 15**, and the
3679
+ seven it drops are exactly seven the mutations had already shown to be pinned
3680
+ by `src/`. The honest score is therefore **4 of 8, not 4 of 15** — precision
3681
+ 50%, not 27%. The conclusion does not move, because it never rested on the
3682
+ precision figure; what moves is how much of the original number was the
3683
+ candidate failing and how much was the measurement being wrong, and that is
3684
+ worth writing down rather than quietly restating.
3685
+ The remaining false positives are not near-misses. They are independent terms
3686
+ the detector cannot see: **the runtime is the term** — `ATX-14`'s
3687
+ `inheritedKey` is `'toString'`, and a mutated spelling is not a prototype key
3688
+ at all, so the behaviour under test changes; **`src/` is the term** —
3689
+ `ATX-51`'s `code` is a second copy of a string the engine emits as a literal;
3690
+ and **arithmetic is the term** — `threshold + 3` builds a derived expectation
3691
+ that mutation breaks.
3692
+ And the four true positives fail the second test, which is the one that
3693
+ decides it: **none is harmful.** `ATX-10` and `ATX-3` assert that a value was
3694
+ substituted or applied, where the identity of the number is not the
3695
+ obligation; `ATX-51`'s is a path prefix and `ATX-63`'s a rendering fixture.
3696
+ Telling those from a mine-density band that must not move is a judgement about
3697
+ whether the value *means* anything — the ground truth §0 refuses to compute
3698
+ for someone else's registry. **50% precision on the shape, 0% on the harm.**
3699
+ Two limits on that number, stated because a score with an unstated method is
3700
+ what this file exists to prevent. The mutation has **one direction per kind**
3701
+ — numbers +1, lists drop-last, strings suffixed — and direction can decide the
3702
+ verdict: `ATX-40.languages` is red at 2→3 and was green at 2→1 before it was
3703
+ pinned, so the true-positive count is a floor. And **recall is unmeasured**:
3704
+ the eight params mutated earlier that this detector does not flag all went
3705
+ red, which is eight correct negatives and not a recall figure.
3706
+ The detector and its verdict table are kept in the measurement suite, with the
3707
+ reach recomputed on every run and the table asserted to cover exactly what
3708
+ fires — because the reach figure quoted for `compound-requirement` went stale
3709
+ in this very file for want of that.
3710
+ *A scoping limit on all of the above.* This repo cannot settle blocker **(2)**.
3711
+ `src/` reads no param at all — every `reqs[…].params` in it is a comment or a
3712
+ doc string — so params here are test vocabulary, while in the reporter's repo
3713
+ they are application constants the app itself consumes. The reporter's
3714
+ detector keys on exactly that difference, so it is inert here and this corpus
3715
+ is evidence neither for nor against it.
3716
+ *What the re-measurement did find is a different defect, and it has been split
3717
+ out below* — the entry is kept whole here because the two were measured in one
3718
+ pass and separating them is the finding.
3442
3719
 
3443
3720
  - **Whether `check` and `verify` should refuse a half-loaded registry the way
3444
3721
  `cover` and `render` do.** Those two return early on `hasError(loadIssues)`,
@@ -3919,7 +4196,7 @@ requirement, and human review at propose is still the whole answer. What is
3919
4196
  rejected is grouping as a way to assist it, and this reopens only on a
3920
4197
  contradiction that grouping would have caught.
3921
4198
 
3922
- [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.7.1...main
4199
+ [0.7.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.2
3923
4200
  [0.7.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.1
3924
4201
  [0.7.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.0
3925
4202
  [0.6.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.6.0
package/README.md CHANGED
@@ -157,7 +157,7 @@ Every diagnostic carries a `code`, and every code has a section in
157
157
  ```
158
158
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
159
159
  Value is not a literal.
160
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.7.1/docs/en/troubleshooting.md#registry-not-static
160
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.7.2/docs/en/troubleshooting.md#registry-not-static
161
161
  ```
162
162
 
163
163
  The anchor **is** the code, so the link cannot point somewhere the section
package/bin/attest.js CHANGED
File without changes
@@ -0,0 +1,48 @@
1
+ import { type JsonCommand, type JsonReport } from './json.js';
2
+ /** What an action produces: the machine report plus how to render it for humans. */
3
+ export interface Rendered {
4
+ report: JsonReport;
5
+ human: () => void;
6
+ }
7
+ export interface ActionOptions {
8
+ json?: boolean;
9
+ }
10
+ /**
11
+ * Send everything written to `process.stdout` to stderr until the returned
12
+ * function is called.
13
+ *
14
+ * This is what keeps the `--json` promise against output Attest does not write
15
+ * — the third obligation on the machine surface in design §9.1, stated there
16
+ * because it belongs to the command shell rather than to any emitter, and
17
+ * attested by ATX-64. The child Vitest run of `verify`/`archive` executes
18
+ * project code,
19
+ * and a bare `process.stdout.write` in a spec reaches this stream — `silent:
20
+ * true` only suppresses Vitest's *console interception*, which such a write
21
+ * never enters. Measured: the child's writes do arrive through the parent's
22
+ * `process.stdout.write` rather than through a separately inherited descriptor,
23
+ * which is why patching it here is sufficient and a change of Vitest pool is
24
+ * the thing that could quietly make it insufficient. The scenario under ATX-64
25
+ * runs a real child run for that reason, rather than asserting on this function
26
+ * with a stub.
27
+ *
28
+ * Diverted, not discarded: a human reading a red pipeline still needs the run
29
+ * output, and stderr is the stream no `--json` consumer parses. It is not
30
+ * sanitised on the way — `verify` executes project code by design, so painting
31
+ * the terminal is not a capability this path lacked (design §9.1 scopes its
32
+ * guarantee to what Attest itself writes).
33
+ */
34
+ export declare function divertStdout(): () => void;
35
+ /**
36
+ * Run one command's work with a single output + exit-code contract:
37
+ * - success: print the JSON report (`--json`) or the human rendering, and set
38
+ * the exit code from `report.ok` — the one source of the verdict.
39
+ * - failure: under `--json`, still emit one parseable `internal-error`
40
+ * envelope on stdout; otherwise print the stack on stderr. Exit code 1.
41
+ *
42
+ * Under `--json` the action runs with stdout diverted, so the report is the
43
+ * only thing on that stream. The divert is released before either rendering
44
+ * runs — the report has to reach the real stdout, and the human path was never
45
+ * under the promise.
46
+ */
47
+ export declare function runAction(command: JsonCommand, opts: ActionOptions, action: () => Promise<Rendered>): Promise<void>;
48
+ //# sourceMappingURL=action.d.ts.map
@@ -0,0 +1,100 @@
1
+ // The convergence point every command passes through: one output, one exit
2
+ // code (design §9). It lives here rather than in `cli/index.ts` because that
3
+ // module calls `program.parseAsync()` at import time — importing it to test the
4
+ // contract would run the CLI. The interface is the test surface, so the
5
+ // contract moved to where a scenario can cross the same seam a command does.
6
+ import { renderJson, errorReport } from './json.js';
7
+ import { formatCrash } from './report.js';
8
+ import { packageVersion } from '../core/version.js';
9
+ const VERSION = packageVersion();
10
+ /**
11
+ * Send everything written to `process.stdout` to stderr until the returned
12
+ * function is called.
13
+ *
14
+ * This is what keeps the `--json` promise against output Attest does not write
15
+ * — the third obligation on the machine surface in design §9.1, stated there
16
+ * because it belongs to the command shell rather than to any emitter, and
17
+ * attested by ATX-64. The child Vitest run of `verify`/`archive` executes
18
+ * project code,
19
+ * and a bare `process.stdout.write` in a spec reaches this stream — `silent:
20
+ * true` only suppresses Vitest's *console interception*, which such a write
21
+ * never enters. Measured: the child's writes do arrive through the parent's
22
+ * `process.stdout.write` rather than through a separately inherited descriptor,
23
+ * which is why patching it here is sufficient and a change of Vitest pool is
24
+ * the thing that could quietly make it insufficient. The scenario under ATX-64
25
+ * runs a real child run for that reason, rather than asserting on this function
26
+ * with a stub.
27
+ *
28
+ * Diverted, not discarded: a human reading a red pipeline still needs the run
29
+ * output, and stderr is the stream no `--json` consumer parses. It is not
30
+ * sanitised on the way — `verify` executes project code by design, so painting
31
+ * the terminal is not a capability this path lacked (design §9.1 scopes its
32
+ * guarantee to what Attest itself writes).
33
+ */
34
+ export function divertStdout() {
35
+ const stream = process.stdout;
36
+ // The prior *state* of the property, not the function it held. `write` is
37
+ // inherited from `Writable.prototype`, so the patch below is a new own
38
+ // property and undoing it means removing that property — assigning the old
39
+ // function back would leave a bound copy shadowing the prototype forever, and
40
+ // would silently swallow anyone else's patch on a nested divert.
41
+ const owned = Object.getOwnPropertyDescriptor(stream, 'write');
42
+ const divert = (chunk, encoding, callback) => typeof encoding === 'function'
43
+ ? process.stderr.write(chunk, encoding)
44
+ : process.stderr.write(chunk, encoding, callback);
45
+ stream.write = divert;
46
+ return () => {
47
+ if (owned)
48
+ Object.defineProperty(stream, 'write', owned);
49
+ else
50
+ Reflect.deleteProperty(stream, 'write');
51
+ };
52
+ }
53
+ /**
54
+ * Run one command's work with a single output + exit-code contract:
55
+ * - success: print the JSON report (`--json`) or the human rendering, and set
56
+ * the exit code from `report.ok` — the one source of the verdict.
57
+ * - failure: under `--json`, still emit one parseable `internal-error`
58
+ * envelope on stdout; otherwise print the stack on stderr. Exit code 1.
59
+ *
60
+ * Under `--json` the action runs with stdout diverted, so the report is the
61
+ * only thing on that stream. The divert is released before either rendering
62
+ * runs — the report has to reach the real stdout, and the human path was never
63
+ * under the promise.
64
+ */
65
+ export async function runAction(command, opts, action) {
66
+ try {
67
+ const { report, human } = await withStdoutDiverted(opts.json === true, action);
68
+ if (opts.json)
69
+ console.log(renderJson(report));
70
+ else
71
+ human();
72
+ process.exitCode = report.ok ? 0 : 1;
73
+ }
74
+ catch (err) {
75
+ if (opts.json) {
76
+ // Unsanitised on purpose: `JSON.stringify` escapes every C0 character,
77
+ // so these bytes cannot carry one out (see report.ts). That is a claim
78
+ // about what this line writes and nothing wider — what the project under
79
+ // test writes is the divert's business, above.
80
+ console.log(renderJson(errorReport(VERSION, command, err)));
81
+ }
82
+ else {
83
+ console.error(formatCrash(err));
84
+ }
85
+ process.exitCode = 1;
86
+ }
87
+ }
88
+ /** Run `action` with stdout diverted when `divert`, restoring on every path. */
89
+ async function withStdoutDiverted(divert, action) {
90
+ if (!divert)
91
+ return action();
92
+ const release = divertStdout();
93
+ try {
94
+ return await action();
95
+ }
96
+ finally {
97
+ release();
98
+ }
99
+ }
100
+ //# sourceMappingURL=action.js.map
package/dist/cli/index.js CHANGED
@@ -7,14 +7,22 @@
7
7
  // The exit code is always derived from that report's `ok`, so the JSON verdict
8
8
  // and the process status can never disagree — including on the crash path,
9
9
  // where the report is an `internal-error` envelope instead of a bare stack.
10
+ //
11
+ // Both halves of that promise are kept by `runAction` (cli/action.ts), which is
12
+ // where the contract is stated and tested; this file only supplies the work.
10
13
  import { Command } from 'commander';
11
14
  import chalk from 'chalk';
12
15
  import { resolve } from 'node:path';
13
16
  import { writeAtomic } from '../core/write.js';
14
17
  import { runCheck, runVerify, runCover, runArchive, runArchiveApply, runInit, runRender, runRenderCheck, runStatus, DEFAULT_TARGET, TARGET_NAMES, } from '../core/pipeline.js';
15
18
  import { hasError } from '../core/types.js';
19
+ // The one project-derived value this shell still interpolates itself. `--out`
20
+ // is argv, which on a fork MR pipeline is written by the repository's own CI
21
+ // config — so it is the project's text, not the operator's (ATX-37).
22
+ import { inline } from '../core/terminal.js';
23
+ import { runAction } from './action.js';
16
24
  import { formatArchiveVerdict, formatCrash, formatIssues, formatScope, summarize, formatCoverage, formatStatus, } from './report.js';
17
- import { archiveReport, checkReport, coverReport, errorReport, initReport, renderJson, renderReport, statusReport, verifyReport, } from './json.js';
25
+ import { archiveReport, checkReport, coverReport, initReport, renderReport, statusReport, verifyReport, } from './json.js';
18
26
  import { packageVersion } from '../core/version.js';
19
27
  const VERSION = packageVersion();
20
28
  /** `runArchive` in the shape `--apply` returns, so the two share one call site. */
@@ -47,34 +55,6 @@ const VITEST_CONFIG_HELP = 'load this Vitest config in the child run (transforms
47
55
  function vitestConfig(opts) {
48
56
  return opts.vitestConfig ? resolve(process.cwd(), opts.vitestConfig) : undefined;
49
57
  }
50
- /**
51
- * Run one command's work with a single output + exit-code contract:
52
- * - success: print the JSON report (`--json`) or the human rendering, and set
53
- * the exit code from `report.ok` — the one source of the verdict.
54
- * - failure: under `--json`, still emit one parseable `internal-error`
55
- * envelope on stdout; otherwise print the stack on stderr. Exit code 1.
56
- */
57
- async function runAction(command, opts, action) {
58
- try {
59
- const { report, human } = await action();
60
- if (opts.json)
61
- console.log(renderJson(report));
62
- else
63
- human();
64
- process.exitCode = report.ok ? 0 : 1;
65
- }
66
- catch (err) {
67
- if (opts.json) {
68
- // Unsanitised on purpose: `JSON.stringify` escapes every C0 character, so
69
- // the machine surface was never the exposed one (see report.ts).
70
- console.log(renderJson(errorReport(VERSION, command, err)));
71
- }
72
- else {
73
- console.error(formatCrash(err));
74
- }
75
- process.exitCode = 1;
76
- }
77
- }
78
58
  program
79
59
  .command('check')
80
60
  .description('Static structural validation (fast CI pre-check); runs no project code.')
@@ -168,7 +148,7 @@ program
168
148
  report: renderReport(VERSION, issues, opts.out),
169
149
  human: () => {
170
150
  if (issues.length === 0)
171
- console.log(chalk.green(`✓ ${opts.out} is up to date.`));
151
+ console.log(chalk.green(`✓ ${inline(opts.out ?? '')} is up to date.`));
172
152
  else {
173
153
  console.log(formatIssues(issues));
174
154
  console.log(summarize(issues));
@@ -188,7 +168,7 @@ program
188
168
  console.log(summarize(issues));
189
169
  }
190
170
  else if (dest) {
191
- console.log(chalk.green(`✓ Wrote ${opts.out}`));
171
+ console.log(chalk.green(`✓ Wrote ${inline(opts.out ?? '')}`));
192
172
  }
193
173
  else {
194
174
  process.stdout.write(markdown);
@@ -261,7 +241,6 @@ program
261
241
  console.log(summarize(result.issues));
262
242
  return;
263
243
  }
264
- console.log(chalk.bold(`Change: ${result.change}`));
265
244
  console.log(formatStatus(result));
266
245
  },
267
246
  };
@@ -166,7 +166,15 @@ const STATE_MARK = {
166
166
  * the argument that keeps `render --check` comparing documents rather than bytes.
167
167
  */
168
168
  export function formatStatus(result) {
169
- const lines = [];
169
+ // The header is built here rather than in the CLI action, and that is a fix
170
+ // rather than a tidy-up. It was interpolated and printed in `cli/index.ts`,
171
+ // which put it past the sanitiser — three lines above a closing line that
172
+ // puts the same value through `inline`. `ATX-37` covers "everything the CLI
173
+ // writes to a terminal", and a write that lives in the shell can only be
174
+ // attested by spawning a process, because `cli/index.ts` runs the CLI at
175
+ // import. Moving the line to the module that already owns this report makes
176
+ // the obligation reachable by a scenario — the interface is the test surface.
177
+ const lines = [chalk.bold(`Change: ${inline(result.change)}`)];
170
178
  if (result.rows.length === 0) {
171
179
  lines.push(chalk.dim('(this change adds no requirements)'));
172
180
  }
@@ -8,7 +8,7 @@
8
8
  * and the `##` headings of both language documents, so landing here cannot
9
9
  * produce a dead link.
10
10
  */
11
- export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "non-scalar-interpolation", "orphan-test", "possible-drift", "proposed-spec-name-taken", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "spec-in-change-dir", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target"];
11
+ export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "non-scalar-interpolation", "orphan-test", "possible-drift", "proposed-spec-name-taken", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "spec-in-change-dir", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target", "unreadable-file"];
12
12
  export type IssueCode = (typeof ISSUE_CODES)[number];
13
13
  /**
14
14
  * The page explaining `code`, or `undefined` when nothing explains it.
package/dist/core/docs.js CHANGED
@@ -55,6 +55,7 @@ export const ISSUE_CODES = [
55
55
  'unbound-param',
56
56
  'uncovered-requirement',
57
57
  'unknown-target',
58
+ 'unreadable-file',
58
59
  ];
59
60
  const DOCUMENTED = new Set(ISSUE_CODES);
60
61
  const REPO = 'https://gitlab.com/Pseudorca/attest/-/blob';
@@ -152,9 +152,10 @@ export declare function idPrefix(id: string): string;
152
152
  * development platform could produce (see CHANGELOG.md, `Under consideration`).
153
153
  * The memory here needed no such evidence — it is arithmetic, and portable.
154
154
  */
155
- export declare function parseSpecs(files: string[], displayRoot: string): Promise<AttestPlan>;
156
- /** Parse every `*.spec.ts` under root into one merged plan (file paths shown relative to root). */
157
- export declare function parseAllSpecFiles(root: string): Promise<AttestPlan>;
155
+ export declare function parseSpecs(files: string[], displayRoot: string): Promise<{
156
+ plan: AttestPlan;
157
+ issues: Issue[];
158
+ }>;
158
159
  /**
159
160
  * Spec-shaped files sitting under `root/changes` — the location a change's
160
161
  * specs used to live at, and which nothing walks any more (design §7).
@@ -95,6 +95,43 @@ export async function scanProject(root) {
95
95
  }
96
96
  return { reqsFiles, specFiles, proposedSpecFiles };
97
97
  }
98
+ /**
99
+ * The message an unreadable file gets, in one place because two call sites
100
+ * raise it — a registry and a spec — and they must not drift into two
101
+ * descriptions of one condition (ATX-65).
102
+ */
103
+ function unreadableIssue(err) {
104
+ // `RangeError` in practice, from the call stack running out inside
105
+ // TypeScript's recursive-descent parser. Caught as `unknown` rather than
106
+ // narrowed to it: the guard's promise is that *no* throw from one file ends
107
+ // the run, and narrowing would make that promise true only of the one trigger
108
+ // that has been measured.
109
+ const detail = err instanceof Error ? err.message : String(err);
110
+ return {
111
+ level: 'ERROR',
112
+ code: 'unreadable-file',
113
+ message: `Could not be read, so nothing in it was checked: ${detail}. ` +
114
+ `Everything else in this run was still reported.`,
115
+ };
116
+ }
117
+ /**
118
+ * `reader.read`, with a throw turned into an issue about that file.
119
+ *
120
+ * At the loop rather than inside either reader, because what is being kept is a
121
+ * property of the *run* — one file's failure is not the run's failure — and
122
+ * both readers need it. `readRegistry` folds these concurrently, so an
123
+ * uncaught throw here rejects the whole `Promise.all` and ends the command:
124
+ * measured, a 20,000-deep literal in one `params` value reduced `attest check`
125
+ * to a single `internal-error` (design §5.1, ATX-65).
126
+ */
127
+ async function readGuarded(reader, file) {
128
+ try {
129
+ return await reader.read(file);
130
+ }
131
+ catch (err) {
132
+ return { issue: unreadableIssue(err) };
133
+ }
134
+ }
98
135
  /**
99
136
  * Read registries by **executing** the module through the Vite loader.
100
137
  *
@@ -207,7 +244,7 @@ export async function loadRegistry(root, reader, files) {
207
244
  const paths = files ?? (await scanProject(root)).reqsFiles;
208
245
  // Read the files concurrently, then fold the results in sorted file order:
209
246
  // the issue list stays deterministic regardless of which one finished first.
210
- const loaded = await Promise.all(paths.map(async (file) => ({ file, outcome: await reader.read(file) })));
247
+ const loaded = await Promise.all(paths.map(async (file) => ({ file, outcome: await readGuarded(reader, file) })));
211
248
  const registry = {};
212
249
  const issues = [];
213
250
  const unreadableFiles = [];
@@ -309,12 +346,24 @@ export async function parseSpecs(files, displayRoot) {
309
346
  // Indexed rather than appended, so the merge below follows the input order
310
347
  // whatever order the reads finish in.
311
348
  const parsed = new Array(files.length);
349
+ // Same index space, so a file contributes either a parse or an issue and the
350
+ // two lists cannot disagree about which file is which.
351
+ const failures = new Array(files.length);
312
352
  let next = 0;
313
353
  const worker = async () => {
314
354
  for (let i = next++; i < files.length; i = next++) {
315
355
  const file = files[i];
316
- const source = await readFile(file, 'utf8');
317
- parsed[i] = parseSpecFile(relativePath(displayRoot, file), source);
356
+ const display = relativePath(displayRoot, file);
357
+ // Per file, for the reason `readGuarded` exists above: these run
358
+ // concurrently, so one throw rejects the whole `Promise.all` and takes
359
+ // the command with it. A hostile spec scraps only itself (ATX-65).
360
+ try {
361
+ parsed[i] = parseSpecFile(display, await readFile(file, 'utf8'));
362
+ }
363
+ catch (err) {
364
+ parsed[i] = { scenarios: [], paramRefs: [] };
365
+ failures[i] = { ...unreadableIssue(err), file: display };
366
+ }
318
367
  }
319
368
  };
320
369
  await Promise.all(Array.from({ length: Math.min(PARSE_CONCURRENCY, files.length) }, worker));
@@ -323,11 +372,7 @@ export async function parseSpecs(files, displayRoot) {
323
372
  plan.scenarios.push(...one.scenarios);
324
373
  plan.paramRefs.push(...one.paramRefs);
325
374
  }
326
- return plan;
327
- }
328
- /** Parse every `*.spec.ts` under root into one merged plan (file paths shown relative to root). */
329
- export async function parseAllSpecFiles(root) {
330
- return parseSpecs(await findFiles(root, isSpecFile), root);
375
+ return { plan, issues: failures.filter((i) => i !== undefined) };
331
376
  }
332
377
  /**
333
378
  * Spec-shaped files sitting under `root/changes` — the location a change's
@@ -109,9 +109,13 @@ export async function runCheck(root, options = {}) {
109
109
  // only one where that distinction is worth anything.
110
110
  return withLoader(options, async (loader) => {
111
111
  const { registry, issues, unreadableFiles } = await readRegistry(root, options, scan.reqsFiles, loader);
112
- const plan = await parseSpecs(scan.specFiles, root);
112
+ const { plan, issues: unreadableSpecs } = await parseSpecs(scan.specFiles, root);
113
113
  return [
114
114
  ...issues,
115
+ // A spec the parser could not read is reported here rather than dropped:
116
+ // `check`'s contract is breadth, and a file silently contributing no
117
+ // scenarios reads as a file with no scenarios (ATX-65).
118
+ ...unreadableSpecs,
115
119
  // `check` keeps reporting on a registry that only half-loaded,
116
120
  // deliberately: its contract is breadth, and the findings from the files
117
121
  // that *did* load are all still true. What it must not do is advise work
@@ -220,7 +224,14 @@ async function unclaimedProposedSpecIssues(root, scan, options, loader) {
220
224
  for (const id of claimedIds(read.delta))
221
225
  claimed.add(id);
222
226
  }
223
- const proposed = await parseSpecs(scan.proposedSpecFiles, root);
227
+ const { plan: proposed, issues: unreadable } = await parseSpecs(scan.proposedSpecFiles, root);
228
+ issues.push(...unreadable);
229
+ // A file that would not parse declares no scenarios, so the loop below would
230
+ // find it claimed by nothing and call it unclaimed — a true sentence about a
231
+ // file whose real problem is that it could not be read, and a fix hint
232
+ // pointing at work that must not be done. Same shape as `orphan-test` after a
233
+ // registry fails to load (ATX-62), and refused here for the same reason.
234
+ const unreadableFiles = new Set(unreadable.map((i) => i.file));
224
235
  const claimedFiles = new Set(proposed.scenarios.filter((s) => claimed.has(s.reqId)).map((s) => s.file));
225
236
  // Reported per file, not per scenario: the file is the unit a run includes,
226
237
  // so it is the unit that did or did not execute, and one line per scenario
@@ -230,7 +241,7 @@ async function unclaimedProposedSpecIssues(root, scan, options, loader) {
230
241
  // nothing for a second reason, and reading the plan alone cannot see it.
231
242
  for (const abs of scan.proposedSpecFiles) {
232
243
  const file = relativePath(root, abs);
233
- if (claimedFiles.has(file))
244
+ if (claimedFiles.has(file) || unreadableFiles.has(file))
234
245
  continue;
235
246
  issues.push({
236
247
  level: 'ERROR',
@@ -287,7 +298,9 @@ export async function runVerify(root, options = {}) {
287
298
  registry = loaded.registry;
288
299
  issues.push(...loaded.issues);
289
300
  unreadableFiles = loaded.unreadableFiles;
290
- plan = await parseSpecs(scan.specFiles, root);
301
+ const parsedSpecs = await parseSpecs(scan.specFiles, root);
302
+ plan = parsedSpecs.plan;
303
+ issues.push(...parsedSpecs.issues);
291
304
  }
292
305
  finally {
293
306
  await loader.close();
@@ -365,7 +378,7 @@ export async function runCover(root, options = {}) {
365
378
  const { registry, issues: loadIssues } = await readRegistry(root, options, scan.reqsFiles);
366
379
  if (hasError(loadIssues))
367
380
  return { rows: [], issues: loadIssues };
368
- const plan = await parseSpecs(scan.specFiles, root);
381
+ const { plan, issues: unreadableSpecs } = await parseSpecs(scan.specFiles, root);
369
382
  const counts = new Map();
370
383
  for (const s of plan.scenarios) {
371
384
  counts.set(s.reqId, (counts.get(s.reqId) ?? 0) + 1);
@@ -381,7 +394,7 @@ export async function runCover(root, options = {}) {
381
394
  covered: (counts.get(reqId) ?? 0) > 0,
382
395
  scenarioCount: counts.get(reqId) ?? 0,
383
396
  }));
384
- return { rows, issues: [...loadIssues, ...uncoveredIssues(registry, plan)] };
397
+ return { rows, issues: [...loadIssues, ...unreadableSpecs, ...uncoveredIssues(registry, plan)] };
385
398
  }
386
399
  /**
387
400
  * Markdown projection of the intent layer (design §9: `attest render`).
@@ -607,10 +620,15 @@ function claimedByDelta(proposed, delta) {
607
620
  * walking the tree again, so a caller that already scanned does not repeat it.
608
621
  */
609
622
  async function changeMergedPlan(root, delta, scan) {
610
- const basePlan = await parseSpecs(scan.specFiles, root);
611
- const proposed = await parseSpecs(scan.proposedSpecFiles, root);
612
- const claimed = claimedByDelta(proposed, delta);
623
+ const base = await parseSpecs(scan.specFiles, root);
624
+ const proposedSpecs = await parseSpecs(scan.proposedSpecFiles, root);
625
+ const basePlan = base.plan;
626
+ const claimed = claimedByDelta(proposedSpecs.plan, delta);
613
627
  return {
628
+ // A spec neither parse could read blocks the gate rather than vanishing
629
+ // from it: an unreadable proposed spec is a scenario the gate would
630
+ // otherwise report as absent (ATX-65).
631
+ issues: [...base.issues, ...proposedSpecs.issues],
614
632
  merged: {
615
633
  scenarios: [...basePlan.scenarios, ...claimed.scenarios],
616
634
  paramRefs: [...basePlan.paramRefs, ...claimed.paramRefs],
@@ -722,7 +740,12 @@ async function archiveRun(root, changeName, options = {}) {
722
740
  // Static plan = merged base suite + this change's specs (design §8), by the
723
741
  // same function `status` reports against — a progress report computed over a
724
742
  // different spec set than the gate uses would be a report about nothing.
725
- const { merged: plan, claimed } = await changeMergedPlan(root, delta, scan);
743
+ const { merged: plan, claimed, issues: unreadableSpecs } = await changeMergedPlan(root, delta, scan);
744
+ // Before the run, not after: an unreadable spec means the gate cannot see
745
+ // what that file declared, so letting the suite start would grade the
746
+ // change against a plan known to be short (ATX-65).
747
+ if (unreadableSpecs.length > 0)
748
+ return { issues: unreadableSpecs };
726
749
  // Other proposals need no exclude glob of their own: the include list below
727
750
  // is the plan's own files, and the plan holds only the proposed specs this
728
751
  // delta claims. That is what replaced `**/changes/<sibling>/**` — with the
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@am_shork/attest",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "TDD-native spec framework: tests are the source of truth for verification, ID-bound requirements the source of truth for intent.",
5
5
  "type": "module",
6
+ "packageManager": "pnpm@10.28.0",
6
7
  "engines": {
7
8
  "node": ">=20.19"
8
9
  },
@@ -36,6 +37,24 @@
36
37
  "publishConfig": {
37
38
  "access": "public"
38
39
  },
40
+ "scripts": {
41
+ "clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
42
+ "build": "pnpm run clean && tsc -p tsconfig.json",
43
+ "typecheck": "tsc -p tsconfig.json --noEmit",
44
+ "typecheck:all": "tsc -p tsconfig.typecheck.json",
45
+ "test": "vitest run",
46
+ "test:watch": "vitest",
47
+ "test:consumer": "pnpm run build && vitest run --config vitest.consumer.config.ts",
48
+ "lint": "eslint .",
49
+ "prepack": "pnpm run build",
50
+ "prepublishOnly": "pnpm run test:consumer",
51
+ "attest": "node bin/attest.js",
52
+ "check:self": "node bin/attest.js check self",
53
+ "verify:self": "node bin/attest.js verify self",
54
+ "cover:self": "node bin/attest.js cover self",
55
+ "render:self": "node bin/attest.js render self --out self/requirements/SPEC.md",
56
+ "render:self:check": "node bin/attest.js render self --out self/requirements/SPEC.md --check"
57
+ },
39
58
  "keywords": [
40
59
  "tdd",
41
60
  "spec",
@@ -62,20 +81,13 @@
62
81
  "vite": "^8.1.5",
63
82
  "vitest": "^4.1.10"
64
83
  },
65
- "scripts": {
66
- "clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
67
- "build": "pnpm run clean && tsc -p tsconfig.json",
68
- "typecheck": "tsc -p tsconfig.json --noEmit",
69
- "typecheck:all": "tsc -p tsconfig.typecheck.json",
70
- "test": "vitest run",
71
- "test:watch": "vitest",
72
- "test:consumer": "pnpm run build && vitest run --config vitest.consumer.config.ts",
73
- "lint": "eslint .",
74
- "attest": "node bin/attest.js",
75
- "check:self": "node bin/attest.js check self",
76
- "verify:self": "node bin/attest.js verify self",
77
- "cover:self": "node bin/attest.js cover self",
78
- "render:self": "node bin/attest.js render self --out self/requirements/SPEC.md",
79
- "render:self:check": "node bin/attest.js render self --out self/requirements/SPEC.md --check"
84
+ "pnpm": {
85
+ "onlyBuiltDependencies": [
86
+ "esbuild"
87
+ ],
88
+ "overrides": {
89
+ "brace-expansion@<5.0.9": ">=5.0.9",
90
+ "js-yaml@<4.3.1": ">=4.3.1"
91
+ }
80
92
  }
81
- }
93
+ }