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
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// The cross-file rule set + runner. Two raise findings the graph makes visible,
|
|
2
|
+
// two suppress single-file findings the graph proves are false positives. Findings
|
|
3
|
+
// fold into the same AuditResult; suppressions are applied (by ruleId + line) to
|
|
4
|
+
// the same Doc's single-file findings in audit.ts.
|
|
5
|
+
import type { Doc, El } from "../parse/html.js";
|
|
6
|
+
import type { Finding } from "../types.js";
|
|
7
|
+
import { attr, hasAttr, visibleText } from "../parse/html.js";
|
|
8
|
+
import { isIntrinsic } from "../parse/jsx-bridge.js";
|
|
9
|
+
import { isFullDocument } from "./rule.js";
|
|
10
|
+
import { type CrossRule, type Suppression, crossToFinding } from "./cross-rule.js";
|
|
11
|
+
import { type DepGraph, resolveUsage, idDefinedAnywhere, htmlLangProvidedFor } from "../graph/graph.js";
|
|
12
|
+
import { NOTE_CATALOG } from "../messages.js";
|
|
13
|
+
|
|
14
|
+
const SEVERITY_ORDER: Record<string, number> = { bloquant: 0, majeur: 1, mineur: 2 };
|
|
15
|
+
const NAME_PROPS = ["aria-label", "aria-labelledby", "title", "label", "alt"];
|
|
16
|
+
|
|
17
|
+
// A JSX component usage (PascalCase / namespaced); excludes intrinsic tags and #fragment.
|
|
18
|
+
function isComponentUsage(el: El): boolean {
|
|
19
|
+
return !isIntrinsic(el.tag) && el.tag !== "#fragment";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Could this component usage already be carrying an accessible name?
|
|
23
|
+
function usageHasName(el: El): boolean {
|
|
24
|
+
if (el.spread) return true; // {...props} may inject a name — stay conservative
|
|
25
|
+
if (visibleText(el) !== "") return true;
|
|
26
|
+
return NAME_PROPS.some((p) => (attr(el, p) ?? "").trim() !== "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isNameableControl(el: El): boolean {
|
|
30
|
+
if (el.tag === "button" || el.tag === "select" || el.tag === "textarea") return true;
|
|
31
|
+
if (el.tag === "a" && hasAttr(el, "href")) return true;
|
|
32
|
+
if (el.tag === "input" || el.tag === "img") return true;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 1) An icon-only component that CAN be named (spread/aria-prop/{children}) but is
|
|
37
|
+
// used without a name → flag at the usage, point at the definition.
|
|
38
|
+
const crossIconOnlyUnnamed: CrossRule = {
|
|
39
|
+
id: "cross-icon-only-unnamed",
|
|
40
|
+
criteria: ["4.1.2"],
|
|
41
|
+
severity: "bloquant",
|
|
42
|
+
run(doc, graph) {
|
|
43
|
+
const findings = [];
|
|
44
|
+
for (const el of doc.elements) {
|
|
45
|
+
if (!isComponentUsage(el)) continue;
|
|
46
|
+
const def = resolveUsage(graph, doc.file, el.tag);
|
|
47
|
+
if (!def?.rendersIconOnlyControl || !def.acceptsName) continue;
|
|
48
|
+
if (usageHasName(el)) continue;
|
|
49
|
+
findings.push({
|
|
50
|
+
criteriaId: "4.1.2",
|
|
51
|
+
el,
|
|
52
|
+
msgId: "cross-icon-only-unnamed",
|
|
53
|
+
params: { tag: el.tag, defName: def.name },
|
|
54
|
+
related: {
|
|
55
|
+
file: def.file,
|
|
56
|
+
line: def.line,
|
|
57
|
+
col: def.col,
|
|
58
|
+
selectorHint: def.name,
|
|
59
|
+
note: NOTE_CATALOG["related.icon-component-def"]!.en,
|
|
60
|
+
noteId: "related.icon-component-def",
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return { findings, suppress: [] };
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// 2) A document whose <html> has no lang, but a wrapper/layout it imports declares
|
|
69
|
+
// one → suppress the missing-lang false positive.
|
|
70
|
+
const crossPageLang: CrossRule = {
|
|
71
|
+
id: "cross-page-lang",
|
|
72
|
+
criteria: ["3.1.1"],
|
|
73
|
+
severity: "bloquant",
|
|
74
|
+
run(doc, graph) {
|
|
75
|
+
const suppress: Suppression[] = [];
|
|
76
|
+
const html = doc.elements.find((e) => e.tag === "html");
|
|
77
|
+
if (html && (attr(html, "lang") ?? "") === "" && htmlLangProvidedFor(graph, doc.file)) {
|
|
78
|
+
suppress.push({ ruleId: "html-lang-missing", line: html.line, reason: "lang fourni par un layout/wrapper importé" });
|
|
79
|
+
}
|
|
80
|
+
return { findings: [], suppress };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// 3) An intrinsic control/img that spreads {...props} can be named by its parent →
|
|
85
|
+
// suppress single-file name-missing findings on it (the component def site).
|
|
86
|
+
const SUPPRESSIBLE_BY_SPREAD = ["icon-only-control-unnamed", "button-empty-name", "link-empty-name", "control-label-missing", "img-alt-missing"];
|
|
87
|
+
const crossAriaForwarding: CrossRule = {
|
|
88
|
+
id: "cross-aria-forwarding",
|
|
89
|
+
criteria: ["4.1.2"],
|
|
90
|
+
severity: "bloquant",
|
|
91
|
+
run(doc) {
|
|
92
|
+
const suppress: Suppression[] = [];
|
|
93
|
+
for (const el of doc.elements) {
|
|
94
|
+
if (!el.spread || !isNameableControl(el)) continue;
|
|
95
|
+
for (const ruleId of SUPPRESSIBLE_BY_SPREAD) suppress.push({ ruleId, line: el.line, reason: "nommable via {...props} au point d'utilisation" });
|
|
96
|
+
}
|
|
97
|
+
return { findings: [], suppress };
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// 4) A skip-link target that lives in another file (imported layout/component) →
|
|
102
|
+
// suppress the in-page "broken anchor" false positive. Only on full documents
|
|
103
|
+
// (mirrors skip-link-target-missing's page scope). A target defined NOWHERE is
|
|
104
|
+
// left to the single-file rule (true positive).
|
|
105
|
+
const crossSkipLinkTarget: CrossRule = {
|
|
106
|
+
id: "cross-skip-link-target",
|
|
107
|
+
criteria: ["2.4.1"],
|
|
108
|
+
severity: "majeur",
|
|
109
|
+
run(doc, graph) {
|
|
110
|
+
const suppress: Suppression[] = [];
|
|
111
|
+
if (!isFullDocument(doc)) return { findings: [], suppress };
|
|
112
|
+
const sameDocHas = (id: string): boolean => doc.byId.has(id) || doc.elements.some((e) => attr(e, "name") === id);
|
|
113
|
+
for (const el of doc.elements) {
|
|
114
|
+
if (el.tag !== "a") continue;
|
|
115
|
+
const href = attr(el, "href") ?? "";
|
|
116
|
+
if (!href.startsWith("#") || href === "#") continue;
|
|
117
|
+
let id = href.slice(1);
|
|
118
|
+
try {
|
|
119
|
+
id = decodeURIComponent(id);
|
|
120
|
+
} catch {
|
|
121
|
+
/* keep raw */
|
|
122
|
+
}
|
|
123
|
+
if (sameDocHas(id)) continue; // single-file rule won't fire anyway
|
|
124
|
+
if (idDefinedAnywhere(graph, id))
|
|
125
|
+
suppress.push({ ruleId: "skip-link-target-missing", line: el.line, reason: `cible #${id} définie dans un autre fichier du graphe` });
|
|
126
|
+
}
|
|
127
|
+
return { findings: [], suppress };
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// 5) A component usage passes a name-bearing prop, but the resolved component renders a
|
|
132
|
+
// control that neither spreads {...props} nor forwards a name to it → the prop-drilled
|
|
133
|
+
// accessible name is lost across the boundary. Raise at the usage, point at the control.
|
|
134
|
+
const NAME_PROPS_PASSED = ["aria-label", "aria-labelledby", "title", "label"];
|
|
135
|
+
const crossPropDrilledNameLost: CrossRule = {
|
|
136
|
+
id: "cross-prop-drilled-name-lost",
|
|
137
|
+
criteria: ["4.1.2"],
|
|
138
|
+
severity: "majeur",
|
|
139
|
+
run(doc, graph) {
|
|
140
|
+
const findings = [];
|
|
141
|
+
for (const el of doc.elements) {
|
|
142
|
+
if (!isComponentUsage(el)) continue;
|
|
143
|
+
const passed = NAME_PROPS_PASSED.find((p) => (attr(el, p) ?? "").trim() !== "");
|
|
144
|
+
if (!passed) continue;
|
|
145
|
+
const def = resolveUsage(graph, doc.file, el.tag);
|
|
146
|
+
// Fire only when the control would otherwise be NAMELESS: it must render a control,
|
|
147
|
+
// not accept a name from props, AND not already carry a literal name (else the passed
|
|
148
|
+
// prop is dead code, not a real 4.1.2 non-conformity).
|
|
149
|
+
if (!def?.hasControl || def.acceptsName || def.controlHasName) continue;
|
|
150
|
+
findings.push({
|
|
151
|
+
criteriaId: "4.1.2",
|
|
152
|
+
el,
|
|
153
|
+
msgId: "cross-prop-drilled-name-lost",
|
|
154
|
+
params: { tag: el.tag, passed, defName: def.name },
|
|
155
|
+
related: {
|
|
156
|
+
file: def.file,
|
|
157
|
+
line: def.line,
|
|
158
|
+
col: def.col,
|
|
159
|
+
selectorHint: def.name,
|
|
160
|
+
note: NOTE_CATALOG["related.name-drop-def"]!.en,
|
|
161
|
+
noteId: "related.name-drop-def",
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return { findings, suppress: [] };
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// 6) An aria-*by / aria-controls reference whose target id is defined in ANOTHER file of
|
|
170
|
+
// the graph → suppress the single-file aria-ref-missing-id false positive. Conservative:
|
|
171
|
+
// suppress only when EVERY in-page-missing id on the element resolves elsewhere, so a
|
|
172
|
+
// genuinely-dangling reference is never hidden.
|
|
173
|
+
const ARIA_IDREFS = [
|
|
174
|
+
"aria-labelledby",
|
|
175
|
+
"aria-describedby",
|
|
176
|
+
"aria-controls",
|
|
177
|
+
"aria-owns",
|
|
178
|
+
"aria-details",
|
|
179
|
+
"aria-errormessage",
|
|
180
|
+
"aria-flowto",
|
|
181
|
+
"aria-activedescendant",
|
|
182
|
+
];
|
|
183
|
+
const crossAriaRefCrossFile: CrossRule = {
|
|
184
|
+
id: "cross-aria-ref-cross-file",
|
|
185
|
+
criteria: ["4.1.2"],
|
|
186
|
+
severity: "majeur",
|
|
187
|
+
run(doc, graph) {
|
|
188
|
+
const suppress: Suppression[] = [];
|
|
189
|
+
for (const el of doc.elements) {
|
|
190
|
+
const missing: string[] = [];
|
|
191
|
+
for (const a of ARIA_IDREFS) {
|
|
192
|
+
const v = (attr(el, a) ?? "").trim();
|
|
193
|
+
if (!v || v.includes("{")) continue; // dynamic JSX expression — not a literal id list
|
|
194
|
+
for (const id of v.split(/\s+/).filter(Boolean)) if (!doc.byId.has(id)) missing.push(id);
|
|
195
|
+
}
|
|
196
|
+
if (!missing.length) continue; // the single-file rule won't fire on this element
|
|
197
|
+
if (missing.every((id) => idDefinedAnywhere(graph, id)))
|
|
198
|
+
suppress.push({ ruleId: "aria-ref-missing-id", line: el.line, reason: "cible(s) définie(s) dans un autre fichier du graphe" });
|
|
199
|
+
}
|
|
200
|
+
return { findings: [], suppress };
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// 7) A <label for="x"> or an empty heading named via aria-labelledby whose target id lives
|
|
205
|
+
// in ANOTHER file of the graph → suppress the single-file label-for-dangling /
|
|
206
|
+
// empty-heading false positive (the field / label is rendered by an imported component).
|
|
207
|
+
// Mirrors cross-aria-ref-cross-file's conservatism: only when the id resolves elsewhere.
|
|
208
|
+
const HEADING_TAG = /^h[1-6]$/;
|
|
209
|
+
const crossNameRefCrossFile: CrossRule = {
|
|
210
|
+
id: "cross-name-ref-cross-file",
|
|
211
|
+
criteria: ["1.3.1"],
|
|
212
|
+
severity: "majeur",
|
|
213
|
+
run(doc, graph) {
|
|
214
|
+
const suppress: Suppression[] = [];
|
|
215
|
+
for (const el of doc.elements) {
|
|
216
|
+
if (el.tag === "label") {
|
|
217
|
+
const f = (attr(el, "for") ?? "").trim();
|
|
218
|
+
if (f && !f.includes("{") && !doc.byId.has(f) && idDefinedAnywhere(graph, f))
|
|
219
|
+
suppress.push({ ruleId: "label-for-dangling", line: el.line, reason: `cible #${f} définie dans un autre fichier du graphe` });
|
|
220
|
+
}
|
|
221
|
+
const isHeading = HEADING_TAG.test(el.tag) || (attr(el, "role") ?? "") === "heading";
|
|
222
|
+
if (isHeading) {
|
|
223
|
+
const lb = (attr(el, "aria-labelledby") ?? "").trim();
|
|
224
|
+
const ids = lb.includes("{") ? [] : lb.split(/\s+/).filter(Boolean);
|
|
225
|
+
if (ids.length && ids.every((id) => !doc.byId.has(id) && idDefinedAnywhere(graph, id)))
|
|
226
|
+
suppress.push({ ruleId: "empty-heading", line: el.line, reason: "intitulé fourni par un aria-labelledby défini dans un autre fichier" });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { findings: [], suppress };
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export const CROSS_RULES: CrossRule[] = [
|
|
234
|
+
crossIconOnlyUnnamed,
|
|
235
|
+
crossPageLang,
|
|
236
|
+
crossAriaForwarding,
|
|
237
|
+
crossSkipLinkTarget,
|
|
238
|
+
crossPropDrilledNameLost,
|
|
239
|
+
crossAriaRefCrossFile,
|
|
240
|
+
crossNameRefCrossFile,
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
/** Rule ids that can raise a finding (for dataset/registry cross-checks). Suppressors
|
|
244
|
+
* raise none and are intentionally excluded. */
|
|
245
|
+
export function crossRuleIds(): string[] {
|
|
246
|
+
return ["cross-icon-only-unnamed", "cross-prop-drilled-name-lost"];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface CrossRunResult {
|
|
250
|
+
findings: Finding[];
|
|
251
|
+
suppress: Suppression[];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function runCrossRules(doc: Doc, graph: DepGraph): CrossRunResult {
|
|
255
|
+
const findings: Finding[] = [];
|
|
256
|
+
const suppress: Suppression[] = [];
|
|
257
|
+
for (const rule of CROSS_RULES) {
|
|
258
|
+
const r = rule.run(doc, graph);
|
|
259
|
+
for (const cf of r.findings) findings.push(crossToFinding(doc, rule.id, rule.severity, cf));
|
|
260
|
+
suppress.push(...r.suppress);
|
|
261
|
+
}
|
|
262
|
+
findings.sort((a, b) => a.line - b.line || a.col - b.col || SEVERITY_ORDER[a.severity]! - SEVERITY_ORDER[b.severity]!);
|
|
263
|
+
return { findings, suppress };
|
|
264
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Contract for cross-file rules. Unlike a single-file Rule (which sees one Doc),
|
|
2
|
+
// a CrossRule also sees the dependency graph, and can BOTH raise findings (anchored
|
|
3
|
+
// at a usage site, with the related definition site attached) AND suppress
|
|
4
|
+
// single-file findings that the graph proves are false positives (e.g. a skip-link
|
|
5
|
+
// target that lives in an imported layout). Cross findings fold into the same
|
|
6
|
+
// Finding/AuditResult as everything else.
|
|
7
|
+
import type { Doc, El } from "../parse/html.js";
|
|
8
|
+
import type { Finding, Severity } from "../types.js";
|
|
9
|
+
import { snippet } from "../parse/html.js";
|
|
10
|
+
import { selectorOf } from "./rule.js";
|
|
11
|
+
import { MSG_CATALOG, type MsgParams } from "../messages.js";
|
|
12
|
+
import type { DepGraph } from "../graph/graph.js";
|
|
13
|
+
|
|
14
|
+
export interface RelatedSite {
|
|
15
|
+
file: string;
|
|
16
|
+
line: number;
|
|
17
|
+
col: number;
|
|
18
|
+
selectorHint: string;
|
|
19
|
+
// Canonical baked ENGLISH prose (mirrors Finding.message/remediation); `noteId` is an
|
|
20
|
+
// optional key into src/messages.ts's NOTE_CATALOG, resolved at render time by
|
|
21
|
+
// `resolveNote` — see types.ts `Finding.related` for the full contract.
|
|
22
|
+
note: string;
|
|
23
|
+
noteId?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CrossFinding {
|
|
27
|
+
criteriaId: string;
|
|
28
|
+
el: El; // anchor (the usage site) for line/col/snippet/selector
|
|
29
|
+
/** catalog key into MSG_CATALOG (src/messages.ts) — mirrors RuleFinding.msgId. */
|
|
30
|
+
msgId: string;
|
|
31
|
+
params?: MsgParams;
|
|
32
|
+
severity?: Severity;
|
|
33
|
+
selectorHint?: string;
|
|
34
|
+
related?: RelatedSite; // the OTHER site (definition) that explains the finding
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Drop a single-file finding the graph proves is a false positive. Matched on the
|
|
38
|
+
// same Doc by ruleId + anchor line.
|
|
39
|
+
export interface Suppression {
|
|
40
|
+
ruleId: string;
|
|
41
|
+
line: number;
|
|
42
|
+
reason: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CrossRuleResult {
|
|
46
|
+
findings: CrossFinding[];
|
|
47
|
+
suppress: Suppression[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CrossRule {
|
|
51
|
+
id: string;
|
|
52
|
+
criteria: string[];
|
|
53
|
+
severity: Severity;
|
|
54
|
+
run(doc: Doc, graph: DepGraph): CrossRuleResult;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Normalise a CrossFinding into a Finding (mirrors rule.ts toFinding, plus related). */
|
|
58
|
+
export function crossToFinding(doc: Doc, ruleId: string, def: Severity, cf: CrossFinding): Finding {
|
|
59
|
+
const entry = MSG_CATALOG[cf.msgId];
|
|
60
|
+
if (!entry) {
|
|
61
|
+
throw new Error(`crossToFinding: msgId "${cf.msgId}" (rule "${ruleId}") is not in MSG_CATALOG — add it to src/messages.ts.`);
|
|
62
|
+
}
|
|
63
|
+
const params = cf.params ?? {};
|
|
64
|
+
return {
|
|
65
|
+
ruleId,
|
|
66
|
+
criteriaId: cf.criteriaId,
|
|
67
|
+
file: doc.file,
|
|
68
|
+
line: cf.el.line,
|
|
69
|
+
col: cf.el.col,
|
|
70
|
+
selectorHint: cf.selectorHint ?? selectorOf(cf.el),
|
|
71
|
+
severity: cf.severity ?? def,
|
|
72
|
+
message: entry.message.en(params),
|
|
73
|
+
remediation: entry.remediation.en(params),
|
|
74
|
+
msg: cf.params ? { id: cf.msgId, params: cf.params } : { id: cf.msgId },
|
|
75
|
+
snippet: snippet(doc, cf.el),
|
|
76
|
+
...(doc.lossy ? {} : { sourceStart: cf.el.start, sourceEnd: cf.el.end }),
|
|
77
|
+
// Mirror toFinding: an SFC/lossy-JSX source is less trustworthy (slots/dynamic content /
|
|
78
|
+
// regex transform), so its cross-file findings are provisional too — otherwise a cross
|
|
79
|
+
// finding reads as definitive while the per-doc findings in the same file are preliminary.
|
|
80
|
+
...(doc.kind === "sfc" || doc.kind === "jsx-lossy" ? { preliminary: true } : {}),
|
|
81
|
+
...(cf.related ? { related: cf.related } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|