agent-file-tools 0.28.2

Agent File Tools — tree-sitter powered code analysis for AI agents
Documentation
use aft::compress::bun::BunCompressor;
use aft::compress::npm::NpmCompressor;
use aft::compress::pnpm::PnpmCompressor;
use aft::compress::pytest::PytestCompressor;
use aft::compress::tsc::TscCompressor;
use aft::compress::{self, Compressor};
use aft::config::Config;
use aft::context::AppContext;
use aft::parser::TreeSitterProvider;

fn compress_context() -> AppContext {
    AppContext::new(
        Box::new(TreeSitterProvider::new()),
        Config {
            experimental_bash_compress: true,
            ..Config::default()
        },
    )
}

#[test]
fn npm_install_caps_deprecations_and_keeps_summary() {
    let mut output = String::new();
    for index in 0..8 {
        output.push_str(&format!(
            "npm WARN deprecated package-{index}@1.0.0: use replacement-{index}\n"
        ));
        output.push_str(&format!(
            "npm http fetch GET 200 https://registry.npmjs.org/package-{index} 12ms\n"
        ));
    }
    output.push_str("added 300 packages in 10s\n\n80 packages are looking for funding\n  run `npm fund` for details\n\naudited 301 packages in 11s\nfound 0 vulnerabilities\n");

    let compressed = NpmCompressor.compress("npm install", &output);
    assert!(compressed.contains("package-0@1.0.0"));
    assert!(compressed.contains("package-4@1.0.0"));
    assert!(compressed.contains("... and 3 more deprecation warnings"));
    assert!(!compressed.contains("package-7@1.0.0"));
    assert!(!compressed.contains("npm http fetch"));
    assert!(!compressed.contains("added 300 packages"));
    assert!(compressed.contains("audited 301 packages"));
    assert!(compressed.contains("found 0 vulnerabilities"));

    let ratio = compressed.len() as f32 / output.len() as f32;
    assert!(ratio < 0.70, "ratio was {ratio}");
}

#[test]
fn bun_install_drops_resolver_noise_but_keeps_errors_and_summary() {
    let mut output = String::new();
    for index in 0..30 {
        output.push_str(&format!("Resolving dependencies {index}/30\n"));
        output.push_str(&format!("Downloaded dep-{index}\n"));
    }
    output.push_str("error: GET https://registry.example/dep - 500\n42 packages installed [1234.00ms]\nSaved lockfile\n");

    let compressed = BunCompressor.compress("bun install", &output);
    assert!(!compressed.contains("Resolving dependencies"));
    assert!(!compressed.contains("Downloaded dep-"));
    assert!(compressed.contains("error: GET https://registry.example/dep - 500"));
    assert!(compressed.contains("42 packages installed"));
    assert!(compressed.contains("Saved lockfile"));

    let ratio = compressed.len() as f32 / output.len() as f32;
    assert!(ratio < 0.15, "ratio was {ratio}");
}

#[test]
fn pnpm_install_limits_progress_and_keeps_auth_warning_error_summary() {
    let mut output =
        String::from("Lockfile is up to date\nAlready up-to-date\nAlready up-to-date\n");
    for index in 0..12 {
        output.push_str(&format!(
            "Progress: resolved {}, reused {}, downloaded {}, added {}\n",
            index * 10,
            index,
            index + 1,
            index + 2
        ));
    }
    output.push_str("WARN GET_NO_AUTH 401 https://registry.example/private\nERR_PNPM_FETCH_401 No authorization header was set\ndependencies:\n+ react 18.2.0\n- left-pad 1.3.0\nDone in 4.2s\n");

    let compressed = PnpmCompressor.compress("pnpm install", &output);
    assert_eq!(compressed.matches("Progress: resolved").count(), 2);
    assert_eq!(compressed.matches("Already up-to-date").count(), 1);
    assert!(compressed.contains("WARN GET_NO_AUTH"));
    assert!(compressed.contains("ERR_PNPM_FETCH_401"));
    assert!(compressed.contains("dependencies:"));
    assert!(compressed.contains("Done in 4.2s"));

    let ratio = compressed.len() as f32 / output.len() as f32;
    assert!(ratio < 0.45, "ratio was {ratio}");
}

#[test]
fn pytest_drops_passes_keeps_failures_summary_and_warning_cap() {
    let mut output = String::from("============================= test session starts =============================\nplatform darwin -- Python 3.12.1, pytest-8.1.1\nrootdir: /repo\ncollected 45 items\n\ntests/test_ok.py ............................ PASSED\ntests/test_more.py sssxxx PASSED\ntests/test_bad.py::test_breaks FAILED\n\n=================================== FAILURES ===================================\n______________________________ test_breaks ______________________________\nE   AssertionError: boom\n\n=============================== warnings summary ===============================\n");
    for index in 0..8 {
        output.push_str(&format!(
            "tests/test_warn.py:{index}: DeprecationWarning: deprecated api {index}\n"
        ));
    }
    output.push_str("=========================== short test summary info ===========================\nFAILED tests/test_bad.py::test_breaks - AssertionError: boom\n==================== 44 passed, 1 failed, 3 skipped in 2.34s ====================\n");

    let compressed = PytestCompressor.compress("python -m pytest", &output);
    assert!(compressed.contains("platform darwin"));
    assert!(compressed.contains("rootdir: /repo"));
    assert!(compressed.contains("collected 45 items"));
    assert!(!compressed.contains("tests/test_ok.py"));
    assert!(compressed.contains("tests/test_bad.py::test_breaks FAILED"));
    assert!(compressed.contains("AssertionError: boom"));
    assert!(compressed.contains("... and 3 more warnings"));
    assert!(compressed.contains("short test summary info"));
    assert!(compressed.contains("44 passed, 1 failed"));

    let ratio = compressed.len() as f32 / output.len() as f32;
    assert!(ratio < 0.80, "ratio was {ratio}");
}

#[test]
fn tsc_groups_errors_by_file_and_handles_clean_output() {
    let mut output = String::from(
        "Project 'tsconfig.json' is out of date because output is older than input\nCompiling...\n",
    );
    for index in 0..35 {
        output.push_str(&format!(
            "src/big.ts({},{}): error TS2322: Type 'string' is not assignable to type 'number'.\n",
            index + 1,
            index + 2
        ));
    }
    for file in 0..22 {
        output.push_str(&format!(
            "src/file_{file}.ts(1,1): error TS2304: Cannot find name 'missing{file}'.\n"
        ));
    }
    output.push_str("Found 57 errors in 23 files.\n");

    let compressed = TscCompressor.compress("pnpm tsc --noEmit", &output);
    assert!(!compressed.contains("Compiling..."));
    assert!(compressed.contains("src/big.ts(1,2): error TS2322"));
    assert!(compressed.contains("... and 25 more errors in this file"));
    assert!(compressed.contains("... and 13 more files with errors"));
    assert!(compressed.contains("Found 57 errors in 23 files"));

    let ratio = compressed.len() as f32 / output.len() as f32;
    assert!(ratio < 0.45, "ratio was {ratio}");
}

#[test]
fn tsc_preserves_top_level_errors_and_only_reports_proven_success() {
    let top_level_error = "error TS18003: No inputs were found in config file 'tsconfig.json'. Specified 'include' paths were '[\"src\"]'.\n";
    let compressed = TscCompressor.compress("tsc --noEmit", top_level_error);
    assert!(compressed.contains("error TS18003: No inputs were found"));
    assert!(!compressed.contains("No errors"));

    let watch_success = TscCompressor.compress(
        "tsc --watch",
        "12:00:00 PM - Found 0 errors. Watching for file changes.\n",
    );
    assert_eq!(watch_success, "No errors. [cmpaft]");

    let empty = TscCompressor.compress("tsc --noEmit", "");
    assert_eq!(empty, "No errors. [cmpaft]");
}

#[test]
fn dispatch_reaches_extra_compressors() {
    let ctx = compress_context();
    let output = "Progress: resolved 1, reused 0, downloaded 0, added 0\nProgress: resolved 2, reused 0, downloaded 0, added 0\nProgress: resolved 3, reused 0, downloaded 0, added 0\ndependencies:\n+ zod 3.22.0\n".to_string();

    let compressed = compress::compress("pnpm install", output, &ctx);
    assert_eq!(compressed.matches("Progress: resolved").count(), 2);
    assert!(compressed.contains("dependencies:"));
}

// ---------------------------------------------------------------------------
// `bun test` compressor tests
//
// Regression: until v0.28.2, `bun test` fell through to GenericCompressor,
// which middle-truncates large outputs. Bun emits failure blocks BETWEEN the
// header and the summary, so truncation would routinely lose the error
// detail an agent needs to debug. The new compress_test() preserves all
// failure blocks (capped at MAX_FAILURES) plus header + summary.
// ---------------------------------------------------------------------------

#[test]
fn bun_test_pass_only_keeps_header_and_summary() {
    let output = "bun test v1.3.14 (0d9b296a)\n\nsrc/__tests__/foo.test.ts:\n\n 12 pass\n 0 fail\n 24 expect() calls\nRan 12 tests across 1 file. [42.00ms]\n";

    let compressed = BunCompressor.compress("bun test", output);
    assert!(compressed.contains("bun test v1.3.14"));
    assert!(compressed.contains("12 pass"));
    assert!(compressed.contains("0 fail"));
    assert!(compressed.contains("Ran 12 tests across 1 file. [42.00ms]"));
}

#[test]
fn bun_test_preserves_single_failure_block_when_middle_truncation_would_hit() {
    // Simulate the realistic shape: header + (many) pass-quiet sections +
    // failure block + summary. Bun's default reporter doesn't print
    // individual pass lines, but it does print a section header per file,
    // so the truncation hazard is real for monorepos with many test files.
    let mut output = String::from("bun test v1.3.14 (0d9b296a)\n");
    for index in 0..50 {
        output.push_str(&format!("\nsrc/pass_only_{index}.test.ts:\n"));
    }
    output.push_str("\nsrc/failing.test.ts:\n");
    output.push_str("11 | test(\"failing example\", () => {\n");
    output.push_str("12 |   expect({ a: 1 }).toEqual({ a: 2 });\n");
    output.push_str("                          ^\n");
    output.push_str("error: expect(received).toEqual(expected)\n");
    output.push_str("\n@@ -1,3 +1,3 @@\n");
    output.push_str("   {\n");
    output.push_str("-    \"a\": 2,\n");
    output.push_str("+    \"a\": 1,\n");
    output.push_str("   }\n");
    output.push_str("\n      at <anonymous> (/repo/src/failing.test.ts:12:24)\n");
    output.push_str("(fail) failing example [3.43ms]\n");
    output.push_str(
        "\n 49 pass\n 1 fail\n 50 expect() calls\nRan 50 tests across 50 files. [142.00ms]\n",
    );

    let compressed = BunCompressor.compress("bun test", &output);

    // Must preserve the failure block.
    assert!(compressed.contains("error: expect(received).toEqual(expected)"));
    assert!(compressed.contains("(fail) failing example"));
    assert!(compressed.contains("expect({ a: 1 }).toEqual({ a: 2 });"));
    assert!(compressed.contains("at <anonymous>"));
    // Must preserve the file section header that owns the failure.
    assert!(compressed.contains("src/failing.test.ts:"));
    // Must preserve the summary tail.
    assert!(compressed.contains("1 fail"));
    assert!(compressed.contains("Ran 50 tests across 50 files. [142.00ms]"));

    // Pass-only section headers should be dropped (no failure beneath them).
    assert!(!compressed.contains("src/pass_only_0.test.ts:"));
    assert!(!compressed.contains("src/pass_only_49.test.ts:"));

    let ratio = compressed.len() as f32 / output.len() as f32;
    assert!(ratio < 0.50, "ratio was {ratio}");
}

#[test]
fn bun_test_multiple_failures_all_preserved_under_cap() {
    let mut output = String::from("bun test v1.3.14 (0d9b296a)\n\nsrc/multi.test.ts:\n\n");
    for index in 0..3 {
        output.push_str(&format!(
            "{} | expect({}).toBe(0);\n",
            10 + index,
            index + 1
        ));
        output.push_str("              ^\n");
        output.push_str(&format!(
            "error: expect(received).toBe(expected) [{index}]\n"
        ));
        output.push_str("\nExpected: 0\n");
        output.push_str(&format!("Received: {}\n", index + 1));
        output.push_str(&format!(
            "      at <anonymous> (/repo/src/multi.test.ts:{}:5)\n",
            10 + index
        ));
        output.push_str(&format!("(fail) case {index} [0.5ms]\n"));
    }
    output
        .push_str("\n 0 pass\n 3 fail\n 3 expect() calls\nRan 3 tests across 1 file. [12.00ms]\n");

    let compressed = BunCompressor.compress("bun test", &output);

    for index in 0..3 {
        assert!(
            compressed.contains(&format!("expect(received).toBe(expected) [{index}]")),
            "missing failure {index} body in: {compressed}"
        );
        assert!(
            compressed.contains(&format!("(fail) case {index}")),
            "missing (fail) marker {index}"
        );
    }
    assert!(compressed.contains("3 fail"));
    assert!(compressed.contains("Ran 3 tests across 1 file. [12.00ms]"));
    assert!(!compressed.contains("+0 more failures"));
}

#[test]
fn bun_test_catastrophic_failure_count_is_capped() {
    let mut output = String::from("bun test v1.3.14 (0d9b296a)\n\nsrc/disaster.test.ts:\n\n");
    let total = 100usize;
    for index in 0..total {
        output.push_str(&format!(
            "{} | expect({}).toBe(0);\n",
            10 + index,
            index + 1
        ));
        output.push_str("              ^\n");
        output.push_str(&format!("error: failure_marker_{index}\n"));
        output.push_str(&format!("(fail) case_{index} [0.5ms]\n"));
    }
    output.push_str(&format!(
        "\n 0 pass\n {total} fail\n {total} expect() calls\nRan {total} tests across 1 file. [12.00ms]\n"
    ));

    let compressed = BunCompressor.compress("bun test", &output);

    // First 25 failures must be preserved (MAX_FAILURES = 25).
    for index in 0..25 {
        assert!(
            compressed.contains(&format!("failure_marker_{index}")),
            "missing kept failure {index}"
        );
    }
    // Failures past 25 must be dropped from the body.
    for index in 25..total {
        assert!(
            !compressed.contains(&format!("failure_marker_{index}")),
            "did not drop failure {index}"
        );
    }
    // Drop trailer must report the count of dropped failures.
    assert!(
        compressed.contains(&format!("+{} more failures", total - 25)),
        "missing dropped-failures trailer in: {compressed}"
    );
    // Summary intact.
    assert!(compressed.contains(&format!("{total} fail")));
    assert!(compressed.contains(&format!("Ran {total} tests across 1 file. [12.00ms]")));
}

#[test]
fn bun_test_dispatch_routes_through_test_compressor_not_generic() {
    // End-to-end: confirm the registry dispatches `bun test` through the
    // new compress_test path. Without the fix, this output would go to
    // GenericCompressor::compress_output() which preserves all lines and
    // does not skip individual file-section headers; with the fix we drop
    // the pass-only sections and keep the failure block.
    let ctx = compress_context();
    let output = "bun test v1.3.14 (0d9b296a)\n\nsrc/a.test.ts:\n\nsrc/b.test.ts:\n\nsrc/c.test.ts:\n12 | expect(1).toBe(2);\n              ^\nerror: expect(received).toBe(expected)\n(fail) c case [0.5ms]\n\n 0 pass\n 1 fail\n 1 expect() calls\nRan 1 tests across 3 files. [3.00ms]\n".to_string();

    let compressed = compress::compress("bun test", output, &ctx);
    // Pass-only sections dropped.
    assert!(!compressed.contains("src/a.test.ts:"));
    assert!(!compressed.contains("src/b.test.ts:"));
    // Failing section header preserved.
    assert!(compressed.contains("src/c.test.ts:"));
    // Failure body preserved.
    assert!(compressed.contains("error: expect(received).toBe(expected)"));
    assert!(compressed.contains("(fail) c case"));
    // Summary tail preserved.
    assert!(compressed.contains("1 fail"));
    assert!(compressed.contains("Ran 1 tests across 3 files. [3.00ms]"));
}