@variance-authority/core 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 (260) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +169 -0
  4. package/dist/artifact.d.ts +47 -0
  5. package/dist/artifact.js +2 -0
  6. package/dist/artifact.js.map +1 -0
  7. package/dist/attribute/because.d.ts +43 -0
  8. package/dist/attribute/because.js +64 -0
  9. package/dist/attribute/because.js.map +1 -0
  10. package/dist/attribute/boundary.d.ts +191 -0
  11. package/dist/attribute/boundary.js +328 -0
  12. package/dist/attribute/boundary.js.map +1 -0
  13. package/dist/attribute/call-site.d.ts +96 -0
  14. package/dist/attribute/call-site.js +183 -0
  15. package/dist/attribute/call-site.js.map +1 -0
  16. package/dist/attribute/component-hash.d.ts +188 -0
  17. package/dist/attribute/component-hash.js +312 -0
  18. package/dist/attribute/component-hash.js.map +1 -0
  19. package/dist/attribute/composition-fixture.d.ts +18 -0
  20. package/dist/attribute/composition-fixture.js +53 -0
  21. package/dist/attribute/composition-fixture.js.map +1 -0
  22. package/dist/attribute/composition.d.ts +182 -0
  23. package/dist/attribute/composition.js +169 -0
  24. package/dist/attribute/composition.js.map +1 -0
  25. package/dist/attribute/control.d.ts +43 -0
  26. package/dist/attribute/control.js +54 -0
  27. package/dist/attribute/control.js.map +1 -0
  28. package/dist/attribute/coverage.d.ts +99 -0
  29. package/dist/attribute/coverage.js +86 -0
  30. package/dist/attribute/coverage.js.map +1 -0
  31. package/dist/attribute/divergence.d.ts +102 -0
  32. package/dist/attribute/divergence.js +152 -0
  33. package/dist/attribute/divergence.js.map +1 -0
  34. package/dist/attribute/index.d.ts +43 -0
  35. package/dist/attribute/index.js +29 -0
  36. package/dist/attribute/index.js.map +1 -0
  37. package/dist/attribute/instability.d.ts +89 -0
  38. package/dist/attribute/instability.js +183 -0
  39. package/dist/attribute/instability.js.map +1 -0
  40. package/dist/attribute/instances.d.ts +145 -0
  41. package/dist/attribute/instances.js +140 -0
  42. package/dist/attribute/instances.js.map +1 -0
  43. package/dist/attribute/locate.d.ts +22 -0
  44. package/dist/attribute/locate.js +100 -0
  45. package/dist/attribute/locate.js.map +1 -0
  46. package/dist/attribute/mask.d.ts +156 -0
  47. package/dist/attribute/mask.js +201 -0
  48. package/dist/attribute/mask.js.map +1 -0
  49. package/dist/attribute/movement.d.ts +211 -0
  50. package/dist/attribute/movement.js +181 -0
  51. package/dist/attribute/movement.js.map +1 -0
  52. package/dist/attribute/region.d.ts +188 -0
  53. package/dist/attribute/region.js +144 -0
  54. package/dist/attribute/region.js.map +1 -0
  55. package/dist/attribute/source-map.d.ts +98 -0
  56. package/dist/attribute/source-map.js +264 -0
  57. package/dist/attribute/source-map.js.map +1 -0
  58. package/dist/attribute/source.d.ts +77 -0
  59. package/dist/attribute/source.js +95 -0
  60. package/dist/attribute/source.js.map +1 -0
  61. package/dist/attribute/stack.d.ts +80 -0
  62. package/dist/attribute/stack.js +108 -0
  63. package/dist/attribute/stack.js.map +1 -0
  64. package/dist/compare/band.d.ts +85 -0
  65. package/dist/compare/band.js +70 -0
  66. package/dist/compare/band.js.map +1 -0
  67. package/dist/compare/cascade.d.ts +45 -0
  68. package/dist/compare/cascade.js +71 -0
  69. package/dist/compare/cascade.js.map +1 -0
  70. package/dist/compare/derive.d.ts +78 -0
  71. package/dist/compare/derive.js +39 -0
  72. package/dist/compare/derive.js.map +1 -0
  73. package/dist/compare/diff/attribution.d.ts +23 -0
  74. package/dist/compare/diff/attribution.js +244 -0
  75. package/dist/compare/diff/attribution.js.map +1 -0
  76. package/dist/compare/diff/compare-nodes.d.ts +15 -0
  77. package/dist/compare/diff/compare-nodes.js +227 -0
  78. package/dist/compare/diff/compare-nodes.js.map +1 -0
  79. package/dist/compare/diff/components.d.ts +20 -0
  80. package/dist/compare/diff/components.js +76 -0
  81. package/dist/compare/diff/components.js.map +1 -0
  82. package/dist/compare/diff/delta.d.ts +165 -0
  83. package/dist/compare/diff/delta.js +12 -0
  84. package/dist/compare/diff/delta.js.map +1 -0
  85. package/dist/compare/diff/index.d.ts +69 -0
  86. package/dist/compare/diff/index.js +101 -0
  87. package/dist/compare/diff/index.js.map +1 -0
  88. package/dist/compare/diff/match.d.ts +37 -0
  89. package/dist/compare/diff/match.js +167 -0
  90. package/dist/compare/diff/match.js.map +1 -0
  91. package/dist/compare/explain.d.ts +23 -0
  92. package/dist/compare/explain.js +190 -0
  93. package/dist/compare/explain.js.map +1 -0
  94. package/dist/compare/holding-diff.d.ts +25 -0
  95. package/dist/compare/holding-diff.js +74 -0
  96. package/dist/compare/holding-diff.js.map +1 -0
  97. package/dist/compare/impact.d.ts +56 -0
  98. package/dist/compare/impact.js +124 -0
  99. package/dist/compare/impact.js.map +1 -0
  100. package/dist/compare/index.d.ts +30 -0
  101. package/dist/compare/index.js +22 -0
  102. package/dist/compare/index.js.map +1 -0
  103. package/dist/compare/instance.d.ts +30 -0
  104. package/dist/compare/instance.js +145 -0
  105. package/dist/compare/instance.js.map +1 -0
  106. package/dist/compare/observability.d.ts +49 -0
  107. package/dist/compare/observability.js +57 -0
  108. package/dist/compare/observability.js.map +1 -0
  109. package/dist/compare/parting.d.ts +179 -0
  110. package/dist/compare/parting.js +244 -0
  111. package/dist/compare/parting.js.map +1 -0
  112. package/dist/compare/slice.d.ts +74 -0
  113. package/dist/compare/slice.js +79 -0
  114. package/dist/compare/slice.js.map +1 -0
  115. package/dist/compare/value.d.ts +60 -0
  116. package/dist/compare/value.js +111 -0
  117. package/dist/compare/value.js.map +1 -0
  118. package/dist/format/accessibility.d.ts +27 -0
  119. package/dist/format/accessibility.js +17 -0
  120. package/dist/format/accessibility.js.map +1 -0
  121. package/dist/format/canonical.d.ts +38 -0
  122. package/dist/format/canonical.js +65 -0
  123. package/dist/format/canonical.js.map +1 -0
  124. package/dist/format/capture.d.ts +212 -0
  125. package/dist/format/capture.js +2 -0
  126. package/dist/format/capture.js.map +1 -0
  127. package/dist/format/document.d.ts +279 -0
  128. package/dist/format/document.js +65 -0
  129. package/dist/format/document.js.map +1 -0
  130. package/dist/format/environment.d.ts +120 -0
  131. package/dist/format/environment.js +53 -0
  132. package/dist/format/environment.js.map +1 -0
  133. package/dist/format/hash.d.ts +45 -0
  134. package/dist/format/hash.js +41 -0
  135. package/dist/format/hash.js.map +1 -0
  136. package/dist/format/holding.d.ts +134 -0
  137. package/dist/format/holding.js +2 -0
  138. package/dist/format/holding.js.map +1 -0
  139. package/dist/format/index.d.ts +39 -0
  140. package/dist/format/index.js +25 -0
  141. package/dist/format/index.js.map +1 -0
  142. package/dist/format/intervention.d.ts +87 -0
  143. package/dist/format/intervention.js +11 -0
  144. package/dist/format/intervention.js.map +1 -0
  145. package/dist/format/profile.d.ts +59 -0
  146. package/dist/format/profile.js +66 -0
  147. package/dist/format/profile.js.map +1 -0
  148. package/dist/format/provenance.d.ts +179 -0
  149. package/dist/format/provenance.js +222 -0
  150. package/dist/format/provenance.js.map +1 -0
  151. package/dist/format/sha256.d.ts +34 -0
  152. package/dist/format/sha256.js +152 -0
  153. package/dist/format/sha256.js.map +1 -0
  154. package/dist/format/snapshot.d.ts +263 -0
  155. package/dist/format/snapshot.js +2 -0
  156. package/dist/format/snapshot.js.map +1 -0
  157. package/dist/format/stabilize.d.ts +190 -0
  158. package/dist/format/stabilize.js +333 -0
  159. package/dist/format/stabilize.js.map +1 -0
  160. package/dist/format/tier.d.ts +19 -0
  161. package/dist/format/tier.js +11 -0
  162. package/dist/format/tier.js.map +1 -0
  163. package/dist/format/value.d.ts +115 -0
  164. package/dist/format/value.js +182 -0
  165. package/dist/format/value.js.map +1 -0
  166. package/dist/format/wiring.d.ts +115 -0
  167. package/dist/format/wiring.js +60 -0
  168. package/dist/format/wiring.js.map +1 -0
  169. package/dist/index.d.ts +35 -0
  170. package/dist/index.js +35 -0
  171. package/dist/index.js.map +1 -0
  172. package/dist/judge/docket.d.ts +85 -0
  173. package/dist/judge/docket.js +162 -0
  174. package/dist/judge/docket.js.map +1 -0
  175. package/dist/judge/fingerprint.d.ts +53 -0
  176. package/dist/judge/fingerprint.js +144 -0
  177. package/dist/judge/fingerprint.js.map +1 -0
  178. package/dist/judge/ignore.d.ts +206 -0
  179. package/dist/judge/ignore.js +237 -0
  180. package/dist/judge/ignore.js.map +1 -0
  181. package/dist/judge/index.d.ts +44 -0
  182. package/dist/judge/index.js +34 -0
  183. package/dist/judge/index.js.map +1 -0
  184. package/dist/judge/inspect.d.ts +148 -0
  185. package/dist/judge/inspect.js +208 -0
  186. package/dist/judge/inspect.js.map +1 -0
  187. package/dist/judge/intent.d.ts +146 -0
  188. package/dist/judge/intent.js +150 -0
  189. package/dist/judge/intent.js.map +1 -0
  190. package/dist/judge/locale.d.ts +111 -0
  191. package/dist/judge/locale.js +247 -0
  192. package/dist/judge/locale.js.map +1 -0
  193. package/dist/judge/scope.d.ts +41 -0
  194. package/dist/judge/scope.js +47 -0
  195. package/dist/judge/scope.js.map +1 -0
  196. package/dist/judge/sensitivity.d.ts +202 -0
  197. package/dist/judge/sensitivity.js +152 -0
  198. package/dist/judge/sensitivity.js.map +1 -0
  199. package/dist/judge/trail.d.ts +122 -0
  200. package/dist/judge/trail.js +96 -0
  201. package/dist/judge/trail.js.map +1 -0
  202. package/dist/judge/verdict.d.ts +39 -0
  203. package/dist/judge/verdict.js +32 -0
  204. package/dist/judge/verdict.js.map +1 -0
  205. package/dist/plan/index.d.ts +12 -0
  206. package/dist/plan/index.js +11 -0
  207. package/dist/plan/index.js.map +1 -0
  208. package/dist/plan/tool.d.ts +85 -0
  209. package/dist/plan/tool.js +87 -0
  210. package/dist/plan/tool.js.map +1 -0
  211. package/dist/relate/graph.d.ts +190 -0
  212. package/dist/relate/graph.js +234 -0
  213. package/dist/relate/graph.js.map +1 -0
  214. package/dist/relate/index.d.ts +16 -0
  215. package/dist/relate/index.js +16 -0
  216. package/dist/relate/index.js.map +1 -0
  217. package/dist/relate/merkle.d.ts +110 -0
  218. package/dist/relate/merkle.js +246 -0
  219. package/dist/relate/merkle.js.map +1 -0
  220. package/dist/relate/reach.d.ts +65 -0
  221. package/dist/relate/reach.js +111 -0
  222. package/dist/relate/reach.js.map +1 -0
  223. package/dist/relate/records.d.ts +129 -0
  224. package/dist/relate/records.js +112 -0
  225. package/dist/relate/records.js.map +1 -0
  226. package/dist/rules/index.d.ts +19 -0
  227. package/dist/rules/index.js +17 -0
  228. package/dist/rules/index.js.map +1 -0
  229. package/dist/rules/normalize/alias.d.ts +50 -0
  230. package/dist/rules/normalize/alias.js +111 -0
  231. package/dist/rules/normalize/alias.js.map +1 -0
  232. package/dist/rules/normalize/cascade.d.ts +85 -0
  233. package/dist/rules/normalize/cascade.js +324 -0
  234. package/dist/rules/normalize/cascade.js.map +1 -0
  235. package/dist/rules/normalize/color.d.ts +30 -0
  236. package/dist/rules/normalize/color.js +185 -0
  237. package/dist/rules/normalize/color.js.map +1 -0
  238. package/dist/rules/normalize/fixture.d.ts +83 -0
  239. package/dist/rules/normalize/fixture.js +74 -0
  240. package/dist/rules/normalize/fixture.js.map +1 -0
  241. package/dist/rules/normalize/index.d.ts +39 -0
  242. package/dist/rules/normalize/index.js +268 -0
  243. package/dist/rules/normalize/index.js.map +1 -0
  244. package/dist/rules/normalize/project.d.ts +42 -0
  245. package/dist/rules/normalize/project.js +83 -0
  246. package/dist/rules/normalize/project.js.map +1 -0
  247. package/dist/rules/normalize/shorthand.d.ts +38 -0
  248. package/dist/rules/normalize/shorthand.js +388 -0
  249. package/dist/rules/normalize/shorthand.js.map +1 -0
  250. package/dist/rules/normalize/value.d.ts +23 -0
  251. package/dist/rules/normalize/value.js +162 -0
  252. package/dist/rules/normalize/value.js.map +1 -0
  253. package/dist/rules/normalize/wrapper.d.ts +16 -0
  254. package/dist/rules/normalize/wrapper.js +137 -0
  255. package/dist/rules/normalize/wrapper.js.map +1 -0
  256. package/dist/rules/ruleset.d.ts +93 -0
  257. package/dist/rules/ruleset.js +175 -0
  258. package/dist/rules/ruleset.js.map +1 -0
  259. package/mark.svg +30 -0
  260. package/package.json +69 -0
@@ -0,0 +1,162 @@
1
+ import { loudestBand } from '../compare/band.js';
2
+ import { aggregateImpact } from '../compare/impact.js';
3
+ export function buildDocket(diffs, options = {}) {
4
+ const sampleSize = options.sampleSize ?? 3;
5
+ const groups = new Map();
6
+ for (const diff of diffs) {
7
+ if (diff.identical)
8
+ continue;
9
+ for (const root of diff.roots) {
10
+ const entry = groups.get(root.id) ?? newAccumulator(root);
11
+ groups.set(root.id, entry);
12
+ entry.subjects.add(diff.subjectId);
13
+ entry.deltas.push(...root.deltas);
14
+ for (const component of componentsUnder(root, diff.components)) {
15
+ mergeComponent(entry.components, component);
16
+ }
17
+ }
18
+ }
19
+ const entries = [...groups.values()]
20
+ .map((entry) => finalize(entry, sampleSize))
21
+ // Widest blast radius first: a root touching forty subjects is the review,
22
+ // and one touching a single subject is a detail underneath it.
23
+ .sort((a, b) => b.subjectCount - a.subjectCount || b.deltaCount - a.deltaCount);
24
+ const changed = diffs.filter((diff) => !diff.identical).length;
25
+ return {
26
+ entries,
27
+ subjectsCompared: diffs.length,
28
+ subjectsUnchanged: diffs.length - changed,
29
+ subjectsChanged: changed,
30
+ singleRoot: entries.length === 1,
31
+ };
32
+ }
33
+ /**
34
+ * The docket as one sentence per entry.
35
+ *
36
+ * Deliberately terse. The audience is a reviewer deciding whether to look
37
+ * further and an agent deciding whether to act, and both are worse served by a
38
+ * paragraph than by a line they can scan.
39
+ */
40
+ export function summarize(docket) {
41
+ if (docket.entries.length === 0) {
42
+ return `${docket.subjectsCompared} subjects compared, nothing changed.`;
43
+ }
44
+ const header = `${docket.subjectsChanged} of ${docket.subjectsCompared} subjects changed, ` +
45
+ `explained by ${docket.entries.length} root${docket.entries.length === 1 ? '' : 's'}.`;
46
+ const lines = docket.entries.map((entry) => {
47
+ const reach = entry.subjectCount === 1
48
+ ? `${entry.deltaCount} change${entry.deltaCount === 1 ? '' : 's'}`
49
+ : `${entry.subjectCount} subjects, ${entry.deltaCount} collateral`;
50
+ const structure = entry.structureIntact ? 'structure intact' : 'structure changed';
51
+ const components = entry.components.length > 0
52
+ ? ` — ${entry.components
53
+ .slice(0, 4)
54
+ .map((component) => component.name)
55
+ .join(', ')}`
56
+ : '';
57
+ return (` ${entry.kind}: ${entry.label} — ${reach}, ${entry.band}/${entry.impact}, ` +
58
+ `${structure}${components}`);
59
+ });
60
+ return [header, ...lines].join('\n');
61
+ }
62
+ function newAccumulator(root) {
63
+ return { root, subjects: new Set(), deltas: [], components: new Map() };
64
+ }
65
+ function finalize(entry, sampleSize) {
66
+ const subjects = [...entry.subjects];
67
+ return {
68
+ rootId: entry.root.id,
69
+ kind: entry.root.kind,
70
+ label: entry.root.label,
71
+ // The root's own band is a floor, not the answer. A token edit whose
72
+ // collateral includes a dropped accessible name is reported at `a11y`: the
73
+ // entry is one review action, so it must be labelled by the loudest thing a
74
+ // reviewer would be signing off on, not by the thing that caused it.
75
+ band: loudestBand([entry.root.band, ...entry.deltas.map((delta) => delta.band)]) ?? entry.root.band,
76
+ impact: aggregateImpact(entry.deltas.map((delta) => delta.impact ?? 'structural')),
77
+ subjects,
78
+ subjectCount: subjects.length,
79
+ deltaCount: entry.deltas.length,
80
+ components: [...entry.components.values()]
81
+ .map((component) => ({
82
+ name: component.name,
83
+ role: component.role,
84
+ deltaCount: component.deltaCount,
85
+ bands: [...component.bands],
86
+ impact: aggregateImpact(component.impacts),
87
+ renderedIn: [...component.renderedIn],
88
+ }))
89
+ .sort((a, b) => b.deltaCount - a.deltaCount || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
90
+ sample: subjects.slice(0, sampleSize),
91
+ places: rankedPlaces(entry.deltas),
92
+ structureIntact: !entry.deltas.some(isStructural),
93
+ };
94
+ }
95
+ /** Distinct `where` phrases, most frequent first, capped. */
96
+ function rankedPlaces(deltas) {
97
+ const counts = new Map();
98
+ for (const delta of deltas) {
99
+ if (delta.where === undefined || delta.where === '')
100
+ continue;
101
+ counts.set(delta.where, (counts.get(delta.where) ?? 0) + 1);
102
+ }
103
+ return [...counts.entries()]
104
+ .sort((a, b) => b[1] - a[1])
105
+ .slice(0, 3)
106
+ .map(([where]) => where);
107
+ }
108
+ /**
109
+ * Whether a delta changed the shape of the tree rather than a value.
110
+ *
111
+ * A `rect-changed` that was *derived* from a style change does not count: the
112
+ * box moved because a value moved, and calling that a structural change would
113
+ * make every spacing token look like it rearranged the page.
114
+ */
115
+ function isStructural(delta) {
116
+ switch (delta.kind) {
117
+ case 'node-added':
118
+ case 'node-removed':
119
+ case 'node-moved':
120
+ case 'role-changed':
121
+ case 'name-changed':
122
+ case 'text-changed':
123
+ return true;
124
+ case 'rect-changed':
125
+ return delta.derivedFrom === undefined;
126
+ default:
127
+ return false;
128
+ }
129
+ }
130
+ /** Components that appear in this root's deltas, from the subject's own view. */
131
+ function componentsUnder(root, components) {
132
+ const names = new Set();
133
+ for (const delta of root.deltas) {
134
+ for (const owner of delta.owners ?? [])
135
+ names.add(owner.name);
136
+ }
137
+ return components.filter((component) => names.has(component.name));
138
+ }
139
+ function mergeComponent(into, component) {
140
+ const existing = into.get(component.name);
141
+ if (!existing) {
142
+ into.set(component.name, {
143
+ name: component.name,
144
+ role: component.role,
145
+ deltaCount: component.deltaCount,
146
+ bands: new Set(component.bands),
147
+ impacts: [],
148
+ renderedIn: new Set(component.renderedIn),
149
+ });
150
+ return;
151
+ }
152
+ existing.deltaCount += component.deltaCount;
153
+ for (const band of component.bands)
154
+ existing.bands.add(band);
155
+ for (const parent of component.renderedIn)
156
+ existing.renderedIn.add(parent);
157
+ // Root in any subject means root: a component that changed internally
158
+ // somewhere is a cause, even if elsewhere it was only along for the ride.
159
+ if (component.role === 'root')
160
+ existing.role = 'root';
161
+ }
162
+ //# sourceMappingURL=docket.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"docket.js","sourceRoot":"","sources":["../../src/judge/docket.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAa,MAAM,oBAAoB,CAAC;AAE5D,OAAO,EAAE,eAAe,EAA6C,MAAM,sBAAsB,CAAC;AAoFlG,MAAM,UAAU,WAAW,CACzB,KAA8B,EAC9B,OAAO,GAAkB,EAAE;IAE3B,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,CAAC,CAAC;IAC3C,MAAM,MAAM,GAAG,IAAI,GAAG,EAAuB,CAAC;IAE9C,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,IAAI,CAAC,SAAS;YAAE,SAAS;QAE7B,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YAC9B,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,cAAc,CAAC,IAAI,CAAC,CAAC;YAC1D,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;YAE3B,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YACnC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC;YAClC,KAAK,MAAM,SAAS,IAAI,eAAe,CAAC,IAAI,EAAE,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC;gBAC/D,cAAc,CAAC,KAAK,CAAC,UAAU,EAAE,SAAS,CAAC,CAAC;YAC9C,CAAC;QACH,CAAC;IACH,CAAC;IAED,MAAM,OAAO,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;SACjC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC;QAC5C,2EAA2E;QAC3E,+DAA+D;SAC9D,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,YAAY,GAAG,CAAC,CAAC,YAAY,IAAI,CAAC,CAAC,UAAU,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC;IAElF,MAAM,OAAO,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC;IAE/D,OAAO;QACL,OAAO;QACP,gBAAgB,EAAE,KAAK,CAAC,MAAM;QAC9B,iBAAiB,EAAE,KAAK,CAAC,MAAM,GAAG,OAAO;QACzC,eAAe,EAAE,OAAO;QACxB,UAAU,EAAE,OAAO,CAAC,MAAM,KAAK,CAAC;KACjC,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,SAAS,CAAC,MAAc;IACtC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAChC,OAAO,GAAG,MAAM,CAAC,gBAAgB,sCAAsC,CAAC;IAC1E,CAAC;IAED,MAAM,MAAM,GACV,GAAG,MAAM,CAAC,eAAe,OAAO,MAAM,CAAC,gBAAgB,qBAAqB;QAC5E,gBAAgB,MAAM,CAAC,OAAO,CAAC,MAAM,QAAQ,MAAM,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC;IAEzF,MAAM,KAAK,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE;QACzC,MAAM,KAAK,GACT,KAAK,CAAC,YAAY,KAAK,CAAC;YACtB,CAAC,CAAC,GAAG,KAAK,CAAC,UAAU,UAAU,KAAK,CAAC,UAAU,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,EAAE;YAClE,CAAC,CAAC,GAAG,KAAK,CAAC,YAAY,cAAc,KAAK,CAAC,UAAU,aAAa,CAAC;QAEvE,MAAM,SAAS,GAAG,KAAK,CAAC,eAAe,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,mBAAmB,CAAC;QACnF,MAAM,UAAU,GACd,KAAK,CAAC,UAAU,CAAC,MAAM,GAAG,CAAC;YACzB,CAAC,CAAC,MAAM,KAAK,CAAC,UAAU;iBACnB,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC;iBACX,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC;iBAClC,IAAI,CAAC,IAAI,CAAC,EAAE;YACjB,CAAC,CAAC,EAAE,CAAC;QAET,OAAO,CACL,KAAK,KAAK,CAAC,IAAI,KAAK,KAAK,CAAC,KAAK,MAAM,KAAK,KAAK,KAAK,CAAC,IAAI,IAAI,KAAK,CAAC,MAAM,IAAI;YAC7E,GAAG,SAAS,GAAG,UAAU,EAAE,CAC5B,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,OAAO,CAAC,MAAM,EAAE,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACvC,CAAC;AAkBD,SAAS,cAAc,CAAC,IAAU;IAChC,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,GAAG,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,UAAU,EAAE,IAAI,GAAG,EAAE,EAAE,CAAC;AAC1E,CAAC;AAED,SAAS,QAAQ,CAAC,KAAkB,EAAE,UAAkB;IACtD,MAAM,QAAQ,GAAG,CAAC,GAAG,KAAK,CAAC,QAAQ,CAAC,CAAC;IAErC,OAAO;QACL,MAAM,EAAE,KAAK,CAAC,IAAI,CAAC,EAAE;QACrB,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI;QACrB,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,KAAK;QACvB,qEAAqE;QACrE,2EAA2E;QAC3E,4EAA4E;QAC5E,qEAAqE;QACrE,IAAI,EAAE,WAAW,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI;QACnG,MAAM,EAAE,eAAe,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,IAAI,YAAY,CAAC,CAAC;QAClF,QAAQ;QACR,YAAY,EAAE,QAAQ,CAAC,MAAM;QAC7B,UAAU,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM;QAC/B,UAAU,EAAE,CAAC,GAAG,KAAK,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC;aACvC,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;YACnB,IAAI,EAAE,SAAS,CAAC,IAAI;YACpB,IAAI,EAAE,SAAS,CAAC,IAAI;YACpB,UAAU,EAAE,SAAS,CAAC,UAAU;YAChC,KAAK,EAAE,CAAC,GAAG,SAAS,CAAC,KAAK,CAAC;YAC3B,MAAM,EAAE,eAAe,CAAC,SAAS,CAAC,OAAO,CAAC;YAC1C,UAAU,EAAE,CAAC,GAAG,SAAS,CAAC,UAAU,CAAC;SACtC,CAAC,CAAC;aACF,IAAI,CACH,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CACP,CAAC,CAAC,UAAU,GAAG,CAAC,CAAC,UAAU,IAAI,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAClF;QACH,MAAM,EAAE,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,UAAU,CAAC;QACrC,MAAM,EAAE,YAAY,CAAC,KAAK,CAAC,MAAM,CAAC;QAClC,eAAe,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC;KAClD,CAAC;AACJ,CAAC;AAED,6DAA6D;AAC7D,SAAS,YAAY,CAAC,MAAwB;IAC5C,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IAEzC,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS,IAAI,KAAK,CAAC,KAAK,KAAK,EAAE;YAAE,SAAS;QAC9D,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IAC9D,CAAC;IAED,OAAO,CAAC,GAAG,MAAM,CAAC,OAAO,EAAE,CAAC;SACzB,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;SAC3B,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC;SACX,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC;AAC7B,CAAC;AAED;;;;;;GAMG;AACH,SAAS,YAAY,CAAC,KAAY;IAChC,QAAQ,KAAK,CAAC,IAAI,EAAE,CAAC;QACnB,KAAK,YAAY,CAAC;QAClB,KAAK,cAAc,CAAC;QACpB,KAAK,YAAY,CAAC;QAClB,KAAK,cAAc,CAAC;QACpB,KAAK,cAAc,CAAC;QACpB,KAAK,cAAc;YACjB,OAAO,IAAI,CAAC;QACd,KAAK,cAAc;YACjB,OAAO,KAAK,CAAC,WAAW,KAAK,SAAS,CAAC;QACzC;YACE,OAAO,KAAK,CAAC;IACjB,CAAC;AACH,CAAC;AAED,iFAAiF;AACjF,SAAS,eAAe,CACtB,IAAU,EACV,UAAuC;IAEvC,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAChC,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;QAChC,KAAK,MAAM,KAAK,IAAI,KAAK,CAAC,MAAM,IAAI,EAAE;YAAE,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAChE,CAAC;IACD,OAAO,UAAU,CAAC,MAAM,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;AACrE,CAAC;AAED,SAAS,cAAc,CACrB,IAAmC,EACnC,SAA2B;IAE3B,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;IAE1C,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,EAAE;YACvB,IAAI,EAAE,SAAS,CAAC,IAAI;YACpB,IAAI,EAAE,SAAS,CAAC,IAAI;YACpB,UAAU,EAAE,SAAS,CAAC,UAAU;YAChC,KAAK,EAAE,IAAI,GAAG,CAAC,SAAS,CAAC,KAAK,CAAC;YAC/B,OAAO,EAAE,EAAE;YACX,UAAU,EAAE,IAAI,GAAG,CAAC,SAAS,CAAC,UAAU,CAAC;SAC1C,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,QAAQ,CAAC,UAAU,IAAI,SAAS,CAAC,UAAU,CAAC;IAC5C,KAAK,MAAM,IAAI,IAAI,SAAS,CAAC,KAAK;QAAE,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC7D,KAAK,MAAM,MAAM,IAAI,SAAS,CAAC,UAAU;QAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAC3E,sEAAsE;IACtE,0EAA0E;IAC1E,IAAI,SAAS,CAAC,IAAI,KAAK,MAAM;QAAE,QAAQ,CAAC,IAAI,GAAG,MAAM,CAAC;AACxD,CAAC","sourcesContent":["import { loudestBand, type Band } from '../compare/band.js';\nimport type { ChangedComponent, Delta, Root, RootKind, SemanticDiff } from '../compare/diff/index.js';\nimport { aggregateImpact, type AggregateImpact, type PropertyImpact } from '../compare/impact.js';\n\n/**\n * The docket: one entry per root cause, across every subject in a change set.\n *\n * A `SemanticDiff` answers \"what changed in this subject\". That is the wrong unit\n * for review, and reviewing at that unit is what makes visual regression\n * unbearable — one design-token edit arrives as three hundred separate subjects\n * to click through, and the three-hundred-and-first gets approved without being\n * read.\n *\n * The docket inverts it. One token edit is **one entry**, carrying the count of\n * what it reached. Spec §6.2 requires that approving it be a single action, which\n * is only possible if the thing being approved is the cause rather than each of\n * its effects.\n *\n * Aggregation is by root **id**, which is why root ids are constructed to be\n * stable across subjects: `token:--color-primary` is the same root wherever it\n * lands, so the grouping needs no similarity heuristic.\n */\n\nexport interface DocketEntry {\n readonly rootId: string;\n readonly kind: RootKind;\n readonly label: string;\n readonly band: Band;\n readonly impact: AggregateImpact;\n\n /** Subjects this root reached, in first-seen order. */\n readonly subjects: readonly string[];\n readonly subjectCount: number;\n /** Individual deltas across all subjects — the collateral count. */\n readonly deltaCount: number;\n\n readonly components: readonly ChangedComponent[];\n\n /**\n * A few subjects to actually look at.\n *\n * Mass re-baselining is only survivable if approval is *informed*, and nobody\n * informs themselves by reviewing three hundred identical diffs. Spec §7.3 asks\n * for a sampled spot-check list, and this is it.\n */\n readonly sample: readonly string[];\n\n /**\n * Distinct places this root's changes were observed, most common first.\n *\n * Carried on the entry rather than derived from deltas at report time, because\n * an entry spans subjects and the deltas behind it are not kept. Three is\n * enough to orient without turning a collapsed root back into a list — the\n * thing collapsing it was for.\n */\n readonly places: readonly string[];\n\n /**\n * `true` when nothing under this root added, removed, moved, or renamed a node.\n *\n * The other half of the sentence the product promises. \"One token change, 300\n * collateral\" is only reassuring alongside \"structure intact\" — that is what\n * makes it a one-action approval rather than three hundred things to check.\n */\n readonly structureIntact: boolean;\n}\n\nexport interface Docket {\n readonly entries: readonly DocketEntry[];\n readonly subjectsCompared: number;\n readonly subjectsUnchanged: number;\n readonly subjectsChanged: number;\n\n /**\n * `true` when every change in the set has one explanation.\n *\n * The condition under which the whole review collapses to a single action.\n */\n readonly singleRoot: boolean;\n}\n\nexport interface DocketOptions {\n /** Subjects listed per entry for spot-checking. Policy, not a constant. */\n readonly sampleSize?: number;\n}\n\nexport function buildDocket(\n diffs: readonly SemanticDiff[],\n options: DocketOptions = {},\n): Docket {\n const sampleSize = options.sampleSize ?? 3;\n const groups = new Map<string, Accumulator>();\n\n for (const diff of diffs) {\n if (diff.identical) continue;\n\n for (const root of diff.roots) {\n const entry = groups.get(root.id) ?? newAccumulator(root);\n groups.set(root.id, entry);\n\n entry.subjects.add(diff.subjectId);\n entry.deltas.push(...root.deltas);\n for (const component of componentsUnder(root, diff.components)) {\n mergeComponent(entry.components, component);\n }\n }\n }\n\n const entries = [...groups.values()]\n .map((entry) => finalize(entry, sampleSize))\n // Widest blast radius first: a root touching forty subjects is the review,\n // and one touching a single subject is a detail underneath it.\n .sort((a, b) => b.subjectCount - a.subjectCount || b.deltaCount - a.deltaCount);\n\n const changed = diffs.filter((diff) => !diff.identical).length;\n\n return {\n entries,\n subjectsCompared: diffs.length,\n subjectsUnchanged: diffs.length - changed,\n subjectsChanged: changed,\n singleRoot: entries.length === 1,\n };\n}\n\n/**\n * The docket as one sentence per entry.\n *\n * Deliberately terse. The audience is a reviewer deciding whether to look\n * further and an agent deciding whether to act, and both are worse served by a\n * paragraph than by a line they can scan.\n */\nexport function summarize(docket: Docket): string {\n if (docket.entries.length === 0) {\n return `${docket.subjectsCompared} subjects compared, nothing changed.`;\n }\n\n const header =\n `${docket.subjectsChanged} of ${docket.subjectsCompared} subjects changed, ` +\n `explained by ${docket.entries.length} root${docket.entries.length === 1 ? '' : 's'}.`;\n\n const lines = docket.entries.map((entry) => {\n const reach =\n entry.subjectCount === 1\n ? `${entry.deltaCount} change${entry.deltaCount === 1 ? '' : 's'}`\n : `${entry.subjectCount} subjects, ${entry.deltaCount} collateral`;\n\n const structure = entry.structureIntact ? 'structure intact' : 'structure changed';\n const components =\n entry.components.length > 0\n ? ` — ${entry.components\n .slice(0, 4)\n .map((component) => component.name)\n .join(', ')}`\n : '';\n\n return (\n ` ${entry.kind}: ${entry.label} — ${reach}, ${entry.band}/${entry.impact}, ` +\n `${structure}${components}`\n );\n });\n\n return [header, ...lines].join('\\n');\n}\n\ninterface Accumulator {\n readonly root: Root;\n readonly subjects: Set<string>;\n readonly deltas: Delta[];\n readonly components: Map<string, MutableComponent>;\n}\n\ninterface MutableComponent {\n name: string;\n role: 'root' | 'collateral';\n deltaCount: number;\n bands: Set<Band>;\n impacts: (PropertyImpact | 'structural')[];\n renderedIn: Set<string>;\n}\n\nfunction newAccumulator(root: Root): Accumulator {\n return { root, subjects: new Set(), deltas: [], components: new Map() };\n}\n\nfunction finalize(entry: Accumulator, sampleSize: number): DocketEntry {\n const subjects = [...entry.subjects];\n\n return {\n rootId: entry.root.id,\n kind: entry.root.kind,\n label: entry.root.label,\n // The root's own band is a floor, not the answer. A token edit whose\n // collateral includes a dropped accessible name is reported at `a11y`: the\n // entry is one review action, so it must be labelled by the loudest thing a\n // reviewer would be signing off on, not by the thing that caused it.\n band: loudestBand([entry.root.band, ...entry.deltas.map((delta) => delta.band)]) ?? entry.root.band,\n impact: aggregateImpact(entry.deltas.map((delta) => delta.impact ?? 'structural')),\n subjects,\n subjectCount: subjects.length,\n deltaCount: entry.deltas.length,\n components: [...entry.components.values()]\n .map((component) => ({\n name: component.name,\n role: component.role,\n deltaCount: component.deltaCount,\n bands: [...component.bands],\n impact: aggregateImpact(component.impacts),\n renderedIn: [...component.renderedIn],\n }))\n .sort(\n (a, b) =>\n b.deltaCount - a.deltaCount || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0),\n ),\n sample: subjects.slice(0, sampleSize),\n places: rankedPlaces(entry.deltas),\n structureIntact: !entry.deltas.some(isStructural),\n };\n}\n\n/** Distinct `where` phrases, most frequent first, capped. */\nfunction rankedPlaces(deltas: readonly Delta[]): readonly string[] {\n const counts = new Map<string, number>();\n\n for (const delta of deltas) {\n if (delta.where === undefined || delta.where === '') continue;\n counts.set(delta.where, (counts.get(delta.where) ?? 0) + 1);\n }\n\n return [...counts.entries()]\n .sort((a, b) => b[1] - a[1])\n .slice(0, 3)\n .map(([where]) => where);\n}\n\n/**\n * Whether a delta changed the shape of the tree rather than a value.\n *\n * A `rect-changed` that was *derived* from a style change does not count: the\n * box moved because a value moved, and calling that a structural change would\n * make every spacing token look like it rearranged the page.\n */\nfunction isStructural(delta: Delta): boolean {\n switch (delta.kind) {\n case 'node-added':\n case 'node-removed':\n case 'node-moved':\n case 'role-changed':\n case 'name-changed':\n case 'text-changed':\n return true;\n case 'rect-changed':\n return delta.derivedFrom === undefined;\n default:\n return false;\n }\n}\n\n/** Components that appear in this root's deltas, from the subject's own view. */\nfunction componentsUnder(\n root: Root,\n components: readonly ChangedComponent[],\n): readonly ChangedComponent[] {\n const names = new Set<string>();\n for (const delta of root.deltas) {\n for (const owner of delta.owners ?? []) names.add(owner.name);\n }\n return components.filter((component) => names.has(component.name));\n}\n\nfunction mergeComponent(\n into: Map<string, MutableComponent>,\n component: ChangedComponent,\n): void {\n const existing = into.get(component.name);\n\n if (!existing) {\n into.set(component.name, {\n name: component.name,\n role: component.role,\n deltaCount: component.deltaCount,\n bands: new Set(component.bands),\n impacts: [],\n renderedIn: new Set(component.renderedIn),\n });\n return;\n }\n\n existing.deltaCount += component.deltaCount;\n for (const band of component.bands) existing.bands.add(band);\n for (const parent of component.renderedIn) existing.renderedIn.add(parent);\n // Root in any subject means root: a component that changed internally\n // somewhere is a cause, even if elsewhere it was only along for the ride.\n if (component.role === 'root') existing.role = 'root';\n}\n"]}
@@ -0,0 +1,53 @@
1
+ import type { Delta, Root } from '../compare/diff/delta.js';
2
+ import type { ChangeMask, DiffRegion } from '../attribute/mask.js';
3
+ import { type Digest } from '../format/hash.js';
4
+ /**
5
+ * The shape of one difference: its kind, its band, and which property moved.
6
+ *
7
+ * Everything positional and everything valued is deliberately absent. Two deltas
8
+ * with the same shape are the same *kind of thing happening*, which is the
9
+ * equivalence a flake needs and the one a coordinate cannot express.
10
+ */
11
+ export declare function shapeOfDelta(delta: Delta): string;
12
+ /**
13
+ * A digest of a difference with its position and its values removed.
14
+ *
15
+ * Built from the root's kind, the multiset of its deltas' shapes, and the
16
+ * component responsible — and from nothing else. The label is excluded on
17
+ * purpose: `token:--brand-a` and `token:--brand-b` moving the same properties in
18
+ * the same component are the same shape, and an operator silencing a themable
19
+ * surface wants both. Where the *label* is the identity worth matching on, the
20
+ * root id already exists and `Policy.alwaysAuthorized` already takes it.
21
+ *
22
+ * The `cause` is included, which is what keeps this from being too coarse: the
23
+ * same shape in a different component is a different fingerprint, so silencing a
24
+ * flake in `Avatar` does not silence the identical-looking regression in `Badge`.
25
+ */
26
+ export declare function fingerprintOfRoot(root: Root): Digest;
27
+ export interface MaskFingerprintOptions {
28
+ /** Grid the cropped region is resampled onto. Larger is stricter. */
29
+ readonly grid?: number;
30
+ /** Fraction of a cell that must have changed for the cell to count as set. */
31
+ readonly coverage?: number;
32
+ }
33
+ /**
34
+ * A digest of what a change *looks like*, for the tier that has no document.
35
+ *
36
+ * The region is cropped to its own bounding box and resampled onto a fixed grid,
37
+ * so the same artifact three hundred pixels lower — a card that moved, a toast
38
+ * that reappeared — produces the same digest. The aspect ratio is bucketed in
39
+ * alongside it, coarsely, so a wide banner and a tall column cannot collide
40
+ * merely by having similar interiors.
41
+ *
42
+ * This is [mask-fingerprint](https://github.com/argos-ci/mask-fingerprint)'s
43
+ * move, and it is the right one: it is the only way an ignore can be scoped to a
44
+ * *flake* rather than to a rectangle, which is the difference between silencing
45
+ * one artifact and blinding a region.
46
+ *
47
+ * It is deliberately the weaker of the two fingerprints on this page. A pixel
48
+ * shape knows nothing about who caused it, so two unrelated components producing
49
+ * similar-looking residue collide — which is exactly why the semantic
50
+ * fingerprint exists and is preferred wherever a document is available.
51
+ */
52
+ export declare function fingerprintOfMask(mask: ChangeMask, region: DiffRegion, options?: MaskFingerprintOptions): Digest;
53
+ //# sourceMappingURL=fingerprint.d.ts.map
@@ -0,0 +1,144 @@
1
+ import { digestString } from '../format/hash.js';
2
+ /**
3
+ * The shape of a difference, with its position and its values removed.
4
+ *
5
+ * Two digests, and the interesting thing about them is that they answer the same
6
+ * question at different strengths. A **semantic** fingerprint knows which
7
+ * component produced the difference and can therefore tell an identical-looking
8
+ * change in `Avatar` from one in `Badge`. A **pixel** fingerprint knows only what
9
+ * the residue looks like, so it collides where two unrelated components produce
10
+ * similar shapes — and it is the only one available where no document survives.
11
+ *
12
+ * Both exist so that an ignore, or an acceptance, can be scoped to *what
13
+ * happened* rather than to *where it landed* (spec 0024, ADR-0025). A coordinate
14
+ * stops covering the thing it was drawn around the first time the layout moves; a
15
+ * shape follows it. That is [`mask-fingerprint`](https://github.com/argos-ci/mask-fingerprint)'s
16
+ * idea, and it is the right one.
17
+ *
18
+ * Separated from `ignore.ts` because ignoring is one of three things these are
19
+ * for. `variance accept --shape` promotes a change everywhere it reached, a
20
+ * report prints one beside every region so an operator can copy it, and an ignore
21
+ * absorbs one — and a file named for the third would misdescribe the other two.
22
+ */
23
+ /**
24
+ * Field separator inside a digest input.
25
+ *
26
+ * A byte that cannot occur in a component name, a property or a band, so no two
27
+ * different shapes can concatenate into the same string. A space would collide
28
+ * the moment a value contained one, which is the collision `digestCombine` uses
29
+ * the same character to avoid.
30
+ */
31
+ const SEPARATOR = '\u0000';
32
+ /**
33
+ * The shape of one difference: its kind, its band, and which property moved.
34
+ *
35
+ * Everything positional and everything valued is deliberately absent. Two deltas
36
+ * with the same shape are the same *kind of thing happening*, which is the
37
+ * equivalence a flake needs and the one a coordinate cannot express.
38
+ */
39
+ export function shapeOfDelta(delta) {
40
+ return `${delta.kind}/${delta.band}/${delta.property ?? ''}`;
41
+ }
42
+ /**
43
+ * A digest of a difference with its position and its values removed.
44
+ *
45
+ * Built from the root's kind, the multiset of its deltas' shapes, and the
46
+ * component responsible — and from nothing else. The label is excluded on
47
+ * purpose: `token:--brand-a` and `token:--brand-b` moving the same properties in
48
+ * the same component are the same shape, and an operator silencing a themable
49
+ * surface wants both. Where the *label* is the identity worth matching on, the
50
+ * root id already exists and `Policy.alwaysAuthorized` already takes it.
51
+ *
52
+ * The `cause` is included, which is what keeps this from being too coarse: the
53
+ * same shape in a different component is a different fingerprint, so silencing a
54
+ * flake in `Avatar` does not silence the identical-looking regression in `Badge`.
55
+ */
56
+ export function fingerprintOfRoot(root) {
57
+ const shapes = root.deltas.map(shapeOfDelta).sort();
58
+ return digestString(['shape/v1', root.kind, root.cause ?? '', ...shapes].join(SEPARATOR));
59
+ }
60
+ const DEFAULT_GRID = 12;
61
+ const DEFAULT_COVERAGE = 0.25;
62
+ /**
63
+ * A digest of what a change *looks like*, for the tier that has no document.
64
+ *
65
+ * The region is cropped to its own bounding box and resampled onto a fixed grid,
66
+ * so the same artifact three hundred pixels lower — a card that moved, a toast
67
+ * that reappeared — produces the same digest. The aspect ratio is bucketed in
68
+ * alongside it, coarsely, so a wide banner and a tall column cannot collide
69
+ * merely by having similar interiors.
70
+ *
71
+ * This is [mask-fingerprint](https://github.com/argos-ci/mask-fingerprint)'s
72
+ * move, and it is the right one: it is the only way an ignore can be scoped to a
73
+ * *flake* rather than to a rectangle, which is the difference between silencing
74
+ * one artifact and blinding a region.
75
+ *
76
+ * It is deliberately the weaker of the two fingerprints on this page. A pixel
77
+ * shape knows nothing about who caused it, so two unrelated components producing
78
+ * similar-looking residue collide — which is exactly why the semantic
79
+ * fingerprint exists and is preferred wherever a document is available.
80
+ */
81
+ export function fingerprintOfMask(mask, region, options = {}) {
82
+ const grid = Math.max(1, Math.floor(options.grid ?? DEFAULT_GRID));
83
+ const coverage = options.coverage ?? DEFAULT_COVERAGE;
84
+ const width = Math.max(1, region.width);
85
+ const height = Math.max(1, region.height);
86
+ // Typed rather than plain arrays: the resample is the one hot loop on this
87
+ // page, running once per region on every red raster comparison.
88
+ const cells = new Uint32Array(grid * grid);
89
+ const totals = new Uint32Array(grid * grid);
90
+ for (let y = 0; y < height; y += 1) {
91
+ const sourceY = region.y + y;
92
+ if (sourceY < 0 || sourceY >= mask.height)
93
+ continue;
94
+ const row = ((y * grid) / height) | 0;
95
+ const rowOffset = sourceY * mask.width;
96
+ const cellRow = Math.min(row, grid - 1) * grid;
97
+ for (let x = 0; x < width; x += 1) {
98
+ const sourceX = region.x + x;
99
+ if (sourceX < 0 || sourceX >= mask.width)
100
+ continue;
101
+ const cell = cellRow + Math.min(((x * grid) / width) | 0, grid - 1);
102
+ totals[cell] = (totals[cell] ?? 0) + 1;
103
+ if (mask.data[rowOffset + sourceX] !== 0)
104
+ cells[cell] = (cells[cell] ?? 0) + 1;
105
+ }
106
+ }
107
+ let bits = '';
108
+ for (let index = 0; index < cells.length; index += 1) {
109
+ const total = totals[index] ?? 0;
110
+ const set = cells[index] ?? 0;
111
+ bits += total > 0 && set / total >= coverage ? '1' : '0';
112
+ }
113
+ return digestString(['mask/v1', aspectBucket(width, height), sizeBucket(width, height), bits].join(SEPARATOR));
114
+ }
115
+ /**
116
+ * Aspect ratio in coarse steps, so a shape is not required to keep its exact box.
117
+ *
118
+ * Halving steps rather than a continuous ratio: a region that grows by a pixel
119
+ * must stay the same fingerprint, or the whole mechanism degrades into the
120
+ * coordinate matching it exists to replace.
121
+ */
122
+ function aspectBucket(width, height) {
123
+ const ratio = width / height;
124
+ return String(Math.round(Math.log2(ratio) * 2));
125
+ }
126
+ /**
127
+ * Extent in coarse doubling steps, so a shape ignore is scoped to a magnitude.
128
+ *
129
+ * Without it the digest is **scale-free**, and that is not a subtlety: solid
130
+ * squares of 12, 16, 24, 36, 48, 96 and 120 device pixels all resample to twelve
131
+ * rows of ones and all carry aspect `0`, so they produce one digest. A rule
132
+ * written for a 12×12 flaky badge would absorb a 120×120 card that had gone
133
+ * solid — an image that failed to load, a component that rendered as a filled
134
+ * block — at a hundred times the area, and the subject would come back green.
135
+ *
136
+ * The geometric mean rather than the area, so this term and the aspect are in the
137
+ * same units and one bucket step means one doubling of *linear* size. That bounds
138
+ * over-absorption to under 2× in each dimension instead of leaving it unbounded,
139
+ * which is the most a position-invariant digest can promise.
140
+ */
141
+ function sizeBucket(width, height) {
142
+ return String(Math.round(Math.log2(Math.sqrt(width * height))));
143
+ }
144
+ //# sourceMappingURL=fingerprint.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fingerprint.js","sourceRoot":"","sources":["../../src/judge/fingerprint.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Delta, Root } from '../compare/diff/delta.js';\nimport type { ChangeMask, DiffRegion } from '../attribute/mask.js';\nimport { digestString, type Digest } from '../format/hash.js';\n\n/**\n * The shape of a difference, with its position and its values removed.\n *\n * Two digests, and the interesting thing about them is that they answer the same\n * question at different strengths. A **semantic** fingerprint knows which\n * component produced the difference and can therefore tell an identical-looking\n * change in `Avatar` from one in `Badge`. A **pixel** fingerprint knows only what\n * the residue looks like, so it collides where two unrelated components produce\n * similar shapes — and it is the only one available where no document survives.\n *\n * Both exist so that an ignore, or an acceptance, can be scoped to *what\n * happened* rather than to *where it landed* (spec 0024, ADR-0025). A coordinate\n * stops covering the thing it was drawn around the first time the layout moves; a\n * shape follows it. That is [`mask-fingerprint`](https://github.com/argos-ci/mask-fingerprint)'s\n * idea, and it is the right one.\n *\n * Separated from `ignore.ts` because ignoring is one of three things these are\n * for. `variance accept --shape` promotes a change everywhere it reached, a\n * report prints one beside every region so an operator can copy it, and an ignore\n * absorbs one — and a file named for the third would misdescribe the other two.\n */\n\n/**\n * Field separator inside a digest input.\n *\n * A byte that cannot occur in a component name, a property or a band, so no two\n * different shapes can concatenate into the same string. A space would collide\n * the moment a value contained one, which is the collision `digestCombine` uses\n * the same character to avoid.\n */\nconst SEPARATOR = '\\u0000';\n\n/**\n * The shape of one difference: its kind, its band, and which property moved.\n *\n * Everything positional and everything valued is deliberately absent. Two deltas\n * with the same shape are the same *kind of thing happening*, which is the\n * equivalence a flake needs and the one a coordinate cannot express.\n */\nexport function shapeOfDelta(delta: Delta): string {\n return `${delta.kind}/${delta.band}/${delta.property ?? ''}`;\n}\n\n/**\n * A digest of a difference with its position and its values removed.\n *\n * Built from the root's kind, the multiset of its deltas' shapes, and the\n * component responsible — and from nothing else. The label is excluded on\n * purpose: `token:--brand-a` and `token:--brand-b` moving the same properties in\n * the same component are the same shape, and an operator silencing a themable\n * surface wants both. Where the *label* is the identity worth matching on, the\n * root id already exists and `Policy.alwaysAuthorized` already takes it.\n *\n * The `cause` is included, which is what keeps this from being too coarse: the\n * same shape in a different component is a different fingerprint, so silencing a\n * flake in `Avatar` does not silence the identical-looking regression in `Badge`.\n */\nexport function fingerprintOfRoot(root: Root): Digest {\n const shapes = root.deltas.map(shapeOfDelta).sort();\n return digestString(\n ['shape/v1', root.kind, root.cause ?? '', ...shapes].join(SEPARATOR),\n );\n}\n\nexport interface MaskFingerprintOptions {\n /** Grid the cropped region is resampled onto. Larger is stricter. */\n readonly grid?: number;\n\n /** Fraction of a cell that must have changed for the cell to count as set. */\n readonly coverage?: number;\n}\n\nconst DEFAULT_GRID = 12;\nconst DEFAULT_COVERAGE = 0.25;\n\n/**\n * A digest of what a change *looks like*, for the tier that has no document.\n *\n * The region is cropped to its own bounding box and resampled onto a fixed grid,\n * so the same artifact three hundred pixels lower — a card that moved, a toast\n * that reappeared — produces the same digest. The aspect ratio is bucketed in\n * alongside it, coarsely, so a wide banner and a tall column cannot collide\n * merely by having similar interiors.\n *\n * This is [mask-fingerprint](https://github.com/argos-ci/mask-fingerprint)'s\n * move, and it is the right one: it is the only way an ignore can be scoped to a\n * *flake* rather than to a rectangle, which is the difference between silencing\n * one artifact and blinding a region.\n *\n * It is deliberately the weaker of the two fingerprints on this page. A pixel\n * shape knows nothing about who caused it, so two unrelated components producing\n * similar-looking residue collide — which is exactly why the semantic\n * fingerprint exists and is preferred wherever a document is available.\n */\nexport function fingerprintOfMask(\n mask: ChangeMask,\n region: DiffRegion,\n options: MaskFingerprintOptions = {},\n): Digest {\n const grid = Math.max(1, Math.floor(options.grid ?? DEFAULT_GRID));\n const coverage = options.coverage ?? DEFAULT_COVERAGE;\n\n const width = Math.max(1, region.width);\n const height = Math.max(1, region.height);\n // Typed rather than plain arrays: the resample is the one hot loop on this\n // page, running once per region on every red raster comparison.\n const cells = new Uint32Array(grid * grid);\n const totals = new Uint32Array(grid * grid);\n\n for (let y = 0; y < height; y += 1) {\n const sourceY = region.y + y;\n if (sourceY < 0 || sourceY >= mask.height) continue;\n\n const row = ((y * grid) / height) | 0;\n const rowOffset = sourceY * mask.width;\n const cellRow = Math.min(row, grid - 1) * grid;\n\n for (let x = 0; x < width; x += 1) {\n const sourceX = region.x + x;\n if (sourceX < 0 || sourceX >= mask.width) continue;\n\n const cell = cellRow + Math.min(((x * grid) / width) | 0, grid - 1);\n totals[cell] = (totals[cell] ?? 0) + 1;\n if (mask.data[rowOffset + sourceX] !== 0) cells[cell] = (cells[cell] ?? 0) + 1;\n }\n }\n\n let bits = '';\n for (let index = 0; index < cells.length; index += 1) {\n const total = totals[index] ?? 0;\n const set = cells[index] ?? 0;\n bits += total > 0 && set / total >= coverage ? '1' : '0';\n }\n\n return digestString(\n ['mask/v1', aspectBucket(width, height), sizeBucket(width, height), bits].join(SEPARATOR),\n );\n}\n\n/**\n * Aspect ratio in coarse steps, so a shape is not required to keep its exact box.\n *\n * Halving steps rather than a continuous ratio: a region that grows by a pixel\n * must stay the same fingerprint, or the whole mechanism degrades into the\n * coordinate matching it exists to replace.\n */\nfunction aspectBucket(width: number, height: number): string {\n const ratio = width / height;\n return String(Math.round(Math.log2(ratio) * 2));\n}\n\n/**\n * Extent in coarse doubling steps, so a shape ignore is scoped to a magnitude.\n *\n * Without it the digest is **scale-free**, and that is not a subtlety: solid\n * squares of 12, 16, 24, 36, 48, 96 and 120 device pixels all resample to twelve\n * rows of ones and all carry aspect `0`, so they produce one digest. A rule\n * written for a 12×12 flaky badge would absorb a 120×120 card that had gone\n * solid — an image that failed to load, a component that rendered as a filled\n * block — at a hundred times the area, and the subject would come back green.\n *\n * The geometric mean rather than the area, so this term and the aspect are in the\n * same units and one bucket step means one doubling of *linear* size. That bounds\n * over-absorption to under 2× in each dimension instead of leaving it unbounded,\n * which is the most a position-invariant digest can promise.\n */\nfunction sizeBucket(width: number, height: number): string {\n return String(Math.round(Math.log2(Math.sqrt(width * height))));\n}\n"]}
@@ -0,0 +1,206 @@
1
+ import type { Band } from '../compare/band.js';
2
+ import type { SemanticDiff } from '../compare/diff/index.js';
3
+ import type { Digest } from '../format/hash.js';
4
+ import type { IgnoreSite } from '../format/snapshot.js';
5
+ export type { IgnoreSite } from '../format/snapshot.js';
6
+ /**
7
+ * Ignores, and the accounting that stops one becoming a blind spot (spec 0024).
8
+ *
9
+ * The README refuses tolerances, and it is right to: a tolerance is an anonymous
10
+ * number that hides whatever fits underneath it, chosen by whoever wrote the
11
+ * default. An ignore is the opposite of that in every respect that matters, and
12
+ * the difference is enforced here rather than asserted in a document:
13
+ *
14
+ * - it names a **place** or a **shape**, never a magnitude;
15
+ * - it carries a **reason**, and a rule without one is refused;
16
+ * - it says how much it absorbed, in how many subjects, every run;
17
+ * - it is **reported when it absorbs nothing**, because an ignore that outlived
18
+ * its flake is a hole in the suite nobody can see;
19
+ * - it never produces `unchanged`. A subject whose every difference was absorbed
20
+ * is a subject that was *ignored*, and the two words are not interchangeable.
21
+ *
22
+ * That last rule is ADR-0002's, applied to a second reason for not having looked.
23
+ * A profile that cannot observe a band reports `unobserved` rather than passing
24
+ * it; an operator who declined to look at a region gets the same treatment, for
25
+ * the same reason — a green subject that is indistinguishable from a genuinely
26
+ * unchanged one makes every count on this page decorative.
27
+ *
28
+ * ## Two ways to name what is not the subject
29
+ *
30
+ * **By place.** A subtree, resolved once by whatever held the document, arrives
31
+ * here as an {@link IgnoreSite}: a path, and the box it occupied. One declaration
32
+ * serves both tiers — the semantic tier drops deltas under the path, the raster
33
+ * tier subtracts the box from the change mask — because two declarations would
34
+ * let the tiers disagree about what the subject is, and a region that is ignored
35
+ * semantically and compared on pixels reports `unexplained`, the highest severity
36
+ * in the system, for something the operator already excluded.
37
+ *
38
+ * **By shape.** A {@link fingerprintOfRoot} is a digest of a difference with its
39
+ * position and its values removed, so the same artifact anywhere in any subject
40
+ * digests the same. An ignore keyed on one silences a known flake *without*
41
+ * blinding the image it appears in: a different regression in the same place has
42
+ * a different shape and is still reported. A coordinate mask cannot make that
43
+ * distinction and never will, which is why no rule here accepts one: a rectangle
44
+ * is a place that stops covering the thing it was drawn around the first time the
45
+ * layout moves, and a selector is a place that does not.
46
+ *
47
+ * ## What is not here
48
+ *
49
+ * A band on its own. `bands` narrows a rule that already names a place or a
50
+ * shape; a rule carrying only a band would be a tolerance wearing an ignore's
51
+ * clothes, and {@link validateIgnoreRule} refuses it. Which bands *block* is a
52
+ * different question with a different answer — `Policy.blocking` in `intent.ts`.
53
+ */
54
+ export interface IgnoreRule {
55
+ /** Stable name, used in the config, in the report, and by `IgnoreSite.rule`. */
56
+ readonly id: string;
57
+ /**
58
+ * Why this is not the subject. Required.
59
+ *
60
+ * Not documentation. Six months on, the only question anyone asks about an
61
+ * ignore is whether it is still true, and a rule that cannot answer it gets
62
+ * kept out of superstition. Carried into the report so the answer is in front
63
+ * of whoever is reading the failure it did not absorb.
64
+ */
65
+ readonly reason: string;
66
+ /**
67
+ * Subjects this applies to. `*` matches any run of characters. Absent means all.
68
+ *
69
+ * Narrow by default is the wrong default here and the right one to offer: a
70
+ * flake that appears in one story should be silenced in one story, and an
71
+ * operator who genuinely means "everywhere" can say so by omission — visibly,
72
+ * in the same file.
73
+ */
74
+ readonly subjects?: readonly string[];
75
+ /**
76
+ * Difference shapes this absorbs. See {@link fingerprintOfRoot}.
77
+ *
78
+ * The form to prefer. It survives layout changes, applies across subjects
79
+ * without listing them, and leaves the rest of the image being tested.
80
+ */
81
+ readonly fingerprints?: readonly Digest[];
82
+ /** Narrows what the rule absorbs where it already applies. Never on its own. */
83
+ readonly bands?: readonly Band[];
84
+ /**
85
+ * The place is the whole subject.
86
+ *
87
+ * The one way a rule may carry `bands` and no selector, and it has to be said
88
+ * rather than inferred from their absence — because "no place" and "every
89
+ * place" are the two readings of the same missing field, and one of them is the
90
+ * tolerance this mechanism exists to refuse.
91
+ *
92
+ * Set by {@link import('./sensitivity.js').asIgnore} and by nothing else. A
93
+ * sensitivity declares what a subject *is asserted on*, so its place genuinely
94
+ * is the subject: "this route asserts on layout" absorbs every token-band
95
+ * difference in it, wherever in it they land. The config parser does not accept
96
+ * this key, so a hand-written ignore still cannot reach it.
97
+ */
98
+ readonly whole?: boolean;
99
+ /**
100
+ * ISO date after which this stops absorbing and starts being reported.
101
+ *
102
+ * Declared rather than inferred, so the default lifetime of a blind spot is
103
+ * "until somebody decides again" instead of "forever". An expired rule is not
104
+ * an error — the run proceeds and the differences it used to absorb come back,
105
+ * which is the point.
106
+ */
107
+ readonly until?: string;
108
+ }
109
+ /** What one rule absorbed in one run. */
110
+ export interface AbsorbedByRule {
111
+ readonly rule: string;
112
+ readonly reason: string;
113
+ readonly deltas: number;
114
+ readonly subjects: readonly string[];
115
+ readonly bands: readonly Band[];
116
+ /**
117
+ * The shapes this rule actually absorbed.
118
+ *
119
+ * The most useful field for an operator holding a place-scoped rule: it names
120
+ * what that place was covering, so a whole excluded subtree can be narrowed to
121
+ * the one shape that is actually noisy without anyone guessing a digest.
122
+ */
123
+ readonly fingerprints: readonly Digest[];
124
+ }
125
+ export interface IgnoreRegister {
126
+ readonly absorbed: readonly AbsorbedByRule[];
127
+ /**
128
+ * Rules that absorbed nothing this run.
129
+ *
130
+ * Not a warning about the config file — a report about the suite. Either the
131
+ * flake is fixed and the rule is a hole that should close, or the rule stopped
132
+ * matching and something is being silently reported that the operator believes
133
+ * is silenced. Both are worth a line.
134
+ */
135
+ readonly dead: readonly string[];
136
+ /** Rules past their `until` date. They absorbed nothing, by construction. */
137
+ readonly expired: readonly string[];
138
+ /**
139
+ * Subjects whose every difference was absorbed.
140
+ *
141
+ * The list that must never be folded into "unchanged". These are subjects that
142
+ * differed and were not looked at.
143
+ */
144
+ readonly fullyAbsorbed: readonly string[];
145
+ readonly totalAbsorbed: number;
146
+ }
147
+ export interface IgnoreOutcome {
148
+ /** The diffs with absorbed deltas removed, in the order they were given. */
149
+ readonly diffs: readonly SemanticDiff[];
150
+ readonly register: IgnoreRegister;
151
+ }
152
+ export interface IgnoreOptions {
153
+ /** Sites per subject id, as resolved against each candidate document. */
154
+ readonly sites?: Readonly<Record<string, readonly IgnoreSite[]>>;
155
+ /**
156
+ * Today, as an ISO date, for `until`. Supplied rather than read.
157
+ *
158
+ * `core` has no clock — a package that reads one cannot be tested for what it
159
+ * does on the day a rule expires, which is the only day the field matters.
160
+ */
161
+ readonly now?: string;
162
+ }
163
+ /**
164
+ * Check a rule before it can absorb anything.
165
+ *
166
+ * Returns the problems rather than throwing, because a config carrying three bad
167
+ * rules should report three, and because the CLI's config parser wants to attach
168
+ * its own file and key to each one.
169
+ */
170
+ export declare function validateIgnoreRule(rule: IgnoreRule, options?: {
171
+ readonly hasPlace?: boolean;
172
+ }): readonly string[];
173
+ /**
174
+ * The shape of one difference: its kind, its band, and which property moved.
175
+ *
176
+ * Everything positional and everything valued is deliberately absent. Two deltas
177
+ * with the same shape are the same *kind of thing happening*, which is the
178
+ * equivalence a flake needs and the one a coordinate cannot express.
179
+ */
180
+ /**
181
+ * Field separator inside a digest input.
182
+ *
183
+ * A byte that cannot occur in a component name, a property or a band, so no two
184
+ * different shapes can concatenate into the same string. A space would collide
185
+ * the moment a value contained one, which is the collision `digestCombine` uses
186
+ * the same character to avoid.
187
+ */
188
+ /**
189
+ * Apply ignores to a run's diffs, and account for every difference absorbed.
190
+ *
191
+ * Runs after `diffSnapshots` and before `buildDocket`: the docket's arithmetic is
192
+ * about which causes a reviewer signs off on, and a cause nobody is going to look
193
+ * at should not be in it. Everything removed here is counted, attributed to the
194
+ * rule that removed it, and reported.
195
+ */
196
+ export declare function applyIgnores(diffs: readonly SemanticDiff[], rules: readonly IgnoreRule[], options?: IgnoreOptions): IgnoreOutcome;
197
+ /**
198
+ * The register as the paragraph a reader is owed.
199
+ *
200
+ * Absorption first, because that is what changed the verdict; dead and expired
201
+ * rules after it, because those are what the operator has to do something about.
202
+ * A register with nothing in it returns the empty string rather than a cheerful
203
+ * line — a run with no ignores should read exactly as it did before this existed.
204
+ */
205
+ export declare function summarizeIgnores(register: IgnoreRegister): string;
206
+ //# sourceMappingURL=ignore.d.ts.map