@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.
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +258 -0
- package/dist/changelog-message.d.ts +51 -0
- package/dist/changelog-message.js +244 -0
- package/dist/changelog-message.js.map +1 -0
- package/dist/changelog.d.ts +231 -0
- package/dist/changelog.js +96 -0
- package/dist/changelog.js.map +1 -0
- package/dist/cluster.d.ts +112 -0
- package/dist/cluster.js +109 -0
- package/dist/cluster.js.map +1 -0
- package/dist/composition.d.ts +248 -0
- package/dist/composition.js +33 -0
- package/dist/composition.js.map +1 -0
- package/dist/declarations.d.ts +266 -0
- package/dist/declarations.js +212 -0
- package/dist/declarations.js.map +1 -0
- package/dist/file.d.ts +28 -0
- package/dist/file.js +151 -0
- package/dist/file.js.map +1 -0
- package/dist/finding-record.d.ts +64 -0
- package/dist/finding-record.js +14 -0
- package/dist/finding-record.js.map +1 -0
- package/dist/findings.d.ts +157 -0
- package/dist/findings.js +227 -0
- package/dist/findings.js.map +1 -0
- package/dist/format.d.ts +444 -0
- package/dist/format.js +2 -0
- package/dist/format.js.map +1 -0
- package/dist/history-records.d.ts +112 -0
- package/dist/history-records.js +16 -0
- package/dist/history-records.js.map +1 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/intent.d.ts +168 -0
- package/dist/intent.js +214 -0
- package/dist/intent.js.map +1 -0
- package/dist/presentation-record.d.ts +66 -0
- package/dist/presentation-record.js +9 -0
- package/dist/presentation-record.js.map +1 -0
- package/dist/promotion.d.ts +86 -0
- package/dist/promotion.js +104 -0
- package/dist/promotion.js.map +1 -0
- package/dist/reach.d.ts +154 -0
- package/dist/reach.js +47 -0
- package/dist/reach.js.map +1 -0
- package/dist/variation.d.ts +58 -0
- package/dist/variation.js +2 -0
- package/dist/variation.js.map +1 -0
- package/mark.svg +30 -0
- package/package.json +48 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { RunReport } from './format.js';
|
|
2
|
+
/**
|
|
3
|
+
* Why a baseline is what it is — written where the baseline is written.
|
|
4
|
+
*
|
|
5
|
+
* A baseline update happens in a run of its own: `variance accept` promotes the
|
|
6
|
+
* candidates a reviewer looked at, and the artifact that lands is a directory of
|
|
7
|
+
* PNGs, or a row in a review database. Both record *what* the new baseline is.
|
|
8
|
+
* Neither records **what the change was**, and by the time anybody asks — a month
|
|
9
|
+
* later, at the twelfth 2px approval — the report that could have said is gone
|
|
10
|
+
* with the CI job that wrote it.
|
|
11
|
+
*
|
|
12
|
+
* So the explanation is written at the only moment it exists, into the same
|
|
13
|
+
* artifact as the bytes it explains: a commit message where baselines are commits,
|
|
14
|
+
* a row where they are rows. This module is the part neither of those owns — the
|
|
15
|
+
* derivation from a report, and the encoding that survives the trip back.
|
|
16
|
+
*
|
|
17
|
+
* ## The unit is a change, not a screenshot
|
|
18
|
+
*
|
|
19
|
+
* One entry per **cluster** ([`cluster.ts`](./cluster.ts)), because that is
|
|
20
|
+
* already this project's answer to the same question one level up: a token edit
|
|
21
|
+
* across forty stories is one decision, and forty entries would reproduce exactly
|
|
22
|
+
* the review problem clustering exists to solve. A changelog that logged
|
|
23
|
+
* screenshots would be forty lines nobody reads, ending in the same place as a
|
|
24
|
+
* commit message that says `regenerate baselines`.
|
|
25
|
+
*
|
|
26
|
+
* ## Only what was accepted, and only what the report already said
|
|
27
|
+
*
|
|
28
|
+
* The subjects on an entry are the intersection of the cluster with the subjects
|
|
29
|
+
* this command actually promoted — never the cluster's own list. `accept --shape`
|
|
30
|
+
* refuses by name any subject where something else also moved, and an entry that
|
|
31
|
+
* copied the cluster would claim those too. What the shape *reached* is carried
|
|
32
|
+
* as a count beside it, so the shortfall is visible rather than absent.
|
|
33
|
+
*
|
|
34
|
+
* Nothing here re-derives anything from bytes. The entry is built from the report
|
|
35
|
+
* the reviewer read, which is what makes it evidence about a decision rather than
|
|
36
|
+
* a second opinion about an image.
|
|
37
|
+
*
|
|
38
|
+
* ## What a record carries, and what it deliberately does not
|
|
39
|
+
*
|
|
40
|
+
* This outlives everything around it. The report is gone with the CI job, the
|
|
41
|
+
* reviewer has left the project, the file has been renamed twice — and the record
|
|
42
|
+
* is still in the log, being read by somebody who cannot check any of it. So a
|
|
43
|
+
* field belongs here only if it will still mean the same thing then.
|
|
44
|
+
*
|
|
45
|
+
* Three that would not, and are absent:
|
|
46
|
+
*
|
|
47
|
+
* **A pixel count.** It measures *displacement* rather than magnitude — a 1px
|
|
48
|
+
* token edit high on a page moves everything below it — and it is machine-bound
|
|
49
|
+
* on top of that. Two commits carrying `1,180px` and `2,180px` invite a
|
|
50
|
+
* comparison that is not valid between them, which is the same argument that
|
|
51
|
+
* keeps pixels out of the history store.
|
|
52
|
+
*
|
|
53
|
+
* **A rendered sentence.** Drift is carried as `token`, `from`, `to` and a step
|
|
54
|
+
* count, never as the phrasing today's reporter happened to use. Prose in a
|
|
55
|
+
* durable record freezes one wording forever and goes stale against the tool that
|
|
56
|
+
* writes the new one.
|
|
57
|
+
*
|
|
58
|
+
* **Anything derivable.** The accepted total is the entries' subjects plus the
|
|
59
|
+
* ungrouped count. Recording it as well would create two numbers that can
|
|
60
|
+
* disagree after a partial read, and no way to decide which is the record.
|
|
61
|
+
*
|
|
62
|
+
* What stays is what a later reader can still verify or act on: a
|
|
63
|
+
* content-addressed fingerprint they can pass straight back to `accept --shape`,
|
|
64
|
+
* the subject ids it landed in, what the shape reached, and the run and commit it
|
|
65
|
+
* all came from. `component` and `file` stay as *where it was then* — a rename
|
|
66
|
+
* makes them stale, and the fingerprint beside them does not.
|
|
67
|
+
*
|
|
68
|
+
* ## Version 1, and what may be added without a version 2
|
|
69
|
+
*
|
|
70
|
+
* A reader ignores keys it does not know, so a field added later is readable by
|
|
71
|
+
* readers that predate it. The version bumps only when the *meaning* of an
|
|
72
|
+
* existing field changes — that is the case where partly understanding a record
|
|
73
|
+
* is worse than refusing it, and refusing is what a version is for.
|
|
74
|
+
*/
|
|
75
|
+
/** One change, as it was accepted. */
|
|
76
|
+
export interface ChangelogEntry {
|
|
77
|
+
/** The shape digest, as `accept --shape` and an ignore both take it. */
|
|
78
|
+
readonly fingerprint: string;
|
|
79
|
+
/**
|
|
80
|
+
* The component the semantic tier attributed this shape to.
|
|
81
|
+
*
|
|
82
|
+
* Absent means the shape was grouped by silhouette alone — the ephemeral and
|
|
83
|
+
* raster-only paths, where no document survived to name a cause. Surfaced
|
|
84
|
+
* rather than smoothed over, because a later reader asking *what changed in
|
|
85
|
+
* `Card`* must not be answered from a group that never named one.
|
|
86
|
+
*/
|
|
87
|
+
readonly component?: string;
|
|
88
|
+
/** A source location for the component, when one was resolved. */
|
|
89
|
+
readonly file?: string;
|
|
90
|
+
/** Accepted subjects this shape landed in, in report order. */
|
|
91
|
+
readonly subjects: readonly string[];
|
|
92
|
+
/**
|
|
93
|
+
* Subjects the shape reached in the run, accepted or not.
|
|
94
|
+
*
|
|
95
|
+
* Equal to `subjects.length` in the ordinary case and larger whenever the
|
|
96
|
+
* command refused part of the cluster. The difference is the part of the change
|
|
97
|
+
* this baseline update did **not** take, and a record that dropped it would read
|
|
98
|
+
* as though the whole shape had been promoted.
|
|
99
|
+
*/
|
|
100
|
+
readonly reached: number;
|
|
101
|
+
/**
|
|
102
|
+
* `true` when the semantic tier called this shape a cause somewhere.
|
|
103
|
+
*
|
|
104
|
+
* A claim the run made, recorded as the run's, because it is what the reviewer
|
|
105
|
+
* was shown when they approved. A later tier that would group the same pixels
|
|
106
|
+
* differently does not make this entry wrong; it makes it historical, which is
|
|
107
|
+
* what a changelog is.
|
|
108
|
+
*/
|
|
109
|
+
readonly cause: boolean;
|
|
110
|
+
}
|
|
111
|
+
/** How the subjects to accept were chosen. */
|
|
112
|
+
export type ChangelogSelection = 'named' | 'all' | 'shape';
|
|
113
|
+
/**
|
|
114
|
+
* A token whose value moved, and how far it has drifted getting there.
|
|
115
|
+
*
|
|
116
|
+
* Four fields and no sentence. The report phrases this finding for a reviewer and
|
|
117
|
+
* phrases it well; storing that phrasing would preserve one release's wording in
|
|
118
|
+
* every commit written under it, and a reader in two years would be reading the
|
|
119
|
+
* output of a reporter nobody runs any more. The facts are what is durable, and
|
|
120
|
+
* whoever prints them can say it however they say it then.
|
|
121
|
+
*/
|
|
122
|
+
export interface ChangelogDrift {
|
|
123
|
+
readonly token: string;
|
|
124
|
+
readonly from: string;
|
|
125
|
+
readonly to: string;
|
|
126
|
+
/** Value changes behind it. One is not drift; this is never below two. */
|
|
127
|
+
readonly steps: number;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* What one baseline update was.
|
|
131
|
+
*
|
|
132
|
+
* Drift sits on the record rather than on an entry, and that is a join that does
|
|
133
|
+
* not exist rather than a placement preference: drift is keyed by token and a
|
|
134
|
+
* cluster is keyed by fingerprint, and nothing in this project maps one to the
|
|
135
|
+
* other. It is a fact about the run, recorded at the level it is true at.
|
|
136
|
+
*/
|
|
137
|
+
export interface ChangelogRecord {
|
|
138
|
+
/** Bumped when the stored shape changes in a way an older reader would misread. */
|
|
139
|
+
readonly changelogVersion: 1;
|
|
140
|
+
/** The run whose candidates were promoted. */
|
|
141
|
+
readonly run: string;
|
|
142
|
+
/** The commit that run observed — not the commit this record lands in. */
|
|
143
|
+
readonly commit: string;
|
|
144
|
+
readonly at: string;
|
|
145
|
+
readonly project?: string;
|
|
146
|
+
/** What the run was comparing, in the author's words. */
|
|
147
|
+
readonly intent?: string;
|
|
148
|
+
/**
|
|
149
|
+
* Who accepted, when the surface that recorded it knows.
|
|
150
|
+
*
|
|
151
|
+
* Absent is not "nobody", for the same reason `Approval.by` is optional: the
|
|
152
|
+
* CLI runs on a machine and in CI, and has no identity to offer that would mean
|
|
153
|
+
* anything. A review service that does know one records it.
|
|
154
|
+
*/
|
|
155
|
+
readonly by?: string;
|
|
156
|
+
/**
|
|
157
|
+
* How the subjects were chosen.
|
|
158
|
+
*
|
|
159
|
+
* Recorded because `--all` and a named subject are different events wearing one
|
|
160
|
+
* outcome. `--all` accepts changed subjects as well as new ones — unattended,
|
|
161
|
+
* that is baseline regeneration rather than review, and a reader who cannot
|
|
162
|
+
* tell the two apart is reading a record that launders one into the other.
|
|
163
|
+
*/
|
|
164
|
+
readonly selection: ChangelogSelection;
|
|
165
|
+
/**
|
|
166
|
+
* Accepted subjects that carried no fingerprint anywhere.
|
|
167
|
+
*
|
|
168
|
+
* Counted, never folded into an entry. A run with no document behind its
|
|
169
|
+
* baseline produces regions with no shape, and inventing a group for them would
|
|
170
|
+
* report a change this record cannot describe as one it can.
|
|
171
|
+
*/
|
|
172
|
+
readonly ungrouped: number;
|
|
173
|
+
readonly entries: readonly ChangelogEntry[];
|
|
174
|
+
readonly drift?: readonly ChangelogDrift[];
|
|
175
|
+
}
|
|
176
|
+
/** Why no record could be made. Never an empty record, which reads as "nothing changed". */
|
|
177
|
+
export interface Unrecordable {
|
|
178
|
+
readonly because: string;
|
|
179
|
+
}
|
|
180
|
+
export interface ChangelogOptions {
|
|
181
|
+
/** The report the reviewer read, and the only source of what changed. */
|
|
182
|
+
readonly report: RunReport;
|
|
183
|
+
/** Subject ids this command actually promoted. */
|
|
184
|
+
readonly accepted: readonly string[];
|
|
185
|
+
/** How those subjects were chosen. See {@link ChangelogRecord.selection}. */
|
|
186
|
+
readonly selection: ChangelogSelection;
|
|
187
|
+
/** ISO 8601. Injected, because nothing in a record may come from a hidden clock. */
|
|
188
|
+
readonly at: string;
|
|
189
|
+
/** The project the record is scoped by, when the operator keeps one. */
|
|
190
|
+
readonly project?: string;
|
|
191
|
+
/** Who accepted. See {@link ChangelogRecord.by}. */
|
|
192
|
+
readonly by?: string;
|
|
193
|
+
/**
|
|
194
|
+
* Entries a caller formed itself, appended after the clustered ones in the
|
|
195
|
+
* order given.
|
|
196
|
+
*
|
|
197
|
+
* Region clustering is one producer of entries, not the definition of one. A
|
|
198
|
+
* change to an interface — an operation whose response lost a field, a named
|
|
199
|
+
* type reached from nine operations — is the same kind of fact this record
|
|
200
|
+
* exists to carry, and it has no rectangle. The alternative was for such a
|
|
201
|
+
* producer to hand `report.observations` a `RegionRecord` with four invented
|
|
202
|
+
* numbers in it, which would give `x`, `y`, `width` and `height` a second
|
|
203
|
+
* meaning and put a fabricated measurement into the one artifact nobody can
|
|
204
|
+
* go back and correct.
|
|
205
|
+
*
|
|
206
|
+
* Appended rather than merged, because merging would need a rule for two
|
|
207
|
+
* producers claiming one fingerprint, and there is no such rule: a fingerprint
|
|
208
|
+
* carries a domain tag inside its digest input (`shape/v1`, `mask/v1`, and
|
|
209
|
+
* whatever a third producer chooses), so two producers cannot collide unless
|
|
210
|
+
* one of them built its digest wrong — and quietly merging is how that stays
|
|
211
|
+
* hidden.
|
|
212
|
+
*
|
|
213
|
+
* `ungrouped` is untouched by these. It counts accepted subjects *this report's
|
|
214
|
+
* regions* could not group, and a producer that never looked at a region has
|
|
215
|
+
* nothing to say about it.
|
|
216
|
+
*/
|
|
217
|
+
readonly entries?: readonly ChangelogEntry[];
|
|
218
|
+
}
|
|
219
|
+
/** `true` when a record was made, rather than a sentence saying why none was. */
|
|
220
|
+
export declare function isRecorded(value: ChangelogRecord | Unrecordable): value is ChangelogRecord;
|
|
221
|
+
/**
|
|
222
|
+
* Derive the record of one baseline update.
|
|
223
|
+
*
|
|
224
|
+
* Refuses in two cases, and both refusals are the same rule: an entry that cannot
|
|
225
|
+
* be joined to what produced it explains nothing. A report with no `run` names no
|
|
226
|
+
* build, and an acceptance with no subjects promoted nothing — writing either
|
|
227
|
+
* would leave a message in the log that a later reader has to decide how to
|
|
228
|
+
* discount.
|
|
229
|
+
*/
|
|
230
|
+
export declare function changelogOf(options: ChangelogOptions): ChangelogRecord | Unrecordable;
|
|
231
|
+
//# sourceMappingURL=changelog.d.ts.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { clusterChanges } from './cluster.js';
|
|
2
|
+
/** `true` when a record was made, rather than a sentence saying why none was. */
|
|
3
|
+
export function isRecorded(value) {
|
|
4
|
+
return 'changelogVersion' in value;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Derive the record of one baseline update.
|
|
8
|
+
*
|
|
9
|
+
* Refuses in two cases, and both refusals are the same rule: an entry that cannot
|
|
10
|
+
* be joined to what produced it explains nothing. A report with no `run` names no
|
|
11
|
+
* build, and an acceptance with no subjects promoted nothing — writing either
|
|
12
|
+
* would leave a message in the log that a later reader has to decide how to
|
|
13
|
+
* discount.
|
|
14
|
+
*/
|
|
15
|
+
export function changelogOf(options) {
|
|
16
|
+
const { report, at, selection } = options;
|
|
17
|
+
const accepted = new Set(options.accepted);
|
|
18
|
+
if (accepted.size === 0) {
|
|
19
|
+
return {
|
|
20
|
+
because: 'nothing was accepted, so there is no baseline update to explain. A record written here ' +
|
|
21
|
+
'would say a change landed that did not',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (report.run === undefined) {
|
|
25
|
+
return {
|
|
26
|
+
because: 'this report does not say which run produced it, so the record has nothing to point at. ' +
|
|
27
|
+
'Runs name themselves from `--run` and `--commit` or from the CI environment; an id ' +
|
|
28
|
+
'invented here would attribute a baseline to a build that never happened',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const clustering = clusterChanges(report.observations.filter((observation) => accepted.has(observation.subject)));
|
|
32
|
+
const reach = new Map(clusterChanges(report.observations).changes.map((change) => [
|
|
33
|
+
change.fingerprint,
|
|
34
|
+
change.subjects.length,
|
|
35
|
+
]));
|
|
36
|
+
const entries = clustering.changes.map((change) => ({
|
|
37
|
+
fingerprint: change.fingerprint,
|
|
38
|
+
...(change.component !== undefined ? { component: change.component } : {}),
|
|
39
|
+
...(change.file !== undefined ? { file: change.file } : {}),
|
|
40
|
+
subjects: change.subjects,
|
|
41
|
+
reached: reach.get(change.fingerprint) ?? change.subjects.length,
|
|
42
|
+
cause: change.cause,
|
|
43
|
+
}));
|
|
44
|
+
// Checked here rather than trusted, because these did not come from clustering
|
|
45
|
+
// and nothing upstream of this function guarantees their shape.
|
|
46
|
+
//
|
|
47
|
+
// `reached < subjects.length` is the clause worth defending: it cannot happen,
|
|
48
|
+
// and it is the one that fails quietly if it does. Containment runs one way —
|
|
49
|
+
// a change reaches some set of subjects and `subjects` is that set intersected
|
|
50
|
+
// with what was accepted, and an intersection cannot exceed what it intersects.
|
|
51
|
+
// The case that looks like an exception is a shape spanning two documents where
|
|
52
|
+
// the operator accepts one of them, and that is `reached: 2, subjects: 1`,
|
|
53
|
+
// which is this check passing. Were it ever to happen, `describe` would print a
|
|
54
|
+
// bare count instead of `n/m` and a partial promotion would read as a whole one.
|
|
55
|
+
const given = options.entries ?? [];
|
|
56
|
+
const wrong = given.find((entry) => entry.fingerprint === '' || entry.subjects.length === 0 || entry.reached < entry.subjects.length);
|
|
57
|
+
if (wrong !== undefined) {
|
|
58
|
+
return {
|
|
59
|
+
because: 'an entry handed to `changelogOf` is not one: it has no fingerprint, no subjects, or ' +
|
|
60
|
+
`reached fewer subjects than it was promoted in (\`${wrong.fingerprint}\`). Refused here ` +
|
|
61
|
+
'rather than written, because a commit message is the one artifact nobody can go back and ' +
|
|
62
|
+
'correct, and `accept --shape` takes that fingerprint as an argument',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const drift = driftOf(report);
|
|
66
|
+
return {
|
|
67
|
+
changelogVersion: 1,
|
|
68
|
+
run: report.run.id,
|
|
69
|
+
commit: report.run.commit,
|
|
70
|
+
at,
|
|
71
|
+
...(options.project !== undefined ? { project: options.project } : {}),
|
|
72
|
+
...(report.intent !== undefined ? { intent: report.intent } : {}),
|
|
73
|
+
...(options.by !== undefined ? { by: options.by } : {}),
|
|
74
|
+
selection,
|
|
75
|
+
ungrouped: clustering.ungrouped.length,
|
|
76
|
+
entries: [...entries, ...given],
|
|
77
|
+
...(drift.length > 0 ? { drift } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function driftOf(report) {
|
|
81
|
+
return Object.entries(report.drift ?? {})
|
|
82
|
+
.map(([token, record]) => ({
|
|
83
|
+
token,
|
|
84
|
+
from: record.from,
|
|
85
|
+
to: record.to,
|
|
86
|
+
steps: record.steps,
|
|
87
|
+
}))
|
|
88
|
+
// Byte order rather than `localeCompare`, which is the right sort for a list
|
|
89
|
+
// being shown to a person and the wrong one for a list being serialised. This
|
|
90
|
+
// one goes verbatim into a commit message, and `localeCompare` orders by the
|
|
91
|
+
// machine's `LANG` — two runners accepting the same run would write different
|
|
92
|
+
// bytes for the same facts, which is exactly the machine-boundness this
|
|
93
|
+
// record refuses everywhere else.
|
|
94
|
+
.sort((a, b) => (a.token < b.token ? -1 : a.token > b.token ? 1 : 0));
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=changelog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"changelog.js","sourceRoot":"","sources":["../src/changelog.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AA4O9C,iFAAiF;AACjF,MAAM,UAAU,UAAU,CAAC,KAAqC;IAC9D,OAAO,kBAAkB,IAAI,KAAK,CAAC;AACrC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,WAAW,CAAC,OAAyB;IACnD,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC;IAC1C,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IAE3C,IAAI,QAAQ,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO;YACL,OAAO,EACL,yFAAyF;gBACzF,wCAAwC;SAC3C,CAAC;IACJ,CAAC;IACD,IAAI,MAAM,CAAC,GAAG,KAAK,SAAS,EAAE,CAAC;QAC7B,OAAO;YACL,OAAO,EACL,yFAAyF;gBACzF,qFAAqF;gBACrF,yEAAyE;SAC5E,CAAC;IACJ,CAAC;IAED,MAAM,UAAU,GAAG,cAAc,CAC/B,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC,QAAQ,CAAC,GAAG,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC,CAC/E,CAAC;IACF,MAAM,KAAK,GAAG,IAAI,GAAG,CACnB,cAAc,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC;QAC1D,MAAM,CAAC,WAAW;QAClB,MAAM,CAAC,QAAQ,CAAC,MAAM;KACvB,CAAC,CACH,CAAC;IAEF,MAAM,OAAO,GAAG,UAAU,CAAC,OAAO,CAAC,GAAG,CACpC,CAAC,MAAM,EAAkB,EAAE,CAAC,CAAC;QAC3B,WAAW,EAAE,MAAM,CAAC,WAAW;QAC/B,GAAG,CAAC,MAAM,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC1E,GAAG,CAAC,MAAM,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3D,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,OAAO,EAAE,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,WAAW,CAAC,IAAI,MAAM,CAAC,QAAQ,CAAC,MAAM;QAChE,KAAK,EAAE,MAAM,CAAC,KAAK;KACpB,CAAC,CACH,CAAC;IAEF,+EAA+E;IAC/E,gEAAgE;IAChE,EAAE;IACF,+EAA+E;IAC/E,8EAA8E;IAC9E,+EAA+E;IAC/E,gFAAgF;IAChF,gFAAgF;IAChF,2EAA2E;IAC3E,gFAAgF;IAChF,iFAAiF;IACjF,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,IAAI,EAAE,CAAC;IACpC,MAAM,KAAK,GAAG,KAAK,CAAC,IAAI,CACtB,CAAC,KAAK,EAAE,EAAE,CACR,KAAK,CAAC,WAAW,KAAK,EAAE,IAAI,KAAK,CAAC,QAAQ,CAAC,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,OAAO,GAAG,KAAK,CAAC,QAAQ,CAAC,MAAM,CACnG,CAAC;IACF,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACxB,OAAO;YACL,OAAO,EACL,sFAAsF;gBACtF,qDAAqD,KAAK,CAAC,WAAW,oBAAoB;gBAC1F,2FAA2F;gBAC3F,qEAAqE;SACxE,CAAC;IACJ,CAAC;IAED,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAE9B,OAAO;QACL,gBAAgB,EAAE,CAAC;QACnB,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE;QAClB,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,MAAM;QACzB,EAAE;QACF,GAAG,CAAC,OAAO,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACtE,GAAG,CAAC,MAAM,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACjE,GAAG,CAAC,OAAO,CAAC,EAAE,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACvD,SAAS;QACT,SAAS,EAAE,UAAU,CAAC,SAAS,CAAC,MAAM;QACtC,OAAO,EAAE,CAAC,GAAG,OAAO,EAAE,GAAG,KAAK,CAAC;QAC/B,GAAG,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACvC,CAAC;AACJ,CAAC;AAED,SAAS,OAAO,CAAC,MAAiB;IAChC,OAAO,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC;SACtC,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC;QACzB,KAAK;QACL,IAAI,EAAE,MAAM,CAAC,IAAI;QACjB,EAAE,EAAE,MAAM,CAAC,EAAE;QACb,KAAK,EAAE,MAAM,CAAC,KAAK;KACpB,CAAC,CAAC;QACH,6EAA6E;QAC7E,8EAA8E;QAC9E,6EAA6E;QAC7E,8EAA8E;QAC9E,wEAAwE;QACxE,kCAAkC;SACjC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAC1E,CAAC","sourcesContent":["import { clusterChanges } from './cluster.js';\nimport type { RunReport } from './format.js';\n\n/**\n * Why a baseline is what it is — written where the baseline is written.\n *\n * A baseline update happens in a run of its own: `variance accept` promotes the\n * candidates a reviewer looked at, and the artifact that lands is a directory of\n * PNGs, or a row in a review database. Both record *what* the new baseline is.\n * Neither records **what the change was**, and by the time anybody asks — a month\n * later, at the twelfth 2px approval — the report that could have said is gone\n * with the CI job that wrote it.\n *\n * So the explanation is written at the only moment it exists, into the same\n * artifact as the bytes it explains: a commit message where baselines are commits,\n * a row where they are rows. This module is the part neither of those owns — the\n * derivation from a report, and the encoding that survives the trip back.\n *\n * ## The unit is a change, not a screenshot\n *\n * One entry per **cluster** ([`cluster.ts`](./cluster.ts)), because that is\n * already this project's answer to the same question one level up: a token edit\n * across forty stories is one decision, and forty entries would reproduce exactly\n * the review problem clustering exists to solve. A changelog that logged\n * screenshots would be forty lines nobody reads, ending in the same place as a\n * commit message that says `regenerate baselines`.\n *\n * ## Only what was accepted, and only what the report already said\n *\n * The subjects on an entry are the intersection of the cluster with the subjects\n * this command actually promoted — never the cluster's own list. `accept --shape`\n * refuses by name any subject where something else also moved, and an entry that\n * copied the cluster would claim those too. What the shape *reached* is carried\n * as a count beside it, so the shortfall is visible rather than absent.\n *\n * Nothing here re-derives anything from bytes. The entry is built from the report\n * the reviewer read, which is what makes it evidence about a decision rather than\n * a second opinion about an image.\n *\n * ## What a record carries, and what it deliberately does not\n *\n * This outlives everything around it. The report is gone with the CI job, the\n * reviewer has left the project, the file has been renamed twice — and the record\n * is still in the log, being read by somebody who cannot check any of it. So a\n * field belongs here only if it will still mean the same thing then.\n *\n * Three that would not, and are absent:\n *\n * **A pixel count.** It measures *displacement* rather than magnitude — a 1px\n * token edit high on a page moves everything below it — and it is machine-bound\n * on top of that. Two commits carrying `1,180px` and `2,180px` invite a\n * comparison that is not valid between them, which is the same argument that\n * keeps pixels out of the history store.\n *\n * **A rendered sentence.** Drift is carried as `token`, `from`, `to` and a step\n * count, never as the phrasing today's reporter happened to use. Prose in a\n * durable record freezes one wording forever and goes stale against the tool that\n * writes the new one.\n *\n * **Anything derivable.** The accepted total is the entries' subjects plus the\n * ungrouped count. Recording it as well would create two numbers that can\n * disagree after a partial read, and no way to decide which is the record.\n *\n * What stays is what a later reader can still verify or act on: a\n * content-addressed fingerprint they can pass straight back to `accept --shape`,\n * the subject ids it landed in, what the shape reached, and the run and commit it\n * all came from. `component` and `file` stay as *where it was then* — a rename\n * makes them stale, and the fingerprint beside them does not.\n *\n * ## Version 1, and what may be added without a version 2\n *\n * A reader ignores keys it does not know, so a field added later is readable by\n * readers that predate it. The version bumps only when the *meaning* of an\n * existing field changes — that is the case where partly understanding a record\n * is worse than refusing it, and refusing is what a version is for.\n */\n\n/** One change, as it was accepted. */\nexport interface ChangelogEntry {\n /** The shape digest, as `accept --shape` and an ignore both take it. */\n readonly fingerprint: string;\n\n /**\n * The component the semantic tier attributed this shape to.\n *\n * Absent means the shape was grouped by silhouette alone — the ephemeral and\n * raster-only paths, where no document survived to name a cause. Surfaced\n * rather than smoothed over, because a later reader asking *what changed in\n * `Card`* must not be answered from a group that never named one.\n */\n readonly component?: string;\n\n /** A source location for the component, when one was resolved. */\n readonly file?: string;\n\n /** Accepted subjects this shape landed in, in report order. */\n readonly subjects: readonly string[];\n\n /**\n * Subjects the shape reached in the run, accepted or not.\n *\n * Equal to `subjects.length` in the ordinary case and larger whenever the\n * command refused part of the cluster. The difference is the part of the change\n * this baseline update did **not** take, and a record that dropped it would read\n * as though the whole shape had been promoted.\n */\n readonly reached: number;\n\n /**\n * `true` when the semantic tier called this shape a cause somewhere.\n *\n * A claim the run made, recorded as the run's, because it is what the reviewer\n * was shown when they approved. A later tier that would group the same pixels\n * differently does not make this entry wrong; it makes it historical, which is\n * what a changelog is.\n */\n readonly cause: boolean;\n}\n\n/** How the subjects to accept were chosen. */\nexport type ChangelogSelection = 'named' | 'all' | 'shape';\n\n/**\n * A token whose value moved, and how far it has drifted getting there.\n *\n * Four fields and no sentence. The report phrases this finding for a reviewer and\n * phrases it well; storing that phrasing would preserve one release's wording in\n * every commit written under it, and a reader in two years would be reading the\n * output of a reporter nobody runs any more. The facts are what is durable, and\n * whoever prints them can say it however they say it then.\n */\nexport interface ChangelogDrift {\n readonly token: string;\n readonly from: string;\n readonly to: string;\n /** Value changes behind it. One is not drift; this is never below two. */\n readonly steps: number;\n}\n\n/**\n * What one baseline update was.\n *\n * Drift sits on the record rather than on an entry, and that is a join that does\n * not exist rather than a placement preference: drift is keyed by token and a\n * cluster is keyed by fingerprint, and nothing in this project maps one to the\n * other. It is a fact about the run, recorded at the level it is true at.\n */\nexport interface ChangelogRecord {\n /** Bumped when the stored shape changes in a way an older reader would misread. */\n readonly changelogVersion: 1;\n /** The run whose candidates were promoted. */\n readonly run: string;\n /** The commit that run observed — not the commit this record lands in. */\n readonly commit: string;\n readonly at: string;\n readonly project?: string;\n /** What the run was comparing, in the author's words. */\n readonly intent?: string;\n\n /**\n * Who accepted, when the surface that recorded it knows.\n *\n * Absent is not \"nobody\", for the same reason `Approval.by` is optional: the\n * CLI runs on a machine and in CI, and has no identity to offer that would mean\n * anything. A review service that does know one records it.\n */\n readonly by?: string;\n\n /**\n * How the subjects were chosen.\n *\n * Recorded because `--all` and a named subject are different events wearing one\n * outcome. `--all` accepts changed subjects as well as new ones — unattended,\n * that is baseline regeneration rather than review, and a reader who cannot\n * tell the two apart is reading a record that launders one into the other.\n */\n readonly selection: ChangelogSelection;\n\n /**\n * Accepted subjects that carried no fingerprint anywhere.\n *\n * Counted, never folded into an entry. A run with no document behind its\n * baseline produces regions with no shape, and inventing a group for them would\n * report a change this record cannot describe as one it can.\n */\n readonly ungrouped: number;\n\n readonly entries: readonly ChangelogEntry[];\n readonly drift?: readonly ChangelogDrift[];\n}\n\n/** Why no record could be made. Never an empty record, which reads as \"nothing changed\". */\nexport interface Unrecordable {\n readonly because: string;\n}\n\nexport interface ChangelogOptions {\n /** The report the reviewer read, and the only source of what changed. */\n readonly report: RunReport;\n /** Subject ids this command actually promoted. */\n readonly accepted: readonly string[];\n /** How those subjects were chosen. See {@link ChangelogRecord.selection}. */\n readonly selection: ChangelogSelection;\n /** ISO 8601. Injected, because nothing in a record may come from a hidden clock. */\n readonly at: string;\n /** The project the record is scoped by, when the operator keeps one. */\n readonly project?: string;\n /** Who accepted. See {@link ChangelogRecord.by}. */\n readonly by?: string;\n /**\n * Entries a caller formed itself, appended after the clustered ones in the\n * order given.\n *\n * Region clustering is one producer of entries, not the definition of one. A\n * change to an interface — an operation whose response lost a field, a named\n * type reached from nine operations — is the same kind of fact this record\n * exists to carry, and it has no rectangle. The alternative was for such a\n * producer to hand `report.observations` a `RegionRecord` with four invented\n * numbers in it, which would give `x`, `y`, `width` and `height` a second\n * meaning and put a fabricated measurement into the one artifact nobody can\n * go back and correct.\n *\n * Appended rather than merged, because merging would need a rule for two\n * producers claiming one fingerprint, and there is no such rule: a fingerprint\n * carries a domain tag inside its digest input (`shape/v1`, `mask/v1`, and\n * whatever a third producer chooses), so two producers cannot collide unless\n * one of them built its digest wrong — and quietly merging is how that stays\n * hidden.\n *\n * `ungrouped` is untouched by these. It counts accepted subjects *this report's\n * regions* could not group, and a producer that never looked at a region has\n * nothing to say about it.\n */\n readonly entries?: readonly ChangelogEntry[];\n}\n\n/** `true` when a record was made, rather than a sentence saying why none was. */\nexport function isRecorded(value: ChangelogRecord | Unrecordable): value is ChangelogRecord {\n return 'changelogVersion' in value;\n}\n\n/**\n * Derive the record of one baseline update.\n *\n * Refuses in two cases, and both refusals are the same rule: an entry that cannot\n * be joined to what produced it explains nothing. A report with no `run` names no\n * build, and an acceptance with no subjects promoted nothing — writing either\n * would leave a message in the log that a later reader has to decide how to\n * discount.\n */\nexport function changelogOf(options: ChangelogOptions): ChangelogRecord | Unrecordable {\n const { report, at, selection } = options;\n const accepted = new Set(options.accepted);\n\n if (accepted.size === 0) {\n return {\n because:\n 'nothing was accepted, so there is no baseline update to explain. A record written here ' +\n 'would say a change landed that did not',\n };\n }\n if (report.run === undefined) {\n return {\n because:\n 'this report does not say which run produced it, so the record has nothing to point at. ' +\n 'Runs name themselves from `--run` and `--commit` or from the CI environment; an id ' +\n 'invented here would attribute a baseline to a build that never happened',\n };\n }\n\n const clustering = clusterChanges(\n report.observations.filter((observation) => accepted.has(observation.subject)),\n );\n const reach = new Map(\n clusterChanges(report.observations).changes.map((change) => [\n change.fingerprint,\n change.subjects.length,\n ]),\n );\n\n const entries = clustering.changes.map(\n (change): ChangelogEntry => ({\n fingerprint: change.fingerprint,\n ...(change.component !== undefined ? { component: change.component } : {}),\n ...(change.file !== undefined ? { file: change.file } : {}),\n subjects: change.subjects,\n reached: reach.get(change.fingerprint) ?? change.subjects.length,\n cause: change.cause,\n }),\n );\n\n // Checked here rather than trusted, because these did not come from clustering\n // and nothing upstream of this function guarantees their shape.\n //\n // `reached < subjects.length` is the clause worth defending: it cannot happen,\n // and it is the one that fails quietly if it does. Containment runs one way —\n // a change reaches some set of subjects and `subjects` is that set intersected\n // with what was accepted, and an intersection cannot exceed what it intersects.\n // The case that looks like an exception is a shape spanning two documents where\n // the operator accepts one of them, and that is `reached: 2, subjects: 1`,\n // which is this check passing. Were it ever to happen, `describe` would print a\n // bare count instead of `n/m` and a partial promotion would read as a whole one.\n const given = options.entries ?? [];\n const wrong = given.find(\n (entry) =>\n entry.fingerprint === '' || entry.subjects.length === 0 || entry.reached < entry.subjects.length,\n );\n if (wrong !== undefined) {\n return {\n because:\n 'an entry handed to `changelogOf` is not one: it has no fingerprint, no subjects, or ' +\n `reached fewer subjects than it was promoted in (\\`${wrong.fingerprint}\\`). Refused here ` +\n 'rather than written, because a commit message is the one artifact nobody can go back and ' +\n 'correct, and `accept --shape` takes that fingerprint as an argument',\n };\n }\n\n const drift = driftOf(report);\n\n return {\n changelogVersion: 1,\n run: report.run.id,\n commit: report.run.commit,\n at,\n ...(options.project !== undefined ? { project: options.project } : {}),\n ...(report.intent !== undefined ? { intent: report.intent } : {}),\n ...(options.by !== undefined ? { by: options.by } : {}),\n selection,\n ungrouped: clustering.ungrouped.length,\n entries: [...entries, ...given],\n ...(drift.length > 0 ? { drift } : {}),\n };\n}\n\nfunction driftOf(report: RunReport): readonly ChangelogDrift[] {\n return Object.entries(report.drift ?? {})\n .map(([token, record]) => ({\n token,\n from: record.from,\n to: record.to,\n steps: record.steps,\n }))\n // Byte order rather than `localeCompare`, which is the right sort for a list\n // being shown to a person and the wrong one for a list being serialised. This\n // one goes verbatim into a commit message, and `localeCompare` orders by the\n // machine's `LANG` — two runners accepting the same run would write different\n // bytes for the same facts, which is exactly the machine-boundness this\n // record refuses everywhere else.\n .sort((a, b) => (a.token < b.token ? -1 : a.token > b.token ? 1 : 0));\n}\n"]}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ObservationRecord } from './format.js';
|
|
2
|
+
/**
|
|
3
|
+
* A run's changes, grouped into the *distinct things that happened*.
|
|
4
|
+
*
|
|
5
|
+
* ## The unit of review is wrong by default
|
|
6
|
+
*
|
|
7
|
+
* Every tool in this category hands a reviewer a list of screenshots, and a
|
|
8
|
+
* reviewer's actual question is not "is this screenshot acceptable". It is *what
|
|
9
|
+
* changed, and how many places did it land*. A design-token edit that touches 40
|
|
10
|
+
* stories is one decision presented as forty, and the fortieth is approved
|
|
11
|
+
* without being looked at — which is the mechanism by which large suites stop
|
|
12
|
+
* being reviewed at all.
|
|
13
|
+
*
|
|
14
|
+
* Argos groups by the shape of the pixel diff and calls the group a **change**,
|
|
15
|
+
* ranked by recurrence
|
|
16
|
+
* ([test page](https://argos-ci.com/docs/learn/reliability-and-flakiness/test-page.md)).
|
|
17
|
+
* Applitools clusters by diff-region shape so one accept propagates across the
|
|
18
|
+
* batch. This project has had the *fingerprints* since 2026-08-05 and printed
|
|
19
|
+
* them one region at a time — [`comparison.md`](../../../docs/comparison.md)
|
|
20
|
+
* states the gap in as many words: they group the batch for you, and here the
|
|
21
|
+
* operator copies one digest out of a report.
|
|
22
|
+
*
|
|
23
|
+
* This is the grouping. It needs nothing new: a fingerprint is already on every
|
|
24
|
+
* region, and it already carries the component responsible, so a cluster is a
|
|
25
|
+
* `groupBy` and an ordering.
|
|
26
|
+
*
|
|
27
|
+
* ## Why the fingerprint is better than the one it is modelled on
|
|
28
|
+
*
|
|
29
|
+
* `mask-fingerprint` hashes the *silhouette* of a red diff mask — bounding box,
|
|
30
|
+
* dilated, resampled onto a 16×16 grid, density quantized to four buckets. That
|
|
31
|
+
* is translation- and scale-invariant, which is the point, and it is also blind
|
|
32
|
+
* to what changed: two different edits occupying the same box collide, and an
|
|
33
|
+
* ignore scoped to one of them silences the other. Their own docs concede a
|
|
34
|
+
* version of this while a neighbouring page claims it cannot happen.
|
|
35
|
+
*
|
|
36
|
+
* The semantic fingerprint here is built from the kind of root, the multiset of
|
|
37
|
+
* delta shapes, **and the component responsible**. So the same-looking change in
|
|
38
|
+
* `Avatar` and in `Badge` are different clusters, and accepting one does not
|
|
39
|
+
* reach the other. Where no document survived, the pixel fingerprint is the
|
|
40
|
+
* fallback and inherits the weaker guarantee — which is a reason to keep the
|
|
41
|
+
* document, not a reason to pretend the two are equal.
|
|
42
|
+
*/
|
|
43
|
+
export interface Change {
|
|
44
|
+
/** The shape digest. What `variance accept --shape` and an ignore both take. */
|
|
45
|
+
readonly fingerprint: string;
|
|
46
|
+
/**
|
|
47
|
+
* The component this shape was attributed to, when the semantic tier named one.
|
|
48
|
+
*
|
|
49
|
+
* The field that makes a cluster safe to act on in bulk. Absent means the
|
|
50
|
+
* change is grouped by silhouette alone, and a bulk decision on it is the
|
|
51
|
+
* weaker claim — surfaced rather than smoothed over.
|
|
52
|
+
*/
|
|
53
|
+
readonly component?: string;
|
|
54
|
+
/** A source location for the component, when one was resolved. */
|
|
55
|
+
readonly file?: string;
|
|
56
|
+
/** Subject ids where this shape appears at all, in report order. */
|
|
57
|
+
readonly subjects: readonly string[];
|
|
58
|
+
/**
|
|
59
|
+
* Subjects where this shape is the **whole** change.
|
|
60
|
+
*
|
|
61
|
+
* The set a single decision can settle. Everywhere else, something the shape
|
|
62
|
+
* does not name also moved, so accepting the shape there would promote an
|
|
63
|
+
* unreviewed difference alongside a reviewed one — which is the exact way a
|
|
64
|
+
* bulk-accept feature turns a gate into a recorder. `accept --shape` already
|
|
65
|
+
* refuses those by name; this is the same distinction, computed before the
|
|
66
|
+
* operator commits to anything.
|
|
67
|
+
*/
|
|
68
|
+
readonly settles: readonly string[];
|
|
69
|
+
/** Regions this shape accounts for, summed. */
|
|
70
|
+
readonly pixels: number;
|
|
71
|
+
/** `true` when the semantic tier called this shape a cause somewhere. */
|
|
72
|
+
readonly cause: boolean;
|
|
73
|
+
}
|
|
74
|
+
export interface Clustering {
|
|
75
|
+
/** Distinct changes, most widespread first. */
|
|
76
|
+
readonly changes: readonly Change[];
|
|
77
|
+
/**
|
|
78
|
+
* Subjects that changed and produced no fingerprint anywhere.
|
|
79
|
+
*
|
|
80
|
+
* Never folded into a catch-all cluster. A run with no document to compare
|
|
81
|
+
* against — the ephemeral mode, or a raster-only path — produces regions with
|
|
82
|
+
* no shape, and grouping those together would invent a "change" that is
|
|
83
|
+
* really the absence of one. Counting them separately is what keeps the
|
|
84
|
+
* headline honest: *N subjects, M changes, and K we could not group*.
|
|
85
|
+
*/
|
|
86
|
+
readonly ungrouped: readonly string[];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Group a run's changed subjects by what happened to them.
|
|
90
|
+
*
|
|
91
|
+
* Ordered by how many subjects a change *settles*, then by how many it touches,
|
|
92
|
+
* then by pixels, then by fingerprint. The first key is the useful one: it ranks
|
|
93
|
+
* by how much of the review one decision would finish, which is the question the
|
|
94
|
+
* reviewer is actually asking. Ties break deterministically so two runs over the
|
|
95
|
+
* same report print the same order — a docket whose ordering wobbles is one
|
|
96
|
+
* nobody can diff.
|
|
97
|
+
*
|
|
98
|
+
* `ignored` observations are excluded along with the green ones. An absorbed
|
|
99
|
+
* difference is not a change awaiting a decision; it is a decision already made,
|
|
100
|
+
* and it is accounted for in the ignore register where the question *what did
|
|
101
|
+
* this rule absorb* can still be asked.
|
|
102
|
+
*/
|
|
103
|
+
export declare function clusterChanges(observations: readonly ObservationRecord[]): Clustering;
|
|
104
|
+
/**
|
|
105
|
+
* The clustering as the two sentences a reviewer needs before anything else.
|
|
106
|
+
*
|
|
107
|
+
* "40 subjects changed" is a workload. "40 subjects changed, and they are 3
|
|
108
|
+
* changes" is a plan — and the difference between those two sentences is most of
|
|
109
|
+
* what this module is for.
|
|
110
|
+
*/
|
|
111
|
+
export declare function describeClustering(clustering: Clustering, changed: number): string;
|
|
112
|
+
//# sourceMappingURL=cluster.d.ts.map
|
package/dist/cluster.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group a run's changed subjects by what happened to them.
|
|
3
|
+
*
|
|
4
|
+
* Ordered by how many subjects a change *settles*, then by how many it touches,
|
|
5
|
+
* then by pixels, then by fingerprint. The first key is the useful one: it ranks
|
|
6
|
+
* by how much of the review one decision would finish, which is the question the
|
|
7
|
+
* reviewer is actually asking. Ties break deterministically so two runs over the
|
|
8
|
+
* same report print the same order — a docket whose ordering wobbles is one
|
|
9
|
+
* nobody can diff.
|
|
10
|
+
*
|
|
11
|
+
* `ignored` observations are excluded along with the green ones. An absorbed
|
|
12
|
+
* difference is not a change awaiting a decision; it is a decision already made,
|
|
13
|
+
* and it is accounted for in the ignore register where the question *what did
|
|
14
|
+
* this rule absorb* can still be asked.
|
|
15
|
+
*/
|
|
16
|
+
export function clusterChanges(observations) {
|
|
17
|
+
const byFingerprint = new Map();
|
|
18
|
+
const ungrouped = [];
|
|
19
|
+
for (const observation of observations) {
|
|
20
|
+
if (observation.verdict !== 'changed')
|
|
21
|
+
continue;
|
|
22
|
+
const fingerprints = new Set(observation.regions
|
|
23
|
+
.map((region) => region.fingerprint)
|
|
24
|
+
.filter((fingerprint) => fingerprint !== undefined));
|
|
25
|
+
if (fingerprints.size === 0) {
|
|
26
|
+
ungrouped.push(observation.subject);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
for (const fingerprint of fingerprints) {
|
|
30
|
+
const regions = observation.regions.filter((region) => region.fingerprint === fingerprint);
|
|
31
|
+
const entry = byFingerprint.get(fingerprint) ?? blank(fingerprint);
|
|
32
|
+
name(entry, regions);
|
|
33
|
+
entry.subjects.push(observation.subject);
|
|
34
|
+
// The whole change exactly when no region of this subject carries a
|
|
35
|
+
// *different* shape. A region with no fingerprint counts against it: we
|
|
36
|
+
// cannot say a shape explains a difference we could not name.
|
|
37
|
+
if (observation.regions.every((region) => region.fingerprint === fingerprint)) {
|
|
38
|
+
entry.settles.push(observation.subject);
|
|
39
|
+
}
|
|
40
|
+
entry.pixels += regions.reduce((total, region) => total + region.pixels, 0);
|
|
41
|
+
entry.cause ||= regions.some((region) => region.cause);
|
|
42
|
+
byFingerprint.set(fingerprint, entry);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const changes = [...byFingerprint.values()]
|
|
46
|
+
.map((entry) => ({
|
|
47
|
+
fingerprint: entry.fingerprint,
|
|
48
|
+
...(entry.component !== undefined ? { component: entry.component } : {}),
|
|
49
|
+
...(entry.file !== undefined ? { file: entry.file } : {}),
|
|
50
|
+
subjects: entry.subjects,
|
|
51
|
+
settles: entry.settles,
|
|
52
|
+
pixels: entry.pixels,
|
|
53
|
+
cause: entry.cause,
|
|
54
|
+
}))
|
|
55
|
+
.sort((a, b) => b.settles.length - a.settles.length ||
|
|
56
|
+
b.subjects.length - a.subjects.length ||
|
|
57
|
+
b.pixels - a.pixels ||
|
|
58
|
+
(a.fingerprint < b.fingerprint ? -1 : a.fingerprint > b.fingerprint ? 1 : 0));
|
|
59
|
+
return { changes, ungrouped };
|
|
60
|
+
}
|
|
61
|
+
function blank(fingerprint) {
|
|
62
|
+
return { fingerprint, subjects: [], settles: [], pixels: 0, cause: false };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Adopt a component and a file from the first region in the cluster that has
|
|
66
|
+
* them, wherever in the run that turns out to be.
|
|
67
|
+
*
|
|
68
|
+
* Filling a gap, not resolving a disagreement. A semantic fingerprint already
|
|
69
|
+
* *contains* the cause, so two regions sharing one agree about the component by
|
|
70
|
+
* construction — which means an absent component is a component that was not
|
|
71
|
+
* resolved *there*, and taking it from a sibling adds information rather than
|
|
72
|
+
* choosing between claims. Doing it only on the first subject seen was the first
|
|
73
|
+
* draft, and it made the answer depend on report order.
|
|
74
|
+
*
|
|
75
|
+
* A pixel fingerprint contains no cause, so no region in that cluster names one
|
|
76
|
+
* and the field stays absent — the honest report of a group formed by silhouette
|
|
77
|
+
* alone, which must not borrow a component from a neighbour and start looking
|
|
78
|
+
* like the stronger kind of claim.
|
|
79
|
+
*/
|
|
80
|
+
function name(entry, regions) {
|
|
81
|
+
if (entry.component !== undefined)
|
|
82
|
+
return;
|
|
83
|
+
const named = regions.find((region) => region.component !== undefined);
|
|
84
|
+
if (named === undefined)
|
|
85
|
+
return;
|
|
86
|
+
entry.component = named.component;
|
|
87
|
+
if (named.file !== undefined)
|
|
88
|
+
entry.file = named.file;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The clustering as the two sentences a reviewer needs before anything else.
|
|
92
|
+
*
|
|
93
|
+
* "40 subjects changed" is a workload. "40 subjects changed, and they are 3
|
|
94
|
+
* changes" is a plan — and the difference between those two sentences is most of
|
|
95
|
+
* what this module is for.
|
|
96
|
+
*/
|
|
97
|
+
export function describeClustering(clustering, changed) {
|
|
98
|
+
const { changes, ungrouped } = clustering;
|
|
99
|
+
if (changed === 0)
|
|
100
|
+
return 'nothing changed';
|
|
101
|
+
if (changes.length === 0) {
|
|
102
|
+
return `${changed} subject(s) changed, and none of them could be grouped`;
|
|
103
|
+
}
|
|
104
|
+
const settled = changes.filter((change) => change.settles.length > 0).length;
|
|
105
|
+
return (`${changed} subject(s) changed, and they are ${changes.length} distinct change(s) — ` +
|
|
106
|
+
`${settled} of which can be decided in one action` +
|
|
107
|
+
(ungrouped.length > 0 ? `; ${ungrouped.length} could not be grouped` : ''));
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=cluster.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cluster.js","sourceRoot":"","sources":["../src/cluster.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ObservationRecord, RegionRecord } from './format.js';\n\n/**\n * A run's changes, grouped into the *distinct things that happened*.\n *\n * ## The unit of review is wrong by default\n *\n * Every tool in this category hands a reviewer a list of screenshots, and a\n * reviewer's actual question is not \"is this screenshot acceptable\". It is *what\n * changed, and how many places did it land*. A design-token edit that touches 40\n * stories is one decision presented as forty, and the fortieth is approved\n * without being looked at — which is the mechanism by which large suites stop\n * being reviewed at all.\n *\n * Argos groups by the shape of the pixel diff and calls the group a **change**,\n * ranked by recurrence\n * ([test page](https://argos-ci.com/docs/learn/reliability-and-flakiness/test-page.md)).\n * Applitools clusters by diff-region shape so one accept propagates across the\n * batch. This project has had the *fingerprints* since 2026-08-05 and printed\n * them one region at a time — [`comparison.md`](../../../docs/comparison.md)\n * states the gap in as many words: they group the batch for you, and here the\n * operator copies one digest out of a report.\n *\n * This is the grouping. It needs nothing new: a fingerprint is already on every\n * region, and it already carries the component responsible, so a cluster is a\n * `groupBy` and an ordering.\n *\n * ## Why the fingerprint is better than the one it is modelled on\n *\n * `mask-fingerprint` hashes the *silhouette* of a red diff mask — bounding box,\n * dilated, resampled onto a 16×16 grid, density quantized to four buckets. That\n * is translation- and scale-invariant, which is the point, and it is also blind\n * to what changed: two different edits occupying the same box collide, and an\n * ignore scoped to one of them silences the other. Their own docs concede a\n * version of this while a neighbouring page claims it cannot happen.\n *\n * The semantic fingerprint here is built from the kind of root, the multiset of\n * delta shapes, **and the component responsible**. So the same-looking change in\n * `Avatar` and in `Badge` are different clusters, and accepting one does not\n * reach the other. Where no document survived, the pixel fingerprint is the\n * fallback and inherits the weaker guarantee — which is a reason to keep the\n * document, not a reason to pretend the two are equal.\n */\n\nexport interface Change {\n /** The shape digest. What `variance accept --shape` and an ignore both take. */\n readonly fingerprint: string;\n\n /**\n * The component this shape was attributed to, when the semantic tier named one.\n *\n * The field that makes a cluster safe to act on in bulk. Absent means the\n * change is grouped by silhouette alone, and a bulk decision on it is the\n * weaker claim — surfaced rather than smoothed over.\n */\n readonly component?: string;\n\n /** A source location for the component, when one was resolved. */\n readonly file?: string;\n\n /** Subject ids where this shape appears at all, in report order. */\n readonly subjects: readonly string[];\n\n /**\n * Subjects where this shape is the **whole** change.\n *\n * The set a single decision can settle. Everywhere else, something the shape\n * does not name also moved, so accepting the shape there would promote an\n * unreviewed difference alongside a reviewed one — which is the exact way a\n * bulk-accept feature turns a gate into a recorder. `accept --shape` already\n * refuses those by name; this is the same distinction, computed before the\n * operator commits to anything.\n */\n readonly settles: readonly string[];\n\n /** Regions this shape accounts for, summed. */\n readonly pixels: number;\n\n /** `true` when the semantic tier called this shape a cause somewhere. */\n readonly cause: boolean;\n}\n\nexport interface Clustering {\n /** Distinct changes, most widespread first. */\n readonly changes: readonly Change[];\n\n /**\n * Subjects that changed and produced no fingerprint anywhere.\n *\n * Never folded into a catch-all cluster. A run with no document to compare\n * against — the ephemeral mode, or a raster-only path — produces regions with\n * no shape, and grouping those together would invent a \"change\" that is\n * really the absence of one. Counting them separately is what keeps the\n * headline honest: *N subjects, M changes, and K we could not group*.\n */\n readonly ungrouped: readonly string[];\n}\n\n/**\n * Group a run's changed subjects by what happened to them.\n *\n * Ordered by how many subjects a change *settles*, then by how many it touches,\n * then by pixels, then by fingerprint. The first key is the useful one: it ranks\n * by how much of the review one decision would finish, which is the question the\n * reviewer is actually asking. Ties break deterministically so two runs over the\n * same report print the same order — a docket whose ordering wobbles is one\n * nobody can diff.\n *\n * `ignored` observations are excluded along with the green ones. An absorbed\n * difference is not a change awaiting a decision; it is a decision already made,\n * and it is accounted for in the ignore register where the question *what did\n * this rule absorb* can still be asked.\n */\nexport function clusterChanges(observations: readonly ObservationRecord[]): Clustering {\n const byFingerprint = new Map<string, Mutable>();\n const ungrouped: string[] = [];\n\n for (const observation of observations) {\n if (observation.verdict !== 'changed') continue;\n\n const fingerprints = new Set(\n observation.regions\n .map((region) => region.fingerprint)\n .filter((fingerprint): fingerprint is string => fingerprint !== undefined),\n );\n\n if (fingerprints.size === 0) {\n ungrouped.push(observation.subject);\n continue;\n }\n\n for (const fingerprint of fingerprints) {\n const regions = observation.regions.filter((region) => region.fingerprint === fingerprint);\n const entry = byFingerprint.get(fingerprint) ?? blank(fingerprint);\n name(entry, regions);\n\n entry.subjects.push(observation.subject);\n // The whole change exactly when no region of this subject carries a\n // *different* shape. A region with no fingerprint counts against it: we\n // cannot say a shape explains a difference we could not name.\n if (observation.regions.every((region) => region.fingerprint === fingerprint)) {\n entry.settles.push(observation.subject);\n }\n entry.pixels += regions.reduce((total, region) => total + region.pixels, 0);\n entry.cause ||= regions.some((region) => region.cause);\n\n byFingerprint.set(fingerprint, entry);\n }\n }\n\n const changes = [...byFingerprint.values()]\n .map(\n (entry): Change => ({\n fingerprint: entry.fingerprint,\n ...(entry.component !== undefined ? { component: entry.component } : {}),\n ...(entry.file !== undefined ? { file: entry.file } : {}),\n subjects: entry.subjects,\n settles: entry.settles,\n pixels: entry.pixels,\n cause: entry.cause,\n }),\n )\n .sort(\n (a, b) =>\n b.settles.length - a.settles.length ||\n b.subjects.length - a.subjects.length ||\n b.pixels - a.pixels ||\n (a.fingerprint < b.fingerprint ? -1 : a.fingerprint > b.fingerprint ? 1 : 0),\n );\n\n return { changes, ungrouped };\n}\n\ninterface Mutable {\n readonly fingerprint: string;\n component?: string;\n file?: string;\n readonly subjects: string[];\n readonly settles: string[];\n pixels: number;\n cause: boolean;\n}\n\nfunction blank(fingerprint: string): Mutable {\n return { fingerprint, subjects: [], settles: [], pixels: 0, cause: false };\n}\n\n/**\n * Adopt a component and a file from the first region in the cluster that has\n * them, wherever in the run that turns out to be.\n *\n * Filling a gap, not resolving a disagreement. A semantic fingerprint already\n * *contains* the cause, so two regions sharing one agree about the component by\n * construction — which means an absent component is a component that was not\n * resolved *there*, and taking it from a sibling adds information rather than\n * choosing between claims. Doing it only on the first subject seen was the first\n * draft, and it made the answer depend on report order.\n *\n * A pixel fingerprint contains no cause, so no region in that cluster names one\n * and the field stays absent — the honest report of a group formed by silhouette\n * alone, which must not borrow a component from a neighbour and start looking\n * like the stronger kind of claim.\n */\nfunction name(entry: Mutable, regions: readonly RegionRecord[]): void {\n if (entry.component !== undefined) return;\n\n const named = regions.find(\n (region): region is RegionRecord & { component: string } => region.component !== undefined,\n );\n if (named === undefined) return;\n\n entry.component = named.component;\n if (named.file !== undefined) entry.file = named.file;\n}\n\n/**\n * The clustering as the two sentences a reviewer needs before anything else.\n *\n * \"40 subjects changed\" is a workload. \"40 subjects changed, and they are 3\n * changes\" is a plan — and the difference between those two sentences is most of\n * what this module is for.\n */\nexport function describeClustering(clustering: Clustering, changed: number): string {\n const { changes, ungrouped } = clustering;\n\n if (changed === 0) return 'nothing changed';\n if (changes.length === 0) {\n return `${changed} subject(s) changed, and none of them could be grouped`;\n }\n\n const settled = changes.filter((change) => change.settles.length > 0).length;\n\n return (\n `${changed} subject(s) changed, and they are ${changes.length} distinct change(s) — ` +\n `${settled} of which can be decided in one action` +\n (ungrouped.length > 0 ? `; ${ungrouped.length} could not be grouped` : '')\n );\n}\n"]}
|