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:
2026-04-13 23:20:05 +02:00
parent 7c10fadcb9
commit 0e86925464
371 changed files with 191 additions and 192 deletions

View File

@@ -0,0 +1,121 @@
<?php
namespace MintyPHP\Support\Search;
class SearchItemMapperProvider
{
public static function mapPreviewItem(string $key, array $data, string $query): ?array
{
$subtitle = '';
if ($key === 'users') {
$label = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? ''));
$subtitle = trim((string) ($data['email'] ?? ''));
if ($label === '' && $subtitle !== '') {
$label = $subtitle;
$subtitle = '';
}
$url = lurl('admin/users/edit/' . ($data['uuid'] ?? '')) . '?search=' . urlencode($query);
} elseif ($key === 'permissions') {
$label = (string) ($data['description'] ?? '');
$url = lurl('admin/permissions/edit/' . ($data['id'] ?? '')) . '?search=' . urlencode($query);
} elseif ($key === 'scheduled-jobs') {
$jobLabel = trim((string) ($data['label'] ?? ''));
$jobKey = trim((string) ($data['job_key'] ?? ''));
$status = trim((string) ($data['last_run_status'] ?? ''));
$label = $jobLabel !== '' ? $jobLabel : $jobKey;
if ($status !== '') {
$subtitle = strtoupper($status);
}
$url = lurl('admin/scheduled-jobs/edit/' . ($data['id'] ?? '')) . '?search=' . urlencode($query);
} elseif ($key === 'mail-log') {
$toEmail = trim((string) ($data['to_email'] ?? ''));
$subject = trim((string) ($data['subject'] ?? ''));
$status = trim((string) ($data['status'] ?? ''));
$label = $subject !== '' ? $subject : $toEmail;
$subtitleParts = [];
if ($status !== '') {
$subtitleParts[] = strtoupper($status);
}
if ($toEmail !== '' && $subject !== '') {
$subtitleParts[] = $toEmail;
}
$subtitle = implode(' — ', $subtitleParts);
$url = lurl('admin/mail-log/view/' . ($data['id'] ?? '')) . '?search=' . urlencode($query);
} elseif ($key === 'pages') {
$label = (string) ($data['slug'] ?? '');
$url = lurl('page/' . $label) . '?search=' . urlencode($query);
} else {
$label = (string) ($data['description'] ?? '');
$url = lurl('admin/' . $key . '/edit/' . ($data['uuid'] ?? '')) . '?search=' . urlencode($query);
}
if ($label === '') {
return null;
}
$item = ['label' => $label, 'url' => $url];
if ($subtitle !== '') {
$item['subtitle'] = $subtitle;
}
return $item;
}
public static function mapResultItem(string $key, string $label, array $data): ?array
{
$title = '';
$description = '';
$url = '';
$image = '';
if ($key === 'users') {
$title = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? ''));
$description = (string) ($data['email'] ?? '');
$url = lurl('admin/users/edit/' . ($data['uuid'] ?? ''));
} elseif ($key === 'permissions') {
$title = (string) ($data['key'] ?? '');
$description = (string) ($data['description'] ?? '');
$url = lurl('admin/permissions/edit/' . ($data['id'] ?? ''));
} elseif ($key === 'scheduled-jobs') {
$jobLabel = trim((string) ($data['label'] ?? ''));
$jobKey = trim((string) ($data['job_key'] ?? ''));
$title = $jobLabel !== '' ? $jobLabel : $jobKey;
$status = trim((string) ($data['last_run_status'] ?? ''));
$description = $status !== '' ? strtoupper($status) : ($jobKey !== '' ? $jobKey : '');
$url = lurl('admin/scheduled-jobs/edit/' . ($data['id'] ?? ''));
} elseif ($key === 'pages') {
$title = (string) ($data['slug'] ?? '');
$description = t('Page');
$url = lurl('page/' . $title);
} elseif ($key === 'mail-log') {
$title = trim((string) ($data['subject'] ?? ''));
if ($title === '') {
$title = trim((string) ($data['to_email'] ?? ''));
}
$status = trim((string) ($data['status'] ?? ''));
$toEmail = trim((string) ($data['to_email'] ?? ''));
$description = trim(($status !== '' ? strtoupper($status) : '') . ($toEmail !== '' ? ' — ' . $toEmail : ''));
$url = lurl('admin/mail-log/view/' . ($data['id'] ?? ''));
} else {
$title = (string) ($data['description'] ?? '');
$description = $label;
$url = lurl('admin/' . $key . '/edit/' . ($data['uuid'] ?? ''));
}
if ($title === '' || $url === '') {
return null;
}
return [
'type' => $label,
'title' => $title,
'description' => $description,
'url' => $url,
'image' => $image,
'icon' => SearchUiMetaProvider::iconForKey($key),
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace MintyPHP\Support\Search;
class SearchQueryNormalizer
{
// Prepares a user query for SQL LIKE. Escapes \ and _ (LIKE special chars),
// converts * to %, and wraps with % wildcards if not already present.
public static function normalizeLikeQuery(string $query): string
{
$query = trim($query);
if ($query === '') {
return '';
}
$query = str_replace('\\', '\\\\', $query);
$query = str_replace('_', '\\_', $query);
$query = str_replace('*', '%', $query);
if (!str_starts_with($query, '%')) {
$query = '%' . $query;
}
if (!str_ends_with($query, '%')) {
$query .= '%';
}
return $query;
}
// Strips wildcard chars for relevance scoring — score queries need a clean term, not a LIKE pattern.
public static function normalizeScoreQuery(string $query): string
{
$query = trim($query);
if ($query === '') {
return '';
}
return str_replace(['*', '%', '_'], '', $query);
}
}

View File

@@ -0,0 +1,253 @@
<?php
namespace MintyPHP\Support\Search;
use MintyPHP\Service\Docs\DocsCatalogService;
use MintyPHP\Service\Ui\HotkeyService;
class SearchSpecialResourceProvider
{
private static function hotkeyEntries(): array
{
$rows = [];
foreach (HotkeyService::list() as $row) {
$actionKey = trim($row['action_key']);
$mac = trim($row['mac']);
$win = trim($row['win']);
if ($actionKey === '' || $mac === '' || $win === '') {
continue;
}
$rows[] = [
'label' => t($actionKey),
'mac' => $mac,
'win' => $win,
];
}
return $rows;
}
// Two-tier match: first checks against the hotkey service's keyword list (e.g. "shortcut", "keyboard"),
// then falls back to substring matching against individual entry labels/key combos.
private static function matchesHotkeyQueryWith(string $query, array $entries): bool
{
$query = trim(mb_strtolower($query));
if ($query === '') {
return false;
}
$tokens = preg_split('/[^a-z0-9]+/i', $query);
$tokens = is_array($tokens) ? array_values(array_filter($tokens, static fn ($part): bool => $part !== '')) : [];
foreach (HotkeyService::searchKeywords() as $keywordRaw) {
$keyword = trim(mb_strtolower((string) $keywordRaw));
if ($keyword === '') {
continue;
}
if (in_array($keyword, $tokens, true)) {
return true;
}
foreach ($tokens as $token) {
if (strlen($token) >= 3 && str_starts_with($keyword, $token)) {
return true;
}
}
if (strlen($query) >= 3 && str_starts_with($keyword, $query)) {
return true;
}
}
foreach ($entries as $entry) {
$label = mb_strtolower((string) ($entry['label'] ?? ''));
$mac = mb_strtolower((string) ($entry['mac'] ?? ''));
$win = mb_strtolower((string) ($entry['win'] ?? ''));
if (($label !== '' && str_contains($label, $query))
|| ($mac !== '' && str_contains($mac, $query))
|| ($win !== '' && str_contains($win, $query))) {
return true;
}
}
return false;
}
private static function filterHotkeyEntries(string $query): array
{
$needle = trim(mb_strtolower($query));
$entries = self::hotkeyEntries();
if (!self::matchesHotkeyQueryWith($query, $entries)) {
return [];
}
if ($needle === '') {
return $entries;
}
$filtered = [];
foreach ($entries as $entry) {
$label = mb_strtolower((string) ($entry['label'] ?? ''));
$mac = mb_strtolower((string) ($entry['mac'] ?? ''));
$win = mb_strtolower((string) ($entry['win'] ?? ''));
if (str_contains($label, $needle) || str_contains($mac, $needle) || str_contains($win, $needle)) {
$filtered[] = $entry;
}
}
return $filtered;
}
public static function hotkeyPreviewResource(string $query): ?array
{
$entries = self::filterHotkeyEntries($query);
if ($entries === []) {
return null;
}
$items = [];
foreach ($entries as $entry) {
$items[] = [
'label' => trim((string) ($entry['label'] ?? '') . ' - ' . (string) ($entry['mac'] ?? '') . ' / ' . (string) ($entry['win'] ?? '')),
'url' => lurl('help/hotkeys'),
];
}
return [
'key' => 'hotkeys',
'label' => t('Keyboard shortcuts'),
'count' => count($items),
'url' => lurl('help/hotkeys'),
'items' => array_slice($items, 0, 5),
];
}
public static function hotkeyResultItems(string $query): array
{
$entries = self::filterHotkeyEntries($query);
if ($entries === []) {
return [];
}
$result = [];
foreach ($entries as $entry) {
$result[] = [
'type' => t('Keyboard shortcuts'),
'title' => (string) ($entry['label'] ?? ''),
'description' => trim((string) ($entry['mac'] ?? '') . ' / ' . (string) ($entry['win'] ?? '')),
'url' => lurl('help/hotkeys'),
'image' => '',
'icon' => 'bi-keyboard',
];
}
return $result;
}
private static function docsResultUrl(string $slug, string $anchor = ''): string
{
$url = lurl('admin/docs/' . $slug);
if ($anchor !== '') {
$url .= '#' . $anchor;
}
return $url;
}
public static function docsPreviewResource(string $query): ?array
{
$matches = DocsCatalogService::search($query);
if ($matches === []) {
return null;
}
$seenDocs = [];
$previewItems = [];
foreach ($matches as $match) {
$slug = trim((string) $match['slug']);
if ($slug === '') {
continue;
}
$seenDocs[$slug] = true;
if (isset($previewItems[$slug])) {
continue;
}
$title = trim((string) $match['title']);
if ($title === '') {
$title = $slug;
}
$section = trim((string) $match['section']);
$label = $title;
if ($section !== '' && mb_strtolower($section) !== mb_strtolower($title)) {
$label .= ' - ' . $section;
}
$previewItems[$slug] = [
'label' => $label,
'url' => self::docsResultUrl($slug, (string) $match['anchor']),
];
}
return [
'key' => 'docs',
'label' => t('Documentation'),
'count' => count($seenDocs),
'url' => lurl('admin/docs/' . DocsCatalogService::defaultSlug()) . '?search=' . urlencode($query),
'items' => array_values(array_slice($previewItems, 0, 5)),
];
}
public static function docsResultItems(string $query): array
{
$matches = DocsCatalogService::search($query);
if ($matches === []) {
return [];
}
$items = [];
foreach ($matches as $match) {
$slug = trim((string) $match['slug']);
$title = trim((string) $match['title']);
if ($slug === '' || $title === '') {
continue;
}
$section = trim((string) $match['section']);
$snippet = trim((string) $match['snippet']);
$descriptionParts = [];
if ($section !== '' && mb_strtolower($section) !== mb_strtolower($title)) {
$descriptionParts[] = $section;
}
if ($snippet !== '') {
$descriptionParts[] = $snippet;
}
// Map raw doc scores (01000+) to coarser search_score buckets for result ranking.
$rawScore = (int) $match['score'];
$searchScore = match (true) {
$rawScore >= 1000 => 400,
$rawScore >= 900 => 360,
$rawScore >= 800 => 330,
$rawScore >= 700 => 300,
$rawScore >= 650 => 260,
$rawScore >= 600 => 230,
$rawScore >= 500 => 200,
default => 150,
};
$items[] = [
'type' => t('Documentation'),
'title' => $title,
'description' => implode(' - ', $descriptionParts),
'url' => self::docsResultUrl($slug, (string) $match['anchor']),
'image' => '',
'icon' => 'bi-journal-text',
'search_score' => $searchScore,
];
}
return $items;
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace MintyPHP\Support\Search;
use MintyPHP\Service\Access\PermissionService;
// Defines all SQL-backed search resources. Each resource declares count/preview/result queries
// with a {{tenantFilter}} placeholder that is substituted at query time based on the user's tenant scope.
class SearchSqlResourceProvider
{
public static function tenantScopeFilters(): array
{
return [
'users' => 'and exists (select 1 from user_tenants ut where ut.user_id = users.id and ut.tenant_id in (???))',
'tenants' => 'and id in (???)',
'departments' => 'and tenant_id in (???)',
];
}
public static function resources(string $query, string $locale): array
{
$like = SearchQueryNormalizer::normalizeLikeQuery($query);
return [
[
'key' => 'users',
'label' => t('Users'),
'permission' => PermissionService::USERS_VIEW,
'countSql' => "select count(*) from users where (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}}",
'countParams' => [$like, $like, $like],
'previewSql' => "select uuid, first_name, last_name, email from users where (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}} order by last_name, first_name limit ?",
'previewParams' => [$like, $like, $like],
'resultSql' => "select uuid, first_name, last_name, email from users where (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}} order by last_name, first_name",
'resultParams' => [$like, $like, $like],
],
[
'key' => 'tenants',
'label' => t('Tenants'),
'permission' => PermissionService::TENANTS_VIEW,
'countSql' => "select count(*) from tenants where description like ? escape '\\\\' {{tenantFilter}}",
'countParams' => [$like],
'previewSql' => "select uuid, description from tenants where description like ? escape '\\\\' {{tenantFilter}} order by description limit ?",
'previewParams' => [$like],
'resultSql' => "select uuid, description from tenants where description like ? escape '\\\\' {{tenantFilter}} order by description",
'resultParams' => [$like],
],
[
'key' => 'departments',
'label' => t('Departments'),
'permission' => PermissionService::DEPARTMENTS_VIEW,
'countSql' => "select count(*) from departments where description like ? escape '\\\\' {{tenantFilter}}",
'countParams' => [$like],
'previewSql' => "select uuid, description from departments where description like ? escape '\\\\' {{tenantFilter}} order by description limit ?",
'previewParams' => [$like],
'resultSql' => "select uuid, description from departments where description like ? escape '\\\\' {{tenantFilter}} order by description",
'resultParams' => [$like],
],
[
'key' => 'roles',
'label' => t('Roles'),
'permission' => PermissionService::ROLES_VIEW,
'countSql' => "select count(*) from roles where active = 1 and description like ? escape '\\\\'",
'countParams' => [$like],
'previewSql' => "select uuid, description from roles where active = 1 and description like ? escape '\\\\' order by description limit ?",
'previewParams' => [$like],
'resultSql' => "select uuid, description from roles where active = 1 and description like ? escape '\\\\' order by description",
'resultParams' => [$like],
],
[
'key' => 'permissions',
'label' => t('Permissions'),
'permission' => PermissionService::PERMISSIONS_VIEW,
'countSql' => "select count(*) from permissions where active = 1 and (`key` like ? escape '\\\\' or description like ? escape '\\\\')",
'countParams' => [$like, $like],
'previewSql' => "select id, description, `key` from permissions where active = 1 and (`key` like ? escape '\\\\' or description like ? escape '\\\\') order by `key` limit ?",
'previewParams' => [$like, $like],
'resultSql' => "select id, description, `key` from permissions where active = 1 and (`key` like ? escape '\\\\' or description like ? escape '\\\\') order by `key`",
'resultParams' => [$like, $like],
],
[
'key' => 'scheduled-jobs',
'label' => t('Scheduled jobs'),
'permission' => PermissionService::JOBS_VIEW,
'countSql' => "select count(*) from scheduled_jobs where (job_key like ? escape '\\\\' or label like ? escape '\\\\' or description like ? escape '\\\\')",
'countParams' => [$like, $like, $like],
'previewSql' => "select id, job_key, label, enabled, next_run_at, last_run_status from scheduled_jobs where (job_key like ? escape '\\\\' or label like ? escape '\\\\' or description like ? escape '\\\\') order by label, job_key limit ?",
'previewParams' => [$like, $like, $like],
'resultSql' => "select id, job_key, label, enabled, next_run_at, last_run_status from scheduled_jobs where (job_key like ? escape '\\\\' or label like ? escape '\\\\' or description like ? escape '\\\\') order by label, job_key",
'resultParams' => [$like, $like, $like],
],
[
'key' => 'pages',
'label' => t('Pages'),
'permission' => '',
'countSql' => "select count(*) from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? escape '\\\\' or pc.content like ? escape '\\\\'",
'countParams' => [$locale, $like, $like],
'previewSql' => "select p.slug from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? escape '\\\\' or pc.content like ? escape '\\\\' order by p.slug limit ?",
'previewParams' => [$locale, $like, $like],
'resultSql' => "select p.slug from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? escape '\\\\' or pc.content like ? escape '\\\\' order by p.slug",
'resultParams' => [$locale, $like, $like],
],
[
'key' => 'mail-log',
'label' => t('Mail logs'),
'permission' => PermissionService::MAIL_LOG_VIEW,
'countSql' => "select count(*) from mail_log where (to_email like ? escape '\\\\' or subject like ? escape '\\\\' or template like ? escape '\\\\' or provider_message_id like ? escape '\\\\')",
'countParams' => [$like, $like, $like, $like],
'previewSql' => "select id, to_email, subject, status, created_at from mail_log where (to_email like ? escape '\\\\' or subject like ? escape '\\\\' or template like ? escape '\\\\' or provider_message_id like ? escape '\\\\') order by created_at desc limit ?",
'previewParams' => [$like, $like, $like, $like],
'resultSql' => "select id, to_email, subject, status, created_at from mail_log where (to_email like ? escape '\\\\' or subject like ? escape '\\\\' or template like ? escape '\\\\' or provider_message_id like ? escape '\\\\') order by created_at desc",
'resultParams' => [$like, $like, $like, $like],
],
];
}
public static function resourceKeys(): array
{
$keys = [];
foreach (self::resources('', '') as $resource) {
$key = (string) ($resource['key'] ?? '');
if ($key !== '') {
$keys[] = $key;
}
}
return $keys;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace MintyPHP\Support\Search;
class SearchUiMetaProvider
{
private const ICONS = [
'users' => 'bi-person-badge',
'tenants' => 'bi-buildings',
'departments' => 'bi-diagram-3',
'roles' => 'bi-people',
'permissions' => 'bi-shield-lock',
'scheduled-jobs' => 'bi-calendar-check',
'pages' => 'bi-file-text',
'mail-log' => 'bi-envelope-paper',
'docs' => 'bi-journal-text',
];
private const LIST_URL_KEYS = [
'users',
'tenants',
'departments',
'roles',
'permissions',
'scheduled-jobs',
'pages',
'mail-log',
];
public static function iconForKey(string $key): string
{
return self::ICONS[$key] ?? 'bi-search';
}
public static function listUrl(string $key, string $query): string
{
$encoded = urlencode($query);
return match ($key) {
'users' => lurl('admin/users') . '?search=' . $encoded,
'tenants' => lurl('admin/tenants') . '?search=' . $encoded,
'departments' => lurl('admin/departments') . '?search=' . $encoded,
'roles' => lurl('admin/roles') . '?search=' . $encoded,
'permissions' => lurl('admin/permissions') . '?search=' . $encoded,
'scheduled-jobs' => lurl('admin/scheduled-jobs') . '?search=' . $encoded,
'pages' => lurl(''),
'mail-log' => lurl('admin/mail-log') . '?search=' . $encoded,
default => lurl(''),
};
}
public static function hasIconForKey(string $key): bool
{
return array_key_exists($key, self::ICONS);
}
public static function hasListUrlForKey(string $key): bool
{
return in_array($key, self::LIST_URL_KEYS, true);
}
}