From a77b291b3dcd1f4f520ccc6452e953367aa34348 Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Fri, 30 Oct 2026 00:10:42 +0530 Subject: [PATCH 1/4] refactor(ci): share flag parsing and GitHub output helpers 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. --- .../wp-tooling/src/ci/cli-support.js | 131 ++++++++++++++++++ .../wp-tooling/src/ci/coverage-gate/cli.js | 116 +++------------- .../wp-tooling/src/ci/coverage-gate/render.js | 24 +++- 3 files changed, 169 insertions(+), 102 deletions(-) create mode 100644 node-packages/wp-tooling/src/ci/cli-support.js diff --git a/node-packages/wp-tooling/src/ci/cli-support.js b/node-packages/wp-tooling/src/ci/cli-support.js new file mode 100644 index 0000000..d6f5a13 --- /dev/null +++ b/node-packages/wp-tooling/src/ci/cli-support.js @@ -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} valueFlags Flag → option key, for flags that take a value. + * @param {Object} 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, +}; diff --git a/node-packages/wp-tooling/src/ci/coverage-gate/cli.js b/node-packages/wp-tooling/src/ci/coverage-gate/cli.js index 4b30128..60f40ac 100644 --- a/node-packages/wp-tooling/src/ci/coverage-gate/cli.js +++ b/node-packages/wp-tooling/src/ci/coverage-gate/cli.js @@ -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'); @@ -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); } /** @@ -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' }; } /** @@ -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; } @@ -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)); @@ -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'); } diff --git a/node-packages/wp-tooling/src/ci/coverage-gate/render.js b/node-packages/wp-tooling/src/ci/coverage-gate/render.js index 63cfd3b..b3f67dd 100644 --- a/node-packages/wp-tooling/src/ci/coverage-gate/render.js +++ b/node-packages/wp-tooling/src/ci/coverage-gate/render.js @@ -260,17 +260,19 @@ function escapeProperty(value) { /** * Build one GitHub workflow command, e.g. `::warning file=a.php,line=3::msg`. * - * @param {string} level `notice`, `warning` or `error`. + * @param {string} level `notice`, `warning` or `error`. * @param {string} message * @param {Object} [location] - * @param {string} [location.file] Path relative to the repo root. Control - * characters are made visible, so a name - * carrying terminal escapes can't restyle the - * log — at the cost of not attaching to it. + * @param {string} [location.file] Path relative to the repo root. Control + * characters are made visible, so a name + * carrying terminal escapes can't restyle the + * log, at the cost of not attaching to it. * @param {number} [location.line] + * @param {number} [location.col] 1-based column; only meaningful with `line`. + * @param {string} [location.title] Annotation title, shown above the message. * @return {string} The command line. */ -function annotation(level, message, { file, line } = {}) { +function annotation(level, message, { file, line, col, title } = {}) { const props = []; if (file) { props.push(`file=${escapeProperty(printable(file))}`); @@ -278,6 +280,12 @@ function annotation(level, message, { file, line } = {}) { if (line) { props.push(`line=${line}`); } + if (col) { + props.push(`col=${col}`); + } + if (title) { + props.push(`title=${escapeProperty(printable(title))}`); + } const head = props.length > 0 ? `${level} ${props.join(',')}` : level; return `::${head}::${escapeData(message)}`; } @@ -370,9 +378,13 @@ function stepOutputs(result) { module.exports = { annotation, buildAnnotations, + codeSpan, + escapeTableCell, formatRanges, formatSkipSummary, formatSummary, formatText, + plural, + printable, stepOutputs, }; From 596cb81ad3bf75e3ff590aaedf75fdcfc053b723 Mon Sep 17 00:00:00 2001 From: Aryan Jasala Date: Fri, 30 Oct 2026 00:11:05 +0530 Subject: [PATCH 2/4] feat(iapi-check): flag Interactivity API usages that WordPress 7.2 removes 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. --- node-packages/wp-tooling/AGENTS.md | 16 +- node-packages/wp-tooling/CHANGELOG.md | 4 + node-packages/wp-tooling/README.md | 3 +- node-packages/wp-tooling/eslint.config.js | 5 + .../wp-tooling/src/ci/iapi-check/cli.js | 277 ++++++ .../wp-tooling/src/ci/iapi-check/errors.js | 19 + .../wp-tooling/src/ci/iapi-check/index.js | 43 + .../wp-tooling/src/ci/iapi-check/render.js | 195 ++++ .../wp-tooling/src/ci/iapi-check/rules.js | 246 +++++ .../wp-tooling/src/ci/iapi-check/scan.js | 257 +++++ node-packages/wp-tooling/src/ci/index.js | 10 + .../wp-tooling/src/cli/commands/iapi-check.js | 15 + .../iapi-check/project/assets/js/view.min.js | 1 + .../project/blocks/legacy/render.php | 18 + .../iapi-check/project/blocks/legacy/view.js | 14 + .../iapi-check/project/blocks/menu/view.js | 10 + .../project/blocks/modern/render.php | 25 + .../iapi-check/project/blocks/modern/view.js | 18 + .../iapi-check/project/patterns/hero.php | 12 + .../ci/fixtures/iapi-check/project/readme.txt | 2 + .../fixtures/iapi-check/project/src/Menu.tsx | 10 + .../ci/fixtures/iapi-check/project/style.css | 1 + .../iapi-check/project/templates/single.html | 3 + .../wp-tooling/tests/ci/iapi-check.test.js | 916 ++++++++++++++++++ .../wp-tooling/tests/cli/index.test.js | 18 + 25 files changed, 2131 insertions(+), 7 deletions(-) create mode 100644 node-packages/wp-tooling/src/ci/iapi-check/cli.js create mode 100644 node-packages/wp-tooling/src/ci/iapi-check/errors.js create mode 100644 node-packages/wp-tooling/src/ci/iapi-check/index.js create mode 100644 node-packages/wp-tooling/src/ci/iapi-check/render.js create mode 100644 node-packages/wp-tooling/src/ci/iapi-check/rules.js create mode 100644 node-packages/wp-tooling/src/ci/iapi-check/scan.js create mode 100644 node-packages/wp-tooling/src/cli/commands/iapi-check.js create mode 100644 node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/assets/js/view.min.js create mode 100644 node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/legacy/render.php create mode 100644 node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/legacy/view.js create mode 100644 node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/menu/view.js create mode 100644 node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/modern/render.php create mode 100644 node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/blocks/modern/view.js create mode 100644 node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/patterns/hero.php create mode 100644 node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/readme.txt create mode 100644 node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/src/Menu.tsx create mode 100644 node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/style.css create mode 100644 node-packages/wp-tooling/tests/ci/fixtures/iapi-check/project/templates/single.html create mode 100644 node-packages/wp-tooling/tests/ci/iapi-check.test.js 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 6448b6d..e4e44a8 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.*` resolves to `undefined` without calling the function). One warning rule: `router-state-navigation` (`state.navigation` from the `core/router` store, deprecated since WordPress 7.0), checked in JS/TS files that mention `core/router` or `interactivity-router`. 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', ... )`). 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 `, 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; + } + for (const match of body.matchAll(rule.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` + ); + } +} + +/** + * 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 && ignore.test(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..982be39 --- /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 ( +