Add JSON Schema validation for JSON files with a $schema property#2325
Add JSON Schema validation for JSON files with a $schema property#2325westonruter merged 9 commits intotrunkfrom
$schema property#2325Conversation
This change introduces a new validation script to ensure that JSON files (such as Playground blueprints and .wp-env.json) are correctly formatted according to their defined schemas. - Implement `bin/validate-json-schema.js` using `ajv`. - Add `lint-json` script to `package.json`. - Integrate JSON schema validation into `lint-staged` and GitHub Actions. - Update `blueprint.json` to use `pluginData` instead of deprecated `pluginZipFile`. - Fix trailing comma in `tsconfig.json` to make it valid JSON. Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
| @@ -12,8 +12,8 @@ | |||
There was a problem hiding this comment.
Can the JSON Schema validator increase the strictness to catch deprecations?
There was a problem hiding this comment.
Seems like the AJV does not consider the deprecated as a error as deprecated is a annotation.
There was a problem hiding this comment.
From my quick research, I found that if we want to handle this in CI, we’ll need to write a custom schema validator to flag deprecated annotations.
There was a problem hiding this comment.
This is a working example I created with the help of Claude that can flag deprecated values.
Show patch
diff --git a/bin/validate-json-schema.js b/bin/validate-json-schema.js
index 9a7084a4..11647213 100755
--- a/bin/validate-json-schema.js
+++ b/bin/validate-json-schema.js
@@ -30,6 +30,226 @@ const ajv = new Ajv( {
} );
addFormats( ajv );
+/**
+ * Fetches the full schema from URL.
+ *
+ * @param {string} schemaUrl The schema URL.
+ * @return {Promise<Object>} The schema object.
+ */
+async function fetchSchema( schemaUrl ) {
+ const response = await fetch( schemaUrl );
+ if ( ! response.ok ) {
+ throw new Error( `Failed to fetch schema from ${ schemaUrl }` );
+ }
+ return await response.json();
+}
+
+/**
+ * Resolves $ref in schema to get the actual definition.
+ *
+ * @param {string} ref The $ref string.
+ * @param {Object} rootSchema The root schema object.
+ * @return {Object|null} The resolved schema definition.
+ */
+function resolveRef( ref, rootSchema ) {
+ if ( ! ref || ! ref.startsWith( '#/' ) ) {
+ return null;
+ }
+
+ const pathParts = ref.substring( 2 ).split( '/' );
+ let current = rootSchema;
+
+ for ( const part of pathParts ) {
+ if ( ! current || ! current[ part ] ) {
+ return null;
+ }
+ current = current[ part ];
+ }
+
+ return current;
+}
+
+/**
+ * Finds the matching oneOf/anyOf schema based on data.
+ *
+ * @param {Array} schemas Array of possible schemas.
+ * @param {*} data The data to match against.
+ * @param {Object} rootSchema Root schema for resolving refs.
+ * @return {Object|null} The matched schema.
+ */
+function findMatchingSchema( schemas, data, rootSchema ) {
+ if ( ! Array.isArray( schemas ) || ! data || typeof data !== 'object' ) {
+ return null;
+ }
+
+ // Try to find a match using discriminator (step property)
+ for ( const schema of schemas ) {
+ let resolvedSchema = schema;
+ if ( schema.$ref ) {
+ resolvedSchema = resolveRef( schema.$ref, rootSchema );
+ }
+
+ if ( ! resolvedSchema ) {
+ continue;
+ }
+
+ // Check if this schema matches based on discriminator
+ if ( resolvedSchema.properties ) {
+ // Look for const values that match data
+ let matches = true;
+ for ( const [ key, propSchema ] of Object.entries(
+ resolvedSchema.properties
+ ) ) {
+ if (
+ propSchema.const !== undefined &&
+ data[ key ] !== undefined
+ ) {
+ if ( propSchema.const !== data[ key ] ) {
+ matches = false;
+ break;
+ }
+ }
+ }
+ if ( matches ) {
+ return resolvedSchema;
+ }
+ }
+ }
+
+ return schemas[ 0 ] || null;
+}
+
+/**
+ * Fully resolves a schema, including nested oneOf/anyOf.
+ *
+ * @param {Object} schema The schema to resolve.
+ * @param {Object} data The data to match against (for oneOf/anyOf).
+ * @param {Object} rootSchema Root schema for $ref resolution.
+ * @return {Object|null} Fully resolved schema.
+ */
+function fullyResolveSchema( schema, data, rootSchema ) {
+ if ( ! schema ) {
+ return null;
+ }
+
+ let current = schema;
+
+ // Resolve $ref
+ if ( current.$ref ) {
+ current = resolveRef( current.$ref, rootSchema );
+ if ( ! current ) {
+ return null;
+ }
+ }
+
+ // If this schema has oneOf/anyOf, we need to match and resolve again
+ if ( current.oneOf && data ) {
+ const matched = findMatchingSchema( current.oneOf, data, rootSchema );
+ if ( matched ) {
+ // Recursively resolve the matched schema
+ return fullyResolveSchema( matched, data, rootSchema );
+ }
+ } else if ( current.anyOf && data ) {
+ const matched = findMatchingSchema( current.anyOf, data, rootSchema );
+ if ( matched ) {
+ // Recursively resolve the matched schema
+ return fullyResolveSchema( matched, data, rootSchema );
+ }
+ }
+
+ return current;
+}
+
+/**
+ * Recursively validates deprecated properties.
+ *
+ * @param {Object} schema Current schema.
+ * @param {*} data Current data.
+ * @param {Object} rootSchema Root schema for $ref resolution.
+ * @param {string} path Current path for error reporting.
+ * @return {Array} Array of deprecation warnings.
+ */
+function validateDeprecated( schema, data, rootSchema, path = '' ) {
+ const warnings = [];
+
+ if ( ! schema || typeof schema !== 'object' ) {
+ return warnings;
+ }
+
+ // Resolve $ref
+ let currentSchema = schema;
+ if ( schema.$ref ) {
+ currentSchema = resolveRef( schema.$ref, rootSchema );
+ if ( ! currentSchema ) {
+ return warnings;
+ }
+ }
+
+ // Handle arrays
+ if ( Array.isArray( data ) ) {
+ if ( currentSchema.items ) {
+ data.forEach( ( item, index ) => {
+ const itemPath = `${ path }[${ index }]`;
+
+ // Fully resolve the item schema
+ const itemSchema = fullyResolveSchema(
+ currentSchema.items,
+ item,
+ rootSchema
+ );
+
+ if ( itemSchema ) {
+ warnings.push(
+ ...validateDeprecated(
+ itemSchema,
+ item,
+ rootSchema,
+ itemPath
+ )
+ );
+ }
+ } );
+ }
+ return warnings;
+ }
+
+ // Handle objects
+ if ( data && typeof data === 'object' ) {
+ // Check each property
+ if ( currentSchema.properties ) {
+ for ( const [ key, value ] of Object.entries( data ) ) {
+ const propSchema = currentSchema.properties[ key ];
+ if ( ! propSchema ) {
+ continue;
+ }
+
+ const propPath = path ? `${ path }.${ key }` : key;
+
+ // Check if this property itself is deprecated
+ if ( propSchema.deprecated ) {
+ const msg =
+ typeof propSchema.deprecated === 'string'
+ ? propSchema.deprecated
+ : 'This property is deprecated';
+ warnings.push( { path: propPath, message: msg } );
+ }
+
+ // Recursively check the value
+ warnings.push(
+ ...validateDeprecated(
+ propSchema,
+ value,
+ rootSchema,
+ propPath
+ )
+ );
+ }
+ }
+ }
+
+ return warnings;
+}
+
/**
* Validates a JSON file against its schema.
*
@@ -61,6 +281,7 @@ async function validateFile( filePath ) {
console.log( `Validating ${ filePath } against schema: ${ data.$schema }` );
try {
+ // Standard validation
const validate = await ajv.compileAsync( { $ref: data.$schema } );
const valid = validate( data );
@@ -71,6 +292,30 @@ async function validateFile( filePath ) {
} );
process.exit( 1 );
}
+
+ // Fetch the full schema for deprecation checking
+ const fullSchema = await fetchSchema( data.$schema );
+
+ // Resolve root schema if it has $ref
+ let rootSchema = fullSchema;
+ if ( fullSchema.$ref ) {
+ rootSchema = resolveRef( fullSchema.$ref, fullSchema );
+ }
+
+ if ( ! rootSchema ) {
+ console.error( 'Failed to resolve root schema' );
+ return;
+ }
+
+ const deprecations = validateDeprecated( rootSchema, data, fullSchema );
+
+ if ( deprecations.length > 0 ) {
+ console.error( `\nDeprecation errors in ${ filePath }:` );
+ deprecations.forEach( ( warning ) => {
+ console.error( `- /${ warning.path }: ${ warning.message }` );
+ } );
+ process.exit( 1 );
+ }
} catch ( error ) {
console.error( `Error validating ${ filePath }: ${ error.message }` );
process.exit( 1 );
Co-authored-by: Aditya Dhade <76063440+b1ink0@users.noreply.github.com>
94a98ac to
17bbc45
Compare
| "phpExtensionBundles": [ | ||
| "kitchen-sink" | ||
| ], |
There was a problem hiding this comment.
phpExtensionBundles is also no longer needed, as all the required PHP extensions are now bundled by default.
New PHP Extensions Support Modern Development Workflows
In 2024, Playground established a solid foundation with core PHP extensions such as bcmath, xml, curl, and mbstring. In 2025, the support extended to:
XDebug – available in CLI with IDE integration.
SOAP, OPCache, ImageMagick, GD 2.3.3, Intl, and Exif PHP extensions.
WebP and AVIF image formats.
Networking is now enabled by default on playground.wordpress.net, allowing PHP to request any URL.
There’s also an ongoing exploration to use XDebug through browser devtools and without an IDE.
ref: https://make.wordpress.org/playground/2025/12/03/wordpress-playground-2025-year-in-review/
Co-authored-by: Aditya Dhade <76063440+b1ink0@users.noreply.github.com>
|
For some reason the PHP 8.2 tests are failing: https://github.com/WordPress/performance/actions/runs/20760428538/job/59613625259 |
Co-authored-by: Aditya Dhade <76063440+b1ink0@users.noreply.github.com>
|
Tests are failing in PHP 8.2 with: That problematic code is apparently coming from: One of these two lines: |
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
|
It looks like the test failures are due to doctrine/instantiator now requiring PHP 8.4: doctrine/instantiator@21dcb5f#diff-2df8a5c5513815c4adb6b34cccc7d1b1233aa0474ec4c3c055446106ab33e075 - private const SERIALIZATION_FORMAT_USE_UNSERIALIZER = 'C';
- private const SERIALIZATION_FORMAT_AVOID_UNSERIALIZER = 'O';
+ private const string SERIALIZATION_FORMAT_USE_UNSERIALIZER = 'C';
+ private const string SERIALIZATION_FORMAT_AVOID_UNSERIALIZER = 'O'; |
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces JSON schema validation for JSON files with a $schema property to improve code quality and catch configuration errors early. It also fixes the WordPress Playground blueprint configuration for the Performance Lab plugin, which was using deprecated properties and preventing the preview button from appearing on wordpress.org.
Key changes:
- Implements a new validation script using Ajv to validate JSON files against their declared schemas
- Integrates JSON schema validation into lint-staged and GitHub Actions CI pipeline
- Updates blueprint.json to use
pluginDatainstead of deprecatedpluginZipFileand removes the deprecatedpasswordproperty - Fixes trailing comma in tsconfig.json to make it valid JSON
Reviewed changes
Copilot reviewed 5 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| bin/validate-json-schema.js | New script that validates JSON files with $schema properties using Ajv, with custom deprecation keyword support |
| package.json | Adds ajv 8.17.1 and ajv-formats 3.0.1 dependencies; adds lint-json script |
| package-lock.json | Locks dependency versions for ajv and ajv-formats, updates dependency tree |
| lint-staged.config.js | Adds JSON validation to pre-commit hooks for all **/*.json files |
| .github/workflows/js-lint.yml | Integrates JSON schema validation step in CI workflow |
| plugins/performance-lab/.wordpress-org/blueprints/blueprint.json | Fixes deprecated properties: replaces pluginZipFile with pluginData, removes password field, removes phpExtensionBundles |
| tsconfig.json | Removes trailing comma on line 15 to make file valid JSON |
| .github/workflows/php-test-plugins.yml | Adds doctrine/instantiator version constraint for PHP 8.2 (appears unrelated to this PR's stated purpose) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@westonruter I've opened a new pull request, #2326, to work on those changes. Once the pull request is ready, I'll request review from you. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 8 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## trunk #2325 +/- ##
=======================================
Coverage 68.87% 68.87%
=======================================
Files 90 90
Lines 7618 7618
=======================================
Hits 5247 5247
Misses 2371 2371
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
… add/json-validation-and-fix-blueprint
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 7 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ajv.removeKeyword( 'deprecated' ); | ||
| ajv.addKeyword( { | ||
| keyword: 'deprecated', | ||
| validate: ( /** @type {string|boolean} */ deprecation ) => ! deprecation, | ||
| error: { | ||
| message: ( cxt ) => { | ||
| return cxt.schema && typeof cxt.schema === 'string' | ||
| ? `is deprecated: ${ cxt.schema }` | ||
| : 'is deprecated'; | ||
| }, | ||
| }, | ||
| } ); |
There was a problem hiding this comment.
Just one thing: these are what get logged when the lint is run with the deprecated password field. It seems like it’s logging some extra things see the logs below.
Show log
$ npm run lint-json
> lint-json
> node bin/validate-json-schema.js
Validating .wp-env.json against schema: https://schemas.wp.org/trunk/wp-env.json
Validating plugins/performance-lab/.wordpress-org/blueprints/blueprint.json against schema: https://playground.wordpress.net/blueprint-schema.json
Validation failed for plugins/performance-lab/.wordpress-org/blueprints/blueprint.json:
- /steps/0 must have required property 'pluginPath'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'themeFolderName'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'fromPath'
- /steps/0 must have required property 'toPath'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'consts'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'siteUrl'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'file'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'wordPressFilesZip'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'pluginData'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'themeData'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0/password is deprecated: The password field is deprecated and will be removed in a future version.
Only the username field is required for user authentication.
- /steps/0 must have required property 'path'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'fromPath'
- /steps/0 must have required property 'toPath'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'request'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'path'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'path'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'code'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'options'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'options'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'sql'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'options'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'extractToPath'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'meta'
- /steps/0 must have required property 'userId'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'data'
- /steps/0 must have required property 'path'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'filesTree'
- /steps/0 must have required property 'writeToPath'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'command'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must have required property 'language'
- /steps/0 must NOT have additional properties
- /steps/0 must NOT have additional properties
- /steps/0/step must be equal to constant
- /steps/0 must match exactly one schema in oneOf
- /steps/0 must be string
- /steps/0 must NOT be valid
- /steps/0 must be boolean
- /steps/0 must be equal to constant
- /steps/0 must be null
- /steps/0 must match a schema in anyOfBut for root-level properties like phpExtensionBundles, it doesn’t log any extra things.
$ npm run lint-json
> lint-json
> node bin/validate-json-schema.js
Validating .wp-env.json against schema: https://schemas.wp.org/trunk/wp-env.json
Validating plugins/performance-lab/.wordpress-org/blueprints/blueprint.json against schema: https://playground.wordpress.net/blueprint-schema.json
Validation failed for plugins/performance-lab/.wordpress-org/blueprints/blueprint.json:
- /phpExtensionBundles is deprecated: No longer used. Feel free to remove it from your Blueprint.If this is expected behavior, then we can ignore it.
There was a problem hiding this comment.
Yeah, I think that's expected. It gets really noisy. You can see this in other JSON validator too: https://www.jsonschemavalidator.net/s/EsS95EwE
|
Follow-up: #2329 |
$schema property
This incorporates changes from <WordPress/performance#2325> Co-authored-by: westonruter <westonruter@git.wordpress.org> Co-authored-by: b1ink0 <b1ink0@git.wordpress.org> Co-authored-by: iqbal-web <iqbal1hossain@git.wordpress.org>
This incorporates changes from <WordPress/performance#2325> Co-authored-by: westonruter <westonruter@git.wordpress.org> Co-authored-by: b1ink0 <b1ink0@git.wordpress.org> Co-authored-by: iqbal-web <iqbal1hossain@git.wordpress.org>

Summary
This change introduces a new validation script to ensure that JSON files (such as Playground blueprints and .wp-env.json) are correctly formatted according to their defined schemas.
bin/validate-json-schema.jsusingajv.lint-jsonscript topackage.json.lint-stagedand GitHub Actions.blueprint.jsonto usepluginDatainstead of deprecatedpluginZipFile.tsconfig.jsonto make it valid JSON.Fixes #2279
Closes #2280
Use of AI Tools
I used Gemini CLI with Gemini 3 author these changes.
My prompts:
@bin/validate-json-schema.jsin that@plugins/performance-lab/.wordpress-org/blueprints/blueprint.jsonhas a password field which is apparently deprecated, but this is not getting picked up in the validation. Can the validation logic be made more strict to check for deprecated properties?