@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 +16 -10
- package/package.json +1 -1
- package/src/cli.ts +205 -112
- package/src/diff-scope.ts +42 -20
- 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/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@
|
|
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
|
|
152
|
-
>
|
|
153
|
-
> Binclusive/a11y@<sha> # v0.1.
|
|
154
|
-
>
|
|
155
|
-
>
|
|
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@
|
|
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@
|
|
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@
|
|
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
|
|
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.
|
|
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(
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
|
733
|
+
gate,
|
|
691
734
|
phoneHome: phoneHomeOverrides,
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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(
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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(
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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(
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
|
996
|
-
|
|
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
|
-
|
|
1002
|
-
json
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
|
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,
|
|
1021
|
-
({ 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
|
|
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
|
|
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
|
-
|
|
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/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
|