@binclusive/a11y 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +285 -0
  2. package/bin/a11y.mjs +36 -0
  3. package/bin/diff-scope.mjs +29 -0
  4. package/data/baseline-rules.json +892 -0
  5. package/package.json +68 -0
  6. package/src/agent-lane.ts +138 -0
  7. package/src/agents-block.ts +157 -0
  8. package/src/baseline/gen-baseline.ts +166 -0
  9. package/src/cli.ts +1026 -0
  10. package/src/collect-dom.ts +119 -0
  11. package/src/collect-liquid.ts +103 -0
  12. package/src/collect-swift.ts +227 -0
  13. package/src/collect-unity.ts +99 -0
  14. package/src/collect.ts +54 -0
  15. package/src/commands.ts +314 -0
  16. package/src/config-scan.ts +177 -0
  17. package/src/contract.ts +355 -0
  18. package/src/core.ts +546 -0
  19. package/src/detect-stack.ts +207 -0
  20. package/src/diff-scope-cli.ts +12 -0
  21. package/src/diff-scope.ts +150 -0
  22. package/src/emit-contract.ts +181 -0
  23. package/src/enforce.ts +1125 -0
  24. package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
  25. package/src/evidence.ts +308 -0
  26. package/src/github-identity.ts +201 -0
  27. package/src/hook.ts +242 -0
  28. package/src/impact-gate.ts +107 -0
  29. package/src/imports-resolve.ts +248 -0
  30. package/src/index.ts +183 -0
  31. package/src/liquid-ast.ts +203 -0
  32. package/src/liquid-rules.ts +691 -0
  33. package/src/mcp.ts +363 -0
  34. package/src/module-scope.ts +137 -0
  35. package/src/phone-home.ts +470 -0
  36. package/src/pr-comment.ts +250 -0
  37. package/src/pr-summary-cli.ts +182 -0
  38. package/src/pr-summary.ts +291 -0
  39. package/src/registry.ts +605 -0
  40. package/src/reporter/contract.ts +154 -0
  41. package/src/reporter/finding.ts +87 -0
  42. package/src/reporter/github-adapter.ts +183 -0
  43. package/src/reporter/null-adapter.ts +51 -0
  44. package/src/reporter/registry.ts +22 -0
  45. package/src/reporter-cli.ts +48 -0
  46. package/src/resolve-components.ts +579 -0
  47. package/src/runner/budget.ts +90 -0
  48. package/src/runner/codegraph-lookup.test.ts +93 -0
  49. package/src/runner/codegraph-lookup.ts +197 -0
  50. package/src/runner/index.ts +86 -0
  51. package/src/runner/lookup.ts +69 -0
  52. package/src/runner/provider.ts +72 -0
  53. package/src/runner/providers/anthropic.ts +168 -0
  54. package/src/runner/providers/openai.ts +191 -0
  55. package/src/runner/reasoner.ts +73 -0
  56. package/src/runner/reasoning/index.ts +53 -0
  57. package/src/runner/reasoning/prompt.ts +200 -0
  58. package/src/runner/reasoning/react.ts +149 -0
  59. package/src/runner/reasoning/shopify.ts +214 -0
  60. package/src/runner/reasoning/skills-reasoner.ts +117 -0
  61. package/src/runner/reasoning/types.ts +99 -0
  62. package/src/runner/runner.ts +203 -0
  63. package/src/sarif.ts +148 -0
  64. package/src/source-identity.ts +0 -0
  65. package/src/source-trace.ts +814 -0
  66. package/src/suggest.ts +328 -0
  67. package/src/suppression-ranges.ts +354 -0
  68. package/src/suppressor-map.ts +189 -0
  69. package/src/suppressors.ts +284 -0
  70. package/src/tsconfig-aliases.ts +155 -0
  71. package/src/unity-ast.ts +331 -0
  72. package/src/unity-findings.ts +91 -0
  73. package/src/unity-guid-registry.ts +82 -0
  74. package/src/unity-label-resolve.ts +249 -0
  75. package/src/unity-rule-color-only.ts +127 -0
  76. package/src/unity-rule-missing-label.ts +156 -0
  77. package/src/unity-rules-baseline.ts +273 -0
  78. package/src/wcag-map.ts +35 -0
  79. package/src/wcag-tags.ts +35 -0
  80. package/src/workspace-resolve.ts +405 -0
@@ -0,0 +1,291 @@
1
+ /**
2
+ * The consolidated PR summary / rollup (issue #2132).
3
+ *
4
+ * The inline reconciler ({@link ./pr-comment}) posts one comment per finding, on
5
+ * the offending line. A reviewer still has no single place to see the SHAPE of a
6
+ * run — how many findings, how bad, which WCAG criteria. This module renders that
7
+ * rollup and posts it two ways:
8
+ *
9
+ * - a **GitHub Actions job summary** ($GITHUB_STEP_SUMMARY), visible on the run
10
+ * page even with NO PR context (push to a branch, manual dispatch), and
11
+ * - **one rollup PR comment**, updated in place across pushes.
12
+ *
13
+ * The counts derive ONLY from the `@binclusive/a11y-contract` metadata the report
14
+ * already carries — `impact` (`critical`/`serious`/`moderate`/`minor`) and
15
+ * `criterion` (the WCAG SC id). No source snippet is manufactured (ADR 0039); the
16
+ * file/line links are the same local navigation aid the inline comments already render.
17
+ *
18
+ * Dedup discipline, one level up from #2131: there is exactly ONE rollup comment,
19
+ * found by a stable hidden marker and UPDATED IN PLACE on every push — never a
20
+ * fresh rollup appended per run. The counts reflect the reconciled set: they are
21
+ * computed over the findings deduped by {@link findingKey}, the same identity the
22
+ * inline reconciler uses, and a fixed finding is simply absent from the report so
23
+ * it drops out of the rollup for free.
24
+ *
25
+ * Pure at its core: {@link computeRollup} and the renderers are side-effect-free
26
+ * functions of their inputs; the effectful GitHub calls are injected through
27
+ * {@link RollupClient} so the reconcile logic is testable against a fake.
28
+ */
29
+ import { type Finding, findingKey } from "./pr-comment";
30
+
31
+ /** Impact buckets in report order — the contract's four concrete impact levels. */
32
+ export const IMPACT_ORDER = ["critical", "serious", "moderate", "minor"] as const;
33
+
34
+ /** One of the four concrete impact levels (the contract's `Impact` minus `unknown`). */
35
+ type ConcreteImpact = (typeof IMPACT_ORDER)[number];
36
+
37
+ /** The rollup shape a run wants to render: total + by-impact + by-WCAG-criterion. */
38
+ export interface Rollup {
39
+ /** Total findings in the reconciled set (deduped by {@link findingKey}). */
40
+ readonly total: number;
41
+ /** Count per contract impact, always carrying all four concrete buckets. */
42
+ readonly byImpact: Record<ConcreteImpact, number>;
43
+ /** Findings with no concrete impact (`unknown`, an older report, or an unmapped rule). */
44
+ readonly unknownImpact: number;
45
+ /** Count per WCAG criterion, descending by count then criterion id. */
46
+ readonly byCriterion: readonly { readonly criterion: string; readonly count: number }[];
47
+ }
48
+
49
+ /** The marker used to render a criterion with no SC mapping. */
50
+ const NO_CRITERION = "(no WCAG mapping)";
51
+
52
+ /**
53
+ * Compute the rollup over `findings`, deduped by {@link findingKey} (first
54
+ * occurrence wins) so two comments that would collapse to one inline comment also
55
+ * count once — the rollup total equals the number of inline comments a converged
56
+ * PR carries. Fixed findings are already absent from the report, so they never
57
+ * reach here: the rollup reflects the reconciled set by construction.
58
+ */
59
+ export function computeRollup(findings: readonly Finding[]): Rollup {
60
+ const deduped = new Map<string, Finding>();
61
+ for (const f of findings) {
62
+ const k = findingKey(f);
63
+ if (!deduped.has(k)) deduped.set(k, f);
64
+ }
65
+
66
+ const byImpact: Record<ConcreteImpact, number> = { critical: 0, serious: 0, moderate: 0, minor: 0 };
67
+ let unknownImpact = 0;
68
+ const criterionCounts = new Map<string, number>();
69
+
70
+ for (const f of deduped.values()) {
71
+ // `unknown` (and an absent impact) is not a concrete bucket — it counts as unclassified.
72
+ if (f.impact !== undefined && f.impact !== "unknown") byImpact[f.impact] += 1;
73
+ else unknownImpact += 1;
74
+ const criterion = f.criterion !== undefined && f.criterion !== "" ? f.criterion : NO_CRITERION;
75
+ criterionCounts.set(criterion, (criterionCounts.get(criterion) ?? 0) + 1);
76
+ }
77
+
78
+ const byCriterion = [...criterionCounts.entries()]
79
+ .map(([criterion, count]) => ({ criterion, count }))
80
+ .sort((a, b) => b.count - a.count || a.criterion.localeCompare(b.criterion));
81
+
82
+ return { total: deduped.size, byImpact, unknownImpact, byCriterion };
83
+ }
84
+
85
+ /** Options that inject the (env-derived) file link into the otherwise-pure renderers. */
86
+ export interface RenderOptions {
87
+ /** Resolve a finding to a clickable link to its changed file / line, or `undefined` for plain text. */
88
+ readonly linkFor?: (finding: Finding) => string | undefined;
89
+ /** How many per-finding rows to list before collapsing into an "and N more" line. */
90
+ readonly maxRows?: number;
91
+ }
92
+
93
+ const DEFAULT_MAX_ROWS = 50;
94
+
95
+ function impactLine(rollup: Rollup): string {
96
+ const parts = IMPACT_ORDER.map((s) => `${rollup.byImpact[s]} ${s}`);
97
+ if (rollup.unknownImpact > 0) parts.push(`${rollup.unknownImpact} unclassified`);
98
+ return parts.join(" · ");
99
+ }
100
+
101
+ /** The shared markdown body of the rollup, minus any marker. */
102
+ function renderBody(rollup: Rollup, findings: readonly Finding[], opts: RenderOptions): string {
103
+ if (rollup.total === 0) {
104
+ return "## ♿ Accessibility summary\n\nNo accessibility findings in the scanned changes. ✅";
105
+ }
106
+
107
+ const lines: string[] = [];
108
+ lines.push("## ♿ Accessibility summary");
109
+ lines.push("");
110
+ lines.push(`**${rollup.total}** finding${rollup.total === 1 ? "" : "s"} — ${impactLine(rollup)}`);
111
+
112
+ lines.push("");
113
+ lines.push("| Impact | Count |");
114
+ lines.push("| --- | ---: |");
115
+ for (const s of IMPACT_ORDER) lines.push(`| ${s} | ${rollup.byImpact[s]} |`);
116
+ if (rollup.unknownImpact > 0) lines.push(`| unclassified | ${rollup.unknownImpact} |`);
117
+
118
+ lines.push("");
119
+ lines.push("| WCAG criterion | Count |");
120
+ lines.push("| --- | ---: |");
121
+ for (const { criterion, count } of rollup.byCriterion) lines.push(`| ${criterion} | ${count} |`);
122
+
123
+ // Per-finding list linking back to the changed files / inline comments. The
124
+ // file/line come from the local report (the same source the inline comments
125
+ // anchor on) — a navigation aid, not a wire-crossing source snippet.
126
+ const maxRows = opts.maxRows ?? DEFAULT_MAX_ROWS;
127
+ const seen = new Set<string>();
128
+ const rows: string[] = [];
129
+ for (const f of findings) {
130
+ const k = findingKey(f);
131
+ if (seen.has(k)) continue;
132
+ seen.add(k);
133
+ const link = opts.linkFor?.(f);
134
+ const where = `${f.file}:${f.line}`;
135
+ const loc = link ? `[${where}](${link})` : `\`${where}\``;
136
+ rows.push(`- ${loc} — \`${f.ruleId}\`${f.criterion ? ` (WCAG ${f.criterion})` : ""}`);
137
+ }
138
+ if (rows.length > 0) {
139
+ lines.push("");
140
+ lines.push("### Findings");
141
+ lines.push(...rows.slice(0, maxRows));
142
+ if (rows.length > maxRows) lines.push(`- …and ${rows.length - maxRows} more`);
143
+ }
144
+
145
+ return lines.join("\n");
146
+ }
147
+
148
+ /** Render the GitHub Actions job summary markdown (no marker — it is not reconciled). */
149
+ export function renderJobSummary(
150
+ rollup: Rollup,
151
+ findings: readonly Finding[],
152
+ opts: RenderOptions = {},
153
+ ): string {
154
+ return renderBody(rollup, findings, opts);
155
+ }
156
+
157
+ /**
158
+ * The stable hidden marker that identifies THE rollup comment. Unlike the inline
159
+ * markers there is exactly one (no per-finding key) — its whole job is to let a
160
+ * later push find the single existing rollup and update it in place.
161
+ */
162
+ export const ROLLUP_MARKER = "<!-- binclusive-a11y-rollup -->";
163
+
164
+ /** True iff `body` is our rollup comment (carries the marker) — the gate that keeps reconciliation off other comments. */
165
+ export function isRollupComment(body: string): boolean {
166
+ return body.includes(ROLLUP_MARKER);
167
+ }
168
+
169
+ /** Render the rollup PR comment body, marker appended so a later push can find it. */
170
+ export function renderRollupComment(
171
+ rollup: Rollup,
172
+ findings: readonly Finding[],
173
+ opts: RenderOptions = {},
174
+ ): string {
175
+ return `${renderBody(rollup, findings, opts)}\n\n${ROLLUP_MARKER}`;
176
+ }
177
+
178
+ /** A conversation comment already on the PR (id + rendered body). */
179
+ export interface RollupComment {
180
+ readonly id: number;
181
+ readonly body: string;
182
+ }
183
+
184
+ /** The plan to converge the PR to exactly one rollup comment. */
185
+ export interface RollupPlan {
186
+ /** Post a new rollup comment — no existing one was found. */
187
+ readonly create: boolean;
188
+ /** PATCH this comment's body (the canonical rollup exists but drifted). */
189
+ readonly update: number | null;
190
+ /** The canonical rollup is already correct — leave it (no API call). */
191
+ readonly unchanged: number | null;
192
+ /** Extra rollup comments to DELETE (leftover double-posts from before dedup). */
193
+ readonly remove: readonly number[];
194
+ }
195
+
196
+ /**
197
+ * Pure reconciliation: given the desired rollup body and the comments already on
198
+ * the PR, decide the single create/update/unchanged plus any duplicate rollups to
199
+ * clean up. Only marker-carrying comments are ours; the first is canonical, the
200
+ * rest are folded into `remove` so a PR that accumulated rollups before this
201
+ * dedup existed converges to one. Idempotent — a second run with the same inputs
202
+ * yields an `unchanged` plan with no churn.
203
+ */
204
+ export function reconcileRollup(desiredBody: string, existing: readonly RollupComment[]): RollupPlan {
205
+ const ours = existing.filter((c) => isRollupComment(c.body));
206
+ if (ours.length === 0) {
207
+ return { create: true, update: null, unchanged: null, remove: [] };
208
+ }
209
+ const [canonical, ...dupes] = ours;
210
+ // canonical is defined: ours.length > 0 guarantees a first element.
211
+ const keep = canonical as RollupComment;
212
+ const remove = dupes.map((c) => c.id);
213
+ if (keep.body === desiredBody) {
214
+ return { create: false, update: null, unchanged: keep.id, remove };
215
+ }
216
+ return { create: false, update: keep.id, unchanged: null, remove };
217
+ }
218
+
219
+ /**
220
+ * The effectful surface the rollup reconciliation drives, injected so the
221
+ * orchestration is testable against an in-memory fake. Every method is
222
+ * best-effort in its implementation — a failure is logged and swallowed so the
223
+ * Action stays advisory.
224
+ */
225
+ export interface RollupClient {
226
+ /**
227
+ * All conversation comments on the PR. MUST resolve to a COMPLETE view or
228
+ * THROW — never a partial list: reconciling against a truncated view could
229
+ * read the existing rollup (on an unfetched page) as absent and re-CREATE it,
230
+ * the exact double-post this module exists to prevent.
231
+ */
232
+ list(): Promise<RollupComment[]>;
233
+ /** POST the one rollup comment. */
234
+ create(body: string): Promise<void>;
235
+ /** PATCH the rollup comment `id` to `body`. */
236
+ update(id: number, body: string): Promise<void>;
237
+ /** DELETE a leftover duplicate rollup `id`. */
238
+ remove(id: number): Promise<void>;
239
+ }
240
+
241
+ /**
242
+ * Reconcile the PR's rollup comment to `findings` through `client`, returning the
243
+ * executed plan. Updates the one rollup in place, cleans up any duplicates, and
244
+ * posts only when none exists — never a second rollup per push.
245
+ */
246
+ export async function syncRollup(
247
+ findings: readonly Finding[],
248
+ client: RollupClient,
249
+ opts: RenderOptions = {},
250
+ log: (msg: string) => void = () => {},
251
+ ): Promise<RollupPlan> {
252
+ const rollup = computeRollup(findings);
253
+ const desiredBody = renderRollupComment(rollup, findings, opts);
254
+ const existing = await client.list();
255
+ const plan = reconcileRollup(desiredBody, existing);
256
+
257
+ if (plan.create) {
258
+ await client.create(desiredBody);
259
+ log("created rollup comment");
260
+ } else if (plan.update !== null) {
261
+ await client.update(plan.update, desiredBody);
262
+ log(`updated rollup comment ${plan.update}`);
263
+ } else {
264
+ log(`rollup comment ${plan.unchanged} unchanged`);
265
+ }
266
+ for (const id of plan.remove) {
267
+ await client.remove(id);
268
+ log(`removed duplicate rollup comment ${id}`);
269
+ }
270
+ return plan;
271
+ }
272
+
273
+ /**
274
+ * The best-effort boundary the CI entrypoint runs: {@link syncRollup} wrapped so
275
+ * it NEVER throws. The rollup is advisory — any render/list/post failure must log
276
+ * and skip, never fail the CI job. Returns the executed plan, or `null` when the
277
+ * sync was aborted/skipped.
278
+ */
279
+ export async function syncRollupBestEffort(
280
+ findings: readonly Finding[],
281
+ client: RollupClient,
282
+ opts: RenderOptions = {},
283
+ log: (msg: string) => void = () => {},
284
+ ): Promise<RollupPlan | null> {
285
+ try {
286
+ return await syncRollup(findings, client, opts, log);
287
+ } catch (e) {
288
+ log(`rollup sync aborted (best-effort, no-op): ${e instanceof Error ? e.message : String(e)}`);
289
+ return null;
290
+ }
291
+ }