@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.
- package/CHANGELOG.md +79 -0
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/dist/artifact.d.ts +47 -0
- package/dist/artifact.js +2 -0
- package/dist/artifact.js.map +1 -0
- package/dist/attribute/because.d.ts +43 -0
- package/dist/attribute/because.js +64 -0
- package/dist/attribute/because.js.map +1 -0
- package/dist/attribute/boundary.d.ts +191 -0
- package/dist/attribute/boundary.js +328 -0
- package/dist/attribute/boundary.js.map +1 -0
- package/dist/attribute/call-site.d.ts +96 -0
- package/dist/attribute/call-site.js +183 -0
- package/dist/attribute/call-site.js.map +1 -0
- package/dist/attribute/component-hash.d.ts +188 -0
- package/dist/attribute/component-hash.js +312 -0
- package/dist/attribute/component-hash.js.map +1 -0
- package/dist/attribute/composition-fixture.d.ts +18 -0
- package/dist/attribute/composition-fixture.js +53 -0
- package/dist/attribute/composition-fixture.js.map +1 -0
- package/dist/attribute/composition.d.ts +182 -0
- package/dist/attribute/composition.js +169 -0
- package/dist/attribute/composition.js.map +1 -0
- package/dist/attribute/control.d.ts +43 -0
- package/dist/attribute/control.js +54 -0
- package/dist/attribute/control.js.map +1 -0
- package/dist/attribute/coverage.d.ts +99 -0
- package/dist/attribute/coverage.js +86 -0
- package/dist/attribute/coverage.js.map +1 -0
- package/dist/attribute/divergence.d.ts +102 -0
- package/dist/attribute/divergence.js +152 -0
- package/dist/attribute/divergence.js.map +1 -0
- package/dist/attribute/index.d.ts +43 -0
- package/dist/attribute/index.js +29 -0
- package/dist/attribute/index.js.map +1 -0
- package/dist/attribute/instability.d.ts +89 -0
- package/dist/attribute/instability.js +183 -0
- package/dist/attribute/instability.js.map +1 -0
- package/dist/attribute/instances.d.ts +145 -0
- package/dist/attribute/instances.js +140 -0
- package/dist/attribute/instances.js.map +1 -0
- package/dist/attribute/locate.d.ts +22 -0
- package/dist/attribute/locate.js +100 -0
- package/dist/attribute/locate.js.map +1 -0
- package/dist/attribute/mask.d.ts +156 -0
- package/dist/attribute/mask.js +201 -0
- package/dist/attribute/mask.js.map +1 -0
- package/dist/attribute/movement.d.ts +211 -0
- package/dist/attribute/movement.js +181 -0
- package/dist/attribute/movement.js.map +1 -0
- package/dist/attribute/region.d.ts +188 -0
- package/dist/attribute/region.js +144 -0
- package/dist/attribute/region.js.map +1 -0
- package/dist/attribute/source-map.d.ts +98 -0
- package/dist/attribute/source-map.js +264 -0
- package/dist/attribute/source-map.js.map +1 -0
- package/dist/attribute/source.d.ts +77 -0
- package/dist/attribute/source.js +95 -0
- package/dist/attribute/source.js.map +1 -0
- package/dist/attribute/stack.d.ts +80 -0
- package/dist/attribute/stack.js +108 -0
- package/dist/attribute/stack.js.map +1 -0
- package/dist/compare/band.d.ts +85 -0
- package/dist/compare/band.js +70 -0
- package/dist/compare/band.js.map +1 -0
- package/dist/compare/cascade.d.ts +45 -0
- package/dist/compare/cascade.js +71 -0
- package/dist/compare/cascade.js.map +1 -0
- package/dist/compare/derive.d.ts +78 -0
- package/dist/compare/derive.js +39 -0
- package/dist/compare/derive.js.map +1 -0
- package/dist/compare/diff/attribution.d.ts +23 -0
- package/dist/compare/diff/attribution.js +244 -0
- package/dist/compare/diff/attribution.js.map +1 -0
- package/dist/compare/diff/compare-nodes.d.ts +15 -0
- package/dist/compare/diff/compare-nodes.js +227 -0
- package/dist/compare/diff/compare-nodes.js.map +1 -0
- package/dist/compare/diff/components.d.ts +20 -0
- package/dist/compare/diff/components.js +76 -0
- package/dist/compare/diff/components.js.map +1 -0
- package/dist/compare/diff/delta.d.ts +165 -0
- package/dist/compare/diff/delta.js +12 -0
- package/dist/compare/diff/delta.js.map +1 -0
- package/dist/compare/diff/index.d.ts +69 -0
- package/dist/compare/diff/index.js +101 -0
- package/dist/compare/diff/index.js.map +1 -0
- package/dist/compare/diff/match.d.ts +37 -0
- package/dist/compare/diff/match.js +167 -0
- package/dist/compare/diff/match.js.map +1 -0
- package/dist/compare/explain.d.ts +23 -0
- package/dist/compare/explain.js +190 -0
- package/dist/compare/explain.js.map +1 -0
- package/dist/compare/holding-diff.d.ts +25 -0
- package/dist/compare/holding-diff.js +74 -0
- package/dist/compare/holding-diff.js.map +1 -0
- package/dist/compare/impact.d.ts +56 -0
- package/dist/compare/impact.js +124 -0
- package/dist/compare/impact.js.map +1 -0
- package/dist/compare/index.d.ts +30 -0
- package/dist/compare/index.js +22 -0
- package/dist/compare/index.js.map +1 -0
- package/dist/compare/instance.d.ts +30 -0
- package/dist/compare/instance.js +145 -0
- package/dist/compare/instance.js.map +1 -0
- package/dist/compare/observability.d.ts +49 -0
- package/dist/compare/observability.js +57 -0
- package/dist/compare/observability.js.map +1 -0
- package/dist/compare/parting.d.ts +179 -0
- package/dist/compare/parting.js +244 -0
- package/dist/compare/parting.js.map +1 -0
- package/dist/compare/slice.d.ts +74 -0
- package/dist/compare/slice.js +79 -0
- package/dist/compare/slice.js.map +1 -0
- package/dist/compare/value.d.ts +60 -0
- package/dist/compare/value.js +111 -0
- package/dist/compare/value.js.map +1 -0
- package/dist/format/accessibility.d.ts +27 -0
- package/dist/format/accessibility.js +17 -0
- package/dist/format/accessibility.js.map +1 -0
- package/dist/format/canonical.d.ts +38 -0
- package/dist/format/canonical.js +65 -0
- package/dist/format/canonical.js.map +1 -0
- package/dist/format/capture.d.ts +212 -0
- package/dist/format/capture.js +2 -0
- package/dist/format/capture.js.map +1 -0
- package/dist/format/document.d.ts +279 -0
- package/dist/format/document.js +65 -0
- package/dist/format/document.js.map +1 -0
- package/dist/format/environment.d.ts +120 -0
- package/dist/format/environment.js +53 -0
- package/dist/format/environment.js.map +1 -0
- package/dist/format/hash.d.ts +45 -0
- package/dist/format/hash.js +41 -0
- package/dist/format/hash.js.map +1 -0
- package/dist/format/holding.d.ts +134 -0
- package/dist/format/holding.js +2 -0
- package/dist/format/holding.js.map +1 -0
- package/dist/format/index.d.ts +39 -0
- package/dist/format/index.js +25 -0
- package/dist/format/index.js.map +1 -0
- package/dist/format/intervention.d.ts +87 -0
- package/dist/format/intervention.js +11 -0
- package/dist/format/intervention.js.map +1 -0
- package/dist/format/profile.d.ts +59 -0
- package/dist/format/profile.js +66 -0
- package/dist/format/profile.js.map +1 -0
- package/dist/format/provenance.d.ts +179 -0
- package/dist/format/provenance.js +222 -0
- package/dist/format/provenance.js.map +1 -0
- package/dist/format/sha256.d.ts +34 -0
- package/dist/format/sha256.js +152 -0
- package/dist/format/sha256.js.map +1 -0
- package/dist/format/snapshot.d.ts +263 -0
- package/dist/format/snapshot.js +2 -0
- package/dist/format/snapshot.js.map +1 -0
- package/dist/format/stabilize.d.ts +190 -0
- package/dist/format/stabilize.js +333 -0
- package/dist/format/stabilize.js.map +1 -0
- package/dist/format/tier.d.ts +19 -0
- package/dist/format/tier.js +11 -0
- package/dist/format/tier.js.map +1 -0
- package/dist/format/value.d.ts +115 -0
- package/dist/format/value.js +182 -0
- package/dist/format/value.js.map +1 -0
- package/dist/format/wiring.d.ts +115 -0
- package/dist/format/wiring.js +60 -0
- package/dist/format/wiring.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/judge/docket.d.ts +85 -0
- package/dist/judge/docket.js +162 -0
- package/dist/judge/docket.js.map +1 -0
- package/dist/judge/fingerprint.d.ts +53 -0
- package/dist/judge/fingerprint.js +144 -0
- package/dist/judge/fingerprint.js.map +1 -0
- package/dist/judge/ignore.d.ts +206 -0
- package/dist/judge/ignore.js +237 -0
- package/dist/judge/ignore.js.map +1 -0
- package/dist/judge/index.d.ts +44 -0
- package/dist/judge/index.js +34 -0
- package/dist/judge/index.js.map +1 -0
- package/dist/judge/inspect.d.ts +148 -0
- package/dist/judge/inspect.js +208 -0
- package/dist/judge/inspect.js.map +1 -0
- package/dist/judge/intent.d.ts +146 -0
- package/dist/judge/intent.js +150 -0
- package/dist/judge/intent.js.map +1 -0
- package/dist/judge/locale.d.ts +111 -0
- package/dist/judge/locale.js +247 -0
- package/dist/judge/locale.js.map +1 -0
- package/dist/judge/scope.d.ts +41 -0
- package/dist/judge/scope.js +47 -0
- package/dist/judge/scope.js.map +1 -0
- package/dist/judge/sensitivity.d.ts +202 -0
- package/dist/judge/sensitivity.js +152 -0
- package/dist/judge/sensitivity.js.map +1 -0
- package/dist/judge/trail.d.ts +122 -0
- package/dist/judge/trail.js +96 -0
- package/dist/judge/trail.js.map +1 -0
- package/dist/judge/verdict.d.ts +39 -0
- package/dist/judge/verdict.js +32 -0
- package/dist/judge/verdict.js.map +1 -0
- package/dist/plan/index.d.ts +12 -0
- package/dist/plan/index.js +11 -0
- package/dist/plan/index.js.map +1 -0
- package/dist/plan/tool.d.ts +85 -0
- package/dist/plan/tool.js +87 -0
- package/dist/plan/tool.js.map +1 -0
- package/dist/relate/graph.d.ts +190 -0
- package/dist/relate/graph.js +234 -0
- package/dist/relate/graph.js.map +1 -0
- package/dist/relate/index.d.ts +16 -0
- package/dist/relate/index.js +16 -0
- package/dist/relate/index.js.map +1 -0
- package/dist/relate/merkle.d.ts +110 -0
- package/dist/relate/merkle.js +246 -0
- package/dist/relate/merkle.js.map +1 -0
- package/dist/relate/reach.d.ts +65 -0
- package/dist/relate/reach.js +111 -0
- package/dist/relate/reach.js.map +1 -0
- package/dist/relate/records.d.ts +129 -0
- package/dist/relate/records.js +112 -0
- package/dist/relate/records.js.map +1 -0
- package/dist/rules/index.d.ts +19 -0
- package/dist/rules/index.js +17 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/normalize/alias.d.ts +50 -0
- package/dist/rules/normalize/alias.js +111 -0
- package/dist/rules/normalize/alias.js.map +1 -0
- package/dist/rules/normalize/cascade.d.ts +85 -0
- package/dist/rules/normalize/cascade.js +324 -0
- package/dist/rules/normalize/cascade.js.map +1 -0
- package/dist/rules/normalize/color.d.ts +30 -0
- package/dist/rules/normalize/color.js +185 -0
- package/dist/rules/normalize/color.js.map +1 -0
- package/dist/rules/normalize/fixture.d.ts +83 -0
- package/dist/rules/normalize/fixture.js +74 -0
- package/dist/rules/normalize/fixture.js.map +1 -0
- package/dist/rules/normalize/index.d.ts +39 -0
- package/dist/rules/normalize/index.js +268 -0
- package/dist/rules/normalize/index.js.map +1 -0
- package/dist/rules/normalize/project.d.ts +42 -0
- package/dist/rules/normalize/project.js +83 -0
- package/dist/rules/normalize/project.js.map +1 -0
- package/dist/rules/normalize/shorthand.d.ts +38 -0
- package/dist/rules/normalize/shorthand.js +388 -0
- package/dist/rules/normalize/shorthand.js.map +1 -0
- package/dist/rules/normalize/value.d.ts +23 -0
- package/dist/rules/normalize/value.js +162 -0
- package/dist/rules/normalize/value.js.map +1 -0
- package/dist/rules/normalize/wrapper.d.ts +16 -0
- package/dist/rules/normalize/wrapper.js +137 -0
- package/dist/rules/normalize/wrapper.js.map +1 -0
- package/dist/rules/ruleset.d.ts +93 -0
- package/dist/rules/ruleset.js +175 -0
- package/dist/rules/ruleset.js.map +1 -0
- package/mark.svg +30 -0
- 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":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAqHhD,uFAAuF;AACvF,MAAM,YAAY,GAAG,QAAQ,CAAC;AAE9B;;;;;;;;;;GAUG;AACH,MAAM,uBAAuB,GAAG,CAAC,OAAO,EAAE,KAAK,EAAE,aAAa,CAAU,CAAC;AAEzE,MAAM,UAAU,cAAc,CAC5B,IAAsB,EACtB,KAAuB,EACvB,MAAyD,EACzD,OAAO,GAAkB,EAAE;IAE3B,IAAI,IAAI,CAAC,OAAO,CAAC,EAAE,KAAK,KAAK,CAAC,OAAO,CAAC,EAAE,EAAE,CAAC;QACzC,MAAM,IAAI,KAAK,CACb,sDAAsD,IAAI,CAAC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC,OAAO,CAAC,EAAE,EAAE,CAC/F,CAAC;IACJ,CAAC;IAED,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,CAAC,CAAC;IACjC,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,GAAG,cAAc,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;IAEpE,MAAM,QAAQ,GAAc,EAAE,CAAC;IAC/B,MAAM,SAAS,GAAyD,EAAE,CAAC;IAC3E,IAAI,UAAU,GAAG,CAAC,CAAC;IACnB,IAAI,cAAc,GAAG,KAAK,CAAC;IAC3B,IAAI,SAAwC,CAAC;IAE7C,KAAK,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,KAAK,EAAE,CAAC;QACjD,yEAAyE;QACzE,2EAA2E;QAC3E,yEAAyE;QACzE,yEAAyE;QACzE,iBAAiB;QACjB,KAAK,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,CAAC,IAAI,SAAS,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC;YACvD,IAAI,GAAG,KAAK,EAAE,EAAE,CAAC;gBACf,UAAU,IAAI,CAAC,CAAC;YAClB,CAAC;iBAAM,IAAI,YAAY,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC;gBACjC,SAAS,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;YAClD,CAAC;QACH,CAAC;QAED,IAAI,MAAM,CAAC,IAAI,KAAK,SAAS,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS;YAAE,SAAS;QACpE,cAAc,GAAG,IAAI,CAAC;QAEtB,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,GAAG,CAAC,EAAE,CAAC;YAC1B,MAAM,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC;YACnD,IAAI,SAAS,KAAK,SAAS,IAAI,KAAK,GAAG,SAAS,CAAC,KAAK,EAAE,CAAC;gBACvD,SAAS,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,GAAG,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC;YAC/D,CAAC;QACH,CAAC;QAED,yEAAyE;QACzE,0EAA0E;QAC1E,yEAAyE;QACzE,4EAA4E;QAC5E,sBAAsB;QACtB,IAAI,SAAS,KAAK,SAAS;YAAE,SAAS;QAEtC,MAAM,UAAU,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC;QACnD,MAAM,SAAS,GAAG,SAAS,CAAC,KAAK,CAAC,IAAK,CAAC,CAAC,GAAG,SAAS,CAAC,KAAK,CAAC,IAAK,CAAC,KAAK,CAAC;QACxE,MAAM,aAAa,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC;QACxD,MAAM,YAAY,GAAG,SAAS,CAAC,MAAM,CAAC,IAAK,CAAC,CAAC,GAAG,SAAS,CAAC,MAAM,CAAC,IAAK,CAAC,KAAK,CAAC;QAE7E,IAAI,UAAU,GAAG,SAAS,GAAG,KAAK,IAAI,aAAa,IAAI,YAAY,GAAG,KAAK,EAAE,CAAC;YAC5E,QAAQ,CAAC,IAAI,CACX,OAAO,CACL,qBAAqB,EACrB,UAAU,EACV,KAAK,EACL,KAAK,EACL,GAAG,QAAQ,CAAC,KAAK,CAAC,OAAO,IAAI,CAAC,KAAK,CAAC,UAAU,GAAG,SAAS,CAAC,qBAAqB;gBAC9E,kBAAkB,MAAM,CAAC,KAAK,mBAAmB,MAAM,CAAC,IAAI,EAAE,CACjE,CACF,CAAC;QACJ,CAAC;IACH,CAAC;IAED,wEAAwE;IACxE,oEAAoE;IACpE,gEAAgE;IAChE,IAAI,UAAU,GAAG,CAAC,EAAE,CAAC;QACnB,sEAAsE;QACtE,uEAAuE;QACvE,kEAAkE;QAClE,yDAAyD;QACzD,KAAK,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC;YACvD,QAAQ,CAAC,IAAI,CACX,OAAO,CACL,cAAc,EACd,SAAS,EACT,IAAI,EACJ,KAAK,EACL,GAAG,IAAI,KAAK,IAAI,CAAC,IAAI,CAAC,qBAAqB,MAAM,CAAC,IAAI,QAAQ,MAAM,CAAC,KAAK,KAAK;gBAC7E,oEAAoE,CACvE,CACF,CAAC;QACJ,CAAC;IACH,CAAC;IAED,OAAO;QACL,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC,EAAE;QACzB,IAAI,EAAE,MAAM,CAAC,IAAI;QACjB,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,UAAU;QACV,SAAS,EAAE,SAAS,CAAC,MAAM;QAC3B,QAAQ;QACR,GAAG,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACjD,cAAc;QACd,UAAU;KACX,CAAC;AACJ,CAAC;AAED;;;;;;;;GAQG;AACH;;;;GAIG;AACH,SAAS,QAAQ,CACf,UAAyE;IAEzE,MAAM,OAAO,GAAG,IAAI,GAAG,EAAiE,CAAC;IAEzF,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,MAAM,GAAG,GAAG,GAAG,SAAS,CAAC,IAAI,CAAC,IAAI,SAAS,SAAS,CAAC,IAAI,EAAE,CAAC;QAC5D,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC9B,IAAI,IAAI;YAAE,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;;YACrC,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,IAAI,EAAE,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,SAAS,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACjG,CAAC;IAED,OAAO,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,CAAC;QAC3D,IAAI;QACJ,IAAI;QACJ,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC;KACxB,CAAC,CAAC,CAAC;AACN,CAAC;AAED,SAAS,SAAS,CAChB,MAAoB,EACpB,KAAmB;IAEnB,MAAM,OAAO,GAA0C,EAAE,CAAC;IAE1D,MAAM,IAAI,GAAG,CAAC,IAAY,EAAE,GAAuB,EAAE,EAAsB,EAAQ,EAAE;QACnF,IAAI,GAAG,KAAK,SAAS,IAAI,EAAE,KAAK,SAAS;YAAE,OAAO,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC,CAAC;IAC3E,CAAC,CAAC;IAEF,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;IAEtC,8EAA8E;IAC9E,4EAA4E;IAC5E,2EAA2E;IAC3E,IAAI,MAAM,CAAC,IAAI,KAAK,MAAM,CAAC,IAAI,IAAI,KAAK,CAAC,IAAI,KAAK,KAAK,CAAC,IAAI,EAAE,CAAC;QAC7D,IAAI,CAAC,iBAAiB,EAAE,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;IACnD,CAAC;IACD,IAAI,CAAC,wBAAwB,EAAE,MAAM,CAAC,WAAW,EAAE,KAAK,CAAC,WAAW,CAAC,CAAC;IACtE,KAAK,MAAM,SAAS,IAAI,uBAAuB,EAAE,CAAC;QAChD,IAAI,CAAC,GAAG,SAAS,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,KAAK,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC;IACnF,CAAC;IAED,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,SAAS,OAAO,CACd,IAA4C,EAC5C,IAAqB,EACrB,IAAkB,EAClB,QAA0B,EAC1B,IAAY;IAEZ,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC;IAErD,OAAO;QACL,IAAI;QACJ,IAAI;QACJ,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,IAAI;QACJ,GAAG,CAAC,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAClC,GAAG,SAAS,CAAC,IAAI,CAAC;QAClB,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC/D,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,SAAS,SAAS,CAAC,IAAkB;IACnC,MAAM,IAAI,GAAG,IAAI,CAAC,UAAU,EAAE,SAAS,IAAI,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC;IAC5E,OAAO,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;AACvD,CAAC;AAED,SAAS,QAAQ,CAAC,IAAkB;IAClC,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS;QAAE,OAAO,GAAG,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,GAAG,KAAK,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC;IACpF,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS;QAAE,OAAO,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC;IAC3D,OAAO,IAAI,IAAI,CAAC,GAAG,GAAG,CAAC;AACzB,CAAC;AAED,SAAS,IAAI,CAAC,IAAY;IACxB,OAAO,IAAI,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC;AAC5D,CAAC;AASD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,SAAS,cAAc,CACrB,IAAkB,EAClB,KAAmB;IAEnB,MAAM,KAAK,GAAW,EAAE,CAAC;IACzB,MAAM,UAAU,GAAe,EAAE,CAAC;IAElC,MAAM,IAAI,GAAG,CAAC,MAAoB,EAAE,KAAmB,EAAE,SAAgB,EAAQ,EAAE;QACjF,IAAI,MAAM,CAAC,GAAG,KAAK,KAAK,CAAC,GAAG,EAAE,CAAC;YAC7B,yEAAyE;YACzE,oEAAoE;YACpE,wEAAwE;YACxE,sBAAsB;YACtB,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;YAC7B,OAAO;QACT,CAAC;QAED,MAAM,IAAI,GAAS;YACjB,MAAM;YACN,KAAK;YACL,GAAG,CAAC,SAAS,KAAK,SAAS,IAAI,SAAS,CAAC,MAAM,CAAC,IAAI,KAAK,SAAS;gBAClE,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS;gBAChC,CAAC,CAAC,EAAE,SAAS,EAAE;gBACf,CAAC,CAAC,EAAE,CAAC;SACR,CAAC;QACF,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAEjB,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QACtE,wEAAwE;QACxE,2EAA2E;QAC3E,sBAAsB;QACtB,IAAI,MAAM,CAAC,QAAQ,CAAC,MAAM,KAAK,KAAK,CAAC,QAAQ,CAAC,MAAM;YAAE,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QAEnF,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;YAC9C,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAE,EAAE,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAE,EAAE,IAAI,CAAC,CAAC;QAC9D,CAAC;IACH,CAAC,CAAC;IAEF,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IAElB,+EAA+E;IAC/E,4EAA4E;IAC5E,qCAAqC;IACrC,OAAO;QACL,KAAK;QACL,UAAU,EAAE;YACV,IAAI,EAAE,UAAU,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,MAAM;YACrC,KAAK,EAAE,UAAU,CAAC,KAAK,CAAC,GAAG,KAAK,CAAC,MAAM;YACvC,UAAU;SACX;KACF,CAAC;AACJ,CAAC;AAED,SAAS,UAAU,CAAC,IAAkB;IACpC,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,QAAQ;QAAE,KAAK,IAAI,UAAU,CAAC,KAAK,CAAC,CAAC;IAC9D,OAAO,KAAK,CAAC;AACf,CAAC","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"]}
|