refactor: reorganize project structure for better maintainability

- Move 45 debug/test files from root to organized scripts/ directories
- Secure public/ directory by removing debug files (security improvement)
- Create structured scripts organization:
  • scripts/debug/      (20 files) - Framework debugging tools
  • scripts/test/       (18 files) - Test and validation scripts
  • scripts/maintenance/ (5 files) - Maintenance utilities
  • scripts/dev/         (2 files) - Development tools

Security improvements:
- Removed all debug/test files from public/ directory
- Only production files remain: index.php, health.php

Root directory cleanup:
- Reduced from 47 to 2 PHP files in root
- Only essential production files: console.php, worker.php

This improves:
 Security (no debug code in public/)
 Organization (clear separation of concerns)
 Maintainability (easy to find and manage scripts)
 Professional structure (clean root directory)
This commit is contained in:
2025-10-05 10:59:15 +02:00
parent 03e5188644
commit 887847dde6
77 changed files with 3902 additions and 787 deletions

View File

@@ -1,96 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $title ?></title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="admin-page">
<div class="admin-header">
<h1>Framework Admin Dashboard</h1>
</div>
<div class="admin-nav">
<a href="/admin" class="active">Dashboard</a>
<a href="/admin/routes">Routen</a>
<a href="/admin/services">Dienste</a>
<a href="/admin/environment">Umgebung</a>
<a href="/admin/performance">Performance</a>
<a href="/admin/redis">Redis</a>
<a href="/admin/phpinfo">PHP Info</a>
</div>
<div class="admin-content">
<div class="dashboard-stats">
<div class="stat-box">
<h3>Framework Version</h3>
<div class="stat-value"><?= $stats['frameworkVersion'] ?></div>
</div>
<div class="stat-box">
<h3>PHP Version</h3>
<div class="stat-value"><?= $stats['phpVersion'] ?></div>
</div>
<div class="stat-box">
<h3>Speicherverbrauch</h3>
<div class="stat-value"><?= $stats['memoryUsage'] ?></div>
</div>
<div class="stat-box">
<h3>Max. Speicherverbrauch</h3>
<div class="stat-value"><?= $stats['peakMemoryUsage'] ?></div>
</div>
<div class="stat-box">
<h3>Server</h3>
<div class="stat-value"><?= $stats['serverInfo'] ?></div>
</div>
<div class="stat-box">
<h3>Serverzeit</h3>
<div class="stat-value"><?= $stats['serverTime'] ?></div>
</div>
<div class="stat-box">
<h3>Zeitzone</h3>
<div class="stat-value"><?= $stats['timezone'] ?></div>
</div>
<div class="stat-box">
<h3>Betriebssystem</h3>
<div class="stat-value"><?= $stats['operatingSystem'] ?></div>
</div>
<div class="stat-box">
<h3>Server Uptime</h3>
<div class="stat-value"><?= $stats['uptime'] ?></div>
</div>
<div class="stat-box">
<h3>Aktive Sessions</h3>
<div class="stat-value"><?= $stats['sessionCount'] ?></div>
</div>
<div class="stat-box">
<h3>Registrierte Dienste</h3>
<div class="stat-value"><?= $stats['servicesCount'] ?></div>
</div>
</div>
<div class="admin-section">
<h2>PHP Erweiterungen</h2>
<div class="extensions-list">
<?php foreach ($stats['loadedExtensions'] as $extension): ?>
<span class="extension-badge"><?= $extension ?></span>
<?php endforeach; ?>
</div>
</div>
</div>
<div class="admin-footer">
<p>&copy; <?= date('Y') ?> Framework Admin</p>
</div>
</body>
</html>

View File

@@ -1,86 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="admin-page">
<div class="admin-header">
<h1>Umgebungsvariablen</h1>
</div>
<div class="admin-nav">
<a href="/admin">Dashboard</a>
<a href="/admin/routes">Routen</a>
<a href="/admin/services">Dienste</a>
<a href="/admin/environment" class="active">Umgebung</a>
<a href="/admin/performance">Performance</a>
<a href="/admin/redis">Redis</a>
<a href="/admin/phpinfo">PHP Info</a>
</div>
<div class="admin-content">
<div class="admin-tools">
<input type="text" id="envFilter" placeholder="Variablen filtern..." class="search-input">
<div class="filter-tags">
<button class="filter-tag" data-prefix="APP_">APP_</button>
<button class="filter-tag" data-prefix="DB_">DB_</button>
<button class="filter-tag" data-prefix="REDIS_">REDIS_</button>
<button class="filter-tag" data-prefix="RATE_LIMIT_">RATE_LIMIT_</button>
<button class="filter-tag" data-prefix="PHP_">PHP_</button>
<button class="filter-tag active" data-prefix="">Alle</button>
</div>
</div>
<table class="admin-table" id="envTable">
<thead>
<tr>
<th>Variable</th>
<th>Wert</th>
</tr>
</thead>
<tbody>
<for var="envVar" in="env">
<tr>
<td>{{ envVar.key }}</td>
<td>{{ envVar.value }}</td>
</tr>
</for>
</tbody>
</table>
</div>
<div class="admin-footer">
<p>&copy; {{ current_year }} Framework Admin</p>
</div>
<script>
// Filterung der Umgebungsvariablen
document.getElementById('envFilter').addEventListener('input', filterTable);
// Tag-Filter
document.querySelectorAll('.filter-tag').forEach(tag => {
tag.addEventListener('click', function() {
document.querySelectorAll('.filter-tag').forEach(t => t.classList.remove('active'));
this.classList.add('active');
const prefix = this.getAttribute('data-prefix');
document.getElementById('envFilter').value = prefix;
filterTable();
});
});
function filterTable() {
const filterValue = document.getElementById('envFilter').value.toLowerCase();
const rows = document.querySelectorAll('#envTable tbody tr');
rows.forEach(row => {
const key = row.cells[0].textContent.toLowerCase();
row.style.display = key.includes(filterValue) ? '' : 'none';
});
}
</script>
</body>
</html>

View File

@@ -1,250 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Manager</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container-fluid">
<h1>Image Manager</h1>
<div class="row">
<!-- Image Slots Section -->
<div class="col-md-6">
<h2>Image Slots</h2>
<div id="image-slots" class="list-group">
<for var="slot" in="slots">
<div class="list-group-item slot-item" data-slot-id="{{ slot.id }}">
<div class="d-flex justify-content-between align-items-center">
<div>
<h5>{{ slot.slotName }}</h5>
<small class="text-muted">ID: {{ slot.id }}</small>
</div>
<div class="slot-image-container" style="width: 100px; height: 100px;">
<div class="border border-dashed d-flex align-items-center justify-content-center h-100"
ondrop="handleDrop(event, '{{ slot.id }}')"
ondragover="handleDragOver(event)"
ondragleave="handleDragLeave(event)">
<span class="text-muted">Drop image here or click to select</span>
</div>
</div>
</div>
</div>
</for>
</div>
</div>
<!-- Available Images Section -->
<div class="col-md-6">
<h2>Available Images</h2>
<!-- Search Bar -->
<div class="mb-3">
<input type="text"
id="image-search"
class="form-control"
placeholder="Search images..."
onkeyup="searchImages()">
</div>
<!-- Images Grid -->
<div id="images-grid" class="row g-2">
<for var="image" in="images">
<div class="col-md-4 image-item"
data-filename="{{ image.originalFilename }}"
data-alt="{{ image.altText }}">
<div class="card">
<img src="/media/images/{{ image.path }}"
alt="{{ image.altText }}"
class="card-img-top"
style="height: 150px; object-fit: cover; cursor: move;"
draggable="true"
ondragstart="handleDragStart(event, '{{ image.ulid }}')"
onclick="selectImage('{{ image.ulid }}')">
<div class="card-body p-2">
<small class="text-truncate d-block">
{{ image.originalFilename }}
</small>
<small class="text-muted">
{{ image.width }}x{{ image.height }} {{ image.fileSize }}KB
</small>
</div>
</div>
</div>
</for>
</div>
</div>
</div>
</div>
<!-- Image Selection Modal -->
<div class="modal fade" id="imageSelectModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Select Image for <span id="modal-slot-name"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="modal-images" class="row g-2">
<!-- Images will be loaded here -->
</div>
</div>
</div>
</div>
</div>
<script>
// Current dragging image
let draggedImageUlid = null;
let selectedSlotId = null;
// Handle drag start
function handleDragStart(event, imageUlid) {
draggedImageUlid = imageUlid;
event.dataTransfer.effectAllowed = 'copy';
}
// Handle drag over
function handleDragOver(event) {
event.preventDefault();
event.currentTarget.classList.add('bg-light');
}
// Handle drag leave
function handleDragLeave(event) {
event.currentTarget.classList.remove('bg-light');
}
// Handle drop
async function handleDrop(event, slotId) {
event.preventDefault();
event.currentTarget.classList.remove('bg-light');
if (draggedImageUlid) {
await assignImageToSlot(slotId, draggedImageUlid);
}
}
// Assign image to slot
async function assignImageToSlot(slotId, imageUlid) {
try {
const response = await fetch(`/api/image-slots/${slotId}/image`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ image_ulid: imageUlid })
});
if (response.ok) {
location.reload(); // Simple reload for now
} else {
alert('Failed to assign image');
}
} catch (error) {
console.error('Error:', error);
alert('Error assigning image');
}
}
// Remove image from slot
async function removeImage(slotId) {
if (!confirm('Remove image from this slot?')) return;
try {
const response = await fetch(`/api/image-slots/${slotId}/image`, {
method: 'DELETE'
});
if (response.ok) {
location.reload();
} else {
alert('Failed to remove image');
}
} catch (error) {
console.error('Error:', error);
alert('Error removing image');
}
}
// Select image (click handler)
function selectImage(imageUlid) {
// Find which slot was clicked if any
const clickedSlot = document.querySelector('.slot-item.selecting');
if (clickedSlot) {
const slotId = clickedSlot.dataset.slotId;
assignImageToSlot(slotId, imageUlid);
clickedSlot.classList.remove('selecting');
}
}
// Search images
function searchImages() {
const searchTerm = document.getElementById('image-search').value.toLowerCase();
const imageItems = document.querySelectorAll('.image-item');
imageItems.forEach(item => {
const filename = item.dataset.filename.toLowerCase();
const alt = item.dataset.alt.toLowerCase();
if (filename.includes(searchTerm) || alt.includes(searchTerm)) {
item.style.display = '';
} else {
item.style.display = 'none';
}
});
}
// Add click handler to slots for selection mode
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.slot-item').forEach(slot => {
const container = slot.querySelector('.slot-image-container');
if (container && !container.querySelector('img')) {
container.style.cursor = 'pointer';
container.addEventListener('click', function() {
// Remove previous selection
document.querySelectorAll('.slot-item').forEach(s => s.classList.remove('selecting'));
// Mark as selecting
slot.classList.add('selecting');
// Highlight available images
document.getElementById('images-grid').classList.add('selecting-mode');
});
}
});
});
// Add some CSS
const style = document.createElement('style');
style.textContent = `
.slot-item.selecting {
border: 2px solid #0d6efd;
background-color: #e7f1ff;
}
.selecting-mode .card {
cursor: pointer;
}
.selecting-mode .card:hover {
border-color: #0d6efd;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
.border-dashed {
border-style: dashed !important;
}
.object-fit-cover {
object-fit: cover;
}
`;
document.head.appendChild(style);
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -1,125 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="admin-page">
<div class="admin-header">
<h1>Performance-Übersicht</h1>
</div>
<div class="admin-nav">
<a href="/admin">Dashboard</a>
<a href="/admin/routes">Routen</a>
<a href="/admin/services">Dienste</a>
<a href="/admin/environment">Umgebung</a>
<a href="/admin/performance" class="active">Performance</a>
<a href="/admin/redis">Redis</a>
<a href="/admin/phpinfo">PHP Info</a>
</div>
<div class="admin-content">
<div class="dashboard-stats">
<div class="stat-box">
<h3>Aktueller Speicherverbrauch</h3>
<div class="stat-value">{{ performance.currentMemoryUsage }}</div>
</div>
<div class="stat-box">
<h3>Maximaler Speicherverbrauch</h3>
<div class="stat-value">{{ performance.peakMemoryUsage }}</div>
</div>
<div class="stat-box">
<h3>Speicherlimit</h3>
<div class="stat-value">{{ performance.memoryLimit }}</div>
</div>
<div class="stat-box">
<h3>Speicherauslastung</h3>
<div class="stat-value">
<div class="progress-bar">
<div class="progress" style="width: {{ performance.memoryUsagePercentage }}%"></div>
</div>
<div class="progress-value">{{ performance.memoryUsagePercentage }}%</div>
</div>
</div>
<div class="stat-box">
<h3>Systemlast (1/5/15 min)</h3>
<div class="stat-value">
{{ performance.loadAverage.0 }} /
{{ performance.loadAverage.1 }} /
{{ performance.loadAverage.2 }}
</div>
</div>
<div class="stat-box">
<h3>OPCache aktiviert</h3>
<div class="stat-value">{{ performance.opcacheEnabled }}</div>
</div>
<div if="performance.opcacheMemoryUsage">
<div class="stat-box">
<h3>OPCache Speicherverbrauch</h3>
<div class="stat-value">{{ performance.opcacheMemoryUsage }}</div>
</div>
<div class="stat-box">
<h3>OPCache Cache Hits</h3>
<div class="stat-value">{{ performance.opcacheCacheHits }}</div>
</div>
<div class="stat-box">
<h3>OPCache Miss Rate</h3>
<div class="stat-value">{{ performance.opcacheMissRate }}</div>
</div>
</div>
<div class="stat-box">
<h3>Ausführungszeit</h3>
<div class="stat-value">{{ performance.executionTime }}</div>
</div>
<div class="stat-box">
<h3>Geladene Dateien</h3>
<div class="stat-value">{{ performance.includedFiles }}</div>
</div>
</div>
<div class="admin-section">
<h2>Geladene Dateien</h2>
<div class="admin-tools">
<input type="text" id="fileFilter" placeholder="Dateien filtern..." class="search-input">
</div>
<div class="file-list" id="fileList">
<for var="file" in="performance.files">
<div class="file-item">
{{ file }}
</div>
</for>
</div>
</div>
</div>
<div class="admin-footer">
<p>&copy; {{ current_year }} Framework Admin</p>
</div>
<script>
document.getElementById('fileFilter').addEventListener('input', function() {
const filterValue = this.value.toLowerCase();
const items = document.querySelectorAll('#fileList .file-item');
items.forEach(item => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(filterValue) ? '' : 'none';
});
});
</script>
</body>
</html>

View File

@@ -1,94 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="admin-page">
<div class="admin-header">
<h1>Redis-Informationen</h1>
</div>
<div class="admin-nav">
<a href="/admin">Dashboard</a>
<a href="/admin/routes">Routen</a>
<a href="/admin/services">Dienste</a>
<a href="/admin/environment">Umgebung</a>
<a href="/admin/performance">Performance</a>
<a href="/admin/redis" class="active">Redis</a>
<a href="/admin/phpinfo">PHP Info</a>
</div>
<div class="admin-content">
<div class="dashboard-stats">
<div class="stat-box">
<h3>Status</h3>
<div class="stat-value status-connected">{{ redis.status }}</div>
</div>
<div class="stat-box">
<h3>Version</h3>
<div class="stat-value">{{ redis.version }}</div>
</div>
<div class="stat-box">
<h3>Uptime</h3>
<div class="stat-value">{{ redis.uptime }}</div>
</div>
<div class="stat-box">
<h3>Speicherverbrauch</h3>
<div class="stat-value">{{ redis.memory }}</div>
</div>
<div class="stat-box">
<h3>Max. Speicherverbrauch</h3>
<div class="stat-value">{{ redis.peak_memory }}</div>
</div>
<div class="stat-box">
<h3>Verbundene Clients</h3>
<div class="stat-value">{{ redis.clients }}</div>
</div>
<div class="stat-box">
<h3>Anzahl Schlüssel</h3>
<div class="stat-value">{{ redis.keys }}</div>
</div>
</div>
<div class="admin-section">
<h2>Schlüssel (max. 50 angezeigt)</h2>
<div class="admin-tools">
<input type="text" id="keyFilter" placeholder="Schlüssel filtern..." class="search-input">
</div>
<div class="key-list" id="keyList">
<for var="key" in="redis.key_sample">
<div class="key-item">
{{ key }}
</div>
</for>
</div>
</div>
</div>
<div class="admin-footer">
<p>&copy; {{ current_year }} Framework Admin</p>
</div>
<script>
document.getElementById('keyFilter')?.addEventListener('input', function() {
const filterValue = this.value.toLowerCase();
const items = document.querySelectorAll('#keyList .key-item');
items.forEach(item => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(filterValue) ? '' : 'none';
});
});
</script>
</body>
</html>

View File

@@ -1,69 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $title ?></title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="admin-page">
<div class="admin-header">
<h1>Routen-Übersicht</h1>
</div>
<div class="admin-nav">
<a href="/admin">Dashboard</a>
<a href="/admin/routes" class="active">Routen</a>
<a href="/admin/services">Dienste</a>
<a href="/admin/environment">Umgebung</a>
<a href="/admin/performance">Performance</a>
<a href="/admin/redis">Redis</a>
<a href="/admin/phpinfo">PHP Info</a>
</div>
<div class="admin-content">
<div class="admin-tools">
<input type="text" id="routeFilter" placeholder="Routen filtern..." class="search-input">
</div>
<table class="admin-table" id="routesTable">
<thead>
<tr>
<th>Pfad</th>
<th>Methode</th>
<th>Controller</th>
<th>Aktion</th>
<th>Name</th>
</tr>
</thead>
<tbody>
<?php foreach ($routes as $route): ?>
<tr>
<td><?= $route->path ?></td>
<td><?= $route->method ?></td>
<td><?= $route->controllerClass ?></td>
<td><?= $route->methodName ?></td>
<td><?= $route->name ?? '-' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="admin-footer">
<p>&copy; <?= date('Y') ?> Framework Admin</p>
</div>
<script>
document.getElementById('routeFilter').addEventListener('input', function() {
const filterValue = this.value.toLowerCase();
const rows = document.querySelectorAll('#routesTable tbody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(filterValue) ? '' : 'none';
});
});
</script>
</body>
</html>

View File

@@ -1,67 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="admin-page">
<div class="admin-header">
<h1>Registrierte Dienste</h1>
</div>
<div class="admin-nav">
<a href="/admin">Dashboard</a>
<a href="/admin/routes">Routen</a>
<a href="/admin/services" class="active">Dienste</a>
<a href="/admin/environment">Umgebung</a>
<a href="/admin/performance">Performance</a>
<a href="/admin/redis">Redis</a>
<a href="/admin/phpinfo">PHP Info</a>
</div>
<div class="admin-content">
<div class="admin-tools">
<input type="text" id="serviceFilter" placeholder="Dienste filtern..." class="search-input">
<span class="services-count">{{ servicesCount }} Dienste insgesamt</span>
</div>
<div class="service-list" id="serviceList">
<for var="service" in="services">
<div class="service-item">
<div class="service-name">{{ service.name }}</div>
<div class="service-category">
<span class="category-badge">{{ service.category }}</span>
<if condition="service.subCategory">
<span class="subcategory-badge">{{ service.subCategory }}</span>
</if>
</div>
</div>
</for>
</div>
</div>
<div class="admin-footer">
<p>&copy; {{ date('Y') }} Framework Admin</p>
</div>
<script>
document.getElementById('serviceFilter').addEventListener('input', function() {
const filterValue = this.value.toLowerCase();
const items = document.querySelectorAll('#serviceList .service-item');
let visibleCount = 0;
items.forEach(item => {
const text = item.textContent.toLowerCase();
const isVisible = text.includes(filterValue);
item.style.display = isVisible ? '' : 'none';
if (isVisible) visibleCount++;
});
document.querySelector('.services-count').textContent =
visibleCount + ' von ' + {{ servicesCount }} + ' Diensten';
});
</script>
</body>
</html>

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Framework\DI;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Reflection\ReflectionProvider;
final readonly class ContainerIntrospector
{
public function __construct(
private Container $container,
private InstanceRegistry $instances,
private BindingRegistry $bindings,
private ReflectionProvider $reflectionProvider,
private \Closure $resolutionChainProvider
) {
}
/**
* @return array<string>
*/
public function listBindings(): array
{
return array_keys($this->bindings->getAllBindings());
}
public function getBinding(string $abstract): callable|string|object|null
{
return $this->bindings->getBinding($abstract);
}
/**
* @return array<string>
*/
public function listSingletons(): array
{
return $this->instances->getSingletons();
}
/**
* @return array<string>
*/
public function listInstances(): array
{
return $this->instances->getInstanceKeys();
}
/** @param class-string $class */
public function isSingleton(string $class): bool
{
return $this->instances->isMarkedAsSingleton($class) || $this->instances->hasSingleton($class);
}
/**
* @return array<class-string>
*/
public function getResolutionChain(): array
{
$f = $this->resolutionChainProvider;
/** @var array<class-string> $chain */
$chain = $f();
return $chain;
}
/** @param class-string $class */
public function isInstantiable(string $class): bool
{
if ($class === '') {
return false;
}
$className = ClassName::create($class);
if (! $className->exists()) {
return false;
}
return $this->reflectionProvider->getClass($className)->isInstantiable();
}
/**
* Describe resolution state and constructor parameters for diagnostics.
* @param class-string $class
* @return array<string,mixed>
*/
public function describe(string $class): array
{
$className = ClassName::create($class);
$exists = $className->exists();
$hasBinding = $this->bindings->hasBinding($class);
$hasInstance = $this->instances->hasInstance($class) || $this->instances->hasSingleton($class);
$singletonMarked = $this->instances->isMarkedAsSingleton($class);
$instantiable = false;
$constructor = [
'has_constructor' => false,
'parameters' => [],
];
$binding = $this->bindings->getBinding($class);
$bindingType = null;
if ($binding !== null) {
$bindingType = is_callable($binding) ? 'callable' : (is_string($binding) ? 'string' : 'object');
}
if ($exists) {
try {
$reflection = $this->reflectionProvider->getClass($className);
$instantiable = $reflection->isInstantiable();
if ($reflection->hasMethod('__construct')) {
$ctor = $reflection->getConstructor();
if ($ctor !== null) {
$constructor['has_constructor'] = true;
foreach ($ctor->getParameters() as $param) {
$type = $param->getType();
$typeName = null;
$isBuiltin = false;
if ($type instanceof \ReflectionNamedType) {
$typeName = $type->getName();
$isBuiltin = $type->isBuiltin();
} elseif ($type !== null) {
// union or complex type - string cast
$typeName = (string) $type;
}
$resolvable = true;
if ($typeName !== null && ! $isBuiltin) {
// best-effort check for class/interface
$resolvable = $this->container->has($typeName);
}
$constructor['parameters'][] = [
'name' => $param->getName(),
'type' => $typeName,
'allows_null' => $type?->allowsNull() ?? true,
'is_builtin' => $isBuiltin,
'has_default' => $param->isDefaultValueAvailable(),
'resolvable' => $resolvable,
];
}
}
}
} catch (\Throwable $e) {
// Keep defaults if reflection fails, but include error message for diagnostics.
$constructor['error'] = $e->getMessage();
}
}
$suggestions = [];
if (! $exists) {
$suggestions[] = 'Class does not exist - check namespace and autoloading.';
} elseif (! $instantiable && ! $hasBinding) {
$suggestions[] = 'Class is not instantiable - add a binding from interface/abstract to a concrete implementation.';
}
if (! $hasBinding && $instantiable && ($constructor['has_constructor'] ?? false)) {
foreach ($constructor['parameters'] as $p) {
if ($p['type'] && ! $p['is_builtin'] && ! $p['resolvable']) {
$suggestions[] = "Add binding for dependency '{$p['type']}' or ensure it is instantiable.";
}
}
}
$chain = $this->getResolutionChain();
return [
'class' => $class,
'exists' => $exists,
'instantiable' => $instantiable,
'has_binding' => $hasBinding,
'binding_type' => $bindingType,
'has_instance' => $hasInstance,
'singleton_marked' => $singletonMarked,
'constructor' => $constructor,
'resolution_chain' => $chain,
'counts' => [
'bindings' => count($this->bindings->getAllBindings()),
'singletons' => count($this->instances->getSingletons()),
'instances' => count($this->instances->getInstanceKeys()),
],
'suggestions' => array_values(array_unique($suggestions)),
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mcp\Tools;
use App\Framework\Mcp\McpTool;
use App\Framework\Router\CompiledRoutes;
use App\Framework\Router\RouteInspector;
/**
* MCP tool exposing routing sanity checks
*/
final readonly class RouteInspectorTool
{
public function __construct(
private CompiledRoutes $compiledRoutes
) {
}
#[McpTool(
name: 'route_sanity_check',
description: 'Analyze compiled routes for common issues (missing controllers/actions, parameter mismatches, duplicates)'
)]
public function routeSanityCheck(): array
{
try {
$inspector = new RouteInspector($this->compiledRoutes);
return $inspector->analyze();
} catch (\Throwable $e) {
return [
'error' => $e->getMessage(),
];
}
}
}

View File

@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use ReflectionClass;
use ReflectionMethod;
/**
* Performs sanity checks on compiled routes (controller/action presence, parameter consistency, etc.)
*/
final readonly class RouteInspector
{
public function __construct(
private CompiledRoutes $compiledRoutes
) {
}
/**
* Analyze compiled routes and return structured diagnostics
* @return array<string, mixed>
*/
public function analyze(): array
{
$issues = [];
$staticRoutes = $this->compiledRoutes->getStaticRoutes();
$namedRoutes = $this->compiledRoutes->getAllNamedRoutes();
$totalStatic = 0;
// Track seen routes for potential duplicates per method+subdomain+path
$seen = [];
foreach ($staticRoutes as $method => $subdomains) {
foreach ($subdomains as $subdomain => $paths) {
foreach ($paths as $path => $route) {
$totalStatic++;
$key = "{$method}|{$subdomain}|{$path}";
$seen[$key] = ($seen[$key] ?? 0) + 1;
$routeName = $route->name ?? null;
// Controller existence
$controller = $route->controller ?? null;
$action = $route->action ?? null;
if (!is_string($controller) || $controller === '' || !class_exists($controller)) {
$issues[] = $this->issue('controller_missing', 'error', $method, $subdomain, $path, $routeName, "Controller class not found or invalid: " . var_export($controller, true));
continue; // skip further checks for this route
}
// Action existence and visibility
if (!is_string($action) || $action === '') {
$issues[] = $this->issue('action_missing', 'error', $method, $subdomain, $path, $routeName, 'Action method not defined or invalid');
} else {
$refClass = new ReflectionClass($controller);
if (!$refClass->hasMethod($action)) {
$issues[] = $this->issue('action_missing', 'error', $method, $subdomain, $path, $routeName, "Action method '{$action}' not found in {$controller}");
} else {
$refMethod = $refClass->getMethod($action);
if (!$refMethod->isPublic()) {
$issues[] = $this->issue('action_not_public', 'warning', $method, $subdomain, $path, $routeName, "Action method '{$action}' is not public");
}
// Parameter consistency check (placeholders vs method signature)
$this->checkParameterConsistency($issues, $method, $subdomain, $path, $routeName, $route, $refMethod);
}
}
}
}
}
// Duplicate path checks (should normally be prevented by map keys, but guard anyway)
foreach ($seen as $k => $count) {
if ($count > 1) {
[$m, $sub, $p] = explode('|', $k, 3);
$issues[] = $this->issue('duplicate_route', 'error', $m, $sub, $p, null, "Duplicate route detected for {$m} {$sub} {$p}");
}
}
// Named routes basic validation: ensure name -> route is consistent
$namedIssues = $this->validateNamedRoutes($namedRoutes);
array_push($issues, ...$namedIssues);
$summary = [
'total_static_routes' => $totalStatic,
'total_named_routes' => count($namedRoutes),
'issue_count' => count($issues),
];
return [
'summary' => $summary,
'issues' => $issues,
'stats' => $this->compiledRoutes->getStats(),
];
}
/**
* Check that route parameters in path are consistent with action method signature
*/
private function checkParameterConsistency(array &$issues, string $method, string $subdomain, string $path, ?string $routeName, object $route, ReflectionMethod $refMethod): void
{
$pathParams = $this->extractPathParams($path);
// Try to read expected parameters from route definition; otherwise from reflection
$expected = [];
if (isset($route->parameters) && is_array($route->parameters)) {
// If associative, use keys; if list, use values
$keys = array_keys($route->parameters);
$expected = array_values(array_filter(
count($keys) !== count($route->parameters) ? $route->parameters : $keys,
fn($v) => is_string($v) && $v !== ''
));
} else {
$expected = array_map(
static fn(\ReflectionParameter $p) => $p->getName(),
$refMethod->getParameters()
);
}
// Normalize unique sets
$pathSet = array_values(array_unique($pathParams));
$expectedSet = array_values(array_unique($expected));
// Missing placeholders in path for expected parameters
$missingInPath = array_values(array_diff($expectedSet, $pathSet));
if (!empty($missingInPath)) {
$issues[] = $this->issue(
'param_mismatch',
'warning',
$method,
$subdomain,
$path,
$routeName,
'Expected parameters not present in path: ' . implode(', ', $missingInPath)
);
}
// Extra placeholders not expected by the action
$extraInPath = array_values(array_diff($pathSet, $expectedSet));
if (!empty($extraInPath)) {
$issues[] = $this->issue(
'param_mismatch',
'warning',
$method,
$subdomain,
$path,
$routeName,
'Path has placeholders not expected by action: ' . implode(', ', $extraInPath)
);
}
}
/**
* Validate named routes (basic structural checks)
* @param array<string, object> $namedRoutes
* @return array<int, array<string, mixed>>
*/
private function validateNamedRoutes(array $namedRoutes): array
{
$issues = [];
foreach ($namedRoutes as $name => $route) {
// Minimal: ensure a path exists
$path = $route->path ?? null;
if (!is_string($path) || $path === '') {
$issues[] = [
'type' => 'invalid_named_route',
'severity' => 'error',
'route_name' => $name,
'message' => 'Named route has no valid path',
];
}
}
return $issues;
}
/**
* Extract placeholders from route path like /users/{id}/posts/{slug}
* @return array<int, string>
*/
private function extractPathParams(string $path): array
{
$matches = [];
preg_match_all('/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', $path, $matches);
/** @var array<int, string> $params */
$params = $matches[1] ?? [];
return $params;
}
/**
* Build a standardized issue array
* @return array<string, mixed>
*/
private function issue(string $type, string $severity, string $method, string $subdomain, string $path, ?string $name, string $message): array
{
return [
'type' => $type,
'severity' => $severity,
'route' => [
'method' => $method,
'subdomain' => $subdomain,
'path' => $path,
'name' => $name,
],
'message' => $message,
];
}
}