@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.
- package/README.md +285 -0
- package/bin/a11y.mjs +36 -0
- package/bin/diff-scope.mjs +29 -0
- package/data/baseline-rules.json +892 -0
- package/package.json +68 -0
- package/src/agent-lane.ts +138 -0
- package/src/agents-block.ts +157 -0
- package/src/baseline/gen-baseline.ts +166 -0
- package/src/cli.ts +1026 -0
- package/src/collect-dom.ts +119 -0
- package/src/collect-liquid.ts +103 -0
- package/src/collect-swift.ts +227 -0
- package/src/collect-unity.ts +99 -0
- package/src/collect.ts +54 -0
- package/src/commands.ts +314 -0
- package/src/config-scan.ts +177 -0
- package/src/contract.ts +355 -0
- package/src/core.ts +546 -0
- package/src/detect-stack.ts +207 -0
- package/src/diff-scope-cli.ts +12 -0
- package/src/diff-scope.ts +150 -0
- package/src/emit-contract.ts +181 -0
- package/src/enforce.ts +1125 -0
- package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
- package/src/evidence.ts +308 -0
- package/src/github-identity.ts +201 -0
- package/src/hook.ts +242 -0
- package/src/impact-gate.ts +107 -0
- package/src/imports-resolve.ts +248 -0
- package/src/index.ts +183 -0
- package/src/liquid-ast.ts +203 -0
- package/src/liquid-rules.ts +691 -0
- package/src/mcp.ts +363 -0
- package/src/module-scope.ts +137 -0
- package/src/phone-home.ts +470 -0
- package/src/pr-comment.ts +250 -0
- package/src/pr-summary-cli.ts +182 -0
- package/src/pr-summary.ts +291 -0
- package/src/registry.ts +605 -0
- package/src/reporter/contract.ts +154 -0
- package/src/reporter/finding.ts +87 -0
- package/src/reporter/github-adapter.ts +183 -0
- package/src/reporter/null-adapter.ts +51 -0
- package/src/reporter/registry.ts +22 -0
- package/src/reporter-cli.ts +48 -0
- package/src/resolve-components.ts +579 -0
- package/src/runner/budget.ts +90 -0
- package/src/runner/codegraph-lookup.test.ts +93 -0
- package/src/runner/codegraph-lookup.ts +197 -0
- package/src/runner/index.ts +86 -0
- package/src/runner/lookup.ts +69 -0
- package/src/runner/provider.ts +72 -0
- package/src/runner/providers/anthropic.ts +168 -0
- package/src/runner/providers/openai.ts +191 -0
- package/src/runner/reasoner.ts +73 -0
- package/src/runner/reasoning/index.ts +53 -0
- package/src/runner/reasoning/prompt.ts +200 -0
- package/src/runner/reasoning/react.ts +149 -0
- package/src/runner/reasoning/shopify.ts +214 -0
- package/src/runner/reasoning/skills-reasoner.ts +117 -0
- package/src/runner/reasoning/types.ts +99 -0
- package/src/runner/runner.ts +203 -0
- package/src/sarif.ts +148 -0
- package/src/source-identity.ts +0 -0
- package/src/source-trace.ts +814 -0
- package/src/suggest.ts +328 -0
- package/src/suppression-ranges.ts +354 -0
- package/src/suppressor-map.ts +189 -0
- package/src/suppressors.ts +284 -0
- package/src/tsconfig-aliases.ts +155 -0
- package/src/unity-ast.ts +331 -0
- package/src/unity-findings.ts +91 -0
- package/src/unity-guid-registry.ts +82 -0
- package/src/unity-label-resolve.ts +249 -0
- package/src/unity-rule-color-only.ts +127 -0
- package/src/unity-rule-missing-label.ts +156 -0
- package/src/unity-rules-baseline.ts +273 -0
- package/src/wcag-map.ts +35 -0
- package/src/wcag-tags.ts +35 -0
- 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
|
+
}
|