@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.
- package/.a11y_artifacts_test/evidence.json +52 -0
- package/.a11y_artifacts_test/gate-result.json +41 -0
- package/.a11y_artifacts_test/report.txt +19 -0
- package/.github/actions/a11y-ci/action.yml +106 -0
- package/.github/workflows/a11y-gate.yml +112 -0
- package/.github/workflows/ci.yml +68 -3
- package/.github/workflows/test-a11y-action.yml +93 -0
- package/.github/workflows/update-baseline.yml +49 -0
- package/.github/workflows/verify-docs.yml +26 -0
- package/CHANGELOG.md +33 -0
- package/GETTING_STARTED.md +87 -0
- package/HANDBOOK.md +747 -0
- package/README.md +202 -23
- package/assets/a11y-logo.png +0 -0
- package/docs/handbooks/A11Y-ASSIST.md +31 -0
- package/docs/handbooks/A11Y-CI.md +71 -0
- package/docs/handbooks/A11Y-DEMO-SITE.md +29 -0
- package/docs/handbooks/A11Y-EVIDENCE-ENGINE.md +31 -0
- package/docs/handbooks/A11Y-LINT.md +62 -0
- package/docs/handbooks/A11Y-MCP-TOOLS.md +34 -0
- package/docs/handbooks/ACCESSIBILITY-SUITE.md +51 -0
- package/docs/handbooks/ALLY-DEMO-PYTHON.md +23 -0
- package/docs/handbooks/COMMON-CONCEPTS.md +24 -0
- package/docs/handbooks/CURSORASSIST.md +18 -0
- package/docs/handbooks/README.md +20 -0
- package/docs/prov-spec/SETUP.md +1 -1
- package/docs/rules.md +132 -0
- package/docs/unified-artifacts.md +52 -0
- package/logo.png +0 -0
- package/package.json +1 -1
- package/pipelines/templates/a11y-ci.yml +135 -0
- package/pipelines/test-a11y-ci-template.yml +36 -0
- package/scripts/verify_handbooks.py +97 -0
- package/src/a11y-assist/README.md +5 -0
- package/src/a11y-ci/.a11y_artifacts_test/current.scorecard.json +11 -0
- package/src/a11y-ci/.a11y_artifacts_test/evidence.json +52 -0
- package/src/a11y-ci/.a11y_artifacts_test/gate-result.json +41 -0
- package/src/a11y-ci/.a11y_artifacts_test/report.txt +19 -0
- package/src/a11y-ci/README.md +83 -23
- package/src/a11y-ci/a11y_ci/allowlist.py +52 -9
- package/src/a11y-ci/a11y_ci/cli.py +143 -46
- package/src/a11y-ci/a11y_ci/error_ids.py +17 -0
- package/src/a11y-ci/a11y_ci/gate.py +83 -48
- package/src/a11y-ci/a11y_ci/help.py +119 -0
- package/src/a11y-ci/a11y_ci/mcp_payload.py +124 -0
- package/src/a11y-ci/a11y_ci/pr_comment.py +127 -0
- package/src/a11y-ci/a11y_ci/report.py +137 -0
- package/src/a11y-ci/a11y_ci/schema/scorecard.schema.json +89 -0
- package/src/a11y-ci/a11y_ci/schemas/allowlist.schema.json +11 -2
- package/src/a11y-ci/a11y_ci/scorecard.py +86 -30
- package/src/a11y-ci/a11y_ci/severity.py +29 -0
- package/src/a11y-ci/npm/README.md +47 -0
- package/src/a11y-ci/npm/package.json +1 -1
- package/src/a11y-ci/tests/fixtures/allowlist_expired.json +2 -1
- package/src/a11y-ci/tests/fixtures/allowlist_ok.json +2 -1
- package/src/a11y-ci/tests/fixtures/baseline_ok.json +17 -4
- package/src/a11y-ci/tests/fixtures/current_fail.json +10 -3
- package/src/a11y-ci/tests/fixtures/current_failures_many.json +11 -0
- package/src/a11y-ci/tests/fixtures/current_ok.json +10 -3
- package/src/a11y-ci/tests/fixtures/current_regresses.json +15 -4
- package/src/a11y-ci/tests/test_allowlist_v2.py +97 -0
- package/src/a11y-ci/tests/test_gate.py +3 -3
- package/src/a11y-ci/tests/test_mcp_cli.py +80 -0
- package/src/a11y-ci/tests/test_mcp_payload.py +76 -0
- package/src/a11y-ci/tests/test_polish.py +83 -0
- package/src/a11y-ci/tests/test_pr_comment.py +103 -0
- package/src/a11y-ci/tests/test_rule_help.py +70 -0
- package/src/a11y-ci/tests/test_schema_validation.py +36 -0
- package/src/a11y-ci/tests/test_scorecard_canonical.py +88 -0
- package/src/a11y-ci/tests/test_smoke_cli.py +41 -0
- package/src/a11y-evidence-engine/README.md +5 -0
- package/src/a11y-lint/README.md +5 -0
- package/src/a11y-lint/a11y_lint/cli.py +29 -0
- package/src/a11y-mcp-tools/README.md +5 -0
- 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": ["
|
|
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
|
}
|