Skip to content

feat(mvn)!: Rust module replacing TOML filter, adds test phase support - #1956

Merged
aeppling merged 16 commits into
rtk-ai:developfrom
vdufloth:feat/mvn-rust-module
Jun 8, 2026
Merged

aeppling merged 16 commits into
rtk-ai:developfrom
vdufloth:feat/mvn-rust-module

Conversation

@vdufloth

@vdufloth vdufloth commented May 18, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1537.

Review response (PR review by @pszymkowiak, 2026-05-21)

Three commits address the review:

  • cc152cd — fixes the blocking Surefire 3.x close-line regex, regenerates all five Surefire-touching fixtures from a real apache/commons-cli build inside maven:3.9-eclipse-temurin-21, adds a failure_trail state to retain the exception + user-code frames Surefire 3.x emits after the close line, and locks 2.x compatibility via a dedicated unit test (surefire_matches_legacy_2x_close_line).
  • 77e28d0 — removes production duration normalisation entirely (went a step further than the suggested test-only gating because there are no insta snapshots in the module). Real Time elapsed / Total time ship to the user/LLM untouched.
  • 97dbf98 — adds mvn -q quiet-mode filtering. Green run emits zero bytes (no overhead); failure run keeps the close-line, exception, user-code stack, summary block, and goal terminator while dropping framework frames + post-failure help boilerplate. 51.1% measured savings on the real fail fixture.

Reviewer-flagged follow-ups documented under Out of scope below: detect_phase ↔ hook regex disagreement on space-valued flags, parallel -T<N>C per-FQN keyed buffer, dependency:tree filter, mvnw.bat wrapper detection.

Summary

Comprehensive Apache Maven support replacing the stateless src/filters/mvn-build.toml (4 phases, 50-line cap) with a stateful Rust module in src/cmds/jvm/mvn_cmd.rs (alongside the existing gradlew_cmd.rs).

The TOML filter could not handle Maven's dominant noise pattern: expected-exception stack traces inside passing Surefire tests (assertThrows). Dropping those requires deciding the fate of a block based on its closing Tests run: N, Failures: 0, Errors: 0 line — a stateful decision the TOML DSL cannot express. Rust module is the right vehicle per CONTRIBUTING § "TOML vs Rust".

Also addresses the hook-routing bug in #1537: the previous regex ^mvn\s+(compile|package|clean|install)\b only matched when the goal was the first token after mvn, so mvn -B install, ./mvnw test, mvn -q -Dtest=Foo test, etc. never rewrote. The reporter (VictorAurelius) measured 270+ unfiltered mvn/mvnw invocations over 30 days in a Spring Boot project.

Relationship to in-flight PRs

Two other PRs landed in parallel targeting the same #1537 gap:

#1907 (Tyler) #1921 (Ehsan) this PR (#1956)
Approach TOML filter TOML filter + engine extension (match_command_excludes) Rust module
Size +124 LoC, 3 files +403 LoC, 8 files +1183 LoC, 17 files
Stateful assertThrows collapse ❌ TOML DSL cannot express ❌ TOML DSL cannot express ✅
Locale guard (non-English) ❌ ❌ ✅
Verbose bypass (-X/-e) ❌ ❌ ✅
Wrapper (./mvnw/mvnw.cmd) detection ❌ ❌ ✅
Flag-skip goal matching ❌ first-token only ✅ lazy quantifier ✅ lazy quantifier
CRLF explicit test ❌ ✅ ❌
Replaces mvn-build.toml preserved preserved + routing-excluded deleted
Savings (passing fixture, real commons-cli) ~85% ~95% 98.0%

Reviewer decision required: TOML approach (cheaper review, smaller surface, but misses assertThrows collapse — the dominant noise pattern on healthy projects) vs Rust approach (this PR — bigger surface but covers the pattern TOML cannot). Happy to defer to maintainer judgment; will close this PR if a TOML approach is preferred.

Token savings (measured on real Maven output)

Reference build: apache/commons-cli (master) inside maven:3.9-eclipse-temurin-21, default toolchain → maven-surefire-plugin:3.5.5. Fixtures are the raw bytes captured from that build — exactly what the binary receives via its child process.

Filter Raw tokens Filtered tokens Savings
mvn test (full fixture) 1896 38 98.0%
mvn install (full fixture) 2021 78 96.1%
mvn -q test (fail fixture) 174 85 51.1%
mvn -q test (green run) 0 0 n/a (zero-byte input)

Why the original 98.9% / 97.5% numbers were wrong: the original fixtures were synthetic Surefire 2.x shape (- in separator). Surefire 3.x emits -- in, so the CLOSE regex never matched against real 3.x output; every [INFO] Running … flushed the previous block as kept. Reviewer measured 27.1% real savings; that has been reproduced on the pre-fix binary and corrected. 2.x close-line shape is preserved by the \s+--?\s+in separator and locked by a unit test.

Commands handled

Command Filter Tier
rtk mvn test Surefire block collapse ≥90%
rtk mvn integration-test Same (Failsafe shape) ≥90%
rtk mvn compile / test-compile [ERROR] + indent + [WARNING] dedup ≥75%
rtk mvn package / install / verify / deploy Compile + Surefire pipeline + artifact line ≥85%
rtk mvn -q <phase> Quiet-mode filter (filter_quiet) ≥50% on fail; 0 → 0 on green
rtk mvn clean / site / dependency:* / --version / --help passthrough 0% (no overhead)

clean and site deliberately bypass RTK (hook regex doesn't match them) so no subprocess fork for zero benefit.

Features

  • ANSI strip first in every filter — real Maven output contains colour escapes everywhere ([[1;34mINFO[m]).
  • English-footer guard: if neither BUILD SUCCESS nor BUILD FAILURE appears as a trimmed line suffix, return ANSI-stripped raw input unchanged. Protects non-English locales (French BUILD ÉCHEC, Chinese 生成成功, etc.) — no mangling.
  • Verbose bypass: -X, --debug, -e, --errors skip filtering. User asked for detail; respect it.
  • Surefire block collapse: buffer [INFO] Running <FQN> … [INFO] Tests run: … blocks, drop silently if Failures: 0 && Errors: 0, else emit with framework stack frames stripped.
  • Stack-frame deny-list: drops at org.junit., at junit., at org.apache.maven.surefire., at sun.reflect., at jdk.internal.reflect., at jdk.proxy, at java.base/, at java.lang.reflect., at java.util. (and stream/concurrent). Keeps user-code frames in any namespace.
  • Raw durations preserved: Time elapsed: N s and [INFO] Total time: N s ship through unchanged — the user/LLM needs the real numbers to diagnose performance regressions. (Earlier revisions normalised these to T s in production; removed per reviewer feedback.)
  • Failure-trail retention (Surefire 3.x): 3.x emits the exception class and stack frames after the per-class close line. filter_surefire and filter_package enter a failure_trail state on any <<< FAILURE! close and keep subsequent non-framework lines until the next blank line. 2.x compat preserved.
  • Quiet-mode filtering (mvn -q): detect -q / --quiet in argv → bypass the English-footer guard and route to filter_quiet. Green run = 0 → 0 (no overhead). Failure run keeps signal lines (close, exception, user frames, summary, goal terminator); drops framework frames + post-failure help boilerplate. Safety net: unclassified [ERROR] lines are kept.
  • [WARNING] dedup: strips file coordinates (/path/Foo.java:[L,C]), keeps first occurrence per normalised message.
  • Wrapper detection: ./mvnw (POSIX) / mvnw.cmd (Windows) via string-literal Command::new (semgrep dynamic-command-execution-safe); falls back to resolved_command("mvn").
  • Hook regex fix (addresses Hook doesn't auto-route mvn / mvnw despite handler existing #1537): previous pattern ^mvn\s+(compile|package|clean|install)\b required goal as first token — mvn -B install did not rewrite. New pattern uses lazy quantifier (?:\s+\S+)*? to skip flags. mvn -B clean install now rewrites correctly. Also adds ./mvnw, mvnw.cmd, mvnw prefixes from the reporter's reproduction.
  • Tee recovery: every filtered path uses RunOptions::with_tee("mvn_<phase>") so raw output is recoverable on BUILD FAILURE.

Architecture

  • Phase detection (detect_phase): scan args left-to-right, skip flag tokens (- prefix + -D…), pick the last lifecycle token; route to Test / Compile / Package / Passthrough.
  • Quiet-mode short-circuit: is_quiet(args) runs before phase routing. If set, non-passthrough phases route to filter_quiet instead of filter_surefire / filter_compile / filter_package because those depend on [INFO]-prefixed markers that -q suppresses.
  • Buffered single-pass filters via runner::run_filtered + stateful Fn(&str) -> String. NOT BlockHandler/BlockStreamFilter — run_filtered takes a plain closure (verified at src/core/runner.rs:152-169). Same pattern as gradle's filter_test at gradlew_cmd.rs:229-301.
  • Three filters share helpers: strip_ansi, has_english_footer, keep_outside_block, is_framework_frame, flush_block_as_keep, emit_block.
  • Single PR delivery (not split) — adding Commands::Mvn removes mvn from the parse-error path that consults the TOML filter, so a partial PR would regress compile/install/package (currently ~70% via TOML) to 0% savings until a follow-up landed.

Quality checks

  • cargo fmt --all -- --check — clean
  • cargo clippy --all-targets -- -D warnings — no warnings
  • cargo test --all — 1925 passed, 7 ignored (pre-existing in git.rs/read.rs), 0 failed
  • 44 mvn-specific unit tests (43 active + 1 #[ignore] summary helper) including: phase detection, English-footer guard, framework deny-list, ERROR continuation, WARNING dedup, install/jar lines, 2.x ↔ 3.x close-line compat (\s+--?\s+in), [WARNING]-prefixed close lines (3.x skip-only classes), failure-trail retention, raw duration preservation, -q detection (short/long/false-positive), -q green run is empty, -q fail strips framework + boilerplate, -q ≥50% savings target, -q unclassified-line safety net
  • 2 inline savings assertions on gzipped full commons-cli fixtures (no #[ignore] gating — they run in standard cargo test --all)
  • 12 classifier/rewrite tests in src/discover/registry.rs under new // --- Maven --- section
  • Token savings verified ≥90% on mvn test, ≥85% on mvn install (actual: 98.0% and 96.1%)
  • Hook regex updated with wrapper prefixes + test/integration-test/verify/deploy + flag-skip
  • src/filters/mvn-build.toml deleted, src/core/toml_filter.rs:1585 scrubbed, count assertions updated

Manual testing

  • rtk mvn -B clean — bypass via hook (regex excludes clean); raw mvn output
  • rtk mvn -B compile against a project with intentional compile error: [ERROR] blocks with file:line + indented continuation preserved, BUILD FAILURE footer preserved
  • rtk mvn --version — passthrough; raw mvn version banner
  • Verbose bypass: rtk mvn -B -X compile → raw mvn output (X flag)
  • Locale guard via fixture: French BUILD ÉCHEC preserved unchanged
  • No-pom fixture (mvn install outside any project) passthroughs the [ERROR] no POM line — never blocks
  • Startup time: hyperfine 'target/release/rtk --help' = 1.7 ms ± 0.5 ms (well under 10 ms target)
  • Release binary size: 7.8 MB (unchanged from develop)

Breaking change

The TOML filter previously covered compile|clean|install|package with simple line-strip + 50-line cap. The new Rust module covers a superset of phases with state-machine collapse + locale guard + verbose bypass. Output shape is different (different lines kept; significantly better savings tier). All previously-handled cases still handled, plus test/integration-test/verify/deploy/-q.

The ! in the commit type ensures release-please surfaces this in the CHANGELOG.

Out of scope (deferred)

Documented here so they aren't lost after merge — happy to convert any into formal issues if you'd prefer to track them in the tracker:

  • detect_phase ↔ hook regex disagreement on space-valued flags (-pl core, -T 4, -pl :core)
  • Parallel -T<N>C builds (per-FQN keyed buffer)
  • dependency:tree filter (reviewer flagged as "common and verbose")
  • mvnw.bat legacy Windows wrapper detection

Integrity-check whitelist

Commands::Mvn is intentionally omitted from is_operational_command at src/main.rs:2455-2501, matching the gradle precedent (Commands::Gradlew also omitted). Per the comment at L2452-2454, the whitelist is opt-in by design and a forgotten command fails open. Filter modules invoked via the hook do not require SHA-256 integrity verification of their own binary path — the hook itself is integrity-checked. Documented in src/cmds/jvm/README.md.

Replaces the stateless src/filters/mvn-build.toml (4 phases, 50-line cap) with
a Rust module under src/cmds/jvm/mvn_cmd.rs handling all common Maven lifecycle
phases with stateful Surefire/Failsafe block collapse.

Why TOML couldn't do this: the dominant noise on a healthy Maven project is
expected-exception stack traces inside passing tests (assertThrows). Collapsing
these requires deciding to drop a block based on its closing
`Tests run: N, Failures: 0, Errors: 0` line — a stateful decision the TOML DSL
cannot express.

Measured savings on synthetic full-shape fixtures (~1100 lines each, gzipped):
- mvn test:    3 422 tok → 36 tok (98.9% savings)
- mvn install: 3 460 tok → 85 tok (97.5% savings)

Coverage:

| Phase                              | Filter                                 |
|------------------------------------|----------------------------------------|
| test, integration-test             | filter_surefire (block collapse)       |
| compile, test-compile              | filter_compile (ERROR + indent + dedup)|
| package, install, verify, deploy   | filter_package (compile + surefire)    |
| clean, site, plugin goals, version | passthrough                            |

Key behaviours:
- ANSI strip first in every filter (real Maven output contains colour escapes).
- English-footer guard: if no `BUILD SUCCESS`/`BUILD FAILURE` line is present,
  return ANSI-stripped raw input unchanged — protects non-English locales.
- Verbose bypass: `-X`, `--debug`, `-e`, `--errors` skip filtering.
- Stack-frame deny-list strips framework frames (`at org.junit.`, `at java.util.`,
  `at sun.reflect.`, etc.); user-code frames (any other prefix) preserved.
- Duration normalisation (`Time elapsed: 2.341 s` → `Time elapsed: T s`) for
  deterministic test output.
- Wrapper detection: `./mvnw` / `mvnw.cmd` via string-literal `Command::new`
  (semgrep-safe); falls back to `resolved_command("mvn")`.

Hook regex changes (src/discover/rules.rs):
- Adds wrapper prefixes (./mvnw, mvnw.cmd, mvnw).
- Adds test, integration-test, verify, deploy to the alternation.
- Drops clean + site so bare invocations bypass RTK entirely (0 overhead).
- Lazy quantifier skips flags before the goal (`mvn -B clean install` now
  rewrites correctly; previously failed because the regex required the goal
  as the first token after `mvn`).
- Drops subcmd_savings (lazy match captures first phase, which would mis-tier
  `clean install` to clean); uses flat 82.0.

Tests:
- 33 unit tests in mvn_cmd.rs (phase detection, footer guard, framework deny,
  duration normalisation, ERROR continuation, WARNING dedup, install/jar lines).
- 2 inline savings assertions on gzipped synthetic full fixtures (~3 KB each)
  using flate2 already in Cargo.toml. Run as part of standard `cargo test --all`.
- 12 classifier/rewrite tests in src/discover/registry.rs under new Maven
  section.

Files changed:
- ADD src/cmds/jvm/mvn_cmd.rs (~810 LoC incl. tests)
- ADD src/cmds/jvm/README.md (documents whitelist omission decision)
- ADD tests/fixtures/mvn_*_raw.txt (6 inline) + 2 .gz synthetic full fixtures
- MOD src/main.rs (1 use, 1 Commands variant, 1 dispatch arm)
- MOD src/discover/rules.rs (replace mvn entry)
- MOD src/discover/registry.rs (12 classifier tests)
- MOD src/core/toml_filter.rs (drop mvn-build from expected-filters list,
  adjust the 2 count assertions)
- DEL src/filters/mvn-build.toml

Fixtures are entirely synthetic — generic `com.example.app.*` package names and
`com.example:myapp` Maven coordinates. Same SHAPE as real Maven output (block
structure, ANSI codes, framework stack frames, BUILD footer, plugin banners)
without any project-identifying content.

Integrity-check whitelist: Commands::Mvn is intentionally omitted from
is_operational_command at src/main.rs:2455-2501, matching the gradle precedent
(Commands::Gradlew also omitted). The whitelist is opt-in by design per the
comment at L2452-2454 — filter modules invoked through an already-verified
hook do not need a second integrity check on their own dispatch path.
Documented in src/cmds/jvm/README.md.

Out-of-scope (follow-ups noted in module + README):
- Parallel mode `-T2C` (interleaved blocks across threads).
- Plugin goals (`mvn dependency:tree`, `mvn versions:*`).
- `mvnw.bat` legacy wrapper (Maven Wrapper 3.x emits `.cmd`).

BREAKING CHANGE: `rtk mvn <args>` output format changed. Previously: TOML
filter strips INFO/Downloading lines and caps at 50 lines. Now: state-machine
filter with Surefire block collapse + locale guard + verbose bypass. Different
output shape; significantly better savings tier; behaviour is otherwise a
superset (all previously-handled cases still handled, plus test/verify/etc.).
@CLAassistant

CLAassistant commented May 18, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@pszymkowiak

Copy link
Copy Markdown
Collaborator

Review — validated end-to-end against real Maven, found a blocking issue

Thanks for this — the architecture is genuinely clean: the phase state machine, the English-footer locale guard, the -X/-e verbose bypass, the framework-frame deny-list and the tee recovery are all the right calls, and the module mirrors gradlew_cmd.rs nicely.

I built the branch into Docker (rust:1-bookworm → maven:3.9-eclipse-temurin-21) and ran it against a real project (apache/commons-cli, warm .m2). The headline feature doesn't trigger on real Surefire output.

Blocking: the Surefire close-line regex misses the -- separator

Maven Surefire 3.x emits a double dash before in:

RAW (Surefire 3.x):  [INFO] Tests run: 115, ... Time elapsed: 0.543 s -- in org.apache.commons.cli.OptionValidatorTest
CLOSE regex (mvn_cmd.rs:26): ... Time elapsed: [^ ]+ s (?:<<< FAILURE!)? - in (.+)$

The regex expects - in (single dash), so CLOSE never matches. Blocks never close, and each new [INFO] Running … flushes the previous block as kept (mvn_cmd.rs:175-177). On a green run, rtk keeps 47 Running + 48 Tests run: lines instead of dropping them.

Measured savings (warm cache, commons-cli):

command real savings PR claim
mvn test (green) 27.1% 98.9%
mvn clean compile 76.1% ≥75%
mvn install -DskipTests 78.3% ≥85%

compile / install are fine — they rely on prefix keep-lists, not CLOSE. Only the Surefire path (the core of this PR) is affected; the same break hits verify/package once tests run.

Why CI is green

The .gz fixtures use - in (single dash), so the 33 unit tests + the gzipped savings asserts validate against output Maven never produces. The fixtures need regenerating from real mvn output — that's what masked this.

Suggested fix: tolerate both separators, e.g. \s+--?\s+in (keeps - for Surefire 2.x).

Goal coverage

Filtered: compile, test-compile, test, integration-test, package, install, verify, deploy — a clean superset of the old TOML. Verified passthrough (0%, by design): clean, site, and all plugin goals (dependency:tree, dependency:analyze, versions:*, …). That's reasonable, but dependency:tree is common and verbose — please follow through on the follow-ups you already documented in the PR description (parallel -T2C, plugin goals like dependency:tree, mvnw.bat); they shouldn't be lost after merge.

Secondary

  • Durations normalized in production (mvn_cmd.rs:120): normalise() rewrites Time elapsed: … s → T s and Total time: … s → T s in shipped output, not just snapshots — real timings are wiped for the user/LLM. Worth gating to test-only.
  • mvn -q suppresses the BUILD SUCCESS line, so the footer guard passes through with 0 savings. Safe fallback, but -q is a common agent invocation — worth a note or handling.
  • Phase detection picks the last non-flag token (mvn_cmd.rs:69), so a space-valued flag after the goal (mvn install -pl core, mvn test -T 4) resolves to Passthrough while the hook regex still rewrites on the first goal — they can disagree. (Observed by reading; not in the runs above.)

Path to merge

  1. Fix the CLOSE separator.
  2. Regenerate all fixtures from real mvn output and re-assert savings.
  3. Address the documented follow-ups, plus duration normalization (test-only) and the -q case.

Happy to share the Docker harness. The approach is right — it just needs to meet real Surefire output.

vdufloth added 3 commits May 21, 2026 12:35
CLOSE regex previously required ` - in ` (single-dash, Surefire 2.x).
Surefire 3.x emits ` -- in ` (double-dash), so close lines never matched
on Maven 3.9 default toolchains: blocks stayed open, every new
`[INFO] Running …` flushed the prior block as kept, and real-world
savings dropped to ~27% on commons-cli.

Changes:
- CLOSE separator widened to `\s+--?\s+in ` (matches 2.x and 3.x).
- CLOSE prefix widened to `INFO|ERROR|WARNING` (3.x emits WARNING for
  classes whose only tests are skipped).
- `filter_surefire` and `filter_package` gain a `failure_trail` flag:
  Surefire 3.x emits the exception class and stack frames *after* the
  CLOSE line; keep them (stripping framework frames) until the next
  blank line. Without this, the failing-test signal was silently dropped.

Fixtures replaced with output from a real `apache/commons-cli` build in
`maven:3.9-eclipse-temurin-21`. Synthetic 2.x shape removed; 2.x compat
locked by a dedicated unit test using the single-dash separator.

Measured token savings on commons-cli fixtures:
- `mvn test` (full):    1896 -> 38 tokens (98.0%)
- `mvn install` (full): 2021 -> 78 tokens (96.1%)

Refs: pszymkowiak review on PR rtk-ai#1956
`normalise()` rewrote real `Time elapsed: N s` and `Total time: N s`
values to `T s` in the shipped output. That was only ever useful for
keeping snapshot tests deterministic and never belonged in production:
test perf signal is exactly the kind of data the LLM needs to spot a
regression, and there are no insta snapshots in this module to protect.

- Remove `normalise()` and its `TIME_DURATION` / `TOTAL_TIME` regexes.
- All `filter_surefire`, `filter_compile`, `filter_package` emit sites
  pass the raw line through.
- Renamed and inverted `surefire_normalises_durations` →
  `surefire_preserves_real_durations`: asserts `2.341 s` and
  `Total time:  4.567 s` survive filtering.

Refs: pszymkowiak review on PR rtk-ai#1956 (E.1)
Under `mvn -q`, Maven 3.x suppresses all `[INFO]` lines: no
`BUILD SUCCESS` footer, no `[INFO] Running` markers, no module
banners. The existing surefire/compile/package filters all key off
the English-footer guard and `[INFO] Running`/CLOSE state machine,
so under `-q` they fell through and shipped the raw output unchanged
(0% savings on PR rtk-ai#1956 reviewer's measurement).

Reviewer (@pszymkowiak on PR rtk-ai#1956) listed `-q` handling in the
path-to-merge as "worth a note or handling". This commit implements
the handling.

Behavior captured from `mvn -q test` on Maven 3.9.9 + Surefire 3.5.5
with JUnit 5:

  - Green run emits ZERO bytes — filter returns empty output.
  - Failure run emits ~27 lines: per-class CLOSE line, exception +
    stack trace, `[ERROR] Failures:` summary, aggregate
    `[ERROR] Tests run: N, Failures: F, ...`, and the
    `[ERROR] Failed to execute goal` terminator, followed by a
    ~10-line `[ERROR] See .../[Help 1].../Re-run Maven .../To see
    the full stack trace .../For more information ...` boilerplate
    block pointing at log files and help URLs.

`filter_quiet` keeps the failure-signal lines and the user-code
stack frames; drops the framework frames (existing deny-list) and
the post-failure boilerplate block.

Routing: `is_quiet` checks argv for `-q` or `--quiet`. When set,
`run()` short-circuits the phase-based filter selection and routes
non-passthrough phases to `filter_quiet`. Passthrough phases
(`clean`, `site`, plugin goals) remain passthrough under `-q`.

Measured savings on the captured fail fixture: 51.1%
(174 -> 85 tokens). Green run: 0 -> 0 (no overhead).

Safety net: unclassified `[ERROR]` lines are kept rather than
dropped — better to leak a line than hide signal.

Tests added (7):
  - `quiet_detects_short_flag` / `quiet_detects_long_flag` /
    `quiet_does_not_match_unrelated_flags` for `is_quiet`.
  - `quiet_green_run_is_empty` — empty input contract.
  - `quiet_fail_strips_framework_and_boilerplate` — kept-list and
    drop-list assertions on the real fixture.
  - `savings_mvn_quiet_fail` — >=50% savings target on the fixture.
  - `quiet_unknown_error_line_kept_as_safety_net` — unclassified
    `[ERROR]` not silently dropped.

New fixture: `tests/fixtures/mvn_quiet_fail_raw.txt` (27 lines,
770 bytes) captured from a real `mvn -q test` run against a
JUnit 5 / Surefire 3.5.5 project with one failing test.
@vdufloth

vdufloth commented May 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review and for sharing the harness — re-ran it against apache/commons-cli in maven:3.9-eclipse-temurin-21, captured the raw output, and replaced all fixtures with it. Three commits on feat/mvn-rust-module:

cc152cd — CLOSE regex + failure-trail state (src/cmds/jvm/mvn_cmd.rs:26-30, :170-222, :344-401)

  • Separator widened to \s+--?\s+in per your suggestion — matches Surefire 2.x (- in) and 3.x (-- in) in a single regex.

  • Prefix widened to INFO|ERROR|WARNING. On commons-cli Surefire 3.x emits [WARNING] for classes whose only tests are skipped, e.g.

    [WARNING] Tests run: 5, Failures: 0, Errors: 0, Skipped: 5, Time elapsed: 0.010 s -- in org.apache.commons.cli.BasicParserTest
    

    Without that the block stayed open and got flushed as kept on the next Running line.

  • Added a failure_trail state in filter_surefire (:170-222) and filter_package (:344-401): Surefire 3.x emits the exception class + stack frames after the close line, not inside the block as 2.x default-config did. Raw shape on a 3.x failure:

    [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.543 s <<< FAILURE! -- in org.apache.commons.cli.RtkInducedFailTest
    
    org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
    	at org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure(...)
    	at org.junit.jupiter.api.AssertionUtils.fail(...)
    	...
    

    Without the trail those lines fell through keep_outside_block and got silently dropped. Now retained (framework frames stripped, user frame preserved) until the next blank line.

  • All five Surefire-touching fixtures regenerated from a real apache/commons-cli build inside maven:3.9-eclipse-temurin-21: mvn_test_pass_slice_raw.txt, mvn_test_fail_slice_raw.txt, mvn_install_slice_raw.txt, mvn_compile_error_slice_raw.txt, and the two gzipped full fixtures. All synthetic 2.x-shape content is gone. 2.x compat locked by surefire_matches_legacy_2x_close_line hitting the single-dash separator directly.

  • Measured savings on the new full fixtures (same maven:3.9-eclipse-temurin-21 toolchain as your harness): mvn test 98.0% (1896 → 38 tokens), mvn install 96.1% (2021 → 78 tokens). These are the input bytes your harness pipes to the binary, so the number that answers your 27.1% is 98.0% on green commons-cli mvn test.

77e28d0 — production duration normalisation

Went a step further than the test-only gating you suggested: normalise() and the TIME_DURATION / TOTAL_TIME regexes are deleted outright. There are no insta snapshots in the module today, and surefire_preserves_real_durations asserts the inverse, so a #[cfg(test)] version would be a #[allow(dead_code)] helper with no callers. Production now ships raw Time elapsed: 2.341 s and [INFO] Total time: 4.567 s untouched. Happy to put it back behind a cfg guard if you'd rather keep it available for future snapshot work.

97dbf98 — mvn -q filtering

Implements the -q handling you flagged. Behaviour captured from mvn -q test on Maven 3.9.9 + Surefire 3.5.5:

  • Green run emits zero bytes — filter_quiet returns empty (0 → 0, no overhead).
  • Failure run emits the per-class CLOSE line, exception + stack, [ERROR] Failures: summary, aggregate, [ERROR] Failed to execute goal terminator, followed by ~10 lines of [ERROR] See … / [Help 1] / Re-run Maven / To see the full stack trace boilerplate pointing at log files and help URLs.

filter_quiet keeps the failure signal and user-code frames, drops framework frames (existing deny-list) and the post-failure boilerplate block. Routing: is_quiet(args) (-q / --quiet) short-circuits the standard phase routing; passthrough phases (clean, site, plugin goals) remain passthrough. Measured savings on the captured fail fixture: 51.1% (174 → 85 tokens). Safety net: unclassified [ERROR] lines are kept rather than dropped — better to leak a line than hide signal.

On the remaining secondary items

  • detect_phase ↔ hook regex disagreement on space-valued flags — captured in the PR body's Out of scope section. Confirmed via a shim mvn on PATH that mvn install -pl core, mvn test -T 4, mvn install -pl :core all return Passthrough from detect_phase while the hook regex at src/discover/rules.rs:693 still rewrites. Latent, not a regression: today those commands route to Passthrough → raw mvn output ships unchanged, same as if the hook hadn't matched. Follow-up tracks the gain opportunity, not a bug.
  • Parallel -T<N>C, dependency:tree, mvnw.bat — also documented under Out of scope in the PR body. Happy to file formal issues for any of these if you'd rather have them in the tracker.

Quality gate on all three commits: cargo fmt --all && cargo clippy --all-targets -- -D warnings && cargo test --all (1925 passed, 7 ignored pre-existing in git.rs/read.rs, 0 failed). 10 new mvn-specific tests across the three commits (2.x compat, WARNING-prefix close, 3.x failure trail, raw-duration preservation, is_quiet, -q empty green, -q fail strip, -q savings, -q safety net) bring the module total to 44. hyperfine 'target/release/rtk --help' measures 1.7 ms ± 0.5 ms.

Happy to take another pass on anything else, @pszymkowiak.

@aeppling

aeppling commented May 24, 2026 •

Copy link
Copy Markdown
Contributor

Verified the branch end-to-end against the real commons-cli fixtures through the built binary. Remaining items:

P0: compile errors lose detail on mvn test
filter_surefire (mvn_cmd.rs:160) lacks the keep_continuation branch that filter_compile (:302) and filter_package (:408) have. Verified: rtk mvn test keeps cannot find symbol but drops the following symbol: / location: lines; rtk mvn install on the same input keeps them. Since the test lifecycle compiles first, this hits the headline command on a common failure. Please mirror the continuation handling into filter_surefire and add a compile-fail-under-test test.

P1: no output cap / config::limits() unused
The module's only bound is the global RAW_CAP (10 MiB), which guards the raw/tee buffer, not the filtered output, so a BUILD FAILURE with many failures isn't capped (unlike git/cargo/gradle, which cap and emit a "+N more" tail). Cap failing classes (and the [ERROR] Failures: block) with a tail line, ideally via a tunable mvn_max_failures on LimitsConfig.

P2: medium

  • Multi-module reactor: keep_outside_block (:127) drops the Reactor Summary rows; the failing module survives only via the [ERROR] Failed to execute goal … line. Keep the rows, and add a multi-module fixture (all current fixtures are single-module).
  • The Surefire block state machine is duplicated between filter_surefire (:177) and filter_package (:352). Extract a shared helper (also prevents the P0-style drift).

P3: minor

  • Idioms: block buffers (:167, :340) can borrow &str rather than clone; &line["[WARNING] ".len()…] (:309, :415) reads cleaner as strip_prefix(...).unwrap_or(line).
  • CRLF is handled by str::lines() but isn't asserted by a test.

Mechanics: Please drop Cargo.lock from the PR.

Open to discuss about those if needed

vdufloth added 6 commits May 25, 2026 12:05
When `mvn test` fails at the compile step before Surefire runs, the
`[ERROR]` block's indented `symbol:` / `location:` / `^` continuation
lines were being dropped. `filter_surefire` had no `keep_continuation`
state, unlike `filter_compile:302` and `filter_package:408`.

Add the missing state machine: reset in the RUNNING branch, set on each
kept `[ERROR]` line (except `Tests run:` / `Failures:` / `Errors:`
headers — same predicate as `filter_package:402-405`), and pass through
indented continuation lines before the outside-block keep-list check.

Fixture `mvn_test_compile_fail_slice_raw.txt` captures the
`mvn test` slice for a project with a deliberate `cannot find symbol`
in a `src/main/java` source. New tests:
- `surefire_keeps_compile_continuation_on_test_phase` — the bug.
- `package_still_keeps_compile_error_continuation_after_refactor` —
  drift guard on the `install`/`verify` path.
`filter_surefire` and `filter_package` had a ~30-line duplicated state
machine for Surefire/Failsafe per-class blocks (RUNNING / in-block
buffer / CLOSE branching / failure-trail). The duplication is what let
the P0 continuation bug land — the fix had to be applied in only one of
the two filters.

Introduce `SurefireBlock` owning that inner machine: PLUGIN_BANNER skip,
RUNNING (with prior-block flush on truncated output), in-block
buffering, CLOSE detection, and the post-close failure-trail with
framework-frame stripping.

Each `step()` returns `SurefireStep::{Consumed, FailingClose, Passthrough}`.
The `FailingClose` arm defers the actual write to the caller via
`commit_failing()` — this is the seam Commit D will use to enforce the
`mvn_max_failures` cap without duplicating the cap logic.

Outside-block keep logic stays inside each filter unchanged
(`MODULE_BANNER || keep_outside_block`, `[WARNING]` dedup, the
`keep_continuation` flag from Commit A). Byte-identical filter output —
all existing `surefire_*` / `package_*` / token-savings tests pass.

The previous free helpers `flush_block_as_keep` and `emit_block` are
removed; their behaviour now lives in `SurefireBlock::flush_open_block_as_keep`
and `SurefireBlock::commit_failing`.
In a reactor build (parent pom with `<modules>`), Maven appends a
`Reactor Summary for <root>` block at the end listing each module's
status:

    [INFO] Reactor Summary for multi-module-skeleton 1.0.0-SNAPSHOT:
    [INFO]
    [INFO] multi-module-skeleton ...... SUCCESS [  0.353 s]
    [INFO] child-a .................... SUCCESS [  1.676 s]
    [INFO] child-b .................... FAILURE [  0.940 s]

`filter_surefire` / `filter_package` were dropping these rows because
`keep_outside_block` only keeps `[INFO]` lines that match a specific
prefix (`Building`, `Installing`, `Total time:`, etc.). The per-module
status rows match none of them.

Add a `REACTOR_SUMMARY` regex and `reactor_summary_keep` helper that
toggles a flag on the header and clears it on `BUILD SUCCESS` /
`BUILD FAILURE`. While the flag is set the helper returns `true` so
the rows survive — including `[WARNING]` (activated-profile notices)
and the in-summary horizontal rule. Caller invokes the helper before
`keep_outside_block` so the clears-flag side effect runs regardless
of `||` short-circuit.

Fixtures captured against a hand-rolled 2-module skeleton checked in
under `tests/fixtures/multi-module-skeleton/` (parent pom + child-a
+ child-b with `Empty.java` stubs). The fail fixture is captured by
temporarily replacing child-b's `Empty.java` with one that references
a `BogusType` symbol. Reproducible from the committed skeleton.
`filter_surefire` / `filter_package` emitted unbounded output on builds
with hundreds of failures — the per-class failing blocks (with their
post-close exception trail) plus every `[ERROR]   ClassA.test:N …`
entry in the `[ERROR] Failures:` summary were all kept. Other RTK
filters (git/cargo/lint) already cap with `... +N more` tails; bring
mvn in line.

Add `LimitsConfig::mvn_max_failures` (default 25, matching the
`grep_max_per_file: 25` convention in the same struct; `0` = opt out)
and wire it into two cap sites:

1. Failing-class blocks. The shared `SurefireBlock::step()` machine
   yields `FailingClose` once per failing class; the outer loop now
   commits the first N and calls a new `drop_failing()` for the rest.
   `drop_failing()` enables a `drop_trail` flag so the post-close
   per-test subline, exception, and user frames are consumed silently
   until the next blank line. After the loop, if any were dropped,
   `\n... +N more failing test classes\n` is appended.

2. `[ERROR] Failures:` summary block. New `FailuresSummaryCap` helper
   tracks the header, caps `[ERROR]   ` entries at the same limit, and
   pre-emits `\n... +N more failures\n` immediately before the
   aggregate `[ERROR] Tests run: …` line.

Tail wording follows the existing repo convention — no `[INFO]`
prefix, no parenthetical hint (`cargo_cmd.rs:1035` uses the same
`\n... +N more failures\n` verbatim).

Tests:
- `surefire_caps_failing_blocks_emits_tail` — synthetic 5-failure
  fixture with `cap = 3`; asserts blocks 1-3 emitted, 4-5 dropped,
  tail emitted.
- `surefire_cap_zero_disables_capping` — same fixture with `cap = 0`;
  all 5 blocks kept, no tail.
- `failures_summary_block_is_capped` — 5-entry summary with `cap = 3`;
  asserts entries 1-3 kept, 4-5 dropped, tail emitted *before* the
  aggregate.

Behaviour-change note: the default cap of 25 means users without a
custom config get a bounded output for the first time. Existing
fixtures all have <25 failures so no current test diffs.
Three small reviewer-suggested cleanups:

- `[WARNING] ` prefix slicing in `filter_compile` / `filter_package`
  was written defensively as `&line["[WARNING] ".len().min(line.len())..]`.
  Both call sites are already gated by `line.starts_with("[WARNING]")`
  so the `min` is dead defence. Use `strip_prefix(...).unwrap_or(line)`
  which reads identically to the rest of the module.

- `SurefireBlock` and `SurefireStep::FailingClose` now borrow lines
  from the post-ANSI-strip `String` (`Vec<&'a str>` / `Option<&'a str>`
  / `&'a str` close), tied to the `stripped` local in
  `filter_surefire_with_cap` / `filter_package_with_cap`. No
  `.to_string()` allocations on the hot per-line path; the slice
  fixtures' savings tests are unaffected and the gzipped full-fixture
  tests still hit ≥90%/≥85%.

- New `surefire_handles_crlf_line_endings` and
  `package_handles_crlf_line_endings` tests assert byte-equality
  between an LF-filtered fixture and a CRLF-converted-then-LF-normalised
  one. Catches the class of bug where exact-equality line checks
  silently miss CRLF input because `str::lines()` strips `\n` but
  keeps trailing `\r`.
`develop`'s Cargo.lock has its rtk-package entry pinned at `0.36.0`
while `Cargo.toml` says `0.34.3` — the lock is internally stale on
develop. When `cargo build` ran on this branch it re-pinned the lock
to `0.34.3` to match `Cargo.toml`, producing the diff the reviewer
flagged on PR rtk-ai#1956.

Revert the lock to `develop`'s baseline so the PR's committed diff
no longer touches Cargo.lock. `cargo build` will rewrite the
working-tree lock on every invocation; that's a local artifact, not
part of the committed PR.
@vdufloth

vdufloth commented May 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks @aeppling — all six points addressed, one commit per ask, P0 → P1 → P2 → P3 → mechanics. Quality gate (cargo fmt --all && cargo clippy --all-targets && cargo test --all) green on every step; total tests went from 1925 to 1934 (+9 new).

P0 — filter_surefire continuation drop (97bd2a7)

Confirmed and fixed at src/cmds/jvm/mvn_cmd.rs:441 (filter_surefire). Added a keep_continuation flag with the same predicate filter_package uses (set on any kept [ERROR] that isn't Tests run: / Failures: / Errors:), and a passthrough branch for indented continuation lines before the outside-block keep-list check.

Fixture tests/fixtures/mvn_test_compile_fail_slice_raw.txt is captured from a deliberate BogusType insertion under src/main/java in a commons-cli checkout; it goes through filter_surefire because phase routing pins on the last non-flag arg (test), and shows the exact cannot find symbol → symbol: → location: triple. Two new tests: surefire_keeps_compile_continuation_on_test_phase (the bug) and package_still_keeps_compile_error_continuation_after_refactor (drift guard on the package path).

Savings on the new fixture: 122 → 70 tokens, 42.6 % savings. (Slice is small — the absolute numbers are modest, but the signal is now preserved end-to-end.)

P1 — bounded failing-class output (be6c812)

Added LimitsConfig::mvn_max_failures at src/core/config.rs:138, default 25 (matching the grep_max_per_file: 25 / status_max_files: 15 convention in the same struct), sentinel 0 = opt-out. Two cap sites:

  1. Failing test classes. The shared SurefireBlock::step() machine (Commit B) yields FailingClose once per failing class; the outer loop in filter_*_with_cap commits the first N and calls a new SurefireBlock::drop_failing() (mvn_cmd.rs:294) for the rest. drop_failing flips a drop_trail flag so the post-close per-test subline, exception, and user frames are consumed silently until the next blank line — otherwise keep_outside_block would still preserve the [ERROR] subline for dropped classes. After the loop, \n... +N more failing test classes\n is appended when any were dropped.
  2. [ERROR] Failures: summary entries. New FailuresSummaryCap helper (mvn_cmd.rs:363) tracks the summary header, caps [ERROR] (three-space-indent) entries at the same limit, and pre-emits \n... +N more failures\n immediately before the aggregate [ERROR] Tests run: line.

Tail wording is verbatim cargo_cmd.rs:1035 (\n... +N more failures\n) — no [INFO] prefix, no parenthetical hint.

Behaviour-change note: with the default cap of 25 in place, users without a custom config now get bounded output on large failure sets. Existing fixtures all have <25 failures so no current test diffs; happy to flip the default to 0 if you'd prefer opt-in.

Cap demo on a synthetic 50-failing-class input: uncapped 1761 → capped 892 tokens, 49.3 % extra savings on top of the base filter.

Three new tests: surefire_caps_failing_blocks_emits_tail (cap=3, 5 failures, asserts blocks 1-3 emitted + 4-5 dropped + tail), surefire_cap_zero_disables_capping, and failures_summary_block_is_capped (verifies tail precedes aggregate).

Documented in src/cmds/jvm/README.md alongside the other key behaviours.

P2a — multi-module Reactor Summary (4609102)

Added REACTOR_SUMMARY regex and reactor_summary_keep helper at src/cmds/jvm/mvn_cmd.rs:140. The helper toggles in_reactor_summary on the [INFO] Reactor Summary for header and clears it on BUILD SUCCESS / BUILD FAILURE. While set, it returns true for every line so the per-module rows ([INFO] child-a ...... SUCCESS [ 1.676 s]), in-summary [WARNING] activated-profile notices, and the dashes-only HR all survive. Both filters call it before keep_outside_block so the BUILD_FOOT-clears-flag side effect always runs regardless of || short-circuit.

Fixtures captured against a hand-rolled 2-module reactor checked in under tests/fixtures/multi-module-skeleton/: parent pom + child-a + child-b with Empty.java stubs. The pass fixture is mvn install on the skeleton as-is; the fail fixture is captured by temporarily swapping child-b/src/main/java/Empty.java to one referencing BogusType. Reproducible from the committed skeleton — cd tests/fixtures/multi-module-skeleton && mvn install.

Savings on the new fixtures: reactor-pass 330 → 98 tokens, 70.3 %; reactor-fail 395 → 200 tokens, 49.4 %. Two new tests: reactor_summary_kept_on_multi_module_pass, reactor_summary_kept_on_multi_module_fail.

P2b — extract shared Surefire block state machine (5459c6d)

Did this before P1 so the cap logic only had to land in one place. Introduced SurefireBlock at src/cmds/jvm/mvn_cmd.rs:174 owning the inner state machine: PLUGIN_BANNER skip, RUNNING (with prior-block flush on truncated output), in-block buffering, CLOSE detection, and the post-close failure-trail with framework-frame stripping.

step() returns SurefireStep::{Consumed, FailingClose { … }, Passthrough}. The FailingClose variant defers the actual write to the caller via commit_failing() (mvn_cmd.rs:302) — this is the seam P1 uses to enforce the cap without duplicating the cap logic.

The outer outside-block keep stays inside each filter unchanged (MODULE_BANNER || keep_outside_block, [WARNING] dedup, keep_continuation). Byte-identical filter output — all pre-existing surefire_* / package_* / token-savings tests still pass.

P3a, P3b, P3c — polish (92a9218)

  • strip_prefix: replaced both &line["[WARNING] ".len().min(line.len())..] defensive slices with strip_prefix("[WARNING] ").unwrap_or(line). Both sites are already gated by line.starts_with("[WARNING]") so the min was dead defence.
  • Borrow refactor: SurefireBlock<'a> and SurefireStep<'a>::FailingClose now hold Vec<&'a str> / Option<&'a str> / &'a str close, tied to the post-ANSI-strip stripped local. No .to_string() allocations on the per-line hot path. Lifetime propagation through the shared helper was clean — 'a only appears on the struct/enum and the step() signature; call sites don't need to mention it. Gzipped full-fixture savings still hit ≥90 % / ≥85 %.
  • CRLF tests: surefire_handles_crlf_line_endings and package_handles_crlf_line_endings convert an LF fixture to CRLF, filter both, normalise the CRLF output back to LF, and assert byte-equality. Catches the class of bug where exact-equality line checks silently miss CRLF input because str::lines() strips \n but keeps trailing \r.

Mechanics — Cargo.lock (f58333c)

develop's lock is internally stale (rtk-package entry pinned at 0.36.0 while Cargo.toml says 0.34.3) — when cargo build ran on this branch it re-pinned the lock to 0.34.3, producing the diff you flagged. Reverted to develop's baseline so the PR's committed diff no longer touches Cargo.lock. cargo build will keep rewriting the working-tree lock on every invocation; that's a local artifact, not part of the PR.


If anything looks off, happy to iterate on individual commits before merge.

@aeppling aeppling self-assigned this May 29, 2026

@aeppling aeppling left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested this against real Maven (Surefire 3.2.5) by building the binary and running live builds, not just the fixtures. The earlier review items all check out: separator fix, duration preservation, -q, the compile-continuation fix, the failure cap, reactor summary, shared block machine. Passing builds, single failures, compile errors and quiet mode all behave correctly.

One blocker though. When a single test class has more than one failing test, the filter drops the exception message and leaks the framework stack for every failure after the first.

Surefire 3.x prints one blank-separated detail block per failing test under a single CLOSE line. failure_trail in SurefireBlock::step() ends at the first blank line and never re-arms, so only the first failure in a class goes through the framework-frame stripping. The rest fall through to the generic keep_continuation path. Real mvn test with two failures in one class:

[ERROR] CalcTest.failTwo -- ... <<< FAILURE!
	at org.junit.jupiter.api.AssertionFailureBuilder.build(...)
	at java.base/java.util.ArrayList.forEach(...)

The AssertionFailedError: expected... but was... line is gone and the full junit/jdk stack is kept, the opposite of what the module is supposed to do. A class with 20 failures dumps a couple hundred lines of framework noise. The summary block at the bottom still has a one-liner per failure so it's not a total signal loss, but the inline output is wrong and savings tank on a common pattern.

CI doesn't catch it because every *_fail* fixture has exactly one failing test per class. Worth noting filter_quiet already handles this, it re-arms failure_trail on each subline (mvn_cmd.rs:730), but filter_surefire/filter_package don't. The fix is to do the same re-arm on [ERROR] <FQN> ... <<< FAILURE!/ERROR! sublines and run is_framework_frame stripping there, plus a fixture with two failures in one class.

Minor, not blocking: the non-quiet failure path keeps the post-failure help boilerplate (-> [Help 1], Re-run Maven, etc.) that filter_quiet strips. Could reuse QUIET_BOILER_PREFIXES.

Otherwise solid. Just needs the multi-failure path sorted before merge.

@aeppling

Copy link
Copy Markdown
Contributor

Also all ci checks should pass for this to be merged

@qingmo

qingmo commented Jun 4, 2026

Copy link
Copy Markdown

wait for this feature

vdufloth added 3 commits June 6, 2026 11:05
Surefire 3.x emits one blank-separated detail block per failing test
under a single class close line. The trail previously ended at the
first blank line and never re-armed, so failures after the first lost
their exception message (unindented -> dropped) and leaked the full
junit/jdk framework stack (indented -> kept by keep_continuation).

SurefireBlock now remembers the trail's keep/drop decision when it
ends at a blank line (trail_rearm) and re-enters the trail on the next
per-test subline with the same decision — a capped class drops all its
per-test blocks, not just the first. Any other non-blank line disarms
re-entry; RUNNING/commit/drop clear it so an unrelated class can never
inherit the decision. Extra blanks between per-test blocks stay armed.

Per-test sublines use '<<< ERROR!' for thrown (non-assertion)
exceptions — accept both markers via a shared is_per_test_subline()
(also reused by filter_quiet, which already re-armed correctly), and
widen the CLOSE regex to tolerate an ERROR! marker defensively
(Surefire 3.5.5 emits FAILURE! even for errors-only classes, per the
fixture capture; detection keys off the counts, not the marker).

Fixture captured from real Maven 3.9.9 / Surefire 3.5.5 output against
the new tests/fixtures/multifail-skeleton/ (CalcTest: assertion failure
+ thrown exception in one class; BoomTest: errors-only class). A
byte-exact pin locks the single-failure path unchanged.
test (windows-latest) failed on the two CRLF tests: with no
.gitattributes, the Windows runner's core.autocrlf=true checks fixtures
out with CRLF, include_str! embeds \r\n, and the tests'
replace('\n', "\r\n") synthesis produces \r\r\n. str::lines() strips
only the final \r\n of each pair, leaving a stray \r that $-anchored
regexes (MODULE_BANNER, BUILD_FOOT) reject — dropping the module banner
and BUILD SUCCESS in the CRLF leg only. Real Maven CRLF output (single
\r\n) was always handled; only the synthesized double-CR leg broke.

Root-cause fix: tests/fixtures/** -text — fixtures are byte-exact
filter inputs; eol conversion must never touch them. Defense-in-depth:
the two CRLF tests normalize the embedded fixture back to LF before
synthesizing, so they stay correct even on checkouts without
attributes (zip downloads, pre-existing autocrlf clones).

Also fixes the test doc comment, which claimed str::lines() keeps the
trailing \r of a \r\n pair — it strips it; the hazard is the doubled
\r\r\n, not single CRLF.
filter_quiet already dropped the help block Maven emits after
'Failed to execute goal' (See ..., -> [Help 1], Re-run Maven, help
URLs, bare [ERROR] dividers); the non-quiet paths kept it via the
[ERROR] catch-alls — keep_outside_block for test/package and
filter_compile's own [ERROR] branch.

Promote QUIET_BOILER_PREFIXES to a shared BOILER_PREFIXES (+
is_boilerplate(), which also covers the bare [ERROR] divider lines,
matching filter_quiet's existing drop) and reject boilerplate before
each [ERROR] catch-all. The list deliberately keeps the resume hint
([ERROR] After correcting the problems... / [ERROR]   mvn <args>
-rf :module) — actionable signal for resuming a multi-module build —
and the Failed to execute goal terminator.

keep_continuation hygiene (defensive parity, not a demonstrated bug):
filter_surefire only reset the flag inside its kept branch, so a
dropped [ERROR] line would leave it stale and a following indented
line could be wrongly kept. No committed fixture exhibits the leak;
add the same end-of-loop fall-through reset filter_package already
has. filter_compile resets on its new boilerplate drop path.

Multifail-slice savings rise from 19.9% to 42.3%; threshold pinned
at >=30% to match the reactor-fail precedent. README: document the
stripping and drop the stale duration-normalisation bullet (that
behaviour was removed in 77e28d0).
@vdufloth

vdufloth commented Jun 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @aeppling — all three points addressed, one commit per ask as before. Quality gate green on every step; 1934 → 1943 tests.

Blocker — multi-failure trail re-arm (1050cfe)

Fixed along the lines you suggested. When a trail ends at a blank line, SurefireBlock now remembers the keep/drop decision (trail_rearm), and the next per-test subline (<<< FAILURE! or <<< ERROR!, shared is_per_test_subline() also used by filter_quiet) re-enters the trail with that same decision. So every failure in a class gets framework-frame stripping, and a class capped by mvn_max_failures drops all of its blocks, with the tail still counting classes. Any other non-blank line disarms re-entry, and RUNNING clears it, so a later class can't inherit the decision.

Fixture is captured from a real mvn test run (Maven 3.9.9, Surefire 3.5.5): new tests/fixtures/multifail-skeleton/ with one class containing an assertion failure plus a thrown exception, and an errors-only class. One note from the capture: 3.5.5 prints <<< FAILURE! on the class close line even for errors-only classes; ERROR! only shows up on per-test sublines. The CLOSE regex accepts both anyway, since detection keys off the counts.

8 new tests, including a byte-exact pin of the single-failure path (output unchanged) and a capped-class test asserting nothing from the second block leaks. Also ran the release binary against the skeleton: both exception messages survive, no junit/jdk frames, and with mvn_max_failures = 1 the capped class disappears entirely behind ... +1 more failing test classes.

CI — windows-latest CRLF failures (be28a51)

Root cause was checkout, not the filter: with no .gitattributes, the Windows runner (core.autocrlf=true) checks fixtures out with CRLF, so the CRLF tests' replace('\n', "\r\n") produces \r\r\n. str::lines() strips only the final \r\n of each pair, and the stray \r makes the $-anchored MODULE_BANNER/BUILD_FOOT regexes miss — which is why only the synthesized CRLF leg failed and everything else passed on Windows.

Fix: tests/fixtures/** -text in a new .gitattributes, plus the two CRLF tests normalize the fixture back to LF before synthesizing, so they stay correct on zip downloads or pre-existing autocrlf clones. Reproduced the failure mode locally with a CRLF-converted fixture copy to confirm. No filter change needed — real Maven CRLF output was always handled.

Minor — non-quiet help boilerplate (df76528)

Done. QUIET_BOILER_PREFIXES is now a shared BOILER_PREFIXES (also covering the bare [ERROR] divider lines, matching filter_quiet), dropped before the [ERROR] catch-alls in test/package. I extended the same stripping to filter_compile for consistency — happy to revert if you'd rather keep it minimal.

One thing I kept on purpose: the resume hint ([ERROR] After correcting the problems… / [ERROR] mvn <args> -rf :child-b). It's the only actionable line in the block, so it stays alongside Failed to execute goal.

Multifail-slice savings went from 19.9% to 42.3% with the boilerplate stripped; full-fixture targets unchanged (≥90% test, ≥85% install).


The CI run for df76528 is waiting on workflow approval — could you approve it when you get a chance? Local gate is green and the Windows failure mode is reproduced and fixed, so it should come back green including test (windows-latest).

@aeppling

aeppling commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Hello @vdufloth, and thanks for the very thorough work across the previous rounds — the trail re-arm, CRLF fix, reactor summary, and shared SurefireBlock are all clean, and CI is green now. Just one last architectural ask before merge.

Could you reuse the existing cap infrastructure instead of the new LimitsConfig::mvn_max_failures field?

src/core/truncate.rs defines the canonical caps (CAP_ERRORS, CAP_WARNINGS, CAP_LIST, CAP_INVENTORY). The README at src/core/README.md documents the policy: every filter binds a local const MAX_* to the right CAP. Every other test-failure filter already does this:

  • pytest_cmd.rs:9 MAX_PYTEST_FAILURES = CAP_WARNINGS
  • rspec_cmd.rs:17 MAX_RSPEC_FAILURES = reduced(CAP_WARNINGS, 5)
  • rake_cmd.rs:12 MAX_RAKE_FAILURES = CAP_WARNINGS
  • runner.rs:10 MAX_RUNNER_FAILURES = CAP_WARNINGS

The README also notes the CAPs are the staging point for the user-config surface that's coming — overriding CAP_WARNINGS once in ~/.config/rtk/config.toml should tune every test-failure filter in one place. A per-filter mvn_max_failures knob would force a special-case migration when that lands.

Suggested change:

use crate::core::truncate::CAP_WARNINGS;
const MAX_MVN_FAILING_CLASSES: usize = CAP_WARNINGS;

If classes warrant a deviation from per-test failure (one class hiding N tests), use truncate::reduced(CAP_WARNINGS, N) with the one-line comment the README's deviation rule asks for. And drop the new field from LimitsConfig.

While you're at it, the tail format would ideally match the canonical … +N more {label} shape from src/core/utils.rs:148 (join_with_overflow) — same convention used by container, aws, gh, glab. The mvn tail is mid-stream so the helper may not slot in directly, but the format string (ellipsis + label) should match so the LLM sees one consistent overflow style. Same idea for the tee [full output: …] recovery hint when content is capped.

With this lined up, the PR should be ready. Thanks again for the patience on the back-and-forth.

vdufloth added 3 commits June 7, 2026 14:30
…g knob

Replace the per-filter LimitsConfig::mvn_max_failures field with a local
const MAX_MVN_FAILING_CLASSES = CAP_WARNINGS, matching the canonical cap
binding used by pytest/rspec/rake/runner. Align cap = 0 semantics with the
core policy (summary-only: no blocks emitted, tail still counts) instead
of the old opt-out meaning, so the future config surface lands without a
special case.
Match the join_with_overflow convention (ellipsis char, '+N more' label)
used by container, aws, gh and glab so the LLM sees one consistent
overflow style. The tee '[full output: …]' recovery hint already comes
from core tee_and_hint via RunOptions::with_tee, so capped failure output
carries the canonical recovery path unchanged.
@vdufloth

vdufloth commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @aeppling — both points addressed, one commit per ask as before.

One note up front: the branch was cut before src/core/truncate.rs landed on develop, so the first step was a sync merge (no conflicts). Suite green at the merge point before any change — 2145 tests; the jump from last round's 1943 is develop's own additions coming in.

Cap infrastructure (3c0ee94)

Done as suggested:

use crate::core::truncate::CAP_WARNINGS;
const MAX_MVN_FAILING_CLASSES: usize = CAP_WARNINGS;

LimitsConfig::mvn_max_failures is gone — with the field dropped and develop merged in, the PR no longer touches src/core/config.rs at all. Both consumers (failing-class blocks and the [ERROR] Failures: summary entries) bind to the shared const. No reduced() deviation: by the time a class block is emitted, framework frames are already stripped, so blocks are compact — per-test sublines plus exception messages and user frames. If real-world use shows classes are still too verbose, reduced(CAP_WARNINGS, N) is a one-line follow-up.

While dropping the knob I also aligned the cap == 0 semantics with the core policy in src/core/README.md: 0 now means summary-only (no blocks, tail still counts every dropped class) instead of the old "opt out of the cap" meaning. Production can't hit 0 today since the const is CAP_WARNINGS, but this way the config surface lands on mvn without the special-case migration you flagged. The cap-0 test flipped accordingly (surefire_cap_zero_emits_summary_only).

Tail format (f8bc856)

The tails are now … +N more failing test classes / … +N more failures — ellipsis plus +N more {label}, matching join_with_overflow. As you anticipated, the helper itself doesn't slot in (the summary tail is pre-emitted mid-stream before the Tests run: aggregate), but the format string is identical so the output shows one overflow style.

On the tee hint: mvn's filtered phases already route through RunOptions::with_tee, so capped output rides core's tee_and_hint and gets the canonical [full output: …] line — no custom format to align. The one gap is -Dmaven.test.failure.ignore=true (failing tests, exit 0): with the default tee.mode = "failures" no file is written, so a capped-but-green run has no recovery hint. Happy to wire force_tee_hint on the dropped-content path if you want that covered; left it out to keep this round minimal.

Quality gate green on both commits (fmt, clippy, cargo test --all). Savings targets unchanged (≥90% test, ≥85% install) with the cap at 10.

Same as last time, the CI run is waiting on workflow approval — could you approve it when you get a chance?

@aeppling

aeppling commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Hello @vdufloth — quick correction on my earlier message, since you offered to wire this up.

The canonical function isn't force_tee_hint — it's force_tee_tail_hint in src/core/tee.rs:225. It emits [see remaining: tail -n +{N} ~/path] so the LLM can read only the dropped section via tail -n +N, not the whole raw file. Far better token recovery, and it bypasses the tee.mode = "failures" gate (via force_tee_path), so it also covers the -Dmaven.test.failure.ignore=true green-with-capped-failures case in one go.

Every other capped-tail filter in the codebase already uses it — container.rs, gh_cmd.rs, dotnet_cmd.rs, go_cmd.rs, lint_cmd.rs, pnpm_cmd.rs, cargo_cmd.rs. The shape is:

if dropped > 0 {
    out.push_str(&format!("\n… +{} more failing test classes\n", dropped));
    if let Some(hint) = crate::core::tee::force_tee_tail_hint(
        raw,                            // the full raw output
        "mvn-test-failures",            // slug
        MAX_MVN_FAILING_CLASSES + 1,    // line offset where the dropped section starts
    ) {
        out.push_str(&format!("{}\n", hint));
    }
}

Reference call sites you can crib from:

  • src/cmds/cloud/container.rs:295-300 (docker images)
  • src/cmds/git/gh_cmd.rs:281 (gh prs)
  • src/cmds/python/pytest_cmd.rs:281 uses force_tee_hint (the non-tail variant) but the pattern of pairing with the cap is the same.

In mvn_cmd.rs this needs wiring at the two cap sites you already have: the failing-class tail (… +N more failing test classes) and the FailuresSummaryCap tail (… +N more failures). The line_offset argument is the line in the raw output where the dropped section begins, so the tail -n +N command reads only the dropped lines — not the slug-naming, the offset, is what matters for correctness here.

With this added, the cap surface is fully consistent with the rest of the codebase and the recovery path works on both red and green capped runs. After that, I think we're truly done.

@vdufloth

vdufloth commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @aeppling — happy to wire it. One thing I want to confirm first, because I think the tail variant may not fit mvn's output shape and I'd rather get it right than ship a hint that points at the wrong lines.

force_tee_tail_hint's tail -n +N only lands on the dropped items when each item is a single line — that's how docker-images (MAX_IMAGES + 2) and gh-prs (MAX_LIST + 1) work: they tee a flat one-line-per-item buffer, so line K is item K. mvn's dropped failing classes are multi-line blocks (close line + per-test sublines + exception + user frames), interleaved through the raw output, so a fixed MAX_MVN_FAILING_CLASSES + 1 offset against raw would land on [INFO] Scanning for projects… rather than the 11th failure. The summary entries ([ERROR] Class.test:line …) are one-liners, but they're not contiguous in raw either.

That's the case the src/cmds/README.md table routes to force_tee_hint — multi-line "test failures, build error blocks," where "a line offset is meaningless" — and it's why the pytest path you pointed at (pytest_cmd.rs:281) uses force_tee_hint(&all_failures, …) rather than the tail variant.

So my plan was force_tee_hint(raw, "mvn-test-failures") paired with the failing-class tail: full raw output for recovery, and since force_tee_path bypasses the tee.mode = "failures" gate it also covers the -Dmaven.test.failure.ignore=true green case you mentioned. Does that work, or did you have a flat-list buffer in mind that I'm not seeing?

@aeppling

aeppling commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor

Hello @vdufloth, my bad on the back-and-forth around the tee hint, that was a wrong direction. The PR is ready as it is. Thanks for the thorough work across all the rounds (trail re-arm, CRLF fix, reactor summary, cap binding to CAP_WARNINGS, tail format alignment), and for the patience.

Approving and mergin, thanks again for this contribution ! Will be available in next release

@aeppling
aeppling merged commit 047f454 into rtk-ai:develop Jun 8, 2026
11 checks passed
@aeppling aeppling mentioned this pull request Jun 8, 2026
audichuang added a commit to audichuang/rtk that referenced this pull request Jun 8, 2026
…tk-ai#1956)

Deep-compared our PR rtk-ai#1089-based mvn module against upstream's newly-merged
PR rtk-ai#1956. Our module remains the richer superset (mvnd/checkstyle/dependency:tree/
clean + Surefire/Failsafe XML enrichment); 8 of 11 dimensions were equal-or-better
and deliberately NOT absorbed. Three confirmed upstream advantages are absorbed
here, scoped to avoid regressing our extra features:

1. Single-goal install/package/deploy/integration-test routing (was 0% savings).
   route_goal now maps them to GoalRouting::TestLike; dispatch runs them through
   run_tests_like with the user's LITERAL goal preserved (NOT rerouted to `verify`,
   which would drop the .m2 install / deploy side-effects). run_tests_like takes a
   &str goal; the TestLikeGoal enum is removed.

2. English-footer guard (has_english_footer, ported from upstream). Non-English
   locales (e.g. French `BUILD ÉCHEC`) and no-POM runs lack a BUILD SUCCESS/FAILURE
   footer; without the guard a failed French `mvn clean` was silently mis-reported
   as "nothing to clean". Guard applied at the single-goal wrapper layer only
   (run_tests_like closure + filter_mvn_clean), never on the shared multi-goal
   cores which receive footerless segment slices.

3. Surefire 2.x single-dash close-line counting. The per-class gate excluded only
   the 3.x ` -- in ` form, so a 2.x ` - in ` multi-class run undercounted to the
   last class (e.g. 5 instead of 35). Gate now excludes both via ` - in `.

Fixtures mvn_install_slice.txt + mvn_locale_fr.txt copied from upstream; a faithful
Surefire 2.22.x multi-class fixture added for rtk-ai#3. Gate green: fmt + clippy +
2224 tests (4 new).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
audichuang added a commit to audichuang/rtk that referenced this pull request Jun 8, 2026
This is a fork that diverges from rtk-ai/rtk (notably the richer PR rtk-ai#1089
mvn/mvnd module, which upstream rejected for its own PR rtk-ai#1956). Record an
explicit per-commit disposition (absorbed / cherry-picked / skipped+reason)
so future syncs never re-triage or miss a commit.

- Ledger covers upstream 0a630fe..047f454 (triaged 2026-06-08).
- Documents the mvn decision (keep ours, absorb 3 wins, 8 deliberately not),
  the sync base, remotes, and a re-sync runbook.
- Link from CLAUDE.md mvn/mvnd section; warns never to blind-merge upstream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ryanmurf added a commit to murphytek/rtk that referenced this pull request Jul 25, 2026
* fix(security): replace insecure tmp, lock git workflow perm

* fix(security): pin workflow actions to SHA, clean up tempfile on failure

* Update pr-target-check.yml

* fix(cicd): set release-please target-branch to master [skip ci]

* chore(master): release 0.40.0

* fix(docker): forward --tail flag in compose logs

Previously `rtk docker compose logs --tail=20 web` would fall through to
the passthrough handler because clap had no `--tail` field on the `Logs`
subcommand, resulting in 0% token savings. Additionally, `run_compose_logs`
always fetched 100 lines regardless of user input.

- Add `#[arg(long, default_value_t = 100)] tail: u32` to `ComposeCommands::Logs`
- Thread the value through to the docker invocation
- Default unchanged (100 lines), so bare `rtk docker compose logs` behaves as before

Ports the core fix from #580, retargeted onto current develop layout
(`src/cmds/cloud/container.rs`). Relates to #578.

Co-authored-by: Mihir Dash <137862945+slice-mihird@users.noreply.github.com>

* 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 <sha>" 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.

* chore(filters): remove filter-level annotations and restore compose logs tail arg

* fix(filters): add test for aggressive filter batch fix

* fix(git): stream push output via FilterMode::Streaming (#963)

## Problem (#963)

`rtk git push` reportedly times out: users see
`bash tool terminated command after exceeding timeout 30000 ms` while
plain `git push` to the same remote completes fine. P1-critical because
every Claude Code git push goes through rtk.

## Root cause

`run_push` used `cmd.stdin(Stdio::inherit()).output()`. `Command::output()`
captures both stdout and stderr until the child exits. Git push prints
its progress (`Counting objects` / `Compressing objects` / `Writing
objects`) to stderr and may prompt for SSH passphrases or HTTPS
credentials. With stderr captured, Claude Code's bash tool saw zero
output for 30+ seconds and killed the command — exactly the 30000 ms
message in the issue.

## Fix

Rewrite `run_push` on top of the streaming infrastructure that already
exists for this exact purpose (`stream::run_streaming` +
`FilterMode::Streaming`, added in 0.37.0).

Add a counterpart to `BlockStreamFilter<H: BlockHandler>` in
`src/core/stream.rs`: `LineStreamFilter<H: LineHandler>`. Where
`BlockStreamFilter` defaults to DROP and emits only collected blocks,
`LineStreamFilter` defaults to KEEP and lets handlers opt into dropping
noise. Trait surface mirrors `BlockHandler`:

- `should_skip(&mut self, line: &str) -> bool` — default false
- `observe_line(&mut self, line: &str)` — default no-op
- `format_summary(&self, exit_code, raw) -> Option<String>`

This lets future streaming commands reuse the line-oriented pattern.

`GitPushLineHandler` then becomes a tiny `LineHandler` impl:

- `should_skip` drops the high-volume progress phases (Enumerating /
  Counting / Compressing / Writing objects, Delta compression, Total)
  and blank lines.
- `observe_line` captures the up-to-date sentinel and the first ref
  update target (e.g. `master`) for the summary.
- `format_summary` emits `ok <ref>` / `ok (up-to-date)` / `ok` on
  success; nothing on failure (raw error lines already flowed through).

Stdin is inherited (`StdinMode::Inherit`) so SSH passphrase and HTTPS
credential prompts still reach the user. Tracking now records the real
raw output and the filtered output.

## Test plan

- [x] `cargo fmt --all -- --check`
- [x] `cargo clippy --all-targets -- -D warnings` — clean
- [x] `cargo test --all` — 1880 passed, 0 failed, 6 ignored
- Six unit tests cover the push handler: progress-prefix drop,
  up-to-date summary, remote message passthrough, no-summary-on-failure,
  first-ref-wins, and token-savings (>=60% on a representative payload).
- Four unit tests cover the new `LineStreamFilter` trait:
  default-keep-all, skip-drops-matching, summary-propagates-exit-code,
  observe-only-called-for-kept-lines.

## Notes

- Behaviour change: users now see git's native output line-by-line
  (with progress phases stripped) plus a final `ok <ref>` summary,
  instead of just the compact summary. This matches plain `git push`
  more closely and is what the issue reporter expects.
- No regression for other filters: `run_pull`, `run_fetch`, `run_clone`
  are untouched; only `run_push` is modified.

Closes #963

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---
_Vibe Coded by Ousama Ben Younes_
_Developed With Ora Studio (Claude Code)_

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 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

* fix: re-add env python as noisy dir

* fix: '...' ascii to unicode, remove some comments

* fix(kubectl): compact get pods and services aliases

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(mvn)!: Rust module replacing TOML filter, adds test phase support

Replaces the stateless src/filters/mvn-build.toml (4 phases, 50-line cap) with
a Rust module under src/cmds/jvm/mvn_cmd.rs handling all common Maven lifecycle
phases with stateful Surefire/Failsafe block collapse.

Why TOML couldn't do this: the dominant noise on a healthy Maven project is
expected-exception stack traces inside passing tests (assertThrows). Collapsing
these requires deciding to drop a block based on its closing
`Tests run: N, Failures: 0, Errors: 0` line — a stateful decision the TOML DSL
cannot express.

Measured savings on synthetic full-shape fixtures (~1100 lines each, gzipped):
- mvn test:    3 422 tok → 36 tok (98.9% savings)
- mvn install: 3 460 tok → 85 tok (97.5% savings)

Coverage:

| Phase                              | Filter                                 |
|------------------------------------|----------------------------------------|
| test, integration-test             | filter_surefire (block collapse)       |
| compile, test-compile              | filter_compile (ERROR + indent + dedup)|
| package, install, verify, deploy   | filter_package (compile + surefire)    |
| clean, site, plugin goals, version | passthrough                            |

Key behaviours:
- ANSI strip first in every filter (real Maven output contains colour escapes).
- English-footer guard: if no `BUILD SUCCESS`/`BUILD FAILURE` line is present,
  return ANSI-stripped raw input unchanged — protects non-English locales.
- Verbose bypass: `-X`, `--debug`, `-e`, `--errors` skip filtering.
- Stack-frame deny-list strips framework frames (`at org.junit.`, `at java.util.`,
  `at sun.reflect.`, etc.); user-code frames (any other prefix) preserved.
- Duration normalisation (`Time elapsed: 2.341 s` → `Time elapsed: T s`) for
  deterministic test output.
- Wrapper detection: `./mvnw` / `mvnw.cmd` via string-literal `Command::new`
  (semgrep-safe); falls back to `resolved_command("mvn")`.

Hook regex changes (src/discover/rules.rs):
- Adds wrapper prefixes (./mvnw, mvnw.cmd, mvnw).
- Adds test, integration-test, verify, deploy to the alternation.
- Drops clean + site so bare invocations bypass RTK entirely (0 overhead).
- Lazy quantifier skips flags before the goal (`mvn -B clean install` now
  rewrites correctly; previously failed because the regex required the goal
  as the first token after `mvn`).
- Drops subcmd_savings (lazy match captures first phase, which would mis-tier
  `clean install` to clean); uses flat 82.0.

Tests:
- 33 unit tests in mvn_cmd.rs (phase detection, footer guard, framework deny,
  duration normalisation, ERROR continuation, WARNING dedup, install/jar lines).
- 2 inline savings assertions on gzipped synthetic full fixtures (~3 KB each)
  using flate2 already in Cargo.toml. Run as part of standard `cargo test --all`.
- 12 classifier/rewrite tests in src/discover/registry.rs under new Maven
  section.

Files changed:
- ADD src/cmds/jvm/mvn_cmd.rs (~810 LoC incl. tests)
- ADD src/cmds/jvm/README.md (documents whitelist omission decision)
- ADD tests/fixtures/mvn_*_raw.txt (6 inline) + 2 .gz synthetic full fixtures
- MOD src/main.rs (1 use, 1 Commands variant, 1 dispatch arm)
- MOD src/discover/rules.rs (replace mvn entry)
- MOD src/discover/registry.rs (12 classifier tests)
- MOD src/core/toml_filter.rs (drop mvn-build from expected-filters list,
  adjust the 2 count assertions)
- DEL src/filters/mvn-build.toml

Fixtures are entirely synthetic — generic `com.example.app.*` package names and
`com.example:myapp` Maven coordinates. Same SHAPE as real Maven output (block
structure, ANSI codes, framework stack frames, BUILD footer, plugin banners)
without any project-identifying content.

Integrity-check whitelist: Commands::Mvn is intentionally omitted from
is_operational_command at src/main.rs:2455-2501, matching the gradle precedent
(Commands::Gradlew also omitted). The whitelist is opt-in by design per the
comment at L2452-2454 — filter modules invoked through an already-verified
hook do not need a second integrity check on their own dispatch path.
Documented in src/cmds/jvm/README.md.

Out-of-scope (follow-ups noted in module + README):
- Parallel mode `-T2C` (interleaved blocks across threads).
- Plugin goals (`mvn dependency:tree`, `mvn versions:*`).
- `mvnw.bat` legacy wrapper (Maven Wrapper 3.x emits `.cmd`).

BREAKING CHANGE: `rtk mvn <args>` output format changed. Previously: TOML
filter strips INFO/Downloading lines and caps at 50 lines. Now: state-machine
filter with Surefire block collapse + locale guard + verbose bypass. Different
output shape; significantly better savings tier; behaviour is otherwise a
superset (all previously-handled cases still handled, plus test/verify/etc.).

* 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

* fix(init): honor dry-run for Pi install and uninstall paths

* fix(tee): safe truncation caps and compose-ps tee content fix

Replace all bare magic offset literals (e.g. 21 for cap=20) with named
constants (MAX_XXX + 1) so offsets stay in sync if caps change.
Fix compose_ps to tee pre-formatted lines instead of raw tab-separated
input, so tail -n +N gives the agent readable content.
All 13 affected modules updated: container, dotnet, gh, glab, go, lint,
pnpm, pytest, rubocop, cargo, aws. 1884 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(rust): multi-line blocks used with tail hint

* docs(cmds): truncations hints and recovery guidelines

* test(hooks/init): add red test for copilot-instructions preservation

Adds a TDD red test that asserts pre-existing user content in
.github/copilot-instructions.md must survive 'rtk init --copilot'.
Currently fails because run_copilot() uses write_if_changed() which
truncates the file.

Refs #1964

* fix(hooks/init): preserve user content in copilot-instructions.md

run_copilot() previously called write_if_changed() which truncated any
pre-existing .github/copilot-instructions.md, destroying user-authored
Copilot rules.

This routes copilot-instructions.md through upsert_rtk_block() (the
same idempotent marker-block helper already used for CLAUDE.md and
AGENTS.md). The COPILOT_INSTRUCTIONS constant is wrapped in
<!-- rtk-instructions v2 --> ... <!-- /rtk-instructions --> markers
so subsequent inits replace only RTK-owned content; the rest of the
file is preserved verbatim. Malformed pre-existing blocks (opening
marker without closing) are refused with a remediation message
rather than silently rewritten.

Fixes #1964, #1891

* test(hooks/init): cover idempotency, stale-block, dry-run, fresh, malformed paths

Adds five additional tests around copilot-instructions.md upsert:
- idempotent re-init (no duplicated blocks, identical content)
- stale RTK block in-place update with surrounding user content preserved
- dry-run never creates or modifies the file
- fresh install creates file with RTK marker block when no file exists
- malformed pre-existing block (opening marker without closing) is
  refused with a clear error rather than silently rewritten

Refs #1964

* refactor(hooks/init): extract run_copilot_at for parallel-safe tests

run_copilot(ctx) is now a thin wrapper around run_copilot_at(base, ctx);
tests pass a TempDir path so they no longer mutate process-global cwd.
This eliminates flakes when cargo test runs in parallel (the default).

The public API is unchanged; only the internal seam moves.

Refs #1964

* docs(readme): fix license references to match LICENSE (Apache-2.0)

The README badge and footer claimed MIT, but the LICENSE file is
Apache License 2.0. Updates both references so the README matches
the authoritative LICENSE file.

Closes #1996

Co-Authored-By: Claude <noreply@anthropic.com>

* refacto(truncations): Set global CAPS for truncation

Following tee and hint refacto
Add global CAP constant to be inherited , to enable easier global configuration later

* refactor(hooks/init): share write_rtk_block, unify malformed handling

Address review feedback on #1976:

- Extract write_rtk_block() shared dispatcher: eliminates the duplicated
  4-arm RtkBlockUpsert match between run_claude_md_mode and the former
  upsert_copilot_instructions (now inlined). Both callers stay under the
  ~60-line guideline.
- Unify malformed handling: both paths now bail!() with a diagnostic and
  the exact recovery command. CLAUDE.md previously warned and exited 0,
  silently skipping the OpenCode plugin step; behaviour is now consistent.
- Reorder run_copilot_at: upsert copilot-instructions.md BEFORE writing
  the hook config so a malformed file aborts the install without leaving
  a stale .github/hooks/rtk-rewrite.json on disk.

Regression coverage:
- test_claude_md_mode_refuses_malformed_block mirrors the existing
  copilot malformed test against the shared dispatcher contract.
- test_copilot_init_malformed_leaves_no_hook_on_disk pins the new write
  order so a future re-order regression is caught.

cargo fmt / clippy --all-targets / test --bin rtk: clean (1909 passed).

* chore(cargo): declare MSRV via rust-version = "1.91"

rtk uses `str::floor_char_boundary()` (src/cmds/system/pipe_cmd.rs:140)
which was stabilized in Rust 1.91.0. Without `rust-version` in
Cargo.toml, packagers on older toolchains hit a confusing
`use of unstable library feature` error from rustc.

With this declaration, cargo gives the expected friendly diagnostic
up-front:

    error: rustc 1.88.0 is not supported by the following packages:
      rtk@0.34.3 requires rustc 1.91

Verified locally by switching toolchains:
- cargo +1.88 check → fails with the new clear MSRV error (intended)
- cargo +1.93 fmt/clippy/test → 1909 passed, zero warnings

Fixes #1402

* fix(truncate): global caps reduce (avoid underflow and 0 results)

- Avoid underflow + 0 results caused by overwrite of global constant CAPS
- Soon user will be able to apply global configuration, this will change global CAPS and should not cause any underflow or 0 results, if overwrite sub >= CAPS -> use CAPS.

* fix: honor explicit -n N limit for git log on merge commits

When user runs 'git log -1 --format='%H' HEAD' where HEAD is a merge
commit, rtk was adding --no-merges which filtered out the merge commit
itself and returned the second parent instead. This made 'git log -1'
return wrong SHAs for merge commits.

Fix: don't add --no-merges when user explicitly passes -n N or
--max-count=N. When a user specifies an exact count they expect exactly
that many commits, not filtered results. Also skip --no-merges if user
already passed --merges or --no-merges explicitly.

Fixes rtk-ai/rtk#2009.

* chore: regroup agent init tests by agent section

* fix(mvn): match Surefire 3.x close lines, preserve failure stack trail

CLOSE regex previously required ` - in ` (single-dash, Surefire 2.x).
Surefire 3.x emits ` -- in ` (double-dash), so close lines never matched
on Maven 3.9 default toolchains: blocks stayed open, every new
`[INFO] Running …` flushed the prior block as kept, and real-world
savings dropped to ~27% on commons-cli.

Changes:
- CLOSE separator widened to `\s+--?\s+in ` (matches 2.x and 3.x).
- CLOSE prefix widened to `INFO|ERROR|WARNING` (3.x emits WARNING for
  classes whose only tests are skipped).
- `filter_surefire` and `filter_package` gain a `failure_trail` flag:
  Surefire 3.x emits the exception class and stack frames *after* the
  CLOSE line; keep them (stripping framework frames) until the next
  blank line. Without this, the failing-test signal was silently dropped.

Fixtures replaced with output from a real `apache/commons-cli` build in
`maven:3.9-eclipse-temurin-21`. Synthetic 2.x shape removed; 2.x compat
locked by a dedicated unit test using the single-dash separator.

Measured token savings on commons-cli fixtures:
- `mvn test` (full):    1896 -> 38 tokens (98.0%)
- `mvn install` (full): 2021 -> 78 tokens (96.1%)

Refs: pszymkowiak review on PR #1956

* refactor(mvn): drop production duration normalisation

`normalise()` rewrote real `Time elapsed: N s` and `Total time: N s`
values to `T s` in the shipped output. That was only ever useful for
keeping snapshot tests deterministic and never belonged in production:
test perf signal is exactly the kind of data the LLM needs to spot a
regression, and there are no insta snapshots in this module to protect.

- Remove `normalise()` and its `TIME_DURATION` / `TOTAL_TIME` regexes.
- All `filter_surefire`, `filter_compile`, `filter_package` emit sites
  pass the raw line through.
- Renamed and inverted `surefire_normalises_durations` →
  `surefire_preserves_real_durations`: asserts `2.341 s` and
  `Total time:  4.567 s` survive filtering.

Refs: pszymkowiak review on PR #1956 (E.1)

* feat(mvn): filter mvn -q quiet-mode output

Under `mvn -q`, Maven 3.x suppresses all `[INFO]` lines: no
`BUILD SUCCESS` footer, no `[INFO] Running` markers, no module
banners. The existing surefire/compile/package filters all key off
the English-footer guard and `[INFO] Running`/CLOSE state machine,
so under `-q` they fell through and shipped the raw output unchanged
(0% savings on PR #1956 reviewer's measurement).

Reviewer (@pszymkowiak on PR #1956) listed `-q` handling in the
path-to-merge as "worth a note or handling". This commit implements
the handling.

Behavior captured from `mvn -q test` on Maven 3.9.9 + Surefire 3.5.5
with JUnit 5:

  - Green run emits ZERO bytes — filter returns empty output.
  - Failure run emits ~27 lines: per-class CLOSE line, exception +
    stack trace, `[ERROR] Failures:` summary, aggregate
    `[ERROR] Tests run: N, Failures: F, ...`, and the
    `[ERROR] Failed to execute goal` terminator, followed by a
    ~10-line `[ERROR] See .../[Help 1].../Re-run Maven .../To see
    the full stack trace .../For more information ...` boilerplate
    block pointing at log files and help URLs.

`filter_quiet` keeps the failure-signal lines and the user-code
stack frames; drops the framework frames (existing deny-list) and
the post-failure boilerplate block.

Routing: `is_quiet` checks argv for `-q` or `--quiet`. When set,
`run()` short-circuits the phase-based filter selection and routes
non-passthrough phases to `filter_quiet`. Passthrough phases
(`clean`, `site`, plugin goals) remain passthrough under `-q`.

Measured savings on the captured fail fixture: 51.1%
(174 -> 85 tokens). Green run: 0 -> 0 (no overhead).

Safety net: unclassified `[ERROR]` lines are kept rather than
dropped — better to leak a line than hide signal.

Tests added (7):
  - `quiet_detects_short_flag` / `quiet_detects_long_flag` /
    `quiet_does_not_match_unrelated_flags` for `is_quiet`.
  - `quiet_green_run_is_empty` — empty input contract.
  - `quiet_fail_strips_framework_and_boilerplate` — kept-list and
    drop-list assertions on the real fixture.
  - `savings_mvn_quiet_fail` — >=50% savings target on the fixture.
  - `quiet_unknown_error_line_kept_as_safety_net` — unclassified
    `[ERROR]` not silently dropped.

New fixture: `tests/fixtures/mvn_quiet_fail_raw.txt` (27 lines,
770 bytes) captured from a real `mvn -q test` run against a
JUnit 5 / Surefire 3.5.5 project with one failing test.

* fix(git): drop -uall from compact status so output never exceeds raw

The compact `git status` path ran `git status --porcelain -b -uall`. The
`-uall` flag expands fully-untracked directories into every file, while raw
`git status` collapses them (e.g. `node_modules/`). This made rtk output
larger than raw — measured ~29x on a 200-file untracked dir (5500B vs 191B) —
violating RTK's compress-or-match-raw invariant and inflating tokens.

Remove `-uall` so untracked directories collapse exactly like raw. This keeps
#991's actual fix intact: all modified/staged/renamed/conflict paths are still
shown with no grouped summaries or `... +N more` overflow markers (`-uall`
never affected those lines). Untracked files in partially-tracked dirs and any
paths git itself expands are still preserved by the formatter.

Measured (rtk vs raw git status):
- node_modules/ (200 files): 5500B (-2780%) -> 25B (+87% savings)
- normal (8 mod + 2 untracked): +71% -> +76%
- 17 modified (#991 case): unchanged at +58%, all 17 shown, 0 overflow markers

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Update git.rs

* docs(git): sync status README with --porcelain -b (drop -uall)

* chore(master): release 0.41.0

* feat: address pr suggestions

* chore: sync with pi api

* chore: minor cleanup

* fix(grep): parse single-file output containing colons

Single-file `rtk grep` returned synthetic `[file] N` buckets when a
matching line contained `:`. ripgrep emits `<line>:<content>` for
single-file searches; the parser used `splitn(3, ':')` length to
disambiguate, which fails when content has `:` (e.g. `Foo::bar(...)`).

Force `--with-filename` on rg (`-H` on the grep fallback) so output is
always `file:line:content`, and replace the inline parser with a
regex-based `parse_match_line` that also handles paths with embedded
`:` (Windows drive letters).

Fixes the parser/rendering portion of #1436. The BRE-translation
portion is addressed in a follow-up PR.

Signed-off-by: Artiom Tofan <arto@queue-it.com>

* fix(grep): NUL-separate file from line:content per review

- rg: switch to `-nH --null` for robust file/content separation
- grep fallback: switch to `-rnHZ` to match rg's --null contract
- Parser regex: `^([^\x00]+)\x00(\d+):(.*)$` so filenames or content
  containing `:digits:` (e.g. `badly_named:52:file.txt`,
  `debug: counter is :42: now`) can no longer fool the parser
- Use `Captures::extract` for cleaner destructuring

Adds tests for the badly-named-filename and digit-colons-in-content
cases; updates existing tests to use NUL-separated fixtures.

Addresses review on #1554.

Signed-off-by: Artiom Tofan <arto@queue-it.com>

* chore(master): release 0.42.0

* fix(hook): collapse bash line continuations before matching (#1564)

Closes #1564.

`rtk hook check` rejected commands that began with a bash line
continuation (`\<NL>`) because the matcher saw the literal `\`
as the first token and bailed out:

    $ rtk hook check $'\\\ngit diff HEAD~1'
    No rewrite for: \\ngit diff HEAD~1

Bash collapses `\<NL>` (and `\<CRLF>`) plus surrounding horizontal
whitespace to a single space before dispatching the command, so
the matcher should do the same. Internal continuations (e.g.
`git diff \<NL>HEAD~1`) are equally common when Claude Code
formats long invocations.

Add a small `collapse_line_continuations` helper that runs a
single regex over the input before `cmd.trim()`. The regex eats
the trailing whitespace on the previous line and the leading
indentation on the next line so the no-double-space invariant
holds. The fast path returns `Cow::Borrowed` when no continuation
is present, so commands without `\<NL>` allocate nothing.

Six regression tests pin: leading \\<NL\>, leading \\<CRLF\>, internal
\\<NL\> between subcommand and args, \\<NL\> followed by indentation,
the no-op fast path, and the helper-level Cow::Borrowed contract.

* fix(pkg): rtk is Apache 2.0 and no MIT

* fix(docs): replace remaining MIT license references with Apache 2.0

* fix(ls): preserve permission info as octal when -l/-la is passed

Closes #1672

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: prefer short args and cleanup comments

* fix: handle GNU `--format=long` and `--format=verbose` long args

* fix(hook): use maintainer regex suggestion for line continuation

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>

* fix(tests): use rewrite_command_no_prefixes in line-continuation tests

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>

* fix: remove remaining noise and rework comments

* fix(mvn): preserve compile-error continuation in filter_surefire

When `mvn test` fails at the compile step before Surefire runs, the
`[ERROR]` block's indented `symbol:` / `location:` / `^` continuation
lines were being dropped. `filter_surefire` had no `keep_continuation`
state, unlike `filter_compile:302` and `filter_package:408`.

Add the missing state machine: reset in the RUNNING branch, set on each
kept `[ERROR]` line (except `Tests run:` / `Failures:` / `Errors:`
headers — same predicate as `filter_package:402-405`), and pass through
indented continuation lines before the outside-block keep-list check.

Fixture `mvn_test_compile_fail_slice_raw.txt` captures the
`mvn test` slice for a project with a deliberate `cannot find symbol`
in a `src/main/java` source. New tests:
- `surefire_keeps_compile_continuation_on_test_phase` — the bug.
- `package_still_keeps_compile_error_continuation_after_refactor` —
  drift guard on the `install`/`verify` path.

* refactor(mvn): extract shared SurefireBlock state machine

`filter_surefire` and `filter_package` had a ~30-line duplicated state
machine for Surefire/Failsafe per-class blocks (RUNNING / in-block
buffer / CLOSE branching / failure-trail). The duplication is what let
the P0 continuation bug land — the fix had to be applied in only one of
the two filters.

Introduce `SurefireBlock` owning that inner machine: PLUGIN_BANNER skip,
RUNNING (with prior-block flush on truncated output), in-block
buffering, CLOSE detection, and the post-close failure-trail with
framework-frame stripping.

Each `step()` returns `SurefireStep::{Consumed, FailingClose, Passthrough}`.
The `FailingClose` arm defers the actual write to the caller via
`commit_failing()` — this is the seam Commit D will use to enforce the
`mvn_max_failures` cap without duplicating the cap logic.

Outside-block keep logic stays inside each filter unchanged
(`MODULE_BANNER || keep_outside_block`, `[WARNING]` dedup, the
`keep_continuation` flag from Commit A). Byte-identical filter output —
all existing `surefire_*` / `package_*` / token-savings tests pass.

The previous free helpers `flush_block_as_keep` and `emit_block` are
removed; their behaviour now lives in `SurefireBlock::flush_open_block_as_keep`
and `SurefireBlock::commit_failing`.

* fix(mvn): keep multi-module Reactor Summary rows

In a reactor build (parent pom with `<modules>`), Maven appends a
`Reactor Summary for <root>` block at the end listing each module's
status:

    [INFO] Reactor Summary for multi-module-skeleton 1.0.0-SNAPSHOT:
    [INFO]
    [INFO] multi-module-skeleton ...... SUCCESS [  0.353 s]
    [INFO] child-a .................... SUCCESS [  1.676 s]
    [INFO] child-b .................... FAILURE [  0.940 s]

`filter_surefire` / `filter_package` were dropping these rows because
`keep_outside_block` only keeps `[INFO]` lines that match a specific
prefix (`Building`, `Installing`, `Total time:`, etc.). The per-module
status rows match none of them.

Add a `REACTOR_SUMMARY` regex and `reactor_summary_keep` helper that
toggles a flag on the header and clears it on `BUILD SUCCESS` /
`BUILD FAILURE`. While the flag is set the helper returns `true` so
the rows survive — including `[WARNING]` (activated-profile notices)
and the in-summary horizontal rule. Caller invokes the helper before
`keep_outside_block` so the clears-flag side effect runs regardless
of `||` short-circuit.

Fixtures captured against a hand-rolled 2-module skeleton checked in
under `tests/fixtures/multi-module-skeleton/` (parent pom + child-a
+ child-b with `Empty.java` stubs). The fail fixture is captured by
temporarily replacing child-b's `Empty.java` with one that references
a `BogusType` symbol. Reproducible from the committed skeleton.

* feat(mvn): cap failing-class blocks and Failures summary entries

`filter_surefire` / `filter_package` emitted unbounded output on builds
with hundreds of failures — the per-class failing blocks (with their
post-close exception trail) plus every `[ERROR]   ClassA.test:N …`
entry in the `[ERROR] Failures:` summary were all kept. Other RTK
filters (git/cargo/lint) already cap with `... +N more` tails; bring
mvn in line.

Add `LimitsConfig::mvn_max_failures` (default 25, matching the
`grep_max_per_file: 25` convention in the same struct; `0` = opt out)
and wire it into two cap sites:

1. Failing-class blocks. The shared `SurefireBlock::step()` machine
   yields `FailingClose` once per failing class; the outer loop now
   commits the first N and calls a new `drop_failing()` for the rest.
   `drop_failing()` enables a `drop_trail` flag so the post-close
   per-test subline, exception, and user frames are consumed silently
   until the next blank line. After the loop, if any were dropped,
   `\n... +N more failing test classes\n` is appended.

2. `[ERROR] Failures:` summary block. New `FailuresSummaryCap` helper
   tracks the header, caps `[ERROR]   ` entries at the same limit, and
   pre-emits `\n... +N more failures\n` immediately before the
   aggregate `[ERROR] Tests run: …` line.

Tail wording follows the existing repo convention — no `[INFO]`
prefix, no parenthetical hint (`cargo_cmd.rs:1035` uses the same
`\n... +N more failures\n` verbatim).

Tests:
- `surefire_caps_failing_blocks_emits_tail` — synthetic 5-failure
  fixture with `cap = 3`; asserts blocks 1-3 emitted, 4-5 dropped,
  tail emitted.
- `surefire_cap_zero_disables_capping` — same fixture with `cap = 0`;
  all 5 blocks kept, no tail.
- `failures_summary_block_is_capped` — 5-entry summary with `cap = 3`;
  asserts entries 1-3 kept, 4-5 dropped, tail emitted *before* the
  aggregate.

Behaviour-change note: the default cap of 25 means users without a
custom config get a bounded output for the first time. Existing
fixtures all have <25 failures so no current test diffs.

* refactor(mvn): polish — strip_prefix, borrow buffers, CRLF tests

Three small reviewer-suggested cleanups:

- `[WARNING] ` prefix slicing in `filter_compile` / `filter_package`
  was written defensively as `&line["[WARNING] ".len().min(line.len())..]`.
  Both call sites are already gated by `line.starts_with("[WARNING]")`
  so the `min` is dead defence. Use `strip_prefix(...).unwrap_or(line)`
  which reads identically to the rest of the module.

- `SurefireBlock` and `SurefireStep::FailingClose` now borrow lines
  from the post-ANSI-strip `String` (`Vec<&'a str>` / `Option<&'a str>`
  / `&'a str` close), tied to the `stripped` local in
  `filter_surefire_with_cap` / `filter_package_with_cap`. No
  `.to_string()` allocations on the hot per-line path; the slice
  fixtures' savings tests are unaffected and the gzipped full-fixture
  tests still hit ≥90%/≥85%.

- New `surefire_handles_crlf_line_endings` and
  `package_handles_crlf_line_endings` tests assert byte-equality
  between an LF-filtered fixture and a CRLF-converted-then-LF-normalised
  one. Catches the class of bug where exact-equality line checks
  silently miss CRLF input because `str::lines()` strips `\n` but
  keeps trailing `\r`.

* chore(mvn): drop unrelated Cargo.lock churn from PR

`develop`'s Cargo.lock has its rtk-package entry pinned at `0.36.0`
while `Cargo.toml` says `0.34.3` — the lock is internally stale on
develop. When `cargo build` ran on this branch it re-pinned the lock
to `0.34.3` to match `Cargo.toml`, producing the diff the reviewer
flagged on PR #1956.

Revert the lock to `develop`'s baseline so the PR's committed diff
no longer touches Cargo.lock. `cargo build` will rewrite the
working-tree lock on every invocation; that's a local artifact, not
part of the committed PR.

* fix(cicd): MIT to Apache 2.0

* fix(hook): emit Copilot CLI-compatible hook config + strip BOM on Windows

* feat(hook): wire Copilot into uninstall, telemetry, and discover

* fix(hook): semgrep marker + regression tests for Copilot hook isolation

* feat(hook): --global Copilot install + transparent rewrite via modifiedArgs

* fix(hook): default Copilot CLI rewrite to 'ask' to preserve permission prompts

* fix(hook): preserve Copilot CLI toolArgs metadata when rewriting command

* fix(hook): omit permissionDecision so Copilot prompts for the rewritten command

* fix(gh,glab): don't pre-reject view/checks/run subcommands missing the id

gh and glab both accept several subcommands without an explicit id and
resolve it from the current branch (gh pr view, gh pr checks, glab mr
view) or open an interactive picker (gh run view). RTK was pre-rejecting
with "X number required" before the underlying tool ever ran.

Six call sites now treat the identifier as optional and forward the
request unchanged, letting gh/glab decide:

- gh pr view, gh pr checks, gh issue view, gh run view
- glab mr view, glab issue view

A new `parse_optional_identifier` helper in each file captures the
"(Option<id>, Vec<extra>)" parse so the no-id branch is unit-testable.
format_run_view also drops the dangling `#` from its header when the
run id is empty (interactive-picker passthrough).

* docs(readme): add Portuguese translation

* fix(aws): preserve values in JSON output for unsupported subcommands

The generic AWS handler (used for subcommands outside the hardcoded
specialized list) called json_cmd::filter_json_string, which extracts
a type-only schema and discards every value.

    $ rtk aws backup describe-global-settings --output json
    {
      GlobalSettings:
      {
        isCrossAccountBackupEnabled: string,
        isDelegatedAdministratorEnabled: string,
        isMpaEnabled: string
      }
      LastUpdateTime: string
    }

Callers got the schema instead of the data they asked for.

Swap to json_cmd::filter_json_compact, which preserves values while
still applying depth, string-length, and array-size truncation:

    $ rtk aws backup describe-global-settings --output json
    {
      GlobalSettings:
      {
        isCrossAccountBackupEnabled: "false",
        isDelegatedAdministratorEnabled: "false",
        isMpaEnabled: "false"
      }
      LastUpdateTime: "2026-05-28T09:52:17.525000+02:00"
    }

Affects every AWS subcommand not on the hardcoded list (backup,
route53, kms, ssm, apigateway, ...) when output is valid JSON
(explicit `--output json` or auto-injected for describe-/list-/get-/scan).

* missing bracket

* missing test tag

* fix(copilot-cli): use user-level instruction for -g

-g was missing user level instructions which are available for copilot

* fix: fmt

* Update main.rs

* allow unsafe code clippy + semgrep

* Update main.rs

* Update main.rs

* fix(provider): sanatize more chars when encoding claude code project pathes

Fixes #2150

* doc(init): fix documentation inconsistencies arount `rtk init`

Fixes #213

* fix(curl): passthrough binary downloads to prevent UTF-8 corruption

`rtk curl <binary-url>` silently corrupted any binary download
(tarballs, zip, png, pdf, ELF, ...) because the response body was
captured via `String::from_utf8_lossy` in `core::stream::exec_capture`.
Every non-UTF-8 byte got replaced with U+FFFD (3 bytes: 0xEF 0xBF 0xBD),
so gzip magic 1f 8b 08 00 arrived at downstream consumers as
1f ef bf bd 08 00 and `tar -xzf` complained "not in gzip format".

Now `rtk curl` runs `Command::output()` directly to keep stdout as
`Vec<u8>`, then checks `std::str::from_utf8(&bytes).is_err()`. If the
response is not valid UTF-8 (i.e. the lossy conversion would corrupt
it), raw bytes are written through to stdout via `write_all` and
tracking is recorded as passthrough (0% savings — token counts over
binary content have no meaning). The text/JSON code path is unchanged.

Verified live on 18 real binary formats (rtk's own release artifacts,
ripgrep, bat, fd, hyperfine, gh, tokei, kubectl ELF 51MB, rustup-init
20MB, W3C PDF, ISO 9899 PDF 4MB, GitHub avatar PNG, Wikimedia GIF,
WebP sample, MP3/MP4/WAV samples) — all byte-identical to raw curl.
10 text regression tests (JSON/HTML/Markdown/Cargo.toml/RFC) confirm
the text path keeps its existing behavior.

Closes #1087

* chore(args): introduce a more generic solution for restoring double dashes in args

Fixes #1669

* fix(init): preserve settings.json symlink during atomic write

atomic_write() replaced symlinked settings.json with a regular file.
Now resolves symlink targets before the tempfile+rename so the link
itself is preserved and the real file gets updated.

Covers both absolute and relative symlink targets.

Closes #653

* fix(init): use fs::canonicalize for symlink resolution

Simplify resolve_atomic_target() per review feedback — fs::canonicalize
handles chained symlinks, relative paths, and all edge cases.

Co-authored-by: Roopesh <roopesh1724989@gmail.com>

* fix(gh): show fallback note when PR/issue body is filtered to empty

When `filter_markdown_body()` strips a PR or issue body to empty
(body contained only badges, images, HTML comments, or horizontal
rules), `format_pr_view` and `format_issue_view` previously skipped
the body section silently. Users had no indication that body content
had been present and filtered.

Now both views emit a fallback note when the filtered body is empty
but the raw body was not:

  PR view:    (body contained only badges/images/comments)
  Issue view: Description: (body contained only badges/images/comments)

The 4 added tests cover:
- PR body with only badges/images/comments -> fallback note appears
- PR body with real content -> no fallback note (sanity)
- PR body empty (raw) -> no fallback note (no signal to give)
- Issue body badges-only -> fallback note appears

Closes #235

Co-authored-by: polaminggkub-debug <polaminggkub-debug@users.noreply.github.com>

* fix(go): respect build failure exit status

Gate go build success output on the child exit code and surface unrecognized non-zero build output instead of reporting success.

Closes #2185.

* fix(grep): command token duplication in output withsame value filename or positional arg

* fix(openclaw): no execSync to avoid async dangerous cmds

* chore(master): release 0.42.1

* fix(permissions): never auto-allow not evaluable + defer to the agent

- Decompose compound commands for permission checks (newline, background &, subshell `( )`) in addition to &&, ||, ;, | so hidden segments are checked.
- `contains_unattestable_construct`: flag command/process substitution and file-target redirects (fd-dup `2>&1` and /dev/null exempt) — RTK can't decompose these, so they are never auto-allowed.
- Route every host hook (Claude, VS Code, Gemini, Cursor, Copilot CLI) through a single decision flow. Precedence: Deny → (defer if unattestable) → Allow → Ask → Default. Auto-allow only on a positive Allow; otherwise defer to the host's own engine.
- Gemini: ask_user instead of hardcoded allow. Cursor: empty `{}` delegation, since permission:"ask" is not enforced on its sandboxed shell.

* fix(permissions): cursor and gemini use correct permissions settings file

-

* fix(permissions): add test for cursor and gemini settings perm

* refacto(permissions): fmt

* fix(permissions): project-first config lookup for Gemini/Cursor

* fix(permissions):  >&file redirect no allow + scope Gemini/Cursor config

* chore(master): release 0.42.2

* fix(permissions): never auto-allow not evaluable + defer to the agent

- Decompose compound commands for permission checks (newline, background &, subshell `( )`) in addition to &&, ||, ;, | so hidden segments are checked.
- `contains_unattestable_construct`: flag command/process substitution and file-target redirects (fd-dup `2>&1` and /dev/null exempt) — RTK can't decompose these, so they are never auto-allowed.
- Route every host hook (Claude, VS Code, Gemini, Cursor, Copilot CLI) through a single decision flow. Precedence: Deny → (defer if unattestable) → Allow → Ask → Default. Auto-allow only on a positive Allow; otherwise defer to the host's own engine.
- Gemini: ask_user instead of hardcoded allow. Cursor: empty `{}` delegation, since permission:"ask" is not enforced on its sandboxed shell.

* fix(permissions): cursor and gemini use correct permissions settings file

-

* fix(permissions): add test for cursor and gemini settings perm

* refacto(permissions): fmt

* fix(permissions): project-first config lookup for Gemini/Cursor

* fix(permissions):  >&file redirect no allow + scope Gemini/Cursor config

* fix(openclaw): no execSync to avoid async dangerous cmds

* test(hook): Copilot CLI acceptance tests

* fix: semgrep markers on test-fixture sensitive paths

* refacto(cmds): strip decorator noise from filter output

Separator lines (═══, ---) and an emoji status marker cost tokens
without adding signal for the LLM — RTK output must never add noise
over raw. Semantic labels are kept; the emoji is swapped for plain
monochrome unicode.

* chore(master): release 0.42.3

* fix(vitest): preserve explicit reporters

* fix(mvn): re-arm failure trail on per-test sublines

Surefire 3.x emits one blank-separated detail block per failing test
under a single class close line. The trail previously ended at the
first blank line and never re-armed, so failures after the first lost
their exception message (unindented -> dropped) and leaked the full
junit/jdk framework stack (indented -> kept by keep_continuation).

SurefireBlock now remembers the trail's keep/drop decision when it
ends at a blank line (trail_rearm) and re-enters the trail on the next
per-test subline with the same decision — a capped class drops all its
per-test blocks, not just the first. Any other non-blank line disarms
re-entry; RUNNING/commit/drop clear it so an unrelated class can never
inherit the decision. Extra blanks between per-test blocks stay armed.

Per-test sublines use '<<< ERROR!' for thrown (non-assertion)
exceptions — accept both markers via a shared is_per_test_subline()
(also reused by filter_quiet, which already re-armed correctly), and
widen the CLOSE regex to tolerate an ERROR! marker defensively
(Surefire 3.5.5 emits FAILURE! even for errors-only classes, per the
fixture capture; detection keys off the counts, not the marker).

Fixture captured from real Maven 3.9.9 / Surefire 3.5.5 output against
the new tests/fixtures/multifail-skeleton/ (CalcTest: assertion failure
+ thrown exception in one class; BoomTest: errors-only class). A
byte-exact pin locks the single-failure path unchanged.

* fix(ci): pin fixture line endings, harden CRLF tests

test (windows-latest) failed on the two CRLF tests: with no
.gitattributes, the Windows runner's core.autocrlf=true checks fixtures
out with CRLF, include_str! embeds \r\n, and the tests'
replace('\n', "\r\n") synthesis produces \r\r\n. str::lines() strips
only the final \r\n of each pair, leaving a stray \r that $-anchored
regexes (MODULE_BANNER, BUILD_FOOT) reject — dropping the module banner
and BUILD SUCCESS in the CRLF leg only. Real Maven CRLF output (single
\r\n) was always handled; only the synthesized double-CR leg broke.

Root-cause fix: tests/fixtures/** -text — fixtures are byte-exact
filter inputs; eol conversion must never touch them. Defense-in-depth:
the two CRLF tests normalize the embedded fixture back to LF before
synthesizing, so they stay correct even on checkouts without
attributes (zip downloads, pre-existing autocrlf clones).

Also fixes the test doc comment, which claimed str::lines() keeps the
trailing \r of a \r\n pair — it strips it; the hazard is the doubled
\r\r\n, not single CRLF.

* fix(mvn): strip post-failure help boilerplate in non-quiet mode

filter_quiet already dropped the help block Maven emits after
'Failed to execute goal' (See ..., -> [Help 1], Re-run Maven, help
URLs, bare [ERROR] dividers); the non-quiet paths kept it via the
[ERROR] catch-alls — keep_outside_block for test/package and
filter_compile's own [ERROR] branch.

Promote QUIET_BOILER_PREFIXES to a shared BOILER_PREFIXES (+
is_boilerplate(), which also covers the bare [ERROR] divider lines,
matching filter_quiet's existing drop) and reject boilerplate before
each [ERROR] catch-all. The list deliberately keeps the resume hint
([ERROR] After correcting the problems... / [ERROR]   mvn <args>
-rf :module) — actionable signal for resuming a multi-module build —
and the Failed to execute goal terminator.

keep_continuation hygiene (defensive parity, not a demonstrated bug):
filter_surefire only reset the flag inside its kept branch, so a
dropped [ERROR] line would leave it stale and a following indented
line could be wrongly kept. No committed fixture exhibits the leak;
add the same end-of-loop fall-through reset filter_package already
has. filter_compile resets on its new boilerplate drop path.

Multifail-slice savings rise from 19.9% to 42.3%; threshold pinned
at >=30% to match the reactor-fail precedent. README: document the
stripping and drop the stale duration-normalisation bullet (that
behaviour was removed in 77e28d0).

* refactor(mvn): bind failure cap to truncate::CAP_WARNINGS, drop config knob

Replace the per-filter LimitsConfig::mvn_max_failures field with a local
const MAX_MVN_FAILING_CLASSES = CAP_WARNINGS, matching the canonical cap
binding used by pytest/rspec/rake/runner. Align cap = 0 semantics with the
core policy (summary-only: no blocks emitted, tail still counts) instead
of the old opt-out meaning, so the future config surface lands without a
special case.

* style(mvn): align overflow tail with canonical '… +N more {label}' shape

Match the join_with_overflow convention (ellipsis char, '+N more' label)
used by container, aws, gh and glab so the LLM sees one consistent
overflow style. The tee '[full output: …]' recovery hint already comes
from core tee_and_hint via RunOptions::with_tee, so capped failure output
carries the canonical recovery path unchanged.

* fix(init): respect CLAUDE_CONFIG_DIR for global paths

rtk init -g ignored CLAUDE_CONFIG_DIR and always targeted ~/.claude.
Honor the env var Claude Code uses to relocate its config directory.

Priority: RTK_CLAUDE_DIR > CLAUDE_CONFIG_DIR > $HOME/.claude.
Empty values are treated as unset. Manual-step instructions now
print the resolved path instead of a hardcoded ~/.claude.

All callsites that previously hardcoded home.join(CLAUDE_DIR) now use
the shared resolve_claude_dir(): hook_check (rtk gain), integrity
(rtk verify), telemetry, and discover (rtk discover).

Closes #633

* refactor(init): address review feedback on CLAUDE_CONFIG_DIR PR

- restore migration Outdated check in hook_check (binary registered +
  legacy script present still reports Outdated until cleanup)
- add error context to resolve_claude_dir call sites (provider, integrity)
- use CLAUDE_DIR const instead of literal in telemetry + provider test
- simplify settings_path construction in print_manual_instructions

Refs #2087

* review: cleanup code, comments and uneeded undocumented RTK_CLAUDE_DIR now that we have CLAUDE_CONFIG_DIR

* fix: minor print_manual_instructions regression

* fix(grep): stabilize argument parsing

* fix(grep): fix value-taking flags, -rN clusters, multi-path, and -e patterns

Four bugs in `extract_pattern_path` caused `rtk grep` to misparse
common invocations:

- `-A 2 error src` → `2` was taken as the pattern (value-taking
  flags not recognised)
- `-rn foo src` → `n` leaked to rg as `--replace` value (only
  standalone `-r` was stripped, not combined clusters like `-rn`)
- `TODO src tests` → second path silently dropped (only one path
  kept)
- `-i x agent -g '*.md'` → rg regex error (`*.md` became positional
  before `-g` could consume it)

New implementation:
- `VALUE_FLAGS_SHORT`: explicit byte-set of 2-char flags that consume
  one following token (`A`, `B`, `C`, `g`, `f`, `j`, `m`)
- `process_flag()`: strips `r`/`R` from any single-dash cluster,
  drops `--recursive`; replaces the old inline `if arg == "-r"` loop
- `extract_pattern_path` returns `(Vec<String>, Vec<String>, Vec<String>)`
  (patterns, paths, flags) — no more `Option<String>` for pattern
- `-e` values collected separately; if any `-e` is present, all
  positionals are paths; otherwise first positional is the pattern
- All patterns forwarded to rg as `-e` flags (BRE `\|`→`|` inline)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(grep): handle value-taking flags in tail position of short clusters

A combined cluster like -rne PATTERN or -rA 2 had the last flag's
value consumed only if the cluster was exactly 2 chars. Longer
clusters (e.g. -rne) fell through to process_flag, which stripped
-r and emitted -ne — then -e ended up in flags without a value,
causing rg to error.

New unified short-flag handler: for any single-dash cluster, inspect
the last byte. If it is -e or a VALUE_FLAGS_SHORT member, strip the
r/R chars from the prefix, emit the prefix as a flag (if non-empty),
and consume the next token as the value (pattern for -e, flag value
otherwise). Long flags (--foo) are still routed through process_flag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(grep): code and test cleanup

* fix(diff): report modified-only diffs and follow diff exit convention

rtk diff treated files whose changes were all classified as modified
(similar lines, e.g. "a: 1" vs "a: 2" in YAML/JSON) as identical,
because the identical check only looked at added/removed counts.
Report any non-empty change set as a difference, and exit 1 when
files differ per diff convention (0 when identical).

Fixes #2364

* fix(security): harden installer checksum, filter-trust, meta-command fallthrough

* fix(security): harden meta command list check

fail in cicd before letting meta command omission reach production

* chore(master): release 0.42.4

* fix(grep): left-to-right cluster scan, long value flags, format passthrough

Five regressions on grep_arg_parsing vs develop:

- `-ecarrot` matched "caot" — strip_r was applied to the inline value bytes.
  Fix: scan cluster left-to-right; stop at first value-taking letter and
  treat the remainder as the inline value without r/R stripping.

- `-g*.rs` glob became `*.s` — same root cause, inline glob value r-stripped.

- `--glob '*.md'` took `*.md` as a path — no VALUE_FLAGS_LONG existed; the
  value token fell through to the positional collector.
  Fix: add VALUE_FLAGS_LONG covering all rg long flags that take a value.

- `--max-count 1 fn file` took `1` as the pattern — same missing long flag.

- VALUE_FLAGS_SHORT expanded: add t/T (--type/--type-not), d (--max-depth),
  M (--max-columns) so inline short forms and space-separated forms parse
  correctly without mis-routing values to patterns or paths.

- --regexp (long form of -e) now routes to patterns like -e does.

- has_format_flag: add --count-matches, --json, --passthru, --files so
  these output-format modes get passthrough treatment instead of silent
  "0 matches in 0 files".

2183 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(grep): restore strip_r as explicit testable helper + pre-existing clippy fix

The left-to-right cluster scan now calls strip_r() at emit time on the
accumulated flag-letter prefix (raw_prefix), never on inline value bytes.
This makes the safety contract explicit and testable: strip_r("carrot")
produces "caot", documenting exactly why it must not touch value bytes.

Also fix pre-existing unused-mut in aws_cmd.rs (has_output_flag).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(grep): extract parse_cluster to make cluster parsing directly testable

The left-to-right scanning logic now lives entirely in parse_cluster(), which
returns a ClusterResult enum (Boolean or ValueTaking). extract_pattern_path
just dispatches on the result — no loop logic inline.

parse_cluster() is the only place that touches cluster bytes, so the full
contract is testable in isolation:

  parse_cluster("ecarrot") → ValueTaking { prefix: None, flag: 'e', inline: "carrot" }
  parse_cluster("rne")     → ValueTaking { prefix: Some("n"), flag: 'e', inline: "" }
  parse_cluster("g*.rs")   → ValueTaking { prefix: None, flag: 'g', inline: "*.rs" }
  parse_cluster("rn")      → Boolean(Some("n"))

strip_r() remains as the focused r/R-stripping helper used internally by
parse_cluster (and tested directly to document the "carrot"→"caot" danger).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(grep): sort content alphabetically

* fix(grep): use portable --null in system grep fallback (BSD/macOS)

The system-grep fallback (used when ripgrep is not installed) passed
-rnHZ, relying on -Z for the NUL filename separator the match parser
requires. -Z only means --null on GNU grep; on BSD/macOS grep it is an
alias for --decompress (zgrep mode), so output is plain
file:line:content with no NUL. parse_match_line() then matches zero
filenames and every result collapses into "N matches in 0 files" with
all lines hidden behind [+N more].

Use the long option --null instead, which both GNU and BSD grep define
as "print a zero-byte after the file name".

Related to #2310

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(oc): add Openshift CLI support with shared k8s filtering

* fix(hook): treat uv run as a transparent prefix

* fix(grep): surface error on exit code >= 2 instead of false "0 matches"

Exit codes >= 2 signal real errors in grep/rg (bad regex, tool
crash, missing binary). rtk was only checking the narrow case
exit_code == 2 with non-empty stderr, letting any other error
exit masquerade as a clean "0 matches" false negative.

Now any exit code >= 2 surfaces the error to stderr instead of
printing a false zero-match result.

Fixes #2461
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(grep): gate grep_error_test with #![cfg(unix)] for Windows CI

The test simulates a broken `rg` via a chmod'd shell script (from_mode,
PermissionsExt) which is unix-only. The unconditional `use std::os::unix`
broke compilation of the integration-test crate on the Windows CI target.
Gate the whole file so Windows stays green while preserving unix coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(test): cargo fmt grep_error_test.rs

Wrap the rtk binary path expression per rustfmt to satisfy
`cargo fmt --all -- --check` on CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(grep): extract error-exit decision into pure unit-tested fn

Address KuSh review on #2465:
- Move the `exit_code >= 2` decision into a pure `is_grep_error_exit`
  function and unit-test it directly (0/1 = normal, >=2 = error),
  instead of faking the rg binary in an integration test.
- Drop the GREP_ERROR_EXIT const; the doc comment on the function
  conveys the grep/rg exit convention.
- Remove tests/grep_error_test.rs (faked binary) in favour of the
  unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(git): propagate exit code when commit fails instead of reporting ok

run_commit had a branch that printed 'ok (nothing to commit)' and fell
through to Ok(0) whenever git exited non-zero with a 'nothing to commit'
message — so a no-op or hook-aborted commit was reported as success with
exit 0. Gate strictly on output.status.success() via a CommitOutcome
helper: any failure surfaces git's message and propagates the real exit
code, matching native git. Same bug class as #1581 (push) and #1535
(stash).

Fixes #2494

* fix(git): propagate exit code on git status failure in compact path

The compact status path only caught the "not a git repository" error and
let every other failure (corrupt index, lock contention, broken refs)
fall through to a formatted "Clean working tree" + Ok(0), masking a real
git error. The non-compact path already guards on result.success(); apply
the same guard to the compact path, keeping the friendly not-a-repo
message and propagating git's real exit code otherwise.

Part of #2497

* fix(git): propagate exit code on git worktree list failure

run_worktree list mode never checked result.success() — a failed
`git worktree list` (e.g. run outside a repo) was flattened to empty
output + exit 0. The has_action branch already guards on success; apply
the same guard to list mode and surface git's error with its exit code.

Part of #2497

* fix(dotnet): stop duplicating failures on failing test runs

On failing `dotnet test` runs the orchestrator prepended the full raw
stdout ahead of the filtered summary. The `Failed Tests:` section already
reproduces each failure (name + message + clipped stack) parsed from
TRX/console, so every failure was printed twice — inflating output +65%
vs raw, scaling linearly with failure count (issue #2501).

Gate the raw-stdout prepend behind `test_needs_raw_fallback`: skip it when
the structured section carries detail, keep it when the filter is blind
(no failures parsed, or a parsed failure has empty detail — e.g. a
self-closing <UnitTestResult> with no <ErrorInfo>, or a build failure /
crash where nothing reaches failed_tests).

Extracts two pure, unit-tested fns (`test_needs_raw_fallback`,
`compose_failure_output`) so the orchestration decision is covered without
running dotnet.

Closes #2501

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(main): classify oc subcommand in PASSTHROUGH allowlist

* fix(vitest): add passthrough recovery hint

Explicit Vitest reporters can produce successful output that exceeds the passthrough cap. Track truncation as structured state and use force_tee_hint so the full verbose output remains recoverable.

* fix(dotnet): keep raw fallback when parsed failures incomplete

test_needs_raw_fallback dropped the raw stdout prepend whenever every
parsed failure carried detail, but never checked the list was complete
against summary.failed. A run reporting 5 failures but parsing only 3
detailed blocks would silently lose the 2 unparsed failures.

Add a `failed_tests.len() < summary.failed` clause so the raw fallback is
kept when the structured section is numerically incomplete. Add a
regression test and fix an inconsistent existing fixture (failed: 5 with a
single parsed entry) that the new clause correctly flagged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(pulumi): keep stack identity in pulumi-stack filter

Stop stripping `Current stack is X:` and `Last updated:`. pulumi stack
is read-only (exit 0) so there's no tee fallback; dropping the active
stack identity was irreversible signal loss. Keeping the header also
fixes the orphaned indented `Managed by <user>` line.

Owner: and Pulumi version used: remain stripped as noise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(grep): correctly handle all flag shapes and never exceed raw output

The grep argument-parsing rework routed many flag shapes through the
grouping reparse, which silently dropped any line it could not parse and
returned a success exit. Common invocations produced wrong or empty
output -- and grep-only syntax like `--include` failed outright -- which
pushed agents back to unfiltered `rtk proxy grep`.

Every valid grep invocation now produces correct output:

- Context (`-A`/`-B`/`-C`) is grouped and compressed instead of dropped;
  the header counts matches only and context lines are shown.
- Output the reparse cannot represent (`-N`, `-I`, `--color`, `-p`,
  `--heading`, binary-file notices, ...) is passed through verbatim
  instead of reported as a false "0 matches".
- Format/shape flags (`-c`, `-l`, `-o`, `-Z`, `--column`, `-b`,
  `--vimgrep`, `--null-data`) pass through cleanly with no NUL leak.
- grep-only syntax (`--include`, `--exclude`, ...) works: ripgrep is the
  fast path and rtk falls back to system grep when ripgrep rejects a flag.
- rtk grep never emits more than plain grep: if grouping is not smaller
  than `file:line:content`, the raw form is shown.

Resolves #2543.

* fix(pulumi): keep Owner and version in pulumi-stack filter

Per review: avoid signal loss that pushes agents to retry with
`rtk proxy`. pulumi-stack now keeps the full stack identity block
(Owner:, Pulumi version used:) alongside the header and Last updated:.
Only interactive prompt and help-footer noise is stripped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(grep): cover bundled -rln files-with-matches cluster (#2543)

Review feedback on #2550: the flag-shape tests all used standalone flags, leaving the exact #2543 repro -- a bundled -rln/-ln files-with-matches cluster -- unexercised. Assert rtk grep -rln <pat> <dir> lists matching files instead of a false '0 matches'.

* fix(core): never-worse output guard so RTK never exceeds the raw command

RTK could emit more tokens than the underlying command on small inputs:
filters that add headers, summaries, re-indentation, or a tee hint, plus
synthetic no-result messages ("0 matches", "No stashes", "[docker] 0
containers") printed where the raw command emitted nothing. Both break the
Transparency principle and inflate tokens instead of saving them.

- core::guard::never_worse(raw, filtered) returns raw when the filtered form
  has more tokens (reuses tracking::estimate_tokens), so RTK output is never
  larger than the real command.
- runner::emit_guarded(filtered, hint, raw) composes body + tee hint, guards
  the whole, prints, and returns what was shown so printed == tracked.
- run_captured_filter guards the run_filtered* family centrally; per-site
  guards cover the remaining single-string filters.
- On empty raw, emit empty and preserve the exit code instead of a synthetic
  no-result message (the messages were cosmetic with no dependents; #2461
  reports the grep one as actively harmful).
- git stash show now propagates its exit code instead of masking a real
  failure as Ok(0).

Resolves #2551.

* fix(env): clean up feature from secrets rewrite

Masking env secrets is not what core RTK (OSS) is for -- it compresses command
output, it does not redact what the command itself already reveals. It also
broke raw-vs-filtered consistency, which the never-worse guard relies on.

* fix(pipe): apply the never-worse guard

pipe filters piped stdin but bypassed the guard; route its output through
never_worse like every other compressor.

* fix(read): make guard baseline faithful to cat/cat -n output

Compare filtered output against the raw the agent's command would
emit (numbered for -n), not against unnumbered content, so the
guard keeps requested line numbers instead of dropping to plain.

* fix(docker): make the agent's command authoritative for the guard baseline

docker ps/ps -a/images ran a plain command for the raw baseline and a
separate --format command for parsing, capturing the plain run with
unwrap_or_default(). A failed plain run while --format succeeded left
raw empty, and never_worse("", summary) dropped real output.

Run the agent's actual command first as the source of truth: on failure
show its output/exit faithfully; on succes…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hook doesn't auto-route mvn / mvnw despite handler existing

5 participants