PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / diagnostics / DiagnosticCollectionManifestRenderer.php

DiagnosticCollectionManifestRenderer.php in 404 Solution trunk, at includes/diagnostics/DiagnosticCollectionManifestRenderer.php

199 lines 8.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Presentation for the support-collection manifest: one header sentence a human
9 * reads first, then the record as a single JSON line so the section stays JSONL
10 * like every other section around it.
11 *
12 * Separate from ABJ_404_Solution_DiagnosticCollectionManifest, which OBSERVES:
13 * it stats the candidate files, reconciles the browser's attempts, and decides
14 * what is present. This class decides nothing about the site and reads nothing
15 * from disk. It answers only "how does this fit in the bytes available", which
16 * is the same layer split the project applies everywhere else -- and it is a
17 * real one here, because the shedding order below is a presentation policy that
18 * changes independently of what the collector looks for.
19 *
20 * The shedding order is the whole point: over-budget input drops detail in
21 * decreasing order of value (per-file stats, then the id lists) rather than
22 * being cut at a byte offset, and the last fallback is a record small enough to
23 * fit unconditionally. A truncated JSON line would be worse than a smaller
24 * complete one, because the manifest exists to be read when everything else in
25 * the payload came back empty.
26 */
27 final class ABJ_404_Solution_DiagnosticCollectionManifestRenderer {
28
29 /**
30 * The block: a human-readable header sentence, then the record as one JSON
31 * line so the section stays JSONL like every other section around it.
32 *
33 * Over-budget input sheds detail in decreasing order of value (per-file
34 * stats, then the id lists) rather than being cut at a byte offset, and the
35 * last fallback is a record small enough to fit unconditionally. The counts
36 * in the header are the same either way, so a shed manifest still says how
37 * much was checked and how much was found.
38 *
39 * @param array<string, mixed> $manifest
40 */
41 public static function render(array $manifest, int $budgetBytes): string {
42 $header = self::headerLine($manifest);
43 $anythingEncoded = false;
44 foreach (array($manifest, self::withoutIdLists($manifest), self::minimal($manifest)) as $candidate) {
45 $line = self::encodeOrEmpty(array(ABJ_404_Solution_DiagnosticCollectionManifest::RECORD_KEY => $candidate));
46 if ($line === '') {
47 continue;
48 }
49 $anythingEncoded = true;
50 if (strlen($header) + strlen($line) <= $budgetBytes) {
51 return $header . $line;
52 }
53 }
54 // Two different reasons reach here and they are not the same finding.
55 // Everything encoded but nothing fit means the budget was too small --
56 // the site is fine and the payload was squeezed. Nothing encoding at
57 // all means the manifest itself held bytes json_encode() rejected,
58 // which is a defect worth chasing. Reporting both as `encoding_failed`
59 // sent a reader after the wrong one, in the very record whose job is
60 // to explain why the rest of the payload is thin.
61 return $header . self::encodeOrEmpty(array(ABJ_404_Solution_DiagnosticCollectionManifest::RECORD_KEY => array(
62 'reduced' => $anythingEncoded ? 'all_forms_over_budget' : 'encoding_failed',
63 'outcome' => self::stringOf($manifest['outcome'] ?? ABJ_404_Solution_DiagnosticCollectionManifest::OUTCOME_EMPTY),
64 )));
65 }
66
67 /**
68 * The scannable one-line version, so the first thing a reader sees is what
69 * was looked for and how much of it was there.
70 *
71 * @param array<string, mixed> $manifest
72 */
73 private static function headerLine(array $manifest): string {
74 $channels = self::arrayOf($manifest['channels'] ?? null);
75 $checked = 0;
76 $found = 0;
77 $lines = 0;
78 foreach ($channels as $channel) {
79 $described = self::arrayOf($channel);
80 $checked += self::intOf($described['candidates_checked'] ?? 0);
81 $found += self::intOf($described['candidates_found'] ?? 0);
82 $lines += self::intOf($described['collected_lines'] ?? 0);
83 }
84 return 'Diagnostic collection manifest -- ' . count($channels) . ' channel(s), '
85 . $found . ' of ' . $checked . ' candidate files present, ' . $lines . ' lines collected; '
86 . self::attemptClause(self::arrayOf($manifest['client_expected_attempts'] ?? null))
87 . " (JSONL):\n";
88 }
89
90 /** @param array<array-key, mixed> $expected */
91 private static function attemptClause(array $expected): string {
92 $status = self::stringOf($expected['status'] ?? 'absent');
93 if ($status === 'parsed') {
94 return 'browser expected ' . self::intOf($expected['expected'] ?? 0) . ' attempt id(s), '
95 . self::intOf($expected['found'] ?? 0) . ' present';
96 }
97 if ($status === 'unparseable') {
98 return 'the browser attempt buffer could not be parsed';
99 }
100 return 'no browser attempt ids were sent';
101 }
102
103 /**
104 * @param array<string, mixed> $manifest
105 * @return array<string, mixed>
106 */
107 private static function withoutIdLists(array $manifest): array {
108 $manifest['reduced'] = 'attempt_id_lists_and_file_stats_dropped_for_budget';
109 $expected = self::arrayOf($manifest['client_expected_attempts'] ?? null);
110 $expected['found_ids'] = array();
111 $expected['missing_ids'] = array();
112 $manifest['client_expected_attempts'] = $expected;
113 $channels = array();
114 foreach (self::arrayOf($manifest['channels'] ?? null) as $channel) {
115 $described = self::arrayOf($channel);
116 unset($described['files']);
117 $selection = self::arrayOf($described['file_selection'] ?? null);
118 $selection['dropped_file_names'] = array();
119 $selection['dropped_request_ids'] = array();
120 $described['file_selection'] = $selection;
121 $channels[] = $described;
122 }
123 $manifest['channels'] = $channels;
124 return $manifest;
125 }
126
127 /**
128 * The smallest manifest that is still worth having: who collected, how many
129 * files each channel checked and found, and the outcome.
130 *
131 * @param array<string, mixed> $manifest
132 * @return array<string, mixed>
133 */
134 private static function minimal(array $manifest): array {
135 $channels = array();
136 foreach (self::arrayOf($manifest['channels'] ?? null) as $channel) {
137 $described = self::arrayOf($channel);
138 $selection = self::arrayOf($described['file_selection'] ?? null);
139 $minimalSelection = array(
140 'policy' => self::stringOf($selection['policy'] ?? ''),
141 );
142 foreach (array(
143 'existing_files', 'selected_files', 'known_failure_files',
144 'server_failure_files', 'classification_issue_files', 'pinned_files',
145 'classification_issues_omitted', 'dropped_files',
146 'dropped_file_names_omitted', 'dropped_request_ids_omitted',
147 ) as $field) {
148 $minimalSelection[$field] = self::intOf($selection[$field] ?? 0);
149 }
150 $channels[] = array(
151 'channel' => self::stringOf($described['channel'] ?? 'unknown'),
152 'directory_usable' => !empty($described['directory_usable']),
153 'candidates_checked' => self::intOf($described['candidates_checked'] ?? 0),
154 'candidates_found' => self::intOf($described['candidates_found'] ?? 0),
155 'collected_bytes' => self::intOf($described['collected_bytes'] ?? 0),
156 'file_selection' => $minimalSelection,
157 );
158 }
159 return array(
160 'reduced' => 'channel_detail_dropped_for_budget',
161 'collector' => self::arrayOf($manifest['collector'] ?? null),
162 'channels' => $channels,
163 'required_evidence_records' =>
164 self::arrayOf($manifest['required_evidence_records'] ?? null),
165 'outcome' => self::stringOf($manifest['outcome'] ?? ABJ_404_Solution_DiagnosticCollectionManifest::OUTCOME_EMPTY),
166 );
167 }
168
169 /** @param array<string, mixed> $value */
170 public static function encodeOrEmpty(array $value): string {
171 $json = json_encode($value, JSON_UNESCAPED_SLASHES);
172 return is_string($json) ? $json : '';
173 }
174
175 /**
176 * Typed reads of the assembled record. The manifest is deliberately a
177 * loose map (its shape is the wire format, not a PHP contract), so the
178 * places that summarize it read through these rather than casting mixed.
179 *
180 * @param mixed $value
181 */
182 private static function intOf($value): int {
183 return is_numeric($value) ? (int)$value : 0;
184 }
185
186 /** @param mixed $value */
187 private static function stringOf($value): string {
188 return is_scalar($value) ? (string)$value : '';
189 }
190
191 /**
192 * @param mixed $value
193 * @return array<array-key, mixed>
194 */
195 private static function arrayOf($value): array {
196 return is_array($value) ? $value : array();
197 }
198 }
199