@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,250 @@
1
+ /**
2
+ * De-duplicating reconciler for inline PR review comments (issue #2131).
3
+ *
4
+ * The CI Action posts one inline review comment per a11y finding. Without a
5
+ * stable identity, every push re-POSTs every finding — spamming the PR thread
6
+ * with a fresh copy of the same comment on each run. This module gives each
7
+ * finding a stable hidden MARKER and reconciles the desired findings against the
8
+ * comments already on the PR, so a re-run converges to exactly one comment per
9
+ * finding instead of accumulating duplicates:
10
+ *
11
+ * - a finding already commented → UPDATE its comment in place (or leave it
12
+ * untouched when the body is identical) — never a second POST,
13
+ * - a brand-new finding → CREATE one comment,
14
+ * - a finding that was fixed → DELETE its now-orphaned comment.
15
+ *
16
+ * The marker is an HTML comment (`<!-- binclusive-a11y-agent:<key> -->`) so it
17
+ * is invisible in the rendered comment yet machine-findable. Reconciliation only
18
+ * ever touches comments carrying that marker, so human review comments are never
19
+ * matched, updated, or removed.
20
+ *
21
+ * Contract-independent and side-effect-free at its core: {@link reconcile} is a
22
+ * pure function of (desired findings, existing comments); the effectful GitHub
23
+ * calls are injected through {@link PrCommentClient} so the reconcile logic is
24
+ * testable against an in-memory fake.
25
+ */
26
+
27
+ // The canonical finding shape a reporter renders from now lives in the seam
28
+ // (`reporter/finding.ts`), so the platform-neutral input type is not owned by
29
+ // this GitHub-specific surface. Re-exported here for existing importers.
30
+ import { type Finding, type Impact, parseFindings } from "./reporter/finding";
31
+ export { type Finding, type Impact, parseFindings };
32
+
33
+ /** An existing inline review comment already on the PR (id + rendered body). */
34
+ export interface ReviewComment {
35
+ readonly id: number;
36
+ readonly body: string;
37
+ }
38
+
39
+ const MARKER_TAG = "binclusive-a11y-agent";
40
+ const MARKER_RE = /<!--\s*binclusive-a11y-agent:(.*?)\s*-->/;
41
+
42
+ /**
43
+ * A live rendered element locator. Mirrors `emit-contract.ts`'s `hasSelector`:
44
+ * empty / whitespace-only is NOT a selector (a source pass has no DOM node), so
45
+ * it does not disambiguate and the base `ruleId:file:line` identity stands.
46
+ * Kept local so this CI-comment surface doesn't drag in the contract package.
47
+ */
48
+ function hasSelector(selector: string | undefined): selector is string {
49
+ return selector !== undefined && selector.trim() !== "";
50
+ }
51
+
52
+ /**
53
+ * FNV-1a → 8 hex chars. The selector is folded into the marker, and a raw CSS
54
+ * selector can carry spaces, `>>>`, `-->`, or newlines — any of which would
55
+ * break the single-line HTML-comment marker (a `-->` closes it early; a newline
56
+ * makes {@link keyOf} fail to match, so the comment reads as not-ours and gets
57
+ * re-posted every push). Hashing into a fixed `[0-9a-f]` token is marker-safe by
58
+ * construction; we never decode it back — it exists only to make distinct
59
+ * selectors produce distinct keys.
60
+ */
61
+ function selectorToken(selector: string): string {
62
+ let h = 0x811c9dc5;
63
+ for (let i = 0; i < selector.length; i++) {
64
+ h ^= selector.charCodeAt(i);
65
+ h = Math.imul(h, 0x01000193);
66
+ }
67
+ return (h >>> 0).toString(16).padStart(8, "0");
68
+ }
69
+
70
+ /**
71
+ * The stable identity of a finding across pushes. Base identity is rule +
72
+ * location (`ruleId:file:line`); when the finding carries a live DOM selector
73
+ * it is folded in (as a marker-safe hash) so two same-rule findings co-located
74
+ * at one `file:line` — distinguished ONLY by their selector — get DISTINCT keys
75
+ * instead of the second silently collapsing onto the first (the #2131 review's
76
+ * blocking defect). This mirrors the engine's own `element = selector ?? ruleId`
77
+ * disambiguation (`emit-contract.ts`). A source finding with no selector keeps
78
+ * the bare `ruleId:file:line` key it always had.
79
+ *
80
+ * Deliberately EXCLUDES the message — when only the wording changes for the same
81
+ * rule/spot/element we update the existing comment in place, not orphan it and
82
+ * post a new one. A finding that genuinely moved to a different line (or a
83
+ * different element) is a different anchor and reconciles as delete-old +
84
+ * create-new.
85
+ */
86
+ export function findingKey(f: Finding): string {
87
+ const base = `${f.ruleId}:${f.file}:${f.line}`;
88
+ return hasSelector(f.selector) ? `${base}:${selectorToken(f.selector)}` : base;
89
+ }
90
+
91
+ /** The hidden marker embedded in every agent-authored comment for `f`. */
92
+ export function markerFor(f: Finding): string {
93
+ return `<!-- ${MARKER_TAG}:${findingKey(f)} -->`;
94
+ }
95
+
96
+ /**
97
+ * The finding key carried by a comment, or `null` if the comment is not one of
98
+ * ours (no marker) — the sole gate that keeps reconciliation off human comments.
99
+ */
100
+ export function keyOf(body: string): string | null {
101
+ const m = MARKER_RE.exec(body);
102
+ return m && m[1] !== undefined ? m[1] : null;
103
+ }
104
+
105
+ /** Render the full comment body for `f`, marker appended so it round-trips. */
106
+ export function renderBody(f: Finding): string {
107
+ const wcag = (f.wcag ?? []).map((s) => `WCAG ${s}`).join(", ") || "no WCAG mapping";
108
+ return `**a11y: \`${f.ruleId}\`** (${wcag})\n\n${f.message}\n\n${markerFor(f)}`;
109
+ }
110
+
111
+ /** The reconciliation plan: what to do to converge the PR to `findings`. */
112
+ export interface ReconcilePlan {
113
+ /** Findings with no existing comment — POST a new one. */
114
+ readonly create: readonly Finding[];
115
+ /** Findings whose comment exists but whose body drifted — PATCH in place. */
116
+ readonly update: readonly { readonly id: number; readonly finding: Finding }[];
117
+ /** Comment ids to DELETE: a fixed finding, or a leftover duplicate. */
118
+ readonly remove: readonly number[];
119
+ /** Comment ids left untouched (body already identical) — no API call. */
120
+ readonly unchanged: readonly number[];
121
+ }
122
+
123
+ /**
124
+ * Pure reconciliation: given the findings a run wants on the PR and the comments
125
+ * already there, decide the minimal set of create / update / delete operations
126
+ * that converges the PR to exactly one comment per finding. Idempotent — running
127
+ * it twice with the same inputs yields an all-`unchanged` plan (no churn).
128
+ *
129
+ * Only marker-carrying comments (ours) are considered; everything else is left
130
+ * alone. A pre-dedup PR may hold several comments for one key (the old spamming
131
+ * behavior); the canonical one (first seen) is kept/updated and the rest are
132
+ * folded into `remove`, so the very first reconciling run also cleans up.
133
+ */
134
+ export function reconcile(
135
+ findings: readonly Finding[],
136
+ existing: readonly ReviewComment[],
137
+ ): ReconcilePlan {
138
+ // Desired findings keyed by identity; first occurrence wins on collision.
139
+ const desired = new Map<string, Finding>();
140
+ for (const f of findings) {
141
+ const k = findingKey(f);
142
+ if (!desired.has(k)) desired.set(k, f);
143
+ }
144
+
145
+ // Our existing comments keyed by their marker. A key seen more than once is a
146
+ // leftover duplicate from before dedup existed — keep the first, delete the rest.
147
+ const ours = new Map<string, ReviewComment>();
148
+ const remove: number[] = [];
149
+ for (const c of existing) {
150
+ const k = keyOf(c.body);
151
+ if (k === null) continue; // not ours — never touch human comments
152
+ if (ours.has(k)) remove.push(c.id);
153
+ else ours.set(k, c);
154
+ }
155
+
156
+ const create: Finding[] = [];
157
+ const update: { id: number; finding: Finding }[] = [];
158
+ const unchanged: number[] = [];
159
+
160
+ for (const [k, f] of desired) {
161
+ const c = ours.get(k);
162
+ if (!c) create.push(f);
163
+ else if (c.body === renderBody(f)) unchanged.push(c.id);
164
+ else update.push({ id: c.id, finding: f });
165
+ }
166
+
167
+ // Any of our comments whose finding is no longer present was fixed → remove it.
168
+ for (const [k, c] of ours) {
169
+ if (!desired.has(k)) remove.push(c.id);
170
+ }
171
+
172
+ return { create, update, remove, unchanged };
173
+ }
174
+
175
+ /**
176
+ * The effectful surface reconciliation drives, injected so the orchestration is
177
+ * testable against an in-memory fake. Every method is best-effort: an
178
+ * implementation logs and swallows failures rather than throwing, so a single
179
+ * bad call never aborts the rest of the sync (the Action stays advisory).
180
+ */
181
+ export interface PrCommentClient {
182
+ /**
183
+ * All inline review comments currently on the PR (paginated upstream). MUST
184
+ * resolve to a COMPLETE view or THROW — never a partial list. If any page
185
+ * fetch fails, reconciling against the truncated result would read an existing
186
+ * comment (on an unfetched page) as absent and re-CREATE it → a duplicate. So
187
+ * a page failure aborts the whole sync (via {@link syncCommentsBestEffort},
188
+ * which swallows the throw and skips the run) rather than half-reconciling.
189
+ */
190
+ list(): Promise<ReviewComment[]>;
191
+ /** POST a new inline review comment for `f`. */
192
+ create(f: Finding): Promise<void>;
193
+ /** PATCH the body of comment `id` to match `f`. */
194
+ update(id: number, f: Finding): Promise<void>;
195
+ /** DELETE comment `id` (its finding was fixed, or it is a leftover duplicate). */
196
+ remove(id: number): Promise<void>;
197
+ }
198
+
199
+ /**
200
+ * Reconcile the PR's inline comments to `findings` through `client`, returning
201
+ * the plan that was executed (handy for logging and assertions). Updates in
202
+ * place and removes fixed findings instead of ever posting a duplicate.
203
+ */
204
+ export async function syncComments(
205
+ findings: readonly Finding[],
206
+ client: PrCommentClient,
207
+ log: (msg: string) => void = () => {},
208
+ ): Promise<ReconcilePlan> {
209
+ const existing = await client.list();
210
+ const plan = reconcile(findings, existing);
211
+
212
+ for (const f of plan.create) {
213
+ await client.create(f);
214
+ log(`created comment for ${findingKey(f)}`);
215
+ }
216
+ for (const { id, finding } of plan.update) {
217
+ await client.update(id, finding);
218
+ log(`updated comment ${id} for ${findingKey(finding)}`);
219
+ }
220
+ for (const id of plan.remove) {
221
+ await client.remove(id);
222
+ log(`removed comment ${id} (finding fixed or duplicate)`);
223
+ }
224
+ log(
225
+ `sync: ${plan.create.length} created, ${plan.update.length} updated, ` +
226
+ `${plan.remove.length} removed, ${plan.unchanged.length} unchanged`,
227
+ );
228
+ return plan;
229
+ }
230
+
231
+ /**
232
+ * The best-effort boundary the CI entrypoint runs: {@link syncComments} wrapped
233
+ * so it NEVER throws. Comment de-duplication is advisory — a failure (a
234
+ * partial-list abort, a mid-sync API error, a bad JSON parse) must log and skip
235
+ * the run, never fail the CI job. Returns the executed plan, or `null` when the
236
+ * sync was aborted/skipped. This is the invariant the top-level CLI relies on to
237
+ * always exit 0.
238
+ */
239
+ export async function syncCommentsBestEffort(
240
+ findings: readonly Finding[],
241
+ client: PrCommentClient,
242
+ log: (msg: string) => void = () => {},
243
+ ): Promise<ReconcilePlan | null> {
244
+ try {
245
+ return await syncComments(findings, client, log);
246
+ } catch (e) {
247
+ log(`sync aborted (best-effort, no-op): ${e instanceof Error ? e.message : String(e)}`);
248
+ return null;
249
+ }
250
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The thin CLI over the PR-summary rollup (issue #2132) that `entrypoint.sh`
3
+ * invokes (through the root `pr-summary.mjs` tsx wrapper) after a scan.
4
+ *
5
+ * It does two independent, best-effort things:
6
+ *
7
+ * 1. Writes a GitHub Actions **job summary** ($GITHUB_STEP_SUMMARY) with the
8
+ * run's rollup — ALWAYS, even with no PR context (push / manual dispatch),
9
+ * so the run page shows the shape of the run.
10
+ * 2. When a PR context + token are present, posts/updates the ONE rollup
11
+ * comment on the PR conversation — found by a stable marker and updated in
12
+ * place across pushes, never re-posted (the #2131 dedup discipline, one
13
+ * level up).
14
+ *
15
+ * Best-effort by design: any missing context, failed read, or failed API call is
16
+ * logged to stderr and skipped — never thrown — so the calling entrypoint still
17
+ * exits 0. The rollup posts through the shared identity resolver (issue #2130):
18
+ * the Binclusive GitHub App when configured, else the Action's own `GITHUB_TOKEN`
19
+ * — the SAME identity the inline comments post under (both go through
20
+ * `resolvePostingToken`, so the two comment surfaces never diverge).
21
+ *
22
+ * Env: GITHUB_TOKEN, GITHUB_REPOSITORY ("owner/name"), PR_NUMBER, HEAD_SHA,
23
+ * GITHUB_STEP_SUMMARY, GITHUB_SERVER_URL, GITHUB_API_URL (all optional; the
24
+ * job summary needs only GITHUB_STEP_SUMMARY, the comment needs the PR set);
25
+ * BINCLUSIVE_APP_ID / BINCLUSIVE_APP_PRIVATE_KEY (optional, opt into the
26
+ * branded App identity).
27
+ */
28
+ import { appendFileSync, readFileSync } from "node:fs";
29
+ import { resolvePostingToken } from "./github-identity";
30
+ import { type Finding, parseFindings } from "./pr-comment";
31
+ import {
32
+ computeRollup,
33
+ type RenderOptions,
34
+ renderJobSummary,
35
+ type RollupClient,
36
+ type RollupComment,
37
+ syncRollupBestEffort,
38
+ } from "./pr-summary";
39
+
40
+ const reportPath = process.argv[2];
41
+ const log = (msg: string): void => console.error(`pr-summary: ${msg}`);
42
+
43
+ if (!reportPath) {
44
+ log("no report path argument; skipping");
45
+ process.exit(0);
46
+ }
47
+
48
+ const loadFindings = (path: string): Finding[] => {
49
+ try {
50
+ return parseFindings(JSON.parse(readFileSync(path, "utf8")));
51
+ } catch (e) {
52
+ log(`could not read findings JSON: ${e instanceof Error ? e.message : String(e)}`);
53
+ return [];
54
+ }
55
+ };
56
+ const findings = loadFindings(reportPath);
57
+
58
+ const repo = process.env.GITHUB_REPOSITORY;
59
+ const headSha = process.env.HEAD_SHA;
60
+ const serverUrl = process.env.GITHUB_SERVER_URL || "https://github.com";
61
+
62
+ // Link each finding to its changed file at the run's head sha — the same local
63
+ // navigation the inline comments anchor on, not a wire-crossing source snippet.
64
+ const linkFor: RenderOptions["linkFor"] =
65
+ repo && headSha
66
+ ? (f: Finding) => `${serverUrl}/${repo}/blob/${headSha}/${encodeURI(f.file)}#L${f.line}`
67
+ : undefined;
68
+ const renderOpts: RenderOptions = linkFor ? { linkFor } : {};
69
+
70
+ // ---- 1. Job summary — always, even with no PR context ----------------------
71
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
72
+ if (summaryPath) {
73
+ try {
74
+ const rollup = computeRollup(findings);
75
+ appendFileSync(summaryPath, `${renderJobSummary(rollup, findings, renderOpts)}\n`);
76
+ log("wrote job summary");
77
+ } catch (e) {
78
+ // Best-effort: a failed summary write must never fail the job.
79
+ log(`job summary write failed (ignored): ${e instanceof Error ? e.message : String(e)}`);
80
+ }
81
+ } else {
82
+ log("no GITHUB_STEP_SUMMARY — skipping job summary");
83
+ }
84
+
85
+ // ---- 2. Rollup PR comment — only with a PR context -------------------------
86
+ const pr = process.env.PR_NUMBER;
87
+ const api = process.env.GITHUB_API_URL || "https://api.github.com";
88
+
89
+ if (!repo || !pr) {
90
+ log("no PR context — skipping rollup comment");
91
+ process.exit(0);
92
+ }
93
+
94
+ // Resolve WHO posts once, the SAME resolver the inline comments use, so both
95
+ // surfaces post under one identity. Never throws — degrades to GITHUB_TOKEN.
96
+ const { token, identity } = await resolvePostingToken(process.env, { repo, api }, log);
97
+ if (!token) {
98
+ log("no posting token (no GitHub App configured and no GITHUB_TOKEN) — skipping rollup comment");
99
+ process.exit(0);
100
+ }
101
+ log(`posting rollup comment as ${identity}`);
102
+
103
+ const headers: Record<string, string> = {
104
+ Authorization: `Bearer ${token}`,
105
+ Accept: "application/vnd.github+json",
106
+ "X-GitHub-Api-Version": "2022-11-28",
107
+ "User-Agent": "binclusive-a11y-agent",
108
+ "Content-Type": "application/json",
109
+ };
110
+
111
+ /**
112
+ * GitHub REST implementation over the PR-conversation (issue) comments endpoints
113
+ * — distinct from the inline review-comment endpoints the per-finding reconciler
114
+ * uses. `list` fetches a COMPLETE view or throws (a partial view could re-CREATE
115
+ * an existing rollup on an unfetched page); create/update/remove are best-effort.
116
+ */
117
+ const client: RollupClient = {
118
+ async list(): Promise<RollupComment[]> {
119
+ const out: RollupComment[] = [];
120
+ for (let page = 1; ; page++) {
121
+ const url = `${api}/repos/${repo}/issues/${pr}/comments?per_page=100&page=${page}`;
122
+ let res: Response;
123
+ try {
124
+ res = await fetch(url, { headers });
125
+ } catch (e) {
126
+ throw new Error(`list page ${page} fetch failed: ${e instanceof Error ? e.message : String(e)}`);
127
+ }
128
+ if (!res.ok) {
129
+ throw new Error(`list page ${page} -> ${res.status} ${(await res.text().catch(() => "")).slice(0, 200)}`);
130
+ }
131
+ let batch: unknown;
132
+ try {
133
+ batch = await res.json();
134
+ } catch (e) {
135
+ throw new Error(`list page ${page} JSON parse failed: ${e instanceof Error ? e.message : String(e)}`);
136
+ }
137
+ if (!Array.isArray(batch) || batch.length === 0) break;
138
+ for (const c of batch) {
139
+ if (c && typeof c === "object" && typeof (c as { id?: unknown }).id === "number" && typeof (c as { body?: unknown }).body === "string") {
140
+ out.push({ id: (c as { id: number }).id, body: (c as { body: string }).body });
141
+ }
142
+ }
143
+ if (batch.length < 100) break;
144
+ }
145
+ return out;
146
+ },
147
+
148
+ async create(body: string): Promise<void> {
149
+ const url = `${api}/repos/${repo}/issues/${pr}/comments`;
150
+ try {
151
+ const res = await fetch(url, { method: "POST", headers, body: JSON.stringify({ body }) });
152
+ if (!res.ok) log(`create rollup -> ${res.status} ${(await res.text()).slice(0, 200)}`);
153
+ } catch (e) {
154
+ log(`create rollup failed: ${e instanceof Error ? e.message : String(e)}`);
155
+ }
156
+ },
157
+
158
+ async update(id: number, body: string): Promise<void> {
159
+ const url = `${api}/repos/${repo}/issues/comments/${id}`;
160
+ try {
161
+ const res = await fetch(url, { method: "PATCH", headers, body: JSON.stringify({ body }) });
162
+ if (!res.ok) log(`update rollup ${id} -> ${res.status} ${(await res.text()).slice(0, 200)}`);
163
+ } catch (e) {
164
+ log(`update rollup ${id} failed: ${e instanceof Error ? e.message : String(e)}`);
165
+ }
166
+ },
167
+
168
+ async remove(id: number): Promise<void> {
169
+ const url = `${api}/repos/${repo}/issues/comments/${id}`;
170
+ try {
171
+ const res = await fetch(url, { method: "DELETE", headers });
172
+ if (!res.ok && res.status !== 404) log(`remove rollup ${id} -> ${res.status} ${(await res.text()).slice(0, 200)}`);
173
+ } catch (e) {
174
+ log(`remove rollup ${id} failed: ${e instanceof Error ? e.message : String(e)}`);
175
+ }
176
+ },
177
+ };
178
+
179
+ // Best-effort by contract: syncRollupBestEffort swallows any throw (a partial-list
180
+ // abort, a mid-sync API error) so the entrypoint always exits 0 — the rollup is
181
+ // advisory and must never fail the CI job.
182
+ await syncRollupBestEffort(findings, client, renderOpts, log);