agent-file-tools 0.56.2

Agent File Tools — tree-sitter powered code analysis for AI agents
Documentation
use aft::callgraph_store::{project_dead_code_snapshot, CallGraphStore};
use rusqlite::{params, Connection};
use std::fs;
use std::path::PathBuf;
use tempfile::tempdir;

#[test]
#[ignore = "requires AFT_STALE_REPRO_STORE and AFT_STALE_REPRO_ROOT"]
fn stale_deleted_store_copy_reproduction() {
    let store_dir = PathBuf::from(
        std::env::var_os("AFT_STALE_REPRO_STORE").expect("AFT_STALE_REPRO_STORE is set"),
    );
    let project_root = PathBuf::from(
        std::env::var_os("AFT_STALE_REPRO_ROOT").expect("AFT_STALE_REPRO_ROOT is set"),
    );
    let store = CallGraphStore::open_ready_repairing(store_dir, project_root)
        .expect("open copied store")
        .expect("copied store is ready");

    println!(
        "projection before empty refresh: {:?}",
        project_dead_code_snapshot(store.sqlite_path()).map(|snapshot| snapshot.files.len())
    );
    let stats = store.refresh_files(&[]).expect("refresh copied store");
    println!("empty refresh stats: {stats:?}");
    println!(
        "projection after empty refresh: {:?}",
        project_dead_code_snapshot(store.sqlite_path()).map(|snapshot| snapshot.files.len())
    );
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct GraphRowCounts {
    nodes: i64,
    edges: i64,
    refs: i64,
    dispatch_hints: i64,
}

impl GraphRowCounts {
    fn totals(conn: &Connection) -> Self {
        Self {
            nodes: count(conn, "nodes", None),
            edges: count(conn, "edges", None),
            refs: count(conn, "refs", None),
            dispatch_hints: count(conn, "dispatch_hints", None),
        }
    }

    fn for_file(conn: &Connection, file: &str) -> Self {
        Self {
            nodes: count(conn, "nodes", Some(("file_path", file))),
            edges: conn
                .query_row(
                    "SELECT COUNT(*) FROM edges WHERE ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?1)",
                    params![file],
                    |row| row.get(0),
                )
                .unwrap(),
            refs: count(conn, "refs", Some(("caller_file", file))),
            dispatch_hints: count(conn, "dispatch_hints", Some(("file", file))),
        }
    }

    fn minus(self, removed: Self) -> Self {
        Self {
            nodes: self.nodes - removed.nodes,
            edges: self.edges - removed.edges,
            refs: self.refs - removed.refs,
            dispatch_hints: self.dispatch_hints - removed.dispatch_hints,
        }
    }
}

#[test]
fn stale_deleted_file_unmentioned_by_refresh_is_removed_and_projection_recovers() {
    let fixture = fixture_store();
    let doomed = fixture.root.join("src/doomed.ts");
    fixture.store.mark_files_stale(&[doomed.clone()]).unwrap();
    fs::remove_file(&doomed).unwrap();

    let before_projection = project_dead_code_snapshot(fixture.store.sqlite_path());
    assert!(
        before_projection.is_err(),
        "stale backend row must block projection before refresh"
    );
    let conn = Connection::open(fixture.store.sqlite_path()).unwrap();
    let totals_before = GraphRowCounts::totals(&conn);
    let doomed_before = GraphRowCounts::for_file(&conn, "src/doomed.ts");
    assert!(doomed_before.nodes > 0, "fixture must contain doomed nodes");
    assert!(doomed_before.edges > 0, "fixture must contain doomed edges");
    assert!(doomed_before.refs > 0, "fixture must contain doomed refs");
    assert!(
        doomed_before.dispatch_hints > 0,
        "fixture must contain doomed dispatch hints"
    );
    drop(conn);

    let stats = fixture
        .store
        .refresh_files(&[fixture.root.join("src/unrelated.ts")])
        .unwrap();

    assert_eq!(stats.deleted_files, vec!["src/doomed.ts"]);
    assert!(fixture.store.stale_files().unwrap().is_empty());
    project_dead_code_snapshot(fixture.store.sqlite_path()).expect("projection recovers");
    let conn = Connection::open(fixture.store.sqlite_path()).unwrap();
    assert_eq!(
        GraphRowCounts::for_file(&conn, "src/doomed.ts"),
        GraphRowCounts {
            nodes: 0,
            edges: 0,
            refs: 0,
            dispatch_hints: 0,
        }
    );
    assert_eq!(
        GraphRowCounts::totals(&conn),
        totals_before.minus(doomed_before),
        "refresh must remove only the deleted file's graph rows"
    );
}

#[test]
fn stale_existing_file_unmentioned_by_refresh_is_untouched() {
    let fixture = fixture_store();
    let doomed = fixture.root.join("src/doomed.ts");
    fixture.store.mark_files_stale(&[doomed]).unwrap();
    let conn = Connection::open(fixture.store.sqlite_path()).unwrap();
    let before = GraphRowCounts::totals(&conn);
    drop(conn);

    let stats = fixture.store.refresh_files(&[]).unwrap();

    assert!(stats.deleted_files.is_empty());
    assert_eq!(fixture.store.stale_files().unwrap(), vec!["src/doomed.ts"]);
    let conn = Connection::open(fixture.store.sqlite_path()).unwrap();
    assert_eq!(GraphRowCounts::totals(&conn), before);
    assert!(project_dead_code_snapshot(fixture.store.sqlite_path()).is_err());
}

#[cfg(unix)]
#[test]
fn stale_file_with_permission_denied_stat_is_untouched() {
    use std::os::unix::fs::PermissionsExt;

    let fixture = fixture_store();
    let guarded = fixture.root.join("guarded");
    let file = guarded.join("unreadable.ts");
    fs::create_dir_all(&guarded).unwrap();
    fs::write(&file, "export function unreadable() {}\n").unwrap();
    fixture.store.refresh_files(&[file.clone()]).unwrap();
    fixture.store.mark_files_stale(&[file]).unwrap();
    let original_mode = fs::metadata(&guarded).unwrap().permissions().mode();
    fs::set_permissions(&guarded, fs::Permissions::from_mode(0)).unwrap();

    let result = fixture.store.refresh_files(&[]);

    fs::set_permissions(&guarded, fs::Permissions::from_mode(original_mode)).unwrap();
    result.unwrap();
    assert_eq!(
        fixture.store.stale_files().unwrap(),
        vec!["guarded/unreadable.ts"]
    );
    assert_eq!(
        fixture
            .store
            .backend_status_for_file(&guarded.join("unreadable.ts"))
            .unwrap()
            .as_deref(),
        Some("stale")
    );
}

struct FixtureStore {
    _temp: tempfile::TempDir,
    root: PathBuf,
    store: CallGraphStore,
}

fn fixture_store() -> FixtureStore {
    let temp = tempdir().unwrap();
    let root = temp.path().join("project");
    fs::create_dir_all(root.join("src")).unwrap();
    fs::write(
        root.join("src/keep.ts"),
        "export function keep() { return 1; }\n",
    )
    .unwrap();
    fs::write(
        root.join("src/unrelated.ts"),
        "export function unrelated() { return 2; }\n",
    )
    .unwrap();
    fs::write(
        root.join("src/doomed.ts"),
        r#"import { keep } from "./keep";
const service = { execute() { return keep(); } };
export function doomed() {
  keep();
  return service.execute();
}
"#,
    )
    .unwrap();
    let root = fs::canonicalize(root).unwrap();
    let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
    store
        .cold_build(&[
            root.join("src/keep.ts"),
            root.join("src/unrelated.ts"),
            root.join("src/doomed.ts"),
        ])
        .unwrap();
    FixtureStore {
        _temp: temp,
        root,
        store,
    }
}

fn count(conn: &Connection, table: &str, filter: Option<(&str, &str)>) -> i64 {
    match filter {
        Some((column, value)) => conn
            .query_row(
                &format!("SELECT COUNT(*) FROM {table} WHERE {column} = ?1"),
                params![value],
                |row| row.get(0),
            )
            .unwrap(),
        None => conn
            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| {
                row.get(0)
            })
            .unwrap(),
    }
}