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
14 changes: 13 additions & 1 deletion crates/ci-core/src/analysis/hotspot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ pub struct HotspotSymbol {
pub is_hub: bool,
pub coreness: Option<i64>,
pub caller_count: i64,
/// Disambiguates two same-named symbols in the same file (e.g. a
/// `#[cfg(feature)]` real impl vs. its stub) — mirrors `symbol_info`,
/// which already carries these for the same reason.
pub line_start: i64,
pub line_end: i64,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -332,7 +337,7 @@ fn collect_complexity(conn: &Connection) -> HashMap<String, ComplexityInfo> {
fn query_top_symbols(conn: &Connection, path: &str) -> Vec<HotspotSymbol> {
let mut stmt = conn
.prepare(
"SELECT name, kind, is_hub, coreness, caller_count \
"SELECT name, kind, is_hub, coreness, caller_count, line_start, line_end \
FROM symbols WHERE path = ? \
ORDER BY COALESCE(caller_count, 0) DESC, coreness DESC \
LIMIT 5",
Expand All @@ -346,6 +351,8 @@ fn query_top_symbols(conn: &Connection, path: &str) -> Vec<HotspotSymbol> {
is_hub: row.get::<_, i32>(2).unwrap_or(0) != 0,
coreness: row.get(3)?,
caller_count: row.get::<_, i64>(4).unwrap_or(0),
line_start: row.get(5)?,
line_end: row.get(6)?,
})
})
.unwrap()
Expand Down Expand Up @@ -424,6 +431,11 @@ mod tests {
let syms = output.hotspots[0].top_symbols.as_ref().unwrap();
assert_eq!(syms.len(), 2);
assert_eq!(syms[0].name, "m.foo"); // higher caller_count first
// Regression: line_start/line_end must be carried through so two
// same-named symbols in one file (e.g. a #[cfg(feature)] real impl
// vs. its stub) are distinguishable, same as symbol_info already does.
assert_eq!(syms[0].line_start, 1);
assert_eq!(syms[0].line_end, 10);
}

fn insert_test_symbol(conn: &Connection, qname: &str, path: &str, coreness: i64) {
Expand Down
7 changes: 6 additions & 1 deletion crates/ci-core/src/db/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ CREATE TABLE IF NOT EXISTS call_sites (
confidence TEXT NOT NULL DEFAULT 'textual',
receiver TEXT,
target_class TEXT,
looks_option_or_result_chained INTEGER NOT NULL DEFAULT 0
looks_option_or_result_chained INTEGER NOT NULL DEFAULT 0,
module_hint TEXT
);
CREATE INDEX IF NOT EXISTS idx_call_sites_from ON call_sites(from_path);
CREATE INDEX IF NOT EXISTS idx_call_sites_callee ON call_sites(callee_name);
Expand Down Expand Up @@ -230,6 +231,10 @@ fn run_migrations(conn: &Connection) -> rusqlite::Result<()> {
"looks_option_or_result_chained",
"INTEGER NOT NULL DEFAULT 0",
)?;
// See parser::module_hint_of — the module-path segment of a
// lowercase-qualified `::`-call (`crate::telemetry::timed_tool`), used to
// disambiguate same-named candidates by file when there's no `use`.
migrate_add_column(conn, "call_sites", "module_hint", "TEXT")?;
conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_call_edges_to ON call_edges(to_symbol);")?;
// Set by the SCIP overlay (`ci_core::scip::ingest`) when a reference at a
// given call site is proven — via real type-checked evidence — to NOT be
Expand Down
7 changes: 7 additions & 0 deletions crates/ci-core/src/indexer/lang_constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ pub fn get_lang_constants(lang: &str) -> Option<LangConstants> {
"class_declaration",
"method_definition",
"lexical_declaration",
// TypeScript-only (never appear in the JS grammar, so no-op
// there): interface/type-alias declarations are otherwise
// invisible to the extractor entirely — a TS/DTO-only file
// would index as 0 symbols. See node_kind_to_symbol_kind for
// the SymbolKind mapping.
"interface_declaration",
"type_alias_declaration",
],
name_field: "name",
docstring_type: Some("comment"),
Expand Down
70 changes: 69 additions & 1 deletion crates/ci-core/src/indexer/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ fn node_kind_to_symbol_kind(node_kind: &str, in_class: bool) -> SymbolKind {
// `impl`/`impl Trait for` block without a corresponding consumer.
"impl_item" => SymbolKind::Impl,
"interface_declaration" => SymbolKind::Interface,
"type_declaration" => SymbolKind::Type,
// Go's `type_declaration` and TS's `type_alias_declaration` are
// distinct grammar node kinds for the same concept (a named type),
// so both map to the one SymbolKind here.
"type_declaration" | "type_alias_declaration" => SymbolKind::Type,
// Explicit method nodes are always Method regardless of scope.
"method_declaration" | "method_definition" => SymbolKind::Method,
// JS/TS `const foo = () => {}` — treated as a variable holding a function.
Expand Down Expand Up @@ -630,6 +633,14 @@ pub struct RawCall {
/// bare-name fan-out candidates whose own signature can't possibly be
/// `Option`/`Result` — see its `MAX_CALLEE_CANDIDATES` fallback.
pub looks_option_or_result_chained: bool,
/// The immediate module-path segment just before the callee, when the
/// whole callee expression is a lowercase-qualified `::`-path
/// (`crate::telemetry::timed_tool` → `Some("telemetry")`) with no `use`
/// bringing the name into `file_symbols`/`import_map` — see
/// `module_hint_of`. `None` for a `.`-receiver call, a type-qualified
/// `Type::method()` call (already carried via `receiver`), or a bare
/// unqualified name.
pub module_hint: Option<String>,
pub line: usize,
}

Expand Down Expand Up @@ -671,6 +682,37 @@ fn split_receiver_callee(raw: &str) -> Option<(Option<String>, String, bool)> {
}
}

/// The module-path segment `split_receiver_callee` discards for a lowercase
/// (non-type-like) `::`-qualified callee, e.g. `crate::telemetry::timed_tool`
/// or `telemetry::timed_tool` → `Some("telemetry")`.
///
/// Without this, a fully-qualified call to a module-level function with no
/// `use` importing it is textually indistinguishable from a bare unqualified
/// call of the same name — `resolve_tier1` matches purely on bare name, and
/// `rebuild_graph`'s same-file preference (see its doc comment) can then bind
/// to an unrelated same-named symbol that merely happens to share the
/// caller's own file, silently misresolving the edge (in the worst case, to
/// a phantom self-recursive edge on the caller itself) instead of the module
/// actually named in the source. Preserved separately from `receiver` so
/// tier-2 method resolution (which expects a variable/type name, not a
/// module) is unaffected — this is consumed only by `rebuild_graph`'s
/// candidate selection, as a same-strength-as-`same_file` tiebreak that
/// takes priority when present, since an explicit qualifier in the source
/// text is stronger evidence than incidental file collocation.
fn module_hint_of(raw: &str) -> Option<String> {
if raw.contains('.') {
return None; // dot-form's own receiver already covers this call
}
let idx = raw.rfind("::")?;
let (left, _) = raw.split_at(idx);
let seg = left.rsplit("::").next().and_then(leading_ident)?;
if is_type_like(&seg) {
None // type-qualified — already carried via `receiver`/`receiver_is_type_path`
} else {
Some(seg)
}
}

/// Heuristic: a path segment is "type-like" when it starts with an uppercase
/// letter, matching Rust/C#/Java/Kotlin/Swift convention for types/classes
/// (vs. snake_case modules or lowerCamelCase namespaces/packages). Not
Expand Down Expand Up @@ -795,6 +837,7 @@ fn walk_calls(
callee,
receiver,
receiver_is_type_path,
module_hint: module_hint_of(&source[fn_node.byte_range()]),
looks_option_or_result_chained: looks_option_or_result_chained(node, source),
line: node.start_position().row + 1,
});
Expand Down Expand Up @@ -1686,6 +1729,31 @@ function standalone() {}
assert_eq!(find(&symbols, "standalone").kind, SymbolKind::Function);
}

/// Regression: a TS/DTO-only file (all `export interface`/`export type`,
/// no functions/classes) used to extract 0 symbols — `interface_declaration`
/// and `type_alias_declaration` were missing from `function_node_types`,
/// making such a file entirely invisible to `file_overview`/`search`.
#[test]
fn test_typescript_interface_and_type_alias_extracted() {
let code = r#"
export interface FooRequest {
id: string;
count: number;
}

export type BarResponse = {
ok: boolean;
};

export type Baz = string | number;
"#;
let symbols = extract_symbols(code, "typescript", "mcp_types.ts").unwrap();
assert_eq!(symbols.len(), 3, "all three type-level declarations must be extracted");
assert_eq!(find(&symbols, "FooRequest").kind, SymbolKind::Interface);
assert_eq!(find(&symbols, "BarResponse").kind, SymbolKind::Type);
assert_eq!(find(&symbols, "Baz").kind, SymbolKind::Type);
}

#[test]
fn test_python_entry_point_decorator() {
let code = r#"
Expand Down
Loading
Loading