@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,111 @@
1
+ import type { NodePath, SemanticSnapshot } from '../format/snapshot.js';
2
+ import type { Finding } from './inspect.js';
3
+ /**
4
+ * One subject, two locales.
5
+ *
6
+ * The join a pixel differ cannot make. A baseline image and a message catalogue
7
+ * have nothing in common — there is no key that relates the string `checkout.cta`
8
+ * to a region of a PNG — so every product in the category answers a locale
9
+ * question by taking N times as many screenshots and asking a human to look at
10
+ * all of them. The document has the strings *and* the boxes, addressed to the
11
+ * same nodes, so the two questions a localized UI actually raises are arithmetic:
12
+ *
13
+ * 1. **Did this string get translated?** Identical text in two locales, in a
14
+ * subject where other text did change.
15
+ * 2. **Did the translation break the layout?** A box that fits its container in
16
+ * one locale and overflows it in another.
17
+ *
18
+ * **This is not a regression comparison and must not be read as one.** Both
19
+ * renders are correct. `diffSnapshots` answers "did this change since the
20
+ * baseline"; this answers "do these two agree about everything except their
21
+ * language", which is a different question with a different failure mode. So it
22
+ * produces `Finding`s — the same type inspection produces, for the same reason:
23
+ * a defect in a render, not a change to one.
24
+ *
25
+ * **What is deliberately not here.** No expansion threshold. "German is 35%
26
+ * longer" is a rule of thumb, and a tool that fails a build on a ratio is a tool
27
+ * whose ratio gets raised until it stops failing. The growth is *measured* and
28
+ * returned; only overflow — a box outside the box that contains it, which is a
29
+ * fact about two rectangles — is a finding.
30
+ */
31
+ export interface LocaleComparison {
32
+ readonly subject: string;
33
+ /** Whatever the caller calls the two renders. This module reads no locale tags. */
34
+ readonly base: string;
35
+ readonly other: string;
36
+ /** Text nodes that differ. Evidence that translation happened at all. */
37
+ readonly translated: number;
38
+ /** Text nodes carrying letters that are byte-identical in both. */
39
+ readonly identical: number;
40
+ readonly findings: readonly Finding[];
41
+ /**
42
+ * The largest width growth observed, when both sides carried layout.
43
+ *
44
+ * Reported, never judged. A reviewer deciding whether a locale is safe wants
45
+ * the number and the component; a build deciding it from a threshold wants an
46
+ * argument nobody has made.
47
+ */
48
+ readonly expansion?: {
49
+ readonly ratio: number;
50
+ readonly path: NodePath;
51
+ readonly component?: string;
52
+ };
53
+ /**
54
+ * `false` when either side lacked rects, so overflow could not be decided.
55
+ *
56
+ * The same rule as `unobserved`: a question that was not asked has no answer,
57
+ * and "no overflow findings" must not be readable as "nothing overflows" when
58
+ * the profile had no layout engine.
59
+ */
60
+ readonly layoutObserved: boolean;
61
+ /**
62
+ * What the two trees did not have in common, and therefore what was not asked.
63
+ *
64
+ * {@link pairByPosition} refuses to guess across a structural difference: a
65
+ * node whose tag moved takes its whole subtree out of the comparison, and
66
+ * children past the shorter of the two lists are never reached. Both are the
67
+ * right call — a plural form with an extra element is not evidence about
68
+ * translation — and both make this function return **fewer findings**, which
69
+ * is the one direction a partial answer must never be reported in silently.
70
+ *
71
+ * A German plural that adds a `<span>`, or a locale whose date renders as
72
+ * `<time>` where English renders `<span>`, drops real strings out of the
73
+ * comparison. Without this field the run looks *cleaner* for it: zero
74
+ * untranslated strings, because zero strings were examined.
75
+ *
76
+ * Always present, never absent when the trees agree. `{ base: 0, other: 0 }`
77
+ * says the trees were walked and matched; a missing field would say nobody
78
+ * counted, and those are the two things this project exists to keep apart.
79
+ */
80
+ readonly uncompared: Uncompared;
81
+ }
82
+ export interface Uncompared {
83
+ /** Nodes in the base render that nothing in the other render was paired with. */
84
+ readonly base: number;
85
+ /** Nodes in the other render that nothing in the base render was paired with. */
86
+ readonly other: number;
87
+ /**
88
+ * Where pairing stopped, as paths in the *base* render, in document order.
89
+ *
90
+ * A count says the answer is partial; a path says where to look. Reported in
91
+ * the base render's addresses because that is the side a reader has a
92
+ * baseline for — the other locale's paths past a divergence are addresses in
93
+ * a tree nothing here compared.
94
+ */
95
+ readonly divergedAt: readonly NodePath[];
96
+ }
97
+ export interface LocaleOptions {
98
+ /**
99
+ * Slack in pixels before a box counts as outside its container.
100
+ *
101
+ * Not a tolerance on the finding — a tolerance on *rounding*. Sub-pixel layout
102
+ * routinely puts a child a fraction outside its parent's rounded rect, and
103
+ * reporting that as overflow would bury the real ones.
104
+ */
105
+ readonly slack?: number;
106
+ }
107
+ export declare function compareLocales(base: SemanticSnapshot, other: SemanticSnapshot, labels: {
108
+ readonly base: string;
109
+ readonly other: string;
110
+ }, options?: LocaleOptions): LocaleComparison;
111
+ //# sourceMappingURL=locale.d.ts.map
@@ -0,0 +1,247 @@
1
+ import { locate } from '../attribute/locate.js';
2
+ /** Has at least one letter. `€ 2,400.00` and `—` are not strings anyone translates. */
3
+ const TRANSLATABLE = /\p{L}/u;
4
+ /**
5
+ * Attributes that carry prose, and are therefore translated.
6
+ *
7
+ * The list matters more than it looks. The first run of this against a real
8
+ * panel found **nothing**, because the only untranslated string in the fixture
9
+ * was a `title` — and the rule was reading text nodes only. In a real product
10
+ * the strings most often left behind are exactly these: an `aria-label` on an
11
+ * icon button, a `placeholder`, an `alt`. They render no pixel of their own,
12
+ * which is why they are forgotten, and why no image comparison at any tolerance
13
+ * has ever reported one.
14
+ */
15
+ const TRANSLATABLE_ATTRIBUTES = ['title', 'alt', 'placeholder'];
16
+ export function compareLocales(base, other, labels, options = {}) {
17
+ if (base.subject.id !== other.subject.id) {
18
+ throw new Error(`refusing to compare locales of different subjects: ${base.subject.id} vs ${other.subject.id}`);
19
+ }
20
+ const slack = options.slack ?? 1;
21
+ const { pairs, uncompared } = pairByPosition(base.root, other.root);
22
+ const findings = [];
23
+ const identical = [];
24
+ let translated = 0;
25
+ let layoutObserved = false;
26
+ let expansion;
27
+ for (const { before, after, container } of pairs) {
28
+ // Text, then the accessible name and description, then the prose-bearing
29
+ // attributes. All four are strings a translator is handed and all four are
30
+ // invisible to a camera in different ways: a name is announced and never
31
+ // painted, a `title` appears only on hover, an `alt` only when the image
32
+ // does not load.
33
+ for (const [what, was, is] of stringsOf(before, after)) {
34
+ if (was !== is) {
35
+ translated += 1;
36
+ }
37
+ else if (TRANSLATABLE.test(is)) {
38
+ identical.push({ node: after, text: is, what });
39
+ }
40
+ }
41
+ if (before.rect === undefined || after.rect === undefined)
42
+ continue;
43
+ layoutObserved = true;
44
+ if (before.rect.width > 0) {
45
+ const ratio = after.rect.width / before.rect.width;
46
+ if (expansion === undefined || ratio > expansion.ratio) {
47
+ expansion = { ratio, path: after.path, ...component(after) };
48
+ }
49
+ }
50
+ // Overflow is judged in the *other* locale against this node's container
51
+ // there, and only where the base render fitted. A layout that was already
52
+ // broken is a real problem and is not this one: reporting it would put a
53
+ // pre-existing bug in front of whoever is reviewing a translation, in every
54
+ // locale, every time.
55
+ if (container === undefined)
56
+ continue;
57
+ const outsideNow = after.rect.x + after.rect.width;
58
+ const insideNow = container.after.rect.x + container.after.rect.width;
59
+ const outsideBefore = before.rect.x + before.rect.width;
60
+ const insideBefore = container.before.rect.x + container.before.rect.width;
61
+ if (outsideNow > insideNow + slack && outsideBefore <= insideBefore + slack) {
62
+ findings.push(finding('overflows-container', 'geometry', after, other, `${describe(after)} is ${Math.round(outsideNow - insideNow)}px wider than what ` +
63
+ `contains it in ${labels.other}, and fitted in ${labels.base}`));
64
+ }
65
+ }
66
+ // Only when something *was* translated. A subject rendered twice in one
67
+ // language has every string identical, and reporting all of them as
68
+ // untranslated would be a tool telling the truth about nothing.
69
+ if (translated > 0) {
70
+ // One node, one string, one finding. An icon button's `title` and its
71
+ // accessible name are routinely the same string, and reporting both is
72
+ // reporting one missing translation twice — the same collapse the
73
+ // name-from-content check makes above, at the other end.
74
+ for (const { node, text, what } of collapse(identical)) {
75
+ findings.push(finding('untranslated', 'content', node, other, `${what} "${clip(text)}" is identical in ${labels.base} and ${labels.other} — ` +
76
+ 'either it was not translated, or it is a name that does not change'));
77
+ }
78
+ }
79
+ return {
80
+ subject: other.subject.id,
81
+ base: labels.base,
82
+ other: labels.other,
83
+ translated,
84
+ identical: identical.length,
85
+ findings,
86
+ ...(expansion !== undefined ? { expansion } : {}),
87
+ layoutObserved,
88
+ uncompared,
89
+ };
90
+ }
91
+ /**
92
+ * Every string on a node, paired across the two renders, labelled by where it
93
+ * came from so the finding can say *which* string rather than only its value.
94
+ *
95
+ * A string present on one side and absent on the other is skipped rather than
96
+ * counted as translated. That is a structural difference between the renders,
97
+ * which is what `pairByPosition` refuses to guess about, and counting it here
98
+ * would smuggle the guess back in through a different door.
99
+ */
100
+ /**
101
+ * One entry per (node, value). Where several fields carry the same string, they
102
+ * are named together: `accessible name / title=` says more than either alone and
103
+ * is still one thing to fix.
104
+ */
105
+ function collapse(candidates) {
106
+ const byValue = new Map();
107
+ for (const candidate of candidates) {
108
+ const key = `${candidate.node.path}\u0000${candidate.text}`;
109
+ const seen = byValue.get(key);
110
+ if (seen)
111
+ seen.whats.push(candidate.what);
112
+ else
113
+ byValue.set(key, { node: candidate.node, text: candidate.text, whats: [candidate.what] });
114
+ }
115
+ return [...byValue.values()].map(({ node, text, whats }) => ({
116
+ node,
117
+ text,
118
+ what: whats.join(' / '),
119
+ }));
120
+ }
121
+ function stringsOf(before, after) {
122
+ const strings = [];
123
+ const pair = (what, was, is) => {
124
+ if (was !== undefined && is !== undefined)
125
+ strings.push([what, was, is]);
126
+ };
127
+ pair('text', before.text, after.text);
128
+ // A name computed from content *is* the text, observed a second way. Counting
129
+ // both makes one translated string read as two, and one missing translation
130
+ // read as two findings on one node. Same rule as `derivedFrom` on a delta.
131
+ if (before.name !== before.text || after.name !== after.text) {
132
+ pair('accessible name', before.name, after.name);
133
+ }
134
+ pair('accessible description', before.description, after.description);
135
+ for (const attribute of TRANSLATABLE_ATTRIBUTES) {
136
+ pair(`${attribute}=`, before.attributes[attribute], after.attributes[attribute]);
137
+ }
138
+ return strings;
139
+ }
140
+ function finding(rule, band, node, snapshot, what) {
141
+ const where = locate(snapshot.root, node.path).where;
142
+ return {
143
+ rule,
144
+ band,
145
+ path: node.path,
146
+ what,
147
+ ...(where !== '' ? { where } : {}),
148
+ ...component(node),
149
+ ...(node.provenance ? { owners: node.provenance.owners } : {}),
150
+ };
151
+ }
152
+ /**
153
+ * A text node has no provenance of its own — React does not own text — so the
154
+ * name comes from the nearest owner recorded on it, and the caller's tree walk
155
+ * has already put the enclosing component there.
156
+ */
157
+ function component(node) {
158
+ const name = node.provenance?.createdBy ?? node.provenance?.owners[0]?.name;
159
+ return name === undefined ? {} : { component: name };
160
+ }
161
+ function describe(node) {
162
+ if (node.name !== undefined)
163
+ return `${node.role ?? node.tag} "${clip(node.name)}"`;
164
+ if (node.text !== undefined)
165
+ return `"${clip(node.text)}"`;
166
+ return `<${node.tag}>`;
167
+ }
168
+ function clip(text) {
169
+ return text.length <= 40 ? text : `${text.slice(0, 39)}…`;
170
+ }
171
+ /**
172
+ * Pair the two trees by position, not by identity — and this is the whole reason
173
+ * `matchTrees` is not reused here.
174
+ *
175
+ * `matchKey` keys a node on its role and accessible name, because that is the
176
+ * strongest signal that two renders of one revision are showing the same thing.
177
+ * A translation changes exactly that: `button "Continue"` and
178
+ * `button "Fortfahren"` get different keys, so the differ reports the button as
179
+ * removed and a different one added, and every comparison this module wants to
180
+ * make evaporates. It is the right key for "same language, later revision" and
181
+ * the wrong one for "same revision, another language".
182
+ *
183
+ * Position is the right key here because a translation is not supposed to change
184
+ * the tree. Where the shapes diverge — a locale that wraps to two lines with an
185
+ * extra element, a plural form with a different structure — pairing stops at
186
+ * that branch rather than guessing, and the strings underneath it are simply not
187
+ * compared. Under-reporting, which is the direction to fail in for a report that
188
+ * does not block anything.
189
+ *
190
+ * **Under-reporting is only the safe direction while it is counted.** Silent, it
191
+ * is the failure this whole project refuses, wearing the most convincing
192
+ * disguise available: a subject whose tree diverged reports *fewer* untranslated
193
+ * strings than one that matched, so the locale nobody translated reads as the
194
+ * clean one. So every node the walk did not reach is counted, and every branch
195
+ * it stopped at is named.
196
+ */
197
+ function pairByPosition(base, other) {
198
+ const pairs = [];
199
+ const divergedAt = [];
200
+ const walk = (before, after, container) => {
201
+ if (before.tag !== after.tag) {
202
+ // This node and everything under it, on both sides. Recorded at the node
203
+ // that stopped the walk rather than at each of its descendants: one
204
+ // address a reader can open beats a list of addresses in a subtree that
205
+ // was never examined.
206
+ divergedAt.push(before.path);
207
+ return;
208
+ }
209
+ const pair = {
210
+ before,
211
+ after,
212
+ ...(container !== undefined && container.before.rect !== undefined &&
213
+ container.after.rect !== undefined
214
+ ? { container }
215
+ : {}),
216
+ };
217
+ pairs.push(pair);
218
+ const count = Math.min(before.children.length, after.children.length);
219
+ // The parent, because the unreached children are the ones the *shorter*
220
+ // list does not have — on whichever side that is, they have no counterpart
221
+ // to be addressed by.
222
+ if (before.children.length !== after.children.length)
223
+ divergedAt.push(before.path);
224
+ for (let index = 0; index < count; index += 1) {
225
+ walk(before.children[index], after.children[index], pair);
226
+ }
227
+ };
228
+ walk(base, other);
229
+ // Total minus paired, per side. A pair consumes exactly one node from each, so
230
+ // the two subtractions are independent and a lopsided divergence says which
231
+ // render carried the extra material.
232
+ return {
233
+ pairs,
234
+ uncompared: {
235
+ base: countNodes(base) - pairs.length,
236
+ other: countNodes(other) - pairs.length,
237
+ divergedAt,
238
+ },
239
+ };
240
+ }
241
+ function countNodes(node) {
242
+ let total = 1;
243
+ for (const child of node.children)
244
+ total += countNodes(child);
245
+ return total;
246
+ }
247
+ //# sourceMappingURL=locale.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locale.js","sourceRoot":"","sources":["../../src/judge/locale.ts"],"names":[],"mappings":"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","sourcesContent":["import type { NodePath, SemanticNode, SemanticSnapshot } from '../format/snapshot.js';\nimport { locate } from '../attribute/locate.js';\nimport type { Finding } from './inspect.js';\n\n/**\n * One subject, two locales.\n *\n * The join a pixel differ cannot make. A baseline image and a message catalogue\n * have nothing in common — there is no key that relates the string `checkout.cta`\n * to a region of a PNG — so every product in the category answers a locale\n * question by taking N times as many screenshots and asking a human to look at\n * all of them. The document has the strings *and* the boxes, addressed to the\n * same nodes, so the two questions a localized UI actually raises are arithmetic:\n *\n * 1. **Did this string get translated?** Identical text in two locales, in a\n * subject where other text did change.\n * 2. **Did the translation break the layout?** A box that fits its container in\n * one locale and overflows it in another.\n *\n * **This is not a regression comparison and must not be read as one.** Both\n * renders are correct. `diffSnapshots` answers \"did this change since the\n * baseline\"; this answers \"do these two agree about everything except their\n * language\", which is a different question with a different failure mode. So it\n * produces `Finding`s — the same type inspection produces, for the same reason:\n * a defect in a render, not a change to one.\n *\n * **What is deliberately not here.** No expansion threshold. \"German is 35%\n * longer\" is a rule of thumb, and a tool that fails a build on a ratio is a tool\n * whose ratio gets raised until it stops failing. The growth is *measured* and\n * returned; only overflow — a box outside the box that contains it, which is a\n * fact about two rectangles — is a finding.\n */\n\nexport interface LocaleComparison {\n readonly subject: string;\n /** Whatever the caller calls the two renders. This module reads no locale tags. */\n readonly base: string;\n readonly other: string;\n\n /** Text nodes that differ. Evidence that translation happened at all. */\n readonly translated: number;\n /** Text nodes carrying letters that are byte-identical in both. */\n readonly identical: number;\n\n readonly findings: readonly Finding[];\n\n /**\n * The largest width growth observed, when both sides carried layout.\n *\n * Reported, never judged. A reviewer deciding whether a locale is safe wants\n * the number and the component; a build deciding it from a threshold wants an\n * argument nobody has made.\n */\n readonly expansion?: {\n readonly ratio: number;\n readonly path: NodePath;\n readonly component?: string;\n };\n\n /**\n * `false` when either side lacked rects, so overflow could not be decided.\n *\n * The same rule as `unobserved`: a question that was not asked has no answer,\n * and \"no overflow findings\" must not be readable as \"nothing overflows\" when\n * the profile had no layout engine.\n */\n readonly layoutObserved: boolean;\n\n /**\n * What the two trees did not have in common, and therefore what was not asked.\n *\n * {@link pairByPosition} refuses to guess across a structural difference: a\n * node whose tag moved takes its whole subtree out of the comparison, and\n * children past the shorter of the two lists are never reached. Both are the\n * right call — a plural form with an extra element is not evidence about\n * translation — and both make this function return **fewer findings**, which\n * is the one direction a partial answer must never be reported in silently.\n *\n * A German plural that adds a `<span>`, or a locale whose date renders as\n * `<time>` where English renders `<span>`, drops real strings out of the\n * comparison. Without this field the run looks *cleaner* for it: zero\n * untranslated strings, because zero strings were examined.\n *\n * Always present, never absent when the trees agree. `{ base: 0, other: 0 }`\n * says the trees were walked and matched; a missing field would say nobody\n * counted, and those are the two things this project exists to keep apart.\n */\n readonly uncompared: Uncompared;\n}\n\nexport interface Uncompared {\n /** Nodes in the base render that nothing in the other render was paired with. */\n readonly base: number;\n /** Nodes in the other render that nothing in the base render was paired with. */\n readonly other: number;\n\n /**\n * Where pairing stopped, as paths in the *base* render, in document order.\n *\n * A count says the answer is partial; a path says where to look. Reported in\n * the base render's addresses because that is the side a reader has a\n * baseline for — the other locale's paths past a divergence are addresses in\n * a tree nothing here compared.\n */\n readonly divergedAt: readonly NodePath[];\n}\n\nexport interface LocaleOptions {\n /**\n * Slack in pixels before a box counts as outside its container.\n *\n * Not a tolerance on the finding — a tolerance on *rounding*. Sub-pixel layout\n * routinely puts a child a fraction outside its parent's rounded rect, and\n * reporting that as overflow would bury the real ones.\n */\n readonly slack?: number;\n}\n\n/** Has at least one letter. `€ 2,400.00` and `—` are not strings anyone translates. */\nconst TRANSLATABLE = /\\p{L}/u;\n\n/**\n * Attributes that carry prose, and are therefore translated.\n *\n * The list matters more than it looks. The first run of this against a real\n * panel found **nothing**, because the only untranslated string in the fixture\n * was a `title` — and the rule was reading text nodes only. In a real product\n * the strings most often left behind are exactly these: an `aria-label` on an\n * icon button, a `placeholder`, an `alt`. They render no pixel of their own,\n * which is why they are forgotten, and why no image comparison at any tolerance\n * has ever reported one.\n */\nconst TRANSLATABLE_ATTRIBUTES = ['title', 'alt', 'placeholder'] as const;\n\nexport function compareLocales(\n base: SemanticSnapshot,\n other: SemanticSnapshot,\n labels: { readonly base: string; readonly other: string },\n options: LocaleOptions = {},\n): LocaleComparison {\n if (base.subject.id !== other.subject.id) {\n throw new Error(\n `refusing to compare locales of different subjects: ${base.subject.id} vs ${other.subject.id}`,\n );\n }\n\n const slack = options.slack ?? 1;\n const { pairs, uncompared } = pairByPosition(base.root, other.root);\n\n const findings: Finding[] = [];\n const identical: { node: SemanticNode; text: string; what: string }[] = [];\n let translated = 0;\n let layoutObserved = false;\n let expansion: LocaleComparison['expansion'];\n\n for (const { before, after, container } of pairs) {\n // Text, then the accessible name and description, then the prose-bearing\n // attributes. All four are strings a translator is handed and all four are\n // invisible to a camera in different ways: a name is announced and never\n // painted, a `title` appears only on hover, an `alt` only when the image\n // does not load.\n for (const [what, was, is] of stringsOf(before, after)) {\n if (was !== is) {\n translated += 1;\n } else if (TRANSLATABLE.test(is)) {\n identical.push({ node: after, text: is, what });\n }\n }\n\n if (before.rect === undefined || after.rect === undefined) continue;\n layoutObserved = true;\n\n if (before.rect.width > 0) {\n const ratio = after.rect.width / before.rect.width;\n if (expansion === undefined || ratio > expansion.ratio) {\n expansion = { ratio, path: after.path, ...component(after) };\n }\n }\n\n // Overflow is judged in the *other* locale against this node's container\n // there, and only where the base render fitted. A layout that was already\n // broken is a real problem and is not this one: reporting it would put a\n // pre-existing bug in front of whoever is reviewing a translation, in every\n // locale, every time.\n if (container === undefined) continue;\n\n const outsideNow = after.rect.x + after.rect.width;\n const insideNow = container.after.rect!.x + container.after.rect!.width;\n const outsideBefore = before.rect.x + before.rect.width;\n const insideBefore = container.before.rect!.x + container.before.rect!.width;\n\n if (outsideNow > insideNow + slack && outsideBefore <= insideBefore + slack) {\n findings.push(\n finding(\n 'overflows-container',\n 'geometry',\n after,\n other,\n `${describe(after)} is ${Math.round(outsideNow - insideNow)}px wider than what ` +\n `contains it in ${labels.other}, and fitted in ${labels.base}`,\n ),\n );\n }\n }\n\n // Only when something *was* translated. A subject rendered twice in one\n // language has every string identical, and reporting all of them as\n // untranslated would be a tool telling the truth about nothing.\n if (translated > 0) {\n // One node, one string, one finding. An icon button's `title` and its\n // accessible name are routinely the same string, and reporting both is\n // reporting one missing translation twice — the same collapse the\n // name-from-content check makes above, at the other end.\n for (const { node, text, what } of collapse(identical)) {\n findings.push(\n finding(\n 'untranslated',\n 'content',\n node,\n other,\n `${what} \"${clip(text)}\" is identical in ${labels.base} and ${labels.other} — ` +\n 'either it was not translated, or it is a name that does not change',\n ),\n );\n }\n }\n\n return {\n subject: other.subject.id,\n base: labels.base,\n other: labels.other,\n translated,\n identical: identical.length,\n findings,\n ...(expansion !== undefined ? { expansion } : {}),\n layoutObserved,\n uncompared,\n };\n}\n\n/**\n * Every string on a node, paired across the two renders, labelled by where it\n * came from so the finding can say *which* string rather than only its value.\n *\n * A string present on one side and absent on the other is skipped rather than\n * counted as translated. That is a structural difference between the renders,\n * which is what `pairByPosition` refuses to guess about, and counting it here\n * would smuggle the guess back in through a different door.\n */\n/**\n * One entry per (node, value). Where several fields carry the same string, they\n * are named together: `accessible name / title=` says more than either alone and\n * is still one thing to fix.\n */\nfunction collapse(\n candidates: readonly { node: SemanticNode; text: string; what: string }[],\n): readonly { node: SemanticNode; text: string; what: string }[] {\n const byValue = new Map<string, { node: SemanticNode; text: string; whats: string[] }>();\n\n for (const candidate of candidates) {\n const key = `${candidate.node.path}\\u0000${candidate.text}`;\n const seen = byValue.get(key);\n if (seen) seen.whats.push(candidate.what);\n else byValue.set(key, { node: candidate.node, text: candidate.text, whats: [candidate.what] });\n }\n\n return [...byValue.values()].map(({ node, text, whats }) => ({\n node,\n text,\n what: whats.join(' / '),\n }));\n}\n\nfunction stringsOf(\n before: SemanticNode,\n after: SemanticNode,\n): readonly (readonly [what: string, was: string, is: string])[] {\n const strings: (readonly [string, string, string])[] = [];\n\n const pair = (what: string, was: string | undefined, is: string | undefined): void => {\n if (was !== undefined && is !== undefined) strings.push([what, was, is]);\n };\n\n pair('text', before.text, after.text);\n\n // A name computed from content *is* the text, observed a second way. Counting\n // both makes one translated string read as two, and one missing translation\n // read as two findings on one node. Same rule as `derivedFrom` on a delta.\n if (before.name !== before.text || after.name !== after.text) {\n pair('accessible name', before.name, after.name);\n }\n pair('accessible description', before.description, after.description);\n for (const attribute of TRANSLATABLE_ATTRIBUTES) {\n pair(`${attribute}=`, before.attributes[attribute], after.attributes[attribute]);\n }\n\n return strings;\n}\n\nfunction finding(\n rule: 'untranslated' | 'overflows-container',\n band: Finding['band'],\n node: SemanticNode,\n snapshot: SemanticSnapshot,\n what: string,\n): Finding {\n const where = locate(snapshot.root, node.path).where;\n\n return {\n rule,\n band,\n path: node.path,\n what,\n ...(where !== '' ? { where } : {}),\n ...component(node),\n ...(node.provenance ? { owners: node.provenance.owners } : {}),\n };\n}\n\n/**\n * A text node has no provenance of its own — React does not own text — so the\n * name comes from the nearest owner recorded on it, and the caller's tree walk\n * has already put the enclosing component there.\n */\nfunction component(node: SemanticNode): { component?: string } {\n const name = node.provenance?.createdBy ?? node.provenance?.owners[0]?.name;\n return name === undefined ? {} : { component: name };\n}\n\nfunction describe(node: SemanticNode): string {\n if (node.name !== undefined) return `${node.role ?? node.tag} \"${clip(node.name)}\"`;\n if (node.text !== undefined) return `\"${clip(node.text)}\"`;\n return `<${node.tag}>`;\n}\n\nfunction clip(text: string): string {\n return text.length <= 40 ? text : `${text.slice(0, 39)}…`;\n}\n\ninterface Pair {\n readonly before: SemanticNode;\n readonly after: SemanticNode;\n /** The enclosing pair, when both sides have one *and* both carry rects. */\n readonly container?: Pair;\n}\n\n/**\n * Pair the two trees by position, not by identity — and this is the whole reason\n * `matchTrees` is not reused here.\n *\n * `matchKey` keys a node on its role and accessible name, because that is the\n * strongest signal that two renders of one revision are showing the same thing.\n * A translation changes exactly that: `button \"Continue\"` and\n * `button \"Fortfahren\"` get different keys, so the differ reports the button as\n * removed and a different one added, and every comparison this module wants to\n * make evaporates. It is the right key for \"same language, later revision\" and\n * the wrong one for \"same revision, another language\".\n *\n * Position is the right key here because a translation is not supposed to change\n * the tree. Where the shapes diverge — a locale that wraps to two lines with an\n * extra element, a plural form with a different structure — pairing stops at\n * that branch rather than guessing, and the strings underneath it are simply not\n * compared. Under-reporting, which is the direction to fail in for a report that\n * does not block anything.\n *\n * **Under-reporting is only the safe direction while it is counted.** Silent, it\n * is the failure this whole project refuses, wearing the most convincing\n * disguise available: a subject whose tree diverged reports *fewer* untranslated\n * strings than one that matched, so the locale nobody translated reads as the\n * clean one. So every node the walk did not reach is counted, and every branch\n * it stopped at is named.\n */\nfunction pairByPosition(\n base: SemanticNode,\n other: SemanticNode,\n): { readonly pairs: readonly Pair[]; readonly uncompared: Uncompared } {\n const pairs: Pair[] = [];\n const divergedAt: NodePath[] = [];\n\n const walk = (before: SemanticNode, after: SemanticNode, container?: Pair): void => {\n if (before.tag !== after.tag) {\n // This node and everything under it, on both sides. Recorded at the node\n // that stopped the walk rather than at each of its descendants: one\n // address a reader can open beats a list of addresses in a subtree that\n // was never examined.\n divergedAt.push(before.path);\n return;\n }\n\n const pair: Pair = {\n before,\n after,\n ...(container !== undefined && container.before.rect !== undefined &&\n container.after.rect !== undefined\n ? { container }\n : {}),\n };\n pairs.push(pair);\n\n const count = Math.min(before.children.length, after.children.length);\n // The parent, because the unreached children are the ones the *shorter*\n // list does not have — on whichever side that is, they have no counterpart\n // to be addressed by.\n if (before.children.length !== after.children.length) divergedAt.push(before.path);\n\n for (let index = 0; index < count; index += 1) {\n walk(before.children[index]!, after.children[index]!, pair);\n }\n };\n\n walk(base, other);\n\n // Total minus paired, per side. A pair consumes exactly one node from each, so\n // the two subtractions are independent and a lopsided divergence says which\n // render carried the extra material.\n return {\n pairs,\n uncompared: {\n base: countNodes(base) - pairs.length,\n other: countNodes(other) - pairs.length,\n divergedAt,\n },\n };\n}\n\nfunction countNodes(node: SemanticNode): number {\n let total = 1;\n for (const child of node.children) total += countNodes(child);\n return total;\n}\n"]}
@@ -0,0 +1,41 @@
1
+ import type { NodePath } from '../format/snapshot.js';
2
+ /**
3
+ * Who a declaration applies to, and when it has stopped.
4
+ *
5
+ * Four predicates and no policy. They are together because they are the whole of
6
+ * *scope* — a rule reaches a subject, a subtree, and a date — and apart from
7
+ * `ignore.ts` because more than one thing asks: an ignore, a sensitivity, and
8
+ * whatever declares itself next. A second implementation of "does this glob
9
+ * match" is how two config keys come to mean subtly different things while
10
+ * looking identical in the documentation.
11
+ */
12
+ /** The scope fields every declaration shares. */
13
+ export interface Scoped {
14
+ readonly subjects?: readonly string[];
15
+ readonly until?: string;
16
+ }
17
+ /**
18
+ * Whether a rule is past its date, comparing *days* rather than instants.
19
+ *
20
+ * `until` names a day and a caller's clock is usually a timestamp, so comparing
21
+ * them directly expires a rule at one millisecond past midnight **on** the day it
22
+ * names — a full day early. The date a run happened is its own first ten
23
+ * characters, which is UTC: the same calendar `until` is written in when nobody
24
+ * says otherwise, and a choice worth stating, since a team near a boundary sees a
25
+ * rule expire on the UTC day rather than on theirs.
26
+ *
27
+ * `until` is the last day it holds, not the first day it does not.
28
+ */
29
+ export declare function isExpired(rule: Scoped, now: string | undefined): boolean;
30
+ /**
31
+ * Path containment on the child-index address, boundary-aware.
32
+ *
33
+ * `0/1` contains `0/1/2` and does not contain `0/10`, which a bare `startsWith`
34
+ * gets wrong — and gets wrong silently, in the direction of ignoring more than
35
+ * was asked for.
36
+ */
37
+ export declare function isUnder(path: NodePath, ancestor: NodePath): boolean;
38
+ export declare function appliesToSubject(rule: Scoped, subjectId: string): boolean;
39
+ /** `*` matches any run of characters, including none. Nothing else is special. */
40
+ export declare function matchesGlob(value: string, pattern: string): boolean;
41
+ //# sourceMappingURL=scope.d.ts.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Whether a rule is past its date, comparing *days* rather than instants.
3
+ *
4
+ * `until` names a day and a caller's clock is usually a timestamp, so comparing
5
+ * them directly expires a rule at one millisecond past midnight **on** the day it
6
+ * names — a full day early. The date a run happened is its own first ten
7
+ * characters, which is UTC: the same calendar `until` is written in when nobody
8
+ * says otherwise, and a choice worth stating, since a team near a boundary sees a
9
+ * rule expire on the UTC day rather than on theirs.
10
+ *
11
+ * `until` is the last day it holds, not the first day it does not.
12
+ */
13
+ export function isExpired(rule, now) {
14
+ if (rule.until === undefined || now === undefined)
15
+ return false;
16
+ if (Number.isNaN(Date.parse(rule.until)) || Number.isNaN(Date.parse(now)))
17
+ return false;
18
+ return now.slice(0, 10) > rule.until.slice(0, 10);
19
+ }
20
+ /**
21
+ * Path containment on the child-index address, boundary-aware.
22
+ *
23
+ * `0/1` contains `0/1/2` and does not contain `0/10`, which a bare `startsWith`
24
+ * gets wrong — and gets wrong silently, in the direction of ignoring more than
25
+ * was asked for.
26
+ */
27
+ export function isUnder(path, ancestor) {
28
+ if (path === ancestor)
29
+ return true;
30
+ return path.startsWith(`${ancestor}/`);
31
+ }
32
+ export function appliesToSubject(rule, subjectId) {
33
+ if (rule.subjects === undefined)
34
+ return true;
35
+ return rule.subjects.some((pattern) => matchesGlob(subjectId, pattern));
36
+ }
37
+ /** `*` matches any run of characters, including none. Nothing else is special. */
38
+ export function matchesGlob(value, pattern) {
39
+ if (!pattern.includes('*'))
40
+ return value === pattern;
41
+ const escaped = pattern
42
+ .split('*')
43
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
44
+ .join('.*');
45
+ return new RegExp(`^${escaped}$`).test(value);
46
+ }
47
+ //# sourceMappingURL=scope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope.js","sourceRoot":"","sources":["../../src/judge/scope.ts"],"names":[],"mappings":"AAmBA;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,SAAS,CAAC,IAAY,EAAE,GAAuB;IAC7D,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS,IAAI,GAAG,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC;IAChE,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IACxF,OAAO,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AACpD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,OAAO,CAAC,IAAc,EAAE,QAAkB;IACxD,IAAI,IAAI,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IACnC,OAAO,IAAI,CAAC,UAAU,CAAC,GAAG,QAAQ,GAAG,CAAC,CAAC;AACzC,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,IAAY,EAAE,SAAiB;IAC9D,IAAI,IAAI,CAAC,QAAQ,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IAC7C,OAAO,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,WAAW,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC,CAAC;AAC1E,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,WAAW,CAAC,KAAa,EAAE,OAAe;IACxD,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,KAAK,KAAK,OAAO,CAAC;IAErD,MAAM,OAAO,GAAG,OAAO;SACpB,KAAK,CAAC,GAAG,CAAC;SACV,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC;SAC1D,IAAI,CAAC,IAAI,CAAC,CAAC;IAEd,OAAO,IAAI,MAAM,CAAC,IAAI,OAAO,GAAG,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAChD,CAAC","sourcesContent":["import type { NodePath } from '../format/snapshot.js';\n\n/**\n * Who a declaration applies to, and when it has stopped.\n *\n * Four predicates and no policy. They are together because they are the whole of\n * *scope* — a rule reaches a subject, a subtree, and a date — and apart from\n * `ignore.ts` because more than one thing asks: an ignore, a sensitivity, and\n * whatever declares itself next. A second implementation of \"does this glob\n * match\" is how two config keys come to mean subtly different things while\n * looking identical in the documentation.\n */\n\n/** The scope fields every declaration shares. */\nexport interface Scoped {\n readonly subjects?: readonly string[];\n readonly until?: string;\n}\n\n/**\n * Whether a rule is past its date, comparing *days* rather than instants.\n *\n * `until` names a day and a caller's clock is usually a timestamp, so comparing\n * them directly expires a rule at one millisecond past midnight **on** the day it\n * names — a full day early. The date a run happened is its own first ten\n * characters, which is UTC: the same calendar `until` is written in when nobody\n * says otherwise, and a choice worth stating, since a team near a boundary sees a\n * rule expire on the UTC day rather than on theirs.\n *\n * `until` is the last day it holds, not the first day it does not.\n */\nexport function isExpired(rule: Scoped, now: string | undefined): boolean {\n if (rule.until === undefined || now === undefined) return false;\n if (Number.isNaN(Date.parse(rule.until)) || Number.isNaN(Date.parse(now))) return false;\n return now.slice(0, 10) > rule.until.slice(0, 10);\n}\n\n/**\n * Path containment on the child-index address, boundary-aware.\n *\n * `0/1` contains `0/1/2` and does not contain `0/10`, which a bare `startsWith`\n * gets wrong — and gets wrong silently, in the direction of ignoring more than\n * was asked for.\n */\nexport function isUnder(path: NodePath, ancestor: NodePath): boolean {\n if (path === ancestor) return true;\n return path.startsWith(`${ancestor}/`);\n}\n\nexport function appliesToSubject(rule: Scoped, subjectId: string): boolean {\n if (rule.subjects === undefined) return true;\n return rule.subjects.some((pattern) => matchesGlob(subjectId, pattern));\n}\n\n/** `*` matches any run of characters, including none. Nothing else is special. */\nexport function matchesGlob(value: string, pattern: string): boolean {\n if (!pattern.includes('*')) return value === pattern;\n\n const escaped = pattern\n .split('*')\n .map((part) => part.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'))\n .join('.*');\n\n return new RegExp(`^${escaped}$`).test(value);\n}\n"]}