Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m11s
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.
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);
|
|
}
|
|
}
|