Skip to content

Commit ccfdb51

Browse files
authored
Merge pull request #87 from mgifford/copilot/update-axe-rules-yaml
Add axe rule human-impact YAML, policy narratives in report, and bi-annual freshness workflow
2 parents 4f6aabb + 1b7312e commit ccfdb51

6 files changed

Lines changed: 1872 additions & 0 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
name: Axe Rules Freshness Check
2+
3+
on:
4+
schedule:
5+
# Run twice a year: March 20 and September 20 at 09:00 UTC
6+
- cron: '0 9 20 3 *'
7+
- cron: '0 9 20 9 *'
8+
workflow_dispatch:
9+
inputs:
10+
check_date:
11+
description: 'Override check date (YYYY-MM-DD, defaults to today)'
12+
required: false
13+
type: string
14+
15+
permissions:
16+
contents: read
17+
issues: write
18+
19+
jobs:
20+
check-axe-rules-freshness:
21+
runs-on: ubuntu-latest
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v4
25+
26+
- name: Setup Node
27+
uses: actions/setup-node@v4
28+
with:
29+
node-version: '24'
30+
cache: 'npm'
31+
32+
- name: Install dependencies
33+
run: npm ci
34+
35+
- name: Check axe rules data freshness
36+
id: freshness
37+
shell: bash
38+
run: |
39+
CHECK_DATE="${{ inputs.check_date }}"
40+
if [[ -z "$CHECK_DATE" ]]; then
41+
CHECK_DATE=$(date -u +%Y-%m-%d)
42+
fi
43+
echo "check_date=$CHECK_DATE" >> "$GITHUB_OUTPUT"
44+
45+
node --input-type=module <<'EOF'
46+
import { getAxeImpactMetadata, isAxeImpactDataStale } from './src/data/axe-impact-loader.js';
47+
48+
const checkDate = process.env.CHECK_DATE || new Date().toISOString().slice(0, 10);
49+
const metadata = getAxeImpactMetadata();
50+
const stale = isAxeImpactDataStale(checkDate);
51+
52+
console.log('YAML axe_version: ', metadata.axe_version);
53+
console.log('YAML last_updated: ', metadata.last_updated);
54+
console.log('YAML next_review_date: ', metadata.next_review_date);
55+
console.log('Check date: ', checkDate);
56+
console.log('Is stale: ', stale);
57+
58+
if (stale) {
59+
console.log('::warning::Axe impact rules data in src/data/axe-impact-rules.yaml is past its review date. Please review and update the YAML data.');
60+
process.exitCode = 1;
61+
} else {
62+
console.log('Axe impact rules data is current. No action needed.');
63+
}
64+
EOF
65+
env:
66+
CHECK_DATE: ${{ steps.freshness.outputs.check_date }}
67+
68+
- name: Check for new axe-core rules
69+
id: new-rules
70+
if: success() || failure()
71+
shell: bash
72+
run: |
73+
node src/cli/update-axe-rules.js --list-new > /tmp/new-rules-output.txt 2>&1 || true
74+
cat /tmp/new-rules-output.txt
75+
76+
if grep -q 'missing from axe-impact-rules.yaml' /tmp/new-rules-output.txt; then
77+
echo "has_new_rules=true" >> "$GITHUB_OUTPUT"
78+
# Extract the missing rule IDs
79+
MISSING=$(grep ' - ' /tmp/new-rules-output.txt | sed 's/ - //' | tr '\n' ', ' | sed 's/,$//')
80+
echo "missing_rules=${MISSING}" >> "$GITHUB_OUTPUT"
81+
else
82+
echo "has_new_rules=false" >> "$GITHUB_OUTPUT"
83+
fi
84+
85+
- name: Open issue if axe rules data is stale or has new rules
86+
if: |
87+
(failure() && steps.freshness.conclusion == 'failure') ||
88+
steps.new-rules.outputs.has_new_rules == 'true'
89+
uses: actions/github-script@v7
90+
with:
91+
script: |
92+
const { data: issues } = await github.rest.issues.listForRepo({
93+
owner: context.repo.owner,
94+
repo: context.repo.repo,
95+
labels: 'axe-rules-review',
96+
state: 'open'
97+
});
98+
99+
if (issues.length > 0) {
100+
console.log('An open axe rules review issue already exists:', issues[0].html_url);
101+
return;
102+
}
103+
104+
const hasNewRules = '${{ steps.new-rules.outputs.has_new_rules }}' === 'true';
105+
const missingRules = '${{ steps.new-rules.outputs.missing_rules }}';
106+
const metadata_section = hasNewRules
107+
? `\n\n### New Rules Detected\n\nThe following axe-core rules do not yet have policy narrative entries in \`src/data/axe-impact-rules.yaml\`:\n\n\`\`\`\n${missingRules}\n\`\`\`\n\nAdd entries for these rules following the existing YAML structure in \`src/data/axe-impact-rules.yaml\`.`
108+
: '';
109+
110+
const { data: issue } = await github.rest.issues.create({
111+
owner: context.repo.owner,
112+
repo: context.repo.repo,
113+
title: 'Bi-annual review: Update axe-core rule impact data',
114+
labels: ['axe-rules-review'],
115+
body: `## Axe Rules Impact Data Review Required
116+
117+
The scheduled freshness check has detected that the axe-core rule impact data in [\`src/data/axe-impact-rules.yaml\`](../blob/main/src/data/axe-impact-rules.yaml) needs review.
118+
119+
### Action Required
120+
121+
1. Check the latest axe-core rule documentation:
122+
- [axe-core rules reference](https://dequeuniversity.com/rules/axe/html/4.11)
123+
- Compare with the installed version by running: \`node src/cli/update-axe-rules.js --check --list-new\`
124+
125+
2. Update \`src/data/axe-impact-rules.yaml\`:
126+
- Update \`metadata.axe_version\` to match the installed axe-core version
127+
- Update \`metadata.last_updated\` to today's date
128+
- Update \`metadata.next_review_date\` to 6 months from today
129+
- Add or update \`technical_summary\` for any changed rules
130+
- Add \`policy_narrative\` entries for any new rules${metadata_section}
131+
132+
3. Run \`npm test\` to verify no tests are broken.
133+
134+
See [\`src/data/axe-impact-rules.yaml\`](../blob/main/src/data/axe-impact-rules.yaml) for the current version and review dates.
135+
`
136+
});
137+
138+
console.log('Created issue:', issue.html_url);

src/cli/update-axe-rules.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#!/usr/bin/env node
2+
// Script to check and update axe-core rule impact data.
3+
//
4+
// Usage:
5+
// node src/cli/update-axe-rules.js --check
6+
// Checks whether the installed axe-core version matches the YAML metadata
7+
// and whether the review date has passed. Exits with code 1 if stale.
8+
//
9+
// node src/cli/update-axe-rules.js --list-new
10+
// Lists any axe-core rule IDs that appear in the installed axe-core but
11+
// are not yet present in axe-impact-rules.yaml.
12+
//
13+
// This script is intended to be run by the GitHub Actions workflow
14+
// check-axe-rules.yml, which runs on a schedule every 6 months.
15+
16+
import { createRequire } from 'node:module';
17+
import { fileURLToPath } from 'node:url';
18+
import { dirname } from 'node:path';
19+
import { getAxeImpactMetadata, getAxeImpactRuleMap, isAxeImpactDataStale } from '../data/axe-impact-loader.js';
20+
21+
const __dirname = dirname(fileURLToPath(import.meta.url));
22+
const require = createRequire(import.meta.url);
23+
24+
const args = process.argv.slice(2);
25+
const isCheck = args.includes('--check');
26+
const isListNew = args.includes('--list-new');
27+
28+
if (!isCheck && !isListNew) {
29+
console.error('Usage: node src/cli/update-axe-rules.js [--check] [--list-new]');
30+
process.exit(1);
31+
}
32+
33+
// Load installed axe-core metadata
34+
let axeVersion = 'unknown';
35+
let installedRuleIds = [];
36+
try {
37+
const axePkg = require('axe-core/package.json');
38+
axeVersion = axePkg.version;
39+
const axe = require('axe-core');
40+
installedRuleIds = axe.getRules().map((r) => r.ruleId);
41+
} catch (err) {
42+
console.warn('Warning: could not load axe-core:', err.message);
43+
}
44+
45+
const metadata = getAxeImpactMetadata();
46+
const ruleMap = getAxeImpactRuleMap();
47+
48+
console.log('=== Axe Rule Impact Data Check ===');
49+
console.log(`YAML axe_version: ${metadata.axe_version ?? '(none)'}`);
50+
console.log(`Installed axe-core: ${axeVersion}`);
51+
console.log(`YAML last_updated: ${metadata.last_updated ?? '(none)'}`);
52+
console.log(`YAML next_review_date: ${metadata.next_review_date ?? '(none)'}`);
53+
console.log(`YAML rules count: ${ruleMap.size}`);
54+
console.log(`Installed rules count: ${installedRuleIds.length}`);
55+
console.log('');
56+
57+
let exitCode = 0;
58+
59+
if (isCheck) {
60+
const today = new Date().toISOString().slice(0, 10);
61+
const stale = isAxeImpactDataStale(today);
62+
63+
if (stale) {
64+
console.log(`\u26a0\ufe0f Review date (${metadata.next_review_date}) has passed. YAML data is stale.`);
65+
exitCode = 1;
66+
}
67+
68+
// Check whether the YAML axe_version major.minor matches installed
69+
const yamlMajorMinor = (metadata.axe_version ?? '').split('.').slice(0, 2).join('.');
70+
const installedMajorMinor = axeVersion.split('.').slice(0, 2).join('.');
71+
if (axeVersion !== 'unknown' && yamlMajorMinor !== installedMajorMinor) {
72+
console.log(`\u26a0\ufe0f YAML axe_version (${metadata.axe_version}) does not match installed axe-core (${axeVersion}).`);
73+
exitCode = 1;
74+
}
75+
76+
if (exitCode === 0) {
77+
console.log('\u2705 Axe impact data is current. No action needed.');
78+
}
79+
}
80+
81+
if (isListNew) {
82+
const missingFromYaml = installedRuleIds.filter((id) => !ruleMap.has(id));
83+
84+
if (missingFromYaml.length === 0) {
85+
console.log('\u2705 All installed axe-core rules are present in axe-impact-rules.yaml.');
86+
} else {
87+
console.log(`\u26a0\ufe0f ${missingFromYaml.length} rule(s) in axe-core but missing from axe-impact-rules.yaml:`);
88+
for (const id of missingFromYaml) {
89+
console.log(` - ${id}`);
90+
}
91+
exitCode = 1;
92+
}
93+
}
94+
95+
process.exitCode = exitCode;

src/data/axe-impact-loader.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Loader for axe-core rule impact mappings.
2+
//
3+
// Reads src/data/axe-impact-rules.yaml and provides lookup functions
4+
// for policy narratives by rule ID.
5+
//
6+
// The YAML is parsed once at module load time and cached for performance.
7+
//
8+
// Review schedule: The YAML data should be refreshed every 6 months to keep
9+
// pace with axe-core releases. Run the check-axe-rules workflow or execute
10+
// `node src/cli/update-axe-rules.js --check` to verify currency.
11+
12+
import { readFileSync } from 'node:fs';
13+
import { fileURLToPath } from 'node:url';
14+
import { dirname, join } from 'node:path';
15+
import { load as yamlLoad } from 'js-yaml';
16+
17+
const __dirname = dirname(fileURLToPath(import.meta.url));
18+
const YAML_PATH = join(__dirname, 'axe-impact-rules.yaml');
19+
20+
let _parsed = null;
21+
22+
function getParsed() {
23+
if (!_parsed) {
24+
const raw = readFileSync(YAML_PATH, 'utf8');
25+
_parsed = yamlLoad(raw);
26+
}
27+
return _parsed;
28+
}
29+
30+
/**
31+
* Returns the full parsed YAML document including metadata and rules array.
32+
*
33+
* @returns {{ metadata: object, rules: Array }}
34+
*/
35+
export function getAxeImpactRules() {
36+
return getParsed();
37+
}
38+
39+
/**
40+
* Returns a Map<string, object> from rule_id to the rule entry object.
41+
* Cached after first call.
42+
*
43+
* @returns {Map<string, object>}
44+
*/
45+
let _ruleMap = null;
46+
export function getAxeImpactRuleMap() {
47+
if (!_ruleMap) {
48+
const { rules = [] } = getParsed();
49+
_ruleMap = new Map(rules.map((r) => [r.rule_id, r]));
50+
}
51+
return _ruleMap;
52+
}
53+
54+
/**
55+
* Returns the policy narrative object for a given axe rule ID,
56+
* or null if no entry exists.
57+
*
58+
* @param {string} ruleId - axe-core rule ID (e.g. "color-contrast")
59+
* @returns {{ title: string, why_it_matters: string, affected_demographics: string[] } | null}
60+
*/
61+
export function getPolicyNarrative(ruleId) {
62+
const entry = getAxeImpactRuleMap().get(ruleId);
63+
return entry?.policy_narrative ?? null;
64+
}
65+
66+
/**
67+
* Returns the technical summary string for a given axe rule ID,
68+
* or null if no entry exists.
69+
*
70+
* @param {string} ruleId - axe-core rule ID
71+
* @returns {string | null}
72+
*/
73+
export function getTechnicalSummary(ruleId) {
74+
const entry = getAxeImpactRuleMap().get(ruleId);
75+
return entry?.technical_summary ?? null;
76+
}
77+
78+
/**
79+
* Returns the metadata block from the YAML (axe_version, last_updated, next_review_date).
80+
*
81+
* @returns {{ axe_version: string, last_updated: string, next_review_date: string, source_url: string }}
82+
*/
83+
export function getAxeImpactMetadata() {
84+
return getParsed().metadata ?? {};
85+
}
86+
87+
/**
88+
* Returns true if the review date is in the past relative to checkDate.
89+
*
90+
* @param {string} [checkDate] - ISO date string (YYYY-MM-DD), defaults to today
91+
* @returns {boolean}
92+
*/
93+
export function isAxeImpactDataStale(checkDate) {
94+
const today = checkDate ?? new Date().toISOString().slice(0, 10);
95+
const { next_review_date } = getAxeImpactMetadata();
96+
if (!next_review_date) return false;
97+
return today >= next_review_date;
98+
}

0 commit comments

Comments
 (0)