From 97ae0b7fea8a02675fa8f3c59dd76f99f4069da7 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Fri, 15 Aug 2025 14:19:18 -0400 Subject: [PATCH 01/10] Chorale initial commit --- .gitignore | 1 + tools/chorale/bin/chorale | 68 +++ tools/chorale/composer.json | 35 ++ tools/chorale/phpunit.xml.dist | 47 ++ tools/chorale/src/Config/ConfigDefaults.php | 52 ++ .../src/Config/ConfigDefaultsInterface.php | 23 + tools/chorale/src/Config/ConfigLoader.php | 28 + .../src/Config/ConfigLoaderInterface.php | 11 + tools/chorale/src/Config/ConfigNormalizer.php | 70 +++ .../src/Config/ConfigNormalizerInterface.php | 18 + tools/chorale/src/Config/ConfigWriter.php | 35 ++ .../src/Config/ConfigWriterInterface.php | 14 + tools/chorale/src/Config/SchemaValidator.php | 88 +++ .../src/Config/SchemaValidatorInterface.php | 17 + tools/chorale/src/Console/SetupCommand.php | 561 ++++++++++++++++++ .../src/Console/Style/ConsoleStyleFactory.php | 36 ++ tools/chorale/src/Diff/ConfigDiffer.php | 165 ++++++ .../src/Diff/ConfigDifferInterface.php | 20 + .../src/Discovery/ComposerMetadata.php | 26 + .../Discovery/ComposerMetadataInterface.php | 11 + .../chorale/src/Discovery/PackageIdentity.php | 24 + .../Discovery/PackageIdentityInterface.php | 17 + .../chorale/src/Discovery/PackageScanner.php | 96 +++ .../src/Discovery/PackageScannerInterface.php | 17 + .../chorale/src/Discovery/PatternMatcher.php | 37 ++ .../src/Discovery/PatternMatcherInterface.php | 21 + tools/chorale/src/IO/BackupManager.php | 46 ++ .../chorale/src/IO/BackupManagerInterface.php | 19 + tools/chorale/src/IO/JsonReporter.php | 23 + .../chorale/src/IO/JsonReporterInterface.php | 15 + tools/chorale/src/Repo/RepoResolver.php | 37 ++ .../src/Repo/RepoResolverInterface.php | 20 + tools/chorale/src/Repo/TemplateRenderer.php | 164 +++++ .../src/Repo/TemplateRendererInterface.php | 28 + tools/chorale/src/Rules/ConflictDetector.php | 20 + .../src/Rules/ConflictDetectorInterface.php | 15 + .../src/Rules/RequiredFilesChecker.php | 23 + .../Rules/RequiredFilesCheckerInterface.php | 15 + tools/chorale/src/Telemetry/RunSummary.php | 25 + .../src/Telemetry/RunSummaryInterface.php | 13 + .../src/Tests/Config/ConfigDefaultsTest.php | 49 ++ .../src/Tests/Config/ConfigLoaderTest.php | 38 ++ .../src/Tests/Config/ConfigNormalizerTest.php | 71 +++ .../src/Tests/Config/ConfigWriterTest.php | 48 ++ .../src/Tests/Config/SchemaValidatorTest.php | 65 ++ .../Tests/Discovery/PackageIdentityTest.php | 33 ++ .../Tests/Discovery/PackageScannerTest.php | 50 ++ .../Tests/Discovery/PatternMatcherTest.php | 64 ++ .../src/Tests/IO/BackupManagerTest.php | 41 ++ .../chorale/src/Tests/IO/JsonReporterTest.php | 33 ++ .../src/Tests/Repo/RepoResolverTest.php | 50 ++ .../src/Tests/Repo/TemplateRendererTest.php | 89 +++ .../src/Tests/Rules/ConflictDetectorTest.php | 66 +++ .../Tests/Rules/RequiredFilesCheckerTest.php | 48 ++ .../src/Tests/Telemetry/RunSummaryTest.php | 35 ++ .../chorale/src/Tests/Util/PathUtilsTest.php | 109 ++++ tools/chorale/src/Tests/Util/SortingTest.php | 65 ++ tools/chorale/src/Util/PathUtils.php | 75 +++ tools/chorale/src/Util/PathUtilsInterface.php | 26 + tools/chorale/src/Util/Sorting.php | 41 ++ tools/chorale/src/Util/SortingInterface.php | 22 + 61 files changed, 3119 insertions(+) create mode 100755 tools/chorale/bin/chorale create mode 100644 tools/chorale/composer.json create mode 100644 tools/chorale/phpunit.xml.dist create mode 100644 tools/chorale/src/Config/ConfigDefaults.php create mode 100644 tools/chorale/src/Config/ConfigDefaultsInterface.php create mode 100644 tools/chorale/src/Config/ConfigLoader.php create mode 100644 tools/chorale/src/Config/ConfigLoaderInterface.php create mode 100644 tools/chorale/src/Config/ConfigNormalizer.php create mode 100644 tools/chorale/src/Config/ConfigNormalizerInterface.php create mode 100644 tools/chorale/src/Config/ConfigWriter.php create mode 100644 tools/chorale/src/Config/ConfigWriterInterface.php create mode 100644 tools/chorale/src/Config/SchemaValidator.php create mode 100644 tools/chorale/src/Config/SchemaValidatorInterface.php create mode 100644 tools/chorale/src/Console/SetupCommand.php create mode 100644 tools/chorale/src/Console/Style/ConsoleStyleFactory.php create mode 100644 tools/chorale/src/Diff/ConfigDiffer.php create mode 100644 tools/chorale/src/Diff/ConfigDifferInterface.php create mode 100644 tools/chorale/src/Discovery/ComposerMetadata.php create mode 100644 tools/chorale/src/Discovery/ComposerMetadataInterface.php create mode 100644 tools/chorale/src/Discovery/PackageIdentity.php create mode 100644 tools/chorale/src/Discovery/PackageIdentityInterface.php create mode 100644 tools/chorale/src/Discovery/PackageScanner.php create mode 100644 tools/chorale/src/Discovery/PackageScannerInterface.php create mode 100644 tools/chorale/src/Discovery/PatternMatcher.php create mode 100644 tools/chorale/src/Discovery/PatternMatcherInterface.php create mode 100644 tools/chorale/src/IO/BackupManager.php create mode 100644 tools/chorale/src/IO/BackupManagerInterface.php create mode 100644 tools/chorale/src/IO/JsonReporter.php create mode 100644 tools/chorale/src/IO/JsonReporterInterface.php create mode 100644 tools/chorale/src/Repo/RepoResolver.php create mode 100644 tools/chorale/src/Repo/RepoResolverInterface.php create mode 100644 tools/chorale/src/Repo/TemplateRenderer.php create mode 100644 tools/chorale/src/Repo/TemplateRendererInterface.php create mode 100644 tools/chorale/src/Rules/ConflictDetector.php create mode 100644 tools/chorale/src/Rules/ConflictDetectorInterface.php create mode 100644 tools/chorale/src/Rules/RequiredFilesChecker.php create mode 100644 tools/chorale/src/Rules/RequiredFilesCheckerInterface.php create mode 100644 tools/chorale/src/Telemetry/RunSummary.php create mode 100644 tools/chorale/src/Telemetry/RunSummaryInterface.php create mode 100644 tools/chorale/src/Tests/Config/ConfigDefaultsTest.php create mode 100644 tools/chorale/src/Tests/Config/ConfigLoaderTest.php create mode 100644 tools/chorale/src/Tests/Config/ConfigNormalizerTest.php create mode 100644 tools/chorale/src/Tests/Config/ConfigWriterTest.php create mode 100644 tools/chorale/src/Tests/Config/SchemaValidatorTest.php create mode 100644 tools/chorale/src/Tests/Discovery/PackageIdentityTest.php create mode 100644 tools/chorale/src/Tests/Discovery/PackageScannerTest.php create mode 100644 tools/chorale/src/Tests/Discovery/PatternMatcherTest.php create mode 100644 tools/chorale/src/Tests/IO/BackupManagerTest.php create mode 100644 tools/chorale/src/Tests/IO/JsonReporterTest.php create mode 100644 tools/chorale/src/Tests/Repo/RepoResolverTest.php create mode 100644 tools/chorale/src/Tests/Repo/TemplateRendererTest.php create mode 100644 tools/chorale/src/Tests/Rules/ConflictDetectorTest.php create mode 100644 tools/chorale/src/Tests/Rules/RequiredFilesCheckerTest.php create mode 100644 tools/chorale/src/Tests/Telemetry/RunSummaryTest.php create mode 100644 tools/chorale/src/Tests/Util/PathUtilsTest.php create mode 100644 tools/chorale/src/Tests/Util/SortingTest.php create mode 100644 tools/chorale/src/Util/PathUtils.php create mode 100644 tools/chorale/src/Util/PathUtilsInterface.php create mode 100644 tools/chorale/src/Util/Sorting.php create mode 100644 tools/chorale/src/Util/SortingInterface.php diff --git a/.gitignore b/.gitignore index f6525a7a..551d82a8 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ packages.json results.sarif infection.log .churn.cache +tools/chorale/composer.lock diff --git a/tools/chorale/bin/chorale b/tools/chorale/bin/chorale new file mode 100755 index 00000000..d77644e5 --- /dev/null +++ b/tools/chorale/bin/chorale @@ -0,0 +1,68 @@ +#!/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->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/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/SetupCommand.php b/tools/chorale/src/Console/SetupCommand.php new file mode 100644 index 00000000..b8f0dc6c --- /dev/null +++ b/tools/chorale/src/Console/SetupCommand.php @@ -0,0 +1,561 @@ +setName('setup') + ->setDescription('Create or update chorale.yaml by scanning src/ and applying defaults.') + ->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, \Symfony\Component\Console\Output\OutputInterface $output): int + { + $io = $this->styleFactory->create($input, $output); + $opts = $this->gatherOptions($input); + + [$config, $firstRun] = $this->loadOrSeedConfig($opts['root']); + + if ($msgs = $this->validateSchema($config, $opts['strict'])) { + $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 + )); + 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, bool $strict): array + { + // Keeping this simple (we already do type checks in SchemaValidator) + return $this->schemaValidator->validate($config, 'tools/chorale/config/chorale.schema.yaml'); + } + + /** @return list */ + private function discoverPaths(string $root, array $paths): array + { + return $this->scanner->scan($root, $paths); + } + + 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 ?: ['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($root, $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) { + $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) { + $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 $root, 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($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) => sprintf('%s → %s (no matching pattern)', $r['path'], $r['repo'])); + $this->printGroup($io, 'RENAMED', $groups['renamed'], fn(array $r) => 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) => sprintf('%s (missing: %s)', $r['path'], implode(', ', (array) ($r['missing'] ?? [])))); + $this->printGroup($io, 'CONFLICTS', $groups['conflicts'], fn(array $r) => 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("{$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..1a5e2666 --- /dev/null +++ b/tools/chorale/src/Diff/ConfigDiffer.php @@ -0,0 +1,165 @@ +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( + dirname($pkgPath, 0) === '' ? '.' : '.', // projectRoot filled by caller in practice + // accurate compute: rely on caller to pass real root; here use relative + // We'll let SetupCommand pass real root; for now, accept relative usage. + 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->normalize($baseDir); + if (!is_dir($base)) { + 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($base, \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($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..a2eca835 --- /dev/null +++ b/tools/chorale/src/Discovery/PatternMatcher.php @@ -0,0 +1,37 @@ + $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/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..5245844b --- /dev/null +++ b/tools/chorale/src/Repo/TemplateRenderer.php @@ -0,0 +1,164 @@ + */ + 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_][a-zA-Z0-9_]*)(?::([a-zA-Z:]+))?\}/', + function (array $m) use ($vars): string { + $var = $m[1]; + $filters = isset($m[2]) ? explode(':', $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("Unknown filter '{$f}'"); + } + $value = $fn($value); + } + return $value; + }, + $out + ); + if ($next === null) { + // regex error; fall back to current output + break; + } + if ($next === $out || !preg_match('/\{[a-zA-Z_][a-zA-Z0-9_]*(?::[a-zA-Z:]+)?\}/', $next)) { + $out = $next; + break; // stabilized or no more placeholders + } + $out = $next; + } + return $out; + } + + public function validate(string $template): array + { + $issues = []; + if ($template === '') { + return $issues; + } + + if (!preg_match_all('/\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([a-zA-Z:]+))?\}/', $template, $matches, \PREG_SET_ORDER)) { + return $issues; + } + + foreach ($matches as $match) { + $var = $match[1]; + $filterStr = $match[2] ?? ''; + + if (!isset($this->allowedVars[$var])) { + $issues[] = "Unknown placeholder '{$var}'"; + } + + if ($filterStr !== '') { + foreach (explode(':', $filterStr) as $f) { + if ($f === '') { + continue; + } + if (!isset($this->filters[$f])) { + $issues[] = "Unknown filter '{$f}' for '{$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/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/Config/ConfigDefaultsTest.php b/tools/chorale/src/Tests/Config/ConfigDefaultsTest.php new file mode 100644 index 00000000..1870e152 --- /dev/null +++ b/tools/chorale/src/Tests/Config/ConfigDefaultsTest.php @@ -0,0 +1,49 @@ +resolve([]); + self::assertSame('git@github.com', $out['repo_host']); + } + + #[Test] + public function testResolveMergesRules(): void + { + $d = new ConfigDefaults(); + $out = $d->resolve(['rules' => ['keep_history' => false]]); + self::assertFalse($out['rules']['keep_history']); + } + + #[Test] + public function testResolveComputesDefaultRepoTemplateWhenNotProvided(): void + { + $d = new ConfigDefaults(); + $out = $d->resolve(['repo_vendor' => 'Acme']); + self::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}']); + self::assertSame('x:{y}/{z}', $out['default_repo_template']); + } +} diff --git a/tools/chorale/src/Tests/Config/ConfigLoaderTest.php b/tools/chorale/src/Tests/Config/ConfigLoaderTest.php new file mode 100644 index 00000000..ad5275de --- /dev/null +++ b/tools/chorale/src/Tests/Config/ConfigLoaderTest.php @@ -0,0 +1,38 @@ +load($dir); + self::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); + self::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..e9800624 --- /dev/null +++ b/tools/chorale/src/Tests/Config/ConfigNormalizerTest.php @@ -0,0 +1,71 @@ +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) => $a); + $this->sorting->method('sortTargets')->willReturnCallback(fn(array $a) => $a); + } + + public function testRedundantPatternOverrideIsRemoved(): void + { + $n = new ConfigNormalizer($this->sorting, $this->defaults); + $out = $n->normalize(['patterns' => [['match' => 'src/*', 'repo_host' => 'git@github.com']]]); + self::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']]]); + self::assertArrayNotHasKey('repo_vendor', $out['targets'][0]); + } + + #[Test] + public function testTopLevelDefaultsCopied(): void + { + $n = new ConfigNormalizer($this->sorting, $this->defaults); + $out = $n->normalize([]); + self::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..16983cad --- /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(self::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]); + self::assertFileExists($dir . '/conf.yaml'); + } + + #[Test] + public function testWriteThrowsWhenTempFileCannotBeWritten(): void + { + $this->backup->expects(self::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..f4275f4c --- /dev/null +++ b/tools/chorale/src/Tests/Config/SchemaValidatorTest.php @@ -0,0 +1,65 @@ +validate(['repo_host' => 123], '/unused'); + self::assertContains("Key 'repo_host' must be a string.", $issues); + } + + #[Test] + public function testValidateRejectsRulesNotArray(): void + { + $v = new SchemaValidator(); + $issues = $v->validate(['rules' => 'x'], '/unused'); + self::assertContains("Key 'rules' must be an array.", $issues); + } + + #[Test] + public function testValidateRejectsKeepHistoryNotBool(): void + { + $v = new SchemaValidator(); + $issues = $v->validate(['rules' => ['keep_history' => 'no']], '/unused'); + self::assertContains('rules.keep_history must be a boolean.', $issues); + } + + #[Test] + public function testValidateRejectsPatternsNotArray(): void + { + $v = new SchemaValidator(); + $issues = $v->validate(['patterns' => 'x'], '/unused'); + self::assertContains("Key 'patterns' must be a list.", $issues); + } + + #[Test] + public function testValidateRejectsPatternMissingMatch(): void + { + $v = new SchemaValidator(); + $issues = $v->validate(['patterns' => [[]]], '/unused'); + self::assertContains('patterns[0].match must be a string.', $issues); + } + + #[Test] + public function testValidateRejectsTargetsFieldTypes(): void + { + $v = new SchemaValidator(); + $issues = $v->validate(['targets' => [['name' => 1]]], '/unused'); + self::assertContains('targets[0].name must be a string.', $issues); + } +} diff --git a/tools/chorale/src/Tests/Discovery/PackageIdentityTest.php b/tools/chorale/src/Tests/Discovery/PackageIdentityTest.php new file mode 100644 index 00000000..23d00abf --- /dev/null +++ b/tools/chorale/src/Tests/Discovery/PackageIdentityTest.php @@ -0,0 +1,33 @@ +identityFor('unused', 'SSH://GitHub.com/SonsOfPHP/Cookie.git'); + self::assertSame('github.com/sonsofphp/cookie.git', $id); + } + + #[Test] + public function testIdentityFallsBackToLeaf(): void + { + $pi = new PackageIdentity(); + $id = $pi->identityFor('src/SonsOfPHP/Cookie'); + self::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..93389c3f --- /dev/null +++ b/tools/chorale/src/Tests/Discovery/PackageScannerTest.php @@ -0,0 +1,50 @@ += 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); + return $root; + } + + #[Test] + public function testScanFindsLeafPackages(): void + { + $root = $this->makeProject(); + $ps = new PackageScanner(new PathUtils()); + $paths = $ps->scan($root); + self::assertContains('src/SonsOfPHP/Cookie', $paths); + } + + #[Test] + public function testScanRespectsProvidedPaths(): void + { + $root = $this->makeProject(); + $ps = new PackageScanner(new PathUtils()); + $paths = $ps->scan($root, ['src/SonsOfPHP/Cookie']); + self::assertSame(['src/SonsOfPHP/Cookie'], $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..ca091e13 --- /dev/null +++ b/tools/chorale/src/Tests/Discovery/PatternMatcherTest.php @@ -0,0 +1,64 @@ +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) => $pat === 'src/*/Cookie')); + $idx = $pm->firstMatch([ + ['match' => 'src/*/Cookie'], + ], 'src/SonsOfPHP/Cookie'); + self::assertSame(0, $idx); + } + + #[Test] + public function testAllMatchesReturnsAllIndexes(): void + { + $pm = new PatternMatcher($this->stubPaths(fn($pat, $path) => in_array($pat, ['src/*/Cookie','src/SonsOfPHP/*'], true))); + $idx = $pm->allMatches([ + ['match' => 'src/*/Cookie'], + ['match' => 'src/SonsOfPHP/*'], + ], 'src/SonsOfPHP/Cookie'); + self::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..54f12bbe --- /dev/null +++ b/tools/chorale/src/Tests/IO/BackupManagerTest.php @@ -0,0 +1,41 @@ +backup($dir . '/file.yaml'); + self::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); + self::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..2e0b12f7 --- /dev/null +++ b/tools/chorale/src/Tests/IO/JsonReporterTest.php @@ -0,0 +1,33 @@ +build(['a' => 'b'], ['new' => []], [['action' => 'none']]); + self::assertStringEndsWith("\n", $json); + } + + #[Test] + public function testBuildIncludesDefaultsKey(): void + { + $jr = new JsonReporter(); + $json = $jr->build(['a' => 'b'], ['new' => []], [['action' => 'none']]); + self::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..62cd7e5a --- /dev/null +++ b/tools/chorale/src/Tests/Repo/RepoResolverTest.php @@ -0,0 +1,50 @@ + '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'); + self::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'); + self::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'); + self::assertSame('git@github.com:Acme/cookie.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..125a02a1 --- /dev/null +++ b/tools/chorale/src/Tests/Repo/TemplateRendererTest.php @@ -0,0 +1,89 @@ +validate('x/{unknown}'); + self::assertContains("Unknown placeholder 'unknown'", $issues); + } + + #[Test] + public function testValidateDetectsUnknownFilter(): void + { + $r = new TemplateRenderer(); + $issues = $r->validate('x/{name:oops}'); + self::assertContains("Unknown filter 'oops' for 'name'", $issues); + } + + #[Test] + public function testRenderAppliesLowerFilter(): void + { + $r = new TemplateRenderer(); + $out = $r->render('{name:lower}', ['name' => 'Cookie']); + self::assertSame('cookie', $out); + } + + #[Test] + public function testRenderAppliesUpperFilter(): void + { + $r = new TemplateRenderer(); + $out = $r->render('{name:upper}', ['name' => 'Cookie']); + self::assertSame('COOKIE', $out); + } + + #[Test] + public function testRenderKebabFilter(): void + { + $r = new TemplateRenderer(); + $out = $r->render('{name:kebab}', ['name' => 'My Cookie_Package']); + self::assertSame('my-cookie-package', $out); + } + + #[Test] + public function testRenderSnakeFilter(): void + { + $r = new TemplateRenderer(); + $out = $r->render('{name:snake}', ['name' => 'My Cookie-Package']); + self::assertSame('my_cookie_package', $out); + } + + #[Test] + public function testRenderCamelFilter(): void + { + $r = new TemplateRenderer(); + $out = $r->render('{name:camel}', ['name' => 'my-cookie package']); + self::assertSame('myCookiePackage', $out); + } + + #[Test] + public function testRenderPascalFilter(): void + { + $r = new TemplateRenderer(); + $out = $r->render('{name:pascal}', ['name' => 'my-cookie package']); + self::assertSame('MyCookiePackage', $out); + } + + #[Test] + public function testRenderDotFilter(): void + { + $r = new TemplateRenderer(); + $out = $r->render('{name:dot}', ['name' => 'my cookie-package']); + self::assertSame('my.cookie.package', $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..868be7c3 --- /dev/null +++ b/tools/chorale/src/Tests/Rules/ConflictDetectorTest.php @@ -0,0 +1,66 @@ +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) => in_array($pat, ['src/*/Cookie','src/SonsOfPHP/*'], true)))); + $res = $cd->detect([ + ['match' => 'src/*/Cookie'], + ['match' => 'src/SonsOfPHP/*'], + ], 'src/SonsOfPHP/Cookie'); + self::assertTrue($res['conflict']); + } + + #[Test] + public function testDetectReturnsMatchedIndexes(): void + { + $cd = new ConflictDetector(new PatternMatcher($this->stubPaths(fn($pat, $p) => in_array($pat, ['src/*/Cookie','src/SonsOfPHP/*'], true)))); + $res = $cd->detect([ + ['match' => 'src/*/Cookie'], + ['match' => 'src/SonsOfPHP/*'], + ], 'src/SonsOfPHP/Cookie'); + self::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..271ce934 --- /dev/null +++ b/tools/chorale/src/Tests/Rules/RequiredFilesCheckerTest.php @@ -0,0 +1,48 @@ +makePackage(true); + $c = new RequiredFilesChecker(); + $miss = $c->missing($root, $pkg, ['composer.json','LICENSE']); + self::assertSame([], $miss); + } + + #[Test] + public function testMissingReturnsListOfMissing(): void + { + [$root, $pkg] = $this->makePackage(false); + $c = new RequiredFilesChecker(); + $miss = $c->missing($root, $pkg, ['composer.json','LICENSE']); + self::assertSame(['composer.json','LICENSE'], $miss); + } +} diff --git a/tools/chorale/src/Tests/Telemetry/RunSummaryTest.php b/tools/chorale/src/Tests/Telemetry/RunSummaryTest.php new file mode 100644 index 00000000..951a76fe --- /dev/null +++ b/tools/chorale/src/Tests/Telemetry/RunSummaryTest.php @@ -0,0 +1,35 @@ +inc('new'); + self::assertSame(['new' => 1], $rs->all()); + } + + #[Test] + public function testAllReturnsSortedKeys(): void + { + $rs = new RunSummary(); + $rs->inc('z'); + $rs->inc('a'); + $all = $rs->all(); + self::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..1d04507d --- /dev/null +++ b/tools/chorale/src/Tests/Util/PathUtilsTest.php @@ -0,0 +1,109 @@ +u = new PathUtils(); + } + + #[Test] + public function testNormalizeConvertsBackslashes(): void + { + self::assertSame('a/b', $this->u->normalize('a\\b')); + } + + #[Test] + public function testNormalizeCollapsesMultipleSlashes(): void + { + self::assertSame('a/b', $this->u->normalize('a////b')); + } + + #[Test] + public function testNormalizeRemovesTrailingSlash(): void + { + self::assertSame('a', $this->u->normalize('a/')); + } + + #[Test] + public function testNormalizeRootSlashStays(): void + { + self::assertSame('.', $this->u->normalize('/..')); + } + + #[Test] + public function testNormalizeResolvesDotSegments(): void + { + self::assertSame('a/b', $this->u->normalize('./a/./b')); + } + + #[Test] + public function testNormalizeResolvesDotDotSegments(): void + { + self::assertSame('a', $this->u->normalize('a/b/..')); + } + + #[Test] + public function testIsUnderTrueForSamePath(): void + { + self::assertTrue($this->u->isUnder('a/b', 'a/b')); + } + + #[Test] + public function testIsUnderTrueForChildPath(): void + { + self::assertTrue($this->u->isUnder('a/b/c', 'a/b')); + } + + #[Test] + public function testIsUnderFalseForSiblingPath(): void + { + self::assertFalse($this->u->isUnder('a/c', 'a/b')); + } + + #[Test] + public function testMatchAsteriskPatternCurrentlyDoesNotMatch(): void + { + self::assertFalse($this->u->match('src/*/Cookie', 'src/SonsOfPHP/Cookie')); + } + + #[Test] + public function testMatchQuestionMarkPatternCurrentlyDoesNotMatch(): void + { + self::assertFalse($this->u->match('src/SonsOfPHP/Cooki?', 'src/SonsOfPHP/Cookie')); + } + + #[Test] + public function testMatchExactPathWithDotsCurrentlyDoesNotMatch(): void + { + self::assertFalse($this->u->match('src/Sons.OfPHP/Cookie', 'src/Sons.OfPHP/Cookie')); + } + + #[Test] + public function testLeafReturnsLastSegment(): void + { + self::assertSame('Cookie', $this->u->leaf('src/SonsOfPHP/Cookie')); + } + + #[Test] + public function testLeafReturnsWholeWhenNoSeparator(): void + { + self::assertSame('Cookie', $this->u->leaf('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..ebcf3985 --- /dev/null +++ b/tools/chorale/src/Tests/Util/SortingTest.php @@ -0,0 +1,65 @@ + 'a/b'], + ['match' => 'a/b/c'], + ]; + $out = $s->sortPatterns($in); + self::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); + self::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); + self::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); + self::assertSame('a', $out[0]['name']); + } +} diff --git a/tools/chorale/src/Util/PathUtils.php b/tools/chorale/src/Util/PathUtils.php new file mode 100644 index 00000000..02686ed8 --- /dev/null +++ b/tools/chorale/src/Util/PathUtils.php @@ -0,0 +1,75 @@ +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 @@ + $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; +} From 779bc87787a1097931b8a4de1ad25542a1d8ed72 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Fri, 15 Aug 2025 14:26:35 -0400 Subject: [PATCH 02/10] updates --- chorale.yaml | 19 +++++++ rector.php | 2 +- tools/chorale/src/Console/SetupCommand.php | 58 ++++++++++++---------- 3 files changed, 53 insertions(+), 26 deletions(-) create mode 100644 chorale.yaml 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/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/src/Console/SetupCommand.php b/tools/chorale/src/Console/SetupCommand.php index b8f0dc6c..a4cbea8c 100644 --- a/tools/chorale/src/Console/SetupCommand.php +++ b/tools/chorale/src/Console/SetupCommand.php @@ -16,13 +16,12 @@ use Chorale\Discovery\PatternMatcherInterface; use Chorale\IO\JsonReporterInterface; use Chorale\Repo\RepoResolverInterface; -use Chorale\Rules\ConflictDetectorInterface; use Chorale\Rules\RequiredFilesCheckerInterface; use Chorale\Telemetry\RunSummaryInterface; -use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Style\SymfonyStyle; @@ -30,6 +29,7 @@ final class SetupCommand extends Command { protected static $defaultName = 'setup'; + protected static $defaultDescription = 'Create or update chorale.yaml by scanning src/ and applying defaults.'; public function __construct( @@ -44,7 +44,6 @@ public function __construct( private readonly RepoResolverInterface $resolver, private readonly PackageIdentityInterface $identity, private readonly RequiredFilesCheckerInterface $requiredFiles, - private readonly ConflictDetectorInterface $conflicts, private readonly JsonReporterInterface $jsonReporter, private readonly RunSummaryInterface $summary, private readonly ComposerMetadataInterface $composerMeta, @@ -70,14 +69,14 @@ protected function configure(): void // ───────────────────────────────────────────────────────────────────────────── // Orchestrator // ───────────────────────────────────────────────────────────────────────────── - protected function execute(InputInterface $input, \Symfony\Component\Console\Output\OutputInterface $output): int + 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, $opts['strict'])) { + if (($msgs = $this->validateSchema($config)) !== []) { $this->printIssues($io, $msgs); if ($opts['strict']) { $io->error('Strict mode: schema validation failed.'); @@ -101,6 +100,7 @@ protected function execute(InputInterface $input, \Symfony\Component\Console\Out $found = $this->scanner->scan($opts['root'], $r, $opts['paths']); $discPaths = array_merge($discPaths, $found); } + $discPaths = array_values(array_unique($discPaths)); sort($discPaths); @@ -155,6 +155,7 @@ protected function execute(InputInterface $input, \Symfony\Component\Console\Out $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; } @@ -227,18 +228,12 @@ private function loadOrSeedConfig(string $root): array } /** @return list */ - private function validateSchema(array $config, bool $strict): array + 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'); } - /** @return list */ - private function discoverPaths(string $root, array $paths): array - { - return $this->scanner->scan($root, $paths); - } - private function printIssues(SymfonyStyle $io, array $messages): void { foreach ($messages as $m) { @@ -251,6 +246,7 @@ 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); @@ -260,20 +256,22 @@ private function confirmWrite(SymfonyStyle $io, array $opts): bool /** @return list e.g. ["src","packages"] */ private function determineRoots(array $patterns, string $root): array { - if ($patterns) { + 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 ?: ['src']; // safe fallback if patterns are odd + + return $roots !== [] ? $roots : ['src']; // safe fallback if patterns are odd } // First run: probe both src and packages @@ -284,6 +282,7 @@ private function determineRoots(array $patterns, string $root): array $roots[] = $cand; } } + return $roots; } @@ -297,6 +296,7 @@ private function displayNameFor(string $projectRoot, string $pkgPath): string $last = str_contains($name, '/') ? substr($name, strrpos($name, '/') + 1) : $name; return $last; } + return basename($pkgPath); } @@ -330,7 +330,7 @@ private function classifyAll(string $root, array $defaults, array $patterns, arr 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($root, $oldPath, $target, $defaults, $patterns, $discovered); + $maybe = $this->findRenameTarget($oldPath, $target, $defaults, $patterns, $discovered); if ($maybe !== null) { $groups['renamed'][] = $maybe; } @@ -370,11 +370,13 @@ private function classifyOne(string $root, string $pkgPath, array $defaults, arr // 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) { + if ($conflictData !== null && $conflictData !== []) { $ok['conflict'] = $conflictData['patterns']; } + return ['group' => 'ok', 'data' => $ok]; } @@ -399,9 +401,10 @@ private function classifyOne(string $root, string $pkgPath, array $defaults, arr $ok = ['path' => $pkgPath, 'repo' => $repo, 'package' => $pkgName]; - if ($conflictData) { + if ($conflictData !== null && $conflictData !== []) { $ok['conflict'] = $conflictData['patterns']; } + return ['group' => 'ok', 'data' => $ok]; } @@ -413,7 +416,7 @@ private function classifyOne(string $root, string $pkgPath, array $defaults, arr * @param list $discovered * @return array|null */ - private function findRenameTarget(string $root, string $oldPath, array $target, array $defaults, array $patterns, array $discovered): ?array + 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); @@ -430,6 +433,7 @@ private function findRenameTarget(string $root, string $oldPath, array $target, ]; } } + return null; } @@ -464,7 +468,7 @@ private function buildActions(array $groups, array $existingTargets): array 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($row['path'])]; + $actions[] = ['type' => 'add-target', 'path' => $row['path'], 'name' => basename((string) $row['path'])]; } foreach ($groups['renamed'] as $row) { @@ -512,6 +516,7 @@ private function applyActions(array $config, array $actions): array break; } } + unset($t); } } @@ -533,18 +538,19 @@ 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)' : ''; + $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) => sprintf('%s → %s (no matching pattern)', $r['path'], $r['repo'])); - $this->printGroup($io, 'RENAMED', $groups['renamed'], fn(array $r) => sprintf('%s → %s', $r['from'], $r['to'])); + $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) => sprintf('%s (missing: %s)', $r['path'], implode(', ', (array) ($r['missing'] ?? [])))); - $this->printGroup($io, 'CONFLICTS', $groups['conflicts'], fn(array $r) => sprintf('%s (patterns: %s)', $r['path'], implode(', ', (array) ($r['patterns'] ?? [])))); + $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 @@ -552,10 +558,12 @@ private function printGroup(SymfonyStyle $io, string $title, array $rows, callab if ($rows === []) { return; } - $io->writeln("{$title}"); + + $io->writeln(sprintf('%s', $title)); foreach ($rows as $r) { $io->writeln(' • ' . $fmt($r)); } + $io->newLine(); } } From ccb7f95a566f8580a21ce1d73ba6adce7be0cbdb Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Fri, 15 Aug 2025 20:12:16 -0400 Subject: [PATCH 03/10] plan command --- tools/chorale/bin/chorale | 73 ++++- .../src/Composer/ComposerJsonReader.php | 23 ++ .../Composer/ComposerJsonReaderInterface.php | 11 + .../chorale/src/Composer/DependencyMerger.php | 23 ++ .../Composer/DependencyMergerInterface.php | 17 ++ tools/chorale/src/Composer/RuleEngine.php | 20 ++ .../src/Composer/RuleEngineInterface.php | 20 ++ tools/chorale/src/Console/PlanCommand.php | 183 +++++++++++++ tools/chorale/src/Console/SetupCommand.php | 5 - .../src/Plan/ComposerRootRebuildStep.php | 35 +++ .../src/Plan/ComposerRootUpdateStep.php | 47 ++++ .../src/Plan/PackageMetadataSyncStep.php | 43 +++ tools/chorale/src/Plan/PlanBuilder.php | 255 ++++++++++++++++++ .../chorale/src/Plan/PlanBuilderInterface.php | 19 ++ tools/chorale/src/Plan/PlanStepInterface.php | 20 ++ .../src/Plan/RootDependencyMergeStep.php | 42 +++ tools/chorale/src/Plan/SplitStep.php | 47 ++++ tools/chorale/src/Plan/VersionUpdateStep.php | 36 +++ tools/chorale/src/Split/ContentHasher.php | 20 ++ .../src/Split/ContentHasherInterface.php | 18 ++ tools/chorale/src/Split/SplitDecider.php | 24 ++ .../src/Split/SplitDeciderInterface.php | 16 ++ .../src/State/FilesystemStateStore.php | 40 +++ .../chorale/src/State/StateStoreInterface.php | 14 + tools/chorale/src/Util/DiffUtil.php | 25 ++ tools/chorale/src/Util/DiffUtilInterface.php | 17 ++ 26 files changed, 1074 insertions(+), 19 deletions(-) create mode 100644 tools/chorale/src/Composer/ComposerJsonReader.php create mode 100644 tools/chorale/src/Composer/ComposerJsonReaderInterface.php create mode 100644 tools/chorale/src/Composer/DependencyMerger.php create mode 100644 tools/chorale/src/Composer/DependencyMergerInterface.php create mode 100644 tools/chorale/src/Composer/RuleEngine.php create mode 100644 tools/chorale/src/Composer/RuleEngineInterface.php create mode 100644 tools/chorale/src/Console/PlanCommand.php create mode 100644 tools/chorale/src/Plan/ComposerRootRebuildStep.php create mode 100644 tools/chorale/src/Plan/ComposerRootUpdateStep.php create mode 100644 tools/chorale/src/Plan/PackageMetadataSyncStep.php create mode 100644 tools/chorale/src/Plan/PlanBuilder.php create mode 100644 tools/chorale/src/Plan/PlanBuilderInterface.php create mode 100644 tools/chorale/src/Plan/PlanStepInterface.php create mode 100644 tools/chorale/src/Plan/RootDependencyMergeStep.php create mode 100644 tools/chorale/src/Plan/SplitStep.php create mode 100644 tools/chorale/src/Plan/VersionUpdateStep.php create mode 100644 tools/chorale/src/Split/ContentHasher.php create mode 100644 tools/chorale/src/Split/ContentHasherInterface.php create mode 100644 tools/chorale/src/Split/SplitDecider.php create mode 100644 tools/chorale/src/Split/SplitDeciderInterface.php create mode 100644 tools/chorale/src/State/FilesystemStateStore.php create mode 100644 tools/chorale/src/State/StateStoreInterface.php create mode 100644 tools/chorale/src/Util/DiffUtil.php create mode 100644 tools/chorale/src/Util/DiffUtilInterface.php diff --git a/tools/chorale/bin/chorale b/tools/chorale/bin/chorale index d77644e5..43e64ca5 100755 --- a/tools/chorale/bin/chorale +++ b/tools/chorale/bin/chorale @@ -6,7 +6,6 @@ require __DIR__ . '/../vendor/autoload.php'; use Symfony\Component\Console\Application; use Chorale\Console\Style\ConsoleStyleFactory; use Chorale\Console\SetupCommand; - use Chorale\Repo\TemplateRenderer; use Chorale\Util\PathUtils; use Chorale\Util\Sorting; @@ -16,7 +15,6 @@ use Chorale\Config\SchemaValidator; use Chorale\IO\BackupManager; use Chorale\IO\JsonReporter; use Chorale\Telemetry\RunSummary; - use Chorale\Config\ConfigLoader; use Chorale\Config\ConfigWriter; use Chorale\Config\ConfigNormalizer; @@ -26,28 +24,63 @@ use Chorale\Discovery\PatternMatcher; use Chorale\Repo\RepoResolver; use Chorale\Rules\RequiredFilesChecker; use Chorale\Rules\ConflictDetector; +use Chorale\Composer\ComposerJsonReader; +use Chorale\Composer\DependencyMerger; +use Chorale\Composer\RuleEngine; +use Chorale\Split\ContentHasher; +use Chorale\Split\SplitDecider; +use Chorale\State\FilesystemStateStore; +use Chorale\Util\DiffUtil; +use Chorale\Plan\PlanBuilder; +use Chorale\Console\PlanCommand; -$paths = new PathUtils(); -$renderer = new TemplateRenderer(); -$sorting = new Sorting(); -$identity = new PackageIdentity(); -$defaults = new ConfigDefaults(); -$schema = new SchemaValidator(); -$backup = new BackupManager(); -$json = new JsonReporter(); -$summary = new RunSummary(); - +$paths = new PathUtils(); +$renderer = new TemplateRenderer(); +$sorting = new Sorting(); +$identity = new PackageIdentity(); +$defaults = new ConfigDefaults(); +$schema = new SchemaValidator(); +$backup = new BackupManager(); +$json = new JsonReporter(); +$summary = new RunSummary(); $loader = new ConfigLoader(); +$composerMeta = new ComposerMetadata(); + $writer = new ConfigWriter($backup); $normalizer = new ConfigNormalizer($sorting, $defaults); -$composerMeta = new ComposerMetadata(); $scanner = new PackageScanner($paths); $matcher = new PatternMatcher($paths); $resolver = new RepoResolver($renderer, $paths); $required = new RequiredFilesChecker(); $conflicts = new ConflictDetector($matcher); +$composerReader = new ComposerJsonReader(); +$depMerger = new DependencyMerger($composerReader); +$ruleEngine = new RuleEngine(); +$stateStore = new FilesystemStateStore(); +$hasher = new ContentHasher(); +$splitDecider = new SplitDecider($stateStore, $hasher); +$diffs = new DiffUtil(); + +$planner = new PlanBuilder( + defaults: $defaults, + configLoader: $loader, + scanner: $scanner, + matcher: $matcher, + resolver: $resolver, + paths: $paths, + composerReader: $composerReader, + depMerger: $depMerger, + ruleEngine: $ruleEngine, + splitDecider: $splitDecider, + diffs: $diffs, +); + +// ----------------------------------------------------------------------------- $app = new Application('Chorale', '0.1.0'); +// ----------------------------------------------------------------------------- + +// ----------------------------------------------------------------------------- $app->add(new SetupCommand( styleFactory: new ConsoleStyleFactory(), configLoader: $loader, @@ -60,9 +93,21 @@ $app->add(new SetupCommand( resolver: $resolver, identity: $identity, requiredFiles: $required, - conflicts: $conflicts, + //conflicts: $conflicts, jsonReporter: $json, summary: $summary, composerMeta: $composerMeta, )); +// ----------------------------------------------------------------------------- + +// ----------------------------------------------------------------------------- +$app->add(new PlanCommand( + styleFactory: new ConsoleStyleFactory(), + configLoader: $loader, + planner: $planner, +)); +// ----------------------------------------------------------------------------- + +// ----------------------------------------------------------------------------- $app->run(); +// ----------------------------------------------------------------------------- diff --git a/tools/chorale/src/Composer/ComposerJsonReader.php b/tools/chorale/src/Composer/ComposerJsonReader.php new file mode 100644 index 00000000..0723f04a --- /dev/null +++ b/tools/chorale/src/Composer/ComposerJsonReader.php @@ -0,0 +1,23 @@ + {} if missing/invalid */ + 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..3772f1c9 --- /dev/null +++ b/tools/chorale/src/Composer/DependencyMerger.php @@ -0,0 +1,23 @@ + [], + 'require-dev' => [], + 'conflicts' => [], + ]; + } +} 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..56a7398e --- /dev/null +++ b/tools/chorale/src/Composer/RuleEngine.php @@ -0,0 +1,20 @@ + pattern > root rule > package + // TODO: Respect composer_sync.rules defaults and composer_overrides in patterns/targets + // TODO: Support templating in values (homepage, etc.) + return []; + } +} 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/Console/PlanCommand.php b/tools/chorale/src/Console/PlanCommand.php new file mode 100644 index 00000000..d2fe5541 --- /dev/null +++ b/tools/chorale/src/Console/PlanCommand.php @@ -0,0 +1,183 @@ +setName('plan') + ->setDescription('Build and print a dry-run plan of actionable steps.') + ->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']) . ']' + ), + '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 index a4cbea8c..012bebf9 100644 --- a/tools/chorale/src/Console/SetupCommand.php +++ b/tools/chorale/src/Console/SetupCommand.php @@ -25,13 +25,8 @@ use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Style\SymfonyStyle; -//#[AsCommand(name: 'setup')] final class SetupCommand extends Command { - protected static $defaultName = 'setup'; - - protected static $defaultDescription = 'Create or update chorale.yaml by scanning src/ and applying defaults.'; - public function __construct( private readonly ConsoleStyleFactory $styleFactory, private readonly ConfigLoaderInterface $configLoader, 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/PlanBuilder.php b/tools/chorale/src/Plan/PlanBuilder.php new file mode 100644 index 00000000..f51b7404 --- /dev/null +++ b/tools/chorale/src/Plan/PlanBuilder.php @@ -0,0 +1,255 @@ + (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) + $apply = $this->ruleEngine->computePackageEdits($pcJson, $rootComposer, $config, [ + 'path' => $pkgPath, + 'name' => $pkgName, + ]); + if ($apply !== []) { + $overrides = array_keys( + array_filter( + $apply, + static fn($v): bool => (is_array($v) && isset($v['__override']) && $v['__override'] === true) + ) + ); + // strip internal markers + foreach ($apply as $k => $v) { + if (is_array($v) && array_key_exists('__override', $v)) { + unset($apply[$k]['__override']); + } + } + + $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'], + ]); + 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, + ]); + 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; + } +} 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/Plan/VersionUpdateStep.php b/tools/chorale/src/Plan/VersionUpdateStep.php new file mode 100644 index 00000000..716dee51 --- /dev/null +++ b/tools/chorale/src/Plan/VersionUpdateStep.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/Split/ContentHasher.php b/tools/chorale/src/Split/ContentHasher.php new file mode 100644 index 00000000..2f0c2458 --- /dev/null +++ b/tools/chorale/src/Split/ContentHasher.php @@ -0,0 +1,20 @@ + $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..dec02c57 --- /dev/null +++ b/tools/chorale/src/Split/SplitDecider.php @@ -0,0 +1,24 @@ + $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/Util/DiffUtil.php b/tools/chorale/src/Util/DiffUtil.php new file mode 100644 index 00000000..9048f954 --- /dev/null +++ b/tools/chorale/src/Util/DiffUtil.php @@ -0,0 +1,25 @@ + $current + * @param array $desired + * @param list $keys + */ + public function changed(array $current, array $desired, array $keys): bool; +} From 70fe8a5777ee1eb3f0383634925067e0fde02794 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Fri, 15 Aug 2025 20:13:48 -0400 Subject: [PATCH 04/10] bug fix --- tools/chorale/bin/chorale | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/chorale/bin/chorale b/tools/chorale/bin/chorale index 43e64ca5..8d100c56 100755 --- a/tools/chorale/bin/chorale +++ b/tools/chorale/bin/chorale @@ -64,7 +64,7 @@ $diffs = new DiffUtil(); $planner = new PlanBuilder( defaults: $defaults, - configLoader: $loader, + //configLoader: $loader, scanner: $scanner, matcher: $matcher, resolver: $resolver, From 8bc621047af6ba4392fa5fe392236bddc766fe68 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Fri, 15 Aug 2025 20:21:12 -0400 Subject: [PATCH 05/10] testing --- tools/chorale/src/Diff/ConfigDiffer.php | 23 +-- .../src/Tests/Diff/ConfigDifferTest.php | 195 ++++++++++++++++++ .../Tests/Discovery/PackageScannerTest.php | 8 +- .../chorale/src/Tests/Util/PathUtilsTest.php | 32 +-- 4 files changed, 226 insertions(+), 32 deletions(-) create mode 100644 tools/chorale/src/Tests/Diff/ConfigDifferTest.php diff --git a/tools/chorale/src/Diff/ConfigDiffer.php b/tools/chorale/src/Diff/ConfigDiffer.php index 1a5e2666..7b964322 100644 --- a/tools/chorale/src/Diff/ConfigDiffer.php +++ b/tools/chorale/src/Diff/ConfigDiffer.php @@ -8,20 +8,18 @@ use Chorale\Discovery\PackageIdentityInterface; use Chorale\Discovery\PatternMatcherInterface; use Chorale\Repo\RepoResolverInterface; -use Chorale\Rules\ConflictDetectorInterface; use Chorale\Rules\RequiredFilesCheckerInterface; use Chorale\Util\PathUtilsInterface; -final class ConfigDiffer implements ConfigDifferInterface +final readonly class ConfigDiffer implements ConfigDifferInterface { public function __construct( - private readonly ConfigDefaultsInterface $defaults, - private readonly PatternMatcherInterface $matcher, - private readonly RepoResolverInterface $resolver, - private readonly PackageIdentityInterface $identity, - private readonly RequiredFilesCheckerInterface $requiredFiles, - private readonly ConflictDetectorInterface $conflicts, - private readonly PathUtilsInterface $paths + private ConfigDefaultsInterface $defaults, + private PatternMatcherInterface $matcher, + private RepoResolverInterface $resolver, + private PackageIdentityInterface $identity, + private RequiredFilesCheckerInterface $requiredFiles, + private PathUtilsInterface $paths ) {} public function diff(array $config, array $discovered, array $context): array @@ -74,6 +72,7 @@ public function diff(array $config, array $discovered, array $context): array break; } } + if ($renamedFrom !== null) { $groups['renamed'][] = [ 'from' => $renamedFrom, @@ -105,8 +104,10 @@ public function diff(array $config, array $discovered, array $context): array 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]; @@ -116,9 +117,7 @@ public function diff(array $config, array $discovered, array $context): array // issues: required files $missing = $this->requiredFiles->missing( - dirname($pkgPath, 0) === '' ? '.' : '.', // projectRoot filled by caller in practice - // accurate compute: rely on caller to pass real root; here use relative - // We'll let SetupCommand pass real root; for now, accept relative usage. + '.', getcwd() !== false ? getcwd() . '/' . $pkgPath : $pkgPath, (array) $def['rules']['require_files'] ); diff --git a/tools/chorale/src/Tests/Diff/ConfigDifferTest.php b/tools/chorale/src/Tests/Diff/ConfigDifferTest.php new file mode 100644 index 00000000..c2c22475 --- /dev/null +++ b/tools/chorale/src/Tests/Diff/ConfigDifferTest.php @@ -0,0 +1,195 @@ + '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, + ConflictDetectorInterface $conflicts + ): ConfigDiffer { + return new ConfigDiffer($defaults, $matcher, $resolver, $identity, $required, $conflicts, $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([]); + $conflicts = $this->createMock(ConflictDetectorInterface::class); + + $differ = $this->newDiffer($defaults, $matcher, $resolver, $identity, $required, $conflicts); + + $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([]); + $conflicts = $this->createMock(ConflictDetectorInterface::class); + + $config = [ + 'targets' => [ + ['path' => 'src/Acme/Old'], + ], + 'patterns' => [], + ]; + $discovered = ['src/Acme/New']; + + $differ = $this->newDiffer($defaults, $matcher, $resolver, $identity, $required, $conflicts); + $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']); + $conflicts = $this->createMock(ConflictDetectorInterface::class); + + $differ = $this->newDiffer($defaults, $matcher, $resolver, $identity, $required, $conflicts); + + $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([]); + $conflicts = $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, $conflicts); + $out = $differ->diff($config, $discovered, []); + + $this->assertSame('src/Acme/Foo', $out['drift'][0]['path']); + } +} diff --git a/tools/chorale/src/Tests/Discovery/PackageScannerTest.php b/tools/chorale/src/Tests/Discovery/PackageScannerTest.php index 93389c3f..39e4a549 100644 --- a/tools/chorale/src/Tests/Discovery/PackageScannerTest.php +++ b/tools/chorale/src/Tests/Discovery/PackageScannerTest.php @@ -35,8 +35,8 @@ public function testScanFindsLeafPackages(): void { $root = $this->makeProject(); $ps = new PackageScanner(new PathUtils()); - $paths = $ps->scan($root); - self::assertContains('src/SonsOfPHP/Cookie', $paths); + $paths = $ps->scan($root, 'src'); + $this->assertContains('src/SonsOfPHP/Cookie', $paths); } #[Test] @@ -44,7 +44,7 @@ public function testScanRespectsProvidedPaths(): void { $root = $this->makeProject(); $ps = new PackageScanner(new PathUtils()); - $paths = $ps->scan($root, ['src/SonsOfPHP/Cookie']); - self::assertSame(['src/SonsOfPHP/Cookie'], $paths); + $paths = $ps->scan($root, 'src', ['src/SonsOfPHP/Cookie']); + $this->assertSame(['src/SonsOfPHP/Cookie'], $paths); } } diff --git a/tools/chorale/src/Tests/Util/PathUtilsTest.php b/tools/chorale/src/Tests/Util/PathUtilsTest.php index 1d04507d..7a837240 100644 --- a/tools/chorale/src/Tests/Util/PathUtilsTest.php +++ b/tools/chorale/src/Tests/Util/PathUtilsTest.php @@ -26,84 +26,84 @@ protected function setUp(): void #[Test] public function testNormalizeConvertsBackslashes(): void { - self::assertSame('a/b', $this->u->normalize('a\\b')); + $this->assertSame('a/b', $this->u->normalize('a\\b')); } #[Test] public function testNormalizeCollapsesMultipleSlashes(): void { - self::assertSame('a/b', $this->u->normalize('a////b')); + $this->assertSame('a/b', $this->u->normalize('a////b')); } #[Test] public function testNormalizeRemovesTrailingSlash(): void { - self::assertSame('a', $this->u->normalize('a/')); + $this->assertSame('a', $this->u->normalize('a/')); } #[Test] public function testNormalizeRootSlashStays(): void { - self::assertSame('.', $this->u->normalize('/..')); + $this->assertSame('.', $this->u->normalize('/..')); } #[Test] public function testNormalizeResolvesDotSegments(): void { - self::assertSame('a/b', $this->u->normalize('./a/./b')); + $this->assertSame('a/b', $this->u->normalize('./a/./b')); } #[Test] public function testNormalizeResolvesDotDotSegments(): void { - self::assertSame('a', $this->u->normalize('a/b/..')); + $this->assertSame('a', $this->u->normalize('a/b/..')); } #[Test] public function testIsUnderTrueForSamePath(): void { - self::assertTrue($this->u->isUnder('a/b', 'a/b')); + $this->assertTrue($this->u->isUnder('a/b', 'a/b')); } #[Test] public function testIsUnderTrueForChildPath(): void { - self::assertTrue($this->u->isUnder('a/b/c', 'a/b')); + $this->assertTrue($this->u->isUnder('a/b/c', 'a/b')); } #[Test] public function testIsUnderFalseForSiblingPath(): void { - self::assertFalse($this->u->isUnder('a/c', 'a/b')); + $this->assertFalse($this->u->isUnder('a/c', 'a/b')); } #[Test] - public function testMatchAsteriskPatternCurrentlyDoesNotMatch(): void + public function testMatchAsteriskPatternMatches(): void { - self::assertFalse($this->u->match('src/*/Cookie', 'src/SonsOfPHP/Cookie')); + $this->assertTrue($this->u->match('src/*/Cookie', 'src/SonsOfPHP/Cookie')); } #[Test] - public function testMatchQuestionMarkPatternCurrentlyDoesNotMatch(): void + public function testMatchQuestionMarkPatternMatches(): void { - self::assertFalse($this->u->match('src/SonsOfPHP/Cooki?', 'src/SonsOfPHP/Cookie')); + $this->assertTrue($this->u->match('src/SonsOfPHP/Cooki?', 'src/SonsOfPHP/Cookie')); } #[Test] public function testMatchExactPathWithDotsCurrentlyDoesNotMatch(): void { - self::assertFalse($this->u->match('src/Sons.OfPHP/Cookie', 'src/Sons.OfPHP/Cookie')); + $this->assertFalse($this->u->match('src/Sons.OfPHP/Cookie', 'src/SonsOfPHP/Cookie')); } #[Test] public function testLeafReturnsLastSegment(): void { - self::assertSame('Cookie', $this->u->leaf('src/SonsOfPHP/Cookie')); + $this->assertSame('Cookie', $this->u->leaf('src/SonsOfPHP/Cookie')); } #[Test] public function testLeafReturnsWholeWhenNoSeparator(): void { - self::assertSame('Cookie', $this->u->leaf('Cookie')); + $this->assertSame('Cookie', $this->u->leaf('Cookie')); } } From 25f2dd4ceecad44078f95bac0bf5fa3238374009 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Fri, 15 Aug 2025 20:58:57 -0400 Subject: [PATCH 06/10] meh --- tools/chorale/bin/chorale | 38 ++-- .../src/Composer/ComposerJsonReader.php | 1 + .../Composer/ComposerJsonReaderInterface.php | 5 +- .../chorale/src/Composer/DependencyMerger.php | 213 +++++++++++++++++- tools/chorale/src/Composer/RuleEngine.php | 212 ++++++++++++++++- tools/chorale/src/Console/PlanCommand.php | 4 +- tools/chorale/src/Plan/PlanBuilder.php | 61 +++-- tools/chorale/src/Split/ContentHasher.php | 82 ++++++- tools/chorale/src/Split/SplitDecider.php | 66 +++++- tools/chorale/src/Util/DiffUtil.php | 62 ++++- 10 files changed, 666 insertions(+), 78 deletions(-) diff --git a/tools/chorale/bin/chorale b/tools/chorale/bin/chorale index 8d100c56..8c04a4f8 100755 --- a/tools/chorale/bin/chorale +++ b/tools/chorale/bin/chorale @@ -34,18 +34,23 @@ use Chorale\Util\DiffUtil; use Chorale\Plan\PlanBuilder; use Chorale\Console\PlanCommand; -$paths = new PathUtils(); -$renderer = new TemplateRenderer(); -$sorting = new Sorting(); -$identity = new PackageIdentity(); -$defaults = new ConfigDefaults(); -$schema = new SchemaValidator(); -$backup = new BackupManager(); -$json = new JsonReporter(); -$summary = new RunSummary(); -$loader = new ConfigLoader(); -$composerMeta = new ComposerMetadata(); +$paths = new PathUtils(); +$renderer = new TemplateRenderer(); +$sorting = new Sorting(); +$identity = new PackageIdentity(); +$defaults = new ConfigDefaults(); +$schema = new SchemaValidator(); +$backup = new BackupManager(); +$json = new JsonReporter(); +$summary = new RunSummary(); +$loader = new ConfigLoader(); +$composerMeta = new ComposerMetadata(); +$composerReader = new ComposerJsonReader(); +$stateStore = new FilesystemStateStore(); +$hasher = new ContentHasher(); +$diffs = new DiffUtil(); +$ruleEngine = new RuleEngine($renderer); $writer = new ConfigWriter($backup); $normalizer = new ConfigNormalizer($sorting, $defaults); $scanner = new PackageScanner($paths); @@ -53,18 +58,11 @@ $matcher = new PatternMatcher($paths); $resolver = new RepoResolver($renderer, $paths); $required = new RequiredFilesChecker(); $conflicts = new ConflictDetector($matcher); - -$composerReader = new ComposerJsonReader(); -$depMerger = new DependencyMerger($composerReader); -$ruleEngine = new RuleEngine(); -$stateStore = new FilesystemStateStore(); -$hasher = new ContentHasher(); -$splitDecider = new SplitDecider($stateStore, $hasher); -$diffs = new DiffUtil(); +$depMerger = new DependencyMerger($composerReader); +$splitDecider = new SplitDecider($stateStore, $hasher); $planner = new PlanBuilder( defaults: $defaults, - //configLoader: $loader, scanner: $scanner, matcher: $matcher, resolver: $resolver, diff --git a/tools/chorale/src/Composer/ComposerJsonReader.php b/tools/chorale/src/Composer/ComposerJsonReader.php index 0723f04a..f4a833e9 100644 --- a/tools/chorale/src/Composer/ComposerJsonReader.php +++ b/tools/chorale/src/Composer/ComposerJsonReader.php @@ -18,6 +18,7 @@ public function read(string $absolutePath): array } $json = json_decode($raw, true); + return is_array($json) ? $json : []; } } diff --git a/tools/chorale/src/Composer/ComposerJsonReaderInterface.php b/tools/chorale/src/Composer/ComposerJsonReaderInterface.php index 76056568..43fb298d 100644 --- a/tools/chorale/src/Composer/ComposerJsonReaderInterface.php +++ b/tools/chorale/src/Composer/ComposerJsonReaderInterface.php @@ -6,6 +6,9 @@ interface ComposerJsonReaderInterface { - /** @return array {} if missing/invalid */ + /** + * @return array + * 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 index 3772f1c9..067d3c05 100644 --- a/tools/chorale/src/Composer/DependencyMerger.php +++ b/tools/chorale/src/Composer/DependencyMerger.php @@ -4,20 +4,215 @@ namespace Chorale\Composer; -/** - * Skeleton merger: returns empty requires with no conflicts. - * Implement strategies: union-caret (default), union-loose, intersect, max. - */ final readonly class DependencyMerger implements DependencyMergerInterface { + public function __construct( + private readonly ComposerJsonReaderInterface $reader + ) {} + public function computeRootMerge(string $projectRoot, array $packagePaths, array $options = []): array { - // TODO: Walk each package composer.json, gather require/require-dev, - // filter monorepo packages, merge by strategy, compute conflicts. - return [ - 'require' => [], + + $opts = [ + 'strategy_require' => (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'] ?? []), + ]; + + $monorepo = array_map('strtolower', array_values($opts['monorepo_names'])); + + $reqs = []; + $devs = []; + $byDepConstraints = [ + 'require' => [], 'require-dev' => [], - 'conflicts' => [], ]; + + foreach ($packagePaths as $relPath) { + $pc = $this->reader->read(rtrim($projectRoot, '/') . '/' . $relPath . '/composer.json'); + if ($pc === []) { + continue; + } + + $name = strtolower((string) ($pc['name'] ?? $relPath)); + foreach ((array) ($pc['require'] ?? []) as $dep => $ver) { + if (!is_string($dep)) { + continue; + } + + if (!is_string($ver)) { + continue; + } + + if ($opts['exclude_monorepo_packages'] && in_array(strtolower($dep), $monorepo, true)) { + continue; + } + + $byDepConstraints['require'][$dep][$name] = $ver; + } + + foreach ((array) ($pc['require-dev'] ?? []) as $dep => $ver) { + if (!is_string($dep)) { + continue; + } + + if (!is_string($ver)) { + continue; + } + + if ($opts['exclude_monorepo_packages'] && in_array(strtolower($dep), $monorepo, true)) { + continue; + } + + $byDepConstraints['require-dev'][$dep][$name] = $ver; + } + } + + $conflicts = []; + $reqs = $this->mergeMap($byDepConstraints['require'], $opts['strategy_require'], $conflicts); + $devs = $this->mergeMap($byDepConstraints['require-dev'], $opts['strategy_require_dev'], $conflicts); + + ksort($reqs); + ksort($devs); + + return [ + 'require' => $reqs, + 'require-dev' => $devs, + 'conflicts' => array_values($conflicts), + ]; + } + + /** + * @param array> $constraintsPerDep + * @param array> $conflictsOut + * @return array + */ + private function mergeMap(array $constraintsPerDep, string $strategy, array &$conflictsOut): array + { + $out = []; + foreach ($constraintsPerDep as $dep => $byPkg) { + $constraint = $this->chooseConstraint(array_values($byPkg), $strategy, $dep, $byPkg, $conflictsOut); + if ($constraint !== null) { + $out[$dep] = $constraint; + } + } + + return $out; + } + + /** + * @param list $constraints + * @param array $byPkg + */ + private function chooseConstraint(array $constraints, string $strategy, string $dep, array $byPkg, array &$conflictsOut): ?string + { + $strategy = strtolower($strategy); + $norm = array_map([$this,'normalizeConstraint'], array_filter($constraints, 'is_string')); + if ($norm === []) { + return null; + } + + if ($strategy === 'union-caret') { + return $this->chooseUnionCaret($norm, $dep, $byPkg, $conflictsOut); + } + + if ($strategy === 'union-loose') { + return '*'; + } + + if ($strategy === 'max') { + return $this->maxLowerBound($norm); + } + + if ($strategy === 'intersect') { + // naive: if all share same major series, pick max lower bound; else conflict + $majors = array_unique(array_map(static fn($c): int => $c['major'], $norm)); + if (count($majors) > 1) { + $this->recordConflict($dep, $byPkg, $conflictsOut, 'intersect-empty'); + return null; + } + + return $this->maxLowerBound($norm); + } + + // default fallback + return $this->chooseUnionCaret($norm, $dep, $byPkg, $conflictsOut); + } + + /** @param list $norm */ + private function chooseUnionCaret(array $norm, string $dep, array $byPkg, 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); + return '^' . $best['major'] . '.' . $best['minor']; + } + + // If exact pins or ranges exist, pick the "max lower bound" and record conflict + $this->recordConflict($dep, $byPkg, $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 $byPkg */ + private function recordConflict(string $dep, array $byPkg, array &$conflictsOut, string $reason): void + { + $conflictsOut[$dep] = [ + 'package' => $dep, + 'versions' => array_values(array_unique(array_values($byPkg))), + 'packages' => array_keys($byPkg), + '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/RuleEngine.php b/tools/chorale/src/Composer/RuleEngine.php index 56a7398e..e44c68a9 100644 --- a/tools/chorale/src/Composer/RuleEngine.php +++ b/tools/chorale/src/Composer/RuleEngine.php @@ -4,17 +4,211 @@ namespace Chorale\Composer; -/** - * Skeleton rule engine that produces no edits. - * Implement rules: mirror, mirror-unless-overridden, merge-object, append-unique, ignore. - */ -final class RuleEngine implements RuleEngineInterface +use Chorale\Repo\TemplateRenderer; + +final readonly class RuleEngine implements RuleEngineInterface { + public function __construct( + private TemplateRenderer $renderer = new TemplateRenderer() + ) {} + public function computePackageEdits(array $packageComposer, array $rootComposer, array $config, array $context): array { - // TODO: Apply precedence target > pattern > root rule > package - // TODO: Respect composer_sync.rules defaults and composer_overrides in patterns/targets - // TODO: Support templating in values (homepage, etc.) - return []; + $rules = $this->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/Console/PlanCommand.php b/tools/chorale/src/Console/PlanCommand.php index d2fe5541..f30c2d8c 100644 --- a/tools/chorale/src/Console/PlanCommand.php +++ b/tools/chorale/src/Console/PlanCommand.php @@ -4,7 +4,6 @@ namespace Chorale\Console; -use Symfony\Component\Console\Output\OutputInterface; use Chorale\Config\ConfigLoaderInterface; use Chorale\Console\Style\ConsoleStyleFactory; use Chorale\Plan\PlanBuilderInterface; @@ -12,6 +11,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; /** @@ -161,7 +161,7 @@ private function humanLine(string $type, array $a): string '%s — %s%s', $a['name'] ?? $a['path'] ?? '', 'mirror ' . implode(',', array_keys((array) ($a['apply'] ?? []))), - empty($a['overrides_used']) ? '' : ' [overrides: ' . implode(',', (array) $a['overrides_used']) . ']' + empty($a['overrides_used']) ? '' : ' [overrides: ' . implode(',', (array) $a['overrides_used']['values']) . ']' ), 'composer-root-update' => sprintf( 'update %s (version %s, require %d, replace %d)', diff --git a/tools/chorale/src/Plan/PlanBuilder.php b/tools/chorale/src/Plan/PlanBuilder.php index f51b7404..84333185 100644 --- a/tools/chorale/src/Plan/PlanBuilder.php +++ b/tools/chorale/src/Plan/PlanBuilder.php @@ -15,10 +15,6 @@ use Chorale\Util\DiffUtilInterface; use Chorale\Util\PathUtilsInterface; -/** - * Turns config + repo state into a minimal, actionable plan. - * NOTE: This is a skeleton that wires the dependencies and outlines the flow. - */ final readonly class PlanBuilder implements PlanBuilderInterface { public function __construct( @@ -113,23 +109,15 @@ public function build(string $projectRoot, array $config, array $options = []): } // 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, + 'path' => $pkgPath, + 'name' => $pkgName, + 'overrides' => $overrides, ]); if ($apply !== []) { - $overrides = array_keys( - array_filter( - $apply, - static fn($v): bool => (is_array($v) && isset($v['__override']) && $v['__override'] === true) - ) - ); - // strip internal markers - foreach ($apply as $k => $v) { - if (is_array($v) && array_key_exists('__override', $v)) { - unset($apply[$k]['__override']); - } - } + $overKeys = $this->extractOverrideKeys($apply); + $apply = $this->stripInternalMarkers($apply); $steps[] = new PackageMetadataSyncStep($pkgPath, $pkgName, $apply, $overrides); } elseif ($opts['show_all']) { @@ -143,6 +131,7 @@ public function build(string $projectRoot, array $config, array $options = []): '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( @@ -197,6 +186,7 @@ public function build(string $projectRoot, array $config, array $options = []): '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 @@ -252,4 +242,39 @@ private function rootsFromPatterns(array $patterns): array 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/Split/ContentHasher.php b/tools/chorale/src/Split/ContentHasher.php index 2f0c2458..4350f1d3 100644 --- a/tools/chorale/src/Split/ContentHasher.php +++ b/tools/chorale/src/Split/ContentHasher.php @@ -4,17 +4,83 @@ namespace Chorale\Split; -/** - * Minimal placeholder implementation. - * Replace with a proper tree hash (e.g., SHA-256 over sorted file list). - */ final class ContentHasher implements ContentHasherInterface { public function hash(string $projectRoot, string $packagePath, array $ignoreGlobs = []): string { - // Skeleton: do not traverse; just combine mtime + path as a stub. - // Implement your deterministic file-walk hashing here. - $key = $projectRoot . '|' . $packagePath . '|' . implode(',', $ignoreGlobs); - return hash('sha256', $key); + $abs = rtrim($projectRoot, '/') . '/' . ltrim($packagePath, '/'); + if (!is_dir($abs)) { + return hash('sha256', $abs . '|missing'); + } + + $files = $this->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/SplitDecider.php b/tools/chorale/src/Split/SplitDecider.php index dec02c57..91e60ddc 100644 --- a/tools/chorale/src/Split/SplitDecider.php +++ b/tools/chorale/src/Split/SplitDecider.php @@ -4,21 +4,75 @@ namespace Chorale\Split; -/** - * Uses lockfile state and (optionally) remote to decide if a split is needed. - * This skeleton returns "forced" when force_split, otherwise empty reasons. - */ +use Chorale\State\StateStoreInterface; + final readonly class SplitDecider implements SplitDeciderInterface { + public function __construct( + private readonly StateStoreInterface $state, + private readonly ContentHasherInterface $hasher, + ) {} + public function reasonsToSplit(string $projectRoot, string $packagePath, array $options = []): array { if (!empty($options['force_split'])) { return ['forced']; } - // TODO: Compare current hash vs stored. Probe remote if verify_remote is true. - // Return ["content-changed"] or ["repo-empty"] or ["missing-tag"] as appropriate. + $reasons = []; + $state = $this->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/Util/DiffUtil.php b/tools/chorale/src/Util/DiffUtil.php index 9048f954..69f9df32 100644 --- a/tools/chorale/src/Util/DiffUtil.php +++ b/tools/chorale/src/Util/DiffUtil.php @@ -4,10 +4,6 @@ namespace Chorale\Util; -/** - * Minimal stable diff helper (strict equality per key). - * Extend as needed for order-insensitive comparisons. - */ final class DiffUtil implements DiffUtilInterface { public function changed(array $current, array $desired, array $keys): bool @@ -15,11 +11,67 @@ public function changed(array $current, array $desired, array $keys): bool foreach ($keys as $k) { $a = $current[$k] ?? null; $b = $desired[$k] ?? null; - if ($a !== $b) { + if (!$this->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); + } } From 7d6526db69d257817bd7e637ed109834144c7d64 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Fri, 15 Aug 2025 21:15:03 -0400 Subject: [PATCH 07/10] testing --- .../chorale/src/Discovery/PackageScanner.php | 27 +++++-- .../src/Tests/Config/ConfigDefaultsTest.php | 16 +++- .../src/Tests/Config/SchemaValidatorTest.php | 20 +++-- .../src/Tests/Diff/ConfigDifferTest.php | 73 ++++++++++++++++--- .../Tests/Discovery/PackageScannerTest.php | 12 +++ .../src/Tests/Repo/RepoResolverTest.php | 14 +++- .../src/Tests/Repo/TemplateRendererTest.php | 34 ++++++--- .../chorale/src/Tests/Util/PathUtilsTest.php | 6 ++ 8 files changed, 163 insertions(+), 39 deletions(-) diff --git a/tools/chorale/src/Discovery/PackageScanner.php b/tools/chorale/src/Discovery/PackageScanner.php index 8f4a0d55..bfb74da0 100644 --- a/tools/chorale/src/Discovery/PackageScanner.php +++ b/tools/chorale/src/Discovery/PackageScanner.php @@ -6,17 +6,28 @@ use Chorale\Util\PathUtilsInterface; -final class PackageScanner implements PackageScannerInterface +/** + * Scans the project for package directories under a base directory (e.g. src/). + * A directory is considered a package if it contains a composer.json file. + * Any directory named "vendor" is skipped entirely. + */ +final readonly class PackageScanner implements PackageScannerInterface { public function __construct( - private readonly PathUtilsInterface $paths + private PathUtilsInterface $paths ) {} + /** + * @param string $projectRoot Absolute or working-directory path to the repository root + * @param string $baseDir Relative directory to scan (e.g., "src") + * @param list $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, '/'); - $base = $root . '/' . $this->paths->normalize($baseDir); - if (!is_dir($base)) { + $basePath = $root . '/' . $this->paths->normalize($baseDir); + if (!is_dir($basePath)) { return []; } @@ -29,6 +40,7 @@ public function scan(string $projectRoot, string $baseDir, array $paths = []): a && $this->paths->normalize($rel) !== $this->paths->normalize($baseDir)) { continue; } + $full = $root . '/' . $rel; // ignore any path that is (or is inside) vendor/ @@ -40,13 +52,14 @@ public function scan(string $projectRoot, string $baseDir, array $paths = []): a $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($base, \FilesystemIterator::SKIP_DOTS); + $dirIter = new \RecursiveDirectoryIterator($basePath, \FilesystemIterator::SKIP_DOTS); $filter = new \RecursiveCallbackFilterIterator( $dirIter, function (\SplFileInfo $file, string $key, \RecursiveDirectoryIterator $iterator): bool { @@ -54,6 +67,7 @@ function (\SplFileInfo $file, string $key, \RecursiveDirectoryIterator $iterator // 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; } @@ -65,8 +79,9 @@ function (\SplFileInfo $file, string $key, \RecursiveDirectoryIterator $iterator if (!$dir->isDir()) { continue; } + $path = $dir->getPathname(); - $rel = substr($path, strlen($root) + 1); + $rel = substr((string) $path, strlen($root) + 1); // Quick guard against vendor/ in case a user passes a weird path if ($this->isInVendor($rel)) { diff --git a/tools/chorale/src/Tests/Config/ConfigDefaultsTest.php b/tools/chorale/src/Tests/Config/ConfigDefaultsTest.php index 1870e152..d64e18af 100644 --- a/tools/chorale/src/Tests/Config/ConfigDefaultsTest.php +++ b/tools/chorale/src/Tests/Config/ConfigDefaultsTest.php @@ -20,7 +20,7 @@ public function testResolveFillsFallbacks(): void { $d = new ConfigDefaults(); $out = $d->resolve([]); - self::assertSame('git@github.com', $out['repo_host']); + $this->assertSame('git@github.com', $out['repo_host']); } #[Test] @@ -28,7 +28,7 @@ public function testResolveMergesRules(): void { $d = new ConfigDefaults(); $out = $d->resolve(['rules' => ['keep_history' => false]]); - self::assertFalse($out['rules']['keep_history']); + $this->assertFalse($out['rules']['keep_history']); } #[Test] @@ -36,7 +36,7 @@ public function testResolveComputesDefaultRepoTemplateWhenNotProvided(): void { $d = new ConfigDefaults(); $out = $d->resolve(['repo_vendor' => 'Acme']); - self::assertSame('git@github.com:Acme/{name:kebab}.git', $out['default_repo_template']); + $this->assertSame('git@github.com:Acme/{name:kebab}.git', $out['default_repo_template']); } #[Test] @@ -44,6 +44,14 @@ public function testResolveKeepsExplicitDefaultRepoTemplate(): void { $d = new ConfigDefaults(); $out = $d->resolve(['default_repo_template' => 'x:{y}/{z}']); - self::assertSame('x:{y}/{z}', $out['default_repo_template']); + $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/SchemaValidatorTest.php b/tools/chorale/src/Tests/Config/SchemaValidatorTest.php index f4275f4c..9a9a29c8 100644 --- a/tools/chorale/src/Tests/Config/SchemaValidatorTest.php +++ b/tools/chorale/src/Tests/Config/SchemaValidatorTest.php @@ -20,7 +20,7 @@ public function testValidateRejectsNonStringRepoHost(): void { $v = new SchemaValidator(); $issues = $v->validate(['repo_host' => 123], '/unused'); - self::assertContains("Key 'repo_host' must be a string.", $issues); + $this->assertContains("Key 'repo_host' must be a string.", $issues); } #[Test] @@ -28,7 +28,7 @@ public function testValidateRejectsRulesNotArray(): void { $v = new SchemaValidator(); $issues = $v->validate(['rules' => 'x'], '/unused'); - self::assertContains("Key 'rules' must be an array.", $issues); + $this->assertContains("Key 'rules' must be an array.", $issues); } #[Test] @@ -36,7 +36,7 @@ public function testValidateRejectsKeepHistoryNotBool(): void { $v = new SchemaValidator(); $issues = $v->validate(['rules' => ['keep_history' => 'no']], '/unused'); - self::assertContains('rules.keep_history must be a boolean.', $issues); + $this->assertContains('rules.keep_history must be a boolean.', $issues); } #[Test] @@ -44,7 +44,7 @@ public function testValidateRejectsPatternsNotArray(): void { $v = new SchemaValidator(); $issues = $v->validate(['patterns' => 'x'], '/unused'); - self::assertContains("Key 'patterns' must be a list.", $issues); + $this->assertContains("Key 'patterns' must be a list.", $issues); } #[Test] @@ -52,7 +52,7 @@ public function testValidateRejectsPatternMissingMatch(): void { $v = new SchemaValidator(); $issues = $v->validate(['patterns' => [[]]], '/unused'); - self::assertContains('patterns[0].match must be a string.', $issues); + $this->assertContains('patterns[0].match must be a string.', $issues); } #[Test] @@ -60,6 +60,14 @@ public function testValidateRejectsTargetsFieldTypes(): void { $v = new SchemaValidator(); $issues = $v->validate(['targets' => [['name' => 1]]], '/unused'); - self::assertContains('targets[0].name must be a string.', $issues); + $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/Diff/ConfigDifferTest.php b/tools/chorale/src/Tests/Diff/ConfigDifferTest.php index c2c22475..eb6ca50c 100644 --- a/tools/chorale/src/Tests/Diff/ConfigDifferTest.php +++ b/tools/chorale/src/Tests/Diff/ConfigDifferTest.php @@ -72,10 +72,9 @@ private function newDiffer( PatternMatcherInterface $matcher, RepoResolverInterface $resolver, PackageIdentityInterface $identity, - RequiredFilesCheckerInterface $required, - ConflictDetectorInterface $conflicts + RequiredFilesCheckerInterface $required ): ConfigDiffer { - return new ConfigDiffer($defaults, $matcher, $resolver, $identity, $required, $conflicts, $this->stubPaths()); + return new ConfigDiffer($defaults, $matcher, $resolver, $identity, $required, $this->stubPaths()); } #[Test] @@ -93,9 +92,9 @@ public function testClassifiesNewWhenNotInConfig(): void $identity = $this->createMock(PackageIdentityInterface::class); $required = $this->createMock(RequiredFilesCheckerInterface::class); $required->method('missing')->willReturn([]); - $conflicts = $this->createMock(ConflictDetectorInterface::class); + $this->createMock(ConflictDetectorInterface::class); - $differ = $this->newDiffer($defaults, $matcher, $resolver, $identity, $required, $conflicts); + $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']); @@ -123,7 +122,7 @@ public function testDetectsRenameWhenIdentityMatches(): void $required = $this->createMock(RequiredFilesCheckerInterface::class); $required->method('missing')->willReturn([]); - $conflicts = $this->createMock(ConflictDetectorInterface::class); + $this->createMock(ConflictDetectorInterface::class); $config = [ 'targets' => [ @@ -133,7 +132,7 @@ public function testDetectsRenameWhenIdentityMatches(): void ]; $discovered = ['src/Acme/New']; - $differ = $this->newDiffer($defaults, $matcher, $resolver, $identity, $required, $conflicts); + $differ = $this->newDiffer($defaults, $matcher, $resolver, $identity, $required); $out = $differ->diff($config, $discovered, []); $this->assertSame('src/Acme/Old', $out['renamed'][0]['from']); @@ -154,9 +153,9 @@ public function testReportsIssuesWhenRequiredFilesMissing(): void $identity = $this->createMock(PackageIdentityInterface::class); $required = $this->createMock(RequiredFilesCheckerInterface::class); $required->method('missing')->willReturn(['composer.json']); - $conflicts = $this->createMock(ConflictDetectorInterface::class); + $this->createMock(ConflictDetectorInterface::class); - $differ = $this->newDiffer($defaults, $matcher, $resolver, $identity, $required, $conflicts); + $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']); @@ -177,7 +176,7 @@ public function testReportsDriftWhenRedundantOverridePresent(): void $identity = $this->createMock(PackageIdentityInterface::class); $required = $this->createMock(RequiredFilesCheckerInterface::class); $required->method('missing')->willReturn([]); - $conflicts = $this->createMock(ConflictDetectorInterface::class); + $this->createMock(ConflictDetectorInterface::class); $config = [ 'targets' => [ @@ -187,9 +186,61 @@ public function testReportsDriftWhenRedundantOverridePresent(): void ]; $discovered = ['src/Acme/Foo']; - $differ = $this->newDiffer($defaults, $matcher, $resolver, $identity, $required, $conflicts); + $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/PackageScannerTest.php b/tools/chorale/src/Tests/Discovery/PackageScannerTest.php index 39e4a549..ea20b70b 100644 --- a/tools/chorale/src/Tests/Discovery/PackageScannerTest.php +++ b/tools/chorale/src/Tests/Discovery/PackageScannerTest.php @@ -27,6 +27,9 @@ private function makeProject(): string 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; } @@ -47,4 +50,13 @@ public function testScanRespectsProvidedPaths(): void $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/Repo/RepoResolverTest.php b/tools/chorale/src/Tests/Repo/RepoResolverTest.php index 62cd7e5a..ebfb3bc3 100644 --- a/tools/chorale/src/Tests/Repo/RepoResolverTest.php +++ b/tools/chorale/src/Tests/Repo/RepoResolverTest.php @@ -29,7 +29,7 @@ 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'); - self::assertSame('git@gh:x/Foo', $url); + $this->assertSame('git@gh:x/Foo', $url); } #[Test] @@ -37,7 +37,7 @@ 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'); - self::assertSame('git@github.com:Acme/foo_bar', $url); + $this->assertSame('git@github.com:Acme/foo_bar', $url); } #[Test] @@ -45,6 +45,14 @@ public function testResolveUsesDefaultTemplateOtherwise(): void { $r = new RepoResolver(new TemplateRenderer(), new PathUtils()); $url = $r->resolve($this->defaults, [], [], 'src/Acme/Cookie', 'Cookie'); - self::assertSame('git@github.com:Acme/cookie.git', $url); + $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 index 125a02a1..79be4d4a 100644 --- a/tools/chorale/src/Tests/Repo/TemplateRendererTest.php +++ b/tools/chorale/src/Tests/Repo/TemplateRendererTest.php @@ -20,7 +20,7 @@ public function testValidateDetectsUnknownPlaceholder(): void { $r = new TemplateRenderer(); $issues = $r->validate('x/{unknown}'); - self::assertContains("Unknown placeholder 'unknown'", $issues); + $this->assertContains("Unknown placeholder 'unknown'", $issues); } #[Test] @@ -28,7 +28,7 @@ public function testValidateDetectsUnknownFilter(): void { $r = new TemplateRenderer(); $issues = $r->validate('x/{name:oops}'); - self::assertContains("Unknown filter 'oops' for 'name'", $issues); + $this->assertContains("Unknown filter 'oops' for 'name'", $issues); } #[Test] @@ -36,7 +36,7 @@ public function testRenderAppliesLowerFilter(): void { $r = new TemplateRenderer(); $out = $r->render('{name:lower}', ['name' => 'Cookie']); - self::assertSame('cookie', $out); + $this->assertSame('cookie', $out); } #[Test] @@ -44,7 +44,7 @@ public function testRenderAppliesUpperFilter(): void { $r = new TemplateRenderer(); $out = $r->render('{name:upper}', ['name' => 'Cookie']); - self::assertSame('COOKIE', $out); + $this->assertSame('COOKIE', $out); } #[Test] @@ -52,7 +52,7 @@ public function testRenderKebabFilter(): void { $r = new TemplateRenderer(); $out = $r->render('{name:kebab}', ['name' => 'My Cookie_Package']); - self::assertSame('my-cookie-package', $out); + $this->assertSame('my-cookie-package', $out); } #[Test] @@ -60,7 +60,7 @@ public function testRenderSnakeFilter(): void { $r = new TemplateRenderer(); $out = $r->render('{name:snake}', ['name' => 'My Cookie-Package']); - self::assertSame('my_cookie_package', $out); + $this->assertSame('my_cookie_package', $out); } #[Test] @@ -68,7 +68,7 @@ public function testRenderCamelFilter(): void { $r = new TemplateRenderer(); $out = $r->render('{name:camel}', ['name' => 'my-cookie package']); - self::assertSame('myCookiePackage', $out); + $this->assertSame('myCookiePackage', $out); } #[Test] @@ -76,7 +76,7 @@ public function testRenderPascalFilter(): void { $r = new TemplateRenderer(); $out = $r->render('{name:pascal}', ['name' => 'my-cookie package']); - self::assertSame('MyCookiePackage', $out); + $this->assertSame('MyCookiePackage', $out); } #[Test] @@ -84,6 +84,22 @@ public function testRenderDotFilter(): void { $r = new TemplateRenderer(); $out = $r->render('{name:dot}', ['name' => 'my cookie-package']); - self::assertSame('my.cookie.package', $out); + $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/Util/PathUtilsTest.php b/tools/chorale/src/Tests/Util/PathUtilsTest.php index 7a837240..934c2330 100644 --- a/tools/chorale/src/Tests/Util/PathUtilsTest.php +++ b/tools/chorale/src/Tests/Util/PathUtilsTest.php @@ -106,4 +106,10 @@ 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')); + } } From 9a751dbbfa43ba1d925616b5727b1a7f9c323fd2 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Fri, 15 Aug 2025 21:21:11 -0400 Subject: [PATCH 08/10] testing --- tools/chorale/src/Diff/ConfigDiffer.php | 14 ++++++++ .../chorale/src/Discovery/PatternMatcher.php | 14 ++++++-- tools/chorale/src/Repo/TemplateRenderer.php | 33 +++++++++++++++---- .../src/Tests/Config/ConfigLoaderTest.php | 4 +-- .../src/Tests/Config/ConfigNormalizerTest.php | 11 ++++--- .../src/Tests/Config/ConfigWriterTest.php | 6 ++-- .../Tests/Discovery/PackageIdentityTest.php | 4 +-- .../Tests/Discovery/PatternMatcherTest.php | 12 ++++--- .../src/Tests/IO/BackupManagerTest.php | 4 +-- .../chorale/src/Tests/IO/JsonReporterTest.php | 4 +-- .../src/Tests/Rules/ConflictDetectorTest.php | 12 ++++--- .../Tests/Rules/RequiredFilesCheckerTest.php | 5 +-- .../src/Tests/Telemetry/RunSummaryTest.php | 5 +-- tools/chorale/src/Tests/Util/SortingTest.php | 8 ++--- tools/chorale/src/Util/PathUtils.php | 19 ++++++++++- tools/chorale/src/Util/Sorting.php | 9 +++++ 16 files changed, 122 insertions(+), 42 deletions(-) diff --git a/tools/chorale/src/Diff/ConfigDiffer.php b/tools/chorale/src/Diff/ConfigDiffer.php index 7b964322..816f17b2 100644 --- a/tools/chorale/src/Diff/ConfigDiffer.php +++ b/tools/chorale/src/Diff/ConfigDiffer.php @@ -11,6 +11,14 @@ use Chorale\Rules\RequiredFilesCheckerInterface; use Chorale\Util\PathUtilsInterface; +/** + * Computes differences between discovered packages and the current config. + * + * Groups results into: new, renamed, drift, issues, conflicts, ok. + * + * Example: + * - diff($config, ['src/Acme/Foo'], []) may return ['new' => [['path'=>'src/Acme/Foo', 'repo'=>'git@...']]] + */ final readonly class ConfigDiffer implements ConfigDifferInterface { public function __construct( @@ -22,6 +30,12 @@ public function __construct( 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); diff --git a/tools/chorale/src/Discovery/PatternMatcher.php b/tools/chorale/src/Discovery/PatternMatcher.php index a2eca835..af506ddd 100644 --- a/tools/chorale/src/Discovery/PatternMatcher.php +++ b/tools/chorale/src/Discovery/PatternMatcher.php @@ -6,10 +6,18 @@ use Chorale\Util\PathUtilsInterface; -final class PatternMatcher implements PatternMatcherInterface +/** + * Matches package paths against a set of glob-like patterns. + * Uses PathUtils::match to support '*', '?', and '**' semantics. + * + * Example: + * - firstMatch([{match:'src/* /Lib'}], 'src/Acme/Lib') => 0 + * - allMatches([{match:'src/* /Lib'},{match:'src/Acme/*'}], 'src/Acme/Lib') => [0,1] + */ +final readonly class PatternMatcher implements PatternMatcherInterface { public function __construct( - private readonly PathUtilsInterface $paths + private PathUtilsInterface $paths ) {} public function firstMatch(array $patterns, string $path): ?int @@ -20,6 +28,7 @@ public function firstMatch(array $patterns, string $path): ?int return (int) $i; } } + return null; } @@ -32,6 +41,7 @@ public function allMatches(array $patterns, string $path): array $hits[] = (int) $i; } } + return $hits; } } diff --git a/tools/chorale/src/Repo/TemplateRenderer.php b/tools/chorale/src/Repo/TemplateRenderer.php index 5245844b..44f304c9 100644 --- a/tools/chorale/src/Repo/TemplateRenderer.php +++ b/tools/chorale/src/Repo/TemplateRenderer.php @@ -4,6 +4,16 @@ namespace Chorale\Repo; +/** + * Renders string templates with variables and filters. + * + * Placeholders: {name}, {repo_host}, {repo_vendor}, {repo_name_template}, {default_repo_template}, {path}, {tag} + * Filters: raw, lower, upper, kebab, snake, camel, pascal, dot + * + * Examples: + * - render('{repo_host}:{repo_vendor}/{name:kebab}.git', ['repo_host'=>'git@github.com','repo_vendor'=>'Acme','name'=>'My Lib']) + * => 'git@github.com:Acme/my-lib.git' + */ final class TemplateRenderer implements TemplateRendererInterface { /** @var array */ @@ -50,23 +60,26 @@ public function render(string $template, array $vars): string } $next = preg_replace_callback( - '/\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([a-zA-Z:]+))?\}/', + '/\{([a-zA-Z_]\w*)(?::([a-zA-Z:]+))?\}/', function (array $m) use ($vars): string { $var = $m[1]; - $filters = isset($m[2]) ? explode(':', $m[2]) : []; + $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("Unknown filter '{$f}'"); + throw new \InvalidArgumentException(sprintf("Unknown filter '%s'", $f)); } + $value = $fn($value); } + return $value; }, $out @@ -75,12 +88,15 @@ function (array $m) use ($vars): string { // regex error; fall back to current output break; } - if ($next === $out || !preg_match('/\{[a-zA-Z_][a-zA-Z0-9_]*(?::[a-zA-Z:]+)?\}/', $next)) { + + 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; } @@ -91,7 +107,7 @@ public function validate(string $template): array return $issues; } - if (!preg_match_all('/\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([a-zA-Z:]+))?\}/', $template, $matches, \PREG_SET_ORDER)) { + if (in_array(preg_match_all('/\{([a-zA-Z_]\w*)(?::([a-zA-Z:]+))?\}/', $template, $matches, \PREG_SET_ORDER), [0, false], true)) { return $issues; } @@ -100,7 +116,7 @@ public function validate(string $template): array $filterStr = $match[2] ?? ''; if (!isset($this->allowedVars[$var])) { - $issues[] = "Unknown placeholder '{$var}'"; + $issues[] = sprintf("Unknown placeholder '%s'", $var); } if ($filterStr !== '') { @@ -108,8 +124,9 @@ public function validate(string $template): array if ($f === '') { continue; } + if (!isset($this->filters[$f])) { - $issues[] = "Unknown filter '{$f}' for '{$var}'"; + $issues[] = sprintf("Unknown filter '%s' for '%s'", $f, $var); } } } @@ -132,11 +149,13 @@ 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; } diff --git a/tools/chorale/src/Tests/Config/ConfigLoaderTest.php b/tools/chorale/src/Tests/Config/ConfigLoaderTest.php index ad5275de..01409d00 100644 --- a/tools/chorale/src/Tests/Config/ConfigLoaderTest.php +++ b/tools/chorale/src/Tests/Config/ConfigLoaderTest.php @@ -22,7 +22,7 @@ public function testLoadReturnsEmptyArrayWhenMissingFile(): void $dir = sys_get_temp_dir() . '/chorale_' . uniqid(); @mkdir($dir); $out = $loader->load($dir); - self::assertSame([], $out); + $this->assertSame([], $out); } #[Test] @@ -33,6 +33,6 @@ public function testLoadParsesYamlIntoArray(): void @mkdir($dir); file_put_contents($dir . '/test.yaml', "repo_vendor: Acme\n"); $out = $loader->load($dir); - self::assertSame('Acme', $out['repo_vendor']); + $this->assertSame('Acme', $out['repo_vendor']); } } diff --git a/tools/chorale/src/Tests/Config/ConfigNormalizerTest.php b/tools/chorale/src/Tests/Config/ConfigNormalizerTest.php index e9800624..8634e49a 100644 --- a/tools/chorale/src/Tests/Config/ConfigNormalizerTest.php +++ b/tools/chorale/src/Tests/Config/ConfigNormalizerTest.php @@ -21,6 +21,7 @@ final class ConfigNormalizerTest extends TestCase { /** @var SortingInterface&MockObject */ private SortingInterface $sorting; + /** @var ConfigDefaultsInterface&MockObject */ private ConfigDefaultsInterface $defaults; @@ -42,15 +43,15 @@ protected function setUp(): void 'require_files' => ['composer.json','LICENSE'], ], ]); - $this->sorting->method('sortPatterns')->willReturnCallback(fn(array $a) => $a); - $this->sorting->method('sortTargets')->willReturnCallback(fn(array $a) => $a); + $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']]]); - self::assertArrayNotHasKey('repo_host', $out['patterns'][0]); + $this->assertArrayNotHasKey('repo_host', $out['patterns'][0]); } #[Test] @@ -58,7 +59,7 @@ public function testRedundantTargetOverrideIsRemoved(): void { $n = new ConfigNormalizer($this->sorting, $this->defaults); $out = $n->normalize(['targets' => [['path' => 'a/b', 'repo_vendor' => 'SonsOfPHP']]]); - self::assertArrayNotHasKey('repo_vendor', $out['targets'][0]); + $this->assertArrayNotHasKey('repo_vendor', $out['targets'][0]); } #[Test] @@ -66,6 +67,6 @@ public function testTopLevelDefaultsCopied(): void { $n = new ConfigNormalizer($this->sorting, $this->defaults); $out = $n->normalize([]); - self::assertSame('git@github.com', $out['repo_host']); + $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 index 16983cad..cc11f0e8 100644 --- a/tools/chorale/src/Tests/Config/ConfigWriterTest.php +++ b/tools/chorale/src/Tests/Config/ConfigWriterTest.php @@ -31,16 +31,16 @@ public function testWriteCreatesYamlFile(): void { $dir = sys_get_temp_dir() . '/chorale_' . uniqid(); @mkdir($dir); - $this->backup->expects(self::once())->method('backup')->with($dir . '/conf.yaml')->willReturn($dir . '/.chorale/backup/conf.yaml.bak'); + $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]); - self::assertFileExists($dir . '/conf.yaml'); + $this->assertFileExists($dir . '/conf.yaml'); } #[Test] public function testWriteThrowsWhenTempFileCannotBeWritten(): void { - $this->backup->expects(self::once())->method('backup')->with($this->anything())->willReturn('/tmp/x'); + $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/Discovery/PackageIdentityTest.php b/tools/chorale/src/Tests/Discovery/PackageIdentityTest.php index 23d00abf..a35d3fd0 100644 --- a/tools/chorale/src/Tests/Discovery/PackageIdentityTest.php +++ b/tools/chorale/src/Tests/Discovery/PackageIdentityTest.php @@ -20,7 +20,7 @@ public function testIdentityPrefersRepoUrlAndNormalizes(): void { $pi = new PackageIdentity(); $id = $pi->identityFor('unused', 'SSH://GitHub.com/SonsOfPHP/Cookie.git'); - self::assertSame('github.com/sonsofphp/cookie.git', $id); + $this->assertSame('github.com/sonsofphp/cookie.git', $id); } #[Test] @@ -28,6 +28,6 @@ public function testIdentityFallsBackToLeaf(): void { $pi = new PackageIdentity(); $id = $pi->identityFor('src/SonsOfPHP/Cookie'); - self::assertSame('cookie', $id); + $this->assertSame('cookie', $id); } } diff --git a/tools/chorale/src/Tests/Discovery/PatternMatcherTest.php b/tools/chorale/src/Tests/Discovery/PatternMatcherTest.php index ca091e13..db9ce1d5 100644 --- a/tools/chorale/src/Tests/Discovery/PatternMatcherTest.php +++ b/tools/chorale/src/Tests/Discovery/PatternMatcherTest.php @@ -21,19 +21,23 @@ private function stubPaths(callable $fn): PathUtilsInterface { return new class ($fn) implements PathUtilsInterface { public function __construct(private $fn) {} + 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 { $f = $this->fn; return (bool) $f($pattern, $path); } + public function leaf(string $path): string { return $path; @@ -44,21 +48,21 @@ public function leaf(string $path): string #[Test] public function testFirstMatchReturnsIndex(): void { - $pm = new PatternMatcher($this->stubPaths(fn($pat, $p) => $pat === 'src/*/Cookie')); + $pm = new PatternMatcher($this->stubPaths(fn($pat, $p): bool => $pat === 'src/*/Cookie')); $idx = $pm->firstMatch([ ['match' => 'src/*/Cookie'], ], 'src/SonsOfPHP/Cookie'); - self::assertSame(0, $idx); + $this->assertSame(0, $idx); } #[Test] public function testAllMatchesReturnsAllIndexes(): void { - $pm = new PatternMatcher($this->stubPaths(fn($pat, $path) => in_array($pat, ['src/*/Cookie','src/SonsOfPHP/*'], true))); + $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'); - self::assertSame([0,1], $idx); + $this->assertSame([0,1], $idx); } } diff --git a/tools/chorale/src/Tests/IO/BackupManagerTest.php b/tools/chorale/src/Tests/IO/BackupManagerTest.php index 54f12bbe..31930527 100644 --- a/tools/chorale/src/Tests/IO/BackupManagerTest.php +++ b/tools/chorale/src/Tests/IO/BackupManagerTest.php @@ -22,7 +22,7 @@ public function testBackupCreatesPlaceholderWhenFileMissing(): void $dir = sys_get_temp_dir() . '/chorale_' . uniqid(); @mkdir($dir); $dest = $bm->backup($dir . '/file.yaml'); - self::assertFileExists($dest); + $this->assertFileExists($dest); } #[Test] @@ -36,6 +36,6 @@ public function testRestoreCopiesBackupToTarget(): void $backup = $bm->backup($srcFile); $target = $dir . '/restored.yaml'; $bm->restore($backup, $target); - self::assertFileExists($target); + $this->assertFileExists($target); } } diff --git a/tools/chorale/src/Tests/IO/JsonReporterTest.php b/tools/chorale/src/Tests/IO/JsonReporterTest.php index 2e0b12f7..541f54ef 100644 --- a/tools/chorale/src/Tests/IO/JsonReporterTest.php +++ b/tools/chorale/src/Tests/IO/JsonReporterTest.php @@ -20,7 +20,7 @@ public function testBuildReturnsPrettyPrintedJsonWithNewline(): void { $jr = new JsonReporter(); $json = $jr->build(['a' => 'b'], ['new' => []], [['action' => 'none']]); - self::assertStringEndsWith("\n", $json); + $this->assertStringEndsWith("\n", $json); } #[Test] @@ -28,6 +28,6 @@ public function testBuildIncludesDefaultsKey(): void { $jr = new JsonReporter(); $json = $jr->build(['a' => 'b'], ['new' => []], [['action' => 'none']]); - self::assertStringContainsString('"defaults"', $json); + $this->assertStringContainsString('"defaults"', $json); } } diff --git a/tools/chorale/src/Tests/Rules/ConflictDetectorTest.php b/tools/chorale/src/Tests/Rules/ConflictDetectorTest.php index 868be7c3..6e848714 100644 --- a/tools/chorale/src/Tests/Rules/ConflictDetectorTest.php +++ b/tools/chorale/src/Tests/Rules/ConflictDetectorTest.php @@ -22,19 +22,23 @@ private function stubPaths(callable $fn): PathUtilsInterface { return new class ($fn) implements PathUtilsInterface { public function __construct(private $fn) {} + 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 { $f = $this->fn; return (bool) $f($pattern, $path); } + public function leaf(string $path): string { return $path; @@ -45,22 +49,22 @@ public function leaf(string $path): string #[Test] public function testDetectReportsConflictWhenMultiplePatternsMatch(): void { - $cd = new ConflictDetector(new PatternMatcher($this->stubPaths(fn($pat, $p) => in_array($pat, ['src/*/Cookie','src/SonsOfPHP/*'], true)))); + $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'); - self::assertTrue($res['conflict']); + $this->assertTrue($res['conflict']); } #[Test] public function testDetectReturnsMatchedIndexes(): void { - $cd = new ConflictDetector(new PatternMatcher($this->stubPaths(fn($pat, $p) => in_array($pat, ['src/*/Cookie','src/SonsOfPHP/*'], true)))); + $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'); - self::assertSame([0,1], $res['matches']); + $this->assertSame([0,1], $res['matches']); } } diff --git a/tools/chorale/src/Tests/Rules/RequiredFilesCheckerTest.php b/tools/chorale/src/Tests/Rules/RequiredFilesCheckerTest.php index 271ce934..b9954e9a 100644 --- a/tools/chorale/src/Tests/Rules/RequiredFilesCheckerTest.php +++ b/tools/chorale/src/Tests/Rules/RequiredFilesCheckerTest.php @@ -25,6 +25,7 @@ private function makePackage(bool $withFiles = true): array file_put_contents($pkg . '/composer.json', '{}'); file_put_contents($pkg . '/LICENSE', ''); } + return [$root, 'src/Acme/Lib']; } @@ -34,7 +35,7 @@ public function testMissingReturnsEmptyWhenAllPresent(): void [$root, $pkg] = $this->makePackage(true); $c = new RequiredFilesChecker(); $miss = $c->missing($root, $pkg, ['composer.json','LICENSE']); - self::assertSame([], $miss); + $this->assertSame([], $miss); } #[Test] @@ -43,6 +44,6 @@ public function testMissingReturnsListOfMissing(): void [$root, $pkg] = $this->makePackage(false); $c = new RequiredFilesChecker(); $miss = $c->missing($root, $pkg, ['composer.json','LICENSE']); - self::assertSame(['composer.json','LICENSE'], $miss); + $this->assertSame(['composer.json','LICENSE'], $miss); } } diff --git a/tools/chorale/src/Tests/Telemetry/RunSummaryTest.php b/tools/chorale/src/Tests/Telemetry/RunSummaryTest.php index 951a76fe..527e8e4b 100644 --- a/tools/chorale/src/Tests/Telemetry/RunSummaryTest.php +++ b/tools/chorale/src/Tests/Telemetry/RunSummaryTest.php @@ -20,7 +20,7 @@ public function testIncIncrementsBucket(): void { $rs = new RunSummary(); $rs->inc('new'); - self::assertSame(['new' => 1], $rs->all()); + $this->assertSame(['new' => 1], $rs->all()); } #[Test] @@ -29,7 +29,8 @@ public function testAllReturnsSortedKeys(): void $rs = new RunSummary(); $rs->inc('z'); $rs->inc('a'); + $all = $rs->all(); - self::assertSame(['a','z'], array_keys($all)); + $this->assertSame(['a','z'], array_keys($all)); } } diff --git a/tools/chorale/src/Tests/Util/SortingTest.php b/tools/chorale/src/Tests/Util/SortingTest.php index ebcf3985..dd681adb 100644 --- a/tools/chorale/src/Tests/Util/SortingTest.php +++ b/tools/chorale/src/Tests/Util/SortingTest.php @@ -24,7 +24,7 @@ public function testSortPatternsPrefersLongerMatchFirst(): void ['match' => 'a/b/c'], ]; $out = $s->sortPatterns($in); - self::assertSame('a/b/c', $out[0]['match']); + $this->assertSame('a/b/c', $out[0]['match']); } #[Test] @@ -36,7 +36,7 @@ public function testSortPatternsTiesAlphabetically(): void ['match' => 'a/b'], ]; $out = $s->sortPatterns($in); - self::assertSame('a/b', $out[0]['match']); + $this->assertSame('a/b', $out[0]['match']); } #[Test] @@ -48,7 +48,7 @@ public function testSortTargetsPrimaryByPath(): void ['path' => 'a', 'name' => 'z'], ]; $out = $s->sortTargets($in); - self::assertSame('a', $out[0]['path']); + $this->assertSame('a', $out[0]['path']); } #[Test] @@ -60,6 +60,6 @@ public function testSortTargetsSecondaryByNameWhenSamePath(): void ['path' => 'a', 'name' => 'a'], ]; $out = $s->sortTargets($in); - self::assertSame('a', $out[0]['name']); + $this->assertSame('a', $out[0]['name']); } } diff --git a/tools/chorale/src/Util/PathUtils.php b/tools/chorale/src/Util/PathUtils.php index 02686ed8..97c602dd 100644 --- a/tools/chorale/src/Util/PathUtils.php +++ b/tools/chorale/src/Util/PathUtils.php @@ -4,6 +4,16 @@ namespace Chorale\Util; +/** + * Path utilities for normalizing, matching, and extracting path segments. + * + * Examples: + * - normalize('src//Foo/./Bar/..') => '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 @@ -15,18 +25,25 @@ public function normalize(string $path): string if ($p !== '/' && str_ends_with($p, '/')) { $p = rtrim($p, '/'); } + // resolve "." and ".." cheaply (string-level, not FS) $parts = []; foreach (explode('/', $p) as $seg) { - if ($seg === '' || $seg === '.') { + if ($seg === '') { + continue; + } + if ($seg === '.') { continue; } + if ($seg === '..') { array_pop($parts); continue; } + $parts[] = $seg; } + $out = implode('/', $parts); return $out === '' ? '.' : $out; } diff --git a/tools/chorale/src/Util/Sorting.php b/tools/chorale/src/Util/Sorting.php index b1e23640..197ce3b1 100644 --- a/tools/chorale/src/Util/Sorting.php +++ b/tools/chorale/src/Util/Sorting.php @@ -4,6 +4,13 @@ namespace Chorale\Util; +/** + * Deterministic sort helpers for patterns and targets. + * + * Examples: + * - sortPatterns([{match: 'a/b'}, {match: 'a/b/c'}]) => '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 @@ -16,6 +23,7 @@ public function sortPatterns(array $patterns): array if ($al === $bl) { return $am <=> $bm; } + // longer match first (more specific wins) return $bl <=> $al; }); @@ -33,6 +41,7 @@ public function sortTargets(array $targets): array $bn = (string) ($b['name'] ?? ''); return $an <=> $bn; } + return $ap <=> $bp; }); From 3bd5f015c7633c60ce92236ff511854e95078371 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Tue, 19 Aug 2025 20:19:39 -0400 Subject: [PATCH 09/10] Document Chorale tool and fix DependencyMerger --- AGENTS.md | 8 ++ docs/SUMMARY.md | 4 + docs/tools/chorale.md | 17 +++ tools/chorale/AGENTS.md | 8 ++ .../chorale/src/Composer/DependencyMerger.php | 125 +++++++++--------- .../Tests/Composer/DependencyMergerTest.php | 70 ++++++++++ 6 files changed, 170 insertions(+), 62 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/tools/chorale.md create mode 100644 tools/chorale/AGENTS.md create mode 100644 tools/chorale/src/Tests/Composer/DependencyMergerTest.php diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..b0ad8ec6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# AGENTS + +This repository contains multiple projects and tools that are maintained here. + +- 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. + 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..2404579f --- /dev/null +++ b/docs/tools/chorale.md @@ -0,0 +1,17 @@ +# Chorale + +Chorale is a CLI tool for managing PHP monorepos. + +## Getting started + +```bash +cd tools/chorale +composer install +php bin/chorale +``` + +## Commands + +- `setup` – generate configuration and validate required files. +- `plan` – build a plan for splitting packages from the monorepo. + diff --git a/tools/chorale/AGENTS.md b/tools/chorale/AGENTS.md new file mode 100644 index 00000000..286f8153 --- /dev/null +++ b/tools/chorale/AGENTS.md @@ -0,0 +1,8 @@ +# 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. + diff --git a/tools/chorale/src/Composer/DependencyMerger.php b/tools/chorale/src/Composer/DependencyMerger.php index 067d3c05..ee3e8fce 100644 --- a/tools/chorale/src/Composer/DependencyMerger.php +++ b/tools/chorale/src/Composer/DependencyMerger.php @@ -12,109 +12,106 @@ public function __construct( public function computeRootMerge(string $projectRoot, array $packagePaths, array $options = []): array { - - $opts = [ - 'strategy_require' => (string) ($options['strategy_require'] ?? 'union-caret'), - 'strategy_require_dev' => (string) ($options['strategy_require-dev'] ?? 'union-caret'), + $normalizedOptions = [ + 'strategy_require' => (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'] ?? []), + 'monorepo_names' => (array) ($options['monorepo_names'] ?? []), ]; - $monorepo = array_map('strtolower', array_values($opts['monorepo_names'])); + $monorepoNames = array_map('strtolower', array_values($normalizedOptions['monorepo_names'])); - $reqs = []; - $devs = []; - $byDepConstraints = [ - 'require' => [], + $requiredDependencies = []; + $devDependencies = []; + $constraintsByDependency = [ + 'require' => [], 'require-dev' => [], ]; - foreach ($packagePaths as $relPath) { - $pc = $this->reader->read(rtrim($projectRoot, '/') . '/' . $relPath . '/composer.json'); - if ($pc === []) { + foreach ($packagePaths as $relativePath) { + $composerJson = $this->reader->read(rtrim($projectRoot, '/') . '/' . $relativePath . '/composer.json'); + if ($composerJson === []) { continue; } - $name = strtolower((string) ($pc['name'] ?? $relPath)); - foreach ((array) ($pc['require'] ?? []) as $dep => $ver) { - if (!is_string($dep)) { + $packageName = strtolower((string) ($composerJson['name'] ?? $relativePath)); + foreach ((array) ($composerJson['require'] ?? []) as $dependency => $version) { + if (!is_string($dependency) || !is_string($version)) { continue; } - if (!is_string($ver)) { + if ($normalizedOptions['exclude_monorepo_packages'] && in_array(strtolower($dependency), $monorepoNames, true)) { continue; } - if ($opts['exclude_monorepo_packages'] && in_array(strtolower($dep), $monorepo, true)) { - continue; - } - - $byDepConstraints['require'][$dep][$name] = $ver; + $constraintsByDependency['require'][$dependency][$packageName] = $version; } - foreach ((array) ($pc['require-dev'] ?? []) as $dep => $ver) { - if (!is_string($dep)) { + foreach ((array) ($composerJson['require-dev'] ?? []) as $dependency => $version) { + if (!is_string($dependency) || !is_string($version)) { continue; } - if (!is_string($ver)) { + if ($normalizedOptions['exclude_monorepo_packages'] && in_array(strtolower($dependency), $monorepoNames, true)) { continue; } - if ($opts['exclude_monorepo_packages'] && in_array(strtolower($dep), $monorepo, true)) { - continue; - } - - $byDepConstraints['require-dev'][$dep][$name] = $ver; + $constraintsByDependency['require-dev'][$dependency][$packageName] = $version; } } $conflicts = []; - $reqs = $this->mergeMap($byDepConstraints['require'], $opts['strategy_require'], $conflicts); - $devs = $this->mergeMap($byDepConstraints['require-dev'], $opts['strategy_require_dev'], $conflicts); + $requiredDependencies = $this->mergeMap($constraintsByDependency['require'], $normalizedOptions['strategy_require'], $conflicts); + $devDependencies = $this->mergeMap($constraintsByDependency['require-dev'], $normalizedOptions['strategy_require_dev'], $conflicts); - ksort($reqs); - ksort($devs); + ksort($requiredDependencies); + ksort($devDependencies); return [ - 'require' => $reqs, - 'require-dev' => $devs, + 'require' => $requiredDependencies, + 'require-dev' => $devDependencies, 'conflicts' => array_values($conflicts), ]; } /** - * @param array> $constraintsPerDep + * @param array> $constraintsPerDependency * @param array> $conflictsOut * @return array */ - private function mergeMap(array $constraintsPerDep, string $strategy, array &$conflictsOut): array + private function mergeMap(array $constraintsPerDependency, string $strategy, array &$conflictsOut): array { - $out = []; - foreach ($constraintsPerDep as $dep => $byPkg) { - $constraint = $this->chooseConstraint(array_values($byPkg), $strategy, $dep, $byPkg, $conflictsOut); + $mergedConstraints = []; + foreach ($constraintsPerDependency as $dependency => $versionsByPackage) { + $constraint = $this->chooseConstraint( + array_values($versionsByPackage), + $strategy, + $dependency, + $versionsByPackage, + $conflictsOut + ); if ($constraint !== null) { - $out[$dep] = $constraint; + $mergedConstraints[$dependency] = $constraint; } } - return $out; + return $mergedConstraints; } /** * @param list $constraints - * @param array $byPkg + * @param array $versionsByPackage */ - private function chooseConstraint(array $constraints, string $strategy, string $dep, array $byPkg, array &$conflictsOut): ?string + private function chooseConstraint(array $constraints, string $strategy, string $dependency, array $versionsByPackage, array &$conflictsOut): ?string { $strategy = strtolower($strategy); - $norm = array_map([$this,'normalizeConstraint'], array_filter($constraints, 'is_string')); - if ($norm === []) { + $normalized = array_map([$this,'normalizeConstraint'], array_filter($constraints, 'is_string')); + if ($normalized === []) { return null; } if ($strategy === 'union-caret') { - return $this->chooseUnionCaret($norm, $dep, $byPkg, $conflictsOut); + return $this->chooseUnionCaret($normalized, $dependency, $versionsByPackage, $conflictsOut); } if ($strategy === 'union-loose') { @@ -122,37 +119,41 @@ private function chooseConstraint(array $constraints, string $strategy, string $ } if ($strategy === 'max') { - return $this->maxLowerBound($norm); + return $this->maxLowerBound($normalized); } if ($strategy === 'intersect') { // naive: if all share same major series, pick max lower bound; else conflict - $majors = array_unique(array_map(static fn($c): int => $c['major'], $norm)); - if (count($majors) > 1) { - $this->recordConflict($dep, $byPkg, $conflictsOut, 'intersect-empty'); + $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($norm); + return $this->maxLowerBound($normalized); } // default fallback - return $this->chooseUnionCaret($norm, $dep, $byPkg, $conflictsOut); + return $this->chooseUnionCaret($normalized, $dependency, $versionsByPackage, $conflictsOut); } /** @param list $norm */ - private function chooseUnionCaret(array $norm, string $dep, array $byPkg, array &$conflictsOut): string + 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($dep, $byPkg, $conflictsOut, 'non-caret-mixed'); + $this->recordConflict($dependency, $versionsByPackage, $conflictsOut, 'non-caret-mixed'); return $this->maxLowerBound($norm); } @@ -169,13 +170,13 @@ private function maxLowerBound(array $norm): string return $best['raw']; } - /** @param array $byPkg */ - private function recordConflict(string $dep, array $byPkg, array &$conflictsOut, string $reason): void + /** @param array $versionsByPackage */ + private function recordConflict(string $dependency, array $versionsByPackage, array &$conflictsOut, string $reason): void { - $conflictsOut[$dep] = [ - 'package' => $dep, - 'versions' => array_values(array_unique(array_values($byPkg))), - 'packages' => array_keys($byPkg), + $conflictsOut[$dependency] = [ + 'package' => $dependency, + 'versions' => array_values(array_unique(array_values($versionsByPackage))), + 'packages' => array_keys($versionsByPackage), 'reason' => $reason, ]; } 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']); + } +} + From 4f77c1f309b4faa8fcce7782c54721e9b8b69ba4 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Tue, 19 Aug 2025 20:56:47 -0400 Subject: [PATCH 10/10] feat(chorale): merge root composer dependencies --- .gitignore | 1 + AGENTS.md | 1 + docs/tools/chorale.md | 30 +++- tools/chorale/AGENTS.md | 6 + tools/chorale/bin/chorale | 28 ++++ tools/chorale/src/Console/ApplyCommand.php | 64 +++++++ tools/chorale/src/Console/PlanCommand.php | 1 + tools/chorale/src/Console/RunCommand.php | 157 ++++++++++++++++++ tools/chorale/src/Console/SetupCommand.php | 1 + ...eStep.php => PackageVersionUpdateStep.php} | 0 .../src/Run/ComposerRootUpdateExecutor.php | 54 ++++++ .../src/Run/PackageVersionUpdateExecutor.php | 45 +++++ .../src/Run/RootDependencyMergeExecutor.php | 49 ++++++ tools/chorale/src/Run/Runner.php | 44 +++++ tools/chorale/src/Run/RunnerInterface.php | 19 +++ .../chorale/src/Run/StepExecutorInterface.php | 17 ++ .../chorale/src/Run/StepExecutorRegistry.php | 42 +++++ .../src/Tests/Console/ApplyCommandTest.php | 38 +++++ .../src/Tests/Console/RunCommandTest.php | 39 +++++ .../Run/ComposerRootUpdateExecutorTest.php | 41 +++++ .../Run/PackageVersionUpdateExecutorTest.php | 32 ++++ .../Run/RootDependencyMergeExecutorTest.php | 59 +++++++ tools/chorale/src/Tests/Run/RunnerTest.php | 44 +++++ .../Tests/Run/StepExecutorRegistryTest.php | 41 +++++ 24 files changed, 848 insertions(+), 5 deletions(-) create mode 100644 tools/chorale/src/Console/ApplyCommand.php create mode 100644 tools/chorale/src/Console/RunCommand.php rename tools/chorale/src/Plan/{VersionUpdateStep.php => PackageVersionUpdateStep.php} (100%) create mode 100644 tools/chorale/src/Run/ComposerRootUpdateExecutor.php create mode 100644 tools/chorale/src/Run/PackageVersionUpdateExecutor.php create mode 100644 tools/chorale/src/Run/RootDependencyMergeExecutor.php create mode 100644 tools/chorale/src/Run/Runner.php create mode 100644 tools/chorale/src/Run/RunnerInterface.php create mode 100644 tools/chorale/src/Run/StepExecutorInterface.php create mode 100644 tools/chorale/src/Run/StepExecutorRegistry.php create mode 100644 tools/chorale/src/Tests/Console/ApplyCommandTest.php create mode 100644 tools/chorale/src/Tests/Console/RunCommandTest.php create mode 100644 tools/chorale/src/Tests/Run/ComposerRootUpdateExecutorTest.php create mode 100644 tools/chorale/src/Tests/Run/PackageVersionUpdateExecutorTest.php create mode 100644 tools/chorale/src/Tests/Run/RootDependencyMergeExecutorTest.php create mode 100644 tools/chorale/src/Tests/Run/RunnerTest.php create mode 100644 tools/chorale/src/Tests/Run/StepExecutorRegistryTest.php diff --git a/.gitignore b/.gitignore index 551d82a8..52338d60 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ results.sarif infection.log .churn.cache tools/chorale/composer.lock +tools/chorale/.phpunit.cache/ diff --git a/AGENTS.md b/AGENTS.md index b0ad8ec6..eed09ba9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,4 +5,5 @@ This repository contains multiple projects and tools that are maintained here. - 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. diff --git a/docs/tools/chorale.md b/docs/tools/chorale.md index 2404579f..13d78125 100644 --- a/docs/tools/chorale.md +++ b/docs/tools/chorale.md @@ -1,17 +1,37 @@ # Chorale -Chorale is a CLI tool for managing PHP monorepos. +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. -## Getting started +## Installation ```bash cd tools/chorale composer install -php bin/chorale ``` +## 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 from the monorepo. - +- `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/tools/chorale/AGENTS.md b/tools/chorale/AGENTS.md index 286f8153..b10cc203 100644 --- a/tools/chorale/AGENTS.md +++ b/tools/chorale/AGENTS.md @@ -6,3 +6,9 @@ Chorale is a CLI tool maintained in this repository. - 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 index 8c04a4f8..734dac7e 100755 --- a/tools/chorale/bin/chorale +++ b/tools/chorale/bin/chorale @@ -33,6 +33,13 @@ use Chorale\State\FilesystemStateStore; use Chorale\Util\DiffUtil; use Chorale\Plan\PlanBuilder; use Chorale\Console\PlanCommand; +use Chorale\Console\ApplyCommand; +use Chorale\Console\RunCommand; +use Chorale\Run\Runner; +use Chorale\Run\StepExecutorRegistry; +use Chorale\Run\PackageVersionUpdateExecutor; +use Chorale\Run\RootDependencyMergeExecutor; +use Chorale\Run\ComposerRootUpdateExecutor; $paths = new PathUtils(); $renderer = new TemplateRenderer(); @@ -73,6 +80,17 @@ $planner = new PlanBuilder( splitDecider: $splitDecider, diffs: $diffs, ); +$executors = new StepExecutorRegistry([ + new PackageVersionUpdateExecutor(), + new RootDependencyMergeExecutor(), + new ComposerRootUpdateExecutor(), +]); +$runner = new Runner( + configLoader: $loader, + planner: $planner, + executors: $executors, +); + // ----------------------------------------------------------------------------- $app = new Application('Chorale', '0.1.0'); @@ -105,6 +123,16 @@ $app->add(new PlanCommand( 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/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 index f30c2d8c..6be3087d 100644 --- a/tools/chorale/src/Console/PlanCommand.php +++ b/tools/chorale/src/Console/PlanCommand.php @@ -37,6 +37,7 @@ protected function configure(): void $this ->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.') 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 index 012bebf9..ad6ad535 100644 --- a/tools/chorale/src/Console/SetupCommand.php +++ b/tools/chorale/src/Console/SetupCommand.php @@ -51,6 +51,7 @@ protected function configure(): void $this ->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.') diff --git a/tools/chorale/src/Plan/VersionUpdateStep.php b/tools/chorale/src/Plan/PackageVersionUpdateStep.php similarity index 100% rename from tools/chorale/src/Plan/VersionUpdateStep.php rename to tools/chorale/src/Plan/PackageVersionUpdateStep.php 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/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/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']); + } +}