Skip to content
Merged
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
50 changes: 50 additions & 0 deletions .github/workflows/phar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,56 @@
fi
eval "$SCRIPT"

phar-run:
name: "Run PHAR on PHP ${{ matrix.php-version }}"
needs: compiler-tests
runs-on: "ubuntu-latest"
timeout-minutes: 20

strategy:
fail-fast: false
matrix:
php-version: ["7.4", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5"]

steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit

- name: "Checkout"
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false

- name: "Install PHP"
# Deliberately without `ini-file: development`: that ini lists the
# pcntl functions in disable_functions, and TurboProcessRestarter then
# returns before TurboExtensionSelector ever runs. With pcntl available
# the phar boots through the turbo restart code, which bin/phpstan
# loads before the Composer autoloader - on PHP < 8.0 without the
# symfony polyfills (https://github.com/phpstan/phpstan/issues/15137).
uses: "shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240" # v2.37.2
with:
coverage: "none"
php-version: "${{ matrix.php-version }}"

- name: "Download PHAR"
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: phar-file
path: tmp

- name: "Verify the turbo restart path is reachable"
run: |
php -r 'exit(function_exists("pcntl_exec") && function_exists("pcntl_fork") ? 0 : 1);'

- name: "Run PHAR"
run: |
php tmp/phpstan.phar --version
cd e2e/phar-run
php ../../tmp/phpstan.phar analyse -vvv

integration-tests:
if: github.event_name == 'pull_request'
needs:
Expand Down
16 changes: 8 additions & 8 deletions build/PHPStan/Build/SkipTestsWithRequiresPhpAttributeRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ public function processNode(Node $node, Scope $scope): array
return [];
}

if (!$firstStmt->cond->left instanceof Node\Expr\ConstFetch || $firstStmt->cond->left->name->toString() !== 'PHP_VERSION_ID') {
return [];
}

if (!$firstStmt->cond->right instanceof Node\Scalar\Int_) {
return [];
}

switch (get_class($firstStmt->cond)) {
case Node\Expr\BinaryOp\SmallerOrEqual::class:
$inverseBinaryOpSigil = '>';
Expand All @@ -75,14 +83,6 @@ public function processNode(Node $node, Scope $scope): array
throw new ShouldNotHappenException('No inverse comparison specified for ' . get_class($firstStmt->cond));
}

if (!$firstStmt->cond->left instanceof Node\Expr\ConstFetch || $firstStmt->cond->left->name->toString() !== 'PHP_VERSION_ID') {
return [];
}

if (!$firstStmt->cond->right instanceof Node\Scalar\Int_) {
return [];
}

if (count($firstStmt->stmts) !== 1) {
return [];
}
Expand Down
4 changes: 4 additions & 0 deletions e2e/phar-run/phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
parameters:
level: 8
paths:
- src
13 changes: 13 additions & 0 deletions e2e/phar-run/src/Foo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php declare(strict_types = 1);

namespace PharRun;

final class Foo
{

public function doFoo(string $s): int
{
return strlen($s);
}

}
11 changes: 10 additions & 1 deletion src/Turbo/TurboDiagnoseExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
use PHPStan\Command\Output;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Diagnose\DiagnoseExtension;
use PHPStan\Php\PhpVersion;
use function php_uname;
use function phpversion;
use function sprintf;
use const PHP_DEBUG;
use const PHP_MAJOR_VERSION;
use const PHP_MINOR_VERSION;
use const PHP_OS_FAMILY;
use const PHP_VERSION_ID;
use const PHP_ZTS;

#[AutowiredService]
Expand Down Expand Up @@ -48,8 +50,15 @@ public function print(Output $output): void
} else {
$workerBinaryLine = 'loaded via php.ini, workers inherit it';
}
} elseif ($workerBinary !== null) {
$workerBinaryLine = $workerBinary;
} elseif (PHP_VERSION_ID < TurboExtensionSelector::MINIMUM_PHP_VERSION_ID) {
$workerBinaryLine = sprintf(
'none built for PHP < %s',
(new PhpVersion(TurboExtensionSelector::MINIMUM_PHP_VERSION_ID))->getVersionString(),
);
} else {
$workerBinaryLine = $workerBinary ?? 'none found';
$workerBinaryLine = 'none found';
}
$output->writeLineFormatted(sprintf(
'<info>Turbo worker binary:</info> %s',
Expand Down
25 changes: 22 additions & 3 deletions src/Turbo/TurboExtensionSelector.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
use function is_file;
use function php_uname;
use function sprintf;
use function str_contains;
use function strpos;
use const PHP_DEBUG;
use const PHP_MAJOR_VERSION;
use const PHP_MINOR_VERSION;
use const PHP_OS_FAMILY;
use const PHP_VERSION_ID;
use const PHP_ZTS;

/**
Expand All @@ -26,15 +27,27 @@
* Windows) by the phar.yml commit job, so they only exist for phar-based
* installations —
* a source checkout loads its locally built extension through php.ini
* instead. Only non-debug builds are shipped; ZTS variants (-zts filename
* instead. Only non-debug builds for PHP >= MINIMUM_PHP_VERSION_ID are
* shipped; ZTS variants (-zts filename
* suffix) exist for linux-gnu and Windows so hosts with a thread-safe PHP
* (like PMMP's bundled build) are covered too. Workers run the regular
* entrypoint, so TurboExtensionEnabler still gates activation on the
* expected extension version.
*
* bin/phpstan loads this class before the Composer autoloader (for the
* process restart), so it must only call functions native to PHP 7.4 - the
* symfony polyfills are not registered yet. PreAutoloadFilesTest guards this.
*/
final class TurboExtensionSelector
{

/**
* The oldest PHP the extension is built for — the phar.yml turbo-compile
* matrix. Older runtimes have no binary to find, so they skip the lookup
* (and with it the process restart) entirely.
*/
public const MINIMUM_PHP_VERSION_ID = 80300;

public static function findExtensionForWorkers(): ?string
{
if (TurboExtensionEnabler::isLoaded()) {
Expand All @@ -59,6 +72,9 @@ public static function findExtensionForWorkers(): ?string
*/
public static function findExtension(): ?string
{
if (PHP_VERSION_ID < self::MINIMUM_PHP_VERSION_ID) {
return null;
}
if ((bool) PHP_DEBUG) {
return null;
}
Expand Down Expand Up @@ -124,7 +140,10 @@ public static function isMusl(): bool
public static function resolveIsMusl(string|false $selfMaps, bool $hasAlpineRelease): bool
{
if ($selfMaps !== false) {
return str_contains($selfMaps, '/ld-musl-');
// strpos() rather than str_contains(): this runs before the Composer
// autoloader, so the symfony polyfill is not loaded yet and
// str_contains() does not exist on PHP < 8.0
return strpos($selfMaps, '/ld-musl-') !== false;
}

// no procfs to inspect (non-Linux, or a sandboxed container without
Expand Down
160 changes: 160 additions & 0 deletions tests/PHPStan/Build/PreAutoloadFilesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<?php declare(strict_types = 1);

namespace PHPStan\Build;

use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\BinaryOp\Concat;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\Include_;
use PhpParser\Node\Name;
use PhpParser\Node\Scalar\MagicConst\Dir;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\Function_;
use PhpParser\NodeFinder;
use PhpParser\ParserFactory;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Finder\Finder;
use function dirname;
use function file_get_contents;
use function realpath;
use function sprintf;
use function str_ends_with;
use function str_replace;
use function strlen;
use function strtolower;
use function substr;

/**
* bin/phpstan loads the turbo restart classes before the Composer autoloader,
* so the symfony polyfills are not registered yet when they run. A call to a
* polyfilled function there (str_contains(), ...) is a fatal error on every
* PHP version lacking it natively - and the phar supports PHP 7.4
* (https://github.com/phpstan/phpstan/issues/15137).
*/
final class PreAutoloadFilesTest extends TestCase
{

/**
* bin/phpstan itself up to the autoloader require, plus every file it
* requires before it.
*
* @return iterable<string, array{string, int|null}>
*/
public static function dataPreAutoloadFiles(): iterable
{
$root = realpath(__DIR__ . '/../../..');
self::assertNotFalse($root);
$root = str_replace('\\', '/', $root);
$binPath = $root . '/bin/phpstan';

$autoloadLine = null;
$files = [];
foreach ((new NodeFinder())->findInstanceOf(self::parse($binPath), Include_::class) as $include) {
$path = self::resolveIncludePath($include->expr, dirname($binPath));
if ($path === null) {
continue;
}
if (str_ends_with($path, '/vendor/autoload.php')) {
$autoloadLine = $include->getStartLine();
break;
}

$files[] = $path;
}

self::assertNotNull($autoloadLine, 'bin/phpstan does not require vendor/autoload.php');
self::assertNotSame([], $files, 'bin/phpstan requires nothing before vendor/autoload.php');

yield 'bin/phpstan' => [$binPath, $autoloadLine];
foreach ($files as $file) {
yield substr($file, strlen($root) + 1) => [$file, null];
}
}

/**
* @param int|null $beforeLine Only calls above this line run before the autoloader
*/
#[DataProvider('dataPreAutoloadFiles')]
public function testCallsOnlyNativeFunctions(string $file, ?int $beforeLine): void
{
$polyfilled = self::polyfilledFunctions();

$violations = [];
foreach ((new NodeFinder())->findInstanceOf(self::parse($file), FuncCall::class) as $call) {
if (!$call->name instanceof Name) {
continue;
}
if ($beforeLine !== null && $call->getStartLine() >= $beforeLine) {
continue;
}

$function = strtolower($call->name->getLast());
if (!isset($polyfilled[$function])) {
continue;
}

$violations[] = sprintf('%s() on line %d', $function, $call->getStartLine());
}

self::assertSame([], $violations, sprintf(
'%s runs before the Composer autoloader registers the symfony polyfills, so it must only call functions native to PHP 7.4.',
$file,
));
}

private static function resolveIncludePath(Expr $expr, string $dir): ?string
{
if (
!$expr instanceof Concat
|| !$expr->left instanceof Dir
|| !$expr->right instanceof String_
) {
return null;
}

$path = realpath($dir . $expr->right->value);
if ($path === false) {
return null;
}

return str_replace('\\', '/', $path);
}

/**
* Every function the bundled symfony polyfills declare, i.e. every function
* missing natively on some PHP version PHPStan runs on.
*
* @return array<string, true>
*/
private static function polyfilledFunctions(): array
{
$functions = [];
$finder = new Finder();
foreach ($finder->files()->name('bootstrap*.php')->depth(0)->in(__DIR__ . '/../../../vendor/symfony/polyfill-*') as $fileInfo) {
foreach ((new NodeFinder())->findInstanceOf(self::parse($fileInfo->getPathname()), Function_::class) as $function) {
$functions[$function->name->toLowerString()] = true;
}
}

self::assertArrayHasKey('str_contains', $functions);

return $functions;
}

/**
* @return Node\Stmt[]
*/
private static function parse(string $file): array
{
$code = file_get_contents($file);
self::assertNotFalse($code, sprintf('Could not read %s', $file));

$ast = (new ParserFactory())->createForNewestSupportedVersion()->parse($code);
self::assertNotNull($ast, sprintf('Could not parse %s', $file));

return $ast;
}

}
34 changes: 34 additions & 0 deletions tests/PHPStan/Build/SkipTestsWithRequiresPhpAttributeRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php declare(strict_types = 1);

namespace PHPStan\Build;

use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/**
* @extends RuleTestCase<SkipTestsWithRequiresPhpAttributeRule>
*/
class SkipTestsWithRequiresPhpAttributeRuleTest extends RuleTestCase
{

protected function getRule(): Rule
{
return new SkipTestsWithRequiresPhpAttributeRule();
}

public function testRule(): void
{
$this->analyse([__DIR__ . '/data/skip-tests-requires-php.php'], [
[
'Skip tests with #[RequiresPhp] attribute instead.',
13,
],
]);
}

public function testFix(): void
{
$this->fix(__DIR__ . '/data/skip-tests-requires-php.php', __DIR__ . '/data/skip-tests-requires-php.php.fixed');
}

}
Loading
Loading