// Maps each rule to how `fix` may repair it, keyed by ruleId (NOT a field on the // Rule objects — keeps the registry's 36-rule invariant + rule purity intact, and // centralises the risky edit logic here). Three classes: // - auto: deterministic, no judgment — safe to write unattended. // - placeholder: insert a syntactically-valid attribute with a TODO sentinel the // agent fills with a real, context-appropriate value. // - proposal: judgment only — the engine never invents the content; the agent // writes it (alt text relevance, link purpose, table structure…). import { type Edit, setAttr, removeAttr, getAttr, openTag } from "./edits.js"; export type Fixability = "auto" | "placeholder" | "proposal"; export const PLACEHOLDER = "TODO"; // sentinel the agent replaces with a real value // `lang` needs a VALID BCP-47 value or the lang-invalid rule fires (swapping one NC // for another). "und" = ISO 639-2 "undetermined": valid, and a clear fill-me signal. export const LANG_PLACEHOLDER = "und"; export interface Codemod { fixability: Fixability; build?: (source: string, start: number) => Edit[]; // Safe to apply to a real JSX/TSX AST (kind === "jsx-ast")? True only when the // edit removes an attribute by range or inserts an attribute whose HTML spelling // is also a valid React DOM prop (alt/lang/title/aria-label/role). A codemod that // REWRITES an existing attribute is unsafe in JSX (it would lowercase the React // spelling, e.g. tabIndex={5} → tabindex="0"), so it stays off for JSX in v1. jsxSafe?: boolean; } const one = (fn: (s: string, start: number) => Edit | null) => (s: string, start: number): Edit[] => { const e = fn(s, start); return e ? [e] : []; }; // Re-enable zoom: drop user-scalable=no/0 and any maximum-scale that blocks 200%. function viewportFix(source: string, start: number): Edit[] { const content = getAttr(source, start, "content"); if (content === null) return []; const kept = content .split(/[,;]/) .map((p) => p.trim()) .filter(Boolean) .filter((p) => { const [k, v] = p.split("=").map((x) => x.trim().toLowerCase()); if (k === "user-scalable") return false; if (k === "maximum-scale") return Number(v) >= 2; return true; }); const e = setAttr(source, start, "content", kept.join(", ")); return e ? [e] : []; } // Native / take alt; a role="img" on something else takes aria-label. function altFix(source: string, start: number): Edit[] { const tag = openTag(source, start)?.tagName; const name = tag === "img" || tag === "area" ? "alt" : "aria-label"; const e = setAttr(source, start, name, PLACEHOLDER); return e ? [e] : []; } export const CODEMODS: Record = { // auto "positive-tabindex": { fixability: "auto", build: one((s, st) => setAttr(s, st, "tabindex", "0")) }, "redundant-aria": { fixability: "auto", jsxSafe: true, build: one((s, st) => removeAttr(s, st, "role")) }, "meta-viewport-zoom-block": { fixability: "auto", build: viewportFix }, // placeholder (inserts a valid attribute; alt/lang/title/aria-label are valid React DOM props) "iframe-title-missing": { fixability: "placeholder", jsxSafe: true, build: one((s, st) => setAttr(s, st, "title", PLACEHOLDER)) }, "html-lang-missing": { fixability: "placeholder", jsxSafe: true, build: one((s, st) => setAttr(s, st, "lang", LANG_PLACEHOLDER)) }, "img-alt-missing": { fixability: "placeholder", jsxSafe: true, build: altFix }, "control-label-missing": { fixability: "placeholder", jsxSafe: true, build: one((s, st) => setAttr(s, st, "aria-label", PLACEHOLDER)) }, }; export function fixabilityOf(ruleId: string): Fixability { return CODEMODS[ruleId]?.fixability ?? "proposal"; }