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/aria.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// WAI-ARIA 1.2 reference tables — the vocabulary the ARIA validation rules check against.
|
|
2
|
+
// Kept in one module (rather than scattered across rules) because these are FACTS about the
|
|
3
|
+
// specification, not policy: every entry is a state/property, value type, required-state or
|
|
4
|
+
// required-context relationship defined by https://www.w3.org/TR/wai-aria-1.2/.
|
|
5
|
+
//
|
|
6
|
+
// Deliberately conservative on two axes:
|
|
7
|
+
// • only relationships the spec states outright are listed — a "should" is not encoded
|
|
8
|
+
// here, because a rule built on one manufactures false positives;
|
|
9
|
+
// • deprecated-but-still-defined attributes (aria-dropeffect, aria-grabbed) count as
|
|
10
|
+
// DEFINED: reporting them as unknown would be wrong, and deprecation is not a WCAG
|
|
11
|
+
// failure.
|
|
12
|
+
|
|
13
|
+
/** Every state and property defined by WAI-ARIA 1.2. */
|
|
14
|
+
export const ARIA_ATTRS = new Set([
|
|
15
|
+
"aria-activedescendant",
|
|
16
|
+
"aria-atomic",
|
|
17
|
+
"aria-autocomplete",
|
|
18
|
+
"aria-braillelabel",
|
|
19
|
+
"aria-brailleroledescription",
|
|
20
|
+
"aria-busy",
|
|
21
|
+
"aria-checked",
|
|
22
|
+
"aria-colcount",
|
|
23
|
+
"aria-colindex",
|
|
24
|
+
"aria-colindextext",
|
|
25
|
+
"aria-colspan",
|
|
26
|
+
"aria-controls",
|
|
27
|
+
"aria-current",
|
|
28
|
+
"aria-describedby",
|
|
29
|
+
"aria-description",
|
|
30
|
+
"aria-details",
|
|
31
|
+
"aria-disabled",
|
|
32
|
+
"aria-dropeffect",
|
|
33
|
+
"aria-errormessage",
|
|
34
|
+
"aria-expanded",
|
|
35
|
+
"aria-flowto",
|
|
36
|
+
"aria-grabbed",
|
|
37
|
+
"aria-haspopup",
|
|
38
|
+
"aria-hidden",
|
|
39
|
+
"aria-invalid",
|
|
40
|
+
"aria-keyshortcuts",
|
|
41
|
+
"aria-label",
|
|
42
|
+
"aria-labelledby",
|
|
43
|
+
"aria-level",
|
|
44
|
+
"aria-live",
|
|
45
|
+
"aria-modal",
|
|
46
|
+
"aria-multiline",
|
|
47
|
+
"aria-multiselectable",
|
|
48
|
+
"aria-orientation",
|
|
49
|
+
"aria-owns",
|
|
50
|
+
"aria-placeholder",
|
|
51
|
+
"aria-posinset",
|
|
52
|
+
"aria-pressed",
|
|
53
|
+
"aria-readonly",
|
|
54
|
+
"aria-relevant",
|
|
55
|
+
"aria-required",
|
|
56
|
+
"aria-roledescription",
|
|
57
|
+
"aria-rowcount",
|
|
58
|
+
"aria-rowindex",
|
|
59
|
+
"aria-rowindextext",
|
|
60
|
+
"aria-rowspan",
|
|
61
|
+
"aria-selected",
|
|
62
|
+
"aria-setsize",
|
|
63
|
+
"aria-sort",
|
|
64
|
+
"aria-valuemax",
|
|
65
|
+
"aria-valuemin",
|
|
66
|
+
"aria-valuenow",
|
|
67
|
+
"aria-valuetext",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
export type AriaValueType =
|
|
71
|
+
| { kind: "token"; values: string[] } // one of a fixed set
|
|
72
|
+
| { kind: "tokenlist"; values: string[] } // space-separated, each from a fixed set
|
|
73
|
+
| { kind: "integer" } // a whole number
|
|
74
|
+
| { kind: "number" } // a real number
|
|
75
|
+
| { kind: "string" } // anything, including empty
|
|
76
|
+
| { kind: "idref" } // a single element id
|
|
77
|
+
| { kind: "idrefs" }; // space-separated element ids
|
|
78
|
+
|
|
79
|
+
const BOOL = ["true", "false"];
|
|
80
|
+
|
|
81
|
+
/** Value type per attribute. Attributes absent from this map accept any string. */
|
|
82
|
+
export const ARIA_VALUE_TYPE: Record<string, AriaValueType> = {
|
|
83
|
+
"aria-atomic": { kind: "token", values: BOOL },
|
|
84
|
+
"aria-busy": { kind: "token", values: BOOL },
|
|
85
|
+
"aria-disabled": { kind: "token", values: BOOL },
|
|
86
|
+
"aria-modal": { kind: "token", values: BOOL },
|
|
87
|
+
"aria-multiline": { kind: "token", values: BOOL },
|
|
88
|
+
"aria-multiselectable": { kind: "token", values: BOOL },
|
|
89
|
+
"aria-readonly": { kind: "token", values: BOOL },
|
|
90
|
+
"aria-required": { kind: "token", values: BOOL },
|
|
91
|
+
// `undefined` is a legal literal value for these three (it means "not applicable").
|
|
92
|
+
"aria-hidden": { kind: "token", values: [...BOOL, "undefined"] },
|
|
93
|
+
"aria-expanded": { kind: "token", values: [...BOOL, "undefined"] },
|
|
94
|
+
"aria-selected": { kind: "token", values: [...BOOL, "undefined"] },
|
|
95
|
+
"aria-grabbed": { kind: "token", values: [...BOOL, "undefined"] },
|
|
96
|
+
// tristate
|
|
97
|
+
"aria-checked": { kind: "token", values: [...BOOL, "mixed", "undefined"] },
|
|
98
|
+
"aria-pressed": { kind: "token", values: [...BOOL, "mixed", "undefined"] },
|
|
99
|
+
// enumerations
|
|
100
|
+
"aria-autocomplete": { kind: "token", values: ["inline", "list", "both", "none"] },
|
|
101
|
+
"aria-current": { kind: "token", values: [...BOOL, "page", "step", "location", "date", "time"] },
|
|
102
|
+
"aria-haspopup": { kind: "token", values: [...BOOL, "menu", "listbox", "tree", "grid", "dialog"] },
|
|
103
|
+
"aria-invalid": { kind: "token", values: [...BOOL, "grammar", "spelling"] },
|
|
104
|
+
"aria-live": { kind: "token", values: ["off", "polite", "assertive"] },
|
|
105
|
+
"aria-orientation": { kind: "token", values: ["horizontal", "vertical", "undefined"] },
|
|
106
|
+
"aria-sort": { kind: "token", values: ["ascending", "descending", "none", "other"] },
|
|
107
|
+
"aria-dropeffect": { kind: "tokenlist", values: ["copy", "execute", "link", "move", "none", "popup"] },
|
|
108
|
+
"aria-relevant": { kind: "tokenlist", values: ["additions", "all", "removals", "text"] },
|
|
109
|
+
// numeric
|
|
110
|
+
"aria-colcount": { kind: "integer" },
|
|
111
|
+
"aria-colindex": { kind: "integer" },
|
|
112
|
+
"aria-colspan": { kind: "integer" },
|
|
113
|
+
"aria-level": { kind: "integer" },
|
|
114
|
+
"aria-posinset": { kind: "integer" },
|
|
115
|
+
"aria-rowcount": { kind: "integer" },
|
|
116
|
+
"aria-rowindex": { kind: "integer" },
|
|
117
|
+
"aria-rowspan": { kind: "integer" },
|
|
118
|
+
"aria-setsize": { kind: "integer" },
|
|
119
|
+
"aria-valuemax": { kind: "number" },
|
|
120
|
+
"aria-valuemin": { kind: "number" },
|
|
121
|
+
"aria-valuenow": { kind: "number" },
|
|
122
|
+
// references
|
|
123
|
+
"aria-activedescendant": { kind: "idref" },
|
|
124
|
+
"aria-details": { kind: "idref" },
|
|
125
|
+
"aria-errormessage": { kind: "idref" },
|
|
126
|
+
"aria-controls": { kind: "idrefs" },
|
|
127
|
+
"aria-describedby": { kind: "idrefs" },
|
|
128
|
+
"aria-flowto": { kind: "idrefs" },
|
|
129
|
+
"aria-labelledby": { kind: "idrefs" },
|
|
130
|
+
"aria-owns": { kind: "idrefs" },
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Is `value` a legal literal for `attr`? Unknown attributes and free-form strings pass. */
|
|
134
|
+
export function isValidAriaValue(attrName: string, value: string): boolean {
|
|
135
|
+
const type = ARIA_VALUE_TYPE[attrName];
|
|
136
|
+
if (!type) return true;
|
|
137
|
+
const v = value.trim();
|
|
138
|
+
switch (type.kind) {
|
|
139
|
+
case "token":
|
|
140
|
+
return type.values.includes(v.toLowerCase());
|
|
141
|
+
case "tokenlist": {
|
|
142
|
+
const parts = v.split(/\s+/).filter(Boolean);
|
|
143
|
+
return parts.length > 0 && parts.every((p) => type.values.includes(p.toLowerCase()));
|
|
144
|
+
}
|
|
145
|
+
case "integer":
|
|
146
|
+
return /^-?\d+$/.test(v);
|
|
147
|
+
case "number":
|
|
148
|
+
return /^-?\d+(\.\d+)?$/.test(v);
|
|
149
|
+
case "idref":
|
|
150
|
+
// A single-reference property pointing at several ids resolves to nothing.
|
|
151
|
+
// (Whether the id EXISTS is aria-ref-missing-id's business, not this rule's.)
|
|
152
|
+
return v.split(/\s+/).filter(Boolean).length <= 1;
|
|
153
|
+
default:
|
|
154
|
+
// idrefs/string: emptiness and resolution are other rules' business.
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Roles whose ARIA definition REQUIRES these states — without them the widget's state is
|
|
160
|
+
* simply not exposed. Only roles the spec marks "Required States and Properties". */
|
|
161
|
+
export const ARIA_REQUIRED_ATTRS: Record<string, string[]> = {
|
|
162
|
+
checkbox: ["aria-checked"],
|
|
163
|
+
combobox: ["aria-expanded", "aria-controls"],
|
|
164
|
+
heading: ["aria-level"],
|
|
165
|
+
menuitemcheckbox: ["aria-checked"],
|
|
166
|
+
menuitemradio: ["aria-checked"],
|
|
167
|
+
meter: ["aria-valuenow"],
|
|
168
|
+
option: ["aria-selected"],
|
|
169
|
+
radio: ["aria-checked"],
|
|
170
|
+
scrollbar: ["aria-controls", "aria-valuenow"],
|
|
171
|
+
slider: ["aria-valuenow"],
|
|
172
|
+
switch: ["aria-checked"],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** Roles that are only meaningful inside a specific container role ("Required Context
|
|
176
|
+
* Role"). Values are the accepted container roles, including the implicit ones a native
|
|
177
|
+
* element provides. */
|
|
178
|
+
export const ARIA_REQUIRED_PARENT: Record<string, string[]> = {
|
|
179
|
+
columnheader: ["row"],
|
|
180
|
+
gridcell: ["row"],
|
|
181
|
+
listitem: ["list", "directory"],
|
|
182
|
+
menuitem: ["menu", "menubar", "group"],
|
|
183
|
+
menuitemcheckbox: ["menu", "menubar", "group"],
|
|
184
|
+
menuitemradio: ["menu", "menubar", "group"],
|
|
185
|
+
option: ["listbox", "group"],
|
|
186
|
+
row: ["grid", "rowgroup", "table", "treegrid"],
|
|
187
|
+
rowgroup: ["grid", "table", "treegrid"],
|
|
188
|
+
rowheader: ["row"],
|
|
189
|
+
tab: ["tablist"],
|
|
190
|
+
treeitem: ["tree", "group"],
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** Native elements that carry an implicit CONTAINER role, for the required-context check.
|
|
194
|
+
* Only the unambiguous cases — a role that depends on attributes or ancestry is left out. */
|
|
195
|
+
export const IMPLICIT_CONTAINER_ROLE: Record<string, string> = {
|
|
196
|
+
ul: "list",
|
|
197
|
+
ol: "list",
|
|
198
|
+
li: "listitem",
|
|
199
|
+
table: "table",
|
|
200
|
+
thead: "rowgroup",
|
|
201
|
+
tbody: "rowgroup",
|
|
202
|
+
tfoot: "rowgroup",
|
|
203
|
+
tr: "row",
|
|
204
|
+
td: "cell",
|
|
205
|
+
th: "columnheader",
|
|
206
|
+
datalist: "listbox",
|
|
207
|
+
optgroup: "group",
|
|
208
|
+
option: "option",
|
|
209
|
+
fieldset: "group",
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/** Roles that PROHIBIT an author-provided name: naming them exposes text the platform has
|
|
213
|
+
* nowhere to put, and assistive tech either drops it or announces it out of context. */
|
|
214
|
+
export const NAME_PROHIBITED_ROLES = new Set([
|
|
215
|
+
"caption",
|
|
216
|
+
"code",
|
|
217
|
+
"deletion",
|
|
218
|
+
"emphasis",
|
|
219
|
+
"generic",
|
|
220
|
+
"insertion",
|
|
221
|
+
"mark",
|
|
222
|
+
"none",
|
|
223
|
+
"paragraph",
|
|
224
|
+
"presentation",
|
|
225
|
+
"strong",
|
|
226
|
+
"subscript",
|
|
227
|
+
"superscript",
|
|
228
|
+
"term",
|
|
229
|
+
"time",
|
|
230
|
+
]);
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// `audit` — run the static engine over the inputs and aggregate findings into an
|
|
2
|
+
// AuditResult: a preliminary, engine-only verdict per criterion (C/NC/NA for the
|
|
3
|
+
// static criteria it can decide; "manual" for everything needing rendering or
|
|
4
|
+
// judgment, surfaced as residual risks). `report` renders this; Claude completes
|
|
5
|
+
// the manual criteria.
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import type { AuditResult, CriterionResult, Finding, ResidualRisk, Status, GuidelineTally } from "./types.js";
|
|
8
|
+
import { VERSION, SCHEMA_VERSION } from "./types.js";
|
|
9
|
+
import { allSC, allGuidelines } from "./wcag.js";
|
|
10
|
+
import { parseSource } from "./parse/source.js";
|
|
11
|
+
import { attr, elementsByTag, type Doc, type CaptureProvenance } from "./parse/html.js";
|
|
12
|
+
import { computeCaptureCoverage, enrichCaptureOrigins, readCaptureDir, capturesForSources } from "./capture.js";
|
|
13
|
+
import { isFullDocument } from "./rules/rule.js";
|
|
14
|
+
import { runRules } from "./rules/registry.js";
|
|
15
|
+
import { runCrossRules } from "./rules/cross-registry.js";
|
|
16
|
+
import { listPacks } from "./standards/registry.js";
|
|
17
|
+
import { runPackRules } from "./standards/pack-rules.js";
|
|
18
|
+
import { buildGraphAndDocs } from "./graph/build.js";
|
|
19
|
+
import type { DepGraph } from "./graph/graph.js";
|
|
20
|
+
import { discover } from "./discover.js";
|
|
21
|
+
import { GRAPH_ONLY_EXT } from "./glob.js";
|
|
22
|
+
import { readText, today } from "./util.js";
|
|
23
|
+
|
|
24
|
+
export type DedupMode = "exact" | "normalized" | "off";
|
|
25
|
+
|
|
26
|
+
export interface AuditInput {
|
|
27
|
+
inputs: string[];
|
|
28
|
+
stdin?: string;
|
|
29
|
+
forceJsx?: boolean;
|
|
30
|
+
include?: string[];
|
|
31
|
+
exclude?: string[];
|
|
32
|
+
ext?: string[];
|
|
33
|
+
// scale controls
|
|
34
|
+
changed?: boolean; // audit only git-changed files
|
|
35
|
+
since?: string; // git ref to diff against (implies changed)
|
|
36
|
+
staged?: boolean; // audit exactly the staged index snapshot (strict pre-commit scope)
|
|
37
|
+
dedup?: DedupMode; // collapse identical files to one canonical audit (default exact)
|
|
38
|
+
maxFiles?: number; // hard cap on canonical files audited (logged truncation)
|
|
39
|
+
graph?: boolean; // also run cross-file rules over a dependency graph (--graph)
|
|
40
|
+
captureCoverage?: boolean; // compute scope.captureCoverage (implies a graph pass)
|
|
41
|
+
captureDir?: string; // dir scanned for the repo-wide capture set (coverage); default .ultra11y/captures
|
|
42
|
+
// In --changed/--since/--staged mode, also ingest the captures under `captureDir`
|
|
43
|
+
// whose provenance sourceFile matches one of the diffed files (capturesForSources) —
|
|
44
|
+
// a capture is rarely itself part of the diff, so the audit would otherwise stay
|
|
45
|
+
// blind to the real rendered DOM for a touched component. No-op outside diff mode
|
|
46
|
+
// (a full scan's capture ingestion is the CLI appending captureDir as a top-level
|
|
47
|
+
// input instead — see cmdAudit's `useCaptures`).
|
|
48
|
+
captureDiff?: boolean;
|
|
49
|
+
noDefaultExcludes?: boolean; // also audit test/spec/story/__tests__ markup
|
|
50
|
+
onWarn?: (msg: string) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const has = (d: Doc, ...tags: string[]): boolean => d.elements.some((e) => tags.includes(e.tag));
|
|
54
|
+
|
|
55
|
+
// Applicability predicate per STATIC success criterion (the only SCs the engine
|
|
56
|
+
// reports Conforming when clean): is there any relevant element to check? If not,
|
|
57
|
+
// the SC is NA rather than a hollow "C". WCAG SCs are coarser than the rules, so the
|
|
58
|
+
// static set is deliberately tiny (see scripts/build-standards.mjs); every other
|
|
59
|
+
// mapped SC raises only DEFINITE non-conformities and stays "manual" otherwise.
|
|
60
|
+
const APPLICABLE: Record<string, (d: Doc) => boolean> = {
|
|
61
|
+
"1.4.2": (d) => has(d, "audio", "video"), // Audio Control — autoplay-media (audio branch)
|
|
62
|
+
"2.4.2": (d) => isFullDocument(d), // Page Titled — title-missing-empty
|
|
63
|
+
"3.1.1": (d) => isFullDocument(d), // Language of Page — html-lang-missing / lang-invalid
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function residualReason(automatability: string): string {
|
|
67
|
+
return automatability === "needs-rendering"
|
|
68
|
+
? "Needs a rendered DOM (contrast, focus visibility, zoom/reflow, target size) — decide via `scan`."
|
|
69
|
+
: "Judgement criterion — adjudicated by the agent from source/context (`verify --manual`, gated).";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Streaming accumulator: parse → run rules → fold → DISCARD each Doc, so the
|
|
73
|
+
// engine never holds a whole repo in memory. The only cross-document state is
|
|
74
|
+
// (a) the flat findings list (bounded by finding count, not source size) and
|
|
75
|
+
// (b) per-criterion applicability, OR-folded across docs (NA only if NO doc made
|
|
76
|
+
// the criterion applicable — never because findings happened to be absent).
|
|
77
|
+
interface Accum {
|
|
78
|
+
byCriterion: Map<string, Finding[]>;
|
|
79
|
+
applicable: Map<string, boolean>; // static criteria only
|
|
80
|
+
allFindings: Finding[];
|
|
81
|
+
packFindings: Finding[]; // declarative pack-rule findings (namespaced pack:<key>:<id>)
|
|
82
|
+
fileCount: number;
|
|
83
|
+
opaqueLibs: Set<string>; // component-library specifiers rendered but not source-analysable
|
|
84
|
+
opaqueFiles: number; // count of source files that render such components
|
|
85
|
+
sfcFiles: number; // .vue/.svelte/.astro source templates audited (verdicts preliminary)
|
|
86
|
+
sfcExts: Set<string>; // which SFC extensions were seen
|
|
87
|
+
captures: { file: string; provenance: CaptureProvenance }[]; // rendered capture files audited
|
|
88
|
+
langCounts: Map<string, number>; // <html lang> primary subtags seen, for repo-language detection
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Precompute the static success criteria + their applicability predicates once.
|
|
92
|
+
const STATIC_PREDS: ReadonlyArray<readonly [string, (d: Doc) => boolean]> = allSC()
|
|
93
|
+
.filter((c) => c.automatability === "static")
|
|
94
|
+
.map((c) => [c.sc, APPLICABLE[c.sc] ?? isFullDocument] as const);
|
|
95
|
+
|
|
96
|
+
function newAccum(): Accum {
|
|
97
|
+
return {
|
|
98
|
+
byCriterion: new Map(),
|
|
99
|
+
applicable: new Map(),
|
|
100
|
+
allFindings: [],
|
|
101
|
+
packFindings: [],
|
|
102
|
+
fileCount: 0,
|
|
103
|
+
opaqueLibs: new Set(),
|
|
104
|
+
opaqueFiles: 0,
|
|
105
|
+
sfcFiles: 0,
|
|
106
|
+
sfcExts: new Set(),
|
|
107
|
+
captures: [],
|
|
108
|
+
langCounts: new Map(),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Primary subtag of an `<html lang>`/`xml:lang` value: "fr-FR" → "fr", "en" → "en".
|
|
113
|
+
// Mirrors the BCP47 primary-subtag reading the html-lang-missing/lang-invalid rules use.
|
|
114
|
+
function primarySubtag(lang: string): string | undefined {
|
|
115
|
+
const m = lang.trim().match(/^[a-z]{1,8}/i);
|
|
116
|
+
return m ? m[0].toLowerCase() : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Fold one parsed document into the accumulator, then let it be GC'd. When a
|
|
120
|
+
* graph is supplied, cross-file findings are added and graph-proven false
|
|
121
|
+
* positives are suppressed (matched by ruleId + line on this same doc). */
|
|
122
|
+
export function foldDoc(acc: Accum, doc: Doc, graph?: DepGraph): void {
|
|
123
|
+
let findings = runRules(doc);
|
|
124
|
+
if (graph) {
|
|
125
|
+
const cross = runCrossRules(doc, graph);
|
|
126
|
+
if (cross.suppress.length) {
|
|
127
|
+
findings = findings.filter((f) => !cross.suppress.some((s) => s.ruleId === f.ruleId && s.line === f.line));
|
|
128
|
+
}
|
|
129
|
+
findings = findings.concat(cross.findings);
|
|
130
|
+
// A cross finding is evidence its criterion is applicable here (the per-doc
|
|
131
|
+
// static predicates only know native elements, not resolved components).
|
|
132
|
+
for (const f of cross.findings) acc.applicable.set(f.criteriaId, true);
|
|
133
|
+
}
|
|
134
|
+
for (const f of findings) {
|
|
135
|
+
acc.allFindings.push(f);
|
|
136
|
+
const arr = acc.byCriterion.get(f.criteriaId) ?? [];
|
|
137
|
+
arr.push(f);
|
|
138
|
+
acc.byCriterion.set(f.criteriaId, arr);
|
|
139
|
+
}
|
|
140
|
+
// Declarative pack rules run AFTER the core rules over the same Doc. Their findings are
|
|
141
|
+
// collected SEPARATELY (never into byCriterion/allFindings) so the core WCAG verdict is
|
|
142
|
+
// untouched — they surface only when a pack projection is derived. A pack without rules
|
|
143
|
+
// is an instant no-op.
|
|
144
|
+
for (const pack of listPacks()) {
|
|
145
|
+
if (pack.rules?.length) acc.packFindings.push(...runPackRules(doc, pack));
|
|
146
|
+
}
|
|
147
|
+
for (const [id, pred] of STATIC_PREDS) {
|
|
148
|
+
if (!acc.applicable.get(id) && pred(doc)) acc.applicable.set(id, true);
|
|
149
|
+
}
|
|
150
|
+
if (doc.opaqueComponents?.length) {
|
|
151
|
+
for (const lib of doc.opaqueComponents) acc.opaqueLibs.add(lib);
|
|
152
|
+
acc.opaqueFiles++;
|
|
153
|
+
}
|
|
154
|
+
if (doc.kind === "sfc") {
|
|
155
|
+
acc.sfcFiles++;
|
|
156
|
+
const e = doc.file.toLowerCase().match(/\.[a-z]+$/)?.[0];
|
|
157
|
+
if (e) acc.sfcExts.add(e);
|
|
158
|
+
}
|
|
159
|
+
if (doc.capture) acc.captures.push({ file: doc.file, provenance: doc.capture });
|
|
160
|
+
const html = elementsByTag(doc, "html")[0];
|
|
161
|
+
const htmlLang = html ? (attr(html, "lang") ?? attr(html, "xml:lang") ?? "").trim() : "";
|
|
162
|
+
const subtag = htmlLang ? primarySubtag(htmlLang) : undefined;
|
|
163
|
+
if (subtag) acc.langCounts.set(subtag, (acc.langCounts.get(subtag) ?? 0) + 1);
|
|
164
|
+
acc.fileCount++;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface FinalizeExtra {
|
|
168
|
+
truncated?: { limit: number; total: number; skipped: number };
|
|
169
|
+
dedup?: { canonicalFiles: number; duplicateFiles: number };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function finalize(acc: Accum, inputs: string[], extra: FinalizeExtra = {}): AuditResult {
|
|
173
|
+
const criteria: CriterionResult[] = [];
|
|
174
|
+
const residualRisks: ResidualRisk[] = [];
|
|
175
|
+
|
|
176
|
+
for (const c of allSC()) {
|
|
177
|
+
const fs = acc.byCriterion.get(c.sc) ?? [];
|
|
178
|
+
// ONLY normative findings drive status + conformancePct. Advisory findings (non-normative
|
|
179
|
+
// recommendations) stay attached to the criterion and in the flat list, but can never
|
|
180
|
+
// flip it to NC — a criterion whose findings are ALL advisory keeps its automatability-
|
|
181
|
+
// driven status (C/NA for static, manual for judgment/needs-rendering).
|
|
182
|
+
const normativeFs = fs.filter((f) => !f.advisory);
|
|
183
|
+
let status: Status;
|
|
184
|
+
let justification: string | undefined;
|
|
185
|
+
|
|
186
|
+
if (c.automatability === "static") {
|
|
187
|
+
const applicable = acc.applicable.get(c.sc) ?? false;
|
|
188
|
+
if (!applicable) {
|
|
189
|
+
status = "NA";
|
|
190
|
+
justification = "No element in scope is concerned by this success criterion.";
|
|
191
|
+
} else if (normativeFs.length > 0) {
|
|
192
|
+
status = "NC";
|
|
193
|
+
} else {
|
|
194
|
+
status = "C";
|
|
195
|
+
}
|
|
196
|
+
} else if (normativeFs.length > 0) {
|
|
197
|
+
// a rule on a needs-rendering / judgment SC raised a DEFINITE failure
|
|
198
|
+
status = "NC";
|
|
199
|
+
} else {
|
|
200
|
+
// engine can't decide — leave it for the agent to adjudicate (`verify --manual`,
|
|
201
|
+
// gated) or the `scan` tier (rendering criteria); never a silent conforming.
|
|
202
|
+
status = "manual";
|
|
203
|
+
residualRisks.push({ criteriaId: c.sc, reason: residualReason(c.automatability), automatability: c.automatability });
|
|
204
|
+
}
|
|
205
|
+
criteria.push({ id: c.sc, guideline: c.guideline, status, findings: fs, ...(justification ? { justification } : {}) });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const guidelines: GuidelineTally[] = allGuidelines().map((g) => {
|
|
209
|
+
const inG = criteria.filter((c) => c.guideline === g.number);
|
|
210
|
+
return {
|
|
211
|
+
key: g.number,
|
|
212
|
+
title: g.title,
|
|
213
|
+
c: inG.filter((c) => c.status === "C").length,
|
|
214
|
+
nc: inG.filter((c) => c.status === "NC").length,
|
|
215
|
+
na: inG.filter((c) => c.status === "NA").length,
|
|
216
|
+
manual: inG.filter((c) => c.status === "manual").length,
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const decided = criteria.filter((c) => c.status === "C" || c.status === "NC");
|
|
221
|
+
const conform = decided.filter((c) => c.status === "C").length;
|
|
222
|
+
const conformancePct = decided.length === 0 ? 100 : Math.round((conform / decided.length) * 100);
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
tool: "ultra11y",
|
|
226
|
+
standard: "wcag",
|
|
227
|
+
version: VERSION,
|
|
228
|
+
schemaVersion: SCHEMA_VERSION,
|
|
229
|
+
date: today(),
|
|
230
|
+
scope: {
|
|
231
|
+
inputs,
|
|
232
|
+
files: acc.fileCount,
|
|
233
|
+
...(extra.truncated ? { truncated: extra.truncated } : {}),
|
|
234
|
+
...(extra.dedup ? { dedup: extra.dedup } : {}),
|
|
235
|
+
...(acc.opaqueLibs.size ? { rendered: { opaqueLibraries: [...acc.opaqueLibs].sort(), files: acc.opaqueFiles } } : {}),
|
|
236
|
+
...(acc.sfcFiles ? { sourceTemplate: { files: acc.sfcFiles, extensions: [...acc.sfcExts].sort() } } : {}),
|
|
237
|
+
...(acc.captures.length
|
|
238
|
+
? {
|
|
239
|
+
captures: {
|
|
240
|
+
files: acc.captures.length,
|
|
241
|
+
components: [...new Set(acc.captures.map((c) => c.provenance.component).filter((x): x is string => !!x))].sort(),
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
: {}),
|
|
245
|
+
...(acc.langCounts.size ? { langs: [...acc.langCounts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([lang]) => lang) } : {}),
|
|
246
|
+
},
|
|
247
|
+
guidelines,
|
|
248
|
+
criteria,
|
|
249
|
+
findings: acc.allFindings,
|
|
250
|
+
...(acc.packFindings.length ? { packFindings: acc.packFindings } : {}),
|
|
251
|
+
residualRisks,
|
|
252
|
+
conformancePct,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Build an AuditResult from already-parsed docs (eager path; used by tests and
|
|
257
|
+
* any in-memory caller). The streaming runAudit produces an identical result. */
|
|
258
|
+
export function buildAudit(docs: Doc[], inputs: string[]): AuditResult {
|
|
259
|
+
const acc = newAccum();
|
|
260
|
+
for (const d of docs) foldDoc(acc, d);
|
|
261
|
+
return finalize(acc, inputs);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function hashContent(content: string, mode: Exclude<DedupMode, "off">): string {
|
|
265
|
+
const norm = mode === "normalized" ? content.replace(/>\s+</g, "><").trim() : content;
|
|
266
|
+
return createHash("sha1").update(norm).digest("hex");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Resolve inputs and audit them in a single streaming pass: read → (dedup) →
|
|
270
|
+
* parse → fold → discard. Bounded memory, deterministic order. */
|
|
271
|
+
export function runAudit(opts: AuditInput): AuditResult {
|
|
272
|
+
const acc = newAccum();
|
|
273
|
+
// Content dedup is off in --changed/--staged mode: a changed file must always be
|
|
274
|
+
// audited, and it could otherwise collapse against an unchanged file we never read.
|
|
275
|
+
const dedupMode: DedupMode = opts.changed || opts.since || opts.staged ? "off" : (opts.dedup ?? "exact");
|
|
276
|
+
const seen = new Set<string>();
|
|
277
|
+
let duplicateFiles = 0;
|
|
278
|
+
let truncated: FinalizeExtra["truncated"];
|
|
279
|
+
|
|
280
|
+
// `--graph` expands the same inputs twice (markup, then markup + .ts/.js). The tree
|
|
281
|
+
// walk is the identical, extension-agnostic part of both — share it.
|
|
282
|
+
const walkCache = new Map<string, string[]>();
|
|
283
|
+
|
|
284
|
+
const {
|
|
285
|
+
files: discovered,
|
|
286
|
+
gitUnavailable,
|
|
287
|
+
stagedContent,
|
|
288
|
+
} = discover(opts.inputs, {
|
|
289
|
+
include: opts.include,
|
|
290
|
+
exclude: opts.exclude,
|
|
291
|
+
ext: opts.ext,
|
|
292
|
+
changed: opts.changed,
|
|
293
|
+
since: opts.since,
|
|
294
|
+
staged: opts.staged,
|
|
295
|
+
noDefaultExcludes: opts.noDefaultExcludes,
|
|
296
|
+
onWarn: opts.onWarn,
|
|
297
|
+
walkCache,
|
|
298
|
+
});
|
|
299
|
+
// In staged mode, read the index blob (from discovery) instead of the working tree.
|
|
300
|
+
const useStaged = opts.staged === true && !gitUnavailable;
|
|
301
|
+
|
|
302
|
+
// Diff-scoped mode (--changed/--since/--staged): a capture is rarely itself part of
|
|
303
|
+
// the diff (the SOURCE changed; its already-committed capture usually didn't), so
|
|
304
|
+
// pull in just the captures relevant to the diffed files (capturesForSources) — read
|
|
305
|
+
// from the working tree/disk even in --staged mode (an unchanged capture's staged
|
|
306
|
+
// blob and working-tree copy are identical). Extra files, never removed from the
|
|
307
|
+
// diff's own scope; a full scan's capture ingestion is the CLI appending captureDir
|
|
308
|
+
// as a top-level input instead (see cmdAudit's `useCaptures`).
|
|
309
|
+
const diffMode = opts.changed || opts.since || opts.staged;
|
|
310
|
+
const relevantCaptures =
|
|
311
|
+
opts.captureDiff && diffMode && opts.captureDir ? capturesForSources(opts.captureDir, discovered).filter((c) => !discovered.includes(c)) : [];
|
|
312
|
+
const files = relevantCaptures.length ? [...discovered, ...relevantCaptures] : discovered;
|
|
313
|
+
|
|
314
|
+
// Cross-file pass: build the dependency graph over the FULL scope (so a changed
|
|
315
|
+
// file's references resolve into unchanged definitions), then run cross rules in
|
|
316
|
+
// the audit loop below. Off by default — a plain audit is byte-identical. The
|
|
317
|
+
// graph's OWN discovery always widens to GRAPH_ONLY_EXT (.ts/.js/.mjs/.cjs) on top
|
|
318
|
+
// of whatever --ext adds — a barrel/plain-JS module is never an audit target (see
|
|
319
|
+
// `files` above), but it is real cross-file structure the graph resolves through.
|
|
320
|
+
let graph: DepGraph | undefined;
|
|
321
|
+
// Docs the graph pass already parsed, handed over so the audit loop below does not
|
|
322
|
+
// read and parse the very same markup a second time. Never used in --staged mode:
|
|
323
|
+
// there the audit must see the INDEX blob while the graph pass read the working
|
|
324
|
+
// tree, so the two can legitimately differ. Nor under --jsx, which forces a parser
|
|
325
|
+
// the graph pass does not apply.
|
|
326
|
+
let carried: Map<string, Doc> | undefined;
|
|
327
|
+
if (opts.graph || opts.captureCoverage) {
|
|
328
|
+
const graphExt = [...GRAPH_ONLY_EXT, ...(opts.ext ?? [])];
|
|
329
|
+
const graphFiles = discover(opts.inputs, {
|
|
330
|
+
include: opts.include,
|
|
331
|
+
exclude: opts.exclude,
|
|
332
|
+
ext: graphExt,
|
|
333
|
+
noDefaultExcludes: opts.noDefaultExcludes,
|
|
334
|
+
walkCache,
|
|
335
|
+
}).files;
|
|
336
|
+
const built = buildGraphAndDocs(graphFiles, { carryDocs: !useStaged && !opts.forceJsx });
|
|
337
|
+
graph = built.graph;
|
|
338
|
+
if (built.docs.size) carried = built.docs;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (let i = 0; i < files.length; i++) {
|
|
342
|
+
if (opts.maxFiles && opts.maxFiles > 0 && acc.fileCount >= opts.maxFiles) {
|
|
343
|
+
// skipped = candidates not audited (reconciles: audited + skipped == total),
|
|
344
|
+
// counting both never-examined files and any read failures along the way.
|
|
345
|
+
const skipped = files.length - acc.fileCount;
|
|
346
|
+
truncated = { limit: opts.maxFiles, total: files.length, skipped };
|
|
347
|
+
opts.onWarn?.(
|
|
348
|
+
`ultra11y: --max-files=${opts.maxFiles} reached; audited ${acc.fileCount}/${files.length} files (highest-priority first). Skipped ${skipped}.`,
|
|
349
|
+
);
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
const file = files[i]!;
|
|
353
|
+
let content: string;
|
|
354
|
+
const staged = useStaged ? stagedContent?.get(file) : undefined;
|
|
355
|
+
// A carried Doc already holds the file's source, so a hit skips the read entirely.
|
|
356
|
+
const reused = staged === undefined ? carried?.get(file) : undefined;
|
|
357
|
+
if (staged !== undefined) {
|
|
358
|
+
content = staged;
|
|
359
|
+
} else if (reused) {
|
|
360
|
+
content = reused.source;
|
|
361
|
+
} else {
|
|
362
|
+
try {
|
|
363
|
+
content = readText(file);
|
|
364
|
+
} catch {
|
|
365
|
+
continue; // unreadable / vanished between discovery and read
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (dedupMode !== "off") {
|
|
369
|
+
const h = hashContent(content, dedupMode);
|
|
370
|
+
if (seen.has(h)) {
|
|
371
|
+
duplicateFiles++;
|
|
372
|
+
continue; // identical to an already-audited file — cite the canonical one
|
|
373
|
+
}
|
|
374
|
+
seen.add(h);
|
|
375
|
+
}
|
|
376
|
+
foldDoc(acc, reused ?? parseSource(content, file, { forceJsx: opts.forceJsx }), graph);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const canonicalFiles = acc.fileCount;
|
|
380
|
+
// Respect --max-files for stdin too (don't let the stdin doc push past the cap).
|
|
381
|
+
if (opts.inputs.includes("-") && opts.stdin !== undefined && !(opts.maxFiles && opts.maxFiles > 0 && acc.fileCount >= opts.maxFiles)) {
|
|
382
|
+
foldDoc(acc, parseSource(opts.stdin, "<stdin>", { forceJsx: opts.forceJsx }), graph);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const result = finalize(acc, opts.inputs, {
|
|
386
|
+
...(truncated ? { truncated } : {}),
|
|
387
|
+
...(duplicateFiles > 0 ? { dedup: { canonicalFiles, duplicateFiles } } : {}),
|
|
388
|
+
});
|
|
389
|
+
if (graph) {
|
|
390
|
+
enrichCaptureOrigins(result.findings, graph); // anchor capture findings at the source component line
|
|
391
|
+
// Coverage is a repo-wide property: read the FULL capture set from the captures dir,
|
|
392
|
+
// not acc.captures (which is scoped to what THIS run audited — empty in --changed/--staged).
|
|
393
|
+
if (opts.captureCoverage) result.scope.captureCoverage = computeCaptureCoverage(graph, readCaptureDir(opts.captureDir ?? ".ultra11y/captures"));
|
|
394
|
+
}
|
|
395
|
+
return result;
|
|
396
|
+
}
|