Files
breadcrumb-the-shire/tests/Architecture/RepositoryInterfaceContractTest.php
fs 0e86925464 refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root
immediately communicates which code is core platform and which lives
in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the
Composer PSR-4 path mapping moves from lib/ to core/.

Module-internal lib/ directories (modules/*/lib/) are untouched.

Updated across the full stack:
- composer.json PSR-4 mapping
- bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php)
- tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer)
- 26 architecture contract tests
- enforcement-policy, guard-catalog, quality-gates
- all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/)
- bin/qa-extended.sh search paths
- .claude/settings.local.json permission paths

Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner →
Executor → Code Review (4 findings fixed) → Security Review →
Acceptance Test → Finalizer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:20:42 +02:00

133 lines
4.4 KiB
PHP

<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
/**
* Enforces that every instance-based repository class has a co-located interface
* and correctly implements it.
*
* Excluded from this contract (static-method-only classes, no instance interface possible):
* - TenantCustomFieldDefinitionRepository
* - TenantCustomFieldOptionRepository
* - UserCustomFieldValueRepository
* - UserCustomFieldValueOptionRepository
* - PageRepository
* - PageContentRepository
*
* Also excluded (utility, not a domain repository):
* - RepoQuery
*/
class RepositoryInterfaceContractTest extends TestCase
{
use ProjectFileAssertionSupport;
/** @var list<string> */
private const EXCLUDED_CLASSES = [
'MintyPHP\\Repository\\Content\\PageContentRepository',
'MintyPHP\\Repository\\Content\\PageRepository',
'MintyPHP\\Repository\\CustomField\\TenantCustomFieldDefinitionRepository',
'MintyPHP\\Repository\\CustomField\\TenantCustomFieldOptionRepository',
'MintyPHP\\Repository\\CustomField\\UserCustomFieldValueRepository',
'MintyPHP\\Repository\\CustomField\\UserCustomFieldValueOptionRepository',
'MintyPHP\\Repository\\Support\\RepoQuery',
];
/**
* @return array<string, array{class: string, interfaceClass: string, interfaceFile: string}>
*/
private function collectRepositoryPairs(): array
{
$root = $this->projectRootPath();
$repoDir = $root . '/core/Repository';
$pairs = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($repoDir, \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if ($file->getExtension() !== 'php') {
continue;
}
$filename = $file->getFilename();
// Only concrete repository files (not interfaces, not utilities)
if (!str_ends_with($filename, 'Repository.php')) {
continue;
}
if (str_ends_with($filename, 'RepositoryInterface.php')) {
continue;
}
// Derive PSR-4 class name: core/Repository/Foo/Bar.php → MintyPHP\Repository\Foo\Bar
$relativePath = ltrim(str_replace($root, '', $file->getPathname()), '/');
$className = 'MintyPHP\\' . str_replace(['/', '.php'], ['\\', ''], substr($relativePath, strlen('core/')));
if (in_array($className, self::EXCLUDED_CLASSES, true)) {
continue;
}
$interfaceClass = $className . 'Interface';
$interfaceFile = substr($relativePath, 0, -strlen('.php')) . 'Interface.php';
$pairs[$className] = [
'class' => $className,
'interfaceClass' => $interfaceClass,
'interfaceFile' => $interfaceFile,
];
}
ksort($pairs);
return $pairs;
}
public function testEveryRepositoryHasACoLocatedInterface(): void
{
$pairs = $this->collectRepositoryPairs();
$this->assertNotEmpty($pairs, 'No repository classes found — check path resolution.');
$missing = [];
foreach ($pairs as $className => $pair) {
$root = $this->projectRootPath();
if (!file_exists($root . '/' . $pair['interfaceFile'])) {
$missing[] = $pair['interfaceFile'] . ' (for ' . $className . ')';
}
}
$this->assertSame(
[],
$missing,
"Missing interface files:\n" . implode("\n", $missing)
);
}
public function testEveryRepositoryImplementsItsInterface(): void
{
$pairs = $this->collectRepositoryPairs();
$violations = [];
foreach ($pairs as $className => $pair) {
if (!class_exists($className)) {
$violations[] = $className . ' — class not found (autoload issue?)';
continue;
}
$implemented = class_implements($className);
if ($implemented === false || !in_array($pair['interfaceClass'], $implemented, true)) {
$violations[] = $className . ' does not implement ' . $pair['interfaceClass'];
}
}
$this->assertSame(
[],
$violations,
"Repository interface contract violations:\n" . implode("\n", $violations)
);
}
}