Skip to content

Commit 50a1db5

Browse files
committed
Keep partial runtime diagnostics available
1 parent 1cd363d commit 50a1db5

10 files changed

Lines changed: 132 additions & 7 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- Keep diagnostics from healthy runtime metadata sections after a partial bridge failure
56
- Add GitLab Code Quality reports to the diagnostics checker
67
- Suppress code-qualified diagnostics with source comments
78
- Honor router request context parameters in route diagnostics

docs/features/headless-diagnostics.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,10 @@ Reports indicate whether each project used runtime or source-only analysis. If
101101
invalid application configuration prevents runtime analysis, the report
102102
identifies the configuration failure and remains incomplete. Other runtime
103103
failures also exit with status ``12`` instead of silently switching to
104-
source-only analysis.
104+
source-only analysis. If one runtime metadata section fails after other sections
105+
load, diagnostics backed by the healthy sections are still reported. Last
106+
successful metadata for the failed section remains active when available;
107+
otherwise diagnostics that need it are omitted.
105108

106109
Configuring the Check
107110
---------------------

docs/features/index.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,9 @@ enable it for a project whose code you would not run from the command line.
168168
Without runtime indexing, Symfony Language Tools continues to provide features
169169
from project files. Features that need the application's current routes,
170170
services or other runtime information may be incomplete. Diagnostics that need
171-
this information are omitted.
171+
this information are omitted. If one runtime metadata section fails while other
172+
sections load, features backed by the healthy sections remain available and the
173+
editor reports the partial failure.
172174

173175
Symfony Version Support
174176
-----------------------

src/Check/CheckProjectAnalyzer.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Symfony\Lsp\Feature\Configuration\ConfigurationValidationException;
77
use Symfony\Lsp\Index\ApplicationSourceScanner;
88
use Symfony\Lsp\Index\ProjectIndexStatusRegistry;
9+
use Symfony\Lsp\Runtime\PartialRuntimeMetadataException;
910
use Symfony\Lsp\Runtime\RuntimeConfiguration;
1011
use Symfony\Lsp\Runtime\RuntimeInitializerInterface;
1112

@@ -102,7 +103,7 @@ public function analyze(CheckPlan $plan, CheckRunCancellation $cancellation): Ch
102103
$status['runtime']['error'] ?? 'Runtime indexing did not complete.',
103104
);
104105
$complete[$root] = false;
105-
if ($runtimeError instanceof ConfigurationValidationException) {
106+
if ($runtimeError instanceof ConfigurationValidationException || $runtimeError instanceof PartialRuntimeMetadataException) {
106107
$diagnosable[$root] = true;
107108
}
108109

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
namespace Symfony\Lsp\Runtime;
4+
5+
final class PartialRuntimeMetadataException extends \RuntimeException
6+
{
7+
/** @param non-empty-list<string> $sections */
8+
public function __construct(public readonly array $sections)
9+
{
10+
parent::__construct('The project bridge could not load runtime metadata: '.implode(', ', $sections).'.');
11+
}
12+
}

src/Runtime/ProjectRuntimeInitializer.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,15 @@ public function initialize(Project $project, ?RuntimeRefreshPlan $plan = null, ?
107107
$loadedSections = array_values(array_intersect($sections, array_keys($snapshotSections)));
108108

109109
if (\is_array($errors) && [] !== $errors) {
110+
$validation = $snapshot['configurationValidation'] ?? null;
111+
$applicationBooted = \is_array($validation) && 'valid' === ($validation['status'] ?? null);
112+
if ([] !== $failedSections
113+
&& !isset($failedSections['runtime'])
114+
&& $applicationBooted
115+
&& [] !== $loadedSections
116+
) {
117+
throw new PartialRuntimeMetadataException(array_keys($failedSections));
118+
}
110119
$detail = [] === $failedSections ? '' : ': '.implode(', ', array_keys($failedSections));
111120

112121
throw new \RuntimeException('The project bridge could not load runtime metadata'.$detail.'.');

src/Runtime/ReportingRuntimeInitializer.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,15 @@ public function initialize(Project $project, ?RuntimeRefreshPlan $plan = null, ?
2929
$this->logger->error($error);
3030
$runtimeStatus = $this->statuses->status($project)['runtime'];
3131
$stale = 'stale' === $runtimeStatus['state'];
32-
if ($error instanceof UnsupportedSymfonyVersionException) {
32+
if ($error instanceof PartialRuntimeMetadataException) {
33+
$message = \sprintf(
34+
'Symfony Language Tools could not load %d runtime metadata section%s for "%s": %s. Other runtime-backed features remain active.',
35+
\count($error->sections),
36+
1 === \count($error->sections) ? '' : 's',
37+
$project->rootPath,
38+
implode(', ', $error->sections),
39+
);
40+
} elseif ($error instanceof UnsupportedSymfonyVersionException) {
3341
$message = \sprintf(
3442
$stale
3543
? 'The project "%s" uses Symfony %s, which is not supported by Symfony Language Tools. The last valid runtime metadata remains active.'

tests/Check/CheckExecutableTest.php

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Symfony\Component\Filesystem\Filesystem;
99
use Symfony\Component\Filesystem\Path;
1010
use Symfony\Lsp\Check\CheckCommand;
11+
use Symfony\Lsp\Runtime\PartialRuntimeMetadataException;
1112
use Symfony\Lsp\Runtime\UnsupportedSymfonyVersionException;
1213
use Symfony\Lsp\Server\ServerVersion;
1314

@@ -25,7 +26,7 @@
2526
* @phpstan-type SarifReport array{version: string, runs: list<SarifRun>}
2627
* @phpstan-type CheckReport array{
2728
* complete: bool,
28-
* projects: list<array{environment: string, analysis: array{mode: string, reason: string|null}}>,
29+
* projects: list<array{environment: string, analysis: array{mode: string, reason: string|null}, runtime: array{state: string}, complete: bool}>,
2930
* diagnostics: list<array{code: string, path: string}>,
3031
* summary: array{blocking: int, stale: int},
3132
* errors: list<array{category: string, message: string, cause?: array{class: string, message: string}}>
@@ -311,6 +312,65 @@ public static function getPrettyVersion(string $package): ?string { return '5.4.
311312
self::assertSame(UnsupportedSymfonyVersionException::class, $report['errors'][0]['cause']['class'] ?? null);
312313
}
313314

315+
public function testKeepsDiagnosticsBackedByHealthyRuntimeSections(): void
316+
{
317+
if ('Windows' === \PHP_OS_FAMILY) {
318+
self::markTestSkipped('The source executable integration requires Unix executable scripts.');
319+
}
320+
321+
mkdir($this->directory.'/src');
322+
file_put_contents($this->directory.'/src/ArticleController.php', <<<'PHP'
323+
<?php
324+
namespace App\Controller;
325+
326+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
327+
328+
final class ArticleController extends AbstractController
329+
{
330+
public function show(): void
331+
{
332+
$this->generateUrl('article_show');
333+
}
334+
}
335+
PHP);
336+
$snapshot = [
337+
'schemaVersion' => 1,
338+
'project' => ['environment' => 'dev'],
339+
'configurationValidation' => ['status' => 'valid'],
340+
'sections' => [
341+
'routes' => [
342+
'complete' => true,
343+
'items' => [[
344+
'name' => 'article_show',
345+
'path' => '/article/{id}',
346+
]],
347+
],
348+
],
349+
'errors' => [[
350+
'section' => 'twig',
351+
'message' => 'CANARY_RUNTIME_SECTION_ERROR',
352+
]],
353+
];
354+
$symfonyCli = $this->directory.'/partial-runtime';
355+
file_put_contents($symfonyCli, "#!/usr/bin/env php\n<?php\nfwrite(STDOUT, json_encode(".var_export($snapshot, true).", JSON_THROW_ON_ERROR).\"\\n\");\n");
356+
chmod($symfonyCli, 0700);
357+
358+
$result = $this->execute(
359+
['check', '--format=json', '--workspace='.$this->directory, 'src/ArticleController.php'],
360+
['SYMFONY_LSP_SYMFONY_CLI' => $symfonyCli],
361+
);
362+
$report = $this->decodeReport($result['stdout']);
363+
364+
self::assertSame(CheckCommand::EXIT_OPERATIONAL, $result['exitCode'], $result['stderr']);
365+
self::assertFalse($report['complete']);
366+
self::assertFalse($report['projects'][0]['complete']);
367+
self::assertSame('failed', $report['projects'][0]['runtime']['state']);
368+
self::assertSame('route.missing_parameters', $report['diagnostics'][0]['code']);
369+
self::assertSame('The project bridge could not load runtime metadata: twig.', $report['errors'][0]['message']);
370+
self::assertSame(PartialRuntimeMetadataException::class, $report['errors'][0]['cause']['class'] ?? null);
371+
self::assertSame(1, $report['summary']['blocking']);
372+
}
373+
314374
public function testDoesNotReportACleanResultWhenRuntimeIndexingFails(): void
315375
{
316376
$result = $this->execute(['check', '--format=json', '--workspace='.$this->directory]);

tests/Runtime/ProjectRuntimeInitializerTest.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use Symfony\Lsp\Project\ProjectRegistry;
2222
use Symfony\Lsp\Runtime\BridgeExecutionException;
2323
use Symfony\Lsp\Runtime\BridgeInstaller;
24+
use Symfony\Lsp\Runtime\PartialRuntimeMetadataException;
2425
use Symfony\Lsp\Runtime\ProcessResult;
2526
use Symfony\Lsp\Runtime\ProcessRunnerInterface;
2627
use Symfony\Lsp\Runtime\RuntimeConfiguration;
@@ -328,6 +329,8 @@ public function testLoadsAvailableSectionsBeforeReportingSectionErrors(): void
328329
$initializer = (new ProjectRuntimeInitializerFixtureBuilder($source))->build(
329330
new CapturingProcessRunner(new ProcessResult(0, json_encode([
330331
'schemaVersion' => 1,
332+
'project' => ['environment' => 'dev'],
333+
'configurationValidation' => ['status' => 'valid'],
331334
'errors' => [['section' => 'routes', 'message' => 'CANARY_RUNTIME_SECTION_ERROR']],
332335
'sections' => [
333336
'routes' => ['complete' => true, 'items' => [['name' => 'replacement', 'path' => '/replacement']]],
@@ -348,7 +351,8 @@ public function testLoadsAvailableSectionsBeforeReportingSectionErrors(): void
348351
try {
349352
$initializer->initialize($project);
350353
self::fail('The section error was not reported.');
351-
} catch (\RuntimeException $error) {
354+
} catch (PartialRuntimeMetadataException $error) {
355+
self::assertSame(['routes'], $error->sections);
352356
self::assertSame('The project bridge could not load runtime metadata: routes.', $error->getMessage());
353357
}
354358
self::assertSame('app.mailer', $serviceIndexes->forProject($project)->get('app.mailer')?->id);
@@ -382,6 +386,8 @@ public function testRestoresOnlyFailedSectionsAfterAPartialBridgeError(): void
382386
$initializer = (new ProjectRuntimeInitializerFixtureBuilder($source, $bridgeInstaller))->build(
383387
new CapturingProcessRunner(new ProcessResult(0, json_encode([
384388
'schemaVersion' => 1,
389+
'project' => ['environment' => 'dev'],
390+
'configurationValidation' => ['status' => 'valid'],
385391
'errors' => [['section' => 'container', 'message' => 'CANARY_RUNTIME_SECTION_ERROR']],
386392
'sections' => [
387393
'routes' => ['complete' => true, 'items' => [['name' => 'new_route', 'path' => '/new']]],
@@ -405,7 +411,8 @@ public function testRestoresOnlyFailedSectionsAfterAPartialBridgeError(): void
405411
try {
406412
$initializer->initialize($project);
407413
self::fail('The section error was not reported.');
408-
} catch (\RuntimeException $error) {
414+
} catch (PartialRuntimeMetadataException $error) {
415+
self::assertSame(['container'], $error->sections);
409416
self::assertSame('The project bridge could not load runtime metadata: container.', $error->getMessage());
410417
}
411418

tests/Runtime/ReportingRuntimeInitializerTest.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Symfony\Lsp\Client\ClientInterface;
99
use Symfony\Lsp\Index\ProjectIndexStatusRegistry;
1010
use Symfony\Lsp\Project\Project;
11+
use Symfony\Lsp\Runtime\PartialRuntimeMetadataException;
1112
use Symfony\Lsp\Runtime\ReportingRuntimeInitializer;
1213
use Symfony\Lsp\Runtime\RuntimeInitializerInterface;
1314
use Symfony\Lsp\Runtime\RuntimeRefreshPlan;
@@ -37,6 +38,27 @@ public function testReportsRefreshFailuresWithoutDiscardingTheServerSession(): v
3738
]], $client->notifications);
3839
}
3940

41+
public function testReportsPartialRuntimeMetadataWithoutHidingAvailableFeatures(): void
42+
{
43+
$client = new ReportingClient();
44+
$statuses = new ProjectIndexStatusRegistry();
45+
$project = new Project('/workspace', 'file:///workspace', '^8.0');
46+
$statuses->runtimeFailed($project);
47+
$initializer = new ReportingRuntimeInitializer(
48+
$this->failingInitializer(new PartialRuntimeMetadataException(['twig'])),
49+
$client,
50+
$statuses,
51+
new ServerLogger(null, new SensitiveDataRedactor()),
52+
);
53+
54+
$initializer->initialize($project);
55+
56+
self::assertSame(
57+
'Symfony Language Tools could not load 1 runtime metadata section for "/workspace": twig. Other runtime-backed features remain active.',
58+
$client->notifications[0]['params']['message'],
59+
);
60+
}
61+
4062
public function testReportsInitialFailureAsStaticOnly(): void
4163
{
4264
$client = new ReportingClient();

0 commit comments

Comments
 (0)