@mcptoolshop/accessibility-suite 0.1.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/.github/workflows/ci.yml +63 -0
- package/LICENSE +21 -0
- package/README.md +37 -0
- package/docs/prov-spec/.github/workflows/ci.yml +68 -0
- package/docs/prov-spec/CHANGELOG.md +69 -0
- package/docs/prov-spec/CODE_OF_CONDUCT.md +129 -0
- package/docs/prov-spec/CONFORMANCE_LEVELS.md +223 -0
- package/docs/prov-spec/CONTRIBUTING.md +145 -0
- package/docs/prov-spec/IMPLEMENTER_CHECKLIST.md +137 -0
- package/docs/prov-spec/LICENSE +21 -0
- package/docs/prov-spec/PRESS_RELEASE.md +74 -0
- package/docs/prov-spec/README.md +182 -0
- package/docs/prov-spec/SETUP.md +135 -0
- package/docs/prov-spec/WHY.md +86 -0
- package/docs/prov-spec/examples/artifact.example.json +14 -0
- package/docs/prov-spec/examples/artifact.ref.example.json +9 -0
- package/docs/prov-spec/examples/evidence.example.json +6 -0
- package/docs/prov-spec/examples/mcp.envelope.example.json +97 -0
- package/docs/prov-spec/examples/mcp.request.example.json +28 -0
- package/docs/prov-spec/examples/prov.record.example.json +35 -0
- package/docs/prov-spec/interop/PROOF_NODE_ENGINE.md +114 -0
- package/docs/prov-spec/spec/MCP_COMPATIBILITY.md +241 -0
- package/docs/prov-spec/spec/PROV_METHODS_CATALOG.md +142 -0
- package/docs/prov-spec/spec/PROV_METHODS_SPEC.md +397 -0
- package/docs/prov-spec/spec/methods.json +213 -0
- package/docs/prov-spec/spec/schemas/artifact.ref.schema.v0.1.json +58 -0
- package/docs/prov-spec/spec/schemas/artifact.schema.v0.1.json +61 -0
- package/docs/prov-spec/spec/schemas/assist.request.schema.v0.1.json +52 -0
- package/docs/prov-spec/spec/schemas/assist.response.schema.v0.1.json +70 -0
- package/docs/prov-spec/spec/schemas/cli.error.schema.v0.1.json +78 -0
- package/docs/prov-spec/spec/schemas/evidence.schema.v0.1.json +37 -0
- package/docs/prov-spec/spec/schemas/mcp.envelope.schema.v0.1.json +141 -0
- package/docs/prov-spec/spec/schemas/mcp.request.schema.v0.1.json +79 -0
- package/docs/prov-spec/spec/schemas/methods.schema.json +93 -0
- package/docs/prov-spec/spec/schemas/prov-capabilities.schema.json +122 -0
- package/docs/prov-spec/spec/schemas/prov.record.schema.v0.1.json +133 -0
- package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/expected.json +4 -0
- package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/input.json +1 -0
- package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/negative/double_wrapped.json +14 -0
- package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/negative/wrong_schema_version.json +11 -0
- package/docs/prov-spec/spec/vectors/engine.extract.evidence.json_pointer/expected.json +24 -0
- package/docs/prov-spec/spec/vectors/engine.extract.evidence.json_pointer/input.json +8 -0
- package/docs/prov-spec/spec/vectors/integrity.digest.sha256/expected.json +7 -0
- package/docs/prov-spec/spec/vectors/integrity.digest.sha256/input.json +1 -0
- package/docs/prov-spec/spec/vectors/integrity.digest.sha256/negative/non_hex_chars.json +16 -0
- package/docs/prov-spec/spec/vectors/integrity.digest.sha256/negative/uppercase_hex.json +16 -0
- package/docs/prov-spec/spec/vectors/integrity.digest.sha256/negative/wrong_length.json +16 -0
- package/docs/prov-spec/spec/vectors/method_id_syntax/negative/hyphen_separator.json +8 -0
- package/docs/prov-spec/spec/vectors/method_id_syntax/negative/reserved_namespace.json +8 -0
- package/docs/prov-spec/spec/vectors/method_id_syntax/negative/starts_with_digit.json +8 -0
- package/docs/prov-spec/spec/vectors/method_id_syntax/negative/uppercase.json +8 -0
- package/docs/prov-spec/spec/vectors/method_id_syntax/positive/valid_ids.json +18 -0
- package/docs/prov-spec/tools/python/prov_validator.py +428 -0
- package/examples/a11y-demo-site/.github/workflows/a11y-artifacts.yml +81 -0
- package/examples/a11y-demo-site/.github/workflows/a11y.yml +34 -0
- package/examples/a11y-demo-site/CODE_OF_CONDUCT.md +129 -0
- package/examples/a11y-demo-site/CONTRIBUTING.md +83 -0
- package/examples/a11y-demo-site/LICENSE +21 -0
- package/examples/a11y-demo-site/README.md +155 -0
- package/examples/a11y-demo-site/html/contact.html +15 -0
- package/examples/a11y-demo-site/html/index.html +20 -0
- package/examples/a11y-demo-site/scripts/a11y.sh +20 -0
- package/package.json +26 -0
- package/src/a11y-assist/.github/workflows/publish.yml +52 -0
- package/src/a11y-assist/.github/workflows/test.yml +30 -0
- package/src/a11y-assist/A11Y_ASSIST_TEST_COVERAGE_REQUIREMENTS.md +104 -0
- package/src/a11y-assist/CODE_OF_CONDUCT.md +129 -0
- package/src/a11y-assist/CONTRIBUTING.md +98 -0
- package/src/a11y-assist/ENGINE.md +363 -0
- package/src/a11y-assist/LICENSE +21 -0
- package/src/a11y-assist/PRESS_RELEASE.md +71 -0
- package/src/a11y-assist/QUICKSTART.md +101 -0
- package/src/a11y-assist/README.md +192 -0
- package/src/a11y-assist/RELEASE_NOTES.md +319 -0
- package/src/a11y-assist/a11y_assist/__init__.py +3 -0
- package/src/a11y-assist/a11y_assist/cli.py +599 -0
- package/src/a11y-assist/a11y_assist/from_cli_error.py +149 -0
- package/src/a11y-assist/a11y_assist/guard.py +444 -0
- package/src/a11y-assist/a11y_assist/ingest.py +407 -0
- package/src/a11y-assist/a11y_assist/methods.py +137 -0
- package/src/a11y-assist/a11y_assist/parse_raw.py +71 -0
- package/src/a11y-assist/a11y_assist/profiles/__init__.py +29 -0
- package/src/a11y-assist/a11y_assist/profiles/cognitive_load.py +245 -0
- package/src/a11y-assist/a11y_assist/profiles/cognitive_load_render.py +86 -0
- package/src/a11y-assist/a11y_assist/profiles/dyslexia.py +144 -0
- package/src/a11y-assist/a11y_assist/profiles/dyslexia_render.py +77 -0
- package/src/a11y-assist/a11y_assist/profiles/plain_language.py +119 -0
- package/src/a11y-assist/a11y_assist/profiles/plain_language_render.py +66 -0
- package/src/a11y-assist/a11y_assist/profiles/screen_reader.py +348 -0
- package/src/a11y-assist/a11y_assist/profiles/screen_reader_render.py +89 -0
- package/src/a11y-assist/a11y_assist/render.py +95 -0
- package/src/a11y-assist/a11y_assist/schemas/assist.request.schema.v0.1.json +52 -0
- package/src/a11y-assist/a11y_assist/schemas/assist.response.schema.v0.1.json +70 -0
- package/src/a11y-assist/a11y_assist/schemas/cli.error.schema.v0.1.json +78 -0
- package/src/a11y-assist/a11y_assist/storage.py +31 -0
- package/src/a11y-assist/pyproject.toml +60 -0
- package/src/a11y-assist/tests/__init__.py +1 -0
- package/src/a11y-assist/tests/fixtures/base_inputs/cli_error_high.json +18 -0
- package/src/a11y-assist/tests/fixtures/base_inputs/cli_error_medium.json +16 -0
- package/src/a11y-assist/tests/fixtures/base_inputs/raw_text_low.txt +3 -0
- package/src/a11y-assist/tests/fixtures/cli_error_good.json +9 -0
- package/src/a11y-assist/tests/fixtures/cli_error_missing_id.json +7 -0
- package/src/a11y-assist/tests/fixtures/cli_error_string_format.json +7 -0
- package/src/a11y-assist/tests/fixtures/expected/cognitive_load_high.txt +20 -0
- package/src/a11y-assist/tests/fixtures/expected/dyslexia_high.txt +20 -0
- package/src/a11y-assist/tests/fixtures/expected/lowvision_high.txt +18 -0
- package/src/a11y-assist/tests/fixtures/expected/plain_language_high.txt +14 -0
- package/src/a11y-assist/tests/fixtures/expected/screen_reader_high.txt +19 -0
- package/src/a11y-assist/tests/fixtures/golden_screen_reader_cli_error.txt +16 -0
- package/src/a11y-assist/tests/fixtures/golden_screen_reader_raw_no_id.txt +14 -0
- package/src/a11y-assist/tests/fixtures/golden_screen_reader_raw_with_id.txt +14 -0
- package/src/a11y-assist/tests/fixtures/raw_good.txt +11 -0
- package/src/a11y-assist/tests/fixtures/raw_no_id.txt +2 -0
- package/src/a11y-assist/tests/test_cognitive_load.py +469 -0
- package/src/a11y-assist/tests/test_dyslexia.py +337 -0
- package/src/a11y-assist/tests/test_explain.py +74 -0
- package/src/a11y-assist/tests/test_golden.py +127 -0
- package/src/a11y-assist/tests/test_guard.py +819 -0
- package/src/a11y-assist/tests/test_guard_integration.py +457 -0
- package/src/a11y-assist/tests/test_ingest.py +311 -0
- package/src/a11y-assist/tests/test_methods_metadata.py +236 -0
- package/src/a11y-assist/tests/test_plain_language.py +348 -0
- package/src/a11y-assist/tests/test_render.py +117 -0
- package/src/a11y-assist/tests/test_screen_reader.py +703 -0
- package/src/a11y-assist/tests/test_storage_last.py +61 -0
- package/src/a11y-assist/tests/test_triage.py +86 -0
- package/src/a11y-ci/.github/workflows/ci.yml +43 -0
- package/src/a11y-ci/.github/workflows/test.yml +30 -0
- package/src/a11y-ci/A11Y_CI_TEST_COVERAGE_REQUIREMENTS.md +94 -0
- package/src/a11y-ci/CODE_OF_CONDUCT.md +129 -0
- package/src/a11y-ci/CONTRIBUTING.md +142 -0
- package/src/a11y-ci/LICENSE +21 -0
- package/src/a11y-ci/README.md +105 -0
- package/src/a11y-ci/a11y_ci/__init__.py +3 -0
- package/src/a11y-ci/a11y_ci/allowlist.py +83 -0
- package/src/a11y-ci/a11y_ci/cli.py +145 -0
- package/src/a11y-ci/a11y_ci/gate.py +131 -0
- package/src/a11y-ci/a11y_ci/render.py +48 -0
- package/src/a11y-ci/a11y_ci/schemas/allowlist.schema.json +24 -0
- package/src/a11y-ci/a11y_ci/scorecard.py +99 -0
- package/src/a11y-ci/npm/package.json +35 -0
- package/src/a11y-ci/pyproject.toml +64 -0
- package/src/a11y-ci/tests/__init__.py +1 -0
- package/src/a11y-ci/tests/fixtures/allowlist_expired.json +10 -0
- package/src/a11y-ci/tests/fixtures/allowlist_ok.json +10 -0
- package/src/a11y-ci/tests/fixtures/baseline_ok.json +7 -0
- package/src/a11y-ci/tests/fixtures/current_fail.json +6 -0
- package/src/a11y-ci/tests/fixtures/current_ok.json +6 -0
- package/src/a11y-ci/tests/fixtures/current_regresses.json +7 -0
- package/src/a11y-ci/tests/test_gate.py +134 -0
- package/src/a11y-evidence-engine/.github/workflows/ci.yml +53 -0
- package/src/a11y-evidence-engine/CODE_OF_CONDUCT.md +129 -0
- package/src/a11y-evidence-engine/CONTRIBUTING.md +128 -0
- package/src/a11y-evidence-engine/LICENSE +21 -0
- package/src/a11y-evidence-engine/README.md +71 -0
- package/src/a11y-evidence-engine/bin/a11y-engine.js +11 -0
- package/src/a11y-evidence-engine/fixtures/bad/button-no-name.html +30 -0
- package/src/a11y-evidence-engine/fixtures/bad/img-missing-alt.html +19 -0
- package/src/a11y-evidence-engine/fixtures/bad/input-missing-label.html +26 -0
- package/src/a11y-evidence-engine/fixtures/bad/missing-lang.html +11 -0
- package/src/a11y-evidence-engine/fixtures/good/index.html +29 -0
- package/src/a11y-evidence-engine/package-lock.json +109 -0
- package/src/a11y-evidence-engine/package.json +45 -0
- package/src/a11y-evidence-engine/src/cli.js +74 -0
- package/src/a11y-evidence-engine/src/evidence/canonicalize.js +52 -0
- package/src/a11y-evidence-engine/src/evidence/json_pointer.js +34 -0
- package/src/a11y-evidence-engine/src/evidence/prov_emit.js +153 -0
- package/src/a11y-evidence-engine/src/fswalk.js +56 -0
- package/src/a11y-evidence-engine/src/html_parse.js +117 -0
- package/src/a11y-evidence-engine/src/ids.js +53 -0
- package/src/a11y-evidence-engine/src/rules/document_missing_lang.js +50 -0
- package/src/a11y-evidence-engine/src/rules/form_control_missing_label.js +105 -0
- package/src/a11y-evidence-engine/src/rules/img_missing_alt.js +77 -0
- package/src/a11y-evidence-engine/src/rules/index.js +37 -0
- package/src/a11y-evidence-engine/src/rules/interactive_missing_name.js +129 -0
- package/src/a11y-evidence-engine/src/scan.js +128 -0
- package/src/a11y-evidence-engine/test/scan.test.js +149 -0
- package/src/a11y-evidence-engine/test/vectors.test.js +200 -0
- package/src/a11y-lint/.github/workflows/ci.yml +46 -0
- package/src/a11y-lint/.github/workflows/test.yml +34 -0
- package/src/a11y-lint/CODE_OF_CONDUCT.md +129 -0
- package/src/a11y-lint/CONTRIBUTING.md +70 -0
- package/src/a11y-lint/GOVERNANCE.md +57 -0
- package/src/a11y-lint/LICENSE +21 -0
- package/src/a11y-lint/PRESS_RELEASE.md +50 -0
- package/src/a11y-lint/README.md +276 -0
- package/src/a11y-lint/RELEASE_NOTES.md +57 -0
- package/src/a11y-lint/RELEASING.md +57 -0
- package/src/a11y-lint/a11y_lint/__init__.py +64 -0
- package/src/a11y-lint/a11y_lint/cli.py +319 -0
- package/src/a11y-lint/a11y_lint/errors.py +252 -0
- package/src/a11y-lint/a11y_lint/render.py +293 -0
- package/src/a11y-lint/a11y_lint/report_md.py +289 -0
- package/src/a11y-lint/a11y_lint/scan_cli_text.py +434 -0
- package/src/a11y-lint/a11y_lint/schemas/cli.error.schema.v0.1.json +83 -0
- package/src/a11y-lint/a11y_lint/scorecard.py +244 -0
- package/src/a11y-lint/a11y_lint/validate.py +225 -0
- package/src/a11y-lint/pyproject.toml +75 -0
- package/src/a11y-lint/tests/__init__.py +1 -0
- package/src/a11y-lint/tests/test_cli.py +200 -0
- package/src/a11y-lint/tests/test_errors.py +188 -0
- package/src/a11y-lint/tests/test_render.py +202 -0
- package/src/a11y-lint/tests/test_report_md.py +188 -0
- package/src/a11y-lint/tests/test_scan_cli_text.py +290 -0
- package/src/a11y-lint/tests/test_scorecard.py +195 -0
- package/src/a11y-lint/tests/test_validate.py +257 -0
- package/src/a11y-mcp-tools/.github/workflows/ci.yml +53 -0
- package/src/a11y-mcp-tools/CODE_OF_CONDUCT.md +129 -0
- package/src/a11y-mcp-tools/CONTRIBUTING.md +136 -0
- package/src/a11y-mcp-tools/LICENSE +21 -0
- package/src/a11y-mcp-tools/PROV_METHODS_CATALOG.md +104 -0
- package/src/a11y-mcp-tools/README.md +168 -0
- package/src/a11y-mcp-tools/bin/cli.js +452 -0
- package/src/a11y-mcp-tools/bin/server.js +244 -0
- package/src/a11y-mcp-tools/fixtures/requests/a11y.diagnose.ok.json +27 -0
- package/src/a11y-mcp-tools/fixtures/requests/a11y.evidence.ok.json +25 -0
- package/src/a11y-mcp-tools/fixtures/responses/a11y.diagnose.ok.json +139 -0
- package/src/a11y-mcp-tools/fixtures/responses/a11y.diagnose.provenance_fail.json +13 -0
- package/src/a11y-mcp-tools/fixtures/responses/a11y.evidence.ok.json +88 -0
- package/src/a11y-mcp-tools/package-lock.json +189 -0
- package/src/a11y-mcp-tools/package.json +49 -0
- package/src/a11y-mcp-tools/src/envelope.js +197 -0
- package/src/a11y-mcp-tools/src/index.js +9 -0
- package/src/a11y-mcp-tools/src/schemas/artifact.js +85 -0
- package/src/a11y-mcp-tools/src/schemas/diagnosis.schema.v0.1.json +137 -0
- package/src/a11y-mcp-tools/src/schemas/envelope.schema.v0.1.json +108 -0
- package/src/a11y-mcp-tools/src/schemas/evidence.bundle.schema.v0.1.json +129 -0
- package/src/a11y-mcp-tools/src/schemas/evidence.js +97 -0
- package/src/a11y-mcp-tools/src/schemas/index.js +11 -0
- package/src/a11y-mcp-tools/src/schemas/provenance.js +140 -0
- package/src/a11y-mcp-tools/src/schemas/tools/a11y.diagnose.request.schema.v0.1.json +77 -0
- package/src/a11y-mcp-tools/src/schemas/tools/a11y.diagnose.response.schema.v0.1.json +50 -0
- package/src/a11y-mcp-tools/src/schemas/tools/a11y.evidence.request.schema.v0.1.json +120 -0
- package/src/a11y-mcp-tools/src/schemas/tools/a11y.evidence.response.schema.v0.1.json +50 -0
- package/src/a11y-mcp-tools/src/tools/diagnose.js +597 -0
- package/src/a11y-mcp-tools/src/tools/evidence.js +481 -0
- package/src/a11y-mcp-tools/src/tools/index.js +10 -0
- package/src/a11y-mcp-tools/test/contract.test.mjs +154 -0
- package/src/a11y-mcp-tools/test/diagnose.test.js +485 -0
- package/src/a11y-mcp-tools/test/evidence.test.js +183 -0
- package/src/a11y-mcp-tools/test/schema.test.js +327 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# a11y-ci
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
CI gate for `a11y-lint` scorecards. Low-vision-first output.
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
- Fails if current run has findings at/above `--fail-on` (default: `serious`)
|
|
12
|
+
- Optional baseline comparison:
|
|
13
|
+
- fails on serious+ count regression
|
|
14
|
+
- fails if new serious+ finding IDs appear
|
|
15
|
+
- Optional allowlist with required reason + expiry
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install a11y-ci
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
### Gate (typical CI)
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
a11y-ci gate --current a11y.scorecard.json --baseline baseline/a11y.scorecard.json
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Allowlist
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
a11y-ci gate --current a11y.scorecard.json --baseline baseline/a11y.scorecard.json --allowlist a11y-ci.allowlist.json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Fail severity
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
a11y-ci gate --current a11y.scorecard.json --fail-on moderate
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Exit codes
|
|
44
|
+
|
|
45
|
+
| Code | Meaning |
|
|
46
|
+
|------|---------|
|
|
47
|
+
| 0 | Pass |
|
|
48
|
+
| 2 | Input/validation error |
|
|
49
|
+
| 3 | Policy gate failed |
|
|
50
|
+
|
|
51
|
+
## Output Contract
|
|
52
|
+
|
|
53
|
+
All output follows the low-vision-first contract:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
[OK] Title (ID: NAMESPACE.CATEGORY.DETAIL)
|
|
57
|
+
|
|
58
|
+
What:
|
|
59
|
+
What happened.
|
|
60
|
+
|
|
61
|
+
Why:
|
|
62
|
+
Why it happened.
|
|
63
|
+
|
|
64
|
+
Fix:
|
|
65
|
+
How to fix it.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Allowlist Format
|
|
69
|
+
|
|
70
|
+
Allowlist entries require:
|
|
71
|
+
- `finding_id`: The rule/finding ID to suppress
|
|
72
|
+
- `expires`: ISO date (yyyy-mm-dd) — expired entries fail the gate
|
|
73
|
+
- `reason`: Minimum 10 chars explaining the suppression
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"version": "1",
|
|
78
|
+
"allow": [
|
|
79
|
+
{
|
|
80
|
+
"finding_id": "CLI.COLOR.ONLY",
|
|
81
|
+
"expires": "2026-12-31",
|
|
82
|
+
"reason": "Temporary suppression for legacy output. Tracked in issue #12."
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## GitHub Actions Example
|
|
89
|
+
|
|
90
|
+
```yaml
|
|
91
|
+
- name: a11y gate
|
|
92
|
+
run: |
|
|
93
|
+
a11y-lint scan output.txt --json > a11y.scorecard.json
|
|
94
|
+
a11y-ci gate --current a11y.scorecard.json --baseline baseline/a11y.scorecard.json
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Notes
|
|
98
|
+
|
|
99
|
+
- This tool is deterministic. It does not call network services.
|
|
100
|
+
- Expired allowlist entries fail the gate (no permanent exceptions).
|
|
101
|
+
- Scorecards are format-tolerant: reads `summary` when present, otherwise computes from `findings`.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Allowlist handling with schema validation and expiry checking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional, Set
|
|
11
|
+
|
|
12
|
+
from jsonschema import Draft202012Validator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_schema() -> Dict[str, Any]:
|
|
16
|
+
"""Load the allowlist JSON schema."""
|
|
17
|
+
with resources.files("a11y_ci.schemas").joinpath("allowlist.schema.json").open("rb") as f:
|
|
18
|
+
return json.load(f)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_SCHEMA = _load_schema()
|
|
22
|
+
_VALIDATOR = Draft202012Validator(_SCHEMA)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AllowlistError(Exception):
|
|
26
|
+
"""Raised when allowlist validation fails."""
|
|
27
|
+
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class AllowlistEntry:
|
|
33
|
+
"""A single allowlist entry with required fields."""
|
|
34
|
+
|
|
35
|
+
finding_id: str
|
|
36
|
+
expires: str
|
|
37
|
+
reason: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Allowlist:
|
|
42
|
+
"""Parsed allowlist with entries."""
|
|
43
|
+
|
|
44
|
+
entries: List[AllowlistEntry]
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def load(path: str) -> "Allowlist":
|
|
48
|
+
"""Load and validate an allowlist from a JSON file."""
|
|
49
|
+
obj = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
50
|
+
errors = []
|
|
51
|
+
for e in sorted(_VALIDATOR.iter_errors(obj), key=lambda x: x.path):
|
|
52
|
+
loc = ".".join([str(p) for p in e.path]) or "(root)"
|
|
53
|
+
errors.append(f"{loc}: {e.message}")
|
|
54
|
+
if errors:
|
|
55
|
+
raise AllowlistError("allowlist validation failed:\n" + "\n".join(errors))
|
|
56
|
+
|
|
57
|
+
allow = obj.get("allow", [])
|
|
58
|
+
entries: List[AllowlistEntry] = []
|
|
59
|
+
for item in allow:
|
|
60
|
+
entries.append(
|
|
61
|
+
AllowlistEntry(
|
|
62
|
+
finding_id=item["finding_id"].strip(),
|
|
63
|
+
expires=item["expires"].strip(),
|
|
64
|
+
reason=item["reason"].strip(),
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
return Allowlist(entries=entries)
|
|
68
|
+
|
|
69
|
+
def suppressed_ids(self) -> Set[str]:
|
|
70
|
+
"""Get set of finding IDs that are suppressed."""
|
|
71
|
+
return {e.finding_id for e in self.entries}
|
|
72
|
+
|
|
73
|
+
def expired_entries(self, today: Optional[date] = None) -> List[AllowlistEntry]:
|
|
74
|
+
"""Get list of entries that have expired."""
|
|
75
|
+
today = today or date.today()
|
|
76
|
+
expired: List[AllowlistEntry] = []
|
|
77
|
+
for e in self.entries:
|
|
78
|
+
# expires is ISO date yyyy-mm-dd
|
|
79
|
+
y, m, d = e.expires.split("-")
|
|
80
|
+
exp = date(int(y), int(m), int(d))
|
|
81
|
+
if exp < today:
|
|
82
|
+
expired.append(e)
|
|
83
|
+
return expired
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""CLI entry point for a11y-ci."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .allowlist import Allowlist, AllowlistError
|
|
9
|
+
from .gate import gate
|
|
10
|
+
from .render import CliMessage, render
|
|
11
|
+
from .scorecard import Scorecard
|
|
12
|
+
|
|
13
|
+
EXIT_PASS = 0
|
|
14
|
+
EXIT_INPUT_ERROR = 2
|
|
15
|
+
EXIT_FAIL = 3
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
19
|
+
@click.version_option(__version__)
|
|
20
|
+
def main():
|
|
21
|
+
"""a11y-ci: CI gate for a11y-lint scorecards."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@main.command("gate")
|
|
26
|
+
@click.option(
|
|
27
|
+
"--current",
|
|
28
|
+
"current_path",
|
|
29
|
+
required=True,
|
|
30
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
31
|
+
help="Path to current scorecard JSON.",
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"--baseline",
|
|
35
|
+
"baseline_path",
|
|
36
|
+
required=False,
|
|
37
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
38
|
+
help="Path to baseline scorecard JSON (optional).",
|
|
39
|
+
)
|
|
40
|
+
@click.option(
|
|
41
|
+
"--fail-on",
|
|
42
|
+
"fail_on",
|
|
43
|
+
default="serious",
|
|
44
|
+
show_default=True,
|
|
45
|
+
type=click.Choice(["info", "minor", "moderate", "serious", "critical"], case_sensitive=False),
|
|
46
|
+
help="Minimum severity to fail on.",
|
|
47
|
+
)
|
|
48
|
+
@click.option(
|
|
49
|
+
"--allowlist",
|
|
50
|
+
"allowlist_path",
|
|
51
|
+
required=False,
|
|
52
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
53
|
+
help="Path to allowlist JSON (optional).",
|
|
54
|
+
)
|
|
55
|
+
def gate_cmd(
|
|
56
|
+
current_path: str,
|
|
57
|
+
baseline_path: str | None,
|
|
58
|
+
fail_on: str,
|
|
59
|
+
allowlist_path: str | None,
|
|
60
|
+
):
|
|
61
|
+
"""Evaluate policy gate against scorecards."""
|
|
62
|
+
try:
|
|
63
|
+
current = Scorecard.load(current_path)
|
|
64
|
+
baseline = Scorecard.load(baseline_path) if baseline_path else None
|
|
65
|
+
allowlist = Allowlist.load(allowlist_path) if allowlist_path else None
|
|
66
|
+
except AllowlistError as e:
|
|
67
|
+
msg = CliMessage(
|
|
68
|
+
status="ERROR",
|
|
69
|
+
id="A11Y.CI.ALLOWLIST.INVALID",
|
|
70
|
+
title="Allowlist is invalid",
|
|
71
|
+
what=["The allowlist file failed schema validation."],
|
|
72
|
+
why=[
|
|
73
|
+
"The allowlist must include finding_id, expires, and reason for each entry."
|
|
74
|
+
],
|
|
75
|
+
fix=[
|
|
76
|
+
"Fix the allowlist JSON and re-run the gate.",
|
|
77
|
+
f"Details: {str(e).splitlines()[0]}",
|
|
78
|
+
],
|
|
79
|
+
)
|
|
80
|
+
click.echo(render(msg), nl=False)
|
|
81
|
+
raise SystemExit(EXIT_INPUT_ERROR)
|
|
82
|
+
except Exception as e:
|
|
83
|
+
msg = CliMessage(
|
|
84
|
+
status="ERROR",
|
|
85
|
+
id="A11Y.CI.INPUT.INVALID",
|
|
86
|
+
title="Could not read inputs",
|
|
87
|
+
what=["One or more input files could not be parsed."],
|
|
88
|
+
why=["The scorecard JSON may be malformed or missing required fields."],
|
|
89
|
+
fix=[
|
|
90
|
+
"Verify the JSON files exist and are valid.",
|
|
91
|
+
f"Error: {type(e).__name__}: {e}",
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
click.echo(render(msg), nl=False)
|
|
95
|
+
raise SystemExit(EXIT_INPUT_ERROR)
|
|
96
|
+
|
|
97
|
+
result = gate(current=current, baseline=baseline, fail_on=fail_on, allowlist=allowlist)
|
|
98
|
+
|
|
99
|
+
if result.ok:
|
|
100
|
+
msg = CliMessage(
|
|
101
|
+
status="OK",
|
|
102
|
+
id="A11Y.CI.GATE.PASS",
|
|
103
|
+
title="Accessibility gate passed",
|
|
104
|
+
what=["No policy violations were detected."],
|
|
105
|
+
why=["Current findings meet the configured threshold and baseline policy."],
|
|
106
|
+
fix=["Proceed with merge/release."],
|
|
107
|
+
)
|
|
108
|
+
click.echo(render(msg), nl=False)
|
|
109
|
+
raise SystemExit(EXIT_PASS)
|
|
110
|
+
|
|
111
|
+
# fail message (low-vision friendly, actionable)
|
|
112
|
+
what_lines = ["Accessibility policy violations were detected."]
|
|
113
|
+
why_lines = result.reasons[:]
|
|
114
|
+
fix_lines = [
|
|
115
|
+
"Review the current scorecard and address the listed findings.",
|
|
116
|
+
"If this is intentional, add a time-bounded allowlist entry with justification.",
|
|
117
|
+
f"Re-run: a11y-ci gate --current {current_path}"
|
|
118
|
+
+ (f" --baseline {baseline_path}" if baseline_path else "")
|
|
119
|
+
+ (f" --allowlist {allowlist_path}" if allowlist_path else ""),
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
# Include a short list of blocking IDs in Fix for immediate control
|
|
123
|
+
if result.current_blocking_ids:
|
|
124
|
+
fix_lines.append(
|
|
125
|
+
"Blocking IDs (current): "
|
|
126
|
+
+ ", ".join(result.current_blocking_ids[:12])
|
|
127
|
+
+ (" ..." if len(result.current_blocking_ids) > 12 else "")
|
|
128
|
+
)
|
|
129
|
+
if result.new_blocking_ids:
|
|
130
|
+
fix_lines.append(
|
|
131
|
+
"New blocking IDs (regression): "
|
|
132
|
+
+ ", ".join(result.new_blocking_ids[:12])
|
|
133
|
+
+ (" ..." if len(result.new_blocking_ids) > 12 else "")
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
msg = CliMessage(
|
|
137
|
+
status="ERROR",
|
|
138
|
+
id="A11Y.CI.GATE.FAIL",
|
|
139
|
+
title="Accessibility gate failed",
|
|
140
|
+
what=what_lines,
|
|
141
|
+
why=why_lines if why_lines else ["Gate policy was not satisfied."],
|
|
142
|
+
fix=fix_lines,
|
|
143
|
+
)
|
|
144
|
+
click.echo(render(msg), nl=False)
|
|
145
|
+
raise SystemExit(EXIT_FAIL)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Core policy gate logic.
|
|
2
|
+
|
|
3
|
+
Rules:
|
|
4
|
+
1. Current has any findings at/above fail_on threshold -> FAIL
|
|
5
|
+
2. If baseline provided, regression in count at/above threshold -> FAIL
|
|
6
|
+
3. If baseline provided, new finding IDs at/above threshold -> FAIL
|
|
7
|
+
4. Expired allowlist entries -> FAIL (no permanent exceptions)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Dict, List, Optional, Set
|
|
14
|
+
|
|
15
|
+
from .allowlist import Allowlist
|
|
16
|
+
from .scorecard import (
|
|
17
|
+
SEVERITY_ORDER,
|
|
18
|
+
Scorecard,
|
|
19
|
+
finding_id,
|
|
20
|
+
normalize_severity,
|
|
21
|
+
severity_ge,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class GateResult:
|
|
27
|
+
"""Result of a gate evaluation."""
|
|
28
|
+
|
|
29
|
+
ok: bool
|
|
30
|
+
reasons: List[str]
|
|
31
|
+
current_blocking_ids: List[str]
|
|
32
|
+
new_blocking_ids: List[str]
|
|
33
|
+
current_counts: Dict[str, int]
|
|
34
|
+
baseline_counts: Optional[Dict[str, int]]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def apply_allowlist(scorecard: Scorecard, suppressed: Set[str]) -> Scorecard:
|
|
38
|
+
"""Return a new scorecard with suppressed findings removed."""
|
|
39
|
+
if not suppressed:
|
|
40
|
+
return scorecard
|
|
41
|
+
filtered = [f for f in scorecard.findings if finding_id(f) not in suppressed]
|
|
42
|
+
raw = dict(scorecard.raw)
|
|
43
|
+
raw["findings"] = filtered
|
|
44
|
+
# keep summary if present but it's now stale; recompute counts from findings downstream
|
|
45
|
+
raw.pop("summary", None)
|
|
46
|
+
return Scorecard(raw=raw, findings=filtered)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def gate(
|
|
50
|
+
current: Scorecard,
|
|
51
|
+
baseline: Optional[Scorecard],
|
|
52
|
+
fail_on: str = "serious",
|
|
53
|
+
allowlist: Optional[Allowlist] = None,
|
|
54
|
+
) -> GateResult:
|
|
55
|
+
"""Evaluate the policy gate.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
current: Current scorecard to evaluate
|
|
59
|
+
baseline: Optional baseline scorecard for regression detection
|
|
60
|
+
fail_on: Severity threshold (default: serious)
|
|
61
|
+
allowlist: Optional allowlist for suppressions
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
GateResult with pass/fail status and reasons
|
|
65
|
+
"""
|
|
66
|
+
fail_on = normalize_severity(fail_on)
|
|
67
|
+
|
|
68
|
+
suppressed: Set[str] = set()
|
|
69
|
+
reasons: List[str] = []
|
|
70
|
+
|
|
71
|
+
if allowlist:
|
|
72
|
+
suppressed = allowlist.suppressed_ids()
|
|
73
|
+
expired = allowlist.expired_entries()
|
|
74
|
+
if expired:
|
|
75
|
+
reasons.append(
|
|
76
|
+
"Allowlist contains expired entries (must be renewed or removed): "
|
|
77
|
+
+ ", ".join([e.finding_id for e in expired])
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
cur = apply_allowlist(current, suppressed)
|
|
81
|
+
base = apply_allowlist(baseline, suppressed) if baseline else None
|
|
82
|
+
|
|
83
|
+
cur_counts = cur.counts()
|
|
84
|
+
base_counts = base.counts() if base else None
|
|
85
|
+
|
|
86
|
+
# Rule 1: current has any at/above fail_on
|
|
87
|
+
cur_blocking = cur.ids_at_or_above(fail_on)
|
|
88
|
+
if cur_blocking:
|
|
89
|
+
reasons.append(
|
|
90
|
+
f"Current run has {len(cur_blocking)} finding(s) at or above '{fail_on}'."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
new_blocking: List[str] = []
|
|
94
|
+
if base:
|
|
95
|
+
# Rule 2: serious+ regression count
|
|
96
|
+
# We treat fail_on as the regression threshold too (default serious)
|
|
97
|
+
def count_at_or_above(counts: Dict[str, int], thr: str) -> int:
|
|
98
|
+
total = 0
|
|
99
|
+
for sev, n in counts.items():
|
|
100
|
+
if severity_ge(sev, thr):
|
|
101
|
+
total += int(n)
|
|
102
|
+
return total
|
|
103
|
+
|
|
104
|
+
cur_n = count_at_or_above(cur_counts, fail_on)
|
|
105
|
+
base_n = count_at_or_above(base_counts or {}, fail_on)
|
|
106
|
+
if cur_n > base_n:
|
|
107
|
+
reasons.append(
|
|
108
|
+
f"Regression: current has {cur_n} finding(s) at/above '{fail_on}' "
|
|
109
|
+
f"vs baseline {base_n}."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Rule 3: new blocking IDs at/above threshold
|
|
113
|
+
base_ids = set(base.ids_at_or_above(fail_on))
|
|
114
|
+
cur_ids = set(cur.ids_at_or_above(fail_on))
|
|
115
|
+
new_blocking = sorted(cur_ids - base_ids)
|
|
116
|
+
if new_blocking:
|
|
117
|
+
reasons.append(
|
|
118
|
+
f"New finding(s) at/above '{fail_on}' not present in baseline: "
|
|
119
|
+
+ ", ".join(new_blocking[:20])
|
|
120
|
+
+ (" ..." if len(new_blocking) > 20 else "")
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
ok = len([r for r in reasons if r]) == 0
|
|
124
|
+
return GateResult(
|
|
125
|
+
ok=ok,
|
|
126
|
+
reasons=reasons,
|
|
127
|
+
current_blocking_ids=cur_blocking,
|
|
128
|
+
new_blocking_ids=new_blocking,
|
|
129
|
+
current_counts=cur_counts,
|
|
130
|
+
baseline_counts=base_counts,
|
|
131
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Low-vision-first CLI message rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import List, Literal
|
|
7
|
+
|
|
8
|
+
Status = Literal["OK", "WARN", "ERROR"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class CliMessage:
|
|
13
|
+
"""Structured CLI message following the What/Why/Fix contract."""
|
|
14
|
+
|
|
15
|
+
status: Status
|
|
16
|
+
id: str
|
|
17
|
+
title: str
|
|
18
|
+
what: List[str]
|
|
19
|
+
why: List[str]
|
|
20
|
+
fix: List[str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render(msg: CliMessage) -> str:
|
|
24
|
+
"""Render a CLI message to the low-vision-first format.
|
|
25
|
+
|
|
26
|
+
Format:
|
|
27
|
+
[STATUS] Title (ID: NAMESPACE.CATEGORY.DETAIL)
|
|
28
|
+
|
|
29
|
+
What:
|
|
30
|
+
What happened.
|
|
31
|
+
|
|
32
|
+
Why:
|
|
33
|
+
Why it happened.
|
|
34
|
+
|
|
35
|
+
Fix:
|
|
36
|
+
How to fix it.
|
|
37
|
+
"""
|
|
38
|
+
head = f"[{msg.status}] {msg.title} (ID: {msg.id})"
|
|
39
|
+
parts = [head, ""]
|
|
40
|
+
parts.append("What:")
|
|
41
|
+
parts.extend([f" {x}" for x in msg.what])
|
|
42
|
+
parts.append("")
|
|
43
|
+
parts.append("Why:")
|
|
44
|
+
parts.extend([f" {x}" for x in msg.why])
|
|
45
|
+
parts.append("")
|
|
46
|
+
parts.append("Fix:")
|
|
47
|
+
parts.extend([f" {x}" for x in msg.fix])
|
|
48
|
+
return "\n".join(parts) + "\n"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://mcp-tool-shop.dev/schemas/a11y-ci.allowlist.schema.json",
|
|
4
|
+
"title": "a11y-ci Allowlist",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["version", "allow"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"version": { "type": "string", "enum": ["1"] },
|
|
9
|
+
"allow": {
|
|
10
|
+
"type": "array",
|
|
11
|
+
"items": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"required": ["finding_id", "expires", "reason"],
|
|
14
|
+
"properties": {
|
|
15
|
+
"finding_id": { "type": "string", "minLength": 3 },
|
|
16
|
+
"expires": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
|
|
17
|
+
"reason": { "type": "string", "minLength": 10 }
|
|
18
|
+
},
|
|
19
|
+
"additionalProperties": false
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"additionalProperties": false
|
|
24
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Scorecard loading and severity handling.
|
|
2
|
+
|
|
3
|
+
This reads a11y-lint scorecards but is defensive: it supports either:
|
|
4
|
+
- summary bucket counts, and/or
|
|
5
|
+
- findings[] with severity and an ID (id, rule_id, or finding_id)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List
|
|
14
|
+
|
|
15
|
+
SEVERITY_ORDER = ["info", "minor", "moderate", "serious", "critical"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def severity_ge(a: str, threshold: str) -> bool:
|
|
19
|
+
"""Check if severity `a` is >= `threshold`."""
|
|
20
|
+
try:
|
|
21
|
+
return SEVERITY_ORDER.index(a) >= SEVERITY_ORDER.index(threshold)
|
|
22
|
+
except ValueError:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def normalize_severity(s: str) -> str:
|
|
27
|
+
"""Normalize severity string to canonical form."""
|
|
28
|
+
s = (s or "").strip().lower()
|
|
29
|
+
if s in SEVERITY_ORDER:
|
|
30
|
+
return s
|
|
31
|
+
# tolerate common alternates
|
|
32
|
+
if s == "warning":
|
|
33
|
+
return "moderate"
|
|
34
|
+
if s == "error":
|
|
35
|
+
return "serious"
|
|
36
|
+
return "info"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def finding_id(f: Dict[str, Any]) -> str:
|
|
40
|
+
"""Extract finding ID from a finding dict, tolerating different key names."""
|
|
41
|
+
for k in ("id", "rule_id", "finding_id", "code"):
|
|
42
|
+
v = f.get(k)
|
|
43
|
+
if isinstance(v, str) and v.strip():
|
|
44
|
+
return v.strip()
|
|
45
|
+
# fallback: deterministic-ish
|
|
46
|
+
title = f.get("title") or f.get("message") or "unknown"
|
|
47
|
+
return f"UNKNOWN:{str(title)[:80]}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Scorecard:
|
|
52
|
+
"""Parsed scorecard with findings and computed counts."""
|
|
53
|
+
|
|
54
|
+
raw: Dict[str, Any]
|
|
55
|
+
findings: List[Dict[str, Any]]
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def load(path: str) -> "Scorecard":
|
|
59
|
+
"""Load a scorecard from a JSON file."""
|
|
60
|
+
p = Path(path)
|
|
61
|
+
obj = json.loads(p.read_text(encoding="utf-8"))
|
|
62
|
+
findings = obj.get("findings") or []
|
|
63
|
+
if not isinstance(findings, list):
|
|
64
|
+
findings = []
|
|
65
|
+
# normalize severities
|
|
66
|
+
for f in findings:
|
|
67
|
+
if isinstance(f, dict):
|
|
68
|
+
f["severity"] = normalize_severity(str(f.get("severity", "info")))
|
|
69
|
+
return Scorecard(raw=obj, findings=[f for f in findings if isinstance(f, dict)])
|
|
70
|
+
|
|
71
|
+
def counts(self) -> Dict[str, int]:
|
|
72
|
+
"""Get severity counts. Prefers summary if present; otherwise computes from findings."""
|
|
73
|
+
s = self.raw.get("summary")
|
|
74
|
+
if isinstance(s, dict) and all(k in s for k in SEVERITY_ORDER):
|
|
75
|
+
out = {k: int(s.get(k, 0) or 0) for k in SEVERITY_ORDER}
|
|
76
|
+
return out
|
|
77
|
+
out = {k: 0 for k in SEVERITY_ORDER}
|
|
78
|
+
for f in self.findings:
|
|
79
|
+
out[normalize_severity(str(f.get("severity")))] += 1
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
def ids_at_or_above(self, threshold: str) -> List[str]:
|
|
83
|
+
"""Get sorted list of finding IDs at or above severity threshold."""
|
|
84
|
+
thr = normalize_severity(threshold)
|
|
85
|
+
ids = []
|
|
86
|
+
for f in self.findings:
|
|
87
|
+
sev = normalize_severity(str(f.get("severity")))
|
|
88
|
+
if severity_ge(sev, thr):
|
|
89
|
+
ids.append(finding_id(f))
|
|
90
|
+
return sorted(set(ids))
|
|
91
|
+
|
|
92
|
+
def findings_at_or_above(self, threshold: str) -> List[Dict[str, Any]]:
|
|
93
|
+
"""Get findings at or above severity threshold."""
|
|
94
|
+
thr = normalize_severity(threshold)
|
|
95
|
+
return [
|
|
96
|
+
f
|
|
97
|
+
for f in self.findings
|
|
98
|
+
if severity_ge(normalize_severity(str(f.get("severity"))), thr)
|
|
99
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mcptoolshop/a11y-ci",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "npm wrapper for a11y-ci - CI gate for accessibility scorecards",
|
|
5
|
+
"bin": {
|
|
6
|
+
"a11y-ci": "./bin/a11y-ci.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"postinstall": "node postinstall.js"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"accessibility",
|
|
13
|
+
"a11y",
|
|
14
|
+
"ci",
|
|
15
|
+
"testing",
|
|
16
|
+
"wcag",
|
|
17
|
+
"python",
|
|
18
|
+
"wrapper"
|
|
19
|
+
],
|
|
20
|
+
"author": "mcp-tool-shop <64996768+mcp-tool-shop@users.noreply.github.com>",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/mcp-tool-shop-org/accessibility-suite.git",
|
|
25
|
+
"directory": "src/a11y-ci/npm"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/mcp-tool-shop-org/accessibility-suite#readme",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18.0.0"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public",
|
|
33
|
+
"registry": "https://registry.npmjs.org"
|
|
34
|
+
}
|
|
35
|
+
}
|