ultra11y 2.26.0 → 2.28.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 (36) hide show
  1. package/.agents/plugins/marketplace.json +12 -0
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +21 -0
  5. package/.opencode/plugins/ultra11y.js +102 -0
  6. package/README.md +34 -16
  7. package/hooks/hooks.json +1 -1
  8. package/hooks/pre-tool-use.mjs +19 -8
  9. package/package.json +6 -2
  10. package/scripts/sync-version.mjs +12 -4
  11. package/scripts/ultra11y.mjs +831 -150
  12. package/scripts/vendor/wcag-2.2-fr.json +1 -1
  13. package/scripts/vendor/wcag-2.2-sc-universe.json +1 -1
  14. package/scripts/verify-skill-bundle.mjs +134 -1
  15. package/skills/review-a11y/SKILL.md +5 -1
  16. package/skills/review-a11y/scripts/ultra11y.mjs +831 -150
  17. package/skills/ultra11y/SKILL.md +2 -1
  18. package/skills/ultra11y/references/automation.md +9 -4
  19. package/skills/ultra11y/references/harnesses.md +117 -0
  20. package/skills/ultra11y/scripts/ultra11y.mjs +831 -150
  21. package/src/cli.ts +116 -17
  22. package/src/data/wcag-universe.json +1 -1
  23. package/src/hook.ts +0 -0
  24. package/src/init.ts +26 -2
  25. package/src/install/agents-md.ts +78 -0
  26. package/src/install/claude-code.ts +61 -0
  27. package/src/install/codex.ts +166 -0
  28. package/src/install/index.ts +204 -0
  29. package/src/install/json-edit.ts +97 -0
  30. package/src/install/opencode.ts +90 -0
  31. package/src/install/paths.ts +132 -0
  32. package/src/install/text-edit.ts +54 -0
  33. package/src/types.ts +1 -1
  34. package/src/vendor/codeindex-engine.d.mts +3 -3
  35. package/src/vendor/codeindex-engine.mjs +23 -3
  36. package/src/vendor/engine.meta.json +5 -5
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "source": "https://www.w3.org/Translations/WCAG22-fr/",
3
- "fetchedAt": "2026-07-31",
3
+ "fetchedAt": "2026-08-01",
4
4
  "principles": {
5
5
  "1": "Perceptible",
6
6
  "2": "Utilisable",
@@ -2,7 +2,7 @@
2
2
  "wcagVersion": "2.2",
3
3
  "source": "https://www.w3.org/TR/WCAG22/",
4
4
  "criteriaSource": "https://github.com/w3c/wcag",
5
- "provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-07-31 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
5
+ "provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-01 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
6
6
  "principles": [
7
7
  {
8
8
  "number": 1,
@@ -12,6 +12,7 @@
12
12
  //
13
13
  // Run by CI and by `pnpm run verify:bundle`. Pure Node, no deps, no network.
14
14
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
15
+ import { spawnSync } from "node:child_process";
15
16
  import { dirname, join } from "node:path";
16
17
  import { fileURLToPath } from "node:url";
17
18
 
@@ -178,9 +179,141 @@ existsSync(join(root, "hooks/pre-tool-use.mjs"))
178
179
  ? ok("hooks/pre-tool-use.mjs present")
179
180
  : bad("missing hooks/pre-tool-use.mjs — hooks.json points at a guard that does not exist");
180
181
 
181
- for (const f of [".claude-plugin", "hooks"])
182
+ for (const f of [".claude-plugin", ".codex-plugin", ".agents", "hooks"])
182
183
  (pkg.files ?? []).includes(f) ? ok(`package.json files[] carries ${f}/`) : bad(`package.json "files" is missing "${f}" — the npm tarball would ship without it`);
183
184
 
185
+ // --- 6. the multi-harness shape -------------------------------------------------------
186
+ // Codex resolves plugin manifests .codex-plugin/ -> .claude-plugin/ -> .cursor-plugin/, so
187
+ // the Claude manifest already works there. .codex-plugin/plugin.json exists to add what
188
+ // Codex alone reads (the `interface` block a marketplace renders), and its validator is
189
+ // STRICTER than Claude Code's: it rejects any key outside this list, so a typo that would
190
+ // merely be ignored on Claude Code makes the plugin uninstallable on Codex. These are the
191
+ // exact allowlists read out of the codex 0.146.0 binary's own validator.
192
+ console.log("codex plugin:");
193
+ const CODEX_KNOWN_KEYS = new Set(["id", "name", "version", "description", "skills", "apps", "mcpServers", "interface", "author", "homepage", "repository", "license", "keywords"]);
194
+ const CODEX_INTERFACE_KEYS = new Set([
195
+ "displayName",
196
+ "shortDescription",
197
+ "longDescription",
198
+ "developerName",
199
+ "category",
200
+ "capabilities",
201
+ "websiteURL",
202
+ "privacyPolicyURL",
203
+ "termsOfServiceURL",
204
+ "brandColor",
205
+ "composerIcon",
206
+ "logo",
207
+ "logoDark",
208
+ "screenshots",
209
+ "defaultPrompt",
210
+ "default_prompt",
211
+ ]);
212
+ const CODEX_INTERFACE_REQUIRED = ["displayName", "shortDescription", "longDescription", "developerName", "category"];
213
+
214
+ const codex = readJson(".codex-plugin/plugin.json");
215
+ if (codex) {
216
+ const unknown = Object.keys(codex).filter((k) => !CODEX_KNOWN_KEYS.has(k));
217
+ unknown.length === 0 ? ok("plugin.json keys are all accepted by Codex's validator") : bad(`.codex-plugin/plugin.json has key(s) Codex rejects: ${unknown.join(", ")} — the plugin would not install`);
218
+
219
+ codex.name === pkg.name ? ok(`plugin.json name "${codex.name}" matches the package`) : bad(`.codex-plugin/plugin.json name "${codex.name}" != package name "${pkg.name}"`);
220
+ codex.version === pkg.version
221
+ ? ok(`plugin.json version ${codex.version} matches the package`)
222
+ : bad(`.codex-plugin/plugin.json version "${codex.version}" != package version "${pkg.version}" — add it to scripts/sync-version.mjs`);
223
+ /^\d+\.\d+\.\d+$/.test(codex.version ?? "") ? ok("plugin.json version is strict semver, as Codex requires") : bad(`.codex-plugin/plugin.json version "${codex.version}" is not strict semver — Codex rejects it`);
224
+
225
+ // One plugin, one description. Two manifests that describe it differently is a bug the
226
+ // moment a user compares the two marketplaces.
227
+ codex.description === plugin?.description ? ok("plugin.json description matches the Claude manifest") : bad(".codex-plugin/plugin.json description differs from .claude-plugin/plugin.json — they describe the same plugin");
228
+
229
+ // Codex pins these to fixed contract paths and rejects anything else.
230
+ codex.skills === undefined || codex.skills.replace(/^\.\//, "").replace(/\/$/, "") === "skills"
231
+ ? ok('plugin.json skills resolves to "skills", as Codex requires')
232
+ : bad(`.codex-plugin/plugin.json skills "${codex.skills}" must resolve to "skills"`);
233
+ if (codex.skills !== undefined)
234
+ existsSync(join(root, "skills")) ? ok("the declared skills/ directory exists") : bad(".codex-plugin/plugin.json declares skills/ but the directory is missing");
235
+
236
+ // `hooks` is deliberately ABSENT: it is not in Codex's allowlist, so declaring it makes
237
+ // the manifest invalid. Codex discovers hooks/hooks.json by convention at the plugin
238
+ // root and sets ${CLAUDE_PLUGIN_ROOT} for it, exactly as Claude Code does.
239
+ codex.hooks === undefined ? ok("plugin.json does not declare hooks (Codex rejects the key; discovery is by convention)") : bad(".codex-plugin/plugin.json declares `hooks`, which Codex's validator rejects — remove it, hooks/hooks.json is found by convention");
240
+
241
+ const iface = codex.interface;
242
+ if (!iface || typeof iface !== "object") bad(".codex-plugin/plugin.json has no `interface` object — Codex requires one");
243
+ else {
244
+ const unknownIface = Object.keys(iface).filter((k) => !CODEX_INTERFACE_KEYS.has(k));
245
+ unknownIface.length === 0 ? ok("plugin.json interface keys are all accepted") : bad(`.codex-plugin/plugin.json interface has key(s) Codex rejects: ${unknownIface.join(", ")}`);
246
+ const missing = CODEX_INTERFACE_REQUIRED.filter((k) => typeof iface[k] !== "string" || !iface[k].trim());
247
+ missing.length === 0 ? ok(`plugin.json interface carries ${CODEX_INTERFACE_REQUIRED.join(", ")}`) : bad(`.codex-plugin/plugin.json interface is missing required field(s): ${missing.join(", ")}`);
248
+ iface.defaultPrompt || iface.default_prompt ? ok("plugin.json interface has a defaultPrompt") : bad(".codex-plugin/plugin.json interface needs `defaultPrompt` (or `default_prompt`) — Codex requires it");
249
+ !iface.brandColor || /^#[0-9A-Fa-f]{6}$/.test(iface.brandColor) ? ok("plugin.json interface brandColor is well formed") : bad(`.codex-plugin/plugin.json interface.brandColor "${iface.brandColor}" must be #RRGGBB`);
250
+ !iface.capabilities || (Array.isArray(iface.capabilities) && iface.capabilities.every((c) => typeof c === "string" && c.trim()))
251
+ ? ok("plugin.json interface capabilities is a list of strings")
252
+ : bad(".codex-plugin/plugin.json interface.capabilities must be an array of non-empty strings");
253
+ }
254
+ }
255
+
256
+ // The file `codex plugin marketplace add <repo>` reads. Versionless by design: it points at
257
+ // the repo, and the plugin manifest inside is the single source of the number.
258
+ const codexMarket = readJson(".agents/plugins/marketplace.json");
259
+ if (codexMarket) {
260
+ codexMarket.interface?.displayName ? ok("marketplace.json has interface.displayName") : bad(".agents/plugins/marketplace.json needs an `interface.displayName` object — Codex requires it");
261
+ const entry = Array.isArray(codexMarket.plugins) ? codexMarket.plugins.find((p) => p.name === pkg.name) : undefined;
262
+ entry ? ok(`marketplace.json lists the "${pkg.name}" plugin`) : bad(`.agents/plugins/marketplace.json does not list a plugin named "${pkg.name}"`);
263
+ if (entry) {
264
+ entry.source && typeof entry.source === "object" ? ok("marketplace.json entry has an object source") : bad(".agents/plugins/marketplace.json entry needs an object `source` (e.g. { source: \"url\", url: \"./\" })");
265
+ const inst = entry.policy?.installation;
266
+ !inst || ["NOT_AVAILABLE", "AVAILABLE", "INSTALLED_BY_DEFAULT"].includes(inst) ? ok("marketplace.json installation policy is valid") : bad(`.agents/plugins/marketplace.json installation policy "${inst}" is not one of NOT_AVAILABLE, AVAILABLE, INSTALLED_BY_DEFAULT`);
267
+ const auth = entry.policy?.authentication;
268
+ !auth || ["ON_INSTALL", "ON_USE"].includes(auth) ? ok("marketplace.json authentication policy is valid") : bad(`.agents/plugins/marketplace.json authentication policy "${auth}" is not one of ON_INSTALL, ON_USE`);
269
+ }
270
+ }
271
+
272
+ // --- 7. the OpenCode plugin -----------------------------------------------------------
273
+ // Committed JavaScript that no type-checker covers and that OpenCode loads straight off
274
+ // disk — from ~/.config/opencode/plugin/, where there is no node_modules to resolve a bare
275
+ // import and no build step to catch a syntax error. Both failures are silent: the plugin
276
+ // simply never loads and the gate never fires.
277
+ console.log("opencode plugin:");
278
+ const OC_REL = ".opencode/plugins/ultra11y.js";
279
+ const ocPath = join(root, OC_REL);
280
+ if (!existsSync(ocPath)) bad(`missing ${OC_REL} — OpenCode would have no plugin to load`);
281
+ else {
282
+ const src = readFileSync(ocPath, "utf8");
283
+ src.includes("ULTRA11Y_OPENCODE_PLUGIN") ? ok("plugin carries the ownership marker") : bad(`${OC_REL} has no ULTRA11Y_OPENCODE_PLUGIN marker — the installer would refuse to manage it`);
284
+ src.includes('"tool.execute.before"') ? ok("plugin declares tool.execute.before") : bad(`${OC_REL} declares no "tool.execute.before" — nothing would gate a commit`);
285
+
286
+ const foreign = [...src.matchAll(/^import .*? from ["']([^"']+)["']/gm)].map((m) => m[1]).filter((s) => !s.startsWith("node:"));
287
+ foreign.length === 0 ? ok("plugin imports only node: builtins") : bad(`${OC_REL} imports ${foreign.join(", ")} — it must stay zero-dependency, it is loaded from outside any node_modules`);
288
+
289
+ const checked = spawnSync(process.execPath, ["--check", ocPath], { encoding: "utf8" });
290
+ checked.status === 0 ? ok("plugin parses on this Node") : bad(`${OC_REL} is not valid JavaScript: ${(checked.stderr ?? "").trim().split("\n")[0]}`);
291
+
292
+ const declared = /ULTRA11Y_PLUGIN_VERSION = "([^"]+)"/.exec(src)?.[1];
293
+ declared === pkg.version ? ok(`plugin version ${declared} matches the package`) : bad(`${OC_REL} declares version "${declared}" != package version "${pkg.version}" — add it to scripts/sync-version.mjs`);
294
+
295
+ // `main` is how an opencode.json npm pin resolves the plugin. Pointing it at a path that
296
+ // is not in files[] publishes a package whose pin route is broken.
297
+ pkg.main === OC_REL ? ok(`package.json main points at ${OC_REL}`) : bad(`package.json "main" is "${pkg.main ?? "unset"}" — it must be "${OC_REL}" or the opencode.json npm pin cannot resolve the plugin`);
298
+ existsSync(join(root, pkg.main ?? "")) ? ok("package.json main resolves on disk") : bad(`package.json "main" points at a file that does not exist`);
299
+ (pkg.files ?? []).includes(pkg.main?.split("/")[0]) ? ok("package.json files[] carries main's directory") : bad(`package.json "files" is missing "${pkg.main?.split("/")[0]}" — main would not be published`);
300
+
301
+ // Two prefilters that disagree mean one harness silently reviews less than the other.
302
+ const guardSrc = readFileSync(join(root, "hooks/pre-tool-use.mjs"), "utf8");
303
+ const PREFILTER = String.raw`/\bgit\b|\bgh\b/`;
304
+ src.includes(PREFILTER) && guardSrc.includes(PREFILTER)
305
+ ? ok("the OpenCode plugin and the Claude/Codex guard share one prefilter literal")
306
+ : bad(`${OC_REL} and hooks/pre-tool-use.mjs must both carry the literal ${PREFILTER} — they gate the same commands`);
307
+ }
308
+
309
+ // Codex's skill validator is looser than Claude Code's (it accepts unknown frontmatter
310
+ // keys) but it does hard-fail on these three, so assert them rather than assume.
311
+ for (const name of skillNames) {
312
+ const fm = readFileSync(join(root, "skills", name, "SKILL.md"), "utf8");
313
+ const dmi = /^disable[-_]model[-_]invocation:\s*(\S+)/m.exec(fm)?.[1];
314
+ !dmi || dmi === "false" ? ok(`${name}: disable-model-invocation is unset or false, as Codex requires`) : bad(`skills/${name}/SKILL.md sets disable-model-invocation: ${dmi} — Codex rejects anything but false`);
315
+ }
316
+
184
317
  if (errors.length) {
185
318
  console.error(`\nverify-skill-bundle: ${errors.length} problem(s) — a published skill would not install correctly.`);
186
319
  process.exit(1);
@@ -5,7 +5,7 @@ when_to_use: "Invoke WITHOUT being asked whenever changed front-end code should
5
5
  allowed-tools: Bash(node ${CLAUDE_SKILL_DIR}/scripts/ultra11y.mjs *), Read, Grep, Glob
6
6
  license: MIT
7
7
  metadata:
8
- version: 2.26.0
8
+ version: 2.28.0
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code
@@ -23,6 +23,10 @@ DOM to `scan`. A human is at most optional oversight.
23
23
  `scripts/ultra11y.mjs` lives **next to this SKILL.md** (the engine ships inside the skill;
24
24
  no install, no key). Run it with the reviewed project as the working directory. In the
25
25
  ultra11y repository itself, the repo root's `scripts/ultra11y.mjs` is the same file.
26
+ On Claude Code that path is `${CLAUDE_SKILL_DIR}/scripts/ultra11y.mjs`; on Codex and
27
+ OpenCode it is the skill directory the plugin installed. If neither resolves — a harness
28
+ with no skill system — `npx -y ultra11y <command>` is the same engine. Per-harness deltas:
29
+ `references/harnesses.md` in the `ultra11y` skill.
26
30
 
27
31
  > **Core rules** (shared with `ultra11y`):
28
32
  > 1. **Never invent a non-conformity**: report only what the engine found or what you can