@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/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
- console.log(formatSarif(findings, runId, { root }));
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, { total: 0, declared: 0, registry: 0, traced: 0, opaque: 0, trusted: 0, icons: 0, structural: 0, declare: 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
- const report = buildJsonReport(root, files.length, result.coverage, findings);
451
- console.log(JSON.stringify(report, null, 2));
452
- // OPTIONAL, non-blocking phone-home (#2108): file metadata-only findings to
453
- // the dashboard when the CI env carries a `b8e_` token + org/project. Fully
454
- // env-gated, so a local `check --json` (no such env) silently skips; a
455
- // failure here is swallowed inside `phoneHome` and never changes exit code.
456
- // Inject this run's true analyzed set (ADR 0043) so phone-home emits it as
457
- // `scannedPaths` — the source-scan-scope coverage 4b's reconcile keys on.
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(url: string): Promise<void> {
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(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> {
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
- renderReport(findings, {
565
- emptyMessage: "No SwiftUI a11y violations found.",
566
- groupKey: (f) => f.file,
567
- groupHeader: (file) => relative(root, file),
568
- formatItem: (f) => formatFinding(f, root),
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(dir: string, json = false): Promise<void> {
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
- if (json) {
585
- // Liquid carries no resolver coverage emit the zeroed coverage literal so
586
- // the JSON shape stays identical to `check`.
587
- const report = buildJsonReport(
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
- files.length,
590
- { total: 0, declared: 0, registry: 0, traced: 0, opaque: 0, trusted: 0, icons: 0, structural: 0, declare: 0 },
591
- findings,
592
- );
593
- console.log(JSON.stringify(report, null, 2));
594
- const blocking = findings.filter((f) => f.enforcement === "block").length;
595
- process.exitCode = blocking > 0 ? 1 : 0;
596
- return;
597
- }
598
-
599
- if (files.length === 0) {
600
- console.log(`No .liquid files under ${root}`);
601
- return;
602
- }
603
-
604
- console.log(`a11y-checker — scanned ${files.length} .liquid file(s) under ${root}`);
605
- if (parseErrors.length > 0) {
606
- console.log(` (${parseErrors.length} file(s) skipped — could not parse)`);
607
- }
608
- console.log("");
609
-
610
- renderReport(findings, {
611
- emptyMessage: "No Liquid a11y violations found.",
612
- groupKey: (f) => f.file,
613
- groupHeader: (file) => relative(root, file),
614
- formatItem: (f) => formatFinding(f, root),
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(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> {
629
774
  const root = resolve(dir);
630
775
  const findings = enrichAll(await collectUnityFindings(root));
631
776
 
632
- if (json) {
633
- // Unity carries no resolver coverage emit the zeroed coverage literal so the
634
- // JSON shape stays identical to `check` / `check-shopify`.
635
- const report = buildJsonReport(
636
- root,
637
- 0,
638
- { total: 0, declared: 0, registry: 0, traced: 0, opaque: 0, trusted: 0, icons: 0, structural: 0, declare: 0 },
639
- findings,
640
- );
641
- console.log(JSON.stringify(report, null, 2));
642
- const blocking = findings.filter((f) => f.enforcement === "block").length;
643
- process.exitCode = blocking > 0 ? 1 : 0;
644
- return;
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
- console.log(`a11y-checker — scanning Unity Force-Text scenes under ${root}\n`);
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
- renderReport(findings, {
650
- emptyMessage: "No Unity a11y violations found.",
651
- groupKey: (f) => f.file,
652
- groupHeader: (file) => relative(root, file),
653
- formatItem: (f) => formatFinding(f, root),
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(dir, resolved === "json", resolved === "sarif", runId, {}, {
850
- failOn: Option.getOrNull(failOn),
851
- maxViolations: Option.getOrNull(maxViolations),
852
- advisory: ci,
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 }) => Effect.promise(() => runCheckSwift(dir)),
876
- ).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
+ );
877
1088
 
878
1089
  const checkShopifyCommand = Command.make(
879
1090
  "check-shopify",
880
- { dir: dirArg, json: Options.boolean("json") },
881
- ({ dir, json }) => Effect.promise(() => runCheckShopify(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: machine-readable)",
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, json: Options.boolean("json") },
891
- ({ 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
+ ),
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 Unity Force-Text scenes (.prefab/.unity) for accessibility findings (static, no browser; --json: machine-readable)",
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,