Skip to content

Commit 6366c6c

Browse files
authored
feat: add theme check sandbox command (#169)
1 parent d4779f3 commit 6366c6c

7 files changed

Lines changed: 348 additions & 3 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"external-adapter-contract-smoke": "tsx scripts/external-adapter-contract-smoke.ts",
3939
"runtime-episode-smoke": "tsx scripts/runtime-episode-smoke.ts",
4040
"core-phpunit-command-smoke": "tsx scripts/core-phpunit-command-smoke.ts",
41+
"theme-check-normalization-smoke": "tsx scripts/theme-check-normalization-smoke.ts",
4142
"phpunit-diagnostic-artifact-smoke": "tsx scripts/phpunit-diagnostic-artifact-smoke.ts",
4243
"plugin-check-normalization-smoke": "tsx scripts/plugin-check-normalization-smoke.ts",
4344
"recipe-bench-smoke": "tsx scripts/recipe-bench-smoke.ts",
@@ -56,7 +57,7 @@
5657
"recipe-staged-files-smoke": "tsx scripts/recipe-staged-files-smoke.ts",
5758
"wp-codebox": "node packages/cli/dist/index.js",
5859
"wordpress-plugin-smoke": "php tests/smoke-wordpress-plugin.php",
59-
"check": "npm run build && npm run discovery-command-smoke && npm run agent-sandbox-code-smoke && npm run policy-validation-smoke && npm run wordpress-plugin-smoke && npm run package-distribution-smoke && npm run artifact-contract-smoke && npm run external-adapter-contract-smoke && npm run runtime-episode-smoke && npm run core-phpunit-command-smoke && npm run plugin-check-normalization-smoke && npm run recipe-bench-smoke && npm run recipe-dry-run-smoke && npm run recipe-workflow-phases-smoke && npm run recipe-site-seed-smoke && npm run recipe-staged-files-smoke && npm run preview-port-smoke && npm run preview-public-url-canonical-smoke && npm run preview-response-body-smoke && npm run boot-preview-smoke && npm run blueprint-validation-smoke && npm run browser-probe-artifact-smoke && npm run wp-codebox -- run --mount ./examples/simple-plugin:/wordpress/wp-content/plugins/simple-plugin --command wordpress.run-php --arg code-file=./examples/simple-plugin/probe.php --artifacts ./artifacts --json"
60+
"check": "npm run build && npm run discovery-command-smoke && npm run theme-check-normalization-smoke && npm run agent-sandbox-code-smoke && npm run policy-validation-smoke && npm run wordpress-plugin-smoke && npm run package-distribution-smoke && npm run artifact-contract-smoke && npm run external-adapter-contract-smoke && npm run runtime-episode-smoke && npm run core-phpunit-command-smoke && npm run plugin-check-normalization-smoke && npm run recipe-bench-smoke && npm run recipe-dry-run-smoke && npm run recipe-workflow-phases-smoke && npm run recipe-site-seed-smoke && npm run recipe-staged-files-smoke && npm run preview-port-smoke && npm run preview-public-url-canonical-smoke && npm run preview-response-body-smoke && npm run boot-preview-smoke && npm run blueprint-validation-smoke && npm run browser-probe-artifact-smoke && npm run wp-codebox -- run --mount ./examples/simple-plugin:/wordpress/wp-content/plugins/simple-plugin --command wordpress.run-php --arg code-file=./examples/simple-plugin/probe.php --artifacts ./artifacts --json"
6061
},
6162
"workspaces": [
6263
"packages/*"

packages/cli/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,16 @@ const commandCatalog: CommandMetadata[] = [
536536
policyRequirement: "Runtime policy commands must include wordpress.core-phpunit.",
537537
recipe: true,
538538
},
539+
{
540+
id: "wordpress.theme-check",
541+
description: "Run Theme Check against a mounted WordPress theme inside the disposable Playground runtime.",
542+
acceptedArgs: [
543+
{ name: "theme", description: "Theme slug under wp-content/themes.", required: true, format: "slug" },
544+
],
545+
outputShape: "Normalized Theme Check JSON plus files/theme-check raw and normalized artifacts.",
546+
policyRequirement: "Runtime policy commands must include wordpress.theme-check.",
547+
recipe: true,
548+
},
539549
{
540550
id: "wordpress.browser-probe",
541551
description: "Open the live Playground preview in Playwright and capture browser console, page errors, and screenshot artifacts.",

packages/runtime-playground/src/blueprint.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ export function normalizeBlueprint(blueprint: unknown): { extraLibraries?: unkno
1717
}
1818

1919
export function playgroundBlueprint(blueprint: unknown, policy: RuntimeCreateSpec["policy"], siteUrl?: string): unknown {
20-
const needsWpCli = policy.commands.includes("wordpress.wp-cli") || policy.commands.includes("wordpress.plugin-check")
20+
const needsWpCli = policy.commands.includes("wordpress.wp-cli") || policy.commands.includes("wordpress.plugin-check") || policy.commands.includes("wordpress.theme-check")
2121
const needsPluginCheck = policy.commands.includes("wordpress.plugin-check")
22+
const needsThemeCheck = policy.commands.includes("wordpress.theme-check")
2223
if (!siteUrl && !needsWpCli && !needsPluginCheck) {
2324
return blueprint
2425
}
@@ -37,6 +38,11 @@ export function playgroundBlueprint(blueprint: unknown, policy: RuntimeCreateSpe
3738
pluginData: { resource: "wordpress.org/plugins", slug: "plugin-check" },
3839
options: { activate: true },
3940
}] : []),
41+
...(needsThemeCheck ? [{
42+
step: "installPlugin",
43+
pluginData: { resource: "wordpress.org/plugins", slug: "theme-check" },
44+
options: { activate: true },
45+
}] : []),
4046
...steps,
4147
],
4248
}

packages/runtime-playground/src/commands.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,194 @@ function numberField(record: Record<string, unknown>, key: string): number | und
238238
return undefined
239239
}
240240

241+
export interface ThemeCheckFinding {
242+
type: string
243+
severity: "error" | "warning" | "required" | "recommended" | "info" | "unknown"
244+
message: string
245+
}
246+
247+
export interface ThemeCheckNormalizedOutput {
248+
schema: "wp-codebox/theme-check/v1"
249+
command: "wordpress.theme-check"
250+
targetTheme: string
251+
status: "passed" | "failed" | "error"
252+
exitCode: number
253+
summary: {
254+
total: number
255+
errors: number
256+
warnings: number
257+
required: number
258+
recommended: number
259+
info: number
260+
unknown: number
261+
}
262+
findings: ThemeCheckFinding[]
263+
raw: {
264+
format: "json" | "text"
265+
parseError?: string
266+
}
267+
}
268+
269+
export function themeCheckRunCode(theme: string): string {
270+
return `$theme_slug = ${JSON.stringify(theme)};
271+
$plugin_dir = WP_PLUGIN_DIR . '/theme-check';
272+
if (!file_exists($plugin_dir . '/theme-check.php')) {
273+
throw new RuntimeException('Theme Check plugin is not installed in the sandbox.');
274+
}
275+
276+
require_once $plugin_dir . '/checkbase.php';
277+
require_once $plugin_dir . '/main.php';
278+
279+
$theme = wp_get_theme($theme_slug);
280+
if (!$theme->exists()) {
281+
throw new RuntimeException("Theme '{$theme_slug}' not found.");
282+
}
283+
284+
$success = run_themechecks_against_theme($theme, $theme_slug);
285+
$messages = array();
286+
global $themechecks;
287+
foreach ($themechecks as $check) {
288+
if ($check instanceof themecheck) {
289+
$error = (array) $check->getError();
290+
if (!empty($error)) {
291+
$messages = array_merge($messages, $error);
292+
}
293+
}
294+
}
295+
296+
$processed = array_map(function ($message) {
297+
if (preg_match('/<span[^>]*>(.*?)<\/span>(.*)/', $message, $matches)) {
298+
$key = $matches[1];
299+
$value = $matches[2];
300+
} else {
301+
$key = '';
302+
$value = $message;
303+
}
304+
305+
$key = wp_strip_all_tags($key);
306+
$key = html_entity_decode($key, ENT_QUOTES, 'UTF-8');
307+
$key = rtrim($key, ':');
308+
309+
$value = wp_strip_all_tags($value);
310+
$value = html_entity_decode($value, ENT_QUOTES, 'UTF-8');
311+
$value = ltrim($value, ': ');
312+
313+
return array(
314+
'type' => trim($key),
315+
'value' => trim($value),
316+
);
317+
}, $messages);
318+
319+
echo wp_json_encode(array(
320+
'exitCode' => $success ? 0 : 1,
321+
'findings' => $processed,
322+
), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);`
323+
}
324+
325+
export function normalizeThemeCheckOutput(rawOutput: string, exitCode: number, targetTheme: string): ThemeCheckNormalizedOutput {
326+
const trimmed = cleanWpCliOutput(rawOutput).trim()
327+
const findings: ThemeCheckFinding[] = []
328+
let format: ThemeCheckNormalizedOutput["raw"]["format"] = "json"
329+
let parseError: string | undefined
330+
let effectiveExitCode = exitCode
331+
332+
try {
333+
const parsed = trimmed ? JSON.parse(extractJsonPayload(trimmed)) : []
334+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && typeof parsed.exitCode === "number") {
335+
effectiveExitCode = parsed.exitCode
336+
}
337+
const rows = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.findings) ? parsed.findings : undefined
338+
if (!rows) {
339+
throw new Error("Theme Check JSON output must be an array or an object with findings")
340+
}
341+
342+
for (const item of rows) {
343+
if (!item || typeof item !== "object") {
344+
continue
345+
}
346+
347+
const record = item as Record<string, unknown>
348+
const rawType = typeof record.type === "string" ? record.type.trim() : ""
349+
const rawMessage = typeof record.value === "string" ? record.value.trim() : ""
350+
const prefixed = !rawType ? rawMessage.match(/^(ERROR|WARNING|REQUIRED|RECOMMENDED|INFO)\s*:?\s*(.*)$/i) : null
351+
const type = prefixed?.[1] ?? rawType
352+
const message = prefixed?.[2]?.trim() || rawMessage
353+
if (!type && !message) {
354+
continue
355+
}
356+
357+
findings.push({
358+
type,
359+
severity: themeCheckSeverity(type),
360+
message,
361+
})
362+
}
363+
} catch (error) {
364+
format = "text"
365+
parseError = error instanceof Error ? error.message : String(error)
366+
if (trimmed) {
367+
findings.push({ type: "raw", severity: "unknown", message: trimmed })
368+
}
369+
}
370+
371+
const summary = {
372+
total: findings.length,
373+
errors: findings.filter((finding) => finding.severity === "error").length,
374+
warnings: findings.filter((finding) => finding.severity === "warning").length,
375+
required: findings.filter((finding) => finding.severity === "required").length,
376+
recommended: findings.filter((finding) => finding.severity === "recommended").length,
377+
info: findings.filter((finding) => finding.severity === "info").length,
378+
unknown: findings.filter((finding) => finding.severity === "unknown").length,
379+
}
380+
381+
return {
382+
schema: "wp-codebox/theme-check/v1",
383+
command: "wordpress.theme-check",
384+
targetTheme,
385+
status: format === "text" && effectiveExitCode !== 0 ? "error" : effectiveExitCode === 0 ? "passed" : "failed",
386+
exitCode: effectiveExitCode,
387+
summary,
388+
findings,
389+
raw: {
390+
format,
391+
...(parseError ? { parseError } : {}),
392+
},
393+
}
394+
}
395+
396+
function extractJsonPayload(output: string): string {
397+
const firstObject = output.indexOf("{")
398+
const firstArray = output.indexOf("[")
399+
const starts = [firstObject, firstArray].filter((index) => index >= 0)
400+
if (starts.length === 0) {
401+
return output
402+
}
403+
404+
const start = Math.min(...starts)
405+
const end = output[start] === "[" ? output.lastIndexOf("]") : output.lastIndexOf("}")
406+
return end > start ? output.slice(start, end + 1) : output.slice(start)
407+
}
408+
409+
function themeCheckSeverity(type: string): ThemeCheckFinding["severity"] {
410+
const normalized = type.trim().toLowerCase()
411+
if (["error", "errors"].includes(normalized)) {
412+
return "error"
413+
}
414+
if (["warning", "warnings"].includes(normalized)) {
415+
return "warning"
416+
}
417+
if (["required", "requirement"].includes(normalized)) {
418+
return "required"
419+
}
420+
if (["recommended", "recommendation"].includes(normalized)) {
421+
return "recommended"
422+
}
423+
if (["info", "information", "notice"].includes(normalized)) {
424+
return "info"
425+
}
426+
427+
return "unknown"
428+
}
241429
export function phpunitRunCode(options: PhpunitRunCodeOptions): string {
242430
return `error_reporting(E_ALL);
243431
ini_set('display_errors', '1');

0 commit comments

Comments
 (0)