@variance-authority/tribunal 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 (271) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/LICENSE +21 -0
  3. package/README.md +506 -0
  4. package/dist/__fixtures__/review.d.ts +47 -0
  5. package/dist/__fixtures__/review.js +145 -0
  6. package/dist/__fixtures__/review.js.map +1 -0
  7. package/dist/bindings.d.ts +134 -0
  8. package/dist/bindings.js +88 -0
  9. package/dist/bindings.js.map +1 -0
  10. package/dist/changelog.d.ts +132 -0
  11. package/dist/changelog.js +129 -0
  12. package/dist/changelog.js.map +1 -0
  13. package/dist/history-queries.d.ts +82 -0
  14. package/dist/history-queries.js +200 -0
  15. package/dist/history-queries.js.map +1 -0
  16. package/dist/history-rows.d.ts +42 -0
  17. package/dist/history-rows.js +148 -0
  18. package/dist/history-rows.js.map +1 -0
  19. package/dist/history.d.ts +40 -0
  20. package/dist/history.js +186 -0
  21. package/dist/history.js.map +1 -0
  22. package/dist/index.d.ts +13 -0
  23. package/dist/index.js +12 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/migration-steps.d.ts +32 -0
  26. package/dist/migration-steps.js +303 -0
  27. package/dist/migration-steps.js.map +1 -0
  28. package/dist/migrations.d.ts +58 -0
  29. package/dist/migrations.js +283 -0
  30. package/dist/migrations.js.map +1 -0
  31. package/dist/next.d.ts +94 -0
  32. package/dist/next.js +44 -0
  33. package/dist/next.js.map +1 -0
  34. package/dist/node/bin.d.ts +120 -0
  35. package/dist/node/bin.js +337 -0
  36. package/dist/node/bin.js.map +1 -0
  37. package/dist/node/bucket.d.ts +58 -0
  38. package/dist/node/bucket.js +142 -0
  39. package/dist/node/bucket.js.map +1 -0
  40. package/dist/node/database.d.ts +33 -0
  41. package/dist/node/database.js +157 -0
  42. package/dist/node/database.js.map +1 -0
  43. package/dist/node/index.d.ts +43 -0
  44. package/dist/node/index.js +43 -0
  45. package/dist/node/index.js.map +1 -0
  46. package/dist/node/serve.d.ts +81 -0
  47. package/dist/node/serve.js +193 -0
  48. package/dist/node/serve.js.map +1 -0
  49. package/dist/node/ui-assets.d.ts +47 -0
  50. package/dist/node/ui-assets.js +60 -0
  51. package/dist/node/ui-assets.js.map +1 -0
  52. package/dist/review-ingest.d.ts +24 -0
  53. package/dist/review-ingest.js +184 -0
  54. package/dist/review-ingest.js.map +1 -0
  55. package/dist/review-read.d.ts +87 -0
  56. package/dist/review-read.js +361 -0
  57. package/dist/review-read.js.map +1 -0
  58. package/dist/review-rows.d.ts +30 -0
  59. package/dist/review-rows.js +67 -0
  60. package/dist/review-rows.js.map +1 -0
  61. package/dist/review-types.d.ts +414 -0
  62. package/dist/review-types.js +2 -0
  63. package/dist/review-types.js.map +1 -0
  64. package/dist/review-write.d.ts +35 -0
  65. package/dist/review-write.js +86 -0
  66. package/dist/review-write.js.map +1 -0
  67. package/dist/review.d.ts +53 -0
  68. package/dist/review.fixtures.d.ts +47 -0
  69. package/dist/review.fixtures.js +145 -0
  70. package/dist/review.fixtures.js.map +1 -0
  71. package/dist/review.js +245 -0
  72. package/dist/review.js.map +1 -0
  73. package/dist/schema.d.ts +36 -0
  74. package/dist/schema.js +39 -0
  75. package/dist/schema.js.map +1 -0
  76. package/dist/store.d.ts +58 -0
  77. package/dist/store.js +278 -0
  78. package/dist/store.js.map +1 -0
  79. package/dist/testing.d.ts +38 -0
  80. package/dist/testing.js +92 -0
  81. package/dist/testing.js.map +1 -0
  82. package/dist/ui/__probe.d.ts +3 -0
  83. package/dist/ui/__probe.js +5 -0
  84. package/dist/ui/__probe.js.map +1 -0
  85. package/dist/ui/attribution.d.ts +46 -0
  86. package/dist/ui/attribution.js +79 -0
  87. package/dist/ui/attribution.js.map +1 -0
  88. package/dist/ui/because.d.ts +51 -0
  89. package/dist/ui/because.js +52 -0
  90. package/dist/ui/because.js.map +1 -0
  91. package/dist/ui/browser-entry.d.ts +2 -0
  92. package/dist/ui/browser-entry.js +34 -0
  93. package/dist/ui/browser-entry.js.map +1 -0
  94. package/dist/ui/builds.d.ts +43 -0
  95. package/dist/ui/builds.js +79 -0
  96. package/dist/ui/builds.js.map +1 -0
  97. package/dist/ui/carried.d.ts +73 -0
  98. package/dist/ui/carried.js +63 -0
  99. package/dist/ui/carried.js.map +1 -0
  100. package/dist/ui/cause.d.ts +73 -0
  101. package/dist/ui/cause.js +133 -0
  102. package/dist/ui/cause.js.map +1 -0
  103. package/dist/ui/change-story.d.ts +153 -0
  104. package/dist/ui/change-story.js +253 -0
  105. package/dist/ui/change-story.js.map +1 -0
  106. package/dist/ui/change.d.ts +51 -0
  107. package/dist/ui/change.js +125 -0
  108. package/dist/ui/change.js.map +1 -0
  109. package/dist/ui/client.d.ts +74 -0
  110. package/dist/ui/client.js +88 -0
  111. package/dist/ui/client.js.map +1 -0
  112. package/dist/ui/consumers.d.ts +80 -0
  113. package/dist/ui/consumers.js +125 -0
  114. package/dist/ui/consumers.js.map +1 -0
  115. package/dist/ui/control.d.ts +63 -0
  116. package/dist/ui/control.js +39 -0
  117. package/dist/ui/control.js.map +1 -0
  118. package/dist/ui/crossing.d.ts +55 -0
  119. package/dist/ui/crossing.js +74 -0
  120. package/dist/ui/crossing.js.map +1 -0
  121. package/dist/ui/declarations.d.ts +36 -0
  122. package/dist/ui/declarations.js +73 -0
  123. package/dist/ui/declarations.js.map +1 -0
  124. package/dist/ui/distance.d.ts +154 -0
  125. package/dist/ui/distance.js +180 -0
  126. package/dist/ui/distance.js.map +1 -0
  127. package/dist/ui/divergence.d.ts +40 -0
  128. package/dist/ui/divergence.js +176 -0
  129. package/dist/ui/divergence.js.map +1 -0
  130. package/dist/ui/docket.d.ts +39 -0
  131. package/dist/ui/docket.js +172 -0
  132. package/dist/ui/docket.js.map +1 -0
  133. package/dist/ui/findings.d.ts +42 -0
  134. package/dist/ui/findings.js +110 -0
  135. package/dist/ui/findings.js.map +1 -0
  136. package/dist/ui/foreseen.d.ts +55 -0
  137. package/dist/ui/foreseen.js +91 -0
  138. package/dist/ui/foreseen.js.map +1 -0
  139. package/dist/ui/glance.d.ts +101 -0
  140. package/dist/ui/glance.js +101 -0
  141. package/dist/ui/glance.js.map +1 -0
  142. package/dist/ui/grouping.d.ts +158 -0
  143. package/dist/ui/grouping.js +187 -0
  144. package/dist/ui/grouping.js.map +1 -0
  145. package/dist/ui/handed.d.ts +60 -0
  146. package/dist/ui/handed.js +122 -0
  147. package/dist/ui/handed.js.map +1 -0
  148. package/dist/ui/history.d.ts +88 -0
  149. package/dist/ui/history.js +170 -0
  150. package/dist/ui/history.js.map +1 -0
  151. package/dist/ui/holding.d.ts +84 -0
  152. package/dist/ui/holding.js +90 -0
  153. package/dist/ui/holding.js.map +1 -0
  154. package/dist/ui/impact.d.ts +31 -0
  155. package/dist/ui/impact.js +132 -0
  156. package/dist/ui/impact.js.map +1 -0
  157. package/dist/ui/index.d.ts +21 -0
  158. package/dist/ui/index.js +21 -0
  159. package/dist/ui/index.js.map +1 -0
  160. package/dist/ui/lead.d.ts +35 -0
  161. package/dist/ui/lead.js +34 -0
  162. package/dist/ui/lead.js.map +1 -0
  163. package/dist/ui/look.d.ts +109 -0
  164. package/dist/ui/look.js +248 -0
  165. package/dist/ui/look.js.map +1 -0
  166. package/dist/ui/mark.d.ts +11 -0
  167. package/dist/ui/mark.js +13 -0
  168. package/dist/ui/mark.js.map +1 -0
  169. package/dist/ui/moved.d.ts +121 -0
  170. package/dist/ui/moved.js +143 -0
  171. package/dist/ui/moved.js.map +1 -0
  172. package/dist/ui/navigation.d.ts +26 -0
  173. package/dist/ui/navigation.js +53 -0
  174. package/dist/ui/navigation.js.map +1 -0
  175. package/dist/ui/order.d.ts +113 -0
  176. package/dist/ui/order.js +226 -0
  177. package/dist/ui/order.js.map +1 -0
  178. package/dist/ui/origins.d.ts +42 -0
  179. package/dist/ui/origins.js +149 -0
  180. package/dist/ui/origins.js.map +1 -0
  181. package/dist/ui/outcome.d.ts +93 -0
  182. package/dist/ui/outcome.js +162 -0
  183. package/dist/ui/outcome.js.map +1 -0
  184. package/dist/ui/parted.d.ts +68 -0
  185. package/dist/ui/parted.js +101 -0
  186. package/dist/ui/parted.js.map +1 -0
  187. package/dist/ui/rail.d.ts +31 -0
  188. package/dist/ui/rail.js +21 -0
  189. package/dist/ui/rail.js.map +1 -0
  190. package/dist/ui/reach.d.ts +66 -0
  191. package/dist/ui/reach.js +185 -0
  192. package/dist/ui/reach.js.map +1 -0
  193. package/dist/ui/regions.d.ts +82 -0
  194. package/dist/ui/regions.js +67 -0
  195. package/dist/ui/regions.js.map +1 -0
  196. package/dist/ui/review.bundle.js +69 -0
  197. package/dist/ui/review.d.ts +97 -0
  198. package/dist/ui/review.js +39 -0
  199. package/dist/ui/review.js.map +1 -0
  200. package/dist/ui/root.d.ts +72 -0
  201. package/dist/ui/root.js +102 -0
  202. package/dist/ui/root.js.map +1 -0
  203. package/dist/ui/route.d.ts +95 -0
  204. package/dist/ui/route.js +114 -0
  205. package/dist/ui/route.js.map +1 -0
  206. package/dist/ui/run.d.ts +26 -0
  207. package/dist/ui/run.js +12 -0
  208. package/dist/ui/run.js.map +1 -0
  209. package/dist/ui/sense.d.ts +187 -0
  210. package/dist/ui/sense.js +222 -0
  211. package/dist/ui/sense.js.map +1 -0
  212. package/dist/ui/settled.d.ts +43 -0
  213. package/dist/ui/settled.js +59 -0
  214. package/dist/ui/settled.js.map +1 -0
  215. package/dist/ui/shell.d.ts +76 -0
  216. package/dist/ui/shell.js +65 -0
  217. package/dist/ui/shell.js.map +1 -0
  218. package/dist/ui/shift.d.ts +52 -0
  219. package/dist/ui/shift.js +98 -0
  220. package/dist/ui/shift.js.map +1 -0
  221. package/dist/ui/styles-docket.d.ts +18 -0
  222. package/dist/ui/styles-docket.js +281 -0
  223. package/dist/ui/styles-docket.js.map +1 -0
  224. package/dist/ui/styles-stage.d.ts +18 -0
  225. package/dist/ui/styles-stage.js +92 -0
  226. package/dist/ui/styles-stage.js.map +1 -0
  227. package/dist/ui/styles.d.ts +12 -0
  228. package/dist/ui/styles.js +482 -0
  229. package/dist/ui/styles.js.map +1 -0
  230. package/dist/ui/subject.d.ts +36 -0
  231. package/dist/ui/subject.js +138 -0
  232. package/dist/ui/subject.js.map +1 -0
  233. package/dist/ui/text.d.ts +129 -0
  234. package/dist/ui/text.js +164 -0
  235. package/dist/ui/text.js.map +1 -0
  236. package/dist/ui/variations.d.ts +37 -0
  237. package/dist/ui/variations.js +60 -0
  238. package/dist/ui/variations.js.map +1 -0
  239. package/dist/ui/viewer.d.ts +67 -0
  240. package/dist/ui/viewer.js +246 -0
  241. package/dist/ui/viewer.js.map +1 -0
  242. package/dist/worker-auth.d.ts +48 -0
  243. package/dist/worker-auth.js +74 -0
  244. package/dist/worker-auth.js.map +1 -0
  245. package/dist/worker-entry.d.ts +58 -0
  246. package/dist/worker-entry.js +36 -0
  247. package/dist/worker-entry.js.map +1 -0
  248. package/dist/worker-http.d.ts +39 -0
  249. package/dist/worker-http.js +111 -0
  250. package/dist/worker-http.js.map +1 -0
  251. package/dist/worker-input.d.ts +81 -0
  252. package/dist/worker-input.js +296 -0
  253. package/dist/worker-input.js.map +1 -0
  254. package/dist/worker.d.ts +91 -0
  255. package/dist/worker.js +287 -0
  256. package/dist/worker.js.map +1 -0
  257. package/mark.svg +30 -0
  258. package/migrations/0001_initial.sql +193 -0
  259. package/migrations/0002_changelog.sql +30 -0
  260. package/migrations/0003_accessibility.sql +8 -0
  261. package/migrations/0004_variations.sql +19 -0
  262. package/migrations/0005_reach.sql +26 -0
  263. package/migrations/0006_baseline-size.sql +7 -0
  264. package/migrations/0007_declarations.sql +7 -0
  265. package/migrations/0008_green-because.sql +7 -0
  266. package/migrations/0009_moved-bands.sql +6 -0
  267. package/migrations/0010_composition.sql +15 -0
  268. package/migrations/0011_movements.sql +21 -0
  269. package/migrations/0012_control-size.sql +6 -0
  270. package/package.json +110 -0
  271. package/wrangler.jsonc +45 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * How far a component is from the edit, in the graph the diff already walked.
3
+ *
4
+ * A page that lists what moved beside a change has answered *what else* and left
5
+ * the question a reviewer asks next. `Card` moved in its layout beside `Button`:
6
+ * is that the card the button is nailed into, or a card on the other side of the
7
+ * app that happens to share a token? The first is arithmetic. The second is the
8
+ * finding — an edit whose consequence arrives somewhere with no import path to
9
+ * it has either found a global, or found a bug.
10
+ *
11
+ * Nothing new is measured for this. `ReachedComponent.trail` is the shortest
12
+ * chain from a file the commit changed to the component, every step a path but
13
+ * the last, so the file a component is declared in is the step before its name
14
+ * and the hops between two components are an index arithmetic away. Where the
15
+ * traversal never arrived, the docket's own causes still carry a file, and a
16
+ * name with a file beside it is placed even when it is not reached.
17
+ *
18
+ * ## What it will not say
19
+ *
20
+ * Nothing about the DOM. Two components can be siblings in a render and unrelated
21
+ * in the module graph, and the reverse — a layout that imports a button it never
22
+ * renders. This answers *how the edit could have got there*, which is the
23
+ * question a reviewer holding a diff is in a position to act on; where the boxes
24
+ * sit relative to each other is the picture's job.
25
+ *
26
+ * `unknown` is a state and not a zero. A run with no diff has no graph, and a
27
+ * surface that drew every distance as *unrelated* would turn a missing input into
28
+ * a page full of alarms.
29
+ */
30
+ import type { BuildDetail } from '../review-types.js';
31
+ export type Distance =
32
+ /** Declared in the same file the change is. */
33
+ {
34
+ readonly kind: 'same-file';
35
+ }
36
+ /** Reached through the change's file: it imports the change, `hops` files out. */
37
+ | {
38
+ readonly kind: 'importer';
39
+ readonly hops: number;
40
+ }
41
+ /** The change is reached through this one's file, `hops` files out. */
42
+ | {
43
+ readonly kind: 'imported';
44
+ readonly hops: number;
45
+ }
46
+ /** The commit reaches it, by a chain that does not pass the change at all. */
47
+ | {
48
+ readonly kind: 'apart';
49
+ }
50
+ /**
51
+ * Off the traversal, but the build knows where it is declared.
52
+ *
53
+ * The interesting half of what used to be one negative. A component the walk
54
+ * never arrived at is not thereby unplaced: the report's own causes carry the
55
+ * file most of them are declared in, and *`ui/card.tsx`, which this commit did
56
+ * not change* is a location a reviewer can act on where *not reached* is a
57
+ * shrug.
58
+ */
59
+ | {
60
+ readonly kind: 'declared';
61
+ readonly file: string;
62
+ readonly edited: boolean;
63
+ }
64
+ /** Nothing in the commit reaches it and no record says where it lives. */
65
+ | {
66
+ readonly kind: 'unreached';
67
+ }
68
+ /** No diff was read, so there is no graph to measure in. */
69
+ | {
70
+ readonly kind: 'unknown';
71
+ };
72
+ /** Distance from one fixed component to any other, over one build's reach. */
73
+ export type Ruler = (component: string) => Distance;
74
+ export declare function distanceFrom(build: BuildDetail, component: string): Ruler;
75
+ /**
76
+ * Whether the records put this component anywhere at all relative to the change.
77
+ *
78
+ * The distinction a row has to draw before it prints anything. A page that gave
79
+ * every unplaced component the same chip printed *the diff does not name it* nine
80
+ * times down one list — a column constant over every row, costing nine lines to
81
+ * say one thing, and saying it as though it were a property of each component
82
+ * rather than of the graph. Unplaced rows carry no chip; the list says once, at
83
+ * its foot, that it could not place them.
84
+ */
85
+ export declare function placed(distance: Distance): boolean;
86
+ /**
87
+ * The distance in words, or nothing when there is nothing to say.
88
+ *
89
+ * `unknown` returns nothing rather than a sentence about the run: a reviewer
90
+ * reading a list of components does not need each row to repeat that this build
91
+ * carried no diff, and the page says it once elsewhere.
92
+ */
93
+ export declare function howFar(distance: Distance): string | undefined;
94
+ /** One rung of the blast radius: components this many imports from an edited file. */
95
+ export interface Depth {
96
+ /** Imports between a file the commit changed and this component's file. */
97
+ readonly depth: number;
98
+ /** Components the commit reaches at this depth. */
99
+ readonly reached: number;
100
+ /** How many of them this build recorded as moving. */
101
+ readonly moved: number;
102
+ }
103
+ /**
104
+ * A component that moved and that no file in this commit declares.
105
+ *
106
+ * The subjects come with it because they are what makes the *why* answerable.
107
+ * The census that knows which components enclose this one knows it over the
108
+ * whole suite, so an unconstrained walk out of `CardFooter` arrives at the cart
109
+ * as readily as at the product page. Held to the subjects it actually moved in,
110
+ * it arrives at the one the commit changed. [`holding.ts`](./holding.js) does
111
+ * that walk.
112
+ */
113
+ export interface Unplaced {
114
+ readonly component: string;
115
+ /** Subjects this build recorded it moving in, in report order. */
116
+ readonly subjects: readonly string[];
117
+ }
118
+ export interface Spread {
119
+ readonly rungs: readonly Depth[];
120
+ /**
121
+ * Components that moved and that no file in this commit declares.
122
+ *
123
+ * The histogram cannot hold these and must not be read as if it had: every
124
+ * rung is *distance from an edited file*, and a component the traversal never
125
+ * arrived at has no distance. Left out silently, a build whose loudest movement
126
+ * is entirely off the graph reads as a build that stayed inside its own diff —
127
+ * which is the reassurance this surface exists to withhold.
128
+ */
129
+ readonly undeclared: readonly Unplaced[];
130
+ /**
131
+ * Components reached only through a file the scan could not read.
132
+ *
133
+ * Kept out of the rungs and counted, because their depth is measured from the
134
+ * blind spot rather than from the edit — a histogram that folded them in would
135
+ * put the scanner's own gaps on the same axis as the commit's consequences.
136
+ */
137
+ readonly throughUnread: number;
138
+ }
139
+ /**
140
+ * The impact-depth histogram: how far out the commit's consequences landed.
141
+ *
142
+ * Depth 0 is a component declared in a file the diff names, and everything above
143
+ * it is collateral in the only sense that can be measured without guessing — the
144
+ * edit is N imports away from the thing that moved. A commit whose movement is
145
+ * all at depth 0 did what it said. One with eleven components moving at depth 3
146
+ * changed something shared, and that is worth knowing before opening the first
147
+ * change rather than after opening the eleventh.
148
+ *
149
+ * `moved` is read from the hashes first and the regions second, because those are
150
+ * the two records that make the claim, and a component that only appears as a
151
+ * region lead is still a component this build says moved.
152
+ */
153
+ export declare function spreadOf(build: BuildDetail): Spread | null;
154
+ //# sourceMappingURL=distance.d.ts.map
@@ -0,0 +1,180 @@
1
+ /**
2
+ * How far a component is from the edit, in the graph the diff already walked.
3
+ *
4
+ * A page that lists what moved beside a change has answered *what else* and left
5
+ * the question a reviewer asks next. `Card` moved in its layout beside `Button`:
6
+ * is that the card the button is nailed into, or a card on the other side of the
7
+ * app that happens to share a token? The first is arithmetic. The second is the
8
+ * finding — an edit whose consequence arrives somewhere with no import path to
9
+ * it has either found a global, or found a bug.
10
+ *
11
+ * Nothing new is measured for this. `ReachedComponent.trail` is the shortest
12
+ * chain from a file the commit changed to the component, every step a path but
13
+ * the last, so the file a component is declared in is the step before its name
14
+ * and the hops between two components are an index arithmetic away. Where the
15
+ * traversal never arrived, the docket's own causes still carry a file, and a
16
+ * name with a file beside it is placed even when it is not reached.
17
+ *
18
+ * ## What it will not say
19
+ *
20
+ * Nothing about the DOM. Two components can be siblings in a render and unrelated
21
+ * in the module graph, and the reverse — a layout that imports a button it never
22
+ * renders. This answers *how the edit could have got there*, which is the
23
+ * question a reviewer holding a diff is in a position to act on; where the boxes
24
+ * sit relative to each other is the picture's job.
25
+ *
26
+ * `unknown` is a state and not a zero. A run with no diff has no graph, and a
27
+ * surface that drew every distance as *unrelated* would turn a missing input into
28
+ * a page full of alarms.
29
+ */
30
+ /** The path out of a cause's `file`, which carries the declaring line after it. */
31
+ function pathOf(file) {
32
+ return file.replace(/:\d+(?::\d+)?$/, '');
33
+ }
34
+ export function distanceFrom(build, component) {
35
+ const reach = build.reach !== null && build.reach.whole === undefined ? build.reach : null;
36
+ if (reach === null)
37
+ return () => ({ kind: 'unknown' });
38
+ const trails = new Map(reach.components.map((each) => [each.component, each.trail]));
39
+ const declared = new Map(build.causes.flatMap((cause) => cause.file === undefined ? [] : [[cause.component, pathOf(cause.file)]]));
40
+ const edited = new Set(reach.changed);
41
+ // The traversal first, the docket second. A trail is a path *and* a distance;
42
+ // a cause's file is only a place, and it is what there is when the walk never
43
+ // arrived.
44
+ const fileOf = (name) => trails.get(name)?.at(-2) ?? declared.get(name);
45
+ const home = trails.get(component);
46
+ const homeFile = fileOf(component);
47
+ return (other) => {
48
+ const file = fileOf(other);
49
+ if (file !== undefined && file === homeFile)
50
+ return { kind: 'same-file' };
51
+ const trail = trails.get(other);
52
+ if (trail === undefined) {
53
+ return file === undefined
54
+ ? { kind: 'unreached' }
55
+ : { kind: 'declared', file, edited: edited.has(file) };
56
+ }
57
+ if (home === undefined || homeFile === undefined)
58
+ return { kind: 'apart' };
59
+ const through = trail.indexOf(homeFile);
60
+ if (through >= 0)
61
+ return { kind: 'importer', hops: trail.length - 2 - through };
62
+ const under = file === undefined ? -1 : home.indexOf(file);
63
+ if (under >= 0)
64
+ return { kind: 'imported', hops: home.length - 2 - under };
65
+ return { kind: 'apart' };
66
+ };
67
+ }
68
+ /**
69
+ * Whether the records put this component anywhere at all relative to the change.
70
+ *
71
+ * The distinction a row has to draw before it prints anything. A page that gave
72
+ * every unplaced component the same chip printed *the diff does not name it* nine
73
+ * times down one list — a column constant over every row, costing nine lines to
74
+ * say one thing, and saying it as though it were a property of each component
75
+ * rather than of the graph. Unplaced rows carry no chip; the list says once, at
76
+ * its foot, that it could not place them.
77
+ */
78
+ export function placed(distance) {
79
+ return distance.kind !== 'unreached' && distance.kind !== 'unknown';
80
+ }
81
+ /**
82
+ * The distance in words, or nothing when there is nothing to say.
83
+ *
84
+ * `unknown` returns nothing rather than a sentence about the run: a reviewer
85
+ * reading a list of components does not need each row to repeat that this build
86
+ * carried no diff, and the page says it once elsewhere.
87
+ */
88
+ export function howFar(distance) {
89
+ switch (distance.kind) {
90
+ case 'same-file':
91
+ return 'same file';
92
+ case 'importer':
93
+ return distance.hops === 1 ? 'imports it' : `imports it, ${String(distance.hops)} files out`;
94
+ case 'imported':
95
+ return distance.hops === 1
96
+ ? 'imported by it'
97
+ : `imported by it, ${String(distance.hops)} files out`;
98
+ case 'apart':
99
+ return 'reached by a separate path';
100
+ case 'declared':
101
+ return distance.edited
102
+ ? `${basename(distance.file)}, which this commit changed`
103
+ : `${basename(distance.file)}, untouched by this commit`;
104
+ case 'unreached':
105
+ case 'unknown':
106
+ return undefined;
107
+ }
108
+ }
109
+ /** The last segment, because a row has no width for `app/src/components/ui/`. */
110
+ function basename(file) {
111
+ return file.slice(file.lastIndexOf('/') + 1);
112
+ }
113
+ /**
114
+ * The impact-depth histogram: how far out the commit's consequences landed.
115
+ *
116
+ * Depth 0 is a component declared in a file the diff names, and everything above
117
+ * it is collateral in the only sense that can be measured without guessing — the
118
+ * edit is N imports away from the thing that moved. A commit whose movement is
119
+ * all at depth 0 did what it said. One with eleven components moving at depth 3
120
+ * changed something shared, and that is worth knowing before opening the first
121
+ * change rather than after opening the eleventh.
122
+ *
123
+ * `moved` is read from the hashes first and the regions second, because those are
124
+ * the two records that make the claim, and a component that only appears as a
125
+ * region lead is still a component this build says moved.
126
+ */
127
+ export function spreadOf(build) {
128
+ const reach = build.reach !== null && build.reach.whole === undefined ? build.reach : null;
129
+ if (reach === null)
130
+ return null;
131
+ // Where, and not only whether. The name alone answers the histogram's question;
132
+ // the subjects are what a walk up the composition has to be held to.
133
+ const moved = new Map();
134
+ const note = (component, subject) => {
135
+ const where = moved.get(component);
136
+ if (where === undefined)
137
+ moved.set(component, [subject]);
138
+ else if (!where.includes(subject))
139
+ where.push(subject);
140
+ };
141
+ for (const subject of build.subjects) {
142
+ if (subject.verdict !== 'changed')
143
+ continue;
144
+ for (const entry of subject.moved ?? []) {
145
+ if (entry.cause)
146
+ note(entry.component, subject.subject);
147
+ }
148
+ for (const region of subject.regions) {
149
+ if (region.cause === true && region.component !== undefined) {
150
+ note(region.component, subject.subject);
151
+ }
152
+ }
153
+ }
154
+ const rungs = new Map();
155
+ let throughUnread = 0;
156
+ for (const each of reach.components) {
157
+ if (each.throughUnread !== undefined) {
158
+ throughUnread += 1;
159
+ continue;
160
+ }
161
+ const depth = Math.max(each.trail.length - 2, 0);
162
+ const rung = rungs.get(depth) ?? { reached: 0, moved: 0 };
163
+ rung.reached += 1;
164
+ if (moved.has(each.component))
165
+ rung.moved += 1;
166
+ rungs.set(depth, rung);
167
+ }
168
+ const named = new Set(reach.components.map((each) => each.component));
169
+ return {
170
+ rungs: [...rungs.entries()]
171
+ .map(([depth, rung]) => ({ depth, ...rung }))
172
+ .sort((left, right) => left.depth - right.depth),
173
+ undeclared: [...moved]
174
+ .filter(([component]) => !named.has(component))
175
+ .map(([component, subjects]) => ({ component, subjects }))
176
+ .sort((left, right) => left.component.localeCompare(right.component)),
177
+ throughUnread,
178
+ };
179
+ }
180
+ //# sourceMappingURL=distance.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"distance.js","sourceRoot":"","sources":["../../src/ui/distance.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * How far a component is from the edit, in the graph the diff already walked.\n *\n * A page that lists what moved beside a change has answered *what else* and left\n * the question a reviewer asks next. `Card` moved in its layout beside `Button`:\n * is that the card the button is nailed into, or a card on the other side of the\n * app that happens to share a token? The first is arithmetic. The second is the\n * finding — an edit whose consequence arrives somewhere with no import path to\n * it has either found a global, or found a bug.\n *\n * Nothing new is measured for this. `ReachedComponent.trail` is the shortest\n * chain from a file the commit changed to the component, every step a path but\n * the last, so the file a component is declared in is the step before its name\n * and the hops between two components are an index arithmetic away. Where the\n * traversal never arrived, the docket's own causes still carry a file, and a\n * name with a file beside it is placed even when it is not reached.\n *\n * ## What it will not say\n *\n * Nothing about the DOM. Two components can be siblings in a render and unrelated\n * in the module graph, and the reverse — a layout that imports a button it never\n * renders. This answers *how the edit could have got there*, which is the\n * question a reviewer holding a diff is in a position to act on; where the boxes\n * sit relative to each other is the picture's job.\n *\n * `unknown` is a state and not a zero. A run with no diff has no graph, and a\n * surface that drew every distance as *unrelated* would turn a missing input into\n * a page full of alarms.\n */\n\nimport type { BuildDetail } from '../review-types.js';\n\nexport type Distance =\n /** Declared in the same file the change is. */\n | { readonly kind: 'same-file' }\n /** Reached through the change's file: it imports the change, `hops` files out. */\n | { readonly kind: 'importer'; readonly hops: number }\n /** The change is reached through this one's file, `hops` files out. */\n | { readonly kind: 'imported'; readonly hops: number }\n /** The commit reaches it, by a chain that does not pass the change at all. */\n | { readonly kind: 'apart' }\n /**\n * Off the traversal, but the build knows where it is declared.\n *\n * The interesting half of what used to be one negative. A component the walk\n * never arrived at is not thereby unplaced: the report's own causes carry the\n * file most of them are declared in, and *`ui/card.tsx`, which this commit did\n * not change* is a location a reviewer can act on where *not reached* is a\n * shrug.\n */\n | { readonly kind: 'declared'; readonly file: string; readonly edited: boolean }\n /** Nothing in the commit reaches it and no record says where it lives. */\n | { readonly kind: 'unreached' }\n /** No diff was read, so there is no graph to measure in. */\n | { readonly kind: 'unknown' };\n\n/** Distance from one fixed component to any other, over one build's reach. */\nexport type Ruler = (component: string) => Distance;\n\n/** The path out of a cause's `file`, which carries the declaring line after it. */\nfunction pathOf(file: string): string {\n return file.replace(/:\\d+(?::\\d+)?$/, '');\n}\n\nexport function distanceFrom(build: BuildDetail, component: string): Ruler {\n const reach = build.reach !== null && build.reach.whole === undefined ? build.reach : null;\n if (reach === null) return () => ({ kind: 'unknown' });\n\n const trails = new Map(reach.components.map((each) => [each.component, each.trail]));\n const declared = new Map(\n build.causes.flatMap((cause) =>\n cause.file === undefined ? [] : [[cause.component, pathOf(cause.file)] as const],\n ),\n );\n const edited = new Set(reach.changed);\n\n // The traversal first, the docket second. A trail is a path *and* a distance;\n // a cause's file is only a place, and it is what there is when the walk never\n // arrived.\n const fileOf = (name: string): string | undefined =>\n trails.get(name)?.at(-2) ?? declared.get(name);\n\n const home = trails.get(component);\n const homeFile = fileOf(component);\n\n return (other) => {\n const file = fileOf(other);\n if (file !== undefined && file === homeFile) return { kind: 'same-file' };\n\n const trail = trails.get(other);\n if (trail === undefined) {\n return file === undefined\n ? { kind: 'unreached' }\n : { kind: 'declared', file, edited: edited.has(file) };\n }\n if (home === undefined || homeFile === undefined) return { kind: 'apart' };\n\n const through = trail.indexOf(homeFile);\n if (through >= 0) return { kind: 'importer', hops: trail.length - 2 - through };\n\n const under = file === undefined ? -1 : home.indexOf(file);\n if (under >= 0) return { kind: 'imported', hops: home.length - 2 - under };\n\n return { kind: 'apart' };\n };\n}\n\n/**\n * Whether the records put this component anywhere at all relative to the change.\n *\n * The distinction a row has to draw before it prints anything. A page that gave\n * every unplaced component the same chip printed *the diff does not name it* nine\n * times down one list — a column constant over every row, costing nine lines to\n * say one thing, and saying it as though it were a property of each component\n * rather than of the graph. Unplaced rows carry no chip; the list says once, at\n * its foot, that it could not place them.\n */\nexport function placed(distance: Distance): boolean {\n return distance.kind !== 'unreached' && distance.kind !== 'unknown';\n}\n\n/**\n * The distance in words, or nothing when there is nothing to say.\n *\n * `unknown` returns nothing rather than a sentence about the run: a reviewer\n * reading a list of components does not need each row to repeat that this build\n * carried no diff, and the page says it once elsewhere.\n */\nexport function howFar(distance: Distance): string | undefined {\n switch (distance.kind) {\n case 'same-file':\n return 'same file';\n case 'importer':\n return distance.hops === 1 ? 'imports it' : `imports it, ${String(distance.hops)} files out`;\n case 'imported':\n return distance.hops === 1\n ? 'imported by it'\n : `imported by it, ${String(distance.hops)} files out`;\n case 'apart':\n return 'reached by a separate path';\n case 'declared':\n return distance.edited\n ? `${basename(distance.file)}, which this commit changed`\n : `${basename(distance.file)}, untouched by this commit`;\n case 'unreached':\n case 'unknown':\n return undefined;\n }\n}\n\n/** The last segment, because a row has no width for `app/src/components/ui/`. */\nfunction basename(file: string): string {\n return file.slice(file.lastIndexOf('/') + 1);\n}\n\n/** One rung of the blast radius: components this many imports from an edited file. */\nexport interface Depth {\n /** Imports between a file the commit changed and this component's file. */\n readonly depth: number;\n /** Components the commit reaches at this depth. */\n readonly reached: number;\n /** How many of them this build recorded as moving. */\n readonly moved: number;\n}\n\n/**\n * A component that moved and that no file in this commit declares.\n *\n * The subjects come with it because they are what makes the *why* answerable.\n * The census that knows which components enclose this one knows it over the\n * whole suite, so an unconstrained walk out of `CardFooter` arrives at the cart\n * as readily as at the product page. Held to the subjects it actually moved in,\n * it arrives at the one the commit changed. [`holding.ts`](./holding.js) does\n * that walk.\n */\nexport interface Unplaced {\n readonly component: string;\n /** Subjects this build recorded it moving in, in report order. */\n readonly subjects: readonly string[];\n}\n\nexport interface Spread {\n readonly rungs: readonly Depth[];\n /**\n * Components that moved and that no file in this commit declares.\n *\n * The histogram cannot hold these and must not be read as if it had: every\n * rung is *distance from an edited file*, and a component the traversal never\n * arrived at has no distance. Left out silently, a build whose loudest movement\n * is entirely off the graph reads as a build that stayed inside its own diff —\n * which is the reassurance this surface exists to withhold.\n */\n readonly undeclared: readonly Unplaced[];\n /**\n * Components reached only through a file the scan could not read.\n *\n * Kept out of the rungs and counted, because their depth is measured from the\n * blind spot rather than from the edit — a histogram that folded them in would\n * put the scanner's own gaps on the same axis as the commit's consequences.\n */\n readonly throughUnread: number;\n}\n\n/**\n * The impact-depth histogram: how far out the commit's consequences landed.\n *\n * Depth 0 is a component declared in a file the diff names, and everything above\n * it is collateral in the only sense that can be measured without guessing — the\n * edit is N imports away from the thing that moved. A commit whose movement is\n * all at depth 0 did what it said. One with eleven components moving at depth 3\n * changed something shared, and that is worth knowing before opening the first\n * change rather than after opening the eleventh.\n *\n * `moved` is read from the hashes first and the regions second, because those are\n * the two records that make the claim, and a component that only appears as a\n * region lead is still a component this build says moved.\n */\nexport function spreadOf(build: BuildDetail): Spread | null {\n const reach = build.reach !== null && build.reach.whole === undefined ? build.reach : null;\n if (reach === null) return null;\n\n // Where, and not only whether. The name alone answers the histogram's question;\n // the subjects are what a walk up the composition has to be held to.\n const moved = new Map<string, string[]>();\n const note = (component: string, subject: string): void => {\n const where = moved.get(component);\n if (where === undefined) moved.set(component, [subject]);\n else if (!where.includes(subject)) where.push(subject);\n };\n\n for (const subject of build.subjects) {\n if (subject.verdict !== 'changed') continue;\n for (const entry of subject.moved ?? []) {\n if (entry.cause) note(entry.component, subject.subject);\n }\n for (const region of subject.regions) {\n if (region.cause === true && region.component !== undefined) {\n note(region.component, subject.subject);\n }\n }\n }\n\n const rungs = new Map<number, { reached: number; moved: number }>();\n let throughUnread = 0;\n\n for (const each of reach.components) {\n if (each.throughUnread !== undefined) {\n throughUnread += 1;\n continue;\n }\n const depth = Math.max(each.trail.length - 2, 0);\n const rung = rungs.get(depth) ?? { reached: 0, moved: 0 };\n rung.reached += 1;\n if (moved.has(each.component)) rung.moved += 1;\n rungs.set(depth, rung);\n }\n\n const named = new Set(reach.components.map((each) => each.component));\n\n return {\n rungs: [...rungs.entries()]\n .map(([depth, rung]) => ({ depth, ...rung }))\n .sort((left, right) => left.depth - right.depth),\n undeclared: [...moved]\n .filter(([component]) => !named.has(component))\n .map(([component, subjects]) => ({ component, subjects }))\n .sort((left, right) => left.component.localeCompare(right.component)),\n throughUnread,\n };\n}\n"]}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * This run against the run before it — which is a different question from this
3
+ * run against its baseline, and the one nobody in the category asks.
4
+ *
5
+ * [`shift.ts`](./shift.ts) does the crossing. What is decided here is how it
6
+ * reads: which states open the section, what each one is worth saying, and how
7
+ * much of the earlier run travels onto a row.
8
+ *
9
+ * ## The decision on the earlier run is part of the finding
10
+ *
11
+ * A difference that is identical to build 5's, where build 5 approved it, means
12
+ * the approval did not reach the baseline. Identical where build 5 rejected it
13
+ * means it came back. Identical where nobody decided means the docket is a queue
14
+ * and this is its second delivery. Same shape, three different afternoons — so the
15
+ * earlier decision is carried onto the row rather than summarised away.
16
+ *
17
+ * ## Ranked by what it costs to miss
18
+ *
19
+ * Not by how many subjects land in a state. *The same difference again* is
20
+ * usually the largest group and the least urgent one, and it is not allowed to
21
+ * open the section and push the two states above it under the fold.
22
+ */
23
+ import type { ReactElement } from 'react';
24
+ import type { BuildDetail } from '../review-types.js';
25
+ import type { ReviewClient } from './client.js';
26
+ import { type Crossing } from './crossing.js';
27
+ export declare function DivergencePanel({ client, build, }: {
28
+ readonly client: ReviewClient;
29
+ readonly build: BuildDetail;
30
+ }): ReactElement | null;
31
+ /**
32
+ * The crossing as a section, from a reading somebody else paid for.
33
+ *
34
+ * Separate from the fetch so the build page can load the previous run once and
35
+ * spend it on both this and the line under each change.
36
+ */
37
+ export declare function DivergenceOf({ crossing }: {
38
+ readonly crossing: Crossing;
39
+ }): ReactElement | null;
40
+ //# sourceMappingURL=divergence.d.ts.map
@@ -0,0 +1,176 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { greenBecause } from '@variance-authority/report';
3
+ import { useCrossing } from './crossing.js';
4
+ import { count, number, when } from './text.js';
5
+ /**
6
+ * The order the states are read in, and what each one is worth saying.
7
+ *
8
+ * Ranked by what it costs to miss, not by how many subjects land in it. *The same
9
+ * difference again* is usually the largest group and the least urgent one: it is
10
+ * the part of the docket a reviewer can take in one decision, which is exactly why
11
+ * it is not allowed to open the section and bury the two states above it.
12
+ */
13
+ const SHIFTS = [
14
+ {
15
+ shift: 'differently',
16
+ title: 'Moved again, and not the same way',
17
+ tone: 'va-shift-alarm',
18
+ say: (against) => `These differed in ${against} too, but not in the same way: the difference itself changed between the two runs, so neither reading has settled.`,
19
+ },
20
+ {
21
+ shift: 'unsaid',
22
+ title: 'Moved again; nothing recorded says whether it is the same',
23
+ tone: 'va-shift-ask',
24
+ say: (against) => `Both runs found a difference, and at least one of them localised no shape for it. Nothing here may call these identical to ${against}, or different from it.`,
25
+ },
26
+ {
27
+ shift: 'first',
28
+ title: 'Moved for the first time',
29
+ tone: 'va-shift-alarm',
30
+ say: (against) => `${against} compared these and found nothing. This run found a difference.`,
31
+ },
32
+ {
33
+ shift: 'unmasked',
34
+ title: 'A rule stopped absorbing these',
35
+ tone: 'va-shift-alarm',
36
+ say: (against) => `${against} reported these green because a declaration absorbed their difference, and this run reports one. Either the rule stopped matching, or something moved outside what it covers — and the second is what a mask over a real regression looks like from the outside.`,
37
+ },
38
+ {
39
+ shift: 'absorbed',
40
+ title: 'A rule now decides these',
41
+ tone: 'va-shift-ask',
42
+ say: (against) => `${against} put these in front of a reviewer and this run does not: a declaration in the config now takes their difference. That is the intended effect of writing one, and it is also how a subject leaves review without anybody deciding it.`,
43
+ },
44
+ {
45
+ shift: 'new',
46
+ title: 'New to this run',
47
+ tone: 'va-shift-quiet',
48
+ say: (against) => `${against} had no such subject, so there is nothing to place these against.`,
49
+ },
50
+ {
51
+ shift: 'again',
52
+ title: 'The same difference, again',
53
+ tone: 'va-shift-known',
54
+ say: (against) => `The shape ${against} already carried, unchanged. Deciding it there would have settled it here.`,
55
+ },
56
+ {
57
+ shift: 'settled',
58
+ title: 'No longer moving',
59
+ tone: 'va-shift-good',
60
+ say: (against) => `${against} found a difference in these. This run agrees with the baseline.`,
61
+ },
62
+ {
63
+ shift: 'declared',
64
+ title: 'Green because a rule says so',
65
+ tone: 'va-shift-known',
66
+ say: () => 'Compared, found to differ, and decided by a declaration this config names. Nothing is awaiting review here; what is worth reading is the ledger below, which says what each rule absorbed and what it no longer does.',
67
+ },
68
+ {
69
+ shift: 'unplaced',
70
+ title: 'The earlier run never compared these',
71
+ tone: 'va-shift-quiet',
72
+ say: (against) => `${against} recorded no comparison for them — no baseline, or nothing to compare — so this run's reading stands alone.`,
73
+ },
74
+ {
75
+ shift: 'uncompared',
76
+ title: 'This run did not compare these',
77
+ tone: 'va-shift-quiet',
78
+ say: () => 'No baseline was put beside them here, so nothing about their history applies yet.',
79
+ },
80
+ {
81
+ shift: 'dropped',
82
+ title: 'Gone since the earlier run',
83
+ tone: 'va-shift-ask',
84
+ say: (against) => `${against} carried these and this run does not. The suite lost them.`,
85
+ },
86
+ ];
87
+ /** How many subjects a group names before the rest become a count. */
88
+ const NAMED = 6;
89
+ export function DivergencePanel({ client, build, }) {
90
+ return _jsx(DivergenceOf, { crossing: useCrossing(client, build) });
91
+ }
92
+ /**
93
+ * The crossing as a section, from a reading somebody else paid for.
94
+ *
95
+ * Separate from the fetch so the build page can load the previous run once and
96
+ * spend it on both this and the line under each change.
97
+ */
98
+ export function DivergenceOf({ crossing }) {
99
+ if (crossing.state === 'loading')
100
+ return null;
101
+ if (crossing.state === 'none') {
102
+ return (_jsxs("section", { className: "va-card", children: [_jsx("h2", { children: "Since the last run" }), _jsx("p", { className: "va-note", children: "There is no earlier run of this project to place this one against. Everything here is being read for the first time." })] }));
103
+ }
104
+ if (crossing.state === 'failed') {
105
+ return (_jsxs("section", { className: "va-card", children: [_jsx("h2", { children: "Since the last run" }), _jsxs("p", { className: "va-failure", children: ["The earlier run could not be read, so nothing on this page says which of these differences you have already seen: ", crossing.why] })] }));
106
+ }
107
+ return _jsx(Crossed, { earlier: crossing.earlier, divergence: crossing.divergence });
108
+ }
109
+ function Crossed({ earlier, divergence, }) {
110
+ const { shifts, held } = divergence;
111
+ const against = `build ${earlier.build}`;
112
+ const known = shifts.filter((each) => each.shift === 'again').length;
113
+ // `unmasked` counts here. A subject the earlier run showed nobody, because a
114
+ // rule absorbed it, is a subject that run did not show you — and it is the one
115
+ // the headline is least entitled to leave out.
116
+ const fresh = shifts.filter((each) => ['differently', 'first', 'new', 'unmasked'].includes(each.shift)).length;
117
+ return (_jsxs("section", { className: "va-card va-divergence", children: [_jsx("h2", { children: "Since the last run" }), _jsxs("p", { className: "va-subtitle", children: ["Against ", _jsxs("strong", { children: ["build ", earlier.build] }), ' ', _jsx("code", { className: "va-commit", children: earlier.commit.slice(0, 8) }), earlier.branch === undefined ? null : (_jsxs("span", { className: "va-note", children: [" on ", earlier.branch] })), ", ", when(earlier.at), ".", ' ', shifts.length === 0
118
+ ? 'Every subject read the same way in both runs.'
119
+ : `${fresh === 0
120
+ ? 'Nothing here is something that run did not show you'
121
+ : `${count(fresh, 'subject')} here ${fresh === 1 ? 'is' : 'are'} something that run did not show you`}; ${count(known, 'subject')} ${known === 1 ? 'carries' : 'carry'} a difference it already had.`] }), SHIFTS.map((group) => {
122
+ const members = shifts.filter((each) => each.shift === group.shift);
123
+ if (members.length === 0)
124
+ return null;
125
+ return (_jsxs("div", { className: `va-shift ${group.tone}`, children: [_jsxs("p", { className: "va-shift-head", children: [_jsx("span", { className: "va-shift-title", children: group.title }), _jsx("span", { className: "va-shift-count va-num", children: number(members.length) })] }), _jsx("p", { className: "va-note", children: group.say(against) }), _jsx("ul", { className: "va-shift-list", children: members.slice(0, NAMED).map((each) => (_jsx(ShiftRow, { shifted: each, against: against }, each.subject))) }), members.length > NAMED ? (_jsxs("p", { className: "va-note", children: ["and ", number(members.length - NAMED), " more, in the rail."] })) : null] }, group.shift));
126
+ }), held === 0 ? null : (_jsxs("p", { className: "va-note va-shift-held", children: [count(held, 'subject'), " read the same in both runs and neither found a difference."] }))] }));
127
+ }
128
+ /**
129
+ * One subject, and the single fact that places it against the earlier run.
130
+ *
131
+ * Which fact that is depends on the state, because the useful number is not the
132
+ * same one twice: a difference that changed size says so in pixels, and a
133
+ * difference that arrived identical says what the earlier run decided about it —
134
+ * which is the whole reason its identity matters.
135
+ */
136
+ function ShiftRow({ shifted, against, }) {
137
+ const { now, earlier } = shifted;
138
+ return (_jsxs("li", { className: "va-shift-row", children: [_jsx("span", { className: "va-shift-subject", children: shifted.subject }), shifted.shift === 'differently' && now !== undefined && earlier !== undefined ? (_jsxs("span", { className: "va-note va-num", children: [number(earlier.changedPixels), " px \u2192 ", number(now.changedPixels), " px"] })) : null, shifted.shift === 'again' ? _jsx(Standing, { earlier: earlier, against: against }) : null, shifted.shift === 'first' && now !== undefined ? (_jsxs("span", { className: "va-note va-num", children: [number(now.changedPixels), " px"] })) : null, shifted.shift === 'unmasked' && now !== undefined ? (_jsxs("span", { className: "va-note va-num", children: [number(now.changedPixels), " px, reported"] })) : null, shifted.shift === 'absorbed' ? _jsx(Absorbing, { now: now, earlier: earlier }) : null] }));
139
+ }
140
+ /**
141
+ * Which rule took a subject out of review, and what it was worth when it did.
142
+ *
143
+ * Both halves are read from the record and neither is invented. Where the build
144
+ * did not store the per-subject block — an older push, or a service that dropped
145
+ * it on the way in — the row says the rule is unnamed rather than leaving a
146
+ * reader to assume the difference was too small to be worth one.
147
+ */
148
+ function Absorbing({ now, earlier, }) {
149
+ const green = now === undefined ? undefined : greenBecause(now);
150
+ const was = earlier === undefined ? null : (_jsxs("span", { className: "va-note va-num", children: [number(earlier.changedPixels), " px before"] }));
151
+ if (green === undefined || green.kind === 'unsaid') {
152
+ return (_jsxs(_Fragment, { children: [_jsx("span", { className: "va-note", children: "this build does not record which rule" }), was] }));
153
+ }
154
+ if (green.kind === 'relaxed') {
155
+ return (_jsxs(_Fragment, { children: [_jsxs("span", { className: "va-mark va-ignored", children: [green.rule, " \u00B7 asserted on ", green.level] }), was] }));
156
+ }
157
+ if (green.kind === 'absorbed') {
158
+ return (_jsxs(_Fragment, { children: [_jsx("span", { className: "va-mark va-ignored", children: green.rules.join(', ') }), _jsxs("span", { className: "va-note va-num", children: [number(green.pixels), " px absorbed"] })] }));
159
+ }
160
+ return _jsx(_Fragment, { children: was });
161
+ }
162
+ /**
163
+ * What the earlier run decided about a difference this one is carrying again.
164
+ *
165
+ * Undecided is the common case and the one worth a word, because it is the
166
+ * failure this whole section exists to name: a docket delivered twice to somebody
167
+ * who read it once.
168
+ */
169
+ function Standing({ earlier, against, }) {
170
+ const decision = earlier?.decision;
171
+ if (decision === undefined || decision === null) {
172
+ return _jsxs("span", { className: "va-note", children: ["undecided in ", against] });
173
+ }
174
+ return (_jsxs("span", { className: decision.decision === 'approved' ? 'va-mark va-approved' : 'va-mark va-rejected', children: [decision.decision, " in ", against, " by ", decision.by] }));
175
+ }
176
+ //# sourceMappingURL=divergence.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"divergence.js","sourceRoot":"","sources":["../../src/ui/divergence.tsx"],"names":[],"mappings":"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","sourcesContent":["/**\n * This run against the run before it — which is a different question from this\n * run against its baseline, and the one nobody in the category asks.\n *\n * [`shift.ts`](./shift.ts) does the crossing. What is decided here is how it\n * reads: which states open the section, what each one is worth saying, and how\n * much of the earlier run travels onto a row.\n *\n * ## The decision on the earlier run is part of the finding\n *\n * A difference that is identical to build 5's, where build 5 approved it, means\n * the approval did not reach the baseline. Identical where build 5 rejected it\n * means it came back. Identical where nobody decided means the docket is a queue\n * and this is its second delivery. Same shape, three different afternoons — so the\n * earlier decision is carried onto the row rather than summarised away.\n *\n * ## Ranked by what it costs to miss\n *\n * Not by how many subjects land in a state. *The same difference again* is\n * usually the largest group and the least urgent one, and it is not allowed to\n * open the section and push the two states above it under the fold.\n */\n\nimport type { ReactElement } from 'react';\nimport { greenBecause } from '@variance-authority/report';\nimport type { BuildDetail, SubjectView } from '../review-types.js';\nimport type { ReviewClient } from './client.js';\nimport { useCrossing, type Crossing } from './crossing.js';\nimport type { Divergence, Shift, Shifted } from './shift.js';\nimport { count, number, when } from './text.js';\n\n/**\n * The order the states are read in, and what each one is worth saying.\n *\n * Ranked by what it costs to miss, not by how many subjects land in it. *The same\n * difference again* is usually the largest group and the least urgent one: it is\n * the part of the docket a reviewer can take in one decision, which is exactly why\n * it is not allowed to open the section and bury the two states above it.\n */\nconst SHIFTS: readonly {\n readonly shift: Shift;\n readonly title: string;\n readonly tone: string;\n readonly say: (against: string) => string;\n}[] = [\n {\n shift: 'differently',\n title: 'Moved again, and not the same way',\n tone: 'va-shift-alarm',\n say: (against) =>\n `These differed in ${against} too, but not in the same way: the difference itself changed between the two runs, so neither reading has settled.`,\n },\n {\n shift: 'unsaid',\n title: 'Moved again; nothing recorded says whether it is the same',\n tone: 'va-shift-ask',\n say: (against) =>\n `Both runs found a difference, and at least one of them localised no shape for it. Nothing here may call these identical to ${against}, or different from it.`,\n },\n {\n shift: 'first',\n title: 'Moved for the first time',\n tone: 'va-shift-alarm',\n say: (against) => `${against} compared these and found nothing. This run found a difference.`,\n },\n {\n shift: 'unmasked',\n title: 'A rule stopped absorbing these',\n tone: 'va-shift-alarm',\n say: (against) =>\n `${against} reported these green because a declaration absorbed their difference, and this run reports one. Either the rule stopped matching, or something moved outside what it covers — and the second is what a mask over a real regression looks like from the outside.`,\n },\n {\n shift: 'absorbed',\n title: 'A rule now decides these',\n tone: 'va-shift-ask',\n say: (against) =>\n `${against} put these in front of a reviewer and this run does not: a declaration in the config now takes their difference. That is the intended effect of writing one, and it is also how a subject leaves review without anybody deciding it.`,\n },\n {\n shift: 'new',\n title: 'New to this run',\n tone: 'va-shift-quiet',\n say: (against) => `${against} had no such subject, so there is nothing to place these against.`,\n },\n {\n shift: 'again',\n title: 'The same difference, again',\n tone: 'va-shift-known',\n say: (against) =>\n `The shape ${against} already carried, unchanged. Deciding it there would have settled it here.`,\n },\n {\n shift: 'settled',\n title: 'No longer moving',\n tone: 'va-shift-good',\n say: (against) => `${against} found a difference in these. This run agrees with the baseline.`,\n },\n {\n shift: 'declared',\n title: 'Green because a rule says so',\n tone: 'va-shift-known',\n say: () =>\n 'Compared, found to differ, and decided by a declaration this config names. Nothing is awaiting review here; what is worth reading is the ledger below, which says what each rule absorbed and what it no longer does.',\n },\n {\n shift: 'unplaced',\n title: 'The earlier run never compared these',\n tone: 'va-shift-quiet',\n say: (against) =>\n `${against} recorded no comparison for them — no baseline, or nothing to compare — so this run's reading stands alone.`,\n },\n {\n shift: 'uncompared',\n title: 'This run did not compare these',\n tone: 'va-shift-quiet',\n say: () => 'No baseline was put beside them here, so nothing about their history applies yet.',\n },\n {\n shift: 'dropped',\n title: 'Gone since the earlier run',\n tone: 'va-shift-ask',\n say: (against) => `${against} carried these and this run does not. The suite lost them.`,\n },\n];\n\n/** How many subjects a group names before the rest become a count. */\nconst NAMED = 6;\n\nexport function DivergencePanel({\n client,\n build,\n}: {\n readonly client: ReviewClient;\n readonly build: BuildDetail;\n}): ReactElement | null {\n return <DivergenceOf crossing={useCrossing(client, build)} />;\n}\n\n/**\n * The crossing as a section, from a reading somebody else paid for.\n *\n * Separate from the fetch so the build page can load the previous run once and\n * spend it on both this and the line under each change.\n */\nexport function DivergenceOf({ crossing }: { readonly crossing: Crossing }): ReactElement | null {\n if (crossing.state === 'loading') return null;\n if (crossing.state === 'none') {\n return (\n <section className=\"va-card\">\n <h2>Since the last run</h2>\n <p className=\"va-note\">\n There is no earlier run of this project to place this one against. Everything here is\n being read for the first time.\n </p>\n </section>\n );\n }\n if (crossing.state === 'failed') {\n return (\n <section className=\"va-card\">\n <h2>Since the last run</h2>\n <p className=\"va-failure\">\n The earlier run could not be read, so nothing on this page says which of these differences\n you have already seen: {crossing.why}\n </p>\n </section>\n );\n }\n\n return <Crossed earlier={crossing.earlier} divergence={crossing.divergence} />;\n}\n\nfunction Crossed({\n earlier,\n divergence,\n}: {\n readonly earlier: BuildDetail;\n readonly divergence: Divergence;\n}): ReactElement {\n const { shifts, held } = divergence;\n const against = `build ${earlier.build}`;\n const known = shifts.filter((each) => each.shift === 'again').length;\n // `unmasked` counts here. A subject the earlier run showed nobody, because a\n // rule absorbed it, is a subject that run did not show you — and it is the one\n // the headline is least entitled to leave out.\n const fresh = shifts.filter((each) =>\n ['differently', 'first', 'new', 'unmasked'].includes(each.shift),\n ).length;\n\n return (\n <section className=\"va-card va-divergence\">\n <h2>Since the last run</h2>\n <p className=\"va-subtitle\">\n Against <strong>build {earlier.build}</strong>{' '}\n <code className=\"va-commit\">{earlier.commit.slice(0, 8)}</code>\n {earlier.branch === undefined ? null : (\n <span className=\"va-note\"> on {earlier.branch}</span>\n )}\n , {when(earlier.at)}.{' '}\n {shifts.length === 0\n ? 'Every subject read the same way in both runs.'\n : `${\n fresh === 0\n ? 'Nothing here is something that run did not show you'\n : `${count(fresh, 'subject')} here ${fresh === 1 ? 'is' : 'are'} something that run did not show you`\n }; ${count(known, 'subject')} ${known === 1 ? 'carries' : 'carry'} a difference it already had.`}\n </p>\n\n {SHIFTS.map((group) => {\n const members = shifts.filter((each) => each.shift === group.shift);\n if (members.length === 0) return null;\n return (\n <div key={group.shift} className={`va-shift ${group.tone}`}>\n <p className=\"va-shift-head\">\n <span className=\"va-shift-title\">{group.title}</span>\n <span className=\"va-shift-count va-num\">{number(members.length)}</span>\n </p>\n <p className=\"va-note\">{group.say(against)}</p>\n <ul className=\"va-shift-list\">\n {members.slice(0, NAMED).map((each) => (\n <ShiftRow key={each.subject} shifted={each} against={against} />\n ))}\n </ul>\n {members.length > NAMED ? (\n <p className=\"va-note\">\n and {number(members.length - NAMED)} more, in the rail.\n </p>\n ) : null}\n </div>\n );\n })}\n\n {held === 0 ? null : (\n <p className=\"va-note va-shift-held\">\n {count(held, 'subject')} read the same in both runs and neither found a difference.\n </p>\n )}\n </section>\n );\n}\n\n/**\n * One subject, and the single fact that places it against the earlier run.\n *\n * Which fact that is depends on the state, because the useful number is not the\n * same one twice: a difference that changed size says so in pixels, and a\n * difference that arrived identical says what the earlier run decided about it —\n * which is the whole reason its identity matters.\n */\nfunction ShiftRow({\n shifted,\n against,\n}: {\n readonly shifted: Shifted;\n readonly against: string;\n}): ReactElement {\n const { now, earlier } = shifted;\n\n return (\n <li className=\"va-shift-row\">\n <span className=\"va-shift-subject\">{shifted.subject}</span>\n {shifted.shift === 'differently' && now !== undefined && earlier !== undefined ? (\n <span className=\"va-note va-num\">\n {number(earlier.changedPixels)} px → {number(now.changedPixels)} px\n </span>\n ) : null}\n {shifted.shift === 'again' ? <Standing earlier={earlier} against={against} /> : null}\n {shifted.shift === 'first' && now !== undefined ? (\n <span className=\"va-note va-num\">{number(now.changedPixels)} px</span>\n ) : null}\n {shifted.shift === 'unmasked' && now !== undefined ? (\n <span className=\"va-note va-num\">{number(now.changedPixels)} px, reported</span>\n ) : null}\n {shifted.shift === 'absorbed' ? <Absorbing now={now} earlier={earlier} /> : null}\n </li>\n );\n}\n\n/**\n * Which rule took a subject out of review, and what it was worth when it did.\n *\n * Both halves are read from the record and neither is invented. Where the build\n * did not store the per-subject block — an older push, or a service that dropped\n * it on the way in — the row says the rule is unnamed rather than leaving a\n * reader to assume the difference was too small to be worth one.\n */\nfunction Absorbing({\n now,\n earlier,\n}: {\n readonly now: SubjectView | undefined;\n readonly earlier: SubjectView | undefined;\n}): ReactElement {\n const green = now === undefined ? undefined : greenBecause(now);\n const was = earlier === undefined ? null : (\n <span className=\"va-note va-num\">{number(earlier.changedPixels)} px before</span>\n );\n\n if (green === undefined || green.kind === 'unsaid') {\n return (\n <>\n <span className=\"va-note\">this build does not record which rule</span>\n {was}\n </>\n );\n }\n if (green.kind === 'relaxed') {\n return (\n <>\n <span className=\"va-mark va-ignored\">{green.rule} · asserted on {green.level}</span>\n {was}\n </>\n );\n }\n if (green.kind === 'absorbed') {\n return (\n <>\n <span className=\"va-mark va-ignored\">{green.rules.join(', ')}</span>\n <span className=\"va-note va-num\">{number(green.pixels)} px absorbed</span>\n </>\n );\n }\n return <>{was}</>;\n}\n\n/**\n * What the earlier run decided about a difference this one is carrying again.\n *\n * Undecided is the common case and the one worth a word, because it is the\n * failure this whole section exists to name: a docket delivered twice to somebody\n * who read it once.\n */\nfunction Standing({\n earlier,\n against,\n}: {\n readonly earlier: SubjectView | undefined;\n readonly against: string;\n}): ReactElement {\n const decision = earlier?.decision;\n if (decision === undefined || decision === null) {\n return <span className=\"va-note\">undecided in {against}</span>;\n }\n return (\n <span className={decision.decision === 'approved' ? 'va-mark va-approved' : 'va-mark va-rejected'}>\n {decision.decision} in {against} by {decision.by}\n </span>\n );\n}\n"]}