TenantAtlas/apps/platform/tests/Support/UiBloat/UiBloatScanner.php
Ahmed Darrazi 780ed0391a
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m11s
feat(guard): implement ui bloat regression guard
Added UiBloatRegressionGuardTest to enforce known UI bloat and customer/auditor safety regression patterns across configured runtime UI source paths as defined in Spec 375. Registered the test in Pest.php and added to TestLaneManifest.
2026-06-13 11:02:28 +02:00

811 lines
26 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Support\UiBloat;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
final class UiBloatScanner
{
public const STRICTNESS_REPORT = 'report';
public const STRICTNESS_WARN = 'warn';
public const STRICTNESS_FAIL = 'fail';
private const RUNTIME_SCAN_PATHS = [
'apps/platform/app/Filament',
'apps/platform/resources/views/filament',
'apps/platform/app/Support/EnvironmentDashboard',
'apps/platform/app/Support/Navigation',
'apps/platform/app/Support/OpsUx',
'apps/platform/app/Support/SupportDiagnostics',
'apps/platform/app/Support/Ui',
'apps/platform/app/Support/Workspaces',
];
private const ABSENT_CANDIDATE_PATHS = [
'apps/platform/resources/views/components',
'apps/platform/app/View',
];
private const ALLOWLIST_REQUIRED_KEYS = [
'rule_id',
'file',
'pattern',
'reason',
'surface_type',
'audience',
'review_marker',
'expires_or_review_after',
'owner_spec',
];
private const CUSTOMER_RAW_ID_TERMS = [
'operation id',
'workspace id',
'tenant id',
'managed environment id',
'provider object id',
'api object id',
'object id',
'fingerprint',
'sha256',
];
private const CUSTOMER_INTERNAL_TERMS = [
'operation context',
'raw graph payload',
'provider response body',
'internal reason',
'platform reason family',
'stack trace',
'debug',
'raw json',
];
/**
* @param list<array<string, string>> $allowlist
* @return array<string, mixed>
*/
public static function scanConfiguredPaths(
string $repoRoot,
string $strictness = self::STRICTNESS_WARN,
array $allowlist = [],
): array {
$files = self::discoverRuntimeUiFiles($repoRoot);
$result = self::scanFiles($repoRoot, $files, $strictness, $allowlist);
$result['configured_paths'] = self::configuredPathStatus($repoRoot);
$result['absent_candidate_paths'] = self::absentCandidatePathStatus($repoRoot);
return $result;
}
/**
* @return list<string>
*/
public static function configuredScanPaths(): array
{
return self::RUNTIME_SCAN_PATHS;
}
/**
* @return list<string>
*/
public static function absentCandidatePaths(): array
{
return self::ABSENT_CANDIDATE_PATHS;
}
/**
* @return list<string>
*/
public static function discoverRuntimeUiFiles(string $repoRoot): array
{
$files = [];
foreach (self::RUNTIME_SCAN_PATHS as $relativeRoot) {
$absoluteRoot = self::normalizePath($repoRoot.'/'.$relativeRoot);
if (! is_dir($absoluteRoot)) {
continue;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($absoluteRoot, FilesystemIterator::SKIP_DOTS)
);
/** @var SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$path = self::normalizePath($file->getPathname());
if (! self::isRuntimeUiPath(self::relativePath($repoRoot, $path))) {
continue;
}
$files[] = $path;
}
}
sort($files);
return array_values(array_unique($files));
}
public static function isRuntimeUiPath(string $relativePath): bool
{
$relativePath = self::normalizePath($relativePath);
if (! str_ends_with($relativePath, '.php')) {
return false;
}
foreach ([
'vendor/',
'node_modules/',
'storage/',
'bootstrap/cache/',
'public/build/',
'database/',
'specs/',
'tests/',
'translations/',
'lang/',
] as $excludedPrefix) {
if (str_starts_with($relativePath, $excludedPrefix)) {
return false;
}
}
foreach (self::RUNTIME_SCAN_PATHS as $scanPath) {
if (str_starts_with($relativePath, $scanPath.'/')) {
return true;
}
}
return false;
}
/**
* @param list<string> $absoluteFiles
* @param list<array<string, string>> $allowlist
* @return array<string, mixed>
*/
public static function scanFiles(
string $repoRoot,
array $absoluteFiles,
string $strictness = self::STRICTNESS_WARN,
array $allowlist = [],
): array {
$findings = [];
$configErrors = self::validateAllowlist($allowlist);
foreach ($absoluteFiles as $absoluteFile) {
$relativePath = self::relativePath($repoRoot, $absoluteFile);
if (! self::isRuntimeUiPath($relativePath)) {
continue;
}
$source = file_get_contents($absoluteFile);
if (! is_string($source) || $source === '') {
continue;
}
$fileResult = self::scanSource($relativePath, $source, $strictness, $allowlist);
$findings = array_merge($findings, $fileResult['findings']);
}
return self::result($strictness, count($absoluteFiles), $findings, $configErrors);
}
/**
* @param list<array<string, string>> $allowlist
* @return array<string, mixed>
*/
public static function scanSource(
string $relativePath,
string $source,
string $strictness = self::STRICTNESS_WARN,
array $allowlist = [],
): array {
$findings = self::findingsForSource($relativePath, $source);
$findings = array_map(
static fn (array $finding): array => self::applyAllowlist($finding, $allowlist),
$findings,
);
return self::result($strictness, 1, $findings, self::validateAllowlist($allowlist));
}
/**
* @param list<array<string, string>> $allowlist
* @return list<string>
*/
public static function validateAllowlist(array $allowlist): array
{
$errors = [];
foreach ($allowlist as $index => $entry) {
foreach (self::ALLOWLIST_REQUIRED_KEYS as $key) {
if (! isset($entry[$key]) || trim((string) $entry[$key]) === '') {
$errors[] = sprintf('Allowlist entry %d is missing required key [%s].', $index, $key);
}
}
$file = (string) ($entry['file'] ?? '');
if (in_array($file, self::RUNTIME_SCAN_PATHS, true) || str_ends_with($file, '/*')) {
$errors[] = sprintf('Allowlist entry %d uses a forbidden blanket file pattern [%s].', $index, $file);
}
}
return $errors;
}
/**
* @param list<array<string, mixed>> $findings
*/
public static function formatFindings(array $findings): string
{
if ($findings === []) {
return 'No findings.';
}
return implode("\n", array_map(
static fn (array $finding): string => sprintf(
'%s %s %s:%d [%s] %s',
$finding['rule_id'],
strtoupper((string) $finding['result']),
$finding['file'],
$finding['line'],
$finding['surface'],
$finding['reason'],
),
$findings,
));
}
/**
* @return list<array<string, mixed>>
*/
private static function findingsForSource(string $relativePath, string $source): array
{
$lowerSource = strtolower($source);
$surface = self::classifySurface($relativePath, $source);
$findings = [];
foreach (self::CUSTOMER_RAW_ID_TERMS as $term) {
foreach (self::termOffsets($lowerSource, $term) as $offset) {
$technicalContext = self::isTechnicalContext($lowerSource, $offset);
$customerDefault = $surface === 'customer-auditor' && ! $technicalContext;
$findings[] = self::finding(
'UIBLOAT_CUSTOMER_RAW_ID',
$relativePath,
$term,
$surface,
$customerDefault ? 'blocking' : 'manual-review-required',
$customerDefault ? 'blocker' : 'medium',
$customerDefault
? 'Raw ID label appears in a likely customer/auditor default surface.'
: 'Raw ID label appears in a non-customer or technical-detail context and needs review.',
'Move raw IDs behind collapsed technical/support detail or document an allowlist exception.',
self::lineForOffset($source, $offset),
);
}
}
foreach (self::CUSTOMER_INTERNAL_TERMS as $term) {
foreach (self::termOffsets($lowerSource, $term) as $offset) {
$technicalContext = self::isTechnicalContext($lowerSource, $offset);
$customerDefault = $surface === 'customer-auditor' && ! $technicalContext;
$findings[] = self::finding(
'UIBLOAT_CUSTOMER_INTERNAL_TERM',
$relativePath,
$term,
$surface,
$customerDefault ? 'blocking' : 'manual-review-required',
$customerDefault ? 'blocker' : 'medium',
$customerDefault
? 'Internal/debug/provider term appears in a likely customer/auditor default surface.'
: 'Internal/debug/provider term appears outside a hard customer default context and needs review.',
'Reword customer copy or move internal detail to diagnostics/technical disclosure.',
self::lineForOffset($source, $offset),
);
}
}
if (preg_match_all('/\b0\s+(?:findings|alerts|failures|errors|runs|reviews|items|policies|backups|reports|snapshots|issues)\b/i', $source, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as [$match, $offset]) {
$findings[] = self::finding(
'UIBLOAT_ZERO_METRIC_CARD',
$relativePath,
(string) $match,
$surface,
'warning',
'low',
'Zero metric copy can create no-action dashboard noise.',
'Suppress zero-only cards unless zero is itself proof or audit-significant.',
self::lineForOffset($source, (int) $offset),
);
}
}
$statusCount = preg_match_all('/\b(?:status|state|ready|readiness|lifecycle|pending|completed|failed|blocked|available|unavailable)\b/i', $source);
if ($statusCount >= 16) {
$findings[] = self::finding(
'UIBLOAT_REPEATED_STATUS',
$relativePath,
'status/readiness/lifecycle repeated '.$statusCount.' times',
$surface,
'manual-review-required',
'medium',
'Status or lifecycle language appears repeatedly in one source file.',
'Keep one first-viewport owner for status/reason/impact and let lower sections add evidence.',
1,
);
}
if (self::looksLikePage($relativePath, $source) && ! self::hasPrimaryQuestionMarker($lowerSource)) {
$findings[] = self::finding(
'UIBLOAT_MISSING_PRIMARY_QUESTION',
$relativePath,
'no primary question marker',
$surface,
'manual-review-required',
'medium',
'Page-like source lacks an obvious primary question, outcome, or next-action marker.',
'Add or preserve decision-first copy and one clear next action where the rendered surface needs it.',
1,
);
}
if (self::headerActionCount($source) >= 5) {
$findings[] = self::finding(
'UIBLOAT_HEADER_ACTION_OVERLOAD',
$relativePath,
'five or more header actions',
$surface,
'manual-review-required',
'medium',
'Header action count may overload the primary next action.',
'Keep one dominant primary action and group rare or risky actions.',
self::lineForNeedle($source, 'getHeaderActions'),
);
}
foreach (['evidence diagnostics', 'diagnostic evidence', 'proof diagnostics', 'diagnostics evidence'] as $term) {
foreach (self::termOffsets($lowerSource, $term) as $offset) {
$findings[] = self::finding(
'UIBLOAT_EVIDENCE_DIAGNOSTICS_MIXED',
$relativePath,
$term,
$surface,
'manual-review-required',
'medium',
'Evidence and diagnostics language appears mixed.',
'Separate evidence proof from diagnostics or support explanation.',
self::lineForOffset($source, $offset),
);
}
}
foreach (['normalization lineage', 'source refs', 'provider details', 'operation context', 'raw payload'] as $term) {
foreach (self::termOffsets($lowerSource, $term) as $offset) {
if (self::isTechnicalContext($lowerSource, $offset)) {
continue;
}
$findings[] = self::finding(
'UIBLOAT_TECH_METADATA_MAIN',
$relativePath,
$term,
$surface,
'manual-review-required',
'medium',
'Technical metadata appears outside an obvious technical-details context.',
'Move implementation metadata into collapsed technical/support disclosure.',
self::lineForOffset($source, $offset),
);
}
}
if ($surface === 'diagnostic-support' && self::technicalTermCount($lowerSource) >= 4 && ! self::hasDiagnosticGuidance($lowerSource)) {
$findings[] = self::finding(
'UIBLOAT_DIAGNOSTIC_GUIDANCE_MISSING',
$relativePath,
'diagnostic technicality without guidance',
$surface,
'manual-review-required',
'medium',
'Diagnostic/support source has technical detail without a clear recommended first check.',
'Add guidance-first wording or document why another surface owns guidance.',
1,
);
}
foreach (['all diagnostics', 'diagnostics hub', 'complete diagnostics', 'full diagnostics', 'environment health diagnostics'] as $term) {
foreach (self::termOffsets($lowerSource, $term) as $offset) {
$findings[] = self::finding(
'UIBLOAT_DIAGNOSTIC_ENTRYPOINT_AMBIGUOUS',
$relativePath,
$term,
$surface,
'manual-review-required',
'medium',
'Diagnostic entrypoint copy may imply broader coverage than the route owns.',
'Name support diagnostics, repair diagnostics, or provider readiness explicitly.',
self::lineForOffset($source, $offset),
);
}
}
return $findings;
}
private static function classifySurface(string $relativePath, string $source): string
{
$normalizedPath = strtolower(self::normalizePath($relativePath));
$combined = $normalizedPath.' '.strtolower($source);
foreach ([
'supportdiagnostics',
'support-diagnostic',
'diagnostics',
'diagnostic',
'requiredpermissions',
'required-permissions',
'providerconnection',
'provider-connection',
'providerreadiness',
'provider-readiness',
] as $pathMarker) {
if (str_contains($normalizedPath, $pathMarker)) {
return 'diagnostic-support';
}
}
foreach ([
'customer-review-workspace',
'customerreviewworkspace',
'reviewpackresource',
'storedreportresource',
'stored-report',
'environmentreviewresource',
'evidencesnapshotresource',
'evidence-snapshot',
] as $pathMarker) {
if (str_contains($normalizedPath, $pathMarker)) {
return 'customer-auditor';
}
}
foreach ([
'customer review',
'auditor',
'review output',
] as $marker) {
if (str_contains($combined, $marker)) {
return 'customer-auditor';
}
}
if (str_contains($relativePath, 'apps/platform/app/Filament') || str_contains($relativePath, 'apps/platform/resources/views/filament')) {
return 'operator';
}
return 'unknown';
}
/**
* @return list<int>
*/
private static function termOffsets(string $lowerSource, string $term): array
{
$offsets = [];
$offset = 0;
while (($position = strpos($lowerSource, strtolower($term), $offset)) !== false) {
$offsets[] = $position;
$offset = $position + max(1, strlen($term));
}
return $offsets;
}
private static function isTechnicalContext(string $lowerSource, int $offset): bool
{
$window = substr($lowerSource, max(0, $offset - 700), 1400);
foreach ([
'technical details',
'technical detail',
'technical evidence',
'collapsed',
'collapsible',
'toggledhiddenbydefault',
'hidden(fn',
'hidden(',
'<details',
'details::make',
'section::make(\'technical',
'section::make("technical',
'support diagnostics',
'raw/support',
] as $marker) {
if (str_contains($window, $marker)) {
return true;
}
}
return false;
}
private static function looksLikePage(string $relativePath, string $source): bool
{
return str_contains($relativePath, '/Pages/')
|| str_contains($relativePath, '/pages/')
|| str_contains($source, 'getHeading')
|| str_contains($source, '<x-filament-panels::page');
}
private static function hasPrimaryQuestionMarker(string $lowerSource): bool
{
foreach ([
'primary question',
'what ',
'next action',
'next step',
'recommended',
'outcome',
'decision',
'review findings',
'download review pack',
'open diagnostics',
'support diagnostics',
'repair diagnostics',
] as $marker) {
if (str_contains($lowerSource, $marker)) {
return true;
}
}
return false;
}
private static function headerActionCount(string $source): int
{
if (! str_contains($source, 'getHeaderActions')) {
return 0;
}
return preg_match_all('/\bAction::make\s*\(/', $source) ?: 0;
}
private static function technicalTermCount(string $lowerSource): int
{
$count = 0;
foreach ([
'raw',
'payload',
'context',
'trace',
'debug',
'provider',
'permission',
'operation',
'id',
'diagnostic',
] as $term) {
$count += substr_count($lowerSource, $term);
}
return $count;
}
private static function hasDiagnosticGuidance(string $lowerSource): bool
{
foreach ([
'recommended first check',
'first check',
'start here',
'next check',
'use this when',
'repair diagnostics',
'support diagnostics',
'recommended action',
] as $marker) {
if (str_contains($lowerSource, $marker)) {
return true;
}
}
return false;
}
/**
* @return array<string, mixed>
*/
private static function finding(
string $ruleId,
string $file,
string $pattern,
string $surface,
string $result,
string $severity,
string $reason,
string $suggestedAction,
int $line,
): array {
return [
'rule_id' => $ruleId,
'file' => self::normalizePath($file),
'pattern' => $pattern,
'surface' => $surface,
'result' => $result,
'severity' => $severity,
'reason' => $reason,
'suggested_action' => $suggestedAction,
'allowlisted' => false,
'line' => $line,
];
}
/**
* @param list<array<string, string>> $allowlist
* @return array<string, mixed>
*/
private static function applyAllowlist(array $finding, array $allowlist): array
{
foreach ($allowlist as $entry) {
if (($entry['rule_id'] ?? null) !== $finding['rule_id']) {
continue;
}
if (($entry['file'] ?? null) !== $finding['file']) {
continue;
}
$entryPattern = strtolower((string) ($entry['pattern'] ?? ''));
$findingPattern = strtolower((string) $finding['pattern']);
if ($entryPattern === '' || ! str_contains($findingPattern, $entryPattern)) {
continue;
}
$finding['result'] = 'allowlisted';
$finding['severity'] = 'info';
$finding['allowlisted'] = true;
$finding['reason'] = 'Allowlisted: '.$entry['reason'];
return $finding;
}
return $finding;
}
/**
* @param list<array<string, mixed>> $findings
* @param list<string> $configErrors
* @return array<string, mixed>
*/
private static function result(string $strictness, int $filesScanned, array $findings, array $configErrors): array
{
$strictness = in_array($strictness, [self::STRICTNESS_REPORT, self::STRICTNESS_WARN, self::STRICTNESS_FAIL], true)
? $strictness
: self::STRICTNESS_WARN;
$blockingFailures = array_values(array_filter($findings, static function (array $finding) use ($strictness): bool {
if ($finding['allowlisted'] ?? false) {
return false;
}
return match ($strictness) {
self::STRICTNESS_REPORT => false,
self::STRICTNESS_FAIL => true,
default => $finding['result'] === 'blocking',
};
}));
return [
'strictness' => $strictness,
'files_scanned' => $filesScanned,
'findings' => array_values($findings),
'blocking_failures' => $blockingFailures,
'config_errors' => $configErrors,
'summary_by_rule' => self::summaryBy($findings, 'rule_id'),
'summary_by_result' => self::summaryBy($findings, 'result'),
'summary_by_surface' => self::summaryBy($findings, 'surface'),
];
}
/**
* @param list<array<string, mixed>> $findings
* @return array<string, int>
*/
private static function summaryBy(array $findings, string $key): array
{
$summary = [];
foreach ($findings as $finding) {
$value = (string) ($finding[$key] ?? 'unknown');
$summary[$value] = ($summary[$value] ?? 0) + 1;
}
ksort($summary);
return $summary;
}
/**
* @return array<string, string>
*/
private static function configuredPathStatus(string $repoRoot): array
{
$status = [];
foreach (self::RUNTIME_SCAN_PATHS as $path) {
$status[$path] = is_dir($repoRoot.'/'.$path) ? 'available' : 'not available';
}
return $status;
}
/**
* @return array<string, string>
*/
private static function absentCandidatePathStatus(string $repoRoot): array
{
$status = [];
foreach (self::ABSENT_CANDIDATE_PATHS as $path) {
$status[$path] = is_dir($repoRoot.'/'.$path) ? 'available' : 'not available';
}
return $status;
}
private static function lineForNeedle(string $source, string $needle): int
{
$offset = strpos($source, $needle);
return self::lineForOffset($source, $offset === false ? 0 : $offset);
}
private static function lineForOffset(string $source, int $offset): int
{
return substr_count(substr($source, 0, max(0, $offset)), "\n") + 1;
}
private static function relativePath(string $repoRoot, string $absolutePath): string
{
$repoRoot = rtrim(self::normalizePath($repoRoot), '/');
$absolutePath = self::normalizePath($absolutePath);
if (str_starts_with($absolutePath, $repoRoot.'/')) {
return substr($absolutePath, strlen($repoRoot) + 1);
}
return $absolutePath;
}
private static function normalizePath(string $path): string
{
return str_replace('\\', '/', $path);
}
}