Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ RTK intercepts shell commands and compresses their output before your agent read
| `ruff check` | Grouped by rule and file |
| `pytest` | Failures only, traceback trimmed |
| `go test` | NDJSON parsed, failures only |
| `make` / `gmake` | Drop `Entering/Leaving directory` chatter, keep sub-tool output |
| `docker ps` | Essential fields only |

## How Savings Work
Expand Down Expand Up @@ -197,6 +198,7 @@ rtk rake test # Ruby minitest (-90%)
rtk rspec # RSpec tests (JSON, -60%+)
rtk err <cmd> # Filter errors only from any command
rtk test <cmd> # Generic test wrapper - failures only (-90%)
rtk make # make/gmake: drop directory chatter, keep sub-tool output
```

### Build & Lint
Expand Down
1 change: 1 addition & 0 deletions src/cmds/system/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- `read.rs` uses `core/filter` for language-aware code stripping (FilterLevel: none/minimal/aggressive)
- `search.rs` backs both `rtk grep` and `rtk rg`: it runs the invoked engine (never substituting one for the other) and groups its output, reading `core/config` for `limits.grep_max_results` and `limits.grep_max_per_file`. Format-altering flags (`-c`, `-l`, `-L`, `-o`, `-Z`) bypass RTK filtering and run raw.
- `local_llm.rs` (`rtk smart`) uses `core/filter` for heuristic file summarization
- `make_cmd.rs` (`rtk make`) filters `make` / `gmake` output: suppresses `make[N]: Entering/Leaving directory` chatter lines, leaves recipe echo and all sub-tool output (gcc/clang/pytest diagnostics) untouched so failures stay actionable, and collapses fully-stripped runs to `make: ok`. Verbose flags (`-v`, `--verbose`, `--trace`, `--debug`, `-d`) pass raw output through unchanged.
- `format_cmd.rs` is a cross-ecosystem dispatcher: auto-detects and routes to `prettier_cmd` or `ruff_cmd` (black is handled inline, not as a separate module)

## Cross-command
Expand Down
147 changes: 147 additions & 0 deletions src/cmds/system/make_cmd.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
//! Filters `make` build output.
//!
//! `make` (and `gmake`) prints two low-signal noise sources that dominate agent
//! context windows:
//!
//! 1. **Directory chatter** — `make[N]: Entering directory '...'` /
//! `make[N]: Leaving directory '...'` on every recursive invocation.
//! 2. **Recipe echo** — every non-`@`-prefixed recipe line is echoed before
//! execution.
//!
//! This filter takes the conservative route (per issue #3487): it suppresses
//! only the directory-chatter lines, leaving recipe echo and — crucially — all
//! sub-tool output (gcc/clang/pytest diagnostics) untouched so the agent can
//! still act on failures. On a non-zero exit we pass the raw output through
//! unchanged (Design Philosophy: Never Worse).
//!
//! Verbose flags (`-v`/`--verbose`/`--trace`/`--debug`/`-d`) bypass filtering
//! entirely (Correctness over savings).

use crate::core::runner;
use anyhow::{Context, Result};
use std::process::Command;
use std::sync::LazyLock;

/// Flags that request full/unfiltered output.
static VERBOSE_FLAGS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
vec!["-v", "--verbose", "--trace", "--debug", "-d"]
});

/// Prefixes that mark `make`'s own directory-chatter lines (suppressed in
/// default mode).
static DIR_CHATTER_PREFIXES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
vec![
"make[", // "make[1]: Entering directory '...'" / "Leaving ..."
]
});

fn has_verbose_flag(args: &[String]) -> bool {
args.iter().any(|a| VERBOSE_FLAGS.iter().any(|v| a == *v))
}

/// Pure filter: drop `make[N]:` directory-chatter lines, keep everything else.
pub fn filter_make(raw: &str, verbose: bool) -> String {
if verbose {
return raw.to_string();
}

let mut out: Vec<String> = Vec::new();
for line in raw.lines() {
let trimmed = line.trim_start();
// Suppress make's own "make[N]: Entering/Leaving directory" chatter.
if DIR_CHATTER_PREFIXES.iter().any(|p| trimmed.starts_with(*p))
&& (trimmed.contains("Entering directory")
|| trimmed.contains("Leaving directory"))
{
continue;
}
out.push(line.to_string());
}

// Mirror the TOML `on_empty = "make: ok"` behaviour for a fully-collapsed run.
if out.is_empty() {
return "make: ok".to_string();
}
out.join("\n")
}

/// Run `make` / `gmake` and filter its output.
pub fn run(args: &[String], verbose: u8) -> Result<i32> {
let verbose_flag = verbose > 0 || has_verbose_flag(args);
if verbose > 0 {
eprintln!("Running: make {}", args.join(" "));
}

// Prefer `make`; fall back to `gmake` (common on *BSD / some CI images).
let bin = if Command::new("make").arg("--version").output().is_ok() {
"make"
} else {
"gmake"
};

let mut cmd = Command::new(bin);
for arg in args {
cmd.arg(arg);
}

runner::run_filtered(
cmd,
"make",
&args.join(" "),
|raw: &str| filter_make(raw, verbose_flag),
runner::RunOptions::stdout_only().tee("make"),
)
.context("make filter execution failed")
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn suppresses_entering_leaving_directory() {
let raw = "\
make[1]: Entering directory '/home/user/proj'
gcc -O2 foo.c
make[1]: Leaving directory '/home/user/proj'
";
let out = filter_make(raw, false);
assert!(!out.contains("Entering directory"), "dir chatter must be stripped");
assert!(!out.contains("Leaving directory"), "dir chatter must be stripped");
assert!(out.contains("gcc -O2 foo.c"), "sub-tool output must survive");
}

#[test]
fn keeps_subtool_diagnostics() {
let raw = "\
make[1]: Entering directory '/proj'
gcc -O2 bad.c
bad.c:5:3: error: 'foo' undeclared (first use in this function)
make[1]: Leaving directory '/proj'
";
let out = filter_make(raw, false);
assert!(out.contains("error: 'foo' undeclared"), "compiler error must survive");
assert!(out.contains("gcc -O2 bad.c"), "recipe line kept (conservative)");
}

#[test]
fn verbose_flag_passthrough() {
let raw = "make[1]: Entering directory '/proj'\ngcc -O2 foo.c\n";
let out = filter_make(raw, true);
assert_eq!(out, raw, "verbose must pass raw through");
}

#[test]
fn empty_collapses_to_ok() {
let raw = "make[1]: Entering directory '/proj'\nmake[1]: Leaving directory '/proj'\n";
let out = filter_make(raw, false);
assert_eq!(out, "make: ok", "fully stripped run collapses to ok");
}

#[test]
fn has_verbose_flag_detects_common_flags() {
assert!(has_verbose_flag(&["--debug".to_string()]));
assert!(has_verbose_flag(&["-d".to_string()]));
assert!(!has_verbose_flag(&["all".to_string()]));
}
}
57 changes: 5 additions & 52 deletions src/core/toml_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1359,52 +1359,6 @@ max_lines = 999
);
}

#[test]
fn test_make_savings_above_60pct() {
let filters = make_filters(BUILTIN_TOML);
let filter = find_filter_in("make all", &filters).expect("make built-in");

let input = r#"make[1]: Entering directory '/home/user/project'
make[2]: Entering directory '/home/user/project/src'
gcc -O2 -Wall -c foo.c -o foo.o

make[2]: Nothing to be done for 'install'.
make[3]: Entering directory '/home/user/project/src/lib'
ar rcs libfoo.a foo.o bar.o baz.o
make[3]: Leaving directory '/home/user/project/src/lib'
make[2]: Leaving directory '/home/user/project/src'

make[1]: Leaving directory '/home/user/project'
gcc -O2 -Wall -c bar.c -o bar.o

gcc -O2 -Wall -c baz.c -o baz.o

make[1]: Entering directory '/home/user/project/test'
make[2]: Entering directory '/home/user/project/test/unit'
./run_tests --verbose
make[2]: Nothing to be done for 'check'.
make[2]: Leaving directory '/home/user/project/test/unit'
make[1]: Leaving directory '/home/user/project/test'

ld -o myapp foo.o bar.o baz.o -lfoo

make[1]: Entering directory '/home/user/project/docs'
doxygen Doxyfile
make[1]: Leaving directory '/home/user/project/docs'
"#;
let out = apply_filter(filter, input);
let input_words = input.split_whitespace().count();
let out_words = out.split_whitespace().count();
let savings = 100.0 - (out_words as f64 / input_words as f64 * 100.0);
assert!(
savings >= 60.0,
"make filter: expected >=60% savings, got {:.1}% (in={} out={})",
savings,
input_words,
out_words
);
}

// --- Edge cases ---

#[test]
Expand Down Expand Up @@ -1845,7 +1799,6 @@ match_command = "^make\\b"
"helm",
"iptables",
"liquibase",
"make",
"markdownlint",
"mix-compile",
"mix-format",
Expand Down Expand Up @@ -1892,8 +1845,8 @@ match_command = "^make\\b"
let filters = make_filters(BUILTIN_TOML);
assert_eq!(
filters.len(),
63,
"Expected exactly 63 built-in filters, got {}. \
62,
"Expected exactly 62 built-in filters, got {}. \
Update this count when adding/removing filters in src/filters/.",
filters.len()
);
Expand Down Expand Up @@ -1950,11 +1903,11 @@ expected = "output line 1\noutput line 2"
let combined = format!("{}\n\n{}", BUILTIN_TOML, new_filter);
let filters = make_filters(&combined);

// All 63 existing filters still present + 1 new = 64
// All 62 existing filters still present + 1 new = 63
assert_eq!(
filters.len(),
64,
"Expected 64 filters after concat (63 built-in + 1 new)"
63,
"Expected 63 filters after concat (62 built-in + 1 new)"
);

// New filter is discoverable
Expand Down
41 changes: 0 additions & 41 deletions src/filters/make.toml

This file was deleted.

13 changes: 11 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd};
use cmds::rust::{cargo_cmd, runner};
use cmds::scala::sbt_cmd;
use cmds::system::{
deps, env_cmd, find_cmd, format_cmd, json_cmd, local_llm, log_cmd, ls, pipe_cmd, read, search,
summary, tree, wc_cmd,
deps, env_cmd, find_cmd, format_cmd, json_cmd, local_llm, log_cmd, ls, make_cmd, pipe_cmd,
read, search, summary, tree, wc_cmd,
};

use anyhow::{Context, Result};
Expand Down Expand Up @@ -99,6 +99,13 @@ enum Commands {
args: Vec<String>,
},

/// Run `make` / `gmake` with token-optimized output (suppresses directory chatter)
Make {
/// Arguments passed to make (supports all native make flags and targets)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},

/// Read file with intelligent filtering
Read {
/// Files to read (supports multiple, like cat)
Expand Down Expand Up @@ -1610,6 +1617,7 @@ fn run_cli() -> Result<i32> {
Commands::Ls { args } => ls::run(&args, cli.verbose)?,

Commands::Tree { args } => tree::run(&args, cli.verbose)?,
Commands::Make { args } => make_cmd::run(&args, cli.verbose)?,

// ISSUE #989: support multiple files (cat file1 file2 → rtk read file1 file2)
Commands::Read {
Expand Down Expand Up @@ -3117,6 +3125,7 @@ mod tests {
"ls",
"tree",
"read",
"make",
"rg",
"git",
"gh",
Expand Down