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.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -0
  3. package/docker/Dockerfile +7 -0
  4. package/docker/package.json +9 -0
  5. package/docker/runner.mjs +23 -0
  6. package/package.json +73 -0
  7. package/scripts/bench.mjs +151 -0
  8. package/scripts/build-act-corpus.mjs +143 -0
  9. package/scripts/build-adjudication.mjs +503 -0
  10. package/scripts/build-pack-rgaa.mjs +397 -0
  11. package/scripts/build-standards.mjs +548 -0
  12. package/scripts/copy-bundle.mjs +39 -0
  13. package/scripts/import-pack.mjs +107 -0
  14. package/scripts/sync-engine.mjs +84 -0
  15. package/scripts/sync-version.mjs +43 -0
  16. package/scripts/ultra11y.mjs +52739 -0
  17. package/scripts/vendor/act-testcases.json +12297 -0
  18. package/scripts/vendor/rgaa/SOURCE.json +8 -0
  19. package/scripts/vendor/rgaa/criteres.json +4144 -0
  20. package/scripts/vendor/rgaa/glossaire.json +480 -0
  21. package/scripts/vendor/wcag-2.2-fr.json +82 -0
  22. package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
  23. package/scripts/vendor/wcag-2.2-sc.json +574 -0
  24. package/scripts/verify-rgaa-source.mjs +126 -0
  25. package/scripts/verify-skill-bundle.mjs +91 -0
  26. package/skills/review-a11y/SKILL.md +123 -0
  27. package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
  28. package/skills/ultra11y/SKILL.md +292 -0
  29. package/skills/ultra11y/references/act.md +163 -0
  30. package/skills/ultra11y/references/adjudication.md +599 -0
  31. package/skills/ultra11y/references/audit.md +100 -0
  32. package/skills/ultra11y/references/authoring.md +37 -0
  33. package/skills/ultra11y/references/automation.md +113 -0
  34. package/skills/ultra11y/references/correction.md +41 -0
  35. package/skills/ultra11y/references/criteria.md +129 -0
  36. package/skills/ultra11y/references/cross-file.md +82 -0
  37. package/skills/ultra11y/references/dynamic.md +152 -0
  38. package/skills/ultra11y/references/false-positives.md +81 -0
  39. package/skills/ultra11y/references/fix.md +46 -0
  40. package/skills/ultra11y/references/focus-and-logic.md +64 -0
  41. package/skills/ultra11y/references/forbidden-patterns.md +61 -0
  42. package/skills/ultra11y/references/forms-and-errors.md +147 -0
  43. package/skills/ultra11y/references/guidance.md +55 -0
  44. package/skills/ultra11y/references/judgment.md +90 -0
  45. package/skills/ultra11y/references/media-and-motion.md +116 -0
  46. package/skills/ultra11y/references/methodology.md +72 -0
  47. package/skills/ultra11y/references/naming.md +117 -0
  48. package/skills/ultra11y/references/orchestration.md +44 -0
  49. package/skills/ultra11y/references/packs.md +193 -0
  50. package/skills/ultra11y/references/prd.md +104 -0
  51. package/skills/ultra11y/references/rendered.md +110 -0
  52. package/skills/ultra11y/references/scale.md +49 -0
  53. package/skills/ultra11y/references/standards.md +97 -0
  54. package/skills/ultra11y/references/structure.md +138 -0
  55. package/skills/ultra11y/references/verify.md +88 -0
  56. package/skills/ultra11y/references/widgets.md +106 -0
  57. package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
  58. package/src/act.ts +441 -0
  59. package/src/adjudicate.ts +630 -0
  60. package/src/aria.ts +230 -0
  61. package/src/audit.ts +396 -0
  62. package/src/auditor.ts +399 -0
  63. package/src/autofill.ts +102 -0
  64. package/src/axe-map.ts +294 -0
  65. package/src/baseline.ts +75 -0
  66. package/src/capture.ts +182 -0
  67. package/src/check.ts +243 -0
  68. package/src/cli.ts +1927 -0
  69. package/src/color.ts +102 -0
  70. package/src/config.ts +182 -0
  71. package/src/crawl.ts +75 -0
  72. package/src/criteria.ts +183 -0
  73. package/src/data/adjudication.json +770 -0
  74. package/src/data/guidance/rgaa.json +2395 -0
  75. package/src/data/standards/rgaa.glossary.json +478 -0
  76. package/src/data/standards/rgaa.json +3454 -0
  77. package/src/data/wcag-universe.json +530 -0
  78. package/src/data/wcag.json +1178 -0
  79. package/src/discover.ts +203 -0
  80. package/src/fix/codemods.ts +76 -0
  81. package/src/fix/diff.ts +64 -0
  82. package/src/fix/edits.ts +126 -0
  83. package/src/fix.ts +215 -0
  84. package/src/gh.ts +214 -0
  85. package/src/glob.ts +206 -0
  86. package/src/graph/build.ts +114 -0
  87. package/src/graph/engine-resolve.ts +0 -0
  88. package/src/graph/graph.ts +126 -0
  89. package/src/graph/imports.ts +359 -0
  90. package/src/graph/resolve.ts +73 -0
  91. package/src/graph/tsconfig.ts +113 -0
  92. package/src/grounding.ts +119 -0
  93. package/src/guidance/index.ts +45 -0
  94. package/src/guidance/types.ts +36 -0
  95. package/src/init.ts +113 -0
  96. package/src/mcp/handlers.ts +388 -0
  97. package/src/mcp/http.ts +260 -0
  98. package/src/mcp/prompts.ts +183 -0
  99. package/src/mcp/protocol.ts +187 -0
  100. package/src/mcp/resources.ts +136 -0
  101. package/src/mcp/server.ts +228 -0
  102. package/src/mcp/stdio.ts +119 -0
  103. package/src/mcp/tools.ts +347 -0
  104. package/src/messages.ts +1011 -0
  105. package/src/name.ts +174 -0
  106. package/src/orchestrate-templates.ts +231 -0
  107. package/src/orchestrate.ts +186 -0
  108. package/src/output.ts +108 -0
  109. package/src/pack.ts +187 -0
  110. package/src/parse/html.ts +258 -0
  111. package/src/parse/jsx-ast.ts +67 -0
  112. package/src/parse/jsx-bridge.ts +247 -0
  113. package/src/parse/jsx.ts +19 -0
  114. package/src/parse/source.ts +75 -0
  115. package/src/prd.ts +406 -0
  116. package/src/project-lock.ts +42 -0
  117. package/src/render.ts +307 -0
  118. package/src/report.ts +399 -0
  119. package/src/rules/colors.ts +125 -0
  120. package/src/rules/cross-registry.ts +264 -0
  121. package/src/rules/cross-rule.ts +83 -0
  122. package/src/rules/forms.ts +590 -0
  123. package/src/rules/frames.ts +33 -0
  124. package/src/rules/headings.ts +227 -0
  125. package/src/rules/images.ts +200 -0
  126. package/src/rules/links.ts +193 -0
  127. package/src/rules/mandatory.ts +173 -0
  128. package/src/rules/multimedia.ts +77 -0
  129. package/src/rules/navigation.ts +190 -0
  130. package/src/rules/presentation.ts +75 -0
  131. package/src/rules/registry.ts +57 -0
  132. package/src/rules/rule.ts +122 -0
  133. package/src/rules/scripts-aria.ts +636 -0
  134. package/src/rules/tables.ts +264 -0
  135. package/src/rules/timing.ts +55 -0
  136. package/src/sample.ts +176 -0
  137. package/src/scan-local.ts +866 -0
  138. package/src/scan.ts +538 -0
  139. package/src/standards/derive.ts +195 -0
  140. package/src/standards/index.ts +31 -0
  141. package/src/standards/pack-rules.ts +111 -0
  142. package/src/standards/pack.ts +81 -0
  143. package/src/standards/registry.ts +100 -0
  144. package/src/standards/types.ts +202 -0
  145. package/src/standards/validate.ts +544 -0
  146. package/src/standards/vocabulary.ts +75 -0
  147. package/src/types.ts +355 -0
  148. package/src/util.ts +32 -0
  149. package/src/vendor/codeindex-engine.d.mts +1009 -0
  150. package/src/vendor/codeindex-engine.mjs +13701 -0
  151. package/src/vendor/engine.meta.json +9 -0
  152. package/src/verify.ts +275 -0
  153. 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
+ }