Skip to content

Commit 20fbd3e

Browse files
authored
Merge pull request #1 from JoshuaEstes/codex/review-pr-and-add-agents.md
Document Chorale tool and fix dependency merger
2 parents 9a751db + 3bd5f01 commit 20fbd3e

File tree

6 files changed

+170
-62
lines changed

6 files changed

+170
-62
lines changed

AGENTS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# AGENTS
2+
3+
This repository contains multiple projects and tools that are maintained here.
4+
5+
- Use clear variable names and keep code well documented.
6+
- Run tests relevant to the areas you change.
7+
- For changes under `tools/chorale`, run `composer install` and `./vendor/bin/phpunit` in that directory before committing.
8+

docs/SUMMARY.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
* [Overview](bard/overview.md)
1212
* [Commands](bard/commands.md)
1313

14+
## 🔧 Tools
15+
16+
* [Chorale](tools/chorale.md)
17+
1418
## Symfony Bundles
1519

1620
* [Feature Toggle](symfony-bundles/feature-toggle.md)

docs/tools/chorale.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Chorale
2+
3+
Chorale is a CLI tool for managing PHP monorepos.
4+
5+
## Getting started
6+
7+
```bash
8+
cd tools/chorale
9+
composer install
10+
php bin/chorale
11+
```
12+
13+
## Commands
14+
15+
- `setup` – generate configuration and validate required files.
16+
- `plan` – build a plan for splitting packages from the monorepo.
17+

tools/chorale/AGENTS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# AGENTS
2+
3+
Chorale is a CLI tool maintained in this repository.
4+
5+
- Use descriptive variable names and document public methods.
6+
- Add unit tests for new features in `src/Tests`.
7+
- Run `composer install` and `./vendor/bin/phpunit` in this directory before committing changes.
8+

tools/chorale/src/Composer/DependencyMerger.php

Lines changed: 63 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -12,147 +12,148 @@ public function __construct(
1212

1313
public function computeRootMerge(string $projectRoot, array $packagePaths, array $options = []): array
1414
{
15-
16-
$opts = [
17-
'strategy_require' => (string) ($options['strategy_require'] ?? 'union-caret'),
18-
'strategy_require_dev' => (string) ($options['strategy_require-dev'] ?? 'union-caret'),
15+
$normalizedOptions = [
16+
'strategy_require' => (string) ($options['strategy_require'] ?? 'union-caret'),
17+
'strategy_require_dev' => (string) ($options['strategy_require-dev'] ?? 'union-caret'),
1918
'exclude_monorepo_packages' => (bool) ($options['exclude_monorepo_packages'] ?? true),
20-
'monorepo_names' => (array) ($options['monorepo_names'] ?? []),
19+
'monorepo_names' => (array) ($options['monorepo_names'] ?? []),
2120
];
2221

23-
$monorepo = array_map('strtolower', array_values($opts['monorepo_names']));
22+
$monorepoNames = array_map('strtolower', array_values($normalizedOptions['monorepo_names']));
2423

25-
$reqs = [];
26-
$devs = [];
27-
$byDepConstraints = [
28-
'require' => [],
24+
$requiredDependencies = [];
25+
$devDependencies = [];
26+
$constraintsByDependency = [
27+
'require' => [],
2928
'require-dev' => [],
3029
];
3130

32-
foreach ($packagePaths as $relPath) {
33-
$pc = $this->reader->read(rtrim($projectRoot, '/') . '/' . $relPath . '/composer.json');
34-
if ($pc === []) {
31+
foreach ($packagePaths as $relativePath) {
32+
$composerJson = $this->reader->read(rtrim($projectRoot, '/') . '/' . $relativePath . '/composer.json');
33+
if ($composerJson === []) {
3534
continue;
3635
}
3736

38-
$name = strtolower((string) ($pc['name'] ?? $relPath));
39-
foreach ((array) ($pc['require'] ?? []) as $dep => $ver) {
40-
if (!is_string($dep)) {
37+
$packageName = strtolower((string) ($composerJson['name'] ?? $relativePath));
38+
foreach ((array) ($composerJson['require'] ?? []) as $dependency => $version) {
39+
if (!is_string($dependency) || !is_string($version)) {
4140
continue;
4241
}
4342

44-
if (!is_string($ver)) {
43+
if ($normalizedOptions['exclude_monorepo_packages'] && in_array(strtolower($dependency), $monorepoNames, true)) {
4544
continue;
4645
}
4746

48-
if ($opts['exclude_monorepo_packages'] && in_array(strtolower($dep), $monorepo, true)) {
49-
continue;
50-
}
51-
52-
$byDepConstraints['require'][$dep][$name] = $ver;
47+
$constraintsByDependency['require'][$dependency][$packageName] = $version;
5348
}
5449

55-
foreach ((array) ($pc['require-dev'] ?? []) as $dep => $ver) {
56-
if (!is_string($dep)) {
50+
foreach ((array) ($composerJson['require-dev'] ?? []) as $dependency => $version) {
51+
if (!is_string($dependency) || !is_string($version)) {
5752
continue;
5853
}
5954

60-
if (!is_string($ver)) {
55+
if ($normalizedOptions['exclude_monorepo_packages'] && in_array(strtolower($dependency), $monorepoNames, true)) {
6156
continue;
6257
}
6358

64-
if ($opts['exclude_monorepo_packages'] && in_array(strtolower($dep), $monorepo, true)) {
65-
continue;
66-
}
67-
68-
$byDepConstraints['require-dev'][$dep][$name] = $ver;
59+
$constraintsByDependency['require-dev'][$dependency][$packageName] = $version;
6960
}
7061
}
7162

7263
$conflicts = [];
73-
$reqs = $this->mergeMap($byDepConstraints['require'], $opts['strategy_require'], $conflicts);
74-
$devs = $this->mergeMap($byDepConstraints['require-dev'], $opts['strategy_require_dev'], $conflicts);
64+
$requiredDependencies = $this->mergeMap($constraintsByDependency['require'], $normalizedOptions['strategy_require'], $conflicts);
65+
$devDependencies = $this->mergeMap($constraintsByDependency['require-dev'], $normalizedOptions['strategy_require_dev'], $conflicts);
7566

76-
ksort($reqs);
77-
ksort($devs);
67+
ksort($requiredDependencies);
68+
ksort($devDependencies);
7869

7970
return [
80-
'require' => $reqs,
81-
'require-dev' => $devs,
71+
'require' => $requiredDependencies,
72+
'require-dev' => $devDependencies,
8273
'conflicts' => array_values($conflicts),
8374
];
8475
}
8576

8677
/**
87-
* @param array<string,array<string,string>> $constraintsPerDep
78+
* @param array<string,array<string,string>> $constraintsPerDependency
8879
* @param array<string,array<string,mixed>> $conflictsOut
8980
* @return array<string,string>
9081
*/
91-
private function mergeMap(array $constraintsPerDep, string $strategy, array &$conflictsOut): array
82+
private function mergeMap(array $constraintsPerDependency, string $strategy, array &$conflictsOut): array
9283
{
93-
$out = [];
94-
foreach ($constraintsPerDep as $dep => $byPkg) {
95-
$constraint = $this->chooseConstraint(array_values($byPkg), $strategy, $dep, $byPkg, $conflictsOut);
84+
$mergedConstraints = [];
85+
foreach ($constraintsPerDependency as $dependency => $versionsByPackage) {
86+
$constraint = $this->chooseConstraint(
87+
array_values($versionsByPackage),
88+
$strategy,
89+
$dependency,
90+
$versionsByPackage,
91+
$conflictsOut
92+
);
9693
if ($constraint !== null) {
97-
$out[$dep] = $constraint;
94+
$mergedConstraints[$dependency] = $constraint;
9895
}
9996
}
10097

101-
return $out;
98+
return $mergedConstraints;
10299
}
103100

104101
/**
105102
* @param list<string> $constraints
106-
* @param array<string,string> $byPkg
103+
* @param array<string,string> $versionsByPackage
107104
*/
108-
private function chooseConstraint(array $constraints, string $strategy, string $dep, array $byPkg, array &$conflictsOut): ?string
105+
private function chooseConstraint(array $constraints, string $strategy, string $dependency, array $versionsByPackage, array &$conflictsOut): ?string
109106
{
110107
$strategy = strtolower($strategy);
111-
$norm = array_map([$this,'normalizeConstraint'], array_filter($constraints, 'is_string'));
112-
if ($norm === []) {
108+
$normalized = array_map([$this,'normalizeConstraint'], array_filter($constraints, 'is_string'));
109+
if ($normalized === []) {
113110
return null;
114111
}
115112

116113
if ($strategy === 'union-caret') {
117-
return $this->chooseUnionCaret($norm, $dep, $byPkg, $conflictsOut);
114+
return $this->chooseUnionCaret($normalized, $dependency, $versionsByPackage, $conflictsOut);
118115
}
119116

120117
if ($strategy === 'union-loose') {
121118
return '*';
122119
}
123120

124121
if ($strategy === 'max') {
125-
return $this->maxLowerBound($norm);
122+
return $this->maxLowerBound($normalized);
126123
}
127124

128125
if ($strategy === 'intersect') {
129126
// naive: if all share same major series, pick max lower bound; else conflict
130-
$majors = array_unique(array_map(static fn($c): int => $c['major'], $norm));
131-
if (count($majors) > 1) {
132-
$this->recordConflict($dep, $byPkg, $conflictsOut, 'intersect-empty');
127+
$majorVersions = array_unique(array_map(static fn($c): int => $c['major'], $normalized));
128+
if (count($majorVersions) > 1) {
129+
$this->recordConflict($dependency, $versionsByPackage, $conflictsOut, 'intersect-empty');
133130
return null;
134131
}
135132

136-
return $this->maxLowerBound($norm);
133+
return $this->maxLowerBound($normalized);
137134
}
138135

139136
// default fallback
140-
return $this->chooseUnionCaret($norm, $dep, $byPkg, $conflictsOut);
137+
return $this->chooseUnionCaret($normalized, $dependency, $versionsByPackage, $conflictsOut);
141138
}
142139

143140
/** @param list<array{raw:string,major:int,minor:int,patch:int,type:string}> $norm */
144-
private function chooseUnionCaret(array $norm, string $dep, array $byPkg, array &$conflictsOut): string
141+
private function chooseUnionCaret(array $norm, string $dependency, array $versionsByPackage, array &$conflictsOut): string
145142
{
146143
// Prefer highest ^MAJOR.MINOR; if any non-caret constraints exist, record a conflict and still pick a sane default.
147144
$caret = array_values(array_filter($norm, static fn($c): bool => $c['type'] === 'caret'));
148145
if ($caret !== []) {
149146
usort($caret, [$this,'cmpSemver']);
150147
$best = end($caret);
148+
if (count($caret) !== count($norm)) {
149+
$this->recordConflict($dependency, $versionsByPackage, $conflictsOut, 'non-caret-mixed');
150+
}
151+
151152
return '^' . $best['major'] . '.' . $best['minor'];
152153
}
153154

154155
// If exact pins or ranges exist, pick the "max lower bound" and record conflict
155-
$this->recordConflict($dep, $byPkg, $conflictsOut, 'non-caret-mixed');
156+
$this->recordConflict($dependency, $versionsByPackage, $conflictsOut, 'non-caret-mixed');
156157
return $this->maxLowerBound($norm);
157158
}
158159

@@ -169,13 +170,13 @@ private function maxLowerBound(array $norm): string
169170
return $best['raw'];
170171
}
171172

172-
/** @param array<string,string> $byPkg */
173-
private function recordConflict(string $dep, array $byPkg, array &$conflictsOut, string $reason): void
173+
/** @param array<string,string> $versionsByPackage */
174+
private function recordConflict(string $dependency, array $versionsByPackage, array &$conflictsOut, string $reason): void
174175
{
175-
$conflictsOut[$dep] = [
176-
'package' => $dep,
177-
'versions' => array_values(array_unique(array_values($byPkg))),
178-
'packages' => array_keys($byPkg),
176+
$conflictsOut[$dependency] = [
177+
'package' => $dependency,
178+
'versions' => array_values(array_unique(array_values($versionsByPackage))),
179+
'packages' => array_keys($versionsByPackage),
179180
'reason' => $reason,
180181
];
181182
}
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\Tests\Composer;
6+
7+
use Chorale\Composer\ComposerJsonReaderInterface;
8+
use Chorale\Composer\DependencyMerger;
9+
use PHPUnit\Framework\Attributes\CoversClass;
10+
use PHPUnit\Framework\Attributes\Group;
11+
use PHPUnit\Framework\Attributes\Small;
12+
use PHPUnit\Framework\Attributes\Test;
13+
use PHPUnit\Framework\TestCase;
14+
15+
#[CoversClass(DependencyMerger::class)]
16+
#[Group('unit')]
17+
#[Small]
18+
final class DependencyMergerTest extends TestCase
19+
{
20+
#[Test]
21+
public function testComputeRootMergeMergesPackageRequirementsUsingUnionCaretStrategy(): void
22+
{
23+
$reader = new class implements ComposerJsonReaderInterface {
24+
public function read(string $absolutePath): array
25+
{
26+
if (str_contains($absolutePath, 'pkg1')) {
27+
return ['name' => 'pkg1', 'require' => ['foo/bar' => '^1.0']];
28+
}
29+
30+
if (str_contains($absolutePath, 'pkg2')) {
31+
return ['name' => 'pkg2', 'require' => ['foo/bar' => '^1.2']];
32+
}
33+
34+
return [];
35+
}
36+
};
37+
38+
$merger = new DependencyMerger($reader);
39+
$result = $merger->computeRootMerge('/root', ['pkg1', 'pkg2']);
40+
41+
$this->assertSame(['foo/bar' => '^1.2'], $result['require']);
42+
$this->assertSame([], $result['conflicts']);
43+
}
44+
45+
#[Test]
46+
public function testComputeRootMergeRecordsConflictWhenMixedConstraintTypes(): void
47+
{
48+
$reader = new class implements ComposerJsonReaderInterface {
49+
public function read(string $absolutePath): array
50+
{
51+
if (str_contains($absolutePath, 'pkg1')) {
52+
return ['name' => 'pkg1', 'require' => ['foo/bar' => '^1.0']];
53+
}
54+
55+
if (str_contains($absolutePath, 'pkg2')) {
56+
return ['name' => 'pkg2', 'require' => ['foo/bar' => '1.3.0']];
57+
}
58+
59+
return [];
60+
}
61+
};
62+
63+
$merger = new DependencyMerger($reader);
64+
$result = $merger->computeRootMerge('/root', ['pkg1', 'pkg2']);
65+
66+
$this->assertSame(['foo/bar' => '^1.0'], $result['require']);
67+
$this->assertSame('non-caret-mixed', $result['conflicts'][0]['reason']);
68+
}
69+
}
70+

0 commit comments

Comments
 (0)