packages/sql-catalog/tests/Unit/Reporter/Html/Page/OverviewPageTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Reporter\Html\Page;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use SqlCatalog\Core\Catalog\AnalysisProblem;
11use SqlCatalog\Core\Catalog\CallSite;
12use SqlCatalog\Core\Catalog\Catalog;
13use SqlCatalog\Core\Catalog\CatalogEntry;
14use SqlCatalog\Core\Catalog\Finding;
15use SqlCatalog\Core\Catalog\FindingRule;
16use SqlCatalog\Core\Catalog\Placeholder;
17use SqlCatalog\Core\Catalog\Resolution;
18use SqlCatalog\Core\Catalog\Severity;
19use SqlCatalog\Core\Catalog\StatementPart;
20use SqlCatalog\Core\Catalog\ValueDomain;
21use SqlCatalog\Core\Sql\StatementKind;
22use SqlCatalog\Core\Text\LiteralText;
23use SqlCatalog\Core\Text\Origin;
24use SqlCatalog\Core\Text\TextHole;
25use SqlCatalog\Core\Text\TextPattern;
26use SqlCatalog\Core\Type\TypeShape;
27use SqlCatalog\Reporter\Html\CatalogIndex;
28use SqlCatalog\Reporter\Html\CatalogStatistics;
29use SqlCatalog\Reporter\Html\HtmlText;
30use SqlCatalog\Reporter\Html\Page\OverviewPage;
31use SqlCatalog\Reporter\Html\Palette;
32use SqlCatalog\Reporter\Html\ReportSite;
33use SqlCatalog\Reporter\Html\Scope;
34use SqlCatalog\Reporter\Html\SqlFormatter;
35use SqlCatalog\Reporter\Html\SqlHighlighter;
36use SqlCatalog\Reporter\Html\StatementList;
37use SqlCatalog\Reporter\Html\StatementRow;
38use SqlCatalog\Reporter\Html\TableName;
39
40#[CoversClass(OverviewPage::class)]
41#[UsesClass(AnalysisProblem::class)]
42#[UsesClass(CallSite::class)]
43#[UsesClass(Catalog::class)]
44#[UsesClass(CatalogEntry::class)]
45#[UsesClass(CatalogIndex::class)]
46#[UsesClass(CatalogStatistics::class)]
47#[UsesClass(Finding::class)]
48#[UsesClass(FindingRule::class)]
49#[UsesClass(HtmlText::class)]
50#[UsesClass(Palette::class)]
51#[UsesClass(ReportSite::class)]
52#[UsesClass(Resolution::class)]
53#[UsesClass(Scope::class)]
54#[UsesClass(Severity::class)]
55#[UsesClass(TableName::class)]
56#[UsesClass(StatementKind::class)]
57#[UsesClass(LiteralText::class)]
58#[UsesClass(Origin::class)]
59#[UsesClass(TextHole::class)]
60#[UsesClass(TextPattern::class)]
61#[UsesClass(TypeShape::class)]
62#[UsesClass(Placeholder::class)]
63#[UsesClass(ValueDomain::class)]
64#[UsesClass(StatementPart::class)]
65#[UsesClass(SqlFormatter::class)]
66#[UsesClass(SqlHighlighter::class)]
67#[UsesClass(StatementList::class)]
68#[UsesClass(StatementRow::class)]
69final class OverviewPageTest extends TestCase
70{
71    public function testRenderLaysOutEveryRouteToAStatement(): void
72    {
73        $catalog = new Catalog([
74            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), ['users'], [], new CallSite('src/a.php', 1, 'App\\R::find', 'pdo.query'), [
75                Finding::of(FindingRule::DynamicSql, 'spliced'),
76            ]),
77        ], [new AnalysisProblem('b.php', 'broken')]);
78        $page = (new OverviewPage())->render(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()));
79
80        self::assertStringContainsString('<h1>Overview</h1>', $page);
81        self::assertStringContainsString('<div class="cards">', $page);
82        self::assertStringContainsString('href="tables/users.html"', $page);
83        self::assertStringContainsString('href="classes/app-r.html"', $page);
84        self::assertStringContainsString('href="files/src-a-php.html"', $page);
85        self::assertStringContainsString('href="statements.html?kind=select"', $page);
86        self::assertStringContainsString('Needs attention', $page);
87        self::assertStringContainsString('How far the analysis got', $page);
88        self::assertStringContainsString('broken', $page);
89    }
90
91    public function testFactsLinkEachCountToItsRoute(): void
92    {
93        $catalog = new Catalog([
94            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), ['users'], [], new CallSite('a.php', 1, 'f', 'pdo.query'), []),
95        ]);
96
97        self::assertSame(
98            '<div class="stats"><a class="stat" href="statements.html"><b class="stat-fig">1</b><span class="stat-label">statement</span></a>'
99            . '<a class="stat" href="tables.html"><b class="stat-fig">1</b><span class="stat-label">table</span></a>'
100            . '<a class="stat" href="namespaces.html"><b class="stat-fig">1</b><span class="stat-label">function</span></a>'
101            . '<a class="stat" href="files.html"><b class="stat-fig">1</b><span class="stat-label">file</span></a></div>',
102            (new OverviewPage())->facts(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter())),
103        );
104    }
105
106    public function testTableRouteListsTheMostNamedTablesWithHowTheyAreUsed(): void
107    {
108        $catalog = new Catalog([
109            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), ['users'], [], new CallSite('a.php', 1, 'f', 'pdo.query'), []),
110            new CatalogEntry('a2', StatementKind::Insert, TextPattern::fromText('INSERT'), ['users'], [], new CallSite('a.php', 2, 'f', 'pdo.query'), []),
111        ]);
112        $route = (new OverviewPage())->tableRoute(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()));
113
114        self::assertStringContainsString('<li><a class="mono" href="tables/users.html">users</a><span class="usage-kind">2 · 1 read · 1 write</span></li>', $route);
115        self::assertStringContainsString('<a href="tables.html">All 1 table</a>', $route);
116    }
117
118    public function testNamespaceRouteListsClassesOrFallsBackToFunctions(): void
119    {
120        $withClass = new Catalog([
121            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'App\\R::find', 'pdo.query'), []),
122        ]);
123        $withoutClass = new Catalog([
124            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'helper', 'pdo.query'), []),
125        ]);
126        $page = new OverviewPage();
127
128        self::assertStringContainsString('<a class="mono" href="classes/app-r.html">App\\R</a>', $page->namespaceRoute(new ReportSite($withClass, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter())));
129        self::assertStringContainsString('<a class="mono" href="statements.html?function=helper">helper</a>', $page->namespaceRoute(new ReportSite($withoutClass, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter())));
130    }
131
132    public function testFileRouteListsTheFilesWithTheMostStatements(): void
133    {
134        $catalog = new Catalog([
135            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'f', 'pdo.query'), []),
136            new CatalogEntry('b1', StatementKind::Select, TextPattern::fromText('SELECT 2'), [], [], new CallSite('b.php', 1, 'f', 'pdo.query'), []),
137            new CatalogEntry('b2', StatementKind::Select, TextPattern::fromText('SELECT 3'), [], [], new CallSite('b.php', 2, 'f', 'pdo.query'), []),
138        ]);
139
140        self::assertStringContainsString(
141            '<li><a class="mono" href="files/b-php.html">b.php</a><span class="usage-kind">2 statements</span></li><li><a class="mono" href="files/a-php.html">a.php</a>',
142            (new OverviewPage())->fileRoute(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter())),
143        );
144    }
145
146    public function testKindRouteLeadsToTheListingNarrowedByKind(): void
147    {
148        $catalog = new Catalog([
149            new CatalogEntry('a1', StatementKind::Delete, TextPattern::fromText('DELETE FROM t'), [], [], new CallSite('a.php', 1, 'f', 'pdo.query'), []),
150        ]);
151
152        self::assertStringContainsString(
153            '<li><a class="chip tone-pink" href="statements.html?kind=delete">DELETE</a><span class="usage-kind">1 statement</span></li>',
154            (new OverviewPage())->kindRoute(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter())),
155        );
156    }
157
158    public function testRouteSaysSoWhenThereIsNothingOnIt(): void
159    {
160        self::assertSame(
161            '<section class="card"><h2><a href="tables.html">Tables</a><span class="count">0</span></h2><p class="card-description">hint</p>'
162            . '<p class="empty-inline">Nothing here.</p><p class="card-more"><a href="tables.html">All 0 tables</a></p></section>',
163            (new OverviewPage())->route('Tables', 'tables.html', 0, 'table', 'hint', ''),
164        );
165    }
166
167    public function testAttentionListsRulesAndTheFunctionsFlaggedMost(): void
168    {
169        $catalog = new Catalog([
170            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'f', 'pdo.query'), [
171                Finding::of(FindingRule::ExternalInput, 'spliced'),
172            ]),
173        ]);
174        $attention = (new OverviewPage())->attention(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()));
175
176        self::assertStringContainsString('<a class="mono" href="findings.html#rule-external-input">external-input</a>', $attention);
177        self::assertStringContainsString('<a class="mono" href="findings.html#hotspots">f</a><span class="usage-kind">a.php · 1 high</span>', $attention);
178        self::assertStringContainsString('Nothing was reported', (new OverviewPage())->attention(new ReportSite(new Catalog(), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter())));
179    }
180
181    public function testCoverageLeadsFromEverySegmentToTheStatementsItCounts(): void
182    {
183        $catalog = new Catalog([
184            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'f', 'pdo.query'), []),
185            new CatalogEntry('a2', StatementKind::Select, TextPattern::fromHole(new TextHole(Origin::Budget, TypeShape::unknown())), [], [], new CallSite('a.php', 2, 'f', 'pdo.query'), []),
186        ]);
187        $coverage = (new OverviewPage())->coverage(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()));
188
189        self::assertStringContainsString('<a class="meter-part tone-ok" style="--dd-part:50%" href="statements.html?resolution=resolved"', $coverage);
190        self::assertStringNotContainsString('class="meter-part tone-neutral"', $coverage);
191        self::assertStringContainsString('<a href="statements.html?open=open">1 statement</a> are lower bounds', $coverage);
192        self::assertStringContainsString('Every search closed', (new OverviewPage())->coverage(new ReportSite(new Catalog(), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter())));
193    }
194
195    public function testProblemsAreListedOnlyWhenThereAreSome(): void
196    {
197        $page = new OverviewPage();
198
199        self::assertSame('', $page->problems(new ReportSite(new Catalog(), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter())));
200        self::assertStringContainsString(
201            '<tr><td><code>b.php</code></td><td>broken</td></tr>',
202            $page->problems(new ReportSite(new Catalog([], [new AnalysisProblem('b.php', 'broken')]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter())),
203        );
204    }
205
206    public function testRenderIsWrittenExactly(): void
207    {
208        $entries = [
209            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromSegments([
210                new LiteralText('SELECT id FROM posts WHERE slug = '),
211                new TextHole(Origin::External, TypeShape::unknown(), '$_GET["s"]'),
212            ]), ['posts'], [new Placeholder('?', 0, null, new ValueDomain('int', [7], true, []))], new CallSite('src/a.php', 4, 'App\\R::find', 'pdo.query'), [
213                Finding::of(FindingRule::ExternalInput, 'spliced'),
214            ], false, ['App\\R::run', 'App\\R::find']),
215            new CatalogEntry('a2', StatementKind::Insert, TextPattern::fromText('INSERT INTO posts (id) VALUES (1)'), ['posts'], [], new CallSite('src/a.php', 9, 'App\\R::add', 'pdo.query'), []),
216            new CatalogEntry('a3', StatementKind::Update, TextPattern::fromText('UPDATE posts SET title = ? WHERE id = ?'), ['posts'], [], new CallSite('src/a.php', 14, 'App\\R::add', 'pdo.prepare'), [
217                Finding::of(FindingRule::PlaceholderCountMismatch, 'one bound'),
218            ]),
219            new CatalogEntry('b1', StatementKind::Delete, TextPattern::fromText('DELETE FROM users WHERE id = 1'), ['users'], [], new CallSite('src/b.php', 3, 'App\\Admin\\U::drop', 'pdo.query'), []),
220            new CatalogEntry('b2', StatementKind::Alter, TextPattern::fromText('ALTER TABLE users ADD x INT'), ['users'], [], new CallSite('src/b.php', 8, 'App\\Admin\\U::migrate', 'pdo.exec'), []),
221            new CatalogEntry('c1', StatementKind::Select, TextPattern::fromHole(new TextHole(Origin::Budget, TypeShape::unknown())), [], [], new CallSite('lib/c.php', 2, 'helper', 'mysqli.query'), [
222                Finding::of(FindingRule::AnalysisIncomplete, 'stopped'),
223            ], true, [], true),
224            new CatalogEntry('c2', StatementKind::Unknown, TextPattern::fromHole(new TextHole(Origin::Unreached, TypeShape::unknown(), '$db->query($sql)')), [], [], new CallSite('lib/c.php', 6, '{main}', 'unmatched'), [
225                Finding::of(FindingRule::CallNotAnalyzed, 'unseen'),
226            ]),
227            new CatalogEntry('d1', StatementKind::Select, TextPattern::fromText('SELECT * FROM posts p JOIN users u ON u.id = p.author'), ['posts', 'users'], [], new CallSite('src/d.php', 1, 'App\\R::find', 'pdo.query'), [
228                Finding::of(FindingRule::DynamicSql, 'dyn'),
229            ]),
230            new CatalogEntry('e1', StatementKind::Show, TextPattern::fromText('SHOW TABLES'), [], [], new CallSite('src/e.php', 1, 'App\\R::find', 'pdo.query'), []),
231            new CatalogEntry('f1', StatementKind::Select, TextPattern::fromText('SELECT 1 FROM t1'), ['t1'], [], new CallSite('src/f.php', 1, 'App\\F::a', 'pdo.query'), []),
232            new CatalogEntry('f2', StatementKind::Select, TextPattern::fromText('SELECT 2 FROM t2'), ['t2'], [], new CallSite('src/f.php', 2, 'App\\F::b', 'pdo.query'), []),
233            new CatalogEntry('f3', StatementKind::Select, TextPattern::fromText('SELECT 3 FROM t3'), ['t3'], [], new CallSite('src/f.php', 3, 'App\\G::a', 'pdo.query'), []),
234            new CatalogEntry('f4', StatementKind::Select, TextPattern::fromText('SELECT 4 FROM t4'), ['t4'], [], new CallSite('src/f.php', 4, 'App\\H::a', 'pdo.query'), []),
235            new CatalogEntry('f5', StatementKind::Select, TextPattern::fromText('SELECT 5 FROM t5'), ['t5'], [], new CallSite('src/g.php', 1, 'App\\I::a', 'pdo.query'), []),
236            new CatalogEntry('f6', StatementKind::Select, TextPattern::fromText('SELECT 6 FROM t6'), ['t6'], [], new CallSite('src/h.php', 1, 'App\\J::a', 'pdo.query'), []),
237            new CatalogEntry('f7', StatementKind::Select, TextPattern::fromText('SELECT 7 FROM t7'), ['t7'], [], new CallSite('src/i.php', 1, 'App\\K::a', 'pdo.query'), [
238                Finding::of(FindingRule::DynamicSql, 'dyn'),
239            ]),
240            new CatalogEntry('f8', StatementKind::Select, TextPattern::fromText('SELECT 8 FROM t8'), ['t8'], [], new CallSite('src/j.php', 1, 'App\\L::a', 'pdo.query'), [
241                Finding::of(FindingRule::DynamicSql, 'dyn'),
242            ]),
243            new CatalogEntry('f9', StatementKind::Select, TextPattern::fromText('SELECT 9 FROM t9'), ['t9'], [], new CallSite('src/k.php', 1, 'App\\M::a', 'pdo.query'), [
244                Finding::of(FindingRule::DynamicSql, 'dyn'),
245            ]),
246        ];
247        $catalog = new Catalog($entries, [new AnalysisProblem('src/broken.php', 'broken')]);
248        $site = new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
249
250        self::assertSame(
251            '<h1>Overview</h1><p class="lede">Every statement this source can issue, read back from the calls that receive it. Start from the table, class or file '
252                . 'you are working on, or from what the analysis flagged.</p><div class="stats"><a class="stat" href="statements.html"><b class="stat-fig">18</b><span cl'
253                . 'ass="stat-label">statements</span></a><a class="stat" href="tables.html"><b class="stat-fig">11</b><span class="stat-label">tables</span></a><a class='
254                . '"stat" href="namespaces.html"><b class="stat-fig">15</b><span class="stat-label">functions</span></a><a class="stat" href="files.html"><b class="stat-'
255                . 'fig">11</b><span class="stat-label">files</span></a></div><div class="cards"><section class="card"><h2><a href="tables.html">Tables</a><span class="co'
256                . 'unt">11</span></h2><p class="card-description">Which statements read, write or alter a table, and where each is issued. Start here before changing a s'
257                . 'chema.</p><ul class="usage-list"><li><a class="mono" href="tables/posts.html">posts</a><span class="usage-kind">4 · 2 read · 2 write</span></li><li><a'
258                . ' class="mono" href="tables/users.html">users</a><span class="usage-kind">3 · 1 read · 1 write</span></li><li><a class="mono" href="tables/t1.html">t1<'
259                . '/a><span class="usage-kind">1 · 1 read · 0 write</span></li><li><a class="mono" href="tables/t2.html">t2</a><span class="usage-kind">1 · 1 read · 0 wr'
260                . 'ite</span></li><li><a class="mono" href="tables/t3.html">t3</a><span class="usage-kind">1 · 1 read · 0 write</span></li><li><a class="mono" href="tabl'
261                . 'es/t4.html">t4</a><span class="usage-kind">1 · 1 read · 0 write</span></li></ul><p class="card-more"><a href="tables.html">All 11 tables</a></p></sect'
262                . 'ion><section class="card"><h2><a href="namespaces.html">Namespaces</a><span class="count">3</span></h2><p class="card-description">The statements each'
263                . ' class and function issues, method by method. Start here before refactoring code that talks to the database.</p><ul class="usage-list"><li><a class="m'
264                . 'ono" href="classes/app-r.html">App\\R</a><span class="usage-kind">5 statements</span></li><li><a class="mono" href="classes/app-admin-u.html">App\\Adm'
265                . 'in\\U</a><span class="usage-kind">2 statements</span></li><li><a class="mono" href="classes/app-f.html">App\\F</a><span class="usage-kind">2 statement'
266                . 's</span></li><li><a class="mono" href="classes/app-g.html">App\\G</a><span class="usage-kind">1 statement</span></li><li><a class="mono" href="classes'
267                . '/app-h.html">App\\H</a><span class="usage-kind">1 statement</span></li><li><a class="mono" href="classes/app-i.html">App\\I</a><span class="usage-kind'
268                . '">1 statement</span></li></ul><p class="card-more"><a href="namespaces.html">All 3 namespaces</a></p></section><section class="card"><h2><a href="file'
269                . 's.html">Files</a><span class="count">11</span></h2><p class="card-description">The statements written in each file, function by function.</p><ul class'
270                . '="usage-list"><li><a class="mono" href="files/src-f-php.html">src/f.php</a><span class="usage-kind">4 statements</span></li><li><a class="mono" href="'
271                . 'files/src-a-php.html">src/a.php</a><span class="usage-kind">3 statements</span></li><li><a class="mono" href="files/lib-c-php.html">lib/c.php</a><span'
272                . ' class="usage-kind">2 statements</span></li><li><a class="mono" href="files/src-b-php.html">src/b.php</a><span class="usage-kind">2 statements</span><'
273                . '/li><li><a class="mono" href="files/src-d-php.html">src/d.php</a><span class="usage-kind">1 statement</span></li><li><a class="mono" href="files/src-e'
274                . '-php.html">src/e.php</a><span class="usage-kind">1 statement</span></li></ul><p class="card-more"><a href="files.html">All 11 files</a></p></section><'
275                . 'section class="card"><h2><a href="statements.html">Statements</a><span class="count">18</span></h2><p class="card-description">Every statement, to nar'
276                . 'row down by what it does, how far the analysis got and what was reported.</p><ul class="usage-list"><li><a class="chip tone-blue" href="statements.htm'
277                . 'l?kind=select">SELECT</a><span class="usage-kind">12 statements</span></li><li><a class="chip tone-slate" href="statements.html?kind=unknown">UNKNOWN<'
278                . '/a><span class="usage-kind">1 statement</span></li><li><a class="chip tone-teal" href="statements.html?kind=insert">INSERT</a><span class="usage-kind"'
279                . '>1 statement</span></li><li><a class="chip tone-violet" href="statements.html?kind=update">UPDATE</a><span class="usage-kind">1 statement</span></li><'
280                . 'li><a class="chip tone-pink" href="statements.html?kind=delete">DELETE</a><span class="usage-kind">1 statement</span></li><li><a class="chip tone-indi'
281                . 'go" href="statements.html?kind=alter">ALTER</a><span class="usage-kind">1 statement</span></li><li><a class="chip tone-slate" href="statements.html?ki'
282                . 'nd=show">SHOW</a><span class="usage-kind">1 statement</span></li></ul><p class="card-more"><a href="statements.html">All 18 statements</a></p></sectio'
283                . 'n></div><h2 id="attention">Needs attention<span class="count">8 findings</span></h2><div class="split"><div class="table-wrap"><table><thead><tr><th s'
284                . 'cope="col">Rule</th><th scope="col" class="tight">Severity</th><th scope="col" class="num">Statements</th><th scope="col">What it reports</th></tr></t'
285                . 'head><tbody><tr><td class="tight"><a class="mono" href="findings.html#rule-dynamic-sql">dynamic-sql</a></td><td class="tight"><span class="chip tone-w'
286                . 'arn">medium</span></td><td class="num">4</td><td>A value is spliced into the statement text instead of being bound.</td></tr><tr><td class="tight"><a '
287                . 'class="mono" href="findings.html#rule-analysis-incomplete">analysis-incomplete</a></td><td class="tight"><span class="chip tone-neutral">low</span></t'
288                . 'd><td class="num">1</td><td>A cycle or an analysis budget stopped the search before it closed.</td></tr><tr><td class="tight"><a class="mono" href="fi'
289                . 'ndings.html#rule-call-not-analyzed">call-not-analyzed</a></td><td class="tight"><span class="chip tone-neutral">low</span></td><td class="num">1</td><'
290                . 'td>A call that carries a statement was found but never examined.</td></tr><tr><td class="tight"><a class="mono" href="findings.html#rule-external-inpu'
291                . 't">external-input</a></td><td class="tight"><span class="chip tone-danger">high</span></td><td class="num">1</td><td>A value spliced into the statemen'
292                . 't text comes from external input.</td></tr><tr><td class="tight"><a class="mono" href="findings.html#rule-placeholder-count-mismatch">placeholder-coun'
293                . 't-mismatch</a></td><td class="tight"><span class="chip tone-warn">medium</span></td><td class="num">1</td><td>The statement binds a different number o'
294                . 'f values than it has placeholders.</td></tr></tbody></table></div><section class="aside"><h3>Functions issuing flagged statements</h3><ul class="usage'
295                . '-list"><li><a class="mono" href="findings.html#hotspots">R::find</a><span class="usage-kind">src/a.php · 1 high · 1 medium</span></li><li><a class="mo'
296                . 'no" href="findings.html#hotspots">K::a</a><span class="usage-kind">src/i.php · 1 medium</span></li><li><a class="mono" href="findings.html#hotspots">L'
297                . '::a</a><span class="usage-kind">src/j.php · 1 medium</span></li><li><a class="mono" href="findings.html#hotspots">M::a</a><span class="usage-kind">src'
298                . '/k.php · 1 medium</span></li><li><a class="mono" href="findings.html#hotspots">R::add</a><span class="usage-kind">src/a.php · 1 medium</span></li></ul'
299                . '><p class="actions"><a href="findings.html#hotspots">Every flagged function</a></p></section></div><h2 id="coverage">How far the analysis got</h2><div cl'
300                . 'ass="meter meter-lg"><a class="meter-part tone-ok" style="--dd-part:83%" href="statements.html?resolution=resolved" title="resolved: The statement tex'
301                . 't is fully determined."></a><a class="meter-part tone-danger" style="--dd-part:6%" href="statements.html?resolution=external-input" title="external-in'
302                . 'put: The values were followed to runtime input, so the text cannot be fixed."></a><a class="meter-part is-open" style="--dd-part:6%" href="statements.'
303                . 'html?resolution=incomplete" title="incomplete: A cycle or an analysis budget stopped the search before it closed."></a><a class="meter-part tone-neutr'
304                . 'al" style="--dd-part:6%" href="statements.html?resolution=not-analyzed" title="not-analyzed: The call was found but never examined, so nothing was rea'
305                . 'd from it."></a></div><ul class="legend"><li><a class="chip tone-ok" href="statements.html?resolution=resolved" title="The statement text is fully det'
306                . 'ermined.">resolved</a><span class="meter-legend-count">15</span><span class="meter-legend-description">The statement text is fully determined.</span><'
307                . '/li><li><a class="chip tone-danger" href="statements.html?resolution=external-input" title="The values were followed to runtime input, so the text can'
308                . 'not be fixed.">external-input</a><span class="meter-legend-count">1</span><span class="meter-legend-description">The values were followed to runtime i'
309                . 'nput, so the text cannot be fixed.</span></li><li><a class="chip chip-ghost" href="statements.html?resolution=incomplete-model" title="A dependency th'
310                . 'e analyzer does not model was reached.">incomplete-model</a><span class="meter-legend-count">0</span><span class="meter-legend-description">A dependen'
311                . 'cy the analyzer does not model was reached.</span></li><li><a class="chip chip-ghost" href="statements.html?resolution=incomplete" title="A cycle or a'
312                . 'n analysis budget stopped the search before it closed.">incomplete</a><span class="meter-legend-count">1</span><span class="meter-legend-description">'
313                . 'A cycle or an analysis budget stopped the search before it closed.</span></li><li><a class="chip tone-neutral" href="statements.html?resolution=not-an'
314                . 'alyzed" title="The call was found but never examined, so nothing was read from it.">not-analyzed</a><span class="meter-legend-count">1</span><span cla'
315                . 'ss="meter-legend-description">The call was found but never examined, so nothing was read from it.</span></li></ul><p class="muted"><a href="statements'
316                . '.html?open=open">2 statements</a> are lower bounds: a dependency, a cycle or a budget stopped the search, so the call may issue more than is listed.</'
317                . 'p><h2 id="problems">Not read<span class="count">1 file</span></h2><p class="lede">These files could not be parsed, so nothing in them was catalogued.<'
318                . '/p><div class="table-wrap"><table><thead><tr><th scope="col">File</th><th scope="col">Why</th></tr></thead><tbody><tr><td><code>src/broken.php</code><'
319                . '/td><td>broken</td></tr></tbody></table></div>',
320            (new OverviewPage())->render($site),
321        );
322    }
323
324    public function testProblemsLinksAvailableSourceAndKeepsEveryParsingError(): void
325    {
326        $site = new ReportSite(new Catalog([], [
327            new AnalysisProblem('a.php', 'Unexpected <token>'),
328            new AnalysisProblem('b.php', 'Source unavailable'),
329        ], ['a.php' => '<?php function {']), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
330        $html = (new OverviewPage())->problems($site);
331
332        self::assertStringContainsString('<tr><td><a class="mono" href="files/a-php.html#source">a.php</a></td><td>Unexpected &lt;token&gt;</td></tr>', $html);
333        self::assertStringContainsString('<tr><td><code>b.php</code></td><td>Source unavailable</td></tr>', $html);
334    }
335}
336