@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,208 @@
1
+ import { locate } from '../attribute/locate.js';
2
+ import { formatSource, resolveSource } from '../attribute/source.js';
3
+ /**
4
+ * The identity of a finding, as a string two runs can be compared on.
5
+ *
6
+ * A finding is *the same finding* when the same rule fires on the same node. Not
7
+ * the same sentence: `what` quotes the text it found, so a copy edit beside a
8
+ * control with no accessible name would read as the old defect going away and a
9
+ * new one arriving in the same place. And not the same component either — a
10
+ * component renders in many places, and every one of them would collapse to one.
11
+ *
12
+ * Small on purpose. This is what a baseline carries so that the next run can say
13
+ * whether a defect it found was already there, and a baseline sidecar rides
14
+ * beside a PNG in a tracked directory: a mark is a few dozen bytes and a stored
15
+ * `Finding` is a paragraph. It can only answer membership, which is the only
16
+ * question the next run is allowed to ask of it — the same restraint
17
+ * `Described.components` is written under.
18
+ */
19
+ export function findingMark(finding) {
20
+ return `${finding.rule}@${finding.path}`;
21
+ }
22
+ /** Roles that are operated. A control nobody can name is a control nobody can use. */
23
+ const INTERACTIVE = new Set([
24
+ 'button', 'link', 'checkbox', 'radio', 'switch', 'textbox', 'searchbox',
25
+ 'combobox', 'slider', 'spinbutton', 'menuitem', 'menuitemcheckbox',
26
+ 'menuitemradio', 'tab', 'treeitem',
27
+ ]);
28
+ /** Attributes whose value the aliaser rewrote, so `#extern:` means unresolved. */
29
+ const REFERENCE_ATTRIBUTES = new Set(['for', 'form', 'list', 'headers']);
30
+ const HEADING_TAGS = {
31
+ h1: 1, h2: 2, h3: 3, h4: 4, h5: 5, h6: 6,
32
+ };
33
+ /** Regions of a page a screen reader user navigates between. */
34
+ const LANDMARKS = new Set([
35
+ 'banner', 'navigation', 'main', 'complementary', 'contentinfo',
36
+ 'region', 'form', 'search',
37
+ ]);
38
+ /** Has at least one letter. A glyph is not a visible label. */
39
+ const HAS_LETTER = /\p{L}/u;
40
+ export function inspect(snapshot) {
41
+ const findings = [];
42
+ const headings = [];
43
+ const landmarks = new Map();
44
+ visit(snapshot.root, false);
45
+ // Heading order is a property of the sequence, not of any one node, so it is
46
+ // decided after the walk. Only *forward* jumps are reported: a component that
47
+ // starts at `h3` is a component rendered inside something, not a defect, and a
48
+ // subject is not a page.
49
+ headings.forEach((heading, index) => {
50
+ const previous = headings[index - 1];
51
+ if (previous === undefined || heading.level <= previous.level + 1)
52
+ return;
53
+ findings.push(finding('heading-level-skipped', heading.node, `heading level jumps from ${previous.level} to ${heading.level}` +
54
+ (heading.node.name !== undefined ? ` at "${heading.node.name}"` : ''), snapshot));
55
+ });
56
+ return findings;
57
+ function visit(node, insideControl) {
58
+ const interactive = node.role !== undefined && INTERACTIVE.has(node.role);
59
+ if (interactive && node.name === undefined) {
60
+ findings.push(finding('control-without-name', node, `<${node.tag}> is a ${node.role} with no accessible name`, snapshot));
61
+ }
62
+ if (interactive && insideControl) {
63
+ findings.push(finding('nested-interactive', node, `a ${node.role} is nested inside another control; only one of the two is reachable`, snapshot));
64
+ }
65
+ // `alt=""` is the author saying "decorative", which is a correct answer and
66
+ // must not be reported. A missing `alt` is the author saying nothing.
67
+ if (node.role === 'img' && node.name === undefined && node.attributes['alt'] === undefined) {
68
+ findings.push(finding('image-without-alt', node, `<${node.tag}> has an image role, no accessible name, and no alt="" to say it is decorative`, snapshot));
69
+ }
70
+ for (const [attribute, value] of Object.entries(node.attributes)) {
71
+ if (!REFERENCE_ATTRIBUTES.has(attribute) || !value.includes('#extern:'))
72
+ continue;
73
+ findings.push(finding('dangling-reference', node,
74
+ // Honest about the ambiguity the aliaser records: from inside the
75
+ // subject, escaping the subtree and pointing at nothing look the same.
76
+ `<${node.tag} ${attribute}> references an element that is not in this subject`, snapshot));
77
+ }
78
+ if (node.role === 'heading') {
79
+ const level = levelOf(node);
80
+ if (level !== undefined)
81
+ headings.push({ node, level });
82
+ }
83
+ if (node.role !== undefined && LANDMARKS.has(node.role)) {
84
+ // Keyed by role *and* name: two `navigation` landmarks called "Primary"
85
+ // and "Footer" are how a page is meant to be built. Two called nothing are
86
+ // two identical entries in the landmark list.
87
+ const key = `${node.role}\u0000${node.name ?? ''}`;
88
+ const first = landmarks.get(key);
89
+ if (first === undefined) {
90
+ landmarks.set(key, node);
91
+ }
92
+ else {
93
+ findings.push(finding('duplicate-landmark', node, `a second ${node.role} landmark` +
94
+ (node.name !== undefined ? ` also named "${node.name}"` : ' with no name') +
95
+ ', so nothing tells the two apart in a landmark list', snapshot));
96
+ }
97
+ }
98
+ if (node.role === 'table' && !hasHeaders(node)) {
99
+ findings.push(finding('table-without-headers', node, `<${node.tag}> is a table with no header cells, so every cell is announced without ` +
100
+ 'the column it belongs to', snapshot));
101
+ }
102
+ const tabindex = Number(node.attributes['tabindex']);
103
+ if (Number.isInteger(tabindex) && tabindex > 0) {
104
+ findings.push(finding('positive-tabindex', node, `<${node.tag}> has tabindex=${tabindex}, which pulls it ahead of every element in ` +
105
+ 'the document that relies on source order', snapshot));
106
+ }
107
+ // WCAG 2.5.3. A control whose accessible name does not contain its visible
108
+ // label cannot be operated by voice: "click Save" does nothing when the
109
+ // button reads Save and is named "Submit form".
110
+ //
111
+ // Only when the visible text carries a letter. An icon button labelled
112
+ // `aria-label="Refresh"` around a glyph is correct, and a rule that reported
113
+ // it would fire on every icon in every design system.
114
+ if (interactive && node.name !== undefined) {
115
+ const visible = visibleTextOf(node);
116
+ if (visible.length > 0 &&
117
+ HAS_LETTER.test(visible) &&
118
+ !node.name.toLowerCase().includes(visible.toLowerCase())) {
119
+ findings.push(finding('label-mismatch', node, `reads "${visible}" and is named "${node.name}", so a voice command using the ` +
120
+ 'visible words does not reach it', snapshot));
121
+ }
122
+ }
123
+ for (const child of node.children)
124
+ visit(child, insideControl || interactive);
125
+ }
126
+ }
127
+ function hasHeaders(node) {
128
+ if (node.role === 'columnheader' || node.role === 'rowheader')
129
+ return true;
130
+ return node.children.some(hasHeaders);
131
+ }
132
+ /**
133
+ * Text a sighted user reads, normalized to single spaces.
134
+ *
135
+ * `aria-hidden` subtrees are excluded, which is what makes the icon-button case
136
+ * work: the glyph is hidden, so the visible text is empty and `label-mismatch`
137
+ * has nothing to compare.
138
+ */
139
+ function visibleTextOf(node) {
140
+ if (node.state?.['hidden'] === true)
141
+ return '';
142
+ const own = node.text ?? '';
143
+ const children = node.children.map(visibleTextOf).join(' ');
144
+ return `${own} ${children}`.replace(/\s+/g, ' ').trim();
145
+ }
146
+ function levelOf(node) {
147
+ // `aria-level` wins, because it is what an assistive technology reads. The tag
148
+ // is the fallback, and a `role="heading"` with neither is not a level at all.
149
+ const declared = node.state?.['level'];
150
+ if (typeof declared === 'number')
151
+ return declared;
152
+ if (typeof declared === 'string' && /^\d+$/.test(declared))
153
+ return Number(declared);
154
+ return HEADING_TAGS[node.tag];
155
+ }
156
+ function finding(rule, node, what, snapshot) {
157
+ const where = locate(snapshot.root, node.path).where;
158
+ const component = node.provenance?.createdBy ?? node.provenance?.owners[0]?.name;
159
+ return {
160
+ rule,
161
+ band: 'a11y',
162
+ path: node.path,
163
+ what,
164
+ ...(where !== '' ? { where } : {}),
165
+ ...(component !== undefined ? { component } : {}),
166
+ ...(node.provenance ? { owners: node.provenance.owners } : {}),
167
+ ...(node.provenance?.source ? { source: node.provenance.source } : {}),
168
+ };
169
+ }
170
+ /**
171
+ * Findings as the thing a reviewer or an agent reads.
172
+ *
173
+ * Same shape as `summarizeAdjudication`: what, where, which file. A finding a
174
+ * reader has to go and locate is a finding that gets skipped.
175
+ */
176
+ export function summarizeFindings(findings, options = {}) {
177
+ if (findings.length === 0)
178
+ return 'no findings.';
179
+ const byRule = new Map();
180
+ for (const found of findings)
181
+ byRule.set(found.rule, (byRule.get(found.rule) ?? 0) + 1);
182
+ const header = `${findings.length} finding${findings.length === 1 ? '' : 's'}: ` +
183
+ [...byRule].map(([rule, count]) => `${count} ${rule}`).join(', ');
184
+ const lines = findings.flatMap((found) => {
185
+ // The element's own line wins over the component's declaration. Both are
186
+ // `file:line`, so the reader cannot tell them apart and does not need to —
187
+ // either one opens at something worth editing, and the recorded one opens at
188
+ // the element the finding is actually about.
189
+ const declared = found.component !== undefined && options.source
190
+ ? resolveSource(found.component, options.source)
191
+ : null;
192
+ const where = found.source !== undefined
193
+ ? `${found.source.file}:${String(found.source.line)}`
194
+ : declared
195
+ ? formatSource(declared)
196
+ : '';
197
+ const attribution = [found.component, where === '' ? undefined : where]
198
+ .filter((part) => part !== undefined)
199
+ .join(' ');
200
+ return [
201
+ ` [${found.rule}] ${found.what}`,
202
+ found.where !== undefined ? ` in ${found.where}` : null,
203
+ attribution === '' ? null : ` ${attribution}`,
204
+ ].filter((line) => line !== null);
205
+ });
206
+ return [header, ...lines].join('\n');
207
+ }
208
+ //# sourceMappingURL=inspect.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inspect.js","sourceRoot":"","sources":["../../src/judge/inspect.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Band } from '../compare/band.js';\nimport type { OwnerFrame, SourceLocation } from '../format/provenance.js';\nimport type { NodePath, SemanticNode, SemanticSnapshot } from '../format/snapshot.js';\nimport { locate } from '../attribute/locate.js';\nimport { formatSource, resolveSource, type SourceIndex } from '../attribute/source.js';\n\n/**\n * What a render says without a baseline.\n *\n * Two producers, one `Finding` type. `inspect` reads a single snapshot;\n * `compareLocales` reads two renders of one subject in different languages,\n * which is also not a regression comparison — both renders are correct. Sharing\n * the type is what lets the report, the CLI and the MCP tools carry both without\n * knowing which produced what.\n *\n * The rest of this comment is about `inspect`, which reads one snapshot with no\n * baseline and no second run.\n *\n * Everything else in this package answers *what changed*, which requires two of\n * something. That framing has a blind spot the whole category shares: **a defect\n * that was present on the first run is invisible to a comparison forever.** A\n * button that never had an accessible name compares equal to itself on every run\n * until someone happens to edit it, and a picture of it is a perfectly good\n * picture. Approving the first baseline approves the defect.\n *\n * These rules read the normalized snapshot and report defects in it. Consequences\n * worth stating, because they are the practical argument for having the document\n * rather than an image of it:\n *\n * - **A fresh checkout with zero baselines is already useful.** The expensive\n * part of adopting a visual-regression tool is that it says nothing until it\n * has a history; this half says something on the first run.\n * - **Every finding names a component and a file**, through the same provenance\n * chain a delta uses. A rule engine that reports a DOM path reports where the\n * symptom is; this reports whose JSX wrote it.\n * - **They band as `a11y`**, so a project that writes `blocking: ['a11y']` gets\n * both halves under one policy: regressions found by comparison, and defects\n * found by inspection.\n *\n * Deliberately *not* an axe-core reimplementation. Axe runs against a live DOM\n * with computed visibility, contrast and focus order, and does dozens of things\n * this cannot. What is here is the subset a normalized snapshot can decide\n * without guessing — and the subset it can decide **offline, from a stored\n * artifact, months later**, which is a thing axe cannot do at all.\n *\n * There is no severity field. A rule that needs one to be tolerable is a rule\n * whose condition is too broad, and the fix is a better condition or no rule.\n *\n * **Why there is no contrast rule, stated here so nobody adds one badly later.**\n * The snapshot carries a resolved `color` and `background-color` per node, so a\n * check looks like four lines and would be wrong: the background a glyph is\n * actually painted on is whatever is behind it, which is a stacking question a\n * layout engine answers and a document does not. A node with a transparent\n * background over a dark ancestor, an image, a gradient, or a positioned sibling\n * all read as \"background-color: rgba(0,0,0,0)\" here. A rule that is right most\n * of the time about accessibility is worse than no rule: it gets disabled after\n * the second false alarm, and takes the four that work with it. Spec 0009\n * records the same for focus order and anything about motion.\n */\n\nexport type FindingRule =\n /** An interactive control with no accessible name. Nothing announces it. */\n | 'control-without-name'\n /** A meaningful image with neither a name nor an explicit `alt=\"\"`. */\n | 'image-without-alt'\n /** Heading levels jump forward by more than one. */\n | 'heading-level-skipped'\n /** A control inside another control. Only one of them is reachable. */\n | 'nested-interactive'\n /** An id reference that resolves to nothing inside this subject. */\n | 'dangling-reference'\n /** A control whose accessible name does not contain its visible label. */\n | 'label-mismatch'\n /** Two landmarks of one role that nothing tells apart. */\n | 'duplicate-landmark'\n /** A table with no header cells. */\n | 'table-without-headers'\n /** `tabindex` above zero, which reorders focus for the whole page. */\n | 'positive-tabindex'\n /**\n * The same string in two locales. From `compareLocales`, not from `inspect` —\n * one render cannot know whether its text was translated.\n */\n | 'untranslated'\n /** A box outside the box that contains it, in one locale and not the other. */\n | 'overflows-container';\n\n/**\n * The identity of a finding, as a string two runs can be compared on.\n *\n * A finding is *the same finding* when the same rule fires on the same node. Not\n * the same sentence: `what` quotes the text it found, so a copy edit beside a\n * control with no accessible name would read as the old defect going away and a\n * new one arriving in the same place. And not the same component either — a\n * component renders in many places, and every one of them would collapse to one.\n *\n * Small on purpose. This is what a baseline carries so that the next run can say\n * whether a defect it found was already there, and a baseline sidecar rides\n * beside a PNG in a tracked directory: a mark is a few dozen bytes and a stored\n * `Finding` is a paragraph. It can only answer membership, which is the only\n * question the next run is allowed to ask of it — the same restraint\n * `Described.components` is written under.\n */\nexport function findingMark(finding: { readonly rule: string; readonly path: string }): string {\n return `${finding.rule}@${finding.path}`;\n}\n\nexport interface Finding {\n readonly rule: FindingRule;\n\n /**\n * The band this finding would block under.\n *\n * Carried so that inspection and comparison answer to one policy. A team that\n * blocks `a11y` should not have to discover that it blocks regressions and not\n * defects.\n */\n readonly band: Band;\n\n readonly path: NodePath;\n\n /** One sentence, naming the thing rather than the rule. */\n readonly what: string;\n\n /** Spoken outside-in, from `locate` — `main → region \"Todos\" → item 2 of 3`. */\n readonly where?: string;\n\n /** The component whose JSX created the node, when provenance reached it. */\n readonly component?: string;\n readonly owners?: readonly OwnerFrame[];\n\n /**\n * The line that wrote *this element*, when a JSX runtime recorded one.\n *\n * Different from — and better than — resolving `component` through the source\n * index, which answers with where the component is *declared*. A defect is\n * rarely at a declaration: a button with no accessible name is a specific\n * element on a specific line inside that component, and that line is the edit.\n * The index stays as the fallback, because it needs no build change.\n *\n * Requires `@variance-authority/jsx-source` in the transform. Absent otherwise,\n * and absence is normal.\n */\n readonly source?: SourceLocation;\n}\n\n/** Roles that are operated. A control nobody can name is a control nobody can use. */\nconst INTERACTIVE = new Set([\n 'button', 'link', 'checkbox', 'radio', 'switch', 'textbox', 'searchbox',\n 'combobox', 'slider', 'spinbutton', 'menuitem', 'menuitemcheckbox',\n 'menuitemradio', 'tab', 'treeitem',\n]);\n\n/** Attributes whose value the aliaser rewrote, so `#extern:` means unresolved. */\nconst REFERENCE_ATTRIBUTES = new Set(['for', 'form', 'list', 'headers']);\n\nconst HEADING_TAGS: Readonly<Record<string, number>> = {\n h1: 1, h2: 2, h3: 3, h4: 4, h5: 5, h6: 6,\n};\n\n/** Regions of a page a screen reader user navigates between. */\nconst LANDMARKS = new Set([\n 'banner', 'navigation', 'main', 'complementary', 'contentinfo',\n 'region', 'form', 'search',\n]);\n\n/** Has at least one letter. A glyph is not a visible label. */\nconst HAS_LETTER = /\\p{L}/u;\n\nexport function inspect(snapshot: SemanticSnapshot): readonly Finding[] {\n const findings: Finding[] = [];\n const headings: { node: SemanticNode; level: number }[] = [];\n const landmarks = new Map<string, SemanticNode>();\n\n visit(snapshot.root, false);\n\n // Heading order is a property of the sequence, not of any one node, so it is\n // decided after the walk. Only *forward* jumps are reported: a component that\n // starts at `h3` is a component rendered inside something, not a defect, and a\n // subject is not a page.\n headings.forEach((heading, index) => {\n const previous = headings[index - 1];\n if (previous === undefined || heading.level <= previous.level + 1) return;\n\n findings.push(\n finding(\n 'heading-level-skipped',\n heading.node,\n `heading level jumps from ${previous.level} to ${heading.level}` +\n (heading.node.name !== undefined ? ` at \"${heading.node.name}\"` : ''),\n snapshot,\n ),\n );\n });\n\n return findings;\n\n function visit(node: SemanticNode, insideControl: boolean): void {\n const interactive = node.role !== undefined && INTERACTIVE.has(node.role);\n\n if (interactive && node.name === undefined) {\n findings.push(\n finding(\n 'control-without-name',\n node,\n `<${node.tag}> is a ${node.role} with no accessible name`,\n snapshot,\n ),\n );\n }\n\n if (interactive && insideControl) {\n findings.push(\n finding(\n 'nested-interactive',\n node,\n `a ${node.role} is nested inside another control; only one of the two is reachable`,\n snapshot,\n ),\n );\n }\n\n // `alt=\"\"` is the author saying \"decorative\", which is a correct answer and\n // must not be reported. A missing `alt` is the author saying nothing.\n if (node.role === 'img' && node.name === undefined && node.attributes['alt'] === undefined) {\n findings.push(\n finding(\n 'image-without-alt',\n node,\n `<${node.tag}> has an image role, no accessible name, and no alt=\"\" to say it is decorative`,\n snapshot,\n ),\n );\n }\n\n for (const [attribute, value] of Object.entries(node.attributes)) {\n if (!REFERENCE_ATTRIBUTES.has(attribute) || !value.includes('#extern:')) continue;\n\n findings.push(\n finding(\n 'dangling-reference',\n node,\n // Honest about the ambiguity the aliaser records: from inside the\n // subject, escaping the subtree and pointing at nothing look the same.\n `<${node.tag} ${attribute}> references an element that is not in this subject`,\n snapshot,\n ),\n );\n }\n\n if (node.role === 'heading') {\n const level = levelOf(node);\n if (level !== undefined) headings.push({ node, level });\n }\n\n if (node.role !== undefined && LANDMARKS.has(node.role)) {\n // Keyed by role *and* name: two `navigation` landmarks called \"Primary\"\n // and \"Footer\" are how a page is meant to be built. Two called nothing are\n // two identical entries in the landmark list.\n const key = `${node.role}\\u0000${node.name ?? ''}`;\n const first = landmarks.get(key);\n\n if (first === undefined) {\n landmarks.set(key, node);\n } else {\n findings.push(\n finding(\n 'duplicate-landmark',\n node,\n `a second ${node.role} landmark` +\n (node.name !== undefined ? ` also named \"${node.name}\"` : ' with no name') +\n ', so nothing tells the two apart in a landmark list',\n snapshot,\n ),\n );\n }\n }\n\n if (node.role === 'table' && !hasHeaders(node)) {\n findings.push(\n finding(\n 'table-without-headers',\n node,\n `<${node.tag}> is a table with no header cells, so every cell is announced without ` +\n 'the column it belongs to',\n snapshot,\n ),\n );\n }\n\n const tabindex = Number(node.attributes['tabindex']);\n if (Number.isInteger(tabindex) && tabindex > 0) {\n findings.push(\n finding(\n 'positive-tabindex',\n node,\n `<${node.tag}> has tabindex=${tabindex}, which pulls it ahead of every element in ` +\n 'the document that relies on source order',\n snapshot,\n ),\n );\n }\n\n // WCAG 2.5.3. A control whose accessible name does not contain its visible\n // label cannot be operated by voice: \"click Save\" does nothing when the\n // button reads Save and is named \"Submit form\".\n //\n // Only when the visible text carries a letter. An icon button labelled\n // `aria-label=\"Refresh\"` around a glyph is correct, and a rule that reported\n // it would fire on every icon in every design system.\n if (interactive && node.name !== undefined) {\n const visible = visibleTextOf(node);\n if (\n visible.length > 0 &&\n HAS_LETTER.test(visible) &&\n !node.name.toLowerCase().includes(visible.toLowerCase())\n ) {\n findings.push(\n finding(\n 'label-mismatch',\n node,\n `reads \"${visible}\" and is named \"${node.name}\", so a voice command using the ` +\n 'visible words does not reach it',\n snapshot,\n ),\n );\n }\n }\n\n for (const child of node.children) visit(child, insideControl || interactive);\n }\n}\n\nfunction hasHeaders(node: SemanticNode): boolean {\n if (node.role === 'columnheader' || node.role === 'rowheader') return true;\n return node.children.some(hasHeaders);\n}\n\n/**\n * Text a sighted user reads, normalized to single spaces.\n *\n * `aria-hidden` subtrees are excluded, which is what makes the icon-button case\n * work: the glyph is hidden, so the visible text is empty and `label-mismatch`\n * has nothing to compare.\n */\nfunction visibleTextOf(node: SemanticNode): string {\n if (node.state?.['hidden'] === true) return '';\n\n const own = node.text ?? '';\n const children = node.children.map(visibleTextOf).join(' ');\n\n return `${own} ${children}`.replace(/\\s+/g, ' ').trim();\n}\n\nfunction levelOf(node: SemanticNode): number | undefined {\n // `aria-level` wins, because it is what an assistive technology reads. The tag\n // is the fallback, and a `role=\"heading\"` with neither is not a level at all.\n const declared = node.state?.['level'];\n if (typeof declared === 'number') return declared;\n if (typeof declared === 'string' && /^\\d+$/.test(declared)) return Number(declared);\n\n return HEADING_TAGS[node.tag];\n}\n\nfunction finding(\n rule: FindingRule,\n node: SemanticNode,\n what: string,\n snapshot: SemanticSnapshot,\n): Finding {\n const where = locate(snapshot.root, node.path).where;\n const component = node.provenance?.createdBy ?? node.provenance?.owners[0]?.name;\n\n return {\n rule,\n band: 'a11y',\n path: node.path,\n what,\n ...(where !== '' ? { where } : {}),\n ...(component !== undefined ? { component } : {}),\n ...(node.provenance ? { owners: node.provenance.owners } : {}),\n ...(node.provenance?.source ? { source: node.provenance.source } : {}),\n };\n}\n\nexport interface InspectionReportOptions {\n /** Component → file, so a finding names an edit rather than an identifier. */\n readonly source?: SourceIndex;\n}\n\n/**\n * Findings as the thing a reviewer or an agent reads.\n *\n * Same shape as `summarizeAdjudication`: what, where, which file. A finding a\n * reader has to go and locate is a finding that gets skipped.\n */\nexport function summarizeFindings(\n findings: readonly Finding[],\n options: InspectionReportOptions = {},\n): string {\n if (findings.length === 0) return 'no findings.';\n\n const byRule = new Map<FindingRule, number>();\n for (const found of findings) byRule.set(found.rule, (byRule.get(found.rule) ?? 0) + 1);\n\n const header =\n `${findings.length} finding${findings.length === 1 ? '' : 's'}: ` +\n [...byRule].map(([rule, count]) => `${count} ${rule}`).join(', ');\n\n const lines = findings.flatMap((found) => {\n // The element's own line wins over the component's declaration. Both are\n // `file:line`, so the reader cannot tell them apart and does not need to —\n // either one opens at something worth editing, and the recorded one opens at\n // the element the finding is actually about.\n const declared =\n found.component !== undefined && options.source\n ? resolveSource(found.component, options.source)\n : null;\n const where =\n found.source !== undefined\n ? `${found.source.file}:${String(found.source.line)}`\n : declared\n ? formatSource(declared)\n : '';\n\n const attribution = [found.component, where === '' ? undefined : where]\n .filter((part): part is string => part !== undefined)\n .join(' ');\n\n return [\n ` [${found.rule}] ${found.what}`,\n found.where !== undefined ? ` in ${found.where}` : null,\n attribution === '' ? null : ` ${attribution}`,\n ].filter((line): line is string => line !== null);\n });\n\n return [header, ...lines].join('\\n');\n}\n"]}
@@ -0,0 +1,146 @@
1
+ import type { Band } from '../compare/band.js';
2
+ import type { Docket, DocketEntry } from './docket.js';
3
+ import type { AggregateImpact } from '../compare/impact.js';
4
+ import { type SourceIndex } from '../attribute/source.js';
5
+ import type { Verdict } from './verdict.js';
6
+ /**
7
+ * Intent, and the adjudication it enables (spec §7.2).
8
+ *
9
+ * Everything up to here answers *what changed*. That is not the question a review
10
+ * asks. A real branch carries several deliberate changes at once — a token, a
11
+ * component, a page — and the only thing anyone wants to know is **which of the
12
+ * changes in front of me is not one I meant to make**.
13
+ *
14
+ * A tool that reports "23 screenshots differ" cannot answer that, and neither can
15
+ * a tool that reports 5 roots without knowing which were intended. The missing
16
+ * half is a declaration, written *before* the diff is read:
17
+ *
18
+ * > this branch changes `--va-color-accent` and `Button`
19
+ *
20
+ * Everything matching the declaration is `authorized` and needs no attention.
21
+ * Everything else is the finding — and on a branch with four intended changes,
22
+ * the accidental fifth is otherwise buried among them by construction.
23
+ *
24
+ * The declaration is deliberately about *roots*, not subjects or screenshots. A
25
+ * root is stable across subjects and across builds, so an approval recorded
26
+ * against one keeps applying as new subjects start consuming it — which is what
27
+ * makes it a contract rather than a snapshot of one build's output.
28
+ */
29
+ export interface IntentClaim {
30
+ /**
31
+ * Root id this claim covers, e.g. `token:--va-color-accent`, `component:Button`.
32
+ *
33
+ * Matched exactly. A prefix or fuzzy match would let a claim about one token
34
+ * silently authorize a change to another, which is the one thing a declaration
35
+ * must never do.
36
+ */
37
+ readonly root: string;
38
+ /** Why. Not used for matching; carried into the report so review has context. */
39
+ readonly reason: string;
40
+ /**
41
+ * Cap on how far the change may reach.
42
+ *
43
+ * The most useful field in practice. "I meant to restyle `Button`" is almost
44
+ * always true; "I meant to restyle `Button` across 40 subjects" is the part
45
+ * nobody checks. Exceeding a declared bound is not a violation — the change is
46
+ * still the one that was intended — but it is no longer authorized silently.
47
+ */
48
+ readonly maxSubjects?: number;
49
+ /**
50
+ * Bands the author expects. A change that also reshapes the tree when only a
51
+ * value change was declared is worth surfacing even though the root matches.
52
+ */
53
+ readonly bands?: readonly Band[];
54
+ /** Impacts the author expects. Declaring `paint` and reflowing is a surprise. */
55
+ readonly impact?: readonly AggregateImpact[];
56
+ }
57
+ export interface Intent {
58
+ readonly claims: readonly IntentClaim[];
59
+ }
60
+ /**
61
+ * What a project decides in advance, independent of any one branch (spec §7.2).
62
+ *
63
+ * Policy is the difference between "not declared" and "not allowed". An
64
+ * undeclared token change is a review; an undeclared change to a protected
65
+ * component is a violation, whatever the branch says about it.
66
+ */
67
+ export interface Policy {
68
+ /**
69
+ * Bands that block when undeclared. Others advise.
70
+ *
71
+ * Empty means nothing blocks, which is a legitimate starting position for a
72
+ * team adopting this — and one worth being able to express, since a tool that
73
+ * blocks by default on day one gets switched off in week one.
74
+ */
75
+ readonly blocking?: readonly Band[];
76
+ /** Any undeclared change here is a violation, whatever its band. */
77
+ readonly protectedComponents?: readonly string[];
78
+ /** Roots that never need declaring. Escape hatch for known-noisy areas. */
79
+ readonly alwaysAuthorized?: readonly string[];
80
+ }
81
+ export interface Adjudication {
82
+ readonly rootId: string;
83
+ readonly label: string;
84
+ readonly verdict: Verdict;
85
+ /** The claim that authorized it, when one did. */
86
+ readonly claim?: IntentClaim;
87
+ /** One sentence a human or agent can act on without reading the diff. */
88
+ readonly because: string;
89
+ readonly entry: DocketEntry;
90
+ }
91
+ export interface Adjudicated {
92
+ readonly adjudications: readonly Adjudication[];
93
+ /**
94
+ * Claims that matched no root.
95
+ *
96
+ * Declared and did not happen. Easy to omit and worth surfacing: on a branch
97
+ * that says it changes a token and does not, either the edit was lost or the
98
+ * declaration is stale, and both are worth a sentence before merge.
99
+ */
100
+ readonly undelivered: readonly IntentClaim[];
101
+ /** Worst verdict present. What CI reports. */
102
+ readonly verdict: Verdict;
103
+ readonly authorized: number;
104
+ readonly needsReview: number;
105
+ readonly violations: number;
106
+ }
107
+ /**
108
+ * FIXME: nothing shipped calls this, and the reason is no longer the one it used
109
+ * to be. Somewhere to declare a claim with this shape now exists — `variance
110
+ * adjudicate --claims <path>` and the `variance_adjudicate` MCP tool — but they
111
+ * resolve against a run report through `adjudicateRun`, which answers *what you
112
+ * declared against what moved* and deliberately changes no verdict.
113
+ *
114
+ * This one is the other half: it takes a `Docket`, so it reads bands and returns
115
+ * `authorized` / `needs-review` / `violation` — a claim that can turn a red run
116
+ * green. That is a policy decision with an exit code behind it, and shipping it
117
+ * needs the thing neither caller has: somewhere durable to declare a claim
118
+ * *before* the branch that exercises it, so the authorization is reviewable
119
+ * separately from the change it authorizes. A claim supplied by the same agent
120
+ * whose work it excuses cannot do that job.
121
+ */
122
+ export declare function adjudicate(docket: Docket, intent: Intent, policy?: Policy): Adjudicated;
123
+ export interface ReportOptions {
124
+ /**
125
+ * Component → file, so a finding names an edit rather than an identifier.
126
+ *
127
+ * Optional, and the report degrades to component names without it — which is
128
+ * what it said before this existed. Better a coarser sentence than a confident
129
+ * wrong path.
130
+ */
131
+ readonly source?: SourceIndex;
132
+ }
133
+ /**
134
+ * Render an adjudication as the thing a reviewer or agent actually reads.
135
+ *
136
+ * The shape follows what a report has to defeat. "Looks right, merge" happens
137
+ * when the output is a picture, so this is a sentence with a cause in it. "100
138
+ * changes? merge" happens when the output is a list as long as the change is
139
+ * wide, so authorized roots collapse to a count and only findings get a line.
140
+ *
141
+ * Each finding carries three things in order: *what* changed, *where* it is, and
142
+ * *which file* to open. Anything less and the reader has to go and find out —
143
+ * which, at review time, means they will not.
144
+ */
145
+ export declare function summarizeAdjudication(result: Adjudicated, options?: ReportOptions): string;
146
+ //# sourceMappingURL=intent.d.ts.map
@@ -0,0 +1,150 @@
1
+ import { formatSource, resolveSource } from '../attribute/source.js';
2
+ /**
3
+ * FIXME: nothing shipped calls this, and the reason is no longer the one it used
4
+ * to be. Somewhere to declare a claim with this shape now exists — `variance
5
+ * adjudicate --claims <path>` and the `variance_adjudicate` MCP tool — but they
6
+ * resolve against a run report through `adjudicateRun`, which answers *what you
7
+ * declared against what moved* and deliberately changes no verdict.
8
+ *
9
+ * This one is the other half: it takes a `Docket`, so it reads bands and returns
10
+ * `authorized` / `needs-review` / `violation` — a claim that can turn a red run
11
+ * green. That is a policy decision with an exit code behind it, and shipping it
12
+ * needs the thing neither caller has: somewhere durable to declare a claim
13
+ * *before* the branch that exercises it, so the authorization is reviewable
14
+ * separately from the change it authorizes. A claim supplied by the same agent
15
+ * whose work it excuses cannot do that job.
16
+ */
17
+ export function adjudicate(docket, intent, policy = {}) {
18
+ const claimed = new Map(intent.claims.map((claim) => [claim.root, claim]));
19
+ const matched = new Set();
20
+ const adjudications = [];
21
+ for (const entry of docket.entries) {
22
+ const claim = claimed.get(entry.rootId);
23
+ if (claim)
24
+ matched.add(entry.rootId);
25
+ adjudications.push(adjudicateEntry(entry, claim, policy));
26
+ }
27
+ const undelivered = intent.claims.filter((claim) => !matched.has(claim.root));
28
+ const counts = {
29
+ authorized: adjudications.filter((a) => a.verdict === 'authorized').length,
30
+ needsReview: adjudications.filter((a) => a.verdict === 'needs-review').length,
31
+ violations: adjudications.filter((a) => a.verdict === 'violation').length,
32
+ };
33
+ return {
34
+ adjudications,
35
+ undelivered,
36
+ verdict: worst(adjudications.map((a) => a.verdict)),
37
+ ...counts,
38
+ };
39
+ }
40
+ function adjudicateEntry(entry, claim, policy) {
41
+ const base = { rootId: entry.rootId, label: entry.label, entry };
42
+ if (policy.alwaysAuthorized?.includes(entry.rootId)) {
43
+ return {
44
+ ...base,
45
+ verdict: 'authorized',
46
+ because: `\`${entry.rootId}\` is always authorized by project policy`,
47
+ };
48
+ }
49
+ // Protection is checked before the claim, deliberately. A protected component
50
+ // is protected *from* the person editing it — letting a branch authorize its
51
+ // own change to one would make the setting decorative.
52
+ const protectedHit = entry.components.find((component) => policy.protectedComponents?.includes(component.name));
53
+ if (protectedHit && !claim) {
54
+ return {
55
+ ...base,
56
+ verdict: 'violation',
57
+ because: `\`${protectedHit.name}\` is a protected component and this change was not declared; ` +
58
+ `${entry.kind} \`${entry.label}\` reached it across ${entry.subjectCount} subject(s)`,
59
+ };
60
+ }
61
+ if (!claim) {
62
+ const blocks = policy.blocking?.includes(entry.band) ?? false;
63
+ return {
64
+ ...base,
65
+ verdict: blocks ? 'violation' : 'needs-review',
66
+ because: `undeclared ${entry.kind} change: \`${entry.label}\` ` +
67
+ `(${entry.band}/${entry.impact}) reached ${entry.subjectCount} subject(s)` +
68
+ (entry.structureIntact ? '' : ', and reshaped the tree'),
69
+ };
70
+ }
71
+ const exceeded = claim.maxSubjects !== undefined && entry.subjectCount > claim.maxSubjects;
72
+ const unexpectedBand = claim.bands !== undefined && !claim.bands.includes(entry.band);
73
+ const unexpectedImpact = claim.impact !== undefined && !claim.impact.includes(entry.impact);
74
+ if (exceeded || unexpectedBand || unexpectedImpact) {
75
+ const surprises = [
76
+ exceeded ? `reached ${entry.subjectCount} subjects, declared at most ${claim.maxSubjects}` : null,
77
+ unexpectedBand ? `banded ${entry.band}, declared ${claim.bands.join('/')}` : null,
78
+ unexpectedImpact ? `impact ${entry.impact}, declared ${claim.impact.join('/')}` : null,
79
+ ].filter((part) => part !== null);
80
+ return {
81
+ ...base,
82
+ verdict: 'needs-review',
83
+ claim,
84
+ // Not a violation: the change *is* the declared one. What failed is the
85
+ // author's estimate of its blast radius, which is exactly the thing worth
86
+ // showing them rather than blocking on.
87
+ because: `declared (${claim.reason}), but ${surprises.join('; ')}`,
88
+ };
89
+ }
90
+ return {
91
+ ...base,
92
+ verdict: 'authorized',
93
+ claim,
94
+ because: `declared: ${claim.reason}`,
95
+ };
96
+ }
97
+ /**
98
+ * Render an adjudication as the thing a reviewer or agent actually reads.
99
+ *
100
+ * The shape follows what a report has to defeat. "Looks right, merge" happens
101
+ * when the output is a picture, so this is a sentence with a cause in it. "100
102
+ * changes? merge" happens when the output is a list as long as the change is
103
+ * wide, so authorized roots collapse to a count and only findings get a line.
104
+ *
105
+ * Each finding carries three things in order: *what* changed, *where* it is, and
106
+ * *which file* to open. Anything less and the reader has to go and find out —
107
+ * which, at review time, means they will not.
108
+ */
109
+ export function summarizeAdjudication(result, options = {}) {
110
+ const notable = result.adjudications.filter((a) => a.verdict !== 'authorized');
111
+ const header = `${result.adjudications.length} root(s): ` +
112
+ `${result.authorized} authorized, ${result.needsReview} to review, ` +
113
+ `${result.violations} violation(s).`;
114
+ const lines = notable
115
+ .sort((a, b) => (a.verdict === 'violation' ? -1 : b.verdict === 'violation' ? 1 : 0))
116
+ .flatMap((a) => {
117
+ const roots = a.entry.components.filter((component) => component.role === 'root');
118
+ const files = roots
119
+ .map((component) => (options.source ? resolveSource(component.name, options.source) : null))
120
+ .filter((resolution) => resolution !== null)
121
+ .map(formatSource);
122
+ const named = roots.map((component) => component.name).join(', ');
123
+ const head = ` [${a.verdict}] ${a.label}${named ? ` — ${named}` : ''}`;
124
+ // Location comes from the deltas rather than the entry: a root spans
125
+ // subjects, and "where" is only meaningful for a place. The first one is
126
+ // representative and the count says how many others there are.
127
+ const places = a.entry.places;
128
+ const place = places.length === 0
129
+ ? null
130
+ : ` in ${places[0]}${places.length > 1 ? ` (+${places.length - 1} more places)` : ''}`;
131
+ return [
132
+ head,
133
+ ` ${a.because}`,
134
+ place,
135
+ files.length > 0 ? ` ${files.join(' | ')}` : null,
136
+ ].filter((line) => line !== null);
137
+ });
138
+ const undelivered = result.undelivered.map((claim) => ` [undelivered] ${claim.root} — declared (${claim.reason}) but nothing changed`);
139
+ return [header, ...lines, ...undelivered].join('\n');
140
+ }
141
+ function worst(verdicts) {
142
+ if (verdicts.includes('violation'))
143
+ return 'violation';
144
+ if (verdicts.includes('needs-review'))
145
+ return 'needs-review';
146
+ if (verdicts.includes('authorized'))
147
+ return 'authorized';
148
+ return 'unchanged';
149
+ }
150
+ //# sourceMappingURL=intent.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"intent.js","sourceRoot":"","sources":["../../src/judge/intent.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Band } from '../compare/band.js';\nimport type { Docket, DocketEntry } from './docket.js';\nimport type { AggregateImpact } from '../compare/impact.js';\nimport { formatSource, resolveSource, type SourceIndex } from '../attribute/source.js';\nimport type { Verdict } from './verdict.js';\n\n/**\n * Intent, and the adjudication it enables (spec §7.2).\n *\n * Everything up to here answers *what changed*. That is not the question a review\n * asks. A real branch carries several deliberate changes at once — a token, a\n * component, a page — and the only thing anyone wants to know is **which of the\n * changes in front of me is not one I meant to make**.\n *\n * A tool that reports \"23 screenshots differ\" cannot answer that, and neither can\n * a tool that reports 5 roots without knowing which were intended. The missing\n * half is a declaration, written *before* the diff is read:\n *\n * > this branch changes `--va-color-accent` and `Button`\n *\n * Everything matching the declaration is `authorized` and needs no attention.\n * Everything else is the finding — and on a branch with four intended changes,\n * the accidental fifth is otherwise buried among them by construction.\n *\n * The declaration is deliberately about *roots*, not subjects or screenshots. A\n * root is stable across subjects and across builds, so an approval recorded\n * against one keeps applying as new subjects start consuming it — which is what\n * makes it a contract rather than a snapshot of one build's output.\n */\n\nexport interface IntentClaim {\n /**\n * Root id this claim covers, e.g. `token:--va-color-accent`, `component:Button`.\n *\n * Matched exactly. A prefix or fuzzy match would let a claim about one token\n * silently authorize a change to another, which is the one thing a declaration\n * must never do.\n */\n readonly root: string;\n\n /** Why. Not used for matching; carried into the report so review has context. */\n readonly reason: string;\n\n /**\n * Cap on how far the change may reach.\n *\n * The most useful field in practice. \"I meant to restyle `Button`\" is almost\n * always true; \"I meant to restyle `Button` across 40 subjects\" is the part\n * nobody checks. Exceeding a declared bound is not a violation — the change is\n * still the one that was intended — but it is no longer authorized silently.\n */\n readonly maxSubjects?: number;\n\n /**\n * Bands the author expects. A change that also reshapes the tree when only a\n * value change was declared is worth surfacing even though the root matches.\n */\n readonly bands?: readonly Band[];\n\n /** Impacts the author expects. Declaring `paint` and reflowing is a surprise. */\n readonly impact?: readonly AggregateImpact[];\n}\n\nexport interface Intent {\n readonly claims: readonly IntentClaim[];\n}\n\n/**\n * What a project decides in advance, independent of any one branch (spec §7.2).\n *\n * Policy is the difference between \"not declared\" and \"not allowed\". An\n * undeclared token change is a review; an undeclared change to a protected\n * component is a violation, whatever the branch says about it.\n */\nexport interface Policy {\n /**\n * Bands that block when undeclared. Others advise.\n *\n * Empty means nothing blocks, which is a legitimate starting position for a\n * team adopting this — and one worth being able to express, since a tool that\n * blocks by default on day one gets switched off in week one.\n */\n readonly blocking?: readonly Band[];\n\n /** Any undeclared change here is a violation, whatever its band. */\n readonly protectedComponents?: readonly string[];\n\n /** Roots that never need declaring. Escape hatch for known-noisy areas. */\n readonly alwaysAuthorized?: readonly string[];\n}\n\nexport interface Adjudication {\n readonly rootId: string;\n readonly label: string;\n readonly verdict: Verdict;\n /** The claim that authorized it, when one did. */\n readonly claim?: IntentClaim;\n /** One sentence a human or agent can act on without reading the diff. */\n readonly because: string;\n readonly entry: DocketEntry;\n}\n\nexport interface Adjudicated {\n readonly adjudications: readonly Adjudication[];\n\n /**\n * Claims that matched no root.\n *\n * Declared and did not happen. Easy to omit and worth surfacing: on a branch\n * that says it changes a token and does not, either the edit was lost or the\n * declaration is stale, and both are worth a sentence before merge.\n */\n readonly undelivered: readonly IntentClaim[];\n\n /** Worst verdict present. What CI reports. */\n readonly verdict: Verdict;\n readonly authorized: number;\n readonly needsReview: number;\n readonly violations: number;\n}\n\n/**\n * FIXME: nothing shipped calls this, and the reason is no longer the one it used\n * to be. Somewhere to declare a claim with this shape now exists — `variance\n * adjudicate --claims <path>` and the `variance_adjudicate` MCP tool — but they\n * resolve against a run report through `adjudicateRun`, which answers *what you\n * declared against what moved* and deliberately changes no verdict.\n *\n * This one is the other half: it takes a `Docket`, so it reads bands and returns\n * `authorized` / `needs-review` / `violation` — a claim that can turn a red run\n * green. That is a policy decision with an exit code behind it, and shipping it\n * needs the thing neither caller has: somewhere durable to declare a claim\n * *before* the branch that exercises it, so the authorization is reviewable\n * separately from the change it authorizes. A claim supplied by the same agent\n * whose work it excuses cannot do that job.\n */\nexport function adjudicate(\n docket: Docket,\n intent: Intent,\n policy: Policy = {},\n): Adjudicated {\n const claimed = new Map(intent.claims.map((claim) => [claim.root, claim]));\n const matched = new Set<string>();\n const adjudications: Adjudication[] = [];\n\n for (const entry of docket.entries) {\n const claim = claimed.get(entry.rootId);\n if (claim) matched.add(entry.rootId);\n\n adjudications.push(adjudicateEntry(entry, claim, policy));\n }\n\n const undelivered = intent.claims.filter((claim) => !matched.has(claim.root));\n\n const counts = {\n authorized: adjudications.filter((a) => a.verdict === 'authorized').length,\n needsReview: adjudications.filter((a) => a.verdict === 'needs-review').length,\n violations: adjudications.filter((a) => a.verdict === 'violation').length,\n };\n\n return {\n adjudications,\n undelivered,\n verdict: worst(adjudications.map((a) => a.verdict)),\n ...counts,\n };\n}\n\nfunction adjudicateEntry(\n entry: DocketEntry,\n claim: IntentClaim | undefined,\n policy: Policy,\n): Adjudication {\n const base = { rootId: entry.rootId, label: entry.label, entry };\n\n if (policy.alwaysAuthorized?.includes(entry.rootId)) {\n return {\n ...base,\n verdict: 'authorized',\n because: `\\`${entry.rootId}\\` is always authorized by project policy`,\n };\n }\n\n // Protection is checked before the claim, deliberately. A protected component\n // is protected *from* the person editing it — letting a branch authorize its\n // own change to one would make the setting decorative.\n const protectedHit = entry.components.find((component) =>\n policy.protectedComponents?.includes(component.name),\n );\n if (protectedHit && !claim) {\n return {\n ...base,\n verdict: 'violation',\n because:\n `\\`${protectedHit.name}\\` is a protected component and this change was not declared; ` +\n `${entry.kind} \\`${entry.label}\\` reached it across ${entry.subjectCount} subject(s)`,\n };\n }\n\n if (!claim) {\n const blocks = policy.blocking?.includes(entry.band) ?? false;\n return {\n ...base,\n verdict: blocks ? 'violation' : 'needs-review',\n because:\n `undeclared ${entry.kind} change: \\`${entry.label}\\` ` +\n `(${entry.band}/${entry.impact}) reached ${entry.subjectCount} subject(s)` +\n (entry.structureIntact ? '' : ', and reshaped the tree'),\n };\n }\n\n const exceeded = claim.maxSubjects !== undefined && entry.subjectCount > claim.maxSubjects;\n const unexpectedBand = claim.bands !== undefined && !claim.bands.includes(entry.band);\n const unexpectedImpact = claim.impact !== undefined && !claim.impact.includes(entry.impact);\n\n if (exceeded || unexpectedBand || unexpectedImpact) {\n const surprises = [\n exceeded ? `reached ${entry.subjectCount} subjects, declared at most ${claim.maxSubjects}` : null,\n unexpectedBand ? `banded ${entry.band}, declared ${claim.bands!.join('/')}` : null,\n unexpectedImpact ? `impact ${entry.impact}, declared ${claim.impact!.join('/')}` : null,\n ].filter((part): part is string => part !== null);\n\n return {\n ...base,\n verdict: 'needs-review',\n claim,\n // Not a violation: the change *is* the declared one. What failed is the\n // author's estimate of its blast radius, which is exactly the thing worth\n // showing them rather than blocking on.\n because: `declared (${claim.reason}), but ${surprises.join('; ')}`,\n };\n }\n\n return {\n ...base,\n verdict: 'authorized',\n claim,\n because: `declared: ${claim.reason}`,\n };\n}\n\nexport interface ReportOptions {\n /**\n * Component → file, so a finding names an edit rather than an identifier.\n *\n * Optional, and the report degrades to component names without it — which is\n * what it said before this existed. Better a coarser sentence than a confident\n * wrong path.\n */\n readonly source?: SourceIndex;\n}\n\n/**\n * Render an adjudication as the thing a reviewer or agent actually reads.\n *\n * The shape follows what a report has to defeat. \"Looks right, merge\" happens\n * when the output is a picture, so this is a sentence with a cause in it. \"100\n * changes? merge\" happens when the output is a list as long as the change is\n * wide, so authorized roots collapse to a count and only findings get a line.\n *\n * Each finding carries three things in order: *what* changed, *where* it is, and\n * *which file* to open. Anything less and the reader has to go and find out —\n * which, at review time, means they will not.\n */\nexport function summarizeAdjudication(result: Adjudicated, options: ReportOptions = {}): string {\n const notable = result.adjudications.filter((a) => a.verdict !== 'authorized');\n\n const header =\n `${result.adjudications.length} root(s): ` +\n `${result.authorized} authorized, ${result.needsReview} to review, ` +\n `${result.violations} violation(s).`;\n\n const lines = notable\n .sort((a, b) => (a.verdict === 'violation' ? -1 : b.verdict === 'violation' ? 1 : 0))\n .flatMap((a) => {\n const roots = a.entry.components.filter((component) => component.role === 'root');\n\n const files = roots\n .map((component) => (options.source ? resolveSource(component.name, options.source) : null))\n .filter((resolution): resolution is NonNullable<typeof resolution> => resolution !== null)\n .map(formatSource);\n\n const named = roots.map((component) => component.name).join(', ');\n const head = ` [${a.verdict}] ${a.label}${named ? ` — ${named}` : ''}`;\n\n // Location comes from the deltas rather than the entry: a root spans\n // subjects, and \"where\" is only meaningful for a place. The first one is\n // representative and the count says how many others there are.\n const places = a.entry.places;\n const place =\n places.length === 0\n ? null\n : ` in ${places[0]}${places.length > 1 ? ` (+${places.length - 1} more places)` : ''}`;\n\n return [\n head,\n ` ${a.because}`,\n place,\n files.length > 0 ? ` ${files.join(' | ')}` : null,\n ].filter((line): line is string => line !== null);\n });\n\n const undelivered = result.undelivered.map(\n (claim) => ` [undelivered] ${claim.root} — declared (${claim.reason}) but nothing changed`,\n );\n\n return [header, ...lines, ...undelivered].join('\\n');\n}\n\nfunction worst(verdicts: readonly Verdict[]): Verdict {\n if (verdicts.includes('violation')) return 'violation';\n if (verdicts.includes('needs-review')) return 'needs-review';\n if (verdicts.includes('authorized')) return 'authorized';\n return 'unchanged';\n}\n"]}