----
+Part of the **[KaririCode Framework](https://kariricode.org)** ecosystem.
-**Built with ❤️ by the KaririCode Team**
+[kariricode.org](https://kariricode.org) · [GitHub](https://github.com/kariricode) · [Packagist](https://packagist.org/packages/kariricode/)
-*Empowering developers to build robust, maintainable, and professional PHP applications*
\ No newline at end of file
+
diff --git a/bin/build-phar.php b/bin/build-phar.php
new file mode 100644
index 0000000..d137753
--- /dev/null
+++ b/bin/build-phar.php
@@ -0,0 +1,105 @@
+startBuffering();
+
+echo "📦 Building kcode.phar...\n";
+
+// ── 1. Add src/ ────────────────────────────────────────────
+$added = 0;
+$srcDir = $root . '/src';
+$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS));
+foreach ($it as $file) {
+ if ($file->isFile() && $file->getExtension() === 'php') {
+ $relative = 'src/' . $it->getSubPathname();
+ $phar[$relative] = file_get_contents($file->getPathname());
+ $added++;
+ }
+}
+echo " + src/: $added PHP files\n";
+
+// ── 2. Add vendor/ (PHP files only, no tests/docs) ─────────
+$vendorDir = $root . '/vendor';
+$excludeDirs = ['Tests', 'tests', 'test', 'doc', 'docs', 'examples', '.github'];
+
+$vendorIt = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($vendorDir, FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::LEAVES_ONLY
+);
+
+$vendorAdded = 0;
+foreach ($vendorIt as $file) {
+ if (!$file->isFile()) continue;
+ $path = $file->getPathname();
+
+ // Skip test/doc directories
+ $skip = false;
+ foreach ($excludeDirs as $ex) {
+ if (str_contains($path, DIRECTORY_SEPARATOR . $ex . DIRECTORY_SEPARATOR)) {
+ $skip = true;
+ break;
+ }
+ }
+ if ($skip) continue;
+
+ // Only PHP and JSON files
+ $ext = $file->getExtension();
+ if (!in_array($ext, ['php', 'json'], true)) continue;
+
+ $relative = 'vendor/' . substr($path, strlen($vendorDir) + 1);
+ $phar[$relative] = file_get_contents($path);
+ $vendorAdded++;
+}
+echo " + vendor/: $vendorAdded files\n";
+
+// ── 3. Add LICENSE ──────────────────────────────────────────
+$phar['LICENSE'] = file_get_contents($root . '/LICENSE');
+echo " + LICENSE\n";
+
+// ── 4. Set bin/kcode as the entry point (stub) ─────────────
+$kcodeContent = file_get_contents($root . '/bin/kcode');
+$phar['bin/kcode'] = $kcodeContent;
+
+$stub = <<<'STUB'
+#!/usr/bin/env php
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+Phar::mapPhar('kcode.phar');
+require 'phar://kcode.phar/bin/kcode';
+__HALT_COMPILER();
+STUB;
+
+$phar->setStub($stub);
+echo " + stub (bin/kcode entry point)\n";
+
+// ── 5. Finalize ─────────────────────────────────────────────
+$phar->stopBuffering();
+$phar->compressFiles(Phar::GZ);
+chmod($output, 0755);
+
+$size = round(filesize($output) / 1024 / 1024, 2);
+echo "\n✅ Built: $output ($size MB)\n";
+echo " Files: " . count($phar) . "\n";
diff --git a/bin/kcode b/bin/kcode
new file mode 100755
index 0000000..0fd2af8
--- /dev/null
+++ b/bin/kcode
@@ -0,0 +1,94 @@
+#!/usr/bin/env php
+addGenerator(new \KaririCode\Devkit\Configuration\PhpUnitConfigGenerator());
+ $devkit->addGenerator(new \KaririCode\Devkit\Configuration\PhpStanConfigGenerator());
+ $devkit->addGenerator(new \KaririCode\Devkit\Configuration\CsFixerConfigGenerator());
+ $devkit->addGenerator(new \KaririCode\Devkit\Configuration\RectorConfigGenerator());
+ $devkit->addGenerator(new \KaririCode\Devkit\Configuration\PsalmConfigGenerator());
+
+ // Register tool runners — require ProcessExecutor and ProjectContext
+ $executor = new \KaririCode\Devkit\Core\ProcessExecutor($workingDirectory);
+
+ // Detect project context eagerly — runners need it at registration.
+ // Graceful null when no composer.json found (help/version still work).
+ try {
+ $context = $devkit->context($workingDirectory);
+ } catch (\KaririCode\Devkit\Exception\DevkitException) {
+ // Help and version work without project context.
+ // Commands requiring context will re-detect and throw at execution time.
+ $context = null;
+ }
+
+ if (null !== $context) {
+ $devkit->addRunner(new \KaririCode\Devkit\Runner\PhpUnitRunner($executor, $context));
+ $devkit->addRunner(new \KaririCode\Devkit\Runner\PhpStanRunner($executor, $context));
+ $devkit->addRunner(new \KaririCode\Devkit\Runner\CsFixerRunner($executor, $context));
+ $devkit->addRunner(new \KaririCode\Devkit\Runner\RectorRunner($executor, $context));
+ $devkit->addRunner(new \KaririCode\Devkit\Runner\PsalmRunner($executor, $context));
+ $devkit->addRunner(new \KaririCode\Devkit\Runner\ComposerAuditRunner($executor, $context));
+ }
+
+ // ── Wire CLI commands ─────────────────────────────────────────
+
+ $app = new \KaririCode\Devkit\Command\Application($devkit);
+
+ $app->register(new \KaririCode\Devkit\Command\InitCommand());
+ $app->register(new \KaririCode\Devkit\Command\MigrateCommand(
+ new \KaririCode\Devkit\Core\MigrationDetector(),
+ ));
+ $app->register(new \KaririCode\Devkit\Command\TestCommand());
+ $app->register(new \KaririCode\Devkit\Command\AnalyseCommand());
+ $app->register(new \KaririCode\Devkit\Command\CsFixCommand());
+ $app->register(new \KaririCode\Devkit\Command\RectorCommand());
+ $app->register(new \KaririCode\Devkit\Command\SecurityCommand());
+ $app->register(new \KaririCode\Devkit\Command\QualityCommand());
+ $app->register(new \KaririCode\Devkit\Command\FormatCommand());
+ $app->register(new \KaririCode\Devkit\Command\CleanCommand());
+
+ // ── Dispatch ──────────────────────────────────────────────────
+
+ exit($app->run($argv));
+})($argv ?? []);
diff --git a/box.json b/box.json
new file mode 100644
index 0000000..fb393ab
--- /dev/null
+++ b/box.json
@@ -0,0 +1,64 @@
+{
+ "main": "bin/kcode",
+ "output": "build/kcode.phar",
+
+ "directories": [
+ "src"
+ ],
+
+ "finder": [
+ {
+ "name": "*.php",
+ "in": ["vendor"],
+ "exclude": [
+ "Tests",
+ "tests",
+ "test",
+ "doc",
+ "docs",
+ "examples"
+ ]
+ }
+ ],
+
+ "files": [
+ "vendor/autoload.php",
+ "LICENSE"
+ ],
+
+ "blacklist": [
+ "box.json",
+ "composer.lock",
+ "Makefile",
+ "README.md",
+ "CHANGELOG.md",
+ "phpunit.xml.dist",
+ "phpstan.neon",
+ ".php-cs-fixer.dist.php"
+ ],
+
+ "compactors": [
+ "KevinGH\\Box\\Compactor\\Php"
+ ],
+
+ "compression": "GZ",
+ "chmod": "0755",
+ "stub": true,
+ "alias": "kcode.phar",
+
+ "banner": [
+ "KaririCode Devkit — kcode",
+ "",
+ "Unified quality toolchain for KaririCode Framework.",
+ "",
+ "(c) Walmir Silva ",
+ "",
+ "For the full copyright and license information, please view",
+ "the LICENSE file that was distributed with this source code."
+ ],
+
+ "metadata": {
+ "tool": "KaririCode Devkit",
+ "version": "1.0.0"
+ }
+}
diff --git a/composer.json b/composer.json
index 5379c3d..c4686c3 100644
--- a/composer.json
+++ b/composer.json
@@ -1,63 +1,74 @@
{
"name": "kariricode/devkit",
- "description": "Professional development environment for KaririCode Framework components",
- "type": "project",
+ "description": "Unified quality toolchain for KaririCode Framework — encapsulates PHPUnit, PHPStan, PHP-CS-Fixer, Rector, and Psalm in .kcode/",
+ "type": "library",
+ "license": "MIT",
"keywords": [
"kariricode",
"devkit",
- "development-environment",
- "docker",
- "php"
+ "quality",
+ "toolchain",
+ "phpunit",
+ "phpstan",
+ "php-cs-fixer",
+ "rector",
+ "psalm",
+ "static-analysis",
+ "code-style"
],
- "license": "MIT",
+ "homepage": "https://kariricode.org",
"authors": [
{
"name": "Walmir Silva",
- "email": "walmir.silva@kariricode.org",
- "homepage": "https://kariricode.org",
- "role": "Developer"
+ "email": "walmir.silva@kariricode.org"
}
],
- "homepage": "https://github.com/KaririCode-Framework/kariricode-devkit",
- "support": {
- "issues": "https://github.com/KaririCode-Framework/kariricode-devkit/issues",
- "source": "https://github.com/KaririCode-Framework/kariricode-devkit",
- "docs": "https://kariricode.org/docs/devkit"
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0",
+ "phpstan/phpstan": "^2.0",
+ "friendsofphp/php-cs-fixer": "^3.64",
+ "rector/rector": "^2.0",
+ "vimeo/psalm": "^6.0"
},
"autoload": {
"psr-4": {
- "KaririCode\\DevKit\\": "src/"
+ "KaririCode\\Devkit\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
- "KaririCode\\DevKit\\Tests\\": "tests/"
+ "KaririCode\\Devkit\\Tests\\": "tests/"
}
},
- "require": {
- "php": "^8.4"
- },
- "require-dev": {
- "friendsofphp/php-cs-fixer": "^3.89",
- "infection/infection": "^0.31",
- "phpbench/phpbench": "^1.3",
- "phpstan/phpstan": "^2.1",
- "phpstan/phpstan-strict-rules": "^2.0",
- "phpunit/phpunit": "^12.4",
- "squizlabs/php_codesniffer": "^4.0",
- "symfony/var-dumper": "^7.3",
- "vimeo/psalm": "^6.13"
+ "bin": [
+ "bin/kcode"
+ ],
+ "scripts": {
+ "kcode:init": "bin/kcode init",
+ "kcode:test": "bin/kcode test",
+ "kcode:analyse": "bin/kcode analyse",
+ "kcode:cs": "bin/kcode cs:fix",
+ "kcode:cs-check": "bin/kcode cs:fix --check",
+ "kcode:quality": "bin/kcode quality",
+ "kcode:migrate": "bin/kcode migrate",
+ "build": "php -d phar.readonly=0 bin/build-phar.php"
},
"config": {
"sort-packages": true,
- "optimize-autoloader": true,
"preferred-install": "dist",
+ "optimize-autoloader": true,
"allow-plugins": {
- "php-http/discovery": true,
- "dealerdirect/phpcodesniffer-composer-installer": true,
- "infection/extension-installer": true
+ "infection/extension-installer": false
+ }
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.0.x-dev"
}
},
- "minimum-stability": "dev",
+ "minimum-stability": "stable",
"prefer-stable": true
}
diff --git a/devkit/.config/php/error-reporting.ini b/devkit/.config/php/error-reporting.ini
deleted file mode 100644
index 8fec4ec..0000000
--- a/devkit/.config/php/error-reporting.ini
+++ /dev/null
@@ -1,145 +0,0 @@
-; ============================================================================
-; KaririCode DevKit - Error Reporting Configuration
-; ============================================================================
-; Strict error reporting configuration for development environment
-; All errors, warnings, and notices are displayed to catch issues early
-;
-; Location: devkit/.config/php/error-reporting.ini
-; ============================================================================
-
-[PHP]
-; ============================================================================
-; ERROR REPORTING
-; ============================================================================
-; Report all errors, warnings, and notices
-error_reporting = E_ALL
-
-; Display errors on screen (development only)
-display_errors = On
-display_startup_errors = On
-
-; Log errors to file
-log_errors = On
-error_log = /var/log/php_errors.log
-
-; Detailed error messages
-html_errors = On
-docref_root = "https://www.php.net/manual/en/"
-docref_ext = .html
-
-; Track all errors
-track_errors = Off
-xmlrpc_errors = Off
-
-; ============================================================================
-; ASSERTIONS
-; ============================================================================
-; Enable assertions for development
-zend.assertions = 1
-assert.active = 1
-assert.exception = 1
-assert.bail = 0
-
-; ============================================================================
-; DEVELOPMENT SETTINGS
-; ============================================================================
-; Hide PHP version in headers (security)
-expose_php = Off
-
-; Variables order
-variables_order = "EGPCS"
-request_order = "GP"
-
-; Auto-detect line endings
-auto_detect_line_endings = Off
-
-; ============================================================================
-; RESOURCE LIMITS
-; ============================================================================
-; Memory limit (generous for development)
-memory_limit = 512M
-
-; Maximum execution time
-max_execution_time = 30
-max_input_time = 60
-
-; Input size limits
-post_max_size = 25M
-upload_max_filesize = 20M
-max_file_uploads = 20
-
-; ============================================================================
-; OUTPUT BUFFERING
-; ============================================================================
-; Output buffering (off for immediate error display)
-output_buffering = Off
-implicit_flush = On
-
-; ============================================================================
-; DATE/TIME
-; ============================================================================
-; Default timezone
-date.timezone = UTC
-
-; ============================================================================
-; SESSION
-; ============================================================================
-; Session configuration
-session.save_handler = files
-session.save_path = "/tmp"
-session.use_strict_mode = 1
-session.use_cookies = 1
-session.use_only_cookies = 1
-session.cookie_httponly = 1
-session.cookie_secure = 0
-session.cookie_samesite = "Lax"
-session.gc_probability = 1
-session.gc_divisor = 100
-session.gc_maxlifetime = 1440
-
-; ============================================================================
-; REALPATH CACHE
-; ============================================================================
-; Realpath cache (keep small for development)
-realpath_cache_size = 4096K
-realpath_cache_ttl = 120
-
-; ============================================================================
-; FILE UPLOADS
-; ============================================================================
-; File uploads enabled
-file_uploads = On
-upload_tmp_dir = /tmp
-
-; ============================================================================
-; SECURITY
-; ============================================================================
-; Disable dangerous functions (customize as needed)
-disable_functions =
-disable_classes =
-
-; ============================================================================
-; MAIL
-; ============================================================================
-; Mail configuration (usually handled by application)
-SMTP = localhost
-smtp_port = 25
-sendmail_path = /usr/sbin/sendmail -t -i
-
-; ============================================================================
-; MISC
-; ============================================================================
-; Allow URL fopen (needed for many libraries)
-allow_url_fopen = On
-allow_url_include = Off
-
-; Auto-prepend/append files
-auto_prepend_file =
-auto_append_file =
-
-; Default charset
-default_charset = "UTF-8"
-
-; Maximum input variables (prevent resource exhaustion)
-max_input_vars = 1000
-max_input_nesting_level = 64
\ No newline at end of file
diff --git a/devkit/.config/php/opcache.ini b/devkit/.config/php/opcache.ini
deleted file mode 100644
index 32af5cc..0000000
--- a/devkit/.config/php/opcache.ini
+++ /dev/null
@@ -1,15 +0,0 @@
-; --- OPcache base (FPM + CLI) ---
-zend_extension=opcache.so
-
-opcache.enable=1
-opcache.enable_cli=1
-
-; Tuning sugerido p/ dev
-opcache.memory_consumption=192
-opcache.interned_strings_buffer=16
-opcache.max_accelerated_files=20000
-opcache.validate_timestamps=1
-opcache.revalidate_freq=2
-opcache.fast_shutdown=1
-; Evita cache de arquivos que mudam muito em dev, se preferir
-; opcache.file_update_protection=0
diff --git a/devkit/.config/php/xdebug.ini b/devkit/.config/php/xdebug.ini
deleted file mode 100644
index 462643e..0000000
--- a/devkit/.config/php/xdebug.ini
+++ /dev/null
@@ -1,160 +0,0 @@
-; ============================================================================
-; KaririCode DevKit - Xdebug Configuration
-; ============================================================================
-; Xdebug 3.x configuration for step debugging and code coverage
-; https://xdebug.org/docs/all_settings
-;
-; Location: devkit/.config/php/xdebug.ini
-; ============================================================================
-
-[xdebug]
-; ============================================================================
-; MODE CONFIGURATION
-; ============================================================================
-; Modes: off, develop, coverage, debug, gcstats, profile, trace
-; Multiple modes can be combined with commas (e.g., "debug,coverage")
-; This is controlled by environment variable XDEBUG_MODE in .env
-xdebug.mode=${XDEBUG_MODE}
-
-; ============================================================================
-; DEBUGGING
-; ============================================================================
-; Start debugging automatically or wait for trigger
-; Values: yes, no, trigger
-xdebug.start_with_request=yes
-
-; IDE/Client connection settings
-; Use host.docker.internal for Docker Desktop (Mac/Windows)
-; Use 172.17.0.1 for Docker on Linux
-xdebug.client_host=host.docker.internal
-xdebug.client_port=9003
-
-; Discovery mode for cloud/dynamic environments
-; Set to 1 if you need automatic discovery (not recommended for local dev)
-xdebug.discover_client_host=0
-
-; IDE key for identifying debugging session
-; PHPStorm: PHPSTORM
-; VSCode: VSCODE
-xdebug.idekey=PHPSTORM
-
-; Connection timeout in milliseconds
-xdebug.connect_timeout_ms=2000
-
-; ============================================================================
-; LOGGING
-; ============================================================================
-; Log file location (useful for debugging connection issues)
-xdebug.log=/var/log/xdebug.log
-
-; Log level (0-10, where 10 is most verbose)
-; 0 = Criticals
-; 1 = Errors
-; 3 = Warnings
-; 5 = Communication
-; 7 = Information
-; 10 = Debug
-xdebug.log_level=7
-
-; ============================================================================
-; STEP DEBUGGING
-; ============================================================================
-; Maximum nesting level for recursive debugging
-; Increase if you have deeply nested structures
-xdebug.max_nesting_level=512
-
-; ============================================================================
-; COVERAGE
-; ============================================================================
-; Enable code coverage (required for PHPUnit coverage)
-xdebug.coverage_enable=1
-
-; ============================================================================
-; DEVELOPMENT MODE
-; ============================================================================
-; Development helpers (when mode=develop)
-; Show local variables in stack traces
-xdebug.dump.GET=*
-xdebug.dump.POST=*
-xdebug.dump.COOKIE=*
-xdebug.dump.FILES=*
-xdebug.dump.SESSION=*
-
-; ============================================================================
-; PROFILING (disabled by default)
-; ============================================================================
-; Uncomment to enable profiling
-; xdebug.profiler_enable=0
-; xdebug.profiler_enable_trigger=1
-; xdebug.profiler_enable_trigger_value=""
-; xdebug.profiler_output_dir=/var/www/profiler
-; xdebug.profiler_output_name=cachegrind.out.%p
-
-; ============================================================================
-; TRACING (disabled by default)
-; ============================================================================
-; Uncomment to enable function tracing
-; xdebug.trace_enable_trigger=1
-; xdebug.trace_enable_trigger_value=""
-; xdebug.trace_output_dir=/var/www/traces
-; xdebug.trace_output_name=trace.%c
-; xdebug.trace_format=0
-; xdebug.trace_options=0
-
-; ============================================================================
-; PERFORMANCE
-; ============================================================================
-; Show memory usage in stack traces
-xdebug.show_mem_delta=1
-
-; ============================================================================
-; DISPLAY
-; ============================================================================
-; HTML error output formatting
-xdebug.cli_color=1
-
-; Variable display depth
-xdebug.var_display_max_depth=10
-
-; Maximum number of array children/object properties
-xdebug.var_display_max_children=256
-
-; Maximum string length
-xdebug.var_display_max_data=4096
-
-; ============================================================================
-; USAGE TIPS
-; ============================================================================
-;
-; Enable Xdebug:
-; make xdebug-on
-;
-; Disable Xdebug:
-; make xdebug-off
-;
-; Check Status:
-; make xdebug-status
-;
-; IDE Configuration:
-; PHPStorm:
-; - Settings > PHP > Debug
-; - Port: 9003
-; - Check "Accept external connections"
-; - Settings > PHP > Servers
-; - Add server: localhost, port 9003
-; - Map: /var/www -> your-project-path
-;
-; VSCode:
-; - Install PHP Debug extension
-; - Add to launch.json:
-; {
-; "name": "Listen for Xdebug",
-; "type": "php",
-; "request": "launch",
-; "port": 9003,
-; "pathMappings": {
-; "/var/www": "${workspaceFolder}"
-; }
-; }
-;
-; ============================================================================
\ No newline at end of file
diff --git a/devkit/.config/phpmd/ruleset.xml b/devkit/.config/phpmd/ruleset.xml
deleted file mode 100644
index 0c05d4d..0000000
--- a/devkit/.config/phpmd/ruleset.xml
+++ /dev/null
@@ -1,273 +0,0 @@
-
-
-
-
-
- Professional PHPMD ruleset for KaririCode Framework components.
- Enforces clean code principles, SOLID design, and maintainability.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- */tests/*
- */Tests/*
-
-
- */vendor/*
-
-
- */cache/*
- */storage/*
- */temp/*
- */tmp/*
-
-
- */build/*
- */coverage/*
- */docs/*
-
-
- */migrations/*
- */database/factories/*
- */database/seeders/*
-
-
- */_ide_helper*.php
- *.blade.php
-
-
- */config/*
-
-
- */public/*
-
-
-
-
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 3c788e0..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,423 +0,0 @@
-# ==============================================================================
-# KaririCode DevKit - Professional Docker Compose Configuration
-# ==============================================================================
-# Production-grade development environment following Docker best practices
-#
-# Features:
-# - Resource limits and reservations
-# - Health checks with proper timeouts
-# - Security hardening (read-only mounts, tmpfs, capabilities)
-# - Structured logging
-# - Multi-environment profiles
-# - Named volumes for performance
-# - Proper dependency management
-#
-# Usage:
-# Development: make up-safe
-# Production: docker compose --profile production up
-# Testing: docker compose --profile testing up
-#
-# Documentation: https://docs.docker.com/compose/compose-file/
-# ==============================================================================
-
-# Modern Compose syntax (no version needed for Compose v2+)
-# Ref: https://docs.docker.com/compose/compose-file/04-version-and-name/
-name: ${APP_NAME:-kariricode-devkit}
-
-services:
- # ============================================================================
- # PHP-FPM + Nginx + Redis Service (kariricode/php-api-stack)
- # ============================================================================
- php:
- image: kariricode/php-api-stack:${PHP_STACK_VERSION:-dev}
- container_name: ${APP_NAME:-kariricode-devkit}_php
- hostname: php-app
-
- # Restart policy for production stability
- restart: unless-stopped
-
- # Working directory
- working_dir: /var/www/html
-
- # User and group for running the application (security best practice)
- # NOTE: Commented out - image runs as root internally but processes run as www-data
- # user: "${UID:-1000}:${GID:-1000}"
-
- # Resource limits (prevent runaway processes)
- deploy:
- resources:
- limits:
- cpus: "${PHP_CPU_LIMIT:-2.0}"
- memory: ${PHP_MEMORY_LIMIT:-2G}
- reservations:
- cpus: "${PHP_CPU_RESERVATION:-0.5}"
- memory: ${PHP_MEMORY_RESERVATION:-512M}
-
- # Port mappings
- ports:
- - target: 80
- published: ${APP_PORT:-8089}
- protocol: tcp
- mode: host
- - target: 6379
- published: ${REDIS_PORT:-6379}
- protocol: tcp
- mode: host
-
- env_file:
- - .env
- # Volume mounts
- volumes:
- # Application code (delegated for macOS performance)
- - type: bind
- source: ./
- target: /var/www/html
- consistency: ${VOLUME_CONSISTENCY:-cached}
-
- # Named volumes for cache (performance optimization)
- - composer-cache:/root/.composer/cache
- - phpstan-cache:/var/www/html/var/cache/phpstan
- - php-session:/var/lib/php/sessions
-
- # Temporary filesystem for uploads (security + performance)
- - type: tmpfs
- target: /tmp
- tmpfs:
- size: ${TMPFS_SIZE:-100M}
- mode: 1777
-
- # # Environment variables
- # environment:
- # # Application
- # APP_ENV: ${APP_ENV:-development}
- # APP_DEBUG: ${APP_DEBUG:-true}
- # APP_SECRET: ${APP_SECRET:-change-me-in-production}
- # APP_VERSION: ${APP_VERSION:-dev}
- # SYMFONY_ENV: ${SYMFONY_ENV:-dev}
-
- # # Session Handler (CRITICAL)
- # SESSION_SAVE_HANDLER: ${SESSION_SAVE_HANDLER:-files}
- # SESSION_SAVE_PATH: ${SESSION_SAVE_PATH:-/tmp}
-
- # # Redis Configuration (Internal - inside container)
- # REDIS_HOST: ${REDIS_HOST:-127.0.0.1}
- # REDIS_PORT: ${REDIS_PORT_INTERNAL:-6379}
- # REDIS_PASSWORD: ${REDIS_PASSWORD:-}
- # REDIS_DB: ${REDIS_DB:-0}
- # REDIS_TIMEOUT: ${REDIS_TIMEOUT:-5}
-
- # # Redis Server Configuration
- # REDIS_LOGFILE: ${REDIS_LOGFILE:-/var/log/redis/redis.log}
- # REDIS_LOGLEVEL: ${REDIS_LOGLEVEL:-notice}
- # REDIS_MAXMEMORY: ${REDIS_MAXMEMORY:-256mb}
- # REDIS_MAXMEMORY_POLICY: ${REDIS_MAXMEMORY_POLICY:-allkeys-lru}
- # REDIS_SAVE: ${REDIS_SAVE:-}
- # REDIS_APPENDONLY: ${REDIS_APPENDONLY:-no}
- # REDIS_LOG_FILE: ${REDIS_LOG_FILE:-/var/log/redis/redis.log}
-
- # # Memcached Configuration (External service)
- # MEMCACHED_HOST: memcached
- # MEMCACHED_PORT: 11211
-
- # # PHP Configuration
- # PHP_MEMORY_LIMIT: ${PHP_MEMORY_LIMIT:-2G}
- # PHP_MAX_EXECUTION_TIME: ${PHP_MAX_EXECUTION_TIME:-300}
- # PHP_UPLOAD_MAX_FILESIZE: ${PHP_UPLOAD_MAX_FILESIZE:-50M}
- # PHP_POST_MAX_SIZE: ${PHP_POST_MAX_SIZE:-50M}
-
- # # PHP-FPM Configuration
- # PHP_FPM_PM: ${PHP_FPM_PM:-dynamic}
- # PHP_FPM_PM_MAX_CHILDREN: ${PHP_FPM_PM_MAX_CHILDREN:-50}
- # PHP_FPM_PM_START_SERVERS: ${PHP_FPM_PM_START_SERVERS:-5}
- # PHP_FPM_PM_MIN_SPARE_SERVERS: ${PHP_FPM_PM_MIN_SPARE_SERVERS:-5}
- # PHP_FPM_PM_MAX_SPARE_SERVERS: ${PHP_FPM_PM_MAX_SPARE_SERVERS:-10}
- # PHP_FPM_PM_MAX_REQUESTS: ${PHP_FPM_PM_MAX_REQUESTS:-500}
-
- # # Nginx Configuration
- # NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
- # NGINX_WORKER_CONNECTIONS: ${NGINX_WORKER_CONNECTIONS:-1024}
- # NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
- # NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
-
- # # Composer
- # COMPOSER_MEMORY_LIMIT: ${COMPOSER_MEMORY_LIMIT:--1}
- # COMPOSER_HOME: ${COMPOSER_HOME:-/root/.composer}
-
- # # Xdebug
- # XDEBUG_MODE: ${XDEBUG_MODE:-off}
- # XDEBUG_CLIENT_HOST: ${XDEBUG_CLIENT_HOST:-host.docker.internal}
- # XDEBUG_CLIENT_PORT: ${XDEBUG_CLIENT_PORT:-9003}
- # XDEBUG_SESSION: ${XDEBUG_SESSION:-PHPSTORM}
-
- # # OPcache
- # OPCACHE_ENABLE: ${OPCACHE_ENABLE:-1}
- # OPCACHE_VALIDATE_TIMESTAMPS: ${OPCACHE_VALIDATE_TIMESTAMPS:-1}
- # OPCACHE_REVALIDATE_FREQ: ${OPCACHE_REVALIDATE_FREQ:-2}
-
- # # Features
- # DEMO_MODE: ${DEMO_MODE:-false}
- # HEALTH_CHECK_INSTALL: ${HEALTH_CHECK_INSTALL:-true}
-
- # # Timezone
- # TZ: ${TZ:-UTC}
-
- # # Logging
- # LOG_LEVEL: ${LOG_LEVEL:-info}
-
- # Network configuration
- networks:
- kariricode_network:
- aliases:
- - php-service
- - api-server
-
- # DNS configuration
- dns:
- - 8.8.8.8
- - 8.8.4.4
-
- # Extra hosts for host machine communication
- extra_hosts:
- - "host.docker.internal:host-gateway"
-
- # Health check
- healthcheck:
- test: ["CMD-SHELL", "curl -f http://localhost/health || exit 1"]
- interval: ${HEALTHCHECK_INTERVAL:-30s}
- timeout: ${HEALTHCHECK_TIMEOUT:-10s}
- retries: ${HEALTHCHECK_RETRIES:-3}
- start_period: ${HEALTHCHECK_START_PERIOD:-40s}
-
- # Logging configuration
- logging:
- driver: "json-file"
- options:
- max-size: "${LOG_MAX_SIZE:-10m}"
- max-file: "${LOG_MAX_FILE:-3}"
- compress: "true"
- labels: "service,environment"
-
- # Labels for organization and monitoring
- labels:
- com.kariricode.service: "php-api"
- com.kariricode.environment: "${APP_ENV:-development}"
- com.kariricode.version: "${APP_VERSION:-dev}"
- com.kariricode.description: "PHP-FPM + Nginx + Redis service (kariricode/php-api-stack)"
- com.kariricode.maintainer: "devops@kariricode.org"
-
- # Dependencies with health check conditions
- depends_on:
- memcached:
- condition: service_healthy
- restart: true
-
- # Security options
- security_opt:
- - no-new-privileges:true
-
- # Capability management (drop all, add only needed)
- cap_drop:
- - ALL
- cap_add:
- - CHOWN
- - SETGID
- - SETUID
- - NET_BIND_SERVICE
- - DAC_OVERRIDE
-
- # Profiles for different environments
- profiles:
- - development
- - production
- - testing
-
- # ============================================================================
- # Memcached Service
- # ============================================================================
- memcached:
- image: memcached:${MEMCACHED_VERSION:-1.6-alpine}
- container_name: ${APP_NAME:-kariricode-devkit}_memcached
- hostname: memcached-cache
-
- # Restart policy
- restart: unless-stopped
-
- # Command with tuned parameters
- command: >
- memcached
- -m ${MEMCACHED_MEMORY:-256}
- -c ${MEMCACHED_MAX_CONNECTIONS:-1024}
- -t ${MEMCACHED_THREADS:-4}
- -I ${MEMCACHED_MAX_ITEM_SIZE:-5m}
- -v
-
- # Resource limits
- deploy:
- resources:
- limits:
- cpus: "${MEMCACHED_CPU_LIMIT:-1.0}"
- memory: ${MEMCACHED_MEMORY_TOTAL:-512M}
- reservations:
- cpus: "${MEMCACHED_CPU_RESERVATION:-0.25}"
- memory: ${MEMCACHED_MEMORY_RESERVATION:-256M}
-
- # Port mapping
- ports:
- - target: 11211
- published: ${MEMCACHED_PORT:-11211}
- protocol: tcp
- mode: host
-
- # Network
- networks:
- kariricode_network:
- aliases:
- - cache-service
-
- # Health check with proper validation
- healthcheck:
- test: ["CMD", "nc", "-z", "127.0.0.1", "11211"]
- interval: ${MEMCACHED_HEALTHCHECK_INTERVAL:-10s}
- timeout: ${MEMCACHED_HEALTHCHECK_TIMEOUT:-5s}
- retries: ${MEMCACHED_HEALTHCHECK_RETRIES:-3}
- start_period: ${MEMCACHED_HEALTHCHECK_START_PERIOD:-10s}
-
- # Logging
- logging:
- driver: "json-file"
- options:
- max-size: "${LOG_MAX_SIZE:-10m}"
- max-file: "${LOG_MAX_FILE:-3}"
- compress: "true"
-
- # Labels
- labels:
- com.kariricode.service: "memcached"
- com.kariricode.environment: "${APP_ENV:-development}"
- com.kariricode.description: "Memcached caching service"
- com.kariricode.maintainer: "devops@kariricode.org"
-
- # Security
- security_opt:
- - no-new-privileges:true
- cap_drop:
- - ALL
- cap_add:
- - SETGID
- - SETUID
-
- # User (non-root)
- user: "memcache"
-
- # Read-only root filesystem (security hardening)
- read_only: true
- tmpfs:
- - /tmp:size=50M,mode=1777
-
- # Profiles
- profiles:
- - development
- - production
- - testing
-
-# ==============================================================================
-# Networks
-# ==============================================================================
-networks:
- kariricode_network:
- driver: bridge
- name: ${APP_NAME:-kariricode-devkit}_network
-
- # Enable IPv6 if needed
- enable_ipv6: ${ENABLE_IPV6:-false}
-
- # Network configuration
- driver_opts:
- com.docker.network.bridge.name: ${BRIDGE_NAME:-kariricode0}
- com.docker.network.bridge.enable_icc: "true"
- com.docker.network.bridge.enable_ip_masquerade: "true"
- com.docker.network.driver.mtu: ${NETWORK_MTU:-1500}
-
- # IPAM configuration
- ipam:
- driver: default
- config:
- - subnet: ${NETWORK_SUBNET:-172.20.0.0/16}
- gateway: ${NETWORK_GATEWAY:-172.20.0.1}
-
- # Labels
- labels:
- com.kariricode.network: "main"
- com.kariricode.environment: "${APP_ENV:-development}"
-
-# ==============================================================================
-# Volumes
-# ==============================================================================
-volumes:
- # Composer cache for faster dependency installation
- composer-cache:
- name: ${APP_NAME:-kariricode-devkit}_composer_cache
- driver: local
- labels:
- com.kariricode.volume: "composer-cache"
- com.kariricode.description: "Composer dependencies cache"
-
- # PHPStan cache for faster static analysis
- phpstan-cache:
- name: ${APP_NAME:-kariricode-devkit}_phpstan_cache
- driver: local
- labels:
- com.kariricode.volume: "phpstan-cache"
- com.kariricode.description: "PHPStan analysis cache"
-
- # PHP session storage
- php-session:
- name: ${APP_NAME:-kariricode-devkit}_php_session
- driver: local
- labels:
- com.kariricode.volume: "php-session"
- com.kariricode.description: "PHP session data"
-
-# ==============================================================================
-# Extension Fields (DRY - reusable configurations)
-# ==============================================================================
-x-logging: &default-logging
- driver: "json-file"
- options:
- max-size: "10m"
- max-file: "3"
- compress: "true"
-
-x-healthcheck-defaults: &healthcheck-defaults
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 40s
-
-x-security-defaults: &security-defaults
- security_opt:
- - no-new-privileges:true
- cap_drop:
- - ALL
-# ==============================================================================
-# Profiles Documentation
-# ==============================================================================
-#
-# Available profiles:
-# - development: Full dev stack with Xdebug and verbose logging
-# - production: Optimized for production with minimal logging
-# - testing: Isolated testing environment
-#
-# Usage examples:
-# make up-safe # Development (recommended)
-# docker compose --profile development up
-# docker compose --profile production up -d
-# docker compose --profile testing run php vendor/bin/phpunit
-#
-# Troubleshooting:
-# make diagnose-ports # Check port conflicts
-# make status # Service status
-# make logs SERVICE=php # View logs
-# make shell # Enter PHP container
-#
-# ==============================================================================
diff --git a/docs/BUILDING.md b/docs/BUILDING.md
new file mode 100644
index 0000000..c150e12
--- /dev/null
+++ b/docs/BUILDING.md
@@ -0,0 +1,185 @@
+# Building kcode.phar
+
+This document covers how to compile `kcode.phar` from source, verify the artifact, and automate releases.
+
+## Prerequisites
+
+| Requirement | Minimum Version | Purpose |
+|---|---|---|
+| PHP | 8.4+ | Runtime and PHAR compilation |
+| Composer | 2.x | Dependency installation |
+
+### PHP Configuration
+
+PHAR compilation requires `phar.readonly=0`. The Makefile and `bin/build-phar.php` pass this automatically. For manual builds:
+
+```bash
+php -d phar.readonly=0 bin/build-phar.php
+```
+
+Alternatively, set it in `php.ini`:
+
+```ini
+phar.readonly = Off
+```
+
+## Building
+
+### Via Makefile (recommended)
+
+```bash
+# Full release pipeline: quality checks → build → verify
+make release
+
+# Or step by step:
+make install # Install dependencies
+make build # Compile kcode.phar
+make verify # Verify integrity
+make self-test # Run against this project
+```
+
+### Manual Build
+
+```bash
+# 1. Install dependencies (dev tools are bundled in the PHAR)
+composer install --no-interaction --prefer-dist --optimize-autoloader --no-scripts
+
+# 2. Compile PHAR using the native builder
+php -d phar.readonly=0 bin/build-phar.php
+
+# 3. Verify
+php build/kcode.phar --version
+php build/kcode.phar --help
+```
+
+## PHAR Builder — `bin/build-phar.php`
+
+The project uses a native PHP PHAR builder (`bin/build-phar.php`) instead of `humbug/box`.
+
+**Why:** Box 4.x has a known compatibility issue with PHP 8.4 (`chdir(): Not a directory (errno 20)` during `endBuffering()`). The native builder avoids this bug entirely with no external dependency.
+
+**What it does:**
+1. Collects all `src/` PHP files (38 files)
+2. Collects `vendor/` PHP + JSON files, excluding test/doc directories
+3. Adds `LICENSE`
+4. Sets `bin/kcode` as the entry-point stub
+5. GZ-compresses the archive
+6. Sets permissions to `0755`
+
+```bash
+# Output
+📦 Building kcode.phar...
+ + src/: 38 PHP files
+ + vendor/: files
+ + LICENSE
+ + stub (bin/kcode entry point)
+
+✅ Built: build/kcode.phar (X.XX MB)
+ Files:
+```
+
+## Build Output
+
+```
+build/
+└── kcode.phar # GZ compressed PHAR
+```
+
+The PHAR includes:
+
+| Content | Source |
+|---|---|
+| Devkit source | `src/` (38 PHP files) |
+| Entry point | `bin/kcode` |
+| PHPUnit | `vendor/phpunit/` + transitive deps |
+| PHPStan | `vendor/phpstan/` + transitive deps |
+| PHP-CS-Fixer | `vendor/friendsofphp/` + transitive deps |
+| Rector | `vendor/rector/` + transitive deps |
+| Psalm | `vendor/vimeo/` + transitive deps |
+| Autoloader | `vendor/autoload.php` + `vendor/composer/` |
+| License | `LICENSE` |
+
+## Verification
+
+After building, verify the PHAR works correctly:
+
+```bash
+# Version check
+php build/kcode.phar --version
+# → KaririCode Devkit 1.0.0
+
+# Help output
+php build/kcode.phar --help
+# → Shows all 10 commands
+
+# Self-test against a real project
+cd /path/to/kariricode-component
+php /path/to/kcode.phar init
+php /path/to/kcode.phar quality
+```
+
+### PHAR Signature
+
+```bash
+php -r "echo (new Phar('build/kcode.phar'))->getSignature()['hash'];"
+```
+
+## Distribution
+
+### GitHub Releases
+
+The recommended distribution method. See `.github/workflows/release.yml`:
+
+1. Tag a release: `git tag v1.0.0 && git push --tags`
+2. CI compiles the PHAR and attaches it to the GitHub release.
+3. Users download via:
+
+```bash
+wget https://github.com/kariricode/devkit/releases/latest/download/kcode.phar
+chmod +x kcode.phar
+sudo mv kcode.phar /usr/local/bin/kcode
+```
+
+### Self-Update (Future)
+
+A `kcode self-update` command is planned for v1.1 to download the latest PHAR from GitHub releases.
+
+## Troubleshooting
+
+### `phar.readonly = On`
+
+```
+Creating a phar archive is disabled by the php.ini setting phar.readonly
+```
+
+**Fix:** Pass `-d phar.readonly=0` to PHP or set `phar.readonly = Off` in php.ini.
+
+### PHAR too large
+
+If the PHAR exceeds 30 MB:
+
+1. Check that GZ compression is enabled in `bin/build-phar.php` (`Phar::GZ`).
+2. Verify test/doc directories are excluded by the builder's `$excludeDirs` list.
+
+### Binary not found inside PHAR
+
+If `kcode.phar test` reports "Binary not found for phpunit":
+
+1. Verify dependencies were installed before building: `composer install`
+2. Check that `vendor/bin/phpunit` exists before compilation.
+
+### Platform-specific issues
+
+The PHAR uses `#!/usr/bin/env php` as the shebang. On systems where PHP is not in PATH:
+
+```bash
+php kcode.phar quality # Explicit PHP invocation
+```
+
+## Version Bumping
+
+The version is stored in one place:
+
+1. `src/Core/Devkit.php` → `private const string VERSION = '1.0.0';`
+
+The Makefile resolves the version via `git describe --tags --abbrev=0`, falling back to `'dev'`. Always tag releases with `git tag vX.Y.Z` before running `make release`.
diff --git a/docs/INDEX.md b/docs/INDEX.md
new file mode 100644
index 0000000..aa90182
--- /dev/null
+++ b/docs/INDEX.md
@@ -0,0 +1,37 @@
+# KaririCode Devkit — Documentation Index
+
+Documentation for the **kariricode/devkit** package — the unified quality toolchain for the KaririCode Framework ecosystem.
+
+---
+
+## Architecture Decision Records
+
+| ADR | Title | Status |
+|---|---|---|
+| [ADR-001](adr/ADR-001-phar-distribution.md) | PHAR-Based Distribution Strategy | Accepted |
+| [ADR-002](adr/ADR-002-zero-dependencies.md) | Zero External Dependencies in Core | Accepted |
+| [ADR-003](adr/ADR-003-config-generation.md) | Configuration Generation Over Manual Configuration | Accepted |
+| [ADR-004](adr/ADR-004-binary-resolution.md) | Three-Tier Binary Resolution Strategy | Accepted |
+| [ADR-005](adr/ADR-005-kariricode-directory.md) | Centralized `.kcode/` Directory Convention | Accepted |
+| [ADR-006](adr/ADR-006-immutable-value-objects.md) | Immutable Value Objects for Tool Results | Accepted |
+
+## Specifications
+
+| Spec | Title | Version |
+|---|---|---|
+| [SPEC-001](spec/SPEC-001-project-detection.md) | Project Detection and Configuration Merging | 1.0.0 |
+| [SPEC-002](spec/SPEC-002-cli-interface.md) | CLI Command Interface and Execution Pipeline | 1.0.0 |
+| [SPEC-003](spec/SPEC-003-tool-runner.md) | Tool Runner Abstraction and Process Execution | 1.0.0 |
+
+## Quick Navigation
+
+| Document | Description |
+|---|---|
+| [README](../README.md) | Installation, usage, CLI reference, architecture |
+| [BUILDING.md](BUILDING.md) | PHAR compilation, troubleshooting, release automation |
+| [CHANGELOG](../CHANGELOG.md) | Release history and migration notes |
+| [composer.json](../composer.json) | Package definition and Composer scripts |
+| [Makefile](../Makefile) | Build automation (`make help` for all targets) |
+| [ci.yml](../.github/workflows/ci.yml) | Quality pipeline on push/PR |
+| [code-quality.yml](../.github/workflows/code-quality.yml) | Security, PHPStan, CS-Fixer via kcode CLI |
+| [release.yml](../.github/workflows/release.yml) | Automated PHAR release on tag push |
diff --git a/docs/adr/ADR-001-phar-distribution.md b/docs/adr/ADR-001-phar-distribution.md
new file mode 100644
index 0000000..bf63102
--- /dev/null
+++ b/docs/adr/ADR-001-phar-distribution.md
@@ -0,0 +1,76 @@
+# ADR-001: PHAR-Based Distribution Strategy
+
+**Status:** Accepted
+**Date:** 2025-02-28
+**Deciders:** Walmir Silva
+**Context:** KaririCode Framework Devkit v1.0.0
+
+## Context
+
+The KaririCode Framework comprises 35+ components, each requiring identical development tooling: PHPUnit, PHPStan, PHP-CS-Fixer, Rector, and Psalm. The conventional Composer `require-dev` approach results in:
+
+- **175+ redundant dependency entries** across the ecosystem (5 tools × 35+ components).
+- **~120 MB per component** in `vendor/` from dev dependencies alone.
+- **Inconsistent tool versions** when components are updated at different cadences.
+- **5+ config files per project root** (phpunit.xml.dist, phpstan.neon, etc.) with near-identical content.
+
+## Decision
+
+Distribute the devkit as a **PHAR archive** compiled via [humbug/box](https://github.com/box-project/box), bundling all five quality tools and their transitive dependencies into a single `kcode.phar` file (~15-20 MB).
+
+Additionally, support **Composer library mode** (`composer require --dev kariricode/devkit`) for environments where PHAR is impractical (e.g., CI caches, Dependabot).
+
+## Rationale
+
+### PHAR Advantages
+
+1. **Single artifact** — One file replaces 5 `require-dev` entries and all their transitive dependencies.
+2. **Version pinning** — The PHAR freezes exact tool versions. Every component uses the same PHPStan 2.x, same Rector 2.x, etc.
+3. **Zero-conflict installation** — PHAR runs in an isolated class-loading context. No dependency conflicts between the project's production code and the analysis tools.
+4. **Portable** — A CI pipeline can `wget` the PHAR and run it without `composer install` for dev dependencies.
+
+### Composer Library Fallback
+
+The `bin/kcode` entry point resolves autoloaders in priority order:
+
+```
+1. PHAR-internal vendor/autoload.php
+2. Project-local vendor/autoload.php (Composer require-dev)
+3. Global Composer vendor/autoload.php
+```
+
+This makes the package usable in both distribution modes without code changes.
+
+### Alternatives Considered
+
+| Alternative | Rejected Because |
+|---|---|
+| Composer plugin | Couples to Composer lifecycle; does not solve version consistency |
+| Docker image | Adds container overhead; poor IDE integration |
+| Makefile + global tools | No version pinning; system-dependent |
+| Mono-repo shared config | Doesn't scale to independently versioned components |
+
+## Consequences
+
+### Positive
+
+- Component `composer.json` shrinks from 5+ dev dependencies to 1 (or 0 with PHAR).
+- Tool version upgrades happen once in devkit, propagate to all components on next PHAR release.
+- CI pipelines download one artifact instead of resolving 5 dependency trees.
+
+### Negative
+
+- PHAR compilation requires `humbug/box` and a release pipeline.
+- PHAR file size (~15-20 MB) must be managed; GZ compression mitigates this.
+- Developers must update the PHAR when tool versions change (mitigated by Composer fallback).
+
+### Risks
+
+- **Tool compatibility** — A bundled tool version may conflict with a component's minimum PHP version. Mitigated by targeting PHP 8.4+ across the ecosystem.
+- **PHAR readonly** — PHP `phar.readonly=1` (default) prevents runtime modification. This is acceptable since the PHAR is read-only by design.
+
+## References
+
+- [humbug/box documentation](https://github.com/box-project/box/blob/main/doc/configuration.md)
+- [PHP PHAR extension](https://www.php.net/manual/en/book.phar.php)
+- [Composer bin-dir specification](https://getcomposer.org/doc/articles/vendor-binaries.md)
diff --git a/docs/adr/ADR-002-zero-dependencies.md b/docs/adr/ADR-002-zero-dependencies.md
new file mode 100644
index 0000000..dc7245a
--- /dev/null
+++ b/docs/adr/ADR-002-zero-dependencies.md
@@ -0,0 +1,86 @@
+# ADR-002: Zero External Dependencies in Core
+
+**Status:** Accepted
+**Date:** 2025-02-28
+**Deciders:** Walmir Silva
+**Context:** KaririCode Framework Devkit v1.0.0
+
+## Context
+
+The devkit's `src/` layer (Contract, Core, Command, Runner, Configuration, Exception, ValueObject) needs a CLI framework, process execution, and config generation. Standard PHP ecosystem choices include Symfony Console (~50+ classes), League CLImate, or Laravel Artisan.
+
+KaririCode Framework follows a **zero external dependencies** principle (ARFA 1.3, §2.1) for all framework components.
+
+## Decision
+
+The devkit core (`src/`) has **zero external `require` dependencies**. The only PHP requirement is `>=8.4`.
+
+All infrastructure is implemented in-house:
+
+| Capability | Implementation | Lines |
+|---|---|---|
+| CLI router | `Command\Application` | 118 |
+| Argument parsing | `Command\AbstractCommand` | 113 |
+| Process execution | `Core\ProcessExecutor` | 109 |
+| Config file loading | `Core\DevkitConfig` | 112 |
+| ANSI output formatting | Inline escape sequences | ~20 |
+
+The five quality tools (PHPUnit, PHPStan, etc.) are `require-dev` dependencies — they are not imported at the PHP level. The devkit spawns them as subprocesses via `proc_open()`.
+
+## Rationale
+
+### Why Not Symfony Console
+
+Symfony Console is excellent but introduces:
+
+- **~50 classes** and their autoloading overhead into the PHAR.
+- **Transitive dependencies** (symfony/string, symfony/deprecation-contracts, etc.).
+- **PHAR namespace conflicts** with projects that use different Symfony versions.
+- **Conceptual overhead** — the devkit has exactly 9 commands with simple flag parsing. A full command framework is over-engineered.
+
+### What 231 Lines Buys
+
+`Application` (118) + `AbstractCommand` (113) = 231 lines that provide:
+
+- Named command dispatch with `--help` and `--version`.
+- ANSI-colored output (info, warning, error, banner).
+- Flag detection (`--coverage`, `--check`).
+- Key-value option extraction (`--suite=Unit`).
+- Passthrough argument filtering.
+
+This covers 100% of the devkit's CLI needs with no runtime dependencies and sub-millisecond boot time.
+
+### Process Execution via proc_open
+
+Tools are spawned as child processes, not loaded as PHP libraries. This provides:
+
+- **Isolation** — Tool crashes don't bring down the devkit process.
+- **Output capture** — stdout and stderr are captured separately into `ToolResult`.
+- **Timing** — `hrtime(true)` captures nanosecond-precision execution time.
+- **Exit code semantics** — Standard Unix exit codes propagate through `ToolResult::$exitCode`.
+
+## Consequences
+
+### Positive
+
+- PHAR boot time < 1ms (no autoloader for framework classes beyond the devkit itself).
+- Zero risk of dependency conflicts with host projects.
+- Full control over CLI behavior — no upstream breaking changes.
+- Entire CLI layer is readable in ~15 minutes.
+
+### Negative
+
+- No built-in features like table rendering, progress bars, or interactive prompts.
+- Argument parsing is less sophisticated than Symfony Console's InputDefinition.
+- Maintenance burden for CLI infrastructure falls on the project.
+
+### Acceptable Trade-offs
+
+- Table rendering is unnecessary — the devkit outputs tool stdout directly.
+- Progress bars are unnecessary — tool progress is handled by the tools themselves.
+- Interactive prompts are unnecessary — the devkit is designed for CI and scripted use.
+
+## References
+
+- ARFA 1.3 Specification, §2.1: Zero External Dependencies Principle
+- KaririCode Specification V4.0, §3.2: Package Independence
diff --git a/docs/adr/ADR-003-config-generation.md b/docs/adr/ADR-003-config-generation.md
new file mode 100644
index 0000000..8f98117
--- /dev/null
+++ b/docs/adr/ADR-003-config-generation.md
@@ -0,0 +1,88 @@
+# ADR-003: Configuration Generation Over Manual Configuration
+
+**Status:** Accepted
+**Date:** 2025-02-28
+**Deciders:** Walmir Silva
+**Context:** KaririCode Framework Devkit v1.0.0
+
+## Context
+
+Each quality tool requires a configuration file (phpunit.xml.dist, phpstan.neon, php-cs-fixer.php, rector.php, psalm.xml). Across 35+ components, these files are near-identical, differing only in:
+
+- Source directory paths (derived from PSR-4 autoload)
+- Test suite directory structure
+- PHP version target
+- PHPStan analysis level
+
+Maintaining 175+ config files manually leads to configuration drift, inconsistent rules, and merge-conflict friction when updating standards.
+
+## Decision
+
+Config files are **generated deterministically** from a `ProjectContext` snapshot by `ConfigGenerator` implementations. The generation cycle is:
+
+```
+composer.json → ProjectDetector → ProjectContext → ConfigGenerator → .kcode/*.config
+```
+
+Manual customization is achieved through a single override file (`devkit.php`) that is **merged** with ecosystem defaults — not by editing generated configs.
+
+Generated files include a header comment: `Generated by KaririCode Devkit — override via devkit.php (project root)`.
+
+## Rationale
+
+### Generation Advantages
+
+1. **Single source of truth** — Coding standards and analysis rules live in `ProjectDetector::DEFAULT_CS_RULES` and `DEFAULT_RECTOR_SETS`. Updating the devkit updates all components.
+2. **Deterministic output** — Same `ProjectContext` always produces the same config files. No hidden state, no manual edits to preserve.
+3. **Override granularity** — `devkit.php` supports per-project customization at the key level (PHPStan level, extra CS rules, excluded directories) without duplicating the entire config.
+4. **Safe regeneration** — `kcode init` can be run repeatedly. Generated files are disposable artifacts.
+
+### Override Merging Strategy
+
+```php
+// devkit.php — only specify what differs from defaults
+ 8, // override: lower level
+ 'exclude_dirs' => ['src/Contract', 'src/Legacy'], // override: extra exclusion
+ 'cs_fixer_rules' => ['yoda_style' => false], // merge: added to defaults
+];
+```
+
+The merge strategy varies by key type:
+
+| Key | Strategy | Rationale |
+|---|---|---|
+| Scalar (phpstan_level, php_version) | Replace | Project-specific requirement |
+| List (source_dirs, exclude_dirs) | Replace | Full override for clarity |
+| Map (cs_fixer_rules) | array_merge | Additive customization |
+| Map (test_suites) | Replace | Suite structure is project-specific |
+
+### Alternatives Considered
+
+| Alternative | Rejected Because |
+|---|---|
+| Symlinks to shared configs | Doesn't support per-project overrides |
+| Composer scripts + templates | Requires Twig/Blade; adds dependencies |
+| .dist files with manual copy | Config drift returns immediately |
+| Central config repo + git submodule | Poor developer experience; merge conflicts |
+
+## Consequences
+
+### Positive
+
+- Zero config drift across the ecosystem.
+- One-command setup for new components: `kcode init`.
+- Override file is version-controlled alongside the project.
+- Generated configs are gitignored-friendly (optional).
+
+### Negative
+
+- Developers cannot hand-edit generated configs (edits are overwritten on next `init`).
+- The override merge logic must be well-documented to avoid confusion.
+- Adding new config keys requires a devkit release.
+
+## References
+
+- Microsoft documentation generators: config-as-code principle
+- Terraform HCL: override files merged with base configuration
+- ARFA 1.3 Specification, §4.3: Deterministic Configuration
diff --git a/docs/adr/ADR-004-binary-resolution.md b/docs/adr/ADR-004-binary-resolution.md
new file mode 100644
index 0000000..cd95b17
--- /dev/null
+++ b/docs/adr/ADR-004-binary-resolution.md
@@ -0,0 +1,77 @@
+# ADR-004: Three-Tier Binary Resolution Strategy
+
+**Status:** Accepted
+**Date:** 2025-02-28
+**Deciders:** Walmir Silva
+**Context:** KaririCode Framework Devkit v1.0.0
+
+## Context
+
+The devkit must locate tool binaries (phpunit, phpstan, php-cs-fixer, rector, psalm, composer) at runtime. The installation context varies:
+
+1. **PHAR distribution** — Tools are bundled inside the archive.
+2. **Composer dependency** — Tools are in the project's `vendor/bin/`.
+3. **Global installation** — Tools are installed system-wide via `composer global require` or package managers.
+
+A single resolution strategy cannot serve all contexts.
+
+## Decision
+
+`ProcessExecutor::resolveBinary()` implements a three-tier cascade:
+
+```
+Tier 1: PHAR-internal → Phar::running(true) . '/' . $vendorBin
+Tier 2: Project-local → $workingDirectory . '/' . $vendorBin
+Tier 3: Global PATH → shell_exec('command -v $basename')
+```
+
+Resolution stops at the first match. If no tier resolves, `null` is returned and `AbstractToolRunner::run()` produces a `ToolResult` with exit code 127.
+
+### Exception: ComposerAuditRunner
+
+Composer is typically installed globally, not in `vendor/bin/`. `ComposerAuditRunner` overrides `binary()` to check global PATH first (Tier 3 before Tier 2).
+
+## Rationale
+
+### Tier Priority
+
+| Tier | Context | Priority Justification |
+|---|---|---|
+| 1. PHAR-internal | Self-contained distribution | Guarantees exact version match |
+| 2. Project vendor | Composer require-dev | Respects project-specific version constraints |
+| 3. Global PATH | System-wide tools | Fallback for minimal setups |
+
+### Why Not Rely on PATH Alone
+
+PATH resolution provides no version guarantees. A globally installed PHPStan 1.x would be used even if the devkit expects 2.x. PHAR-internal binaries eliminate this risk entirely.
+
+### Binary Caching
+
+`AbstractToolRunner` caches the resolved binary path in `$resolvedBinary` (null-coalescing assignment). Resolution happens once per runner instance per process. Since the CLI is short-lived (single command execution), this is sufficient without cache invalidation.
+
+### Security
+
+Tier 3 uses `escapeshellarg()` around the binary basename to prevent shell injection:
+
+```php
+shell_exec('command -v ' . \escapeshellarg($basename) . ' 2>/dev/null')
+```
+
+## Consequences
+
+### Positive
+
+- PHAR users get deterministic tool versions with zero configuration.
+- Composer users can override tool versions via their own `require-dev`.
+- Global fallback enables `kcode` usage in minimal CI environments.
+
+### Negative
+
+- Tier precedence may surprise developers who expect their global tool to be used over the PHAR-bundled version.
+- `command -v` is POSIX-specific; Windows support would require `where.exe` (out of scope for v1.0).
+
+## References
+
+- POSIX `command -v` specification: IEEE Std 1003.1-2017
+- PHP `Phar::running()` documentation
+- Composer `vendor/bin` binary proxy specification
diff --git a/docs/adr/ADR-005-kariricode-directory.md b/docs/adr/ADR-005-kariricode-directory.md
new file mode 100644
index 0000000..1c8ef1e
--- /dev/null
+++ b/docs/adr/ADR-005-kariricode-directory.md
@@ -0,0 +1,153 @@
+# ADR-005: Centralized .kcode/ Directory Convention
+
+**Status:** Accepted
+**Date:** 2025-02-28
+**Deciders:** Walmir Silva
+**Context:** KaririCode Framework Devkit v1.0.0
+
+## Context
+
+Quality tools traditionally place their config files in the project root:
+
+```
+project/
+├── phpunit.xml.dist
+├── phpstan.neon
+├── .php-cs-fixer.dist.php
+├── rector.php
+├── psalm.xml
+├── composer.json
+├── README.md
+└── src/
+```
+
+Five config files in the root creates visual noise and pushes domain-relevant files below the fold in directory listings. Each file also requires a `.gitignore` entry for its cache directory.
+
+## Decision
+
+All devkit-generated configs and build artifacts are placed inside a single `.kcode/` directory:
+
+```
+project/
+├── devkit.php # Optional: project-specific overrides (committed to git)
+├── .kcode/ # Gitignored — regenerated by `kcode init`
+│ ├── phpunit.xml.dist # Generated
+│ ├── phpstan.neon # Generated
+│ ├── php-cs-fixer.php # Generated
+│ ├── rector.php # Generated
+│ ├── psalm.xml # Generated
+│ └── build/ # Caches, coverage, reports
+│ ├── .phpunit.cache/
+│ ├── .phpstan/
+│ ├── .php-cs-fixer.cache
+│ ├── .psalm/
+│ ├── coverage/
+│ ├── clover.xml
+│ └── junit.xml
+├── composer.json
+├── README.md
+└── src/
+```
+
+### Gitignore Strategy
+
+`kcode init` adds `.kcode/` to `.gitignore` automatically:
+
+```gitignore
+# KaririCode Devkit — generated configs and build artifacts
+.kcode/
+```
+
+The entire `.kcode/` directory is gitignored because:
+
+1. **Configs are deterministic** — generated from `composer.json` + `devkit.php`. Running `kcode init` on any machine produces identical output.
+2. **Zero CI friction** — CI already runs `kcode init` before quality checks. No stale config drift between branches.
+3. **Clean diffs** — config regeneration after devkit upgrades doesn't pollute PRs.
+
+If a project needs custom overrides, create `devkit.php` in the project root:
+
+```bash
+kcode init --config # Scaffolds devkit.php with all available keys documented
+```
+
+The `devkit.php` lives at the project root (not inside `.kcode/`) and is committed to git normally.
+
+## Rationale
+
+### Clean Project Root
+
+The project root now contains only domain-relevant files. The `.kcode/` directory is a single entry that collapses in file explorers and IDE project trees.
+
+### Convention Over Configuration
+
+The directory name `.kcode/` is:
+
+- **Dot-prefixed** — Hidden in Unix directory listings by default.
+- **Namespaced** — No collision with other tools (unlike `.config/` or `.tools/`).
+- **Discoverable** — Any KaririCode contributor recognizes the convention.
+
+### Relative Path Strategy
+
+Generated configs use `../` relative paths to reference project source:
+
+```xml
+
+../tests/Unit
+```
+
+```neon
+# .kcode/phpstan.neon
+paths:
+ - ../src
+```
+
+This works because tools are invoked with `--configuration .kcode/phpunit.xml.dist`, making the config file's directory the resolution base.
+
+### Build Isolation
+
+All tool caches and generated reports go to `.kcode/build/`:
+
+| Tool | Cache Location |
+|---|---|
+| PHPUnit | `.kcode/build/.phpunit.cache/` |
+| PHPStan | `.kcode/build/.phpstan/` |
+| PHP-CS-Fixer | `.kcode/build/.php-cs-fixer.cache` |
+| Psalm | `.kcode/build/.psalm/` |
+| Coverage HTML | `.kcode/build/coverage/` |
+| JUnit XML | `.kcode/build/junit.xml` |
+| Clover XML | `.kcode/build/clover.xml` |
+
+One `.gitignore` entry covers all build artifacts.
+
+## Consequences
+
+### Positive
+
+- Clean project root — 5 fewer files visible at the top level.
+- Single gitignore entry (`.kcode/`) covers all generated configs and build artifacts.
+- No config drift — regenerated deterministically from `composer.json` on every `kcode init`.
+- Clean PRs — devkit version upgrades don't pollute diffs with config changes.
+- Convention is immediately recognizable across the KaririCode ecosystem.
+
+### Negative
+
+- CI pipelines must run `kcode init` before any quality step. The standard CI workflow already includes this.
+- Tool commands must specify config paths explicitly (`--configuration .kcode/phpunit.xml.dist`). The devkit CLI handles this transparently, but raw tool invocation requires the path.
+- IDE integrations (PHPStorm PHPUnit runner) may need manual config path setup.
+
+### Migration
+
+Projects migrating from root-level configs:
+
+```bash
+composer require --dev kariricode/devkit
+vendor/bin/kcode init # Generates .kcode/, adds to .gitignore
+vendor/bin/kcode migrate # Interactive removal of old deps and configs
+```
+
+## References
+
+- Angular CLI: `.angular/` directory convention
+- Next.js: `.next/` build directory
+- Rust Cargo: `target/` build directory
+- ARFA 1.3 Specification, §5.1: Project Layout Conventions
diff --git a/docs/adr/ADR-006-immutable-value-objects.md b/docs/adr/ADR-006-immutable-value-objects.md
new file mode 100644
index 0000000..6118091
--- /dev/null
+++ b/docs/adr/ADR-006-immutable-value-objects.md
@@ -0,0 +1,102 @@
+# ADR-006: Immutable Value Objects for Tool Results
+
+**Status:** Accepted
+**Date:** 2025-02-28
+**Deciders:** Walmir Silva
+**Context:** KaririCode Framework Devkit v1.0.0
+
+## Context
+
+Tool execution produces structured output: exit code, stdout, stderr, elapsed time, and a pass/fail determination. This data flows from `ProcessExecutor` → `ToolRunner` → `Command` → user output, and is also aggregated in quality pipeline reports.
+
+The data must be reliable at every consumption point — no mutation between capture and display.
+
+## Decision
+
+Tool execution results are modeled as `final readonly class` value objects (PHP 8.2+):
+
+```php
+final readonly class ToolResult
+{
+ public bool $success;
+
+ public function __construct(
+ public string $toolName,
+ public int $exitCode,
+ public string $stdout,
+ public string $stderr,
+ public float $elapsedSeconds,
+ ) {
+ $this->success = 0 === $exitCode;
+ }
+}
+```
+
+```php
+final readonly class QualityReport
+{
+ public bool $passed;
+ public float $totalSeconds;
+ public int $failureCount;
+
+ public function __construct(
+ public array $results, // list
+ ) {
+ $this->passed = array_all($results, fn (ToolResult $r) => $r->success);
+ // ... aggregations computed in constructor
+ }
+}
+```
+
+`ProjectContext` follows the same pattern — `final readonly class` with all state computed at construction time.
+
+## Rationale
+
+### ARFA 1.3 Principle P1: Immutable State
+
+ARFA 1.3 mandates immutable state for data flowing through processing pipelines. Value objects satisfy this by construction:
+
+- `readonly` prevents property reassignment after construction.
+- `final` prevents subclassing that could introduce mutation.
+- Derived properties (`$success`, `$passed`, `$totalSeconds`) are computed once in the constructor.
+
+### Why Not DTOs with Getters
+
+KaririCode V4.0 Specification explicitly forbids the getter/setter anti-pattern. Public readonly properties are the canonical access pattern:
+
+```php
+// ✅ Direct property access
+$result->exitCode
+$result->success
+
+// ❌ Getter anti-pattern (V4.0 violation)
+$result->getExitCode()
+$result->isSuccess()
+```
+
+### Constructor-Computed Derived State
+
+`QualityReport::$passed` and `$failureCount` are derived from `$results`. Computing them in the constructor guarantees consistency — there is no window where the report exists but aggregations haven't been calculated.
+
+This follows the **complete construction** principle: an object is fully valid immediately after `new`.
+
+## Consequences
+
+### Positive
+
+- Thread-safe by construction (relevant for future async/parallel tool execution).
+- No defensive copying needed when passing results between layers.
+- IDE autocompletion works directly on public properties.
+- Memory-efficient — no getter overhead, no backing field duplication.
+
+### Negative
+
+- Cannot extend or decorate results without creating a new class.
+- `array_all()` requires PHP 8.4+ (acceptable given the framework's minimum version).
+
+## References
+
+- ARFA 1.3 Specification, §2.3: Immutable State Principle (P1)
+- KaririCode Specification V4.0, §4.1: No Getter/Setter Anti-pattern
+- Martin Fowler, *Value Object* pattern (https://martinfowler.com/bliki/ValueObject.html)
+- PHP RFC: Readonly classes (PHP 8.2)
diff --git a/docs/spec/SPEC-001-project-detection.md b/docs/spec/SPEC-001-project-detection.md
new file mode 100644
index 0000000..84b35c8
--- /dev/null
+++ b/docs/spec/SPEC-001-project-detection.md
@@ -0,0 +1,210 @@
+# SPEC-001: Project Detection and Configuration Merging
+
+**Version:** 1.0.0
+**Status:** Normative
+**Date:** 2025-02-28
+**Author:** Walmir Silva
+
+## 1. Purpose
+
+This specification defines how the devkit detects project structure, loads configuration overrides, and produces the `ProjectContext` snapshot consumed by all generators and runners.
+
+## 2. Scope
+
+Covers `ProjectDetector`, `DevkitConfig`, and `ProjectContext` classes. Does not cover config file generation (see SPEC-003) or tool execution (see SPEC-002).
+
+## 3. Terminology
+
+| Term | Definition |
+|---|---|
+| **Project root** | Directory containing `composer.json` |
+| **Devkit directory** | `{project_root}/.kcode/` |
+| **Override file** | `devkit.php` (project root) — optional PHP file returning an associative array |
+| **ProjectContext** | Immutable snapshot containing all resolved configuration values |
+
+## 4. Detection Pipeline
+
+### 4.1 Entry Point
+
+```
+ProjectDetector::detect(string $workingDirectory): ProjectContext
+```
+
+**Precondition:** `$workingDirectory` must be an absolute path.
+
+**Throws:** `DevkitException::projectNotDetected()` when `composer.json` is absent.
+
+### 4.2 Detection Sequence
+
+```
+1. Parse composer.json (JSON decode with JSON_THROW_ON_ERROR)
+2. Load `devkit.php` from project root via DevkitConfig
+3. For each configuration key:
+ a. Check devkit.php override
+ b. Fall back to composer.json detection
+ c. Fall back to ecosystem default
+4. Construct ProjectContext (immutable)
+```
+
+### 4.3 Detection Rules
+
+#### 4.3.1 Project Name
+
+| Priority | Source | Example |
+|---|---|---|
+| 1 | `devkit.php → project_name` | `"kariricode/parser"` |
+| 2 | `composer.json → name` | `"kariricode/parser"` |
+| 3 | `basename($workingDirectory)` | `"parser"` |
+
+#### 4.3.2 Namespace
+
+| Priority | Source | Example |
+|---|---|---|
+| 1 | `devkit.php → namespace` | `"KaririCode\\Parser"` |
+| 2 | First key in `autoload.psr-4` | `"KaririCode\\Parser\\"` → `"KaririCode\\Parser"` |
+| 3 | Literal `"App"` | — |
+
+The trailing backslash from PSR-4 keys is stripped via `rtrim($ns, '\\')`.
+
+#### 4.3.3 PHP Version
+
+| Priority | Source | Example |
+|---|---|---|
+| 1 | `devkit.php → php_version` | `"8.4"` |
+| 2 | First `\d+\.\d+` match in `require.php` | `">=8.4"` → `"8.4"` |
+| 3 | Literal `"8.4"` | — |
+
+#### 4.3.4 Source Directories
+
+| Priority | Source | Result |
+|---|---|---|
+| 1 | `devkit.php → source_dirs` | Absolute paths from override |
+| 2 | `autoload.psr-4` values | Absolute paths for existing directories |
+| 3 | Fallback: `['src']` if directory exists | Single-element list |
+
+#### 4.3.5 Test Directories
+
+| Priority | Source | Result |
+|---|---|---|
+| 1 | `devkit.php → test_dirs` | Absolute paths from override |
+| 2 | `autoload-dev.psr-4` values | Absolute paths for existing directories |
+| 3 | Fallback: `['tests']` if directory exists | Single-element list |
+
+**Important:** Source and test fallbacks use distinct default directories (`src` vs `tests`) to prevent misidentification.
+
+#### 4.3.6 Test Suites
+
+| Priority | Source |
+|---|---|
+| 1 | `devkit.php → test_suites` |
+| 2 | Auto-detected from test directory subdirectories |
+
+Auto-detection scans for standard suite names in order: `Unit`, `Integration`, `Conformance`, `Functional`. Each existing subdirectory becomes a named suite.
+
+If no standard subdirectories exist, a single `Default` suite is registered pointing to the first test directory.
+
+#### 4.3.7 PHPStan Level
+
+| Priority | Source | Default |
+|---|---|---|
+| 1 | `devkit.php → phpstan_level` | — |
+| 2 | Ecosystem default | `9` (maximum) |
+
+#### 4.3.8 Psalm Level
+
+| Priority | Source | Default |
+|---|---|---|
+| 1 | `devkit.php → psalm_level` | — |
+| 2 | Ecosystem default | `3` |
+
+#### 4.3.9 CS-Fixer Rules
+
+Override rules are **merged** with ecosystem defaults via `array_merge()`. This means override keys replace defaults with the same key, and new keys are added.
+
+#### 4.3.10 Rector Sets
+
+Override sets **replace** ecosystem defaults entirely (not merged). This is because set order matters and partial merging could produce invalid configurations.
+
+## 5. DevkitConfig
+
+### 5.1 File Location
+
+The `devkit.php` file lives at the **project root** (not inside `.kcode/`). This separation ensures:
+
+- `devkit.php` is committed to git (user-owned configuration).
+- `.kcode/` is gitignored (generated, deterministic output).
+
+Scaffold with `kcode init --config`.
+
+### 5.2 File Format
+
+```php
+ 'value',
+ // ...
+];
+```
+
+The file must return an associative array. Non-array returns throw `ConfigurationException::invalidOverride()`.
+
+### 5.3 Type Safety
+
+`DevkitConfig::get()` enforces type consistency between the override value and the default:
+
+```php
+$config->get('phpstan_level', 9); // OK: int override for int default
+$config->get('phpstan_level', '9'); // Throws: string override for int default
+$config->get('source_dirs', null); // OK: null default bypasses type check
+```
+
+**Rationale for null bypass:** `null` defaults indicate "detect from composer.json if not overridden." The override type is validated implicitly by the consuming code.
+
+### 5.4 Unknown Keys
+
+Unknown keys in `devkit.php` are silently ignored. This provides forward-compatibility — a newer devkit version can introduce keys without breaking older config files.
+
+## 6. ProjectContext
+
+### 6.1 Invariants
+
+- All directory paths in `$sourceDirs` and `$testDirs` are absolute.
+- `$devkitDir` and `$buildDir` are derived deterministically from `$projectRoot`.
+- The object is `final readonly` — no mutation after construction.
+
+### 6.2 Path Utilities
+
+```php
+$ctx->configPath('phpstan.neon') // → /project/.kcode/phpstan.neon
+$ctx->buildPath('coverage') // → /project/.kcode/build/coverage
+$ctx->relativeSourceDirs() // → ['src']
+$ctx->relativeTestDirs() // → ['tests']
+$ctx->relativize('/project/src') // → 'src'
+```
+
+`relativize()` strips the `$projectRoot` prefix. Paths not under the project root are returned unchanged.
+
+## 7. Ecosystem Defaults
+
+### 7.1 CS-Fixer Rules
+
+```
+@PSR12, @PHP84Migration, array_syntax (short), ordered_imports (alpha),
+no_unused_imports, trailing_comma_in_multiline, phpdoc_scalar,
+unary_operator_spaces, binary_operator_spaces, blank_line_before_statement,
+class_attributes_separation, method_argument_space,
+single_trait_insert_per_statement, declare_strict_types,
+native_function_invocation (@compiler_optimized, namespaced),
+not_operator_with_successor_space
+```
+
+### 7.2 Rector Sets
+
+```
+LevelSetList::UP_TO_PHP_84, SetList::CODE_QUALITY, SetList::DEAD_CODE,
+SetList::EARLY_RETURN, SetList::TYPE_DECLARATION
+```
+
+### 7.3 Default Exclusions
+
+- Analysis excludes: `src/Contract` (interfaces are analyzed via implementors)
+- Coverage excludes: `src/Exception` (exception classes are trivial)
diff --git a/docs/spec/SPEC-002-cli-interface.md b/docs/spec/SPEC-002-cli-interface.md
new file mode 100644
index 0000000..7c03a9c
--- /dev/null
+++ b/docs/spec/SPEC-002-cli-interface.md
@@ -0,0 +1,186 @@
+# SPEC-002: CLI Command Interface and Execution Pipeline
+
+**Version:** 1.0.0
+**Status:** Normative
+**Date:** 2025-02-28
+**Author:** Walmir Silva
+
+## 1. Purpose
+
+This specification defines the CLI interface, command dispatch, argument parsing, and output formatting for the `kcode` binary.
+
+## 2. Binary Invocation
+
+```
+kcode [options] [arguments]
+kcode --help | -h | help
+kcode --version | -V
+```
+
+Exit codes follow Unix conventions: `0` = success, `1` = tool failure, `127` = binary not found.
+
+## 3. Command Registry
+
+### 3.1 Available Commands
+
+| Command | Description | Tools Invoked |
+|---|---|---|
+| `init` | Generate `.kcode/` config directory | None (filesystem only) |
+| `migrate` | Detect and remove redundant deps/configs | None (filesystem + composer.json) |
+| `test` | Run PHPUnit tests | phpunit |
+| `analyse` | Run static analysis | phpstan, psalm |
+| `cs:fix` | Fix code style | php-cs-fixer |
+| `rector` | Run Rector refactoring | rector |
+| `security` | Vulnerability scanning | composer audit |
+| `quality` | Full pipeline | cs-fixer, phpstan, psalm, phpunit |
+| `format` | Apply formatting | cs-fixer, rector |
+| `clean` | Remove build artifacts | None (filesystem only) |
+
+### 3.2 Command-Specific Options
+
+#### init
+
+| Option | Effect |
+|---|---|
+| `--config` | Scaffold a `devkit.php` override file in the project root |
+
+#### test
+
+| Option | Effect |
+|---|---|
+| `--coverage` | Enable HTML coverage report in `.kcode/build/coverage/` |
+| `--suite=Name` | Run only the named test suite |
+| All other `--*` flags | Passed through to PHPUnit |
+
+#### migrate
+
+| Option | Effect |
+|---|---|
+| `--dry-run` or `--check` | Show findings without making changes |
+| `-n` or `--no-interaction` | Apply all removals without prompting |
+
+#### cs:fix
+
+| Option | Effect |
+|---|---|
+| `--check` or `--dry-run` | Check-only mode (no modifications) |
+| All other `--*` flags | Passed through to PHP-CS-Fixer |
+
+#### rector
+
+| Option | Effect |
+|---|---|
+| `--fix` or `--apply` | Apply changes (default is dry-run preview) |
+| All other `--*` flags | Passed through to Rector |
+
+#### quality
+
+No command-specific options. Runs the pipeline: `cs-fixer --dry-run → phpstan → psalm → phpunit`. Unavailable tools are skipped automatically.
+
+## 4. Dispatch Architecture
+
+### 4.1 Application Router
+
+`Command\Application` maps command names to `AbstractCommand` instances:
+
+```
+argv → strip script name → match command → execute(Devkit, arguments) → exit code
+```
+
+Unknown commands produce exit code 1 with a help suggestion.
+
+### 4.2 Argument Parsing
+
+`AbstractCommand` provides parsing utilities consumed by subclasses:
+
+| Method | Purpose | Example |
+|---|---|---|
+| `hasFlag($args, ...$flags)` | Boolean flag detection | `hasFlag($args, '--coverage')` |
+| `option($args, $key, $default)` | Key-value extraction | `option($args, 'suite')` → `'Unit'` |
+| `positional($args)` | Non-flag arguments | Filters out all `--*` prefixed args |
+| `passthrough($args, $consume)` | Forward remaining args | Strips consumed flags, passes rest |
+
+### 4.3 Passthrough Pattern
+
+Commands consume their own flags and forward everything else to the underlying tool:
+
+```php
+// CsFixCommand
+$dryRun = $this->hasFlag($arguments, '--check', '--dry-run'); // consume
+$passthrough = $this->passthrough($arguments, ['--check', '--dry-run']); // strip consumed
+$result = $devkit->run('cs-fixer', [...$extraArgs, ...$passthrough]); // forward rest
+```
+
+This allows users to pass tool-native flags without the devkit needing to enumerate all possibilities:
+
+```bash
+kcode test --filter=testSpecificMethod --verbose
+kcode cs:fix --check --using-cache=no
+kcode analyse --level=7
+```
+
+## 5. Output Formatting
+
+### 5.1 Output Streams
+
+| Stream | Content |
+|---|---|
+| STDOUT | Info messages, tool output, banners |
+| STDERR | Error messages, exception messages |
+
+### 5.2 ANSI Formatting
+
+| Method | Prefix | Color |
+|---|---|---|
+| `info()` | `✓` | Green (32) |
+| `warning()` | `⚠` | Yellow (33) |
+| `error()` | `✗` | Red (31) |
+| `banner()` | Ruler + bold title | Cyan (36) + Bold (1) |
+| `line()` | None | Default |
+
+### 5.3 Banner Format
+
+```
+──────────────────────────────────────────────────────────── (cyan)
+ KaririCode Devkit — Command Name (bold)
+──────────────────────────────────────────────────────────── (cyan)
+```
+
+60-character ruler width. Title indented by 2 spaces.
+
+## 6. Error Handling
+
+### 6.1 Command-Level
+
+`Application::run()` wraps each command execution in `try/catch`. Unhandled exceptions produce:
+
+```
+✗ Exception message here
+```
+
+Exit code: 1.
+
+### 6.2 Tool-Level
+
+When a tool binary is not found, `AbstractToolRunner::run()` returns a `ToolResult` with:
+
+- `exitCode: 127`
+- `stderr: 'Binary not found for "toolName".'`
+- `success: false`
+
+Commands that iterate over multiple tools (analyse, quality, format) skip unavailable tools with a warning and continue.
+
+## 7. Quality Pipeline Execution Order
+
+The `quality` command delegates to `Devkit::quality()`, which executes tools in this fixed order:
+
+```
+1. cs-fixer (--dry-run --diff)
+2. phpstan (default args)
+3. psalm (default args)
+4. phpunit (default args)
+```
+
+**Rationale for order:** Style issues are cheapest to detect. Static analysis catches type errors before tests run. Tests are the most expensive operation and run last.
+
+Results are aggregated into a `QualityReport` and the pipeline always completes all available tools (no fail-fast).
diff --git a/docs/spec/SPEC-003-tool-runner.md b/docs/spec/SPEC-003-tool-runner.md
new file mode 100644
index 0000000..bc979a1
--- /dev/null
+++ b/docs/spec/SPEC-003-tool-runner.md
@@ -0,0 +1,241 @@
+# SPEC-003: Tool Runner Abstraction and Process Execution
+
+**Version:** 1.0.0
+**Status:** Normative
+**Date:** 2025-02-28
+**Author:** Walmir Silva
+
+## 1. Purpose
+
+This specification defines the tool runner contract, process execution model, binary resolution strategy, and result capture semantics.
+
+## 2. Architecture
+
+```
+┌──────────────┐ ┌──────────────────┐ ┌────────────────┐
+│ Command │────▸│ Devkit (facade) │────▸│ ToolRunner │
+│ │ │ │ │ (interface) │
+└──────────────┘ └──────────────────┘ └───────┬────────┘
+ │
+ ┌───────▾────────┐
+ │ AbstractTool- │
+ │ Runner (base) │
+ └───────┬────────┘
+ │ delegates
+ ┌───────▾────────┐
+ │ ProcessExecutor │
+ │ (proc_open) │
+ └───────┬────────┘
+ │ returns
+ ┌───────▾────────┐
+ │ ToolResult │
+ │ (value object) │
+ └────────────────┘
+```
+
+## 3. ToolRunner Contract
+
+```php
+interface ToolRunner
+{
+ public function toolName(): string;
+ public function isAvailable(): bool;
+ public function run(array $arguments = []): ToolResult;
+}
+```
+
+### 3.1 Behavioral Requirements
+
+| Method | Requirement |
+|---|---|
+| `toolName()` | Returns a stable, unique identifier used as registry key |
+| `isAvailable()` | Returns `true` if and only if the binary can be resolved |
+| `run()` | Always returns a `ToolResult` — never throws for tool failures |
+
+### 3.2 Exception Policy
+
+`run()` must not throw exceptions for tool execution failures. All failure states are captured in `ToolResult`:
+
+| Failure | ToolResult |
+|---|---|
+| Binary not found | `exitCode: 127, stderr: 'Binary not found...'` |
+| Tool exits non-zero | `exitCode: N, stdout/stderr: tool output` |
+| Process spawn failure | `exitCode: 127, stderr: 'Failed to spawn process...'` |
+
+Exceptions are only thrown for programming errors (e.g., unknown tool name in `Devkit::run()`).
+
+## 4. AbstractToolRunner
+
+### 4.1 Template Method Pattern
+
+Concrete runners implement three abstract methods:
+
+```php
+abstract public function toolName(): string;
+abstract protected function vendorBin(): string;
+abstract protected function defaultArguments(): array;
+```
+
+The base class provides `isAvailable()` and `run()`:
+
+```
+run(arguments) → binary() → [binary, ...defaultArguments(), ...arguments] → executor.execute()
+```
+
+### 4.2 Binary Caching
+
+```php
+protected function binary(): ?string
+{
+ return $this->resolvedBinary ??= $this->executor->resolveBinary($this->vendorBin());
+}
+```
+
+Resolution happens once per process. The null-coalescing assignment operator (`??=`) ensures thread-safe lazy initialization in the single-threaded CLI context.
+
+### 4.3 Registered Runners
+
+| Runner | Tool Name | Vendor Binary | Default Args |
+|---|---|---|---|
+| `PhpUnitRunner` | `phpunit` | `vendor/bin/phpunit` | `--configuration .kcode/phpunit.xml.dist` |
+| `PhpStanRunner` | `phpstan` | `vendor/bin/phpstan` | `analyse --configuration ... --no-progress --memory-limit=1G` |
+| `CsFixerRunner` | `cs-fixer` | `vendor/bin/php-cs-fixer` | `fix --config ... --diff --ansi` |
+| `RectorRunner` | `rector` | `vendor/bin/rector` | `process --config ... --dry-run --ansi` |
+| `PsalmRunner` | `psalm` | `vendor/bin/psalm` | `--config ... --no-progress --show-info=false` |
+| `ComposerAuditRunner` | `composer-audit` | `vendor/bin/composer` | `audit --format=plain --ansi` |
+
+### 4.4 ComposerAuditRunner Override
+
+Composer is typically global. The runner overrides `binary()` to check global PATH before vendor:
+
+```
+1. Global PATH (command -v composer)
+2. Vendor binary (parent::binary())
+```
+
+This inverts the standard Tier 2 → Tier 3 order because `vendor/bin/composer` rarely exists.
+
+## 5. ProcessExecutor
+
+### 5.1 Process Spawning
+
+```php
+proc_open($command, $descriptors, $pipes, $workingDirectory)
+```
+
+| Descriptor | Direction | Purpose |
+|---|---|---|
+| 0 (stdin) | `['pipe', 'r']` | Closed immediately (no interactive input) |
+| 1 (stdout) | `['pipe', 'w']` | Captured via `stream_get_contents()` |
+| 2 (stderr) | `['pipe', 'w']` | Captured via `stream_get_contents()` |
+
+### 5.2 Timing
+
+```php
+$start = hrtime(true);
+// ... process execution ...
+$elapsed = (hrtime(true) - $start) / 1_000_000_000;
+```
+
+`hrtime(true)` returns nanoseconds as an integer. Division by 10^9 converts to seconds. Result is rounded to 3 decimal places (millisecond precision).
+
+### 5.3 Binary Resolution (Three-Tier)
+
+See ADR-004 for full rationale.
+
+```php
+resolveBinary(string $vendorBin): ?string
+```
+
+| Tier | Check | Example Path |
+|---|---|---|
+| 1 | `Phar::running(true)/$vendorBin` | `phar:///kcode.phar/vendor/bin/phpunit` |
+| 2 | `$workingDirectory/$vendorBin` | `/project/vendor/bin/phpunit` |
+| 3 | `command -v $basename` | `/usr/local/bin/phpunit` |
+
+Tier 1 is only checked when running inside a PHAR (`Phar::running(false) !== ''`).
+
+### 5.4 Security
+
+- `escapeshellarg()` is used for all shell-injected values in `command -v` calls.
+- `proc_open()` accepts command as an array (no shell interpolation).
+
+## 6. ToolResult
+
+### 6.1 Structure
+
+```php
+final readonly class ToolResult
+{
+ public bool $success;
+
+ public function __construct(
+ public string $toolName,
+ public int $exitCode,
+ public string $stdout,
+ public string $stderr,
+ public float $elapsedSeconds,
+ );
+}
+```
+
+### 6.2 Derived Property
+
+`$success = (0 === $exitCode)` — computed in constructor, immutable.
+
+### 6.3 Combined Output
+
+```php
+public function output(): string
+```
+
+Returns `trim(stdout + "\n" + stderr)`. Falls back to `'(no output)'` when both streams are empty.
+
+## 7. QualityReport
+
+### 7.1 Aggregation
+
+```php
+final readonly class QualityReport
+{
+ public bool $passed; // all results successful
+ public float $totalSeconds; // sum of elapsed times
+ public int $failureCount; // count of failed results
+
+ public function __construct(public array $results);
+ public function failures(): array; // filtered failed results
+}
+```
+
+### 7.2 Pipeline Semantics
+
+The quality pipeline always completes all tools. `$passed` reflects the aggregate. Individual tool results are accessible via `$results` for detailed reporting.
+
+## 8. Argument Flow
+
+Complete argument flow from CLI to tool binary:
+
+```
+User CLI input:
+ kcode test --coverage --suite=Unit --verbose
+
+↓ Application strips "test"
+
+Command receives:
+ ['--coverage', '--suite=Unit', '--verbose']
+
+↓ TestCommand processes
+
+ extraArgs: ['--coverage-html', '.kcode/build/coverage', '--testsuite', 'Unit']
+ passthrough: ['--verbose'] (--coverage and --suite=Unit consumed)
+
+↓ Devkit::run('phpunit', allArgs)
+
+Runner prepends defaults:
+ ['vendor/bin/phpunit', '--configuration', '.kcode/phpunit.xml.dist',
+ '--coverage-html', '.kcode/build/coverage', '--testsuite', 'Unit', '--verbose']
+
+↓ ProcessExecutor::execute()
+
+proc_open() receives full command array
+```
diff --git a/infection.json b/infection.json
deleted file mode 100644
index cad07f6..0000000
--- a/infection.json
+++ /dev/null
@@ -1,32 +0,0 @@
-{
- "$schema": "vendor/infection/infection/resources/schema.json",
- "source": {
- "directories": [
- "src"
- ]
- },
- "timeout": 30,
- "logs": {
- "text": "infection.log",
- "html": "infection.html",
- "summary": "infection-summary.log",
- "debug": "infection-debug.log",
- "perMutator": "infection-per-mutator.md"
- },
- "tmpDir": "var/cache/infection",
- "phpUnit": {
- "configDir": ".",
- "customPath": "vendor/bin/phpunit"
- },
- "minMsi": 80,
- "minCoveredMsi": 90,
- "mutators": {
- "@default": true,
- "@function_signature": false,
- "MethodCallRemoval": {
- "ignore": []
- }
- },
- "testFramework": "phpunit",
- "bootstrap": "vendor/autoload.php"
-}
diff --git a/phpbench.json b/phpbench.json
deleted file mode 100644
index aae4e40..0000000
--- a/phpbench.json
+++ /dev/null
@@ -1,37 +0,0 @@
-{
- "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json",
- "runner.bootstrap": "vendor/autoload.php",
- "runner.path": "benchmarks",
- "runner.php_config": {
- "memory_limit": "1G"
- },
- "runner.executors": {
- "profiling": {
- "executor": "local",
- "php_config": {
- "xdebug.mode": "profile",
- "xdebug.output_dir": "build/profile"
- }
- }
- },
- "report.generators": {
- "table": {
- "cols": [
- "benchmark",
- "subject",
- "revs",
- "its",
- "mem_peak",
- "best",
- "mean",
- "mode",
- "worst",
- "stdev",
- "rstdev",
- "diff"
- ]
- }
- },
- "runner.iterations": 10,
- "runner.revs": 1000
-}
diff --git a/phpcs.xml b/phpcs.xml
deleted file mode 100644
index c423cc0..0000000
--- a/phpcs.xml
+++ /dev/null
@@ -1,50 +0,0 @@
-
-
- Coding standard with exceptions for PHP keyword conflicts
-
-
- src
- tests
-
-
- vendor/*
- var/*
- build/*
- coverage/*
- public/index.php
- *.blade.php
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/phpstan.neon b/phpstan.neon
deleted file mode 100644
index 6658f4c..0000000
--- a/phpstan.neon
+++ /dev/null
@@ -1,32 +0,0 @@
-parameters:
- level: max
- paths:
- - src
- excludePaths:
- - tests/Fixtures (?)
- tmpDir: var/cache/phpstan
-
- # Allow analysis even with no files initially
- reportUnmatchedIgnoredErrors: false
- treatPhpDocTypesAsCertain: false
-
- # Case sensitivity checks
- checkClassCaseSensitivity: true
- checkFunctionNameCase: true
- checkInternalClassCaseSensitivity: true
-
- # Strict production quality rules
- checkExplicitMixedMissingReturn: true
- checkUninitializedProperties: true
- checkTooWideReturnTypesInProtectedAndPublicMethods: true
-
- # Scope pollution prevention
- polluteScopeWithLoopInitialAssignments: false
- polluteScopeWithAlwaysIterableForeach: false
-
- # PHPDoc flexibility
- reportMaybesInMethodSignatures: false
- reportStaticMethodSignatures: false
-
-includes:
- - vendor/phpstan/phpstan-strict-rules/rules.neon
diff --git a/phpunit.xml b/phpunit.xml
index fd3e50d..8afc8f6 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,11 +1,17 @@
+ displayDetailsOnTestsThatTriggerErrors="true"
+ displayDetailsOnTestsThatTriggerNotices="true"
+ displayDetailsOnTestsThatTriggerWarnings="true"
+>
tests/Unit
@@ -13,9 +19,6 @@
tests/Integration
-
- tests/Functional
-
@@ -32,14 +35,12 @@
-
-
-
+
diff --git a/src/Command/AbstractCommand.php b/src/Command/AbstractCommand.php
new file mode 100644
index 0000000..e4731f6
--- /dev/null
+++ b/src/Command/AbstractCommand.php
@@ -0,0 +1,139 @@
+ $arguments Raw CLI arguments after command name. */
+ abstract public function execute(Devkit $devkit, array $arguments): int;
+
+ // ── Output Helpers ────────────────────────────────────────────
+
+ protected function info(string $message): void
+ {
+ fwrite(\STDOUT, "\033[32m✓\033[0m {$message}" . \PHP_EOL);
+ }
+
+ protected function warning(string $message): void
+ {
+ fwrite(\STDOUT, "\033[33m⚠\033[0m {$message}" . \PHP_EOL);
+ }
+
+ protected function error(string $message): void
+ {
+ fwrite(\STDERR, "\033[31m✗\033[0m {$message}" . \PHP_EOL);
+ }
+
+ protected function line(string $message = ''): void
+ {
+ fwrite(\STDOUT, $message . \PHP_EOL);
+ }
+
+ protected function banner(string $title): void
+ {
+ $ruler = str_repeat('─', 60);
+ $this->line("\033[36m{$ruler}\033[0m");
+ $this->line("\033[1m {$title}\033[0m");
+ $this->line("\033[36m{$ruler}\033[0m");
+ }
+
+ protected function section(string $title): void
+ {
+ $this->line();
+ $this->line("\033[33m {$title}\033[0m");
+ $this->line();
+ }
+
+ /**
+ * Interactive yes/no confirmation via STDIN.
+ *
+ * @param bool $default Default answer when user presses Enter without input.
+ */
+ protected function confirm(string $question, bool $default = false): bool
+ {
+ $hint = $default ? '[Y/n]' : '[y/N]';
+ fwrite(\STDOUT, "\033[33m?\033[0m {$question} {$hint} ");
+
+ $input = trim((string) fgets(\STDIN));
+
+ if ('' === $input) {
+ return $default;
+ }
+
+ return \in_array(strtolower($input), ['y', 'yes', 'sim', 's'], true);
+ }
+
+ // ── Argument Helpers ──────────────────────────────────────────
+
+ /** @param list $arguments */
+ protected function hasFlag(array $arguments, string ...$flags): bool
+ {
+ foreach ($flags as $flag) {
+ if (\in_array($flag, $arguments, true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Extract --key=value from arguments.
+ *
+ * @param list $arguments
+ */
+ protected function option(array $arguments, string $key, ?string $default = null): ?string
+ {
+ $prefix = "--{$key}=";
+
+ foreach ($arguments as $arg) {
+ if (str_starts_with($arg, $prefix)) {
+ return substr($arg, \strlen($prefix));
+ }
+ }
+
+ return $default;
+ }
+
+ /**
+ * Return arguments that are not flags (--xxx).
+ *
+ * @param list $arguments
+ * @return list
+ */
+ protected function positional(array $arguments): array
+ {
+ return array_values(array_filter(
+ $arguments,
+ static fn (string $arg): bool => ! str_starts_with($arg, '--'),
+ ));
+ }
+
+ /**
+ * Filter arguments to pass through to underlying tool.
+ *
+ * @param list $arguments
+ * @param list $consume Flags consumed by the command itself.
+ * @return list
+ */
+ protected function passthrough(array $arguments, array $consume = []): array
+ {
+ return array_values(array_filter(
+ $arguments,
+ static fn (string $arg): bool => ! \in_array($arg, $consume, true),
+ ));
+ }
+}
diff --git a/src/Command/AnalyseCommand.php b/src/Command/AnalyseCommand.php
new file mode 100644
index 0000000..56f4407
--- /dev/null
+++ b/src/Command/AnalyseCommand.php
@@ -0,0 +1,58 @@
+banner('KaririCode Devkit — Analyse');
+
+ $exitCode = 0;
+
+ foreach (['phpstan', 'psalm'] as $tool) {
+ if (! $devkit->isToolAvailable($tool)) {
+ $this->warning("{$tool} not available — skipping");
+
+ continue;
+ }
+
+ $this->line("\033[1m▸ Running {$tool}…\033[0m");
+ $result = $devkit->run($tool, $arguments);
+ $this->line($result->output());
+
+ if ($result->success) {
+ $this->info(\sprintf('%s passed (%.2fs)', $tool, $result->elapsedSeconds));
+ } else {
+ $this->error(\sprintf('%s failed — exit code %d (%.2fs)', $tool, $result->exitCode, $result->elapsedSeconds));
+ $exitCode = max($exitCode, $result->exitCode);
+ }
+
+ $this->line();
+ }
+
+ return $exitCode;
+ }
+}
diff --git a/src/Command/Application.php b/src/Command/Application.php
new file mode 100644
index 0000000..37f78d2
--- /dev/null
+++ b/src/Command/Application.php
@@ -0,0 +1,118 @@
+ */
+ private array $commands = [];
+
+ public function __construct(
+ private readonly Devkit $devkit,
+ ) {
+ }
+
+ public function register(AbstractCommand $command): void
+ {
+ $this->commands[$command->name()] = $command;
+ }
+
+ /** @param list $argv Raw $argv from CLI. */
+ public function run(array $argv): int
+ {
+ // Strip script name
+ array_shift($argv);
+
+ if ([] === $argv || $this->isHelp($argv)) {
+ $this->printUsage();
+
+ return 0;
+ }
+
+ if ($this->isVersion($argv)) {
+ $this->printVersion();
+
+ return 0;
+ }
+
+ $commandName = array_shift($argv);
+ $command = $this->commands[$commandName] ?? null;
+
+ if (null === $command) {
+ fwrite(\STDERR, "\033[31m✗\033[0m Unknown command: {$commandName}" . \PHP_EOL);
+ fwrite(\STDERR, " Run \033[1mkcode --help\033[0m for available commands." . \PHP_EOL);
+
+ return 1;
+ }
+
+ try {
+ return $command->execute($this->devkit, $argv);
+ } catch (\Throwable $exception) {
+ fwrite(\STDERR, "\033[31m✗\033[0m {$exception->getMessage()}" . \PHP_EOL);
+
+ return 1;
+ }
+ }
+
+ // ── Internals ─────────────────────────────────────────────────
+
+ /** @param list $argv */
+ private function isHelp(array $argv): bool
+ {
+ return \in_array($argv[0] ?? '', ['--help', '-h', 'help'], true);
+ }
+
+ /** @param list $argv */
+ private function isVersion(array $argv): bool
+ {
+ return \in_array($argv[0] ?? '', ['--version', '-V'], true);
+ }
+
+ private function printVersion(): void
+ {
+ fwrite(\STDOUT, \sprintf(
+ "\033[1mKaririCode Devkit\033[0m %s" . \PHP_EOL,
+ Devkit::version(),
+ ));
+ }
+
+ private function printUsage(): void
+ {
+ $this->printVersion();
+ fwrite(\STDOUT, \PHP_EOL);
+ fwrite(\STDOUT, "\033[33mUsage:\033[0m" . \PHP_EOL);
+ fwrite(\STDOUT, " kcode [options] [arguments]" . \PHP_EOL . \PHP_EOL);
+ fwrite(\STDOUT, "\033[33mAvailable commands:\033[0m" . \PHP_EOL);
+
+ $maxLen = 0;
+
+ foreach ($this->commands as $name => $command) {
+ $maxLen = max($maxLen, \strlen($name));
+ }
+
+ foreach ($this->commands as $name => $command) {
+ fwrite(\STDOUT, \sprintf(
+ " \033[32m%-{$maxLen}s\033[0m %s" . \PHP_EOL,
+ $name,
+ $command->description(),
+ ));
+ }
+
+ fwrite(\STDOUT, \PHP_EOL);
+ fwrite(\STDOUT, "\033[33mOptions:\033[0m" . \PHP_EOL);
+ fwrite(\STDOUT, " \033[32m-h, --help\033[0m Show this help" . \PHP_EOL);
+ fwrite(\STDOUT, " \033[32m-V, --version\033[0m Show version" . \PHP_EOL);
+ }
+}
diff --git a/src/Command/CleanCommand.php b/src/Command/CleanCommand.php
new file mode 100644
index 0000000..8708b31
--- /dev/null
+++ b/src/Command/CleanCommand.php
@@ -0,0 +1,39 @@
+banner('KaririCode Devkit — Clean');
+
+ $devkit->clean();
+
+ $this->info('Build directory cleaned: .kcode/build/');
+
+ return 0;
+ }
+}
diff --git a/src/Command/CsFixCommand.php b/src/Command/CsFixCommand.php
new file mode 100644
index 0000000..a38da08
--- /dev/null
+++ b/src/Command/CsFixCommand.php
@@ -0,0 +1,52 @@
+hasFlag($arguments, '--check', '--dry-run');
+ $mode = $dryRun ? 'checking' : 'fixing';
+
+ $this->banner("KaririCode Devkit — CS {$mode}");
+
+ $extraArgs = $dryRun ? ['--dry-run'] : [];
+ $passthrough = $this->passthrough($arguments, ['--check', '--dry-run']);
+
+ $result = $devkit->run('cs-fixer', [...$extraArgs, ...$passthrough]);
+
+ $this->line($result->output());
+ $this->line();
+
+ if ($result->success) {
+ $this->info(\sprintf('Code style %s (%.2fs)', $dryRun ? 'OK' : 'fixed', $result->elapsedSeconds));
+ } else {
+ $this->error(\sprintf('Code style issues found (%.2fs)', $result->elapsedSeconds));
+ }
+
+ return $result->exitCode;
+ }
+}
diff --git a/src/Command/FormatCommand.php b/src/Command/FormatCommand.php
new file mode 100644
index 0000000..9058266
--- /dev/null
+++ b/src/Command/FormatCommand.php
@@ -0,0 +1,67 @@
+banner('KaririCode Devkit — Format');
+
+ $exitCode = 0;
+
+ // Step 1: CS-Fixer fix
+ if ($devkit->isToolAvailable('cs-fixer')) {
+ $this->line("\033[1m▸ Running php-cs-fixer fix…\033[0m");
+ $result = $devkit->run('cs-fixer', $arguments);
+ $this->line($result->output());
+
+ if ($result->success) {
+ $this->info(\sprintf('CS-Fixer done (%.2fs)', $result->elapsedSeconds));
+ } else {
+ $this->error(\sprintf('CS-Fixer failed (%.2fs)', $result->elapsedSeconds));
+ $exitCode = $result->exitCode;
+ }
+
+ $this->line();
+ }
+
+ // Step 2: Rector apply (--no-dry-run overrides runner default)
+ if ($devkit->isToolAvailable('rector')) {
+ $this->line("\033[1m▸ Running rector process…\033[0m");
+ $result = $devkit->run('rector', ['--no-dry-run', ...$arguments]);
+ $this->line($result->output());
+
+ if ($result->success) {
+ $this->info(\sprintf('Rector done (%.2fs)', $result->elapsedSeconds));
+ } else {
+ $this->error(\sprintf('Rector failed (%.2fs)', $result->elapsedSeconds));
+ $exitCode = max($exitCode, $result->exitCode);
+ }
+ }
+
+ return $exitCode;
+ }
+}
diff --git a/src/Command/InitCommand.php b/src/Command/InitCommand.php
new file mode 100644
index 0000000..645c60a
--- /dev/null
+++ b/src/Command/InitCommand.php
@@ -0,0 +1,157 @@
+banner('KaririCode Devkit — Init');
+
+ $context = $devkit->context();
+ $this->info("Project: {$context->projectName}");
+ $this->info("Namespace: {$context->namespace}");
+ $this->info("PHP: {$context->phpVersion}");
+
+ $count = $devkit->init();
+
+ $this->line();
+ $this->info("Generated {$count} config file(s) in .kcode/");
+ $this->info(".kcode/ added to .gitignore (regenerate with kcode init)");
+
+ // Scaffold devkit.php if requested
+ if ($this->hasFlag($arguments, '--config')) {
+ $this->scaffoldDevkitConfig($context->projectRoot, $context);
+ }
+
+ // Hint: detect redundant root-level configs and dev dependencies
+ $detector = new \KaririCode\Devkit\Core\MigrationDetector();
+ $migration = $detector->detect($context->projectRoot);
+
+ if ($migration->hasRedundancies) {
+ $this->line();
+ $this->warning(\sprintf(
+ 'Found %d redundant item(s) that kcode replaces.',
+ $migration->totalItems,
+ ));
+ $this->line(' Run \033[1mkcode migrate\033[0m to review and clean up.');
+ }
+
+ return 0;
+ }
+
+ private function scaffoldDevkitConfig(string $projectRoot, \KaririCode\Devkit\Core\ProjectContext $context): void
+ {
+ $configPath = $projectRoot . \DIRECTORY_SEPARATOR . 'devkit.php';
+
+ if (is_file($configPath)) {
+ $this->warning('devkit.php already exists — skipping scaffold.');
+
+ return;
+ }
+
+ $content = <<<'PHP'
+ 'kariricode/my-component',
+ // 'namespace' => 'KaririCode\\MyComponent',
+
+ // ── PHP Version ───────────────────────────────────────────
+ // 'php_version' => '8.4',
+
+ // ── Static Analysis ───────────────────────────────────────
+ // 'phpstan_level' => 9, // 0–9 (default: 9)
+ // 'psalm_level' => 3, // 1–9 (default: 3)
+
+ // ── Directories ───────────────────────────────────────────
+ // 'source_dirs' => ['src'],
+ // 'test_dirs' => ['tests'],
+ // 'exclude_dirs' => ['src/Contract'], // excluded from static analysis
+
+ // ── Test Suites ───────────────────────────────────────────
+ // 'test_suites' => [
+ // 'Unit' => 'tests/Unit',
+ // 'Integration' => 'tests/Integration',
+ // ],
+
+ // ── Coverage ──────────────────────────────────────────────
+ // 'coverage_exclude' => ['src/Exception'],
+
+ // ── Code Style (MERGED with KaririCode defaults) ──────────
+ // 'cs_fixer_rules' => [
+ // 'concat_space' => ['spacing' => 'one'],
+ // 'yoda_style' => false,
+ // ],
+
+ // ── Rector (REPLACES KaririCode defaults) ─────────────────
+ // 'rector_sets' => [
+ // 'LevelSetList::UP_TO_PHP_84',
+ // 'SetList::CODE_QUALITY',
+ // 'SetList::DEAD_CODE',
+ // 'SetList::EARLY_RETURN',
+ // 'SetList::TYPE_DECLARATION',
+ // ],
+
+ // ── Tool Versions (informational) ─────────────────────────
+ // 'tools' => [
+ // 'phpunit' => '^11.0',
+ // 'phpstan' => '^2.0',
+ // 'php-cs-fixer' => '^3.64',
+ // 'rector' => '^2.0',
+ // 'psalm' => '^6.0',
+ // ],
+ ];
+ PHP;
+
+ file_put_contents($configPath, $content . \PHP_EOL);
+
+ $this->line();
+ $this->info('Scaffolded devkit.php in project root.');
+ $this->line(' Edit it, then run \033[1mkcode init\033[0m to regenerate configs.');
+ }
+}
diff --git a/src/Command/MigrateCommand.php b/src/Command/MigrateCommand.php
new file mode 100644
index 0000000..5a186af
--- /dev/null
+++ b/src/Command/MigrateCommand.php
@@ -0,0 +1,162 @@
+banner('KaririCode Devkit — Migrate');
+
+ $dryRun = $this->hasFlag($arguments, '--dry-run', '--check');
+ $noInteraction = $this->hasFlag($arguments, '--no-interaction', '-n');
+
+ $context = $devkit->context();
+ $report = $this->detector->detect($context->projectRoot);
+
+ if (! $report->hasRedundancies) {
+ $this->info('No redundant dependencies or config files found. Project is clean.');
+
+ return 0;
+ }
+
+ $this->renderReport($report);
+
+ if ($dryRun) {
+ $this->warning('Dry-run mode — no changes applied.');
+
+ return 0;
+ }
+
+ // ── Config files & caches ─────────────────────────────────
+ $filesRemoved = 0;
+
+ if ($report->hasConfigFiles() || $report->hasCachePaths()) {
+ $shouldRemoveFiles = $noInteraction || $this->confirm(
+ 'Remove these config files and cache paths?',
+ );
+
+ if ($shouldRemoveFiles) {
+ $filesRemoved = $report->removeFiles();
+ $this->info("Removed {$filesRemoved} file(s)/directory(ies).");
+ } else {
+ $this->warning('Skipped file removal.');
+ }
+ }
+
+ // ── Composer.json require-dev ─────────────────────────────
+ $packagesRemoved = [];
+
+ if ($report->hasPackages()) {
+ $shouldRemovePackages = $noInteraction || $this->confirm(
+ 'Remove these packages from composer.json require-dev?',
+ );
+
+ if ($shouldRemovePackages) {
+ $packagesRemoved = $report->removePackagesFromComposer();
+
+ if ([] !== $packagesRemoved) {
+ $this->info(\sprintf(
+ 'Removed %d package(s) from composer.json: %s',
+ \count($packagesRemoved),
+ implode(', ', $packagesRemoved),
+ ));
+ }
+ } else {
+ $this->warning('Skipped composer.json modification.');
+ }
+ }
+
+ // ── Summary ──────────────────────────────────────────────
+ $this->section('Summary');
+
+ $totalActioned = $filesRemoved + \count($packagesRemoved);
+
+ if ($totalActioned > 0) {
+ $this->info("{$totalActioned} item(s) cleaned up.");
+
+ if ([] !== $packagesRemoved) {
+ $this->line();
+ $this->warning('Run \033[1mcomposer update\033[0m to apply dependency changes.');
+ }
+ } else {
+ $this->warning('No changes applied.');
+ }
+
+ return 0;
+ }
+
+ private function renderReport(MigrationReport $report): void
+ {
+ $this->line(\sprintf(
+ ' Found \033[1m%d\033[0m redundant item(s) that kcode replaces:',
+ $report->totalItems,
+ ));
+
+ if ($report->hasPackages()) {
+ $this->section('composer.json require-dev');
+
+ foreach ($report->redundantPackages as $package => $version) {
+ $this->line(" \033[31m✗\033[0m {$package}: {$version}");
+ }
+ }
+
+ if ($report->hasConfigFiles()) {
+ $this->section('Root-level config files');
+
+ foreach ($report->redundantConfigFiles as $file) {
+ $this->line(" \033[31m✗\033[0m {$file}");
+ }
+ }
+
+ if ($report->hasCachePaths()) {
+ $this->section('Root-level cache paths');
+
+ foreach ($report->redundantCachePaths as $cachePath) {
+ $isDir = is_dir($report->projectRoot . \DIRECTORY_SEPARATOR . $cachePath);
+ $suffix = $isDir ? '/' : '';
+ $this->line(" \033[31m✗\033[0m {$cachePath}{$suffix}");
+ }
+ }
+
+ $this->line();
+ $this->line(' These are replaced by \033[1m.kcode/\033[0m generated configs.');
+ $this->line();
+ }
+}
diff --git a/src/Command/QualityCommand.php b/src/Command/QualityCommand.php
new file mode 100644
index 0000000..dc65579
--- /dev/null
+++ b/src/Command/QualityCommand.php
@@ -0,0 +1,87 @@
+banner('KaririCode Devkit — Quality Pipeline');
+
+ $report = $devkit->quality();
+
+ foreach ($report->results as $result) {
+ $this->renderToolResult($result);
+ }
+
+ // Summary
+ $this->line();
+
+ if ($report->passed) {
+ $this->info(\sprintf(
+ 'All %d tool(s) passed (%.2fs total)',
+ \count($report->results),
+ $report->totalSeconds,
+ ));
+
+ return 0;
+ }
+
+ $this->error(\sprintf(
+ '%d of %d tool(s) failed (%.2fs total)',
+ $report->failureCount,
+ \count($report->results),
+ $report->totalSeconds,
+ ));
+
+ foreach ($report->failures() as $failure) {
+ $this->error(" └─ {$failure->toolName} (exit {$failure->exitCode})");
+ }
+
+ return 1;
+ }
+
+ private function renderToolResult(ToolResult $result): void
+ {
+ if ($result->success) {
+ $this->info(\sprintf(
+ '%s passed (%.2fs)',
+ $result->toolName,
+ $result->elapsedSeconds,
+ ));
+ } else {
+ $this->error(\sprintf(
+ '%s failed — exit code %d (%.2fs)',
+ $result->toolName,
+ $result->exitCode,
+ $result->elapsedSeconds,
+ ));
+ $this->line($result->output());
+ }
+
+ $this->line();
+ }
+}
diff --git a/src/Command/RectorCommand.php b/src/Command/RectorCommand.php
new file mode 100644
index 0000000..3fd09a4
--- /dev/null
+++ b/src/Command/RectorCommand.php
@@ -0,0 +1,55 @@
+hasFlag($arguments, '--fix', '--apply');
+ $mode = $apply ? 'applying' : 'previewing';
+
+ $this->banner("KaririCode Devkit — Rector ({$mode})");
+
+ $passthrough = $this->passthrough($arguments, ['--fix', '--apply']);
+
+ // RectorRunner defaults to --dry-run for safety.
+ // When applying, --no-dry-run overrides it (Rector: last flag wins).
+ $result = $apply
+ ? $devkit->run('rector', ['--no-dry-run', ...$passthrough])
+ : $devkit->run('rector', $passthrough);
+
+ $this->line($result->output());
+ $this->line();
+
+ if ($result->success) {
+ $this->info(\sprintf('Rector %s (%.2fs)', $apply ? 'applied' : 'clean', $result->elapsedSeconds));
+ } else {
+ $this->error(\sprintf('Rector found issues (%.2fs)', $result->elapsedSeconds));
+ }
+
+ return $result->exitCode;
+ }
+}
diff --git a/src/Command/SecurityCommand.php b/src/Command/SecurityCommand.php
new file mode 100644
index 0000000..d643edc
--- /dev/null
+++ b/src/Command/SecurityCommand.php
@@ -0,0 +1,46 @@
+banner('KaririCode Devkit — Security Audit');
+
+ $result = $devkit->run('composer-audit', $arguments);
+
+ $this->line($result->output());
+ $this->line();
+
+ if ($result->success) {
+ $this->info(\sprintf('No known vulnerabilities (%.2fs)', $result->elapsedSeconds));
+ } else {
+ $this->error(\sprintf('Vulnerabilities found (%.2fs)', $result->elapsedSeconds));
+ }
+
+ return $result->exitCode;
+ }
+}
diff --git a/src/Command/TestCommand.php b/src/Command/TestCommand.php
new file mode 100644
index 0000000..54e3185
--- /dev/null
+++ b/src/Command/TestCommand.php
@@ -0,0 +1,71 @@
+banner('KaririCode Devkit — Test');
+
+ $extraArgs = [];
+
+ if ($this->hasFlag($arguments, '--coverage')) {
+ $extraArgs[] = '--coverage-html';
+ $extraArgs[] = $devkit->context()->buildPath('coverage');
+ }
+
+ $suite = $this->option($arguments, 'suite');
+ if (null !== $suite) {
+ $extraArgs[] = '--testsuite';
+ $extraArgs[] = $suite;
+ }
+
+ $passthrough = $this->passthrough($arguments, ['--coverage']);
+
+ // Strip consumed --suite=X option (prefix match, not exact)
+ $passthrough = array_values(array_filter(
+ $passthrough,
+ static fn (string $arg): bool => ! str_starts_with($arg, '--suite='),
+ ));
+
+ $allArgs = [...$extraArgs, ...$passthrough];
+
+ $result = $devkit->run('phpunit', $allArgs);
+
+ $this->line($result->output());
+ $this->line();
+
+ if ($result->success) {
+ $this->info(\sprintf('Tests passed (%.2fs)', $result->elapsedSeconds));
+ } else {
+ $this->error(\sprintf('Tests failed — exit code %d (%.2fs)', $result->exitCode, $result->elapsedSeconds));
+ }
+
+ return $result->exitCode;
+ }
+}
diff --git a/src/Configuration/CsFixerConfigGenerator.php b/src/Configuration/CsFixerConfigGenerator.php
new file mode 100644
index 0000000..5680d36
--- /dev/null
+++ b/src/Configuration/CsFixerConfigGenerator.php
@@ -0,0 +1,85 @@
+relativeSourceDirs() as $dir) {
+ $finderDirs .= " ->in(__DIR__ . '/../{$dir}')\n";
+ }
+
+ foreach ($context->relativeTestDirs() as $dir) {
+ $finderDirs .= " ->in(__DIR__ . '/../{$dir}')\n";
+ }
+
+ $rulesExport = $this->exportRules($context->csFixerRules);
+
+ return <<name('*.php')
+ ->ignoreDotFiles(true)
+ ->ignoreVCS(true);
+
+ return (new PhpCsFixer\\Config())
+ ->setRules({$rulesExport})
+ ->setFinder(\$finder)
+ ->setRiskyAllowed(true)
+ ->setUsingCache(true)
+ ->setCacheFile(__DIR__ . '/build/.php-cs-fixer.cache');
+
+ PHP;
+ }
+
+ /** @param array $rules */
+ private function exportRules(array $rules): string
+ {
+ $export = var_export($rules, true);
+
+ // Normalize var_export output to short array syntax
+ $export = (string) preg_replace('/^array \($/m', '[', $export);
+ $export = (string) preg_replace('/^\)$/m', ']', $export);
+ $export = str_replace('array (', '[', $export);
+ $export = str_replace(')', ']', $export);
+
+ return $export;
+ }
+}
diff --git a/src/Configuration/PhpStanConfigGenerator.php b/src/Configuration/PhpStanConfigGenerator.php
new file mode 100644
index 0000000..9d90d34
--- /dev/null
+++ b/src/Configuration/PhpStanConfigGenerator.php
@@ -0,0 +1,64 @@
+ " - ../{$d}",
+ $context->relativeSourceDirs(),
+ ));
+
+ $excludes = '';
+ if ([] !== $context->excludeDirs) {
+ $items = implode("\n", array_map(
+ static fn (string $d): string => " - ../{$d}",
+ $context->excludeDirs,
+ ));
+ $excludes = <<phpstanLevel}
+ paths:
+ {$paths}
+ {$excludes}
+ tmpDir: build/.phpstan
+ checkMissingCallableSignature: true
+ treatPhpDocTypesAsCertain: false
+ reportUnmatchedIgnoredErrors: true
+
+ NEON;
+ }
+}
diff --git a/src/Configuration/PhpUnitConfigGenerator.php b/src/Configuration/PhpUnitConfigGenerator.php
new file mode 100644
index 0000000..c800f0a
--- /dev/null
+++ b/src/Configuration/PhpUnitConfigGenerator.php
@@ -0,0 +1,116 @@
+renderSuites($context);
+ $sourceIncludes = $this->renderDirList($context->relativeSourceDirs(), 12);
+ $coverageExcludes = $this->renderDirList($context->coverageExclude, 12);
+
+ return <<
+
+
+
+
+
+
+
+
+
+
+
+ {$suites}
+
+
+
+ {$sourceIncludes}
+
+ {$coverageExcludes}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ XML;
+ }
+
+ private function renderSuites(ProjectContext $context): string
+ {
+ $xml = '';
+
+ foreach ($context->testSuites as $name => $relativeDir) {
+ $xml .= " \n";
+ $xml .= " ../{$relativeDir}\n";
+ $xml .= " \n";
+ }
+
+ return $xml;
+ }
+
+ /** @param list $dirs */
+ private function renderDirList(array $dirs, int $indent): string
+ {
+ $pad = str_repeat(' ', $indent);
+ $xml = '';
+
+ foreach ($dirs as $dir) {
+ $xml .= "{$pad}../{$dir}\n";
+ }
+
+ return $xml;
+ }
+}
diff --git a/src/Configuration/PsalmConfigGenerator.php b/src/Configuration/PsalmConfigGenerator.php
new file mode 100644
index 0000000..d01dd97
--- /dev/null
+++ b/src/Configuration/PsalmConfigGenerator.php
@@ -0,0 +1,60 @@
+relativeSourceDirs() as $dir) {
+ $sourceDirs .= " \n";
+ }
+
+ return <<
+
+
+
+ {$sourceDirs}
+
+
+
+
+
+ XML;
+ }
+}
diff --git a/src/Configuration/RectorConfigGenerator.php b/src/Configuration/RectorConfigGenerator.php
new file mode 100644
index 0000000..dac7774
--- /dev/null
+++ b/src/Configuration/RectorConfigGenerator.php
@@ -0,0 +1,83 @@
+relativeSourceDirs() as $dir) {
+ $paths .= " __DIR__ . '/../{$dir}',\n";
+ }
+
+ foreach ($context->relativeTestDirs() as $dir) {
+ $paths .= " __DIR__ . '/../{$dir}',\n";
+ }
+
+ $sets = $this->renderSets($context->rectorSets);
+
+ return <<withPaths([
+ {$paths} ])
+ ->withPhpSets(php84: true)
+ ->withSets([
+ {$sets} ])
+ ->withImportNames(
+ importShortClasses: false,
+ removeUnusedImports: true,
+ );
+
+ PHP;
+ }
+
+ /** @param list $sets */
+ private function renderSets(array $sets): string
+ {
+ $lines = '';
+
+ foreach ($sets as $set) {
+ $lines .= " {$set},\n";
+ }
+
+ return $lines;
+ }
+}
diff --git a/src/Contract/ConfigGenerator.php b/src/Contract/ConfigGenerator.php
new file mode 100644
index 0000000..61b4d4f
--- /dev/null
+++ b/src/Contract/ConfigGenerator.php
@@ -0,0 +1,29 @@
+ $arguments Extra CLI args forwarded to the tool. */
+ public function run(array $arguments = []): ToolResult;
+}
diff --git a/src/Core/Devkit.php b/src/Core/Devkit.php
new file mode 100644
index 0000000..b994bfc
--- /dev/null
+++ b/src/Core/Devkit.php
@@ -0,0 +1,238 @@
+ */
+ private array $generators = [];
+
+ /** @var array */
+ private array $runners = [];
+
+ public function __construct(
+ private readonly ProjectDetector $detector,
+ ) {
+ }
+
+ public static function version(): string
+ {
+ return self::VERSION;
+ }
+
+ // ── Registration ──────────────────────────────────────────────
+
+ public function addGenerator(ConfigGenerator $generator): void
+ {
+ $this->generators[$generator->toolName()] = $generator;
+ }
+
+ public function addRunner(ToolRunner $runner): void
+ {
+ $this->runners[$runner->toolName()] = $runner;
+ }
+
+ // ── Context ───────────────────────────────────────────────────
+
+ public function context(string $workingDirectory = '.'): ProjectContext
+ {
+ return $this->context ??= $this->detector->detect(
+ realpath($workingDirectory) ?: $workingDirectory,
+ );
+ }
+
+ // ── Init ──────────────────────────────────────────────────────
+
+ /** Generate all config files inside `.kcode/`. Returns file count. */
+ public function init(string $workingDirectory = '.'): int
+ {
+ $ctx = $this->context($workingDirectory);
+ $this->ensureDirectories($ctx);
+
+ $count = 0;
+
+ foreach ($this->generators as $generator) {
+ $path = $ctx->configPath($generator->outputPath());
+
+ $dir = \dirname($path);
+ if (! is_dir($dir)) {
+ mkdir($dir, 0o755, true);
+ }
+
+ file_put_contents($path, $generator->generate($ctx));
+ ++$count;
+ }
+
+ $this->appendGitIgnore($ctx);
+
+ return $count;
+ }
+
+ // ── Run ───────────────────────────────────────────────────────
+
+ /** @param list $arguments */
+ public function run(string $toolName, array $arguments = []): ToolResult
+ {
+ $runner = $this->runners[$toolName] ?? null;
+
+ if (null === $runner) {
+ throw new DevkitException(\sprintf(
+ 'Unknown tool "%s". Available: %s',
+ $toolName,
+ implode(', ', array_keys($this->runners)),
+ ));
+ }
+
+ return $runner->run($arguments);
+ }
+
+ /** Check if a tool runner is registered and its binary is available. */
+ public function isToolAvailable(string $toolName): bool
+ {
+ return isset($this->runners[$toolName]) && $this->runners[$toolName]->isAvailable();
+ }
+
+ // ── Quality Pipeline ──────────────────────────────────────────
+
+ /**
+ * Full quality pipeline: cs-check → analyse → test.
+ *
+ * Skips unavailable tools instead of failing.
+ *
+ * @param list $onlyTools Restrict to these tools (empty = all).
+ */
+ public function quality(array $onlyTools = []): QualityReport
+ {
+ $pipeline = [] !== $onlyTools
+ ? $onlyTools
+ : ['cs-fixer', 'phpstan', 'psalm', 'phpunit'];
+
+ $results = [];
+
+ foreach ($pipeline as $tool) {
+ if (! $this->isToolAvailable($tool)) {
+ continue;
+ }
+
+ $extraArgs = match ($tool) {
+ 'cs-fixer' => ['--dry-run', '--diff'],
+ 'rector' => ['--dry-run'],
+ default => [],
+ };
+
+ $results[] = $this->run($tool, $extraArgs);
+ }
+
+ return new QualityReport($results);
+ }
+
+ // ── Clean ─────────────────────────────────────────────────────
+
+ public function clean(string $workingDirectory = '.'): void
+ {
+ $buildDir = $this->context($workingDirectory)->buildDir;
+
+ if (is_dir($buildDir)) {
+ $this->removeRecursive($buildDir);
+ }
+
+ mkdir($buildDir, 0o755, true);
+ }
+
+ /** @return list */
+ public function registeredTools(): array
+ {
+ return array_keys($this->runners);
+ }
+
+ // ── Internals ─────────────────────────────────────────────────
+
+ private function ensureDirectories(ProjectContext $ctx): void
+ {
+ foreach ([$ctx->devkitDir, $ctx->buildDir] as $dir) {
+ if (! is_dir($dir) && ! mkdir($dir, 0o755, true)) {
+ throw DevkitException::directoryNotWritable($dir);
+ }
+ }
+ }
+
+ private function appendGitIgnore(ProjectContext $ctx): void
+ {
+ $gitignore = $ctx->projectRoot . \DIRECTORY_SEPARATOR . '.gitignore';
+ $entry = '.kcode/';
+
+ // Create .gitignore if it doesn't exist
+ if (! is_file($gitignore)) {
+ file_put_contents(
+ $gitignore,
+ '# KaririCode Devkit — generated configs and build artifacts' . \PHP_EOL
+ . $entry . \PHP_EOL,
+ );
+
+ return;
+ }
+
+ $content = file_get_contents($gitignore);
+
+ if (false === $content) {
+ return;
+ }
+
+ // Already covered
+ if (str_contains($content, $entry)) {
+ return;
+ }
+
+ // Migrate: if old .kcode/build/ entry exists, replace with .kcode/
+ $legacyEntry = '.kcode/build/';
+ if (str_contains($content, $legacyEntry)) {
+ $content = str_replace($legacyEntry, $entry, $content);
+ file_put_contents($gitignore, $content);
+
+ return;
+ }
+
+ file_put_contents(
+ $gitignore,
+ \PHP_EOL . '# KaririCode Devkit — generated configs and build artifacts' . \PHP_EOL
+ . $entry . \PHP_EOL,
+ \FILE_APPEND,
+ );
+ }
+
+ private function removeRecursive(string $dir): void
+ {
+ $items = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST,
+ );
+
+ foreach ($items as $item) {
+ /** @var \SplFileInfo $item */
+ $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
+ }
+
+ rmdir($dir);
+ }
+}
diff --git a/src/Core/DevkitConfig.php b/src/Core/DevkitConfig.php
new file mode 100644
index 0000000..9cc9204
--- /dev/null
+++ b/src/Core/DevkitConfig.php
@@ -0,0 +1,130 @@
+ 'kariricode/parser',
+ * 'namespace' => 'KaririCode\\Parser',
+ * 'php_version' => '8.4',
+ * 'phpstan_level' => 9, // 0–9
+ * 'psalm_level' => 3, // 1–9
+ * 'source_dirs' => ['src'], // relative to project root
+ * 'test_dirs' => ['tests'],
+ * 'exclude_dirs' => ['src/Contract'], // excluded from analysis
+ * 'test_suites' => ['Unit' => 'tests/Unit', 'Integration' => 'tests/Integration'],
+ * 'coverage_exclude' => ['src/Exception'],
+ * 'cs_fixer_rules' => [], // MERGED with KaririCode defaults
+ * 'rector_sets' => [], // REPLACES KaririCode defaults
+ * 'tools' => [ // version constraints (optional)
+ * 'phpunit' => '^11.0',
+ * 'phpstan' => '^2.0',
+ * 'php-cs-fixer' => '^3.64',
+ * 'rector' => '^2.0',
+ * 'psalm' => '^6.0',
+ * ],
+ * ];
+ * ```
+ *
+ * @since 1.0.0
+ */
+final readonly class DevkitConfig
+{
+ private const string CONFIG_FILE = 'devkit.php';
+
+ /** @var array */
+ public array $overrides;
+
+ public function __construct(string $projectRoot)
+ {
+ $configPath = $projectRoot . \DIRECTORY_SEPARATOR . self::CONFIG_FILE;
+
+ if (! is_file($configPath)) {
+ $this->overrides = [];
+
+ return;
+ }
+
+ if (! is_readable($configPath)) {
+ throw ConfigurationException::fileNotReadable($configPath);
+ }
+
+ $loaded = require $configPath;
+
+ if (! \is_array($loaded)) {
+ throw ConfigurationException::invalidOverride(
+ self::CONFIG_FILE,
+ 'Must return an array.',
+ );
+ }
+
+ /** @var array $loaded */
+ $this->overrides = $loaded;
+ }
+
+ /**
+ * Get a config value with type-safe fallback.
+ *
+ * @template T
+ * @param T $default
+ * @return T
+ */
+ public function get(string $key, mixed $default): mixed
+ {
+ if (! \array_key_exists($key, $this->overrides)) {
+ return $default;
+ }
+
+ $value = $this->overrides[$key];
+
+ // Type consistency check: override must match default's type
+ if (null !== $default && \gettype($value) !== \gettype($default)) {
+ throw ConfigurationException::invalidOverride(
+ $key,
+ \sprintf('Expected %s, got %s.', \gettype($default), \gettype($value)),
+ );
+ }
+
+ return $value;
+ }
+
+ /** @return array */
+ public function toolVersions(): array
+ {
+ $tools = $this->overrides['tools'] ?? [];
+
+ if (! \is_array($tools)) {
+ return [];
+ }
+
+ /** @var array $typed */
+ $typed = array_filter(
+ $tools,
+ static fn (mixed $v): bool => \is_string($v),
+ );
+
+ return $typed;
+ }
+
+ public function hasOverrides(): bool
+ {
+ return [] !== $this->overrides;
+ }
+}
diff --git a/src/Core/MigrationDetector.php b/src/Core/MigrationDetector.php
new file mode 100644
index 0000000..3d97c58
--- /dev/null
+++ b/src/Core/MigrationDetector.php
@@ -0,0 +1,109 @@
+ $composer */
+ $composer = json_decode(
+ $raw,
+ true,
+ 512,
+ \JSON_THROW_ON_ERROR,
+ );
+
+ /** @var array $requireDev */
+ $requireDev = $composer['require-dev'] ?? [];
+
+ foreach (self::REPLACED_PACKAGES as $package) {
+ if (\array_key_exists($package, $requireDev)) {
+ $redundantPackages[$package] = $requireDev[$package];
+ }
+ }
+ }
+ }
+
+ // Scan root-level config files
+ foreach (self::REPLACED_CONFIG_FILES as $file) {
+ $fullPath = $projectRoot . \DIRECTORY_SEPARATOR . $file;
+ if (is_file($fullPath)) {
+ $redundantConfigFiles[] = $file;
+ }
+ }
+
+ // Scan root-level cache paths
+ foreach (self::REPLACED_CACHE_PATHS as $cachePath) {
+ $fullPath = $projectRoot . \DIRECTORY_SEPARATOR . $cachePath;
+ if (file_exists($fullPath)) {
+ $redundantCachePaths[] = $cachePath;
+ }
+ }
+
+ return new MigrationReport(
+ projectRoot: $projectRoot,
+ redundantPackages: $redundantPackages,
+ redundantConfigFiles: $redundantConfigFiles,
+ redundantCachePaths: $redundantCachePaths,
+ );
+ }
+}
diff --git a/src/Core/ProcessExecutor.php b/src/Core/ProcessExecutor.php
new file mode 100644
index 0000000..c701dab
--- /dev/null
+++ b/src/Core/ProcessExecutor.php
@@ -0,0 +1,109 @@
+ $command Full command with arguments.
+ */
+ public function execute(string $toolName, array $command): ToolResult
+ {
+ $start = hrtime(true);
+
+ $process = proc_open(
+ $command,
+ [
+ 0 => ['pipe', 'r'],
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes,
+ $this->workingDirectory,
+ );
+
+ if (! \is_resource($process)) {
+ return new ToolResult(
+ toolName: $toolName,
+ exitCode: 127,
+ stdout: '',
+ stderr: 'Failed to spawn process: ' . implode(' ', $command),
+ elapsedSeconds: 0.0,
+ );
+ }
+
+ fclose($pipes[0]);
+
+ $stdout = (string) stream_get_contents($pipes[1]);
+ $stderr = (string) stream_get_contents($pipes[2]);
+
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+
+ $exitCode = proc_close($process);
+ $elapsed = (hrtime(true) - $start) / 1_000_000_000;
+
+ return new ToolResult(
+ toolName: $toolName,
+ exitCode: $exitCode,
+ stdout: $stdout,
+ stderr: $stderr,
+ elapsedSeconds: round($elapsed, 3),
+ );
+ }
+
+ /**
+ * Resolve a tool binary path using the three-tier strategy.
+ *
+ * @param string $vendorBin Relative path like "vendor/bin/phpunit"
+ */
+ public function resolveBinary(string $vendorBin): ?string
+ {
+ // Tier 1: PHAR-internal binary
+ if ('' !== \Phar::running(false)) {
+ $pharBin = \Phar::running(true) . '/' . $vendorBin;
+ if (file_exists($pharBin)) {
+ return $pharBin;
+ }
+ }
+
+ // Tier 2: Project-local vendor binary
+ $localBin = $this->workingDirectory . '/' . $vendorBin;
+ if (is_file($localBin) && is_executable($localBin)) {
+ return $localBin;
+ }
+
+ // Tier 3: Global PATH
+ $basename = basename($vendorBin);
+ $globalBin = trim((string) shell_exec('command -v ' . escapeshellarg($basename) . ' 2>/dev/null'));
+ if ('' !== $globalBin && is_executable($globalBin)) {
+ return $globalBin;
+ }
+
+ return null;
+ }
+}
diff --git a/src/Core/ProjectContext.php b/src/Core/ProjectContext.php
new file mode 100644
index 0000000..186f11d
--- /dev/null
+++ b/src/Core/ProjectContext.php
@@ -0,0 +1,83 @@
+ $sourceDirs
+ * @param list $testDirs
+ * @param list $excludeDirs Relative to project root
+ * @param array $testSuites Suite name → relative dir
+ * @param list $coverageExclude Relative to project root
+ * @param array $csFixerRules Merged with KaririCode defaults
+ * @param list $rectorSets
+ * @param array $toolVersions Tool name → version constraint
+ */
+ public function __construct(
+ public string $projectRoot,
+ public string $projectName,
+ public string $namespace,
+ public string $phpVersion,
+ public int $phpstanLevel,
+ public int $psalmLevel,
+ public array $sourceDirs,
+ public array $testDirs,
+ public array $excludeDirs,
+ public array $testSuites,
+ public array $coverageExclude,
+ public array $csFixerRules,
+ public array $rectorSets,
+ public array $toolVersions,
+ ) {
+ $this->devkitDir = $projectRoot . \DIRECTORY_SEPARATOR . '.kcode';
+ $this->buildDir = $this->devkitDir . \DIRECTORY_SEPARATOR . 'build';
+ }
+
+ /** Absolute path to a file inside `.kcode/`. */
+ public function configPath(string $filename): string
+ {
+ return $this->devkitDir . \DIRECTORY_SEPARATOR . $filename;
+ }
+
+ /** Absolute path inside `.kcode/build/`. */
+ public function buildPath(string $filename = ''): string
+ {
+ return $this->buildDir . ('' !== $filename ? \DIRECTORY_SEPARATOR . $filename : '');
+ }
+
+ /** @return list Convert absolute source dirs to project-relative paths. */
+ public function relativeSourceDirs(): array
+ {
+ return array_map(fn (string $dir): string => $this->relativize($dir), $this->sourceDirs);
+ }
+
+ /** @return list Convert absolute test dirs to project-relative paths. */
+ public function relativeTestDirs(): array
+ {
+ return array_map(fn (string $dir): string => $this->relativize($dir), $this->testDirs);
+ }
+
+ public function relativize(string $absolutePath): string
+ {
+ $prefix = $this->projectRoot . \DIRECTORY_SEPARATOR;
+
+ return str_starts_with($absolutePath, $prefix)
+ ? substr($absolutePath, \strlen($prefix))
+ : $absolutePath;
+ }
+}
diff --git a/src/Core/ProjectDetector.php b/src/Core/ProjectDetector.php
new file mode 100644
index 0000000..9562873
--- /dev/null
+++ b/src/Core/ProjectDetector.php
@@ -0,0 +1,210 @@
+ $composer */
+ $composer = json_decode(
+ $raw,
+ true,
+ 512,
+ \JSON_THROW_ON_ERROR,
+ );
+
+ // Load overrides from project root devkit.php (not from .kcode/)
+ $config = new DevkitConfig($workingDirectory);
+
+ /** @var array>> $autoload */
+ $autoload = \is_array($composer['autoload'] ?? null) ? $composer['autoload'] : [];
+ /** @var array>> $autoloadDev */
+ $autoloadDev = \is_array($composer['autoload-dev'] ?? null) ? $composer['autoload-dev'] : [];
+
+ /** @var array> $psr4Source */
+ $psr4Source = \is_array($autoload['psr-4'] ?? null) ? $autoload['psr-4'] : [];
+ /** @var array> $psr4Test */
+ $psr4Test = \is_array($autoloadDev['psr-4'] ?? null) ? $autoloadDev['psr-4'] : [];
+
+ $sourceDirs = $config->get('source_dirs', null)
+ ?? $this->detectPsr4Dirs($workingDirectory, $psr4Source, ['src']);
+
+ $testDirs = $config->get('test_dirs', null)
+ ?? $this->detectPsr4Dirs($workingDirectory, $psr4Test, ['tests']);
+
+ $projectName = isset($composer['name']) && \is_string($composer['name'])
+ ? $composer['name']
+ : basename($workingDirectory);
+
+ return new ProjectContext(
+ projectRoot: $workingDirectory,
+ projectName: $config->get('project_name', $projectName),
+ namespace: $config->get('namespace', $this->detectNamespace($composer)),
+ phpVersion: $config->get('php_version', $this->detectPhpVersion($composer)),
+ phpstanLevel: $config->get('phpstan_level', 9),
+ psalmLevel: $config->get('psalm_level', 3),
+ sourceDirs: $sourceDirs,
+ testDirs: $testDirs,
+ excludeDirs: $config->get('exclude_dirs', ['src/Contract']),
+ testSuites: $config->get('test_suites', $this->detectTestSuites($workingDirectory, $testDirs)),
+ coverageExclude: $config->get('coverage_exclude', ['src/Exception']),
+ csFixerRules: array_merge(self::DEFAULT_CS_RULES, $config->get('cs_fixer_rules', [])),
+ rectorSets: $config->get('rector_sets', self::DEFAULT_RECTOR_SETS),
+ toolVersions: $config->toolVersions(),
+ );
+ }
+
+ // ── Detection helpers ─────────────────────────────────────────
+
+ /** @param array $composer */
+ private function detectNamespace(array $composer): string
+ {
+ $autoload = \is_array($composer['autoload'] ?? null) ? $composer['autoload'] : [];
+
+ /** @var array> $psr4 */
+ $psr4 = \is_array($autoload['psr-4'] ?? null) ? $autoload['psr-4'] : [];
+
+ foreach ($psr4 as $ns => $path) {
+ return rtrim((string) $ns, '\\');
+ }
+
+ return 'App';
+ }
+
+ /** @param array $composer */
+ private function detectPhpVersion(array $composer): string
+ {
+ $require = \is_array($composer['require'] ?? null) ? $composer['require'] : [];
+ $constraint = \is_string($require['php'] ?? null) ? $require['php'] : '^8.4';
+
+ return preg_match('/(\d+\.\d+)/', $constraint, $m) ? $m[1] : '8.4';
+ }
+
+ /**
+ * @param array> $psr4Map
+ * @param list $fallbackDirs Context-aware fallback directories.
+ * @return list Absolute paths
+ */
+ private function detectPsr4Dirs(string $root, array $psr4Map, array $fallbackDirs): array
+ {
+ $dirs = [];
+
+ foreach ($psr4Map as $paths) {
+ foreach ((array) $paths as $path) {
+ $absolute = $root . \DIRECTORY_SEPARATOR . rtrim((string) $path, '/');
+ if (is_dir($absolute)) {
+ $dirs[] = $absolute;
+ }
+ }
+ }
+
+ // Fallback: use context-aware defaults (source → 'src', test → 'tests')
+ if ([] === $dirs) {
+ foreach ($fallbackDirs as $fallback) {
+ $candidate = $root . \DIRECTORY_SEPARATOR . $fallback;
+ if (is_dir($candidate)) {
+ $dirs[] = $candidate;
+
+ break;
+ }
+ }
+ }
+
+ return $dirs;
+ }
+
+ /**
+ * @param list $testDirs
+ * @return array Suite name → relative path
+ */
+ private function detectTestSuites(string $root, array $testDirs): array
+ {
+ $suites = [];
+ $standard = ['Unit', 'Integration', 'Conformance', 'Functional'];
+
+ foreach ($testDirs as $testDir) {
+ foreach ($standard as $suite) {
+ $candidate = $testDir . \DIRECTORY_SEPARATOR . $suite;
+ if (is_dir($candidate)) {
+ $relative = str_replace($root . \DIRECTORY_SEPARATOR, '', $candidate);
+ $suites[$suite] = $relative;
+ }
+ }
+ }
+
+ // If nothing detected, register full test dir
+ if ([] === $suites && [] !== $testDirs) {
+ $relative = str_replace($root . \DIRECTORY_SEPARATOR, '', $testDirs[0]);
+ $suites['Default'] = $relative;
+ }
+
+ return $suites;
+ }
+
+ // ── KaririCode Defaults ───────────────────────────────────────
+
+ private const array DEFAULT_CS_RULES = [
+ '@PSR12' => true,
+ '@PHP84Migration' => true,
+ 'array_syntax' => ['syntax' => 'short'],
+ 'ordered_imports' => ['sort_algorithm' => 'alpha'],
+ 'no_unused_imports' => true,
+ 'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']],
+ 'phpdoc_scalar' => true,
+ 'unary_operator_spaces' => true,
+ 'binary_operator_spaces' => true,
+ 'blank_line_before_statement' => [
+ 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'],
+ ],
+ 'class_attributes_separation' => [
+ 'elements' => ['method' => 'one', 'property' => 'one'],
+ ],
+ 'method_argument_space' => [
+ 'on_multiline' => 'ensure_fully_multiline',
+ 'keep_multiple_spaces_after_comma' => true,
+ ],
+ 'single_trait_insert_per_statement' => true,
+ 'declare_strict_types' => true,
+ 'native_function_invocation' => [
+ 'include' => ['@compiler_optimized'],
+ 'scope' => 'namespaced',
+ ],
+ 'not_operator_with_successor_space' => true,
+ ];
+
+ private const array DEFAULT_RECTOR_SETS = [
+ 'LevelSetList::UP_TO_PHP_84',
+ 'SetList::CODE_QUALITY',
+ 'SetList::DEAD_CODE',
+ 'SetList::EARLY_RETURN',
+ 'SetList::TYPE_DECLARATION',
+ ];
+}
diff --git a/src/Email.php b/src/Email.php
deleted file mode 100644
index 73d16de..0000000
--- a/src/Email.php
+++ /dev/null
@@ -1,44 +0,0 @@
-
- * @copyright 2025 KaririCode
- * @license MIT
- * @version 1.0.0
- * @since 1.0.0
- */
-final readonly class Email implements Stringable, JsonSerializable
-{
- public function __construct(
- #[Length(min: 3, max: 320)]
- public string $value,
- ) {
- if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
- throw new InvalidArgumentException("Invalid email: {$value}");
- }
- }
-
- public function __toString(): string
- {
- return $this->value;
- }
-
- public function jsonSerialize(): string
- {
- return $this->value;
- }
-}
diff --git a/src/Exception/ConfigurationException.php b/src/Exception/ConfigurationException.php
new file mode 100644
index 0000000..30f9d4c
--- /dev/null
+++ b/src/Exception/ConfigurationException.php
@@ -0,0 +1,23 @@
+
- * @copyright 2025 KaririCode
- * @license MIT
- * @version 1.0.0
- * @since 1.0.0
- */
-#[Attribute(Attribute::TARGET_PROPERTY)]
-class Length
-{
- public function __construct(
- public readonly int $min = 0,
- public readonly int $max = PHP_INT_MAX,
- ) {
- }
-}
diff --git a/src/Runner/AbstractToolRunner.php b/src/Runner/AbstractToolRunner.php
new file mode 100644
index 0000000..34a682d
--- /dev/null
+++ b/src/Runner/AbstractToolRunner.php
@@ -0,0 +1,70 @@
+
+ */
+ abstract protected function defaultArguments(): array;
+
+ #[\Override]
+ public function isAvailable(): bool
+ {
+ return null !== $this->binary();
+ }
+
+ /** @param list $arguments */
+ #[\Override]
+ public function run(array $arguments = []): ToolResult
+ {
+ $binary = $this->binary();
+
+ if (null === $binary) {
+ return new ToolResult(
+ toolName: $this->toolName(),
+ exitCode: 127,
+ stdout: '',
+ stderr: \sprintf('Binary not found for "%s".', $this->toolName()),
+ elapsedSeconds: 0.0,
+ );
+ }
+
+ $command = [$binary, ...$this->defaultArguments(), ...$arguments];
+
+ return $this->executor->execute($this->toolName(), $command);
+ }
+
+ protected function binary(): ?string
+ {
+ return $this->resolvedBinary ??= $this->executor->resolveBinary($this->vendorBin());
+ }
+}
diff --git a/src/Runner/ComposerAuditRunner.php b/src/Runner/ComposerAuditRunner.php
new file mode 100644
index 0000000..3727d77
--- /dev/null
+++ b/src/Runner/ComposerAuditRunner.php
@@ -0,0 +1,50 @@
+/dev/null'));
+
+ if ('' !== $global && is_executable($global)) {
+ return $global;
+ }
+
+ return parent::binary();
+ }
+}
diff --git a/src/Runner/CsFixerRunner.php b/src/Runner/CsFixerRunner.php
new file mode 100644
index 0000000..03e53db
--- /dev/null
+++ b/src/Runner/CsFixerRunner.php
@@ -0,0 +1,39 @@
+context->configPath('php-cs-fixer.php'),
+ '--diff',
+ '--ansi',
+ ];
+ }
+}
diff --git a/src/Runner/PhpStanRunner.php b/src/Runner/PhpStanRunner.php
new file mode 100644
index 0000000..b636ddb
--- /dev/null
+++ b/src/Runner/PhpStanRunner.php
@@ -0,0 +1,37 @@
+context->configPath('phpstan.neon'),
+ '--no-progress',
+ '--memory-limit=1G',
+ ];
+ }
+}
diff --git a/src/Runner/PhpUnitRunner.php b/src/Runner/PhpUnitRunner.php
new file mode 100644
index 0000000..b967ccf
--- /dev/null
+++ b/src/Runner/PhpUnitRunner.php
@@ -0,0 +1,34 @@
+context->configPath('phpunit.xml.dist'),
+ ];
+ }
+}
diff --git a/src/Runner/PsalmRunner.php b/src/Runner/PsalmRunner.php
new file mode 100644
index 0000000..5fa914b
--- /dev/null
+++ b/src/Runner/PsalmRunner.php
@@ -0,0 +1,36 @@
+context->configPath('psalm.xml'),
+ '--no-progress',
+ '--show-info=false',
+ ];
+ }
+}
diff --git a/src/Runner/RectorRunner.php b/src/Runner/RectorRunner.php
new file mode 100644
index 0000000..e4daced
--- /dev/null
+++ b/src/Runner/RectorRunner.php
@@ -0,0 +1,39 @@
+context->configPath('rector.php'),
+ '--dry-run',
+ '--ansi',
+ ];
+ }
+}
diff --git a/src/UserId.php b/src/UserId.php
deleted file mode 100644
index 79b7e21..0000000
--- a/src/UserId.php
+++ /dev/null
@@ -1,44 +0,0 @@
-
- * @copyright 2025 KaririCode
- * @license MIT
- * @version 1.0.0
- * @since 1.0.0
- */
-final readonly class UserId implements Stringable, JsonSerializable
-{
- public function __construct(
- #[Length(min: 1, max: 50)]
- public string $value,
- ) {
- if (empty($value)) {
- throw new InvalidArgumentException('User ID cannot be empty.');
- }
- }
-
- public function __toString(): string
- {
- return $this->value;
- }
-
- public function jsonSerialize(): string
- {
- return $this->value;
- }
-}
diff --git a/src/UserProfile.php b/src/UserProfile.php
deleted file mode 100644
index 7cc084e..0000000
--- a/src/UserProfile.php
+++ /dev/null
@@ -1,327 +0,0 @@
-with2FA('secret')
- * ->promote();
- *
- * echo $user->displayLabel(); // "Walmir Silva (editor)"
- * ```
- *
- * @package KaririCode\DevKit
- * @category Query Filtering
- * @author Walmir Silva
- * @copyright 2025 KaririCode
- * @license MIT
- * @version 1.0.0
- * @since 1.0.0
- */
-final readonly class UserProfile implements JsonSerializable, Stringable
-{
- public const string MODEL = 'UserProfile';
-
- public const int VERSION = 1;
-
- public function __construct(
- #[Length(min: 1, max: 50)]
- public string $id,
- #[Length(min: 2, max: 120)]
- public string $name,
- public Email $email,
- public UserRole $role = UserRole::VIEWER,
- public UserStatus $status = UserStatus::ACTIVE,
- #[SensitiveParameter]
- public ?string $twoFactorSecret = null,
- public ?array $meta = null,
- public ?DateTimeImmutable $createdAt = null,
- public ?DateTimeImmutable $updatedAt = null,
- ) {
- }
-
- /**
- * Creates a new UserProfile instance with a generated ID and current timestamps.
- *
- * @param string $name The user's full name.
- * @param Email|string $email The user's email address. Can be an Email object or a string.
- * @param UserRole $role The user's role, defaults to VIEWER.
- * @return UserProfile A new instance.
- */
- public static function new(
- string $name,
- Email|string $email,
- UserRole $role = UserRole::VIEWER,
- ): self {
- $now = new DateTimeImmutable();
-
- return new self(
- id: self::generateId(),
- name: $name,
- email: $email instanceof Email ? $email : new Email($email),
- role: $role,
- createdAt: $now,
- updatedAt: $now,
- );
- }
-
- /**
- * Creates a UserProfile from a raw data array (e.g., from a database or API).
- * This is the elegant, declarative hydration method.
- * @param array $data An associative array containing user data. Expected keys:
- * - 'id' (string, optional): User ID. If not provided, a new one will be generated.
- * - 'name' (string): User's full name.
- * - 'email' (string|Email): User's email address.
- * - 'role' (string|UserRole, optional): User's role. Defaults to 'VIEWER'.
- * - 'status' (string|UserStatus, optional): User's status. Defaults to 'ACTIVE'.
- * - 'twoFactorSecret' (string, optional): Two-factor authentication secret.
- * - 'meta' (array, optional): Additional metadata.
- * - 'createdAt' (string|DateTimeImmutable, optional): Creation timestamp.
- * - 'updatedAt' (string|DateTimeImmutable, optional): Last update timestamp.
- * * @return UserProfile A new UserProfile instance hydrated with the provided data.
- * @throws InvalidArgumentException If required data is missing or invalid.
- */
- public static function fromArray(array $data): self
- {
- return new self(
- id: $data['id'] ?? self::generateId(),
- name: $data['name'] ?? '',
- email: self::hydrateEmail($data),
- role: self::hydrateRole($data),
- status: self::hydrateStatus($data),
- twoFactorSecret: $data['twoFactorSecret'] ?? null,
- meta: $data['meta'] ?? null,
- createdAt: self::hydrateDate($data, 'createdAt'),
- updatedAt: self::hydrateDate($data, 'updatedAt'),
- );
- }
-
- // ----------------------------------------------------------
- // Query Methods
- // ----------------------------------------------------------
-
- public function canEdit(): bool
- {
- return $this->status->isActive() && $this->role->canEdit();
- }
-
- public function has2FA(): bool
- {
- return $this->twoFactorSecret !== null;
- }
-
- // ----------------------------------------------------------
- // Domain Behavior
- // ----------------------------------------------------------
-
- public function promote(): self
- {
- $newRole = $this->role === UserRole::ADMIN ? UserRole::ADMIN : UserRole::EDITOR;
-
- return new self(
- id: $this->id,
- name: $this->name,
- email: $this->email,
- role: $newRole,
- status: $this->status,
- twoFactorSecret: $this->twoFactorSecret,
- meta: $this->meta,
- createdAt: $this->createdAt,
- updatedAt: new DateTimeImmutable(),
- );
- }
-
- public function suspend(): self
- {
- return new self(
- id: $this->id,
- name: $this->name,
- email: $this->email,
- role: $this->role,
- status: UserStatus::SUSPENDED,
- twoFactorSecret: $this->twoFactorSecret,
- meta: $this->meta,
- createdAt: $this->createdAt,
- updatedAt: new DateTimeImmutable(),
- );
- }
-
- public function with2FA(#[SensitiveParameter] string $secret): self
- {
- return new self(
- id: $this->id,
- name: $this->name,
- email: $this->email,
- role: $this->role,
- status: $this->status,
- twoFactorSecret: $secret,
- meta: $this->meta,
- createdAt: $this->createdAt,
- updatedAt: new DateTimeImmutable(),
- );
- }
-
- public function withMeta(array $meta): self
- {
- return new self(
- id: $this->id,
- name: $this->name,
- email: $this->email,
- role: $this->role,
- status: $this->status,
- twoFactorSecret: $this->twoFactorSecret,
- meta: $meta,
- createdAt: $this->createdAt,
- updatedAt: new DateTimeImmutable(),
- );
- }
-
- public function displayLabel(): string
- {
- return "{$this->name} ({$this->role->label()})";
- }
-
- // ----------------------------------------------------------
- // Serialization
- // ----------------------------------------------------------
-
- public function jsonSerialize(): array
- {
- return [
- 'model' => self::MODEL,
- 'version' => self::VERSION,
- 'id' => $this->id,
- 'name' => $this->name,
- 'email' => (string) $this->email,
- 'role' => $this->role->value,
- 'status' => $this->status->value,
- 'has2FA' => $this->has2FA(),
- 'meta' => $this->meta,
- 'createdAt' => $this->createdAt?->format('c'),
- 'updatedAt' => $this->updatedAt?->format('c'),
- ];
- }
-
- public function __toString(): string
- {
- return sprintf('%s#%s<%s>', self::MODEL, $this->id, $this->role->value);
- }
-
- // ----------------------------------------------------------
- // Helpers
- // ----------------------------------------------------------
-
- /**
- * Converts 'email' data from an array into an Email object.
- */
- private static function hydrateEmail(array $data): Email
- {
- $emailInput = $data['email'] ?? '';
-
- return $emailInput instanceof Email ? $emailInput : new Email($emailInput);
- }
-
- /**
- * Converts 'role' data from an array into a UserRole enum.
- * Uses UserRole::tryFrom for safe conversion, defaulting to VIEWER.
- */
- private static function hydrateRole(array $data): UserRole
- {
- $role = $data['role'] ?? null;
-
- if ($role instanceof UserRole) {
- return $role;
- }
-
- if (is_string($role) && $role !== '') {
- return UserRole::tryFrom($role) ?? UserRole::VIEWER;
- }
-
- return UserRole::VIEWER;
- }
-
- /**
- * Converts 'status' data from an array into a UserStatus enum.
- * Uses UserStatus::tryFrom for safe conversion, defaulting to ACTIVE.
- */
- private static function hydrateStatus(array $data): UserStatus
- {
- $status = $data['status'] ?? null;
-
- if ($status instanceof UserStatus) {
- return $status;
- }
-
- if (is_string($status) && $status !== '') {
- return UserStatus::tryFrom($status) ?? UserStatus::ACTIVE;
- }
-
- return UserStatus::ACTIVE;
- }
-
- /**
- * Converts a date string from an array into a DateTimeImmutable object.
- * Returns null if the key is missing, empty, or the date is invalid.
- */
- private static function hydrateDate(array $data, string $key): ?DateTimeImmutable
- {
- if (empty($data[$key])) {
- return null;
- }
-
- if ($data[$key] instanceof DateTimeImmutable) {
- return $data[$key];
- }
-
- try {
- return new DateTimeImmutable($data[$key]);
- } catch (Exception $e) {
- // Log error if needed: error_log("Failed to hydrate date '{$key}': {$e->getMessage()}");
- return null;
- }
- }
-
- /**
- * Generates a random ID.
- * Uses a static *variable* for caching the Randomizer,
- * which is allowed in readonly classes.
- */
- private static function generateId(): string
- {
- static $rng = null;
- $rng ??= new Randomizer();
-
- return bin2hex($rng->getBytes(8));
- }
-}
diff --git a/src/UserRole.php b/src/UserRole.php
deleted file mode 100644
index f1ceaef..0000000
--- a/src/UserRole.php
+++ /dev/null
@@ -1,62 +0,0 @@
-with2FA('secret')
- * ->promote();
- *
- * echo $user->displayLabel(); // "Walmir Silva (editor)"
- * ```
- *
- * @package KaririCode\DevKit
- * @category Query Filtering
- * @author Walmir Silva
- * @copyright 2025 KaririCode
- * @license MIT
- * @version 1.0.0
- * @since 1.0.0
- */
-enum UserRole: string
-{
- case ADMIN = 'ADMIN';
- case EDITOR = 'EDITOR';
- case VIEWER = 'VIEWER';
-
- public function canEdit(): bool
- {
- return match ($this) {
- self::ADMIN, self::EDITOR => true,
- self::VIEWER => false,
- };
- }
-
- public function label(): string
- {
- return match ($this) {
- self::ADMIN => 'admin',
- self::EDITOR => 'editor',
- self::VIEWER => 'viewer',
- };
- }
-}
diff --git a/src/UserStatus.php b/src/UserStatus.php
deleted file mode 100644
index 7d6fb9b..0000000
--- a/src/UserStatus.php
+++ /dev/null
@@ -1,29 +0,0 @@
-
- * @copyright 2025 KaririCode
- * @license MIT
- * @version 1.0.0
- * @since 1.0.0
- */
-enum UserStatus: string
-{
- case ACTIVE = 'ACTIVE';
- case SUSPENDED = 'SUSPENDED';
-
- public function isActive(): bool
- {
- return $this === self::ACTIVE;
- }
-}
diff --git a/src/ValueObject/MigrationReport.php b/src/ValueObject/MigrationReport.php
new file mode 100644
index 0000000..397205a
--- /dev/null
+++ b/src/ValueObject/MigrationReport.php
@@ -0,0 +1,153 @@
+ $redundantPackages Package name → version constraint
+ * @param list $redundantConfigFiles Filenames relative to project root
+ * @param list $redundantCachePaths Cache paths relative to project root
+ */
+ public function __construct(
+ public string $projectRoot,
+ public array $redundantPackages,
+ public array $redundantConfigFiles,
+ public array $redundantCachePaths,
+ ) {
+ $this->totalItems = \count($redundantPackages)
+ + \count($redundantConfigFiles)
+ + \count($redundantCachePaths);
+ $this->hasRedundancies = $this->totalItems > 0;
+ }
+
+ public function hasPackages(): bool
+ {
+ return [] !== $this->redundantPackages;
+ }
+
+ public function hasConfigFiles(): bool
+ {
+ return [] !== $this->redundantConfigFiles;
+ }
+
+ public function hasCachePaths(): bool
+ {
+ return [] !== $this->redundantCachePaths;
+ }
+
+ /** Remove redundant config files and cache paths from disk. */
+ public function removeFiles(): int
+ {
+ $removed = 0;
+
+ foreach ([...$this->redundantConfigFiles, ...$this->redundantCachePaths] as $relative) {
+ $fullPath = $this->projectRoot . \DIRECTORY_SEPARATOR . $relative;
+
+ if (is_dir($fullPath)) {
+ $this->removeRecursive($fullPath);
+ ++$removed;
+ } elseif (is_file($fullPath)) {
+ unlink($fullPath);
+ ++$removed;
+ }
+ }
+
+ return $removed;
+ }
+
+ /**
+ * Remove redundant packages from composer.json require-dev.
+ *
+ * Rewrites composer.json in place preserving JSON formatting.
+ *
+ * @return list Package names actually removed.
+ */
+ public function removePackagesFromComposer(): array
+ {
+ $composerPath = $this->projectRoot . \DIRECTORY_SEPARATOR . 'composer.json';
+
+ if (! is_file($composerPath)) {
+ return [];
+ }
+
+ $raw = file_get_contents($composerPath);
+
+ if (false === $raw) {
+ return [];
+ }
+
+ /** @var array $composer */
+ $composer = json_decode($raw, true, 512, \JSON_THROW_ON_ERROR);
+
+ $removed = [];
+
+ /** @var array $requireDev */
+ $requireDev = \is_array($composer['require-dev'] ?? null) ? $composer['require-dev'] : [];
+
+ foreach (array_keys($this->redundantPackages) as $package) {
+ if (isset($requireDev[$package])) {
+ unset($requireDev[$package]);
+ $removed[] = $package;
+ }
+ }
+
+ if ([] === $removed) {
+ return [];
+ }
+
+ // Write back the updated require-dev (or remove the key if empty)
+ if ([] === $requireDev) {
+ unset($composer['require-dev']);
+ } else {
+ $composer['require-dev'] = $requireDev;
+ }
+
+ // Detect indentation: 4-space (default) or tab
+ $jsonFlags = \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE;
+ $encoded = json_encode($composer, $jsonFlags);
+
+ if (false === $encoded) {
+ return [];
+ }
+
+ // Re-apply tab indentation if original used tabs
+ if (str_contains($raw, "\t")) {
+ $encoded = str_replace(' ', "\t", $encoded);
+ }
+
+ file_put_contents(
+ $composerPath,
+ $encoded . \PHP_EOL,
+ );
+
+ return $removed;
+ }
+
+ private function removeRecursive(string $dir): void
+ {
+ $items = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST,
+ );
+
+ foreach ($items as $item) {
+ /** @var \SplFileInfo $item */
+ $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
+ }
+
+ rmdir($dir);
+ }
+}
diff --git a/src/ValueObject/QualityReport.php b/src/ValueObject/QualityReport.php
new file mode 100644
index 0000000..f290ec4
--- /dev/null
+++ b/src/ValueObject/QualityReport.php
@@ -0,0 +1,43 @@
+ $results */
+ public function __construct(
+ public array $results,
+ ) {
+ $this->passed = array_all($results, static fn (ToolResult $r): bool => $r->success);
+ $this->totalSeconds = array_sum(array_map(
+ static fn (ToolResult $r): float => $r->elapsedSeconds,
+ $results,
+ ));
+ $this->failureCount = \count(array_filter(
+ $results,
+ static fn (ToolResult $r): bool => ! $r->success,
+ ));
+ }
+
+ /** @return list */
+ public function failures(): array
+ {
+ return array_values(array_filter(
+ $this->results,
+ static fn (ToolResult $r): bool => ! $r->success,
+ ));
+ }
+}
diff --git a/src/ValueObject/ToolResult.php b/src/ValueObject/ToolResult.php
new file mode 100644
index 0000000..b90b9fe
--- /dev/null
+++ b/src/ValueObject/ToolResult.php
@@ -0,0 +1,32 @@
+success = 0 === $exitCode;
+ }
+
+ public function output(): string
+ {
+ $combined = trim($this->stdout . "\n" . $this->stderr);
+
+ return '' !== $combined ? $combined : '(no output)';
+ }
+}
diff --git a/tests/Integration/UserProfileFlowTest.php b/tests/Integration/UserProfileFlowTest.php
deleted file mode 100644
index 424c260..0000000
--- a/tests/Integration/UserProfileFlowTest.php
+++ /dev/null
@@ -1,91 +0,0 @@
- can edit.
- * 3. Activates 2FA.
- * 4. Verifies the final state and JSON serialization.
- */
- #[Test]
- public function itHandlesACompleteUserLifecycleFlow(): void
- {
- // --- 1) Simulate DB load (VIEWER + ACTIVE) ---
- $dbData = [
- 'id' => 'flow-user-001',
- 'name' => 'Utilizador de Fluxo',
- 'email' => 'flow@exemplo.com',
- 'role' => 'VIEWER',
- 'status' => 'ACTIVE',
- 'createdAt' => '2025-01-01T10:00:00+00:00',
- 'updatedAt' => '2025-01-01T10:00:00+00:00',
- ];
-
- $user = UserProfile::fromArray($dbData);
-
- // Initial assertions
- $this->assertSame(UserRole::VIEWER, $user->role);
- $this->assertSame(UserStatus::ACTIVE, $user->status);
- $this->assertFalse($user->has2FA());
- $this->assertFalse($user->canEdit(), 'Viewer ativo não deve poder editar.');
-
- // --- 2) Business logic: promote (VIEWER -> EDITOR). Now can edit. ---
- usleep(10);
- $promotedUser = $user->promote();
-
- $this->assertNotSame($user, $promotedUser); // immutable
- $this->assertSame(UserRole::VIEWER, $user->role); // original intact
- $this->assertSame(UserRole::EDITOR, $promotedUser->role);
- $this->assertSame(UserStatus::ACTIVE, $promotedUser->status);
- $this->assertTrue($promotedUser->canEdit(), 'Editor ativo deve poder editar.');
- $this->assertNotEquals($user->updatedAt, $promotedUser->updatedAt);
-
- // --- 3) Business logic: enable 2FA (still EDITOR + ACTIVE). ---
- usleep(10);
- $secureUser = $promotedUser->with2FA('segredo-super-secreto-123');
-
- $this->assertNotSame($promotedUser, $secureUser); // immutable
- $this->assertFalse($promotedUser->has2FA()); // previous intact
- $this->assertTrue($secureUser->has2FA());
- $this->assertSame(UserRole::EDITOR, $secureUser->role);
- $this->assertSame(UserStatus::ACTIVE, $secureUser->status);
- $this->assertTrue($secureUser->canEdit(), 'Editor ativo deve poder editar.');
- $this->assertNotEquals($promotedUser->updatedAt, $secureUser->updatedAt);
-
- // --- 4) Final state + serialization checks ---
- $finalJson = $secureUser->jsonSerialize();
-
- $this->assertSame('UserProfile', $finalJson['model']);
- $this->assertSame('flow-user-001', $finalJson['id']);
- $this->assertSame('Utilizador de Fluxo', $finalJson['name']);
- $this->assertSame('EDITOR', $finalJson['role']);
- $this->assertSame('ACTIVE', $finalJson['status']);
- $this->assertTrue($finalJson['has2FA']);
- $this->assertSame('2025-01-01T10:00:00+00:00', $finalJson['createdAt']);
-
- // updatedAt must be newer than createdAt
- $this->assertGreaterThan(
- new DateTimeImmutable($finalJson['createdAt']),
- new DateTimeImmutable($finalJson['updatedAt']),
- );
- }
-}
diff --git a/tests/Unit/Core/DevkitConfigTest.php b/tests/Unit/Core/DevkitConfigTest.php
new file mode 100644
index 0000000..23ea761
--- /dev/null
+++ b/tests/Unit/Core/DevkitConfigTest.php
@@ -0,0 +1,131 @@
+tmpDir = sys_get_temp_dir() . '/devkit_config_test_' . uniqid();
+ mkdir($this->tmpDir, 0777, true);
+ }
+
+ protected function tearDown(): void
+ {
+ // Clean up devkit.php if left
+ $configPath = $this->tmpDir . '/devkit.php';
+ if (file_exists($configPath)) {
+ unlink($configPath);
+ }
+ if (is_dir($this->tmpDir)) {
+ rmdir($this->tmpDir);
+ }
+ }
+
+ #[Test]
+ public function withoutConfigFileOverridesAreEmpty(): void
+ {
+ $config = new DevkitConfig($this->tmpDir);
+
+ $this->assertFalse($config->hasOverrides());
+ $this->assertSame([], $config->overrides);
+ }
+
+ #[Test]
+ public function getReturnsDefaultWhenKeyNotSet(): void
+ {
+ $config = new DevkitConfig($this->tmpDir);
+
+ $this->assertSame(9, $config->get('phpstan_level', 9));
+ $this->assertSame('8.4', $config->get('php_version', '8.4'));
+ }
+
+ #[Test]
+ public function withValidConfigFileOverridesAreLoaded(): void
+ {
+ file_put_contents(
+ $this->tmpDir . '/devkit.php',
+ " 5, 'php_version' => '8.3'];",
+ );
+
+ $config = new DevkitConfig($this->tmpDir);
+
+ $this->assertTrue($config->hasOverrides());
+ $this->assertSame(5, $config->get('phpstan_level', 9));
+ $this->assertSame('8.3', $config->get('php_version', '8.4'));
+ }
+
+ #[Test]
+ public function getReturnsDefaultForUnknownKeyEvenWithConfigFile(): void
+ {
+ file_put_contents(
+ $this->tmpDir . '/devkit.php',
+ " 5];",
+ );
+
+ $config = new DevkitConfig($this->tmpDir);
+
+ $this->assertSame(3, $config->get('psalm_level', 3));
+ }
+
+ #[Test]
+ public function getThrowsConfigurationExceptionOnTypeMismatch(): void
+ {
+ file_put_contents(
+ $this->tmpDir . '/devkit.php',
+ " 'nine'];", // string instead of int
+ );
+
+ $config = new DevkitConfig($this->tmpDir);
+
+ $this->expectException(ConfigurationException::class);
+ $config->get('phpstan_level', 9); // expects int, got string
+ }
+
+ #[Test]
+ public function invalidConfigFileThrowsConfigurationException(): void
+ {
+ file_put_contents(
+ $this->tmpDir . '/devkit.php',
+ "expectException(ConfigurationException::class);
+ new DevkitConfig($this->tmpDir);
+ }
+
+ #[Test]
+ public function toolVersionsReturnsEmptyArrayWhenNotConfigured(): void
+ {
+ $config = new DevkitConfig($this->tmpDir);
+
+ $this->assertSame([], $config->toolVersions());
+ }
+
+ #[Test]
+ public function toolVersionsReturnsToolsArrayFromConfig(): void
+ {
+ file_put_contents(
+ $this->tmpDir . '/devkit.php',
+ " ['phpunit' => '^11.0', 'phpstan' => '^2.0']];",
+ );
+
+ $config = new DevkitConfig($this->tmpDir);
+
+ $this->assertSame(
+ ['phpunit' => '^11.0', 'phpstan' => '^2.0'],
+ $config->toolVersions(),
+ );
+ }
+}
diff --git a/tests/Unit/Core/ProjectContextTest.php b/tests/Unit/Core/ProjectContextTest.php
new file mode 100644
index 0000000..74ec259
--- /dev/null
+++ b/tests/Unit/Core/ProjectContextTest.php
@@ -0,0 +1,102 @@
+root = '/var/www/my-project';
+ $this->context = new ProjectContext(
+ projectRoot: $this->root,
+ projectName: 'kariricode/parser',
+ namespace: 'KaririCode\\Parser',
+ phpVersion: '8.4',
+ phpstanLevel: 9,
+ psalmLevel: 3,
+ sourceDirs: [$this->root . '/src'],
+ testDirs: [$this->root . '/tests'],
+ excludeDirs: ['src/Contract'],
+ testSuites: ['Unit' => 'tests/Unit'],
+ coverageExclude: ['src/Exception'],
+ csFixerRules: ['@PSR12' => true],
+ rectorSets: ['LevelSetList::UP_TO_PHP_84'],
+ toolVersions: [],
+ );
+ }
+
+ #[Test]
+ public function devkitDirIsComposedCorrectly(): void
+ {
+ $this->assertSame($this->root . DIRECTORY_SEPARATOR . '.kcode', $this->context->devkitDir);
+ }
+
+ #[Test]
+ public function buildDirIsInsideDevkitDir(): void
+ {
+ $expected = $this->root . DIRECTORY_SEPARATOR . '.kcode' . DIRECTORY_SEPARATOR . 'build';
+ $this->assertSame($expected, $this->context->buildDir);
+ }
+
+ #[Test]
+ public function configPathReturnsAbsolutePathInsideDevkitDir(): void
+ {
+ $path = $this->context->configPath('phpunit.xml');
+ $this->assertSame($this->root . '/.kcode/phpunit.xml', $path);
+ }
+
+ #[Test]
+ public function buildPathWithFilenameReturnsFullPath(): void
+ {
+ $path = $this->context->buildPath('kcode.phar');
+ $this->assertStringEndsWith('kcode.phar', $path);
+ $this->assertStringContainsString('.kcode/build', $path);
+ }
+
+ #[Test]
+ public function buildPathWithoutFilenameReturnsBuildDir(): void
+ {
+ $path = $this->context->buildPath();
+ $this->assertSame($this->context->buildDir, $path);
+ }
+
+ #[Test]
+ public function relativeSourceDirsReturnProjectRelativePaths(): void
+ {
+ $relative = $this->context->relativeSourceDirs();
+ $this->assertSame(['src'], $relative);
+ }
+
+ #[Test]
+ public function relativeTestDirsReturnProjectRelativePaths(): void
+ {
+ $relative = $this->context->relativeTestDirs();
+ $this->assertSame(['tests'], $relative);
+ }
+
+ #[Test]
+ public function relativizeStripsProjectRootPrefix(): void
+ {
+ $absolute = $this->root . '/src/Core/Devkit.php';
+ $relative = $this->context->relativize($absolute);
+ $this->assertSame('src/Core/Devkit.php', $relative);
+ }
+
+ #[Test]
+ public function relativizeReturnsPathUnchangedWhenNotUnderRoot(): void
+ {
+ $external = '/some/other/path/file.php';
+ $this->assertSame($external, $this->context->relativize($external));
+ }
+}
diff --git a/tests/Unit/EnumsTest.php b/tests/Unit/EnumsTest.php
deleted file mode 100644
index 89d0848..0000000
--- a/tests/Unit/EnumsTest.php
+++ /dev/null
@@ -1,65 +0,0 @@
-
- */
- public static function roleEditProvider(): array
- {
- return [
- 'Admin pode editar' => [UserRole::ADMIN, true],
- 'Editor pode editar' => [UserRole::EDITOR, true],
- 'Viewer não pode editar' => [UserRole::VIEWER, false],
- ];
- }
-
- /**
- * Tests the edit permission logic.
- */
- #[Test]
- #[DataProvider('roleEditProvider')]
- public function userRoleCanEdit(UserRole $role, bool $expectedResult): void
- {
- $this->assertSame($expectedResult, $role->canEdit());
- }
-
- /**
- * Tests the labels of the roles.
- */
- #[Test]
- public function userRoleLabels(): void
- {
- $this->assertSame('admin', UserRole::ADMIN->label());
- $this->assertSame('editor', UserRole::EDITOR->label());
- $this->assertSame('viewer', UserRole::VIEWER->label());
- }
-
- /**
- * Tests the 'active' status logic.
- */
- #[Test]
- public function userStatusIsActive(): void
- {
- $this->assertTrue(UserStatus::ACTIVE->isActive());
- $this->assertFalse(UserStatus::SUSPENDED->isActive());
- }
-}
diff --git a/tests/Unit/Exception/ConfigurationExceptionTest.php b/tests/Unit/Exception/ConfigurationExceptionTest.php
new file mode 100644
index 0000000..7ae1044
--- /dev/null
+++ b/tests/Unit/Exception/ConfigurationExceptionTest.php
@@ -0,0 +1,46 @@
+assertInstanceOf(ConfigurationException::class, $ex);
+ $this->assertStringContainsString($key, $ex->getMessage());
+ $this->assertStringContainsString($reason, $ex->getMessage());
+ }
+
+ #[Test]
+ public function fileNotReadableContainsPathInMessage(): void
+ {
+ $path = '/etc/shadow';
+ $ex = ConfigurationException::fileNotReadable($path);
+
+ $this->assertInstanceOf(ConfigurationException::class, $ex);
+ $this->assertStringContainsString($path, $ex->getMessage());
+ }
+
+ #[Test]
+ public function exceptionExtendsDevkitException(): void
+ {
+ $ex = ConfigurationException::invalidOverride('key', 'reason');
+
+ $this->assertInstanceOf(DevkitException::class, $ex);
+ }
+}
diff --git a/tests/Unit/Exception/DevkitExceptionTest.php b/tests/Unit/Exception/DevkitExceptionTest.php
new file mode 100644
index 0000000..b3ff102
--- /dev/null
+++ b/tests/Unit/Exception/DevkitExceptionTest.php
@@ -0,0 +1,43 @@
+assertInstanceOf(DevkitException::class, $ex);
+ $this->assertStringContainsString($path, $ex->getMessage());
+ $this->assertStringContainsString('composer.json', $ex->getMessage());
+ }
+
+ #[Test]
+ public function directoryNotWritableContainsPathInMessage(): void
+ {
+ $path = '/some/readonly/dir';
+ $ex = DevkitException::directoryNotWritable($path);
+
+ $this->assertInstanceOf(DevkitException::class, $ex);
+ $this->assertStringContainsString($path, $ex->getMessage());
+ }
+
+ #[Test]
+ public function exceptionExtendsRuntimeException(): void
+ {
+ $ex = DevkitException::projectNotDetected('/foo');
+
+ $this->assertInstanceOf(\RuntimeException::class, $ex);
+ }
+}
diff --git a/tests/Unit/Exception/ToolExceptionTest.php b/tests/Unit/Exception/ToolExceptionTest.php
new file mode 100644
index 0000000..eb74bae
--- /dev/null
+++ b/tests/Unit/Exception/ToolExceptionTest.php
@@ -0,0 +1,53 @@
+assertInstanceOf(ToolException::class, $ex);
+ $this->assertStringContainsString('phpunit', $ex->getMessage());
+ }
+
+ #[Test]
+ public function executionFailedContainsToolNameExitCodeAndOutput(): void
+ {
+ $ex = ToolException::executionFailed('phpstan', 1, 'Analysis failed');
+
+ $this->assertInstanceOf(ToolException::class, $ex);
+ $this->assertStringContainsString('phpstan', $ex->getMessage());
+ $this->assertStringContainsString('1', $ex->getMessage());
+ $this->assertStringContainsString('Analysis failed', $ex->getMessage());
+ $this->assertSame(1, $ex->getCode());
+ }
+
+ #[Test]
+ public function executionFailedWithEmptyOutputUsesNoOutputPlaceholder(): void
+ {
+ $ex = ToolException::executionFailed('rector', 2, '');
+
+ $this->assertStringContainsString('(no output)', $ex->getMessage());
+ $this->assertSame(2, $ex->getCode());
+ }
+
+ #[Test]
+ public function exceptionExtendsDevkitException(): void
+ {
+ $ex = ToolException::binaryNotFound('phpunit');
+
+ $this->assertInstanceOf(DevkitException::class, $ex);
+ }
+}
diff --git a/tests/Unit/UserProfileTest.php b/tests/Unit/UserProfileTest.php
deleted file mode 100644
index 95d808e..0000000
--- a/tests/Unit/UserProfileTest.php
+++ /dev/null
@@ -1,239 +0,0 @@
-assertInstanceOf(UserProfile::class, $user);
- $this->assertSame('Utilizador Teste', $user->name);
- $this->assertSame('teste@exemplo.com', (string) $user->email);
- $this->assertSame(UserRole::EDITOR, $user->role); // Verifies the role
- $this->assertSame(UserStatus::ACTIVE, $user->status); // Verifies the default status
- $this->assertNotNull($user->id);
- $this->assertNotNull($user->createdAt);
- $this->assertNotNull($user->updatedAt);
- $this->assertNull($user->twoFactorSecret);
- }
-
- /**
- * Tests creation from an array.
- */
- #[Test]
- public function itHydratesFromArray(): void
- {
- $expectedCreated = new DateTimeImmutable('2025-10-22T22:03:20+00:00');
- $data = [
- 'id' => 'user-123',
- 'name' => 'Nome do Array',
- 'email' => 'array@exemplo.com',
- 'role' => 'ADMIN',
- 'status' => 'SUSPENDED',
- 'twoFactorSecret' => 'segredo123',
- 'meta' => ['key' => 'value'],
- 'createdAt' => $expectedCreated->format('c'),
- ];
-
- $user = UserProfile::fromArray($data);
-
- $this->assertSame('user-123', $user->id);
- $this->assertSame('Nome do Array', $user->name);
- $this->assertSame('array@exemplo.com', (string) $user->email);
- $this->assertSame(UserRole::ADMIN, $user->role);
- $this->assertSame(UserStatus::SUSPENDED, $user->status);
- $this->assertSame('segredo123', $user->twoFactorSecret);
- $this->assertSame(['key' => 'value'], $user->meta);
- $this->assertSame(
- $expectedCreated->format('Y-m-d\TH:i:sP'),
- $user->createdAt?->format('Y-m-d\TH:i:sP'),
- );
-
- $this->assertNull($user->updatedAt);
- }
-
- /**
- * Tests default values when hydrating with minimal data.
- */
- #[Test]
- public function itHydratesFromArrayWithDefaults(): void
- {
- $data = [
- 'name' => 'Utilizador Mínimo',
- 'email' => 'minimo@exemplo.com',
- ];
-
- $user = UserProfile::fromArray($data);
-
- $this->assertNotNull($user->id); // ID is generated
- $this->assertSame('Utilizador Mínimo', $user->name);
- $this->assertSame(UserRole::VIEWER, $user->role); // Default role
- $this->assertSame(UserStatus::ACTIVE, $user->status); // Default status
- $this->assertNull($user->createdAt);
- }
-
- /**
- * Tests if 'fromArray' fails with an invalid email.
- */
- #[Test]
- public function itFailsHydrationWithInvalidEmail(): void
- {
- $data = [
- 'name' => 'Utilizador Falhado',
- 'email' => 'email-invalido',
- ];
-
- // The exception comes from the Email constructor
- $this->expectException(InvalidArgumentException::class);
- UserProfile::fromArray($data);
- }
-
- /**
- * Tests 'with*' methods to ensure immutability.
- * The UserProfile class is 'readonly', but 'with*' methods
- * create NEW instances.
- */
- #[Test]
- public function itIsImmutableAndCreatesNewInstances(): void
- {
- $user1 = UserProfile::new('Original', 'original@exemplo.com');
- $user1CreatedAt = $user1->createdAt;
-
- // A microsecond delay is needed to ensure the timestamp changes
- usleep(10);
-
- // 1. Promote
- $user2 = $user1->promote();
- $this->assertNotSame($user1, $user2); // They are different objects
- $this->assertSame(UserRole::VIEWER, $user1->role); // Original does not change
- $this->assertSame(UserRole::EDITOR, $user2->role); // New object changes
- $this->assertNotEquals($user1->updatedAt, $user2->updatedAt);
- $this->assertEquals($user1CreatedAt, $user2->createdAt); // createdAt is maintained
-
- // 2. Suspend
- $user3 = $user2->suspend();
- $this->assertNotSame($user2, $user3);
- $this->assertSame(UserStatus::ACTIVE, $user2->status); // Previous does not change
- $this->assertSame(UserStatus::SUSPENDED, $user3->status); // New object changes
-
- // 3. Add 2FA
- $user4 = $user3->with2FA('segredo');
- $this->assertNotSame($user3, $user4);
- $this->assertNull($user3->twoFactorSecret); // Previous does not change
- $this->assertSame('segredo', $user4->twoFactorSecret);
- $this->assertTrue($user4->has2FA());
-
- // 4. Add Meta
- $user5 = $user4->withMeta(['foo' => 'bar']);
- $this->assertNotSame($user4, $user5);
- $this->assertNull($user4->meta); // Previous does not change
- $this->assertSame(['foo' => 'bar'], $user5->meta);
- }
-
- /**
- * Testa a lógica de promoção (não deve promover quem já é admin).
- */
- #[Test]
- public function itPromotesCorrectly(): void
- {
- $viewer = UserProfile::new('Viewer', 'v@v.com', UserRole::VIEWER);
- $editor = UserProfile::new('Editor', 'e@e.com', UserRole::EDITOR);
- $admin = UserProfile::new('Admin', 'a@a.com', UserRole::ADMIN);
-
- // Viewer is promoted to Editor
- $this->assertSame(UserRole::EDITOR, $viewer->promote()->role);
- // Editor remains Editor
- $this->assertSame(UserRole::EDITOR, $editor->promote()->role);
- // Admin remains Admin
- $this->assertSame(UserRole::ADMIN, $admin->promote()->role);
- }
-
- /**
- * Provides data for the 'canEdit' test.
- */
- public static function editPermissionProvider(): array
- {
- return [
- // Role, Status, Expected
- 'Admin Ativo' => [UserRole::ADMIN, UserStatus::ACTIVE, true],
- 'Editor Ativo' => [UserRole::EDITOR, UserStatus::ACTIVE, true],
- 'Viewer Ativo' => [UserRole::VIEWER, UserStatus::ACTIVE, false],
- 'Admin Suspenso' => [UserRole::ADMIN, UserStatus::SUSPENDED, false],
- 'Editor Suspenso' => [UserRole::EDITOR, UserStatus::SUSPENDED, false],
- 'Viewer Suspenso' => [UserRole::VIEWER, UserStatus::SUSPENDED, false],
- ];
- }
-
- /**
- * Tests the 'canEdit' logic (combination of Role and Status).
- */
- #[Test]
- #[DataProvider('editPermissionProvider')]
- public function itChecksEditPermissions(UserRole $role, UserStatus $status, bool $expected): void
- {
- // We use fromArray to "force" the status
- $user = UserProfile::fromArray([
- 'name' => 'Teste Permissão',
- 'email' => 'p@p.com',
- 'role' => $role,
- 'status' => $status,
- ]);
-
- $this->assertSame($expected, $user->canEdit());
- }
-
- /**
- * Tests serialization outputs.
- */
- #[Test]
- public function itSerializesCorrectly(): void
- {
- $user = UserProfile::new('Serializar', 's@s.com');
- $id = $user->id;
-
- // Test __toString
- $this->assertSame("UserProfile#{$id}", (string) $user);
-
- // Teste displayLabel
- $this->assertSame('Serializar (viewer)', $user->displayLabel());
-
- // Teste jsonSerialize
- $json = $user->jsonSerialize();
-
- $this->assertSame('UserProfile', $json['model']);
- $this->assertSame(1, $json['version']);
- $this->assertSame($id, $json['id']);
- $this->assertSame('Serializar', $json['name']);
- $this->assertSame('s@s.com', $json['email']);
- $this->assertSame('VIEWER', $json['role']);
- $this->assertSame('ACTIVE', $json['status']);
- $this->assertFalse($json['has2FA']);
- $this->assertNotNull($json['createdAt']);
- }
-}
diff --git a/tests/Unit/ValueObject/MigrationReportTest.php b/tests/Unit/ValueObject/MigrationReportTest.php
new file mode 100644
index 0000000..5d6f541
--- /dev/null
+++ b/tests/Unit/ValueObject/MigrationReportTest.php
@@ -0,0 +1,88 @@
+assertFalse($report->hasRedundancies);
+ $this->assertSame(0, $report->totalItems);
+ $this->assertFalse($report->hasPackages());
+ $this->assertFalse($report->hasConfigFiles());
+ $this->assertFalse($report->hasCachePaths());
+ }
+
+ #[Test]
+ public function hasRedundanciesIsTrueWhenThereAreRedundantPackages(): void
+ {
+ $report = new MigrationReport(
+ projectRoot: '/app',
+ redundantPackages: ['phpunit/phpunit' => '^11.0'],
+ redundantConfigFiles: [],
+ redundantCachePaths: [],
+ );
+
+ $this->assertTrue($report->hasRedundancies);
+ $this->assertSame(1, $report->totalItems);
+ $this->assertTrue($report->hasPackages());
+ $this->assertFalse($report->hasConfigFiles());
+ }
+
+ #[Test]
+ public function totalItemsCountsAllCategories(): void
+ {
+ $report = new MigrationReport(
+ projectRoot: '/app',
+ redundantPackages: ['phpstan/phpstan' => '^2.0', 'vimeo/psalm' => '^6.0'],
+ redundantConfigFiles: ['phpstan.neon', 'phpcs.xml'],
+ redundantCachePaths: ['.phpunit.cache'],
+ );
+
+ $this->assertSame(5, $report->totalItems);
+ $this->assertTrue($report->hasRedundancies);
+ $this->assertTrue($report->hasPackages());
+ $this->assertTrue($report->hasConfigFiles());
+ $this->assertTrue($report->hasCachePaths());
+ }
+
+ #[Test]
+ public function removeFilesDeletesExistingFiles(): void
+ {
+ $tmpDir = sys_get_temp_dir() . '/devkit_test_' . uniqid();
+ mkdir($tmpDir, 0777, true);
+
+ $fileToRemove = $tmpDir . '/phpstan.neon';
+ file_put_contents($fileToRemove, 'parameters:');
+
+ $report = new MigrationReport(
+ projectRoot: $tmpDir,
+ redundantPackages: [],
+ redundantConfigFiles: ['phpstan.neon'],
+ redundantCachePaths: [],
+ );
+
+ $removed = $report->removeFiles();
+
+ $this->assertSame(1, $removed);
+ $this->assertFileDoesNotExist($fileToRemove);
+
+ rmdir($tmpDir);
+ }
+}
diff --git a/tests/Unit/ValueObject/QualityReportTest.php b/tests/Unit/ValueObject/QualityReportTest.php
new file mode 100644
index 0000000..40f5ed2
--- /dev/null
+++ b/tests/Unit/ValueObject/QualityReportTest.php
@@ -0,0 +1,89 @@
+makeResult('phpunit', 0, 1.0),
+ $this->makeResult('phpstan', 0, 0.5),
+ ]);
+
+ $this->assertTrue($report->passed);
+ $this->assertSame(0, $report->failureCount);
+ }
+
+ #[Test]
+ public function passedIsFalseWhenAtLeastOneToolFails(): void
+ {
+ $report = new QualityReport([
+ $this->makeResult('phpunit', 0, 1.0),
+ $this->makeResult('phpstan', 1, 0.5),
+ ]);
+
+ $this->assertFalse($report->passed);
+ $this->assertSame(1, $report->failureCount);
+ }
+
+ #[Test]
+ public function totalSecondsIsTheSumOfAllElapsedTimes(): void
+ {
+ $report = new QualityReport([
+ $this->makeResult('phpunit', 0, 1.5),
+ $this->makeResult('phpstan', 0, 0.7),
+ $this->makeResult('psalm', 0, 0.3),
+ ]);
+
+ $this->assertEqualsWithDelta(2.5, $report->totalSeconds, 0.001);
+ }
+
+ #[Test]
+ public function failuresReturnsOnlyFailedResults(): void
+ {
+ $passing = $this->makeResult('phpunit', 0);
+ $failing1 = $this->makeResult('phpstan', 1);
+ $failing2 = $this->makeResult('psalm', 2);
+
+ $report = new QualityReport([$passing, $failing1, $failing2]);
+
+ $failures = $report->failures();
+
+ $this->assertCount(2, $failures);
+ $this->assertSame($failing1, $failures[0]);
+ $this->assertSame($failing2, $failures[1]);
+ }
+
+ #[Test]
+ public function emptyReportPassesWithZeroTotals(): void
+ {
+ $report = new QualityReport([]);
+
+ $this->assertTrue($report->passed);
+ $this->assertSame(0, $report->failureCount);
+ $this->assertSame(0.0, $report->totalSeconds);
+ $this->assertEmpty($report->failures());
+ }
+}
diff --git a/tests/Unit/ValueObject/ToolResultTest.php b/tests/Unit/ValueObject/ToolResultTest.php
new file mode 100644
index 0000000..4567af4
--- /dev/null
+++ b/tests/Unit/ValueObject/ToolResultTest.php
@@ -0,0 +1,89 @@
+assertTrue($result->success);
+ $this->assertSame('phpunit', $result->toolName);
+ $this->assertSame(0, $result->exitCode);
+ $this->assertSame(1.234, $result->elapsedSeconds);
+ }
+
+ #[Test]
+ public function successIsFalseWhenExitCodeIsNonZero(): void
+ {
+ $result = new ToolResult(
+ toolName: 'phpstan',
+ exitCode: 1,
+ stdout: '',
+ stderr: 'Found 3 errors',
+ elapsedSeconds: 0.5,
+ );
+
+ $this->assertFalse($result->success);
+ }
+
+ #[Test]
+ public function outputCombinesStdoutAndStderr(): void
+ {
+ $result = new ToolResult(
+ toolName: 'phpstan',
+ exitCode: 1,
+ stdout: 'Line 1',
+ stderr: 'Error here',
+ elapsedSeconds: 0.1,
+ );
+
+ $output = $result->output();
+ $this->assertStringContainsString('Line 1', $output);
+ $this->assertStringContainsString('Error here', $output);
+ }
+
+ #[Test]
+ public function outputReturnsPlaceholderWhenBothStreamsAreEmpty(): void
+ {
+ $result = new ToolResult(
+ toolName: 'rector',
+ exitCode: 0,
+ stdout: '',
+ stderr: '',
+ elapsedSeconds: 0.0,
+ );
+
+ $this->assertSame('(no output)', $result->output());
+ }
+
+ #[Test]
+ public function outputTrimsWhitespace(): void
+ {
+ $result = new ToolResult(
+ toolName: 'psalm',
+ exitCode: 0,
+ stdout: " passed \n",
+ stderr: ' ',
+ elapsedSeconds: 0.2,
+ );
+
+ $this->assertSame('passed', $result->output());
+ }
+}
diff --git a/tests/Unit/ValueObjectsTest.php b/tests/Unit/ValueObjectsTest.php
deleted file mode 100644
index 5f7ae1b..0000000
--- a/tests/Unit/ValueObjectsTest.php
+++ /dev/null
@@ -1,74 +0,0 @@
-assertSame('teste@exemplo.com', $email->value);
- // Verify string casting and JSON serialization
- $this->assertSame('teste@exemplo.com', (string) $email);
- $this->assertSame('teste@exemplo.com', $email->jsonSerialize());
- }
-
- #[Test]
- public function emailThrowsExceptionForInvalidValue(): void
- {
- // Expect an InvalidArgumentException to be thrown for an invalid email format
- $this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('Invalid email: email-invalido');
-
- // Attempt to create an Email object with an invalid string, which should trigger the exception
- new Email('email-invalido');
- }
-
- /**
- * Tests that a UserId object is successfully created with a valid, non-empty ID string.
- * It also verifies the __toString() and jsonSerialize() methods.
- */
- #[Test]
- public function userIdIsCreatedWithValidValue(): void
- {
- $id = new UserId('id-12345');
- $this->assertSame('id-12345', $id->value);
- // Verify string casting and JSON serialization
- $this->assertSame('id-12345', (string) $id);
- $this->assertSame('id-12345', $id->jsonSerialize());
- }
-
- /**
- * Tests that a UserId object throws an exception when initialized with an empty string.
- */
- #[Test]
- public function userIdThrowsExceptionForEmptyValue(): void
- {
- // Expect an InvalidArgumentException to be thrown for an empty user ID
- $this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('User ID cannot be empty.');
-
- new UserId(''); // Isto deve falhar
- }
-}