diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e33d6..c6dcdef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat!: require Bun `^1.4.0`, npm `>=10.9.8`, pnpm `^11.23.0`, or Yarn `^4.18.0` and remove legacy manager support. - fix: run manager commands in the configured root directory without changing the PHP working directory, and prevent manager probes and npm dependency cleanup when manager execution is disabled. - feat!: add secure frontend audits with CVE reporting, CI formats, and strict npm, pnpm, Yarn, and Bun validation. +- perf: write generated Composer asset manifests directly without first copying their source files. ## 0.2.0 January 24, 2026 diff --git a/src/Solver/Solver.php b/src/Solver/Solver.php index 8850642..0330468 100644 --- a/src/Solver/Solver.php +++ b/src/Solver/Solver.php @@ -27,6 +27,7 @@ use function is_dir; use function is_file; use function is_string; +use function json_decode; use function realpath; use function rtrim; use function sprintf; @@ -173,7 +174,7 @@ private function getAssets(Composer $composer, string $assetDir, array $packages * @param string $assetDir The asset directory. * @param string $filename The filename of asset package. * - * @throws Exception if the asset package cannot be copied or written. + * @throws Exception if the asset package cannot be read or written. * * @return array{0: string, 1: string} The package name and absolute generated manifest path. */ @@ -194,17 +195,23 @@ private function getMockPackagePath(PackageInterface $package, string $assetDir, ); } - if (!$this->fs->copy($filename, $newFilename)) { + $sourceContent = file_get_contents($filename); + + if (false === $sourceContent) { throw new RuntimeException( - sprintf('Unable to copy asset manifest "%s".', $filename), + sprintf('Unable to read asset manifest "%s".', $filename), ); } - $jsonFile = new JsonFile($newFilename); + $packageValue = AssetUtil::formatPackage( + $package, + $packageName, + (array) json_decode($sourceContent, false, flags: JSON_THROW_ON_ERROR), + ); - $packageValue = AssetUtil::formatPackage($package, $packageName, (array) $jsonFile->read()); + $targetJsonFile = new JsonFile($newFilename); - $jsonFile->write($packageValue); + $targetJsonFile->write($packageValue); return [$packageName, $newFilename]; } diff --git a/tests/Solver/SolverTest.php b/tests/Solver/SolverTest.php index a4318ff..c26b2f3 100644 --- a/tests/Solver/SolverTest.php +++ b/tests/Solver/SolverTest.php @@ -84,23 +84,74 @@ public function testCanonicalizePathStopsAtFilesystemRootWhenExistenceCheckFails ); } - public function testGetMockPackagePathRejectsCopyFailure(): void + public function testGetMockPackagePathDoesNotCopySourceBeforeWritingFormattedManifest(): void { - $assetDir = $this->cwd . '/copy-failure-assets'; - $source = $this->cwd . '/source-package.json'; - $target = $assetDir . '/foo/bar/source-package.json'; + $assetDir = "{$this->cwd}/direct-write-assets"; + $source = "{$this->cwd}/source-package.json"; + + $target = "{$assetDir}/foo/bar/source-package.json"; + + $sourceContent = <<createMock(PackageInterface::class); $package->method('getName')->willReturn('foo/bar'); - file_put_contents($source, '{}'); + + file_put_contents($source, $sourceContent); $fs = $this->getMockBuilder(Filesystem::class)->onlyMethods(['copy'])->getMock(); - $fs->expects(self::once())->method('copy')->with($source, $target)->willReturn(false); + $fs->expects(self::never())->method('copy'); $solver = new Solver($this->manager, $this->config, $fs, $this->composerFallback); + $result = $this->invokeSolverMethodOn($solver, 'getMockPackagePath', $package, $assetDir, $source); + + self::assertSame(['@composer-asset/foo--bar', $target], $result); + self::assertSame($sourceContent, file_get_contents($source)); + self::assertFileExists($target); + self::assertSame( + <<<'JSON' + { + "name": "@composer-asset/foo--bar", + "version": "1.2.3", + "engines": {}, + "bundleDependencies": [], + "dependencies": {} + } + + JSON, + file_get_contents($target), + ); + } + + public function testGetMockPackagePathRejectsUnreadableSourceManifest(): void + { + $assetDir = "{$this->cwd}/unreadable-source-assets"; + $source = "{$this->cwd}/source-package.json"; + + $package = $this->createMock(PackageInterface::class); + $package->method('getName')->willReturn('foo/bar'); + + MockerState::addCondition( + 'Foxy\\Solver', + 'file_get_contents', + [$source, false, null, 0, null], + false, + ); + $this->expectException(RuntimeException::class); - $this->expectExceptionMessage(sprintf('Unable to copy asset manifest "%s".', $source)); + $this->expectExceptionMessage(sprintf('Unable to read asset manifest "%s".', $source)); - $this->invokeSolverMethodOn($solver, 'getMockPackagePath', $package, $assetDir, $source); + $this->invokeSolverMethod('getMockPackagePath', $package, $assetDir, $source); } public function testGetMockPackagePathWrapsDirectoryCreationFailure(): void diff --git a/tests/Support/InternalMockerExtension.php b/tests/Support/InternalMockerExtension.php index d61775f..137023e 100644 --- a/tests/Support/InternalMockerExtension.php +++ b/tests/Support/InternalMockerExtension.php @@ -60,6 +60,10 @@ public static function load(): void 'namespace' => 'Foxy\\Solver', 'name' => 'file_exists', ], + [ + 'namespace' => 'Foxy\\Solver', + 'name' => 'file_get_contents', + ], [ 'namespace' => 'Foxy\\Solver', 'name' => 'file_put_contents',