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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/plugins/ultra11y.js +1 -1
- package/package.json +1 -1
- package/scripts/ultra11y.mjs +1700 -1276
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +1700 -1276
- package/skills/ultra11y/SKILL.md +2 -2
- package/skills/ultra11y/references/ci.md +46 -4
- package/skills/ultra11y/references/judgment.md +25 -12
- package/skills/ultra11y/references/pages.md +13 -4
- package/skills/ultra11y/scripts/ultra11y.mjs +1700 -1276
- package/src/adjudicate.ts +159 -37
- package/src/audit.ts +163 -2
- package/src/cli.ts +204 -18
- package/src/ledger.ts +250 -0
- package/src/orchestrate-templates.ts +2 -2
- package/src/report.ts +24 -6
- package/src/rules/rendered.ts +39 -1
- package/src/types.ts +10 -3
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);
|
|
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
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
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
|
|
2750
|
-
//
|
|
2751
|
-
//
|
|
2752
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
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
|
|