From 30fe7a4090e424c6fac8d72c9dcd7d5802f1905a Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Thu, 30 Apr 2026 11:13:03 -0400 Subject: [PATCH 01/19] feat(php): consolidated PHP tooling (php, artisan, phpunit, phpstan, pest, paratest, ecs, pint) Consolidates the PHP-tooling work from three upstream PRs plus a new Pint module, leaving phpt to its own PR (#1503). - rtk php / rtk artisan: syntax check (-l) and Laravel artisan wrapper. - rtk phpunit: structured-state parser, aggregate counts, bounded failure list. Uses runner::run_filtered. - rtk phpstan: typed serde::Deserialize parser for --error-format=json, groups errors by file, sorts by count desc. Utility commands (--version, list, clear-result-cache) pass through unchanged. - rtk pest / rtk paratest: shared test_output helper. - rtk ecs / rtk pint: code-style fixers; pint uses --format=json for structured per-file rule counts. Composer custom-bin-dir detection: composer_bin_dirs() reads COMPOSER_BIN_DIR and composer.json config.bin-dir, so tools/bin/phpunit classifies identically to vendor/bin/phpunit. registry.rs normalizes tool paths before matching. Sources: - rtk-ai/rtk#1246 (aaronflorey, self-closed): php, artisan, ecs, pest, paratest, test_output, utils, composer_bin_dirs, registry normalization. - rtk-ai/rtk#874 (Beninho, open): phpunit state-machine parser. - rtk-ai/rtk#1110 (LucianoVandi, open): phpstan typed parser. - New: pint_cmd.rs. Tests: discover::registry 253 pass; cmds::php 36 pass; cargo build --release 0 errors; cargo fmt --check clean. --- src/cmds/mod.rs | 1 + src/cmds/php/README.md | 15 + src/cmds/php/artisan_cmd.rs | 54 ++++ src/cmds/php/ecs_cmd.rs | 81 ++++++ src/cmds/php/mod.rs | 1 + src/cmds/php/paratest_cmd.rs | 31 +++ src/cmds/php/pest_cmd.rs | 45 +++ src/cmds/php/php_cmd.rs | 114 ++++++++ src/cmds/php/phpstan_cmd.rs | 467 ++++++++++++++++++++++++++++++++ src/cmds/php/phpunit_cmd.rs | 337 +++++++++++++++++++++++ src/cmds/php/pint_cmd.rs | 204 ++++++++++++++ src/cmds/php/test_output.rs | 84 ++++++ src/cmds/php/utils.rs | 59 ++++ src/core/utils.rs | 76 ++++++ src/discover/registry.rs | 216 ++++++++++++++- src/discover/rules.rs | 84 ++++++ src/main.rs | 71 +++++ tests/fixtures/phpstan_raw.json | 380 ++++++++++++++++++++++++++ 18 files changed, 2319 insertions(+), 1 deletion(-) create mode 100644 src/cmds/php/README.md create mode 100644 src/cmds/php/artisan_cmd.rs create mode 100644 src/cmds/php/ecs_cmd.rs create mode 100644 src/cmds/php/mod.rs create mode 100644 src/cmds/php/paratest_cmd.rs create mode 100644 src/cmds/php/pest_cmd.rs create mode 100644 src/cmds/php/php_cmd.rs create mode 100644 src/cmds/php/phpstan_cmd.rs create mode 100644 src/cmds/php/phpunit_cmd.rs create mode 100644 src/cmds/php/pint_cmd.rs create mode 100644 src/cmds/php/test_output.rs create mode 100644 src/cmds/php/utils.rs create mode 100644 tests/fixtures/phpstan_raw.json diff --git a/src/cmds/mod.rs b/src/cmds/mod.rs index d064182b3d..ce1443c2db 100644 --- a/src/cmds/mod.rs +++ b/src/cmds/mod.rs @@ -6,6 +6,7 @@ pub mod git; pub mod go; pub mod js; pub mod jvm; +pub mod php; pub mod python; pub mod ruby; pub mod rust; diff --git a/src/cmds/php/README.md b/src/cmds/php/README.md new file mode 100644 index 0000000000..003de14204 --- /dev/null +++ b/src/cmds/php/README.md @@ -0,0 +1,15 @@ +# PHP + +> Part of [`src/cmds/`](../README.md) — see also [docs/contributing/TECHNICAL.md](../../../docs/contributing/TECHNICAL.md) + +## Specifics + +- `php_cmd.rs` summarizes `php -l` syntax checks and routes `php artisan*` to specialized helpers +- `artisan_cmd.rs` cleans Artisan output and applies runner-aware filtering for `php artisan test` +- `phpunit_cmd.rs` strips progress/header noise and keeps failure details + final summary +- `phpstan_cmd.rs` injects JSON output by default and emits compact file/line error summaries +- `ecs_cmd.rs` condenses EasyCodingStandard output while preserving file paths and error lines +- `pest_cmd.rs` runs Pest with compact progress suppression and test-focused output +- `paratest_cmd.rs` runs ParaTest with compact progress suppression and test-focused output +- `test_output.rs` provides shared PHPUnit/Pest/ParaTest output filtering logic +- `utils.rs` resolves local `vendor/bin/*` tools first, then falls back to global binaries diff --git a/src/cmds/php/artisan_cmd.rs b/src/cmds/php/artisan_cmd.rs new file mode 100644 index 0000000000..88543515d1 --- /dev/null +++ b/src/cmds/php/artisan_cmd.rs @@ -0,0 +1,54 @@ +//! Laravel Artisan output cleanup helpers. + +use super::test_output::filter_test_runner_output; +use super::utils::{strip_ansi_and_controls, PhpTestRunner}; +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + static ref BOX_CHARS_RE: Regex = + Regex::new(r"[\u{2500}-\u{257F}\u{2580}-\u{259F}\u{25A0}-\u{25FF}\u{27A0}-\u{27BF}]+") + .unwrap(); + static ref DOTS_RE: Regex = Regex::new(r"\.{3,}").unwrap(); + static ref MULTI_SPACE_RE: Regex = Regex::new(r"[ \t]{2,}").unwrap(); + static ref MULTI_BLANK_RE: Regex = Regex::new(r"\n{3,}").unwrap(); +} + +pub fn filter_artisan_output(output: &str) -> String { + let mut cleaned = strip_ansi_and_controls(output); + cleaned = BOX_CHARS_RE.replace_all(&cleaned, "").to_string(); + cleaned = DOTS_RE.replace_all(&cleaned, "..").to_string(); + cleaned = MULTI_SPACE_RE.replace_all(&cleaned, " ").to_string(); + cleaned = MULTI_BLANK_RE.replace_all(&cleaned, "\n\n").to_string(); + cleaned.trim().to_string() +} + +pub fn filter_artisan_test_output(output: &str, runner: PhpTestRunner) -> String { + match runner { + PhpTestRunner::Pest | PhpTestRunner::Phpunit => filter_test_runner_output(output), + PhpTestRunner::Unknown => filter_artisan_output(output), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_artisan_cleanup() { + let out = + "\u{1b}[32mEnvironment .....\u{1b}[0m\n\u{2502} Laravel Version \u{2502} 13.0.0 \u{2502}\n\n\n"; + let filtered = filter_artisan_output(out); + assert!(!filtered.contains('\u{1b}')); + assert!(!filtered.contains('\u{2502}')); + assert!(filtered.contains("Environment ..")); + } + + #[test] + fn test_artisan_test_prefers_runner_filter() { + let output = "PHPUnit 12.2.0\n....\nOK (4 tests, 4 assertions)\n"; + let filtered = filter_artisan_test_output(output, PhpTestRunner::Phpunit); + assert!(!filtered.contains("PHPUnit 12.2.0")); + assert!(filtered.contains("OK (4 tests, 4 assertions)")); + } +} diff --git a/src/cmds/php/ecs_cmd.rs b/src/cmds/php/ecs_cmd.rs new file mode 100644 index 0000000000..ea81f71291 --- /dev/null +++ b/src/cmds/php/ecs_cmd.rs @@ -0,0 +1,81 @@ +//! EasyCodingStandard output filter. + +use super::utils::{php_tool_command, strip_ansi_and_controls}; +use crate::core::runner; +use anyhow::Result; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = php_tool_command("ecs"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: ecs {}", args.join(" ")); + } + + runner::run_filtered( + cmd, + "ecs", + &args.join(" "), + filter_ecs_output, + runner::RunOptions::default(), + ) +} + +fn filter_ecs_output(output: &str) -> String { + let cleaned = strip_ansi_and_controls(output); + if cleaned.contains("No errors found") { + return "✓ ecs: no issues".to_string(); + } + + let mut lines = Vec::new(); + for line in cleaned.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + if trimmed.contains(".php") + || trimmed.contains("ERROR") + || trimmed.contains("FAIL") + || trimmed.contains("Fixed") + || trimmed.contains("checked") + || trimmed.contains("files") + { + lines.push(trimmed.to_string()); + } + } + + if lines.is_empty() { + let fallback = cleaned.trim(); + if fallback.is_empty() { + "ok".to_string() + } else { + fallback.to_string() + } + } else { + lines.join("\n") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ecs_success_output() { + assert_eq!( + filter_ecs_output("[OK] No errors found. Great job!"), + "✓ ecs: no issues" + ); + } + + #[test] + fn test_ecs_keeps_file_errors() { + let output = "src/Foo.php\n 10 | ERROR | Something bad\n"; + let filtered = filter_ecs_output(output); + assert!(filtered.contains("src/Foo.php")); + assert!(filtered.contains("ERROR")); + } +} diff --git a/src/cmds/php/mod.rs b/src/cmds/php/mod.rs new file mode 100644 index 0000000000..90c027f68a --- /dev/null +++ b/src/cmds/php/mod.rs @@ -0,0 +1 @@ +automod::dir!(pub "src/cmds/php"); diff --git a/src/cmds/php/paratest_cmd.rs b/src/cmds/php/paratest_cmd.rs new file mode 100644 index 0000000000..3585f3ffac --- /dev/null +++ b/src/cmds/php/paratest_cmd.rs @@ -0,0 +1,31 @@ +//! ParaTest runner filter. + +use super::test_output::filter_test_runner_output; +use super::utils::php_tool_command; +use crate::core::runner; +use anyhow::Result; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = php_tool_command("paratest"); + + let has_no_progress = args.iter().any(|a| a == "--no-progress"); + if !has_no_progress { + cmd.arg("--no-progress"); + } + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: paratest {}", args.join(" ")); + } + + runner::run_filtered( + cmd, + "paratest", + &args.join(" "), + filter_test_runner_output, + runner::RunOptions::default(), + ) +} diff --git a/src/cmds/php/pest_cmd.rs b/src/cmds/php/pest_cmd.rs new file mode 100644 index 0000000000..559beb1676 --- /dev/null +++ b/src/cmds/php/pest_cmd.rs @@ -0,0 +1,45 @@ +//! Pest test runner filter. + +use super::test_output::filter_test_runner_output; +use super::utils::php_tool_command; +use crate::core::runner; +use anyhow::Result; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = php_tool_command("pest"); + + let has_no_progress = args.iter().any(|a| a == "--no-progress"); + if !has_no_progress { + cmd.arg("--no-progress"); + } + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: pest {}", args.join(" ")); + } + + runner::run_filtered( + cmd, + "pest", + &args.join(" "), + filter_test_runner_output, + runner::RunOptions::default(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pest_filters_progress_noise() { + let output = "Pest 5.0.0\n.....\nPASS Tests\\Unit\\ExampleTest\n"; + let filtered = filter_test_runner_output(output); + assert!(!filtered.contains("Pest 5.0.0")); + assert!(!filtered.contains(".....")); + assert!(filtered.contains("PASS Tests\\Unit\\ExampleTest")); + } +} diff --git a/src/cmds/php/php_cmd.rs b/src/cmds/php/php_cmd.rs new file mode 100644 index 0000000000..3bbca99427 --- /dev/null +++ b/src/cmds/php/php_cmd.rs @@ -0,0 +1,114 @@ +//! PHP command filter: syntax-check summaries and generic cleanup. + +use super::artisan_cmd::{filter_artisan_output, filter_artisan_test_output}; +use super::utils::{detect_php_test_runner, strip_ansi_and_controls, PhpTestRunner}; +use crate::core::runner; +use crate::core::utils::resolved_command; +use anyhow::Result; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = resolved_command("php"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: php {}", args.join(" ")); + } + + let is_artisan = args.first().map(String::as_str) == Some("artisan"); + let is_artisan_test = is_artisan && args.get(1).map(String::as_str) == Some("test"); + let is_lint = args.iter().any(|a| a == "-l" || a == "--syntax-check"); + let detected_runner = if is_artisan_test { + detect_php_test_runner() + } else { + PhpTestRunner::Unknown + }; + + if verbose > 0 && is_artisan_test { + eprintln!("Detected artisan test runner: {:?}", detected_runner); + } + + runner::run_filtered( + cmd, + "php", + &args.join(" "), + move |raw| { + if is_lint { + return filter_php_lint_output(raw); + } + if is_artisan_test { + return filter_artisan_test_output(raw, detected_runner); + } + if is_artisan { + return filter_artisan_output(raw); + } + filter_php_output(raw) + }, + runner::RunOptions::default(), + ) +} + +fn filter_php_lint_output(output: &str) -> String { + let mut lines = Vec::new(); + + for line in strip_ansi_and_controls(output).lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + if let Some(file) = trimmed.strip_prefix("No syntax errors detected in ") { + lines.push(format!("ok {}", file.trim())); + continue; + } + + if trimmed.starts_with("Errors parsing ") + || trimmed.contains("Parse error") + || trimmed.contains("Fatal error") + || trimmed.contains("syntax error") + { + lines.push(trimmed.to_string()); + } + } + + if lines.is_empty() { + let fallback = output.trim(); + if fallback.is_empty() { + "ok".to_string() + } else { + fallback.to_string() + } + } else { + lines.join("\n") + } +} + +fn filter_php_output(output: &str) -> String { + let cleaned = strip_ansi_and_controls(output); + let trimmed = cleaned.trim(); + if trimmed.is_empty() { + "ok".to_string() + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_php_lint_summary() { + let out = "No syntax errors detected in app/Http/Controller.php\n"; + assert_eq!(filter_php_lint_output(out), "ok app/Http/Controller.php"); + } + + #[test] + fn test_php_lint_error_preserved() { + let out = "Errors parsing app/Foo.php\nParse error: syntax error, unexpected ')' in app/Foo.php on line 10\n"; + let filtered = filter_php_lint_output(out); + assert!(filtered.contains("Errors parsing app/Foo.php")); + assert!(filtered.contains("Parse error")); + } +} diff --git a/src/cmds/php/phpstan_cmd.rs b/src/cmds/php/phpstan_cmd.rs new file mode 100644 index 0000000000..56fd56fbad --- /dev/null +++ b/src/cmds/php/phpstan_cmd.rs @@ -0,0 +1,467 @@ +//! PHPStan static analysis filter. +//! +//! Injects `--error-format=json` for structured output, parses errors grouped by +//! file and sorted by error count. Falls back to text parsing when the user +//! specifies a custom format or when injected JSON output fails to parse. + +use crate::core::runner; +use crate::core::utils::{exit_code_from_status, resolved_command}; +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::Path; + +// ── JSON structures matching PHPStan's --error-format=json output ─────────── + +#[derive(Deserialize)] +struct PhpstanOutput { + totals: PhpstanTotals, + files: HashMap, + #[serde(default)] + errors: Vec, +} + +#[derive(Deserialize)] +struct PhpstanTotals { + errors: usize, + #[allow(dead_code)] + file_errors: usize, +} + +#[derive(Deserialize)] +struct PhpstanFile { + errors: usize, + messages: Vec, +} + +#[derive(Deserialize)] +struct PhpstanMessage { + message: String, + line: usize, + #[serde(default)] + #[allow(dead_code)] + ignorable: bool, +} + +// ── Public entry point ─────────────────────────────────────────────────────── + +pub fn run(args: &[String], verbose: u8) -> Result { + // Check for vendor/bin/phpstan first + let mut cmd = if Path::new("vendor/bin/phpstan").exists() { + resolved_command("vendor/bin/phpstan") + } else { + resolved_command("phpstan") + }; + + // Utility commands (--version, list, clear-result-cache, worker, …): real passthrough. + // Only analyse/analyze subcommands get filtered and token-tracked. + let is_analyse = args + .first() + .map(|a| a == "analyse" || a == "analyze") + .unwrap_or(false); + + if !is_analyse { + if verbose > 0 { + eprintln!("Running: phpstan {} (passthrough)", args.join(" ")); + } + cmd.args(args); + let status = cmd.status().context("Failed to run phpstan")?; + return Ok(exit_code_from_status(&status, "phpstan")); + } + + // Detect if user specified a custom output format (not json). + // Handles both `--error-format=table` and `--error-format table` forms. + let has_custom_format = { + let mut it = args.iter().peekable(); + let mut found = false; + while let Some(a) = it.next() { + if a == "--error-format" { + if it.peek().map(|v| v.as_str()) != Some("json") { + found = true; + } + break; + } + if a.starts_with("--error-format=") && a != "--error-format=json" { + found = true; + break; + } + } + found + }; + + // Pass user args first (subcommand must come before global flags for PHPStan), + // then append --error-format=json unless the user specified a custom format. + cmd.args(args); + if !has_custom_format { + cmd.arg("--error-format").arg("json"); + } + + if verbose > 0 { + eprintln!("Running: phpstan {}", args.join(" ")); + } + + runner::run_filtered( + cmd, + "phpstan", + &args.join(" "), + move |stdout| { + if has_custom_format { + filter_phpstan_text(stdout) + } else { + filter_phpstan_json(stdout) + } + }, + runner::RunOptions::stdout_only().tee("phpstan"), + ) +} + +// ── JSON filtering ─────────────────────────────────────────────────────────── + +fn filter_phpstan_json(output: &str) -> String { + if output.trim().is_empty() { + return "PHPStan: No output".to_string(); + } + + let parsed: Result = serde_json::from_str(output); + let phpstan = match parsed { + Ok(p) => p, + Err(e) => { + eprintln!("[rtk] phpstan: JSON parse failed ({})", e); + return crate::core::utils::fallback_tail(output, "phpstan (JSON parse error)", 5); + } + }; + + // No errors case + if phpstan.totals.errors == 0 { + return "phpstan: ok".to_string(); + } + + let mut result = format!( + "phpstan: {} errors in {} files\n", + phpstan.totals.errors, + phpstan.files.len() + ); + + // Add global errors first if any + if !phpstan.errors.is_empty() { + result.push_str("\nGlobal errors:\n"); + for error in &phpstan.errors { + result.push_str(&format!(" {}\n", error)); + } + result.push('\n'); + } + + // Build list of files with errors, sorted by error count descending + let mut files_vec: Vec<(&String, &PhpstanFile)> = phpstan.files.iter().collect(); + files_vec.sort_by(|a, b| b.1.errors.cmp(&a.1.errors).then(a.0.cmp(b.0))); + + let max_files = 10; + let max_messages_per_file = 5; + + for (path, file) in files_vec.iter().take(max_files) { + let short = compact_php_path(path); + result.push_str(&format!("\n{} ({} errors)\n", short, file.errors)); + + for message in file.messages.iter().take(max_messages_per_file) { + let first_line = message.message.lines().next().unwrap_or(""); + result.push_str(&format!(" :{} {}\n", message.line, first_line)); + } + + if file.messages.len() > max_messages_per_file { + result.push_str(&format!( + " ... +{} more\n", + file.messages.len() - max_messages_per_file + )); + } + } + + if files_vec.len() > max_files { + result.push_str(&format!( + "\n... +{} more files\n", + files_vec.len() - max_files + )); + } + + result.trim().to_string() +} + +// ── Text fallback ──────────────────────────────────────────────────────────── + +fn filter_phpstan_text(output: &str) -> String { + // Check for errors first + for line in output.lines() { + let t = line.trim(); + if t.contains("cannot load such file") + || t.contains("not found") + || t.starts_with("phpstan: command not found") + || t.starts_with("phpstan: No such file") + { + let error_lines: Vec<&str> = output.trim().lines().take(20).collect(); + let truncated = error_lines.join("\n"); + let total_lines = output.trim().lines().count(); + if total_lines > 20 { + return format!( + "PHPStan error:\n{}\n... ({} more lines)", + truncated, + total_lines - 20 + ); + } + return format!("PHPStan error:\n{}", truncated); + } + } + + // Extract summary if present + for line in output.lines().rev() { + let t = line.trim(); + if t.contains("[OK]") || t.contains("No errors") { + return "phpstan: ok".to_string(); + } + if t.contains("errors") && (t.contains("found") || t.contains("in")) { + return format!("PHPStan: {}", t); + } + } + + // Last resort: last 20 lines + crate::core::utils::fallback_tail(output, "phpstan", 20) +} + +/// Compact PHP file path by finding the nearest conventional directory +/// and stripping the absolute path prefix. +fn compact_php_path(path: &str) -> String { + let path = path.replace('\\', "/"); + + for prefix in &[ + "app/Models/", + "app/Http/Controllers/", + "app/Http/Middleware/", + "app/Services/", + "app/Repositories/", + "src/", + "tests/", + "config/", + "database/", + ] { + if let Some(pos) = path.find(prefix) { + return path[pos..].to_string(); + } + } + + // Generic: strip up to last known directory marker + if let Some(pos) = path.rfind("/app/") { + return path[pos + 1..].to_string(); + } + if let Some(pos) = path.rfind("/src/") { + return path[pos + 1..].to_string(); + } + // Keep last 2 path components to preserve context (dir/File.php) + if let Some(pos) = path.rfind('/') { + if let Some(prev) = path[..pos].rfind('/') { + return path[prev + 1..].to_string(); + } + return path[pos + 1..].to_string(); + } + path +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::utils::count_tokens; + + fn no_errors_json() -> &'static str { + r#"{ + "totals": {"errors": 0, "file_errors": 0}, + "files": {}, + "errors": [] + }"# + } + + fn with_errors_json() -> &'static str { + r#"{ + "totals": {"errors": 5, "file_errors": 5}, + "files": { + "app/Models/User.php": { + "errors": 2, + "messages": [ + {"message": "Property $id does not accept null.", "line": 10, "ignorable": true}, + {"message": "Call to undefined method Model::find().", "line": 25, "ignorable": false} + ] + }, + "app/Http/Controllers/UserController.php": { + "errors": 2, + "messages": [ + {"message": "Parameter $id of anonymous function has no typehint.", "line": 45, "ignorable": false}, + {"message": "Variable $user might not be defined.", "line": 67, "ignorable": false} + ] + }, + "app/Services/AuthService.php": { + "errors": 1, + "messages": [ + {"message": "Return type missing.", "line": 12, "ignorable": false} + ] + } + }, + "errors": [] + }"# + } + + fn large_json_for_truncation() -> String { + let mut files = HashMap::new(); + + // Create 12 files with varying error counts + for i in 1..=12 { + let filename = format!("app/Models/Model{}.php", i); + let error_count = if i <= 3 { 10 } else { i % 5 + 1 }; + + let mut messages = Vec::new(); + for j in 1..=error_count { + messages.push(format!( + r#"{{"message": "Error {} in file {}", "line": {}, "ignorable": false}}"#, + j, i, j * 10 + )); + } + + files.insert( + filename, + format!( + r#"{{"errors": {}, "messages": [{}]}}"#, + error_count, + messages.join(",") + ), + ); + } + + let files_json: Vec = files + .iter() + .map(|(k, v)| format!(r#""{}": {}"#, k, v)) + .collect(); + + format!( + r#"{{"totals": {{"errors": 50, "file_errors": 50}}, "files": {{{}}}, "errors": []}}"#, + files_json.join(",") + ) + } + + #[test] + fn test_filter_phpstan_json_no_errors() { + let result = filter_phpstan_json(no_errors_json()); + assert_eq!(result, "phpstan: ok"); + } + + #[test] + fn test_filter_phpstan_json_with_errors() { + let result = filter_phpstan_json(with_errors_json()); + + // Check summary line + assert!(result.contains("5 errors in 3 files")); + + // Check file names are present + assert!(result.contains("app/Models/User.php")); + assert!(result.contains("app/Http/Controllers/UserController.php")); + assert!(result.contains("app/Services/AuthService.php")); + + // Check line numbers and messages + assert!(result.contains(":10 Property $id does not accept null.")); + assert!(result.contains(":25 Call to undefined method Model::find().")); + assert!(result.contains(":45 Parameter $id of anonymous function has no typehint.")); + } + + #[test] + fn test_filter_phpstan_json_truncation() { + let result = filter_phpstan_json(&large_json_for_truncation()); + + // Should show max 10 files + assert!(result.contains("+2 more files")); + + // Should not show all 12 files inline + let file_count = result.matches("app/Models/Model").count(); + assert_eq!(file_count, 10, "Should show exactly 10 files"); + } + + #[test] + fn test_filter_phpstan_token_savings() { + // Use the realistic fixture with many files, long paths, and JSON metadata + // to verify the ≥75% savings claim in rules.rs + let input = include_str!("../../../tests/fixtures/phpstan_raw.json"); + let output = filter_phpstan_json(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 60.0, + "PHPStan: expected ≥60% savings, got {:.1}% (in={}, out={})", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_filter_phpstan_empty_input() { + let result = filter_phpstan_json(""); + assert_eq!(result, "PHPStan: No output"); + } + + #[test] + fn test_filter_phpstan_malformed_json() { + let garbage = "some php warning\n{broken json"; + let result = filter_phpstan_json(garbage); + assert!(!result.is_empty(), "should not panic on invalid JSON"); + } + + #[test] + fn test_compact_php_path() { + assert_eq!( + compact_php_path("/var/www/project/app/Models/User.php"), + "app/Models/User.php" + ); + assert_eq!( + compact_php_path("app/Http/Controllers/UserController.php"), + "app/Http/Controllers/UserController.php" + ); + assert_eq!( + compact_php_path("/home/user/project/src/Service.php"), + "src/Service.php" + ); + assert_eq!( + compact_php_path("tests/Unit/UserTest.php"), + "tests/Unit/UserTest.php" + ); + } + + #[test] + fn test_filter_phpstan_text_fallback() { + let text = r#"PHPStan analysis complete +[OK] No errors found"#; + let result = filter_phpstan_text(text); + assert_eq!(result, "phpstan: ok"); + } + + #[test] + fn test_filter_phpstan_text_with_errors() { + let text = r#"PHPStan analysis complete + +Found 5 errors in 3 files"#; + let result = filter_phpstan_text(text); + assert!(result.starts_with("PHPStan:"), "should have PHPStan: prefix"); + assert!(result.contains("5 errors"), "should contain error count"); + assert!(result.contains("3 files"), "should contain file count"); + } + + #[test] + fn test_filter_phpstan_fixture_structure() { + // Verify output structure on the realistic fixture (14 files, 47 errors) + let input = include_str!("../../../tests/fixtures/phpstan_raw.json"); + let output = filter_phpstan_json(input); + + assert!(output.contains("47 errors in 14 files")); + // Files are sorted by error count descending — User.php has 6, comes first + assert!(output.contains("app/Models/User.php (6 errors)")); + // 14 files → only 10 shown, 4 more + assert!(output.contains("+4 more files")); + } +} diff --git a/src/cmds/php/phpunit_cmd.rs b/src/cmds/php/phpunit_cmd.rs new file mode 100644 index 0000000000..8706cf0cc2 --- /dev/null +++ b/src/cmds/php/phpunit_cmd.rs @@ -0,0 +1,337 @@ +//! PHPUnit output filter. +//! +//! Parses PHPUnit's plain-text runner output and emits a compact summary: +//! aggregate counts from the `Tests: X, Assertions: Y, Failures: Z.` line +//! plus a bounded list of failures with their first two detail lines. +//! Dot-progress lines and headers are stripped entirely. + +use super::utils::php_tool_command; +use crate::core::runner; +use anyhow::Result; + +const MAX_FAILURES_SHOWN: usize = 10; +const MAX_DETAIL_LINES_PER_FAILURE: usize = 2; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = php_tool_command("phpunit"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: phpunit {}", args.join(" ")); + } + + runner::run_filtered( + cmd, + "phpunit", + &args.join(" "), + filter_phpunit_output, + runner::RunOptions::stdout_only().tee("phpunit"), + ) +} + +pub(crate) fn filter_phpunit_output(output: &str) -> String { + let mut failures: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + let mut in_failures = false; + + for line in output.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with("OK (") { + return format!("PHPUnit: {}", trimmed); + } + + if trimmed.starts_with("OK, but") { + return build_success_with_skipped(output); + } + + if (trimmed.starts_with("There was") || trimmed.starts_with("There were")) + && (trimmed.contains("failure") || trimmed.contains("error")) + { + in_failures = true; + continue; + } + + if trimmed == "FAILURES!" || trimmed == "ERRORS!" { + if !current.is_empty() { + failures.push(std::mem::take(&mut current)); + } + in_failures = false; + continue; + } + + if in_failures { + if is_numbered_failure_heading(trimmed) { + if !current.is_empty() { + failures.push(std::mem::take(&mut current)); + } + current.push(trimmed.to_string()); + } else if !trimmed.is_empty() { + current.push(trimmed.to_string()); + } + } + } + + if !current.is_empty() { + failures.push(current); + } + + if failures.is_empty() { + let (tests, assertions, _, _) = parse_counts(output); + if tests > 0 { + return format!("PHPUnit: {} tests, {} assertions", tests, assertions); + } + return "PHPUnit: ok".to_string(); + } + + build_phpunit_summary(output, &failures) +} + +fn is_numbered_failure_heading(line: &str) -> bool { + // PHPUnit formats each failure as "N) Class::method" + let mut chars = line.chars(); + let first_digit = chars.next().is_some_and(|c| c.is_ascii_digit()); + first_digit && line.contains(')') +} + +fn build_success_with_skipped(output: &str) -> String { + let (tests, assertions, _, skipped) = parse_counts(output); + if skipped > 0 { + format!( + "PHPUnit: {} tests, {} assertions, {} skipped", + tests, assertions, skipped + ) + } else { + format!("PHPUnit: {} tests, {} assertions", tests, assertions) + } +} + +fn build_phpunit_summary(output: &str, failures: &[Vec]) -> String { + let (tests, assertions, failures_count, _skipped) = parse_counts(output); + + let mut result = format!( + "PHPUnit: {} tests, {} assertions, {} failures\n", + tests, assertions, failures_count + ); + + for failure_lines in failures.iter().take(MAX_FAILURES_SHOWN) { + if let Some(first) = failure_lines.first() { + result.push_str(&format!("\n{}\n", first)); + } + for detail in failure_lines + .iter() + .skip(1) + .take(MAX_DETAIL_LINES_PER_FAILURE) + { + result.push_str(&format!(" {}\n", detail)); + } + } + + if failures.len() > MAX_FAILURES_SHOWN { + result.push_str(&format!( + "\n... +{} more failures\n", + failures.len() - MAX_FAILURES_SHOWN + )); + } + + result.trim().to_string() +} + +fn parse_counts(output: &str) -> (usize, usize, usize, usize) { + let mut tests = 0; + let mut assertions = 0; + let mut failures = 0; + let mut skipped = 0; + + for line in output.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with("Tests:") { + continue; + } + + for part in trimmed.split(',') { + let mut it = part.split_whitespace(); + let key = it.next().unwrap_or(""); + let val = it + .next() + .unwrap_or("") + .trim_end_matches('.') + .parse() + .unwrap_or(0); + + match key { + "Tests:" => tests = val, + "Assertions:" => assertions = val, + k if k.starts_with("Failures") || k.starts_with("Errors") => failures += val, + k if k.starts_with("Skipped") => skipped = val, + _ => {} + } + } + } + + (tests, assertions, failures, skipped) +} + +#[cfg(test)] +mod tests { + use super::*; + + const REAL_PHPUNIT_FAILURE: &str = r#"PHPUnit 10.5.0 by Sebastian Bergmann and contributors. + +Runtime: PHP 8.2.27 with Xdebug 3.3.1 +Configuration: /var/www/html/phpunit.xml + +........................................ 40 / 40 (100%) +.................................................. 80 / 80 (100%) +.F................................................ 100 / 100 (100%) +.......... 110 / 110 (100%) + +Time: 00:01:23.456, Memory: 48.00 MB + +There was 1 failure: + +1) App\Tests\UserTest::testEmailValidation +Failed asserting that false is true. + +#0 /var/www/html/src/User.php:142 (App\User::validate) +#1 /var/www/html/tests/UserTest.php:38 (App\Tests\UserTest::testEmailValidation) + +FAILURES! +Tests: 110, Assertions: 340, Failures: 1."#; + + const REAL_PHPUNIT_SUCCESS: &str = r#"PHPUnit 10.5.0 by Sebastian Bergmann and contributors. + +Runtime: PHP 8.2.0 + +......... 9 / 9 (100%) + +Time: 00:00:00.234, Memory: 6.00 MB + +OK (9 tests, 20 assertions)"#; + + const REAL_PHPUNIT_MULTIPLE_FAILURES: &str = r#"PHPUnit 10.5.0 by Sebastian Bergmann and contributors. + +FF....... 9 / 9 (100%) + +Time: 00:00:00.234, Memory: 6.00 MB + +There were 2 failures: + +1) UserTest::testEmail +Failed asserting that false is true. + +/home/user/tests/UserTest.php:42 + +2) OrderTest::testTotal +Failed asserting that 42 matches expected 100. + +/home/user/tests/OrderTest.php:17 + +FAILURES! +Tests: 9, Assertions: 15, Failures: 2."#; + + #[test] + fn test_phpunit_success() { + let result = filter_phpunit_output(REAL_PHPUNIT_SUCCESS); + assert!(result.contains("PHPUnit"), "got: {}", result); + assert!(result.contains("OK (9 tests, 20 assertions)"), "got: {}", result); + } + + #[test] + fn test_phpunit_failure_captures_test_name() { + let result = filter_phpunit_output(REAL_PHPUNIT_FAILURE); + assert!( + result.contains("UserTest::testEmailValidation"), + "got: {}", + result + ); + assert!( + result.contains("Failed asserting that false is true"), + "got: {}", + result + ); + } + + #[test] + fn test_phpunit_failure_summary_counts() { + let result = filter_phpunit_output(REAL_PHPUNIT_FAILURE); + assert!(result.contains("110 tests"), "got: {}", result); + assert!(result.contains("340 assertions"), "got: {}", result); + assert!(result.contains("1 failures"), "got: {}", result); + } + + #[test] + fn test_phpunit_multiple_failures() { + let result = filter_phpunit_output(REAL_PHPUNIT_MULTIPLE_FAILURES); + assert!(result.contains("UserTest::testEmail"), "got: {}", result); + assert!(result.contains("OrderTest::testTotal"), "got: {}", result); + assert!(result.contains("2 failures"), "got: {}", result); + } + + #[test] + fn test_phpunit_ok_with_skipped() { + let output = r#"OK, but incomplete, skipped, or risky tests! +Tests: 5, Assertions: 10, Skipped: 2."#; + let result = filter_phpunit_output(output); + assert!(result.contains("5 tests"), "got: {}", result); + assert!(result.contains("2 skipped"), "got: {}", result); + } + + #[test] + fn test_phpunit_errors_summary() { + let output = r#"There was 1 error: + +1) FooTest::testBar +RuntimeException: boom + +ERRORS! +Tests: 1, Assertions: 0, Errors: 1."#; + let result = filter_phpunit_output(output); + assert!(result.contains("FooTest::testBar"), "got: {}", result); + assert!(result.contains("1 failures"), "got: {}", result); + } + + #[test] + fn test_phpunit_failure_truncation() { + let mut output = String::from("There were 15 failures:\n\n"); + for i in 1..=15 { + output.push_str(&format!( + "{}) Suite::test{}\nFailed asserting thing {}.\n\n", + i, i, i + )); + } + output.push_str("FAILURES!\nTests: 15, Assertions: 15, Failures: 15.\n"); + + let result = filter_phpunit_output(&output); + assert!(result.contains("Suite::test1"), "got: {}", result); + assert!(result.contains("Suite::test10"), "got: {}", result); + assert!(!result.contains("Suite::test11"), "got: {}", result); + assert!(result.contains("+5 more failures"), "got: {}", result); + } + + #[test] + fn test_phpunit_empty_ok_fallback() { + let result = filter_phpunit_output(""); + assert_eq!(result, "PHPUnit: ok"); + } + + #[test] + fn test_phpunit_only_summary_line() { + let result = filter_phpunit_output("Tests: 4, Assertions: 4.\n"); + assert!(result.contains("4 tests"), "got: {}", result); + } + + #[test] + fn test_phpunit_compression() { + let raw_len = REAL_PHPUNIT_FAILURE.len(); + let filtered_len = filter_phpunit_output(REAL_PHPUNIT_FAILURE).len(); + assert!( + filtered_len < raw_len / 2, + "expected >50% reduction, raw={}, filtered={}", + raw_len, + filtered_len + ); + } +} diff --git a/src/cmds/php/pint_cmd.rs b/src/cmds/php/pint_cmd.rs new file mode 100644 index 0000000000..ff68ec471d --- /dev/null +++ b/src/cmds/php/pint_cmd.rs @@ -0,0 +1,204 @@ +//! Laravel Pint (PHP-CS-Fixer wrapper) output filter. +//! +//! Pint emits verbose per-rule progress and config chatter on its default +//! text output. It also supports `--format=json`, which gives a structured +//! list of files with their applied rules. We inject `--format=json` when +//! the user hasn't picked a format, parse it, and emit a compact summary +//! grouped by file and sorted by rule count. + +use super::utils::php_tool_command; +use crate::core::runner; +use crate::core::utils::fallback_tail; +use anyhow::Result; +use serde::Deserialize; + +const MAX_FILES_SHOWN: usize = 15; +const MAX_RULES_PER_FILE: usize = 5; + +#[derive(Deserialize)] +struct PintOutput { + #[serde(default)] + files: Vec, +} + +#[derive(Deserialize)] +struct PintFile { + name: String, + #[serde(rename = "appliedFixers")] + applied_fixers: Vec, +} + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = php_tool_command("pint"); + + let has_format = args + .iter() + .any(|a| a == "--format" || a.starts_with("--format=")); + let is_utility_cmd = args + .first() + .map(|a| matches!(a.as_str(), "--version" | "-V" | "--help" | "-h")) + .unwrap_or(false); + + if !has_format && !is_utility_cmd { + cmd.arg("--format=json"); + } + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: pint {}", args.join(" ")); + } + + let filter = move |stdout: &str| -> String { + if has_format || is_utility_cmd { + fallback_tail(stdout, "pint", 60) + } else { + filter_pint_json(stdout) + } + }; + + runner::run_filtered( + cmd, + "pint", + &args.join(" "), + filter, + runner::RunOptions::stdout_only().tee("pint"), + ) +} + +fn filter_pint_json(output: &str) -> String { + let trimmed = output.trim(); + if trimmed.is_empty() { + return "pint: ok".to_string(); + } + + let parsed: Result = serde_json::from_str(trimmed); + let pint = match parsed { + Ok(p) => p, + Err(_) => return fallback_tail(output, "pint (JSON parse error)", 20), + }; + + if pint.files.is_empty() { + return "pint: ok".to_string(); + } + + let total_files = pint.files.len(); + let total_rules: usize = pint.files.iter().map(|f| f.applied_fixers.len()).sum(); + + let mut files = pint.files; + files.sort_by(|a, b| { + b.applied_fixers + .len() + .cmp(&a.applied_fixers.len()) + .then(a.name.cmp(&b.name)) + }); + + let mut result = format!("pint: {} changes in {} files\n", total_rules, total_files); + + for file in files.iter().take(MAX_FILES_SHOWN) { + let name = short_path(&file.name); + result.push_str(&format!("\n{} ({})\n", name, file.applied_fixers.len())); + for rule in file.applied_fixers.iter().take(MAX_RULES_PER_FILE) { + result.push_str(&format!(" - {}\n", rule)); + } + if file.applied_fixers.len() > MAX_RULES_PER_FILE { + result.push_str(&format!( + " ... +{} more rules\n", + file.applied_fixers.len() - MAX_RULES_PER_FILE + )); + } + } + + if files.len() > MAX_FILES_SHOWN { + result.push_str(&format!( + "\n... +{} more files\n", + files.len() - MAX_FILES_SHOWN + )); + } + + result.trim().to_string() +} + +fn short_path(path: &str) -> String { + if let Ok(cwd) = std::env::current_dir() { + if let Ok(cwd_str) = cwd.into_os_string().into_string() { + let with_sep = format!("{}/", cwd_str); + if let Some(rest) = path.strip_prefix(&with_sep) { + return rest.to_string(); + } + } + } + path.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pint_empty_is_ok() { + assert_eq!(filter_pint_json(""), "pint: ok"); + } + + #[test] + fn test_pint_no_files_is_ok() { + assert_eq!(filter_pint_json(r#"{"files":[]}"#), "pint: ok"); + } + + #[test] + fn test_pint_single_file() { + let json = r#"{"files":[{"name":"app/Foo.php","appliedFixers":["no_unused_imports","ordered_imports"]}]}"#; + let result = filter_pint_json(json); + assert!(result.contains("2 changes in 1 files"), "got: {}", result); + assert!(result.contains("app/Foo.php (2)"), "got: {}", result); + assert!(result.contains("no_unused_imports"), "got: {}", result); + assert!(result.contains("ordered_imports"), "got: {}", result); + } + + #[test] + fn test_pint_sorted_by_count_desc() { + let json = r#"{"files":[ + {"name":"a.php","appliedFixers":["x"]}, + {"name":"b.php","appliedFixers":["x","y","z"]}, + {"name":"c.php","appliedFixers":["x","y"]} + ]}"#; + let result = filter_pint_json(json); + let pos_b = result.find("b.php").unwrap(); + let pos_c = result.find("c.php").unwrap(); + let pos_a = result.find("a.php").unwrap(); + assert!(pos_b < pos_c && pos_c < pos_a, "got: {}", result); + } + + #[test] + fn test_pint_file_truncation() { + let mut files = Vec::new(); + for i in 1..=20 { + files.push(format!( + r#"{{"name":"f{}.php","appliedFixers":["x"]}}"#, + i + )); + } + let json = format!(r#"{{"files":[{}]}}"#, files.join(",")); + let result = filter_pint_json(&json); + assert!(result.contains("20 changes in 20 files"), "got: {}", result); + assert!(result.contains("+5 more files"), "got: {}", result); + } + + #[test] + fn test_pint_rule_truncation() { + let json = r#"{"files":[{"name":"f.php","appliedFixers":["a","b","c","d","e","f","g"]}]}"#; + let result = filter_pint_json(json); + assert!(result.contains(" - a\n"), "got: {}", result); + assert!(result.contains(" - e\n"), "got: {}", result); + assert!(!result.contains(" - f\n"), "got: {}", result); + assert!(result.contains("+2 more rules"), "got: {}", result); + } + + #[test] + fn test_pint_invalid_json_falls_back() { + let result = filter_pint_json("Laravel Pint v1.13.6\n\n... some text ..."); + assert!(!result.contains("pint: ok"), "got: {}", result); + } +} diff --git a/src/cmds/php/test_output.rs b/src/cmds/php/test_output.rs new file mode 100644 index 0000000000..a77ab8961b --- /dev/null +++ b/src/cmds/php/test_output.rs @@ -0,0 +1,84 @@ +//! Shared compact output filtering for PHP test runners. + +use super::utils::strip_ansi_and_controls; + +fn is_progress_line(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.is_empty() { + return false; + } + + let has_dot = trimmed.contains('.'); + let progress_charset = trimmed.chars().all(|c| { + matches!( + c, + '.' | 'F' | 'E' | 'W' | 'R' | 'S' | 'I' | 'D' | 'N' | 'O' | 'K' | '0' + ..='9' | ' ' | '/' | '%' | '(' | ')' | '-' + ) + }); + + has_dot && progress_charset +} + +pub fn filter_test_runner_output(output: &str) -> String { + let mut lines = Vec::new(); + + for line in strip_ansi_and_controls(output).lines() { + let trimmed = line.trim_end(); + if trimmed.trim().is_empty() { + continue; + } + + if trimmed.starts_with("PHPUnit ") + || trimmed.starts_with("Pest ") + || trimmed.starts_with("ParaTest ") + || trimmed.starts_with("Runtime:") + || trimmed.starts_with("Configuration:") + || trimmed.starts_with("Random Seed:") + { + continue; + } + + if is_progress_line(trimmed) { + continue; + } + + lines.push(trimmed.to_string()); + } + + if lines.is_empty() { + return "ok".to_string(); + } + + if lines.len() > 120 { + let mut reduced = Vec::new(); + reduced.extend(lines.iter().take(80).cloned()); + reduced.push(format!("... +{} more lines", lines.len() - 120)); + reduced.extend(lines.iter().skip(lines.len() - 40).cloned()); + return reduced.join("\n"); + } + + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filters_phpunit_headers_and_progress() { + let output = "PHPUnit 12.2.0\n....\nOK (4 tests, 4 assertions)\n"; + let filtered = filter_test_runner_output(output); + assert!(!filtered.contains("PHPUnit 12.2.0")); + assert!(!filtered.contains("....")); + assert!(filtered.contains("OK (4 tests, 4 assertions)")); + } + + #[test] + fn test_keeps_failures() { + let output = "..F\nThere was 1 failure:\nFailed asserting true is false\n"; + let filtered = filter_test_runner_output(output); + assert!(filtered.contains("There was 1 failure")); + assert!(filtered.contains("Failed asserting true is false")); + } +} diff --git a/src/cmds/php/utils.rs b/src/cmds/php/utils.rs new file mode 100644 index 0000000000..dee8b05cfa --- /dev/null +++ b/src/cmds/php/utils.rs @@ -0,0 +1,59 @@ +use crate::core::utils::{composer_tool_paths, resolve_binary, resolved_command}; +use lazy_static::lazy_static; +use regex::Regex; +use std::path::Path; +use std::process::Command; + +lazy_static! { + static ref ANSI_RE: Regex = Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").unwrap(); + static ref CONTROL_RE: Regex = Regex::new(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]").unwrap(); +} + +pub fn php_tool_command(tool: &str) -> Command { + for local_tool in composer_tool_paths(tool) { + let local_tool_name = local_tool.to_string_lossy().into_owned(); + if let Ok(resolved_tool) = resolve_binary(&local_tool_name) { + return Command::new(resolved_tool); + } + + if local_tool.exists() { + return Command::new(local_tool); + } + } + + resolved_command(tool) +} + +fn composer_tool_exists(tool: &str) -> bool { + composer_tool_paths(tool).into_iter().any(|local_tool| { + let local_tool_name = local_tool.to_string_lossy().into_owned(); + resolve_binary(&local_tool_name).is_ok() || local_tool.exists() + }) +} + +pub fn strip_ansi_and_controls(input: &str) -> String { + let no_ansi = ANSI_RE.replace_all(input, ""); + CONTROL_RE.replace_all(&no_ansi, "").to_string() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PhpTestRunner { + Pest, + Phpunit, + Unknown, +} + +pub fn detect_php_test_runner() -> PhpTestRunner { + if composer_tool_exists("pest") || Path::new("pest.php").exists() { + return PhpTestRunner::Pest; + } + + if composer_tool_exists("phpunit") + || Path::new("phpunit.xml").exists() + || Path::new("phpunit.xml.dist").exists() + { + return PhpTestRunner::Phpunit; + } + + PhpTestRunner::Unknown +} diff --git a/src/core/utils.rs b/src/core/utils.rs index a3bc84fe00..12e37b757a 100644 --- a/src/core/utils.rs +++ b/src/core/utils.rs @@ -7,6 +7,8 @@ use anyhow::{Context, Result}; use regex::Regex; +use serde_json::Value; +use std::fs; use std::path::PathBuf; use std::process::Command; @@ -355,6 +357,54 @@ pub fn resolved_command(name: &str) -> Command { } } +/// Return Composer bin directories in precedence order. +/// +/// Composer allows overriding the default `vendor/bin` via `COMPOSER_BIN_DIR` +/// or `composer.json` `config.bin-dir`. Keep the default as a fallback so we +/// continue recognizing the common layout even when the repo is not configured. +pub fn composer_bin_dirs() -> Vec { + let env_bin_dir = std::env::var("COMPOSER_BIN_DIR").ok(); + let composer_json = fs::read_to_string("composer.json").ok(); + composer_bin_dirs_from(env_bin_dir.as_deref(), composer_json.as_deref()) +} + +pub fn composer_tool_paths(tool: &str) -> Vec { + composer_bin_dirs() + .into_iter() + .map(|dir| dir.join(tool)) + .collect() +} + +fn composer_bin_dirs_from(env_bin_dir: Option<&str>, composer_json: Option<&str>) -> Vec { + let mut dirs = Vec::new(); + + if let Some(dir) = env_bin_dir + .map(str::trim) + .filter(|dir| !dir.is_empty()) + .map(PathBuf::from) + .or_else(|| composer_json.and_then(read_composer_bin_dir)) + { + dirs.push(dir); + } + + let default_dir = PathBuf::from("vendor/bin"); + if !dirs.iter().any(|dir| dir == &default_dir) { + dirs.push(default_dir); + } + + dirs +} + +fn read_composer_bin_dir(composer_json: &str) -> Option { + let parsed: Value = serde_json::from_str(composer_json).ok()?; + let bin_dir = parsed.get("config")?.get("bin-dir")?.as_str()?.trim(); + if bin_dir.is_empty() { + None + } else { + Some(PathBuf::from(bin_dir)) + } +} + /// Check if a tool exists on PATH (PATHEXT-aware on Windows). /// /// Replaces manual `Command::new("which").arg(tool)` checks that fail on Windows. @@ -428,6 +478,32 @@ mod tests { assert_eq!(truncate("hello world", 3), "..."); } + #[test] + fn test_composer_bin_dirs_use_default_when_unconfigured() { + assert_eq!( + composer_bin_dirs_from(None, None), + vec![PathBuf::from("vendor/bin")] + ); + } + + #[test] + fn test_composer_bin_dirs_prefer_env_override() { + let composer_json = r#"{"config":{"bin-dir":"tools/bin"}}"#; + assert_eq!( + composer_bin_dirs_from(Some("custom/bin"), Some(composer_json)), + vec![PathBuf::from("custom/bin"), PathBuf::from("vendor/bin")] + ); + } + + #[test] + fn test_composer_bin_dirs_read_composer_config() { + let composer_json = r#"{"config":{"bin-dir":"tools/bin"}}"#; + assert_eq!( + composer_bin_dirs_from(None, Some(composer_json)), + vec![PathBuf::from("tools/bin"), PathBuf::from("vendor/bin")] + ); + } + #[test] fn test_strip_ansi_simple() { let input = "\x1b[31mError\x1b[0m"; diff --git a/src/discover/registry.rs b/src/discover/registry.rs index fc18b6be07..5283fc2643 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -1,11 +1,15 @@ //! Matches shell commands against known RTK rewrite rules to decide how to handle them. +use crate::core::utils::composer_bin_dirs; use lazy_static::lazy_static; use regex::{Regex, RegexSet}; +use std::path::Path; use super::lexer::{split_on_operators, tokenize, TokenKind}; use super::rules::{IGNORED_EXACT, IGNORED_PREFIXES, RULES}; +const PHP_TOOL_NAMES: [&str; 6] = ["phpunit", "phpstan", "ecs", "pest", "paratest", "pint"]; + /// Result of classifying a command. #[derive(Debug, PartialEq)] pub enum Classification { @@ -120,6 +124,9 @@ pub fn classify_command(cmd: &str) -> Classification { let cmd_normalized = strip_absolute_path(cmd_clean); // Strip git global options: git -C /tmp status → git status (#163) let cmd_normalized = strip_git_global_opts(&cmd_normalized); + // Normalize PHP tool paths: vendor/bin/phpunit, bin/phpunit, or composer + // custom bin-dir → phpunit (so one rule matches every Composer layout). + let cmd_normalized = normalize_php_tool_command(&cmd_normalized); // Strip golangci-lint global options before `run` so classify/rewrite stays // aligned with the runtime wrapper behavior. let cmd_normalized = strip_golangci_global_opts(&cmd_normalized); @@ -247,6 +254,70 @@ pub fn split_command_chain(cmd: &str) -> Vec<&str> { split_on_operators(trimmed, true) } +fn normalize_php_tool_command(cmd: &str) -> String { + normalize_php_tool_command_with_dirs(cmd, &composer_bin_dirs()) +} + +fn normalize_php_tool_command_with_dirs(cmd: &str, bin_dirs: &[std::path::PathBuf]) -> String { + let first_space = cmd.find(char::is_whitespace); + let first_word = match first_space { + Some(pos) => &cmd[..pos], + None => cmd, + }; + + let Some(tool) = normalize_php_tool_word(first_word, bin_dirs) else { + return cmd.to_string(); + }; + + match first_space { + Some(pos) => format!("{}{}", tool, &cmd[pos..]), + None => tool.to_string(), + } +} + +fn normalize_php_tool_word<'a>(word: &str, bin_dirs: &'a [std::path::PathBuf]) -> Option<&'a str> { + let normalized_word = normalize_php_tool_path(word); + + for tool in PHP_TOOL_NAMES { + if normalized_word == tool { + return Some(tool); + } + + if bin_dirs + .iter() + .any(|bin_dir| matches_php_tool_path(&normalized_word, bin_dir, tool)) + { + return Some(tool); + } + } + + None +} + +fn matches_php_tool_path(word: &str, bin_dir: &Path, tool: &str) -> bool { + let normalized_dir = normalize_php_tool_path(&bin_dir.to_string_lossy()); + let candidate = format!("{normalized_dir}/{tool}"); + word == candidate || word.ends_with(&format!("/{candidate}")) +} + +fn normalize_php_tool_path(path: &str) -> String { + let mut normalized = path.trim().replace('\\', "/"); + while let Some(stripped) = normalized.strip_prefix("./") { + normalized = stripped.to_string(); + } + + if let Some((stem, ext)) = normalized.rsplit_once('.') { + if ["bat", "cmd", "exe", "ps1"] + .iter() + .any(|candidate| ext.eq_ignore_ascii_case(candidate)) + { + normalized = stem.to_string(); + } + } + + normalized +} + /// Strip git global options before the subcommand (#163). /// `git -C /tmp status` → `git status`, preserving the rest. /// Returns the original string unchanged if not a git command. @@ -4094,7 +4165,7 @@ mod tests { ); } - // --- line-continuation handling (issue #1564) ------------------- + // --- line-continuation handling (issue #1564) --- #[test] fn test_rewrite_leading_backslash_newline() { @@ -4157,4 +4228,147 @@ mod tests { std::borrow::Cow::::Borrowed("git diff HEAD~1"), ); } + + // --- PHP tooling --- + + #[test] + fn test_classify_phpunit() { + assert!(matches!( + classify_command("phpunit tests/"), + Classification::Supported { + rtk_equivalent: "rtk phpunit", + .. + } + )); + } + + #[test] + fn test_classify_vendor_bin_phpunit() { + assert!(matches!( + classify_command("vendor/bin/phpunit --filter EmailTest"), + Classification::Supported { + rtk_equivalent: "rtk phpunit", + .. + } + )); + } + + #[test] + fn test_classify_php_vendor_bin_phpunit() { + assert!(matches!( + classify_command("php vendor/bin/phpunit tests/"), + Classification::Supported { + rtk_equivalent: "rtk phpunit", + .. + } + )); + } + + #[test] + fn test_rewrite_phpunit() { + assert_eq!( + rewrite_command("phpunit tests/", &[]), + Some("rtk phpunit tests/".into()) + ); + } + + #[test] + fn test_rewrite_vendor_bin_phpunit() { + assert_eq!( + rewrite_command("vendor/bin/phpunit --filter EmailTest", &[]), + Some("rtk phpunit --filter EmailTest".into()) + ); + } + + #[test] + fn test_classify_phpstan() { + assert!(matches!( + classify_command("vendor/bin/phpstan analyse src/"), + Classification::Supported { + rtk_equivalent: "rtk phpstan", + .. + } + )); + } + + #[test] + fn test_classify_phpstan_direct() { + assert!(matches!( + classify_command("phpstan analyse --level=9"), + Classification::Supported { + rtk_equivalent: "rtk phpstan", + .. + } + )); + } + + #[test] + fn test_rewrite_phpstan_vendor_bin() { + assert_eq!( + rewrite_command("vendor/bin/phpstan analyse src/", &[]), + Some("rtk phpstan analyse src/".into()) + ); + } + + #[test] + fn test_rewrite_phpstan_php_prefix() { + assert_eq!( + rewrite_command("php vendor/bin/phpstan analyse", &[]), + Some("rtk phpstan analyse".into()) + ); + } + + #[test] + fn test_rewrite_phpstan_version_not_rewritten() { + assert_eq!(rewrite_command("phpstan --version", &[]), None); + assert_eq!(rewrite_command("phpstan list", &[]), None); + assert_eq!(rewrite_command("phpstan clear-result-cache", &[]), None); + } + + #[test] + fn test_classify_pest() { + assert!(matches!( + classify_command("vendor/bin/pest tests/"), + Classification::Supported { + rtk_equivalent: "rtk pest", + .. + } + )); + } + + #[test] + fn test_classify_pint() { + assert!(matches!( + classify_command("vendor/bin/pint --test"), + Classification::Supported { + rtk_equivalent: "rtk pint", + .. + } + )); + } + + #[test] + fn test_php_artisan_rewrites() { + assert!(matches!( + classify_command("php artisan migrate"), + Classification::Supported { + rtk_equivalent: "rtk php", + .. + } + )); + } + + #[test] + fn test_normalize_php_tool_command_custom_bin_dir() { + use std::path::PathBuf; + let dirs = vec![PathBuf::from("tools/bin"), PathBuf::from("vendor/bin")]; + assert_eq!( + normalize_php_tool_command_with_dirs("tools/bin/phpunit tests/", &dirs), + "phpunit tests/" + ); + assert_eq!( + normalize_php_tool_command_with_dirs("./tools/bin/pest", &dirs), + "pest" + ); + } } diff --git a/src/discover/rules.rs b/src/discover/rules.rs index e9bf1b1f31..7b3b4fb639 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -530,6 +530,90 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + // PHP tooling + RtkRule { + pattern: r"^php\s+artisan(?:\s|$)", + rtk_cmd: "rtk php", + rewrite_prefixes: &["php"], + category: "Build", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^php\s+-l(?:\s|$)", + rtk_cmd: "rtk php", + rewrite_prefixes: &["php"], + category: "Build", + savings_pct: 60.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^(?:php\s+)?(?:(?:vendor/bin|bin)/)?phpunit(?:\s|$)", + rtk_cmd: "rtk phpunit", + rewrite_prefixes: &[ + "php vendor/bin/phpunit", + "php bin/phpunit", + "vendor/bin/phpunit", + "bin/phpunit", + "phpunit", + ], + category: "Tests", + savings_pct: 75.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^(?:php\s+)?(?:\.?/?vendor/bin/)?phpstan\s+analy[sz]e\b", + rtk_cmd: "rtk phpstan", + rewrite_prefixes: &[ + "php vendor/bin/phpstan", + "vendor/bin/phpstan", + "./vendor/bin/phpstan", + "phpstan", + ], + category: "Build", + savings_pct: 65.0, + subcmd_savings: &[("analyse", 65.0), ("analyze", 65.0)], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^(?:vendor/bin/)?pest(?:\s|$)", + rtk_cmd: "rtk pest", + rewrite_prefixes: &["vendor/bin/pest", "pest"], + category: "Tests", + savings_pct: 80.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^(?:vendor/bin/)?paratest(?:\s|$)", + rtk_cmd: "rtk paratest", + rewrite_prefixes: &["vendor/bin/paratest", "paratest"], + category: "Tests", + savings_pct: 80.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^(?:vendor/bin/)?ecs(?:\s|$)", + rtk_cmd: "rtk ecs", + rewrite_prefixes: &["vendor/bin/ecs", "ecs"], + category: "Build", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^(?:vendor/bin/)?pint(?:\s|$)", + rtk_cmd: "rtk pint", + rewrite_prefixes: &["vendor/bin/pint", "pint"], + category: "Build", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, RtkRule { pattern: r"^aws\s+", rtk_cmd: "rtk aws", diff --git a/src/main.rs b/src/main.rs index d919afe8e3..e8e669e1a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ use cmds::js::{ vitest_cmd, }; use cmds::jvm::{gradlew_cmd, mvn_cmd}; +use cmds::php::{ecs_cmd, paratest_cmd, pest_cmd, php_cmd, phpstan_cmd, phpunit_cmd, pint_cmd}; use cmds::python::{mypy_cmd, pip_cmd, pytest_cmd, ruff_cmd}; use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd}; use cmds::rust::{cargo_cmd, runner}; @@ -675,6 +676,55 @@ enum Commands { args: Vec, }, + /// PHP command runner with compact output for artisan and syntax checks + Php { + /// PHP arguments (e.g., artisan about, -l app/Http/Controller.php) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// PHPUnit test runner with compact output + Phpunit { + /// PHPUnit arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// PHPStan analyzer with compact output + Phpstan { + /// PHPStan arguments (e.g., analyse src/) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// Pest test runner with compact output + Pest { + /// Pest arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// ParaTest parallel test runner with compact output + Paratest { + /// ParaTest arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// EasyCodingStandard (ECS) code style fixer with compact output + Ecs { + /// ECS arguments (e.g., check src/, --fix) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// Laravel Pint (PHP-CS-Fixer) code style fixer with compact output + Pint { + /// Pint arguments (e.g., --test, app/) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Rake/Rails test with compact Minitest output (Ruby) Rake { /// Rake arguments (e.g., test, test TEST=path/to/test.rb) @@ -2218,6 +2268,20 @@ fn run_cli() -> Result { Commands::Mypy { args } => mypy_cmd::run(&args, cli.verbose)?, + Commands::Php { args } => php_cmd::run(&args, cli.verbose)?, + + Commands::Phpunit { args } => phpunit_cmd::run(&args, cli.verbose)?, + + Commands::Phpstan { args } => phpstan_cmd::run(&args, cli.verbose)?, + + Commands::Pest { args } => pest_cmd::run(&args, cli.verbose)?, + + Commands::Paratest { args } => paratest_cmd::run(&args, cli.verbose)?, + + Commands::Ecs { args } => ecs_cmd::run(&args, cli.verbose)?, + + Commands::Pint { args } => pint_cmd::run(&args, cli.verbose)?, + Commands::Rake { args } => rake_cmd::run(&args, cli.verbose)?, Commands::Rubocop { args } => rubocop_cmd::run(&args, cli.verbose)?, @@ -2583,6 +2647,13 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Curl { .. } | Commands::Ruff { .. } | Commands::Pytest { .. } + | Commands::Php { .. } + | Commands::Phpunit { .. } + | Commands::Phpstan { .. } + | Commands::Pest { .. } + | Commands::Paratest { .. } + | Commands::Ecs { .. } + | Commands::Pint { .. } | Commands::Rake { .. } | Commands::Rubocop { .. } | Commands::Rspec { .. } diff --git a/tests/fixtures/phpstan_raw.json b/tests/fixtures/phpstan_raw.json new file mode 100644 index 0000000000..56eff4709b --- /dev/null +++ b/tests/fixtures/phpstan_raw.json @@ -0,0 +1,380 @@ +{ + "totals": { + "errors": 47, + "file_errors": 47 + }, + "files": { + "/var/www/project/app/Models/User.php": { + "errors": 6, + "messages": [ + { + "message": "Property User::$id (int) does not accept null.", + "line": 15, + "ignorable": false, + "identifier": "property.nonObject", + "tip": "Use ?int if null is a valid value." + }, + { + "message": "Property User::$email (string) does not accept null.", + "line": 18, + "ignorable": false, + "identifier": "property.nonObject", + "tip": null + }, + { + "message": "Method User::find() has no return type specified.", + "line": 45, + "ignorable": true, + "identifier": "missingType.return", + "tip": "Add @return type annotation or declare the return type." + }, + { + "message": "Call to an undefined method Illuminate\\Database\\Eloquent\\Model::unknownMethod().", + "line": 67, + "ignorable": false, + "identifier": "method.notFound", + "tip": null + }, + { + "message": "Parameter #1 $id of method User::find() expects int, string given.", + "line": 89, + "ignorable": false, + "identifier": "argument.type", + "tip": null + }, + { + "message": "Dead catch - Exception is never thrown in the try block.", + "line": 120, + "ignorable": true, + "identifier": "deadCode.catch", + "tip": null + } + ] + }, + "/var/www/project/app/Http/Controllers/UserController.php": { + "errors": 5, + "messages": [ + { + "message": "Parameter $request of method UserController::store() has no type specified.", + "line": 34, + "ignorable": false, + "identifier": "missingType.parameter", + "tip": null + }, + { + "message": "Variable $user might not be defined.", + "line": 56, + "ignorable": false, + "identifier": "variable.undefined", + "tip": null + }, + { + "message": "Binary operation '+' between string and int results in an error.", + "line": 78, + "ignorable": true, + "identifier": "binaryOp.invalid", + "tip": null + }, + { + "message": "Unreachable statement - code above always terminates.", + "line": 95, + "ignorable": false, + "identifier": "deadCode.unreachable", + "tip": null + }, + { + "message": "Result of method UserRepository::create() (void) is used.", + "line": 112, + "ignorable": false, + "identifier": "return.void", + "tip": null + } + ] + }, + "/var/www/project/app/Services/AuthService.php": { + "errors": 4, + "messages": [ + { + "message": "Method AuthService::login() should return User but returns User|null.", + "line": 23, + "ignorable": false, + "identifier": "return.type", + "tip": null + }, + { + "message": "Comparison operation '>' between int<0, max> and 0 is always true.", + "line": 45, + "ignorable": true, + "identifier": "comparison.alwaysTrue", + "tip": null + }, + { + "message": "Parameter #2 $password of function password_verify expects string, int given.", + "line": 67, + "ignorable": false, + "identifier": "argument.type", + "tip": null + }, + { + "message": "Property AuthService::$logger is never read, only written.", + "line": 89, + "ignorable": true, + "identifier": "deadCode.property", + "tip": null + } + ] + }, + "/var/www/project/app/Repositories/UserRepository.php": { + "errors": 3, + "messages": [ + { + "message": "Access to an undefined property User::$unknownField.", + "line": 28, + "ignorable": false, + "identifier": "property.notFound", + "tip": null + }, + { + "message": "Method UserRepository::all() should return Collection but returns Collection.", + "line": 52, + "ignorable": false, + "identifier": "return.type", + "tip": "Specify the collection type parameter." + }, + { + "message": "Instanceof between User and stdClass will always evaluate to false.", + "line": 78, + "ignorable": false, + "identifier": "instanceof.alwaysFalse", + "tip": null + } + ] + }, + "/var/www/project/app/Http/Middleware/Authenticate.php": { + "errors": 2, + "messages": [ + { + "message": "Method Authenticate::handle() has parameter $next with no type specified.", + "line": 19, + "ignorable": false, + "identifier": "missingType.parameter", + "tip": null + }, + { + "message": "Negated boolean expression is always false.", + "line": 34, + "ignorable": true, + "identifier": "booleanNot.alwaysFalse", + "tip": null + } + ] + }, + "/var/www/project/app/Console/Commands/SyncUsers.php": { + "errors": 3, + "messages": [ + { + "message": "Method SyncUsers::handle() has no return type specified.", + "line": 22, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + }, + { + "message": "Variable $users might not be defined.", + "line": 45, + "ignorable": false, + "identifier": "variable.undefined", + "tip": null + }, + { + "message": "Call to function array_map() with Closure and array|null will result in an error.", + "line": 67, + "ignorable": false, + "identifier": "argument.type", + "tip": null + } + ] + }, + "/var/www/project/app/Events/UserRegistered.php": { + "errors": 2, + "messages": [ + { + "message": "Property UserRegistered::$user has no type specified.", + "line": 12, + "ignorable": false, + "identifier": "missingType.property", + "tip": null + }, + { + "message": "Call to an undefined method Illuminate\\Events\\Dispatcher::dispatch().", + "line": 30, + "ignorable": false, + "identifier": "method.notFound", + "tip": null + } + ] + }, + "/var/www/project/app/Listeners/SendWelcomeEmail.php": { + "errors": 2, + "messages": [ + { + "message": "Parameter $event of method SendWelcomeEmail::handle() has no type specified.", + "line": 18, + "ignorable": false, + "identifier": "missingType.parameter", + "tip": null + }, + { + "message": "Variable $mailer might not be defined.", + "line": 35, + "ignorable": false, + "identifier": "variable.undefined", + "tip": null + } + ] + }, + "/var/www/project/app/Policies/UserPolicy.php": { + "errors": 3, + "messages": [ + { + "message": "Method UserPolicy::view() has no return type specified.", + "line": 25, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + }, + { + "message": "Method UserPolicy::create() has no return type specified.", + "line": 35, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + }, + { + "message": "Method UserPolicy::update() has no return type specified.", + "line": 45, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + } + ] + }, + "/var/www/project/app/Observers/UserObserver.php": { + "errors": 2, + "messages": [ + { + "message": "Method UserObserver::created() has parameter $user with no type specified.", + "line": 14, + "ignorable": false, + "identifier": "missingType.parameter", + "tip": null + }, + { + "message": "Method UserObserver::deleted() has parameter $user with no type specified.", + "line": 24, + "ignorable": false, + "identifier": "missingType.parameter", + "tip": null + } + ] + }, + "/var/www/project/app/Providers/AuthServiceProvider.php": { + "errors": 2, + "messages": [ + { + "message": "Method AuthServiceProvider::boot() has no return type specified.", + "line": 30, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + }, + { + "message": "Variable $gate might not be defined.", + "line": 45, + "ignorable": false, + "identifier": "variable.undefined", + "tip": null + } + ] + }, + "/var/www/project/app/Jobs/ProcessPayment.php": { + "errors": 4, + "messages": [ + { + "message": "Method ProcessPayment::handle() has no return type specified.", + "line": 35, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + }, + { + "message": "Property ProcessPayment::$amount has no type specified.", + "line": 18, + "ignorable": false, + "identifier": "missingType.property", + "tip": null + }, + { + "message": "Variable $payment might not be defined.", + "line": 58, + "ignorable": false, + "identifier": "variable.undefined", + "tip": null + }, + { + "message": "Call to an undefined method PaymentGateway::charge().", + "line": 72, + "ignorable": false, + "identifier": "method.notFound", + "tip": null + } + ] + }, + "/var/www/project/app/Mail/WelcomeEmail.php": { + "errors": 2, + "messages": [ + { + "message": "Method WelcomeEmail::build() has no return type specified.", + "line": 28, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + }, + { + "message": "Property WelcomeEmail::$user has no type specified.", + "line": 15, + "ignorable": false, + "identifier": "missingType.property", + "tip": null + } + ] + }, + "/var/www/project/app/Http/Requests/StoreUserRequest.php": { + "errors": 3, + "messages": [ + { + "message": "Method StoreUserRequest::rules() has no return type specified.", + "line": 18, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + }, + { + "message": "Method StoreUserRequest::authorize() has no return type specified.", + "line": 12, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + }, + { + "message": "Strict comparison using === between string and int will always evaluate to false.", + "line": 32, + "ignorable": false, + "identifier": "comparison.strict", + "tip": null + } + ] + } + }, + "errors": [] +} From c37eed962850af470de5c1bc0a69f26f8ea92ef9 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sun, 17 May 2026 11:56:44 -0400 Subject: [PATCH 02/19] fix(test): adapt new rewrite_command signature in php-tooling tests Upstream develop changed `rewrite_command` to take 3 args (cmd, excluded, transparent_prefixes). The PHP tooling tests that don't care about transparent prefixes now use the existing `rewrite_command_no_prefixes` helper instead. --- src/discover/registry.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 5283fc2643..3860942729 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -4267,7 +4267,7 @@ mod tests { #[test] fn test_rewrite_phpunit() { assert_eq!( - rewrite_command("phpunit tests/", &[]), + rewrite_command_no_prefixes("phpunit tests/", &[]), Some("rtk phpunit tests/".into()) ); } @@ -4275,7 +4275,7 @@ mod tests { #[test] fn test_rewrite_vendor_bin_phpunit() { assert_eq!( - rewrite_command("vendor/bin/phpunit --filter EmailTest", &[]), + rewrite_command_no_prefixes("vendor/bin/phpunit --filter EmailTest", &[]), Some("rtk phpunit --filter EmailTest".into()) ); } @@ -4305,7 +4305,7 @@ mod tests { #[test] fn test_rewrite_phpstan_vendor_bin() { assert_eq!( - rewrite_command("vendor/bin/phpstan analyse src/", &[]), + rewrite_command_no_prefixes("vendor/bin/phpstan analyse src/", &[]), Some("rtk phpstan analyse src/".into()) ); } @@ -4313,16 +4313,19 @@ mod tests { #[test] fn test_rewrite_phpstan_php_prefix() { assert_eq!( - rewrite_command("php vendor/bin/phpstan analyse", &[]), + rewrite_command_no_prefixes("php vendor/bin/phpstan analyse", &[]), Some("rtk phpstan analyse".into()) ); } #[test] fn test_rewrite_phpstan_version_not_rewritten() { - assert_eq!(rewrite_command("phpstan --version", &[]), None); - assert_eq!(rewrite_command("phpstan list", &[]), None); - assert_eq!(rewrite_command("phpstan clear-result-cache", &[]), None); + assert_eq!(rewrite_command_no_prefixes("phpstan --version", &[]), None); + assert_eq!(rewrite_command_no_prefixes("phpstan list", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("phpstan clear-result-cache", &[]), + None + ); } #[test] From 214a79a9ff0a5991ae7dc86badb72f3275333498 Mon Sep 17 00:00:00 2001 From: Eli White Date: Thu, 18 Jun 2026 11:36:16 -0400 Subject: [PATCH 03/19] feat(pipe): expose PHP tool filters as stdin pipe filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PHP runners (phpunit, pest/paratest, ecs, phpstan, pint) only applied their compact filters when rtk itself launched the process. Output produced elsewhere — most commonly a tool run inside a Docker container and piped back to the host — bypassed them entirely, since the visible command is `docker ...`, not the PHP tool. Wire the existing filter functions into `rtk pipe`: - resolve_filter: phpunit, pest|paratest|php-test, ecs, phpstan, pint - phpstan/pint pipe wrappers sniff JSON-vs-text by content, since the runners force --format=json but piped output may be either - auto_detect_filter: route the "by Sebastian Bergmann" banner to the phpunit filter (no -f needed) - bump the four backing fns to pub(crate) Also fix filter_phpstan_text matching the summary line case-sensitively ("found"), which missed phpstan's actual "[ERROR] Found N errors". Tests: phpunit banner auto-detect, phpstan case-insensitive summary. --- src/cmds/php/ecs_cmd.rs | 2 +- src/cmds/php/phpstan_cmd.rs | 21 ++++++++++++---- src/cmds/php/pint_cmd.rs | 2 +- src/cmds/system/pipe_cmd.rs | 48 ++++++++++++++++++++++++++++++++++++- src/main.rs | 2 +- 5 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/cmds/php/ecs_cmd.rs b/src/cmds/php/ecs_cmd.rs index ea81f71291..8b02fb71e4 100644 --- a/src/cmds/php/ecs_cmd.rs +++ b/src/cmds/php/ecs_cmd.rs @@ -23,7 +23,7 @@ pub fn run(args: &[String], verbose: u8) -> Result { ) } -fn filter_ecs_output(output: &str) -> String { +pub(crate) fn filter_ecs_output(output: &str) -> String { let cleaned = strip_ansi_and_controls(output); if cleaned.contains("No errors found") { return "✓ ecs: no issues".to_string(); diff --git a/src/cmds/php/phpstan_cmd.rs b/src/cmds/php/phpstan_cmd.rs index 56fd56fbad..811b8b207e 100644 --- a/src/cmds/php/phpstan_cmd.rs +++ b/src/cmds/php/phpstan_cmd.rs @@ -117,7 +117,7 @@ pub fn run(args: &[String], verbose: u8) -> Result { // ── JSON filtering ─────────────────────────────────────────────────────────── -fn filter_phpstan_json(output: &str) -> String { +pub(crate) fn filter_phpstan_json(output: &str) -> String { if output.trim().is_empty() { return "PHPStan: No output".to_string(); } @@ -187,7 +187,7 @@ fn filter_phpstan_json(output: &str) -> String { // ── Text fallback ──────────────────────────────────────────────────────────── -fn filter_phpstan_text(output: &str) -> String { +pub(crate) fn filter_phpstan_text(output: &str) -> String { // Check for errors first for line in output.lines() { let t = line.trim(); @@ -210,13 +210,15 @@ fn filter_phpstan_text(output: &str) -> String { } } - // Extract summary if present + // Extract summary if present. Match case-insensitively: phpstan prints + // "[ERROR] Found N errors" / "[OK] No errors" with varying capitalization. for line in output.lines().rev() { let t = line.trim(); - if t.contains("[OK]") || t.contains("No errors") { + let lower = t.to_lowercase(); + if lower.contains("[ok]") || lower.contains("no errors") { return "phpstan: ok".to_string(); } - if t.contains("errors") && (t.contains("found") || t.contains("in")) { + if lower.contains("error") && (lower.contains("found") || lower.contains("in")) { return format!("PHPStan: {}", t); } } @@ -452,6 +454,15 @@ Found 5 errors in 3 files"#; assert!(result.contains("3 files"), "should contain file count"); } + #[test] + fn test_filter_phpstan_text_error_summary_case_insensitive() { + // phpstan prints "[ERROR] Found N errors" — capital F and no " in ", + // so the summary must be matched case-insensitively (regression guard). + let text = " ------\n 42 problem\n ------\n\n [ERROR] Found 2 errors\n"; + let result = filter_phpstan_text(text); + assert_eq!(result, "PHPStan: [ERROR] Found 2 errors"); + } + #[test] fn test_filter_phpstan_fixture_structure() { // Verify output structure on the realistic fixture (14 files, 47 errors) diff --git a/src/cmds/php/pint_cmd.rs b/src/cmds/php/pint_cmd.rs index ff68ec471d..80d4272e0a 100644 --- a/src/cmds/php/pint_cmd.rs +++ b/src/cmds/php/pint_cmd.rs @@ -68,7 +68,7 @@ pub fn run(args: &[String], verbose: u8) -> Result { ) } -fn filter_pint_json(output: &str) -> String { +pub(crate) fn filter_pint_json(output: &str) -> String { let trimmed = output.trim(); if trimmed.is_empty() { return "pint: ok".to_string(); diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index c1a1a07c87..f12ac7dcbf 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -27,6 +27,13 @@ pub fn resolve_filter(name: &str) -> Option String> { "ruff-check" => Some(crate::cmds::python::ruff_cmd::filter_ruff_check_json), "ruff-format" => Some(crate::cmds::python::ruff_cmd::filter_ruff_format), "prettier" => Some(crate::cmds::js::prettier_cmd::filter_prettier_output), + "phpunit" => Some(crate::cmds::php::phpunit_cmd::filter_phpunit_output), + "pest" | "paratest" | "php-test" => { + Some(crate::cmds::php::test_output::filter_test_runner_output) + } + "ecs" => Some(crate::cmds::php::ecs_cmd::filter_ecs_output), + "phpstan" => Some(phpstan_wrapper), + "pint" => Some(pint_wrapper), _ => None, } } @@ -47,6 +54,24 @@ fn git_diff_wrapper(input: &str) -> String { crate::cmds::git::git::compact_diff(input, 200) } +fn phpstan_wrapper(input: &str) -> String { + // Runner forces --format=json; piped output may be either JSON or the + // default human table. Pick by content. + if input.trim_start().starts_with('{') { + crate::cmds::php::phpstan_cmd::filter_phpstan_json(input) + } else { + crate::cmds::php::phpstan_cmd::filter_phpstan_text(input) + } +} + +fn pint_wrapper(input: &str) -> String { + if input.trim_start().starts_with('{') { + crate::cmds::php::pint_cmd::filter_pint_json(input) + } else { + crate::core::utils::fallback_tail(input, "pint", 60) + } +} + fn vitest_wrapper(input: &str) -> String { use crate::cmds::js::vitest_cmd::VitestParser; use crate::parser::{FormatMode, OutputParser, TokenFormatter}; @@ -154,6 +179,11 @@ pub fn auto_detect_filter(input: &str) -> fn(&str) -> String { return crate::cmds::python::pytest_cmd::filter_pytest_output; } + // phpunit banner: "PHPUnit X.Y.Z by Sebastian Bergmann and contributors." + if first_1k.contains("by Sebastian Bergmann") { + return crate::cmds::php::phpunit_cmd::filter_phpunit_output; + } + let first_trimmed = first_1k.trim_start(); if first_trimmed.starts_with('{') && first_1k.contains("\"Action\"") { return go_test_wrapper; @@ -231,7 +261,8 @@ pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { anyhow::anyhow!( "Unknown filter '{}'. Available: cargo-test, pytest, go-test, go-build, \ tsc, vitest, grep, rg, find, fd, git-log, git-diff, git-status, \ - log, mypy, ruff-check, ruff-format, prettier", + log, mypy, ruff-check, ruff-format, prettier, phpunit, pest, \ + paratest, php-test, ecs, phpstan, pint", name ) })?, @@ -248,6 +279,21 @@ pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { mod tests { use super::*; + #[test] + fn test_auto_detect_phpunit_banner() { + // The phpunit banner must route to the phpunit filter with no -f flag. + let input = "PHPUnit 11.0.0 by Sebastian Bergmann and contributors.\n\n\ + ... 3 / 3 (100%)\n\nOK (3 tests, 5 assertions)\n"; + let f = auto_detect_filter(input); + let out = f(input); + assert!(out.starts_with("PHPUnit:"), "out={}", out); + } + + #[test] + fn test_resolve_filter_phpunit() { + assert!(resolve_filter("phpunit").is_some()); + } + #[test] fn test_resolve_filter_cargo_test() { let f = resolve_filter("cargo-test").expect("cargo-test filter must exist"); diff --git a/src/main.rs b/src/main.rs index e8e669e1a2..a7aab0bf66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -626,7 +626,7 @@ enum Commands { /// Read stdin, apply filter, print filtered output (Unix pipe mode) Pipe { - /// Filter name (cargo-test, pytest, grep, find, git-log, etc.) + /// Filter name (cargo-test, pytest, phpunit, phpstan, pint, grep, find, git-log, etc.) #[arg(short, long)] filter: Option, From 0b75581f6b9a2df1a4066b01f0b1d6cf8dbbab37 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Mon, 22 Jun 2026 09:50:34 -0400 Subject: [PATCH 04/19] fix(php): align pint/phpstan parsers with current tool schemas pint: Pint >=1.14 renamed JSON keys name->path and appliedFixers->fixers. The struct fields were required with no aliases, so serde rejected current output and the filter fell back to raw (no compression). Add backward- compatible aliases so both schemas parse. phpstan: the "ok" gate and summary line read totals.errors, which counts only non-file-specific (global) errors. A normal failing run reports errors=0 with the count in file_errors, so runs with real errors were reported as "phpstan: ok", silently hiding failures. Gate on both counts and report file_errors in the summary. Both regressions slipped past the suite because the fixtures set errors == file_errors (phpstan) and used the old key names (pint). Added regression tests using the current-version schemas. Reported by @evaldnet (verified against Pint 1.27.1, PHPStan 2.1.40). Co-authored-by: Aaron Florey Co-authored-by: Eli White <1153183+EliW@users.noreply.github.com> Co-authored-by: Benjamin LETELLIER Co-authored-by: Luciano --- src/cmds/php/phpstan_cmd.rs | 32 ++++++++++++++++++++++++++++---- src/cmds/php/pint_cmd.rs | 16 +++++++++++++++- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/cmds/php/phpstan_cmd.rs b/src/cmds/php/phpstan_cmd.rs index 811b8b207e..9d4d57037b 100644 --- a/src/cmds/php/phpstan_cmd.rs +++ b/src/cmds/php/phpstan_cmd.rs @@ -23,8 +23,10 @@ struct PhpstanOutput { #[derive(Deserialize)] struct PhpstanTotals { + // `errors` counts only non-file-specific (global/config) errors; per-file + // errors live in `file_errors`. Gating "ok" on `errors` alone hides real + // failures, since a normal failing run reports errors=0, file_errors=N. errors: usize, - #[allow(dead_code)] file_errors: usize, } @@ -131,14 +133,14 @@ pub(crate) fn filter_phpstan_json(output: &str) -> String { } }; - // No errors case - if phpstan.totals.errors == 0 { + // No errors case: both file-specific and global error counts must be zero. + if phpstan.totals.file_errors == 0 && phpstan.totals.errors == 0 { return "phpstan: ok".to_string(); } let mut result = format!( "phpstan: {} errors in {} files\n", - phpstan.totals.errors, + phpstan.totals.file_errors, phpstan.files.len() ); @@ -352,6 +354,28 @@ mod tests { assert_eq!(result, "phpstan: ok"); } + #[test] + fn test_filter_phpstan_file_errors_not_hidden() { + // Real failing runs report errors=0 (no global errors) with the count + // in file_errors. Gating "ok" on `errors` alone silently hid failures. + let json = r#"{ + "totals": {"errors": 0, "file_errors": 2}, + "files": { + "app/Models/User.php": { + "errors": 2, + "messages": [ + {"message": "Property $id does not accept null.", "line": 10, "ignorable": true}, + {"message": "Call to undefined method.", "line": 25, "ignorable": false} + ] + } + }, + "errors": [] + }"#; + let result = filter_phpstan_json(json); + assert!(result.starts_with("phpstan: 2 errors in 1 files"), "got: {}", result); + assert!(result.contains("app/Models/User.php (2 errors)"), "got: {}", result); + } + #[test] fn test_filter_phpstan_json_with_errors() { let result = filter_phpstan_json(with_errors_json()); diff --git a/src/cmds/php/pint_cmd.rs b/src/cmds/php/pint_cmd.rs index 80d4272e0a..397eb82334 100644 --- a/src/cmds/php/pint_cmd.rs +++ b/src/cmds/php/pint_cmd.rs @@ -23,8 +23,11 @@ struct PintOutput { #[derive(Deserialize)] struct PintFile { + // Pint ≥ ~1.14 renamed the JSON keys: name→path, appliedFixers→fixers. + // Aliases keep both schemas parsing so output stays compressed across versions. + #[serde(alias = "path")] name: String, - #[serde(rename = "appliedFixers")] + #[serde(rename = "appliedFixers", alias = "fixers")] applied_fixers: Vec, } @@ -157,6 +160,17 @@ mod tests { assert!(result.contains("ordered_imports"), "got: {}", result); } + #[test] + fn test_pint_current_schema_path_fixers() { + // Pint ≥ ~1.14 emits path/fixers instead of name/appliedFixers. + // Without aliases this fell back to raw output (no compression). + let json = r#"{"result":"fail","files":[{"path":"app/Foo.php","fixers":["concat_space","ordered_imports"]}]}"#; + let result = filter_pint_json(json); + assert!(result.contains("2 changes in 1 files"), "got: {}", result); + assert!(result.contains("app/Foo.php (2)"), "got: {}", result); + assert!(result.contains("concat_space"), "got: {}", result); + } + #[test] fn test_pint_sorted_by_count_desc() { let json = r#"{"files":[ From 88e56cef85dbbaf74f9857ecfa634b9da6db57d9 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Tue, 23 Jun 2026 08:31:08 -0400 Subject: [PATCH 05/19] fix(php): rewrite ./vendor/bin/ form for phpunit/pest/paratest/ecs/pint `./vendor/bin/` is the common Laravel invocation form. classify_command normalizes the leading `./`, so these classify as supported, but the rewrite strips literal `rewrite_prefixes` from the raw command and the five rules only carried `vendor/bin/` and bare ``. So `./vendor/bin/pint` ran raw with no compression while `vendor/bin/pint` rewrote to `rtk pint`. phpstan already carried `./vendor/bin/phpstan`. Add the `./vendor/bin/` prefix to the other five, with a regression test. Reported by @evaldnet (verified against pint 1.29.1, phpstan 2.1.40). Co-authored-by: Aaron Florey Co-authored-by: Eli White <1153183+EliW@users.noreply.github.com> Co-authored-by: Benjamin LETELLIER Co-authored-by: Luciano --- src/discover/registry.rs | 27 +++++++++++++++++++++++++++ src/discover/rules.rs | 9 +++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 3860942729..554d439bd5 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -4280,6 +4280,33 @@ mod tests { ); } + #[test] + fn test_rewrite_dotslash_vendor_bin() { + // `./vendor/bin/` is the common Laravel invocation form. classify + // normalizes the leading `./`, but the rewrite strips literal prefixes, + // so the `./vendor/bin/` prefix must be present or rewrite no-ops. + assert_eq!( + rewrite_command_no_prefixes("./vendor/bin/pint --test", &[]), + Some("rtk pint --test".into()) + ); + assert_eq!( + rewrite_command_no_prefixes("./vendor/bin/pest tests/", &[]), + Some("rtk pest tests/".into()) + ); + assert_eq!( + rewrite_command_no_prefixes("./vendor/bin/paratest", &[]), + Some("rtk paratest".into()) + ); + assert_eq!( + rewrite_command_no_prefixes("./vendor/bin/ecs check", &[]), + Some("rtk ecs check".into()) + ); + assert_eq!( + rewrite_command_no_prefixes("./vendor/bin/phpunit --filter EmailTest", &[]), + Some("rtk phpunit --filter EmailTest".into()) + ); + } + #[test] fn test_classify_phpstan() { assert!(matches!( diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 7b3b4fb639..ca743dbce7 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -555,6 +555,7 @@ pub const RULES: &[RtkRule] = &[ rewrite_prefixes: &[ "php vendor/bin/phpunit", "php bin/phpunit", + "./vendor/bin/phpunit", "vendor/bin/phpunit", "bin/phpunit", "phpunit", @@ -581,7 +582,7 @@ pub const RULES: &[RtkRule] = &[ RtkRule { pattern: r"^(?:vendor/bin/)?pest(?:\s|$)", rtk_cmd: "rtk pest", - rewrite_prefixes: &["vendor/bin/pest", "pest"], + rewrite_prefixes: &["./vendor/bin/pest", "vendor/bin/pest", "pest"], category: "Tests", savings_pct: 80.0, subcmd_savings: &[], @@ -590,7 +591,7 @@ pub const RULES: &[RtkRule] = &[ RtkRule { pattern: r"^(?:vendor/bin/)?paratest(?:\s|$)", rtk_cmd: "rtk paratest", - rewrite_prefixes: &["vendor/bin/paratest", "paratest"], + rewrite_prefixes: &["./vendor/bin/paratest", "vendor/bin/paratest", "paratest"], category: "Tests", savings_pct: 80.0, subcmd_savings: &[], @@ -599,7 +600,7 @@ pub const RULES: &[RtkRule] = &[ RtkRule { pattern: r"^(?:vendor/bin/)?ecs(?:\s|$)", rtk_cmd: "rtk ecs", - rewrite_prefixes: &["vendor/bin/ecs", "ecs"], + rewrite_prefixes: &["./vendor/bin/ecs", "vendor/bin/ecs", "ecs"], category: "Build", savings_pct: 70.0, subcmd_savings: &[], @@ -608,7 +609,7 @@ pub const RULES: &[RtkRule] = &[ RtkRule { pattern: r"^(?:vendor/bin/)?pint(?:\s|$)", rtk_cmd: "rtk pint", - rewrite_prefixes: &["vendor/bin/pint", "pint"], + rewrite_prefixes: &["./vendor/bin/pint", "vendor/bin/pint", "pint"], category: "Build", savings_pct: 70.0, subcmd_savings: &[], From 4a37246a051e6b4585a4a396e2581854a930f029 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Fri, 26 Jun 2026 10:09:12 -0400 Subject: [PATCH 06/19] fix(php): classify php subcommands in the PASSTHROUGH list develop's `test_every_subcommand_is_classified` requires every CLI subcommand to appear in `RTK_META_COMMANDS` or `PASSTHROUGH`. The consolidated php subcommands (php, phpunit, phpstan, pest, paratest, ecs, pint) wrap real tools, so they belong in `PASSTHROUGH`. Without this the PR-into-develop merge fails the test. --- src/main.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main.rs b/src/main.rs index a7aab0bf66..1f432ff196 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3037,6 +3037,13 @@ mod tests { "golangci-lint", "gradlew", "mvn", + "php", + "phpunit", + "phpstan", + "pest", + "paratest", + "ecs", + "pint", ]; let unclassified: Vec = Cli::command() From 8eae6b7334ccc43758f8870773c491ebc4e99f35 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Fri, 26 Jun 2026 10:09:12 -0400 Subject: [PATCH 07/19] fix(php): route php_tool_command through resolved_command The `.semgrep.yml` `dynamic-command-execution` rule forbids `Command::new` on a variable. `php_tool_command` built the command directly from the resolved tool path; route it through `resolved_command` (the sanctioned PATHEXT-aware constructor) instead. Clears both blocking findings with no behavior change. --- src/cmds/php/utils.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/cmds/php/utils.rs b/src/cmds/php/utils.rs index dee8b05cfa..bae5f434de 100644 --- a/src/cmds/php/utils.rs +++ b/src/cmds/php/utils.rs @@ -12,12 +12,11 @@ lazy_static! { pub fn php_tool_command(tool: &str) -> Command { for local_tool in composer_tool_paths(tool) { let local_tool_name = local_tool.to_string_lossy().into_owned(); - if let Ok(resolved_tool) = resolve_binary(&local_tool_name) { - return Command::new(resolved_tool); - } - - if local_tool.exists() { - return Command::new(local_tool); + // Route through resolved_command (the sanctioned constructor) rather than + // a raw dynamic command constructor, so the binary still resolves + // PATHEXT-aware on Windows and the security scan's no-dynamic-exec rule holds. + if resolve_binary(&local_tool_name).is_ok() || local_tool.exists() { + return resolved_command(&local_tool_name); } } From 07c231ee4cc31fa1df218a91ec7d8046144c1c7b Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sat, 27 Jun 2026 15:19:09 -0400 Subject: [PATCH 08/19] fix(php): address phpstan review feedback (resolution, text fallback, path compaction) - run() now uses php_tool_command("phpstan") instead of hardcoding vendor/bin/phpstan, so COMPOSER_BIN_DIR and composer.json's config.bin-dir are respected, matching every other php tool in this module. - filter_phpstan_text only treats shell-level "binary missing" lines as a failure. The previous contains("not found") matched real analysis messages ("Class X not found") and swallowed the summary; also dropped a stray Ruby LoadError string ("cannot load such file"). Adds a regression test. - compact_php_path reduced to last-two-components for all frameworks, dropping the Laravel-specific prefix list. Tests updated to the compacted output. --- src/cmds/php/phpstan_cmd.rs | 90 +++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 48 deletions(-) diff --git a/src/cmds/php/phpstan_cmd.rs b/src/cmds/php/phpstan_cmd.rs index 9d4d57037b..ea11cd83d0 100644 --- a/src/cmds/php/phpstan_cmd.rs +++ b/src/cmds/php/phpstan_cmd.rs @@ -4,12 +4,12 @@ //! file and sorted by error count. Falls back to text parsing when the user //! specifies a custom format or when injected JSON output fails to parse. +use super::utils::php_tool_command; use crate::core::runner; -use crate::core::utils::{exit_code_from_status, resolved_command}; +use crate::core::utils::exit_code_from_status; use anyhow::{Context, Result}; use serde::Deserialize; use std::collections::HashMap; -use std::path::Path; // ── JSON structures matching PHPStan's --error-format=json output ─────────── @@ -48,12 +48,9 @@ struct PhpstanMessage { // ── Public entry point ─────────────────────────────────────────────────────── pub fn run(args: &[String], verbose: u8) -> Result { - // Check for vendor/bin/phpstan first - let mut cmd = if Path::new("vendor/bin/phpstan").exists() { - resolved_command("vendor/bin/phpstan") - } else { - resolved_command("phpstan") - }; + // Composer-aware resolution (COMPOSER_BIN_DIR / config.bin-dir), matching + // the other php tools, with PATH fallback for a global install. + let mut cmd = php_tool_command("phpstan"); // Utility commands (--version, list, clear-result-cache, worker, …): real passthrough. // Only analyse/analyze subcommands get filtered and token-tracked. @@ -193,10 +190,12 @@ pub(crate) fn filter_phpstan_text(output: &str) -> String { // Check for errors first for line in output.lines() { let t = line.trim(); - if t.contains("cannot load such file") - || t.contains("not found") - || t.starts_with("phpstan: command not found") + // Shell-level "binary missing" lines only. A substring match on + // "not found" would swallow real analysis output ("Class X not found"). + if t.starts_with("phpstan: command not found") || t.starts_with("phpstan: No such file") + || t.starts_with("sh: ") + || t.starts_with("bash: ") { let error_lines: Vec<&str> = output.trim().lines().take(20).collect(); let truncated = error_lines.join("\n"); @@ -229,35 +228,11 @@ pub(crate) fn filter_phpstan_text(output: &str) -> String { crate::core::utils::fallback_tail(output, "phpstan", 20) } -/// Compact PHP file path by finding the nearest conventional directory -/// and stripping the absolute path prefix. +/// Compact a PHP file path to its last two components (`dir/File.php`), +/// which is enough context across frameworks without a per-convention list. fn compact_php_path(path: &str) -> String { let path = path.replace('\\', "/"); - for prefix in &[ - "app/Models/", - "app/Http/Controllers/", - "app/Http/Middleware/", - "app/Services/", - "app/Repositories/", - "src/", - "tests/", - "config/", - "database/", - ] { - if let Some(pos) = path.find(prefix) { - return path[pos..].to_string(); - } - } - - // Generic: strip up to last known directory marker - if let Some(pos) = path.rfind("/app/") { - return path[pos + 1..].to_string(); - } - if let Some(pos) = path.rfind("/src/") { - return path[pos + 1..].to_string(); - } - // Keep last 2 path components to preserve context (dir/File.php) if let Some(pos) = path.rfind('/') { if let Some(prev) = path[..pos].rfind('/') { return path[prev + 1..].to_string(); @@ -373,7 +348,7 @@ mod tests { }"#; let result = filter_phpstan_json(json); assert!(result.starts_with("phpstan: 2 errors in 1 files"), "got: {}", result); - assert!(result.contains("app/Models/User.php (2 errors)"), "got: {}", result); + assert!(result.contains("Models/User.php (2 errors)"), "got: {}", result); } #[test] @@ -383,10 +358,10 @@ mod tests { // Check summary line assert!(result.contains("5 errors in 3 files")); - // Check file names are present - assert!(result.contains("app/Models/User.php")); - assert!(result.contains("app/Http/Controllers/UserController.php")); - assert!(result.contains("app/Services/AuthService.php")); + // Check file names are present (compacted to last two components) + assert!(result.contains("Models/User.php")); + assert!(result.contains("Controllers/UserController.php")); + assert!(result.contains("Services/AuthService.php")); // Check line numbers and messages assert!(result.contains(":10 Property $id does not accept null.")); @@ -401,8 +376,8 @@ mod tests { // Should show max 10 files assert!(result.contains("+2 more files")); - // Should not show all 12 files inline - let file_count = result.matches("app/Models/Model").count(); + // Should not show all 12 files inline (paths compacted to last 2 components) + let file_count = result.matches("Models/Model").count(); assert_eq!(file_count, 10, "Should show exactly 10 files"); } @@ -443,11 +418,11 @@ mod tests { fn test_compact_php_path() { assert_eq!( compact_php_path("/var/www/project/app/Models/User.php"), - "app/Models/User.php" + "Models/User.php" ); assert_eq!( compact_php_path("app/Http/Controllers/UserController.php"), - "app/Http/Controllers/UserController.php" + "Controllers/UserController.php" ); assert_eq!( compact_php_path("/home/user/project/src/Service.php"), @@ -455,7 +430,7 @@ mod tests { ); assert_eq!( compact_php_path("tests/Unit/UserTest.php"), - "tests/Unit/UserTest.php" + "Unit/UserTest.php" ); } @@ -478,6 +453,25 @@ Found 5 errors in 3 files"#; assert!(result.contains("3 files"), "should contain file count"); } + #[test] + fn test_filter_phpstan_text_not_found_in_analysis_not_swallowed() { + // "not found" appears in real analysis messages. The text fallback must + // report the summary, not mistake it for a missing-binary shell error. + let text = r#"Note: Using configuration file phpstan.neon. + + ------ ----------------------------------------------- + Line app/Foo.php + ------ ----------------------------------------------- + 10 Class 'NotFoundHttpException' not found. + 25 Method 'findById' not found in class 'UserRepository'. + ------ ----------------------------------------------- + + [ERROR] Found 2 errors +"#; + let result = filter_phpstan_text(text); + assert_eq!(result, "PHPStan: [ERROR] Found 2 errors"); + } + #[test] fn test_filter_phpstan_text_error_summary_case_insensitive() { // phpstan prints "[ERROR] Found N errors" — capital F and no " in ", @@ -495,7 +489,7 @@ Found 5 errors in 3 files"#; assert!(output.contains("47 errors in 14 files")); // Files are sorted by error count descending — User.php has 6, comes first - assert!(output.contains("app/Models/User.php (6 errors)")); + assert!(output.contains("Models/User.php (6 errors)")); // 14 files → only 10 shown, 4 more assert!(output.contains("+4 more files")); } From 128d04f7790990a20ee7609f77cc8baa105dc526 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sat, 27 Jun 2026 15:19:09 -0400 Subject: [PATCH 09/19] fix(php): anchor phpunit failure-heading detection to the "N) " format is_numbered_failure_heading matched any line starting with a digit and containing ')', which split a failure block on detail lines like "5 of 10 assertions passed in Foo::bar()". Anchor to `^\d+\) \S` via a lazy_static regex. Adds a regression test. --- src/cmds/php/phpunit_cmd.rs | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/cmds/php/phpunit_cmd.rs b/src/cmds/php/phpunit_cmd.rs index 8706cf0cc2..80bdbc23e3 100644 --- a/src/cmds/php/phpunit_cmd.rs +++ b/src/cmds/php/phpunit_cmd.rs @@ -8,10 +8,19 @@ use super::utils::php_tool_command; use crate::core::runner; use anyhow::Result; +use lazy_static::lazy_static; +use regex::Regex; const MAX_FAILURES_SHOWN: usize = 10; const MAX_DETAIL_LINES_PER_FAILURE: usize = 2; +lazy_static! { + // PHPUnit prints each failure heading as "N) Class::method". Anchor to that + // exact shape so detail lines that merely start with a digit and contain ')' + // (e.g. "5 of 10 assertions passed in Foo::bar()") don't split a block. + static ref FAILURE_HEADING_RE: Regex = Regex::new(r"^\d+\) \S").unwrap(); +} + pub fn run(args: &[String], verbose: u8) -> Result { let mut cmd = php_tool_command("phpunit"); for arg in args { @@ -90,10 +99,7 @@ pub(crate) fn filter_phpunit_output(output: &str) -> String { } fn is_numbered_failure_heading(line: &str) -> bool { - // PHPUnit formats each failure as "N) Class::method" - let mut chars = line.chars(); - let first_digit = chars.next().is_some_and(|c| c.is_ascii_digit()); - first_digit && line.contains(')') + FAILURE_HEADING_RE.is_match(line) } fn build_success_with_skipped(output: &str) -> String { @@ -178,6 +184,21 @@ fn parse_counts(output: &str) -> (usize, usize, usize, usize) { mod tests { use super::*; + #[test] + fn test_numbered_failure_heading_anchored() { + // Real PHPUnit failure headings match. + assert!(is_numbered_failure_heading("1) App\\Tests\\UserTest::testEmail")); + assert!(is_numbered_failure_heading("12) Foo::bar")); + // Detail lines that merely start with a digit and contain ')' must not. + assert!(!is_numbered_failure_heading( + "5 of 10 assertions passed in Foo::bar()" + )); + assert!(!is_numbered_failure_heading("1)")); // no method after ") " + assert!(!is_numbered_failure_heading( + "Failed asserting that Array(3) is identical." + )); + } + const REAL_PHPUNIT_FAILURE: &str = r#"PHPUnit 10.5.0 by Sebastian Bergmann and contributors. Runtime: PHP 8.2.27 with Xdebug 3.3.1 From b5c3f7fac56a3f273c3a6517fb920e5970f7c545 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sat, 27 Jun 2026 15:19:09 -0400 Subject: [PATCH 10/19] perf(php): resolve cwd once in pint output instead of per file short_path() called current_dir() on every file (up to MAX_FILES_SHOWN per invocation). Hoist the cwd prefix out of the loop and strip it inline. --- src/cmds/php/pint_cmd.rs | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/cmds/php/pint_cmd.rs b/src/cmds/php/pint_cmd.rs index 397eb82334..dd356cf4ac 100644 --- a/src/cmds/php/pint_cmd.rs +++ b/src/cmds/php/pint_cmd.rs @@ -100,8 +100,17 @@ pub(crate) fn filter_pint_json(output: &str) -> String { let mut result = format!("pint: {} changes in {} files\n", total_rules, total_files); + // Resolve cwd once; short_path() used to re-syscall current_dir() per file. + let cwd_prefix = std::env::current_dir() + .ok() + .and_then(|p| p.into_os_string().into_string().ok()) + .map(|s| format!("{}/", s)); + for file in files.iter().take(MAX_FILES_SHOWN) { - let name = short_path(&file.name); + let name = cwd_prefix + .as_deref() + .and_then(|c| file.name.strip_prefix(c)) + .unwrap_or(&file.name); result.push_str(&format!("\n{} ({})\n", name, file.applied_fixers.len())); for rule in file.applied_fixers.iter().take(MAX_RULES_PER_FILE) { result.push_str(&format!(" - {}\n", rule)); @@ -124,18 +133,6 @@ pub(crate) fn filter_pint_json(output: &str) -> String { result.trim().to_string() } -fn short_path(path: &str) -> String { - if let Ok(cwd) = std::env::current_dir() { - if let Ok(cwd_str) = cwd.into_os_string().into_string() { - let with_sep = format!("{}/", cwd_str); - if let Some(rest) = path.strip_prefix(&with_sep) { - return rest.to_string(); - } - } - } - path.to_string() -} - #[cfg(test)] mod tests { use super::*; From 6117bb4deb80dbe4745681cc09908c0c23b6ebd8 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sun, 28 Jun 2026 19:51:39 -0400 Subject: [PATCH 11/19] refactor(php): standardize tool regexes and normalize invocation in rewrite The six Composer-tool rules had inconsistent patterns, and their rewrite_prefixes only worked because classify normalizes the command while rewrite stripped literal prefixes off the raw text. A form the pattern accepted but the prefix list missed (e.g. ./bin/phpunit) would classify yet silently fail to rewrite. rewrite_segment_inner now normalizes the leading invocation for these tools (php wrapper, ./, vendor/bin, composer bin-dir) the same way classify does, so the prefix list collapses to the residual canonical forms: bin/ + bare name for phpunit/phpstan, bare name for pest/paratest/ecs/pint. Patterns standardized to ^(?:php\s+)?(?:\./)?(?:(?:vendor/)?bin/)? for phpunit/phpstan and ^(?:\./)?(?:vendor/bin/)? for the rest. Adds a form-coverage test asserting every accepted spelling maps to one rewrite. --- src/discover/registry.rs | 68 +++++++++++++++++++++++++++++++++++++++- src/discover/rules.rs | 40 ++++++++++------------- 2 files changed, 83 insertions(+), 25 deletions(-) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 554d439bd5..ddffecc927 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -258,6 +258,14 @@ fn normalize_php_tool_command(cmd: &str) -> String { normalize_php_tool_command_with_dirs(cmd, &composer_bin_dirs()) } +/// Peel a leading `php` interpreter wrapper off a Composer-tool invocation +/// (`php vendor/bin/phpunit …` → `vendor/bin/phpunit …`) so the tool path +/// normalizes to its bare name. Only meaningful for the resolved tools, where +/// a `php` prefix is always the interpreter (never `php artisan`/`run-tests.php`). +fn strip_php_wrapper(cmd: &str) -> &str { + cmd.strip_prefix("php ").map_or(cmd, str::trim_start) +} + fn normalize_php_tool_command_with_dirs(cmd: &str, bin_dirs: &[std::path::PathBuf]) -> String { let first_space = cmd.find(char::is_whitespace); let first_word = match first_space { @@ -916,9 +924,30 @@ fn rewrite_segment_inner( } } + // For the Composer-resolved php tools, normalize the leading invocation + // (php wrapper + ini flags, ./, vendor/bin, composer bin-dir) exactly as + // classify_command does, so a small canonical prefix list matches every + // invocation form instead of enumerating each literal spelling. + let php_normalized; + let strip_target: &str = if rule + .rtk_cmd + .strip_prefix("rtk ") + .is_some_and(|t| PHP_TOOL_NAMES.contains(&t)) + { + // Peel `php ` then a leading `./` (normalize_php_tool_command only + // strips `./` for paths that resolve to a Composer tool, so a plain + // `./bin/` would otherwise survive and miss the prefix match). + let unwrapped = strip_php_wrapper(cmd_part); + let unwrapped = unwrapped.strip_prefix("./").unwrap_or(unwrapped); + php_normalized = normalize_php_tool_command(unwrapped); + &php_normalized + } else { + cmd_part + }; + // Try each rewrite prefix (longest first) with word-boundary check for &prefix in rule.rewrite_prefixes { - if let Some(rest) = strip_word_prefix(cmd_part, prefix) { + if let Some(rest) = strip_word_prefix(strip_target, prefix) { let rewritten = if rest.is_empty() { format!("{}{}", rule.rtk_cmd, redirect_suffix) } else { @@ -4307,6 +4336,43 @@ mod tests { ); } + #[test] + fn test_rewrite_php_tool_invocation_forms() { + // phpunit carries the full matrix: php wrapper, ./, plain bin/, vendor/bin. + // rewrite_segment_inner normalizes each to the same canonical rewrite. + for cmd in [ + "phpunit tests/", + "vendor/bin/phpunit tests/", + "./vendor/bin/phpunit tests/", + "bin/phpunit tests/", + "./bin/phpunit tests/", + "php vendor/bin/phpunit tests/", + "php phpunit tests/", + ] { + assert_eq!( + rewrite_command_no_prefixes(cmd, &[]), + Some("rtk phpunit tests/".into()), + "form: {cmd}" + ); + } + + // pest/pint/ecs/paratest use the simpler variant: ./ and vendor/bin only. + for cmd in ["pint", "vendor/bin/pint", "./vendor/bin/pint", "./pint"] { + assert_eq!( + rewrite_command_no_prefixes(cmd, &[]), + Some("rtk pint".into()), + "form: {cmd}" + ); + } + // Forms the simpler variant intentionally does not accept (no php + // wrapper, no plain bin/) — must not rewrite rather than misfire. + assert_eq!( + rewrite_command_no_prefixes("php vendor/bin/pint", &[]), + None + ); + assert_eq!(rewrite_command_no_prefixes("bin/pint", &[]), None); + } + #[test] fn test_classify_phpstan() { assert!(matches!( diff --git a/src/discover/rules.rs b/src/discover/rules.rs index ca743dbce7..2d3eb17dc8 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -550,66 +550,58 @@ pub const RULES: &[RtkRule] = &[ subcmd_status: &[], }, RtkRule { - pattern: r"^(?:php\s+)?(?:(?:vendor/bin|bin)/)?phpunit(?:\s|$)", + pattern: r"^(?:php\s+)?(?:\./)?(?:(?:vendor/)?bin/)?phpunit(?:\s|$)", rtk_cmd: "rtk phpunit", - rewrite_prefixes: &[ - "php vendor/bin/phpunit", - "php bin/phpunit", - "./vendor/bin/phpunit", - "vendor/bin/phpunit", - "bin/phpunit", - "phpunit", - ], + // rewrite_segment_inner normalizes the php wrapper, `./`, vendor/bin and + // composer bin-dir before matching, so only the residual forms remain: + // a plain `bin/` (not a Composer dir, so it survives normalization) and + // the bare tool name. + rewrite_prefixes: &["bin/phpunit", "phpunit"], category: "Tests", savings_pct: 75.0, subcmd_savings: &[], subcmd_status: &[], }, RtkRule { - pattern: r"^(?:php\s+)?(?:\.?/?vendor/bin/)?phpstan\s+analy[sz]e\b", + pattern: r"^(?:php\s+)?(?:\./)?(?:(?:vendor/)?bin/)?phpstan\s+analy[sz]e\b", rtk_cmd: "rtk phpstan", - rewrite_prefixes: &[ - "php vendor/bin/phpstan", - "vendor/bin/phpstan", - "./vendor/bin/phpstan", - "phpstan", - ], + rewrite_prefixes: &["bin/phpstan", "phpstan"], category: "Build", savings_pct: 65.0, subcmd_savings: &[("analyse", 65.0), ("analyze", 65.0)], subcmd_status: &[], }, RtkRule { - pattern: r"^(?:vendor/bin/)?pest(?:\s|$)", + pattern: r"^(?:\./)?(?:vendor/bin/)?pest(?:\s|$)", rtk_cmd: "rtk pest", - rewrite_prefixes: &["./vendor/bin/pest", "vendor/bin/pest", "pest"], + rewrite_prefixes: &["pest"], category: "Tests", savings_pct: 80.0, subcmd_savings: &[], subcmd_status: &[], }, RtkRule { - pattern: r"^(?:vendor/bin/)?paratest(?:\s|$)", + pattern: r"^(?:\./)?(?:vendor/bin/)?paratest(?:\s|$)", rtk_cmd: "rtk paratest", - rewrite_prefixes: &["./vendor/bin/paratest", "vendor/bin/paratest", "paratest"], + rewrite_prefixes: &["paratest"], category: "Tests", savings_pct: 80.0, subcmd_savings: &[], subcmd_status: &[], }, RtkRule { - pattern: r"^(?:vendor/bin/)?ecs(?:\s|$)", + pattern: r"^(?:\./)?(?:vendor/bin/)?ecs(?:\s|$)", rtk_cmd: "rtk ecs", - rewrite_prefixes: &["./vendor/bin/ecs", "vendor/bin/ecs", "ecs"], + rewrite_prefixes: &["ecs"], category: "Build", savings_pct: 70.0, subcmd_savings: &[], subcmd_status: &[], }, RtkRule { - pattern: r"^(?:vendor/bin/)?pint(?:\s|$)", + pattern: r"^(?:\./)?(?:vendor/bin/)?pint(?:\s|$)", rtk_cmd: "rtk pint", - rewrite_prefixes: &["./vendor/bin/pint", "vendor/bin/pint", "pint"], + rewrite_prefixes: &["pint"], category: "Build", savings_pct: 70.0, subcmd_savings: &[], From 0cc15dc4484d9bec5a11e770515f8c2d69378de5 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Mon, 29 Jun 2026 19:45:47 -0400 Subject: [PATCH 12/19] fix(php): default pint applied_fixers when key is absent PHP-CS-Fixer omits the fixers key in dry-run/diff modes (it is optional in the JSON reporter), so a valid report like {"files":[{"name":"x.php"}]} failed the whole parse and fell back to raw output. Mark the field #[serde(default)] so a missing key deserializes to an empty vec. --- src/cmds/php/pint_cmd.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/cmds/php/pint_cmd.rs b/src/cmds/php/pint_cmd.rs index dd356cf4ac..51068a7469 100644 --- a/src/cmds/php/pint_cmd.rs +++ b/src/cmds/php/pint_cmd.rs @@ -27,7 +27,9 @@ struct PintFile { // Aliases keep both schemas parsing so output stays compressed across versions. #[serde(alias = "path")] name: String, - #[serde(rename = "appliedFixers", alias = "fixers")] + // PHP-CS-Fixer omits the fixers key entirely in dry-run/diff modes, so it + // must default rather than fail the whole parse. + #[serde(rename = "appliedFixers", alias = "fixers", default)] applied_fixers: Vec, } @@ -207,6 +209,16 @@ mod tests { assert!(result.contains("+2 more rules"), "got: {}", result); } + #[test] + fn test_pint_file_without_fixers_key() { + // PHP-CS-Fixer omits applied_fixers when there's nothing to report; + // the entry must still parse rather than fall back to raw output. + let json = r#"{"files":[{"name":"x.php"}]}"#; + let result = filter_pint_json(json); + assert!(result.contains("0 changes in 1 files"), "got: {}", result); + assert!(result.contains("x.php (0)"), "got: {}", result); + } + #[test] fn test_pint_invalid_json_falls_back() { let result = filter_pint_json("Laravel Pint v1.13.6\n\n... some text ..."); From 8825480b7af671cb7cbf87f8c902f79486d4eb95 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Tue, 30 Jun 2026 08:18:27 -0400 Subject: [PATCH 13/19] fix(php): strip ANSI in phpunit filter and split errors from failures filter_phpunit_output matched on raw bytes, so `--colors=always` output (colorized "OK ("/"FAILURES!"/"Tests:" lines) defeated every anchor and counts were lost. Strip ANSI first, mirroring the other PHP filters. Also report PHPUnit errors (thrown exceptions) distinctly from failures (assertion mismatches) instead of lumping both under "failures". --- src/cmds/php/phpunit_cmd.rs | 82 +++++++++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 22 deletions(-) diff --git a/src/cmds/php/phpunit_cmd.rs b/src/cmds/php/phpunit_cmd.rs index 80bdbc23e3..ff7cf30c11 100644 --- a/src/cmds/php/phpunit_cmd.rs +++ b/src/cmds/php/phpunit_cmd.rs @@ -5,7 +5,7 @@ //! plus a bounded list of failures with their first two detail lines. //! Dot-progress lines and headers are stripped entirely. -use super::utils::php_tool_command; +use super::utils::{php_tool_command, strip_ansi_and_controls}; use crate::core::runner; use anyhow::Result; use lazy_static::lazy_static; @@ -41,6 +41,12 @@ pub fn run(args: &[String], verbose: u8) -> Result { } pub(crate) fn filter_phpunit_output(output: &str) -> String { + // PHPUnit colorizes its result line and progress with ANSI under + // `--colors=always`; without stripping, the "OK ("/"FAILURES!"/"Tests:" + // anchors below never match and real counts are lost. + let cleaned = strip_ansi_and_controls(output); + let output = cleaned.as_str(); + let mut failures: Vec> = Vec::new(); let mut current: Vec = Vec::new(); let mut in_failures = false; @@ -88,9 +94,12 @@ pub(crate) fn filter_phpunit_output(output: &str) -> String { } if failures.is_empty() { - let (tests, assertions, _, _) = parse_counts(output); - if tests > 0 { - return format!("PHPUnit: {} tests, {} assertions", tests, assertions); + let counts = parse_counts(output); + if counts.tests > 0 { + return format!( + "PHPUnit: {} tests, {} assertions", + counts.tests, counts.assertions + ); } return "PHPUnit: ok".to_string(); } @@ -103,24 +112,33 @@ fn is_numbered_failure_heading(line: &str) -> bool { } fn build_success_with_skipped(output: &str) -> String { - let (tests, assertions, _, skipped) = parse_counts(output); - if skipped > 0 { + let counts = parse_counts(output); + if counts.skipped > 0 { format!( "PHPUnit: {} tests, {} assertions, {} skipped", - tests, assertions, skipped + counts.tests, counts.assertions, counts.skipped ) } else { - format!("PHPUnit: {} tests, {} assertions", tests, assertions) + format!( + "PHPUnit: {} tests, {} assertions", + counts.tests, counts.assertions + ) } } fn build_phpunit_summary(output: &str, failures: &[Vec]) -> String { - let (tests, assertions, failures_count, _skipped) = parse_counts(output); + let counts = parse_counts(output); + // PHPUnit separates failures (assertion mismatches) from errors (thrown + // exceptions); report them distinctly rather than lumping under "failures". let mut result = format!( - "PHPUnit: {} tests, {} assertions, {} failures\n", - tests, assertions, failures_count + "PHPUnit: {} tests, {} assertions, {} failures", + counts.tests, counts.assertions, counts.failures ); + if counts.errors > 0 { + result.push_str(&format!(", {} errors", counts.errors)); + } + result.push('\n'); for failure_lines in failures.iter().take(MAX_FAILURES_SHOWN) { if let Some(first) = failure_lines.first() { @@ -145,11 +163,8 @@ fn build_phpunit_summary(output: &str, failures: &[Vec]) -> String { result.trim().to_string() } -fn parse_counts(output: &str) -> (usize, usize, usize, usize) { - let mut tests = 0; - let mut assertions = 0; - let mut failures = 0; - let mut skipped = 0; +fn parse_counts(output: &str) -> Counts { + let mut counts = Counts::default(); for line in output.lines() { let trimmed = line.trim(); @@ -168,16 +183,26 @@ fn parse_counts(output: &str) -> (usize, usize, usize, usize) { .unwrap_or(0); match key { - "Tests:" => tests = val, - "Assertions:" => assertions = val, - k if k.starts_with("Failures") || k.starts_with("Errors") => failures += val, - k if k.starts_with("Skipped") => skipped = val, + "Tests:" => counts.tests = val, + "Assertions:" => counts.assertions = val, + k if k.starts_with("Failures") => counts.failures += val, + k if k.starts_with("Errors") => counts.errors += val, + k if k.starts_with("Skipped") => counts.skipped = val, _ => {} } } } - (tests, assertions, failures, skipped) + counts +} + +#[derive(Default)] +struct Counts { + tests: usize, + assertions: usize, + failures: usize, + errors: usize, + skipped: usize, } #[cfg(test)] @@ -311,7 +336,8 @@ ERRORS! Tests: 1, Assertions: 0, Errors: 1."#; let result = filter_phpunit_output(output); assert!(result.contains("FooTest::testBar"), "got: {}", result); - assert!(result.contains("1 failures"), "got: {}", result); + // Errors are now reported distinctly from failures. + assert!(result.contains("0 failures, 1 errors"), "got: {}", result); } #[test] @@ -332,6 +358,18 @@ Tests: 1, Assertions: 0, Errors: 1."#; assert!(result.contains("+5 more failures"), "got: {}", result); } + #[test] + fn test_phpunit_strips_ansi_colors() { + // --colors=always wraps the result line; anchors must still match. + let colored = "\x1b[30;42mOK\x1b[0m \x1b[32m(9 tests, 20 assertions)\x1b[0m"; + let result = filter_phpunit_output(colored); + assert!( + result.contains("OK (9 tests, 20 assertions)"), + "got: {}", + result + ); + } + #[test] fn test_phpunit_empty_ok_fallback() { let result = filter_phpunit_output(""); From 7cc46b46766e4b37256f385392ed0c6a4ea7bc85 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Tue, 30 Jun 2026 08:18:27 -0400 Subject: [PATCH 14/19] fix(php): detect phpstan analyse after global flags; avoid duplicate format `phpstan -c phpstan.neon analyse src/` put a global option before the subcommand, so the first-arg-only check missed it and the run fell back to unfiltered passthrough. Scan all args for analyse/analyze. Replace the has_custom_format bool with a 3-state ErrorFormat enum so an explicit `--error-format=json` is preserved without appending a second `--error-format json`. Strip ANSI in the text fallback for `--ansi`. --- src/cmds/php/phpstan_cmd.rs | 126 ++++++++++++++++++++++++++++-------- 1 file changed, 99 insertions(+), 27 deletions(-) diff --git a/src/cmds/php/phpstan_cmd.rs b/src/cmds/php/phpstan_cmd.rs index ea11cd83d0..e3c1547bc6 100644 --- a/src/cmds/php/phpstan_cmd.rs +++ b/src/cmds/php/phpstan_cmd.rs @@ -54,10 +54,7 @@ pub fn run(args: &[String], verbose: u8) -> Result { // Utility commands (--version, list, clear-result-cache, worker, …): real passthrough. // Only analyse/analyze subcommands get filtered and token-tracked. - let is_analyse = args - .first() - .map(|a| a == "analyse" || a == "analyze") - .unwrap_or(false); + let is_analyse = is_analyse_command(args); if !is_analyse { if verbose > 0 { @@ -68,30 +65,13 @@ pub fn run(args: &[String], verbose: u8) -> Result { return Ok(exit_code_from_status(&status, "phpstan")); } - // Detect if user specified a custom output format (not json). - // Handles both `--error-format=table` and `--error-format table` forms. - let has_custom_format = { - let mut it = args.iter().peekable(); - let mut found = false; - while let Some(a) = it.next() { - if a == "--error-format" { - if it.peek().map(|v| v.as_str()) != Some("json") { - found = true; - } - break; - } - if a.starts_with("--error-format=") && a != "--error-format=json" { - found = true; - break; - } - } - found - }; + // Detect whether the user already chose an output format. When they passed + // `--error-format=json` we must NOT append a second one; only inject json + // when no format is present. + let fmt = detect_error_format(args); - // Pass user args first (subcommand must come before global flags for PHPStan), - // then append --error-format=json unless the user specified a custom format. cmd.args(args); - if !has_custom_format { + if matches!(fmt, ErrorFormat::Unspecified) { cmd.arg("--error-format").arg("json"); } @@ -99,12 +79,13 @@ pub fn run(args: &[String], verbose: u8) -> Result { eprintln!("Running: phpstan {}", args.join(" ")); } + let use_text = matches!(fmt, ErrorFormat::Custom); runner::run_filtered( cmd, "phpstan", &args.join(" "), move |stdout| { - if has_custom_format { + if use_text { filter_phpstan_text(stdout) } else { filter_phpstan_json(stdout) @@ -114,6 +95,44 @@ pub fn run(args: &[String], verbose: u8) -> Result { ) } +/// True when the invocation runs the `analyse`/`analyze` subcommand. The +/// subcommand may appear after global options (`phpstan -c x.neon analyse src/`), +/// so scan every arg rather than just the first. +fn is_analyse_command(args: &[String]) -> bool { + args.iter().any(|a| a == "analyse" || a == "analyze") +} + +/// Which `--error-format` the user requested, if any. +enum ErrorFormat { + /// No format flag — rtk injects `--error-format=json`. + Unspecified, + /// User explicitly asked for json — keep it, don't duplicate the flag. + Json, + /// User asked for a non-json format — leave args alone, use the text filter. + Custom, +} + +/// Parse `--error-format=` / `--error-format ` from the args. +fn detect_error_format(args: &[String]) -> ErrorFormat { + let mut it = args.iter().peekable(); + while let Some(a) = it.next() { + if a == "--error-format" { + return match it.peek().map(|v| v.as_str()) { + Some("json") => ErrorFormat::Json, + _ => ErrorFormat::Custom, + }; + } + if let Some(val) = a.strip_prefix("--error-format=") { + return if val == "json" { + ErrorFormat::Json + } else { + ErrorFormat::Custom + }; + } + } + ErrorFormat::Unspecified +} + // ── JSON filtering ─────────────────────────────────────────────────────────── pub(crate) fn filter_phpstan_json(output: &str) -> String { @@ -187,6 +206,11 @@ pub(crate) fn filter_phpstan_json(output: &str) -> String { // ── Text fallback ──────────────────────────────────────────────────────────── pub(crate) fn filter_phpstan_text(output: &str) -> String { + // The text path matches on substrings ("[OK]", "Found N errors"); strip + // ANSI first so `--ansi` colorized output still matches. + let cleaned = super::utils::strip_ansi_and_controls(output); + let output = cleaned.as_str(); + // Check for errors first for line in output.lines() { let t = line.trim(); @@ -472,6 +496,54 @@ Found 5 errors in 3 files"#; assert_eq!(result, "PHPStan: [ERROR] Found 2 errors"); } + fn args(s: &[&str]) -> Vec { + s.iter().map(|a| a.to_string()).collect() + } + + #[test] + fn test_is_analyse_detects_subcommand_after_global_flag() { + // Regression: `phpstan -c phpstan.neon analyse src/` must be filtered. + assert!(is_analyse_command(&args(&[ + "-c", + "phpstan.neon", + "analyse", + "src/" + ]))); + assert!(is_analyse_command(&args(&["analyze", "src/"]))); + assert!(!is_analyse_command(&args(&["--version"]))); + assert!(!is_analyse_command(&args(&["clear-result-cache"]))); + } + + #[test] + fn test_detect_error_format() { + assert!(matches!( + detect_error_format(&args(&["analyse", "src/"])), + ErrorFormat::Unspecified + )); + assert!(matches!( + detect_error_format(&args(&["analyse", "--error-format", "json"])), + ErrorFormat::Json + )); + assert!(matches!( + detect_error_format(&args(&["analyse", "--error-format=json"])), + ErrorFormat::Json + )); + assert!(matches!( + detect_error_format(&args(&["analyse", "--error-format", "table"])), + ErrorFormat::Custom + )); + assert!(matches!( + detect_error_format(&args(&["analyse", "--error-format=table"])), + ErrorFormat::Custom + )); + } + + #[test] + fn test_filter_phpstan_text_strips_ansi() { + let text = "\x1b[32m [OK] No errors\x1b[0m"; + assert_eq!(filter_phpstan_text(text), "phpstan: ok"); + } + #[test] fn test_filter_phpstan_text_error_summary_case_insensitive() { // phpstan prints "[ERROR] Found N errors" — capital F and no " in ", From 28366ce17bd9f7fe898deee8b2a7ca8e3223c8de Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Tue, 30 Jun 2026 08:18:27 -0400 Subject: [PATCH 15/19] fix(php): drop bogus pest.php check in test-runner detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pest has no root `pest.php` file — its bootstrap lives at tests/Pest.php and its canonical marker is the vendor/bin/pest binary. The root-level check never matched real Pest projects and false-positived on unrelated utility files, misrouting PHPUnit-only projects to the Pest filter. --- src/cmds/php/utils.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cmds/php/utils.rs b/src/cmds/php/utils.rs index bae5f434de..f056cd4890 100644 --- a/src/cmds/php/utils.rs +++ b/src/cmds/php/utils.rs @@ -43,7 +43,11 @@ pub enum PhpTestRunner { } pub fn detect_php_test_runner() -> PhpTestRunner { - if composer_tool_exists("pest") || Path::new("pest.php").exists() { + // Pest's canonical marker is the `vendor/bin/pest` binary (composer dep). + // There is no root `pest.php` file — Pest's bootstrap lives at `tests/Pest.php` + // — so a root-level `pest.php` check both never matches Pest and false-positives + // on unrelated utility files in PHPUnit-only projects. + if composer_tool_exists("pest") { return PhpTestRunner::Pest; } From 50a8743041081f7185ecd8e3ca48e432fb8ca5f4 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Tue, 30 Jun 2026 08:18:27 -0400 Subject: [PATCH 16/19] fix(pipe): anchor phpunit auto-detection to the leading banner Matching only "by Sebastian Bergmann" misrouted any LICENSE, composer metadata, or git log mentioning the author to the phpunit filter. Require the input to start with "PHPUnit " as well. --- src/cmds/system/pipe_cmd.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index f12ac7dcbf..563d54a10f 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -179,12 +179,15 @@ pub fn auto_detect_filter(input: &str) -> fn(&str) -> String { return crate::cmds::python::pytest_cmd::filter_pytest_output; } + let first_trimmed = first_1k.trim_start(); + // phpunit banner: "PHPUnit X.Y.Z by Sebastian Bergmann and contributors." - if first_1k.contains("by Sebastian Bergmann") { + // Anchor to the leading "PHPUnit " token so a LICENSE/composer/`git log` + // that merely mentions the author isn't misrouted here. + if first_trimmed.starts_with("PHPUnit ") && first_1k.contains("by Sebastian Bergmann") { return crate::cmds::php::phpunit_cmd::filter_phpunit_output; } - let first_trimmed = first_1k.trim_start(); if first_trimmed.starts_with('{') && first_1k.contains("\"Action\"") { return go_test_wrapper; } @@ -289,6 +292,16 @@ mod tests { assert!(out.starts_with("PHPUnit:"), "out={}", out); } + #[test] + fn test_auto_detect_phpunit_not_misrouted_by_author_mention() { + // Text that merely mentions the author (LICENSE, git log, composer meta) + // must pass through untouched, not route to the phpunit filter. + let input = "commit abc123\nAuthor: written by Sebastian Bergmann and contributors\n\n Update changelog\n"; + let f = auto_detect_filter(input); + let out = f(input); + assert_eq!(out, input, "should pass through unchanged, got: {}", out); + } + #[test] fn test_resolve_filter_phpunit() { assert!(resolve_filter("phpunit").is_some()); From 5d2928c64e14b96e91a80761136aeb3e27fe5480 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Tue, 30 Jun 2026 08:18:27 -0400 Subject: [PATCH 17/19] perf(php): cache composer_bin_dirs to avoid per-segment file reads composer_bin_dirs() re-read composer.json and the env on every call; the rewrite hot path queries it several times per command segment via normalize_php_tool_command (both classify_command and rewrite_segment_inner). Resolution is constant for a process, so cache it in a OnceLock. --- src/core/utils.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/core/utils.rs b/src/core/utils.rs index 12e37b757a..2d5c983639 100644 --- a/src/core/utils.rs +++ b/src/core/utils.rs @@ -11,6 +11,7 @@ use serde_json::Value; use std::fs; use std::path::PathBuf; use std::process::Command; +use std::sync::OnceLock; /// Truncates a string to `max_len` characters, appending `...` if needed. /// @@ -363,9 +364,17 @@ pub fn resolved_command(name: &str) -> Command { /// or `composer.json` `config.bin-dir`. Keep the default as a fallback so we /// continue recognizing the common layout even when the repo is not configured. pub fn composer_bin_dirs() -> Vec { - let env_bin_dir = std::env::var("COMPOSER_BIN_DIR").ok(); - let composer_json = fs::read_to_string("composer.json").ok(); - composer_bin_dirs_from(env_bin_dir.as_deref(), composer_json.as_deref()) + // Resolution depends only on the process's env + cwd composer.json, both + // constant for a single rtk invocation. The rewrite hot path queries this + // several times per command segment, so read the file once and cache. + static CACHE: OnceLock> = OnceLock::new(); + CACHE + .get_or_init(|| { + let env_bin_dir = std::env::var("COMPOSER_BIN_DIR").ok(); + let composer_json = fs::read_to_string("composer.json").ok(); + composer_bin_dirs_from(env_bin_dir.as_deref(), composer_json.as_deref()) + }) + .clone() } pub fn composer_tool_paths(tool: &str) -> Vec { From fe755957e16a894c541d22254585156150fd59c7 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Wed, 1 Jul 2026 06:11:33 -0400 Subject: [PATCH 18/19] test(php): add paratest unit tests to satisfy test-presence gate paratest_cmd.rs lacked the mandatory #[cfg(test)] module, failing the check-test-presence CI gate. Add tests exercising the ParaTest-specific path in filter_test_runner_output: banner + "Random Seed:" + dot progress stripped, result summary and failure details preserved. --- src/cmds/php/paratest_cmd.rs | 45 ++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/cmds/php/paratest_cmd.rs b/src/cmds/php/paratest_cmd.rs index 3585f3ffac..dd7918af6c 100644 --- a/src/cmds/php/paratest_cmd.rs +++ b/src/cmds/php/paratest_cmd.rs @@ -29,3 +29,48 @@ pub fn run(args: &[String], verbose: u8) -> Result { runner::RunOptions::default(), ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_paratest_strips_banner_seed_and_progress() { + // ParaTest prints its own banner and a "Random Seed:" line on top of + // PHPUnit-style dot progress; only the result summary should survive. + let output = "ParaTest v7.3.0 upon PHPUnit 10.5.0 by Sebastian Bergmann and contributors.\n\ + Random Seed: 1234567890\n\ + .......... 10 / 10 (100%)\n\n\ + OK (10 tests, 25 assertions)\n"; + let filtered = filter_test_runner_output(output); + assert!(!filtered.contains("ParaTest v7.3.0"), "got: {}", filtered); + assert!(!filtered.contains("Random Seed:"), "got: {}", filtered); + assert!(!filtered.contains("10 / 10 (100%)"), "got: {}", filtered); + assert!( + filtered.contains("OK (10 tests, 25 assertions)"), + "got: {}", + filtered + ); + } + + #[test] + fn test_paratest_keeps_failures() { + let output = "ParaTest v7.3.0 upon PHPUnit 10.5.0\n\ + ..F.\n\ + There was 1 failure:\n\ + 1) App\\Tests\\UserTest::testEmail\n\ + Failed asserting that false is true.\n"; + let filtered = filter_test_runner_output(output); + assert!(!filtered.contains("ParaTest v7.3.0"), "got: {}", filtered); + assert!( + filtered.contains("App\\Tests\\UserTest::testEmail"), + "got: {}", + filtered + ); + assert!( + filtered.contains("Failed asserting that false is true."), + "got: {}", + filtered + ); + } +} From d67fc28884cd0cd7adb3d4e9849a099cc47c46d6 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Wed, 1 Jul 2026 06:18:31 -0400 Subject: [PATCH 19/19] test(php): add token-savings assertion to paratest per cli-testing guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .claude/rules/cli-testing.md marks token-savings verification as a 🔴 requirement for filters. Add a ≥60% savings assertion over a realistic paratest run (banner + config + parallel-worker progress → one-line summary), alongside the existing behavioral tests. --- src/cmds/php/paratest_cmd.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/cmds/php/paratest_cmd.rs b/src/cmds/php/paratest_cmd.rs index dd7918af6c..a32c614e79 100644 --- a/src/cmds/php/paratest_cmd.rs +++ b/src/cmds/php/paratest_cmd.rs @@ -73,4 +73,36 @@ mod tests { filtered ); } + + #[test] + fn test_paratest_token_savings() { + use crate::core::utils::count_tokens; + + // A realistic passing run: banner + config + many parallel-worker + // progress lines, collapsing to a one-line summary. + let mut output = String::from( + "ParaTest v7.3.0 upon PHPUnit 10.5.0 by Sebastian Bergmann and contributors.\n\ + Runtime: PHP 8.3.10\n\ + Configuration: /var/www/html/phpunit.xml\n\ + Random Seed: 1234567890\n\n", + ); + for i in 1..=40 { + output.push_str(&format!( + ".......................................... {} / 40 ({}%)\n", + i, + i * 100 / 40 + )); + } + output.push_str("\nOK (400 tests, 1200 assertions)\n"); + + let filtered = filter_test_runner_output(&output); + let savings = + 100.0 - (count_tokens(&filtered) as f64 / count_tokens(&output) as f64 * 100.0); + assert!( + savings >= 60.0, + "expected ≥60% savings, got {:.1}%\n{}", + savings, + filtered + ); + } }