|
4 | 4 |
|
5 | 5 | namespace Chorale\Composer; |
6 | 6 |
|
7 | | -/** |
8 | | - * Skeleton merger: returns empty requires with no conflicts. |
9 | | - * Implement strategies: union-caret (default), union-loose, intersect, max. |
10 | | - */ |
11 | 7 | final readonly class DependencyMerger implements DependencyMergerInterface |
12 | 8 | { |
| 9 | + public function __construct( |
| 10 | + private readonly ComposerJsonReaderInterface $reader |
| 11 | + ) {} |
| 12 | + |
13 | 13 | public function computeRootMerge(string $projectRoot, array $packagePaths, array $options = []): array |
14 | 14 | { |
15 | | - // TODO: Walk each package composer.json, gather require/require-dev, |
16 | | - // filter monorepo packages, merge by strategy, compute conflicts. |
17 | | - return [ |
18 | | - 'require' => [], |
| 15 | + |
| 16 | + $opts = [ |
| 17 | + 'strategy_require' => (string) ($options['strategy_require'] ?? 'union-caret'), |
| 18 | + 'strategy_require_dev' => (string) ($options['strategy_require-dev'] ?? 'union-caret'), |
| 19 | + 'exclude_monorepo_packages' => (bool) ($options['exclude_monorepo_packages'] ?? true), |
| 20 | + 'monorepo_names' => (array) ($options['monorepo_names'] ?? []), |
| 21 | + ]; |
| 22 | + |
| 23 | + $monorepo = array_map('strtolower', array_values($opts['monorepo_names'])); |
| 24 | + |
| 25 | + $reqs = []; |
| 26 | + $devs = []; |
| 27 | + $byDepConstraints = [ |
| 28 | + 'require' => [], |
19 | 29 | 'require-dev' => [], |
20 | | - 'conflicts' => [], |
21 | 30 | ]; |
| 31 | + |
| 32 | + foreach ($packagePaths as $relPath) { |
| 33 | + $pc = $this->reader->read(rtrim($projectRoot, '/') . '/' . $relPath . '/composer.json'); |
| 34 | + if ($pc === []) { |
| 35 | + continue; |
| 36 | + } |
| 37 | + |
| 38 | + $name = strtolower((string) ($pc['name'] ?? $relPath)); |
| 39 | + foreach ((array) ($pc['require'] ?? []) as $dep => $ver) { |
| 40 | + if (!is_string($dep)) { |
| 41 | + continue; |
| 42 | + } |
| 43 | + |
| 44 | + if (!is_string($ver)) { |
| 45 | + continue; |
| 46 | + } |
| 47 | + |
| 48 | + if ($opts['exclude_monorepo_packages'] && in_array(strtolower($dep), $monorepo, true)) { |
| 49 | + continue; |
| 50 | + } |
| 51 | + |
| 52 | + $byDepConstraints['require'][$dep][$name] = $ver; |
| 53 | + } |
| 54 | + |
| 55 | + foreach ((array) ($pc['require-dev'] ?? []) as $dep => $ver) { |
| 56 | + if (!is_string($dep)) { |
| 57 | + continue; |
| 58 | + } |
| 59 | + |
| 60 | + if (!is_string($ver)) { |
| 61 | + continue; |
| 62 | + } |
| 63 | + |
| 64 | + if ($opts['exclude_monorepo_packages'] && in_array(strtolower($dep), $monorepo, true)) { |
| 65 | + continue; |
| 66 | + } |
| 67 | + |
| 68 | + $byDepConstraints['require-dev'][$dep][$name] = $ver; |
| 69 | + } |
| 70 | + } |
| 71 | + |
| 72 | + $conflicts = []; |
| 73 | + $reqs = $this->mergeMap($byDepConstraints['require'], $opts['strategy_require'], $conflicts); |
| 74 | + $devs = $this->mergeMap($byDepConstraints['require-dev'], $opts['strategy_require_dev'], $conflicts); |
| 75 | + |
| 76 | + ksort($reqs); |
| 77 | + ksort($devs); |
| 78 | + |
| 79 | + return [ |
| 80 | + 'require' => $reqs, |
| 81 | + 'require-dev' => $devs, |
| 82 | + 'conflicts' => array_values($conflicts), |
| 83 | + ]; |
| 84 | + } |
| 85 | + |
| 86 | + /** |
| 87 | + * @param array<string,array<string,string>> $constraintsPerDep |
| 88 | + * @param array<string,array<string,mixed>> $conflictsOut |
| 89 | + * @return array<string,string> |
| 90 | + */ |
| 91 | + private function mergeMap(array $constraintsPerDep, string $strategy, array &$conflictsOut): array |
| 92 | + { |
| 93 | + $out = []; |
| 94 | + foreach ($constraintsPerDep as $dep => $byPkg) { |
| 95 | + $constraint = $this->chooseConstraint(array_values($byPkg), $strategy, $dep, $byPkg, $conflictsOut); |
| 96 | + if ($constraint !== null) { |
| 97 | + $out[$dep] = $constraint; |
| 98 | + } |
| 99 | + } |
| 100 | + |
| 101 | + return $out; |
| 102 | + } |
| 103 | + |
| 104 | + /** |
| 105 | + * @param list<string> $constraints |
| 106 | + * @param array<string,string> $byPkg |
| 107 | + */ |
| 108 | + private function chooseConstraint(array $constraints, string $strategy, string $dep, array $byPkg, array &$conflictsOut): ?string |
| 109 | + { |
| 110 | + $strategy = strtolower($strategy); |
| 111 | + $norm = array_map([$this,'normalizeConstraint'], array_filter($constraints, 'is_string')); |
| 112 | + if ($norm === []) { |
| 113 | + return null; |
| 114 | + } |
| 115 | + |
| 116 | + if ($strategy === 'union-caret') { |
| 117 | + return $this->chooseUnionCaret($norm, $dep, $byPkg, $conflictsOut); |
| 118 | + } |
| 119 | + |
| 120 | + if ($strategy === 'union-loose') { |
| 121 | + return '*'; |
| 122 | + } |
| 123 | + |
| 124 | + if ($strategy === 'max') { |
| 125 | + return $this->maxLowerBound($norm); |
| 126 | + } |
| 127 | + |
| 128 | + if ($strategy === 'intersect') { |
| 129 | + // 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'); |
| 133 | + return null; |
| 134 | + } |
| 135 | + |
| 136 | + return $this->maxLowerBound($norm); |
| 137 | + } |
| 138 | + |
| 139 | + // default fallback |
| 140 | + return $this->chooseUnionCaret($norm, $dep, $byPkg, $conflictsOut); |
| 141 | + } |
| 142 | + |
| 143 | + /** @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 |
| 145 | + { |
| 146 | + // Prefer highest ^MAJOR.MINOR; if any non-caret constraints exist, record a conflict and still pick a sane default. |
| 147 | + $caret = array_values(array_filter($norm, static fn($c): bool => $c['type'] === 'caret')); |
| 148 | + if ($caret !== []) { |
| 149 | + usort($caret, [$this,'cmpSemver']); |
| 150 | + $best = end($caret); |
| 151 | + return '^' . $best['major'] . '.' . $best['minor']; |
| 152 | + } |
| 153 | + |
| 154 | + // If exact pins or ranges exist, pick the "max lower bound" and record conflict |
| 155 | + $this->recordConflict($dep, $byPkg, $conflictsOut, 'non-caret-mixed'); |
| 156 | + return $this->maxLowerBound($norm); |
| 157 | + } |
| 158 | + |
| 159 | + /** @param list<array{raw:string,major:int,minor:int,patch:int,type:string}> $norm */ |
| 160 | + private function maxLowerBound(array $norm): string |
| 161 | + { |
| 162 | + usort($norm, [$this,'cmpSemver']); |
| 163 | + $best = end($norm); |
| 164 | + if ($best['type'] === 'caret') { |
| 165 | + return '^' . $best['major'] . '.' . $best['minor']; |
| 166 | + } |
| 167 | + |
| 168 | + // fallback to exact lower bound |
| 169 | + return $best['raw']; |
| 170 | + } |
| 171 | + |
| 172 | + /** @param array<string,string> $byPkg */ |
| 173 | + private function recordConflict(string $dep, array $byPkg, array &$conflictsOut, string $reason): void |
| 174 | + { |
| 175 | + $conflictsOut[$dep] = [ |
| 176 | + 'package' => $dep, |
| 177 | + 'versions' => array_values(array_unique(array_values($byPkg))), |
| 178 | + 'packages' => array_keys($byPkg), |
| 179 | + 'reason' => $reason, |
| 180 | + ]; |
| 181 | + } |
| 182 | + |
| 183 | + /** @return array{raw:string,major:int,minor:int,patch:int,type:string} */ |
| 184 | + private function normalizeConstraint(string $raw): array |
| 185 | + { |
| 186 | + $raw = trim($raw); |
| 187 | + if ($raw === '' || $raw === '*') { |
| 188 | + return ['raw' => '*', 'major' => 0, 'minor' => 0, 'patch' => 0, 'type' => 'wild']; |
| 189 | + } |
| 190 | + |
| 191 | + if ($raw[0] === '^') { |
| 192 | + $v = substr($raw, 1); |
| 193 | + [$M,$m,$p] = $this->parseSemver($v); |
| 194 | + return ['raw' => '^' . $M . '.' . $m, 'major' => $M, 'minor' => $m, 'patch' => $p, 'type' => 'caret']; |
| 195 | + } |
| 196 | + |
| 197 | + // naive parse: try to get leading semver numbers |
| 198 | + [$M,$m,$p] = $this->parseSemver($raw); |
| 199 | + return ['raw' => $M . '.' . $m . '.' . $p, 'major' => $M, 'minor' => $m, 'patch' => $p, 'type' => 'pin']; |
| 200 | + } |
| 201 | + |
| 202 | + /** @return array{0:int,1:int,2:int} */ |
| 203 | + private function parseSemver(string $raw): array |
| 204 | + { |
| 205 | + $raw = ltrim($raw, 'vV'); |
| 206 | + $parts = preg_split('/[^\d]+/', $raw); |
| 207 | + $M = (int) ($parts[0] ?? 0); |
| 208 | + $m = (int) ($parts[1] ?? 0); |
| 209 | + $p = (int) ($parts[2] ?? 0); |
| 210 | + return [$M,$m,$p]; |
| 211 | + } |
| 212 | + |
| 213 | + /** @param array{major:int,minor:int,patch:int} $a @param array{major:int,minor:int,patch:int} $b */ |
| 214 | + private function cmpSemver(array $a, array $b): int |
| 215 | + { |
| 216 | + return [$a['major'],$a['minor'],$a['patch']] <=> [$b['major'],$b['minor'],$b['patch']]; |
22 | 217 | } |
23 | 218 | } |
0 commit comments