@binclusive/a11y 0.1.1 → 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 CHANGED
@@ -7,6 +7,8 @@ A local accessibility checker for React/TSX code, grounded in a real-world audit
7
7
  This is a private review build. Clone it, point it at any React codebase (yours or ours), and see what it finds — no setup, no explanation needed.
8
8
 
9
9
  > **New here? Start with the [Getting Started](docs/GETTING-STARTED.md) walkthrough.** Zero to your first fix — install, `init`, wire your editor, read a finding, clear it, gate CI.
10
+ >
11
+ > **Just want it on your PRs?** The [CI Quickstart](docs/QUICKSTART-CI.md) is the 5-minute path — copy [`examples/github-actions/a11y.yml`](examples/github-actions/a11y.yml), open a PR, read the findings. No account, no secret.
10
12
 
11
13
  ---
12
14
 
@@ -134,9 +136,12 @@ jobs:
134
136
  a11y:
135
137
  runs-on: ubuntu-latest
136
138
  steps:
139
+ # fetch-depth: 0 — the a11y diff scan needs base history; a shallow clone finds 0 changed files (a11y#198)
137
140
  - uses: actions/checkout@v4
141
+ with:
142
+ fetch-depth: 0
138
143
  - id: a11y
139
- uses: Binclusive/a11y@main
144
+ uses: Binclusive/a11y@v0.1.1
140
145
  - if: always() # advisory gate exits 0; upload regardless of findings
141
146
  uses: github/codeql-action/upload-sarif@v3
142
147
  with:
@@ -148,11 +153,11 @@ provenance (`deterministic` vs `agent`) in the SARIF property bag. The SARIF
148
153
  file exists only to render on **your** GitHub — it carries file/line for local
149
154
  annotation and is never sent to the Binclusive dashboard.
150
155
 
151
- > **Pin for supply-chain safety.** The examples use `@main` for readability, but
152
- > production workflows should pin the Action to a commit SHA — `uses:
153
- > Binclusive/a11y@<sha> # v0.1.0` — rather than a floating tag or
154
- > branch, so a compromised upstream tag can't silently change what runs in your
155
- > CI. Dependabot (`github-actions` ecosystem) will bump the pin for you.
156
+ > **Pin for supply-chain safety.** The examples pin to the released tag
157
+ > `@v0.1.1`. For production, pin to a commit SHA — `uses:
158
+ > Binclusive/a11y@<sha> # v0.1.1` — rather than a floating tag or branch, so a
159
+ > moved tag can't silently change what runs in your CI. Dependabot
160
+ > (`github-actions` ecosystem) will bump the pin for you.
156
161
 
157
162
  ### Optional — opt into a blocking check (default off)
158
163
 
@@ -169,7 +174,7 @@ way.
169
174
 
170
175
  ```yaml
171
176
  - id: a11y
172
- uses: Binclusive/a11y@main
177
+ uses: Binclusive/a11y@v0.1.1
173
178
  with:
174
179
  fail-on: critical # optional — block only on critical findings
175
180
  # max-violations: 0 # optional — block on any finding at all
@@ -194,7 +199,7 @@ means "lane off", never an error; the scan still exits 0.
194
199
 
195
200
  ```yaml
196
201
  - id: a11y
197
- uses: Binclusive/a11y@main
202
+ uses: Binclusive/a11y@v0.1.1
198
203
  with:
199
204
  llm-api-key: ${{ secrets.LLM_API_KEY }} # optional — your BYOK model key
200
205
  llm-model: "" # optional — override the model
@@ -224,7 +229,7 @@ does not, and nothing new crosses the wire.
224
229
 
225
230
  ```yaml
226
231
  - id: a11y
227
- uses: Binclusive/a11y@main
232
+ uses: Binclusive/a11y@v0.1.1
228
233
  with:
229
234
  binclusive-app-id: ${{ vars.BINCLUSIVE_APP_ID }}
230
235
  binclusive-app-private-key: ${{ secrets.BINCLUSIVE_APP_PRIVATE_KEY }}
@@ -244,7 +249,7 @@ artifact:
244
249
 
245
250
  ```sh
246
251
  docker run --rm -v "$PWD:/workspace" -w /workspace -e A11Y_PLATFORM=null \
247
- ghcr.io/binclusive/a11y-checker:latest \
252
+ ghcr.io/binclusive/a11y:latest \
248
253
  check /workspace/src --ci --format sarif > a11y.sarif
249
254
  ```
250
255
 
@@ -265,6 +270,7 @@ native platform adapters build on, are in **[`docs/CI.md`](docs/CI.md)**.
265
270
  |---|---|
266
271
  | **Adopt it with your own design system** | **`WALKTHROUGH.md`** |
267
272
  | **Run it on any CI/CD (CircleCI / Jenkins / Drone / generic)** | **`docs/CI.md`** |
273
+ | **Ready-made configs for GitLab / CircleCI / Buildkite / Jenkins / Bitbucket** | **[`examples/ci/`](examples/ci/)** |
268
274
  | **Audit a live URL or HTML page (non-React)** | **`docs/AUDIT-URL.md`** |
269
275
  | The pitch + the moat, with numbers | `docs/decks/numbers.html` |
270
276
  | Real findings on real OSS projects | `docs/decks/showcase.html` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@binclusive/a11y",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Local accessibility checker for React/TSX, grounded in axe-core's published rule catalog. Runs entirely on your machine — no network, no upload.",
6
6
  "main": "./src/index.ts",
package/src/cli.ts CHANGED
@@ -21,7 +21,6 @@ import { runHookCli } from "./hook";
21
21
  import { type PhoneHomeDeps, phoneHome } from "./phone-home";
22
22
  import { formatSarif } from "./sarif";
23
23
  import {
24
- GATE_ADVISORY,
25
24
  GATE_OFF,
26
25
  type GateConfig,
27
26
  gateExitCode,
@@ -470,6 +469,37 @@ function renderReport(
470
469
  process.exitCode = gateExitCode(findings.map(toGateFinding), gate);
471
470
  }
472
471
 
472
+ /**
473
+ * The ONE report+gate path every stack runner shares (issue #176). A machine
474
+ * format routes through {@link emitFindings} (SARIF / JSON + phone-home); `text`
475
+ * routes through the human {@link renderReport}. BOTH branches derive the exit
476
+ * code from the SAME `gate` on `ctx` (via `gateExitCode`), so a stack scan's exit
477
+ * is identical across text / --json / --sarif and honors --fail-on /
478
+ * --max-violations / --ci everywhere. The old format-dependent split (advisory
479
+ * exit on the machine branch, block-gated exit on the text branch) is
480
+ * unrepresentable here: there is one gate, threaded once.
481
+ */
482
+ async function reportStack(
483
+ format: OutputFormat,
484
+ findings: readonly EnrichedFinding[],
485
+ ctx: EmitContext,
486
+ text: {
487
+ /** Human-report preamble (scan header / parse-skips) — printed for `text` only. */
488
+ readonly preamble: () => void;
489
+ readonly emptyMessage: string;
490
+ readonly groupKey: (f: EnrichedFinding) => string;
491
+ readonly groupHeader: (key: string) => string;
492
+ readonly formatItem: (f: EnrichedFinding) => string;
493
+ },
494
+ ): Promise<void> {
495
+ if (format !== "text") {
496
+ await emitFindings(format, findings, ctx);
497
+ return;
498
+ }
499
+ text.preamble();
500
+ renderReport(findings, text, ctx.gate);
501
+ }
502
+
473
503
  /**
474
504
  * The `check` command's runner. The optional {@link AgentLaneOverrides} is the AI
475
505
  * lane's ONLY injection seam: the CLI handler never passes it (the lane resolves
@@ -629,7 +659,12 @@ export async function runCheckUrl(
629
659
  * exactly as `runCheck` does. The Swift toolchain may be missing — `scanSwift`
630
660
  * surfaces that as a one-line Error, handled like `runCheckUrl`'s launch failure.
631
661
  */
632
- async function runCheckSwift(dir: string): Promise<void> {
662
+ async function runCheckSwift(
663
+ dir: string,
664
+ format: OutputFormat = "text",
665
+ runId = "local",
666
+ gate: GateConfig = GATE_OFF,
667
+ ): Promise<void> {
633
668
  let result: Awaited<ReturnType<typeof scanSwift>>;
634
669
  try {
635
670
  result = await scanSwift(dir);
@@ -644,16 +679,22 @@ async function runCheckSwift(dir: string): Promise<void> {
644
679
  // `root` it scanned in, so `relative(root, …)` here renders clean
645
680
  // `Sources/…/X.swift:line` locations that agree with the engine's emitted paths.
646
681
  const { root } = result;
647
- console.log(`a11y-checker — scanning .swift under ${root} for SwiftUI a11y\n`);
648
-
649
682
  const findings = enrichAll(result.findings);
650
683
 
651
- renderReport(findings, {
652
- emptyMessage: "No SwiftUI a11y violations found.",
653
- groupKey: (f) => f.file,
654
- groupHeader: (file) => relative(root, file),
655
- formatItem: (f) => formatFinding(f, root),
656
- });
684
+ // Swift's engine reports findings, not a scanned-file list — filesScanned/coverage
685
+ // stay zeroed in the machine report, identical to Unity (no component resolver).
686
+ await reportStack(
687
+ format,
688
+ findings,
689
+ { root, runId, filesScanned: 0, coverage: EMPTY_COVERAGE, analyzedFiles: [], gate },
690
+ {
691
+ preamble: () => console.log(`a11y-checker — scanning .swift under ${root} for SwiftUI a11y\n`),
692
+ emptyMessage: "No SwiftUI a11y violations found.",
693
+ groupKey: (f) => f.file,
694
+ groupHeader: (file) => relative(root, file),
695
+ formatItem: (f) => formatFinding(f, root),
696
+ },
697
+ );
657
698
  }
658
699
 
659
700
  /**
@@ -669,47 +710,49 @@ export async function runCheckShopify(
669
710
  format: OutputFormat = "text",
670
711
  runId = "local",
671
712
  phoneHomeOverrides: Partial<PhoneHomeDeps> = {},
713
+ gate: GateConfig = GATE_OFF,
672
714
  ): Promise<void> {
673
715
  const { root, files, findings: raw, parseErrors } = await scanLiquid(dir);
674
716
  const findings = enrichAll(raw);
675
717
 
676
- if (format !== "text") {
677
- // Route the Liquid scan through the SAME canonical emit path `check` uses
678
- // (#163): SARIF or the JSON report + `toContractFinding` phone-home projection,
679
- // so a Shopify scan hands real `@binclusive/a11y-contract` findings to a real
680
- // consumer instead of a bespoke `buildJsonReport`→stdout report. Liquid carries
681
- // no component resolver (zeroed coverage) and the whole scanned set is analyzed.
682
- // Non-blocking (`GATE_ADVISORY`): the stack scan reports, it doesn't gate (exit
683
- // 0 even with findings) — the opt-in blocking gate is a noted follow-up (#163 AC).
684
- await emitFindings(format, findings, {
718
+ // Route through the SAME report+gate path as `check` (#163 for the wire, #176
719
+ // for the gate): SARIF / JSON + `toContractFinding` phone-home on the machine
720
+ // branch, the human report on text — both gated by the SAME `gate`, so the exit
721
+ // is format-independent (no more advisory-on-json vs block-on-text split). Liquid
722
+ // carries no component resolver (zeroed coverage) and the whole scanned set is
723
+ // analyzed.
724
+ await reportStack(
725
+ format,
726
+ findings,
727
+ {
685
728
  root,
686
729
  runId,
687
730
  filesScanned: files.length,
688
731
  coverage: EMPTY_COVERAGE,
689
732
  analyzedFiles: files,
690
- gate: GATE_ADVISORY,
733
+ gate,
691
734
  phoneHome: phoneHomeOverrides,
692
- });
693
- return;
694
- }
695
-
696
- if (files.length === 0) {
697
- console.log(`No .liquid files under ${root}`);
698
- return;
699
- }
700
-
701
- console.log(`a11y-checker — scanned ${files.length} .liquid file(s) under ${root}`);
702
- if (parseErrors.length > 0) {
703
- console.log(` (${parseErrors.length} file(s) skipped — could not parse)`);
704
- }
705
- console.log("");
706
-
707
- renderReport(findings, {
708
- emptyMessage: "No Liquid a11y violations found.",
709
- groupKey: (f) => f.file,
710
- groupHeader: (file) => relative(root, file),
711
- formatItem: (f) => formatFinding(f, root),
712
- });
735
+ },
736
+ {
737
+ // A theme with no .liquid files is a distinct, more specific empty-state than
738
+ // "no findings" — preserve it (and skip the scan-count header entirely).
739
+ preamble: () => {
740
+ if (files.length === 0) return;
741
+ console.log(`a11y-checker — scanned ${files.length} .liquid file(s) under ${root}`);
742
+ if (parseErrors.length > 0) {
743
+ console.log(` (${parseErrors.length} file(s) skipped — could not parse)`);
744
+ }
745
+ console.log("");
746
+ },
747
+ emptyMessage:
748
+ files.length === 0
749
+ ? `No .liquid files under ${root}`
750
+ : "No Liquid a11y violations found.",
751
+ groupKey: (f) => f.file,
752
+ groupHeader: (file) => relative(root, file),
753
+ formatItem: (f) => formatFinding(f, root),
754
+ },
755
+ );
713
756
  }
714
757
 
715
758
  /**
@@ -722,33 +765,31 @@ export async function runCheckShopify(
722
765
  * resolver, so coverage is zeroed in the `--json` shape — identical structure to
723
766
  * `check-shopify --json`.
724
767
  */
725
- async function runCheckUnity(dir: string, json = false): Promise<void> {
768
+ async function runCheckUnity(
769
+ dir: string,
770
+ format: OutputFormat = "text",
771
+ runId = "local",
772
+ gate: GateConfig = GATE_OFF,
773
+ ): Promise<void> {
726
774
  const root = resolve(dir);
727
775
  const findings = enrichAll(await collectUnityFindings(root));
728
776
 
729
- if (json) {
730
- // Unity carries no resolver coverage emit the zeroed coverage literal so the
731
- // JSON shape stays identical to `check` / `check-shopify`.
732
- const report = buildJsonReport(
733
- root,
734
- 0,
735
- { total: 0, declared: 0, registry: 0, traced: 0, opaque: 0, trusted: 0, icons: 0, structural: 0, declare: 0 },
736
- findings,
737
- );
738
- console.log(JSON.stringify(report, null, 2));
739
- const blocking = findings.filter((f) => f.enforcement === "block").length;
740
- process.exitCode = blocking > 0 ? 1 : 0;
741
- return;
742
- }
743
-
744
- console.log(`a11y-checker — scanning Unity Force-Text scenes under ${root}\n`);
745
-
746
- renderReport(findings, {
747
- emptyMessage: "No Unity a11y violations found.",
748
- groupKey: (f) => f.file,
749
- groupHeader: (file) => relative(root, file),
750
- formatItem: (f) => formatFinding(f, root),
751
- });
777
+ // Unity carries no component resolver — coverage is zeroed so the machine report
778
+ // is identical in shape to `check` / `check-shopify`. Routing through the shared
779
+ // path (#176) replaces the old bespoke json-only exit with the SAME gate the text
780
+ // path uses, and adds the SARIF + phone-home wire the other stacks already reach.
781
+ await reportStack(
782
+ format,
783
+ findings,
784
+ { root, runId, filesScanned: 0, coverage: EMPTY_COVERAGE, analyzedFiles: [], gate },
785
+ {
786
+ preamble: () => console.log(`a11y-checker scanning Unity Force-Text scenes under ${root}\n`),
787
+ emptyMessage: "No Unity a11y violations found.",
788
+ groupKey: (f) => f.file,
789
+ groupHeader: (file) => relative(root, file),
790
+ formatItem: (f) => formatFinding(f, root),
791
+ },
792
+ );
752
793
  }
753
794
 
754
795
  /**
@@ -760,18 +801,31 @@ async function runCheckUnity(dir: string, json = false): Promise<void> {
760
801
  * never a throw. The collector returns the canonical `root` it scanned so
761
802
  * `relative(root, …)` renders clean `app/src/main/res/layout/…:line` locations.
762
803
  */
763
- async function runCheckAndroid(dir: string): Promise<void> {
764
- const { root, findings: raw } = await scanAndroidXml(dir);
765
- console.log(`a11y-checker scanning res/layout XML under ${root} for Android a11y\n`);
766
-
804
+ async function runCheckAndroid(
805
+ dir: string,
806
+ format: OutputFormat = "text",
807
+ runId = "local",
808
+ gate: GateConfig = GATE_OFF,
809
+ ): Promise<void> {
810
+ const { root, files, findings: raw } = await scanAndroidXml(dir);
767
811
  const findings = enrichAll(raw);
768
812
 
769
- renderReport(findings, {
770
- emptyMessage: "No Android XML a11y violations found.",
771
- groupKey: (f) => f.file,
772
- groupHeader: (file) => relative(root, file),
773
- formatItem: (f) => formatFinding(f, root),
774
- });
813
+ // Android layouts are plain XML parsed in Node — the scanned file set is real,
814
+ // so filesScanned/analyzedFiles carry it (unlike Swift/Unity). No component
815
+ // resolver, so coverage is zeroed. Same shared report+gate path as every stack.
816
+ await reportStack(
817
+ format,
818
+ findings,
819
+ { root, runId, filesScanned: files.length, coverage: EMPTY_COVERAGE, analyzedFiles: files, gate },
820
+ {
821
+ preamble: () =>
822
+ console.log(`a11y-checker — scanning res/layout XML under ${root} for Android a11y\n`),
823
+ emptyMessage: "No Android XML a11y violations found.",
824
+ groupKey: (f) => f.file,
825
+ groupHeader: (file) => relative(root, file),
826
+ formatItem: (f) => formatFinding(f, root),
827
+ },
828
+ );
775
829
  }
776
830
 
777
831
  async function runInit(suggest: boolean, dirArg: string): Promise<void> {
@@ -951,26 +1005,52 @@ function resolveFormat(format: Option.Option<OutputFormat>, json: boolean, sarif
951
1005
  return Option.getOrElse(format, (): OutputFormat => (sarif ? "sarif" : json ? "json" : "text"));
952
1006
  }
953
1007
 
1008
+ // The ONE gate + output-format flag set every `check*` command mounts (issue
1009
+ // #176). Sharing the exact same Options across `check` and each stack command
1010
+ // makes a divergent gate surface unrepresentable — no stack can quietly ship a
1011
+ // different exit-code policy than the reference `check` does.
1012
+ const gateOptions = {
1013
+ json: Options.boolean("json"),
1014
+ sarif: Options.boolean("sarif"),
1015
+ format: formatOption,
1016
+ ci: ciOption,
1017
+ runId: Options.text("run-id").pipe(Options.withDefault("local")),
1018
+ failOn: failOnOption,
1019
+ maxViolations: maxViolationsOption,
1020
+ };
1021
+
1022
+ /**
1023
+ * Resolve the {@link GateConfig} from the parsed gate flags — the ONE gate every
1024
+ * runner receives, so the exit-code policy (opt-in `--fail-on` / `--max-violations`
1025
+ * over the non-blocking `--ci` baseline over the default block-gated exit) is
1026
+ * decided identically for `check` and every stack command.
1027
+ */
1028
+ function resolveGate(a: {
1029
+ readonly failOn: Option.Option<Impact>;
1030
+ readonly maxViolations: Option.Option<number>;
1031
+ readonly ci: boolean;
1032
+ }): GateConfig {
1033
+ return {
1034
+ failOn: Option.getOrNull(a.failOn),
1035
+ maxViolations: Option.getOrNull(a.maxViolations),
1036
+ advisory: a.ci,
1037
+ };
1038
+ }
1039
+
954
1040
  const checkCommand = Command.make(
955
1041
  "check",
956
- {
957
- dir: dirArg,
958
- json: Options.boolean("json"),
959
- sarif: Options.boolean("sarif"),
960
- format: formatOption,
961
- ci: ciOption,
962
- runId: Options.text("run-id").pipe(Options.withDefault("local")),
963
- failOn: failOnOption,
964
- maxViolations: maxViolationsOption,
965
- },
1042
+ { dir: dirArg, ...gateOptions },
966
1043
  ({ dir, json, sarif, format, ci, runId, failOn, maxViolations }) => {
967
1044
  const resolved = resolveFormat(format, json, sarif);
968
1045
  return Effect.promise(() =>
969
- runCheck(dir, resolved === "json", resolved === "sarif", runId, {}, {
970
- failOn: Option.getOrNull(failOn),
971
- maxViolations: Option.getOrNull(maxViolations),
972
- advisory: ci,
973
- }),
1046
+ runCheck(
1047
+ dir,
1048
+ resolved === "json",
1049
+ resolved === "sarif",
1050
+ runId,
1051
+ {},
1052
+ resolveGate({ failOn, maxViolations, ci }),
1053
+ ),
974
1054
  );
975
1055
  },
976
1056
  ).pipe(
@@ -989,49 +1069,62 @@ const checkUrlCommand = Command.make(
989
1069
  ),
990
1070
  );
991
1071
 
1072
+ // Every `check*` stack command mounts the SAME `gateOptions` and threads the SAME
1073
+ // `resolveFormat` + `resolveGate` as `check` (issue #176): one output-format
1074
+ // selector, one gate, one format-independent exit-code rule across all stacks.
1075
+
992
1076
  const checkSwiftCommand = Command.make(
993
1077
  "check-swift",
994
- { dir: dirArg },
995
- ({ dir }) => Effect.promise(() => runCheckSwift(dir)),
996
- ).pipe(Command.withDescription("scan .swift for SwiftUI accessibility findings (static)"));
1078
+ { dir: dirArg, ...gateOptions },
1079
+ ({ dir, json, sarif, format, ci, runId, failOn, maxViolations }) =>
1080
+ Effect.promise(() =>
1081
+ runCheckSwift(dir, resolveFormat(format, json, sarif), runId, resolveGate({ failOn, maxViolations, ci })),
1082
+ ),
1083
+ ).pipe(
1084
+ Command.withDescription(
1085
+ "scan .swift for SwiftUI accessibility findings (static; --format text|json|sarif; --ci / --fail-on / --max-violations gate identically to `check`)",
1086
+ ),
1087
+ );
997
1088
 
998
1089
  const checkShopifyCommand = Command.make(
999
1090
  "check-shopify",
1000
- {
1001
- dir: dirArg,
1002
- json: Options.boolean("json"),
1003
- sarif: Options.boolean("sarif"),
1004
- format: formatOption,
1005
- runId: Options.text("run-id").pipe(Options.withDefault("local")),
1006
- },
1007
- ({ dir, json, sarif, format, runId }) =>
1008
- // Reuse the CANONICAL `--format text|json|sarif` selector (`--json`/`--sarif`
1009
- // aliases) the default `check` uses, so the Shopify scan reaches SARIF + the
1010
- // phone-home contract projection through the SAME path, not a bespoke flag (#163).
1011
- Effect.promise(() => runCheckShopify(dir, resolveFormat(format, json, sarif), runId)),
1091
+ { dir: dirArg, ...gateOptions },
1092
+ ({ dir, json, sarif, format, ci, runId, failOn, maxViolations }) =>
1093
+ // Reuse the CANONICAL `--format text|json|sarif` selector + gate the default
1094
+ // `check` uses, so the Shopify scan reaches SARIF + the phone-home contract
1095
+ // projection AND gates through the SAME path, not a bespoke flag (#163, #176).
1096
+ Effect.promise(() =>
1097
+ runCheckShopify(dir, resolveFormat(format, json, sarif), runId, {}, resolveGate({ failOn, maxViolations, ci })),
1098
+ ),
1012
1099
  ).pipe(
1013
1100
  Command.withDescription(
1014
- "scan .liquid Shopify theme source for structural a11y findings (static, no browser; --format text|json|sarif, canonical — routes findings through the same canonical contract wire path as `check`)",
1101
+ "scan .liquid Shopify theme source for structural a11y findings (static, no browser; --format text|json|sarif — routes findings through the same canonical contract wire path AND gate as `check`)",
1015
1102
  ),
1016
1103
  );
1017
1104
 
1018
1105
  const checkUnityCommand = Command.make(
1019
1106
  "check-unity",
1020
- { dir: dirArg, json: Options.boolean("json") },
1021
- ({ dir, json }) => Effect.promise(() => runCheckUnity(dir, json)),
1107
+ { dir: dirArg, ...gateOptions },
1108
+ ({ dir, json, sarif, format, ci, runId, failOn, maxViolations }) =>
1109
+ Effect.promise(() =>
1110
+ runCheckUnity(dir, resolveFormat(format, json, sarif), runId, resolveGate({ failOn, maxViolations, ci })),
1111
+ ),
1022
1112
  ).pipe(
1023
1113
  Command.withDescription(
1024
- "scan Unity Force-Text scenes (.prefab/.unity) for accessibility findings (static, no browser; --json: machine-readable)",
1114
+ "scan Unity Force-Text scenes (.prefab/.unity) for accessibility findings (static, no browser; --format text|json|sarif; --ci / --fail-on / --max-violations gate identically to `check`)",
1025
1115
  ),
1026
1116
  );
1027
1117
 
1028
1118
  const checkAndroidCommand = Command.make(
1029
1119
  "check-android",
1030
- { dir: dirArg },
1031
- ({ dir }) => Effect.promise(() => runCheckAndroid(dir)),
1120
+ { dir: dirArg, ...gateOptions },
1121
+ ({ dir, json, sarif, format, ci, runId, failOn, maxViolations }) =>
1122
+ Effect.promise(() =>
1123
+ runCheckAndroid(dir, resolveFormat(format, json, sarif), runId, resolveGate({ failOn, maxViolations, ci })),
1124
+ ),
1032
1125
  ).pipe(
1033
1126
  Command.withDescription(
1034
- "scan Android res/layout XML for accessibility findings (static, in-process — no browser, no toolchain)",
1127
+ "scan Android res/layout XML for accessibility findings (static, in-process — no browser, no toolchain; --format text|json|sarif; --ci / --fail-on / --max-violations gate identically to `check`)",
1035
1128
  ),
1036
1129
  );
1037
1130
 
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/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