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,544 @@
|
|
|
1
|
+
// Runtime validator for a StandardPack — the deterministic guardrail shared by the
|
|
2
|
+
// runtime `--pack` loader (src/config.ts), the `pack check` command (src/pack.ts), and
|
|
3
|
+
// the AI-assisted ingestion gate. It promotes the invariants previously only asserted in
|
|
4
|
+
// tests/packs.test.ts into one reusable, fail-loud function so an AI-produced (or
|
|
5
|
+
// hand-authored) pack cannot fabricate a criterion→SC mapping and slip through. No new
|
|
6
|
+
// dependency: pure TS over the bundled types + the WCAG core (`hasSC`).
|
|
7
|
+
import type { StandardPack } from "./types.js";
|
|
8
|
+
import { hasSC, knownScStatus } from "../wcag.js";
|
|
9
|
+
|
|
10
|
+
// Mirrors CORE_KEY in registry.ts; inlined to avoid an import cycle (the registry
|
|
11
|
+
// imports this validator for registerRuntimePack).
|
|
12
|
+
const RESERVED_CORE_KEY = "wcag";
|
|
13
|
+
// A pack locale is a BCP-47-ish tag ("fr", "en", "pt-BR", "nl-BE"…) — NOT constrained to
|
|
14
|
+
// the UI frame's `Lang` ("fr"|"en"): a pack may legitimately be authored in any language
|
|
15
|
+
// (see src/standards/types.ts `LocaleString`).
|
|
16
|
+
const LOCALE_SHAPE = /^[a-z]{2,3}(-[a-zA-Z]{2,4})?$/;
|
|
17
|
+
const SC_SHAPE = /^\d+\.\d+\.\d+$/; // a WCAG success-criterion id, e.g. "1.4.3"
|
|
18
|
+
const RULE_ID_SHAPE = /^[a-z0-9]+(-[a-z0-9]+)*$/; // a declarative pack-rule slug, e.g. "download-link-format"
|
|
19
|
+
const SEVERITIES = new Set(["bloquant", "majeur", "mineur"]);
|
|
20
|
+
// Depth cap on a declarative rule's has/lacks nesting — the top-level `match` is depth 1;
|
|
21
|
+
// each nested has/lacks node adds one. Mirrored by the interpreter (src/standards/pack-rules.ts).
|
|
22
|
+
const MAX_MATCH_DEPTH = 3;
|
|
23
|
+
const MATCH_OPS = new Set(["present", "absent", "equals", "matches"]);
|
|
24
|
+
const TEXT_OPS = new Set(["matches", "lacks"]);
|
|
25
|
+
// The recognized CONDITION keys of a match node (each constrains which elements fire); a
|
|
26
|
+
// node must carry ≥1 so it can never fire on EVERY element. `scope` (top-node only) is an
|
|
27
|
+
// applicability modifier, not a condition — a scope-only match is still empty.
|
|
28
|
+
const MATCH_CONDITION_KEYS = ["tag", "attrs", "text", "has", "lacks"] as const;
|
|
29
|
+
const MATCH_NODE_KEYS = new Set<string>(MATCH_CONDITION_KEYS);
|
|
30
|
+
|
|
31
|
+
// Catastrophic-backtracking (ReDoS) SHAPES rejected in ANY pack-supplied regex (an
|
|
32
|
+
// `idPattern`, an attr `matches`, or a `text` predicate). Untrusted packs are validated at
|
|
33
|
+
// load, so we reject conservatively via three complementary red flags — none of which the
|
|
34
|
+
// legitimate criterion-id grammar `(\.\d+)*` or the non-quantified download-extension
|
|
35
|
+
// alternations `(pdf|docx?|…)` trip:
|
|
36
|
+
//
|
|
37
|
+
// 1. SINGLE quantified atom, itself quantified — (a+)+ / (a*)* / (\d+)+ / (.*)+ . The
|
|
38
|
+
// classic evil regex; the inner and outer quantifiers match overlapping input.
|
|
39
|
+
// 2. An ALTERNATION under a quantifier — (a|a)* / (a|b|c)+ . Overlapping branches let a
|
|
40
|
+
// single input char be matched N ways per iteration → exponential. A NON-quantified
|
|
41
|
+
// alternation (the (pdf|docx?|…) download list, whose group is followed by `(`/end,
|
|
42
|
+
// not by `*`/`+`) is untouched.
|
|
43
|
+
// 3. A NESTED quantifier over a GROUP that is itself quantified — (ab+)+ . Defensive:
|
|
44
|
+
// star-height ≥ 2 over a group is a red flag even when a specific instance is linear.
|
|
45
|
+
// The one legitimate star-height-2 shape — the dotted criterion grammar (\.\d+)*, where
|
|
46
|
+
// the escaped-literal prefix anchors every outer iteration so there is no blow-up — is
|
|
47
|
+
// carved out via the leading-backslash negative lookahead.
|
|
48
|
+
// Shared by `idPattern` and every declarative pack-rule regex (attrs `matches`, `text`).
|
|
49
|
+
const REDOS_SINGLE_ATOM = /\((?:\\.|\[[^\]]*\]|[^()\\])[*+]\)[*+]/;
|
|
50
|
+
const REDOS_ALT_QUANTIFIED = /\([^()]*\|[^()]*\)[*+]/;
|
|
51
|
+
const REDOS_NESTED_QUANT_GROUP = /\((?![\\])[^()|]*[*+][^()|]*\)[*+]/;
|
|
52
|
+
|
|
53
|
+
/** True iff a regex STRING trips any catastrophic-backtracking red flag. Reused by both the
|
|
54
|
+
* `idPattern` guard and the pack-rule regex guard (single shared helper). */
|
|
55
|
+
function isRedosShape(pattern: string): boolean {
|
|
56
|
+
return REDOS_SINGLE_ATOM.test(pattern) || REDOS_ALT_QUANTIFIED.test(pattern) || REDOS_NESTED_QUANT_GROUP.test(pattern);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Validate a regex STRING for a pack: reject the ReDoS shapes and non-compiling patterns.
|
|
60
|
+
* Returns a human message (to append to a field-specific prefix) or null when safe. */
|
|
61
|
+
function regexIssue(pattern: string): string | null {
|
|
62
|
+
if (isRedosShape(pattern))
|
|
63
|
+
return "has a nested quantifier or an ambiguous alternation (e.g. (a+)+, (a|a)*, (ab+)+) — a catastrophic-backtracking (ReDoS) shape; simplify it";
|
|
64
|
+
try {
|
|
65
|
+
new RegExp(pattern);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return `is not a valid regex: ${(e as Error).message}`;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Classify a WCAG SC id for pack/guidance validation, against the REAL WCAG 2.x
|
|
73
|
+
* universe (src/wcag.ts `knownScStatus`) — never a single hardcoded tolerated id, so a
|
|
74
|
+
* second pack (Section 508, EN 301 549…) can legitimately cite an AAA success criterion
|
|
75
|
+
* or the removed 4.1.1 without the gate having to special-case it. */
|
|
76
|
+
export function classifySc(sc: string): "core" | "out-of-core" | "removed" | "malformed" | "unknown" {
|
|
77
|
+
if (!SC_SHAPE.test(sc)) return "malformed";
|
|
78
|
+
if (hasSC(sc)) return "core";
|
|
79
|
+
const status = knownScStatus(sc);
|
|
80
|
+
if (status === "out-of-core" || status === "removed") return status;
|
|
81
|
+
return "unknown";
|
|
82
|
+
}
|
|
83
|
+
const REQUIRED_STRING_FIELDS = ["key", "name", "org", "country", "baseVersion", "wcagVersion", "license", "source", "attribution", "idPattern"] as const;
|
|
84
|
+
|
|
85
|
+
export interface PackIssue {
|
|
86
|
+
path: string; // dotted path into the pack, e.g. "criteria[3].wcag[0]" ("" = whole pack)
|
|
87
|
+
message: string;
|
|
88
|
+
severity: "error" | "warn";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface PackValidation {
|
|
92
|
+
ok: boolean; // false iff there is at least one error-severity issue
|
|
93
|
+
issues: PackIssue[];
|
|
94
|
+
pack?: StandardPack; // the normalized pack (key lower-cased), present only when ok
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ValidateOpts {
|
|
98
|
+
knownKeys?: ReadonlySet<string>; // built-in + already-registered keys (collision check)
|
|
99
|
+
allowOverride?: boolean; // permit a pack to replace a known key (warns instead of errors)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Validate an untrusted pack object. Errors block registration; warnings are advisory. */
|
|
103
|
+
export function validatePack(raw: unknown, opts: ValidateOpts = {}): PackValidation {
|
|
104
|
+
const issues: PackIssue[] = [];
|
|
105
|
+
const err = (path: string, message: string) => issues.push({ path, message, severity: "error" });
|
|
106
|
+
const warn = (path: string, message: string) => issues.push({ path, message, severity: "warn" });
|
|
107
|
+
const done = (): PackValidation => {
|
|
108
|
+
const ok = !issues.some((x) => x.severity === "error");
|
|
109
|
+
return { ok, issues, pack: ok ? normalize(raw as Record<string, unknown>) : undefined };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
113
|
+
err("", "pack must be a JSON object");
|
|
114
|
+
return { ok: false, issues };
|
|
115
|
+
}
|
|
116
|
+
const p = raw as Record<string, unknown>;
|
|
117
|
+
|
|
118
|
+
for (const f of REQUIRED_STRING_FIELDS) {
|
|
119
|
+
const v = p[f];
|
|
120
|
+
if (typeof v !== "string" || v.trim() === "") err(f, `"${f}" must be a non-empty string`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const key = typeof p.key === "string" ? p.key.toLowerCase() : "";
|
|
124
|
+
if (key === RESERVED_CORE_KEY) err("key", `pack key "${RESERVED_CORE_KEY}" is reserved for the WCAG core`);
|
|
125
|
+
if (key && opts.knownKeys?.has(key)) {
|
|
126
|
+
if (opts.allowOverride) warn("key", `pack key "${key}" overrides a built-in/loaded standard`);
|
|
127
|
+
else err("key", `pack key "${key}" collides with a built-in/loaded standard (use --override to replace it)`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const locales = Array.isArray(p.locales) ? p.locales : [];
|
|
131
|
+
if (locales.length === 0) err("locales", "locales must be a non-empty array");
|
|
132
|
+
for (const l of locales) {
|
|
133
|
+
if (typeof l !== "string" || !LOCALE_SHAPE.test(l)) err("locales", `malformed locale "${String(l)}" (expected a BCP-47-ish tag, e.g. "en", "fr", "pt-BR")`);
|
|
134
|
+
}
|
|
135
|
+
// A pack need not carry fr/en at all (a "de"-only standard is legitimate — the UI frame
|
|
136
|
+
// falls back to its own generic terms), but it's worth flagging: WARNING, not an error.
|
|
137
|
+
if (locales.length && !locales.some((l) => l === "fr" || l === "en")) {
|
|
138
|
+
warn("locales", 'pack carries neither "fr" nor "en" — the UI frame will fall back to its own generic auditor-display terms');
|
|
139
|
+
}
|
|
140
|
+
const defaultLocale = p.defaultLocale;
|
|
141
|
+
if (typeof defaultLocale !== "string" || !locales.includes(defaultLocale)) {
|
|
142
|
+
err("defaultLocale", "defaultLocale must be one of locales");
|
|
143
|
+
}
|
|
144
|
+
const loc = typeof defaultLocale === "string" ? defaultLocale : ((locales[0] as string | undefined) ?? "en");
|
|
145
|
+
|
|
146
|
+
let idRe: RegExp | null = null;
|
|
147
|
+
if (typeof p.idPattern === "string") {
|
|
148
|
+
const bad = regexIssue(p.idPattern);
|
|
149
|
+
if (bad) err("idPattern", `idPattern ${bad}`);
|
|
150
|
+
else idRe = new RegExp(p.idPattern);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const themes = Array.isArray(p.themes) ? (p.themes as Record<string, unknown>[]) : null;
|
|
154
|
+
if (!themes) err("themes", "themes must be an array");
|
|
155
|
+
const themeNumbers = new Set<number>();
|
|
156
|
+
themes?.forEach((t, i) => {
|
|
157
|
+
const n = t?.number;
|
|
158
|
+
if (typeof n !== "number") {
|
|
159
|
+
err(`themes[${i}].number`, "theme number must be a number");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (themeNumbers.has(n)) err(`themes[${i}].number`, `duplicate theme number ${n}`);
|
|
163
|
+
themeNumbers.add(n);
|
|
164
|
+
const name = t?.name as Record<string, unknown> | undefined;
|
|
165
|
+
if (!name || typeof name[loc] !== "string") err(`themes[${i}].name`, `theme ${n} missing name[${loc}]`);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const criteria = Array.isArray(p.criteria) ? (p.criteria as Record<string, unknown>[]) : null;
|
|
169
|
+
if (!criteria) err("criteria", "criteria must be an array");
|
|
170
|
+
const ids = new Set<string>();
|
|
171
|
+
const countByTheme = new Map<number, number>();
|
|
172
|
+
criteria?.forEach((c, i) => {
|
|
173
|
+
const id = c?.id;
|
|
174
|
+
if (typeof id !== "string" || id === "") {
|
|
175
|
+
err(`criteria[${i}].id`, "criterion id must be a non-empty string");
|
|
176
|
+
} else {
|
|
177
|
+
if (ids.has(id)) err(`criteria[${i}].id`, `duplicate criterion id "${id}"`);
|
|
178
|
+
ids.add(id);
|
|
179
|
+
if (idRe && !idRe.test(id)) err(`criteria[${i}].id`, `id "${id}" does not match idPattern ${String(p.idPattern)}`);
|
|
180
|
+
}
|
|
181
|
+
const theme = c?.theme;
|
|
182
|
+
if (typeof theme !== "number") {
|
|
183
|
+
err(`criteria[${i}].theme`, "criterion theme must be a number");
|
|
184
|
+
} else {
|
|
185
|
+
if (themes && !themeNumbers.has(theme)) err(`criteria[${i}].theme`, `criterion "${String(id)}" references unknown theme ${theme}`);
|
|
186
|
+
countByTheme.set(theme, (countByTheme.get(theme) ?? 0) + 1);
|
|
187
|
+
}
|
|
188
|
+
const title = c?.title as Record<string, unknown> | undefined;
|
|
189
|
+
if (!title || typeof title[loc] !== "string") err(`criteria[${i}].title`, `criterion "${String(id)}" missing title[${loc}]`);
|
|
190
|
+
const titlePlain = c?.titlePlain as Record<string, unknown> | undefined;
|
|
191
|
+
if (!titlePlain || typeof titlePlain[loc] !== "string") err(`criteria[${i}].titlePlain`, `criterion "${String(id)}" missing titlePlain[${loc}]`);
|
|
192
|
+
// Optional per-criterion applicability (src/standards/types.ts PackCriterion.appliesTo).
|
|
193
|
+
// Present → must be { ruleIds: string[] } of non-empty strings; an empty list is legal
|
|
194
|
+
// (a criterion no engine rule can evidence). Registry-existence of the ruleIds is an
|
|
195
|
+
// advisory `pack check` WARNING (src/pack.ts), not a hard error here.
|
|
196
|
+
if (c?.appliesTo !== undefined) {
|
|
197
|
+
const a = c.appliesTo as Record<string, unknown> | undefined;
|
|
198
|
+
const ruleIds = a && typeof a === "object" && !Array.isArray(a) ? a.ruleIds : undefined;
|
|
199
|
+
if (!a || typeof a !== "object" || Array.isArray(a) || !Array.isArray(ruleIds)) {
|
|
200
|
+
err(`criteria[${i}].appliesTo`, `criterion "${String(id)}" appliesTo must be an object { ruleIds: string[] }`);
|
|
201
|
+
} else {
|
|
202
|
+
(ruleIds as unknown[]).forEach((r, k) => {
|
|
203
|
+
if (typeof r !== "string" || r.trim() === "")
|
|
204
|
+
err(`criteria[${i}].appliesTo.ruleIds[${k}]`, `criterion "${String(id)}" appliesTo.ruleIds must be non-empty strings`);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const wcag = Array.isArray(c?.wcag) ? (c.wcag as unknown[]) : null;
|
|
209
|
+
if (!wcag || wcag.length === 0) {
|
|
210
|
+
err(`criteria[${i}].wcag`, `criterion "${String(id)}" must map to at least one WCAG SC`);
|
|
211
|
+
} else {
|
|
212
|
+
wcag.forEach((sc, j) => {
|
|
213
|
+
const where = `criteria[${i}].wcag[${j}]`;
|
|
214
|
+
if (typeof sc !== "string") {
|
|
215
|
+
err(where, `malformed SC id "${String(sc)}" (expected N.N.N)`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
switch (classifySc(sc)) {
|
|
219
|
+
case "malformed":
|
|
220
|
+
err(where, `malformed SC id "${sc}" (expected N.N.N)`);
|
|
221
|
+
break;
|
|
222
|
+
case "unknown":
|
|
223
|
+
err(
|
|
224
|
+
where,
|
|
225
|
+
`SC "${sc}" is not a recognized WCAG success criterion (not in the WCAG 2.2 AA core, and not a real WCAG AAA or removed SC) — fabricated?`,
|
|
226
|
+
);
|
|
227
|
+
break;
|
|
228
|
+
case "out-of-core":
|
|
229
|
+
warn(
|
|
230
|
+
where,
|
|
231
|
+
`SC "${sc}" is a real WCAG AAA success criterion, outside the WCAG 2.2 AA core — kept as a pack-local mapping (out of engine scope; derive as manual)`,
|
|
232
|
+
);
|
|
233
|
+
break;
|
|
234
|
+
case "removed":
|
|
235
|
+
warn(
|
|
236
|
+
where,
|
|
237
|
+
`SC "${sc}" is a real but removed WCAG success criterion (obsolete) — kept as a pack-local mapping (out of engine scope; derive as manual)`,
|
|
238
|
+
);
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
themes?.forEach((t, i) => {
|
|
246
|
+
const n = t?.number;
|
|
247
|
+
if (typeof n !== "number") return;
|
|
248
|
+
const declared = t?.count;
|
|
249
|
+
const actual = countByTheme.get(n) ?? 0;
|
|
250
|
+
if (typeof declared === "number" && declared !== actual) {
|
|
251
|
+
err(`themes[${i}].count`, `theme ${n} declares count ${declared} but has ${actual} criteria`);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Optional auditor-display vocabulary. Absent → the generic/core defaults apply (never an
|
|
256
|
+
// error). Present → each supplied term must be a LocaleString carrying at least the default
|
|
257
|
+
// locale; a partial/unknown/malformed term is a WARNING (the default is used), never a hard
|
|
258
|
+
// failure — a pack should not be blocked over presentation strings.
|
|
259
|
+
if (p.vocabulary !== undefined) {
|
|
260
|
+
if (typeof p.vocabulary !== "object" || p.vocabulary === null || Array.isArray(p.vocabulary)) {
|
|
261
|
+
warn("vocabulary", "vocabulary must be an object of localized terms — ignored");
|
|
262
|
+
} else {
|
|
263
|
+
const VOC_KEYS = ["theme", "criterion", "test", "conformant", "nonConformant", "notApplicable", "auditorHeading", "normativeNote"];
|
|
264
|
+
const voc = p.vocabulary as Record<string, unknown>;
|
|
265
|
+
for (const k of Object.keys(voc)) {
|
|
266
|
+
if (!VOC_KEYS.includes(k)) {
|
|
267
|
+
warn(`vocabulary.${k}`, `unknown vocabulary term "${k}" (ignored)`);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const term = voc[k] as Record<string, unknown> | undefined;
|
|
271
|
+
if (typeof term !== "object" || term === null || Array.isArray(term)) {
|
|
272
|
+
warn(`vocabulary.${k}`, `term "${k}" must be a localized object (e.g. { "${loc}": "…" }) — default used`);
|
|
273
|
+
} else if (typeof term[loc] !== "string") {
|
|
274
|
+
warn(`vocabulary.${k}`, `term "${k}" has no string for the default locale "${loc}" — default used`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Optional normative page-sample methodology (src/standards/types.ts SampleMethodology).
|
|
281
|
+
// Purely ADVISORY (drives `sample check` / `scan --sample` lint), so a malformation is a
|
|
282
|
+
// WARNING and the field is ignored — never a hard failure that blocks the pack from
|
|
283
|
+
// deriving reports. Mirrors the vocabulary block's tolerance above.
|
|
284
|
+
if (p.sampleMethodology !== undefined) {
|
|
285
|
+
const m = p.sampleMethodology as Record<string, unknown> | null;
|
|
286
|
+
const kinds = m && typeof m === "object" && !Array.isArray(m) ? m.requiredKinds : undefined;
|
|
287
|
+
if (!m || typeof m !== "object" || Array.isArray(m) || !Array.isArray(kinds)) {
|
|
288
|
+
warn("sampleMethodology", "sampleMethodology must be an object { requiredKinds: [...] } — ignored");
|
|
289
|
+
} else {
|
|
290
|
+
(kinds as unknown[]).forEach((k, i) => {
|
|
291
|
+
const kk = k as Record<string, unknown> | null;
|
|
292
|
+
if (!kk || typeof kk !== "object" || Array.isArray(kk)) {
|
|
293
|
+
warn(`sampleMethodology.requiredKinds[${i}]`, "each required kind must be an object { id, label, keywords } — ignored");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (typeof kk.id !== "string" || kk.id.trim() === "") warn(`sampleMethodology.requiredKinds[${i}].id`, "required kind id should be a non-empty string");
|
|
297
|
+
const label = kk.label as Record<string, unknown> | undefined;
|
|
298
|
+
if (!label || typeof label !== "object" || Array.isArray(label) || typeof label[loc] !== "string")
|
|
299
|
+
warn(`sampleMethodology.requiredKinds[${i}].label`, `required kind should carry label[${loc}]`);
|
|
300
|
+
if (!Array.isArray(kk.keywords) || (kk.keywords as unknown[]).some((w) => typeof w !== "string"))
|
|
301
|
+
warn(`sampleMethodology.requiredKinds[${i}].keywords`, "required kind keywords should be an array of strings");
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---- Declarative pack RULES (src/standards/types.ts PackRule). Optional; when present,
|
|
307
|
+
// each rule is bounded, self-contained data (NO code): a slug id, a criterion that must
|
|
308
|
+
// exist in THIS pack, WCAG SC(s) in the core universe, a matcher whose regexes are
|
|
309
|
+
// ReDoS-guarded and whose has/lacks nesting is depth-capped, and en+fr message/remediation.
|
|
310
|
+
if (p.rules !== undefined) {
|
|
311
|
+
if (!Array.isArray(p.rules)) {
|
|
312
|
+
err("rules", "rules must be an array");
|
|
313
|
+
} else {
|
|
314
|
+
const ruleIds = new Set<string>();
|
|
315
|
+
(p.rules as unknown[]).forEach((raw, i) => {
|
|
316
|
+
const at = `rules[${i}]`;
|
|
317
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
318
|
+
err(at, "each rule must be an object");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const r = raw as Record<string, unknown>;
|
|
322
|
+
const rid = r.id;
|
|
323
|
+
if (typeof rid !== "string" || !RULE_ID_SHAPE.test(rid)) {
|
|
324
|
+
err(`${at}.id`, 'rule id must be a lower-kebab slug (e.g. "download-link-format")');
|
|
325
|
+
} else {
|
|
326
|
+
if (ruleIds.has(rid)) err(`${at}.id`, `duplicate rule id "${rid}"`);
|
|
327
|
+
ruleIds.add(rid);
|
|
328
|
+
}
|
|
329
|
+
if (typeof r.criterion !== "string" || r.criterion === "") {
|
|
330
|
+
err(`${at}.criterion`, "rule criterion must be a non-empty string");
|
|
331
|
+
} else if (!ids.has(r.criterion)) {
|
|
332
|
+
err(`${at}.criterion`, `rule reports under criterion "${r.criterion}" which does not exist in this pack`);
|
|
333
|
+
}
|
|
334
|
+
if (typeof r.severity !== "string" || !SEVERITIES.has(r.severity)) {
|
|
335
|
+
err(`${at}.severity`, "rule severity must be one of bloquant|majeur|mineur");
|
|
336
|
+
}
|
|
337
|
+
if (r.advisory !== undefined && typeof r.advisory !== "boolean") err(`${at}.advisory`, "rule advisory must be a boolean");
|
|
338
|
+
const wcag = Array.isArray(r.wcag) ? (r.wcag as unknown[]) : null;
|
|
339
|
+
if (!wcag || wcag.length === 0) {
|
|
340
|
+
err(`${at}.wcag`, "rule must map to at least one WCAG SC");
|
|
341
|
+
} else {
|
|
342
|
+
wcag.forEach((sc, j) => {
|
|
343
|
+
const where = `${at}.wcag[${j}]`;
|
|
344
|
+
if (typeof sc !== "string") {
|
|
345
|
+
err(where, `malformed SC id "${String(sc)}" (expected N.N.N)`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
switch (classifySc(sc)) {
|
|
349
|
+
case "malformed":
|
|
350
|
+
err(where, `malformed SC id "${sc}" (expected N.N.N)`);
|
|
351
|
+
break;
|
|
352
|
+
case "unknown":
|
|
353
|
+
err(where, `SC "${sc}" is not a recognized WCAG success criterion — fabricated?`);
|
|
354
|
+
break;
|
|
355
|
+
case "out-of-core":
|
|
356
|
+
warn(where, `SC "${sc}" is a real WCAG AAA success criterion, outside the WCAG 2.2 AA core (out of engine scope)`);
|
|
357
|
+
break;
|
|
358
|
+
case "removed":
|
|
359
|
+
warn(where, `SC "${sc}" is a real but removed WCAG success criterion (obsolete)`);
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
// The declared SC must be one the reporting criterion maps to, else the finding
|
|
364
|
+
// can never project onto it (inert) — advisory, mirrors the guidance check.
|
|
365
|
+
if (typeof r.criterion === "string") {
|
|
366
|
+
const crit = criteria?.find((c) => c.id === r.criterion) as Record<string, unknown> | undefined;
|
|
367
|
+
const critWcag = Array.isArray(crit?.wcag) ? (crit!.wcag as unknown[]) : [];
|
|
368
|
+
if (critWcag.length && !wcag.some((sc) => critWcag.includes(sc)))
|
|
369
|
+
warn(`${at}.wcag`, `none of the rule's SC(s) are in criterion ${String(r.criterion)}'s WCAG mapping — the finding will not project`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
validateMatch(r.match, `${at}.match`, 1, err, true);
|
|
373
|
+
validateLocaleText(r.message, `${at}.message`, err);
|
|
374
|
+
validateLocaleText(r.remediation, `${at}.remediation`, err);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---- Per-pack normativity/severity OVERRIDES (src/standards/types.ts PackOverride).
|
|
380
|
+
// Optional map keyed by a finding ruleId; each value flips advisory and/or re-grades
|
|
381
|
+
// severity within the pack projection only. Shape-checked here; ruleId existence is an
|
|
382
|
+
// advisory `pack check` concern (a pack may target a future/renamed rule).
|
|
383
|
+
if (p.overrides !== undefined) {
|
|
384
|
+
if (typeof p.overrides !== "object" || p.overrides === null || Array.isArray(p.overrides)) {
|
|
385
|
+
err("overrides", "overrides must be an object keyed by ruleId");
|
|
386
|
+
} else {
|
|
387
|
+
for (const [ruleId, raw] of Object.entries(p.overrides as Record<string, unknown>)) {
|
|
388
|
+
const at = `overrides["${ruleId}"]`;
|
|
389
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
390
|
+
err(at, "each override must be an object { advisory?, severity? }");
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
const o = raw as Record<string, unknown>;
|
|
394
|
+
if (o.advisory !== undefined && typeof o.advisory !== "boolean") err(`${at}.advisory`, "override advisory must be a boolean");
|
|
395
|
+
if (o.severity !== undefined && (typeof o.severity !== "string" || !SEVERITIES.has(o.severity)))
|
|
396
|
+
err(`${at}.severity`, "override severity must be one of bloquant|majeur|mineur");
|
|
397
|
+
if (o.advisory === undefined && o.severity === undefined) warn(at, "override has neither advisory nor severity — no effect");
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---- Secondary crosswalk MAPPINGS (src/standards/types.ts SecondaryMapping). Optional
|
|
403
|
+
// array; each entry projects a ruleId onto an ADDITIONAL pack criterion whose WCAG
|
|
404
|
+
// crosswalk does NOT contain the finding's SC — a DELIBERATE, opt-in deviation. Shape:
|
|
405
|
+
// non-empty `ruleId`; a `criterion` that MUST exist in this pack (like a declarative
|
|
406
|
+
// rule's reporting criterion); an optional localized `note`; an optional boolean
|
|
407
|
+
// `enabled`. We DELIBERATELY do NOT require the finding's SC to be in the criterion's
|
|
408
|
+
// `wcag` — that bypass is the whole point — so every well-formed entry gets a `warn`
|
|
409
|
+
// documenting the intentional deviation. Runs for built-in AND runtime packs.
|
|
410
|
+
if (p.secondaryMappings !== undefined) {
|
|
411
|
+
if (!Array.isArray(p.secondaryMappings)) {
|
|
412
|
+
err("secondaryMappings", "secondaryMappings must be an array");
|
|
413
|
+
} else {
|
|
414
|
+
(p.secondaryMappings as unknown[]).forEach((raw, i) => {
|
|
415
|
+
const at = `secondaryMappings[${i}]`;
|
|
416
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
417
|
+
err(at, "each secondary mapping must be an object { ruleId, criterion, note?, enabled? }");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const m = raw as Record<string, unknown>;
|
|
421
|
+
if (typeof m.ruleId !== "string" || m.ruleId.trim() === "") err(`${at}.ruleId`, "secondary mapping ruleId must be a non-empty string");
|
|
422
|
+
if (typeof m.criterion !== "string" || m.criterion === "") {
|
|
423
|
+
err(`${at}.criterion`, "secondary mapping criterion must be a non-empty string");
|
|
424
|
+
} else if (!ids.has(m.criterion)) {
|
|
425
|
+
err(`${at}.criterion`, `secondary mapping projects onto criterion "${m.criterion}" which does not exist in this pack`);
|
|
426
|
+
}
|
|
427
|
+
if (m.enabled !== undefined && typeof m.enabled !== "boolean") err(`${at}.enabled`, "secondary mapping enabled must be a boolean");
|
|
428
|
+
if (m.note !== undefined) {
|
|
429
|
+
if (typeof m.note !== "object" || m.note === null || Array.isArray(m.note)) {
|
|
430
|
+
err(`${at}.note`, `secondary mapping note must be a localized object (e.g. { "${loc}": "…" })`);
|
|
431
|
+
} else if (typeof (m.note as Record<string, unknown>)[loc] !== "string") {
|
|
432
|
+
warn(`${at}.note`, `secondary mapping note has no string for the default locale "${loc}"`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// Only a well-formed entry (ruleId present, criterion exists) documents the deviation.
|
|
436
|
+
if (typeof m.ruleId === "string" && m.ruleId.trim() !== "" && typeof m.criterion === "string" && ids.has(m.criterion)) {
|
|
437
|
+
warn(at, `secondary mapping bypasses the SC crosswalk — intentional deviation (ruleId "${m.ruleId}" → criterion "${m.criterion}")`);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return done();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
type Emit = (path: string, message: string) => void;
|
|
447
|
+
|
|
448
|
+
/** Recursively validate a declarative rule's match node: bounded depth, known ops,
|
|
449
|
+
* ReDoS-safe regexes. `top` marks the outermost node (which may carry `scope`). */
|
|
450
|
+
function validateMatch(node: unknown, path: string, depth: number, err: Emit, top: boolean): void {
|
|
451
|
+
if (top && node === undefined) {
|
|
452
|
+
err(path, "rule must carry a match");
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (typeof node !== "object" || node === null || Array.isArray(node)) {
|
|
456
|
+
err(path, "match must be an object");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (depth > MAX_MATCH_DEPTH) {
|
|
460
|
+
err(path, `match nesting exceeds the maximum depth of ${MAX_MATCH_DEPTH}`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const n = node as Record<string, unknown>;
|
|
464
|
+
// Reject unknown keys — a typo like { tgo: "a" } would otherwise be silently ignored and
|
|
465
|
+
// the (now condition-less) match would fire on every element.
|
|
466
|
+
const allowedKeys = top ? new Set([...MATCH_NODE_KEYS, "scope"]) : MATCH_NODE_KEYS;
|
|
467
|
+
for (const k of Object.keys(n)) {
|
|
468
|
+
if (!allowedKeys.has(k)) err(`${path}.${k}`, `unknown match key "${k}" (allowed: ${[...allowedKeys].sort().join(", ")})`);
|
|
469
|
+
}
|
|
470
|
+
// Require ≥1 EFFECTIVE condition (an empty match — or one with only empty arrays / a bare
|
|
471
|
+
// scope — fires on every element, a footgun).
|
|
472
|
+
const hasCondition = MATCH_CONDITION_KEYS.some((k) => {
|
|
473
|
+
const v = n[k];
|
|
474
|
+
if (k === "tag") return typeof v === "string" && v !== "";
|
|
475
|
+
if (k === "text") return v !== undefined && v !== null;
|
|
476
|
+
return Array.isArray(v) && v.length > 0; // attrs / has / lacks
|
|
477
|
+
});
|
|
478
|
+
if (!hasCondition) err(path, "match must carry at least one condition (tag, attrs, text, has, or lacks) — an empty match fires on every element");
|
|
479
|
+
if (n.tag !== undefined && (typeof n.tag !== "string" || n.tag === "")) err(`${path}.tag`, "match tag must be a non-empty string");
|
|
480
|
+
if (top && n.scope !== undefined && n.scope !== "page" && n.scope !== "fragment") err(`${path}.scope`, 'match scope must be "page" or "fragment"');
|
|
481
|
+
if (n.attrs !== undefined) {
|
|
482
|
+
if (!Array.isArray(n.attrs)) err(`${path}.attrs`, "match attrs must be an array");
|
|
483
|
+
else
|
|
484
|
+
(n.attrs as unknown[]).forEach((raw, i) => {
|
|
485
|
+
const a = raw as Record<string, unknown> | null;
|
|
486
|
+
const at = `${path}.attrs[${i}]`;
|
|
487
|
+
if (!a || typeof a !== "object" || Array.isArray(a)) {
|
|
488
|
+
err(at, "each attr condition must be an object { name, op, value? }");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (typeof a.name !== "string" || a.name === "") err(`${at}.name`, "attr name must be a non-empty string");
|
|
492
|
+
if (typeof a.op !== "string" || !MATCH_OPS.has(a.op)) err(`${at}.op`, "attr op must be one of present|absent|equals|matches");
|
|
493
|
+
if ((a.op === "equals" || a.op === "matches") && typeof a.value !== "string") err(`${at}.value`, `attr op "${String(a.op)}" requires a string value`);
|
|
494
|
+
if (a.op === "matches" && typeof a.value === "string") {
|
|
495
|
+
const bad = regexIssue(a.value);
|
|
496
|
+
if (bad) err(`${at}.value`, `attr matches regex ${bad}`);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
if (n.text !== undefined) {
|
|
501
|
+
const t = n.text as Record<string, unknown> | null;
|
|
502
|
+
if (!t || typeof t !== "object" || Array.isArray(t)) {
|
|
503
|
+
err(`${path}.text`, "match text must be an object { op, value }");
|
|
504
|
+
} else {
|
|
505
|
+
if (typeof t.op !== "string" || !TEXT_OPS.has(t.op)) err(`${path}.text.op`, "text op must be one of matches|lacks");
|
|
506
|
+
if (typeof t.value !== "string" || t.value === "") err(`${path}.text.value`, "text value must be a non-empty regex string");
|
|
507
|
+
else {
|
|
508
|
+
const bad = regexIssue(t.value);
|
|
509
|
+
if (bad) err(`${path}.text.value`, `text regex ${bad}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
for (const key of ["has", "lacks"] as const) {
|
|
514
|
+
const v = n[key];
|
|
515
|
+
if (v === undefined) continue;
|
|
516
|
+
if (!Array.isArray(v)) err(`${path}.${key}`, `match ${key} must be an array of match nodes`);
|
|
517
|
+
else (v as unknown[]).forEach((child, i) => validateMatch(child, `${path}.${key}[${i}]`, depth + 1, err, false));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** A rule's message/remediation must carry BOTH en and fr (findings render in the UI
|
|
522
|
+
* frame's Lang, which is fr|en) — stricter than a pack's own free-form LocaleString. */
|
|
523
|
+
function validateLocaleText(v: unknown, path: string, err: Emit): void {
|
|
524
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) {
|
|
525
|
+
err(path, "must be a localized object carrying both en and fr");
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const t = v as Record<string, unknown>;
|
|
529
|
+
for (const lang of ["en", "fr"] as const) {
|
|
530
|
+
if (typeof t[lang] !== "string" || t[lang] === "") err(`${path}.${lang}`, `missing ${lang} text`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function normalize(p: Record<string, unknown>): StandardPack {
|
|
535
|
+
return { ...p, key: String(p.key).toLowerCase() } as unknown as StandardPack;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Format issues for CLI output (one line each, errors first). */
|
|
539
|
+
export function formatIssues(issues: PackIssue[]): string[] {
|
|
540
|
+
const order = (s: PackIssue["severity"]) => (s === "error" ? 0 : 1);
|
|
541
|
+
return [...issues]
|
|
542
|
+
.sort((a, b) => order(a.severity) - order(b.severity))
|
|
543
|
+
.map((x) => ` ${x.severity === "error" ? "✗" : "⚠"} ${x.path ? `${x.path}: ` : ""}${x.message}`);
|
|
544
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Resolve the localized AUDITOR-DISPLAY vocabulary for a standard — the nouns the
|
|
2
|
+
// auditor block (src/auditor.ts) and GitHub issues render. The standard-specific terms
|
|
3
|
+
// come from `pack.vocabulary` (see types.ts); the WCAG core is canonical (not a pack) so
|
|
4
|
+
// its terms live here; anything a pack omits falls back to a generic default. This is the
|
|
5
|
+
// modular seam: a new country pack ships its own vocabulary and the output speaks its
|
|
6
|
+
// language, with zero engine changes.
|
|
7
|
+
import type { Lang } from "../types.js";
|
|
8
|
+
import type { LocaleString, PackVocabulary } from "./types.js";
|
|
9
|
+
import { isCore, getPack } from "./registry.js";
|
|
10
|
+
|
|
11
|
+
// The fully-resolved terms (every field present, in the requested language).
|
|
12
|
+
export interface ResolvedVocabulary {
|
|
13
|
+
theme: string;
|
|
14
|
+
criterion: string;
|
|
15
|
+
test: string;
|
|
16
|
+
conformant: string;
|
|
17
|
+
nonConformant: string;
|
|
18
|
+
notApplicable: string;
|
|
19
|
+
auditorHeading: string;
|
|
20
|
+
normativeNote?: string; // present only when a pack supplies a bespoke note
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type TermKey = Exclude<keyof PackVocabulary, "normativeNote">;
|
|
24
|
+
|
|
25
|
+
// Generic fallback for a pack that omits a term.
|
|
26
|
+
const DEFAULT: Record<TermKey, Record<Lang, string>> = {
|
|
27
|
+
theme: { en: "Theme", fr: "Thématique" },
|
|
28
|
+
criterion: { en: "Criterion", fr: "Critère" },
|
|
29
|
+
test: { en: "Test", fr: "Test" },
|
|
30
|
+
conformant: { en: "Conformant", fr: "Conforme" },
|
|
31
|
+
nonConformant: { en: "Non-conformant", fr: "Non conforme" },
|
|
32
|
+
notApplicable: { en: "Not applicable", fr: "Non applicable" },
|
|
33
|
+
auditorHeading: { en: "Accessibility criterion", fr: "Critère d'accessibilité" },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// The WCAG 2.2 core vocabulary (the core is not a pack, so it cannot carry `vocabulary`).
|
|
37
|
+
const CORE: Record<TermKey, Record<Lang, string>> = {
|
|
38
|
+
theme: { en: "Principle · Guideline", fr: "Principe · Règle" },
|
|
39
|
+
criterion: { en: "Success criterion", fr: "Critère de succès" },
|
|
40
|
+
test: { en: "Technique", fr: "Technique" },
|
|
41
|
+
conformant: { en: "Pass", fr: "Conforme" },
|
|
42
|
+
nonConformant: { en: "Fail", fr: "Non conforme" },
|
|
43
|
+
notApplicable: { en: "Not applicable", fr: "Non applicable" },
|
|
44
|
+
auditorHeading: { en: "Accessibility criterion", fr: "Critère d'accessibilité" },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Resolve a localized string: requested lang → pack default → English → first available.
|
|
48
|
+
* `lang`/`fallbackLang` are `string`, not the UI frame's `Lang` — a pack's own locale
|
|
49
|
+
* (its `defaultLocale`) is not constrained to "fr"|"en" (see src/standards/types.ts). */
|
|
50
|
+
function pick(s: LocaleString | undefined, lang: string, fallbackLang: string): string | undefined {
|
|
51
|
+
if (!s) return undefined;
|
|
52
|
+
return s[lang] ?? s[fallbackLang] ?? s.en ?? Object.values(s)[0];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The resolved auditor vocabulary for a standard (WCAG core or a pack) in `lang`. */
|
|
56
|
+
export function vocabularyFor(standard: string, lang: string): ResolvedVocabulary {
|
|
57
|
+
const base = isCore(standard) ? CORE : DEFAULT;
|
|
58
|
+
const pack = isCore(standard) ? undefined : getPack(standard);
|
|
59
|
+
const voc = pack?.vocabulary;
|
|
60
|
+
const packDefault = pack?.defaultLocale ?? "en";
|
|
61
|
+
// The core/default fallback tables only ever carry "fr"/"en" — a pack-only locale
|
|
62
|
+
// (e.g. "de") that isn't one of those two falls through to "en" here, same as any
|
|
63
|
+
// other unmapped requested lang.
|
|
64
|
+
const term = (k: TermKey): string => pick(voc?.[k], lang, packDefault) ?? (base[k] as Record<string, string>)[lang] ?? base[k].en;
|
|
65
|
+
return {
|
|
66
|
+
theme: term("theme"),
|
|
67
|
+
criterion: term("criterion"),
|
|
68
|
+
test: term("test"),
|
|
69
|
+
conformant: term("conformant"),
|
|
70
|
+
nonConformant: term("nonConformant"),
|
|
71
|
+
notApplicable: term("notApplicable"),
|
|
72
|
+
auditorHeading: term("auditorHeading"),
|
|
73
|
+
normativeNote: pick(voc?.normativeNote, lang, packDefault),
|
|
74
|
+
};
|
|
75
|
+
}
|