Phase 1 sub-commit 7: CI quality job
Some checks failed
CI / Quality (push) Has been cancelled

PHPStan (level 6 + symfony extension) and PHP CS Fixer (Symfony +
PHP83Migration ruleset) configs at framework/php/. composer.json
exposes phpstan / cs:check / cs:fix / phpunit / quality scripts.
PHPStan-clean across the bundle; cs:check is happy after auto-fix
applied @Symfony idioms (yoda, leading-backslash JSON_*, blank-line
before return). Test mocks consolidated into a HubSpy helper to keep
PHPStan happy about by-ref captures.

Skeleton's Makefile target `quality` chains `composer quality` (in
framework/php/) with cmake's all_qmllint target. Local run is green —
11 tests / 32 assertions, no PHPStan errors, cs-fixer clean, qmllint
emits advisory warnings only.

Layout fix in skeleton's Main.qml: status-dot Rectangles inside
RowLayout now use Layout.preferredWidth/Height instead of width/height
to satisfy Quick.layout-positioning checks.

.gitea/workflows/ci.yml replaces the placeholder with a real `quality`
job: setup-php, composer install (cached), the four PHP checks, Qt 6
via install-qt-action (cached), QML module build, qmllint via the
all_qmllint CMake target. Workflow exists from this commit onward
even if a runner isn't provisioned yet.

bridge:doctor lost the Publisher dependency since it was only used as
a "service is wired" marker — the command being injectable already
proves that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 02:15:06 +02:00
parent d671b26cac
commit 7323b9affe
14 changed files with 198 additions and 108 deletions

View File

@@ -10,11 +10,60 @@ jobs:
quality: quality:
name: Quality name: Quality
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Quality (placeholder) - name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: curl, json, mbstring
tools: composer:v2
coverage: none
- name: Cache Composer
uses: actions/cache@v4
with:
path: ~/.cache/composer
key: composer-${{ hashFiles('framework/php/composer.json') }}
- name: Install bundle dependencies
working-directory: framework/php
run: composer install --no-interaction --prefer-dist
- name: PHPStan
working-directory: framework/php
run: composer phpstan
- name: php-cs-fixer (check)
working-directory: framework/php
run: composer cs:check
- name: PHPUnit
working-directory: framework/php
run: composer phpunit
- name: Setup Qt 6
uses: jurplel/install-qt-action@v4
with:
version: '6.5.*'
modules: 'qtquickcontrols2'
cache: true
- name: Cache CMake build
uses: actions/cache@v4
with:
path: framework/skeleton/build
key: cmake-${{ runner.os }}-${{ hashFiles('framework/qml/**', 'framework/skeleton/qml/**') }}
- name: Build QML module + skeleton
working-directory: framework/skeleton
run: | run: |
echo "quality job — populated in Phase 1 sub-commit 7" cmake -S qml -B build/qml
echo "will run: PHPStan, php-cs-fixer (check), PHPUnit, qmllint" cmake --build build/qml --parallel
- name: qmllint
working-directory: framework/skeleton
run: cmake --build build/qml --target all_qmllint

View File

@@ -0,0 +1,17 @@
<?php
$finder = (new PhpCsFixer\Finder())
->in([__DIR__ . '/src', __DIR__ . '/tests']);
return (new PhpCsFixer\Config())
->setRiskyAllowed(true)
->setRules([
'@Symfony' => true,
'@Symfony:risky' => true,
'@PHP83Migration' => true,
'declare_strict_types' => true,
'native_function_invocation' => false,
'phpdoc_align' => false,
'binary_operator_spaces' => ['default' => 'align_single_space_minimal'],
])
->setFinder($finder);

View File

@@ -31,5 +31,16 @@
"PhpQml\\Bridge\\Tests\\": "tests/" "PhpQml\\Bridge\\Tests\\": "tests/"
} }
}, },
"scripts": {
"phpstan": "phpstan analyse --memory-limit=512M",
"cs:check": "php-cs-fixer check --diff --allow-unsupported-php-version=yes",
"cs:fix": "php-cs-fixer fix --allow-unsupported-php-version=yes",
"phpunit": "phpunit",
"quality": [
"@phpstan",
"@cs:check",
"@phpunit"
]
},
"minimum-stability": "stable" "minimum-stability": "stable"
} }

View File

@@ -0,0 +1,10 @@
includes:
- vendor/phpstan/phpstan-symfony/extension.neon
parameters:
level: 6
paths:
- src
- tests
excludePaths:
- vendor/*

View File

@@ -11,6 +11,9 @@ use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
final class BridgeBundle extends AbstractBundle final class BridgeBundle extends AbstractBundle
{ {
/**
* @param array<string, mixed> $config
*/
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
{ {
$container->import(__DIR__.'/../config/services.yaml'); $container->import(__DIR__.'/../config/services.yaml');

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace PhpQml\Bridge\Command; namespace PhpQml\Bridge\Command;
use PhpQml\Bridge\Publisher;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@@ -23,7 +22,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class BridgeDoctorCommand extends Command final class BridgeDoctorCommand extends Command
{ {
public function __construct( public function __construct(
private readonly Publisher $publisher,
#[Autowire('%env(default::BRIDGE_TOKEN)%')] #[Autowire('%env(default::BRIDGE_TOKEN)%')]
private readonly string $bridgeToken, private readonly string $bridgeToken,
#[Autowire('%env(default::MERCURE_URL)%')] #[Autowire('%env(default::MERCURE_URL)%')]
@@ -47,30 +45,26 @@ final class BridgeDoctorCommand extends Command
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$io->title('php-qml bridge — readiness checks'); $io->title('php-qml bridge — readiness checks');
// PHP version + extensions are enforced by composer, so we don't
// re-check them runtime — would always be true on a working install.
$checks = [ $checks = [
['PHP version >= 8.3',
PHP_VERSION_ID >= 80300,
'Upgrade PHP to 8.3 or newer; the bundle requires it.'],
['ext-curl available', ['ext-curl available',
extension_loaded('curl'), extension_loaded('curl'),
'Install the PHP curl extension.'], 'Install the PHP curl extension.'],
['ext-json available', ['ext-json available',
extension_loaded('json'), extension_loaded('json'),
'Install the PHP json extension.'], 'Install the PHP json extension.'],
['Publisher service wired',
$this->publisher instanceof Publisher,
'Bundle services failed to load — register PhpQml\\Bridge\\BridgeBundle in config/bundles.php.'],
['BRIDGE_TOKEN env set', ['BRIDGE_TOKEN env set',
$this->bridgeToken !== '', '' !== $this->bridgeToken,
'Set BRIDGE_TOKEN in .env.local; the Qt host expects this as the bearer token.'], 'Set BRIDGE_TOKEN in .env.local; the Qt host expects this as the bearer token.'],
['MERCURE_URL env set', ['MERCURE_URL env set',
$this->mercureUrl !== '', '' !== $this->mercureUrl,
'Set MERCURE_URL in .env, e.g. http://127.0.0.1:8765/.well-known/mercure.'], 'Set MERCURE_URL in .env, e.g. http://127.0.0.1:8765/.well-known/mercure.'],
['MERCURE_PUBLISHER_JWT_KEY env set', ['MERCURE_PUBLISHER_JWT_KEY env set',
$this->mercurePublisherKey !== '', '' !== $this->mercurePublisherKey,
'Set MERCURE_PUBLISHER_JWT_KEY in .env.local; mercure-bundle uses it to sign publish requests.'], 'Set MERCURE_PUBLISHER_JWT_KEY in .env.local; mercure-bundle uses it to sign publish requests.'],
['MERCURE_SUBSCRIBER_JWT_KEY env set', ['MERCURE_SUBSCRIBER_JWT_KEY env set',
$this->mercureSubscriberKey !== '', '' !== $this->mercureSubscriberKey,
'Set MERCURE_SUBSCRIBER_JWT_KEY in .env.local; or rely on the Caddy `anonymous` directive in dev mode.'], 'Set MERCURE_SUBSCRIBER_JWT_KEY in .env.local; or rely on the Caddy `anonymous` directive in dev mode.'],
]; ];
@@ -100,10 +94,12 @@ final class BridgeDoctorCommand extends Command
if ($allPass) { if ($allPass) {
$io->success('All checks passed. Bridge is ready.'); $io->success('All checks passed. Bridge is ready.');
return Command::SUCCESS; return Command::SUCCESS;
} }
$io->warning('Some checks failed — fix the items above before running the bridge.'); $io->warning('Some checks failed — fix the items above before running the bridge.');
return Command::FAILURE; return Command::FAILURE;
} }
@@ -120,17 +116,19 @@ final class BridgeDoctorCommand extends Command
], ],
]); ]);
$body = @file_get_contents($url, false, $ctx); $body = @file_get_contents($url, false, $ctx);
if ($body === false) { if (false === $body) {
$err = error_get_last()['message'] ?? 'unknown error'; $err = error_get_last()['message'] ?? 'unknown error';
return ['ok' => false, 'detail' => $err]; return ['ok' => false, 'detail' => $err];
} }
$statusLine = $http_response_header[0] ?? ''; $statusLine = $http_response_header[0] ?? '';
preg_match('#HTTP/\S+\s+(\d+)#', $statusLine, $m); preg_match('#HTTP/\S+\s+(\d+)#', $statusLine, $m);
$status = (int) ($m[1] ?? 0); $status = (int) ($m[1] ?? 0);
return [ return [
'ok' => $status === 200, 'ok' => 200 === $status,
'status' => $status, 'status' => $status,
'detail' => $status === 200 ? '' : "expected 200, got {$status}", 'detail' => 200 === $status ? '' : "expected 200, got {$status}",
]; ];
} }
} }

View File

@@ -28,7 +28,7 @@ final readonly class Publisher
{ {
return $this->hub->publish(new Update( return $this->hub->publish(new Update(
$topic, $topic,
json_encode($envelope, JSON_THROW_ON_ERROR), json_encode($envelope, \JSON_THROW_ON_ERROR),
$private, $private,
)); ));
} }

View File

@@ -30,7 +30,7 @@ final class SessionAuthenticator extends AbstractAuthenticator
) { ) {
} }
public function supports(Request $request): ?bool public function supports(Request $request): bool
{ {
return $request->headers->has('Authorization'); return $request->headers->has('Authorization');
} }
@@ -43,7 +43,7 @@ final class SessionAuthenticator extends AbstractAuthenticator
} }
$token = substr($header, 7); $token = substr($header, 7);
if ($this->expectedToken === '' || !hash_equals($this->expectedToken, $token)) { if ('' === $this->expectedToken || !hash_equals($this->expectedToken, $token)) {
throw new AuthenticationException('Bearer token invalid.'); throw new AuthenticationException('Bearer token invalid.');
} }
@@ -56,7 +56,7 @@ final class SessionAuthenticator extends AbstractAuthenticator
return null; return null;
} }
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{ {
return new JsonResponse( return new JsonResponse(
[ [

View File

@@ -5,12 +5,9 @@ declare(strict_types=1);
namespace PhpQml\Bridge\Tests\Command; namespace PhpQml\Bridge\Tests\Command;
use PhpQml\Bridge\Command\BridgeDoctorCommand; use PhpQml\Bridge\Command\BridgeDoctorCommand;
use PhpQml\Bridge\Publisher;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
#[CoversClass(BridgeDoctorCommand::class)] #[CoversClass(BridgeDoctorCommand::class)]
final class BridgeDoctorCommandTest extends TestCase final class BridgeDoctorCommandTest extends TestCase
@@ -18,7 +15,6 @@ final class BridgeDoctorCommandTest extends TestCase
public function testAllPassesWhenEnvIsFullyConfigured(): void public function testAllPassesWhenEnvIsFullyConfigured(): void
{ {
$command = new BridgeDoctorCommand( $command = new BridgeDoctorCommand(
publisher: $this->makePublisher(),
bridgeToken: 'devtoken', bridgeToken: 'devtoken',
mercureUrl: 'http://127.0.0.1:8765/.well-known/mercure', mercureUrl: 'http://127.0.0.1:8765/.well-known/mercure',
mercurePublisherKey: 'devkey', mercurePublisherKey: 'devkey',
@@ -37,7 +33,6 @@ final class BridgeDoctorCommandTest extends TestCase
public function testFailsAndShowsHintsWhenEnvIsMissing(): void public function testFailsAndShowsHintsWhenEnvIsMissing(): void
{ {
$command = new BridgeDoctorCommand( $command = new BridgeDoctorCommand(
publisher: $this->makePublisher(),
bridgeToken: '', bridgeToken: '',
mercureUrl: '', mercureUrl: '',
mercurePublisherKey: '', mercurePublisherKey: '',
@@ -58,7 +53,6 @@ final class BridgeDoctorCommandTest extends TestCase
public function testConnectOptionFailsClosedAgainstAnUnreachableUrl(): void public function testConnectOptionFailsClosedAgainstAnUnreachableUrl(): void
{ {
$command = new BridgeDoctorCommand( $command = new BridgeDoctorCommand(
publisher: $this->makePublisher(),
bridgeToken: 'devtoken', bridgeToken: 'devtoken',
mercureUrl: 'http://127.0.0.1:8765/.well-known/mercure', mercureUrl: 'http://127.0.0.1:8765/.well-known/mercure',
mercurePublisherKey: 'devkey', mercurePublisherKey: 'devkey',
@@ -72,17 +66,4 @@ final class BridgeDoctorCommandTest extends TestCase
self::assertSame(1, $exit); self::assertSame(1, $exit);
self::assertStringContainsString('Backend probe failed', $tester->getDisplay()); self::assertStringContainsString('Backend probe failed', $tester->getDisplay());
} }
private function makePublisher(): Publisher
{
return new Publisher(new class implements HubInterface {
public function getUrl(): string { return ''; }
public function getPublicUrl(): string { return ''; }
public function getProvider(): \Symfony\Component\Mercure\Jwt\TokenProviderInterface
{ throw new \LogicException(); }
public function getFactory(): ?\Symfony\Component\Mercure\Jwt\TokenFactoryInterface
{ return null; }
public function publish(Update $update): string { return ''; }
});
}
} }

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge\Tests\Helper;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Jwt\TokenFactoryInterface;
use Symfony\Component\Mercure\Jwt\TokenProviderInterface;
use Symfony\Component\Mercure\Update;
/**
* Minimal HubInterface fake that records the last published Update.
* Used by Publisher / BridgeDoctorCommand tests.
*/
final class HubSpy implements HubInterface
{
public ?Update $captured = null;
public function __construct(private readonly string $stubReturnId = '')
{
}
public function getUrl(): string
{
return 'http://localhost/.well-known/mercure';
}
public function getPublicUrl(): string
{
return $this->getUrl();
}
public function getProvider(): TokenProviderInterface
{
throw new \LogicException('HubSpy::getProvider not used in tests.');
}
public function getFactory(): ?TokenFactoryInterface
{
return null;
}
public function publish(Update $update): string
{
$this->captured = $update;
return $this->stubReturnId;
}
}

View File

@@ -5,9 +5,9 @@ declare(strict_types=1);
namespace PhpQml\Bridge\Tests; namespace PhpQml\Bridge\Tests;
use PhpQml\Bridge\Publisher; use PhpQml\Bridge\Publisher;
use PhpQml\Bridge\Tests\Helper\HubSpy;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update; use Symfony\Component\Mercure\Update;
#[CoversClass(Publisher::class)] #[CoversClass(Publisher::class)]
@@ -15,58 +15,30 @@ final class PublisherTest extends TestCase
{ {
public function testPublishWritesEnvelopeAsJsonOnTheGivenTopic(): void public function testPublishWritesEnvelopeAsJsonOnTheGivenTopic(): void
{ {
$captured = null; $hub = new HubSpy('urn:uuid:test');
$hub = new class($captured) implements HubInterface {
public function __construct(private mixed &$captured) {}
public function getUrl(): string { return 'http://localhost/.well-known/mercure'; }
public function getPublicUrl(): string { return $this->getUrl(); }
public function getProvider(): \Symfony\Component\Mercure\Jwt\TokenProviderInterface
{
throw new \LogicException('not used in test');
}
public function getFactory(): ?\Symfony\Component\Mercure\Jwt\TokenFactoryInterface
{
return null;
}
public function publish(Update $update): string
{
$this->captured = $update;
return 'urn:uuid:test';
}
};
$publisher = new Publisher($hub); $publisher = new Publisher($hub);
$id = $publisher->publish('app://model/todo', ['op' => 'upsert', 'id' => '1', 'data' => ['done' => true], 'version' => 7]);
$id = $publisher->publish(
'app://model/todo',
['op' => 'upsert', 'id' => '1', 'data' => ['done' => true], 'version' => 7],
);
self::assertSame('urn:uuid:test', $id); self::assertSame('urn:uuid:test', $id);
self::assertInstanceOf(Update::class, $captured); self::assertInstanceOf(Update::class, $hub->captured);
self::assertSame(['app://model/todo'], $captured->getTopics()); self::assertSame(['app://model/todo'], $hub->captured->getTopics());
self::assertJsonStringEqualsJsonString( self::assertJsonStringEqualsJsonString(
'{"op":"upsert","id":"1","data":{"done":true},"version":7}', '{"op":"upsert","id":"1","data":{"done":true},"version":7}',
$captured->getData(), $hub->captured->getData(),
); );
self::assertFalse($captured->isPrivate()); self::assertFalse($hub->captured->isPrivate());
} }
public function testPrivateFlagIsForwarded(): void public function testPrivateFlagIsForwarded(): void
{ {
$captured = null; $hub = new HubSpy();
$hub = new class($captured) implements HubInterface {
public function __construct(private mixed &$captured) {}
public function getUrl(): string { return ''; }
public function getPublicUrl(): string { return ''; }
public function getProvider(): \Symfony\Component\Mercure\Jwt\TokenProviderInterface { throw new \LogicException(); }
public function getFactory(): ?\Symfony\Component\Mercure\Jwt\TokenFactoryInterface { return null; }
public function publish(Update $update): string { $this->captured = $update; return ''; }
};
(new Publisher($hub))->publish('app://event/internal', ['data' => 'x'], private: true); (new Publisher($hub))->publish('app://event/internal', ['data' => 'x'], private: true);
self::assertTrue($captured->isPrivate()); self::assertNotNull($hub->captured);
self::assertTrue($hub->captured->isPrivate());
} }
} }

View File

@@ -76,7 +76,6 @@ final class SessionAuthenticatorTest extends TestCase
$auth = new SessionAuthenticator('s3cret'); $auth = new SessionAuthenticator('s3cret');
$response = $auth->onAuthenticationFailure(new Request(), new AuthenticationException('Bearer token invalid.')); $response = $auth->onAuthenticationFailure(new Request(), new AuthenticationException('Bearer token invalid.'));
self::assertNotNull($response);
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
self::assertSame('application/problem+json', $response->headers->get('Content-Type')); self::assertSame('application/problem+json', $response->headers->get('Content-Type'));
$body = json_decode((string) $response->getContent(), true); $body = json_decode((string) $response->getContent(), true);

View File

@@ -37,6 +37,6 @@ clean: ## Remove build artefacts
rm -rf $(BUILD_DIR) rm -rf $(BUILD_DIR)
.PHONY: quality .PHONY: quality
quality: ## (TBD) Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint
@echo "quality: not yet implemented (Phase 1 sub-commit 7)" cd ../php && composer quality
@exit 1 cmake --build $(BUILD_DIR) --target all_qmllint

View File

@@ -62,7 +62,7 @@ ApplicationWindow {
Layout.fillWidth: true Layout.fillWidth: true
Rectangle { Rectangle {
width: 12; height: 12; radius: 6 Layout.preferredWidth: 12; Layout.preferredHeight: 12; radius: 6
color: BackendConnection.connectionState === BackendConnection.Online color: BackendConnection.connectionState === BackendConnection.Online
? "#3ab36c" ? "#3ab36c"
: (BackendConnection.connectionState === BackendConnection.Error ? "#d8503c" : "#d89614") : (BackendConnection.connectionState === BackendConnection.Error ? "#d8503c" : "#d89614")
@@ -72,7 +72,7 @@ ApplicationWindow {
Item { width: 12 } Item { width: 12 }
Rectangle { Rectangle {
width: 12; height: 12; radius: 6 Layout.preferredWidth: 12; Layout.preferredHeight: 12; radius: 6
color: mercure.active ? "#3ab36c" : "#666" color: mercure.active ? "#3ab36c" : "#666"
} }
Label { text: "Mercure: " + (mercure.active ? "subscribed" : "off") } Label { text: "Mercure: " + (mercure.active ? "subscribed" : "off") }