From d72bef3a6e9c849846372be8f263bb83e02b06f8 Mon Sep 17 00:00:00 2001 From: Aaryan Choudhary Date: Fri, 21 Aug 2026 10:03:54 +0530 Subject: [PATCH 1/2] fix(a11y-critical): log unknown ids in options.rules, do not throw options.rules is an enable/disable map and its consumers look ids up by key, so an id the current instance does not know is a no-op. Throwing on one turns a harmless extra key into a rejected axe.run, and in a multi-frame run a single frame's rejection discards the whole result set. Log and continue instead, matching the existing behaviour for an unmatched tag in options.runOnly. options.runOnly still throws on an unknown rule id. Co-Authored-By: Claude Opus 5 (1M context) --- lib/core/utils/normalize-run-options.js | 14 ++++++------ test/core/utils/normalize-run-options.js | 27 ++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/lib/core/utils/normalize-run-options.js b/lib/core/utils/normalize-run-options.js index 1c65d518c..d163902bd 100644 --- a/lib/core/utils/normalize-run-options.js +++ b/lib/core/utils/normalize-run-options.js @@ -1,6 +1,6 @@ /** * Ensure all rules that are expected to run exist - * @throws {Error} If any tag or rule specified in options is unknown + * @throws {Error} If any rule specified in options.runOnly is unknown * @param {Object} options Options object * @return {Object} Validated options object */ @@ -69,11 +69,13 @@ export default function normalizeRunOptions(options = {}) { } } if (typeof options.rules === 'object') { - Object.keys(options.rules).forEach(ruleId => { - if (!ruleIds.includes(ruleId)) { - throw new Error('unknown rule `' + ruleId + '` in options.rules'); - } - }); + // [a11y-critical]: log unknown ids in options.rules instead of throwing + const unmatchedRules = Object.keys(options.rules).filter( + ruleId => !ruleIds.includes(ruleId) + ); + if (unmatchedRules.length !== 0) { + axe.log('Could not find rules `' + unmatchedRules.join('`, `') + '`'); + } } return options; } diff --git a/test/core/utils/normalize-run-options.js b/test/core/utils/normalize-run-options.js index 67b472d83..daffced4c 100644 --- a/test/core/utils/normalize-run-options.js +++ b/test/core/utils/normalize-run-options.js @@ -213,8 +213,21 @@ describe('axe.utils.normalizeRunOptions', () => { }); }); - it('throws an error when option.rules has an unknown rule', () => { - assert.throws(() => { + it('logs an issue when option.rules has an unknown rule', () => { + let message = ''; + axe._setLogger(m => { + message = m; + }); + axe.utils.normalizeRunOptions({ + rules: { + fakeRule: { enabled: false } + } + }); + assert.include(message, 'Could not find rules'); + }); + + it('does not throw when option.rules has an unknown rule', () => { + assert.doesNotThrow(() => { axe.utils.normalizeRunOptions({ rules: { fakeRule: { enabled: false } @@ -223,6 +236,16 @@ describe('axe.utils.normalizeRunOptions', () => { }); }); + it('keeps known rules in options.rules when another id is unknown', () => { + const options = axe.utils.normalizeRunOptions({ + rules: { + 'color-contrast': { enabled: false }, + fakeRule: { enabled: false } + } + }); + assert.deepEqual(options.rules['color-contrast'], { enabled: false }); + }); + it('logs an issue when a tag is unknown', () => { let message = ''; axe._setLogger(m => { From 7e9d94500496e885ba4c3a6e1a139996a422e6da Mon Sep 17 00:00:00 2001 From: Aaryan Choudhary Date: Fri, 21 Aug 2026 10:33:01 +0530 Subject: [PATCH 2/2] feat(a11y-critical): record what configure registered and when an audit is replaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit configure() reports success by not throwing and returns nothing, and _load() replaces axe._audit outright — so a caller cannot tell a completed registration from one that was later discarded. The built axe.js calls _load on evaluation, which makes a second evaluation in one context reset the registry to stock with no error raised. Record the before/after rule counts on audit.lastConfigure, carry a generation counter across _load, and log when _load replaces an existing audit. Co-Authored-By: Claude Opus 5 (1M context) --- lib/core/public/configure.js | 16 ++++++++++++++++ lib/core/public/load.js | 12 ++++++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/core/public/configure.js b/lib/core/public/configure.js index 0915c981e..499bb6d6a 100644 --- a/lib/core/public/configure.js +++ b/lib/core/public/configure.js @@ -9,6 +9,16 @@ function configure(spec) { throw new Error('No audit configured'); } + // [a11y-critical]: configure() reports success by not throwing and returns + // nothing, so a caller cannot tell a completed registration from one that was + // later discarded (see load.js — a second axe evaluation replaces _audit). + // Record what this call registered so that is answerable after the fact. + const configureLog = { + ruleCountBefore: audit.rules.length, + rulesIn: Array.isArray(spec.rules) ? spec.rules.length : 0, + checksIn: Array.isArray(spec.checks) ? spec.checks.length : 0 + }; + if (spec.axeVersion || spec.ver) { const specVersion = spec.axeVersion || spec.ver; if (!/^\d+\.\d+\.\d+(-canary)?/.test(specVersion)) { @@ -139,6 +149,12 @@ function configure(spec) { if (spec.crossOriginDenylist) { audit.setCrossOriginDenylist(spec.crossOriginDenylist); } + + // [a11y-critical]: completes the record started above. audit.generation lets a + // caller detect that the audit it configured is no longer the live one. + configureLog.ruleCountAfter = audit.rules.length; + configureLog.generation = audit.generation; + audit.lastConfigure = configureLog; } export default configure; diff --git a/lib/core/public/load.js b/lib/core/public/load.js index ea69f3b69..c599e04bf 100644 --- a/lib/core/public/load.js +++ b/lib/core/public/load.js @@ -12,7 +12,19 @@ import mergeErrors from '../utils/merge-errors'; * @private */ export default function load(audit) { + // [a11y-critical]: this discards every prior axe.configure() with no error — + // the built axe.js calls _load on evaluation, so evaluating axe twice in one + // context silently resets the registry to stock. Carry a generation counter so + // a caller can tell its configured audit was replaced out from under it. + const previous = axe._audit; axe._audit = new Audit(audit); + axe._audit.generation = previous ? (previous.generation || 0) + 1 : 0; + if (previous && typeof axe.log === 'function') { + axe.log( + 'axe._load replaced an existing audit; previously configured rules and checks are gone', + { generation: axe._audit.generation } + ); + } } function runCommand(data, keepalive, callback) {