ultra11y 5.3.4 → 5.4.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/cli.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  hydrateAdjudication,
51
51
  type AdjudicationFile,
52
52
  } from "./adjudicate.js";
53
+ import { entriesFrom, isLedger, ledgerPath, mergeLedger, readLedger, replayLedger, type VerdictLedger, writeLedger } from "./ledger.js";
53
54
  import { BATCH_SIZE, apiKeyFromEnv, applyRawVerdicts, judgeAll, modelFromEnv } from "./llm.js";
54
55
  import { runScan, runScanMany, runCrawlScan, runSampleScan, mergeDynamic, mergeSnapshotAudit, cleanDynamic, dockerAvailable } from "./scan.js";
55
56
  import { runScanLocal, runScanManyLocal, runCrawlScanLocal, runSampleScanLocal, localAvailable } from "./scan-local.js";
@@ -375,6 +376,13 @@ Options:
375
376
  --generate criteria: emit the bundled references/criteria.md (WCAG 2.2 AA)
376
377
  --apply <file> verify: reduce a filled verdicts file to a pass/fail gate
377
378
  (requires --report — coverage is re-derived from the report, uncapped)
379
+ Also accepts an ADJUDICATION (--in <audit.json>), or a VERDICT LEDGER,
380
+ which it REPLAYS onto the audit with no model in the loop
381
+ --ledger <file> verify --apply / judge --apply: record the verdicts the fold ACCEPTED,
382
+ so a later run replays them for free (default:
383
+ .ultra11y/verdicts/<standard>.json). Replay re-derives the evidence
384
+ and re-runs the same gate; a verdict whose evidence changed is
385
+ dropped as stale and its criterion says so
378
386
  --max-verify <n> verify: cap the worklist size; 0 = no cap (default: 40)
379
387
  --verdicts <file> check --semantic: the adjudicated verdicts artifact
380
388
  (default: VERIFY.todo.json next to the report)
@@ -437,6 +445,10 @@ Options:
437
445
  --apply judge: fold the verdicts straight into the audit, through the SAME
438
446
  fail-closed gate an agent's verdicts pass (no unjustified C/NA, no
439
447
  ungroundable NC, no unadjudicated criterion)
448
+ --strict verify --apply / judge --apply: all-or-nothing fold — one refused
449
+ verdict discards the whole adjudication. The default folds PER
450
+ VERDICT: a refusal costs its own criterion, which stays to assess
451
+ carrying the refusal, and every verdict that proved itself lands
440
452
  --semantic verify: fold the support-check into one pass
441
453
  check: engage the semantic gate — requires an adjudicated verdicts
442
454
  artifact (fails closed when absent) and re-grounds every passing
@@ -515,6 +527,10 @@ const VALUE_FLAGS = new Set([
515
527
  "dedup",
516
528
  "only",
517
529
  "standard",
530
+ // `verify --apply` / `judge --apply`: where to RECORD the verdicts the fold accepted, so a
531
+ // later run can replay them without a model (src/ledger.ts). A path; empty falls back to the
532
+ // standard's default location under .ultra11y/verdicts/.
533
+ "ledger",
518
534
  "baseline",
519
535
  "fail-on",
520
536
  "split",
@@ -599,6 +615,9 @@ const BOOLEAN_FLAGS = new Set([
599
615
  "generate",
600
616
  "semantic",
601
617
  "manual",
618
+ // `verify --apply` / `judge --apply`: restore the all-or-nothing fold, where one refused
619
+ // verdict discards the whole adjudication. The default is per-verdict.
620
+ "strict",
602
621
  "no-technical",
603
622
  "override",
604
623
  "local",
@@ -2493,10 +2512,14 @@ function cmdVerify(p: ParsedArgs): number {
2493
2512
  return 2;
2494
2513
  }
2495
2514
  // Dispatch on shape: an OBJECT with kind:"adjudication" is a manual-criteria adjudication
2496
- // (src/adjudicate.ts); a plain ARRAY is the classic NC-verdicts worklist.
2515
+ // (src/adjudicate.ts); kind:"verdict-ledger" is a stored one being REPLAYED (src/ledger.ts,
2516
+ // no model in the loop); a plain ARRAY is the classic NC-verdicts worklist.
2497
2517
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && (parsed as { kind?: string }).kind === "adjudication") {
2498
2518
  return applyAdjudicationFile(p, parsed as AdjudicationFile, lang);
2499
2519
  }
2520
+ if (isLedger(parsed)) {
2521
+ return replayLedgerFile(p, parsed, lang);
2522
+ }
2500
2523
  if (!Array.isArray(parsed)) {
2501
2524
  console.error("ultra11y verify: --apply must be a JSON array of verdicts, or an adjudication object.");
2502
2525
  return 2;
@@ -2624,6 +2647,109 @@ function cmdVerify(p: ParsedArgs): number {
2624
2647
  return 0;
2625
2648
  }
2626
2649
 
2650
+ /** Where to record the verdicts a fold accepted. `--ledger <path>` names a file; `--ledger`
2651
+ * bare takes the standard's default location. Absent ⇒ nothing is recorded, so a caller who
2652
+ * never asked for a ledger never gets a surprise file in their tree. */
2653
+ function ledgerTarget(p: ParsedArgs, standard: StandardId): string | undefined {
2654
+ const flag = p.flags.ledger;
2655
+ if (flag === undefined || flag === false) return undefined;
2656
+ return typeof flag === "string" && flag ? flag : ledgerPath(standard);
2657
+ }
2658
+
2659
+ /** `verify --apply <ledger.json> --in <audit.json> --out <dir>` — REPLAY stored verdicts onto
2660
+ * a fresh audit, with no model in the loop.
2661
+ *
2662
+ * This is the parity mechanism: the same `applyAdjudication` gate runs, on evidence re-derived
2663
+ * from the audit in front of it, so a stored verdict has to prove itself again exactly as it
2664
+ * did the day it was recorded. A verdict whose evidence has since changed is dropped as stale
2665
+ * and its criterion returns to « to assess », saying so. */
2666
+ function replayLedgerFile(p: ParsedArgs, ledger: VerdictLedger, lang: Lang): number {
2667
+ const inFlag = p.flags.in;
2668
+ if (typeof inFlag !== "string" || !inFlag) {
2669
+ console.error(
2670
+ lang === "fr"
2671
+ ? "ultra11y verify : --apply <registre> exige --in <audit.json> (l'audit à mettre à jour)."
2672
+ : "ultra11y verify: --apply <ledger> requires --in <audit.json> (the audit to update).",
2673
+ );
2674
+ return 2;
2675
+ }
2676
+ let audit: AuditResult;
2677
+ try {
2678
+ audit = JSON.parse(readText(inFlag)) as AuditResult;
2679
+ } catch {
2680
+ console.error(`ultra11y verify: --in file not found or not valid JSON: ${inFlag}.`);
2681
+ return 2;
2682
+ }
2683
+ const cwd = typeof p.flags.cwd === "string" ? (p.flags.cwd as string) : undefined;
2684
+ const standard = resolveStandard(p.flags.standard) ?? ledger.standard;
2685
+ const rp = replayLedger(audit, ledger, { cwd, standard });
2686
+ const r = applyAdjudication(audit, rp.adj, { cwd, strict: p.flags.strict === true, residualReasons: rp.residualReasons });
2687
+
2688
+ if (!r.ok && (p.flags.strict === true || r.applied + r.stillManual === 0)) {
2689
+ if (p.flags.json) console.log(JSON.stringify({ ...r, replay: rp }, null, 2));
2690
+ else {
2691
+ console.error(lang === "fr" ? `✗ Registre rejeté (${r.issues.length} problème(s)) :` : `✗ Ledger rejected (${r.issues.length} issue(s)):`);
2692
+ for (const i of r.issues) console.error(` ✗ ${i}`);
2693
+ }
2694
+ return 1;
2695
+ }
2696
+ const out = typeof p.flags.out === "string" ? (p.flags.out as string) : ".";
2697
+ mkdirSync(out, { recursive: true });
2698
+ const auditPath = join(out, "audit-latest.json");
2699
+ writeFileSync(auditPath, `${JSON.stringify(r.audit, null, 2)}\n`);
2700
+
2701
+ if (p.flags.json)
2702
+ console.log(
2703
+ JSON.stringify(
2704
+ {
2705
+ ok: true,
2706
+ auditPath,
2707
+ applied: r.applied,
2708
+ stillManual: r.stillManual,
2709
+ rejected: r.rejected,
2710
+ rejectedCriteria: r.rejectedCriteria,
2711
+ replayed: rp.fresh.length,
2712
+ stale: rp.stale,
2713
+ obsolete: rp.obsolete,
2714
+ missing: rp.missing,
2715
+ issues: r.issues,
2716
+ conformancePct: r.audit.conformancePct,
2717
+ grounding: r.grounding,
2718
+ },
2719
+ null,
2720
+ 2,
2721
+ ),
2722
+ );
2723
+ else {
2724
+ console.log(
2725
+ lang === "fr"
2726
+ ? `✓ ${r.applied} verdict(s) rejoué(s) depuis le registre, ${r.stillManual} laissé(s) en résiduel${r.rejected ? `, ${r.rejected} refusé(s)` : ""} → ${auditPath}`
2727
+ : `✓ ${r.applied} verdict(s) replayed from the ledger, ${r.stillManual} left residual${r.rejected ? `, ${r.rejected} refused` : ""} → ${auditPath}`,
2728
+ );
2729
+ // Named, never merely counted: these are exactly the criteria a refresh pass has to
2730
+ // adjudicate, and a silent count is what let a half-covered grid read as complete.
2731
+ if (rp.stale.length)
2732
+ console.error(
2733
+ lang === "fr"
2734
+ ? `⚠ ${rp.stale.length} verdict(s) périmé(s) (l'évidence a changé) : ${rp.stale.join(", ")}`
2735
+ : `⚠ ${rp.stale.length} stale verdict(s) (the evidence changed): ${rp.stale.join(", ")}`,
2736
+ );
2737
+ if (rp.missing.length)
2738
+ console.error(
2739
+ lang === "fr"
2740
+ ? `⚠ ${rp.missing.length} critère(s) absent(s) du registre : ${rp.missing.join(", ")}`
2741
+ : `⚠ ${rp.missing.length} criterion(ia) absent from the ledger: ${rp.missing.join(", ")}`,
2742
+ );
2743
+ if (rp.obsolete.length)
2744
+ console.error(
2745
+ lang === "fr"
2746
+ ? `ℹ ${rp.obsolete.length} entrée(s) obsolète(s) (le moteur les décide désormais) : ${rp.obsolete.join(", ")}`
2747
+ : `ℹ ${rp.obsolete.length} obsolete entry(ies) (the engine now decides them): ${rp.obsolete.join(", ")}`,
2748
+ );
2749
+ }
2750
+ return 0;
2751
+ }
2752
+
2627
2753
  /** `verify --apply <adjudication.json> --in <audit.json> --out <dir>` — fold an AI
2628
2754
  * adjudication of the manual criteria back into the audit, fail-closed, then rewrite the
2629
2755
  * audit JSON so `report`/`prd` re-render with the adjudicated statuses. */
@@ -2648,9 +2774,15 @@ function applyAdjudicationFile(p: ParsedArgs, adj: AdjudicationFile, lang: Lang)
2648
2774
  // they were made against. Put it back from the audit before folding: the worklist is a pure
2649
2775
  // function of the audit, so this reconstructs the very anchors the citation gate checks
2650
2776
  // against — the fold below is the same fold, with the same refusals.
2651
- hydrateAdjudication(adj, audit, { cwd: typeof p.flags.cwd === "string" ? (p.flags.cwd as string) : undefined });
2652
- const r = applyAdjudication(audit, adj);
2653
- if (!r.ok) {
2777
+ const cwd = typeof p.flags.cwd === "string" ? (p.flags.cwd as string) : undefined;
2778
+ hydrateAdjudication(adj, audit, { cwd });
2779
+ const strict = p.flags.strict === true;
2780
+ const r = applyAdjudication(audit, adj, { cwd, strict });
2781
+ // STRICT: one refusal discards the file, nothing is persisted — the historical contract.
2782
+ // PARTIAL (default): the refusals are reported, the criteria they condemn stay to assess,
2783
+ // and everything that proved itself is kept. Only a fold that landed NOTHING is an error;
2784
+ // otherwise a single bad verdict would again cost the whole run.
2785
+ if (!r.ok && (strict || r.applied + r.stillManual === 0)) {
2654
2786
  if (p.flags.json) console.log(JSON.stringify(r, null, 2));
2655
2787
  else {
2656
2788
  console.error(lang === "fr" ? `✗ Adjudication rejetée (${r.issues.length} problème(s)) :` : `✗ Adjudication rejected (${r.issues.length} issue(s)):`);
@@ -2658,11 +2790,35 @@ function applyAdjudicationFile(p: ParsedArgs, adj: AdjudicationFile, lang: Lang)
2658
2790
  }
2659
2791
  return 1;
2660
2792
  }
2793
+ if (r.rejected > 0 && !p.flags.json) {
2794
+ console.error(
2795
+ lang === "fr"
2796
+ ? `⚠ ${r.rejected} critère(s) refusé(s) par la porte — ils restent à évaluer, avec le motif du refus :`
2797
+ : `⚠ ${r.rejected} criterion(ia) refused by the gate — they stay to assess, carrying the refusal:`,
2798
+ );
2799
+ for (const i of r.issues) console.error(` ✗ ${i}`);
2800
+ }
2661
2801
  // Persist the updated audit so report/prd re-render with the adjudicated statuses.
2662
2802
  const out = typeof p.flags.out === "string" ? (p.flags.out as string) : ".";
2663
2803
  mkdirSync(out, { recursive: true });
2664
2804
  const auditPath = join(out, "audit-latest.json");
2665
2805
  writeFileSync(auditPath, JSON.stringify(r.audit, null, 2) + "\n");
2806
+ // Record what landed, so the next run does not have to pay a model to learn it again. Only
2807
+ // the ACCEPTED verdicts go in: a refused one in the ledger would be laundered back on the
2808
+ // next replay, which is the whole thing the gate exists to prevent.
2809
+ const ledgerOut = ledgerTarget(p, adj.standard);
2810
+ if (ledgerOut) {
2811
+ const refused = new Set(r.rejectedCriteria);
2812
+ const accepted = new Set(adj.items.map((it) => it.criteriaId).filter((id) => !refused.has(id)));
2813
+ const fresh = entriesFrom(adj, accepted, r.audit.date);
2814
+ writeLedger(ledgerOut, mergeLedger(readLedger(ledgerOut), adj.standard, fresh));
2815
+ if (!p.flags.json)
2816
+ console.log(
2817
+ lang === "fr"
2818
+ ? `✓ ${fresh.length} verdict(s) enregistré(s) au registre → ${ledgerOut}`
2819
+ : `✓ ${fresh.length} verdict(s) recorded in the ledger → ${ledgerOut}`,
2820
+ );
2821
+ }
2666
2822
  // Carry the numbers the fold produced. The failure payload has always described its own
2667
2823
  // outcome (`issues`); the success one described only the mechanics, so a caller that gates
2668
2824
  // on the adjudicated result — a CI step, an orchestrator's tool node — had to re-read and
@@ -2675,6 +2831,9 @@ function applyAdjudicationFile(p: ParsedArgs, adj: AdjudicationFile, lang: Lang)
2675
2831
  auditPath,
2676
2832
  applied: r.applied,
2677
2833
  stillManual: r.stillManual,
2834
+ rejected: r.rejected,
2835
+ rejectedCriteria: r.rejectedCriteria,
2836
+ issues: r.issues,
2678
2837
  conformancePct: r.audit.conformancePct,
2679
2838
  findings: r.audit.findings.length,
2680
2839
  grounding: r.grounding,
@@ -2686,8 +2845,8 @@ function applyAdjudicationFile(p: ParsedArgs, adj: AdjudicationFile, lang: Lang)
2686
2845
  else
2687
2846
  console.log(
2688
2847
  lang === "fr"
2689
- ? `✓ ${r.applied} critère(s) adjugé(s), ${r.stillManual} laissé(s) en résiduel → ${auditPath}`
2690
- : `✓ ${r.applied} criterion(ia) adjudicated, ${r.stillManual} left residual → ${auditPath}`,
2848
+ ? `✓ ${r.applied} critère(s) adjugé(s), ${r.stillManual} laissé(s) en résiduel${r.rejected ? `, ${r.rejected} refusé(s)` : ""} → ${auditPath}`
2849
+ : `✓ ${r.applied} criterion(ia) adjudicated, ${r.stillManual} left residual${r.rejected ? `, ${r.rejected} refused` : ""} → ${auditPath}`,
2691
2850
  );
2692
2851
  return 0;
2693
2852
  }
@@ -2746,16 +2905,18 @@ async function cmdJudge(p: ParsedArgs): Promise<number> {
2746
2905
  }
2747
2906
  const max = typeof p.flags.max === "string" ? Number(p.flags.max) : undefined;
2748
2907
  const truncated = max !== undefined && Number.isFinite(max) && max > 0 && items.length > max;
2749
- // `--max` bounds the spend; `--apply` folds the verdicts through a gate that REFUSES an
2750
- // incomplete adjudication. Together they can never succeed — so say so before spending
2751
- // anything, instead of billing a full run and failing on coverage at the end.
2752
- if (truncated && p.flags.apply === true) {
2908
+ // `--max` bounds the spend. It used to be refused outright together with `--apply`, and
2909
+ // rightly so: the fold was all-or-nothing, so a bounded adjudication could only ever fail on
2910
+ // coverage — billing a full run to guarantee a failure. The fold is now per-verdict, so a
2911
+ // bounded run lands what it covered and leaves the rest to assess, which is precisely what
2912
+ // someone bounding spend is asking for. The pair is impossible only under `--strict`.
2913
+ if (truncated && p.flags.apply === true && p.flags.strict === true) {
2753
2914
  console.error(
2754
2915
  lang === "fr"
2755
- ? `ultra11y judge : --max ${max} ne couvre que ${max} des ${items.length} critères, et --apply exige une adjudication COMPLÈTE (le gate refuse une couverture partielle).\n` +
2756
- " Retirez --max pour tout adjuger, ou retirez --apply pour produire la worklist et l'appliquer plus tard."
2757
- : `ultra11y judge: --max ${max} covers only ${max} of ${items.length} criteria, and --apply requires a COMPLETE adjudication (the gate refuses partial coverage).\n` +
2758
- " Drop --max to adjudicate everything, or drop --apply to produce the worklist and apply it later.",
2916
+ ? `ultra11y judge : --max ${max} ne couvre que ${max} des ${items.length} critères, et --apply --strict exige une adjudication COMPLÈTE (le fold tout-ou-rien refuse une couverture partielle).\n` +
2917
+ " Retirez --strict pour appliquer ce qui est couvert, retirez --max pour tout adjuger, ou retirez --apply pour produire la worklist et l'appliquer plus tard."
2918
+ : `ultra11y judge: --max ${max} covers only ${max} of ${items.length} criteria, and --apply --strict requires a COMPLETE adjudication (the all-or-nothing fold refuses partial coverage).\n` +
2919
+ " Drop --strict to apply what is covered, drop --max to adjudicate everything, or drop --apply to produce the worklist and apply it later.",
2759
2920
  );
2760
2921
  return 2;
2761
2922
  }
@@ -2811,20 +2972,45 @@ async function cmdJudge(p: ParsedArgs): Promise<number> {
2811
2972
  // --apply runs the SAME gate an agent's verdicts go through. A truncated run cannot pass
2812
2973
  // its coverage check, which is the correct outcome, not a bug.
2813
2974
  const adj = JSON.parse(readText(w.todoPath)) as AdjudicationFile;
2814
- const r = applyAdjudication(audit, adj);
2815
- if (!r.ok) {
2975
+ const r = applyAdjudication(audit, adj, { strict: p.flags.strict === true });
2976
+ // Same policy as `verify --apply`: a batch the model got wrong costs its own criteria, not
2977
+ // the whole paid run. Only a fold that landed nothing at all is an error.
2978
+ if (!r.ok && (p.flags.strict === true || r.applied + r.stillManual === 0)) {
2816
2979
  console.error(lang === "fr" ? `✗ Adjudication rejetée (${r.issues.length} problème(s)) :` : `✗ Adjudication rejected (${r.issues.length} issue(s)):`);
2817
2980
  for (const i of r.issues.slice(0, 40)) console.error(` ✗ ${i}`);
2818
2981
  if (r.issues.length > 40) console.error(` … +${r.issues.length - 40}`);
2819
2982
  return 1;
2820
2983
  }
2984
+ if (r.rejected > 0) {
2985
+ console.error(
2986
+ lang === "fr"
2987
+ ? `⚠ ${r.rejected} critère(s) refusé(s) par la porte — ils restent à évaluer, avec le motif du refus :`
2988
+ : `⚠ ${r.rejected} criterion(ia) refused by the gate — they stay to assess, carrying the refusal:`,
2989
+ );
2990
+ for (const i of r.issues.slice(0, 40)) console.error(` ✗ ${i}`);
2991
+ if (r.issues.length > 40) console.error(` … +${r.issues.length - 40}`);
2992
+ }
2821
2993
  mkdirSync(out, { recursive: true });
2822
2994
  const auditPath = join(out, "audit-latest.json");
2823
2995
  writeFileSync(auditPath, `${JSON.stringify(r.audit, null, 2)}\n`);
2996
+ // Same recording as `verify --apply`: what the model paid for, written down where the next
2997
+ // run can replay it for free.
2998
+ const ledgerOut = ledgerTarget(p, adj.standard);
2999
+ if (ledgerOut) {
3000
+ const refused = new Set(r.rejectedCriteria);
3001
+ const accepted = new Set(adj.items.map((it) => it.criteriaId).filter((id) => !refused.has(id)));
3002
+ const fresh = entriesFrom(adj, accepted, r.audit.date);
3003
+ writeLedger(ledgerOut, mergeLedger(readLedger(ledgerOut), adj.standard, fresh));
3004
+ console.log(
3005
+ lang === "fr"
3006
+ ? `✓ ${fresh.length} verdict(s) enregistré(s) au registre → ${ledgerOut}`
3007
+ : `✓ ${fresh.length} verdict(s) recorded in the ledger → ${ledgerOut}`,
3008
+ );
3009
+ }
2824
3010
  console.log(
2825
3011
  lang === "fr"
2826
- ? `✓ ${r.applied} critère(s) adjugé(s), ${r.stillManual} laissé(s) en résiduel → ${auditPath}`
2827
- : `✓ ${r.applied} criterion(ia) adjudicated, ${r.stillManual} left residual → ${auditPath}`,
3012
+ ? `✓ ${r.applied} critère(s) adjugé(s), ${r.stillManual} laissé(s) en résiduel${r.rejected ? `, ${r.rejected} refusé(s)` : ""} → ${auditPath}`
3013
+ : `✓ ${r.applied} criterion(ia) adjudicated, ${r.stillManual} left residual${r.rejected ? `, ${r.rejected} refused` : ""} → ${auditPath}`,
2828
3014
  );
2829
3015
  return 0;
2830
3016
  }
package/src/ledger.ts ADDED
@@ -0,0 +1,250 @@
1
+ // The VERDICT LEDGER — how a judgment criterion stays decided without paying a model again.
2
+ //
3
+ // The engine can decide only a handful of criteria outright; the rest are judgment calls that
4
+ // an agent adjudicates from harvested evidence (src/adjudicate.ts). That worked in a session
5
+ // and nowhere else: every other run — a CI job, a nightly report, a colleague's checkout —
6
+ // started from zero and published « to assess », because a verdict lived only in the
7
+ // `audit-latest.json` that one run produced.
8
+ //
9
+ // The ledger is that verdict, written down where it can be reviewed: a small file committed to
10
+ // the audited repository, holding one entry per adjudicated criterion with its justification,
11
+ // its citations and — the part that makes it trustworthy — a FINGERPRINT of the evidence it was
12
+ // ruled against. Replaying it is not a cache lookup: each entry is rebuilt into an ordinary
13
+ // adjudication and folded through `applyAdjudication`, so the same coverage checks, the same
14
+ // citation matching and the same content-level re-grounding decide whether it still stands.
15
+ // A verdict nobody can prove is refused in CI exactly as it would be in a session.
16
+ //
17
+ // Staleness is the reason the fingerprint exists. When the code under a criterion changes, the
18
+ // evidence the agent read changes with it, the fingerprint stops matching, and the verdict is
19
+ // dropped as STALE — the criterion returns to « to assess » saying so. The alternative, a
20
+ // verdict that silently outlives the code it described, is the one failure mode a conformance
21
+ // deliverable cannot afford.
22
+ //
23
+ // The fingerprint deliberately ignores LINE NUMBERS. Grounding already tolerates ±10 lines of
24
+ // drift (a citation that moved is still the same citation), so hashing line numbers would
25
+ // invalidate every verdict in a file the moment someone added a comment at the top — punishing
26
+ // a formatting change like a semantic one. It hashes what the agent actually read: the file, the
27
+ // selector and the normalised snippet.
28
+
29
+ import { createHash } from "node:crypto";
30
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
31
+ import { dirname, join } from "node:path";
32
+
33
+ import { type AdjudicationFile, type AdjudicationItem, type AgentFinding, buildAdjudicationWorklist, type Evidence } from "./adjudicate.js";
34
+ import { CORE, type StandardId } from "./standards/index.js";
35
+ import type { AuditResult } from "./types.js";
36
+ import { SCHEMA_VERSION } from "./types.js";
37
+
38
+ /** Where a ledger lives by default, relative to the audited repository root. Committed on
39
+ * purpose: a verdict is a claim about this codebase, so it belongs in review alongside it. */
40
+ export const LEDGER_DIR = ".ultra11y/verdicts";
41
+
42
+ /** The default path for a standard's ledger. */
43
+ export function ledgerPath(standard: StandardId, root = "."): string {
44
+ return join(root, LEDGER_DIR, `${standard}.json`);
45
+ }
46
+
47
+ export interface LedgerEntry {
48
+ criteriaId: string;
49
+ /** The verdict as folded — same vocabulary as an adjudication item. */
50
+ verdict: "C" | "NC" | "NA" | "manual";
51
+ justification?: string;
52
+ reason?: string | null;
53
+ citations?: Evidence[];
54
+ findings?: AgentFinding[];
55
+ recommendations?: AgentFinding[];
56
+ /** Fingerprint of the evidence this verdict was ruled against (see module header). */
57
+ evidenceFingerprint: string;
58
+ /** How many evidence items were harvested — carried for the reader, never trusted. */
59
+ evidenceCount: number;
60
+ /** ISO date the verdict was recorded. */
61
+ date: string;
62
+ /** Who ruled. Only ever "agent": the engine's own verdicts are recomputed every run and
63
+ * have no business in a ledger. */
64
+ decidedBy: "agent";
65
+ }
66
+
67
+ export interface VerdictLedger {
68
+ tool: "ultra11y";
69
+ kind: "verdict-ledger";
70
+ schemaVersion: number;
71
+ standard: StandardId;
72
+ entries: LedgerEntry[];
73
+ }
74
+
75
+ const norm = (s: string) => s.replace(/\s+/g, " ").trim();
76
+
77
+ /** The line-independent identity of one piece of evidence: what the agent actually read. */
78
+ const anchorKey = (e: { file: string; selector?: string; snippet?: string }) => `${e.file}|${e.selector ?? ""}|${norm(e.snippet ?? "")}`;
79
+
80
+ /** Fingerprint the evidence a criterion was ruled against. Order-independent (the harvester's
81
+ * traversal order is not a property of the code) and line-independent (see module header). */
82
+ export function evidenceFingerprint(evidence: Evidence[]): string {
83
+ const keys = evidence.map(anchorKey).sort();
84
+ return `sha256:${createHash("sha256")
85
+ .update(`${keys.length}\n${keys.join("\n")}`)
86
+ .digest("hex")
87
+ .slice(0, 32)}`;
88
+ }
89
+
90
+ /** Move a stored anchor onto TODAY's line for the same piece of evidence.
91
+ *
92
+ * Necessary, not cosmetic. The fingerprint is deliberately line-independent, but the fold's
93
+ * citation check is exact: a citation must match one of the criterion's harvested anchors by
94
+ * `file:line`. Without re-anchoring, adding a comment at the top of a file shifted every line
95
+ * and the gate refused every stored verdict in it as fabricated — punishing a reformatting
96
+ * exactly like a rewrite, and making the ledger useless in practice.
97
+ *
98
+ * This launders nothing: the fingerprint has already proved the evidence set is the same
99
+ * content, so all that moves is the line number — the very drift `groundFinding` was written to
100
+ * tolerate. An anchor with no counterpart in today's evidence is left untouched, and the fold
101
+ * refuses it, which is the correct outcome. */
102
+ function reanchor<T extends { file: string; line: number; selector?: string; snippet?: string }>(stored: T[] | undefined, today: Evidence[]): T[] | undefined {
103
+ if (!stored?.length) return stored;
104
+ const byKey = new Map(today.map((e) => [anchorKey(e), e]));
105
+ return stored.map((s) => {
106
+ const now = byKey.get(anchorKey(s));
107
+ return now && now.line !== s.line ? { ...s, line: now.line } : s;
108
+ });
109
+ }
110
+
111
+ export function emptyLedger(standard: StandardId = CORE): VerdictLedger {
112
+ return { tool: "ultra11y", kind: "verdict-ledger", schemaVersion: SCHEMA_VERSION, standard, entries: [] };
113
+ }
114
+
115
+ export function isLedger(v: unknown): v is VerdictLedger {
116
+ const o = v as VerdictLedger | null;
117
+ return !!o && typeof o === "object" && o.tool === "ultra11y" && o.kind === "verdict-ledger" && Array.isArray(o.entries);
118
+ }
119
+
120
+ export function readLedger(path: string): VerdictLedger | undefined {
121
+ if (!existsSync(path)) return undefined;
122
+ try {
123
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
124
+ return isLedger(parsed) ? parsed : undefined;
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ }
129
+
130
+ export function writeLedger(path: string, ledger: VerdictLedger): void {
131
+ mkdirSync(dirname(path), { recursive: true });
132
+ // Entries id-sorted so the committed file has a stable diff — a ledger that reshuffles on
133
+ // every write is a ledger nobody reviews.
134
+ const sorted: VerdictLedger = { ...ledger, entries: [...ledger.entries].sort((a, b) => a.criteriaId.localeCompare(b.criteriaId, "en", { numeric: true })) };
135
+ writeFileSync(path, `${JSON.stringify(sorted, null, 2)}\n`);
136
+ }
137
+
138
+ /** Record the verdicts an adjudication just landed, as ledger entries.
139
+ *
140
+ * `applied` names the criteria whose verdict the fold ACCEPTED — a refused one must never
141
+ * reach the ledger, or the next replay would launder it back in. */
142
+ export function entriesFrom(adj: AdjudicationFile, accepted: ReadonlySet<string>, date: string): LedgerEntry[] {
143
+ const out: LedgerEntry[] = [];
144
+ for (const it of adj.items) {
145
+ if (!accepted.has(it.criteriaId) || it.verdict === null) continue;
146
+ out.push({
147
+ criteriaId: it.criteriaId,
148
+ verdict: it.verdict,
149
+ ...(it.justification?.trim() ? { justification: it.justification.trim() } : {}),
150
+ ...(it.reason ? { reason: it.reason } : {}),
151
+ ...(it.citations?.length ? { citations: it.citations } : {}),
152
+ ...(it.findings?.length ? { findings: it.findings } : {}),
153
+ ...(it.recommendations?.length ? { recommendations: it.recommendations } : {}),
154
+ evidenceFingerprint: evidenceFingerprint(it.evidence),
155
+ evidenceCount: it.evidence.length,
156
+ date,
157
+ decidedBy: "agent",
158
+ });
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /** Merge fresh entries over an existing ledger — same criterion, newest wins. A refresh pass
164
+ * that only re-adjudicated part of the grid must not delete the rest. */
165
+ export function mergeLedger(existing: VerdictLedger | undefined, standard: StandardId, fresh: LedgerEntry[]): VerdictLedger {
166
+ const base = existing && existing.standard === standard ? existing : emptyLedger(standard);
167
+ const byId = new Map(base.entries.map((e) => [e.criteriaId, e]));
168
+ for (const e of fresh) byId.set(e.criteriaId, e);
169
+ return { ...base, schemaVersion: SCHEMA_VERSION, standard, entries: [...byId.values()] };
170
+ }
171
+
172
+ export interface ReplayResult {
173
+ /** The ledger's still-valid verdicts, as an ordinary adjudication for `applyAdjudication`. */
174
+ adj: AdjudicationFile;
175
+ /** Criteria whose verdict still matches the evidence and will be folded. */
176
+ fresh: string[];
177
+ /** Criteria whose evidence changed since the verdict — dropped, with the reason to report. */
178
+ stale: string[];
179
+ /** Ledger entries for criteria the engine no longer leaves open (it decided them itself, or
180
+ * they are not part of this standard). Dropped: the engine outranks a stored verdict. */
181
+ obsolete: string[];
182
+ /** Open criteria the ledger says nothing about — they stay to assess, and are what a refresh
183
+ * pass has to adjudicate. */
184
+ missing: string[];
185
+ /** Per-criterion residual reasons for everything that did NOT fold, ready to hand to
186
+ * `applyAdjudication`. */
187
+ residualReasons: Record<string, string>;
188
+ }
189
+
190
+ /** Rebuild a ledger into an adjudication for THIS audit, dropping what no longer holds.
191
+ *
192
+ * The worklist is a pure function of the audit, so re-deriving it here reconstructs the very
193
+ * evidence anchors the citation gate checks against — the fold that follows is the same fold,
194
+ * with the same refusals. Nothing about a ledger entry is trusted except that it was written:
195
+ * its fingerprint is recomputed, its criterion must still be open, and its citations are
196
+ * re-grounded downstream. */
197
+ export function replayLedger(audit: AuditResult, ledger: VerdictLedger, opts: { cwd?: string; standard?: StandardId } = {}): ReplayResult {
198
+ const standard = opts.standard ?? ledger.standard ?? CORE;
199
+ const worklist = buildAdjudicationWorklist(audit, { cwd: opts.cwd, standard });
200
+ const open = new Map(worklist.map((it) => [it.criteriaId, it]));
201
+ const byId = new Map(ledger.entries.map((e) => [e.criteriaId, e]));
202
+
203
+ const items: AdjudicationItem[] = [];
204
+ const fresh: string[] = [];
205
+ const stale: string[] = [];
206
+ const obsolete: string[] = [];
207
+ const missing: string[] = [];
208
+ const residualReasons: Record<string, string> = {};
209
+
210
+ for (const e of ledger.entries) if (!open.has(e.criteriaId)) obsolete.push(e.criteriaId);
211
+
212
+ for (const it of worklist) {
213
+ const e = byId.get(it.criteriaId);
214
+ if (!e) {
215
+ missing.push(it.criteriaId);
216
+ residualReasons[it.criteriaId] = "No verdict in the ledger — this criterion has never been adjudicated. Run an adjudication pass to record one.";
217
+ continue;
218
+ }
219
+ const now = evidenceFingerprint(it.evidence);
220
+ if (now !== e.evidenceFingerprint) {
221
+ stale.push(it.criteriaId);
222
+ residualReasons[it.criteriaId] =
223
+ `Ledger verdict is STALE — the evidence changed since it was recorded on ${e.date} (${e.evidenceCount} item(s) then, ${it.evidence.length} now). Re-adjudicate this criterion.`;
224
+ continue;
225
+ }
226
+ fresh.push(it.criteriaId);
227
+ // Rebuild the item on TODAY's harvested evidence, carrying only the decision from the
228
+ // ledger. The stored citations still have to be found among that evidence, which is what
229
+ // makes the replay a re-verification rather than a restore.
230
+ items.push({
231
+ ...it,
232
+ verdict: e.verdict,
233
+ justification: e.justification ?? "",
234
+ reason: e.reason ?? null,
235
+ findings: reanchor(e.findings, it.evidence) ?? [],
236
+ ...(e.citations ? { citations: reanchor(e.citations, it.evidence) ?? [] } : {}),
237
+ ...(e.recommendations ? { recommendations: reanchor(e.recommendations, it.evidence) ?? [] } : {}),
238
+ decidedBy: "agent",
239
+ });
240
+ }
241
+
242
+ return {
243
+ adj: { tool: "ultra11y", kind: "adjudication", schemaVersion: SCHEMA_VERSION, standard, auditDate: audit.date, items },
244
+ fresh,
245
+ stale,
246
+ obsolete,
247
+ missing,
248
+ residualReasons,
249
+ };
250
+ }
@@ -197,7 +197,7 @@ For EACH of your criteria:
197
197
  - \`manual\` (still undecidable) — REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide, e.g. computed contrast) or \`undecidable\` (the evidence cannot settle it either way).
198
198
  3. Never guess. A criterion you cannot decide from real evidence stays \`manual\` with a reason — that is a valid, honest verdict; the scan tier or a human picks it up.
199
199
 
200
- Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "citations", "reason", "findings" }] }\` — your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`, and every C (plus every evidenced NA) carrying its \`citations\`. A verdict that clears a criterion without naming what it cleared is refused by the fold, and one refusal discards the WHOLE adjudication — including every other verdict you got right.
200
+ Return (structured output): \`{ "verdicts": [{ "criteriaId", "verdict", "justification", "citations", "reason", "findings" }] }\` — your ITEMS only, every field grounded in what you actually read, every NC finding carrying its \`normativeRef\`, and every C (plus every evidenced NA) carrying its \`citations\`. A verdict that clears a criterion without naming what it cleared is refused by the fold; the refusal costs THAT criterion — which stays « to assess » carrying the reason — and leaves every other verdict standing. So never guess to fill a gap: an honest \`manual\` is worth more than a verdict the gate throws away.
201
201
  ${footer}`,
202
202
  refuter: `# Contract: refuter
203
203
 
@@ -243,7 +243,7 @@ ${status}
243
243
  ## The loop (play every role yourself, one item at a time)
244
244
 
245
245
  1. **Audit** (if not done): \`${engine} audit "<globs>" --graph --out ${runAbs}\` → \`${join(runAbs, "audit-latest.json")}\`.
246
- 2. **Adjudicate the residual criteria** — \`${engine} verify --manual --in ${join(runAbs, "audit-latest.json")} --out ${runAbs}\` writes \`${join(runAbs, "ADJUDICATE.todo.json")}\`. For EVERY item, apply \`${join(runAbs, "orchestration", "agents", "adjudicator.md")}\` yourself (read the evidence, rule C/NC/NA/manual, fill the required justification/findings/reason IN the todo file). Then fold, fail-closed: \`${engine} verify --apply ${join(runAbs, "ADJUDICATE.todo.json")} --in ${join(runAbs, "audit-latest.json")} --out ${runAbs}\`.
246
+ 2. **Adjudicate the residual criteria** — \`${engine} verify --manual --in ${join(runAbs, "audit-latest.json")} --out ${runAbs}\` writes \`${join(runAbs, "ADJUDICATE.todo.json")}\`. For EVERY item, apply \`${join(runAbs, "orchestration", "agents", "adjudicator.md")}\` yourself (read the evidence, rule C/NC/NA/manual, fill the required justification/findings/reason IN the todo file). Then fold — gated per verdict, so a refusal costs its own criterion and no other: \`${engine} verify --apply ${join(runAbs, "ADJUDICATE.todo.json")} --in ${join(runAbs, "audit-latest.json")} --out ${runAbs}\`. Add \`--ledger .ultra11y/verdicts/<standard>.json\` to RECORD the verdicts that landed, so CI can replay them without a model; \`--strict\` restores the old all-or-nothing fold.
247
247
  3. **Report**: \`${engine} report --in ${join(runAbs, "audit-latest.json")} --out ${runAbs}\`.
248
248
  4. **Verify the report's claims** — \`${engine} verify --report <the report .md> --out ${runAbs}\` writes \`${join(runAbs, "VERIFY.todo.json")}\`. For EVERY entry, apply \`${join(runAbs, "orchestration", "agents", "refuter.md")}\` yourself (open file:line, verdict supported/partial/refuted/unsupported + note IN the todo file). Then: \`${engine} verify --apply ${join(runAbs, "VERIFY.todo.json")} --report <the report .md>\`.
249
249
  5. **Gate**: \`${engine} check --report <the report .md> --semantic\` must exit 0 before presenting anything.
package/src/report.ts CHANGED
@@ -171,17 +171,35 @@ const L = {
171
171
  },
172
172
  } as const;
173
173
 
174
- // The needs-rendering criteria the scan tier decides, with the labels the partial-audit
175
- // banner names them by. Coverage comes from scope.scan.testedScs (stamped by mergeDynamic:
176
- // Docker measures 1.4.10 only; the local runtime adds zoom / spacing / focus / hover, and
177
- // live regions when interactions are on) — so the banner only ever names criteria that
178
- // genuinely lack a dynamic verdict, and drops only when every one of them is covered.
174
+ // Every criterion an automated tier can CREDIT, with the labels the partial-audit banner names
175
+ // them by. Two tiers contribute, and `scope.scan.testedScs` is the single coverage stamp for
176
+ // both — so the banner only ever names criteria that genuinely lack a verdict, and disappears
177
+ // once they are all covered:
178
+ //
179
+ // - the SNAPSHOT tier (src/rules/rendered.ts), offline from a recorded page: 1.3.4, 1.4.1,
180
+ // 1.4.3, 1.4.11, 2.4.7;
181
+ // - the LIVE-BROWSER tier (src/scan.ts Docker measures 1.4.10 only; src/scan-local.ts adds
182
+ // zoom / spacing / focus / hover, and live regions when interactions are on).
183
+ //
184
+ // This listed six, all from the live-browser tier, and that was wrong in both directions at
185
+ // once: an audit that had ingested 35 snapshots and measured contrast and focus visibility from
186
+ // them was still told the rendering criteria "were not tested", while the criteria those
187
+ // snapshots really did decide were absent from the list and so could never be reported covered.
188
+ //
189
+ // The five needs-rendering criteria NO tier measures (1.4.5, 2.1.2, 2.3.1, 2.4.11, 2.5.8) are
190
+ // deliberately NOT here: listing them would make the banner permanent and un-actionable, since
191
+ // no run could ever clear it. They carry a per-criterion reason instead (src/audit.ts
192
+ // RESIDUAL_TRAIL) saying that no automated tier decides them, and what does.
179
193
  const NEEDS_RENDERING: readonly { sc: string; label: Record<Lang, string> }[] = [
194
+ { sc: "1.3.4", label: { fr: "verrou d’orientation", en: "orientation lock" } },
195
+ { sc: "1.4.1", label: { fr: "information par la couleur", en: "use of colour" } },
196
+ { sc: "1.4.3", label: { fr: "contraste du texte", en: "text contrast" } },
180
197
  { sc: "1.4.4", label: { fr: "zoom 200 %", en: "200% zoom" } },
181
198
  { sc: "1.4.10", label: { fr: "reflow 320 px", en: "320px reflow" } },
199
+ { sc: "1.4.11", label: { fr: "contraste des composants", en: "non-text contrast" } },
182
200
  { sc: "1.4.12", label: { fr: "espacement du texte", en: "text spacing" } },
183
- { sc: "2.4.7", label: { fr: "visibilité du focus", en: "focus visibility" } },
184
201
  { sc: "1.4.13", label: { fr: "contenu au survol", en: "content on hover" } },
202
+ { sc: "2.4.7", label: { fr: "visibilité du focus", en: "focus visibility" } },
185
203
  { sc: "4.1.3", label: { fr: "régions live", en: "live regions" } },
186
204
  ];
187
205