@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 +334 -57
- package/README.md +1 -1
- package/bin/attest.js +0 -0
- package/dist/cli/action.d.ts +48 -0
- package/dist/cli/action.js +100 -0
- package/dist/cli/index.js +11 -32
- package/dist/cli/report.js +9 -1
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +1 -0
- package/dist/core/locate.d.ts +4 -3
- package/dist/core/locate.js +53 -8
- package/dist/core/pipeline.js +33 -10
- package/package.json +29 -17
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
|
-
[
|
|
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.
|
|
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,
|
|
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
|
};
|
package/dist/cli/report.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/core/docs.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* and the `##` headings of both language documents, so landing here cannot
|
|
9
9
|
* produce a dead link.
|
|
10
10
|
*/
|
|
11
|
-
export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "non-scalar-interpolation", "orphan-test", "possible-drift", "proposed-spec-name-taken", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "spec-in-change-dir", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target"];
|
|
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
package/dist/core/locate.d.ts
CHANGED
|
@@ -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<
|
|
156
|
-
|
|
157
|
-
|
|
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).
|
package/dist/core/locate.js
CHANGED
|
@@ -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
|
|
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
|
|
317
|
-
|
|
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
|
package/dist/core/pipeline.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
611
|
-
const
|
|
612
|
-
const
|
|
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.
|
|
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
|
-
"
|
|
66
|
-
"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
"
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
}
|