From 52e401a522814d764492d6fb2e632ed350ac5b83 Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Wed, 24 Jun 2026 13:19:23 -0700 Subject: [PATCH 01/24] Add AccessLint scan engine --- .github/actions/find/action.yml | 2 +- .github/actions/find/package-lock.json | 49 ++++++++++++++++--- .github/actions/find/package.json | 2 + .github/actions/find/src/findForUrl.ts | 30 ++++++++++++ .../actions/find/src/scansContextProvider.ts | 18 +++---- .github/actions/find/tests/findForUrl.test.ts | 44 +++++++++++++++++ 6 files changed, 127 insertions(+), 18 deletions(-) diff --git a/.github/actions/find/action.yml b/.github/actions/find/action.yml index fb53d901..e437ea35 100644 --- a/.github/actions/find/action.yml +++ b/.github/actions/find/action.yml @@ -17,7 +17,7 @@ inputs: required: false default: 'false' scans: - description: 'Stringified JSON array of scans to perform. If not provided, only Axe will be performed' + description: "Stringified JSON array of scans to perform. Core engines are 'axe' and 'accesslint'; any other entry is treated as a plugin name. If not provided, only Axe will be performed" required: false reduced_motion: description: 'Playwright reducedMotion setting: https://playwright.dev/docs/api/class-browser#browser-new-page-option-reduced-motion' diff --git a/.github/actions/find/package-lock.json b/.github/actions/find/package-lock.json index 410fb747..c98d5378 100644 --- a/.github/actions/find/package-lock.json +++ b/.github/actions/find/package-lock.json @@ -9,8 +9,10 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@accesslint/playwright": "^0.5.0", "@actions/core": "^3.0.1", "@axe-core/playwright": "^4.11.3", + "@playwright/test": "^1.60.0", "esbuild": "^0.28.0", "playwright": "^1.60.0" }, @@ -19,6 +21,24 @@ "typescript": "^6.0.3" } }, + "node_modules/@accesslint/core": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@accesslint/core/-/core-0.13.0.tgz", + "integrity": "sha512-l1R6if3qsqevQjcTdZsilnu2IBO6G6ZXaYbpYmd1tL8vgwATQ57fDKaWltdrMeRQToh0yOdpjiTORMFObfCYbA==", + "license": "MIT" + }, + "node_modules/@accesslint/playwright": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@accesslint/playwright/-/playwright-0.5.0.tgz", + "integrity": "sha512-vSMOqmMkAF8mBDYUFN1tq567PpnTj4QWEGEAvBQeQmw8GWj5mNovd8tsXJkOwVirZNmEnNhNnfI0yt/+dfcrnw==", + "license": "MIT", + "dependencies": { + "@accesslint/core": "0.13.0" + }, + "peerDependencies": { + "@playwright/test": ">=1.40.0" + } + }, "node_modules/@actions/core": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", @@ -482,6 +502,21 @@ "node": ">=18" } }, + "node_modules/@playwright/test": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@types/node": { "version": "25.9.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", @@ -557,12 +592,12 @@ } }, "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0" + "playwright-core": "1.61.0" }, "bin": { "playwright": "cli.js" @@ -575,9 +610,9 @@ } }, "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/.github/actions/find/package.json b/.github/actions/find/package.json index 7f3fd7ab..4b0ce269 100644 --- a/.github/actions/find/package.json +++ b/.github/actions/find/package.json @@ -13,8 +13,10 @@ "license": "MIT", "type": "module", "dependencies": { + "@accesslint/playwright": "^0.5.0", "@actions/core": "^3.0.1", "@axe-core/playwright": "^4.11.3", + "@playwright/test": "^1.60.0", "esbuild": "^0.28.0", "playwright": "^1.60.0" }, diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index d9f1ea87..7eec1f56 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -1,5 +1,6 @@ import type {ColorSchemePreference, Finding, ReducedMotionPreference, UrlConfig} from './types.d.js' import {AxeBuilder} from '@axe-core/playwright' +import {accesslintAudit} from '@accesslint/playwright' import playwright from 'playwright' import {AuthContext} from './AuthContext.js' import {generateScreenshots} from './generateScreenshots.js' @@ -59,6 +60,10 @@ export async function findForUrl( if (scansContext.shouldPerformAxeScan) { await runAxeScan({page, addFinding, excludeSelectors}) } + + if (scansContext.shouldPerformAccesslintScan) { + await runAccesslintScan({page, addFinding}) + } } catch (e) { core.error(`Error during accessibility scan: ${e}`) } @@ -98,3 +103,28 @@ async function runAxeScan({ } } } + +async function runAccesslintScan({ + page, + addFinding, +}: { + page: playwright.Page + addFinding: (findingData: Finding, options?: {includeScreenshots?: boolean}) => Promise +}) { + const url = page.url() + core.info(`Scanning ${url} with AccessLint`) + + // One violation per element; no per-rule docs URL, so problemUrl is the core rules table + const {violations} = await accesslintAudit(page as Parameters[0]) + for (const violation of violations) { + await addFinding({ + scannerType: 'accesslint', + url, + html: violation.html.replace(/'/g, '''), + problemShort: violation.message.toLowerCase().replace(/'/g, '''), + problemUrl: 'https://github.com/AccessLint/accesslint/blob/main/core/README.md#rules-1', + ruleId: violation.ruleId, + solutionShort: `resolve the ${violation.ruleId} violation that accesslint flagged on \`${violation.selector}\``, + }) + } +} diff --git a/.github/actions/find/src/scansContextProvider.ts b/.github/actions/find/src/scansContextProvider.ts index 014c6d58..c8abfb0a 100644 --- a/.github/actions/find/src/scansContextProvider.ts +++ b/.github/actions/find/src/scansContextProvider.ts @@ -3,6 +3,7 @@ import * as core from '@actions/core' type ScansContext = { scansToPerform: Array shouldPerformAxeScan: boolean + shouldPerformAccesslintScan: boolean shouldRunPlugins: boolean } let scansContext: ScansContext | undefined @@ -11,20 +12,17 @@ export function getScansContext() { if (!scansContext) { const scansInput = core.getInput('scans', {required: false}) const scansToPerform = JSON.parse(scansInput || '[]') - // - if we don't have a scans input - // or we do have a scans input, but it only has 1 item and its 'axe' - // then we only want to run 'axe' and not the plugins - // - keep in mind, 'onlyAxeScan' is not the same as 'shouldPerformAxeScan' - const onlyAxeScan = scansToPerform.length === 0 || (scansToPerform.length === 1 && scansToPerform[0] === 'axe') + // 'axe' and 'accesslint' are built-in core engines; anything else in the + // list is treated as a plugin name. + const coreEngines = ['axe', 'accesslint'] + const pluginScans = scansToPerform.filter((scan: string) => !coreEngines.includes(scan)) scansContext = { scansToPerform, - // - if no 'scans' input is provided, we default to the existing behavior - // (only axe scan) for backwards compatability. - // - we can enforce using the 'scans' input in a future major release and - // mark it as required + // No 'scans' input keeps the existing axe-only default for backwards compatibility. shouldPerformAxeScan: !scansInput || scansToPerform.includes('axe'), - shouldRunPlugins: scansToPerform.length > 0 && !onlyAxeScan, + shouldPerformAccesslintScan: scansToPerform.includes('accesslint'), + shouldRunPlugins: pluginScans.length > 0, } } diff --git a/.github/actions/find/tests/findForUrl.test.ts b/.github/actions/find/tests/findForUrl.test.ts index 85299c5c..f23508cb 100644 --- a/.github/actions/find/tests/findForUrl.test.ts +++ b/.github/actions/find/tests/findForUrl.test.ts @@ -2,6 +2,7 @@ import {describe, it, expect, vi} from 'vitest' import * as core from '@actions/core' import {findForUrl} from '../src/findForUrl.js' import {AxeBuilder} from '@axe-core/playwright' +import {accesslintAudit} from '@accesslint/playwright' import axe from 'axe-core' import * as pluginManager from '../src/pluginManager/index.js' import type {Plugin} from '../src/pluginManager/types.js' @@ -33,6 +34,10 @@ vi.mock('@axe-core/playwright', () => { return {AxeBuilder: AxeBuilderMock} }) +vi.mock('@accesslint/playwright', () => ({ + accesslintAudit: vi.fn(() => Promise.resolve({violations: []})), +})) + let actionInput: string = '' let loadedPlugins: Plugin[] = [] @@ -51,6 +56,7 @@ describe('findForUrl', () => { await findForUrl('test.com') expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(1) + expect(accesslintAudit).toHaveBeenCalledTimes(0) expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(0) expect(pluginManager.invokePlugin).toHaveBeenCalledTimes(0) } @@ -104,6 +110,44 @@ describe('findForUrl', () => { }) }) + describe('and the list includes accesslint', () => { + it('runs only the accesslint scan when it is the only entry', async () => { + actionInput = JSON.stringify(['accesslint']) + clearAll() + + await findForUrl('test.com') + expect(accesslintAudit).toHaveBeenCalledTimes(1) + expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(0) + expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(0) + }) + + it('runs alongside axe when both are listed', async () => { + actionInput = JSON.stringify(['axe', 'accesslint']) + clearAll() + + await findForUrl('test.com') + expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(1) + expect(accesslintAudit).toHaveBeenCalledTimes(1) + expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(0) + }) + + it('is treated as a core engine and runs alongside plugins', async () => { + loadedPlugins = [ + {name: 'custom-scan-1', default: vi.fn()}, + {name: 'custom-scan-2', default: vi.fn()}, + ] + + actionInput = JSON.stringify(['accesslint', 'custom-scan-1']) + clearAll() + + await findForUrl('test.com') + expect(accesslintAudit).toHaveBeenCalledTimes(1) + expect(pluginManager.invokePlugin).toHaveBeenCalledTimes(1) + expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1) + expect(loadedPlugins[1].default).toHaveBeenCalledTimes(0) + }) + }) + it('should only run scans that are included in the list', async () => { loadedPlugins = [ {name: 'custom-scan-1', default: vi.fn()}, From d07f5eb151ceb618eed6aa70692de27c44b114a0 Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Wed, 24 Jun 2026 13:40:07 -0700 Subject: [PATCH 02/24] Document AccessLint engine in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a20261e7..9af2c9d6 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ jobs: # dry_run: false # Optional: Set to true to scan and log what would be filed without creating/closing issues or writing the cache # reduced_motion: no-preference # Optional: Playwright reduced motion configuration option # color_scheme: light # Optional: Playwright color scheme configuration option - # scans: '["axe","reflow-scan"]' # Optional: An array of scans (or plugins) to be performed. If not provided, only Axe will be performed. + # scans: '["axe","accesslint","reflow-scan"]' # Optional: An array of scans (or plugins) to be performed. Built-in engines are 'axe' and 'accesslint'; any other entry is a plugin name. If not provided, only Axe will be performed. # url_configs: '[{"url":"https://example.com","excludeSelectors":["iframe","#widget"]}]' # Optional: Per-URL config with CSS selectors to exclude from the Axe scan. When provided, takes precedence over 'urls'. ``` @@ -131,7 +131,7 @@ Trigger the workflow manually or automatically based on your configuration. The | `open_grouped_issues` | No | Whether to create a tracking issue which groups filed issues together by violation type. Default: `false` | `true` | | `reduced_motion` | No | Playwright `reducedMotion` setting for scan contexts. Allowed values: `reduce`, `no-preference` | `reduce` | | `color_scheme` | No | Playwright `colorScheme` setting for scan contexts. Allowed values: `light`, `dark`, `no-preference` | `dark` | -| `scans` | No | An array of scans (or plugins) to be performed. If not provided, only Axe will be performed. | `'["axe", "reflow-scan", ...other plugins]'` | +| `scans` | No | An array of scans (or plugins) to be performed. Built-in engines are `axe` and `accesslint`; any other entry is treated as a plugin name. If not provided, only Axe will be performed. | `'["axe", "accesslint", ...other plugins]'` | | `dry_run` | No | When `true`, scan and log the issues that _would_ be filed without opening, closing, reopening, or assigning any issues — and without writing to the `gh-cache` branch. Useful for safely previewing results. Default: `false` | `true` | | `url_configs` | No | A stringified JSON array of URL config objects. Each object must have a `url` field and may have an optional `excludeSelectors` field (array of CSS selectors to exclude from the Axe scan for that URL). When provided, takes precedence over the `urls` input. | `'[{"url":"https://example.com","excludeSelectors":["iframe","#widget"]}]'` | From 41f49ecc68f54ba622173079352d1e842496f786 Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Wed, 24 Jun 2026 13:49:51 -0700 Subject: [PATCH 03/24] Apply suggestions from code review (escape AccessLint solutionShort and pass UrlConfig in tests) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/actions/find/src/findForUrl.ts | 2 +- .github/actions/find/tests/findForUrl.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index 7eec1f56..832201f5 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -124,7 +124,7 @@ async function runAccesslintScan({ problemShort: violation.message.toLowerCase().replace(/'/g, '''), problemUrl: 'https://github.com/AccessLint/accesslint/blob/main/core/README.md#rules-1', ruleId: violation.ruleId, - solutionShort: `resolve the ${violation.ruleId} violation that accesslint flagged on \`${violation.selector}\``, + solutionShort: `resolve the ${violation.ruleId} violation that accesslint flagged on \`${violation.selector}\``.replace(/'/g, '''), }) } } diff --git a/.github/actions/find/tests/findForUrl.test.ts b/.github/actions/find/tests/findForUrl.test.ts index f23508cb..17cfd8bd 100644 --- a/.github/actions/find/tests/findForUrl.test.ts +++ b/.github/actions/find/tests/findForUrl.test.ts @@ -115,7 +115,7 @@ describe('findForUrl', () => { actionInput = JSON.stringify(['accesslint']) clearAll() - await findForUrl('test.com') + await findForUrl({url: 'test.com'}) expect(accesslintAudit).toHaveBeenCalledTimes(1) expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(0) expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(0) @@ -125,7 +125,7 @@ describe('findForUrl', () => { actionInput = JSON.stringify(['axe', 'accesslint']) clearAll() - await findForUrl('test.com') + await findForUrl({url: 'test.com'}) expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(1) expect(accesslintAudit).toHaveBeenCalledTimes(1) expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(0) @@ -140,7 +140,7 @@ describe('findForUrl', () => { actionInput = JSON.stringify(['accesslint', 'custom-scan-1']) clearAll() - await findForUrl('test.com') + await findForUrl({url: 'test.com'}) expect(accesslintAudit).toHaveBeenCalledTimes(1) expect(pluginManager.invokePlugin).toHaveBeenCalledTimes(1) expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1) From 0432b26ea98a25a98f4154913e3e86097630de81 Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Wed, 24 Jun 2026 13:55:11 -0700 Subject: [PATCH 04/24] Fix formatting --- .github/actions/find/src/findForUrl.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index 832201f5..da95a440 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -124,7 +124,11 @@ async function runAccesslintScan({ problemShort: violation.message.toLowerCase().replace(/'/g, '''), problemUrl: 'https://github.com/AccessLint/accesslint/blob/main/core/README.md#rules-1', ruleId: violation.ruleId, - solutionShort: `resolve the ${violation.ruleId} violation that accesslint flagged on \`${violation.selector}\``.replace(/'/g, '''), + solutionShort: + `resolve the ${violation.ruleId} violation that accesslint flagged on \`${violation.selector}\``.replace( + /'/g, + ''', + ), }) } } From 671de5cf2c83c48cc5ed367af9f415810a1bf47d Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Thu, 25 Jun 2026 15:20:04 -0700 Subject: [PATCH 05/24] Load first-party scanner plugins from NPM packages Accept object entries in the 'scans' input ({name, package, version}) and install/import them at runtime via npm install --ignore-scripts. Loading is gated to first-party allowlist. Includes unit tests. --- .github/actions/find/action.yml | 2 +- .github/actions/find/src/findForUrl.ts | 2 +- .../actions/find/src/pluginManager/index.ts | 48 +++++++++- .../find/src/pluginManager/npmPluginLoader.ts | 23 +++++ .../actions/find/src/pluginManager/types.ts | 7 ++ .../actions/find/src/scansContextProvider.ts | 28 +++++- .../find/tests/npmPluginLoader.test.ts | 88 +++++++++++++++++++ 7 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 .github/actions/find/src/pluginManager/npmPluginLoader.ts create mode 100644 .github/actions/find/tests/npmPluginLoader.test.ts diff --git a/.github/actions/find/action.yml b/.github/actions/find/action.yml index fb53d901..3e66cd6b 100644 --- a/.github/actions/find/action.yml +++ b/.github/actions/find/action.yml @@ -17,7 +17,7 @@ inputs: required: false default: 'false' scans: - description: 'Stringified JSON array of scans to perform. If not provided, only Axe will be performed' + description: "Stringified JSON array of scans to perform. Each entry is either a scan/plugin name (string) or, for a first-party NPM-published plugin, an object with 'name', 'package', and optional 'version'. If not provided, only Axe will be performed" required: false reduced_motion: description: 'Playwright reducedMotion setting: https://playwright.dev/docs/api/class-browser#browser-new-page-option-reduced-motion' diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index d9f1ea87..296cd965 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -41,7 +41,7 @@ export async function findForUrl( const scansContext = getScansContext() if (scansContext.shouldRunPlugins) { - const plugins = await loadPlugins() + const plugins = await loadPlugins(scansContext.npmPlugins) for (const plugin of plugins) { if (scansContext.scansToPerform.includes(plugin.name)) { core.info(`Running plugin: ${plugin.name}`) diff --git a/.github/actions/find/src/pluginManager/index.ts b/.github/actions/find/src/pluginManager/index.ts index c6ec5d71..bdeffd76 100644 --- a/.github/actions/find/src/pluginManager/index.ts +++ b/.github/actions/find/src/pluginManager/index.ts @@ -3,7 +3,8 @@ import * as path from 'path' import {fileURLToPath} from 'url' import * as core from '@actions/core' import {loadPluginViaJsFile, loadPluginViaTsFile} from './pluginFileLoaders.js' -import type {Plugin, PluginDefaultParams} from './types.js' +import {loadPluginViaNpm} from './npmPluginLoader.js' +import type {NpmPluginRequest, Plugin, PluginDefaultParams} from './types.js' // Helper to get __dirname equivalent in ES Modules const __filename = fileURLToPath(import.meta.url) @@ -20,12 +21,13 @@ export function getPlugins() { } let pluginsLoaded = false -export async function loadPlugins() { +export async function loadPlugins(npmPlugins: NpmPluginRequest[] = []) { try { if (!pluginsLoaded) { core.info('loading plugins') await loadBuiltInPlugins() await loadCustomPlugins() + await loadNpmPlugins(npmPlugins) } } catch { plugins.length = 0 @@ -55,7 +57,7 @@ export async function loadBuiltInPlugins() { await loadPluginsFromPath({pluginsPath}) } -// exported for mocking/testing. not for actual use +// export to be used for mocking/testing. not for actual use export async function loadCustomPlugins() { core.info('Loading custom plugins') const pluginsPath = path.join(process.cwd(), '.github/scanner-plugins/') @@ -75,6 +77,46 @@ export async function loadCustomPlugins() { await loadPluginsFromPath({pluginsPath, skipBuiltInPlugins: BUILT_IN_PLUGINS}) } +// Curated first-party packages allowed to be installed and loaded from NPM. +// Kept intentionally small while the plugin system is being prototyped. +const FIRST_PARTY_NPM_PLUGINS = ['@github/accessibility-scanner-alt-text-plugin'] + +// exported for mocking/testing. not for actual use +export async function loadNpmPlugins(npmPlugins: NpmPluginRequest[]) { + if (npmPlugins.length === 0) { + return + } + core.info('Loading NPM plugins') + + for (const request of npmPlugins) { + // Only install curated first-party packages. + if (!FIRST_PARTY_NPM_PLUGINS.includes(request.package)) { + core.warning(`Skipping NPM plugin '${request.package}' because it is not a first-party package`) + continue + } + + const plugin = await loadPluginViaNpm(request) + if (!plugin) { + continue + } + + // Validate the package actually exports a usable plugin. + if (typeof plugin.name !== 'string' || typeof plugin.default !== 'function') { + core.warning(`Skipping NPM plugin '${request.package}' because it does not export a valid plugin`) + continue + } + + // Built-in and local plugins take precedence over NPM ones of the same name. + if (plugins.some(existing => existing.name === plugin.name)) { + core.info(`Skipping NPM plugin '${plugin.name}' because a plugin with that name is already loaded`) + continue + } + + core.info(`Found NPM plugin: ${plugin.name}`) + plugins.push(plugin) + } +} + // exported for mocking/testing. not for actual use export async function loadPluginsFromPath({ pluginsPath, diff --git a/.github/actions/find/src/pluginManager/npmPluginLoader.ts b/.github/actions/find/src/pluginManager/npmPluginLoader.ts new file mode 100644 index 00000000..5f6157a5 --- /dev/null +++ b/.github/actions/find/src/pluginManager/npmPluginLoader.ts @@ -0,0 +1,23 @@ +import {execFileSync} from 'child_process' +import * as core from '@actions/core' +import type {NpmPluginRequest, Plugin} from './types.js' + +// Install the package at runtime +export function installNpmPackage(spec: string) { + execFileSync('npm', ['install', spec, '--no-save', '--ignore-scripts'], {stdio: 'inherit'}) +} + +// Install and import a single NPM-published plugin +export async function loadPluginViaNpm(request: NpmPluginRequest): Promise { + const spec = request.version ? `${request.package}@${request.version}` : request.package + try { + core.info(`Installing NPM plugin: ${spec}`) + installNpmPackage(spec) + // Import the bare package specifier as-is; pathToFileURL would mangle it. + const imported = await import(request.package) + return imported as Plugin + } catch (e) { + core.warning(`Failed to load NPM plugin '${spec}': ${e}`) + return undefined + } +} diff --git a/.github/actions/find/src/pluginManager/types.ts b/.github/actions/find/src/pluginManager/types.ts index 8fbb315d..65b82469 100644 --- a/.github/actions/find/src/pluginManager/types.ts +++ b/.github/actions/find/src/pluginManager/types.ts @@ -10,3 +10,10 @@ export type Plugin = { name: string default: (options: PluginDefaultParams) => Promise } + +// A plugin requested from an NPM package rather than a local folder. +export type NpmPluginRequest = { + name: string + package: string + version?: string +} diff --git a/.github/actions/find/src/scansContextProvider.ts b/.github/actions/find/src/scansContextProvider.ts index 014c6d58..286ae6f7 100644 --- a/.github/actions/find/src/scansContextProvider.ts +++ b/.github/actions/find/src/scansContextProvider.ts @@ -1,16 +1,41 @@ import * as core from '@actions/core' +import type {NpmPluginRequest} from './pluginManager/types.js' type ScansContext = { scansToPerform: Array + npmPlugins: NpmPluginRequest[] shouldPerformAxeScan: boolean shouldRunPlugins: boolean } let scansContext: ScansContext | undefined +// A scans entry is either a plain name (core engine or local plugin folder) or +// an object describing an NPM-published plugin to install and load. +type ScanEntry = string | {name?: unknown; package?: unknown; version?: unknown} + export function getScansContext() { if (!scansContext) { const scansInput = core.getInput('scans', {required: false}) - const scansToPerform = JSON.parse(scansInput || '[]') + const rawScans = JSON.parse(scansInput || '[]') as ScanEntry[] + + // scansToPerform holds the name of every scan/plugin to run + const scansToPerform: string[] = [] + const npmPlugins: NpmPluginRequest[] = [] + for (const entry of rawScans) { + if (typeof entry === 'string') { + scansToPerform.push(entry) + } else if (entry && typeof entry.name === 'string' && typeof entry.package === 'string') { + scansToPerform.push(entry.name) + npmPlugins.push({ + name: entry.name, + package: entry.package, + version: typeof entry.version === 'string' ? entry.version : undefined, + }) + } else { + core.warning(`Ignoring invalid 'scans' entry: ${JSON.stringify(entry)}`) + } + } + // - if we don't have a scans input // or we do have a scans input, but it only has 1 item and its 'axe' // then we only want to run 'axe' and not the plugins @@ -19,6 +44,7 @@ export function getScansContext() { scansContext = { scansToPerform, + npmPlugins, // - if no 'scans' input is provided, we default to the existing behavior // (only axe scan) for backwards compatability. // - we can enforce using the 'scans' input in a future major release and diff --git a/.github/actions/find/tests/npmPluginLoader.test.ts b/.github/actions/find/tests/npmPluginLoader.test.ts new file mode 100644 index 00000000..05a1eefa --- /dev/null +++ b/.github/actions/find/tests/npmPluginLoader.test.ts @@ -0,0 +1,88 @@ +import {describe, it, expect, vi, beforeEach} from 'vitest' + +import * as childProcess from 'child_process' +import * as core from '@actions/core' +import * as pluginManager from '../src/pluginManager/index.js' +import * as npmPluginLoader from '../src/pluginManager/npmPluginLoader.js' +import type {Plugin} from '../src/pluginManager/types.js' + +vi.mock('child_process', {spy: true}) +vi.mock('@actions/core', {spy: true}) +vi.mock('../src/pluginManager/npmPluginLoader.js', {spy: true}) + +const ALLOWED = '@github/accessibility-scanner-alt-text-plugin' + +describe('npmPluginLoader', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + describe('installNpmPackage', () => { + it('installs with --no-save and --ignore-scripts', () => { + const execSpy = vi.spyOn(childProcess, 'execFileSync').mockImplementation(() => Buffer.from('')) + npmPluginLoader.installNpmPackage('some-pkg@1.0.0') + expect(execSpy).toHaveBeenCalledWith('npm', ['install', 'some-pkg@1.0.0', '--no-save', '--ignore-scripts'], { + stdio: 'inherit', + }) + }) + }) + + describe('loadPluginViaNpm', () => { + it('pins the version in the install spec', async () => { + const execSpy = vi.spyOn(childProcess, 'execFileSync').mockImplementation(() => Buffer.from('')) + await npmPluginLoader.loadPluginViaNpm({name: 'p', package: 'nonexistent-pkg-xyz', version: '2.3.4'}) + expect(execSpy).toHaveBeenCalledWith( + 'npm', + ['install', 'nonexistent-pkg-xyz@2.3.4', '--no-save', '--ignore-scripts'], + {stdio: 'inherit'}, + ) + }) + + it('returns undefined and warns when loading fails', async () => { + vi.spyOn(childProcess, 'execFileSync').mockImplementation(() => Buffer.from('')) + const warnSpy = vi.spyOn(core, 'warning').mockImplementation(() => {}) + const plugin = await npmPluginLoader.loadPluginViaNpm({name: 'p', package: 'nonexistent-pkg-xyz'}) + expect(plugin).toBeUndefined() + expect(warnSpy).toHaveBeenCalled() + }) + }) +}) + +describe('loadNpmPlugins', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + pluginManager.clearCache() + }) + + it('loads a plugin from a first-party package', async () => { + vi.spyOn(npmPluginLoader, 'loadPluginViaNpm').mockResolvedValue({name: 'alt-text-scan', default: vi.fn()}) + await pluginManager.loadNpmPlugins([{name: 'alt-text-scan', package: ALLOWED}]) + expect(pluginManager.getPlugins().map(plugin => plugin.name)).toContain('alt-text-scan') + }) + + it('skips and warns when a package is not first-party', async () => { + const loadSpy = vi.spyOn(npmPluginLoader, 'loadPluginViaNpm').mockResolvedValue(undefined) + const warnSpy = vi.spyOn(core, 'warning').mockImplementation(() => {}) + await pluginManager.loadNpmPlugins([{name: 'evil', package: 'evil-pkg'}]) + expect(loadSpy).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalled() + expect(pluginManager.getPlugins().length).toBe(0) + }) + + it('skips a package that does not export a valid plugin', async () => { + vi.spyOn(npmPluginLoader, 'loadPluginViaNpm').mockResolvedValue({name: 'bad'} as unknown as Plugin) + const warnSpy = vi.spyOn(core, 'warning').mockImplementation(() => {}) + await pluginManager.loadNpmPlugins([{name: 'bad', package: ALLOWED}]) + expect(warnSpy).toHaveBeenCalled() + expect(pluginManager.getPlugins().length).toBe(0) + }) + + it('skips an NPM plugin whose name collides with an already-loaded plugin', async () => { + pluginManager.getPlugins().push({name: 'dup', default: vi.fn()}) + vi.spyOn(npmPluginLoader, 'loadPluginViaNpm').mockResolvedValue({name: 'dup', default: vi.fn()}) + await pluginManager.loadNpmPlugins([{name: 'dup', package: ALLOWED}]) + expect(pluginManager.getPlugins().filter(plugin => plugin.name === 'dup').length).toBe(1) + }) +}) From 9a9e932a0e333302f9d7628886eab519ffe22e1e Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Thu, 25 Jun 2026 15:20:04 -0700 Subject: [PATCH 06/24] Document loading scanner plugins from NPM packages --- PLUGINS.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/PLUGINS.md b/PLUGINS.md index 7f3058fa..f65ac0ef 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -43,6 +43,36 @@ jobs: # ... the rest of the workflow setup ``` +## Loading plugins from NPM packages + +In addition to local plugins under `./.github/scanner-plugins`, the scanner can install and load plugins published as NPM packages. This avoids having to vendor a plugin's source into your repo. + +To use an NPM plugin, pass an object (instead of a plain string) in the `scans` input with the following fields: + +- `name` — the plugin name exported by the package (used to match against `scans`, same as local plugins). +- `package` — the NPM package name to install. +- `version` — (optional) a version or dist-tag to pin. If omitted, the latest version is installed. + +Only the set of first-party packages may be loaded from NPM. Any other package is skipped with a warning. + +```yaml +jobs: + accessibility_scanner: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: github/accessibility-scanner@v3 + with: + scans: | + ["axe", {"name": "alt-text-scan", "package": "@github/accessibility-scanner-alt-text-plugin", "version": "1.0.0"}] +``` + +Notes: + +- Packages are installed at runtime with `npm install --ignore-scripts`, so install/postinstall scripts in the package will not run. Pin a `version` to avoid silently picking up future releases. +- If an NPM plugin shares a name with a built-in or local plugin, the built-in/local plugin wins and the NPM one is skipped. +- Plugin configuration works the same as local plugins: place a config-only folder at `./.github/scanner-plugins//config.json` in your repo (the plugin reads its config relative to the repo you run the workflow from). + ## Things to look out for - Plugin names should be unique. If multiple plugins have the same name, and the `scans` input array contains this name, all the plugins with that name _will_ run. However, this is not advised because if you want to turn off one plugin, you'll have to go back and change that plugin name. From 6aad92b055e2c856b6a142555da63c15ea53b1a9 Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Fri, 26 Jun 2026 12:00:27 -0700 Subject: [PATCH 07/24] Validate scans input, skip lockfile writes, and warn on name mismatches --- .../actions/find/src/pluginManager/index.ts | 13 ++++++++--- .../find/src/pluginManager/npmPluginLoader.ts | 4 ++-- .../actions/find/src/scansContextProvider.ts | 7 +++++- .github/actions/find/tests/findForUrl.test.ts | 11 ++++++++++ .../find/tests/npmPluginLoader.test.ts | 22 ++++++++++++++----- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/.github/actions/find/src/pluginManager/index.ts b/.github/actions/find/src/pluginManager/index.ts index bdeffd76..ae197641 100644 --- a/.github/actions/find/src/pluginManager/index.ts +++ b/.github/actions/find/src/pluginManager/index.ts @@ -77,8 +77,7 @@ export async function loadCustomPlugins() { await loadPluginsFromPath({pluginsPath, skipBuiltInPlugins: BUILT_IN_PLUGINS}) } -// Curated first-party packages allowed to be installed and loaded from NPM. -// Kept intentionally small while the plugin system is being prototyped. +// First-party packages allowed to be installed and loaded from NPM. const FIRST_PARTY_NPM_PLUGINS = ['@github/accessibility-scanner-alt-text-plugin'] // exported for mocking/testing. not for actual use @@ -89,7 +88,7 @@ export async function loadNpmPlugins(npmPlugins: NpmPluginRequest[]) { core.info('Loading NPM plugins') for (const request of npmPlugins) { - // Only install curated first-party packages. + // Only install first-party packages. if (!FIRST_PARTY_NPM_PLUGINS.includes(request.package)) { core.warning(`Skipping NPM plugin '${request.package}' because it is not a first-party package`) continue @@ -106,6 +105,14 @@ export async function loadNpmPlugins(npmPlugins: NpmPluginRequest[]) { continue } + // The requested name (in 'scans') gates invocation, so a mismatch means the plugin would load but never run. + if (plugin.name !== request.name) { + core.warning( + `Skipping NPM plugin '${request.package}' because it exported name '${plugin.name}', which does not match requested name '${request.name}'`, + ) + continue + } + // Built-in and local plugins take precedence over NPM ones of the same name. if (plugins.some(existing => existing.name === plugin.name)) { core.info(`Skipping NPM plugin '${plugin.name}' because a plugin with that name is already loaded`) diff --git a/.github/actions/find/src/pluginManager/npmPluginLoader.ts b/.github/actions/find/src/pluginManager/npmPluginLoader.ts index 5f6157a5..ce30524b 100644 --- a/.github/actions/find/src/pluginManager/npmPluginLoader.ts +++ b/.github/actions/find/src/pluginManager/npmPluginLoader.ts @@ -2,9 +2,9 @@ import {execFileSync} from 'child_process' import * as core from '@actions/core' import type {NpmPluginRequest, Plugin} from './types.js' -// Install the package at runtime +// Install the package at runtime. export function installNpmPackage(spec: string) { - execFileSync('npm', ['install', spec, '--no-save', '--ignore-scripts'], {stdio: 'inherit'}) + execFileSync('npm', ['install', spec, '--no-save', '--no-package-lock', '--ignore-scripts'], {stdio: 'inherit'}) } // Install and import a single NPM-published plugin diff --git a/.github/actions/find/src/scansContextProvider.ts b/.github/actions/find/src/scansContextProvider.ts index 286ae6f7..047f142c 100644 --- a/.github/actions/find/src/scansContextProvider.ts +++ b/.github/actions/find/src/scansContextProvider.ts @@ -16,7 +16,12 @@ type ScanEntry = string | {name?: unknown; package?: unknown; version?: unknown} export function getScansContext() { if (!scansContext) { const scansInput = core.getInput('scans', {required: false}) - const rawScans = JSON.parse(scansInput || '[]') as ScanEntry[] + const parsed = JSON.parse(scansInput || '[]') + // Fail early with a clear message instead of a cryptic 'not iterable' error later. + if (!Array.isArray(parsed)) { + throw new Error(`'scans' input must be a JSON array, got: ${scansInput}`) + } + const rawScans = parsed as ScanEntry[] // scansToPerform holds the name of every scan/plugin to run const scansToPerform: string[] = [] diff --git a/.github/actions/find/tests/findForUrl.test.ts b/.github/actions/find/tests/findForUrl.test.ts index 54244885..dd48dd81 100644 --- a/.github/actions/find/tests/findForUrl.test.ts +++ b/.github/actions/find/tests/findForUrl.test.ts @@ -116,6 +116,17 @@ describe('findForUrl', () => { expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1) expect(loadedPlugins[1].default).toHaveBeenCalledTimes(0) }) + + it('forwards object-form NPM plugin entries to loadPlugins', async () => { + loadedPlugins = [] + actionInput = JSON.stringify([{name: 'alt-text-scan', package: '@github/accessibility-scanner-alt-text-plugin'}]) + clearAll() + + await findForUrl('test.com') + expect(pluginManager.loadPlugins).toHaveBeenCalledWith([ + {name: 'alt-text-scan', package: '@github/accessibility-scanner-alt-text-plugin', version: undefined}, + ]) + }) }) describe('axe finding categorization', () => { diff --git a/.github/actions/find/tests/npmPluginLoader.test.ts b/.github/actions/find/tests/npmPluginLoader.test.ts index 05a1eefa..30a6560b 100644 --- a/.github/actions/find/tests/npmPluginLoader.test.ts +++ b/.github/actions/find/tests/npmPluginLoader.test.ts @@ -19,12 +19,16 @@ describe('npmPluginLoader', () => { }) describe('installNpmPackage', () => { - it('installs with --no-save and --ignore-scripts', () => { + it('installs with --no-save, --no-package-lock and --ignore-scripts', () => { const execSpy = vi.spyOn(childProcess, 'execFileSync').mockImplementation(() => Buffer.from('')) npmPluginLoader.installNpmPackage('some-pkg@1.0.0') - expect(execSpy).toHaveBeenCalledWith('npm', ['install', 'some-pkg@1.0.0', '--no-save', '--ignore-scripts'], { - stdio: 'inherit', - }) + expect(execSpy).toHaveBeenCalledWith( + 'npm', + ['install', 'some-pkg@1.0.0', '--no-save', '--no-package-lock', '--ignore-scripts'], + { + stdio: 'inherit', + }, + ) }) }) @@ -34,7 +38,7 @@ describe('npmPluginLoader', () => { await npmPluginLoader.loadPluginViaNpm({name: 'p', package: 'nonexistent-pkg-xyz', version: '2.3.4'}) expect(execSpy).toHaveBeenCalledWith( 'npm', - ['install', 'nonexistent-pkg-xyz@2.3.4', '--no-save', '--ignore-scripts'], + ['install', 'nonexistent-pkg-xyz@2.3.4', '--no-save', '--no-package-lock', '--ignore-scripts'], {stdio: 'inherit'}, ) }) @@ -79,6 +83,14 @@ describe('loadNpmPlugins', () => { expect(pluginManager.getPlugins().length).toBe(0) }) + it('skips an NPM plugin whose exported name does not match the requested name', async () => { + vi.spyOn(npmPluginLoader, 'loadPluginViaNpm').mockResolvedValue({name: 'actual-name', default: vi.fn()}) + const warnSpy = vi.spyOn(core, 'warning').mockImplementation(() => {}) + await pluginManager.loadNpmPlugins([{name: 'requested-name', package: ALLOWED}]) + expect(warnSpy).toHaveBeenCalled() + expect(pluginManager.getPlugins().length).toBe(0) + }) + it('skips an NPM plugin whose name collides with an already-loaded plugin', async () => { pluginManager.getPlugins().push({name: 'dup', default: vi.fn()}) vi.spyOn(npmPluginLoader, 'loadPluginViaNpm').mockResolvedValue({name: 'dup', default: vi.fn()}) From ef9b812b1406810df27dd75e22e82f4ecabe9460 Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Fri, 26 Jun 2026 18:04:17 -0700 Subject: [PATCH 08/24] Pin Playwright to 1.60.0 to keep the integration-test Chromium unchanged --- .github/actions/find/package-lock.json | 26 +++++++++++++------------- .github/actions/find/package.json | 7 +++++-- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/.github/actions/find/package-lock.json b/.github/actions/find/package-lock.json index c98d5378..25012857 100644 --- a/.github/actions/find/package-lock.json +++ b/.github/actions/find/package-lock.json @@ -12,9 +12,9 @@ "@accesslint/playwright": "^0.5.0", "@actions/core": "^3.0.1", "@axe-core/playwright": "^4.11.3", - "@playwright/test": "^1.60.0", + "@playwright/test": "1.60.0", "esbuild": "^0.28.0", - "playwright": "^1.60.0" + "playwright": "1.60.0" }, "devDependencies": { "@types/node": "^25.9.0", @@ -503,12 +503,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", - "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.0" + "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -592,12 +592,12 @@ } }, "node_modules/playwright": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", - "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.0" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -610,9 +610,9 @@ } }, "node_modules/playwright-core": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", - "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/.github/actions/find/package.json b/.github/actions/find/package.json index 4b0ce269..9c0ba7da 100644 --- a/.github/actions/find/package.json +++ b/.github/actions/find/package.json @@ -16,12 +16,15 @@ "@accesslint/playwright": "^0.5.0", "@actions/core": "^3.0.1", "@axe-core/playwright": "^4.11.3", - "@playwright/test": "^1.60.0", + "@playwright/test": "1.60.0", "esbuild": "^0.28.0", - "playwright": "^1.60.0" + "playwright": "1.60.0" }, "devDependencies": { "@types/node": "^25.9.0", "typescript": "^6.0.3" + }, + "overrides": { + "playwright-core": "1.60.0" } } From d065c0ce3e7db792397afbc04b23231ac0a66821 Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Mon, 29 Jun 2026 10:00:24 -0700 Subject: [PATCH 09/24] Add axe vs. AccessLint comparison doc --- AXE_VS_ACCESSLINT.md | 47 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 AXE_VS_ACCESSLINT.md diff --git a/AXE_VS_ACCESSLINT.md b/AXE_VS_ACCESSLINT.md new file mode 100644 index 00000000..1f635f88 --- /dev/null +++ b/AXE_VS_ACCESSLINT.md @@ -0,0 +1,47 @@ +# axe vs. AccessLint + +The a11y scanner ships two built-in scan engines: [axe-core](https://github.com/dequelabs/axe-core) (the default) and [AccessLint](https://github.com/AccessLint/accesslint), a newer, lightweight ruleset. Both run against the live page, report WCAG violations, and share a large core of checks. axe is the mature, well-documented baseline; AccessLint adds extra WCAG 2.2 and best-practice checks. This doc explains what AccessLint adds and which combination to pick. + +> 👉 Pick engines with the `scans` input. They run independently and file their findings as separate issues. + +## Three ways to run + +| `scans` | Runs | Best when | +| ------------------------ | --------------- | --------------------------------------------------------------------------------------------------- | +| _(omitted)_ or `["axe"]` | axe only | the mature default; lowest noise | +| `["accesslint"]` | AccessLint only | you want its newer ruleset without axe's duplicates — but you give up axe's maturity and Deque docs | +| `["axe","accesslint"]` | both | maximum coverage, at the cost of duplicate findings | + +## At a glance + +| | axe-core | AccessLint | +| ----------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| **Role in the scanner** | Default engine; runs when no `scans` input is given | Optional second engine via `scans` | +| **Strength** | Mature, widely adopted, well-documented baseline | Lightweight second pass with newer WCAG 2.2 and best-practice checks | +| **Coverage** | WCAG 2.0/2.1/2.2 (A/AA/AAA) tags plus best practices; some 2.2 rules are off by default | WCAG 2.2 A/AA plus best-practice rules | +| **Per-rule docs** | Deque University help URL on every finding | Rule IDs + messages; less rich public per-rule docs | +| **Main risk** | Doesn't catch every WCAG issue automatically (~57%) | More overlap/noise; smaller ecosystem | + +Both cover the same large core: alt text, link/button names, form labels, ARIA validity, heading order, landmarks, table headers, language attributes, and color contrast. + +## What AccessLint adds + +AccessLint includes checks that may not be surfaced by the scanner's default axe configuration, including some WCAG 2.2 and best-practice checks. Findings link to the [AccessLint rules reference](https://github.com/AccessLint/accesslint/blob/main/core/README.md#rules-1). Examples: + +- **Focus Visible (2.4.7)** — focusable elements without a visible focus indicator. +- **Orientation (1.3.4)** — pages locked to a single screen orientation. +- **Accessible Authentication (3.3.8)** — password fields that block password managers / paste. +- **Audio transcript (1.2.1)** — `