2322 lines
100 KiB
PHP
2322 lines
100 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Support;
|
|
|
|
use InvalidArgumentException;
|
|
use RecursiveDirectoryIterator;
|
|
use RecursiveIteratorIterator;
|
|
use SplFileInfo;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
final class TestLaneManifest
|
|
{
|
|
private const ARTIFACT_DIRECTORY = 'storage/logs/test-lanes';
|
|
|
|
private const FULL_SUITE_BASELINE_SECONDS = 2625;
|
|
|
|
private const COMMAND_REFS = [
|
|
'fast-feedback' => 'test',
|
|
'confidence' => 'test:confidence',
|
|
'browser' => 'test:browser',
|
|
'heavy-governance' => 'test:heavy',
|
|
'profiling' => 'test:profile',
|
|
'junit' => 'test:junit',
|
|
];
|
|
|
|
private const COMPARISON_BASELINES = [
|
|
'shared-test-fixture-slimming' => [
|
|
'fast-feedback' => [
|
|
'laneId' => 'fast-feedback',
|
|
'finishedAt' => '2026-04-16T13:11:57+00:00',
|
|
'wallClockSeconds' => 176.73623,
|
|
'budgetThresholdSeconds' => 200,
|
|
'targetImprovementPercent' => 10,
|
|
'maxRegressionPercent' => 5,
|
|
],
|
|
'confidence' => [
|
|
'laneId' => 'confidence',
|
|
'finishedAt' => '2026-04-16T13:11:57+00:00',
|
|
'wallClockSeconds' => 394.383441,
|
|
'budgetThresholdSeconds' => 450,
|
|
'targetImprovementPercent' => 10,
|
|
'maxRegressionPercent' => 5,
|
|
],
|
|
],
|
|
];
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function manifest(): array
|
|
{
|
|
return [
|
|
'version' => 1,
|
|
'artifactDirectory' => self::artifactDirectory(),
|
|
'classifications' => self::classifications(),
|
|
'families' => self::families(),
|
|
'mixedFileResolutions' => self::mixedFileResolutions(),
|
|
'placementRules' => self::placementRules(),
|
|
'driftGuards' => self::driftGuards(),
|
|
'budgetTargets' => self::budgetTargets(),
|
|
'lanes' => self::lanes(),
|
|
'familyBudgets' => self::familyBudgets(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public static function classifications(): array
|
|
{
|
|
return [
|
|
[
|
|
'classificationId' => 'ui-light',
|
|
'purpose' => 'Localized tenant or admin UI checks that keep narrow operator trust without broad discovery fan-out.',
|
|
'dominantCostDrivers' => ['single-mount', 'localized assertions'],
|
|
'defaultLaneId' => 'confidence',
|
|
'allowedLaneIds' => ['fast-feedback', 'confidence'],
|
|
'forbiddenLaneIds' => ['browser'],
|
|
'reviewerSignals' => [
|
|
'single page or table surface',
|
|
'no broad discovery or reflection pass',
|
|
'bounded assertion count',
|
|
],
|
|
'escalationTriggers' => [
|
|
'multi-surface workflow fan-out',
|
|
'relation-manager breadth',
|
|
'resource or action-surface discovery',
|
|
],
|
|
],
|
|
[
|
|
'classificationId' => 'ui-workflow',
|
|
'purpose' => 'Bounded operator workflows that still provide meaningful product trust but fan out across more than one surface or step.',
|
|
'dominantCostDrivers' => ['multi-mount workflow', 'fixture fan-out'],
|
|
'defaultLaneId' => 'confidence',
|
|
'allowedLaneIds' => ['confidence', 'heavy-governance'],
|
|
'forbiddenLaneIds' => ['fast-feedback', 'browser'],
|
|
'reviewerSignals' => [
|
|
'multi-step wizard or matrix flow',
|
|
'shared fixture graphs that stay inside one product workflow',
|
|
'direct operator trust value even when setup is dense',
|
|
],
|
|
'escalationTriggers' => [
|
|
'governance-wide discovery',
|
|
'repeated broad relation-manager scans',
|
|
'surface discipline across multiple unrelated resources',
|
|
],
|
|
],
|
|
[
|
|
'classificationId' => 'surface-guard',
|
|
'purpose' => 'Broad governance checks that validate action surfaces, relation-manager breadth, or navigation discipline across multiple components.',
|
|
'dominantCostDrivers' => ['surface-wide validation', 'relation-manager breadth', 'assertion density'],
|
|
'defaultLaneId' => 'heavy-governance',
|
|
'allowedLaneIds' => ['heavy-governance'],
|
|
'forbiddenLaneIds' => ['fast-feedback', 'confidence', 'browser'],
|
|
'reviewerSignals' => [
|
|
'governance contract or discipline test',
|
|
'multiple actions or surfaces validated together',
|
|
'broad relation-manager or navigation coverage',
|
|
],
|
|
'escalationTriggers' => [
|
|
'action inventory discovery',
|
|
'header or navigation discipline spanning many resources',
|
|
'matrix or relation-manager breadth beyond one local workflow',
|
|
],
|
|
],
|
|
[
|
|
'classificationId' => 'discovery-heavy',
|
|
'purpose' => 'Discovery, reflection, or remembered-context scans that widen cost as the Filament surface grows.',
|
|
'dominantCostDrivers' => ['resource discovery', 'reflection', 'broad search or registry scans'],
|
|
'defaultLaneId' => 'heavy-governance',
|
|
'allowedLaneIds' => ['heavy-governance'],
|
|
'forbiddenLaneIds' => ['fast-feedback', 'confidence', 'browser'],
|
|
'reviewerSignals' => [
|
|
'reflection-backed discovery',
|
|
'resource or global-search parity scans',
|
|
'remembered tenant or registry breadth dominating runtime',
|
|
],
|
|
'escalationTriggers' => [
|
|
'new resources or pages increase the touched surface',
|
|
'broad remembered-context validation',
|
|
'registry or relationship discovery beyond one feature',
|
|
],
|
|
],
|
|
[
|
|
'classificationId' => 'browser',
|
|
'purpose' => 'Real browser smoke and workflow coverage that remains fully isolated from non-browser lanes.',
|
|
'dominantCostDrivers' => ['real-browser interaction', 'end-to-end rendering'],
|
|
'defaultLaneId' => 'browser',
|
|
'allowedLaneIds' => ['browser'],
|
|
'forbiddenLaneIds' => ['fast-feedback', 'confidence', 'heavy-governance', 'profiling', 'junit'],
|
|
'reviewerSignals' => [
|
|
'visit() or browser assertions',
|
|
'real browser session state',
|
|
'JavaScript or DOM continuity checks',
|
|
],
|
|
'escalationTriggers' => [
|
|
'browser runtime or DOM interactivity is required',
|
|
'cross-page journey depends on real browser semantics',
|
|
'client-side behavior is part of the contract',
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function classification(string $classificationId): array
|
|
{
|
|
foreach (self::classifications() as $classification) {
|
|
if ($classification['classificationId'] === $classificationId) {
|
|
return $classification;
|
|
}
|
|
}
|
|
|
|
throw new InvalidArgumentException(sprintf('Unknown heavy test classification [%s].', $classificationId));
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public static function families(): array
|
|
{
|
|
return [
|
|
[
|
|
'familyId' => 'backup-set-admin-tenant-parity',
|
|
'classificationId' => 'ui-light',
|
|
'purpose' => 'Keep remembered admin tenant scoping honest on a localized backup-set listing surface.',
|
|
'currentLaneId' => 'confidence',
|
|
'targetLaneId' => 'confidence',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'ui-light',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Document the retained localized UI confidence anchor.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Filament/BackupSetAdminTenantParityTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Canonical ui-light confidence example for Spec 208.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/Filament/BackupSetAdminTenantParityTest.php',
|
|
],
|
|
'costSignals' => ['single-mount', 'remembered tenant state', 'localized table assertions'],
|
|
'confidenceRationale' => 'This remains in Confidence because it protects a narrow remembered-tenant table surface without broad discovery cost.',
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'baseline-compare-matrix-workflow',
|
|
'classificationId' => 'ui-workflow',
|
|
'purpose' => 'Validate the bounded baseline compare matrix workflow, including visible-assignment fan-out and dense matrix rendering.',
|
|
'currentLaneId' => 'confidence',
|
|
'targetLaneId' => 'confidence',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'ui-workflow',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Keep the matrix workflow explicit as retained confidence coverage.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Seeded matrix workflow action hotspot.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Seeded matrix workflow builder hotspot.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php',
|
|
'tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php',
|
|
'tests/Feature/Concerns/BuildsBaselineCompareMatrixFixtures.php',
|
|
],
|
|
'costSignals' => ['workflow fan-out', 'dense fixture graph', 'multi-tenant matrix rendering'],
|
|
'confidenceRationale' => 'The compare matrix remains a primary operator workflow and stays bounded to one feature surface even when fixtures are dense.',
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'onboarding-wizard-enforcement',
|
|
'classificationId' => 'ui-workflow',
|
|
'purpose' => 'Preserve the managed tenant onboarding wizard capability flow as an intentional retained workflow check.',
|
|
'currentLaneId' => 'confidence',
|
|
'targetLaneId' => 'confidence',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'ui-workflow',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Retain bounded wizard trust in Confidence with explicit classification.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Seeded onboarding workflow hotspot.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php',
|
|
],
|
|
'costSignals' => ['multi-step wizard flow', 'repeated Livewire mounts', 'workspace capability transitions'],
|
|
'confidenceRationale' => 'The onboarding wizard is a high-value operator path that should remain in Confidence despite its bounded workflow cost.',
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'finding-bulk-actions-workflow',
|
|
'classificationId' => 'ui-workflow',
|
|
'purpose' => 'Escalate the large finding bulk-action workflow once multi-action fan-out and audit density stop fitting the standard contributor loops.',
|
|
'currentLaneId' => 'confidence',
|
|
'targetLaneId' => 'heavy-governance',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'ui-workflow',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Keep the bulk finding workflow explicitly cataloged as workflow-heavy even after it leaves the standard lanes.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Findings/FindingBulkActionsTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Canonical high-fan-out finding bulk workflow hotspot.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/Findings/FindingBulkActionsTest.php',
|
|
],
|
|
'costSignals' => ['bulk table actions', 'multi-action workflow fan-out', 'high-volume audit assertions'],
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'drift-bulk-triage-all-matching',
|
|
'classificationId' => 'ui-workflow',
|
|
'purpose' => 'Escalate the all-matching drift triage confirmation flow once it operates over a large result set and broad action fan-out.',
|
|
'currentLaneId' => 'confidence',
|
|
'targetLaneId' => 'heavy-governance',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'ui-workflow',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Keep the all-matching drift triage confirmation flow classified as workflow-heavy while routing it intentionally to Heavy Governance.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Canonical all-matching drift triage hotspot.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php',
|
|
],
|
|
'costSignals' => ['all-matching bulk action', 'typed confirmation flow', 'large result-set workflow'],
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'baseline-profile-start-surfaces',
|
|
'classificationId' => 'ui-workflow',
|
|
'purpose' => 'Escalate the baseline profile capture and compare start surfaces once repeated header-action gating and queued-operation setup dominate runtime.',
|
|
'currentLaneId' => 'confidence',
|
|
'targetLaneId' => 'heavy-governance',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'ui-workflow',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Keep the baseline profile start surfaces classified as workflow-heavy while routing them intentionally to Heavy Governance.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Canonical baseline compare start-surface hotspot.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Canonical baseline capture start-surface hotspot.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Filament/BaselineActionAuthorizationTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Canonical baseline action authorization hotspot tied to the same start-surface family.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php',
|
|
'tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php',
|
|
'tests/Feature/Filament/BaselineActionAuthorizationTest.php',
|
|
],
|
|
'costSignals' => ['repeated Livewire page mounts', 'header action gating matrix', 'queued-operation launch workflow'],
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'workspace-settings-slice-management',
|
|
'classificationId' => 'ui-workflow',
|
|
'purpose' => 'Escalate the multi-slice workspace settings management surface once one test covers a broad save, reset, and resolver verification workflow across several settings domains.',
|
|
'currentLaneId' => 'confidence',
|
|
'targetLaneId' => 'heavy-governance',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'ui-workflow',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Keep the workspace settings management surface classified as a broad workflow-heavy check while routing it out of Confidence.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Canonical workspace settings multi-slice hotspot.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php',
|
|
],
|
|
'costSignals' => ['multi-slice form workflow', 'save-and-reset path coverage', 'post-write resolver verification fan-out'],
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'findings-workflow-surfaces',
|
|
'classificationId' => 'ui-workflow',
|
|
'purpose' => 'Escalate the remaining row, view, filter, and renewal findings workflow surfaces once their multi-step state transitions and evidence handling dominate confidence runtime.',
|
|
'currentLaneId' => 'confidence',
|
|
'targetLaneId' => 'heavy-governance',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'ui-workflow',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Keep the findings workflow surface explicitly cataloged as workflow-heavy while routing it out of Confidence.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Findings/FindingWorkflowRowActionsTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Canonical row-action workflow hotspot.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Findings/FindingWorkflowViewActionsTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Canonical view-header workflow hotspot.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Findings/FindingsListFiltersTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Canonical findings filter workflow hotspot.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Findings/FindingExceptionRenewalTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Canonical exception renewal workflow hotspot.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/Findings/FindingWorkflowRowActionsTest.php',
|
|
'tests/Feature/Findings/FindingWorkflowViewActionsTest.php',
|
|
'tests/Feature/Findings/FindingsListFiltersTest.php',
|
|
'tests/Feature/Findings/FindingExceptionRenewalTest.php',
|
|
],
|
|
'costSignals' => ['multi-step workflow transitions', 'filter-state persistence', 'evidence and audit verification'],
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'workspace-only-admin-surface-independence',
|
|
'classificationId' => 'surface-guard',
|
|
'purpose' => 'Escalate cross-resource workspace-only admin surface independence checks because they validate broad remembered-tenant behavior across several Filament resources together.',
|
|
'currentLaneId' => 'confidence',
|
|
'targetLaneId' => 'heavy-governance',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'surface-guard',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Treat workspace-only cross-resource independence as a broad surface guard.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Filament/WorkspaceOnlySurfaceTenantIndependenceTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Canonical workspace-only admin surface independence hotspot.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/Filament/WorkspaceOnlySurfaceTenantIndependenceTest.php',
|
|
],
|
|
'costSignals' => ['cross-resource surface assertions', 'remembered tenant context variance', 'workspace-only admin invariants'],
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'action-surface-contract',
|
|
'classificationId' => 'surface-guard',
|
|
'purpose' => 'Guard broad action-surface discovery and declaration discipline across pages, resources, and relation managers.',
|
|
'currentLaneId' => 'heavy-governance',
|
|
'targetLaneId' => 'heavy-governance',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'surface-guard',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Flag broad governance validation separately from retained workflows.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Guards/ActionSurfaceContractTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Canonical seeded surface-guard hotspot.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/Guards/ActionSurfaceContractTest.php',
|
|
],
|
|
'costSignals' => ['resource discovery', 'surface-wide validation', 'broad assertion density'],
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'policy-resource-admin-search-parity',
|
|
'classificationId' => 'discovery-heavy',
|
|
'purpose' => 'Verify remembered canonical admin tenant state never leaks policy global-search discovery.',
|
|
'currentLaneId' => 'confidence',
|
|
'targetLaneId' => 'heavy-governance',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'discovery-heavy',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Explicitly classify broad admin search parity as discovery-heavy.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Seeded policy resource discovery hotspot.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php',
|
|
],
|
|
'costSignals' => ['reflection', 'global-search discovery', 'remembered admin tenant context'],
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'policy-version-admin-search-parity',
|
|
'classificationId' => 'discovery-heavy',
|
|
'purpose' => 'Keep policy-version admin search parity classified as discovery-heavy when remembered context broadens the surface.',
|
|
'currentLaneId' => 'confidence',
|
|
'targetLaneId' => 'heavy-governance',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'discovery-heavy',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Treat policy-version parity as the same discovery-heavy family class as resource parity.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Seeded policy-version discovery hotspot.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php',
|
|
],
|
|
'costSignals' => ['reflection', 'global-search discovery', 'remembered admin tenant context'],
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'backup-items-relation-manager-enforcement',
|
|
'classificationId' => 'surface-guard',
|
|
'purpose' => 'Protect broad relation-manager authorization affordances for backup item actions from drifting into lighter lanes.',
|
|
'currentLaneId' => 'confidence',
|
|
'targetLaneId' => 'heavy-governance',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'surface-guard',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Relation-manager breadth belongs to the surface-guard class.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Seeded backup items relation-manager hotspot.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php',
|
|
],
|
|
'costSignals' => ['relation-manager breadth', 'table action matrix', 'tenant capability transitions'],
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'workspace-memberships-relation-manager-enforcement',
|
|
'classificationId' => 'surface-guard',
|
|
'purpose' => 'Keep workspace membership relation-manager action discipline classified as broad surface governance.',
|
|
'currentLaneId' => 'confidence',
|
|
'targetLaneId' => 'heavy-governance',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'surface-guard',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Workspace membership relation-manager breadth is a deliberate surface guard.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Rbac/WorkspaceMembershipsRelationManagerUiEnforcementTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Seeded workspace memberships relation-manager hotspot.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/Rbac/WorkspaceMembershipsRelationManagerUiEnforcementTest.php',
|
|
],
|
|
'costSignals' => ['relation-manager breadth', 'action visibility matrix', 'workspace membership transitions'],
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'tenant-review-header-discipline',
|
|
'classificationId' => 'surface-guard',
|
|
'purpose' => 'Enforce primary and grouped header action discipline for tenant review pages.',
|
|
'currentLaneId' => 'confidence',
|
|
'targetLaneId' => 'heavy-governance',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'surface-guard',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Header action discipline spans a broad action surface.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Seeded header discipline hotspot.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php',
|
|
],
|
|
'costSignals' => ['header action discipline', 'grouped action inventory', 'multi-state surface assertions'],
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'panel-navigation-segregation',
|
|
'classificationId' => 'surface-guard',
|
|
'purpose' => 'Keep panel navigation segregation and tenant-sensitive registration checks in the heavy governance lane.',
|
|
'currentLaneId' => 'confidence',
|
|
'targetLaneId' => 'heavy-governance',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'surface-guard',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Navigation discipline is a broad surface-guard concern.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Feature/Filament/PanelNavigationSegregationTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Seeded navigation discipline hotspot.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/Filament/PanelNavigationSegregationTest.php',
|
|
],
|
|
'costSignals' => ['navigation registration breadth', 'panel boot', 'tenant-sensitive surface assertions'],
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'ops-ux-governance',
|
|
'classificationId' => 'surface-guard',
|
|
'purpose' => 'Keep established Ops UX, alert header, and credential leak governance checks under the heavy-governance umbrella.',
|
|
'currentLaneId' => 'heavy-governance',
|
|
'targetLaneId' => 'heavy-governance',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'surface-guard',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Treat the established Ops UX batch as broad governance coverage.',
|
|
],
|
|
[
|
|
'selectorType' => 'path',
|
|
'selectorValue' => 'tests/Feature/OpsUx',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Existing broad Ops UX directory remains heavy-governance owned.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php',
|
|
'tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php',
|
|
'tests/Feature/ProviderConnections/CredentialLeakGuardTest.php',
|
|
],
|
|
'costSignals' => ['ops-ux governance breadth', 'cross-surface workflow coverage', 'alert and credential guard fan-out'],
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
[
|
|
'familyId' => 'browser-smoke',
|
|
'classificationId' => 'browser',
|
|
'purpose' => 'Isolate browser smoke and real DOM continuity checks in the dedicated browser lane.',
|
|
'currentLaneId' => 'browser',
|
|
'targetLaneId' => 'browser',
|
|
'selectors' => [
|
|
[
|
|
'selectorType' => 'group',
|
|
'selectorValue' => 'browser',
|
|
'selectorRole' => 'include',
|
|
'sourceOfTruth' => 'pest-group',
|
|
'rationale' => 'Keep browser ownership explicit at the group layer.',
|
|
],
|
|
[
|
|
'selectorType' => 'path',
|
|
'selectorValue' => 'tests/Browser',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Attribute the browser lane to its dedicated suite path.',
|
|
],
|
|
[
|
|
'selectorType' => 'file',
|
|
'selectorValue' => 'tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php',
|
|
'selectorRole' => 'inventory-only',
|
|
'sourceOfTruth' => 'manifest',
|
|
'rationale' => 'Seeded browser hotspot for Spec 208.',
|
|
],
|
|
],
|
|
'hotspotFiles' => [
|
|
'tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php',
|
|
],
|
|
'costSignals' => ['real-browser interaction', 'dense DOM continuity', 'end-to-end smoke'],
|
|
'validationStatus' => 'guarded',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function family(string $familyId): array
|
|
{
|
|
foreach (self::families() as $family) {
|
|
if ($family['familyId'] === $familyId) {
|
|
return $family;
|
|
}
|
|
}
|
|
|
|
throw new InvalidArgumentException(sprintf('Unknown heavy test family [%s].', $familyId));
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public static function familiesByTargetLane(string $laneId): array
|
|
{
|
|
return array_values(array_filter(
|
|
self::families(),
|
|
static fn (array $family): bool => $family['targetLaneId'] === $laneId,
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public static function mixedFileResolutions(): array
|
|
{
|
|
return [
|
|
[
|
|
'filePath' => 'tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php',
|
|
'primaryClassificationId' => 'discovery-heavy',
|
|
'secondaryClassificationIds' => ['ui-light'],
|
|
'resolutionStrategy' => 'broadest-cost-wins',
|
|
'rationale' => 'The file reads like a narrow admin check but remembered tenant search parity and reflection-backed discovery dominate the runtime shape.',
|
|
'followUpRequired' => true,
|
|
],
|
|
[
|
|
'filePath' => 'tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php',
|
|
'primaryClassificationId' => 'ui-workflow',
|
|
'secondaryClassificationIds' => ['discovery-heavy'],
|
|
'resolutionStrategy' => 'broadest-cost-wins',
|
|
'rationale' => 'The builder is dense and discovery aware, but it still primarily protects the bounded compare-matrix workflow that Confidence keeps.',
|
|
'followUpRequired' => true,
|
|
],
|
|
[
|
|
'filePath' => 'tests/Feature/Concerns/BuildsBaselineCompareMatrixFixtures.php',
|
|
'primaryClassificationId' => 'ui-workflow',
|
|
'secondaryClassificationIds' => ['discovery-heavy', 'surface-guard'],
|
|
'resolutionStrategy' => 'broadest-cost-wins',
|
|
'rationale' => 'The fixture trait amplifies the compare-matrix workflow family until a narrower extraction is justified.',
|
|
'followUpRequired' => true,
|
|
],
|
|
[
|
|
'filePath' => 'tests/Feature/Concerns/BuildsPortfolioTriageFixtures.php',
|
|
'primaryClassificationId' => 'surface-guard',
|
|
'secondaryClassificationIds' => ['ui-workflow'],
|
|
'resolutionStrategy' => 'broadest-cost-wins',
|
|
'rationale' => 'The portfolio triage fixture graph widens cross-tenant scope and should inherit the broadest governance-oriented cost signal until split.',
|
|
'followUpRequired' => true,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public static function mixedFileResolution(string $filePath): ?array
|
|
{
|
|
foreach (self::mixedFileResolutions() as $resolution) {
|
|
if ($resolution['filePath'] === $filePath) {
|
|
return $resolution;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public static function placementRules(): array
|
|
{
|
|
return [
|
|
[
|
|
'ruleId' => 'ui-light-fast-feedback-allowed',
|
|
'classificationId' => 'ui-light',
|
|
'laneId' => 'fast-feedback',
|
|
'allowance' => 'allowed',
|
|
'reason' => 'Explicitly approved localized UI checks may remain in Fast Feedback when discovery stays bounded.',
|
|
],
|
|
[
|
|
'ruleId' => 'ui-light-confidence-required',
|
|
'classificationId' => 'ui-light',
|
|
'laneId' => 'confidence',
|
|
'allowance' => 'required',
|
|
'reason' => 'Confidence keeps localized UI trust as its lightest retained UI layer.',
|
|
],
|
|
[
|
|
'ruleId' => 'ui-workflow-confidence-required',
|
|
'classificationId' => 'ui-workflow',
|
|
'laneId' => 'confidence',
|
|
'allowance' => 'required',
|
|
'reason' => 'Selected bounded workflows remain in Confidence to preserve real operator trust.',
|
|
],
|
|
[
|
|
'ruleId' => 'ui-workflow-heavy-governance-allowed',
|
|
'classificationId' => 'ui-workflow',
|
|
'laneId' => 'heavy-governance',
|
|
'allowance' => 'allowed',
|
|
'reason' => 'Workflow families may escalate into Heavy Governance when their breadth stops being local.',
|
|
],
|
|
[
|
|
'ruleId' => 'surface-guard-heavy-governance-required',
|
|
'classificationId' => 'surface-guard',
|
|
'laneId' => 'heavy-governance',
|
|
'allowance' => 'required',
|
|
'reason' => 'Broad surface guards belong in Heavy Governance by default.',
|
|
],
|
|
[
|
|
'ruleId' => 'surface-guard-fast-feedback-forbidden',
|
|
'classificationId' => 'surface-guard',
|
|
'laneId' => 'fast-feedback',
|
|
'allowance' => 'forbidden',
|
|
'reason' => 'Fast Feedback must never carry broad surface-guard breadth.',
|
|
],
|
|
[
|
|
'ruleId' => 'surface-guard-confidence-forbidden',
|
|
'classificationId' => 'surface-guard',
|
|
'laneId' => 'confidence',
|
|
'allowance' => 'forbidden',
|
|
'reason' => 'Confidence should retain bounded workflows, not governance-wide surface discipline.',
|
|
],
|
|
[
|
|
'ruleId' => 'discovery-heavy-heavy-governance-required',
|
|
'classificationId' => 'discovery-heavy',
|
|
'laneId' => 'heavy-governance',
|
|
'allowance' => 'required',
|
|
'reason' => 'Discovery-heavy scans scale with surface breadth and therefore belong in Heavy Governance.',
|
|
],
|
|
[
|
|
'ruleId' => 'discovery-heavy-fast-feedback-forbidden',
|
|
'classificationId' => 'discovery-heavy',
|
|
'laneId' => 'fast-feedback',
|
|
'allowance' => 'forbidden',
|
|
'reason' => 'Fast Feedback must stay free of discovery-heavy scans.',
|
|
],
|
|
[
|
|
'ruleId' => 'discovery-heavy-confidence-forbidden',
|
|
'classificationId' => 'discovery-heavy',
|
|
'laneId' => 'confidence',
|
|
'allowance' => 'forbidden',
|
|
'reason' => 'Confidence keeps explicit retained UI trust, not remembered-context discovery scans.',
|
|
],
|
|
[
|
|
'ruleId' => 'browser-browser-required',
|
|
'classificationId' => 'browser',
|
|
'laneId' => 'browser',
|
|
'allowance' => 'required',
|
|
'reason' => 'Browser coverage remains isolated in the dedicated browser lane.',
|
|
],
|
|
[
|
|
'ruleId' => 'browser-fast-feedback-forbidden',
|
|
'classificationId' => 'browser',
|
|
'laneId' => 'fast-feedback',
|
|
'allowance' => 'forbidden',
|
|
'reason' => 'Browser coverage cannot run in Fast Feedback.',
|
|
],
|
|
[
|
|
'ruleId' => 'browser-confidence-forbidden',
|
|
'classificationId' => 'browser',
|
|
'laneId' => 'confidence',
|
|
'allowance' => 'forbidden',
|
|
'reason' => 'Browser coverage cannot run in Confidence.',
|
|
],
|
|
[
|
|
'ruleId' => 'browser-heavy-governance-forbidden',
|
|
'classificationId' => 'browser',
|
|
'laneId' => 'heavy-governance',
|
|
'allowance' => 'forbidden',
|
|
'reason' => 'Browser coverage does not merge into Heavy Governance.',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public static function driftGuards(): array
|
|
{
|
|
return [
|
|
[
|
|
'guardId' => 'browser-lane-isolation',
|
|
'scope' => 'classification',
|
|
'assertionType' => 'browser-isolation',
|
|
'targetRefs' => ['browser'],
|
|
'owningTestPaths' => ['tests/Feature/Guards/BrowserLaneIsolationTest.php'],
|
|
'failureContract' => 'Failures must name the browser file or family that leaked into a non-browser lane.',
|
|
],
|
|
[
|
|
'guardId' => 'discovery-heavy-fast-feedback-exclusion',
|
|
'scope' => 'lane',
|
|
'assertionType' => 'forbidden-membership',
|
|
'targetRefs' => ['discovery-heavy', 'fast-feedback'],
|
|
'owningTestPaths' => [
|
|
'tests/Feature/Guards/FastFeedbackLaneContractTest.php',
|
|
'tests/Feature/Guards/FastFeedbackLaneExclusionTest.php',
|
|
],
|
|
'failureContract' => 'Failures must name the discovery-heavy family or file still present in Fast Feedback.',
|
|
],
|
|
[
|
|
'guardId' => 'surface-guard-fast-feedback-exclusion',
|
|
'scope' => 'lane',
|
|
'assertionType' => 'forbidden-membership',
|
|
'targetRefs' => ['surface-guard', 'fast-feedback'],
|
|
'owningTestPaths' => [
|
|
'tests/Feature/Guards/FastFeedbackLaneContractTest.php',
|
|
'tests/Feature/Guards/FastFeedbackLaneExclusionTest.php',
|
|
],
|
|
'failureContract' => 'Failures must identify the broad surface-guard file or family that still leaks into Fast Feedback.',
|
|
],
|
|
[
|
|
'guardId' => 'confidence-ui-workflow-whitelist',
|
|
'scope' => 'lane',
|
|
'assertionType' => 'required-membership',
|
|
'targetRefs' => ['ui-light', 'ui-workflow', 'confidence'],
|
|
'owningTestPaths' => [
|
|
'tests/Feature/Guards/ConfidenceLaneContractTest.php',
|
|
'tests/Feature/Guards/HeavyGovernanceLaneContractTest.php',
|
|
],
|
|
'failureContract' => 'Failures must name the confidence family whose classification or rationale is inconsistent with the retained confidence catalog.',
|
|
],
|
|
[
|
|
'guardId' => 'heavy-attribution-contract',
|
|
'scope' => 'report',
|
|
'assertionType' => 'required-attribution',
|
|
'targetRefs' => ['surface-guard', 'discovery-heavy', 'browser'],
|
|
'owningTestPaths' => [
|
|
'tests/Feature/Guards/TestLaneArtifactsContractTest.php',
|
|
'tests/Feature/Guards/ProfileLaneContractTest.php',
|
|
],
|
|
'failureContract' => 'Failures must name the missing classification or family attribution payload under storage/logs/test-lanes.',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public static function budgetTargets(): array
|
|
{
|
|
return [
|
|
[
|
|
'budgetId' => 'lane-heavy-governance',
|
|
'targetType' => 'lane',
|
|
'targetId' => 'heavy-governance',
|
|
'thresholdSeconds' => 200,
|
|
'baselineSource' => 'measured-lane',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'refresh when heavy family ownership changes',
|
|
],
|
|
[
|
|
'budgetId' => 'classification-ui-workflow',
|
|
'targetType' => 'classification',
|
|
'targetId' => 'ui-workflow',
|
|
'thresholdSeconds' => 150,
|
|
'baselineSource' => 'measured-current-suite',
|
|
'enforcement' => 'report-only',
|
|
'lifecycleState' => 'draft',
|
|
'reviewCadence' => 'tighten after the retained confidence families stabilize',
|
|
],
|
|
[
|
|
'budgetId' => 'classification-surface-guard',
|
|
'targetType' => 'classification',
|
|
'targetId' => 'surface-guard',
|
|
'thresholdSeconds' => 90,
|
|
'baselineSource' => 'measured-post-spec-207',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable heavy-governance runs',
|
|
],
|
|
[
|
|
'budgetId' => 'classification-discovery-heavy',
|
|
'targetType' => 'classification',
|
|
'targetId' => 'discovery-heavy',
|
|
'thresholdSeconds' => 60,
|
|
'baselineSource' => 'measured-post-spec-207',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable heavy-governance runs',
|
|
],
|
|
[
|
|
'budgetId' => 'classification-browser',
|
|
'targetType' => 'classification',
|
|
'targetId' => 'browser',
|
|
'thresholdSeconds' => 150,
|
|
'baselineSource' => 'measured-lane',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable browser runs',
|
|
],
|
|
[
|
|
'budgetId' => 'family-backup-set-admin-tenant-parity',
|
|
'targetType' => 'family',
|
|
'targetId' => 'backup-set-admin-tenant-parity',
|
|
'thresholdSeconds' => 20,
|
|
'baselineSource' => 'measured-current-suite',
|
|
'enforcement' => 'report-only',
|
|
'lifecycleState' => 'draft',
|
|
'reviewCadence' => 'tighten if more localized admin parity families are cataloged',
|
|
],
|
|
[
|
|
'budgetId' => 'family-baseline-compare-matrix-workflow',
|
|
'targetType' => 'family',
|
|
'targetId' => 'baseline-compare-matrix-workflow',
|
|
'thresholdSeconds' => 75,
|
|
'baselineSource' => 'measured-current-suite',
|
|
'enforcement' => 'report-only',
|
|
'lifecycleState' => 'draft',
|
|
'reviewCadence' => 'tighten after confidence workflow coverage settles',
|
|
],
|
|
[
|
|
'budgetId' => 'family-onboarding-wizard-enforcement',
|
|
'targetType' => 'family',
|
|
'targetId' => 'onboarding-wizard-enforcement',
|
|
'thresholdSeconds' => 45,
|
|
'baselineSource' => 'measured-current-suite',
|
|
'enforcement' => 'report-only',
|
|
'lifecycleState' => 'draft',
|
|
'reviewCadence' => 'tighten after confidence workflow coverage settles',
|
|
],
|
|
[
|
|
'budgetId' => 'family-finding-bulk-actions-workflow',
|
|
'targetType' => 'family',
|
|
'targetId' => 'finding-bulk-actions-workflow',
|
|
'thresholdSeconds' => 90,
|
|
'baselineSource' => 'measured-lane',
|
|
'enforcement' => 'report-only',
|
|
'lifecycleState' => 'draft',
|
|
'reviewCadence' => 'tighten after escalated heavy workflows stabilize',
|
|
],
|
|
[
|
|
'budgetId' => 'family-drift-bulk-triage-all-matching',
|
|
'targetType' => 'family',
|
|
'targetId' => 'drift-bulk-triage-all-matching',
|
|
'thresholdSeconds' => 25,
|
|
'baselineSource' => 'measured-lane',
|
|
'enforcement' => 'report-only',
|
|
'lifecycleState' => 'draft',
|
|
'reviewCadence' => 'tighten after escalated heavy workflows stabilize',
|
|
],
|
|
[
|
|
'budgetId' => 'family-baseline-profile-start-surfaces',
|
|
'targetType' => 'family',
|
|
'targetId' => 'baseline-profile-start-surfaces',
|
|
'thresholdSeconds' => 140,
|
|
'baselineSource' => 'measured-lane',
|
|
'enforcement' => 'report-only',
|
|
'lifecycleState' => 'draft',
|
|
'reviewCadence' => 'tighten after escalated heavy workflows stabilize',
|
|
],
|
|
[
|
|
'budgetId' => 'family-workspace-settings-slice-management',
|
|
'targetType' => 'family',
|
|
'targetId' => 'workspace-settings-slice-management',
|
|
'thresholdSeconds' => 70,
|
|
'baselineSource' => 'measured-lane',
|
|
'enforcement' => 'report-only',
|
|
'lifecycleState' => 'draft',
|
|
'reviewCadence' => 'tighten after escalated heavy workflows stabilize',
|
|
],
|
|
[
|
|
'budgetId' => 'family-findings-workflow-surfaces',
|
|
'targetType' => 'family',
|
|
'targetId' => 'findings-workflow-surfaces',
|
|
'thresholdSeconds' => 130,
|
|
'baselineSource' => 'measured-lane',
|
|
'enforcement' => 'report-only',
|
|
'lifecycleState' => 'draft',
|
|
'reviewCadence' => 'tighten after escalated heavy workflows stabilize',
|
|
],
|
|
[
|
|
'budgetId' => 'family-workspace-only-admin-surface-independence',
|
|
'targetType' => 'family',
|
|
'targetId' => 'workspace-only-admin-surface-independence',
|
|
'thresholdSeconds' => 40,
|
|
'baselineSource' => 'measured-lane',
|
|
'enforcement' => 'report-only',
|
|
'lifecycleState' => 'draft',
|
|
'reviewCadence' => 'tighten after escalated heavy workflows stabilize',
|
|
],
|
|
[
|
|
'budgetId' => 'family-action-surface-contract',
|
|
'targetType' => 'family',
|
|
'targetId' => 'action-surface-contract',
|
|
'thresholdSeconds' => 35,
|
|
'baselineSource' => 'measured-post-spec-207',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable heavy-governance runs',
|
|
],
|
|
[
|
|
'budgetId' => 'family-policy-resource-admin-search-parity',
|
|
'targetType' => 'family',
|
|
'targetId' => 'policy-resource-admin-search-parity',
|
|
'thresholdSeconds' => 20,
|
|
'baselineSource' => 'measured-post-spec-207',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable heavy-governance runs',
|
|
],
|
|
[
|
|
'budgetId' => 'family-policy-version-admin-search-parity',
|
|
'targetType' => 'family',
|
|
'targetId' => 'policy-version-admin-search-parity',
|
|
'thresholdSeconds' => 20,
|
|
'baselineSource' => 'measured-post-spec-207',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable heavy-governance runs',
|
|
],
|
|
[
|
|
'budgetId' => 'family-backup-items-relation-manager-enforcement',
|
|
'targetType' => 'family',
|
|
'targetId' => 'backup-items-relation-manager-enforcement',
|
|
'thresholdSeconds' => 30,
|
|
'baselineSource' => 'measured-post-spec-207',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable heavy-governance runs',
|
|
],
|
|
[
|
|
'budgetId' => 'family-workspace-memberships-relation-manager-enforcement',
|
|
'targetType' => 'family',
|
|
'targetId' => 'workspace-memberships-relation-manager-enforcement',
|
|
'thresholdSeconds' => 25,
|
|
'baselineSource' => 'measured-post-spec-207',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable heavy-governance runs',
|
|
],
|
|
[
|
|
'budgetId' => 'family-tenant-review-header-discipline',
|
|
'targetType' => 'family',
|
|
'targetId' => 'tenant-review-header-discipline',
|
|
'thresholdSeconds' => 20,
|
|
'baselineSource' => 'measured-post-spec-207',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable heavy-governance runs',
|
|
],
|
|
[
|
|
'budgetId' => 'family-panel-navigation-segregation',
|
|
'targetType' => 'family',
|
|
'targetId' => 'panel-navigation-segregation',
|
|
'thresholdSeconds' => 20,
|
|
'baselineSource' => 'measured-post-spec-207',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable heavy-governance runs',
|
|
],
|
|
[
|
|
'budgetId' => 'family-ops-ux-governance',
|
|
'targetType' => 'family',
|
|
'targetId' => 'ops-ux-governance',
|
|
'thresholdSeconds' => 120,
|
|
'baselineSource' => 'measured-current-suite',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable runs',
|
|
],
|
|
[
|
|
'budgetId' => 'family-browser-smoke',
|
|
'targetType' => 'family',
|
|
'targetId' => 'browser-smoke',
|
|
'thresholdSeconds' => 150,
|
|
'baselineSource' => 'measured-lane',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable runs',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public static function familyBudgets(): array
|
|
{
|
|
$familyBudgets = [];
|
|
|
|
foreach (self::budgetTargets() as $budgetTarget) {
|
|
if (($budgetTarget['targetType'] ?? null) !== 'family') {
|
|
continue;
|
|
}
|
|
|
|
$family = self::family((string) $budgetTarget['targetId']);
|
|
$selectors = array_values(array_unique(array_merge(
|
|
self::budgetSelectorsForFamily($family),
|
|
self::familyHotspotTestFiles($family),
|
|
)));
|
|
|
|
$familyBudgets[] = array_merge($budgetTarget, [
|
|
'familyId' => $budgetTarget['targetId'],
|
|
'selectorType' => self::primaryBudgetSelectorType($family),
|
|
'selectors' => $selectors,
|
|
]);
|
|
}
|
|
|
|
return $familyBudgets;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public static function budgetTarget(string $targetType, string $targetId): ?array
|
|
{
|
|
foreach (self::budgetTargets() as $budgetTarget) {
|
|
if ($budgetTarget['targetType'] === $targetType && $budgetTarget['targetId'] === $targetId) {
|
|
return $budgetTarget;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function lane(string $id): array
|
|
{
|
|
foreach (self::lanes() as $lane) {
|
|
if ($lane['id'] === $id) {
|
|
return $lane;
|
|
}
|
|
}
|
|
|
|
throw new InvalidArgumentException(sprintf('Unknown test lane [%s].', $id));
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public static function lanes(): array
|
|
{
|
|
return [
|
|
[
|
|
'id' => 'fast-feedback',
|
|
'governanceClass' => 'fast',
|
|
'description' => 'Quick representative feedback for normal local edits.',
|
|
'intendedAudience' => 'Contributors working in the default authoring loop.',
|
|
'includedFamilies' => ['unit', 'core-feature-safety', 'ui-light'],
|
|
'excludedFamilies' => ['browser', 'surface-guard', 'discovery-heavy', 'heavy-governance'],
|
|
'ownershipExpectations' => 'Run on normal edits and escalate to confidence or heavy-governance when the touched surface expands.',
|
|
'defaultEntryPoint' => true,
|
|
'parallelMode' => 'required',
|
|
'selectors' => [
|
|
'includeSuites' => ['Unit'],
|
|
'includePaths' => [
|
|
'tests/Feature/Auth',
|
|
'tests/Feature/Authorization',
|
|
'tests/Feature/EntraAdminRoles',
|
|
'tests/Feature/Findings',
|
|
'tests/Feature/Guards',
|
|
'tests/Feature/Monitoring',
|
|
'tests/Feature/Navigation',
|
|
'tests/Feature/Onboarding',
|
|
'tests/Feature/RequiredPermissions',
|
|
'tests/Feature/Tenants',
|
|
'tests/Feature/Workspaces',
|
|
],
|
|
'includeGroups' => [],
|
|
'includeFiles' => [],
|
|
'excludeSuites' => ['Browser'],
|
|
'excludePaths' => [
|
|
'tests/Architecture',
|
|
'tests/Browser',
|
|
'tests/Deprecation',
|
|
'tests/Feature/078',
|
|
'tests/Feature/090',
|
|
'tests/Feature/144',
|
|
'tests/Feature/OpsUx',
|
|
],
|
|
'excludeGroups' => [],
|
|
'excludeFiles' => self::disallowedFamilyFilesForLane('fast-feedback'),
|
|
],
|
|
'artifacts' => ['summary', 'junit-xml', 'budget-report'],
|
|
'budget' => [
|
|
'thresholdSeconds' => 200,
|
|
'baselineSource' => 'measured-current-suite',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'baselineDeltaTargetPercent' => 50,
|
|
'reviewCadence' => 'tighten after two stable runs',
|
|
],
|
|
'dbStrategy' => [
|
|
'connectionMode' => 'sqlite-memory',
|
|
'resetStrategy' => 'refresh-database',
|
|
'seedsPolicy' => 'restricted',
|
|
'schemaBaselineCandidate' => false,
|
|
],
|
|
],
|
|
[
|
|
'id' => 'confidence',
|
|
'governanceClass' => 'confidence',
|
|
'description' => 'Broader pre-merge validation for non-browser feature and integration work.',
|
|
'intendedAudience' => 'Contributors and reviewers preparing a higher-confidence run before merge.',
|
|
'includedFamilies' => ['unit', 'non-browser-feature-integration', 'ui-light', 'ui-workflow'],
|
|
'excludedFamilies' => ['browser', 'surface-guard', 'discovery-heavy', 'heavy-governance'],
|
|
'ownershipExpectations' => 'Run before merge when a change touches multiple feature surfaces or shared infrastructure.',
|
|
'defaultEntryPoint' => false,
|
|
'parallelMode' => 'required',
|
|
'selectors' => [
|
|
'includeSuites' => ['Unit'],
|
|
'includePaths' => ['tests/Feature'],
|
|
'includeGroups' => [],
|
|
'includeFiles' => self::laneHotspotTestFiles('confidence'),
|
|
'excludeSuites' => ['Browser'],
|
|
'excludePaths' => [
|
|
'tests/Architecture',
|
|
'tests/Browser',
|
|
'tests/Deprecation',
|
|
'tests/Feature/078',
|
|
'tests/Feature/090',
|
|
'tests/Feature/144',
|
|
'tests/Feature/OpsUx',
|
|
],
|
|
'excludeGroups' => [],
|
|
'excludeFiles' => self::disallowedFamilyFilesForLane('confidence'),
|
|
],
|
|
'artifacts' => ['summary', 'junit-xml', 'budget-report'],
|
|
'budget' => [
|
|
'thresholdSeconds' => 450,
|
|
'baselineSource' => 'measured-current-suite',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable runs',
|
|
],
|
|
'dbStrategy' => [
|
|
'connectionMode' => 'sqlite-memory',
|
|
'resetStrategy' => 'refresh-database',
|
|
'seedsPolicy' => 'restricted',
|
|
'schemaBaselineCandidate' => false,
|
|
],
|
|
],
|
|
[
|
|
'id' => 'browser',
|
|
'governanceClass' => 'heavy',
|
|
'description' => 'Dedicated browser runtime lane for smoke and workflow validation.',
|
|
'intendedAudience' => 'Contributors validating browser-specific behavior and smoke coverage.',
|
|
'includedFamilies' => ['browser'],
|
|
'excludedFamilies' => ['fast-feedback', 'confidence', 'heavy-governance'],
|
|
'ownershipExpectations' => 'Run when a change touches browser behavior or before promoting browser-sensitive work.',
|
|
'defaultEntryPoint' => false,
|
|
'parallelMode' => 'forbidden',
|
|
'selectors' => [
|
|
'includeSuites' => ['Browser'],
|
|
'includePaths' => ['tests/Browser'],
|
|
'includeGroups' => [],
|
|
'includeFiles' => [],
|
|
'excludeSuites' => [],
|
|
'excludePaths' => [],
|
|
'excludeGroups' => [],
|
|
'excludeFiles' => [],
|
|
],
|
|
'artifacts' => ['summary', 'junit-xml', 'budget-report'],
|
|
'budget' => [
|
|
'thresholdSeconds' => 150,
|
|
'baselineSource' => 'measured-lane',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable runs',
|
|
],
|
|
'dbStrategy' => [
|
|
'connectionMode' => 'sqlite-memory',
|
|
'resetStrategy' => 'refresh-database',
|
|
'seedsPolicy' => 'restricted',
|
|
'schemaBaselineCandidate' => false,
|
|
],
|
|
],
|
|
[
|
|
'id' => 'heavy-governance',
|
|
'governanceClass' => 'heavy',
|
|
'description' => 'Intentionally expensive governance scans, discovery-heavy parity checks, and high-fan-out operational validations.',
|
|
'intendedAudience' => 'Maintainers and reviewers validating the heaviest governance families on purpose.',
|
|
'includedFamilies' => ['architecture-governance', 'ops-ux', 'ui-workflow', 'surface-guard', 'discovery-heavy'],
|
|
'excludedFamilies' => ['browser', 'ui-light'],
|
|
'ownershipExpectations' => 'Run intentionally when touching governance scans, discovery-heavy parity, or broad UI contract families.',
|
|
'defaultEntryPoint' => false,
|
|
'parallelMode' => 'optional',
|
|
'selectors' => [
|
|
'includeSuites' => [],
|
|
'includePaths' => [
|
|
'tests/Architecture',
|
|
'tests/Deprecation',
|
|
'tests/Feature/078',
|
|
'tests/Feature/090',
|
|
'tests/Feature/144',
|
|
'tests/Feature/OpsUx',
|
|
],
|
|
'includeGroups' => [],
|
|
'includeFiles' => self::laneHotspotTestFiles('heavy-governance'),
|
|
'excludeSuites' => ['Browser'],
|
|
'excludePaths' => [],
|
|
'excludeGroups' => [],
|
|
'excludeFiles' => [],
|
|
],
|
|
'artifacts' => ['summary', 'junit-xml', 'budget-report'],
|
|
'budget' => [
|
|
'thresholdSeconds' => 300,
|
|
'baselineSource' => 'measured-lane',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable runs',
|
|
],
|
|
'dbStrategy' => [
|
|
'connectionMode' => 'mixed',
|
|
'resetStrategy' => 'refresh-database',
|
|
'seedsPolicy' => 'restricted',
|
|
'schemaBaselineCandidate' => false,
|
|
],
|
|
],
|
|
[
|
|
'id' => 'profiling',
|
|
'governanceClass' => 'support',
|
|
'description' => 'Serial non-browser profiling support run that exposes the slowest tests and family attribution.',
|
|
'intendedAudience' => 'Maintainers investigating slow-test drift before it becomes baseline behavior.',
|
|
'includedFamilies' => ['unit', 'non-browser-feature-integration', 'ui-workflow', 'surface-guard', 'discovery-heavy'],
|
|
'excludedFamilies' => ['browser'],
|
|
'ownershipExpectations' => 'Run intentionally when refreshing baselines or explaining which heavy families own the cost.',
|
|
'defaultEntryPoint' => false,
|
|
'parallelMode' => 'forbidden',
|
|
'selectors' => [
|
|
'includeSuites' => ['Unit'],
|
|
'includePaths' => ['tests/Feature'],
|
|
'includeGroups' => [],
|
|
'includeFiles' => [],
|
|
'excludeSuites' => ['Browser'],
|
|
'excludePaths' => ['tests/Browser'],
|
|
'excludeGroups' => [],
|
|
'excludeFiles' => [],
|
|
],
|
|
'artifacts' => ['summary', 'junit-xml', 'profile-top', 'budget-report'],
|
|
'budget' => [
|
|
'thresholdSeconds' => 3000,
|
|
'baselineSource' => 'measured-current-suite',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable runs',
|
|
],
|
|
'dbStrategy' => [
|
|
'connectionMode' => 'sqlite-memory',
|
|
'resetStrategy' => 'refresh-database',
|
|
'seedsPolicy' => 'restricted',
|
|
'schemaBaselineCandidate' => false,
|
|
],
|
|
],
|
|
[
|
|
'id' => 'junit',
|
|
'governanceClass' => 'support',
|
|
'description' => 'Machine-readable report run for the confidence-shaped non-browser scope.',
|
|
'intendedAudience' => 'Contributors and reviewers who need durable artifacts instead of ad-hoc terminal output.',
|
|
'includedFamilies' => ['unit', 'non-browser-feature-integration', 'ui-light', 'ui-workflow'],
|
|
'excludedFamilies' => ['browser', 'surface-guard', 'discovery-heavy', 'heavy-governance'],
|
|
'ownershipExpectations' => 'Run when the latest confidence-shaped machine-readable report needs to be shared or attached to follow-up work.',
|
|
'defaultEntryPoint' => false,
|
|
'parallelMode' => 'required',
|
|
'selectors' => [
|
|
'includeSuites' => ['Unit'],
|
|
'includePaths' => ['tests/Feature'],
|
|
'includeGroups' => [],
|
|
'includeFiles' => self::laneHotspotTestFiles('confidence'),
|
|
'excludeSuites' => ['Browser'],
|
|
'excludePaths' => [
|
|
'tests/Architecture',
|
|
'tests/Browser',
|
|
'tests/Deprecation',
|
|
'tests/Feature/078',
|
|
'tests/Feature/090',
|
|
'tests/Feature/144',
|
|
'tests/Feature/OpsUx',
|
|
],
|
|
'excludeGroups' => [],
|
|
'excludeFiles' => self::disallowedFamilyFilesForLane('junit'),
|
|
],
|
|
'artifacts' => ['summary', 'junit-xml', 'budget-report'],
|
|
'budget' => [
|
|
'thresholdSeconds' => 450,
|
|
'baselineSource' => 'measured-lane',
|
|
'enforcement' => 'warn',
|
|
'lifecycleState' => 'documented',
|
|
'reviewCadence' => 'tighten after two stable runs',
|
|
],
|
|
'dbStrategy' => [
|
|
'connectionMode' => 'sqlite-memory',
|
|
'resetStrategy' => 'refresh-database',
|
|
'seedsPolicy' => 'restricted',
|
|
'schemaBaselineCandidate' => false,
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
public static function commandRef(string $laneId): string
|
|
{
|
|
if (! array_key_exists($laneId, self::COMMAND_REFS)) {
|
|
throw new InvalidArgumentException(sprintf('Unknown lane command reference [%s].', $laneId));
|
|
}
|
|
|
|
return self::COMMAND_REFS[$laneId];
|
|
}
|
|
|
|
public static function artifactDirectory(): string
|
|
{
|
|
return self::ARTIFACT_DIRECTORY;
|
|
}
|
|
|
|
public static function fullSuiteBaselineSeconds(): int
|
|
{
|
|
return self::FULL_SUITE_BASELINE_SECONDS;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, float|int|string>|null
|
|
*/
|
|
public static function comparisonBaseline(string $comparisonProfile, string $laneId): ?array
|
|
{
|
|
$profileBaselines = self::COMPARISON_BASELINES[$comparisonProfile] ?? null;
|
|
|
|
if (! is_array($profileBaselines)) {
|
|
return null;
|
|
}
|
|
|
|
$baseline = $profileBaselines[$laneId] ?? null;
|
|
|
|
return is_array($baseline) ? $baseline : null;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
public static function buildCommand(string $laneId): array
|
|
{
|
|
$lane = self::lane($laneId);
|
|
$commandTargets = self::commandTargets($laneId, $lane);
|
|
$command = ['php', 'vendor/bin/pest', '--colors=never'];
|
|
$configurationPath = $commandTargets !== []
|
|
? self::writeLaneConfiguration($laneId, $commandTargets)
|
|
: null;
|
|
$commandSuites = $commandTargets === []
|
|
? self::commandSuites($lane)
|
|
: [];
|
|
|
|
if (! in_array('profile-top', $lane['artifacts'], true)) {
|
|
$command[] = '--compact';
|
|
}
|
|
|
|
if ($lane['parallelMode'] === 'required') {
|
|
$command[] = '--parallel';
|
|
}
|
|
|
|
if ($configurationPath !== null) {
|
|
$command[] = '--configuration='.$configurationPath;
|
|
$command[] = '--testsuite=Lane';
|
|
} elseif ($commandSuites !== []) {
|
|
$command[] = '--testsuite='.implode(',', $commandSuites);
|
|
}
|
|
|
|
$command[] = '--log-junit='.TestLaneReport::artifactPaths($laneId)['junit'];
|
|
|
|
if (in_array('profile-top', $lane['artifacts'], true)) {
|
|
$command[] = '--profile';
|
|
}
|
|
|
|
if ($commandTargets === []) {
|
|
foreach ($lane['selectors']['includeGroups'] as $group) {
|
|
$command[] = '--group='.$group;
|
|
}
|
|
|
|
foreach ($lane['selectors']['excludeGroups'] as $group) {
|
|
$command[] = '--exclude-group='.$group;
|
|
}
|
|
}
|
|
|
|
return $command;
|
|
}
|
|
|
|
public static function runLane(string $laneId): int
|
|
{
|
|
self::ensureArtifactDirectory();
|
|
|
|
$command = self::buildCommand($laneId);
|
|
$process = new Process($command, self::appRoot());
|
|
$process->setTimeout(null);
|
|
|
|
$capturedOutput = '';
|
|
$startedAt = microtime(true);
|
|
|
|
$process->run(function (string $type, string $buffer) use (&$capturedOutput): void {
|
|
echo $buffer;
|
|
|
|
$capturedOutput .= $buffer;
|
|
});
|
|
|
|
TestLaneReport::finalizeLane($laneId, microtime(true) - $startedAt, $capturedOutput);
|
|
|
|
return $process->getExitCode() ?? 1;
|
|
}
|
|
|
|
public static function renderLatestReport(string $laneId, ?string $comparisonProfile = null): int
|
|
{
|
|
$artifactPaths = TestLaneReport::artifactPaths($laneId);
|
|
$reportPath = self::absolutePath($artifactPaths['report']);
|
|
$wallClockSeconds = 0.0;
|
|
|
|
if (is_file($reportPath)) {
|
|
$existingReport = json_decode((string) file_get_contents($reportPath), true);
|
|
$wallClockSeconds = (float) ($existingReport['wallClockSeconds'] ?? 0.0);
|
|
}
|
|
|
|
$parsed = TestLaneReport::parseJUnit(self::absolutePath($artifactPaths['junit']), $laneId);
|
|
$profileOutputPath = self::absolutePath($artifactPaths['profile']);
|
|
|
|
$report = TestLaneReport::buildReport(
|
|
laneId: $laneId,
|
|
wallClockSeconds: $wallClockSeconds,
|
|
slowestEntries: $parsed['slowestEntries'],
|
|
durationsByFile: $parsed['durationsByFile'],
|
|
comparisonProfile: $comparisonProfile,
|
|
);
|
|
|
|
TestLaneReport::writeArtifacts(
|
|
laneId: $laneId,
|
|
report: $report,
|
|
profileOutput: is_file($profileOutputPath) ? (string) file_get_contents($profileOutputPath) : null,
|
|
);
|
|
|
|
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
public static function discoverFiles(string $laneId): array
|
|
{
|
|
$lane = self::lane($laneId);
|
|
$selectors = $lane['selectors'];
|
|
$files = [];
|
|
|
|
foreach (array_merge(self::suiteTargets($selectors['includeSuites']), $selectors['includePaths'], $selectors['includeFiles']) as $target) {
|
|
foreach (self::discoverTarget($target) as $resolvedFile) {
|
|
if (self::isExcluded($resolvedFile, $selectors, $laneId)) {
|
|
continue;
|
|
}
|
|
|
|
$files[] = $resolvedFile;
|
|
}
|
|
}
|
|
|
|
$files = array_values(array_unique($files));
|
|
sort($files);
|
|
|
|
return $files;
|
|
}
|
|
|
|
public static function absolutePath(string $relativePath): string
|
|
{
|
|
return self::appRoot().DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, ltrim($relativePath, '/'));
|
|
}
|
|
|
|
public static function laneConfigurationPath(string $laneId, ?string $artifactDirectory = null): string
|
|
{
|
|
$directory = trim($artifactDirectory ?? self::artifactDirectory(), '/');
|
|
|
|
return sprintf('%s/%s-lane.phpunit.xml', $directory, $laneId);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $family
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public static function familyForFile(string $filePath, ?array $family = null): ?array
|
|
{
|
|
if (is_array($family)) {
|
|
return self::familyMatchesFile($family, $filePath) ? $family : null;
|
|
}
|
|
|
|
$matchedFamilies = [];
|
|
|
|
foreach (self::families() as $candidate) {
|
|
if (! self::familyMatchesFile($candidate, $filePath)) {
|
|
continue;
|
|
}
|
|
|
|
$matchedFamilies[] = [
|
|
'family' => $candidate,
|
|
'score' => self::familyMatchScore($candidate, $filePath),
|
|
];
|
|
}
|
|
|
|
if ($matchedFamilies === []) {
|
|
return null;
|
|
}
|
|
|
|
usort($matchedFamilies, static fn (array $left, array $right): int => $right['score'] <=> $left['score']);
|
|
|
|
return $matchedFamilies[0]['family'];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function validateLanePlacement(
|
|
string $laneId,
|
|
?string $classificationId = null,
|
|
?string $familyId = null,
|
|
?string $filePath = null,
|
|
array $observedCostSignals = [],
|
|
): array {
|
|
$family = is_string($familyId) ? self::family($familyId) : null;
|
|
|
|
if ($family === null && is_string($filePath)) {
|
|
$family = self::familyForFile($filePath);
|
|
}
|
|
|
|
$mixedResolution = is_string($filePath) ? self::mixedFileResolution($filePath) : null;
|
|
$resolvedClassificationId = $classificationId
|
|
?? ($mixedResolution['primaryClassificationId'] ?? null)
|
|
?? ($family['classificationId'] ?? null);
|
|
|
|
if (! is_string($resolvedClassificationId) || $resolvedClassificationId === '') {
|
|
throw new InvalidArgumentException('Lane placement validation requires a classification, family, or resolvable file path.');
|
|
}
|
|
|
|
$classification = self::classification($resolvedClassificationId);
|
|
$rule = self::placementRuleFor($resolvedClassificationId, $laneId);
|
|
|
|
$allowance = $rule['allowance']
|
|
?? (in_array($laneId, $classification['allowedLaneIds'], true)
|
|
? 'allowed'
|
|
: (in_array($laneId, $classification['forbiddenLaneIds'], true)
|
|
? 'forbidden'
|
|
: ($classification['defaultLaneId'] === $laneId ? 'required' : 'discouraged')));
|
|
|
|
$reasons = [];
|
|
|
|
if (is_array($rule)) {
|
|
$reasons[] = (string) $rule['reason'];
|
|
}
|
|
|
|
if (is_array($family)) {
|
|
$reasons[] = sprintf(
|
|
'Family [%s] is cataloged as [%s] and targets lane [%s].',
|
|
$family['familyId'],
|
|
$family['classificationId'],
|
|
$family['targetLaneId'],
|
|
);
|
|
}
|
|
|
|
if (is_array($mixedResolution)) {
|
|
$reasons[] = sprintf(
|
|
'Mixed-file resolution [%s] applies [%s] via [%s].',
|
|
$mixedResolution['filePath'],
|
|
$mixedResolution['primaryClassificationId'],
|
|
$mixedResolution['resolutionStrategy'],
|
|
);
|
|
}
|
|
|
|
if ($observedCostSignals !== []) {
|
|
$reasons[] = sprintf('Observed cost signals: %s.', implode(', ', $observedCostSignals));
|
|
}
|
|
|
|
$valid = $allowance !== 'forbidden';
|
|
|
|
if (is_array($family) && ! in_array($laneId, self::allowedFamilyTargetLanesForLane($laneId), true)) {
|
|
$valid = false;
|
|
}
|
|
|
|
if (is_array($family) && ! in_array($family['targetLaneId'], self::allowedFamilyTargetLanesForLane($laneId), true)) {
|
|
$allowance = 'forbidden';
|
|
$valid = false;
|
|
$reasons[] = sprintf(
|
|
'Family [%s] targets [%s], which is outside the allowed family targets for lane [%s].',
|
|
$family['familyId'],
|
|
$family['targetLaneId'],
|
|
$laneId,
|
|
);
|
|
}
|
|
|
|
$remediationOptions = [];
|
|
|
|
if (! $valid) {
|
|
$remediationOptions[] = sprintf(
|
|
'Move the file or family into [%s].',
|
|
is_array($family) ? $family['targetLaneId'] : $classification['defaultLaneId'],
|
|
);
|
|
$remediationOptions[] = sprintf('Tag the surface with [%s] or [%s] groups as appropriate.', $resolvedClassificationId, is_array($family) ? $family['targetLaneId'] : $classification['defaultLaneId']);
|
|
}
|
|
|
|
return array_filter([
|
|
'laneId' => $laneId,
|
|
'resolvedClassificationId' => $resolvedClassificationId,
|
|
'familyId' => $family['familyId'] ?? null,
|
|
'allowance' => $allowance,
|
|
'valid' => $valid,
|
|
'reasons' => $reasons,
|
|
'remediationOptions' => $remediationOptions,
|
|
'mixedFileResolution' => $mixedResolution,
|
|
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
|
}
|
|
|
|
public static function describeFilePlacement(string $filePath): string
|
|
{
|
|
$family = self::familyForFile($filePath);
|
|
|
|
if ($family === null) {
|
|
return sprintf('File [%s] is not cataloged in the Spec 208 family inventory.', $filePath);
|
|
}
|
|
|
|
$mixedResolution = self::mixedFileResolution($filePath);
|
|
|
|
$message = sprintf(
|
|
'File [%s] resolves to family [%s] in class [%s] targeting lane [%s].',
|
|
$filePath,
|
|
$family['familyId'],
|
|
$family['classificationId'],
|
|
$family['targetLaneId'],
|
|
);
|
|
|
|
if (is_array($mixedResolution)) {
|
|
$message .= sprintf(
|
|
' Mixed-file strategy [%s] keeps [%s] as the primary class.',
|
|
$mixedResolution['resolutionStrategy'],
|
|
$mixedResolution['primaryClassificationId'],
|
|
);
|
|
}
|
|
|
|
return $message;
|
|
}
|
|
|
|
private static function appRoot(): string
|
|
{
|
|
return dirname(__DIR__, 2);
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $targets
|
|
*/
|
|
private static function writeLaneConfiguration(string $laneId, array $targets): string
|
|
{
|
|
$relativePath = self::laneConfigurationPath($laneId);
|
|
$absolutePath = self::absolutePath($relativePath);
|
|
|
|
self::ensureDirectory(dirname($absolutePath));
|
|
|
|
$xml = new \DOMDocument('1.0', 'UTF-8');
|
|
$xml->preserveWhiteSpace = false;
|
|
$xml->formatOutput = true;
|
|
$xml->load(self::absolutePath('phpunit.xml'));
|
|
|
|
$phpunit = $xml->documentElement;
|
|
|
|
if (! $phpunit instanceof \DOMElement) {
|
|
throw new \RuntimeException('Unable to load phpunit.xml root element.');
|
|
}
|
|
|
|
$phpunit->setAttribute('bootstrap', self::absolutePath('vendor/autoload.php'));
|
|
|
|
foreach (iterator_to_array($phpunit->childNodes) as $childNode) {
|
|
if ($childNode instanceof \DOMElement && $childNode->tagName === 'testsuites') {
|
|
$phpunit->removeChild($childNode);
|
|
}
|
|
}
|
|
|
|
foreach (iterator_to_array($phpunit->getElementsByTagName('directory')) as $directoryNode) {
|
|
if (trim($directoryNode->textContent) === 'app') {
|
|
$directoryNode->nodeValue = self::absolutePath('app');
|
|
}
|
|
}
|
|
|
|
$testsuites = $xml->createElement('testsuites');
|
|
$testsuite = $xml->createElement('testsuite');
|
|
$testsuite->setAttribute('name', 'Lane');
|
|
|
|
foreach ($targets as $target) {
|
|
$testsuite->appendChild($xml->createElement('file', self::absolutePath($target)));
|
|
}
|
|
|
|
$testsuites->appendChild($testsuite);
|
|
|
|
$insertBefore = null;
|
|
|
|
foreach (iterator_to_array($phpunit->childNodes) as $childNode) {
|
|
if ($childNode instanceof \DOMElement && in_array($childNode->tagName, ['source', 'php'], true)) {
|
|
$insertBefore = $childNode;
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($insertBefore instanceof \DOMElement) {
|
|
$phpunit->insertBefore($testsuites, $insertBefore);
|
|
} else {
|
|
$phpunit->appendChild($testsuites);
|
|
}
|
|
|
|
$xml->save($absolutePath);
|
|
|
|
return $relativePath;
|
|
}
|
|
|
|
private static function ensureArtifactDirectory(): void
|
|
{
|
|
$directory = self::absolutePath(self::artifactDirectory());
|
|
|
|
if (is_dir($directory)) {
|
|
return;
|
|
}
|
|
|
|
mkdir($directory, 0777, true);
|
|
}
|
|
|
|
private static function ensureDirectory(string $directory): void
|
|
{
|
|
if (is_dir($directory)) {
|
|
return;
|
|
}
|
|
|
|
mkdir($directory, 0777, true);
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $suites
|
|
* @return list<string>
|
|
*/
|
|
private static function suiteTargets(array $suites): array
|
|
{
|
|
$targets = [];
|
|
|
|
foreach ($suites as $suite) {
|
|
$targets = array_merge($targets, match ($suite) {
|
|
'Unit' => ['tests/Unit'],
|
|
'Feature' => ['tests/Feature'],
|
|
'Browser' => ['tests/Browser'],
|
|
'Pgsql' => ['tests/Feature/WorkspaceIsolation/WorkspaceIdForeignKeyConstraintTest.php'],
|
|
default => [],
|
|
});
|
|
}
|
|
|
|
return array_values(array_unique($targets));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $lane
|
|
* @return list<string>
|
|
*/
|
|
private static function commandTargets(string $laneId, array $lane): array
|
|
{
|
|
$targets = self::discoverFiles($laneId);
|
|
|
|
if ($targets !== []) {
|
|
return $targets;
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $lane
|
|
* @return list<string>
|
|
*/
|
|
private static function commandSuites(array $lane): array
|
|
{
|
|
$selectors = $lane['selectors'];
|
|
$suites = [];
|
|
|
|
foreach ($selectors['includeSuites'] as $suite) {
|
|
$suites[] = $suite;
|
|
}
|
|
|
|
foreach (array_merge($selectors['includePaths'], $selectors['includeFiles']) as $target) {
|
|
$suite = self::inferSuiteFromTarget($target);
|
|
|
|
if ($suite !== null) {
|
|
$suites[] = $suite;
|
|
}
|
|
}
|
|
|
|
$suites = array_values(array_unique(array_filter($suites, static fn (mixed $suite): bool => is_string($suite) && $suite !== '')));
|
|
|
|
if ($selectors['excludeSuites'] !== []) {
|
|
$suites = array_values(array_filter(
|
|
$suites,
|
|
static fn (string $suite): bool => ! in_array($suite, $selectors['excludeSuites'], true),
|
|
));
|
|
}
|
|
|
|
$suiteOrder = [
|
|
'Unit' => 10,
|
|
'Feature' => 20,
|
|
'Browser' => 30,
|
|
'Architecture' => 40,
|
|
'Deprecation' => 50,
|
|
'Pgsql' => 60,
|
|
];
|
|
|
|
usort($suites, static function (string $left, string $right) use ($suiteOrder): int {
|
|
return ($suiteOrder[$left] ?? 999) <=> ($suiteOrder[$right] ?? 999);
|
|
});
|
|
|
|
return $suites;
|
|
}
|
|
|
|
private static function inferSuiteFromTarget(string $target): ?string
|
|
{
|
|
return match (true) {
|
|
str_starts_with($target, 'tests/Unit') => 'Unit',
|
|
str_starts_with($target, 'tests/Feature') => 'Feature',
|
|
str_starts_with($target, 'tests/Browser') => 'Browser',
|
|
str_starts_with($target, 'tests/Architecture') => 'Architecture',
|
|
str_starts_with($target, 'tests/Deprecation') => 'Deprecation',
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private static function discoverTarget(string $target): array
|
|
{
|
|
$absoluteTarget = self::absolutePath($target);
|
|
|
|
if (is_file($absoluteTarget)) {
|
|
return [str_replace(DIRECTORY_SEPARATOR, '/', $target)];
|
|
}
|
|
|
|
if (! is_dir($absoluteTarget)) {
|
|
return [];
|
|
}
|
|
|
|
$files = [];
|
|
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($absoluteTarget));
|
|
|
|
/** @var SplFileInfo $file */
|
|
foreach ($iterator as $file) {
|
|
if (! $file->isFile() || ! str_ends_with($file->getFilename(), 'Test.php')) {
|
|
continue;
|
|
}
|
|
|
|
$resolvedPath = str_replace(self::appRoot().DIRECTORY_SEPARATOR, '', $file->getPathname());
|
|
$files[] = str_replace(DIRECTORY_SEPARATOR, '/', $resolvedPath);
|
|
}
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $selectors
|
|
*/
|
|
private static function isExcluded(string $filePath, array $selectors, string $laneId): bool
|
|
{
|
|
if (in_array($filePath, $selectors['excludeFiles'], true)) {
|
|
return true;
|
|
}
|
|
|
|
foreach ($selectors['excludePaths'] as $excludedPath) {
|
|
if (str_starts_with($filePath, rtrim($excludedPath, '/'))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
$family = self::familyForFile($filePath);
|
|
|
|
if ($family === null) {
|
|
return false;
|
|
}
|
|
|
|
return ! in_array($family['targetLaneId'], self::allowedFamilyTargetLanesForLane($laneId), true);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private static function placementRuleFor(string $classificationId, string $laneId): ?array
|
|
{
|
|
foreach (self::placementRules() as $rule) {
|
|
if ($rule['classificationId'] === $classificationId && $rule['laneId'] === $laneId) {
|
|
return $rule;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private static function allowedFamilyTargetLanesForLane(string $laneId): array
|
|
{
|
|
return match ($laneId) {
|
|
'fast-feedback' => ['fast-feedback'],
|
|
'confidence', 'junit' => ['fast-feedback', 'confidence'],
|
|
'heavy-governance' => ['heavy-governance'],
|
|
'browser' => ['browser'],
|
|
'profiling' => ['fast-feedback', 'confidence', 'heavy-governance'],
|
|
default => [$laneId],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private static function disallowedFamilyFilesForLane(string $laneId): array
|
|
{
|
|
$allowedTargets = self::allowedFamilyTargetLanesForLane($laneId);
|
|
$files = [];
|
|
|
|
foreach (self::families() as $family) {
|
|
if (in_array($family['targetLaneId'], $allowedTargets, true)) {
|
|
continue;
|
|
}
|
|
|
|
$files = array_merge($files, self::familyHotspotTestFiles($family));
|
|
}
|
|
|
|
$files = array_values(array_unique($files));
|
|
sort($files);
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private static function laneHotspotTestFiles(string $laneId): array
|
|
{
|
|
$files = [];
|
|
|
|
foreach (self::familiesByTargetLane($laneId) as $family) {
|
|
$files = array_merge($files, self::familyHotspotTestFiles($family));
|
|
}
|
|
|
|
$files = array_values(array_unique($files));
|
|
sort($files);
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $family
|
|
* @return list<string>
|
|
*/
|
|
private static function familyHotspotTestFiles(array $family): array
|
|
{
|
|
return array_values(array_filter(
|
|
$family['hotspotFiles'],
|
|
static fn (mixed $filePath): bool => is_string($filePath) && str_ends_with($filePath, 'Test.php'),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $family
|
|
*/
|
|
private static function primaryBudgetSelectorType(array $family): string
|
|
{
|
|
foreach ($family['selectors'] as $selector) {
|
|
if (($selector['selectorType'] ?? null) === 'path' && in_array($selector['selectorRole'] ?? 'include', ['include', 'inventory-only'], true)) {
|
|
return 'path';
|
|
}
|
|
}
|
|
|
|
return 'file';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $family
|
|
* @return list<string>
|
|
*/
|
|
private static function budgetSelectorsForFamily(array $family): array
|
|
{
|
|
$selectors = [];
|
|
|
|
foreach ($family['selectors'] as $selector) {
|
|
if (! in_array($selector['selectorRole'] ?? 'include', ['include', 'inventory-only'], true)) {
|
|
continue;
|
|
}
|
|
|
|
if (! in_array($selector['selectorType'] ?? 'file', ['file', 'path'], true)) {
|
|
continue;
|
|
}
|
|
|
|
$selectors[] = (string) $selector['selectorValue'];
|
|
}
|
|
|
|
return array_values(array_unique($selectors));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $family
|
|
*/
|
|
private static function familyMatchesFile(array $family, string $filePath): bool
|
|
{
|
|
if (in_array($filePath, $family['hotspotFiles'], true)) {
|
|
return true;
|
|
}
|
|
|
|
foreach ($family['selectors'] as $selector) {
|
|
if (! in_array($selector['selectorRole'] ?? 'include', ['include', 'inventory-only'], true)) {
|
|
continue;
|
|
}
|
|
|
|
$selectorType = (string) ($selector['selectorType'] ?? 'file');
|
|
$selectorValue = (string) ($selector['selectorValue'] ?? '');
|
|
|
|
if ($selectorValue === '') {
|
|
continue;
|
|
}
|
|
|
|
$matches = match ($selectorType) {
|
|
'file' => $filePath === $selectorValue,
|
|
'path' => str_starts_with($filePath, rtrim($selectorValue, '/')),
|
|
default => false,
|
|
};
|
|
|
|
if ($matches) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $family
|
|
*/
|
|
private static function familyMatchScore(array $family, string $filePath): int
|
|
{
|
|
$score = 0;
|
|
|
|
foreach ($family['selectors'] as $selector) {
|
|
if (! in_array($selector['selectorRole'] ?? 'include', ['include', 'inventory-only'], true)) {
|
|
continue;
|
|
}
|
|
|
|
$selectorType = (string) ($selector['selectorType'] ?? 'file');
|
|
$selectorValue = (string) ($selector['selectorValue'] ?? '');
|
|
|
|
if ($selectorType === 'file' && $selectorValue === $filePath) {
|
|
$score = max($score, 2_000 + strlen($selectorValue));
|
|
}
|
|
|
|
if ($selectorType === 'path' && str_starts_with($filePath, rtrim($selectorValue, '/'))) {
|
|
$score = max($score, 1_000 + strlen($selectorValue));
|
|
}
|
|
}
|
|
|
|
if (in_array($filePath, $family['hotspotFiles'], true)) {
|
|
$score = max($score, 1_500 + strlen($filePath));
|
|
}
|
|
|
|
return $score;
|
|
}
|
|
} |