> $allowlist * @return array */ 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 */ public static function configuredScanPaths(): array { return self::RUNTIME_SCAN_PATHS; } /** * @return list */ public static function absentCandidatePaths(): array { return self::ABSENT_CANDIDATE_PATHS; } /** * @return list */ 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 $absoluteFiles * @param list> $allowlist * @return array */ 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> $allowlist * @return array */ 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> $allowlist * @return list */ 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> $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> */ 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 */ 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(', ' */ 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> $allowlist * @return array */ 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> $findings * @param list $configErrors * @return array */ 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> $findings * @return array */ 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 */ 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 */ 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); } }