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>
This commit is contained in:
349
core/Http/ErrorDataCollector.php
Normal file
349
core/Http/ErrorDataCollector.php
Normal file
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Debugger;
|
||||
|
||||
final class ErrorDataCollector
|
||||
{
|
||||
private const CODE_CONTEXT_LINES = 10;
|
||||
private const MAX_TRACE_FRAMES = 50;
|
||||
|
||||
private const REDACTED_HEADER_PATTERNS = [
|
||||
'authorization',
|
||||
'cookie',
|
||||
'x-api-key',
|
||||
];
|
||||
|
||||
private const REDACTED_KEY_PATTERNS = [
|
||||
'password',
|
||||
'secret',
|
||||
'token',
|
||||
'key',
|
||||
'csrf',
|
||||
];
|
||||
|
||||
private const SAFE_ENV_KEYS = [
|
||||
'APP_DEBUG',
|
||||
'APP_ENV',
|
||||
'APP_NAME',
|
||||
'APP_TIMEZONE',
|
||||
'APP_LOCALE',
|
||||
'APP_URL',
|
||||
'APP_VENDOR_PHP_ISSUES_MODE',
|
||||
'TENANT_SCOPE_STRICT',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{exception: array<string, mixed>, request: array<string, mixed>, environment: array<string, mixed>, queries: array<string, mixed>}
|
||||
*/
|
||||
public static function collect(\Throwable $exception): array
|
||||
{
|
||||
return [
|
||||
'exception' => self::collectException($exception),
|
||||
'request' => self::collectRequest(),
|
||||
'environment' => self::collectEnvironment(),
|
||||
'queries' => self::collectQueries(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function collectException(\Throwable $exception): array
|
||||
{
|
||||
$data = [
|
||||
'class' => get_class($exception),
|
||||
'message' => $exception->getMessage(),
|
||||
'code' => $exception->getCode(),
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine(),
|
||||
'trace' => self::collectStackFrames($exception),
|
||||
];
|
||||
|
||||
$previous = $exception->getPrevious();
|
||||
if ($previous !== null) {
|
||||
$data['previous'] = self::collectException($previous);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private static function collectStackFrames(\Throwable $exception): array
|
||||
{
|
||||
$frames = [];
|
||||
|
||||
// Frame 0 is the exception origin itself.
|
||||
$frames[] = [
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine(),
|
||||
'function' => null,
|
||||
'class' => null,
|
||||
'code_snippet' => self::extractCodeSnippet($exception->getFile(), $exception->getLine()),
|
||||
];
|
||||
|
||||
foreach (array_slice($exception->getTrace(), 0, self::MAX_TRACE_FRAMES) as $frame) {
|
||||
$file = $frame['file'] ?? null;
|
||||
$line = $frame['line'] ?? null;
|
||||
$function = $frame['function'];
|
||||
$class = $frame['class'] ?? null;
|
||||
|
||||
$frames[] = [
|
||||
'file' => $file,
|
||||
'line' => $line,
|
||||
'function' => $function,
|
||||
'class' => $class,
|
||||
'args_summary' => self::summarizeArgs($frame['args'] ?? []),
|
||||
'code_snippet' => ($file !== null && $line !== null)
|
||||
? self::extractCodeSnippet($file, $line)
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
return $frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string> Line number => code line
|
||||
*/
|
||||
private static function extractCodeSnippet(string $file, int $line): array
|
||||
{
|
||||
try {
|
||||
if (!is_file($file) || !is_readable($file)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = @file($file, FILE_IGNORE_NEW_LINES);
|
||||
if ($lines === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$start = max(0, $line - self::CODE_CONTEXT_LINES - 1);
|
||||
$end = min(count($lines), $line + self::CODE_CONTEXT_LINES);
|
||||
|
||||
$snippet = [];
|
||||
for ($i = $start; $i < $end; $i++) {
|
||||
$snippet[$i + 1] = $lines[$i];
|
||||
}
|
||||
|
||||
return $snippet;
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<mixed> $args
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function summarizeArgs(array $args): array
|
||||
{
|
||||
$summary = [];
|
||||
foreach ($args as $arg) {
|
||||
$summary[] = match (true) {
|
||||
is_null($arg) => 'null',
|
||||
is_bool($arg) => $arg ? 'true' : 'false',
|
||||
is_int($arg) => 'int',
|
||||
is_float($arg) => 'float',
|
||||
is_string($arg) => 'string(' . strlen($arg) . ')',
|
||||
is_array($arg) => 'array(' . count($arg) . ')',
|
||||
is_object($arg) => get_class($arg),
|
||||
is_resource($arg) => 'resource',
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function collectRequest(): array
|
||||
{
|
||||
$data = [
|
||||
'request_id' => null,
|
||||
'channel' => 'web',
|
||||
'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
|
||||
'url' => ($_SERVER['REQUEST_URI'] ?? ''),
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||
'user_agent' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
|
||||
'headers' => self::collectHeaders(),
|
||||
'get' => $_GET,
|
||||
'post' => self::redactParams($_POST),
|
||||
'session_keys' => [],
|
||||
'user_id' => null,
|
||||
'tenant_id' => null,
|
||||
];
|
||||
|
||||
// Safe request context access.
|
||||
try {
|
||||
$data['request_id'] = RequestContext::currentId() ?? RequestContext::id();
|
||||
} catch (\Throwable) {
|
||||
// RequestContext may not be initialized.
|
||||
}
|
||||
|
||||
try {
|
||||
$ctx = RequestContext::context();
|
||||
$data['channel'] = $ctx['channel'] ?? 'web';
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
// Session data — keys and types only, never values.
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
$data['session_keys'] = self::describeSessionKeys($_SESSION ?? []);
|
||||
$data['user_id'] = $_SESSION['user']['id'] ?? $_SESSION['user_id'] ?? null;
|
||||
$data['tenant_id'] = $_SESSION['current_tenant']['id'] ?? $_SESSION['tenant_id'] ?? null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function collectHeaders(): array
|
||||
{
|
||||
$headers = [];
|
||||
foreach ($_SERVER as $key => $value) {
|
||||
if (!str_starts_with($key, 'HTTP_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = strtolower(str_replace('_', '-', substr($key, 5)));
|
||||
$headers[$name] = self::shouldRedactHeader($name) ? '[REDACTED]' : (string) $value;
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
private static function shouldRedactHeader(string $name): bool
|
||||
{
|
||||
$lower = strtolower($name);
|
||||
foreach (self::REDACTED_HEADER_PATTERNS as $pattern) {
|
||||
if (str_contains($lower, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function redactParams(array $params): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($params as $key => $value) {
|
||||
$result[$key] = self::shouldRedactParam((string) $key) ? '[REDACTED]' : $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function shouldRedactParam(string $key): bool
|
||||
{
|
||||
$lower = strtolower($key);
|
||||
foreach (self::REDACTED_KEY_PATTERNS as $pattern) {
|
||||
if (str_contains($lower, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function describeSessionKeys(array $session): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($session as $key => $value) {
|
||||
$result[(string) $key] = get_debug_type($value);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function collectEnvironment(): array
|
||||
{
|
||||
$data = [
|
||||
'php_version' => PHP_VERSION,
|
||||
'php_sapi' => PHP_SAPI,
|
||||
'extensions' => get_loaded_extensions(),
|
||||
'memory_current' => memory_get_usage(true),
|
||||
'memory_peak' => memory_get_peak_usage(true),
|
||||
'env' => self::collectSafeEnv(),
|
||||
'modules' => [],
|
||||
];
|
||||
|
||||
// Active modules — may fail if container is unavailable.
|
||||
try {
|
||||
if (function_exists('app')) {
|
||||
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
|
||||
$modules = $registry->getModules();
|
||||
$data['modules'] = array_map(
|
||||
fn (object $m): string => $m->id ?? (string) $m,
|
||||
$modules,
|
||||
);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function collectSafeEnv(): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach (self::SAFE_ENV_KEYS as $key) {
|
||||
$value = $_ENV[$key] ?? $_SERVER[$key] ?? null;
|
||||
if ($value !== null) {
|
||||
$result[$key] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function collectQueries(): array
|
||||
{
|
||||
try {
|
||||
if (!class_exists(Debugger::class, false) || !Debugger::$enabled) {
|
||||
return ['available' => false, 'queries' => [], 'start' => null];
|
||||
}
|
||||
|
||||
$queries = Debugger::get('queries');
|
||||
$start = Debugger::get('start');
|
||||
|
||||
if (!is_array($queries)) {
|
||||
$queries = [];
|
||||
}
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'queries' => $queries,
|
||||
'start' => is_float($start) ? $start : null,
|
||||
'count' => count($queries),
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
return ['available' => false, 'queries' => [], 'start' => null];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user