ultra11y 2.31.3 → 2.32.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.
@@ -10,7 +10,7 @@
10
10
  "name": "ultra11y",
11
11
  "source": "./",
12
12
  "description": "Automatic accessibility review of the code under change before every commit, push and pull request, plus full WCAG 2.2 AA / RGAA audits and conformance deliverables on demand.",
13
- "version": "2.31.3",
13
+ "version": "2.32.0",
14
14
  "author": { "name": "maxgfr" },
15
15
  "homepage": "https://github.com/maxgfr/ultra11y",
16
16
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "2.31.3",
4
+ "version": "2.32.0",
5
5
  "author": { "name": "maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ultra11y",
3
3
  "description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "2.31.3",
4
+ "version": "2.32.0",
5
5
  "author": { "name": "maxgfr", "url": "https://github.com/maxgfr" },
6
6
  "homepage": "https://github.com/maxgfr/ultra11y",
7
7
  "repository": "https://github.com/maxgfr/ultra11y",
@@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url";
27
27
  // Ownership marker: `ultra11y install --opencode` refuses to overwrite a plugin file that
28
28
  // does not carry it, so a user's own ultra11y.js is never clobbered.
29
29
  const ULTRA11Y_OPENCODE_PLUGIN = "ULTRA11Y_OPENCODE_PLUGIN";
30
- const ULTRA11Y_PLUGIN_VERSION = "2.31.3";
30
+ const ULTRA11Y_PLUGIN_VERSION = "2.32.0";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "2.31.3",
3
+ "version": "2.32.0",
4
4
  "description": "Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or author/review accessible markup (native-HTML-first). A skills.sh agent skill: an install-free static engine (bundled, no npm install) plus the agent's judgment, with check/verify gates against hallucinated non-conformities, and pluggable in-repo standards packs (RGAA France first; Section 508 & EN 301 549 next).",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.0",
@@ -6,7 +6,7 @@ import { join as join45, relative as relative5, sep as sep7, dirname as dirname1
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "2.31.3";
9
+ var VERSION = "2.32.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -49698,6 +49698,22 @@ function issueBody(unit, lang, standard = "wcag", format = "audit") {
49698
49698
  }
49699
49699
  return lines.join("\n");
49700
49700
  }
49701
+ function issueLabels(unit, standard = "wcag") {
49702
+ const { tag } = standardTag(standard);
49703
+ return unit.advisory ? ["accessibility", tag, "recommendation", unit.severity] : ["accessibility", tag, unit.severity];
49704
+ }
49705
+ function issueSet(units, lang, standard = "wcag", format = "audit") {
49706
+ const { label } = standardTag(standard);
49707
+ return units.map((u) => ({
49708
+ criteriaId: u.criteriaId,
49709
+ title: issueTitle(u, label),
49710
+ body: issueBody(u, lang, standard, format),
49711
+ labels: issueLabels(u, standard),
49712
+ severity: u.severity,
49713
+ advisory: u.advisory === true,
49714
+ occurrences: u.findings.map((f) => ({ file: f.file, line: f.line, selector: f.selectorHint, message: resolveMessage(f, lang) }))
49715
+ }));
49716
+ }
49701
49717
  function createIssue(title2, body2, labels) {
49702
49718
  const base = ["issue", "create", "--title", title2, "--body-file", "-"];
49703
49719
  try {
@@ -49717,7 +49733,7 @@ function recordFailure(result, reason) {
49717
49733
  if (reason && !result.errors.includes(reason)) result.errors.push(reason);
49718
49734
  }
49719
49735
  function pushIssues(units, lang, standard = "wcag", format = "audit") {
49720
- const { label, tag } = standardTag(standard);
49736
+ const { label } = standardTag(standard);
49721
49737
  const existing = existingIssueTitles();
49722
49738
  const result = { created: 0, skipped: 0, failed: 0, createdTitles: [], errors: [] };
49723
49739
  for (const u of units) {
@@ -49726,8 +49742,7 @@ function pushIssues(units, lang, standard = "wcag", format = "audit") {
49726
49742
  result.skipped++;
49727
49743
  continue;
49728
49744
  }
49729
- const labels = u.advisory ? ["accessibility", tag, "recommendation", u.severity] : ["accessibility", tag, u.severity];
49730
- const r = createIssue(title2, issueBody(u, lang, standard, format), labels);
49745
+ const r = createIssue(title2, issueBody(u, lang, standard, format), issueLabels(u, standard));
49731
49746
  if (r.ok) {
49732
49747
  result.created++;
49733
49748
  result.createdTitles.push(title2);
@@ -51629,6 +51644,18 @@ var adjudication_default = {
51629
51644
 
51630
51645
  // src/adjudicate.ts
51631
51646
  var ADJUDICATE_MAX_EVIDENCE = 30;
51647
+ function adjudicationContract() {
51648
+ return {
51649
+ verdicts: [...VERDICTS],
51650
+ manualReasons: [...MANUAL_REASON_VALUES],
51651
+ requires: {
51652
+ C: "a non-empty justification AND citations[] naming the harvested evidence it cleared (each anchor resolvable and drawn from this criterion's own evidence); a criterion with no harvested evidence cannot be C at all",
51653
+ NA: "a non-empty justification",
51654
+ NC: "at least one groundable finding, each citing a normativeRef that resolves against the active standard",
51655
+ manual: `a reason \u2208 {${MANUAL_REASON_VALUES.join(", ")}}`
51656
+ }
51657
+ };
51658
+ }
51632
51659
  var selectorFor = (el) => {
51633
51660
  const id = el.attribs.id ? `#${el.attribs.id}` : "";
51634
51661
  const cls = el.attribs.class ? `.${el.attribs.class.trim().split(/\s+/)[0]}` : "";
@@ -51830,6 +51857,31 @@ function buildAdjudicationWorklist(audit2, opts = {}) {
51830
51857
  }
51831
51858
  var NC_SEVERITY_DEFAULT = "majeur";
51832
51859
  var MANUAL_REASONS = /* @__PURE__ */ new Set(["needs-rendered-dom", "undecidable"]);
51860
+ var VERDICTS = ["C", "NC", "NA", "manual"];
51861
+ var MANUAL_REASON_VALUES = ["needs-rendered-dom", "undecidable"];
51862
+ function normalizeVerdict2(v) {
51863
+ if (typeof v !== "string") return void 0;
51864
+ const k = v.trim().toLowerCase();
51865
+ return VERDICTS.find((x) => x.toLowerCase() === k);
51866
+ }
51867
+ function normalizeManualReason(r) {
51868
+ if (typeof r !== "string") return void 0;
51869
+ const k = r.trim().toLowerCase();
51870
+ return MANUAL_REASON_VALUES.find((x) => x === k);
51871
+ }
51872
+ function canonicalizeAdjudication(adj) {
51873
+ for (const it of adj.items) {
51874
+ if (it.verdict !== null) {
51875
+ const v = normalizeVerdict2(it.verdict);
51876
+ if (v !== void 0) it.verdict = v;
51877
+ }
51878
+ if (it.reason !== null && it.reason !== void 0) {
51879
+ const r = normalizeManualReason(it.reason);
51880
+ if (r !== void 0) it.reason = r;
51881
+ }
51882
+ }
51883
+ return adj;
51884
+ }
51833
51885
  function normativeRefResolves(ref, standard, itemCriterionId) {
51834
51886
  const r = (ref ?? "").trim();
51835
51887
  if (!r) return false;
@@ -51852,6 +51904,7 @@ function normativeRefResolves(ref, standard, itemCriterionId) {
51852
51904
  }
51853
51905
  function applyAdjudication(audit2, adj, opts = {}) {
51854
51906
  const issues = [];
51907
+ canonicalizeAdjudication(adj);
51855
51908
  const byId2 = new Map(adj.items.map((it) => [it.criteriaId, it]));
51856
51909
  const packMode = !isCore(adj.standard);
51857
51910
  const open = /* @__PURE__ */ new Set();
@@ -51913,9 +51966,9 @@ function applyAdjudication(audit2, adj, opts = {}) {
51913
51966
  }
51914
51967
  } else if (v === "manual") {
51915
51968
  if (!it.reason || !MANUAL_REASONS.has(it.reason))
51916
- issues.push(`criterion ${it.criteriaId}: a manual verdict requires reason \u2208 {needs-rendered-dom, undecidable}`);
51969
+ issues.push(`criterion ${it.criteriaId}: a manual verdict requires reason \u2208 {${MANUAL_REASON_VALUES.join(", ")}}`);
51917
51970
  } else {
51918
- issues.push(`criterion ${it.criteriaId}: unknown verdict "${String(v)}"`);
51971
+ issues.push(`criterion ${it.criteriaId}: unknown verdict "${String(v)}" \u2014 expected one of ${VERDICTS.join(" | ")}`);
51919
51972
  }
51920
51973
  for (const rec of it.recommendations ?? []) groundInputs.push({ file: rec.file, line: rec.line, selector: rec.selector, snippet: rec.snippet });
51921
51974
  }
@@ -52173,6 +52226,7 @@ function writeAdjudication(items, outDir, opts) {
52173
52226
  schemaVersion: SCHEMA_VERSION,
52174
52227
  standard: opts.standard,
52175
52228
  auditDate: opts.auditDate,
52229
+ contract: adjudicationContract(),
52176
52230
  items
52177
52231
  };
52178
52232
  writeFileSync10(todoPath, JSON.stringify(file, null, 2) + "\n");
@@ -57796,6 +57850,9 @@ Options:
57796
57850
  technique + Contexte de reproduction) for a pure-auditor block
57797
57851
  --gh-issues prd: also create one GitHub issue per criterion via the gh CLI (opt-in)
57798
57852
  --gh-single prd: file the whole audit as ONE consolidated GitHub issue (opt-in; wins over --gh-issues)
57853
+ --issues-json prd: write the issue set (one item per criterion: title, body, labels,
57854
+ severity, occurrences) to <out>/issues-<date>.json for a non-GitHub
57855
+ tracker to file \u2014 same items --gh-issues would create, no gh needed
57799
57856
  --scaffold render: write an SSR-snapshot harness (default: ultra11y-render.tsx)
57800
57857
  --setup render: install the zero-touch test-render capture harvester (.ultra11y/capture-setup.mjs) + print the runner wiring
57801
57858
  --coverage render: report rendered-capture coverage (covered vs blind-spot components); with --json emits the coverage object
@@ -58012,6 +58069,7 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
58012
58069
  "no-technical",
58013
58070
  "gh-issues",
58014
58071
  "gh-single",
58072
+ "issues-json",
58015
58073
  "override",
58016
58074
  "local",
58017
58075
  "docker",
@@ -58785,6 +58843,19 @@ async function cmdPrd(p) {
58785
58843
  const paths = writePrd(result, { out: out2, lang, split, format, standard, technical });
58786
58844
  const json = p.flags.json === true;
58787
58845
  if (!json) for (const path of paths) console.log(path);
58846
+ let issuesPath;
58847
+ if (p.flags["issues-json"] === true) {
58848
+ const issueFormat = format === "remediation" ? "remediation" : "audit";
58849
+ const issues = issueSet(prdUnits(result, standard, lang), lang, standard, issueFormat);
58850
+ mkdirSync15(out2, { recursive: true });
58851
+ issuesPath = join45(out2, `issues-${result.date}.json`);
58852
+ writeFileSync17(
58853
+ issuesPath,
58854
+ `${JSON.stringify({ tool: "ultra11y", kind: "issues", schemaVersion: 1, standard, date: result.date, count: issues.length, issues }, null, 2)}
58855
+ `
58856
+ );
58857
+ if (!json) console.log(issuesPath);
58858
+ }
58788
58859
  const ghMode = p.flags["gh-single"] === true ? "single" : p.flags["gh-issues"] === true ? "per-criterion" : null;
58789
58860
  let gh2;
58790
58861
  if (ghMode) {
@@ -58808,7 +58879,8 @@ async function cmdPrd(p) {
58808
58879
  }
58809
58880
  }
58810
58881
  }
58811
- if (json) console.log(JSON.stringify({ paths, units: prdUnits(result, standard, lang), ...gh2 ? { gh: gh2 } : {} }, null, 2));
58882
+ if (json)
58883
+ console.log(JSON.stringify({ paths, ...issuesPath ? { issuesPath } : {}, units: prdUnits(result, standard, lang), ...gh2 ? { gh: gh2 } : {} }, null, 2));
58812
58884
  return gh2 && gh2.failed > 0 ? 1 : 0;
58813
58885
  }
58814
58886
  function depsAt(root) {
@@ -59204,7 +59276,22 @@ function applyAdjudicationFile(p, adj, lang) {
59204
59276
  mkdirSync15(out2, { recursive: true });
59205
59277
  const auditPath = join45(out2, "audit-latest.json");
59206
59278
  writeFileSync17(auditPath, JSON.stringify(r.audit, null, 2) + "\n");
59207
- if (p.flags.json) console.log(JSON.stringify({ ok: true, auditPath, applied: r.applied, stillManual: r.stillManual, grounding: r.grounding }, null, 2));
59279
+ if (p.flags.json)
59280
+ console.log(
59281
+ JSON.stringify(
59282
+ {
59283
+ ok: true,
59284
+ auditPath,
59285
+ applied: r.applied,
59286
+ stillManual: r.stillManual,
59287
+ conformancePct: r.audit.conformancePct,
59288
+ findings: r.audit.findings.length,
59289
+ grounding: r.grounding
59290
+ },
59291
+ null,
59292
+ 2
59293
+ )
59294
+ );
59208
59295
  else
59209
59296
  console.log(
59210
59297
  lang === "fr" ? `\u2713 ${r.applied} crit\xE8re(s) adjug\xE9(s), ${r.stillManual} laiss\xE9(s) en r\xE9siduel \u2192 ${auditPath}` : `\u2713 ${r.applied} criterion(ia) adjudicated, ${r.stillManual} left residual \u2192 ${auditPath}`
@@ -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.31.3
8
+ version: 2.32.0
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code
@@ -6,7 +6,7 @@ import { join as join45, relative as relative5, sep as sep7, dirname as dirname1
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "2.31.3";
9
+ var VERSION = "2.32.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -49698,6 +49698,22 @@ function issueBody(unit, lang, standard = "wcag", format = "audit") {
49698
49698
  }
49699
49699
  return lines.join("\n");
49700
49700
  }
49701
+ function issueLabels(unit, standard = "wcag") {
49702
+ const { tag } = standardTag(standard);
49703
+ return unit.advisory ? ["accessibility", tag, "recommendation", unit.severity] : ["accessibility", tag, unit.severity];
49704
+ }
49705
+ function issueSet(units, lang, standard = "wcag", format = "audit") {
49706
+ const { label } = standardTag(standard);
49707
+ return units.map((u) => ({
49708
+ criteriaId: u.criteriaId,
49709
+ title: issueTitle(u, label),
49710
+ body: issueBody(u, lang, standard, format),
49711
+ labels: issueLabels(u, standard),
49712
+ severity: u.severity,
49713
+ advisory: u.advisory === true,
49714
+ occurrences: u.findings.map((f) => ({ file: f.file, line: f.line, selector: f.selectorHint, message: resolveMessage(f, lang) }))
49715
+ }));
49716
+ }
49701
49717
  function createIssue(title2, body2, labels) {
49702
49718
  const base = ["issue", "create", "--title", title2, "--body-file", "-"];
49703
49719
  try {
@@ -49717,7 +49733,7 @@ function recordFailure(result, reason) {
49717
49733
  if (reason && !result.errors.includes(reason)) result.errors.push(reason);
49718
49734
  }
49719
49735
  function pushIssues(units, lang, standard = "wcag", format = "audit") {
49720
- const { label, tag } = standardTag(standard);
49736
+ const { label } = standardTag(standard);
49721
49737
  const existing = existingIssueTitles();
49722
49738
  const result = { created: 0, skipped: 0, failed: 0, createdTitles: [], errors: [] };
49723
49739
  for (const u of units) {
@@ -49726,8 +49742,7 @@ function pushIssues(units, lang, standard = "wcag", format = "audit") {
49726
49742
  result.skipped++;
49727
49743
  continue;
49728
49744
  }
49729
- const labels = u.advisory ? ["accessibility", tag, "recommendation", u.severity] : ["accessibility", tag, u.severity];
49730
- const r = createIssue(title2, issueBody(u, lang, standard, format), labels);
49745
+ const r = createIssue(title2, issueBody(u, lang, standard, format), issueLabels(u, standard));
49731
49746
  if (r.ok) {
49732
49747
  result.created++;
49733
49748
  result.createdTitles.push(title2);
@@ -51629,6 +51644,18 @@ var adjudication_default = {
51629
51644
 
51630
51645
  // src/adjudicate.ts
51631
51646
  var ADJUDICATE_MAX_EVIDENCE = 30;
51647
+ function adjudicationContract() {
51648
+ return {
51649
+ verdicts: [...VERDICTS],
51650
+ manualReasons: [...MANUAL_REASON_VALUES],
51651
+ requires: {
51652
+ C: "a non-empty justification AND citations[] naming the harvested evidence it cleared (each anchor resolvable and drawn from this criterion's own evidence); a criterion with no harvested evidence cannot be C at all",
51653
+ NA: "a non-empty justification",
51654
+ NC: "at least one groundable finding, each citing a normativeRef that resolves against the active standard",
51655
+ manual: `a reason \u2208 {${MANUAL_REASON_VALUES.join(", ")}}`
51656
+ }
51657
+ };
51658
+ }
51632
51659
  var selectorFor = (el) => {
51633
51660
  const id = el.attribs.id ? `#${el.attribs.id}` : "";
51634
51661
  const cls = el.attribs.class ? `.${el.attribs.class.trim().split(/\s+/)[0]}` : "";
@@ -51830,6 +51857,31 @@ function buildAdjudicationWorklist(audit2, opts = {}) {
51830
51857
  }
51831
51858
  var NC_SEVERITY_DEFAULT = "majeur";
51832
51859
  var MANUAL_REASONS = /* @__PURE__ */ new Set(["needs-rendered-dom", "undecidable"]);
51860
+ var VERDICTS = ["C", "NC", "NA", "manual"];
51861
+ var MANUAL_REASON_VALUES = ["needs-rendered-dom", "undecidable"];
51862
+ function normalizeVerdict2(v) {
51863
+ if (typeof v !== "string") return void 0;
51864
+ const k = v.trim().toLowerCase();
51865
+ return VERDICTS.find((x) => x.toLowerCase() === k);
51866
+ }
51867
+ function normalizeManualReason(r) {
51868
+ if (typeof r !== "string") return void 0;
51869
+ const k = r.trim().toLowerCase();
51870
+ return MANUAL_REASON_VALUES.find((x) => x === k);
51871
+ }
51872
+ function canonicalizeAdjudication(adj) {
51873
+ for (const it of adj.items) {
51874
+ if (it.verdict !== null) {
51875
+ const v = normalizeVerdict2(it.verdict);
51876
+ if (v !== void 0) it.verdict = v;
51877
+ }
51878
+ if (it.reason !== null && it.reason !== void 0) {
51879
+ const r = normalizeManualReason(it.reason);
51880
+ if (r !== void 0) it.reason = r;
51881
+ }
51882
+ }
51883
+ return adj;
51884
+ }
51833
51885
  function normativeRefResolves(ref, standard, itemCriterionId) {
51834
51886
  const r = (ref ?? "").trim();
51835
51887
  if (!r) return false;
@@ -51852,6 +51904,7 @@ function normativeRefResolves(ref, standard, itemCriterionId) {
51852
51904
  }
51853
51905
  function applyAdjudication(audit2, adj, opts = {}) {
51854
51906
  const issues = [];
51907
+ canonicalizeAdjudication(adj);
51855
51908
  const byId2 = new Map(adj.items.map((it) => [it.criteriaId, it]));
51856
51909
  const packMode = !isCore(adj.standard);
51857
51910
  const open = /* @__PURE__ */ new Set();
@@ -51913,9 +51966,9 @@ function applyAdjudication(audit2, adj, opts = {}) {
51913
51966
  }
51914
51967
  } else if (v === "manual") {
51915
51968
  if (!it.reason || !MANUAL_REASONS.has(it.reason))
51916
- issues.push(`criterion ${it.criteriaId}: a manual verdict requires reason \u2208 {needs-rendered-dom, undecidable}`);
51969
+ issues.push(`criterion ${it.criteriaId}: a manual verdict requires reason \u2208 {${MANUAL_REASON_VALUES.join(", ")}}`);
51917
51970
  } else {
51918
- issues.push(`criterion ${it.criteriaId}: unknown verdict "${String(v)}"`);
51971
+ issues.push(`criterion ${it.criteriaId}: unknown verdict "${String(v)}" \u2014 expected one of ${VERDICTS.join(" | ")}`);
51919
51972
  }
51920
51973
  for (const rec of it.recommendations ?? []) groundInputs.push({ file: rec.file, line: rec.line, selector: rec.selector, snippet: rec.snippet });
51921
51974
  }
@@ -52173,6 +52226,7 @@ function writeAdjudication(items, outDir, opts) {
52173
52226
  schemaVersion: SCHEMA_VERSION,
52174
52227
  standard: opts.standard,
52175
52228
  auditDate: opts.auditDate,
52229
+ contract: adjudicationContract(),
52176
52230
  items
52177
52231
  };
52178
52232
  writeFileSync10(todoPath, JSON.stringify(file, null, 2) + "\n");
@@ -57796,6 +57850,9 @@ Options:
57796
57850
  technique + Contexte de reproduction) for a pure-auditor block
57797
57851
  --gh-issues prd: also create one GitHub issue per criterion via the gh CLI (opt-in)
57798
57852
  --gh-single prd: file the whole audit as ONE consolidated GitHub issue (opt-in; wins over --gh-issues)
57853
+ --issues-json prd: write the issue set (one item per criterion: title, body, labels,
57854
+ severity, occurrences) to <out>/issues-<date>.json for a non-GitHub
57855
+ tracker to file \u2014 same items --gh-issues would create, no gh needed
57799
57856
  --scaffold render: write an SSR-snapshot harness (default: ultra11y-render.tsx)
57800
57857
  --setup render: install the zero-touch test-render capture harvester (.ultra11y/capture-setup.mjs) + print the runner wiring
57801
57858
  --coverage render: report rendered-capture coverage (covered vs blind-spot components); with --json emits the coverage object
@@ -58012,6 +58069,7 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
58012
58069
  "no-technical",
58013
58070
  "gh-issues",
58014
58071
  "gh-single",
58072
+ "issues-json",
58015
58073
  "override",
58016
58074
  "local",
58017
58075
  "docker",
@@ -58785,6 +58843,19 @@ async function cmdPrd(p) {
58785
58843
  const paths = writePrd(result, { out: out2, lang, split, format, standard, technical });
58786
58844
  const json = p.flags.json === true;
58787
58845
  if (!json) for (const path of paths) console.log(path);
58846
+ let issuesPath;
58847
+ if (p.flags["issues-json"] === true) {
58848
+ const issueFormat = format === "remediation" ? "remediation" : "audit";
58849
+ const issues = issueSet(prdUnits(result, standard, lang), lang, standard, issueFormat);
58850
+ mkdirSync15(out2, { recursive: true });
58851
+ issuesPath = join45(out2, `issues-${result.date}.json`);
58852
+ writeFileSync17(
58853
+ issuesPath,
58854
+ `${JSON.stringify({ tool: "ultra11y", kind: "issues", schemaVersion: 1, standard, date: result.date, count: issues.length, issues }, null, 2)}
58855
+ `
58856
+ );
58857
+ if (!json) console.log(issuesPath);
58858
+ }
58788
58859
  const ghMode = p.flags["gh-single"] === true ? "single" : p.flags["gh-issues"] === true ? "per-criterion" : null;
58789
58860
  let gh2;
58790
58861
  if (ghMode) {
@@ -58808,7 +58879,8 @@ async function cmdPrd(p) {
58808
58879
  }
58809
58880
  }
58810
58881
  }
58811
- if (json) console.log(JSON.stringify({ paths, units: prdUnits(result, standard, lang), ...gh2 ? { gh: gh2 } : {} }, null, 2));
58882
+ if (json)
58883
+ console.log(JSON.stringify({ paths, ...issuesPath ? { issuesPath } : {}, units: prdUnits(result, standard, lang), ...gh2 ? { gh: gh2 } : {} }, null, 2));
58812
58884
  return gh2 && gh2.failed > 0 ? 1 : 0;
58813
58885
  }
58814
58886
  function depsAt(root) {
@@ -59204,7 +59276,22 @@ function applyAdjudicationFile(p, adj, lang) {
59204
59276
  mkdirSync15(out2, { recursive: true });
59205
59277
  const auditPath = join45(out2, "audit-latest.json");
59206
59278
  writeFileSync17(auditPath, JSON.stringify(r.audit, null, 2) + "\n");
59207
- if (p.flags.json) console.log(JSON.stringify({ ok: true, auditPath, applied: r.applied, stillManual: r.stillManual, grounding: r.grounding }, null, 2));
59279
+ if (p.flags.json)
59280
+ console.log(
59281
+ JSON.stringify(
59282
+ {
59283
+ ok: true,
59284
+ auditPath,
59285
+ applied: r.applied,
59286
+ stillManual: r.stillManual,
59287
+ conformancePct: r.audit.conformancePct,
59288
+ findings: r.audit.findings.length,
59289
+ grounding: r.grounding
59290
+ },
59291
+ null,
59292
+ 2
59293
+ )
59294
+ );
59208
59295
  else
59209
59296
  console.log(
59210
59297
  lang === "fr" ? `\u2713 ${r.applied} crit\xE8re(s) adjug\xE9(s), ${r.stillManual} laiss\xE9(s) en r\xE9siduel \u2192 ${auditPath}` : `\u2713 ${r.applied} criterion(ia) adjudicated, ${r.stillManual} left residual \u2192 ${auditPath}`
@@ -4,7 +4,7 @@ description: "Use to AUDIT existing HTML/CSS/JSX against WCAG 2.2 AA accessibili
4
4
  when_to_use: "Invoke when the user asks for an accessibility AUDIT or a formal deliverable: audit a repo, site or page against WCAG 2.2 AA or a country standard (RGAA, Section 508, EN 301 549); produce a dated conformance report, a criterion grid, a PRD remediation backlog or GitHub issues; author accessible markup from scratch; wire the repo gate (init --hook/--ci) or a rendered-DOM capture pipeline. For a review of the code UNDER CHANGE (diff, branch, PR), use the `review-a11y` skill instead."
5
5
  license: MIT
6
6
  metadata:
7
- version: 2.31.3
7
+ version: 2.32.0
8
8
  ---
9
9
 
10
10
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -90,6 +90,7 @@ Domain knowledge first, then the tooling. Read the one that matches the question
90
90
  | `references/false-positives.md` | Where a finding may still be wrong, and how to refute it |
91
91
  | `references/rendered.md` · `references/automation.md` | Auditing produced HTML, captures, hooks and CI |
92
92
  | `references/harnesses.md` | Where this runs: Claude Code, Codex CLI, OpenCode, and the AGENTS.md fallback |
93
+ | `references/orchestrators.md` | Driving the engine from a workflow engine / non-GitHub CI: pinning, the six-step pipeline, exit codes, `--issues-json` for any tracker |
93
94
  | `references/ci.md` | `--format sarif\|github`: inline PR annotations, code scanning, job summary |
94
95
  | `references/pages.md` | Page snapshots + the per-page criterion grid (`pages`), and its honesty rules |
95
96
  | `references/e2e.md` | `render --e2e`: auditing a page during your Playwright/Cypress run |
@@ -0,0 +1,176 @@
1
+ # Drive the engine from an orchestrator
2
+
3
+ The skill and the GitHub Action assume a person or a coding harness is in the loop. An
4
+ **orchestrator** — an agent workflow engine, a CI job that is not GitHub, a cron runner —
5
+ is different: nothing reads a banner, nothing retries on a hunch, and the only things that
6
+ travel between steps are exit codes and JSON. This page is the contract for that caller.
7
+
8
+ The shape that matters: **the engine finds, the agent adjudicates, the gates refuse.** Keep
9
+ the detection deterministic and there is no model between "a defect exists" and "a defect is
10
+ counted" — which is the whole reason to wire the engine in rather than ask a model to audit.
11
+
12
+ ## Resolving the engine
13
+
14
+ Pin the version. `npx` resolves `latest` otherwise, so what runs can change with no commit
15
+ anywhere.
16
+
17
+ ```sh
18
+ npm_config_update_notifier=false npx -y ultra11y@2.31.2 --version # → 2.31.2, exit 0
19
+ ```
20
+
21
+ - **`npm_config_update_notifier=false` is not optional.** Without it npm prints its
22
+ "New major version available" notice into the output a step is about to parse.
23
+ - **`ULTRA11Y_BIN`** points at an engine you already have (a vendored `ultra11y.mjs`, a
24
+ container layer). The engine is one self-contained file with no runtime dependencies, so
25
+ vendoring is a supported path — that is exactly what the skills and the Action do.
26
+ - **Node ≥ 22.18.** No other system dependency, no API key. (`judge` takes a key; nothing
27
+ else does. `scan` needs a browser tier — see `references/dynamic.md`.)
28
+ - **Verify what you resolved, and fail if it is not what you asked for.** A step that lets a
29
+ missing engine fall through reports zero findings, which reads exactly like a clean audit.
30
+ This is the one failure mode worth being loud about.
31
+
32
+ ## The pipeline
33
+
34
+ Six steps. Four are deterministic; only step 3 involves a model.
35
+
36
+ ```sh
37
+ ENGINE="npx -y ultra11y@2.31.2" # or "node $ULTRA11Y_BIN"
38
+ RUN=/tmp/a11y-run # out-of-tree: see "Where output lands"
39
+
40
+ # 1. FIND — the static engine. No model. This is the finding set.
41
+ $ENGINE audit "<globs>" --graph --out "$RUN" --json --lang en > /dev/null
42
+ # …or, on a pull request, exactly what the branch introduced:
43
+ $ENGINE audit --since "$BASE_REF" --graph --out "$RUN" --json --lang en > /dev/null
44
+
45
+ # 2. WORKLIST — a draft report, then the criteria a static pass cannot decide.
46
+ $ENGINE report --in "$RUN/audit-latest.json" --out "$RUN" --lang en > /dev/null
47
+ $ENGINE verify --report "$RUN"/wcag-*.md --in "$RUN/audit-latest.json" --manual --out "$RUN" --json
48
+
49
+ # 3. ADJUDICATE — hand $RUN/ADJUDICATE.todo.json to the agent; it fills every `verdict`.
50
+ # The file carries its own `contract` (see below) — the agent needs nothing else.
51
+
52
+ # 4. FOLD — fail-closed. Exit 1 with per-criterion reasons if a verdict is unsupported.
53
+ $ENGINE verify --apply "$RUN/adjudication.json" --in "$RUN/audit-latest.json" --out "$RUN" --json
54
+
55
+ # 5. REPORT — deterministic rendering of the adjudicated audit.
56
+ $ENGINE report --in "$RUN/audit-latest.json" --out audits --standard rgaa --lang fr
57
+
58
+ # 6. GATE — integrity. Exit ≠ 0 on an invented criterion or unjustified NA.
59
+ $ENGINE check --report audits/rgaa-*.md --in "$RUN/audit-latest.json" --json
60
+ ```
61
+
62
+ Step 4 is the one to understand: it is a **deterministic gate on the model's output**. A
63
+ verdict with no justification, an NC with no resolvable `normativeRef`, a criterion missing
64
+ from the adjudication — each is refused by name, and the run stops. That is what keeps the
65
+ final report defensible.
66
+
67
+ ## The adjudication contract
68
+
69
+ `ADJUDICATE.todo.json` declares what may be written into it, so the filler never has to read
70
+ ultra11y's source:
71
+
72
+ ```jsonc
73
+ {
74
+ "contract": {
75
+ "verdicts": ["C", "NC", "NA", "manual"],
76
+ "manualReasons": ["needs-rendered-dom", "undecidable"],
77
+ "requires": {
78
+ "C": "a non-empty justification AND citations[] naming the harvested evidence it cleared …",
79
+ "NA": "a non-empty justification",
80
+ "NC": "at least one groundable finding, each citing a normativeRef that resolves against the active standard",
81
+ "manual": "a reason ∈ {needs-rendered-dom, undecidable}"
82
+ }
83
+ },
84
+ "items": [ /* one per criterion the engine could not decide; fill `verdict` */ ]
85
+ }
86
+ ```
87
+
88
+ Note what `C` costs: a clearing verdict is gated exactly like an accusing one. It must
89
+ name in `citations[]` the evidence it cleared, each anchor resolvable and drawn from that
90
+ criterion's own harvested evidence — and a criterion presented with **no** evidence cannot
91
+ be `C` at all. This is what stops "C everywhere with a plausible sentence" from passing.
92
+
93
+ The header is **advisory**. `verify --apply` validates against its own constants, so editing
94
+ the contract in the file widens nothing.
95
+
96
+ Verdicts are matched **case-insensitively** (`na` is `NA`, `MANUAL` is `manual`) — a spelling
97
+ accident is not a disagreement about accessibility. A verdict outside the vocabulary is still
98
+ refused, and the message names the four that are not.
99
+
100
+ `manual` is the honest verdict for anything that genuinely needs a rendered page. It keeps
101
+ the criterion in `residualRisks`, where the report declares it as a residual risk. **Never
102
+ map an undecided criterion to `C`** — that is the one way to turn this pipeline back into
103
+ the thing it replaces.
104
+
105
+ ## Filing findings on a tracker that is not GitHub
106
+
107
+ `--gh-issues` shells out to the `gh` CLI. For any other board, take the same items as JSON:
108
+
109
+ ```sh
110
+ $ENGINE prd --in "$RUN/audit-latest.json" --out "$RUN" --issues-json --lang en
111
+ # → $RUN/issues-<date>.json
112
+ ```
113
+
114
+ ```jsonc
115
+ {
116
+ "tool": "ultra11y", "kind": "issues", "schemaVersion": 1,
117
+ "standard": "wcag", "date": "2026-08-12", "count": 4,
118
+ "issues": [{
119
+ "criteriaId": "1.1.1",
120
+ "title": "[a11y] WCAG 1.1.1 — Non-text Content", // the de-dupe grain: stable across runs
121
+ "body": "…", // the auditor block, rendered
122
+ "labels": ["accessibility", "wcag", "bloquant"],
123
+ "severity": "bloquant",
124
+ "advisory": false, // true ⇒ a good practice, NOT a non-conformity
125
+ "occurrences": [{ "file": "src/Page.tsx", "line": 4, "selector": "img", "message": "…" }]
126
+ }]
127
+ }
128
+ ```
129
+
130
+ One issue per criterion, not per occurrence — a missing `alt` on forty images is one item
131
+ with forty `occurrences`, not forty tickets. De-dupe on `title` and re-running files nothing
132
+ twice. `advisory: true` marks a non-normative recommendation: it must not be triaged as a
133
+ non-conformity, and it never enters the conformance rate.
134
+
135
+ ## Where output lands
136
+
137
+ `--out <dir>` fully contains what a command writes: pointed outside the checkout, an audit
138
+ leaves the target repository byte-identical. That matters when the orchestrator audits a
139
+ repo it does not own — write the run's intermediates out-of-tree and let only the final
140
+ dated report land in the project, if at all.
141
+
142
+ `.ultra11y/pages/` is written only by the page-snapshot commands (`scan`, `snapshot`,
143
+ `dev`); `--root` relocates it. `audit`, `report`, `prd`, `verify` and `check` never touch it.
144
+
145
+ ## Exit codes
146
+
147
+ | | 0 | 1 | 2 |
148
+ |---|---|---|---|
149
+ | `audit` | reported (no `--fail-on`), or clean | findings ≥ `--fail-on` | usage / bad input |
150
+ | `verify --apply` | folded | the adjudication was refused (see `issues[]`) | usage / bad input |
151
+ | `check` | report is sound | integrity failure | report not found / bad input |
152
+ | `prd` | written | `--gh-*` had failures | usage / bad input |
153
+
154
+ Read exit codes from the process, **not** from a pipeline — `cmd --json | head` reports
155
+ `head`'s status, which is how a failing gate reads as a pass.
156
+
157
+ ## What the JSON tells you
158
+
159
+ - `audit --json` → the `AuditResult` (also at `<out>/audit-latest.json`): `findings[]` with
160
+ stable `findingId`, `criteriaId`, `severity`, `file`, `line`; `residualRisks[]`;
161
+ `conformancePct` — the **automatic static pass rate**, not a conformance rate.
162
+ - `verify --apply --json` → `{ ok, auditPath, applied, stillManual, conformancePct, findings, grounding }`
163
+ on success; `{ ok: false, issues[], audit }` when refused.
164
+ - `check --json` → `{ ok, issues[] }`.
165
+
166
+ ## Honesty rules an orchestrator has to carry
167
+
168
+ The engine is explicit about what it did not decide; a wrapper that flattens that away
169
+ produces exactly the false clean bill of health this design exists to prevent.
170
+
171
+ 1. **A missing engine is a failure, never an empty finding set.**
172
+ 2. **Report the residual criteria.** The rendering criteria (contrast, focus, zoom, reflow)
173
+ are undecided until `scan` runs. Say so; do not let a step summary imply full coverage.
174
+ 3. **`conformancePct` is the static pass rate.** Label it as such.
175
+ 4. **Never let a model write the counts.** Take them from the JSON — the numbers exist
176
+ precisely so nothing has to be re-derived by a model that may drop a field.
@@ -6,7 +6,7 @@ import { join as join45, relative as relative5, sep as sep7, dirname as dirname1
6
6
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
7
7
 
8
8
  // src/types.ts
9
- var VERSION = "2.31.3";
9
+ var VERSION = "2.32.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
 
12
12
  // src/audit.ts
@@ -49698,6 +49698,22 @@ function issueBody(unit, lang, standard = "wcag", format = "audit") {
49698
49698
  }
49699
49699
  return lines.join("\n");
49700
49700
  }
49701
+ function issueLabels(unit, standard = "wcag") {
49702
+ const { tag } = standardTag(standard);
49703
+ return unit.advisory ? ["accessibility", tag, "recommendation", unit.severity] : ["accessibility", tag, unit.severity];
49704
+ }
49705
+ function issueSet(units, lang, standard = "wcag", format = "audit") {
49706
+ const { label } = standardTag(standard);
49707
+ return units.map((u) => ({
49708
+ criteriaId: u.criteriaId,
49709
+ title: issueTitle(u, label),
49710
+ body: issueBody(u, lang, standard, format),
49711
+ labels: issueLabels(u, standard),
49712
+ severity: u.severity,
49713
+ advisory: u.advisory === true,
49714
+ occurrences: u.findings.map((f) => ({ file: f.file, line: f.line, selector: f.selectorHint, message: resolveMessage(f, lang) }))
49715
+ }));
49716
+ }
49701
49717
  function createIssue(title2, body2, labels) {
49702
49718
  const base = ["issue", "create", "--title", title2, "--body-file", "-"];
49703
49719
  try {
@@ -49717,7 +49733,7 @@ function recordFailure(result, reason) {
49717
49733
  if (reason && !result.errors.includes(reason)) result.errors.push(reason);
49718
49734
  }
49719
49735
  function pushIssues(units, lang, standard = "wcag", format = "audit") {
49720
- const { label, tag } = standardTag(standard);
49736
+ const { label } = standardTag(standard);
49721
49737
  const existing = existingIssueTitles();
49722
49738
  const result = { created: 0, skipped: 0, failed: 0, createdTitles: [], errors: [] };
49723
49739
  for (const u of units) {
@@ -49726,8 +49742,7 @@ function pushIssues(units, lang, standard = "wcag", format = "audit") {
49726
49742
  result.skipped++;
49727
49743
  continue;
49728
49744
  }
49729
- const labels = u.advisory ? ["accessibility", tag, "recommendation", u.severity] : ["accessibility", tag, u.severity];
49730
- const r = createIssue(title2, issueBody(u, lang, standard, format), labels);
49745
+ const r = createIssue(title2, issueBody(u, lang, standard, format), issueLabels(u, standard));
49731
49746
  if (r.ok) {
49732
49747
  result.created++;
49733
49748
  result.createdTitles.push(title2);
@@ -51629,6 +51644,18 @@ var adjudication_default = {
51629
51644
 
51630
51645
  // src/adjudicate.ts
51631
51646
  var ADJUDICATE_MAX_EVIDENCE = 30;
51647
+ function adjudicationContract() {
51648
+ return {
51649
+ verdicts: [...VERDICTS],
51650
+ manualReasons: [...MANUAL_REASON_VALUES],
51651
+ requires: {
51652
+ C: "a non-empty justification AND citations[] naming the harvested evidence it cleared (each anchor resolvable and drawn from this criterion's own evidence); a criterion with no harvested evidence cannot be C at all",
51653
+ NA: "a non-empty justification",
51654
+ NC: "at least one groundable finding, each citing a normativeRef that resolves against the active standard",
51655
+ manual: `a reason \u2208 {${MANUAL_REASON_VALUES.join(", ")}}`
51656
+ }
51657
+ };
51658
+ }
51632
51659
  var selectorFor = (el) => {
51633
51660
  const id = el.attribs.id ? `#${el.attribs.id}` : "";
51634
51661
  const cls = el.attribs.class ? `.${el.attribs.class.trim().split(/\s+/)[0]}` : "";
@@ -51830,6 +51857,31 @@ function buildAdjudicationWorklist(audit2, opts = {}) {
51830
51857
  }
51831
51858
  var NC_SEVERITY_DEFAULT = "majeur";
51832
51859
  var MANUAL_REASONS = /* @__PURE__ */ new Set(["needs-rendered-dom", "undecidable"]);
51860
+ var VERDICTS = ["C", "NC", "NA", "manual"];
51861
+ var MANUAL_REASON_VALUES = ["needs-rendered-dom", "undecidable"];
51862
+ function normalizeVerdict2(v) {
51863
+ if (typeof v !== "string") return void 0;
51864
+ const k = v.trim().toLowerCase();
51865
+ return VERDICTS.find((x) => x.toLowerCase() === k);
51866
+ }
51867
+ function normalizeManualReason(r) {
51868
+ if (typeof r !== "string") return void 0;
51869
+ const k = r.trim().toLowerCase();
51870
+ return MANUAL_REASON_VALUES.find((x) => x === k);
51871
+ }
51872
+ function canonicalizeAdjudication(adj) {
51873
+ for (const it of adj.items) {
51874
+ if (it.verdict !== null) {
51875
+ const v = normalizeVerdict2(it.verdict);
51876
+ if (v !== void 0) it.verdict = v;
51877
+ }
51878
+ if (it.reason !== null && it.reason !== void 0) {
51879
+ const r = normalizeManualReason(it.reason);
51880
+ if (r !== void 0) it.reason = r;
51881
+ }
51882
+ }
51883
+ return adj;
51884
+ }
51833
51885
  function normativeRefResolves(ref, standard, itemCriterionId) {
51834
51886
  const r = (ref ?? "").trim();
51835
51887
  if (!r) return false;
@@ -51852,6 +51904,7 @@ function normativeRefResolves(ref, standard, itemCriterionId) {
51852
51904
  }
51853
51905
  function applyAdjudication(audit2, adj, opts = {}) {
51854
51906
  const issues = [];
51907
+ canonicalizeAdjudication(adj);
51855
51908
  const byId2 = new Map(adj.items.map((it) => [it.criteriaId, it]));
51856
51909
  const packMode = !isCore(adj.standard);
51857
51910
  const open = /* @__PURE__ */ new Set();
@@ -51913,9 +51966,9 @@ function applyAdjudication(audit2, adj, opts = {}) {
51913
51966
  }
51914
51967
  } else if (v === "manual") {
51915
51968
  if (!it.reason || !MANUAL_REASONS.has(it.reason))
51916
- issues.push(`criterion ${it.criteriaId}: a manual verdict requires reason \u2208 {needs-rendered-dom, undecidable}`);
51969
+ issues.push(`criterion ${it.criteriaId}: a manual verdict requires reason \u2208 {${MANUAL_REASON_VALUES.join(", ")}}`);
51917
51970
  } else {
51918
- issues.push(`criterion ${it.criteriaId}: unknown verdict "${String(v)}"`);
51971
+ issues.push(`criterion ${it.criteriaId}: unknown verdict "${String(v)}" \u2014 expected one of ${VERDICTS.join(" | ")}`);
51919
51972
  }
51920
51973
  for (const rec of it.recommendations ?? []) groundInputs.push({ file: rec.file, line: rec.line, selector: rec.selector, snippet: rec.snippet });
51921
51974
  }
@@ -52173,6 +52226,7 @@ function writeAdjudication(items, outDir, opts) {
52173
52226
  schemaVersion: SCHEMA_VERSION,
52174
52227
  standard: opts.standard,
52175
52228
  auditDate: opts.auditDate,
52229
+ contract: adjudicationContract(),
52176
52230
  items
52177
52231
  };
52178
52232
  writeFileSync10(todoPath, JSON.stringify(file, null, 2) + "\n");
@@ -57796,6 +57850,9 @@ Options:
57796
57850
  technique + Contexte de reproduction) for a pure-auditor block
57797
57851
  --gh-issues prd: also create one GitHub issue per criterion via the gh CLI (opt-in)
57798
57852
  --gh-single prd: file the whole audit as ONE consolidated GitHub issue (opt-in; wins over --gh-issues)
57853
+ --issues-json prd: write the issue set (one item per criterion: title, body, labels,
57854
+ severity, occurrences) to <out>/issues-<date>.json for a non-GitHub
57855
+ tracker to file \u2014 same items --gh-issues would create, no gh needed
57799
57856
  --scaffold render: write an SSR-snapshot harness (default: ultra11y-render.tsx)
57800
57857
  --setup render: install the zero-touch test-render capture harvester (.ultra11y/capture-setup.mjs) + print the runner wiring
57801
57858
  --coverage render: report rendered-capture coverage (covered vs blind-spot components); with --json emits the coverage object
@@ -58012,6 +58069,7 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
58012
58069
  "no-technical",
58013
58070
  "gh-issues",
58014
58071
  "gh-single",
58072
+ "issues-json",
58015
58073
  "override",
58016
58074
  "local",
58017
58075
  "docker",
@@ -58785,6 +58843,19 @@ async function cmdPrd(p) {
58785
58843
  const paths = writePrd(result, { out: out2, lang, split, format, standard, technical });
58786
58844
  const json = p.flags.json === true;
58787
58845
  if (!json) for (const path of paths) console.log(path);
58846
+ let issuesPath;
58847
+ if (p.flags["issues-json"] === true) {
58848
+ const issueFormat = format === "remediation" ? "remediation" : "audit";
58849
+ const issues = issueSet(prdUnits(result, standard, lang), lang, standard, issueFormat);
58850
+ mkdirSync15(out2, { recursive: true });
58851
+ issuesPath = join45(out2, `issues-${result.date}.json`);
58852
+ writeFileSync17(
58853
+ issuesPath,
58854
+ `${JSON.stringify({ tool: "ultra11y", kind: "issues", schemaVersion: 1, standard, date: result.date, count: issues.length, issues }, null, 2)}
58855
+ `
58856
+ );
58857
+ if (!json) console.log(issuesPath);
58858
+ }
58788
58859
  const ghMode = p.flags["gh-single"] === true ? "single" : p.flags["gh-issues"] === true ? "per-criterion" : null;
58789
58860
  let gh2;
58790
58861
  if (ghMode) {
@@ -58808,7 +58879,8 @@ async function cmdPrd(p) {
58808
58879
  }
58809
58880
  }
58810
58881
  }
58811
- if (json) console.log(JSON.stringify({ paths, units: prdUnits(result, standard, lang), ...gh2 ? { gh: gh2 } : {} }, null, 2));
58882
+ if (json)
58883
+ console.log(JSON.stringify({ paths, ...issuesPath ? { issuesPath } : {}, units: prdUnits(result, standard, lang), ...gh2 ? { gh: gh2 } : {} }, null, 2));
58812
58884
  return gh2 && gh2.failed > 0 ? 1 : 0;
58813
58885
  }
58814
58886
  function depsAt(root) {
@@ -59204,7 +59276,22 @@ function applyAdjudicationFile(p, adj, lang) {
59204
59276
  mkdirSync15(out2, { recursive: true });
59205
59277
  const auditPath = join45(out2, "audit-latest.json");
59206
59278
  writeFileSync17(auditPath, JSON.stringify(r.audit, null, 2) + "\n");
59207
- if (p.flags.json) console.log(JSON.stringify({ ok: true, auditPath, applied: r.applied, stillManual: r.stillManual, grounding: r.grounding }, null, 2));
59279
+ if (p.flags.json)
59280
+ console.log(
59281
+ JSON.stringify(
59282
+ {
59283
+ ok: true,
59284
+ auditPath,
59285
+ applied: r.applied,
59286
+ stillManual: r.stillManual,
59287
+ conformancePct: r.audit.conformancePct,
59288
+ findings: r.audit.findings.length,
59289
+ grounding: r.grounding
59290
+ },
59291
+ null,
59292
+ 2
59293
+ )
59294
+ );
59208
59295
  else
59209
59296
  console.log(
59210
59297
  lang === "fr" ? `\u2713 ${r.applied} crit\xE8re(s) adjug\xE9(s), ${r.stillManual} laiss\xE9(s) en r\xE9siduel \u2192 ${auditPath}` : `\u2713 ${r.applied} criterion(ia) adjudicated, ${r.stillManual} left residual \u2192 ${auditPath}`
package/src/adjudicate.ts CHANGED
@@ -102,9 +102,37 @@ export interface AdjudicationFile {
102
102
  schemaVersion: number;
103
103
  standard: StandardId;
104
104
  auditDate: string;
105
+ // The contract, carried BY the worklist. Whoever fills this file — an agent in a coding
106
+ // harness, an orchestrator's tool node, a script — reads the file, not ultra11y's source,
107
+ // so the file has to say what a verdict may be and what each one additionally requires.
108
+ // Advisory to the reader, never read back by `applyAdjudication` (which validates against
109
+ // its own constants), so a hand-edited or stale header can never widen what is accepted.
110
+ contract?: AdjudicationContract;
105
111
  items: AdjudicationItem[];
106
112
  }
107
113
 
114
+ /** What the filler of an adjudication file is allowed to write, stated in the file. */
115
+ export interface AdjudicationContract {
116
+ verdicts: readonly string[];
117
+ manualReasons: readonly string[];
118
+ requires: Record<string, string>;
119
+ }
120
+
121
+ /** The contract as written into every worklist. Derived from the same constants the gate
122
+ * validates against, so the two cannot drift. */
123
+ export function adjudicationContract(): AdjudicationContract {
124
+ return {
125
+ verdicts: [...VERDICTS],
126
+ manualReasons: [...MANUAL_REASON_VALUES],
127
+ requires: {
128
+ C: "a non-empty justification AND citations[] naming the harvested evidence it cleared (each anchor resolvable and drawn from this criterion's own evidence); a criterion with no harvested evidence cannot be C at all",
129
+ NA: "a non-empty justification",
130
+ NC: "at least one groundable finding, each citing a normativeRef that resolves against the active standard",
131
+ manual: `a reason ∈ {${MANUAL_REASON_VALUES.join(", ")}}`,
132
+ },
133
+ };
134
+ }
135
+
108
136
  // ---- evidence harvesters ----
109
137
  // Each harvester answers "for this SC, what did the engine see that the agent needs to
110
138
  // rule?" — bounded, source-anchored, language-neutral. A criterion with no harvester gets
@@ -390,6 +418,53 @@ export interface ApplyAdjudicationResult {
390
418
  const NC_SEVERITY_DEFAULT: Severity = "majeur";
391
419
  const MANUAL_REASONS = new Set(["needs-rendered-dom", "undecidable"]);
392
420
 
421
+ /** The verdicts an adjudication may carry, in the exact spelling the file must use. Exported
422
+ * because it is a CONTRACT, not an implementation detail: the worklist declares it in its own
423
+ * header and the rejection message names it, so whoever fills the file never has to read this
424
+ * source to learn what they are allowed to write. */
425
+ export const VERDICTS = ["C", "NC", "NA", "manual"] as const;
426
+ /** The reasons a still-`manual` verdict may cite — same contract, same reason to export it. */
427
+ export const MANUAL_REASON_VALUES = ["needs-rendered-dom", "undecidable"] as const;
428
+
429
+ /** Canonicalise a verdict written in any case ("na", "Nc", "MANUAL" → "NA", "NC", "manual").
430
+ *
431
+ * Three of the four verdicts are upper-case and the fourth is not, which is exactly the kind
432
+ * of detail an agent filling the worklist gets wrong — and it used to cost the entire run,
433
+ * because `applyAdjudication` fail-closes on an unknown verdict. Case carries no meaning
434
+ * here: "na" can only ever mean NA. What stays rejected is a verdict outside the vocabulary,
435
+ * which is a real disagreement about the contract rather than a spelling accident. Returns
436
+ * undefined when there is no match. */
437
+ export function normalizeVerdict(v: unknown): Exclude<CriterionVerdict, null> | undefined {
438
+ if (typeof v !== "string") return undefined;
439
+ const k = v.trim().toLowerCase();
440
+ return VERDICTS.find((x) => x.toLowerCase() === k);
441
+ }
442
+
443
+ /** The same tolerance for a `manual` verdict's reason. */
444
+ function normalizeManualReason(r: unknown): string | undefined {
445
+ if (typeof r !== "string") return undefined;
446
+ const k = r.trim().toLowerCase();
447
+ return MANUAL_REASON_VALUES.find((x) => x === k);
448
+ }
449
+
450
+ /** Rewrite an adjudication's verdicts and manual reasons into their canonical spelling, in
451
+ * place. Anything unrecognised is left untouched, so the per-item validation below still
452
+ * reports it as the contract violation it is — this normalises spelling, it never invents a
453
+ * decision the file did not carry. */
454
+ export function canonicalizeAdjudication(adj: AdjudicationFile): AdjudicationFile {
455
+ for (const it of adj.items) {
456
+ if (it.verdict !== null) {
457
+ const v = normalizeVerdict(it.verdict);
458
+ if (v !== undefined) it.verdict = v;
459
+ }
460
+ if (it.reason !== null && it.reason !== undefined) {
461
+ const r = normalizeManualReason(it.reason);
462
+ if (r !== undefined) it.reason = r;
463
+ }
464
+ }
465
+ return adj;
466
+ }
467
+
393
468
  /** Does an NC finding's `normativeRef` resolve against the ACTIVE standard?
394
469
  *
395
470
  * Core: a real success-criterion id (reuses `hasSC`).
@@ -433,6 +508,9 @@ function normativeRefResolves(ref: string | undefined, standard: StandardId, ite
433
508
  * a shrunk residual set, and the `adjudicated` marker. */
434
509
  export function applyAdjudication(audit: AuditResult, adj: AdjudicationFile, opts: { cwd?: string } = {}): ApplyAdjudicationResult {
435
510
  const issues: string[] = [];
511
+ // Spelling first, decisions second: "na" is NA, and rejecting the run over the case of a
512
+ // verdict taught the caller nothing about accessibility. Everything below stays fail-closed.
513
+ canonicalizeAdjudication(adj);
436
514
  const byId = new Map(adj.items.map((it) => [it.criteriaId, it]));
437
515
 
438
516
  // Coverage. Under the core that means every residual success criterion; under a pack it
@@ -520,9 +598,12 @@ export function applyAdjudication(audit: AuditResult, adj: AdjudicationFile, opt
520
598
  }
521
599
  } else if (v === "manual") {
522
600
  if (!it.reason || !MANUAL_REASONS.has(it.reason))
523
- issues.push(`criterion ${it.criteriaId}: a manual verdict requires reason ∈ {needs-rendered-dom, undecidable}`);
601
+ issues.push(`criterion ${it.criteriaId}: a manual verdict requires reason ∈ {${MANUAL_REASON_VALUES.join(", ")}}`);
524
602
  } else {
525
- issues.push(`criterion ${it.criteriaId}: unknown verdict "${String(v)}"`);
603
+ // Name the vocabulary in the rejection. The caller is usually a model that has just
604
+ // spent a whole worklist filling this file; "unknown verdict" alone told it nothing
605
+ // about how to be right on the retry.
606
+ issues.push(`criterion ${it.criteriaId}: unknown verdict "${String(v)}" — expected one of ${VERDICTS.join(" | ")}`);
526
607
  }
527
608
  // Recommendations are independent of the verdict (a C criterion may still carry a good
528
609
  // practice) and are grounded exactly like an NC finding — no normativeRef required, as
@@ -912,6 +993,7 @@ export function writeAdjudication(
912
993
  schemaVersion: SCHEMA_VERSION,
913
994
  standard: opts.standard,
914
995
  auditDate: opts.auditDate,
996
+ contract: adjudicationContract(),
915
997
  items,
916
998
  };
917
999
  writeFileSync(todoPath, JSON.stringify(file, null, 2) + "\n");
package/src/cli.ts CHANGED
@@ -6,7 +6,7 @@ import { runAudit } from "./audit.js";
6
6
  import { decide, type PreToolUsePayload } from "./hook.js";
7
7
  import { writeReport, untestedNeedsRendering, partialAuditBanner } from "./report.js";
8
8
  import { writePrd, prdUnits, type PrdFormat } from "./prd.js";
9
- import { ghAvailable, pushIssues, pushPrComment, pushSingleIssue } from "./gh.js";
9
+ import { ghAvailable, issueSet, pushIssues, pushPrComment, pushSingleIssue, type IssueFormat } from "./gh.js";
10
10
  import {
11
11
  detectFrameworks,
12
12
  renderPlan,
@@ -294,6 +294,9 @@ Options:
294
294
  technique + Contexte de reproduction) for a pure-auditor block
295
295
  --gh-issues prd: also create one GitHub issue per criterion via the gh CLI (opt-in)
296
296
  --gh-single prd: file the whole audit as ONE consolidated GitHub issue (opt-in; wins over --gh-issues)
297
+ --issues-json prd: write the issue set (one item per criterion: title, body, labels,
298
+ severity, occurrences) to <out>/issues-<date>.json for a non-GitHub
299
+ tracker to file — same items --gh-issues would create, no gh needed
297
300
  --scaffold render: write an SSR-snapshot harness (default: ultra11y-render.tsx)
298
301
  --setup render: install the zero-touch test-render capture harvester (.ultra11y/capture-setup.mjs) + print the runner wiring
299
302
  --coverage render: report rendered-capture coverage (covered vs blind-spot components); with --json emits the coverage object
@@ -527,6 +530,7 @@ const BOOLEAN_FLAGS = new Set([
527
530
  "no-technical",
528
531
  "gh-issues",
529
532
  "gh-single",
533
+ "issues-json",
530
534
  "override",
531
535
  "local",
532
536
  "docker",
@@ -1557,6 +1561,23 @@ async function cmdPrd(p: ParsedArgs): Promise<number> {
1557
1561
  const json = p.flags.json === true;
1558
1562
  if (!json) for (const path of paths) console.log(path);
1559
1563
 
1564
+ // Tracker-agnostic export: the same de-duplicated items `--gh-issues` files, as JSON, so a
1565
+ // board that is not GitHub can file them itself. Written to a file (not stdout) so it
1566
+ // composes with --json, and so an orchestrator reads one stable path instead of parsing
1567
+ // a payload that also carries the markdown paths.
1568
+ let issuesPath: string | undefined;
1569
+ if (p.flags["issues-json"] === true) {
1570
+ const issueFormat: IssueFormat = format === "remediation" ? "remediation" : "audit";
1571
+ const issues = issueSet(prdUnits(result, standard, lang), lang, standard, issueFormat);
1572
+ mkdirSync(out, { recursive: true });
1573
+ issuesPath = join(out, `issues-${result.date}.json`);
1574
+ writeFileSync(
1575
+ issuesPath,
1576
+ `${JSON.stringify({ tool: "ultra11y", kind: "issues", schemaVersion: 1, standard, date: result.date, count: issues.length, issues }, null, 2)}\n`,
1577
+ );
1578
+ if (!json) console.log(issuesPath);
1579
+ }
1580
+
1560
1581
  // GitHub: always-written markdown above; issues are opt-in + best-effort.
1561
1582
  // --gh-single → one consolidated issue; --gh-issues → one issue per criterion.
1562
1583
  const ghMode: "single" | "per-criterion" | null = p.flags["gh-single"] === true ? "single" : p.flags["gh-issues"] === true ? "per-criterion" : null;
@@ -1586,7 +1607,8 @@ async function cmdPrd(p: ParsedArgs): Promise<number> {
1586
1607
  }
1587
1608
  }
1588
1609
  }
1589
- if (json) console.log(JSON.stringify({ paths, units: prdUnits(result, standard, lang), ...(gh ? { gh } : {}) }, null, 2));
1610
+ if (json)
1611
+ console.log(JSON.stringify({ paths, ...(issuesPath ? { issuesPath } : {}), units: prdUnits(result, standard, lang), ...(gh ? { gh } : {}) }, null, 2));
1590
1612
  // Markdown was written above regardless; but if issue creation was attempted and had
1591
1613
  // any failures, exit non-zero so a CI step / caller sees the GitHub push did not fully
1592
1614
  // succeed (a total failure previously exited 0 and looked green).
@@ -2076,7 +2098,26 @@ function applyAdjudicationFile(p: ParsedArgs, adj: AdjudicationFile, lang: Lang)
2076
2098
  mkdirSync(out, { recursive: true });
2077
2099
  const auditPath = join(out, "audit-latest.json");
2078
2100
  writeFileSync(auditPath, JSON.stringify(r.audit, null, 2) + "\n");
2079
- if (p.flags.json) console.log(JSON.stringify({ ok: true, auditPath, applied: r.applied, stillManual: r.stillManual, grounding: r.grounding }, null, 2));
2101
+ // Carry the numbers the fold produced. The failure payload has always described its own
2102
+ // outcome (`issues`); the success one described only the mechanics, so a caller that gates
2103
+ // on the adjudicated result — a CI step, an orchestrator's tool node — had to re-read and
2104
+ // re-parse the file it had just been handed the path to.
2105
+ if (p.flags.json)
2106
+ console.log(
2107
+ JSON.stringify(
2108
+ {
2109
+ ok: true,
2110
+ auditPath,
2111
+ applied: r.applied,
2112
+ stillManual: r.stillManual,
2113
+ conformancePct: r.audit.conformancePct,
2114
+ findings: r.audit.findings.length,
2115
+ grounding: r.grounding,
2116
+ },
2117
+ null,
2118
+ 2,
2119
+ ),
2120
+ );
2080
2121
  else
2081
2122
  console.log(
2082
2123
  lang === "fr"
package/src/gh.ts CHANGED
@@ -1,8 +1,19 @@
1
- // Optional GitHub issue creation for `prd --gh-issues`. Shells out to the `gh`
2
- // CLI (which handles its own auth) — NO npm dependency, no tokens in ultra11y.
3
- // Everything is best-effort: if gh is absent/unauthenticated, the caller still
4
- // wrote the markdown and we just report that issues were skipped. De-dupes by
5
- // issue title so re-running never creates duplicates.
1
+ // The issue set an audit turns into, plus GitHub as one transport for it.
2
+ //
3
+ // The SET is tracker-agnostic — `issueSet()` renders one de-duplicated item per
4
+ // criterion (title, body, labels, severity) and is what `prd --issues-json`
5
+ // emits, so a board that is not GitHub (a native kanban, GitLab, Jira, Linear)
6
+ // files the same items without shelling out to anything. `pushIssues` /
7
+ // `pushSingleIssue` are the GitHub TRANSPORT for that set: they shell out to the
8
+ // `gh` CLI (which handles its own auth) — NO npm dependency, no tokens in
9
+ // ultra11y. Both paths build their titles, bodies and labels through the same
10
+ // three functions, so the exported JSON and the filed GitHub issue can never
11
+ // drift apart.
12
+ //
13
+ // Everything on the transport side is best-effort: if gh is absent or
14
+ // unauthenticated, the caller still wrote the markdown and we just report that
15
+ // issues were skipped. De-dupes by issue title so re-running never creates
16
+ // duplicates.
6
17
  import { execFileSync } from "node:child_process";
7
18
  import type { Lang, Severity } from "./types.js";
8
19
  import type { PrdUnit } from "./prd.js";
@@ -99,6 +110,43 @@ export function issueBody(unit: PrdUnit, lang: Lang, standard: StandardId = "wca
99
110
  return lines.join("\n");
100
111
  }
101
112
 
113
+ /** The labels an issue carries: `accessibility`, the active standard's tag, its severity —
114
+ * plus `recommendation` when the unit is advisory, so a good practice is never triaged as a
115
+ * non-conformity. Shared by the GitHub transport and the tracker-agnostic export. */
116
+ export function issueLabels(unit: PrdUnit, standard: StandardId = "wcag"): string[] {
117
+ const { tag } = standardTag(standard);
118
+ return unit.advisory ? ["accessibility", tag, "recommendation", unit.severity] : ["accessibility", tag, unit.severity];
119
+ }
120
+
121
+ /** One issue, rendered for whatever tracker is going to file it. */
122
+ export interface TrackerIssue {
123
+ criteriaId: string;
124
+ /** The de-dupe grain: stable across runs, so re-filing is a no-op on any tracker. */
125
+ title: string;
126
+ body: string;
127
+ labels: string[];
128
+ severity: Severity;
129
+ /** A non-normative good practice, not a non-conformity. Never counts toward conformance. */
130
+ advisory: boolean;
131
+ /** Occurrences behind this issue — a tracker that wants inline anchors reads them here. */
132
+ occurrences: { file: string; line: number; selector: string; message: string }[];
133
+ }
134
+
135
+ /** Render the audit's units as a tracker-agnostic issue set — the exact items `pushIssues`
136
+ * would file, minus the transport. This is what `prd --issues-json` emits. */
137
+ export function issueSet(units: PrdUnit[], lang: Lang, standard: StandardId = "wcag", format: IssueFormat = "audit"): TrackerIssue[] {
138
+ const { label } = standardTag(standard);
139
+ return units.map((u) => ({
140
+ criteriaId: u.criteriaId,
141
+ title: issueTitle(u, label),
142
+ body: issueBody(u, lang, standard, format),
143
+ labels: issueLabels(u, standard),
144
+ severity: u.severity,
145
+ advisory: u.advisory === true,
146
+ occurrences: u.findings.map((f) => ({ file: f.file, line: f.line, selector: f.selectorHint, message: resolveMessage(f, lang) })),
147
+ }));
148
+ }
149
+
102
150
  export interface CreateResult {
103
151
  ok: boolean;
104
152
  /** Concise `gh` stderr reason when the creation failed (both attempts). */
@@ -141,7 +189,7 @@ function recordFailure(result: PushResult, reason?: string): void {
141
189
 
142
190
  /** Create a GitHub issue per unit, skipping titles that already exist. */
143
191
  export function pushIssues(units: PrdUnit[], lang: Lang, standard: StandardId = "wcag", format: IssueFormat = "audit"): PushResult {
144
- const { label, tag } = standardTag(standard);
192
+ const { label } = standardTag(standard);
145
193
  const existing = existingIssueTitles();
146
194
  const result: PushResult = { created: 0, skipped: 0, failed: 0, createdTitles: [], errors: [] };
147
195
  for (const u of units) {
@@ -151,9 +199,8 @@ export function pushIssues(units: PrdUnit[], lang: Lang, standard: StandardId =
151
199
  continue;
152
200
  }
153
201
  // Advisory units get the `recommendation` label so they can be triaged apart from
154
- // real non-conformities (which they are not).
155
- const labels = u.advisory ? ["accessibility", tag, "recommendation", u.severity] : ["accessibility", tag, u.severity];
156
- const r = createIssue(title, issueBody(u, lang, standard, format), labels);
202
+ // real non-conformities (which they are not). Shared with the JSON export.
203
+ const r = createIssue(title, issueBody(u, lang, standard, format), issueLabels(u, standard));
157
204
  if (r.ok) {
158
205
  result.created++;
159
206
  result.createdTitles.push(title);
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Single source of truth for shared types + the embedded version. sync-version.mjs
2
2
  // rewrites VERSION at release time (kept in lockstep with package.json + SKILL.md).
3
- export const VERSION = "2.31.3";
3
+ export const VERSION = "2.32.0";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,