Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 10 additions & 6 deletions node-packages/wp-tooling/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@ two-ecosystem layout, and release mechanics; this file does not repeat any of it

```
src/
ci/ detect-changes.js, index.js, coverage-gate/ (cli, diff, errors, gate,
index, render, reports) — CI helper commands (change detection and
changed-line coverage gating for CI pipelines)
cli/ index.js (dispatcher) + commands/ (14 files: a11y, add, cache,
coverage-gate, detect-changes, features, install-hooks, list, perf,
release-bump, release-changelog, release-zip, validate, version-monitor)
ci/ cli-support.js, detect-changes.js, index.js, coverage-gate/ (cli, diff,
errors, gate, index, render, reports), iapi-check/ (cli, errors, index,
render, rules, scan): CI helper commands (change detection, changed-line
coverage gating and the WordPress 7.2 Interactivity API check).
cli-support.js holds the flag parsing and GitHub output plumbing the
commands share
cli/ index.js (dispatcher) + commands/ (15 files: a11y, add, cache,
coverage-gate, detect-changes, features, iapi-check, install-hooks, list,
perf, release-bump, release-changelog, release-zip, validate,
version-monitor)
hooks/ index.js, install.js, templates/ — git hook installer + shell templates
(commit-msg, pre-commit)
init/ cleanup.js, examples.js, features.js, git.js, identity.js, index.js,
Expand Down
4 changes: 4 additions & 0 deletions node-packages/wp-tooling/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- `wp-tooling iapi-check` subcommand (`src/ci/iapi-check/`; `checkIapi`, `scanIapiText`, `IapiCheckError` and `IAPI_RULES` exported from `@rtcamp/wp-tooling/ci`). Finds Interactivity API usages that WordPress 7.2 (due 2026-12-09) stops supporting, now that [WordPress/gutenberg#84098](https://github.com/WordPress/gutenberg/pull/84098) has removed the expired deprecations from the client runtime. Four error rules: `ignore-directive` (`data-wp-ignore` is removed and its subtree now hydrates), `lifecycle-double-hyphen-id` (`data-wp-init--id`, `data-wp-watch--id` and `data-wp-run--id` no longer run; use `---id`), `event-double-hyphen-id` (`data-wp-on--click--id` and its `-window`/`-document` variants listen for a `click--id` event instead of `click`; use `--click---id`) and `negated-function` (`!` before `actions.*` or `callbacks.*`, or after the namespace as in `ns::!actions.*`, resolves to `undefined` without calling the function; `!ns::actions.*` never resolved its namespace, so it is not reported). One warning rule: `router-state-navigation` (`.navigation` on the `core/router` store's state, deprecated since WordPress 7.0), checked in JS/TS files that mention `core/router` or `interactivity-router`. It follows the name the router state is bound to (`const { state: router } = store( 'core/router' )`, `import { state as rs } from '@wordpress/interactivity-router'`, `router.state`), so another store's `state.navigation` in the same file is not reported, and its message says to switch to `.navigating` once the plugin requires WordPress 7.2. Matches raw text in `*.php`, `*.html`, `*.js`, `*.jsx`, `*.ts`, `*.tsx`, `*.mjs` and `*.cjs`, so it finds directives in `render.php`, templates, patterns, PHP strings and JSX, including a unique ID added at run time (`'data-wp-watch--' . $id`, `` `data-wp-watch--${ id }` ``, `sprintf( 'data-wp-init--%s', ... )`, `data-wp-watch--<?php echo $id; ?>`). A directive name with nothing after `--` (`set_attribute( 'data-wp-watch--', ... )`) still runs, so it is not reported. Three-hyphen IDs, `data-wp-on-async*`, single-hyphen custom events and `!state.*`/`!context.*` are not flagged. Skips `node_modules/`, `vendor/`, `.git/`, `build/` and `dist/` at any depth, `*.min.js`, symbolic links and paths matching `--ignore <regex>` (a pattern with the `g` or `y` flag is reset for each path), and matches on a line that starts a comment (`//`, `#`, `*`, or an unclosed `/*` or `<!--`). Each finding has the file, line, column, rule and a message that names the replacement. `--output text|github|json`: `github` writes one annotation per finding, a `$GITHUB_STEP_SUMMARY` table and the `findings`, `errors` and `warnings` step outputs. Annotation paths are re-rooted to the repository root with `git rev-parse --show-prefix`, like `coverage-gate`, so `--working-dir` can be a subdirectory. Exits 1 when there is an error-severity finding; warnings never fail the run. `--soft-fail` exits 0 and annotates every finding as a warning, and `--dry-run` previews the GitHub output. An empty flag value counts as not given. Built for the Interactivity API check in rtCamp/wp-shared-workflows.

### Fixed

- `detect-changes` buckets the remaining tool config files that change what a job checks, so a pull request that only touches one no longer skips that job. `php` now claims `phpcs*.xml` and `.phpcs*.xml` (with or without `.dist`, so `phpcs.i18n.xml.dist` and `phpcs-custom.xml` count), `phpunit*.xml(.dist)`, `.wp-env.json` and its `.wp-env.<name>.json` variants, and `.distignore`. `js` also claims `jest.config.json` and `tsconfig*.json` / `jsconfig*.json` (any name, such as `tsconfig-base.json` or `tsconfig.app.build.json`), and `css` also claims `postcss.config.*`. `.nvmrc` counts under `css`, `js` and `php` (test-php runs `wp-env` on that Node version), `.browserslistrc` under `css` and `js`, and `block.json` under `js` and `php`. rtCamp/wp-shared-workflows lints the whole project when a bucket's list holds anything but source files, so a ruleset change is checked against every file.
Expand Down
3 changes: 2 additions & 1 deletion node-packages/wp-tooling/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ npx wp-tooling <command> --help # options for any command
| `release:zip` | Build a `.distignore`-aware `dist/<slug>-<version>.zip` |
| `detect-changes` | Bucket the files a change touches, so CI runs only the jobs it needs |
| `coverage-gate` | Fail when too few of a pull request's changed lines are covered (Clover or LCOV) |
| `iapi-check` | Find Interactivity API usages that WordPress 7.2 stops supporting: `data-wp-ignore`, two-hyphen unique IDs and negated functions |
| `a11y` | Run pa11y-ci and report normalised accessibility violations |
| `perf` | Collect Core Web Vitals and Lighthouse scores, plus optional server profiling |
| `version-monitor` | Detect and apply WordPress, PHP and Node updates that Dependabot misses |
Expand Down Expand Up @@ -84,7 +85,7 @@ The same functionality is available from code:
| `@rtcamp/wp-tooling/features` | Feature toggles |
| `@rtcamp/wp-tooling/release` | Version bump, changelog and zip |
| `@rtcamp/wp-tooling/hooks` | Git hook installer |
| `@rtcamp/wp-tooling/ci` | `detectChanges`, `computeGate` and their parsers and errors |
| `@rtcamp/wp-tooling/ci` | `detectChanges`, `computeGate`, `checkIapi` and their parsers and errors |
| `@rtcamp/wp-tooling/a11y` | `runA11y` |
| `@rtcamp/wp-tooling/perf` | Performance runner |
| `@rtcamp/wp-tooling/version-monitor` | Detectors, updaters and reporters |
Expand Down
5 changes: 5 additions & 0 deletions node-packages/wp-tooling/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ const wpToolingConfig = require('@rtcamp/eslint-config');

module.exports = [
...wpToolingConfig,
{
// A fake WordPress project with deliberately outdated Interactivity API
// markup (ES modules, JSX) for the iapi-check tests: data, not code.
ignores: ['tests/ci/fixtures/iapi-check/**'],
},
{
plugins: { n: nPlugin },
languageOptions: {
Expand Down
131 changes: 131 additions & 0 deletions node-packages/wp-tooling/src/ci/cli-support.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* CLI plumbing shared by the CI commands (`coverage-gate`, `iapi-check`):
* table-driven flag parsing, and GitHub Actions output (workflow commands,
* `$GITHUB_OUTPUT` and `$GITHUB_STEP_SUMMARY` appends) with the `--dry-run`
* preview of each.
*/

'use strict';

const fs = require('fs');

const { takeValue, formatGithubLine } = require('./detect-changes');

/**
* Parse argv (after the subcommand name) against flag tables. An empty value
* counts as not given, so a workflow can pass `--flag "${{ inputs.x }}"`
* whether or not the input is set.
*
* @param {string[]} argv
* @param {Object<string, string>} valueFlags Flag → option key, for flags that take a value.
* @param {Object<string, string>} booleanFlags Flag → option key, for flags that don't.
* @return {Object} Option key → raw string, or `true` for boolean flags.
*/
function parseFlags(argv, valueFlags, booleanFlags) {
const opts = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (Object.hasOwn(booleanFlags, arg)) {
opts[booleanFlags[arg]] = true;
continue;
}
if (!Object.hasOwn(valueFlags, arg)) {
throw new Error(`unknown argument: ${arg}`);
}
const value = takeValue(argv, ++i, arg);
if (value !== '') {
opts[valueFlags[arg]] = value;
}
}
return opts;
}

/**
* Check a raw value against the allowed choices, or fall back to a default.
*
* @param {string|undefined} raw
* @param {string[]} choices
* @param {string} fallback
* @param {string} flag Flag name, for the error message.
* @return {string} The chosen value.
*/
function parseChoice(raw, choices, fallback, flag) {
if (raw === undefined) {
return fallback;
}
if (!choices.includes(raw)) {
throw new Error(
`invalid ${flag} "${raw}" (expected one of: ${choices.join(', ')})`
);
}
return raw;
}

/**
* Print workflow commands, or, under --dry-run, print them prefixed so the
* runner does not act on them.
*
* @param {string[]} commands Workflow command lines.
* @param {boolean} dryRun
*/
function emitAnnotations(commands, dryRun) {
if (commands.length === 0) {
return;
}
const lines = dryRun
? commands.map((command) => `[dry-run] would emit: ${command}`)
: commands;
process.stdout.write(lines.join('\n') + '\n');
}

/**
* Append to the file a GitHub Actions environment variable points at.
*
* @param {Object} env Environment to read the path from.
* @param {string} variable e.g. `GITHUB_OUTPUT`.
* @param {string} content
* @param {string} command Command name, for the warning when the variable is unset.
*/
function appendToEnvFile(env, variable, content, command) {
const dest = env[variable];
if (!dest) {
process.stderr.write(
`${command}: --output github requested but $${variable} is unset; nothing written.\n`
);
return;
}
fs.appendFileSync(dest, content);
}

/**
* Write step outputs and the job summary, or preview them under --dry-run.
*
* @param {Object} outputs Step output name → value.
* @param {string} summary Markdown summary.
* @param {Object} options
* @param {Object} options.env Environment holding `$GITHUB_OUTPUT` and `$GITHUB_STEP_SUMMARY`.
* @param {boolean} options.dryRun Preview to stdout instead of writing.
* @param {string} options.command Command name, for warnings.
*/
function writeGithubFiles(outputs, summary, { env, dryRun, command }) {
const outputLines =
Object.entries(outputs)
.map(([key, value]) => formatGithubLine(key, value))
.join('\n') + '\n';
if (dryRun) {
process.stdout.write(
`[dry-run] would append to $GITHUB_OUTPUT:\n${outputLines}` +
`[dry-run] would append to $GITHUB_STEP_SUMMARY:\n${summary}`
);
return;
}
appendToEnvFile(env, 'GITHUB_OUTPUT', outputLines, command);
appendToEnvFile(env, 'GITHUB_STEP_SUMMARY', summary, command);
}

module.exports = {
parseFlags,
parseChoice,
emitAnnotations,
writeGithubFiles,
};
116 changes: 20 additions & 96 deletions node-packages/wp-tooling/src/ci/coverage-gate/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
const fs = require('fs');
const path = require('path');

const { takeValue, formatGithubLine } = require('../detect-changes');
const {
parseFlags,
parseChoice,
emitAnnotations,
writeGithubFiles,
} = require('../cli-support');
const { GateError } = require('./errors');
const { gitChangedLines, repoPrefix, resolveGateBase } = require('./diff');
const { parseReport, inferFormat, REPORT_FORMATS } = require('./reports');
Expand Down Expand Up @@ -128,43 +133,7 @@ const USAGE = [
* @return {Object} Option key → raw string, or `true` for boolean flags.
*/
function parseArgs(argv) {
const opts = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (Object.hasOwn(BOOLEAN_FLAGS, arg)) {
opts[BOOLEAN_FLAGS[arg]] = true;
continue;
}
if (!Object.hasOwn(VALUE_FLAGS, arg)) {
throw new Error(`unknown argument: ${arg}`);
}
const value = takeValue(argv, ++i, arg);
if (value !== '') {
opts[VALUE_FLAGS[arg]] = value;
}
}
return opts;
}

/**
* Check a raw value against the allowed choices, or fall back to a default.
*
* @param {string|undefined} raw
* @param {string[]} choices
* @param {string} fallback
* @param {string} flag Flag name, for the error message.
* @return {string} The chosen value.
*/
function parseChoice(raw, choices, fallback, flag) {
if (raw === undefined) {
return fallback;
}
if (!choices.includes(raw)) {
throw new Error(
`invalid ${flag} "${raw}" (expected one of: ${choices.join(', ')})`
);
}
return raw;
return parseFlags(argv, VALUE_FLAGS, BOOLEAN_FLAGS);
}

/**
Expand Down Expand Up @@ -263,61 +232,13 @@ function resolveConfig(opts, env) {
}

/**
* Print workflow commands, or — under --dry-run — print them prefixed so the
* runner does not act on them.
*
* @param {string[]} commands Workflow command lines.
* @param {Object} config resolveConfig() result.
*/
function emitAnnotations(commands, config) {
if (commands.length === 0) {
return;
}
const lines = config.dryRun
? commands.map((command) => `[dry-run] would emit: ${command}`)
: commands;
process.stdout.write(lines.join('\n') + '\n');
}

/**
* Append to the file a GitHub Actions environment variable points at.
* Options for the shared `$GITHUB_*` file writer.
*
* @param {Object} env Environment to read the path from.
* @param {string} variable e.g. `GITHUB_OUTPUT`.
* @param {string} content
*/
function appendToEnvFile(env, variable, content) {
const dest = env[variable];
if (!dest) {
process.stderr.write(
`coverage-gate: --output github requested but $${variable} is unset; nothing written.\n`
);
return;
}
fs.appendFileSync(dest, content);
}

/**
* Write step outputs and the job summary, or preview them under --dry-run.
*
* @param {Object} outputs Step outputs.
* @param {string} summary Markdown summary.
* @param {Object} config resolveConfig() result.
* @param {Object} config resolveConfig() result.
* @return {Object} `{ env, dryRun, command }`.
*/
function writeGithubFiles(outputs, summary, config) {
const outputLines =
Object.entries(outputs)
.map(([key, value]) => formatGithubLine(key, value))
.join('\n') + '\n';
if (config.dryRun) {
process.stdout.write(
`[dry-run] would append to $GITHUB_OUTPUT:\n${outputLines}` +
`[dry-run] would append to $GITHUB_STEP_SUMMARY:\n${summary}`
);
return;
}
appendToEnvFile(config.env, 'GITHUB_OUTPUT', outputLines);
appendToEnvFile(config.env, 'GITHUB_STEP_SUMMARY', summary);
function githubFileOptions(config) {
return { env: config.env, dryRun: config.dryRun, command: 'coverage-gate' };
}

/**
Expand All @@ -331,11 +252,11 @@ function skipGate(config) {
process.stdout.write(`coverage-gate: ${NO_BASE_MESSAGE}\n`);
return 0;
}
emitAnnotations([annotation('notice', NO_BASE_MESSAGE)], config);
emitAnnotations([annotation('notice', NO_BASE_MESSAGE)], config.dryRun);
writeGithubFiles(
stepOutputs(EMPTY_RESULT),
formatSkipSummary(NO_BASE_MESSAGE),
config
githubFileOptions(config)
);
return 0;
}
Expand Down Expand Up @@ -377,11 +298,11 @@ function publish(result, config) {
failureLevel: config.failureLevel,
unmeasuredPolicy: config.unmeasuredPolicy,
});
emitAnnotations(commands, config);
emitAnnotations(commands, config.dryRun);
writeGithubFiles(
stepOutputs(result),
formatSummary(result, config.threshold),
config
githubFileOptions(config)
);
}
process.stdout.write(formatText(result, config.threshold));
Expand Down Expand Up @@ -447,7 +368,10 @@ function failRun(err, config) {
}
const message = `coverage-gate: ${err.message}`;
if (config.github) {
emitAnnotations([annotation(config.failureLevel, message)], config);
emitAnnotations(
[annotation(config.failureLevel, message)],
config.dryRun
);
} else {
process.stderr.write(message + '\n');
}
Expand Down
Loading
Loading