Packs
A pack brings rules, variants of core's rules, a standard or a checklist, and their profiles and messages, to @surea11y/core from a package of its own, without a fork or a build of core. A national standard such as RGAA can be a pack, and so can your organisation's accessibility policy. A scan takes packs in engineOptions.packs, and runs them as it runs core's own rules and standards: in Node and in a browser, from the CLI, the bindings and the test matchers, with results, rollups and report entries in the same format as core's. Packs need @surea11y/core 1.11.1 or later.
Which kind of pack
| You have | Kind | What it holds |
|---|---|---|
| An organisation's accessibility policy: “WCAG 2.2 AA, plus these rules, minus that one, with these items in our report” | checklist | Profiles choosing core's rules and your own, and rollups as the checklist's items. |
| A standard with requirements of its own (a national standard such as RGAA, a sector's): numbered requirements, each checked by some rules | standard | A requirements table, the rules that check each requirement, a result per requirement, and profiles. |
| A few rules to add to every scan, with no profile | rules only | Rules, and variants or overrides of core's. Start from a checklist and delete its profiles and rollups. |
A pack's rules and profiles can use any of core's rules: a checklist or a standard is mostly core's rules, chosen and grouped, plus the few rules core doesn't have. The examples on this page use acme-policy-pack.js, the small pack every binding ships in its examples/ folder: one house rule, a checklist profile (WCAG 2.2 A and AA, region, and the house rule) and two checklist items. Its source is below.
The EN 301 549 pack
@surea11y/pack-en301549 is a standard pack of the SureA11y family, ready to install: EN 301 549 chapter 9 (Web), in V3.2.1 and V4.1.1. It has a profile per version, en301549-v4.1.1 and en301549-v3.2.1, that reports one result per clause, and mappings: ['en301549'] to name the clauses on the results of any scan, a WCAG one included. It needs @surea11y/core 1.13.0 or later.
Its 1.1.0 release, built on core 1.14.0, judges each clause as core judges the WCAG criterion it restates. So no clause passes yet: where all its rules pass, a clause is cantTell, for a person to check the rest, as its criterion is (see WCAG conformance mapping). The en301549-v3.2.1 profile keeps 4.1.1 Parsing, so a duplicate id still fails 9.4.1.1 there, and 9.1.4.5, 9.3.3.1 and 9.4.1.3 have their first rules (image-of-text, invalid-field-error-message, live-region-announcements). With core 1.13 it behaves as its 1.0 releases do.
// npm install @surea11y/core @surea11y/pack-en301549const en301549 = require('@surea11y/pack-en301549');runDomRulesInPage(url, null, { packs: [en301549], profile: 'en301549-v4.1.1' });runDomRulesInPage(url, null, { packs: [en301549], profile: 'wcag22-aa', mappings: ['en301549:V4.1.1'] });
It is also a worked example of a standard of your own. It has no rules: chapter 9 restates WCAG, so each clause is checked by core's rules for its criterion. Its profiles choose those rules (tags, mappedRules: true), name the WCAG version each version of the standard is judged by (wcagVersion) and, for V3.2.1, keep 4.1.1 Parsing (keepsParsing). Its clauses restate WCAG criteria one for one (applicability: 'wcag'), and its rule map is derived from the installed core's catalog, so a rule core adds is covered with no change to the pack. A standard with requirements WCAG doesn't have uses the same shape and adds its own rules. Its source is short and worth reading beside Writing a pack below.
Core had EN 301 549 built in until 1.13.0, which deprecates it in favour of this pack (see WCAG mapping). Like any profile, the pack's profiles choose what runs: a conformance claim still needs a person to check every clause.
Using a pack
From Node
const { runDomRulesInPage, getChecksCatalog } = require('@surea11y/core');const acmePolicy = require('./acme-policy-pack');const result = runDomRulesInPage(url, null, { packs: [acmePolicy], profile: 'acme-web-2026' }, null);result.engine.packs; // ['@acme/a11y-policy@1.0.0']getChecksCatalog({ packs: [acmePolicy] }); // core's rules and the pack's
The pack is prepared once and kept for the next scan with the same pack object, so a crawl that passes the same packs to every page prepares them once. definePack freezes the pack object: treat it as immutable, and reuse the same object between scans. Passing a pack registers it for the scan; it doesn't select its rules. A pack rule tagged with the pack's namespace is opt-in: without one of the pack's profiles, it runs only when selected by id or by that tag (see Names). A pack rule without the tag is selected as core's rules are, so a default scan with the pack runs it. Add runOnly: { bestPractices: true } beside the profile to run the best practices too.
With a browser binding
Playwright, Puppeteer, Selenium and WebdriverIO take the pack object in .withPacks(), register it in the page, and in every frame with .frames(true), before the scan:
const { A11yCoreBuilder, getScanGaps } = require('@surea11y/playwright');const acmePolicy = require('./acme-policy-pack');const results = await new A11yCoreBuilder({ page }).withPacks(acmePolicy).options({ profile: 'acme-web-2026' }).analyze();results.engine.packs; // ['@acme/a11y-policy@1.0.0']// A checklist's items, each one result, beside core's WCAG rollups:results.rulesResults.filter((r) => r.meta && r.meta.standard === 'Acme web policy');
Pass an array, or call .withPacks() again, to scan with several packs. The bindings share .withPacks(), but each builder takes its own driver object ({ page }, { driver } or { browser }) and needs its own setup: see Playwright, Puppeteer, Selenium and WebdriverIO.
With Cypress
A pack is prepared in Node, and a Cypress spec runs in the browser. So in Cypress .withPacks() takes each pack's module name (a package name, or a path from the project root), and the binding's plugin prepares the pack in Node. Register the plugin once:
// cypress.config.jsconst { defineConfig } = require('cypress');const surea11y = require('@surea11y/cypress/plugin');module.exports = defineConfig({e2e: {setupNodeEvents(on, config) {surea11y(on, config);}}});
new A11yCoreBuilder().withPacks('./examples/acme-policy-pack.js') // or a package name, '@acme/a11y-policy'.options({ profile: 'acme-web-2026' }).analyze().then((results) => {expect(results.engine.packs).to.deep.equal(['@acme/a11y-policy@1.0.0']);});
.analyze() asks the plugin, through cy.task, for the script that registers the packs, and runs it in the page, and in every same-origin frame with .frames(true). The plugin keeps a pack for the rest of the run, so a change to a pack's file shows on the next cypress run, or after restarting cypress open. A pack object, as the other bindings take, throws at .withPacks(): it can't be prepared in the browser.
With the test matchers
@surea11y/test-matchers scans in Node, so a pack is passed as the object itself:
const acmePolicy = require('./acme-policy-pack');const policy = { packs: [acmePolicy], profile: 'acme-web-2026' };test("a page that breaks the pack's house rule fails the assertion", () => {document.body.innerHTML = '<main><h1>Welcome</h1><video autoplay src="intro.mp4"></video></main>';expect(document).not.toHaveNoA11yViolations(policy);});
Keep the pack objects in one place (a setup file or a module), so every test passes the same ones. A pack that did not run fails the assertion. See Test matchers.
From the CLI
$ surea11y scan ./dist/index.html --pack ./acme-policy-pack.js --profile acme-web-2026$ surea11y scan ./dist/index.html --pack @acme/a11y-policy --profile acme-web-2026
--pack takes a package name, resolved from the working directory's project, or a path, and repeats for several packs. The pack loads @surea11y/core/pack, which is installed beside the CLI when you install @surea11y/cli in the project. See CLI.
In a page of your own
For a browser extension or a page.evaluate of your own, inject packScript([acmePolicy]) from @surea11y/core/pack after core's browser bundle, or ship one file built with buildBrowserBundle({ packs: [acmePolicy] }), and name the pack as name@version. See Standalone. A page that loads its scripts as files is best served by the one file from buildBrowserBundle: the pack's script runs only with the core that wrote it.
A page with another core's bundle skips the pack, and skippedPacks names the two versions, so build that file again for each release of core or of the pack.
What a scan reports
engine.packsnames each pack that ran, asname@version, andengine.profilethe profile that applied.- A pack's rules appear in
checksResultslike core's, with ids that start with its namespace (acme-no-autoplay). A pack rule that maps to a WCAG criterion counts toward that criterion's rollup, unless it carries the pack's opt-in namespace tag: those rules check what WCAG doesn't require, so they count only in the pack's own rollups. TheirwcagScstill names the WCAG criteria they check, for the reporters, and no other standard (EN 301 549, which maps every rule by its WCAG criteria) claims them. An override counts in place of the core rule it replaces, where its own mapping puts it. - A checklist's items, or a standard's requirements, are rollups in
rulesResults, with the checklist's or standard's name asmeta.standard. The HTML report shows them in a section of their own, SARIF tags each rule with the items it belongs to (acme-images), and JUnit lists them as properties. The result'sstandardsnames the checklist, so a stored result renders the same wherever it is rendered. - A profile's
severityshows on the results it changes, with the rule's own kept asruleSeverity.
When a pack doesn't run
A pack whose core range leaves out the installed core, that isn't valid, or that doesn't fit with core (a rule id outside its namespace, a dictionary key core has, a variant of a rule that isn't there) is skipped: the scan runs without it, warns, and lists it in skippedPacks as { name, reason }. Under strictOptions it throws, with code: 'INVALID_ENGINE_OPTIONS', as every error in engineOptions.packs has. Two packs that clash with each other (a dictionary key both define, two standards with one key) throw, since any way of choosing between them would depend on the order they were passed in. In a page, a pack named in engineOptions.packs that no packScript call registered, or that was registered with another core version, is listed the same way. So a scan can finish without the checks you added the pack for: a tool that depends on a pack should check skippedPacks before treating the scan as complete.
Each tool turns a skipped pack into a failure you can see:
- The browser bindings check each pack when you call
.withPacks()and throw before scanning for one core can't run. A pack the page then doesn't run is a'pack-skipped'gap ingetScanGaps(results), and.analyze()warns about it, so a test that asserts on the gaps fails. - The test matchers fail the assertion, naming the pack and the reason.
- The CLI exits
2, with no report or baseline written, for a pack it can't find or load, or one the engine skips.
Install the pack beside @surea11y/core and the package that runs it, so all three share one copy of core.
Writing a pack
Start from the example
A pack is an object made with definePack from @surea11y/core/pack. This is acme-policy-pack.js, the example the bindings ship:
'use strict';// A small pack: one house rule, a checklist profile and two checklist items.// A real pack is a package of its own; `npx surea11y-pack new <folder>` writes one.const { definePack, wcagTags } = require('@surea11y/core/pack');module.exports = definePack({name: '@acme/a11y-policy',title: 'Acme web policy',version: '1.0.0',namespace: 'acme',core: '^1.11.1',rules: [{id: 'acme-no-autoplay',meta: {title: 'Videos do not play sound on their own',description: 'A video that starts with sound talks over a screen reader.',tags: ['acme'],defaultSeverity: 'serious'},runInPage(ctx) {// defaultMuted reflects the muted attribute where muted isn't set from it yet (jsdom).const loud = [...ctx.document.querySelectorAll('video[autoplay]')].filter((v) => !(v.muted || v.defaultMuted));return loud.length? {outcome: 'fail',occurrences: loud.map((v) =>ctx.helpers.reportOccurrence(v, { summary: 'This video plays with sound as the page opens.', hint: 'Remove autoplay, or add muted.' }))}: { outcome: 'pass', occurrences: [] };}}],profiles: {'acme-web-2026': { tags: wcagTags('2.2', ['A', 'AA']), rules: ['region', 'acme-no-autoplay'] }},rollups: [{ id: 'acme-images', title: 'Images have a text alternative', checksIds: ['img-alt-present'] },{ id: 'acme-media', title: 'Media stays quiet until asked', checksIds: ['acme-no-autoplay'] }]});
Its profile, acme-web-2026, runs core's WCAG 2.2 A and AA rules, the best-practice rule region, which no WCAG tag selects, and the house rule. Its two rollups are the checklist's items: each groups rules, core's or the pack's, into one result.
For a standard, the EN 301 549 pack is a complete, published example.
Create one with surea11y-pack new
For a pack of your own, start from a generated package:
$ npx -p @surea11y/core surea11y-pack new acme-a11y --name @acme/a11y-pack# or, for a standard:$ npx -p @surea11y/core surea11y-pack new city-standard --name @city/a11y-standard --kind standard --title "City Standard"$ cd acme-a11y$ npm install$ npx playwright install chromium # once, for npm run docs$ npm test && npm run lint && npm run docs
The pack works as generated: its tests pass, its rules lint clean, its examples agree with its rules. surea11y-pack new checks the pack as a scan would before writing anything, and refuses a name, namespace or title that would make it invalid (a title of WCAG names its standard as one of core's), saying why. Change it step by step from there, running npm test as you go. Its README walks through each file:
| File | What it holds |
|---|---|
index.js | The pack, written with definePack() from @surea11y/core/pack. |
rules/automatic/<ns>-link-text-specific.js | A rule that decides, using core's helpers. |
rules/automatic/<ns>-contrast-enhanced.js | A variant of core's contrast-minimum at 7:1. |
rules/manual/<ns>-new-window-review.js | A manual rule that calls for human review, with its reason codes and the steps a person follows to answer it (meta.reasonCodes, meta.review). |
i18n/en.json | The messages. |
tests/rules/*.test.js, tests/profiles.test.js | A test per rule, and what each profile runs. |
docs/RULE_EXAMPLES.md | Examples of each rule's outcomes. |
eslint.config.js | Core's lint rules for rules. |
requirements.js, rule-map.js | Standard only: the requirements and the rules that check each. |
package.json | private, with @surea11y/core as a peer dependency at the version that wrote it (^1.14.1), and eslint, jsdom and Playwright as development dependencies. |
README.md, .gitignore | The pack's own guide to these files, and what git leaves out. |
Names
- The package name and version are an npm package name and a whole version (
1.0.0). A scan reports them inengine.packs. - The namespace (
namespace: 'acme') keeps the pack's ids apart from core's and other packs': every rule, rollup and profile id starts with it and-. It can't be a prefix of core's rule ids (img,ariaorlinkwould be, as inimg-alt-present), nor start withwcag, and two packs in one scan can't share one. It is also the tag that makes the pack's own rules opt-in: a rule tagged with it runs only under one of the pack's profiles, by its id, or by the tag (optInRules: ['acme']). By default, then, a scan that targets WCAG doesn't run it. Unlocking the tag withoptInRulesdoesn't remove the scan's profile or filters: under a WCAG profile, an unlocked rule runs only when its tags also match that profile. Leave the tag off a rule that should run in every scan with the pack: it is then selected as core's rules are, and a scan that targets WCAG can run it and report its failures when its tags match. coreis the range of core versions the pack works with ('^1.11.1'), read as npm reads ranges. Start new packs at 1.11.1: the 1.11.0 package left out a file packs need in Node. A scan with a core outside it skips the pack, with the reason inskippedPacks.surea11y-pack newwrites the version that generated the pack (^1.14.1); keep a range that starts at 1.14 if the pack uses what 1.14 added:meta.review,reasonCodesandfindingsrows,meta.contributions,applicability,partialorkeepsParsing.titleis what results and reports call a checklist, anddescriptiona sentence on what the pack checks, for tools that let their users choose packs (describePacksreturns it).
Profiles
A profile is a named selection, run with engineOptions.profile. A rule runs under it when it carries one of its tags or its id is in rules, and is not in exclude; the pack's rules tagged with its namespace run under every profile of the pack.
profiles: {'acme-policy': {tags: wcagTags('2.2', ['A', 'AA']), // core's WCAG 2.2 A and AA rulesrules: ['region', 'heading-order'], // and these, by idexclude: { rules: ['contrast-minimum'] }, // but not this oneseverity: { 'img-alt-present': 'critical' } // and this one is critical here},'acme-quick': {tags: [],rules: ['img-alt-present', 'link-name-present', 'page-title-present']}}
| Field | What it does |
|---|---|
tags | Rules carrying any of these tags. wcagTags(version, levels) from @surea11y/core/pack gives core's WCAG tags (wcagTags('2.1') is 2.0 and 2.1, A and AA). |
rules | Rules by id, core's or the pack's: a best-practice rule no WCAG tag selects, or, with tags: [], the core rules the profile runs besides the pack's own. |
exclude | { rules: [...] } leaves rules out of the scan; { criteria: ['2.5.8'] } waives those criteria: their rollups go, and so does each rule whose every criterion is waived; a rule that also checks a criterion the profile keeps still runs. This narrows what the scan checks: WCAG's requirements stay as they are. |
severity | { ruleId: level }: another severity under the profile. Results show it and keep the rule's own as ruleSeverity; baselines and finding ids don't change. |
wcagVersion | '2.0', '2.1' or '2.2': the WCAG version the profile's scans are judged by (engine.wcagVersion). Without it, the WCAG tags in tags imply the version, and a profile with none is judged by 2.2. A scan's own engineOptions.wcagVersion still wins. Core 1.12.0 and later. |
keepsParsing | true when the standard requires 4.1.1 Parsing as written, as EN 301 549 V3.2.1 does: a failure of it then stays a failure under a 2.0 or 2.1 target. Under a profile without it, the 2023 errata to WCAG 2.0 and 2.1 count 4.1.1 as satisfied for HTML and XML, and duplicate-id asks instead (under 2.2, which removed 4.1.1, it asks whatever the profile says). Only the profile's own field counts: a pack's profile that takes the place of one of core's doesn't inherit it. engineOptions.mappings never changes an outcome, so a scan judged by a standard that keeps 4.1.1 runs its profile. Core 1.14.0 and later. |
version, mappedRules | A standard's profile only: the standard's version it targets, and mappedRules: true to run every rule the standard maps for that version. |
A profile that runs only what its standard asks for. A standard's profile with tags: [<ns>] and mappedRules: true runs the pack's own rules and the core rules its map links to a requirement, and nothing else: no rule checks what the standard doesn't, and results hold no WCAG criteria. With no WCAG tag, though, nothing says which WCAG version the standard is built on, and the run is judged by 2.2. For a standard built on 2.1 that is wrong: the run reports 2.2's criteria, such as 2.5.8, which the standard doesn't have. Give the version, and keepsParsing: true if the standard still requires 4.1.1 Parsing. The generated index.js reads it from requirements.js, where each version of the standard names the WCAG version it is built on ({ version: '1.0', wcagVersion: '2.2' }):
'city-1.0': {version: '1.0',tags: ['city'], // the pack's own rulesmappedRules: true, // and the core rules its map links to a requirementwcagVersion: wcagVersionOf('1.0') // judged by the WCAG version requirements.js gives for 1.0}
Core's rules, with their ids and tags, are in the rule catalog.
Checklist items and requirements
A checklist's items are its rollups: { id, title, checksIds }, each grouping rules into one result. An item fails when one of its rules fails, and passes when they pass. When none fails but one returns cantTell, the item is cantTell too: it needs human review. A pack gives either profiles and rollups, a checklist, or a standard, not both.
A standard's requirements come from two tables the generator writes, requirements.js (per version, each requirement's number, wording and WCAG criteria) and rule-map.js (which requirements each rule checks, and why). ruleMappedStandard() from @surea11y/core/pack turns them into the standard: a rollup per requirement, the requirements each rule's result names, and checks of both tables when the pack is prepared.
Sending findings to requirements
A rule's row in rule-map.js can send its findings of one reason code elsewhere, with reasonCodes: { [code]: { requirements, outcome } }: requirements (by default the row's) are the ones those findings count toward, [] for none, and outcome: 'cantTell' makes a failing one a question there. A requirement's rollup then judges the rule by the findings that count toward it, so a standard can ask about one cause where WCAG fails it, or file two causes under two tests:
'list-children-valid': {requirements: ['9.3.1'],reasonCodes: { LIST_ITEM_ROLE_OVERRIDDEN: { requirements: ['9.3.1', '9.3.2'], outcome: 'cantTell' } }}
A row can also send findings by what they record, with findings: [{ when, requirements, outcome }]: when gives values that fields of a finding's data.details must equal, named by dotted path. A finding goes by the first entry it matches, else by its reason code, else by the row. So a standard can file one rule's failures under the test for their text size, though they share a reason code:
'contrast-minimum': {requirements: ['3.1', '3.2'],findings: [{ when: { 'typography.isLargeText': false }, requirements: ['3.1'] },{ when: { 'typography.isLargeText': true }, requirements: ['3.2'] }]}
Match only fields a rule keeps stable: reasonCode on every rule, and the few others core's API stability policy on GitHub names (contrast-minimum's ratio, threshold and typography, among them). A field that changes or disappears matches nothing, and the finding goes by its code or the row. Each rule's reason codes are listed under "What a finding reports" in core's rule catalog on GitHub.
Two more fields change how a requirement is judged. A standard whose requirements restate WCAG criteria one for one, as EN 301 549's chapter 9 does, passes applicability: 'wcag' to ruleMappedStandard: a requirement's rollup is then notApplicable or pass only where the WCAG rollup of its criteria would be, so the two agree in one report. A requirement that core's rules don't check in full, though it doesn't restate a criterion, says partial: true in the requirements table: its rollup is then cantTell where its rules all pass or all find nothing, since they checked only part of it. A standard that writes its own rollups says both in each rollup's meta, and routes findings in meta.contributions; StandardRollup in pack.d.ts gives the shape, and core's pack guide on GitHub walks through it. All of these need core 1.14.0.
Rules
A rule has the shape of core's rule modules: { id, meta, runInPage }, and applicability when it applies only sometimes, with the same ctx. runInPage(ctx) runs in the page, so it uses only ctx, never a variable or module from outside it, and it only reads: a pack's rules run after core's, and the helpers and options they are given are read-only. Read-only is a rule, not a sandbox: nothing stops a rule's code from doing more, so load only packs you trust (see core's security policy on GitHub). Three shortcuts need little or no code: a variant runs a core rule at other settings ({ id, from: 'contrast-minimum', config: { normalTextRatio: 7 }, meta }; a threshold takes a finite number above 0, a choice one of its listed values, and definePack refuses anything else), an override replaces a core rule (listed in overrides, and in results' overriddenBuiltinIds), and a core rule as it is needs only its id in a profile or a rollup. Writing a rule is in core's rule-authoring guide on GitHub.
Core's rule settings list on GitHub gives every setting core's rules offer a variant, with its default, the values it takes and what it changes; getChecksCatalog() gives the same as each rule's variantSettings. That is how another standard's version of a requirement, or a choice WCAG leaves to the evaluator, runs on core's code. Since core 1.14, contrast-minimum offers choices for text that looks like a logo (logoText), a page with a higher-contrast switch (contrastSwitch) and semi-bold text (semiBold), and valid-lang offers codes: 'iso639' for a standard, such as RGAA, that accepts any ISO 639 language code.
How a person checks a cantTell
A rule that can leave a finding undecided may say, per reason code, how a person answers it, as core's rules do (see "How to check" in the output schema): a few steps starting from the element the finding names, then what they see if it passes and if it fails.
meta.review = {GENERIC_LINK_NAME: {steps: ['Find the link the report names.', 'Read the text around it.'],passWhen: 'The text around the link says where it goes.',failWhen: 'Nothing near the link says where it goes, which fails WCAG 2.4.4.'}};
definePack checks the shape: 1 to 6 steps and a non-empty passWhen and failWhen per reason code. A rule that declares its reason codes in meta.reasonCodes (['GENERIC_LINK_NAME']) gives steps only for a code it declares there or names in its runInPage, so a mistyped code is refused: declaring them is worth it for that check. The steps are English, whatever the scan's locale, and stay out of the page: runDomRulesInPage with engineOptions.packs adds them to the result, and for a result from a page addHowToCheck(result, { packs }) does. A variant has its core rule's steps, translated, so it needs none; an override, like a custom rule, has none. Core 1.14.0 and later.
Messages and languages
Each text a rule shows has a key, held per locale in i18n/<locale>.json and listed in the pack's dictionaries. A scan with engineOptions.locale: 'fr' shows the French ones; a locale the pack has no dictionary for shows English. A pack's keys are its own, starting with its namespace: a pack can't change a core message in a locale core ships.
Test, lint and document it
- Tests use
@surea11y/core/testing, what core's own rule tests use:runa11yCoreOnHtml(html, { engineOptions })scans HTML in jsdom, andassertRule(result, ruleId, outcome)checks a rule. With packs, the scan runs them both from the pack objects and as a page has them, so a rule that reads a variable from outside its function fails its test. It needs jsdom as a dev dependency. - Checks:
definePackthrows, naming each problem, so the pack's own tests fail;checkPack(pack)returns the same list without throwing, with the installed core's version checked too. Both run a standard's own checks of its tables (validate) against core's rules and the pack's, as a scan does, so a version without itswcagVersion, or a finding sent to a requirement the table lacks, is refused there rather than making every scan skip the pack. Core 1.14.0 and later. - Lint (
npm run lint,@surea11y/core/eslint-plugin) holds the pack's rules to the lint rules core's are held to. - Docs (
npm run docs,surea11y-pack docs) write the pack'sdocs/RULE_CATALOG.mdand run every example of itsdocs/RULE_EXAMPLES.mdin Chromium (CHROMIUM_EXECUTABLE_PATHnames another Chromium build);--checkfails when either is stale, for the pack's CI.
Publish it
The generated package is private. Choose its licence, set license and remove private in package.json, and npm publish. Keep @surea11y/core a peer dependency, with the range in core. Semver covers what a pack builds on: a pack written for core 1.11 keeps working with later 1.x versions. For your own users, a rule's id and its reason codes are what their baselines hold: rename or remove one only in a major version of the pack.
The full guide, from an empty folder to a published pack, is core's PACKS.md on GitHub, and every field is in the packs reference of ENGINE_OPTIONS.md.