@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,11 @@
1
+ /**
2
+ * Ambient types for `eslint-plugin-jsx-a11y`, which ships no `.d.ts` and has
3
+ * no `@types/` package on npm. We only consume it as an ESLint flat-config
4
+ * plugin, so we type it precisely against ESLint's own `ESLint.Plugin` shape
5
+ * rather than reaching for `any` (which the repo bans).
6
+ */
7
+ declare module "eslint-plugin-jsx-a11y" {
8
+ import type { ESLint } from "eslint";
9
+ const plugin: ESLint.Plugin;
10
+ export default plugin;
11
+ }
@@ -0,0 +1,308 @@
1
+ import baselineCatalog from "../data/baseline-rules.json" with { type: "json" };
2
+ import type { AxeImpact, Finding } from "./core";
3
+
4
+ export type { AxeImpact };
5
+
6
+ /**
7
+ * The evidence attached to a finding — a DISCRIMINATED UNION on `source`. The
8
+ * engine is pure detection: it cross-references a finding against the coverage
9
+ * catalog (axe-core's published per-rule metadata) and nothing else. Audit
10
+ * frequency is platform-derived (ADR 0041 §G — the corpus left the engine), so
11
+ * there is no `audit` variant and no finding carries a frequency `tier`.
12
+ *
13
+ * - `"baseline"` — axe-core's baseline catalog (`data/baseline-rules.json`)
14
+ * knows the rule. Coverage, NOT audit-frequency data: carries
15
+ * axe's `impact` + standard `fix` + `helpUrl`. Matched by the
16
+ * finding's SC, OR — for axe best-practice rules that carry no
17
+ * WCAG SC tag (`region`, `landmark-unique`, …) — by the axe
18
+ * ruleId, in which case `sc` is null and `bestPractice` is true
19
+ * (`sc === null` ⇔ `bestPractice`). An axe recommendation must
20
+ * never be dressed up with a fabricated SC.
21
+ * - `"none"` — the finding's ruleId is genuinely absent from the catalog
22
+ * (and no SC matched). It carries NO catalog evidence at all;
23
+ * any impact/helpUrl to display comes off the finding's own
24
+ * runtime axe metadata (read via {@link evidenceImpact} /
25
+ * {@link evidenceHelpUrl}), not off this variant.
26
+ *
27
+ * The axe-vs-SC DISPLAY policy ("for axe findings show the rule's own help, not
28
+ * the SC-generic fix") lives in exactly one place — {@link resolveDisplay} — not
29
+ * on this type and not in its consumers.
30
+ */
31
+ export type Evidence =
32
+ | {
33
+ readonly source: "baseline";
34
+ readonly sc: string | null;
35
+ readonly impact: AxeImpact;
36
+ readonly fix: string;
37
+ readonly helpUrl: string | null;
38
+ /** `sc === null` ⇔ `bestPractice` — an axe rule with no WCAG SC tag. */
39
+ readonly bestPractice: boolean;
40
+ }
41
+ | { readonly source: "none" };
42
+
43
+ /** A finding plus its coverage-catalog cross-reference. */
44
+ export interface EnrichedFinding extends Finding {
45
+ readonly corpus: Evidence;
46
+ }
47
+
48
+ /**
49
+ * A baseline-catalog rule, narrowed from `data/baseline-rules.json`. Mirrors the
50
+ * generator's `BaselineRule` shape. This is the COVERAGE source of truth (axe's
51
+ * published per-rule metadata); it never carries an org count or a frequency tier.
52
+ */
53
+ interface BaselineRuleEntry {
54
+ readonly ruleId: string;
55
+ readonly sc: readonly string[];
56
+ readonly impact: AxeImpact;
57
+ readonly help: string;
58
+ readonly helpUrl: string;
59
+ }
60
+
61
+ /**
62
+ * Narrow the baseline catalog (loaded as `unknown` at the JSON boundary) into
63
+ * two indexes: by axe ruleId (for axe findings, the exact rule) and by SC (for
64
+ * source-pass findings, which carry an SC but no axe ruleId). For an SC mapped by
65
+ * several rules, the first in the catalog's deterministic (ruleId-sorted) order
66
+ * wins — stable across regenerations. Each entry is validated structurally so a
67
+ * malformed file fails loud rather than smuggling `any` inward.
68
+ */
69
+ function readBaseline(raw: unknown): {
70
+ byRule: ReadonlyMap<string, BaselineRuleEntry>;
71
+ bySc: ReadonlyMap<string, BaselineRuleEntry>;
72
+ } {
73
+ const byRule = new Map<string, BaselineRuleEntry>();
74
+ const bySc = new Map<string, BaselineRuleEntry>();
75
+ const isImpact = (s: unknown): s is AxeImpact =>
76
+ s === "minor" || s === "moderate" || s === "serious" || s === "critical";
77
+
78
+ if (typeof raw !== "object" || raw === null || !("rules" in raw)) return { byRule, bySc };
79
+ const list = (raw as { rules: unknown }).rules;
80
+ if (!Array.isArray(list)) return { byRule, bySc };
81
+
82
+ for (const r of list) {
83
+ if (typeof r !== "object" || r === null) continue;
84
+ const { ruleId, sc, impact, help, helpUrl } = r as Record<string, unknown>;
85
+ if (
86
+ typeof ruleId !== "string" ||
87
+ !Array.isArray(sc) ||
88
+ !isImpact(impact) ||
89
+ typeof help !== "string" ||
90
+ typeof helpUrl !== "string"
91
+ ) {
92
+ continue;
93
+ }
94
+ const scList = sc.filter((s): s is string => typeof s === "string");
95
+ const entry: BaselineRuleEntry = { ruleId, sc: scList, impact, help, helpUrl };
96
+ byRule.set(ruleId, entry);
97
+ for (const oneSc of scList) {
98
+ if (!bySc.has(oneSc)) bySc.set(oneSc, entry);
99
+ }
100
+ }
101
+ return { byRule, bySc };
102
+ }
103
+
104
+ const BASELINE = readBaseline(baselineCatalog);
105
+
106
+ /**
107
+ * Find the first of a finding's SCs that the baseline catalog knows, with the
108
+ * matched entry. Source-pass findings (jsx-a11y / enforce) and WCAG-tagged axe
109
+ * findings resolve here. Finding order is preserved (first known SC wins).
110
+ */
111
+ function baselineBySc(finding: Finding): { sc: string; entry: BaselineRuleEntry } | null {
112
+ for (const sc of finding.wcag) {
113
+ const entry = BASELINE.bySc.get(sc);
114
+ if (entry !== undefined) return { sc, entry };
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Cross-reference a finding against the coverage catalog, most-authoritative
121
+ * first, so every finding surfaces with an impact and a fix instead of
122
+ * dead-ending at null:
123
+ *
124
+ * 1. BASELINE (by SC) — coverage for the finding's WCAG SCs. axe's published
125
+ * per-rule impact + standard fix + helpUrl. → `source: "baseline"`,
126
+ * `bestPractice: false`.
127
+ * 2. BASELINE (by ruleId) — the axe best-practice rules that carry NO WCAG SC
128
+ * tag (`region`, `landmark-unique`, …). Matched by the finding's axe ruleId,
129
+ * reported honestly: `sc: null`, `bestPractice: true`, still carrying
130
+ * impact + fix + helpUrl. → `source: "baseline"`.
131
+ * 3. NONE — the ruleId is genuinely absent from the catalog (and no SC
132
+ * matched). An axe finding may still surface its own runtime impact/helpUrl.
133
+ *
134
+ * For axe findings the runtime impact already on the finding always wins over
135
+ * the catalog's static impact.
136
+ */
137
+ export function enrich(finding: Finding): EnrichedFinding {
138
+ // 1. BASELINE by SC — coverage for the finding's WCAG SCs. Runtime axe impact
139
+ // (most accurate) wins over the catalog default.
140
+ const bySc = baselineBySc(finding);
141
+ if (bySc !== null) {
142
+ return withEvidence(finding, {
143
+ source: "baseline",
144
+ sc: bySc.sc,
145
+ fix: bySc.entry.help,
146
+ impact: finding.impact ?? bySc.entry.impact,
147
+ helpUrl: finding.helpUrl ?? bySc.entry.helpUrl,
148
+ bestPractice: false,
149
+ });
150
+ }
151
+
152
+ // 2. BASELINE by ruleId — axe best-practice rules with NO WCAG SC tag. The
153
+ // catalog knows the rule even though `bySc` missed; report it honestly with
154
+ // `sc: null` + `bestPractice: true` rather than dropping to NONE. (A catalog
155
+ // rule WITH an SC would have matched by SC above.)
156
+ const byRule = BASELINE.byRule.get(finding.ruleId);
157
+ if (byRule !== undefined) {
158
+ return withEvidence(finding, {
159
+ source: "baseline",
160
+ sc: byRule.sc[0] ?? null,
161
+ fix: byRule.help,
162
+ impact: finding.impact ?? byRule.impact,
163
+ helpUrl: finding.helpUrl ?? byRule.helpUrl,
164
+ bestPractice: byRule.sc.length === 0,
165
+ });
166
+ }
167
+
168
+ // 3. NONE — the ruleId is absent from the catalog and no SC matched. No catalog
169
+ // evidence; any displayable impact/helpUrl comes off the finding.
170
+ return withEvidence(finding, { source: "none" });
171
+ }
172
+
173
+ /** Attach an evidence variant to a finding. The one place the two join. */
174
+ function withEvidence(finding: Finding, evidence: Evidence): EnrichedFinding {
175
+ return { ...finding, corpus: evidence };
176
+ }
177
+
178
+ /** Enrich a batch of findings. */
179
+ export function enrichAll(findings: readonly Finding[]): EnrichedFinding[] {
180
+ return findings.map(enrich);
181
+ }
182
+
183
+ /** The SC-keyed fix where one exists (baseline), else null. */
184
+ export function evidenceFix(c: Evidence): string | null {
185
+ switch (c.source) {
186
+ case "baseline":
187
+ return c.fix;
188
+ case "none":
189
+ return null;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * The impact to display for a finding: the catalog/runtime value the variant
195
+ * carries, falling back to the finding's own runtime axe impact for `none`.
196
+ */
197
+ export function evidenceImpact(f: EnrichedFinding): AxeImpact | null {
198
+ const c = f.corpus;
199
+ switch (c.source) {
200
+ case "baseline":
201
+ return c.impact;
202
+ case "none":
203
+ return f.impact ?? null;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * The Deque help URL to display for a finding: the catalog/runtime value the
209
+ * variant carries, falling back to the finding's own runtime URL for `none`.
210
+ */
211
+ export function evidenceHelpUrl(f: EnrichedFinding): string | null {
212
+ const c = f.corpus;
213
+ switch (c.source) {
214
+ case "baseline":
215
+ return c.helpUrl;
216
+ case "none":
217
+ return f.helpUrl ?? null;
218
+ }
219
+ }
220
+
221
+ /** Whether this is an axe best-practice recommendation (no WCAG SC). */
222
+ export function evidenceBestPractice(c: Evidence): boolean {
223
+ return c.source === "baseline" && c.bestPractice;
224
+ }
225
+
226
+ /**
227
+ * The resolved DISPLAY contract for a finding — the SOLE owner of the axe-vs-SC
228
+ * policy. Every consumer (the CLI `detailLines` printer, the MCP `CheckFinding`)
229
+ * reads this instead of re-deriving the policy, so they can never disagree.
230
+ *
231
+ * The policy: for a `provenance === "axe"` (rendered-DOM) finding the baseline
232
+ * `fix` is SC-GENERIC, so for axe findings we show the rule's own per-rule help
233
+ * (`message`)/`ref` instead. For source findings (`jsx-a11y` / `enforce`) the
234
+ * rule↔SC mapping is clean via wcag-map, so the baseline `fix` is rule-accurate
235
+ * and shown verbatim.
236
+ */
237
+ export interface DisplayContract {
238
+ /** Uppercased impact for the `impact:` line, or null to omit it. */
239
+ readonly impactLabel: string | null;
240
+ /** Text for the CLI `fix:` line, or null to suppress it. */
241
+ readonly fixLine: string | null;
242
+ /**
243
+ * The rule-accurate fix string for API emission (MCP `CheckFinding.fix`): axe
244
+ * findings get axe's per-rule help (`message`); source findings get the
245
+ * baseline `fix`. Unlike `fixLine` this is never suppressed.
246
+ */
247
+ readonly fix: string | null;
248
+ /** The Deque help URL to render as a `ref:` line, or null to omit it. */
249
+ readonly refUrl: string | null;
250
+ }
251
+
252
+ export function resolveDisplay(f: EnrichedFinding): DisplayContract {
253
+ const c = f.corpus;
254
+ const isAxe = f.provenance === "axe";
255
+ const impact = evidenceImpact(f);
256
+ // axe → rule-accurate help (its own message); source → SC-keyed baseline fix.
257
+ const ruleFix = isAxe ? (f.message ?? null) : evidenceFix(c);
258
+ // A baseline fix is axe's per-rule help (rule-accurate), so it is shown for axe
259
+ // and source alike; `none` never carries a fix line.
260
+ const fixLine = evidenceFix(c);
261
+ // `ref:` shows for every axe finding, and for every finding carrying a help URL.
262
+ const refUrl = evidenceHelpUrl(f);
263
+ return {
264
+ impactLabel: impact === null ? null : impact.toUpperCase(),
265
+ fixLine,
266
+ fix: ruleFix,
267
+ refUrl,
268
+ };
269
+ }
270
+
271
+ /**
272
+ * A baseline-catalog rule surfaced for `get_a11y_rules`: axe's published
273
+ * per-rule data (ruleId, SC, impact, standard fix, helpUrl) for ANY axe/WCAG
274
+ * rule. Carries NO org count and NO frequency tier (it is not audit data).
275
+ */
276
+ export interface BaselineRuleInfo {
277
+ readonly ruleId: string;
278
+ readonly sc: readonly string[];
279
+ readonly impact: AxeImpact;
280
+ readonly fix: string;
281
+ readonly helpUrl: string;
282
+ }
283
+
284
+ /**
285
+ * Look up baseline rules by axe ruleId substring and/or WCAG SC. With no filter,
286
+ * returns the whole catalog (already ruleId-sorted, deterministic). Pure read
287
+ * over `baseline-rules.json`.
288
+ */
289
+ export function baselineRules(filter: { ruleId?: string; sc?: string } = {}): BaselineRuleInfo[] {
290
+ const toInfo = (e: BaselineRuleEntry): BaselineRuleInfo => ({
291
+ ruleId: e.ruleId,
292
+ sc: e.sc,
293
+ impact: e.impact,
294
+ fix: e.help,
295
+ helpUrl: e.helpUrl,
296
+ });
297
+
298
+ let entries = [...BASELINE.byRule.values()];
299
+ const ruleNeedle = filter.ruleId?.trim().toLowerCase();
300
+ if (ruleNeedle !== undefined && ruleNeedle !== "") {
301
+ entries = entries.filter((e) => e.ruleId.toLowerCase().includes(ruleNeedle));
302
+ }
303
+ const scNeedle = filter.sc?.trim();
304
+ if (scNeedle !== undefined && scNeedle !== "") {
305
+ entries = entries.filter((e) => e.sc.includes(scNeedle));
306
+ }
307
+ return entries.map(toInfo);
308
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The one identity resolver both PR-comment surfaces authenticate through (issue
3
+ * #2130). Inline per-finding comments (the GitHub reporter, `reporter/github-adapter.ts`)
4
+ * and the single rollup comment (`pr-summary-cli.ts`) MUST post under the *same*
5
+ * GitHub identity, so the auth decision lives here once rather than duplicated at each callsite.
6
+ *
7
+ * Default-safe by construction — the load-bearing invariant:
8
+ *
9
+ * - Binclusive GitHub App credentials configured (App id + private key in env) →
10
+ * mint a short-lived installation access token and post under the branded App
11
+ * identity (name + avatar), not `github-actions[bot]`.
12
+ * - App credentials ABSENT → return the Action's own `GITHUB_TOKEN` unchanged.
13
+ * Unset ⇒ byte-for-byte the current behavior, so this is safe to merge before
14
+ * the App exists.
15
+ *
16
+ * Non-blocking, always: any mint failure (a bad key, a missing installation, a
17
+ * network error, a non-2xx from GitHub) is logged and FALLS BACK to the default
18
+ * token — the resolver never throws, so a failed identity swap never gates the
19
+ * merge. A failed brand is a lost avatar, never a failed check.
20
+ *
21
+ * No new runtime dependency: the App JWT is RS256-signed with node's built-in
22
+ * `crypto`, and every GitHub call goes through the same global `fetch` the comment
23
+ * clients already use. The signer and fetch are injected ({@link MintDeps}) so both
24
+ * branches — App-configured and App-absent — are unit-testable without a real key
25
+ * or a live network.
26
+ */
27
+ import { createSign } from "node:crypto";
28
+
29
+ /** Which identity a resolved token posts under. */
30
+ export type PostingIdentity = "binclusive-github-app" | "github-actions";
31
+
32
+ /** A resolved posting token paired with the identity GitHub will attribute it to. */
33
+ export interface TokenResolution {
34
+ /** The bearer token to authenticate the comment POST/PATCH/DELETE calls with. */
35
+ readonly token: string;
36
+ /** `binclusive-github-app` when a branded installation token was minted, else `github-actions`. */
37
+ readonly identity: PostingIdentity;
38
+ }
39
+
40
+ /**
41
+ * Binclusive GitHub App credentials, present only when the customer installed the
42
+ * App and supplied its id + private key. `installationId` is optional — when
43
+ * absent it is discovered from the repo the run targets.
44
+ */
45
+ export interface AppConfig {
46
+ readonly appId: string;
47
+ readonly privateKey: string;
48
+ readonly installationId?: string;
49
+ }
50
+
51
+ /** The repo + API host a token is being resolved for. */
52
+ export interface ResolveContext {
53
+ /** `owner/name`, from `GITHUB_REPOSITORY`. */
54
+ readonly repo: string;
55
+ /** The GitHub REST base, e.g. `https://api.github.com` (GHES uses a different host). */
56
+ readonly api: string;
57
+ }
58
+
59
+ /**
60
+ * Injected effects so both branches are testable offline: `fetchImpl` is the HTTP
61
+ * surface (real global `fetch` in prod, a fake in tests), and `mintJwt` produces
62
+ * the App JWT (real RS256 signer in prod, a stub in tests so no key is needed).
63
+ */
64
+ export interface MintDeps {
65
+ readonly fetchImpl: typeof fetch;
66
+ readonly mintJwt: (appId: string, privateKey: string) => string;
67
+ }
68
+
69
+ const APP_ID_ENV = "BINCLUSIVE_APP_ID";
70
+ const APP_KEY_ENV = "BINCLUSIVE_APP_PRIVATE_KEY";
71
+ const APP_INSTALL_ENV = "BINCLUSIVE_APP_INSTALLATION_ID";
72
+
73
+ /**
74
+ * Read the App credentials from the environment, or `null` when unconfigured
75
+ * (either secret missing/blank). `null` is the default-safe signal that routes
76
+ * the resolver straight to the `GITHUB_TOKEN` fallback — the App absence is the
77
+ * common case until Can creates and installs the App.
78
+ *
79
+ * The private key is stored in a secret as PEM; some secret stores collapse its
80
+ * newlines to the literal `\n` escape, so those are restored — a no-op on a key
81
+ * that already carries real newlines.
82
+ */
83
+ export function readAppConfig(env: NodeJS.ProcessEnv): AppConfig | null {
84
+ const appId = env[APP_ID_ENV]?.trim();
85
+ const rawKey = env[APP_KEY_ENV];
86
+ if (!appId || !rawKey || rawKey.trim() === "") return null;
87
+ const privateKey = rawKey.includes("\\n") ? rawKey.replace(/\\n/g, "\n") : rawKey;
88
+ const installationId = env[APP_INSTALL_ENV]?.trim();
89
+ return { appId, privateKey, ...(installationId ? { installationId } : {}) };
90
+ }
91
+
92
+ function base64url(input: string): string {
93
+ return Buffer.from(input, "utf8").toString("base64url");
94
+ }
95
+
96
+ /**
97
+ * RS256-sign a GitHub App JWT (App-authentication credential) with node's built-in
98
+ * `crypto` — no JWT library. `iat` is backdated 60s to absorb clock skew between
99
+ * the runner and GitHub, and `exp` is capped at 10 minutes (GitHub rejects longer);
100
+ * the JWT is used only to immediately mint an installation token, then discarded.
101
+ */
102
+ export function mintAppJwt(appId: string, privateKey: string, now: () => number = Date.now): string {
103
+ const iat = Math.floor(now() / 1000) - 60;
104
+ const exp = iat + 600;
105
+ const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
106
+ const payload = base64url(JSON.stringify({ iat, exp, iss: appId }));
107
+ const signingInput = `${header}.${payload}`;
108
+ const signer = createSign("RSA-SHA256");
109
+ signer.update(signingInput);
110
+ signer.end();
111
+ const signature = signer.sign(privateKey).toString("base64url");
112
+ return `${signingInput}.${signature}`;
113
+ }
114
+
115
+ /** The real signer + global fetch — the production {@link MintDeps}. */
116
+ export const defaultMintDeps: MintDeps = {
117
+ fetchImpl: (...args) => fetch(...args),
118
+ mintJwt: (appId, privateKey) => mintAppJwt(appId, privateKey),
119
+ };
120
+
121
+ function appJwtHeaders(jwt: string): Record<string, string> {
122
+ return {
123
+ Authorization: `Bearer ${jwt}`,
124
+ Accept: "application/vnd.github+json",
125
+ "X-GitHub-Api-Version": "2022-11-28",
126
+ "User-Agent": "binclusive-a11y-agent",
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Mint a short-lived installation access token for the App on `ctx.repo`. Throws on
132
+ * any failure (missing installation, non-2xx, unparseable body) so the caller's
133
+ * try/catch routes to the default-token fallback — this function never silently
134
+ * returns a bad token. When `installationId` is absent it is discovered from the
135
+ * repo, so a customer only needs to install the App, not look up its id.
136
+ */
137
+ export async function mintInstallationToken(
138
+ app: AppConfig,
139
+ ctx: ResolveContext,
140
+ deps: MintDeps,
141
+ ): Promise<string> {
142
+ const jwt = deps.mintJwt(app.appId, app.privateKey);
143
+ const headers = appJwtHeaders(jwt);
144
+
145
+ let installationId = app.installationId;
146
+ if (!installationId) {
147
+ const res = await deps.fetchImpl(`${ctx.api}/repos/${ctx.repo}/installation`, { headers });
148
+ if (!res.ok) {
149
+ throw new Error(`installation lookup -> ${res.status} ${(await res.text().catch(() => "")).slice(0, 200)}`);
150
+ }
151
+ const body: unknown = await res.json();
152
+ const id = (body as { id?: unknown })?.id;
153
+ if (typeof id !== "number" && typeof id !== "string") {
154
+ throw new Error("installation lookup returned no id");
155
+ }
156
+ installationId = String(id);
157
+ }
158
+
159
+ const res = await deps.fetchImpl(`${ctx.api}/app/installations/${installationId}/access_tokens`, {
160
+ method: "POST",
161
+ headers,
162
+ });
163
+ if (!res.ok) {
164
+ throw new Error(`installation token -> ${res.status} ${(await res.text().catch(() => "")).slice(0, 200)}`);
165
+ }
166
+ const body: unknown = await res.json();
167
+ const token = (body as { token?: unknown })?.token;
168
+ if (typeof token !== "string" || token === "") {
169
+ throw new Error("installation token response carried no token");
170
+ }
171
+ return token;
172
+ }
173
+
174
+ /**
175
+ * The single entrypoint both comment surfaces call to decide *who* posts. Returns
176
+ * the branded App identity + a freshly minted installation token when the App is
177
+ * configured and the mint succeeds; otherwise the Action's own `GITHUB_TOKEN`
178
+ * under the `github-actions` identity — the zero-behavior-change default. Never
179
+ * throws: a mint failure logs and degrades to the default token so the run exits 0.
180
+ */
181
+ export async function resolvePostingToken(
182
+ env: NodeJS.ProcessEnv,
183
+ ctx: ResolveContext,
184
+ log: (msg: string) => void = () => {},
185
+ deps: MintDeps = defaultMintDeps,
186
+ ): Promise<TokenResolution> {
187
+ const defaultToken = env.GITHUB_TOKEN ?? "";
188
+ const app = readAppConfig(env);
189
+ if (!app) {
190
+ return { token: defaultToken, identity: "github-actions" };
191
+ }
192
+ try {
193
+ const token = await mintInstallationToken(app, ctx, deps);
194
+ log("posting under the Binclusive GitHub App identity");
195
+ return { token, identity: "binclusive-github-app" };
196
+ } catch (e) {
197
+ // Non-blocking: a failed brand falls back to the default token, never the run.
198
+ log(`GitHub App token mint failed, falling back to GITHUB_TOKEN: ${e instanceof Error ? e.message : String(e)}`);
199
+ return { token: defaultToken, identity: "github-actions" };
200
+ }
201
+ }