From c8180ef60f3021654a491d36f57a4d78ba84c323 Mon Sep 17 00:00:00 2001 From: Ruud Kamphuis Date: Thu, 16 Oct 2025 10:41:16 +0200 Subject: [PATCH] Introduce detection of dev source code used in production This adds a new error type DEV_SOURCE_IN_PROD that detects when production code imports classes from dev-only autoload paths. This prevents runtime failures when for example these dev directories are left out of the deployment to production. The feature detects cases like: - Production code in src/ importing from src-dev/ - Production code importing from tests/ directory Users can ignore these errors using: $config->ignoreErrorsOnPackage('src-dev', [ErrorType::DEV_SOURCE_IN_PROD]); --- README.md | 6 ++ bin/composer-dependency-analyser | 2 +- src/Analyser.php | 69 ++++++++++++++++++- src/Config/ErrorType.php | 1 + src/Config/Ignore/IgnoreList.php | 2 +- src/Result/AnalysisResult.php | 21 ++++++ src/Result/ConsoleFormatter.php | 13 ++++ tests/AnalyserTest.php | 40 +++++++++++ tests/ConsoleFormatterTest.php | 5 +- tests/JunitFormatterTest.php | 5 +- .../src-dev/DevOnlyClass.php | 11 +++ .../src/ProductionClass.php | 14 ++++ 12 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 tests/data/not-autoloaded/dev-source-in-prod/src-dev/DevOnlyClass.php create mode 100644 tests/data/not-autoloaded/dev-source-in-prod/src/ProductionClass.php diff --git a/README.md b/README.md index f44c626..af59a87 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,12 @@ This tool reads your `composer.json` and scans all paths listed in `autoload` & - For applications, it can break once you run it with `composer install --no-dev` - You should move those from `require-dev` to `require` +### Dev source code used in production + - Detects when production code imports classes from dev-only autoload paths (e.g., `src-dev/`, `tests/`) + - This happens when the same namespace is mapped to both production and dev paths + - Can cause runtime failures when dev directories are excluded from deployment + - You should either move the used classes to production paths or refactor your code + ### Prod dependencies used only in dev paths - For libraries, this miscategorization can lead to uselessly required dependencies for your users - You should move those from `require` to `require-dev` diff --git a/bin/composer-dependency-analyser b/bin/composer-dependency-analyser index d80ecfb..6c6f414 100755 --- a/bin/composer-dependency-analyser +++ b/bin/composer-dependency-analyser @@ -41,7 +41,7 @@ try { $configuration = $initializer->initConfiguration($options, $composerJson); $classLoaders = $initializer->initComposerClassLoaders(); - $analyser = new Analyser($stopwatch, $composerJson->composerVendorDir, $classLoaders, $configuration, $composerJson->dependencies); + $analyser = new Analyser($stopwatch, $composerJson->composerVendorDir, $classLoaders, $configuration, $composerJson->dependencies, $composerJson->autoloadPaths); $result = $analyser->run(); $formatter = $initializer->initFormatter($options); diff --git a/src/Analyser.php b/src/Analyser.php index c27c3d7..cf299e5 100644 --- a/src/Analyser.php +++ b/src/Analyser.php @@ -23,6 +23,8 @@ use function array_key_exists; use function array_keys; use function array_values; +use function arsort; +use function basename; use function explode; use function file_get_contents; use function get_declared_classes; @@ -125,16 +127,25 @@ class Analyser */ private $knownSymbolKinds = []; + /** + * autoload path => isDev + * + * @var array + */ + private $autoloadPaths = []; + /** * @param array $classLoaders vendorDir => ClassLoader (e.g. result of \Composer\Autoload\ClassLoader::getRegisteredLoaders()) * @param array $composerJsonDependencies package or ext-* => is dev dependency + * @param array $autoloadPaths absolute path => isDev */ public function __construct( Stopwatch $stopwatch, string $defaultVendorDir, array $classLoaders, Configuration $config, - array $composerJsonDependencies + array $composerJsonDependencies, + array $autoloadPaths = [] ) { $this->stopwatch = $stopwatch; @@ -142,6 +153,7 @@ public function __construct( $this->composerJsonDependencies = $this->filterDependencies($composerJsonDependencies, $config); $this->vendorDirs = array_keys($classLoaders + [$defaultVendorDir => null]); $this->classLoaders = array_values($classLoaders); + $this->autoloadPaths = $autoloadPaths; $this->initExistingSymbols($config); } @@ -158,6 +170,7 @@ public function run(): AnalysisResult $unknownFunctionErrors = []; $shadowErrors = []; $devInProdErrors = []; + $devSourceInProdErrors = []; $prodOnlyInDevErrors = []; $unusedErrors = []; @@ -204,6 +217,27 @@ public function run(): AnalysisResult } if (!$this->isVendorPath($symbolPath)) { + // Check if this is a local dev-only class being used in production code + if (!$isDevFilePath && $this->isDevAutoloadPath($symbolPath)) { + $devAutoloadPath = $this->getDevAutoloadPath($symbolPath); + + if ($devAutoloadPath !== null) { + // Use basename of the dev path as identifier (e.g. "src-dev" or "tests") + $devSourceName = basename($devAutoloadPath); + + if (!$ignoreList->shouldIgnoreError(ErrorType::DEV_SOURCE_IN_PROD, $filePath, $devSourceName)) { + foreach ($lineNumbers as $lineNumber) { + $devSourceInProdErrors[$devSourceName][$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); + } + } + + // Track usages for --dump-usages support + foreach ($lineNumbers as $lineNumber) { + $usages[$devSourceName][$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind); + } + } + } + continue; // local class } @@ -320,6 +354,7 @@ public function run(): AnalysisResult $unknownFunctionErrors, $shadowErrors, $devInProdErrors, + $devSourceInProdErrors, $prodOnlyInDevErrors, $unusedErrors, $ignoreList->getUnusedIgnores() @@ -434,6 +469,38 @@ private function isVendorPath(string $realPath): bool return false; } + private function isDevAutoloadPath(string $realPath): bool + { + // Check if the file's path starts with any dev autoload path + foreach ($this->autoloadPaths as $autoloadPath => $isDev) { + if ($isDev && strpos($realPath, $autoloadPath) === 0) { + return true; + } + } + + return false; + } + + private function getDevAutoloadPath(string $realPath): ?string + { + // Find which specific dev autoload path contains this file + // Sort by length descending to get the most specific match + $devPaths = []; + + foreach ($this->autoloadPaths as $autoloadPath => $isDev) { + if ($isDev && strpos($realPath, $autoloadPath) === 0) { + $devPaths[$autoloadPath] = strlen($autoloadPath); + } + } + + if ($devPaths === []) { + return null; + } + + arsort($devPaths); + return array_keys($devPaths)[0]; + } + private function getSymbolPath(string $symbol, ?int $kind): ?string { if ($kind === SymbolKind::FUNCTION || $kind === null) { diff --git a/src/Config/ErrorType.php b/src/Config/ErrorType.php index 4fa9223..6c78f56 100644 --- a/src/Config/ErrorType.php +++ b/src/Config/ErrorType.php @@ -10,6 +10,7 @@ final class ErrorType public const SHADOW_DEPENDENCY = 'shadow-dependency'; public const UNUSED_DEPENDENCY = 'unused-dependency'; public const DEV_DEPENDENCY_IN_PROD = 'dev-dependency-in-prod'; + public const DEV_SOURCE_IN_PROD = 'dev-source-in-prod'; public const PROD_DEPENDENCY_ONLY_IN_DEV = 'prod-dependency-only-in-dev'; } diff --git a/src/Config/Ignore/IgnoreList.php b/src/Config/Ignore/IgnoreList.php index 92e3e8a..ace010a 100644 --- a/src/Config/Ignore/IgnoreList.php +++ b/src/Config/Ignore/IgnoreList.php @@ -238,7 +238,7 @@ private function shouldIgnoreUnknownFunctionByRegex(string $function): bool } /** - * @param ErrorType::SHADOW_DEPENDENCY|ErrorType::UNUSED_DEPENDENCY|ErrorType::DEV_DEPENDENCY_IN_PROD|ErrorType::PROD_DEPENDENCY_ONLY_IN_DEV $errorType + * @param ErrorType::SHADOW_DEPENDENCY|ErrorType::UNUSED_DEPENDENCY|ErrorType::DEV_DEPENDENCY_IN_PROD|ErrorType::DEV_SOURCE_IN_PROD|ErrorType::PROD_DEPENDENCY_ONLY_IN_DEV $errorType */ public function shouldIgnoreError(string $errorType, ?string $realPath, ?string $dependency): bool { diff --git a/src/Result/AnalysisResult.php b/src/Result/AnalysisResult.php index ab3ba30..c58a76b 100644 --- a/src/Result/AnalysisResult.php +++ b/src/Result/AnalysisResult.php @@ -45,6 +45,11 @@ class AnalysisResult */ private $devDependencyInProductionErrors = []; + /** + * @var array>> + */ + private $devSourceInProductionErrors = []; + /** * @var list */ @@ -66,6 +71,7 @@ class AnalysisResult * @param array> $unknownFunctionErrors package => usages * @param array>> $shadowDependencyErrors package => [ classname => usage[] ] * @param array>> $devDependencyInProductionErrors package => [ classname => usage[] ] + * @param array>> $devSourceInProductionErrors dev-source => [ classname => usage[] ] * @param list $prodDependencyOnlyInDevErrors package[] * @param list $unusedDependencyErrors package[] * @param list $unusedIgnores @@ -78,6 +84,7 @@ public function __construct( array $unknownFunctionErrors, array $shadowDependencyErrors, array $devDependencyInProductionErrors, + array $devSourceInProductionErrors, array $prodDependencyOnlyInDevErrors, array $unusedDependencyErrors, array $unusedIgnores @@ -88,6 +95,7 @@ public function __construct( ksort($unknownFunctionErrors); ksort($shadowDependencyErrors); ksort($devDependencyInProductionErrors); + ksort($devSourceInProductionErrors); sort($prodDependencyOnlyInDevErrors); sort($unusedDependencyErrors); @@ -111,6 +119,11 @@ public function __construct( $this->devDependencyInProductionErrors[$package] = $classes; } + foreach ($devSourceInProductionErrors as $devSource => $classes) { + ksort($classes); + $this->devSourceInProductionErrors[$devSource] = $classes; + } + $this->prodDependencyOnlyInDevErrors = $prodDependencyOnlyInDevErrors; $this->unusedDependencyErrors = $unusedDependencyErrors; $this->unusedIgnores = $unusedIgnores; @@ -166,6 +179,14 @@ public function getDevDependencyInProductionErrors(): array return $this->devDependencyInProductionErrors; } + /** + * @return array>> + */ + public function getDevSourceInProductionErrors(): array + { + return $this->devSourceInProductionErrors; + } + /** * @return list */ diff --git a/src/Result/ConsoleFormatter.php b/src/Result/ConsoleFormatter.php index 68af02d..fcfa813 100644 --- a/src/Result/ConsoleFormatter.php +++ b/src/Result/ConsoleFormatter.php @@ -121,6 +121,7 @@ private function printResultErrors( $unknownFunctionErrors = $result->getUnknownFunctionErrors(); $shadowDependencyErrors = $result->getShadowDependencyErrors(); $devDependencyInProductionErrors = $result->getDevDependencyInProductionErrors(); + $devSourceInProductionErrors = $result->getDevSourceInProductionErrors(); $prodDependencyOnlyInDevErrors = $result->getProdDependencyOnlyInDevErrors(); $unusedDependencyErrors = $result->getUnusedDependencyErrors(); @@ -128,6 +129,7 @@ private function printResultErrors( $unknownFunctionErrorsCount = count($unknownFunctionErrors); $shadowDependencyErrorsCount = count($shadowDependencyErrors); $devDependencyInProductionErrorsCount = count($devDependencyInProductionErrors); + $devSourceInProductionErrorsCount = count($devSourceInProductionErrors); $prodDependencyOnlyInDevErrorsCount = count($prodDependencyOnlyInDevErrors); $unusedDependencyErrorsCount = count($unusedDependencyErrors); @@ -175,6 +177,17 @@ private function printResultErrors( ); } + if ($devSourceInProductionErrorsCount > 0) { + $hasError = true; + $sources = $this->pluralize($devSourceInProductionErrorsCount, 'dev source'); + $this->printPackageBasedErrors( + "Found $devSourceInProductionErrorsCount $sources used in production code!", + 'source code from autoload-dev paths should not be used in production', + $devSourceInProductionErrors, + $maxShownUsages + ); + } + if ($prodDependencyOnlyInDevErrorsCount > 0) { $hasError = true; $dependencies = $this->pluralize($prodDependencyOnlyInDevErrorsCount, 'dependency'); diff --git a/tests/AnalyserTest.php b/tests/AnalyserTest.php index e534a5b..c7a8a69 100644 --- a/tests/AnalyserTest.php +++ b/tests/AnalyserTest.php @@ -21,6 +21,7 @@ use function realpath; use function strtr; use function unlink; +use const DIRECTORY_SEPARATOR; class AnalyserTest extends TestCase { @@ -467,6 +468,7 @@ private function createAnalysisResult(int $scannedFiles, array $args, array $unu array_filter($args[ErrorType::UNKNOWN_FUNCTION] ?? []), // @phpstan-ignore-line ignore mixed array_filter($args[ErrorType::SHADOW_DEPENDENCY] ?? []), // @phpstan-ignore-line ignore mixed array_filter($args[ErrorType::DEV_DEPENDENCY_IN_PROD] ?? []), // @phpstan-ignore-line ignore mixed + array_filter($args[ErrorType::DEV_SOURCE_IN_PROD] ?? []), // @phpstan-ignore-line ignore mixed array_filter($args[ErrorType::PROD_DEPENDENCY_ONLY_IN_DEV] ?? []), // @phpstan-ignore-line ignore mixed array_filter($args[ErrorType::UNUSED_DEPENDENCY] ?? []), // @phpstan-ignore-line ignore mixed $unusedIgnores @@ -812,6 +814,43 @@ public function testExplicitFileWithoutExtension(): void $this->assertResultsWithoutUsages($this->createAnalysisResult(1, []), $result); } + public function testDevSourceInProduction(): void + { + require_once __DIR__ . '/data/not-autoloaded/dev-source-in-prod/src-dev/DevOnlyClass.php'; + + $vendorDir = realpath(__DIR__ . '/data/autoloaded/vendor'); + $prodPath = realpath(__DIR__ . '/data/not-autoloaded/dev-source-in-prod/src'); + $devPath = realpath(__DIR__ . '/data/not-autoloaded/dev-source-in-prod/src-dev'); + self::assertNotFalse($vendorDir); + self::assertNotFalse($prodPath); + self::assertNotFalse($devPath); + + $config = new Configuration(); + $config->addPathToScan($prodPath, false); + + $autoloadPaths = [ + $prodPath => false, + $devPath => true, + ]; + + $detector = new Analyser( + $this->getStopwatchMock(), + $vendorDir, + [$vendorDir => $this->getClassLoaderMock()], + $config, + [], + $autoloadPaths + ); + $result = $detector->run(); + + $prodFile = $prodPath . DIRECTORY_SEPARATOR . 'ProductionClass.php'; + $expected = $this->createAnalysisResult(1, [ + ErrorType::DEV_SOURCE_IN_PROD => ['src-dev' => ['App\DevOnlyClass' => [new SymbolUsage($prodFile, 11, SymbolKind::CLASSLIKE)]]], + ]); + + $this->assertResultsWithoutUsages($expected, $result); + } + private function getStopwatchMock(): Stopwatch { $stopwatch = $this->createMock(Stopwatch::class); @@ -840,6 +879,7 @@ private function assertResultsWithoutUsages(AnalysisResult $expectedResult, Anal self::assertEquals($expectedResult->getUnknownFunctionErrors(), $result->getUnknownFunctionErrors(), 'Unknown functions mismatch'); self::assertEquals($expectedResult->getShadowDependencyErrors(), $result->getShadowDependencyErrors(), 'Shadow dependency mismatch'); self::assertEquals($expectedResult->getDevDependencyInProductionErrors(), $result->getDevDependencyInProductionErrors(), 'Dev dependency in production mismatch'); + self::assertEquals($expectedResult->getDevSourceInProductionErrors(), $result->getDevSourceInProductionErrors(), 'Dev source in production mismatch'); self::assertEquals($expectedResult->getProdDependencyOnlyInDevErrors(), $result->getProdDependencyOnlyInDevErrors(), 'Prod dependency only in dev mismatch'); self::assertEquals($expectedResult->getUnusedDependencyErrors(), $result->getUnusedDependencyErrors(), 'Unused dependency mismatch'); } diff --git a/tests/ConsoleFormatterTest.php b/tests/ConsoleFormatterTest.php index 57ebf93..79ad549 100644 --- a/tests/ConsoleFormatterTest.php +++ b/tests/ConsoleFormatterTest.php @@ -17,10 +17,10 @@ public function testPrintResult(): void { // editorconfig-checker-disable $noIssuesOutput = $this->getFormatterNormalizedOutput(static function (ResultFormatter $formatter): void { - $formatter->format(new AnalysisResult(2, 0.123, [], [], [], [], [], [], [], []), new CliOptions(), new Configuration()); + $formatter->format(new AnalysisResult(2, 0.123, [], [], [], [], [], [], [], [], []), new CliOptions(), new Configuration()); }); $noIssuesButUnusedIgnores = $this->getFormatterNormalizedOutput(static function (ResultFormatter $formatter): void { - $formatter->format(new AnalysisResult(2, 0.123, [], [], [], [], [], [], [], [new UnusedErrorIgnore(ErrorType::SHADOW_DEPENDENCY, null, null)]), new CliOptions(), new Configuration()); + $formatter->format(new AnalysisResult(2, 0.123, [], [], [], [], [], [], [], [], [new UnusedErrorIgnore(ErrorType::SHADOW_DEPENDENCY, null, null)]), new CliOptions(), new Configuration()); }); $expectedNoIssuesOutput = <<<'OUT' @@ -67,6 +67,7 @@ public function testPrintResult(): void ], ], ['some/package' => ['Another\Command' => [new SymbolUsage('/app/src/ProductGenerator.php', 28, SymbolKind::CLASSLIKE)]]], + [], ['misplaced/package'], ['dead/package'], [] diff --git a/tests/JunitFormatterTest.php b/tests/JunitFormatterTest.php index 370d947..7ca58e9 100644 --- a/tests/JunitFormatterTest.php +++ b/tests/JunitFormatterTest.php @@ -17,10 +17,10 @@ public function testPrintResult(): void { // editorconfig-checker-disable $noIssuesOutput = $this->getFormatterNormalizedOutput(static function (ResultFormatter $formatter): void { - $formatter->format(new AnalysisResult(2, 0.123, [], [], [], [], [], [], [], []), new CliOptions(), new Configuration()); + $formatter->format(new AnalysisResult(2, 0.123, [], [], [], [], [], [], [], [], []), new CliOptions(), new Configuration()); }); $noIssuesButUnusedIgnores = $this->getFormatterNormalizedOutput(static function (ResultFormatter $formatter): void { - $formatter->format(new AnalysisResult(2, 0.123, [], [], [], [], [], [], [], [new UnusedErrorIgnore(ErrorType::SHADOW_DEPENDENCY, null, null)]), new CliOptions(), new Configuration()); + $formatter->format(new AnalysisResult(2, 0.123, [], [], [], [], [], [], [], [], [new UnusedErrorIgnore(ErrorType::SHADOW_DEPENDENCY, null, null)]), new CliOptions(), new Configuration()); }); $expectedNoIssuesOutput = <<<'OUT' @@ -69,6 +69,7 @@ public function testPrintResult(): void ], ], ['some/package' => ['Another\Command' => [new SymbolUsage('/app/src/ProductGenerator.php', 28, SymbolKind::CLASSLIKE)]]], + [], ['misplaced/package'], ['dead/package'], [] diff --git a/tests/data/not-autoloaded/dev-source-in-prod/src-dev/DevOnlyClass.php b/tests/data/not-autoloaded/dev-source-in-prod/src-dev/DevOnlyClass.php new file mode 100644 index 0000000..105ad42 --- /dev/null +++ b/tests/data/not-autoloaded/dev-source-in-prod/src-dev/DevOnlyClass.php @@ -0,0 +1,11 @@ +devOnlyMethod(); + } +}