diff --git a/.gitignore b/.gitignore
index f6525a7a..52338d60 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,5 @@ packages.json
results.sarif
infection.log
.churn.cache
+tools/chorale/composer.lock
+tools/chorale/.phpunit.cache/
diff --git a/AGENTS.md b/AGENTS.md
index a3b67200..4abe097f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,6 +2,11 @@
This repository is a PHP monorepo containing many packages under `src/`. This guide provides consistent instructions for AI coding agents to work safely and effectively across the codebase.
+- Use clear variable names and keep code well documented.
+- Run tests relevant to the areas you change.
+- For changes under `tools/chorale`, run `composer install` and `./vendor/bin/phpunit` in that directory before committing.
+- Chorale is the monorepo management CLI using a plan/apply workflow; see `tools/chorale/AGENTS.md` for its roadmap and guidelines.
+
## Repo Layout
- Root: build tooling (`Makefile`, composer), shared configs, CI inputs.
@@ -56,5 +61,4 @@ This repository is a PHP monorepo containing many packages under `src/`. This gu
- Build passes: `make test` (optionally with coverage).
- Code quality passes: `make php-cs-fixer`, `make psalm`, and (if applicable) `make upgrade-code`.
- Docs updated where needed.
-- No changes to `vendor/` or generated artifacts.
-
+- No changes to `vendor/` or generated artifacts.
\ No newline at end of file
diff --git a/chorale.yaml b/chorale.yaml
new file mode 100644
index 00000000..ffb02836
--- /dev/null
+++ b/chorale.yaml
@@ -0,0 +1,19 @@
+version: 1
+repo_host: git@github.com
+repo_vendor: SonsOfPHP
+repo_name_template: '{name:kebab}.git'
+default_repo_template: '{repo_host}:{repo_vendor}/{repo_name_template}'
+default_branch: main
+splitter: splitsh
+tag_strategy: inherit-monorepo-tag
+rules:
+ keep_history: true
+ skip_if_unchanged: true
+ require_files:
+ - composer.json
+ - LICENSE
+patterns:
+ -
+ match: 'src/**'
+ include:
+ - '**'
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index 0fa2ff21..25470239 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -11,6 +11,10 @@
* [Overview](bard/overview.md)
* [Commands](bard/commands.md)
+## 🔧 Tools
+
+* [Chorale](tools/chorale.md)
+
## Symfony Bundles
* [Feature Toggle](symfony-bundles/feature-toggle.md)
diff --git a/docs/tools/chorale.md b/docs/tools/chorale.md
new file mode 100644
index 00000000..13d78125
--- /dev/null
+++ b/docs/tools/chorale.md
@@ -0,0 +1,37 @@
+# Chorale
+
+Chorale is a CLI tool for managing PHP monorepos. It uses a plan/apply workflow to keep package metadata and the root package in sync.
+
+## Installation
+
+```bash
+cd tools/chorale
+composer install
+```
+
+## Usage
+
+Run the commands from the project root:
+
+```bash
+# create chorale.yaml by scanning packages
+php bin/chorale setup
+
+# preview changes without modifying files
+php bin/chorale plan --json > plan.json
+
+# apply an exported plan
+php bin/chorale apply --file plan.json
+
+# build and apply a plan in one go
+php bin/chorale run
+```
+
+Chorale automatically merges all package `composer.json` files into the root `composer.json` so the monorepo can be installed as a single package. Any dependency conflicts are recorded under the `extra.chorale.dependency-conflicts` section for review.
+
+## Commands
+
+- `setup` – generate configuration and validate required files.
+- `plan` – build a plan for splitting packages and root updates.
+- `run` – build and immediately apply a plan.
+- `apply` – execute steps from a JSON plan file.
diff --git a/rector.php b/rector.php
index 4c7285e4..15402a28 100644
--- a/rector.php
+++ b/rector.php
@@ -34,7 +34,7 @@
earlyReturn: true,
strictBooleans: true,
phpunitCodeQuality: true,
- phpunit: true,
+ //phpunit: true,
)
->withImportNames(
importShortClasses: false,
diff --git a/tools/chorale/AGENTS.md b/tools/chorale/AGENTS.md
new file mode 100644
index 00000000..b10cc203
--- /dev/null
+++ b/tools/chorale/AGENTS.md
@@ -0,0 +1,14 @@
+# AGENTS
+
+Chorale is a CLI tool maintained in this repository.
+
+- Use descriptive variable names and document public methods.
+- Add unit tests for new features in `src/Tests`.
+- Run `composer install` and `./vendor/bin/phpunit` in this directory before committing changes.
+
+## Roadmap
+
+- Implement executors for remaining plan steps such as composer root rebuild and metadata sync.
+- Improve conflict resolution strategies for dependency merges.
+- Enhance documentation with more real-world examples as features grow.
+
diff --git a/tools/chorale/bin/chorale b/tools/chorale/bin/chorale
new file mode 100755
index 00000000..734dac7e
--- /dev/null
+++ b/tools/chorale/bin/chorale
@@ -0,0 +1,139 @@
+#!/usr/bin/env php
+add(new SetupCommand(
+ styleFactory: new ConsoleStyleFactory(),
+ configLoader: $loader,
+ configWriter: $writer,
+ configNormalizer: $normalizer,
+ schemaValidator: $schema,
+ defaults: $defaults,
+ scanner: $scanner,
+ matcher: $matcher,
+ resolver: $resolver,
+ identity: $identity,
+ requiredFiles: $required,
+ //conflicts: $conflicts,
+ jsonReporter: $json,
+ summary: $summary,
+ composerMeta: $composerMeta,
+));
+// -----------------------------------------------------------------------------
+
+// -----------------------------------------------------------------------------
+$app->add(new PlanCommand(
+ styleFactory: new ConsoleStyleFactory(),
+ configLoader: $loader,
+ planner: $planner,
+));
+// -----------------------------------------------------------------------------
+$app->add(new ApplyCommand(
+ styleFactory: new ConsoleStyleFactory(),
+ runner: $runner,
+));
+// -----------------------------------------------------------------------------
+$app->add(new RunCommand(
+ styleFactory: new ConsoleStyleFactory(),
+ runner: $runner,
+));
+// -----------------------------------------------------------------------------
+
+// -----------------------------------------------------------------------------
+$app->run();
+// -----------------------------------------------------------------------------
diff --git a/tools/chorale/composer.json b/tools/chorale/composer.json
new file mode 100644
index 00000000..8220d5ef
--- /dev/null
+++ b/tools/chorale/composer.json
@@ -0,0 +1,35 @@
+{
+ "name": "sonsofphp/chorale",
+ "description": "Chorale: a CLI tool to help manage PHP monorepos.",
+ "type": "project",
+ "license": "MIT",
+ "require": {
+ "php": "^8.3",
+ "ext-mbstring": "*",
+ "symfony/console": "^7.0",
+ "symfony/yaml": "^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0",
+ "symfony/var-dumper": "^7.3"
+ },
+ "autoload": {
+ "psr-4": {
+ "Chorale\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Chorale\\Tests\\": "src/Tests/"
+ }
+ },
+ "bin": [
+ "bin/chorale"
+ ],
+ "config": {
+ "sort-packages": true,
+ "preferred-install": "dist"
+ },
+ "minimum-stability": "stable",
+ "prefer-stable": true
+}
diff --git a/tools/chorale/phpunit.xml.dist b/tools/chorale/phpunit.xml.dist
new file mode 100644
index 00000000..dac0c3f5
--- /dev/null
+++ b/tools/chorale/phpunit.xml.dist
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+ src/Tests
+
+
+
+
+
+
+
+ src
+
+
+ src/Tests
+
+
+
+
diff --git a/tools/chorale/src/Composer/ComposerJsonReader.php b/tools/chorale/src/Composer/ComposerJsonReader.php
new file mode 100644
index 00000000..f4a833e9
--- /dev/null
+++ b/tools/chorale/src/Composer/ComposerJsonReader.php
@@ -0,0 +1,24 @@
+
+ * if missing/invalid, it will return an empty array
+ */
+ public function read(string $absolutePath): array;
+}
diff --git a/tools/chorale/src/Composer/DependencyMerger.php b/tools/chorale/src/Composer/DependencyMerger.php
new file mode 100644
index 00000000..ee3e8fce
--- /dev/null
+++ b/tools/chorale/src/Composer/DependencyMerger.php
@@ -0,0 +1,219 @@
+ (string) ($options['strategy_require'] ?? 'union-caret'),
+ 'strategy_require_dev' => (string) ($options['strategy_require-dev'] ?? 'union-caret'),
+ 'exclude_monorepo_packages' => (bool) ($options['exclude_monorepo_packages'] ?? true),
+ 'monorepo_names' => (array) ($options['monorepo_names'] ?? []),
+ ];
+
+ $monorepoNames = array_map('strtolower', array_values($normalizedOptions['monorepo_names']));
+
+ $requiredDependencies = [];
+ $devDependencies = [];
+ $constraintsByDependency = [
+ 'require' => [],
+ 'require-dev' => [],
+ ];
+
+ foreach ($packagePaths as $relativePath) {
+ $composerJson = $this->reader->read(rtrim($projectRoot, '/') . '/' . $relativePath . '/composer.json');
+ if ($composerJson === []) {
+ continue;
+ }
+
+ $packageName = strtolower((string) ($composerJson['name'] ?? $relativePath));
+ foreach ((array) ($composerJson['require'] ?? []) as $dependency => $version) {
+ if (!is_string($dependency) || !is_string($version)) {
+ continue;
+ }
+
+ if ($normalizedOptions['exclude_monorepo_packages'] && in_array(strtolower($dependency), $monorepoNames, true)) {
+ continue;
+ }
+
+ $constraintsByDependency['require'][$dependency][$packageName] = $version;
+ }
+
+ foreach ((array) ($composerJson['require-dev'] ?? []) as $dependency => $version) {
+ if (!is_string($dependency) || !is_string($version)) {
+ continue;
+ }
+
+ if ($normalizedOptions['exclude_monorepo_packages'] && in_array(strtolower($dependency), $monorepoNames, true)) {
+ continue;
+ }
+
+ $constraintsByDependency['require-dev'][$dependency][$packageName] = $version;
+ }
+ }
+
+ $conflicts = [];
+ $requiredDependencies = $this->mergeMap($constraintsByDependency['require'], $normalizedOptions['strategy_require'], $conflicts);
+ $devDependencies = $this->mergeMap($constraintsByDependency['require-dev'], $normalizedOptions['strategy_require_dev'], $conflicts);
+
+ ksort($requiredDependencies);
+ ksort($devDependencies);
+
+ return [
+ 'require' => $requiredDependencies,
+ 'require-dev' => $devDependencies,
+ 'conflicts' => array_values($conflicts),
+ ];
+ }
+
+ /**
+ * @param array> $constraintsPerDependency
+ * @param array> $conflictsOut
+ * @return array
+ */
+ private function mergeMap(array $constraintsPerDependency, string $strategy, array &$conflictsOut): array
+ {
+ $mergedConstraints = [];
+ foreach ($constraintsPerDependency as $dependency => $versionsByPackage) {
+ $constraint = $this->chooseConstraint(
+ array_values($versionsByPackage),
+ $strategy,
+ $dependency,
+ $versionsByPackage,
+ $conflictsOut
+ );
+ if ($constraint !== null) {
+ $mergedConstraints[$dependency] = $constraint;
+ }
+ }
+
+ return $mergedConstraints;
+ }
+
+ /**
+ * @param list $constraints
+ * @param array $versionsByPackage
+ */
+ private function chooseConstraint(array $constraints, string $strategy, string $dependency, array $versionsByPackage, array &$conflictsOut): ?string
+ {
+ $strategy = strtolower($strategy);
+ $normalized = array_map([$this,'normalizeConstraint'], array_filter($constraints, 'is_string'));
+ if ($normalized === []) {
+ return null;
+ }
+
+ if ($strategy === 'union-caret') {
+ return $this->chooseUnionCaret($normalized, $dependency, $versionsByPackage, $conflictsOut);
+ }
+
+ if ($strategy === 'union-loose') {
+ return '*';
+ }
+
+ if ($strategy === 'max') {
+ return $this->maxLowerBound($normalized);
+ }
+
+ if ($strategy === 'intersect') {
+ // naive: if all share same major series, pick max lower bound; else conflict
+ $majorVersions = array_unique(array_map(static fn($c): int => $c['major'], $normalized));
+ if (count($majorVersions) > 1) {
+ $this->recordConflict($dependency, $versionsByPackage, $conflictsOut, 'intersect-empty');
+ return null;
+ }
+
+ return $this->maxLowerBound($normalized);
+ }
+
+ // default fallback
+ return $this->chooseUnionCaret($normalized, $dependency, $versionsByPackage, $conflictsOut);
+ }
+
+ /** @param list $norm */
+ private function chooseUnionCaret(array $norm, string $dependency, array $versionsByPackage, array &$conflictsOut): string
+ {
+ // Prefer highest ^MAJOR.MINOR; if any non-caret constraints exist, record a conflict and still pick a sane default.
+ $caret = array_values(array_filter($norm, static fn($c): bool => $c['type'] === 'caret'));
+ if ($caret !== []) {
+ usort($caret, [$this,'cmpSemver']);
+ $best = end($caret);
+ if (count($caret) !== count($norm)) {
+ $this->recordConflict($dependency, $versionsByPackage, $conflictsOut, 'non-caret-mixed');
+ }
+
+ return '^' . $best['major'] . '.' . $best['minor'];
+ }
+
+ // If exact pins or ranges exist, pick the "max lower bound" and record conflict
+ $this->recordConflict($dependency, $versionsByPackage, $conflictsOut, 'non-caret-mixed');
+ return $this->maxLowerBound($norm);
+ }
+
+ /** @param list $norm */
+ private function maxLowerBound(array $norm): string
+ {
+ usort($norm, [$this,'cmpSemver']);
+ $best = end($norm);
+ if ($best['type'] === 'caret') {
+ return '^' . $best['major'] . '.' . $best['minor'];
+ }
+
+ // fallback to exact lower bound
+ return $best['raw'];
+ }
+
+ /** @param array $versionsByPackage */
+ private function recordConflict(string $dependency, array $versionsByPackage, array &$conflictsOut, string $reason): void
+ {
+ $conflictsOut[$dependency] = [
+ 'package' => $dependency,
+ 'versions' => array_values(array_unique(array_values($versionsByPackage))),
+ 'packages' => array_keys($versionsByPackage),
+ 'reason' => $reason,
+ ];
+ }
+
+ /** @return array{raw:string,major:int,minor:int,patch:int,type:string} */
+ private function normalizeConstraint(string $raw): array
+ {
+ $raw = trim($raw);
+ if ($raw === '' || $raw === '*') {
+ return ['raw' => '*', 'major' => 0, 'minor' => 0, 'patch' => 0, 'type' => 'wild'];
+ }
+
+ if ($raw[0] === '^') {
+ $v = substr($raw, 1);
+ [$M,$m,$p] = $this->parseSemver($v);
+ return ['raw' => '^' . $M . '.' . $m, 'major' => $M, 'minor' => $m, 'patch' => $p, 'type' => 'caret'];
+ }
+
+ // naive parse: try to get leading semver numbers
+ [$M,$m,$p] = $this->parseSemver($raw);
+ return ['raw' => $M . '.' . $m . '.' . $p, 'major' => $M, 'minor' => $m, 'patch' => $p, 'type' => 'pin'];
+ }
+
+ /** @return array{0:int,1:int,2:int} */
+ private function parseSemver(string $raw): array
+ {
+ $raw = ltrim($raw, 'vV');
+ $parts = preg_split('/[^\d]+/', $raw);
+ $M = (int) ($parts[0] ?? 0);
+ $m = (int) ($parts[1] ?? 0);
+ $p = (int) ($parts[2] ?? 0);
+ return [$M,$m,$p];
+ }
+
+ /** @param array{major:int,minor:int,patch:int} $a @param array{major:int,minor:int,patch:int} $b */
+ private function cmpSemver(array $a, array $b): int
+ {
+ return [$a['major'],$a['minor'],$a['patch']] <=> [$b['major'],$b['minor'],$b['patch']];
+ }
+}
diff --git a/tools/chorale/src/Composer/DependencyMergerInterface.php b/tools/chorale/src/Composer/DependencyMergerInterface.php
new file mode 100644
index 00000000..57f9d3f3
--- /dev/null
+++ b/tools/chorale/src/Composer/DependencyMergerInterface.php
@@ -0,0 +1,17 @@
+ $packagePaths
+ * @param array $options keys: strategy_require, strategy_require_dev, exclude_monorepo_packages(bool)
+ * @return array{require:array, require-dev:array, conflicts:list>}
+ */
+ public function computeRootMerge(string $projectRoot, array $packagePaths, array $options = []): array;
+}
diff --git a/tools/chorale/src/Composer/RuleEngine.php b/tools/chorale/src/Composer/RuleEngine.php
new file mode 100644
index 00000000..e44c68a9
--- /dev/null
+++ b/tools/chorale/src/Composer/RuleEngine.php
@@ -0,0 +1,214 @@
+resolveRules($config, $context);
+ $edits = [];
+
+ $mirrorKeys = ['authors','license'];
+ $mergeObjectKeys = ['support','funding','extra'];
+ $appendKeys = ['keywords'];
+ $maybeKeys = ['homepage','description'];
+
+ foreach ($mirrorKeys as $key) {
+ $this->maybeApplyMirror($edits, $key, $rules, $rootComposer, $packageComposer, $context);
+ }
+
+ foreach ($mergeObjectKeys as $key) {
+ $this->maybeApplyMergeObject($edits, $key, $rules, $rootComposer, $packageComposer, $context);
+ }
+
+ foreach ($appendKeys as $key) {
+ $this->maybeApplyAppendUnique($edits, $key, $rules, $rootComposer, $packageComposer, $context);
+ }
+
+ foreach ($maybeKeys as $key) {
+ $this->maybeApplyMirrorUnless($edits, $key, $rules, $rootComposer, $packageComposer, $context);
+ }
+
+ // Allow explicit value overrides to force specific values (wins over rules)
+ $overrides = (array) ($context['overrides']['values'] ?? []);
+ foreach ($overrides as $key => $value) {
+ $rendered = $this->renderIfString($value, $context, $rootComposer);
+ if (!$this->equal($packageComposer[$key] ?? null, $rendered)) {
+ $edits[$key] = ['__override' => true] + (is_array($rendered) ? $rendered : ['value' => $rendered]);
+ // Normalize scalar override shape to direct value
+ if (array_key_exists('value', $edits[$key]) && count($edits[$key]) === 2) {
+ $edits[$key] = $edits[$key]['value'];
+ }
+ }
+ }
+
+ return $edits;
+ }
+
+ /** @return array */
+ private function resolveRules(array $config, array $context): array
+ {
+ $defaults = [
+ 'homepage' => 'mirror-unless-overridden',
+ 'authors' => 'mirror',
+ 'license' => 'mirror',
+ 'support' => 'merge-object',
+ 'funding' => 'merge-object',
+ 'keywords' => 'append-unique',
+ 'extra' => 'ignore',
+ 'description' => 'ignore',
+ ];
+ $rootRules = (array) ($config['composer_sync']['rules'] ?? []);
+ $overrideRules = (array) ($context['overrides']['rules'] ?? []);
+ return array_merge($defaults, $rootRules, $overrideRules);
+ }
+
+ /** @param array $edits */
+ private function maybeApplyMirror(array &$edits, string $key, array $rules, array $root, array $pkg, array $ctx): void
+ {
+ if (($rules[$key] ?? 'ignore') !== 'mirror') {
+ return;
+ }
+
+ if (!array_key_exists($key, $root)) {
+ return;
+ }
+
+ $desired = $this->renderIfString($root[$key], $ctx, $root);
+ if (!$this->equal($pkg[$key] ?? null, $desired)) {
+ $edits[$key] = $desired;
+ }
+ }
+
+ /** @param array $edits */
+ private function maybeApplyMirrorUnless(array &$edits, string $key, array $rules, array $root, array $pkg, array $ctx): void
+ {
+ if (($rules[$key] ?? 'ignore') !== 'mirror-unless-overridden') {
+ return;
+ }
+
+ if (array_key_exists($key, $pkg)) {
+ return;
+ }
+
+ if (!array_key_exists($key, $root)) {
+ return;
+ }
+
+ $desired = $this->renderIfString($root[$key], $ctx, $root);
+ if (!$this->equal($pkg[$key] ?? null, $desired)) {
+ $edits[$key] = $desired;
+ }
+ }
+
+ /** @param array $edits */
+ private function maybeApplyMergeObject(array &$edits, string $key, array $rules, array $root, array $pkg, array $ctx): void
+ {
+ if (($rules[$key] ?? 'ignore') !== 'merge-object') {
+ return;
+ }
+
+ $rootVal = $this->renderIfString($root[$key] ?? null, $ctx, $root);
+ $pkgVal = $pkg[$key] ?? null;
+ if (!is_array($rootVal)) {
+ return;
+ }
+
+ $merged = $this->deepMerge($rootVal, is_array($pkgVal) ? $pkgVal : []);
+ if (!$this->equal($pkgVal, $merged)) {
+ $edits[$key] = $merged;
+ }
+ }
+
+ /** @param array $edits */
+ private function maybeApplyAppendUnique(array &$edits, string $key, array $rules, array $root, array $pkg, array $ctx): void
+ {
+ if (($rules[$key] ?? 'ignore') !== 'append-unique') {
+ return;
+ }
+
+ $rootVal = $this->renderIfString($root[$key] ?? null, $ctx, $root);
+ $pkgVal = $pkg[$key] ?? null;
+ if (!is_array($rootVal)) {
+ return;
+ }
+
+ $rootList = array_values(array_filter($rootVal, static fn($v): bool => is_string($v) && $v !== ''));
+ $pkgList = is_array($pkgVal) ? array_values(array_filter($pkgVal, static fn($v): bool => is_string($v) && $v !== '')) : [];
+ $merged = array_values(array_unique(array_merge($pkgList, $rootList)));
+ sort($merged);
+ if (!$this->equal($pkgList, $merged)) {
+ $edits[$key] = $merged;
+ }
+ }
+
+ private function renderIfString(mixed $val, array $ctx, array $root): mixed
+ {
+ if (!is_string($val)) {
+ return $val;
+ }
+
+ $vars = [
+ 'name' => (string) ($ctx['name'] ?? ''),
+ 'path' => (string) ($ctx['path'] ?? ''),
+ 'repo_vendor' => $this->inferVendorFromRoot($root),
+ ];
+ return $this->renderer->render($val, $vars);
+ }
+
+ private function inferVendorFromRoot(array $root): string
+ {
+ $name = is_string($root['name'] ?? null) ? $root['name'] : '';
+ if (str_contains($name, '/')) {
+ return strtolower(substr($name, 0, strpos($name, '/')));
+ }
+
+ return '';
+ }
+
+ private function deepMerge(array $a, array $b): array
+ {
+ $out = $a;
+ foreach ($b as $k => $v) {
+ $out[$k] = is_array($v) && isset($out[$k]) && is_array($out[$k]) ? $this->deepMerge($out[$k], $v) : $v;
+ }
+
+ return $out;
+ }
+
+ private function equal(mixed $a, mixed $b): bool
+ {
+ if (is_array($a) && is_array($b)) {
+ ksort($a);
+ ksort($b);
+ foreach ($a as $k => $v) {
+ if (!array_key_exists($k, $b)) {
+ return false;
+ }
+
+ if (!$this->equal($v, $b[$k])) {
+ return false;
+ }
+ }
+
+ foreach (array_keys($b) as $k) {
+ if (!array_key_exists($k, $a)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ return $a === $b;
+ }
+}
diff --git a/tools/chorale/src/Composer/RuleEngineInterface.php b/tools/chorale/src/Composer/RuleEngineInterface.php
new file mode 100644
index 00000000..d2e3bf89
--- /dev/null
+++ b/tools/chorale/src/Composer/RuleEngineInterface.php
@@ -0,0 +1,20 @@
+ $packageComposer Current package composer.json
+ * @param array $rootComposer Root composer.json
+ * @param array $config chorale.yaml config
+ * @param array $context { path, name }
+ *
+ * @return array Changed keys only (what to write). Overridden values may include an internal '__override' flag.
+ */
+ public function computePackageEdits(array $packageComposer, array $rootComposer, array $config, array $context): array;
+}
diff --git a/tools/chorale/src/Config/ConfigDefaults.php b/tools/chorale/src/Config/ConfigDefaults.php
new file mode 100644
index 00000000..54d1751d
--- /dev/null
+++ b/tools/chorale/src/Config/ConfigDefaults.php
@@ -0,0 +1,52 @@
+ */
+ private array $fallbacks = [
+ 'repo_host' => 'git@github.com',
+ 'repo_vendor' => 'SonsOfPHP',
+ 'repo_name_template' => '{name:kebab}.git',
+ 'default_repo_template' => '{repo_host}:{repo_vendor}/{repo_name_template}',
+ 'default_branch' => 'main',
+ 'splitter' => 'splitsh',
+ 'tag_strategy' => 'inherit-monorepo-tag',
+ 'rules' => [
+ 'keep_history' => true,
+ 'skip_if_unchanged' => true,
+ 'require_files' => ['composer.json', 'LICENSE'],
+ ],
+ ];
+
+ public function resolve(array $config): array
+ {
+ $out = $this->fallbacks;
+
+ foreach (array_keys($this->fallbacks) as $k) {
+ if (array_key_exists($k, $config)) {
+ if ($k === 'rules') {
+ $out['rules'] = array_merge($out['rules'], (array) $config['rules']);
+ } else {
+ $out[$k] = (string) $config[$k];
+ }
+ }
+ }
+
+ // If the template explicitly provided, keep it;
+ // otherwise compute from the resolved parts.
+ if (!isset($config['default_repo_template']) || $config['default_repo_template'] === '') {
+ $out['default_repo_template'] = sprintf(
+ '%s:%s/%s',
+ $out['repo_host'],
+ $out['repo_vendor'],
+ $out['repo_name_template']
+ );
+ }
+
+ return $out;
+ }
+}
diff --git a/tools/chorale/src/Config/ConfigDefaultsInterface.php b/tools/chorale/src/Config/ConfigDefaultsInterface.php
new file mode 100644
index 00000000..4226cf78
--- /dev/null
+++ b/tools/chorale/src/Config/ConfigDefaultsInterface.php
@@ -0,0 +1,23 @@
+ $config Raw parsed YAML or empty array.
+ * @return array{
+ * repo_host: string,
+ * repo_vendor: string,
+ * repo_name_template: string,
+ * default_repo_template: string,
+ * default_branch: string,
+ * splitter: string,
+ * tag_strategy: string,
+ * rules: array
+ * }
+ */
+ public function resolve(array $config): array;
+}
diff --git a/tools/chorale/src/Config/ConfigLoader.php b/tools/chorale/src/Config/ConfigLoader.php
new file mode 100644
index 00000000..0d3ce93e
--- /dev/null
+++ b/tools/chorale/src/Config/ConfigLoader.php
@@ -0,0 +1,28 @@
+fileName;
+ if (!is_file($path)) {
+ return [];
+ }
+ $raw = file_get_contents($path);
+ if ($raw === false) {
+ throw new \RuntimeException("Failed to read {$path}");
+ }
+ $data = Yaml::parse($raw);
+ return is_array($data) ? $data : [];
+ }
+}
diff --git a/tools/chorale/src/Config/ConfigLoaderInterface.php b/tools/chorale/src/Config/ConfigLoaderInterface.php
new file mode 100644
index 00000000..8856827f
--- /dev/null
+++ b/tools/chorale/src/Config/ConfigLoaderInterface.php
@@ -0,0 +1,11 @@
+defaults->resolve($config);
+
+ // drop redundant overrides in patterns
+ $patterns = (array) ($config['patterns'] ?? []);
+ foreach ($patterns as &$p) {
+ $p = (array) $p;
+ foreach (['repo_host','repo_vendor','repo_name_template'] as $k) {
+ if (isset($p[$k]) && (string) $p[$k] === (string) $def[$k]) {
+ unset($p[$k]);
+ }
+ }
+ }
+ unset($p);
+ $patterns = $this->sorting->sortPatterns($patterns);
+
+ // drop redundant overrides in targets
+ $targets = (array) ($config['targets'] ?? []);
+ foreach ($targets as &$t) {
+ $t = (array) $t;
+ foreach (['repo_host','repo_vendor','repo_name_template'] as $k) {
+ if (isset($t[$k]) && (string) $t[$k] === (string) $def[$k]) {
+ unset($t[$k]);
+ }
+ }
+ }
+ unset($t);
+ $targets = $this->sorting->sortTargets($targets);
+
+ // Rebuild config with stable top-level key order
+ $out = [
+ 'version' => $config['version'] ?? 1,
+ 'repo_host' => $def['repo_host'],
+ 'repo_vendor' => $def['repo_vendor'],
+ 'repo_name_template' => $def['repo_name_template'],
+ 'default_repo_template' => $def['default_repo_template'],
+ 'default_branch' => $def['default_branch'],
+ 'splitter' => $def['splitter'],
+ 'tag_strategy' => $def['tag_strategy'],
+ 'rules' => $def['rules'],
+ ];
+ if ($patterns !== []) {
+ $out['patterns'] = $patterns;
+ }
+ if ($targets !== []) {
+ $out['targets'] = $targets;
+ }
+ if (!empty($config['hooks'])) {
+ $out['hooks'] = array_values((array) $config['hooks']);
+ }
+
+ return $out;
+ }
+}
diff --git a/tools/chorale/src/Config/ConfigNormalizerInterface.php b/tools/chorale/src/Config/ConfigNormalizerInterface.php
new file mode 100644
index 00000000..11577874
--- /dev/null
+++ b/tools/chorale/src/Config/ConfigNormalizerInterface.php
@@ -0,0 +1,18 @@
+ $config
+ * @return array
+ */
+ public function normalize(array $config): array;
+}
diff --git a/tools/chorale/src/Config/ConfigWriter.php b/tools/chorale/src/Config/ConfigWriter.php
new file mode 100644
index 00000000..024a69d1
--- /dev/null
+++ b/tools/chorale/src/Config/ConfigWriter.php
@@ -0,0 +1,35 @@
+fileName;
+
+ // backup first
+ $this->backup->backup($path);
+
+ $yaml = Yaml::dump($config, 8, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
+ $tmp = $path . '.tmp';
+
+ if (@file_put_contents($tmp, $yaml) === false) {
+ throw new \RuntimeException("Failed to write temp file: {$tmp}");
+ }
+ if (!@rename($tmp, $path)) {
+ @unlink($tmp);
+ throw new \RuntimeException("Failed to replace {$path}");
+ }
+ }
+}
diff --git a/tools/chorale/src/Config/ConfigWriterInterface.php b/tools/chorale/src/Config/ConfigWriterInterface.php
new file mode 100644
index 00000000..ae1c3eb1
--- /dev/null
+++ b/tools/chorale/src/Config/ConfigWriterInterface.php
@@ -0,0 +1,14 @@
+ $config
+ */
+ public function write(string $projectRoot, array $config): void;
+}
diff --git a/tools/chorale/src/Config/SchemaValidator.php b/tools/chorale/src/Config/SchemaValidator.php
new file mode 100644
index 00000000..8d39ed07
--- /dev/null
+++ b/tools/chorale/src/Config/SchemaValidator.php
@@ -0,0 +1,88 @@
+ $p) {
+ if (!is_array($p)) {
+ $issues[] = "patterns[$i] must be an object.";
+ continue;
+ }
+ if (!isset($p['match']) || !is_string($p['match'])) {
+ $issues[] = "patterns[$i].match must be a string.";
+ }
+ foreach (['repo_host','repo_vendor','repo_name_template','repo'] as $k) {
+ if (isset($p[$k]) && !is_string($p[$k])) {
+ $issues[] = "patterns[$i].{$k} must be a string.";
+ }
+ }
+ foreach (['include','exclude'] as $k) {
+ if (isset($p[$k]) && !is_array($p[$k])) {
+ $issues[] = "patterns[$i].{$k} must be a list of strings.";
+ }
+ }
+ }
+ }
+
+ if (isset($config['targets']) && is_array($config['targets'])) {
+ foreach ($config['targets'] as $i => $t) {
+ if (!is_array($t)) {
+ $issues[] = "targets[$i] must be an object.";
+ continue;
+ }
+ foreach (['name','path','repo_host','repo_vendor','repo_name_template','repo'] as $k) {
+ if (isset($t[$k]) && !is_string($t[$k])) {
+ $issues[] = "targets[$i].{$k} must be a string.";
+ }
+ }
+ foreach (['include','exclude'] as $k) {
+ if (isset($t[$k]) && !is_array($t[$k])) {
+ $issues[] = "targets[$i].{$k} must be a list of strings.";
+ }
+ }
+ }
+ }
+
+ return $issues;
+ }
+}
diff --git a/tools/chorale/src/Config/SchemaValidatorInterface.php b/tools/chorale/src/Config/SchemaValidatorInterface.php
new file mode 100644
index 00000000..ba5095ea
--- /dev/null
+++ b/tools/chorale/src/Config/SchemaValidatorInterface.php
@@ -0,0 +1,17 @@
+ $config
+ * @param string $schemaPath absolute or repo-relative path
+ * @return list messages; empty means valid
+ */
+ public function validate(array $config, string $schemaPath): array;
+}
diff --git a/tools/chorale/src/Console/ApplyCommand.php b/tools/chorale/src/Console/ApplyCommand.php
new file mode 100644
index 00000000..bda3aecd
--- /dev/null
+++ b/tools/chorale/src/Console/ApplyCommand.php
@@ -0,0 +1,64 @@
+setName('apply')
+ ->setDescription('Apply steps from a JSON plan file.')
+ ->setHelp(<<<'HELP'
+Reads a plan exported from `chorale plan --json` and executes each step.
+
+Example:
+ chorale apply --project-root /path/to/repo --file plan.json
+HELP)
+ ->addOption('project-root', null, InputOption::VALUE_REQUIRED, 'Project root (default: CWD).')
+ ->addOption('file', 'f', InputOption::VALUE_REQUIRED, 'Path to JSON plan file.', 'plan.json');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = $this->styleFactory->create($input, $output);
+ $root = rtrim((string) ($input->getOption('project-root') ?: getcwd()), '/');
+ $file = (string) $input->getOption('file');
+
+ if (!is_file($file)) {
+ $io->error('Plan file not found: ' . $file);
+ return 2;
+ }
+
+ $json = json_decode((string) file_get_contents($file), true);
+ if (!is_array($json) || !isset($json['steps']) || !is_array($json['steps'])) {
+ $io->error('Invalid plan file.');
+ return 2;
+ }
+
+ /** @var list> $steps */
+ $steps = $json['steps'];
+ $this->runner->apply($root, $steps);
+ $io->success(sprintf('Applied %d step(s).', count($steps)));
+ return 0;
+ }
+}
diff --git a/tools/chorale/src/Console/PlanCommand.php b/tools/chorale/src/Console/PlanCommand.php
new file mode 100644
index 00000000..6be3087d
--- /dev/null
+++ b/tools/chorale/src/Console/PlanCommand.php
@@ -0,0 +1,184 @@
+setName('plan')
+ ->setDescription('Build and print a dry-run plan of actionable steps.')
+ ->setHelp('Generates a plan of changes without modifying files. Use --json to export for apply.')
+ ->addOption('project-root', null, InputOption::VALUE_REQUIRED, 'Project root (default: CWD).')
+ ->addOption('paths', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Limit to specific package paths', [])
+ ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON instead of human-readable.')
+ ->addOption('show-all', null, InputOption::VALUE_NONE, 'Show no-op summaries (does not turn them into steps).')
+ ->addOption('force-split', null, InputOption::VALUE_NONE, 'Force split steps even if unchanged.')
+ ->addOption('verify-remote', null, InputOption::VALUE_NONE, 'Verify remote state if lockfile is missing/stale.')
+ ->addOption('strict', null, InputOption::VALUE_NONE, 'Fail on missing root version / unresolved conflicts / remote failures.');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = $this->styleFactory->create($input, $output);
+ $root = rtrim((string) ($input->getOption('project-root') ?: getcwd()), '/');
+ /** @var list $paths */
+ $paths = (array) $input->getOption('paths');
+ $json = (bool) $input->getOption('json');
+ $showAll = (bool) $input->getOption('show-all');
+ $force = (bool) $input->getOption('force-split');
+ $verify = (bool) $input->getOption('verify-remote');
+ $strict = (bool) $input->getOption('strict');
+
+ $config = $this->configLoader->load($root);
+ if ($config === []) {
+ $io->warning('No chorale.yaml found. Run "chorale setup" first.');
+ return 2;
+ }
+
+ $result = $this->planner->build($root, $config, [
+ 'paths' => $paths,
+ 'show_all' => $showAll,
+ 'force_split' => $force,
+ 'verify_remote' => $verify,
+ 'strict' => $strict,
+ ]);
+
+ // Planner returns an associative array with 'steps' and optional 'noop'
+ $steps = $result['steps'] ?? [];
+ $noop = $result['noop'] ?? [];
+
+ if ($json) {
+ $payload = [
+ 'version' => 1,
+ 'steps' => array_map(static fn(PlanStepInterface $s): array => $s->toArray(), $steps),
+ 'noop' => $showAll ? $noop : [],
+ ];
+ $encoded = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ if ($encoded === false) {
+ $io->error('Failed to encode plan.');
+ return 2;
+ }
+
+ $output->writeln($encoded);
+ // Non-zero exit in strict mode when planner flags issues
+ return (int) ($result['exit_code'] ?? 0);
+ }
+
+ $this->renderHuman($io, $steps, $showAll ? $noop : []);
+ return (int) ($result['exit_code'] ?? 0);
+ }
+
+ /** @param list $steps */
+ private function renderHuman(SymfonyStyle $io, array $steps, array $noop): void
+ {
+ $io->title('Chorale Plan');
+
+ $byType = [];
+ foreach ($steps as $s) {
+ $byType[$s->type()][] = $s;
+ }
+
+ $sections = [
+ 'split' => 'Split steps',
+ 'package-version-update' => 'Package versions',
+ 'package-metadata-sync' => 'Package metadata',
+ 'composer-root-update' => 'Root composer: aggregator',
+ 'composer-root-merge' => 'Root composer: dependency merge',
+ 'composer-root-rebuild' => 'Root composer: maintenance',
+ ];
+
+ $any = false;
+ foreach ($sections as $type => $label) {
+ if (empty($byType[$type])) {
+ continue;
+ }
+
+ $any = true;
+ $io->section($label);
+ foreach ($byType[$type] as $s) {
+ $a = $s->toArray();
+ $io->writeln(' • ' . $this->humanLine($type, $a));
+ }
+ }
+
+ if (!$any) {
+ $io->writeln('No steps. Nothing to do.');
+ }
+
+ if ($noop !== []) {
+ $io->newLine();
+ $io->section('No-op summary (debug)');
+ foreach ($noop as $group => $rows) {
+ $io->writeln(sprintf(' - %s: ', $group) . count($rows));
+ }
+ }
+
+ $io->comment('Use "--json" to export this plan for apply.');
+ }
+
+ /** @param array $a */
+ private function humanLine(string $type, array $a): string
+ {
+ return match ($type) {
+ 'split' => sprintf(
+ '%s → %s [%s]%s',
+ $a['path'] ?? '',
+ $a['repo'] ?? '',
+ $a['splitter'] ?? '',
+ empty($a['reasons']) ? '' : ' {' . implode(',', (array) $a['reasons']) . '}'
+ ),
+ 'package-version-update' => sprintf('%s — set version %s', $a['name'] ?? $a['path'] ?? '', $a['version'] ?? ''),
+ 'package-metadata-sync' => sprintf(
+ '%s — %s%s',
+ $a['name'] ?? $a['path'] ?? '',
+ 'mirror ' . implode(',', array_keys((array) ($a['apply'] ?? []))),
+ empty($a['overrides_used']) ? '' : ' [overrides: ' . implode(',', (array) $a['overrides_used']['values']) . ']'
+ ),
+ 'composer-root-update' => sprintf(
+ 'update %s (version %s, require %d, replace %d)',
+ $a['root'] ?? '',
+ $a['root_version'] ?? 'n/a',
+ isset($a['require']) ? count((array) $a['require']) : 0,
+ isset($a['replace']) ? count((array) $a['replace']) : 0
+ ),
+ 'composer-root-merge' => sprintf(
+ 'require %d, require-dev %d%s',
+ isset($a['require']) ? count((array) $a['require']) : 0,
+ isset($a['require-dev']) ? count((array) $a['require-dev']) : 0,
+ empty($a['conflicts']) ? '' : ' [conflicts: ' . count((array) $a['conflicts']) . ']'
+ ),
+ 'composer-root-rebuild' => sprintf('actions: %s', implode(',', (array) ($a['actions'] ?? []))),
+ default => json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: $type,
+ };
+ }
+}
diff --git a/tools/chorale/src/Console/RunCommand.php b/tools/chorale/src/Console/RunCommand.php
new file mode 100644
index 00000000..749fbe05
--- /dev/null
+++ b/tools/chorale/src/Console/RunCommand.php
@@ -0,0 +1,157 @@
+setName('run')
+ ->setDescription('Plan and immediately apply steps.')
+ ->setHelp(<<<'HELP'
+Builds a plan for the repository and applies it in a single command.
+This is equivalent to running `chorale plan` followed by `chorale apply`.
+
+Examples:
+ chorale run
+ chorale run --paths packages/acme --strict
+HELP)
+ ->addOption('project-root', null, InputOption::VALUE_REQUIRED, 'Project root (default: CWD).')
+ ->addOption('paths', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Limit to specific package paths', [])
+ ->addOption('force-split', null, InputOption::VALUE_NONE, 'Force split steps even if unchanged.')
+ ->addOption('verify-remote', null, InputOption::VALUE_NONE, 'Verify remote state if lockfile is missing/stale.')
+ ->addOption('strict', null, InputOption::VALUE_NONE, 'Fail on missing root version / unresolved conflicts / remote failures.')
+ ->addOption('show-all', null, InputOption::VALUE_NONE, 'Show no-op summaries.');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = $this->styleFactory->create($input, $output);
+ $root = rtrim((string) ($input->getOption('project-root') ?: getcwd()), '/');
+ /** @var list $paths */
+ $paths = (array) $input->getOption('paths');
+ $force = (bool) $input->getOption('force-split');
+ $verify = (bool) $input->getOption('verify-remote');
+ $strict = (bool) $input->getOption('strict');
+ $showAll = (bool) $input->getOption('show-all');
+
+ try {
+ $result = $this->runner->run($root, [
+ 'paths' => $paths,
+ 'force_split' => $force,
+ 'verify_remote' => $verify,
+ 'strict' => $strict,
+ 'show_all' => $showAll,
+ ]);
+ } catch (\RuntimeException $e) {
+ $io->error($e->getMessage());
+ return 2;
+ }
+
+ $this->renderHuman($io, $result['steps'], $showAll ? ($result['noop'] ?? []) : []);
+ $io->success(sprintf('Applied %d step(s).', count($result['steps'])));
+ return (int) ($result['exit_code'] ?? 0);
+ }
+
+ /** @param list $steps */
+ private function renderHuman(SymfonyStyle $io, array $steps, array $noop): void
+ {
+ $io->title('Chorale Run');
+ $byType = [];
+ foreach ($steps as $s) {
+ $byType[$s->type()][] = $s;
+ }
+
+ $sections = [
+ 'split' => 'Split steps',
+ 'package-version-update' => 'Package versions',
+ 'package-metadata-sync' => 'Package metadata',
+ 'composer-root-update' => 'Root composer: aggregator',
+ 'composer-root-merge' => 'Root composer: dependency merge',
+ 'composer-root-rebuild' => 'Root composer: maintenance',
+ ];
+
+ $any = false;
+ foreach ($sections as $type => $label) {
+ if (empty($byType[$type])) {
+ continue;
+ }
+
+ $any = true;
+ $io->section($label);
+ foreach ($byType[$type] as $s) {
+ $a = $s->toArray();
+ $io->writeln(' • ' . $this->humanLine($type, $a));
+ }
+ }
+
+ if (!$any) {
+ $io->writeln('No steps. Nothing to do.');
+ }
+
+ if ($noop !== []) {
+ $io->newLine();
+ $io->section('No-op summary (debug)');
+ foreach ($noop as $group => $rows) {
+ $io->writeln(sprintf(' - %s: ', $group) . count($rows));
+ }
+ }
+ }
+
+ /** @param array $a */
+ private function humanLine(string $type, array $a): string
+ {
+ return match ($type) {
+ 'split' => sprintf(
+ '%s → %s [%s]%s',
+ $a['path'] ?? '',
+ $a['repo'] ?? '',
+ $a['splitter'] ?? '',
+ empty($a['reasons']) ? '' : ' {' . implode(',', (array) $a['reasons']) . '}'
+ ),
+ 'package-version-update' => sprintf('%s — set version %s', $a['name'] ?? $a['path'] ?? '', $a['version'] ?? ''),
+ 'package-metadata-sync' => sprintf(
+ '%s — mirror %s',
+ $a['name'] ?? $a['path'] ?? '',
+ implode(',', array_keys((array) ($a['apply'] ?? [])))
+ ),
+ 'composer-root-update' => sprintf(
+ 'update %s (version %s, require %d, replace %d)',
+ $a['root'] ?? '',
+ $a['root_version'] ?? 'n/a',
+ isset($a['require']) ? count((array) $a['require']) : 0,
+ isset($a['replace']) ? count((array) $a['replace']) : 0
+ ),
+ 'composer-root-merge' => sprintf(
+ 'require %d, require-dev %d%s',
+ isset($a['require']) ? count((array) $a['require']) : 0,
+ isset($a['require-dev']) ? count((array) $a['require-dev']) : 0,
+ empty($a['conflicts']) ? '' : ' [conflicts: ' . count((array) $a['conflicts']) . ']'
+ ),
+ 'composer-root-rebuild' => sprintf('actions: %s', implode(',', (array) ($a['actions'] ?? []))),
+ default => json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: $type,
+ };
+ }
+}
diff --git a/tools/chorale/src/Console/SetupCommand.php b/tools/chorale/src/Console/SetupCommand.php
new file mode 100644
index 00000000..ad6ad535
--- /dev/null
+++ b/tools/chorale/src/Console/SetupCommand.php
@@ -0,0 +1,565 @@
+setName('setup')
+ ->setDescription('Create or update chorale.yaml by scanning src/ and applying defaults.')
+ ->setHelp('Scans packages and writes a chorale.yaml configuration file.')
+ ->addOption('non-interactive', null, InputOption::VALUE_NONE, 'Never prompt.')
+ ->addOption('accept-all', null, InputOption::VALUE_NONE, 'Accept suggested adds/renames.')
+ ->addOption('discover-only', null, InputOption::VALUE_NONE, 'Only scan & print; do not write.')
+ ->addOption('paths', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Limit discovery to these paths (relative to repo root).', [])
+ ->addOption('strict', null, InputOption::VALUE_NONE, 'Treat warnings as errors.')
+ ->addOption('json', null, InputOption::VALUE_NONE, 'Emit machine-readable JSON report.')
+ ->addOption('write', null, InputOption::VALUE_NONE, 'Write without confirmation (CI-safe with --non-interactive).')
+ ->addOption('project-root', null, InputOption::VALUE_REQUIRED, 'Override project root (default: cwd).');
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Orchestrator
+ // ─────────────────────────────────────────────────────────────────────────────
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = $this->styleFactory->create($input, $output);
+ $opts = $this->gatherOptions($input);
+
+ [$config, $firstRun] = $this->loadOrSeedConfig($opts['root']);
+
+ if (($msgs = $this->validateSchema($config)) !== []) {
+ $this->printIssues($io, $msgs);
+ if ($opts['strict']) {
+ $io->error('Strict mode: schema validation failed.');
+ return 2;
+ }
+ }
+
+ $def = $this->defaults->resolve($config);
+ $patterns = (array) ($config['patterns'] ?? []);
+ $targets = (array) ($config['targets'] ?? []);
+ $scanRoots = $this->determineRoots($patterns, $opts['root']);
+ if ($firstRun && $patterns === []) {
+ foreach ($scanRoots as $r) {
+ // Add a single globstar pattern for that root
+ $config['patterns'][] = ['match' => $r . '/**', 'include' => ['**']];
+ }
+ }
+
+ $discPaths = [];
+ foreach ($scanRoots as $r) {
+ $found = $this->scanner->scan($opts['root'], $r, $opts['paths']);
+ $discPaths = array_merge($discPaths, $found);
+ }
+
+ $discPaths = array_values(array_unique($discPaths));
+ sort($discPaths);
+
+ $groups = $this->classifyAll(
+ $opts['root'],
+ $def,
+ $patterns,
+ $targets,
+ $discPaths
+ );
+
+ // Summary counts
+ foreach ($groups as $k => $items) {
+ foreach ($items as $_) {
+ $this->summary->inc($k);
+ }
+ }
+
+ if ($opts['json']) {
+ $defaultsForJson = [
+ 'repo_host' => $def['repo_host'],
+ 'repo_vendor' => $def['repo_vendor'],
+ 'repo_name_template' => $def['repo_name_template'],
+ 'default_repo_template' => $def['default_repo_template'],
+ ];
+ $output->write($this->jsonReporter->build($defaultsForJson, $groups, $this->buildActions($groups, $targets)));
+ return 0;
+ }
+
+ $io->title('Chorale Setup');
+ $this->renderHumanReport($io, $groups);
+
+ if ($opts['discoverOnly']) {
+ $io->success('Discovery only. No changes written.');
+ return 4;
+ }
+
+ if ($opts['strict'] && ($groups['issues'] !== [] || $groups['conflicts'] !== [])) {
+ $io->error('Strict mode: unresolved issues/conflicts.');
+ return 2;
+ }
+
+ $actions = $this->buildActions($groups, $targets);
+ if ($actions === []) {
+ $tot = $this->summary->all();
+ $io->writeln(sprintf(
+ "No changes detected. • %d ok • %d new • %d renamed • %d drift • %d issues • %d conflicts",
+ $tot['ok'] ?? 0,
+ $tot['new'] ?? 0,
+ $tot['renamed'] ?? 0,
+ $tot['drift'] ?? 0,
+ $tot['issues'] ?? 0,
+ $tot['conflicts'] ?? 0
+ ));
+ $io->note('You can run this command as many times as you want. Run this after you create a new package.');
+ return 0;
+ }
+
+ $io->section('Summary (to be written)');
+ foreach ($actions as $a) {
+ $io->writeln('- ' . $this->renderAction($a));
+ }
+
+ if (!$this->confirmWrite($io, $opts)) {
+ $io->warning('Aborted. No changes written.');
+ return 3;
+ }
+
+ $newConfig = $this->applyActions($config, $actions);
+ $normalized = $this->configNormalizer->normalize($newConfig);
+ $this->configWriter->write($opts['root'], $normalized);
+
+ $io->success('Updated ./chorale.yaml');
+ return 0;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Options / IO
+ // ─────────────────────────────────────────────────────────────────────────────
+ /** @return array{root:string,nonInteractive:bool,acceptAll:bool,discoverOnly:bool,strict:bool,json:bool,write:bool,paths:array} */
+ private function gatherOptions(InputInterface $input): array
+ {
+ $root = rtrim((string) ($input->getOption('project-root') ?: getcwd()), '/');
+
+ return [
+ 'root' => $root,
+ 'nonInteractive' => (bool) $input->getOption('non-interactive'),
+ 'acceptAll' => (bool) $input->getOption('accept-all'),
+ 'discoverOnly' => (bool) $input->getOption('discover-only'),
+ 'strict' => (bool) $input->getOption('strict'),
+ 'json' => (bool) $input->getOption('json'),
+ 'write' => (bool) $input->getOption('write'),
+ 'paths' => (array) $input->getOption('paths'),
+ ];
+ }
+
+ /** @return array{array, bool} [config, firstRun] */
+ private function loadOrSeedConfig(string $root): array
+ {
+ $config = $this->configLoader->load($root);
+ if ($config !== []) {
+ return [$config, false];
+ }
+
+ // Seed minimal config on first run (patterns-only; no targets by default)
+ return [[
+ 'version' => 1,
+ 'repo_host' => 'git@github.com',
+ 'repo_vendor' => 'SonsOfPHP',
+ 'repo_name_template' => '{name:kebab}.git',
+ 'default_repo_template' => '{repo_host}:{repo_vendor}/{repo_name_template}',
+ 'default_branch' => 'main',
+ 'splitter' => 'splitsh',
+ 'tag_strategy' => 'inherit-monorepo-tag',
+ 'rules' => [
+ 'keep_history' => true,
+ 'skip_if_unchanged' => true,
+ 'require_files' => ['composer.json', 'LICENSE'],
+ ],
+ //'patterns' => [
+ // ['match' => 'src/**', 'include' => ['**']],
+ //],
+ // 'targets' omitted by design
+ ], true];
+ }
+
+ /** @return list */
+ private function validateSchema(array $config): array
+ {
+ // Keeping this simple (we already do type checks in SchemaValidator)
+ return $this->schemaValidator->validate($config, 'tools/chorale/config/chorale.schema.yaml');
+ }
+
+ private function printIssues(SymfonyStyle $io, array $messages): void
+ {
+ foreach ($messages as $m) {
+ $io->warning($m);
+ }
+ }
+
+ private function confirmWrite(SymfonyStyle $io, array $opts): bool
+ {
+ if ($opts['write'] || $opts['acceptAll'] || $opts['nonInteractive']) {
+ return true;
+ }
+
+ $helper = $this->getHelper('question');
+ $confirm = new ConfirmationQuestion('Proceed? [Y/n] ', true);
+
+ return (bool) $helper->ask($io->getInput(), $io->getOutput(), $confirm);
+ }
+
+ /** @return list e.g. ["src","packages"] */
+ private function determineRoots(array $patterns, string $root): array
+ {
+ if ($patterns !== []) {
+ $roots = [];
+ foreach ($patterns as $p) {
+ $m = (string) ($p['match'] ?? '');
+ if ($m === '') {
+ continue;
+ }
+
+ // root is first segment before slash
+ $seg = explode('/', ltrim($m, '/'), 2)[0] ?? '';
+ if ($seg !== '' && !in_array($seg, $roots, true)) {
+ $roots[] = $seg;
+ }
+ }
+
+ return $roots !== [] ? $roots : ['src']; // safe fallback if patterns are odd
+ }
+
+ // First run: probe both src and packages
+ $roots = [];
+ foreach (['src','packages'] as $cand) {
+ $any = $this->scanner->scan($root, $cand, []);
+ if ($any !== []) {
+ $roots[] = $cand;
+ }
+ }
+
+ return $roots;
+ }
+
+ private function displayNameFor(string $projectRoot, string $pkgPath): string
+ {
+ $abs = rtrim($projectRoot, '/') . '/' . ltrim($pkgPath, '/');
+ $meta = $this->composerMeta->read($abs);
+ if (!empty($meta['name'])) {
+ $name = (string) $meta['name'];
+ // choose style: last segment after "/" for brevity
+ $last = str_contains($name, '/') ? substr($name, strrpos($name, '/') + 1) : $name;
+ return $last;
+ }
+
+ return basename($pkgPath);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Classification
+ // ─────────────────────────────────────────────────────────────────────────────
+ /**
+ * @param array $defaults
+ * @param array> $patterns
+ * @param array> $targets
+ * @param list $discovered
+ * @return array{new:array,renamed:array,drift:array,issues:array,conflicts:array,ok:array}
+ */
+ private function classifyAll(string $root, array $defaults, array $patterns, array $targets, array $discovered): array
+ {
+ $byPath = [];
+ foreach ($targets as $t) {
+ $byPath[(string) $t['path']] = $t;
+ }
+
+ $groups = [
+ 'new' => [], 'renamed' => [], 'drift' => [], 'issues' => [], 'conflicts' => [], 'ok' => [],
+ ];
+
+ foreach ($discovered as $pkgPath) {
+ $row = $this->classifyOne($root, $pkgPath, $defaults, $patterns, $byPath);
+ $groups[$row['group']][] = $row['data'];
+ }
+
+ // Additionally, detect explicit-target renames where the old path no longer exists
+ foreach ($byPath as $oldPath => $target) {
+ if (!in_array($oldPath, $discovered, true)) {
+ // If target points to a path that no longer exists but a new path with same identity does, propose rename
+ $maybe = $this->findRenameTarget($oldPath, $target, $defaults, $patterns, $discovered);
+ if ($maybe !== null) {
+ $groups['renamed'][] = $maybe;
+ }
+ }
+ }
+
+ return $groups;
+ }
+
+ /**
+ * Classify a single discovered package path.
+ * Rules:
+ * - If no explicit target & no matching pattern ⇒ NEW (config must change)
+ * - If pattern matches and no explicit target ⇒ OK (covered by pattern)
+ * - If explicit target exists ⇒ check issues/drift; else OK
+ *
+ * @return array{group:string,data:array}
+ */
+ private function classifyOne(string $root, string $pkgPath, array $defaults, array $patterns, array $targetsByPath): array
+ {
+ $matches = $this->matcher->allMatches($patterns, $pkgPath);
+ $pattern = $matches ? (array) $patterns[$matches[0]] : [];
+ $hasExplicitTarget = isset($targetsByPath[$pkgPath]);
+
+ $name = basename($pkgPath);
+ $target = $targetsByPath[$pkgPath] ?? [];
+ $repo = $this->resolver->resolve($defaults, $pattern, $target, $pkgPath, $name);
+
+ $pkgName = $this->displayNameFor($root, $pkgPath);
+
+ // Conflicts noted but don’t force NEW
+ $conflictData = (count($matches) > 1) ? ['path' => $pkgPath, 'patterns' => $matches] : null;
+
+ // No explicit target
+ if (!$hasExplicitTarget) {
+ if ($matches === []) {
+ // Truly untracked: no pattern covers it
+ return ['group' => 'new', 'data' => ['path' => $pkgPath, 'repo' => $repo, 'package' => $pkgName, 'reason' => 'no-pattern']];
+ }
+
+ // Covered by pattern → OK
+ $ok = ['path' => $pkgPath, 'repo' => $repo, 'covered_by_pattern' => true, 'package' => $pkgName];
+ if ($conflictData !== null && $conflictData !== []) {
+ $ok['conflict'] = $conflictData['patterns'];
+ }
+
+ return ['group' => 'ok', 'data' => $ok];
+ }
+
+ // Explicit target exists → check required files + drift
+ $missing = $this->requiredFiles->missing($root, $pkgPath, (array) $defaults['rules']['require_files']);
+ if ($missing !== []) {
+ return ['group' => 'issues', 'data' => ['path' => $pkgPath, 'missing' => $missing, 'package' => $pkgName]];
+ }
+
+ // Drift: if explicit `repo` template renders differently from resolved repo
+ if (array_key_exists('repo', $target)) {
+ $rendered = $this->resolver->resolve($defaults, $pattern, $target, $pkgPath, $name);
+ if ($rendered !== $repo) {
+ return ['group' => 'drift', 'data' => [
+ 'path' => $pkgPath,
+ 'package' => $pkgName,
+ 'current' => ['repo' => $rendered],
+ 'suggested' => ['repo' => $repo],
+ ]];
+ }
+ }
+
+
+ $ok = ['path' => $pkgPath, 'repo' => $repo, 'package' => $pkgName];
+ if ($conflictData !== null && $conflictData !== []) {
+ $ok['conflict'] = $conflictData['patterns'];
+ }
+
+ return ['group' => 'ok', 'data' => $ok];
+ }
+
+ /**
+ * If an explicit target refers to a path that no longer exists, try to detect the new path by identity.
+ * Returns a rename action payload or null.
+ *
+ * @param array> $patterns
+ * @param list $discovered
+ * @return array|null
+ */
+ private function findRenameTarget(string $oldPath, array $target, array $defaults, array $patterns, array $discovered): ?array
+ {
+ $oldRepo = $this->resolver->resolve($defaults, $this->firstPatternFor($patterns, $oldPath), $target, $oldPath, basename($oldPath));
+ $oldId = $this->identity->identityFor($oldPath, $oldRepo);
+
+ foreach ($discovered as $newPath) {
+ $pattern = $this->firstPatternFor($patterns, $newPath);
+ $newRepo = $this->resolver->resolve($defaults, $pattern, [], $newPath, basename($newPath));
+ if ($this->identity->identityFor($newPath, $newRepo) === $oldId) {
+ return [
+ 'from' => $oldPath,
+ 'to' => $newPath,
+ 'repo_before' => $oldRepo,
+ 'repo_after_suggested' => $newRepo,
+ ];
+ }
+ }
+
+ return null;
+ }
+
+ /** @param array> $patterns */
+ private function firstPatternFor(array $patterns, string $path): array
+ {
+ $idxs = $this->matcher->allMatches($patterns, $path);
+ return $idxs ? (array) $patterns[$idxs[0]] : [];
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Actions
+ // ─────────────────────────────────────────────────────────────────────────────
+ /**
+ * Build the set of changes to write:
+ * - add-target ONLY for true NEW (no pattern)
+ * - rename-target ONLY for explicit targets that moved
+ *
+ * @param array>> $groups
+ * @param array> $existingTargets
+ * @return array>
+ */
+ private function buildActions(array $groups, array $existingTargets): array
+ {
+ // Build a quick lookup of explicit targets
+ $byPath = [];
+ foreach ($existingTargets as $t) {
+ $byPath[(string) $t['path']] = true;
+ }
+
+ $actions = [];
+
+ foreach ($groups['new'] as $row) {
+ // New only occurs when no pattern covers it → we must add a target (or new pattern, future)
+ $actions[] = ['type' => 'add-target', 'path' => $row['path'], 'name' => basename((string) $row['path'])];
+ }
+
+ foreach ($groups['renamed'] as $row) {
+ // Only apply rename if the old path is an explicit target
+ if (!empty($byPath[(string) $row['from']])) {
+ $actions[] = ['type' => 'rename-target', 'from' => $row['from'], 'to' => $row['to']];
+ }
+ }
+
+ return $actions;
+ }
+
+ /** @param array $action */
+ private function renderAction(array $action): string
+ {
+ return match ($action['type']) {
+ 'add-target' => sprintf('Add target: %s', $action['path']),
+ 'rename-target' => sprintf('Update target path: %s → %s', $action['from'], $action['to']),
+ default => 'Unknown action',
+ };
+ }
+
+ /**
+ * Apply actions to config (pure transform).
+ * @param array $config
+ * @param array> $actions
+ * @return array
+ */
+ private function applyActions(array $config, array $actions): array
+ {
+ $targets = (array) ($config['targets'] ?? []);
+
+ foreach ($actions as $a) {
+ if ($a['type'] === 'add-target') {
+ $targets[] = [
+ 'name' => (string) $a['name'],
+ 'path' => (string) $a['path'],
+ 'include' => ['**'],
+ // no overrides; patterns handle repo computation
+ ];
+ } elseif ($a['type'] === 'rename-target') {
+ foreach ($targets as &$t) {
+ if (($t['path'] ?? '') === (string) $a['from']) {
+ $t['path'] = (string) $a['to'];
+ break;
+ }
+ }
+
+ unset($t);
+ }
+ }
+
+ if ($targets !== []) {
+ $config['targets'] = $targets;
+ } else {
+ unset($config['targets']);
+ }
+
+ return $config;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Reporting
+ // ─────────────────────────────────────────────────────────────────────────────
+ private function renderHumanReport(SymfonyStyle $io, array $groups): void
+ {
+ $io->section('Auto-discovery (src/)');
+
+ $this->printGroup($io, 'OK', $groups['ok'], function (array $r): string {
+ $suffix = empty($r['covered_by_pattern']) ? '' : ' (pattern)';
+ if (!empty($r['conflict'])) {
+ $suffix .= sprintf(' (conflict: patterns %s)', implode(',', (array) $r['conflict']));
+ }
+
+ return sprintf('%s%s', $r['package'], $suffix);
+ });
+
+ $this->printGroup($io, 'NEW', $groups['new'], fn(array $r): string => sprintf('%s → %s (no matching pattern)', $r['path'], $r['repo']));
+ $this->printGroup($io, 'RENAMED', $groups['renamed'], fn(array $r): string => sprintf('%s → %s', $r['from'], $r['to']));
+ $this->printGroup($io, 'DRIFT', $groups['drift'], fn(array $r) => $r['path']);
+ $this->printGroup($io, 'ISSUES', $groups['issues'], fn(array $r): string => sprintf('%s (missing: %s)', $r['path'], implode(', ', (array) ($r['missing'] ?? []))));
+ $this->printGroup($io, 'CONFLICTS', $groups['conflicts'], fn(array $r): string => sprintf('%s (patterns: %s)', $r['path'], implode(', ', (array) ($r['patterns'] ?? []))));
+ }
+
+ private function printGroup(SymfonyStyle $io, string $title, array $rows, callable $fmt): void
+ {
+ if ($rows === []) {
+ return;
+ }
+
+ $io->writeln(sprintf('%s', $title));
+ foreach ($rows as $r) {
+ $io->writeln(' • ' . $fmt($r));
+ }
+
+ $io->newLine();
+ }
+}
diff --git a/tools/chorale/src/Console/Style/ConsoleStyleFactory.php b/tools/chorale/src/Console/Style/ConsoleStyleFactory.php
new file mode 100644
index 00000000..387c1aff
--- /dev/null
+++ b/tools/chorale/src/Console/Style/ConsoleStyleFactory.php
@@ -0,0 +1,36 @@
+input;
+ }
+
+ public function getOutput(): OutputInterface
+ {
+ return $this->output;
+ }
+ };
+
+ return $io;
+ }
+}
diff --git a/tools/chorale/src/Diff/ConfigDiffer.php b/tools/chorale/src/Diff/ConfigDiffer.php
new file mode 100644
index 00000000..816f17b2
--- /dev/null
+++ b/tools/chorale/src/Diff/ConfigDiffer.php
@@ -0,0 +1,178 @@
+ [['path'=>'src/Acme/Foo', 'repo'=>'git@...']]]
+ */
+final readonly class ConfigDiffer implements ConfigDifferInterface
+{
+ public function __construct(
+ private ConfigDefaultsInterface $defaults,
+ private PatternMatcherInterface $matcher,
+ private RepoResolverInterface $resolver,
+ private PackageIdentityInterface $identity,
+ private RequiredFilesCheckerInterface $requiredFiles,
+ private PathUtilsInterface $paths
+ ) {}
+
+ /**
+ * @param array $config Full configuration array
+ * @param list $discovered Discovered package paths (relative)
+ * @param array $context Reserved for future extension
+ * @return array>> Grouped diff results
+ */
+ public function diff(array $config, array $discovered, array $context): array
+ {
+ $def = $this->defaults->resolve($config);
+ $patterns = (array) ($config['patterns'] ?? []);
+ $targets = (array) ($config['targets'] ?? []);
+
+ // Build quick lookups
+ $targetsByPath = [];
+ foreach ($targets as $t) {
+ $targetsByPath[(string) $t['path']] = $t;
+ }
+
+ $groups = [
+ 'new' => [],
+ 'renamed' => [],
+ 'drift' => [],
+ 'issues' => [],
+ 'conflicts' => [],
+ 'ok' => [],
+ ];
+
+ foreach ($discovered as $pkgPath) {
+ $matchIdxs = $this->matcher->allMatches($patterns, $pkgPath);
+ $pattern = $matchIdxs ? (array) $patterns[$matchIdxs[0]] : [];
+ $target = $targetsByPath[$pkgPath] ?? [];
+
+ $name = $this->paths->leaf($pkgPath);
+ $repo = $this->resolver->resolve($def, $pattern, $target, $pkgPath, $name);
+
+ // conflicts?
+ if (count($matchIdxs) > 1) {
+ $groups['conflicts'][] = [
+ 'path' => $pkgPath,
+ 'patterns' => $matchIdxs,
+ ];
+ // continue; we still classify but show conflict
+ }
+
+ $existsInConfig = isset($targetsByPath[$pkgPath]);
+ if (!$existsInConfig) {
+ // try rename detection: see if any configured target shares identity
+ $id = $this->identity->identityFor($pkgPath, $repo);
+ $renamedFrom = null;
+ foreach ($targetsByPath as $p => $t) {
+ $oldRepo = $this->resolver->resolve($def, $this->findPatternFor($patterns, $p), $t, $p, $this->paths->leaf($p));
+ if ($this->identity->identityFor($p, $oldRepo) === $id) {
+ $renamedFrom = $p;
+ break;
+ }
+ }
+
+ if ($renamedFrom !== null) {
+ $groups['renamed'][] = [
+ 'from' => $renamedFrom,
+ 'to' => $pkgPath,
+ 'repo_before' => $this->resolver->resolve($def, $this->findPatternFor($patterns, $renamedFrom), $targetsByPath[$renamedFrom], $renamedFrom, $this->paths->leaf($renamedFrom)),
+ 'repo_after_suggested' => $repo,
+ ];
+ continue;
+ }
+
+ $groups['new'][] = [
+ 'path' => $pkgPath,
+ 'repo' => $repo,
+ 'pattern' => $matchIdxs[0] ?? null,
+ ];
+ continue;
+ }
+
+ // drift: compare rendered repo vs stored overrides (if any)
+ $current = $targetsByPath[$pkgPath];
+ $curRepo = $this->resolver->resolve($def, $pattern, $current, $pkgPath, $name);
+ $driftFields = [];
+ foreach (['repo_host','repo_vendor','repo_name_template','repo'] as $k) {
+ if (array_key_exists($k, $current)) {
+ $scope = $current[$k];
+ $expected = $pattern[$k] ?? $def[$k] ?? null;
+ if ($k === 'repo' && $scope !== null) {
+ // explicit template; compare rendered values instead
+ if ($curRepo !== $repo) {
+ $driftFields['repo'] = ['from' => $curRepo, 'to' => $repo];
+ }
+
+ continue;
+ }
+
+ if ($expected !== null && (string) $scope === (string) $expected) {
+ // redundant override; suggest removing by reporting drift
+ $driftFields[$k] = ['from' => $scope, 'to' => $expected];
+ }
+ }
+ }
+
+ // issues: required files
+ $missing = $this->requiredFiles->missing(
+ '.',
+ getcwd() !== false ? getcwd() . '/' . $pkgPath : $pkgPath,
+ (array) $def['rules']['require_files']
+ );
+ if ($missing !== []) {
+ $groups['issues'][] = ['path' => $pkgPath, 'missing' => $missing];
+ }
+
+ if ($driftFields !== []) {
+ $groups['drift'][] = [
+ 'path' => $pkgPath,
+ 'current' => [
+ 'repo_host' => $current['repo_host'] ?? null,
+ 'repo_vendor' => $current['repo_vendor'] ?? null,
+ 'repo_name_template' => $current['repo_name_template'] ?? null,
+ 'repo' => $current['repo'] ?? null,
+ ],
+ 'suggested' => [
+ 'repo_host' => $pattern['repo_host'] ?? $def['repo_host'],
+ 'repo_vendor' => $pattern['repo_vendor'] ?? $def['repo_vendor'],
+ 'repo_name_template' => $pattern['repo_name_template'] ?? $def['repo_name_template'],
+ 'repo' => $pattern['repo'] ?? null,
+ ],
+ ];
+ continue;
+ }
+
+ if (count($matchIdxs) > 1) {
+ // Still OK but with conflict noted
+ $groups['conflicts'][] = ['path' => $pkgPath, 'patterns' => $matchIdxs];
+ } else {
+ $groups['ok'][] = ['path' => $pkgPath, 'repo' => $repo];
+ }
+ }
+
+ return $groups;
+ }
+
+ /** @param array> $patterns */
+ private function findPatternFor(array $patterns, string $path): array
+ {
+ $idxs = $this->matcher->allMatches($patterns, $path);
+ return $idxs ? (array) $patterns[$idxs[0]] : [];
+ }
+}
diff --git a/tools/chorale/src/Diff/ConfigDifferInterface.php b/tools/chorale/src/Diff/ConfigDifferInterface.php
new file mode 100644
index 00000000..aca65723
--- /dev/null
+++ b/tools/chorale/src/Diff/ConfigDifferInterface.php
@@ -0,0 +1,20 @@
+ $config full config array
+ * @param list $paths discovered package paths (e.g., ["src/.../Cookie"])
+ * @param array $context helpers: defaults, patternsByIndex, targetsByPath, checkers, etc.
+ *
+ * @return array>> keyed by group:
+ * - new, renamed, drift, issues, conflicts, ok
+ */
+ public function diff(array $config, array $paths, array $context): array;
+}
diff --git a/tools/chorale/src/Discovery/ComposerMetadata.php b/tools/chorale/src/Discovery/ComposerMetadata.php
new file mode 100644
index 00000000..7004376f
--- /dev/null
+++ b/tools/chorale/src/Discovery/ComposerMetadata.php
@@ -0,0 +1,26 @@
+ $name] : [];
+ }
+}
diff --git a/tools/chorale/src/Discovery/ComposerMetadataInterface.php b/tools/chorale/src/Discovery/ComposerMetadataInterface.php
new file mode 100644
index 00000000..a474b9ea
--- /dev/null
+++ b/tools/chorale/src/Discovery/ComposerMetadataInterface.php
@@ -0,0 +1,11 @@
+ $paths Optional relative paths to validate + return
+ * @return list Normalized relative package paths
+ */
+ public function scan(string $projectRoot, string $baseDir, array $paths = []): array
+ {
+ $root = rtrim($projectRoot, '/');
+ $basePath = $root . '/' . $this->paths->normalize($baseDir);
+ if (!is_dir($basePath)) {
+ return [];
+ }
+
+ if ($paths !== []) {
+ $out = [];
+ foreach ($paths as $p) {
+ $rel = ltrim($p, './');
+ // must be under baseDir
+ if (!str_starts_with($this->paths->normalize($rel), $this->paths->normalize($baseDir) . '/')
+ && $this->paths->normalize($rel) !== $this->paths->normalize($baseDir)) {
+ continue;
+ }
+
+ $full = $root . '/' . $rel;
+
+ // ignore any path that is (or is inside) vendor/
+ if ($this->isInVendor($rel)) {
+ continue;
+ }
+
+ if (is_dir($full) && is_file($full . '/composer.json')) {
+ $out[] = $this->paths->normalize($rel);
+ }
+ }
+
+ $out = array_values(array_unique($out));
+ sort($out);
+ return $out;
+ }
+
+ // Default: recursively scan $base, but never descend into any vendor/ directory
+ $dirIter = new \RecursiveDirectoryIterator($basePath, \FilesystemIterator::SKIP_DOTS);
+ $filter = new \RecursiveCallbackFilterIterator(
+ $dirIter,
+ function (\SplFileInfo $file, string $key, \RecursiveDirectoryIterator $iterator): bool {
+ if ($file->isDir()) {
+ // Do not descend into vendor directories anywhere under src/
+ return $file->getFilename() !== 'vendor';
+ }
+
+ // Files are irrelevant for traversal (we only care about dirs)
+ return false;
+ }
+ );
+ $it = new \RecursiveIteratorIterator($filter, \RecursiveIteratorIterator::SELF_FIRST);
+
+ $candidates = [];
+ foreach ($it as $dir) {
+ if (!$dir->isDir()) {
+ continue;
+ }
+
+ $path = $dir->getPathname();
+ $rel = substr((string) $path, strlen($root) + 1);
+
+ // Quick guard against vendor/ in case a user passes a weird path
+ if ($this->isInVendor($rel)) {
+ continue;
+ }
+
+ // Only treat a directory as a package if it contains composer.json
+ if (is_file($path . '/composer.json')) {
+ $candidates[] = $this->paths->normalize($rel);
+ // No need to look deeper inside this package for more composer.json files
+ // (but iterator will continue along sibling branches)
+ }
+ }
+
+ $candidates = array_values(array_unique($candidates));
+ sort($candidates);
+ return $candidates;
+ }
+
+ private function isInVendor(string $relativePath): bool
+ {
+ // Normalize separators and check any path segment equals 'vendor'
+ $p = $this->paths->normalize($relativePath);
+ $segments = explode('/', $p);
+ return in_array('vendor', $segments, true);
+ }
+}
diff --git a/tools/chorale/src/Discovery/PackageScannerInterface.php b/tools/chorale/src/Discovery/PackageScannerInterface.php
new file mode 100644
index 00000000..7238786c
--- /dev/null
+++ b/tools/chorale/src/Discovery/PackageScannerInterface.php
@@ -0,0 +1,17 @@
+ $paths relative to project root; if empty, scan "src/"
+ * @return list normalized relative paths like "src/SonsOfPHP/Cookie"
+ */
+ public function scan(string $projectRoot, string $baseDir, array $paths = []): array;
+}
diff --git a/tools/chorale/src/Discovery/PatternMatcher.php b/tools/chorale/src/Discovery/PatternMatcher.php
new file mode 100644
index 00000000..af506ddd
--- /dev/null
+++ b/tools/chorale/src/Discovery/PatternMatcher.php
@@ -0,0 +1,47 @@
+ 0
+ * - allMatches([{match:'src/* /Lib'},{match:'src/Acme/*'}], 'src/Acme/Lib') => [0,1]
+ */
+final readonly class PatternMatcher implements PatternMatcherInterface
+{
+ public function __construct(
+ private PathUtilsInterface $paths
+ ) {}
+
+ public function firstMatch(array $patterns, string $path): ?int
+ {
+ foreach ($patterns as $i => $p) {
+ $m = (string) ($p['match'] ?? '');
+ if ($m !== '' && $this->paths->match($m, $path)) {
+ return (int) $i;
+ }
+ }
+
+ return null;
+ }
+
+ public function allMatches(array $patterns, string $path): array
+ {
+ $hits = [];
+ foreach ($patterns as $i => $p) {
+ $pattern = (string) ($p['match'] ?? '');
+ if ($pattern !== '' && $this->paths->match($pattern, $path)) {
+ $hits[] = (int) $i;
+ }
+ }
+
+ return $hits;
+ }
+}
diff --git a/tools/chorale/src/Discovery/PatternMatcherInterface.php b/tools/chorale/src/Discovery/PatternMatcherInterface.php
new file mode 100644
index 00000000..1ee3c736
--- /dev/null
+++ b/tools/chorale/src/Discovery/PatternMatcherInterface.php
@@ -0,0 +1,21 @@
+> $patterns
+ */
+ public function firstMatch(array $patterns, string $path): ?int;
+
+ /**
+ * Return all matching pattern indexes (ordered).
+ * @param array> $patterns
+ * @return list
+ */
+ public function allMatches(array $patterns, string $path): array;
+}
diff --git a/tools/chorale/src/IO/BackupManager.php b/tools/chorale/src/IO/BackupManager.php
new file mode 100644
index 00000000..4afd0e3a
--- /dev/null
+++ b/tools/chorale/src/IO/BackupManager.php
@@ -0,0 +1,46 @@
+format('Ymd-His');
+ $base = basename($filePath);
+ $dest = $backupDir . '/' . $base . '.' . $ts . '.bak';
+
+ if (is_file($filePath)) {
+ if (@copy($filePath, $dest) === false) {
+ throw new \RuntimeException("Failed to create backup file: {$dest}");
+ }
+ } else {
+ // Create an empty marker so rollback tooling has a reference
+ if (@file_put_contents($dest, '') === false) {
+ throw new \RuntimeException("Failed to create backup placeholder: {$dest}");
+ }
+ }
+
+ return $dest;
+ }
+
+ public function restore(string $backupFilePath, string $targetPath): void
+ {
+ if (!is_file($backupFilePath)) {
+ throw new \RuntimeException("Backup file not found: {$backupFilePath}");
+ }
+ if (@copy($backupFilePath, $targetPath) === false) {
+ throw new \RuntimeException("Failed to restore backup to: {$targetPath}");
+ }
+ }
+}
diff --git a/tools/chorale/src/IO/BackupManagerInterface.php b/tools/chorale/src/IO/BackupManagerInterface.php
new file mode 100644
index 00000000..263d41ca
--- /dev/null
+++ b/tools/chorale/src/IO/BackupManagerInterface.php
@@ -0,0 +1,19 @@
+ $defaults,
+ 'discovery' => $discoverySets,
+ 'plan' => ['actions' => $actions],
+ ];
+
+ $json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ if ($json === false) {
+ throw new \RuntimeException('Failed to encode JSON output.');
+ }
+ return $json . PHP_EOL;
+ }
+}
diff --git a/tools/chorale/src/IO/JsonReporterInterface.php b/tools/chorale/src/IO/JsonReporterInterface.php
new file mode 100644
index 00000000..ca27394e
--- /dev/null
+++ b/tools/chorale/src/IO/JsonReporterInterface.php
@@ -0,0 +1,15 @@
+ $defaults
+ * @param array $discoverySets keyed by group: new, renamed, drift, issues, conflicts, ok
+ * @param array> $actions action preview for "Summary (to be written)"
+ */
+ public function build(array $defaults, array $discoverySets, array $actions): string;
+}
diff --git a/tools/chorale/src/Plan/ComposerRootRebuildStep.php b/tools/chorale/src/Plan/ComposerRootRebuildStep.php
new file mode 100644
index 00000000..66e33b1b
--- /dev/null
+++ b/tools/chorale/src/Plan/ComposerRootRebuildStep.php
@@ -0,0 +1,35 @@
+ $actions */
+ public function __construct(
+ private array $actions = ['validate']
+ ) {}
+
+ public function type(): string
+ {
+ return 'composer-root-rebuild';
+ }
+
+ public function id(): string
+ {
+ return 'composer-root-rebuild';
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'type' => $this->type(),
+ 'actions' => $this->actions,
+ 'status' => 'planned',
+ ];
+ }
+}
diff --git a/tools/chorale/src/Plan/ComposerRootUpdateStep.php b/tools/chorale/src/Plan/ComposerRootUpdateStep.php
new file mode 100644
index 00000000..a94fae65
--- /dev/null
+++ b/tools/chorale/src/Plan/ComposerRootUpdateStep.php
@@ -0,0 +1,47 @@
+ $require package => version
+ * @param array $replace package => version
+ * @param array $meta
+ */
+ public function __construct(
+ private string $rootPackageName,
+ private ?string $rootVersion,
+ private array $require,
+ private array $replace = [],
+ private array $meta = []
+ ) {}
+
+ public function type(): string
+ {
+ return 'composer-root-update';
+ }
+
+ public function id(): string
+ {
+ return $this->rootPackageName;
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'type' => $this->type(),
+ 'root' => $this->rootPackageName,
+ 'root_version' => $this->rootVersion,
+ 'require' => $this->require,
+ 'replace' => $this->replace,
+ 'meta' => $this->meta,
+ ];
+ }
+}
diff --git a/tools/chorale/src/Plan/PackageMetadataSyncStep.php b/tools/chorale/src/Plan/PackageMetadataSyncStep.php
new file mode 100644
index 00000000..b29f01ff
--- /dev/null
+++ b/tools/chorale/src/Plan/PackageMetadataSyncStep.php
@@ -0,0 +1,43 @@
+ $apply Changed keys only (what will be written)
+ * @param list $overridesUsed Which keys came from overrides
+ */
+ public function __construct(
+ private string $path,
+ private string $name,
+ private array $apply,
+ private array $overridesUsed = []
+ ) {}
+
+ public function type(): string
+ {
+ return 'package-metadata-sync';
+ }
+
+ public function id(): string
+ {
+ return $this->path;
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'type' => $this->type(),
+ 'path' => $this->path,
+ 'name' => $this->name,
+ 'apply' => $this->apply,
+ 'overrides_used' => $this->overridesUsed,
+ ];
+ }
+}
diff --git a/tools/chorale/src/Plan/PackageVersionUpdateStep.php b/tools/chorale/src/Plan/PackageVersionUpdateStep.php
new file mode 100644
index 00000000..716dee51
--- /dev/null
+++ b/tools/chorale/src/Plan/PackageVersionUpdateStep.php
@@ -0,0 +1,36 @@
+path;
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'type' => $this->type(),
+ 'path' => $this->path,
+ 'name' => $this->name,
+ 'version' => $this->version,
+ 'reason' => $this->reason,
+ ];
+ }
+}
diff --git a/tools/chorale/src/Plan/PlanBuilder.php b/tools/chorale/src/Plan/PlanBuilder.php
new file mode 100644
index 00000000..84333185
--- /dev/null
+++ b/tools/chorale/src/Plan/PlanBuilder.php
@@ -0,0 +1,280 @@
+ (array) ($options['paths'] ?? []),
+ 'show_all' => (bool) ($options['show_all'] ?? false),
+ 'force_split' => (bool) ($options['force_split'] ?? false),
+ 'verify_remote' => (bool) ($options['verify_remote'] ?? false),
+ 'strict' => (bool) ($options['strict'] ?? false),
+ ];
+
+ $exit = 0;
+ $def = $this->defaults->resolve($config);
+ $patterns = (array) ($config['patterns'] ?? []);
+ $targets = (array) ($config['targets'] ?? []);
+ $targetsByPath = [];
+ foreach ($targets as $t) {
+ $targetsByPath[(string) $t['path']] = $t;
+ }
+
+ // Read root composer.json (name/version and current require maps)
+ $rootComposer = $this->composerReader->read($projectRoot . '/composer.json');
+ $rootVersion = is_string($rootComposer['version'] ?? null) ? $rootComposer['version'] : null;
+ $rootName = is_string($rootComposer['name'] ?? null) ? strtolower($rootComposer['name']) : null;
+ if ($rootName === null) {
+ $rootName = strtolower($def['repo_vendor'] . '/' . $def['repo_vendor']);
+ }
+
+ // Discover packages based on patterns roots
+ $roots = $this->rootsFromPatterns($patterns) !== [] ? $this->rootsFromPatterns($patterns) : ['src'];
+ $discovered = [];
+ foreach ($roots as $r) {
+ $discovered = array_merge($discovered, $this->scanner->scan($projectRoot, $r, $opts['paths']));
+ }
+
+ $discovered = array_values(array_unique($discovered));
+ sort($discovered);
+
+ $steps = [];
+ $noop = [
+ 'version' => [],
+ 'metadata' => [],
+ 'split' => [],
+ 'root-agg' => [],
+ 'root-merge' => [],
+ ];
+
+ // Collect package names and per-package diffs
+ $packageNames = []; // path => full composer name
+ foreach ($discovered as $pkgPath) {
+ $matches = $this->matcher->allMatches($patterns, $pkgPath);
+ if ($matches === []) {
+ // not covered by any pattern → out of scope
+ continue;
+ }
+
+ $pattern = (array) $patterns[$matches[0]];
+ $nameLeaf = $this->paths->leaf($pkgPath);
+ $target = $targetsByPath[$pkgPath] ?? [];
+ $repo = $this->resolver->resolve($def, $pattern, $target, $pkgPath, $nameLeaf);
+
+ // Composer name (prefer composer.json)
+ $pcJson = $this->composerReader->read($projectRoot . '/' . $pkgPath . '/composer.json');
+ $pkgName = is_string($pcJson['name'] ?? null) ? strtolower($pcJson['name']) : strtolower($def['repo_vendor'] . '/' . $this->paths->kebab($nameLeaf));
+ $packageNames[$pkgPath] = $pkgName;
+
+ // 1) Version sync
+ if (is_string($rootVersion) && $rootVersion !== '') {
+ $current = is_string($pcJson['version'] ?? null) ? $pcJson['version'] : null;
+ if ($current !== $rootVersion) {
+ $reason = $current === null ? 'missing' : 'mismatch';
+ $steps[] = new PackageVersionUpdateStep($pkgPath, $pkgName, $rootVersion, $reason);
+ } elseif ($opts['show_all']) {
+ $noop['version'][] = $pkgName;
+ }
+ } elseif ($opts['strict']) {
+ $exit = $exit !== 0 ? $exit : 1; // missing root version in strict mode
+ }
+
+ // 2) Metadata sync (compute desired vs current using rule engine)
+ $overrides = $this->collectOverrides($pattern, $target);
+ $apply = $this->ruleEngine->computePackageEdits($pcJson, $rootComposer, $config, [
+ 'path' => $pkgPath,
+ 'name' => $pkgName,
+ 'overrides' => $overrides,
+ ]);
+ if ($apply !== []) {
+ $overKeys = $this->extractOverrideKeys($apply);
+ $apply = $this->stripInternalMarkers($apply);
+
+ $steps[] = new PackageMetadataSyncStep($pkgPath, $pkgName, $apply, $overrides);
+ } elseif ($opts['show_all']) {
+ $noop['metadata'][] = $pkgName;
+ }
+
+ // 3) Split necessity (content/remote/policy)
+ $splitReasons = $this->splitDecider->reasonsToSplit($projectRoot, $pkgPath, [
+ 'force_split' => $opts['force_split'],
+ 'verify_remote' => $opts['verify_remote'],
+ 'repo' => $repo,
+ 'branch' => (string) $def['default_branch'],
+ 'tag_strategy' => (string) $def['tag_strategy'],
+ 'ignore' => (array) ($config['split']['ignore'] ?? ['vendor/**','**/composer.lock','**/.DS_Store']),
+ ]);
+ if ($splitReasons !== []) {
+ $steps[] = new SplitStep(
+ path: $pkgPath,
+ name: $nameLeaf,
+ repo: $repo,
+ branch: (string) $def['default_branch'],
+ splitter: (string) $def['splitter'],
+ tagStrategy: (string) $def['tag_strategy'],
+ keepHistory: (bool) ($def['rules']['keep_history'] ?? true),
+ skipIfUnchanged: (bool) ($def['rules']['skip_if_unchanged'] ?? true),
+ reasons: $splitReasons
+ );
+ } elseif ($opts['show_all']) {
+ $noop['split'][] = $pkgName;
+ }
+ }
+
+ // 4) Root aggregator (require/replace all packages at rootVersion)
+ $aggStep = null;
+ if ($packageNames !== []) {
+ $require = [];
+ $replace = [];
+ foreach ($packageNames as $pkgFull) {
+ if ($pkgFull === $rootName) {
+ continue;
+ }
+
+ $ver = $rootVersion ?? '*';
+ $require[$pkgFull] = $ver;
+ $replace[$pkgFull] = $ver;
+ }
+
+ // Compare with current root (only add if it would change)
+ $desired = ['require' => $require, 'replace' => $replace, 'root' => $rootName, 'root_version' => $rootVersion];
+ $current = [
+ 'require' => (array) ($rootComposer['require'] ?? []),
+ 'replace' => (array) ($rootComposer['replace'] ?? []),
+ 'root' => (string) ($rootComposer['name'] ?? $rootName),
+ 'root_version' => (string) ($rootComposer['version'] ?? ($rootVersion ?? '')),
+ ];
+ if ($this->diffs->changed($current, $desired, ['require','replace','root','root_version'])) {
+ $aggStep = new ComposerRootUpdateStep($rootName, $rootVersion, $require, $replace, ['version_strategy' => 'lockstep-root']);
+ $steps[] = $aggStep;
+ } elseif ($opts['show_all']) {
+ $noop['root-agg'][] = 'composer.json';
+ }
+ }
+
+ // 5) Root dependency merge (strategy engine)
+ $merge = $this->depMerger->computeRootMerge($projectRoot, array_keys($packageNames), [
+ 'strategy_require' => (string) ($config['composer_sync']['merge_strategy']['require'] ?? 'union-caret'),
+ 'strategy_require_dev' => (string) ($config['composer_sync']['merge_strategy']['require-dev'] ?? 'union-caret'),
+ 'exclude_monorepo_packages' => true,
+ 'monorepo_names' => array_values($packageNames),
+ ]);
+ if (!empty($merge['require']) || !empty($merge['require-dev']) || !empty($merge['conflicts'])) {
+ // Compare with current
+ $current = [
+ 'require' => (array) ($rootComposer['require'] ?? []),
+ 'require-dev' => (array) ($rootComposer['require-dev'] ?? []),
+ ];
+ $desired = [
+ 'require' => (array) ($merge['require'] ?? []),
+ 'require-dev' => (array) ($merge['require-dev'] ?? []),
+ ];
+ if ($this->diffs->changed($current, $desired, ['require','require-dev'])) {
+ $steps[] = new RootDependencyMergeStep(
+ (array) $merge['require'],
+ (array) $merge['require-dev'],
+ (array) ($merge['conflicts'] ?? [])
+ );
+ if (!empty($merge['conflicts']) && $opts['strict']) {
+ $exit = $exit !== 0 ? $exit : 1;
+ }
+ } elseif ($opts['show_all']) {
+ $noop['root-merge'][] = 'composer.json';
+ }
+ }
+
+ // 6) Root rebuild (only if prior steps exist)
+ if ($steps !== []) {
+ $steps[] = new ComposerRootRebuildStep(['validate']); // add 'normalize' later if desired
+ }
+
+ return [
+ 'steps' => $steps,
+ 'noop' => $noop,
+ 'exit_code' => $exit,
+ ];
+ }
+
+ /** @param array> $patterns @return list */
+ private function rootsFromPatterns(array $patterns): array
+ {
+ $roots = [];
+ foreach ($patterns as $p) {
+ $m = (string) ($p['match'] ?? '');
+ if ($m === '') {
+ continue;
+ }
+
+ $seg = explode('/', ltrim($m, '/'), 2)[0] ?? '';
+ if ($seg !== '' && !in_array($seg, $roots, true)) {
+ $roots[] = $seg;
+ }
+ }
+
+ return $roots;
+ }
+
+ /** @return array{values:array,rules:array} */
+ private function collectOverrides(array $pattern, array $target): array
+ {
+ $p = (array) ($pattern['composer_overrides'] ?? []);
+ $t = (array) ($target['composer_overrides'] ?? []);
+ $values = array_merge((array) ($p['values'] ?? []), (array) ($t['values'] ?? []));
+ $rules = array_merge((array) ($p['rules'] ?? []), (array) ($t['rules'] ?? []));
+ return ['values' => $values, 'rules' => $rules];
+ }
+
+ /** @param array $apply @return list */
+ private function extractOverrideKeys(array $apply): array
+ {
+ $keys = [];
+ foreach ($apply as $k => $v) {
+ if (is_array($v) && array_key_exists('__override', $v) && $v['__override'] === true) {
+ $keys[] = (string) $k;
+ }
+ }
+
+ return $keys;
+ }
+
+ /** @param array $apply @return array */
+ private function stripInternalMarkers(array $apply): array
+ {
+ foreach ($apply as $k => $v) {
+ if (is_array($v) && array_key_exists('__override', $v)) {
+ unset($apply[$k]['__override']);
+ }
+ }
+
+ return $apply;
+ }
+}
diff --git a/tools/chorale/src/Plan/PlanBuilderInterface.php b/tools/chorale/src/Plan/PlanBuilderInterface.php
new file mode 100644
index 00000000..f5ea5d68
--- /dev/null
+++ b/tools/chorale/src/Plan/PlanBuilderInterface.php
@@ -0,0 +1,19 @@
+ $config Parsed chorale.yaml
+ * @param array $options keys: paths (list), show_all (bool), force_split (bool), verify_remote (bool), strict (bool)
+ *
+ * @return array{steps:list, noop:array>, exit_code:int}
+ */
+ public function build(string $projectRoot, array $config, array $options = []): array;
+}
diff --git a/tools/chorale/src/Plan/PlanStepInterface.php b/tools/chorale/src/Plan/PlanStepInterface.php
new file mode 100644
index 00000000..51412fc7
--- /dev/null
+++ b/tools/chorale/src/Plan/PlanStepInterface.php
@@ -0,0 +1,20 @@
+ */
+ public function toArray(): array;
+}
diff --git a/tools/chorale/src/Plan/RootDependencyMergeStep.php b/tools/chorale/src/Plan/RootDependencyMergeStep.php
new file mode 100644
index 00000000..120d7167
--- /dev/null
+++ b/tools/chorale/src/Plan/RootDependencyMergeStep.php
@@ -0,0 +1,42 @@
+ $require
+ * @param array $requireDev
+ * @param list> $conflicts
+ */
+ public function __construct(
+ private array $require,
+ private array $requireDev,
+ private array $conflicts = []
+ ) {}
+
+ public function type(): string
+ {
+ return 'composer-root-merge';
+ }
+
+ public function id(): string
+ {
+ return 'composer-root-merge';
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'type' => $this->type(),
+ 'require' => $this->require,
+ 'require-dev' => $this->requireDev,
+ 'conflicts' => $this->conflicts,
+ ];
+ }
+}
diff --git a/tools/chorale/src/Plan/SplitStep.php b/tools/chorale/src/Plan/SplitStep.php
new file mode 100644
index 00000000..5b16a0e3
--- /dev/null
+++ b/tools/chorale/src/Plan/SplitStep.php
@@ -0,0 +1,47 @@
+ $reasons */
+ public function __construct(
+ private string $path,
+ private string $name,
+ private string $repo,
+ private string $branch,
+ private string $splitter,
+ private string $tagStrategy,
+ private bool $keepHistory,
+ private bool $skipIfUnchanged,
+ private array $reasons = []
+ ) {}
+
+ public function type(): string
+ {
+ return 'split';
+ }
+
+ public function id(): string
+ {
+ return $this->path;
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'type' => $this->type(),
+ 'path' => $this->path,
+ 'name' => $this->name,
+ 'repo' => $this->repo,
+ 'branch' => $this->branch,
+ 'splitter' => $this->splitter,
+ 'tag_strategy' => $this->tagStrategy,
+ 'keep_history' => $this->keepHistory,
+ 'skip_if_unchanged' => $this->skipIfUnchanged,
+ 'reasons' => $this->reasons,
+ ];
+ }
+}
diff --git a/tools/chorale/src/Repo/RepoResolver.php b/tools/chorale/src/Repo/RepoResolver.php
new file mode 100644
index 00000000..771dd7c1
--- /dev/null
+++ b/tools/chorale/src/Repo/RepoResolver.php
@@ -0,0 +1,37 @@
+ pattern > defaults
+ $vars = [
+ 'repo_host' => $target['repo_host'] ?? $pattern['repo_host'] ?? $defaults['repo_host'],
+ 'repo_vendor' => $target['repo_vendor'] ?? $pattern['repo_vendor'] ?? $defaults['repo_vendor'],
+ 'repo_name_template' => $target['repo_name_template'] ?? $pattern['repo_name_template'] ?? $defaults['repo_name_template'],
+ 'default_repo_template' => $defaults['default_repo_template'],
+ 'name' => $name ?? $this->paths->leaf($path),
+ 'path' => $path,
+ 'tag' => '', // filled by plan/apply, not needed for setup
+ ];
+
+ // choose template: explicit repo wins, then pattern.repo, else default_repo_template
+ $tpl = $target['repo']
+ ?? $pattern['repo']
+ ?? (string) $defaults['default_repo_template'];
+
+ // validate template separately is done by TemplateRenderer; here we render confidently
+ return $this->renderer->render($tpl, $vars);
+ }
+}
diff --git a/tools/chorale/src/Repo/RepoResolverInterface.php b/tools/chorale/src/Repo/RepoResolverInterface.php
new file mode 100644
index 00000000..1d4953cb
--- /dev/null
+++ b/tools/chorale/src/Repo/RepoResolverInterface.php
@@ -0,0 +1,20 @@
+ pattern.repo > default_repo_template (+ per-scope overrides).
+ *
+ * @param array $defaults resolved via ConfigDefaultsInterface
+ * @param array $pattern pattern entry (if any)
+ * @param array $target target entry (if any)
+ * @param string $path package path, e.g. "src/SonsOfPHP/Cookie"
+ * @param string|null $name derived name (leaf) or null to compute from $path
+ */
+ public function resolve(array $defaults, array $pattern, array $target, string $path, ?string $name = null): string;
+}
diff --git a/tools/chorale/src/Repo/TemplateRenderer.php b/tools/chorale/src/Repo/TemplateRenderer.php
new file mode 100644
index 00000000..44f304c9
--- /dev/null
+++ b/tools/chorale/src/Repo/TemplateRenderer.php
@@ -0,0 +1,183 @@
+'git@github.com','repo_vendor'=>'Acme','name'=>'My Lib'])
+ * => 'git@github.com:Acme/my-lib.git'
+ */
+final class TemplateRenderer implements TemplateRendererInterface
+{
+ /** @var array */
+ private array $allowedVars = [
+ 'repo_host' => true,
+ 'repo_vendor' => true,
+ 'repo_name_template' => true,
+ 'default_repo_template' => true,
+ 'name' => true,
+ 'path' => true,
+ 'tag' => true,
+ ];
+
+ /** @var array */
+ private array $filters;
+
+ public function __construct()
+ {
+ $this->filters = [
+ // @todo is "raw" needed?
+ 'raw' => static fn(string $s): string => $s,
+ 'lower' => static fn(string $s): string => mb_strtolower($s),
+ 'upper' => static fn(string $s): string => mb_strtoupper($s),
+ 'kebab' => static fn(string $s): string => self::toKebab($s),
+ 'snake' => static fn(string $s): string => self::toSnake($s),
+ 'camel' => static fn(string $s): string => self::toCamel($s),
+ 'pascal' => static fn(string $s): string => self::toPascal($s),
+ 'dot' => static fn(string $s): string => str_replace(['_', ' ', '-'], '.', self::basicWords($s)),
+ ];
+ }
+
+ public function render(string $template, array $vars): string
+ {
+ // Support nested placeholders by iteratively expanding until stable.
+ // e.g. "{repo_host}:{repo_vendor}/{repo_name_template}"
+ // where repo_name_template = "{name:kebab}.git"
+ // will fully resolve to ".../cookie.git".
+ $out = $template;
+ $maxPasses = 5; // avoid infinite loops
+ for ($i = 0; $i < $maxPasses; $i++) {
+ $issues = $this->validate($out);
+ if ($issues !== []) {
+ throw new \InvalidArgumentException('Invalid template: ' . implode('; ', $issues));
+ }
+
+ $next = preg_replace_callback(
+ '/\{([a-zA-Z_]\w*)(?::([a-zA-Z:]+))?\}/',
+ function (array $m) use ($vars): string {
+ $var = $m[1];
+ $filters = isset($m[2]) ? explode(':', (string) $m[2]) : [];
+ $value = (string) ($vars[$var] ?? '');
+ foreach ($filters as $f) {
+ if ($f === '') {
+ continue;
+ }
+
+ /** @var callable(string):string $fn */
+ $fn = $this->filters[$f] ?? null;
+ if ($fn === null) {
+ // validate() would have caught this; keep defensive anyway
+ throw new \InvalidArgumentException(sprintf("Unknown filter '%s'", $f));
+ }
+
+ $value = $fn($value);
+ }
+
+ return $value;
+ },
+ $out
+ );
+ if ($next === null) {
+ // regex error; fall back to current output
+ break;
+ }
+
+ if ($next === $out || in_array(preg_match('/\{[a-zA-Z_]\w*(?::[a-zA-Z:]+)?\}/', $next), [0, false], true)) {
+ $out = $next;
+ break; // stabilized or no more placeholders
+ }
+
+ $out = $next;
+ }
+
+ return $out;
+ }
+
+ public function validate(string $template): array
+ {
+ $issues = [];
+ if ($template === '') {
+ return $issues;
+ }
+
+ if (in_array(preg_match_all('/\{([a-zA-Z_]\w*)(?::([a-zA-Z:]+))?\}/', $template, $matches, \PREG_SET_ORDER), [0, false], true)) {
+ return $issues;
+ }
+
+ foreach ($matches as $match) {
+ $var = $match[1];
+ $filterStr = $match[2] ?? '';
+
+ if (!isset($this->allowedVars[$var])) {
+ $issues[] = sprintf("Unknown placeholder '%s'", $var);
+ }
+
+ if ($filterStr !== '') {
+ foreach (explode(':', $filterStr) as $f) {
+ if ($f === '') {
+ continue;
+ }
+
+ if (!isset($this->filters[$f])) {
+ $issues[] = sprintf("Unknown filter '%s' for '%s'", $f, $var);
+ }
+ }
+ }
+ }
+
+ return $issues;
+ }
+
+ private static function toKebab(string $s): string
+ {
+ return str_replace('_', '-', self::toWordsLower($s));
+ }
+
+ private static function toSnake(string $s): string
+ {
+ return str_replace('-', '_', self::toWordsLower($s, '_'));
+ }
+
+ private static function toCamel(string $s): string
+ {
+ $words = self::basicWords($s);
+ $words = preg_split('/[ \-_\.]+/u', $words) ?: [];
+
+ $out = '';
+ foreach ($words as $i => $w) {
+ $w = mb_strtolower($w);
+ $out .= $i === 0 ? $w : mb_strtoupper(mb_substr($w, 0, 1)) . mb_substr($w, 1);
+ }
+
+ return $out;
+ }
+
+ private static function toPascal(string $s): string
+ {
+ $camel = self::toCamel($s);
+ return mb_strtoupper(mb_substr($camel, 0, 1)) . mb_substr($camel, 1);
+ }
+
+ /** Normalize to word separators as spaces for filtering. */
+ private static function basicWords(string $s): string
+ {
+ // Split camelCase/PascalCase
+ $s = preg_replace('/(? mb_strtolower($x), $w);
+ return implode($glue, array_filter($w, static fn(string $x): bool => $x !== ''));
+ }
+}
diff --git a/tools/chorale/src/Repo/TemplateRendererInterface.php b/tools/chorale/src/Repo/TemplateRendererInterface.php
new file mode 100644
index 00000000..f362070e
--- /dev/null
+++ b/tools/chorale/src/Repo/TemplateRendererInterface.php
@@ -0,0 +1,28 @@
+ $vars e.g. ['repo_host'=>'git@github.com','name'=>'Cookie']
+ *
+ * @throws \InvalidArgumentException on unknown placeholder or filter
+ */
+ public function render(string $template, array $vars): string;
+
+ /**
+ * Validate that all placeholders and filters in the template are known.
+ * Returns a list of problems; empty array means valid.
+ *
+ * @return list list of validation messages
+ */
+ public function validate(string $template): array;
+}
diff --git a/tools/chorale/src/Rules/ConflictDetector.php b/tools/chorale/src/Rules/ConflictDetector.php
new file mode 100644
index 00000000..fd6bb0d9
--- /dev/null
+++ b/tools/chorale/src/Rules/ConflictDetector.php
@@ -0,0 +1,20 @@
+matcher->allMatches($patterns, $path);
+ return ['conflict' => count($matches) > 1, 'matches' => $matches];
+ }
+}
diff --git a/tools/chorale/src/Rules/ConflictDetectorInterface.php b/tools/chorale/src/Rules/ConflictDetectorInterface.php
new file mode 100644
index 00000000..ec4da828
--- /dev/null
+++ b/tools/chorale/src/Rules/ConflictDetectorInterface.php
@@ -0,0 +1,15 @@
+> $patterns
+ * @return array{conflict:bool, matches:list}
+ */
+ public function detect(array $patterns, string $path): array;
+}
diff --git a/tools/chorale/src/Rules/RequiredFilesChecker.php b/tools/chorale/src/Rules/RequiredFilesChecker.php
new file mode 100644
index 00000000..390a734c
--- /dev/null
+++ b/tools/chorale/src/Rules/RequiredFilesChecker.php
@@ -0,0 +1,23 @@
+ $required relative file names like ["composer.json","LICENSE"]
+ * @return list missing file names
+ */
+ public function missing(string $projectRoot, string $packagePath, array $required): array;
+}
diff --git a/tools/chorale/src/Run/ComposerRootUpdateExecutor.php b/tools/chorale/src/Run/ComposerRootUpdateExecutor.php
new file mode 100644
index 00000000..d9a71537
--- /dev/null
+++ b/tools/chorale/src/Run/ComposerRootUpdateExecutor.php
@@ -0,0 +1,54 @@
+ $step */
+ public function supports(array $step): bool
+ {
+ return ($step['type'] ?? '') === 'composer-root-update';
+ }
+
+ /** @param array $step */
+ public function execute(string $projectRoot, array $step): void
+ {
+ $composerPath = rtrim($projectRoot, '/') . '/composer.json';
+ $data = is_file($composerPath) ? json_decode((string) file_get_contents($composerPath), true) : [];
+ if (!is_array($data)) {
+ throw new RuntimeException('Invalid root composer.json');
+ }
+
+ $rootName = (string) ($step['root'] ?? $data['name'] ?? '');
+ if ($rootName === '') {
+ throw new RuntimeException('Root package name missing.');
+ }
+ $data['name'] = $rootName;
+
+ $rootVersion = $step['root_version'] ?? null;
+ if (is_string($rootVersion) && $rootVersion !== '') {
+ $data['version'] = $rootVersion;
+ }
+
+ $data['require'] = (array) ($step['require'] ?? []);
+ $data['replace'] = (array) ($step['replace'] ?? []);
+
+ if (!empty($step['meta'])) {
+ $data['extra']['chorale']['root-meta'] = $step['meta'];
+ }
+
+ $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ if ($encoded === false) {
+ throw new RuntimeException('Failed to encode root composer.json');
+ }
+
+ file_put_contents($composerPath, $encoded . "\n");
+ }
+}
diff --git a/tools/chorale/src/Run/PackageVersionUpdateExecutor.php b/tools/chorale/src/Run/PackageVersionUpdateExecutor.php
new file mode 100644
index 00000000..8db1b6c6
--- /dev/null
+++ b/tools/chorale/src/Run/PackageVersionUpdateExecutor.php
@@ -0,0 +1,45 @@
+ $step */
+ public function supports(array $step): bool
+ {
+ return ($step['type'] ?? '') === 'composer-root-merge';
+ }
+
+ /** @param array $step */
+ public function execute(string $projectRoot, array $step): void
+ {
+ $composerPath = rtrim($projectRoot, '/') . '/composer.json';
+ $data = is_file($composerPath) ? json_decode((string) file_get_contents($composerPath), true) : [];
+ if (!is_array($data)) {
+ throw new RuntimeException('Invalid root composer.json');
+ }
+
+ $require = (array) ($step['require'] ?? []);
+ $requireDev = (array) ($step['require-dev'] ?? []);
+ ksort($require);
+ ksort($requireDev);
+ $data['require'] = $require;
+ $data['require-dev'] = $requireDev;
+
+ if (!empty($step['conflicts'])) {
+ $data['extra']['chorale']['dependency-conflicts'] = $step['conflicts'];
+ } else {
+ unset($data['extra']['chorale']['dependency-conflicts']);
+ }
+
+ $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ if ($encoded === false) {
+ throw new RuntimeException('Failed to encode root composer.json');
+ }
+
+ file_put_contents($composerPath, $encoded . "\n");
+ }
+}
diff --git a/tools/chorale/src/Run/Runner.php b/tools/chorale/src/Run/Runner.php
new file mode 100644
index 00000000..24db321c
--- /dev/null
+++ b/tools/chorale/src/Run/Runner.php
@@ -0,0 +1,44 @@
+configLoader->load($projectRoot);
+ if ($config === []) {
+ throw new RuntimeException('No chorale.yaml found.');
+ }
+
+ return $this->planner->build($projectRoot, $config, $options);
+ }
+
+ public function apply(string $projectRoot, array $steps): void
+ {
+ foreach ($steps as $step) {
+ $this->executors->execute($projectRoot, $step);
+ }
+ }
+
+ public function run(string $projectRoot, array $options = []): array
+ {
+ $result = $this->plan($projectRoot, $options);
+ $arrays = array_map(static fn(PlanStepInterface $s): array => $s->toArray(), $result['steps'] ?? []);
+ $this->apply($projectRoot, $arrays);
+ return $result;
+ }
+}
diff --git a/tools/chorale/src/Run/RunnerInterface.php b/tools/chorale/src/Run/RunnerInterface.php
new file mode 100644
index 00000000..d8ec3a38
--- /dev/null
+++ b/tools/chorale/src/Run/RunnerInterface.php
@@ -0,0 +1,19 @@
+, noop?:array, exit_code?:int} */
+ public function plan(string $projectRoot, array $options = []): array;
+
+ /** @param list> $steps */
+ public function apply(string $projectRoot, array $steps): void;
+
+ /** @return array{steps:list, noop?:array, exit_code?:int} */
+ public function run(string $projectRoot, array $options = []): array;
+}
diff --git a/tools/chorale/src/Run/StepExecutorInterface.php b/tools/chorale/src/Run/StepExecutorInterface.php
new file mode 100644
index 00000000..76b6dc7d
--- /dev/null
+++ b/tools/chorale/src/Run/StepExecutorInterface.php
@@ -0,0 +1,17 @@
+ $step */
+ public function supports(array $step): bool;
+
+ /** @param array $step */
+ public function execute(string $projectRoot, array $step): void;
+}
diff --git a/tools/chorale/src/Run/StepExecutorRegistry.php b/tools/chorale/src/Run/StepExecutorRegistry.php
new file mode 100644
index 00000000..a711a1ce
--- /dev/null
+++ b/tools/chorale/src/Run/StepExecutorRegistry.php
@@ -0,0 +1,42 @@
+ */
+ private array $executors = [];
+
+ /** @param iterable $executors */
+ public function __construct(iterable $executors = [])
+ {
+ foreach ($executors as $executor) {
+ $this->executors[] = $executor;
+ }
+ }
+
+ public function add(StepExecutorInterface $executor): void
+ {
+ $this->executors[] = $executor;
+ }
+
+ /** @param array $step */
+ public function execute(string $projectRoot, array $step): void
+ {
+ foreach ($this->executors as $executor) {
+ if ($executor->supports($step)) {
+ $executor->execute($projectRoot, $step);
+ return;
+ }
+ }
+
+ throw new RuntimeException('No executor registered for step type: ' . ($step['type'] ?? 'unknown'));
+ }
+}
diff --git a/tools/chorale/src/Split/ContentHasher.php b/tools/chorale/src/Split/ContentHasher.php
new file mode 100644
index 00000000..4350f1d3
--- /dev/null
+++ b/tools/chorale/src/Split/ContentHasher.php
@@ -0,0 +1,86 @@
+collectFiles($abs, $ignoreGlobs);
+ sort($files);
+ $h = hash_init('sha256');
+ foreach ($files as $rel) {
+ $full = $abs . '/' . $rel;
+ hash_update($h, $rel . '|' . filesize($full) . '|');
+ $data = @file_get_contents($full);
+ if ($data !== false) {
+ hash_update($h, $data);
+ }
+ }
+
+ return hash_final($h);
+ }
+
+ /** @return list relative file paths */
+ private function collectFiles(string $absPackageDir, array $ignoreGlobs): array
+ {
+ $list = [];
+ $iter = new \RecursiveIteratorIterator(
+ new \RecursiveCallbackFilterIterator(
+ new \RecursiveDirectoryIterator($absPackageDir, \FilesystemIterator::SKIP_DOTS),
+ fn(\SplFileInfo $f, string $key, \RecursiveDirectoryIterator $it): bool => !($f->isDir() && $f->getFilename() === 'vendor')
+ ),
+ \RecursiveIteratorIterator::LEAVES_ONLY
+ );
+ foreach ($iter as $file) {
+ if (!$file->isFile()) {
+ continue;
+ }
+
+ $rel = ltrim(substr((string) $file->getPathname(), strlen($absPackageDir)), '/');
+ if ($this->isIgnored($rel, $ignoreGlobs)) {
+ continue;
+ }
+
+ $list[] = $rel;
+ }
+
+ return $list;
+ }
+
+ private function isIgnored(string $relPath, array $globs): bool
+ {
+ foreach ($globs as $g) {
+ if ($this->globMatch($g, $relPath)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function globMatch(string $glob, string $path): bool
+ {
+ // Treat ** as .* and * as [^/]* for path components
+ $re = $this->globToRegex($glob);
+ return (bool) preg_match($re, $path);
+ }
+
+ private function globToRegex(string $glob): string
+ {
+ $g = str_replace('\\', '/', $glob);
+ $g = ltrim($g, '/');
+ $g = preg_quote($g, '#');
+ // Undo quotes for wildcards and translate
+ $g = str_replace(['\*\*', '\*', '\?'], ['\000DBLSTAR\000', '[^/]*', '[^/]'], $g);
+ $g = str_replace('\000DBLSTAR\000', '.*', $g);
+ return '#^' . $g . '$#u';
+ }
+}
diff --git a/tools/chorale/src/Split/ContentHasherInterface.php b/tools/chorale/src/Split/ContentHasherInterface.php
new file mode 100644
index 00000000..6b165408
--- /dev/null
+++ b/tools/chorale/src/Split/ContentHasherInterface.php
@@ -0,0 +1,18 @@
+ $ignoreGlobs glob patterns to ignore (relative to package root)
+ */
+ public function hash(string $projectRoot, string $packagePath, array $ignoreGlobs = []): string;
+}
diff --git a/tools/chorale/src/Split/SplitDecider.php b/tools/chorale/src/Split/SplitDecider.php
new file mode 100644
index 00000000..91e60ddc
--- /dev/null
+++ b/tools/chorale/src/Split/SplitDecider.php
@@ -0,0 +1,78 @@
+state->read($projectRoot);
+ $ignore = (array) ($options['ignore'] ?? []);
+ $finger = $this->hasher->hash($projectRoot, $packagePath, $ignore);
+
+ $pkgState = (array) ($state['packages'][$packagePath] ?? []);
+ $lastHash = (string) ($pkgState['fingerprint'] ?? '');
+ if ($lastHash === '' || $lastHash !== $finger) {
+ $reasons[] = 'content-changed';
+ }
+
+ if (!empty($options['verify_remote'])) {
+ $repo = (string) ($options['repo'] ?? '');
+ $branch = (string) ($options['branch'] ?? 'main');
+ $probe = $this->probeRemote($repo, $branch);
+ foreach ($probe as $r) {
+ if (!in_array($r, $reasons, true)) {
+ $reasons[] = $r;
+ }
+ }
+ }
+
+ return [];
+ }
+
+ /** @return list */
+ private function probeRemote(string $repo, string $branch): array
+ {
+ if ($repo === '') {
+ return [];
+ }
+
+ $refs = $this->lsRemote($repo, 'refs/heads/' . $branch);
+ if ($refs === null) {
+ return ['repo-unreachable'];
+ }
+
+ if ($refs === '') {
+ return ['branch-missing'];
+ }
+
+ return [];
+ }
+
+ private function lsRemote(string $repo, string $ref): ?string
+ {
+ $cmd = sprintf('git ls-remote %s %s 2>&1', escapeshellarg($repo), escapeshellarg($ref));
+ $out = [];
+ $code = 0;
+ @exec($cmd, $out, $code);
+ if ($code !== 0) {
+ return null;
+ }
+
+ return implode("\n", $out);
+ }
+}
diff --git a/tools/chorale/src/Split/SplitDeciderInterface.php b/tools/chorale/src/Split/SplitDeciderInterface.php
new file mode 100644
index 00000000..0adbb5eb
--- /dev/null
+++ b/tools/chorale/src/Split/SplitDeciderInterface.php
@@ -0,0 +1,16 @@
+ $options keys: force_split(bool), verify_remote(bool), repo(string), branch(string), tag_strategy(string)
+ * @return list reasons e.g. ["content-changed","repo-empty","missing-tag","forced"]
+ */
+ public function reasonsToSplit(string $projectRoot, string $packagePath, array $options = []): array;
+}
diff --git a/tools/chorale/src/State/FilesystemStateStore.php b/tools/chorale/src/State/FilesystemStateStore.php
new file mode 100644
index 00000000..a474d300
--- /dev/null
+++ b/tools/chorale/src/State/FilesystemStateStore.php
@@ -0,0 +1,40 @@
+ state payload (e.g., per-package fingerprints) */
+ public function read(string $projectRoot): array;
+
+ /** @param array $state */
+ public function write(string $projectRoot, array $state): void;
+}
diff --git a/tools/chorale/src/Telemetry/RunSummary.php b/tools/chorale/src/Telemetry/RunSummary.php
new file mode 100644
index 00000000..1d210f55
--- /dev/null
+++ b/tools/chorale/src/Telemetry/RunSummary.php
@@ -0,0 +1,25 @@
+ */
+ private array $buckets = [];
+
+ public function inc(string $bucket): void
+ {
+ if ($bucket === '') {
+ return;
+ }
+ $this->buckets[$bucket] = ($this->buckets[$bucket] ?? 0) + 1;
+ }
+
+ public function all(): array
+ {
+ ksort($this->buckets);
+ return $this->buckets;
+ }
+}
diff --git a/tools/chorale/src/Telemetry/RunSummaryInterface.php b/tools/chorale/src/Telemetry/RunSummaryInterface.php
new file mode 100644
index 00000000..e915b2fa
--- /dev/null
+++ b/tools/chorale/src/Telemetry/RunSummaryInterface.php
@@ -0,0 +1,13 @@
+ */
+ public function all(): array;
+}
diff --git a/tools/chorale/src/Tests/Composer/DependencyMergerTest.php b/tools/chorale/src/Tests/Composer/DependencyMergerTest.php
new file mode 100644
index 00000000..bbfdf3e2
--- /dev/null
+++ b/tools/chorale/src/Tests/Composer/DependencyMergerTest.php
@@ -0,0 +1,70 @@
+ 'pkg1', 'require' => ['foo/bar' => '^1.0']];
+ }
+
+ if (str_contains($absolutePath, 'pkg2')) {
+ return ['name' => 'pkg2', 'require' => ['foo/bar' => '^1.2']];
+ }
+
+ return [];
+ }
+ };
+
+ $merger = new DependencyMerger($reader);
+ $result = $merger->computeRootMerge('/root', ['pkg1', 'pkg2']);
+
+ $this->assertSame(['foo/bar' => '^1.2'], $result['require']);
+ $this->assertSame([], $result['conflicts']);
+ }
+
+ #[Test]
+ public function testComputeRootMergeRecordsConflictWhenMixedConstraintTypes(): void
+ {
+ $reader = new class implements ComposerJsonReaderInterface {
+ public function read(string $absolutePath): array
+ {
+ if (str_contains($absolutePath, 'pkg1')) {
+ return ['name' => 'pkg1', 'require' => ['foo/bar' => '^1.0']];
+ }
+
+ if (str_contains($absolutePath, 'pkg2')) {
+ return ['name' => 'pkg2', 'require' => ['foo/bar' => '1.3.0']];
+ }
+
+ return [];
+ }
+ };
+
+ $merger = new DependencyMerger($reader);
+ $result = $merger->computeRootMerge('/root', ['pkg1', 'pkg2']);
+
+ $this->assertSame(['foo/bar' => '^1.0'], $result['require']);
+ $this->assertSame('non-caret-mixed', $result['conflicts'][0]['reason']);
+ }
+}
+
diff --git a/tools/chorale/src/Tests/Config/ConfigDefaultsTest.php b/tools/chorale/src/Tests/Config/ConfigDefaultsTest.php
new file mode 100644
index 00000000..d64e18af
--- /dev/null
+++ b/tools/chorale/src/Tests/Config/ConfigDefaultsTest.php
@@ -0,0 +1,57 @@
+resolve([]);
+ $this->assertSame('git@github.com', $out['repo_host']);
+ }
+
+ #[Test]
+ public function testResolveMergesRules(): void
+ {
+ $d = new ConfigDefaults();
+ $out = $d->resolve(['rules' => ['keep_history' => false]]);
+ $this->assertFalse($out['rules']['keep_history']);
+ }
+
+ #[Test]
+ public function testResolveComputesDefaultRepoTemplateWhenNotProvided(): void
+ {
+ $d = new ConfigDefaults();
+ $out = $d->resolve(['repo_vendor' => 'Acme']);
+ $this->assertSame('git@github.com:Acme/{name:kebab}.git', $out['default_repo_template']);
+ }
+
+ #[Test]
+ public function testResolveKeepsExplicitDefaultRepoTemplate(): void
+ {
+ $d = new ConfigDefaults();
+ $out = $d->resolve(['default_repo_template' => 'x:{y}/{z}']);
+ $this->assertSame('x:{y}/{z}', $out['default_repo_template']);
+ }
+
+ #[Test]
+ public function testResolveOverridesRequireFilesList(): void
+ {
+ $d = new ConfigDefaults();
+ $out = $d->resolve(['rules' => ['require_files' => ['README.md']]]);
+ $this->assertSame(['README.md'], $out['rules']['require_files']);
+ }
+}
diff --git a/tools/chorale/src/Tests/Config/ConfigLoaderTest.php b/tools/chorale/src/Tests/Config/ConfigLoaderTest.php
new file mode 100644
index 00000000..01409d00
--- /dev/null
+++ b/tools/chorale/src/Tests/Config/ConfigLoaderTest.php
@@ -0,0 +1,38 @@
+load($dir);
+ $this->assertSame([], $out);
+ }
+
+ #[Test]
+ public function testLoadParsesYamlIntoArray(): void
+ {
+ $loader = new ConfigLoader('test.yaml');
+ $dir = sys_get_temp_dir() . '/chorale_' . uniqid();
+ @mkdir($dir);
+ file_put_contents($dir . '/test.yaml', "repo_vendor: Acme\n");
+ $out = $loader->load($dir);
+ $this->assertSame('Acme', $out['repo_vendor']);
+ }
+}
diff --git a/tools/chorale/src/Tests/Config/ConfigNormalizerTest.php b/tools/chorale/src/Tests/Config/ConfigNormalizerTest.php
new file mode 100644
index 00000000..8634e49a
--- /dev/null
+++ b/tools/chorale/src/Tests/Config/ConfigNormalizerTest.php
@@ -0,0 +1,72 @@
+sorting = $this->createMock(SortingInterface::class);
+ $this->defaults = $this->createMock(ConfigDefaultsInterface::class);
+ $this->defaults->method('resolve')->willReturn([
+ 'repo_host' => 'git@github.com',
+ 'repo_vendor' => 'SonsOfPHP',
+ 'repo_name_template' => '{name:kebab}.git',
+ 'default_repo_template' => 'git@github.com:{repo_vendor}/{repo_name_template}',
+ 'default_branch' => 'main',
+ 'splitter' => 'splitsh',
+ 'tag_strategy' => 'inherit-monorepo-tag',
+ 'rules' => [
+ 'keep_history' => true,
+ 'skip_if_unchanged' => true,
+ 'require_files' => ['composer.json','LICENSE'],
+ ],
+ ]);
+ $this->sorting->method('sortPatterns')->willReturnCallback(fn(array $a): array => $a);
+ $this->sorting->method('sortTargets')->willReturnCallback(fn(array $a): array => $a);
+ }
+
+ public function testRedundantPatternOverrideIsRemoved(): void
+ {
+ $n = new ConfigNormalizer($this->sorting, $this->defaults);
+ $out = $n->normalize(['patterns' => [['match' => 'src/*', 'repo_host' => 'git@github.com']]]);
+ $this->assertArrayNotHasKey('repo_host', $out['patterns'][0]);
+ }
+
+ #[Test]
+ public function testRedundantTargetOverrideIsRemoved(): void
+ {
+ $n = new ConfigNormalizer($this->sorting, $this->defaults);
+ $out = $n->normalize(['targets' => [['path' => 'a/b', 'repo_vendor' => 'SonsOfPHP']]]);
+ $this->assertArrayNotHasKey('repo_vendor', $out['targets'][0]);
+ }
+
+ #[Test]
+ public function testTopLevelDefaultsCopied(): void
+ {
+ $n = new ConfigNormalizer($this->sorting, $this->defaults);
+ $out = $n->normalize([]);
+ $this->assertSame('git@github.com', $out['repo_host']);
+ }
+}
diff --git a/tools/chorale/src/Tests/Config/ConfigWriterTest.php b/tools/chorale/src/Tests/Config/ConfigWriterTest.php
new file mode 100644
index 00000000..cc11f0e8
--- /dev/null
+++ b/tools/chorale/src/Tests/Config/ConfigWriterTest.php
@@ -0,0 +1,48 @@
+backup = $this->createMock(BackupManagerInterface::class);
+ }
+
+ #[Test]
+ public function testWriteCreatesYamlFile(): void
+ {
+ $dir = sys_get_temp_dir() . '/chorale_' . uniqid();
+ @mkdir($dir);
+ $this->backup->expects($this->once())->method('backup')->with($dir . '/conf.yaml')->willReturn($dir . '/.chorale/backup/conf.yaml.bak');
+ $w = new ConfigWriter($this->backup, 'conf.yaml');
+ $w->write($dir, ['version' => 1]);
+ $this->assertFileExists($dir . '/conf.yaml');
+ }
+
+ #[Test]
+ public function testWriteThrowsWhenTempFileCannotBeWritten(): void
+ {
+ $this->backup->expects($this->once())->method('backup')->with($this->anything())->willReturn('/tmp/x');
+ $w = new ConfigWriter($this->backup, 'conf.yaml');
+ $this->expectException(\RuntimeException::class);
+ $w->write(sys_get_temp_dir() . uniqid(), ['a' => 'b']);
+ }
+}
diff --git a/tools/chorale/src/Tests/Config/SchemaValidatorTest.php b/tools/chorale/src/Tests/Config/SchemaValidatorTest.php
new file mode 100644
index 00000000..9a9a29c8
--- /dev/null
+++ b/tools/chorale/src/Tests/Config/SchemaValidatorTest.php
@@ -0,0 +1,73 @@
+validate(['repo_host' => 123], '/unused');
+ $this->assertContains("Key 'repo_host' must be a string.", $issues);
+ }
+
+ #[Test]
+ public function testValidateRejectsRulesNotArray(): void
+ {
+ $v = new SchemaValidator();
+ $issues = $v->validate(['rules' => 'x'], '/unused');
+ $this->assertContains("Key 'rules' must be an array.", $issues);
+ }
+
+ #[Test]
+ public function testValidateRejectsKeepHistoryNotBool(): void
+ {
+ $v = new SchemaValidator();
+ $issues = $v->validate(['rules' => ['keep_history' => 'no']], '/unused');
+ $this->assertContains('rules.keep_history must be a boolean.', $issues);
+ }
+
+ #[Test]
+ public function testValidateRejectsPatternsNotArray(): void
+ {
+ $v = new SchemaValidator();
+ $issues = $v->validate(['patterns' => 'x'], '/unused');
+ $this->assertContains("Key 'patterns' must be a list.", $issues);
+ }
+
+ #[Test]
+ public function testValidateRejectsPatternMissingMatch(): void
+ {
+ $v = new SchemaValidator();
+ $issues = $v->validate(['patterns' => [[]]], '/unused');
+ $this->assertContains('patterns[0].match must be a string.', $issues);
+ }
+
+ #[Test]
+ public function testValidateRejectsTargetsFieldTypes(): void
+ {
+ $v = new SchemaValidator();
+ $issues = $v->validate(['targets' => [['name' => 1]]], '/unused');
+ $this->assertContains('targets[0].name must be a string.', $issues);
+ }
+
+ #[Test]
+ public function testValidateRejectsHooksWhenNotList(): void
+ {
+ $v = new SchemaValidator();
+ $issues = $v->validate(['hooks' => 'not-a-list'], '/unused');
+ $this->assertContains("Key 'hooks' must be a list.", $issues);
+ }
+}
diff --git a/tools/chorale/src/Tests/Console/ApplyCommandTest.php b/tools/chorale/src/Tests/Console/ApplyCommandTest.php
new file mode 100644
index 00000000..072ac80d
--- /dev/null
+++ b/tools/chorale/src/Tests/Console/ApplyCommandTest.php
@@ -0,0 +1,38 @@
+ [['type' => 'x']]]));
+
+ $runner = $this->createMock(RunnerInterface::class);
+ $runner->expects($this->once())->method('apply')->with($projectRoot, [['type' => 'x']]);
+
+ $command = new ApplyCommand(new ConsoleStyleFactory(), $runner);
+ $tester = new CommandTester($command);
+ $exitCode = $tester->execute(['--project-root' => $projectRoot, '--file' => $planPath]);
+ $this->assertSame(0, $exitCode);
+ }
+}
diff --git a/tools/chorale/src/Tests/Console/RunCommandTest.php b/tools/chorale/src/Tests/Console/RunCommandTest.php
new file mode 100644
index 00000000..ba6e4f22
--- /dev/null
+++ b/tools/chorale/src/Tests/Console/RunCommandTest.php
@@ -0,0 +1,39 @@
+createMock(RunnerInterface::class);
+ $runner->expects($this->once())->method('run')->with($projectRoot, $this->anything())->willReturn([
+ 'steps' => [new PackageVersionUpdateStep('pkg', 'pkg/pkg', '1.0.0')],
+ ]);
+
+ $command = new RunCommand(new ConsoleStyleFactory(), $runner);
+ $tester = new CommandTester($command);
+ $exitCode = $tester->execute(['--project-root' => $projectRoot]);
+ $this->assertSame(0, $exitCode);
+ }
+}
diff --git a/tools/chorale/src/Tests/Diff/ConfigDifferTest.php b/tools/chorale/src/Tests/Diff/ConfigDifferTest.php
new file mode 100644
index 00000000..eb6ca50c
--- /dev/null
+++ b/tools/chorale/src/Tests/Diff/ConfigDifferTest.php
@@ -0,0 +1,246 @@
+ 'git@github.com',
+ 'repo_vendor' => 'Acme',
+ 'repo_name_template' => '{name:kebab}.git',
+ 'default_repo_template' => 'git@github.com:{repo_vendor}/{name:kebab}.git',
+ 'default_branch' => 'main',
+ 'splitter' => 'splitsh',
+ 'tag_strategy' => 'inherit-monorepo-tag',
+ 'rules' => [
+ 'keep_history' => true,
+ 'skip_if_unchanged' => true,
+ 'require_files' => ['composer.json','LICENSE'],
+ ],
+ ];
+ }
+
+ private function stubPaths(): PathUtilsInterface
+ {
+ return new class implements PathUtilsInterface {
+ public function normalize(string $path): string
+ {
+ return $path;
+ }
+
+ public function isUnder(string $path, string $root): bool
+ {
+ return false;
+ }
+
+ public function match(string $pattern, string $path): bool
+ {
+ return false;
+ }
+
+ public function leaf(string $path): string
+ {
+ $pos = strrpos($path, '/');
+ return $pos === false ? $path : substr($path, $pos + 1);
+ }
+ };
+ }
+
+ private function newDiffer(
+ ConfigDefaultsInterface $defaults,
+ PatternMatcherInterface $matcher,
+ RepoResolverInterface $resolver,
+ PackageIdentityInterface $identity,
+ RequiredFilesCheckerInterface $required
+ ): ConfigDiffer {
+ return new ConfigDiffer($defaults, $matcher, $resolver, $identity, $required, $this->stubPaths());
+ }
+
+ #[Test]
+ public function testClassifiesNewWhenNotInConfig(): void
+ {
+ $defaults = $this->createMock(ConfigDefaultsInterface::class);
+ $defaults->method('resolve')->willReturn($this->defaults());
+
+ $matcher = $this->createMock(PatternMatcherInterface::class);
+ $matcher->method('allMatches')->willReturn([]);
+
+ $resolver = $this->createMock(RepoResolverInterface::class);
+ $resolver->method('resolve')->willReturn('git@github.com:Acme/foo.git');
+
+ $identity = $this->createMock(PackageIdentityInterface::class);
+ $required = $this->createMock(RequiredFilesCheckerInterface::class);
+ $required->method('missing')->willReturn([]);
+ $this->createMock(ConflictDetectorInterface::class);
+
+ $differ = $this->newDiffer($defaults, $matcher, $resolver, $identity, $required);
+
+ $out = $differ->diff(['targets' => [], 'patterns' => []], ['src/Acme/Foo'], []);
+ $this->assertSame('src/Acme/Foo', $out['new'][0]['path']);
+ }
+
+ #[Test]
+ public function testDetectsRenameWhenIdentityMatches(): void
+ {
+ $defaults = $this->createMock(ConfigDefaultsInterface::class);
+ $defaults->method('resolve')->willReturn($this->defaults());
+
+ $matcher = $this->createMock(PatternMatcherInterface::class);
+ $matcher->method('allMatches')->willReturn([]);
+
+ $resolver = $this->createMock(RepoResolverInterface::class);
+ // Called for new path and old path
+ $resolver->method('resolve')->willReturnOnConsecutiveCalls(
+ 'git@github.com:Acme/new.git',
+ 'git@github.com:Acme/old.git',
+ 'git@github.com:Acme/old.git'
+ );
+
+ $identity = $this->createMock(PackageIdentityInterface::class);
+ $identity->method('identityFor')->willReturn('same-id');
+
+ $required = $this->createMock(RequiredFilesCheckerInterface::class);
+ $required->method('missing')->willReturn([]);
+ $this->createMock(ConflictDetectorInterface::class);
+
+ $config = [
+ 'targets' => [
+ ['path' => 'src/Acme/Old'],
+ ],
+ 'patterns' => [],
+ ];
+ $discovered = ['src/Acme/New'];
+
+ $differ = $this->newDiffer($defaults, $matcher, $resolver, $identity, $required);
+ $out = $differ->diff($config, $discovered, []);
+
+ $this->assertSame('src/Acme/Old', $out['renamed'][0]['from']);
+ }
+
+ #[Test]
+ public function testReportsIssuesWhenRequiredFilesMissing(): void
+ {
+ $defaults = $this->createMock(ConfigDefaultsInterface::class);
+ $defaults->method('resolve')->willReturn($this->defaults());
+
+ $matcher = $this->createMock(PatternMatcherInterface::class);
+ $matcher->method('allMatches')->willReturn([]);
+
+ $resolver = $this->createMock(RepoResolverInterface::class);
+ $resolver->method('resolve')->willReturn('git@github.com:Acme/foo.git');
+
+ $identity = $this->createMock(PackageIdentityInterface::class);
+ $required = $this->createMock(RequiredFilesCheckerInterface::class);
+ $required->method('missing')->willReturn(['composer.json']);
+ $this->createMock(ConflictDetectorInterface::class);
+
+ $differ = $this->newDiffer($defaults, $matcher, $resolver, $identity, $required);
+
+ $out = $differ->diff(['targets' => [['path' => 'src/Acme/Foo']], 'patterns' => []], ['src/Acme/Foo'], []);
+ $this->assertSame(['composer.json'], $out['issues'][0]['missing']);
+ }
+
+ #[Test]
+ public function testReportsDriftWhenRedundantOverridePresent(): void
+ {
+ $defaults = $this->createMock(ConfigDefaultsInterface::class);
+ $defaults->method('resolve')->willReturn($this->defaults());
+
+ $matcher = $this->createMock(PatternMatcherInterface::class);
+ $matcher->method('allMatches')->willReturn([]);
+
+ $resolver = $this->createMock(RepoResolverInterface::class);
+ $resolver->method('resolve')->willReturn('git@github.com:Acme/foo.git');
+
+ $identity = $this->createMock(PackageIdentityInterface::class);
+ $required = $this->createMock(RequiredFilesCheckerInterface::class);
+ $required->method('missing')->willReturn([]);
+ $this->createMock(ConflictDetectorInterface::class);
+
+ $config = [
+ 'targets' => [
+ ['path' => 'src/Acme/Foo', 'repo_vendor' => 'Acme'],
+ ],
+ 'patterns' => [],
+ ];
+ $discovered = ['src/Acme/Foo'];
+
+ $differ = $this->newDiffer($defaults, $matcher, $resolver, $identity, $required);
+ $out = $differ->diff($config, $discovered, []);
+
+ $this->assertSame('src/Acme/Foo', $out['drift'][0]['path']);
+ }
+
+ #[Test]
+ public function testReportsConflictsWhenMultiplePatternsMatch(): void
+ {
+ $defaults = $this->createMock(ConfigDefaultsInterface::class);
+ $defaults->method('resolve')->willReturn($this->defaults());
+
+ $matcher = $this->createMock(PatternMatcherInterface::class);
+ $matcher->method('allMatches')->willReturn([0, 1]);
+
+ $resolver = $this->createMock(RepoResolverInterface::class);
+ $resolver->method('resolve')->willReturn('git@github.com:Acme/foo.git');
+
+ $identity = $this->createMock(PackageIdentityInterface::class);
+ $required = $this->createMock(RequiredFilesCheckerInterface::class);
+ $required->method('missing')->willReturn([]);
+ $this->createMock(ConflictDetectorInterface::class);
+
+ $config = [
+ 'targets' => [['path' => 'src/Acme/Foo']],
+ 'patterns' => [['match' => 'src/*'], ['match' => 'src/Acme/*']],
+ ];
+ $differ = $this->newDiffer($defaults, $matcher, $resolver, $identity, $required);
+ $out = $differ->diff($config, ['src/Acme/Foo'], []);
+ $this->assertSame([0, 1], $out['conflicts'][0]['patterns']);
+ }
+
+ #[Test]
+ public function testOkGroupWhenNoDriftOrIssues(): void
+ {
+ $defaults = $this->createMock(ConfigDefaultsInterface::class);
+ $defaults->method('resolve')->willReturn($this->defaults());
+
+ $matcher = $this->createMock(PatternMatcherInterface::class);
+ $matcher->method('allMatches')->willReturn([]);
+
+ $resolver = $this->createMock(RepoResolverInterface::class);
+ $resolver->method('resolve')->willReturn('git@github.com:Acme/foo.git');
+
+ $identity = $this->createMock(PackageIdentityInterface::class);
+ $required = $this->createMock(RequiredFilesCheckerInterface::class);
+ $required->method('missing')->willReturn([]);
+ $this->createMock(ConflictDetectorInterface::class);
+
+ $config = [
+ 'targets' => [['path' => 'src/Acme/Foo']],
+ 'patterns' => [],
+ ];
+ $differ = $this->newDiffer($defaults, $matcher, $resolver, $identity, $required);
+ $out = $differ->diff($config, ['src/Acme/Foo'], []);
+ $this->assertSame('src/Acme/Foo', $out['ok'][0]['path']);
+ }
+}
diff --git a/tools/chorale/src/Tests/Discovery/PackageIdentityTest.php b/tools/chorale/src/Tests/Discovery/PackageIdentityTest.php
new file mode 100644
index 00000000..a35d3fd0
--- /dev/null
+++ b/tools/chorale/src/Tests/Discovery/PackageIdentityTest.php
@@ -0,0 +1,33 @@
+identityFor('unused', 'SSH://GitHub.com/SonsOfPHP/Cookie.git');
+ $this->assertSame('github.com/sonsofphp/cookie.git', $id);
+ }
+
+ #[Test]
+ public function testIdentityFallsBackToLeaf(): void
+ {
+ $pi = new PackageIdentity();
+ $id = $pi->identityFor('src/SonsOfPHP/Cookie');
+ $this->assertSame('cookie', $id);
+ }
+}
diff --git a/tools/chorale/src/Tests/Discovery/PackageScannerTest.php b/tools/chorale/src/Tests/Discovery/PackageScannerTest.php
new file mode 100644
index 00000000..ea20b70b
--- /dev/null
+++ b/tools/chorale/src/Tests/Discovery/PackageScannerTest.php
@@ -0,0 +1,62 @@
+= 2 and has a file
+ @mkdir($root . '/src/SonsOfPHP/Cookie', 0o777, true);
+ file_put_contents($root . '/src/SonsOfPHP/Cookie/composer.json', '{}');
+ // non-candidate: only dirs, no file
+ @mkdir($root . '/src/Empty/NoFiles', 0o777, true);
+ // vendor should be skipped
+ @mkdir($root . '/src/vendor/IgnoreMe', 0o777, true);
+ file_put_contents($root . '/src/vendor/IgnoreMe/composer.json', '{}');
+ return $root;
+ }
+
+ #[Test]
+ public function testScanFindsLeafPackages(): void
+ {
+ $root = $this->makeProject();
+ $ps = new PackageScanner(new PathUtils());
+ $paths = $ps->scan($root, 'src');
+ $this->assertContains('src/SonsOfPHP/Cookie', $paths);
+ }
+
+ #[Test]
+ public function testScanRespectsProvidedPaths(): void
+ {
+ $root = $this->makeProject();
+ $ps = new PackageScanner(new PathUtils());
+ $paths = $ps->scan($root, 'src', ['src/SonsOfPHP/Cookie']);
+ $this->assertSame(['src/SonsOfPHP/Cookie'], $paths);
+ }
+
+ #[Test]
+ public function testScanSkipsVendorDirectories(): void
+ {
+ $root = $this->makeProject();
+ $ps = new PackageScanner(new PathUtils());
+ $paths = $ps->scan($root, 'src');
+ $this->assertNotContains('src/vendor/IgnoreMe', $paths);
+ }
+}
diff --git a/tools/chorale/src/Tests/Discovery/PatternMatcherTest.php b/tools/chorale/src/Tests/Discovery/PatternMatcherTest.php
new file mode 100644
index 00000000..db9ce1d5
--- /dev/null
+++ b/tools/chorale/src/Tests/Discovery/PatternMatcherTest.php
@@ -0,0 +1,68 @@
+fn;
+ return (bool) $f($pattern, $path);
+ }
+
+ public function leaf(string $path): string
+ {
+ return $path;
+ }
+ };
+ }
+
+ #[Test]
+ public function testFirstMatchReturnsIndex(): void
+ {
+ $pm = new PatternMatcher($this->stubPaths(fn($pat, $p): bool => $pat === 'src/*/Cookie'));
+ $idx = $pm->firstMatch([
+ ['match' => 'src/*/Cookie'],
+ ], 'src/SonsOfPHP/Cookie');
+ $this->assertSame(0, $idx);
+ }
+
+ #[Test]
+ public function testAllMatchesReturnsAllIndexes(): void
+ {
+ $pm = new PatternMatcher($this->stubPaths(fn($pat, $path): bool => in_array($pat, ['src/*/Cookie','src/SonsOfPHP/*'], true)));
+ $idx = $pm->allMatches([
+ ['match' => 'src/*/Cookie'],
+ ['match' => 'src/SonsOfPHP/*'],
+ ], 'src/SonsOfPHP/Cookie');
+ $this->assertSame([0,1], $idx);
+ }
+}
diff --git a/tools/chorale/src/Tests/IO/BackupManagerTest.php b/tools/chorale/src/Tests/IO/BackupManagerTest.php
new file mode 100644
index 00000000..31930527
--- /dev/null
+++ b/tools/chorale/src/Tests/IO/BackupManagerTest.php
@@ -0,0 +1,41 @@
+backup($dir . '/file.yaml');
+ $this->assertFileExists($dest);
+ }
+
+ #[Test]
+ public function testRestoreCopiesBackupToTarget(): void
+ {
+ $bm = new BackupManager();
+ $dir = sys_get_temp_dir() . '/chorale_' . uniqid();
+ @mkdir($dir);
+ $srcFile = $dir . '/file.yaml';
+ file_put_contents($srcFile, 'abc');
+ $backup = $bm->backup($srcFile);
+ $target = $dir . '/restored.yaml';
+ $bm->restore($backup, $target);
+ $this->assertFileExists($target);
+ }
+}
diff --git a/tools/chorale/src/Tests/IO/JsonReporterTest.php b/tools/chorale/src/Tests/IO/JsonReporterTest.php
new file mode 100644
index 00000000..541f54ef
--- /dev/null
+++ b/tools/chorale/src/Tests/IO/JsonReporterTest.php
@@ -0,0 +1,33 @@
+build(['a' => 'b'], ['new' => []], [['action' => 'none']]);
+ $this->assertStringEndsWith("\n", $json);
+ }
+
+ #[Test]
+ public function testBuildIncludesDefaultsKey(): void
+ {
+ $jr = new JsonReporter();
+ $json = $jr->build(['a' => 'b'], ['new' => []], [['action' => 'none']]);
+ $this->assertStringContainsString('"defaults"', $json);
+ }
+}
diff --git a/tools/chorale/src/Tests/Repo/RepoResolverTest.php b/tools/chorale/src/Tests/Repo/RepoResolverTest.php
new file mode 100644
index 00000000..ebfb3bc3
--- /dev/null
+++ b/tools/chorale/src/Tests/Repo/RepoResolverTest.php
@@ -0,0 +1,58 @@
+ 'git@github.com',
+ 'repo_vendor' => 'Acme',
+ 'repo_name_template' => '{name:kebab}.git',
+ 'default_repo_template' => '{repo_host}:{repo_vendor}/{name:kebab}.git',
+ ];
+
+ public function testResolveUsesTargetRepoWhenPresent(): void
+ {
+ $r = new RepoResolver(new TemplateRenderer(), new PathUtils());
+ $url = $r->resolve($this->defaults, [], ['repo' => 'git@gh:x/{name}'], 'src/Acme/Foo', 'Foo');
+ $this->assertSame('git@gh:x/Foo', $url);
+ }
+
+ #[Test]
+ public function testResolveUsesPatternRepoWhenTargetMissing(): void
+ {
+ $r = new RepoResolver(new TemplateRenderer(), new PathUtils());
+ $url = $r->resolve($this->defaults, ['repo' => '{repo_host}:{repo_vendor}/{name:snake}'], [], 'src/Acme/Foo', 'FooBar');
+ $this->assertSame('git@github.com:Acme/foo_bar', $url);
+ }
+
+ #[Test]
+ public function testResolveUsesDefaultTemplateOtherwise(): void
+ {
+ $r = new RepoResolver(new TemplateRenderer(), new PathUtils());
+ $url = $r->resolve($this->defaults, [], [], 'src/Acme/Cookie', 'Cookie');
+ $this->assertSame('git@github.com:Acme/cookie.git', $url);
+ }
+
+ #[Test]
+ public function testResolveDerivesNameFromLeafWhenNameNull(): void
+ {
+ $r = new RepoResolver(new TemplateRenderer(), new PathUtils());
+ $url = $r->resolve($this->defaults, [], [], 'src/Acme/CamelCase', null);
+ $this->assertSame('git@github.com:Acme/camel-case.git', $url);
+ }
+}
diff --git a/tools/chorale/src/Tests/Repo/TemplateRendererTest.php b/tools/chorale/src/Tests/Repo/TemplateRendererTest.php
new file mode 100644
index 00000000..79be4d4a
--- /dev/null
+++ b/tools/chorale/src/Tests/Repo/TemplateRendererTest.php
@@ -0,0 +1,105 @@
+validate('x/{unknown}');
+ $this->assertContains("Unknown placeholder 'unknown'", $issues);
+ }
+
+ #[Test]
+ public function testValidateDetectsUnknownFilter(): void
+ {
+ $r = new TemplateRenderer();
+ $issues = $r->validate('x/{name:oops}');
+ $this->assertContains("Unknown filter 'oops' for 'name'", $issues);
+ }
+
+ #[Test]
+ public function testRenderAppliesLowerFilter(): void
+ {
+ $r = new TemplateRenderer();
+ $out = $r->render('{name:lower}', ['name' => 'Cookie']);
+ $this->assertSame('cookie', $out);
+ }
+
+ #[Test]
+ public function testRenderAppliesUpperFilter(): void
+ {
+ $r = new TemplateRenderer();
+ $out = $r->render('{name:upper}', ['name' => 'Cookie']);
+ $this->assertSame('COOKIE', $out);
+ }
+
+ #[Test]
+ public function testRenderKebabFilter(): void
+ {
+ $r = new TemplateRenderer();
+ $out = $r->render('{name:kebab}', ['name' => 'My Cookie_Package']);
+ $this->assertSame('my-cookie-package', $out);
+ }
+
+ #[Test]
+ public function testRenderSnakeFilter(): void
+ {
+ $r = new TemplateRenderer();
+ $out = $r->render('{name:snake}', ['name' => 'My Cookie-Package']);
+ $this->assertSame('my_cookie_package', $out);
+ }
+
+ #[Test]
+ public function testRenderCamelFilter(): void
+ {
+ $r = new TemplateRenderer();
+ $out = $r->render('{name:camel}', ['name' => 'my-cookie package']);
+ $this->assertSame('myCookiePackage', $out);
+ }
+
+ #[Test]
+ public function testRenderPascalFilter(): void
+ {
+ $r = new TemplateRenderer();
+ $out = $r->render('{name:pascal}', ['name' => 'my-cookie package']);
+ $this->assertSame('MyCookiePackage', $out);
+ }
+
+ #[Test]
+ public function testRenderDotFilter(): void
+ {
+ $r = new TemplateRenderer();
+ $out = $r->render('{name:dot}', ['name' => 'my cookie-package']);
+ $this->assertSame('my.cookie.package', $out);
+ }
+
+ #[Test]
+ public function testRenderSupportsChainedFilters(): void
+ {
+ $r = new TemplateRenderer();
+ $out = $r->render('{name:snake:upper}', ['name' => 'CamelCase']);
+ $this->assertSame('CAMEL_CASE', $out);
+ }
+
+ #[Test]
+ public function testRenderEmptyTemplateReturnsEmptyString(): void
+ {
+ $r = new TemplateRenderer();
+ $out = $r->render('', ['name' => 'Anything']);
+ $this->assertSame('', $out);
+ }
+}
diff --git a/tools/chorale/src/Tests/Rules/ConflictDetectorTest.php b/tools/chorale/src/Tests/Rules/ConflictDetectorTest.php
new file mode 100644
index 00000000..6e848714
--- /dev/null
+++ b/tools/chorale/src/Tests/Rules/ConflictDetectorTest.php
@@ -0,0 +1,70 @@
+fn;
+ return (bool) $f($pattern, $path);
+ }
+
+ public function leaf(string $path): string
+ {
+ return $path;
+ }
+ };
+ }
+
+ #[Test]
+ public function testDetectReportsConflictWhenMultiplePatternsMatch(): void
+ {
+ $cd = new ConflictDetector(new PatternMatcher($this->stubPaths(fn($pat, $p): bool => in_array($pat, ['src/*/Cookie','src/SonsOfPHP/*'], true))));
+ $res = $cd->detect([
+ ['match' => 'src/*/Cookie'],
+ ['match' => 'src/SonsOfPHP/*'],
+ ], 'src/SonsOfPHP/Cookie');
+ $this->assertTrue($res['conflict']);
+ }
+
+ #[Test]
+ public function testDetectReturnsMatchedIndexes(): void
+ {
+ $cd = new ConflictDetector(new PatternMatcher($this->stubPaths(fn($pat, $p): bool => in_array($pat, ['src/*/Cookie','src/SonsOfPHP/*'], true))));
+ $res = $cd->detect([
+ ['match' => 'src/*/Cookie'],
+ ['match' => 'src/SonsOfPHP/*'],
+ ], 'src/SonsOfPHP/Cookie');
+ $this->assertSame([0,1], $res['matches']);
+ }
+}
diff --git a/tools/chorale/src/Tests/Rules/RequiredFilesCheckerTest.php b/tools/chorale/src/Tests/Rules/RequiredFilesCheckerTest.php
new file mode 100644
index 00000000..b9954e9a
--- /dev/null
+++ b/tools/chorale/src/Tests/Rules/RequiredFilesCheckerTest.php
@@ -0,0 +1,49 @@
+makePackage(true);
+ $c = new RequiredFilesChecker();
+ $miss = $c->missing($root, $pkg, ['composer.json','LICENSE']);
+ $this->assertSame([], $miss);
+ }
+
+ #[Test]
+ public function testMissingReturnsListOfMissing(): void
+ {
+ [$root, $pkg] = $this->makePackage(false);
+ $c = new RequiredFilesChecker();
+ $miss = $c->missing($root, $pkg, ['composer.json','LICENSE']);
+ $this->assertSame(['composer.json','LICENSE'], $miss);
+ }
+}
diff --git a/tools/chorale/src/Tests/Run/ComposerRootUpdateExecutorTest.php b/tools/chorale/src/Tests/Run/ComposerRootUpdateExecutorTest.php
new file mode 100644
index 00000000..b864d08e
--- /dev/null
+++ b/tools/chorale/src/Tests/Run/ComposerRootUpdateExecutorTest.php
@@ -0,0 +1,41 @@
+ 'old/root'], JSON_PRETTY_PRINT));
+
+ $executor = new ComposerRootUpdateExecutor();
+ $executor->execute($projectRoot, [
+ 'type' => 'composer-root-update',
+ 'root' => 'acme/monorepo',
+ 'root_version' => '1.0.0',
+ 'require' => ['foo/bar' => '*'],
+ 'replace' => ['foo/bar' => '*'],
+ ]);
+
+ $data = json_decode((string) file_get_contents($projectRoot . '/composer.json'), true);
+ $this->assertSame('acme/monorepo', $data['name']);
+ $this->assertSame('1.0.0', $data['version']);
+ $this->assertSame(['foo/bar' => '*'], $data['require']);
+ $this->assertSame(['foo/bar' => '*'], $data['replace']);
+ }
+}
diff --git a/tools/chorale/src/Tests/Run/PackageVersionUpdateExecutorTest.php b/tools/chorale/src/Tests/Run/PackageVersionUpdateExecutorTest.php
new file mode 100644
index 00000000..76d4bd1e
--- /dev/null
+++ b/tools/chorale/src/Tests/Run/PackageVersionUpdateExecutorTest.php
@@ -0,0 +1,32 @@
+ 'pkg/pkg'], JSON_PRETTY_PRINT));
+
+ $executor = new PackageVersionUpdateExecutor();
+ $executor->execute($projectRoot, ['type' => 'package-version-update', 'path' => 'pkg', 'version' => '2.0.0']);
+
+ $data = json_decode((string) file_get_contents($projectRoot . '/pkg/composer.json'), true);
+ $this->assertSame('2.0.0', $data['version']);
+ }
+}
diff --git a/tools/chorale/src/Tests/Run/RootDependencyMergeExecutorTest.php b/tools/chorale/src/Tests/Run/RootDependencyMergeExecutorTest.php
new file mode 100644
index 00000000..ad4eb92a
--- /dev/null
+++ b/tools/chorale/src/Tests/Run/RootDependencyMergeExecutorTest.php
@@ -0,0 +1,59 @@
+ 'acme/root'], JSON_PRETTY_PRINT));
+
+ $executor = new RootDependencyMergeExecutor();
+ $executor->execute($projectRoot, [
+ 'type' => 'composer-root-merge',
+ 'require' => ['foo/bar' => '^1.0'],
+ 'require-dev' => ['baz/qux' => '^2.0'],
+ ]);
+
+ $data = json_decode((string) file_get_contents($projectRoot . '/composer.json'), true);
+ $this->assertSame(['foo/bar' => '^1.0'], $data['require']);
+ $this->assertSame(['baz/qux' => '^2.0'], $data['require-dev']);
+ $this->assertArrayNotHasKey('extra', $data);
+ }
+
+ #[Test]
+ public function testExecuteRecordsConflicts(): void
+ {
+ $projectRoot = sys_get_temp_dir() . '/chorale-merge-conflict-' . uniqid();
+ mkdir($projectRoot);
+ file_put_contents($projectRoot . '/composer.json', json_encode(['name' => 'acme/root'], JSON_PRETTY_PRINT));
+
+ $conflict = [['package' => 'foo/bar', 'versions' => ['^1.0', '1.2.0'], 'packages' => ['a', 'b'], 'reason' => 'non-caret-mixed']];
+
+ $executor = new RootDependencyMergeExecutor();
+ $executor->execute($projectRoot, [
+ 'type' => 'composer-root-merge',
+ 'require' => [],
+ 'require-dev' => [],
+ 'conflicts' => $conflict,
+ ]);
+
+ $data = json_decode((string) file_get_contents($projectRoot . '/composer.json'), true);
+ $this->assertSame($conflict, $data['extra']['chorale']['dependency-conflicts']);
+ }
+}
diff --git a/tools/chorale/src/Tests/Run/RunnerTest.php b/tools/chorale/src/Tests/Run/RunnerTest.php
new file mode 100644
index 00000000..c83557cb
--- /dev/null
+++ b/tools/chorale/src/Tests/Run/RunnerTest.php
@@ -0,0 +1,44 @@
+ 'pkg/pkg'], JSON_PRETTY_PRINT));
+
+ $configLoader = $this->createStub(ConfigLoaderInterface::class);
+ $configLoader->method('load')->willReturn(['packages' => []]);
+
+ $step = new PackageVersionUpdateStep('pkg', 'pkg/pkg', '1.2.3');
+ $planner = $this->createMock(PlanBuilderInterface::class);
+ $planner->method('build')->willReturn(['steps' => [$step]]);
+
+ $runner = new Runner($configLoader, $planner, new StepExecutorRegistry([new PackageVersionUpdateExecutor()]));
+ $runner->run($projectRoot);
+
+ $data = json_decode((string) file_get_contents($projectRoot . '/pkg/composer.json'), true);
+ $this->assertSame('1.2.3', $data['version']);
+ }
+}
diff --git a/tools/chorale/src/Tests/Run/StepExecutorRegistryTest.php b/tools/chorale/src/Tests/Run/StepExecutorRegistryTest.php
new file mode 100644
index 00000000..117588a0
--- /dev/null
+++ b/tools/chorale/src/Tests/Run/StepExecutorRegistryTest.php
@@ -0,0 +1,41 @@
+called = true; }
+ };
+ $registry = new StepExecutorRegistry([$executor]);
+ $registry->execute('/tmp', ['type' => 'x']);
+ $this->assertTrue($executor->called);
+ }
+
+ #[Test]
+ public function testExecuteThrowsForUnknownStep(): void
+ {
+ $registry = new StepExecutorRegistry();
+ $this->expectException(RuntimeException::class);
+ $registry->execute('/tmp', ['type' => 'missing']);
+ }
+}
diff --git a/tools/chorale/src/Tests/Telemetry/RunSummaryTest.php b/tools/chorale/src/Tests/Telemetry/RunSummaryTest.php
new file mode 100644
index 00000000..527e8e4b
--- /dev/null
+++ b/tools/chorale/src/Tests/Telemetry/RunSummaryTest.php
@@ -0,0 +1,36 @@
+inc('new');
+ $this->assertSame(['new' => 1], $rs->all());
+ }
+
+ #[Test]
+ public function testAllReturnsSortedKeys(): void
+ {
+ $rs = new RunSummary();
+ $rs->inc('z');
+ $rs->inc('a');
+
+ $all = $rs->all();
+ $this->assertSame(['a','z'], array_keys($all));
+ }
+}
diff --git a/tools/chorale/src/Tests/Util/PathUtilsTest.php b/tools/chorale/src/Tests/Util/PathUtilsTest.php
new file mode 100644
index 00000000..934c2330
--- /dev/null
+++ b/tools/chorale/src/Tests/Util/PathUtilsTest.php
@@ -0,0 +1,115 @@
+u = new PathUtils();
+ }
+
+ #[Test]
+ public function testNormalizeConvertsBackslashes(): void
+ {
+ $this->assertSame('a/b', $this->u->normalize('a\\b'));
+ }
+
+ #[Test]
+ public function testNormalizeCollapsesMultipleSlashes(): void
+ {
+ $this->assertSame('a/b', $this->u->normalize('a////b'));
+ }
+
+ #[Test]
+ public function testNormalizeRemovesTrailingSlash(): void
+ {
+ $this->assertSame('a', $this->u->normalize('a/'));
+ }
+
+ #[Test]
+ public function testNormalizeRootSlashStays(): void
+ {
+ $this->assertSame('.', $this->u->normalize('/..'));
+ }
+
+ #[Test]
+ public function testNormalizeResolvesDotSegments(): void
+ {
+ $this->assertSame('a/b', $this->u->normalize('./a/./b'));
+ }
+
+ #[Test]
+ public function testNormalizeResolvesDotDotSegments(): void
+ {
+ $this->assertSame('a', $this->u->normalize('a/b/..'));
+ }
+
+ #[Test]
+ public function testIsUnderTrueForSamePath(): void
+ {
+ $this->assertTrue($this->u->isUnder('a/b', 'a/b'));
+ }
+
+ #[Test]
+ public function testIsUnderTrueForChildPath(): void
+ {
+ $this->assertTrue($this->u->isUnder('a/b/c', 'a/b'));
+ }
+
+ #[Test]
+ public function testIsUnderFalseForSiblingPath(): void
+ {
+ $this->assertFalse($this->u->isUnder('a/c', 'a/b'));
+ }
+
+ #[Test]
+ public function testMatchAsteriskPatternMatches(): void
+ {
+ $this->assertTrue($this->u->match('src/*/Cookie', 'src/SonsOfPHP/Cookie'));
+ }
+
+ #[Test]
+ public function testMatchQuestionMarkPatternMatches(): void
+ {
+ $this->assertTrue($this->u->match('src/SonsOfPHP/Cooki?', 'src/SonsOfPHP/Cookie'));
+ }
+
+ #[Test]
+ public function testMatchExactPathWithDotsCurrentlyDoesNotMatch(): void
+ {
+ $this->assertFalse($this->u->match('src/Sons.OfPHP/Cookie', 'src/SonsOfPHP/Cookie'));
+ }
+
+ #[Test]
+ public function testLeafReturnsLastSegment(): void
+ {
+ $this->assertSame('Cookie', $this->u->leaf('src/SonsOfPHP/Cookie'));
+ }
+
+ #[Test]
+ public function testLeafReturnsWholeWhenNoSeparator(): void
+ {
+ $this->assertSame('Cookie', $this->u->leaf('Cookie'));
+ }
+
+ #[Test]
+ public function testMatchDoubleStarCrossesDirectories(): void
+ {
+ $this->assertTrue($this->u->match('src/**/Cookie', 'src/A/B/Cookie'));
+ }
+}
diff --git a/tools/chorale/src/Tests/Util/SortingTest.php b/tools/chorale/src/Tests/Util/SortingTest.php
new file mode 100644
index 00000000..dd681adb
--- /dev/null
+++ b/tools/chorale/src/Tests/Util/SortingTest.php
@@ -0,0 +1,65 @@
+ 'a/b'],
+ ['match' => 'a/b/c'],
+ ];
+ $out = $s->sortPatterns($in);
+ $this->assertSame('a/b/c', $out[0]['match']);
+ }
+
+ #[Test]
+ public function testSortPatternsTiesAlphabetically(): void
+ {
+ $s = new Sorting();
+ $in = [
+ ['match' => 'b/b'],
+ ['match' => 'a/b'],
+ ];
+ $out = $s->sortPatterns($in);
+ $this->assertSame('a/b', $out[0]['match']);
+ }
+
+ #[Test]
+ public function testSortTargetsPrimaryByPath(): void
+ {
+ $s = new Sorting();
+ $in = [
+ ['path' => 'b', 'name' => 'x'],
+ ['path' => 'a', 'name' => 'z'],
+ ];
+ $out = $s->sortTargets($in);
+ $this->assertSame('a', $out[0]['path']);
+ }
+
+ #[Test]
+ public function testSortTargetsSecondaryByNameWhenSamePath(): void
+ {
+ $s = new Sorting();
+ $in = [
+ ['path' => 'a', 'name' => 'z'],
+ ['path' => 'a', 'name' => 'a'],
+ ];
+ $out = $s->sortTargets($in);
+ $this->assertSame('a', $out[0]['name']);
+ }
+}
diff --git a/tools/chorale/src/Util/DiffUtil.php b/tools/chorale/src/Util/DiffUtil.php
new file mode 100644
index 00000000..69f9df32
--- /dev/null
+++ b/tools/chorale/src/Util/DiffUtil.php
@@ -0,0 +1,77 @@
+equalsNormalized($a, $b)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function equalsNormalized(mixed $a, mixed $b): bool
+ {
+ if (is_array($a) && is_array($b)) {
+ return $this->equalArray($a, $b);
+ }
+
+ return $a === $b;
+ }
+
+ private function equalArray(array $a, array $b): bool
+ {
+ if ($this->isAssoc($a) || $this->isAssoc($b)) {
+ ksort($a);
+ ksort($b);
+ if (count($a) !== count($b)) {
+ return false;
+ }
+
+ foreach ($a as $k => $v) {
+ if (!array_key_exists($k, $b)) {
+ return false;
+ }
+
+ if (!$this->equalsNormalized($v, $b[$k])) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ // list arrays: compare values irrespective of order
+ sort($a);
+ sort($b);
+ if (count($a) !== count($b)) {
+ return false;
+ }
+
+ foreach ($a as $i => $v) {
+ if (!$this->equalsNormalized($v, $b[$i])) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private function isAssoc(array $arr): bool
+ {
+ if ($arr === []) {
+ return false;
+ }
+
+ return array_keys($arr) !== range(0, count($arr) - 1);
+ }
+}
diff --git a/tools/chorale/src/Util/DiffUtilInterface.php b/tools/chorale/src/Util/DiffUtilInterface.php
new file mode 100644
index 00000000..958d91f8
--- /dev/null
+++ b/tools/chorale/src/Util/DiffUtilInterface.php
@@ -0,0 +1,17 @@
+ $current
+ * @param array $desired
+ * @param list $keys
+ */
+ public function changed(array $current, array $desired, array $keys): bool;
+}
diff --git a/tools/chorale/src/Util/PathUtils.php b/tools/chorale/src/Util/PathUtils.php
new file mode 100644
index 00000000..97c602dd
--- /dev/null
+++ b/tools/chorale/src/Util/PathUtils.php
@@ -0,0 +1,92 @@
+ 'src/Foo'
+ * - isUnder('src/Acme/Lib', 'src') => true
+ * - match('src/* /Lib', 'src/Acme/Lib') => true (single-star within one segment)
+ * - match('src/** /Lib', 'src/a/b/c/Lib') => true (double-star across directories)
+ * - leaf('src/Acme/Lib') => 'Lib'
+ */
+final class PathUtils implements PathUtilsInterface
+{
+ public function normalize(string $path): string
+ {
+ $p = str_replace('\\', '/', $path);
+ // remove multiple slashes
+ $p = preg_replace('#/+#', '/', $p) ?? $p;
+ // remove trailing slash (except root '/')
+ if ($p !== '/' && str_ends_with($p, '/')) {
+ $p = rtrim($p, '/');
+ }
+
+ // resolve "." and ".." cheaply (string-level, not FS)
+ $parts = [];
+ foreach (explode('/', $p) as $seg) {
+ if ($seg === '') {
+ continue;
+ }
+ if ($seg === '.') {
+ continue;
+ }
+
+ if ($seg === '..') {
+ array_pop($parts);
+ continue;
+ }
+
+ $parts[] = $seg;
+ }
+
+ $out = implode('/', $parts);
+ return $out === '' ? '.' : $out;
+ }
+
+ public function isUnder(string $path, string $root): bool
+ {
+ $p = $this->normalize($path);
+ $r = $this->normalize($root);
+ return $p === $r || str_starts_with($p, $r . '/');
+ }
+
+ public function match(string $pattern, string $path): bool
+ {
+ $pat = $this->normalize($pattern);
+ $pth = $this->normalize($path);
+
+ // Split into tokens while keeping the delimiters (** , * , ?)
+ $parts = preg_split('/(\*\*|\*|\?)/', $pat, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
+ if ($parts === false) {
+ return false;
+ }
+
+ $regex = '';
+ foreach ($parts as $part) {
+ if ($part === '**') {
+ $regex .= '.*'; // can cross slashes, zero or more
+ } elseif ($part === '*') {
+ $regex .= '[^/]*'; // single segment
+ } elseif ($part === '?') {
+ $regex .= '[^/]'; // one char in a segment
+ } else {
+ $regex .= preg_quote($part, '#'); // literal
+ }
+ }
+
+ // full-string, case-sensitive; add 'i' if you want case-insensitive
+ return (bool) preg_match('#^' . $regex . '$#u', $pth);
+ }
+
+ public function leaf(string $path): string
+ {
+ $p = $this->normalize($path);
+ $pos = strrpos($p, '/');
+ return $pos === false ? $p : substr($p, $pos + 1);
+ }
+}
diff --git a/tools/chorale/src/Util/PathUtilsInterface.php b/tools/chorale/src/Util/PathUtilsInterface.php
new file mode 100644
index 00000000..b392528b
--- /dev/null
+++ b/tools/chorale/src/Util/PathUtilsInterface.php
@@ -0,0 +1,26 @@
+ 'a/b/c' first (more specific)
+ * - sortTargets([{path:'b',name:'x'},{path:'a',name:'z'}]) => path 'a' first; ties break by name
+ */
+final class Sorting implements SortingInterface
+{
+ public function sortPatterns(array $patterns): array
+ {
+ usort($patterns, static function (array $a, array $b): int {
+ $am = (string) ($a['match'] ?? '');
+ $bm = (string) ($b['match'] ?? '');
+ $al = strlen($am);
+ $bl = strlen($bm);
+ if ($al === $bl) {
+ return $am <=> $bm;
+ }
+
+ // longer match first (more specific wins)
+ return $bl <=> $al;
+ });
+
+ return $patterns;
+ }
+
+ public function sortTargets(array $targets): array
+ {
+ usort($targets, static function (array $a, array $b): int {
+ $ap = (string) ($a['path'] ?? '');
+ $bp = (string) ($b['path'] ?? '');
+ if ($ap === $bp) {
+ $an = (string) ($a['name'] ?? '');
+ $bn = (string) ($b['name'] ?? '');
+ return $an <=> $bn;
+ }
+
+ return $ap <=> $bp;
+ });
+
+ return $targets;
+ }
+}
diff --git a/tools/chorale/src/Util/SortingInterface.php b/tools/chorale/src/Util/SortingInterface.php
new file mode 100644
index 00000000..ba51c4ee
--- /dev/null
+++ b/tools/chorale/src/Util/SortingInterface.php
@@ -0,0 +1,22 @@
+> $patterns
+ * @return array>
+ */
+ public function sortPatterns(array $patterns): array;
+
+ /**
+ * Sort targets by path asc (normalized), then by name.
+ * @param array> $targets
+ * @return array>
+ */
+ public function sortTargets(array $targets): array;
+}