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. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #446
811 lines
26 KiB
PHP
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);
|
|
}
|
|
}
|