PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 All 129 releases
wdesignkit / includes / abilities / widgets / wdesignkit-validate-widget.php

wdesignkit-validate-widget.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.0, at includes/abilities/widgets/wdesignkit-validate-widget.php

790 lines 39.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Ability: Perform pre-write, read-only validation of WDesignKit widget PHP, CSS, and JS code.
4 */
5
6 declare(strict_types=1);
7
8 if (!defined('ABSPATH')) {
9 exit();
10 }
11
12 wp_register_ability('wdesignkit/validate-widget', [
13 'label' => __('Validate WDesignKit Widget Code', 'wdesignkit'),
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, 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 'wdesignkit',
17 ),
18 'category' => 'wdesignkit',
19 'input_schema' => [
20 'type' => 'object',
21 'properties' => [
22 'php_code' => [
23 'type' => 'string',
24 'description' => 'PHP file content to validate.',
25 ],
26 'css_code' => [
27 'type' => 'string',
28 'description' => 'CSS file content to validate.',
29 ],
30 'js_code' => [
31 'type' => 'string',
32 'description' => 'JS file content to validate.',
33 ],
34 'builder' => [
35 'type' => 'string',
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 ],
39 'folder' => [
40 'type' => 'string',
41 'description' => 'Widget folder name. Reads existing files from disk if php_code is omitted.',
42 ],
43 'widget_id' => [
44 'type' => 'string',
45 'description' => 'Widget unique ID. Used to locate widget folder if folder is omitted.',
46 ],
47 'name' => [
48 'type' => 'string',
49 'description' => 'Widget display name. Used for class name and hash validation.',
50 ],
51 ],
52 'additionalProperties' => false,
53 ],
54 'output_schema' => [
55 'type' => 'object',
56 'properties' => [
57 'success' => ['type' => 'boolean'],
58 'valid' => ['type' => 'boolean'],
59 'message' => ['type' => 'string'],
60 'checks' => ['type' => 'array'],
61 'auto_fix_available' => ['type' => 'boolean'],
62 'fixed_php_code' => ['type' => 'string'],
63 ],
64 ],
65 'execute_callback' => 'wdesignkit_mcp_validate_widget',
66 'permission_callback' => 'wdesignkit_mcp_permission_callback',
67 'meta' => [
68 'public' => true,
69 'show_in_rest' => true,
70 'mcp' => ['public' => true],
71 'annotations' => [
72 'instructions' => implode("\n", [
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.',
75 'Use this before calling wdesignkit/update-widget to catch syntax and structural errors early.',
76 'If top-level namespace is found, auto_fix_available will be true and fixed_php_code will contain sanitized PHP.',
77 ]),
78 'readonly' => true,
79 'destructive' => false,
80 'idempotent' => true,
81 ],
82 ],
83 ]);
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
250 function wdesignkit_mcp_validate_widget(array $input): array {
251 set_time_limit(60);
252
253 $builder = sanitize_text_field((string) ($input['builder'] ?? ''));
254 $folder = sanitize_file_name((string) ($input['folder'] ?? ''));
255 $widget_id = sanitize_text_field((string) ($input['widget_id'] ?? ''));
256 $name = sanitize_text_field((string) ($input['name'] ?? ''));
257
258 $php_code = (string) ($input['php_code'] ?? '');
259 $css_code = (string) ($input['css_code'] ?? '');
260 $js_code = (string) ($input['js_code'] ?? '');
261
262 // Read existing files from disk if code fields are empty and folder/widget_id is supplied
263 if ($php_code === '' && $css_code === '' && $js_code === '' && defined('WDKIT_BUILDER_PATH')) {
264 $allowed_builders = ['elementor', 'gutenberg', 'gutenberg_core', 'bricks'];
265 $builders_to_check = ($builder !== '' && in_array($builder, $allowed_builders, true)) ? [$builder] : $allowed_builders;
266
267 foreach ($builders_to_check as $b) {
268 $b_dir = WDKIT_BUILDER_PATH . '/' . $b;
269 if (!is_dir($b_dir)) {
270 continue;
271 }
272 $subfolders = ($folder !== '') ? [$folder] : array_diff(@scandir($b_dir) ?: [], ['.', '..']);
273 foreach ($subfolders as $sub) {
274 $dir_path = $b_dir . '/' . $sub;
275 if (!is_dir($dir_path)) {
276 continue;
277 }
278 $files = array_diff(@scandir($dir_path) ?: [], ['.', '..']);
279 foreach ($files as $f) {
280 $ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
281 $fp = $dir_path . '/' . $f;
282 if ($ext === 'php' && $php_code === '') {
283 $php_code = (string) @file_get_contents($fp);
284 } elseif ($ext === 'css' && $css_code === '') {
285 $css_code = (string) @file_get_contents($fp);
286 } elseif ($ext === 'js' && $js_code === '') {
287 $js_code = (string) @file_get_contents($fp);
288 } elseif ($ext === 'json') {
289 $raw = @file_get_contents($fp);
290 $data = ($raw !== false) ? json_decode($raw, true) : null;
291 $wd = $data['widget_data']['widgetdata'] ?? [];
292 if ($name === '') {
293 $name = (string) ($wd['name'] ?? '');
294 }
295 if ($widget_id === '') {
296 $widget_id = (string) ($wd['widget_id'] ?? '');
297 }
298 }
299 }
300 if ($php_code !== '') {
301 $builder = $b;
302 $folder = $sub;
303 break 2;
304 }
305 }
306 }
307 }
308
309 if ($php_code === '' && $css_code === '' && $js_code === '') {
310 return [
311 'success' => false,
312 'valid' => false,
313 'message' => 'Provide php_code, css_code, js_code, or a valid local widget folder/widget_id.',
314 'checks' => [],
315 'auto_fix_available' => false,
316 ];
317 }
318
319 $checks = [];
320 $auto_fix_available = false;
321 $fixed_php_code = $php_code;
322
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.
334 if ($php_code !== '') {
335 $syntax_status = 'pass';
336 $syntax_msg = 'PHP syntax is valid.';
337
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 {
350 try {
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();
357 } catch (\Throwable $e) {
358 $syntax_status = 'fail';
359 $syntax_msg = 'PHP could not be parsed: ' . $e->getMessage();
360 }
361 }
362
363 $checks[] = [
364 'rule' => 'php_syntax',
365 'status' => $syntax_status,
366 'message' => $syntax_msg,
367 ];
368 }
369
370 // ── Check 2: No Namespace Declaration (no_namespace_declaration) ─────────
371 if ($php_code !== '') {
372 $has_namespace = (bool) preg_match('/\bnamespace\s+[A-Za-z0-9_\\\\]+\s*;/i', $php_code);
373
374 if ($has_namespace) {
375 $auto_fix_available = true;
376 $fixed_php_code = preg_replace('/\bnamespace\s+[A-Za-z0-9_\\\\]+\s*;/i', '', $php_code, 1) ?? $php_code;
377
378 $checks[] = [
379 'rule' => 'no_namespace_declaration',
380 'status' => 'fail',
381 'message' => 'Forbidden top-level namespace declaration detected. WDesignKit widget loaders instantiate classes by bare global names (e.g. Wdkit_...). Namespace declarations cause fatal "Class not found" errors.',
382 'details' => 'Top-level namespace declaration can be safely stripped using fixed_php_code.',
383 ];
384 } else {
385 $checks[] = [
386 'rule' => 'no_namespace_declaration',
387 'status' => 'pass',
388 'message' => 'No forbidden top-level namespace declaration found.',
389 ];
390 }
391 }
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
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".
470 if ($php_code !== '') {
471 $class_match = [];
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) {
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[] = [
510 'rule' => 'class_name_convention',
511 'status' => 'fail',
512 'message' => 'Bricks elements must declare a class extending \\Bricks\\Element. No class declaration found.',
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 ];
526 } else {
527 $checks[] = [
528 'rule' => 'class_name_convention',
529 'status' => 'pass',
530 'message' => "Class name '{$found_class}' follows the Bricks element convention (..._Bricks extends \\Bricks\\Element).",
531 ];
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 ];
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.
555 $checks[] = [
556 'rule' => 'class_name_convention',
557 'status' => 'warning',
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.",
559 ];
560 }
561 }
562
563 // ── Check 4: get_name() Validity (get_name_validity) ──────────────────────
564 if ($php_code !== '') {
565 $name_match = [];
566 if (preg_match('/function\s+get_name\s*\(\s*\)\s*\{[^}]*return\s+[\'\"]([^\'\"]+)[\'\"]/i', $php_code, $name_match)) {
567 $widget_name_handle = $name_match[1];
568 if (strpos($widget_name_handle, 'wb-') === 0 || strpos($widget_name_handle, 'wdkit-') === 0) {
569 $checks[] = [
570 'rule' => 'get_name_validity',
571 'status' => 'pass',
572 'message' => "get_name() returns valid unique handle '{$widget_name_handle}'.",
573 ];
574 } else {
575 $checks[] = [
576 'rule' => 'get_name_validity',
577 'status' => 'fail',
578 'message' => "get_name() return value '{$widget_name_handle}' must start with 'wb-' (e.g. wb-1a2b3c4d) to ensure unique widget registration.",
579 ];
580 }
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 === '') {
587 $checks[] = [
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.',
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 ];
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 {
648 $checks[] = [
649 'rule' => 'get_name_validity',
650 'status' => 'warning',
651 'message' => 'Bricks element is missing a public $name property holding its unique handle.',
652 ];
653 }
654 } else {
655 $checks[] = [
656 'rule' => 'get_name_validity',
657 'status' => 'warning',
658 'message' => 'Missing get_name() method returning unique handle string.',
659 ];
660 }
661 }
662
663 // ── Check 5: Asset Dependency Methods (asset_dependencies_present) ─────────
664 if ($php_code !== '') {
665 $has_script_deps = (bool) preg_match('/function\s+get_script_depends\s*\(/i', $php_code);
666 $has_style_deps = (bool) preg_match('/function\s+get_style_depends\s*\(/i', $php_code);
667
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) {
696 $checks[] = [
697 'rule' => 'asset_dependencies_present',
698 'status' => 'pass',
699 'message' => 'get_script_depends() and get_style_depends() methods are present.',
700 ];
701 } elseif ($builder === 'elementor' || $builder === 'bricks' || $builder === '') {
702 $missing_methods = [];
703 if (!$has_script_deps) {
704 $missing_methods[] = 'get_script_depends()';
705 }
706 if (!$has_style_deps) {
707 $missing_methods[] = 'get_style_depends()';
708 }
709 $checks[] = [
710 'rule' => 'asset_dependencies_present',
711 'status' => 'warning',
712 'message' => 'Missing recommended asset dependency method(s): ' . implode(', ', $missing_methods) . '. These methods load the widget\'s CSS/JS on the frontend.',
713 ];
714 }
715 }
716
717 // ── Check 6: CSS Syntax (css_syntax) ─────────────────────────────────────
718 if ($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
725 if ($curly_open === $curly_close) {
726 $checks[] = [
727 'rule' => 'css_syntax',
728 'status' => 'pass',
729 'message' => 'CSS formatting and curly braces are balanced.',
730 ];
731 } else {
732 $checks[] = [
733 'rule' => 'css_syntax',
734 'status' => 'fail',
735 'message' => "Mismatched CSS curly braces: {$curly_open} opening '{' vs {$curly_close} closing '}' (comments and strings excluded).",
736 ];
737 }
738 }
739
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.
746 if ($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'];
752
753 if ($paren_open === $paren_close && $curly_open === $curly_close) {
754 $checks[] = [
755 'rule' => 'js_syntax',
756 'status' => 'pass',
757 'message' => 'JS parentheses and curly braces are balanced.',
758 ];
759 } else {
760 $checks[] = [
761 'rule' => 'js_syntax',
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.",
764 ];
765 }
766 }
767
768 // Determine overall validity (fails if any check status is 'fail')
769 $all_passed = true;
770 foreach ($checks as $check) {
771 if (($check['status'] ?? '') === 'fail') {
772 $all_passed = false;
773 break;
774 }
775 }
776
777 $summary_message = $all_passed
778 ? 'Widget code passed all validation checks.'
779 : 'Widget code failed one or more validation checks.';
780
781 return [
782 'success' => true,
783 'valid' => $all_passed,
784 'message' => $summary_message,
785 'checks' => $checks,
786 'auto_fix_available' => $auto_fix_available,
787 'fixed_php_code' => $auto_fix_available ? $fixed_php_code : '',
788 ];
789 }
790