use std::collections::HashMap;
use serde_json::{json, Value};
use crate::errors::Result;
use crate::redundancy::{
composite_similarity, compute_fingerprint, find_node_at_exact_range, jaccard_similarity,
overlap_kind, parse_file, severity_bucket, tokenize, Fingerprint,
};
use crate::tokensave::TokenSave;
use crate::types::{Node, NodeKind};
use super::super::ToolResult;
use super::{effective_path, truncate_response};
pub(super) async fn handle_redundancy(
cg: &TokenSave,
args: Value,
scope_prefix: Option<&str>,
) -> Result<ToolResult> {
let path_prefix = effective_path(&args, scope_prefix);
let min_lines = args
.get("min_lines")
.and_then(Value::as_u64)
.map_or(8u32, |v| u32::try_from(v).unwrap_or(8));
let max_pairs = args
.get("max_pairs")
.and_then(Value::as_u64)
.map_or(20usize, |v| usize::try_from(v.min(500)).unwrap_or(20));
let threshold = args
.get("similarity_threshold")
.and_then(Value::as_f64)
.unwrap_or(0.6)
.clamp(0.0, 1.0);
let include_naming = args
.get("include_naming_only")
.and_then(Value::as_bool)
.unwrap_or(false);
let nodes = collect_candidates(cg, path_prefix, min_lines).await?;
let total_candidates = nodes.len();
let fingerprints = ensure_fingerprints(cg, &nodes).await?;
let scanned = fingerprints.len();
let pairs = find_redundant_pairs(&nodes, &fingerprints, threshold, include_naming, max_pairs);
let pair_count = pairs.len();
let output = json!({
"candidates": total_candidates,
"scanned": scanned,
"skipped_for_size": total_candidates.saturating_sub(scanned),
"pair_count": pair_count,
"pairs": pairs,
"ranked_by": "similarity desc",
"scope": path_prefix.unwrap_or("(whole project)"),
"thresholds": {
"min_lines": min_lines,
"similarity_threshold": threshold,
"include_naming_only": include_naming,
},
});
let formatted = serde_json::to_string_pretty(&output).unwrap_or_default();
Ok(ToolResult {
value: json!({
"content": [{ "type": "text", "text": truncate_response(&formatted) }]
}),
touched_files: vec![],
})
}
async fn collect_candidates(
cg: &TokenSave,
path_prefix: Option<&str>,
min_lines: u32,
) -> Result<Vec<Node>> {
let mut filter = crate::db::NodeFilter::new()
.kinds(&[
NodeKind::Function,
NodeKind::Method,
NodeKind::SingletonMethod,
])
.min_lines(min_lines);
if let Some(prefix) = path_prefix {
filter = filter.path_prefix(prefix);
}
cg.db().get_nodes_filtered(&filter).await
}
async fn ensure_fingerprints(
cg: &TokenSave,
candidates: &[Node],
) -> Result<HashMap<String, Fingerprint>> {
let registry = crate::extraction::LanguageRegistry::new();
let project_root = cg.project_root().to_path_buf();
let mut by_file: HashMap<String, Vec<&Node>> = HashMap::new();
for n in candidates {
by_file.entry(n.file_path.clone()).or_default().push(n);
}
let mut out: HashMap<String, Fingerprint> = HashMap::new();
for (file_path, file_nodes) in by_file {
let Some(extractor) =
crate::project_manifest::resolve_extractor(®istry, &project_root, &file_path)
else {
continue;
};
let lang_key = extractor_to_language_key(extractor.language_name());
let Some(lang_key) = lang_key else {
continue;
};
let abs = project_root.join(&file_path);
let Ok(source) = std::fs::read_to_string(&abs) else {
continue;
};
let mut needs_parse = false;
let mut cached: HashMap<&str, Fingerprint> = HashMap::new();
for node in &file_nodes {
let body = body_bytes(
&source,
node.start_line,
node.start_column,
node.end_line,
node.end_column,
);
let expected_hash = quick_body_hash(body);
match cg.db().get_fingerprint(&node.id).await? {
Some(stored) if stored.source_hash == expected_hash => {
let live_body_tokens = tokenize(body).len();
let poisoned = stored.body_tokens as usize != live_body_tokens;
if poisoned {
needs_parse = true;
} else {
cached.insert(
node.id.as_str(),
Fingerprint {
ast_hash: stored.ast_hash,
cfg_hash: stored.cfg_hash,
call_seq_hash: stored.call_seq_hash,
shingles: stored.shingles,
body_tokens: stored.body_tokens as usize,
source_hash: stored.source_hash,
},
);
}
}
_ => {
needs_parse = true;
}
}
}
for (id, fp) in cached {
out.insert(id.to_string(), fp);
}
if !needs_parse {
continue;
}
let language = crate::extraction::ts_provider::language(lang_key);
let Some(tree) = parse_file(&source, &language) else {
continue;
};
for node in &file_nodes {
if out.contains_key(&node.id) {
continue;
}
let Some(ts_node) = find_node_at_exact_range(
&tree,
node.start_line,
node.start_column,
node.end_line,
node.end_column,
) else {
continue;
};
let fp = compute_fingerprint(&source, ts_node);
if let Err(e) = cg.db().upsert_fingerprint(&node.id, &fp).await {
eprintln!("[tokensave] redundancy: upsert_fingerprint failed: {e}");
}
out.insert(node.id.clone(), fp);
}
}
Ok(out)
}
fn extractor_to_language_key(name: &str) -> Option<&'static str> {
Some(match name {
"Rust" => "rust",
"Go" => "go",
"Java" => "java",
"Scala" => "scala",
"TypeScript" => "typescript",
"TSX" => "tsx",
"Python" => "python",
"C" => "c",
"C++" => "cpp",
"C#" => "c_sharp",
"Kotlin" => "kotlin",
"Swift" => "swift",
"JavaScript" => "javascript",
"Ruby" => "ruby",
"PHP" => "php",
"Lua" => "lua",
"Zig" => "zig",
"Bash" => "bash",
"Dart" => "dart",
"Haskell" => "haskell",
"OCaml" => "ocaml",
"Elixir" => "elixir",
"Erlang" => "erlang",
"Clojure" => "clojure",
"F#" => "fsharp",
"Perl" => "perl",
"R" => "r",
"Julia" => "julia",
"Nix" => "nix",
_ => return None,
})
}
fn line_column_to_byte(source: &str, line: u32, column: u32) -> usize {
let target_line = line as usize;
let target_col = column as usize;
let mut offset = 0;
for (i, line_text) in source.split_inclusive('\n').enumerate() {
if i == target_line {
let col = target_col.min(line_text.len());
return offset + col;
}
offset += line_text.len();
}
offset
}
fn body_bytes(
source: &str,
start_line: u32,
start_column: u32,
end_line: u32,
end_column: u32,
) -> &str {
let start = line_column_to_byte(source, start_line, start_column);
let end = line_column_to_byte(source, end_line, end_column);
if end <= start || end > source.len() {
return "";
}
&source[start..end]
}
fn quick_body_hash(body: &str) -> String {
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
let mut h = Sha256::new();
h.update(body.as_bytes());
let d = h.finalize();
let mut s = String::with_capacity(16);
for b in d.iter().take(8) {
let _ = write!(s, "{b:02x}");
}
s
}
fn find_redundant_pairs(
nodes: &[Node],
fingerprints: &HashMap<String, Fingerprint>,
threshold: f64,
include_naming: bool,
max_pairs: usize,
) -> Vec<Value> {
let scope: Vec<(&Node, &Fingerprint)> = nodes
.iter()
.filter_map(|n| fingerprints.get(&n.id).map(|fp| (n, fp)))
.collect();
let mut sorted = scope;
sorted.sort_by_key(|(_, fp)| fp.body_tokens);
let mut found: Vec<(f64, &str, &Node, &Node, &Fingerprint, &Fingerprint)> = Vec::new();
for (i, (node_a, fp_a)) in sorted.iter().enumerate() {
let lo = (fp_a.body_tokens as f64 * 0.75).floor() as usize;
let hi = (fp_a.body_tokens as f64 * 1.25).ceil() as usize;
for (node_b, fp_b) in sorted.iter().skip(i + 1) {
if fp_b.body_tokens > hi {
break; }
if fp_b.body_tokens < lo {
continue;
}
let score = composite_similarity(fp_a, fp_b);
if score < threshold {
continue;
}
let kind = overlap_kind(fp_a, fp_b);
if !include_naming && kind == "naming" {
continue;
}
found.push((score, kind, node_a, node_b, fp_a, fp_b));
}
}
found.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
found.truncate(max_pairs);
found
.into_iter()
.map(|(score, kind, na, nb, fp_a, fp_b)| {
let shingle_jaccard = jaccard_similarity(&fp_a.shingles, &fp_b.shingles);
let severity = severity_bucket(score, kind);
json!({
"similarity": (score * 10000.0).round() / 10000.0,
"severity": severity,
"overlap_kind": kind,
"a": {
"file": na.file_path,
"line": super::display_line(na.start_line),
"name": na.name,
"id": na.id,
},
"b": {
"file": nb.file_path,
"line": super::display_line(nb.start_line),
"name": nb.name,
"id": nb.id,
},
"signals": {
"ast_match": fp_a.ast_hash == fp_b.ast_hash,
"cfg_match": fp_a.cfg_hash == fp_b.cfg_hash,
"call_seq_match": fp_a.call_seq_hash == fp_b.call_seq_hash,
"shingle_jaccard": (shingle_jaccard * 10000.0).round() / 10000.0,
"body_tokens": [fp_a.body_tokens, fp_b.body_tokens],
},
})
})
.collect()
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
#[allow(clippy::expect_used)]
mod tests {
use super::body_bytes;
use super::find_redundant_pairs;
use super::quick_body_hash;
#[test]
fn body_bytes_exact_range() {
let src = "int add(int a, int b) {\n return a + b;\n}\n";
let body = body_bytes(src, 0, 0, 2, 1);
assert_eq!(body, "int add(int a, int b) {\n return a + b;\n}");
assert!(
!body.ends_with('\n'),
"exact byte range must NOT include trailing newline (matches utf8_text)"
);
}
#[test]
fn body_bytes_comment_after_close_brace() {
let src = "fn foo() {} // trailing\n";
let body = body_bytes(src, 0, 0, 0, 11);
assert_eq!(body, "fn foo() {}");
assert!(
!body.contains("//"),
"comment after }} must be excluded from exact byte range"
);
}
#[test]
fn body_bytes_indented_function() {
let src = "mod m;\n fn bar() {}\n";
let body = body_bytes(src, 1, 2, 1, 13);
assert_eq!(body, "fn bar() {}");
}
#[test]
fn body_bytes_mid_line_node() {
let src = "class C { int get() { return 42; } }\n";
let body = body_bytes(src, 0, 20, 0, 34);
assert_eq!(body, "{ return 42; }");
}
#[test]
fn body_bytes_hash_is_deterministic() {
let src = "fn validate(x: i32) -> bool {\n x > 0\n}\n";
let body = body_bytes(src, 0, 0, 2, 1);
let h = quick_body_hash(body);
assert!(!h.is_empty());
assert_eq!(h, quick_body_hash(body), "same input → same hash");
}
#[test]
fn body_bytes_crlf_line_endings() {
let src = "fn foo() {\r\n bar()\r\n}\r\n";
let body = body_bytes(src, 0, 0, 2, 1);
assert_eq!(body, "fn foo() {\r\n bar()\r\n}");
assert!(!body.ends_with('\n'), "body must not include trailing \\n");
assert!(!body.ends_with('\r'), "body must not include trailing \\r");
}
#[test]
fn body_bytes_handles_out_of_bounds() {
let src = "alpha\nbeta\n";
assert_eq!(body_bytes(src, 5, 0, 9, 0), "");
}
use super::ensure_fingerprints;
use crate::redundancy::{composite_similarity, tokenize, Fingerprint};
use crate::tokensave::TokenSave;
use crate::types::{Node, NodeKind, Visibility};
fn function_node(
id: &str,
name: &str,
file_path: &str,
start_line: u32,
end_line: u32,
start_column: u32,
end_column: u32,
) -> Node {
Node {
id: id.to_string(),
kind: NodeKind::Function,
name: name.to_string(),
qualified_name: name.to_string(),
file_path: file_path.to_string(),
start_line,
attrs_start_line: start_line,
end_line,
start_column,
end_column,
signature: None,
docstring: None,
visibility: Visibility::Private,
is_async: false,
branches: 1,
loops: 0,
returns: 1,
max_nesting: 0,
unsafe_blocks: 0,
unchecked_calls: 0,
assertions: 0,
cognitive_complexity: 0,
distinct_operators: 2,
distinct_operands: 2,
total_operators: 2,
total_operands: 2,
updated_at: 1,
parent_id: None,
}
}
const TWO_FNS: &str =
"fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n\nfn sub(a: i32, b: i32) -> i32 {\n a - b\n}\n";
const SMALL_FN_WITH_EXTRAS: &str =
"fn small() -> i32 {\n 1\n}\n\n// trailing comment\ntype Alias = u32;\n";
#[tokio::test]
async fn fingerprints_stored_and_read_from_db() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("lib.rs"), TWO_FNS).unwrap();
let cg = TokenSave::init(tmp.path()).await.unwrap();
let nodes = vec![
function_node("node:add", "add", "lib.rs", 0, 2, 0, 1),
function_node("node:sub", "sub", "lib.rs", 4, 6, 0, 1),
];
for n in &nodes {
cg.db().insert_node(n).await.unwrap();
}
let fingerprints = ensure_fingerprints(&cg, &nodes).await.unwrap();
assert_eq!(fingerprints.len(), 2);
assert!(fingerprints.contains_key("node:add"));
assert!(fingerprints.contains_key("node:sub"));
let stored_add = cg
.db()
.get_fingerprint("node:add")
.await
.unwrap()
.expect("add fingerprint must be in db");
let stored_sub = cg
.db()
.get_fingerprint("node:sub")
.await
.unwrap()
.expect("sub fingerprint must be in db");
assert!(!stored_add.ast_hash.is_empty());
assert!(!stored_add.source_hash.is_empty());
assert!(!stored_add.cfg_hash.is_empty());
assert!(!stored_sub.ast_hash.is_empty());
assert_eq!(stored_add.ast_hash, fingerprints["node:add"].ast_hash);
assert_eq!(stored_sub.ast_hash, fingerprints["node:sub"].ast_hash);
assert_eq!(stored_add.source_hash, fingerprints["node:add"].source_hash);
}
#[tokio::test]
async fn second_invocation_is_cache_hit() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("lib.rs"), TWO_FNS).unwrap();
let cg = TokenSave::init(tmp.path()).await.unwrap();
let nodes = vec![function_node("node:add", "add", "lib.rs", 0, 2, 0, 1)];
cg.db().insert_node(&nodes[0]).await.unwrap();
let fp1 = ensure_fingerprints(&cg, &nodes).await.unwrap();
let real_hash = fp1["node:add"].ast_hash.clone();
assert!(!real_hash.is_empty());
let stored = cg.db().get_fingerprint("node:add").await.unwrap().unwrap();
let correct_source_hash = stored.source_hash.clone();
let fake_fp = Fingerprint {
ast_hash: "cached-hit-0000000000".to_string(),
cfg_hash: stored.cfg_hash.clone(),
call_seq_hash: stored.call_seq_hash.clone(),
shingles: vec![99, 88, 77],
body_tokens: stored.body_tokens as usize,
source_hash: correct_source_hash,
};
cg.db()
.upsert_fingerprint("node:add", &fake_fp)
.await
.unwrap();
let fp2 = ensure_fingerprints(&cg, &nodes).await.unwrap();
assert_eq!(
fp2["node:add"].ast_hash, "cached-hit-0000000000",
"second call must return cached (fake) hash, not recompute"
);
assert_eq!(fp2["node:add"].shingles, vec![99, 88, 77]);
}
#[tokio::test]
async fn poisoned_row_triggers_recompute() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("lib.rs"), SMALL_FN_WITH_EXTRAS).unwrap();
let cg = TokenSave::init(tmp.path()).await.unwrap();
let nodes = vec![function_node("node:small", "small", "lib.rs", 0, 2, 0, 1)];
cg.db().insert_node(&nodes[0]).await.unwrap();
let fp1 = ensure_fingerprints(&cg, &nodes).await.unwrap();
let real_body_tokens = fp1["node:small"].body_tokens;
let real_ast_hash = fp1["node:small"].ast_hash.clone();
let source = std::fs::read_to_string(tmp.path().join("lib.rs")).unwrap();
let whole_tokens = tokenize(&source).len();
assert!(
real_body_tokens < whole_tokens,
"precondition: small() body is smaller than whole file ({real_body_tokens} < {whole_tokens})"
);
let stored = cg
.db()
.get_fingerprint("node:small")
.await
.unwrap()
.unwrap();
let correct_source_hash = stored.source_hash.clone();
let poison_fp = Fingerprint {
ast_hash: "poison-ast-0000000000".to_string(),
cfg_hash: stored.cfg_hash.clone(),
call_seq_hash: stored.call_seq_hash.clone(),
shingles: vec![],
body_tokens: whole_tokens, source_hash: correct_source_hash, };
cg.db()
.upsert_fingerprint("node:small", &poison_fp)
.await
.unwrap();
let fp2 = ensure_fingerprints(&cg, &nodes).await.unwrap();
assert_ne!(
fp2["node:small"].ast_hash, "poison-ast-0000000000",
"poisoned row must be rejected; ast_hash must be recomputed"
);
assert_eq!(
fp2["node:small"].body_tokens, real_body_tokens,
"recomputed body_tokens must match real function body size"
);
assert_eq!(
fp2["node:small"].ast_hash, real_ast_hash,
"recomputed ast_hash must match original"
);
}
#[tokio::test]
#[allow(clippy::float_cmp)]
async fn phantom_pair_disappears_after_recompute() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("lib.rs"), TWO_FNS).unwrap();
let cg = TokenSave::init(tmp.path()).await.unwrap();
let nodes = vec![
function_node("node:add", "add", "lib.rs", 0, 2, 0, 1),
function_node("node:sub", "sub", "lib.rs", 4, 6, 0, 1),
];
for n in &nodes {
cg.db().insert_node(n).await.unwrap();
}
let source = std::fs::read_to_string(tmp.path().join("lib.rs")).unwrap();
let add_body = body_bytes(&source, 0, 0, 2, 1);
let sub_body = body_bytes(&source, 4, 0, 6, 1);
let add_source_hash = quick_body_hash(add_body);
let sub_source_hash = quick_body_hash(sub_body);
assert_ne!(
add_source_hash, sub_source_hash,
"different bodies must have different source hashes"
);
let base_poison = Fingerprint {
ast_hash: "identical-ast-deadbeef".to_string(),
cfg_hash: "identical-cfg-cafebabe".to_string(),
call_seq_hash: "identical-call-12345678".to_string(),
shingles: vec![1, 2, 3, 4, 5],
body_tokens: 0, source_hash: String::new(), };
let poison_add = Fingerprint {
source_hash: add_source_hash.clone(),
..base_poison.clone()
};
let poison_sub = Fingerprint {
source_hash: sub_source_hash.clone(),
..base_poison
};
cg.db()
.upsert_fingerprint("node:add", &poison_add)
.await
.unwrap();
cg.db()
.upsert_fingerprint("node:sub", &poison_sub)
.await
.unwrap();
assert_eq!(
composite_similarity(&poison_add, &poison_sub),
1.0,
"planted fingerprints with identical non-source signals produce similarity 1.0"
);
let fingerprints = ensure_fingerprints(&cg, &nodes).await.unwrap();
let add_fp = &fingerprints["node:add"];
let sub_fp = &fingerprints["node:sub"];
assert_ne!(
add_fp.ast_hash, "identical-ast-deadbeef",
"add fingerprint must be recomputed (poison rejected)"
);
assert_ne!(
sub_fp.ast_hash, "identical-ast-deadbeef",
"sub fingerprint must be recomputed (poison rejected)"
);
let pairs = find_redundant_pairs(&nodes, &fingerprints, 0.95, true, 10);
let has_phantom = pairs.iter().any(|p| {
let score = p["similarity"].as_f64().unwrap_or(0.0);
score >= 1.0
});
assert!(
!has_phantom,
"phantom 1.0-similarity pair must disappear after poison recovery.\npairs: {pairs:?}"
);
let real_score = composite_similarity(add_fp, sub_fp);
assert!(
real_score < 1.0,
"recomputed fingerprints for different functions must have similarity < 1.0, got {real_score}"
);
}
#[tokio::test]
async fn same_line_functions_get_correct_exact_range() {
let src = "fn a() {} fn b() {}\n";
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("lib.rs"), src).unwrap();
let cg = TokenSave::init(tmp.path()).await.unwrap();
let nodes = vec![
function_node("node:a", "a", "lib.rs", 0, 0, 0, 9),
function_node("node:b", "b", "lib.rs", 0, 0, 10, 19),
];
for n in &nodes {
cg.db().insert_node(n).await.unwrap();
}
let fp1 = ensure_fingerprints(&cg, &nodes).await.unwrap();
assert_eq!(fp1.len(), 2);
let a_fp = &fp1["node:a"];
let b_fp = &fp1["node:b"];
let body_a = body_bytes(src, 0, 0, 0, 9); let body_b = body_bytes(src, 0, 10, 0, 19); assert_eq!(body_a, "fn a() {}");
assert_eq!(body_b, "fn b() {}");
assert_eq!(a_fp.source_hash, quick_body_hash(body_a));
assert_eq!(b_fp.source_hash, quick_body_hash(body_b));
assert_ne!(a_fp.source_hash, b_fp.source_hash);
let fake_a = Fingerprint {
ast_hash: "cache-hit-a-deadbeef".to_string(),
cfg_hash: a_fp.cfg_hash.clone(),
call_seq_hash: a_fp.call_seq_hash.clone(),
shingles: a_fp.shingles.clone(),
body_tokens: a_fp.body_tokens,
source_hash: a_fp.source_hash.clone(),
};
let fake_b = Fingerprint {
ast_hash: "cache-hit-b-cafebabe".to_string(),
cfg_hash: b_fp.cfg_hash.clone(),
call_seq_hash: b_fp.call_seq_hash.clone(),
shingles: b_fp.shingles.clone(),
body_tokens: b_fp.body_tokens,
source_hash: b_fp.source_hash.clone(),
};
cg.db().upsert_fingerprint("node:a", &fake_a).await.unwrap();
cg.db().upsert_fingerprint("node:b", &fake_b).await.unwrap();
let fp2 = ensure_fingerprints(&cg, &nodes).await.unwrap();
assert_eq!(
fp2["node:a"].ast_hash, "cache-hit-a-deadbeef",
"second invocation for node:a must be a cache hit"
);
assert_eq!(
fp2["node:b"].ast_hash, "cache-hit-b-cafebabe",
"second invocation for node:b must be a cache hit"
);
}
#[tokio::test]
async fn exact_range_lookup_selects_function_not_root_for_no_trailing_newline() {
let src = "fn f() {}";
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("lib.rs"), src).unwrap();
let cg = TokenSave::init(tmp.path()).await.unwrap();
let nodes = vec![function_node("node:f", "f", "lib.rs", 0, 0, 0, 9)];
cg.db().insert_node(&nodes[0]).await.unwrap();
let fingerprints = ensure_fingerprints(&cg, &nodes).await.unwrap();
let fp = &fingerprints["node:f"];
let lang = crate::extraction::ts_provider::language("rust");
let tree = crate::redundancy::parse_file(src, &lang).unwrap();
let root = tree.root_node();
let fn_node = root.named_child(0).expect("function_item child");
assert_eq!(fn_node.kind(), "function_item");
let expected_fp = crate::redundancy::compute_fingerprint(src, fn_node);
assert_eq!(
fp.ast_hash, expected_fp.ast_hash,
"ast_hash must come from function_item, not source_file"
);
assert_eq!(
fp.cfg_hash, expected_fp.cfg_hash,
"cfg_hash must come from function_item, not source_file"
);
assert_eq!(
fp.call_seq_hash, expected_fp.call_seq_hash,
"call_seq_hash must come from function_item, not source_file"
);
}
}