diff --git a/node-packages/wp-tooling/AGENTS.md b/node-packages/wp-tooling/AGENTS.md index ee0d1fc..a037a43 100644 --- a/node-packages/wp-tooling/AGENTS.md +++ b/node-packages/wp-tooling/AGENTS.md @@ -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, diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 68a291f..69d3e8b 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -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--`). 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 ` (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 `'); + } + return false; +} + +/** + * Order findings by line, then column, then rule order. + * + * @param {Object} a + * @param {Object} b + * @return {number} Sort order. + */ +function byPosition(a, b) { + return a.line - b.line || a.column - b.column; +} + +/** + * Match every rule that covers `file`'s extension against its text. Matches + * on a commented-out line (see isCommented()) are skipped. + * + * @param {string} text Source text. A leading byte-order mark is not counted as a column. + * @param {string} file Path to report (also picks the rules by extension). + * @return {Object[]} Findings: `{ file, line, column, rule, severity, message, match, replacement }`, sorted by position. + */ +function scanText(text, file) { + const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; + const ext = extensionOf(file); + const findings = []; + let starts = null; + for (const rule of RULES) { + if ( + !rule.extensions.includes(ext) || + (rule.appliesTo && !rule.appliesTo(body)) + ) { + continue; + } + const pattern = rule.patternFor ? rule.patternFor(body) : rule.pattern; + for (const match of body.matchAll(pattern)) { + starts ??= lineStarts(body); + const { line } = locate(starts, match.index); + if (isCommented(body.slice(starts[line - 1], match.index))) { + continue; + } + const hit = rule.describe(match); + findings.push({ + file, + ...locate(starts, hit.index), + rule: rule.id, + severity: rule.severity, + message: hit.message, + match: hit.text, + replacement: hit.replacement, + }); + } + } + return findings.sort(byPosition); +} + +/** + * Fail unless `root` is a readable directory. + * + * @param {string} root + */ +function assertDirectory(root) { + let stat; + try { + stat = fs.statSync(root); + } catch (err) { + throw new IapiCheckError( + `cannot read --working-dir "${root}" (${err.code || err.message}); expected an existing directory` + ); + } + if (!stat.isDirectory()) { + throw new IapiCheckError( + `--working-dir "${root}" is a file; expected a directory` + ); + } +} + +/** + * Test `rel` against `pattern` from the start. A caller's RegExp with the `g` + * or `y` flag keeps `lastIndex` between calls, which would make one file's + * result depend on the previous file's. + * + * @param {RegExp} pattern + * @param {string} rel + * @return {boolean} Whether the path matches. + */ +function resetAndTest(pattern, rel) { + pattern.lastIndex = 0; + return pattern.test(rel); +} + +/** + * List the files to scan under `root`, relative to it with `/` separators and + * sorted. Symbolic links are not followed. + * + * @param {string} root + * @param {RegExp|null} ignore Relative paths to skip. + * @return {string[]} Relative paths. + */ +function listFiles(root, ignore) { + const files = []; + const pending = ['']; + while (pending.length > 0) { + const dir = pending.pop(); + let entries; + try { + entries = fs.readdirSync(path.join(root, dir), { + withFileTypes: true, + }); + } catch (err) { + throw new IapiCheckError( + `cannot read directory "${path.join(root, dir)}" (${err.code || err.message})` + ); + } + for (const entry of entries) { + const rel = dir ? `${dir}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (!SKIPPED_DIRS.has(entry.name)) { + pending.push(rel); + } + continue; + } + if ( + entry.isFile() && + SCANNED_EXTENSIONS.has(extensionOf(entry.name)) && + !MINIFIED_RE.test(entry.name) && + !(ignore && resetAndTest(ignore, rel)) + ) { + files.push(rel); + } + } + } + return files.sort(); +} + +/** + * Scan a project for Interactivity API usages WordPress 7.2 stops supporting. + * + * @param {Object} options + * @param {string} options.root Directory to scan. + * @param {RegExp|null} [options.ignore=null] Skip files whose path relative to `root` matches. + * @return {{filesScanned: number, findings: Object[], errors: number, warnings: number}} Findings sorted by file, then position. + * @throws {IapiCheckError} When `root` or a file under it cannot be read. + */ +function checkIapi({ root, ignore = null }) { + assertDirectory(root); + const files = listFiles(root, ignore); + const findings = []; + for (const file of files) { + let text; + try { + text = fs.readFileSync(path.join(root, file), 'utf8'); + } catch (err) { + throw new IapiCheckError( + `cannot read "${path.join(root, file)}" (${err.code || err.message})` + ); + } + for (const finding of scanText(text, file)) { + findings.push(finding); + } + } + const errors = findings.filter((f) => f.severity === 'error').length; + return { + filesScanned: files.length, + findings, + errors, + warnings: findings.length - errors, + }; +} + +module.exports = { + checkIapi, + scanText, + SKIPPED_DIRS, +}; diff --git a/node-packages/wp-tooling/src/ci/index.js b/node-packages/wp-tooling/src/ci/index.js index 914c3ef..a89e780 100644 --- a/node-packages/wp-tooling/src/ci/index.js +++ b/node-packages/wp-tooling/src/ci/index.js @@ -19,6 +19,12 @@ const { DEFAULT_UNMEASURED_POLICY, UNMEASURED_POLICIES, } = require('./coverage-gate'); +const { + checkIapi, + scanText: scanIapiText, + IapiCheckError, + RULES: IAPI_RULES, +} = require('./iapi-check'); module.exports = { detectChanges, @@ -32,4 +38,8 @@ module.exports = { GateError, DEFAULT_UNMEASURED_POLICY, UNMEASURED_POLICIES, + checkIapi, + scanIapiText, + IapiCheckError, + IAPI_RULES, }; diff --git a/node-packages/wp-tooling/src/cli/commands/iapi-check.js b/node-packages/wp-tooling/src/cli/commands/iapi-check.js new file mode 100644 index 0000000..cc85837 --- /dev/null +++ b/node-packages/wp-tooling/src/cli/commands/iapi-check.js @@ -0,0 +1,15 @@ +/** + * iapi-check subcommand registration. + * + * Auto-discovered by `src/cli/index.js`; `run` is required lazily so + * cold-start cost stays close to a single subcommand's footprint. + */ + +'use strict'; + +module.exports = { + name: 'iapi-check', + summary: + 'Find Interactivity API usages that WordPress 7.2 stops supporting', + run: (argv) => require('../../ci/iapi-check').runCli(argv), +}; diff --git a/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/assets/js/view.min.js b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/assets/js/view.min.js new file mode 100644 index 0000000..cb85f8c --- /dev/null +++ b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/assets/js/view.min.js @@ -0,0 +1 @@ +!function(){var e="data-wp-ignore";}(); diff --git a/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/legacy/render.php b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/legacy/render.php new file mode 100644 index 0000000..118b4c8 --- /dev/null +++ b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/legacy/render.php @@ -0,0 +1,18 @@ + +
+ +
diff --git a/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/legacy/view.js b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/legacy/view.js new file mode 100644 index 0000000..a9c98e8 --- /dev/null +++ b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/legacy/view.js @@ -0,0 +1,14 @@ +/** + * Legacy store: reads the router's deprecated navigation state. + */ +import { store } from '@wordpress/interactivity'; + +const { state } = store( 'core/router' ); + +store( 'acme/legacy', { + callbacks: { + isLoading() { + return state.navigation.hasStarted && ! state.navigation.hasFinished; + }, + }, +} ); diff --git a/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/menu/view.js b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/menu/view.js new file mode 100644 index 0000000..88f408d --- /dev/null +++ b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/menu/view.js @@ -0,0 +1,10 @@ +/** + * A store of its own with a navigation key: not the router, not flagged. + */ +import { store } from '@wordpress/interactivity'; + +const { state } = store( 'acme/menu', { + state: { navigation: { isOpen: false } }, +} ); + +export const isOpen = () => state.navigation.isOpen; diff --git a/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/modern/render.php b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/modern/render.php new file mode 100644 index 0000000..479d46d --- /dev/null +++ b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/modern/render.php @@ -0,0 +1,25 @@ + +
+ +
diff --git a/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/modern/view.js b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/modern/view.js new file mode 100644 index 0000000..271d0cf --- /dev/null +++ b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/modern/view.js @@ -0,0 +1,18 @@ +/** + * Current store: reads the router's public state.navigating. The deprecated + * state.navigation and data-wp-watch--id syntax are only mentioned in comments. + */ +// Was: data-wp-on--click--track and !actions.isOpen. +import { store } from '@wordpress/interactivity'; + +const { state: router } = store( 'core/router' ); + +const { state } = store( 'acme/modern', { + state: { + get isLoading() { + return router.navigating; + }, + }, +} ); + +export const menu = state.navigationMenu; diff --git a/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/patterns/hero.php b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/patterns/hero.php new file mode 100644 index 0000000..352d657 --- /dev/null +++ b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/patterns/hero.php @@ -0,0 +1,12 @@ + 'acme/hero', + 'data-wp-watch--hero' => 'callbacks.sync', + 'data-wp-bind--hidden' => '!callbacks.isHidden', +); +$processor->set_attribute( sprintf( 'data-wp-init--%s', $slug ), 'callbacks.init' ); diff --git a/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/readme.txt b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/readme.txt new file mode 100644 index 0000000..0b06e55 --- /dev/null +++ b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/readme.txt @@ -0,0 +1,2 @@ +=== Acme === +Mentions data-wp-ignore and data-wp-watch--logger, but readme.txt is not scanned. diff --git a/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/src/Menu.tsx b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/src/Menu.tsx new file mode 100644 index 0000000..1519eb6 --- /dev/null +++ b/node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/src/Menu.tsx @@ -0,0 +1,10 @@ +export function Menu( { id }: { id: string } ) { + return ( +