Skip to content

Commit 37ab72d

Browse files
authored
Merge pull request #18 from Eilodon/claude/dead-code-false-positives-lboozu
fix(indexer): resolve 4 dead-code/hub false-positive sources
2 parents 2fd999c + 4387a70 commit 37ab72d

7 files changed

Lines changed: 327 additions & 18 deletions

File tree

crates/ci-core/src/analysis/hotspot.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ pub struct HotspotSymbol {
2828
pub is_hub: bool,
2929
pub coreness: Option<i64>,
3030
pub caller_count: i64,
31+
/// Disambiguates two same-named symbols in the same file (e.g. a
32+
/// `#[cfg(feature)]` real impl vs. its stub) — mirrors `symbol_info`,
33+
/// which already carries these for the same reason.
34+
pub line_start: i64,
35+
pub line_end: i64,
3136
}
3237

3338
#[derive(Debug, Clone)]
@@ -332,7 +337,7 @@ fn collect_complexity(conn: &Connection) -> HashMap<String, ComplexityInfo> {
332337
fn query_top_symbols(conn: &Connection, path: &str) -> Vec<HotspotSymbol> {
333338
let mut stmt = conn
334339
.prepare(
335-
"SELECT name, kind, is_hub, coreness, caller_count \
340+
"SELECT name, kind, is_hub, coreness, caller_count, line_start, line_end \
336341
FROM symbols WHERE path = ? \
337342
ORDER BY COALESCE(caller_count, 0) DESC, coreness DESC \
338343
LIMIT 5",
@@ -346,6 +351,8 @@ fn query_top_symbols(conn: &Connection, path: &str) -> Vec<HotspotSymbol> {
346351
is_hub: row.get::<_, i32>(2).unwrap_or(0) != 0,
347352
coreness: row.get(3)?,
348353
caller_count: row.get::<_, i64>(4).unwrap_or(0),
354+
line_start: row.get(5)?,
355+
line_end: row.get(6)?,
349356
})
350357
})
351358
.unwrap()
@@ -424,6 +431,11 @@ mod tests {
424431
let syms = output.hotspots[0].top_symbols.as_ref().unwrap();
425432
assert_eq!(syms.len(), 2);
426433
assert_eq!(syms[0].name, "m.foo"); // higher caller_count first
434+
// Regression: line_start/line_end must be carried through so two
435+
// same-named symbols in one file (e.g. a #[cfg(feature)] real impl
436+
// vs. its stub) are distinguishable, same as symbol_info already does.
437+
assert_eq!(syms[0].line_start, 1);
438+
assert_eq!(syms[0].line_end, 10);
427439
}
428440

429441
fn insert_test_symbol(conn: &Connection, qname: &str, path: &str, coreness: i64) {

crates/ci-core/src/db/schema.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ CREATE TABLE IF NOT EXISTS call_sites (
8787
confidence TEXT NOT NULL DEFAULT 'textual',
8888
receiver TEXT,
8989
target_class TEXT,
90-
looks_option_or_result_chained INTEGER NOT NULL DEFAULT 0
90+
looks_option_or_result_chained INTEGER NOT NULL DEFAULT 0,
91+
module_hint TEXT
9192
);
9293
CREATE INDEX IF NOT EXISTS idx_call_sites_from ON call_sites(from_path);
9394
CREATE INDEX IF NOT EXISTS idx_call_sites_callee ON call_sites(callee_name);
@@ -230,6 +231,10 @@ fn run_migrations(conn: &Connection) -> rusqlite::Result<()> {
230231
"looks_option_or_result_chained",
231232
"INTEGER NOT NULL DEFAULT 0",
232233
)?;
234+
// See parser::module_hint_of — the module-path segment of a
235+
// lowercase-qualified `::`-call (`crate::telemetry::timed_tool`), used to
236+
// disambiguate same-named candidates by file when there's no `use`.
237+
migrate_add_column(conn, "call_sites", "module_hint", "TEXT")?;
233238
conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_call_edges_to ON call_edges(to_symbol);")?;
234239
// Set by the SCIP overlay (`ci_core::scip::ingest`) when a reference at a
235240
// given call site is proven — via real type-checked evidence — to NOT be

crates/ci-core/src/indexer/lang_constants.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ pub fn get_lang_constants(lang: &str) -> Option<LangConstants> {
5757
"class_declaration",
5858
"method_definition",
5959
"lexical_declaration",
60+
// TypeScript-only (never appear in the JS grammar, so no-op
61+
// there): interface/type-alias declarations are otherwise
62+
// invisible to the extractor entirely — a TS/DTO-only file
63+
// would index as 0 symbols. See node_kind_to_symbol_kind for
64+
// the SymbolKind mapping.
65+
"interface_declaration",
66+
"type_alias_declaration",
6067
],
6168
name_field: "name",
6269
docstring_type: Some("comment"),

crates/ci-core/src/indexer/parser.rs

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ fn node_kind_to_symbol_kind(node_kind: &str, in_class: bool) -> SymbolKind {
4545
// `impl`/`impl Trait for` block without a corresponding consumer.
4646
"impl_item" => SymbolKind::Impl,
4747
"interface_declaration" => SymbolKind::Interface,
48-
"type_declaration" => SymbolKind::Type,
48+
// Go's `type_declaration` and TS's `type_alias_declaration` are
49+
// distinct grammar node kinds for the same concept (a named type),
50+
// so both map to the one SymbolKind here.
51+
"type_declaration" | "type_alias_declaration" => SymbolKind::Type,
4952
// Explicit method nodes are always Method regardless of scope.
5053
"method_declaration" | "method_definition" => SymbolKind::Method,
5154
// JS/TS `const foo = () => {}` — treated as a variable holding a function.
@@ -630,6 +633,14 @@ pub struct RawCall {
630633
/// bare-name fan-out candidates whose own signature can't possibly be
631634
/// `Option`/`Result` — see its `MAX_CALLEE_CANDIDATES` fallback.
632635
pub looks_option_or_result_chained: bool,
636+
/// The immediate module-path segment just before the callee, when the
637+
/// whole callee expression is a lowercase-qualified `::`-path
638+
/// (`crate::telemetry::timed_tool` → `Some("telemetry")`) with no `use`
639+
/// bringing the name into `file_symbols`/`import_map` — see
640+
/// `module_hint_of`. `None` for a `.`-receiver call, a type-qualified
641+
/// `Type::method()` call (already carried via `receiver`), or a bare
642+
/// unqualified name.
643+
pub module_hint: Option<String>,
633644
pub line: usize,
634645
}
635646

@@ -671,6 +682,37 @@ fn split_receiver_callee(raw: &str) -> Option<(Option<String>, String, bool)> {
671682
}
672683
}
673684

685+
/// The module-path segment `split_receiver_callee` discards for a lowercase
686+
/// (non-type-like) `::`-qualified callee, e.g. `crate::telemetry::timed_tool`
687+
/// or `telemetry::timed_tool` → `Some("telemetry")`.
688+
///
689+
/// Without this, a fully-qualified call to a module-level function with no
690+
/// `use` importing it is textually indistinguishable from a bare unqualified
691+
/// call of the same name — `resolve_tier1` matches purely on bare name, and
692+
/// `rebuild_graph`'s same-file preference (see its doc comment) can then bind
693+
/// to an unrelated same-named symbol that merely happens to share the
694+
/// caller's own file, silently misresolving the edge (in the worst case, to
695+
/// a phantom self-recursive edge on the caller itself) instead of the module
696+
/// actually named in the source. Preserved separately from `receiver` so
697+
/// tier-2 method resolution (which expects a variable/type name, not a
698+
/// module) is unaffected — this is consumed only by `rebuild_graph`'s
699+
/// candidate selection, as a same-strength-as-`same_file` tiebreak that
700+
/// takes priority when present, since an explicit qualifier in the source
701+
/// text is stronger evidence than incidental file collocation.
702+
fn module_hint_of(raw: &str) -> Option<String> {
703+
if raw.contains('.') {
704+
return None; // dot-form's own receiver already covers this call
705+
}
706+
let idx = raw.rfind("::")?;
707+
let (left, _) = raw.split_at(idx);
708+
let seg = left.rsplit("::").next().and_then(leading_ident)?;
709+
if is_type_like(&seg) {
710+
None // type-qualified — already carried via `receiver`/`receiver_is_type_path`
711+
} else {
712+
Some(seg)
713+
}
714+
}
715+
674716
/// Heuristic: a path segment is "type-like" when it starts with an uppercase
675717
/// letter, matching Rust/C#/Java/Kotlin/Swift convention for types/classes
676718
/// (vs. snake_case modules or lowerCamelCase namespaces/packages). Not
@@ -795,6 +837,7 @@ fn walk_calls(
795837
callee,
796838
receiver,
797839
receiver_is_type_path,
840+
module_hint: module_hint_of(&source[fn_node.byte_range()]),
798841
looks_option_or_result_chained: looks_option_or_result_chained(node, source),
799842
line: node.start_position().row + 1,
800843
});
@@ -1686,6 +1729,31 @@ function standalone() {}
16861729
assert_eq!(find(&symbols, "standalone").kind, SymbolKind::Function);
16871730
}
16881731

1732+
/// Regression: a TS/DTO-only file (all `export interface`/`export type`,
1733+
/// no functions/classes) used to extract 0 symbols — `interface_declaration`
1734+
/// and `type_alias_declaration` were missing from `function_node_types`,
1735+
/// making such a file entirely invisible to `file_overview`/`search`.
1736+
#[test]
1737+
fn test_typescript_interface_and_type_alias_extracted() {
1738+
let code = r#"
1739+
export interface FooRequest {
1740+
id: string;
1741+
count: number;
1742+
}
1743+
1744+
export type BarResponse = {
1745+
ok: boolean;
1746+
};
1747+
1748+
export type Baz = string | number;
1749+
"#;
1750+
let symbols = extract_symbols(code, "typescript", "mcp_types.ts").unwrap();
1751+
assert_eq!(symbols.len(), 3, "all three type-level declarations must be extracted");
1752+
assert_eq!(find(&symbols, "FooRequest").kind, SymbolKind::Interface);
1753+
assert_eq!(find(&symbols, "BarResponse").kind, SymbolKind::Type);
1754+
assert_eq!(find(&symbols, "Baz").kind, SymbolKind::Type);
1755+
}
1756+
16891757
#[test]
16901758
fn test_python_entry_point_decorator() {
16911759
let code = r#"

0 commit comments

Comments
 (0)