From 80fc704ccdd8e75991017d06db4acf64dbf037f9 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 19 Aug 2026 21:12:22 +0800 Subject: [PATCH 1/5] Add TDD tests for vendor path isolation and whitelisting in FileFilter --- tests/Contract/VendorIsolationPathTest.php | 98 ++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/Contract/VendorIsolationPathTest.php diff --git a/tests/Contract/VendorIsolationPathTest.php b/tests/Contract/VendorIsolationPathTest.php new file mode 100644 index 0000000..7c826a8 --- /dev/null +++ b/tests/Contract/VendorIsolationPathTest.php @@ -0,0 +1,98 @@ + [ + 'src/**', + 'app/**', + 'tests/**', + ], + 'exclude' => [ + 'vendor/**', + 'storage/**', + 'var/**', + 'cache/**', + ], + ]); + + StreamWrapper::register(); + + $projectRoot = Config::getProjectRoot(); + + $vendorDoctrineFile = str_replace('\\', '/', $projectRoot . '/vendor/doctrine/dbal/src/Schema/AbstractNamedObject.php'); + + $appFile = str_replace('\\', '/', $projectRoot . '/app/Services/UserService.php'); + + expect(FileFilter::isFileExcluded($vendorDoctrineFile))->toBeTrue() + ->and(FileFilter::isFileExcluded($appFile))->toBeFalse() + ; + + $refMethod = new ReflectionMethod(StreamWrapper::class, 'isApplicationFile'); + expect($refMethod->invoke(null, $vendorDoctrineFile, $vendorDoctrineFile))->toBeFalse() + ->and($refMethod->invoke(null, $appFile, $appFile))->toBeTrue() + ; + }); + + test('allows explicitly whitelisted vendor packages while strictly excluding all other vendor files', function () { + Config::set([ + 'include' => [ + 'src/**', + 'vendor/my-org/whitelisted-package/**', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + StreamWrapper::register(); + + $projectRoot = Config::getProjectRoot(); + + $whitelistedVendorFile = str_replace('\\', '/', $projectRoot . '/vendor/my-org/whitelisted-package/src/Service.php'); + $unwhitelistedVendorFile = str_replace('\\', '/', $projectRoot . '/vendor/doctrine/dbal/src/Schema/AbstractNamedObject.php'); + + expect(FileFilter::isFileExcluded($whitelistedVendorFile))->toBeFalse(); + expect(FileFilter::isFileExcluded($unwhitelistedVendorFile))->toBeTrue(); + + $refMethod = new ReflectionMethod(StreamWrapper::class, 'isApplicationFile'); + expect($refMethod->invoke(null, $whitelistedVendorFile, $whitelistedVendorFile))->toBeTrue() + ->and($refMethod->invoke(null, $unwhitelistedVendorFile, $unwhitelistedVendorFile))->toBeFalse() + ; + }); + + test('strictly isolates vendor files when specific nested application subpaths are included', function () { + Config::set([ + 'include' => [ + 'src/**', + 'src/Core/Framework/**', + 'src/Core/Content/**', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + $projectRoot = Config::getProjectRoot(); + $vendorFile = str_replace('\\', '/', $projectRoot . '/vendor/doctrine/dbal/src/Core/Table.php'); + + expect(FileFilter::isFileExcluded($vendorFile))->toBeTrue(); + }); +}); \ No newline at end of file From 12077df0bdc3dbe477b139ef0477b7b3a90d838b Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 19 Aug 2026 21:20:13 +0800 Subject: [PATCH 2/5] Add test for differentiating application folders from vendor folder names in FileFilter --- tests/Contract/VendorIsolationPathTest.php | 27 ++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/Contract/VendorIsolationPathTest.php b/tests/Contract/VendorIsolationPathTest.php index 7c826a8..887eced 100644 --- a/tests/Contract/VendorIsolationPathTest.php +++ b/tests/Contract/VendorIsolationPathTest.php @@ -36,9 +36,7 @@ StreamWrapper::register(); $projectRoot = Config::getProjectRoot(); - $vendorDoctrineFile = str_replace('\\', '/', $projectRoot . '/vendor/doctrine/dbal/src/Schema/AbstractNamedObject.php'); - $appFile = str_replace('\\', '/', $projectRoot . '/app/Services/UserService.php'); expect(FileFilter::isFileExcluded($vendorDoctrineFile))->toBeTrue() @@ -95,4 +93,29 @@ expect(FileFilter::isFileExcluded($vendorFile))->toBeTrue(); }); + + test('differentiates application folders from identical vendor folder names (e.g. lib/** in app vs lib/** in vendor)', function () { + Config::set([ + 'include' => [ + 'lib/**', + 'modules/**', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + StreamWrapper::register(); + $projectRoot = Config::getProjectRoot(); + + $appLibFile = str_replace('\\', '/', $projectRoot . '/lib/Services/PaymentProcessor.php'); + $vendorLibFile = str_replace('\\', '/', $projectRoot . '/vendor/dompdf/php-font-lib/lib/Font.php'); + expect(FileFilter::isFileExcluded($appLibFile))->toBeFalse(); + expect(FileFilter::isFileExcluded($vendorLibFile))->toBeTrue(); + + $refMethod = new ReflectionMethod(StreamWrapper::class, 'isApplicationFile'); + expect($refMethod->invoke(null, $appLibFile, $appLibFile))->toBeTrue() + ->and($refMethod->invoke(null, $vendorLibFile, $vendorLibFile))->toBeFalse() + ; + }); }); \ No newline at end of file From a1982392104e4b8d99859acae69351dcc85255a1 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 19 Aug 2026 21:22:38 +0800 Subject: [PATCH 3/5] Enhance vendor path handling in FileFilter and StreamWrapper to support explicit vendor whitelisting and improve test isolation with reset methods in Config and StreamWrapper. --- src/Contract/FileFilter.php | 21 ++++++++++++++++++--- src/Internal/Config.php | 7 ++++++- src/Internal/StreamWrapper.php | 31 +++++++++++++++++++++++++++---- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/Contract/FileFilter.php b/src/Contract/FileFilter.php index bc0d08b..f6289cc 100644 --- a/src/Contract/FileFilter.php +++ b/src/Contract/FileFilter.php @@ -82,16 +82,31 @@ public static function isFileExcluded(string|false|null $fileName): bool self::compilePatterns(); } - $longestIncludeMatch = 0; $isVendorPath = str_contains($normalizedPath, '/vendor/'); + if ($isVendorPath) { + $hasExplicitVendorWhitelist = false; + /** @var array $includes */ + $includes = self::$compiledIncludes; + foreach ($includes as $compiled) { + if (str_starts_with($compiled['pattern'], 'vendor/') && preg_match($compiled['regex'], $normalizedPath) === 1) { + $hasExplicitVendorWhitelist = true; + + break; + } + } + + if (! $hasExplicitVendorWhitelist) { + return self::$pathFilterCache[$normalizedPath] = true; // Instantly exclude! + } + } + $longestIncludeMatch = 0; /** @var array $includes */ $includes = self::$compiledIncludes; foreach ($includes as $compiled) { $isExplicitVendorInclude = str_starts_with($compiled['pattern'], 'vendor/'); $isWildcard = ($compiled['pattern'] === '*' || $compiled['pattern'] === '**'); - // Application include rules (like src/**, src/Core/**) never match inside vendor directories if ($isVendorPath && ! $isExplicitVendorInclude && ! $isWildcard) { continue; } @@ -173,4 +188,4 @@ private static function compileGlobToRegex(string $glob, string $baseDir): strin return '#' . $pattern . '#i'; } -} \ No newline at end of file +} diff --git a/src/Internal/Config.php b/src/Internal/Config.php index 576fd9c..01e0eb7 100644 --- a/src/Internal/Config.php +++ b/src/Internal/Config.php @@ -23,6 +23,9 @@ final class Config */ private static ?array $cachedConfig = null; + /** + * Cached absolute project root path. + */ private static ?string $projectRoot = null; private static bool $enabled = true; @@ -208,6 +211,7 @@ public static function set(array $config): void ContractParser::reset(); FileFilter::reset(); + StreamWrapper::reset(); } /** @@ -228,6 +232,7 @@ public static function reset(): void TemplateManager::reset(); HierarchyResolver::reset(); FileFilter::reset(); + StreamWrapper::reset(); } /** @@ -244,4 +249,4 @@ private static function syncFlags(array $config): void self::$magicMethods = (bool) ($config['magic_methods'] ?? true); self::$respectIgnoreTags = (bool) ($config['respect_ignore_tags'] ?? true); } -} \ No newline at end of file +} diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index 0197136..108dab1 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -53,6 +53,17 @@ final class StreamWrapper implements StreamWrapperInterface private static string $cacheDir = ''; + /** + * Resets the compiled pattern cache. Useful for test isolation. + */ + public static function reset(): void + { + self::$isInitialized = false; + self::$includeRawPatterns = []; + self::$excludeRawPatterns = []; + self::$baseDir = ''; + } + /** * @param array $config */ @@ -515,20 +526,32 @@ private static function isApplicationFile(string $path, string|false $resolvedPa return false; } - // Unconditionally prevent double-parsing cached files! $normalizedCacheDir = rtrim(str_replace('\\', '/', self::$cacheDir), '/') . '/'; if (str_starts_with($normalizedPath, $normalizedCacheDir)) { return false; } - $longestIncludeMatch = 0; $isVendorPath = str_contains($normalizedPath, '/vendor/'); + if ($isVendorPath) { + $hasExplicitVendorWhitelist = false; + foreach (self::$includeRawPatterns as $pattern => $regex) { + if (str_starts_with($pattern, 'vendor/') && preg_match($regex, $normalizedPath) === 1) { + $hasExplicitVendorWhitelist = true; + break; + } + } + + if (! $hasExplicitVendorWhitelist) { + return false; + } + } + + $longestIncludeMatch = 0; foreach (self::$includeRawPatterns as $pattern => $regex) { $isExplicitVendorInclude = str_starts_with($pattern, 'vendor/'); $isWildcard = ($pattern === '*' || $pattern === '**'); - // Application include rules (like src/**, src/Core/**) never match inside vendor directories if ($isVendorPath && ! $isExplicitVendorInclude && ! $isWildcard) { continue; } @@ -661,4 +684,4 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs); } -} \ No newline at end of file +} From ac3f4ba95e82affd45bae6dd9b72ac13279bb225 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 19 Aug 2026 21:22:59 +0800 Subject: [PATCH 4/5] Improve code styling --- src/Contract/ContractParser.php | 8 ++--- src/Contract/DocblockExtractor.php | 2 +- src/Resolver/SpecialTypeResolver.php | 2 +- src/Validator/TypeValidatorRegistry.php | 2 +- tests/Contract/VendorIsolationPathTest.php | 8 ++--- .../BaseShopwareExceptionFixture.php | 2 +- .../Exception/TableHelperExceptionFixture.php | 2 +- tests/Internal/StreamWrapperTest.php | 6 ++-- tests/Resolver/TemplateManagerTest.php | 3 +- tests/RuntimeChecker/InlineCheckerTest.php | 30 ++++++++++++------- tests/RuntimeChecker/ParamCheckerTest.php | 23 +++++++++----- tests/RuntimeChecker/ReturnCheckerTest.php | 25 ++++++++++------ .../ConstructorInheritanceMismatchTest.php | 7 +++-- 13 files changed, 72 insertions(+), 48 deletions(-) diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 2a9655a..3b34519 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -711,7 +711,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof CallableTypeNode) { $parameters = array_map( - fn(CallableTypeParameterNode $param) => new CallableTypeParameterNode( + fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode( self::substituteAliases($param->type, $aliases), $param->isReference, $param->isVariadic, @@ -745,7 +745,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof GenericTypeNode) { $genericType = self::substituteAliases($node->type, $aliases); $genericTypes = array_map( - fn($t) => self::substituteAliases($t, $aliases), + fn ($t) => self::substituteAliases($t, $aliases), $node->genericTypes ); @@ -762,14 +762,14 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof UnionTypeNode) { return new UnionTypeNode(array_map( - fn($t) => self::substituteAliases($t, $aliases), + fn ($t) => self::substituteAliases($t, $aliases), $node->types )); } if ($node instanceof IntersectionTypeNode) { return new IntersectionTypeNode(array_map( - fn($t) => self::substituteAliases($t, $aliases), + fn ($t) => self::substituteAliases($t, $aliases), $node->types )); } diff --git a/src/Contract/DocblockExtractor.php b/src/Contract/DocblockExtractor.php index 833faba..d813551 100644 --- a/src/Contract/DocblockExtractor.php +++ b/src/Contract/DocblockExtractor.php @@ -424,4 +424,4 @@ public static function extractMagicMethodContract(string $doc, string $methodNam return null; } -} \ No newline at end of file +} diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index d9a600b..c9195f7 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -1061,4 +1061,4 @@ private static function parseFileMetadata(string $fileName, string $source): voi // Silently fall back to empty metadata if parsing fails } } -} \ No newline at end of file +} diff --git a/src/Validator/TypeValidatorRegistry.php b/src/Validator/TypeValidatorRegistry.php index d3d5056..be191a5 100644 --- a/src/Validator/TypeValidatorRegistry.php +++ b/src/Validator/TypeValidatorRegistry.php @@ -89,4 +89,4 @@ public function validate(mixed $value, TypeNode $node, string $context): ?ErrorM return $err; } -} \ No newline at end of file +} diff --git a/tests/Contract/VendorIsolationPathTest.php b/tests/Contract/VendorIsolationPathTest.php index 887eced..f9a9ebe 100644 --- a/tests/Contract/VendorIsolationPathTest.php +++ b/tests/Contract/VendorIsolationPathTest.php @@ -53,7 +53,7 @@ Config::set([ 'include' => [ 'src/**', - 'vendor/my-org/whitelisted-package/**', + 'vendor/my-org/whitelisted-package/**', ], 'exclude' => [ 'vendor/**', @@ -80,7 +80,7 @@ Config::set([ 'include' => [ 'src/**', - 'src/Core/Framework/**', + 'src/Core/Framework/**', 'src/Core/Content/**', ], 'exclude' => [ @@ -97,7 +97,7 @@ test('differentiates application folders from identical vendor folder names (e.g. lib/** in app vs lib/** in vendor)', function () { Config::set([ 'include' => [ - 'lib/**', + 'lib/**', 'modules/**', ], 'exclude' => [ @@ -118,4 +118,4 @@ ->and($refMethod->invoke(null, $vendorLibFile, $vendorLibFile))->toBeFalse() ; }); -}); \ No newline at end of file +}); diff --git a/tests/Fixtures/Shopware/Exception/BaseShopwareExceptionFixture.php b/tests/Fixtures/Shopware/Exception/BaseShopwareExceptionFixture.php index f8cad6b..81f00fd 100644 --- a/tests/Fixtures/Shopware/Exception/BaseShopwareExceptionFixture.php +++ b/tests/Fixtures/Shopware/Exception/BaseShopwareExceptionFixture.php @@ -18,4 +18,4 @@ public function __construct( ) { parent::__construct($message, 0, $e); } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Shopware/Exception/TableHelperExceptionFixture.php b/tests/Fixtures/Shopware/Exception/TableHelperExceptionFixture.php index 93fce90..e1faeff 100644 --- a/tests/Fixtures/Shopware/Exception/TableHelperExceptionFixture.php +++ b/tests/Fixtures/Shopware/Exception/TableHelperExceptionFixture.php @@ -12,4 +12,4 @@ public function __construct( ) { parent::__construct($message, [], $previousException); } -} \ No newline at end of file +} diff --git a/tests/Internal/StreamWrapperTest.php b/tests/Internal/StreamWrapperTest.php index 1827fd7..a650986 100644 --- a/tests/Internal/StreamWrapperTest.php +++ b/tests/Internal/StreamWrapperTest.php @@ -56,16 +56,16 @@ function testGen(): Generator ; }); - test('strictly isolates vendor files with nested src directories when application includes specific src subpackages', function () { + test('strictly isolates vendor files with nested src directories when application includes specific src subpackages', function () { Config::set([ 'include' => [ 'src/**', - 'src/Core/**', + 'src/Core/**', 'src/Storefront/**', 'src/Administration/**', ], 'exclude' => [ - 'vendor/**', + 'vendor/**', 'storage/**', 'var/**', 'cache/**', diff --git a/tests/Resolver/TemplateManagerTest.php b/tests/Resolver/TemplateManagerTest.php index 014af48..dac76da 100644 --- a/tests/Resolver/TemplateManagerTest.php +++ b/tests/Resolver/TemplateManagerTest.php @@ -231,7 +231,8 @@ $variances = TemplateManager::getTemplateVariances($producer); expect($variances)->toHaveKey('T') - ->and($variances['T'])->toBe('covariant'); + ->and($variances['T'])->toBe('covariant') + ; }); }); }); diff --git a/tests/RuntimeChecker/InlineCheckerTest.php b/tests/RuntimeChecker/InlineCheckerTest.php index d00f930..76f7d8e 100644 --- a/tests/RuntimeChecker/InlineCheckerTest.php +++ b/tests/RuntimeChecker/InlineCheckerTest.php @@ -42,7 +42,8 @@ $invalid = InlineChecker::checkVariable(-5, 'positive-int', 'age', __FILE__, $registry); expect($invalid)->toBeInstanceOf(ErrorMessage::class) - ->and($invalid->getMessage())->toContain('Variable $age must be of type positive-int'); + ->and($invalid->getMessage())->toContain('Variable $age must be of type positive-int') + ; }); test('validates non-empty-string and numeric-string', function () { @@ -77,7 +78,8 @@ expect(InlineChecker::checkVariable([1, -5, 3], 'list', 'scores', __FILE__, $registry))->toBeInstanceOf(ErrorMessage::class); expect(InlineChecker::checkVariable(['id' => 1, 'name' => 'Alice'], 'array{id: int, name: string}', 'user', __FILE__, $registry)) - ->toBe(['id' => 1, 'name' => 'Alice']); + ->toBe(['id' => 1, 'name' => 'Alice']) + ; expect(InlineChecker::checkVariable(['id' => 1], 'array{id: int, name: string}', 'user', __FILE__, $registry))->toBeInstanceOf(ErrorMessage::class); }); @@ -112,7 +114,8 @@ $result = InlineChecker::checkVariable($collection, $typeString, 'dogs', __FILE__, $registry); expect($result)->toBe($collection) - ->and(TypePHP::getGenericType($collection))->toBe(Dog::class); + ->and(TypePHP::getGenericType($collection))->toBe(Dog::class) + ; }); test('ignores object and generic checks when respective toggles are false', function () { @@ -131,14 +134,15 @@ describe('checkVariable: Callables & Direct Returns', function () { test('wraps callable variable in lazy proxy', function () { $registry = new TypeValidatorRegistry(); - $cb = fn(int $id): string => "user_{$id}"; + $cb = fn (int $id): string => "user_{$id}"; $wrapped = InlineChecker::checkVariable($cb, 'callable(positive-int): non-empty-string', 'formatter', __FILE__, $registry); expect($wrapped)->toBeCallable() - ->and($wrapped(10))->toBe('user_10'); + ->and($wrapped(10))->toBe('user_10') + ; - expect(fn() => $wrapped(-5))->toThrow(TypeError::class, 'positive-int'); + expect(fn () => $wrapped(-5))->toThrow(TypeError::class, 'positive-int'); }); test('formats error message context as Return value when varName is return', function () { @@ -147,7 +151,8 @@ $invalid = InlineChecker::checkVariable(-5, 'positive-int', 'return', __FILE__, $registry); expect($invalid)->toBeInstanceOf(ErrorMessage::class) - ->and($invalid->getMessage())->toContain('Return value must be of type positive-int'); + ->and($invalid->getMessage())->toContain('Return value must be of type positive-int') + ; }); test('returns value immediately when all inline checks are disabled', function () { @@ -181,7 +186,8 @@ $invalid = InlineChecker::checkProperty(['invalid'], $fixture, 'numbers', __FILE__, $registry); expect($invalid)->toBeInstanceOf(ErrorMessage::class) - ->and($invalid->getMessage())->toContain('numbers[0]'); + ->and($invalid->getMessage())->toContain('numbers[0]') + ; }); test('validates static class properties against @var docblock', function () { @@ -192,7 +198,8 @@ $invalid = InlineChecker::checkProperty(12345, ConfiguredProperty::class, 'staticTitle', __FILE__, $registry); expect($invalid)->toBeInstanceOf(ErrorMessage::class) - ->and($invalid->getMessage())->toContain('staticTitle must be of type string'); + ->and($invalid->getMessage())->toContain('staticTitle must be of type string') + ; }); test('substitutes generic template types in class properties', function () { @@ -205,14 +212,15 @@ $registry = new TypeValidatorRegistry(); $collection = new HookedCollection(); - TemplateManager::bindTemplate(HookedCollection::class . '::__construct', $collection, 'T', new \PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode(Dog::class)); + TemplateManager::bindTemplate(HookedCollection::class . '::__construct', $collection, 'T', new PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode(Dog::class)); $valid = InlineChecker::checkProperty([new Dog()], $collection, 'items', __FILE__, $registry); expect($valid)->toBeArray(); $invalid = InlineChecker::checkProperty([new Car()], $collection, 'items', __FILE__, $registry); expect($invalid)->toBeInstanceOf(ErrorMessage::class) - ->and($invalid->getMessage())->toContain("items['0']"); + ->and($invalid->getMessage())->toContain("items['0']") + ; }); test('ignores property checks when properties toggle is false', function () { diff --git a/tests/RuntimeChecker/ParamCheckerTest.php b/tests/RuntimeChecker/ParamCheckerTest.php index 56f976a..ef2b761 100644 --- a/tests/RuntimeChecker/ParamCheckerTest.php +++ b/tests/RuntimeChecker/ParamCheckerTest.php @@ -39,7 +39,8 @@ $err = ParamChecker::checkParams($target, ['id' => -5], new UserService(), $registry); expect($err)->toBeInstanceOf(ErrorMessage::class) - ->and($err->getMessage())->toContain('positive-int'); + ->and($err->getMessage())->toContain('positive-int') + ; }); test('handles omitted optional parameters gracefully without error', function () { @@ -91,7 +92,8 @@ ], $service, $registry); expect($err)->toBeInstanceOf(ErrorMessage::class) - ->and($err->getMessage())->toContain("['b']"); + ->and($err->getMessage())->toContain("['b']") + ; }); test('pre-infers template T from list parameter', function () { @@ -105,7 +107,8 @@ ], $service, $registry); expect($err)->toBeInstanceOf(ErrorMessage::class) - ->and($err->getMessage())->toContain('[1]'); + ->and($err->getMessage())->toContain('[1]') + ; }); }); @@ -159,7 +162,8 @@ ], null, $registry); expect($err)->toBeInstanceOf(ErrorMessage::class) - ->and($err->getMessage())->toContain('must be a class-string of Countable'); + ->and($err->getMessage())->toContain('must be a class-string of Countable') + ; }); test('returns ErrorMessage when class-string is not a valid class name', function () { @@ -171,7 +175,8 @@ ], null, $registry); expect($err)->toBeInstanceOf(ErrorMessage::class) - ->and($err->getMessage())->toContain('must be a valid class-string'); + ->and($err->getMessage())->toContain('must be a valid class-string') + ; }); }); @@ -192,7 +197,8 @@ 'arguments' => [-5, 'Alice'], ], $fixture, $registry); expect($invalidErr)->toBeInstanceOf(ErrorMessage::class) - ->and($invalidErr->getMessage())->toContain('positive-int'); + ->and($invalidErr->getMessage())->toContain('positive-int') + ; }); test('validates variadic parameters on dynamic static @method calls routed via __callStatic', function () { @@ -210,7 +216,8 @@ 'arguments' => [1, 2, 'invalid_int'], ], MagicMethodFixture::class, $registry); expect($invalidErr)->toBeInstanceOf(ErrorMessage::class) - ->and($invalidErr->getMessage())->toContain('$items[2]'); + ->and($invalidErr->getMessage())->toContain('$items[2]') + ; }); }); @@ -236,4 +243,4 @@ ->and($invalidErr->getMessage())->toContain('positive-int'); }); }); -}); \ No newline at end of file +}); diff --git a/tests/RuntimeChecker/ReturnCheckerTest.php b/tests/RuntimeChecker/ReturnCheckerTest.php index dd20726..f8c92a5 100644 --- a/tests/RuntimeChecker/ReturnCheckerTest.php +++ b/tests/RuntimeChecker/ReturnCheckerTest.php @@ -7,7 +7,7 @@ use TypePHP\Internal\ErrorMessage; use TypePHP\Tests\Fixtures\Collections\ConcreteFileCollection; use TypePHP\Tests\Fixtures\Collections\PluginConfiguration; -use TypePHP\Tests\Fixtures\Conditionals\ConditionalReturnService;; +use TypePHP\Tests\Fixtures\Conditionals\ConditionalReturnService; use TypePHP\Tests\Fixtures\Generics\DogConditionalBox; use TypePHP\Tests\Fixtures\Services\AdminEntityFactory; use TypePHP\Tests\Fixtures\Services\FluentService; @@ -45,7 +45,8 @@ $result = ReturnChecker::checkReturn($target, $badValue, new UserService(), ['id' => -5], $registry, fn () => null); expect($result)->toBeInstanceOf(ErrorMessage::class) - ->and($result->getMessage())->toContain("Return value['id'] must be of type positive-int"); + ->and($result->getMessage())->toContain("Return value['id'] must be of type positive-int") + ; }); test('returns value directly when returns checking is disabled in config', function () { @@ -83,7 +84,8 @@ $result = ReturnChecker::checkReturn($target, new FluentService(), $service, [], $registry, fn () => null); expect($result)->toBeInstanceOf(ErrorMessage::class) - ->and($result->getMessage())->toContain('must be $this instance'); + ->and($result->getMessage())->toContain('must be $this instance') + ; }); }); @@ -106,7 +108,8 @@ $result = ReturnChecker::checkReturn($target, $siblingInstance, UserEntityFactory::class, [], $registry, fn () => null); expect($result)->toBeInstanceOf(ErrorMessage::class) - ->and($result->getMessage())->toContain('must be of type TypePHP\Tests\Fixtures\Services\UserEntityFactory'); + ->and($result->getMessage())->toContain('must be of type TypePHP\Tests\Fixtures\Services\UserEntityFactory') + ; }); }); @@ -121,7 +124,8 @@ $badResult = ReturnChecker::checkReturn($target, -10, $service, ['format' => 'int', 'value' => -10], $registry, fn () => null); expect($badResult)->toBeInstanceOf(ErrorMessage::class) - ->and($badResult->getMessage())->toContain('positive-int'); + ->and($badResult->getMessage())->toContain('positive-int') + ; }); test('evaluates fallback else branch (non-empty-string)', function () { @@ -134,7 +138,8 @@ $badResult = ReturnChecker::checkReturn($target, '', $service, ['format' => 'other', 'value' => ''], $registry, fn () => null); expect($badResult)->toBeInstanceOf(ErrorMessage::class) - ->and($badResult->getMessage())->toContain('non-empty-string'); + ->and($badResult->getMessage())->toContain('non-empty-string') + ; }); test('evaluates negated parameter conditions ($flag is not true)', function () { @@ -161,7 +166,8 @@ $badResult = ReturnChecker::checkReturn($target, -50, $box, ['input' => -50], $registry, fn () => null); expect($badResult)->toBeInstanceOf(ErrorMessage::class) - ->and($badResult->getMessage())->toContain('positive-int'); + ->and($badResult->getMessage())->toContain('positive-int') + ; }); }); @@ -192,7 +198,8 @@ ], $registry, fn () => null); expect($result)->toBeInstanceOf(ErrorMessage::class) - ->and($result->getMessage())->toContain("Return value['id'] must be of type positive-int"); + ->and($result->getMessage())->toContain("Return value['id'] must be of type positive-int") + ; }); }); @@ -222,4 +229,4 @@ function () use (&$wrappedCalled) { ->and($wrappedCalled)->toBeFalse(); }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/InheritanceAndAttributes/ConstructorInheritanceMismatchTest.php b/tests/TypeChecking/InheritanceAndAttributes/ConstructorInheritanceMismatchTest.php index aac9d6f..583e662 100644 --- a/tests/TypeChecking/InheritanceAndAttributes/ConstructorInheritanceMismatchTest.php +++ b/tests/TypeChecking/InheritanceAndAttributes/ConstructorInheritanceMismatchTest.php @@ -6,11 +6,12 @@ describe('Constructor Parameter Inheritance Mismatch (Shopware TableHelperException)', function () { test('child constructor with different parameter names does not inherit parent constructor docblocks by positional index', function () { - $previous = new \Exception('Underlying DB error'); + $previous = new Exception('Underlying DB error'); $exception = new TableHelperExceptionFixture('Table missing', $previous); expect($exception)->toBeInstanceOf(TableHelperExceptionFixture::class) - ->and($exception->getPrevious())->toBe($previous); + ->and($exception->getPrevious())->toBe($previous) + ; }); -}); \ No newline at end of file +}); From d46891e8221fc28ab68765658c0a30eb03bb2847 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 19 Aug 2026 21:28:34 +0800 Subject: [PATCH 5/5] Add more edge cases test in vendor isolation tests suite --- tests/Contract/VendorIsolationPathTest.php | 150 ++++++++++++++++++++- 1 file changed, 144 insertions(+), 6 deletions(-) diff --git a/tests/Contract/VendorIsolationPathTest.php b/tests/Contract/VendorIsolationPathTest.php index f9a9ebe..7d5a64a 100644 --- a/tests/Contract/VendorIsolationPathTest.php +++ b/tests/Contract/VendorIsolationPathTest.php @@ -2,9 +2,6 @@ declare(strict_types=1); -namespace TypePHP\Tests\Contract; - -use ReflectionMethod; use TypePHP\Contract\FileFilter; use TypePHP\Internal\Config; use TypePHP\Internal\StreamWrapper; @@ -36,6 +33,7 @@ StreamWrapper::register(); $projectRoot = Config::getProjectRoot(); + $vendorDoctrineFile = str_replace('\\', '/', $projectRoot . '/vendor/doctrine/dbal/src/Schema/AbstractNamedObject.php'); $appFile = str_replace('\\', '/', $projectRoot . '/app/Services/UserService.php'); @@ -53,7 +51,7 @@ Config::set([ 'include' => [ 'src/**', - 'vendor/my-org/whitelisted-package/**', + 'vendor/my-org/whitelisted-package/**', ], 'exclude' => [ 'vendor/**', @@ -80,7 +78,7 @@ Config::set([ 'include' => [ 'src/**', - 'src/Core/Framework/**', + 'src/Core/Framework/**', 'src/Core/Content/**', ], 'exclude' => [ @@ -106,10 +104,12 @@ ]); StreamWrapper::register(); + $projectRoot = Config::getProjectRoot(); $appLibFile = str_replace('\\', '/', $projectRoot . '/lib/Services/PaymentProcessor.php'); $vendorLibFile = str_replace('\\', '/', $projectRoot . '/vendor/dompdf/php-font-lib/lib/Font.php'); + expect(FileFilter::isFileExcluded($appLibFile))->toBeFalse(); expect(FileFilter::isFileExcluded($vendorLibFile))->toBeTrue(); @@ -118,4 +118,142 @@ ->and($refMethod->invoke(null, $vendorLibFile, $vendorLibFile))->toBeFalse() ; }); -}); + + test('allows whitelisting a single specific file inside a vendor package while excluding its siblings', function () { + Config::set([ + 'include' => [ + 'src/**', + 'vendor/monolog/monolog/src/Monolog/Logger.php', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + StreamWrapper::register(); + + $projectRoot = Config::getProjectRoot(); + + $whitelistedSingleFile = str_replace('\\', '/', $projectRoot . '/vendor/monolog/monolog/src/Monolog/Logger.php'); + $siblingVendorFile = str_replace('\\', '/', $projectRoot . '/vendor/monolog/monolog/src/Monolog/Formatter/LineFormatter.php'); + + expect(FileFilter::isFileExcluded($whitelistedSingleFile))->toBeFalse(); + expect(FileFilter::isFileExcluded($siblingVendorFile))->toBeTrue(); + + $refMethod = new ReflectionMethod(StreamWrapper::class, 'isApplicationFile'); + expect($refMethod->invoke(null, $whitelistedSingleFile, $whitelistedSingleFile))->toBeTrue() + ->and($refMethod->invoke(null, $siblingVendorFile, $siblingVendorFile))->toBeFalse() + ; + }); + + test('allows blacklisting a specific legacy file inside an otherwise whitelisted vendor package', function () { + Config::set([ + 'include' => [ + 'src/**', + 'vendor/acme/custom-package/**', + ], + 'exclude' => [ + 'vendor/**', + 'vendor/acme/custom-package/src/Legacy/UnsafeFile.php', + ], + ]); + + StreamWrapper::register(); + + $projectRoot = Config::getProjectRoot(); + + $safeFile = str_replace('\\', '/', $projectRoot . '/vendor/acme/custom-package/src/SafeService.php'); + $unsafeFile = str_replace('\\', '/', $projectRoot . '/vendor/acme/custom-package/src/Legacy/UnsafeFile.php'); + + expect(FileFilter::isFileExcluded($safeFile))->toBeFalse(); + expect(FileFilter::isFileExcluded($unsafeFile))->toBeTrue(); + + $refMethod = new ReflectionMethod(StreamWrapper::class, 'isApplicationFile'); + expect($refMethod->invoke(null, $safeFile, $safeFile))->toBeTrue() + ->and($refMethod->invoke(null, $unsafeFile, $unsafeFile))->toBeFalse() + ; + }); + + test('does not falsely classify application directories like vendor-tools/ or vendor_custom/ as vendor directories', function () { + Config::set([ + 'include' => [ + 'vendor-tools/**', + 'vendor_custom/**', + 'src/**', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + StreamWrapper::register(); + + $projectRoot = Config::getProjectRoot(); + + $appToolsFile = str_replace('\\', '/', $projectRoot . '/vendor-tools/DeployScript.php'); + $appCustomFile = str_replace('\\', '/', $projectRoot . '/vendor_custom/Helper.php'); + $realVendorFile = str_replace('\\', '/', $projectRoot . '/vendor/symfony/console/Application.php'); + + expect(FileFilter::isFileExcluded($appToolsFile))->toBeFalse(); + expect(FileFilter::isFileExcluded($appCustomFile))->toBeFalse(); + expect(FileFilter::isFileExcluded($realVendorFile))->toBeTrue(); + + $refMethod = new ReflectionMethod(StreamWrapper::class, 'isApplicationFile'); + expect($refMethod->invoke(null, $appToolsFile, $appToolsFile))->toBeTrue() + ->and($refMethod->invoke(null, $appCustomFile, $appCustomFile))->toBeTrue() + ->and($refMethod->invoke(null, $realVendorFile, $realVendorFile))->toBeFalse() + ; + }); + + test('handles vendor package names containing hyphens, dots, numbers, and scoped prefixes', function () { + Config::set([ + 'include' => [ + 'src/**', + 'vendor/symfony/polyfill-php83/**', + 'vendor/2amigos/qrcode-library/**', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + StreamWrapper::register(); + + $projectRoot = Config::getProjectRoot(); + + $scopedVendorFile = str_replace('\\', '/', $projectRoot . '/vendor/symfony/polyfill-php83/bootstrap.php'); + $numericVendorFile = str_replace('\\', '/', $projectRoot . '/vendor/2amigos/qrcode-library/src/QrCode.php'); + $unwhitelistedVendor = str_replace('\\', '/', $projectRoot . '/vendor/guzzlehttp/guzzle/src/Client.php'); + + expect(FileFilter::isFileExcluded($scopedVendorFile))->toBeFalse(); + expect(FileFilter::isFileExcluded($numericVendorFile))->toBeFalse(); + expect(FileFilter::isFileExcluded($unwhitelistedVendor))->toBeTrue(); + }); + + test('handles mixed Windows backslashes and Unix forward slashes in vendor paths seamlessly', function () { + Config::set([ + 'include' => [ + 'src/**', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + StreamWrapper::register(); + + $projectRoot = Config::getProjectRoot(); + + $windowsVendorPath = $projectRoot . '\\vendor\\doctrine\\dbal\\src\\Schema\\Column.php'; + $windowsAppPath = $projectRoot . '\\app\\Services\\OrderService.php'; + + expect(FileFilter::isFileExcluded($windowsVendorPath))->toBeTrue() + ->and(FileFilter::isFileExcluded($windowsAppPath))->toBeFalse() + ; + + $refMethod = new ReflectionMethod(StreamWrapper::class, 'isApplicationFile'); + expect($refMethod->invoke(null, $windowsVendorPath, $windowsVendorPath))->toBeFalse() + ->and($refMethod->invoke(null, $windowsAppPath, $windowsAppPath))->toBeTrue() + ; + }); +}); \ No newline at end of file