Skip to content

Commit 1be6249

Browse files
committed
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]);
1 parent debd7cb commit 1be6249

File tree

12 files changed

+181
-7
lines changed

12 files changed

+181
-7
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ This tool reads your `composer.json` and scans all paths listed in `autoload` &
6565
- For applications, it can break once you run it with `composer install --no-dev`
6666
- You should move those from `require-dev` to `require`
6767

68+
### Dev source code used in production
69+
- Detects when production code imports classes from dev-only autoload paths (e.g., `src-dev/`, `tests/`)
70+
- This happens when the same namespace is mapped to both production and dev paths
71+
- Can cause runtime failures when dev directories are excluded from deployment
72+
- You should either move the used classes to production paths or refactor your code
73+
6874
### Prod dependencies used only in dev paths
6975
- For libraries, this miscategorization can lead to uselessly required dependencies for your users
7076
- You should move those from `require` to `require-dev`

bin/composer-dependency-analyser

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ try {
4141
$configuration = $initializer->initConfiguration($options, $composerJson);
4242
$classLoaders = $initializer->initComposerClassLoaders();
4343

44-
$analyser = new Analyser($stopwatch, $composerJson->composerVendorDir, $classLoaders, $configuration, $composerJson->dependencies);
44+
$analyser = new Analyser($stopwatch, $composerJson->composerVendorDir, $classLoaders, $configuration, $composerJson->dependencies, $composerJson->autoloadPaths);
4545
$result = $analyser->run();
4646

4747
$formatter = $initializer->initFormatter($options);

src/Analyser.php

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
use function array_key_exists;
2424
use function array_keys;
2525
use function array_values;
26+
use function arsort;
27+
use function basename;
2628
use function explode;
2729
use function file_get_contents;
2830
use function get_declared_classes;
@@ -125,23 +127,33 @@ class Analyser
125127
*/
126128
private $knownSymbolKinds = [];
127129

130+
/**
131+
* autoload path => isDev
132+
*
133+
* @var array<string, bool>
134+
*/
135+
private $autoloadPaths = [];
136+
128137
/**
129138
* @param array<string, ClassLoader> $classLoaders vendorDir => ClassLoader (e.g. result of \Composer\Autoload\ClassLoader::getRegisteredLoaders())
130139
* @param array<string, bool> $composerJsonDependencies package or ext-* => is dev dependency
140+
* @param array<string, bool> $autoloadPaths absolute path => isDev
131141
*/
132142
public function __construct(
133143
Stopwatch $stopwatch,
134144
string $defaultVendorDir,
135145
array $classLoaders,
136146
Configuration $config,
137-
array $composerJsonDependencies
147+
array $composerJsonDependencies,
148+
array $autoloadPaths = []
138149
)
139150
{
140151
$this->stopwatch = $stopwatch;
141152
$this->config = $config;
142153
$this->composerJsonDependencies = $this->filterDependencies($composerJsonDependencies, $config);
143154
$this->vendorDirs = array_keys($classLoaders + [$defaultVendorDir => null]);
144155
$this->classLoaders = array_values($classLoaders);
156+
$this->autoloadPaths = $autoloadPaths;
145157

146158
$this->initExistingSymbols($config);
147159
}
@@ -158,6 +170,7 @@ public function run(): AnalysisResult
158170
$unknownFunctionErrors = [];
159171
$shadowErrors = [];
160172
$devInProdErrors = [];
173+
$devSourceInProdErrors = [];
161174
$prodOnlyInDevErrors = [];
162175
$unusedErrors = [];
163176

@@ -204,6 +217,27 @@ public function run(): AnalysisResult
204217
}
205218

206219
if (!$this->isVendorPath($symbolPath)) {
220+
// Check if this is a local dev-only class being used in production code
221+
if (!$isDevFilePath && $this->isDevAutoloadPath($symbolPath)) {
222+
$devAutoloadPath = $this->getDevAutoloadPath($symbolPath);
223+
224+
if ($devAutoloadPath !== null) {
225+
// Use basename of the dev path as identifier (e.g. "src-dev" or "tests")
226+
$devSourceName = basename($devAutoloadPath);
227+
228+
if (!$ignoreList->shouldIgnoreError(ErrorType::DEV_SOURCE_IN_PROD, $filePath, $devSourceName)) {
229+
foreach ($lineNumbers as $lineNumber) {
230+
$devSourceInProdErrors[$devSourceName][$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind);
231+
}
232+
}
233+
234+
// Track usages for --dump-usages support
235+
foreach ($lineNumbers as $lineNumber) {
236+
$usages[$devSourceName][$usedSymbol][] = new SymbolUsage($filePath, $lineNumber, $kind);
237+
}
238+
}
239+
}
240+
207241
continue; // local class
208242
}
209243

@@ -320,6 +354,7 @@ public function run(): AnalysisResult
320354
$unknownFunctionErrors,
321355
$shadowErrors,
322356
$devInProdErrors,
357+
$devSourceInProdErrors,
323358
$prodOnlyInDevErrors,
324359
$unusedErrors,
325360
$ignoreList->getUnusedIgnores()
@@ -434,6 +469,38 @@ private function isVendorPath(string $realPath): bool
434469
return false;
435470
}
436471

472+
private function isDevAutoloadPath(string $realPath): bool
473+
{
474+
// Check if the file's path starts with any dev autoload path
475+
foreach ($this->autoloadPaths as $autoloadPath => $isDev) {
476+
if ($isDev && strpos($realPath, $autoloadPath) === 0) {
477+
return true;
478+
}
479+
}
480+
481+
return false;
482+
}
483+
484+
private function getDevAutoloadPath(string $realPath): ?string
485+
{
486+
// Find which specific dev autoload path contains this file
487+
// Sort by length descending to get the most specific match
488+
$devPaths = [];
489+
490+
foreach ($this->autoloadPaths as $autoloadPath => $isDev) {
491+
if ($isDev && strpos($realPath, $autoloadPath) === 0) {
492+
$devPaths[$autoloadPath] = strlen($autoloadPath);
493+
}
494+
}
495+
496+
if ($devPaths === []) {
497+
return null;
498+
}
499+
500+
arsort($devPaths);
501+
return array_keys($devPaths)[0];
502+
}
503+
437504
private function getSymbolPath(string $symbol, ?int $kind): ?string
438505
{
439506
if ($kind === SymbolKind::FUNCTION || $kind === null) {

src/Config/ErrorType.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ final class ErrorType
1010
public const SHADOW_DEPENDENCY = 'shadow-dependency';
1111
public const UNUSED_DEPENDENCY = 'unused-dependency';
1212
public const DEV_DEPENDENCY_IN_PROD = 'dev-dependency-in-prod';
13+
public const DEV_SOURCE_IN_PROD = 'dev-source-in-prod';
1314
public const PROD_DEPENDENCY_ONLY_IN_DEV = 'prod-dependency-only-in-dev';
1415

1516
}

src/Config/Ignore/IgnoreList.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ private function shouldIgnoreUnknownFunctionByRegex(string $function): bool
238238
}
239239

240240
/**
241-
* @param ErrorType::SHADOW_DEPENDENCY|ErrorType::UNUSED_DEPENDENCY|ErrorType::DEV_DEPENDENCY_IN_PROD|ErrorType::PROD_DEPENDENCY_ONLY_IN_DEV $errorType
241+
* @param ErrorType::SHADOW_DEPENDENCY|ErrorType::UNUSED_DEPENDENCY|ErrorType::DEV_DEPENDENCY_IN_PROD|ErrorType::DEV_SOURCE_IN_PROD|ErrorType::PROD_DEPENDENCY_ONLY_IN_DEV $errorType
242242
*/
243243
public function shouldIgnoreError(string $errorType, ?string $realPath, ?string $dependency): bool
244244
{

src/Result/AnalysisResult.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ class AnalysisResult
4545
*/
4646
private $devDependencyInProductionErrors = [];
4747

48+
/**
49+
* @var array<string, array<string, list<SymbolUsage>>>
50+
*/
51+
private $devSourceInProductionErrors = [];
52+
4853
/**
4954
* @var list<string>
5055
*/
@@ -66,6 +71,7 @@ class AnalysisResult
6671
* @param array<string, list<SymbolUsage>> $unknownFunctionErrors package => usages
6772
* @param array<string, array<string, list<SymbolUsage>>> $shadowDependencyErrors package => [ classname => usage[] ]
6873
* @param array<string, array<string, list<SymbolUsage>>> $devDependencyInProductionErrors package => [ classname => usage[] ]
74+
* @param array<string, array<string, list<SymbolUsage>>> $devSourceInProductionErrors dev-source => [ classname => usage[] ]
6975
* @param list<string> $prodDependencyOnlyInDevErrors package[]
7076
* @param list<string> $unusedDependencyErrors package[]
7177
* @param list<UnusedSymbolIgnore|UnusedErrorIgnore> $unusedIgnores
@@ -78,6 +84,7 @@ public function __construct(
7884
array $unknownFunctionErrors,
7985
array $shadowDependencyErrors,
8086
array $devDependencyInProductionErrors,
87+
array $devSourceInProductionErrors,
8188
array $prodDependencyOnlyInDevErrors,
8289
array $unusedDependencyErrors,
8390
array $unusedIgnores
@@ -88,6 +95,7 @@ public function __construct(
8895
ksort($unknownFunctionErrors);
8996
ksort($shadowDependencyErrors);
9097
ksort($devDependencyInProductionErrors);
98+
ksort($devSourceInProductionErrors);
9199
sort($prodDependencyOnlyInDevErrors);
92100
sort($unusedDependencyErrors);
93101

@@ -111,6 +119,11 @@ public function __construct(
111119
$this->devDependencyInProductionErrors[$package] = $classes;
112120
}
113121

122+
foreach ($devSourceInProductionErrors as $devSource => $classes) {
123+
ksort($classes);
124+
$this->devSourceInProductionErrors[$devSource] = $classes;
125+
}
126+
114127
$this->prodDependencyOnlyInDevErrors = $prodDependencyOnlyInDevErrors;
115128
$this->unusedDependencyErrors = $unusedDependencyErrors;
116129
$this->unusedIgnores = $unusedIgnores;
@@ -166,6 +179,14 @@ public function getDevDependencyInProductionErrors(): array
166179
return $this->devDependencyInProductionErrors;
167180
}
168181

182+
/**
183+
* @return array<string, array<string, list<SymbolUsage>>>
184+
*/
185+
public function getDevSourceInProductionErrors(): array
186+
{
187+
return $this->devSourceInProductionErrors;
188+
}
189+
169190
/**
170191
* @return list<string>
171192
*/

src/Result/ConsoleFormatter.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,15 @@ private function printResultErrors(
121121
$unknownFunctionErrors = $result->getUnknownFunctionErrors();
122122
$shadowDependencyErrors = $result->getShadowDependencyErrors();
123123
$devDependencyInProductionErrors = $result->getDevDependencyInProductionErrors();
124+
$devSourceInProductionErrors = $result->getDevSourceInProductionErrors();
124125
$prodDependencyOnlyInDevErrors = $result->getProdDependencyOnlyInDevErrors();
125126
$unusedDependencyErrors = $result->getUnusedDependencyErrors();
126127

127128
$unknownClassErrorsCount = count($unknownClassErrors);
128129
$unknownFunctionErrorsCount = count($unknownFunctionErrors);
129130
$shadowDependencyErrorsCount = count($shadowDependencyErrors);
130131
$devDependencyInProductionErrorsCount = count($devDependencyInProductionErrors);
132+
$devSourceInProductionErrorsCount = count($devSourceInProductionErrors);
131133
$prodDependencyOnlyInDevErrorsCount = count($prodDependencyOnlyInDevErrors);
132134
$unusedDependencyErrorsCount = count($unusedDependencyErrors);
133135

@@ -175,6 +177,17 @@ private function printResultErrors(
175177
);
176178
}
177179

180+
if ($devSourceInProductionErrorsCount > 0) {
181+
$hasError = true;
182+
$sources = $this->pluralize($devSourceInProductionErrorsCount, 'dev source');
183+
$this->printPackageBasedErrors(
184+
"Found $devSourceInProductionErrorsCount $sources used in production code!",
185+
'source code from autoload-dev paths should not be used in production',
186+
$devSourceInProductionErrors,
187+
$maxShownUsages
188+
);
189+
}
190+
178191
if ($prodDependencyOnlyInDevErrorsCount > 0) {
179192
$hasError = true;
180193
$dependencies = $this->pluralize($prodDependencyOnlyInDevErrorsCount, 'dependency');

tests/AnalyserTest.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,7 @@ private function createAnalysisResult(int $scannedFiles, array $args, array $unu
467467
array_filter($args[ErrorType::UNKNOWN_FUNCTION] ?? []), // @phpstan-ignore-line ignore mixed
468468
array_filter($args[ErrorType::SHADOW_DEPENDENCY] ?? []), // @phpstan-ignore-line ignore mixed
469469
array_filter($args[ErrorType::DEV_DEPENDENCY_IN_PROD] ?? []), // @phpstan-ignore-line ignore mixed
470+
array_filter($args[ErrorType::DEV_SOURCE_IN_PROD] ?? []), // @phpstan-ignore-line ignore mixed
470471
array_filter($args[ErrorType::PROD_DEPENDENCY_ONLY_IN_DEV] ?? []), // @phpstan-ignore-line ignore mixed
471472
array_filter($args[ErrorType::UNUSED_DEPENDENCY] ?? []), // @phpstan-ignore-line ignore mixed
472473
$unusedIgnores
@@ -812,6 +813,43 @@ public function testExplicitFileWithoutExtension(): void
812813
$this->assertResultsWithoutUsages($this->createAnalysisResult(1, []), $result);
813814
}
814815

816+
public function testDevSourceInProduction(): void
817+
{
818+
require_once __DIR__ . '/data/not-autoloaded/dev-source-in-prod/src-dev/DevOnlyClass.php';
819+
820+
$vendorDir = realpath(__DIR__ . '/data/autoloaded/vendor');
821+
$prodPath = realpath(__DIR__ . '/data/not-autoloaded/dev-source-in-prod/src');
822+
$devPath = realpath(__DIR__ . '/data/not-autoloaded/dev-source-in-prod/src-dev');
823+
self::assertNotFalse($vendorDir);
824+
self::assertNotFalse($prodPath);
825+
self::assertNotFalse($devPath);
826+
827+
$config = new Configuration();
828+
$config->addPathToScan($prodPath, false);
829+
830+
$autoloadPaths = [
831+
$prodPath => false,
832+
$devPath => true,
833+
];
834+
835+
$detector = new Analyser(
836+
$this->getStopwatchMock(),
837+
$vendorDir,
838+
[$vendorDir => $this->getClassLoaderMock()],
839+
$config,
840+
[],
841+
$autoloadPaths
842+
);
843+
$result = $detector->run();
844+
845+
$prodFile = $prodPath . DIRECTORY_SEPARATOR . 'ProductionClass.php';
846+
$expected = $this->createAnalysisResult(1, [
847+
ErrorType::DEV_SOURCE_IN_PROD => ['src-dev' => ['App\DevOnlyClass' => [new SymbolUsage($prodFile, 11, SymbolKind::CLASSLIKE)]]],
848+
]);
849+
850+
$this->assertResultsWithoutUsages($expected, $result);
851+
}
852+
815853
private function getStopwatchMock(): Stopwatch
816854
{
817855
$stopwatch = $this->createMock(Stopwatch::class);
@@ -840,6 +878,7 @@ private function assertResultsWithoutUsages(AnalysisResult $expectedResult, Anal
840878
self::assertEquals($expectedResult->getUnknownFunctionErrors(), $result->getUnknownFunctionErrors(), 'Unknown functions mismatch');
841879
self::assertEquals($expectedResult->getShadowDependencyErrors(), $result->getShadowDependencyErrors(), 'Shadow dependency mismatch');
842880
self::assertEquals($expectedResult->getDevDependencyInProductionErrors(), $result->getDevDependencyInProductionErrors(), 'Dev dependency in production mismatch');
881+
self::assertEquals($expectedResult->getDevSourceInProductionErrors(), $result->getDevSourceInProductionErrors(), 'Dev source in production mismatch');
843882
self::assertEquals($expectedResult->getProdDependencyOnlyInDevErrors(), $result->getProdDependencyOnlyInDevErrors(), 'Prod dependency only in dev mismatch');
844883
self::assertEquals($expectedResult->getUnusedDependencyErrors(), $result->getUnusedDependencyErrors(), 'Unused dependency mismatch');
845884
}

tests/ConsoleFormatterTest.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ public function testPrintResult(): void
1717
{
1818
// editorconfig-checker-disable
1919
$noIssuesOutput = $this->getFormatterNormalizedOutput(static function (ResultFormatter $formatter): void {
20-
$formatter->format(new AnalysisResult(2, 0.123, [], [], [], [], [], [], [], []), new CliOptions(), new Configuration());
20+
$formatter->format(new AnalysisResult(2, 0.123, [], [], [], [], [], [], [], [], []), new CliOptions(), new Configuration());
2121
});
2222
$noIssuesButUnusedIgnores = $this->getFormatterNormalizedOutput(static function (ResultFormatter $formatter): void {
23-
$formatter->format(new AnalysisResult(2, 0.123, [], [], [], [], [], [], [], [new UnusedErrorIgnore(ErrorType::SHADOW_DEPENDENCY, null, null)]), new CliOptions(), new Configuration());
23+
$formatter->format(new AnalysisResult(2, 0.123, [], [], [], [], [], [], [], [], [new UnusedErrorIgnore(ErrorType::SHADOW_DEPENDENCY, null, null)]), new CliOptions(), new Configuration());
2424
});
2525

2626
$expectedNoIssuesOutput = <<<'OUT'
@@ -67,6 +67,7 @@ public function testPrintResult(): void
6767
],
6868
],
6969
['some/package' => ['Another\Command' => [new SymbolUsage('/app/src/ProductGenerator.php', 28, SymbolKind::CLASSLIKE)]]],
70+
[],
7071
['misplaced/package'],
7172
['dead/package'],
7273
[]

tests/JunitFormatterTest.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ public function testPrintResult(): void
1717
{
1818
// editorconfig-checker-disable
1919
$noIssuesOutput = $this->getFormatterNormalizedOutput(static function (ResultFormatter $formatter): void {
20-
$formatter->format(new AnalysisResult(2, 0.123, [], [], [], [], [], [], [], []), new CliOptions(), new Configuration());
20+
$formatter->format(new AnalysisResult(2, 0.123, [], [], [], [], [], [], [], [], []), new CliOptions(), new Configuration());
2121
});
2222
$noIssuesButUnusedIgnores = $this->getFormatterNormalizedOutput(static function (ResultFormatter $formatter): void {
23-
$formatter->format(new AnalysisResult(2, 0.123, [], [], [], [], [], [], [], [new UnusedErrorIgnore(ErrorType::SHADOW_DEPENDENCY, null, null)]), new CliOptions(), new Configuration());
23+
$formatter->format(new AnalysisResult(2, 0.123, [], [], [], [], [], [], [], [], [new UnusedErrorIgnore(ErrorType::SHADOW_DEPENDENCY, null, null)]), new CliOptions(), new Configuration());
2424
});
2525

2626
$expectedNoIssuesOutput = <<<'OUT'
@@ -69,6 +69,7 @@ public function testPrintResult(): void
6969
],
7070
],
7171
['some/package' => ['Another\Command' => [new SymbolUsage('/app/src/ProductGenerator.php', 28, SymbolKind::CLASSLIKE)]]],
72+
[],
7273
['misplaced/package'],
7374
['dead/package'],
7475
[]

0 commit comments

Comments
 (0)