Skip to content

Commit 97ae0b7

Browse files
committed
Chorale initial commit
1 parent 4bbc133 commit 97ae0b7

61 files changed

Lines changed: 3119 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ packages.json
1616
results.sarif
1717
infection.log
1818
.churn.cache
19+
tools/chorale/composer.lock

tools/chorale/bin/chorale

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/usr/bin/env php
2+
<?php declare(strict_types=1);
3+
4+
require __DIR__ . '/../vendor/autoload.php';
5+
6+
use Symfony\Component\Console\Application;
7+
use Chorale\Console\Style\ConsoleStyleFactory;
8+
use Chorale\Console\SetupCommand;
9+
10+
use Chorale\Repo\TemplateRenderer;
11+
use Chorale\Util\PathUtils;
12+
use Chorale\Util\Sorting;
13+
use Chorale\Discovery\PackageIdentity;
14+
use Chorale\Config\ConfigDefaults;
15+
use Chorale\Config\SchemaValidator;
16+
use Chorale\IO\BackupManager;
17+
use Chorale\IO\JsonReporter;
18+
use Chorale\Telemetry\RunSummary;
19+
20+
use Chorale\Config\ConfigLoader;
21+
use Chorale\Config\ConfigWriter;
22+
use Chorale\Config\ConfigNormalizer;
23+
use Chorale\Discovery\ComposerMetadata;
24+
use Chorale\Discovery\PackageScanner;
25+
use Chorale\Discovery\PatternMatcher;
26+
use Chorale\Repo\RepoResolver;
27+
use Chorale\Rules\RequiredFilesChecker;
28+
use Chorale\Rules\ConflictDetector;
29+
30+
$paths = new PathUtils();
31+
$renderer = new TemplateRenderer();
32+
$sorting = new Sorting();
33+
$identity = new PackageIdentity();
34+
$defaults = new ConfigDefaults();
35+
$schema = new SchemaValidator();
36+
$backup = new BackupManager();
37+
$json = new JsonReporter();
38+
$summary = new RunSummary();
39+
40+
$loader = new ConfigLoader();
41+
$writer = new ConfigWriter($backup);
42+
$normalizer = new ConfigNormalizer($sorting, $defaults);
43+
$composerMeta = new ComposerMetadata();
44+
$scanner = new PackageScanner($paths);
45+
$matcher = new PatternMatcher($paths);
46+
$resolver = new RepoResolver($renderer, $paths);
47+
$required = new RequiredFilesChecker();
48+
$conflicts = new ConflictDetector($matcher);
49+
50+
$app = new Application('Chorale', '0.1.0');
51+
$app->add(new SetupCommand(
52+
styleFactory: new ConsoleStyleFactory(),
53+
configLoader: $loader,
54+
configWriter: $writer,
55+
configNormalizer: $normalizer,
56+
schemaValidator: $schema,
57+
defaults: $defaults,
58+
scanner: $scanner,
59+
matcher: $matcher,
60+
resolver: $resolver,
61+
identity: $identity,
62+
requiredFiles: $required,
63+
conflicts: $conflicts,
64+
jsonReporter: $json,
65+
summary: $summary,
66+
composerMeta: $composerMeta,
67+
));
68+
$app->run();

tools/chorale/composer.json

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"name": "sonsofphp/chorale",
3+
"description": "Chorale: a CLI tool to help manage PHP monorepos.",
4+
"type": "project",
5+
"license": "MIT",
6+
"require": {
7+
"php": "^8.3",
8+
"ext-mbstring": "*",
9+
"symfony/console": "^7.0",
10+
"symfony/yaml": "^7.0"
11+
},
12+
"require-dev": {
13+
"phpunit/phpunit": "^10.0",
14+
"symfony/var-dumper": "^7.3"
15+
},
16+
"autoload": {
17+
"psr-4": {
18+
"Chorale\\": "src/"
19+
}
20+
},
21+
"autoload-dev": {
22+
"psr-4": {
23+
"Chorale\\Tests\\": "src/Tests/"
24+
}
25+
},
26+
"bin": [
27+
"bin/chorale"
28+
],
29+
"config": {
30+
"sort-packages": true,
31+
"preferred-install": "dist"
32+
},
33+
"minimum-stability": "stable",
34+
"prefer-stable": true
35+
}

tools/chorale/phpunit.xml.dist

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
4+
bootstrap="vendor/autoload.php"
5+
cacheDirectory=".phpunit.cache"
6+
requireCoverageMetadata="true"
7+
backupGlobals="false"
8+
colors="true"
9+
cacheResult="true"
10+
executionOrder="defects"
11+
beStrictAboutCoverageMetadata="true"
12+
stopOnDefect="true"
13+
stopOnError="true"
14+
stopOnFailure="true"
15+
stopOnWarning="true"
16+
stopOnDeprecation="true"
17+
stopOnNotice="true"
18+
displayDetailsOnIncompleteTests="true"
19+
displayDetailsOnSkippedTests="true"
20+
displayDetailsOnTestsThatTriggerDeprecations="true"
21+
displayDetailsOnPhpunitDeprecations="true"
22+
displayDetailsOnTestsThatTriggerErrors="true"
23+
displayDetailsOnTestsThatTriggerNotices="true"
24+
displayDetailsOnTestsThatTriggerWarnings="true"
25+
>
26+
27+
<php>
28+
<ini name="error_reporting" value="-1" />
29+
</php>
30+
<testsuites>
31+
<testsuite name="Chorale Test Suite">
32+
<directory>src/Tests</directory>
33+
</testsuite>
34+
</testsuites>
35+
36+
<coverage includeUncoveredFiles="true" pathCoverage="false" ignoreDeprecatedCodeUnits="true" disableCodeCoverageIgnore="false" />
37+
38+
<source>
39+
<include>
40+
<directory suffix=".php">src</directory>
41+
</include>
42+
<exclude>
43+
<directory>src/Tests</directory>
44+
</exclude>
45+
</source>
46+
</phpunit>
47+
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Chorale\Config;
6+
7+
final class ConfigDefaults implements ConfigDefaultsInterface
8+
{
9+
/** @var array<string, mixed> */
10+
private array $fallbacks = [
11+
'repo_host' => 'git@github.com',
12+
'repo_vendor' => 'SonsOfPHP',
13+
'repo_name_template' => '{name:kebab}.git',
14+
'default_repo_template' => '{repo_host}:{repo_vendor}/{repo_name_template}',
15+
'default_branch' => 'main',
16+
'splitter' => 'splitsh',
17+
'tag_strategy' => 'inherit-monorepo-tag',
18+
'rules' => [
19+
'keep_history' => true,
20+
'skip_if_unchanged' => true,
21+
'require_files' => ['composer.json', 'LICENSE'],
22+
],
23+
];
24+
25+
public function resolve(array $config): array
26+
{
27+
$out = $this->fallbacks;
28+
29+
foreach (array_keys($this->fallbacks) as $k) {
30+
if (array_key_exists($k, $config)) {
31+
if ($k === 'rules') {
32+
$out['rules'] = array_merge($out['rules'], (array) $config['rules']);
33+
} else {
34+
$out[$k] = (string) $config[$k];
35+
}
36+
}
37+
}
38+
39+
// If the template explicitly provided, keep it;
40+
// otherwise compute from the resolved parts.
41+
if (!isset($config['default_repo_template']) || $config['default_repo_template'] === '') {
42+
$out['default_repo_template'] = sprintf(
43+
'%s:%s/%s',
44+
$out['repo_host'],
45+
$out['repo_vendor'],
46+
$out['repo_name_template']
47+
);
48+
}
49+
50+
return $out;
51+
}
52+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Chorale\Config;
6+
7+
interface ConfigDefaultsInterface
8+
{
9+
/**
10+
* @param array<string, mixed> $config Raw parsed YAML or empty array.
11+
* @return array{
12+
* repo_host: string,
13+
* repo_vendor: string,
14+
* repo_name_template: string,
15+
* default_repo_template: string,
16+
* default_branch: string,
17+
* splitter: string,
18+
* tag_strategy: string,
19+
* rules: array<string, mixed>
20+
* }
21+
*/
22+
public function resolve(array $config): array;
23+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Chorale\Config;
6+
7+
use Symfony\Component\Yaml\Yaml;
8+
9+
final class ConfigLoader implements ConfigLoaderInterface
10+
{
11+
public function __construct(
12+
private readonly string $fileName = 'chorale.yaml'
13+
) {}
14+
15+
public function load(string $projectRoot): array
16+
{
17+
$path = rtrim($projectRoot, '/') . '/' . $this->fileName;
18+
if (!is_file($path)) {
19+
return [];
20+
}
21+
$raw = file_get_contents($path);
22+
if ($raw === false) {
23+
throw new \RuntimeException("Failed to read {$path}");
24+
}
25+
$data = Yaml::parse($raw);
26+
return is_array($data) ? $data : [];
27+
}
28+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Chorale\Config;
6+
7+
interface ConfigLoaderInterface
8+
{
9+
/** Load chorale.yaml (if present) into an array; return [] when missing. */
10+
public function load(string $projectRoot): array;
11+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Chorale\Config;
6+
7+
use Chorale\Util\SortingInterface;
8+
9+
final class ConfigNormalizer implements ConfigNormalizerInterface
10+
{
11+
public function __construct(
12+
private readonly SortingInterface $sorting,
13+
private readonly ConfigDefaultsInterface $defaults
14+
) {}
15+
16+
public function normalize(array $config): array
17+
{
18+
$def = $this->defaults->resolve($config);
19+
20+
// drop redundant overrides in patterns
21+
$patterns = (array) ($config['patterns'] ?? []);
22+
foreach ($patterns as &$p) {
23+
$p = (array) $p;
24+
foreach (['repo_host','repo_vendor','repo_name_template'] as $k) {
25+
if (isset($p[$k]) && (string) $p[$k] === (string) $def[$k]) {
26+
unset($p[$k]);
27+
}
28+
}
29+
}
30+
unset($p);
31+
$patterns = $this->sorting->sortPatterns($patterns);
32+
33+
// drop redundant overrides in targets
34+
$targets = (array) ($config['targets'] ?? []);
35+
foreach ($targets as &$t) {
36+
$t = (array) $t;
37+
foreach (['repo_host','repo_vendor','repo_name_template'] as $k) {
38+
if (isset($t[$k]) && (string) $t[$k] === (string) $def[$k]) {
39+
unset($t[$k]);
40+
}
41+
}
42+
}
43+
unset($t);
44+
$targets = $this->sorting->sortTargets($targets);
45+
46+
// Rebuild config with stable top-level key order
47+
$out = [
48+
'version' => $config['version'] ?? 1,
49+
'repo_host' => $def['repo_host'],
50+
'repo_vendor' => $def['repo_vendor'],
51+
'repo_name_template' => $def['repo_name_template'],
52+
'default_repo_template' => $def['default_repo_template'],
53+
'default_branch' => $def['default_branch'],
54+
'splitter' => $def['splitter'],
55+
'tag_strategy' => $def['tag_strategy'],
56+
'rules' => $def['rules'],
57+
];
58+
if ($patterns !== []) {
59+
$out['patterns'] = $patterns;
60+
}
61+
if ($targets !== []) {
62+
$out['targets'] = $targets;
63+
}
64+
if (!empty($config['hooks'])) {
65+
$out['hooks'] = array_values((array) $config['hooks']);
66+
}
67+
68+
return $out;
69+
}
70+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Chorale\Config;
6+
7+
interface ConfigNormalizerInterface
8+
{
9+
/**
10+
* Return a normalized, DRY config:
11+
* - remove overrides equal to inherited defaults
12+
* - sort patterns/targets deterministically
13+
* - ensure minimal keys order for clean diffs
14+
* @param array<string,mixed> $config
15+
* @return array<string,mixed>
16+
*/
17+
public function normalize(array $config): array;
18+
}

0 commit comments

Comments
 (0)