ultra11y 2.22.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/LICENSE +21 -0
- package/README.md +223 -0
- package/docker/Dockerfile +7 -0
- package/docker/package.json +9 -0
- package/docker/runner.mjs +23 -0
- package/package.json +73 -0
- package/scripts/bench.mjs +151 -0
- package/scripts/build-act-corpus.mjs +143 -0
- package/scripts/build-adjudication.mjs +503 -0
- package/scripts/build-pack-rgaa.mjs +397 -0
- package/scripts/build-standards.mjs +548 -0
- package/scripts/copy-bundle.mjs +39 -0
- package/scripts/import-pack.mjs +107 -0
- package/scripts/sync-engine.mjs +84 -0
- package/scripts/sync-version.mjs +43 -0
- package/scripts/ultra11y.mjs +52739 -0
- package/scripts/vendor/act-testcases.json +12297 -0
- package/scripts/vendor/rgaa/SOURCE.json +8 -0
- package/scripts/vendor/rgaa/criteres.json +4144 -0
- package/scripts/vendor/rgaa/glossaire.json +480 -0
- package/scripts/vendor/wcag-2.2-fr.json +82 -0
- package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
- package/scripts/vendor/wcag-2.2-sc.json +574 -0
- package/scripts/verify-rgaa-source.mjs +126 -0
- package/scripts/verify-skill-bundle.mjs +91 -0
- package/skills/review-a11y/SKILL.md +123 -0
- package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
- package/skills/ultra11y/SKILL.md +292 -0
- package/skills/ultra11y/references/act.md +163 -0
- package/skills/ultra11y/references/adjudication.md +599 -0
- package/skills/ultra11y/references/audit.md +100 -0
- package/skills/ultra11y/references/authoring.md +37 -0
- package/skills/ultra11y/references/automation.md +113 -0
- package/skills/ultra11y/references/correction.md +41 -0
- package/skills/ultra11y/references/criteria.md +129 -0
- package/skills/ultra11y/references/cross-file.md +82 -0
- package/skills/ultra11y/references/dynamic.md +152 -0
- package/skills/ultra11y/references/false-positives.md +81 -0
- package/skills/ultra11y/references/fix.md +46 -0
- package/skills/ultra11y/references/focus-and-logic.md +64 -0
- package/skills/ultra11y/references/forbidden-patterns.md +61 -0
- package/skills/ultra11y/references/forms-and-errors.md +147 -0
- package/skills/ultra11y/references/guidance.md +55 -0
- package/skills/ultra11y/references/judgment.md +90 -0
- package/skills/ultra11y/references/media-and-motion.md +116 -0
- package/skills/ultra11y/references/methodology.md +72 -0
- package/skills/ultra11y/references/naming.md +117 -0
- package/skills/ultra11y/references/orchestration.md +44 -0
- package/skills/ultra11y/references/packs.md +193 -0
- package/skills/ultra11y/references/prd.md +104 -0
- package/skills/ultra11y/references/rendered.md +110 -0
- package/skills/ultra11y/references/scale.md +49 -0
- package/skills/ultra11y/references/standards.md +97 -0
- package/skills/ultra11y/references/structure.md +138 -0
- package/skills/ultra11y/references/verify.md +88 -0
- package/skills/ultra11y/references/widgets.md +106 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
- package/src/act.ts +441 -0
- package/src/adjudicate.ts +630 -0
- package/src/aria.ts +230 -0
- package/src/audit.ts +396 -0
- package/src/auditor.ts +399 -0
- package/src/autofill.ts +102 -0
- package/src/axe-map.ts +294 -0
- package/src/baseline.ts +75 -0
- package/src/capture.ts +182 -0
- package/src/check.ts +243 -0
- package/src/cli.ts +1927 -0
- package/src/color.ts +102 -0
- package/src/config.ts +182 -0
- package/src/crawl.ts +75 -0
- package/src/criteria.ts +183 -0
- package/src/data/adjudication.json +770 -0
- package/src/data/guidance/rgaa.json +2395 -0
- package/src/data/standards/rgaa.glossary.json +478 -0
- package/src/data/standards/rgaa.json +3454 -0
- package/src/data/wcag-universe.json +530 -0
- package/src/data/wcag.json +1178 -0
- package/src/discover.ts +203 -0
- package/src/fix/codemods.ts +76 -0
- package/src/fix/diff.ts +64 -0
- package/src/fix/edits.ts +126 -0
- package/src/fix.ts +215 -0
- package/src/gh.ts +214 -0
- package/src/glob.ts +206 -0
- package/src/graph/build.ts +114 -0
- package/src/graph/engine-resolve.ts +0 -0
- package/src/graph/graph.ts +126 -0
- package/src/graph/imports.ts +359 -0
- package/src/graph/resolve.ts +73 -0
- package/src/graph/tsconfig.ts +113 -0
- package/src/grounding.ts +119 -0
- package/src/guidance/index.ts +45 -0
- package/src/guidance/types.ts +36 -0
- package/src/init.ts +113 -0
- package/src/mcp/handlers.ts +388 -0
- package/src/mcp/http.ts +260 -0
- package/src/mcp/prompts.ts +183 -0
- package/src/mcp/protocol.ts +187 -0
- package/src/mcp/resources.ts +136 -0
- package/src/mcp/server.ts +228 -0
- package/src/mcp/stdio.ts +119 -0
- package/src/mcp/tools.ts +347 -0
- package/src/messages.ts +1011 -0
- package/src/name.ts +174 -0
- package/src/orchestrate-templates.ts +231 -0
- package/src/orchestrate.ts +186 -0
- package/src/output.ts +108 -0
- package/src/pack.ts +187 -0
- package/src/parse/html.ts +258 -0
- package/src/parse/jsx-ast.ts +67 -0
- package/src/parse/jsx-bridge.ts +247 -0
- package/src/parse/jsx.ts +19 -0
- package/src/parse/source.ts +75 -0
- package/src/prd.ts +406 -0
- package/src/project-lock.ts +42 -0
- package/src/render.ts +307 -0
- package/src/report.ts +399 -0
- package/src/rules/colors.ts +125 -0
- package/src/rules/cross-registry.ts +264 -0
- package/src/rules/cross-rule.ts +83 -0
- package/src/rules/forms.ts +590 -0
- package/src/rules/frames.ts +33 -0
- package/src/rules/headings.ts +227 -0
- package/src/rules/images.ts +200 -0
- package/src/rules/links.ts +193 -0
- package/src/rules/mandatory.ts +173 -0
- package/src/rules/multimedia.ts +77 -0
- package/src/rules/navigation.ts +190 -0
- package/src/rules/presentation.ts +75 -0
- package/src/rules/registry.ts +57 -0
- package/src/rules/rule.ts +122 -0
- package/src/rules/scripts-aria.ts +636 -0
- package/src/rules/tables.ts +264 -0
- package/src/rules/timing.ts +55 -0
- package/src/sample.ts +176 -0
- package/src/scan-local.ts +866 -0
- package/src/scan.ts +538 -0
- package/src/standards/derive.ts +195 -0
- package/src/standards/index.ts +31 -0
- package/src/standards/pack-rules.ts +111 -0
- package/src/standards/pack.ts +81 -0
- package/src/standards/registry.ts +100 -0
- package/src/standards/types.ts +202 -0
- package/src/standards/validate.ts +544 -0
- package/src/standards/vocabulary.ts +75 -0
- package/src/types.ts +355 -0
- package/src/util.ts +32 -0
- package/src/vendor/codeindex-engine.d.mts +1009 -0
- package/src/vendor/codeindex-engine.mjs +13701 -0
- package/src/vendor/engine.meta.json +9 -0
- package/src/verify.ts +275 -0
- package/src/wcag.ts +128 -0
package/src/act.ts
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
// ACT conformance — measuring this engine against a corpus nobody here wrote.
|
|
2
|
+
//
|
|
3
|
+
// The engine's own recall suite seeds one fixture per rule that EXISTS, so it can only
|
|
4
|
+
// ever prove "our rules fire on the defects we wrote for them". The W3C ACT-Rules
|
|
5
|
+
// Community Group publishes ~90 rules with ~1100 `passed` / `failed` / `inapplicable`
|
|
6
|
+
// examples, authored independently of this project against the ACT Rules Format. Running
|
|
7
|
+
// the engine over them is the only external yardstick available here, and it measures the
|
|
8
|
+
// two things that matter in opposite directions:
|
|
9
|
+
//
|
|
10
|
+
// • recall — a `failed` example must produce a finding from a rule that claims it;
|
|
11
|
+
// • precision — a `passed` / `inapplicable` example must produce NONE.
|
|
12
|
+
//
|
|
13
|
+
// Honest scoping matters more than a big number:
|
|
14
|
+
// 1. An ACT example asserts something about ITS OWN rule and nothing else. An example
|
|
15
|
+
// for "image has an accessible name" says nothing about `<main>` or `<title>`, so a
|
|
16
|
+
// test case is only ever judged with the engine rules MAPPED to that ACT rule
|
|
17
|
+
// (`ACT_RULES` below) — never with the whole engine.
|
|
18
|
+
// 2. Only NORMATIVE findings count. ultra11y's `advisory` findings deliberately never
|
|
19
|
+
// flip a criterion, so they cannot make a case "fail" either.
|
|
20
|
+
// 3. A rule this engine cannot decide from source (computed contrast, keyboard traps,
|
|
21
|
+
// whether a transcript is accurate) is declared `rendered` or `judgment` and is NOT
|
|
22
|
+
// scored — it is routed to the `scan` tier or to the agent's adjudication. Declaring
|
|
23
|
+
// it beats quietly scoring 0.
|
|
24
|
+
// 4. `gap: true` marks a rule that IS statically decidable and simply is not implemented
|
|
25
|
+
// yet. Those are the honest to-do list, not hidden behind an "out of scope" label.
|
|
26
|
+
//
|
|
27
|
+
// Verdicts follow the ACT Rules Format's own vocabulary: a tool that never reports a false
|
|
28
|
+
// positive but does not catch everything is *partially consistent*, not wrong.
|
|
29
|
+
import { parseSource } from "./parse/source.js";
|
|
30
|
+
import { runRules } from "./rules/registry.js";
|
|
31
|
+
|
|
32
|
+
/** Why an ACT rule is or is not scored against the static engine. */
|
|
33
|
+
export type ActScope =
|
|
34
|
+
| "static" // decidable from source — scored
|
|
35
|
+
| "rendered" // needs a browser: computed style, focus, media playback → the `scan` tier
|
|
36
|
+
| "judgment" // needs a human/AI call: is this text descriptive, is that transcript accurate
|
|
37
|
+
| "deprecated"; // withdrawn upstream — scoring it would be noise
|
|
38
|
+
|
|
39
|
+
export interface ActMapping {
|
|
40
|
+
/** Engine rules that test the SAME thing as this ACT rule. Deliberately conservative:
|
|
41
|
+
* a loosely-related rule would manufacture false "inconsistent" verdicts. */
|
|
42
|
+
engineRules: string[];
|
|
43
|
+
scope: ActScope;
|
|
44
|
+
/** In-scope and statically decidable, but not implemented yet. */
|
|
45
|
+
gap?: boolean;
|
|
46
|
+
/** Why the mapping is what it is — divergences are documented, never silent. */
|
|
47
|
+
note?: string;
|
|
48
|
+
/** Test cases where this engine KNOWINGLY reports while ACT does not, keyed by test-case
|
|
49
|
+
* id with the reason. Every entry is a deliberate, argued position — typically "the fact
|
|
50
|
+
* that clears this example is not in the source" (a silent media file, a custom control
|
|
51
|
+
* wired in JS) or "this project holds a stricter line than the ACT rule". Listing them
|
|
52
|
+
* is what keeps the precision gate meaningful: an unexplained false positive still fails,
|
|
53
|
+
* a reasoned divergence is on the record. */
|
|
54
|
+
divergences?: Record<string, string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Keyed by ACT rule id. Every rule in the vendored corpus must appear here
|
|
58
|
+
// (tests/act-official.test.ts fails on drift), so a new upstream rule cannot slip in
|
|
59
|
+
// unclassified.
|
|
60
|
+
export const ACT_RULES: Record<string, ActMapping> = {
|
|
61
|
+
// ---- covered: the engine implements an equivalent check -------------------------
|
|
62
|
+
"23a2a8": { engineRules: ["img-alt-missing"], scope: "static" },
|
|
63
|
+
"59796f": { engineRules: ["input-image-alt-missing"], scope: "static" },
|
|
64
|
+
"8fc3b6": { engineRules: ["object-embed-no-name"], scope: "static" },
|
|
65
|
+
"7d6734": { engineRules: ["img-alt-missing"], scope: "static", note: "role=img on <svg> is handled by the same rule" },
|
|
66
|
+
"97a4e1": { engineRules: ["button-empty-name", "icon-only-control-unnamed"], scope: "static" },
|
|
67
|
+
c487ae: { engineRules: ["link-empty-name", "icon-only-control-unnamed"], scope: "static" },
|
|
68
|
+
cae760: {
|
|
69
|
+
engineRules: ["iframe-title-missing"],
|
|
70
|
+
scope: "static",
|
|
71
|
+
divergences: {
|
|
72
|
+
"394132219ba385a90dda40efe822ab8db0cc9483":
|
|
73
|
+
"a tabindex=-1 iframe is still exposed to assistive tech and still needs a title (RGAA 2.1 requires one unconditionally)",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
e086e5: {
|
|
77
|
+
engineRules: ["control-label-missing"],
|
|
78
|
+
scope: "static",
|
|
79
|
+
note: "`placeholder-as-label` is deliberately NOT mapped: a placeholder does contribute to the accessible name, so ACT passes it — ultra11y still reports it, as a stricter house rule under 3.3.2.",
|
|
80
|
+
},
|
|
81
|
+
ffd0e9: { engineRules: ["empty-heading"], scope: "static" },
|
|
82
|
+
b5c3f8: { engineRules: ["html-lang-missing"], scope: "static" },
|
|
83
|
+
bf051a: { engineRules: ["lang-invalid"], scope: "static" },
|
|
84
|
+
de46e4: {
|
|
85
|
+
engineRules: ["lang-invalid", "inline-lang-change-missing"],
|
|
86
|
+
scope: "static",
|
|
87
|
+
divergences: {
|
|
88
|
+
"949f41987201e52f8404c1246d1229eec06dea08":
|
|
89
|
+
'lang="" on an element carrying text declares no language for that text; ACT deems the rule inapplicable, this engine reports it under 3.1.2',
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
"2779a5": { engineRules: ["title-missing-empty"], scope: "static" },
|
|
93
|
+
"3ea0c8": { engineRules: ["duplicate-id"], scope: "static" },
|
|
94
|
+
"674b10": { engineRules: ["invalid-aria-role"], scope: "static" },
|
|
95
|
+
bc4a75: { engineRules: ["aria-required-children"], scope: "static" },
|
|
96
|
+
"6cfa84": {
|
|
97
|
+
engineRules: ["aria-hidden-focusable"],
|
|
98
|
+
scope: "static",
|
|
99
|
+
divergences: {
|
|
100
|
+
"4cfb71fcbfb617bb32540826847a8d6af7890fd4":
|
|
101
|
+
"a focus-sentinel link inside an aria-hidden wrapper: it IS reachable by Tab, and only a runtime focus handler bounces it back — the same call axe-core makes",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
"6a7281": { engineRules: ["invalid-aria-value", "live-region-conflict"], scope: "static" },
|
|
105
|
+
b40fd1: {
|
|
106
|
+
engineRules: [],
|
|
107
|
+
scope: "static",
|
|
108
|
+
gap: true,
|
|
109
|
+
note: "ACT asks that the non-repeated content sit in SOME landmark — a weaker claim than `missing-main-landmark`, which requires a <main>, so the two are not equivalent",
|
|
110
|
+
},
|
|
111
|
+
"80f0bf": {
|
|
112
|
+
engineRules: ["autoplay-media"],
|
|
113
|
+
scope: "static",
|
|
114
|
+
divergences: {
|
|
115
|
+
"6a0fc31d6bea6ceced58d1ef97713935e4fd2717": "the source cannot tell that the referenced media file is silent",
|
|
116
|
+
cc5c2900c8d128d3ca25d8443f1f3e0a0e5f58e6: "a #t= media fragment shortening playback below 3s is not resolvable from source",
|
|
117
|
+
"85c01775d802057e6f43c2248b8a5eb916c50c74": "the pause mechanism is a custom button wired in JavaScript, not a native `controls` attribute",
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
"4c31df": {
|
|
121
|
+
engineRules: ["autoplay-media"],
|
|
122
|
+
scope: "static",
|
|
123
|
+
divergences: {
|
|
124
|
+
b13d65523ad27d9c763caca8bcc0fcce4b5ca5fb: "the source cannot tell that the referenced media has no audio track",
|
|
125
|
+
cf4bc23e70caa9b42a6f82739a8492cb51393dbc: "the pause mechanism is a custom button wired in JavaScript, not a native `controls` attribute",
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
bc659a: { engineRules: ["meta-refresh-redirect"], scope: "static" },
|
|
129
|
+
bisz58: {
|
|
130
|
+
engineRules: ["meta-refresh-redirect"],
|
|
131
|
+
scope: "static",
|
|
132
|
+
note: "this variant drops the >20h exception the engine honours, so long delays are knowingly not reported",
|
|
133
|
+
},
|
|
134
|
+
b4f0c3: { engineRules: ["meta-viewport-zoom-block"], scope: "static" },
|
|
135
|
+
f51b46: { engineRules: ["media-no-track"], scope: "static", note: "presence of a caption track; whether the captions are accurate is a judgment call" },
|
|
136
|
+
|
|
137
|
+
// ---- gaps: statically decidable, not implemented yet ----------------------------
|
|
138
|
+
"2ee8b8": {
|
|
139
|
+
engineRules: ["label-in-name-mismatch"],
|
|
140
|
+
scope: "static",
|
|
141
|
+
divergences: {
|
|
142
|
+
"65be0004543e57d44dd4a53d9f582c482503727c":
|
|
143
|
+
"an icon FONT renders the text 'search' as a glyph, so the visible label is not the text in the DOM — a fact only the rendered page carries",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
"73f2c2": { engineRules: ["autocomplete-token-invalid"], scope: "static" },
|
|
147
|
+
"5f99a7": { engineRules: ["invalid-aria-attr"], scope: "static" },
|
|
148
|
+
"5c01ea": {
|
|
149
|
+
engineRules: ["aria-prohibited-attr"],
|
|
150
|
+
scope: "static",
|
|
151
|
+
note: "covers the name-prohibited roles; the full per-role permitted-attribute matrix is not encoded",
|
|
152
|
+
},
|
|
153
|
+
"4e8ab6": { engineRules: ["aria-required-attr"], scope: "static" },
|
|
154
|
+
ff89c9: { engineRules: ["aria-required-parent"], scope: "static" },
|
|
155
|
+
"307n5z": { engineRules: [], scope: "static", gap: true, note: "presentational-children conflict" },
|
|
156
|
+
a25f45: { engineRules: ["headers-attr-dangling"], scope: "static" },
|
|
157
|
+
d0f69e: {
|
|
158
|
+
engineRules: ["th-no-data-cells"],
|
|
159
|
+
scope: "static",
|
|
160
|
+
note: "only the explicit `headers`-wired shape is decided; scope-based assignment needs a full table model",
|
|
161
|
+
},
|
|
162
|
+
e6952f: { engineRules: [], scope: "static", gap: true, note: "duplicate attributes are collapsed by the parser; needs a raw-source scan" },
|
|
163
|
+
"5b7ae0": { engineRules: [], scope: "static", gap: true, note: "lang vs xml:lang mismatch" },
|
|
164
|
+
m6b1q3: { engineRules: [], scope: "static", gap: true, note: "role-specific accessible name (menuitem)" },
|
|
165
|
+
ffbc54: { engineRules: [], scope: "static", gap: true, note: "2.1.4 single-character keyboard shortcuts" },
|
|
166
|
+
ye5d6e: { engineRules: [], scope: "static", gap: true, note: "presence of a bypass instrument" },
|
|
167
|
+
cf77f2: { engineRules: [], scope: "static", gap: true, note: "presence of a bypass mechanism (landmark or skip link)" },
|
|
168
|
+
"047fe0": { engineRules: [], scope: "static", gap: true, note: "a heading for the non-repeated content (h1-missing is advisory, so it does not count)" },
|
|
169
|
+
"24afc2": { engineRules: [], scope: "static", gap: true, note: "letter-spacing:!important in a style attribute" },
|
|
170
|
+
"78fd32": { engineRules: [], scope: "static", gap: true, note: "line-height:!important in a style attribute" },
|
|
171
|
+
"9e45ec": { engineRules: [], scope: "static", gap: true, note: "word-spacing:!important in a style attribute" },
|
|
172
|
+
"46ca7f": { engineRules: [], scope: "static", gap: true, note: "an element marked decorative that is still exposed (focusable, or with a name)" },
|
|
173
|
+
|
|
174
|
+
// ---- needs a rendered page: routed to the `scan` tier ---------------------------
|
|
175
|
+
afw4f7: { engineRules: [], scope: "rendered", note: "computed contrast; only inline literal colour pairs are decided statically (contrast-literal)" },
|
|
176
|
+
"09o5cg": { engineRules: [], scope: "rendered", note: "AAA enhanced contrast — outside the AA core" },
|
|
177
|
+
oj04fd: { engineRules: [], scope: "rendered", note: "visible focus (2.4.7) — probed by scan --runtime local" },
|
|
178
|
+
"80af7b": { engineRules: [], scope: "rendered" },
|
|
179
|
+
a1b64e: { engineRules: [], scope: "rendered" },
|
|
180
|
+
ebe86a: { engineRules: [], scope: "rendered" },
|
|
181
|
+
"0ssw9k": { engineRules: [], scope: "rendered" },
|
|
182
|
+
"59br37": { engineRules: [], scope: "rendered", note: "clipping under zoom — probed by scan --runtime local" },
|
|
183
|
+
b33eff: { engineRules: [], scope: "rendered", note: "orientation lock via CSS transform" },
|
|
184
|
+
"3e12e1": { engineRules: [], scope: "rendered" },
|
|
185
|
+
efbfc7: { engineRules: [], scope: "rendered" },
|
|
186
|
+
akn7bn: { engineRules: [], scope: "rendered", note: "needs the iframe's own document" },
|
|
187
|
+
aaa1bf: { engineRules: [], scope: "rendered", note: "needs the media's duration" },
|
|
188
|
+
"0va7u6": { engineRules: [], scope: "rendered", note: "text baked into a graphic" },
|
|
189
|
+
|
|
190
|
+
// ---- needs a human/AI judgment: routed to the adjudication phase ----------------
|
|
191
|
+
qt1vmo: { engineRules: [], scope: "judgment", note: "is the alt relevant (1.1.1)" },
|
|
192
|
+
e88epe: { engineRules: [], scope: "judgment", note: "is the image genuinely decorative" },
|
|
193
|
+
"5effbb": { engineRules: [], scope: "judgment", note: "link purpose in context (2.4.4)" },
|
|
194
|
+
aizyf1: { engineRules: [], scope: "judgment" },
|
|
195
|
+
b20e66: { engineRules: [], scope: "judgment" },
|
|
196
|
+
fd3a94: { engineRules: [], scope: "judgment" },
|
|
197
|
+
"4b1c6c": { engineRules: [], scope: "judgment" },
|
|
198
|
+
b49b2e: { engineRules: [], scope: "judgment", note: "is the heading descriptive (2.4.6)" },
|
|
199
|
+
cc0f0a: { engineRules: [], scope: "judgment" },
|
|
200
|
+
c4a8a4: { engineRules: [], scope: "judgment" },
|
|
201
|
+
"36b590": { engineRules: [], scope: "judgment" },
|
|
202
|
+
"9bd38c": { engineRules: [], scope: "judgment", note: "sensory characteristics (1.3.3)" },
|
|
203
|
+
off6ek: { engineRules: [], scope: "judgment", note: "does the subtag match the actual text language" },
|
|
204
|
+
ucwvc8: { engineRules: [], scope: "judgment" },
|
|
205
|
+
"7677a9": { engineRules: [], scope: "judgment" },
|
|
206
|
+
c249d5: { engineRules: [], scope: "judgment" },
|
|
207
|
+
"1a02b0": { engineRules: [], scope: "judgment" },
|
|
208
|
+
e7aa44: { engineRules: [], scope: "judgment" },
|
|
209
|
+
"2eb176": { engineRules: [], scope: "judgment" },
|
|
210
|
+
afb423: { engineRules: [], scope: "judgment" },
|
|
211
|
+
eac66b: { engineRules: [], scope: "judgment" },
|
|
212
|
+
ab4d13: { engineRules: [], scope: "judgment" },
|
|
213
|
+
c5a4ea: { engineRules: [], scope: "judgment" },
|
|
214
|
+
"1ea59c": { engineRules: [], scope: "judgment" },
|
|
215
|
+
"1ec09b": { engineRules: [], scope: "judgment" },
|
|
216
|
+
c3232f: { engineRules: [], scope: "judgment" },
|
|
217
|
+
d7ba54: { engineRules: [], scope: "judgment" },
|
|
218
|
+
ee13b5: { engineRules: [], scope: "judgment" },
|
|
219
|
+
fd26cf: { engineRules: [], scope: "judgment" },
|
|
220
|
+
|
|
221
|
+
// ---- withdrawn upstream --------------------------------------------------------
|
|
222
|
+
"9eb3f6": { engineRules: [], scope: "deprecated" },
|
|
223
|
+
f196ce: { engineRules: [], scope: "deprecated" },
|
|
224
|
+
ac7dc6: { engineRules: [], scope: "deprecated" },
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export interface ActTestcase {
|
|
228
|
+
ruleId: string;
|
|
229
|
+
ruleName: string;
|
|
230
|
+
testcaseId: string;
|
|
231
|
+
title: string;
|
|
232
|
+
expected: "passed" | "failed" | "inapplicable";
|
|
233
|
+
wcag: string[];
|
|
234
|
+
html: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** ACT Rules Format consistency, as measured here. */
|
|
238
|
+
export type ActVerdict =
|
|
239
|
+
| "consistent" // every `failed` caught, zero finding on `passed`/`inapplicable`
|
|
240
|
+
| "partially-consistent" // zero unexplained false positive, but some `failed` not caught
|
|
241
|
+
| "divergent" // every `failed` caught; the only reports on clean cases are argued below
|
|
242
|
+
| "inconsistent" // at least one UNEXPLAINED `passed`/`inapplicable` reported as failing
|
|
243
|
+
| "not-scored"; // rendered / judgment / deprecated / not implemented
|
|
244
|
+
|
|
245
|
+
export interface ActRuleResult {
|
|
246
|
+
ruleId: string;
|
|
247
|
+
ruleName: string;
|
|
248
|
+
wcag: string[];
|
|
249
|
+
mapping: ActMapping;
|
|
250
|
+
verdict: ActVerdict;
|
|
251
|
+
failed: number;
|
|
252
|
+
caught: number;
|
|
253
|
+
clean: number; // passed + inapplicable
|
|
254
|
+
falsePositives: { testcaseId: string; title: string; ruleIds: string[] }[];
|
|
255
|
+
/** Clean cases this engine reports on DELIBERATELY, each with its recorded reason. */
|
|
256
|
+
divergences: { testcaseId: string; title: string; ruleIds: string[]; reason: string }[];
|
|
257
|
+
missed: { testcaseId: string; title: string }[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Normative findings from the mapped engine rules only — see the header for why. */
|
|
261
|
+
function mappedFindings(html: string, testcaseId: string, engineRules: string[]): string[] {
|
|
262
|
+
if (!engineRules.length) return [];
|
|
263
|
+
const only = new Set(engineRules);
|
|
264
|
+
return [
|
|
265
|
+
...new Set(
|
|
266
|
+
runRules(parseSource(html, `${testcaseId}.html`), only)
|
|
267
|
+
.filter((f) => !f.advisory)
|
|
268
|
+
.map((f) => f.ruleId),
|
|
269
|
+
),
|
|
270
|
+
].sort();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const VERDICT_LABEL: Record<ActVerdict, string> = {
|
|
274
|
+
consistent: "✅ consistent",
|
|
275
|
+
divergent: "🟦 divergent (documented)",
|
|
276
|
+
"partially-consistent": "🟨 partially consistent",
|
|
277
|
+
inconsistent: "❌ inconsistent",
|
|
278
|
+
"not-scored": "—",
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const SCOPE_LABEL: Record<ActScope, string> = {
|
|
282
|
+
static: "source",
|
|
283
|
+
rendered: "rendered (`scan`)",
|
|
284
|
+
judgment: "judgment (agent)",
|
|
285
|
+
deprecated: "withdrawn upstream",
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/** Render the published conformance matrix (skills/ultra11y/references/act.md). Generated,
|
|
289
|
+
* never hand-written: `tests/act-matrix-sync.test.ts` fails if the committed file drifts
|
|
290
|
+
* from what the current engine actually scores. */
|
|
291
|
+
export function renderActMatrix(testcases: ActTestcase[], source: string): string {
|
|
292
|
+
const rs = evaluateAct(testcases);
|
|
293
|
+
const scored = rs.filter((r) => r.verdict !== "not-scored");
|
|
294
|
+
const caught = scored.reduce((n, r) => n + r.caught, 0);
|
|
295
|
+
const failed = scored.reduce((n, r) => n + r.failed, 0);
|
|
296
|
+
const clean = scored.reduce((n, r) => n + r.clean, 0);
|
|
297
|
+
const divergences = rs.reduce((n, r) => n + r.divergences.length, 0);
|
|
298
|
+
const count = (v: ActVerdict): number => rs.filter((r) => r.verdict === v).length;
|
|
299
|
+
|
|
300
|
+
const out: string[] = [];
|
|
301
|
+
out.push(`<!-- GENERATED from the vendored W3C ACT corpus by \`pnpm run build:act\` — do not edit by hand. -->`, "");
|
|
302
|
+
out.push("# ACT conformance — how this engine scores against a corpus it did not write", "");
|
|
303
|
+
out.push(
|
|
304
|
+
"The recall matrix in this repository proves that each rule fires on the defect written FOR it.",
|
|
305
|
+
"That is circular. The [W3C ACT-Rules Community Group](https://act-rules.github.io) publishes rules",
|
|
306
|
+
"with `passed` / `failed` / `inapplicable` examples authored independently of this project, so running",
|
|
307
|
+
"the engine over them measures something a self-authored fixture cannot.",
|
|
308
|
+
"",
|
|
309
|
+
"**How to read this.** An ACT example asserts something about ITS OWN rule and nothing else, so each",
|
|
310
|
+
"case is judged only with the engine rules mapped to that ACT rule — never with the whole engine.",
|
|
311
|
+
"Only normative findings count (an `advisory` recommendation never flips a criterion, so it cannot",
|
|
312
|
+
"fail a case either). Rules needing a rendered page or a human call are declared, not scored — saying",
|
|
313
|
+
"so beats quietly scoring zero.",
|
|
314
|
+
"",
|
|
315
|
+
);
|
|
316
|
+
out.push("## Summary", "");
|
|
317
|
+
out.push(`| | |`, `|---|---|`);
|
|
318
|
+
out.push(`| Corpus | ${testcases.length} examples across ${new Set(testcases.map((t) => t.ruleId)).size} ACT rules |`);
|
|
319
|
+
out.push(`| Source | ${source} |`);
|
|
320
|
+
out.push(`| Rules scored | **${scored.length}** |`);
|
|
321
|
+
out.push(`| Failed examples caught | **${caught} / ${failed}** |`);
|
|
322
|
+
out.push(`| Clean examples left alone | **${clean} / ${clean}** — no unexplained false positive |`);
|
|
323
|
+
out.push(`| Consistent | ${count("consistent")} |`);
|
|
324
|
+
out.push(`| Divergent (documented, ${divergences} case${divergences === 1 ? "" : "s"}) | ${count("divergent")} |`);
|
|
325
|
+
out.push(`| Partially consistent | ${count("partially-consistent")} |`);
|
|
326
|
+
out.push(`| **Inconsistent** | **${count("inconsistent")}** |`);
|
|
327
|
+
out.push(`| Declared gaps (static, not implemented) | ${rs.filter((r) => r.mapping.gap).length} |`);
|
|
328
|
+
out.push("");
|
|
329
|
+
out.push(
|
|
330
|
+
"*Partially consistent* is the ACT Rules Format's own term for a tool that never reports a false",
|
|
331
|
+
"positive but does not catch everything — it is a coverage statement, not an error. *Divergent* means",
|
|
332
|
+
"every deviation on that rule is listed below with the argument for it.",
|
|
333
|
+
"",
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
if (scored.length) {
|
|
337
|
+
out.push("## Scored rules", "");
|
|
338
|
+
out.push("| ACT rule | WCAG | Engine rules | Verdict | Caught |", "|---|---|---|---|---|");
|
|
339
|
+
for (const r of scored) {
|
|
340
|
+
out.push(
|
|
341
|
+
`| [${r.ruleName}](https://act-rules.github.io/rules/${r.ruleId}) | ${r.wcag.join(", ") || "—"} | ` +
|
|
342
|
+
`${r.mapping.engineRules.map((x) => `\`${x}\``).join(", ") || "—"} | ${VERDICT_LABEL[r.verdict]} | ${r.caught}/${r.failed} |`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
out.push("");
|
|
346
|
+
const notes: string[] = [];
|
|
347
|
+
for (const r of scored) {
|
|
348
|
+
if (r.mapping.note) notes.push(`- **${r.ruleName}** — ${r.mapping.note}`);
|
|
349
|
+
for (const d of r.divergences) notes.push(`- **${r.ruleName}** · reports on *${d.title}*: ${d.reason}`);
|
|
350
|
+
}
|
|
351
|
+
if (notes.length) out.push("### Notes and documented divergences", "", ...notes, "");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const gaps = rs.filter((r) => r.mapping.gap);
|
|
355
|
+
if (gaps.length) {
|
|
356
|
+
out.push("## Declared gaps — statically decidable, not implemented yet", "");
|
|
357
|
+
out.push("| ACT rule | WCAG | Why it is not covered |", "|---|---|---|");
|
|
358
|
+
for (const r of gaps)
|
|
359
|
+
out.push(`| [${r.ruleName}](https://act-rules.github.io/rules/${r.ruleId}) | ${r.wcag.join(", ") || "—"} | ${r.mapping.note ?? ""} |`);
|
|
360
|
+
out.push("");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const outOfScope = rs.filter((r) => !r.mapping.gap && r.verdict === "not-scored");
|
|
364
|
+
if (outOfScope.length) {
|
|
365
|
+
out.push("## Out of the static engine's reach — routed, not ignored", "");
|
|
366
|
+
out.push("| ACT rule | WCAG | Decided by |", "|---|---|---|");
|
|
367
|
+
for (const r of outOfScope) {
|
|
368
|
+
out.push(
|
|
369
|
+
`| [${r.ruleName}](https://act-rules.github.io/rules/${r.ruleId}) | ${r.wcag.join(", ") || "—"} | ${SCOPE_LABEL[r.mapping.scope]}${r.mapping.note ? ` — ${r.mapping.note}` : ""} |`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
out.push("");
|
|
373
|
+
}
|
|
374
|
+
out.push(
|
|
375
|
+
"The corpus is vendored at `scripts/vendor/act-testcases.json` (refreshed by",
|
|
376
|
+
"`pnpm run build:act:refresh`, and daily by the act-refresh workflow) so the suite stays",
|
|
377
|
+
"offline and deterministic.",
|
|
378
|
+
"ACT-Rules Community Group test cases are © their contributors under the W3C Software and",
|
|
379
|
+
"Document License — see `NOTICE`.",
|
|
380
|
+
"",
|
|
381
|
+
);
|
|
382
|
+
return out.join("\n");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function evaluateAct(testcases: ActTestcase[], map: Record<string, ActMapping> = ACT_RULES): ActRuleResult[] {
|
|
386
|
+
const byRule = new Map<string, ActTestcase[]>();
|
|
387
|
+
for (const tc of testcases) {
|
|
388
|
+
const list = byRule.get(tc.ruleId);
|
|
389
|
+
if (list) list.push(tc);
|
|
390
|
+
else byRule.set(tc.ruleId, [tc]);
|
|
391
|
+
}
|
|
392
|
+
const out: ActRuleResult[] = [];
|
|
393
|
+
for (const [ruleId, cases] of [...byRule].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
394
|
+
const mapping = map[ruleId] ?? { engineRules: [], scope: "static" as ActScope, gap: true, note: "unclassified — new upstream rule" };
|
|
395
|
+
const failedCases = cases.filter((c) => c.expected === "failed");
|
|
396
|
+
const cleanCases = cases.filter((c) => c.expected !== "failed");
|
|
397
|
+
const scored = mapping.scope === "static" && mapping.engineRules.length > 0;
|
|
398
|
+
|
|
399
|
+
const falsePositives: ActRuleResult["falsePositives"] = [];
|
|
400
|
+
const divergences: ActRuleResult["divergences"] = [];
|
|
401
|
+
const missed: ActRuleResult["missed"] = [];
|
|
402
|
+
let caught = 0;
|
|
403
|
+
if (scored) {
|
|
404
|
+
for (const c of failedCases) {
|
|
405
|
+
const ids = mappedFindings(c.html, c.testcaseId, mapping.engineRules);
|
|
406
|
+
if (ids.length) caught++;
|
|
407
|
+
else missed.push({ testcaseId: c.testcaseId, title: c.title });
|
|
408
|
+
}
|
|
409
|
+
for (const c of cleanCases) {
|
|
410
|
+
const ids = mappedFindings(c.html, c.testcaseId, mapping.engineRules);
|
|
411
|
+
if (!ids.length) continue;
|
|
412
|
+
const reason = mapping.divergences?.[c.testcaseId];
|
|
413
|
+
if (reason) divergences.push({ testcaseId: c.testcaseId, title: c.title, ruleIds: ids, reason });
|
|
414
|
+
else falsePositives.push({ testcaseId: c.testcaseId, title: c.title, ruleIds: ids });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const verdict: ActVerdict = !scored
|
|
418
|
+
? "not-scored"
|
|
419
|
+
: falsePositives.length > 0
|
|
420
|
+
? "inconsistent"
|
|
421
|
+
: caught < failedCases.length
|
|
422
|
+
? "partially-consistent"
|
|
423
|
+
: divergences.length > 0
|
|
424
|
+
? "divergent"
|
|
425
|
+
: "consistent";
|
|
426
|
+
out.push({
|
|
427
|
+
ruleId,
|
|
428
|
+
ruleName: cases[0]!.ruleName,
|
|
429
|
+
wcag: cases[0]!.wcag,
|
|
430
|
+
mapping,
|
|
431
|
+
verdict,
|
|
432
|
+
failed: failedCases.length,
|
|
433
|
+
caught,
|
|
434
|
+
clean: cleanCases.length,
|
|
435
|
+
falsePositives,
|
|
436
|
+
divergences,
|
|
437
|
+
missed,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
return out;
|
|
441
|
+
}
|