Skip to content

Commit 8f06875

Browse files
ondrejmirtesclaude
andcommitted
Un-prefix Filter\ and PDO\ class name strings in the phar
php-scoper prefixes namespaced string literals such as `new ObjectType('Filter\FilterFailedException')` even when they name internal PHP classes, so the phar reported `_PHPStan_<sha>\Filter\FilterFailedException` and returned prefixed `PDO\*` subclasses from `PDO::connect()`. The namespaces un-prefixed by the patcher now live in compiler/build/scoper-namespaces.php, shared with a test that fails when src/ contains a class name string whose namespace is not handled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SCH8hvECi7zaEUr1Get7sG
1 parent 98898c2 commit 8f06875

3 files changed

Lines changed: 164 additions & 35 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php declare(strict_types=1);
2+
3+
return [
4+
/**
5+
* Namespaces php-scoper leaves alone completely - neither declarations nor
6+
* references to them are prefixed.
7+
*/
8+
'excluded' => [
9+
'PHPStan',
10+
// the native turbo extension's classes — must match the loaded
11+
// extension exactly, never prefixed (segment-aware matching means the
12+
// PHPStan entry above does not cover this name)
13+
'PHPStanTurbo',
14+
'PHPUnit',
15+
'PhpParser',
16+
'Hoa',
17+
'Symfony\Polyfill\Php80',
18+
'Symfony\Polyfill\Php81',
19+
'Symfony\Polyfill\Php83',
20+
'Symfony\Polyfill\Php84',
21+
'Symfony\Polyfill\Php85',
22+
'Symfony\Polyfill\Mbstring',
23+
'Symfony\Polyfill\Intl\Normalizer',
24+
'Symfony\Polyfill\Intl\Grapheme',
25+
],
26+
27+
/**
28+
* Namespaces of classes that belong to the analysed code, referenced from
29+
* src/ through string literals like `new ObjectType('BcMath\Number')`.
30+
*
31+
* php-scoper prefixes such literals even when they name internal PHP
32+
* classes, which would turn them into class names that do not exist in the
33+
* analysed code, so a patcher in scoper.inc.php strips the prefix back off.
34+
* ScoperClassNameStringsTest fails when src/ references a namespace that is
35+
* missing here.
36+
*/
37+
'unprefixedClassNameStringsInSrc' => [
38+
'BcMath',
39+
'Dom',
40+
'Ds',
41+
'FFI',
42+
'Filter',
43+
'Foobar',
44+
'PDO',
45+
],
46+
];

‎compiler/build/scoper.inc.php‎

Lines changed: 10 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
require_once __DIR__ . '/../vendor/autoload.php';
44

5+
$namespaces = require __DIR__ . '/scoper-namespaces.php';
6+
57
$stubs = [
68
'../../vendor/hoa/consistency/Prelude.php',
79
'../../vendor/composer/InstalledVersions.php',
@@ -213,49 +215,22 @@ function (string $filePath, string $prefix, string $content): string {
213215

214216
return str_replace(sprintf('%s\\PropertyHookType', $prefix), 'PropertyHookType', $content);
215217
},
216-
function (string $filePath, string $prefix, string $content): string {
218+
function (string $filePath, string $prefix, string $content) use ($namespaces): string {
217219
if (strpos($filePath, 'src/') !== 0) {
218220
return $content;
219221
}
220222

221-
return str_replace([
222-
sprintf('\'%s\\BcMath\\', $prefix),
223-
sprintf('\'%s\\Dom\\', $prefix),
224-
sprintf('\'%s\\FFI\\', $prefix),
225-
sprintf('\'%s\\Ds\\', $prefix),
226-
], [
227-
'\'BcMath\\',
228-
'\'Dom\\',
229-
'\'FFI\\',
230-
'\'Ds\\',
231-
], $content);
232-
},
233-
function (string $filePath, string $prefix, string $content): string {
234-
if (strpos($filePath, 'src/Testing/ErrorFormatterTestCase.php') !== 0) {
235-
return $content;
223+
$search = [];
224+
$replace = [];
225+
foreach ($namespaces['unprefixedClassNameStringsInSrc'] as $namespace) {
226+
$search[] = sprintf('\'%s\\%s\\', $prefix, $namespace);
227+
$replace[] = sprintf('\'%s\\', $namespace);
236228
}
237229

238-
return str_replace(sprintf('new Error(\'%s\\Foobar\\Buz', $prefix), 'new Error(\'Foobar\\Buz', $content);
230+
return str_replace($search, $replace, $content);
239231
},
240232
],
241-
'exclude-namespaces' => [
242-
'PHPStan',
243-
// the native turbo extension's classes — must match the loaded
244-
// extension exactly, never prefixed (segment-aware matching means the
245-
// PHPStan entry above does not cover this name)
246-
'PHPStanTurbo',
247-
'PHPUnit',
248-
'PhpParser',
249-
'Hoa',
250-
'Symfony\Polyfill\Php80',
251-
'Symfony\Polyfill\Php81',
252-
'Symfony\Polyfill\Php83',
253-
'Symfony\Polyfill\Php84',
254-
'Symfony\Polyfill\Php85',
255-
'Symfony\Polyfill\Mbstring',
256-
'Symfony\Polyfill\Intl\Normalizer',
257-
'Symfony\Polyfill\Intl\Grapheme',
258-
],
233+
'exclude-namespaces' => $namespaces['excluded'],
259234
'expose-global-functions' => false,
260235
'expose-global-classes' => false,
261236
];
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Build;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Symfony\Component\Finder\Finder;
7+
use function file_get_contents;
8+
use function implode;
9+
use function is_array;
10+
use function preg_match;
11+
use function sprintf;
12+
use function str_replace;
13+
use function str_starts_with;
14+
use function strpos;
15+
use function strtolower;
16+
use function substr;
17+
use function token_get_all;
18+
use const T_CONSTANT_ENCAPSED_STRING;
19+
20+
/**
21+
* Class-name string literals in src/ - `new ObjectType('BcMath\Number')` and
22+
* friends - name classes of the analysed code. php-scoper prefixes every string
23+
* that looks like a fully qualified class name, so unless the namespace is
24+
* either excluded from prefixing altogether or listed among the namespaces the
25+
* patcher in compiler/build/scoper.inc.php un-prefixes again, the phar ends up
26+
* reporting class names like `_PHPStan_abcdef\Filter\FilterFailedException`.
27+
*/
28+
final class ScoperClassNameStringsTest extends TestCase
29+
{
30+
31+
public function testClassNameStringsAreNotPrefixedInPhar(): void
32+
{
33+
/** @var array{excluded: list<string>, unprefixedClassNameStringsInSrc: list<string>} $namespaces */
34+
$namespaces = require __DIR__ . '/../../../compiler/build/scoper-namespaces.php';
35+
36+
$finder = new Finder();
37+
$finder->followLinks();
38+
foreach ($finder->files()->name('*.php')->in(__DIR__ . '/../../../src') as $fileInfo) {
39+
$file = $fileInfo->getPathname();
40+
$code = file_get_contents($file);
41+
if ($code === false) {
42+
self::fail(sprintf('Could not read %s', $file));
43+
}
44+
45+
foreach (token_get_all($code) as $token) {
46+
if (!is_array($token) || $token[0] !== T_CONSTANT_ENCAPSED_STRING) {
47+
continue;
48+
}
49+
50+
$className = self::parseStringLiteral($token[1]);
51+
if (preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*(\\\\[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)+$/', $className) !== 1) {
52+
continue;
53+
}
54+
55+
if (
56+
self::belongsToNamespace($className, $namespaces['excluded'])
57+
|| self::belongsToNamespace($className, $namespaces['unprefixedClassNameStringsInSrc'])
58+
) {
59+
continue;
60+
}
61+
62+
self::fail(sprintf(
63+
"%s on line %d contains the class name string '%s'. php-scoper prefixes such strings in the phar, "
64+
. "so its root namespace has to be added to 'unprefixedClassNameStringsInSrc' in compiler/build/scoper-namespaces.php. "
65+
. 'Namespaces currently handled: %s.',
66+
$file,
67+
$token[2],
68+
$className,
69+
implode(', ', $namespaces['unprefixedClassNameStringsInSrc']),
70+
));
71+
}
72+
}
73+
74+
self::expectNotToPerformAssertions();
75+
}
76+
77+
private static function parseStringLiteral(string $token): string
78+
{
79+
$contents = substr($token, 1, -1);
80+
if ($token[0] === '\'') {
81+
return str_replace(['\\\\', '\\\''], ['\\', '\''], $contents);
82+
}
83+
84+
// double-quoted strings with any escape sequence besides \\ cannot be class names
85+
$contents = str_replace('\\\\', "\0", $contents);
86+
if (strpos($contents, '\\') !== false) {
87+
return '';
88+
}
89+
90+
return str_replace("\0", '\\', $contents);
91+
}
92+
93+
/**
94+
* @param list<string> $namespaces
95+
*/
96+
private static function belongsToNamespace(string $className, array $namespaces): bool
97+
{
98+
foreach ($namespaces as $namespace) {
99+
// namespaces are case-insensitive in PHP and so is php-scoper's matching
100+
if (str_starts_with(strtolower($className), strtolower($namespace) . '\\')) {
101+
return true;
102+
}
103+
}
104+
105+
return false;
106+
}
107+
108+
}

0 commit comments

Comments
 (0)