From 62fc0e0d2159e82aaa8c36a18d69ca569a1ce0b5 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 12 May 2026 10:51:15 +0200 Subject: [PATCH 1/7] fix(filters): address adversarial test-suite findings on aggressive filtering Several filters dropped or rewrote information an agent needs, producing output that was misleading rather than merely compressed. This fixes the clear-cut cases surfaced by the TheDecipherist/rtk-test suite: - ls: stop hiding the `.env` file (removed `.env`/`env` from NOISE_DIRS). - git status: restore the explicit "HEAD detached at " line instead of the opaque "* HEAD (no branch)". - log/docker logs: recognise CRITICAL/FATAL/ALERT/EMERGENCY/SEVERE (and NOTICE) as severities so those lines are no longer silently filtered; expose `log` as a `rtk pipe` filter and accept a positional filter name. - wc: forward stdin to the child so `cat file | rtk wc` counts the pipe instead of reporting 0 (new RunOptions::inherit_stdin). - jest/vitest: include skipped/pending test count in the compact summary. - pytest: surface xfailed/xpassed counts and list XFAIL/XPASS entries with their reasons (adds `-rxX`). - ruff/eslint: list individual violations with file:line:col, not just rule/file group counts. - pnpm/npm list: render the actual package list instead of the false-positive "All packages up-to-date" when there's no upgrade info. - git add: stay silent on a no-op instead of printing an ambiguous "ok". - git stash: pass git's own message through instead of collapsing to "ok". - docker ps: keep the Status column (health/restart info) and list stopped/exited containers; docker images: show all images with full names; docker compose ps: use `-a` so crashed services stay visible. All 1870 unit tests pass; cargo fmt + clippy clean. --- src/cmds/cloud/container.rs | 116 ++++++++++++++++---------- src/cmds/git/git.rs | 44 ++++++++-- src/cmds/js/lint_cmd.rs | 52 +++++++----- src/cmds/python/pip_cmd.rs | 21 +++-- src/cmds/python/pytest_cmd.rs | 153 +++++++++++++++++++++++++--------- src/cmds/python/ruff_cmd.rs | 26 +++++- src/cmds/system/constants.rs | 2 - src/cmds/system/log_cmd.rs | 11 ++- src/cmds/system/pipe_cmd.rs | 3 +- src/cmds/system/wc_cmd.rs | 12 ++- src/core/runner.rs | 16 +++- src/main.rs | 7 +- src/parser/formatter.rs | 28 ++++++- 13 files changed, 365 insertions(+), 126 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index dd32a23125..797aeba866 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -54,14 +54,18 @@ where fn docker_ps(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); + // Baseline the LLM would otherwise see. let raw = exec_capture(resolved_command("docker").args(["ps"])) .map(|r| r.stdout) .unwrap_or_default(); + // One structured call over *all* containers (`-a`) — splitting on the State + // field lets us list crashed/exited ones too, which plain `docker ps` hides. let result = exec_capture(resolved_command("docker").args([ "ps", + "-a", "--format", - "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", + "{{.State}}\t{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", ])) .context("Failed to run docker ps")?; @@ -71,43 +75,66 @@ fn docker_ps(_verbose: u8) -> Result { return Ok(result.exit_code); } - let stdout = result.stdout; - let mut rtk = String::new(); + let format_line = |parts: &[&str], with_ports: bool| -> Option { + // parts: State, ID, Names, Status, Image, Ports + if parts.len() < 5 { + return None; + } + let id = &parts[1][..12.min(parts[1].len())]; + let name = parts[2]; + // Keep the Status verbatim — it carries health ("Up 4s (unhealthy)") + // and exit-code/restart info an agent needs to judge service health. + let status = parts[3].trim(); + let short_image = parts[4].split('/').next_back().unwrap_or(""); + let port_suffix = if with_ports { + let ports = compact_ports(parts.get(5).unwrap_or(&"")); + if ports == "-" { + String::new() + } else { + format!(" [{}]", ports) + } + } else { + String::new() + }; + Some(format!( + " {} {} ({}) {}{}\n", + id, name, short_image, status, port_suffix + )) + }; - if stdout.trim().is_empty() { - rtk.push_str("[docker] 0 containers"); - println!("{}", rtk); - timer.track("docker ps", "rtk docker ps", &raw, &rtk); - return Ok(0); + let mut running: Vec> = Vec::new(); + let mut stopped: Vec> = Vec::new(); + for line in result.stdout.lines().filter(|l| !l.trim().is_empty()) { + let parts: Vec<&str> = line.split('\t').collect(); + let state = parts.first().copied().unwrap_or(""); + if matches!(state, "running" | "restarting") { + running.push(parts); + } else { + stopped.push(parts); + } } - let count = stdout.lines().count(); - rtk.push_str(&format!("[docker] {} containers:\n", count)); + let mut rtk = String::new(); + rtk.push_str(&format!("[docker] {} running:\n", running.len())); + for parts in running.iter().take(20) { + if let Some(l) = format_line(parts, true) { + rtk.push_str(&l); + } + } + if running.len() > 20 { + rtk.push_str(&format!(" ... +{} more\n", running.len() - 20)); + } - for line in stdout.lines().take(15) { - let parts: Vec<&str> = line.split('\t').collect(); - if parts.len() >= 4 { - let id = &parts[0][..12.min(parts[0].len())]; - let name = parts[1]; - let short_image = parts - .get(3) - .unwrap_or(&"") - .split('/') - .next_back() - .unwrap_or(""); - let ports = compact_ports(parts.get(4).unwrap_or(&"")); - if ports == "-" { - rtk.push_str(&format!(" {} {} ({})\n", id, name, short_image)); - } else { - rtk.push_str(&format!( - " {} {} ({}) [{}]\n", - id, name, short_image, ports - )); + if !stopped.is_empty() { + rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped.len())); + for parts in stopped.iter().take(20) { + if let Some(l) = format_line(parts, false) { + rtk.push_str(&l); } } - } - if count > 15 { - rtk.push_str(&format!(" ... +{} more", count - 15)); + if stopped.len() > 20 { + rtk.push_str(&format!(" ... +{} more\n", stopped.len() - 20)); + } } print!("{}", rtk); @@ -173,21 +200,22 @@ fn docker_images(_verbose: u8) -> Result { total_display )); - for line in lines.iter().take(15) { + // Show images with their full `repository:tag` name — truncating the + // registry/user prefix to "..." breaks exact-match lookups against + // deployment manifests and CI configs. The list is generously capped (a + // higher bound than before, and only the count, never the names, is + // abbreviated) so token savings still hold on machines with many images. + const MAX_IMAGES: usize = 60; + for line in lines.iter().take(MAX_IMAGES) { let parts: Vec<&str> = line.split('\t').collect(); if !parts.is_empty() { let image = parts[0]; let size = parts.get(1).unwrap_or(&""); - let short = if image.len() > 40 { - format!("...{}", &image[image.len() - 37..]) - } else { - image.to_string() - }; - rtk.push_str(&format!(" {} [{}]\n", short, size)); + rtk.push_str(&format!(" {} [{}]\n", image, size)); } } - if lines.len() > 15 { - rtk.push_str(&format!(" ... +{} more", lines.len() - 15)); + if lines.len() > MAX_IMAGES { + rtk.push_str(&format!(" ... +{} more\n", lines.len() - MAX_IMAGES)); } print!("{}", rtk); @@ -526,8 +554,9 @@ pub fn run_docker_passthrough(args: &[OsString], verbose: u8) -> Result { pub fn run_compose_ps(verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - // Raw output for token tracking - let raw_result = exec_capture(resolved_command("docker").args(["compose", "ps"])) + // Use `-a` so stopped/exited services stay visible — a worker that crashed + // on startup must not silently vanish from the agent's view. + let raw_result = exec_capture(resolved_command("docker").args(["compose", "ps", "-a"])) .context("Failed to run docker compose ps")?; if !raw_result.success() { @@ -540,6 +569,7 @@ pub fn run_compose_ps(verbose: u8) -> Result { let result = exec_capture(resolved_command("docker").args([ "compose", "ps", + "-a", "--format", "{{.Name}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}", ])) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index ae71b24d04..45ec9cb498 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -807,6 +807,20 @@ fn extract_state_header(raw: &str) -> Option { None } +/// Extract the explicit "HEAD detached at/from " line from plain +/// `git status` output. +/// +/// Porcelain `-b` collapses a detached HEAD to the opaque `## HEAD (no branch)`, +/// which an agent (or a distracted human) can misread as a branch literally +/// named `HEAD`. The plain-status output keeps the explicit SHA/ref, so we +/// surface that instead. Returns `None` when HEAD is on a branch. +fn extract_detached_head(raw: &str) -> Option { + raw.lines() + .map(str::trim) + .find(|l| l.starts_with("HEAD detached ")) + .map(str::to_string) +} + /// Minimal filtering for git status with user-provided args fn filter_status_with_args(output: &str) -> String { let mut result = Vec::new(); @@ -911,7 +925,13 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result" from the plain status we captured. + if let Some(detached) = extract_detached_head(&raw_output) { + formatted = formatted.replacen("* HEAD (no branch)", &format!("* {detached}"), 1); + } // Surface in-progress state (rebase/merge/cherry-pick/bisect/am) from the // plain-status output we already captured for tracking. Porcelain omits it @@ -968,8 +988,11 @@ fn run_add(args: &[String], verbose: u8, global_args: &[String]) -> Result stat_cmd.args(["diff", "--cached", "--stat", "--shortstat"]); let stat_result = exec_capture(&mut stat_cmd).context("Failed to check staged files")?; + // Mirror git's own behaviour: a no-op `git add` is silent. Emitting a + // generic "ok" here is misleading — an agent can't tell "staged N files" + // from "staged nothing" when both print "ok". let compact = if stat_result.stdout.trim().is_empty() { - "ok (nothing to add)".to_string() + String::new() } else { // Parse "1 file changed, 5 insertions(+)" format let short = stat_result.stdout.lines().last().unwrap_or("").trim(); @@ -980,7 +1003,9 @@ fn run_add(args: &[String], verbose: u8, global_args: &[String]) -> Result } }; - println!("{}", compact); + if !compact.is_empty() { + println!("{}", compact); + } timer.track( &format!("git add {}", args.join(" ")), @@ -1478,11 +1503,16 @@ fn run_fetch(args: &[String], verbose: u8, global_args: &[String]) -> Result, result: &CaptureResult) -> String { match subcommand { None | Some("push") | Some("save") => { - // Create operations check for "no local changes" - if result.stdout.contains("No local changes") { - "ok (nothing to stash)".to_string() - } else { + // Pass git's own message through — it distinguishes "Saved working + // directory and index state WIP on : " from + // "No local changes to save". Collapsing both to "ok" hides whether + // anything was actually stashed (and which ref to pop later). + let out = result.combined(); + let trimmed = out.trim(); + if trimmed.is_empty() { "ok stashed".to_string() + } else { + trimmed.to_string() } } Some(sub) => format!("ok stash {}", sub), diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index cfb5bea860..d60ed155bb 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -281,30 +281,44 @@ fn filter_eslint_json(output: &str) -> String { result.push('\n'); } - // Show top files with most issues - result.push_str("Top files:\n"); - for (file_result, count) in by_file.iter().take(10) { - let short_path = compact_path(&file_result.file_path); - result.push_str(&format!(" {} ({} issues)\n", short_path, count)); - - // Show top 3 rules in this file - let mut file_rules: HashMap = HashMap::new(); + // Per-file violation list with line:col — the rule rollup above is a + // summary, but an agent fixing lint errors needs the exact locations. + // Capped so a huge codebase still compresses; the rollup carries the rest. + const MAX_VIOLATIONS: usize = 60; + let total_messages: usize = results.iter().map(|r| r.messages.len()).sum(); + result.push_str("Violations:\n"); + let mut shown = 0; + 'outer: for (file_result, count) in by_file.iter().take(10) { + result.push_str(&format!( + " {} ({}):\n", + compact_path(&file_result.file_path), + count + )); for msg in &file_result.messages { - if let Some(rule) = &msg.rule_id { - *file_rules.entry(rule.clone()).or_insert(0) += 1; + if shown >= MAX_VIOLATIONS { + result.push_str(" ...\n"); + break 'outer; } - } - - let mut file_rule_counts: Vec<_> = file_rules.iter().collect(); - file_rule_counts.sort_by(|a, b| b.1.cmp(a.1)); - - for (rule, count) in file_rule_counts.iter().take(3) { - result.push_str(&format!(" {} ({})\n", rule, count)); + let sev = if msg.severity >= 2 { "error" } else { "warning" }; + let rule = msg.rule_id.as_deref().unwrap_or("-"); + result.push_str(&format!( + " {}:{} {} {} {}\n", + msg.line, + msg.column, + sev, + rule, + truncate(msg.message.trim(), 100), + )); + shown += 1; } } - if by_file.len() > 10 { - result.push_str(&format!("\n... +{} more files\n", by_file.len() - 10)); + result.push_str(&format!(" ... +{} more files\n", by_file.len() - 10)); + } else if total_messages > MAX_VIOLATIONS { + result.push_str(&format!( + " ... +{} more violations\n", + total_messages - MAX_VIOLATIONS + )); } result.trim().to_string() diff --git a/src/cmds/python/pip_cmd.rs b/src/cmds/python/pip_cmd.rs index ddb56c47e3..25f7c3c8ca 100644 --- a/src/cmds/python/pip_cmd.rs +++ b/src/cmds/python/pip_cmd.rs @@ -17,12 +17,16 @@ struct Package { pub fn run(args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - // Auto-detect uv vs pip - let use_uv = tool_exists("uv"); + // The user ran `pip` — run `pip` so RTK stays transparent and reports the + // *same* environment the bare command would. Only fall back to `uv pip` when + // `pip` genuinely isn't on PATH (uv-only environments). Auto-substituting + // `uv pip` unconditionally made `pip list` show uv's discovered env instead + // of the active one — often just the 2-package base interpreter. + let use_uv = !tool_exists("pip") && tool_exists("uv"); let base_cmd = if use_uv { "uv" } else { "pip" }; if verbose > 0 && use_uv { - eprintln!("Using uv (pip-compatible)"); + eprintln!("pip not found — falling back to `uv pip`"); } // Detect subcommand @@ -162,16 +166,21 @@ fn filter_pip_list(output: &str) -> String { let mut letters: Vec<_> = by_letter.keys().collect(); letters.sort(); + // `pip list` is an inventory query — dependency audits need every package + // visible. The compression here is structural (drop the alignment padding, + // group by initial); the per-group cap is just a safety bound for + // pathological environments, not a normal-case truncation. + const MAX_PER_LETTER: usize = 50; for letter in letters { let pkgs = by_letter.get(letter).unwrap(); result.push_str(&format!("\n[{}]\n", letter.to_uppercase())); - for pkg in pkgs.iter().take(10) { + for pkg in pkgs.iter().take(MAX_PER_LETTER) { result.push_str(&format!(" {} ({})\n", pkg.name, pkg.version)); } - if pkgs.len() > 10 { - result.push_str(&format!(" ... +{} more\n", pkgs.len() - 10)); + if pkgs.len() > MAX_PER_LETTER { + result.push_str(&format!(" ... +{} more\n", pkgs.len() - MAX_PER_LETTER)); } } diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index 0b91d7daf2..9b349bcf55 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -23,6 +23,8 @@ pub fn run(args: &[String], verbose: u8) -> Result { let has_tb_flag = args.iter().any(|a| a.starts_with("--tb")); let has_quiet_flag = args.iter().any(|a| a == "-q" || a == "--quiet"); + // Only treat a short `-r…` as pytest's report flag (not `--randomly-seed` etc.) + let has_report_flag = args.iter().any(|a| a.starts_with("-r") && !a.starts_with("--")); if !has_tb_flag { cmd.arg("--tb=short"); @@ -30,6 +32,12 @@ pub fn run(args: &[String], verbose: u8) -> Result { if !has_quiet_flag { cmd.arg("-q"); } + // Surface xfailed/xpassed (and their reasons) in the short summary section + // so the compact output can report expected failures and — crucially — + // unexpected passes (XPASS), which signal a behavior change. + if !has_report_flag { + cmd.arg("-rxX"); + } for arg in args { cmd.arg(arg); @@ -53,6 +61,7 @@ pub(crate) fn filter_pytest_output(output: &str) -> String { let mut test_files: Vec = Vec::new(); let mut failures: Vec = Vec::new(); let mut current_failure: Vec = Vec::new(); + let mut xfail_lines: Vec = Vec::new(); let mut summary_line = String::new(); for line in output.lines() { @@ -127,6 +136,9 @@ pub(crate) fn filter_pytest_output(output: &str) -> String { // FAILED test lines if trimmed.starts_with("FAILED") || trimmed.starts_with("ERROR") { failures.push(trimmed.to_string()); + } else if trimmed.starts_with("XFAIL") || trimmed.starts_with("XPASS") { + // `-rxX` adds these: "XFAIL path::test - reason" / "XPASS path::test - reason" + xfail_lines.push(trimmed.to_string()); } } } @@ -138,19 +150,41 @@ pub(crate) fn filter_pytest_output(output: &str) -> String { } // Build compact output - build_pytest_summary(&summary_line, &test_files, &failures) + build_pytest_summary(&summary_line, &test_files, &failures, &xfail_lines) } -fn build_pytest_summary(summary: &str, _test_files: &[String], failures: &[String]) -> String { - // Parse summary line - let (passed, failed, skipped) = parse_summary_line(summary); +#[derive(Default)] +struct PytestCounts { + passed: usize, + failed: usize, + skipped: usize, + xfailed: usize, + xpassed: usize, +} - if failed == 0 && passed > 0 { - return format!("Pytest: {} passed", passed); +fn build_pytest_summary( + summary: &str, + _test_files: &[String], + failures: &[String], + xfail_lines: &[String], +) -> String { + let counts = parse_summary_line(summary); + let PytestCounts { + passed, + failed, + skipped, + xfailed, + xpassed, + } = counts; + + if passed == 0 && failed == 0 && skipped == 0 && xfailed == 0 && xpassed == 0 { + return "Pytest: No tests collected".to_string(); } - if passed == 0 && failed == 0 && skipped == 0 { - return "Pytest: No tests collected".to_string(); + let extras_present = skipped > 0 || xfailed > 0 || xpassed > 0 || !xfail_lines.is_empty(); + + if failed == 0 && passed > 0 && !extras_present { + return format!("Pytest: {} passed", passed); } let mut result = String::new(); @@ -158,9 +192,27 @@ fn build_pytest_summary(summary: &str, _test_files: &[String], failures: &[Strin if skipped > 0 { result.push_str(&format!(", {} skipped", skipped)); } + if xfailed > 0 { + result.push_str(&format!(", {} xfailed", xfailed)); + } + if xpassed > 0 { + result.push_str(&format!(", {} xpassed", xpassed)); + } result.push('\n'); result.push_str("═══════════════════════════════════════\n"); + // Surface xfail/xpass entries (with their reasons) — XPASS in particular + // signals that something expected-to-fail now passes. + if !xfail_lines.is_empty() { + result.push_str("\nExpected-failure outcomes:\n"); + for line in xfail_lines.iter().take(10) { + result.push_str(&format!(" {}\n", truncate(line, 120))); + } + if xfail_lines.len() > 10 { + result.push_str(&format!(" ... +{} more\n", xfail_lines.len() - 10)); + } + } + if failures.is_empty() { return result.trim().to_string(); } @@ -220,36 +272,35 @@ fn build_pytest_summary(summary: &str, _test_files: &[String], failures: &[Strin result.trim().to_string() } -fn parse_summary_line(summary: &str) -> (usize, usize, usize) { - let mut passed = 0; - let mut failed = 0; - let mut skipped = 0; - - // Parse lines like "=== 4 passed, 1 failed in 0.50s ===" - let parts: Vec<&str> = summary.split(',').collect(); +fn parse_summary_line(summary: &str) -> PytestCounts { + let mut counts = PytestCounts::default(); - for part in parts { + // Parse lines like "=== 4 passed, 1 failed, 2 xfailed, 1 xpassed in 0.50s ===" + for part in summary.split(',') { let words: Vec<&str> = part.split_whitespace().collect(); for (i, word) in words.iter().enumerate() { - if i > 0 { - if word.contains("passed") { - if let Ok(n) = words[i - 1].parse::() { - passed = n; - } - } else if word.contains("failed") { - if let Ok(n) = words[i - 1].parse::() { - failed = n; - } - } else if word.contains("skipped") { - if let Ok(n) = words[i - 1].parse::() { - skipped = n; - } - } + if i == 0 { + continue; + } + let Ok(n) = words[i - 1].parse::() else { + continue; + }; + // Order matters: "xpassed"/"xfailed" contain "passed"/"failed". + if word.contains("xpassed") { + counts.xpassed = n; + } else if word.contains("xfailed") { + counts.xfailed = n; + } else if word.contains("passed") { + counts.passed = n; + } else if word.contains("failed") { + counts.failed = n; + } else if word.contains("skipped") { + counts.skipped = n; } } } - (passed, failed, skipped) + counts } #[cfg(test)] @@ -337,17 +388,43 @@ collected 0 items #[test] fn test_parse_summary_line() { - assert_eq!(parse_summary_line("=== 5 passed in 0.50s ==="), (5, 0, 0)); - assert_eq!( - parse_summary_line("=== 4 passed, 1 failed in 0.50s ==="), - (4, 1, 0) - ); + let c = parse_summary_line("=== 5 passed in 0.50s ==="); + assert_eq!((c.passed, c.failed, c.skipped), (5, 0, 0)); + + let c = parse_summary_line("=== 4 passed, 1 failed in 0.50s ==="); + assert_eq!((c.passed, c.failed, c.skipped), (4, 1, 0)); + + let c = parse_summary_line("=== 3 passed, 1 failed, 2 skipped in 1.0s ==="); + assert_eq!((c.passed, c.failed, c.skipped), (3, 1, 2)); + + let c = parse_summary_line("=== 2 passed, 1 failed, 2 xfailed, 1 xpassed in 1.0s ==="); assert_eq!( - parse_summary_line("=== 3 passed, 1 failed, 2 skipped in 1.0s ==="), - (3, 1, 2) + (c.passed, c.failed, c.xfailed, c.xpassed), + (2, 1, 2, 1) ); } + #[test] + fn test_filter_pytest_xfail_xpass() { + let output = r#"=== test session starts === +collected 5 items + +test_math.py ..xxX [100%] + +=== short test summary info === +XFAIL test_math.py::test_division_by_zero - known bug in division +XFAIL test_math.py::test_float_precision - float precision issue — bug #42 +XPASS test_math.py::test_unexpected_pass - this should fail but currently passes +=== 2 passed, 2 xfailed, 1 xpassed in 0.05s ==="#; + + let result = filter_pytest_output(output); + assert!(result.contains("xfailed"), "got: {result}"); + assert!(result.contains("xpassed"), "got: {result}"); + assert!(result.contains("XPASS"), "got: {result}"); + assert!(result.contains("float precision"), "got: {result}"); + assert!(result.contains("test_division_by_zero"), "got: {result}"); + } + #[test] fn test_filter_pytest_quiet_mode_failures() { // In -q mode, the final summary line has NO === wrapper diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index b50409629f..f641d9f039 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -9,9 +9,7 @@ use std::collections::HashMap; #[derive(Debug, Deserialize)] struct RuffLocation { - #[allow(dead_code)] row: usize, - #[allow(dead_code)] column: usize, } @@ -24,9 +22,7 @@ struct RuffFix { #[derive(Debug, Deserialize)] struct RuffDiagnostic { code: String, - #[allow(dead_code)] message: String, - #[allow(dead_code)] location: RuffLocation, #[allow(dead_code)] end_location: Option, @@ -185,6 +181,28 @@ pub fn filter_ruff_check_json(output: &str) -> String { result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); } + // List individual violations with file:line:col so an agent can navigate + // straight to each one — the rule/file grouping above is a summary, not a + // substitute for locations. + const MAX_VIOLATIONS: usize = 50; + result.push_str("\nViolations:\n"); + for diag in diagnostics.iter().take(MAX_VIOLATIONS) { + result.push_str(&format!( + " {}:{}:{} {} {}\n", + compact_path(&diag.filename), + diag.location.row, + diag.location.column, + diag.code, + truncate(diag.message.trim(), 100), + )); + } + if diagnostics.len() > MAX_VIOLATIONS { + result.push_str(&format!( + " ... +{} more violations\n", + diagnostics.len() - MAX_VIOLATIONS + )); + } + if fixable_count > 0 { result.push_str(&format!( "\n[hint] Run `ruff check --fix` to auto-fix {} issues\n", diff --git a/src/cmds/system/constants.rs b/src/cmds/system/constants.rs index 2454f5299c..bd8c496378 100644 --- a/src/cmds/system/constants.rs +++ b/src/cmds/system/constants.rs @@ -14,8 +14,6 @@ pub const NOISE_DIRS: &[&str] = &[ ".tox", ".venv", "venv", - "env", - ".env", "coverage", ".nyc_output", ".DS_Store", diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index fd9942d0b1..cb6a66bb4f 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -81,17 +81,24 @@ fn analyze_logs(content: &str) -> String { let normalized = normalize_log_line(line, &TIMESTAMP_RE, &UUID_RE, &HEX_RE, &NUM_RE, &PATH_RE); - // Categorize + // Categorize. The error bucket also covers severity labels above ERROR + // (CRITICAL, FATAL, ALERT, EMERGENCY, SEVERE, PANIC) — these are the most + // important lines in a log and were previously dropped as noise when they + // didn't literally contain "error". if line_lower.contains("error") || line_lower.contains("fatal") || line_lower.contains("panic") + || line_lower.contains("critical") + || line_lower.contains("alert") + || line_lower.contains("emerg") + || line_lower.contains("severe") { let count = error_counts.entry(normalized.clone()).or_insert(0); if *count == 0 { unique_errors.push(line.to_string()); } *count += 1; - } else if line_lower.contains("warn") { + } else if line_lower.contains("warn") || line_lower.contains("notice") { let count = warn_counts.entry(normalized.clone()).or_insert(0); if *count == 0 { unique_warnings.push(line.to_string()); diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index fe569a597d..ebfbb1f5c3 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -16,6 +16,7 @@ pub fn resolve_filter(name: &str) -> Option String> { "git-log" => Some(git_log_wrapper), "git-diff" => Some(git_diff_wrapper), "git-status" => Some(crate::cmds::git::git::format_status_output), + "log" => Some(crate::cmds::system::log_cmd::run_stdin_str), "mypy" => Some(crate::cmds::python::mypy_cmd::filter_mypy_output), "ruff-check" => Some(crate::cmds::python::ruff_cmd::filter_ruff_check_json), "ruff-format" => Some(crate::cmds::python::ruff_cmd::filter_ruff_format), @@ -220,7 +221,7 @@ 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, \ - mypy, ruff-check, ruff-format, prettier", + log, mypy, ruff-check, ruff-format, prettier", name ) })?, diff --git a/src/cmds/system/wc_cmd.rs b/src/cmds/system/wc_cmd.rs index dcfe98b39f..78c3924411 100644 --- a/src/cmds/system/wc_cmd.rs +++ b/src/cmds/system/wc_cmd.rs @@ -21,12 +21,22 @@ pub fn run(args: &[String], verbose: u8) -> Result { } let mode = detect_mode(args); + + // No file operands → wc reads from stdin. Forward rtk's stdin to the child + // so `cat file | rtk wc` counts the piped data instead of reporting zero. + let reads_stdin = !args.iter().any(|a| !a.starts_with('-')); + let opts = if reads_stdin { + RunOptions::stdout_only().inherit_stdin() + } else { + RunOptions::stdout_only() + }; + runner::run_filtered( cmd, "wc", &args.join(" "), |stdout| filter_wc_output(stdout, &mode), - RunOptions::stdout_only(), + opts, ) } diff --git a/src/core/runner.rs b/src/core/runner.rs index f127a6081d..17f0997da0 100644 --- a/src/core/runner.rs +++ b/src/core/runner.rs @@ -20,6 +20,10 @@ pub struct RunOptions<'a> { pub filter_stdout_only: bool, pub skip_filter_on_failure: bool, pub no_trailing_newline: bool, + /// Forward rtk's own stdin to the child process. Needed for commands that + /// can read from a pipe (e.g. `cat file | rtk wc`); without it the child + /// gets an empty stdin and reports zero. + pub inherit_stdin: bool, } impl<'a> RunOptions<'a> { @@ -51,6 +55,11 @@ impl<'a> RunOptions<'a> { self.no_trailing_newline = true; self } + + pub fn inherit_stdin(mut self) -> Self { + self.inherit_stdin = true; + self + } } pub enum RunMode<'a> { @@ -71,7 +80,12 @@ pub fn run( match mode { RunMode::Filtered(filter_fn) => { - let result = stream::run_streaming(&mut cmd, StdinMode::Null, FilterMode::CaptureOnly) + let stdin_mode = if opts.inherit_stdin { + StdinMode::Inherit + } else { + StdinMode::Null + }; + let result = stream::run_streaming(&mut cmd, stdin_mode, FilterMode::CaptureOnly) .with_context(|| format!("Failed to run {}", tool_name))?; let exit_code = result.exit_code; diff --git a/src/main.rs b/src/main.rs index 25c70dcb06..81619b7be6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -629,6 +629,9 @@ enum Commands { /// Read stdin, apply filter, print filtered output (Unix pipe mode) Pipe { + /// Filter name as a positional argument (e.g. `rtk pipe log`) + filter_name: Option, + /// Filter name (cargo-test, pytest, grep, find, git-log, etc.) #[arg(short, long)] filter: Option, @@ -2195,10 +2198,12 @@ fn run_cli() -> Result { } Commands::Pipe { + filter_name, filter, passthrough, } => { - pipe_cmd::run(filter.as_deref(), passthrough)?; + let name = filter.or(filter_name); + pipe_cmd::run(name.as_deref(), passthrough)?; 0 } diff --git a/src/parser/formatter.rs b/src/parser/formatter.rs index 6fc9022ef1..aca8164575 100644 --- a/src/parser/formatter.rs +++ b/src/parser/formatter.rs @@ -45,7 +45,13 @@ pub trait TokenFormatter { impl TokenFormatter for TestResult { fn format_compact(&self) -> String { - let mut lines = vec![format!("PASS ({}) FAIL ({})", self.passed, self.failed)]; + // Always surface skipped/pending tests — hiding them lets coverage gaps + // (test.skip / it.skip / xfail) accumulate invisibly. + let mut summary = format!("PASS ({}) FAIL ({})", self.passed, self.failed); + if self.skipped > 0 { + summary.push_str(&format!(" skipped ({})", self.skipped)); + } + let mut lines = vec![summary]; if !self.failures.is_empty() { lines.push(String::new()); @@ -112,6 +118,26 @@ impl TokenFormatter for TestResult { impl TokenFormatter for DependencyState { fn format_compact(&self) -> String { + // A plain package listing (`pnpm list` / `npm ls`) carries no upgrade + // info — every dep has `latest_version == None`. Reporting "All packages + // up-to-date" there is a false positive that hides the entire list, so + // we render the actual packages instead. + let is_listing = self.outdated_count == 0 + && !self.dependencies.is_empty() + && self.dependencies.iter().all(|d| d.latest_version.is_none()); + if is_listing { + let total = self.total_packages.max(self.dependencies.len()); + let mut lines = vec![format!("{} packages", total)]; + for dep in self.dependencies.iter().take(50) { + let dev = if dep.dev_dependency { " (dev)" } else { "" }; + lines.push(format!(" {} {}{}", dep.name, dep.current_version, dev)); + } + if self.dependencies.len() > 50 { + lines.push(format!(" ... +{} more", self.dependencies.len() - 50)); + } + return lines.join("\n"); + } + if self.outdated_count == 0 { return "All packages up-to-date".to_string(); } From 16803a6cce57598ffcb8844bf71ba38399870565 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 15 May 2026 15:08:30 +0200 Subject: [PATCH 2/7] chore(filters): remove filter-level annotations and restore compose logs tail arg --- src/cmds/git/git.rs | 16 ++++++-------- src/cmds/js/lint_cmd.rs | 49 ++++++++++++++--------------------------- src/main.rs | 7 +----- 3 files changed, 24 insertions(+), 48 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 45ec9cb498..b98aedf1b0 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1503,16 +1503,14 @@ fn run_fetch(args: &[String], verbose: u8, global_args: &[String]) -> Result, result: &CaptureResult) -> String { match subcommand { None | Some("push") | Some("save") => { - // Pass git's own message through — it distinguishes "Saved working - // directory and index state WIP on : " from - // "No local changes to save". Collapsing both to "ok" hides whether - // anything was actually stashed (and which ref to pop later). - let out = result.combined(); - let trimmed = out.trim(); - if trimmed.is_empty() { - "ok stashed".to_string() + // A successful stash collapses to "ok stashed" (the WIP ref/sha git + // prints isn't needed to `git stash pop`). But a no-op must NOT look + // like success — pass git's "No local changes to save" through so the + // agent can tell nothing was stashed. + if result.combined().contains("No local changes") { + "No local changes to save".to_string() } else { - trimmed.to_string() + "ok stashed".to_string() } } Some(sub) => format!("ok stash {}", sub), diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index d60ed155bb..04ea8a5dbf 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -281,44 +281,27 @@ fn filter_eslint_json(output: &str) -> String { result.push('\n'); } - // Per-file violation list with line:col — the rule rollup above is a - // summary, but an agent fixing lint errors needs the exact locations. - // Capped so a huge codebase still compresses; the rollup carries the rest. - const MAX_VIOLATIONS: usize = 60; - let total_messages: usize = results.iter().map(|r| r.messages.len()).sum(); - result.push_str("Violations:\n"); - let mut shown = 0; - 'outer: for (file_result, count) in by_file.iter().take(10) { - result.push_str(&format!( - " {} ({}):\n", - compact_path(&file_result.file_path), - count - )); + // Show top files with most issues, plus the top rules in each + result.push_str("Top files:\n"); + for (file_result, count) in by_file.iter().take(10) { + let short_path = compact_path(&file_result.file_path); + result.push_str(&format!(" {} ({} issues)\n", short_path, count)); + + let mut file_rules: HashMap = HashMap::new(); for msg in &file_result.messages { - if shown >= MAX_VIOLATIONS { - result.push_str(" ...\n"); - break 'outer; + if let Some(rule) = &msg.rule_id { + *file_rules.entry(rule.clone()).or_insert(0) += 1; } - let sev = if msg.severity >= 2 { "error" } else { "warning" }; - let rule = msg.rule_id.as_deref().unwrap_or("-"); - result.push_str(&format!( - " {}:{} {} {} {}\n", - msg.line, - msg.column, - sev, - rule, - truncate(msg.message.trim(), 100), - )); - shown += 1; + } + let mut file_rule_counts: Vec<_> = file_rules.iter().collect(); + file_rule_counts.sort_by(|a, b| b.1.cmp(a.1)); + for (rule, count) in file_rule_counts.iter().take(3) { + result.push_str(&format!(" {} ({})\n", rule, count)); } } + if by_file.len() > 10 { - result.push_str(&format!(" ... +{} more files\n", by_file.len() - 10)); - } else if total_messages > MAX_VIOLATIONS { - result.push_str(&format!( - " ... +{} more violations\n", - total_messages - MAX_VIOLATIONS - )); + result.push_str(&format!("\n... +{} more files\n", by_file.len() - 10)); } result.trim().to_string() diff --git a/src/main.rs b/src/main.rs index 81619b7be6..25c70dcb06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -629,9 +629,6 @@ enum Commands { /// Read stdin, apply filter, print filtered output (Unix pipe mode) Pipe { - /// Filter name as a positional argument (e.g. `rtk pipe log`) - filter_name: Option, - /// Filter name (cargo-test, pytest, grep, find, git-log, etc.) #[arg(short, long)] filter: Option, @@ -2198,12 +2195,10 @@ fn run_cli() -> Result { } Commands::Pipe { - filter_name, filter, passthrough, } => { - let name = filter.or(filter_name); - pipe_cmd::run(name.as_deref(), passthrough)?; + pipe_cmd::run(filter.as_deref(), passthrough)?; 0 } From f6b28c292b517d55733ad1d3868f320b017901a5 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 15 May 2026 17:01:01 +0200 Subject: [PATCH 3/7] fix(filters): add test for aggressive filter batch fix --- src/cmds/git/git.rs | 15 +++++++++++++++ src/cmds/python/ruff_cmd.rs | 2 ++ src/cmds/system/log_cmd.rs | 12 ++++++++++++ src/cmds/system/pipe_cmd.rs | 5 +++++ src/parser/formatter.rs | 29 +++++++++++++++++++++++++++++ 5 files changed, 63 insertions(+) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index b98aedf1b0..ba1b5922bb 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -2765,6 +2765,21 @@ no changes added to commit (use "git add" and/or "git commit -a") ); } + #[test] + fn test_extract_detached_head_returns_line() { + let raw = "HEAD detached at abc1234\nnothing to commit, working tree clean\n"; + assert_eq!( + extract_detached_head(raw), + Some("HEAD detached at abc1234".to_string()) + ); + } + + #[test] + fn test_extract_detached_head_on_branch_is_none() { + let raw = "On branch main\nnothing to commit, working tree clean\n"; + assert!(extract_detached_head(raw).is_none()); + } + #[test] fn test_filter_log_output_body_omission_indicator() { // Commit with 6 meaningful body lines: only 3 shown, must signal "+3 lines omitted" diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index f641d9f039..c13ac0e9e8 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -364,6 +364,8 @@ mod tests { assert!(result.contains("E501")); assert!(result.contains("main.py")); assert!(result.contains("utils.py")); + assert!(result.contains("Violations:"), "Violations section missing"); + assert!(result.contains("1:8"), "line:col location missing"); } #[test] diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index cb6a66bb4f..bd76a73b7a 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -246,6 +246,18 @@ mod tests { assert!(result.contains("ERRORS")); } + #[test] + fn test_analyze_logs_extended_severity_keywords() { + let logs = "2024-01-01 10:00:00 CRITICAL: disk full\n\ + 2024-01-01 10:00:01 ALERT: memory pressure\n\ + 2024-01-01 10:00:02 emerg: system shutdown imminent\n\ + 2024-01-01 10:00:03 SEVERE: data corruption detected\n\ + 2024-01-01 10:00:04 notice: config reloaded\n"; + let result = analyze_logs(logs); + assert!(result.contains("ERRORS"), "critical/alert/emerg/severe should count as errors"); + assert!(result.contains("WARNINGS"), "notice should count as warning"); + } + #[test] fn test_analyze_logs_multibyte() { let logs = format!( diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index ebfbb1f5c3..dcbc52cad9 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -301,6 +301,11 @@ mod tests { assert!(resolve_filter("git-status").is_some()); } + #[test] + fn test_resolve_filter_log() { + assert!(resolve_filter("log").is_some()); + } + #[test] fn test_resolve_filter_unknown_returns_none() { assert!(resolve_filter("nonexistent-filter").is_none()); diff --git a/src/parser/formatter.rs b/src/parser/formatter.rs index aca8164575..225fd6f01c 100644 --- a/src/parser/formatter.rs +++ b/src/parser/formatter.rs @@ -224,6 +224,35 @@ mod tests { } } + fn make_dep(name: &str, version: &str, latest: Option<&str>) -> Dependency { + Dependency { + name: name.to_string(), + current_version: version.to_string(), + latest_version: latest.map(str::to_string), + wanted_version: None, + dev_dependency: false, + } + } + + #[test] + fn test_dependency_state_plain_listing_shows_packages() { + let state = DependencyState { + total_packages: 2, + outdated_count: 0, + dependencies: vec![ + make_dep("react", "18.0.0", None), + make_dep("typescript", "5.0.0", None), + ], + }; + let out = state.format_compact(); + assert!(out.contains("react"), "package name missing"); + assert!(out.contains("typescript"), "package name missing"); + assert!( + !out.contains("up-to-date"), + "false positive: plain listing should not say up-to-date" + ); + } + // RED: format_compact must show the full error message, not just 2 lines. // Playwright errors contain the expected/received diff and call log starting // at line 3+. Truncating to 2 lines leaves the agent with no debug info. From 46fe7c47293fcbef28159ddc9fcd118a344cc42b Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sat, 16 May 2026 15:00:11 +0200 Subject: [PATCH 4/7] feat(hints): add tail hints for tee & hints + address reviews - fix signal truncation gaps + kush reviews - added a new tee and hints function to give an hint with tail (avoid re-ingest of the head) - extracted patterns in constants --- src/cmds/cloud/container.rs | 84 ++++++++++++----- src/cmds/git/git.rs | 38 ++++++-- src/cmds/js/pnpm_cmd.rs | 166 ++++++++++++++++++++++++++++++++-- src/cmds/python/pytest_cmd.rs | 12 ++- src/cmds/python/ruff_cmd.rs | 15 +-- src/cmds/system/pipe_cmd.rs | 6 +- src/core/README.md | 2 + src/core/tee.rs | 79 ++++++++++------ 8 files changed, 318 insertions(+), 84 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 797aeba866..d5cb012012 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -114,26 +114,46 @@ fn docker_ps(_verbose: u8) -> Result { } } - let mut rtk = String::new(); - rtk.push_str(&format!("[docker] {} running:\n", running.len())); - for parts in running.iter().take(20) { - if let Some(l) = format_line(parts, true) { - rtk.push_str(&l); - } + const MAX_CONTAINERS: usize = 20; + + // Pre-build compressed lines once; assemble full (for tee) and capped (for display) from them. + let running_lines: Vec = running.iter().filter_map(|p| format_line(p, true)).collect(); + let stopped_lines: Vec = stopped.iter().filter_map(|p| format_line(p, false)).collect(); + + let truncated = running_lines.len() > MAX_CONTAINERS || stopped_lines.len() > MAX_CONTAINERS; + + let mut full_rtk = String::new(); + full_rtk.push_str(&format!("[docker] {} running:\n", running_lines.len())); + for l in &running_lines { + full_rtk.push_str(l); } - if running.len() > 20 { - rtk.push_str(&format!(" ... +{} more\n", running.len() - 20)); + if !stopped_lines.is_empty() { + full_rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped_lines.len())); + for l in &stopped_lines { + full_rtk.push_str(l); + } } - if !stopped.is_empty() { - rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped.len())); - for parts in stopped.iter().take(20) { - if let Some(l) = format_line(parts, false) { - rtk.push_str(&l); - } + let mut rtk = String::new(); + rtk.push_str(&format!("[docker] {} running:\n", running_lines.len())); + for l in running_lines.iter().take(MAX_CONTAINERS) { + rtk.push_str(l); + } + if running_lines.len() > MAX_CONTAINERS { + rtk.push_str(&format!(" ... +{} more\n", running_lines.len() - MAX_CONTAINERS)); + } + if !stopped_lines.is_empty() { + rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped_lines.len())); + for l in stopped_lines.iter().take(MAX_CONTAINERS) { + rtk.push_str(l); } - if stopped.len() > 20 { - rtk.push_str(&format!(" ... +{} more\n", stopped.len() - 20)); + if stopped_lines.len() > MAX_CONTAINERS { + rtk.push_str(&format!(" ... +{} more\n", stopped_lines.len() - MAX_CONTAINERS)); + } + } + if truncated { + if let Some(hint) = crate::core::tee::force_tee_hint(&full_rtk, "docker-ps") { + rtk.push_str(&format!("{}\n", hint)); } } @@ -206,16 +226,30 @@ fn docker_images(_verbose: u8) -> Result { // higher bound than before, and only the count, never the names, is // abbreviated) so token savings still hold on machines with many images. const MAX_IMAGES: usize = 60; - for line in lines.iter().take(MAX_IMAGES) { - let parts: Vec<&str> = line.split('\t').collect(); - if !parts.is_empty() { - let image = parts[0]; - let size = parts.get(1).unwrap_or(&""); - rtk.push_str(&format!(" {} [{}]\n", image, size)); - } + let image_lines: Vec = lines + .iter() + .map(|line| { + let parts: Vec<&str> = line.split('\t').collect(); + let image = parts.first().copied().unwrap_or(""); + let size = parts.get(1).copied().unwrap_or(""); + format!(" {} [{}]\n", image, size) + }) + .collect(); + + // full_rtk = header already in rtk + all image lines (for tee when truncated) + let mut full_rtk = rtk.clone(); + for l in &image_lines { + full_rtk.push_str(l); + } + + for l in image_lines.iter().take(MAX_IMAGES) { + rtk.push_str(l); } - if lines.len() > MAX_IMAGES { - rtk.push_str(&format!(" ... +{} more\n", lines.len() - MAX_IMAGES)); + if image_lines.len() > MAX_IMAGES { + rtk.push_str(&format!(" ... +{} more\n", image_lines.len() - MAX_IMAGES)); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&full_rtk, "docker-images", MAX_IMAGES + 2) { + rtk.push_str(&format!("{}\n", hint)); + } } print!("{}", rtk); diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index ba1b5922bb..b2fb7c6b06 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -678,6 +678,16 @@ fn truncate_line(line: &str, width: usize) -> String { /// Preserve RTK's branch/clean framing while keeping porcelain file lines intact. pub(crate) fn format_status_output(porcelain: &str) -> String { + format_status_inner(porcelain, None) +} + +/// Like `format_status_output` but substitutes the explicit detached-HEAD ref. +// receives the real ref extracted from plain-status output. +pub(crate) fn format_status_output_detached(porcelain: &str, detached_ref: &str) -> String { + format_status_inner(porcelain, Some(detached_ref)) +} + +fn format_status_inner(porcelain: &str, detached: Option<&str>) -> String { let lines: Vec<&str> = porcelain .lines() .filter(|line| !line.trim().is_empty()) @@ -692,7 +702,8 @@ pub(crate) fn format_status_output(porcelain: &str) -> String { if let Some(branch_line) = lines.first() { if branch_line.starts_with("##") { let branch = branch_line.trim_start_matches("## "); - output.push(format!("* {}", branch)); + let display = detached.unwrap_or(branch); + output.push(format!("* {}", display)); } else { output.push((*branch_line).to_string()); } @@ -925,13 +936,10 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result" from the plain status we captured. - if let Some(detached) = extract_detached_head(&raw_output) { - formatted = formatted.replacen("* HEAD (no branch)", &format!("* {detached}"), 1); - } + let formatted = match extract_detached_head(&raw_output) { + Some(detached_ref) => format_status_output_detached(&result.stdout, &detached_ref), + None => format_status_output(&result.stdout), + }; // Surface in-progress state (rebase/merge/cherry-pick/bisect/am) from the // plain-status output we already captured for tracking. Porcelain omits it @@ -2780,6 +2788,20 @@ no changes added to commit (use "git add" and/or "git commit -a") assert!(extract_detached_head(raw).is_none()); } + #[test] + fn test_format_status_output_detached_head() { + let porcelain = "## HEAD (no branch)\n M src/main.rs\n"; + let result = format_status_output_detached(porcelain, "HEAD detached at abc1234"); + assert!( + result.contains("HEAD detached at abc1234"), + "should use explicit detached ref, got: {result}" + ); + assert!( + !result.contains("HEAD (no branch)"), + "should not show opaque porcelain string, got: {result}" + ); + } + #[test] fn test_filter_log_output_body_omission_indicator() { // Commit with 6 meaningful body lines: only 3 shown, must signal "+3 lines omitted" diff --git a/src/cmds/js/pnpm_cmd.rs b/src/cmds/js/pnpm_cmd.rs index 53661faaf5..90357dea70 100644 --- a/src/cmds/js/pnpm_cmd.rs +++ b/src/cmds/js/pnpm_cmd.rs @@ -13,6 +13,11 @@ use crate::parser::{ DependencyState, FormatMode, OutputParser, ParseResult, TokenFormatter, }; +/// Max packages shown per category in `pnpm list` (mixed prod+dev). +/// --prod and --dev filtered calls are never capped — they serve as hint +/// targets so the LLM can retrieve the packages hidden by this cap. +const MAX_LISTING: usize = 20; + /// pnpm list JSON output structure #[derive(Debug, Deserialize)] struct PnpmListOutput { @@ -125,20 +130,33 @@ fn collect_dependencies( fn extract_list_text(output: &str) -> Option { let mut dependencies = Vec::new(); let mut count = 0; + let mut is_dev = false; for line in output.lines() { + let trimmed = line.trim(); + + // Track dependency category from pnpm's section headers + if trimmed == "devDependencies:" { + is_dev = true; + continue; + } + if trimmed == "dependencies:" { + is_dev = false; + continue; + } + // Skip box-drawing and metadata if line.contains('│') || line.contains('├') || line.contains('└') || line.contains("Legend:") - || line.trim().is_empty() + || trimmed.is_empty() { continue; } // Parse lines like: "package@1.2.3" - let parts: Vec<&str> = line.split_whitespace().collect(); + let parts: Vec<&str> = trimmed.split_whitespace().collect(); if !parts.is_empty() { let pkg_str = parts[0]; if let Some(at_pos) = pkg_str.rfind('@') { @@ -150,7 +168,7 @@ fn extract_list_text(output: &str) -> Option { current_version: version.to_string(), latest_version: None, wanted_version: None, - dev_dependency: false, + dev_dependency: is_dev, }); count += 1; } @@ -270,6 +288,55 @@ fn extract_outdated_text(output: &str) -> Option { } } +/// Format a dependency listing with grouped [prod]/[dev] sections. +/// `cap = true` for plain `pnpm list` (both categories present, may truncate). +/// `cap = false` for `pnpm list --prod` / `pnpm list --dev` (hint targets, +/// must show every package so the LLM can find what was hidden by the cap). +fn format_dependency_listing(state: &DependencyState, cap: bool) -> String { + let prod: Vec<_> = state.dependencies.iter().filter(|d| !d.dev_dependency).collect(); + let dev: Vec<_> = state.dependencies.iter().filter(|d| d.dev_dependency).collect(); + let total = state.total_packages.max(state.dependencies.len()); + + let mut lines = vec![format!( + "{} packages ({} prod / {} dev)", + total, + prod.len(), + dev.len() + )]; + + if !prod.is_empty() { + lines.push("[prod]".to_string()); + let shown = if cap { prod.len().min(MAX_LISTING) } else { prod.len() }; + for dep in prod.iter().take(shown) { + lines.push(format!(" {} {}", dep.name, dep.current_version)); + } + if cap && prod.len() > MAX_LISTING { + lines.push(format!( + " ... +{} more — run `pnpm list --prod | tail -n +{}`", + prod.len() - MAX_LISTING, + MAX_LISTING + 3 + )); + } + } + + if !dev.is_empty() { + lines.push("[dev]".to_string()); + let shown = if cap { dev.len().min(MAX_LISTING) } else { dev.len() }; + for dep in dev.iter().take(shown) { + lines.push(format!(" {} {}", dep.name, dep.current_version)); + } + if cap && dev.len() > MAX_LISTING { + lines.push(format!( + " ... +{} more — run `pnpm list --dev | tail -n +{}`", + dev.len() - MAX_LISTING, + MAX_LISTING + 3 + )); + } + } + + lines.join("\n") +} + #[derive(Debug, Clone)] pub enum PnpmCommand { List { depth: usize }, @@ -304,22 +371,24 @@ fn run_list(depth: usize, args: &[String], verbose: u8) -> Result { return Ok(result.exit_code); } - // Parse output using PnpmListParser + let is_filtered = args + .iter() + .any(|a| matches!(a.as_str(), "--prod" | "-P" | "--dev" | "-D")); + let parse_result = PnpmListParser::parse(&result.stdout); - let mode = FormatMode::from_verbosity(verbose); let filtered = match parse_result { ParseResult::Full(data) => { if verbose > 0 { eprintln!("pnpm list (Tier 1: Full JSON parse)"); } - data.format(mode) + format_dependency_listing(&data, !is_filtered) } ParseResult::Degraded(data, warnings) => { if verbose > 0 { emit_degradation_warning("pnpm list", &warnings.join(", ")); } - data.format(mode) + format_dependency_listing(&data, !is_filtered) } ParseResult::Passthrough(raw) => { emit_passthrough_warning("pnpm list", "All parsing tiers failed"); @@ -513,4 +582,87 @@ mod tests { let _args: Vec = vec![OsString::from("help")]; // Compile-time verification that the function exists with correct signature } + + fn make_state(prod: &[&str], dev: &[&str]) -> DependencyState { + let mut deps = Vec::new(); + for name in prod { + deps.push(Dependency { + name: name.to_string(), + current_version: "1.0.0".to_string(), + latest_version: None, + wanted_version: None, + dev_dependency: false, + }); + } + for name in dev { + deps.push(Dependency { + name: name.to_string(), + current_version: "1.0.0".to_string(), + latest_version: None, + wanted_version: None, + dev_dependency: true, + }); + } + DependencyState { + total_packages: deps.len(), + outdated_count: 0, + dependencies: deps, + } + } + + #[test] + fn test_format_listing_grouped_sections() { + let state = make_state(&["react", "typescript"], &["eslint", "vitest"]); + let out = format_dependency_listing(&state, true); + assert!(out.contains("[prod]"), "prod section missing"); + assert!(out.contains("[dev]"), "dev section missing"); + assert!(out.contains("react"), "prod package missing"); + assert!(out.contains("eslint"), "dev package missing"); + assert!(!out.contains("(dev)"), "per-line (dev) marker should be gone"); + } + + #[test] + fn test_format_listing_cap_shows_hint_with_offset() { + let prod: Vec<&str> = (0..60).map(|_| "pkg").collect(); + let state = make_state(&prod, &["eslint"]); + let out = format_dependency_listing(&state, true); + let prod_count = 60usize; + assert!( + out.contains(&format!("... +{} more", prod_count - MAX_LISTING)), + "truncation count missing" + ); + assert!( + out.contains(&format!("tail -n +{}", MAX_LISTING + 3)), + "tail offset missing: got\n{out}" + ); + assert!(out.contains("pnpm list --prod"), "hint command missing"); + } + + #[test] + fn test_format_listing_no_cap_when_prod_only() { + let prod: Vec<&str> = (0..60).map(|_| "pkg").collect(); + let state = make_state(&prod, &[]); + let out = format_dependency_listing(&state, false); + assert!(!out.contains("... +"), "should not truncate when cap=false"); + assert!(!out.contains("[dev]"), "no dev section for prod-only state"); + } + + #[test] + fn test_format_listing_no_cap_when_dev_only() { + let dev: Vec<&str> = (0..60).map(|_| "pkg").collect(); + let state = make_state(&[], &dev); + let out = format_dependency_listing(&state, false); + assert!(!out.contains("... +"), "should not truncate when cap=false"); + assert!(!out.contains("[prod]"), "no prod section for dev-only state"); + } + + #[test] + fn test_extract_list_text_tracks_dev_section() { + let input = "dependencies:\nreact@18.0.0\ndevDependencies:\neslint@8.0.0\n"; + let state = extract_list_text(input).expect("should parse"); + let react = state.dependencies.iter().find(|d| d.name == "react").unwrap(); + let eslint = state.dependencies.iter().find(|d| d.name == "eslint").unwrap(); + assert!(!react.dev_dependency, "react should be prod"); + assert!(eslint.dev_dependency, "eslint should be dev"); + } } diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index 9b349bcf55..18bd3b9939 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -4,6 +4,8 @@ use crate::core::runner; use crate::core::utils::{resolved_command, tool_exists, truncate}; use anyhow::Result; +const MAX_XFAIL: usize = 10; + #[derive(Debug, PartialEq)] enum ParseState { Header, @@ -205,11 +207,15 @@ fn build_pytest_summary( // signals that something expected-to-fail now passes. if !xfail_lines.is_empty() { result.push_str("\nExpected-failure outcomes:\n"); - for line in xfail_lines.iter().take(10) { + for line in xfail_lines.iter().take(MAX_XFAIL) { result.push_str(&format!(" {}\n", truncate(line, 120))); } - if xfail_lines.len() > 10 { - result.push_str(&format!(" ... +{} more\n", xfail_lines.len() - 10)); + if xfail_lines.len() > MAX_XFAIL { + result.push_str(&format!(" ... +{} more\n", xfail_lines.len() - MAX_XFAIL)); + let all_xfail = xfail_lines.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_xfail, "pytest-xfail", MAX_XFAIL + 1) { + result.push_str(&format!(" {}\n", hint)); + } } } diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index c13ac0e9e8..1468483cd4 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -181,12 +181,11 @@ pub fn filter_ruff_check_json(output: &str) -> String { result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); } - // List individual violations with file:line:col so an agent can navigate - // straight to each one — the rule/file grouping above is a summary, not a - // substitute for locations. - const MAX_VIOLATIONS: usize = 50; + // List all violations with file:line:col — format compression (JSON → one + // liner) already saves ~10x; capping would hide locations the agent needs + // to navigate directly to each issue. result.push_str("\nViolations:\n"); - for diag in diagnostics.iter().take(MAX_VIOLATIONS) { + for diag in &diagnostics { result.push_str(&format!( " {}:{}:{} {} {}\n", compact_path(&diag.filename), @@ -196,12 +195,6 @@ pub fn filter_ruff_check_json(output: &str) -> String { truncate(diag.message.trim(), 100), )); } - if diagnostics.len() > MAX_VIOLATIONS { - result.push_str(&format!( - " ... +{} more violations\n", - diagnostics.len() - MAX_VIOLATIONS - )); - } if fixable_count > 0 { result.push_str(&format!( diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index dcbc52cad9..f4e041d221 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -15,7 +15,7 @@ pub fn resolve_filter(name: &str) -> Option String> { "find" | "fd" => Some(find_wrapper), "git-log" => Some(git_log_wrapper), "git-diff" => Some(git_diff_wrapper), - "git-status" => Some(crate::cmds::git::git::format_status_output), + "git-status" => Some(git_status_wrapper), "log" => Some(crate::cmds::system::log_cmd::run_stdin_str), "mypy" => Some(crate::cmds::python::mypy_cmd::filter_mypy_output), "ruff-check" => Some(crate::cmds::python::ruff_cmd::filter_ruff_check_json), @@ -29,6 +29,10 @@ fn go_test_wrapper(input: &str) -> String { crate::cmds::go::go_cmd::filter_go_test_json(input) } +fn git_status_wrapper(input: &str) -> String { + crate::cmds::git::git::format_status_output(input) +} + fn git_log_wrapper(input: &str) -> String { crate::cmds::git::git::filter_log_output(input, 50, false, false) } diff --git a/src/core/README.md b/src/core/README.md index ca031a15ac..9b8a8a414f 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -121,5 +121,7 @@ Consumers that parse structured output (JSON, NDJSON, state machines) should cal For truncation recovery on **success** (e.g., list truncated at 20 items), use `tee::force_tee_hint()` which bypasses the tee mode check and writes regardless of exit code. This ensures LLMs always have a `[full output: ...]` recovery path instead of burning tokens working around missing data. +When the truncated output is a **flat list** and the hidden items start at a predictable line, prefer `tee::force_tee_tail_hint(content, slug, offset)`. It writes the same tee file but emits a directly runnable hint — `[see remaining: tail -n +{offset} ~/path]` — so the agent jumps to exactly the first hidden item without scanning the whole file. The offset is `header_lines + MAX_CAP + 1`. Use `force_tee_hint` instead when the output has multiple sections (e.g. running + stopped containers) and no single offset cleanly covers the gap. + ## Adding New Functionality Place new infrastructure code here if it meets **all** of these criteria: (1) it has no dependencies on command modules or hooks, (2) it is used by two or more other modules, and (3) it provides a general-purpose utility rather than command-specific logic. Follow the existing pattern of lazy-initialized resources (`lazy_static!` for regex, on-demand config loading) to preserve the <10ms startup target. Add `#[cfg(test)] mod tests` with unit tests in the same file. diff --git a/src/core/tee.rs b/src/core/tee.rs index c67ea8349d..0b01bcae3b 100644 --- a/src/core/tee.rs +++ b/src/core/tee.rs @@ -168,19 +168,17 @@ pub fn tee_raw(raw: &str, command_slug: &str, exit_code: i32) -> Option ) } -/// Format the hint line with ~ shorthand for home directory. -fn format_hint(path: &std::path::Path) -> String { - let display = if let Some(home) = dirs::home_dir() { +fn display_path(path: &std::path::Path) -> String { + if let Some(home) = dirs::home_dir() { if let Ok(relative) = path.strip_prefix(&home) { - format!("~/{}", relative.display()) - } else { - path.display().to_string() + return format!("~/{}", relative.display()); } - } else { - path.display().to_string() - }; + } + path.display().to_string() +} - format!("[full output: {}]", display) +fn format_hint(path: &std::path::Path) -> String { + format!("[full output: {}]", display_path(path)) } /// Convenience: tee + format hint in one call. @@ -190,26 +188,17 @@ pub fn tee_and_hint(raw: &str, command_slug: &str, exit_code: i32) -> Option= MIN_TEE_SIZE and tee is enabled. -/// Returns hint string if file was written, None if skipped/disabled. -/// -/// Used by AWS filters when FilterResult.truncated = true, ensuring -/// the LLM has access to full untruncated output via the hint path. -pub fn force_tee_hint(raw: &str, command_slug: &str) -> Option { - // Check RTK_TEE=0 env override (disable) +fn force_tee_path(content: &str, command_slug: &str) -> Option { if std::env::var("RTK_TEE").ok().as_deref() == Some("0") { return None; } - // Skip if output too small - if raw.len() < MIN_TEE_SIZE { + if content.is_empty() { return None; } let config = Config::load().ok()?; - // Respect enabled flag but ignore mode (force tee) if !config.tee.enabled { return None; } @@ -217,17 +206,35 @@ pub fn force_tee_hint(raw: &str, command_slug: &str) -> Option { let tee_dir = get_tee_dir(&config)?; let tee_dir = std::fs::create_dir_all(&tee_dir).ok().and(Some(tee_dir))?; - let path = write_tee_file( - raw, + write_tee_file( + content, command_slug, &tee_dir, config.tee.max_file_size, config.tee.max_files, - )?; + ) +} +/// Returns `[full output: ~/path]`, or None if tee is disabled/skipped. +pub fn force_tee_hint(raw: &str, command_slug: &str) -> Option { + let path = force_tee_path(raw, command_slug)?; Some(format_hint(&path)) } +/// Returns `[see remaining: tail -n +{line_offset} ~/path]`, or None if tee is disabled/skipped. +pub fn force_tee_tail_hint( + content: &str, + command_slug: &str, + line_offset: usize, +) -> Option { + let path = force_tee_path(content, command_slug)?; + Some(format!( + "[see remaining: tail -n +{} {}]", + line_offset, + display_path(&path) + )) +} + /// TeeMode controls when tee writes files. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Default)] #[serde(rename_all = "lowercase")] @@ -487,11 +494,9 @@ directory = "/tmp/rtk-tee" } #[test] - fn test_force_tee_hint_skip_small_output() { - // force_tee_hint should respect MIN_TEE_SIZE - let small_output = "short error"; - let hint = force_tee_hint(small_output, "test_cmd"); - assert!(hint.is_none(), "Should skip output < MIN_TEE_SIZE"); + fn test_force_tee_hint_skip_empty() { + let hint = force_tee_hint("", "test_cmd"); + assert!(hint.is_none(), "Should skip empty content"); } #[test] @@ -503,4 +508,20 @@ directory = "/tmp/rtk-tee" std::env::remove_var("RTK_TEE"); assert!(hint.is_none(), "Should respect RTK_TEE=0"); } + + #[test] + fn test_force_tee_tail_hint_skip_empty() { + let hint = force_tee_tail_hint("", "test_cmd", 22); + assert!(hint.is_none(), "Should skip empty content"); + } + + #[test] + fn test_force_tee_tail_hint_format() { + let path = std::path::PathBuf::from("/tmp/rtk/tee/123_docker_images.log"); + let display = display_path(&path); + let hint = format!("[see remaining: tail -n +{} {}]", 22, display); + assert!(hint.starts_with("[see remaining: tail -n +22 ")); + assert!(hint.ends_with(']')); + assert!(hint.contains("123_docker_images.log")); + } } From 4eefe2f225ea512a2f1bf800dd20c09994721108 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sat, 16 May 2026 15:06:46 +0200 Subject: [PATCH 5/7] fix: re-add env python as noisy dir --- src/cmds/system/constants.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cmds/system/constants.rs b/src/cmds/system/constants.rs index bd8c496378..4b39d54fe7 100644 --- a/src/cmds/system/constants.rs +++ b/src/cmds/system/constants.rs @@ -14,6 +14,7 @@ pub const NOISE_DIRS: &[&str] = &[ ".tox", ".venv", "venv", + "env", // Python legacy virtualenv dir — noise. .env (dotenv) is intentionally NOT here: agents must see it. "coverage", ".nyc_output", ".DS_Store", From 3571d5293dc463c2a0aadfa9a5587b18478ca99a Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sat, 16 May 2026 15:23:06 +0200 Subject: [PATCH 6/7] fix: '...' ascii to unicode, remove some comments --- src/cmds/cloud/container.rs | 19 ++++++++----------- src/cmds/git/git.rs | 3 --- src/cmds/js/pnpm_cmd.rs | 11 +++++------ src/cmds/python/pytest_cmd.rs | 5 ++--- 4 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index d5cb012012..ee2ac6e4bf 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -54,7 +54,6 @@ where fn docker_ps(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - // Baseline the LLM would otherwise see. let raw = exec_capture(resolved_command("docker").args(["ps"])) .map(|r| r.stdout) .unwrap_or_default(); @@ -76,7 +75,6 @@ fn docker_ps(_verbose: u8) -> Result { } let format_line = |parts: &[&str], with_ports: bool| -> Option { - // parts: State, ID, Names, Status, Image, Ports if parts.len() < 5 { return None; } @@ -140,7 +138,7 @@ fn docker_ps(_verbose: u8) -> Result { rtk.push_str(l); } if running_lines.len() > MAX_CONTAINERS { - rtk.push_str(&format!(" ... +{} more\n", running_lines.len() - MAX_CONTAINERS)); + rtk.push_str(&format!(" … +{} more\n", running_lines.len() - MAX_CONTAINERS)); } if !stopped_lines.is_empty() { rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped_lines.len())); @@ -148,7 +146,7 @@ fn docker_ps(_verbose: u8) -> Result { rtk.push_str(l); } if stopped_lines.len() > MAX_CONTAINERS { - rtk.push_str(&format!(" ... +{} more\n", stopped_lines.len() - MAX_CONTAINERS)); + rtk.push_str(&format!(" … +{} more\n", stopped_lines.len() - MAX_CONTAINERS)); } } if truncated { @@ -236,7 +234,6 @@ fn docker_images(_verbose: u8) -> Result { }) .collect(); - // full_rtk = header already in rtk + all image lines (for tee when truncated) let mut full_rtk = rtk.clone(); for l in &image_lines { full_rtk.push_str(l); @@ -246,7 +243,7 @@ fn docker_images(_verbose: u8) -> Result { rtk.push_str(l); } if image_lines.len() > MAX_IMAGES { - rtk.push_str(&format!(" ... +{} more\n", image_lines.len() - MAX_IMAGES)); + rtk.push_str(&format!(" … +{} more\n", image_lines.len() - MAX_IMAGES)); if let Some(hint) = crate::core::tee::force_tee_tail_hint(&full_rtk, "docker-images", MAX_IMAGES + 2) { rtk.push_str(&format!("{}\n", hint)); } @@ -356,7 +353,7 @@ fn format_kubectl_pods(json: &Value) -> String { out.push_str(&format!(" {}\n", issue)); } if issues.len() > 10 { - out.push_str(&format!(" ... +{} more", issues.len() - 10)); + out.push_str(&format!(" … +{} more", issues.len() - 10)); } } out @@ -409,7 +406,7 @@ fn format_kubectl_services(json: &Value) -> String { )); } if services.len() > 15 { - out.push_str(&format!(" ... +{} more", services.len() - 15)); + out.push_str(&format!(" … +{} more", services.len() - 15)); } out } @@ -483,7 +480,7 @@ pub fn format_compose_ps(raw: &str) -> String { } } if lines.len() > 20 { - result.push_str(&format!(" ... +{} more\n", lines.len() - 20)); + result.push_str(&format!(" … +{} more\n", lines.len() - 20)); } result.trim_end().to_string() @@ -573,7 +570,7 @@ fn compact_ports(ports: &str) -> String { port_nums.join(", ") } else { format!( - "{}, ... +{}", + "{}, … +{}", port_nums[..2].join(", "), port_nums.len() - 2 ) @@ -814,6 +811,6 @@ api-1 | Connected to database"; #[test] fn test_compact_ports_many() { let result = compact_ports("0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, 0.0.0.0:8080->8080/tcp, 0.0.0.0:9090->9090/tcp"); - assert!(result.contains("..."), "should truncate for >3 ports"); + assert!(result.contains("…"), "should truncate for >3 ports"); } } diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index b2fb7c6b06..622f45709f 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -676,13 +676,10 @@ fn truncate_line(line: &str, width: usize) -> String { } } -/// Preserve RTK's branch/clean framing while keeping porcelain file lines intact. pub(crate) fn format_status_output(porcelain: &str) -> String { format_status_inner(porcelain, None) } -/// Like `format_status_output` but substitutes the explicit detached-HEAD ref. -// receives the real ref extracted from plain-status output. pub(crate) fn format_status_output_detached(porcelain: &str, detached_ref: &str) -> String { format_status_inner(porcelain, Some(detached_ref)) } diff --git a/src/cmds/js/pnpm_cmd.rs b/src/cmds/js/pnpm_cmd.rs index 90357dea70..ce5f98444e 100644 --- a/src/cmds/js/pnpm_cmd.rs +++ b/src/cmds/js/pnpm_cmd.rs @@ -135,7 +135,6 @@ fn extract_list_text(output: &str) -> Option { for line in output.lines() { let trimmed = line.trim(); - // Track dependency category from pnpm's section headers if trimmed == "devDependencies:" { is_dev = true; continue; @@ -312,7 +311,7 @@ fn format_dependency_listing(state: &DependencyState, cap: bool) -> String { } if cap && prod.len() > MAX_LISTING { lines.push(format!( - " ... +{} more — run `pnpm list --prod | tail -n +{}`", + " … +{} more — run `pnpm list --prod | tail -n +{}`", prod.len() - MAX_LISTING, MAX_LISTING + 3 )); @@ -327,7 +326,7 @@ fn format_dependency_listing(state: &DependencyState, cap: bool) -> String { } if cap && dev.len() > MAX_LISTING { lines.push(format!( - " ... +{} more — run `pnpm list --dev | tail -n +{}`", + " … +{} more — run `pnpm list --dev | tail -n +{}`", dev.len() - MAX_LISTING, MAX_LISTING + 3 )); @@ -628,7 +627,7 @@ mod tests { let out = format_dependency_listing(&state, true); let prod_count = 60usize; assert!( - out.contains(&format!("... +{} more", prod_count - MAX_LISTING)), + out.contains(&format!("… +{} more", prod_count - MAX_LISTING)), "truncation count missing" ); assert!( @@ -643,7 +642,7 @@ mod tests { let prod: Vec<&str> = (0..60).map(|_| "pkg").collect(); let state = make_state(&prod, &[]); let out = format_dependency_listing(&state, false); - assert!(!out.contains("... +"), "should not truncate when cap=false"); + assert!(!out.contains("… +"), "should not truncate when cap=false"); assert!(!out.contains("[dev]"), "no dev section for prod-only state"); } @@ -652,7 +651,7 @@ mod tests { let dev: Vec<&str> = (0..60).map(|_| "pkg").collect(); let state = make_state(&[], &dev); let out = format_dependency_listing(&state, false); - assert!(!out.contains("... +"), "should not truncate when cap=false"); + assert!(!out.contains("… +"), "should not truncate when cap=false"); assert!(!out.contains("[prod]"), "no prod section for dev-only state"); } diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index 18bd3b9939..cda1768f2a 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -139,7 +139,6 @@ pub(crate) fn filter_pytest_output(output: &str) -> String { if trimmed.starts_with("FAILED") || trimmed.starts_with("ERROR") { failures.push(trimmed.to_string()); } else if trimmed.starts_with("XFAIL") || trimmed.starts_with("XPASS") { - // `-rxX` adds these: "XFAIL path::test - reason" / "XPASS path::test - reason" xfail_lines.push(trimmed.to_string()); } } @@ -211,7 +210,7 @@ fn build_pytest_summary( result.push_str(&format!(" {}\n", truncate(line, 120))); } if xfail_lines.len() > MAX_XFAIL { - result.push_str(&format!(" ... +{} more\n", xfail_lines.len() - MAX_XFAIL)); + result.push_str(&format!(" … +{} more\n", xfail_lines.len() - MAX_XFAIL)); let all_xfail = xfail_lines.join("\n"); if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_xfail, "pytest-xfail", MAX_XFAIL + 1) { result.push_str(&format!(" {}\n", hint)); @@ -272,7 +271,7 @@ fn build_pytest_summary( } if failures.len() > 5 { - result.push_str(&format!("\n... +{} more failures\n", failures.len() - 5)); + result.push_str(&format!("\n… +{} more failures\n", failures.len() - 5)); } result.trim().to_string() From f21b8642dea5ac37ade5308bcf443315d63665e8 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Mon, 18 May 2026 19:06:57 +0200 Subject: [PATCH 7/7] fix(filters): split docker ps/-a paths, cap ruff violations at 50 - docker ps reverts to plain (no -a augmentation) - docker ps -a / compose ps -a wired to the running/stopped split filter - ruff Violations: capped at 50 + force_tee_tail_hint recovery - tests for the ruff and pytest xfail caps --- src/cmds/cloud/container.rs | 197 ++++++++++++++++++++++------------ src/cmds/python/pytest_cmd.rs | 31 ++++++ src/cmds/python/ruff_cmd.rs | 70 ++++++++++-- src/main.rs | 21 +++- 4 files changed, 233 insertions(+), 86 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index ee2ac6e4bf..3a91f99e14 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -12,6 +12,7 @@ use std::process::Command; #[derive(Debug, Clone, Copy)] pub enum ContainerCmd { DockerPs, + DockerPsAll, DockerImages, DockerLogs, KubectlPods, @@ -22,6 +23,7 @@ pub enum ContainerCmd { pub fn run(cmd: ContainerCmd, args: &[String], verbose: u8) -> Result { match cmd { ContainerCmd::DockerPs => docker_ps(verbose), + ContainerCmd::DockerPsAll => docker_ps_all(verbose), ContainerCmd::DockerImages => docker_images(verbose), ContainerCmd::DockerLogs => docker_logs(args, verbose), ContainerCmd::KubectlPods => kubectl_pods(args, verbose), @@ -58,13 +60,10 @@ fn docker_ps(_verbose: u8) -> Result { .map(|r| r.stdout) .unwrap_or_default(); - // One structured call over *all* containers (`-a`) — splitting on the State - // field lets us list crashed/exited ones too, which plain `docker ps` hides. let result = exec_capture(resolved_command("docker").args([ "ps", - "-a", "--format", - "{{.State}}\t{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", + "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", ])) .context("Failed to run docker ps")?; @@ -74,92 +73,146 @@ fn docker_ps(_verbose: u8) -> Result { return Ok(result.exit_code); } - let format_line = |parts: &[&str], with_ports: bool| -> Option { - if parts.len() < 5 { - return None; - } - let id = &parts[1][..12.min(parts[1].len())]; - let name = parts[2]; - // Keep the Status verbatim — it carries health ("Up 4s (unhealthy)") - // and exit-code/restart info an agent needs to judge service health. - let status = parts[3].trim(); - let short_image = parts[4].split('/').next_back().unwrap_or(""); - let port_suffix = if with_ports { - let ports = compact_ports(parts.get(5).unwrap_or(&"")); - if ports == "-" { - String::new() - } else { - format!(" [{}]", ports) - } - } else { - String::new() - }; - Some(format!( - " {} {} ({}) {}{}\n", - id, name, short_image, status, port_suffix - )) - }; + let stdout = result.stdout; + let mut rtk = String::new(); - let mut running: Vec> = Vec::new(); - let mut stopped: Vec> = Vec::new(); - for line in result.stdout.lines().filter(|l| !l.trim().is_empty()) { - let parts: Vec<&str> = line.split('\t').collect(); - let state = parts.first().copied().unwrap_or(""); - if matches!(state, "running" | "restarting") { - running.push(parts); - } else { - stopped.push(parts); - } + if stdout.trim().is_empty() { + rtk.push_str("[docker] 0 containers"); + println!("{}", rtk); + timer.track("docker ps", "rtk docker ps", &raw, &rtk); + return Ok(0); } const MAX_CONTAINERS: usize = 20; + let lines: Vec = stdout + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|line| format_container_line(line, true)) + .collect(); - // Pre-build compressed lines once; assemble full (for tee) and capped (for display) from them. - let running_lines: Vec = running.iter().filter_map(|p| format_line(p, true)).collect(); - let stopped_lines: Vec = stopped.iter().filter_map(|p| format_line(p, false)).collect(); + rtk.push_str(&format!("[docker] {} containers:\n", lines.len())); + for entry in lines.iter().take(MAX_CONTAINERS) { + rtk.push_str(entry); + } + if lines.len() > MAX_CONTAINERS { + rtk.push_str(&format!(" … +{} more\n", lines.len() - MAX_CONTAINERS)); + let full: String = lines.concat(); + if let Some(hint) = crate::core::tee::force_tee_hint(&full, "docker-ps") { + rtk.push_str(&format!("{}\n", hint)); + } + } - let truncated = running_lines.len() > MAX_CONTAINERS || stopped_lines.len() > MAX_CONTAINERS; + print!("{}", rtk); + timer.track("docker ps", "rtk docker ps", &raw, &rtk); + Ok(0) +} - let mut full_rtk = String::new(); - full_rtk.push_str(&format!("[docker] {} running:\n", running_lines.len())); - for l in &running_lines { - full_rtk.push_str(l); +fn docker_ps_all(_verbose: u8) -> Result { + let timer = tracking::TimedExecution::start(); + + let raw = exec_capture(resolved_command("docker").args(["ps", "-a"])) + .map(|r| r.stdout) + .unwrap_or_default(); + + let result = exec_capture(resolved_command("docker").args([ + "ps", + "-a", + "--format", + "{{.State}}\t{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", + ])) + .context("Failed to run docker ps -a")?; + + if !result.success() { + eprint!("{}", result.stderr); + timer.track("docker ps -a", "rtk docker ps -a", &raw, &raw); + return Ok(result.exit_code); } - if !stopped_lines.is_empty() { - full_rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped_lines.len())); - for l in &stopped_lines { - full_rtk.push_str(l); + + let mut running_lines: Vec = Vec::new(); + let mut stopped_lines: Vec = Vec::new(); + for line in result.stdout.lines().filter(|l| !l.trim().is_empty()) { + let parts: Vec<&str> = line.split('\t').collect(); + let state = parts.first().copied().unwrap_or(""); + let is_running = matches!(state, "running" | "restarting"); + if let Some(entry) = format_container_line_from_parts(&parts[1..], is_running) { + if is_running { + running_lines.push(entry); + } else { + stopped_lines.push(entry); + } } } + const MAX_CONTAINERS: usize = 20; + let truncated = running_lines.len() > MAX_CONTAINERS || stopped_lines.len() > MAX_CONTAINERS; + let mut rtk = String::new(); rtk.push_str(&format!("[docker] {} running:\n", running_lines.len())); for l in running_lines.iter().take(MAX_CONTAINERS) { rtk.push_str(l); } if running_lines.len() > MAX_CONTAINERS { - rtk.push_str(&format!(" … +{} more\n", running_lines.len() - MAX_CONTAINERS)); + rtk.push_str(&format!( + " … +{} more\n", + running_lines.len() - MAX_CONTAINERS + )); } if !stopped_lines.is_empty() { - rtk.push_str(&format!("[docker] {} stopped/exited:\n", stopped_lines.len())); + rtk.push_str(&format!( + "[docker] {} stopped/exited:\n", + stopped_lines.len() + )); for l in stopped_lines.iter().take(MAX_CONTAINERS) { rtk.push_str(l); } if stopped_lines.len() > MAX_CONTAINERS { - rtk.push_str(&format!(" … +{} more\n", stopped_lines.len() - MAX_CONTAINERS)); + rtk.push_str(&format!( + " … +{} more\n", + stopped_lines.len() - MAX_CONTAINERS + )); } } if truncated { - if let Some(hint) = crate::core::tee::force_tee_hint(&full_rtk, "docker-ps") { + let full: String = running_lines.iter().chain(stopped_lines.iter()).cloned().collect(); + if let Some(hint) = crate::core::tee::force_tee_hint(&full, "docker-ps-a") { rtk.push_str(&format!("{}\n", hint)); } } print!("{}", rtk); - timer.track("docker ps", "rtk docker ps", &raw, &rtk); + timer.track("docker ps -a", "rtk docker ps -a", &raw, &rtk); Ok(0) } +fn format_container_line(line: &str, with_ports: bool) -> Option { + let parts: Vec<&str> = line.split('\t').collect(); + format_container_line_from_parts(&parts, with_ports) +} + +fn format_container_line_from_parts(parts: &[&str], with_ports: bool) -> Option { + if parts.len() < 4 { + return None; + } + let id = &parts[0][..12.min(parts[0].len())]; + let name = parts[1]; + let status = parts[2].trim(); + let short_image = parts[3].split('/').next_back().unwrap_or(""); + let port_suffix = if with_ports { + let ports = compact_ports(parts.get(4).unwrap_or(&"")); + if ports == "-" { + String::new() + } else { + format!(" [{}]", ports) + } + } else { + String::new() + }; + Some(format!( + " {} {} ({}) {}{}\n", + id, name, short_image, status, port_suffix + )) +} + fn docker_images(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); @@ -581,13 +634,15 @@ pub fn run_docker_passthrough(args: &[OsString], verbose: u8) -> Result { crate::core::runner::run_passthrough("docker", args, verbose) } -/// Run `docker compose ps` with compact output -pub fn run_compose_ps(verbose: u8) -> Result { +/// Run `docker compose ps` (or `docker compose ps -a`) with compact output +pub fn run_compose_ps(all: bool, verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - // Use `-a` so stopped/exited services stay visible — a worker that crashed - // on startup must not silently vanish from the agent's view. - let raw_result = exec_capture(resolved_command("docker").args(["compose", "ps", "-a"])) + let mut raw_args: Vec<&str> = vec!["compose", "ps"]; + if all { + raw_args.push("-a"); + } + let raw_result = exec_capture(resolved_command("docker").args(&raw_args)) .context("Failed to run docker compose ps")?; if !raw_result.success() { @@ -596,15 +651,13 @@ pub fn run_compose_ps(verbose: u8) -> Result { } let raw = raw_result.stdout; - // Structured output for parsing (same pattern as docker_ps) - let result = exec_capture(resolved_command("docker").args([ - "compose", - "ps", - "-a", - "--format", - "{{.Name}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}", - ])) - .context("Failed to run docker compose ps --format")?; + let mut format_args: Vec<&str> = vec!["compose", "ps"]; + if all { + format_args.push("-a"); + } + format_args.extend(["--format", "{{.Name}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"]); + let result = exec_capture(resolved_command("docker").args(&format_args)) + .context("Failed to run docker compose ps --format")?; if !result.success() { eprintln!("{}", result.stderr); @@ -618,7 +671,9 @@ pub fn run_compose_ps(verbose: u8) -> Result { let rtk = format_compose_ps(&structured); println!("{}", rtk); - timer.track("docker compose ps", "rtk docker compose ps", &raw, &rtk); + let label = if all { "docker compose ps -a" } else { "docker compose ps" }; + let rtk_label = if all { "rtk docker compose ps -a" } else { "rtk docker compose ps" }; + timer.track(label, rtk_label, &raw, &rtk); Ok(0) } diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index cda1768f2a..89f91ea72f 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -409,6 +409,37 @@ collected 0 items ); } + #[test] + fn test_filter_pytest_xfail_caps_and_tee_hint() { + let mut lines = String::from("=== test session starts ===\ncollected 30 items\n\n"); + lines.push_str("test_x.py "); + for _ in 0..15 { + lines.push('x'); + } + lines.push_str("\n\n=== short test summary info ===\n"); + for i in 0..15 { + lines.push_str(&format!( + "XFAIL test_x.py::test_case_{i} - known issue #{i}\n" + )); + } + lines.push_str("=== 0 passed, 15 xfailed in 0.05s ===\n"); + + let result = filter_pytest_output(&lines); + let xfail_in_section = result + .split("Expected-failure outcomes:") + .nth(1) + .unwrap_or(""); + let listed = xfail_in_section + .lines() + .filter(|l| l.trim().starts_with("XFAIL")) + .count(); + assert!( + listed <= 10, + "MAX_XFAIL cap not enforced: listed {listed}" + ); + assert!(result.contains("… +5 more"), "missing '+N more': {result}"); + } + #[test] fn test_filter_pytest_xfail_xpass() { let output = r#"=== test session starts === diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index 1468483cd4..05f52448f1 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -181,19 +181,36 @@ pub fn filter_ruff_check_json(output: &str) -> String { result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); } - // List all violations with file:line:col — format compression (JSON → one - // liner) already saves ~10x; capping would hide locations the agent needs - // to navigate directly to each issue. + const MAX_VIOLATIONS: usize = 50; + let violation_lines: Vec = diagnostics + .iter() + .map(|diag| { + format!( + " {}:{}:{} {} {}\n", + compact_path(&diag.filename), + diag.location.row, + diag.location.column, + diag.code, + truncate(diag.message.trim(), 100), + ) + }) + .collect(); + result.push_str("\nViolations:\n"); - for diag in &diagnostics { + for line in violation_lines.iter().take(MAX_VIOLATIONS) { + result.push_str(line); + } + if violation_lines.len() > MAX_VIOLATIONS { result.push_str(&format!( - " {}:{}:{} {} {}\n", - compact_path(&diag.filename), - diag.location.row, - diag.location.column, - diag.code, - truncate(diag.message.trim(), 100), + " … +{} more\n", + violation_lines.len() - MAX_VIOLATIONS )); + let full: String = violation_lines.concat(); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&full, "ruff-check", MAX_VIOLATIONS + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } if fixable_count > 0 { @@ -381,6 +398,39 @@ Would reformat: tests/test_utils.py assert!(result.contains("3 files already formatted")); } + #[test] + fn test_filter_ruff_check_caps_violations_and_emits_hint() { + // Mirror ruff's pretty-printed JSON shape so the input-vs-output + // comparison reflects what a real `ruff check --output-format=json` emits. + let mut diags = Vec::new(); + for i in 0..200 { + diags.push(format!( + " {{\n \"code\": \"F401\",\n \"message\": \"`module_{i}` imported but unused\",\n \"location\": {{\"row\": {i}, \"column\": 4}},\n \"end_location\": {{\"row\": {i}, \"column\": 20}},\n \"filename\": \"/Users/dev/project/src/feature_{i}.py\",\n \"fix\": null\n }}" + )); + } + let json = format!("[\n{}\n]", diags.join(",\n")); + let result = filter_ruff_check_json(&json); + + let in_section = result.split("Violations:").nth(1).unwrap_or(""); + let listed = in_section + .lines() + .filter(|l| l.trim().starts_with("src/")) + .count(); + assert!(listed <= 50, "violations cap not enforced: got {listed}"); + assert!( + result.contains("… +150 more"), + "missing '+N more' indicator" + ); + + let raw_tokens = json.split_whitespace().count(); + let out_tokens = result.split_whitespace().count(); + let savings = 100.0 - (out_tokens as f64 / raw_tokens as f64) * 100.0; + assert!( + savings >= 60.0, + "token savings dropped below 60%: {savings:.1}%" + ); + } + #[test] fn test_compact_path() { assert_eq!( diff --git a/src/main.rs b/src/main.rs index 25c70dcb06..f7e914c0eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -903,7 +903,10 @@ enum PnpmCommands { #[derive(Debug, Subcommand)] enum DockerCommands { /// List running containers - Ps, + Ps { + #[arg(short = 'a', long)] + all: bool, + }, /// List images Images, /// Show container logs (deduplicated) @@ -921,7 +924,10 @@ enum DockerCommands { #[derive(Debug, Subcommand)] enum ComposeCommands { /// List compose services (compact) - Ps, + Ps { + #[arg(short = 'a', long)] + all: bool, + }, /// Show compose logs (deduplicated) Logs { /// Optional service name @@ -1699,8 +1705,13 @@ fn run_cli() -> Result { }, Commands::Docker { command } => match command { - DockerCommands::Ps => { - container::run(container::ContainerCmd::DockerPs, &[], cli.verbose)? + DockerCommands::Ps { all } => { + let cmd = if all { + container::ContainerCmd::DockerPsAll + } else { + container::ContainerCmd::DockerPs + }; + container::run(cmd, &[], cli.verbose)? } DockerCommands::Images => { container::run(container::ContainerCmd::DockerImages, &[], cli.verbose)? @@ -1709,7 +1720,7 @@ fn run_cli() -> Result { container::run(container::ContainerCmd::DockerLogs, &[c], cli.verbose)? } DockerCommands::Compose { command: compose } => match compose { - ComposeCommands::Ps => container::run_compose_ps(cli.verbose)?, + ComposeCommands::Ps { all } => container::run_compose_ps(all, cli.verbose)?, ComposeCommands::Logs { service, tail } => { container::run_compose_logs(service.as_deref(), tail, cli.verbose)? }