@binclusive/a11y 0.1.0 → 0.1.2
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 +25 -13
- package/package.json +8 -3
- package/src/cli.ts +338 -104
- package/src/collect-android-xml.ts +447 -0
- package/src/contract.ts +9 -4
- package/src/core.ts +5 -1
- package/src/decisions-lint.ts +137 -0
- package/src/detect-stack.ts +70 -2
- package/src/diff-scope.ts +42 -20
- package/src/enforce.ts +18 -6
- package/src/finding-voice.ts +66 -0
- package/src/pr-comment.ts +30 -5
- package/src/reporter/finding.ts +7 -2
- package/src/reporter/github-adapter.ts +16 -2
- package/src/runner/runner.ts +3 -0
- package/src/sarif.ts +68 -9
package/src/detect-stack.ts
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* builds) so detection and scanning agree on what a "component import" is.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
-
import { dirname, join } from "node:path";
|
|
15
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join, sep } from "node:path";
|
|
17
17
|
import type { Language, Router, Stack } from "./contract";
|
|
18
18
|
import { familyLabel, isFrameworkPrimitive, isOwnModule, packageNameOf } from "./module-scope";
|
|
19
19
|
import { resolveComponents } from "./resolve-components";
|
|
@@ -125,6 +125,67 @@ function detectLanguage(dir: string): Language {
|
|
|
125
125
|
return findUp(dir, "tsconfig.json") !== null ? "ts" : "js";
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/** Build/output dirs not worth descending into when sniffing for Android markers. */
|
|
129
|
+
const ANDROID_SCAN_SKIP = new Set([
|
|
130
|
+
"node_modules",
|
|
131
|
+
".git",
|
|
132
|
+
".gradle",
|
|
133
|
+
"build",
|
|
134
|
+
"dist",
|
|
135
|
+
".idea",
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Bounded find-down for an Android marker — an `AndroidManifest.xml` or a
|
|
140
|
+
* `res/layout…` resource directory — within `maxDepth` levels of `dir`. Android
|
|
141
|
+
* projects nest these under `app/src/main/…`, so a pure package-up probe (used
|
|
142
|
+
* for tsconfig) would miss them; a shallow, skip-pruned descent is the cheap,
|
|
143
|
+
* reliable signal. Short-circuits on the first marker found.
|
|
144
|
+
*/
|
|
145
|
+
function hasAndroidMarker(dir: string, maxDepth: number): boolean {
|
|
146
|
+
let entries: ReturnType<typeof readdirSync>;
|
|
147
|
+
try {
|
|
148
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
if (entry.isFile() && entry.name === "AndroidManifest.xml") return true;
|
|
154
|
+
if (entry.isDirectory()) {
|
|
155
|
+
// a `layout` / `layout-…` dir whose parent is `res` is a layout resource dir
|
|
156
|
+
if ((entry.name === "layout" || entry.name.startsWith("layout-")) && dir.endsWith(`${sep}res`)) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (maxDepth <= 0) return false;
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
if (!entry.isDirectory() || ANDROID_SCAN_SKIP.has(entry.name)) continue;
|
|
164
|
+
if (hasAndroidMarker(join(dir, entry.name), maxDepth - 1)) return true;
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* True when `dir` is an Android project: a Gradle root (a `build.gradle` /
|
|
171
|
+
* `settings.gradle`, in Groovy or Kotlin DSL) paired with an Android marker
|
|
172
|
+
* (`AndroidManifest.xml` or a `res/layout*` dir) reachable a few levels down.
|
|
173
|
+
* Requiring BOTH keeps a plain JVM Gradle project (no Android UI) from being
|
|
174
|
+
* misread as Android.
|
|
175
|
+
*/
|
|
176
|
+
export function detectAndroid(dir: string): boolean {
|
|
177
|
+
const gradle =
|
|
178
|
+
existsSync(join(dir, "build.gradle")) ||
|
|
179
|
+
existsSync(join(dir, "build.gradle.kts")) ||
|
|
180
|
+
existsSync(join(dir, "settings.gradle")) ||
|
|
181
|
+
existsSync(join(dir, "settings.gradle.kts"));
|
|
182
|
+
if (!gradle && !existsSync(join(dir, "AndroidManifest.xml"))) {
|
|
183
|
+
// No gradle root and no manifest right here — not the root of an Android repo.
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
return hasAndroidMarker(dir, 5);
|
|
187
|
+
}
|
|
188
|
+
|
|
128
189
|
/**
|
|
129
190
|
* The repo's design system: the published package that contributes the most
|
|
130
191
|
* components RESOLVING TO A KNOWN INTERACTIVE HOST (registry or traced). That
|
|
@@ -193,6 +254,13 @@ export function detectDesignSystem(tsxFiles: readonly string[], rootDir?: string
|
|
|
193
254
|
* signal; see the per-field helpers for what each reads.
|
|
194
255
|
*/
|
|
195
256
|
export function detectStack(dir: string, tsxFiles: readonly string[]): Stack {
|
|
257
|
+
// Android is a distinct platform from the React/TSX path: an Android repo has
|
|
258
|
+
// no `package.json`/tsconfig to read, and its UI is `res/layout*` XML, not
|
|
259
|
+
// `.tsx`. Detect it first so it routes to the Android XML collector instead of
|
|
260
|
+
// degrading to a `framework: unknown · custom · js` web stack.
|
|
261
|
+
if (detectAndroid(dir)) {
|
|
262
|
+
return { framework: "android", router: null, designSystem: "android-views", language: "android-xml" };
|
|
263
|
+
}
|
|
196
264
|
// Package-up: a scan pointed at a nested `src/` finds the app's package.json
|
|
197
265
|
// (and the on-disk app/pages layout) one or more levels above.
|
|
198
266
|
const pkgRoot = findUp(dir, "package.json") ?? dir;
|
package/src/diff-scope.ts
CHANGED
|
@@ -38,18 +38,50 @@ function isGitRepo(workspace: string): boolean {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Names touched between base and head, honoring extra diff flags (deletion
|
|
43
|
+
* filter, rename detection).
|
|
44
|
+
*
|
|
45
|
+
* Prefers the THREE-dot `base...head` range — the PR-diff semantic GitHub's own
|
|
46
|
+
* PR view uses (changes since the merge-base, excluding commits added to base
|
|
47
|
+
* after the branch point). On a SHALLOW checkout the merge-base commit is usually
|
|
48
|
+
* absent, so three-dot fails hard with "no merge base" (#198). Rather than swallow
|
|
49
|
+
* that as an empty scope — the silent-empty-green trap: 0 files → 0 findings → a
|
|
50
|
+
* GREEN check on a scan that saw NOTHING — degrade to the TWO-dot `base..head`
|
|
51
|
+
* tree comparison, which needs only the two endpoint commits (both guaranteed
|
|
52
|
+
* present — `entrypoint.sh` self-fetches the base before scoping). Two-dot is a
|
|
53
|
+
* strict superset of the three-dot change set, so it can only OVER-scan (extra
|
|
54
|
+
* files a drifted base changed), never HIDE a PR-touched file — the safe
|
|
55
|
+
* direction for an advisory gate.
|
|
56
|
+
*/
|
|
57
|
+
function gitDiffRange(
|
|
58
|
+
workspace: string,
|
|
59
|
+
baseSha: string,
|
|
60
|
+
headSha: string,
|
|
61
|
+
extraArgs: readonly string[],
|
|
62
|
+
): string[] {
|
|
63
|
+
const run = (range: string): string =>
|
|
64
|
+
execFileSync("git", ["-C", workspace, "diff", ...extraArgs, "--name-only", range], {
|
|
65
|
+
encoding: "utf8",
|
|
66
|
+
});
|
|
67
|
+
let out: string;
|
|
42
68
|
try {
|
|
43
|
-
|
|
44
|
-
"git",
|
|
45
|
-
["-C", workspace, "diff", "--name-only", `${baseSha}...${headSha}`],
|
|
46
|
-
{ encoding: "utf8" },
|
|
47
|
-
);
|
|
48
|
-
return out.split("\n").filter((p) => p !== "");
|
|
69
|
+
out = run(`${baseSha}...${headSha}`);
|
|
49
70
|
} catch {
|
|
50
|
-
|
|
51
|
-
|
|
71
|
+
try {
|
|
72
|
+
out = run(`${baseSha}..${headSha}`);
|
|
73
|
+
} catch {
|
|
74
|
+
// Advisory gate: both ranges failed (e.g. head unresolvable) is an empty
|
|
75
|
+
// scope, never a throw. A missing BASE is caught earlier, and loud, in
|
|
76
|
+
// entrypoint.sh — this path never sees a bad base.
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
52
79
|
}
|
|
80
|
+
return out.split("\n").filter((p) => p !== "");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function gitDiffNames(workspace: string, baseSha: string, headSha: string): string[] {
|
|
84
|
+
return gitDiffRange(workspace, baseSha, headSha, []);
|
|
53
85
|
}
|
|
54
86
|
|
|
55
87
|
/**
|
|
@@ -63,17 +95,7 @@ function gitDiffNames(workspace: string, baseSha: string, headSha: string): stri
|
|
|
63
95
|
* Paths are repo-root-relative, forward-slash (git's native output).
|
|
64
96
|
*/
|
|
65
97
|
function gitDeletedNames(workspace: string, baseSha: string, headSha: string): string[] {
|
|
66
|
-
|
|
67
|
-
const out = execFileSync(
|
|
68
|
-
"git",
|
|
69
|
-
["-C", workspace, "diff", "--diff-filter=D", "--find-renames", "--name-only", `${baseSha}...${headSha}`],
|
|
70
|
-
{ encoding: "utf8" },
|
|
71
|
-
);
|
|
72
|
-
return out.split("\n").filter((p) => p !== "");
|
|
73
|
-
} catch {
|
|
74
|
-
// Advisory gate: a git failure is an empty deletion set, never a throw.
|
|
75
|
-
return [];
|
|
76
|
-
}
|
|
98
|
+
return gitDiffRange(workspace, baseSha, headSha, ["--diff-filter=D", "--find-renames"]);
|
|
77
99
|
}
|
|
78
100
|
|
|
79
101
|
/**
|
package/src/enforce.ts
CHANGED
|
@@ -605,36 +605,48 @@ interface EnforceRule {
|
|
|
605
605
|
readonly message: string;
|
|
606
606
|
}
|
|
607
607
|
|
|
608
|
+
/**
|
|
609
|
+
* Every finding message — here and in the jsx-a11y wrapper (see {@link
|
|
610
|
+
* import("./finding-voice")}) — leads with the IMPACT-FIRST template:
|
|
611
|
+
*
|
|
612
|
+
* [who is affected] can't [do what] because [cause], so [fix].
|
|
613
|
+
*
|
|
614
|
+
* The lead names the harmed user and the human consequence (#14), not the rule
|
|
615
|
+
* id. The rule id, WCAG SC, and corpus "seen-in-the-wild" frequency are SECONDARY
|
|
616
|
+
* lines the report renders separately ({@link import("./cli").detailLines}), so
|
|
617
|
+
* the message carries NO `(corpus: …)` suffix — that data is not dropped, it just
|
|
618
|
+
* lives on its own line rather than inline in the impact sentence.
|
|
619
|
+
*/
|
|
608
620
|
const RULES: Record<string, EnforceRule> = {
|
|
609
621
|
buttonNoName: {
|
|
610
622
|
ruleId: "enforce/button-no-name",
|
|
611
623
|
wcag: ["4.1.2"],
|
|
612
624
|
message:
|
|
613
|
-
"
|
|
625
|
+
"Screen-reader users can't tell what this button does because it has no accessible name — its children are empty or icon-only and there is no aria-label, aria-labelledby, or title, so add visible text or an aria-label that names its action.",
|
|
614
626
|
},
|
|
615
627
|
imageNoAlt: {
|
|
616
628
|
ruleId: "enforce/image-no-alt",
|
|
617
629
|
wcag: ["1.1.1"],
|
|
618
630
|
message:
|
|
619
|
-
'
|
|
631
|
+
'Blind users can\'t perceive this image because it has no text alternative — no alt and no aria-label/aria-labelledby, so add an alt that conveys its meaning, or alt="" if it is purely decorative.',
|
|
620
632
|
},
|
|
621
633
|
linkNoName: {
|
|
622
634
|
ruleId: "enforce/link-no-name",
|
|
623
635
|
wcag: ["2.4.4"],
|
|
624
636
|
message:
|
|
625
|
-
"
|
|
637
|
+
"Screen-reader users can't tell where this link goes because it has no discernible name — no text child and no aria-label, aria-labelledby, or title, so give it visible text or an aria-label that names its destination.",
|
|
626
638
|
},
|
|
627
639
|
dialogNoName: {
|
|
628
640
|
ruleId: "enforce/dialog-no-name",
|
|
629
641
|
wcag: ["4.1.2", "1.3.1"],
|
|
630
642
|
message:
|
|
631
|
-
"
|
|
643
|
+
"Screen-reader users can't tell what this dialog is for because it has no accessible name — no aria-label/aria-labelledby, no title or label prop, and no nested title subcomponent, so name the dialog and assistive tech will announce it on open.",
|
|
632
644
|
},
|
|
633
645
|
inputNoName: {
|
|
634
646
|
ruleId: "enforce/input-no-name",
|
|
635
647
|
wcag: ["1.3.1", "3.3.2"],
|
|
636
648
|
message:
|
|
637
|
-
"
|
|
649
|
+
"Screen-reader users can't tell what to enter in this field because it has no associated label — no aria-label/aria-labelledby, no label or title prop, and no id to pair with a <label for>, so associate a real label and the field will be announced.",
|
|
638
650
|
},
|
|
639
651
|
};
|
|
640
652
|
|
|
@@ -703,7 +715,7 @@ function preferTagOverRole(
|
|
|
703
715
|
const line = sf.getLineAndCharacterOfPosition(attr.getStart(sf)).line + 1;
|
|
704
716
|
return {
|
|
705
717
|
line,
|
|
706
|
-
message: `
|
|
718
|
+
message: `Assistive-tech users get fragile semantics here because <${tag}> hand-sets role="${value}" instead of using the native element ${native.suggest} that conveys it, so switch to ${native.suggest} and the role works without relying on ARIA.`,
|
|
707
719
|
};
|
|
708
720
|
}
|
|
709
721
|
return null;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Impact-first message voice (#14).
|
|
3
|
+
*
|
|
4
|
+
* Every finding a11y-checker surfaces leads with one consistent shape:
|
|
5
|
+
*
|
|
6
|
+
* [who is affected] can't [do what] because [cause], so [fix].
|
|
7
|
+
*
|
|
8
|
+
* The lead names the harmed user and the human consequence — the first thing a
|
|
9
|
+
* reader sees — rather than a rule id or SC number. The rule id, WCAG SC, and
|
|
10
|
+
* corpus "seen-in-the-wild" frequency stay, but as SECONDARY lines the report
|
|
11
|
+
* renders separately (see `cli.ts` `detailLines`), never inline in the sentence.
|
|
12
|
+
*
|
|
13
|
+
* The enforce content rules (`enforce.ts`) author their own messages in this
|
|
14
|
+
* shape. The jsx-a11y structural pass does NOT — its findings come straight from
|
|
15
|
+
* eslint-plugin-jsx-a11y, whose upstream messages lead with the rule requirement
|
|
16
|
+
* ("Buttons must have discernible text"), not the impacted user. This module is
|
|
17
|
+
* the wrapper that rewrites those upstream messages into the same impact-first
|
|
18
|
+
* voice, keyed by jsx-a11y rule id. It covers exactly the rules we score
|
|
19
|
+
* (`core.ts` `SCORED_RULES`); any rule not mapped here falls back to its upstream
|
|
20
|
+
* message unchanged, so the remap can only improve voice, never blank a finding.
|
|
21
|
+
*
|
|
22
|
+
* This is a display-only rewrite: it changes the `message` string, nothing about
|
|
23
|
+
* detection, the rule id, the WCAG mapping, the corpus enrichment, or the matrix
|
|
24
|
+
* baseline (which records file/line/ruleId, not message text).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Impact-first messages for the jsx-a11y rules we score. Keyed by the bare rule
|
|
29
|
+
* name (no `jsx-a11y/` prefix) — the caller strips the namespace before lookup.
|
|
30
|
+
* Each follows the [who] can't [do what] because [cause], so [fix] template.
|
|
31
|
+
*/
|
|
32
|
+
const JSX_A11Y_IMPACT_MESSAGES: Readonly<Record<string, string>> = {
|
|
33
|
+
"label-has-associated-control":
|
|
34
|
+
"Screen-reader users can't tell what to enter in this field because its control has no associated <label>, so pair the control with a <label> (htmlFor + id, or wrap it) and the field will be announced.",
|
|
35
|
+
"alt-text":
|
|
36
|
+
'Blind users can\'t perceive this image because it has no alt text, so add an alt that conveys its meaning, or alt="" if it is purely decorative.',
|
|
37
|
+
"anchor-has-content":
|
|
38
|
+
"Screen-reader users can't tell where this link goes because it has no discernible content, so give the anchor visible text or an aria-label that names its destination.",
|
|
39
|
+
"anchor-is-valid":
|
|
40
|
+
"Keyboard and screen-reader users can't reliably follow this link because it has no valid href, so give it a real href — or use a <button> if it triggers an action rather than navigating.",
|
|
41
|
+
"aria-props":
|
|
42
|
+
"Assistive tech can't interpret this element because it carries an attribute that isn't a valid aria-* property, so remove or correct the invalid ARIA attribute.",
|
|
43
|
+
"role-has-required-aria-props":
|
|
44
|
+
"Screen-reader users can't fully operate this control because its role is missing ARIA properties that role requires, so add the required aria-* attributes for the role.",
|
|
45
|
+
"role-supports-aria-props":
|
|
46
|
+
"Screen-reader users can't rely on this element because it has an aria-* attribute its role doesn't support, so remove the unsupported attribute or change the role.",
|
|
47
|
+
"interactive-supports-focus":
|
|
48
|
+
"Keyboard users can't reach this control because it is interactive but not focusable, so make it focusable (use a native control, or add tabIndex) and it can be operated without a mouse.",
|
|
49
|
+
"click-events-have-key-events":
|
|
50
|
+
"Keyboard users can't activate this element because it has a click handler but no keyboard handler, so add a key handler — or use a <button> — and it will work without a mouse.",
|
|
51
|
+
"no-static-element-interactions":
|
|
52
|
+
"Keyboard and screen-reader users can't operate this element because a click handler sits on a non-interactive element with no role, so use a native control, or add an interactive role plus keyboard support.",
|
|
53
|
+
"heading-has-content":
|
|
54
|
+
"Screen-reader users can't navigate by this heading because it has no text content, so give the heading discernible text (or remove it if it is not really a heading).",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Rewrite a jsx-a11y finding's upstream eslint message into the impact-first
|
|
59
|
+
* voice. `ruleId` is the full id (`jsx-a11y/alt-text`); `fallback` is the
|
|
60
|
+
* upstream message used verbatim when the rule isn't in the map — so a finding
|
|
61
|
+
* always carries a message, and an unmapped rule degrades to its original text.
|
|
62
|
+
*/
|
|
63
|
+
export function impactFirstJsxA11yMessage(ruleId: string, fallback: string): string {
|
|
64
|
+
const bare = ruleId.startsWith("jsx-a11y/") ? ruleId.slice("jsx-a11y/".length) : ruleId;
|
|
65
|
+
return JSX_A11Y_IMPACT_MESSAGES[bare] ?? fallback;
|
|
66
|
+
}
|
package/src/pr-comment.ts
CHANGED
|
@@ -34,10 +34,20 @@ export { type Finding, type Impact, parseFindings };
|
|
|
34
34
|
export interface ReviewComment {
|
|
35
35
|
readonly id: number;
|
|
36
36
|
readonly body: string;
|
|
37
|
+
/**
|
|
38
|
+
* The comment author's login (GitHub `user.login`), when the platform supplies
|
|
39
|
+
* it. Feeds the {@link reconcile} author guard: our marker is public text anyone
|
|
40
|
+
* could paste, so a marker on a comment authored by someone other than us is not
|
|
41
|
+
* actually ours. Absent ⇒ the guard falls back to marker-only matching.
|
|
42
|
+
*/
|
|
43
|
+
readonly author?: string;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
const MARKER_TAG = "binclusive-a11y-agent";
|
|
40
|
-
|
|
47
|
+
// Composed from MARKER_TAG so the tag lives in exactly one place — the marker we
|
|
48
|
+
// WRITE (markerFor) and the marker we READ (keyOf) can never drift apart. Safe to
|
|
49
|
+
// interpolate raw: MARKER_TAG is a fixed [a-z-] literal with no regex metachars.
|
|
50
|
+
const MARKER_RE = new RegExp(`<!--\\s*${MARKER_TAG}:(.*?)\\s*-->`);
|
|
41
51
|
|
|
42
52
|
/**
|
|
43
53
|
* A live rendered element locator. Mirrors `emit-contract.ts`'s `hasSelector`:
|
|
@@ -99,7 +109,11 @@ export function markerFor(f: Finding): string {
|
|
|
99
109
|
*/
|
|
100
110
|
export function keyOf(body: string): string | null {
|
|
101
111
|
const m = MARKER_RE.exec(body);
|
|
102
|
-
|
|
112
|
+
// An empty/whitespace-only key (`<!-- ...agent: -->`) identifies no finding, so
|
|
113
|
+
// it can't be reconciled against one — treat it as not-ours rather than let a
|
|
114
|
+
// degenerate "" key collide in the reconcile maps.
|
|
115
|
+
if (!m || m[1] === undefined || m[1] === "") return null;
|
|
116
|
+
return m[1];
|
|
103
117
|
}
|
|
104
118
|
|
|
105
119
|
/** Render the full comment body for `f`, marker appended so it round-trips. */
|
|
@@ -130,10 +144,17 @@ export interface ReconcilePlan {
|
|
|
130
144
|
* alone. A pre-dedup PR may hold several comments for one key (the old spamming
|
|
131
145
|
* behavior); the canonical one (first seen) is kept/updated and the rest are
|
|
132
146
|
* folded into `remove`, so the very first reconciling run also cleans up.
|
|
147
|
+
*
|
|
148
|
+
* `self` is the login we post under: when given, a marker comment authored by a
|
|
149
|
+
* DIFFERENT login is not treated as ours (someone pasted our marker) — we never
|
|
150
|
+
* update or delete it. Omitted, or a comment with no known author, keeps the
|
|
151
|
+
* marker-only behavior, so passing an unknown `self` can never make us skip our
|
|
152
|
+
* own comments and re-post duplicates.
|
|
133
153
|
*/
|
|
134
154
|
export function reconcile(
|
|
135
155
|
findings: readonly Finding[],
|
|
136
156
|
existing: readonly ReviewComment[],
|
|
157
|
+
self?: string,
|
|
137
158
|
): ReconcilePlan {
|
|
138
159
|
// Desired findings keyed by identity; first occurrence wins on collision.
|
|
139
160
|
const desired = new Map<string, Finding>();
|
|
@@ -148,7 +169,9 @@ export function reconcile(
|
|
|
148
169
|
const remove: number[] = [];
|
|
149
170
|
for (const c of existing) {
|
|
150
171
|
const k = keyOf(c.body);
|
|
151
|
-
if (k === null) continue; //
|
|
172
|
+
if (k === null) continue; // no marker — never touch human comments
|
|
173
|
+
// Author guard: a marker on a comment someone else authored is not ours.
|
|
174
|
+
if (self !== undefined && c.author !== undefined && c.author !== self) continue;
|
|
152
175
|
if (ours.has(k)) remove.push(c.id);
|
|
153
176
|
else ours.set(k, c);
|
|
154
177
|
}
|
|
@@ -205,9 +228,10 @@ export async function syncComments(
|
|
|
205
228
|
findings: readonly Finding[],
|
|
206
229
|
client: PrCommentClient,
|
|
207
230
|
log: (msg: string) => void = () => {},
|
|
231
|
+
self?: string,
|
|
208
232
|
): Promise<ReconcilePlan> {
|
|
209
233
|
const existing = await client.list();
|
|
210
|
-
const plan = reconcile(findings, existing);
|
|
234
|
+
const plan = reconcile(findings, existing, self);
|
|
211
235
|
|
|
212
236
|
for (const f of plan.create) {
|
|
213
237
|
await client.create(f);
|
|
@@ -240,9 +264,10 @@ export async function syncCommentsBestEffort(
|
|
|
240
264
|
findings: readonly Finding[],
|
|
241
265
|
client: PrCommentClient,
|
|
242
266
|
log: (msg: string) => void = () => {},
|
|
267
|
+
self?: string,
|
|
243
268
|
): Promise<ReconcilePlan | null> {
|
|
244
269
|
try {
|
|
245
|
-
return await syncComments(findings, client, log);
|
|
270
|
+
return await syncComments(findings, client, log, self);
|
|
246
271
|
} catch (e) {
|
|
247
272
|
log(`sync aborted (best-effort, no-op): ${e instanceof Error ? e.message : String(e)}`);
|
|
248
273
|
return null;
|
package/src/reporter/finding.ts
CHANGED
|
@@ -63,7 +63,12 @@ export function parseFindings(raw: unknown): Finding[] {
|
|
|
63
63
|
if (typeof item !== "object" || item === null) continue;
|
|
64
64
|
const f = item as Record<string, unknown>;
|
|
65
65
|
if (typeof f.ruleId !== "string" || typeof f.file !== "string") continue;
|
|
66
|
-
|
|
66
|
+
// A line must be a real number: NaN/Infinity would anchor a review comment on a
|
|
67
|
+
// nonexistent line, and `renderBody`/the marker would carry the junk value.
|
|
68
|
+
if (typeof f.line !== "number" || !Number.isFinite(f.line)) continue;
|
|
69
|
+
// Drop rather than synthesize an empty message: a comment with no body text is
|
|
70
|
+
// noise on the PR, and the boundary shouldn't invent content the report lacks.
|
|
71
|
+
if (typeof f.message !== "string" || f.message.trim() === "") continue;
|
|
67
72
|
const wcag = Array.isArray(f.wcag) ? f.wcag.filter((w): w is string => typeof w === "string") : undefined;
|
|
68
73
|
// Keep the selector across the boundary — it is what distinguishes co-located
|
|
69
74
|
// same-rule findings; dropping it here reintroduces the collision.
|
|
@@ -76,7 +81,7 @@ export function parseFindings(raw: unknown): Finding[] {
|
|
|
76
81
|
ruleId: f.ruleId,
|
|
77
82
|
file: f.file,
|
|
78
83
|
line: f.line,
|
|
79
|
-
message:
|
|
84
|
+
message: f.message,
|
|
80
85
|
...(wcag ? { wcag } : {}),
|
|
81
86
|
...(selector !== undefined ? { selector } : {}),
|
|
82
87
|
...(impact !== undefined ? { impact } : {}),
|
|
@@ -115,7 +115,16 @@ function makeClient(target: GithubPostTarget, token: string, log: Logger): PrCom
|
|
|
115
115
|
typeof (c as { id?: unknown }).id === "number" &&
|
|
116
116
|
typeof (c as { body?: unknown }).body === "string"
|
|
117
117
|
) {
|
|
118
|
-
|
|
118
|
+
const user = (c as { user?: unknown }).user;
|
|
119
|
+
const author =
|
|
120
|
+
user && typeof user === "object" && typeof (user as { login?: unknown }).login === "string"
|
|
121
|
+
? (user as { login: string }).login
|
|
122
|
+
: undefined;
|
|
123
|
+
out.push({
|
|
124
|
+
id: (c as { id: number }).id,
|
|
125
|
+
body: (c as { body: string }).body,
|
|
126
|
+
...(author !== undefined ? { author } : {}),
|
|
127
|
+
});
|
|
119
128
|
}
|
|
120
129
|
}
|
|
121
130
|
if (batch.length < 100) break;
|
|
@@ -170,8 +179,13 @@ export const githubReporter: FindingsReporter<GithubPostTarget> = {
|
|
|
170
179
|
}
|
|
171
180
|
log(`posting inline comments as ${identity}`);
|
|
172
181
|
const client = makeClient(target, token, log);
|
|
182
|
+
// The author guard needs the login our comments carry. GITHUB_TOKEN posts as
|
|
183
|
+
// `github-actions[bot]` (statically known); the branded App's bot login is not
|
|
184
|
+
// known here, so leave `self` undefined for it — the guard degrades to
|
|
185
|
+
// marker-only rather than risk skipping our own App-authored comments.
|
|
186
|
+
const self = identity === "github-actions" ? "github-actions[bot]" : undefined;
|
|
173
187
|
// Best-effort by contract: swallows any throw so the entrypoint always exits 0.
|
|
174
|
-
await syncCommentsBestEffort(findings, client, log);
|
|
188
|
+
await syncCommentsBestEffort(findings, client, log, self);
|
|
175
189
|
},
|
|
176
190
|
};
|
|
177
191
|
|
package/src/runner/runner.ts
CHANGED
|
@@ -175,6 +175,9 @@ export async function runAgentLane(input: RunInput): Promise<RunOutcome> {
|
|
|
175
175
|
// (cross-dedup by file:line:sc) or that duplicates another discovery (self-dedup
|
|
176
176
|
// by file:line:patternId). Reuse the engine's `dedupeRecall` verbatim — one dedup
|
|
177
177
|
// discipline, not a second one. Survivors keep identity, so re-narrow to agent.
|
|
178
|
+
// Note (#2180): self-dedup keys on patternId, so two byte-identical PATTERNLESS
|
|
179
|
+
// discoveries at one file:line do NOT collapse here — each survives as its own
|
|
180
|
+
// agent finding (a patternless candidate has no identity to safely key on).
|
|
178
181
|
const survivors = new Set(dedupeRecall(discoveries, input.findings));
|
|
179
182
|
const agentFindings = discoveries.filter((d) => survivors.has(d));
|
|
180
183
|
|
package/src/sarif.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { relative } from "node:path";
|
|
16
16
|
import type { Impact } from "@binclusive/a11y-contract";
|
|
17
17
|
import { hasSelector, toContractProvenance } from "./emit-contract";
|
|
18
|
-
import { evidenceHelpUrl, evidenceImpact, type EnrichedFinding } from "./evidence";
|
|
18
|
+
import { evidenceHelpUrl, evidenceImpact, type EnrichedFinding, resolveDisplay } from "./evidence";
|
|
19
19
|
import { type LocationOptions, resolveLocations } from "./source-identity";
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -48,6 +48,17 @@ interface SarifLocation {
|
|
|
48
48
|
logicalLocations?: Array<{ fullyQualifiedName: string; kind: "element" }>;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// A `relatedLocations` entry: a location relevant to *understanding* a finding
|
|
52
|
+
// but that is not where the finding IS (SARIF §3.27.22). Here it is the rendered
|
|
53
|
+
// DOM node a SOURCE-anchored finding also names — a genuinely distinct second
|
|
54
|
+
// node beyond the code site. A CSS selector has no source region, so it rides a
|
|
55
|
+
// `logicalLocations` and carries no `physicalLocation` (§3.28 — all fields
|
|
56
|
+
// optional). No `id`: nothing links to it, so the spec says omit it (§3.28.2).
|
|
57
|
+
interface SarifRelatedLocation {
|
|
58
|
+
message: { text: string };
|
|
59
|
+
logicalLocations: Array<{ fullyQualifiedName: string; kind: "element" }>;
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
// GitHub code-scanning resolves a SARIF `uri` relative to the repo root, so when
|
|
52
63
|
// a workspace `root` is given the source-file uri is relativized against it — a
|
|
53
64
|
// scan of a staged mirror dir then still yields repo-relative `src/Foo.tsx`
|
|
@@ -59,26 +70,55 @@ function locationUri(file: string, root: string | undefined): string {
|
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
function findingLocations(f: EnrichedFinding, root: string | undefined): SarifLocation[] {
|
|
73
|
+
const hasRegion = f.line > 0;
|
|
62
74
|
const location: SarifLocation = {
|
|
63
75
|
physicalLocation: {
|
|
64
76
|
artifactLocation: { uri: locationUri(f.file, root) },
|
|
65
|
-
...(
|
|
77
|
+
...(hasRegion ? { region: { startLine: f.line } } : {}),
|
|
66
78
|
},
|
|
67
79
|
};
|
|
68
|
-
|
|
80
|
+
// A PAGE finding (no source region — its `file` is the page URL) addresses its
|
|
81
|
+
// offending node by CSS selector, so the selector is that primary node's
|
|
82
|
+
// logical address (§3.28). A SOURCE finding's selector names a DISTINCT
|
|
83
|
+
// rendered node, not a qualifier of the code line, so it rides
|
|
84
|
+
// `relatedLocations` (see {@link findingRelatedLocations}) — never both.
|
|
85
|
+
if (!hasRegion && hasSelector(f.selector)) {
|
|
69
86
|
location.logicalLocations = [{ fullyQualifiedName: f.selector, kind: "element" }];
|
|
70
87
|
}
|
|
71
88
|
return [location];
|
|
72
89
|
}
|
|
73
90
|
|
|
91
|
+
// A source-anchored finding (primary = `file:line`) that ALSO names a rendered
|
|
92
|
+
// DOM node has a genuinely distinct second location worth surfacing: the code
|
|
93
|
+
// line is where the finding IS; the element is context that helps understand it
|
|
94
|
+
// (e.g. a corpus-agent discovery grounded in a `jsx-a11y` line that names an
|
|
95
|
+
// `element`). A PAGE finding's selector is its PRIMARY node, not a related one,
|
|
96
|
+
// so it never reaches here — the result is graceful-empty for every other shape.
|
|
97
|
+
function findingRelatedLocations(f: EnrichedFinding): SarifRelatedLocation[] {
|
|
98
|
+
if (f.line > 0 && hasSelector(f.selector)) {
|
|
99
|
+
return [
|
|
100
|
+
{
|
|
101
|
+
message: { text: `Rendered element: ${f.selector}` },
|
|
102
|
+
logicalLocations: [{ fullyQualifiedName: f.selector, kind: "element" }],
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
74
109
|
/**
|
|
75
110
|
* Render a SARIF 2.1.0 log over the LOCAL findings. `runId` names the run in
|
|
76
111
|
* `automationDetails` (e.g. a PR number or scan id). Rules are the deduped set
|
|
77
|
-
* of fired rule ids; each
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
112
|
+
* of fired rule ids; each carries the fix prose in `help`/`fullDescription` —
|
|
113
|
+
* the field Copilot Autofix reads to GENERATE an edit (it ignores
|
|
114
|
+
* `result.fixes[]`, which this renderer deliberately never emits: a valid SARIF
|
|
115
|
+
* fix requires fabricated `artifactChanges`, violating suggestions-not-patches).
|
|
116
|
+
* Each result carries its impact level, source location, the rationale in
|
|
117
|
+
* `message`, an optional `relatedLocations` entry when it names a distinct
|
|
118
|
+
* rendered node, and a `properties.provenance` tag (`deterministic` | `agent`)
|
|
119
|
+
* so the two checker lanes stay distinguishable once both feed SARIF.
|
|
120
|
+
* `opts.root`, when given, relativizes source-file uris against the scanned
|
|
121
|
+
* root — the form GitHub code-scanning needs to anchor annotations on the PR diff.
|
|
82
122
|
*
|
|
83
123
|
* A source result also carries `partialFingerprints.primaryLocationLineHash` so
|
|
84
124
|
* code-scanning tracks the alert across commits as lines move (ADR 0042's "free
|
|
@@ -112,9 +152,18 @@ export function formatSarif(
|
|
|
112
152
|
rules: ruleIds.map((id) => {
|
|
113
153
|
const f = ruleById.get(id);
|
|
114
154
|
const helpUri = f ? evidenceHelpUrl(f) : null;
|
|
155
|
+
// The rule-generic fix prose. Copilot Autofix reads `help`/
|
|
156
|
+
// `fullDescription` (NOT `result.fixes[]`) to GENERATE its edit, so
|
|
157
|
+
// this is the lever for auto-fixability — the SC/rule-accurate fix
|
|
158
|
+
// guidance, single-sourced through {@link resolveDisplay}. It stays
|
|
159
|
+
// prose (suggestions-not-patches): guidance to generate from, never a
|
|
160
|
+
// fabricated edit. Absent when a finding carries no fix (evidence
|
|
161
|
+
// `none`) — then Autofix falls back to the message + help URL.
|
|
162
|
+
const fixProse = f ? resolveDisplay(f).fix : null;
|
|
115
163
|
return {
|
|
116
164
|
id,
|
|
117
165
|
...(f ? { shortDescription: { text: f.message } } : {}),
|
|
166
|
+
...(fixProse ? { fullDescription: { text: fixProse }, help: { text: fixProse } } : {}),
|
|
118
167
|
...(helpUri ? { helpUri } : {}),
|
|
119
168
|
};
|
|
120
169
|
}),
|
|
@@ -122,11 +171,21 @@ export function formatSarif(
|
|
|
122
171
|
},
|
|
123
172
|
results: findings.map((f) => {
|
|
124
173
|
const loc = located.get(f);
|
|
174
|
+
const related = findingRelatedLocations(f);
|
|
175
|
+
// The specific rationale/suggestion Autofix pulls snippets around. A
|
|
176
|
+
// DISCOVERY finding already folds observation+rationale+fix into
|
|
177
|
+
// `message`; an ENRICHED deterministic finding carries the suggestion
|
|
178
|
+
// separately in `agentNote`, so append it — prose, never a patch — so
|
|
179
|
+
// the SARIF message carries the agent's reasoning either way.
|
|
180
|
+
const message = f.agentNote !== undefined ? `${f.message} ${f.agentNote}` : f.message;
|
|
125
181
|
return {
|
|
126
182
|
ruleId: f.ruleId,
|
|
127
183
|
level: impactToLevel(evidenceImpact(f) ?? "unknown"),
|
|
128
|
-
message: { text:
|
|
184
|
+
message: { text: message },
|
|
129
185
|
locations: findingLocations(f, opts.root),
|
|
186
|
+
// Only present when a source-anchored finding names a distinct rendered
|
|
187
|
+
// node; graceful-empty (omitted) otherwise, never a fabricated spot.
|
|
188
|
+
...(related.length > 0 ? { relatedLocations: related } : {}),
|
|
130
189
|
// `<lineHash>:<index>` — the content hash lets code-scanning re-match the
|
|
131
190
|
// alert when the line moves; `index` disambiguates identical lines within a
|
|
132
191
|
// file, exactly as the finding identity does. A page finding has no source
|