@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,201 @@
1
+ const DEFAULT_CELL = 8;
2
+ const DEFAULT_LIMIT = 32;
3
+ /**
4
+ * Cluster a change mask into regions.
5
+ *
6
+ * Connected components are computed on a coarse grid rather than on the pixels
7
+ * themselves. That is a performance decision and a semantic one at once: the
8
+ * coarse pass is one linear sweep instead of a merge over thousands of glyph-edge
9
+ * fragments, and it produces the grouping a reader would have produced by eye.
10
+ * Bounding boxes are then tightened back onto the actual changed pixels, so a
11
+ * region's coordinates are exact even though its *membership* was decided coarsely.
12
+ */
13
+ export function isolateRegions(mask, options = {}) {
14
+ const cell = Math.max(1, Math.floor(options.cell ?? DEFAULT_CELL));
15
+ const limit = options.limit ?? DEFAULT_LIMIT;
16
+ const columns = Math.ceil(mask.width / cell);
17
+ const rows = Math.ceil(mask.height / cell);
18
+ const occupied = new Uint8Array(columns * rows);
19
+ for (let y = 0; y < mask.height; y += 1) {
20
+ const rowOffset = y * mask.width;
21
+ const gridRow = ((y / cell) | 0) * columns;
22
+ for (let x = 0; x < mask.width; x += 1) {
23
+ if (mask.data[rowOffset + x] !== 0)
24
+ occupied[gridRow + ((x / cell) | 0)] = 1;
25
+ }
26
+ }
27
+ const seen = new Uint8Array(occupied.length);
28
+ const found = [];
29
+ for (let index = 0; index < occupied.length; index += 1) {
30
+ if (occupied[index] === 0 || seen[index] !== 0)
31
+ continue;
32
+ // Explicit stack. A full-page change is a single component covering every
33
+ // cell, and recursion at that depth is a stack overflow rather than a slow path.
34
+ const stack = [index];
35
+ seen[index] = 1;
36
+ const cells = [];
37
+ while (stack.length > 0) {
38
+ const current = stack.pop();
39
+ cells.push(current);
40
+ const cx = current % columns;
41
+ const cy = (current / columns) | 0;
42
+ for (let dy = -1; dy <= 1; dy += 1) {
43
+ for (let dx = -1; dx <= 1; dx += 1) {
44
+ const nx = cx + dx;
45
+ const ny = cy + dy;
46
+ if (nx < 0 || ny < 0 || nx >= columns || ny >= rows)
47
+ continue;
48
+ const neighbour = ny * columns + nx;
49
+ if (occupied[neighbour] === 0 || seen[neighbour] !== 0)
50
+ continue;
51
+ seen[neighbour] = 1;
52
+ stack.push(neighbour);
53
+ }
54
+ }
55
+ }
56
+ found.push(tighten(mask, cells, cell, columns));
57
+ }
58
+ found.sort((a, b) => b.pixels - a.pixels || a.y - b.y || a.x - b.x);
59
+ const kept = found.slice(0, limit);
60
+ const dropped = found.slice(limit);
61
+ return {
62
+ regions: kept,
63
+ truncated: dropped.length,
64
+ truncatedPixels: dropped.reduce((sum, region) => sum + region.pixels, 0),
65
+ };
66
+ }
67
+ /**
68
+ * Clear excluded boxes out of a change mask, and say what that cost.
69
+ *
70
+ * The raster half of an ignore (spec 0024). The semantic half drops deltas under
71
+ * a subtree; this drops the pixels the same subtree occupied, so the two tiers
72
+ * cannot disagree about what the subject is — a region excluded semantically and
73
+ * still compared on pixels arrives as `unexplained`, the highest severity in the
74
+ * system, for something the operator already said was not the subject.
75
+ *
76
+ * Subtracting *before* isolation rather than filtering regions afterwards, which
77
+ * looks equivalent and is not: a region that straddles the boundary would
78
+ * otherwise be dropped whole or kept whole, and both answers are wrong. Clearing
79
+ * pixels lets the part outside the box cluster on its own and be reported.
80
+ *
81
+ * The count is returned rather than folded away. `changed` on the result is what
82
+ * the run compares against zero; `ignored` is what it owes the reader, because a
83
+ * comparison that discarded four thousand pixels and reported "no difference" is
84
+ * the failure this whole mechanism is written around.
85
+ */
86
+ export function subtractRegions(mask, boxes) {
87
+ if (boxes.length === 0)
88
+ return { mask, ignored: 0, cleared: [], inert: [] };
89
+ const data = Uint8Array.from(mask.data);
90
+ const cleared = [];
91
+ const inert = [];
92
+ let ignored = 0;
93
+ for (const box of boxes) {
94
+ // Rounded outward. A box is a CSS rectangle and a mask is a pixel grid; the
95
+ // half-pixel at the edge belongs to the thing that was excluded, because the
96
+ // alternative is a one-pixel rim of permanent residue around every ignore.
97
+ const startX = Math.max(0, Math.floor(box.x));
98
+ const startY = Math.max(0, Math.floor(box.y));
99
+ const endX = Math.min(mask.width, Math.ceil(box.x + box.width));
100
+ const endY = Math.min(mask.height, Math.ceil(box.y + box.height));
101
+ let inside = 0;
102
+ for (let y = startY; y < endY; y += 1) {
103
+ const rowOffset = y * mask.width;
104
+ for (let x = startX; x < endX; x += 1) {
105
+ const index = rowOffset + x;
106
+ if (data[index] === 0)
107
+ continue;
108
+ data[index] = 0;
109
+ inside += 1;
110
+ }
111
+ }
112
+ ignored += inside;
113
+ cleared.push(inside);
114
+ // Against the untouched mask. `inside` counts what *this* box cleared, which
115
+ // is zero for the second of two overlapping boxes however much it covers.
116
+ if (!coversAnyChange(mask, startX, startY, endX, endY))
117
+ inert.push(box);
118
+ }
119
+ return {
120
+ mask: { width: mask.width, height: mask.height, data, changed: mask.changed - ignored },
121
+ ignored,
122
+ cleared,
123
+ inert,
124
+ };
125
+ }
126
+ /** Whether the original mask had any changed pixel in this box. */
127
+ function coversAnyChange(mask, startX, startY, endX, endY) {
128
+ for (let y = startY; y < endY; y += 1) {
129
+ const rowOffset = y * mask.width;
130
+ for (let x = startX; x < endX; x += 1) {
131
+ if (mask.data[rowOffset + x] !== 0)
132
+ return true;
133
+ }
134
+ }
135
+ return false;
136
+ }
137
+ /** Exact extent and count of the changed pixels inside one component's cells. */
138
+ function tighten(mask, cells, cell, columns) {
139
+ let minX = mask.width;
140
+ let minY = mask.height;
141
+ let maxX = -1;
142
+ let maxY = -1;
143
+ let pixels = 0;
144
+ for (const index of cells) {
145
+ const startX = (index % columns) * cell;
146
+ const startY = ((index / columns) | 0) * cell;
147
+ const endX = Math.min(startX + cell, mask.width);
148
+ const endY = Math.min(startY + cell, mask.height);
149
+ for (let y = startY; y < endY; y += 1) {
150
+ const rowOffset = y * mask.width;
151
+ for (let x = startX; x < endX; x += 1) {
152
+ if (mask.data[rowOffset + x] === 0)
153
+ continue;
154
+ pixels += 1;
155
+ if (x < minX)
156
+ minX = x;
157
+ if (x > maxX)
158
+ maxX = x;
159
+ if (y < minY)
160
+ minY = y;
161
+ if (y > maxY)
162
+ maxY = y;
163
+ }
164
+ }
165
+ }
166
+ const width = maxX - minX + 1;
167
+ const height = maxY - minY + 1;
168
+ return { x: minX, y: minY, width, height, pixels, density: pixels / (width * height) };
169
+ }
170
+ /**
171
+ * Excluded subtrees as device-pixel boxes on the raster's own canvas.
172
+ *
173
+ * The conversion that lets one declaration serve both tiers (spec 0024). A site
174
+ * is recorded in CSS pixels in page space, because that is what a document knows;
175
+ * a mask is device pixels in raster space, because that is what a screenshot is.
176
+ * Getting the transform wrong is not a small error — at the wrong scale every box
177
+ * lands in the top-left quadrant and silences whatever happens to be there — so
178
+ * it is inverted from exactly the expression `attributeRegions` uses in the other
179
+ * direction, in one place, rather than re-derived per caller.
180
+ *
181
+ * Sites with no `rect` are dropped: a profile with no layout engine observed no
182
+ * box, and a box that was never observed must never be inferred. The semantic
183
+ * half of the same rule still applies on that profile, which is the correct
184
+ * asymmetry — that profile decides no pixels either.
185
+ */
186
+ export function excludedBoxes(snapshot, options) {
187
+ const sites = snapshot.ignoreSites ?? [];
188
+ if (sites.length === 0)
189
+ return [];
190
+ const origin = options.origin ?? snapshot.root.rect ?? { x: 0, y: 0 };
191
+ return sites
192
+ .filter((site) => site.rect !== undefined)
193
+ .map((site) => ({
194
+ rule: site.rule,
195
+ x: (site.rect.x - origin.x) * options.scale,
196
+ y: (site.rect.y - origin.y) * options.scale,
197
+ width: site.rect.width * options.scale,
198
+ height: site.rect.height * options.scale,
199
+ }));
200
+ }
201
+ //# sourceMappingURL=mask.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mask.js","sourceRoot":"","sources":["../../src/attribute/mask.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Rect } from '../format/capture.js';\nimport type { IgnoreSite, SemanticSnapshot } from '../format/snapshot.js';\n\n/**\n * A change as a bitmask: where on the canvas, and what was excluded from it.\n *\n * Arithmetic, and nothing else. No DOM, no snapshot, no notion of what a\n * component is — which is why it is the half of attribution that can be tested\n * with a hand-written mask and why it is a file of its own. Everything here\n * answers *where on the canvas*; `region.ts` joins those coordinates to a tree\n * and answers *what is there*, and the two fail differently enough that reading\n * one to understand the other was how they came to share 500 lines.\n *\n * The pixel count is what this exists to defeat. \"5482 pixels changed\" cannot be\n * read, cannot be assigned, and leaves opening the image as the only available\n * response — the expensive act the tool was supposed to replace.\n */\n\n/**\n * Per-pixel changed/unchanged, row-major, one byte per pixel.\n *\n * A mask rather than a diff image: an image is for looking at, and everything\n * downstream of here wants to compute. Producing one is the comparison phase's\n * job and involves PNG decoding; consuming one is pure.\n */\nexport interface ChangeMask {\n readonly width: number;\n readonly height: number;\n /** `1` where the pixel differs. Length is `width * height`. */\n readonly data: Uint8Array;\n /** Count of set bytes, carried so callers need not rescan. */\n readonly changed: number;\n}\n\nexport interface DiffRegion extends Rect {\n /** Changed pixels inside the box. Always ≤ `width * height`. */\n readonly pixels: number;\n /** `pixels / (width * height)`. Low means scattered; high means a solid block. */\n readonly density: number;\n}\n\nexport interface IsolationOptions {\n /**\n * Grid size, in pixels, at which neighbouring changes are considered one place.\n *\n * Not a tuning knob so much as a statement about what a region *is*. At cell 1\n * every antialiased glyph edge is its own region and a paragraph of restyled\n * text produces four hundred of them, which is the same unreadable output as a\n * single number, only longer. At cell 8 a word is one region and a button is\n * one region, which is the granularity a person names when they point at a\n * screen.\n */\n readonly cell?: number;\n\n /**\n * Cap on regions returned, largest first.\n *\n * Truncation is reported in {@link Isolation.truncated} rather than applied\n * silently. A capped list that does not say it was capped reads as complete\n * coverage, and the reader has no way to know the difference.\n */\n readonly limit?: number;\n}\n\nexport interface Isolation {\n readonly regions: readonly DiffRegion[];\n /** Regions found but not returned, because of `limit`. `0` in the normal case. */\n readonly truncated: number;\n /** Changed pixels in the truncated tail. Nothing is lost silently. */\n readonly truncatedPixels: number;\n}\n\nconst DEFAULT_CELL = 8;\nconst DEFAULT_LIMIT = 32;\n\n/**\n * Cluster a change mask into regions.\n *\n * Connected components are computed on a coarse grid rather than on the pixels\n * themselves. That is a performance decision and a semantic one at once: the\n * coarse pass is one linear sweep instead of a merge over thousands of glyph-edge\n * fragments, and it produces the grouping a reader would have produced by eye.\n * Bounding boxes are then tightened back onto the actual changed pixels, so a\n * region's coordinates are exact even though its *membership* was decided coarsely.\n */\nexport function isolateRegions(mask: ChangeMask, options: IsolationOptions = {}): Isolation {\n const cell = Math.max(1, Math.floor(options.cell ?? DEFAULT_CELL));\n const limit = options.limit ?? DEFAULT_LIMIT;\n\n const columns = Math.ceil(mask.width / cell);\n const rows = Math.ceil(mask.height / cell);\n const occupied = new Uint8Array(columns * rows);\n\n for (let y = 0; y < mask.height; y += 1) {\n const rowOffset = y * mask.width;\n const gridRow = ((y / cell) | 0) * columns;\n for (let x = 0; x < mask.width; x += 1) {\n if (mask.data[rowOffset + x] !== 0) occupied[gridRow + ((x / cell) | 0)] = 1;\n }\n }\n\n const seen = new Uint8Array(occupied.length);\n const found: DiffRegion[] = [];\n\n for (let index = 0; index < occupied.length; index += 1) {\n if (occupied[index] === 0 || seen[index] !== 0) continue;\n\n // Explicit stack. A full-page change is a single component covering every\n // cell, and recursion at that depth is a stack overflow rather than a slow path.\n const stack = [index];\n seen[index] = 1;\n const cells: number[] = [];\n\n while (stack.length > 0) {\n const current = stack.pop()!;\n cells.push(current);\n\n const cx = current % columns;\n const cy = (current / columns) | 0;\n\n for (let dy = -1; dy <= 1; dy += 1) {\n for (let dx = -1; dx <= 1; dx += 1) {\n const nx = cx + dx;\n const ny = cy + dy;\n if (nx < 0 || ny < 0 || nx >= columns || ny >= rows) continue;\n\n const neighbour = ny * columns + nx;\n if (occupied[neighbour] === 0 || seen[neighbour] !== 0) continue;\n\n seen[neighbour] = 1;\n stack.push(neighbour);\n }\n }\n }\n\n found.push(tighten(mask, cells, cell, columns));\n }\n\n found.sort((a, b) => b.pixels - a.pixels || a.y - b.y || a.x - b.x);\n\n const kept = found.slice(0, limit);\n const dropped = found.slice(limit);\n\n return {\n regions: kept,\n truncated: dropped.length,\n truncatedPixels: dropped.reduce((sum, region) => sum + region.pixels, 0),\n };\n}\n\nexport interface Subtraction {\n /** The mask with the excluded boxes cleared. Never the same object. */\n readonly mask: ChangeMask;\n\n /** Changed pixels that fell inside an excluded box. */\n readonly ignored: number;\n\n /**\n * Pixels each input box cleared, in the order the boxes were given.\n *\n * Positional rather than keyed, because this file knows nothing about ignore\n * rules and should not start: a box is geometry. The caller that supplied the\n * boxes knows which rule each came from and can attribute the counts, which is\n * what a per-rule register is assembled from.\n *\n * Two boxes overlapping share their pixels on a first-come basis, so these sum\n * to {@link Subtraction.ignored} exactly and never double-count.\n */\n readonly cleared: readonly number[];\n\n /**\n * Boxes that covered no changed pixel at all.\n *\n * The raster half of the dead-ignore register. A mask drawn around a carousel\n * that has since been removed covers nothing, and an operator who cannot see\n * that keeps a hole in the suite because deleting it feels risky.\n *\n * Judged against the *original* mask, not against what earlier boxes left. Two\n * rules covering one element is ordinary — a selector and a marker attribute\n * reaching the same node — and clearing is first-come, so the second box would\n * otherwise clear zero and be reported as covering nothing at all. That reads\n * as \"delete this rule\" about a rule that is working.\n */\n readonly inert: readonly Rect[];\n}\n\n/**\n * Clear excluded boxes out of a change mask, and say what that cost.\n *\n * The raster half of an ignore (spec 0024). The semantic half drops deltas under\n * a subtree; this drops the pixels the same subtree occupied, so the two tiers\n * cannot disagree about what the subject is — a region excluded semantically and\n * still compared on pixels arrives as `unexplained`, the highest severity in the\n * system, for something the operator already said was not the subject.\n *\n * Subtracting *before* isolation rather than filtering regions afterwards, which\n * looks equivalent and is not: a region that straddles the boundary would\n * otherwise be dropped whole or kept whole, and both answers are wrong. Clearing\n * pixels lets the part outside the box cluster on its own and be reported.\n *\n * The count is returned rather than folded away. `changed` on the result is what\n * the run compares against zero; `ignored` is what it owes the reader, because a\n * comparison that discarded four thousand pixels and reported \"no difference\" is\n * the failure this whole mechanism is written around.\n */\nexport function subtractRegions(mask: ChangeMask, boxes: readonly Rect[]): Subtraction {\n if (boxes.length === 0) return { mask, ignored: 0, cleared: [], inert: [] };\n\n const data = Uint8Array.from(mask.data);\n const cleared: number[] = [];\n const inert: Rect[] = [];\n let ignored = 0;\n\n for (const box of boxes) {\n // Rounded outward. A box is a CSS rectangle and a mask is a pixel grid; the\n // half-pixel at the edge belongs to the thing that was excluded, because the\n // alternative is a one-pixel rim of permanent residue around every ignore.\n const startX = Math.max(0, Math.floor(box.x));\n const startY = Math.max(0, Math.floor(box.y));\n const endX = Math.min(mask.width, Math.ceil(box.x + box.width));\n const endY = Math.min(mask.height, Math.ceil(box.y + box.height));\n\n let inside = 0;\n for (let y = startY; y < endY; y += 1) {\n const rowOffset = y * mask.width;\n for (let x = startX; x < endX; x += 1) {\n const index = rowOffset + x;\n if (data[index] === 0) continue;\n data[index] = 0;\n inside += 1;\n }\n }\n\n ignored += inside;\n cleared.push(inside);\n // Against the untouched mask. `inside` counts what *this* box cleared, which\n // is zero for the second of two overlapping boxes however much it covers.\n if (!coversAnyChange(mask, startX, startY, endX, endY)) inert.push(box);\n }\n\n return {\n mask: { width: mask.width, height: mask.height, data, changed: mask.changed - ignored },\n ignored,\n cleared,\n inert,\n };\n}\n\n/** Whether the original mask had any changed pixel in this box. */\nfunction coversAnyChange(\n mask: ChangeMask,\n startX: number,\n startY: number,\n endX: number,\n endY: number,\n): boolean {\n for (let y = startY; y < endY; y += 1) {\n const rowOffset = y * mask.width;\n for (let x = startX; x < endX; x += 1) {\n if (mask.data[rowOffset + x] !== 0) return true;\n }\n }\n return false;\n}\n\n/** Exact extent and count of the changed pixels inside one component's cells. */\nfunction tighten(mask: ChangeMask, cells: readonly number[], cell: number, columns: number): DiffRegion {\n let minX = mask.width;\n let minY = mask.height;\n let maxX = -1;\n let maxY = -1;\n let pixels = 0;\n\n for (const index of cells) {\n const startX = (index % columns) * cell;\n const startY = ((index / columns) | 0) * cell;\n const endX = Math.min(startX + cell, mask.width);\n const endY = Math.min(startY + cell, mask.height);\n\n for (let y = startY; y < endY; y += 1) {\n const rowOffset = y * mask.width;\n for (let x = startX; x < endX; x += 1) {\n if (mask.data[rowOffset + x] === 0) continue;\n pixels += 1;\n if (x < minX) minX = x;\n if (x > maxX) maxX = x;\n if (y < minY) minY = y;\n if (y > maxY) maxY = y;\n }\n }\n }\n\n const width = maxX - minX + 1;\n const height = maxY - minY + 1;\n\n return { x: minX, y: minY, width, height, pixels, density: pixels / (width * height) };\n}\n\n\n/**\n * Excluded subtrees as device-pixel boxes on the raster's own canvas.\n *\n * The conversion that lets one declaration serve both tiers (spec 0024). A site\n * is recorded in CSS pixels in page space, because that is what a document knows;\n * a mask is device pixels in raster space, because that is what a screenshot is.\n * Getting the transform wrong is not a small error — at the wrong scale every box\n * lands in the top-left quadrant and silences whatever happens to be there — so\n * it is inverted from exactly the expression `attributeRegions` uses in the other\n * direction, in one place, rather than re-derived per caller.\n *\n * Sites with no `rect` are dropped: a profile with no layout engine observed no\n * box, and a box that was never observed must never be inferred. The semantic\n * half of the same rule still applies on that profile, which is the correct\n * asymmetry — that profile decides no pixels either.\n */\nexport function excludedBoxes(\n snapshot: SemanticSnapshot,\n options: { readonly scale: number; readonly origin?: { readonly x: number; readonly y: number } },\n): readonly ExcludedBox[] {\n const sites: readonly IgnoreSite[] = snapshot.ignoreSites ?? [];\n if (sites.length === 0) return [];\n\n const origin = options.origin ?? snapshot.root.rect ?? { x: 0, y: 0 };\n\n return sites\n .filter((site): site is IgnoreSite & { rect: Rect } => site.rect !== undefined)\n .map((site) => ({\n rule: site.rule,\n x: (site.rect.x - origin.x) * options.scale,\n y: (site.rect.y - origin.y) * options.scale,\n width: site.rect.width * options.scale,\n height: site.rect.height * options.scale,\n }));\n}\n\n/** A box, carrying the rule that excluded it so absorption can be attributed. */\nexport interface ExcludedBox extends Rect {\n readonly rule: string;\n}\n"]}
@@ -0,0 +1,211 @@
1
+ import type { Band } from '../compare/band.js';
2
+ import type { Composition, Site } from './composition.js';
3
+ /**
4
+ * Why a component moved — and what it means when nothing here can say.
5
+ *
6
+ * A run already knows *that* a component's own content differs from its
7
+ * baseline: that is `causesBetween`, and it is where every report starts. What it
8
+ * has never known is whether anybody edited the thing. Those are different
9
+ * questions, and the gap between them is where flakes live:
10
+ *
11
+ * > Detect a pixel change. Find the HTML area behind it. Find no related change.
12
+ * > That is a flake — or the beginning of one.
13
+ *
14
+ * The last step is the one that needs evidence, and the evidence has to be
15
+ * assembled rather than assumed. Four kinds are available to a run that has
16
+ * already done its work, in descending order of how much they explain:
17
+ *
18
+ * 1. **An edit.** A file declaring this component is in the change set. Nothing
19
+ * further is interesting; somebody changed it and the run noticed.
20
+ * 2. **A token.** A custom property this component's own nodes resolve through
21
+ * took a new value in this run. The component's code is untouched and its
22
+ * output legitimately moved.
23
+ * 3. **An ancestor.** A component that reaches it was edited, and it is reached
24
+ * by climbing — the caller that wrote the element, or, on a build where React
25
+ * kept no owner, the enclosure graph of the subject it moved in until an
26
+ * edited name appears. Its props are not recoverable in detail, but *this
27
+ * moved on what it was given* is the sentence, and it names a real file.
28
+ * 4. **A contradiction.** The same component, with the same props, rendered
29
+ * somewhere else in this same run and rendered *differently*. That is not an
30
+ * explanation — it is proof the explanation is not in the component's own
31
+ * code, because one commit produced two answers from one input.
32
+ *
33
+ * And then **unexplained**, which is the finding. A component whose content
34
+ * moved, whose file nobody touched, whose tokens held, whose ancestors held, and
35
+ * which rendered identically everywhere else in the suite at this commit. That
36
+ * last clause is the part no previous version of this could say, and it is what
37
+ * the cross-subject join bought: the *stable states to refer to* are the other
38
+ * sites of the same rendering, and the run already has them.
39
+ *
40
+ * ## What this refuses to conclude
41
+ *
42
+ * Unexplained is not a flake. This system's position on what establishes a flake
43
+ * has not moved: a subject is unstable when it fails to read the same way twice,
44
+ * and one reading can never prove instability
45
+ * ([`flakiness.md`](../../../../docs/flakiness.md)). What an unexplained movement
46
+ * is, exactly, is **the shortlist of subjects worth reading twice** — which is
47
+ * the scarce resource a sweep spends, and until now it was spent on `changed`
48
+ * subjects in whatever order the plan produced them.
49
+ */
50
+ export type Cause = 'edited' | 'token' | 'upstream' | 'contradicted' | 'unexplained';
51
+ /** One component the run found to have moved in one subject. */
52
+ export interface Moved {
53
+ readonly subject: string;
54
+ readonly component: string;
55
+ /**
56
+ * Which bands moved, when the run could tell.
57
+ *
58
+ * Empty when the comparison produced names only — a baseline written before
59
+ * the bands were split carries no per-band digests, and an empty list here
60
+ * means *not known*, never *no band*.
61
+ */
62
+ readonly bands: readonly Band[];
63
+ }
64
+ /** What a run can put beside a movement to explain it. */
65
+ export interface Evidence {
66
+ /**
67
+ * Files in the change set, from whichever flag read a diff. Absent when none did.
68
+ *
69
+ * Absent and empty are different answers and are kept different: a run with no
70
+ * `--since` has not established that nobody edited anything, and reading its
71
+ * silence as "no edits" would attribute every movement in the suite to nothing
72
+ * and call the result a flake list.
73
+ */
74
+ readonly changed?: readonly string[];
75
+ /** Component name → the files that declare it, from the source index. */
76
+ readonly declaredIn?: ReadonlyMap<string, readonly string[]>;
77
+ /** Tokens whose resolved value moved in this run. Absent when nothing asked. */
78
+ readonly tokens?: readonly string[];
79
+ /** Subjects that failed to read the same way twice in this run. */
80
+ readonly unstable?: ReadonlySet<string>;
81
+ /**
82
+ * Every component whose hashes moved, by subject — the whole reading, not the
83
+ * causes.
84
+ *
85
+ * The control group is read from this and not from the movements, because the
86
+ * movements are a *filtered* set: they come from regions the pixels named as
87
+ * causes, so a component whose hashes moved and whose box no region named is
88
+ * absent from them. Filtering the control group by the movements alone hands
89
+ * back exactly those renders as *held still*, which is the one claim that
90
+ * makes a control group worse than no control group.
91
+ *
92
+ * **Absent is not empty**, twice over. An absent map is a run that never
93
+ * compared hashes, and the control group falls back to the movements with a
94
+ * weaker guarantee. A subject *missing from* a present map had no hashes read
95
+ * there — nobody measured it, so it cannot be a control for anything.
96
+ */
97
+ readonly hashesMoved?: ReadonlyMap<string, ReadonlySet<string>>;
98
+ }
99
+ export interface Movement {
100
+ readonly subject: string;
101
+ readonly component: string;
102
+ readonly bands: readonly Band[];
103
+ readonly cause: Cause;
104
+ /** One sentence, naming the evidence rather than the category. */
105
+ readonly because: string;
106
+ readonly file?: string;
107
+ readonly tokens?: readonly string[];
108
+ readonly upstream?: string;
109
+ /**
110
+ * Components between `upstream` and this one, outermost first.
111
+ *
112
+ * Empty when the edited component draws it directly, and present only on the
113
+ * `upstream` rung. A reviewer sent to `ProductCard` for a `CardFooter` that
114
+ * moved has one question — *how does that reach this* — and the answer is two
115
+ * names the run already holds.
116
+ */
117
+ readonly through?: readonly string[];
118
+ /**
119
+ * Other subjects in which this same component moved in this run.
120
+ *
121
+ * The "one cause, N subjects" fold. A reviewer reading eleven changed subjects
122
+ * is often reading one edit, and the count is the difference between a
123
+ * frightening report and an accurate one.
124
+ */
125
+ readonly alsoIn: readonly string[];
126
+ /**
127
+ * Sites of this component, with the same props, whose hashes this run read and
128
+ * found unmoved.
129
+ *
130
+ * The control group, and the reason an unexplained movement is worth
131
+ * reporting rather than shrugging at. Empty means there was no control — the
132
+ * component appears nowhere else with these inputs, or it moved in every one
133
+ * of them — which weakens the finding and is why it is a list rather than a
134
+ * flag.
135
+ */
136
+ readonly held: readonly Site[];
137
+ /**
138
+ * Sites of this component with the same props, other than this one, that the
139
+ * run read — the pool `held` was drawn from.
140
+ *
141
+ * The denominator, and without it `held: []` is two opposite findings under one
142
+ * shape. Zero is *nothing to compare against*; four with an empty `held` is a
143
+ * comparison that was made, in four renders, and came back the same way in all
144
+ * of them — the statement that nothing rendered under those props escaped
145
+ * whatever moved here.
146
+ */
147
+ readonly compared: number;
148
+ }
149
+ export interface Attribution {
150
+ /** Every movement supplied, in the order supplied, each with a cause. */
151
+ readonly movements: readonly Movement[];
152
+ /**
153
+ * Unexplained movements in subjects that also failed to read the same way
154
+ * twice. A movement nothing explains, in a subject already proven unstable.
155
+ */
156
+ readonly flakes: readonly Movement[];
157
+ /**
158
+ * Unexplained movements in subjects nothing has read twice.
159
+ *
160
+ * Not a verdict. The shortlist a sweep should spend its second readings on,
161
+ * ordered by how much control the suite has over each — a component that
162
+ * rendered identically in six other places and moved here is a better use of a
163
+ * second reading than one that appears nowhere else.
164
+ */
165
+ readonly suspects: readonly Movement[];
166
+ }
167
+ /**
168
+ * Attribute every movement, given whatever evidence the run assembled.
169
+ *
170
+ * Degrades honestly. With no change set the `edited` rung is unreachable and
171
+ * every movement falls to a lower one, so a run that did not ask cannot produce
172
+ * a confident `unexplained` — which is checked here rather than trusted to the
173
+ * caller, because the shortlist this feeds is exactly the thing somebody will
174
+ * act on.
175
+ */
176
+ export declare function attributeMovement(moved: readonly Moved[], composition: Composition, evidence?: Evidence): Attribution;
177
+ /** Everything the run assembled, gathered so one movement can be asked about. */
178
+ export interface Bench {
179
+ readonly movedIn: ReadonlyMap<string, readonly string[]>;
180
+ readonly edited: ReadonlySet<string>;
181
+ readonly evidence: Evidence;
182
+ readonly composition: Composition;
183
+ readonly graph: Graph;
184
+ }
185
+ /**
186
+ * Who encloses what and who wrote what, in each subject separately.
187
+ *
188
+ * Keyed on the pair because the constraint is the whole point. `ComponentEntry.within`
189
+ * and `ComponentEntry.createdBy` are both folded over the suite, so `Card` is enclosed by
190
+ * `CartCard` and by `ProductCard` at once, and a walk that reads either hands a
191
+ * reviewer the cart as the reason a product story moved. A site knows which
192
+ * subject it was read in, so the graph can be built the way the question is
193
+ * asked — and it has to be built that way for *both* edges, because the creator
194
+ * is consulted first and an unconstrained answer there is never reached by the
195
+ * constrained walk underneath it.
196
+ */
197
+ type Enclosures = ReadonlyMap<string, readonly string[]>;
198
+ interface Graph {
199
+ /** Who this component sits inside, per subject. */
200
+ readonly within: Enclosures;
201
+ /** Whose JSX wrote this component's element, per subject. */
202
+ readonly wrote: Enclosures;
203
+ }
204
+ /** An edited component above a moved one, and the components in between. */
205
+ export interface Ancestor {
206
+ readonly name: string;
207
+ /** Outermost first, and empty when the edited component draws it directly. */
208
+ readonly through: readonly string[];
209
+ }
210
+ export {};
211
+ //# sourceMappingURL=movement.d.ts.map
@@ -0,0 +1,181 @@
1
+ import { becauseUpstream, unexplainedBecause } from './because.js';
2
+ import { heldSites } from './control.js';
3
+ /**
4
+ * Attribute every movement, given whatever evidence the run assembled.
5
+ *
6
+ * Degrades honestly. With no change set the `edited` rung is unreachable and
7
+ * every movement falls to a lower one, so a run that did not ask cannot produce
8
+ * a confident `unexplained` — which is checked here rather than trusted to the
9
+ * caller, because the shortlist this feeds is exactly the thing somebody will
10
+ * act on.
11
+ */
12
+ export function attributeMovement(moved, composition, evidence = {}) {
13
+ const byComponent = new Map(composition.components.map((entry) => [entry.component, entry]));
14
+ const movedIn = new Map();
15
+ for (const each of moved) {
16
+ const subjects = movedIn.get(each.component) ?? [];
17
+ subjects.push(each.subject);
18
+ movedIn.set(each.component, subjects);
19
+ }
20
+ const edited = new Set([...(evidence.declaredIn ?? new Map())]
21
+ .filter(([, files]) => files.some((file) => (evidence.changed ?? []).includes(file)))
22
+ .map(([component]) => component));
23
+ const graph = graphOf(composition);
24
+ const movements = moved.map((each) => attributeOne(each, byComponent.get(each.component), {
25
+ movedIn,
26
+ edited,
27
+ evidence,
28
+ composition,
29
+ graph,
30
+ }));
31
+ const unexplained = movements.filter((movement) => movement.cause === 'unexplained');
32
+ return {
33
+ movements,
34
+ flakes: unexplained.filter((movement) => evidence.unstable?.has(movement.subject) === true),
35
+ suspects: unexplained
36
+ .filter((movement) => evidence.unstable?.has(movement.subject) !== true)
37
+ .sort((a, b) => b.held.length - a.held.length),
38
+ };
39
+ }
40
+ function attributeOne(moved, entry, bench) {
41
+ const { movedIn, edited, evidence, composition, graph } = bench;
42
+ const alsoIn = (movedIn.get(moved.component) ?? []).filter((subject) => subject !== moved.subject);
43
+ const { held, considered } = heldSites(moved, entry, bench);
44
+ const base = { ...moved, alsoIn, held, compared: considered };
45
+ const file = (evidence.declaredIn?.get(moved.component) ?? []).find((each) => (evidence.changed ?? []).includes(each));
46
+ if (file !== undefined) {
47
+ return { ...base, cause: 'edited', file, because: `\`${file}\` is in the change set` };
48
+ }
49
+ const tokens = movedTokens(moved.component, composition, evidence);
50
+ if (tokens.length > 0) {
51
+ return {
52
+ ...base,
53
+ cause: 'token',
54
+ tokens,
55
+ because: `it resolves through ${tokens.map((token) => `\`${token}\``).join(', ')}, which moved in this run`,
56
+ };
57
+ }
58
+ const ancestor = editedAncestor(moved, edited, graph);
59
+ if (ancestor !== undefined) {
60
+ return {
61
+ ...base,
62
+ cause: 'upstream',
63
+ upstream: ancestor.name,
64
+ ...(ancestor.through.length === 0 ? {} : { through: ancestor.through }),
65
+ because: becauseUpstream(ancestor),
66
+ };
67
+ }
68
+ const contradiction = composition.divergences.find((divergence) => divergence.component === moved.component &&
69
+ divergence.renderings.some((rendering) => rendering.sites.some((site) => site.subject === moved.subject)));
70
+ if (contradiction !== undefined) {
71
+ return {
72
+ ...base,
73
+ cause: 'contradicted',
74
+ because: `at this commit it renders ${contradiction.renderings.length} different ways from the same props ` +
75
+ `(${contradiction.bands.join(', ')}), so the change is not in its own code`,
76
+ };
77
+ }
78
+ return { ...base, cause: 'unexplained', because: unexplainedBecause(base, considered, evidence) };
79
+ }
80
+ const IN = '\u0000';
81
+ function graphOf(composition) {
82
+ const within = new Map();
83
+ const wrote = new Map();
84
+ const add = (graph, key, name) => {
85
+ const names = graph.get(key);
86
+ if (names === undefined)
87
+ graph.set(key, [name]);
88
+ else if (!names.includes(name))
89
+ names.push(name);
90
+ };
91
+ for (const entry of composition.components) {
92
+ for (const group of entry.classes) {
93
+ for (const rendering of group.renderings) {
94
+ for (const site of rendering.sites) {
95
+ const key = `${site.subject}${IN}${entry.component}`;
96
+ if (site.within !== undefined)
97
+ add(within, key, site.within);
98
+ if (site.createdBy !== undefined)
99
+ add(wrote, key, site.createdBy);
100
+ }
101
+ }
102
+ }
103
+ }
104
+ for (const names of within.values())
105
+ names.sort();
106
+ for (const names of wrote.values())
107
+ names.sort();
108
+ return { within, wrote };
109
+ }
110
+ /**
111
+ * The nearest edited component above this one, in the subject it moved in.
112
+ *
113
+ * The creator first, and it is not a tie-break. The component that *wrote the
114
+ * element* is the one whose edit changed this component's inputs; the one it
115
+ * happens to sit inside may be a presentational wrapper that knows nothing about
116
+ * it. On `examples/todomvc` every `Chip` sits within a `Stack` and is created by
117
+ * `TodoFooter`, so a run consulting only enclosure would fail to connect an edit
118
+ * to `TodoFooter` with the chips it moved — and report five unexplained movements
119
+ * instead of one caller. It is absent on a production build, which is why the
120
+ * enclosure walk is not a fallback but the other half.
121
+ *
122
+ * Read off the per-subject graph and not off the entry. The entry's `createdBy`
123
+ * is the suite's whole set, so an edit to whoever writes this component on one
124
+ * page answers for every page it appears on — the same unconstrained answer the
125
+ * walk below refuses, arriving one line earlier and winning.
126
+ *
127
+ * And that walk climbs rather than looking once. The rung has always said
128
+ * *ancestor* and checked a parent, and a React tree is mostly components that
129
+ * draw one wrapper each: `ProductCard` was edited, `ProductCard` draws `Card`,
130
+ * `Card` draws `CardFooter`, and one look up arrives at `Card`, whose file nobody
131
+ * touched. That is the shape of every unexplained movement this ladder used to
132
+ * produce over an edit it was holding the graph for.
133
+ */
134
+ function editedAncestor(moved, edited, graph) {
135
+ const here = `${moved.subject}${IN}${moved.component}`;
136
+ const wrote = (graph.wrote.get(here) ?? []).find((name) => edited.has(name));
137
+ if (wrote !== undefined)
138
+ return { name: wrote, through: [] };
139
+ // Breadth-first, so the answer is the *nearest* edit and not whichever one the
140
+ // recursion reached first. `seen` is written as a rung is built: an enclosure
141
+ // graph folded over a suite can close a loop, and a component reached twice on
142
+ // one rung would otherwise queue twice.
143
+ const seen = new Set([moved.component]);
144
+ const step = (from) => {
145
+ const child = from === null ? moved.component : from.name;
146
+ const rung = [];
147
+ for (const holder of graph.within.get(`${moved.subject}${IN}${child}`) ?? []) {
148
+ if (seen.has(holder))
149
+ continue;
150
+ seen.add(holder);
151
+ rung.push({ name: holder, through: from === null ? [] : [from.name, ...from.through] });
152
+ }
153
+ return rung;
154
+ };
155
+ let rung = step(null);
156
+ while (rung.length > 0) {
157
+ const arrived = rung.find((each) => edited.has(each.name));
158
+ if (arrived !== undefined)
159
+ return arrived;
160
+ rung = rung.flatMap(step);
161
+ }
162
+ return undefined;
163
+ }
164
+ /**
165
+ * Tokens this component resolves through that moved in this run.
166
+ *
167
+ * Read off the instances rather than off the subject, which is the distinction
168
+ * that makes the answer worth anything: every subject on a themed page resolves
169
+ * through every token in the theme, so a subject-level intersection names them
170
+ * all and explains nothing.
171
+ */
172
+ function movedTokens(component, composition, evidence) {
173
+ if (evidence.tokens === undefined || evidence.tokens.length === 0)
174
+ return [];
175
+ const entry = composition.components.find((each) => each.component === component);
176
+ if (entry === undefined)
177
+ return [];
178
+ const moved = new Set(evidence.tokens);
179
+ return [...entry.tokens].filter((token) => moved.has(token)).sort();
180
+ }
181
+ //# sourceMappingURL=movement.js.map