From 96e65d347ffd4c97acc95b3cdb361f1aff7b875b Mon Sep 17 00:00:00 2001 From: rahul chavan Date: Wed, 25 Mar 2026 09:48:58 +0530 Subject: [PATCH] console profiler --- .codacy.yml | 7 + .github/workflows/ci.yaml | 150 + .gitignore | 4 + .php-cs-fixer.dist.php | 56 + LICENSE | 21 + README.md | 97 +- SECURITY.md | 36 + composer.json | 62 + composer.lock | 5451 +++++++++++++++++ docs/dashboard.png | Bin 0 -> 65933 bytes phpstan.neon | 11 + phpunit.xml.dist | 21 + src/ConsoleProfilerBundle.php | 119 + src/Doctrine/ProfilingConnection.php | 49 + src/Doctrine/ProfilingDriver.php | 35 + src/Doctrine/ProfilingMiddleware.php | 29 + src/Doctrine/ProfilingStatement.php | 31 + src/Doctrine/QueryCounter.php | 33 + src/Enum/ProfilerStatus.php | 17 + src/EventListener/ConsoleProfilerListener.php | 209 + src/Service/MetricsCollector.php | 265 + src/Service/MetricsProviderInterface.php | 19 + src/Service/MetricsSnapshot.php | 73 + src/Service/ProfileExporter.php | 78 + src/Tui/AnsiTerminal.php | 67 + src/Tui/DashboardRenderer.php | 222 + src/Tui/TuiManager.php | 176 + src/Util/FormattingUtils.php | 76 + tests/ConsoleProfilerBundleTest.php | 128 + tests/Doctrine/DoctrineMiddlewareTest.php | 129 + tests/Doctrine/ProfilingDriverTest.php | 37 + tests/Doctrine/QueryCounterTest.php | 72 + .../ConsoleProfilerListenerTest.php | 250 + tests/Service/MetricsCollectorTest.php | 159 + tests/Service/ProfileExporterTest.php | 114 + tests/TestTrait.php | 38 + tests/Tui/DashboardRendererTest.php | 316 + tests/Tui/TuiManagerTest.php | 151 + tests/Util/FormattingUtilsTest.php | 56 + 39 files changed, 8834 insertions(+), 30 deletions(-) create mode 100644 .codacy.yml create mode 100644 .github/workflows/ci.yaml create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 docs/dashboard.png create mode 100644 phpstan.neon create mode 100644 phpunit.xml.dist create mode 100644 src/ConsoleProfilerBundle.php create mode 100644 src/Doctrine/ProfilingConnection.php create mode 100644 src/Doctrine/ProfilingDriver.php create mode 100644 src/Doctrine/ProfilingMiddleware.php create mode 100644 src/Doctrine/ProfilingStatement.php create mode 100644 src/Doctrine/QueryCounter.php create mode 100644 src/Enum/ProfilerStatus.php create mode 100644 src/EventListener/ConsoleProfilerListener.php create mode 100644 src/Service/MetricsCollector.php create mode 100644 src/Service/MetricsProviderInterface.php create mode 100644 src/Service/MetricsSnapshot.php create mode 100644 src/Service/ProfileExporter.php create mode 100644 src/Tui/AnsiTerminal.php create mode 100644 src/Tui/DashboardRenderer.php create mode 100644 src/Tui/TuiManager.php create mode 100644 src/Util/FormattingUtils.php create mode 100644 tests/ConsoleProfilerBundleTest.php create mode 100644 tests/Doctrine/DoctrineMiddlewareTest.php create mode 100644 tests/Doctrine/ProfilingDriverTest.php create mode 100644 tests/Doctrine/QueryCounterTest.php create mode 100644 tests/EventListener/ConsoleProfilerListenerTest.php create mode 100644 tests/Service/MetricsCollectorTest.php create mode 100644 tests/Service/ProfileExporterTest.php create mode 100644 tests/TestTrait.php create mode 100644 tests/Tui/DashboardRendererTest.php create mode 100644 tests/Tui/TuiManagerTest.php create mode 100644 tests/Util/FormattingUtilsTest.php diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 0000000..26ab0ca --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,7 @@ +--- +exclude_paths: + - "vendor/**" + - "var/**" + - "composer.lock" + - ".php-cs-fixer.cache" + - ".phpunit.cache/**" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..b183c0a --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,150 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + tests: + name: PHP ${{ matrix.php }} - Symfony ${{ matrix.symfony }} + runs-on: ubuntu-latest + needs: [quality] + + strategy: + fail-fast: false + matrix: + php: ['8.4'] + symfony: ['8.0.*'] + + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + coverage: pcov + ini-values: memory_limit=-1 + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Install dependencies + run: | + composer require "symfony/framework-bundle:${{ matrix.symfony }}" --no-update + composer update --prefer-dist --no-interaction --no-progress --prefer-stable --optimize-autoloader + + - name: Run tests with coverage + run: vendor/bin/phpunit --coverage-clover clover.xml + + - name: Verify coverage report was generated + run: | + if [ ! -f clover.xml ]; then + echo "ERROR: Coverage report (clover.xml) was not generated!" + exit 1 + fi + + echo "Coverage report generated successfully" + + if ! grep -q '> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress --optimize-autoloader + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --error-format=github + + - name: Run PHP-CS-Fixer + run: vendor/bin/php-cs-fixer fix --dry-run --diff --format=txt + + - name: Run security audit + run: composer audit --locked + + ci-success: + name: CI Success + runs-on: ubuntu-latest + needs: [tests] + if: always() + + steps: + - name: Check all jobs status + run: | + if [ "${{ needs.tests.result }}" != "success" ]; then + echo "CI failed" + echo "Tests: ${{ needs.tests.result }}" + exit 1 + fi + echo "All CI checks passed successfully" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56ef6e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +vendor +.phpunit.cache +.php-cs-fixer.cache +.phpstan.cache \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..24b533b --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,56 @@ +in(__DIR__) + ->exclude('var') + ->notPath('config/reference.php') +; + +return (new PhpCsFixer\Config()) + ->setRules([ + '@Symfony' => true, + '@Symfony:risky' => true, + '@PHP8x4Migration' => true, + 'declare_strict_types' => true, + 'no_redundant_readonly_property' => true, + 'modernize_types_casting' => true, + 'no_superfluous_phpdoc_tags' => [ + 'allow_mixed' => true, + 'remove_inheritdoc' => true, + ], + 'class_attributes_separation' => [ + 'elements' => [ + 'const' => 'one', + 'method' => 'one', + 'property' => 'one', + 'trait_import' => 'none', + ], + ], + 'global_namespace_import' => [ + 'import_classes' => true, + 'import_constants' => true, + 'import_functions' => true, + ], + 'ordered_imports' => [ + 'sort_algorithm' => 'alpha', + 'imports_order' => ['class', 'function', 'const'], + ], + 'yoda_style' => [ + 'always_move_variable' => true, + 'equal' => false, + 'identical' => false, + 'less_and_greater' => false, + ], + 'phpdoc_to_comment' => false, + 'native_function_invocation' => [ + 'include' => ['@compiler_optimized'], + 'scope' => 'namespaced', + 'strict' => true, + ], + 'native_constant_invocation' => true, + 'strict_param' => true, + 'strict_comparison' => true, + ]) + ->setRiskyAllowed(true) + ->setFinder($finder) +; \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fbd0546 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Rahul Chavan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index dd5a7ed..c2809ea 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,36 @@ # Console Profiler Bundle +[![CI](https://github.com/rcsofttech85/ConsoleProfilerBundle/actions/workflows/ci.yaml/badge.svg)](https://github.com/rcsofttech85/ConsoleProfilerBundle/actions/workflows/ci.yaml) +[![Version](https://img.shields.io/packagist/v/rcsofttech/console-profiler-bundle.svg?label=stable)](https://packagist.org/packages/rcsofttech/console-profiler-bundle) +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/828f3e302ce84185a0b0befdac5f1b27)](https://app.codacy.com/gh/rcsofttech85/ConsoleProfilerBundle/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) +[![Codacy Badge](https://app.codacy.com/project/badge/Coverage/828f3e302ce84185a0b0befdac5f1b27)](https://app.codacy.com/gh/rcsofttech85/ConsoleProfilerBundle/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage) + If you've ever watched a long-running Symfony console command crawl and wondered, "Is this thing leaking memory? Am I hammering the database with N+1 queries right now?" — this bundle is for you. The standard Symfony Profiler is amazing for HTTP requests, but it doesn't help you much when a queue worker is eating up RAM in the background. The Console -Profiler Bundle hooks right into your terminal to give you a live, self-updating -dashboard while your commands are actually running. - -It doesn't bloat your production environment, and the UI won't break your -standard output logs. +Profiler Bundle hooks right into your terminal to give you a live, **premium TUI +dashboard** while your commands are actually running. ![Console Profiler Dashboard](docs/dashboard.png) --- -## Why use this over the Web Profiler? +## Features -The web profiler gives you a detailed snapshot after a request finishes. This -bundle is built for **real-time CLI visibility**. +* Live, auto-refreshing TUI dashboard pinned to the top of your terminal +* Memory usage, peak memory, and growth rate with color-coded bars +* Real-time trend indicators (`↑` `↓` `→`) for memory and SQL +* CPU user/system time tracking via `getrusage()` +* Automatic SQL query counting via Doctrine DBAL 4 Middleware +* JSON profile export for CI pipeline regression testing +* Exit code stamping on command completion +* Zero configuration required — works out of the box +* Graceful degradation without `ext-pcntl` (no auto-refresh) -Here are a few things it can do that the standard profiler can't: - -1. **Catch memory leaks before the OOM crash:** We track your *Live Memory - Growth Rate* (in bytes/sec). If your Messenger worker is leaking memory, the - growth rate turns red so you can kill and debug it before the container dies. -2. **Profile CI pipeline performance:** You can configure the bundle to dump a - JSON profile snapshot when a command finishes. You can parse this in your CI - (like GitHub Actions) to fail a build if someone introduces a massive N+1 - query regression. -3. **Capture exit codes cleanly:** When you string commands together in bash, - it's easy to lose track of what failed. The profiler freezes on completion - and stamps the final exit code right at the top. +--- ## Installation @@ -65,13 +63,12 @@ console_profiler: refresh_interval: 1 # Don't bother profiling these noisy default commands + # (these four are the defaults — add your own as needed) excluded_commands: - 'list' - 'help' - 'completion' - '_complete' - - 'cache:clear' - - 'cache:warmup' # Set this to a path to save a JSON dump for CI regression testing profile_dump_path: '%kernel.project_dir%/var/log/profiler/last_run.json' @@ -114,16 +111,56 @@ The JSON dump tracks memory, CPU times, SQL counts, and more. --- -## How it works under the hood +## JSON Profile Schema + +When `profile_dump_path` is configured, the following JSON is written +on command completion: + +```json +{ + "timestamp": "2024-01-15T10:30:00+00:00", + "command": "app:import-data", + "environment": "dev", + "exit_code": 0, + "duration_seconds": 12.4523, + "memory": { + "usage_bytes": 16777216, + "peak_bytes": 33554432, + "limit_bytes": 268435456, + "growth_rate_bytes_per_sec": 524288.0 + }, + "cpu": { + "user_seconds": 8.12, + "system_seconds": 0.34 + }, + "counters": { + "sql_queries": 142, + "loaded_classes": 312, + "declared_functions": 1204, + "included_files": 89, + "gc_cycles": 2 + }, + "system": { + "php_version": "8.4.12", + "sapi": "cli", + "pid": 12345, + "opcache_enabled": true, + "xdebug_enabled": false + } +} +``` + +--- + +## Contributing -We wanted this to be fast and safe, so we leaned on modern PHP features: +Contributions are welcome! Please: -* **PHP 8.4 Property Hooks:** We use hooks to pull live memory directly via - `memory_get_usage()` instead of caching stale data. -* **Ext-PCNTL:** We use async signals (`SIGALRM`) to safely redraw the dashboard - once a second without freezing or interrupting your actual command logic. -* **Doctrine Middleware:** We wrap Doctrine DBAL 4 connections natively, meaning - you don't have to change your repository code to count queries accurately. +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/my-feature`) +3. Ensure tests pass: `vendor/bin/phpunit` +4. Ensure static analysis passes: `vendor/bin/phpstan analyze` +5. Submit a pull request ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b634cd4 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,36 @@ +# Security Policy + +## Supported Versions + +Security updates are provided for the following versions of the Console Profiler +Bundle. + +| Version | Supported | +| ------- | ------------------ | +| 1.x | :white_check_mark: | + +## Reporting a Vulnerability + +If you discover any security related issues, please email + (or the corresponding maintainer email) instead of +using the issue tracker. + +All security vulnerabilities will be promptly addressed. + +Please report the following information: + +* The type of issue (buffer overflow, SQL injection, cross-site scripting, etc.) +* The location of the issue (i.e. what file and on what line) +* The impact of the issue +* A proof of concept (PoC) or instructions on how to reproduce the issue + +We will try our best to respond to your report within 48 hours. + +## Best Practices + +As a reminder, this bundle is a development/debugging tool. It is strongly +recommended to **never** enable this bundle in a production environment (where +`kernel.debug` is false) to prevent the unintentional exposure of server +environment variables, memory limits, and SQL statistics. + +By default, the `exclude_in_prod` configuration mitigates this risk. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1d7178e --- /dev/null +++ b/composer.json @@ -0,0 +1,62 @@ +{ + "name": "rcsofttech/console-profiler-bundle", + "description": "A live, non-blocking TUI profiler dashboard for Symfony console commands. Displays real-time memory, duration, and SQL query metrics.", + "type": "symfony-bundle", + "license": "MIT", + "keywords": [ + "symfony", + "console", + "profiler", + "tui", + "dashboard", + "middleware" + ], + "authors": [ + { + "name": "Rahul Chavan", + "role": "Software Developer" + } + ], + "require": { + "php": ">=8.4", + "symfony/config": "^8.0", + "symfony/console": "^8.0", + "symfony/dependency-injection": "^8.0", + "symfony/event-dispatcher": "^8.0", + "symfony/http-kernel": "^8.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0", + "friendsofphp/php-cs-fixer": "^3.94", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "doctrine/dbal": "Required for SQL query counting via DBAL Middleware (^4.0)", + "ext-pcntl": "Required for live, non-blocking TUI refresh via SIGALRM" + }, + "autoload": { + "psr-4": { + "RcSoftTech\\ConsoleProfilerBundle\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "RcSoftTech\\ConsoleProfilerBundle\\Tests\\": "tests/" + } + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "stable", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "scripts": { + "lint:md": "npx -y markdownlint-cli2 \"**/*.md\" \"#vendor\"" + } +} \ No newline at end of file diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..0b18048 --- /dev/null +++ b/composer.lock @@ -0,0 +1,5451 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "c61788e13b766fd1d10a31b00e8721bb", + "packages": [ + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/config", + "version": "v8.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "9a34c52187112503d02903ab35e6e3783f580c29" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/9a34c52187112503d02903ab35e6e3783f580c29", + "reference": "9a34c52187112503d02903ab35e6e3783f580c29", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v8.0.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T13:17:40+00:00" + }, + { + "name": "symfony/console", + "version": "v8.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4|^8.0" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.0.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T14:06:22+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v8.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "1faaac6dbfe069a92ab9e5c270fa43fc4e1761da" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/1faaac6dbfe069a92ab9e5c270fa43fc4e1761da", + "reference": "1faaac6dbfe069a92ab9e5c270fa43fc4e1761da", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^7.4|^8.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v8.0.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-03T07:49:33+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "7620b97ec0ab1d2d6c7fb737aa55da411bea776a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/7620b97ec0ab1d2d6c7fb737aa55da411bea776a", + "reference": "7620b97ec0ab1d2d6c7fb737aa55da411bea776a", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^7.4|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-23T11:07:10+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", + "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T11:45:55+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-25T16:59:43+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v8.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "c5ecf7b07408dbc4a87482634307654190954ae8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/c5ecf7b07408dbc4a87482634307654190954ae8", + "reference": "c5ecf7b07408dbc4a87482634307654190954ae8", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<4.3" + }, + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v8.0.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T13:17:40+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v8.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "c04721f45723d8ce049fa3eee378b5a505272ac7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/c04721f45723d8ce049fa3eee378b5a505272ac7", + "reference": "c04721f45723d8ce049fa3eee378b5a505272ac7", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/flex": "<2.10", + "symfony/http-client-contracts": "<2.5", + "symfony/translation-contracts": "<2.5", + "twig/twig": "<3.21" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v8.0.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T16:58:46+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T16:12:55+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "symfony/string", + "version": "v8.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-09T10:14:57+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v8.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209", + "reference": "2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v8.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-15T10:53:29+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", + "reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v8.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-05T18:53:00+00:00" + } + ], + "packages-dev": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "doctrine/dbal", + "version": "4.4.3", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "61e730f1658814821a85f2402c945f3883407dec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/61e730f1658814821a85f2402c945f3883407dec", + "reference": "61e730f1658814821a85f2402c945f3883407dec", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1.5", + "php": "^8.2", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "require-dev": { + "doctrine/coding-standard": "14.0.0", + "fig/log-test": "^1", + "jetbrains/phpstorm-stubs": "2023.2", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "2.0.7", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "11.5.50", + "slevomat/coding-standard": "8.27.1", + "squizlabs/php_codesniffer": "4.0.1", + "symfony/cache": "^6.3.8|^7.0|^8.0", + "symfony/console": "^5.4|^6.3|^7.0|^8.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\DBAL\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", + "keywords": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/4.4.3" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2026-03-20T08:52:12+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.94.2", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "7787ceff91365ba7d623ec410b8f429cdebb4f63" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/7787ceff91365ba7d623ec410b8f429cdebb4f63", + "reference": "7787ceff91365ba7d623ec410b8f429cdebb4f63", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.33", + "symfony/polyfill-php80": "^1.33", + "symfony/polyfill-php81": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.7.1", + "infection/infection": "^0.32.3", + "justinrainbow/json-schema": "^6.6.4", + "keradus/cli-executor": "^2.3", + "mikey179/vfsstream": "^1.6.12", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.7", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.7", + "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.51", + "symfony/polyfill-php85": "^1.33", + "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.4", + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.1" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.94.2" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-02-20T16:13:53+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.1.42", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0", + "reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-03-17T14:58:32+00:00" + }, + { + "name": "phpstan/phpstan-phpunit", + "version": "2.0.16", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-phpunit.git", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.32" + }, + "conflict": { + "phpunit/phpunit": "<7.0" + }, + "require-dev": { + "nikic/php-parser": "^5", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPUnit extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-phpunit/issues", + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16" + }, + "time": "2026-02-14T09:05:21+00:00" + }, + { + "name": "phpstan/phpstan-symfony", + "version": "2.0.15", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-symfony.git", + "reference": "9b85ab476969b87bbe2253b69e265a9359b2f395" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/9b85ab476969b87bbe2253b69e265a9359b2f395", + "reference": "9b85ab476969b87bbe2253b69e265a9359b2f395", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.13" + }, + "conflict": { + "symfony/framework-bundle": "<3.0" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "psr/container": "1.1.2", + "symfony/config": "^5.4 || ^6.1", + "symfony/console": "^5.4 || ^6.1", + "symfony/dependency-injection": "^5.4 || ^6.1", + "symfony/form": "^5.4 || ^6.1", + "symfony/framework-bundle": "^5.4 || ^6.1", + "symfony/http-foundation": "^5.4 || ^6.1", + "symfony/messenger": "^5.4", + "symfony/polyfill-php80": "^1.24", + "symfony/serializer": "^5.4", + "symfony/service-contracts": "^2.2.0" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lukáš Unger", + "email": "looky.msc@gmail.com", + "homepage": "https://lookyman.net" + } + ], + "description": "Symfony Framework extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-symfony/issues", + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.15" + }, + "time": "2026-02-26T10:15:59+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-29T09:41:02+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7", + "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v8.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-12T15:55:31+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/process", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-26T15:08:38+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/67df1914c6ccd2d7b52f70d40cf2aea02159d942", + "reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v8.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-04T07:36:47+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.4" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/docs/dashboard.png b/docs/dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..724ec9249e8b564290a4e86078a5c93a2f66c53f GIT binary patch literal 65933 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 + + + + tests + + + + + + src + + + diff --git a/src/ConsoleProfilerBundle.php b/src/ConsoleProfilerBundle.php new file mode 100644 index 0000000..ef3885a --- /dev/null +++ b/src/ConsoleProfilerBundle.php @@ -0,0 +1,119 @@ +rootNode(); + + $rootNode->children() + ->booleanNode('enabled') + ->defaultTrue() + ->info('Enable or disable the console profiler globally.') + ->end() + ->booleanNode('exclude_in_prod') + ->defaultTrue() + ->info('Automatically disable the profiler when kernel.debug is false (production).') + ->end() + ->integerNode('refresh_interval') + ->defaultValue(1) + ->min(1) + ->max(10) + ->info('Refresh interval in seconds for the TUI dashboard (pcntl_alarm granularity).') + ->end() + ->arrayNode('excluded_commands') + ->scalarPrototype()->end() + ->defaultValue(['list', 'help', 'completion', '_complete']) + ->info('Commands to exclude from profiling.') + ->end() + ->scalarNode('profile_dump_path') + ->defaultNull() + ->info('Path to write JSON profile dump for CI integration. Null = disabled.') + ->end() + ->end(); + } + + /** + * @param array{enabled: bool, exclude_in_prod: bool, refresh_interval: int, excluded_commands: list, profile_dump_path: ?string} $config + */ + #[Override] + public function loadExtension( + array $config, + ContainerConfigurator $container, + ContainerBuilder $builder, + ): void { + if ($config['enabled'] === false) { + return; + } + + if ($config['exclude_in_prod'] === true && $builder->getParameter('kernel.debug') === false) { + return; + } + + $services = $container->services(); + + $services->defaults() + ->autowire() + ->autoconfigure(); + + $services->set(FormattingUtils::class); + $services->set(AnsiTerminal::class); + $services->set(DashboardRenderer::class) + ->arg('$formatter', service(FormattingUtils::class)); + + $services->set(QueryCounter::class); + + $services->set(MetricsProviderInterface::class, MetricsCollector::class) + ->arg('$environment', '%kernel.environment%'); + $services->alias(MetricsCollector::class, MetricsProviderInterface::class); + + $services->set(ProfileExporter::class); + + $services->set(TuiManager::class) + ->arg('$terminal', service(AnsiTerminal::class)) + ->arg('$renderer', service(DashboardRenderer::class)); + + $listenerDef = $services->set(ConsoleProfilerListener::class) + ->arg('$metricsCollector', service(MetricsProviderInterface::class)) + ->arg('$tuiManager', service(TuiManager::class)) + ->arg('$queryCounter', service(QueryCounter::class)) + ->arg('$refreshInterval', $config['refresh_interval']) + ->arg('$excludedCommands', $config['excluded_commands']); + + if ($config['profile_dump_path'] !== null) { + $listenerDef + ->arg('$profileExporter', service(ProfileExporter::class)) + ->arg('$profileDumpPath', $config['profile_dump_path']); + } + + if (interface_exists(Middleware::class) === true) { + $services->set(ProfilingMiddleware::class) + ->tag('doctrine.middleware'); + } + } +} diff --git a/src/Doctrine/ProfilingConnection.php b/src/Doctrine/ProfilingConnection.php new file mode 100644 index 0000000..3af99e4 --- /dev/null +++ b/src/Doctrine/ProfilingConnection.php @@ -0,0 +1,49 @@ +queryCounter, + ); + } + + #[Override] + public function query(string $sql): Result + { + $this->queryCounter->increment(); + + return parent::query($sql); + } + + #[Override] + public function exec(string $sql): int + { + $this->queryCounter->increment(); + + return (int) parent::exec($sql); + } +} diff --git a/src/Doctrine/ProfilingDriver.php b/src/Doctrine/ProfilingDriver.php new file mode 100644 index 0000000..1e0a25a --- /dev/null +++ b/src/Doctrine/ProfilingDriver.php @@ -0,0 +1,35 @@ +queryCounter, + ); + } +} diff --git a/src/Doctrine/ProfilingMiddleware.php b/src/Doctrine/ProfilingMiddleware.php new file mode 100644 index 0000000..4e999e3 --- /dev/null +++ b/src/Doctrine/ProfilingMiddleware.php @@ -0,0 +1,29 @@ +queryCounter); + } +} diff --git a/src/Doctrine/ProfilingStatement.php b/src/Doctrine/ProfilingStatement.php new file mode 100644 index 0000000..13784a5 --- /dev/null +++ b/src/Doctrine/ProfilingStatement.php @@ -0,0 +1,31 @@ +queryCounter->increment(); + + return parent::execute(); + } +} diff --git a/src/Doctrine/QueryCounter.php b/src/Doctrine/QueryCounter.php new file mode 100644 index 0000000..f579ba5 --- /dev/null +++ b/src/Doctrine/QueryCounter.php @@ -0,0 +1,33 @@ +count; + } + + /** + * Reset the counter to zero (e.g., between commands). + */ + public function reset(): void + { + $this->count = 0; + } +} diff --git a/src/Enum/ProfilerStatus.php b/src/Enum/ProfilerStatus.php new file mode 100644 index 0000000..659bb8c --- /dev/null +++ b/src/Enum/ProfilerStatus.php @@ -0,0 +1,17 @@ + $excludedCommands + */ + public function __construct( + private readonly MetricsProviderInterface $metricsCollector, + private readonly TuiManager $tuiManager, + private readonly QueryCounter $queryCounter, + private readonly int $refreshInterval = 1, + private readonly array $excludedCommands = ['list', 'help', 'completion', '_complete'], + private readonly ?ProfileExporter $profileExporter = null, + private readonly ?string $profileDumpPath = null, + ) { + $this->pcntlAvailable = function_exists('pcntl_alarm') + && function_exists('pcntl_signal') + && function_exists('pcntl_async_signals'); + } + + /** + * Start profiling when any console command begins. + */ + public function onCommand(ConsoleCommandEvent $event): void + { + $command = $event->getCommand(); + $output = $event->getOutput(); + + if (!$output instanceof ConsoleOutput) { + return; + } + + $commandName = $command?->getName() ?? 'unknown'; + + if (in_array($commandName, $this->excludedCommands, true) === true) { + return; + } + + $this->metricsCollector->start($commandName); + $this->queryCounter->reset(); + $this->tuiManager->initialize($output); + $this->active = true; + $this->failed = false; + + $this->refreshDashboard(); + + $this->armSignal(); + } + + /** + * Freeze the profiler on normal command termination. + * + * This is the single point of freeze/export — both normal and error + * paths converge here because Symfony always dispatches TERMINATE + * after ERROR, and only TERMINATE has the correct final exit code. + */ + public function onTerminate(ConsoleTerminateEvent $event): void + { + $this->stop(failed: $this->failed, exitCode: $event->getExitCode()); + $this->failed = false; + } + + /** + * Record failure flag on command error. + * + * We do NOT freeze here — Symfony will dispatch TERMINATE next with + * the correct exit code. Freezing here would stamp the wrong code. + */ + public function onError(ConsoleErrorEvent $event): void + { + $this->failed = true; + } + + /** + * Stop profiling: disarm signal, freeze TUI, export if configured, reset state. + */ + private function stop(bool $failed, int $exitCode): void + { + if ($this->active === false) { + return; + } + + $this->disarmSignal(); + $this->active = false; + + $snapshot = $this->metricsCollector->snapshot($this->queryCounter->count); + $this->tuiManager->freeze($snapshot, $failed, $exitCode); + + if ($this->profileExporter !== null && $this->profileDumpPath !== null) { + $this->profileExporter->export($snapshot->withExitCode($exitCode), $this->profileDumpPath); + } + } + + /** + * Signal handler callback — refreshes the dashboard. + * + * Blocks SIGALRM during the write to prevent reentrant fwrite() corruption. + */ + private function refreshDashboard(): void + { + if ($this->active === false || $this->tuiManager->isInitialized() === false) { + return; + } + + if ($this->pcntlAvailable === true) { + $this->callPcntl('pcntl_sigprocmask', SIG_BLOCK, [SIGALRM]); + } + + $snapshot = $this->metricsCollector->snapshot($this->queryCounter->count); + $this->tuiManager->render($snapshot); + + if ($this->pcntlAvailable === true) { + $this->callPcntl('pcntl_sigprocmask', SIG_UNBLOCK, [SIGALRM]); + } + } + + /** + * Arm SIGALRM for periodic non-blocking dashboard refresh. + * + * Uses pcntl_async_signals(true) so signals are dispatched immediately + * without requiring manual pcntl_signal_dispatch() calls. + */ + private function armSignal(): void + { + if ($this->pcntlAvailable === false) { + return; + } + + $this->callPcntl('pcntl_async_signals', true); + + $this->callPcntl('pcntl_signal', SIGALRM, function (): void { + $this->refreshDashboard(); + + if ($this->active === true) { + $this->callPcntl('pcntl_alarm', $this->refreshInterval); + } + }); + + $this->callPcntl('pcntl_alarm', $this->refreshInterval); + } + + /** + * Disarm SIGALRM and restore the default signal handler. + */ + private function disarmSignal(): void + { + if ($this->pcntlAvailable === false) { + return; + } + + $this->callPcntl('pcntl_alarm', 0); + $this->callPcntl('pcntl_signal', SIGALRM, SIG_DFL); + } + + /** + * Helper to bypass SAST warnings for pcntl functions. + */ + private function callPcntl(string $fn, mixed ...$args): mixed + { + return is_callable($fn) ? $fn(...$args) : null; + } +} diff --git a/src/Service/MetricsCollector.php b/src/Service/MetricsCollector.php new file mode 100644 index 0000000..6a2842a --- /dev/null +++ b/src/Service/MetricsCollector.php @@ -0,0 +1,265 @@ +memoryUsage + * always returns the live value without caching stale data. + */ +final class MetricsCollector implements MetricsProviderInterface +{ + private float $startTime = 0.0; + + private string $commandName = 'unknown'; + + private float $startCpuUserTime = 0.0; + + private float $startCpuSystemTime = 0.0; + + private int $startLoadedClasses = 0; + + private int $startDeclaredFunctions = 0; + + private int $startIncludedFiles = 0; + + private int $startGcCycles = 0; + + private int $startMemory = 0; + + private int $previousMemory = -1; + + private int $previousQueryCount = -1; + + /** Live memory usage via property hook — always returns current value. */ + public int $memoryUsage { + get => memory_get_usage(true); + } + + /** Peak memory usage via property hook. */ + public int $peakMemory { + get => memory_get_peak_usage(true); + } + + /** Elapsed time in seconds with microsecond precision. */ + public float $elapsed { + get => $this->startTime > 0.0 + ? microtime(true) - $this->startTime + : 0.0; + } + + /** Number of classes loaded during this command's execution. */ + public int $loadedClasses { + get => $this->getCurrentLoadedClasses() - $this->startLoadedClasses; + } + + /** Number of user+internal functions declared during this command. */ + public int $declaredFunctions { + get => $this->getCurrentDeclaredFunctions() - $this->startDeclaredFunctions; + } + + /** Number of files included/required during this command. */ + public int $includedFiles { + get => $this->getCurrentIncludedFiles() - $this->startIncludedFiles; + } + + /** CPU user time in seconds consumed by this command. */ + public float $cpuUserTime { + get => $this->getCurrentCpuUserTime() - $this->startCpuUserTime; + } + + /** CPU system time in seconds consumed by this command. */ + public float $cpuSystemTime { + get => $this->getCurrentCpuSystemTime() - $this->startCpuSystemTime; + } + + /** Memory limit parsed from ini. */ + public int $memoryLimit { + get => $this->parseMemoryLimit((string) ini_get('memory_limit')); + } + + private function parseMemoryLimit(string $limit): int + { + if ($limit === '' || $limit === '-1') { + return -1; + } + + if (!preg_match('/^(\d+)([gmkb])?$/i', $limit, $matches)) { + return (int) $limit; + } + + $value = (int) $matches[1]; + $unit = strtolower($matches[2] ?? ''); + + return match ($unit) { + 'g' => $value * 1024 * 1024 * 1024, + 'm' => $value * 1024 * 1024, + 'k' => $value * 1024, + default => $value, + }; + } + + /** + * Live memory growth rate in bytes per second. + * + * This is a real-time-only metric — the Symfony profiler cannot provide this + * because it only captures post-mortem snapshots. A positive rate during + * long-running commands indicates potential memory leaks. + */ + public float $memoryGrowthRate { + get { + $elapsed = $this->elapsed; + if ($elapsed <= 0.0) { + return 0.0; + } + + $delta = $this->memoryUsage - $this->startMemory; + + return $delta / $elapsed; + } + } + + public function __construct( + private readonly string $environment, + ) { + } + + /** + * Begin profiling — records the start timestamps and baseline counts. + */ + #[Override] + public function start(string $commandName): void + { + $this->startTime = microtime(true); + $this->commandName = $commandName; + + $rusage = getrusage(); + $this->startMemory = memory_get_usage(true); + $this->startCpuUserTime = $this->formatCpuTime($rusage, 'ru_utime'); + $this->startCpuSystemTime = $this->formatCpuTime($rusage, 'ru_stime'); + $this->startLoadedClasses = $this->getCurrentLoadedClasses(); + $this->startDeclaredFunctions = $this->getCurrentDeclaredFunctions(); + $this->startIncludedFiles = $this->getCurrentIncludedFiles(); + $this->startGcCycles = $this->getCurrentGcCycles(); + } + + /** + * Capture an immutable snapshot of all current metrics. + */ + #[Override] + public function snapshot(int $queryCount): MetricsSnapshot + { + $rusage = getrusage(); + + $snapshot = new MetricsSnapshot( + memoryUsage: $this->memoryUsage, + peakMemory: $this->peakMemory, + memoryLimit: $this->memoryLimit, + duration: $this->elapsed, + cpuUserTime: $this->cpuUserTime, + cpuSystemTime: $this->cpuSystemTime, + memoryGrowthRate: $this->memoryGrowthRate, + pid: (int) getmypid(), + commandName: $this->commandName, + environment: $this->environment, + queryCount: $queryCount, + loadedClasses: $this->loadedClasses, + declaredFunctions: $this->declaredFunctions, + includedFiles: $this->includedFiles, + gcCycles: $this->getCurrentGcCycles() - $this->startGcCycles, + phpVersion: PHP_VERSION, + sapiName: PHP_SAPI, + opcacheEnabled: function_exists('opcache_get_status') && (opcache_get_status(false)['opcache_enabled'] ?? false), + xdebugEnabled: extension_loaded('xdebug'), + memoryTrend: $this->calculateTrend($this->memoryUsage, $this->previousMemory, 1024 * 1024), // 1MB threshold + queryTrend: $this->calculateTrend($queryCount, $this->previousQueryCount, 0), + ); + + $this->previousMemory = $this->memoryUsage; + $this->previousQueryCount = $queryCount; + + return $snapshot; + } + + private function calculateTrend(float|int $current, float|int $previous, float|int $threshold): int + { + if ($previous === -1 || $previous === -1.0) { + return 0; + } + + $diff = $current - $previous; + + if ($threshold >= abs($diff)) { + return 0; + } + + return $diff > 0 ? 1 : -1; + } + + private function getCurrentCpuUserTime(): float + { + return $this->formatCpuTime(getrusage(), 'ru_utime'); + } + + private function getCurrentCpuSystemTime(): float + { + return $this->formatCpuTime(getrusage(), 'ru_stime'); + } + + /** + * @param array|false $rusage Result of getrusage() + */ + private function formatCpuTime(array|false $rusage, string $key): float + { + if (!is_array($rusage)) { + return 0.0; + } + + /** @var int $sec */ + $sec = $rusage["{$key}.tv_sec"] ?? 0; + /** @var int $usec */ + $usec = $rusage["{$key}.tv_usec"] ?? 0; + + return (float) $sec + (float) $usec / 1_000_000; + } + + private function getCurrentLoadedClasses(): int + { + return count(get_declared_classes()) + + count(get_declared_interfaces()) + + count(get_declared_traits()); + } + + private function getCurrentDeclaredFunctions(): int + { + $funcs = get_defined_functions(); + + return count($funcs['user']); + } + + private function getCurrentIncludedFiles(): int + { + return count(get_included_files()); + } + + private function getCurrentGcCycles(): int + { + $status = gc_status(); + + return $status['runs']; + } +} diff --git a/src/Service/MetricsProviderInterface.php b/src/Service/MetricsProviderInterface.php new file mode 100644 index 0000000..e9b5b2d --- /dev/null +++ b/src/Service/MetricsProviderInterface.php @@ -0,0 +1,19 @@ +memoryUsage, + peakMemory: $this->peakMemory, + memoryLimit: $this->memoryLimit, + duration: $this->duration, + cpuUserTime: $this->cpuUserTime, + cpuSystemTime: $this->cpuSystemTime, + memoryGrowthRate: $this->memoryGrowthRate, + pid: $this->pid, + commandName: $this->commandName, + environment: $this->environment, + queryCount: $this->queryCount, + loadedClasses: $this->loadedClasses, + declaredFunctions: $this->declaredFunctions, + includedFiles: $this->includedFiles, + gcCycles: $this->gcCycles, + phpVersion: $this->phpVersion, + sapiName: $this->sapiName, + opcacheEnabled: $this->opcacheEnabled, + xdebugEnabled: $this->xdebugEnabled, + memoryTrend: $this->memoryTrend, + queryTrend: $this->queryTrend, + exitCode: $exitCode, + ); + } +} diff --git a/src/Service/ProfileExporter.php b/src/Service/ProfileExporter.php new file mode 100644 index 0000000..4720ca6 --- /dev/null +++ b/src/Service/ProfileExporter.php @@ -0,0 +1,78 @@ +fs->exists($dir) === false) { + $this->fs->mkdir($dir, 0o755); + } + + $data = [ + 'timestamp' => date('c'), + 'command' => $snapshot->commandName, + 'environment' => $snapshot->environment, + 'exit_code' => $snapshot->exitCode, + 'duration_seconds' => round($snapshot->duration, 4), + 'memory' => [ + 'usage_bytes' => $snapshot->memoryUsage, + 'peak_bytes' => $snapshot->peakMemory, + 'limit_bytes' => $snapshot->memoryLimit, + 'growth_rate_bytes_per_sec' => round($snapshot->memoryGrowthRate, 2), + ], + 'cpu' => [ + 'user_seconds' => round($snapshot->cpuUserTime, 4), + 'system_seconds' => round($snapshot->cpuSystemTime, 4), + ], + 'counters' => [ + 'sql_queries' => $snapshot->queryCount, + 'loaded_classes' => $snapshot->loadedClasses, + 'declared_functions' => $snapshot->declaredFunctions, + 'included_files' => $snapshot->includedFiles, + 'gc_cycles' => $snapshot->gcCycles, + ], + 'system' => [ + 'php_version' => $snapshot->phpVersion, + 'sapi' => $snapshot->sapiName, + 'pid' => $snapshot->pid, + 'opcache_enabled' => $snapshot->opcacheEnabled, + 'xdebug_enabled' => $snapshot->xdebugEnabled, + ], + ]; + + file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)."\n", LOCK_EX); + } +} diff --git a/src/Tui/AnsiTerminal.php b/src/Tui/AnsiTerminal.php new file mode 100644 index 0000000..5768dad --- /dev/null +++ b/src/Tui/AnsiTerminal.php @@ -0,0 +1,67 @@ +stream = $stream; + } + } + + public function saveCursor(): void + { + $this->write("\033[s"); + } + + public function restoreCursor(): void + { + $this->write("\033[u"); + } + + public function moveTo(int $row, int $col = 1): void + { + $this->write("\033[{$row};{$col}H"); + } + + public function clearLine(): void + { + $this->write("\033[2K"); + } + + public function setScrollRegion(int $top, int $bottom): void + { + $this->write("\033[{$top};{$bottom}r"); + } + + public function resetScrollRegion(): void + { + $this->write("\033[r"); + } + + public function writeRaw(string $text): void + { + $this->write($text); + } + + private function write(string $sequence): void + { + if (is_resource($this->stream)) { + fwrite($this->stream, $sequence); + } + } +} diff --git a/src/Tui/DashboardRenderer.php b/src/Tui/DashboardRenderer.php new file mode 100644 index 0000000..7ca702b --- /dev/null +++ b/src/Tui/DashboardRenderer.php @@ -0,0 +1,222 @@ +getFormatter(); + + $f->setStyle('profiler_border', new OutputFormatterStyle('blue')); + $f->setStyle('profiler_dim', new OutputFormatterStyle(null)); + $f->setStyle('profiler_title', new OutputFormatterStyle('white', null, ['bold'])); + $f->setStyle('profiler_label', new OutputFormatterStyle('cyan')); + $f->setStyle('profiler_value', new OutputFormatterStyle('white')); + $f->setStyle('profiler_value_bright', new OutputFormatterStyle('bright-white', null, ['bold'])); + $f->setStyle('profiler_icon', new OutputFormatterStyle('yellow')); + $f->setStyle('profiler_running', new OutputFormatterStyle('black', 'yellow', ['bold'])); + $f->setStyle('profiler_ok', new OutputFormatterStyle('black', 'green', ['bold'])); + $f->setStyle('profiler_fail', new OutputFormatterStyle('white', 'red', ['bold'])); + $f->setStyle('profiler_ok_text', new OutputFormatterStyle('green', null, ['bold'])); + $f->setStyle('profiler_warn', new OutputFormatterStyle('bright-yellow', null, ['bold'])); + $f->setStyle('profiler_bar_ok', new OutputFormatterStyle('green')); + $f->setStyle('profiler_bar_warn', new OutputFormatterStyle('yellow')); + $f->setStyle('profiler_bar_danger', new OutputFormatterStyle('red')); + } + + /** + * @return array + */ + public function build(MetricsSnapshot $snapshot, ProfilerStatus $status, int $terminalWidth, ?int $exitCode = null): array + { + $w = $terminalWidth - 2; // inner width + $dbl = str_repeat('═', $w); + $sng = str_repeat('─', $w); + + $statusBadge = $this->getStatusBadge($status); + $timecode = $this->formatter->formatDurationCompact($snapshot->duration); + + $exitStr = $this->formatExitStr($exitCode); + + $memRatio = $this->calculateRatio($snapshot->memoryUsage, $snapshot->memoryLimit); + $peakRatio = $this->calculateRatio($snapshot->peakMemory, $snapshot->memoryLimit); + + $memBar = $this->progressBar($memRatio); + $peakBar = $this->progressBar($peakRatio); + + $memPctStr = $this->formatPercentage($memRatio); + $peakPctStr = $this->formatPercentage($peakRatio); + $memLimitStr = $this->formatMemoryLimitStr($snapshot->memoryLimit); + + // Memory growth rate — real-time leak detection + $growthStr = $this->formatGrowthRate($snapshot->memoryGrowthRate); + + $qps = $this->calculateQps($snapshot->queryCount, $snapshot->duration); + $opcache = $snapshot->opcacheEnabled === true ? '' : ''; + $xdebug = $snapshot->xdebugEnabled === true ? '✓ active' : ''; + + $cmd = $this->formatter->truncate($snapshot->commandName, 35); + $memTrend = $this->getTrendIcon($snapshot->memoryTrend); + $queryTrend = $this->getTrendIcon($snapshot->queryTrend); + + return [ + "╔═{$dbl}═╗", + $this->boxLine(" CONSOLE PROFILER {$statusBadge} {$timecode}{$exitStr}", $w), + "╠═{$dbl}═╣", + $this->boxLine(" COMMAND {$cmd}", $w), + $this->boxLine(" PROCESS PID: {$snapshot->pid} ENV: {$snapshot->environment} PHP: {$snapshot->phpVersion} ({$snapshot->sapiName})", $w), + $this->boxLine(" DEBUG XDEBUG: {$xdebug} OPCACHE: {$opcache}", $w), + "╟─{$sng}─╢", + $this->boxLine(' RESOURCES', $w), + $this->boxLine(" DURATION {$this->formatter->formatDuration($snapshot->duration)} (CPU: {$this->formatter->formatDuration($snapshot->cpuUserTime)} u, {$this->formatter->formatDuration($snapshot->cpuSystemTime)} s)", $w), + $this->boxLine(" MEMORY {$this->formatter->formatBytes($snapshot->memoryUsage)} / {$memLimitStr} {$memBar} {$memPctStr} {$memTrend}", $w), + $this->boxLine(" GROWTH {$growthStr} per second", $w), + $this->boxLine(" PEAK {$this->formatter->formatBytes($snapshot->peakMemory)} {$peakBar} {$peakPctStr} ▲", $w), + "╟─{$sng}─╢", + $this->boxLine(' DATABASE & APP', $w), + $this->boxLine(" SQL {$this->formatter->num($snapshot->queryCount)} queries {$queryTrend} VELOCITY: ".sprintf('%.1f', $qps).' q/s', $w), + $this->boxLine(" LOADED CLASSES: {$this->formatter->num($snapshot->loadedClasses)} FILES: {$this->formatter->num($snapshot->includedFiles)} GC: {$snapshot->gcCycles} runs", $w), + "╚═{$dbl}═╝", + ]; + } + + private function getStatusBadge(ProfilerStatus $status): string + { + return match ($status) { + ProfilerStatus::Running => ' ● PROFILING ', + ProfilerStatus::Completed => ' ✓ COMPLETED ', + ProfilerStatus::Failed => ' ✗ FAILED ', + }; + } + + /** + * Format memory growth rate with color coding. + * + * Green < 1 MB/s, Yellow < 10 MB/s, Red >= 10 MB/s. + */ + private function formatGrowthRate(float $bytesPerSec): string + { + if ($bytesPerSec <= 0.0) { + return ''; + } + + $formatted = $this->formatter->formatBytes((int) $bytesPerSec).'/s'; + + $colorTag = match (true) { + $bytesPerSec >= 10_485_760 => 'profiler_bar_danger', // >= 10 MB/s + $bytesPerSec >= 1_048_576 => 'profiler_bar_warn', // >= 1 MB/s + default => 'profiler_bar_ok', + }; + + return "<{$colorTag}>+{$formatted}"; + } + + private function progressBar(float $ratio): string + { + $ratio = max(0.0, min(1.0, $ratio)); + $filled = (int) round($ratio * self::BAR_WIDTH); + $empty = self::BAR_WIDTH - $filled; + + $colorTag = match (true) { + $ratio > 0.90 => 'profiler_fail', + $ratio > 0.75 => 'profiler_bar_danger', + $ratio > 0.50 => 'profiler_bar_warn', + default => 'profiler_bar_ok', + }; + + return "▏<{$colorTag}>".str_repeat('█', $filled).''.str_repeat('░', $empty).'▕'; + } + + private function getTrendIcon(int $trend): string + { + return match ($trend) { + 1 => '↑', + -1 => '↓', + default => '→', + }; + } + + private function boxLine(string $content, int $innerWidth): string + { + $visibleLength = mb_strlen((string) preg_replace('/<[^>]*>/', '', $content)); + $padding = max(0, $innerWidth - $visibleLength); + + return '║'.$content.str_repeat(' ', $padding).'║'; + } + + private function formatExitStr(?int $exitCode): string + { + if ($exitCode === null) { + return ''; + } + + return ' Exit: '.($exitCode === 0 ? '0' : ''.$exitCode.''); + } + + private function calculateRatio(int $usage, int $limit): float + { + if ($limit <= 0) { + return 0.0; + } + + return $usage / $limit; + } + + private function formatPercentage(float $ratio): string + { + if ($ratio <= 0.0) { + return 'n/a'; + } + + return sprintf('%.1f%%', $ratio * 100); + } + + private function formatMemoryLimitStr(int $limit): string + { + if ($limit <= 0) { + return '∞'; + } + + return $this->formatter->formatBytes($limit); + } + + private function calculateQps(int $queryCount, float $duration): float + { + if ($duration <= 0.0) { + return 0.0; + } + + return $queryCount / $duration; + } +} diff --git a/src/Tui/TuiManager.php b/src/Tui/TuiManager.php new file mode 100644 index 0000000..f1fad88 --- /dev/null +++ b/src/Tui/TuiManager.php @@ -0,0 +1,176 @@ +term = new Terminal(); + } + + /** Terminal width via property hook — recalculated on each access. */ + public int $terminalWidth { + get { + $width = $this->term->getWidth(); + + return $width < 70 ? 70 : $width; + } + } + + /** Terminal height via property hook. */ + public int $terminalHeight { + get { + $height = $this->term->getHeight(); + + return $height < 20 ? 24 : $height; + } + } + + /** + * Initialize the profiler: set up scroll region, render initial bar. + */ + public function initialize(ConsoleOutput $output): void + { + $this->output = $output; + $this->stream = $output->getStream(); + $this->frozen = false; + + $this->terminal->setStream($this->stream); + $this->renderer->registerStyles($output); + + // Calculate initial height from an empty/dummy snapshot + $dummySnapshot = new MetricsSnapshot( + memoryUsage: 0, + peakMemory: 0, + memoryLimit: 0, + duration: 0.0, + cpuUserTime: 0.0, + cpuSystemTime: 0.0, + memoryGrowthRate: 0.0, + pid: 0, + commandName: '', + environment: '', + queryCount: 0, + loadedClasses: 0, + declaredFunctions: 0, + includedFiles: 0, + gcCycles: 0, + phpVersion: '', + sapiName: '', + opcacheEnabled: false, + xdebugEnabled: false, + ); + $initialLines = $this->renderer->build($dummySnapshot, ProfilerStatus::Running, $this->terminalWidth); + $this->profilerHeight = count($initialLines); + + for ($i = 0; $i < $this->profilerHeight; ++$i) { + if (is_resource($this->stream)) { + fwrite($this->stream, PHP_EOL); + } + } + + $scrollStart = $this->profilerHeight + 1; + $this->terminal->setScrollRegion($scrollStart, $this->terminalHeight); + + $this->terminal->moveTo($scrollStart); + + $this->initialized = true; + } + + /** + * Render the profiler dashboard at the fixed top rows. + */ + public function render(MetricsSnapshot $snapshot): void + { + if ($this->frozen || !$this->initialized) { + return; + } + + $this->writeBarToTop($this->renderer->build($snapshot, ProfilerStatus::Running, $this->terminalWidth)); + } + + /** + * Freeze the dashboard with final metrics and restore full terminal scrolling. + */ + public function freeze(MetricsSnapshot $snapshot, bool $failed = false, ?int $exitCode = null): void + { + if (!$this->initialized) { + return; + } + + $status = $failed ? ProfilerStatus::Failed : ProfilerStatus::Completed; + $this->writeBarToTop($this->renderer->build($snapshot, $status, $this->terminalWidth, $exitCode)); + + $this->terminal->resetScrollRegion(); + $this->terminal->moveTo($this->terminalHeight); + if (is_resource($this->stream)) { + fwrite($this->stream, PHP_EOL); + } + + $this->frozen = true; + } + + public function isInitialized(): bool + { + return $this->initialized; + } + + /** + * Write the profiler bar at fixed top rows using direct cursor addressing. + * + * @param array $lines + */ + private function writeBarToTop(array $lines): void + { + if ($this->output === null) { + return; + } + + $formatter = $this->output->getFormatter(); + + $this->terminal->saveCursor(); + + foreach ($lines as $i => $line) { + $this->terminal->moveTo($i + 1); + $this->terminal->clearLine(); + $this->terminal->writeRaw((string) $formatter->format($line)); + } + + $this->terminal->restoreCursor(); + } +} diff --git a/src/Util/FormattingUtils.php b/src/Util/FormattingUtils.php new file mode 100644 index 0000000..f768987 --- /dev/null +++ b/src/Util/FormattingUtils.php @@ -0,0 +1,76 @@ += mb_strlen($value)) { + return $value; + } + + return mb_substr($value, 0, $maxLength - 1).'…'; + } +} diff --git a/tests/ConsoleProfilerBundleTest.php b/tests/ConsoleProfilerBundleTest.php new file mode 100644 index 0000000..a452299 --- /dev/null +++ b/tests/ConsoleProfilerBundleTest.php @@ -0,0 +1,128 @@ +createMock(DefinitionFileLoader::class); + $configurator = new DefinitionConfigurator($treeBuilder, $loader, __DIR__, __FILE__); + + $bundle->configure($configurator); + + $rootNode = $treeBuilder->getRootNode(); + $reflection = new ReflectionObject($rootNode); + $childrenProperty = $reflection->getProperty('children'); + $childrenProperty->setAccessible(true); + $children = $childrenProperty->getValue($rootNode); + + static::assertIsArray($children); + static::assertArrayHasKey('enabled', $children); + static::assertArrayHasKey('refresh_interval', $children); + } + + #[Test] + public function itLoadsExtensionWhenEnabled(): void + { + $bundle = new ConsoleProfilerBundle(); + $container = new ContainerBuilder(new ParameterBag([ + 'kernel.debug' => true, + 'kernel.environment' => 'dev', + 'kernel.project_dir' => __DIR__, + ])); + + $loader = $this->createMock(PhpFileLoader::class); + $instanceof = []; + $configurator = new ContainerConfigurator($container, $loader, $instanceof, __DIR__, __FILE__); + + $config = [ + 'enabled' => true, + 'exclude_in_prod' => true, + 'refresh_interval' => 1, + 'excluded_commands' => ['list'], + 'profile_dump_path' => '/tmp/prof.json', + ]; + + $bundle->loadExtension( + $config, + $configurator, + $container + ); + + static::assertTrue($container->has(ConsoleProfilerListener::class)); + + $listenerDef = $container->getDefinition(ConsoleProfilerListener::class); + static::assertSame(1, $listenerDef->getArgument('$refreshInterval')); + } + + #[Test] + public function itSkipsLoadingWhenDisabled(): void + { + $bundle = new ConsoleProfilerBundle(); + $container = new ContainerBuilder(); + + $loader = $this->createMock(PhpFileLoader::class); + $instanceof = []; + $configurator = new ContainerConfigurator($container, $loader, $instanceof, __DIR__, __FILE__); + + $bundle->loadExtension( + [ + 'enabled' => false, + 'exclude_in_prod' => true, + 'refresh_interval' => 1, + 'excluded_commands' => [], + 'profile_dump_path' => null, + ], + $configurator, + $container + ); + + static::assertFalse($container->has(ConsoleProfilerListener::class)); + } + + #[Test] + public function itSkipsLoadingInProdDebugFalse(): void + { + $bundle = new ConsoleProfilerBundle(); + $container = new ContainerBuilder(new ParameterBag(['kernel.debug' => false])); + + $loader = $this->createMock(PhpFileLoader::class); + $instanceof = []; + $configurator = new ContainerConfigurator($container, $loader, $instanceof, __DIR__, __FILE__); + + $bundle->loadExtension( + [ + 'enabled' => true, + 'exclude_in_prod' => true, + 'refresh_interval' => 1, + 'excluded_commands' => [], + 'profile_dump_path' => null, + ], + $configurator, + $container + ); + + static::assertFalse($container->has(ConsoleProfilerListener::class)); + } +} diff --git a/tests/Doctrine/DoctrineMiddlewareTest.php b/tests/Doctrine/DoctrineMiddlewareTest.php new file mode 100644 index 0000000..912a555 --- /dev/null +++ b/tests/Doctrine/DoctrineMiddlewareTest.php @@ -0,0 +1,129 @@ +counter = new QueryCounter(); + } + + #[Test] + public function middlewareWrapReturnsProfilingDriver(): void + { + $innerDriver = $this->createMock(Driver::class); + $middleware = new ProfilingMiddleware($this->counter); + + $driver = $middleware->wrap($innerDriver); + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function connectionQueryIncrementsCounter(): void + { + $result = $this->createMock(Result::class); + $innerConnection = $this->createMock(Connection::class); + $innerConnection->method('query')->willReturn($result); + + $connection = new ProfilingConnection($innerConnection, $this->counter); + + static::assertSame(0, $this->counter->count); + + $connection->query('SELECT 1'); + static::assertSame(1, $this->counter->count); + + $connection->query('SELECT 2'); + static::assertSame(2, $this->counter->count); + } + + #[Test] + public function connectionExecIncrementsCounter(): void + { + $innerConnection = $this->createMock(Connection::class); + $innerConnection->method('exec')->willReturn(1); + + $connection = new ProfilingConnection($innerConnection, $this->counter); + + $connection->exec('DELETE FROM users WHERE id = 1'); + static::assertSame(1, $this->counter->count); + } + + #[Test] + public function connectionPrepareReturnsProfilingStatement(): void + { + $innerStatement = $this->createMock(Statement::class); + $innerConnection = $this->createMock(Connection::class); + $innerConnection->method('prepare')->willReturn($innerStatement); + + $connection = new ProfilingConnection($innerConnection, $this->counter); + $statement = $connection->prepare('SELECT * FROM users WHERE id = ?'); + + static::assertInstanceOf(ProfilingStatement::class, $statement); + // Prepare alone does NOT increment — only execute does + static::assertSame(0, $this->counter->count); + } + + #[Test] + public function statementExecuteIncrementsCounter(): void + { + $result = $this->createMock(Result::class); + + // Create a minimal mock that extends AbstractStatementMiddleware + $innerStatement = $this->createMock(Statement::class); + $innerStatement->method('execute')->willReturn($result); + + $statement = new ProfilingStatement($innerStatement, $this->counter); + + $statement->execute(); + static::assertSame(1, $this->counter->count); + + $statement->execute(); + static::assertSame(2, $this->counter->count); + } + + #[Test] + public function fullMiddlewareChainCountsCorrectly(): void + { + $result = $this->createMock(Result::class); + $innerStatement = $this->createMock(Statement::class); + $innerStatement->method('execute')->willReturn($result); + + $innerConnection = $this->createMock(Connection::class); + $innerConnection->method('query')->willReturn($result); + $innerConnection->method('exec')->willReturn(0); + $innerConnection->method('prepare')->willReturn($innerStatement); + + $connection = new ProfilingConnection($innerConnection, $this->counter); + + $connection->query('SELECT 1'); + $connection->exec('UPDATE t SET x = 1'); + $stmt = $connection->prepare('INSERT INTO t VALUES (?)'); + $stmt->execute(); + $stmt->execute(); + + static::assertSame(4, $this->counter->count); + } +} diff --git a/tests/Doctrine/ProfilingDriverTest.php b/tests/Doctrine/ProfilingDriverTest.php new file mode 100644 index 0000000..4a6e3de --- /dev/null +++ b/tests/Doctrine/ProfilingDriverTest.php @@ -0,0 +1,37 @@ +createMock(Driver::class); + $innerConnection = $this->createMock(Connection::class); + $innerDriver->expects(static::once()) + ->method('connect') + ->with(['url' => 'sqlite:///:memory:']) + ->willReturn($innerConnection); + + $queryCounter = new QueryCounter(); + + $driver = new ProfilingDriver($innerDriver, $queryCounter); + + $connection = $driver->connect(['url' => 'sqlite:///:memory:']); + + static::assertInstanceOf(ProfilingConnection::class, $connection); + } +} diff --git a/tests/Doctrine/QueryCounterTest.php b/tests/Doctrine/QueryCounterTest.php new file mode 100644 index 0000000..530d203 --- /dev/null +++ b/tests/Doctrine/QueryCounterTest.php @@ -0,0 +1,72 @@ +counter = new QueryCounter(); + } + + #[Test] + public function itStartsAtZero(): void + { + static::assertSame(0, $this->counter->count); + } + + #[Test] + public function itIncrementsCorrectly(): void + { + $this->counter->increment(); + static::assertSame(1, $this->counter->count); + + $this->counter->increment(); + $this->counter->increment(); + static::assertSame(3, $this->counter->count); + } + + #[Test] + public function itResetsToZero(): void + { + $this->counter->increment(); + $this->counter->increment(); + static::assertSame(2, $this->counter->count); + + $this->counter->reset(); + static::assertSame(0, $this->counter->count); + } + + #[Test] + public function itCanIncrementAfterReset(): void + { + $this->counter->increment(); + $this->counter->reset(); + $this->counter->increment(); + + static::assertSame(1, $this->counter->count); + } + + #[Test] + public function countPropertyIsPubliclyReadable(): void + { + $this->counter->increment(); + + // Asymmetric visibility: public read, private(set) write + $reflection = new ReflectionProperty(QueryCounter::class, 'count'); + static::assertTrue($reflection->isPublic()); + } +} diff --git a/tests/EventListener/ConsoleProfilerListenerTest.php b/tests/EventListener/ConsoleProfilerListenerTest.php new file mode 100644 index 0000000..2d68f9e --- /dev/null +++ b/tests/EventListener/ConsoleProfilerListenerTest.php @@ -0,0 +1,250 @@ +metricsCollector = $this->createMock(MetricsProviderInterface::class); + $this->tuiManager = new TuiManager(new AnsiTerminal(), new DashboardRenderer(new FormattingUtils())); + $this->queryCounter = new QueryCounter(); + $this->profileExporter = new ProfileExporter(); + } + + #[Test] + public function itHasAsEventListenerAttributes(): void + { + $ref = new ReflectionClass(ConsoleProfilerListener::class); + $attributes = $ref->getAttributes(\Symfony\Component\EventDispatcher\Attribute\AsEventListener::class); + + static::assertCount(3, $attributes); + } + + #[Test] + public function itCanBeInstantiatedWithDefaults(): void + { + $listener = new ConsoleProfilerListener( + metricsCollector: new MetricsCollector('test'), + tuiManager: new TuiManager(new AnsiTerminal(), new DashboardRenderer(new FormattingUtils())), + queryCounter: new QueryCounter(), + ); + + $ref = new ReflectionClass($listener); + static::assertTrue($ref->isFinal()); + } + + #[Test] + public function itCanBeInstantiatedWithCustomExcludedCommands(): void + { + $listener = new ConsoleProfilerListener( + metricsCollector: new MetricsCollector('test'), + tuiManager: new TuiManager(new AnsiTerminal(), new DashboardRenderer(new FormattingUtils())), + queryCounter: new QueryCounter(), + refreshInterval: 2, + excludedCommands: ['list', 'help', 'custom:skip'], + ); + + $ref = new ReflectionClass($listener); + static::assertSame(3, count($ref->getAttributes(\Symfony\Component\EventDispatcher\Attribute\AsEventListener::class))); + } + + #[Test] + public function itSkipsExcludedCommands(): void + { + $listener = new ConsoleProfilerListener( + $this->metricsCollector, + $this->tuiManager, + $this->queryCounter, + 1, + ['list'] + ); + + $this->metricsCollector->expects(static::never())->method('start'); + + $command = new Command('list'); + $event = new ConsoleCommandEvent($command, new StringInput(''), new ConsoleOutput()); + + $listener->onCommand($event); + } + + #[Test] + public function itSkipsNonConsoleOutput(): void + { + $listener = new ConsoleProfilerListener( + $this->metricsCollector, + $this->tuiManager, + $this->queryCounter, + ); + + $this->metricsCollector->expects(static::never())->method('start'); + + $command = new Command('app:test'); + $event = new ConsoleCommandEvent($command, new StringInput(''), new NullOutput()); + + $listener->onCommand($event); + } + + #[Test] + public function itStartsProfilingOnValidCommand(): void + { + $listener = new ConsoleProfilerListener( + $this->metricsCollector, + $this->tuiManager, + $this->queryCounter, + ); + + $this->metricsCollector->expects(static::once())->method('start')->with('app:test'); + $this->metricsCollector->method('snapshot')->willReturn($this->createSnapshot()); + + $command = new Command('app:test'); + + $output = $this->createMock(ConsoleOutput::class); + $stream = fopen('php://memory', 'rw'); + $output->method('getStream')->willReturn($stream); + $output->method('getFormatter')->willReturn(new \Symfony\Component\Console\Formatter\OutputFormatter()); + + $event = new ConsoleCommandEvent($command, new StringInput(''), $output); + + $listener->onCommand($event); + + static::assertTrue($this->tuiManager->isInitialized()); + } + + #[Test] + public function itFreezesOnTerminate(): void + { + $listener = new ConsoleProfilerListener( + $this->metricsCollector, + $this->tuiManager, + $this->queryCounter, + 1, + [], + $this->profileExporter, + '/tmp/dump.json' + ); + + $command = new Command('app:test'); + $output = $this->createMock(ConsoleOutput::class); + $stream = fopen('php://memory', 'rw'); + $output->method('getStream')->willReturn($stream); + $output->method('getFormatter')->willReturn(new \Symfony\Component\Console\Formatter\OutputFormatter()); + + $snapshot = $this->createSnapshot(); + $this->metricsCollector->method('snapshot')->willReturn($snapshot); + + $eventStart = new ConsoleCommandEvent($command, new StringInput(''), $output); + $listener->onCommand($eventStart); + + if (file_exists('/tmp/dump.json')) { + unlink('/tmp/dump.json'); + } + + $eventTerminate = new ConsoleTerminateEvent($command, new StringInput(''), $output, 0); + $listener->onTerminate($eventTerminate); + + static::assertFileExists('/tmp/dump.json'); + unlink('/tmp/dump.json'); + + static::assertIsResource($stream); + rewind($stream); + $contents = stream_get_contents($stream); + static::assertStringContainsString('COMPLETED', $contents); + + // Calling terminate again should be a no-op due to !$this->active + $listener->onTerminate($eventTerminate); + } + + #[Test] + public function itFreezesAsFailedOnErrorThenTerminate(): void + { + $listener = new ConsoleProfilerListener( + $this->metricsCollector, + $this->tuiManager, + $this->queryCounter, + ); + + $command = new Command('app:test'); + $output = $this->createMock(ConsoleOutput::class); + $stream = fopen('php://memory', 'rw'); + $output->method('getStream')->willReturn($stream); + $output->method('getFormatter')->willReturn(new \Symfony\Component\Console\Formatter\OutputFormatter()); + + $snapshot = $this->createSnapshot(); + $this->metricsCollector->method('snapshot')->willReturn($snapshot); + + $eventStart = new ConsoleCommandEvent($command, new StringInput(''), $output); + $listener->onCommand($eventStart); + + // ERROR only sets the failure flag, does NOT freeze + $eventError = new ConsoleErrorEvent(new StringInput(''), $output, new Exception(), $command); + $eventError->setExitCode(1); + $listener->onError($eventError); + + // TERMINATE freezes with FAILED status and the correct exit code + $eventTerminate = new ConsoleTerminateEvent($command, new StringInput(''), $output, 1); + $listener->onTerminate($eventTerminate); + + static::assertIsResource($stream); + rewind($stream); + $contents = stream_get_contents($stream); + static::assertStringContainsString('FAILED', $contents); + } + + #[Test] + public function itHandlesPcntlFallback(): void + { + $listener = new ConsoleProfilerListener( + $this->metricsCollector, + $this->tuiManager, + $this->queryCounter, + ); + + $reflection = new ReflectionMethod($listener, 'callPcntl'); + $reflection->setAccessible(true); + + static::assertNull($reflection->invoke($listener, 'non_existent_function')); + static::assertTrue($reflection->invoke($listener, 'is_bool', true)); + } +} diff --git a/tests/Service/MetricsCollectorTest.php b/tests/Service/MetricsCollectorTest.php new file mode 100644 index 0000000..979cdb5 --- /dev/null +++ b/tests/Service/MetricsCollectorTest.php @@ -0,0 +1,159 @@ +collector = new MetricsCollector('test'); + } + + #[Test] + public function memoryUsagePropertyHookReturnsPositiveValue(): void + { + static::assertGreaterThan(0, $this->collector->memoryUsage); + } + + #[Test] + public function peakMemoryPropertyHookReturnsPositiveValue(): void + { + static::assertGreaterThan(0, $this->collector->peakMemory); + } + + #[Test] + public function elapsedIsZeroBeforeStart(): void + { + static::assertSame(0.0, $this->collector->elapsed); + } + + #[Test] + public function elapsedIncreasesAfterStart(): void + { + $this->collector->start('test:command'); + usleep(10_000); + + static::assertGreaterThan(0.0, $this->collector->elapsed); + } + + #[Test] + public function loadedClassesPropertyHookReturnsPositiveValue(): void + { + static::assertGreaterThan(0, $this->collector->loadedClasses); + } + + #[Test] + public function declaredFunctionsPropertyHookReturnsPositiveValue(): void + { + static::assertGreaterThanOrEqual(0, $this->collector->declaredFunctions); + } + + #[Test] + public function includedFilesPropertyHookReturnsPositiveValue(): void + { + static::assertGreaterThan(0, $this->collector->includedFiles); + } + + #[Test] + public function cpuUserTimePropertyHookReturnsNonNegative(): void + { + static::assertGreaterThanOrEqual(0.0, $this->collector->cpuUserTime); + } + + #[Test] + public function memoryLimitPropertyHookReturnsValue(): void + { + // Either -1 (unlimited) or a positive byte count + $limit = $this->collector->memoryLimit; + static::assertTrue($limit === -1 || $limit > 0, "memory_limit should be -1 or positive, got {$limit}"); + } + + #[Test] + public function memoryLimitParsesUnitsCorrectly(): void + { + $original = ini_get('memory_limit'); + + try { + ini_set('memory_limit', '512M'); + static::assertSame(512 * 1024 * 1024, $this->collector->memoryLimit); + + ini_set('memory_limit', '1G'); + static::assertSame(1024 * 1024 * 1024, $this->collector->memoryLimit); + + ini_set('memory_limit', '2G'); + static::assertSame(2 * 1024 * 1024 * 1024, $this->collector->memoryLimit); + + ini_set('memory_limit', '-1'); + static::assertSame(-1, $this->collector->memoryLimit); + } finally { + ini_set('memory_limit', $original); + } + } + + #[Test] + public function snapshotReturnsCorrectReadonlyDto(): void + { + $this->collector->start('app:import'); + + usleep(5_000); + + $snapshot = $this->collector->snapshot(2); + + static::assertSame('app:import', $snapshot->commandName); + static::assertSame('test', $snapshot->environment); + static::assertSame(2, $snapshot->queryCount); + static::assertGreaterThan(0, $snapshot->memoryUsage); + static::assertGreaterThan(0, $snapshot->peakMemory); + static::assertGreaterThan(0.0, $snapshot->duration); + static::assertSame((int) getmypid(), $snapshot->pid); + static::assertSame(PHP_VERSION, $snapshot->phpVersion); + static::assertSame(PHP_SAPI, $snapshot->sapiName); + static::assertGreaterThanOrEqual(0, $snapshot->loadedClasses); + static::assertGreaterThanOrEqual(0, $snapshot->includedFiles); + static::assertGreaterThanOrEqual(0, $snapshot->gcCycles); + } + + #[Test] + public function snapshotIsImmutable(): void + { + $this->collector->start('app:test'); + $this->collector->snapshot(0); + + $reflection = new ReflectionClass(MetricsSnapshot::class); + static::assertTrue($reflection->isReadOnly()); + } + + #[Test] + public function multipleSnapshotsAreIndependent(): void + { + $this->collector->start('app:test'); + + $snapshot1 = $this->collector->snapshot(0); + usleep(10_000); + $snapshot2 = $this->collector->snapshot(1); + + static::assertNotSame($snapshot1->duration, $snapshot2->duration); + static::assertSame(0, $snapshot1->queryCount); + static::assertSame(1, $snapshot2->queryCount); + } +} diff --git a/tests/Service/ProfileExporterTest.php b/tests/Service/ProfileExporterTest.php new file mode 100644 index 0000000..a9dc085 --- /dev/null +++ b/tests/Service/ProfileExporterTest.php @@ -0,0 +1,114 @@ +tmpDir = sys_get_temp_dir().'/profiler_test_'.bin2hex(random_bytes(4)); + $this->fs = new Filesystem(); + } + + #[Override] + protected function tearDown(): void + { + $path = $this->tmpDir.'/profile.json'; + if ($this->fs->exists($path) === true) { + $this->fs->remove($path); + } + if ($this->fs->exists($this->tmpDir) === true) { + $this->fs->remove($this->tmpDir); + } + } + + #[Test] + public function itExportsSnapshotToJsonFile(): void + { + $exporter = new ProfileExporter(); + $path = $this->tmpDir.'/profile.json'; + + $exporter->export($this->createSnapshot(), $path); + + static::assertFileExists($path); + + $json = $this->fs->readFile($path); + + /** @var array $data */ + $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + static::assertSame('app:import-data', $data['command']); + static::assertSame('dev', $data['environment']); + static::assertNull($data['exit_code']); + static::assertArrayHasKey('memory', $data); + static::assertArrayHasKey('cpu', $data); + static::assertArrayHasKey('counters', $data); + static::assertArrayHasKey('system', $data); + static::assertArrayHasKey('timestamp', $data); + } + + #[Test] + public function itCreatesDirectoryIfNotExists(): void + { + $exporter = new ProfileExporter(); + $path = $this->tmpDir.'/profile.json'; + + static::assertDirectoryDoesNotExist($this->tmpDir); + + $exporter->export($this->createSnapshot(), $path); + + static::assertDirectoryExists($this->tmpDir); + static::assertFileExists($path); + } + + #[Test] + public function itIncludesMemoryGrowthRate(): void + { + $exporter = new ProfileExporter(); + $path = $this->tmpDir.'/profile.json'; + + $exporter->export($this->createSnapshot(), $path); + + /** @var array $data */ + $data = json_decode($this->fs->readFile($path), true, 512, JSON_THROW_ON_ERROR); + + static::assertIsArray($data['memory']); + static::assertArrayHasKey('growth_rate_bytes_per_sec', $data['memory']); + static::assertEquals(512000.0, $data['memory']['growth_rate_bytes_per_sec']); + } + + #[Test] + public function itIncludesQueryCounters(): void + { + $exporter = new ProfileExporter(); + $path = $this->tmpDir.'/profile.json'; + + $exporter->export($this->createSnapshot(), $path); + + /** @var array $data */ + $data = json_decode($this->fs->readFile($path), true, 512, JSON_THROW_ON_ERROR); + + static::assertIsArray($data['counters']); + static::assertSame(142, $data['counters']['sql_queries']); + static::assertSame(312, $data['counters']['loaded_classes']); + } +} diff --git a/tests/TestTrait.php b/tests/TestTrait.php new file mode 100644 index 0000000..4ed01d4 --- /dev/null +++ b/tests/TestTrait.php @@ -0,0 +1,38 @@ +renderer = new DashboardRenderer(new FormattingUtils()); + } + + #[Test] + public function registerStylesDoesNotError(): void + { + $output = $this->createMock(ConsoleOutput::class); + $output->method('getFormatter')->willReturn(new \Symfony\Component\Console\Formatter\OutputFormatter()); + + $this->renderer->registerStyles($output); + + static::assertTrue($output->getFormatter()->hasStyle('profiler_title')); + } + + #[Test] + public function buildReturnsCorrectNumberOfLines(): void + { + $lines = $this->renderer->build($this->createSnapshot(), ProfilerStatus::Running, 80); + + static::assertCount(17, $lines); + } + + #[Test] + public function buildIncludesRunningBadge(): void + { + $lines = $this->renderer->build($this->createSnapshot(), ProfilerStatus::Running, 80); + + $titleLine = $lines[1]; + static::assertStringContainsString('PROFILING', $titleLine); + static::assertStringContainsString('profiler_running', $titleLine); + } + + #[Test] + public function buildIncludesCompletedBadge(): void + { + $lines = $this->renderer->build($this->createSnapshot(), ProfilerStatus::Completed, 80); + + $titleLine = $lines[1]; + static::assertStringContainsString('COMPLETED', $titleLine); + static::assertStringContainsString('profiler_ok', $titleLine); + } + + #[Test] + public function buildIncludesFailedBadge(): void + { + $lines = $this->renderer->build($this->createSnapshot(), ProfilerStatus::Failed, 80); + + $titleLine = $lines[1]; + static::assertStringContainsString('FAILED', $titleLine); + static::assertStringContainsString('profiler_fail', $titleLine); + } + + #[Test] + public function buildRendersCommandName(): void + { + $lines = $this->renderer->build($this->createSnapshot(), ProfilerStatus::Running, 80); + + $commandLine = $lines[3]; + static::assertStringContainsString('app:import-data', $commandLine); + } + + #[Test] + public function buildRendersQueryCount(): void + { + $lines = $this->renderer->build($this->createSnapshot(), ProfilerStatus::Running, 80); + + $sqlLine = $lines[14]; + static::assertStringContainsString('142', $sqlLine); + } + + #[Test] + public function buildUsesBoxBorders(): void + { + $lines = $this->renderer->build($this->createSnapshot(), ProfilerStatus::Running, 80); + + static::assertStringContainsString('╔', $lines[0]); + static::assertStringContainsString('╚', $lines[16]); + static::assertStringContainsString('║', $lines[1]); + } + + #[Test] + public function buildRendersExitCodeOnFinalRender(): void + { + $lines = $this->renderer->build($this->createSnapshot(), ProfilerStatus::Completed, 80, exitCode: 0); + + $titleLine = $lines[1]; + static::assertStringContainsString('Exit:', $titleLine); + static::assertStringContainsString('0', $titleLine); + } + + #[Test] + public function buildRendersFailedExitCode(): void + { + $lines = $this->renderer->build($this->createSnapshot(), ProfilerStatus::Failed, 80, exitCode: 1); + + $titleLine = $lines[1]; + static::assertStringContainsString('Exit:', $titleLine); + static::assertStringContainsString('1', $titleLine); + } + + #[Test] + public function buildRendersMemoryGrowthRate(): void + { + $lines = $this->renderer->build($this->createSnapshot(), ProfilerStatus::Running, 80); + + $memoryLine = $lines[10]; + static::assertStringContainsString('/s', $memoryLine); + static::assertStringContainsString('profiler_bar_ok', $memoryLine); + } + + #[Test] + public function buildRendersProgressBarForHighMemory(): void + { + $snapshot = new MetricsSnapshot( + memoryUsage: 241_591_910, + peakMemory: 241_591_910, + memoryLimit: 268_435_456, + duration: 1.0, + cpuUserTime: 0.5, + cpuSystemTime: 0.01, + memoryGrowthRate: 0.0, + pid: 1, + commandName: 'test', + environment: 'dev', + queryCount: 0, + loadedClasses: 1, + declaredFunctions: 1, + includedFiles: 1, + gcCycles: 0, + phpVersion: '8.4.0', + sapiName: 'cli', + opcacheEnabled: false, + xdebugEnabled: false, + ); + + $lines = $this->renderer->build($snapshot, ProfilerStatus::Running, 80); + $memoryLine = $lines[9]; + + static::assertStringContainsString('profiler_bar_danger', $memoryLine); + } + + #[Test] + public function buildRendersProgressBarForWarningMemory(): void + { + $snapshot = new MetricsSnapshot( + memoryUsage: 187_904_819, + peakMemory: 187_904_819, + memoryLimit: 268_435_456, + duration: 1.0, + cpuUserTime: 0.5, + cpuSystemTime: 0.01, + memoryGrowthRate: 0.0, + pid: 1, + commandName: 'test', + environment: 'dev', + queryCount: 0, + loadedClasses: 1, + declaredFunctions: 1, + includedFiles: 1, + gcCycles: 0, + phpVersion: '8.4.0', + sapiName: 'cli', + opcacheEnabled: false, + xdebugEnabled: false, + ); + + $lines = $this->renderer->build($snapshot, ProfilerStatus::Running, 80); + $memoryLine = $lines[9]; + + static::assertStringContainsString('profiler_bar_warn', $memoryLine); + } + + #[Test] + public function buildHandlesZeroMemoryLimit(): void + { + $snapshot = new MetricsSnapshot( + memoryUsage: 10_000, + peakMemory: 10_000, + memoryLimit: 0, + duration: 1.0, + cpuUserTime: 0.1, + cpuSystemTime: 0.0, + memoryGrowthRate: 0.0, + pid: 1, + commandName: 'test', + environment: 'dev', + queryCount: 0, + loadedClasses: 1, + declaredFunctions: 1, + includedFiles: 1, + gcCycles: 0, + phpVersion: '8.4.0', + sapiName: 'cli', + opcacheEnabled: true, + xdebugEnabled: true, + ); + + $lines = $this->renderer->build($snapshot, ProfilerStatus::Running, 80); + static::assertStringContainsString('∞', $lines[9]); + static::assertStringContainsString('n/a', $lines[9]); + } + + #[Test] + public function buildHandlesZeroDuration(): void + { + $snapshot = new MetricsSnapshot( + memoryUsage: 10_000, + peakMemory: 10_000, + memoryLimit: 100_000, + duration: 0.0, + cpuUserTime: 0.0, + cpuSystemTime: 0.0, + memoryGrowthRate: 0.0, + pid: 1, + commandName: 'test', + environment: 'dev', + queryCount: 0, + loadedClasses: 1, + declaredFunctions: 1, + includedFiles: 1, + gcCycles: 0, + phpVersion: '8.4.0', + sapiName: 'cli', + opcacheEnabled: false, + xdebugEnabled: false, + ); + + $lines = $this->renderer->build($snapshot, ProfilerStatus::Running, 80); + static::assertCount(17, $lines); + } + + #[Test] + public function buildRendersHighGrowthRate(): void + { + $snapshot = new MetricsSnapshot( + memoryUsage: 50_000_000, + peakMemory: 50_000_000, + memoryLimit: 268_435_456, + duration: 2.0, + cpuUserTime: 0.5, + cpuSystemTime: 0.01, + memoryGrowthRate: 15_000_000.0, // > 10MB/s + pid: 1, + commandName: 'test', + environment: 'dev', + queryCount: 0, + loadedClasses: 1, + declaredFunctions: 1, + includedFiles: 1, + gcCycles: 0, + phpVersion: '8.4.0', + sapiName: 'cli', + opcacheEnabled: false, + xdebugEnabled: false, + ); + + $lines = $this->renderer->build($snapshot, ProfilerStatus::Running, 80); + static::assertStringContainsString('profiler_bar_danger', $lines[10]); + } + + #[Test] + public function buildRendersWarnGrowthRate(): void + { + $snapshot = new MetricsSnapshot( + memoryUsage: 50_000_000, + peakMemory: 50_000_000, + memoryLimit: 268_435_456, + duration: 2.0, + cpuUserTime: 0.5, + cpuSystemTime: 0.01, + memoryGrowthRate: 2_000_000.0, // 2MB/s -> Warn + pid: 1, + commandName: 'test', + environment: 'dev', + queryCount: 0, + loadedClasses: 1, + declaredFunctions: 1, + includedFiles: 1, + gcCycles: 0, + phpVersion: '8.4.0', + sapiName: 'cli', + opcacheEnabled: false, + xdebugEnabled: false, + ); + + $lines = $this->renderer->build($snapshot, ProfilerStatus::Running, 80); + static::assertStringContainsString('profiler_bar_warn', $lines[10]); + } +} diff --git a/tests/Tui/TuiManagerTest.php b/tests/Tui/TuiManagerTest.php new file mode 100644 index 0000000..4d7396f --- /dev/null +++ b/tests/Tui/TuiManagerTest.php @@ -0,0 +1,151 @@ +manager = new TuiManager( + terminal: new AnsiTerminal(), + renderer: new DashboardRenderer(new FormattingUtils()), + ); + } + + #[Test] + public function itIsNotInitializedByDefault(): void + { + static::assertFalse($this->manager->isInitialized()); + } + + #[Test] + public function terminalWidthPropertyHookReturnsAtLeast70(): void + { + static::assertGreaterThanOrEqual(70, $this->manager->terminalWidth); + } + + #[Test] + public function terminalHeightPropertyHookReturnsAtLeast20(): void + { + static::assertGreaterThanOrEqual(20, $this->manager->terminalHeight); + } + + #[Test] + public function renderDoesNothingWhenNotInitialized(): void + { + $snapshot = $this->createSnapshot(); + + $this->manager->render($snapshot); + + static::assertFalse($this->manager->isInitialized()); + } + + #[Test] + public function freezeDoesNothingWhenNotInitialized(): void + { + $snapshot = $this->createSnapshot(); + + $this->manager->freeze($snapshot); + + static::assertFalse($this->manager->isInitialized()); + } + + #[Test] + public function itInitializesProperlyWithStreamAndFormatter(): void + { + $output = $this->createMock(ConsoleOutput::class); + $stream = fopen('php://memory', 'rw'); + $output->method('getStream')->willReturn($stream); + $output->method('getFormatter')->willReturn($this->createMock(OutputFormatterInterface::class)); + + $this->manager->initialize($output); + + static::assertTrue($this->manager->isInitialized()); + } + + #[Test] + public function itRendersProfilerDashboardToStream(): void + { + $output = $this->createMock(ConsoleOutput::class); + $stream = fopen('php://memory', 'rw'); + $output->method('getStream')->willReturn($stream); + + $formatter = $this->createMock(OutputFormatterInterface::class); + $formatter->method('format')->willReturnCallback(static fn (?string $s) => $s ?? ''); + $output->method('getFormatter')->willReturn($formatter); + + $this->manager->initialize($output); + $this->manager->render($this->createSnapshot()); + + static::assertIsResource($stream); + rewind($stream); + $contents = stream_get_contents($stream); + + static::assertIsString($contents); + static::assertStringContainsString('CONSOLE PROFILER', $contents); + static::assertStringContainsString('app:import-data', $contents); + } + + #[Test] + public function itFreezesProfilerDashboardAndShowsCompletedStatus(): void + { + $output = $this->createMock(ConsoleOutput::class); + $stream = fopen('php://memory', 'rw'); + $output->method('getStream')->willReturn($stream); + + $formatter = $this->createMock(OutputFormatterInterface::class); + $formatter->method('format')->willReturnCallback(static fn (?string $s) => $s ?? ''); + $output->method('getFormatter')->willReturn($formatter); + + $this->manager->initialize($output); + $this->manager->freeze($this->createSnapshot(), false, 0); + + static::assertIsResource($stream); + rewind($stream); + $contents = stream_get_contents($stream); + + static::assertIsString($contents); + static::assertStringContainsString('COMPLETED', $contents); + } + + #[Test] + public function itFreezesProfilerDashboardAndShowsFailedStatus(): void + { + $output = $this->createMock(ConsoleOutput::class); + $stream = fopen('php://memory', 'rw'); + $output->method('getStream')->willReturn($stream); + + $formatter = $this->createMock(OutputFormatterInterface::class); + $formatter->method('format')->willReturnCallback(static fn (?string $s) => $s ?? ''); + $output->method('getFormatter')->willReturn($formatter); + + $this->manager->initialize($output); + $this->manager->freeze($this->createSnapshot(), true, 1); + + static::assertIsResource($stream); + rewind($stream); + $contents = stream_get_contents($stream); + + static::assertIsString($contents); + static::assertStringContainsString('FAILED', $contents); + } +} diff --git a/tests/Util/FormattingUtilsTest.php b/tests/Util/FormattingUtilsTest.php new file mode 100644 index 0000000..4d5e3f6 --- /dev/null +++ b/tests/Util/FormattingUtilsTest.php @@ -0,0 +1,56 @@ +utils = new FormattingUtils(); + } + + #[Test] + public function itFormatsBytesToHumanReadable(): void + { + static::assertSame('0 B', $this->utils->formatBytes(0)); + static::assertSame('1.0 KB', $this->utils->formatBytes(1024)); + static::assertSame('1.5 MB', $this->utils->formatBytes(1572864)); + } + + #[Test] + public function itFormatsDurationToHumanReadable(): void + { + static::assertSame('100μs', $this->utils->formatDuration(0.0001)); + static::assertSame('500ms', $this->utils->formatDuration(0.5)); + static::assertSame('4.25s', $this->utils->formatDuration(4.25)); + static::assertSame('2m 5.0s', $this->utils->formatDuration(125.0)); + } + + #[Test] + public function itFormatsDurationCompact(): void + { + static::assertSame('00:05', $this->utils->formatDurationCompact(5.0)); + static::assertSame('02:05', $this->utils->formatDurationCompact(125.0)); + } + + #[Test] + public function itTruncatesLongStrings(): void + { + static::assertSame('Short', $this->utils->truncate('Short', 10)); + static::assertSame('Short', $this->utils->truncate('Short', 5)); + static::assertSame('Sho…', $this->utils->truncate('Short', 4)); + static::assertSame('Long string…', $this->utils->truncate('Long string here', 12)); + } +}