Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion bin/composer-dependency-analyser
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
69 changes: 68 additions & 1 deletion src/Analyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -125,23 +127,33 @@ class Analyser
*/
private $knownSymbolKinds = [];

/**
* autoload path => isDev
*
* @var array<string, bool>
*/
private $autoloadPaths = [];

/**
* @param array<string, ClassLoader> $classLoaders vendorDir => ClassLoader (e.g. result of \Composer\Autoload\ClassLoader::getRegisteredLoaders())
* @param array<string, bool> $composerJsonDependencies package or ext-* => is dev dependency
* @param array<string, bool> $autoloadPaths absolute path => isDev
*/
public function __construct(
Stopwatch $stopwatch,
string $defaultVendorDir,
array $classLoaders,
Configuration $config,
array $composerJsonDependencies
array $composerJsonDependencies,
array $autoloadPaths = []
)
{
$this->stopwatch = $stopwatch;
$this->config = $config;
$this->composerJsonDependencies = $this->filterDependencies($composerJsonDependencies, $config);
$this->vendorDirs = array_keys($classLoaders + [$defaultVendorDir => null]);
$this->classLoaders = array_values($classLoaders);
$this->autoloadPaths = $autoloadPaths;

$this->initExistingSymbols($config);
}
Expand All @@ -158,6 +170,7 @@ public function run(): AnalysisResult
$unknownFunctionErrors = [];
$shadowErrors = [];
$devInProdErrors = [];
$devSourceInProdErrors = [];
$prodOnlyInDevErrors = [];
$unusedErrors = [];

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -320,6 +354,7 @@ public function run(): AnalysisResult
$unknownFunctionErrors,
$shadowErrors,
$devInProdErrors,
$devSourceInProdErrors,
$prodOnlyInDevErrors,
$unusedErrors,
$ignoreList->getUnusedIgnores()
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/Config/ErrorType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

}
2 changes: 1 addition & 1 deletion src/Config/Ignore/IgnoreList.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
21 changes: 21 additions & 0 deletions src/Result/AnalysisResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ class AnalysisResult
*/
private $devDependencyInProductionErrors = [];

/**
* @var array<string, array<string, list<SymbolUsage>>>
*/
private $devSourceInProductionErrors = [];

/**
* @var list<string>
*/
Expand All @@ -66,6 +71,7 @@ class AnalysisResult
* @param array<string, list<SymbolUsage>> $unknownFunctionErrors package => usages
* @param array<string, array<string, list<SymbolUsage>>> $shadowDependencyErrors package => [ classname => usage[] ]
* @param array<string, array<string, list<SymbolUsage>>> $devDependencyInProductionErrors package => [ classname => usage[] ]
* @param array<string, array<string, list<SymbolUsage>>> $devSourceInProductionErrors dev-source => [ classname => usage[] ]
* @param list<string> $prodDependencyOnlyInDevErrors package[]
* @param list<string> $unusedDependencyErrors package[]
* @param list<UnusedSymbolIgnore|UnusedErrorIgnore> $unusedIgnores
Expand All @@ -78,6 +84,7 @@ public function __construct(
array $unknownFunctionErrors,
array $shadowDependencyErrors,
array $devDependencyInProductionErrors,
array $devSourceInProductionErrors,
array $prodDependencyOnlyInDevErrors,
array $unusedDependencyErrors,
array $unusedIgnores
Expand All @@ -88,6 +95,7 @@ public function __construct(
ksort($unknownFunctionErrors);
ksort($shadowDependencyErrors);
ksort($devDependencyInProductionErrors);
ksort($devSourceInProductionErrors);
sort($prodDependencyOnlyInDevErrors);
sort($unusedDependencyErrors);

Expand All @@ -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;
Expand Down Expand Up @@ -166,6 +179,14 @@ public function getDevDependencyInProductionErrors(): array
return $this->devDependencyInProductionErrors;
}

/**
* @return array<string, array<string, list<SymbolUsage>>>
*/
public function getDevSourceInProductionErrors(): array
{
return $this->devSourceInProductionErrors;
}

/**
* @return list<string>
*/
Expand Down
13 changes: 13 additions & 0 deletions src/Result/ConsoleFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,15 @@ private function printResultErrors(
$unknownFunctionErrors = $result->getUnknownFunctionErrors();
$shadowDependencyErrors = $result->getShadowDependencyErrors();
$devDependencyInProductionErrors = $result->getDevDependencyInProductionErrors();
$devSourceInProductionErrors = $result->getDevSourceInProductionErrors();
$prodDependencyOnlyInDevErrors = $result->getProdDependencyOnlyInDevErrors();
$unusedDependencyErrors = $result->getUnusedDependencyErrors();

$unknownClassErrorsCount = count($unknownClassErrors);
$unknownFunctionErrorsCount = count($unknownFunctionErrors);
$shadowDependencyErrorsCount = count($shadowDependencyErrors);
$devDependencyInProductionErrorsCount = count($devDependencyInProductionErrors);
$devSourceInProductionErrorsCount = count($devSourceInProductionErrors);
$prodDependencyOnlyInDevErrorsCount = count($prodDependencyOnlyInDevErrors);
$unusedDependencyErrorsCount = count($unusedDependencyErrors);

Expand Down Expand Up @@ -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');
Expand Down
40 changes: 40 additions & 0 deletions tests/AnalyserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use function realpath;
use function strtr;
use function unlink;
use const DIRECTORY_SEPARATOR;

class AnalyserTest extends TestCase
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
}
Expand Down
5 changes: 3 additions & 2 deletions tests/ConsoleFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'],
[]
Expand Down
5 changes: 3 additions & 2 deletions tests/JunitFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'],
[]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php declare(strict_types = 1);

namespace App;

class DevOnlyClass
{
public function devOnlyMethod(): string
{
return 'This is only available in dev';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php declare(strict_types = 1);

namespace App;

use App\DevOnlyClass;

class ProductionClass
{
public function useDevClass(): void
{
$devClass = new DevOnlyClass();
$devClass->devOnlyMethod();
}
}
Loading