use crate::helpers::{fixture_path, AftProcess};
use std::fs;
use std::path::Path;
use tempfile::tempdir;
fn configure_project(aft: &mut AftProcess, root: &Path) {
let resp = aft.send(&format!(
r#"{{"id":"configure","command":"configure","project_root":"{}"}}"#,
root.display()
));
assert_eq!(resp["success"], true, "configure should succeed: {resp:?}");
}
#[test]
fn callgraph_configure_sets_project_root() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
assert_eq!(
resp["success"], true,
"configure should succeed: {:?}",
resp
);
assert_eq!(
resp["project_root"].as_str().unwrap(),
root,
"should echo back the configured root"
);
aft.shutdown();
}
#[test]
fn callgraph_configure_missing_param() {
let mut aft = AftProcess::spawn();
let resp = aft.send(r#"{"id":"1","command":"configure"}"#);
assert_eq!(resp["success"], false);
assert_eq!(resp["code"], "invalid_request");
aft.shutdown();
}
#[test]
fn callgraph_call_tree_without_configure() {
let mut aft = AftProcess::spawn();
let resp = aft.send(r#"{"id":"1","command":"call_tree","file":"main.ts","symbol":"main"}"#);
assert_eq!(resp["success"], false);
assert_eq!(resp["code"], "not_configured");
aft.shutdown();
}
#[test]
fn callgraph_cross_file_tree() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
assert_eq!(resp["success"], true);
let resp = aft.send(&format!(
r#"{{"id":"2","command":"call_tree","file":"{}/main.ts","symbol":"main","depth":5}}"#,
root
));
assert_eq!(
resp["success"], true,
"call_tree should succeed: {:?}",
resp
);
assert_eq!(resp["name"], "main");
assert_eq!(resp["resolved"], true);
assert_eq!(resp["line"], 3, "main definition line should be 1-based");
let children = resp["children"]
.as_array()
.expect("children should be array");
let process_data = children
.iter()
.find(|c| c["name"] == "processData")
.expect("main should call processData");
assert_eq!(process_data["resolved"], true);
assert_eq!(
process_data["line"], 3,
"processData line should be 1-based"
);
assert!(
process_data["file"].as_str().unwrap().contains("utils.ts"),
"processData should be in utils.ts, got: {}",
process_data["file"]
);
let pd_children = process_data["children"]
.as_array()
.expect("processData children");
let validate = pd_children
.iter()
.find(|c| c["name"] == "validate")
.expect("processData should call validate");
assert_eq!(validate["resolved"], true);
assert_eq!(validate["line"], 1, "validate line should be 1-based");
assert!(
validate["file"].as_str().unwrap().contains("helpers.ts"),
"validate should be in helpers.ts, got: {}",
validate["file"]
);
let v_children = validate["children"].as_array().expect("validate children");
let check_format = v_children.iter().find(|c| c["name"] == "checkFormat");
assert!(
check_format.is_some(),
"validate should call checkFormat, children: {:?}",
v_children
.iter()
.map(|c| c["name"].clone())
.collect::<Vec<_>>()
);
assert_eq!(
check_format.unwrap()["line"],
5,
"checkFormat line should be 1-based definition after same-file resolution"
);
assert_eq!(
check_format.unwrap()["resolved"],
true,
"checkFormat should resolve within helpers.ts"
);
aft.shutdown();
}
#[test]
fn callgraph_depth_limit_truncates() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
let resp = aft.send(&format!(
r#"{{"id":"2","command":"call_tree","file":"{}/main.ts","symbol":"main","depth":1}}"#,
root
));
assert_eq!(resp["success"], true);
assert_eq!(resp["name"], "main");
let children = resp["children"].as_array().expect("children");
for child in children {
let grandchildren = child["children"].as_array();
if let Some(gc) = grandchildren {
assert!(
gc.is_empty(),
"At depth 1, child '{}' should have no grandchildren",
child["name"]
);
}
}
aft.shutdown();
}
#[test]
fn callgraph_unknown_symbol_error() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
let resp = aft.send(&format!(
r#"{{"id":"2","command":"call_tree","file":"{}/main.ts","symbol":"nonexistent"}}"#,
root
));
assert_eq!(
resp["success"], false,
"unknown symbol should fail: {:?}",
resp
);
assert_eq!(resp["code"], "symbol_not_found");
aft.shutdown();
}
#[test]
fn callgraph_aliased_import_resolution() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
let resp = aft.send(&format!(
r#"{{"id":"2","command":"call_tree","file":"{}/aliased.ts","symbol":"runCheck","depth":3}}"#,
root
));
assert_eq!(
resp["success"], true,
"aliased call_tree should succeed: {:?}",
resp
);
assert_eq!(resp["name"], "runCheck");
let children = resp["children"].as_array().expect("children");
let resolved_child = children
.iter()
.find(|c| c["resolved"] == true && c["file"].as_str().unwrap_or("").contains("helpers.ts"));
assert!(
resolved_child.is_some(),
"checker alias should resolve to helpers.ts, children: {:?}",
children
);
aft.shutdown();
}
#[test]
fn callgraph_callers_without_configure() {
let mut aft = AftProcess::spawn();
let resp =
aft.send(r#"{"id":"1","command":"callers","file":"helpers.ts","symbol":"validate"}"#);
assert_eq!(resp["success"], false);
assert_eq!(resp["code"], "not_configured");
aft.shutdown();
}
#[test]
fn callgraph_callers_cross_file() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
assert_eq!(resp["success"], true);
let resp = aft.send(&format!(
r#"{{"id":"2","command":"callers","file":"{}/helpers.ts","symbol":"validate","depth":1}}"#,
root
));
assert_eq!(resp["success"], true, "callers should succeed: {:?}", resp);
assert_eq!(resp["symbol"], "validate");
assert!(
resp["total_callers"].as_u64().unwrap() > 0,
"validate should have callers"
);
assert!(
resp["scanned_files"].as_u64().unwrap() > 0,
"should report scanned files"
);
let callers = resp["callers"].as_array().expect("callers array");
let utils_group = callers
.iter()
.find(|g| g["file"].as_str().unwrap_or("").contains("utils.ts"));
assert!(
utils_group.is_some(),
"validate should be called from utils.ts, groups: {:?}",
callers
);
let group = utils_group.unwrap();
let entries = group["callers"].as_array().expect("callers entries");
let process_data_caller = entries
.iter()
.find(|e| e["symbol"].as_str().unwrap_or("") == "processData");
assert!(
process_data_caller.is_some(),
"validate should be called by processData, entries: {:?}",
entries
);
assert_eq!(
process_data_caller.unwrap()["line"],
4,
"call site line should be 1-based"
);
aft.shutdown();
}
#[test]
fn callgraph_callers_empty_result() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
let resp = aft.send(&format!(
r#"{{"id":"2","command":"callers","file":"{}/main.ts","symbol":"main"}}"#,
root
));
assert_eq!(resp["success"], true, "callers should succeed: {:?}", resp);
assert_eq!(resp["total_callers"], 0, "main should have no callers");
let callers = resp["callers"].as_array().expect("callers array");
assert!(
callers.is_empty(),
"callers should be empty for entry point"
);
aft.shutdown();
}
#[test]
fn callgraph_callers_recursive() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
let resp = aft.send(&format!(
r#"{{"id":"2","command":"callers","file":"{}/helpers.ts","symbol":"validate","depth":2}}"#,
root
));
assert_eq!(
resp["success"], true,
"recursive callers should succeed: {:?}",
resp
);
let total = resp["total_callers"].as_u64().unwrap();
assert!(
total >= 2,
"with depth 2, validate should have >= 2 callers (direct + transitive), got {}",
total
);
aft.shutdown();
}
#[test]
fn callgraph_resolves_workspace_package_import_callers_and_tree() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("package.json"),
r#"{"private":true,"workspaces":["packages/*"]}"#,
)
.unwrap();
let pkg_a = root.join("packages/pkg-a");
let pkg_b = root.join("packages/pkg-b");
fs::create_dir_all(pkg_a.join("src")).unwrap();
fs::create_dir_all(pkg_b.join("src")).unwrap();
fs::write(
pkg_a.join("package.json"),
r#"{"name":"@scope/pkg-a","exports":{".":{"import":"./dist/index.js"}}}"#,
)
.unwrap();
fs::write(
pkg_a.join("src/index.ts"),
r#"export { workspaceTarget } from "./target.js";
"#,
)
.unwrap();
fs::write(
pkg_a.join("src/target.ts"),
r#"export function workspaceTarget(): string {
return "ok";
}
"#,
)
.unwrap();
fs::write(pkg_b.join("package.json"), r#"{"name":"pkg-b"}"#).unwrap();
fs::write(
pkg_b.join("src/main.ts"),
r#"import { workspaceTarget } from "@scope/pkg-a";
export function runWorkspaceImport(): string {
return workspaceTarget();
}
"#,
)
.unwrap();
let mut aft = AftProcess::spawn();
let root_display = root.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root_display
));
assert_eq!(
resp["success"], true,
"configure should succeed: {:?}",
resp
);
let resp = aft.send(&format!(
r#"{{"id":"2","command":"callers","file":"{}","symbol":"workspaceTarget","depth":1}}"#,
pkg_a.join("src/target.ts").display()
));
assert_eq!(resp["success"], true, "callers should succeed: {:?}", resp);
assert_eq!(
resp["total_callers"], 1,
"workspace import caller should resolve"
);
let callers = resp["callers"].as_array().expect("callers array");
let pkg_b_group = callers.iter().find(|group| {
group["file"]
.as_str()
.unwrap_or("")
.ends_with("packages/pkg-b/src/main.ts")
});
assert!(
pkg_b_group.is_some(),
"caller should be pkg-b main.ts: {:?}",
callers
);
let resp = aft.send(&format!(
r#"{{"id":"3","command":"call_tree","file":"{}","symbol":"runWorkspaceImport","depth":2}}"#,
pkg_b.join("src/main.ts").display()
));
assert_eq!(
resp["success"], true,
"call_tree should succeed: {:?}",
resp
);
let children = resp["children"].as_array().expect("children array");
let target_child = children
.iter()
.find(|child| child["name"] == "workspaceTarget")
.expect("runWorkspaceImport should call workspaceTarget");
assert_eq!(target_child["resolved"], true);
assert!(
target_child["file"]
.as_str()
.unwrap_or("")
.ends_with("packages/pkg-a/src/target.ts"),
"workspaceTarget should resolve through package export to source file: {:?}",
target_child
);
aft.shutdown();
}
#[test]
fn callgraph_resolves_workspace_package_imports_past_nested_lockfile() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("package.json"),
r#"{"private":true,"workspaces":["packages/*"]}"#,
)
.unwrap();
let bridge = root.join("packages/aft-bridge");
let opencode = root.join("packages/opencode-plugin");
let pi = root.join("packages/pi-plugin");
fs::create_dir_all(bridge.join("src")).unwrap();
fs::create_dir_all(opencode.join("src/tools")).unwrap();
fs::create_dir_all(pi.join("src/tools")).unwrap();
fs::write(
bridge.join("package.json"),
r#"{"name":"@cortexkit/aft-bridge","exports":{".":{"import":"./dist/index.js"}}}"#,
)
.unwrap();
fs::write(
bridge.join("src/index.ts"),
r#"export { fetchUrlToTempFile } from "./url-fetch.js";
export { formatZoomText } from "./zoom-format.js";
"#,
)
.unwrap();
fs::write(
bridge.join("src/url-fetch.ts"),
r#"export function fetchUrlToTempFile(url: string): string {
return url;
}
"#,
)
.unwrap();
fs::write(
bridge.join("src/zoom-format.ts"),
r#"export function formatZoomText(text: string): string {
return text;
}
"#,
)
.unwrap();
fs::write(
opencode.join("package.json"),
r#"{"name":"@cortexkit/aft-opencode","dependencies":{"@cortexkit/aft-bridge":"0.0.0"}}"#,
)
.unwrap();
fs::write(opencode.join("bun.lock"), "").unwrap();
fs::write(
opencode.join("src/tools/reading.ts"),
r#"import { fetchUrlToTempFile, formatZoomText } from "@cortexkit/aft-bridge";
export function registerOpenCodeReadingTools(): string {
const path = fetchUrlToTempFile("https://example.com/opencode");
return formatZoomText(path);
}
"#,
)
.unwrap();
fs::write(
pi.join("package.json"),
r#"{"name":"@cortexkit/aft-pi","dependencies":{"@cortexkit/aft-bridge":"0.0.0"}}"#,
)
.unwrap();
fs::write(
pi.join("src/tools/reading.ts"),
r#"import { fetchUrlToTempFile, formatZoomText } from "@cortexkit/aft-bridge";
export function registerPiReadingTools(): string {
const path = fetchUrlToTempFile("https://example.com/pi");
return formatZoomText(path);
}
"#,
)
.unwrap();
let mut aft = AftProcess::spawn();
let root_display = root.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root_display
));
assert_eq!(
resp["success"], true,
"configure should succeed: {:?}",
resp
);
for (id, file, symbol) in [
("2", bridge.join("src/url-fetch.ts"), "fetchUrlToTempFile"),
("3", bridge.join("src/zoom-format.ts"), "formatZoomText"),
] {
let resp = aft.send(&format!(
r#"{{"id":"{}","command":"callers","file":"{}","symbol":"{}","depth":1}}"#,
id,
file.display(),
symbol
));
assert_eq!(resp["success"], true, "callers should succeed: {:?}", resp);
assert_eq!(
resp["total_callers"], 2,
"both workspace package consumers should call {symbol}: {resp:?}"
);
let callers = resp["callers"].as_array().expect("callers array");
for expected_file in [
"packages/opencode-plugin/src/tools/reading.ts",
"packages/pi-plugin/src/tools/reading.ts",
] {
assert!(
callers.iter().any(|group| group["file"]
.as_str()
.unwrap_or("")
.ends_with(expected_file)),
"{symbol} caller should include {expected_file}: {:?}",
callers
);
}
}
aft.shutdown();
}
#[test]
fn callgraph_prefers_workspace_package_source_over_existing_dist() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("package.json"),
r#"{"private":true,"workspaces":["packages/*"]}"#,
)
.unwrap();
let pkg_a = root.join("packages/pkg-a");
let pkg_b = root.join("packages/pkg-b");
fs::create_dir_all(pkg_a.join("dist")).unwrap();
fs::create_dir_all(pkg_a.join("src")).unwrap();
fs::create_dir_all(pkg_b.join("src")).unwrap();
fs::write(
pkg_a.join("package.json"),
r#"{"name":"@scope/pkg-a","main":"dist/index.js"}"#,
)
.unwrap();
fs::write(
pkg_a.join("dist/index.js"),
r#"export const bundledValue = 1;
"#,
)
.unwrap();
fs::write(
pkg_a.join("src/index.ts"),
r#"export { workspaceTarget } from "./target.js";
"#,
)
.unwrap();
fs::write(
pkg_a.join("src/target.ts"),
r#"export function workspaceTarget(): string {
return "ok";
}
"#,
)
.unwrap();
fs::write(pkg_b.join("package.json"), r#"{"name":"pkg-b"}"#).unwrap();
fs::write(
pkg_b.join("src/main.ts"),
r#"import { workspaceTarget } from "@scope/pkg-a";
export function runWorkspaceImport(): string {
return workspaceTarget();
}
"#,
)
.unwrap();
let mut aft = AftProcess::spawn();
let root_display = root.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root_display
));
assert_eq!(
resp["success"], true,
"configure should succeed: {:?}",
resp
);
let resp = aft.send(&format!(
r#"{{"id":"2","command":"callers","file":"{}","symbol":"workspaceTarget","depth":1}}"#,
pkg_a.join("src/target.ts").display()
));
assert_eq!(resp["success"], true, "callers should succeed: {:?}", resp);
assert_eq!(
resp["total_callers"], 1,
"workspace import caller should resolve through source even when dist exists: {:?}",
resp
);
let callers = resp["callers"].as_array().expect("callers array");
let pkg_b_group = callers.iter().find(|group| {
group["file"]
.as_str()
.unwrap_or("")
.ends_with("packages/pkg-b/src/main.ts")
});
assert!(
pkg_b_group.is_some(),
"caller should be pkg-b main.ts, not dist/index.js: {:?}",
callers
);
let resp = aft.send(&format!(
r#"{{"id":"3","command":"call_tree","file":"{}","symbol":"runWorkspaceImport","depth":2}}"#,
pkg_b.join("src/main.ts").display()
));
assert_eq!(
resp["success"], true,
"call_tree should succeed: {:?}",
resp
);
let children = resp["children"].as_array().expect("children array");
let target_child = children
.iter()
.find(|child| child["name"] == "workspaceTarget")
.expect("runWorkspaceImport should call workspaceTarget");
assert_eq!(target_child["resolved"], true);
assert!(
target_child["file"]
.as_str()
.unwrap_or("")
.ends_with("packages/pkg-a/src/target.ts"),
"workspaceTarget should resolve to source target instead of dist bundle: {:?}",
target_child
);
assert!(
!target_child["file"]
.as_str()
.unwrap_or("")
.contains("/dist/"),
"workspaceTarget should not resolve to dist bundle: {:?}",
target_child
);
aft.shutdown();
}
#[test]
fn callgraph_indexes_relative_calls_inside_test_callbacks() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::create_dir_all(root.join("src/shared")).unwrap();
fs::create_dir_all(root.join("src/__tests__")).unwrap();
fs::write(
root.join("src/shared/model.ts"),
r#"export function testTarget(): number {
return 1;
}
"#,
)
.unwrap();
fs::write(
root.join("src/__tests__/model.test.ts"),
r#"import { expect, test } from "bun:test";
import { testTarget } from "../shared/model.js";
test("calls target", () => {
expect(testTarget()).toBe(1);
});
"#,
)
.unwrap();
let mut aft = AftProcess::spawn();
let root_display = root.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root_display
));
assert_eq!(
resp["success"], true,
"configure should succeed: {:?}",
resp
);
let resp = aft.send(&format!(
r#"{{"id":"2","command":"callers","file":"{}","symbol":"testTarget","depth":1}}"#,
root.join("src/shared/model.ts").display()
));
assert_eq!(resp["success"], true, "callers should succeed: {:?}", resp);
assert_eq!(
resp["total_callers"], 1,
"test callback caller should be indexed"
);
let callers = resp["callers"].as_array().expect("callers array");
let test_group = callers.iter().find(|group| {
group["file"]
.as_str()
.unwrap_or("")
.ends_with("src/__tests__/model.test.ts")
});
assert!(
test_group.is_some(),
"caller should be the test file: {:?}",
callers
);
let entries = test_group.unwrap()["callers"]
.as_array()
.expect("caller entries");
assert!(
entries.iter().any(|entry| entry["line"] == 5),
"testTarget call site should be line 5: {:?}",
entries
);
aft.shutdown();
}
#[test]
fn callgraph_leaves_non_workspace_package_imports_unresolved() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("package.json"),
r#"{"private":true,"workspaces":["packages/*"]}"#,
)
.unwrap();
let app = root.join("packages/app");
fs::create_dir_all(app.join("src")).unwrap();
fs::write(app.join("package.json"), r#"{"name":"app"}"#).unwrap();
fs::write(
app.join("src/main.ts"),
r#"import { useMemo } from "react";
export function render(): unknown {
return useMemo(() => "ok", []);
}
"#,
)
.unwrap();
let mut aft = AftProcess::spawn();
let root_display = root.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root_display
));
assert_eq!(
resp["success"], true,
"configure should succeed: {:?}",
resp
);
let resp = aft.send(&format!(
r#"{{"id":"2","command":"call_tree","file":"{}","symbol":"render","depth":1}}"#,
app.join("src/main.ts").display()
));
assert_eq!(
resp["success"], true,
"call_tree should succeed: {:?}",
resp
);
let children = resp["children"].as_array().expect("children array");
let use_memo = children
.iter()
.find(|child| child["name"] == "useMemo")
.expect("render should call useMemo");
assert_eq!(
use_memo["resolved"], false,
"react import should not resolve as workspace"
);
aft.shutdown();
}
#[test]
fn callgraph_reexport_alias_resolves_to_source_symbol() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("real.ts"),
"export function foo(): string { return 'ok'; }\n",
)
.unwrap();
fs::write(
root.join("barrel.ts"),
"export { foo as bar } from './real.js';\n",
)
.unwrap();
fs::write(
root.join("app.ts"),
"import { bar } from './barrel.js';\nexport function run(): string { return bar(); }\n",
)
.unwrap();
let mut aft = AftProcess::spawn();
configure_project(&mut aft, root);
let resp = aft.send(&format!(
r#"{{"id":"1","command":"callers","file":"{}","symbol":"foo","depth":1}}"#,
root.join("real.ts").display()
));
assert_eq!(resp["success"], true, "callers should succeed: {resp:?}");
assert_eq!(
resp["total_callers"], 1,
"alias re-export should point at foo: {resp:?}"
);
aft.shutdown();
}
#[test]
fn callgraph_aliased_import_follows_reexport_barrel() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("real.ts"),
"export function foo(): string { return 'ok'; }\n",
)
.unwrap();
fs::write(root.join("barrel.ts"), "export { foo } from './real.js';\n").unwrap();
fs::write(
root.join("app.ts"),
"import { foo as bar } from './barrel.js';\nexport function run(): string { return bar(); }\n",
)
.unwrap();
let mut aft = AftProcess::spawn();
configure_project(&mut aft, root);
let resp = aft.send(&format!(
r#"{{"id":"1","command":"callers","file":"{}","symbol":"foo","depth":1}}"#,
root.join("real.ts").display()
));
assert_eq!(resp["success"], true, "callers should succeed: {resp:?}");
assert_eq!(
resp["total_callers"], 1,
"aliased import should follow barrel: {resp:?}"
);
aft.shutdown();
}
#[test]
fn callgraph_default_reexport_resolves_real_default_symbol() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("real.ts"),
"export default function targetDefault(): string { return 'ok'; }\n",
)
.unwrap();
fs::write(
root.join("barrel.ts"),
"export { default } from './real.js';\n",
)
.unwrap();
fs::write(
root.join("app.ts"),
"import targetDefault from './barrel.js';\nexport function run(): string { return targetDefault(); }\n",
)
.unwrap();
let mut aft = AftProcess::spawn();
configure_project(&mut aft, root);
let resp = aft.send(&format!(
r#"{{"id":"1","command":"callers","file":"{}","symbol":"targetDefault","depth":1}}"#,
root.join("real.ts").display()
));
assert_eq!(resp["success"], true, "callers should succeed: {resp:?}");
assert_eq!(
resp["total_callers"], 1,
"default re-export should resolve to real default: {resp:?}"
);
aft.shutdown();
}
#[test]
fn callgraph_call_tree_resolves_same_file_calls() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("local.ts"),
"export function main(): string { return helper(); }\nfunction helper(): string { return leaf(); }\nfunction leaf(): string { return 'ok'; }\n",
)
.unwrap();
let mut aft = AftProcess::spawn();
configure_project(&mut aft, root);
let resp = aft.send(&format!(
r#"{{"id":"1","command":"call_tree","file":"{}","symbol":"main","depth":3}}"#,
root.join("local.ts").display()
));
assert_eq!(resp["success"], true, "call_tree should succeed: {resp:?}");
let helper = resp["children"]
.as_array()
.unwrap()
.iter()
.find(|c| c["name"] == "helper")
.expect("main should resolve helper");
assert_eq!(
helper["resolved"], true,
"helper should be a resolved local child: {helper:?}"
);
assert!(
helper["children"]
.as_array()
.unwrap()
.iter()
.any(|c| c["name"] == "leaf"),
"helper should descend into leaf: {helper:?}"
);
aft.shutdown();
}
#[test]
fn callgraph_workspace_package_cache_refreshes_after_reconfigure() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("package.json"),
r#"{"private":true,"workspaces":["packages/*"]}"#,
)
.unwrap();
let pkg = root.join("packages/pkg");
let app = root.join("packages/app");
fs::create_dir_all(pkg.join("src")).unwrap();
fs::create_dir_all(app.join("src")).unwrap();
fs::write(pkg.join("package.json"), r#"{"name":"@scope/old"}"#).unwrap();
fs::write(
pkg.join("src/index.ts"),
"export function target(): string { return 'ok'; }\n",
)
.unwrap();
fs::write(app.join("package.json"), r#"{"name":"app"}"#).unwrap();
fs::write(app.join("src/main.ts"), "import { target } from '@scope/new';\nexport function run(): string { return target(); }\n").unwrap();
let mut aft = AftProcess::spawn();
configure_project(&mut aft, root);
let resp = aft.send(&format!(
r#"{{"id":"1","command":"callers","file":"{}","symbol":"target","depth":1}}"#,
pkg.join("src/index.ts").display()
));
assert_eq!(
resp["success"], true,
"first callers should succeed: {resp:?}"
);
assert_eq!(
resp["total_callers"], 0,
"new package name should miss before package.json changes"
);
fs::write(pkg.join("package.json"), r#"{"name":"@scope/new"}"#).unwrap();
configure_project(&mut aft, root);
let resp = aft.send(&format!(
r#"{{"id":"2","command":"callers","file":"{}","symbol":"target","depth":1}}"#,
pkg.join("src/index.ts").display()
));
assert_eq!(
resp["success"], true,
"second callers should succeed: {resp:?}"
);
assert_eq!(
resp["total_callers"], 1,
"workspace cache should not keep stale miss: {resp:?}"
);
aft.shutdown();
}
#[test]
fn callgraph_indexes_tsx_jsx_components_and_new_expressions() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("component.tsx"),
"export function Widget(): JSX.Element { return <span />; }\nexport class Service {}\nexport function App(): JSX.Element { const service = new Service(); return <Widget />; }\n",
)
.unwrap();
let mut aft = AftProcess::spawn();
configure_project(&mut aft, root);
for (id, symbol) in [("1", "Widget"), ("2", "Service")] {
let resp = aft.send(&format!(
r#"{{"id":"{}","command":"callers","file":"{}","symbol":"{}","depth":1}}"#,
id,
root.join("component.tsx").display(),
symbol
));
assert_eq!(resp["success"], true, "callers should succeed: {resp:?}");
assert_eq!(
resp["total_callers"], 1,
"{symbol} should be indexed from JSX/new: {resp:?}"
);
}
aft.shutdown();
}
#[test]
fn callgraph_source_less_export_alias_resolves_local_symbol() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("module.ts"),
"function foo(): string { return 'ok'; }\nexport { foo as bar };\n",
)
.unwrap();
fs::write(
root.join("app.ts"),
"import { bar } from './module.js';\nexport function run(): string { return bar(); }\n",
)
.unwrap();
let mut aft = AftProcess::spawn();
configure_project(&mut aft, root);
let resp = aft.send(&format!(
r#"{{"id":"1","command":"callers","file":"{}","symbol":"foo","depth":1}}"#,
root.join("module.ts").display()
));
assert_eq!(resp["success"], true, "callers should succeed: {resp:?}");
assert_eq!(
resp["total_callers"], 1,
"source-less alias should resolve to local foo: {resp:?}"
);
aft.shutdown();
}
#[test]
fn callgraph_commands_accept_private_leaf_symbols() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(root.join("leaf.ts"), "export function caller(): string { return leaf(); }\nfunction leaf(): string { return 'ok'; }\n").unwrap();
let mut aft = AftProcess::spawn();
configure_project(&mut aft, root);
let tree = aft.send(&format!(
r#"{{"id":"1","command":"call_tree","file":"{}","symbol":"leaf","depth":1}}"#,
root.join("leaf.ts").display()
));
assert_eq!(
tree["success"], true,
"call_tree should accept private leaf: {tree:?}"
);
let callers = aft.send(&format!(
r#"{{"id":"2","command":"callers","file":"{}","symbol":"leaf","depth":1}}"#,
root.join("leaf.ts").display()
));
assert_eq!(
callers["success"], true,
"callers should accept private leaf: {callers:?}"
);
assert_eq!(
callers["total_callers"], 1,
"leaf should have one caller: {callers:?}"
);
aft.shutdown();
}
#[test]
fn callgraph_detects_pnpm_workspace_and_skips_empty_nested_workspaces() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("pnpm-workspace.yaml"),
"packages:\n - 'packages/*'\n",
)
.unwrap();
let pkg = root.join("packages/lib");
let app = root.join("packages/app");
fs::create_dir_all(pkg.join("src")).unwrap();
fs::create_dir_all(app.join("src")).unwrap();
fs::write(pkg.join("package.json"), r#"{"name":"@scope/lib"}"#).unwrap();
fs::write(
pkg.join("src/index.ts"),
"export function pnpmTarget(): string { return 'ok'; }\n",
)
.unwrap();
fs::write(
app.join("package.json"),
r#"{"name":"app","workspaces":[]}"#,
)
.unwrap();
fs::write(app.join("src/main.ts"), "import { pnpmTarget } from '@scope/lib';\nexport function run(): string { return pnpmTarget(); }\n").unwrap();
let mut aft = AftProcess::spawn();
configure_project(&mut aft, root);
let resp = aft.send(&format!(
r#"{{"id":"1","command":"callers","file":"{}","symbol":"pnpmTarget","depth":1}}"#,
pkg.join("src/index.ts").display()
));
assert_eq!(resp["success"], true, "callers should succeed: {resp:?}");
assert_eq!(
resp["total_callers"], 1,
"pnpm workspace root should resolve past empty nested workspaces: {resp:?}"
);
aft.shutdown();
}
#[test]
fn callgraph_workspace_globs_support_recursive_patterns_and_negations() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("package.json"),
r#"{"private":true,"workspaces":["packages/**","!packages/legacy"]}"#,
)
.unwrap();
let recursive = root.join("packages/nested/pkg");
let legacy = root.join("packages/legacy");
let app = root.join("app");
fs::create_dir_all(recursive.join("src")).unwrap();
fs::create_dir_all(legacy.join("src")).unwrap();
fs::create_dir_all(app.join("src")).unwrap();
fs::write(
recursive.join("package.json"),
r#"{"name":"@scope/recursive"}"#,
)
.unwrap();
fs::write(
recursive.join("src/index.ts"),
"export function recursiveTarget(): string { return 'ok'; }\n",
)
.unwrap();
fs::write(legacy.join("package.json"), r#"{"name":"@scope/legacy"}"#).unwrap();
fs::write(
legacy.join("src/index.ts"),
"export function legacyTarget(): string { return 'legacy'; }\n",
)
.unwrap();
fs::write(app.join("package.json"), r#"{"name":"app"}"#).unwrap();
fs::write(app.join("src/main.ts"), "import { recursiveTarget } from '@scope/recursive';\nimport { legacyTarget } from '@scope/legacy';\nexport function run(): string { recursiveTarget(); return legacyTarget(); }\n").unwrap();
let mut aft = AftProcess::spawn();
configure_project(&mut aft, root);
let resp = aft.send(&format!(
r#"{{"id":"1","command":"callers","file":"{}","symbol":"recursiveTarget","depth":1}}"#,
recursive.join("src/index.ts").display()
));
assert_eq!(
resp["success"], true,
"recursive callers should succeed: {resp:?}"
);
assert_eq!(
resp["total_callers"], 1,
"recursive glob should include nested package: {resp:?}"
);
let resp = aft.send(&format!(
r#"{{"id":"2","command":"callers","file":"{}","symbol":"legacyTarget","depth":1}}"#,
legacy.join("src/index.ts").display()
));
assert_eq!(
resp["success"], true,
"legacy callers should succeed: {resp:?}"
);
assert_eq!(
resp["total_callers"], 0,
"negated workspace should not resolve: {resp:?}"
);
aft.shutdown();
}
#[test]
fn callgraph_resolves_tsconfig_paths_aliases() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::create_dir_all(root.join("src/lib")).unwrap();
fs::write(
root.join("tsconfig.json"),
r#"{"compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}}}"#,
)
.unwrap();
fs::write(
root.join("src/lib/target.ts"),
"export function pathTarget(): string { return 'ok'; }\n",
)
.unwrap();
fs::write(root.join("src/main.ts"), "import { pathTarget } from '@/lib/target';\nexport function run(): string { return pathTarget(); }\n").unwrap();
let mut aft = AftProcess::spawn();
configure_project(&mut aft, root);
let resp = aft.send(&format!(
r#"{{"id":"1","command":"callers","file":"{}","symbol":"pathTarget","depth":1}}"#,
root.join("src/lib/target.ts").display()
));
assert_eq!(resp["success"], true, "callers should succeed: {resp:?}");
assert_eq!(
resp["total_callers"], 1,
"tsconfig paths alias should resolve: {resp:?}"
);
aft.shutdown();
}
#[test]
fn callgraph_computed_member_call_uses_static_property_name() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(root.join("computed.ts"), "export function caller(obj: any): void { obj[\"method\"](); }\nfunction method(): void {}\n").unwrap();
let mut aft = AftProcess::spawn();
configure_project(&mut aft, root);
let resp = aft.send(&format!(
r#"{{"id":"1","command":"callers","file":"{}","symbol":"method","depth":1}}"#,
root.join("computed.ts").display()
));
assert_eq!(resp["success"], true, "callers should succeed: {resp:?}");
assert_eq!(
resp["total_callers"], 1,
"computed member should be indexed as method: {resp:?}"
);
aft.shutdown();
}
#[test]
fn callgraph_keeps_external_member_call_with_same_short_name_as_caller() {
let temp = tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("math.ts"),
"export function add(): number { return 1; }\n",
)
.unwrap();
fs::write(root.join("app.ts"), "import * as math from './math.js';\nexport function add(): number { return math.add(); }\n").unwrap();
let mut aft = AftProcess::spawn();
configure_project(&mut aft, root);
let resp = aft.send(&format!(
r#"{{"id":"1","command":"callers","file":"{}","symbol":"add","depth":1}}"#,
root.join("math.ts").display()
));
assert_eq!(resp["success"], true, "callers should succeed: {resp:?}");
assert_eq!(
resp["total_callers"], 1,
"external math.add call should not be filtered as self-recursion: {resp:?}"
);
aft.shutdown();
}
#[test]
fn callgraph_trace_to_not_configured() {
let mut aft = AftProcess::spawn();
let resp =
aft.send(r#"{"id":"1","command":"trace_to","file":"helpers.ts","symbol":"checkFormat"}"#);
assert_eq!(resp["success"], false);
assert_eq!(resp["code"], "not_configured");
aft.shutdown();
}
#[test]
fn callgraph_trace_to_symbol_not_found() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
let resp = aft.send(&format!(
r#"{{"id":"2","command":"trace_to","file":"{}/helpers.ts","symbol":"nonexistent"}}"#,
root
));
assert_eq!(
resp["success"], false,
"unknown symbol should fail: {:?}",
resp
);
assert_eq!(resp["code"], "symbol_not_found");
aft.shutdown();
}
#[test]
fn callgraph_trace_to_single_path() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
let resp = aft.send(&format!(
r#"{{"id":"2","command":"trace_to","file":"{}/helpers.ts","symbol":"checkFormat","depth":10}}"#,
root
));
assert_eq!(resp["success"], true, "trace_to should succeed: {:?}", resp);
assert_eq!(resp["target_symbol"], "checkFormat");
assert!(resp["target_file"].as_str().unwrap().contains("helpers.ts"));
let paths = resp["paths"].as_array().expect("paths should be array");
assert!(
!paths.is_empty(),
"checkFormat should have at least one path to an entry point"
);
let main_path = paths.iter().find(|p| {
let hops = p["hops"].as_array().unwrap();
!hops.is_empty() && hops[0]["symbol"] == "main"
});
assert!(
main_path.is_some(),
"should have a path starting from main, paths: {:?}",
paths
);
let hops = main_path.unwrap()["hops"].as_array().unwrap();
let last = &hops[hops.len() - 1];
assert_eq!(
last["symbol"], "checkFormat",
"path should end at checkFormat"
);
assert_eq!(hops[0]["line"], 3, "entry point line should be 1-based");
assert_eq!(last["line"], 5, "target line should be 1-based");
assert!(resp["total_paths"].as_u64().unwrap() >= 1);
assert!(resp["entry_points_found"].as_u64().unwrap() >= 1);
aft.shutdown();
}
#[test]
fn callgraph_trace_to_multi_path() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
let resp = aft.send(&format!(
r#"{{"id":"2","command":"trace_to","file":"{}/helpers.ts","symbol":"validate","depth":10}}"#,
root
));
assert_eq!(resp["success"], true, "trace_to should succeed: {:?}", resp);
assert_eq!(resp["target_symbol"], "validate");
let total_paths = resp["total_paths"].as_u64().unwrap();
assert!(
total_paths >= 2,
"validate should have multiple paths to entry points, got {}",
total_paths
);
let entry_points_found = resp["entry_points_found"].as_u64().unwrap();
assert!(
entry_points_found >= 2,
"validate should have multiple distinct entry points, got {}",
entry_points_found
);
let paths = resp["paths"].as_array().expect("paths should be array");
let entry_names: Vec<&str> = paths
.iter()
.filter_map(|p| {
let hops = p["hops"].as_array()?;
hops.first().and_then(|h| h["symbol"].as_str())
})
.collect();
assert!(
entry_names.contains(&"main") || entry_names.contains(&"handleRequest"),
"should have main or handleRequest as entry point, got: {:?}",
entry_names
);
for path in paths {
let hops = path["hops"].as_array().unwrap();
let last = &hops[hops.len() - 1];
assert_eq!(
last["symbol"], "validate",
"every path should end at validate"
);
}
aft.shutdown();
}
#[test]
fn callgraph_trace_to_no_entry_points() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
let resp = aft.send(&format!(
r#"{{"id":"2","command":"trace_to","file":"{}/main.ts","symbol":"main","depth":10}}"#,
root
));
assert_eq!(
resp["success"], true,
"trace_to on entry point should succeed: {:?}",
resp
);
assert_eq!(resp["target_symbol"], "main");
let paths = resp["paths"].as_array().expect("paths should be array");
for path in paths {
let hops = path["hops"].as_array().unwrap();
let has_main = hops.iter().any(|h| h["symbol"] == "main");
assert!(has_main, "any path for main should include main");
}
assert!(resp.get("total_paths").is_some());
assert!(resp.get("entry_points_found").is_some());
assert!(resp.get("truncated_paths").is_some());
aft.shutdown();
}
#[test]
fn callgraph_default_import_targets_real_default_export_name() {
let tmp = tempfile::tempdir().expect("create temp dir");
let root = tmp.path();
let main = root.join("main.ts");
let helper = root.join("helper.ts");
fs::write(
&main,
"import helper from './helper';\n\nexport function main() {\n return helper();\n}\n",
)
.expect("write main");
fs::write(
&helper,
"export default function realName() {\n return 1;\n}\n",
)
.expect("write helper");
let mut aft = AftProcess::spawn();
let root_str = root.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root_str
));
assert_eq!(resp["success"], true, "configure should succeed: {resp:?}");
let resp = aft.send(&format!(
r#"{{"id":"2","command":"call_tree","file":"{}","symbol":"main","depth":2}}"#,
main.display()
));
assert_eq!(resp["success"], true, "call_tree should succeed: {resp:?}");
let children = resp["children"].as_array().expect("children array");
let default_child = children
.iter()
.find(|child| {
child["file"]
.as_str()
.is_some_and(|file| file.ends_with("helper.ts"))
})
.expect("default import call should resolve into helper.ts");
assert_eq!(default_child["resolved"], true);
assert_eq!(default_child["name"], "realName");
assert_ne!(default_child["name"], "default");
aft.shutdown();
}
fn setup_watcher_fixture() -> (tempfile::TempDir, String) {
let fixtures = fixture_path("callgraph");
let tmp = tempfile::tempdir().expect("create temp dir");
for entry in std::fs::read_dir(&fixtures).expect("read fixtures dir") {
let entry = entry.expect("read entry");
let src = entry.path();
if src.is_file() {
let dst = tmp.path().join(entry.file_name());
std::fs::copy(&src, &dst).expect("copy fixture file");
}
}
let root = tmp.path().display().to_string();
(tmp, root)
}
fn poll_watcher_update<F>(
aft: &mut AftProcess,
query: &str,
predicate: F,
description: &str,
) -> serde_json::Value
where
F: Fn(&serde_json::Value) -> bool,
{
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
let poll_interval = std::time::Duration::from_millis(100);
let mut last_response = serde_json::Value::Null;
let mut ping_id = 1000;
while std::time::Instant::now() < deadline {
ping_id += 1;
aft.send(&format!(r#"{{"id":"ping-{}","command":"ping"}}"#, ping_id));
let resp = aft.send(query);
if predicate(&resp) {
return resp;
}
last_response = resp;
std::thread::sleep(poll_interval);
}
panic!(
"watcher update did not propagate within 15s: {}\nlast response: {:?}",
description, last_response
);
}
#[test]
fn callgraph_watcher_add_caller() {
let (_tmp, root) = setup_watcher_fixture();
let mut aft = AftProcess::spawn();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
assert_eq!(
resp["success"], true,
"configure should succeed: {:?}",
resp
);
let resp = aft.send(&format!(
r#"{{"id":"2","command":"callers","file":"{}/helpers.ts","symbol":"validate","depth":1}}"#,
root
));
assert_eq!(
resp["success"], true,
"initial callers should succeed: {:?}",
resp
);
let initial_total = resp["total_callers"].as_u64().unwrap();
assert!(initial_total > 0, "validate should have initial callers");
std::thread::sleep(std::time::Duration::from_millis(250));
let new_file = std::path::Path::new(&root).join("extra_caller.ts");
std::fs::write(
&new_file,
r#"import { validate } from './helpers';
export function extraCheck(input: string): boolean {
return validate(input);
}
"#,
)
.expect("write new caller file");
let query = format!(
r#"{{"id":"4","command":"callers","file":"{}/helpers.ts","symbol":"validate","depth":1}}"#,
root
);
let resp = poll_watcher_update(
&mut aft,
&query,
|r| {
r["success"] == true
&& r["total_callers"].as_u64().unwrap_or(0) > initial_total
&& r["callers"]
.as_array()
.map(|cs| {
cs.iter()
.any(|g| g["file"].as_str().unwrap_or("").contains("extra_caller.ts"))
})
.unwrap_or(false)
},
"extra_caller.ts should appear as a new caller of validate",
);
let new_total = resp["total_callers"].as_u64().unwrap();
assert!(
new_total > initial_total,
"adding a caller should increase total_callers: initial={}, new={}",
initial_total,
new_total
);
aft.shutdown();
}
#[test]
fn callgraph_watcher_remove_caller() {
let (_tmp, root) = setup_watcher_fixture();
let mut aft = AftProcess::spawn();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
assert_eq!(
resp["success"], true,
"configure should succeed: {:?}",
resp
);
let resp = aft.send(&format!(
r#"{{"id":"2","command":"callers","file":"{}/helpers.ts","symbol":"validate","depth":1}}"#,
root
));
assert_eq!(
resp["success"], true,
"initial callers should succeed: {:?}",
resp
);
let callers = resp["callers"].as_array().expect("callers array");
let utils_group = callers
.iter()
.find(|g| g["file"].as_str().unwrap_or("").contains("utils.ts"));
assert!(
utils_group.is_some(),
"validate should initially be called from utils.ts"
);
let utils_path = std::path::Path::new(&root).join("utils.ts");
std::fs::write(
&utils_path,
r#"export function processData(input: string): string {
// validate call removed
return input.toUpperCase();
}
"#,
)
.expect("rewrite utils.ts");
let query = format!(
r#"{{"id":"4","command":"callers","file":"{}/helpers.ts","symbol":"validate","depth":1}}"#,
root
);
poll_watcher_update(
&mut aft,
&query,
|r| {
if r["success"] != true {
return false;
}
let callers = match r["callers"].as_array() {
Some(cs) => cs,
None => return false,
};
let utils_group = callers
.iter()
.find(|g| g["file"].as_str().unwrap_or("").contains("utils.ts"));
match utils_group {
None => true, Some(group) => group["callers"]
.as_array()
.map(|entries| {
entries
.iter()
.all(|e| e["callee"].as_str().unwrap_or("") != "validate")
})
.unwrap_or(false),
}
},
"validate call should be removed from utils.ts after rewrite",
);
aft.shutdown();
}
#[test]
fn callgraph_impact_not_configured() {
let mut aft = AftProcess::spawn();
let resp = aft.send(r#"{"id":"1","command":"impact","file":"helpers.ts","symbol":"validate"}"#);
assert_eq!(resp["success"], false);
assert_eq!(resp["code"], "not_configured");
aft.shutdown();
}
#[test]
fn callgraph_impact_symbol_not_found() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
let resp = aft.send(&format!(
r#"{{"id":"2","command":"impact","file":"{}/helpers.ts","symbol":"nonexistent"}}"#,
root
));
assert_eq!(
resp["success"], false,
"unknown symbol should fail: {:?}",
resp
);
assert_eq!(resp["code"], "symbol_not_found");
aft.shutdown();
}
#[test]
fn callgraph_impact_multi_caller() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
let resp = aft.send(&format!(
r#"{{"id":"2","command":"impact","file":"{}/helpers.ts","symbol":"validate","depth":5}}"#,
root
));
assert_eq!(resp["success"], true, "impact should succeed: {:?}", resp);
assert_eq!(resp["symbol"], "validate");
assert!(resp["file"].as_str().unwrap().contains("helpers.ts"));
let total_affected = resp["total_affected"].as_u64().unwrap();
assert!(
total_affected >= 2,
"validate should have at least 2 affected callers, got {}",
total_affected
);
let affected_files = resp["affected_files"].as_u64().unwrap();
assert!(
affected_files >= 2,
"validate should affect at least 2 files, got {}",
affected_files
);
let callers = resp["callers"].as_array().expect("callers array");
assert!(!callers.is_empty(), "callers should not be empty");
for caller in callers {
assert!(
caller.get("caller_symbol").is_some(),
"caller should have caller_symbol: {:?}",
caller
);
assert!(
caller.get("caller_file").is_some(),
"caller should have caller_file: {:?}",
caller
);
assert!(
caller.get("line").is_some(),
"caller should have line: {:?}",
caller
);
assert!(
caller["line"].as_u64().unwrap_or(0) >= 1,
"caller line should be 1-based: {:?}",
caller
);
assert!(
caller.get("is_entry_point").is_some(),
"caller should have is_entry_point: {:?}",
caller
);
assert!(
caller.get("parameters").is_some(),
"caller should have parameters: {:?}",
caller
);
}
let has_entry_point = callers
.iter()
.any(|c| c["is_entry_point"].as_bool() == Some(true));
assert!(
has_entry_point,
"at least one caller should be an entry point, callers: {:?}",
callers
);
assert!(
resp.get("signature").is_some(),
"target should have a signature"
);
let params = resp["parameters"].as_array().expect("parameters array");
assert!(
params.iter().any(|p| p.as_str() == Some("input")),
"validate parameters should include 'input', got: {:?}",
params
);
aft.shutdown();
}
#[test]
fn callgraph_trace_data_not_configured() {
let mut aft = AftProcess::spawn();
let resp = aft.send(
r#"{"id":"1","command":"trace_data","file":"data_flow.ts","symbol":"transformData","expression":"rawInput"}"#,
);
assert_eq!(resp["success"], false);
assert_eq!(resp["code"], "not_configured");
aft.shutdown();
}
#[test]
fn callgraph_trace_data_symbol_not_found() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
let resp = aft.send(&format!(
r#"{{"id":"2","command":"trace_data","file":"{}/data_flow.ts","symbol":"nonexistent","expression":"x"}}"#,
root
));
assert_eq!(
resp["success"], false,
"unknown symbol should fail: {:?}",
resp
);
assert_eq!(resp["code"], "symbol_not_found");
aft.shutdown();
}
#[test]
fn callgraph_trace_data_assignment_tracking() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
let resp = aft.send(&format!(
r#"{{"id":"2","command":"trace_data","file":"{}/data_flow.ts","symbol":"transformData","expression":"rawInput","depth":5}}"#,
root
));
assert_eq!(
resp["success"], true,
"trace_data should succeed: {:?}",
resp
);
assert_eq!(resp["expression"], "rawInput");
assert!(
resp["origin_file"]
.as_str()
.unwrap()
.contains("data_flow.ts"),
"origin_file should reference data_flow.ts"
);
assert_eq!(resp["origin_symbol"], "transformData");
let hops = resp["hops"].as_array().expect("hops array");
assert!(
!hops.is_empty(),
"should have at least one hop (assignment rawInput → cleaned)"
);
let first = &hops[0];
assert_eq!(
first["flow_type"], "assignment",
"first hop should be assignment"
);
assert_eq!(first["variable"], "cleaned", "should track to 'cleaned'");
assert_eq!(
first["approximate"], false,
"direct assignment is not approximate"
);
assert_eq!(first["line"], 4, "assignment line should be 1-based");
aft.shutdown();
}
#[test]
fn callgraph_trace_data_cross_file() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
let resp = aft.send(&format!(
r#"{{"id":"2","command":"trace_data","file":"{}/data_flow.ts","symbol":"transformData","expression":"rawInput","depth":5}}"#,
root
));
assert_eq!(
resp["success"], true,
"trace_data should succeed: {:?}",
resp
);
let hops = resp["hops"].as_array().expect("hops array");
assert!(
hops.len() >= 2,
"should have at least 2 hops (assignment + cross-file parameter), got {}: {:?}",
hops.len(),
hops
);
let has_param_hop = hops.iter().any(|h| {
h["flow_type"] == "parameter"
&& h["file"]
.as_str()
.map(|f| f.contains("data_processor.ts"))
.unwrap_or(false)
});
assert!(
has_param_hop,
"should have a parameter hop into data_processor.ts, hops: {:?}",
hops
);
let param_hop = hops.iter().find(|h| {
h["flow_type"] == "parameter"
&& h["file"]
.as_str()
.map(|f| f.contains("data_processor.ts"))
.unwrap_or(false)
});
if let Some(ph) = param_hop {
assert_eq!(
ph["variable"], "input",
"parameter should be 'input' (processInput's parameter)"
);
assert_eq!(ph["approximate"], false);
assert_eq!(ph["line"], 1, "parameter line should be 1-based");
}
aft.shutdown();
}
#[test]
fn callgraph_trace_data_approximation() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
let resp = aft.send(&format!(
r#"{{"id":"2","command":"trace_data","file":"{}/data_flow.ts","symbol":"complexFlow","expression":"data","depth":5}}"#,
root
));
assert_eq!(
resp["success"], true,
"trace_data should succeed: {:?}",
resp
);
let hops = resp["hops"].as_array().expect("hops array");
let has_approximate = hops.iter().any(|h| h["approximate"] == true);
assert!(
has_approximate,
"destructuring should produce an approximate hop, hops: {:?}",
hops
);
aft.shutdown();
}
#[test]
fn callgraph_navigation_rejects_paths_outside_project_root() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
let outside = tempfile::tempdir().expect("create outside temp dir");
let outside_file = outside.path().join("outside.ts");
fs::write(
&outside_file,
r#"export function outside(value: string): string {
const copied = value;
return copied;
}
"#,
)
.expect("write outside file");
let outside_path = outside_file.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
assert_eq!(resp["success"], true, "configure should succeed: {resp:?}");
let requests = [
format!(
r#"{{"id":"2","command":"callers","file":"{}","symbol":"outside"}}"#,
outside_path
),
format!(
r#"{{"id":"3","command":"impact","file":"{}","symbol":"outside"}}"#,
outside_path
),
format!(
r#"{{"id":"4","command":"trace_to","file":"{}","symbol":"outside"}}"#,
outside_path
),
format!(
r#"{{"id":"5","command":"trace_data","file":"{}","symbol":"outside","expression":"value"}}"#,
outside_path
),
];
for request in requests {
let resp = aft.send(&request);
assert_eq!(
resp["success"], false,
"outside project_root request should fail: {request} -> {resp:?}"
);
assert_eq!(
resp["code"], "path_outside_project_root",
"outside project_root request should use path_outside_project_root: {request} -> {resp:?}"
);
}
aft.shutdown();
}
#[test]
fn callgraph_configure_small_repo_does_not_flag_exceeds() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}"}}"#,
root
));
assert_eq!(
resp["success"], true,
"configure should succeed: {:?}",
resp
);
assert_eq!(
resp["source_file_count_exceeds_max"], false,
"small fixture with default cap should NOT be flagged as exceeding"
);
let count = resp["source_file_count"].as_u64().unwrap_or(0);
assert!(
count > 0 && count < 100,
"small fixture should report a real (non-capped) count, got {}",
count
);
aft.shutdown();
}
#[test]
fn callgraph_configure_reports_source_file_count_exceeds_max() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}","max_callgraph_files":1}}"#,
root
));
assert_eq!(
resp["success"], true,
"configure should succeed: {:?}",
resp
);
assert_eq!(
resp["source_file_count_exceeds_max"], true,
"9-file fixture with cap=1 should be flagged as exceeding max"
);
assert_eq!(resp["max_callgraph_files"], 1);
aft.shutdown();
}
#[test]
fn callgraph_callers_project_too_large() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}","max_callgraph_files":1}}"#,
root
));
assert_eq!(resp["success"], true);
let resp = aft.send(&format!(
r#"{{"id":"2","command":"callers","file":"{}/helpers.ts","symbol":"validate","depth":1}}"#,
root
));
assert_eq!(resp["success"], false);
assert_eq!(resp["code"], "project_too_large");
let msg = resp["message"].as_str().unwrap_or("");
assert!(
msg.contains("max_callgraph_files"),
"error message should mention max_callgraph_files: {}",
msg
);
aft.shutdown();
}
#[test]
fn callgraph_trace_to_project_too_large() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}","max_callgraph_files":1}}"#,
root
));
assert_eq!(resp["success"], true);
let resp = aft.send(&format!(
r#"{{"id":"2","command":"trace_to","file":"{}/helpers.ts","symbol":"validate","depth":5}}"#,
root
));
assert_eq!(resp["success"], false);
assert_eq!(resp["code"], "project_too_large");
aft.shutdown();
}
#[test]
fn callgraph_impact_project_too_large() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}","max_callgraph_files":1}}"#,
root
));
assert_eq!(resp["success"], true);
let resp = aft.send(&format!(
r#"{{"id":"2","command":"impact","file":"{}/helpers.ts","symbol":"validate"}}"#,
root
));
assert_eq!(resp["success"], false);
assert_eq!(resp["code"], "project_too_large");
aft.shutdown();
}
#[test]
fn callgraph_trace_data_project_too_large() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}","max_callgraph_files":1}}"#,
root
));
assert_eq!(resp["success"], true);
let resp = aft.send(&format!(
r#"{{"id":"2","command":"trace_data","file":"{}/data_flow.ts","symbol":"transformData","expression":"rawInput"}}"#,
root
));
assert_eq!(resp["success"], false);
assert_eq!(resp["code"], "project_too_large");
aft.shutdown();
}
#[test]
fn callgraph_configure_rejects_zero_max_callgraph_files() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}","max_callgraph_files":0}}"#,
root
));
assert_eq!(resp["success"], false, "configure should reject 0");
assert_eq!(resp["code"], "invalid_request");
let msg = resp["message"].as_str().unwrap_or("");
assert!(
msg.contains("max_callgraph_files"),
"error message should mention max_callgraph_files: {}",
msg
);
aft.shutdown();
}
#[test]
fn callgraph_configure_rejects_negative_max_callgraph_files() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}","max_callgraph_files":-5}}"#,
root
));
assert_eq!(resp["success"], false, "configure should reject -5");
assert_eq!(resp["code"], "invalid_request");
aft.shutdown();
}
#[test]
fn callgraph_configure_accepts_positive_max_callgraph_files() {
let mut aft = AftProcess::spawn();
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}","max_callgraph_files":42}}"#,
root
));
assert_eq!(resp["success"], true);
assert_eq!(resp["max_callgraph_files"], 42);
aft.shutdown();
}
#[test]
fn callgraph_configure_rejects_non_integer_max_callgraph_files_payloads() {
let fixtures = fixture_path("callgraph");
let root = fixtures.display().to_string();
let rejected_payloads = [
("float", "1.5"),
("string", "\"twenty\""),
("numeric_string", "\"20000\""),
("bool_true", "true"),
("bool_false", "false"),
("null", "null"),
("array", "[]"),
("object", "{}"),
];
for (label, payload) in rejected_payloads {
let mut aft = AftProcess::spawn();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","project_root":"{}","max_callgraph_files":{}}}"#,
root, payload
));
assert_eq!(
resp["success"], false,
"configure should reject {label} payload ({payload})"
);
assert_eq!(
resp["code"], "invalid_request",
"configure should return invalid_request for {label} payload ({payload})"
);
let msg = resp["message"].as_str().unwrap_or("");
assert!(
msg.contains("max_callgraph_files"),
"error message should mention max_callgraph_files for {label}: {msg}"
);
aft.shutdown();
}
}