From 0a05f3911cd6909cd572b380dff79fba4c6a990a Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Mon, 4 May 2026 16:06:44 +0200 Subject: [PATCH] =?UTF-8?q?Replace=20Psalm=20with=20PHPStan=20+=20PHP?= =?UTF-8?q?=E2=80=91CS=E2=80=91Fixer=20+=20Biome,=20add=20linting=20config?= =?UTF-8?q?s=20&=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed the `vimeo/psalm` dependency and all related files (`psalm.xml`, `psalm‑baseline.xml`, suppress annotations). - Added **PHPStan** (v2.1.54) and **PHP‑CS‑Fixer** (v3.95.1) to `vendor/bin/`. - Created `phpstan.neon` (level 5, bootstraps `app/bootstrap.php`, scans `Parsedown.php`). - Created `phpstan‑baseline.neon` with 10 pre‑existing errors. - Added `.php‑cs‑fixer.dist.php` (PSR‑12 + PHP80Migration, targets `app/src` & `app/tests`). - Added `biome.json` and updated `justfile` to replace the old Psalm recipes with `phpstan`, `cs‑check`, and `cs‑fix`. - Updated `.gitignore` to exclude PHPStan and PHP‑CS‑Fixer cache files. - Updated several JS files (`file‑preview.js`, `file‑upload‑queue.js`) eand PHP controllers (`MediaController.php`, `SearchController.php`, `SystemController.php`). - Minor adjustments to `TODO.md`, `app/src/Database.php`, `app/src/Parsedown.php`, `app/src/ShareLink.php`, and `app/src/SmtpRelay.php`. --- .gitignore | 6 +++ .php-cs-fixer.dist.php | 25 +++++++++++ TODO.md | 17 ++++++- app/public/assets/js/file-preview.js | 24 +++++----- app/public/assets/js/file-upload-queue.js | 46 +++++++++---------- app/src/Controllers/MediaController.php | 9 +--- app/src/Controllers/SearchController.php | 1 - app/src/Controllers/SystemController.php | 10 ++--- app/src/Database.php | 6 +-- app/src/Parsedown.php | 2 +- app/src/ShareLink.php | 4 +- app/src/SmtpRelay.php | 2 +- biome.json | 15 +++++++ justfile | 16 +++++++ phpstan-baseline.neon | 55 +++++++++++++++++++++++ phpstan.neon | 11 +++++ 16 files changed, 191 insertions(+), 58 deletions(-) create mode 100644 .php-cs-fixer.dist.php create mode 100644 biome.json create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon diff --git a/.gitignore b/.gitignore index 6ab9c4d..275c0a8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,9 @@ Thumbs.db .idea/ /node_modules + +# PHPStan cache +.phpstan.result.cache + +# PHP CS Fixer cache +.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..0be22dd --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,25 @@ +setRiskyAllowed(false) + ->setRules([ + '@PSR12' => true, + '@PHP80Migration' => true, + 'array_syntax' => ['syntax' => 'short'], + 'no_unused_imports' => true, + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'single_quote' => true, + 'trailing_comma_in_multiline' => true, + ]) + ->setFinder( + (new Finder()) + ->in(__DIR__ . '/app/src') + ->in(__DIR__ . '/app/tests') + ->name('*.php') + ) +; diff --git a/TODO.md b/TODO.md index 79c985b..0e2779d 100644 --- a/TODO.md +++ b/TODO.md @@ -6,4 +6,19 @@ - [x] Simplify `test-*` recipes - [x] Remove redundant `default` recipe - [x] Preserve all critical functionality -- [x] Enhance `serve` recipe to automatically open the browser \ No newline at end of file +- [x] Enhance `serve` recipe to automatically open the browser +- [x] Keep `serve` recipe in the foreground (browser open backgrounded, PHP server blocks) +- [x] Add `psalm` recipe (auto-inits config on first run, then analyses) +- [x] Fix all genuine Psalm errors (InvalidOperand, UnusedVariable, InvalidReturnType, NullableReturnStatement, InvalidArrayOffset, UnusedForeachValue, RedundantFunctionCall) +- [x] Generate psalm-baseline.xml to suppress false positives (UndefinedConstant, PossiblyUnused*, UnusedClass) +- [x] Add `lint-biome` recipe; fix all JS errors and warnings (arrow functions, template literals, noRedundantUseStrict, noUnusedVariables, useIterableCallbackReturn) +- [x] Replace Psalm with PHPStan + PHP-CS-Fixer + - [x] Remove vimeo/psalm and all its deps from vendor/ + - [x] Install phpstan.phar (2.1.54) and php-cs-fixer.phar (3.95.1) in vendor/bin/ + - [x] Create phpstan.neon (level 5, bootstraps app/bootstrap.php, scanFiles Parsedown) + - [x] Generate phpstan-baseline.neon (10 pre-existing errors baselined) + - [x] Create .php-cs-fixer.dist.php (PSR-12 + PHP80Migration, targets app/src + app/tests) + - [x] Replace `psalm` justfile recipe with `phpstan`, `cs-check`, `cs-fix` + - [x] Remove psalm.xml, psalm-baseline.xml + - [x] Remove @psalm-suppress annotations from SmtpRelay.php and RateLimit.php + - [x] Add .phpstan.result.cache and .php-cs-fixer.cache to .gitignore diff --git a/app/public/assets/js/file-preview.js b/app/public/assets/js/file-preview.js index aeb6575..f9326ea 100644 --- a/app/public/assets/js/file-preview.js +++ b/app/public/assets/js/file-preview.js @@ -4,9 +4,7 @@ * renders a list of selected files with thumbnails (images) or file-type icons * (PDFs, videos, archives…) and the filename + size. */ -(function () { - 'use strict'; - +(() => { const ICON = { pdf: '📄', video: '🎬', @@ -27,10 +25,10 @@ } function humanSize(bytes) { - if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(2) + ' GB'; - if (bytes >= 1048576) return (bytes / 1048576).toFixed(2) + ' MB'; - if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'; - return bytes + ' B'; + if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(2)} GB`; + if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(2)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${bytes} B`; } function renderPreview(input, container) { @@ -38,7 +36,7 @@ const files = Array.from(input.files); if (!files.length) return; - files.forEach(function (file) { + files.forEach((file) => { const item = document.createElement('div'); item.className = 'fp-item'; @@ -47,7 +45,7 @@ img.className = 'fp-thumb'; img.alt = file.name; const reader = new FileReader(); - reader.onload = function (e) { img.src = e.target.result; }; + reader.onload = (e) => { img.src = e.target.result; }; reader.readAsDataURL(file); item.appendChild(img); } else { @@ -77,12 +75,12 @@ } function init() { - document.querySelectorAll('input[type="file"][data-preview]').forEach(function (input) { - var containerId = input.getAttribute('data-preview'); - var container = document.getElementById(containerId); + document.querySelectorAll('input[type="file"][data-preview]').forEach((input) => { + const containerId = input.getAttribute('data-preview'); + const container = document.getElementById(containerId); if (!container) return; - input.addEventListener('change', function () { + input.addEventListener('change', () => { renderPreview(input, container); }); }); diff --git a/app/public/assets/js/file-upload-queue.js b/app/public/assets/js/file-upload-queue.js index 60f5b27..34b35d6 100644 --- a/app/public/assets/js/file-upload-queue.js +++ b/app/public/assets/js/file-upload-queue.js @@ -15,9 +15,7 @@ * 2. Legacy single-file previews (data-preview="CONTAINER_ID") * - Backward-compatible with cover-image and banner inputs. */ -(function () { - 'use strict'; - +(() => { /* ── Helpers ──────────────────────────────────────────────────────────── */ const ICONS = { @@ -43,10 +41,10 @@ } function humanSize(bytes) { - if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(2) + ' GB'; - if (bytes >= 1048576) return (bytes / 1048576).toFixed(2) + ' MB'; - if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'; - return bytes + ' B'; + if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(2)} GB`; + if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(2)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${bytes} B`; } function esc(str) { @@ -60,9 +58,9 @@ function syncInputFiles(input, fileArray) { try { const dt = new DataTransfer(); - fileArray.forEach(f => dt.items.add(f)); + for (const f of fileArray) dt.items.add(f); input.files = dt.files; - } catch (e) { + } catch { // DataTransfer not available in older browsers — graceful degradation. } } @@ -80,9 +78,9 @@ let fileArray = []; // Keep SortableJS instance reference - let sortable = null; + let _sortable = null; if (typeof Sortable !== 'undefined') { - sortable = Sortable.create(queue, { + _sortable = Sortable.create(queue, { animation: 150, handle: '.fq-drag-handle', ghostClass: 'fq-ghost', @@ -90,7 +88,7 @@ }); } - picker.addEventListener('change', function () { + picker.addEventListener('change', () => { const newFiles = Array.from(picker.files); fileArray = fileArray.concat(newFiles); renderQueue(); @@ -108,7 +106,7 @@ } empty.style.display = 'none'; - fileArray.forEach(function (file, idx) { + fileArray.forEach((file, idx) => { const li = document.createElement('li'); li.className = 'fq-item'; li.setAttribute('data-idx', idx); @@ -125,7 +123,7 @@ ''; // Remove button - li.querySelector('.fq-remove').addEventListener('click', function () { + li.querySelector('.fq-remove').addEventListener('click', () => { fileArray.splice(idx, 1); renderQueue(); }); @@ -150,12 +148,12 @@ // Remove previous hidden fields const form = picker.closest('form'); if (!form) return; - form.querySelectorAll('.fq-hidden-label, .fq-hidden-order').forEach(el => el.remove()); + for (const el of form.querySelectorAll('.fq-hidden-label, .fq-hidden-order')) el.remove(); // Inject current labels and order indices // We use the queue DOM (post-sort) as the source of truth. const items = Array.from(queue.querySelectorAll('.fq-item')); - items.forEach(function (li, sortedIdx) { + items.forEach((li, sortedIdx) => { const labelVal = li.querySelector('.fq-label').value; const lInput = document.createElement('input'); @@ -177,7 +175,7 @@ // Before form submit, inject hidden fields so labels are up-to-date const form = picker.closest('form'); if (form) { - form.addEventListener('submit', function () { + form.addEventListener('submit', () => { syncInputFiles(picker, fileArray); injectHiddenFields(); }); @@ -194,11 +192,11 @@ animation: 150, handle: '.admin-file-drag-handle', ghostClass: 'fq-ghost', - onEnd: function () { + onEnd: () => { // Update the hidden file_sort_order[] inputs to reflect new order const items = list.querySelectorAll('.admin-file-list-item[data-file-id]'); - list.querySelectorAll('input[name="file_sort_order[]"]').forEach(el => el.remove()); - items.forEach(function (li) { + for (const el of list.querySelectorAll('input[name="file_sort_order[]"]')) el.remove(); + items.forEach((li) => { const inp = document.createElement('input'); inp.type = 'hidden'; inp.name = 'file_sort_order[]'; @@ -212,7 +210,7 @@ /* ── Legacy single-file preview (data-preview="CONTAINER_ID") ─────────── */ function initLegacyPreviews() { - document.querySelectorAll('input[type="file"][data-preview]').forEach(function (input) { + document.querySelectorAll('input[type="file"][data-preview]').forEach((input) => { // Skip the TFE multi-file picker (handled by queue above) if (input.id === 'tfe-files-input') return; @@ -220,7 +218,7 @@ const container = document.getElementById(containerId); if (!container) return; - input.addEventListener('change', function () { + input.addEventListener('change', () => { renderLegacyPreview(input, container); }); }); @@ -231,7 +229,7 @@ const files = Array.from(input.files); if (!files.length) return; - files.forEach(function (file) { + files.forEach((file) => { const item = document.createElement('div'); item.className = 'fp-item'; @@ -240,7 +238,7 @@ img.className = 'fp-thumb'; img.alt = file.name; const reader = new FileReader(); - reader.onload = function (e) { img.src = e.target.result; }; + reader.onload = (e) => { img.src = e.target.result; }; reader.readAsDataURL(file); item.appendChild(img); } else { diff --git a/app/src/Controllers/MediaController.php b/app/src/Controllers/MediaController.php index 748cd12..9cbd1cd 100644 --- a/app/src/Controllers/MediaController.php +++ b/app/src/Controllers/MediaController.php @@ -109,14 +109,9 @@ class MediaController // 5. Determine if download was explicitly requested $forceDownload = !empty($_GET['download']) && $_GET['download'] === '1'; - // File types that should be displayed inline by default - $inlineExts = ['jpg','jpeg','png','gif','webp','pdf','mp4','webm','ogv','mov', - 'mp3','ogg','oga','wav','flac','aac','m4a','vtt']; - $inline = in_array($ext, $inlineExts, true) && !$forceDownload; - // 6. Send response headers header('Content-Type: ' . $mimeType); - header('Content-Length: ' . filesize($realFull)); + header('Content-Length: ' . (int) filesize($realFull)); header('X-Content-Type-Options: nosniff'); if ($ext === 'vtt') { @@ -155,7 +150,7 @@ class MediaController */ private function streamWithRange(string $path, string $mimeType): void { - $size = filesize($path); + $size = (int) filesize($path); $start = 0; $end = $size - 1; diff --git a/app/src/Controllers/SearchController.php b/app/src/Controllers/SearchController.php index f96a0f0..d8dfe0e 100644 --- a/app/src/Controllers/SearchController.php +++ b/app/src/Controllers/SearchController.php @@ -233,7 +233,6 @@ class SearchController array $activeFilters, ): never { header("Content-Type: text/html; charset=UTF-8"); - $isHtmx = true; include APP_ROOT . "/templates/partials/repertoire-index.php"; exit(); } diff --git a/app/src/Controllers/SystemController.php b/app/src/Controllers/SystemController.php index 214dbbf..9baaa81 100644 --- a/app/src/Controllers/SystemController.php +++ b/app/src/Controllers/SystemController.php @@ -98,9 +98,9 @@ class SystemController $info = [ 'version' => PHP_VERSION, 'sapi' => PHP_SAPI, - 'memory_limit' => ini_get('memory_limit'), - 'upload_max' => ini_get('upload_max_filesize'), - 'post_max' => ini_get('post_max_size'), + 'memory_limit' => (string) ini_get('memory_limit'), + 'upload_max' => (string) ini_get('upload_max_filesize'), + 'post_max' => (string) ini_get('post_max_size'), 'max_exec' => ini_get('max_execution_time') . 's', ]; $this->cache->set('php_info', $info); @@ -123,7 +123,7 @@ class SystemController $total = (int) disk_total_space(APP_ROOT); $free = (int) disk_free_space(APP_ROOT); $used = $total - $free; - $pct = $total > 0 ? (int) round($used / $total * 100) : 0; + $pct = $total > 0 ? (int) round((float) $used / (float) $total * 100.0) : 0; $info = ['total' => $total, 'free' => $free, 'used' => $used, 'pct' => $pct]; $this->cache->set('disk_info', $info); @@ -449,7 +449,7 @@ class SystemController ]); $start = microtime(true); curl_exec($ch); - $ms = (int) round((microtime(true) - $start) * 1000); + $ms = (int) round((microtime(true) - $start) * 1000.0); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); return $code > 0 ? [$code, $ms] : null; } diff --git a/app/src/Database.php b/app/src/Database.php index 657f9e3..cef09c2 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -1242,7 +1242,7 @@ class Database { $role = in_array($member['role'], ['president', 'promoteur', 'lecteur']) ? $member['role'] : 'promoteur'; $isExternal = isset($member['is_external']) ? (int)$member['is_external'] : 0; - $stmt->execute([$thesisId, $supervisorId, $role, $isExternal, $order + 1]); + $stmt->execute([$thesisId, $supervisorId, $role, $isExternal, (int)$order + 1]); } if (!$alreadyInTransaction) { $this->pdo->commit(); @@ -1605,7 +1605,7 @@ class Database { if ($name === '') continue; $showContact = !empty($author['show_contact']); $authorId = $this->findOrCreateAuthor($name, $author['email'] ?? null, $showContact); - $stmt->execute([$thesisId, $authorId, $index + 1]); + $stmt->execute([$thesisId, $authorId, (int)$index + 1]); } } @@ -1763,7 +1763,7 @@ class Database { "UPDATE thesis_files SET sort_order = ? WHERE id = ? AND thesis_id = ?" ); foreach ($order as $i => $fileId) { - $stmt->execute([$i + 1, (int)$fileId, $thesisId]); + $stmt->execute([(int)$i + 1, (int)$fileId, $thesisId]); } } diff --git a/app/src/Parsedown.php b/app/src/Parsedown.php index 4349421..77031a2 100644 --- a/app/src/Parsedown.php +++ b/app/src/Parsedown.php @@ -1880,7 +1880,7 @@ class Parsedown if ( ! empty($Element['attributes'])) { - foreach ($Element['attributes'] as $att => $val) + foreach ($Element['attributes'] as $att => $_) { # filter out badly parsed attribute if ( ! preg_match($goodAttribute, $att)) diff --git a/app/src/ShareLink.php b/app/src/ShareLink.php index 74a2cda..64670e7 100644 --- a/app/src/ShareLink.php +++ b/app/src/ShareLink.php @@ -46,9 +46,9 @@ class ShareLink * @param int $createdBy Admin user ID * @param string|null $password Plain-text password (will be hashed), null = no password * @param string|null $expiresAt ISO-8601 expiration date, null = never expires - * @return array The created link row + * @return array|null The created link row */ - public function create(int $createdBy, ?string $password = null, ?string $expiresAt = null, ?string $objetRestriction = null): array + public function create(int $createdBy, ?string $password = null, ?string $expiresAt = null, ?string $objetRestriction = null): ?array { $slug = self::generateSlug(); $passwordHash = $password !== null ? password_hash($password, PASSWORD_BCRYPT) : null; diff --git a/app/src/SmtpRelay.php b/app/src/SmtpRelay.php index c1390a8..582ed2c 100644 --- a/app/src/SmtpRelay.php +++ b/app/src/SmtpRelay.php @@ -60,7 +60,7 @@ class SmtpRelay { /** * Fetch current SMTP settings from the DB. * - * @return array{host:string,port:int,encryption:string,username:string,password:string,from_email:string,from_name:string} + * @return array{host:string,port:int,encryption:string,username:string,password:string,from_email:string,from_name:string,notify_email:string} */ public static function getSettings(Database $db): array { $stmt = $db->getPDO()->query( diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..6b9e543 --- /dev/null +++ b/biome.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json", + "files": { + "ignoreUnknown": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "formatter": { + "enabled": false + } +} diff --git a/justfile b/justfile index 6199f11..3e5fe90 100644 --- a/justfile +++ b/justfile @@ -84,6 +84,22 @@ test: # php app/tests/Integration/SearchTest.php @php app/tests/run-tests.php +[group('test')] +lint-biome: + @biome lint app/public/assets/js/file-preview.js app/public/assets/js/file-upload-queue.js + +[group('test')] +phpstan: + @vendor/bin/phpstan analyse --memory-limit=512M + +[group('test')] +cs-check: + @vendor/bin/php-cs-fixer check --no-interaction + +[group('test')] +cs-fix: + @vendor/bin/php-cs-fixer fix --no-interaction + [group('test')] syntax: @find app/ -name '*.php' -exec php -l {} \; 2>/dev/null | grep -v 'No syntax errors' || true diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..2d8aa31 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,55 @@ +parameters: + ignoreErrors: + - + message: '#^Property SearchController\:\:\$rateLimit is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: app/src/Controllers/SearchController.php + + - + message: '#^Strict comparison using \!\=\= between mixed~\(0\|0\.0\|''''\|''0''\|array\{\}\|false\|null\) and '''' will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: app/src/Database.php + + - + message: '#^Property Dispatcher\:\:\$queryParams is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: app/src/Dispatcher.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 1 + path: app/src/Parsedown.php + + - + message: '#^Variable \$text might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: app/src/Parsedown.php + + - + message: '#^Offset ''from_email'' on array\{host\: string, port\: int, encryption\: string, username\: string, password\: string, from_email\: string, from_name\: string, notify_email\: string\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: app/src/SmtpRelay.php + + - + message: '#^Offset ''from_name'' on array\{host\: string, port\: int, encryption\: string, username\: string, password\: string, from_email\: non\-empty\-string, from_name\: string, notify_email\: string\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: app/src/SmtpRelay.php + + - + message: '#^Offset ''notify_email'' on array\{host\: string, port\: int, encryption\: string, username\: string, password\: string, from_email\: string, from_name\: string, notify_email\: string\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: app/src/SmtpRelay.php + + - + message: '#^Static method SmtpRelay\:\:htmlToPlain\(\) is unused\.$#' + identifier: method.unused + count: 1 + path: app/src/SmtpRelay.php diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..2a60caa --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,11 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 5 + paths: + - app/src + bootstrapFiles: + - app/bootstrap.php + scanFiles: + - app/src/Parsedown.php