Skip to content

Commit 6c0e254

Browse files
committed
[funding] Normalize composer sync output (#56)
1 parent 2ca7b20 commit 6c0e254

9 files changed

Lines changed: 144 additions & 12 deletions

File tree

.github/FUNDING.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
github: php-fast-forward
22
custom:
3-
- https://www.paypal.com/donate/?business=JLDAF45XZ8D84
3+
- 'https://www.paypal.com/donate/?business=JLDAF45XZ8D84'

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ metrics reports without extra setup.
123123

124124
The `funding` command keeps supported `composer.json` funding entries aligned
125125
with `.github/FUNDING.yml`, including GitHub Sponsors handles and `custom`
126-
URLs, while preserving unsupported providers in place.
126+
URLs, while preserving unsupported providers in place and re-running
127+
`composer normalize` after manifest updates.
127128

128129
The `skills` command keeps `.agents/skills` aligned with the packaged Fast
129130
Forward skill set. It creates missing links, repairs broken links, and

composer.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@
2626
"source": "https://github.com/php-fast-forward/dev-tools",
2727
"docs": "https://php-fast-forward.github.io/dev-tools/"
2828
},
29+
"funding": [
30+
{
31+
"type": "github",
32+
"url": "https://github.com/sponsors/php-fast-forward"
33+
},
34+
{
35+
"type": "custom",
36+
"url": "https://www.paypal.com/donate/?business=JLDAF45XZ8D84"
37+
}
38+
],
2939
"require": {
3040
"php": "^8.3",
3141
"composer-plugin-api": "^2.0",

docs/commands/funding.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ Behavior
9797
entries.
9898
- Preserves unsupported Composer funding providers and unsupported GitHub
9999
funding YAML keys.
100+
- Normalizes ``composer.json`` with ``composer normalize`` after applying
101+
funding metadata updates.
100102
- Creates ``.github/FUNDING.yml`` when Composer declares supported funding
101103
metadata and the file is missing.
102104
- Skips writing ``.github/FUNDING.yml`` when neither side declares supported

src/Console/Command/FundingCommand.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
use FastForward\DevTools\Funding\ComposerFundingCodec;
2626
use FastForward\DevTools\Funding\FundingProfileMerger;
2727
use FastForward\DevTools\Funding\FundingYamlCodec;
28+
use FastForward\DevTools\Process\ProcessBuilderInterface;
29+
use FastForward\DevTools\Process\ProcessQueueInterface;
2830
use FastForward\DevTools\Resource\FileDiffer;
2931
use Symfony\Component\Console\Attribute\AsCommand;
3032
use Symfony\Component\Console\Input\InputInterface;
@@ -50,13 +52,17 @@ final class FundingCommand extends BaseCommand
5052
* @param FundingYamlCodec $fundingYamlCodec the codec used to parse and render GitHub funding YAML metadata
5153
* @param FundingProfileMerger $fundingProfileMerger the merger used to synchronize normalized funding profiles
5254
* @param FileDiffer $fileDiffer the differ used to summarize managed-file drift
55+
* @param ProcessBuilderInterface $processBuilder the process builder used to normalize composer.json after updates
56+
* @param ProcessQueueInterface $processQueue the process queue used to execute composer normalize
5357
*/
5458
public function __construct(
5559
private readonly FilesystemInterface $filesystem,
5660
private readonly ComposerFundingCodec $composerFundingCodec,
5761
private readonly FundingYamlCodec $fundingYamlCodec,
5862
private readonly FundingProfileMerger $fundingProfileMerger,
5963
private readonly FileDiffer $fileDiffer,
64+
private readonly ProcessBuilderInterface $processBuilder,
65+
private readonly ProcessQueueInterface $processQueue,
6066
) {
6167
parent::__construct();
6268
}
@@ -210,6 +216,11 @@ private function handleComposerFile(
210216
}
211217

212218
$this->filesystem->dumpFile($composerFile, $updatedComposerContents);
219+
220+
if (self::SUCCESS !== $this->normalizeComposerFile($composerFile, $output)) {
221+
return self::FAILURE;
222+
}
223+
213224
$output->writeln(\sprintf('<info>Updated funding metadata in %s.</info>', $composerFile));
214225

215226
return self::SUCCESS;
@@ -296,4 +307,35 @@ private function shouldWriteManagedFile(InputInterface $input, OutputInterface $
296307

297308
return (bool) $this->getHelper('question')->ask($input, $output, $question);
298309
}
310+
311+
/**
312+
* Normalizes a composer manifest after funding metadata changes.
313+
*
314+
* @param string $composerFile the composer manifest path
315+
* @param OutputInterface $output the command output
316+
*
317+
* @return int the normalization status code
318+
*/
319+
private function normalizeComposerFile(string $composerFile, OutputInterface $output): int
320+
{
321+
$processBuilder = $this->processBuilder
322+
->withArgument('--ansi')
323+
->withArgument('--no-update-lock');
324+
325+
$workingDirectory = $this->filesystem->dirname($composerFile);
326+
327+
if ('.' !== $workingDirectory) {
328+
$processBuilder = $processBuilder->withArgument('--working-dir', $workingDirectory);
329+
}
330+
331+
$composerBasename = $this->filesystem->basename($composerFile);
332+
333+
if ('composer.json' !== $composerBasename) {
334+
$processBuilder = $processBuilder->withArgument('--file', $composerBasename);
335+
}
336+
337+
$this->processQueue->add($processBuilder->build('composer normalize'));
338+
339+
return $this->processQueue->run($output);
340+
}
299341
}

src/Funding/ComposerFundingCodec.php

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,10 @@
2020
namespace FastForward\DevTools\Funding;
2121

2222
use Composer\Json\JsonFile;
23-
use Composer\Json\JsonManipulator;
24-
2523
use function array_values;
2624
use function is_array;
2725
use function is_string;
26+
use function json_encode;
2827
use function parse_url;
2928
use function preg_match;
3029
use function sprintf;
@@ -103,7 +102,6 @@ public function parse(string $contents): FundingProfile
103102
*/
104103
public function dump(string $contents, FundingProfile $profile): string
105104
{
106-
$manipulator = new JsonManipulator($contents);
107105
$entries = [];
108106

109107
foreach ($profile->getGithubSponsors() as $githubSponsor) {
@@ -124,13 +122,20 @@ public function dump(string $contents, FundingProfile $profile): string
124122
$entries[] = $unsupportedEntry;
125123
}
126124

127-
$manipulator->removeMainKey('funding');
125+
$data = JsonFile::parseJson($contents);
126+
unset($data['funding']);
128127

129-
if ([] !== $entries) {
130-
$manipulator->addMainKey('funding', $entries);
128+
if ([] === $entries) {
129+
return json_encode(
130+
$data,
131+
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
132+
) . "\n";
131133
}
132134

133-
return $manipulator->getContents();
135+
return json_encode(
136+
$this->insertFundingEntries($data, $entries),
137+
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
138+
) . "\n";
134139
}
135140

136141
/**
@@ -159,4 +164,33 @@ private function extractGithubSponsor(string $url): ?string
159164

160165
return $matches[1];
161166
}
167+
168+
/**
169+
* Inserts funding entries in a stable Composer key order.
170+
*
171+
* @param array<string, mixed> $data the decoded composer.json payload
172+
* @param array<int, array<string, mixed>> $entries the funding entries to insert
173+
*
174+
* @return array<string, mixed> the composer payload with funding inserted
175+
*/
176+
private function insertFundingEntries(array $data, array $entries): array
177+
{
178+
$orderedData = [];
179+
$inserted = false;
180+
181+
foreach ($data as $key => $value) {
182+
$orderedData[$key] = $value;
183+
184+
if ('support' === $key) {
185+
$orderedData['funding'] = $entries;
186+
$inserted = true;
187+
}
188+
}
189+
190+
if (! $inserted) {
191+
$orderedData['funding'] = $entries;
192+
}
193+
194+
return $orderedData;
195+
}
162196
}

src/Funding/FundingYamlCodec.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public function dump(FundingProfile $profile): string
8181
}
8282

8383
if ([] !== $profile->getCustomUrls()) {
84-
$data['custom'] = $this->denormalizeList($profile->getCustomUrls());
84+
$data['custom'] = $profile->getCustomUrls();
8585
}
8686

8787
return Yaml::dump($data, 4, 2);

tests/Console/Command/FundingCommandTest.php

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
use FastForward\DevTools\Funding\FundingProfile;
2626
use FastForward\DevTools\Funding\FundingProfileMerger;
2727
use FastForward\DevTools\Funding\FundingYamlCodec;
28+
use FastForward\DevTools\Process\ProcessBuilderInterface;
29+
use FastForward\DevTools\Process\ProcessQueueInterface;
2830
use FastForward\DevTools\Resource\FileDiff;
2931
use FastForward\DevTools\Resource\FileDiffer;
3032
use PHPUnit\Framework\Attributes\CoversClass;
@@ -37,6 +39,7 @@
3739
use ReflectionMethod;
3840
use Symfony\Component\Console\Input\InputInterface;
3941
use Symfony\Component\Console\Output\OutputInterface;
42+
use Symfony\Component\Process\Process;
4043
use Symfony\Component\Yaml\Yaml;
4144

4245
use function Safe\json_decode;
@@ -59,6 +62,12 @@ final class FundingCommandTest extends TestCase
5962

6063
private ObjectProphecy $fileDiffer;
6164

65+
private ObjectProphecy $processBuilder;
66+
67+
private ObjectProphecy $processQueue;
68+
69+
private ObjectProphecy $normalizeProcess;
70+
6271
private FundingCommand $command;
6372

6473
protected function setUp(): void
@@ -67,6 +76,9 @@ protected function setUp(): void
6776
$this->input = $this->prophesize(InputInterface::class);
6877
$this->output = $this->prophesize(OutputInterface::class);
6978
$this->fileDiffer = $this->prophesize(FileDiffer::class);
79+
$this->processBuilder = $this->prophesize(ProcessBuilderInterface::class);
80+
$this->processQueue = $this->prophesize(ProcessQueueInterface::class);
81+
$this->normalizeProcess = $this->prophesize(Process::class);
7082
$this->output->isDecorated()->willReturn(false);
7183
$this->output->writeln(Argument::any());
7284
$this->fileDiffer->formatForConsole(Argument::cetera())->willReturn(null);
@@ -76,13 +88,20 @@ protected function setUp(): void
7688
$this->input->getOption('check')->willReturn(false);
7789
$this->input->getOption('interactive')->willReturn(false);
7890
$this->filesystem->dirname('.github/FUNDING.yml')->willReturn('.github');
91+
$this->filesystem->dirname('composer.json')->willReturn('.');
92+
$this->filesystem->basename('composer.json')->willReturn('composer.json');
93+
$this->processBuilder->withArgument(Argument::any())->willReturn($this->processBuilder->reveal());
94+
$this->processBuilder->withArgument(Argument::any(), Argument::any())->willReturn($this->processBuilder->reveal());
95+
$this->processBuilder->build('composer normalize')->willReturn($this->normalizeProcess->reveal());
7996

8097
$this->command = new FundingCommand(
8198
$this->filesystem->reveal(),
8299
new ComposerFundingCodec(),
83100
new FundingYamlCodec(),
84101
new FundingProfileMerger(),
85102
$this->fileDiffer->reveal(),
103+
$this->processBuilder->reveal(),
104+
$this->processQueue->reveal(),
86105
);
87106
}
88107

@@ -117,6 +136,8 @@ public function executeWillCreateComposerFundingFromFundingYaml(): void
117136
$fundingYaml,
118137
'Updating managed file .github/FUNDING.yml from generated funding metadata synchronization.',
119138
)->willReturn(new FileDiff(FileDiff::STATUS_UNCHANGED, 'Funding unchanged'))->shouldBeCalledOnce();
139+
$this->processQueue->add($this->normalizeProcess->reveal())->shouldBeCalledOnce();
140+
$this->processQueue->run($this->output->reveal())->willReturn(ProcessQueueInterface::SUCCESS)->shouldBeCalledOnce();
120141
$this->filesystem->dumpFile(
121142
'composer.json',
122143
Argument::that(static fn(string $contents): bool => str_contains($contents, '"funding"')),
@@ -149,7 +170,7 @@ public function executeWillCreateFundingYamlFromComposerFunding(): void
149170
$decoded = Yaml::parse($contents);
150171

151172
return 'foo' === $decoded['github']
152-
&& 'https://example.com/support' === $decoded['custom'];
173+
&& ['https://example.com/support'] === $decoded['custom'];
153174
}),
154175
null,
155176
'Managed file .github/FUNDING.yml will be created from generated funding metadata synchronization.',
@@ -193,11 +214,13 @@ public function executeWillMergeBothSourcesWithoutDuplicatingEntries(): void
193214
$decoded = Yaml::parse($contents);
194215

195216
return 'foo' === $decoded['github']
196-
&& 'https://example.com/support' === $decoded['custom'];
217+
&& ['https://example.com/support'] === $decoded['custom'];
197218
}),
198219
$fundingYaml,
199220
'Updating managed file .github/FUNDING.yml from generated funding metadata synchronization.',
200221
)->willReturn(new FileDiff(FileDiff::STATUS_CHANGED, 'Funding changed'))->shouldBeCalledOnce();
222+
$this->processQueue->add($this->normalizeProcess->reveal())->shouldBeCalledOnce();
223+
$this->processQueue->run($this->output->reveal())->willReturn(ProcessQueueInterface::SUCCESS)->shouldBeCalledOnce();
201224
$this->filesystem->dumpFile('composer.json', Argument::type('string'))->shouldBeCalledOnce();
202225
$this->filesystem->mkdir('.github')->shouldBeCalledOnce();
203226
$this->filesystem->dumpFile('.github/FUNDING.yml', Argument::type('string'))->shouldBeCalledOnce();
@@ -232,6 +255,7 @@ public function executeWillBeIdempotentWhenFundingMetadataAlreadyMatches(): void
232255
'Updating managed file .github/FUNDING.yml from generated funding metadata synchronization.',
233256
)->willReturn(new FileDiff(FileDiff::STATUS_UNCHANGED, 'Funding unchanged'))->shouldBeCalledOnce();
234257
$this->filesystem->dumpFile(Argument::cetera())->shouldNotBeCalled();
258+
$this->processQueue->add(Argument::cetera())->shouldNotBeCalled();
235259

236260
self::assertSame(FundingCommand::SUCCESS, $this->executeCommand());
237261
}

tests/Funding/FundingYamlCodecTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,23 @@ public function dumpWillRenderScalarAndListFundingKeys(): void
6868
Yaml::parse($contents),
6969
);
7070
}
71+
72+
#[Test]
73+
public function dumpWillRenderSingleCustomUrlAsList(): void
74+
{
75+
$codec = new FundingYamlCodec();
76+
77+
$contents = $codec->dump(new FundingProfile(
78+
['foo'],
79+
['https://example.com/support'],
80+
));
81+
82+
self::assertSame(
83+
[
84+
'github' => 'foo',
85+
'custom' => ['https://example.com/support'],
86+
],
87+
Yaml::parse($contents),
88+
);
89+
}
7190
}

0 commit comments

Comments
 (0)