forked from fa/breadcrumb-the-shire
Restructure Support dashboard into 2 KPI groups (Tickets + Contracts) with section dividers, merge escalations and recommendations into "Attention needed". Redesign Sales dashboard with clickable contract timeline and dialog. Add CSS shimmer skeleton loading for all dashboard tabs and aside. Unify vertical rhythm via * + * sibling combinator on tab content containers. Remove ~265 lines of dead CSS (contract cards, escalation styles) and unused JS functions. Refactor hydrateTicketCategoryFilter into generic hydrateSelectFilter. Fix role="button" CSS bleed from shell and remove extra future year from timeline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
184 lines
6.9 KiB
PHP
184 lines
6.9 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Tests\Architecture;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class AgentSystemEnforcementContractTest extends TestCase
|
|
{
|
|
use AgentSystemContractSupport;
|
|
use ProjectFileAssertionSupport;
|
|
|
|
public function testEnforcementPolicyReferencesOnlyKnownQualityGates(): void
|
|
{
|
|
$gates = $this->decodeAgentJson('.agents/checks/quality-gates.json');
|
|
$policy = $this->decodeAgentJson('.agents/checks/enforcement-policy.json');
|
|
|
|
$knownGateIds = array_map(
|
|
static fn (array $gate): string => (string) ($gate['id'] ?? ''),
|
|
is_array($gates['gates'] ?? null) ? $gates['gates'] : []
|
|
);
|
|
|
|
$required = is_array($policy['gate_policy']['required_gates_always'] ?? null)
|
|
? $policy['gate_policy']['required_gates_always']
|
|
: [];
|
|
$manual = is_array($policy['gate_policy']['manual_non_blocking'] ?? null)
|
|
? $policy['gate_policy']['manual_non_blocking']
|
|
: [];
|
|
$periodic = is_array($policy['gate_policy']['periodic_non_blocking'] ?? null)
|
|
? $policy['gate_policy']['periodic_non_blocking']
|
|
: [];
|
|
|
|
foreach (array_merge($required, $manual, $periodic) as $gateId) {
|
|
$this->assertContains($gateId, $knownGateIds, 'Unknown gate id in enforcement-policy.json: ' . (string) $gateId);
|
|
}
|
|
}
|
|
|
|
public function testGuardEnforcementMapCoversAllGuardsExactlyOnce(): void
|
|
{
|
|
$catalog = $this->decodeAgentJson('.agents/checks/guard-catalog.json');
|
|
$map = $this->decodeAgentJson('.agents/checks/guard-enforcement-map.json');
|
|
|
|
$knownGuardIds = array_map(
|
|
static fn (array $guard): string => (string) ($guard['id'] ?? ''),
|
|
is_array($catalog['guards'] ?? null) ? $catalog['guards'] : []
|
|
);
|
|
$entries = is_array($map['entries'] ?? null) ? $map['entries'] : [];
|
|
$mappedGuardIds = array_map(
|
|
static fn (array $entry): string => (string) ($entry['guard_id'] ?? ''),
|
|
$entries
|
|
);
|
|
|
|
$this->assertSame($mappedGuardIds, array_values(array_unique($mappedGuardIds)), 'Duplicate guard_id in guard-enforcement-map.json.');
|
|
|
|
foreach ($knownGuardIds as $guardId) {
|
|
$this->assertContains($guardId, $mappedGuardIds, 'Missing guard mapping: ' . $guardId);
|
|
}
|
|
foreach ($mappedGuardIds as $guardId) {
|
|
$this->assertContains($guardId, $knownGuardIds, 'Unknown guard_id in guard-enforcement-map.json: ' . $guardId);
|
|
}
|
|
|
|
foreach ($entries as $entry) {
|
|
$mode = (string) ($entry['enforcement_mode'] ?? '');
|
|
$this->assertContains(
|
|
$mode,
|
|
['automated', 'hybrid', 'reviewer'],
|
|
'Invalid enforcement_mode in guard-enforcement-map.json for ' . (string) ($entry['guard_id'] ?? '')
|
|
);
|
|
}
|
|
}
|
|
|
|
public function testAutomatedGuardMappingsReferenceExistingTestFiles(): void
|
|
{
|
|
$map = $this->decodeAgentJson('.agents/checks/guard-enforcement-map.json');
|
|
$entries = is_array($map['entries'] ?? null) ? $map['entries'] : [];
|
|
$root = $this->projectRootPath();
|
|
|
|
foreach ($entries as $entry) {
|
|
$guardId = (string) ($entry['guard_id'] ?? '');
|
|
$mode = (string) ($entry['enforcement_mode'] ?? '');
|
|
if ($mode !== 'automated') {
|
|
continue;
|
|
}
|
|
|
|
$sources = is_array($entry['evidence_source'] ?? null) ? $entry['evidence_source'] : [];
|
|
$testPaths = array_values(array_filter(
|
|
$sources,
|
|
static fn ($source): bool => is_string($source) && str_starts_with($source, 'tests/')
|
|
));
|
|
|
|
$this->assertNotSame([], $testPaths, "Automated guard {$guardId} must reference at least one test file in evidence_source.");
|
|
|
|
foreach ($testPaths as $testPath) {
|
|
$this->assertFileExists($root . '/' . $testPath, "Automated guard {$guardId} references missing test file: {$testPath}");
|
|
}
|
|
}
|
|
}
|
|
|
|
public function testGuardAndGateReferencesAcrossRepoUseKnownIds(): void
|
|
{
|
|
$catalog = $this->decodeAgentJson('.agents/checks/guard-catalog.json');
|
|
$gates = $this->decodeAgentJson('.agents/checks/quality-gates.json');
|
|
|
|
$knownGuardIds = array_fill_keys(array_map(
|
|
static fn (array $guard): string => (string) ($guard['id'] ?? ''),
|
|
is_array($catalog['guards'] ?? null) ? $catalog['guards'] : []
|
|
), true);
|
|
$knownGateIds = array_fill_keys(array_map(
|
|
static fn (array $gate): string => (string) ($gate['id'] ?? ''),
|
|
is_array($gates['gates'] ?? null) ? $gates['gates'] : []
|
|
), true);
|
|
|
|
$unknown = [];
|
|
foreach ($this->idReferenceScanFiles() as $relativePath) {
|
|
$content = $this->readProjectFile($relativePath);
|
|
|
|
preg_match_all('/\bGR-(CORE|TEST|UI|SEC)-[A-Z0-9]{3,}\b/', $content, $guardMatches);
|
|
foreach (array_unique($guardMatches[0]) as $id) {
|
|
if (!isset($knownGuardIds[$id])) {
|
|
$unknown[] = $relativePath . ': ' . $id;
|
|
}
|
|
}
|
|
|
|
preg_match_all('/\bQG-[0-9]{3}\b/', $content, $gateMatches);
|
|
foreach (array_unique($gateMatches[0]) as $id) {
|
|
if (!isset($knownGateIds[$id])) {
|
|
$unknown[] = $relativePath . ': ' . $id;
|
|
}
|
|
}
|
|
}
|
|
|
|
$unknown = array_values(array_unique($unknown));
|
|
sort($unknown);
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$unknown,
|
|
"Unknown GR/QG references found in repository:\n" . implode("\n", $unknown)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function idReferenceScanFiles(): array
|
|
{
|
|
$root = $this->projectRootPath();
|
|
$scanTargets = ['CLAUDE.md', '.agents', 'docs', 'tests', 'bin', '.gitea'];
|
|
$extensions = ['md', 'json', 'php', 'sh', 'yml', 'yaml'];
|
|
|
|
$files = [];
|
|
foreach ($scanTargets as $target) {
|
|
$fullPath = $root . '/' . $target;
|
|
if (is_file($fullPath)) {
|
|
$files[] = $target;
|
|
continue;
|
|
}
|
|
if (!is_dir($fullPath)) {
|
|
continue;
|
|
}
|
|
|
|
$iterator = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($fullPath, \FilesystemIterator::SKIP_DOTS)
|
|
);
|
|
foreach ($iterator as $file) {
|
|
if (!$file->isFile()) {
|
|
continue;
|
|
}
|
|
|
|
$ext = strtolower((string) $file->getExtension());
|
|
if (!in_array($ext, $extensions, true)) {
|
|
continue;
|
|
}
|
|
|
|
$relativePath = str_replace($root . '/', '', $file->getPathname());
|
|
$files[] = $relativePath;
|
|
}
|
|
}
|
|
|
|
$files = array_values(array_unique($files));
|
|
sort($files);
|
|
return $files;
|
|
}
|
|
}
|