Commit 7ae95323024 for woocommerce

commit 7ae9532302442b34a3fb8d977e5a5e9a19c92a53
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Thu Oct 15 17:22:44 2026 +0200

    Add shared theme CLI (#69819)

    * Add shared theme CLI

    * Add changelog entry for the shared theme CLI

    * update tooling

    * Add shared theme CLI

    * Add changelog entry for the shared theme CLI

    * add build zip

    * update readme

    * Update themes/README.md

    Co-authored-by: Karol Manijak <20098064+kmanijak@users.noreply.github.com>

    * Update tools/theme-cli/README.md

    Co-authored-by: Karol Manijak <20098064+kmanijak@users.noreply.github.com>

    * remove readme

    * improve code

    ---------

    Co-authored-by: Karol Manijak <20098064+kmanijak@users.noreply.github.com>

diff --git a/AGENTS.md b/AGENTS.md
index 07b8d1adbf4..f1598dc6c0f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -257,6 +257,49 @@ WooCommerce names its enumerated string vocabularies — order statuses, product
 - **Respect the plugin lifecycle.** Some code paths (REST controllers, report queries) can run during install or upgrade, before the autoloader resolves classes under `src/`; referencing an enum class there is a fatal (`Class ... not found`). Code that can execute mid-install or mid-upgrade keeps its string literals.
 - **Near-duplicate vocabularies are distinct classes on purpose.** `OrderStatus` holds the unprefixed values (`completed`) most WooCommerce APIs expect; `OrderInternalStatus` holds the `wc-`-prefixed variants (`wc-completed`) WordPress stores. Reach for the class that matches what the consuming API expects.

+## Theme Development
+
+Each theme lives in its own private pnpm workspace package under `themes/<slug>/`, with independent versioning, changelogs, and releases. Shared tooling lives in `tools/theme-cli/` as `@woocommerce/theme-cli`. Register each theme explicitly in `pnpm-workspace.yaml`.
+
+Run setup from the repository root:
+
+```sh
+pnpm install --frozen-lockfile
+composer install --working-dir=tools/theme-cli
+```
+
+### Theme Commands
+
+Replace `<slug>` with the directory name of an existing theme, such as `purple`:
+
+```sh
+# Validate theme metadata, versions, patterns, assets, and PHP coding standards.
+pnpm --filter=@woocommerce/theme-cli validate <slug>
+
+# Build a ZIP from the current commit's theme files.
+pnpm --filter=@woocommerce/theme-cli build <slug>
+
+# Start the theme's local environment using Playground by default.
+pnpm --filter=@woocommerce/theme-cli wp-env start <slug>
+
+# Start using Docker instead.
+pnpm --filter=@woocommerce/theme-cli wp-env start <slug> --runtime=docker
+
+# Stop the theme's local environment.
+pnpm --filter=@woocommerce/theme-cli wp-env stop <slug>
+
+# Check shared tooling formatting.
+pnpm --filter=@woocommerce/theme-cli lint
+```
+
+`@woocommerce/theme-cli` provides the `wp-env` command, dependency, and shared configuration at `tools/theme-cli/.wp-env.json`. Pass a theme slug to select the theme to mount. The CLI runs wp-env from its own package and generates an ignored configuration under `tools/theme-cli/.wp-env/<slug>/` for a separate environment per theme. Local overrides belong in that directory's `.wp-env.override.json`. Playground is the default runtime. Keep the same `WP_ENV_HOME` for startup and shutdown when using an isolated environment.
+
+Add theme changes to that theme's own changelog; the shared CLI changelog is for tooling changes. Keep the theme package version, `style.css` version, and `readme.txt` stable tag in sync. Preserve source provenance and licenses. Live release publishing remains deferred.
+
+Builds write `themes/<slug>/artifacts/<slug>-<version>.zip` with a single theme directory and exclude development files. They package source from the current commit only.
+
+See [the theme CLI README](tools/theme-cli/README.md) and the selected theme's README for setup details. Validation fetches schemas from `schemas.wp.org`; PHP linting uses the shared Composer dependencies and `tools/theme-cli/phpcs.xml.dist`.
+
 ## Block Development

 ### Block Styling
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 60572149bdb..60198c5e871 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3892,6 +3892,15 @@ importers:
         specifier: catalog:tooling
         version: 16.26.1(typescript@5.7.3)

+  tools/theme-cli:
+    devDependencies:
+      '@wordpress/env':
+        specifier: catalog:tooling
+        version: 11.17.0(@types/node@24.12.2)
+      ajv:
+        specifier: ^8.17.1
+        version: 8.18.0
+
   tools/code-analyzer:
     dependencies:
       '@actions/core':
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 10b3e978101..b7da6f3e30f 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -97,6 +97,7 @@ packages:
     - 'plugins/woocommerce/client/blocks'
     - 'plugins/woocommerce/client/legacy'
     - 'tools/monorepo-merge'
+    - 'tools/theme-cli'
     - 'tools/code-analyzer'
     - 'tools/compare-perf'
     - 'tools/eslint-config'
diff --git a/tools/theme-cli/.gitignore b/tools/theme-cli/.gitignore
new file mode 100644
index 00000000000..2b5bb0b9198
--- /dev/null
+++ b/tools/theme-cli/.gitignore
@@ -0,0 +1 @@
+/.wp-env/
diff --git a/tools/theme-cli/.wp-env.json b/tools/theme-cli/.wp-env.json
new file mode 100644
index 00000000000..64ee7c1bf69
--- /dev/null
+++ b/tools/theme-cli/.wp-env.json
@@ -0,0 +1,14 @@
+{
+	"core": "https://wordpress.org/wordpress-latest.zip",
+	"phpVersion": "8.1",
+	"port": 8890,
+	"testsPort": 8891,
+	"testsEnvironment": false,
+	"plugins": [ "https://downloads.wordpress.org/plugin/woocommerce.zip" ],
+	"themes": [],
+	"config": {
+		"JETPACK_AUTOLOAD_DEV": true,
+		"WP_DEBUG_LOG": true,
+		"WP_DEBUG_DISPLAY": true
+	}
+}
diff --git a/tools/theme-cli/README.md b/tools/theme-cli/README.md
new file mode 100644
index 00000000000..c187f8b5531
--- /dev/null
+++ b/tools/theme-cli/README.md
@@ -0,0 +1,55 @@
+# WooCommerce theme CLI
+
+`@woocommerce/theme-cli` provides shared development tooling for WooCommerce themes in `themes/<slug>/`. Themes are versioned independently of WooCommerce Core. Each theme is its own private pnpm workspace package, with its own version, commands, changelog, and release lifecycle. Shared development tooling lives in the private `@woocommerce/theme-cli` workspace package, with shared Composer dependencies in `tools/theme-cli/`. Individual themes keep their development manifests out of installable ZIPs.
+
+## Setup
+
+Use the monorepo's Node and pnpm versions. PHP and Composer are also required.
+
+Run these commands from the repository root:
+
+```bash
+pnpm install
+composer install --working-dir=tools/theme-cli
+```
+
+## Validation
+
+```bash
+pnpm --filter=@woocommerce/theme-cli validate <slug>
+pnpm --filter=@woocommerce/theme-cli lint
+pnpm --filter=@woocommerce/theme-cli changelog add
+```
+
+Validation checks metadata, matching versions, theme JSON and style variations against the minimum supported WordPress schema, pattern references, asset paths, and PHP coding standards. The command fails if either validation or PHP linting fails. Schema validation requires access to `schemas.wp.org`. PHP linting uses `tools/theme-cli/phpcs.xml.dist` and the shared Composer dependencies, selecting the theme's text domain and minimum WordPress version at runtime.
+
+Changelog entries for shared tooling live in `tools/theme-cli/changelog/`, with shared types and versioning from `tools/theme-cli/composer.json`.
+
+## Local environments
+
+The shared CLI starts an environment for the selected theme. For example, once a theme is added:
+
+```bash
+pnpm --filter=@woocommerce/theme-cli wp-env start <slug>
+pnpm --filter=@woocommerce/theme-cli wp-env stop <slug>
+```
+
+`@woocommerce/theme-cli` provides the wp-env command, dependency, and shared configuration in `tools/theme-cli/.wp-env.json`. The slug selects the theme to mount. The CLI runs wp-env from its own package, generating an ignored configuration in `tools/theme-cli/.wp-env/<slug>/.wp-env.json` to keep each theme's environment separate. Local overrides belong in `tools/theme-cli/.wp-env/<slug>/.wp-env.override.json`; relative paths are resolved from `tools/theme-cli/`. Start uses Playground by default; pass `--runtime=docker` to use Docker. Theme packages can expose shortcuts to these shared commands.
+
+## Build a theme ZIP
+
+Validate the theme first, then build its ZIP from the repository root. Replace `<slug>` with the theme directory name:
+
+```bash
+pnpm --filter=@woocommerce/theme-cli validate <slug>
+pnpm --filter=@woocommerce/theme-cli build <slug>
+```
+
+This packages the current commit's theme files into `themes/<slug>/artifacts/<slug>-<version>.zip`, with a single `<slug>/` directory. The version comes from the committed `style.css`. Assets, licenses, and `readme.txt` are included; development manifests, dependencies, local configuration, changelog fragments, and development documentation are excluded. Uncommitted edits are excluded, so commit theme changes before building their ZIP.
+
+Theme content checks belong to `validate`; `build` packages the committed files. ZIP generation does not publish a release.
+
+## Adding a theme
+
+Add a standalone block theme under `themes/<slug>/`, preserving its licenses and using the slug as its text domain. Run validation with that slug, and add a changelog fragment to that theme's `changelog/` directory. Register the theme explicitly in `pnpm-workspace.yaml`; a wildcard is not required.
+
diff --git a/tools/theme-cli/changelog/add-theme-workspace b/tools/theme-cli/changelog/add-theme-workspace
new file mode 100644
index 00000000000..eebdd59f08e
--- /dev/null
+++ b/tools/theme-cli/changelog/add-theme-workspace
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Add shared theme CLI and Playground environment commands; no theme runtime change.
diff --git a/tools/theme-cli/composer.json b/tools/theme-cli/composer.json
new file mode 100644
index 00000000000..49779fbaab5
--- /dev/null
+++ b/tools/theme-cli/composer.json
@@ -0,0 +1,31 @@
+{
+	"name": "woocommerce/theme-cli",
+	"description": "Shared development tools for WooCommerce themes.",
+	"type": "project",
+	"license": "GPL-2.0-or-later",
+	"require-dev": {
+		"automattic/jetpack-changelogger": "3.3.0",
+		"dealerdirect/phpcodesniffer-composer-installer": "^1.1",
+		"wp-coding-standards/wpcs": "^3.3"
+	},
+	"config": {
+		"allow-plugins": {
+			"dealerdirect/phpcodesniffer-composer-installer": true
+		},
+		"platform": {
+			"php": "7.4"
+		},
+		"sort-packages": true
+	},
+	"extra": {
+		"changelogger": {
+			"versioning": "wordpress",
+			"types": {
+				"fix": "Fixes an existing bug",
+				"add": "Adds functionality",
+				"update": "Updates existing functionality",
+				"dev": "Development related task"
+			}
+		}
+	}
+}
diff --git a/tools/theme-cli/composer.lock b/tools/theme-cli/composer.lock
new file mode 100644
index 00000000000..c2af153c6ae
--- /dev/null
+++ b/tools/theme-cli/composer.lock
@@ -0,0 +1,1498 @@
+{
+    "_readme": [
+        "This file locks the dependencies of your project to a known state",
+        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+        "This file is @generated automatically"
+    ],
+    "content-hash": "1dd9cc5d9623438a26f093b938ddec96",
+    "packages": [],
+    "packages-dev": [
+        {
+            "name": "automattic/jetpack-changelogger",
+            "version": "v3.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/Automattic/jetpack-changelogger.git",
+                "reference": "8f63c829b8d1b0d7b1d5de93510d78523ed18959"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/Automattic/jetpack-changelogger/zipball/8f63c829b8d1b0d7b1d5de93510d78523ed18959",
+                "reference": "8f63c829b8d1b0d7b1d5de93510d78523ed18959",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.6",
+                "symfony/console": "^3.4 || ^5.2 || ^6.0",
+                "symfony/process": "^3.4 || ^5.2 || ^6.0",
+                "wikimedia/at-ease": "^1.2 || ^2.0"
+            },
+            "require-dev": {
+                "wikimedia/testing-access-wrapper": "^1.0 || ^2.0",
+                "yoast/phpunit-polyfills": "1.0.4"
+            },
+            "bin": [
+                "bin/changelogger"
+            ],
+            "type": "project",
+            "extra": {
+                "autotagger": true,
+                "mirror-repo": "Automattic/jetpack-changelogger",
+                "branch-alias": {
+                    "dev-trunk": "3.3.x-dev"
+                },
+                "changelogger": {
+                    "link-template": "https://github.com/Automattic/jetpack-changelogger/compare/${old}...${new}"
+                },
+                "version-constants": {
+                    "::VERSION": "src/Application.php"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Automattic\\Jetpack\\Changelog\\": "lib",
+                    "Automattic\\Jetpack\\Changelogger\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "GPL-2.0-or-later"
+            ],
+            "description": "Jetpack Changelogger tool. Allows for managing changelogs by dropping change files into a changelog directory with each PR.",
+            "support": {
+                "source": "https://github.com/Automattic/jetpack-changelogger/tree/v3.3.0"
+            },
+            "time": "2022-12-26T13:49:01+00:00"
+        },
+        {
+            "name": "dealerdirect/phpcodesniffer-composer-installer",
+            "version": "v1.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHPCSStandards/composer-installer.git",
+                "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd",
+                "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd",
+                "shasum": ""
+            },
+            "require": {
+                "composer-plugin-api": "^2.2",
+                "php": ">=5.4",
+                "squizlabs/php_codesniffer": "^3.1.0 || ^4.0"
+            },
+            "require-dev": {
+                "composer/composer": "^2.2",
+                "ext-json": "*",
+                "ext-zip": "*",
+                "php-parallel-lint/php-parallel-lint": "^1.4.0",
+                "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev",
+                "yoast/phpunit-polyfills": "^1.0"
+            },
+            "type": "composer-plugin",
+            "extra": {
+                "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin"
+            },
+            "autoload": {
+                "psr-4": {
+                    "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Franck Nijhof",
+                    "email": "opensource@frenck.dev",
+                    "homepage": "https://frenck.dev",
+                    "role": "Open source developer"
+                },
+                {
+                    "name": "Contributors",
+                    "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors"
+                }
+            ],
+            "description": "PHP_CodeSniffer Standards Composer Installer Plugin",
+            "keywords": [
+                "PHPCodeSniffer",
+                "PHP_CodeSniffer",
+                "code quality",
+                "codesniffer",
+                "composer",
+                "installer",
+                "phpcbf",
+                "phpcs",
+                "plugin",
+                "qa",
+                "quality",
+                "standard",
+                "standards",
+                "style guide",
+                "stylecheck",
+                "tests"
+            ],
+            "support": {
+                "issues": "https://github.com/PHPCSStandards/composer-installer/issues",
+                "security": "https://github.com/PHPCSStandards/composer-installer/security/policy",
+                "source": "https://github.com/PHPCSStandards/composer-installer"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/PHPCSStandards",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/jrfnl",
+                    "type": "github"
+                },
+                {
+                    "url": "https://opencollective.com/php_codesniffer",
+                    "type": "open_collective"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/phpcsstandards",
+                    "type": "thanks_dev"
+                }
+            ],
+            "time": "2026-05-06T08:26:05+00:00"
+        },
+        {
+            "name": "phpcsstandards/phpcsextra",
+            "version": "1.5.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHPCSStandards/PHPCSExtra.git",
+                "reference": "39467533fdb742446d68c1d10ac33d625ee0311c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/39467533fdb742446d68c1d10ac33d625ee0311c",
+                "reference": "39467533fdb742446d68c1d10ac33d625ee0311c",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.4",
+                "phpcsstandards/phpcsutils": "^1.2.3",
+                "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1"
+            },
+            "require-dev": {
+                "php-parallel-lint/php-console-highlighter": "^1.0",
+                "php-parallel-lint/php-parallel-lint": "^1.4.0",
+                "phpcsstandards/phpcsdevcs": "^1.2.0",
+                "phpcsstandards/phpcsdevtools": "^1.2.1",
+                "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
+            },
+            "type": "phpcodesniffer-standard",
+            "extra": {
+                "branch-alias": {
+                    "dev-stable": "1.x-dev",
+                    "dev-develop": "1.x-dev"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "LGPL-3.0-or-later"
+            ],
+            "authors": [
+                {
+                    "name": "Juliette Reinders Folmer",
+                    "homepage": "https://github.com/jrfnl",
+                    "role": "lead"
+                },
+                {
+                    "name": "Contributors",
+                    "homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors"
+                }
+            ],
+            "description": "A collection of sniffs and standards for use with PHP_CodeSniffer.",
+            "keywords": [
+                "PHP_CodeSniffer",
+                "phpcbf",
+                "phpcodesniffer-standard",
+                "phpcs",
+                "standards",
+                "static analysis"
+            ],
+            "support": {
+                "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues",
+                "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy",
+                "source": "https://github.com/PHPCSStandards/PHPCSExtra"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/PHPCSStandards",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/jrfnl",
+                    "type": "github"
+                },
+                {
+                    "url": "https://opencollective.com/php_codesniffer",
+                    "type": "open_collective"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/phpcsstandards",
+                    "type": "thanks_dev"
+                }
+            ],
+            "time": "2026-07-27T11:13:17+00:00"
+        },
+        {
+            "name": "phpcsstandards/phpcsutils",
+            "version": "1.2.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHPCSStandards/PHPCSUtils.git",
+                "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/5f35d9408c54d7b529501f3c688b6eae562aea1f",
+                "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f",
+                "shasum": ""
+            },
+            "require": {
+                "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0",
+                "php": ">=5.4",
+                "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1"
+            },
+            "require-dev": {
+                "ext-filter": "*",
+                "php-parallel-lint/php-console-highlighter": "^1.0",
+                "php-parallel-lint/php-parallel-lint": "^1.4.0",
+                "phpcsstandards/phpcsdevcs": "^1.2.0",
+                "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0"
+            },
+            "type": "phpcodesniffer-standard",
+            "extra": {
+                "branch-alias": {
+                    "dev-stable": "1.x-dev",
+                    "dev-develop": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "PHPCSUtils/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "LGPL-3.0-or-later"
+            ],
+            "authors": [
+                {
+                    "name": "Juliette Reinders Folmer",
+                    "homepage": "https://github.com/jrfnl",
+                    "role": "lead"
+                },
+                {
+                    "name": "Contributors",
+                    "homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors"
+                }
+            ],
+            "description": "A suite of utility functions for use with PHP_CodeSniffer",
+            "homepage": "https://phpcsutils.com/",
+            "keywords": [
+                "PHP_CodeSniffer",
+                "phpcbf",
+                "phpcodesniffer-standard",
+                "phpcs",
+                "phpcs3",
+                "phpcs4",
+                "standards",
+                "static analysis",
+                "tokens",
+                "utility"
+            ],
+            "support": {
+                "docs": "https://phpcsutils.com/",
+                "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues",
+                "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy",
+                "source": "https://github.com/PHPCSStandards/PHPCSUtils"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/PHPCSStandards",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/jrfnl",
+                    "type": "github"
+                },
+                {
+                    "url": "https://opencollective.com/php_codesniffer",
+                    "type": "open_collective"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/phpcsstandards",
+                    "type": "thanks_dev"
+                }
+            ],
+            "time": "2026-07-27T10:28:41+00:00"
+        },
+        {
+            "name": "psr/container",
+            "version": "1.1.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/container.git",
+                "reference": "513e0666f7216c7459170d56df27dfcefe1689ea"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea",
+                "reference": "513e0666f7216c7459170d56df27dfcefe1689ea",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.4.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Container\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common Container Interface (PHP FIG PSR-11)",
+            "homepage": "https://github.com/php-fig/container",
+            "keywords": [
+                "PSR-11",
+                "container",
+                "container-interface",
+                "container-interop",
+                "psr"
+            ],
+            "support": {
+                "issues": "https://github.com/php-fig/container/issues",
+                "source": "https://github.com/php-fig/container/tree/1.1.2"
+            },
+            "time": "2021-11-05T16:50:12+00:00"
+        },
+        {
+            "name": "squizlabs/php_codesniffer",
+            "version": "3.13.6",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
+                "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/4c378e1a528ea066890fc2397cbdd2f94eb2fc91",
+                "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91",
+                "shasum": ""
+            },
+            "require": {
+                "ext-simplexml": "*",
+                "ext-tokenizer": "*",
+                "ext-xmlwriter": "*",
+                "php": ">=5.4.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
+            },
+            "bin": [
+                "bin/phpcbf",
+                "bin/phpcs"
+            ],
+            "type": "library",
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Greg Sherwood",
+                    "role": "Former lead"
+                },
+                {
+                    "name": "Juliette Reinders Folmer",
+                    "role": "Current lead"
+                },
+                {
+                    "name": "Contributors",
+                    "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors"
+                }
+            ],
+            "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.",
+            "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+            "keywords": [
+                "phpcs",
+                "standards",
+                "static analysis"
+            ],
+            "support": {
+                "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues",
+                "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy",
+                "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+                "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/PHPCSStandards",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/jrfnl",
+                    "type": "github"
+                },
+                {
+                    "url": "https://opencollective.com/php_codesniffer",
+                    "type": "open_collective"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/phpcsstandards",
+                    "type": "thanks_dev"
+                }
+            ],
+            "time": "2026-08-06T00:17:32+00:00"
+        },
+        {
+            "name": "symfony/console",
+            "version": "v5.4.47",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/console.git",
+                "reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/console/zipball/c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed",
+                "reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "symfony/deprecation-contracts": "^2.1|^3",
+                "symfony/polyfill-mbstring": "~1.0",
+                "symfony/polyfill-php73": "^1.9",
+                "symfony/polyfill-php80": "^1.16",
+                "symfony/service-contracts": "^1.1|^2|^3",
+                "symfony/string": "^5.1|^6.0"
+            },
+            "conflict": {
+                "psr/log": ">=3",
+                "symfony/dependency-injection": "<4.4",
+                "symfony/dotenv": "<5.1",
+                "symfony/event-dispatcher": "<4.4",
+                "symfony/lock": "<4.4",
+                "symfony/process": "<4.4"
+            },
+            "provide": {
+                "psr/log-implementation": "1.0|2.0"
+            },
+            "require-dev": {
+                "psr/log": "^1|^2",
+                "symfony/config": "^4.4|^5.0|^6.0",
+                "symfony/dependency-injection": "^4.4|^5.0|^6.0",
+                "symfony/event-dispatcher": "^4.4|^5.0|^6.0",
+                "symfony/lock": "^4.4|^5.0|^6.0",
+                "symfony/process": "^4.4|^5.0|^6.0",
+                "symfony/var-dumper": "^4.4|^5.0|^6.0"
+            },
+            "suggest": {
+                "psr/log": "For using the console logger",
+                "symfony/event-dispatcher": "",
+                "symfony/lock": "",
+                "symfony/process": ""
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Console\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Eases the creation of beautiful and testable command line interfaces",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "cli",
+                "command-line",
+                "console",
+                "terminal"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/console/tree/v5.4.47"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-11-06T11:30:55+00:00"
+        },
+        {
+            "name": "symfony/deprecation-contracts",
+            "version": "v2.5.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/deprecation-contracts.git",
+                "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/605389f2a7e5625f273b53960dc46aeaf9c62918",
+                "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/contracts",
+                    "name": "symfony/contracts"
+                },
+                "branch-alias": {
+                    "dev-main": "2.5-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "function.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "A generic function and convention to trigger deprecation notices",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.4"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-25T14:11:13+00:00"
+        },
+        {
+            "name": "symfony/polyfill-ctype",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-ctype.git",
+                "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
+                "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "provide": {
+                "ext-ctype": "*"
+            },
+            "suggest": {
+                "ext-ctype": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Ctype\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Gert de Pagter",
+                    "email": "BackEndTea@gmail.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for ctype functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "ctype",
+                "polyfill",
+                "portable"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-04-10T16:19:22+00:00"
+        },
+        {
+            "name": "symfony/polyfill-intl-grapheme",
+            "version": "v1.43.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+                "reference": "5cac8f4db97208823aa353f59a01faad2da52c75"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75",
+                "reference": "5cac8f4db97208823aa353f59a01faad2da52c75",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "suggest": {
+                "ext-intl": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for intl's grapheme_* functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "grapheme",
+                "intl",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-09-25T15:46:47+00:00"
+        },
+        {
+            "name": "symfony/polyfill-intl-normalizer",
+            "version": "v1.43.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+                "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b",
+                "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "suggest": {
+                "ext-intl": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for intl's Normalizer class and related functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "intl",
+                "normalizer",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-09-23T22:02:09+00:00"
+        },
+        {
+            "name": "symfony/polyfill-mbstring",
+            "version": "v1.43.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-mbstring.git",
+                "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72",
+                "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72",
+                "shasum": ""
+            },
+            "require": {
+                "ext-iconv": "*",
+                "php": ">=7.2"
+            },
+            "provide": {
+                "ext-mbstring": "*"
+            },
+            "suggest": {
+                "ext-mbstring": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Mbstring\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for the Mbstring extension",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "mbstring",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-09-24T15:19:04+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php73",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php73.git",
+                "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/0f68c03565dcaaf25a890667542e8bd75fe7e5bb",
+                "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php73\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php73/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-09T11:45:10+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php80",
+            "version": "v1.43.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php80.git",
+                "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
+                "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php80\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Ion Bazan",
+                    "email": "ion.bazan@gmail.com"
+                },
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-09-25T15:50:05+00:00"
+        },
+        {
+            "name": "symfony/process",
+            "version": "v5.4.51",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/process.git",
+                "reference": "467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/process/zipball/467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f",
+                "reference": "467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "symfony/polyfill-php80": "^1.16"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Process\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Executes commands in sub-processes",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/process/tree/v5.4.51"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-01-26T15:53:37+00:00"
+        },
+        {
+            "name": "symfony/service-contracts",
+            "version": "v2.5.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/service-contracts.git",
+                "reference": "f37b419f7aea2e9abf10abd261832cace12e3300"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f37b419f7aea2e9abf10abd261832cace12e3300",
+                "reference": "f37b419f7aea2e9abf10abd261832cace12e3300",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "psr/container": "^1.1",
+                "symfony/deprecation-contracts": "^2.1|^3"
+            },
+            "conflict": {
+                "ext-psr": "<1.1|>=2"
+            },
+            "suggest": {
+                "symfony/service-implementation": ""
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/contracts",
+                    "name": "symfony/contracts"
+                },
+                "branch-alias": {
+                    "dev-main": "2.5-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\Service\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Generic abstractions related to writing services",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/service-contracts/tree/v2.5.4"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-25T14:11:13+00:00"
+        },
+        {
+            "name": "symfony/string",
+            "version": "v5.4.47",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/string.git",
+                "reference": "136ca7d72f72b599f2631aca474a4f8e26719799"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/string/zipball/136ca7d72f72b599f2631aca474a4f8e26719799",
+                "reference": "136ca7d72f72b599f2631aca474a4f8e26719799",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "symfony/polyfill-ctype": "~1.8",
+                "symfony/polyfill-intl-grapheme": "~1.0",
+                "symfony/polyfill-intl-normalizer": "~1.0",
+                "symfony/polyfill-mbstring": "~1.0",
+                "symfony/polyfill-php80": "~1.15"
+            },
+            "conflict": {
+                "symfony/translation-contracts": ">=3.0"
+            },
+            "require-dev": {
+                "symfony/error-handler": "^4.4|^5.0|^6.0",
+                "symfony/http-client": "^4.4|^5.0|^6.0",
+                "symfony/translation-contracts": "^1.1|^2",
+                "symfony/var-exporter": "^4.4|^5.0|^6.0"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "Resources/functions.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Component\\String\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "grapheme",
+                "i18n",
+                "string",
+                "unicode",
+                "utf-8",
+                "utf8"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/string/tree/v5.4.47"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-11-10T20:33:58+00:00"
+        },
+        {
+            "name": "wikimedia/at-ease",
+            "version": "v2.1.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/wikimedia/at-ease.git",
+                "reference": "e8ebaa7bb7c8a8395481a05f6dc4deaceab11c33"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/wikimedia/at-ease/zipball/e8ebaa7bb7c8a8395481a05f6dc4deaceab11c33",
+                "reference": "e8ebaa7bb7c8a8395481a05f6dc4deaceab11c33",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.9"
+            },
+            "require-dev": {
+                "mediawiki/mediawiki-codesniffer": "35.0.0",
+                "mediawiki/minus-x": "1.1.1",
+                "ockcyp/covers-validator": "1.3.3",
+                "php-parallel-lint/php-console-highlighter": "0.5.0",
+                "php-parallel-lint/php-parallel-lint": "1.2.0",
+                "phpunit/phpunit": "^8.5"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/Wikimedia/Functions.php"
+                ],
+                "psr-4": {
+                    "Wikimedia\\AtEase\\": "src/Wikimedia/AtEase/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "GPL-2.0-or-later"
+            ],
+            "authors": [
+                {
+                    "name": "Tim Starling",
+                    "email": "tstarling@wikimedia.org"
+                },
+                {
+                    "name": "MediaWiki developers",
+                    "email": "wikitech-l@lists.wikimedia.org"
+                }
+            ],
+            "description": "Safe replacement to @ for suppressing warnings.",
+            "homepage": "https://www.mediawiki.org/wiki/at-ease",
+            "support": {
+                "source": "https://github.com/wikimedia/at-ease/tree/v2.1.0"
+            },
+            "time": "2021-02-27T15:53:37+00:00"
+        },
+        {
+            "name": "wp-coding-standards/wpcs",
+            "version": "3.4.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/WordPress/WordPress-Coding-Standards.git",
+                "reference": "ec2ff942335f33683a5957a85d138753876a05cf"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/ec2ff942335f33683a5957a85d138753876a05cf",
+                "reference": "ec2ff942335f33683a5957a85d138753876a05cf",
+                "shasum": ""
+            },
+            "require": {
+                "ext-filter": "*",
+                "ext-libxml": "*",
+                "ext-tokenizer": "*",
+                "ext-xmlreader": "*",
+                "php": ">=7.2",
+                "phpcsstandards/phpcsextra": "^1.5.1",
+                "phpcsstandards/phpcsutils": "^1.2.3",
+                "squizlabs/php_codesniffer": "^3.13.5"
+            },
+            "require-dev": {
+                "php-parallel-lint/php-console-highlighter": "^1.0.0",
+                "php-parallel-lint/php-parallel-lint": "^1.4.0",
+                "phpcompatibility/php-compatibility": "^10.0.0@dev",
+                "phpcsstandards/phpcsdevtools": "^1.2.0",
+                "phpunit/phpunit": "^8.0 || ^9.0"
+            },
+            "suggest": {
+                "ext-iconv": "For improved results",
+                "ext-mbstring": "For improved results"
+            },
+            "type": "phpcodesniffer-standard",
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Contributors",
+                    "homepage": "https://github.com/WordPress/WordPress-Coding-Standards/graphs/contributors"
+                }
+            ],
+            "description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress coding conventions",
+            "keywords": [
+                "phpcs",
+                "standards",
+                "static analysis",
+                "wordpress"
+            ],
+            "support": {
+                "issues": "https://github.com/WordPress/WordPress-Coding-Standards/issues",
+                "source": "https://github.com/WordPress/WordPress-Coding-Standards",
+                "wiki": "https://github.com/WordPress/WordPress-Coding-Standards/wiki"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/php_codesniffer",
+                    "type": "custom"
+                }
+            ],
+            "time": "2026-07-27T11:53:23+00:00"
+        }
+    ],
+    "aliases": [],
+    "minimum-stability": "stable",
+    "stability-flags": {},
+    "prefer-stable": false,
+    "prefer-lowest": false,
+    "platform": {},
+    "platform-dev": {},
+    "platform-overrides": {
+        "php": "7.4"
+    },
+    "plugin-api-version": "2.9.0"
+}
diff --git a/tools/theme-cli/package.json b/tools/theme-cli/package.json
new file mode 100644
index 00000000000..d4b9d9bff32
--- /dev/null
+++ b/tools/theme-cli/package.json
@@ -0,0 +1,20 @@
+{
+	"name": "@woocommerce/theme-cli",
+	"description": "Shared development tools for WooCommerce themes.",
+	"private": true,
+	"license": "GPL-2.0-or-later",
+	"engines": {
+		"node": "^24.15.0"
+	},
+	"scripts": {
+		"build": "node scripts/build-zip.mjs",
+		"validate": "node scripts/validate-theme.mjs",
+		"changelog": "composer exec -- changelogger",
+		"lint": "prettier --check scripts package.json composer.json .wp-env.json",
+		"wp-env": "node scripts/env.mjs"
+	},
+	"devDependencies": {
+		"@wordpress/env": "catalog:tooling",
+		"ajv": "^8.17.1"
+	}
+}
diff --git a/tools/theme-cli/phpcs.xml.dist b/tools/theme-cli/phpcs.xml.dist
new file mode 100644
index 00000000000..1cf818a7593
--- /dev/null
+++ b/tools/theme-cli/phpcs.xml.dist
@@ -0,0 +1,22 @@
+<?xml version="1.0"?>
+<ruleset name="WooCommerce themes">
+	<description>Shared WordPress coding standards for WooCommerce themes.</description>
+
+	<exclude-pattern>*/node_modules/*</exclude-pattern>
+	<exclude-pattern>*/vendor/*</exclude-pattern>
+
+	<arg name="extensions" value="php"/>
+	<arg name="tab-width" value="4"/>
+	<arg value="sp"/>
+	<arg name="parallel" value="8"/>
+
+	<rule ref="WordPress"/>
+
+	<!-- Inline PHP inside serialized block markup must keep its exact
+	     placement: reflowing it onto separate lines changes the blocks'
+	     innerHTML and risks block validation in the editor. -->
+	<rule ref="Squiz.PHP.EmbeddedPhp">
+		<exclude-pattern>*/patterns/*</exclude-pattern>
+	</rule>
+
+</ruleset>
diff --git a/tools/theme-cli/scripts/build-zip.mjs b/tools/theme-cli/scripts/build-zip.mjs
new file mode 100644
index 00000000000..a2b3f46bc06
--- /dev/null
+++ b/tools/theme-cli/scripts/build-zip.mjs
@@ -0,0 +1,51 @@
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import {
+	assertThemeSlug,
+	isMain,
+	isThemeFile,
+	readThemeHeader,
+	themesRoot,
+} from './theme-utils.mjs';
+
+export async function buildZip( slug ) {
+	assertThemeSlug( slug );
+	const git = ( ...args ) =>
+		execFileSync( 'git', args, {
+			cwd: path.dirname( themesRoot ),
+			encoding: 'utf8',
+		} ).trim();
+	const commit = git( 'rev-parse', 'HEAD' );
+	const tree = `${ commit }:themes/${ slug }`;
+	const stylesheet = git( 'show', `${ tree }/style.css` );
+	const version = readThemeHeader( stylesheet, 'Version' );
+	const files = git( 'ls-tree', '-r', '--name-only', tree )
+		.split( '\n' )
+		.filter( isThemeFile );
+	const outputDirectory = path.join( themesRoot, slug, 'artifacts' );
+	await fs.mkdir( outputDirectory, { recursive: true } );
+	const archive = path.resolve(
+		outputDirectory,
+		`${ slug }-${ version }.zip`
+	);
+	git(
+		'archive',
+		'--format=zip',
+		`--prefix=${ slug }/`,
+		`--output=${ archive }`,
+		tree,
+		'--',
+		...files
+	);
+	return archive;
+}
+
+if ( isMain( import.meta.url ) ) {
+	try {
+		console.log( await buildZip( process.argv[ 2 ] ) );
+	} catch ( error ) {
+		console.error( error.message );
+		process.exitCode = 1;
+	}
+}
diff --git a/tools/theme-cli/scripts/env.mjs b/tools/theme-cli/scripts/env.mjs
new file mode 100644
index 00000000000..0c5cf0af19a
--- /dev/null
+++ b/tools/theme-cli/scripts/env.mjs
@@ -0,0 +1,66 @@
+import { spawnSync } from 'node:child_process';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { getThemeDirectory, isMain, toolingRoot } from './theme-utils.mjs';
+
+export function environmentArgs( args ) {
+	if (
+		args[ 0 ] === 'start' &&
+		! args.some(
+			( arg ) => arg === '--runtime' || arg.startsWith( '--runtime=' )
+		)
+	) {
+		return [ ...args, '--runtime=playground' ];
+	}
+	return args;
+}
+
+export async function prepareEnvironment( slug ) {
+	const directory = await getThemeDirectory( slug );
+	await fs.access( path.join( directory, 'style.css' ) );
+	await fs.access( path.join( directory, 'theme.json' ) );
+	const config = JSON.parse(
+		await fs.readFile( path.join( toolingRoot, '.wp-env.json' ), 'utf8' )
+	);
+	config.themes = [ directory ];
+	const configDirectory = path.join( toolingRoot, '.wp-env', slug );
+	await fs.mkdir( configDirectory, { recursive: true } );
+	const configPath = path.join( configDirectory, '.wp-env.json' );
+	await fs.writeFile(
+		configPath,
+		JSON.stringify( config, null, '\t' ) + '\n'
+	);
+	return configPath;
+}
+
+export async function runEnvironment( slug, args ) {
+	const configPath = await prepareEnvironment( slug );
+	const result = spawnSync(
+		'pnpm',
+		[
+			'exec',
+			'wp-env',
+			...environmentArgs( args ),
+			'--config',
+			configPath,
+		],
+		{
+			cwd: toolingRoot,
+			stdio: 'inherit',
+		}
+	);
+	if ( result.error ) {
+		throw result.error;
+	}
+	return result.status ?? 1;
+}
+
+if ( isMain( import.meta.url ) ) {
+	try {
+		const [ command, slug, ...args ] = process.argv.slice( 2 );
+		process.exitCode = await runEnvironment( slug, [ command, ...args ] );
+	} catch ( error ) {
+		console.error( error.message );
+		process.exitCode = 1;
+	}
+}
diff --git a/tools/theme-cli/scripts/lint-php.mjs b/tools/theme-cli/scripts/lint-php.mjs
new file mode 100644
index 00000000000..d9998f93ed4
--- /dev/null
+++ b/tools/theme-cli/scripts/lint-php.mjs
@@ -0,0 +1,35 @@
+import { spawnSync } from 'node:child_process';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { readThemeHeader, toolingRoot } from './theme-utils.mjs';
+
+export async function lintPHP( directory ) {
+	const slug = path.basename( directory );
+	const stylesheet = await fs.readFile(
+		path.join( directory, 'style.css' ),
+		'utf8'
+	);
+	const minimum = readThemeHeader( stylesheet, 'Requires at least' );
+	if ( ! /^\d+\.\d+(?:\.\d+)?$/.test( minimum ?? '' ) ) {
+		throw new Error( 'Requires at least must be a WordPress version.' );
+	}
+	const result = spawnSync(
+		'php',
+		[
+			path.join( toolingRoot, 'vendor/bin/phpcs' ),
+			`--standard=${ path.join( toolingRoot, 'phpcs.xml.dist' ) }`,
+			'--runtime-set',
+			'text_domain',
+			slug,
+			'--runtime-set',
+			'minimum_wp_version',
+			minimum,
+			'.',
+		],
+		{ cwd: directory, stdio: 'inherit' }
+	);
+	if ( result.error ) {
+		throw result.error;
+	}
+	return result.status ?? 1;
+}
diff --git a/tools/theme-cli/scripts/theme-utils.mjs b/tools/theme-cli/scripts/theme-utils.mjs
new file mode 100644
index 00000000000..facb4d9f12b
--- /dev/null
+++ b/tools/theme-cli/scripts/theme-utils.mjs
@@ -0,0 +1,118 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export const toolingRoot = fileURLToPath( new URL( '../', import.meta.url ) );
+export const themesRoot = path.resolve( toolingRoot, '../../themes' );
+
+export function isMain( url ) {
+	return (
+		process.argv[ 1 ] &&
+		path.resolve( process.argv[ 1 ] ) === fileURLToPath( url )
+	);
+}
+
+export function assertThemeSlug( slug ) {
+	if ( ! /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test( slug ?? '' ) ) {
+		throw new Error(
+			'Supply a theme slug containing lowercase letters, numbers, and hyphens.'
+		);
+	}
+}
+
+export async function getThemeDirectory( slug, root = themesRoot ) {
+	assertThemeSlug( slug );
+	const directory = path.join( root, slug );
+	if ( ! ( await fs.lstat( directory ) ).isDirectory() ) {
+		throw new Error(
+			`Theme ${ slug } must be a directory, not a symbolic link.`
+		);
+	}
+	return directory;
+}
+
+export function readThemeHeader( contents, header ) {
+	return (
+		contents
+			.match( new RegExp( `^${ header }:\\s*(.+)$`, 'mi' ) )?.[ 1 ]
+			.trim() ?? null
+	);
+}
+
+export async function discoverThemes( root = themesRoot ) {
+	const themes = [];
+	for ( const entry of await fs.readdir( root, { withFileTypes: true } ) ) {
+		if ( ! entry.isDirectory() ) {
+			continue;
+		}
+		try {
+			await fs.access( path.join( root, entry.name, 'style.css' ) );
+			await fs.access( path.join( root, entry.name, 'theme.json' ) );
+		} catch {
+			continue;
+		}
+		assertThemeSlug( entry.name );
+		themes.push( { slug: entry.name } );
+	}
+	return themes.sort( ( left, right ) =>
+		left.slug.localeCompare( right.slug )
+	);
+}
+
+const developmentPaths = new Set( [
+	'node_modules',
+	'vendor',
+	'tests',
+	'scripts',
+	'changelog',
+	'artifacts',
+	'package.json',
+	'package-lock.json',
+	'pnpm-lock.yaml',
+	'composer.json',
+	'composer.lock',
+	'phpcs.xml',
+	'phpcs.xml.dist',
+	'phpunit.xml',
+	'phpunit.xml.dist',
+	'phpstan.neon',
+	'phpstan.neon.dist',
+	'README.md',
+	'CHANGELOG.md',
+	'AGENTS.md',
+	'CLAUDE.md',
+] );
+
+export function isThemeFile( relativePath ) {
+	return relativePath
+		.split( '/' )
+		.every(
+			( part ) =>
+				! part.startsWith( '.' ) && ! developmentPaths.has( part )
+		);
+}
+
+export async function listThemeFiles( directory, prefix = '' ) {
+	const files = [];
+	for ( const entry of await fs.readdir( path.join( directory, prefix ), {
+		withFileTypes: true,
+	} ) ) {
+		const relativePath = prefix + entry.name;
+		if ( ! isThemeFile( relativePath ) ) {
+			continue;
+		}
+		if ( entry.isSymbolicLink() ) {
+			throw new Error(
+				`Theme files must not be symbolic links: ${ relativePath }`
+			);
+		}
+		if ( entry.isDirectory() ) {
+			files.push(
+				...( await listThemeFiles( directory, relativePath + '/' ) )
+			);
+		} else {
+			files.push( relativePath );
+		}
+	}
+	return files.sort();
+}
diff --git a/tools/theme-cli/scripts/validate-theme.mjs b/tools/theme-cli/scripts/validate-theme.mjs
new file mode 100644
index 00000000000..738f523dbc6
--- /dev/null
+++ b/tools/theme-cli/scripts/validate-theme.mjs
@@ -0,0 +1,286 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import Ajv from 'ajv';
+import { lintPHP } from './lint-php.mjs';
+import {
+	getThemeDirectory,
+	isMain,
+	listThemeFiles,
+	readThemeHeader,
+} from './theme-utils.mjs';
+
+async function fetchSchema( url ) {
+	const response = await fetch( url, {
+		signal: AbortSignal.timeout( 30000 ),
+	} );
+	if ( ! response.ok ) {
+		throw new Error( `could not load ${ url }: ${ response.status }` );
+	}
+	return response.json();
+}
+
+function normalizeVersion( version ) {
+	if ( ! /^\d+\.\d+(?:\.\d+)?$/.test( version ?? '' ) ) {
+		return null;
+	}
+
+	return version.split( '.' ).map( Number );
+}
+
+function compareVersions( left, right ) {
+	for ( let index = 0; index < 3; index++ ) {
+		const difference = ( left[ index ] ?? 0 ) - ( right[ index ] ?? 0 );
+		if ( difference !== 0 ) {
+			return difference;
+		}
+	}
+
+	return 0;
+}
+
+export async function validateTheme(
+	themeDirectory,
+	loadSchema = fetchSchema
+) {
+	const errors = [];
+	const report = ( file, message ) =>
+		errors.push( `${ file }: ${ message }` );
+	const files = ( await listThemeFiles( themeDirectory ) ).map( ( file ) =>
+		path.join( themeDirectory, file )
+	);
+	const themeSlug = path.basename( themeDirectory );
+	for ( const requiredFile of [ 'theme.json', 'templates/index.html' ] ) {
+		const file = path.join( themeDirectory, requiredFile );
+		if ( ! files.includes( file ) ) {
+			report( file, 'missing required block theme file' );
+		}
+	}
+	const stylesheetPath = path.join( themeDirectory, 'style.css' );
+	const stylesheet = await fs.readFile( stylesheetPath, 'utf8' );
+	const requiredHeaders = [
+		'Theme Name',
+		'Author',
+		'Description',
+		'Requires at least',
+		'Tested up to',
+		'Requires PHP',
+		'Version',
+		'License',
+		'License URI',
+		'Text Domain',
+	];
+
+	for ( const header of requiredHeaders ) {
+		if ( ! readThemeHeader( stylesheet, header ) ) {
+			report( stylesheetPath, `missing required "${ header }" header` );
+		}
+	}
+
+	const readme = await fs.readFile(
+		path.join( themeDirectory, 'readme.txt' ),
+		'utf8'
+	);
+	const version = readThemeHeader( stylesheet, 'Version' );
+	if ( ! /^\d+\.\d+\.\d+$/.test( version ?? '' ) ) {
+		report( stylesheetPath, '"Version" must use X.Y.Z format' );
+	}
+	const manifestPath = path.join( themeDirectory, 'package.json' );
+	try {
+		const manifest = JSON.parse(
+			await fs.readFile( manifestPath, 'utf8' )
+		);
+		if ( manifest.version !== version ) {
+			report( manifestPath, 'Version must match style.css' );
+		}
+	} catch ( error ) {
+		report( manifestPath, error.message );
+	}
+	if ( readThemeHeader( readme, 'Stable tag' ) !== version ) {
+		report( stylesheetPath, 'Version must match readme.txt Stable tag' );
+	}
+	if ( readThemeHeader( stylesheet, 'Text Domain' ) !== themeSlug ) {
+		report(
+			stylesheetPath,
+			'Text Domain must match the theme directory name'
+		);
+	}
+
+	const minimumWordPress = readThemeHeader( stylesheet, 'Requires at least' );
+	const testedWordPress = readThemeHeader( stylesheet, 'Tested up to' );
+	const minimumVersion = normalizeVersion( minimumWordPress );
+	const testedVersion = normalizeVersion( testedWordPress );
+
+	if ( ! minimumVersion ) {
+		report(
+			stylesheetPath,
+			'"Requires at least" must be a valid WordPress version'
+		);
+	}
+	if ( ! testedVersion ) {
+		report(
+			stylesheetPath,
+			'"Tested up to" must be a valid WordPress version'
+		);
+	} else if (
+		minimumVersion &&
+		compareVersions( testedVersion, minimumVersion ) < 0
+	) {
+		report(
+			stylesheetPath,
+			'"Tested up to" cannot be lower than "Requires at least"'
+		);
+	}
+
+	const jsonFiles = files.filter( ( file ) => file.endsWith( '.json' ) );
+	const parsedJson = new Map();
+	for ( const file of jsonFiles ) {
+		try {
+			parsedJson.set(
+				file,
+				JSON.parse( await fs.readFile( file, 'utf8' ) )
+			);
+		} catch ( error ) {
+			report( file, `invalid JSON: ${ error.message }` );
+		}
+	}
+
+	if ( minimumVersion ) {
+		const schemaUrl = `https://schemas.wp.org/wp/${ minimumWordPress }/theme.json`;
+
+		try {
+			const schema = await loadSchema( schemaUrl );
+			const ajv = new Ajv( {
+				allErrors: true,
+				loadSchema,
+				strict: false,
+			} );
+			const validate = await ajv.compileAsync( schema );
+			const themeJsonFiles = jsonFiles.filter(
+				( file ) =>
+					file === path.join( themeDirectory, 'theme.json' ) ||
+					file.startsWith(
+						path.join( themeDirectory, 'styles' ) + path.sep
+					)
+			);
+
+			for ( const file of themeJsonFiles ) {
+				const contents = parsedJson.get( file );
+				if ( parsedJson.has( file ) && ! validate( contents ) ) {
+					for ( const error of validate.errors ?? [] ) {
+						report(
+							file,
+							`schema error at ${ error.instancePath || '/' }: ${ error.message }`
+						);
+					}
+				}
+			}
+		} catch ( error ) {
+			report( 'theme.json schema', error.message );
+		}
+	}
+
+	const patternFiles = files.filter(
+		( file ) =>
+			file.startsWith(
+				path.join( themeDirectory, 'patterns' ) + path.sep
+			) && file.endsWith( '.php' )
+	);
+	const patternSlugs = new Map();
+
+	for ( const file of patternFiles ) {
+		const contents = await fs.readFile( file, 'utf8' );
+		const title = contents
+			.match( /^\s*\*\s+Title:\s*(.+)$/m )?.[ 1 ]
+			.trim();
+		const slug = contents.match( /^\s*\*\s+Slug:\s*(\S+)$/m )?.[ 1 ];
+
+		if ( ! title ) {
+			report( file, 'missing pattern Title header' );
+		}
+		if ( ! slug ) {
+			report( file, 'missing pattern Slug header' );
+			continue;
+		}
+		if ( patternSlugs.has( slug ) ) {
+			report(
+				file,
+				`duplicates pattern slug declared by ${ patternSlugs.get( slug ) }`
+			);
+		} else {
+			patternSlugs.set( slug, file );
+		}
+	}
+
+	const referenceFiles = files.filter( ( file ) =>
+		/\.(?:html|php)$/.test( file )
+	);
+	for ( const file of referenceFiles ) {
+		const contents = await fs.readFile( file, 'utf8' );
+		for ( const match of contents.matchAll(
+			/<!--\s+wp:pattern\s+\{"slug":"([^"]+)"/g
+		) ) {
+			const slug = match[ 1 ];
+			if (
+				slug.startsWith( themeSlug + '/' ) &&
+				! patternSlugs.has( slug )
+			) {
+				report( file, `references missing pattern ${ slug }` );
+			}
+		}
+	}
+
+	const assetReferences = [];
+	for ( const file of files.filter( ( candidate ) =>
+		/\.(?:css|html|js|json|php)$/.test( candidate )
+	) ) {
+		const contents = await fs.readFile( file, 'utf8' );
+		for ( const match of contents.matchAll(
+			/file:\.\/(assets\/[^"'\s)]+)/g
+		) ) {
+			assetReferences.push( { file, asset: match[ 1 ] } );
+		}
+		for ( const match of contents.matchAll( /\/(assets\/[^"'\s<)]+)/g ) ) {
+			assetReferences.push( { file, asset: match[ 1 ] } );
+		}
+	}
+
+	for ( const { file, asset } of assetReferences ) {
+		const cleanAsset = asset.replace( /[?#].*$/, '' );
+		try {
+			await fs.access( path.join( themeDirectory, cleanAsset ) );
+		} catch {
+			report( file, `references missing asset ${ cleanAsset }` );
+		}
+	}
+
+	return {
+		errors,
+		jsonFiles: jsonFiles.length,
+		patterns: patternFiles.length,
+		assets: assetReferences.length,
+	};
+}
+
+if ( isMain( import.meta.url ) ) {
+	try {
+		const directory = await getThemeDirectory( process.argv[ 2 ] );
+		const result = await validateTheme( directory );
+		if ( result.errors.length ) {
+			for ( const error of result.errors ) {
+				console.error( error );
+			}
+			process.exitCode = 1;
+		} else {
+			console.log(
+				`Validated ${ result.jsonFiles } JSON files, ${ result.patterns } patterns, and ${ result.assets } asset references.`
+			);
+		}
+		const lintStatus = await lintPHP( directory );
+		if ( lintStatus !== 0 ) {
+			process.exitCode = lintStatus;
+		}
+	} catch ( error ) {
+		console.error( error.message );
+		process.exitCode = 1;
+	}
+}