fix: catalogue "on loan" count includes books with no copies #725
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Code Quality | |
| # Fast static-analysis checks: dependency CVEs, translation drift, | |
| # route integrity, Tailwind JIT safety, plugin schema rules. | |
| # No database or browser required — runs in ~60 seconds. | |
| on: | |
| push: | |
| branches: [main] | |
| paths: | |
| - '**.php' | |
| - '**.js' | |
| - 'locale/**' | |
| - 'composer.json' | |
| - 'package.json' | |
| - 'installer/database/schema.sql' | |
| - 'version.json' | |
| - '.github/workflows/ci-quality.yml' | |
| pull_request: | |
| branches: [main] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| quality: | |
| name: Static quality checks | |
| runs-on: ubuntu-latest | |
| # MySQL for the DB-backed unit tests (migration-*, book-field-types, | |
| # loan-edge-cases, session-fixes). Without it those tests can't read a DB | |
| # and would abort — the migration behavioural tests are mandatory per | |
| # updater.md, so CI provides a real database instead of skipping them. | |
| services: | |
| mysql: | |
| image: mysql:8.0 | |
| env: | |
| MYSQL_ROOT_PASSWORD: root | |
| MYSQL_DATABASE: pinakes_test | |
| ports: | |
| - 3306:3306 | |
| options: >- | |
| --health-cmd="mysqladmin ping" | |
| --health-interval=10s | |
| --health-timeout=5s | |
| --health-retries=5 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Setup PHP | |
| uses: shivammathur/setup-php@v2 | |
| with: | |
| php-version: '8.2' | |
| extensions: mysqli, pdo_mysql | |
| coverage: none | |
| github-token: '' | |
| - name: Setup Node | |
| uses: actions/setup-node@v5 | |
| with: | |
| node-version: '22' | |
| # ── Dependency CVE scanning ─────────────────────────────────────────── | |
| - name: composer audit (known CVEs) | |
| run: | | |
| composer install --no-interaction --prefer-dist --quiet | |
| composer audit --no-dev --abandoned=ignore 2>&1 || { echo "⚠ composer audit: vulnerabilities found (see above)"; exit 1; } | |
| - name: npm audit (known CVEs) | |
| run: | | |
| npm ci --silent 2>/dev/null || npm install --silent | |
| npm audit --audit-level=high 2>&1 || { echo "⚠ npm audit: high/critical vulnerabilities found"; exit 1; } | |
| continue-on-error: true # Warn, don't block — js devDependencies may have unfixed advisories | |
| # ── Translation completeness ────────────────────────────────────────── | |
| - name: Translation key parity (en_US ↔ de_DE, placeholders en/de/it) | |
| run: | | |
| FAILED=0 | |
| # 1. Exact key-set parity for non-Italian translations only. | |
| # it_IT is intentionally sparse: __() falls back to the key itself | |
| # (keys are native Italian text), so it_IT.json only needs overrides. | |
| for PAIR in "en_US:de_DE"; do | |
| A=${PAIR%%:*}; B=${PAIR##*:} | |
| MISSING=$(comm -23 <(jq -r 'keys[]' locale/${A}.json | sort) <(jq -r 'keys[]' locale/${B}.json | sort)) | |
| EXTRA=$(comm -13 <(jq -r 'keys[]' locale/${A}.json | sort) <(jq -r 'keys[]' locale/${B}.json | sort)) | |
| if [ -n "$MISSING" ]; then | |
| echo "✗ ${B}.json manca di chiavi presenti in ${A}.json:" | |
| echo "$MISSING" | sed 's/^/ /' | |
| FAILED=1 | |
| fi | |
| if [ -n "$EXTRA" ]; then | |
| echo "✗ ${B}.json ha chiavi extra non presenti in ${A}.json:" | |
| echo "$EXTRA" | sed 's/^/ /' | |
| FAILED=1 | |
| fi | |
| done | |
| # 2. Placeholder parity: %s / %d must match count between en_US, de_DE and it_IT | |
| python3 - <<'PYEOF' | |
| import json, re, sys | |
| en = json.load(open('locale/en_US.json')) | |
| de = json.load(open('locale/de_DE.json')) | |
| it = json.load(open('locale/it_IT.json')) | |
| ph = re.compile(r'%(?:\d+\$)?[sd]') | |
| failed = 0 | |
| for locale_name, data in [('de_DE', de), ('it_IT', it)]: | |
| for k in en: | |
| if k not in data: | |
| continue | |
| ep = sorted(ph.findall(str(en[k]))) | |
| lp = sorted(ph.findall(str(data[k]))) | |
| if ep != lp: | |
| print(f'✗ Placeholder mismatch for key "{k[:80]}": en={ep} {locale_name}={lp}') | |
| failed = 1 | |
| sys.exit(failed) | |
| PYEOF | |
| [ "$FAILED" -eq 0 ] && echo "✓ Parità chiavi e placeholder confermata" || exit 1 | |
| # ── Route key integrity ─────────────────────────────────────────────── | |
| - name: Route key integrity (route_path() keys exist in routes_it_IT.json) | |
| run: | | |
| ROUTE_KEYS=$(python3 -c "import json; d=json.load(open('locale/routes_it_IT.json')); [print(k) for k in d.keys()]") | |
| FALLBACK_KEYS=$(php -r "require 'vendor/autoload.php'; echo implode(PHP_EOL, App\\Support\\RouteTranslator::getStaticFallbackKeys());") | |
| ALL_KEYS=$(printf '%s\n%s' "$ROUTE_KEYS" "$FALLBACK_KEYS" | sort -u) | |
| FAILED=0 | |
| while IFS= read -r key; do | |
| if ! echo "$ALL_KEYS" | grep -qx "$key"; then | |
| echo " ✗ route_path('$key') usato in views ma mancante in routes_it_IT.json o fallbackRoutes" | |
| FAILED=1 | |
| fi | |
| done < <(grep -rohE "route_path\('([^']+)'\)" app/Views/ | grep -oE "'[^']+'" | tr -d "'" | sort -u) | |
| [ "$FAILED" -eq 0 ] && echo "✓ Tutte le chiavi route_path() esistono in routes_it_IT.json o fallbackRoutes" || exit 1 | |
| # ── Tailwind JIT safety ─────────────────────────────────────────────── | |
| - name: Tailwind JIT — no dynamic class construction | |
| run: | | |
| VIOLATIONS=$(grep -rn \ | |
| -e "'bg-'\s*\.\s*\$" \ | |
| -e '"bg-"\s*\.\s*\$' \ | |
| -e "'text-'\s*\.\s*\$" \ | |
| -e '"text-"\s*\.\s*\$' \ | |
| -e "'border-'\s*\.\s*\$" \ | |
| -e '"border-"\s*\.\s*\$' \ | |
| --include="*.php" app/ storage/plugins/ 2>/dev/null || true) | |
| if [ -n "$VIOLATIONS" ]; then | |
| echo "✗ Classi Tailwind costruite dinamicamente (non generate dal JIT):" | |
| echo "$VIOLATIONS" | |
| exit 1 | |
| fi | |
| echo "✓ Nessuna classe Tailwind dinamica trovata" | |
| # ── Plugin ensureSchema() rule ──────────────────────────────────────── | |
| - name: Plugin ensureSchema() rule (CLAUDE.md absolute rule) | |
| run: | | |
| FAILED=0 | |
| for file in storage/plugins/*/*.php; do | |
| [ -f "$file" ] || continue | |
| if grep -q 'CREATE TABLE' "$file"; then | |
| if ! grep -q 'ensureSchema()' "$file"; then | |
| echo " ✗ $(basename $(dirname $file))/$(basename $file): CREATE TABLE senza ensureSchema()" | |
| FAILED=1 | |
| elif ! awk '/function onActivate/,/^[[:space:]]*}/' "$file" | grep -q 'ensureSchema'; then | |
| echo " ✗ $(basename $(dirname $file))/$(basename $file): ensureSchema() non in onActivate()" | |
| FAILED=1 | |
| elif ! awk '/function onInstall/,/^[[:space:]]*}/' "$file" | grep -q 'ensureSchema'; then | |
| echo " ✗ $(basename $(dirname $file))/$(basename $file): ensureSchema() non in onInstall()" | |
| FAILED=1 | |
| else | |
| echo " ✓ $(basename $(dirname $file))/$(basename $file)" | |
| fi | |
| fi | |
| done | |
| [ "$FAILED" -eq 0 ] || exit 1 | |
| # ── Soft-delete guard ───────────────────────────────────────────────── | |
| - name: Soft-delete guard (libri queries must include deleted_at IS NULL) | |
| run: | | |
| VIOLATIONS=0 | |
| while IFS= read -r file; do | |
| if grep -qiE 'FROM[[:space:]]+`?libri`?[[:space:],)]' "$file" 2>/dev/null; then | |
| if ! grep -qiE 'deleted_at[[:space:]]+IS[[:space:]]+NULL' "$file"; then | |
| echo " ⚠ $file: query FROM libri senza deleted_at IS NULL" | |
| VIOLATIONS=$((VIOLATIONS + 1)) | |
| fi | |
| fi | |
| done < <(find app/ -name "*.php" -not -path "*/vendor/*") | |
| if [ "$VIOLATIONS" -gt 0 ]; then | |
| echo "⚠ $VIOLATIONS file potrebbero mancare del soft-delete guard" | |
| echo " Verificare manualmente se le query sono intentenzionali (query admin, conteggi, ecc.)" | |
| else | |
| echo "✓ Soft-delete guard presente in tutti i file con query su libri" | |
| fi | |
| # ── Autoloader safety ───────────────────────────────────────────────── | |
| - name: Autoloader phpstan-free | |
| run: | | |
| if [ -f vendor/composer/autoload_static.php ]; then | |
| COUNT=$(grep -c "phpstan" vendor/composer/autoload_static.php || echo 0) | |
| if [ "$COUNT" -gt 0 ]; then | |
| echo "✗ autoload_static.php contiene $COUNT riferimenti a phpstan (run composer install --no-dev)" | |
| exit 1 | |
| fi | |
| echo "✓ autoloader phpstan-free" | |
| else | |
| echo "ℹ vendor/ not installed — skipping" | |
| fi | |
| # ── Migration version guard ─────────────────────────────────────────── | |
| - name: Migration version guard (all migrate_*.sql ≤ version.json) | |
| run: | | |
| TARGET=$(php -r "echo json_decode(file_get_contents('version.json'))->version;") | |
| FAILED=0 | |
| for f in installer/database/migrations/migrate_*.sql; do | |
| V=$(basename "$f" | sed 's/migrate_//;s/\.sql//') | |
| if ! php -r "exit(version_compare('$V','$TARGET','<=') ? 0 : 1);"; then | |
| echo " ✗ $(basename $f): $V > $TARGET (sarebbe ignorata dall'updater)" | |
| FAILED=1 | |
| fi | |
| done | |
| [ "$FAILED" -eq 0 ] && echo "✓ Tutte le migration hanno versione ≤ $TARGET" || exit 1 | |
| # DB-backed unit tests (migration-*, book-field-types, loan-edge-cases, | |
| # session-fixes) read credentials from .env and connect over TCP. Provide | |
| # a CI .env pointing at the MySQL service and load the base schema so the | |
| # migration behavioural tests run against a real, current-schema database. | |
| - name: Prepare DB for unit tests (.env + schema) | |
| run: | | |
| cat > .env <<'ENVEOF' | |
| DB_HOST=127.0.0.1 | |
| DB_PORT=3306 | |
| DB_USER=root | |
| DB_PASS=root | |
| DB_NAME=pinakes_test | |
| ENVEOF | |
| # Wait until MySQL answers, then import the base schema + triggers. | |
| for i in $(seq 1 30); do | |
| mysql -h 127.0.0.1 -u root -proot -e "SELECT 1" >/dev/null 2>&1 && break | |
| sleep 2 | |
| done | |
| mysql -h 127.0.0.1 -u root -proot pinakes_test < installer/database/schema.sql | |
| # Copy-occupancy triggers live in a separate file (DELIMITER-based); the | |
| # mysql CLI handles DELIMITER natively. loan-edge-cases relies on them. | |
| mysql -h 127.0.0.1 -u root -proot pinakes_test < installer/database/triggers.sql | |
| - name: PHP unit tests (standalone .unit.php) | |
| run: | | |
| FAILED=0 | |
| shopt -s nullglob | |
| for t in tests/*.unit.php; do | |
| echo "── $t" | |
| php "$t" || FAILED=1 | |
| done | |
| [ "$FAILED" -eq 0 ] && echo "✓ Tutti gli unit test PHP passati" || { echo "✗ Unit test PHP falliti"; exit 1; } | |
| - name: Shell test — bin/setup-permissions.sh | |
| run: bash tests/setup-permissions.test.sh | |
| - name: Remove CI .env | |
| if: always() | |
| run: rm -f .env |