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; +}