Skip to content

feat(iapi-check): flag Interactivity API usages that WordPress 7.2 removes - #91

Open
aryanjasala wants to merge 6 commits into
mainfrom
feat/iapi-check
Open

aryanjasala wants to merge 6 commits into
mainfrom
feat/iapi-check

Conversation

@aryanjasala

@aryanjasala aryanjasala commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

What this PR does

Adds wp-tooling iapi-check, which finds the Interactivity API usages that WordPress 7.2 (due 2026-12-09) stops supporting. WordPress/gutenberg#84098 removes the expired deprecations from the client runtime: data-wp-ignore, two-hyphen unique IDs, and ! applied to a function. Code that still uses them breaks quietly. The command runs locally and in CI (wp-shared-workflows ci-check-iapi.yml).

Closes

N/A

Changes

  • src/ci/cli-support.js (refactor, first commit): coverage-gate's flag parser, choice validation, workflow-command emitter and $GITHUB_OUTPUT / $GITHUB_STEP_SUMMARY writers move here so both CI commands share them. coverage-gate's messages and behaviour are unchanged; its annotations gain optional col and title.

  • src/ci/iapi-check/ and src/cli/commands/iapi-check.js: scans *.php, *.html, *.js, *.jsx, *.ts, *.tsx, *.mjs and *.cjs as raw text, so directives in render.php, templates, patterns, PHP strings and JSX are all found. Skips node_modules/, vendor/, .git/, build output, minified bundles, symlinks, --ignore matches and lines that start a comment. Rules:

    Rule Severity What 7.2 does
    ignore-directive error data-wp-ignore is gone; the subtree hydrates
    lifecycle-double-hyphen-id error data-wp-init/watch/run--id callbacks are skipped
    event-double-hyphen-id error data-wp-on--click--id listens for a click--id event, never fires on click
    negated-function error !actions.x / !callbacks.x (also ns::!actions.x; !ns::actions.x never resolved its namespace, so it is skipped) is undefined, never called
    router-state-navigation warning state.navigation from core/router, deprecated in 7.0

    Each finding has file, line, column and the exact replacement (Use data-wp-watch---logger.). IDs built at run time ('data-wp-watch--' . $id, template literals, sprintf) are caught too.

  • Output: text (default), json, or github: annotations with paths re-rooted to the repository root, a summary table, and findings, errors and warnings step outputs. Exit 1 on any error unless --soft-fail, which reports everything as a warning and exits 0. --dry-run as every command must have.

  • @rtcamp/wp-tooling/ci also exports checkIapi, scanIapiText, IapiCheckError and IAPI_RULES. README, package AGENTS.md and CHANGELOG updated; the fixture project is excluded from ESLint because it is deliberately old code.

How I verified

$ npm run check            # in node-packages/wp-tooling
Test Suites: 83 passed, 83 total
Tests:       1516 passed, 1516 total    # on main after #80 and #83; 135 for iapi-check, 3 for the dispatcher
$ npm run lint             # repository root, exit 0
$ npm run test:coverage    # repository root, exit 0; new code ~98.6% lines
  • The patterns were checked against Gutenberg's own directive-name parser (splitDirectiveName() in vdom.ts) over 118,092 generated names: no false positives, and every suggested replacement parses. The only misses are names nobody writes (runs of four or more hyphens). A 1-2 MB file scans in about 12 ms.
  • On a real project: ICIMOD is clean. Comment-only lines are skipped, because an early run flagged directive names inside comments.
  • --output github on the fixture project: repo-relative annotation paths, --soft-fail exits 0 with warnings, without it exits 1.

Acceptance criteria

Runtime behavior

  • Every rule fires on its positive cases and stays quiet on the valid forms (---id, data-wp-on-async*, !state.*, single-hyphen custom events)
  • Annotations attach to repository paths when --working-dir is a subdirectory
  • --soft-fail exits 0; errors exit 1 otherwise

Code quality

  • Lint: zero errors
  • Static analysis: zero errors
  • Tests pass

Housekeeping

  • CHANGELOG.md entry under ## Unreleased

Reviewer notes

  • negated-function is a heuristic. Statically we cannot know what resolves to a function, so it flags only the actions. and callbacks. namespaces, which are functions by convention. !state.x and !context.x stay valid in 7.2 (getters and booleans still work).
  • router-state-navigation only runs on files that mention core/router or interactivity-router, so a theme's own menu store with state.navigation is not flagged.
  • A block deprecation that keeps the old names on purpose is flagged. --ignore covers it today; an inline ignore comment would be a small follow-up.
  • Needs a 2.1.0 release before December 9 to be useful in CI.

Move coverage-gate's table-driven flag parser, choice validation,
workflow-command emitter and $GITHUB_OUTPUT / $GITHUB_STEP_SUMMARY
writers (with their --dry-run previews) into src/ci/cli-support.js so
the next CI command reuses them instead of copying them. Messages and
behaviour are unchanged.

annotation() accepts optional col and title properties, and the render
module exports printable(), codeSpan(), escapeTableCell() and plural()
for reuse.
…moves

WordPress/gutenberg#84098 removes the expired Interactivity API
deprecations from the client runtime in WordPress 7.2 (due 2026-12-09).
`wp-tooling iapi-check` scans a project's php, html, js, jsx, ts, tsx,
mjs and cjs files as raw text and reports, with file, line, column and
the replacement:

- ignore-directive: data-wp-ignore is removed
- lifecycle-double-hyphen-id: data-wp-init/watch/run--id no longer run
- event-double-hyphen-id: data-wp-on(-window|-document)--event--id
  listens for an "event--id" event
- negated-function: "!" on actions.* or callbacks.* resolves to undefined
- router-state-navigation (warning): core/router state.navigation

Output is text, json or github (annotations re-rooted to the repository
root, a step summary table, and findings/errors/warnings outputs). Exits
1 on any error finding unless --soft-fail. Skips dependency, build and
git directories, minified bundles, symbolic links, --ignore matches and
commented-out lines.
Copilot AI balanced review requested due to automatic review settings November 5, 2026 23:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved matching and exclusion bugs cause missed PHP deprecations, false positives for valid or unrelated usages, and inconsistent file filtering with stateful regular expressions.

5 open issues
What changed in this PR

Adds wp-tooling iapi-check to help WordPress projects identify Interactivity API usages affected by WordPress 7.2, both locally and in CI.

Changes:

  • Adds five scanning rules, library exports, and text, JSON, and GitHub output modes.
  • Shares CLI parsing and GitHub output helpers with coverage-gate.
  • Adds regression tests, representative fixtures, and command documentation.
File Description
node-packages/​wp-tooling/​tests/​cli/​index.test.js Tests command registration, help, and error routing.
node-packages/​wp-tooling/​tests/​ci/​iapi-check.test.js Tests scanning rules, reporting, exclusions, and CLI behavior.
node-packages/​wp-tooling/​tests/​ci/​fixtures/​iapi-check/​project/​templates/​single.html Provides a legacy event directive in a block template.
node-packages/​wp-tooling/​tests/​ci/​fixtures/​iapi-check/​project/​style.css Verifies CSS files are excluded.
node-packages/​wp-tooling/​tests/​ci/​fixtures/​iapi-check/​project/​src/​Menu.tsx Exercises JSX directives and dynamic IDs.
node-packages/​wp-tooling/​tests/​ci/​fixtures/​iapi-check/​project/​readme.txt Verifies text documentation is excluded.
node-packages/​wp-tooling/​tests/​ci/​fixtures/​iapi-check/​project/​patterns/​hero.php Exercises PHP attribute arrays and generated IDs.
node-packages/​wp-tooling/​tests/​ci/​fixtures/​iapi-check/​project/​blocks/​modern/​view.js Provides current router usage and comment examples.
node-packages/​wp-tooling/​tests/​ci/​fixtures/​iapi-check/​project/​blocks/​modern/​render.php Provides supported directive syntax.
node-packages/​wp-tooling/​tests/​ci/​fixtures/​iapi-check/​project/​blocks/​menu/​view.js Provides navigation state unrelated to the router.
node-packages/​wp-tooling/​tests/​ci/​fixtures/​iapi-check/​project/​blocks/​legacy/​view.js Provides deprecated router navigation access.
node-packages/​wp-tooling/​tests/​ci/​fixtures/​iapi-check/​project/​blocks/​legacy/​render.php Provides deprecated markup and concatenated IDs.
node-packages/​wp-tooling/​tests/​ci/​fixtures/​iapi-check/​project/​assets/​js/​view.min.js Verifies minified bundles are excluded.
node-packages/​wp-tooling/​src/​cli/​commands/​iapi-check.js Registers the lazily loaded command.
node-packages/​wp-tooling/​src/​ci/​index.js Exports the checker’s public library API.
node-packages/​wp-tooling/​src/​ci/​iapi-check/​scan.js Walks source files and locates findings.
node-packages/​wp-tooling/​src/​ci/​iapi-check/​rules.js Defines detection patterns and migration messages.
node-packages/​wp-tooling/​src/​ci/​iapi-check/​render.js Builds reports, annotations, summaries, and step outputs.
node-packages/​wp-tooling/​src/​ci/​iapi-check/​index.js Exposes checker modules.
node-packages/​wp-tooling/​src/​ci/​iapi-check/​errors.js Defines expected scan failures.
node-packages/​wp-tooling/​src/​ci/​iapi-check/​cli.js Handles options, reporting, annotation paths, and exit codes.
node-packages/​wp-tooling/​src/​ci/​coverage-gate/​render.js Adds annotation columns and titles; exports rendering helpers.
node-packages/​wp-tooling/​src/​ci/​coverage-gate/​cli.js Uses shared CLI and GitHub output helpers.
node-packages/​wp-tooling/​src/​ci/​cli-support.js Centralizes flag parsing and GitHub output handling.
node-packages/​wp-tooling/​README.md Lists the command and library entry point.
node-packages/​wp-tooling/​eslint.config.js Excludes intentionally outdated test fixtures.
node-packages/​wp-tooling/​CHANGELOG.md Documents the new checker under Unreleased.
node-packages/​wp-tooling/​AGENTS.md Updates the documented module and command layout.

Review effort: Balanced

- A unique ID printed with an inline PHP tag (data-wp-watch--<?php echo
  $id; ?>, data-wp-on--click--<?= $id ?>) is reported. A closing quote
  only counts as a run-time ID when concatenation follows ('...--' . $id,
  '...--' + id): set_attribute( 'data-wp-watch--', ... ) is a directive
  with no ID, which still runs.
- negated-function also reads an escaped opening quote (echo
  "...=\\"!actions.x\\"") and spaces before an array's closing bracket.
  It reports only !actions.* and ns::!actions.*: the runtime reads the
  namespace first, so !ns::actions.* never resolved, before 7.2 too.
- router-state-navigation follows the name core/router's state is bound
  to (state: router, import { state as rs }, router.state, a direct
  store( 'core/router' ).state), so another store's state.navigation in
  the same file is no longer reported. Its message says to switch to
  .navigating once the plugin requires WordPress 7.2.
- An ignore pattern with the g or y flag is reset before each path, so a
  kept lastIndex no longer lets every other matching file through.
Copilot AI balanced review requested due to automatic review settings November 6, 2026 00:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The rules incorrectly flag non-router state and literal-valued directives while missing deprecated usage in valid unquoted HTML attributes, causing both false CI failures and undetected breakages.

No open issues 🎉

4 previously missed issues

In code that hasn't changed since last review

Medium severity Exclude literal-valued directives from this rule

node-packages/​wp-tooling/​src/​ci/​iapi-check/​rules.js:85

This also flags literal-valued directives: <li data-wp-key="!actions.item"> becomes an error even though Gutenberg assigns that value directly to vnode.key in hooks.tsx, without evaluating or negating a function. The key remains valid after the runtime change, so this can fail CI for unaffected markup. Exclude literal-valued directives, including data-wp-key and its three-hyphen unique-ID forms, from this rule and add a negative test.

Medium severity Detect deprecated directives with unquoted HTML values

node-packages/​wp-tooling/​src/​ci/​iapi-check/​rules.js:85

Unquoted attribute values are valid HTML, but the required opening quote makes <b data-wp-bind--hidden=!actions.isOpen> produce no findings. WordPress evaluates that value exactly like the quoted form, so this deprecated usage passes CI despite breaking in 7.2. Add a separate unquoted HTML = branch while retaining the quote requirement for PHP/JS/JSX expressions, and cover it with a regression test.

Medium severity Avoid treating non-router state as router state

node-packages/​wp-tooling/​src/​ci/​iapi-check/​rules.js:129

This fallback also flags state that is explicitly owned by another store. For example, const { actions } = store('core/router'); const { state } = store('acme/menu'); state.navigation.isOpen; creates no router-state head, so state is assumed to be router state and the menu access receives an incorrect warning/replacement. Avoid assuming state when it is bound to a non-router store (or require a proven router-state binding), and cover this actions-only router usage in the tests.

Medium severity Exclude member-access state references from router matches

node-packages/​wp-tooling/​src/​ci/​iapi-check/​rules.js:144

The identifier boundary allows a preceding dot, so with const { state } = store('core/router'), an unrelated menu.state.navigation.isOpen also matches as state.navigation. This reports another store's property as deprecated router state and recommends changing the wrong API. Exclude a preceding member-access dot from the boundary and add a regression test containing both accesses.

5 resolved since last review

Review effort: Balanced

The helper only escaped `$`. The names come from identifier matches,
so nothing else can occur today, but a full escape keeps it safe if the
binding pattern ever widens and clears the CodeQL alert.
# Conflicts:
#	node-packages/wp-tooling/CHANGELOG.md
# Conflicts:
#	node-packages/wp-tooling/CHANGELOG.md
continue;
}
const pattern = rule.patternFor ? rule.patternFor(body) : rule.pattern;
for (const match of body.matchAll(pattern)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exported rule regexes keep their search position between calls, so later scans miss findings

IAPI_RULES is exported, and its patterns are the same global (g) RegExp objects scanText uses. Object.freeze is shallow, and matchAll starts at the source regex's lastIndex, so a caller that runs .test() or .exec() on a rule pattern breaks later scans:

const { scanIapiText, IAPI_RULES } = require('@rtcamp/wp-tooling/ci');
const html = '<div data-wp-ignore>';
scanIapiText(html, 'a.html'); // 1 finding
IAPI_RULES.find((r) => r.id === 'ignore-directive').pattern.test(html); // lastIndex is now 19
scanIapiText(html, 'a.html'); // 0 findings

This is the same lastIndex bug resetAndTest() fixed for --ignore. Suggest building a fresh regex per call (new RegExp(p.source, p.flags)), or exporting copies.

let text;
try {
text = fs.readFileSync(path.join(root, file), 'utf8');
} catch (err) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One unreadable file aborts the scan, and --soft-fail turns that into a green job. An EACCES/EIO from readdirSync/readFileSync throws IapiCheckError, which drops every finding. Under --soft-fail, the shared workflow's default, the job exits 0 with a single ::warning::iapi-check: cannot read ...: no findings, no step outputs, no summary. A root-owned folder, such as a docker-mounted wp-env MySQL directory, is enough to trigger it.

Suggest warning per file, counting the unreadable entries, and carrying on with the scan.

*/
function isCommented(before) {
const lead = before.trimStart();
if (/^(?:\/\/|#(?!\[))/.test(lead)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isCommented() treats a leading # as a comment in every language, and a leading * as a block-comment line even when no block comment is open. Real code on those lines is skipped:

class A {
	#html = '<b data-wp-on--click--x="actions.y"></b>'; // priv.ts: private field, no findings
}
*load() { if ( state.navigation.hasStarted ) {} },                 // gen.js: generator method, no findings

Generator methods are the normal async-action form in IAPI stores. Suggest passing the extension in and applying # only to .php, and treating a line starting with * as a comment only when a /* block is actually open.

* still works. Placeholders such as `--[id]` in prose don't match.
*/
const LIFECYCLE_RE =
/(?<![\w-])data-wp-(init|watch|run)--(?:(\w[\w-]*)|(?=[{$%<]|['"`]\s*[.+]))/gi;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Directives after \n or \t escapes are missed. The (?<![\w-]) lookbehind on every directive pattern sees the n/t of an escape as a name character, so it rejects the match:

$html = "<div\n\tdata-wp-ignore>";   // no findings

The same applies to "\tdata-wp-watch--x=..." and '\ndata-wp-on--click--id'. Suggest a lookbehind that also accepts an escape, e.g. (?<=^|[^\w-]|\\[nrt]).

{
id: 'router-state-navigation',
severity: 'warning',
extensions: SCRIPT_EXTENSIONS,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Router state.navigation is missed in several common forms.

const { state: rs } = store< RouterStore >( 'core/router' );   // TS generic: regexes require `store\s*\(`
rs.navigation.hasStarted;                                       // no findings
const { state: { navigation } } = store( 'core/router' );      // nested destructuring: no findings
<div data-wp-class--loading="core/router::state.navigation.hasStarted">  <!-- PHP/HTML aren't scanned by this rule -->

Suggest allowing store\s*(?:<[^>]*>)?\s*\( in the router regexes, and scanning markup for core/router::state.navigation.

index: match.index,
text: match[0],
replacement: null,
message: `${match[0]} is removed in WordPress ${WP_VERSION}: the element and its descendants now hydrate like any other element. Remove the attribute.`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before 7.2, toVdom only honoured ignore when ignore && !island, so on the same element as data-wp-interactive it already hydrated. The message "the element and its descendants now hydrate" is wrong there, and with iapi-check-fail: true this fails CI on markup that behaves the same. The PR's own fixture blocks/legacy/render.php:8-9 is this case.

<div data-wp-interactive="acme" data-wp-ignore>  <!-- reported as an error at 1:33 -->

Suggest skipping the finding, or downgrading it to a warning, when data-wp-interactive is on the same tag.

const SOURCE_URL = 'https://github.com/WordPress/gutenberg/pull/84098';

/** Every extension a directive can be written in. */
const MARKUP_EXTENSIONS = Object.freeze([

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.mts and .cts aren't in MARKUP_EXTENSIONS or SCRIPT_EXTENSIONS, but detect-changes counts them in the js bucket ([cm]?[jt]s), so they trigger the job and are never scanned. A view.mts with 'data-wp-watch--' + id gives no findings, while the same code in view.ts is reported. Add 'mts' and 'cts' to both lists.

});
} catch (err) {
throw new IapiCheckError(
`cannot read directory "${path.join(root, dir)}" (${err.code || err.message})`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

node-packages/wp-tooling/AGENTS.md asks for error messages that say what was expected and what was received. This one, and cannot read "${path.join(root, file)}" at line 252, only give the error code. Add the expectation the way the --working-dir check does at line 165, e.g. (${err.code || err.message}); expected a readable directory.

Comment on lines +129 to +131
if (!heads.size) {
heads.add('state');
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the file has no router state binding, the fallback assumes state is the router's. It does this even when the file binds state to its own store, as long as the router is mentioned anywhere in the file:

import { actions } from '@wordpress/interactivity-router';
const { state } = store( 'acme/menu' );
state.navigation.isOpen; // router-state-navigation: "Read state.navigating instead"

The same happens with const { actions } = await import( '@wordpress/interactivity-router' ), the usual way to call actions.navigate, and when core/router only appears in a comment. ci-check-iapi.yml in rtCamp/wp-shared-workflows#67 runs this check by default, so these false warnings will show up on PRs.

Suggest skipping the fallback when state is destructured from a store other than core/router, and adding this mixed-store case as a regression test. This still keeps the fallback for const { state } = store<RouterStore>( 'core/router' ), which the destructure regexes don't parse.

/** `const { state } = store( 'acme/menu' )`: `state` belongs to another store. */
const OTHER_STORE_DESTRUCTURE_RE =
	/\{([^{}]*)\}\s*=\s*store\s*(?:<[^>]*>)?\s*\(\s*['"](?!core\/router['"])/g;
Suggested change
if (!heads.size) {
heads.add('state');
}
const stateIsOwn = [...text.matchAll(OTHER_STORE_DESTRUCTURE_RE)].some(
(match) => match[1].split(',').some((part) => part.trim() === 'state')
);
if (!heads.size && !stateIsOwn) {
heads.add('state');
}

Comment on lines +72 to +73
const EVENT_RE =
/(?<![\w-])data-wp-(on|on-window|on-document)--(\w+(?:-\w+)*)--(?:(\w[\w-]*)|(?=[{$%<]|['"`]\s*[.+]))/gi;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A trailing -- with no ID isn't flagged, but it breaks in 7.2

<button data-wp-on--click--="actions.toggle"> returns no findings. parseDirectiveName() gives it the suffix click--, both before and after WordPress/gutenberg#84098. The old on.ts cut that at -- (split( '--', 2 )[ 0 ]), so it listened for click. Now the whole suffix is the event name, so it listens for click-- and the handler never fires. The old runtime never warned about this either, because the empty ID is falsy. on-window and on-document behave the same way.

Lifecycle directives are different: data-wp-watch-- gets no suffix and still runs. So the LIFECYCLE_RE note ("a closing quote on its own is a directive with no ID, which still works") is right there. It doesn't hold for events, but EVENT_RE uses the same lookahead.

Suggest a third branch that matches a name ending at --:

Suggested change
const EVENT_RE =
/(?<![\w-])data-wp-(on|on-window|on-document)--(\w+(?:-\w+)*)--(?:(\w[\w-]*)|(?=[{$%<]|['"`]\s*[.+]))/gi;
const EVENT_RE =
/(?<![\w-])data-wp-(on|on-window|on-document)--(\w+(?:-\w+)*)--(?:(\w[\w-]*)|(?=[{$%<]|['"`]\s*[.+])|(?=([\s=>/'"`]|$)))/gi;

In describeEvent(), when match[4] !== undefined, use text.slice( 0, -2 ) as the replacement, with a message like:

data-wp-on--click-- ends with two hyphens. WordPress 7.2 reads click-- as the event name, so the handler no longer fires on click. Use data-wp-on--click, or data-wp-on--click--- if a unique ID follows.

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.

4 participants