@am_shork/attest 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -13,6 +13,288 @@ 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.3] - 2026-08-07
17
+
18
+ ### Fixed
19
+
20
+ - **A loader that never opened left its scratch directory behind — one per
21
+ `attest` invocation, i.e. one per CI build.** `createLoader` creates the
22
+ directory and writes the stub, and the only `rm` of it lives inside the `close`
23
+ of the object it returns — so nothing between those two points can be cleaned
24
+ up by anyone, because until the caller holds that object no `close` exists to
25
+ call. It is the exact leak the comment on `close` records as already fixed,
26
+ surviving on the other side of the same function. The window is now closed by
27
+ the function that opened it.
28
+ *Stated over the window rather than over `createServer`, which is where it was
29
+ found.* `writeFileSync` is inside it too, and a full disk or a temp directory
30
+ that turns read-only between the two calls reaches it with no project
31
+ involved — so scoping the repair to the call it was noticed at would have left
32
+ half of it.
33
+ *The trigger this was filed on does not reproduce, and that is the useful
34
+ half.* The entry proposed a malformed `package.json` at the root, on the
35
+ reasoning that `configFile: false` still leaves Vite reading it for `type`.
36
+ Measured: it starts a server cleanly, as do eight other shapes — a
37
+ `package.json` holding an array, one that is a directory, an unknown `type`, a
38
+ `node_modules` occupied by a file, a `node_modules/.vite` occupied by a file, a
39
+ `.env` that is a directory, a deleted root, and a well-formed control. Nine
40
+ candidates, zero throws. **`configFile: false` is why**, and it is worth
41
+ recording as a property rather than an accident: the option set chosen to stop
42
+ the loader reading a project's Vite config, and to stop it opening a socket,
43
+ also leaves almost no project I/O that can fail hard. The throw surface was
44
+ closed as a side effect of decisions taken for other reasons.
45
+ *So the repair is justified by ownership rather than by a symptom*, which is a
46
+ weaker case than this file usually accepts and is why the alternative was to
47
+ reject the entry outright. What decides it is the asymmetry of cost: the fan-out
48
+ entry below stays open because bounding concurrency changes how every run
49
+ behaves, whereas a `try` around a window nothing currently reaches changes
50
+ nothing observable on any path that exists. A resource whose ownership transfer
51
+ is conditional on nothing throwing is a shape, and the shape is what recurs.
52
+ *A cleanup that fails must not replace the error that caused it*, so the `rm`
53
+ swallows its own failure and the original throw propagates unchanged. The
54
+ regression test asserts the identity of what comes out, not its wording.
55
+ **`close` had the same hazard and now makes the same choice, which is a change
56
+ to an existing path rather than a new one.** Its `rm` runs in a `finally`, so a
57
+ failed cleanup replaced whatever `close` was doing — and all five callers in
58
+ `pipeline.ts` call `close` from a `finally` of their own, where the damage is
59
+ larger than symmetry: a real error on the way out is lost, and a *successful*
60
+ command becomes a crash about a temp directory. What that reader is handed is a
61
+ misdiagnosis pointing at their own correct files, which this project already
62
+ treats as worse than the gap it fills — the argument `ATX-49` records against
63
+ reporting a load failure as a wall of skipped scenarios, in a much smaller
64
+ place. Given up with it: the one signal a failed cleanup could have raised,
65
+ which no caller could have acted on.
66
+ *The test goes red without the fix, which is the only reason it is worth
67
+ having.* Verified by reverting `src/` and re-running: `expected [
68
+ 'attest-loader-z7jhoK' ] to deeply equal []`, the leaked directory named in the
69
+ failure. It injects the throw at the module boundary — no project input could
70
+ produce one — and mocks `tmpdir` to a directory of its own, because on the
71
+ throwing path there is no loader to ask for its `scratchDir` and counting
72
+ `attest-loader-*` in the shared temp directory is the flake the lifecycle test
73
+ beside it already documents.
74
+ *It is its own file*, which is a rule this repo already wrote down and which
75
+ the first draft broke: `tests/locate-fanout.spec.ts` is separate because
76
+ `vi.mock` is file-scoped and it mocks a Node builtin, and the four lifecycle
77
+ tests boot the *real* Vite loader — sharing a module registry with a
78
+ `createServer` that throws is exactly what that convention exists to prevent.
79
+ The first attempt scoped the mocks with `doMock` and `resetModules` inside the
80
+ shared file instead, which is machinery bought to work around a constraint the
81
+ repo had already decided how to solve. Following it deleted the machinery.
82
+ *No new requirement, and the precedent cuts the other way, so it is worth
83
+ saying why.* `ATX-36` is the sibling property — what a live loader leaves on the
84
+ user's machine, stated as a count, with a scenario in `self/` — and "leaves no
85
+ scratch directory" is the same shape. What decides against filing one here is
86
+ that the success-path half has never had a requirement either, so a new one
87
+ covering only the throwing path would state half a contract and cost a
88
+ permanent scenario for the half nothing reaches. If it is ever filed it should
89
+ be filed whole, and that is a larger change than this fix.
90
+
91
+ - **The `cmd.exe` guard in `tests/consumer.spec.ts` checked one character of a
92
+ class, and the comment above it claimed the quoting was doing work it cannot
93
+ do here.** `shellArg` asserted the argument held no `"` and then quoted only
94
+ when it held whitespace, so an argument with neither went to the shell bare
95
+ with `&`, `|`, `^`, `<`, `>`, `(` unexamined. Measured against the twelve
96
+ shapes: the old assertion let **11 of 12** through.
97
+ *What decided it, and it is the thing the entry this replaces did not have.*
98
+ That entry framed the choice as "widen the class, or fix the prose", and
99
+ argued that widening while leaving the prose is the change that looks like a
100
+ fix and settles nothing. Both halves turn on a fact neither named: **every
101
+ command this suite spawns is a `.cmd`**, which is the whole reason
102
+ `NEEDS_SHELL` exists, and a batch file expands `%*` into its own line *before*
103
+ anything parses it — so a metacharacter survives double quotes and is acted on
104
+ at the far end. That is the argument-injection hole Node closed in 22.x,
105
+ arrived at the long way round. So the quoting is not a defence that the
106
+ assertion merely backstops; **the assertion is the only defence**, and a
107
+ defence that covers one character of its class is the defect rather than the
108
+ prose being generous. Both are fixed together: the class is named, and the
109
+ comment now says the quotes do whitespace and nothing else.
110
+ *`%` and `!` are in the class for a different reason* — expansion rather than
111
+ syntax, and `!` only under delayed expansion, which the spawned shell does not
112
+ enable but a batch file can enable for itself. Neither costs anything to
113
+ refuse.
114
+ *The cost, which is real and is the right cost.* Windows permits `&`, `(` and
115
+ `%` in a user name, and `os.tmpdir()` sits under it — so on such a machine this
116
+ suite now stops with *"argument would need escaping, not quoting"* instead of
117
+ running. That is correct rather than a false red: through a batch target those
118
+ arguments genuinely are not safe, and the invariant the assertion guards is
119
+ that every argument is a literal flag or a path from `mkdtemp`. Stopping is
120
+ right even where quoting would have survived it, because the value is then not
121
+ the kind of value the helper accepts.
122
+ *What is measured, and what is not.* `pnpm test:consumer` was run on Windows,
123
+ where `NEEDS_SHELL` is true and the changed path actually executes: 11 tests
124
+ passed, none skipped — the distinction that matters, since the failure this
125
+ file's own comment records reported ten tests as *skipped* at zero run. CI is
126
+ Linux, where `shellArg` returns before the assertion, so the pipeline says
127
+ nothing about this either way. Not measured end to end: an argument carrying a
128
+ metacharacter actually reaching the guard, because no call site can produce
129
+ one — the class is verified as a predicate over the twelve shapes above and
130
+ the sixteen arguments the suite really passes, not by driving the suite red.
131
+
132
+ - **A statement or rationale can no longer become HTML, or open a section of
133
+ its own.** `sanitised` is the entry §9.1 names and it strips control
134
+ characters and nothing else, so `statement` and `rationale` reached the
135
+ Markdown by concatenation exactly as written. Measured: a `rationale`
136
+ containing a newline and `## ATX-999` rendered a whole section — heading,
137
+ statement, `**Why:**` — for a requirement no registry holds, sitting between
138
+ two real ones and worded identically, with the overview table above it listing
139
+ three ids and the body carrying four sections. `<script>` and `<img onerror>`
140
+ survived the same way into a file that is committed and served. `check` and
141
+ `cover` saw nothing, because the id is in no registry, and `render --check`
142
+ could not object, because that *was* what the registry rendered to.
143
+ *Which of the three is intended, which is the part the fix had to decide.*
144
+ Marking prose up stays — `plain` escapes a param value precisely because the
145
+ value is data and the sentence around it is not — and that decision had never
146
+ been separated from allowing raw HTML or a heading. It is now a **fifth
147
+ obligation in design §9.1** rather than a patch at an emitter: prose may be
148
+ marked up, it may not become a tag, and it may not open a section. Stated as a
149
+ count over the document — one heading per requirement id — because what a
150
+ reader trusts is that a section means a requirement (`ATX-67`).
151
+ *Escaped rather than refused, on an asymmetry.* A rule that misses something
152
+ ships the document it ships today; a rule that fires wrongly makes a
153
+ legitimate rationale a hard error in the one command whose value is that it
154
+ always produces the document — the `ATX-29` argument about `--check` seen from
155
+ the other side. So there is no new diagnostic and no exit code moves.
156
+ *And escaped for the position, which is what keeps it lossless.* A code span
157
+ is literal text in every renderer, so `&lt;` written inside one is **visible**
158
+ — Markdown does not decode entities there, and this repository documents
159
+ `attest status <change>` in a statement. The scan therefore tracks spans and
160
+ leaves their contents alone, conservatively: a backtick run opens a span only
161
+ if a run of exactly that length closes it, and an unmatched run is text with
162
+ the defence still applied after it.
163
+ *One rendering change beyond the fix, and it is a repair.* A param
164
+ substituted inside the author's own code span was emitted as `**value**`,
165
+ which in a code span is two asterisks around the value; knowing the position
166
+ is what removes them. `SPEC.md` in this repository moves by 12 lines, all of
167
+ that shape — `` `**changes/<name>/first-run.json**` `` becomes
168
+ `` `changes/<name>/first-run.json` `` — and no `&lt;` appears anywhere in it,
169
+ which is the measurement that the span exception works on real prose.
170
+ *The defence had the defect it was written next to, and `ATX-59` gained a
171
+ third shape because of it.* The first code-span scan searched forward from
172
+ each backtick run for a run of the same length, so a rationale whose runs are
173
+ all of *different* lengths paid a scan to the end of the string for every one
174
+ of them: 500 KB cost 1.6 s and 2 MB cost 15 s, from `attest render` with no
175
+ flag, on text the registry chooses — the same "a registry must not decide what
176
+ rendering costs" defect the padded statement and the backtick-run param
177
+ already stand for. The runs are now read once and indexed by length, each
178
+ length holding a forward-only cursor, which is linear: 40 ms at 2 MB. It is
179
+ filed under `ATX-59` rather than here because the obligation is that one, and
180
+ it is worth saying where the site came from — the *defence another requirement
181
+ added*, which is the direction the next one will arrive from too.
182
+ *This leaves `Planned`.* The forged section was reproduced first, then the
183
+ four scenarios were run against the pre-fix renderer: three went red, and the
184
+ fourth — the setext underline, which heads the line *above* it — went green,
185
+ because counting `##` lines cannot see a heading that has no `##` in it. That
186
+ scenario was rewritten to assert the neutralised underline instead. A scenario
187
+ that cannot fail is the defect `archive` exists to catch, and it was caught
188
+ here only by running it against the old code.
189
+
190
+ - **`attest init` no longer follows a symbolic link out of the project, and
191
+ refuses the whole run when a destination leads there.** `runInit` did
192
+ `join(root, target.file)`, `mkdir -p` and then the write, and nothing asked
193
+ whether the path was still under `root` once the filesystem had had its say.
194
+ A `.claude/skills/attest` planted in a repository as a link to anywhere the
195
+ user can write was followed by both: measured on Windows with a junction, a
196
+ file at the far end holding `USER OWNED FILE - DO NOT OVERWRITE` came back
197
+ holding the skill, and the report printed the relative path, so nothing on
198
+ screen said where it had gone. Every destination is now resolved segment by
199
+ segment before the first byte of any of them is written, and one that escapes
200
+ is an `unsafe-target-path` ERROR that writes nothing at all (`ATX-66`).
201
+ *What the exposure was, stated precisely, because two nearby things are not
202
+ it.* `writeAtomic`'s exclusive creation is not the gap and was never scoped to
203
+ be one: it defends the *temporary* path against a planted link, and it does
204
+ that perfectly in whichever directory the destination turns out to be. And
205
+ `workflowBody()` is a pure function of nothing, so no byte of the checked
206
+ project reaches the file — there is nothing to exfiltrate. What was exposed is
207
+ the **path**, which is `targets.ts`'s first scoping rule ("Attest owns the
208
+ path") having been an assumption rather than a check.
209
+ *Why the check is a resolution rather than a comparison.* `join` normalises
210
+ `..`, so a lexical containment test was already satisfied — the escape is a
211
+ segment that *is* a link, which only `realpath` can see. Containment itself
212
+ goes through `isInside` in `src/core/paths.ts`, asked as a relative path
213
+ rather than a string prefix: `/repo-backup` starts with `/repo`, and on
214
+ Windows a path on another drive has no relative spelling at all, so
215
+ `path.relative` answers with an absolute one instead of a chain of `..`. Both
216
+ read as contained under `startsWith`.
217
+ *The limit, recorded rather than papered over.* A link planted between the
218
+ resolution and the write is not caught, and no check outside the filesystem
219
+ can catch it. The exposure this closes is one committed to a repository, which
220
+ is the one a fresh clone and every merge-request checkout hands you.
221
+ *Behaviour change:* an `init` that used to write through such a link now
222
+ fails with exit 1. The only input that reaches it is a path that was already
223
+ leaving the project. The JSON surface is unchanged beyond one more `code`
224
+ value, which is additive — no `SCHEMA_VERSION` bump.
225
+ *This leaves `Planned`*, where it was filed with the measurement above; the
226
+ first act of building it was re-running that measurement, and it reproduced.
227
+
228
+ ### Changed
229
+
230
+ - **The guidance now draws the line between a literal that is drift and a literal
231
+ that is a pin — the shipped text forbade the only defence against a shortened
232
+ domain.** `[0.7.2]` fixed the repo half: every param a scenario iterates gained
233
+ a literal pinning its extent, after three of that release's four green
234
+ mutations turned out to be a domain silently losing cases rather than the
235
+ tautology the experiment was looking for. The user-facing half was held back,
236
+ and it was not merely absent. The `init` skill's "Four things you must not do"
237
+ says **"Do not hardcode a value that lives in `params`"**, and the pin every one
238
+ of those fixes adds is, read literally, exactly that. An adopter who writes a
239
+ roster param — which the design actively pushes them toward, since a quantifier
240
+ a scenario can iterate is the shape `QUANTIFIED` recommends — was being handed
241
+ an instruction that forbids the only thing standing between them and a run that
242
+ covers less every time someone edits the list.
243
+ *The distinction is real and one sentence long*, and it is now drawn in all four
244
+ places the claim is made: the rule is about the **expectation** the system is
245
+ measured against, and the pin asserts what the **intent** is. A list a scenario
246
+ loops over is not a value under test — it is the set of cases the run covers,
247
+ so dropping a member removes a case while every assertion inside still passes,
248
+ with the expectation as independent as it ever was. That is why the `[0.5.0]`
249
+ rule (*the expectation must not be a function of the param the code under test
250
+ consumed*) does not reach it, and why a literal is the **only** independent term
251
+ available: anything derived from the list shrinks with it, so a length assertion
252
+ written as a function of the list is true at every length.
253
+ *Where it landed, and why it is four documents rather than one line appended.*
254
+ README §"killer move", the `init` skill (a sibling bullet in the model section
255
+ and a carve-out inside rule 3 itself, where the contradiction was), the
256
+ `possible-drift` troubleshooting section with a worked ✓/✗ pair, and design §11
257
+ as a residual weakness beside the tautology bullet it shares a gap with — both
258
+ languages. `[0.5.0]` records the reason this shape of change is done in one
259
+ deliberate pass rather than incrementally: it is the central claim of the tool
260
+ being reworded, and the last time it was split the halves disagreed.
261
+ *No engine change, and none is proposed here.* Nothing in this is detectable —
262
+ which surface a statement claims is prose judgement, the same judgement §11
263
+ already records as the reason obligation counting can never be an engine rule.
264
+ The counter-pressure that exists is a hand-judged table, and §11 now says so,
265
+ including what it cannot reach: a domain whose size is carried by a scalar.
266
+ *This leaves `Planned`*, where it was filed with the measurement above; the
267
+ measurement was already taken, and what it needed was the pass.
268
+
269
+ - **The release publishes through `pnpm` again, and the check that read the
270
+ attestation back is gone.** Two corrections to the provenance job shipped in
271
+ `[0.7.2]`, both from what the first real tag pipeline showed.
272
+ *`pnpm publish --provenance` works on the pinned 10.28.0; the claim that it
273
+ did not was read off `pnpm publish --help`, where the flag is absent.* The
274
+ parser knows it — `--provenanceX` is rejected with *"Did you mean
275
+ 'provenance'?"*, and `--provenance --dry-run` runs clean. A help text is a
276
+ document, not the interface; checking a flag means asking the parser. So the
277
+ job is pnpm like every other job here, `--no-git-checks` comes back with it
278
+ (pnpm inspects the branch, CI builds tags in detached HEAD), and
279
+ `COREPACK_ENABLE_STRICT=0` goes away, since it existed only so a bare `npm`
280
+ could run under a `packageManager` that pins pnpm.
281
+ *The post-publish check failed the 0.7.2 release, and the publish it failed
282
+ was correctly signed.* Six attempts over a minute all got a 404 from the
283
+ registry's attestation endpoint; the same endpoint now serves
284
+ `slsa.dev/provenance/v0.2` for `0.7.2`. Lengthening the budget was the wrong
285
+ repair, and the entry it replaces already contains the reason without drawing
286
+ the conclusion: the check runs *after* an irreversible step, so its red cannot
287
+ be cleared — the republish that would rerun it fails `EPUBLISHCONFLICT` on a
288
+ version that now exists. Every failure it can reach is therefore either a
289
+ false one or an unfixable one, on a job nobody can make green again, which
290
+ makes it a machine for teaching that red is routine — the exact habit the
291
+ `audit` job's comment refuses. A gate has to sit where the answer can still
292
+ change what happens. Verification moves to where it can: `npm audit
293
+ signatures` in an installing project, or the package page's Provenance panel.
294
+ *What is unchanged:* the attestation itself, the three preconditions, and the
295
+ fact that npm rejects the publish rather than degrading to an unsigned one if
296
+ any is missing. `0.7.2` is signed, and shipped signed, despite the red job.
297
+
16
298
  ## [0.7.2] - 2026-08-07
17
299
 
18
300
  ### Added
@@ -3375,26 +3657,6 @@ symptom are worth batching**: individually none justifies a commit, together the
3375
3657
  cost about an hour, and they are the group no gate could have found, since having
3376
3658
  no symptom is the definition of what a gate cannot see.
3377
3659
 
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
-
3398
3660
  - **Binding the `✗` samples to fixtures whose rejection is asserted.** A sample
3399
3661
  showing input the engine must *reject* can be bound the way a runnable one is,
3400
3662
  to a fixture whose rejection is the thing under test — which is what
@@ -3413,43 +3675,6 @@ no symptom is the definition of what a gate cannot see.
3413
3675
  `[0.4.3]` — that the gate and the run proving the fixture still works live in
3414
3676
  different suites — applies here identically.
3415
3677
 
3416
- - **`render`'s document can carry raw HTML and a forged requirement section.**
3417
- `sanitised` is the entry §9.1 names and it strips control characters and
3418
- nothing else, so `statement` and `rationale` reach the Markdown by
3419
- concatenation exactly as written. Measured: a `rationale` containing newlines
3420
- and `## ATX-999` renders a whole section — heading, statement, `**Why:**` —
3421
- for a requirement no registry holds, sitting between two real ones and worded
3422
- identically; `<script>` and `<img onerror>` survive the same way. `check` and
3423
- `cover` see only the real id, and `render --check` cannot object, because that
3424
- *is* what the registry renders to now.
3425
- *Not an accepted risk, which is the part worth pinning.* §9.1 and the
3426
- reference both scope their guarantee to control characters, and the
3427
- reference's heading over that paragraph is "Safe to read, and safe to keep",
3428
- with "a site generator" named in the threat it describes. What *is* a decision
3429
- is that prose may be marked up — `plain()` escapes a param value precisely
3430
- because it is data while the statement around it is not — and that decision
3431
- has never been separated from allowing raw HTML or a heading. So the entry is
3432
- as much about stating which of the three is intended as about the escaping,
3433
- and if the answer is that Markdown stays and HTML and headings go, that is a
3434
- fourth obligation in §9.1 rather than a patch at an emitter.
3435
-
3436
- - **`attest init` follows a symlink out of the project root.** `runInit` does
3437
- `mkdir(dirname(dest), { recursive: true })` and then `writeAtomic`, and
3438
- neither asks whether `dest` is still under `root`. A `.claude/skills/attest`
3439
- planted in the repository as a link to anywhere the user can write is followed
3440
- by both: measured on Windows with a junction, a file at the target holding
3441
- `USER OWNED FILE - DO NOT OVERWRITE` came back holding the skill. The report
3442
- prints the relative path, so nothing on screen says where it went.
3443
- *`write.ts` is not the gap and neither is `targets.ts`'s content.* The `wx`
3444
- flag defends the *temporary* path against a planted link and does exactly
3445
- that; the destination's directory was never in its scope. And `workflowBody()`
3446
- is a pure function of nothing, so no byte of the checked project reaches the
3447
- file — the exposure is the path, not the content, which is `targets.ts`'s
3448
- first rule ("Attest owns the path") being an assumption rather than a check.
3449
- The check belongs beside `resolveTargets`, which is already all-or-nothing for
3450
- the same reason: a name known to be unwritable must not leave half a set
3451
- behind.
3452
-
3453
3678
  ## Under consideration
3454
3679
 
3455
3680
  Candidates with **no decision yet**, between the two sections either side of it
@@ -3463,7 +3688,7 @@ decision, because that is what a re-proposal a year from now would otherwise
3463
3688
  have to gather again.
3464
3689
 
3465
3690
  - **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
3691
+ The provenance work in `[0.7.2]` signs the release but does not change
3467
3692
  what authorises it: a long-lived automation token, masked and protected, that
3468
3693
  publishes as us to anyone holding it. npm's trusted publishing exchanges the
3469
3694
  same OIDC identity the attestation already uses for a short-lived credential,
@@ -3837,41 +4062,6 @@ have to gather again.
3837
4062
  because a project whose requirement count only rises should have decided in
3838
4063
  advance what would make one leave.
3839
4064
 
3840
- - **The `cmd.exe` quoting in `tests/consumer.spec.ts` guards one character of
3841
- several.** `shellArg` asserts the argument holds no `"` and then quotes only
3842
- when it holds whitespace, so an argument with none goes to the shell bare —
3843
- and `&`, `|`, `^`, `<`, `>`, `(` are metacharacters there that the assertion
3844
- does not name. The fix is one character class, which is why the entry is not
3845
- about the fix.
3846
- *What blocks the decision is that no input reaches it.* Both arguments are
3847
- either a literal flag or a path from `mkdtemp`, `NEEDS_SHELL` is win32 only
3848
- and CI is Linux, and the one path an attacker could plausibly influence — the
3849
- checkout directory on a fork MR pipeline — comes from a GitLab project path,
3850
- whose character set excludes every metacharacter above. So this is an
3851
- observation with no attack path, and the honest question is not whether to
3852
- widen the class but whether the comment above it should stop claiming more
3853
- than the assertion covers: it says "the assertion keeps that true" about
3854
- arguments generally, where what it checks is one character. Widening the class
3855
- and leaving the prose is the change that would look like a fix and settle
3856
- nothing.
3857
-
3858
- - **A loader that fails to start leaves its scratch directory behind.**
3859
- `createLoader` calls `mkdtempSync` and writes the stub *before* `createServer`,
3860
- and the only `rm` of that directory is inside the `close` of the object
3861
- `createServer` returns — so a throw from it leaks one `attest-loader-*` per
3862
- invocation, which on a CI runner is one per build. That is the exact failure
3863
- shape the comment on `close` records as already fixed, surviving on the other
3864
- path.
3865
- *What blocks it is that nothing has been made to throw there.* With
3866
- `configFile: false` Vite still reads the `package.json` at the root to decide
3867
- `type`, so a malformed one in the checked project is the obvious candidate and
3868
- it is the project's to write — but it was not tried, and an entry claiming a
3869
- trigger it has not seen is the thing this section exists to not accumulate.
3870
- The repair is three lines (a `try` around the two calls that `rm`s and
3871
- rethrows) and is not what the decision turns on; measuring whether the throw
3872
- is reachable at all is, because if it is not, this is a leak with no input
3873
- that produces it.
3874
-
3875
4065
  ## Considered and rejected
3876
4066
 
3877
4067
  Decisions **not** to build something, kept where they can be found before the
@@ -4196,6 +4386,7 @@ requirement, and human review at propose is still the whole answer. What is
4196
4386
  rejected is grouping as a way to assist it, and this reopens only on a
4197
4387
  contradiction that grouping would have caught.
4198
4388
 
4389
+ [0.7.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.3
4199
4390
  [0.7.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.2
4200
4391
  [0.7.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.1
4201
4392
  [0.7.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.0
package/README.md CHANGED
@@ -29,6 +29,15 @@ together and the test stays green through any edit. Pin the expectation to
29
29
  something that does not move with the param — a fixture, a literal in the test,
30
30
  or a second independently derived value.
31
31
 
32
+ A composite param has a second failure of its own, and it runs the other way. When
33
+ a test **loops over** a list it read from `params`, that list is the set of cases
34
+ the run covers: drop a member and every assertion inside the loop still passes
35
+ over what is left, so the suite quietly tests less with nothing to show for it.
36
+ Pin the extent beside the loop — the members against a literal when their identity
37
+ is the promise, the length when the size is. That literal is not the copy the
38
+ single source exists to prevent: it is not what the system is measured against,
39
+ it is what the intent claimed to cover.
40
+
32
41
  ## Prerequisites
33
42
 
34
43
  - Node ≥ 20.19
@@ -157,7 +166,7 @@ Every diagnostic carries a `code`, and every code has a section in
157
166
  ```
158
167
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
159
168
  Value is not a literal.
160
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.7.2/docs/en/troubleshooting.md#registry-not-static
169
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.7.3/docs/en/troubleshooting.md#registry-not-static
161
170
  ```
162
171
 
163
172
  The anchor **is** the code, so the link cannot point somewhere the section
package/bin/attest.js CHANGED
File without changes
@@ -74,8 +74,9 @@ export declare function renderReport(version: string, issues: Issue[], outFile?:
74
74
  /**
75
75
  * `init`. Carries the paths written so a consumer learns where the instructions
76
76
  * went without hardcoding the convention. `issues` is empty on the success path
77
- * — `init` writes or throws — apart from an `unknown-target` ERROR, which is
78
- * refused before anything is written and so reports no files at all.
77
+ * — `init` writes or throws — apart from the two ERRORs it refuses on,
78
+ * `unknown-target` and `unsafe-target-path`. Both are decided before anything is
79
+ * written and so report no files at all.
79
80
  *
80
81
  * `outFile` survives beside `outFiles` for the one-file run, which is what the
81
82
  * default invocation still is: dropping it would break the consumer the field
package/dist/cli/json.js CHANGED
@@ -92,8 +92,9 @@ export function renderReport(version, issues, outFile) {
92
92
  /**
93
93
  * `init`. Carries the paths written so a consumer learns where the instructions
94
94
  * went without hardcoding the convention. `issues` is empty on the success path
95
- * — `init` writes or throws — apart from an `unknown-target` ERROR, which is
96
- * refused before anything is written and so reports no files at all.
95
+ * — `init` writes or throws — apart from the two ERRORs it refuses on,
96
+ * `unknown-target` and `unsafe-target-path`. Both are decided before anything is
97
+ * written and so report no files at all.
97
98
  *
98
99
  * `outFile` survives beside `outFiles` for the one-file run, which is what the
99
100
  * default invocation still is: dropping it would break the consumer the field
@@ -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", "unreadable-file"];
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", "unsafe-target-path"];
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
@@ -56,6 +56,7 @@ export const ISSUE_CODES = [
56
56
  'uncovered-requirement',
57
57
  'unknown-target',
58
58
  'unreadable-file',
59
+ 'unsafe-target-path',
59
60
  ];
60
61
  const DOCUMENTED = new Set(ISSUE_CODES);
61
62
  const REPO = 'https://gitlab.com/Pseudorca/attest/-/blob';
@@ -60,38 +60,59 @@ const VITEST_STUB = 'export const describe=()=>{};export const it=()=>{};export
60
60
  'export default {};';
61
61
  export async function createLoader() {
62
62
  const dir = mkdtempSync(join(tmpdir(), 'attest-loader-'));
63
- const stub = join(dir, 'vitest-stub.mjs');
64
- writeFileSync(stub, VITEST_STUB);
65
- const server = await createServer({
66
- configFile: false,
67
- logLevel: 'error',
68
- // Not just quiet — sanitised. See `sanitisedLogger`: what survives
69
- // `logLevel: 'error'` is exactly the message that carries the checked
70
- // project's own text.
71
- customLogger: sanitisedLogger(),
72
- appType: 'custom',
73
- // `ws: false` is load-bearing, and `middlewareMode` is not enough on its
74
- // own: it suppresses the HTTP server but Vite still starts the HMR
75
- // WebSocket server, which binds `::` — every interface, not loopback — on
76
- // port 24678. Every `attest` command that reads a registry by evaluating it
77
- // therefore opened a network port for the length of the run, on a
78
- // developer's machine and on every CI runner, for a channel that does
79
- // nothing here: nothing subscribes to HMR, because nothing is watching.
80
- //
81
- // The visible symptom was the collision. Two attest processes at once —
82
- // ordinary in a CI matrix, and what this repo's own concurrent specs do —
83
- // and the second printed `WebSocket server error: Port is already in use`
84
- // into the middle of a report, through a `logLevel: 'error'` that was
85
- // supposed to have silenced the loader entirely.
86
- //
87
- // `hmr: false` does *not* close it (measured, Vite 5.4): the ws server is
88
- // created before the hmr option is consulted. `ws: false` is the one that
89
- // leaves no listening handle at all.
90
- server: { middlewareMode: true, ws: false },
91
- resolve: { alias: { vitest: stub } },
92
- ssr: { noExternal: ['vitest'] },
93
- optimizeDeps: { noDiscovery: true },
94
- });
63
+ // Everything between creating the directory and returning its owner runs
64
+ // inside this `try`, because until the caller holds the object below, nothing
65
+ // else can call `close` — so a throw here is the one path where the directory
66
+ // outlives the only code that knows about it. That is the same leak the
67
+ // comment on `close` records as already fixed, surviving on the other side of
68
+ // the same function.
69
+ //
70
+ // Stated over the window rather than over `createServer`, which is where it
71
+ // was found: `writeFileSync` is inside it too, and a disk that is full or a
72
+ // temp directory that turns read-only between the two calls reaches it without
73
+ // any project being involved.
74
+ let server;
75
+ try {
76
+ const stub = join(dir, 'vitest-stub.mjs');
77
+ writeFileSync(stub, VITEST_STUB);
78
+ server = await createServer({
79
+ configFile: false,
80
+ logLevel: 'error',
81
+ // Not just quiet sanitised. See `sanitisedLogger`: what survives
82
+ // `logLevel: 'error'` is exactly the message that carries the checked
83
+ // project's own text.
84
+ customLogger: sanitisedLogger(),
85
+ appType: 'custom',
86
+ // `ws: false` is load-bearing, and `middlewareMode` is not enough on its
87
+ // own: it suppresses the HTTP server but Vite still starts the HMR
88
+ // WebSocket server, which binds `::` every interface, not loopback on
89
+ // port 24678. Every `attest` command that reads a registry by evaluating
90
+ // it therefore opened a network port for the length of the run, on a
91
+ // developer's machine and on every CI runner, for a channel that does
92
+ // nothing here: nothing subscribes to HMR, because nothing is watching.
93
+ //
94
+ // The visible symptom was the collision. Two attest processes at once —
95
+ // ordinary in a CI matrix, and what this repo's own concurrent specs do —
96
+ // and the second printed `WebSocket server error: Port is already in use`
97
+ // into the middle of a report, through a `logLevel: 'error'` that was
98
+ // supposed to have silenced the loader entirely.
99
+ //
100
+ // `hmr: false` does *not* close it (measured, Vite 5.4): the ws server is
101
+ // created before the hmr option is consulted. `ws: false` is the one that
102
+ // leaves no listening handle at all.
103
+ server: { middlewareMode: true, ws: false },
104
+ resolve: { alias: { vitest: stub } },
105
+ ssr: { noExternal: ['vitest'] },
106
+ optimizeDeps: { noDiscovery: true },
107
+ });
108
+ }
109
+ catch (err) {
110
+ // The original error is what the caller has to see, so a cleanup that fails
111
+ // must not replace it. `force` already forgives a directory that is not
112
+ // there; this forgives one that will not go.
113
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
114
+ throw err;
115
+ }
95
116
  let closed = false;
96
117
  return {
97
118
  scratchDir: dir,
@@ -111,7 +132,16 @@ export async function createLoader() {
111
132
  await server.close();
112
133
  }
113
134
  finally {
114
- await rm(dir, { recursive: true, force: true });
135
+ // Swallowed for the same reason as the `rm` on the failing path above,
136
+ // and it counts for more here: every caller closes from a `finally`, so
137
+ // a throw from this line replaces whatever the block was doing — the
138
+ // real error on the way out, or a *successful* return turned into a
139
+ // crash about a temp directory. What that reader would then be handed
140
+ // is a misdiagnosis pointing at their own correct files, which this
141
+ // repository already treats as worse than the gap it fills.
142
+ // Given up with it: the one signal a failed cleanup could have raised,
143
+ // which no caller could have acted on anyway.
144
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
115
145
  }
116
146
  },
117
147
  };
@@ -12,4 +12,20 @@
12
12
  export declare function toPosixPath(path: string, separator?: string): string;
13
13
  /** `path.relative`, in the one spelling the rest of the engine expects. */
14
14
  export declare function relativePath(from: string, to: string): string;
15
+ /**
16
+ * Whether `path` is `root` or sits below it, both given as resolved paths.
17
+ *
18
+ * Asked through `relativePath` rather than by comparing prefixes, because the
19
+ * two ways this goes wrong are both invisible in a `startsWith`. A sibling
20
+ * directory shares the prefix — `/repo-backup` starts with `/repo` — and on
21
+ * Windows a path on another drive has *no* relative spelling at all, so
22
+ * `path.relative` answers with an absolute one rather than a chain of `..`.
23
+ * Testing the relative form catches both: an escape is either `..`-led or
24
+ * absolute, and nothing else is.
25
+ *
26
+ * The caller owes the resolution. Nothing here follows a symbolic link, so a
27
+ * path that is lexically inside can still be physically outside — that is the
28
+ * question `join` cannot answer and this function does not pretend to.
29
+ */
30
+ export declare function isInside(root: string, path: string): boolean;
15
31
  //# sourceMappingURL=paths.d.ts.map
@@ -14,7 +14,7 @@
14
14
  // Normalising here rather than at the glob has a second dividend: a report is
15
15
  // then byte-identical across platforms, so a `--json` consumer diffing two CI
16
16
  // runs is not reading the runner's operating system.
17
- import { relative, sep } from 'node:path';
17
+ import { isAbsolute, relative, sep } from 'node:path';
18
18
  /**
19
19
  * A native path as a POSIX one.
20
20
  *
@@ -33,4 +33,23 @@ export function toPosixPath(path, separator = sep) {
33
33
  export function relativePath(from, to) {
34
34
  return toPosixPath(relative(from, to));
35
35
  }
36
+ /**
37
+ * Whether `path` is `root` or sits below it, both given as resolved paths.
38
+ *
39
+ * Asked through `relativePath` rather than by comparing prefixes, because the
40
+ * two ways this goes wrong are both invisible in a `startsWith`. A sibling
41
+ * directory shares the prefix — `/repo-backup` starts with `/repo` — and on
42
+ * Windows a path on another drive has *no* relative spelling at all, so
43
+ * `path.relative` answers with an absolute one rather than a chain of `..`.
44
+ * Testing the relative form catches both: an escape is either `..`-led or
45
+ * absolute, and nothing else is.
46
+ *
47
+ * The caller owes the resolution. Nothing here follows a symbolic link, so a
48
+ * path that is lexically inside can still be physically outside — that is the
49
+ * question `join` cannot answer and this function does not pretend to.
50
+ */
51
+ export function isInside(root, path) {
52
+ const rel = relativePath(root, path);
53
+ return rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel);
54
+ }
36
55
  //# sourceMappingURL=paths.js.map
@@ -13,9 +13,9 @@ import { DEFAULT_TARGET, resolveTargets } from './targets.js';
13
13
  import { writeAtomic } from './write.js';
14
14
  import { applyMerge, mergedSpecPath } from './merge.js';
15
15
  import { compilerIssue } from './compiler.js';
16
- import { mkdir, readFile } from 'node:fs/promises';
16
+ import { mkdir, readFile, realpath } from 'node:fs/promises';
17
17
  import { basename, dirname, join } from 'node:path';
18
- import { relativePath } from './paths.js';
18
+ import { isInside, relativePath } from './paths.js';
19
19
  import { hasError } from './types.js';
20
20
  // The runner half of the engine, reached only when a command actually needs it.
21
21
  //
@@ -450,15 +450,78 @@ export async function runInit(root, names) {
450
450
  const { targets, issues } = resolveTargets(names.length > 0 ? names : [DEFAULT_TARGET]);
451
451
  if (issues.length > 0)
452
452
  return { files: [], issues };
453
- const files = [];
453
+ // Every destination is resolved before any of them is written, for the reason
454
+ // `resolveTargets` refuses the whole set on one unknown name: a run that stops
455
+ // partway leaves a repository carrying instructions for some agents and a
456
+ // failing command that does not say which.
457
+ // A root that does not exist yet is `init <dir>` on a directory this run
458
+ // creates, and nothing can have been planted inside a directory that is not
459
+ // there — so it stands for itself and every segment below it resolves
460
+ // lexically, which is what the old implementation did for all of them.
461
+ const realRoot = await realpath(root).catch(() => root);
462
+ const writes = [];
463
+ const refusals = [];
454
464
  for (const target of targets) {
455
- const dest = join(root, target.file);
465
+ const resolved = await resolveDest(realRoot, target.file);
466
+ if (resolved.ok) {
467
+ writes.push({ target, dest: resolved.dest });
468
+ }
469
+ else {
470
+ refusals.push({
471
+ level: 'ERROR',
472
+ code: 'unsafe-target-path',
473
+ file: target.file,
474
+ message: `Refusing to write ${target.file}: it resolves to ${resolved.escape}, outside the project. Remove the link at that path and re-run.`,
475
+ });
476
+ }
477
+ }
478
+ if (refusals.length > 0)
479
+ return { files: [], issues: refusals };
480
+ const files = [];
481
+ for (const { target, dest } of writes) {
456
482
  await mkdir(dirname(dest), { recursive: true });
457
483
  await writeAtomic(dest, target.content());
458
484
  files.push(target.file);
459
485
  }
460
486
  return { files, issues };
461
487
  }
488
+ /**
489
+ * Where a target's file really goes, or the path outside the project that
490
+ * asking put it at (design §9: ownership of the path is checked, not assumed).
491
+ *
492
+ * `join(root, file)` answers where the path *reads* as going, and that is not
493
+ * the same question: a segment that is a link makes a path lexically inside the
494
+ * root and physically outside it, which `mkdir -p` and the write both follow.
495
+ * So the resolution is the check, and a lexical one would not be — `join`
496
+ * normalises `..`, so it is already satisfied here. `writeAtomic`'s `wx` is not
497
+ * the defence either and was never scoped to be: it guards the *temporary* path
498
+ * against a planted link, in whichever directory the destination turns out to
499
+ * be.
500
+ *
501
+ * So every segment is resolved, not just the leaf. A link anywhere along the
502
+ * way is the escape, and the leaf is checked too — `rename` replaces a symlink
503
+ * rather than following it, so a link there would not leak the content, but it
504
+ * would silently destroy a file the user made deliberately.
505
+ *
506
+ * A segment that does not exist cannot be a link, and neither can the ones
507
+ * below it, so `realpath` failing means the rest of the path is Attest's own
508
+ * `mkdir` to create. What this cannot close is a link planted between this
509
+ * resolution and the write; the exposure it does close is one committed to the
510
+ * repository, which is the one a checkout hands you.
511
+ */
512
+ async function resolveDest(realRoot, file) {
513
+ let current = realRoot;
514
+ // `AgentTarget.file` is spelled with `/` on every platform (ATX-28), so the
515
+ // segments are the same list here as they are in the report.
516
+ for (const segment of file.split('/')) {
517
+ const next = join(current, segment);
518
+ const real = await realpath(next).catch(() => next);
519
+ if (!isInside(realRoot, real))
520
+ return { ok: false, escape: relativePath(realRoot, real) };
521
+ current = real;
522
+ }
523
+ return { ok: true, dest: current };
524
+ }
462
525
  /**
463
526
  * A change name must name one directory inside `changes/`, and nothing else.
464
527
  *
@@ -6,7 +6,7 @@
6
6
  // back, so the direction OpenSpec's pure-Markdown model went (Markdown as
7
7
  // truth, and the free-form drift that comes with it) stays closed.
8
8
  //
9
- // Four properties this file must keep:
9
+ // Five properties this file must keep:
10
10
  // - **Intent only.** The document says what the system promises, never what is
11
11
  // proven or green: coverage and results are verdicts, and verdicts belong to
12
12
  // `cover` / `verify`, which recompute them on demand. Putting them here also
@@ -25,6 +25,10 @@
25
25
  // and it is a *file* — committed, served, and read again long after the run
26
26
  // that wrote it. See `sanitised` for why the defence sits here rather than
27
27
  // at the terminal write.
28
+ // - **Prose stays prose.** A string the registry owns may be marked up and may
29
+ // not become a tag or a heading: the document promises one section per
30
+ // requirement, and a rationale that opens its own breaks the only structural
31
+ // claim the document makes. See `prose` (design §9.1).
28
32
  import { byCodeUnit, sortDeep } from './order.js';
29
33
  import { control } from './terminal.js';
30
34
  /** Whether a value reads as words in a sentence — a scalar, or a list of them. */
@@ -199,7 +203,7 @@ function compareIds(a, b) {
199
203
  function overviewTable(registry, ids) {
200
204
  const rows = ids.map((id) => {
201
205
  const req = registry[id];
202
- return `| [${id}](${anchor(id)}) | ${cell(interpolate(req.statement, req.params))} |`;
206
+ return `| [${id}](${anchor(id)}) | ${cell(statementText(req))} |`;
203
207
  });
204
208
  return ['| ID | Requirement |', '| --- | --- |', ...rows];
205
209
  }
@@ -207,9 +211,9 @@ function section(id, req) {
207
211
  const out = [
208
212
  `## ${id}`,
209
213
  '',
210
- interpolate(req.statement, req.params),
214
+ statementText(req),
211
215
  '',
212
- `**Why:** ${req.rationale}`,
216
+ `**Why:** ${prose(req.rationale)}`,
213
217
  ];
214
218
  const params = Object.entries(req.params);
215
219
  if (params.length > 0) {
@@ -225,33 +229,200 @@ function section(id, req) {
225
229
  }
226
230
  }
227
231
  if (req.outOfScope.length > 0) {
228
- out.push('', '**Out of scope**', '', ...req.outOfScope.map((s) => `- ${s}`));
232
+ out.push('', '**Out of scope**', '', ...req.outOfScope.map((s) => `- ${prose(s)}`));
229
233
  }
230
234
  return out;
231
235
  }
236
+ /** The statement as the document says it, with its params in place. */
237
+ function statementText(req) {
238
+ return prose(req.statement, req.params);
239
+ }
240
+ /**
241
+ * Author prose as Markdown that cannot become HTML and cannot open a section,
242
+ * with `{param}` placeholders substituted as it goes (design §9.1).
243
+ *
244
+ * One pass rather than two because the two jobs need the same fact — which
245
+ * regime each character is in. Escaping first and substituting after would run
246
+ * the value through a scanner reading backticks as structure; substituting
247
+ * first and escaping after would let a value's own backtick open a code span
248
+ * that was never in the statement, and carry the rest of the sentence out of
249
+ * the defence. Knowing the position is also what lets a value be escaped
250
+ * *for* it, which is the difference between `emphasised` and `literal` below.
251
+ *
252
+ * Which of the three things a registry string could do here is intended, and
253
+ * why this escapes rather than refuses, is §9.1. What is local to this function
254
+ * is where it stops: **a code span is left exactly as written**, because a
255
+ * span's contents are literal text in every renderer and an entity written
256
+ * there would be visible rather than decoded. The match is therefore
257
+ * deliberately conservative — a backtick run opens a span only if a run of
258
+ * *exactly* that length closes it (CommonMark), and an unmatched run is text
259
+ * with the defence still applied after it. Being wrong the other way, reading
260
+ * text as a span the renderer will not, is the only direction that leaks.
261
+ */
262
+ function prose(text, params = {}) {
263
+ const spans = spanReader(text);
264
+ const out = [];
265
+ let i = 0;
266
+ let atLineStart = true;
267
+ while (i < text.length) {
268
+ const ch = text[i];
269
+ if (ch === '`') {
270
+ const end = spans.spanEndAt(i);
271
+ out.push(substitute(text.slice(i, end), params, literal));
272
+ atLineStart = false;
273
+ i = end;
274
+ continue;
275
+ }
276
+ if (ch === '{') {
277
+ const placeholder = /^\{\w+\}/.exec(text.slice(i));
278
+ if (placeholder !== null) {
279
+ out.push(substitute(placeholder[0], params, emphasised));
280
+ atLineStart = false;
281
+ i += placeholder[0].length;
282
+ continue;
283
+ }
284
+ }
285
+ if (ch === '\n') {
286
+ out.push(ch);
287
+ atLineStart = true;
288
+ i += 1;
289
+ continue;
290
+ }
291
+ if (atLineStart) {
292
+ // Leading whitespace does not end the line's opening position: ` # x` is
293
+ // a heading, and four spaces make an indented code block where the escape
294
+ // is visible but harmless. Counting the indent to tell those apart would
295
+ // be a second Markdown reader for one cosmetic case.
296
+ if (ch === ' ' || ch === '\t') {
297
+ out.push(ch);
298
+ i += 1;
299
+ continue;
300
+ }
301
+ atLineStart = false;
302
+ // A heading opens a section; a line of `=` or `-` alone makes a heading
303
+ // out of the line *above* it, which is the same forgery approaching from
304
+ // behind. A backslash before either renders the character and nothing else.
305
+ if (ch === '#' || SETEXT.test(lineAt(text, i))) {
306
+ out.push('\\');
307
+ continue;
308
+ }
309
+ }
310
+ out.push(ch === '<' ? '&lt;' : ch);
311
+ i += 1;
312
+ }
313
+ return out.join('');
314
+ }
315
+ /** A line that would turn the one above it into a heading. */
316
+ const SETEXT = /^(?:=+|-+)[ \t]*$/;
317
+ function lineAt(text, from) {
318
+ const end = text.indexOf('\n', from);
319
+ return end === -1 ? text.slice(from) : text.slice(from, end);
320
+ }
321
+ /**
322
+ * Where each code span in `text` ends, answered in constant time per question.
323
+ *
324
+ * The obvious implementation searches forward from the opening run for a run of
325
+ * the same length, and it is the ATX-59 defect in a second costume: a run that
326
+ * closes nothing scans to the end of the string, so prose whose runs are all of
327
+ * *different* lengths pays that for every one of them. Measured, before this:
328
+ * 500 KB of such a rationale took 1.6 s and 2 MB took 15 s — from `attest
329
+ * render` with no flag, on text the registry chooses.
330
+ *
331
+ * So the runs are read once and indexed by length, and each length keeps a
332
+ * cursor into its own list. Openers are visited left to right, so a cursor only
333
+ * ever moves forward and the total work is bounded by the number of runs. A
334
+ * shorter or longer run is not a closer and is never a candidate here, which is
335
+ * the CommonMark rule and also what stops a scanner believing in a span the
336
+ * renderer does not.
337
+ */
338
+ function spanReader(text) {
339
+ const runs = [];
340
+ for (let i = 0; i < text.length; i += 1) {
341
+ if (text[i] !== '`')
342
+ continue;
343
+ const start = i;
344
+ while (text[i] === '`')
345
+ i += 1;
346
+ runs.push({ at: start, length: i - start });
347
+ i -= 1;
348
+ }
349
+ const byLength = new Map();
350
+ runs.forEach((run, index) => {
351
+ const list = byLength.get(run.length);
352
+ if (list === undefined)
353
+ byLength.set(run.length, [index]);
354
+ else
355
+ list.push(index);
356
+ });
357
+ const cursors = new Map();
358
+ let nextRun = 0;
359
+ return {
360
+ /** The end of the span opening at `at`, or the end of that run if none. */
361
+ spanEndAt: (at) => {
362
+ while (runs[nextRun].at < at)
363
+ nextRun += 1;
364
+ const opener = runs[nextRun];
365
+ const candidates = byLength.get(opener.length);
366
+ let cursor = cursors.get(opener.length) ?? 0;
367
+ while (cursor < candidates.length && candidates[cursor] <= nextRun)
368
+ cursor += 1;
369
+ cursors.set(opener.length, cursor);
370
+ const closer = cursor < candidates.length ? runs[candidates[cursor]] : undefined;
371
+ return closer === undefined ? at + opener.length : closer.at + closer.length;
372
+ },
373
+ };
374
+ }
232
375
  /**
233
- * Substitute `{param}` placeholders with the actual value, emphasised so a
234
- * reader can see which words in the sentence are pinned to a single source.
376
+ * Substitute `{param}` placeholders in one region with `render`, the spelling
377
+ * that region's position calls for.
378
+ *
235
379
  * An unbound placeholder is left verbatim — `check` already reports it as an
236
380
  * `unbound-param` error, and silently swallowing it here would hide that.
237
381
  */
238
- function interpolate(statement, params) {
382
+ function substitute(region, params, render) {
239
383
  // `Object.hasOwn`, never `in`: `'toString' in {}` is true, and an `in` probe
240
384
  // would splice `function toString() { [native code] }` into a document that
241
385
  // reviewers and audit read as the system's promise.
242
- return statement.replace(/\{(\w+)\}/g, (whole, name) => Object.hasOwn(params, name) ? `**${plain(params[name])}**` : whole);
386
+ return region.replace(/\{(\w+)\}/g, (whole, name) => Object.hasOwn(params, name) ? render(params[name]) : whole);
387
+ }
388
+ /**
389
+ * A param value in prose position: escaped, and emphasised so a reader can see
390
+ * which words in the sentence are pinned to a single source.
391
+ *
392
+ * Escaped unlike the statement around it, because those are prose an author may
393
+ * want to mark up and a value is data that must survive verbatim — an unescaped
394
+ * `*` would otherwise break out of the emphasis it is wrapped in. `<` and `#`
395
+ * are in the class for the same reason the rest of it is: they are structure
396
+ * *here*. A value is spliced into a sentence, so a tag in one is a tag in the
397
+ * document, and a value carrying a newline puts what follows at the start of a
398
+ * line, where `#` opens a section (design §9.1).
399
+ */
400
+ function emphasised(value) {
401
+ const one = (v) => v.replace(/([*_`[\]\\#])/g, '\\$1').replaceAll('<', '&lt;');
402
+ return `**${listed(value, (v) => one(inlineText(v)))}**`;
243
403
  }
244
404
  /**
245
- * A param value as prose, for inline use inside a statement. Escaped, unlike
246
- * the statement and rationale around it: those are prose an author may want to
247
- * mark up, a param value is data and must survive verbatim an unescaped `*`
248
- * would otherwise break out of the emphasis it is wrapped in.
405
+ * A param value inside the author's code span: the value, and nothing added.
406
+ *
407
+ * A code span's contents are literal in every renderer, so escaping there is
408
+ * not merely unnecessary, it is *visible* Markdown does not decode `&lt;`
409
+ * inside one, and this repository's own `ATX-30` renders a path with `<name>`
410
+ * in it. Emphasis is dropped for the same reason: `**` in a code span is two
411
+ * asterisks, which is what the document said before this knew where it was.
412
+ *
413
+ * The exception is a value that would end the span early — a backtick, or the
414
+ * blank line a code span cannot contain. There the author has asked for two
415
+ * incompatible things, and the prose escape is the safe answer rather than the
416
+ * pretty one: what follows the broken span is author text this pass has already
417
+ * walked past.
249
418
  */
250
- function plain(value) {
251
- const one = (v) => v.replace(/([*_`[\]\\])/g, '\\$1');
252
- return Array.isArray(value)
253
- ? value.map((v) => one(inlineText(v))).join(', ')
254
- : one(inlineText(value));
419
+ function literal(value) {
420
+ const text = listed(value, inlineText);
421
+ return text.includes('`') || /\n[ \t]*\n/.test(text) ? emphasised(value) : text;
422
+ }
423
+ /** A list param reads as its members; a scalar reads as itself. */
424
+ function listed(value, one) {
425
+ return Array.isArray(value) ? value.map(one).join(', ') : one(value);
255
426
  }
256
427
  /**
257
428
  * One param value as a run of text.
@@ -273,9 +444,7 @@ function inlineText(value) {
273
444
  function formatValue(value) {
274
445
  if (!isFlat(value))
275
446
  return '_see below_';
276
- return Array.isArray(value)
277
- ? value.map((v) => code(inlineText(v))).join(', ')
278
- : code(inlineText(value));
447
+ return listed(value, (v) => code(inlineText(v)));
279
448
  }
280
449
  /**
281
450
  * A structured param as a fenced JSON block.
@@ -93,6 +93,15 @@ promises is a two-stage workflow, and the stages are separate on purpose.
93
93
  through any edit. Pin the expectation to something that does not move with the
94
94
  param — a fixture, a literal in the test, or a second independently derived
95
95
  value.
96
+ - **A param the scenario loops over is the *domain* of the run, and shortening it
97
+ is silent.** Drop a member and you drop a case; every assertion inside the loop
98
+ still passes over what is left, with the expectation as independent as it ever
99
+ was — so the rule above does not reach this. Pin the extent beside the loop:
100
+ the members against a literal when their identity is the promise, the count
101
+ when the size is. That literal is **not** the hardcoded value stage 2's third
102
+ rule forbids, and the distinction is the whole of it — that rule is about the
103
+ **expectation** the system is measured against; this pin asserts what the
104
+ **intent** is.
96
105
 
97
106
  ## Rules the engine enforces
98
107
 
@@ -313,6 +322,16 @@ failure this framework exists to make visible:
313
322
  is the drift the single source exists to prevent, and the param is typed at
314
323
  the value written in the registry, so a stale expectation stops compiling
315
324
  rather than silently passing.
325
+ *This forbids one literal and not the other, and the difference decides
326
+ whether a domain can be shortened.* Forbidden is a literal standing in for the
327
+ param **as the value the system is measured against** — that is the second
328
+ copy. Required, when a scenario **iterates** a list it read from \`params\`, is
329
+ a literal pinning that list or its length beside the loop: the list is then
330
+ the set of cases the run covers rather than a value under test, and dropping a
331
+ member removes a case while every assertion inside still passes. A literal is
332
+ the only independent term available when what is at risk is the **size** of
333
+ the set, so the two never collide — one asserts what the system does, the
334
+ other what the intent promised.
316
335
  *One thing to know before implementation code reads a param this change
317
336
  ADDs:* the suite imports your \`*.reqs.ts\` from disk, while the gate applies
318
337
  the delta in memory — so that read throws at import and the gate answers
package/package.json CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "@am_shork/attest",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
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",
7
6
  "engines": {
8
7
  "node": ">=20.19"
9
8
  },
@@ -37,24 +36,6 @@
37
36
  "publishConfig": {
38
37
  "access": "public"
39
38
  },
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
- },
58
39
  "keywords": [
59
40
  "tdd",
60
41
  "spec",
@@ -81,13 +62,20 @@
81
62
  "vite": "^8.1.5",
82
63
  "vitest": "^4.1.10"
83
64
  },
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
- }
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"
92
80
  }
93
- }
81
+ }