Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
52e401a
Add AccessLint scan engine
kzhou314 Jun 24, 2026
d07f5eb
Document AccessLint engine in README
kzhou314 Jun 24, 2026
41f49ec
Apply suggestions from code review (escape AccessLint solutionShort a…
kzhou314 Jun 24, 2026
0432b26
Fix formatting
kzhou314 Jun 24, 2026
2cdf7c1
Merge remote-tracking branch 'origin/main' into accesslint-engine-pro…
kzhou314 Jun 24, 2026
671de5c
Load first-party scanner plugins from NPM packages
kzhou314 Jun 25, 2026
9a9e932
Document loading scanner plugins from NPM packages
kzhou314 Jun 25, 2026
61c5831
Merge remote-tracking branch 'origin/main' into accesslint-engine-pro…
kzhou314 Jun 26, 2026
c451932
Merge branch 'main' into npm-plugin-loading
kzhou314 Jun 26, 2026
6aad92b
Validate scans input, skip lockfile writes, and warn on name mismatches
kzhou314 Jun 26, 2026
e1aed69
Merge branch 'main' into accesslint-engine-prototype
kzhou314 Jun 27, 2026
4488f96
Merge branch 'main' into npm-plugin-loading
kzhou314 Jun 27, 2026
ef9b812
Pin Playwright to 1.60.0 to keep the integration-test Chromium unchanged
kzhou314 Jun 27, 2026
d065c0c
Add axe vs. AccessLint comparison doc
kzhou314 Jun 29, 2026
296fa7e
Trim axe vs. AccessLint doc
kzhou314 Jun 29, 2026
52cc0db
List AccessLint-only checks and tighten wording in comparison doc
kzhou314 Jun 29, 2026
59a5adc
Rename npmPluginLoader to pluginNpmLoader for consistency
kzhou314 Jun 29, 2026
fdafec3
Extract isValidPlugin and isDuplicatePlugin helpers for plugin loading
kzhou314 Jun 29, 2026
454be60
Link first-party allowlist in docs and drop redundant scans comment
kzhou314 Jun 29, 2026
4853746
Merge branch 'main' into accesslint-engine-prototype
kzhou314 Jun 29, 2026
4cadf62
Merge branch 'main' into npm-plugin-loading
kzhou314 Jun 29, 2026
600734b
Update AXE_VS_ACCESSLINT.md
kzhou314 Jun 29, 2026
2757b80
Guard object scans entries and read npm plugins from cached scans con…
kzhou314 Jul 1, 2026
aee1d92
Clarify axe and accesslint function
kzhou314 Jul 1, 2026
011eff9
Use GitHub tip alert syntax for engine-selection callout
kzhou314 Jul 1, 2026
78b3876
Npm plugin loading (#242)
kzhou314 Jul 2, 2026
99ad11e
Merge branch 'main' into accesslint-engine-prototype
kzhou314 Jul 2, 2026
c018ad2
Add AccessLint scan engine (#241)
kzhou314 Jul 6, 2026
b786707
chore(deps): Bump ruby/setup-ruby
dependabot[bot] Jul 6, 2026
21949eb
chore(deps): Bump ruby/setup-ruby from 1.314.0 to 1.316.0 in the gith…
JoyceZhu Jul 7, 2026
2f157b4
Document alt-text plugin in Plugins section
kzhou314 Jul 7, 2026
92e55ab
Make section less dense/more readable
kzhou314 Jul 7, 2026
40bcff5
Merge branch 'main' into document-alt-text-plugin
kzhou314 Jul 7, 2026
752cdcb
Wording changes
kzhou314 Jul 8, 2026
b8c7793
chore(deps): Bump the github-actions group across 1 directory with 2 …
dependabot[bot] Jul 20, 2026
8bd715f
chore(deps-dev): Bump brace-expansion from 5.0.3 to 5.0.7
dependabot[bot] Jul 20, 2026
4f5bdfc
chore(deps): Bump the github-actions group across 1 directory with 2 …
abdulahmad307 Jul 21, 2026
72f473f
chore(deps-dev): Bump brace-expansion from 5.0.3 to 5.0.7 (#249)
abdulahmad307 Jul 21, 2026
28456f9
Merge branch 'main' into document-alt-text-plugin
JoyceZhu Jul 23, 2026
096ac67
Document alt-text plugin in Plugins section (#247)
kzhou314 Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/find/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 either a 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'
Expand Down
37 changes: 36 additions & 1 deletion .github/actions/find/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion .github/actions/find/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,18 @@
"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"
"playwright": "1.60.0"
},
"devDependencies": {
"@types/node": "^25.9.0",
"typescript": "^6.0.3"
},
"overrides": {
"playwright-core": "1.60.0"
}
}
34 changes: 34 additions & 0 deletions .github/actions/find/src/findForUrl.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {ColorSchemePreference, Finding, FindingCategory, 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'
Expand Down Expand Up @@ -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}`)
}
Expand Down Expand Up @@ -105,6 +110,35 @@ async function runAxeScan({
}
}

async function runAccesslintScan({
page,
addFinding,
}: {
page: playwright.Page
addFinding: (findingData: Finding, options?: {includeScreenshots?: boolean}) => Promise<void>
}) {
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<typeof accesslintAudit>[0])
for (const violation of violations) {
await addFinding({
scannerType: 'accesslint',
url,
Comment on lines +126 to +128
html: violation.html.replace(/'/g, '&apos;'),
problemShort: violation.message.toLowerCase().replace(/'/g, '&apos;'),
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,
'&apos;',
),
})
}
}

// Maps an Axe violation's tags to a conformance tier. Experimental is checked
// first because some experimental rules also carry a wcag* tag.
function categorizeAxeViolation(tags: string[]): FindingCategory {
Expand Down
70 changes: 69 additions & 1 deletion .github/actions/find/src/pluginManager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import * as path from 'path'
import {fileURLToPath} from 'url'
import * as core from '@actions/core'
import {loadPluginViaJsFile, loadPluginViaTsFile} from './pluginFileLoaders.js'
import {loadPluginViaNpm} from './pluginNpmLoader.js'
import type {Plugin, PluginDefaultParams} from './types.js'
import {getScansContext} from '../scansContextProvider.js'

// Helper to get __dirname equivalent in ES Modules
const __filename = fileURLToPath(import.meta.url)
Expand All @@ -26,6 +28,7 @@ export async function loadPlugins() {
core.info('loading plugins')
await loadBuiltInPlugins()
await loadCustomPlugins()
await loadNpmPlugins()
}
} catch {
plugins.length = 0
Expand All @@ -47,6 +50,16 @@ export function clearCache() {
plugins.length = 0
}

// True when the object is a usable plugin (exposes a name and default export).
function isValidPlugin(plugin: Plugin | undefined): plugin is Plugin {
return typeof plugin?.name === 'string' && typeof plugin.default === 'function'
}

// True when a plugin with the same name is already loaded.
function isDuplicatePlugin(plugin: Plugin): boolean {
return plugins.some(existing => existing.name === plugin.name)
}

// exported for mocking/testing. not for actual use
export async function loadBuiltInPlugins() {
core.info('Loading built-in plugins')
Expand All @@ -55,7 +68,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/')
Expand All @@ -75,6 +88,52 @@ export async function loadCustomPlugins() {
await loadPluginsFromPath({pluginsPath, skipBuiltInPlugins: BUILT_IN_PLUGINS})
}

// 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
export async function loadNpmPlugins() {
const {npmPlugins} = getScansContext()
if (npmPlugins.length === 0) {
return
}
core.info('Loading NPM plugins')

for (const request of npmPlugins) {
// 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
}

const plugin = await loadPluginViaNpm(request)
if (!plugin) {
continue
}

// Plugin doesn't expose a usable name/default export.
if (!isValidPlugin(plugin)) {
core.warning(`Skipping NPM plugin '${request.package}' because it does not export a valid plugin`)
continue
}
// 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 (isDuplicatePlugin(plugin)) {
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,
Expand Down Expand Up @@ -105,6 +164,15 @@ export async function loadPluginsFromPath({
continue
}

if (!isValidPlugin(plugin)) {
core.warning(`Skipping plugin '${pluginFolder}' because it does not export a valid plugin`)
continue
}
if (isDuplicatePlugin(plugin)) {
core.warning(`Skipping plugin '${pluginFolder}' because a plugin named '${plugin.name}' is already loaded`)
continue
}
Comment on lines +171 to +174

core.info(`Found plugin: ${plugin.name}`)
plugins.push(plugin)
}
Expand Down
23 changes: 23 additions & 0 deletions .github/actions/find/src/pluginManager/pluginNpmLoader.ts
Original file line number Diff line number Diff line change
@@ -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', '--no-package-lock', '--ignore-scripts'], {stdio: 'inherit'})
}

// Install and import a single NPM-published plugin
export async function loadPluginViaNpm(request: NpmPluginRequest): Promise<Plugin | undefined> {
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
}
}
7 changes: 7 additions & 0 deletions .github/actions/find/src/pluginManager/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@ export type Plugin = {
name: string
default: (options: PluginDefaultParams) => Promise<void>
}

// A plugin requested from an NPM package rather than a local folder.
export type NpmPluginRequest = {
name: string
package: string
version?: string
}
50 changes: 43 additions & 7 deletions .github/actions/find/src/scansContextProvider.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,66 @@
import * as core from '@actions/core'
import type {NpmPluginRequest} from './pluginManager/types.js'

type ScansContext = {
scansToPerform: Array<string>
npmPlugins: NpmPluginRequest[]
shouldPerformAxeScan: boolean
shouldPerformAccesslintScan: 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 || '[]')
// - 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')
const parsed = JSON.parse(scansInput || '[]')
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[] = []
const npmPlugins: NpmPluginRequest[] = []
for (const entry of rawScans) {
if (typeof entry === 'string') {
scansToPerform.push(entry)
} else if (
typeof entry === 'object' &&
entry !== null &&
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)}`)
}
}

// 'axe' and 'accesslint' are built-in core engines; anything else in the
// list is treated as a plugin name (local folder or NPM-published plugin).
const coreEngines = ['axe', 'accesslint']
const pluginScans = scansToPerform.filter(scan => !coreEngines.includes(scan))

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
// mark it as required
shouldPerformAxeScan: !scansInput || scansToPerform.includes('axe'),
shouldRunPlugins: scansToPerform.length > 0 && !onlyAxeScan,
shouldPerformAccesslintScan: scansToPerform.includes('accesslint'),
shouldRunPlugins: pluginScans.length > 0,
}
}

Expand Down
Loading
Loading