@variance-authority/report 0.1.0

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +21 -0
  3. package/README.md +258 -0
  4. package/dist/changelog-message.d.ts +51 -0
  5. package/dist/changelog-message.js +244 -0
  6. package/dist/changelog-message.js.map +1 -0
  7. package/dist/changelog.d.ts +231 -0
  8. package/dist/changelog.js +96 -0
  9. package/dist/changelog.js.map +1 -0
  10. package/dist/cluster.d.ts +112 -0
  11. package/dist/cluster.js +109 -0
  12. package/dist/cluster.js.map +1 -0
  13. package/dist/composition.d.ts +248 -0
  14. package/dist/composition.js +33 -0
  15. package/dist/composition.js.map +1 -0
  16. package/dist/declarations.d.ts +266 -0
  17. package/dist/declarations.js +212 -0
  18. package/dist/declarations.js.map +1 -0
  19. package/dist/file.d.ts +28 -0
  20. package/dist/file.js +151 -0
  21. package/dist/file.js.map +1 -0
  22. package/dist/finding-record.d.ts +64 -0
  23. package/dist/finding-record.js +14 -0
  24. package/dist/finding-record.js.map +1 -0
  25. package/dist/findings.d.ts +157 -0
  26. package/dist/findings.js +227 -0
  27. package/dist/findings.js.map +1 -0
  28. package/dist/format.d.ts +444 -0
  29. package/dist/format.js +2 -0
  30. package/dist/format.js.map +1 -0
  31. package/dist/history-records.d.ts +112 -0
  32. package/dist/history-records.js +16 -0
  33. package/dist/history-records.js.map +1 -0
  34. package/dist/index.d.ts +45 -0
  35. package/dist/index.js +35 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/intent.d.ts +168 -0
  38. package/dist/intent.js +214 -0
  39. package/dist/intent.js.map +1 -0
  40. package/dist/presentation-record.d.ts +66 -0
  41. package/dist/presentation-record.js +9 -0
  42. package/dist/presentation-record.js.map +1 -0
  43. package/dist/promotion.d.ts +86 -0
  44. package/dist/promotion.js +104 -0
  45. package/dist/promotion.js.map +1 -0
  46. package/dist/reach.d.ts +154 -0
  47. package/dist/reach.js +47 -0
  48. package/dist/reach.js.map +1 -0
  49. package/dist/variation.d.ts +58 -0
  50. package/dist/variation.js +2 -0
  51. package/dist/variation.js.map +1 -0
  52. package/mark.svg +30 -0
  53. package/package.json +48 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # @variance-authority/report
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1d402d1: Carry the narrowing coordinate in the report, and print it in the summary header.
8
+
9
+ A `RunReport` now holds `narrowing`: the ref the run was told to observe from,
10
+ and where the recorded execution index stands — the commit it was written at and
11
+ how many files the working tree differs from it by. A run that narrowed nothing
12
+ carries the second half alone, so the coordinate is present whether or not it was
13
+ spent.
14
+
15
+ `variance_summary` prints it. Narrowing is an option and stays one; what this
16
+ refuses is the state where an agent works against a suite for weeks without ever
17
+ learning that an index is on disk and that the distance from it is a number. The
18
+ line names the commit and spells out the `variance run --since` that would use
19
+ it, and is omitted when there is no index, no position, or no distance.
20
+
21
+ `run` takes the coordinate as `index` and acts on it for nothing else.
22
+ `narrowingFor` resolves `since`, `against` and `index` together, so a caller
23
+ assembling a run reaches one call rather than three.
24
+
25
+ ### Patch Changes
26
+
27
+ - Updated dependencies [e8fee66]
28
+ - Updated dependencies [5c34e6d]
29
+ - @variance-authority/core@0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Machine Garden
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,258 @@
1
+ <p align="center"><img src="./mark.svg" alt="Variance Authority mark" width="72"></p>
2
+
3
+ # @variance-authority/report
4
+
5
+ > The shape a Variance Authority run leaves behind, so a person, a pull request and an agent read one format.
6
+
7
+ What a run leaves behind. A run compares one or more **subjects** — the pages,
8
+ routes, or components under test — and produces its answers in memory before the
9
+ process ends. A **`RunReport`** is the shape those answers take, so they can be
10
+ read afterwards: from a different process, on a different machine, by whoever or
11
+ whatever is asking. The format itself is plain data; only `report/file` touches a
12
+ disk, and it needs a path this process can read and write.
13
+
14
+ ```bash
15
+ npm install --save-dev @variance-authority/report
16
+ ```
17
+ ## Use this package when
18
+
19
+ Install `@variance-authority/report` when a producer and its readers need a
20
+ versioned run artifact without sharing a runner, browser, or transport. Use the
21
+ root entrypoint for in-memory report types and derivations. Use
22
+ `@variance-authority/report/file` only when this process should read or write a
23
+ local path; an object store, PR comment, or socket should carry the same
24
+ `RunReport` value without importing the file entrypoint.
25
+
26
+ This package does not compare anything itself. It has no browser, no renderer,
27
+ and no baseline store — pair it with whatever produces a `RunReport` (the
28
+ `variance` CLI, or a custom runner) and, on the reading side, with something
29
+ like `@variance-authority/mcp` or `@variance-authority/store`.
30
+
31
+ ## Package boundary
32
+
33
+ It has several readers. The CLI writes it, a PR comment renders it, the MCP tools
34
+ read it, and none of those is the format's home — a format owned by one reader
35
+ bends towards that reader.
36
+
37
+ The failure that shape produces is concrete: with these types living in
38
+ `@variance-authority/mcp`, the CLI depends on an agent protocol to describe its
39
+ own output.
40
+
41
+ ## Entrypoints
42
+
43
+ | entrypoint | requires | holds |
44
+ |---|---|---|
45
+ | `.` | nothing | `RunReport`, `ObservationRecord`, `PresentationSignalRecord`, `RegionRecord`, `NotObserved`, `clusterChanges`, `adjudicateRun` |
46
+ | `./file` | a filesystem | `readRunReport`, `writeRunReport` |
47
+
48
+ The split exists because a run happening on a pinned machine in CI and the
49
+ questions being asked on a laptop is exactly why this artifact exists — and a
50
+ consumer who moves it some other way (an object store, a PR comment, a socket)
51
+ wants the shapes and not the disk.
52
+
53
+ ## Smallest working path
54
+
55
+ ```ts
56
+ import { readRunReport, writeRunReport } from '@variance-authority/report/file';
57
+
58
+ // Throws on an unknown runVersion or malformed notObserved entry.
59
+ const report = await readRunReport('.variance/run.json');
60
+ console.log(report.runVersion, report.observations.length);
61
+
62
+ // A producer can write the same validated shape to a new path.
63
+ await writeRunReport('.variance/checked.json', report);
64
+ ```
65
+
66
+ The read returns a validated `RunReport`; the file entrypoint does not rerun a
67
+ browser or recompute observations. A report from a future format is refused,
68
+ and an absent `notObserved` field remains absent rather than being treated as an
69
+ empty coverage list.
70
+
71
+ ## The shape
72
+
73
+ A `RunReport` is one JSON value: run metadata plus one **`ObservationRecord`**
74
+ per subject. Trimmed to the fields most reports use:
75
+
76
+ ```json
77
+ {
78
+ "runVersion": 1,
79
+ "at": "2026-08-27T10:00:00.000Z",
80
+ "identity": {
81
+ "renderer": "playwright-chromium@1.49.0",
82
+ "engine": "chromium@131",
83
+ "platform": "linux-x64",
84
+ "deviceScaleFactor": 1,
85
+ "fonts": ["Inter"]
86
+ },
87
+ "retention": "durable",
88
+ "observations": [
89
+ {
90
+ "subject": "component:Button",
91
+ "verdict": "changed",
92
+ "because": "12 pixels differ inside the label",
93
+ "changedPixels": 12,
94
+ "regions": [
95
+ { "x": 4, "y": 8, "width": 60, "height": 18, "pixels": 12, "component": "Button", "cause": true }
96
+ ]
97
+ }
98
+ ]
99
+ }
100
+ ```
101
+
102
+ Each `ObservationRecord` holds one subject's **verdict** — `unchanged`,
103
+ `changed`, `new`, `incomparable`, or `ignored` — and, when it changed, the
104
+ **region**s responsible: attributed rectangles of the diff, each with the
105
+ component and pixel count that explain it.
106
+
107
+ A report also carries **`narrowing`**: the ref a run was told to observe from,
108
+ and where the recorded execution index stands — the commit it was written at,
109
+ and how many files the working tree differs from it by. A run that observed
110
+ everything carries the second half alone, which is what makes a narrowing option
111
+ visible to a reader who never passed one. Absent `index` means there is nothing
112
+ to diff from, either because no index was recorded or because the one on disk has
113
+ no position; it never means the index is current, which is `changed: 0`.
114
+
115
+ ## Presentation consequence is a signal, not a verdict
116
+
117
+ An `ObservationRecord` can carry one or more **signals** —
118
+ `ObservationRecord.signals`, each an independently measured boundary
119
+ (`document`, `pixels`, `accessibility`, `presentation`); a missing member means
120
+ that boundary was never measured, not that it was clean.
121
+ `ObservationRecord.signals.presentation` retains what changed in rendered
122
+ relationships beside the document, pixel, and accessibility boundaries. It is
123
+ orthogonal to the renderer's `layout`/`paint`/`composite` impact and does not
124
+ change the observation verdict.
125
+
126
+ A comparable signal carries the two presentation-report digests, information
127
+ counts, and `introduced`, `resolved`, or `persisted` relationship effects. An
128
+ empty `effects` list means both sides were measured and no relationship
129
+ consequence changed. An `incomparable` signal carries a reason and no effects;
130
+ an absent `presentation` member means nothing measured that boundary.
131
+
132
+ Product-aware collectors return the signal with their collected subject. The
133
+ CLI carries it through both a pixel-compared path and a **digest-settled**
134
+ one — a subject whose document digest matched the baseline's, so it was
135
+ declared unchanged without ever being repainted — and the JSON file,
136
+ HTML report, text report, MCP description, and Tribunal record read the same
137
+ stored value without re-running presentation analysis. The producing API and a complete
138
+ example live with `@variance-authority/presentation`.
139
+
140
+ ## Format derivations
141
+
142
+ None has a home in a reader. `clusterChanges` groups a run's changed subjects
143
+ by fingerprint — the shape digest carried on each region — into a **cluster**:
144
+ the set of subjects a single accept-or-reject decision covers. A token edit
145
+ across forty stories becomes one cluster, so it is **one decision presented
146
+ once** rather than forty. `adjudicateRun` reads those changes back against what
147
+ the author said they were doing:
148
+
149
+ ```ts
150
+ import { readRunReport } from '@variance-authority/report/file';
151
+ import { adjudicateRun, describeAdjudication } from '@variance-authority/report';
152
+
153
+ const report = await readRunReport('.variance/run.json');
154
+ const answer = adjudicateRun(
155
+ report,
156
+ [{ root: 'component:Button', reason: 'new brand accent', maxSubjects: 3 }],
157
+ { unchecked: ['bands'] },
158
+ );
159
+
160
+ console.log(describeAdjudication(answer));
161
+ ```
162
+
163
+ Each claim comes back `delivered`, `overreached`, `undelivered` or
164
+ `unobservable`, alongside the changes no claim covered. `undelivered` is the one
165
+ no diff can produce on its own — a component that **rendered and held still**,
166
+ which means the edit did not take. Telling that apart from *never rendered, so
167
+ nothing here is evidence* is what the composition census is for, and why the
168
+ adjudication lives beside the format rather than inside a reader.
169
+
170
+ `unchecked` is how a boundary keeps a claim it could not verify. A caller that
171
+ parses agent-supplied claims — the MCP tool, the CLI — passes the field names
172
+ this resolution does not read, and the answer ends `Not checked here: bands`
173
+ instead of reporting `delivered` about something nothing looked at. Dropping
174
+ them silently would be the more comfortable default and the worse one: the agent
175
+ would be told its band claim held.
176
+
177
+ ## Changelog derivation
178
+
179
+ A baseline update lands in a run of its own — `variance accept` promotes what a
180
+ reviewer looked at — and the artifact that lands says *what* the new baseline is
181
+ and nothing about **what the change was**. A month later, at the twelfth 2px
182
+ approval, the report that could have said is gone with the CI job.
183
+
184
+ `changelogOf` folds a report and the subjects that were actually accepted into
185
+ one record, and `renderCommitMessage` puts it where the baseline is: in the
186
+ commit message, as prose a reviewer reads and trailers a parser reads.
187
+
188
+ ```ts
189
+ import { readRunReport } from '@variance-authority/report/file';
190
+ import {
191
+ changelogOf,
192
+ isRecorded,
193
+ renderCommitMessage,
194
+ parseCommitMessage,
195
+ } from '@variance-authority/report';
196
+
197
+ const report = await readRunReport('.variance/run.json');
198
+ const accepted = report.observations
199
+ .filter((observation) => observation.verdict === 'changed')
200
+ .slice(0, 2)
201
+ .map((observation) => observation.subject);
202
+ if (accepted.length === 0) throw new Error('the report has no changed subject to promote');
203
+
204
+ const record = changelogOf({
205
+ report,
206
+ accepted,
207
+ selection: 'shape',
208
+ at: new Date().toISOString(),
209
+ project: 'design-system',
210
+ by: 'marina',
211
+ });
212
+
213
+ if (isRecorded(record)) {
214
+ const text = renderCommitMessage({ message: 'chore(variance): regenerate baselines', record });
215
+ parseCommitMessage(text); // the same record, out of a commit somebody squash-merged
216
+ }
217
+ ```
218
+
219
+ `changelogOf` takes:
220
+
221
+ | option | what it decides |
222
+ |---|---|
223
+ | `report` | the run the reviewer read. Nothing is re-derived from bytes; the entry is evidence about a decision rather than a second opinion about an image |
224
+ | `accepted` | the subjects actually promoted. The entries are the **intersection** with each cluster, never the cluster's own list — `accept --shape` refuses by name any subject where something else also moved, and an entry that copied the cluster would claim those too |
225
+ | `selection` | `named`, `shape` or `all`. Recorded rather than inferred: a regeneration under `--all` and a reviewed subject are different amounts of review, and a record that flattened them would let one read as the other |
226
+ | `at` | ISO 8601, injected. Nothing written into a record may come from a hidden clock |
227
+ | `project` | optional; the name the run is scoped by |
228
+ | `by` | optional; who accepted |
229
+ | `entries` | optional; entries a caller already formed, appended after the clustered ones. Region clustering is one producer of entries, not the definition of one — a change to an interface is the same kind of fact and has no rectangle, and the alternative was fabricating four numbers into a `RegionRecord` to get through the region path. `ungrouped` is untouched by them, and an entry with no fingerprint, no subjects, or fewer reached than promoted is refused rather than written |
230
+
231
+ It refuses, with a message rather than an empty record, when nothing was
232
+ accepted or when the report has no run id to attribute the baseline to.
233
+
234
+ The record omits a changed-pixel count (the regions already say where the
235
+ change was), rendered prose (drift is a token and two values instead), and any
236
+ derived total. `changelogVersion` changes only when an existing field's meaning
237
+ changes — a reader keeps keys it does not recognize, so adding a new field does
238
+ not need one.
239
+
240
+ `renderCommitMessage` takes `message` (the operator's subject line, emitted
241
+ unchanged) and `record`, and emits one versioned, opaque trailer per change.
242
+ `parseCommitMessage` scans the whole message for them rather than just the last
243
+ paragraph, since a squash merge can move them out of it.
244
+
245
+ Reading it back where baselines are commits is
246
+ `@variance-authority/store`'s `readChangelog`; where they are rows it
247
+ is `@variance-authority/tribunal`'s.
248
+
249
+ ## Validation boundaries
250
+
251
+ `readRunReport` validates rather than casts. It refuses:
252
+
253
+ - an unknown `runVersion`, including one from a future writer
254
+ - a malformed `notObserved` entry
255
+ - a presentation transition missing the before/after evidence it requires
256
+
257
+ A caller gets a fully-typed `RunReport` back, or a thrown error — never a
258
+ partially-parsed value with some fields silently absent.
@@ -0,0 +1,51 @@
1
+ import type { ChangelogRecord } from './changelog.js';
2
+ export interface CommitMessageOptions {
3
+ /** The subject line, in the operator's words. The record never invents one. */
4
+ readonly message: string;
5
+ readonly record: ChangelogRecord;
6
+ }
7
+ /**
8
+ * Render the commit that carries a baseline update.
9
+ *
10
+ * The subject line is the operator's, unchanged. A command that rewrote it would
11
+ * be overruling the one part of the message a project's own conventions govern —
12
+ * and every repository with a commit-message convention has one.
13
+ */
14
+ export declare function renderCommitMessage(options: CommitMessageOptions): string;
15
+ /**
16
+ * The body, as lines somebody scans rather than sentences they read.
17
+ *
18
+ * Exported because a commit message is not the only place these lines belong.
19
+ * An agent asking *what would this update record* before running `accept` has to
20
+ * be answered in the same words the commit will carry, or the preview becomes a
21
+ * second description of a baseline update — and the two would then be edited
22
+ * separately until one of them was wrong.
23
+ *
24
+ * This lands in **every** baseline commit. That is the constraint the shape is
25
+ * chosen for: a paragraph explaining what `--all` means is true, and by the
26
+ * fourth commit nobody reads it, and by the tenth it is what `git log` looks
27
+ * like. So the body is a table — one line per change, fixed columns, fingerprint
28
+ * first, because that is the token a reader recognises when the same shape comes
29
+ * back three months later.
30
+ *
31
+ * Nothing here argues. Everything that used to be argued in prose is in the
32
+ * trailers, where the reader that needs it decodes it and the person scrolling
33
+ * past does not pay for it.
34
+ */
35
+ export declare function changelogBody(record: ChangelogRecord): readonly string[];
36
+ /**
37
+ * Read the record out of a commit message, or `undefined` when it carries none.
38
+ *
39
+ * `undefined` is *this commit is not a baseline update*, which is the answer for
40
+ * almost every commit in a repository and is not a failure. A malformed one is,
41
+ * and it throws: a record that half-parsed would answer "what changed here" with
42
+ * the part that happened to survive.
43
+ *
44
+ * Keys this reader does not know are **kept, not refused**. A field added by a
45
+ * later writer is not a corrupt record, it is a newer one, and a reader that
46
+ * threw on it would make every additive change a breaking change — which is the
47
+ * pressure that turns a version number into a wall nobody may cross. The version
48
+ * is checked instead, and it moves only when an existing field changes meaning.
49
+ */
50
+ export declare function parseCommitMessage(text: string): ChangelogRecord | undefined;
51
+ //# sourceMappingURL=changelog-message.d.ts.map
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The record as a commit message, and back.
3
+ *
4
+ * Where baselines are files in the repository, the commit that updates them is
5
+ * the only artifact guaranteed to still exist when somebody asks why. It already
6
+ * has a place for an explanation, and every tool a reader has — `git log`,
7
+ * `git blame`, a pull request page — already shows it. So the message *is* the
8
+ * store, and this is its codec.
9
+ *
10
+ * ## Two audiences, two halves, one message
11
+ *
12
+ * The subject line and the table under it are for whoever scrolls `git log`. The
13
+ * **trailers** are for the reader that comes back with a question. Asking one
14
+ * string to be both readable and parseable is how a format acquires escaping
15
+ * rules that a component named `Card: small` then breaks, so the halves are
16
+ * separate and only one of them is parsed.
17
+ *
18
+ * That split is also what keeps the readable half short. It carries no field it
19
+ * has to carry — the record is complete in the trailers either way — so it
20
+ * carries the four things a person scanning a log is actually looking for, and a
21
+ * change to what is *displayed* is never a change to what was *recorded*.
22
+ *
23
+ * ## Why the payload is opaque
24
+ *
25
+ * A trailer value is a single line of arbitrary text, and every field here is a
26
+ * string somebody else chose: subject ids, component names, an intent sentence,
27
+ * a file path. Base64url removes the entire question — no delimiter to collide
28
+ * with, no colon to split on twice, nothing that can become shell or markdown
29
+ * syntax on the way through a pull-request body.
30
+ *
31
+ * The `v1` prefix in front of it is what lets a later reader **refuse** rather
32
+ * than half-understand. A commit written by a newer writer is named as such;
33
+ * partly parsing it would answer a question about a change with a subset of what
34
+ * that change was.
35
+ *
36
+ * ## What git does to this, and what it does not
37
+ *
38
+ * Trailers are repeated keys, one per entry, which git treats as ordered and
39
+ * preserves. Values never contain a newline, so nothing ever needs unfolding.
40
+ *
41
+ * The parser scans the **whole message**, not the last paragraph. That deviates
42
+ * from git's own trailer rules deliberately: a squash merge concatenates commit
43
+ * bodies, so a baseline commit's trailers land mid-message and `%(trailers)`
44
+ * finds nothing. A false positive is not reachable — a line that matches must
45
+ * also decode as base64 and parse as a versioned record.
46
+ *
47
+ * What this cannot promise is append-only. `--amend` and `reword` rewrite a
48
+ * message in place and nothing here observes it. That is a property of the
49
+ * medium, stated rather than papered over: the trigger-backed guarantee belongs
50
+ * to the review database, and a repository is a place where history is editable
51
+ * by whoever can push.
52
+ */
53
+ /** Head fields: which run, at which commit, and how it was accepted. */
54
+ const HEAD = 'Variance-Run';
55
+ /** One per change, repeated. */
56
+ const CHANGE = 'Variance-Change';
57
+ const VERSION = 'v1';
58
+ /** How many changes the prose names before it starts counting instead. */
59
+ const NAMED_IN_PROSE = 20;
60
+ /**
61
+ * Render the commit that carries a baseline update.
62
+ *
63
+ * The subject line is the operator's, unchanged. A command that rewrote it would
64
+ * be overruling the one part of the message a project's own conventions govern —
65
+ * and every repository with a commit-message convention has one.
66
+ */
67
+ export function renderCommitMessage(options) {
68
+ const { record } = options;
69
+ const head = { ...record, entries: undefined };
70
+ return [
71
+ options.message,
72
+ '',
73
+ ...changelogBody(record),
74
+ '',
75
+ `${HEAD}: ${VERSION} ${encode(head)}`,
76
+ ...record.entries.map((entry) => `${CHANGE}: ${VERSION} ${encode(entry)}`),
77
+ '',
78
+ ].join('\n');
79
+ }
80
+ /**
81
+ * The body, as lines somebody scans rather than sentences they read.
82
+ *
83
+ * Exported because a commit message is not the only place these lines belong.
84
+ * An agent asking *what would this update record* before running `accept` has to
85
+ * be answered in the same words the commit will carry, or the preview becomes a
86
+ * second description of a baseline update — and the two would then be edited
87
+ * separately until one of them was wrong.
88
+ *
89
+ * This lands in **every** baseline commit. That is the constraint the shape is
90
+ * chosen for: a paragraph explaining what `--all` means is true, and by the
91
+ * fourth commit nobody reads it, and by the tenth it is what `git log` looks
92
+ * like. So the body is a table — one line per change, fixed columns, fingerprint
93
+ * first, because that is the token a reader recognises when the same shape comes
94
+ * back three months later.
95
+ *
96
+ * Nothing here argues. Everything that used to be argued in prose is in the
97
+ * trailers, where the reader that needs it decodes it and the person scrolling
98
+ * past does not pay for it.
99
+ */
100
+ export function changelogBody(record) {
101
+ const lines = [];
102
+ // The operator's own sentence, unlabelled. It is the one line here a person
103
+ // wrote, and an `Intent:` prefix would be this format taking credit for it.
104
+ if (record.intent !== undefined)
105
+ lines.push(record.intent, '');
106
+ for (const entry of record.entries.slice(0, NAMED_IN_PROSE))
107
+ lines.push(describe(entry));
108
+ if (record.entries.length > NAMED_IN_PROSE) {
109
+ lines.push(`+${String(record.entries.length - NAMED_IN_PROSE)} more in trailers`);
110
+ }
111
+ // On its own line rather than folded into a change: nothing in the run said
112
+ // what changed in these subjects, and a count that cannot become a row must
113
+ // not be printed as one.
114
+ if (record.ungrouped > 0)
115
+ lines.push(`+${String(record.ungrouped)} unshaped`);
116
+ // The one finding that earns a full line of its own, because it is about what
117
+ // no single review could see — including the one that produced this commit.
118
+ for (const drift of record.drift ?? []) {
119
+ lines.push(`drift ${drift.token} ${drift.from} -> ${drift.to} over ${String(drift.steps)} approvals`);
120
+ }
121
+ lines.push('', `run ${record.run} @ ${record.commit} ${selection(record.selection)}` +
122
+ `${record.by === undefined ? '' : ` ${record.by}`}`);
123
+ return lines;
124
+ }
125
+ /**
126
+ * One change, as columns: what it is, where it was, how much of it landed.
127
+ *
128
+ * The fingerprint leads because it is the only field on the line that is stable
129
+ * *across* commits — the same shape returning is the finding, and a reader who
130
+ * has seen `v1:9a3f1c2e04` before recognises it without reading the rest. It is
131
+ * also the exact string `accept --shape` and an ignore take, so the line is
132
+ * something to copy rather than something to interpret.
133
+ *
134
+ * A shape with no component keeps its digest rather than borrowing a name, and
135
+ * says which it is. "An unnamed shape" is a weaker claim than a component, and a
136
+ * line that hid the difference would let a bulk decision be read back as an
137
+ * attributed one.
138
+ *
139
+ * `3/4` wherever the promotion was partial: the subjects this shape reached and
140
+ * did not take are the part of the change still sitting in the suite, and a bare
141
+ * count reads as though the whole shape landed.
142
+ */
143
+ function describe(entry) {
144
+ const what = entry.component === undefined
145
+ ? `unnamed ${entry.fingerprint.slice(0, 12)}`
146
+ : `${entry.component}${entry.file === undefined ? '' : ` ${entry.file}`}`;
147
+ const subjects = entry.reached > entry.subjects.length
148
+ ? `${String(entry.subjects.length)}/${String(entry.reached)}`
149
+ : String(entry.subjects.length);
150
+ return `${entry.fingerprint} ${what} ${subjects}${entry.cause ? '' : ' collateral'}`;
151
+ }
152
+ /**
153
+ * The flag that chose the subjects, as the flag.
154
+ *
155
+ * `--all` accepts changed subjects as well as new ones — unattended, that is
156
+ * regeneration rather than review, and it is the distinction this whole record
157
+ * exists to keep. Carried as the flag rather than as the clause explaining it:
158
+ * the clause is identical in every commit ever written, and the flag is the part
159
+ * that differs between them.
160
+ */
161
+ function selection(kind) {
162
+ if (kind === 'all')
163
+ return '--all';
164
+ if (kind === 'shape')
165
+ return '--shape';
166
+ return '--subject';
167
+ }
168
+ /**
169
+ * Read the record out of a commit message, or `undefined` when it carries none.
170
+ *
171
+ * `undefined` is *this commit is not a baseline update*, which is the answer for
172
+ * almost every commit in a repository and is not a failure. A malformed one is,
173
+ * and it throws: a record that half-parsed would answer "what changed here" with
174
+ * the part that happened to survive.
175
+ *
176
+ * Keys this reader does not know are **kept, not refused**. A field added by a
177
+ * later writer is not a corrupt record, it is a newer one, and a reader that
178
+ * threw on it would make every additive change a breaking change — which is the
179
+ * pressure that turns a version number into a wall nobody may cross. The version
180
+ * is checked instead, and it moves only when an existing field changes meaning.
181
+ */
182
+ export function parseCommitMessage(text) {
183
+ const head = payload(text, HEAD);
184
+ if (head === undefined)
185
+ return undefined;
186
+ const record = decode(head, HEAD);
187
+ if (record.changelogVersion !== 1) {
188
+ throw new Error(`a commit carries a variance changelog with changelogVersion=${String(record.changelogVersion)}, ` +
189
+ 'which this reader does not know. Partly reading it would describe a baseline update by ' +
190
+ 'whichever fields happened to be recognised');
191
+ }
192
+ const entries = payloads(text, CHANGE).map((value) => decode(value, CHANGE));
193
+ for (const entry of entries) {
194
+ if (typeof entry.fingerprint !== 'string' || !Array.isArray(entry.subjects)) {
195
+ throw new Error(`a commit carries a \`${CHANGE}\` trailer that is not a change: it has no fingerprint or ` +
196
+ 'no subject list');
197
+ }
198
+ }
199
+ return { ...record, entries };
200
+ }
201
+ /**
202
+ * Every value for a key, in message order.
203
+ *
204
+ * Anchored on the key and the version together, so a line mentioning the trailer
205
+ * name in prose — a commit that *discusses* this format, of which this repository
206
+ * has several — is not mistaken for one.
207
+ */
208
+ function payloads(text, key) {
209
+ const pattern = new RegExp(`^[ \\t]*${key}:[ \\t]+(\\S+)[ \\t]+(\\S+)[ \\t]*$`, 'gm');
210
+ return [...text.matchAll(pattern)].map(([, version, value]) => {
211
+ if (version !== VERSION) {
212
+ throw new Error(`a commit carries a \`${key}\` trailer encoded as ${String(version)}, and this reader ` +
213
+ `understands ${VERSION}`);
214
+ }
215
+ return value ?? '';
216
+ });
217
+ }
218
+ function payload(text, key) {
219
+ const found = payloads(text, key);
220
+ if (found.length > 1) {
221
+ throw new Error(`a commit carries ${String(found.length)} \`${key}\` trailers, and a commit updates ` +
222
+ 'baselines for one run. Two heads in one message is a rewritten history, not a record');
223
+ }
224
+ return found[0];
225
+ }
226
+ function encode(value) {
227
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
228
+ let binary = '';
229
+ for (const byte of bytes)
230
+ binary += String.fromCharCode(byte);
231
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
232
+ }
233
+ function decode(value, key) {
234
+ try {
235
+ const binary = atob(value.replace(/-/g, '+').replace(/_/g, '/'));
236
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
237
+ return JSON.parse(new TextDecoder().decode(bytes));
238
+ }
239
+ catch (error) {
240
+ throw new Error(`a commit carries a \`${key}\` trailer this reader cannot decode ` +
241
+ `(${error instanceof Error ? error.message : String(error)})`);
242
+ }
243
+ }
244
+ //# sourceMappingURL=changelog-message.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"changelog-message.js","sourceRoot":"","sources":["../src/changelog-message.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ChangelogEntry, ChangelogRecord } from './changelog.js';\n\n/**\n * The record as a commit message, and back.\n *\n * Where baselines are files in the repository, the commit that updates them is\n * the only artifact guaranteed to still exist when somebody asks why. It already\n * has a place for an explanation, and every tool a reader has — `git log`,\n * `git blame`, a pull request page — already shows it. So the message *is* the\n * store, and this is its codec.\n *\n * ## Two audiences, two halves, one message\n *\n * The subject line and the table under it are for whoever scrolls `git log`. The\n * **trailers** are for the reader that comes back with a question. Asking one\n * string to be both readable and parseable is how a format acquires escaping\n * rules that a component named `Card: small` then breaks, so the halves are\n * separate and only one of them is parsed.\n *\n * That split is also what keeps the readable half short. It carries no field it\n * has to carry — the record is complete in the trailers either way — so it\n * carries the four things a person scanning a log is actually looking for, and a\n * change to what is *displayed* is never a change to what was *recorded*.\n *\n * ## Why the payload is opaque\n *\n * A trailer value is a single line of arbitrary text, and every field here is a\n * string somebody else chose: subject ids, component names, an intent sentence,\n * a file path. Base64url removes the entire question — no delimiter to collide\n * with, no colon to split on twice, nothing that can become shell or markdown\n * syntax on the way through a pull-request body.\n *\n * The `v1` prefix in front of it is what lets a later reader **refuse** rather\n * than half-understand. A commit written by a newer writer is named as such;\n * partly parsing it would answer a question about a change with a subset of what\n * that change was.\n *\n * ## What git does to this, and what it does not\n *\n * Trailers are repeated keys, one per entry, which git treats as ordered and\n * preserves. Values never contain a newline, so nothing ever needs unfolding.\n *\n * The parser scans the **whole message**, not the last paragraph. That deviates\n * from git's own trailer rules deliberately: a squash merge concatenates commit\n * bodies, so a baseline commit's trailers land mid-message and `%(trailers)`\n * finds nothing. A false positive is not reachable — a line that matches must\n * also decode as base64 and parse as a versioned record.\n *\n * What this cannot promise is append-only. `--amend` and `reword` rewrite a\n * message in place and nothing here observes it. That is a property of the\n * medium, stated rather than papered over: the trigger-backed guarantee belongs\n * to the review database, and a repository is a place where history is editable\n * by whoever can push.\n */\n\n/** Head fields: which run, at which commit, and how it was accepted. */\nconst HEAD = 'Variance-Run';\n\n/** One per change, repeated. */\nconst CHANGE = 'Variance-Change';\n\nconst VERSION = 'v1';\n\n/** How many changes the prose names before it starts counting instead. */\nconst NAMED_IN_PROSE = 20;\n\nexport interface CommitMessageOptions {\n /** The subject line, in the operator's words. The record never invents one. */\n readonly message: string;\n readonly record: ChangelogRecord;\n}\n\n/**\n * Render the commit that carries a baseline update.\n *\n * The subject line is the operator's, unchanged. A command that rewrote it would\n * be overruling the one part of the message a project's own conventions govern —\n * and every repository with a commit-message convention has one.\n */\nexport function renderCommitMessage(options: CommitMessageOptions): string {\n const { record } = options;\n const head: Record<string, unknown> = { ...record, entries: undefined };\n\n return [\n options.message,\n '',\n ...changelogBody(record),\n '',\n `${HEAD}: ${VERSION} ${encode(head)}`,\n ...record.entries.map((entry) => `${CHANGE}: ${VERSION} ${encode(entry)}`),\n '',\n ].join('\\n');\n}\n\n/**\n * The body, as lines somebody scans rather than sentences they read.\n *\n * Exported because a commit message is not the only place these lines belong.\n * An agent asking *what would this update record* before running `accept` has to\n * be answered in the same words the commit will carry, or the preview becomes a\n * second description of a baseline update — and the two would then be edited\n * separately until one of them was wrong.\n *\n * This lands in **every** baseline commit. That is the constraint the shape is\n * chosen for: a paragraph explaining what `--all` means is true, and by the\n * fourth commit nobody reads it, and by the tenth it is what `git log` looks\n * like. So the body is a table — one line per change, fixed columns, fingerprint\n * first, because that is the token a reader recognises when the same shape comes\n * back three months later.\n *\n * Nothing here argues. Everything that used to be argued in prose is in the\n * trailers, where the reader that needs it decodes it and the person scrolling\n * past does not pay for it.\n */\nexport function changelogBody(record: ChangelogRecord): readonly string[] {\n const lines: string[] = [];\n\n // The operator's own sentence, unlabelled. It is the one line here a person\n // wrote, and an `Intent:` prefix would be this format taking credit for it.\n if (record.intent !== undefined) lines.push(record.intent, '');\n\n for (const entry of record.entries.slice(0, NAMED_IN_PROSE)) lines.push(describe(entry));\n if (record.entries.length > NAMED_IN_PROSE) {\n lines.push(`+${String(record.entries.length - NAMED_IN_PROSE)} more in trailers`);\n }\n\n // On its own line rather than folded into a change: nothing in the run said\n // what changed in these subjects, and a count that cannot become a row must\n // not be printed as one.\n if (record.ungrouped > 0) lines.push(`+${String(record.ungrouped)} unshaped`);\n\n // The one finding that earns a full line of its own, because it is about what\n // no single review could see — including the one that produced this commit.\n for (const drift of record.drift ?? []) {\n lines.push(\n `drift ${drift.token} ${drift.from} -> ${drift.to} over ${String(drift.steps)} approvals`,\n );\n }\n\n lines.push(\n '',\n `run ${record.run} @ ${record.commit} ${selection(record.selection)}` +\n `${record.by === undefined ? '' : ` ${record.by}`}`,\n );\n\n return lines;\n}\n\n/**\n * One change, as columns: what it is, where it was, how much of it landed.\n *\n * The fingerprint leads because it is the only field on the line that is stable\n * *across* commits — the same shape returning is the finding, and a reader who\n * has seen `v1:9a3f1c2e04` before recognises it without reading the rest. It is\n * also the exact string `accept --shape` and an ignore take, so the line is\n * something to copy rather than something to interpret.\n *\n * A shape with no component keeps its digest rather than borrowing a name, and\n * says which it is. \"An unnamed shape\" is a weaker claim than a component, and a\n * line that hid the difference would let a bulk decision be read back as an\n * attributed one.\n *\n * `3/4` wherever the promotion was partial: the subjects this shape reached and\n * did not take are the part of the change still sitting in the suite, and a bare\n * count reads as though the whole shape landed.\n */\nfunction describe(entry: ChangelogEntry): string {\n const what =\n entry.component === undefined\n ? `unnamed ${entry.fingerprint.slice(0, 12)}`\n : `${entry.component}${entry.file === undefined ? '' : ` ${entry.file}`}`;\n\n const subjects =\n entry.reached > entry.subjects.length\n ? `${String(entry.subjects.length)}/${String(entry.reached)}`\n : String(entry.subjects.length);\n\n return `${entry.fingerprint} ${what} ${subjects}${entry.cause ? '' : ' collateral'}`;\n}\n\n/**\n * The flag that chose the subjects, as the flag.\n *\n * `--all` accepts changed subjects as well as new ones — unattended, that is\n * regeneration rather than review, and it is the distinction this whole record\n * exists to keep. Carried as the flag rather than as the clause explaining it:\n * the clause is identical in every commit ever written, and the flag is the part\n * that differs between them.\n */\nfunction selection(kind: ChangelogRecord['selection']): string {\n if (kind === 'all') return '--all';\n if (kind === 'shape') return '--shape';\n return '--subject';\n}\n\n/**\n * Read the record out of a commit message, or `undefined` when it carries none.\n *\n * `undefined` is *this commit is not a baseline update*, which is the answer for\n * almost every commit in a repository and is not a failure. A malformed one is,\n * and it throws: a record that half-parsed would answer \"what changed here\" with\n * the part that happened to survive.\n *\n * Keys this reader does not know are **kept, not refused**. A field added by a\n * later writer is not a corrupt record, it is a newer one, and a reader that\n * threw on it would make every additive change a breaking change — which is the\n * pressure that turns a version number into a wall nobody may cross. The version\n * is checked instead, and it moves only when an existing field changes meaning.\n */\nexport function parseCommitMessage(text: string): ChangelogRecord | undefined {\n const head = payload(text, HEAD);\n if (head === undefined) return undefined;\n\n const record = decode(head, HEAD) as Omit<ChangelogRecord, 'entries'>;\n if (record.changelogVersion !== 1) {\n throw new Error(\n `a commit carries a variance changelog with changelogVersion=${String(record.changelogVersion)}, ` +\n 'which this reader does not know. Partly reading it would describe a baseline update by ' +\n 'whichever fields happened to be recognised',\n );\n }\n\n const entries = payloads(text, CHANGE).map((value) => decode(value, CHANGE) as ChangelogEntry);\n for (const entry of entries) {\n if (typeof entry.fingerprint !== 'string' || !Array.isArray(entry.subjects)) {\n throw new Error(\n `a commit carries a \\`${CHANGE}\\` trailer that is not a change: it has no fingerprint or ` +\n 'no subject list',\n );\n }\n }\n\n return { ...record, entries };\n}\n\n/**\n * Every value for a key, in message order.\n *\n * Anchored on the key and the version together, so a line mentioning the trailer\n * name in prose — a commit that *discusses* this format, of which this repository\n * has several — is not mistaken for one.\n */\nfunction payloads(text: string, key: string): readonly string[] {\n const pattern = new RegExp(`^[ \\\\t]*${key}:[ \\\\t]+(\\\\S+)[ \\\\t]+(\\\\S+)[ \\\\t]*$`, 'gm');\n\n return [...text.matchAll(pattern)].map(([, version, value]) => {\n if (version !== VERSION) {\n throw new Error(\n `a commit carries a \\`${key}\\` trailer encoded as ${String(version)}, and this reader ` +\n `understands ${VERSION}`,\n );\n }\n return value ?? '';\n });\n}\n\nfunction payload(text: string, key: string): string | undefined {\n const found = payloads(text, key);\n if (found.length > 1) {\n throw new Error(\n `a commit carries ${String(found.length)} \\`${key}\\` trailers, and a commit updates ` +\n 'baselines for one run. Two heads in one message is a rewritten history, not a record',\n );\n }\n return found[0];\n}\n\nfunction encode(value: unknown): string {\n const bytes = new TextEncoder().encode(JSON.stringify(value));\n let binary = '';\n for (const byte of bytes) binary += String.fromCharCode(byte);\n\n return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n}\n\nfunction decode(value: string, key: string): unknown {\n try {\n const binary = atob(value.replace(/-/g, '+').replace(/_/g, '/'));\n const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));\n return JSON.parse(new TextDecoder().decode(bytes));\n } catch (error) {\n throw new Error(\n `a commit carries a \\`${key}\\` trailer this reader cannot decode ` +\n `(${error instanceof Error ? error.message : String(error)})`,\n );\n }\n}\n"]}