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,203 @@
1
+ // Smart file discovery for `audit` at scale. The static engine never needs to
2
+ // hold a whole monorepo in memory — it focuses: only markup files, optionally
3
+ // only what git says changed, ordered so the highest-value markup (layouts,
4
+ // templates, shared components) is audited first. Dedup + the --max-files cap are
5
+ // enforced by the streaming loop in audit.ts (single read per file). Zero deps:
6
+ // just node:fs + node:child_process (git) + node:path.
7
+ import { existsSync } from "node:fs";
8
+ import { execFileSync } from "node:child_process";
9
+ import { join, relative } from "node:path";
10
+ import { expandInputs, makeFilter, inScopeMatcher, toPosix, type ExpandOpts } from "./glob.js";
11
+
12
+ export interface DiscoverOpts extends ExpandOpts {
13
+ changed?: boolean; // audit only files changed vs `since` (default HEAD)
14
+ since?: string; // git ref to diff against (implies changed)
15
+ staged?: boolean; // audit exactly the staged index snapshot (strict pre-commit scope)
16
+ onWarn?: (msg: string) => void;
17
+ }
18
+
19
+ export interface Discovery {
20
+ files: string[]; // ordered: priority tier, then path (deterministic)
21
+ changedMode: boolean;
22
+ gitUnavailable: boolean; // changed mode requested but not a git repo / no git
23
+ stagedContent?: Map<string, string>; // staged mode only: file → index blob content
24
+ }
25
+
26
+ function git(args: string[], maxBuffer?: number): string | null {
27
+ try {
28
+ return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...(maxBuffer ? { maxBuffer } : {}) });
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /** Files changed vs `ref` (working tree + index + untracked), repo-root-relative
35
+ * paths resolved to cwd-relative. null if git is unavailable / not a repo. */
36
+ export function gitChangedFiles(ref?: string): string[] | null {
37
+ const top = git(["rev-parse", "--show-toplevel"]);
38
+ if (top === null) return null;
39
+ const repoRoot = top.trim();
40
+ const base = ref?.trim() ? ref.trim() : "HEAD";
41
+ const out = new Set<string>();
42
+ const add = (s: string | null): void => {
43
+ if (!s) return;
44
+ for (const line of s.split("\n")) {
45
+ const t = line.trim();
46
+ if (t) out.add(t);
47
+ }
48
+ };
49
+ // --diff-filter=d drops deletions: we never audit a file that no longer exists.
50
+ add(git(["diff", "--name-only", "--diff-filter=d", base]));
51
+ add(git(["diff", "--name-only", "--diff-filter=d", "--cached", base]));
52
+ add(git(["ls-files", "--others", "--exclude-standard"]));
53
+ const cwd = process.cwd();
54
+ return [...out].map((p) => relative(cwd, join(repoRoot, p)));
55
+ }
56
+
57
+ /** Staged (index) markup files: adds + modifications only (index vs HEAD) — NOT
58
+ * untracked, NOT working-tree-only edits. --diff-filter=d drops staged deletions; a
59
+ * rename surfaces as the new path. Repo-root-relative → cwd-relative. null if git is
60
+ * unavailable / not a repo. */
61
+ export function gitStagedFiles(): string[] | null {
62
+ const top = git(["rev-parse", "--show-toplevel"]);
63
+ if (top === null) return null;
64
+ const out = git(["diff", "--cached", "--name-only", "--diff-filter=d"]);
65
+ if (out === null) return null;
66
+ const repoRoot = top.trim();
67
+ const cwd = process.cwd();
68
+ return out
69
+ .split("\n")
70
+ .map((s) => s.trim())
71
+ .filter(Boolean)
72
+ .map((p) => relative(cwd, join(repoRoot, p)));
73
+ }
74
+
75
+ /** Exact bytes of the index (staged) blob for a path — what a commit would record,
76
+ * including any .gitattributes clean filters. `:./` makes the pathspec cwd-relative.
77
+ * null on any error (untracked path, or a blob larger than maxBuffer). */
78
+ export function stagedContent(file: string): string | null {
79
+ return git(["show", `:./${toPosix(file)}`], 32 * 1024 * 1024);
80
+ }
81
+
82
+ /** Index blobs for MANY paths in ONE `git cat-file --batch` instead of one `git show`
83
+ * process per file — the pre-commit hook's cost used to grow one spawn at a time.
84
+ * Falls back to per-file `git show` when the batch is unavailable, so behaviour is
85
+ * identical either way (a path with no index blob is simply absent from the map).
86
+ *
87
+ * `--batch` answers each request with `<sha> <type> <size>\n<size bytes>\n`, or
88
+ * `<request> missing\n`. Sizes are BYTE counts, so the stream is walked as a Buffer
89
+ * and each blob decoded afterwards — slicing utf8 by character count would corrupt
90
+ * any file containing non-ASCII. */
91
+ export function stagedContents(files: string[]): Map<string, string> {
92
+ const out = new Map<string, string>();
93
+ if (!files.length) return out;
94
+ const requests = files.map((f) => `:./${toPosix(f)}`);
95
+ let buf: Buffer | null = null;
96
+ try {
97
+ buf = execFileSync("git", ["cat-file", "--batch"], {
98
+ input: `${requests.join("\n")}\n`,
99
+ stdio: ["pipe", "pipe", "ignore"],
100
+ maxBuffer: 512 * 1024 * 1024,
101
+ });
102
+ } catch {
103
+ buf = null;
104
+ }
105
+ if (buf === null) {
106
+ for (const f of files) {
107
+ const c = stagedContent(f);
108
+ if (c !== null) out.set(f, c);
109
+ }
110
+ return out;
111
+ }
112
+ let pos = 0;
113
+ for (const file of files) {
114
+ const nl = buf.indexOf(0x0a, pos);
115
+ if (nl === -1) break; // truncated stream — the rest is simply not resolved
116
+ const header = buf.toString("utf8", pos, nl);
117
+ pos = nl + 1;
118
+ const m = /^[0-9a-f]{40,64} \w+ (\d+)$/.exec(header);
119
+ if (!m) continue; // "<request> missing" (or an ambiguous name): no index blob
120
+ const size = Number(m[1]);
121
+ out.set(file, buf.toString("utf8", pos, pos + size));
122
+ pos += size + 1; // blob + its trailing newline
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /** True when the working tree differs from the index for `file` (unstaged edits).
128
+ * A partially-staged file must never be auto-fixed + re-staged: writing index-derived
129
+ * output over the working tree and `git add`-ing it would silently stage those edits. */
130
+ export function hasUnstagedChanges(file: string): boolean {
131
+ return !!git(["diff", "--name-only", "--", file])?.trim();
132
+ }
133
+
134
+ /** Re-stage a file after a deterministic fix (only ever called on fully-staged files). */
135
+ export function gitAdd(file: string): void {
136
+ git(["add", "--", file]);
137
+ }
138
+
139
+ // Audit the highest-value markup first so a partial / capped run still covers
140
+ // what matters. Lower number = earlier.
141
+ const TIER0 = /(^|\/)(layout|template|_app|_document|app|main|index)[.\-/]|(^|\/)(layouts?|templates?)\//i;
142
+ const TIER1 = /(^|\/)(components?|shared|ui|design-system|ds|partials?|includes?)\//i;
143
+ export function priority(file: string): number {
144
+ const rel = toPosix(file);
145
+ if (TIER0.test(rel)) return 0;
146
+ if (TIER1.test(rel)) return 1;
147
+ return 2;
148
+ }
149
+
150
+ /** Sort by priority tier, then path — deterministic, and the tier is computed ONCE per
151
+ * file instead of twice per comparison (the two `priority()` regexes used to run
152
+ * ~4·n·log n times on every discovery). */
153
+ function sortByPriorityThenPath(files: string[]): string[] {
154
+ return files
155
+ .map((file) => ({ file, tier: priority(file) }))
156
+ .sort((a, b) => a.tier - b.tier || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0))
157
+ .map((d) => d.file);
158
+ }
159
+
160
+ /** Resolve inputs into an ordered candidate file list. In `--changed` mode this
161
+ * starts from git's changed set (no full tree walk) filtered to the audited
162
+ * scope; otherwise it expands inputs normally. */
163
+ export function discover(inputs: string[], opts: DiscoverOpts = {}): Discovery {
164
+ const changedMode = !!(opts.changed || opts.since || opts.staged);
165
+ let files: string[];
166
+ let gitUnavailable = false;
167
+ let staged: Map<string, string> | undefined;
168
+
169
+ if (opts.staged) {
170
+ // Strict staged snapshot: audit exactly the index blobs (what a commit records),
171
+ // not the working-tree copy. --staged takes precedence over --changed/--since.
172
+ const stagedFiles = gitStagedFiles();
173
+ if (stagedFiles === null) {
174
+ gitUnavailable = true;
175
+ opts.onWarn?.("ultra11y: --staged requested but git is unavailable here — falling back to a full scan.");
176
+ files = expandInputs(inputs, opts);
177
+ } else {
178
+ const filter = makeFilter(opts);
179
+ const inScope = inScopeMatcher(inputs);
180
+ // No existsSync: a staged-added file may be edited/removed on disk, yet its index
181
+ // blob is exactly what the commit would record — audit that.
182
+ const scoped = stagedFiles.filter((f) => filter(f) && (!inScope || inScope(f)));
183
+ staged = stagedContents(scoped);
184
+ files = [...staged.keys()];
185
+ }
186
+ } else if (changedMode) {
187
+ const changed = gitChangedFiles(opts.since);
188
+ if (changed === null) {
189
+ gitUnavailable = true;
190
+ opts.onWarn?.("ultra11y: --changed requested but git is unavailable here — falling back to a full scan.");
191
+ files = expandInputs(inputs, opts);
192
+ } else {
193
+ const filter = makeFilter(opts);
194
+ const inScope = inScopeMatcher(inputs);
195
+ files = changed.filter((f) => existsSync(f) && filter(f) && (!inScope || inScope(f)));
196
+ }
197
+ } else {
198
+ files = expandInputs(inputs, opts);
199
+ }
200
+
201
+ files = sortByPriorityThenPath([...new Set(files)]);
202
+ return { files, changedMode, gitUnavailable, ...(staged ? { stagedContent: staged } : {}) };
203
+ }
@@ -0,0 +1,76 @@
1
+ // Maps each rule to how `fix` may repair it, keyed by ruleId (NOT a field on the
2
+ // Rule objects — keeps the registry's 36-rule invariant + rule purity intact, and
3
+ // centralises the risky edit logic here). Three classes:
4
+ // - auto: deterministic, no judgment — safe to write unattended.
5
+ // - placeholder: insert a syntactically-valid attribute with a TODO sentinel the
6
+ // agent fills with a real, context-appropriate value.
7
+ // - proposal: judgment only — the engine never invents the content; the agent
8
+ // writes it (alt text relevance, link purpose, table structure…).
9
+ import { type Edit, setAttr, removeAttr, getAttr, openTag } from "./edits.js";
10
+
11
+ export type Fixability = "auto" | "placeholder" | "proposal";
12
+
13
+ export const PLACEHOLDER = "TODO"; // sentinel the agent replaces with a real value
14
+ // `lang` needs a VALID BCP-47 value or the lang-invalid rule fires (swapping one NC
15
+ // for another). "und" = ISO 639-2 "undetermined": valid, and a clear fill-me signal.
16
+ export const LANG_PLACEHOLDER = "und";
17
+
18
+ export interface Codemod {
19
+ fixability: Fixability;
20
+ build?: (source: string, start: number) => Edit[];
21
+ // Safe to apply to a real JSX/TSX AST (kind === "jsx-ast")? True only when the
22
+ // edit removes an attribute by range or inserts an attribute whose HTML spelling
23
+ // is also a valid React DOM prop (alt/lang/title/aria-label/role). A codemod that
24
+ // REWRITES an existing attribute is unsafe in JSX (it would lowercase the React
25
+ // spelling, e.g. tabIndex={5} → tabindex="0"), so it stays off for JSX in v1.
26
+ jsxSafe?: boolean;
27
+ }
28
+
29
+ const one =
30
+ (fn: (s: string, start: number) => Edit | null) =>
31
+ (s: string, start: number): Edit[] => {
32
+ const e = fn(s, start);
33
+ return e ? [e] : [];
34
+ };
35
+
36
+ // Re-enable zoom: drop user-scalable=no/0 and any maximum-scale that blocks 200%.
37
+ function viewportFix(source: string, start: number): Edit[] {
38
+ const content = getAttr(source, start, "content");
39
+ if (content === null) return [];
40
+ const kept = content
41
+ .split(/[,;]/)
42
+ .map((p) => p.trim())
43
+ .filter(Boolean)
44
+ .filter((p) => {
45
+ const [k, v] = p.split("=").map((x) => x.trim().toLowerCase());
46
+ if (k === "user-scalable") return false;
47
+ if (k === "maximum-scale") return Number(v) >= 2;
48
+ return true;
49
+ });
50
+ const e = setAttr(source, start, "content", kept.join(", "));
51
+ return e ? [e] : [];
52
+ }
53
+
54
+ // Native <img>/<area> take alt; a role="img" on something else takes aria-label.
55
+ function altFix(source: string, start: number): Edit[] {
56
+ const tag = openTag(source, start)?.tagName;
57
+ const name = tag === "img" || tag === "area" ? "alt" : "aria-label";
58
+ const e = setAttr(source, start, name, PLACEHOLDER);
59
+ return e ? [e] : [];
60
+ }
61
+
62
+ export const CODEMODS: Record<string, Codemod> = {
63
+ // auto
64
+ "positive-tabindex": { fixability: "auto", build: one((s, st) => setAttr(s, st, "tabindex", "0")) },
65
+ "redundant-aria": { fixability: "auto", jsxSafe: true, build: one((s, st) => removeAttr(s, st, "role")) },
66
+ "meta-viewport-zoom-block": { fixability: "auto", build: viewportFix },
67
+ // placeholder (inserts a valid attribute; alt/lang/title/aria-label are valid React DOM props)
68
+ "iframe-title-missing": { fixability: "placeholder", jsxSafe: true, build: one((s, st) => setAttr(s, st, "title", PLACEHOLDER)) },
69
+ "html-lang-missing": { fixability: "placeholder", jsxSafe: true, build: one((s, st) => setAttr(s, st, "lang", LANG_PLACEHOLDER)) },
70
+ "img-alt-missing": { fixability: "placeholder", jsxSafe: true, build: altFix },
71
+ "control-label-missing": { fixability: "placeholder", jsxSafe: true, build: one((s, st) => setAttr(s, st, "aria-label", PLACEHOLDER)) },
72
+ };
73
+
74
+ export function fixabilityOf(ruleId: string): Fixability {
75
+ return CODEMODS[ruleId]?.fixability ?? "proposal";
76
+ }
@@ -0,0 +1,64 @@
1
+ // A tiny, zero-dependency unified-diff renderer for `fix --dry-run` previews.
2
+ // LCS over lines with context collapsing; guarded against pathological inputs
3
+ // (minified bundles) so it never blows up memory on a giant single line.
4
+
5
+ interface Op {
6
+ t: " " | "-" | "+";
7
+ line: string;
8
+ }
9
+
10
+ export function unifiedDiff(file: string, before: string, after: string, ctx = 2): string {
11
+ if (before === after) return "";
12
+ const a = before.split("\n");
13
+ const b = after.split("\n");
14
+ const header = `--- a/${file}\n+++ b/${file}`;
15
+ if (a.length > 4000 || b.length > 4000) return `${header}\n@@ diff omitted (large file): ${a.length} → ${b.length} lines @@`;
16
+
17
+ const n = a.length;
18
+ const m = b.length;
19
+ const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
20
+ for (let i = n - 1; i >= 0; i--) {
21
+ for (let j = m - 1; j >= 0; j--) {
22
+ dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
23
+ }
24
+ }
25
+
26
+ const ops: Op[] = [];
27
+ let i = 0;
28
+ let j = 0;
29
+ while (i < n && j < m) {
30
+ if (a[i] === b[j]) {
31
+ ops.push({ t: " ", line: a[i]! });
32
+ i++;
33
+ j++;
34
+ } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
35
+ ops.push({ t: "-", line: a[i]! });
36
+ i++;
37
+ } else {
38
+ ops.push({ t: "+", line: b[j]! });
39
+ j++;
40
+ }
41
+ }
42
+ while (i < n) ops.push({ t: "-", line: a[i++]! });
43
+ while (j < m) ops.push({ t: "+", line: b[j++]! });
44
+
45
+ const show = new Array<boolean>(ops.length).fill(false);
46
+ for (let k = 0; k < ops.length; k++) {
47
+ if (ops[k]!.t !== " ") for (let d = -ctx; d <= ctx; d++) if (k + d >= 0 && k + d < ops.length) show[k + d] = true;
48
+ }
49
+
50
+ const out = [header];
51
+ let k = 0;
52
+ while (k < ops.length) {
53
+ if (!show[k]) {
54
+ k++;
55
+ continue;
56
+ }
57
+ let e = k;
58
+ while (e < ops.length && show[e]) e++;
59
+ out.push(`@@ ${k + 1},${e - k} @@`);
60
+ for (let x = k; x < e; x++) out.push(`${ops[x]!.t}${ops[x]!.line}`);
61
+ k = e;
62
+ }
63
+ return out.join("\n");
64
+ }
@@ -0,0 +1,126 @@
1
+ // Source-range edit primitives for `fix`. The parser gives us each element's
2
+ // [start, end) byte range (open+close, end EXCLUSIVE) but NO per-attribute or
3
+ // text-node offsets, and `end` points at the CLOSING tag. So codemods never splice
4
+ // blindly on start/end: they re-scan the *opening tag* locally from `start` to find
5
+ // the `>` and any attribute span. Edits are applied back-to-front so earlier offsets
6
+ // stay valid; overlapping edits on the same element are dropped, not mis-applied.
7
+
8
+ export interface Edit {
9
+ start: number;
10
+ end: number;
11
+ replacement: string;
12
+ }
13
+
14
+ interface OpenTag {
15
+ tagName: string;
16
+ gt: number; // index of the '>' that closes the opening tag
17
+ selfClose: boolean; // '/>' form
18
+ }
19
+
20
+ /** Locate the opening tag starting at `start` (must point at '<'), scanning to the
21
+ * first '>' that is not inside a quoted attribute value. */
22
+ export function openTag(source: string, start: number): OpenTag | null {
23
+ if (source[start] !== "<") return null;
24
+ let i = start + 1;
25
+ let name = "";
26
+ while (i < source.length && /[A-Za-z0-9:_-]/.test(source[i]!)) {
27
+ name += source[i]!;
28
+ i++;
29
+ }
30
+ if (!name) return null;
31
+ let quote: string | null = null;
32
+ for (; i < source.length; i++) {
33
+ const ch = source[i]!;
34
+ if (quote) {
35
+ if (ch === quote) quote = null;
36
+ } else if (ch === '"' || ch === "'") {
37
+ quote = ch;
38
+ } else if (ch === ">") {
39
+ return { tagName: name.toLowerCase(), gt: i, selfClose: source[i - 1] === "/" };
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ interface AttrSpan {
46
+ attrStart: number; // includes the leading whitespace
47
+ attrEnd: number;
48
+ value: string | null; // unquoted value, if present
49
+ }
50
+
51
+ /** Find a whole attribute (name + optional value) inside the opening tag. */
52
+ export function findAttr(source: string, start: number, gt: number, name: string): AttrSpan | null {
53
+ const seg = source.slice(start, gt);
54
+ // The negative lookahead stops `alt` from matching inside `alt_backup`/`data-alt`:
55
+ // the name must be a whole attribute token (next char is not name-continuing).
56
+ const re = new RegExp(`(\\s+)(${name})(?![A-Za-z0-9_:-])(\\s*=\\s*("[^"]*"|'[^']*'|[^\\s>]+))?`, "i");
57
+ const m = re.exec(seg);
58
+ if (!m) return null;
59
+ const attrStart = start + m.index;
60
+ const attrEnd = attrStart + m[0].length;
61
+ let value: string | null = null;
62
+ if (m[4]) {
63
+ const raw = m[4];
64
+ value = raw.startsWith('"') || raw.startsWith("'") ? raw.slice(1, -1) : raw;
65
+ }
66
+ return { attrStart, attrEnd, value };
67
+ }
68
+
69
+ export function getAttr(source: string, start: number, name: string): string | null {
70
+ const ot = openTag(source, start);
71
+ if (!ot) return null;
72
+ return findAttr(source, start, ot.gt, name)?.value ?? null;
73
+ }
74
+
75
+ /** Set (rewrite if present, else insert before '>') an attribute on the element
76
+ * whose opening tag begins at `start`. */
77
+ export function setAttr(source: string, start: number, name: string, value: string): Edit | null {
78
+ const ot = openTag(source, start);
79
+ if (!ot) return null;
80
+ const existing = findAttr(source, start, ot.gt, name);
81
+ if (existing) return { start: existing.attrStart, end: existing.attrEnd, replacement: ` ${name}="${value}"` };
82
+ const insertAt = ot.selfClose ? ot.gt - 1 : ot.gt;
83
+ return { start: insertAt, end: insertAt, replacement: ` ${name}="${value}"` };
84
+ }
85
+
86
+ /** Remove an attribute (and its leading whitespace) from the element at `start`. */
87
+ export function removeAttr(source: string, start: number, name: string): Edit | null {
88
+ const ot = openTag(source, start);
89
+ if (!ot) return null;
90
+ const span = findAttr(source, start, ot.gt, name);
91
+ if (!span) return null;
92
+ return { start: span.attrStart, end: span.attrEnd, replacement: "" };
93
+ }
94
+
95
+ export interface ApplyResult {
96
+ output: string;
97
+ applied: number;
98
+ conflicts: number;
99
+ }
100
+
101
+ /** Apply edits back-to-front; drop any edit that overlaps an already-applied
102
+ * (higher-offset) one so we never corrupt the source. */
103
+ export function applyEdits(source: string, edits: Edit[]): ApplyResult {
104
+ const sorted = [...edits].sort((a, b) => b.start - a.start || b.end - a.end);
105
+ let out = source;
106
+ let lastStart = Infinity;
107
+ const insertedAt = new Set<number>();
108
+ let applied = 0;
109
+ let conflicts = 0;
110
+ for (const e of sorted) {
111
+ // Ranges are half-open [start, end): `>` allows ADJACENT edits (one ending where
112
+ // the next begins) but rejects true overlap into the already-applied region.
113
+ // Zero-width insertions don't trip that test, so a second insertion at the same
114
+ // point is caught separately.
115
+ const isInsertion = e.start === e.end;
116
+ if (e.end > lastStart || (isInsertion && insertedAt.has(e.start))) {
117
+ conflicts++;
118
+ continue;
119
+ }
120
+ out = out.slice(0, e.start) + e.replacement + out.slice(e.end);
121
+ lastStart = e.start;
122
+ if (isInsertion) insertedAt.add(e.start);
123
+ applied++;
124
+ }
125
+ return { output: out, applied, conflicts };
126
+ }
package/src/fix.ts ADDED
@@ -0,0 +1,215 @@
1
+ // `fix` — put the fixes in place. Hybrid by design (anti-hallucination first):
2
+ // • auto codemods are applied deterministically;
3
+ // • placeholders insert a valid attribute with a TODO sentinel the agent fills;
4
+ // • proposals are judgment-only and never auto-written.
5
+ // --dry-run (default) prints a unified diff; --write applies, but ONLY after a
6
+ // re-audit proves no NEW non-conformity was introduced (regression gate). JSX/TSX
7
+ // parsed to a real AST is editable (jsxSafe codemods only); the lossy JSX fallback
8
+ // stays proposal-only (its offsets index the transformed string, not the file).
9
+ import { writeFileSync } from "node:fs";
10
+ import type { Finding, Lang } from "./types.js";
11
+ import { discover, hasUnstagedChanges, gitAdd } from "./discover.js";
12
+ import { parseSource } from "./parse/source.js";
13
+ import { runRules } from "./rules/registry.js";
14
+ import { readText } from "./util.js";
15
+ import { applyEdits, type Edit } from "./fix/edits.js";
16
+ import { CODEMODS, fixabilityOf, type Fixability } from "./fix/codemods.js";
17
+ import { unifiedDiff } from "./fix/diff.js";
18
+
19
+ export interface FixInput {
20
+ inputs: string[];
21
+ stdin?: string;
22
+ forceJsx?: boolean;
23
+ include?: string[];
24
+ exclude?: string[];
25
+ ext?: string[];
26
+ changed?: boolean;
27
+ since?: string;
28
+ staged?: boolean; // fix exactly the staged index snapshot; re-stage written files
29
+ safe?: boolean; // apply only genuinely-automatic codemods (skip placeholder/proposal)
30
+ only?: string[]; // limit to these ruleIds
31
+ write?: boolean; // default false → dry-run
32
+ noDefaultExcludes?: boolean; // also fix test/spec/story/__tests__ markup
33
+ onWarn?: (m: string) => void;
34
+ }
35
+
36
+ export interface FixItem {
37
+ ruleId: string;
38
+ criteriaId: string;
39
+ line: number;
40
+ selectorHint: string;
41
+ fixability: Fixability;
42
+ }
43
+
44
+ export interface FileFix {
45
+ file: string;
46
+ lossy: boolean;
47
+ items: FixItem[];
48
+ diff: string;
49
+ applied: number; // edits applied (auto + placeholder)
50
+ written: boolean;
51
+ regression: boolean; // gate tripped → not written
52
+ restaged?: boolean; // staged mode: written fix re-staged (git add)
53
+ skippedPartialStage?: boolean; // staged mode: had unstaged edits → not written (unsafe to re-stage)
54
+ }
55
+
56
+ export interface FixResult {
57
+ files: FileFix[];
58
+ totals: {
59
+ auto: number;
60
+ placeholder: number;
61
+ proposal: number;
62
+ filesWritten: number;
63
+ regressions: number;
64
+ filesRestaged: number;
65
+ partialStageSkipped: number;
66
+ };
67
+ }
68
+
69
+ function itemOf(f: Finding): FixItem {
70
+ return { ruleId: f.ruleId, criteriaId: f.criteriaId, line: f.line, selectorHint: f.selectorHint, fixability: fixabilityOf(f.ruleId) };
71
+ }
72
+
73
+ function fixOne(file: string, source: string, opts: FixInput, canWrite = true): FileFix {
74
+ const doc = parseSource(source, file, { forceJsx: opts.forceJsx });
75
+ // Rendered captures are GENERATED output (serialized DOM). A codemod on them is
76
+ // meaningless and would be clobbered on the next test run — and in --staged mode we
77
+ // must never rewrite/re-stage a capture. Skip entirely (no items, no edits).
78
+ if (doc.capture) return { file, lossy: doc.lossy, items: [], diff: "", applied: 0, written: false, regression: false };
79
+ const findings = runRules(doc);
80
+ const items = findings.map(itemOf);
81
+
82
+ const edits: Edit[] = [];
83
+ // Real-AST docs (HTML or jsx-ast) carry accurate file offsets, so codemods edit
84
+ // by range. On jsx-ast we apply only codemods marked jsxSafe (attribute removal /
85
+ // insertion of React-valid props); name-rewriting codemods stay off so we never
86
+ // turn tabIndex={5} into tabindex="0". The lossy fallback (jsx-lossy) edits nothing.
87
+ const isJsxAst = doc.kind === "jsx-ast";
88
+ if (!doc.lossy) {
89
+ for (const f of findings) {
90
+ if (opts.only && !opts.only.includes(f.ruleId)) continue;
91
+ // --safe: apply only genuinely-automatic codemods. Placeholder inserts (alt="TODO",
92
+ // lang="und", title="TODO") mechanically clear a finding with a stub that still
93
+ // needs a human/AI value — so skip them here, leaving those findings for the gate.
94
+ if (opts.safe && fixabilityOf(f.ruleId) !== "auto") continue;
95
+ const cm = CODEMODS[f.ruleId];
96
+ if (!cm?.build || f.sourceStart === undefined) continue;
97
+ if (isJsxAst && !cm.jsxSafe) continue;
98
+ edits.push(...cm.build(source, f.sourceStart));
99
+ }
100
+ } else if (opts.write && findings.some((f) => CODEMODS[f.ruleId]?.build)) {
101
+ opts.onWarn?.(`ultra11y fix: ${file} is JSX/TSX (lossy parse fallback) — fixes are proposal-only here; edit the source by hand.`);
102
+ }
103
+
104
+ let diff = "";
105
+ let applied = 0;
106
+ let written = false;
107
+ let regression = false;
108
+ let skippedPartialStage = false;
109
+
110
+ if (edits.length) {
111
+ const { output, applied: n } = applyEdits(source, edits);
112
+ applied = n;
113
+ diff = unifiedDiff(file, source, output);
114
+ // regression gate: re-audit the fixed text; a fix must never add an NC — not
115
+ // just in total count, but also no NEW kind of finding (catches one-NC-for-another
116
+ // swaps, e.g. a bad placeholder value that trips a different rule).
117
+ const after = runRules(parseSource(output, file, { forceJsx: opts.forceJsx }));
118
+ const beforeKinds = new Set(findings.map((f) => f.ruleId));
119
+ regression = after.length > findings.length || after.some((f) => !beforeKinds.has(f.ruleId));
120
+ if (opts.write && !regression && file !== "<stdin>") {
121
+ if (canWrite) {
122
+ writeFileSync(file, output);
123
+ written = true;
124
+ } else {
125
+ // Staged mode + unstaged edits: writing index-derived output over the working
126
+ // tree (and re-staging it) would silently stage those edits. Leave it to a human.
127
+ skippedPartialStage = true;
128
+ opts.onWarn?.(`ultra11y fix: ${file} has unstaged edits — not auto-fixed/re-staged; fix and stage it manually.`);
129
+ }
130
+ }
131
+ if (regression) opts.onWarn?.(`ultra11y fix: ${file} not written — fix would introduce a new non-conformity (regression gate).`);
132
+ }
133
+
134
+ return { file, lossy: doc.lossy, items, diff, applied, written, regression, ...(skippedPartialStage ? { skippedPartialStage } : {}) };
135
+ }
136
+
137
+ export function runFix(opts: FixInput): FixResult {
138
+ const { files, gitUnavailable, stagedContent } = discover(opts.inputs, {
139
+ include: opts.include,
140
+ exclude: opts.exclude,
141
+ ext: opts.ext,
142
+ changed: opts.changed,
143
+ since: opts.since,
144
+ staged: opts.staged,
145
+ noDefaultExcludes: opts.noDefaultExcludes,
146
+ onWarn: opts.onWarn,
147
+ });
148
+ const useStaged = opts.staged === true && !gitUnavailable;
149
+
150
+ const out: FileFix[] = [];
151
+ for (const file of files) {
152
+ let src: string;
153
+ if (useStaged) {
154
+ const c = stagedContent?.get(file);
155
+ if (c === undefined) continue;
156
+ src = c;
157
+ } else {
158
+ try {
159
+ src = readText(file);
160
+ } catch {
161
+ continue;
162
+ }
163
+ }
164
+ // Only auto-fix + re-stage files whose working tree already matches the index;
165
+ // a partially-staged file is left untouched (its findings keep the gate blocking).
166
+ const canWrite = !(useStaged && hasUnstagedChanges(file));
167
+ const ff = fixOne(file, src, opts, canWrite);
168
+ if (useStaged && ff.written) {
169
+ gitAdd(file);
170
+ ff.restaged = true;
171
+ }
172
+ out.push(ff);
173
+ }
174
+ if (opts.inputs.includes("-") && opts.stdin !== undefined) {
175
+ out.push(fixOne("<stdin>", opts.stdin, { ...opts, write: false }));
176
+ }
177
+
178
+ const totals = { auto: 0, placeholder: 0, proposal: 0, filesWritten: 0, regressions: 0, filesRestaged: 0, partialStageSkipped: 0 };
179
+ for (const ff of out) {
180
+ for (const it of ff.items) totals[it.fixability]++;
181
+ if (ff.written) totals.filesWritten++;
182
+ if (ff.regression) totals.regressions++;
183
+ if (ff.restaged) totals.filesRestaged++;
184
+ if (ff.skippedPartialStage) totals.partialStageSkipped++;
185
+ }
186
+ return { files: out, totals };
187
+ }
188
+
189
+ const FIX_LABEL: Record<Fixability, { fr: string; en: string }> = {
190
+ auto: { fr: "auto", en: "auto" },
191
+ placeholder: { fr: "à compléter", en: "fill-in" },
192
+ proposal: { fr: "jugement", en: "judgment" },
193
+ };
194
+
195
+ /** Human summary for the CLI. */
196
+ export function fixSummary(r: FixResult, lang: Lang = "fr", write = false): string {
197
+ const out: string[] = [];
198
+ const t = r.totals;
199
+ const head =
200
+ lang === "fr"
201
+ ? `${write ? "Corrections appliquées" : "Corrections proposées (dry-run)"} : ${t.auto} auto, ${t.placeholder} à compléter, ${t.proposal} jugement · ${t.filesWritten} fichier(s) écrit(s)${t.filesRestaged ? `, ${t.filesRestaged} re-stagé(s)` : ""}${t.regressions ? `, ${t.regressions} bloqué(s) par le garde anti-régression` : ""}${t.partialStageSkipped ? `, ${t.partialStageSkipped} ignoré(s) (modifs non-stagées)` : ""}.`
202
+ : `${write ? "Fixes applied" : "Proposed fixes (dry-run)"}: ${t.auto} auto, ${t.placeholder} fill-in, ${t.proposal} judgment · ${t.filesWritten} file(s) written${t.filesRestaged ? `, ${t.filesRestaged} re-staged` : ""}${t.regressions ? `, ${t.regressions} blocked by the regression gate` : ""}${t.partialStageSkipped ? `, ${t.partialStageSkipped} skipped (unstaged edits)` : ""}.`;
203
+ out.push(head, "");
204
+ for (const ff of r.files) {
205
+ const fixable = ff.items.filter((i) => i.fixability !== "proposal");
206
+ if (!fixable.length && !ff.items.length) continue;
207
+ out.push(`### ${ff.file}${ff.lossy ? " (JSX/TSX — " + (lang === "fr" ? "proposition seule" : "proposal-only") + ")" : ""}`);
208
+ for (const it of ff.items)
209
+ out.push(`- [${FIX_LABEL[it.fixability][lang]}] ${it.ruleId} (WCAG ${it.criteriaId}) — \`${it.selectorHint}\` @ ${ff.file}:${it.line}`);
210
+ if (ff.diff) out.push("", "```diff", ff.diff, "```");
211
+ if (ff.regression) out.push(`> ⚠️ ${lang === "fr" ? "non écrit : régression détectée" : "not written: regression detected"}`);
212
+ out.push("");
213
+ }
214
+ return out.join("\n");
215
+ }