@out-of-order/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/src/rules.ts ADDED
@@ -0,0 +1,473 @@
1
+ import { computeAccessibleName } from "dom-accessibility-api";
2
+ import { isFocusable } from "tabbable";
3
+ import type { RuleId, SequenceEntry, Severity } from "./types.js";
4
+ import {
5
+ isInteractive,
6
+ hasExplicitName,
7
+ selectorFor,
8
+ hiddenReason,
9
+ focusRevealSelectors,
10
+ inAriaHidden,
11
+ isInert,
12
+ looksClickable,
13
+ isFocusManaged,
14
+ compositeAncestor,
15
+ floatingAncestor,
16
+ scrollAncestor,
17
+ isScrollContainer,
18
+ openModal,
19
+ nativeReplacement,
20
+ isNativelyFocusable,
21
+ } from "./dom.js";
22
+
23
+ /** Everything a rule may need beyond the sequence itself. */
24
+ export interface RuleContext {
25
+ /** The analyzed root element (lets rules look beyond the tab sequence). */
26
+ container: Element;
27
+ inSequence: Set<Element>;
28
+ }
29
+
30
+ /** One problem a rule reports, before grading. */
31
+ export interface Finding {
32
+ /** Human-readable description of what's wrong. */
33
+ message: string;
34
+ /** The element the finding points at. A {@link SequenceEntry} when it is a tab
35
+ stop (carries orderIndex and a selector), or a bare Element when it is not. */
36
+ target: SequenceEntry | Element;
37
+ /** Other elements with the same root cause. Ringed alongside `target` but not
38
+ reported as separate findings, so one missing fix doesn't become N violations. */
39
+ relatedElements?: Element[];
40
+ }
41
+
42
+ /** Takes the tab sequence (plus context) and returns any findings. Pure. */
43
+ export type RuleRun = (sequence: SequenceEntry[], ctx: RuleContext) => Finding[];
44
+
45
+ export interface Rule {
46
+ /** Stable rule identifier, surfaced on every Violation it produces. */
47
+ id: string;
48
+ /** Spec link the rule is grounded in (WCAG, WAI-ARIA, or ARIA APG). */
49
+ docs: string;
50
+ /** Severity the rule fires at unless overridden via `AuditOptions.rules`. */
51
+ defaultSeverity: Severity;
52
+ run: RuleRun;
53
+ }
54
+
55
+ /** px tolerance for treating two stops as the same visual row. Elements on one row
56
+ rarely share an exact vertical center (height/padding/baseline differ), and below
57
+ ~8px a sighted user doesn't perceive a row break. */
58
+ const ROW_TOLERANCE_PX = 8;
59
+
60
+ /** Map the tab sequence to at most one finding per entry: return a message to flag
61
+ the entry, or null to pass it. Collapses the boilerplate of the per-entry rules. */
62
+ const flagEntries = (
63
+ sequence: SequenceEntry[],
64
+ message: (entry: SequenceEntry) => string | null,
65
+ ): Finding[] =>
66
+ sequence.flatMap((entry) => {
67
+ const msg = message(entry);
68
+ return msg ? [{ message: msg, target: entry }] : [];
69
+ });
70
+
71
+ const noPositiveTabIndex: RuleRun = (sequence) =>
72
+ flagEntries(sequence, (entry) =>
73
+ entry.tabIndex > 0
74
+ ? `Element has tabindex="${entry.tabIndex}". Positive tabindex overrides the natural DOM order and is fragile; use 0 or restructure the DOM.`
75
+ : null,
76
+ );
77
+
78
+ /**
79
+ * The tab sequence should match the visual reading order (top→bottom,
80
+ * left→right). A mismatch is local: it happens between two consecutive tab
81
+ * stops when the second one sits visually *before* the first (an earlier row,
82
+ * or the same row but to its left), i.e. Tab makes a backward hop.
83
+ */
84
+ const visualOrderMismatch: RuleRun = (sequence) => {
85
+ // Each element's floating ancestor, computed once (it walks the tree calling
86
+ // getComputedStyle); the adjacent-pair loop below would otherwise recompute
87
+ // every element's twice, as the "cur" of one pair and the "prev" of the next.
88
+ const floats = sequence.map((entry) => floatingAncestor(entry.element));
89
+ const scrollers = sequence.map((entry) => scrollAncestor(entry.element));
90
+ const out: Finding[] = [];
91
+ for (let idx = 1; idx < sequence.length; idx++) {
92
+ const prev = sequence[idx - 1]!;
93
+ const cur = sequence[idx]!;
94
+ // Only compare stops that share a scroll context. If they ride different
95
+ // fixed/sticky layers (page chrome floating over scrolling content), or
96
+ // different scroll containers (one inside an overflow:auto/scroll box, the
97
+ // other outside, or in separate boxes), then their up/down/left/right
98
+ // relationship moves with a scrollbar, so a "backward hop" between them isn't
99
+ // real. Skip the pair.
100
+ if (floats[idx - 1] !== floats[idx] || scrollers[idx - 1] !== scrollers[idx]) {
101
+ continue;
102
+ }
103
+
104
+ const prevX = prev.rect.left + prev.rect.width / 2;
105
+ const curX = cur.rect.left + cur.rect.width / 2;
106
+
107
+ // A stop whose box sits entirely to the right of prev starts a later column.
108
+ // Multi-column reading runs down one column then jumps to the TOP of the next,
109
+ // so this upward move is a forward column advance, not a backward hop. Without
110
+ // this, every column break (left column's end → right column's start) misfires.
111
+ const nextColumn = cur.rect.left >= prev.rect.right;
112
+
113
+ const earlierRow = cur.rect.bottom <= prev.rect.top + ROW_TOLERANCE_PX;
114
+ const sameRow = !earlierRow && cur.rect.top < prev.rect.bottom - ROW_TOLERANCE_PX;
115
+ const backwardHop = !nextColumn && (earlierRow || (sameRow && curX < prevX - 1));
116
+ if (!backwardHop) {
117
+ continue;
118
+ }
119
+
120
+ out.push({
121
+ message: `"${cur.selector}" comes after "${prev.selector}" in the tab order, but sits visually before it (reading order is top→bottom, left→right). Tab makes a backward hop here.`,
122
+ target: cur,
123
+ });
124
+ }
125
+
126
+ return out;
127
+ };
128
+
129
+ const missingAccessibleName: RuleRun = (sequence) =>
130
+ flagEntries(sequence, (entry) => {
131
+ if (!isInteractive(entry.element)) {
132
+ return null;
133
+ }
134
+ // Skip the costly subtree walk when a name is already guaranteed.
135
+ if (hasExplicitName(entry.element)) {
136
+ return null;
137
+ }
138
+ if (computeAccessibleName(entry.element).trim() !== "") {
139
+ return null;
140
+ }
141
+ return `Focusable element "${entry.selector}" has no accessible name (no text, aria-label, aria-labelledby, associated label, alt, or title).`;
142
+ });
143
+
144
+ const ariaHiddenFocusable: RuleRun = (sequence) =>
145
+ flagEntries(sequence, (entry) =>
146
+ inAriaHidden(entry.element)
147
+ ? `"${entry.selector}" is tabbable but inside aria-hidden="true", so a screen-reader user lands on a control the SR won't announce. Add tabindex="-1"/inert, or remove aria-hidden.`
148
+ : null,
149
+ );
150
+
151
+ const hiddenWhileFocusable: RuleRun = (sequence, { container }) => {
152
+ // Scan the page's focus-reveal rules once, not per element.
153
+ const revealOnFocus = focusRevealSelectors(container.ownerDocument);
154
+ return flagEntries(sequence, (entry) => {
155
+ const reason = hiddenReason(entry.element, entry.rect, revealOnFocus);
156
+ return reason
157
+ ? `"${entry.selector}" is tabbable but ${reason}. Hide it from the tab order too (display:none, the hidden attribute, or tabindex="-1").`
158
+ : null;
159
+ });
160
+ };
161
+
162
+ const clickableNotFocusable: RuleRun = (_sequence, { container, inSequence }) => {
163
+ // Every ancestor-or-self of a tab stop, collected once. A clickable element
164
+ // that's in this set merely wraps a real focusable control, so the keyboard
165
+ // can still get in, so skip it.
166
+ const wrapsFocusable = new Set<Element>();
167
+ for (const stop of inSequence) {
168
+ for (let node: Element | null = stop; node; node = node.parentElement) {
169
+ if (wrapsFocusable.has(node)) {
170
+ break;
171
+ }
172
+ wrapsFocusable.add(node);
173
+ }
174
+ }
175
+
176
+ const out: Finding[] = [];
177
+ for (const element of container.querySelectorAll("*")) {
178
+ if (inSequence.has(element)) {
179
+ continue;
180
+ }
181
+
182
+ if (wrapsFocusable.has(element)) {
183
+ continue;
184
+ }
185
+
186
+ if (!looksClickable(element)) {
187
+ continue;
188
+ }
189
+
190
+ // Reachable by arrow keys / a virtual cursor rather than Tab (roving tabindex,
191
+ // a composite container, or aria-activedescendant): deliberately out of the Tab
192
+ // sequence, not unreachable. Its container is the keyboard entry point.
193
+ if (isFocusManaged(element)) {
194
+ continue;
195
+ }
196
+
197
+ // Inside an inert subtree (e.g. the background while a modal is open): nothing
198
+ // here is reachable by mouse OR keyboard, by design. That's a focus-trap concern
199
+ // (focus-escapes-modal), not a clickable-but-unfocusable bug, so don't flag it.
200
+ if (isInert(element)) {
201
+ continue;
202
+ }
203
+
204
+ const rect = element.getBoundingClientRect();
205
+ if (rect.width < 1 || rect.height < 1) {
206
+ continue;
207
+ } // not rendered / no target
208
+ const selector = selectorFor(element);
209
+ out.push({
210
+ message: `"${selector}" looks interactive (role or onclick) but is not in the tab order, so keyboard users can't reach it. Use a <button>/<a>, or add tabindex="0" plus Enter/Space handlers.`,
211
+ target: element,
212
+ });
213
+ }
214
+
215
+ return out;
216
+ };
217
+
218
+ const compositeRovingTabindex: RuleRun = (sequence) => {
219
+ const groups = new Map<Element, SequenceEntry[]>();
220
+ for (const entry of sequence) {
221
+ const container = compositeAncestor(entry.element);
222
+ if (!container) {
223
+ continue;
224
+ }
225
+ const list = groups.get(container) ?? [];
226
+ list.push(entry);
227
+ groups.set(container, list);
228
+ }
229
+
230
+ const out: Finding[] = [];
231
+ for (const [container, members] of groups) {
232
+ if (members.length < 2) {
233
+ continue;
234
+ }
235
+
236
+ const role = container.getAttribute("role");
237
+ for (const member of members) {
238
+ out.push({
239
+ message: `${members.length} items inside role="${role}" are separate tab stops. A ${role} should expose one tab stop and move between items with the arrow keys (roving tabindex).`,
240
+ target: member,
241
+ });
242
+ }
243
+ }
244
+
245
+ return out;
246
+ };
247
+
248
+ const focusEscapesModal: RuleRun = (sequence, { container }) => {
249
+ const modal = openModal(container);
250
+ if (!modal) {
251
+ return [];
252
+ }
253
+
254
+ const leaked = sequence.filter(
255
+ (entry) => !modal.contains(entry.element) && !isInert(entry.element),
256
+ );
257
+
258
+ if (leaked.length === 0) {
259
+ return [];
260
+ }
261
+
262
+ const first = leaked[0]!;
263
+ const subject =
264
+ leaked.length === 1
265
+ ? `"${first.selector}" outside it is still tabbable`
266
+ : `${leaked.length} controls outside it are still tabbable (e.g. "${first.selector}")`;
267
+
268
+ return [
269
+ {
270
+ message: `A modal dialog is open, but ${subject}, so focus can leak behind the dialog. Mark background content inert (or aria-hidden + remove it from the tab order).`,
271
+ target: first,
272
+ // One finding, anchored on the first leaked control, but every other leaked
273
+ // control shares the root cause (background not inert) and is ringed too.
274
+ relatedElements: leaked.slice(1).map((entry) => entry.element),
275
+ },
276
+ ];
277
+ };
278
+
279
+ const tabindexOnNoninteractive: RuleRun = (sequence) =>
280
+ flagEntries(sequence, (entry) => {
281
+ if (entry.tabIndex !== 0) {
282
+ return null;
283
+ }
284
+ if (entry.element.getAttribute("tabindex") === null) {
285
+ return null;
286
+ } // implicitly focusable
287
+ const element = entry.element as HTMLElement;
288
+ if (isNativelyFocusable(element)) {
289
+ return null;
290
+ }
291
+ if (isInteractive(element)) {
292
+ return null;
293
+ }
294
+ if (element.isContentEditable) {
295
+ return null;
296
+ }
297
+ const role = element.getAttribute("role");
298
+ if (role && role !== "presentation" && role !== "none") {
299
+ return null;
300
+ }
301
+ // A keyboard-scrollable region legitimately takes tabindex="0", even when it
302
+ // isn't currently overflowing, so key on computed overflow, not a live size.
303
+ if (isScrollContainer(element)) {
304
+ return null;
305
+ }
306
+ return `"${entry.selector}" has tabindex="0" but is non-interactive (no role, not a control). If it's decorative, remove the tabindex, since it adds a dead stop to the tab order; if it's meant to be a control, give it a real role (or use a <button>).`;
307
+ });
308
+
309
+ const preferNativeElement: RuleRun = (sequence) =>
310
+ flagEntries(sequence, (entry) => {
311
+ const native = nativeReplacement(entry.element);
312
+ if (!native) {
313
+ return null;
314
+ }
315
+ const tag = entry.element.tagName.toLowerCase();
316
+ const role = entry.element.getAttribute("role");
317
+ return `"${entry.selector}" is a <${tag}> with role="${role}". Prefer a native ${native}: focus, keyboard activation (Enter/Space), and screen-reader semantics come for free, instead of being reimplemented with ARIA + JS.`;
318
+ });
319
+
320
+ /** autofocus applies to any *focusable* element (even tabindex="-1" non-stops), and
321
+ the browser focuses the first in *document* order — so scan the whole container,
322
+ not `sequence`. The first focusable one wins; the rest are dead intent. */
323
+ const duplicateAutofocus: RuleRun = (_sequence, { container }) => {
324
+ const focusableAutofocus = Array.from(container.querySelectorAll("[autofocus]")).filter(
325
+ (element) => isFocusable(element, { getShadowRoot: true }),
326
+ );
327
+ if (focusableAutofocus.length < 2) {
328
+ return [];
329
+ }
330
+
331
+ return focusableAutofocus.slice(1).map((element) => {
332
+ const selector = selectorFor(element);
333
+
334
+ return {
335
+ message: `"${selector}" also has autofocus, but a page can autofocus only one element; the first focusable one in document order wins, so this one is silently ignored. Remove the extra autofocus.`,
336
+ target: element,
337
+ };
338
+ });
339
+ };
340
+
341
+ const autofocusNotFocusable: RuleRun = (_sequence, { container }) => {
342
+ const out: Finding[] = [];
343
+ for (const element of container.querySelectorAll("[autofocus]")) {
344
+ if (isFocusable(element, { getShadowRoot: true })) {
345
+ continue;
346
+ }
347
+
348
+ const selector = selectorFor(element);
349
+ out.push({
350
+ message: `"${selector}" has autofocus but isn't focusable (no tabindex, not a form control), so it's ignored on load. Remove the autofocus, or make the element focusable (e.g. tabindex="-1").`,
351
+ target: element,
352
+ });
353
+ }
354
+
355
+ return out;
356
+ };
357
+
358
+ const nestedInteractive: RuleRun = (sequence, { container, inSequence }) => {
359
+ const stop = container.parentElement;
360
+ const out: Finding[] = [];
361
+ for (const entry of sequence) {
362
+ for (let node = entry.element.parentElement; node && node !== stop; node = node.parentElement) {
363
+ if (!inSequence.has(node) && !isInteractive(node)) {
364
+ continue;
365
+ }
366
+
367
+ out.push({
368
+ message: `"${entry.selector}" is focusable but nested inside another focusable element ("${selectorFor(node)}"). Nesting interactive controls stacks two tab stops in one place and can hide the inner control's role/name from screen readers; don't put a focusable element inside another.`,
369
+ target: entry,
370
+ });
371
+
372
+ break;
373
+ }
374
+ }
375
+
376
+ return out;
377
+ };
378
+
379
+ /** A natively focusable element (<button>, <a href>, <input>, …) carrying an
380
+ explicit tabindex="0". It's already a tab stop, so the attribute is a no-op:
381
+ redundant markup that adds noise and invites a positive value to creep in later.
382
+ tabindex="-1" (removed from the sequence) and positive values (no-positive-tabindex)
383
+ aren't redundant and never reach here. */
384
+ const redundantTabindex: RuleRun = (sequence) =>
385
+ flagEntries(sequence, (entry) => {
386
+ if (entry.tabIndex !== 0) {
387
+ return null;
388
+ }
389
+ if (entry.element.getAttribute("tabindex") === null) {
390
+ return null;
391
+ } // implicitly focusable, no attribute to remove
392
+ if (!isNativelyFocusable(entry.element)) {
393
+ return null;
394
+ }
395
+ return `"${entry.selector}" is already focusable, so its tabindex="0" is redundant. Remove the attribute; the element stays in the tab order on its own.`;
396
+ });
397
+
398
+ export const ALL_RULES = {
399
+ "no-positive-tabindex": {
400
+ docs: "https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html",
401
+ defaultSeverity: "error",
402
+ run: noPositiveTabIndex,
403
+ },
404
+ "visual-order-mismatch": {
405
+ docs: "https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html",
406
+ defaultSeverity: "warning",
407
+ run: visualOrderMismatch,
408
+ },
409
+ "missing-accessible-name": {
410
+ docs: "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html",
411
+ defaultSeverity: "error",
412
+ run: missingAccessibleName,
413
+ },
414
+ "aria-hidden-focusable": {
415
+ docs: "https://www.w3.org/TR/wai-aria-1.2/#aria-hidden",
416
+ defaultSeverity: "error",
417
+ run: ariaHiddenFocusable,
418
+ },
419
+ "hidden-while-focusable": {
420
+ docs: "https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html",
421
+ defaultSeverity: "error",
422
+ run: hiddenWhileFocusable,
423
+ },
424
+ "clickable-not-focusable": {
425
+ docs: "https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html",
426
+ defaultSeverity: "error",
427
+ run: clickableNotFocusable,
428
+ },
429
+ "composite-roving-tabindex": {
430
+ docs: "https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/",
431
+ defaultSeverity: "warning",
432
+ run: compositeRovingTabindex,
433
+ },
434
+ "focus-escapes-modal": {
435
+ docs: "https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/",
436
+ defaultSeverity: "error",
437
+ run: focusEscapesModal,
438
+ },
439
+ "tabindex-on-noninteractive": {
440
+ docs: "https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/",
441
+ defaultSeverity: "error",
442
+ run: tabindexOnNoninteractive,
443
+ },
444
+ "prefer-native-element": {
445
+ docs: "https://www.w3.org/TR/using-aria/#firstrule",
446
+ defaultSeverity: "warning",
447
+ run: preferNativeElement,
448
+ },
449
+ "duplicate-autofocus": {
450
+ docs: "https://html.spec.whatwg.org/multipage/interaction.html#the-autofocus-attribute",
451
+ defaultSeverity: "warning",
452
+ run: duplicateAutofocus,
453
+ },
454
+ "autofocus-not-focusable": {
455
+ docs: "https://html.spec.whatwg.org/multipage/interaction.html#the-autofocus-attribute",
456
+ defaultSeverity: "warning",
457
+ run: autofocusNotFocusable,
458
+ },
459
+ "nested-interactive": {
460
+ docs: "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html",
461
+ defaultSeverity: "error",
462
+ run: nestedInteractive,
463
+ },
464
+ "redundant-tabindex": {
465
+ docs: "https://html.spec.whatwg.org/multipage/interaction.html#attr-tabindex",
466
+ defaultSeverity: "warning",
467
+ run: redundantTabindex,
468
+ },
469
+ } satisfies Record<string, Omit<Rule, "id">>;
470
+
471
+ export const DEFAULT_SEVERITY = Object.fromEntries(
472
+ Object.entries(ALL_RULES).map(([id, rule]) => [id, rule.defaultSeverity]),
473
+ ) as Record<RuleId, Severity>;
package/src/types.ts ADDED
@@ -0,0 +1,105 @@
1
+ import type { ALL_RULES } from "./rules.js";
2
+
3
+ export type Severity = "error" | "warning";
4
+
5
+ export type RuleOverride = Severity | "off";
6
+
7
+ /** Serialized shape `audit` returns when asked to format its result: the findings
8
+ grouped by element, or as a human-readable text block. */
9
+ export type AuditFormat = "by-element" | "text";
10
+
11
+ /** A single rule failure on one element. */
12
+ export interface Issue {
13
+ /** Stable rule identifier. */
14
+ rule: RuleId | (string & {});
15
+ /** How serious this finding is. */
16
+ severity: Severity;
17
+ /** Human-readable description of what's wrong. */
18
+ message: string;
19
+ /** Spec link for the rule (WCAG, WAI-ARIA, or ARIA APG). */
20
+ docs?: string;
21
+ /** Other elements sharing this issue's root cause. */
22
+ relatedElements?: Element[];
23
+ /** Approved (silenced) by a `data-ooo-ignore` on the element */
24
+ ignored?: boolean;
25
+ }
26
+
27
+ /** One offending element and every rule it failed. */
28
+ export interface Violation {
29
+ /** The offending element. */
30
+ element: Element;
31
+ /** A CSS-ish path to the element, for messages and logs. */
32
+ selector: string;
33
+ /** Position in the tab sequence, when the element is a tab stop. */
34
+ orderIndex?: number;
35
+ /** The rules this element failed. */
36
+ issues: Issue[];
37
+ }
38
+
39
+ /** A rule failure with its element references flattened to selector strings, so it
40
+ survives serialization (e.g. `JSON.stringify`) unlike {@link Issue}, which holds
41
+ live `Element`s. The issue shape shared by the formatted views. */
42
+ export interface SerializedIssue {
43
+ rule: AnyRuleId;
44
+ severity: Severity;
45
+ message: string;
46
+ docs?: string;
47
+ /** Selectors of the elements sharing this issue's root cause. */
48
+ related?: string[];
49
+ /** Approved (silenced) by a `data-ooo-ignore` */
50
+ ignored?: boolean;
51
+ }
52
+
53
+ /** `"by-element"` format: one entry per offending element, its issues nested. The
54
+ serializable twin of {@link Violation}. */
55
+ export interface ByElement {
56
+ selector: string;
57
+ orderIndex?: number;
58
+ /** How many issues this element failed (`issues.length`). */
59
+ issueCount: number;
60
+ issues: SerializedIssue[];
61
+ }
62
+
63
+ /** The type of {@link AuditResult.violations} for a given `format`: a string for
64
+ `"text"`, the matching structured array for the others. */
65
+ export type Formatted<F extends AuditFormat> = F extends "text"
66
+ ? string
67
+ : F extends "by-element"
68
+ ? ByElement[]
69
+ : never;
70
+
71
+ export type RuleId = keyof typeof ALL_RULES;
72
+
73
+ type AnyRuleId = RuleId | (string & {});
74
+
75
+ export interface SequenceEntry {
76
+ element: Element;
77
+ selector: string;
78
+ /** Zero-based position in the tab sequence. */
79
+ orderIndex: number;
80
+ /** Resolved tabindex (0 if unset but focusable). */
81
+ tabIndex: number;
82
+ /** Bounding rect at analysis time (real layout, browser only). */
83
+ rect: DOMRect;
84
+ }
85
+
86
+ export interface AuditResult<V = Violation[]> {
87
+ /** True when no enabled rule produced an `error` severity finding. */
88
+ valid: boolean;
89
+ /** Elements in the exact order tabbing will reach them. */
90
+ sequence: SequenceEntry[];
91
+ /** One entry per offending element, each carrying its failed rules. When
92
+ `AuditOptions.format` is set these same findings are reshaped to that view:
93
+ a string for `"text"`, else a {@link Formatted} array. */
94
+ violations: V;
95
+ }
96
+
97
+ export interface AuditOptions {
98
+ /** Per-rule overrides. Every rule runs at its default severity unless listed
99
+ here: set `"off"` to disable it, or `"error"`/`"warning"` to
100
+ re-grade it. See {@link RuleOverride}. */
101
+ rules?: Partial<Record<RuleId, RuleOverride>>;
102
+ /** Reshape the result's `violations` to this view. Omit to keep the structured
103
+ {@link Violation}`[]`. See {@link Formatted} for the type each one yields. */
104
+ format?: AuditFormat;
105
+ }