Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:25:36 +08:00
commit cfadf66888
24 changed files with 4160 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
# WordPress Ability API Scripts
This directory contains automation scripts for scaffolding and validating WordPress Abilities and Categories.
## Scripts
### Ability Scripts
- **scaffold-ability.php** - Generate ability code from CLI arguments
- **validate-ability.php** - Validate PHP ability registration code
### Category Scripts
- **scaffold-category.php** - Generate category registration code (PHP or JS)
- **validate-category.php** - Validate PHP category registration code
- **validate-category.js** - Validate JavaScript category registration code
## Requirements
### PHP Scripts
- PHP 7.0+ (uses `token_get_all()` for parsing)
- No external dependencies
### JavaScript Validator
- Node.js
- acorn parser
To install acorn:
```bash
npm install acorn
```
Or install globally:
```bash
npm install -g acorn
```
## Usage
See individual script headers for detailed usage instructions.
Quick examples:
```bash
# Scaffold a server-side ability
php scaffold-ability.php --name="plugin/ability" --type="server" --category="data-retrieval"
# Scaffold a category
php scaffold-category.php --name="custom-category" --label="Custom" --description="Description" --type="server"
# Validate PHP category
php validate-category.php path/to/category.php
# Validate JavaScript category
node validate-category.js path/to/category.js
```

View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"acorn": "^8.15.0"
}
}

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env php
<?php
/**
* WordPress Ability Scaffolding Script
*
* Generates ability registration code from templates based on command-line arguments.
* Designed to be called by AI agents to quickly scaffold abilities.
*
* Usage:
* php scaffold-ability.php --name="plugin/ability" --type="server" --category="data-retrieval"
*
* Required Arguments:
* --name Ability name in format "namespace/ability-name"
* --type Type of ability: "server" or "client"
* --category Category slug (e.g., "data-retrieval", "data-modification")
*
* Optional Arguments:
* --label Human-readable label (default: generated from name)
* --description Detailed description (default: placeholder)
* --readonly Whether ability only reads data: "true" or "false" (default: false)
* --destructive Whether ability can delete data: "true" or "false" (default: true)
* --idempotent Whether repeated calls are safe: "true" or "false" (default: false)
*
* Output:
* Complete ability registration code printed to stdout
*
* Exit Codes:
* 0 - Success
* 1 - Missing required arguments
* 2 - Invalid argument values
*/
// Parse command-line arguments
$options = getopt('', [
'name:',
'type:',
'category:',
'label::',
'description::',
'readonly::',
'destructive::',
'idempotent::',
]);
// Validate required arguments
$required = ['name', 'type', 'category'];
$missing = array_diff($required, array_keys($options));
if (!empty($missing)) {
fwrite(STDERR, "Error: Missing required arguments: " . implode(', ', $missing) . "\n");
fwrite(STDERR, "Usage: php scaffold-ability.php --name=\"plugin/ability\" --type=\"server\" --category=\"data-retrieval\"\n");
exit(1);
}
// Extract and validate arguments
$name = $options['name'];
$type = strtolower($options['type']);
$category = $options['category'];
// Validate ability name format
if (!preg_match('/^[a-z0-9-]+\/[a-z0-9-]+$/', $name)) {
fwrite(STDERR, "Error: Invalid ability name format. Must be 'namespace/ability-name' (lowercase, hyphens allowed)\n");
exit(2);
}
// Validate type
if (!in_array($type, ['server', 'client'])) {
fwrite(STDERR, "Error: Invalid type. Must be 'server' or 'client'\n");
exit(2);
}
// Parse optional arguments with defaults
$label = $options['label'] ?? generate_label_from_name($name);
$description = $options['description'] ?? 'TODO: Add a detailed description of what this ability does.';
$readonly = parse_bool($options['readonly'] ?? 'false');
$destructive = parse_bool($options['destructive'] ?? 'true');
$idempotent = parse_bool($options['idempotent'] ?? 'false');
// Load the appropriate template and generate code
$template_file = $type === 'server'
? __DIR__ . '/../assets/server-ability-template.php'
: __DIR__ . '/../assets/client-ability-template.js';
if (!file_exists($template_file)) {
fwrite(STDERR, "Error: Template file not found: {$template_file}\n");
exit(2);
}
$template = file_get_contents($template_file);
if ($template === false) {
fwrite(STDERR, "Error: Unable to read template file: {$template_file}\n");
exit(2);
}
// Prepare placeholder replacements
$namespace = explode('/', $name)[0];
$ability_slug = str_replace(['/', '-'], '_', $name);
$placeholders = [
'{{ABILITY_NAME}}' => $name,
'{{NAMESPACE}}' => $namespace,
'{{LABEL}}' => $label,
'{{DESCRIPTION}}' => $description,
'{{CATEGORY}}' => $category,
'{{CALLBACK_FUNCTION}}' => $ability_slug . '_callback',
'{{REGISTER_FUNCTION}}' => $ability_slug . '_register',
'{{READONLY}}' => $readonly ? 'true' : 'false',
'{{DESTRUCTIVE}}' => $destructive ? 'true' : 'false',
'{{IDEMPOTENT}}' => $idempotent ? 'true' : 'false',
];
// Replace placeholders
$output = str_replace(array_keys($placeholders), array_values($placeholders), $template);
// Output the result
echo $output;
exit(0);
/**
* Generate a human-readable label from the ability name.
*
* @param string $name Ability name in format "namespace/ability-name"
* @return string Human-readable label
*/
function generate_label_from_name($name) {
$parts = explode('/', $name);
$ability_part = end($parts);
$words = explode('-', $ability_part);
return ucwords(implode(' ', $words));
}
/**
* Parse boolean string to actual boolean.
*
* @param string $value String value ("true" or "false")
* @return bool
*/
function parse_bool($value) {
return strtolower($value) === 'true';
}

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env php
<?php
/**
* WordPress Ability Category Scaffolding Script
*
* Generates category registration code from templates based on command-line arguments.
* Designed to be called by AI agents to quickly scaffold categories.
*
* Usage:
* php scaffold-category.php --name="category-slug" --label="Category Label" --description="Description" --type="server"
*
* Required Arguments:
* --name Category slug (kebab-case, e.g., "data-retrieval")
* --label Human-readable label
* --description Detailed description of what abilities belong in this category
* --type Type: "server" (PHP) or "client" (JavaScript)
*
* Output:
* Complete category registration code printed to stdout
*
* Exit Codes:
* 0 - Success
* 1 - Missing required arguments
* 2 - Invalid argument values
*/
// Parse command-line arguments
$options = getopt('', [
'name:',
'label:',
'description:',
'type:',
]);
// Validate required arguments
$required = ['name', 'label', 'description', 'type'];
$missing = array_diff($required, array_keys($options));
if (!empty($missing)) {
fwrite(STDERR, "Error: Missing required arguments: " . implode(', ', $missing) . "\n");
fwrite(STDERR, "Usage: php scaffold-category.php --name=\"category-slug\" --label=\"Label\" --description=\"Description\" --type=\"server\"\n");
exit(1);
}
// Extract and validate arguments
$name = $options['name'];
$label = $options['label'];
$description = $options['description'];
$type = strtolower($options['type']);
// Validate category name format (kebab-case)
if (!preg_match('/^[a-z0-9-]+$/', $name)) {
fwrite(STDERR, "Error: Invalid category name format. Must be kebab-case (lowercase, hyphens allowed, e.g., 'data-retrieval')\n");
exit(2);
}
// Validate type
if (!in_array($type, ['server', 'client'])) {
fwrite(STDERR, "Error: Invalid type. Must be 'server' or 'client'\n");
exit(2);
}
// Load the appropriate template
$template_file = $type === 'server'
? __DIR__ . '/../assets/category-template.php'
: __DIR__ . '/../assets/client-category-template.js';
if (!file_exists($template_file)) {
fwrite(STDERR, "Error: Template file not found: {$template_file}\n");
exit(2);
}
$template = file_get_contents($template_file);
if ($template === false) {
fwrite(STDERR, "Error: Unable to read template file: {$template_file}\n");
exit(2);
}
// Prepare placeholder replacements
$register_function = str_replace('-', '_', $name) . '_category_register';
$namespace = 'text-domain'; // Generic text domain for templates
$placeholders = [
'{{CATEGORY_NAME}}' => $name,
'{{LABEL}}' => $label,
'{{DESCRIPTION}}' => $description,
'{{REGISTER_FUNCTION}}' => $register_function,
'{{NAMESPACE}}' => $namespace,
];
// Replace placeholders
$output = str_replace(array_keys($placeholders), array_values($placeholders), $template);
// Output the result
echo $output;
exit(0);

View File

@@ -0,0 +1,439 @@
#!/usr/bin/env node
/**
* WordPress Ability Validation Script (JavaScript)
*
* Validates JavaScript ability registration code using acorn AST parser.
* For PHP validation, use validate-ability.php instead.
*
* Usage:
* node validate-ability.js path/to/ability-file.js
*
* Requirements:
* - Node.js
* - acorn parser: npm install acorn
*
* Arguments:
* file_path Path to the JavaScript file containing ability registration
*
* Exit Codes:
* 0 - Validation passed
* 1 - Validation failed
* 2 - File not found, invalid usage, or missing dependencies
*/
const fs = require('fs');
const path = require('path');
// Check for acorn dependency
let acorn;
try {
acorn = require('acorn');
} catch (e) {
console.error(
'Error: acorn parser not found. Install with: npm install acorn'
);
process.exit(2);
}
// Check for file argument
if (process.argv.length < 3) {
console.error('Usage: node validate-ability.js path/to/ability-file.js');
process.exit(2);
}
const filePath = process.argv[2];
// Check if file exists
if (!fs.existsSync(filePath)) {
console.error(`Error: File not found: ${filePath}`);
process.exit(2);
}
// Check if file is JavaScript
const ext = path.extname(filePath);
if (!['.js', '.jsx', '.ts', '.tsx', '.mjs'].includes(ext)) {
console.error(
'Error: This validator only supports JavaScript files. For PHP validation, use validate-ability.php'
);
process.exit(2);
}
// Read file contents
let content;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (e) {
console.error(`Error: Unable to read file: ${filePath}`);
process.exit(2);
}
// Initialize validation results
const errors = [];
const warnings = [];
// Parse and validate JavaScript abilities
try {
const ast = acorn.parse(content, {
sourceType: 'module',
ecmaVersion: 'latest',
allowAwaitOutsideFunction: true,
});
const abilities = extractAbilities(ast);
if (abilities.length === 0) {
errors.push('No registerAbility() calls found in file');
} else {
abilities.forEach((ability) => {
console.log(`Validating ability: ${ability.name || '(unnamed)'}`);
validateAbility(ability, errors, warnings);
console.log('');
});
}
} catch (e) {
if (e instanceof SyntaxError) {
errors.push(`JavaScript syntax error: ${e.message}`);
} else {
throw e;
}
}
// Output final results
outputResults(filePath, errors, warnings);
// Exit with appropriate code
process.exit(errors.length > 0 ? 1 : 0);
/**
* Extract ability registrations from AST.
*
* @param {Object} ast - Acorn AST
* @returns {Array} Array of ability objects
*/
function extractAbilities(ast) {
const abilities = [];
const processed = new Set(); // Track processed nodes to avoid duplicates
// Walk the AST to find registerAbility calls
walk(ast, (node) => {
let callNode = null;
// Direct call: registerAbility()
if (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
node.callee.name === 'registerAbility' &&
!processed.has(node)
) {
callNode = node;
}
// Await call: await registerAbility()
if (
node.type === 'AwaitExpression' &&
node.argument.type === 'CallExpression' &&
node.argument.callee.type === 'Identifier' &&
node.argument.callee.name === 'registerAbility' &&
!processed.has(node.argument)
) {
callNode = node.argument;
}
if (callNode) {
processed.add(callNode);
const ability = extractAbilityFromCall(callNode);
if (ability) {
abilities.push(ability);
}
}
});
return abilities;
}
/**
* Extract ability data from a CallExpression node.
*
* @param {Object} node - CallExpression AST node
* @returns {Object|null} Ability object or null
*/
function extractAbilityFromCall(node) {
if (node.arguments.length < 1) {
return null;
}
const ability = {
name: null,
label: null,
description: null,
category: null,
hasCallback: false,
hasPermissionCallback: false,
hasInputSchema: false,
hasOutputSchema: false,
annotations: {},
};
// First argument: config object
const configArg = node.arguments[0];
if (configArg.type !== 'ObjectExpression') {
return null; // Config must be an object
}
// Extract properties from config object
configArg.properties.forEach((prop) => {
if (prop.type !== 'Property' || prop.key.type !== 'Identifier') {
return;
}
const key = prop.key.name;
// Extract string fields
if (['name', 'label', 'description', 'category'].includes(key)) {
if (
prop.value.type === 'Literal' &&
typeof prop.value.value === 'string'
) {
ability[key] = prop.value.value;
}
}
// Check for callback (function or arrow function)
if (key === 'callback') {
ability.hasCallback = [
'FunctionExpression',
'ArrowFunctionExpression',
].includes(prop.value.type);
}
// Check for permissionCallback
if (key === 'permissionCallback') {
ability.hasPermissionCallback = [
'FunctionExpression',
'ArrowFunctionExpression',
].includes(prop.value.type);
}
// Check for schemas (object expressions)
if (key === 'inputSchema' && prop.value.type === 'ObjectExpression') {
ability.hasInputSchema = true;
}
if (key === 'outputSchema' && prop.value.type === 'ObjectExpression') {
ability.hasOutputSchema = true;
}
// Extract annotations from meta
if (key === 'meta' && prop.value.type === 'ObjectExpression') {
prop.value.properties.forEach((metaProp) => {
if (
metaProp.type === 'Property' &&
metaProp.key.type === 'Identifier' &&
metaProp.key.name === 'annotations' &&
metaProp.value.type === 'ObjectExpression'
) {
metaProp.value.properties.forEach((annoProp) => {
if (
annoProp.type === 'Property' &&
annoProp.key.type === 'Identifier'
) {
const annoKey = annoProp.key.name;
if (
[
'readonly',
'destructive',
'idempotent',
].includes(annoKey)
) {
if (
annoProp.value.type === 'Literal' &&
typeof annoProp.value.value === 'boolean'
) {
ability.annotations[annoKey] =
annoProp.value.value;
}
}
}
});
}
});
}
});
return ability;
}
/**
* Validate ability data.
*
* @param {Object} ability - Ability data
* @param {Array} errors - Error messages array
* @param {Array} warnings - Warning messages array
*/
function validateAbility(ability, errors, warnings) {
const {
name,
label,
description,
category,
hasCallback,
hasPermissionCallback,
hasInputSchema,
hasOutputSchema,
annotations,
} = ability;
// Validate required fields
if (!name) {
errors.push('Missing required field: name');
return; // Can't continue without a name
}
// Validate name format (namespace/ability-name in kebab-case)
if (!/^[a-z0-9-]+\/[a-z0-9-]+$/.test(name)) {
errors.push(
`Invalid ability name format: '${name}'. Must be 'namespace/ability-name' in kebab-case (e.g., 'my-plugin/do-something')`
);
}
if (!label) {
errors.push(`Missing required field 'label' in ability '${name}'`);
} else {
// Validate label quality
if (label.trim().length < 2) {
warnings.push(
`Label for ability '${name}' is too short. Provide a meaningful label.`
);
}
}
if (!description) {
errors.push(
`Missing required field 'description' in ability '${name}'`
);
} else {
// Validate description quality
const descLength = description.trim().length;
if (descLength < 20) {
warnings.push(
`Description for ability '${name}' is too short (${descLength} chars). Provide detailed information about what this ability does, when to use it, and what parameters it accepts.`
);
}
if (/TODO/i.test(description)) {
warnings.push(
`Description for ability '${name}' contains TODO placeholder. Replace with actual description.`
);
}
}
if (!category) {
errors.push(`Missing required field 'category' in ability '${name}'`);
}
// Validate callback presence
if (!hasCallback) {
errors.push(
`Missing required field 'callback' in ability '${name}'. Abilities must have an execute callback function.`
);
}
// Validate permission callback
if (!hasPermissionCallback) {
warnings.push(
`Missing 'permissionCallback' in ability '${name}'. Consider adding permission checks to control who can execute this ability.`
);
}
// Validate schemas (recommended if ability takes input or provides output)
if (!hasInputSchema) {
warnings.push(
`Missing 'inputSchema' in ability '${name}'. Input schema should be provided if this ability accepts parameters.`
);
}
if (!hasOutputSchema) {
warnings.push(
`Missing 'outputSchema' in ability '${name}'. Output schema should be provided if this ability returns data.`
);
}
// Validate annotations
if ('readonly' in annotations && 'destructive' in annotations) {
if (annotations.readonly === true && annotations.destructive === true) {
warnings.push(
`Conflicting annotations in ability '${name}': readonly=true and destructive=true. A readonly ability should have destructive=false.`
);
}
}
}
/**
* Simple AST walker.
*
* @param {Object} node - AST node
* @param {Function} callback - Function to call for each node
*/
function walk(node, callback) {
if (!node || typeof node !== 'object') {
return;
}
callback(node);
for (const key in node) {
if (
key === 'loc' ||
key === 'range' ||
key === 'start' ||
key === 'end'
) {
continue; // Skip position metadata
}
const value = node[key];
if (Array.isArray(value)) {
value.forEach((child) => walk(child, callback));
} else if (value && typeof value === 'object') {
walk(value, callback);
}
}
}
/**
* Output validation results.
*
* @param {string} filePath - File being validated
* @param {Array} errors - Error messages
* @param {Array} warnings - Warning messages
*/
function outputResults(filePath, errors, warnings) {
console.log('='.repeat(70));
console.log(`Validation Results: ${filePath}`);
console.log('='.repeat(70));
console.log('');
if (errors.length > 0) {
console.log(`ERRORS (${errors.length}):`);
errors.forEach((error) => console.log(`${error}`));
console.log('');
}
if (warnings.length > 0) {
console.log(`WARNINGS (${warnings.length}):`);
warnings.forEach((warning) => console.log(`${warning}`));
console.log('');
}
if (errors.length === 0 && warnings.length === 0) {
console.log('✓ All validations passed! No issues found.');
console.log('');
} else if (errors.length === 0) {
console.log('✓ Validation passed with warnings.');
console.log('');
} else {
console.log('✗ Validation failed. Please fix the errors above.');
console.log('');
}
}

View File

@@ -0,0 +1,306 @@
#!/usr/bin/env php
<?php
/**
* WordPress Ability Validation Script
*
* Validates ability registration code independently of WordPress.
* Checks structure, required fields, and JSON Schema validity.
*
* Usage:
* php validate-ability.php path/to/ability-file.php
*
* Arguments:
* file_path Path to the PHP file containing ability registration code
*
* Exit Codes:
* 0 - Validation passed
* 1 - Validation failed
* 2 - File not found or invalid usage
*/
// Check for file argument
if ($argc < 2) {
fwrite(STDERR, "Usage: php validate-ability.php path/to/ability-file.php\n");
exit(2);
}
$file_path = $argv[1];
// Check if file exists
if (!file_exists($file_path)) {
fwrite(STDERR, "Error: File not found: {$file_path}\n");
exit(2);
}
// Read file contents
$content = file_get_contents($file_path);
if ($content === false) {
fwrite(STDERR, "Error: Unable to read file: {$file_path}\n");
exit(2);
}
// Initialize validation results
$errors = [];
$warnings = [];
// Extract wp_register_ability calls
$pattern = '/wp_register_ability\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*array\s*\((.*?)\)\s*\)\s*;/s';
preg_match_all($pattern, $content, $matches, PREG_SET_ORDER);
if (empty($matches)) {
$errors[] = "No wp_register_ability() calls found in file";
output_results($file_path, $errors, $warnings);
exit(1);
}
// Validate each ability registration
foreach ($matches as $index => $match) {
$ability_name = $match[1];
$args_string = $match[2];
echo "Validating ability: {$ability_name}\n";
// Validate ability name format
if (!preg_match('/^[a-z0-9-]+\/[a-z0-9-]+$/', $ability_name)) {
$errors[] = "Invalid ability name format: '{$ability_name}'. Must be 'namespace/ability-name' (lowercase, hyphens allowed)";
}
// Parse the arguments array
$args = parse_ability_args($args_string);
// Validate required fields
validate_required_fields($ability_name, $args, $errors, $warnings);
// Validate schemas
if (isset($args['input_schema'])) {
validate_json_schema($ability_name, 'input_schema', $args['input_schema'], $errors);
}
if (isset($args['output_schema'])) {
validate_json_schema($ability_name, 'output_schema', $args['output_schema'], $errors);
}
// Best practice checks
check_best_practices($ability_name, $args, $warnings);
echo "\n";
}
// Output final results
output_results($file_path, $errors, $warnings);
// Exit with appropriate code
exit(empty($errors) ? 0 : 1);
/**
* Parse ability registration arguments from string.
*
* This is a simplified parser that extracts key configuration values.
*
* @param string $args_string The arguments array as a string
* @return array Parsed arguments
*/
function parse_ability_args($args_string) {
$args = [];
// Extract simple string/boolean values
$simple_patterns = [
'label' => '/[\'"]label[\'"]\s*=>\s*(?:__\s*\(\s*)?[\'"]([^\'"]+)[\'"]/',
'description' => '/[\'"]description[\'"]\s*=>\s*(?:__\s*\(\s*)?[\'"]([^\'"]+)[\'"]/',
'category' => '/[\'"]category[\'"]\s*=>\s*[\'"]([^\'"]+)[\'"]/',
'execute_callback' => '/[\'"]execute_callback[\'"]\s*=>\s*[\'"]([^\'"]+)[\'"]/',
];
foreach ($simple_patterns as $key => $pattern) {
if (preg_match($pattern, $args_string, $matches)) {
$args[$key] = $matches[1];
}
}
// Check for permission_callback (function or string)
if (preg_match('/[\'"]permission_callback[\'"]\s*=>\s*(.+?),?\s*(?=[\'"][a-z_]+[\'"]|$)/s', $args_string, $matches)) {
$args['permission_callback'] = trim($matches[1]);
}
// Extract schema arrays (look for 'input_schema' and 'output_schema')
if (preg_match('/[\'"]input_schema[\'"]\s*=>\s*array\s*\((.*?)\),?\s*(?=[\'"][a-z_]+[\'"]|$)/s', $args_string, $matches)) {
$args['input_schema'] = $matches[1];
}
if (preg_match('/[\'"]output_schema[\'"]\s*=>\s*array\s*\((.*?)\),?\s*(?=[\'"][a-z_]+[\'"]|$)/s', $args_string, $matches)) {
$args['output_schema'] = $matches[1];
}
// Extract meta array
if (preg_match('/[\'"]meta[\'"]\s*=>\s*array\s*\((.*?)\),?\s*(?:\)|$)/s', $args_string, $matches)) {
$args['meta'] = $matches[1];
}
return $args;
}
/**
* Validate required fields are present.
*
* @param string $ability_name Ability name
* @param array $args Parsed arguments
* @param array &$errors Error messages array
* @param array &$warnings Warning messages array
*/
function validate_required_fields($ability_name, $args, &$errors, &$warnings) {
$required_fields = ['label', 'description', 'category', 'execute_callback'];
foreach ($required_fields as $field) {
if (!isset($args[$field])) {
$errors[] = "Missing required field '{$field}' in ability '{$ability_name}'";
}
}
// Check for empty descriptions (common mistake)
if (isset($args['description']) && strlen(trim($args['description'])) < 10) {
$warnings[] = "Description for '{$ability_name}' is too short. Provide detailed information for AI agents.";
}
// Check for placeholder/TODO descriptions
if (isset($args['description']) && stripos($args['description'], 'TODO') !== false) {
$warnings[] = "Description for '{$ability_name}' contains TODO placeholder. Replace with actual description.";
}
// Warn about missing schemas (recommended if ability takes input or provides output)
if (!isset($args['input_schema'])) {
$warnings[] = "Missing 'input_schema' in ability '{$ability_name}'. Input schema should be provided if this ability accepts parameters.";
}
if (!isset($args['output_schema'])) {
$warnings[] = "Missing 'output_schema' in ability '{$ability_name}'. Output schema should be provided if this ability returns data.";
}
// Warn about missing permission callback
if (!isset($args['permission_callback'])) {
$warnings[] = "Missing 'permission_callback' in ability '{$ability_name}'. Consider adding permission checks to control who can execute this ability.";
}
}
/**
* Validate a JSON Schema structure.
*
* @param string $ability_name Ability name
* @param string $schema_type Schema type ('input_schema' or 'output_schema')
* @param string $schema_string Schema as string
* @param array &$errors Error messages array
*/
function validate_json_schema($ability_name, $schema_type, $schema_string, &$errors) {
// Check for 'type' field (required in JSON Schema)
if (strpos($schema_string, "'type'") === false && strpos($schema_string, '"type"') === false) {
$errors[] = "{$schema_type} for '{$ability_name}' missing 'type' field (required by JSON Schema)";
}
// Check for 'properties' if type is 'object'
if (preg_match('/[\'"]type[\'"]\s*=>\s*[\'"]object[\'"]/', $schema_string)) {
if (strpos($schema_string, "'properties'") === false && strpos($schema_string, '"properties"') === false) {
$errors[] = "{$schema_type} for '{$ability_name}' has type 'object' but missing 'properties' field";
}
}
// Check for 'items' if type is 'array'
if (preg_match('/[\'"]type[\'"]\s*=>\s*[\'"]array[\'"]/', $schema_string)) {
if (strpos($schema_string, "'items'") === false && strpos($schema_string, '"items"') === false) {
$warnings[] = "{$schema_type} for '{$ability_name}' has type 'array' but missing 'items' field (recommended)";
}
}
// Validate that properties have types
if (preg_match_all('/[\'"]properties[\'"]\s*=>\s*array\s*\((.*?)\)/s', $schema_string, $prop_matches)) {
foreach ($prop_matches[1] as $properties) {
// Extract each property definition
if (preg_match_all('/[\'"]([a-z_]+)[\'"]\s*=>\s*array\s*\((.*?)\)/s', $properties, $prop_defs, PREG_SET_ORDER)) {
foreach ($prop_defs as $prop_def) {
$prop_name = $prop_def[1];
$prop_content = $prop_def[2];
// Each property should have a 'type'
if (strpos($prop_content, "'type'") === false && strpos($prop_content, '"type"') === false) {
$errors[] = "{$schema_type} property '{$prop_name}' in '{$ability_name}' missing 'type' field";
}
}
}
}
}
}
/**
* Check for best practices.
*
* @param string $ability_name Ability name
* @param array $args Parsed arguments
* @param array &$warnings Warning messages array
*/
function check_best_practices($ability_name, $args, &$warnings) {
// Check for __return_true permission callback (context-aware security check)
if (isset($args['permission_callback']) && strpos($args['permission_callback'], '__return_true') !== false) {
// Extract annotations to determine if this is a safe public ability
$is_readonly = false;
$is_destructive = true;
if (isset($args['meta']) && strpos($args['meta'], 'annotations') !== false) {
$is_readonly = preg_match('/[\'"]readonly[\'"]\s*=>\s*true/', $args['meta']);
$is_destructive = !preg_match('/[\'"]destructive[\'"]\s*=>\s*false/', $args['meta']);
}
// Only warn if this is potentially dangerous (not readonly OR is destructive)
if (!$is_readonly || $is_destructive) {
$warnings[] = "Ability '{$ability_name}' uses __return_true() for permissions with write/destructive operations. Ensure public access is intentional.";
}
// If it's readonly and non-destructive, __return_true is fine - no warning needed
}
// Check for annotations in meta
if (isset($args['meta'])) {
$has_annotations = strpos($args['meta'], 'annotations') !== false;
if (!$has_annotations) {
$warnings[] = "Ability '{$ability_name}' missing annotations (readonly, destructive, idempotent). These help AI agents understand the ability's behavior.";
}
} else {
$warnings[] = "Ability '{$ability_name}' missing 'meta' array. Consider adding annotations for better discoverability.";
}
// Check category naming (should be kebab-case)
if (isset($args['category']) && !preg_match('/^[a-z0-9-]+$/', $args['category'])) {
$warnings[] = "Category '{$args['category']}' should use kebab-case naming (lowercase with hyphens)";
}
}
/**
* Output validation results.
*
* @param string $file_path File being validated
* @param array $errors Error messages
* @param array $warnings Warning messages
*/
function output_results($file_path, $errors, $warnings) {
echo str_repeat('=', 70) . "\n";
echo "Validation Results: {$file_path}\n";
echo str_repeat('=', 70) . "\n\n";
if (!empty($errors)) {
echo "ERRORS (" . count($errors) . "):\n";
foreach ($errors as $error) {
echo "{$error}\n";
}
echo "\n";
}
if (!empty($warnings)) {
echo "WARNINGS (" . count($warnings) . "):\n";
foreach ($warnings as $warning) {
echo "{$warning}\n";
}
echo "\n";
}
if (empty($errors) && empty($warnings)) {
echo "✓ All validations passed! No issues found.\n\n";
} elseif (empty($errors)) {
echo "✓ Validation passed with warnings.\n\n";
} else {
echo "✗ Validation failed. Please fix the errors above.\n\n";
}
}

View File

@@ -0,0 +1,317 @@
#!/usr/bin/env node
/**
* WordPress Ability Category Validation Script (JavaScript)
*
* Validates JavaScript category registration code using acorn AST parser.
* For PHP validation, use validate-category.php instead.
*
* Usage:
* node validate-category.js path/to/category-file.js
*
* Requirements:
* - Node.js
* - acorn parser: npm install acorn
*
* Arguments:
* file_path Path to the JavaScript file containing category registration
*
* Exit Codes:
* 0 - Validation passed
* 1 - Validation failed
* 2 - File not found, invalid usage, or missing dependencies
*/
const fs = require('fs');
const path = require('path');
// Check for acorn dependency
let acorn;
try {
acorn = require('acorn');
} catch (e) {
console.error(
'Error: acorn parser not found. Install with: npm install acorn'
);
process.exit(2);
}
// Check for file argument
if (process.argv.length < 3) {
console.error('Usage: node validate-category.js path/to/category-file.js');
process.exit(2);
}
const filePath = process.argv[2];
// Check if file exists
if (!fs.existsSync(filePath)) {
console.error(`Error: File not found: ${filePath}`);
process.exit(2);
}
// Check if file is JavaScript
const ext = path.extname(filePath);
if (!['.js', '.jsx', '.ts', '.tsx', '.mjs'].includes(ext)) {
console.error(
'Error: This validator only supports JavaScript files. For PHP validation, use validate-category.php'
);
process.exit(2);
}
// Read file contents
let content;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (e) {
console.error(`Error: Unable to read file: ${filePath}`);
process.exit(2);
}
// Initialize validation results
const errors = [];
const warnings = [];
// Parse and validate JavaScript categories
try {
const ast = acorn.parse(content, {
sourceType: 'module',
ecmaVersion: 'latest',
allowAwaitOutsideFunction: true,
});
const categories = extractCategories(ast);
if (categories.length === 0) {
errors.push('No registerAbilityCategory() calls found in file');
} else {
categories.forEach((category) => {
console.log(`Validating category: ${category.name}`);
validateCategory(category, errors, warnings);
console.log('');
});
}
} catch (e) {
if (e instanceof SyntaxError) {
errors.push(`JavaScript syntax error: ${e.message}`);
} else {
throw e;
}
}
// Output final results
outputResults(filePath, errors, warnings);
// Exit with appropriate code
process.exit(errors.length > 0 ? 1 : 0);
/**
* Extract category registrations from AST.
*
* @param {Object} ast - Acorn AST
* @returns {Array} Array of category objects
*/
function extractCategories(ast) {
const categories = [];
const processed = new Set(); // Track processed nodes to avoid duplicates
// Walk the AST to find registerAbilityCategory calls
walk(ast, (node) => {
let callNode = null;
// Direct call: registerAbilityCategory()
if (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
node.callee.name === 'registerAbilityCategory' &&
!processed.has(node)
) {
callNode = node;
}
// Await call: await registerAbilityCategory()
if (
node.type === 'AwaitExpression' &&
node.argument.type === 'CallExpression' &&
node.argument.callee.type === 'Identifier' &&
node.argument.callee.name === 'registerAbilityCategory' &&
!processed.has(node.argument)
) {
callNode = node.argument;
}
if (callNode) {
processed.add(callNode);
const category = extractCategoryFromCall(callNode);
if (category) {
categories.push(category);
}
}
});
return categories;
}
/**
* Extract category data from a CallExpression node.
*
* @param {Object} node - CallExpression AST node
* @returns {Object|null} Category object or null
*/
function extractCategoryFromCall(node) {
if (node.arguments.length < 2) {
return null;
}
const category = {
name: null,
label: null,
description: null,
};
// First argument: category name (string literal)
const nameArg = node.arguments[0];
if (nameArg.type === 'Literal' && typeof nameArg.value === 'string') {
category.name = nameArg.value;
} else {
return null; // Name must be a string literal
}
// Second argument: config object
const configArg = node.arguments[1];
if (configArg.type === 'ObjectExpression') {
configArg.properties.forEach((prop) => {
if (prop.type === 'Property' && prop.key.type === 'Identifier') {
const key = prop.key.name;
if (
(key === 'label' || key === 'description') &&
prop.value.type === 'Literal' &&
typeof prop.value.value === 'string'
) {
category[key] = prop.value.value;
}
}
});
}
return category;
}
/**
* Validate category data.
*
* @param {Object} category - Category data
* @param {Array} errors - Error messages array
* @param {Array} warnings - Warning messages array
*/
function validateCategory(category, errors, warnings) {
const { name, label, description } = category;
// Validate category name format (kebab-case)
if (!/^[a-z0-9-]+$/.test(name)) {
errors.push(
`Invalid category name format: '${name}'. Must be kebab-case (lowercase, hyphens allowed)`
);
}
// Check required fields
if (!label) {
errors.push(`Missing required field 'label' in category '${name}'`);
} else {
// Validate label quality
if (label.trim().length < 2) {
warnings.push(
`Label for category '${name}' is too short. Provide a meaningful label.`
);
}
}
if (!description) {
errors.push(
`Missing required field 'description' in category '${name}'`
);
} else {
// Validate description quality
const descLength = description.trim().length;
if (descLength < 15) {
warnings.push(
`Description for category '${name}' is too short (${descLength} chars). Provide detailed information about what abilities belong in this category.`
);
}
if (/TODO/i.test(description)) {
warnings.push(
`Description for category '${name}' contains TODO placeholder. Replace with actual description.`
);
}
}
}
/**
* Simple AST walker.
*
* @param {Object} node - AST node
* @param {Function} callback - Function to call for each node
*/
function walk(node, callback) {
if (!node || typeof node !== 'object') {
return;
}
callback(node);
for (const key in node) {
if (
key === 'loc' ||
key === 'range' ||
key === 'start' ||
key === 'end'
) {
continue; // Skip position metadata
}
const value = node[key];
if (Array.isArray(value)) {
value.forEach((child) => walk(child, callback));
} else if (value && typeof value === 'object') {
walk(value, callback);
}
}
}
/**
* Output validation results.
*
* @param {string} filePath - File being validated
* @param {Array} errors - Error messages
* @param {Array} warnings - Warning messages
*/
function outputResults(filePath, errors, warnings) {
console.log('='.repeat(70));
console.log(`Validation Results: ${filePath}`);
console.log('='.repeat(70));
console.log('');
if (errors.length > 0) {
console.log(`ERRORS (${errors.length}):`);
errors.forEach((error) => console.log(`${error}`));
console.log('');
}
if (warnings.length > 0) {
console.log(`WARNINGS (${warnings.length}):`);
warnings.forEach((warning) => console.log(`${warning}`));
console.log('');
}
if (errors.length === 0 && warnings.length === 0) {
console.log('✓ All validations passed! No issues found.');
console.log('');
} else if (errors.length === 0) {
console.log('✓ Validation passed with warnings.');
console.log('');
} else {
console.log('✗ Validation failed. Please fix the errors above.');
console.log('');
}
}

View File

@@ -0,0 +1,325 @@
#!/usr/bin/env php
<?php
/**
* WordPress Ability Category Validation Script (PHP)
*
* Validates PHP category registration code using PHP's built-in tokenizer.
* For JavaScript validation, use validate-category.js instead.
*
* Usage:
* php validate-category.php path/to/category-file.php
*
* Arguments:
* file_path Path to the PHP file containing category registration code
*
* Exit Codes:
* 0 - Validation passed
* 1 - Validation failed
* 2 - File not found or invalid usage
*/
// Check for file argument
if ($argc < 2) {
fwrite(STDERR, "Usage: php validate-category.php path/to/category-file.php\n");
exit(2);
}
$file_path = $argv[1];
// Check if file exists
if (!file_exists($file_path)) {
fwrite(STDERR, "Error: File not found: {$file_path}\n");
exit(2);
}
// Check if file is PHP
$file_extension = pathinfo($file_path, PATHINFO_EXTENSION);
if ($file_extension !== 'php') {
fwrite(STDERR, "Error: This validator only supports PHP files. For JavaScript validation, use validate-category.js\n");
exit(2);
}
// Read file contents
$content = file_get_contents($file_path);
if ($content === false) {
fwrite(STDERR, "Error: Unable to read file: {$file_path}\n");
exit(2);
}
// Initialize validation results
$errors = [];
$warnings = [];
// Parse and validate PHP categories
$categories = parse_php_categories($content);
if (empty($categories)) {
$errors[] = "No wp_register_ability_category() calls found in file";
} else {
foreach ($categories as $category) {
echo "Validating category: {$category['name']}\n";
validate_category($category, $errors, $warnings);
echo "\n";
}
}
// Output final results
output_results($file_path, $errors, $warnings);
// Exit with appropriate code
exit(empty($errors) ? 0 : 1);
/**
* Parse PHP file to extract category registrations using token_get_all().
*
* @param string $content PHP file content
* @return array Array of category data
*/
function parse_php_categories($content) {
$tokens = token_get_all($content);
$categories = [];
$i = 0;
$count = count($tokens);
while ($i < $count) {
$token = $tokens[$i];
// Look for function call: wp_register_ability_category
if (is_array($token) && $token[0] === T_STRING && $token[1] === 'wp_register_ability_category') {
// Found the function call, now extract arguments
$category = extract_category_from_tokens($tokens, $i);
if ($category) {
$categories[] = $category;
}
}
$i++;
}
return $categories;
}
/**
* Extract category data from tokens starting at function call position.
*
* @param array $tokens All tokens
* @param int $start_pos Position of function name token
* @return array|null Category data or null if parsing fails
*/
function extract_category_from_tokens($tokens, $start_pos) {
$i = $start_pos + 1;
$count = count($tokens);
$category = ['name' => null, 'label' => null, 'description' => null];
// Skip whitespace to opening parenthesis
while ($i < $count && is_array($tokens[$i]) && $tokens[$i][0] === T_WHITESPACE) {
$i++;
}
// Should be at opening parenthesis
if ($i >= $count || $tokens[$i] !== '(') {
return null;
}
$i++;
// Skip whitespace to category name
while ($i < $count && is_array($tokens[$i]) && $tokens[$i][0] === T_WHITESPACE) {
$i++;
}
// Extract category name (first argument - string)
if ($i < $count && is_array($tokens[$i]) && ($tokens[$i][0] === T_CONSTANT_ENCAPSED_STRING)) {
$category['name'] = trim($tokens[$i][1], '\'"');
$i++;
} else {
return null; // No category name found
}
// Now look for the array with label and description
// Skip to 'array' keyword or '['
while ($i < $count) {
$token = $tokens[$i];
if (is_array($token) && $token[0] === T_ARRAY) {
// Found 'array(' syntax
$i++;
// Skip to opening parenthesis
while ($i < $count && $tokens[$i] !== '(') {
$i++;
}
if ($i < $count) {
$i++; // Move past '('
$extracted = extract_array_contents($tokens, $i);
$category = array_merge($category, $extracted);
}
break;
} elseif ($token === '[') {
// Found '[' short array syntax
$i++;
$extracted = extract_array_contents($tokens, $i);
$category = array_merge($category, $extracted);
break;
}
$i++;
}
return $category;
}
/**
* Extract label and description from array tokens.
*
* @param array $tokens All tokens
* @param int $start_pos Position after array opening
* @return array Array with label and description keys
*/
function extract_array_contents($tokens, $start_pos) {
$result = ['label' => null, 'description' => null];
$i = $start_pos;
$count = count($tokens);
$depth = 1; // Track nested arrays/parentheses
while ($i < $count && $depth > 0) {
$token = $tokens[$i];
// Track depth for nested structures
if ($token === '(' || $token === '[') {
$depth++;
} elseif ($token === ')' || $token === ']') {
$depth--;
if ($depth === 0) break;
}
// Look for 'label' or 'description' keys
if (is_array($token) && $token[0] === T_CONSTANT_ENCAPSED_STRING) {
$key = trim($token[1], '\'"');
if ($key === 'label' || $key === 'description') {
// Skip to the value (past '=>' and whitespace)
$i++;
// Skip whitespace
while ($i < $count && is_array($tokens[$i]) && $tokens[$i][0] === T_WHITESPACE) {
$i++;
}
// Skip '=>' (T_DOUBLE_ARROW)
if ($i < $count && is_array($tokens[$i]) && $tokens[$i][0] === T_DOUBLE_ARROW) {
$i++;
}
// Skip more whitespace
while ($i < $count && is_array($tokens[$i]) && $tokens[$i][0] === T_WHITESPACE) {
$i++;
}
// Check for __() translation function
if ($i < $count && is_array($tokens[$i]) && $tokens[$i][0] === T_STRING && $tokens[$i][1] === '__') {
// Skip to opening parenthesis of __()
while ($i < $count && $tokens[$i] !== '(') {
$i++;
}
if ($i < $count) {
$depth++; // Track the opening paren
$i++; // Move past '('
}
// Skip whitespace
while ($i < $count && is_array($tokens[$i]) && $tokens[$i][0] === T_WHITESPACE) {
$i++;
}
// Extract string value
if ($i < $count && is_array($tokens[$i]) && $tokens[$i][0] === T_CONSTANT_ENCAPSED_STRING) {
$result[$key] = trim($tokens[$i][1], '\'"');
}
} elseif ($i < $count && is_array($tokens[$i]) && $tokens[$i][0] === T_CONSTANT_ENCAPSED_STRING) {
// Direct string value
$result[$key] = trim($tokens[$i][1], '\'"');
}
// Continue to next iteration without incrementing again
continue;
}
}
$i++;
}
return $result;
}
/**
* Validate category data.
*
* @param array $category Category data
* @param array &$errors Error messages array
* @param array &$warnings Warning messages array
*/
function validate_category($category, &$errors, &$warnings) {
$name = $category['name'];
// Validate category name format (kebab-case)
if (!preg_match('/^[a-z0-9-]+$/', $name)) {
$errors[] = "Invalid category name format: '{$name}'. Must be kebab-case (lowercase, hyphens allowed)";
}
// Check required fields
if (empty($category['label'])) {
$errors[] = "Missing required field 'label' in category '{$name}'";
} else {
// Validate label quality
if (strlen(trim($category['label'])) < 2) {
$warnings[] = "Label for category '{$name}' is too short. Provide a meaningful label.";
}
}
if (empty($category['description'])) {
$errors[] = "Missing required field 'description' in category '{$name}'";
} else {
// Validate description quality
$desc_length = strlen(trim($category['description']));
if ($desc_length < 15) {
$warnings[] = "Description for category '{$name}' is too short ({$desc_length} chars). Provide detailed information about what abilities belong in this category.";
}
if (stripos($category['description'], 'TODO') !== false) {
$warnings[] = "Description for category '{$name}' contains TODO placeholder. Replace with actual description.";
}
}
}
/**
* Output validation results.
*
* @param string $file_path File being validated
* @param array $errors Error messages
* @param array $warnings Warning messages
*/
function output_results($file_path, $errors, $warnings) {
echo str_repeat('=', 70) . "\n";
echo "Validation Results: {$file_path}\n";
echo str_repeat('=', 70) . "\n\n";
if (!empty($errors)) {
echo "ERRORS (" . count($errors) . "):\n";
foreach ($errors as $error) {
echo "{$error}\n";
}
echo "\n";
}
if (!empty($warnings)) {
echo "WARNINGS (" . count($warnings) . "):\n";
foreach ($warnings as $warning) {
echo "{$warning}\n";
}
echo "\n";
}
if (empty($errors) && empty($warnings)) {
echo "✓ All validations passed! No issues found.\n\n";
} elseif (empty($errors)) {
echo "✓ Validation passed with warnings.\n\n";
} else {
echo "✗ Validation failed. Please fix the errors above.\n\n";
}
}