@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,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform-adapter seam (issue #2235) — the ONE contract two CI platforms
|
|
3
|
+
* plug into, so a second platform is added behind this boundary instead of
|
|
4
|
+
* forking `entrypoint.sh`. It has two halves:
|
|
5
|
+
*
|
|
6
|
+
* 1. a DIFF-CONTEXT RESOLVER — "what changed + where to post", derived from the
|
|
7
|
+
* platform's environment (the GitHub adapter reads `GITHUB_EVENT_PATH`-derived
|
|
8
|
+
* env; a future GitLab/Buildkite adapter reads its own), and
|
|
9
|
+
* 2. a FINDINGS REPORTER — consumes the canonical `@binclusive/a11y-contract`
|
|
10
|
+
* finding shape ({@link Finding}, the engine's `check --json` output) and
|
|
11
|
+
* surfaces it on the platform's native review UI.
|
|
12
|
+
*
|
|
13
|
+
* An adapter pairs the two over ONE platform-native post-target type `Ctx`
|
|
14
|
+
* ({@link PlatformAdapter}). The `Ctx` never crosses the seam: {@link bindAdapter}
|
|
15
|
+
* erases it into a {@link BoundAdapter} by capturing the resolver's target in the
|
|
16
|
+
* closure it hands to the reporter, so a heterogeneous registry
|
|
17
|
+
* ({@link AdapterRegistry}) can store adapters of different `Ctx` types with no
|
|
18
|
+
* cast and no `any`.
|
|
19
|
+
*
|
|
20
|
+
* Opt-in by construction: when the resolver finds no PR/MR context (no token / not
|
|
21
|
+
* a PR event) it yields a `null` post-target, and {@link dispatch} no-ops the
|
|
22
|
+
* reporter — artifacts still emit, the gate still exits 0.
|
|
23
|
+
*/
|
|
24
|
+
import { type Finding, parseFindings } from "./finding";
|
|
25
|
+
|
|
26
|
+
export { type Finding, parseFindings };
|
|
27
|
+
export type { Impact } from "./finding";
|
|
28
|
+
|
|
29
|
+
/** A best-effort logger; adapters log to stderr and never throw. */
|
|
30
|
+
export type Logger = (msg: string) => void;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Half 1 of the contract. Resolve the platform's change-context from the process
|
|
34
|
+
* env: the changed `.tsx` files to scan and the platform-native post-target, or
|
|
35
|
+
* `null` when there is no PR/MR context (⇒ the reporter no-ops). `Ctx` is the
|
|
36
|
+
* adapter's own post-target type — opaque to the seam.
|
|
37
|
+
*/
|
|
38
|
+
export interface DiffContextResolver<Ctx> {
|
|
39
|
+
resolve(env: NodeJS.ProcessEnv): DiffContext<Ctx>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The resolved change-context: what changed + where to post (if anywhere). */
|
|
43
|
+
export interface DiffContext<Ctx> {
|
|
44
|
+
/** Changed `.tsx` paths to scan; empty ⇒ the caller falls back to a wholesale scan. */
|
|
45
|
+
readonly changedTsx: readonly string[];
|
|
46
|
+
/**
|
|
47
|
+
* The platform-native surface findings post to, or `null` when no PR/MR context
|
|
48
|
+
* is present. `null` is the opt-in no-op signal — the reporter is never invoked.
|
|
49
|
+
*/
|
|
50
|
+
readonly postTarget: Ctx | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Half 2 of the contract. Surface `findings` on the platform's native review UI,
|
|
55
|
+
* posting to the resolved `target`. Best-effort: an implementation logs and
|
|
56
|
+
* swallows failures rather than throwing, so a reporter error never fails the
|
|
57
|
+
* advisory gate.
|
|
58
|
+
*/
|
|
59
|
+
export interface FindingsReporter<Ctx> {
|
|
60
|
+
report(findings: readonly Finding[], target: Ctx, log: Logger): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A platform adapter pairs the two halves over one post-target type `Ctx`. */
|
|
64
|
+
export interface PlatformAdapter<Ctx> {
|
|
65
|
+
/** The explicit platform key used to select this adapter (e.g. `github`, `null`). */
|
|
66
|
+
readonly key: string;
|
|
67
|
+
readonly resolver: DiffContextResolver<Ctx>;
|
|
68
|
+
readonly reporter: FindingsReporter<Ctx>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A reporter already bound to a resolved target — a thunk that posts `findings`,
|
|
73
|
+
* or `null` when there is no post-target (the opt-in no-op).
|
|
74
|
+
*/
|
|
75
|
+
export type BoundReporter = (findings: readonly Finding[], log: Logger) => Promise<void>;
|
|
76
|
+
|
|
77
|
+
/** The Ctx-erased adapter the registry stores and {@link dispatch} drives. */
|
|
78
|
+
export interface BoundAdapter {
|
|
79
|
+
readonly key: string;
|
|
80
|
+
/** Resolve change-context from env, returning changed files + a bound reporter (null ⇒ no-op). */
|
|
81
|
+
resolve(env: NodeJS.ProcessEnv): { readonly changedTsx: readonly string[]; readonly report: BoundReporter | null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Erase an adapter's `Ctx` by pairing its resolver with its reporter in a
|
|
86
|
+
* closure: resolve the target once, then partially-apply the reporter into a
|
|
87
|
+
* {@link BoundReporter} that captures it. The `Ctx` never appears in
|
|
88
|
+
* {@link BoundAdapter}'s type, so adapters of different post-target types are
|
|
89
|
+
* assignment-compatible in one registry — with no cast (the narrowed `target`
|
|
90
|
+
* is captured in a const, not asserted).
|
|
91
|
+
*/
|
|
92
|
+
export function bindAdapter<Ctx>(adapter: PlatformAdapter<Ctx>): BoundAdapter {
|
|
93
|
+
return {
|
|
94
|
+
key: adapter.key,
|
|
95
|
+
resolve(env) {
|
|
96
|
+
const ctx = adapter.resolver.resolve(env);
|
|
97
|
+
if (ctx.postTarget === null) {
|
|
98
|
+
return { changedTsx: ctx.changedTsx, report: null };
|
|
99
|
+
}
|
|
100
|
+
const target = ctx.postTarget;
|
|
101
|
+
const report: BoundReporter = (findings, log) => adapter.reporter.report(findings, target, log);
|
|
102
|
+
return { changedTsx: ctx.changedTsx, report };
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A registry of bound adapters, selectable by explicit platform key. */
|
|
108
|
+
export class AdapterRegistry {
|
|
109
|
+
private readonly byKey: Map<string, BoundAdapter>;
|
|
110
|
+
|
|
111
|
+
constructor(adapters: readonly BoundAdapter[]) {
|
|
112
|
+
this.byKey = new Map(adapters.map((a) => [a.key, a]));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Select an adapter by its explicit platform key; `undefined` when unknown. */
|
|
116
|
+
select(key: string): BoundAdapter | undefined {
|
|
117
|
+
return this.byKey.get(key);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The keys of every registered adapter (for diagnostics / listing). */
|
|
121
|
+
keys(): string[] {
|
|
122
|
+
return [...this.byKey.keys()];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Drive one bound adapter: given the findings and the resolved change-context,
|
|
128
|
+
* invoke the reporter — or no-op when the resolver yielded no post-target. This
|
|
129
|
+
* is the dispatch the CLI runs; it locks the seam's opt-in contract in one place.
|
|
130
|
+
*/
|
|
131
|
+
export async function dispatch(
|
|
132
|
+
resolved: { readonly report: BoundReporter | null },
|
|
133
|
+
findings: readonly Finding[],
|
|
134
|
+
log: Logger,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
if (resolved.report === null) {
|
|
137
|
+
log("no post context — reporter no-op (findings still emitted)");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
await resolved.report(findings, log);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The default platform when no explicit key is given — GitHub, so the shipped Action is behavior-preserving. */
|
|
144
|
+
export const DEFAULT_PLATFORM_KEY = "github";
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve the platform key from env (or a CLI flag value passed as `override`),
|
|
148
|
+
* defaulting to {@link DEFAULT_PLATFORM_KEY}. `A11Y_PLATFORM` is the explicit
|
|
149
|
+
* selector; a bare, empty, or unset value falls through to the default.
|
|
150
|
+
*/
|
|
151
|
+
export function resolvePlatformKey(env: NodeJS.ProcessEnv, override?: string): string {
|
|
152
|
+
const explicit = override && override !== "" ? override : env.A11Y_PLATFORM;
|
|
153
|
+
return explicit && explicit !== "" ? explicit : DEFAULT_PLATFORM_KEY;
|
|
154
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical finding shape a reporter consumes — the engine's `check --json`
|
|
3
|
+
* output (ADR 0039, `@binclusive/a11y-contract`). This is the platform-NEUTRAL
|
|
4
|
+
* input every {@link FindingsReporter} takes; it carries the source locators
|
|
5
|
+
* (`ruleId`/`file`/`line`) an inline review surface anchors on, which the
|
|
6
|
+
* metadata-only wire DTO (`emit-contract.ts`) deliberately drops.
|
|
7
|
+
*
|
|
8
|
+
* Lives here (not in a platform adapter) so the seam owns its own input type and
|
|
9
|
+
* no adapter is the canonical home of the shape all adapters share. `pr-comment.ts`
|
|
10
|
+
* re-exports these for its existing importers.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Impact } from "@binclusive/a11y-contract";
|
|
14
|
+
|
|
15
|
+
/** The contract's canonical impact scale — the `Impact` enum in `@binclusive/a11y-contract`. */
|
|
16
|
+
export type { Impact };
|
|
17
|
+
|
|
18
|
+
/** The subset of an a11y finding a reporter renders from — the `check --json` shape. */
|
|
19
|
+
export interface Finding {
|
|
20
|
+
readonly ruleId: string;
|
|
21
|
+
readonly file: string;
|
|
22
|
+
readonly line: number;
|
|
23
|
+
readonly message: string;
|
|
24
|
+
readonly wcag?: readonly string[];
|
|
25
|
+
/**
|
|
26
|
+
* The finding's contract impact, carried through from the report so a
|
|
27
|
+
* platform rollup can count by it. Optional: a report predating the field
|
|
28
|
+
* simply buckets the finding as unclassified.
|
|
29
|
+
*/
|
|
30
|
+
readonly impact?: Impact;
|
|
31
|
+
/** The WCAG success-criterion id (contract `criterion`), e.g. "1.4.3". */
|
|
32
|
+
readonly criterion?: string;
|
|
33
|
+
/**
|
|
34
|
+
* The CSS selector of the offending rendered element, on axe/DOM findings only
|
|
35
|
+
* (source passes anchor by `file:line` and omit it). It is what distinguishes
|
|
36
|
+
* two same-rule findings co-located at one `file:line`.
|
|
37
|
+
*/
|
|
38
|
+
readonly selector?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Narrow an unknown report value to the contract's impact enum. */
|
|
42
|
+
export function isImpact(value: unknown): value is Impact {
|
|
43
|
+
return (
|
|
44
|
+
value === "critical" ||
|
|
45
|
+
value === "serious" ||
|
|
46
|
+
value === "moderate" ||
|
|
47
|
+
value === "minor" ||
|
|
48
|
+
value === "unknown"
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Boundary parse of the engine's findings JSON into the minimal shape a reporter
|
|
54
|
+
* renders from. Unknown in, narrowed out — a malformed entry is dropped rather
|
|
55
|
+
* than smuggling `any` inward (Parse-Don't-Validate at the reporter boundary).
|
|
56
|
+
*/
|
|
57
|
+
export function parseFindings(raw: unknown): Finding[] {
|
|
58
|
+
if (typeof raw !== "object" || raw === null) return [];
|
|
59
|
+
const findings = (raw as { findings?: unknown }).findings;
|
|
60
|
+
if (!Array.isArray(findings)) return [];
|
|
61
|
+
const out: Finding[] = [];
|
|
62
|
+
for (const item of findings) {
|
|
63
|
+
if (typeof item !== "object" || item === null) continue;
|
|
64
|
+
const f = item as Record<string, unknown>;
|
|
65
|
+
if (typeof f.ruleId !== "string" || typeof f.file !== "string") continue;
|
|
66
|
+
if (typeof f.line !== "number") continue;
|
|
67
|
+
const wcag = Array.isArray(f.wcag) ? f.wcag.filter((w): w is string => typeof w === "string") : undefined;
|
|
68
|
+
// Keep the selector across the boundary — it is what distinguishes co-located
|
|
69
|
+
// same-rule findings; dropping it here reintroduces the collision.
|
|
70
|
+
const selector = typeof f.selector === "string" ? f.selector : undefined;
|
|
71
|
+
const impact = isImpact(f.impact) ? f.impact : undefined;
|
|
72
|
+
// criterion is the contract's SC id; fall back to the first wcag tag so an
|
|
73
|
+
// older report (no criterion field) still yields a by-WCAG breakdown.
|
|
74
|
+
const criterion = typeof f.criterion === "string" && f.criterion !== "" ? f.criterion : wcag?.[0];
|
|
75
|
+
out.push({
|
|
76
|
+
ruleId: f.ruleId,
|
|
77
|
+
file: f.file,
|
|
78
|
+
line: f.line,
|
|
79
|
+
message: typeof f.message === "string" ? f.message : "",
|
|
80
|
+
...(wcag ? { wcag } : {}),
|
|
81
|
+
...(selector !== undefined ? { selector } : {}),
|
|
82
|
+
...(impact !== undefined ? { impact } : {}),
|
|
83
|
+
...(criterion !== undefined ? { criterion } : {}),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitHub platform adapter — the FIRST adapter behind the reporter seam
|
|
3
|
+
* (issue #2235). It is the existing inline PR-comment behavior refactored to fit
|
|
4
|
+
* the contract, so the shipped Action is byte-for-byte behavior-preserving:
|
|
5
|
+
*
|
|
6
|
+
* - the RESOLVER reads the same `GITHUB_REPOSITORY` / `PR_NUMBER` / `HEAD_SHA` /
|
|
7
|
+
* `GITHUB_API_URL` env `entrypoint.sh` exports (from `GITHUB_EVENT_PATH`) and
|
|
8
|
+
* the same `CHANGED_FILES` / `BASE_SHA` / `HEAD_SHA` diff scope, and
|
|
9
|
+
* - the REPORTER is the former `pr-comment-cli.ts` body: it resolves the posting
|
|
10
|
+
* identity (branded App, else `GITHUB_TOKEN` — issue #2130) and reconciles the
|
|
11
|
+
* PR's inline review comments through {@link syncCommentsBestEffort} (#2131).
|
|
12
|
+
*
|
|
13
|
+
* Opt-in: the resolver yields a `null` post-target — so the reporter no-ops — when
|
|
14
|
+
* the PR context is incomplete OR no credential is present (no `GITHUB_TOKEN` and
|
|
15
|
+
* no App configured), matching the old `entrypoint.sh` skip guard.
|
|
16
|
+
*/
|
|
17
|
+
import { scopeChangedTsxFromEnv } from "../diff-scope";
|
|
18
|
+
import { resolvePostingToken } from "../github-identity";
|
|
19
|
+
import {
|
|
20
|
+
type Finding,
|
|
21
|
+
type PrCommentClient,
|
|
22
|
+
renderBody,
|
|
23
|
+
type ReviewComment,
|
|
24
|
+
syncCommentsBestEffort,
|
|
25
|
+
} from "../pr-comment";
|
|
26
|
+
import type { DiffContext, DiffContextResolver, FindingsReporter, Logger, PlatformAdapter } from "./contract";
|
|
27
|
+
|
|
28
|
+
/** The GitHub-native surface an inline review comment posts to. */
|
|
29
|
+
export interface GithubPostTarget {
|
|
30
|
+
/** `owner/name`, from `GITHUB_REPOSITORY`. */
|
|
31
|
+
readonly repo: string;
|
|
32
|
+
/** The PR number, from `PR_NUMBER`. */
|
|
33
|
+
readonly pr: string;
|
|
34
|
+
/** The head commit the RIGHT-side comment anchors on, from `HEAD_SHA`. */
|
|
35
|
+
readonly commitId: string;
|
|
36
|
+
/** The GitHub REST base (public API, or a GHES host). */
|
|
37
|
+
readonly api: string;
|
|
38
|
+
/**
|
|
39
|
+
* The process env the reporter resolves its posting token from — the branded
|
|
40
|
+
* App credentials (`BINCLUSIVE_APP_*`) or the Action's `GITHUB_TOKEN`. Carried
|
|
41
|
+
* on the target so token resolution (an async mint) stays in the reporter half.
|
|
42
|
+
*/
|
|
43
|
+
readonly env: NodeJS.ProcessEnv;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const nonEmpty = (v: string | undefined): v is string => v !== undefined && v !== "";
|
|
47
|
+
|
|
48
|
+
/** True when SOME posting credential is present — a token or a configured App. */
|
|
49
|
+
function hasCredential(env: NodeJS.ProcessEnv): boolean {
|
|
50
|
+
if (nonEmpty(env.GITHUB_TOKEN)) return true;
|
|
51
|
+
return nonEmpty(env.BINCLUSIVE_APP_ID) && nonEmpty(env.BINCLUSIVE_APP_PRIVATE_KEY);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resolve the GitHub change-context: changed `.tsx` + a post-target, or `null` when no PR context/credential. */
|
|
55
|
+
export const githubResolver: DiffContextResolver<GithubPostTarget> = {
|
|
56
|
+
resolve(env): DiffContext<GithubPostTarget> {
|
|
57
|
+
const changedTsx = scopeChangedTsxFromEnv(env);
|
|
58
|
+
const repo = env.GITHUB_REPOSITORY;
|
|
59
|
+
const pr = env.PR_NUMBER;
|
|
60
|
+
const commitId = env.HEAD_SHA;
|
|
61
|
+
// Opt-in: a complete PR context AND a credential are both required to post —
|
|
62
|
+
// otherwise no target, so the reporter no-ops (the old entrypoint skip guard).
|
|
63
|
+
if (!nonEmpty(repo) || !nonEmpty(pr) || !nonEmpty(commitId) || !hasCredential(env)) {
|
|
64
|
+
return { changedTsx, postTarget: null };
|
|
65
|
+
}
|
|
66
|
+
const api = nonEmpty(env.GITHUB_API_URL) ? env.GITHUB_API_URL : "https://api.github.com";
|
|
67
|
+
return { changedTsx, postTarget: { repo, pr, commitId, api, env } };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the GitHub REST reconcile client for a resolved target. Inline review
|
|
73
|
+
* comments live under two endpoints: the PR-scoped collection (list + create) and
|
|
74
|
+
* the repo-scoped single-comment resource (update + delete). Every call is
|
|
75
|
+
* best-effort — a failure is logged and swallowed so one bad comment never aborts
|
|
76
|
+
* the sync.
|
|
77
|
+
*/
|
|
78
|
+
function makeClient(target: GithubPostTarget, token: string, log: Logger): PrCommentClient {
|
|
79
|
+
const { repo, pr, commitId, api } = target;
|
|
80
|
+
const headers: Record<string, string> = {
|
|
81
|
+
Authorization: `Bearer ${token}`,
|
|
82
|
+
Accept: "application/vnd.github+json",
|
|
83
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
84
|
+
"User-Agent": "binclusive-a11y-agent",
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
async list(): Promise<ReviewComment[]> {
|
|
89
|
+
const out: ReviewComment[] = [];
|
|
90
|
+
for (let page = 1; ; page++) {
|
|
91
|
+
const url = `${api}/repos/${repo}/pulls/${pr}/comments?per_page=100&page=${page}`;
|
|
92
|
+
let res: Response;
|
|
93
|
+
// A page failure must ABORT the whole sync (throw), not `break` with a
|
|
94
|
+
// partial list: reconciling against a truncated view reads comments on the
|
|
95
|
+
// unfetched pages as absent and re-CREATEs them → duplicates.
|
|
96
|
+
try {
|
|
97
|
+
res = await fetch(url, { headers });
|
|
98
|
+
} catch (e) {
|
|
99
|
+
throw new Error(`list page ${page} fetch failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
100
|
+
}
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
throw new Error(`list page ${page} -> ${res.status} ${(await res.text().catch(() => "")).slice(0, 200)}`);
|
|
103
|
+
}
|
|
104
|
+
let batch: unknown;
|
|
105
|
+
try {
|
|
106
|
+
batch = await res.json();
|
|
107
|
+
} catch (e) {
|
|
108
|
+
throw new Error(`list page ${page} JSON parse failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
109
|
+
}
|
|
110
|
+
if (!Array.isArray(batch) || batch.length === 0) break;
|
|
111
|
+
for (const c of batch) {
|
|
112
|
+
if (
|
|
113
|
+
c &&
|
|
114
|
+
typeof c === "object" &&
|
|
115
|
+
typeof (c as { id?: unknown }).id === "number" &&
|
|
116
|
+
typeof (c as { body?: unknown }).body === "string"
|
|
117
|
+
) {
|
|
118
|
+
out.push({ id: (c as { id: number }).id, body: (c as { body: string }).body });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (batch.length < 100) break;
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async create(f: Finding): Promise<void> {
|
|
127
|
+
// side:"RIGHT" anchors the comment on the head (post-change) version of the
|
|
128
|
+
// file — the line only exists there when it is part of the PR diff.
|
|
129
|
+
const payload = { body: renderBody(f), commit_id: commitId, path: f.file, line: f.line, side: "RIGHT" };
|
|
130
|
+
const url = `${api}/repos/${repo}/pulls/${pr}/comments`;
|
|
131
|
+
try {
|
|
132
|
+
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
|
|
133
|
+
if (!res.ok) log(`create ${f.file}:${f.line} -> ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
log(`create failed for ${f.file}:${f.line}: ${e instanceof Error ? e.message : String(e)}`);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async update(id: number, f: Finding): Promise<void> {
|
|
140
|
+
const url = `${api}/repos/${repo}/pulls/comments/${id}`;
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetch(url, { method: "PATCH", headers, body: JSON.stringify({ body: renderBody(f) }) });
|
|
143
|
+
if (!res.ok) log(`update ${id} -> ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
log(`update failed for comment ${id}: ${e instanceof Error ? e.message : String(e)}`);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
async remove(id: number): Promise<void> {
|
|
150
|
+
const url = `${api}/repos/${repo}/pulls/comments/${id}`;
|
|
151
|
+
try {
|
|
152
|
+
const res = await fetch(url, { method: "DELETE", headers });
|
|
153
|
+
if (!res.ok && res.status !== 404) log(`remove ${id} -> ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
154
|
+
} catch (e) {
|
|
155
|
+
log(`remove failed for comment ${id}: ${e instanceof Error ? e.message : String(e)}`);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Post inline PR review comments, de-duplicating across pushes (the #2131 reconcile). */
|
|
162
|
+
export const githubReporter: FindingsReporter<GithubPostTarget> = {
|
|
163
|
+
async report(findings, target, log): Promise<void> {
|
|
164
|
+
// Resolve WHO posts once: branded App identity when configured, else GITHUB_TOKEN.
|
|
165
|
+
// Never throws — a mint failure degrades to the default token.
|
|
166
|
+
const { token, identity } = await resolvePostingToken(target.env, { repo: target.repo, api: target.api }, log);
|
|
167
|
+
if (!token) {
|
|
168
|
+
log("no posting token (no GitHub App configured and no GITHUB_TOKEN); skipping");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
log(`posting inline comments as ${identity}`);
|
|
172
|
+
const client = makeClient(target, token, log);
|
|
173
|
+
// Best-effort by contract: swallows any throw so the entrypoint always exits 0.
|
|
174
|
+
await syncCommentsBestEffort(findings, client, log);
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** The GitHub adapter: the diff-context resolver + the inline-comment reporter, keyed `github`. */
|
|
179
|
+
export const githubAdapter: PlatformAdapter<GithubPostTarget> = {
|
|
180
|
+
key: "github",
|
|
181
|
+
resolver: githubResolver,
|
|
182
|
+
reporter: githubReporter,
|
|
183
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The null / stdout adapter — the "generic, no native review UI" platform, and
|
|
3
|
+
* the TRIVIAL SECOND adapter that proves the reporter seam holds ≥ 2 platforms
|
|
4
|
+
* (issue #2235). It is deliberately not GitHub-shaped: no PR identity, no
|
|
5
|
+
* credential, no de-dup reconcile — it just writes each finding to a sink.
|
|
6
|
+
*
|
|
7
|
+
* This is the seam's proof-of-generality, and the seed the future generic `--ci`
|
|
8
|
+
* mode (#2236) and the Buildkite/GitLab adapters (#2237/#2238) build on. Its
|
|
9
|
+
* post-target is always present (a sink is always available), so — unlike the
|
|
10
|
+
* GitHub adapter — it always reports; the no-context no-op path is exercised by
|
|
11
|
+
* the GitHub resolver instead.
|
|
12
|
+
*/
|
|
13
|
+
import { scopeChangedTsxFromEnv } from "../diff-scope";
|
|
14
|
+
import type { DiffContext, DiffContextResolver, Finding, FindingsReporter, PlatformAdapter } from "./contract";
|
|
15
|
+
|
|
16
|
+
/** A line sink — where the generic reporter writes. Injected so it is testable. */
|
|
17
|
+
export type LineSink = (line: string) => void;
|
|
18
|
+
|
|
19
|
+
/** The null adapter's post-target: just the line sink it writes findings to. */
|
|
20
|
+
export interface NullPostTarget {
|
|
21
|
+
readonly write: LineSink;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One finding as a single stdout line — the generic, UI-less rendering. */
|
|
25
|
+
export function renderLine(f: Finding): string {
|
|
26
|
+
const wcag = (f.wcag ?? []).length > 0 ? ` [${(f.wcag ?? []).map((s) => `WCAG ${s}`).join(", ")}]` : "";
|
|
27
|
+
const impact = f.impact ? `${f.impact}: ` : "";
|
|
28
|
+
return `${f.file}:${f.line} ${impact}${f.ruleId}${wcag} — ${f.message}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Build a null adapter over `sink` (defaults to stdout). */
|
|
32
|
+
export function makeNullAdapter(sink: LineSink = (line) => process.stdout.write(line)): PlatformAdapter<NullPostTarget> {
|
|
33
|
+
const resolver: DiffContextResolver<NullPostTarget> = {
|
|
34
|
+
resolve(env): DiffContext<NullPostTarget> {
|
|
35
|
+
// A sink is always available, so the generic reporter always has a target.
|
|
36
|
+
return { changedTsx: scopeChangedTsxFromEnv(env), postTarget: { write: sink } };
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const reporter: FindingsReporter<NullPostTarget> = {
|
|
41
|
+
async report(findings, target, log): Promise<void> {
|
|
42
|
+
for (const f of findings) target.write(`${renderLine(f)}\n`);
|
|
43
|
+
log(`generic reporter: wrote ${findings.length} finding(s) to the sink`);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return { key: "null", resolver, reporter };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The default null adapter — writes to stdout. */
|
|
51
|
+
export const nullAdapter: PlatformAdapter<NullPostTarget> = makeNullAdapter();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default adapter registry — the two shipped platforms bound behind the seam
|
|
3
|
+
* (issue #2235): `github` (the first adapter, real inline PR comments) and `null`
|
|
4
|
+
* (the generic stdout adapter, the ≥ 2 proof). New platforms (#2237 Buildkite,
|
|
5
|
+
* #2238 GitLab) register here; nothing else changes.
|
|
6
|
+
*
|
|
7
|
+
* Each adapter is Ctx-erased through {@link bindAdapter} at registration, so the
|
|
8
|
+
* registry stores platforms of different post-target types in one map with no cast.
|
|
9
|
+
*/
|
|
10
|
+
import { AdapterRegistry, bindAdapter, type BoundAdapter } from "./contract";
|
|
11
|
+
import { githubAdapter } from "./github-adapter";
|
|
12
|
+
import { nullAdapter } from "./null-adapter";
|
|
13
|
+
|
|
14
|
+
/** The bound adapters shipped by default. Order is irrelevant — selection is by key. */
|
|
15
|
+
export function defaultBoundAdapters(): BoundAdapter[] {
|
|
16
|
+
return [bindAdapter(githubAdapter), bindAdapter(nullAdapter)];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The default registry: `github` + `null`, selectable by explicit platform key. */
|
|
20
|
+
export function defaultRegistry(): AdapterRegistry {
|
|
21
|
+
return new AdapterRegistry(defaultBoundAdapters());
|
|
22
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The thin CLI over the reporter-adapter seam (issue #2235) that `entrypoint.sh`
|
|
3
|
+
* invokes (through the root `report.mjs` tsx wrapper) after a scan. It selects the
|
|
4
|
+
* platform adapter by explicit key (`A11Y_PLATFORM`, defaulting to `github` so the
|
|
5
|
+
* shipped Action is behavior-preserving), resolves the platform's post-context
|
|
6
|
+
* from env, parses the engine's findings JSON, and dispatches to the reporter.
|
|
7
|
+
*
|
|
8
|
+
* Best-effort by design: any missing context, unknown platform, failed read, or
|
|
9
|
+
* failed API call is logged to stderr and skipped — never thrown — so the calling
|
|
10
|
+
* entrypoint always exits 0 (the gate is advisory). When the resolver finds no
|
|
11
|
+
* PR/MR post-context, the reporter no-ops and the artifacts still emit.
|
|
12
|
+
*
|
|
13
|
+
* Args: <report-path>. Env: A11Y_PLATFORM (optional; default `github`), plus each
|
|
14
|
+
* adapter's own env (GitHub: GITHUB_REPOSITORY / PR_NUMBER / HEAD_SHA / GITHUB_TOKEN
|
|
15
|
+
* / GITHUB_API_URL / BINCLUSIVE_APP_*).
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { dispatch, type Finding, parseFindings, resolvePlatformKey } from "./reporter/contract";
|
|
19
|
+
import { defaultRegistry } from "./reporter/registry";
|
|
20
|
+
|
|
21
|
+
const log = (msg: string): void => console.error(`reporter: ${msg}`);
|
|
22
|
+
|
|
23
|
+
const reportPath = process.argv[2];
|
|
24
|
+
if (!reportPath) {
|
|
25
|
+
log("no report path argument; skipping");
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const key = resolvePlatformKey(process.env);
|
|
30
|
+
const adapter = defaultRegistry().select(key);
|
|
31
|
+
if (!adapter) {
|
|
32
|
+
log(`unknown platform "${key}"; skipping`);
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
log(`platform: ${key}`);
|
|
36
|
+
|
|
37
|
+
const loadFindings = (path: string): Finding[] => {
|
|
38
|
+
try {
|
|
39
|
+
return parseFindings(JSON.parse(readFileSync(path, "utf8")));
|
|
40
|
+
} catch (e) {
|
|
41
|
+
log(`could not read findings JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const findings = loadFindings(reportPath);
|
|
47
|
+
const resolved = adapter.resolve(process.env);
|
|
48
|
+
await dispatch(resolved, findings, log);
|