Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 70 additions & 3 deletions src/index/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,13 +417,20 @@ fn extract_rust(
let name = node_name(&gc, source);
if !name.is_empty() {
nodes.push(AstNode {
name,
name: name.clone(),
kind: SymbolKind::Function,
file: file_path.to_string(),
line: (gc.start_position().row + 1) as u32,
signature: signature_for_node(&gc, source),
parent: Some(type_n.clone()),
});
// Walk impl-method bodies for call
// edges too — without this,
// cross-crate callers of these
// methods are invisible (#519).
extract_calls_from_node(
&gc, source, file_path, &name, &mut edges,
);
}
}
if !dc.goto_next_sibling() {
Expand Down Expand Up @@ -455,6 +462,16 @@ fn extract_rust(
(nodes, edges)
}

/// Reduce a call target to its final name segment so it joins against symbol
/// names: method calls (`self.export_full`, `manager.remember()`) and
/// qualified paths (`std::mem::drop`) all become their bare final name (#519).
fn normalize_callee_name(raw: &str) -> &str {
raw.rsplit(['.', ':'])
.next()
.filter(|s| !s.is_empty())
.unwrap_or(raw)
}

/// Walk function body for call expressions using cursor-based DFS.
fn extract_calls_from_node(
node: &tree_sitter::Node,
Expand All @@ -472,12 +489,13 @@ fn extract_calls_from_node(
) {
if node.kind() == "call_expression" {
if let Some(fn_node) = node.child_by_field_name("function") {
let callee = node_text(&fn_node, source);
let raw = node_text(&fn_node, source);
let callee = normalize_callee_name(&raw);
if !callee.is_empty() {
edges.push(AstEdge {
source: caller.to_string(),
kind: EdgeKind::Calls,
target: callee,
target: callee.to_string(),
file: file_path.to_string(),
line: (node.start_position().row + 1) as u32,
});
Expand Down Expand Up @@ -2644,6 +2662,55 @@ export const processForm = (data: string) => {
);
}

/// Regression (#519): method calls (`self.export_full()`,
/// `manager.remember_with_contradiction()`) and qualified paths
/// (`std::mem::drop(...)`) must record the FINAL name segment as the
/// call target, so the edge joins against symbol names across files and
/// crates. Previously the raw text (`self.export_full`) was stored and
/// dead-code mis-flagged these symbols as uncalled.
#[test]
fn test_method_call_target_records_bare_name() {
let code = r#"
pub struct StructuralExporter;

impl StructuralExporter {
pub fn export_full(&self) -> String { String::new() }
}

struct Manager;

impl Manager {
pub fn maintenance(&self, x: &StructuralExporter) {
let out = x.export_full();
std::mem::drop(out);
}
}
"#;
let (nodes, edges) = extract(code, "rs", "maintenance.rs");
assert!(nodes.iter().any(|n| n.name == "export_full"));

// Method-call edge lands on the bare method name…
assert!(
edges.iter().any(|e| e.source == "maintenance"
&& e.target == "export_full"
&& e.kind == EdgeKind::Calls),
"expected Calls edge to bare name 'export_full', got: {:?}",
edges
.iter()
.filter(|e| e.kind == EdgeKind::Calls)
.map(|e| &e.target)
.collect::<Vec<_>>()
);

// …and qualified paths resolve to their final segment too.
assert!(
edges
.iter()
.any(|e| e.source == "maintenance" && e.target == "drop"),
"expected Calls edge to 'drop' from std::mem::drop"
);
}

#[test]
fn test_extract_svelte_line_numbers() {
let code = r#"<script lang="ts">
Expand Down
116 changes: 114 additions & 2 deletions src/index/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,10 @@ pub struct DeadCodeOptions {
/// Each entry is a SQL LIKE pattern (e.g. `%Handler`, `%Listener`, `%Route`).
/// These are checked against symbol names in addition to WELL_KNOWN_NAMES.
pub entry_point_patterns: Vec<String>,
/// Include public API surface (`pub`/`export` items) in dead code results.
/// By default these are skipped: their purpose is external consumption,
/// so absence of internal callers does not make them dead (#520).
pub include_pub_api: bool,
}

/// Well-known symbol names that should not be flagged as dead code even when
Expand Down Expand Up @@ -798,6 +802,19 @@ pub fn find_dead_code(
sql.push_str(" AND s.name NOT LIKE 'test_%' AND s.name NOT LIKE '%_test'");
}

// Exclude public API surface unless explicitly requested (#520): `pub`
// (Rust, incl. `pub(crate)`) and `export` (TS/JS) items are consumed
// externally, so missing internal callers does not make them dead.
if !opts.include_pub_api {
sql.push_str(
" AND (s.signature IS NULL OR (
s.signature NOT LIKE 'pub %'
AND s.signature NOT LIKE 'pub(%'
AND s.signature NOT LIKE 'export %'
))",
);
}

// Exclude symbols with `// cora: keep` suppression marker in their signature.
sql.push_str(" AND (s.signature IS NULL OR s.signature NOT LIKE '%cora: keep%')");

Expand Down Expand Up @@ -1339,8 +1356,7 @@ mod tests {
pid,
&DeadCodeOptions {
include_tests: true,
min_lines: None,
entry_point_patterns: vec![],
..Default::default()
},
)
.unwrap();
Expand All @@ -1355,4 +1371,100 @@ mod tests {
assert_eq!(orphan.line, 20);
assert_eq!(orphan.reason, "no callers found");
}

/// Regression (#519): a symbol defined in one crate/file and called via
/// method syntax (`obj.remember_with_contradiction()`) from another
/// crate's file must NOT be flagged dead. Mirrors the real uteke
/// workspace case: definition in uteke-core, caller in uteke-cli.
#[test]
fn test_dead_code_resolves_cross_file_method_calls() {
use super::super::index_file;

let conn = mem_conn();
let pid = test_project(&conn);

// "uteke-core": the definition.
index_file(
&conn,
pid,
"crates/core/src/consolidate.rs",
r#"
pub fn remember_with_contradiction(content: &str) -> usize { content.len() }
"#,
"rs",
)
.unwrap();

// "uteke-cli": a cross-crate caller using method syntax.
index_file(
&conn,
pid,
"crates/cli/src/commands/maintenance.rs",
r#"
pub fn maintenance() -> usize {
let store = Store;
store.remember_with_contradiction("note")
}
"#,
"rs",
)
.unwrap();

let dead = find_dead_code(&conn, pid, &DeadCodeOptions::default()).unwrap();
assert!(
!dead.iter().any(|d| d.name == "remember_with_contradiction"),
"cross-crate method call must prevent false-positive dead code, got: {:?}",
dead.iter().map(|d| &d.name).collect::<Vec<_>>()
);
}

/// Regression (#520): public API surface is skipped by default — missing
/// internal callers does not make a `pub` item dead — and can be opted
/// back in with `include_pub_api`. Private helpers still get flagged.
#[test]
fn test_dead_code_skips_pub_api_by_default() {
use super::super::index_file;

let conn = mem_conn();
let pid = test_project(&conn);

index_file(
&conn,
pid,
"src/lib.rs",
r#"
pub async fn chunk_markdown_embed_aware(text: &str) -> usize { text.len() }

fn internal_only_helper() -> u8 { 7 }
"#,
"rs",
)
.unwrap();

// Default: pub items are treated as API surface and skipped.
let dead = find_dead_code(&conn, pid, &DeadCodeOptions::default()).unwrap();
let names: Vec<&str> = dead.iter().map(|d| d.name.as_str()).collect();
assert!(
!names.contains(&"chunk_markdown_embed_aware"),
"pub fn must be skipped by default"
);
assert!(
names.contains(&"internal_only_helper"),
"private helper without callers is dead"
);

// Opt-in: pub items are reported again.
let all = find_dead_code(
&conn,
pid,
&DeadCodeOptions {
include_pub_api: true,
..Default::default()
},
)
.unwrap();
let names_all: Vec<&str> = all.iter().map(|d| d.name.as_str()).collect();
assert!(names_all.contains(&"chunk_markdown_embed_aware"));
assert!(names_all.contains(&"internal_only_helper"));
}
}
17 changes: 12 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,11 @@ enum Command {
/// Include test functions (test_*, _test) in results
#[clap(long)]
include_tests: bool,
/// Include public API surface (pub/export items) in results.
/// By default they are skipped: they are consumed externally, so
/// missing internal callers does not make them dead (#520).
#[clap(long)]
include_pub: bool,
/// Minimum lines of code (filter out tiny functions)
#[clap(long)]
min_lines: Option<u32>,
Expand Down Expand Up @@ -1591,15 +1596,16 @@ async fn main() -> Result<()> {
}
Command::DeadCode {
include_tests,
include_pub,
min_lines,
json,
} => {
let conn = index::open_global_index()?;
// Resolve the project root the same way `cora index` does, so
// dead-code queries the workspace the index actually built (#522).
let cwd = std::env::current_dir().with_context(|| "failed to get cwd")?;
let project_id = index::schema::get_or_create_project(
&conn,
cwd.to_str().with_context(|| "invalid UTF-8 in cwd path")?,
)?;
let project_root = index::resolve_project_root(&cwd).unwrap_or(cwd.clone());
let conn = index::open_global_index()?;
let project_id = index::ensure_project(&conn, &project_root)?;

// Load config for entry_point_patterns
let config = crate::config::loader::load_config(
Expand All @@ -1617,6 +1623,7 @@ async fn main() -> Result<()> {
include_tests,
min_lines,
entry_point_patterns,
include_pub_api: include_pub,
};
let results = index::graph::find_dead_code(&conn, project_id, &opts)?;
if json {
Expand Down
8 changes: 7 additions & 1 deletion src/mcp/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,11 +211,12 @@ pub fn list_tools() -> Vec<Tool> {
// ─── Dead Code Detection ───
Tool {
name: "cora.dead_code".to_string(),
description: "Find potentially dead code — functions/methods with no callers in the codebase.".to_string(),
description: "Find potentially dead code — functions/methods with no callers in the codebase. Public API surface (pub/export) is skipped by default.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"include_tests": { "type": "boolean", "description": "Include test functions in results" },
"include_pub_api": { "type": "boolean", "description": "Include public API surface (pub/export items); skipped by default since they are consumed externally" },
"min_lines": { "type": "integer", "description": "Minimum lines of code to report" }
},
"required": []
Expand Down Expand Up @@ -1019,6 +1020,10 @@ fn handle_dead_code(params: &serde_json::Value) -> ToolResult {
.get("include_tests")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let include_pub_api = params
.get("include_pub_api")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let min_lines = params
.get("min_lines")
.and_then(|v| v.as_u64())
Expand All @@ -1028,6 +1033,7 @@ fn handle_dead_code(params: &serde_json::Value) -> ToolResult {
include_tests,
min_lines,
entry_point_patterns: vec![],
include_pub_api,
};

match crate::index::graph::find_dead_code(&conn, project_id, &opts) {
Expand Down
Loading