use kglite::api::GraphRead;
use kglite::api::{session, DirGraph, Value};
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::Path;
use std::time::Instant;
#[derive(Serialize)]
struct FunctionMetric {
path: String,
qualified_name: String,
start_line: i64,
end_line: i64,
branch_count: i64,
max_nesting: i64,
is_test: bool,
}
fn string_value(value: &Value) -> String {
match value {
Value::String(value) => value.clone(),
Value::Null => String::new(),
other => panic!("expected string metric, got {other:?}"),
}
}
fn int_value(value: &Value) -> i64 {
match value {
Value::Int64(value) => *value,
Value::UniqueId(value) => i64::from(*value),
Value::Null => 0,
other => panic!("expected integer metric, got {other:?}"),
}
}
fn bool_value(value: &Value) -> bool {
match value {
Value::Boolean(value) => *value,
Value::Null => false,
other => panic!("expected boolean metric, got {other:?}"),
}
}
fn function_metrics(graph: &kglite::api::DirGraph) -> Vec<FunctionMetric> {
let params = HashMap::new();
let options = session::ExecuteOptions::eager(¶ms);
let query = "MATCH (f:Function) RETURN f.file_path, f.qualified_name, \
f.line_number, f.end_line, f.branch_count, f.max_nesting, f.is_test";
let outcome = session::execute_read(graph, query, &options).expect("query function metrics");
let mut metrics: Vec<_> = outcome
.result
.rows
.iter()
.map(|row| FunctionMetric {
path: string_value(&row[0]),
qualified_name: string_value(&row[1]),
start_line: int_value(&row[2]),
end_line: int_value(&row[3]),
branch_count: int_value(&row[4]),
max_nesting: int_value(&row[5]),
is_test: bool_value(&row[6]),
})
.collect();
metrics.sort_by(|a, b| {
(&a.path, &a.qualified_name, a.start_line).cmp(&(&b.path, &b.qualified_name, b.start_line))
});
metrics
}
fn edge_breakdown_key(conn: &str, source_type: &str, target_type: &str) -> String {
if conn == "IMPORTS" {
format!("IMPORTS({source_type}->{target_type})")
} else {
conn.to_string()
}
}
fn edge_breakdown(graph: &DirGraph) -> BTreeMap<String, usize> {
let mut out: BTreeMap<String, usize> = BTreeMap::new();
for e in graph.graph.edge_indices() {
let Some(edge) = graph.graph.edge_weight(e) else {
continue;
};
let Some((s, t)) = graph.graph.edge_endpoints(e) else {
continue;
};
let (Some(sn), Some(tn)) = (graph.node_view(s), graph.node_view(t)) else {
continue;
};
let key = edge_breakdown_key(
edge.connection_type_str(&graph.interner),
sn.node_type_str(&graph.interner),
tn.node_type_str(&graph.interner),
);
*out.entry(key).or_insert(0) += 1;
}
out
}
fn short_name(qname: &str) -> &str {
let mut cut = 0usize;
for sep in ["::", ".", "/"] {
if let Some(i) = qname.rfind(sep) {
let after = i + sep.len();
if after > cut {
cut = after;
}
}
}
&qname[cut..]
}
fn id_string(id: std::borrow::Cow<'_, Value>) -> String {
match id.as_ref() {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn str_prop(props: &HashMap<String, Value>, key: &str) -> String {
match props.get(key) {
Some(Value::String(v)) => v.clone(),
_ => String::new(),
}
}
#[derive(Serialize, PartialEq, Eq, Debug)]
struct CallRow {
caller: String,
callee: String,
caller_file: String,
callee_file: String,
call_lines: String,
resolution: String,
candidates: i64,
import_backed: bool,
}
fn dump_calls(graph: &DirGraph, names: &BTreeSet<String>) -> Vec<CallRow> {
let mut rows: Vec<CallRow> = Vec::new();
for e in graph.graph.edge_indices() {
let Some(edge) = graph.graph.edge_weight(e) else {
continue;
};
if edge.connection_type_str(&graph.interner) != "CALLS" {
continue;
}
let Some((s, t)) = graph.graph.edge_endpoints(e) else {
continue;
};
let (Some(sn), Some(tn)) = (graph.node_view(s), graph.node_view(t)) else {
continue;
};
let callee = id_string(tn.id());
if !names.contains(short_name(&callee)) {
continue;
}
let props = edge.properties_cloned(&graph.interner);
rows.push(CallRow {
caller: id_string(sn.id()),
callee,
caller_file: str_prop(&sn.properties_cloned(&graph.interner), "file_path"),
callee_file: str_prop(&tn.properties_cloned(&graph.interner), "file_path"),
call_lines: str_prop(&props, "call_lines"),
resolution: str_prop(&props, "resolution"),
candidates: match props.get("candidates") {
Some(Value::Int64(v)) => *v,
_ => 0,
},
import_backed: matches!(props.get("import_backed"), Some(Value::Boolean(true))),
});
}
rows.sort_by(|a, b| (&a.callee, &a.caller).cmp(&(&b.callee, &b.caller)));
rows
}
const USAGE: &str = "usage: codingest_stats <path> [--include-tests] [--include-docs] \
[--function-metrics] [--edge-breakdown] [--dump-calls name1,name2,...]";
#[derive(Debug, Default, PartialEq, Eq)]
struct Options {
include_tests: bool,
include_docs: bool,
function_metrics: bool,
edge_breakdown: bool,
dump_calls: Option<BTreeSet<String>>,
}
fn parse_options(args: &[String]) -> Result<Options, String> {
let mut out = Options::default();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--include-tests" => out.include_tests = true,
"--include-docs" => out.include_docs = true,
"--function-metrics" => out.function_metrics = true,
"--edge-breakdown" => out.edge_breakdown = true,
"--dump-calls" => {
let list = args.get(i + 1).ok_or_else(|| {
"--dump-calls requires a comma-separated name list".to_string()
})?;
out.dump_calls = Some(
list.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
);
i += 1;
}
other => return Err(format!("unknown argument `{other}`")),
}
i += 1;
}
Ok(out)
}
fn main() {
let mut args = std::env::args().skip(1);
let path = match args.next() {
Some(p) => p,
None => {
eprintln!("{USAGE}");
std::process::exit(2);
}
};
let rest: Vec<String> = args.collect();
let options = match parse_options(&rest) {
Ok(options) => options,
Err(e) => {
eprintln!("{e}");
eprintln!("{USAGE}");
std::process::exit(2);
}
};
let include_tests = options.include_tests;
let include_docs = options.include_docs;
let emit_function_metrics = options.function_metrics;
let emit_edge_breakdown = options.edge_breakdown;
let dump_call_names = options.dump_calls.clone();
let t = Instant::now();
let (graph, stats) = match codingest::builder::run_with_options_stats(
Path::new(&path),
false,
include_tests,
None,
None,
include_docs,
) {
Ok(pair) => pair,
Err(e) => {
eprintln!("build failed: {e}");
std::process::exit(1);
}
};
let build_secs = t.elapsed().as_secs_f64();
if emit_function_metrics {
println!(
"{}",
serde_json::to_string_pretty(&function_metrics(&graph)).unwrap()
);
return;
}
if let Some(names) = &dump_call_names {
println!(
"{}",
serde_json::to_string_pretty(&dump_calls(&graph, names)).unwrap()
);
return;
}
let denom = stats.total_calls.saturating_sub(stats.excluded_noise);
let resolution_rate = if denom > 0 {
stats.resolved_call_sites as f64 / denom as f64
} else {
0.0
};
let mut out = serde_json::json!({
"path": path,
"include_tests": include_tests,
"include_docs": include_docs,
"build_secs": (build_secs * 1000.0).round() / 1000.0,
"nodes": graph.graph.node_count(),
"edges": graph.graph.edge_count(),
"total_calls": stats.total_calls,
"excluded_noise": stats.excluded_noise,
"no_candidate": stats.no_candidate,
"ambiguous_dropped": stats.ambiguous_dropped,
"resolved_call_sites": stats.resolved_call_sites,
"resolved_via_inheritance": stats.resolved_via_inheritance,
"resolved_edges": stats.resolved_edges,
"resolution_rate": (resolution_rate * 10000.0).round() / 10000.0,
});
if emit_edge_breakdown {
out["edge_breakdown"] = serde_json::to_value(edge_breakdown(&graph)).unwrap();
}
println!("{}", serde_json::to_string_pretty(&out).unwrap());
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn corpus(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("tests")
.join("corpus")
.join(name)
}
fn build(name: &str) -> std::sync::Arc<DirGraph> {
codingest::builder::run_with_options(&corpus(name), false, true, None, None, true)
.expect("corpus build")
}
fn opts(args: &[&str]) -> Result<Options, String> {
parse_options(&args.iter().map(|a| a.to_string()).collect::<Vec<_>>())
}
#[test]
fn parse_options_defaults_to_the_historical_configuration() {
let parsed = opts(&[]).expect("empty argv parses");
assert_eq!(parsed, Options::default());
assert!(!parsed.include_tests);
assert!(!parsed.include_docs);
}
#[test]
fn parse_options_reads_every_flag_including_docs() {
let parsed = opts(&[
"--include-tests",
"--include-docs",
"--edge-breakdown",
"--function-metrics",
"--dump-calls",
"run, helper ,,other",
])
.expect("all flags parse");
assert!(parsed.include_tests);
assert!(parsed.include_docs);
assert!(parsed.edge_breakdown);
assert!(parsed.function_metrics);
assert_eq!(
parsed.dump_calls,
Some(BTreeSet::from([
"run".to_string(),
"helper".to_string(),
"other".to_string()
])),
"the name list is split, trimmed and empties dropped"
);
}
#[test]
fn parse_options_rejects_an_unknown_flag_but_not_a_dump_calls_value() {
assert_eq!(
opts(&["--include-dcos"]),
Err("unknown argument `--include-dcos`".to_string())
);
assert_eq!(
opts(&["--dump-calls"]),
Err("--dump-calls requires a comma-separated name list".to_string())
);
let parsed = opts(&["--dump-calls", "--include-docs", "--include-tests"])
.expect("the value after --dump-calls is a value");
assert!(!parsed.include_docs, "the value was not read as a flag");
assert!(parsed.include_tests);
}
#[test]
fn include_docs_is_what_makes_doc_nodes_visible() {
fn doc_nodes(include_docs: bool) -> usize {
let graph = codingest::builder::run_with_options(
&corpus("docs_mdx"),
false,
true,
None,
None,
include_docs,
)
.expect("corpus build");
let params = HashMap::new();
let options = session::ExecuteOptions::eager(¶ms);
session::execute_read(&graph, "MATCH (d:Doc) RETURN d.name", &options)
.expect("query docs")
.result
.rows
.len()
}
assert_eq!(doc_nodes(false), 0, "docs off means no Doc node exists");
assert!(
doc_nodes(true) > 0,
"docs on must surface the docs_mdx corpus's Doc nodes"
);
}
#[test]
fn short_name_takes_the_rightmost_separator_of_any_flavour() {
assert_eq!(short_name("pkg.util.helper"), "helper");
assert_eq!(short_name("crate::graph::cypher::run"), "run");
assert_eq!(short_name("packages/opencode/src/mcp/fetch"), "fetch");
assert_eq!(short_name("bare"), "bare");
assert_eq!(short_name("web/api.ts::Client.send"), "send");
}
#[test]
fn edge_breakdown_splits_imports_and_accounts_for_every_edge() {
let graph = build("agc_basic");
let breakdown = edge_breakdown(&graph);
assert_eq!(
breakdown.values().sum::<usize>(),
graph.graph.edge_count(),
"breakdown must account for every edge: {breakdown:?}"
);
assert_eq!(
breakdown.get("IMPORTS(File->Module)").copied(),
Some(1),
"expected exactly one File→Module IMPORTS edge: {breakdown:?}"
);
assert_eq!(
breakdown.get("IMPORTS(File->File)").copied(),
Some(1),
"expected exactly one File→File IMPORTS edge: {breakdown:?}"
);
assert!(
!breakdown.contains_key("IMPORTS"),
"IMPORTS must always be split by endpoint, never reported bare: {breakdown:?}"
);
assert_eq!(breakdown.get("CALLS").copied(), Some(4));
assert_eq!(breakdown.get("DEFINES").copied(), Some(21));
}
#[test]
fn dump_calls_selects_by_callee_short_name_and_sorts() {
let graph = build("py_basic");
let rows = dump_calls(&graph, &BTreeSet::from(["helper".to_string()]));
assert_eq!(
rows,
vec![CallRow {
caller: "py_basic.pkg.app.run".into(),
callee: "py_basic.pkg.util.helper".into(),
caller_file: "pkg/app.py".into(),
callee_file: "pkg/util.py".into(),
call_lines: "5".into(),
resolution: "unique_name".into(),
candidates: 1,
import_backed: true,
}],
"expected the single helper CALLS edge"
);
assert!(dump_calls(&graph, &BTreeSet::from(["other".to_string()])).is_empty());
assert!(dump_calls(&graph, &BTreeSet::new()).is_empty());
let graph = build("agc_basic");
let all: BTreeSet<String> = graph
.graph
.edge_indices()
.filter_map(|e| {
let edge = graph.graph.edge_weight(e)?;
if edge.connection_type_str(&graph.interner) != "CALLS" {
return None;
}
let (_, t) = graph.graph.edge_endpoints(e)?;
Some(short_name(&id_string(graph.node_view(t)?.id())).to_string())
})
.collect();
assert!(all.len() > 1, "need several distinct callees to prove sort");
let rows = dump_calls(&graph, &all);
assert_eq!(rows.len(), 4, "every CALLS edge must be selected");
let keys: Vec<(String, String)> = rows
.iter()
.map(|r| (r.callee.clone(), r.caller.clone()))
.collect();
let mut sorted = keys.clone();
sorted.sort();
assert_eq!(keys, sorted, "dump must be (callee, caller)-sorted");
for row in &rows {
assert!(
all.contains(short_name(&row.callee)),
"row {row:?} escaped the name filter"
);
assert!(
!row.call_lines.is_empty(),
"row {row:?} lost its call_lines property"
);
assert!(
row.caller_file.ends_with(".agc") && row.callee_file.ends_with(".agc"),
"row {row:?} lost an endpoint file_path"
);
}
}
}