ultra11y 2.31.2 → 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.
package/src/adjudicate.ts CHANGED
@@ -77,6 +77,18 @@ export interface AdjudicationItem {
77
77
  justification: string; // REQUIRED for C and NA
78
78
  reason: string | null; // REQUIRED for a still-`manual` verdict ("needs-rendered-dom" | "undecidable")
79
79
  findings: AgentFinding[]; // REQUIRED (≥1, groundable, each with a normativeRef) for NC
80
+ // What the agent CLEARED, for a C (or ruled out of scope, for an NA). The mirror image of
81
+ // `findings`, and required for the same reason: without a slot to cite in, a conforming
82
+ // verdict was pure prose, and the gate could only ever check that the prose was non-empty
83
+ // — so a model answering "C" to everything with the justification "x" passed, and the
84
+ // report published 91 conformant criteria nobody had assessed.
85
+ //
86
+ // Each citation must ground (the file/line/snippet must resolve, exactly like an NC
87
+ // finding) AND match one of this item's own `evidence` anchors by file+line — that pairing
88
+ // is what turns "cite something real" into "cite the evidence you were shown". When the
89
+ // harvester found no evidence at all there is nothing to clear, and the honest verdicts
90
+ // are `manual` or `NA`, never `C`.
91
+ citations?: Evidence[];
80
92
  // Non-normative good practices the agent noted on this criterion — folded back into the
81
93
  // audit as ADVISORY findings (grounded exactly like an NC finding, but never affecting
82
94
  // status: they cannot flip the criterion to NC nor enter conformancePct). Optional.
@@ -90,9 +102,37 @@ export interface AdjudicationFile {
90
102
  schemaVersion: number;
91
103
  standard: StandardId;
92
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;
93
111
  items: AdjudicationItem[];
94
112
  }
95
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
+
96
136
  // ---- evidence harvesters ----
97
137
  // Each harvester answers "for this SC, what did the engine see that the agent needs to
98
138
  // rule?" — bounded, source-anchored, language-neutral. A criterion with no harvester gets
@@ -378,6 +418,53 @@ export interface ApplyAdjudicationResult {
378
418
  const NC_SEVERITY_DEFAULT: Severity = "majeur";
379
419
  const MANUAL_REASONS = new Set(["needs-rendered-dom", "undecidable"]);
380
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
+
381
468
  /** Does an NC finding's `normativeRef` resolve against the ACTIVE standard?
382
469
  *
383
470
  * Core: a real success-criterion id (reuses `hasSC`).
@@ -421,18 +508,38 @@ function normativeRefResolves(ref: string | undefined, standard: StandardId, ite
421
508
  * a shrunk residual set, and the `adjudicated` marker. */
422
509
  export function applyAdjudication(audit: AuditResult, adj: AdjudicationFile, opts: { cwd?: string } = {}): ApplyAdjudicationResult {
423
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);
424
514
  const byId = new Map(adj.items.map((it) => [it.criteriaId, it]));
425
515
 
426
516
  // Coverage. Under the core that means every residual success criterion; under a pack it
427
517
  // means every pack criterion that derives `manual` — the pack's own granularity, which is
428
518
  // what the worklist was built at.
429
519
  const packMode = !isCore(adj.standard);
520
+ const open = new Set<string>();
430
521
  if (packMode) {
431
522
  for (const pc of derivePackResults(audit, adj.standard)) {
432
- if (pc.status === "manual" && !byId.has(pc.id)) issues.push(`criterion ${pc.id}: missing from the adjudication (coverage gap)`);
523
+ if (pc.status !== "manual") continue;
524
+ open.add(pc.id);
525
+ if (!byId.has(pc.id)) issues.push(`criterion ${pc.id}: missing from the adjudication (coverage gap)`);
433
526
  }
434
527
  } else {
435
- for (const r of audit.residualRisks) if (!byId.has(r.criteriaId)) issues.push(`criterion ${r.criteriaId}: missing from the adjudication (coverage gap)`);
528
+ for (const r of audit.residualRisks) {
529
+ open.add(r.criteriaId);
530
+ if (!byId.has(r.criteriaId)) issues.push(`criterion ${r.criteriaId}: missing from the adjudication (coverage gap)`);
531
+ }
532
+ }
533
+
534
+ // …and the other direction. The coverage check above only proves every open criterion was
535
+ // ruled on; it says nothing about a SURPLUS item. That mattered: the fold resolved a
536
+ // criterion by id against the whole audit, so an extra `{criteriaId: "3.1.1", verdict:
537
+ // "C"}` overwrote a non-conformity the deterministic engine had decided — no finding, no
538
+ // citation, no normativeRef. Adjudication may only ever decide what the engine left open.
539
+ for (const it of adj.items) {
540
+ if (!open.has(it.criteriaId)) {
541
+ issues.push(`criterion ${it.criteriaId}: not open for adjudication — the engine already decided it, or it is not part of ${adj.standard}`);
542
+ }
436
543
  }
437
544
 
438
545
  // Per-item fail-closed validation.
@@ -443,6 +550,36 @@ export function applyAdjudication(audit: AuditResult, adj: AdjudicationFile, opt
443
550
  issues.push(`criterion ${it.criteriaId}: unadjudicated (verdict is null)`);
444
551
  } else if (v === "C" || v === "NA") {
445
552
  if (!it.justification || !it.justification.trim()) issues.push(`criterion ${it.criteriaId}: a ${v} verdict requires a justification`);
553
+ // A clearing verdict is gated exactly like an accusing one. Before this, the only
554
+ // check was "the justification is a non-empty string", so `"x"` cleared a criterion —
555
+ // and a model answering C to everything published a conformance nobody had assessed.
556
+ const cites = it.citations ?? [];
557
+ if (it.evidence.length === 0) {
558
+ // Nothing was harvested for this criterion, so there is nothing the agent could have
559
+ // read to clear it. `NA` is still legitimate (the honest "no element in scope is
560
+ // concerned"); `C` is not — it must stay `manual` and be ruled on against the
561
+ // criterion's own tests, from a rendered capture or by hand.
562
+ if (v === "C") {
563
+ issues.push(
564
+ `criterion ${it.criteriaId}: a C verdict needs evidence to cite, and none was harvested for this criterion — record "manual" (reason "undecidable"), or "NA" if nothing in scope is concerned`,
565
+ );
566
+ }
567
+ } else if (cites.length === 0) {
568
+ issues.push(
569
+ `criterion ${it.criteriaId}: a ${v} verdict must cite at least one of the ${it.evidence.length} evidence item(s) it was shown (citations: [{file, line, …}])`,
570
+ );
571
+ } else {
572
+ // Each citation must name evidence THIS item carried. Grounding alone would only
573
+ // prove the anchor exists somewhere in the tree; the pairing proves the agent ruled
574
+ // on what it was actually given.
575
+ const anchors = new Set(it.evidence.map((e) => `${e.file}:${e.line}`));
576
+ for (const c of cites) {
577
+ if (!anchors.has(`${c.file}:${c.line}`)) {
578
+ issues.push(`criterion ${it.criteriaId}: citation ${c.file}:${c.line} is not among this criterion's harvested evidence (fabricated?)`);
579
+ }
580
+ groundInputs.push({ file: c.file, line: c.line, selector: c.selector, snippet: c.snippet });
581
+ }
582
+ }
446
583
  } else if (v === "NC") {
447
584
  if (!it.findings || it.findings.length === 0) issues.push(`criterion ${it.criteriaId}: an NC verdict requires at least one groundable finding`);
448
585
  for (const f of it.findings ?? []) {
@@ -461,9 +598,12 @@ export function applyAdjudication(audit: AuditResult, adj: AdjudicationFile, opt
461
598
  }
462
599
  } else if (v === "manual") {
463
600
  if (!it.reason || !MANUAL_REASONS.has(it.reason))
464
- 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(", ")}}`);
465
602
  } else {
466
- 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(" | ")}`);
467
607
  }
468
608
  // Recommendations are independent of the verdict (a C criterion may still carry a good
469
609
  // practice) and are grounded exactly like an NC finding — no normativeRef required, as
@@ -471,7 +611,8 @@ export function applyAdjudication(audit: AuditResult, adj: AdjudicationFile, opt
471
611
  for (const rec of it.recommendations ?? []) groundInputs.push({ file: rec.file, line: rec.line, selector: rec.selector, snippet: rec.snippet });
472
612
  }
473
613
 
474
- // Content-level grounding of every agent NC finding + every recommendation.
614
+ // Content-level grounding of every agent NC finding, every C/NA citation, and every
615
+ // recommendation — the same check, whichever direction the verdict points.
475
616
  const grounding = groundItems(groundInputs, { cwd: opts.cwd });
476
617
  for (const gi of grounding.issues) issues.push(gi);
477
618
 
@@ -596,7 +737,12 @@ function recomputeTallies(a: AuditResult): void {
596
737
  g.na = inG.filter((c) => c.status === "NA").length;
597
738
  g.manual = inG.filter((c) => c.status === "manual").length;
598
739
  }
599
- const decided = a.criteria.filter((c) => c.status === "C" || c.status === "NC");
740
+ // `conformancePct` is labelled everywhere as the AUTOMATIC static-check pass rate, so an
741
+ // agent's C must not enter it — otherwise one adjudication pass turns a judgement into a
742
+ // machine-verified number, which is exactly the claim the label denies. An agent NC does
743
+ // count: a non-conformity is evidenced (grounded finding + normativeRef) and reporting
744
+ // it lowers the rate, which is the safe direction.
745
+ const decided = a.criteria.filter((c) => c.status === "NC" || (c.status === "C" && c.decidedBy !== "agent"));
600
746
  const conform = decided.filter((c) => c.status === "C").length;
601
747
  a.conformancePct = decided.length === 0 ? 100 : Math.round((conform / decided.length) * 100);
602
748
  }
@@ -611,13 +757,13 @@ const T = {
611
757
  intro:
612
758
  "Pour CHAQUE critère, lisez les évidences ci-dessous (extraites de la source auditée) et attribuez un verdict dans `ADJUDICATE.todo.json` (champ `verdict`) :",
613
759
  verdicts: [
614
- "- `C` — conforme (renseignez `justification`) ;",
760
+ "- `C` — conforme (renseignez `justification` ET `citations[]` : les évidences que vous avez levées, `file`/`line` recopiés depuis la liste du critère) ;",
615
761
  "- `NC` — non conforme (ajoutez au moins un `findings[]` : file/line/message, avec un `snippet` groundable ET un `normativeRef` citant le test précis échoué) ;",
616
- "- `NA` — non applicable (renseignez `justification`) ;",
762
+ "- `NA` — non applicable (renseignez `justification` ; si des évidences sont présentées, citez-les aussi dans `citations[]` pour dire lesquelles sortent du périmètre) ;",
617
763
  "- `manual` — indécidable statiquement (renseignez `reason` : `needs-rendered-dom` → `scan`, ou `undecidable`).",
618
764
  ],
619
- rule: "> Ne signalez une NC que si un test précis du référentiel actif échoue — citez-le (`normativeRef`). Une bonne pratique sans test normatif est une recommandation (`recommendations[]`, non normative). Une simple préoccupation UX n'est ni l'un ni l'autre.",
620
- then: "Puis : `ultra11y verify --apply ADJUDICATE.todo.json --in <audit.json> --out <dir>` (échoue si un verdict manque une justification/finding/reason).",
765
+ rule: "> Ne signalez une NC que si un test précis du référentiel actif échoue — citez-le (`normativeRef`). Une bonne pratique sans test normatif est une recommandation (`recommendations[]`, non normative). Une simple préoccupation UX n'est ni l'un ni l'autre.\n>\n> Symétriquement, un `C` se cite comme une NC : il faut nommer dans `citations[]` les évidences levées. **Un critère présenté sans aucune évidence ne peut pas être `C`** — c'est `manual` (`undecidable`), ou `NA` si rien n'est concerné.",
766
+ then: "Puis : `ultra11y verify --apply ADJUDICATE.todo.json --in <audit.json> --out <dir>` (échoue si un verdict manque sa justification, ses citations, son finding ou sa raison).",
621
767
  evidence: "Évidences",
622
768
  none: "(aucune évidence automatique — décidez depuis la source, ou laissez `manual` avec une raison)",
623
769
  questions: "À vérifier manuellement",
@@ -635,13 +781,13 @@ const T = {
635
781
  title: "# Criteria adjudication (ultra11y)",
636
782
  intro: "For EACH criterion, read the evidence below (harvested from the audited source) and set a verdict in `ADJUDICATE.todo.json` (field `verdict`):",
637
783
  verdicts: [
638
- "- `C` — conformant (fill `justification`);",
784
+ "- `C` — conformant (fill `justification` AND `citations[]`: the evidence you cleared, `file`/`line` copied from the criterion's own list);",
639
785
  "- `NC` — non-conformant (add at least one `findings[]`: file/line/message, with a groundable `snippet` AND a `normativeRef` citing the precise failed test);",
640
- "- `NA` — not applicable (fill `justification`);",
786
+ "- `NA` — not applicable (fill `justification`; when evidence is presented, cite it too in `citations[]` to say which items are out of scope);",
641
787
  "- `manual` — not statically decidable (fill `reason`: `needs-rendered-dom` → `scan`, or `undecidable`).",
642
788
  ],
643
- rule: "> Report NC only if a precise test of the active standard fails — cite it (`normativeRef`). A good practice without a normative test is a recommendation (`recommendations[]`, non-normative). A purely UX concern is neither.",
644
- then: "Then: `ultra11y verify --apply ADJUDICATE.todo.json --in <audit.json> --out <dir>` (fails if any verdict lacks its justification/finding/reason).",
789
+ rule: "> Report NC only if a precise test of the active standard fails — cite it (`normativeRef`). A good practice without a normative test is a recommendation (`recommendations[]`, non-normative). A purely UX concern is neither.\n>\n> A `C` is cited the same way an NC is: name the evidence you cleared in `citations[]`. **A criterion presented with no evidence at all cannot be `C`** — record `manual` (`undecidable`), or `NA` if nothing in scope is concerned.",
790
+ then: "Then: `ultra11y verify --apply ADJUDICATE.todo.json --in <audit.json> --out <dir>` (fails if any verdict lacks its justification, citations, finding or reason).",
645
791
  evidence: "Evidence",
646
792
  none: "(no automatic evidence — decide from source, or leave `manual` with a reason)",
647
793
  questions: "To verify manually",
@@ -847,6 +993,7 @@ export function writeAdjudication(
847
993
  schemaVersion: SCHEMA_VERSION,
848
994
  standard: opts.standard,
849
995
  auditDate: opts.auditDate,
996
+ contract: adjudicationContract(),
850
997
  items,
851
998
  };
852
999
  writeFileSync(todoPath, JSON.stringify(file, null, 2) + "\n");
package/src/auditor.ts CHANGED
@@ -11,7 +11,7 @@ import type { AuditResult, Finding, Lang, Severity } from "./types.js";
11
11
  import { prdUnits, partitionUnits, effortOf, guidanceFor, guidanceExampleBlock, acceptanceCriteria, type PrdUnit, type PrdFile } from "./prd.js";
12
12
  import { getSC, guidelineTitle, principleTitle, techniques as scTechniques } from "./wcag.js";
13
13
  import { resolveMessage, resolveRemediation, resolveNote } from "./messages.js";
14
- import { type StandardId, isCore, loadPack, standardLabel, themeName, vocabularyFor } from "./standards/index.js";
14
+ import { type StandardId, isCore, loadPack, packTestIds, standardLabel, themeName, vocabularyFor } from "./standards/index.js";
15
15
 
16
16
  const SEV_ORDER: Severity[] = ["bloquant", "majeur", "mineur"];
17
17
  const ICON: Record<Severity, string> = { bloquant: "🔴", majeur: "🟠", mineur: "🟡" };
@@ -186,7 +186,7 @@ export function renderAuditorUnit(unit: PrdUnit, standard: StandardId, lang: Lan
186
186
  const pc = pack.criteria.find((c) => c.id === unit.criteriaId);
187
187
  if (pc) out.push(`**${v.theme}** : ${pc.theme}. ${themeName(pack, pc.theme, lang) ?? ""}`.trimEnd());
188
188
  out.push(`**${v.criterion}** : ${unit.criteriaId} — ${unit.title}`);
189
- const testNums = pc?.tests ? Object.keys(pc.tests).map((k) => `${unit.criteriaId}.${k}`) : [];
189
+ const testNums = packTestIds(pack, unit.criteriaId);
190
190
  if (testNums.length) out.push(`**${v.test}(s)** : ${testNums.join(" · ")}`);
191
191
  if (unit.refs.length) out.push(`**WCAG** : ${unit.refs.map((sc) => `${sc}${scLevel(sc)}`).join(" · ")}`);
192
192
  }
package/src/cli.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { realpathSync, writeFileSync, mkdirSync, existsSync, readFileSync, appendFileSync } from "node:fs";
1
+ import { realpathSync, writeFileSync, mkdirSync, existsSync, readFileSync, appendFileSync, copyFileSync } from "node:fs";
2
2
  import { join, relative, sep, dirname } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import { VERSION, type Lang, type AuditResult, type DynamicResult, type SampleConfig, type Severity } from "./types.js";
@@ -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",
@@ -1157,14 +1161,30 @@ async function cmdPages(p: ParsedArgs): Promise<number> {
1157
1161
  const split = p.flags.split === "page";
1158
1162
  const outDir = typeof p.flags.out === "string" && p.flags.out ? (p.flags.out as string) : undefined;
1159
1163
 
1160
- // The screenshot lives beside the snapshot, on disk. Reference it relatively from wherever
1161
- // the file lands — a copy would double the repo's weight for no gain, and an absolute path
1162
- // would break the moment the report is read anywhere else.
1163
- const shotsFor = (fileDir: string): Map<string, string> => {
1164
+ // The screenshot lives beside the snapshot, in `.ultra11y/pages/`. Referencing it
1165
+ // relatively is right when the report is read in place, and wrong the moment the report
1166
+ // DIRECTORY travels on its own: CI uploads `audits/` as the artifact, and a
1167
+ // `../../.ultra11y/…` link then points outside it — every image broken in the artifact
1168
+ // the reviewer actually opens. So when writing to a directory we copy each screenshot
1169
+ // into it (`assets/<page-id>.png`) and link that; stdout mode keeps the relative
1170
+ // reference, since there is no output directory to be self-contained.
1171
+ const shotOf = (id: string): string => join(PAGES_DIR, id, "screen.png");
1172
+ const shotsRelative = (fileDir: string): Map<string, string> => {
1164
1173
  const m = new Map<string, string>();
1165
1174
  for (const pg of derived) {
1166
- const shot = join(PAGES_DIR, pg.id, "screen.png");
1167
- if (existsSync(shot)) m.set(pg.id, relative(fileDir, shot).split("\\").join("/"));
1175
+ if (existsSync(shotOf(pg.id))) m.set(pg.id, relative(fileDir, shotOf(pg.id)).split("\\").join("/"));
1176
+ }
1177
+ return m;
1178
+ };
1179
+ const shotsCopiedInto = (dir: string): Map<string, string> => {
1180
+ const m = new Map<string, string>();
1181
+ const assets = join(dir, "assets");
1182
+ for (const pg of derived) {
1183
+ const src = shotOf(pg.id);
1184
+ if (!existsSync(src)) continue;
1185
+ mkdirSync(assets, { recursive: true });
1186
+ copyFileSync(src, join(assets, `${pg.id}.png`));
1187
+ m.set(pg.id, `./assets/${pg.id}.png`);
1168
1188
  }
1169
1189
  return m;
1170
1190
  };
@@ -1172,19 +1192,19 @@ async function cmdPages(p: ParsedArgs): Promise<number> {
1172
1192
  if (!outDir) {
1173
1193
  // No --out: stream the whole document to stdout. Screenshots are resolved against the
1174
1194
  // CWD, which is where a reader piping this would sit.
1175
- console.log(renderPagesDocument(result, derived, { standard, lang, screenshots: shotsFor(".") }));
1195
+ console.log(renderPagesDocument(result, derived, { standard, lang, screenshots: shotsRelative(".") }));
1176
1196
  return 0;
1177
1197
  }
1178
1198
 
1179
1199
  mkdirSync(outDir, { recursive: true });
1180
1200
  if (!split) {
1181
1201
  const file = join(outDir, `pages-${result.date}.md`);
1182
- writeFileSync(file, `${renderPagesDocument(result, derived, { standard, lang, screenshots: shotsFor(outDir) })}\n`);
1202
+ writeFileSync(file, `${renderPagesDocument(result, derived, { standard, lang, screenshots: shotsCopiedInto(outDir) })}\n`);
1183
1203
  console.log(file);
1184
1204
  return 0;
1185
1205
  }
1186
1206
 
1187
- const shots = shotsFor(outDir);
1207
+ const shots = shotsCopiedInto(outDir);
1188
1208
  // Sheets are `page-<id>.md`, never `<id>.md`: a page whose id is `index` — the ordinary id
1189
1209
  // for an `index.html` target — would otherwise be written to the same path as the index and
1190
1210
  // one of the two would silently vanish. The prefix makes the collision impossible by
@@ -1541,6 +1561,23 @@ async function cmdPrd(p: ParsedArgs): Promise<number> {
1541
1561
  const json = p.flags.json === true;
1542
1562
  if (!json) for (const path of paths) console.log(path);
1543
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
+
1544
1581
  // GitHub: always-written markdown above; issues are opt-in + best-effort.
1545
1582
  // --gh-single → one consolidated issue; --gh-issues → one issue per criterion.
1546
1583
  const ghMode: "single" | "per-criterion" | null = p.flags["gh-single"] === true ? "single" : p.flags["gh-issues"] === true ? "per-criterion" : null;
@@ -1570,7 +1607,8 @@ async function cmdPrd(p: ParsedArgs): Promise<number> {
1570
1607
  }
1571
1608
  }
1572
1609
  }
1573
- 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));
1574
1612
  // Markdown was written above regardless; but if issue creation was attempted and had
1575
1613
  // any failures, exit non-zero so a CI step / caller sees the GitHub push did not fully
1576
1614
  // succeed (a total failure previously exited 0 and looked green).
@@ -2060,7 +2098,26 @@ function applyAdjudicationFile(p: ParsedArgs, adj: AdjudicationFile, lang: Lang)
2060
2098
  mkdirSync(out, { recursive: true });
2061
2099
  const auditPath = join(out, "audit-latest.json");
2062
2100
  writeFileSync(auditPath, JSON.stringify(r.audit, null, 2) + "\n");
2063
- 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
+ );
2064
2121
  else
2065
2122
  console.log(
2066
2123
  lang === "fr"
@@ -1258,7 +1258,8 @@
1258
1258
  "wcag": ["1.4.2"],
1259
1259
  "appliesTo": {
1260
1260
  "ruleIds": ["autoplay-media", "axe:no-autoplay-audio"]
1261
- }
1261
+ },
1262
+ "judgment": true
1262
1263
  },
1263
1264
  {
1264
1265
  "id": "4.11",
@@ -1897,7 +1898,8 @@
1897
1898
  "wcag": ["3.1.1"],
1898
1899
  "appliesTo": {
1899
1900
  "ruleIds": ["axe:html-lang-valid", "lang-invalid"]
1900
- }
1901
+ },
1902
+ "judgment": true
1901
1903
  },
1902
1904
  {
1903
1905
  "id": "8.5",
@@ -1933,7 +1935,8 @@
1933
1935
  "wcag": ["2.4.2"],
1934
1936
  "appliesTo": {
1935
1937
  "ruleIds": []
1936
- }
1938
+ },
1939
+ "judgment": true
1937
1940
  },
1938
1941
  {
1939
1942
  "id": "8.7",
@@ -3327,7 +3330,8 @@
3327
3330
  "wcag": ["1.1.1", "1.3.1", "1.3.2", "2.4.1", "2.4.3", "3.1.1", "4.1.2"],
3328
3331
  "appliesTo": {
3329
3332
  "ruleIds": []
3330
- }
3333
+ },
3334
+ "judgment": true
3331
3335
  },
3332
3336
  {
3333
3337
  "id": "13.4",
@@ -3349,7 +3353,8 @@
3349
3353
  "wcag": ["1.1.1", "1.3.1", "1.3.2", "2.4.1", "2.4.3", "3.1.1", "4.1.2"],
3350
3354
  "appliesTo": {
3351
3355
  "ruleIds": []
3352
- }
3356
+ },
3357
+ "judgment": true
3353
3358
  },
3354
3359
  {
3355
3360
  "id": "13.5",
@@ -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-08-11 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-12 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
  "criteria": [
7
7
  {
8
8
  "id": "1.1.1",
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);