@binclusive/a11y 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -13
- package/package.json +8 -3
- package/src/cli.ts +338 -104
- package/src/collect-android-xml.ts +447 -0
- package/src/contract.ts +9 -4
- package/src/core.ts +5 -1
- package/src/decisions-lint.ts +137 -0
- package/src/detect-stack.ts +70 -2
- package/src/diff-scope.ts +42 -20
- package/src/enforce.ts +18 -6
- package/src/finding-voice.ts +66 -0
- package/src/pr-comment.ts +30 -5
- package/src/reporter/finding.ts +7 -2
- package/src/reporter/github-adapter.ts +16 -2
- package/src/runner/runner.ts +3 -0
- package/src/sarif.ts +68 -9
package/src/cli.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { Effect, Option } from "effect";
|
|
|
6
6
|
import type { Impact } from "@binclusive/a11y-contract";
|
|
7
7
|
import { type AgentLaneOverrides, augmentWithAgentLane } from "./agent-lane";
|
|
8
8
|
import { collectTsx } from "./collect";
|
|
9
|
+
import { scanAndroidXml } from "./collect-android-xml";
|
|
9
10
|
// Type-only: the rendered-DOM lane (playwright/@axe-core) is loaded lazily inside
|
|
10
11
|
// `runCheckUrl` so the static `check` path carries no eager browser-stack import
|
|
11
12
|
// and the CI image can ship without it (issue #2133).
|
|
@@ -17,7 +18,7 @@ import { collectUnityFindings } from "./unity-findings";
|
|
|
17
18
|
import { type FindingProvenance, scan } from "./core";
|
|
18
19
|
import { type Evidence, type EnrichedFinding, enrichAll, evidenceImpact, resolveDisplay } from "./evidence";
|
|
19
20
|
import { runHookCli } from "./hook";
|
|
20
|
-
import { phoneHome } from "./phone-home";
|
|
21
|
+
import { type PhoneHomeDeps, phoneHome } from "./phone-home";
|
|
21
22
|
import { formatSarif } from "./sarif";
|
|
22
23
|
import {
|
|
23
24
|
GATE_OFF,
|
|
@@ -47,10 +48,14 @@ export function detailLines(f: EnrichedFinding): string[] {
|
|
|
47
48
|
: f.provenance === "swiftui"
|
|
48
49
|
? " (SwiftUI static)"
|
|
49
50
|
: "";
|
|
51
|
+
// Impact-first voice (#14): the message LEADS — it names the harmed user and
|
|
52
|
+
// the human consequence, the first thing a reader sees under the location. The
|
|
53
|
+
// rule id and WCAG SC follow as SECONDARY lines (and the corpus block further
|
|
54
|
+
// below), so the identifiers frame the finding without burying the impact.
|
|
50
55
|
const lines = [
|
|
56
|
+
` ${f.message}`,
|
|
51
57
|
` rule: ${f.ruleId} [${f.enforcement}]${via}`,
|
|
52
58
|
` wcag: ${scList}`,
|
|
53
|
-
` ${f.message}`,
|
|
54
59
|
];
|
|
55
60
|
|
|
56
61
|
// The display contract resolves the axe-vs-SC policy once (see resolveDisplay):
|
|
@@ -355,6 +360,66 @@ export function buildJsonReport(
|
|
|
355
360
|
};
|
|
356
361
|
}
|
|
357
362
|
|
|
363
|
+
/**
|
|
364
|
+
* The zeroed coverage literal for a stack with no component resolver (Liquid,
|
|
365
|
+
* Unity) or an empty scan — the `--json`/`buildJsonReport` shape stays identical
|
|
366
|
+
* to `check` even when there is nothing to resolve.
|
|
367
|
+
*/
|
|
368
|
+
const EMPTY_COVERAGE: Coverage = {
|
|
369
|
+
total: 0,
|
|
370
|
+
declared: 0,
|
|
371
|
+
registry: 0,
|
|
372
|
+
traced: 0,
|
|
373
|
+
opaque: 0,
|
|
374
|
+
trusted: 0,
|
|
375
|
+
icons: 0,
|
|
376
|
+
structural: 0,
|
|
377
|
+
declare: 0,
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/** The run context a machine-readable emit needs, threaded from each stack runner. */
|
|
381
|
+
interface EmitContext {
|
|
382
|
+
readonly root: string;
|
|
383
|
+
readonly runId: string;
|
|
384
|
+
readonly filesScanned: number;
|
|
385
|
+
readonly coverage: Coverage;
|
|
386
|
+
/** ABSOLUTE paths this run analyzed — phone-home relativizes them into `scannedPaths` (ADR 0043). */
|
|
387
|
+
readonly analyzedFiles: readonly string[];
|
|
388
|
+
readonly gate: GateConfig;
|
|
389
|
+
/** Test-only seam: override phone-home transport deps to capture the projected wire batch (#163 tracer). */
|
|
390
|
+
readonly phoneHome?: Partial<PhoneHomeDeps>;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The ONE machine-readable emit path, shared by every stack runner (#163). SARIF
|
|
395
|
+
* and phone-home are the only two consumers that carry findings off the machine,
|
|
396
|
+
* and both narrow through the canonical `@binclusive/a11y-contract` vocabulary —
|
|
397
|
+
* so a new stack (`check-shopify`, and next `check-swift`/`check-unity`/…) reaches
|
|
398
|
+
* the wire by routing HERE, never by re-forking a bespoke `buildJsonReport`→stdout
|
|
399
|
+
* path. `text` is intentionally excluded: the human report differs per stack and
|
|
400
|
+
* stays in each runner.
|
|
401
|
+
*/
|
|
402
|
+
async function emitFindings(
|
|
403
|
+
format: "json" | "sarif",
|
|
404
|
+
findings: readonly EnrichedFinding[],
|
|
405
|
+
ctx: EmitContext,
|
|
406
|
+
): Promise<void> {
|
|
407
|
+
if (format === "sarif") {
|
|
408
|
+
console.log(formatSarif(findings, ctx.runId, { root: ctx.root }));
|
|
409
|
+
} else {
|
|
410
|
+
console.log(JSON.stringify(buildJsonReport(ctx.root, ctx.filesScanned, ctx.coverage, findings), null, 2));
|
|
411
|
+
// OPTIONAL, non-blocking phone-home (#2108): file metadata-only findings to the
|
|
412
|
+
// dashboard when the CI env carries a `b8e_` token + org/project. Env-gated, so a
|
|
413
|
+
// local run silently skips; a failure is swallowed inside `phoneHome` and never
|
|
414
|
+
// changes the exit code. Inject this run's analyzed set (ADR 0043) as `scannedPaths`.
|
|
415
|
+
await phoneHome(findings, ctx.root, process.env, {
|
|
416
|
+
analyzedFiles: () => [...ctx.analyzedFiles],
|
|
417
|
+
...ctx.phoneHome,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
process.exitCode = gateExitCode(findings.map(toGateFinding), ctx.gate);
|
|
421
|
+
}
|
|
422
|
+
|
|
358
423
|
/**
|
|
359
424
|
* The shared report tail for both runners (source + rendered-DOM): empty-state,
|
|
360
425
|
* grouped findings, the totals rollup, and the blocking-gated exit code. Each
|
|
@@ -404,6 +469,37 @@ function renderReport(
|
|
|
404
469
|
process.exitCode = gateExitCode(findings.map(toGateFinding), gate);
|
|
405
470
|
}
|
|
406
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
|
+
|
|
407
503
|
/**
|
|
408
504
|
* The `check` command's runner. The optional {@link AgentLaneOverrides} is the AI
|
|
409
505
|
* lane's ONLY injection seam: the CLI handler never passes it (the lane resolves
|
|
@@ -430,14 +526,13 @@ export async function runCheck(
|
|
|
430
526
|
// Non-blocking — agent findings are warn-only, so the block-gated exit is
|
|
431
527
|
// computed on the augmented list and can only reflect the deterministic floor.
|
|
432
528
|
const findings = await augmentWithAgentLane(deterministic, root, process.env, agentOverrides);
|
|
433
|
-
|
|
434
|
-
process.exitCode = gateExitCode(findings.map(toGateFinding), gate);
|
|
529
|
+
await emitFindings("sarif", findings, { root, runId, filesScanned: files.length, coverage: EMPTY_COVERAGE, analyzedFiles: [], gate });
|
|
435
530
|
return;
|
|
436
531
|
}
|
|
437
532
|
|
|
438
533
|
if (json) {
|
|
439
534
|
if (files.length === 0) {
|
|
440
|
-
const report = buildJsonReport(root, 0,
|
|
535
|
+
const report = buildJsonReport(root, 0, EMPTY_COVERAGE, []);
|
|
441
536
|
console.log(JSON.stringify(report, null, 2));
|
|
442
537
|
return;
|
|
443
538
|
}
|
|
@@ -445,18 +540,17 @@ export async function runCheck(
|
|
|
445
540
|
const deterministic = enrichAll(result.findings);
|
|
446
541
|
// AI lane (issue #2182): agent findings flow through the SAME JSON report and
|
|
447
542
|
// phone-home envelope as the deterministic floor. When no key is present this
|
|
448
|
-
// returns `deterministic` unchanged.
|
|
543
|
+
// returns `deterministic` unchanged. Inject this run's true analyzed set (ADR
|
|
544
|
+
// 0043) so phone-home emits it as `scannedPaths` — 4b's reconcile keys on it.
|
|
449
545
|
const findings = await augmentWithAgentLane(deterministic, root, process.env, agentOverrides);
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
await phoneHome(findings, root, process.env, { analyzedFiles: () => result.analyzedFiles });
|
|
459
|
-
process.exitCode = gateExitCode(findings.map(toGateFinding), gate);
|
|
546
|
+
await emitFindings("json", findings, {
|
|
547
|
+
root,
|
|
548
|
+
runId,
|
|
549
|
+
filesScanned: files.length,
|
|
550
|
+
coverage: result.coverage,
|
|
551
|
+
analyzedFiles: result.analyzedFiles,
|
|
552
|
+
gate,
|
|
553
|
+
});
|
|
460
554
|
return;
|
|
461
555
|
}
|
|
462
556
|
|
|
@@ -504,8 +598,16 @@ function normalizeTarget(target: string): string {
|
|
|
504
598
|
* selectors instead of source lines. This is the source-less path — it inspects
|
|
505
599
|
* what actually ships, so it covers non-React pages and anything the static
|
|
506
600
|
* .tsx scan can't see (server-rendered markup, third-party widgets, runtime DOM).
|
|
601
|
+
*
|
|
602
|
+
* The optional {@link PhoneHomeDeps} overrides mirror `runCheck`'s `agentOverrides`
|
|
603
|
+
* seam: the CLI handler never passes them (phone-home resolves its config + fetch
|
|
604
|
+
* from the env), but the behavioral tracer test injects a stub `fetch` to prove a
|
|
605
|
+
* real rendered-URL finding reaches the wire as a page-shaped contract.
|
|
507
606
|
*/
|
|
508
|
-
async function runCheckUrl(
|
|
607
|
+
export async function runCheckUrl(
|
|
608
|
+
url: string,
|
|
609
|
+
phoneHomeOverrides: Partial<PhoneHomeDeps> = {},
|
|
610
|
+
): Promise<void> {
|
|
509
611
|
const target = normalizeTarget(url);
|
|
510
612
|
console.log(`a11y-checker — rendering ${target} and running axe-core\n`);
|
|
511
613
|
|
|
@@ -532,6 +634,21 @@ async function runCheckUrl(url: string): Promise<void> {
|
|
|
532
634
|
groupHeader: (ruleId) => ruleId,
|
|
533
635
|
formatItem: (f) => formatUrlFinding(f),
|
|
534
636
|
});
|
|
637
|
+
|
|
638
|
+
// OPTIONAL, non-blocking phone-home (#2335): route the rendered-URL findings
|
|
639
|
+
// through the SAME emit seam the static `check --json` path uses. Inside
|
|
640
|
+
// `phoneHome`, `toFindingPayloadLenient` → `resolveLocations` branches each
|
|
641
|
+
// `^https?://` finding to the canonical page-shaped `{ location: { kind: "page",
|
|
642
|
+
// url }, … }` contract and POSTs it when the CI env carries a `b8e_` token +
|
|
643
|
+
// org/project. Fully env-gated, swallows its own failures, and never touches the
|
|
644
|
+
// exit code the gate above set. The scanned page IS the scanned target — the
|
|
645
|
+
// same `url` vocabulary platform-side scope-reconcile keys on (#2166). `root` is
|
|
646
|
+
// the cwd: page findings resolve their location from the URL, not `root`, and a
|
|
647
|
+
// URL scan analyzes no source files (`scannedPaths` stays the safe empty set).
|
|
648
|
+
await phoneHome(findings, process.cwd(), process.env, {
|
|
649
|
+
scanTargets: () => [target],
|
|
650
|
+
...phoneHomeOverrides,
|
|
651
|
+
});
|
|
535
652
|
}
|
|
536
653
|
|
|
537
654
|
/**
|
|
@@ -542,7 +659,12 @@ async function runCheckUrl(url: string): Promise<void> {
|
|
|
542
659
|
* exactly as `runCheck` does. The Swift toolchain may be missing — `scanSwift`
|
|
543
660
|
* surfaces that as a one-line Error, handled like `runCheckUrl`'s launch failure.
|
|
544
661
|
*/
|
|
545
|
-
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> {
|
|
546
668
|
let result: Awaited<ReturnType<typeof scanSwift>>;
|
|
547
669
|
try {
|
|
548
670
|
result = await scanSwift(dir);
|
|
@@ -557,16 +679,22 @@ async function runCheckSwift(dir: string): Promise<void> {
|
|
|
557
679
|
// `root` it scanned in, so `relative(root, …)` here renders clean
|
|
558
680
|
// `Sources/…/X.swift:line` locations that agree with the engine's emitted paths.
|
|
559
681
|
const { root } = result;
|
|
560
|
-
console.log(`a11y-checker — scanning .swift under ${root} for SwiftUI a11y\n`);
|
|
561
|
-
|
|
562
682
|
const findings = enrichAll(result.findings);
|
|
563
683
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
+
);
|
|
570
698
|
}
|
|
571
699
|
|
|
572
700
|
/**
|
|
@@ -577,42 +705,54 @@ async function runCheckSwift(dir: string): Promise<void> {
|
|
|
577
705
|
* has no component resolver, so coverage is zeroed in the `--json` shape. A file
|
|
578
706
|
* the parser rejects is skipped (surfaced as a count), never fatal.
|
|
579
707
|
*/
|
|
580
|
-
async function runCheckShopify(
|
|
708
|
+
export async function runCheckShopify(
|
|
709
|
+
dir: string,
|
|
710
|
+
format: OutputFormat = "text",
|
|
711
|
+
runId = "local",
|
|
712
|
+
phoneHomeOverrides: Partial<PhoneHomeDeps> = {},
|
|
713
|
+
gate: GateConfig = GATE_OFF,
|
|
714
|
+
): Promise<void> {
|
|
581
715
|
const { root, files, findings: raw, parseErrors } = await scanLiquid(dir);
|
|
582
716
|
const findings = enrichAll(raw);
|
|
583
717
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
+
{
|
|
588
728
|
root,
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
729
|
+
runId,
|
|
730
|
+
filesScanned: files.length,
|
|
731
|
+
coverage: EMPTY_COVERAGE,
|
|
732
|
+
analyzedFiles: files,
|
|
733
|
+
gate,
|
|
734
|
+
phoneHome: phoneHomeOverrides,
|
|
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
|
+
);
|
|
616
756
|
}
|
|
617
757
|
|
|
618
758
|
/**
|
|
@@ -625,33 +765,67 @@ async function runCheckShopify(dir: string, json = false): Promise<void> {
|
|
|
625
765
|
* resolver, so coverage is zeroed in the `--json` shape — identical structure to
|
|
626
766
|
* `check-shopify --json`.
|
|
627
767
|
*/
|
|
628
|
-
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> {
|
|
629
774
|
const root = resolve(dir);
|
|
630
775
|
const findings = enrichAll(await collectUnityFindings(root));
|
|
631
776
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
+
);
|
|
793
|
+
}
|
|
646
794
|
|
|
647
|
-
|
|
795
|
+
/**
|
|
796
|
+
* The Android counterpart to `runCheck`: statically scan an Android project's
|
|
797
|
+
* `res/layout*` XML in-process (`scanAndroidXml`), enrich through the SAME corpus
|
|
798
|
+
* cross-ref, and report findings anchored on `file:line` like every other
|
|
799
|
+
* producer. No browser, no network, no second toolchain — Android layouts are
|
|
800
|
+
* plain XML parsed in Node. A missing or unreadable project dir is an empty scan,
|
|
801
|
+
* never a throw. The collector returns the canonical `root` it scanned so
|
|
802
|
+
* `relative(root, …)` renders clean `app/src/main/res/layout/…:line` locations.
|
|
803
|
+
*/
|
|
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);
|
|
811
|
+
const findings = enrichAll(raw);
|
|
648
812
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
+
);
|
|
655
829
|
}
|
|
656
830
|
|
|
657
831
|
async function runInit(suggest: boolean, dirArg: string): Promise<void> {
|
|
@@ -831,26 +1005,52 @@ function resolveFormat(format: Option.Option<OutputFormat>, json: boolean, sarif
|
|
|
831
1005
|
return Option.getOrElse(format, (): OutputFormat => (sarif ? "sarif" : json ? "json" : "text"));
|
|
832
1006
|
}
|
|
833
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
|
+
|
|
834
1040
|
const checkCommand = Command.make(
|
|
835
1041
|
"check",
|
|
836
|
-
{
|
|
837
|
-
dir: dirArg,
|
|
838
|
-
json: Options.boolean("json"),
|
|
839
|
-
sarif: Options.boolean("sarif"),
|
|
840
|
-
format: formatOption,
|
|
841
|
-
ci: ciOption,
|
|
842
|
-
runId: Options.text("run-id").pipe(Options.withDefault("local")),
|
|
843
|
-
failOn: failOnOption,
|
|
844
|
-
maxViolations: maxViolationsOption,
|
|
845
|
-
},
|
|
1042
|
+
{ dir: dirArg, ...gateOptions },
|
|
846
1043
|
({ dir, json, sarif, format, ci, runId, failOn, maxViolations }) => {
|
|
847
1044
|
const resolved = resolveFormat(format, json, sarif);
|
|
848
1045
|
return Effect.promise(() =>
|
|
849
|
-
runCheck(
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
1046
|
+
runCheck(
|
|
1047
|
+
dir,
|
|
1048
|
+
resolved === "json",
|
|
1049
|
+
resolved === "sarif",
|
|
1050
|
+
runId,
|
|
1051
|
+
{},
|
|
1052
|
+
resolveGate({ failOn, maxViolations, ci }),
|
|
1053
|
+
),
|
|
854
1054
|
);
|
|
855
1055
|
},
|
|
856
1056
|
).pipe(
|
|
@@ -869,29 +1069,62 @@ const checkUrlCommand = Command.make(
|
|
|
869
1069
|
),
|
|
870
1070
|
);
|
|
871
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
|
+
|
|
872
1076
|
const checkSwiftCommand = Command.make(
|
|
873
1077
|
"check-swift",
|
|
874
|
-
{ dir: dirArg },
|
|
875
|
-
({ dir
|
|
876
|
-
|
|
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
|
+
);
|
|
877
1088
|
|
|
878
1089
|
const checkShopifyCommand = Command.make(
|
|
879
1090
|
"check-shopify",
|
|
880
|
-
{ dir: dirArg,
|
|
881
|
-
({ dir, json
|
|
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
|
+
),
|
|
882
1099
|
).pipe(
|
|
883
1100
|
Command.withDescription(
|
|
884
|
-
"scan .liquid Shopify theme source for structural a11y findings (static, no browser; --json
|
|
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`)",
|
|
885
1102
|
),
|
|
886
1103
|
);
|
|
887
1104
|
|
|
888
1105
|
const checkUnityCommand = Command.make(
|
|
889
1106
|
"check-unity",
|
|
890
|
-
{ dir: dirArg,
|
|
891
|
-
({ 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
|
+
),
|
|
1112
|
+
).pipe(
|
|
1113
|
+
Command.withDescription(
|
|
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`)",
|
|
1115
|
+
),
|
|
1116
|
+
);
|
|
1117
|
+
|
|
1118
|
+
const checkAndroidCommand = Command.make(
|
|
1119
|
+
"check-android",
|
|
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
|
+
),
|
|
892
1125
|
).pipe(
|
|
893
1126
|
Command.withDescription(
|
|
894
|
-
"scan
|
|
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`)",
|
|
895
1128
|
),
|
|
896
1129
|
);
|
|
897
1130
|
|
|
@@ -976,6 +1209,7 @@ const rootCommand = Command.make("a11y-checker", { dir: rootDir }, ({ dir }) =>
|
|
|
976
1209
|
checkSwiftCommand,
|
|
977
1210
|
checkShopifyCommand,
|
|
978
1211
|
checkUnityCommand,
|
|
1212
|
+
checkAndroidCommand,
|
|
979
1213
|
initCommand,
|
|
980
1214
|
learnCommand,
|
|
981
1215
|
genCommand,
|