@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.
@@ -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
- function gitDiffNames(workspace: string, baseSha: string, headSha: string): string[] {
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
- const out = execFileSync(
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
- // Advisory gate: a git failure is an empty scope, never a throw.
51
- return [];
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
- try {
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
- "Control resolves to a button but has no accessible name: children are empty or icon-only and there is no aria-label/aria-labelledby/title. Give it discernible text or an aria-label (corpus: 4.1.2-button-no-name).",
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
- 'Control resolves to an image but has no alt and no aria-label/aria-labelledby. Add an alt that conveys the image\'s meaning, or alt="" if decorative (corpus: 1.1.1).',
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
- "Control resolves to a link but has no discernible name: no text child and no aria-label/aria-labelledby/title. Give the link visible text or an aria-label that names its destination (corpus: 2.4.4-link-no-name).",
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
- "Control resolves to a dialog/modal but has no accessible name: no aria-label/aria-labelledby, no title/label prop, and no nested title subcomponent. Name the dialog so assistive tech announces it (corpus: 4.1.2).",
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
- "Control resolves to a form input but has no associated label: no aria-label/aria-labelledby, no label/title prop, and no id to pair with a <label for>. Associate a real label (corpus: 1.3.1 / 3.3.2-form-control-no-name).",
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: `This <${tag}> sets role="${value}", which ${native.suggest} conveys natively. Use ${native.suggest} instead of the role so the semantics work without ARIA (corpus: 1.3.1-prefer-tag-over-role).`,
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
- const MARKER_RE = /<!--\s*binclusive-a11y-agent:(.*?)\s*-->/;
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
- return m && m[1] !== undefined ? m[1] : null;
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; // not ours — never touch human comments
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;
@@ -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
- if (typeof f.line !== "number") continue;
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: typeof f.message === "string" ? f.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
- out.push({ id: (c as { id: number }).id, body: (c as { body: string }).body });
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
 
@@ -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
- ...(f.line > 0 ? { region: { startLine: f.line } } : {}),
77
+ ...(hasRegion ? { region: { startLine: f.line } } : {}),
66
78
  },
67
79
  };
68
- if (hasSelector(f.selector)) {
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 result carries its impact level, source location,
78
- * and a `properties.provenance` tag (`deterministic` | `agent`) so the two
79
- * checker lanes stay distinguishable once both feed SARIF. `opts.root`, when
80
- * given, relativizes source-file uris against the scanned root — the form
81
- * GitHub code-scanning needs to anchor annotations on the PR diff.
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: f.message },
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