@mcptoolshop/accessibility-suite 0.1.0 → 0.2.0

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.
Files changed (75) hide show
  1. package/.a11y_artifacts_test/evidence.json +52 -0
  2. package/.a11y_artifacts_test/gate-result.json +41 -0
  3. package/.a11y_artifacts_test/report.txt +19 -0
  4. package/.github/actions/a11y-ci/action.yml +106 -0
  5. package/.github/workflows/a11y-gate.yml +112 -0
  6. package/.github/workflows/ci.yml +68 -3
  7. package/.github/workflows/test-a11y-action.yml +93 -0
  8. package/.github/workflows/update-baseline.yml +49 -0
  9. package/.github/workflows/verify-docs.yml +26 -0
  10. package/CHANGELOG.md +33 -0
  11. package/GETTING_STARTED.md +87 -0
  12. package/HANDBOOK.md +747 -0
  13. package/README.md +202 -23
  14. package/assets/a11y-logo.png +0 -0
  15. package/docs/handbooks/A11Y-ASSIST.md +31 -0
  16. package/docs/handbooks/A11Y-CI.md +71 -0
  17. package/docs/handbooks/A11Y-DEMO-SITE.md +29 -0
  18. package/docs/handbooks/A11Y-EVIDENCE-ENGINE.md +31 -0
  19. package/docs/handbooks/A11Y-LINT.md +62 -0
  20. package/docs/handbooks/A11Y-MCP-TOOLS.md +34 -0
  21. package/docs/handbooks/ACCESSIBILITY-SUITE.md +51 -0
  22. package/docs/handbooks/ALLY-DEMO-PYTHON.md +23 -0
  23. package/docs/handbooks/COMMON-CONCEPTS.md +24 -0
  24. package/docs/handbooks/CURSORASSIST.md +18 -0
  25. package/docs/handbooks/README.md +20 -0
  26. package/docs/prov-spec/SETUP.md +1 -1
  27. package/docs/rules.md +132 -0
  28. package/docs/unified-artifacts.md +52 -0
  29. package/logo.png +0 -0
  30. package/package.json +1 -1
  31. package/pipelines/templates/a11y-ci.yml +135 -0
  32. package/pipelines/test-a11y-ci-template.yml +36 -0
  33. package/scripts/verify_handbooks.py +97 -0
  34. package/src/a11y-assist/README.md +5 -0
  35. package/src/a11y-ci/.a11y_artifacts_test/current.scorecard.json +11 -0
  36. package/src/a11y-ci/.a11y_artifacts_test/evidence.json +52 -0
  37. package/src/a11y-ci/.a11y_artifacts_test/gate-result.json +41 -0
  38. package/src/a11y-ci/.a11y_artifacts_test/report.txt +19 -0
  39. package/src/a11y-ci/README.md +83 -23
  40. package/src/a11y-ci/a11y_ci/allowlist.py +52 -9
  41. package/src/a11y-ci/a11y_ci/cli.py +143 -46
  42. package/src/a11y-ci/a11y_ci/error_ids.py +17 -0
  43. package/src/a11y-ci/a11y_ci/gate.py +83 -48
  44. package/src/a11y-ci/a11y_ci/help.py +119 -0
  45. package/src/a11y-ci/a11y_ci/mcp_payload.py +124 -0
  46. package/src/a11y-ci/a11y_ci/pr_comment.py +127 -0
  47. package/src/a11y-ci/a11y_ci/report.py +137 -0
  48. package/src/a11y-ci/a11y_ci/schema/scorecard.schema.json +89 -0
  49. package/src/a11y-ci/a11y_ci/schemas/allowlist.schema.json +11 -2
  50. package/src/a11y-ci/a11y_ci/scorecard.py +86 -30
  51. package/src/a11y-ci/a11y_ci/severity.py +29 -0
  52. package/src/a11y-ci/npm/README.md +47 -0
  53. package/src/a11y-ci/npm/package.json +1 -1
  54. package/src/a11y-ci/tests/fixtures/allowlist_expired.json +2 -1
  55. package/src/a11y-ci/tests/fixtures/allowlist_ok.json +2 -1
  56. package/src/a11y-ci/tests/fixtures/baseline_ok.json +17 -4
  57. package/src/a11y-ci/tests/fixtures/current_fail.json +10 -3
  58. package/src/a11y-ci/tests/fixtures/current_failures_many.json +11 -0
  59. package/src/a11y-ci/tests/fixtures/current_ok.json +10 -3
  60. package/src/a11y-ci/tests/fixtures/current_regresses.json +15 -4
  61. package/src/a11y-ci/tests/test_allowlist_v2.py +97 -0
  62. package/src/a11y-ci/tests/test_gate.py +3 -3
  63. package/src/a11y-ci/tests/test_mcp_cli.py +80 -0
  64. package/src/a11y-ci/tests/test_mcp_payload.py +76 -0
  65. package/src/a11y-ci/tests/test_polish.py +83 -0
  66. package/src/a11y-ci/tests/test_pr_comment.py +103 -0
  67. package/src/a11y-ci/tests/test_rule_help.py +70 -0
  68. package/src/a11y-ci/tests/test_schema_validation.py +36 -0
  69. package/src/a11y-ci/tests/test_scorecard_canonical.py +88 -0
  70. package/src/a11y-ci/tests/test_smoke_cli.py +41 -0
  71. package/src/a11y-evidence-engine/README.md +5 -0
  72. package/src/a11y-lint/README.md +5 -0
  73. package/src/a11y-lint/a11y_lint/cli.py +29 -0
  74. package/src/a11y-mcp-tools/README.md +5 -0
  75. package/tools/ado/a11y-ci.ps1 +195 -0
@@ -0,0 +1,119 @@
1
+ """Rule help registry for actionable fixes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Dict, Optional
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class HelpInfo:
11
+ """Help information for a finding."""
12
+
13
+ title: str
14
+ hint: str
15
+ url: str
16
+
17
+
18
+ # Base URL for rule documentation
19
+ DOCS_BASE_URL = "https://github.com/microsoft/accessibility-suite/blob/main/docs/rules.md"
20
+
21
+
22
+ def _make_url(anchor: str) -> str:
23
+ """Generate a URL for a rule anchor."""
24
+ return f"{DOCS_BASE_URL}#{anchor}"
25
+
26
+
27
+ # Static registry of help info
28
+ _REGISTRY: Dict[str, HelpInfo] = {
29
+ # Images
30
+ "A11Y.IMG.ALT": HelpInfo(
31
+ title="Missing Image Alt Text",
32
+ hint="Add an 'alt' attribute describing the image content, or alt='' if decorative.",
33
+ url=_make_url("a11yimgalt"),
34
+ ),
35
+
36
+ # Forms
37
+ "A11Y.FORM.LABEL": HelpInfo(
38
+ title="Missing Form Label",
39
+ hint="Ensure every input has a <label>, aria-label, or aria-labelledby.",
40
+ url=_make_url("a11yformlabel"),
41
+ ),
42
+ "A11Y.FORM.DUPLICATE": HelpInfo(
43
+ title="Duplicate Form Label ID",
44
+ hint="Ensure every 'for' attribute points to a unique input ID.",
45
+ url=_make_url("a11yformduplicate"),
46
+ ),
47
+
48
+ # Interactive Elements
49
+ "A11Y.BTN.NAME": HelpInfo(
50
+ title="Button Missing Name",
51
+ hint="Buttons must have text content or an aria-label.",
52
+ url=_make_url("a11ybtnname"),
53
+ ),
54
+ "A11Y.LINK.NAME": HelpInfo(
55
+ title="Link Missing Name",
56
+ hint="Links must have text content or an aria-label to be navigable.",
57
+ url=_make_url("a11ylinkname"),
58
+ ),
59
+ "A11Y.AREA.ALT": HelpInfo(
60
+ title="Missing Area Alt Text",
61
+ hint="<area> elements must have an 'alt' attribute describing the target.",
62
+ url=_make_url("a11yareaalt"),
63
+ ),
64
+
65
+ # Structure & ARIA
66
+ "A11Y.HTML.LANG": HelpInfo(
67
+ title="Missing Document Language",
68
+ hint="The <html> element must have a 'lang' attribute (e.g., lang='en').",
69
+ url=_make_url("a11yhtmllang"),
70
+ ),
71
+ "A11Y.ARIA.ROLES": HelpInfo(
72
+ title="Invalid ARIA Role",
73
+ hint="Ensure role attributes use valid WAI-ARIA values.",
74
+ url=_make_url("a11yariaroles"),
75
+ ),
76
+ "A11Y.ARIA.ATTR": HelpInfo(
77
+ title="Invalid ARIA Attribute",
78
+ hint="Ensure aria-* attributes are valid and allowed on this element.",
79
+ url=_make_url("a11yariaattr"),
80
+ ),
81
+ "A11Y.HEADING.ORDER": HelpInfo(
82
+ title="Skipped Heading Level",
83
+ hint="Headings should follow a valid sequence (h1 -> h2 -> h3). Do not skip levels.",
84
+ url=_make_url("a11yheadingorder"),
85
+ ),
86
+
87
+ # Perception
88
+ "A11Y.COLOR.CONTRAST": HelpInfo(
89
+ title="Low Color Contrast",
90
+ hint="Ensure text contrast ratio matches WCAG requirements (4.5:1 normal, 3:1 large).",
91
+ url=_make_url("a11ycolorcontrast"),
92
+ ),
93
+ "A11Y.META.VIEWPORT": HelpInfo(
94
+ title="Zoom Disabled",
95
+ hint="Do not block user zooming. Remove 'user-scalable=no' or 'maximum-scale' from viewport.",
96
+ url=_make_url("a11ymetaviewport"),
97
+ ),
98
+ "A11Y.DOC.TITLE": HelpInfo(
99
+ title="Missing Document Title",
100
+ hint="The <title> element must be present and non-empty.",
101
+ url=_make_url("a11ydoctitle"),
102
+ ),
103
+ # Legacy/Fixture IDs
104
+ "CLI.COLOR.ONLY": HelpInfo(
105
+ title="Color-Only Information",
106
+ hint="Do not rely solely on color to convey meaning; use text or icons too.",
107
+ url=_make_url("clicoloronly"),
108
+ ),
109
+ }
110
+
111
+
112
+ def get_help(finding_id: str) -> Optional[HelpInfo]:
113
+ """Get help info for a finding ID (case-insensitive normalization)."""
114
+ if not finding_id:
115
+ return None
116
+
117
+ # Normalize: uppercase, trimmed
118
+ key = finding_id.strip().upper()
119
+ return _REGISTRY.get(key)
@@ -0,0 +1,124 @@
1
+ """
2
+ MCP payload generation for accessibility evidence.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import datetime
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import uuid
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from . import __version__
15
+ from .gate import GateResult
16
+ from .help import get_help
17
+ from .scorecard import Scorecard, compute_fingerprint, finding_id
18
+
19
+
20
+ def sha256_file(path: str) -> str | None:
21
+ """Compute SHA256 hash of a file, returning None if not found."""
22
+ try:
23
+ with open(path, "rb") as f:
24
+ digest = hashlib.sha256()
25
+ while chunk := f.read(8192):
26
+ digest.update(chunk)
27
+ return digest.hexdigest()
28
+ except (FileNotFoundError, PermissionError):
29
+ return None
30
+
31
+
32
+ def build_mcp_payload(
33
+ result: GateResult,
34
+ scorecard: Scorecard,
35
+ fail_on: str,
36
+ artifacts: List[Dict[str, str]],
37
+ ) -> Dict[str, Any]:
38
+ """Build the structured MCP evidence payload."""
39
+
40
+ # 1. Environment metadata
41
+ env_repo = os.environ.get("GITHUB_REPOSITORY")
42
+ env_sha = os.environ.get("GITHUB_SHA")
43
+ env_workflow = os.environ.get("GITHUB_WORKFLOW")
44
+ env_run_id = os.environ.get("GITHUB_RUN_ID")
45
+
46
+ # 2. Gate details
47
+ gate_info = {
48
+ "decision": "pass" if result.ok else "fail",
49
+ "exit_code": 0 if result.ok else 3,
50
+ "fail_on": fail_on,
51
+ "counts": result.current_counts,
52
+ "deltas": {}, # Could populate if baseline comparison logic exposed logic for deltas
53
+ }
54
+
55
+ # Calculate deltas if baseline exists
56
+ if result.baseline_counts:
57
+ deltas = {}
58
+ for severity, count in result.current_counts.items():
59
+ base_count = result.baseline_counts.get(severity, 0)
60
+ diff = count - base_count
61
+ if diff != 0:
62
+ deltas[severity] = diff
63
+ gate_info["deltas"] = deltas
64
+
65
+ # 3. Blocking findings details
66
+ blocking_details = []
67
+
68
+ # Create a lookup for blocking status to include help details
69
+ # We only want to detail findings that are actually blocking,
70
+ # but the GateResult only gives us IDs.
71
+ # We need to find the full finding objects in the scorecard matching those IDs.
72
+
73
+ blocking_ids_set = set(result.current_blocking_ids)
74
+
75
+ # Optimization: Map IDs to help info once
76
+ help_map = {}
77
+ for fid in blocking_ids_set:
78
+ help_map[fid] = get_help(fid)
79
+
80
+ for f in scorecard.findings:
81
+ fid = finding_id(f)
82
+ if fid in blocking_ids_set:
83
+ # Reconstruct details
84
+ item = {
85
+ "id": fid,
86
+ "fingerprint": compute_fingerprint(f),
87
+ "severity": f.get("severity"),
88
+ "message": f.get("message"),
89
+ "location": f.get("location"),
90
+ }
91
+
92
+ help_data = help_map.get(fid)
93
+ if help_data:
94
+ item["help_url"] = help_data.url
95
+ item["help_hint"] = help_data.hint
96
+
97
+ blocking_details.append(item)
98
+
99
+ # 4. Artifacts
100
+ artifact_list = []
101
+ for art in artifacts:
102
+ path = art.get("path")
103
+ if path:
104
+ h = sha256_file(path)
105
+ if h:
106
+ artifact_list.append({
107
+ "kind": art.get("kind"),
108
+ "path": path,
109
+ "sha256": h
110
+ })
111
+
112
+ # Assemble final payload
113
+ return {
114
+ "tool": "a11y-ci",
115
+ "tool_version": __version__,
116
+ "run_id": str(uuid.uuid4()),
117
+ "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
118
+ "repo": env_repo,
119
+ "commit_sha": env_sha,
120
+ "workflow": env_workflow,
121
+ "gate": gate_info,
122
+ "blocking": blocking_details,
123
+ "artifacts": artifact_list,
124
+ }
@@ -0,0 +1,127 @@
1
+ """
2
+ PR Comment Formatter for a11y-ci evidence.
3
+ Renders MCP payloads into GitHub or ADO Markdown.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Dict, List
9
+
10
+ def render_pr_comment(payload: Dict[str, Any], *, platform: str = "github", top: int = 10) -> str:
11
+ """Render an MCP payload as a Markdown PR comment."""
12
+ is_github = platform == "github"
13
+
14
+ # 1. Header
15
+ gate = payload.get("gate", {})
16
+ decision = gate.get("decision", "unknown").lower()
17
+ pass_icon = "✅"
18
+ fail_icon = "❌"
19
+
20
+ if decision == "pass":
21
+ title = f"{pass_icon} Accessibility Gate: PASS"
22
+ else:
23
+ title = f"{fail_icon} Accessibility Gate: FAIL"
24
+
25
+ lines = [f"# {title}", ""]
26
+
27
+ # 2. Summary Table
28
+ counts = gate.get("counts", {})
29
+ deltas = gate.get("deltas", {})
30
+ fail_on = gate.get("fail_on", "serious")
31
+
32
+ lines.append("| Metric | Value | Delta |")
33
+ lines.append("|:---|:---|:---|")
34
+ lines.append(f"| **Fail Threshold** | `{fail_on}` | - |")
35
+
36
+ # Sort severities for consistent display
37
+ severities = ["critical", "serious", "moderate", "minor", "info"]
38
+ has_counts = False
39
+ for sev in severities:
40
+ if sev in counts:
41
+ has_counts = True
42
+ count = counts[sev]
43
+ delta = deltas.get(sev, 0)
44
+ delta_str = f"{delta:+d}" if delta != 0 else "-"
45
+ # Highlight failing severities
46
+ # Roughly: if sev >= fail_on and delta > 0 or absolute count issues
47
+ # For table simplicity, just list them.
48
+ lines.append(f"| {sev.title()} | {count} | {delta_str} |")
49
+
50
+ if not has_counts:
51
+ lines.append("| Findings | 0 | - |")
52
+
53
+ lines.append("")
54
+
55
+ # 3. Blocking Findings (Top N)
56
+ blocking = payload.get("blocking", [])
57
+ if blocking and top > 0:
58
+ lines.append("## 🚫 Blocking Findings")
59
+ if len(blocking) > top:
60
+ lines.append(f"_Showing first {top} of {len(blocking)} violations_")
61
+
62
+ # Sort by severity (rank) then ID for stability
63
+ # We need a rank map
64
+ severity_rank = {k: i for i, k in enumerate(reversed(severities))}
65
+
66
+ def sort_key(item):
67
+ s = item.get("severity", "info")
68
+ r = severity_rank.get(s, 0)
69
+ return (-r, item.get("id", ""))
70
+
71
+ sorted_blocking = sorted(blocking, key=sort_key)
72
+
73
+ for item in sorted_blocking[:top]:
74
+ fid = item.get("id", "UNKNOWN")
75
+ severity = item.get("severity", "info").upper()
76
+ msg = item.get("message", "No message provided")
77
+ loc = item.get("location") or "Unknown location"
78
+ hint = item.get("help_hint")
79
+ url = item.get("help_url")
80
+
81
+ # Formatting
82
+ if is_github:
83
+ header = f"### [{severity}] {fid}"
84
+ else:
85
+ header = f"### {severity}: {fid}"
86
+
87
+ lines.append(header)
88
+ lines.append(f"> {msg}")
89
+ lines.append("")
90
+ lines.append(f"- **Location**: `{loc}`")
91
+ if hint:
92
+ lines.append(f"- **Fix**: {hint}")
93
+ if url:
94
+ lines.append(f"- **Docs**: [Read Guidance]({url})")
95
+ lines.append("")
96
+
97
+
98
+ # 4. Artifacts
99
+ artifacts = payload.get("artifacts", [])
100
+ if artifacts:
101
+ lines.append("## 📦 Artifacts")
102
+ lines.append("| Kind | SHA256 Hash |")
103
+ lines.append("|:---|:---|")
104
+ for art in artifacts:
105
+ kind = art.get("kind", "unknown")
106
+ sha = art.get("sha256", "unknown")
107
+ # Truncate hash for readability
108
+ short_sha = sha[:12] + "..." if len(sha) > 12 else sha
109
+ lines.append(f"| {kind} | `{short_sha}` |")
110
+ lines.append("")
111
+
112
+ # 5. Footer
113
+ tool_ver = payload.get("tool_version", "unknown")
114
+ repo = payload.get("repo")
115
+ sha = payload.get("commit_sha")
116
+ run_id = payload.get("run_id", "").split("-")[-1] # Short run ID
117
+
118
+ footer_parts = [f"Generated by **a11y-ci v{tool_ver}**"]
119
+ if sha:
120
+ footer_parts.append(f"Commit: `{sha[:7]}`")
121
+ if run_id:
122
+ footer_parts.append(f"Run: `{run_id}`")
123
+
124
+ lines.append("---")
125
+ lines.append(" | ".join(footer_parts))
126
+
127
+ return "\n".join(lines)
@@ -0,0 +1,137 @@
1
+ """Reporting logic for gate results."""
2
+
3
+ import json
4
+ from dataclasses import asdict
5
+ from typing import Any, Dict, List
6
+
7
+ from .gate import GateResult
8
+ from .help import get_help
9
+ from .render import CliMessage, render
10
+ from .severity import SEVERITY_ORDER
11
+
12
+
13
+ def _format_counts(counts: Dict[str, int], baseline: Dict[str, int] | None) -> List[str]:
14
+ """Format counts with optional baseline deltas."""
15
+ lines = []
16
+ for sev in reversed(SEVERITY_ORDER):
17
+ count = counts.get(sev, 0)
18
+ base_count = baseline.get(sev, 0) if baseline else 0
19
+ delta = count - base_count
20
+
21
+ delta_str = ""
22
+ if baseline and delta != 0:
23
+ sign = "+" if delta > 0 else ""
24
+ delta_str = f" ({sign}{delta})"
25
+
26
+ if count > 0 or (baseline and base_count > 0):
27
+ lines.append(f"{sev.title()}: {count}{delta_str}")
28
+
29
+ if not lines:
30
+ lines.append("No findings.")
31
+ return lines
32
+
33
+
34
+ def render_text_report(result: GateResult, top: int = 10) -> str:
35
+ """Render human-readable report using CliMessage."""
36
+ counts_lines = _format_counts(result.current_counts, result.baseline_counts)
37
+
38
+ if result.ok:
39
+ msg = CliMessage(
40
+ status="OK",
41
+ id="A11Y.CI.GATE.PASS",
42
+ title="Accessibility gate passed",
43
+ what=["No policy violations detected."] + counts_lines,
44
+ why=["Current findings meet the configured threshold."],
45
+ fix=["Proceed with merge/release."],
46
+ )
47
+ return render(msg)
48
+
49
+ # Failure case
50
+ what_lines = ["Accessibility policy violations were detected."]
51
+ what_lines.append("")
52
+ what_lines.append("Summary:")
53
+ what_lines.extend([f" {line}" for line in counts_lines])
54
+
55
+ why_lines = result.reasons[:]
56
+
57
+ fix_lines = [
58
+ "Address the listed findings or update the baseline.",
59
+ "Run local check: a11y-ci gate --current <path>",
60
+ ]
61
+
62
+ if result.current_blocking_ids and top > 0:
63
+ fix_lines.append("")
64
+ fix_lines.append(f"Blocking IDs (Top {top}):")
65
+
66
+ limit = min(len(result.current_blocking_ids), top)
67
+ for bid in result.current_blocking_ids[:limit]:
68
+ info = get_help(bid)
69
+ if info:
70
+ # Add hint and link
71
+ fix_lines.append(f"- {bid}")
72
+ fix_lines.append(f" Fix: {info.hint}")
73
+ fix_lines.append(f" Docs: {info.url}")
74
+ else:
75
+ fix_lines.append(f"- {bid}")
76
+
77
+ remaining = len(result.current_blocking_ids) - limit
78
+ if remaining > 0:
79
+ fix_lines.append(f"... and {remaining} more.")
80
+
81
+ if result.new_blocking_ids and top > 0:
82
+ fix_lines.append("")
83
+ fix_lines.append(f"New Regression IDs (Top {top}):")
84
+ fix_lines.extend([f"- {bid}" for bid in result.new_blocking_ids[:top]])
85
+
86
+
87
+ msg = CliMessage(
88
+ status="ERROR",
89
+ id="A11Y.CI.GATE.FAIL",
90
+ title="Accessibility gate failed",
91
+ what=what_lines,
92
+ why=why_lines if why_lines else ["Gate policy was not satisfied."],
93
+ fix=fix_lines,
94
+ )
95
+ return render(msg)
96
+
97
+
98
+ def print_text_report(result: GateResult, top: int = 10):
99
+ """Print human-readable report (backward compatibility)."""
100
+ print(render_text_report(result, top), end="")
101
+
102
+
103
+ def get_json_report(result: GateResult) -> Dict[str, Any]:
104
+ """Get machine-readable JSON report dict."""
105
+
106
+ # Enrich blocking findings with help
107
+ blocking_details = []
108
+ for bid in result.current_blocking_ids:
109
+ item = {"id": bid}
110
+ info = get_help(bid)
111
+ if info:
112
+ item["help_url"] = info.url
113
+ item["help_hint"] = info.hint
114
+ else:
115
+ item["help_url"] = None
116
+ item["help_hint"] = None
117
+ blocking_details.append(item)
118
+
119
+ payload = {
120
+ "gate": "PASS" if result.ok else "FAIL",
121
+ "timestamp": "ISO8601-TODO", # or omit
122
+ "counts": result.current_counts,
123
+ "baseline_counts": result.baseline_counts,
124
+ "reasons": result.reasons,
125
+ "blocking": {
126
+ "current_ids": result.current_blocking_ids,
127
+ "details": blocking_details, # enriched list
128
+ "new_ids": result.new_blocking_ids,
129
+ "new_fingerprints": result.new_fingerprints if hasattr(result, "new_fingerprints") else [],
130
+ }
131
+ }
132
+ return payload
133
+
134
+
135
+ def print_json_report(result: GateResult):
136
+ """Print machine-readable JSON report."""
137
+ print(json.dumps(get_json_report(result), indent=2))
@@ -0,0 +1,89 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "a11y-ci Scorecard",
4
+ "description": "Standard accessibility finding scorecard for gating",
5
+ "type": "object",
6
+ "required": ["meta", "findings"],
7
+ "properties": {
8
+ "meta": {
9
+ "type": "object",
10
+ "required": ["tool"],
11
+ "properties": {
12
+ "tool": {
13
+ "type": "string",
14
+ "description": "Name of the tool that generated this scorecard"
15
+ },
16
+ "version": {
17
+ "type": "string",
18
+ "description": "Version of the tool"
19
+ },
20
+ "url": {
21
+ "type": "string",
22
+ "format": "uri",
23
+ "description": "URL to the tool documentation or repository"
24
+ }
25
+ }
26
+ },
27
+ "summary": {
28
+ "type": "object",
29
+ "description": "Optional summary counts by severity",
30
+ "additionalProperties": {
31
+ "type": "integer",
32
+ "minimum": 0
33
+ }
34
+ },
35
+ "findings": {
36
+ "type": "array",
37
+ "description": "List of accessibility findings",
38
+ "items": {
39
+ "type": "object",
40
+ "required": ["id", "severity", "message"],
41
+ "properties": {
42
+ "id": {
43
+ "type": "string",
44
+ "description": "Stable identifier for the rule violated (e.g. 'aria-hidden-body')"
45
+ },
46
+ "rule_id": {
47
+ "type": "string",
48
+ "description": "Legacy alias for 'id'"
49
+ },
50
+ "finding_id": {
51
+ "type": "string",
52
+ "description": "Legacy alias for 'id'"
53
+ },
54
+ "severity": {
55
+ "type": "string",
56
+ "enum": ["info", "minor", "moderate", "serious", "critical"],
57
+ "description": "Severity level of the finding"
58
+ },
59
+ "message": {
60
+ "type": "string",
61
+ "description": "Human-readable description of the issue"
62
+ },
63
+ "location": {
64
+ "type": "object",
65
+ "description": "Location of the issue",
66
+ "properties": {
67
+ "path": {
68
+ "type": "string",
69
+ "description": "File path or URL"
70
+ },
71
+ "line": {
72
+ "type": "integer",
73
+ "minimum": 1
74
+ },
75
+ "column": {
76
+ "type": "integer",
77
+ "minimum": 1
78
+ }
79
+ }
80
+ },
81
+ "fingerprint": {
82
+ "type": "string",
83
+ "description": "Stable hash of the finding for deduping"
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }
@@ -10,11 +10,20 @@
10
10
  "type": "array",
11
11
  "items": {
12
12
  "type": "object",
13
- "required": ["finding_id", "expires", "reason"],
13
+ "required": ["expires", "reason", "owner"],
14
+ "oneOf": [
15
+ { "required": ["finding_id"] },
16
+ { "required": ["id"] },
17
+ { "required": ["fingerprint"] }
18
+ ],
14
19
  "properties": {
15
20
  "finding_id": { "type": "string", "minLength": 3 },
21
+ "id": { "type": "string", "minLength": 3 },
22
+ "fingerprint": { "type": "string", "minLength": 64, "maxLength": 64 },
16
23
  "expires": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
17
- "reason": { "type": "string", "minLength": 10 }
24
+ "reason": { "type": "string", "minLength": 10 },
25
+ "owner": { "type": "string", "minLength": 3 },
26
+ "ticket": { "type": "string" }
18
27
  },
19
28
  "additionalProperties": false
20
29
  }