@am_shork/attest 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -13,6 +13,141 @@ 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.1] - 2026-08-05
17
+
18
+ ### Added
19
+
20
+ - **`params` takes any JSON value, and the constraint moved to the place it was
21
+ actually protecting.** A param was a scalar or a list of scalars. The argument
22
+ for admitting lists — the values most likely to drift are the composite ones,
23
+ and rejecting them left exactly those outside the one mechanism built to pin
24
+ them — does not stop at a list: a `kind -> payload` table drifts harder than a
25
+ flat list of names, and it was the one shape still excluded. `null` comes with
26
+ it, because refusing it forces a sentinel (`''`, `-1`) that means something
27
+ else to every later reader.
28
+ What the old union was defending was not the registry but the **rendering**:
29
+ `{payloadKinds}` interpolated into a statement as `[object Object]`, in a
30
+ document reviewers and audit read as the system's promise. That is a property
31
+ of the interpolation point, so it is stated there — a new
32
+ **`non-scalar-interpolation`** ERROR from `check` and `verify`. Narrower and
33
+ truer: it refuses what actually breaks and admits the data a scenario reads,
34
+ which the schema rule could not tell apart. What the schema keeps is what does
35
+ not survive being written down — a function, a `Date`, a class instance, an
36
+ object whose prototype was swapped.
37
+ *The part that is not a schema edit.* Depth. Four obligations had exactly one
38
+ level of reach, which is correct only while a param cannot nest, and none of
39
+ them fails loudly: the canonical form behind `add-conflict` (an identical copy
40
+ written with its inner keys in another order would have conflicted with
41
+ itself), the ATX-58 control-character stripping (nested strings would have
42
+ gone unsanitised, reopening the hole `[0.7.0]` closed), the key order behind a
43
+ byte-stable rendering, and `splice.ts`, which writes a requirement back out as
44
+ TypeScript source and would have committed `[object Object]` into a
45
+ `*.reqs.ts` through `archive --apply`. Three of the four are ordering, and are
46
+ now one shared `sortDeep` in `order.ts` rather than three sorts a level deep —
47
+ they wanted the same guarantee for the same reason, which is what makes it one
48
+ function and not a helper each.
49
+ *Two divergences between the readers, which is what the differential suite is
50
+ for.* `null` is a keyword rather than a literal node, so the AST reader fell
51
+ off the end of `literalValue` and reported `registry-not-static` for the whole
52
+ file while the evaluating reader accepted it. And `{ __proto__: … }` swaps the
53
+ prototype instead of creating a key, so `z.record` copies own keys into a fresh
54
+ object and the taint is invisible one step later — the static reader refuses
55
+ that source at any depth, and the schema now refuses the object the evaluator
56
+ builds from it, both spellings (a literal `__proto__:` and the own key
57
+ `JSON.parse` produces).
58
+ *Rendering.* A structured param gets a fenced JSON block below the params
59
+ table, keys sorted at every depth (ATX-10: the same registry renders the same
60
+ bytes) with the fence measured against backtick runs in the value, as `code()`
61
+ already did. It is below the table rather than in it because a fence cannot
62
+ live in a table cell. And `render` defends the document on its own: it reads
63
+ the registry with no spec parse, so no `AttestPlan` and no `validateStructure`
64
+ — `attest render` runs to completion on a registry `check` refuses. Leaning on
65
+ the diagnostic would have left the document defended by a command nobody is
66
+ obliged to run first.
67
+ *The writing side of the `__proto__` rule.* `splice.ts` turns a requirement
68
+ back into TypeScript source for `--apply`, and it is the only site that does.
69
+ Extending it to depth meant it would have emitted a `__proto__` key as a bare
70
+ property — text whose *evaluation* swaps a prototype rather than being the
71
+ value it was handed. Unreachable through any command (the schema rejects a
72
+ nested `__proto__` and `z.record` drops a top-level one), and refused anyway
73
+ with a new `UnwritableValue`, reported as `internal-error` by the merge with
74
+ its account of what it had already written intact. Quoting is not the repair —
75
+ a quoted `__proto__` swaps the prototype in a literal exactly as the bare form
76
+ does, and the bracketed form that would create an own property is a computed
77
+ key no static reader will read. The reader's guard is worth nothing if the
78
+ writer can produce the file it exists to refuse.
79
+ *Not breaking, with one edge worth naming.* No previously-green registry goes
80
+ red — a structured param was `registry-invalid` before, so nothing that
81
+ compiled and passed now fails — and an all-scalar registry renders
82
+ byte-identical, so a committed `render --check` stays green across the
83
+ upgrade. What did widen is the exported type in a **read** position:
84
+ `Requirement['params'][string]` is now the recursive JSON value. The const
85
+ generic is untouched, so `reqs['AUTH-3'].params.idleTimeoutMin` is still the
86
+ literal `30` and every anti-drift test keeps its narrow type. A consumer only
87
+ notices if they assign an *undeclared* key to the old scalar-or-list union
88
+ (`Type 'null' is not assignable`), or wrote their own handler over that type
89
+ and does `String(v)` in it — which is the `[object Object]` defect this entry
90
+ fixes in `render` and `splice`, seen from outside.
91
+ *This overturns a standing rejection, and not on its stated terms.* *Nested
92
+ objects in `params`* sat under **Considered and rejected** since `[0.4.2]`,
93
+ raised by `mine-capablanca`, and that entry is removed by this release. Its
94
+ argument was that a param has two properties a table lacks: rendered as a value
95
+ a human checks at a glance, and read by a scenario as the one place the value
96
+ lives. The first is answered rather than waived — `non-scalar-interpolation`
97
+ keeps a table out of the *sentence*, so what a reader takes in at a glance is
98
+ still glance-sized, and the table is rendered below it as a block. The second
99
+ is answered by the criterion that admitted arrays in the first place: a
100
+ `kind -> payload` table drifts *harder* than a flat list, so the reason arrays
101
+ were let in is the reason this shape should have been.
102
+ **The reopening condition it named was never met, and that is worth stating
103
+ plainly.** The entry said it reopens on "a report where the value is genuinely
104
+ promised — a table an adopter owes their users"; none has been seen. This
105
+ shipped on the strength of the argument alone. What changed is the recognition
106
+ that the schema was answering the wrong question: *params for promises, plain
107
+ constants for tuning* is the rule, and a type can only ever check shape, so it
108
+ refused a promised table while admitting a list of tuning knobs. Design §11
109
+ now carries that as a judgement rather than a type, which is the permanent half
110
+ — and it is weaker than what it replaces, because nothing enforces a judgement.
111
+ New requirements: `ATX-7` rewritten, `ATX-63` added.
112
+
113
+ ### Changed
114
+
115
+ - **Rationale that only a comment held now has a design section, and the comment
116
+ cites it.** Measured over the diffs in three eras split at `[0.3.0]` and
117
+ `[0.5.0]`: comment blocks got better by every specificity signal — naming a
118
+ symbol 50 → 60%, a measured number 3 → 13%, a causal argument 26 → 54% — and
119
+ roughly doubled per line of code, while the share citing a `§` went 15 → 12 →
120
+ 11% and the design document stopped growing (sixteen sections since `[0.3.0]`,
121
+ 105 lines against `src/`'s 2,862). **The anchoring did not fall; it never
122
+ scaled**, and a `§` is the only part of a comment any gate reads.
123
+ Nineteen blocks written since `[0.5.0]` carried the cost — ten lines or more,
124
+ fully argued, unanchored — and they split three ways. *Six were properties a
125
+ rewrite would have to preserve*, and now sit in the document: a new **§9.1**
126
+ for the artifacts Attest writes, an extension to **§8** (`--apply` acts on this
127
+ run's verdict), and one to **§5.4** (the two readers of one plan must descend
128
+ the same way). *Four were history* — a quadratic backtrack, a 27–84x constant
129
+ factor, a 47 MiB peak, an indexing cost filed as invisible — and were
130
+ compressed to the invariant a maintainer would otherwise re-break; every
131
+ measurement in them was already under `[0.7.0]`, so this deleted duplicates
132
+ rather than moving anything. *Ten were correct as they stood and were left
133
+ alone*, which is the half worth naming: `§` is low because the design document
134
+ stalled, not because comments are too long, so a pass that shortened comments
135
+ and left it at one in nine would have moved the wrong quantity and called it
136
+ done. Net `src/`: 29 lines removed, all of them comments and no code touched,
137
+ which is also how the deletion was checked.
138
+ **One of the six corrected the document rather than extending it.** §8
139
+ summarised a merge as renaming a proposed spec "in place", which is true of its
140
+ location and not of its imports — the delta it reads its proposed params from
141
+ is what moves to `archive/`, so the sentence hid the one specifier the merge
142
+ has to repoint.
143
+ *What the measurement cost.* A first pass reported the `§` share as halving,
144
+ 25 → 12%, an artefact of bucketing by `git blame` over the current tree: that
145
+ sees only surviving lines, and `§`-citing comments survive better, being tied
146
+ to sections that do not move, so the early era was measured on its most durable
147
+ residue. Re-measuring from the diffs before building is what caught it.
148
+ The routing test this produced is in `pre-commit-check` §6, and the obligation
149
+ a new diagnostic code owes the CLI reference is now in §2.
150
+
16
151
  ## [0.7.0] - 2026-08-04
17
152
 
18
153
  ### Added
@@ -3022,82 +3157,26 @@ feedback reports are triaged into issues or `Unreleased` and deliberately not
3022
3157
  kept in this tree; that rule stands, and holds for anything with a reporter, a
3023
3158
  repro, or a state to track. What is kept here is the narrower thing this file
3024
3159
  already keeps one section of: a **decision with an argument attached**, where
3025
- losing the argument would cost more than losing the task. The one entry left
3026
- below came out of reading another repository against this one — `msw`.
3027
-
3028
- Eleven entries have left by being built: three together in `[0.6.0]`, and six
3029
- under `[0.7.0]` the verdict predicates, the nested `describe`, the runner
3030
- the static commands loaded and could not start, the diagnosis for a spec file
3031
- that failed to load, the six comments describing a property the code lacked, and
3032
- the sketches now bound to the files they name — which are the first to leave by
3033
- being **read** rather than by being reported or gated. The last of those left
3034
- from `Under consideration` rather than from here, which is the move this file
3035
- describes as going up before going out, and it went up on the strength of one
3036
- thing: of its three candidates, exactly one could be measured against instances
3037
- that had already happened.
3038
- The second of those left *partially*: one of its three parts came back here as an
3039
- entry of its own, because implementing the other two showed the estimate on it
3040
- was wrong. An entry leaving is not evidence that all of it shipped, and this is
3041
- the first time that has mattered. That returned part has now shipped as well, and
3042
- it closed the way it said it would: the estimate was wrong because the fix was a
3043
- new `Issue` code rather than a suppression, and what made it so was a decision
3044
- recorded three lines from where the suppression would have gone. **The thing that
3045
- cost the most was the thing already written down** — which is an argument for
3046
- this section, since the note that corrected the estimate survived only because
3047
- someone had filed the reasoning next to the code.
3048
- `compiler-unsupported` came from building something queued here,
3049
- which is one way an entry arrives: a gate that finds a defect on its first run
3050
- files the defect, and the `typescript` matrix `[0.4.2]` added is what found this
3051
- one.
3052
- `--apply` came from an adoption report (`mine-capablanca`, 0.4.2, 34 requirements
3053
- / 56 scenarios / greenfield) — a third way an entry arrives: not a defect, but a
3054
- shape the tool made an adopter work around eight times without ever erroring. It
3055
- took `spec-in-change-dir` out with it, by a fourth way worth naming: designing one
3056
- of `--apply`'s checks surfaced a different one, against a migration this file had
3057
- itself shipped. **What a release costs its adopters is not fully known on the day
3058
- it is cut** — and building the entry is what finds out, which is also how
3059
- `--apply` discovered that one clause of its own argument here was wrong. Both are
3060
- recorded under `[0.6.0]`, and two earlier entries from the same report left
3061
- by shipping in `[0.5.0]` — a change's specs moving to their merged location, and
3062
- the delta reading as the registry of what it adds — their arguments recorded
3063
- there, including the part of the first one that turned out to be wrong.
3064
- The entries added after `[0.6.0]` arrived a **fifth** way, and it is the
3065
- one with no external input at all: a strict read of `src/` with nothing failing.
3066
- Every gate in this repository was green while all three were true, which is the
3067
- whole of what that way is for — a defect no test is shaped to ask about is
3068
- reachable only by someone reading for it. **All three have now shipped under
3069
- `[0.7.0]`**, and each closed having found something a strict read is
3070
- apparently good for and a gate is not. The runner one: the gate that would have
3071
- had to catch it *already* looked for the shape, so what the read bought was not a
3072
- missing gate but a cost nobody had priced. The load-failure diagnosis: every gate
3073
- was green because the report was *complete* — three ERRORs on a red run — and
3074
- what was wrong was which three, a judgement no assertion in this repository is
3075
- shaped to make. The third, eight items under one heading: six comments naming a
3076
- property the code lacked and two costs beside them.
3077
-
3078
- **One entry has now left this section the other way — by being rejected** — and
3079
- it is the first, which is why it is worth a line. The region-marked samples were
3080
- decided *yes*, and building the count they rested on is what killed them: the two
3081
- spans the entry named are not spans, and both carry a mark the entry's own rule
3082
- excludes. It moved down with that measurement attached. The lesson is not about
3083
- regions. **An entry can sit in `Planned` for releases on the strength of a figure
3084
- nobody has checked**, because the section's discipline is aimed at keeping the
3085
- *argument* rather than at re-testing the evidence under it — so the first act of
3086
- building one should be re-measuring what it claims, not implementing what it
3087
- proposes. That is what happened here, and it cost an hour instead of a mechanism.
3088
-
3089
- *What the fifth way cost and returned, now that all of it is spent.* Sixteen
3090
- items, no external input, and nothing in the suite shaped to catch any of them.
3091
- The returns were not evenly distributed and the split is the useful part: two
3092
- defects a user could hit today (a report that misdiagnosed a load failure, a
3093
- merge crossing midnight), two costs worth a second each on a real command
3094
- (`check --eval` at 1 + N Vite servers, `control` at 27–84x), one fixed toll on
3095
- every static invocation (~1 s), and the rest true-but-unobservable — identical
3096
- output either way. Only the last group is genuinely optional, and it is also the
3097
- group that would never have been found any other way, because *no observable
3098
- symptom* is precisely the definition of what a gate cannot see. The transferable
3099
- lesson is the batching: individually none of the unobservable ones justifies a
3100
- commit, and together they cost about an hour.
3160
+ losing the argument would cost more than losing the task.
3161
+
3162
+ **Where an entry went is recorded under the version that shipped it.** This
3163
+ section keeps no ledger of its own departures one was maintained here by hand
3164
+ for several releases, and a count kept in prose with nothing to check it is a
3165
+ claim that goes wrong quietly. What is worth keeping is what the section learned
3166
+ about itself, which is three things.
3167
+
3168
+ **An entry can sit here for releases on the strength of a figure nobody has
3169
+ checked.** The discipline aims at keeping the *argument*, not at re-testing the
3170
+ evidence under it, so the first act of building one is re-measuring what it
3171
+ claims rather than implementing what it proposes. That has paid twice: once
3172
+ killing an entry outright, when the spans it rested on turned out not to be
3173
+ spans; once correcting an entry's headline number before a line of it was
3174
+ written. **What a release costs its adopters is not known on the day it is cut**
3175
+ several entries arrived from one adoption report, about shapes the tool made
3176
+ someone work around without ever erroring. And **the fixes with no observable
3177
+ symptom are worth batching**: individually none justifies a commit, together they
3178
+ cost about an hour, and they are the group no gate could have found, since having
3179
+ no symptom is the definition of what a gate cannot see.
3101
3180
 
3102
3181
  - **Binding the `✗` samples to fixtures whose rejection is asserted.** A sample
3103
3182
  showing input the engine must *reject* can be bound the way a runnable one is,
@@ -3117,6 +3196,99 @@ commit, and together they cost about an hour.
3117
3196
  `[0.4.3]` — that the gate and the run proving the fixture still works live in
3118
3197
  different suites — applies here identically.
3119
3198
 
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
+ - **`render`'s document can carry raw HTML and a forged requirement section.**
3226
+ `sanitised` is the entry §9.1 names and it strips control characters and
3227
+ nothing else, so `statement` and `rationale` reach the Markdown by
3228
+ concatenation exactly as written. Measured: a `rationale` containing newlines
3229
+ and `## ATX-999` renders a whole section — heading, statement, `**Why:**` —
3230
+ for a requirement no registry holds, sitting between two real ones and worded
3231
+ identically; `<script>` and `<img onerror>` survive the same way. `check` and
3232
+ `cover` see only the real id, and `render --check` cannot object, because that
3233
+ *is* what the registry renders to now.
3234
+ *Not an accepted risk, which is the part worth pinning.* §9.1 and the
3235
+ reference both scope their guarantee to control characters, and the
3236
+ reference's heading over that paragraph is "Safe to read, and safe to keep",
3237
+ with "a site generator" named in the threat it describes. What *is* a decision
3238
+ is that prose may be marked up — `plain()` escapes a param value precisely
3239
+ because it is data while the statement around it is not — and that decision
3240
+ has never been separated from allowing raw HTML or a heading. So the entry is
3241
+ as much about stating which of the three is intended as about the escaping,
3242
+ and if the answer is that Markdown stays and HTML and headings go, that is a
3243
+ fourth obligation in §9.1 rather than a patch at an emitter.
3244
+
3245
+ - **`attest init` follows a symlink out of the project root.** `runInit` does
3246
+ `mkdir(dirname(dest), { recursive: true })` and then `writeAtomic`, and
3247
+ neither asks whether `dest` is still under `root`. A `.claude/skills/attest`
3248
+ planted in the repository as a link to anywhere the user can write is followed
3249
+ by both: measured on Windows with a junction, a file at the target holding
3250
+ `USER OWNED FILE - DO NOT OVERWRITE` came back holding the skill. The report
3251
+ prints the relative path, so nothing on screen says where it went.
3252
+ *`write.ts` is not the gap and neither is `targets.ts`'s content.* The `wx`
3253
+ flag defends the *temporary* path against a planted link and does exactly
3254
+ that; the destination's directory was never in its scope. And `workflowBody()`
3255
+ is a pure function of nothing, so no byte of the checked project reaches the
3256
+ file — the exposure is the path, not the content, which is `targets.ts`'s
3257
+ first rule ("Attest owns the path") being an assumption rather than a check.
3258
+ The check belongs beside `resolveTargets`, which is already all-or-nothing for
3259
+ the same reason: a name known to be unwritable must not leave half a set
3260
+ behind.
3261
+
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
+
3120
3292
  ## Under consideration
3121
3293
 
3122
3294
  Candidates with **no decision yet**, between the two sections either side of it
@@ -3388,6 +3560,41 @@ have to gather again.
3388
3560
  because a project whose requirement count only rises should have decided in
3389
3561
  advance what would make one leave.
3390
3562
 
3563
+ - **The `cmd.exe` quoting in `tests/consumer.spec.ts` guards one character of
3564
+ several.** `shellArg` asserts the argument holds no `"` and then quotes only
3565
+ when it holds whitespace, so an argument with none goes to the shell bare —
3566
+ and `&`, `|`, `^`, `<`, `>`, `(` are metacharacters there that the assertion
3567
+ does not name. The fix is one character class, which is why the entry is not
3568
+ about the fix.
3569
+ *What blocks the decision is that no input reaches it.* Both arguments are
3570
+ either a literal flag or a path from `mkdtemp`, `NEEDS_SHELL` is win32 only
3571
+ and CI is Linux, and the one path an attacker could plausibly influence — the
3572
+ checkout directory on a fork MR pipeline — comes from a GitLab project path,
3573
+ whose character set excludes every metacharacter above. So this is an
3574
+ observation with no attack path, and the honest question is not whether to
3575
+ widen the class but whether the comment above it should stop claiming more
3576
+ than the assertion covers: it says "the assertion keeps that true" about
3577
+ arguments generally, where what it checks is one character. Widening the class
3578
+ and leaving the prose is the change that would look like a fix and settle
3579
+ nothing.
3580
+
3581
+ - **A loader that fails to start leaves its scratch directory behind.**
3582
+ `createLoader` calls `mkdtempSync` and writes the stub *before* `createServer`,
3583
+ and the only `rm` of that directory is inside the `close` of the object
3584
+ `createServer` returns — so a throw from it leaks one `attest-loader-*` per
3585
+ invocation, which on a CI runner is one per build. That is the exact failure
3586
+ shape the comment on `close` records as already fixed, surviving on the other
3587
+ path.
3588
+ *What blocks it is that nothing has been made to throw there.* With
3589
+ `configFile: false` Vite still reads the `package.json` at the root to decide
3590
+ `type`, so a malformed one in the checked project is the obvious candidate and
3591
+ it is the project's to write — but it was not tried, and an entry claiming a
3592
+ trigger it has not seen is the thing this section exists to not accumulate.
3593
+ The repair is three lines (a `try` around the two calls that `rm`s and
3594
+ rethrows) and is not what the decision turns on; measuring whether the throw
3595
+ is reachable at all is, because if it is not, this is a leak with no input
3596
+ that produces it.
3597
+
3391
3598
  ## Considered and rejected
3392
3599
 
3393
3600
  Decisions **not** to build something, kept where they can be found before the
@@ -3712,42 +3919,8 @@ requirement, and human review at propose is still the whole answer. What is
3712
3919
  rejected is grouping as a way to assist it, and this reopens only on a
3713
3920
  contradiction that grouping would have caught.
3714
3921
 
3715
- ### Nested objects in `params`, so a lookup table can be registry-owned
3716
-
3717
- Raised by `mine-capablanca` (0.4.2) and explicitly not as a request, which is
3718
- why it is recorded with its own answer rather than left as a complaint about a
3719
- message. A `params` value is a scalar or a list of scalars; a table — piece kind
3720
- to weight, tier to limit — is refused, and the only way to register one is to
3721
- split it across parallel arrays, which reads worse in the rendered spec than it
3722
- buys. *What rejected it: the two properties a param has, and a table has
3723
- neither.* A param is **rendered into `SPEC.md` as a value a human checks at a
3724
- glance**, and it is **read by a scenario as the one place that value lives**. A
3725
- nested object fails the first by construction — there is no reading of a
3726
- five-row table that a reviewer verifies the way they verify `30`. It fails the
3727
- second more quietly: a table has many readers by definition, so "the one place"
3728
- becomes "the one file", which is what an ordinary module already is. The
3729
- schema's own comment records where the line was drawn and why arrays were let
3730
- in — list constants are the most drift-prone values, and an array still has one
3731
- owner read by one scenario. A table does not clear that bar; the reason arrays
3732
- did is exactly the reason it does not.
3733
- *The reporter's own resolution is the argument's strongest form.* The table went
3734
- to a shared module, is not registry-owned, and **nothing was lost** — because a
3735
- piece-value table is tuning, not a promise. That is the rule design §11 now
3736
- states — *params for promises, plain constants for tuning* — derived
3737
- independently by the same adopter before it was written anywhere. Under it this
3738
- candidate is not a limitation of `params` at all: it is a value that was never a
3739
- requirement's to hold, and §11 carries this rejection's own argument in the same
3740
- paragraph, as the rule stated over the type rather than over the value.
3741
- *What ships instead*, in `[0.5.0]`: the refusal now says what is accepted
3742
- instead of `Invalid input`, and `troubleshooting.md` states the constraint and
3743
- where such a value belongs — the reporter's actual ask, which was for the
3744
- decision to be visible rather than discovered by reading the shipped `.d.ts`.
3745
- The permanent half goes to design §11 with the params rule, not here.
3746
- *This reopens on* a report where the value is genuinely promised — a table an
3747
- adopter owes their users, whose rows a reviewer would want in the rendered spec.
3748
- None has been seen; the one that raised it was tuning.
3749
-
3750
- [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.7.0...main
3922
+ [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.7.1...main
3923
+ [0.7.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.1
3751
3924
  [0.7.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.0
3752
3925
  [0.6.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.6.0
3753
3926
  [0.5.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.5.0
package/README.md CHANGED
@@ -12,9 +12,9 @@ The killer move against drift: values a requirement **promises** (timeouts,
12
12
  limits, budgets) live **once** in its `params`, and tests read them from there —
13
13
  so a number is physically impossible to drift between the spec and the assertion.
14
14
  Values that merely tune behaviour stay ordinary constants; nothing is owed to
15
- anyone when a tuning knob changes. A param may be a scalar or an array of
16
- scalars, so list-shaped constants (vendor blacklists, id sets) get the same
17
- single source as a lone number.
15
+ anyone when a tuning knob changes. A param may be any JSON value, so a
16
+ composite constant a vendor blacklist, a `kind -> payload` table — gets the
17
+ same single source as a lone number, which is where drift is worst.
18
18
 
19
19
  What that does not buy is a warning when you change the value. `check` runs
20
20
  nothing, so editing a param leaves it at `✓ No issues` — nothing became unbound,
@@ -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.0/docs/en/troubleshooting.md#registry-not-static
160
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.7.1/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
@@ -3,7 +3,7 @@
3
3
  // Map<id, Requirement>, with content-compare on ADDED and already-synced
4
4
  // no-ops on RENAMED.
5
5
  import { RequirementIdSchema, RequirementSchema } from './schema.js';
6
- import { byCodeUnit } from './order.js';
6
+ import { byCodeUnit, sortDeep } from './order.js';
7
7
  /**
8
8
  * The ids a delta ADDs — the scope of the first-red obligation (design §6).
9
9
  *
@@ -185,16 +185,13 @@ function canonical(req) {
185
185
  return JSON.stringify({
186
186
  statement: req.statement,
187
187
  rationale: req.rationale,
188
- params: sortKeys(req.params),
188
+ // Deep, not one level: a param is a JSON value, so the nested keys of a
189
+ // kind -> payload table are as much a part of this string as the top-level
190
+ // ones, and the failure is identical one level down — an identical copy
191
+ // written with its inner keys in another order reported as `add-conflict`
192
+ // against itself. Code-unit order throughout, and `sortDeep` says why.
193
+ params: sortDeep(req.params),
189
194
  outOfScope: [...req.outOfScope],
190
195
  });
191
196
  }
192
- // Code-unit order, not localeCompare: this string is a *verdict input*.
193
- // localeCompare calls some distinct keys equal, and a stable sort then leaves
194
- // them in insertion order — so the canonical form would encode how the params
195
- // happened to be written, and `add-conflict` would report a requirement as
196
- // conflicting with an identical copy of itself.
197
- function sortKeys(obj) {
198
- return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => byCodeUnit(a, b)));
199
- }
200
197
  //# sourceMappingURL=apply.js.map
@@ -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", "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"];
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
@@ -36,6 +36,7 @@ export const ISSUE_CODES = [
36
36
  'modify-invalid',
37
37
  'modify-missing',
38
38
  'never-red',
39
+ 'non-scalar-interpolation',
39
40
  'orphan-test',
40
41
  'possible-drift',
41
42
  'proposed-spec-name-taken',
@@ -141,13 +141,11 @@ export declare function idPrefix(id: string): string;
141
141
  * displayed, they become the child run's `include` globs, where a Windows
142
142
  * separator would silently match nothing.
143
143
  *
144
- * Each source is parsed as it arrives rather than after all of them. The
145
- * previous `Promise.all(files.map(readFile))` held every spec file in memory at
146
- * once measured at ~47 MiB on a synthetic tree of 6000 files for input size
147
- * that is not ours to choose, since `check` is the command this project tells
148
- * people to run first on an untrusted fork MR. Parsing at the point of arrival
149
- * makes the peak `PARSE_CONCURRENCY` sources instead of `files.length`, and the
150
- * plan is the only thing that still grows with the tree.
144
+ * Each source is parsed as it arrives rather than after all of them, so the peak
145
+ * is `PARSE_CONCURRENCY` sources rather than `files.length` and the plan is the
146
+ * only thing still growing with the tree. Reading them all first is the shape to
147
+ * avoid: input size is not ours to choose here, `check` being what this project
148
+ * tells people to run first on an untrusted fork MR. Measured in `[0.7.0]`.
151
149
  *
152
150
  * `findFiles` above is deliberately left unbounded: its fan-out is real, but the
153
151
  * failure it invites is descriptor exhaustion, which no measurement on either
@@ -294,13 +294,11 @@ const PARSE_CONCURRENCY = 32;
294
294
  * displayed, they become the child run's `include` globs, where a Windows
295
295
  * separator would silently match nothing.
296
296
  *
297
- * Each source is parsed as it arrives rather than after all of them. The
298
- * previous `Promise.all(files.map(readFile))` held every spec file in memory at
299
- * once measured at ~47 MiB on a synthetic tree of 6000 files for input size
300
- * that is not ours to choose, since `check` is the command this project tells
301
- * people to run first on an untrusted fork MR. Parsing at the point of arrival
302
- * makes the peak `PARSE_CONCURRENCY` sources instead of `files.length`, and the
303
- * plan is the only thing that still grows with the tree.
297
+ * Each source is parsed as it arrives rather than after all of them, so the peak
298
+ * is `PARSE_CONCURRENCY` sources rather than `files.length` and the plan is the
299
+ * only thing still growing with the tree. Reading them all first is the shape to
300
+ * avoid: input size is not ours to choose here, `check` being what this project
301
+ * tells people to run first on an untrusted fork MR. Measured in `[0.7.0]`.
304
302
  *
305
303
  * `findFiles` above is deliberately left unbounded: its fan-out is real, but the
306
304
  * failure it invites is descriptor exhaustion, which no measurement on either
@@ -30,7 +30,7 @@
30
30
  // at runtime on the happy path, and a later reordering would look harmless.
31
31
  import { mkdir, readFile, rename, stat } from 'node:fs/promises';
32
32
  import { join, dirname, basename } from 'node:path';
33
- import { repointImport, spliceRequirements } from './splice.js';
33
+ import { repointImport, spliceRequirements, UnwritableValue } from './splice.js';
34
34
  import { writeAtomic } from './write.js';
35
35
  import { idPrefix } from './locate.js';
36
36
  import { addedIds } from './apply.js';
@@ -178,7 +178,32 @@ export async function applyMerge(input) {
178
178
  }
179
179
  for (const file of [...byFile.keys()].sort(byCodeUnit)) {
180
180
  const source = await readFile(file, 'utf8');
181
- const spliced = spliceRequirements(file, source, byFile.get(file));
181
+ let spliced;
182
+ try {
183
+ spliced = spliceRequirements(file, source, byFile.get(file));
184
+ }
185
+ catch (err) {
186
+ // The emitter refused a value it cannot write as source — today only a
187
+ // `__proto__` param key, which the schema rejects before `--apply` runs.
188
+ // Caught rather than left to the CLI's crash envelope so the account of
189
+ // what this merge had already written survives: `--apply` is destructive
190
+ // and half a merge reported as a bare stack is the shape a resume cannot
191
+ // read. The write for *this* file has not happened — the throw is in the
192
+ // text generation, above `writeAtomic`.
193
+ if (!(err instanceof UnwritableValue))
194
+ throw err;
195
+ return {
196
+ issues: [
197
+ {
198
+ level: 'ERROR',
199
+ code: 'internal-error',
200
+ file: relativePath(root, file),
201
+ message: `${relativePath(root, file)} could not be written: ${err.message}.`,
202
+ },
203
+ ],
204
+ written,
205
+ };
206
+ }
182
207
  if (spliced === undefined) {
183
208
  // Unreachable through the command — the gate read this file as a literal
184
209
  // moments ago — so it is reported as the internal inconsistency it is
@@ -1,3 +1,20 @@
1
1
  /** Compare by UTF-16 code unit — the same order as a bare `Array#sort()`. */
2
2
  export declare function byCodeUnit(a: string, b: string): number;
3
+ /**
4
+ * A JSON value with every object's keys in code-unit order, at every depth.
5
+ *
6
+ * `JSON.stringify` writes object keys in insertion order, which is *how the
7
+ * source happened to be written* — the thing this module exists to keep out of
8
+ * anything compared or committed. Two callers need the same guarantee for the
9
+ * same reason, one depth apart:
10
+ *
11
+ * - `apply.ts` canonicalises a requirement to decide `add-conflict`, so an
12
+ * identical copy written with its keys in another order must not read as a
13
+ * conflict with itself.
14
+ * - `render.ts` emits a structured param as JSON, and ATX-10 holds the same
15
+ * registry to the same bytes.
16
+ *
17
+ * Arrays keep their order: an array is data whose order is part of the value.
18
+ */
19
+ export declare function sortDeep<T>(value: T): T;
3
20
  //# sourceMappingURL=order.d.ts.map