sprintf('%s/%s-latest.junit.xml', $directory, $laneId), 'summary' => sprintf('%s/%s-latest.summary.md', $directory, $laneId), 'budget' => sprintf('%s/%s-latest.budget.json', $directory, $laneId), 'report' => sprintf('%s/%s-latest.report.json', $directory, $laneId), 'profile' => sprintf('%s/%s-latest.profile.txt', $directory, $laneId), 'trendHistory' => sprintf('%s/%s-latest.trend-history.json', $directory, $laneId), ]; } /** * @return array */ public static function artifactPublicationStatus(string $laneId, ?string $artifactDirectory = null): array { $artifactPaths = self::artifactPaths($laneId, $artifactDirectory); $artifactFileMap = self::artifactFileMap($artifactPaths); $artifactContract = TestLaneManifest::artifactPublicationContract($laneId); $publishedArtifacts = []; $missingRequiredArtifacts = []; foreach (array_merge($artifactContract['requiredFiles'], $artifactContract['optionalFiles']) as $artifactFile) { if (! array_key_exists($artifactFile, $artifactFileMap)) { continue; } $relativePath = $artifactFileMap[$artifactFile]; $required = in_array($artifactFile, $artifactContract['requiredFiles'], true); $exists = is_file(TestLaneManifest::absolutePath($relativePath)); $publishedArtifacts[] = [ 'artifactType' => $artifactFile, 'relativePath' => $relativePath, 'required' => $required, 'exists' => $exists, ]; if ($required && ! $exists) { $missingRequiredArtifacts[] = $artifactFile; } } return [ 'contractId' => $artifactContract['contractId'], 'laneId' => $laneId, 'sourceDirectory' => $artifactContract['sourceDirectory'], 'requiredFiles' => $artifactContract['requiredFiles'], 'optionalFiles' => $artifactContract['optionalFiles'], 'publishedArtifacts' => $publishedArtifacts, 'missingRequiredArtifacts' => $missingRequiredArtifacts, 'complete' => $missingRequiredArtifacts === [], 'primaryFailureClassId' => $missingRequiredArtifacts === [] ? null : 'artifact-publication-failure', ]; } /** * @param array|null $artifactPublicationStatus * @param array|null $budgetOutcome */ public static function classifyPrimaryFailure( ?int $exitCode, ?array $artifactPublicationStatus = null, ?array $budgetOutcome = null, bool $entryPointResolved = true, bool $workflowLaneMatched = true, bool $infrastructureFailure = false, ): ?string { if ($infrastructureFailure) { return 'infrastructure-failure'; } if (! $entryPointResolved || ! $workflowLaneMatched) { return 'wrapper-failure'; } if ($exitCode !== null && $exitCode !== 0) { return 'test-failure'; } if (is_array($artifactPublicationStatus) && ($artifactPublicationStatus['complete'] ?? true) !== true) { return 'artifact-publication-failure'; } if (is_array($budgetOutcome) && ($budgetOutcome['budgetStatus'] ?? 'within-budget') !== 'within-budget') { return 'budget-breach'; } return null; } /** * @param array $report * @param array|null $budgetOutcome * @param array|null $artifactPublicationStatus * @return array */ public static function buildCiSummary( array $report, ?int $exitCode = 0, ?array $budgetOutcome = null, ?array $artifactPublicationStatus = null, bool $entryPointResolved = true, bool $workflowLaneMatched = true, bool $infrastructureFailure = false, ): array { $artifactPublicationStatus ??= self::artifactPublicationStatus((string) $report['laneId']); $budgetOutcome ??= is_array($report['ciBudgetEvaluation'] ?? null) ? $report['ciBudgetEvaluation'] : null; $primaryFailureClassId = self::classifyPrimaryFailure( exitCode: $exitCode, artifactPublicationStatus: $artifactPublicationStatus, budgetOutcome: $budgetOutcome, entryPointResolved: $entryPointResolved, workflowLaneMatched: $workflowLaneMatched, infrastructureFailure: $infrastructureFailure, ); $budgetStatus = (string) ($budgetOutcome['budgetStatus'] ?? $report['budgetStatus'] ?? 'within-budget'); $blockingStatus = match ($primaryFailureClassId) { 'test-failure', 'wrapper-failure', 'artifact-publication-failure', 'infrastructure-failure' => 'blocking', 'budget-breach' => (string) ($budgetOutcome['blockingStatus'] ?? 'non-blocking-warning'), default => 'informational', }; return [ 'runId' => (string) (getenv('GITEA_RUN_ID') ?: getenv('GITHUB_RUN_ID') ?: sprintf('local-%s', $report['laneId'])), 'workflowId' => (string) ($report['ciContext']['workflowId'] ?? sprintf('local-%s', $report['laneId'])), 'laneId' => (string) $report['laneId'], 'testStatus' => $exitCode === 0 ? 'passed' : 'failed', 'artifactStatus' => ($artifactPublicationStatus['complete'] ?? false) ? 'complete' : 'incomplete', 'budgetStatus' => $budgetStatus, 'blockingStatus' => $blockingStatus, 'primaryFailureClassId' => $primaryFailureClassId, 'publishedArtifacts' => $artifactPublicationStatus['publishedArtifacts'], ]; } /** * @return array */ public static function stageArtifacts(string $laneId, string $stagingDirectory, ?string $artifactDirectory = null): array { $artifactPaths = self::artifactPaths($laneId, $artifactDirectory); $artifactFileMap = self::artifactFileMap($artifactPaths); $artifactContract = TestLaneManifest::artifactPublicationContract($laneId); $stagingRoot = self::stagingRootPath($stagingDirectory); self::ensureDirectory($stagingRoot); $stagedArtifacts = []; $missingRequiredArtifacts = []; foreach (array_merge($artifactContract['requiredFiles'], $artifactContract['optionalFiles']) as $artifactFile) { if (! array_key_exists($artifactFile, $artifactFileMap)) { continue; } $required = in_array($artifactFile, $artifactContract['requiredFiles'], true); $sourceRelativePath = $artifactFileMap[$artifactFile]; $sourceAbsolutePath = TestLaneManifest::absolutePath($sourceRelativePath); if (! is_file($sourceAbsolutePath)) { if ($required) { $missingRequiredArtifacts[] = $artifactFile; } continue; } $stagedFileName = self::stagedArtifactName((string) $artifactContract['stagedNamePattern'], $laneId, $artifactFile); $targetAbsolutePath = $stagingRoot.DIRECTORY_SEPARATOR.$stagedFileName; copy($sourceAbsolutePath, $targetAbsolutePath); $stagedArtifacts[] = [ 'artifactType' => $artifactFile, 'relativePath' => str_starts_with($stagingDirectory, DIRECTORY_SEPARATOR) ? $targetAbsolutePath : trim($stagingDirectory, '/').'/'.$stagedFileName, 'required' => $required, ]; } return [ 'laneId' => $laneId, 'stagedArtifacts' => $stagedArtifacts, 'complete' => $missingRequiredArtifacts === [], 'primaryFailureClassId' => $missingRequiredArtifacts === [] ? null : 'artifact-publication-failure', 'missingRequiredArtifacts' => $missingRequiredArtifacts, 'uploadGroupName' => $artifactContract['uploadGroupName'], ]; } /** * @return array */ public static function hydrateTrendHistory( string $laneId, ?string $historyFile = null, ?string $bundlePath = null, ?string $artifactDirectory = null, ): array { $artifactPaths = self::artifactPaths($laneId, $artifactDirectory); $targetPath = TestLaneManifest::absolutePath($artifactPaths['trendHistory']); self::ensureDirectory(dirname($targetPath)); $resolvedHistoryFile = is_string($historyFile) && trim($historyFile) !== '' ? self::resolveInputPath($historyFile) : null; $resolvedBundlePath = is_string($bundlePath) && trim($bundlePath) !== '' ? self::resolveInputPath($bundlePath) : null; if (is_string($resolvedHistoryFile) && is_file($resolvedHistoryFile)) { copy($resolvedHistoryFile, $targetPath); return [ 'laneId' => $laneId, 'targetPath' => $targetPath, 'hydrated' => true, 'sourceType' => 'history-file', 'sourcePath' => $resolvedHistoryFile, ]; } if (is_string($resolvedBundlePath) && $resolvedBundlePath !== '') { if (is_dir($resolvedBundlePath)) { $bundleHistoryPath = self::findTrendHistoryInDirectory($laneId, $resolvedBundlePath); if (is_string($bundleHistoryPath)) { copy($bundleHistoryPath, $targetPath); return [ 'laneId' => $laneId, 'targetPath' => $targetPath, 'hydrated' => true, 'sourceType' => 'bundle-directory', 'sourcePath' => $bundleHistoryPath, ]; } } elseif (is_file($resolvedBundlePath) && str_ends_with(strtolower($resolvedBundlePath), '.zip')) { $zip = new ZipArchive(); if ($zip->open($resolvedBundlePath) === true) { $entryName = self::findTrendHistoryInZip($laneId, $zip); if (is_string($entryName)) { $contents = $zip->getFromName($entryName); $zip->close(); if (is_string($contents) && $contents !== '') { file_put_contents($targetPath, $contents); return [ 'laneId' => $laneId, 'targetPath' => $targetPath, 'hydrated' => true, 'sourceType' => 'bundle-zip', 'sourcePath' => $resolvedBundlePath, 'sourceEntry' => $entryName, ]; } } $zip->close(); } } } return [ 'laneId' => $laneId, 'targetPath' => $targetPath, 'hydrated' => false, 'sourceType' => null, 'sourcePath' => $resolvedHistoryFile ?? $resolvedBundlePath, ]; } /** * @return array{slowestEntries: list>, durationsByFile: array} */ public static function parseJUnit(string $filePath, string $laneId): array { if (! is_file($filePath)) { return [ 'slowestEntries' => [], 'durationsByFile' => [], ]; } $useInternalErrors = libxml_use_internal_errors(true); $xml = simplexml_load_file($filePath); libxml_clear_errors(); libxml_use_internal_errors($useInternalErrors); if (! $xml instanceof SimpleXMLElement) { return [ 'slowestEntries' => [], 'durationsByFile' => [], ]; } $slowestEntries = []; $durationsByFile = []; foreach ($xml->xpath('//testcase') ?: [] as $testcase) { $rawSubject = trim((string) ($testcase['file'] ?? '')); $subject = $rawSubject !== '' ? $rawSubject : trim((string) ($testcase['name'] ?? 'unknown-testcase')); $duration = round((float) ($testcase['time'] ?? 0.0), 6); $normalizedFile = explode('::', $subject)[0]; $slowestEntries[] = [ 'label' => $subject, 'subject' => $subject, 'filePath' => $normalizedFile, 'durationSeconds' => $duration, 'wallClockSeconds' => $duration, 'laneId' => $laneId, ]; $durationsByFile[$normalizedFile] = round(($durationsByFile[$normalizedFile] ?? 0.0) + $duration, 6); } usort($slowestEntries, static fn (array $left, array $right): int => $right['wallClockSeconds'] <=> $left['wallClockSeconds']); return [ 'slowestEntries' => array_slice($slowestEntries, 0, 10), 'durationsByFile' => $durationsByFile, ]; } /** * @param list> $slowestEntries * @param array $durationsByFile * @return array */ public static function buildReport( string $laneId, float $wallClockSeconds, array $slowestEntries, array $durationsByFile, ?string $artifactDirectory = null, ?string $comparisonProfile = null, ?array $ciContext = null, ): array { $lane = TestLaneManifest::lane($laneId); $heavyGovernanceContract = $laneId === 'heavy-governance' ? TestLaneManifest::heavyGovernanceBudgetContract($wallClockSeconds) : null; if (is_array($heavyGovernanceContract)) { $lane['budget']['thresholdSeconds'] = $heavyGovernanceContract['normalizedThresholdSeconds']; $lane['budget']['lifecycleState'] = $heavyGovernanceContract['lifecycleState']; } $laneBudget = TestLaneBudget::fromArray($lane['budget']); $laneBudgetEvaluation = $laneBudget->evaluate($wallClockSeconds); $resolvedCiContext = array_filter($ciContext ?? TestLaneManifest::currentCiContext($laneId), static fn (mixed $value): bool => $value !== null); $ciBudgetEvaluation = null; if (is_string($resolvedCiContext['triggerClass'] ?? null) && $resolvedCiContext['triggerClass'] !== '') { $ciBudgetEvaluation = TestLaneBudget::evaluateLaneForTrigger( $laneId, (string) $resolvedCiContext['triggerClass'], $wallClockSeconds, ); } $artifactPaths = self::artifactPaths($laneId, $artifactDirectory); $artifacts = []; foreach ($lane['artifacts'] as $artifactMode) { $relativePath = match ($artifactMode) { 'summary' => $artifactPaths['summary'], 'junit-xml' => $artifactPaths['junit'], 'profile-top' => $artifactPaths['profile'], 'budget-report' => $artifactPaths['budget'], default => null, }; if (! is_string($relativePath)) { continue; } $artifacts[] = [ 'artifactMode' => $artifactMode, 'relativePath' => $relativePath, 'machineReadable' => in_array($artifactMode, ['junit-xml', 'budget-report'], true), ]; } $attribution = self::buildAttribution($durationsByFile); $relevantBudgetTargets = self::relevantBudgetTargets( $laneId, $attribution['classificationTotals'], $attribution['familyTotals'], ); if (is_array($heavyGovernanceContract)) { $relevantBudgetTargets = array_values(array_map( static function (array $budgetTarget) use ($heavyGovernanceContract): array { if (($budgetTarget['targetType'] ?? null) !== 'lane' || ($budgetTarget['targetId'] ?? null) !== 'heavy-governance') { return $budgetTarget; } $budgetTarget['thresholdSeconds'] = $heavyGovernanceContract['normalizedThresholdSeconds']; $budgetTarget['lifecycleState'] = $heavyGovernanceContract['lifecycleState']; return $budgetTarget; }, $relevantBudgetTargets, )); } $budgetEvaluations = TestLaneBudget::evaluateBudgetTargets( $relevantBudgetTargets, $wallClockSeconds, $attribution['classificationTotals'], $attribution['familyTotals'], ); $enrichedSlowestEntries = array_values(array_map( static function (array $entry) use ($attribution): array { $filePath = (string) ($entry['filePath'] ?? ''); $familyId = $attribution['fileToFamily'][$filePath] ?? null; $classificationId = $attribution['fileToClassification'][$filePath] ?? null; return array_filter(array_merge($entry, [ 'familyId' => $familyId, 'classificationId' => $classificationId, ]), static fn (mixed $value): bool => $value !== null); }, $slowestEntries, )); $heavyGovernanceContext = $laneId === 'heavy-governance' ? self::buildHeavyGovernanceContext( budgetContract: $heavyGovernanceContract ?? TestLaneManifest::heavyGovernanceBudgetContract(), wallClockSeconds: $wallClockSeconds, artifactPaths: [ 'summary' => $artifactPaths['summary'], 'budget' => $artifactPaths['budget'], 'report' => $artifactPaths['report'], ], classificationAttribution: $attribution['classificationAttribution'], familyAttribution: $attribution['familyAttribution'], slowestEntries: $enrichedSlowestEntries, ) : []; $report = [ 'laneId' => $laneId, 'artifactDirectory' => trim($artifactDirectory ?? TestLaneManifest::artifactDirectory(), '/'), 'finishedAt' => gmdate('c'), 'wallClockSeconds' => round($wallClockSeconds, 6), 'budgetThresholdSeconds' => $laneBudget->thresholdSeconds, 'budgetBaselineSource' => $laneBudget->baselineSource, 'budgetEnforcement' => $laneBudget->enforcement, 'budgetLifecycleState' => $laneBudget->lifecycleState, 'budgetStatus' => $laneBudgetEvaluation['budgetStatus'], 'slowestEntries' => $enrichedSlowestEntries, 'classificationAttribution' => $attribution['classificationAttribution'], 'familyAttribution' => $attribution['familyAttribution'], 'budgetEvaluations' => $budgetEvaluations, 'familyBudgetEvaluations' => array_values(array_filter( $budgetEvaluations, static fn (array $evaluation): bool => ($evaluation['targetType'] ?? null) === 'family', )), 'artifacts' => $artifacts, 'artifactPublicationContract' => TestLaneManifest::artifactPublicationContract($laneId), 'knownWorkflowProfiles' => array_values(array_map( static fn (array $workflowProfile): string => (string) $workflowProfile['workflowId'], TestLaneManifest::workflowProfilesForLane($laneId), )), 'failureClasses' => TestLaneManifest::failureClasses(), ]; if ($heavyGovernanceContext !== []) { $report = array_merge($report, $heavyGovernanceContext); } if ($laneBudget->baselineDeltaTargetPercent !== null) { $report['baselineDeltaTargetPercent'] = $laneBudget->baselineDeltaTargetPercent; } $comparison = self::buildSharedFixtureSlimmingComparison($laneId, $wallClockSeconds, $comparisonProfile); if ($comparison !== null) { $report['sharedFixtureSlimmingComparison'] = $comparison; } if ($resolvedCiContext !== []) { $report['ciContext'] = $resolvedCiContext; } if ($ciBudgetEvaluation !== null) { $report['ciBudgetEvaluation'] = $ciBudgetEvaluation; } $trendHistoryArtifact = self::buildTrendHistoryArtifact($report, $artifactPaths); $report['trendHistoryArtifact'] = $trendHistoryArtifact; $report['trendCurrentAssessment'] = $trendHistoryArtifact['currentAssessment']; $report['trendHotspotSnapshot'] = $trendHistoryArtifact['hotspotSnapshot'] ?? null; $report['trendRecalibrationDecisions'] = $trendHistoryArtifact['recalibrationDecisions'] ?? []; $report['trendWarnings'] = $trendHistoryArtifact['warnings'] ?? []; return $report; } /** * @param array $report * @return array{summary: string, budget: string, report: string, profile: string, trendHistory: string} */ public static function writeArtifacts( string $laneId, array $report, ?string $profileOutput = null, ?string $artifactDirectory = null, ?int $exitCode = 0, ): array { $artifactPaths = self::artifactPaths($laneId, $artifactDirectory); self::ensureDirectory(dirname(TestLaneManifest::absolutePath($artifactPaths['summary']))); file_put_contents( TestLaneManifest::absolutePath($artifactPaths['summary']), self::buildSummaryMarkdown($report), ); if (is_string($profileOutput) && trim($profileOutput) !== '') { file_put_contents(TestLaneManifest::absolutePath($artifactPaths['profile']), $profileOutput); } file_put_contents( TestLaneManifest::absolutePath($artifactPaths['budget']), json_encode(self::budgetPayload($report), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), ); file_put_contents( TestLaneManifest::absolutePath($artifactPaths['report']), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), ); file_put_contents( TestLaneManifest::absolutePath($artifactPaths['trendHistory']), json_encode($report['trendHistoryArtifact'] ?? [], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), ); $report['artifactPublication'] = self::artifactPublicationStatus($laneId, $artifactDirectory); $report['ciSummary'] = self::buildCiSummary( report: $report, exitCode: $exitCode, budgetOutcome: is_array($report['ciBudgetEvaluation'] ?? null) ? $report['ciBudgetEvaluation'] : null, artifactPublicationStatus: $report['artifactPublication'], entryPointResolved: (bool) ($report['ciContext']['entryPointResolved'] ?? true), workflowLaneMatched: (bool) ($report['ciContext']['workflowLaneMatched'] ?? true), ); file_put_contents( TestLaneManifest::absolutePath($artifactPaths['summary']), self::buildSummaryMarkdown($report), ); file_put_contents( TestLaneManifest::absolutePath($artifactPaths['budget']), json_encode(self::budgetPayload($report), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), ); file_put_contents( TestLaneManifest::absolutePath($artifactPaths['report']), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), ); return $artifactPaths; } /** * @return array */ public static function finalizeLane( string $laneId, float $wallClockSeconds, string $capturedOutput = '', ?string $comparisonProfile = null, ?int $exitCode = 0, ): array { $artifactPaths = self::artifactPaths($laneId); $parsed = self::parseJUnit(TestLaneManifest::absolutePath($artifactPaths['junit']), $laneId); $ciContext = TestLaneManifest::currentCiContext($laneId); $report = self::buildReport( laneId: $laneId, wallClockSeconds: $wallClockSeconds, slowestEntries: $parsed['slowestEntries'], durationsByFile: $parsed['durationsByFile'], comparisonProfile: $comparisonProfile, ciContext: $ciContext, ); self::writeArtifacts($laneId, $report, $capturedOutput, exitCode: $exitCode); return $report; } /** * @param array $report */ private static function buildSummaryMarkdown(array $report): string { $lines = [ '# Test Lane Summary', '', sprintf('- Lane: %s', $report['laneId']), sprintf('- Finished: %s', $report['finishedAt']), sprintf('- Wall clock: %.2f seconds', (float) $report['wallClockSeconds']), sprintf('- Budget: %d seconds (%s)', (int) $report['budgetThresholdSeconds'], $report['budgetStatus']), ]; $trendHistory = is_array($report['trendHistoryArtifact'] ?? null) ? $report['trendHistoryArtifact'] : null; $trendAssessment = is_array($report['trendCurrentAssessment'] ?? null) ? $report['trendCurrentAssessment'] : ($trendHistory['currentAssessment'] ?? null); $currentTrendRecord = is_array($trendHistory['history'][0] ?? null) ? $trendHistory['history'][0] : null; $previousTrendRecord = is_array($trendHistory['history'][1] ?? null) ? $trendHistory['history'][1] : null; if (isset($report['ciSummary']) && is_array($report['ciSummary'])) { $lines[] = sprintf( '- CI outcome: %s / %s', (string) $report['ciSummary']['testStatus'], (string) $report['ciSummary']['blockingStatus'], ); if (is_string($report['ciSummary']['primaryFailureClassId'] ?? null)) { $lines[] = sprintf('- Primary failure class: %s', (string) $report['ciSummary']['primaryFailureClassId']); } } if (($report['laneId'] ?? null) === 'heavy-governance' && isset($report['budgetContract']) && is_array($report['budgetContract'])) { $lines[] = sprintf( '- Budget contract: %.0f seconds (%s)', (float) $report['budgetContract']['normalizedThresholdSeconds'], (string) ($report['budgetOutcome']['decisionStatus'] ?? $report['budgetContract']['decisionStatus'] ?? 'pending'), ); $lines[] = sprintf( '- Legacy drift signal: %.0f seconds (pre-normalization evidence)', (float) $report['budgetContract']['evaluationThresholdSeconds'], ); if (isset($report['budgetOutcome']['deltaSeconds'], $report['budgetOutcome']['deltaPercent'])) { $lines[] = sprintf( '- Baseline delta: %+0.2f seconds (%+0.2f%%)', (float) $report['budgetOutcome']['deltaSeconds'], (float) $report['budgetOutcome']['deltaPercent'], ); } if (isset($report['inventoryCoverage']) && is_array($report['inventoryCoverage'])) { $lines[] = sprintf( '- Inventory coverage: %.2f%% across %d required families (%s)', (float) $report['inventoryCoverage']['coveredRuntimePercent'], (int) $report['inventoryCoverage']['requiredFamilyCount'], (bool) $report['inventoryCoverage']['meetsInclusionRule'] ? 'meets inclusion rule' : 'missing required families', ); } } if (isset($report['sharedFixtureSlimmingComparison']) && is_array($report['sharedFixtureSlimmingComparison'])) { $comparison = $report['sharedFixtureSlimmingComparison']; $lines[] = sprintf( '- Shared fixture slimming baseline: %.2f seconds (%s, %+0.2f%%)', (float) $comparison['baselineSeconds'], (string) $comparison['status'], (float) $comparison['deltaPercent'], ); } $lines[] = ''; $lines[] = '## Lane trend'; if (is_array($trendAssessment)) { $previousRuntime = is_array($previousTrendRecord) ? sprintf('%.2fs', (float) ($previousTrendRecord['wallClockSeconds'] ?? 0.0)) : 'n/a'; $baselineRuntime = isset($currentTrendRecord['baselineSeconds']) && $currentTrendRecord['baselineSeconds'] !== null ? sprintf('%.2fs', (float) $currentTrendRecord['baselineSeconds']) : 'n/a'; $lines[] = sprintf( '- Window: current %.2fs | previous %s | baseline %s | budget %.2fs', (float) $report['wallClockSeconds'], $previousRuntime, $baselineRuntime, (float) $report['budgetThresholdSeconds'], ); $lines[] = sprintf('- Health class: %s', (string) $trendAssessment['healthClass']); $lines[] = sprintf('- Window status: %s', (string) $trendAssessment['windowStatus']); $lines[] = sprintf('- Recalibration recommendation: %s', (string) $trendAssessment['recalibrationRecommendation']); $lines[] = sprintf('- Budget headroom: %.2fs', (float) $trendAssessment['budgetHeadroomSeconds']); $lines[] = sprintf('- Summary: %s', (string) $trendAssessment['summaryLine']); if (array_key_exists('deltaToPreviousSeconds', $trendAssessment) && $trendAssessment['deltaToPreviousSeconds'] !== null) { $lines[] = sprintf('- Delta to previous: %+0.2fs', (float) $trendAssessment['deltaToPreviousSeconds']); } if (array_key_exists('deltaToBaselineSeconds', $trendAssessment) && $trendAssessment['deltaToBaselineSeconds'] !== null) { $lines[] = sprintf('- Delta to baseline: %+0.2fs', (float) $trendAssessment['deltaToBaselineSeconds']); } } else { $lines[] = '- Trend assessment unavailable.'; } $hotspotSnapshot = is_array($report['trendHotspotSnapshot'] ?? null) ? $report['trendHotspotSnapshot'] : ($trendHistory['hotspotSnapshot'] ?? null); if (is_array($hotspotSnapshot)) { $lines[] = sprintf('- Hotspot evidence: %s', (string) $hotspotSnapshot['evidenceAvailability']); foreach (array_slice($hotspotSnapshot['familyDeltas'] ?? [], 0, 3) as $delta) { $lines[] = sprintf( '- Family delta: %s %+0.2fs', (string) $delta['name'], (float) $delta['deltaSeconds'], ); } } foreach ($report['trendRecalibrationDecisions'] ?? [] as $decision) { $lines[] = sprintf( '- Recalibration: %s %s (%s)', (string) $decision['targetType'], (string) $decision['decisionStatus'], (string) $decision['rationaleCode'], ); } foreach ($report['trendWarnings'] ?? [] as $warning) { $lines[] = sprintf('- Warning: %s', $warning); } $lines[] = ''; $lines[] = '## Slowest entries'; foreach ($report['slowestEntries'] as $entry) { $label = (string) ($entry['label'] ?? $entry['subject'] ?? 'unknown'); $lines[] = sprintf('- %s (%.2fs)', $label, (float) ($entry['wallClockSeconds'] ?? $entry['durationSeconds'] ?? 0.0)); } if (($report['classificationAttribution'] ?? []) !== []) { $lines[] = ''; $lines[] = '## Classification attribution'; foreach ($report['classificationAttribution'] as $entry) { $lines[] = sprintf('- %s (%.2fs)', $entry['classificationId'], (float) $entry['totalWallClockSeconds']); } } if (($report['familyAttribution'] ?? []) !== []) { $lines[] = ''; $lines[] = '## Family attribution'; foreach ($report['familyAttribution'] as $entry) { $lines[] = sprintf('- %s [%s] (%.2fs)', $entry['familyId'], $entry['classificationId'], (float) $entry['totalWallClockSeconds']); } } return implode(PHP_EOL, $lines).PHP_EOL; } /** * @return array */ private static function buildAttribution(array $durationsByFile): array { $classificationTotals = []; $familyTotals = []; $classificationHotspots = []; $familyHotspots = []; $fileToClassification = []; $fileToFamily = []; foreach ($durationsByFile as $filePath => $duration) { $family = TestLaneManifest::familyForFile($filePath); $mixedResolution = TestLaneManifest::mixedFileResolution($filePath); $classificationId = $mixedResolution['primaryClassificationId'] ?? ($family['classificationId'] ?? null); if (! is_string($classificationId) || $classificationId === '') { continue; } $classificationTotals[$classificationId] = round(($classificationTotals[$classificationId] ?? 0.0) + $duration, 6); $classificationHotspots[$classificationId][] = $filePath; $fileToClassification[$filePath] = $classificationId; if (is_array($family)) { $familyId = (string) $family['familyId']; $familyTotals[$familyId] = round(($familyTotals[$familyId] ?? 0.0) + $duration, 6); $familyHotspots[$familyId]['classificationId'] = $family['classificationId']; $familyHotspots[$familyId]['hotspotFiles'][] = $filePath; $fileToFamily[$filePath] = $familyId; } } $classificationAttribution = array_values(array_map( static fn (string $classificationId, float $total): array => [ 'classificationId' => $classificationId, 'totalWallClockSeconds' => $total, 'hotspotFiles' => array_values(array_unique($classificationHotspots[$classificationId] ?? [])), ], array_keys($classificationTotals), $classificationTotals, )); usort($classificationAttribution, static fn (array $left, array $right): int => $right['totalWallClockSeconds'] <=> $left['totalWallClockSeconds']); $familyAttribution = array_values(array_map( static function (string $familyId, float $total) use ($familyHotspots): array { return [ 'familyId' => $familyId, 'classificationId' => $familyHotspots[$familyId]['classificationId'], 'totalWallClockSeconds' => $total, 'hotspotFiles' => array_values(array_unique($familyHotspots[$familyId]['hotspotFiles'] ?? [])), ]; }, array_keys($familyTotals), $familyTotals, )); usort($familyAttribution, static fn (array $left, array $right): int => $right['totalWallClockSeconds'] <=> $left['totalWallClockSeconds']); return [ 'classificationTotals' => $classificationTotals, 'familyTotals' => $familyTotals, 'classificationAttribution' => $classificationAttribution, 'familyAttribution' => $familyAttribution, 'fileToClassification' => $fileToClassification, 'fileToFamily' => $fileToFamily, ]; } /** * @param array $classificationTotals * @param array $familyTotals * @return list> */ private static function relevantBudgetTargets( string $laneId, array $classificationTotals, array $familyTotals, ): array { return array_values(array_filter( TestLaneManifest::budgetTargets(), static function (array $budgetTarget) use ($laneId, $classificationTotals, $familyTotals): bool { $targetType = (string) ($budgetTarget['targetType'] ?? ''); $targetId = (string) ($budgetTarget['targetId'] ?? ''); return match ($targetType) { 'lane' => $targetId === $laneId, 'classification' => array_key_exists($targetId, $classificationTotals), 'family' => array_key_exists($targetId, $familyTotals), default => false, }; }, )); } /** * @return array|null */ private static function buildSharedFixtureSlimmingComparison( string $laneId, float $wallClockSeconds, ?string $comparisonProfile, ): ?array { if ($comparisonProfile !== 'shared-test-fixture-slimming') { return null; } $baseline = TestLaneManifest::comparisonBaseline($comparisonProfile, $laneId); if (! is_array($baseline)) { return null; } $baselineSeconds = (float) $baseline['wallClockSeconds']; $deltaSeconds = round($wallClockSeconds - $baselineSeconds, 6); $deltaPercent = $baselineSeconds > 0 ? round(($deltaSeconds / $baselineSeconds) * 100, 6) : 0.0; $targetImprovementPercent = (int) ($baseline['targetImprovementPercent'] ?? 10); $maxRegressionPercent = (int) ($baseline['maxRegressionPercent'] ?? 5); $status = 'stable'; if ($deltaPercent <= -$targetImprovementPercent) { $status = 'improved'; } elseif ($deltaPercent > $maxRegressionPercent) { $status = 'regressed'; } return [ 'comparisonProfile' => $comparisonProfile, 'baselineFinishedAt' => (string) $baseline['finishedAt'], 'baselineSeconds' => $baselineSeconds, 'deltaSeconds' => $deltaSeconds, 'deltaPercent' => $deltaPercent, 'targetImprovementPercent' => $targetImprovementPercent, 'maxRegressionPercent' => $maxRegressionPercent, 'status' => $status, ]; } /** * @param list> $classificationAttribution * @param list> $familyAttribution * @param list> $slowestEntries * @param array{summary: string, budget: string, report: string} $artifactPaths * @param array $budgetContract * @return array */ private static function buildHeavyGovernanceContext( array $budgetContract, float $wallClockSeconds, array $artifactPaths, array $classificationAttribution, array $familyAttribution, array $slowestEntries, ): array { $inventory = TestLaneManifest::heavyGovernanceHotspotInventory(); $inventoryCoverage = self::buildHeavyGovernanceInventoryCoverage($familyAttribution, $inventory, $wallClockSeconds); $budgetSnapshots = [ TestLaneManifest::heavyGovernanceBudgetSnapshots()[0], [ 'snapshotId' => 'post-slimming', 'capturedAt' => gmdate('c'), 'wallClockSeconds' => round($wallClockSeconds, 6), 'classificationTotals' => array_map( static fn (array $entry): array => [ 'classificationId' => (string) $entry['classificationId'], 'totalWallClockSeconds' => round((float) $entry['totalWallClockSeconds'], 6), ], $classificationAttribution, ), 'familyTotals' => array_map( static fn (array $entry): array => [ 'familyId' => (string) $entry['familyId'], 'totalWallClockSeconds' => round((float) $entry['totalWallClockSeconds'], 6), ], $familyAttribution, ), 'slowestEntries' => array_map( static fn (array $entry): array => [ 'label' => (string) $entry['label'], 'wallClockSeconds' => round((float) ($entry['wallClockSeconds'] ?? 0.0), 6), ], $slowestEntries, ), 'artifactPaths' => $artifactPaths, 'budgetStatus' => (string) TestLaneBudget::evaluateGovernanceContract($budgetContract, $wallClockSeconds)['budgetStatus'], ], ]; $remainingOpenFamilies = array_values(array_map( static fn (array $record): string => $record['familyId'], array_filter( $inventory, static fn (array $record): bool => in_array($record['status'], ['retained', 'follow-up'], true), ), )); $stabilizedFamilies = array_values(array_map( static fn (array $record): string => $record['familyId'], array_filter( $inventory, static fn (array $record): bool => $record['status'] === 'slimmed', ), )); $followUpDebt = array_values(array_map( static fn (array $decision): string => $decision['familyId'], array_filter( TestLaneManifest::heavyGovernanceSlimmingDecisions(), static fn (array $decision): bool => in_array($decision['decisionType'], ['retain', 'follow-up'], true), ), )); $budgetOutcome = TestLaneBudget::buildOutcomeRecord( contract: $budgetContract, baselineSnapshot: $budgetSnapshots[0], currentSnapshot: $budgetSnapshots[1], remainingOpenFamilies: $remainingOpenFamilies, justification: $budgetContract['decisionStatus'] === 'recalibrated' ? sprintf( 'The primary workflow-heavy hotspots were slimmed, but the lane still retains intentional surface-guard depth and the workspace settings residual helper cost, so the authoritative threshold is now %.0fs.', $budgetContract['normalizedThresholdSeconds'], ) : 'The primary workflow-heavy hotspots slimmed enough duplicated work for the heavy-governance lane to recover within 300 seconds.', followUpDebt: $followUpDebt, ); return [ 'budgetContract' => $budgetContract, 'hotspotInventory' => $inventory, 'decompositionRecords' => TestLaneManifest::heavyGovernanceDecompositionRecords(), 'slimmingDecisions' => TestLaneManifest::heavyGovernanceSlimmingDecisions(), 'authorGuidance' => TestLaneManifest::heavyGovernanceAuthorGuidance(), 'inventoryCoverage' => $inventoryCoverage, 'budgetSnapshots' => $budgetSnapshots, 'budgetOutcome' => $budgetOutcome, 'remainingOpenFamilies' => $remainingOpenFamilies, 'stabilizedFamilies' => $stabilizedFamilies, ]; } /** * @param list> $familyAttribution * @param list> $inventory * @return array */ private static function buildHeavyGovernanceInventoryCoverage(array $familyAttribution, array $inventory, float $laneSeconds): array { $requiredFamilyIds = []; $coveredSeconds = 0.0; foreach ($familyAttribution as $entry) { $requiredFamilyIds[] = (string) $entry['familyId']; $coveredSeconds += (float) ($entry['totalWallClockSeconds'] ?? 0.0); if (count($requiredFamilyIds) >= 5 && ($laneSeconds <= 0.0 || ($coveredSeconds / $laneSeconds) >= 0.8)) { break; } } $inventoryFamilyIds = array_values(array_map(static fn (array $entry): string => $entry['familyId'], $inventory)); $coveredFamilyIds = array_values(array_intersect($requiredFamilyIds, $inventoryFamilyIds)); $coveredRuntimeSeconds = array_reduce( $familyAttribution, static function (float $carry, array $entry) use ($coveredFamilyIds): float { if (! in_array((string) $entry['familyId'], $coveredFamilyIds, true)) { return $carry; } return $carry + (float) ($entry['totalWallClockSeconds'] ?? 0.0); }, 0.0, ); $coveredRuntimePercent = $laneSeconds > 0.0 ? round(($coveredRuntimeSeconds / $laneSeconds) * 100, 6) : 0.0; return [ 'requiredFamilyCount' => count($requiredFamilyIds), 'requiredFamilyIds' => $requiredFamilyIds, 'inventoryFamilyCount' => count($inventoryFamilyIds), 'inventoryFamilyIds' => $inventoryFamilyIds, 'coveredFamilyIds' => $coveredFamilyIds, 'coveredRuntimeSeconds' => round($coveredRuntimeSeconds, 6), 'coveredRuntimePercent' => $coveredRuntimePercent, 'meetsInclusionRule' => count($coveredFamilyIds) === count($requiredFamilyIds), 'topHotspots' => array_slice($familyAttribution, 0, 10), ]; } /** * @param array $report * @return array */ private static function budgetPayload(array $report): array { return [ 'laneId' => $report['laneId'], 'artifactDirectory' => $report['artifactDirectory'], 'wallClockSeconds' => $report['wallClockSeconds'], 'budgetThresholdSeconds' => $report['budgetThresholdSeconds'], 'budgetBaselineSource' => $report['budgetBaselineSource'], 'budgetEnforcement' => $report['budgetEnforcement'], 'budgetLifecycleState' => $report['budgetLifecycleState'], 'budgetStatus' => $report['budgetStatus'], 'classificationAttribution' => $report['classificationAttribution'], 'familyAttribution' => $report['familyAttribution'], 'budgetEvaluations' => $report['budgetEvaluations'], 'familyBudgetEvaluations' => $report['familyBudgetEvaluations'], 'budgetContract' => $report['budgetContract'] ?? null, 'inventoryCoverage' => $report['inventoryCoverage'] ?? null, 'budgetSnapshots' => $report['budgetSnapshots'] ?? null, 'budgetOutcome' => $report['budgetOutcome'] ?? null, 'remainingOpenFamilies' => $report['remainingOpenFamilies'] ?? null, 'stabilizedFamilies' => $report['stabilizedFamilies'] ?? null, 'sharedFixtureSlimmingComparison' => $report['sharedFixtureSlimmingComparison'] ?? null, 'trendHistoryArtifact' => $report['trendHistoryArtifact'] ?? null, 'trendCurrentAssessment' => $report['trendCurrentAssessment'] ?? null, 'trendHotspotSnapshot' => $report['trendHotspotSnapshot'] ?? null, 'trendRecalibrationDecisions' => $report['trendRecalibrationDecisions'] ?? null, 'trendWarnings' => $report['trendWarnings'] ?? null, 'ciBudgetEvaluation' => $report['ciBudgetEvaluation'] ?? null, 'artifactPublication' => $report['artifactPublication'] ?? null, 'ciSummary' => $report['ciSummary'] ?? null, ]; } /** * @param array $report * @param array{junit: string, summary: string, budget: string, report: string, profile: string, trendHistory: string} $artifactPaths * @return array */ private static function buildTrendHistoryArtifact(array $report, array $artifactPaths): array { $laneId = (string) $report['laneId']; $existingArtifact = self::loadTrendHistoryArtifact($laneId, (string) $report['artifactDirectory']); $existingDecisions = array_values(array_filter( $existingArtifact['recalibrationDecisions'] ?? [], static fn (mixed $decision): bool => is_array($decision), )); $policy = TestLaneManifest::laneTrendPolicy( $laneId, $report['ciContext']['workflowId'] ?? null, $report['ciContext']['triggerClass'] ?? null, ); $baselineReference = self::resolveBaselineReference($report, $existingArtifact['history'] ?? [], $existingDecisions); $currentRecord = self::buildTrendRecord($report, $artifactPaths, $baselineReference); $history = self::mergeTrendHistory($currentRecord, $existingArtifact['history'] ?? [], (int) $policy['retentionLimit']); $comparisonWindow = self::buildComparisonWindow($currentRecord, $history, $policy); $assessment = self::buildTrendAssessment($currentRecord, $comparisonWindow, $policy); $hotspotSnapshot = self::buildHotspotSnapshot( $currentRecord, $comparisonWindow['previousComparableRecord'] ?? null, $policy, ); $recordedIn = 'specs/211-runtime-trend-recalibration/spec.md'; $recalibrationDecisions = self::mergeRecalibrationDecisions( $existingDecisions, TestLaneBudget::automaticRecalibrationDecisions($laneId, $assessment, $history, $recordedIn), ); $warnings = self::buildTrendWarnings($assessment, $hotspotSnapshot, $comparisonWindow); return [ 'schemaVersion' => TestLaneManifest::laneTrendContractVersion(), 'laneId' => $laneId, 'workflowProfile' => (string) $currentRecord['workflowId'], 'generatedAt' => (string) $report['finishedAt'], 'policy' => $policy, 'history' => $history, 'currentAssessment' => $assessment, 'hotspotSnapshot' => $hotspotSnapshot, 'recalibrationDecisions' => $recalibrationDecisions, 'warnings' => $warnings, ]; } /** * @param list> $history * @param list> $recalibrationDecisions * @return array{seconds: float|null, source: string|null} */ private static function resolveBaselineReference(array $report, array $history, array $recalibrationDecisions): array { foreach ($recalibrationDecisions as $decision) { if (($decision['targetType'] ?? null) !== 'baseline' || ($decision['decisionStatus'] ?? null) !== 'approved') { continue; } if (($decision['proposedValueSeconds'] ?? null) === null) { continue; } return [ 'seconds' => round((float) $decision['proposedValueSeconds'], 6), 'source' => (string) ($decision['recordedIn'] ?? 'approved-baseline'), ]; } if (is_array($report['sharedFixtureSlimmingComparison'] ?? null)) { return [ 'seconds' => round((float) $report['sharedFixtureSlimmingComparison']['baselineSeconds'], 6), 'source' => (string) ($report['sharedFixtureSlimmingComparison']['comparisonProfile'] ?? 'shared-fixture-baseline'), ]; } foreach (array_reverse($history) as $record) { if (! is_array($record)) { continue; } if (($record['baselineSeconds'] ?? null) !== null) { return [ 'seconds' => round((float) $record['baselineSeconds'], 6), 'source' => (string) ($record['baselineSource'] ?? 'trend-history-anchor'), ]; } if (($record['wallClockSeconds'] ?? null) !== null) { return [ 'seconds' => round((float) $record['wallClockSeconds'], 6), 'source' => 'trend-history-anchor', ]; } } return [ 'seconds' => null, 'source' => null, ]; } /** * @param array $report * @param array{junit: string, summary: string, budget: string, report: string, profile: string, trendHistory: string} $artifactPaths * @param array{seconds: float|null, source: string|null} $baselineReference * @return array */ private static function buildTrendRecord(array $report, array $artifactPaths, array $baselineReference): array { $laneId = (string) $report['laneId']; $workflowId = (string) ($report['ciContext']['workflowId'] ?? sprintf('local-%s', $laneId)); $triggerClass = (string) ($report['ciContext']['triggerClass'] ?? 'local'); $runRef = self::currentRunRef($laneId, (string) $report['finishedAt'], (float) $report['wallClockSeconds']); $budgetEvaluation = is_array($report['ciBudgetEvaluation'] ?? null) ? $report['ciBudgetEvaluation'] : [ 'budgetStatus' => (string) ($report['budgetStatus'] ?? 'within-budget'), 'blockingStatus' => 'informational', ]; return array_filter([ 'runRef' => $runRef, 'laneId' => $laneId, 'workflowId' => $workflowId, 'triggerClass' => $triggerClass, 'generatedAt' => (string) $report['finishedAt'], 'wallClockSeconds' => round((float) $report['wallClockSeconds'], 6), 'baselineSeconds' => $baselineReference['seconds'] !== null ? round((float) $baselineReference['seconds'], 6) : null, 'baselineSource' => $baselineReference['source'], 'budgetSeconds' => round((float) $report['budgetThresholdSeconds'], 6), 'budgetStatus' => (string) ($budgetEvaluation['budgetStatus'] ?? $report['budgetStatus'] ?? 'within-budget'), 'blockingStatus' => (string) ($budgetEvaluation['blockingStatus'] ?? 'informational'), 'comparisonFingerprint' => self::comparisonFingerprint($laneId, $workflowId, $triggerClass), 'classificationTotals' => self::runtimeBucketsFromAttribution( $report['classificationAttribution'] ?? [], 'classificationId', ), 'familyTotals' => self::runtimeBucketsFromAttribution( $report['familyAttribution'] ?? [], 'familyId', ), 'hotspotFiles' => self::hotspotFileBuckets($report['slowestEntries'] ?? []), 'slowestEntries' => self::trendSlowestEntries($report['slowestEntries'] ?? []), 'artifactRefs' => [ 'summary' => $artifactPaths['summary'], 'report' => $artifactPaths['report'], 'budget' => $artifactPaths['budget'], 'junit' => $artifactPaths['junit'], 'trendHistory' => $artifactPaths['trendHistory'], ], ], static fn (mixed $value): bool => $value !== null); } private static function currentRunRef(string $laneId, string $finishedAt, float $wallClockSeconds): string { $ciRunId = getenv('GITEA_RUN_ID') ?: getenv('GITHUB_RUN_ID') ?: null; if (is_string($ciRunId) && $ciRunId !== '') { return sprintf('%s-%s', $laneId, $ciRunId); } return sprintf( '%s-%s-%s', $laneId, str_replace([':', '+'], ['-', '_'], $finishedAt), str_replace('.', '-', sprintf('%0.6f', $wallClockSeconds)), ); } private static function comparisonFingerprint(string $laneId, string $workflowId, string $triggerClass): string { $inputs = TestLaneManifest::comparisonFingerprintInputs($laneId, $workflowId, $triggerClass); return sha1(json_encode($inputs, JSON_THROW_ON_ERROR)); } /** * @param array $currentRecord * @param list> $existingHistory * @return list> */ private static function mergeTrendHistory(array $currentRecord, array $existingHistory, int $retentionLimit): array { $merged = [$currentRecord]; $seenRunRefs = [(string) $currentRecord['runRef'] => true]; foreach ($existingHistory as $record) { if (! is_array($record)) { continue; } $runRef = (string) ($record['runRef'] ?? ''); if ($runRef === '' || array_key_exists($runRef, $seenRunRefs)) { continue; } $merged[] = $record; $seenRunRefs[$runRef] = true; if (count($merged) >= $retentionLimit) { break; } } return $merged; } /** * @param array $currentRecord * @param list> $history * @param array $policy * @return array */ private static function buildComparisonWindow(array $currentRecord, array $history, array $policy): array { $comparableRecords = []; $excludedRecords = []; $fingerprint = (string) $currentRecord['comparisonFingerprint']; $comparisonWindowSize = (int) $policy['comparisonWindowSize']; foreach ($history as $record) { if (($record['comparisonFingerprint'] ?? null) !== $fingerprint) { $excludedRecords[] = [ 'runRef' => (string) ($record['runRef'] ?? 'unknown'), 'comparisonFingerprint' => (string) ($record['comparisonFingerprint'] ?? ''), ]; continue; } $comparableRecords[] = $record; if (count($comparableRecords) >= $comparisonWindowSize) { break; } } $windowStatus = 'stable'; if (count($comparableRecords) < (int) $policy['minimumComparableSamples']) { $windowStatus = $excludedRecords !== [] ? 'scope-changed' : 'insufficient-history'; } return [ 'currentRecord' => $currentRecord, 'previousComparableRecord' => $comparableRecords[1] ?? null, 'comparableRecords' => $comparableRecords, 'excludedRecords' => $excludedRecords, 'windowStatus' => $windowStatus, 'sampleCount' => count($comparableRecords), ]; } /** * @param array $currentRecord * @param array $comparisonWindow * @param array $policy * @return array */ private static function buildTrendAssessment(array $currentRecord, array $comparisonWindow, array $policy): array { $comparableRecords = $comparisonWindow['comparableRecords']; $previousComparableRecord = $comparisonWindow['previousComparableRecord']; $sampleCount = (int) $comparisonWindow['sampleCount']; $varianceFloorSeconds = (float) $policy['varianceFloorSeconds']; $nearBudgetHeadroomSeconds = (float) $policy['nearBudgetHeadroomSeconds']; $currentSeconds = (float) $currentRecord['wallClockSeconds']; $budgetSeconds = (float) $currentRecord['budgetSeconds']; $budgetHeadroomSeconds = round($budgetSeconds - $currentSeconds, 6); $previousSeconds = is_array($previousComparableRecord) ? (float) ($previousComparableRecord['wallClockSeconds'] ?? 0.0) : null; $baselineSeconds = ($currentRecord['baselineSeconds'] ?? null) !== null ? (float) $currentRecord['baselineSeconds'] : null; $deltaToPreviousSeconds = $previousSeconds !== null ? round($currentSeconds - $previousSeconds, 6) : null; $deltaToBaselineSeconds = $baselineSeconds !== null ? round($currentSeconds - $baselineSeconds, 6) : null; $deltaToPreviousPercent = $previousSeconds !== null && $previousSeconds > 0.0 ? round(($deltaToPreviousSeconds / $previousSeconds) * 100, 6) : null; $deltaToBaselinePercent = $baselineSeconds !== null && $baselineSeconds > 0.0 ? round(($deltaToBaselineSeconds / $baselineSeconds) * 100, 6) : null; $worseningStreak = self::worseningStreak($comparableRecords, $varianceFloorSeconds); $varianceObservedSeconds = self::varianceObservedSeconds($comparableRecords); $windowStatus = (string) $comparisonWindow['windowStatus']; $noiseDetected = self::isNoisyWindow($comparableRecords, $varianceFloorSeconds); $stablePlateau = $sampleCount >= (int) $policy['minimumComparableSamples'] && $varianceObservedSeconds <= $varianceFloorSeconds && ($deltaToPreviousSeconds === null || abs($deltaToPreviousSeconds) <= $varianceFloorSeconds) && $deltaToBaselineSeconds !== null && abs($deltaToBaselineSeconds) > $varianceFloorSeconds; $healthClass = 'healthy'; $recalibrationRecommendation = 'none'; $summaryLine = 'Lane runtime is stable and comfortably inside the documented budget.'; if ($windowStatus !== 'stable') { $healthClass = 'unstable'; $recalibrationRecommendation = 'investigate'; $summaryLine = $windowStatus === 'scope-changed' ? 'Lane scope or workflow context changed, so older runs are not directly comparable yet.' : 'Lane history is still building, so trend classification remains intentionally unstable.'; } elseif ($noiseDetected) { $healthClass = 'unstable'; $windowStatus = 'noisy'; $recalibrationRecommendation = 'investigate'; $summaryLine = 'Recent samples disagree with each other, so the latest spike is treated as noise instead of a structural regression.'; } elseif ($budgetHeadroomSeconds < 0.0 && $worseningStreak >= 2) { $healthClass = 'regressed'; $recalibrationRecommendation = 'review-budget'; $summaryLine = 'Comparable runs show repeated worsening and the lane is now over budget.'; } elseif ($worseningStreak >= 2) { $healthClass = 'trending-worse'; $recalibrationRecommendation = $budgetHeadroomSeconds <= $nearBudgetHeadroomSeconds ? 'review-budget' : 'investigate'; $summaryLine = 'Comparable runs show sustained worsening above the documented variance floor.'; } elseif ($budgetHeadroomSeconds <= $nearBudgetHeadroomSeconds) { $healthClass = 'budget-near'; $recalibrationRecommendation = 'investigate'; $summaryLine = 'Lane runtime remains under budget, but headroom is now thin enough to warrant attention.'; } elseif ($stablePlateau) { $recalibrationRecommendation = 'review-baseline'; $summaryLine = 'Lane runtime has stabilized at a new level, so baseline review is reasonable if scope or infrastructure truth changed.'; } return [ 'healthClass' => $healthClass, 'recalibrationRecommendation' => $recalibrationRecommendation, 'budgetHeadroomSeconds' => $budgetHeadroomSeconds, 'deltaToPreviousSeconds' => $deltaToPreviousSeconds, 'deltaToPreviousPercent' => $deltaToPreviousPercent, 'deltaToBaselineSeconds' => $deltaToBaselineSeconds, 'deltaToBaselinePercent' => $deltaToBaselinePercent, 'worseningStreak' => $worseningStreak, 'varianceObservedSeconds' => $varianceObservedSeconds, 'windowStatus' => $windowStatus, 'sampleCount' => $sampleCount, 'previousComparableRunRef' => is_array($previousComparableRecord) ? (string) ($previousComparableRecord['runRef'] ?? '') : null, 'summaryLine' => $summaryLine, ]; } /** * @param array $currentRecord * @param array|null $previousComparableRecord * @param array $policy * @return array */ private static function buildHotspotSnapshot(array $currentRecord, ?array $previousComparableRecord, array $policy): array { if (! is_array($previousComparableRecord) || ($currentRecord['familyTotals'] ?? []) === [] || ($previousComparableRecord['familyTotals'] ?? []) === []) { return [ 'evidenceAvailability' => 'unavailable', 'familyDeltas' => [], 'fileHotspots' => [], 'newEntrants' => [], 'droppedEntrants' => [], ]; } $familyDeltas = self::deltaBuckets( $currentRecord['familyTotals'], $previousComparableRecord['familyTotals'] ?? [], (int) $policy['hotspotFamilyLimit'], ); $fileHotspots = self::deltaBuckets( $currentRecord['hotspotFiles'] ?? [], $previousComparableRecord['hotspotFiles'] ?? [], (int) $policy['hotspotFileLimit'], ); $currentHotspots = array_column($fileHotspots, 'name'); $previousHotspots = array_map( static fn (array $bucket): string => (string) ($bucket['name'] ?? ''), array_slice($previousComparableRecord['hotspotFiles'] ?? [], 0, (int) $policy['hotspotFileLimit']), ); return [ 'evidenceAvailability' => 'available', 'familyDeltas' => $familyDeltas, 'fileHotspots' => $fileHotspots, 'newEntrants' => array_values(array_diff($currentHotspots, $previousHotspots)), 'droppedEntrants' => array_values(array_diff($previousHotspots, $currentHotspots)), ]; } /** * @param array $assessment * @param array $hotspotSnapshot * @param array $comparisonWindow * @return list */ private static function buildTrendWarnings(array $assessment, array $hotspotSnapshot, array $comparisonWindow): array { $warnings = []; if (($assessment['windowStatus'] ?? 'stable') !== 'stable') { $warnings[] = sprintf('Trend window status is %s.', (string) $assessment['windowStatus']); } if (($hotspotSnapshot['evidenceAvailability'] ?? 'unavailable') !== 'available') { $warnings[] = 'Hotspot evidence is unavailable for this cycle.'; } if (($comparisonWindow['excludedRecords'] ?? []) !== []) { $warnings[] = 'One or more recent records were excluded because the comparison fingerprint changed.'; } return $warnings; } /** * @param list> $existingDecisions * @param list> $newDecisions * @return list> */ private static function mergeRecalibrationDecisions(array $existingDecisions, array $newDecisions): array { $merged = []; $seen = []; foreach (array_merge($newDecisions, $existingDecisions) as $decision) { if (! is_array($decision)) { continue; } $signature = implode('|', [ (string) ($decision['targetType'] ?? ''), (string) ($decision['decisionStatus'] ?? ''), (string) ($decision['rationaleCode'] ?? ''), (string) ($decision['recordedIn'] ?? ''), ]); if (isset($seen[$signature])) { continue; } $merged[] = $decision; $seen[$signature] = true; if (count($merged) >= 6) { break; } } return $merged; } /** * @param list> $attribution * @return list> */ private static function runtimeBucketsFromAttribution(array $attribution, string $nameKey): array { return array_values(array_map( static fn (array $entry): array => [ 'name' => (string) ($entry[$nameKey] ?? 'unknown'), 'runtimeSeconds' => round((float) ($entry['totalWallClockSeconds'] ?? 0.0), 6), ], $attribution, )); } /** * @param list> $slowestEntries * @return list> */ private static function hotspotFileBuckets(array $slowestEntries): array { $durations = []; foreach ($slowestEntries as $entry) { $file = (string) ($entry['filePath'] ?? ''); if ($file === '') { continue; } $durations[$file] = round(($durations[$file] ?? 0.0) + (float) ($entry['wallClockSeconds'] ?? $entry['durationSeconds'] ?? 0.0), 6); } arsort($durations); return array_values(array_map( static fn (string $file, float $seconds): array => [ 'name' => $file, 'runtimeSeconds' => $seconds, ], array_keys($durations), $durations, )); } /** * @param list> $slowestEntries * @return list> */ private static function trendSlowestEntries(array $slowestEntries): array { return array_values(array_map( static fn (array $entry): array => [ 'label' => (string) ($entry['label'] ?? $entry['subject'] ?? 'unknown'), 'runtimeSeconds' => round((float) ($entry['wallClockSeconds'] ?? $entry['durationSeconds'] ?? 0.0), 6), 'file' => isset($entry['filePath']) ? (string) $entry['filePath'] : null, ], array_slice($slowestEntries, 0, 10), )); } /** * @param list> $currentBuckets * @param list> $previousBuckets * @return list> */ private static function deltaBuckets(array $currentBuckets, array $previousBuckets, int $limit): array { $currentMap = []; $previousMap = []; foreach ($currentBuckets as $bucket) { $currentMap[(string) ($bucket['name'] ?? '')] = (float) ($bucket['runtimeSeconds'] ?? 0.0); } foreach ($previousBuckets as $bucket) { $previousMap[(string) ($bucket['name'] ?? '')] = (float) ($bucket['runtimeSeconds'] ?? 0.0); } $names = array_values(array_filter(array_unique(array_merge(array_keys($currentMap), array_keys($previousMap))))); $deltas = []; foreach ($names as $name) { $currentSeconds = round((float) ($currentMap[$name] ?? 0.0), 6); $previousSeconds = round((float) ($previousMap[$name] ?? 0.0), 6); $deltaSeconds = round($currentSeconds - $previousSeconds, 6); $deltaPercent = $previousSeconds > 0.0 ? round(($deltaSeconds / $previousSeconds) * 100, 6) : null; $deltas[] = [ 'name' => $name, 'currentSeconds' => $currentSeconds, 'previousSeconds' => $previousSeconds, 'deltaSeconds' => $deltaSeconds, 'deltaPercent' => $deltaPercent, 'direction' => $deltaSeconds > 0.0 ? 'up' : ($deltaSeconds < 0.0 ? 'down' : 'flat'), ]; } usort($deltas, static fn (array $left, array $right): int => abs((float) $right['deltaSeconds']) <=> abs((float) $left['deltaSeconds'])); return array_slice($deltas, 0, $limit); } /** * @param list> $comparableRecords */ private static function worseningStreak(array $comparableRecords, float $varianceFloorSeconds): int { $streak = 0; for ($index = 0; $index < count($comparableRecords) - 1; $index++) { $currentSeconds = (float) ($comparableRecords[$index]['wallClockSeconds'] ?? 0.0); $previousSeconds = (float) ($comparableRecords[$index + 1]['wallClockSeconds'] ?? 0.0); if (($currentSeconds - $previousSeconds) <= $varianceFloorSeconds) { break; } $streak++; } return $streak; } /** * @param list> $comparableRecords */ private static function varianceObservedSeconds(array $comparableRecords): float { $seconds = array_values(array_map( static fn (array $record): float => (float) ($record['wallClockSeconds'] ?? 0.0), $comparableRecords, )); if ($seconds === []) { return 0.0; } return round(max($seconds) - min($seconds), 6); } /** * @param list> $comparableRecords */ private static function isNoisyWindow(array $comparableRecords, float $varianceFloorSeconds): bool { if (count($comparableRecords) < 3) { return false; } $directions = []; for ($index = 0; $index < count($comparableRecords) - 1; $index++) { $currentSeconds = (float) ($comparableRecords[$index]['wallClockSeconds'] ?? 0.0); $previousSeconds = (float) ($comparableRecords[$index + 1]['wallClockSeconds'] ?? 0.0); $deltaSeconds = round($currentSeconds - $previousSeconds, 6); if (abs($deltaSeconds) <= $varianceFloorSeconds) { continue; } $directions[] = $deltaSeconds > 0.0 ? 'up' : 'down'; } return in_array('up', $directions, true) && in_array('down', $directions, true); } /** * @return array */ private static function loadTrendHistoryArtifact(string $laneId, ?string $artifactDirectory = null): array { $artifactPaths = self::artifactPaths($laneId, $artifactDirectory); $trendHistoryPath = TestLaneManifest::absolutePath($artifactPaths['trendHistory']); if (! is_file($trendHistoryPath)) { return []; } $decoded = json_decode((string) file_get_contents($trendHistoryPath), true); return is_array($decoded) ? $decoded : []; } private static function resolveInputPath(string $path): string { if (str_starts_with($path, DIRECTORY_SEPARATOR)) { return $path; } return self::repositoryRoot().DIRECTORY_SEPARATOR.ltrim($path, DIRECTORY_SEPARATOR); } private static function findTrendHistoryInDirectory(string $laneId, string $bundleDirectory): ?string { $candidates = [ sprintf('%s.trend-history.json', $laneId), sprintf('%s-latest.trend-history.json', $laneId), ]; foreach ($candidates as $candidate) { $candidatePath = rtrim($bundleDirectory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$candidate; if (is_file($candidatePath)) { return $candidatePath; } } $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($bundleDirectory)); foreach ($iterator as $file) { if (! $file->isFile()) { continue; } if (! in_array($file->getFilename(), $candidates, true)) { continue; } return $file->getPathname(); } return null; } private static function findTrendHistoryInZip(string $laneId, ZipArchive $zip): ?string { $candidates = [ sprintf('%s.trend-history.json', $laneId), sprintf('%s-latest.trend-history.json', $laneId), ]; for ($index = 0; $index < $zip->numFiles; $index++) { $entryName = $zip->getNameIndex($index); if (! is_string($entryName)) { continue; } foreach ($candidates as $candidate) { if (str_ends_with($entryName, $candidate)) { return $entryName; } } } return null; } /** * @param array{junit: string, summary: string, budget: string, report: string, profile: string, trendHistory: string} $artifactPaths * @return array */ private static function artifactFileMap(array $artifactPaths): array { return [ 'junit.xml' => $artifactPaths['junit'], 'summary.md' => $artifactPaths['summary'], 'budget.json' => $artifactPaths['budget'], 'report.json' => $artifactPaths['report'], 'profile.txt' => $artifactPaths['profile'], 'trend-history.json' => $artifactPaths['trendHistory'], ]; } private static function stagedArtifactName(string $pattern, string $laneId, string $artifactFile): string { return str_replace( ['{laneId}', '{artifactFile}'], [$laneId, $artifactFile], $pattern, ); } private static function stagingRootPath(string $stagingDirectory): string { if (str_starts_with($stagingDirectory, DIRECTORY_SEPARATOR)) { return $stagingDirectory; } return self::repositoryRoot().DIRECTORY_SEPARATOR.ltrim($stagingDirectory, '/'); } private static function repositoryRoot(): string { return TestLaneManifest::repoRoot(); } private static function ensureDirectory(string $directory): void { if (is_dir($directory)) { return; } mkdir($directory, 0777, true); } }