← All changes
|
includes/abilities/widgets/wdesignkit-validate-widget.php
+472
-55
2.6.3
→
2.7.0
View file →
| @@ -11,9 +11,9 @@ | ||
| 11 | 11 | |
| 12 | 12 | wp_register_ability('wdesignkit/validate-widget', [ |
| 13 | 13 | 'label' => __('Validate WDesignKit Widget Code', 'wdesignkit'), |
| 14 | 14 | 'description' => __( |
| 15 | - 'Performs pre-write validation of WDesignKit widget PHP, CSS, and JS code without modifying any files. Checks PHP syntax, forbidden top-level namespace declarations, Wdkit_ class name conventions, get_name() unique handle validity, and asset dependency methods. Returns a pass/fail check list and optional auto-fixed PHP code.', | |
| 15 | + 'Performs pre-write validation of WDesignKit widget PHP, CSS, and JS code without modifying any files. Checks PHP syntax, forbidden top-level namespace declarations, and builder-appropriate structure: Wdkit_ class names plus get_name() unique handles for Elementor, \\Bricks\\Element subclasses for Bricks, and register_block_type() presence plus a well-formed unique block name for Gutenberg and Gutenberg Core. Also checks asset wiring. Returns a pass/fail check list and optional auto-fixed PHP code.', | |
| 16 | 16 | 'wdesignkit', |
| 17 | 17 | ), |
| 18 | 18 | 'category' => 'wdesignkit', |
| 19 | 19 | 'input_schema' => [ |
| @@ -32,10 +32,10 @@ | ||
| 32 | 32 | 'description' => 'JS file content to validate.', |
| 33 | 33 | ], |
| 34 | 34 | 'builder' => [ |
| 35 | 35 | 'type' => 'string', |
| 36 | - 'description' => 'Target page builder (elementor, gutenberg, gutenberg_core, bricks).', | |
| 37 | - 'enum' => ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'], | |
| 36 | + 'description' => 'Target page builder. "nexter" is accepted as an alias for gutenberg, which is how Nexter widgets are registered. Omitting this makes the ability infer the builder from the code, so pass it whenever it is known — every structural rule depends on it.', | |
| 37 | + 'enum' => ['elementor', 'gutenberg', 'gutenberg_core', 'bricks', 'nexter'], | |
| 38 | 38 | ], |
| 39 | 39 | 'folder' => [ |
| 40 | 40 | 'type' => 'string', |
| 41 | 41 | 'description' => 'Widget folder name. Reads existing files from disk if php_code is omitted.', |
| @@ -64,13 +64,15 @@ | ||
| 64 | 64 | ], |
| 65 | 65 | 'execute_callback' => 'wdesignkit_mcp_validate_widget', |
| 66 | 66 | 'permission_callback' => 'wdesignkit_mcp_permission_callback', |
| 67 | 67 | 'meta' => [ |
| 68 | + 'public' => true, | |
| 68 | 69 | 'show_in_rest' => true, |
| 69 | 70 | 'mcp' => ['public' => true], |
| 70 | 71 | 'annotations' => [ |
| 71 | 72 | 'instructions' => implode("\n", [ |
| 72 | 73 | 'Validates WDesignKit widget code (PHP syntax, namespace rules, class name conventions, get_name, asset dependencies) without modifying disk files.', |
| 74 | + 'Rules are builder-specific, so always pass builder. For gutenberg/gutenberg_core the structural rules are register_block_type() presence and a well-formed, unique block name; pass folder as well when validating an existing widget so its own block name is not reported as a duplicate.', | |
| 73 | 75 | 'Use this before calling wdesignkit/update-widget to catch syntax and structural errors early.', |
| 74 | 76 | 'If top-level namespace is found, auto_fix_available will be true and fixed_php_code will contain sanitized PHP.', |
| 75 | 77 | ]), |
| 76 | 78 | 'readonly' => true, |
| @@ -79,8 +81,173 @@ | ||
| 79 | 81 | ], |
| 80 | 82 | ], |
| 81 | 83 | ]); |
| 82 | 84 | |
| 85 | +/** | |
| 86 | + * Count structural braces and parentheses, ignoring comments and string literals. | |
| 87 | + * | |
| 88 | + * The CSS and JS checks counted every brace in the file, so a brace inside a comment or a string | |
| 89 | + * — "/* } *\/", or content: "{" — reported a valid file as malformed and, with validate:true, | |
| 90 | + * blocked a correct widget from being written (ClickUp 86d41cd0a). | |
| 91 | + * | |
| 92 | + * A single scan is used rather than successive regex strips because the two orderings disagree: | |
| 93 | + * stripping comments first corrupts a string containing "/*", stripping strings first corrupts a | |
| 94 | + * comment containing a quote. Tracking the state as we go gets both right. | |
| 95 | + * | |
| 96 | + * $allow_line_comments must stay false for CSS: "//" is not a comment there, and treating it as | |
| 97 | + * one would swallow the rest of the line in a perfectly normal url(http://example.com/x.png). | |
| 98 | + * | |
| 99 | + * JS regex literals are still not parsed — /}/ counts its brace — which is why the JS check | |
| 100 | + * treats a mismatch as a warning rather than a failure. | |
| 101 | + * | |
| 102 | + * @param string $code Source to scan. | |
| 103 | + * @param bool $allow_line_comments Treat "//" as a comment (JS yes, CSS no). | |
| 104 | + * @return array{curly_open:int,curly_close:int,paren_open:int,paren_close:int} | |
| 105 | + */ | |
| 106 | +function wdesignkit_mcp_count_structural_braces(string $code, bool $allow_line_comments): array { | |
| 107 | + $curly_open = 0; | |
| 108 | + $curly_close = 0; | |
| 109 | + $paren_open = 0; | |
| 110 | + $paren_close = 0; | |
| 111 | + | |
| 112 | + $state = 'code'; | |
| 113 | + $quote = ''; | |
| 114 | + $len = strlen($code); | |
| 115 | + | |
| 116 | + for ($i = 0; $i < $len; $i++) { | |
| 117 | + $ch = $code[$i]; | |
| 118 | + $next = ($i + 1 < $len) ? $code[$i + 1] : ''; | |
| 119 | + | |
| 120 | + if ('block' === $state) { | |
| 121 | + if ('*' === $ch && '/' === $next) { | |
| 122 | + $state = 'code'; | |
| 123 | + $i++; | |
| 124 | + } | |
| 125 | + continue; | |
| 126 | + } | |
| 127 | + | |
| 128 | + if ('line' === $state) { | |
| 129 | + if ("\n" === $ch) { | |
| 130 | + $state = 'code'; | |
| 131 | + } | |
| 132 | + continue; | |
| 133 | + } | |
| 134 | + | |
| 135 | + if ('string' === $state) { | |
| 136 | + if ('\\' === $ch) { | |
| 137 | + $i++; | |
| 138 | + continue; | |
| 139 | + } | |
| 140 | + if ($ch === $quote) { | |
| 141 | + $state = 'code'; | |
| 142 | + $quote = ''; | |
| 143 | + } | |
| 144 | + continue; | |
| 145 | + } | |
| 146 | + | |
| 147 | + if ('/' === $ch && '*' === $next) { | |
| 148 | + $state = 'block'; | |
| 149 | + $i++; | |
| 150 | + continue; | |
| 151 | + } | |
| 152 | + if ($allow_line_comments && '/' === $ch && '/' === $next) { | |
| 153 | + $state = 'line'; | |
| 154 | + $i++; | |
| 155 | + continue; | |
| 156 | + } | |
| 157 | + if ('"' === $ch || "'" === $ch || '`' === $ch) { | |
| 158 | + $state = 'string'; | |
| 159 | + $quote = $ch; | |
| 160 | + continue; | |
| 161 | + } | |
| 162 | + | |
| 163 | + if ('{' === $ch) { | |
| 164 | + $curly_open++; | |
| 165 | + } elseif ('}' === $ch) { | |
| 166 | + $curly_close++; | |
| 167 | + } elseif ('(' === $ch) { | |
| 168 | + $paren_open++; | |
| 169 | + } elseif (')' === $ch) { | |
| 170 | + $paren_close++; | |
| 171 | + } | |
| 172 | + } | |
| 173 | + | |
| 174 | + return [ | |
| 175 | + 'curly_open' => $curly_open, | |
| 176 | + 'curly_close' => $curly_close, | |
| 177 | + 'paren_open' => $paren_open, | |
| 178 | + 'paren_close' => $paren_close, | |
| 179 | + ]; | |
| 180 | +} | |
| 181 | + | |
| 182 | +/** | |
| 183 | + * Finds installed Gutenberg widgets that already register $block_name. | |
| 184 | + * | |
| 185 | + * Block names are the unique registration handle: WordPress drops a second | |
| 186 | + * register_block_type() for a name it already holds, so a collision silently costs one of | |
| 187 | + * the two widgets. Read-only — scans the two Gutenberg builder directories only. | |
| 188 | + * | |
| 189 | + * A widget is never reported as colliding with itself. When $skip_folder is known that folder | |
| 190 | + * is excluded and every other folder holding the name is a genuine conflict. When it is not | |
| 191 | + * known — validate-widget accepts bare php_code — re-validating an installed widget cannot be | |
| 192 | + * told apart from a new widget claiming a taken name, so fall back to excluding the folder | |
| 193 | + * whose trailing "_{hash}" matches the hash in the block name: generated widgets derive both | |
| 194 | + * from the same widget_id, so that folder is almost certainly the caller itself. This is | |
| 195 | + * deliberately the forgiving direction; a missed duplicate still fails at update time, whereas | |
| 196 | + * a false "already taken" would block a legitimate write (the ClickUp 86d3yk4z8 failure mode). | |
| 197 | + * | |
| 198 | + * @param string $block_name Block name read from register_block_type(), e.g. wdkit/wb-1a2b3c4d. | |
| 199 | + * @param string $skip_folder Folder currently being validated, excluded from the scan. | |
| 200 | + * | |
| 201 | + * @return string[] Conflicting widgets as "builder/folder" strings. | |
| 202 | + */ | |
| 203 | +function wdesignkit_mcp_find_conflicting_block_files(string $block_name, string $skip_folder = ''): array { | |
| 204 | + if ($block_name === '' || !defined('WDKIT_BUILDER_PATH')) { | |
| 205 | + return []; | |
| 206 | + } | |
| 207 | + | |
| 208 | + $slug = substr($block_name, (int) strpos($block_name, '/') + 1); | |
| 209 | + $own_hash = ($skip_folder === '') ? (string) preg_replace('/^(?:wb|wdkit)-/i', '', $slug) : ''; | |
| 210 | + $conflicts = []; | |
| 211 | + | |
| 212 | + foreach (['gutenberg', 'gutenberg_core'] as $b) { | |
| 213 | + $b_dir = WDKIT_BUILDER_PATH . '/' . $b; | |
| 214 | + if (!is_dir($b_dir)) { | |
| 215 | + continue; | |
| 216 | + } | |
| 217 | + | |
| 218 | + foreach (array_diff(@scandir($b_dir) ?: [], ['.', '..']) as $sub) { | |
| 219 | + if ($sub === $skip_folder || !is_dir($b_dir . '/' . $sub)) { | |
| 220 | + continue; | |
| 221 | + } | |
| 222 | + if ($own_hash !== '' && substr($sub, -(strlen($own_hash) + 1)) === '_' . $own_hash) { | |
| 223 | + continue; | |
| 224 | + } | |
| 225 | + | |
| 226 | + foreach (array_diff(@scandir($b_dir . '/' . $sub) ?: [], ['.', '..']) as $f) { | |
| 227 | + if (strtolower((string) pathinfo($f, PATHINFO_EXTENSION)) !== 'php') { | |
| 228 | + continue; | |
| 229 | + } | |
| 230 | + | |
| 231 | + $fp = $b_dir . '/' . $sub . '/' . $f; | |
| 232 | + if (!is_file($fp) || filesize($fp) > 2097152) { | |
| 233 | + continue; | |
| 234 | + } | |
| 235 | + | |
| 236 | + $existing = (string) @file_get_contents($fp); | |
| 237 | + $match = []; | |
| 238 | + if (preg_match('/\bregister_block_type(?:_from_metadata)?\s*\(\s*[\'"]([^\'"]+)[\'"]/i', $existing, $match) | |
| 239 | + && trim($match[1]) === $block_name) { | |
| 240 | + $conflicts[] = $b . '/' . $sub; | |
| 241 | + break; | |
| 242 | + } | |
| 243 | + } | |
| 244 | + } | |
| 245 | + } | |
| 246 | + | |
| 247 | + return array_values(array_unique($conflicts)); | |
| 248 | +} | |
| 249 | + | |
| 83 | 250 | function wdesignkit_mcp_validate_widget(array $input): array { |
| 84 | 251 | set_time_limit(60); |
| 85 | 252 | |
| 86 | 253 | $builder = sanitize_text_field((string) ($input['builder'] ?? '')); |
| @@ -153,44 +320,50 @@ | ||
| 153 | 320 | $auto_fix_available = false; |
| 154 | 321 | $fixed_php_code = $php_code; |
| 155 | 322 | |
| 156 | 323 | // ── Check 1: PHP Syntax (php_syntax) ─────────────────────────────────────── |
| 324 | + // token_get_all() must be called WITH the TOKEN_PARSE flag. Without it the function is | |
| 325 | + // only a lexer: it tokenizes malformed code happily, returns an array and never throws, | |
| 326 | + // so genuine parse errors ("function x( {", a missing semicolon, an unterminated string) | |
| 327 | + // all reported "PHP syntax is valid" and update-widget wrote them to disk (86d41ccjk). | |
| 328 | + // | |
| 329 | + // The old curly-brace counter that used to back this check is gone with it. It counted | |
| 330 | + // every brace in the file including those inside strings and comments, so it false-failed | |
| 331 | + // correct code (86d41cd0a) while catching nothing TOKEN_PARSE misses — unbalanced | |
| 332 | + // structural braces are always a parse error. TOKEN_PARSE also reports the real line | |
| 333 | + // number and reason instead of a brace tally. | |
| 157 | 334 | if ($php_code !== '') { |
| 158 | - $syntax_valid = true; | |
| 159 | - $syntax_msg = 'PHP syntax is valid.'; | |
| 335 | + $syntax_status = 'pass'; | |
| 336 | + $syntax_msg = 'PHP syntax is valid.'; | |
| 160 | 337 | |
| 161 | - if (function_exists('token_get_all')) { | |
| 338 | + if (strpos($php_code, '<?php') === false && strpos($php_code, '<?') === false) { | |
| 339 | + // Not a parse error — a file with no PHP tag is valid inline HTML — but a widget | |
| 340 | + // whose code never enters PHP cannot register anything, so it stays a failure. | |
| 341 | + $syntax_status = 'fail'; | |
| 342 | + $syntax_msg = 'PHP code is missing opening <?php tag.'; | |
| 343 | + } elseif (!function_exists('token_get_all') || !defined('TOKEN_PARSE')) { | |
| 344 | + // The tokenizer extension is what does the checking here. If it is unavailable, | |
| 345 | + // say the syntax is unverified rather than reporting a clean pass — silently | |
| 346 | + // claiming "valid" when nothing was checked is the defect this check just had. | |
| 347 | + $syntax_status = 'warning'; | |
| 348 | + $syntax_msg = 'PHP syntax could not be verified: the tokenizer extension is unavailable on this server. Review the code manually before writing it.'; | |
| 349 | + } else { | |
| 162 | 350 | try { |
| 163 | - // Tokenize code — PHP parser error throws or returns boolean false if invalid | |
| 164 | - $tokens = @token_get_all($php_code); | |
| 165 | - if ($tokens === false) { | |
| 166 | - $syntax_valid = false; | |
| 167 | - $syntax_msg = 'PHP tokenization failed — check for unclosed strings, brackets, or syntax errors.'; | |
| 168 | - } | |
| 351 | + // Parses without executing. @ suppresses lexer warnings (e.g. unterminated | |
| 352 | + // comment) that are reported separately from the ParseError below. | |
| 353 | + @token_get_all($php_code, TOKEN_PARSE); | |
| 354 | + } catch (\ParseError $e) { | |
| 355 | + $syntax_status = 'fail'; | |
| 356 | + $syntax_msg = 'PHP parse error on line ' . $e->getLine() . ': ' . $e->getMessage(); | |
| 169 | 357 | } catch (\Throwable $e) { |
| 170 | - $syntax_valid = false; | |
| 171 | - $syntax_msg = 'PHP syntax error: ' . $e->getMessage(); | |
| 358 | + $syntax_status = 'fail'; | |
| 359 | + $syntax_msg = 'PHP could not be parsed: ' . $e->getMessage(); | |
| 172 | 360 | } |
| 173 | 361 | } |
| 174 | 362 | |
| 175 | - // Bracket & tag validation | |
| 176 | - if ($syntax_valid) { | |
| 177 | - if (strpos($php_code, '<?php') === false && strpos($php_code, '<?') === false) { | |
| 178 | - $syntax_valid = false; | |
| 179 | - $syntax_msg = 'PHP code is missing opening <?php tag.'; | |
| 180 | - } else { | |
| 181 | - $curly_open = substr_count($php_code, '{'); | |
| 182 | - $curly_close = substr_count($php_code, '}'); | |
| 183 | - if ($curly_open !== $curly_close) { | |
| 184 | - $syntax_valid = false; | |
| 185 | - $syntax_msg = "Mismatched curly braces: {$curly_open} opening '{' vs {$curly_close} closing '}'."; | |
| 186 | - } | |
| 187 | - } | |
| 188 | - } | |
| 189 | - | |
| 190 | 363 | $checks[] = [ |
| 191 | 364 | 'rule' => 'php_syntax', |
| 192 | - 'status' => $syntax_valid ? 'pass' : 'fail', | |
| 365 | + 'status' => $syntax_status, | |
| 193 | 366 | 'message' => $syntax_msg, |
| 194 | 367 | ]; |
| 195 | 368 | } |
| 196 | 369 | |
| @@ -216,31 +389,174 @@ | ||
| 216 | 389 | ]; |
| 217 | 390 | } |
| 218 | 391 | } |
| 219 | 392 | |
| 393 | + // ── Resolve the builder ─────────────────────────────────────────────────── | |
| 394 | + // Every rule below is builder-specific, and builder is not a required input. Callers | |
| 395 | + // routinely pass php_code alone, and the only inference that existed ran inside the | |
| 396 | + // read-from-disk branch — which is skipped entirely whenever code was supplied. So a Bricks | |
| 397 | + // element validated without a builder fell through to the Elementor Wdkit_ class rule and | |
| 398 | + // came back as an outright "fail", the exact symptom 2.6.4 set out to fix (ClickUp 86d41cd15), | |
| 399 | + // while Gutenberg code lost its registration rule and let arbitrary PHP pass. | |
| 400 | + // | |
| 401 | + // Infer from the code instead: each builder has an unambiguous structural signature. Class | |
| 402 | + // signatures are checked before the Gutenberg one because they are the more specific match. | |
| 403 | + $builder_source = ($builder !== '') ? 'input' : 'unknown'; | |
| 404 | + | |
| 405 | + // Nexter ships its widgets as Gutenberg blocks, so its slug maps onto the gutenberg rules. | |
| 406 | + if ('nexter' === $builder) { | |
| 407 | + $builder = 'gutenberg'; | |
| 408 | + $builder_source = 'alias'; | |
| 409 | + } | |
| 410 | + | |
| 411 | + if ($builder === '' && $php_code !== '') { | |
| 412 | + if (preg_match('/extends\s+\\\\?Bricks\\\\Element\b/i', $php_code)) { | |
| 413 | + $builder = 'bricks'; | |
| 414 | + } elseif (preg_match('/extends\s+\\\\?(?:Elementor\\\\)?Widget_Base\b/i', $php_code)) { | |
| 415 | + $builder = 'elementor'; | |
| 416 | + } elseif (preg_match('/\bregister_block_type(?:_from_metadata)?\s*\(/i', $php_code) | |
| 417 | + || preg_match('/WP_Block_Type_Registry\b[^;]*->\s*register\s*\(/i', $php_code)) { | |
| 418 | + // gutenberg and gutenberg_core are indistinguishable from the PHP alone — they | |
| 419 | + // generate the same shape — and both take the same rules here, so either is correct. | |
| 420 | + $builder = 'gutenberg'; | |
| 421 | + } | |
| 422 | + | |
| 423 | + if ($builder !== '') { | |
| 424 | + $builder_source = 'inferred'; | |
| 425 | + } | |
| 426 | + } | |
| 427 | + | |
| 428 | + if ($php_code !== '') { | |
| 429 | + $checks[] = [ | |
| 430 | + 'rule' => 'builder_resolved', | |
| 431 | + 'status' => ('unknown' === $builder_source) ? 'warning' : 'pass', | |
| 432 | + 'message' => 'unknown' === $builder_source | |
| 433 | + ? 'Could not determine the target builder from the input or the code, so only builder-agnostic rules were applied. Pass builder (elementor, gutenberg, gutenberg_core, bricks) for a full check.' | |
| 434 | + : sprintf( | |
| 435 | + 'inferred' === $builder_source | |
| 436 | + ? 'Builder inferred from the code as "%s"; its rules were applied. Pass builder explicitly to be certain.' | |
| 437 | + : 'Validated against the "%s" ruleset.', | |
| 438 | + $builder | |
| 439 | + ), | |
| 440 | + ]; | |
| 441 | + } | |
| 442 | + | |
| 443 | + // Gutenberg block identity, shared by the registration, handle and asset rules below. | |
| 444 | + // Generated Gutenberg/Gutenberg Core widgets are function-based: the whole widget is a | |
| 445 | + // wb_{Folder}() function hooked on init that calls register_block_type( 'wdkit/wb-{hash}', … ) | |
| 446 | + // (see src/widget-builder/file-creation/gutenberg_file.js and gutenberg_core_file.js). | |
| 447 | + // That call — not a class name and not get_name() — is what makes the file a widget. | |
| 448 | + $is_gutenberg = ('gutenberg' === $builder || 'gutenberg_core' === $builder); | |
| 449 | + $gb_registered = false; | |
| 450 | + $gb_block_name = ''; | |
| 451 | + | |
| 452 | + if ($is_gutenberg && $php_code !== '') { | |
| 453 | + // register_block_type() is the normal form; _from_metadata() and the registry | |
| 454 | + // singleton are accepted so hand-written or repackaged blocks are not false-failed. | |
| 455 | + $gb_registered = (bool) preg_match('/\bregister_block_type(?:_from_metadata)?\s*\(/i', $php_code) | |
| 456 | + || (bool) preg_match('/WP_Block_Type_Registry\b[^;]*->\s*register\s*\(/i', $php_code); | |
| 457 | + | |
| 458 | + $gb_name_match = []; | |
| 459 | + if (preg_match('/\bregister_block_type(?:_from_metadata)?\s*\(\s*[\'"]([^\'"]+)[\'"]/i', $php_code, $gb_name_match)) { | |
| 460 | + $gb_block_name = trim($gb_name_match[1]); | |
| 461 | + } | |
| 462 | + } | |
| 463 | + | |
| 220 | 464 | // ── Check 3: Class Name Convention (class_name_convention) ─────────────── |
| 465 | + // Each builder has its OWN class convention — only Elementor uses the Wdkit_ prefix. | |
| 466 | + // Applying the Elementor rule everywhere false-flagged correct code (ClickUp 86d3yk4z8): | |
| 467 | + // Gutenberg blocks have no class at all (warning), and Bricks elements are named | |
| 468 | + // "{Pascal}_Bricks" with no Wdkit_ prefix, so correctly generated Bricks widgets were | |
| 469 | + // reported as an outright "fail". | |
| 221 | 470 | if ($php_code !== '') { |
| 222 | 471 | $class_match = []; |
| 223 | - if (preg_match('/class\s+([A-Za-z0-9_]+)\s+extends\s+/i', $php_code, $class_match)) { | |
| 224 | - $found_class = $class_match[1]; | |
| 225 | - if (strpos($found_class, 'Wdkit_') === 0 || strpos($found_class, 'Wdkit') === 0) { | |
| 472 | + $has_class = (bool) preg_match('/class\s+([A-Za-z0-9_]+)\s+extends\s+([\\\\A-Za-z0-9_]+)/i', $php_code, $class_match); | |
| 473 | + $found_class = $has_class ? $class_match[1] : ''; | |
| 474 | + $parent = $has_class ? ltrim($class_match[2], '\\') : ''; | |
| 475 | + | |
| 476 | + if ($is_gutenberg) { | |
| 477 | + // The Wdkit_ class rule genuinely does not apply here — but "no class rule" was | |
| 478 | + // previously emitted as a bare "skip", which left Gutenberg with no structural | |
| 479 | + // check at all, so arbitrary non-widget PHP validated as valid (ClickUp 86d41ccj9). | |
| 480 | + // Registration is the equivalent structural requirement: with no register_block_type() | |
| 481 | + // call the file defines no block, and update-widget would happily write it to disk. | |
| 482 | + if (!$gb_registered) { | |
| 226 | 483 | $checks[] = [ |
| 484 | + 'rule' => 'block_registration', | |
| 485 | + 'status' => 'fail', | |
| 486 | + 'message' => 'No register_block_type() call found. A Gutenberg widget must register its block — wrap the widget in a wb_{Folder}() function that calls register_block_type( \'wdkit/wb-{hash}\', [...] ) and hook it on init.', | |
| 487 | + ]; | |
| 488 | + } else { | |
| 489 | + $gb_reg_message = 'Block registers via register_block_type(). Class naming does not apply: Gutenberg widgets are function-based by design.'; | |
| 490 | + $gb_reg_status = 'pass'; | |
| 491 | + | |
| 492 | + // The generator always hooks registration on init. Registering at include time | |
| 493 | + // is a warning, not a fail: the loader include may itself already run on init. | |
| 494 | + if (!preg_match('/add_action\s*\(\s*[\'"]init[\'"]/i', $php_code)) { | |
| 495 | + $gb_reg_status = 'warning'; | |
| 496 | + $gb_reg_message = 'register_block_type() is present but no add_action( \'init\', ... ) hook was found. Block registration must run on init or WordPress may not pick the block up.'; | |
| 497 | + } | |
| 498 | + | |
| 499 | + $checks[] = [ | |
| 500 | + 'rule' => 'block_registration', | |
| 501 | + 'status' => $gb_reg_status, | |
| 502 | + 'message' => $gb_reg_message, | |
| 503 | + ]; | |
| 504 | + } | |
| 505 | + } elseif ('bricks' === $builder) { | |
| 506 | + // Bricks discovers the class itself via Bricks\Elements::register_element( $file ), | |
| 507 | + // so no Wdkit_ prefix is required — only that it extends \Bricks\Element. | |
| 508 | + if (!$has_class) { | |
| 509 | + $checks[] = [ | |
| 227 | 510 | 'rule' => 'class_name_convention', |
| 228 | - 'status' => 'pass', | |
| 229 | - 'message' => "Class name '{$found_class}' follows WDesignKit naming convention (Wdkit_...).", | |
| 511 | + 'status' => 'fail', | |
| 512 | + 'message' => 'Bricks elements must declare a class extending \\Bricks\\Element. No class declaration found.', | |
| 230 | 513 | ]; |
| 514 | + } elseif (strcasecmp($parent, 'Bricks\\Element') !== 0) { | |
| 515 | + $checks[] = [ | |
| 516 | + 'rule' => 'class_name_convention', | |
| 517 | + 'status' => 'fail', | |
| 518 | + 'message' => "Class '{$found_class}' extends '{$parent}'. Bricks elements must extend \\Bricks\\Element.", | |
| 519 | + ]; | |
| 520 | + } elseif (substr($found_class, -7) !== '_Bricks') { | |
| 521 | + $checks[] = [ | |
| 522 | + 'rule' => 'class_name_convention', | |
| 523 | + 'status' => 'warning', | |
| 524 | + 'message' => "Class '{$found_class}' extends \\Bricks\\Element correctly but does not use the '_Bricks' suffix (e.g. My_Widget_Bricks). This is a naming convention only and does not affect registration.", | |
| 525 | + ]; | |
| 231 | 526 | } else { |
| 232 | 527 | $checks[] = [ |
| 233 | 528 | 'rule' => 'class_name_convention', |
| 234 | - 'status' => 'fail', | |
| 235 | - 'message' => "Class name '{$found_class}' does not follow WDesignKit convention. Class names should start with Wdkit_ (e.g. Wdkit_my_widget_1a2b3c4d).", | |
| 529 | + 'status' => 'pass', | |
| 530 | + 'message' => "Class name '{$found_class}' follows the Bricks element convention (..._Bricks extends \\Bricks\\Element).", | |
| 236 | 531 | ]; |
| 237 | 532 | } |
| 533 | + } elseif (!$has_class) { | |
| 534 | + $checks[] = [ | |
| 535 | + 'rule' => 'class_name_convention', | |
| 536 | + 'status' => 'warning', | |
| 537 | + 'message' => 'Could not detect a standard "class ... extends ..." declaration in PHP code.', | |
| 538 | + ]; | |
| 539 | + } elseif (strpos($found_class, 'Wdkit') === 0) { | |
| 540 | + $checks[] = [ | |
| 541 | + 'rule' => 'class_name_convention', | |
| 542 | + 'status' => 'pass', | |
| 543 | + 'message' => "Class name '{$found_class}' follows WDesignKit naming convention (Wdkit_...).", | |
| 544 | + ]; | |
| 545 | + } elseif ('elementor' === $builder) { | |
| 546 | + $checks[] = [ | |
| 547 | + 'rule' => 'class_name_convention', | |
| 548 | + 'status' => 'fail', | |
| 549 | + 'message' => "Class name '{$found_class}' does not follow WDesignKit convention. Class names should start with Wdkit_ (e.g. Wdkit_my_widget_1a2b3c4d).", | |
| 550 | + ]; | |
| 238 | 551 | } else { |
| 552 | + // Builder unknown and the class matches no known signature. The Wdkit_ prefix is an | |
| 553 | + // Elementor rule, so asserting it here is what produced the spurious Bricks failures | |
| 554 | + // (ClickUp 86d41cd15) — report it, but do not fail a builder we could not identify. | |
| 239 | 555 | $checks[] = [ |
| 240 | 556 | 'rule' => 'class_name_convention', |
| 241 | 557 | 'status' => 'warning', |
| 242 | - 'message' => 'Could not detect a standard "class ... extends ..." declaration in PHP code.', | |
| 558 | + 'message' => "Class name '{$found_class}' does not start with Wdkit_. That is the Elementor convention; the target builder could not be determined, so this is not treated as a failure. Pass builder to get a definitive result.", | |
| 243 | 559 | ]; |
| 244 | 560 | } |
| 245 | 561 | } |
| 246 | 562 | |
| @@ -261,23 +577,87 @@ | ||
| 261 | 577 | 'status' => 'fail', |
| 262 | 578 | 'message' => "get_name() return value '{$widget_name_handle}' must start with 'wb-' (e.g. wb-1a2b3c4d) to ensure unique widget registration.", |
| 263 | 579 | ]; |
| 264 | 580 | } |
| 265 | - } else { | |
| 266 | - // Gutenberg/Bricks blocks may use alternative registration hooks | |
| 267 | - if ($builder === 'gutenberg' || $builder === 'gutenberg_core') { | |
| 581 | + } elseif ($is_gutenberg) { | |
| 582 | + // Gutenberg has no get_name(); the unique registration handle is the block name | |
| 583 | + // passed to register_block_type(). This branch used to return an unconditional | |
| 584 | + // "pass" without ever looking at that name (ClickUp 86d41ccj9), so validate the | |
| 585 | + // real handle here: well-formed per WordPress, and not already taken on disk. | |
| 586 | + if ($gb_block_name === '') { | |
| 268 | 587 | $checks[] = [ |
| 269 | - 'rule' => 'get_name_validity', | |
| 270 | - 'status' => 'pass', | |
| 271 | - 'message' => 'Gutenberg block uses register_block_type() hook.', | |
| 588 | + 'rule' => 'block_name_validity', | |
| 589 | + 'status' => $gb_registered ? 'warning' : 'fail', | |
| 590 | + 'message' => $gb_registered | |
| 591 | + ? 'Could not read a literal block name from register_block_type(). Pass the name as a string literal (e.g. \'wdkit/wb-1a2b3c4d\') so it can be validated.' | |
| 592 | + : 'No block name found. A Gutenberg widget must call register_block_type( \'wdkit/wb-{hash}\', [...] ) with its unique handle.', | |
| 272 | 593 | ]; |
| 594 | + } elseif (!preg_match('#^[a-z][a-z0-9-]*/[a-z][a-z0-9-]*$#', $gb_block_name)) { | |
| 595 | + // WordPress rejects malformed names outright (see WP_Block_Type_Registry::register), | |
| 596 | + // so the block would silently never register. | |
| 597 | + $checks[] = [ | |
| 598 | + 'rule' => 'block_name_validity', | |
| 599 | + 'status' => 'fail', | |
| 600 | + 'message' => "Block name '{$gb_block_name}' is not a valid WordPress block name. It must be lowercase 'namespace/slug' using only letters, numbers and dashes (e.g. wdkit/wb-1a2b3c4d). WordPress refuses to register malformed names, so the block would never appear.", | |
| 601 | + ]; | |
| 273 | 602 | } else { |
| 603 | + $gb_slug = substr($gb_block_name, (int) strpos($gb_block_name, '/') + 1); | |
| 604 | + $gb_dupes = wdesignkit_mcp_find_conflicting_block_files($gb_block_name, $folder); | |
| 605 | + | |
| 606 | + if (!empty($gb_dupes)) { | |
| 607 | + $checks[] = [ | |
| 608 | + 'rule' => 'block_name_validity', | |
| 609 | + 'status' => 'fail', | |
| 610 | + 'message' => "Block name '{$gb_block_name}' is already registered by another widget on this site (" . implode(', ', $gb_dupes) . '). Block names must be unique — the duplicate registration is dropped by WordPress and one of the two widgets disappears.', | |
| 611 | + ]; | |
| 612 | + } elseif (strpos($gb_slug, 'wb-') !== 0 && strpos($gb_slug, 'wdkit-') !== 0) { | |
| 613 | + // Naming convention only — a unique non-conventional slug still registers. | |
| 614 | + $checks[] = [ | |
| 615 | + 'rule' => 'block_name_validity', | |
| 616 | + 'status' => 'warning', | |
| 617 | + 'message' => "Block name '{$gb_block_name}' is valid and unique but its slug does not use the 'wb-' prefix (e.g. wdkit/wb-1a2b3c4d). WDesignKit tooling — preview, duplicate, copy/paste and the plugin packager — matches widget blocks by that prefix.", | |
| 618 | + ]; | |
| 619 | + } else { | |
| 620 | + $checks[] = [ | |
| 621 | + 'rule' => 'block_name_validity', | |
| 622 | + 'status' => 'pass', | |
| 623 | + 'message' => "register_block_type() declares valid unique block name '{$gb_block_name}'.", | |
| 624 | + ]; | |
| 625 | + } | |
| 626 | + } | |
| 627 | + } elseif ($builder === 'bricks') { | |
| 628 | + // Bricks elements have no get_name(); the handle lives in the public $name | |
| 629 | + // property. Without this branch every valid Bricks widget drew a spurious | |
| 630 | + // "Missing get_name()" warning (ClickUp 86d3yk4z8). | |
| 631 | + $bricks_name = []; | |
| 632 | + if (preg_match('/public\s+\$name\s*=\s*[\'"]([^\'"]+)[\'"]/i', $php_code, $bricks_name)) { | |
| 633 | + $handle = $bricks_name[1]; | |
| 634 | + if (strpos($handle, 'wb-') === 0 || strpos($handle, 'wdkit-') === 0) { | |
| 635 | + $checks[] = [ | |
| 636 | + 'rule' => 'get_name_validity', | |
| 637 | + 'status' => 'pass', | |
| 638 | + 'message' => "Bricks element declares valid unique handle \$name = '{$handle}'.", | |
| 639 | + ]; | |
| 640 | + } else { | |
| 641 | + $checks[] = [ | |
| 642 | + 'rule' => 'get_name_validity', | |
| 643 | + 'status' => 'fail', | |
| 644 | + 'message' => "Bricks element \$name '{$handle}' must start with 'wb-' or 'wdkit-' to ensure unique element registration.", | |
| 645 | + ]; | |
| 646 | + } | |
| 647 | + } else { | |
| 274 | 648 | $checks[] = [ |
| 275 | 649 | 'rule' => 'get_name_validity', |
| 276 | 650 | 'status' => 'warning', |
| 277 | - 'message' => 'Missing get_name() method returning unique handle string.', | |
| 651 | + 'message' => 'Bricks element is missing a public $name property holding its unique handle.', | |
| 278 | 652 | ]; |
| 279 | 653 | } |
| 654 | + } else { | |
| 655 | + $checks[] = [ | |
| 656 | + 'rule' => 'get_name_validity', | |
| 657 | + 'status' => 'warning', | |
| 658 | + 'message' => 'Missing get_name() method returning unique handle string.', | |
| 659 | + ]; | |
| 280 | 660 | } |
| 281 | 661 | } |
| 282 | 662 | |
| 283 | 663 | // ── Check 5: Asset Dependency Methods (asset_dependencies_present) ───────── |
| @@ -284,9 +664,36 @@ | ||
| 284 | 664 | if ($php_code !== '') { |
| 285 | 665 | $has_script_deps = (bool) preg_match('/function\s+get_script_depends\s*\(/i', $php_code); |
| 286 | 666 | $has_style_deps = (bool) preg_match('/function\s+get_style_depends\s*\(/i', $php_code); |
| 287 | 667 | |
| 288 | - if ($has_script_deps && $has_style_deps) { | |
| 668 | + if ($is_gutenberg) { | |
| 669 | + // Gutenberg has no get_script_depends()/get_style_depends(); a block declares its | |
| 670 | + // assets by registering handles and naming them in the register_block_type() args. | |
| 671 | + // Previously this check appended nothing at all for Gutenberg (ClickUp 86d41ccj9). | |
| 672 | + $gb_missing_assets = []; | |
| 673 | + if (!preg_match('/\bwp_(?:register|enqueue)_script\s*\(/i', $php_code)) { | |
| 674 | + $gb_missing_assets[] = 'wp_register_script()'; | |
| 675 | + } | |
| 676 | + // The handle key is quoted in generated code ("'editor_script' => 'handle'"), so the | |
| 677 | + // closing quote sits between the key and the arrow. | |
| 678 | + if (!preg_match('/\b(?:editor_script|script|editor_script_handles|script_handles|view_script)\b[\'"]?\s*=>/i', $php_code)) { | |
| 679 | + $gb_missing_assets[] = "an 'editor_script' entry in the register_block_type() args"; | |
| 680 | + } | |
| 681 | + | |
| 682 | + if (empty($gb_missing_assets)) { | |
| 683 | + $checks[] = [ | |
| 684 | + 'rule' => 'asset_dependencies_present', | |
| 685 | + 'status' => 'pass', | |
| 686 | + 'message' => 'Block registers its script handle and declares it in the register_block_type() args.', | |
| 687 | + ]; | |
| 688 | + } else { | |
| 689 | + $checks[] = [ | |
| 690 | + 'rule' => 'asset_dependencies_present', | |
| 691 | + 'status' => 'warning', | |
| 692 | + 'message' => 'Missing recommended block asset wiring: ' . implode(', ', $gb_missing_assets) . '. Without an editor script the block has no edit-time UI and will not appear in the inserter.', | |
| 693 | + ]; | |
| 694 | + } | |
| 695 | + } elseif ($has_script_deps && $has_style_deps) { | |
| 289 | 696 | $checks[] = [ |
| 290 | 697 | 'rule' => 'asset_dependencies_present', |
| 291 | 698 | 'status' => 'pass', |
| 292 | 699 | 'message' => 'get_script_depends() and get_style_depends() methods are present.', |
| @@ -308,10 +715,14 @@ | ||
| 308 | 715 | } |
| 309 | 716 | |
| 310 | 717 | // ── Check 6: CSS Syntax (css_syntax) ───────────────────────────────────── |
| 311 | 718 | if ($css_code !== '') { |
| 312 | - $curly_open = substr_count($css_code, '{'); | |
| 313 | - $curly_close = substr_count($css_code, '}'); | |
| 719 | + // Comments and strings excluded, so "/* } */" and content: "{" no longer count | |
| 720 | + // (ClickUp 86d41cd0a). Line comments off — "//" is not a comment in CSS. | |
| 721 | + $css_counts = wdesignkit_mcp_count_structural_braces($css_code, false); | |
| 722 | + $curly_open = $css_counts['curly_open']; | |
| 723 | + $curly_close = $css_counts['curly_close']; | |
| 724 | + | |
| 314 | 725 | if ($curly_open === $curly_close) { |
| 315 | 726 | $checks[] = [ |
| 316 | 727 | 'rule' => 'css_syntax', |
| 317 | 728 | 'status' => 'pass', |
| @@ -320,19 +731,25 @@ | ||
| 320 | 731 | } else { |
| 321 | 732 | $checks[] = [ |
| 322 | 733 | 'rule' => 'css_syntax', |
| 323 | 734 | 'status' => 'fail', |
| 324 | - 'message' => "Mismatched CSS curly braces: {$curly_open} opening '{' vs {$curly_close} closing '}'.", | |
| 735 | + 'message' => "Mismatched CSS curly braces: {$curly_open} opening '{' vs {$curly_close} closing '}' (comments and strings excluded).", | |
| 325 | 736 | ]; |
| 326 | 737 | } |
| 327 | 738 | } |
| 328 | 739 | |
| 329 | 740 | // ── Check 7: JS Syntax (js_syntax) ─────────────────────────────────────── |
| 741 | + // Same comment/string exclusion as the CSS check above. A mismatch is reported as a warning | |
| 742 | + // rather than a failure: a character counter cannot parse JS — a regex literal such as /}/ | |
| 743 | + // still contributes a brace — and this check as a hard failure was rejecting all six installed | |
| 744 | + // Gutenberg Core widgets, which blocked update-widget(validate:true) from saving valid code. | |
| 745 | + // A heuristic this approximate must not be the thing that refuses a write. | |
| 330 | 746 | if ($js_code !== '') { |
| 331 | - $paren_open = substr_count($js_code, '('); | |
| 332 | - $paren_close = substr_count($js_code, ')'); | |
| 333 | - $curly_open = substr_count($js_code, '{'); | |
| 334 | - $curly_close = substr_count($js_code, '}'); | |
| 747 | + $js_counts = wdesignkit_mcp_count_structural_braces($js_code, true); | |
| 748 | + $paren_open = $js_counts['paren_open']; | |
| 749 | + $paren_close = $js_counts['paren_close']; | |
| 750 | + $curly_open = $js_counts['curly_open']; | |
| 751 | + $curly_close = $js_counts['curly_close']; | |
| 335 | 752 | |
| 336 | 753 | if ($paren_open === $paren_close && $curly_open === $curly_close) { |
| 337 | 754 | $checks[] = [ |
| 338 | 755 | 'rule' => 'js_syntax', |
| @@ -341,10 +758,10 @@ | ||
| 341 | 758 | ]; |
| 342 | 759 | } else { |
| 343 | 760 | $checks[] = [ |
| 344 | 761 | 'rule' => 'js_syntax', |
| 345 | - 'status' => 'fail', | |
| 346 | - 'message' => "Mismatched JS parentheses/braces: ({$paren_open}/{$paren_close} '()', {$curly_open}/{$curly_close} '{}').", | |
| 762 | + 'status' => 'warning', | |
| 763 | + 'message' => "Unbalanced JS parentheses/braces outside comments and strings: ({$paren_open}/{$paren_close} '()', {$curly_open}/{$curly_close} '{}'). This is a heuristic — regex literals can skew it — so review the JS rather than treating it as definitive.", | |
| 347 | 764 | ]; |
| 348 | 765 | } |
| 349 | 766 | } |
| 350 | 767 | |