@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,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
|
+
}
|
package/src/evidence.ts
ADDED
|
@@ -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
|
+
}
|