Skip to content

[v1.0.0] feat(a11y): add wp-tooling a11y runner and accessibility skill - #35

Open
Adi-ty wants to merge 2 commits into
release/v1.0.0from
v1.0.0/task/a11y-skill
Open

[v1.0.0] feat(a11y): add wp-tooling a11y runner and accessibility skill#35
Adi-ty wants to merge 2 commits into
release/v1.0.0from
v1.0.0/task/a11y-skill

Conversation

@Adi-ty

@Adi-ty Adi-ty commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Adds a wp-tooling a11y command that runs the consumer-installed pa11y-ci against the project's pa11y config and emits a normalized report an agent can act on. Ships a companion accessibility Claude Code skill that triages the violations, maps each one to the source that rendered it via the report's domHints, fixes it with consent, and re-verifies.

Closes

Closes #34

Changes

  • New src/a11y/ library (runA11y() core + runCli() adapter) exposed as @rtcamp/wp-tooling/a11y, with a src/cli/commands/a11y.js dispatcher shim.
  • The runner is config-driven only: URLs come from .pa11yci.json or --config <path>. Exit codes are 0 clean, 1 run failure or unreachable URL, 2 usage or binary missing, 3 violations found.
  • URL load failures are reported as scanError and counted in summary.failedUrls, never as violations.
  • New skills/accessibility/ skill (SKILL.md + evals) on the find → fix → re-check lens flow; setup/claude-skills now distributes it alongside scaffold and setup.
  • setup/pa11y scaffold fixed and improved: pa11y-ci pin corrected to the published ^4.1.1, template runs both engines (axe + htmlcs), and scans project-owned URLs (front page plus configurable sample_page / search_page / extra_page paths) instead of wp-admin / wp-login.
  • Jest coverage for the runner (cli, normalize, resolve-bin, urls) and the rendered pa11y config; CHANGELOG.md and .claude/issues/34-a11y-skill.md updated.
  • src/init/index.js and tests/ui/selects.test.js carry small lint fixes: HEAD fails the npm run lint gate without them.

How I verified

    edge cases
      ✓ skips entry with missing slug and warns (1 ms)
      ✓ skips preset section when array is empty
      ✓ skips preset section when key is absent
      ✓ returns a valid @theme {} block for empty themeJson
  GenerateTailwindThemePlugin.generate()
    ✓ writes _tailwind-theme.css containing the @theme block (13 ms)
    ✓ scaffolds tailwind.css with layer and theme imports when absent (2 ms)
    ✓ does not overwrite tailwind.css when it already exists (2 ms)
    ✓ updates _tailwind-theme.css when theme.json changes (3 ms)
    ✓ does not rewrite _tailwind-theme.css when content is unchanged (2 ms)
    ✓ logs an error and writes nothing when theme.json is missing
    ✓ logs an error when theme.json contains invalid JSON (1 ms)

Test Suites: 1 passed, 1 total
Tests:       20 passed, 20 total
Snapshots:   0 total
Time:        0.107 s, estimated 1 s
Ran all test suites.

> @rtcamp/wp-tooling@0.1.0 check
> npm run lint && npm test


> @rtcamp/wp-tooling@0.1.0 lint
> eslint src tests


> @rtcamp/wp-tooling@0.1.0 test
> jest

 PASS  tests/scaffolds/features.test.js
  feature block validation
    ✓ rejects a feature block missing config_key (13 ms)
  status()
    ✓ lists features as disabled when no .wp-tooling.json exists (3 ms)
    ✓ reflects an enabled flag from .wp-tooling.json (3 ms)
  enable()
    ✓ creates files, sets the flag, and adds gitignore lines (5 ms)
    ✓ is idempotent on re-enable (existing files skipped, flag stays true) (5 ms)
    ✓ renders {{placeholder}} gitignore lines against resolved inputs (3 ms)
    ✓ throws ENOTFEATURE for a non-feature scaffold (1 ms)
  disable()
    ✓ removes owned files, clears the flag, and removes gitignore lines (5 ms)
    ✓ keeps a confirm_remove file when confirmRemove is absent (7 ms)
    ✓ removes a confirm_remove file when confirmRemove returns true (5 ms)
    ✓ reports missing files and is a safe no-op when already disabled (2 ms)
  enable()/disable() mirror via persisted feature files
    ✓ persists rendered file lists on enable (5 ms)
    ✓ disable removes the enable-time paths without re-supplying inputs (5 ms)
  recordDeps()
    ✓ records missing deps sorted, preserving tab indentation
    ✓ never overwrites an existing version range (1 ms)
    ✓ is a no-op without a package.json
    ✓ applyChange with install:false, record:true records deps instead of installing (4 ms)
    ✓ applyChange with plain install:false reports deps without touching package.json (6 ms)
  parseArgs()
    ✓ wires the shared value guard for --enable (5 ms)
    ✓ accepts --flag=value and repeated flags
    ✓ parses --record-deps (off by default)
  applyChange disable summary
    ✓ includes the documented `missing` list (4 ms)

 PASS  tests/scaffolds/add.test.js
  add command argument parsing
    ✓ exits 0 on --help without doing work (2 ms)
    ✓ exits 1 when scaffold id is missing
    ✓ still accepts --flag=value for values starting with --
    ✓ exits 1 (not interactive hang) on a parse error (1 ms)
  formatErrorPayload
    ✓ keeps EBADSCAFFOLD file + errors fields (documented payload)
  add command non-interactive flow
    ✓ writes files in non-interactive mode (43 ms)
    ✓ emits a single JSON line on --json (15 ms)
    ✓ --dry-run does not write files (19 ms)
    ✓ emits structured error JSON on --json for missing input (30 ms)
    ✓ emits ENOSCAFFOLD JSON for unknown id (33 ms)
  engine core is not coupled to TTY UI
    ✓ non-interactive code path does not require the TTY UI kit (2 ms)

 PASS  tests/scaffolds/validate.test.js
  validate base scaffold
    ✓ accepts a minimal valid scaffold (5 ms)
    ✓ rejects non-objects
    ✓ reports missing required fields
    ✓ rejects unknown top-level fields (1 ms)
    ✓ rejects malformed slug
    ✓ accepts optional category in kebab-case
    ✓ accepts nested category with slashes (e.g. lint/phpcs)
    ✓ rejects unknown source value
    ✓ rejects unknown wizard_step value (1 ms)
  validate files block
    ✓ rejects non-array files
    ✓ rejects entry missing src (1 ms)
    ✓ accepts empty files array (source: package case)
  validate inputs block
    ✓ accepts a full inputs entry (1 ms)
    ✓ rejects uppercase input key
    ✓ rejects duplicate input keys
    ✓ rejects invalid transform value
  validate wiring block
    ✓ accepts a wiring entry
    ✓ accepts a wiring entry with no anchor (anchor is advisory per the AI contract)
    ✓ rejects a wiring entry whose anchor is an empty string
    ✓ rejects a wiring entry missing snippet_template
  validate tests block
    ✓ accepts a tests entry
    ✓ rejects unknown framework
    ✓ accepts actionlint for YAML
  validate secrets block
    ✓ accepts a secrets entry
    ✓ rejects lowercase secret key
    ✓ rejects unknown scope
    ✓ rejects extra fields (no value smuggling) (1 ms)
  validate scripts block
    ✓ accepts npm-only scripts (1 ms)
    ✓ accepts composer-only scripts
    ✓ accepts both targets
    ✓ rejects unknown script target (1 ms)
    ✓ rejects non-string command
    ✓ rejects non-object map per target
  validate dependency maps
    ✓ accepts a composer dependency map (1 ms)
    ✓ rejects non-string version
  validate rejects the removed repository field
    ✓ treats 'repository' as an unknown top-level field
    ✓ rejects source: 'repository' (no longer an allowed source)
  validateOne on-disk checks
    ✓ source: template still reports missing files (1 ms)
  validate runCli
    ✓ exits 0 on --help and prints usage
    ✓ validates the whole bundled catalogue with exit 0 (19 ms)
    ✓ filters by single scaffold id (6 ms)
    ✓ exits 1 on unknown scaffold id with available list (5 ms)
    ✓ --json emits machine-readable results (5 ms)
    ✓ --json emits structured error for unknown id on stderr (6 ms)
    ✓ includes project-local scaffolds when --cwd is set (7 ms)
    ✓ rejects unknown flags
    ✓ validates a valid project sources file (offline, no fetch) (9 ms)
    ✓ fails when a source has a bad repository slug (7 ms)
  validate --remote (sources + index)
    ✓ default validate (no --remote, no cache) makes no network call (13 ms)
    ✓ recognises a remote id offline from a cached index (28 ms)
    ✓ --remote fetches the index + schema-validates a sound manifest (ok) (12 ms)
    ✓ --remote fails a manifest whose checksum mismatches the index (13 ms)
    ✓ --remote surfaces EFETCHFAIL when the manifest 404s (9 ms)
    ✓ --remote reports schema errors from a fetched-but-invalid manifest (11 ms)
    ✓ --remote flags a remote id colliding with a local scaffold (10 ms)

 PASS  tests/scaffolds/list.test.js
  list command help / parsing
    ✓ --help prints usage and exits 0 (3 ms)
    ✓ rejects --origin with invalid value
  list command, human output (default catalogue only)
    ✓ lists all bundled scaffolds with categories (38 ms)
    ✓ groups scaffolds by category in the human output (19 ms)
  list command, --json output
    ✓ emits a single JSON line on stdout (16 ms)
    ✓ JSON entries carry the expected shape and counts (29 ms)
    ✓ reports secrets for the WPORG workflow scaffold (20 ms)
  list command, two-directory merge
    ✓ shows project-local scaffolds alongside defaults (30 ms)
    ✓ --origin=project shows ONLY project scaffolds (30 ms)
    ✓ --category filter narrows the listing (32 ms)
    ✓ empty filter returns an empty scaffolds array (not an error) (27 ms)

 PASS  tests/cli/index.test.js
  cli main()
    ✓ no args prints top-level usage and exits 0 (1 ms)
    ✓ --help prints top-level usage
    ✓ -h prints top-level usage
    ✓ --version prints package version and exits 0
    ✓ -v prints package version
    ✓ unknown top-level flag exits 2 with stderr message (1 ms)
    ✓ unknown subcommand exits 2 with stderr message
    ✓ routes detect-changes --help to its runCli (2 ms)
    ✓ routes detect-changes through to its runCli with args (2 ms)
    ✓ detect-changes propagates a usage-error exit code
  cli main() central error handling
    ✓ CancelledError from a subcommand exits 130 with a stderr message (1 ms)
    ✓ non-CancelledError rejections propagate to the bin shim (5 ms)
  cli COMMANDS registry
    ✓ detect-changes is registered with a summary and run handler
    ✓ install-hooks is registered with a summary and run handler
    ✓ scaffold subcommand "add" is registered with a summary and run handler
    ✓ scaffold subcommand "list" is registered with a summary and run handler
    ✓ scaffold subcommand "validate" is registered with a summary and run handler (1 ms)
  cli main() routes scaffold subcommands
    ✓ routes add --help to src/scaffolds/add runCli (2 ms)
    ✓ routes list --help to src/scaffolds/list runCli (1 ms)
    ✓ routes validate --help to src/scaffolds/validate runCli (1 ms)
  cli main() routes install-hooks
    ✓ routes install-hooks --help to its runCli (1 ms)
  cli loadCommands()
    ✓ discovers a valid command module and indexes it by name (52 ms)
    ✓ ignores non-.js files in the commands directory (8 ms)
    ✓ throws a clear error when a module is missing required fields (3 ms)
    ✓ throws when two modules register the same name (5 ms)
    ✓ returns entries in deterministic (sorted) order (8 ms)

 PASS  tests/init/git.test.js
  installGitHooks
    ✓ installs both native hooks and uses no Husky (47 ms)
    ✓ preserves an existing non-Husky prepare by chaining (49 ms)
    ✓ replaces a Husky prepare and is idempotent (73 ms)
    ✓ skips cleanly when the directory is not a git repo

 PASS  tests/scaffolds/registry.test.js
  ScaffoldRegistry construction
    ✓ rejects empty args (17 ms)
    ✓ accepts a single string (back-compat for project-only) (4 ms)
    ✓ accepts object form (1 ms)
  scan()
    ✓ discovers scaffolds and tags source (3 ms)
    ✓ returns empty when no scaffolds found (1 ms)
    ✓ throws EBADSCAFFOLD on malformed JSON (1 ms)
    ✓ throws EBADSCAFFOLD on schema-invalid scaffold (1 ms)
  two-directory merge
    ✓ project entries override default entries on category/slug collision (4 ms)
    ✓ non-colliding default and project entries both appear (12 ms)
  get() and filter()
    ✓ finds scaffold by category/slug id (1 ms)
    ✓ finds scaffold by slug alone (no category)
    ✓ returns null for unknown id
    ✓ filter by predicate
  collectDependencies()
    ✓ merges composer/npm deps across selected scaffolds (3 ms)
  execute() result shape
    ✓ returns the four-block shape (2 ms)
    ✓ dry-run writes no files (2 ms)
    ✓ full run writes files (2 ms)
    ✓ skips existing files (no overwrite) (1 ms)
    ✓ treats a test entry sharing a file dest as declarative, on both clean run and idempotent re-run (D2) (3 ms)
    ✓ warns on supplied inputs the scaffold does not declare (1 ms)
    ✓ does not warn when every supplied input is declared
  execute() error paths
    ✓ throws ENOSCAFFOLD with available list
    ✓ throws EMISSINGINPUT with missingDetails when required input is absent (1 ms)
  execute() passes scripts through to developer block
    ✓ passes npm and composer scripts verbatim (2 ms)
    ✓ emits empty script maps when scaffold declares none (1 ms)
    ✓ keeps npm runtime vs dev vs composer-suggest in distinct fields (no merging) (2 ms)
  execute() never embeds secret values
    ✓ passes through declared secrets without any value field (2 ms)
  remote scaffolds (sources + index)
    ✓ scan fetches the index and surfaces a thin remote record (10 ms)
    ✓ remote id colliding with a local scaffold throws EBADSCAFFOLD (4 ms)
    ✓ same id offered by two sources throws EBADSCAFFOLD (2 ms)
    ✓ unreachable index is skipped with a warning, not a hard failure (3 ms)
    ✓ malformed index is skipped with a warning; local scaffolds survive (4 ms)
    ✓ schema-invalid index is skipped with a warning, not a hard failure (3 ms)
    ✓ execute hydrates the manifest then fetches + writes the template (6 ms)
    ✓ renders the fetched manifest inputs into the template (9 ms)
    ✓ ETag cache serves the body on 304 across fresh registries (8 ms)
    ✓ malformed remote manifest throws EBADSCAFFOLD (6 ms)
    ✓ schema-invalid remote manifest throws EBADSCAFFOLD (6 ms)
    ✓ manifest 404 throws EFETCHFAIL (3 ms)
    ✓ index checksum is verified against the fetched manifest (10 ms)
    ✓ checksum mismatch throws EBADSCAFFOLD before any write (10 ms)
    ✓ checksum matches case-insensitively (uppercase hex + SHA256: prefix) (11 ms)
    ✓ remote manifest dest escaping --cwd throws EWRITEFAIL (6 ms)
    ✓ dry-run fetches the manifest but not the template, writes nothing (8 ms)
    ✓ parallel template prefetch: 3 templates fetched concurrently (106 ms)
    ✓ re-run on an existing dest performs no further fetch (offline-safe) (21 ms)

 PASS  tests/version-monitor/npm.test.js
  npm detector
    ✓ reports an update when a newer version is published (3 ms)
    ✓ reports nothing when current equals latest
    ✓ skips floating pins like "*"
    ✓ skips pre-release publishes

 PASS  tests/scaffolds/config.test.js
  detectIndent()
    ✓ detects tab indentation (2 ms)
    ✓ detects two-space indentation
    ✓ returns null for single-line / empty input (1 ms)
  writeConfig() indentation
    ✓ preserves tabs when the file was written with tabs (1 ms)
    ✓ defaults to two spaces for a new file (1 ms)
  feature-file persistence
    ✓ setFeatureState sets the flag and round-trips file lists in one write
    ✓ setFeatureState preserves an existing file indentation (1 ms)
    ✓ returns null when nothing was persisted
    ✓ clearFeatureFiles removes the entry and an empty block

 PASS  tests/scaffolds/cli-support.test.js
  requireFlagValue
    ✓ returns a normal value
    ✓ throws when the value is missing (5 ms)
    ✓ throws when the next token is itself a flag
    ✓ points the user at the =value form for dash-leading values
  fetchOptsFrom
    ✓ maps only the set fetch options
  paths + buildRegistry
    ✓ resolves the bundled defaults dir and a project bin/scaffolds dir
    ✓ scans the bundled catalogue (71 ms)

 PASS  tests/ui/terminal.test.js
  ANSI
    ✓ should expose the expected escape sequences (2 ms)
  isTTY
    ✓ should return true when stdout is a TTY (1 ms)
    ✓ should return false when stdout is not a TTY
  write / writeLine
    ✓ should write text without appending a newline
    ✓ should append a newline when writeLine is called (1 ms)
    ✓ should default writeLine to an empty line
  clearLine
    ✓ should write clear-line + carriage return when stdout is a TTY
    ✓ should write nothing when stdout is not a TTY (1 ms)
  moveCursorUp
    ✓ should emit the move-up escape when n > 0 in a TTY
    ✓ should not write anything when n is 0
    ✓ should not write anything in non-TTY (1 ms)
  hideCursor / showCursor
    ✓ should write the hide/show sequences in a TTY
    ✓ should write nothing in non-TTY
  readLine -- TTY mode
    ✓ should resolve with the typed answer
    ✓ should reject with CancelledError on SIGINT (1 ms)
    ✓ should resolve empty when closed without an answer or cancellation
  readLine -- non-TTY mode
    ✓ should buffer lines from stdin and resolve in order (2 ms)
    ✓ should reject queued waiters as cancelled when stdin closes (1 ms)
    ✓ should reject immediately as cancelled after stdin has closed (2 ms)
    ✓ should buffer lines that arrive before a reader is waiting
  onKeypress
    ✓ should return a no-op cleanup in non-TTY (1 ms)
    ✓ should wire up keypress events on stdin in a TTY and clean them up (2 ms)
    ✓ should skip setRawMode when stdin does not support it

 PASS  tests/release/cli.test.js
  release CLIs - dispatcher registration
    ✓ release:bump is registered
    ✓ release:changelog is registered
    ✓ release:zip is registered
  release CLIs - parseArgs
    ✓ release:bump parses --type, --to, --dry-run, --help
    ✓ release:bump rejects unknown args (4 ms)
    ✓ release:changelog parses --to, --dry-run, --help
    ✓ release:zip parses --force, --dry-run, --help (1 ms)
  release CLIs - runCli help
    ✓ release:bump --help prints usage and returns 0
    ✓ release:changelog --help prints usage and returns 0
    ✓ release:zip --help prints usage and returns 0
    ✓ release:bump unknown arg returns 2
  release CLIs - runCli end-to-end against fixture
    ✓ release:bump --type patch updates files and returns 0 (7 ms)
    ✓ release:bump --dry-run does not modify files (4 ms)
    ✓ release:bump exits 1 when plugin entry missing (5 ms)
    ✓ release:changelog rewrites CHANGELOG and returns 0 (7 ms)
    ✓ release:changelog exits 1 on empty Unreleased (5 ms)
    ✓ release:zip writes dist/<slug>-<version>.zip and returns 0 (29 ms)
    ✓ release:zip refuses to overwrite without --force (78 ms)

 PASS  tests/release/bump.test.js
  release/bump - pure helpers
    ✓ nextVersion patch
    ✓ nextVersion minor zeros patch
    ✓ nextVersion major zeros minor + patch
    ✓ nextVersion explicit override wins
    ✓ nextVersion rejects malformed current (6 ms)
    ✓ nextVersion rejects malformed explicit
    ✓ nextVersion rejects unknown type
    ✓ slugToConstantPrefix converts kebab to UPPER_SNAKE
    ✓ rewritePluginEntry rewrites Version header and constant value
    ✓ rewritePluginEntry also rewrites const-style constants
    ✓ rewriteJsonVersion preserves a 2-space indent
    ✓ rewriteJsonVersion returns null when version key absent
  release/bump - integration against fixture
    ✓ patch bump updates package.json, composer.json, plugin entry, constant (6 ms)
    ✓ --to overrides --type (6 ms)
    ✓ dry-run does not write any file (8 ms)
    ✓ composer.json without version is left untouched (7 ms)
    ✓ throws when plugin entry has no Version header (12 ms)
    ✓ exits non-zero (via thrown error) when plugin entry is missing (5 ms)
    ✓ rejects a malformed config.constantPrefix (9 ms)
    ✓ config.constantPrefix overrides the slug-derived constant name (7 ms)

 PASS  tests/scaffolds/prompt-inputs.test.js
  promptMissingInputs
    ✓ passes `defaultValue` (not `default`) to text() (1 ms)
    ✓ omits defaultValue when scaffold input has no default
  confirmRun
    ✓ passes `defaultValue: true` (not `default: true`) to confirm()

 PASS  tests/release/changelog.test.js
  release/changelog - pure helpers
    ✓ isoDate formats UTC date as YYYY-MM-DD (2 ms)
    ✓ unreleasedHasContent detects bullets
    ✓ unreleasedHasContent detects plain paragraph content
    ✓ unreleasedHasContent rejects empty Unreleased
    ✓ unreleasedHasContent rejects subheadings without bullets
    ✓ rewriteChangelog renames heading + prepends fresh Unreleased (1 ms)
    ✓ rewriteChangelog throws when Unreleased missing (3 ms)
    ✓ rewriteChangelog throws when Unreleased is empty
  release/changelog - integration against fixture
    ✓ reads version from package.json by default (6 ms)
    ✓ --to overrides package.json version (6 ms)
    ✓ dryRun leaves CHANGELOG unchanged (4 ms)
    ✓ refuses to run when Unreleased is empty (11 ms)
    ✓ throws when CHANGELOG.md is missing (9 ms)
    ✓ throws on malformed semver passed via --to (3 ms)

 PASS  tests/release/zip.test.js
  release/zip - helpers
    ✓ crc32 matches known vectors
    ✓ dosTimeDate encodes a known epoch (1 ms)
    ✓ dosTimeDate clamps year >= 1980
    ✓ compileIgnorePattern returns null for comments and blanks
    ✓ compileIgnorePattern matches a simple filename
    ✓ compileIgnorePattern with trailing / matches directories only (1 ms)
    ✓ compileIgnorePattern with single * does not cross /
    ✓ compileIgnorePattern with ** crosses /
    ✓ loadIgnorePatterns falls back to defaults when no .distignore (1 ms)
    ✓ resolveEpoch honours SOURCE_DATE_EPOCH env var
    ✓ resolveEpoch honours explicit option over env
    ✓ FALLBACK_EPOCH is exposed and stable
  release/zip - walkProject
    ✓ honours .distignore and skips .git / dist always (14 ms)
  release/zip - integration
    ✓ builds dist/<slug>-<version>.zip and excludes per .distignore (9 ms)
    ✓ two runs against the same tree produce byte-identical zips (6 ms)
    ✓ refuses to overwrite existing zip without --force (11 ms)
    ✓ overwrites existing zip when --force is set (7 ms)
    ✓ dryRun does not write dist/ (6 ms)
    ✓ default ignore list excludes node_modules when .distignore absent (4 ms)
    ✓ zipPack round-trips a single entry through DEFLATE (1 ms)

 PASS  tests/version-monitor/reporter.test.js
  report
    ✓ groups updates by source with a heading and a summary (2 ms)
    ✓ adds a Notes column only to groups containing a major bump
    ✓ uses singular wording for a single update
    ✓ reports no updates cleanly

 PASS  tests/scaffolds/features-install.test.js
  installDeps
    ✓ runs npm without a shell, passing each spec as a literal argv element (6 ms)
    ✓ raises EINSTALLFAIL with npm stderr attached on failure (8 ms)

 PASS  tests/release/context.test.js
  release/context
    ✓ findPluginEntry returns the *.php with Plugin Name header (3 ms)
    ✓ findPluginEntry throws when no entry file is present (16 ms)
    ✓ findPluginEntry throws when multiple entry files exist at root (6 ms)
    ✓ findPluginEntry ignores nested *.php with Plugin Name header (5 ms)
    ✓ loadContext returns the full project shape (3 ms)
    ✓ loadContext sets composerJson to null when composer.json is absent (3 ms)
    ✓ loadContext throws when package.json is missing (3 ms)
    ✓ loadContext throws when package.json has no version field (8 ms)
    ✓ loadContext propagates a malformed package.json error (3 ms)

 PASS  tests/a11y/normalize.test.js
  normalizeA11y
    ✓ tags the tool and default standard (1 ms)
    ✓ summary counts violations by impact across URLs
    ✓ results are sorted by URL
    ✓ a URL with no issues is reported as clean
    ✓ violations sort by impact, then id, then selector
    ✓ maps HTMLCS codes to a WCAG criterion and axe codes to null (5 ms)
    ✓ extracts DOM hints from the context for the grep-to-source step (1 ms)
    ✓ honours a custom standard label (1 ms)
    ✓ tolerates missing or malformed results
    ✓ a load failure becomes scanError, not a violation
  parseWcagCriterion
    ✓ pulls the dotted criterion from an HTMLCS code
    ✓ returns null when there is no criterion
  extractDomHints
    ✓ falls back to the selector tag when context has no opening tag
    ✓ captures aria attributes

 PASS  tests/ui/selects.test.js
  checkbox (non-TTY)
    ✓ should return selected items by number (1 ms)
    ✓ should return unique selections in display order
    ✓ should handle empty input gracefully
  radio (non-TTY)
    ✓ should return a single selected item
    ✓ should default to first choice on invalid input
  flat select validation
    ✓ should throw when checkbox choices is missing (8 ms)
    ✓ should throw when radio choices is empty
  flat select (TTY) -- Ctrl+C
    ✓ should reject with CancelledError on Ctrl+C (1 ms)
  checkboxTree (non-TTY)
    ✓ should return selected items from groups by number (1 ms)
    ✓ should return unique selections in display order
    ✓ should keep pre-checked items when input is empty (keep model)
    ✓ should toggle items off and on from the pre-checked state (1 ms)
    ✓ should handle empty selection
    ✓ should throw when groups is missing (2 ms)
    ✓ should throw when groups is null
    ✓ should throw when a group items is not an array
    ✓ should return an empty array for empty groups without prompting
  checkboxTree (TTY)
    ✓ should resolve selections in display order, not toggle order (1 ms)
    ✓ should reject with CancelledError on Ctrl+C

 PASS  tests/scaffolds/fetch.test.js
  defaultCacheDir
    ✓ honours XDG_CACHE_HOME when set (1 ms)
    ✓ falls back to ~/.cache when XDG_CACHE_HOME is empty
  composeUrl
    ✓ builds a raw-content URL
    ✓ normalises leading and trailing slashes on each segment
    ✓ rejects a '..' segment in the relative path (5 ms)
    ✓ rejects a '..' segment in the source path with EFETCHFAIL
  fetchRemoteFile
    ✓ cache miss fetches, writes body + ETag sidecar (3 ms)
    ✓ cache hit revalidates with If-None-Match; 304 serves cache (11 ms)
    ✓ 200 with a new body updates the cache (9 ms)
    ✓ --refresh re-fetches without a conditional header (7 ms)
    ✓ --refresh ignores a stray 304 and re-fetches a real body (7 ms)
    ✓ offline (transport error) with a cached copy serves cache + warns (14 ms)
    ✓ 404 with a cached copy still throws (does not serve stale) (9 ms)
    ✓ sends User-Agent and Authorization when token supplied (2 ms)
    ✓ falls back to WP_TOOLING_GITHUB_TOKEN env var (1 ms)
    ✓ non-2xx with no cache throws EFETCHFAIL with statusCode
    ✓ 403 + rate-limit body sets rateLimited (1 ms)
    ✓ transport error with no cache throws EFETCHFAIL with cause
    ✓ response-stream error (reset mid-body) rejects EFETCHFAIL (1 ms)
    ✓ timeout with no cache throws EFETCHFAIL (1 ms)
    ✓ cache write failure records a warning but returns body (2 ms)
  readCached
    ✓ returns null when nothing is cached, never hits the network
    ✓ returns the cached body when present (1 ms)

 PASS  tests/version-monitor/config.test.js
  parseConfigYaml
    ✓ parses inline-flow mappings and lists
    ✓ ignores comments and blank lines
  loadConfig
    ✓ loads and normalises the sample config (2 ms)
    ✓ throws naming the path when the config is missing (1 ms)
  validate
    ✓ defaults paths for an enabled source that omits them
    ✓ rejects an unknown source
    ✓ rejects a non-string paths entry
    ✓ rejects a missing sources mapping

 PASS  tests/scaffolds/discover-from.test.js
  discover_from: composer.json:autoload.psr-4
    ✓ replaces the first segment of each default, preserving its sub-namespace (10 ms)
    ✓ does NOT override a path input (base_path keeps its default) (3 ms)
    ✓ falls back to default when composer.json is absent (backward compatible) (5 ms)
    ✓ falls back to default when composer.json is malformed (10 ms)
    ✓ lets a supplied value win over discovery (5 ms)
  discover_from: config:<key>
    ✓ resolves a value from .wp-tooling.json (3 ms)
    ✓ falls back to default when .wp-tooling.json is absent (6 ms)
  discover_from: package.json:<dot.path>
    ✓ resolves a string value via a dotted path (3 ms)

 PASS  tests/version-monitor/updater.test.js
  applyUpdates
    ✓ rewrites each of the six source types in place (5 ms)
    ✓ skips major bumps unless allowMajor is set (6 ms)
    ✓ rewrites every occurrence of a duplicated scalar pin (5 ms)
    ✓ writes nothing on a dry run (4 ms)
    ✓ records an update whose current value is not found as unmatched (5 ms)

 PASS  tests/version-monitor/http.test.js
  getJson
    ✓ resolves parsed JSON on a 2xx response (2 ms)
    ✓ sends a User-Agent and a bearer token when given one (4 ms)
    ✓ rejects on a non-2xx status (2 ms)
    ✓ flags rate-limit responses
    ✓ rejects on invalid JSON
    ✓ rejects on a transport error
    ✓ rejects and tears down the request on timeout (1 ms)
  isClientError
    ✓ is true for a non-rate-limit 4xx
    ✓ is false for rate limits, 5xx, and transport errors

 PASS  tests/a11y/cli.test.js
  a11y runCli
    ✓ --help prints usage and exits 0 (1 ms)
    ✓ unknown flag exits 2
    ✓ invalid --output exits 2
    ✓ --config with no value exits 2
    ✓ an unreadable config exits 2 (ENOURLS) (1 ms)
    ✓ missing pa11y-ci exits 2 with the install hint (1 ms)
    ✓ the resolved config path is handed to pa11y-ci via --config
    ✓ violations found: exits 3 with a parseable JSON report (1 ms)
    ✓ clean run exits 0
    ✓ text mode prints a human summary (1 ms)
    ✓ unparseable output exits 1 (EBADJSON)
    ✓ a genuine run failure exits 1 (EBINFAIL) (1 ms)
    ✓ an unreachable URL is a run failure (exit 1), not a violation (2 ms)
    ✓ a scan failure is reported in text mode
    ✓ --dry-run prints the plan and runs pa11y-ci not at all

 PASS  tests/init/examples.test.js
  stripRegions
    ✓ drops only the marker lines when keeping the body (1 ms)
    ✓ drops the markers and the enclosed body when removing
    ✓ only touches regions tagged with the given marker
  expandGlob
    ✓ expands a single-segment * glob to matching absolute paths (3 ms)
    ✓ returns nothing when no file matches (1 ms)

 PASS  tests/scaffolds/traversal.test.js
  execute() refuses paths outside --cwd
    ✓ rejects a `..` in a path input (7 ms)
    ✓ rejects a `..` in a manifest file dest (2 ms)
    ✓ rejects a `..` in a manifest test dest (4 ms)
    ✓ still allows nested paths inside the target directory (7 ms)
  disable() refuses removals outside --cwd
    ✓ rejects a tampered featureFiles entry pointing outside the project (6 ms)

 PASS  tests/ci/detect-changes.test.js
  detectChanges
    ✓ counts files into the right buckets (1 ms)
    ✓ default ignore excludes docs and .wordpress-org
    ✓ default ignore preserves .github/workflows and .github/actions (1 ms)
    ✓ lockfile changes count under both css and js buckets
    ✓ phpstan.neon and phpstan.neon.dist count as php (1 ms)
    ✓ composer.json and composer.lock count as php
    ✓ string --ignore overrides the default
    ✓ RegExp --ignore is accepted directly (1 ms)
    ✓ null ignore disables filtering
    ✓ empty-string ignore disables filtering
    ✓ invalid ignore type throws TypeError (23 ms)
    ✓ accepts a newline-delimited string for files
    ✓ tolerates Windows line endings in file list
    ✓ invalid files type throws TypeError
    ✓ returns zero counts for an empty list
    ✓ gha bucket excludes nested-directory yml files outside workflows/actions
    ✓ includeFiles adds <bucket>-files arrays alongside counts (1 ms)
    ✓ includeFiles omitted leaves the result counts-only (8 ms)
    ✓ includeFiles preserves the same file in multiple buckets (5 ms)
  exports
    ✓ DEFAULT_PATTERNS has the four expected buckets (2 ms)
    ✓ DEFAULT_IGNORE matches docs/, .wordpress-org/, and .github/ non-workflow paths
  runCli
    ✓ --help prints usage and exits 0
    ✓ unknown flag exits 2 with stderr message (1 ms)
    ✓ invalid --output exits 2
    ✓ --files <path> with --output json prints valid JSON (1 ms)
    ✓ --output github appends key=value lines to $GITHUB_OUTPUT (1 ms)
    ✓ --output github warns to stderr when GITHUB_OUTPUT is unset
    ✓ --dry-run parses cleanly and exits 0 (1 ms)
    ✓ --dry-run + --output github does not touch $GITHUB_OUTPUT and previews to stdout (1 ms)
    ✓ --dry-run + --output github previews even when $GITHUB_OUTPUT is unset (1 ms)
    ✓ text mode prints key: value lines
    ✓ --ignore overrides default
    ✓ missing --files path exits 1 with stderr message
    ✓ --files followed by another flag exits 2 (does not swallow the flag) (1 ms)
    ✓ --files at end of argv exits 2
    ✓ --ignore followed by another flag exits 2
    ✓ --files - is accepted as the stdin sentinel (parser does not reject lone dash)
    ✓ --include-files in json mode emits <bucket>-files arrays (1 ms)
    ✓ --include-files in text mode prints space-joined paths
    ✓ --include-files in github mode writes heredoc multi-line outputs (1 ms)
    ✓ --include-files dry-run previews heredoc blocks without writing (1 ms)
    ✓ invalid --ignore regex exits 2 with a clean usage error

 PASS  tests/version-monitor/php.test.js
  php detector
    ✓ targets the latest stable at the current granularity (2 ms)
    ✓ reports nothing when already on the latest branch (1 ms)
    ✓ skips compound constraints
    ✓ ignores pre-release releases when picking the latest (1 ms)

 PASS  tests/ui/spinner.test.js
  spinner
    ✓ should return an object with start, succeed, fail, update methods (1 ms)
    ✓ should default to current text when succeed is called without args
    non-TTY mode
      ✓ should print plain text on start in non-TTY (1 ms)
      ✓ should print succeed message in non-TTY (4 ms)
      ✓ should print fail message in non-TTY
    TTY mode
      ✓ should animate frames on an interval (1 ms)
      ✓ should not create multiple intervals when start is called twice
      ✓ should stop animation on succeed
      ✓ should stop animation on fail
      ✓ should update text while running (1 ms)

version-monitor: actions lookup for "actions/checkout" failed: rate limited
 PASS  tests/version-monitor/actions.test.js
  actions.parseUses
    ✓ extracts version-tagged actions and skips non-tag refs
  actions detector
    ✓ reports an update when a newer release exists (1 ms)
    ✓ reports nothing when the pinned ref is already latest
    ✓ stops early on a rate-limit error (1 ms)

 PASS  tests/scaffolds/render.test.js
  render
    ✓ substitutes a single placeholder
    ✓ substitutes multiple placeholders
    ✓ substitutes the same placeholder repeatedly
    ✓ accepts whitespace inside braces (1 ms)
    ✓ throws ERENDERFAIL on undefined placeholder (5 ms)
    ✓ does NOT HTML-escape (critical for code generation)
    ✓ treats null vars argument as empty (1 ms)
    ✓ passes through templates with no placeholders
    ✓ throws on non-string template
  collectPlaceholders
    ✓ lists unique placeholders in first-appearance order
    ✓ returns empty array for templates with no placeholders
    ✓ returns empty for non-string input
    ✓ includes section flags and inverted sections
  render — sections
    ✓ keeps inner content when section flag is truthy ("true")
    ✓ drops inner content when section flag is falsy ("false")
    ✓ treats empty string, "no", "0" as falsy
    ✓ treats arbitrary non-empty value as truthy (1 ms)
    ✓ inverted section renders when flag is falsy
    ✓ substitutes variables inside a truthy section
    ✓ throws ERENDERFAIL when section flag is undefined (3 ms)
    ✓ handles multi-line section content (singleton use-case)
  applyTransform
    ✓ pascal-case from kebab
    ✓ pascal-case from snake
    ✓ kebab-case from PascalCase
    ✓ snake-case from kebab (1 ms)
    ✓ upper-snake-case from kebab
    ✓ json-escape doubles backslashes
    ✓ returns value unchanged when no transform
    ✓ returns value unchanged for unknown transform

 PASS  tests/skills/evals-json.test.js
  skills/accessibility/evals/evals.json
    ✓ file exists
    ✓ parses as JSON (1 ms)
    ✓ skill_name matches the directory name
    ✓ evals[] is a non-empty array
    ✓ each eval has id, prompt, expected_output, files, expectations (5 ms)
    ✓ eval ids are unique within the skill (1 ms)
  skills/scaffold/evals/evals.json
    ✓ file exists
    ✓ parses as JSON
    ✓ skill_name matches the directory name
    ✓ evals[] is a non-empty array
    ✓ each eval has id, prompt, expected_output, files, expectations (2 ms)
    ✓ eval ids are unique within the skill
  skills/setup/evals/evals.json
    ✓ file exists
    ✓ parses as JSON (1 ms)
    ✓ skill_name matches the directory name
    ✓ evals[] is a non-empty array
    ✓ each eval has id, prompt, expected_output, files, expectations (4 ms)
    ✓ eval ids are unique within the skill

 PASS  tests/scaffolds/bundled-manifests.test.js
  setup/psr4 wiring snippet
    ✓ renders a JSON-valid PSR-4 key for a multi-segment namespace (6 ms)
  wp/cli PSR-4 discovery (grafted sub-namespaces)
    ✓ fills namespace and tests_namespace from composer.json, keeping kind sub-namespaces (3 ms)
  wiring targetFile normalisation
    ✓ emits a `..`-free targetFile for wp/cli (4 ms)
  setup/pa11y rendered config
    ✓ renders valid JSON with default page paths (extra_page omitted) (12 ms)
    ✓ renders custom page paths and appends extra_page when given (6 ms)

 PASS  tests/init/transform.test.js
  applyReplacements
    ✓ replaces every token in one pass (1 ms)
    ✓ does not re-scan substituted text (no cascade)
    ✓ prefers the longest source token at each position (1 ms)
    ✓ returns the input unchanged with no replacements
  resolveWithin
    ✓ resolves a relative path inside the root
    ✓ allows the root itself
    ✓ rejects a parent-directory escape (2 ms)
    ✓ rejects an absolute path outside the root (1 ms)
  applyVersion
    ✓ stamps the version into json and php-header files (1 ms)

 PASS  tests/init/identity.test.js
  validateName
    ✓ accepts a normal multi-word name
    ✓ rejects an empty name
    ✓ rejects a name starting with a digit
    ✓ rejects a PHP reserved keyword
  generateIdentity
    ✓ derives every case variant and WP convention from a name (1 ms)
    ✓ splits acronym boundaries
  buildIdentityReplacements
    ✓ covers the key case variants and prefixes
    ✓ covers the namespace in single- and double-backslash forms
    ✓ is de-duplicated by source token
    ✓ is sorted longest-source-first so specific tokens win (1 ms)

 PASS  tests/scaffolds/sources.test.js
  parseId
    ✓ splits category/slug (1 ms)
    ✓ handles nested category
    ✓ handles slug with no category
  readSources
    ✓ returns null when the file is absent
    ✓ reads + parses the fixture sources (1 ms)
    ✓ throws EBADSCAFFOLD on invalid JSON (1 ms)
  validateSources
    ✓ accepts a valid sources file
    ✓ accepts the fixture
    ✓ rejects a non-object
    ✓ rejects a missing sources array
    ✓ rejects an unknown top-level field
    ✓ rejects a source missing required fields
    ✓ rejects a bad repository slug (1 ms)
    ✓ rejects an unknown source field
    ✓ flags a duplicate repository + path source
    ✓ same repo + path but different ref is not a duplicate
  validateIndex
    ✓ accepts a valid index
    ✓ accepts an optional checksum
    ✓ rejects a non-object
    ✓ rejects a missing scaffolds array
    ✓ rejects an entry missing required fields
    ✓ rejects a bad slug in id
    ✓ rejects an unknown entry field
    ✓ rejects duplicate ids
  indexEntryToRecord
    ✓ builds a thin remote record with the resolved scaffold path
    ✓ normalises slashes when joining source.path + entry.path

version-monitor: npm lookup for "lodash" failed: network down
version-monitor: npm lookup for "jest" failed: network down
version-monitor: npm lookup for "lodash" failed: rate limited
version-monitor: npm lookup for "lodash" failed: not found
version-monitor: npm lookup for "jest" failed: not found
 PASS  tests/version-monitor/detect.test.js
  detect orchestrator
    ✓ annotates each update with is_major (1 ms)
    ✓ runs no detector when every source is disabled (1 ms)
    ✓ records hard failures so the run is not silently empty
    ✓ treats a rate limit as soft (no recorded error)
    ✓ does not record an expected 404 as a hard failure

 PASS  tests/ui/wizard.test.js
  Wizard
    ✓ should run all steps in order (1 ms)
    ✓ should honour the skip() predicate
    ✓ should pass context to skip()
    ✓ should default context to empty object
    ✓ should default steps to empty array
    ✓ should handle an empty steps array
    ✓ should throw a clear error when steps is not an array (2 ms)
    ✓ should omit ANSI formatting in non-TTY mode
    ✓ should propagate step errors

 PASS  tests/version-monitor/cli.test.js
  runCli arg handling
    ✓ prints usage and exits 0 on --help (1 ms)
    ✓ exits 2 when no mode is given
    ✓ exits 2 on mutually exclusive modes
    ✓ exits 2 on an unknown argument
  runCli --detect
    ✓ loads config, runs detect, prints JSON
    ✓ exits 2 when the config is missing
    ✓ exits 1 but still prints results when a detector errored
  runCli --report / --apply (stdin)
    ✓ reads JSON from stdin and prints markdown for --report
    ✓ reads JSON from stdin and applies for --apply
    ✓ exits 2 when stdin is not valid JSON (2 ms)

 PASS  tests/scaffolds/cache.test.js
  clear
    ✓ removes a populated cache directory (9 ms)
    ✓ is a no-op on a missing directory (2 ms)
  parseArgs
    ✓ extracts the subaction (1 ms)
    ✓ extracts --cache-dir <path>
    ✓ extracts --cache-dir=<path>
    ✓ extracts --dry-run
    ✓ extracts --help
    ✓ throws on unexpected positional after the action (1 ms)
  runCli
    ✓ clear --cache-dir succeeds on a populated dir (3 ms)
    ✓ clear --dry-run reports without removing the dir
    ✓ --help returns 0 and prints usage (1 ms)
    ✓ no subaction returns 1 and prints usage
    ✓ unknown subaction returns 1 and writes to stderr

 PASS  tests/ui/prompts.test.js
  text
    ✓ should return the user input (1 ms)
    ✓ should return defaultValue when input is empty
    ✓ should trim whitespace from input
    ✓ should retry when validation fails (1 ms)
    ✓ should accept a string message shortcut
    ✓ should propagate CancelledError from readLine (2 ms)
  confirm
    ✓ should return true for "y" (1 ms)
    ✓ should return true for "yes"
    ✓ should return false for "n"
    ✓ should return defaultValue on empty input (1 ms)
    ✓ should default to false when no defaultValue
    ✓ should accept a string message shortcut
    ✓ should propagate CancelledError from readLine (1 ms)
  password
    ✓ should fall back to readLine in non-TTY (1 ms)
    ✓ should accept a string message shortcut
    ✓ should reject with CancelledError on Ctrl+C (6 ms)

 PASS  tests/version-monitor/wp-cli.test.js
  wp-cli detector
    ✓ reports an update when a newer release exists (1 ms)
    ✓ reports nothing when already on the latest release
    ✓ makes no request when no wp-cli version is pinned

 PASS  tests/a11y/resolve-bin.test.js
  findInNodeModules
    ✓ finds a directly installed binary as local (1 ms)
    ✓ finds a hoisted binary in an ancestor as hoisted (3 ms)
    ✓ returns null when no installed copy exists
  resolveBin
    ✓ falls back to npx --no-install when nothing is installed (1 ms)
  detectBin
    ✓ reports available with a trimmed version when the probe succeeds (1 ms)
    ✓ reports unavailable when the probe throws

 PASS  tests/version-monitor/container.test.js
  container helpers
    ✓ splits image references and skips digest pins (1 ms)
    ✓ maps official images to library/* and rejects other registries
    ✓ picks the newest tag of the same numeric shape (4 ms)
    ✓ collects images past --platform flags and across multi-stage FROMs (1 ms)
  container detector
    ✓ reports updates for Dockerfile and devcontainer images (6 ms)
    ✓ reports nothing when the current tag is newest

 PASS  tests/version-monitor/semver.test.js
  semver.splitVersion
    ✓ separates a range prefix from the numeric core
    ✓ separates a v tag prefix (1 ms)
    ✓ returns an empty core for non-numeric specs
  semver.parse
    ✓ fills missing minor/patch with zero
    ✓ captures the pre-release tag
  semver.compareStable / gt
    ✓ ranks by major, then minor, then patch
    ✓ ignores prefixes and pre-release tags
  semver.isMajorBump
    ✓ is true only when the major increases
  semver.isPreRelease
    ✓ detects pre-release suffixes
  semver.formatLatest
    ✓ re-attaches the current spec prefix to the latest core (1 ms)

 PASS  tests/ui/report.test.js
  report helpers
    ✓ prints a success line with a + marker
    ✓ prints warn and error lines with their markers
    ✓ prints a heading with a leading blank line
    ✓ prints an indented info line (1 ms)
    ✓ renders a key/value table with a title and box borders
    ✓ skips empty tables

 PASS  tests/version-monitor/node.test.js
  node detector
    ✓ bumps .nvmrc to the newest LTS in the same major
    ✓ preserves the range prefix in package.json engines
    ✓ reports nothing when already on the newest LTS (1 ms)

 PASS  tests/a11y/urls.test.js
  resolveUrls
    ✓ reads string and { url } entries from the default config
    ✓ a custom --config path is resolved relative to cwd (empty urls -> ENOURLS) (1 ms)
    ✓ a missing config throws ENOURLS with the install hint
  extractUrls
    ✓ handles strings, objects, and skips junk
    ✓ returns [] when urls is absent or not an array

 PASS  tests/ui/style.test.js
  style
    ✓ wraps text in ANSI codes when stdout is a TTY
    ✓ returns plain text when stdout is not a TTY (no ANSI leaks into logs) (1 ms)
    ✓ exposes the full set of semantic helpers

 PASS  tests/hooks/install.test.js
  installHooks
    ✓ installs both hooks with the executable bit set (58 ms)
    ✓ injects the version header right after the shebang (33 ms)
    ✓ skips when a hook already exists, prints the right reason (46 ms)
    ✓ detects Husky-managed hooks and tags the skip reason (44 ms)
    ✓ --force overwrites existing hooks (61 ms)
    ✓ --dry-run plans without touching the filesystem (67 ms)
    ✓ throws a clear error outside a git repository (20 ms)
    ✓ resolves the hooks dir when .git is a file (separate git dir) (75 ms)
    ✓ onConflict callback receives the conflict info per template (96 ms)
    ✓ onConflict returning true overwrites the existing hook (102 ms)
    ✓ --force bypasses the onConflict callback entirely (66 ms)
    ✓ --dry-run reports skips for conflicts without consulting onConflict (68 ms)
    ✓ onBeforeWrite / onAfterWrite fire around each successful install (69 ms)
  injectVersionHeader
    ✓ inserts the header between the shebang and the body
    ✓ returns the body unchanged when there is no newline
  looksLikeHusky
    ✓ matches the `.husky/` directory marker
    ✓ matches the husky.sh sourcing marker
    ✓ returns false for arbitrary hook scripts
  install-hooks runCli
    ✓ --help exits 0 and prints usage (3 ms)
    ✓ -h exits 0 and prints usage
    ✓ unknown flag exits 2 with a stderr message (1 ms)
    ✓ runs end-to-end inside a git repo, prints `installed` lines (32 ms)
    ✓ --dry-run prints planned actions, writes nothing (29 ms)
    ✓ outside a git repo exits 1 with a clear stderr message (9 ms)
    ✓ non-TTY conflict skips without prompting (CI-safe) (28 ms)

 PASS  tests/hooks/templates.test.js
  commit-msg template
    ✓ accepts "feat: add Logger" (3 ms)
    ✓ accepts "fix: handle empty CHANGELOG" (1 ms)
    ✓ accepts "docs: update README"
    ✓ accepts "feat(utilities): add Logger"
    ✓ accepts "fix(release): handle empty CHANGELOG"
    ✓ accepts "feat(ui)!: breaking change to wizard"
    ✓ accepts "chore(deps): bump eslint"
    ✓ accepts "ci(detect-changes): cover edge case"
    ✓ accepts "refactor(scaffolds): inline registry scan"
    ✓ accepts "revert: feat(ui): add wizard"
    ✓ accepts "revert(release): restore prior tag"
    ✓ rejects "wip" (1 ms)
    ✓ rejects "WIP: x"
    ✓ rejects "feat:"
    ✓ rejects "feat: "
    ✓ rejects "feat(): no scope chars allowed empty"
    ✓ rejects "random commit message"
    ✓ rejects "Feat: capitalised type"
    ✓ rejects "feature: not in type list"
    ✓ shell bypasses cover merge / revert / fixup / squash
  commit-msg template (end-to-end against /bin/sh)
    ✓ exits 0 for a valid Conventional Commit subject (12 ms)
    ✓ exits 1 for an invalid subject and prints guidance (14 ms)
    ✓ exits 0 for merge commits (10 ms)
    ✓ exits 0 for revert commits (12 ms)
    ✓ exits 0 for fixup! commits (9 ms)
  pre-commit template
    ✓ no-ops when package.json is absent (5 ms)
    ✓ no-ops when package.json has no lint:staged script (122 ms)
    ✓ does not false-positive when "lint:staged" appears only as a string (154 ms)
    ✓ runs the lint:staged script when defined (248 ms)
    ✓ propagates a non-zero exit from the lint:staged script (209 ms)
  shellcheck (optional)
    ✓ commit-msg passes shellcheck -s sh (72 ms)
    ✓ pre-commit passes shellcheck -s sh (23 ms)

Test Suites: 54 passed, 54 total
Tests:       728 passed, 728 total
Snapshots:   0 total
Time:        1.335 s, estimated 2 s
Ran all test suites.

Acceptance criteria

Runtime behavior

  • wp-tooling a11y runs the pa11y accessibility check locally (using the pa11y-ci command-line tool) and emits the normalized report in both text and json.
  • The binary is resolved from the consumer; a clear wp-tooling add setup/pa11y hint is shown when it is missing; pa11y-ci is never added to @rtcamp/wp-tooling dependencies.
  • Exit codes behave as specified (0 clean / 1 failure / 2 usage-or-missing / 3 violations) and --dry-run runs nothing.

Code quality

  • Lint — zero errors
  • Tests pass (unit tests cover normalize + urls + resolve-bin + cli against fixtures)

Housekeeping

  • The accessibility skill (SKILL.md + evals) is added and auto-validated by tests/skills/evals-json.test.js.
  • The setup/claude-skills scaffold copies the skill (SKILL.md + evals), and the bundled scaffold-manifest tests pass.
  • skills/README.md lists the skill, and a CHANGELOG.md entry is added under ## Unreleased.
  • .claude/issues/34-a11y-skill.md updated with final state.

Reviewer notes

  • There is no --url flag by design: pa11y-ci merges cwd-config URLs with positional URLs, so a flag cannot reliably override the config. The config is the single source of truth.
  • The skill treats setup/pa11y as one way to obtain a config, not a requirement; a project with its own pa11y setup works as-is.
  • The skill lives in wp-tooling for now rather than wp-dev-tools (no public repo yet for remote scaffold sources).

Full decision log and verification history for this work lives in .claude/issues/34-a11y-skill.md on this branch.

@Adi-ty
Adi-ty force-pushed the v1.0.0/task/a11y-skill branch from e218727 to c280617 Compare July 6, 2026 10:03
@Adi-ty
Adi-ty requested a review from AnuragVasanwala July 6, 2026 13:14
@Adi-ty Adi-ty self-assigned this Jul 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant