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 <token></td></tr>', $html);
333 self::assertStringContainsString('<tr><td><code>b.php</code></td><td>Source unavailable</td></tr>', $html);
334 }
335}
336