From 345af3b3b10134ca7c9c9229aecff2264be60b30 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 14:27:12 +0800 Subject: [PATCH 01/21] fix(array-alignment): measure a long line's columns incrementally, not from the line start per item --- src/Linter/Rules/ArrayAlignmentRule.php | 34 +++++++++++++++++++------ 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/Linter/Rules/ArrayAlignmentRule.php b/src/Linter/Rules/ArrayAlignmentRule.php index 5cede176..9b767c95 100644 --- a/src/Linter/Rules/ArrayAlignmentRule.php +++ b/src/Linter/Rules/ArrayAlignmentRule.php @@ -222,21 +222,39 @@ private function fix(LintContext $context, array $item, int $spaces, string $cod ); } - /** The display width of the line's text up to $offset, tabs counted to the next multiple of 4. */ + /** + * The display width of the line's text up to $offset, tabs counted to the next multiple of 4. + * + * Items on one very long line ask for increasing offsets, so the last answer is kept and the + * next one continues from it instead of rescanning the line. + */ private static function width(SourceFile $file, int $offset): int { - // A negative offset searches backwards from that many bytes before the end: no copy of the text before it. - $newline = $offset === 0 - ? false - : strrpos($file->contents, needle: "\n", offset: $offset - strlen($file->contents) - 1); - $start = $newline === false ? 0 : $newline + 1; - $width = 0; - for ($i = $start; $i < $offset; ++$i) { + static $memo = null; + + if ( + $memo !== null + && $memo[0] === $file + && $memo[1] <= $offset + && !str_contains(substr($file->contents, $memo[1], $offset - $memo[1]), "\n") + ) { + [, $i, $width] = $memo; + } else { + // A negative offset searches backwards from that many bytes before the end: no copy of the text before it. + $newline = $offset === 0 + ? false + : strrpos($file->contents, needle: "\n", offset: $offset - strlen($file->contents) - 1); + $i = $newline === false ? 0 : $newline + 1; + $width = 0; + } + + for (; $i < $offset; ++$i) { // Characters, not bytes, as phpcs counts (a UTF-8 continuation byte is no column). $width += $file->contents[$i] === "\t" ? self::TAB_WIDTH - ($width % self::TAB_WIDTH) : (int) ((ord($file->contents[$i]) & 0xC0) !== 0x80); } + $memo = [$file, $offset, $width]; return $width; } From ec449a510b181deaefe6badf64c1a1c45910fc7f Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 14:28:28 +0800 Subject: [PATCH 02/21] docs(bench): rerun the bake-off on v1.6.0 --- bench/results/2026-10-20-bakeoff.md | 725 ++++++++++++++++++++++++++++ 1 file changed, 725 insertions(+) create mode 100644 bench/results/2026-10-20-bakeoff.md diff --git a/bench/results/2026-10-20-bakeoff.md b/bench/results/2026-10-20-bakeoff.md new file mode 100644 index 00000000..10bc4949 --- /dev/null +++ b/bench/results/2026-10-20-bakeoff.md @@ -0,0 +1,725 @@ +# Bake-off 2026-10-20: top 10 WordPress.org plugins + WordPress core + +`bench/bakeoff.sh` (phpcs WordPress-Extra vs mago + mago-wordpress), mean of 3 runs after a warm-up. +Versions: mago 1.57.0 / PHP_CodeSniffer version 3.13.6 (stable) by Squiz and PHPCSStandards, PHP 8.4.24, this package at list-20-g0342f01. + +## Results + +| Codebase | PHP files | phpcs `WordPress-Extra` | `mago lint` + extension | Speed-up | +|:---|---:|---:|---:|---:| +| WooCommerce | 3528 | 25.71 s | 14.55 s | 1.8× | +| WordPress core | 1868 | 18.66 s | 16.84 s | 1.1× | +| Elementor | 1460 | 10.55 s | 5.94 s | 1.8× | +| Google Site Kit | 1869 | 12.89 s | 6.26 s | 2.1× | +| Yoast SEO | 1511 | 9.71 s | 4.17 s | 2.3× | +| WPForms Lite | 963 | 8.22 s | 6.34 s | 1.3× | +| LiteSpeed Cache | 212 | 2.01 s | 2.41 s | 0.8× | +| WP Mail SMTP | 185 | 1.64 s | 1.83 s | 0.9× | +| All-in-One WP Migration | 147 | 1.24 s | 0.80 s | 1.5× | +| Contact Form 7 | 111 | 0.87 s | 1.00 s | 0.9× | +| Akismet | 29 | 0.46 s | 0.55 s | 0.8× | +| **Total** | **11883** | **91.96 s** | **60.69 s** | **1.5×** | + +## mago issue counts by rule, per codebase + +### WooCommerce + +``` +error[generic/disallow-space-indent]: 47092 +error[wordpress/valid-variable-name]: 13190 +error[wordpress/function-call-signature]: 12319 +warning[wordpress/array-alignment]: 6954 +error[wordpress/control-structure-spacing]: 5959 +error[wordpress/file-name]: 4536 +error[generic/function-comment]: 4230 +error[generic/disallow-short-array-syntax]: 4103 +error[generic/function-declaration-argument-spacing]: 3687 +error[generic/file-comment]: 2499 +error[wordpress/array-brace-spacing]: 2469 +error[generic/opening-function-brace-kernighan-ritchie]: 2337 +error[generic/doc-comment]: 1977 +error[wordpress/comma-after-last]: 1930 +warning[wordpress/direct-database-query]: 1756 +warning[wordpress/statement-alignment]: 1327 +error[wordpress/escape-output]: 1155 +error[wordpress/valid-function-name]: 859 +error[wordpress/operator-spacing]: 801 +error[wordpress/array-key-spacing-restrictions]: 774 +error[wordpress/yoda-conditions]: 750 +error[generic/inline-comment]: 583 +error[generic/arbitrary-parentheses-spacing]: 564 +error[generic/opening-brace-same-line]: 446 +error[wordpress/validated-sanitized-input]: 434 +error[wordpress/array-declaration-spacing]: 404 +warning[wordpress/nonce-verification-warning]: 363 +error[generic/concatenation-spacing]: 234 +warning[generic/no-reserved-keyword-parameter-names]: 229 +error[wordpress/prepared-sql]: 226 +warning[wordpress/capabilities-warning]: 189 +warning[generic/strict-comparisons]: 163 +warning[generic/unused-function-parameter]: 158 +error[generic/block-comment]: 135 +error[generic/function-comment-throw-tag]: 98 +error[wordpress/nonce-verification]: 98 +error[generic/double-quote-usage]: 93 +error[generic/multi-line-function-declaration]: 90 +warning[wordpress/strict-in-array]: 89 +warning[wordpress/alternative-functions]: 76 +error[generic/disallow-multiple-assignments]: 74 +error[generic/return-type-declaration]: 74 +warning[generic/disallow-standalone-post-increment-decrement]: 71 +warning[wordpress/slow-db-query]: 61 +error[generic/class-declaration]: 60 +error[wordpress/wp-date-time]: 52 +error[generic/variable-comment]: 46 +warning[wordpress/no-silenced-errors]: 43 +error[generic/dirname]: 39 +warning[generic/method-declaration-warning]: 39 +warning[wordpress/discouraged-wp-functions]: 39 +warning[generic/assignment-in-condition]: 37 +error[generic/increment-decrement-spacing]: 30 +error[generic/superfluous-whitespace]: 29 +error[wordpress/array-indentation]: 28 +error[wordpress/wp-deprecated-parameters]: 28 +error[generic/disallow-short-ternary]: 25 +error[generic/space-after-cast]: 25 +error[generic/disallow-lonely-if]: 22 +warning[generic/commented-out-code]: 19 +error[wordpress/cast-structure-spacing]: 19 +error[wordpress/wp-i18n]: 19 +error[generic/no-leading-backslash]: 18 +warning[generic/precision-alignment]: 18 +error[wordpress/global-variables-override]: 17 +error[generic/function-closing-brace]: 16 +error[generic/valid-logical-operators]: 15 +error[generic/method-scope]: 14 +warning[wordpress/safe-redirect]: 14 +error[generic/self-member-reference]: 12 +warning[generic/useless-overriding-method]: 12 +error[generic/disallow-inline-tabs]: 11 +error[generic/require-explicit-boolean-operator-precedence]: 11 +error[generic/end-file-newline]: 10 +error[wordpress/prefix-all-globals]: 10 +error[generic/scope-indent]: 8 +error[generic/valid-class-name]: 7 +warning[generic/else-if-declaration]: 6 +warning[generic/property-declaration-warning]: 6 +warning[wordpress/prepared-sql-placeholders-warning]: 6 +error[generic/no-echo-sprintf]: 5 +error[generic/embedded-php]: 4 +warning[generic/non-executable-code]: 4 +error[generic/semicolon-spacing]: 4 +error[generic/class-instantiation]: 3 +error[generic/forbidden-functions]: 3 +error[generic/one-object-structure-per-file]: 3 +error[generic/disallow-size-functions-in-loops]: 2 +error[generic/doc-comment-alignment]: 2 +error[generic/duplicate-array-key]: 2 +error[generic/including-file]: 2 +warning[wordpress/enqueued-resource-parameters]: 2 +warning[wordpress/get-meta-single]: 2 +error[wordpress/ini-set]: 2 +warning[wordpress/preg-quote-delimiter]: 2 +error[generic/closing-tag]: 1 +error[generic/deprecated-functions]: 1 +warning[generic/for-loop-with-test-function-call]: 1 +error[generic/function-call-argument-spacing]: 1 +error[generic/no-double-negative]: 1 +error[generic/upper-case-constant-name]: 1 +warning[wordpress/assignment-in-ternary-condition]: 1 +warning[wordpress/cron-interval]: 1 +warning[wordpress/ini-set-warning]: 1 +warning[wordpress/prepared-sql-unquoted-complex-placeholder]: 1 +warning[wordpress/valid-hook-name-warning]: 1 +error[wordpress/wp-deprecated-functions]: 1 +``` + +### WordPress core + +``` +error[generic/disallow-space-indent]: 79731 +error[wordpress/function-call-signature]: 49673 +error[wordpress/control-structure-spacing]: 18051 +error[wordpress/valid-variable-name]: 10073 +error[generic/arbitrary-parentheses-spacing]: 8783 +error[generic/inline-comment]: 6842 +error[generic/scope-indent]: 6796 +error[wordpress/escape-output]: 6604 +warning[wordpress/array-alignment]: 6337 +warning[wordpress/statement-alignment]: 6242 +error[generic/function-comment]: 6015 +error[wordpress/operator-spacing]: 5964 +error[wordpress/array-brace-spacing]: 5532 +error[generic/function-declaration-argument-spacing]: 4567 +error[wordpress/array-key-spacing-restrictions]: 4000 +error[wordpress/wp-i18n]: 3291 +warning[generic/precision-alignment]: 3243 +error[generic/doc-comment]: 2947 +error[wordpress/comma-after-last]: 2361 +error[wordpress/validated-sanitized-input]: 2222 +error[generic/opening-function-brace-kernighan-ritchie]: 2211 +error[generic/disallow-inline-tabs]: 2025 +error[generic/concatenation-spacing]: 1899 +error[wordpress/yoda-conditions]: 1829 +error[wordpress/array-declaration-spacing]: 1611 +warning[generic/strict-comparisons]: 1341 +error[wordpress/global-variables-override]: 1293 +error[generic/function-call-argument-spacing]: 1284 +error[generic/disallow-short-array-syntax]: 1223 +warning[wordpress/direct-database-query]: 989 +warning[wordpress/alternative-functions]: 946 +error[generic/double-quote-usage]: 933 +warning[wordpress/nonce-verification-warning]: 837 +error[wordpress/file-name]: 813 +warning[generic/commented-out-code]: 807 +error[wordpress/valid-function-name]: 707 +error[wordpress/array-indentation]: 569 +error[generic/file-comment]: 568 +error[generic/disallow-multiple-assignments]: 548 +warning[generic/assignment-in-condition]: 506 +error[generic/inline-control-structure]: 499 +error[generic/property-declaration]: 440 +warning[wordpress/discouraged-wp-functions]: 401 +error[generic/comma-spacing]: 397 +warning[wordpress/no-silenced-errors]: 390 +error[generic/opening-brace-same-line]: 366 +error[wordpress/nonce-verification]: 354 +error[generic/method-scope]: 329 +error[generic/control-signature]: 294 +error[generic/block-comment]: 272 +error[generic/function-comment-throw-tag]: 269 +warning[generic/unused-function-parameter]: 265 +error[generic/superfluous-whitespace]: 250 +error[wordpress/prepared-sql]: 237 +error[generic/self-member-reference]: 225 +error[wordpress/cast-structure-spacing]: 210 +error[generic/lowercase-constant]: 208 +warning[wordpress/safe-redirect]: 195 +warning[generic/no-reserved-keyword-parameter-names]: 192 +error[generic/multi-line-function-declaration]: 183 +warning[generic/method-declaration-warning]: 175 +warning[generic/disallow-standalone-post-increment-decrement]: 167 +error[generic/require-explicit-boolean-operator-precedence]: 146 +error[generic/disallow-lonely-if]: 118 +warning[generic/property-declaration-warning]: 110 +warning[generic/non-executable-code]: 107 +warning[wordpress/strict-in-array]: 103 +error[generic/increment-decrement-usage]: 96 +error[generic/empty-statement]: 88 +error[generic/disallow-size-functions-in-loops]: 81 +warning[wordpress/slow-db-query]: 79 +error[wordpress/wp-deprecated-functions]: 79 +error[generic/upper-case-constant-name]: 74 +error[generic/disallow-multiple-statements]: 66 +error[generic/valid-class-name]: 65 +error[generic/space-after-cast]: 62 +warning[generic/for-loop-with-test-function-call]: 57 +error[generic/valid-logical-operators]: 56 +warning[generic/else-if-declaration]: 54 +error[generic/doc-comment-alignment]: 53 +warning[wordpress/enqueued-resource-parameters]: 38 +warning[wordpress/discouraged-constants]: 36 +error[generic/one-object-structure-per-file]: 34 +warning[generic/unconditional-if-statement]: 34 +error[generic/class-declaration]: 32 +error[generic/dirname]: 31 +error[wordpress/db-restricted-functions]: 30 +error[generic/static-in-final-class]: 29 +error[generic/forbidden-functions]: 24 +error[generic/unnecessary-string-concat]: 22 +error[generic/switch-declaration]: 18 +warning[wordpress/get-meta-single]: 16 +error[wordpress/valid-post-type-slug]: 16 +error[generic/end-file-newline]: 14 +error[generic/disallow-short-ternary]: 13 +error[wordpress/enqueued-resources]: 12 +warning[wordpress/preg-quote-delimiter]: 12 +error[wordpress/ini-set]: 11 +error[generic/deprecated-functions]: 10 +error[generic/separate-functions-from-oo]: 10 +error[generic/variable-comment]: 10 +error[wordpress/prefix-all-globals]: 10 +error[generic/including-file]: 9 +warning[wordpress/valid-hook-name-warning]: 9 +error[generic/semicolon-spacing]: 8 +error[generic/trait-use-declaration]: 7 +error[wordpress/wp-date-time]: 7 +error[wordpress/wp-deprecated-classes]: 7 +error[wordpress/wp-deprecated-parameters]: 7 +error[generic/function-closing-brace]: 6 +error[wordpress/language-construct-spacing]: 6 +warning[generic/unnecessary-heredoc]: 5 +error[generic/class-comment]: 4 +error[generic/closing-tag]: 4 +error[generic/no-echo-sprintf]: 4 +error[generic/class-instantiation]: 3 +warning[generic/useless-overriding-method]: 3 +warning[wordpress/ini-set-warning]: 3 +error[wordpress/wp-deprecated-parameter-values]: 3 +error[wordpress/db-restricted-classes]: 2 +warning[wordpress/posts-per-page]: 2 +warning[wordpress/prepared-sql-placeholders-warning]: 2 +warning[generic/including-file-warning]: 1 +error[generic/increment-decrement-spacing]: 1 +error[generic/require-anon-class-parentheses]: 1 +warning[wordpress/assignment-in-ternary-condition]: 1 +error[wordpress/dont-extract]: 1 +error[wordpress/valid-hook-name]: 1 +``` + +### Elementor + +``` +warning[wordpress/array-alignment]: 16235 +error[generic/disallow-short-array-syntax]: 14793 +error[wordpress/function-call-signature]: 3768 +warning[wordpress/statement-alignment]: 3397 +error[generic/function-comment]: 1836 +error[generic/file-comment]: 1456 +error[wordpress/file-name]: 1377 +error[generic/doc-comment]: 1119 +error[generic/inline-comment]: 407 +warning[wordpress/valid-hook-name-warning]: 385 +error[generic/multi-line-function-declaration]: 177 +error[generic/embedded-php]: 165 +error[wordpress/prefix-all-globals]: 151 +warning[generic/unused-function-parameter]: 77 +warning[wordpress/nonce-verification-warning]: 60 +warning[wordpress/slow-db-query]: 58 +warning[wordpress/direct-database-query]: 57 +warning[wordpress/alternative-functions]: 53 +warning[wordpress/strict-in-array]: 51 +warning[generic/method-declaration-warning]: 46 +warning[wordpress/discouraged-wp-functions]: 34 +error[wordpress/valid-variable-name]: 32 +warning[generic/commented-out-code]: 27 +warning[generic/strict-comparisons]: 19 +warning[generic/disallow-standalone-post-increment-decrement]: 16 +warning[generic/property-declaration-warning]: 16 +warning[wordpress/no-silenced-errors]: 16 +error[wordpress/wp-date-time]: 13 +warning[generic/no-reserved-keyword-parameter-names]: 11 +error[generic/superfluous-whitespace]: 10 +error[wordpress/array-brace-spacing]: 8 +error[wordpress/yoda-conditions]: 8 +error[generic/double-quote-usage]: 5 +warning[generic/else-if-declaration]: 5 +warning[generic/assignment-in-condition]: 3 +error[generic/one-object-structure-per-file]: 3 +warning[wordpress/capital-p-dangit]: 3 +warning[wordpress/enqueued-resource-parameters]: 3 +error[wordpress/valid-function-name]: 3 +warning[generic/for-loop-with-test-function-call]: 2 +warning[wordpress/capabilities-warning]: 2 +warning[wordpress/safe-redirect]: 2 +warning[generic/constructor-destructor-return-warning]: 1 +error[generic/deprecated-functions]: 1 +error[generic/disallow-lonely-if]: 1 +error[generic/empty-statement]: 1 +error[generic/inline-control-structure]: 1 +warning[generic/non-executable-code]: 1 +error[generic/separate-functions-from-oo]: 1 +warning[wordpress/cron-interval]: 1 +error[wordpress/dont-extract]: 1 +warning[wordpress/get-meta-single]: 1 +``` + +### Google Site Kit + +``` +error[generic/disallow-space-indent]: 135765 +error[wordpress/function-call-signature]: 33183 +error[wordpress/valid-variable-name]: 16567 +error[wordpress/control-structure-spacing]: 12124 +error[generic/function-declaration-argument-spacing]: 10326 +error[wordpress/array-brace-spacing]: 8192 +error[generic/function-comment]: 8063 +error[generic/opening-function-brace-kernighan-ritchie]: 7963 +error[generic/disallow-short-array-syntax]: 5183 +warning[wordpress/statement-alignment]: 5142 +error[wordpress/file-name]: 3481 +error[generic/doc-comment]: 3465 +error[wordpress/operator-spacing]: 2991 +error[wordpress/array-key-spacing-restrictions]: 2285 +error[wordpress/array-declaration-spacing]: 2186 +error[generic/file-comment]: 2052 +error[wordpress/prefix-all-globals]: 2006 +error[generic/inline-comment]: 1608 +error[generic/opening-brace-same-line]: 1360 +error[generic/arbitrary-parentheses-spacing]: 1226 +error[wordpress/yoda-conditions]: 1103 +error[generic/block-comment]: 937 +error[wordpress/valid-function-name]: 823 +warning[generic/strict-comparisons]: 766 +error[generic/scope-indent]: 495 +warning[wordpress/array-alignment]: 472 +error[wordpress/escape-output]: 411 +error[generic/function-comment-throw-tag]: 402 +error[generic/double-quote-usage]: 324 +warning[generic/precision-alignment]: 243 +error[generic/unnecessary-string-concat]: 237 +warning[generic/no-reserved-keyword-parameter-names]: 235 +error[generic/disallow-multiple-assignments]: 231 +warning[wordpress/alternative-functions]: 207 +error[generic/upper-case-constant-name]: 202 +error[generic/disallow-short-ternary]: 169 +warning[generic/commented-out-code]: 150 +warning[generic/assignment-in-condition]: 130 +error[generic/superfluous-whitespace]: 107 +warning[wordpress/discouraged-wp-functions]: 91 +error[wordpress/cast-structure-spacing]: 90 +error[generic/disallow-size-functions-in-loops]: 86 +error[generic/valid-class-name]: 76 +warning[wordpress/strict-in-array]: 75 +warning[generic/disallow-standalone-post-increment-decrement]: 64 +warning[generic/for-loop-with-test-function-call]: 62 +warning[generic/unused-function-parameter]: 58 +error[generic/multi-line-function-declaration]: 48 +error[generic/trait-use-declaration]: 44 +error[wordpress/validated-sanitized-input]: 41 +error[generic/empty-statement]: 39 +warning[wordpress/no-silenced-errors]: 38 +error[generic/variable-comment]: 35 +error[generic/self-member-reference]: 31 +warning[generic/method-declaration-warning]: 25 +error[wordpress/wp-i18n]: 24 +error[generic/one-object-structure-per-file]: 22 +error[generic/require-explicit-boolean-operator-precedence]: 17 +error[generic/increment-decrement-usage]: 16 +error[generic/empty-catch-comment]: 14 +error[wordpress/array-indentation]: 12 +error[wordpress/comma-after-last]: 12 +warning[generic/else-if-declaration]: 10 +error[generic/comma-spacing]: 8 +error[generic/disallow-namespace-curly-brace-syntax]: 6 +error[generic/one-namespace-declaration-per-file]: 5 +error[wordpress/wp-date-time]: 5 +warning[generic/non-executable-code]: 4 +error[generic/static-in-final-class]: 4 +warning[generic/useless-overriding-method]: 4 +error[wordpress/nonce-verification]: 4 +warning[wordpress/nonce-verification-warning]: 4 +error[generic/dirname]: 2 +error[generic/disallow-namespace-without-name]: 2 +error[generic/doc-comment-alignment]: 2 +error[generic/file-header]: 2 +error[generic/switch-declaration]: 2 +warning[generic/unnecessary-heredoc]: 2 +warning[wordpress/ini-set-warning]: 2 +error[generic/deprecated-functions]: 1 +error[generic/namespace-declaration]: 1 +warning[generic/property-declaration-warning]: 1 +warning[generic/unconditional-if-statement]: 1 +warning[wordpress/preg-quote-delimiter]: 1 +``` + +### Yoast SEO + +``` +error[generic/disallow-space-indent]: 7009 +error[generic/disallow-short-array-syntax]: 4497 +error[generic/arbitrary-parentheses-spacing]: 3910 +error[wordpress/function-call-signature]: 1852 +error[wordpress/yoda-conditions]: 1578 +error[wordpress/prefix-all-globals]: 1512 +error[wordpress/file-name]: 1304 +error[generic/file-comment]: 1274 +error[generic/disallow-multiple-assignments]: 1149 +warning[wordpress/array-alignment]: 645 +error[generic/opening-function-brace-kernighan-ritchie]: 594 +error[wordpress/array-brace-spacing]: 370 +error[generic/control-signature]: 337 +warning[wordpress/direct-database-query]: 96 +error[wordpress/array-declaration-spacing]: 94 +error[wordpress/control-structure-spacing]: 76 +error[wordpress/escape-output]: 65 +error[wordpress/prepared-sql]: 54 +warning[wordpress/slow-db-query]: 22 +warning[wordpress/alternative-functions]: 20 +warning[wordpress/valid-hook-name-warning]: 19 +error[wordpress/valid-hook-name]: 18 +warning[wordpress/capabilities-warning]: 14 +warning[wordpress/nonce-verification-warning]: 14 +warning[wordpress/discouraged-wp-functions]: 9 +error[wordpress/validated-sanitized-input]: 8 +warning[generic/unused-function-parameter]: 6 +warning[wordpress/prepared-sql-placeholders-warning]: 5 +warning[generic/commented-out-code]: 3 +warning[generic/no-reserved-keyword-parameter-names]: 3 +error[wordpress/wp-i18n]: 3 +error[generic/doc-comment]: 2 +error[generic/function-comment]: 2 +error[wordpress/dont-extract]: 2 +error[wordpress/global-variables-override]: 2 +warning[generic/assignment-in-condition]: 1 +error[generic/block-comment]: 1 +error[generic/function-comment-throw-tag]: 1 +error[generic/opening-brace-same-line]: 1 +warning[generic/useless-overriding-method]: 1 +error[wordpress/prepared-sql-placeholders]: 1 +warning[wordpress/statement-alignment]: 1 +error[wordpress/valid-variable-name]: 1 +error[wordpress/wp-date-time]: 1 +``` + +### WPForms Lite + +``` +error[generic/disallow-short-array-syntax]: 10556 +error[wordpress/yoda-conditions]: 1623 +error[wordpress/file-name]: 1411 +error[generic/file-comment]: 967 +error[wordpress/prefix-all-globals]: 382 +error[generic/scope-indent]: 160 +error[generic/disallow-space-indent]: 104 +error[wordpress/escape-output]: 96 +error[generic/function-call-argument-spacing]: 38 +warning[generic/unused-function-parameter]: 38 +warning[generic/no-reserved-keyword-parameter-names]: 26 +error[generic/doc-comment]: 22 +warning[wordpress/array-alignment]: 22 +error[generic/inline-comment]: 20 +warning[generic/precision-alignment]: 11 +error[generic/function-comment]: 10 +error[generic/multi-line-function-declaration]: 10 +error[generic/class-declaration]: 7 +error[generic/no-leading-backslash]: 7 +warning[generic/commented-out-code]: 6 +error[generic/increment-decrement-spacing]: 4 +error[generic/switch-declaration]: 4 +error[generic/disallow-lonely-if]: 3 +error[generic/function-comment-throw-tag]: 2 +error[generic/no-echo-sprintf]: 2 +error[generic/class-instantiation]: 1 +error[generic/control-signature]: 1 +warning[generic/disallow-standalone-post-increment-decrement]: 1 +error[generic/empty-statement]: 1 +error[generic/function-closing-brace]: 1 +warning[generic/property-declaration-warning]: 1 +error[generic/separate-functions-from-oo]: 1 +error[generic/superfluous-whitespace]: 1 +error[wordpress/function-call-signature]: 1 +error[wordpress/global-variables-override]: 1 +error[wordpress/operator-spacing]: 1 +warning[wordpress/strict-in-array]: 1 +error[wordpress/valid-function-name]: 1 +``` + +### LiteSpeed Cache + +``` +error[wordpress/function-call-signature]: 1349 +error[generic/disallow-short-array-syntax]: 919 +error[wordpress/prefix-all-globals]: 738 +warning[wordpress/array-alignment]: 548 +error[generic/inline-comment]: 547 +error[generic/disallow-space-indent]: 435 +error[wordpress/control-structure-spacing]: 376 +error[wordpress/wp-i18n]: 278 +warning[generic/method-declaration-warning]: 272 +error[wordpress/file-name]: 272 +error[wordpress/operator-spacing]: 160 +warning[generic/property-declaration-warning]: 123 +error[generic/scope-indent]: 110 +error[wordpress/array-key-spacing-restrictions]: 78 +warning[wordpress/direct-database-query]: 78 +warning[generic/commented-out-code]: 49 +error[wordpress/array-declaration-spacing]: 13 +error[generic/superfluous-whitespace]: 11 +error[generic/arbitrary-parentheses-spacing]: 10 +error[wordpress/cast-structure-spacing]: 4 +error[generic/file-comment]: 1 +error[generic/multi-line-function-declaration]: 1 +``` + +### WP Mail SMTP + +``` +error[generic/disallow-short-array-syntax]: 2179 +error[generic/inline-comment]: 358 +error[wordpress/file-name]: 352 +error[wordpress/yoda-conditions]: 311 +error[wordpress/prefix-all-globals]: 223 +error[generic/file-comment]: 185 +warning[generic/precision-alignment]: 102 +error[generic/doc-comment]: 65 +error[generic/function-comment]: 55 +warning[wordpress/direct-database-query]: 37 +error[generic/disallow-space-indent]: 33 +error[wordpress/nonce-verification]: 18 +error[generic/disallow-lonely-if]: 17 +warning[wordpress/array-alignment]: 10 +warning[generic/no-reserved-keyword-parameter-names]: 9 +error[generic/multi-line-function-declaration]: 8 +warning[wordpress/alternative-functions]: 8 +warning[generic/unused-function-parameter]: 7 +error[wordpress/wp-i18n]: 6 +warning[generic/disallow-standalone-post-increment-decrement]: 5 +error[generic/block-comment]: 4 +error[generic/increment-decrement-spacing]: 4 +warning[wordpress/discouraged-wp-functions]: 4 +error[wordpress/escape-output]: 4 +error[wordpress/valid-variable-name]: 4 +error[wordpress/array-declaration-spacing]: 3 +error[wordpress/comma-after-last]: 3 +error[wordpress/control-structure-spacing]: 3 +warning[wordpress/nonce-verification-warning]: 3 +error[generic/class-declaration]: 2 +error[generic/dirname]: 2 +error[generic/end-file-newline]: 2 +error[generic/function-call-argument-spacing]: 2 +error[generic/scope-indent]: 2 +error[wordpress/array-brace-spacing]: 2 +warning[wordpress/capabilities-warning]: 2 +warning[wordpress/capital-p-dangit]: 2 +warning[wordpress/enqueued-resource-parameters]: 2 +error[wordpress/function-call-signature]: 2 +warning[wordpress/statement-alignment]: 2 +warning[generic/commented-out-code]: 1 +error[generic/empty-catch-comment]: 1 +error[generic/empty-statement]: 1 +error[generic/function-closing-brace]: 1 +error[generic/no-echo-sprintf]: 1 +warning[generic/strict-comparisons]: 1 +error[generic/superfluous-whitespace]: 1 +error[generic/switch-declaration]: 1 +warning[wordpress/no-silenced-errors]: 1 +``` + +### All-in-One WP Migration + +``` +error[generic/inline-comment]: 1237 +error[generic/function-comment]: 377 +error[generic/file-comment]: 147 +error[generic/disallow-multiple-assignments]: 138 +warning[generic/assignment-in-condition]: 136 +warning[wordpress/strict-in-array]: 94 +warning[wordpress/alternative-functions]: 78 +warning[wordpress/discouraged-wp-functions]: 57 +warning[wordpress/no-silenced-errors]: 39 +error[wordpress/yoda-conditions]: 35 +error[wordpress/array-declaration-spacing]: 31 +error[generic/one-object-structure-per-file]: 25 +error[wordpress/nonce-verification]: 21 +warning[wordpress/capabilities-warning]: 15 +error[wordpress/validated-sanitized-input]: 12 +warning[wordpress/nonce-verification-warning]: 11 +error[wordpress/prefix-all-globals]: 11 +error[wordpress/wp-i18n]: 9 +error[generic/disallow-size-functions-in-loops]: 8 +error[generic/doc-comment]: 8 +error[generic/empty-statement]: 8 +warning[generic/for-loop-with-test-function-call]: 8 +warning[generic/unused-function-parameter]: 8 +error[generic/function-comment-throw-tag]: 7 +warning[generic/disallow-standalone-post-increment-decrement]: 6 +error[generic/empty-catch-comment]: 5 +error[generic/self-member-reference]: 4 +warning[wordpress/direct-database-query]: 4 +error[generic/disallow-lonely-if]: 2 +warning[generic/no-reserved-keyword-parameter-names]: 2 +error[generic/require-explicit-boolean-operator-precedence]: 2 +error[wordpress/escape-output]: 2 +error[wordpress/file-name]: 2 +warning[wordpress/ini-set-warning]: 2 +warning[generic/useless-overriding-method]: 1 +error[wordpress/prepared-sql]: 1 +``` + +### Contact Form 7 + +``` +error[wordpress/function-call-signature]: 1740 +warning[wordpress/array-alignment]: 1003 +warning[wordpress/statement-alignment]: 337 +error[generic/function-comment]: 302 +error[wordpress/array-declaration-spacing]: 258 +error[generic/valid-logical-operators]: 240 +error[wordpress/array-key-spacing-restrictions]: 174 +error[generic/file-comment]: 117 +error[generic/disallow-multiple-assignments]: 88 +warning[generic/assignment-in-condition]: 79 +error[generic/doc-comment-alignment]: 68 +error[generic/scope-indent]: 63 +error[wordpress/file-name]: 60 +error[generic/class-declaration]: 54 +error[generic/inline-comment]: 53 +error[generic/upper-case-constant-name]: 49 +error[wordpress/prefix-all-globals]: 34 +warning[wordpress/capabilities-warning]: 33 +warning[generic/unused-function-parameter]: 31 +error[generic/embedded-php]: 20 +error[generic/no-echo-sprintf]: 15 +error[generic/one-object-structure-per-file]: 14 +warning[generic/no-reserved-keyword-parameter-names]: 13 +error[generic/double-quote-usage]: 11 +error[generic/increment-decrement-usage]: 9 +error[generic/separate-functions-from-oo]: 9 +warning[wordpress/direct-database-query]: 9 +error[wordpress/nonce-verification]: 9 +error[generic/require-explicit-boolean-operator-precedence]: 8 +warning[wordpress/discouraged-wp-functions]: 8 +error[generic/disallow-inline-tabs]: 7 +error[wordpress/control-structure-spacing]: 6 +warning[wordpress/no-silenced-errors]: 6 +warning[generic/commented-out-code]: 3 +error[generic/disallow-lonely-if]: 3 +error[wordpress/comma-after-last]: 3 +error[wordpress/validated-sanitized-input]: 3 +error[wordpress/array-brace-spacing]: 2 +warning[wordpress/enqueued-resource-parameters]: 2 +warning[wordpress/nonce-verification-warning]: 2 +warning[wordpress/slow-db-query]: 2 +error[wordpress/valid-variable-name]: 2 +error[generic/class-comment]: 1 +error[generic/disallow-short-ternary]: 1 +error[generic/disallow-size-functions-in-loops]: 1 +error[generic/doc-comment]: 1 +error[generic/empty-statement]: 1 +warning[generic/including-file-warning]: 1 +warning[generic/precision-alignment]: 1 +warning[generic/strict-comparisons]: 1 +error[wordpress/array-indentation]: 1 +warning[wordpress/get-meta-single]: 1 +warning[wordpress/preg-quote-delimiter]: 1 +warning[wordpress/safe-redirect]: 1 +warning[wordpress/strict-in-array]: 1 +error[wordpress/yoda-conditions]: 1 +``` + +### Akismet + +``` +error[wordpress/yoda-conditions]: 96 +warning[generic/strict-comparisons]: 88 +error[generic/inline-comment]: 79 +error[generic/function-comment]: 55 +warning[wordpress/direct-database-query]: 32 +error[wordpress/prefix-all-globals]: 32 +error[generic/file-comment]: 24 +warning[generic/unused-function-parameter]: 21 +warning[wordpress/strict-in-array]: 21 +warning[wordpress/nonce-verification-warning]: 19 +warning[generic/assignment-in-condition]: 10 +error[wordpress/file-name]: 10 +warning[wordpress/discouraged-wp-functions]: 8 +warning[generic/commented-out-code]: 4 +error[generic/disallow-multiple-assignments]: 4 +error[generic/doc-comment]: 4 +warning[wordpress/alternative-functions]: 4 +error[wordpress/prepared-sql]: 4 +error[generic/block-comment]: 3 +warning[wordpress/slow-db-query]: 3 +warning[generic/method-declaration-warning]: 2 +error[generic/no-double-negative]: 2 +warning[wordpress/enqueued-resource-parameters]: 2 +error[wordpress/nonce-verification]: 2 +warning[wordpress/prepared-sql-placeholders-warning]: 2 +error[generic/empty-statement]: 1 +error[generic/method-scope]: 1 +error[generic/separate-functions-from-oo]: 1 +error[wordpress/escape-output]: 1 +warning[wordpress/get-meta-single]: 1 +warning[wordpress/safe-redirect]: 1 +``` From 15ce9308130ee474df4d708db2342f12155a442f Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 15:32:26 +0800 Subject: [PATCH 03/21] perf(presets): start the worker with opcache.enable_cli=1, 8-15% less CPU on large plugins --- bench/lib.sh | 2 +- bin/generate-presets.php | 2 +- wordpress-core.mago.toml | 2 +- wordpress-extra.mago.toml | 2 +- wordpress.mago.toml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bench/lib.sh b/bench/lib.sh index 29994d47..9923c211 100644 --- a/bench/lib.sh +++ b/bench/lib.sh @@ -19,7 +19,7 @@ php-version = "8.2" paths = [{$json($project)}] excludes = ["**/vendor/**", "**/vendor_prefixed/**", "**/node_modules/**", "**/tests/**"] [extension-hosts.wordpress] -command = ["php", {$json($worker)}] +command = ["php", "-d", "opcache.enable_cli=1", {$json($worker)}] TOML); file_put_contents("$work/composer.json", json_encode( diff --git a/bin/generate-presets.php b/bin/generate-presets.php index acb73399..fd1588d8 100644 --- a/bin/generate-presets.php +++ b/bin/generate-presets.php @@ -166,7 +166,7 @@ TOML . $disabled($off) . << Date: Tue, 20 Oct 2026 15:53:27 +0800 Subject: [PATCH 04/21] perf(rules): find a line number with LineIndex, not by counting newlines from the start of the file --- src/Linter/Rules/ArrayAlignmentRule.php | 4 ++-- src/Linter/Rules/FileHeaderRule.php | 4 ++-- src/Linter/Rules/NonExecutableCodeRule.php | 4 ++-- src/Linter/Rules/SeparateFunctionsFromOORule.php | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Linter/Rules/ArrayAlignmentRule.php b/src/Linter/Rules/ArrayAlignmentRule.php index 9b767c95..e901f9e3 100644 --- a/src/Linter/Rules/ArrayAlignmentRule.php +++ b/src/Linter/Rules/ArrayAlignmentRule.php @@ -14,6 +14,7 @@ use Mago\Sdk\Syntax\Node; use Mago\Sdk\Syntax\NodeKind; use Mago\Sdk\Syntax\SourceFile; +use Rlorenzo\MagoWordPress\Internal\LineIndex; use Rlorenzo\MagoWordPress\Internal\Report; use function max; @@ -24,7 +25,6 @@ use function strpos; use function strrpos; use function substr; -use function substr_count; use function trim; /** @@ -261,6 +261,6 @@ private static function width(SourceFile $file, int $offset): int private static function line(SourceFile $file, int $offset): int { - return substr_count($file->contents, needle: "\n", offset: 0, length: $offset); + return LineIndex::of($file, $offset); } } diff --git a/src/Linter/Rules/FileHeaderRule.php b/src/Linter/Rules/FileHeaderRule.php index f83c57d1..013a4a73 100644 --- a/src/Linter/Rules/FileHeaderRule.php +++ b/src/Linter/Rules/FileHeaderRule.php @@ -14,6 +14,7 @@ use Mago\Sdk\Syntax\NodeKind; use Mago\Sdk\Syntax\SourceFile; use Mago\Sdk\Syntax\TriviaKind; +use Rlorenzo\MagoWordPress\Internal\LineIndex; use Rlorenzo\MagoWordPress\Internal\NodeIndex; use Rlorenzo\MagoWordPress\Internal\Report; @@ -25,7 +26,6 @@ use function preg_match; use function stripos; use function strtolower; -use function substr_count; use function usort; /** @@ -292,6 +292,6 @@ private static function following(SourceFile $file, array $statements, int $offs private static function line(SourceFile $file, int $offset): int { - return substr_count($file->contents, needle: "\n", offset: 0, length: $offset) + 1; + return LineIndex::of($file, $offset); } } diff --git a/src/Linter/Rules/NonExecutableCodeRule.php b/src/Linter/Rules/NonExecutableCodeRule.php index d6dbeb27..ccb94c7d 100644 --- a/src/Linter/Rules/NonExecutableCodeRule.php +++ b/src/Linter/Rules/NonExecutableCodeRule.php @@ -15,6 +15,7 @@ use Mago\Sdk\Syntax\SourceFile; use PhpToken; use Rlorenzo\MagoWordPress\Internal\FileCache; +use Rlorenzo\MagoWordPress\Internal\LineIndex; use Rlorenzo\MagoWordPress\Internal\NodeIndex; use Rlorenzo\MagoWordPress\Internal\Report; @@ -29,7 +30,6 @@ use function rtrim; use function strlen; use function strtolower; -use function substr_count; use function trim; /** @@ -328,6 +328,6 @@ private static function type(Node $terminal): string private static function line(SourceFile $file, int $offset): int { - return substr_count($file->contents, needle: "\n", offset: 0, length: $offset) + 1; + return LineIndex::of($file, $offset); } } diff --git a/src/Linter/Rules/SeparateFunctionsFromOORule.php b/src/Linter/Rules/SeparateFunctionsFromOORule.php index 0e9f7111..f5065f16 100644 --- a/src/Linter/Rules/SeparateFunctionsFromOORule.php +++ b/src/Linter/Rules/SeparateFunctionsFromOORule.php @@ -12,12 +12,12 @@ use Mago\Sdk\Syntax\Node; use Mago\Sdk\Syntax\NodeKind; use Mago\Sdk\Syntax\SourceFile; +use Rlorenzo\MagoWordPress\Internal\LineIndex; use Rlorenzo\MagoWordPress\Internal\NodeIndex; use Rlorenzo\MagoWordPress\Internal\Report; use function count; use function in_array; -use function substr_count; /** * Ports `Universal.Files.SeparateFunctionsFromOO`: a file that declares both functions and @@ -128,6 +128,6 @@ private static function keyword(SourceFile $file, Node $node): Node private static function line(SourceFile $file, Node $node): int { - return substr_count($file->contents, needle: "\n", offset: 0, length: $node->span->start) + 1; + return LineIndex::of($file, $node->span->start); } } From 7489e997e5f8e12ce6be6b73be669ace4fe70ec5 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 15:54:45 +0800 Subject: [PATCH 05/21] perf(leading-comment): look up the comment by end offset, drop the prefix copy and the per-character trim --- src/Internal/LeadingComment.php | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/Internal/LeadingComment.php b/src/Internal/LeadingComment.php index 5b643c7b..c10e065c 100644 --- a/src/Internal/LeadingComment.php +++ b/src/Internal/LeadingComment.php @@ -9,6 +9,8 @@ use Mago\Sdk\Syntax\Trivia; use Mago\Sdk\Syntax\TriviaKind; +use function strlen; +use function strpbrk; use function strrpos; use function substr; use function trim; @@ -33,29 +35,18 @@ public static function before(SourceFile $file, int $start): ?array { $text = $file->contents; $end = $start; - while ($end > 0 && trim($text[$end - 1]) === '') { + while ($end > 0 && strpbrk($text[$end - 1], " \t\n\r\v\0") !== false) { --$end; } - $found = null; - foreach (self::trivia($file) as $trivia) { - if ($trivia->span->end === $end) { - $found = $trivia; - - break; - } - - if ($trivia->span->end > $end) { - break; - } - } + $found = self::trivia($file)[$end] ?? null; if ($found === null) { return null; } if ($found->kind !== TriviaKind::DocBlockComment) { - $lineStart = strrpos(substr($text, 0, $found->span->start), "\n"); + $lineStart = Strings::lastNewlineBefore($text, $found->span->start); $from = $lineStart === false ? 0 : $lineStart + 1; if (trim(substr($text, $from, $found->span->start - $from)) !== '') { return null; @@ -65,14 +56,22 @@ public static function before(SourceFile $file, int $start): ?array return ['kind' => $found->kind, 'span' => $found->span]; } - /** @return list */ + /** + * The file's comments by the offset they end at. + * + * @return array + */ private static function trivia(SourceFile $file): array { return FileCache::remember($file, 'leading-comment-trivia', static function () use ($file): array { $trivia = $file->getTrivia(); usort($trivia, static fn($a, $b): int => $a->span->start <=> $b->span->start); + $byEnd = []; + foreach ($trivia as $piece) { + $byEnd[$piece->span->end] ??= $piece; + } - return $trivia; + return $byEnd; }); } } From ff2a2ff93e59c19d79d6f0ab26403ee6b8c17273 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 15:56:05 +0800 Subject: [PATCH 06/21] perf(strings): find the start of a line without copying the text before it --- src/Internal/BlankLinesAfterComment.php | 2 +- src/Internal/DocBlockScan.php | 4 ++-- src/Internal/ItemShift.php | 2 +- src/Internal/JoinedStatement.php | 2 +- src/Internal/ScopeColon.php | 2 +- src/Internal/Strings.php | 7 +++++++ src/Linter/Rules/DisallowLonelyIfRule.php | 3 ++- src/Linter/Rules/FunctionCommentRule.php | 3 ++- src/Linter/Rules/InlineControlStructureRule.php | 3 ++- 9 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/Internal/BlankLinesAfterComment.php b/src/Internal/BlankLinesAfterComment.php index f44c398c..3a926fda 100644 --- a/src/Internal/BlankLinesAfterComment.php +++ b/src/Internal/BlankLinesAfterComment.php @@ -39,7 +39,7 @@ public static function range(string $text, int $end, string $eol, array $closers return null; } - $lineStart = strrpos(substr($text, 0, $next), "\n"); + $lineStart = Strings::lastNewlineBefore($text, $next); $from = $end + (str_starts_with(substr($text, $end, 2), $eol) ? strlen($eol) : 0); $to = $lineStart === false ? 0 : $lineStart + 1; diff --git a/src/Internal/DocBlockScan.php b/src/Internal/DocBlockScan.php index 962d30be..a078f2b2 100644 --- a/src/Internal/DocBlockScan.php +++ b/src/Internal/DocBlockScan.php @@ -67,7 +67,7 @@ public static function previous(array $t, int $from, int $to, array $codes, bool /** @param array{code: string, text: string, offset: int, line: int} $token The column (1-based, a tab counting as one) of a token. */ public static function column(array $token, string $source): int { - $lineStart = strrpos(substr($source, 0, $token['offset']), "\n"); + $lineStart = Strings::lastNewlineBefore($source, $token['offset']); return $token['offset'] - ($lineStart === false ? -1 : $lineStart); } @@ -79,7 +79,7 @@ public static function column(array $token, string $source): int */ public static function visualColumn(array $token, string $source): int { - $lineStart = strrpos(substr($source, 0, $token['offset']), "\n"); + $lineStart = Strings::lastNewlineBefore($source, $token['offset']); $prefix = substr( $source, $lineStart === false ? 0 : $lineStart + 1, diff --git a/src/Internal/ItemShift.php b/src/Internal/ItemShift.php index 7be661ef..db072311 100644 --- a/src/Internal/ItemShift.php +++ b/src/Internal/ItemShift.php @@ -55,7 +55,7 @@ public static function edits(PhpcsTokens $tokens, string $text, int $start, int /** What the new item indent adds to the indent of the line the item starts on. */ private static function delta(string $text, int $start, string $indent): string { - $newline = strrpos(substr($text, 0, $start), "\n"); + $newline = Strings::lastNewlineBefore($text, $start); $lineStart = $newline === false ? 0 : $newline + 1; $lead = substr($text, $lineStart, $start - $lineStart); if (trim($lead) !== '') { diff --git a/src/Internal/JoinedStatement.php b/src/Internal/JoinedStatement.php index b434e0d1..a1c7f6ae 100644 --- a/src/Internal/JoinedStatement.php +++ b/src/Internal/JoinedStatement.php @@ -80,7 +80,7 @@ private static function strip(PhpcsTokens $tokens, string $text, int $start, int /** The whitespace at the start of the line that holds $offset. */ private static function indentOf(string $text, int $offset): string { - $newline = strrpos(substr($text, 0, $offset), "\n"); + $newline = Strings::lastNewlineBefore($text, $offset); $start = $newline === false ? 0 : $newline + 1; $line = substr($text, $start, $offset - $start); diff --git a/src/Internal/ScopeColon.php b/src/Internal/ScopeColon.php index 012b5689..e14ca65d 100644 --- a/src/Internal/ScopeColon.php +++ b/src/Internal/ScopeColon.php @@ -26,7 +26,7 @@ public static function at(string $text, int $colon): bool return false; } - $lineStart = strrpos(substr($text, 0, $colon), "\n"); + $lineStart = Strings::lastNewlineBefore($text, $colon); $line = substr( $text, $lineStart === false ? 0 : $lineStart + 1, diff --git a/src/Internal/Strings.php b/src/Internal/Strings.php index 0308e527..94f6509b 100644 --- a/src/Internal/Strings.php +++ b/src/Internal/Strings.php @@ -23,6 +23,7 @@ use function str_ends_with; use function str_starts_with; use function strlen; +use function strrpos; use function strtolower; use function strtr; use function substr; @@ -226,4 +227,10 @@ private static function codepointToUtf8(int $codepoint): string . chr(0x80 | ($codepoint & 0x3F)) ); } + + /** The offset of the last "\n" before $offset, as `strrpos(substr($text, 0, $offset), "\n")` finds it, without copying the text. */ + public static function lastNewlineBefore(string $text, int $offset): int|false + { + return $offset === 0 ? false : strrpos($text, "\n", $offset - strlen($text) - 1); + } } diff --git a/src/Linter/Rules/DisallowLonelyIfRule.php b/src/Linter/Rules/DisallowLonelyIfRule.php index 365c87b4..55658a65 100644 --- a/src/Linter/Rules/DisallowLonelyIfRule.php +++ b/src/Linter/Rules/DisallowLonelyIfRule.php @@ -15,6 +15,7 @@ use Mago\Sdk\Syntax\NodeKind; use Mago\Sdk\Syntax\SourceFile; use Rlorenzo\MagoWordPress\Internal\Report; +use Rlorenzo\MagoWordPress\Internal\Strings; use function array_slice; use function count; @@ -153,7 +154,7 @@ private static function fix(SourceFile $file, Node $else, Node $block, Node $inn $condition = rtrim($between($ifKeyword->span->end, $innerOpen->span->start)); // The inner `if` line goes whole when it has a line of its own. - $newlineBefore = strrpos(substr($contents, offset: 0, length: $inner->span->start), needle: "\n"); + $newlineBefore = Strings::lastNewlineBefore($contents, $inner->span->start); $lineStart = $newlineBefore === false ? 0 : $newlineBefore + 1; $from = $lineStart > $block->span->start ? $lineStart : $inner->span->start; $to = $innerOpen->span->start + 1; diff --git a/src/Linter/Rules/FunctionCommentRule.php b/src/Linter/Rules/FunctionCommentRule.php index 07a86953..e633d4bc 100644 --- a/src/Linter/Rules/FunctionCommentRule.php +++ b/src/Linter/Rules/FunctionCommentRule.php @@ -18,6 +18,7 @@ use Rlorenzo\MagoWordPress\Internal\DocComment; use Rlorenzo\MagoWordPress\Internal\LeadingComment; use Rlorenzo\MagoWordPress\Internal\Report; +use Rlorenzo\MagoWordPress\Internal\Strings; use function count; use function explode; @@ -168,7 +169,7 @@ private function spacingAfter(LintContext $context, Span $comment, Node $functio return; } - $lineStart = strrpos(substr($text, 0, $function->span->start), needle: "\n"); + $lineStart = Strings::lastNewlineBefore($text, $function->span->start); $declaration = $lineStart === false ? 0 : $lineStart + 1; $this->add( $context, diff --git a/src/Linter/Rules/InlineControlStructureRule.php b/src/Linter/Rules/InlineControlStructureRule.php index 61a2cc16..79f73184 100644 --- a/src/Linter/Rules/InlineControlStructureRule.php +++ b/src/Linter/Rules/InlineControlStructureRule.php @@ -15,6 +15,7 @@ use Mago\Sdk\Syntax\SourceFile; use PhpToken; use Rlorenzo\MagoWordPress\Internal\Report; +use Rlorenzo\MagoWordPress\Internal\Strings; use function array_filter; use function count; @@ -173,7 +174,7 @@ private static function fix(SourceFile $file, Node $node, Node $keyword, Node $b /** The keyword line's indentation, or null when the line opens with inline HTML or an open tag. */ private static function indent(string $contents, int $offset): ?string { - $lineStart = strrpos(substr($contents, offset: 0, length: $offset), needle: "\n"); + $lineStart = Strings::lastNewlineBefore($contents, $offset); $lineStart = $lineStart === false ? 0 : $lineStart + 1; $before = substr($contents, $lineStart, $offset - $lineStart); $indent = substr($before, offset: 0, length: strspn($before, characters: " \t")); From 308fc8512e8bb8ac3220455abd825020ddbbcb48 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 15:58:02 +0800 Subject: [PATCH 07/21] perf(empty-catch-comment): skip a file without a catch --- src/Linter/Rules/EmptyCatchCommentRule.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Linter/Rules/EmptyCatchCommentRule.php b/src/Linter/Rules/EmptyCatchCommentRule.php index ae67911f..5aa0f2d7 100644 --- a/src/Linter/Rules/EmptyCatchCommentRule.php +++ b/src/Linter/Rules/EmptyCatchCommentRule.php @@ -11,6 +11,7 @@ use Mago\Sdk\Reporting\Level; use Mago\Sdk\Span; use Mago\Sdk\Syntax\NodeKind; +use Rlorenzo\MagoWordPress\Internal\FileGate; use Rlorenzo\MagoWordPress\Internal\PhpcsTokens; use Rlorenzo\MagoWordPress\Internal\Report; @@ -26,9 +27,13 @@ final class EmptyCatchCommentRule implements Rule { private const SNIFF = 'Squiz.Commenting.EmptyCatchComment'; + private readonly FileGate $gate; + public function __construct( private readonly Report $report, - ) {} + ) { + $this->gate = FileGate::forWords(['catch']); + } public function getDefinition(): RuleDefinition { @@ -44,6 +49,10 @@ public function getDefinition(): RuleDefinition public function lint(LintContext $context): void { + if (!$this->gate->passes($context->file)) { + return; + } + $tokens = PhpcsTokens::of($context->file); $text = $context->file->contents; foreach ($tokens->codes as $index => $code) { From 490d3f3bda6f9c532dabcbc4ad1bfc80a4e323e4 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 16:00:17 +0800 Subject: [PATCH 08/21] perf(phpcs-comments): build the comment tokens once per file and number lines with LineIndex --- src/Internal/PhpcsComments.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Internal/PhpcsComments.php b/src/Internal/PhpcsComments.php index 761de485..dabae429 100644 --- a/src/Internal/PhpcsComments.php +++ b/src/Internal/PhpcsComments.php @@ -15,7 +15,6 @@ use function strlen; use function strtolower; use function substr; -use function substr_count; use function trim; use function usort; @@ -38,6 +37,12 @@ private function __construct() {} * @return list */ public static function tokens(SourceFile $file): array + { + return FileCache::remember($file, 'phpcs-comments', static fn(): array => self::build($file)); + } + + /** @return list */ + private static function build(SourceFile $file): array { $trivia = $file->getTrivia(); usort($trivia, static fn($a, $b): int => $a->span->start <=> $b->span->start); @@ -47,7 +52,7 @@ public static function tokens(SourceFile $file): array $start = $comment->span->start; $adjacent = $end !== null && trim(substr($file->contents, $end, $start - $end)) === ''; $end = $comment->span->end; - $line = substr_count($file->contents, "\n", 0, $start) + 1; + $line = LineIndex::of($file, $start); $text = $file->getText($comment->span); if ($comment->kind === TriviaKind::DocBlockComment) { $tokens[] = [ From 2bb6a3aa4019b57713afaef91f56e0072a513f09 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 16:02:29 +0800 Subject: [PATCH 09/21] perf(calls): unwrap only the requested argument in Calls::argument --- src/Internal/Calls.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Internal/Calls.php b/src/Internal/Calls.php index 91d7f465..f06a4fba 100644 --- a/src/Internal/Calls.php +++ b/src/Internal/Calls.php @@ -261,7 +261,19 @@ public static function argument( } } - return self::positionalArguments($file, $call)[$index] ?? null; + // The $index-th positional argument, unwrapped alone rather than every argument of the call. + $position = 0; + foreach ($call->arguments as $argument) { + if ($argument->unpacked || $argument->name !== null) { + continue; + } + + if ($position++ === $index) { + return Values::unwrap($file, $argument->value); + } + } + + return null; } /** From 0c6d706ae59234955d5eb79861297bb1473347f9 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 16:05:30 +0800 Subject: [PATCH 10/21] perf(escape-output): reuse the shared phpcs token stream instead of building it again --- src/Linter/Rules/EscapeOutputRule.php | 3 ++- src/WordPressExtension.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Linter/Rules/EscapeOutputRule.php b/src/Linter/Rules/EscapeOutputRule.php index 92fe5dc9..b7ecf237 100644 --- a/src/Linter/Rules/EscapeOutputRule.php +++ b/src/Linter/Rules/EscapeOutputRule.php @@ -15,6 +15,7 @@ use Rlorenzo\MagoWordPress\Internal\PhpcsToken; use Rlorenzo\MagoWordPress\Internal\PhpcsTokenStream; use Rlorenzo\MagoWordPress\Internal\Report; +use Rlorenzo\MagoWordPress\Internal\Spacing; use Rlorenzo\MagoWordPress\Internal\Strings; use Rlorenzo\MagoWordPress\Internal\WordPress\Lists; use Rlorenzo\MagoWordPress\Settings; @@ -212,7 +213,7 @@ public function lint(LintContext $context): void } $this->context = $context; - $this->tokens = PhpcsTokenStream::fromSource($context->file->contents); + $this->tokens = Spacing::tokens($context); $count = count($this->tokens); $ignoreTo = null; for ($ptr = 0; $ptr < $count; $ptr++) { diff --git a/src/WordPressExtension.php b/src/WordPressExtension.php index c84e01f0..aee94d9a 100644 --- a/src/WordPressExtension.php +++ b/src/WordPressExtension.php @@ -253,6 +253,8 @@ public static function create(?Settings $settings = null): Extension new NullableTypeDeclarationRule($report), new ScopeKeywordSpacingRule($report), new SpreadOperatorSpacingAfterRule($report), + // Reads the shared token stream too, so it runs before DisallowMultipleAssignmentsRule frees it. + new EscapeOutputRule($report, $settings), new DisallowMultipleAssignmentsRule($report), new GlobalVariablesOverrideRule($report, $settings), new EnqueuedResourceParametersRule($report), @@ -266,7 +268,6 @@ public static function create(?Settings $settings = null): Extension ), new PreparedSqlUnquotedComplexPlaceholderRule($report), new SafeRedirectRule($report), - new EscapeOutputRule($report, $settings), ...SplitRule::pair( new ValidHookNameRule($report, $settings), $report, From 2a5e033a8e259ab933dee85bd42df80a2ee6666c Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 16:12:04 +0800 Subject: [PATCH 11/21] bench: run phpcs with opcache.enable_cli=1 too, so both tools are measured alike --- bench/run.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bench/run.sh b/bench/run.sh index a29ef8f6..09a4a80c 100755 --- a/bench/run.sh +++ b/bench/run.sh @@ -8,7 +8,8 @@ # a markdown table; paste them into docs/benchmarks.md. # # Not an apples-to-apples rule comparison: phpcs runs the full WordPress-Extra ruleset -# (WPCS 3.4.1), while mago only runs this extension's rules. +# (WPCS 3.4.1), while mago only runs this extension's rules. Both run on PHP with opcache.enable_cli=1 +# (the worker command and phpcs alike), so neither tool pays for compiling its scripts on every run. set -euo pipefail [[ $# -ge 1 ]] || { echo "usage: bench/run.sh [] []" >&2; exit 1; } @@ -48,7 +49,7 @@ XML); # The worker discovers composer.json via getcwd(), so mago must run from $work. run_mago() { (cd "$work" && "$mago" --config "$work/mago.toml" lint --only "$codes" --reporting-format code-count); } run_phpcs() { - "$phpcs" --standard="$work/phpcs.xml" --extensions=php \ + php -d opcache.enable_cli=1 "$phpcs" --standard="$work/phpcs.xml" --extensions=php \ --ignore='*/vendor/*,*/vendor_prefixed/*,*/node_modules/*,*/tests/*' \ --parallel=8 -d memory_limit=2G --report=summary "$project" } From 89fe173a2de6101ad9d98f8797845f22b33150f2 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 16:15:31 +0800 Subject: [PATCH 12/21] refactor(rules): share one PhpToken::tokenize of the file between the rules that need it --- src/Internal/FileTokens.php | 25 +++++++++++++++++++ src/Internal/LineIndents.php | 7 +++--- src/Linter/Rules/ClassInstantiationRule.php | 3 ++- src/Linter/Rules/DisallowInlineTabsRule.php | 3 ++- .../Rules/DisallowMultipleStatementsRule.php | 3 ++- src/Linter/Rules/EndFileNewlineRule.php | 8 +++--- .../Rules/FunctionCallSignatureRule.php | 7 ++---- src/Linter/Rules/NonExecutableCodeRule.php | 7 ++---- .../Rules/SuperfluousWhitespaceRule.php | 3 ++- 9 files changed, 46 insertions(+), 20 deletions(-) create mode 100644 src/Internal/FileTokens.php diff --git a/src/Internal/FileTokens.php b/src/Internal/FileTokens.php new file mode 100644 index 00000000..8fe65dab --- /dev/null +++ b/src/Internal/FileTokens.php @@ -0,0 +1,25 @@ + */ + public static function raw(SourceFile $file): array + { + return FileCache::remember($file, 'php-tokens', static fn(): array => PhpToken::tokenize($file->contents)); + } +} diff --git a/src/Internal/LineIndents.php b/src/Internal/LineIndents.php index c882c4eb..e4f061d0 100644 --- a/src/Internal/LineIndents.php +++ b/src/Internal/LineIndents.php @@ -50,7 +50,7 @@ final class LineIndents */ public static function of(SourceFile $file): array { - return FileCache::remember($file, 'line-indents', static fn(): array => self::build($file->contents)); + return FileCache::remember($file, 'line-indents', static fn(): array => self::build($file)); } /** The display width of $whitespace starting at column 0, tabs going to the next tab stop. */ @@ -67,10 +67,11 @@ public static function width(string $whitespace): int /** * @return list */ - private static function build(string $text): array + private static function build(SourceFile $file): array { + $text = $file->contents; $found = []; - foreach (PhpToken::tokenize($text) as $token) { + foreach (FileTokens::raw($file) as $token) { $id = $token->id; if ($id === T_WHITESPACE) { self::lines($found, $text, $token, self::WHITESPACE, firstLineToo: true); diff --git a/src/Linter/Rules/ClassInstantiationRule.php b/src/Linter/Rules/ClassInstantiationRule.php index dd28c14b..d881f25f 100644 --- a/src/Linter/Rules/ClassInstantiationRule.php +++ b/src/Linter/Rules/ClassInstantiationRule.php @@ -13,6 +13,7 @@ use Mago\Sdk\Span; use Mago\Sdk\Syntax\NodeKind; use PhpToken; +use Rlorenzo\MagoWordPress\Internal\FileTokens; use Rlorenzo\MagoWordPress\Internal\Report; use function count; @@ -92,7 +93,7 @@ public function lint(LintContext $context): void } /** @var list $tokens */ - $tokens = PhpToken::tokenize($contents); + $tokens = FileTokens::raw($context->file); $count = count($tokens); foreach ($tokens as $index => $token) { if ($token->id !== T_NEW || $this->isIdentifier($tokens, $index)) { diff --git a/src/Linter/Rules/DisallowInlineTabsRule.php b/src/Linter/Rules/DisallowInlineTabsRule.php index b906a069..c126c7ef 100644 --- a/src/Linter/Rules/DisallowInlineTabsRule.php +++ b/src/Linter/Rules/DisallowInlineTabsRule.php @@ -13,6 +13,7 @@ use Mago\Sdk\Span; use Mago\Sdk\Syntax\NodeKind; use PhpToken; +use Rlorenzo\MagoWordPress\Internal\FileTokens; use Rlorenzo\MagoWordPress\Internal\Report; use function explode; @@ -75,7 +76,7 @@ public function lint(LintContext $context): void return; } - foreach (PhpToken::tokenize($text) as $token) { + foreach (FileTokens::raw($context->file) as $token) { if (!str_contains($token->text, "\t") || $token->id === T_INLINE_HTML) { continue; } diff --git a/src/Linter/Rules/DisallowMultipleStatementsRule.php b/src/Linter/Rules/DisallowMultipleStatementsRule.php index d2bac514..749fb3ab 100644 --- a/src/Linter/Rules/DisallowMultipleStatementsRule.php +++ b/src/Linter/Rules/DisallowMultipleStatementsRule.php @@ -13,6 +13,7 @@ use Mago\Sdk\Span; use Mago\Sdk\Syntax\NodeKind; use PhpToken; +use Rlorenzo\MagoWordPress\Internal\FileTokens; use Rlorenzo\MagoWordPress\Internal\Layout; use Rlorenzo\MagoWordPress\Internal\Report; @@ -61,7 +62,7 @@ public function lint(LintContext $context): void { $contents = $context->file->contents; /** @var list $tokens */ - $tokens = PhpToken::tokenize($contents); + $tokens = FileTokens::raw($context->file); $newline = strpos($contents, "\n"); $eol = $newline !== false && $newline > 0 && $contents[$newline - 1] === "\r" ? "\r\n" : "\n"; $count = count($tokens); diff --git a/src/Linter/Rules/EndFileNewlineRule.php b/src/Linter/Rules/EndFileNewlineRule.php index 73314c26..b283b5bb 100644 --- a/src/Linter/Rules/EndFileNewlineRule.php +++ b/src/Linter/Rules/EndFileNewlineRule.php @@ -12,7 +12,9 @@ use Mago\Sdk\Reporting\TextEdit; use Mago\Sdk\Span; use Mago\Sdk\Syntax\NodeKind; +use Mago\Sdk\Syntax\SourceFile; use PhpToken; +use Rlorenzo\MagoWordPress\Internal\FileTokens; use Rlorenzo\MagoWordPress\Internal\Report; use function rtrim; @@ -66,7 +68,7 @@ public function lint(LintContext $context): void return; } - if (str_contains($text, '?>') && self::hasHtmlAfterOpenTag($text)) { + if (str_contains($text, '?>') && self::hasHtmlAfterOpenTag($context->file)) { return; } @@ -105,10 +107,10 @@ private static function isBlank(string $char): bool return $char === ' ' || $char === "\t" || $char === "\r" || $char === "\n"; } - private static function hasHtmlAfterOpenTag(string $text): bool + private static function hasHtmlAfterOpenTag(SourceFile $file): bool { $open = false; - foreach (PhpToken::tokenize($text) as $token) { + foreach (FileTokens::raw($file) as $token) { if ($token->id === T_OPEN_TAG || $token->id === T_OPEN_TAG_WITH_ECHO) { $open = true; } elseif ($open && $token->id === T_INLINE_HTML) { diff --git a/src/Linter/Rules/FunctionCallSignatureRule.php b/src/Linter/Rules/FunctionCallSignatureRule.php index 4732465c..8a60526e 100644 --- a/src/Linter/Rules/FunctionCallSignatureRule.php +++ b/src/Linter/Rules/FunctionCallSignatureRule.php @@ -15,6 +15,7 @@ use Mago\Sdk\Syntax\SourceFile; use PhpToken; use Rlorenzo\MagoWordPress\Internal\FileCache; +use Rlorenzo\MagoWordPress\Internal\FileTokens; use Rlorenzo\MagoWordPress\Internal\FunctionTokens; use Rlorenzo\MagoWordPress\Internal\PhpcsToken; use Rlorenzo\MagoWordPress\Internal\PhpcsTokenStream; @@ -830,11 +831,7 @@ private static function bracket(PhpToken $token): int /** @return list */ private static function tokens(SourceFile $file): array { - return FileCache::remember( - $file, - 'call-signature-tokens', - static fn(): array => PhpToken::tokenize($file->contents), - ); + return FileTokens::raw($file); } /** @param list $tokens */ diff --git a/src/Linter/Rules/NonExecutableCodeRule.php b/src/Linter/Rules/NonExecutableCodeRule.php index ccb94c7d..d866a761 100644 --- a/src/Linter/Rules/NonExecutableCodeRule.php +++ b/src/Linter/Rules/NonExecutableCodeRule.php @@ -15,6 +15,7 @@ use Mago\Sdk\Syntax\SourceFile; use PhpToken; use Rlorenzo\MagoWordPress\Internal\FileCache; +use Rlorenzo\MagoWordPress\Internal\FileTokens; use Rlorenzo\MagoWordPress\Internal\LineIndex; use Rlorenzo\MagoWordPress\Internal\NodeIndex; use Rlorenzo\MagoWordPress\Internal\Report; @@ -259,11 +260,7 @@ private function unreachable(LintContext $context, Node $terminal, Node $stateme */ private static function tokens(SourceFile $file): array { - return FileCache::remember( - $file, - 'non-executable-code-tokens', - static fn(): array => PhpToken::tokenize($file->contents), - ); + return FileTokens::raw($file); } /** diff --git a/src/Linter/Rules/SuperfluousWhitespaceRule.php b/src/Linter/Rules/SuperfluousWhitespaceRule.php index ba7bcfcd..92346fe4 100644 --- a/src/Linter/Rules/SuperfluousWhitespaceRule.php +++ b/src/Linter/Rules/SuperfluousWhitespaceRule.php @@ -13,6 +13,7 @@ use Mago\Sdk\Span; use Mago\Sdk\Syntax\NodeKind; use PhpToken; +use Rlorenzo\MagoWordPress\Internal\FileTokens; use Rlorenzo\MagoWordPress\Internal\Report; use function array_pop; @@ -77,7 +78,7 @@ public function getDefinition(): RuleDefinition public function lint(LintContext $context): void { $text = $context->file->contents; - $tokens = PhpToken::tokenize($text); + $tokens = FileTokens::raw($context->file); $count = count($tokens); $this->fileEdges($context, $tokens, $count); From bf6a701cbbc37be2a806ed6329e309085e8a7580 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 16:17:56 +0800 Subject: [PATCH 13/21] perf(superfluous-whitespace): skip a file with no whitespace at its edges, before a line break or empty lines --- .../Rules/SuperfluousWhitespaceRule.php | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/Linter/Rules/SuperfluousWhitespaceRule.php b/src/Linter/Rules/SuperfluousWhitespaceRule.php index 92346fe4..6791e316 100644 --- a/src/Linter/Rules/SuperfluousWhitespaceRule.php +++ b/src/Linter/Rules/SuperfluousWhitespaceRule.php @@ -18,7 +18,9 @@ use function array_pop; use function count; +use function ctype_space; use function explode; +use function ord; use function preg_match; use function sprintf; use function strlen; @@ -78,12 +80,16 @@ public function getDefinition(): RuleDefinition public function lint(LintContext $context): void { $text = $context->file->contents; + // Empty lines are only looked for in a file with an empty line followed by a blank one. + $checkEmptyLines = preg_match('/\n\r?\n[ \t]*\r?\n/', $text) === 1; + if (!$checkEmptyLines && !self::mayHaveWhitespaceFindings($text)) { + return; + } + $tokens = FileTokens::raw($context->file); $count = count($tokens); $this->fileEdges($context, $tokens, $count); - // Empty lines are only looked for in a file with an empty line followed by a blank one. - $checkEmptyLines = preg_match('/\n\r?\n[ \t]*\r?\n/', $text) === 1; $stack = []; $pending = null; $depth = 0; @@ -146,6 +152,26 @@ public function lint(LintContext $context): void } } + /** + * Whether the text can have a finding other than empty lines: whitespace at either end of the file + * (a byte of 0x80 or more could start a Unicode space) or a space or tab before a line break or the + * end of the file. A file without any of them has nothing to tokenize for. + */ + private static function mayHaveWhitespaceFindings(string $text): bool + { + if ($text === '') { + return false; + } + + foreach ([$text[0], $text[strlen($text) - 1]] as $edge) { + if (ord($edge) >= 0x80 || ctype_space($edge)) { + return true; + } + } + + return preg_match('/[ \t](?:\r?\n|\z)/', $text) === 1; + } + /** * @param list $tokens */ From 1b685c317d428241b9dc37e499415ce80a9d588b Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 16:20:55 +0800 Subject: [PATCH 14/21] perf(class-comment, file-comment): number lines with LineIndex and drop PhpText::line --- src/Internal/PhpText.php | 7 ------- src/Linter/Rules/ClassCommentRule.php | 3 ++- src/Linter/Rules/FileCommentRule.php | 3 ++- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/Internal/PhpText.php b/src/Internal/PhpText.php index 35ac1b42..53752ad0 100644 --- a/src/Internal/PhpText.php +++ b/src/Internal/PhpText.php @@ -8,7 +8,6 @@ use function strlen; use function strpos; use function substr; -use function substr_count; /** * Scanning over PHP source text without tokenizing it. @@ -35,12 +34,6 @@ public static function skipAttributes(string $text, int $offset): int return PhpAttributes::skip($text, $offset); } - /** The zero-based line of an offset. */ - public static function line(string $text, int $offset): int - { - return substr_count($text, "\n", 0, $offset); - } - /** * Where the docblock opening at $start ends: the offset of its closing tag (which takes the * stars in front of the slash, as in `**\/`) and the offset after it. NULL when it never closes. diff --git a/src/Linter/Rules/ClassCommentRule.php b/src/Linter/Rules/ClassCommentRule.php index 784ce3cc..efea7820 100644 --- a/src/Linter/Rules/ClassCommentRule.php +++ b/src/Linter/Rules/ClassCommentRule.php @@ -13,6 +13,7 @@ use Mago\Sdk\Syntax\NodeKind; use Mago\Sdk\Syntax\TriviaKind; use Rlorenzo\MagoWordPress\Internal\LeadingComment; +use Rlorenzo\MagoWordPress\Internal\LineIndex; use Rlorenzo\MagoWordPress\Internal\PhpText; use Rlorenzo\MagoWordPress\Internal\Report; @@ -67,7 +68,7 @@ public function lint(LintContext $context): void } } - if (PhpText::line($text, self::tokenAbove($text, $keyword) - 1) === (PhpText::line($text, $keyword) - 1)) { + if (LineIndex::of($file, self::tokenAbove($text, $keyword) - 1) === (LineIndex::of($file, $keyword) - 1)) { return; } diff --git a/src/Linter/Rules/FileCommentRule.php b/src/Linter/Rules/FileCommentRule.php index 4beae68d..6add5d5f 100644 --- a/src/Linter/Rules/FileCommentRule.php +++ b/src/Linter/Rules/FileCommentRule.php @@ -13,6 +13,7 @@ use Mago\Sdk\Syntax\NodeKind; use Rlorenzo\MagoWordPress\Internal\DocComment; use Rlorenzo\MagoWordPress\Internal\FileCommentTags; +use Rlorenzo\MagoWordPress\Internal\LineIndex; use Rlorenzo\MagoWordPress\Internal\NodeIndex; use Rlorenzo\MagoWordPress\Internal\PhpText; use Rlorenzo\MagoWordPress\Internal\Report; @@ -113,7 +114,7 @@ private function check(LintContext $context, Span $open): void return; } - $line = static fn(int $offset): int => PhpText::line($text, $offset); + $line = static fn(int $offset): int => LineIndex::of($context->file, $offset); if ($line($start) > ($line($open->start) + 1)) { $this->add($context, 'There must be no blank lines before the file comment', $open, 'SpacingAfterOpen'); } From 4f08d52a4facc906ef8562bdf26a684a8ed128bf Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 16:52:06 +0800 Subject: [PATCH 15/21] docs(bench): rerun the bake-off with the worker speed-ups and phpcs on opcache --- bench/results/2026-10-20-bakeoff.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/bench/results/2026-10-20-bakeoff.md b/bench/results/2026-10-20-bakeoff.md index 10bc4949..fadcbdd1 100644 --- a/bench/results/2026-10-20-bakeoff.md +++ b/bench/results/2026-10-20-bakeoff.md @@ -1,24 +1,24 @@ # Bake-off 2026-10-20: top 10 WordPress.org plugins + WordPress core `bench/bakeoff.sh` (phpcs WordPress-Extra vs mago + mago-wordpress), mean of 3 runs after a warm-up. -Versions: mago 1.57.0 / PHP_CodeSniffer version 3.13.6 (stable) by Squiz and PHPCSStandards, PHP 8.4.24, this package at list-20-g0342f01. +Versions: mago 1.57.0 / PHP_CodeSniffer version 3.13.6 (stable) by Squiz and PHPCSStandards, PHP 8.4.24, this package at perf/worker-speedups (1b685c3). ## Results | Codebase | PHP files | phpcs `WordPress-Extra` | `mago lint` + extension | Speed-up | |:---|---:|---:|---:|---:| -| WooCommerce | 3528 | 25.71 s | 14.55 s | 1.8× | -| WordPress core | 1868 | 18.66 s | 16.84 s | 1.1× | -| Elementor | 1460 | 10.55 s | 5.94 s | 1.8× | -| Google Site Kit | 1869 | 12.89 s | 6.26 s | 2.1× | -| Yoast SEO | 1511 | 9.71 s | 4.17 s | 2.3× | -| WPForms Lite | 963 | 8.22 s | 6.34 s | 1.3× | -| LiteSpeed Cache | 212 | 2.01 s | 2.41 s | 0.8× | -| WP Mail SMTP | 185 | 1.64 s | 1.83 s | 0.9× | -| All-in-One WP Migration | 147 | 1.24 s | 0.80 s | 1.5× | -| Contact Form 7 | 111 | 0.87 s | 1.00 s | 0.9× | -| Akismet | 29 | 0.46 s | 0.55 s | 0.8× | -| **Total** | **11883** | **91.96 s** | **60.69 s** | **1.5×** | +| WooCommerce | 3528 | 23.50 s | 12.37 s | 1.9× | +| WordPress core | 1868 | 18.28 s | 14.41 s | 1.3× | +| Elementor | 1460 | 10.64 s | 5.30 s | 2.0× | +| Google Site Kit | 1869 | 12.36 s | 5.45 s | 2.3× | +| Yoast SEO | 1511 | 9.71 s | 3.86 s | 2.5× | +| WPForms Lite | 963 | 8.28 s | 5.71 s | 1.5× | +| LiteSpeed Cache | 212 | 2.10 s | 2.34 s | 0.9× | +| WP Mail SMTP | 185 | 1.80 s | 1.77 s | 1.0× | +| All-in-One WP Migration | 147 | 1.27 s | 0.85 s | 1.5× | +| Contact Form 7 | 111 | 1.07 s | 0.97 s | 1.1× | +| Akismet | 29 | 0.46 s | 0.52 s | 0.9× | +| **Total** | **11883** | **89.47 s** | **53.55 s** | **1.7×** | ## mago issue counts by rule, per codebase From 36ed029437a76e7d8904b35fd95570e826e37fcd Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 17:55:22 +0800 Subject: [PATCH 16/21] bench: compare phpcs WordPress with the extension's default rules, which port 183 of its 186 sniffs --- bench/bakeoff.sh | 4 ++-- bench/run.sh | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/bench/bakeoff.sh b/bench/bakeoff.sh index 6e33d9e5..b2b4e784 100755 --- a/bench/bakeoff.sh +++ b/bench/bakeoff.sh @@ -51,13 +51,13 @@ fi first=$(ls "$out"/*.out | head -1) echo "# Bake-off $(basename "$out"): top 10 WordPress.org plugins + WordPress core" echo -echo "\`bench/bakeoff.sh\` (phpcs WordPress-Extra vs mago + mago-wordpress), mean of 3 runs after a warm-up." +echo "\`bench/bakeoff.sh\` (phpcs WordPress vs mago + mago-wordpress), mean of 3 runs after a warm-up." [[ -f "$out/versions.txt" ]] || { echo "error: $out/versions.txt is missing; run the benchmarks first" >&2; exit 1; } echo "Versions: $(grep -m1 "^mago " "$first"), $(cat "$out/versions.txt")." echo echo "## Results" echo -echo "| Codebase | PHP files | phpcs \`WordPress-Extra\` | \`mago lint\` + extension | Speed-up |" +echo "| Codebase | PHP files | phpcs \`WordPress\` | \`mago lint\` + extension | Speed-up |" echo "|:---|---:|---:|---:|---:|" tf=0; tp=0; tm=0 for entry in "${codebases[@]}"; do diff --git a/bench/run.sh b/bench/run.sh index 09a4a80c..11ac635f 100755 --- a/bench/run.sh +++ b/bench/run.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Benchmarks phpcs (WordPress-Extra) against mago + this extension on a WordPress codebase. +# Benchmarks phpcs (WordPress) against mago + this extension on a WordPress codebase. # # bench/run.sh [] [] # @@ -7,9 +7,11 @@ # in $PHPCS (defaults to vendor/bin/phpcs of this package if installed). Results print as # a markdown table; paste them into docs/benchmarks.md. # -# Not an apples-to-apples rule comparison: phpcs runs the full WordPress-Extra ruleset -# (WPCS 3.4.1), while mago only runs this extension's rules. Both run on PHP with opcache.enable_cli=1 -# (the worker command and phpcs alike), so neither tool pays for compiling its scripts on every run. +# Rule coverage: phpcs runs the full WordPress standard (WPCS 3.4.1, 186 sniffs), and the mago side +# runs the extension's default rules, which port 183 of those sniffs. The three left out are +# Generic.PHP.Syntax (php -l), Generic.Classes.DuplicateClassName and WordPress.Utils.I18nTextDomainFixer. +# Both run on PHP with opcache.enable_cli=1 (the worker command and phpcs alike), so neither tool +# pays for compiling its scripts on every run. set -euo pipefail [[ $# -ge 1 ]] || { echo "usage: bench/run.sh [] []" >&2; exit 1; } @@ -38,7 +40,7 @@ $xml = fn (string $value): string => htmlspecialchars($value, ENT_QUOTES | ENT_X file_put_contents("$work/phpcs.xml", << - + @@ -110,5 +112,5 @@ timed() { # label, tool } echo "| Tool | Mean of 3 runs |" echo "|:---|---:|" -timed "phpcs WordPress-Extra (WPCS 3.4.1, --parallel=8)" phpcs +timed "phpcs WordPress (WPCS 3.4.1, --parallel=8)" phpcs timed "mago + mago-wordpress" mago From 4f3f1d837e6e62617359d819788acc4b55d05f81 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 18:08:05 +0800 Subject: [PATCH 17/21] =?UTF-8?q?docs:=20update=20the=20benchmarks=20to=20?= =?UTF-8?q?the=202026-10-20=20bake-off=20(1.7=C3=97=20overall)=20and=20reg?= =?UTF-8?q?enerate=20the=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- bench/results/2026-10-20-bakeoff.md | 30 ++++---- docs/benchmarks-dark.svg | 107 ++++++++++++++-------------- docs/benchmarks-light.svg | 107 ++++++++++++++-------------- docs/benchmarks.md | 55 +++++++------- 5 files changed, 159 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index 9ba7308d..6652e6b3 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,14 @@ WordPress Coding Standards for [Mago](https://github.com/carthage-software/mago) ports the WPCS lint sniffs to Mago's linter, so a plugin or theme is checked in seconds, not minutes. -## 2–12× faster than phpcs +## About 2× faster than phpcs on large plugins - Bar chart: phpcs WordPress-Extra vs mago + mago-wordpress lint time on the top 10 WordPress.org plugins by active installs and WordPress core, from WooCommerce (33.40 s vs 3.32 s) down to Akismet (0.52 s vs 0.26 s) + Bar chart: phpcs WordPress vs mago + mago-wordpress lint time on the top 10 WordPress.org plugins by active installs and WordPress core, from WooCommerce (25.68 s vs 12.56 s) down to Akismet (0.47 s vs 0.61 s) -8.1× faster overall on the top 10 WordPress.org plugins plus WordPress core. Method and numbers: +1.7× faster overall on the top 10 WordPress.org plugins plus WordPress core: 2–2.6× on the large plugins, about even on the small ones. Method and numbers: [Benchmarks](docs/benchmarks.md). ## Install diff --git a/bench/results/2026-10-20-bakeoff.md b/bench/results/2026-10-20-bakeoff.md index fadcbdd1..a2bb6d33 100644 --- a/bench/results/2026-10-20-bakeoff.md +++ b/bench/results/2026-10-20-bakeoff.md @@ -1,24 +1,24 @@ # Bake-off 2026-10-20: top 10 WordPress.org plugins + WordPress core -`bench/bakeoff.sh` (phpcs WordPress-Extra vs mago + mago-wordpress), mean of 3 runs after a warm-up. -Versions: mago 1.57.0 / PHP_CodeSniffer version 3.13.6 (stable) by Squiz and PHPCSStandards, PHP 8.4.24, this package at perf/worker-speedups (1b685c3). +`bench/bakeoff.sh` (phpcs WordPress vs mago + mago-wordpress), mean of 3 runs after a warm-up. +Versions: mago 1.57.0 / PHP_CodeSniffer version 3.13.6 (stable) by Squiz and PHPCSStandards, PHP 8.4.24, this package at perf/worker-speedups (36ed029). ## Results -| Codebase | PHP files | phpcs `WordPress-Extra` | `mago lint` + extension | Speed-up | +| Codebase | PHP files | phpcs `WordPress` | `mago lint` + extension | Speed-up | |:---|---:|---:|---:|---:| -| WooCommerce | 3528 | 23.50 s | 12.37 s | 1.9× | -| WordPress core | 1868 | 18.28 s | 14.41 s | 1.3× | -| Elementor | 1460 | 10.64 s | 5.30 s | 2.0× | -| Google Site Kit | 1869 | 12.36 s | 5.45 s | 2.3× | -| Yoast SEO | 1511 | 9.71 s | 3.86 s | 2.5× | -| WPForms Lite | 963 | 8.28 s | 5.71 s | 1.5× | -| LiteSpeed Cache | 212 | 2.10 s | 2.34 s | 0.9× | -| WP Mail SMTP | 185 | 1.80 s | 1.77 s | 1.0× | -| All-in-One WP Migration | 147 | 1.27 s | 0.85 s | 1.5× | -| Contact Form 7 | 111 | 1.07 s | 0.97 s | 1.1× | -| Akismet | 29 | 0.46 s | 0.52 s | 0.9× | -| **Total** | **11883** | **89.47 s** | **53.55 s** | **1.7×** | +| WooCommerce | 3528 | 25.68 s | 12.56 s | 2.0× | +| WordPress core | 1868 | 18.20 s | 14.27 s | 1.3× | +| Elementor | 1460 | 10.66 s | 5.18 s | 2.1× | +| Google Site Kit | 1869 | 12.62 s | 5.57 s | 2.3× | +| Yoast SEO | 1511 | 9.90 s | 3.86 s | 2.6× | +| WPForms Lite | 963 | 8.03 s | 5.39 s | 1.5× | +| LiteSpeed Cache | 212 | 1.96 s | 2.11 s | 0.9× | +| WP Mail SMTP | 185 | 1.73 s | 1.67 s | 1.0× | +| All-in-One WP Migration | 147 | 1.27 s | 0.73 s | 1.7× | +| Contact Form 7 | 111 | 0.91 s | 0.91 s | 1.0× | +| Akismet | 29 | 0.47 s | 0.61 s | 0.8× | +| **Total** | **11883** | **91.43 s** | **52.86 s** | **1.7×** | ## mago issue counts by rule, per codebase diff --git a/docs/benchmarks-dark.svg b/docs/benchmarks-dark.svg index efb9564c..910e95bd 100644 --- a/docs/benchmarks-dark.svg +++ b/docs/benchmarks-dark.svg @@ -1,72 +1,75 @@ Lint time, seconds (lower is better) -phpcs WordPress-Extra +phpcs WordPress mago + mago-wordpress 0 - -10 + +5 + +10 -20 - -30 +15 + +20 + +25 -40 +30 WooCommerce - -33.40 s - -3.32 s (10× faster) + +25.68 s + +12.56 s (2.0× faster) WordPress core - -25.89 s - -3.78 s (7× faster) + +18.20 s + +14.27 s (1.3× faster) Elementor - -13.02 s - -1.35 s (10× faster) + +10.66 s + +5.18 s (2.1× faster) Google Site Kit - -15.92 s - -1.33 s (12× faster) + +12.62 s + +5.57 s (2.3× faster) Yoast SEO - -12.32 s - -1.25 s (10× faster) + +9.90 s + +3.86 s (2.6× faster) WPForms Lite - -11.11 s - -1.68 s (7× faster) + +8.03 s + +5.39 s (1.5× faster) LiteSpeed Cache - -2.53 s - -0.62 s (4× faster) + +1.96 s + +2.11 s (1.1× slower) WP Mail SMTP - -2.35 s - -0.56 s (4× faster) + +1.73 s + +1.67 s (about the same) All-in-One WP Migration - -1.67 s - -0.35 s (5× faster) + +1.27 s + +0.73 s (1.7× faster) Contact Form 7 - -1.40 s - -0.34 s (4× faster) + +0.91 s + +0.91 s (about the same) Akismet - -0.52 s - -0.26 s (2× faster) - + +0.47 s + +0.61 s (1.3× slower) diff --git a/docs/benchmarks-light.svg b/docs/benchmarks-light.svg index 5bf1e5a9..8d319710 100644 --- a/docs/benchmarks-light.svg +++ b/docs/benchmarks-light.svg @@ -1,72 +1,75 @@ Lint time, seconds (lower is better) -phpcs WordPress-Extra +phpcs WordPress mago + mago-wordpress 0 - -10 + +5 + +10 -20 - -30 +15 + +20 + +25 -40 +30 WooCommerce - -33.40 s - -3.32 s (10× faster) + +25.68 s + +12.56 s (2.0× faster) WordPress core - -25.89 s - -3.78 s (7× faster) + +18.20 s + +14.27 s (1.3× faster) Elementor - -13.02 s - -1.35 s (10× faster) + +10.66 s + +5.18 s (2.1× faster) Google Site Kit - -15.92 s - -1.33 s (12× faster) + +12.62 s + +5.57 s (2.3× faster) Yoast SEO - -12.32 s - -1.25 s (10× faster) + +9.90 s + +3.86 s (2.6× faster) WPForms Lite - -11.11 s - -1.68 s (7× faster) + +8.03 s + +5.39 s (1.5× faster) LiteSpeed Cache - -2.53 s - -0.62 s (4× faster) + +1.96 s + +2.11 s (1.1× slower) WP Mail SMTP - -2.35 s - -0.56 s (4× faster) + +1.73 s + +1.67 s (about the same) All-in-One WP Migration - -1.67 s - -0.35 s (5× faster) + +1.27 s + +0.73 s (1.7× faster) Contact Form 7 - -1.40 s - -0.34 s (4× faster) + +0.91 s + +0.91 s (about the same) Akismet - -0.52 s - -0.26 s (2× faster) - + +0.47 s + +0.61 s (1.3× slower) diff --git a/docs/benchmarks.md b/docs/benchmarks.md index a2765497..e03e8a90 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -1,42 +1,49 @@ # Benchmarks -`bench/run.sh ` times phpcs (`WordPress-Extra`, WPCS 3.4.1, -`--parallel=8`) against `mago lint` running only this extension's rules. Each figure is the mean of -three runs after a warm-up, on the same machine (Apple M4 MacBook Air, PHP 8.4.24, Mago 1.51.0). +`bench/run.sh ` times phpcs (`WordPress`, WPCS 3.4.1, +`--parallel=8`) against `mago lint` running this extension's default rules. Each figure is the mean of +three runs after a warm-up, on the same machine (Apple M4 MacBook Air, PHP 8.4.24, Mago 1.57.0). Both +tools run on PHP with `opcache.enable_cli=1`. -The comparison isn't apples to apples. `WordPress-Extra` also runs WPCS's formatting and generic -sniffs, while the mago side runs this extension's rules only. +The rule sets match closely. The extension ports 183 of the 186 sniffs in the `WordPress` standard. +The three left out are `Generic.PHP.Syntax` (which runs `php -l`), `Generic.Classes.DuplicateClassName` +and `WordPress.Utils.I18nTextDomainFixer`. Mago also parses each file itself, which phpcs does not. The bake-off covers the top 10 plugins on WordPress.org by active installs, plus WordPress core (`src/` from [wordpress-develop](https://github.com/WordPress/wordpress-develop), text domain `default`, prefix `wp`). WPCS ships a narrower `WordPress-Core` ruleset for core, but the table runs -the same `WordPress-Extra` comparison throughout, for consistency. `classic-editor`, next in +the same `WordPress` comparison throughout, for consistency. `classic-editor`, next in active-install rank, was skipped (1 PHP file after excludes) in favour of `wp-mail-smtp`, the next plugin down the list. -| Codebase | Version | Active installs | PHP files | phpcs `WordPress-Extra` | `mago lint` + this extension | Speed-up | +| Codebase | Version | Active installs | PHP files | phpcs `WordPress` | `mago lint` + this extension | Speed-up | |:---|:---|---:|---:|---:|---:|---:| -| WooCommerce | 11.1.2 | 7,000,000+ | 3,528 | 33.40 s | 3.32 s | 10.1× | -| WordPress core | trunk | n/a¹ | 1,868 | 25.89 s | 3.78 s | 6.8× | -| Elementor | 4.3.2 | 10,000,000+ | 1,460 | 13.02 s | 1.35 s | 9.6× | -| Google Site Kit | 1.188.0 | 5,000,000+ | 1,869 | 15.92 s | 1.33 s | 12.0× | -| Yoast SEO | 28.5 | 10,000,000+ | 1,511 | 12.32 s | 1.25 s | 9.9× | -| WPForms Lite | 2.0.2.1 | 5,000,000+ | 963 | 11.11 s | 1.68 s | 6.6× | -| LiteSpeed Cache | 7.9.1 | 7,000,000+ | 212 | 2.53 s | 0.62 s | 4.1× | -| WP Mail SMTP | 4.9.0 | 4,000,000+ | 185 | 2.35 s | 0.56 s | 4.2× | -| All-in-One WP Migration | 7.111 | 5,000,000+ | 147 | 1.67 s | 0.35 s | 4.8× | -| Contact Form 7 | 6.1.7 | 10,000,000+ | 111 | 1.40 s | 0.34 s | 4.1× | -| Akismet | 5.7.2 | 5,000,000+ | 29 | 0.52 s | 0.26 s | 2.0× | -| **Total** | | | **11,883** | **120.13 s** | **14.84 s** | **8.1×** | +| WooCommerce | 11.1.2 | 7,000,000+ | 3,528 | 25.68 s | 12.56 s | 2.0× | +| WordPress core | trunk | n/a¹ | 1,868 | 18.20 s | 14.27 s | 1.3× | +| Elementor | 4.3.2 | 10,000,000+ | 1,460 | 10.66 s | 5.18 s | 2.1× | +| Google Site Kit | 1.188.0 | 5,000,000+ | 1,869 | 12.62 s | 5.57 s | 2.3× | +| Yoast SEO | 28.5 | 10,000,000+ | 1,511 | 9.90 s | 3.86 s | 2.6× | +| WPForms Lite | 2.0.2.1 | 5,000,000+ | 963 | 8.03 s | 5.39 s | 1.5× | +| LiteSpeed Cache | 7.9.1 | 7,000,000+ | 212 | 1.96 s | 2.11 s | 0.9× | +| WP Mail SMTP | 4.9.0 | 4,000,000+ | 185 | 1.73 s | 1.67 s | 1.0× | +| All-in-One WP Migration | 7.111 | 5,000,000+ | 147 | 1.27 s | 0.73 s | 1.7× | +| Contact Form 7 | 6.1.7 | 10,000,000+ | 111 | 0.91 s | 0.91 s | 1.0× | +| Akismet | 5.7.2 | 5,000,000+ | 29 | 0.47 s | 0.61 s | 0.8× | +| **Total** | | | **11,883** | **91.43 s** | **52.86 s** | **1.7×** | ¹ WordPress.org publishes active-install counts for plugins only; core has no equivalent figure. -Measured 2026-10-01 on the plugins' release zips (vendor and tests excluded) and a wordpress-develop -checkout, with `mago` at 1.51.0, this package at 1.2.0 and the rules that are on by default. phpcs -suppression comments are honoured. Load average was 5–9 from background services, so expect some -noise. The mago column includes starting the PHP worker. mago never lost a single-codebase comparison. +Measured 2026-10-20 on the plugins' release zips (vendor and tests excluded) and a wordpress-develop +checkout, with `mago` at 1.57.0, this package at 1.6.0 plus the speed-ups in this release and the rules +that are on by default. phpcs suppression comments are honoured. Load average was 4–8 from background +services, so expect some noise. The mago column includes starting the PHP worker. + +The speed-up is 2–2.6× on the large plugins and about even on the small ones, where starting the worker +processes is a large share of the time. mago is slower than phpcs on LiteSpeed Cache and Akismet. The +extension has about four times as many rules as the 46 that the first bake-off (2026-10-01, 8.1×) +measured, so those figures are not comparable with these. Full output, per-codebase mago issue counts by rule and exact reproduction commands are in -[`bench/results/2026-10-01-bakeoff.md`](../bench/results/2026-10-01-bakeoff.md). Every rule PR +[`bench/results/2026-10-20-bakeoff.md`](../bench/results/2026-10-20-bakeoff.md). Every rule PR re-runs the bake-off (`bench/bakeoff.sh`) and commits the per-rule counts next to it; the newest `*-bakeoff.md` in [`bench/results/`](../bench/results/) has the current counts. From 2253c21c7b0dc766fc481fcb21cfa4a676742e7c Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 19:58:33 +0800 Subject: [PATCH 18/21] fix(array-alignment): keep the width memo in a typed property, which Mago 1.47's analyzer reads as never from a static local --- src/Linter/Rules/ArrayAlignmentRule.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Linter/Rules/ArrayAlignmentRule.php b/src/Linter/Rules/ArrayAlignmentRule.php index e901f9e3..8d35246f 100644 --- a/src/Linter/Rules/ArrayAlignmentRule.php +++ b/src/Linter/Rules/ArrayAlignmentRule.php @@ -50,6 +50,13 @@ final class ArrayAlignmentRule implements Rule private const TAB_WIDTH = 4; + /** + * The last answer of width(): the file, the offset and the width there. + * + * @var array{SourceFile, int, int}|null + */ + private static ?array $widthMemo = null; + public function __construct( private readonly Report $report, ) {} @@ -230,8 +237,7 @@ private function fix(LintContext $context, array $item, int $spaces, string $cod */ private static function width(SourceFile $file, int $offset): int { - static $memo = null; - + $memo = self::$widthMemo; if ( $memo !== null && $memo[0] === $file @@ -254,7 +260,7 @@ private static function width(SourceFile $file, int $offset): int ? self::TAB_WIDTH - ($width % self::TAB_WIDTH) : (int) ((ord($file->contents[$i]) & 0xC0) !== 0x80); } - $memo = [$file, $offset, $width]; + self::$widthMemo = [$file, $offset, $width]; return $width; } From b31b57e463f849dcdbe2b85f374f7506bbaf96f3 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 20:01:50 +0800 Subject: [PATCH 19/21] bench: include Mago's missing-docs rule, which covers the WordPress-Docs sniffs, in the timed rules --- bench/lib.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bench/lib.sh b/bench/lib.sh index 9923c211..80833285 100644 --- a/bench/lib.sh +++ b/bench/lib.sh @@ -38,3 +38,6 @@ foreach (json_decode(stream_get_contents(STDIN), true)["extensions"][0]["linter- if ($r["default-enabled"]) { echo $r["code"], ","; } }' | sed 's/,$//') [[ -n $codes ]] || { echo "could not list the extension rules" >&2; exit 1; } +# Mago's own missing-docs rule covers the WordPress-Docs sniffs the extension leaves to it; the presets +# enable it, so the timed run does too. +codes="$codes,missing-docs" From 905ea058cddd6b928ce62882c1bafd0394b264f9 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 20:14:36 +0800 Subject: [PATCH 20/21] =?UTF-8?q?docs:=20refresh=20the=20bake-off=20number?= =?UTF-8?q?s=20with=20missing-docs=20in=20the=20timed=20rules=20(1.8=C3=97?= =?UTF-8?q?=20overall)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- bench/results/2026-10-20-bakeoff.md | 36 +++++++----- docs/benchmarks-dark.svg | 88 ++++++++++++++--------------- docs/benchmarks-light.svg | 88 ++++++++++++++--------------- docs/benchmarks.md | 34 +++++------ 5 files changed, 130 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 6652e6b3..67e8b4bc 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,10 @@ minutes. - Bar chart: phpcs WordPress vs mago + mago-wordpress lint time on the top 10 WordPress.org plugins by active installs and WordPress core, from WooCommerce (25.68 s vs 12.56 s) down to Akismet (0.47 s vs 0.61 s) + Bar chart: phpcs WordPress vs mago + mago-wordpress lint time on the top 10 WordPress.org plugins by active installs and WordPress core, from WooCommerce (26.33 s vs 12.64 s) down to Akismet (0.70 s vs 0.57 s) -1.7× faster overall on the top 10 WordPress.org plugins plus WordPress core: 2–2.6× on the large plugins, about even on the small ones. Method and numbers: +1.8× faster overall on the top 10 WordPress.org plugins plus WordPress core: about 2–2.7× on the large plugins, about even on the small ones. Method and numbers: [Benchmarks](docs/benchmarks.md). ## Install diff --git a/bench/results/2026-10-20-bakeoff.md b/bench/results/2026-10-20-bakeoff.md index a2bb6d33..4e51d586 100644 --- a/bench/results/2026-10-20-bakeoff.md +++ b/bench/results/2026-10-20-bakeoff.md @@ -1,24 +1,24 @@ # Bake-off 2026-10-20: top 10 WordPress.org plugins + WordPress core `bench/bakeoff.sh` (phpcs WordPress vs mago + mago-wordpress), mean of 3 runs after a warm-up. -Versions: mago 1.57.0 / PHP_CodeSniffer version 3.13.6 (stable) by Squiz and PHPCSStandards, PHP 8.4.24, this package at perf/worker-speedups (36ed029). +Versions: mago 1.57.0 / PHP_CodeSniffer version 3.13.6 (stable) by Squiz and PHPCSStandards, PHP 8.4.24, this package at perf/worker-speedups (b31b57e). ## Results | Codebase | PHP files | phpcs `WordPress` | `mago lint` + extension | Speed-up | |:---|---:|---:|---:|---:| -| WooCommerce | 3528 | 25.68 s | 12.56 s | 2.0× | -| WordPress core | 1868 | 18.20 s | 14.27 s | 1.3× | -| Elementor | 1460 | 10.66 s | 5.18 s | 2.1× | -| Google Site Kit | 1869 | 12.62 s | 5.57 s | 2.3× | -| Yoast SEO | 1511 | 9.90 s | 3.86 s | 2.6× | -| WPForms Lite | 963 | 8.03 s | 5.39 s | 1.5× | -| LiteSpeed Cache | 212 | 1.96 s | 2.11 s | 0.9× | -| WP Mail SMTP | 185 | 1.73 s | 1.67 s | 1.0× | -| All-in-One WP Migration | 147 | 1.27 s | 0.73 s | 1.7× | -| Contact Form 7 | 111 | 0.91 s | 0.91 s | 1.0× | -| Akismet | 29 | 0.47 s | 0.61 s | 0.8× | -| **Total** | **11883** | **91.43 s** | **52.86 s** | **1.7×** | +| WooCommerce | 3528 | 26.33 s | 12.64 s | 2.1× | +| WordPress core | 1868 | 18.14 s | 14.63 s | 1.2× | +| Elementor | 1460 | 10.61 s | 4.96 s | 2.1× | +| Google Site Kit | 1869 | 12.47 s | 5.40 s | 2.3× | +| Yoast SEO | 1511 | 9.72 s | 3.59 s | 2.7× | +| WPForms Lite | 963 | 7.97 s | 5.32 s | 1.5× | +| LiteSpeed Cache | 212 | 2.19 s | 2.04 s | 1.1× | +| WP Mail SMTP | 185 | 1.79 s | 1.68 s | 1.1× | +| All-in-One WP Migration | 147 | 1.56 s | 0.74 s | 2.1× | +| Contact Form 7 | 111 | 1.15 s | 0.89 s | 1.3× | +| Akismet | 29 | 0.70 s | 0.57 s | 1.2× | +| **Total** | **11883** | **92.63 s** | **52.46 s** | **1.8×** | ## mago issue counts by rule, per codebase @@ -40,6 +40,7 @@ error[generic/opening-function-brace-kernighan-ritchie]: 2337 error[generic/doc-comment]: 1977 error[wordpress/comma-after-last]: 1930 warning[wordpress/direct-database-query]: 1756 +help[missing-docs]: 1362 warning[wordpress/statement-alignment]: 1327 error[wordpress/escape-output]: 1155 error[wordpress/valid-function-name]: 859 @@ -172,6 +173,7 @@ error[generic/double-quote-usage]: 933 warning[wordpress/nonce-verification-warning]: 837 error[wordpress/file-name]: 813 warning[generic/commented-out-code]: 807 +help[missing-docs]: 767 error[wordpress/valid-function-name]: 707 error[wordpress/array-indentation]: 569 error[generic/file-comment]: 568 @@ -272,6 +274,7 @@ error[wordpress/valid-hook-name]: 1 ``` warning[wordpress/array-alignment]: 16235 error[generic/disallow-short-array-syntax]: 14793 +help[missing-docs]: 8569 error[wordpress/function-call-signature]: 3768 warning[wordpress/statement-alignment]: 3397 error[generic/function-comment]: 1836 @@ -339,6 +342,7 @@ error[generic/disallow-short-array-syntax]: 5183 warning[wordpress/statement-alignment]: 5142 error[wordpress/file-name]: 3481 error[generic/doc-comment]: 3465 +help[missing-docs]: 3111 error[wordpress/operator-spacing]: 2991 error[wordpress/array-key-spacing-restrictions]: 2285 error[wordpress/array-declaration-spacing]: 2186 @@ -443,6 +447,7 @@ warning[wordpress/nonce-verification-warning]: 14 warning[wordpress/discouraged-wp-functions]: 9 error[wordpress/validated-sanitized-input]: 8 warning[generic/unused-function-parameter]: 6 +help[missing-docs]: 5 warning[wordpress/prepared-sql-placeholders-warning]: 5 warning[generic/commented-out-code]: 3 warning[generic/no-reserved-keyword-parameter-names]: 3 @@ -498,6 +503,7 @@ error[generic/function-closing-brace]: 1 warning[generic/property-declaration-warning]: 1 error[generic/separate-functions-from-oo]: 1 error[generic/superfluous-whitespace]: 1 +help[missing-docs]: 1 error[wordpress/function-call-signature]: 1 error[wordpress/global-variables-override]: 1 error[wordpress/operator-spacing]: 1 @@ -521,6 +527,7 @@ error[wordpress/file-name]: 272 error[wordpress/operator-spacing]: 160 warning[generic/property-declaration-warning]: 123 error[generic/scope-indent]: 110 +help[missing-docs]: 85 error[wordpress/array-key-spacing-restrictions]: 78 warning[wordpress/direct-database-query]: 78 warning[generic/commented-out-code]: 49 @@ -591,6 +598,7 @@ warning[wordpress/no-silenced-errors]: 1 ``` error[generic/inline-comment]: 1237 error[generic/function-comment]: 377 +help[missing-docs]: 174 error[generic/file-comment]: 147 error[generic/disallow-multiple-assignments]: 138 warning[generic/assignment-in-condition]: 136 @@ -632,6 +640,7 @@ error[wordpress/prepared-sql]: 1 ``` error[wordpress/function-call-signature]: 1740 warning[wordpress/array-alignment]: 1003 +help[missing-docs]: 512 warning[wordpress/statement-alignment]: 337 error[generic/function-comment]: 302 error[wordpress/array-declaration-spacing]: 258 @@ -691,6 +700,7 @@ error[wordpress/yoda-conditions]: 1 ### Akismet ``` +help[missing-docs]: 156 error[wordpress/yoda-conditions]: 96 warning[generic/strict-comparisons]: 88 error[generic/inline-comment]: 79 diff --git a/docs/benchmarks-dark.svg b/docs/benchmarks-dark.svg index 910e95bd..d523f2e0 100644 --- a/docs/benchmarks-dark.svg +++ b/docs/benchmarks-dark.svg @@ -18,58 +18,58 @@ 30 WooCommerce - -25.68 s - -12.56 s (2.0× faster) + +26.33 s + +12.64 s (2.1× faster) WordPress core - -18.20 s - -14.27 s (1.3× faster) + +18.14 s + +14.63 s (1.2× faster) Elementor - -10.66 s - -5.18 s (2.1× faster) + +10.61 s + +4.96 s (2.1× faster) Google Site Kit - -12.62 s - -5.57 s (2.3× faster) + +12.47 s + +5.40 s (2.3× faster) Yoast SEO - -9.90 s - -3.86 s (2.6× faster) + +9.72 s + +3.59 s (2.7× faster) WPForms Lite - -8.03 s - -5.39 s (1.5× faster) + +7.97 s + +5.32 s (1.5× faster) LiteSpeed Cache - -1.96 s - -2.11 s (1.1× slower) + +2.19 s + +2.04 s (1.1× faster) WP Mail SMTP - -1.73 s - -1.67 s (about the same) + +1.79 s + +1.68 s (1.1× faster) All-in-One WP Migration - -1.27 s - -0.73 s (1.7× faster) + +1.56 s + +0.74 s (2.1× faster) Contact Form 7 - -0.91 s - -0.91 s (about the same) + +1.15 s + +0.89 s (1.3× faster) Akismet - -0.47 s - -0.61 s (1.3× slower) + +0.70 s + +0.57 s (1.2× faster) diff --git a/docs/benchmarks-light.svg b/docs/benchmarks-light.svg index 8d319710..073a653b 100644 --- a/docs/benchmarks-light.svg +++ b/docs/benchmarks-light.svg @@ -18,58 +18,58 @@ 30 WooCommerce - -25.68 s - -12.56 s (2.0× faster) + +26.33 s + +12.64 s (2.1× faster) WordPress core - -18.20 s - -14.27 s (1.3× faster) + +18.14 s + +14.63 s (1.2× faster) Elementor - -10.66 s - -5.18 s (2.1× faster) + +10.61 s + +4.96 s (2.1× faster) Google Site Kit - -12.62 s - -5.57 s (2.3× faster) + +12.47 s + +5.40 s (2.3× faster) Yoast SEO - -9.90 s - -3.86 s (2.6× faster) + +9.72 s + +3.59 s (2.7× faster) WPForms Lite - -8.03 s - -5.39 s (1.5× faster) + +7.97 s + +5.32 s (1.5× faster) LiteSpeed Cache - -1.96 s - -2.11 s (1.1× slower) + +2.19 s + +2.04 s (1.1× faster) WP Mail SMTP - -1.73 s - -1.67 s (about the same) + +1.79 s + +1.68 s (1.1× faster) All-in-One WP Migration - -1.27 s - -0.73 s (1.7× faster) + +1.56 s + +0.74 s (2.1× faster) Contact Form 7 - -0.91 s - -0.91 s (about the same) + +1.15 s + +0.89 s (1.3× faster) Akismet - -0.47 s - -0.61 s (1.3× slower) + +0.70 s + +0.57 s (1.2× faster) diff --git a/docs/benchmarks.md b/docs/benchmarks.md index e03e8a90..57c21061 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -1,7 +1,7 @@ # Benchmarks `bench/run.sh ` times phpcs (`WordPress`, WPCS 3.4.1, -`--parallel=8`) against `mago lint` running this extension's default rules. Each figure is the mean of +`--parallel=8`) against `mago lint` running this extension's default rules and Mago's `missing-docs` (the WordPress-Docs sniffs). Each figure is the mean of three runs after a warm-up, on the same machine (Apple M4 MacBook Air, PHP 8.4.24, Mago 1.57.0). Both tools run on PHP with `opcache.enable_cli=1`. @@ -18,18 +18,18 @@ plugin down the list. | Codebase | Version | Active installs | PHP files | phpcs `WordPress` | `mago lint` + this extension | Speed-up | |:---|:---|---:|---:|---:|---:|---:| -| WooCommerce | 11.1.2 | 7,000,000+ | 3,528 | 25.68 s | 12.56 s | 2.0× | -| WordPress core | trunk | n/a¹ | 1,868 | 18.20 s | 14.27 s | 1.3× | -| Elementor | 4.3.2 | 10,000,000+ | 1,460 | 10.66 s | 5.18 s | 2.1× | -| Google Site Kit | 1.188.0 | 5,000,000+ | 1,869 | 12.62 s | 5.57 s | 2.3× | -| Yoast SEO | 28.5 | 10,000,000+ | 1,511 | 9.90 s | 3.86 s | 2.6× | -| WPForms Lite | 2.0.2.1 | 5,000,000+ | 963 | 8.03 s | 5.39 s | 1.5× | -| LiteSpeed Cache | 7.9.1 | 7,000,000+ | 212 | 1.96 s | 2.11 s | 0.9× | -| WP Mail SMTP | 4.9.0 | 4,000,000+ | 185 | 1.73 s | 1.67 s | 1.0× | -| All-in-One WP Migration | 7.111 | 5,000,000+ | 147 | 1.27 s | 0.73 s | 1.7× | -| Contact Form 7 | 6.1.7 | 10,000,000+ | 111 | 0.91 s | 0.91 s | 1.0× | -| Akismet | 5.7.2 | 5,000,000+ | 29 | 0.47 s | 0.61 s | 0.8× | -| **Total** | | | **11,883** | **91.43 s** | **52.86 s** | **1.7×** | +| WooCommerce | 11.1.2 | 7,000,000+ | 3,528 | 26.33 s | 12.64 s | 2.1× | +| WordPress core | trunk | n/a¹ | 1,868 | 18.14 s | 14.63 s | 1.2× | +| Elementor | 4.3.2 | 10,000,000+ | 1,460 | 10.61 s | 4.96 s | 2.1× | +| Google Site Kit | 1.188.0 | 5,000,000+ | 1,869 | 12.47 s | 5.40 s | 2.3× | +| Yoast SEO | 28.5 | 10,000,000+ | 1,511 | 9.72 s | 3.59 s | 2.7× | +| WPForms Lite | 2.0.2.1 | 5,000,000+ | 963 | 7.97 s | 5.32 s | 1.5× | +| LiteSpeed Cache | 7.9.1 | 7,000,000+ | 212 | 2.19 s | 2.04 s | 1.1× | +| WP Mail SMTP | 4.9.0 | 4,000,000+ | 185 | 1.79 s | 1.68 s | 1.1× | +| All-in-One WP Migration | 7.111 | 5,000,000+ | 147 | 1.56 s | 0.74 s | 2.1× | +| Contact Form 7 | 6.1.7 | 10,000,000+ | 111 | 1.15 s | 0.89 s | 1.3× | +| Akismet | 5.7.2 | 5,000,000+ | 29 | 0.70 s | 0.57 s | 1.2× | +| **Total** | | | **11,883** | **92.63 s** | **52.46 s** | **1.8×** | ¹ WordPress.org publishes active-install counts for plugins only; core has no equivalent figure. @@ -38,10 +38,10 @@ checkout, with `mago` at 1.57.0, this package at 1.6.0 plus the speed-ups in thi that are on by default. phpcs suppression comments are honoured. Load average was 4–8 from background services, so expect some noise. The mago column includes starting the PHP worker. -The speed-up is 2–2.6× on the large plugins and about even on the small ones, where starting the worker -processes is a large share of the time. mago is slower than phpcs on LiteSpeed Cache and Akismet. The -extension has about four times as many rules as the 46 that the first bake-off (2026-10-01, 8.1×) -measured, so those figures are not comparable with these. +The speed-up is 2.1-2.7× on the large plugins (WordPress core, 1.2×, is the exception) and about even +on the small ones, where starting the worker processes is a large share of the time. The extension has +about four times as many rules as the 46 that the first bake-off (2026-10-01, 8.1×) measured, so those +figures are not comparable with these. Full output, per-codebase mago issue counts by rule and exact reproduction commands are in [`bench/results/2026-10-20-bakeoff.md`](../bench/results/2026-10-20-bakeoff.md). Every rule PR From 69b57d5b61025bed50f84436ad4ac872a513ae3f Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 20 Oct 2026 20:20:01 +0800 Subject: [PATCH 21/21] chore(release): 1.6.1 --- src/WordPressExtension.php | 2 +- tests/Unit/WordPressExtensionTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WordPressExtension.php b/src/WordPressExtension.php index aee94d9a..585c3ff4 100644 --- a/src/WordPressExtension.php +++ b/src/WordPressExtension.php @@ -201,7 +201,7 @@ */ final class WordPressExtension { - private const VERSION = '1.4.0'; + private const VERSION = '1.6.1'; private function __construct() {} diff --git a/tests/Unit/WordPressExtensionTest.php b/tests/Unit/WordPressExtensionTest.php index 7cc00985..22b7d247 100644 --- a/tests/Unit/WordPressExtensionTest.php +++ b/tests/Unit/WordPressExtensionTest.php @@ -44,7 +44,7 @@ public function testFactoryOwnsStableRegistration(): void self::assertSame('rlorenzo/mago-wordpress', $extension->identifier); self::assertSame('WordPress', $extension->name); - self::assertSame('1.4.0', $extension->version); + self::assertSame('1.6.1', $extension->version); self::assertCount(0, $extension->analyzerPlugins); self::assertNull($extension->workerReducer); }