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
47 changes: 40 additions & 7 deletions crates/codegraph-core/src/db/repository/graph_read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,41 @@ fn escape_like(s: &str) -> String {
out
}

/// Append an `AND (col LIKE ? [OR col LIKE ? ...])` fragment for a multi-value
/// file filter and push the corresponding escaped LIKE params. Mirrors
/// `buildFileConditionSQL()` / `NodeQuery.fileFilter()` in
/// `src/db/query-builder.ts`, which is why callers must accept `Vec<String>`
/// (repeatable `-f/--file`) rather than a single `String`. No-op when `files`
/// is empty. Advances `*idx` past the placeholders it consumes.
fn push_file_filter(
sql: &mut String,
params_v: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
idx: &mut usize,
column: &str,
files: &[String],
) {
if files.is_empty() {
return;
}
let start = *idx;
if files.len() == 1 {
sql.push_str(&format!(" AND {column} LIKE ?{start} ESCAPE '\\'"));
params_v.push(Box::new(format!("%{}%", escape_like(&files[0]))));
*idx += 1;
return;
}
let clauses: Vec<String> = files
.iter()
.enumerate()
.map(|(i, _)| format!("{column} LIKE ?{} ESCAPE '\\'", start + i))
.collect();
sql.push_str(&format!(" AND ({})", clauses.join(" OR ")));
for f in files {
params_v.push(Box::new(format!("%{}%", escape_like(f))));
}
*idx += files.len();
}

/// Check if a file path looks like a test file (mirrors `isTestFile` in JS).
fn is_test_file(file: &str) -> bool {
file.contains(".test.")
Expand Down Expand Up @@ -143,7 +178,7 @@ struct FnDepsCallerWithId {
fn build_fn_deps_match_query(
name: &str,
kind: Option<&str>,
file: Option<&str>,
file: Option<&[String]>,
) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
let default_kinds: Vec<String> = vec![
"function".to_string(),
Expand Down Expand Up @@ -180,8 +215,7 @@ fn build_fn_deps_match_query(
idx += kinds.len();
}
if let Some(f) = file {
sql.push_str(&format!(" AND n.file LIKE ?{idx} ESCAPE '\\'"));
params_v.push(Box::new(format!("%{}%", escape_like(f))));
push_file_filter(&mut sql, &mut params_v, &mut idx, "n.file", f);
}

(sql, params_v)
Expand Down Expand Up @@ -1086,7 +1120,7 @@ impl NativeDatabase {
&self,
name_pattern: String,
kinds: Option<Vec<String>>,
file: Option<String>,
file: Option<Vec<String>>,
) -> napi::Result<Vec<NativeNodeRowWithFanIn>> {
let conn = self.conn()?;

Expand All @@ -1112,8 +1146,7 @@ impl NativeDatabase {
}
}
if let Some(ref f) = file {
sql.push_str(&format!(" AND n.file LIKE ?{idx} ESCAPE '\\'"));
param_values.push(Box::new(format!("%{}%", escape_like(f))));
push_file_filter(&mut sql, &mut param_values, &mut idx, "n.file", f);
}

let mut stmt = conn
Expand Down Expand Up @@ -2114,7 +2147,7 @@ impl NativeDatabase {
name: String,
depth: Option<i32>,
no_tests: Option<bool>,
file: Option<String>,
file: Option<Vec<String>>,
kind: Option<String>,
) -> napi::Result<FnDepsResult> {
let conn = self.conn()?;
Expand Down
88 changes: 81 additions & 7 deletions crates/codegraph-core/src/graph/classifiers/roles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,55 @@ fn median(sorted: &[u32]) -> f64 {
}
}

/// Compute, per file, the set of symbol names that are `TYPE_DEF_KINDS`-kind
/// declarations (interface/type/struct/enum/trait/record). Mirrors JS
/// `computeTypeDefNamesByFile` in `graph/classifiers/roles.ts`.
fn compute_type_def_names_by_file(
rows: &[(i64, String, String, String, u32, u32)],
) -> HashMap<String, std::collections::HashSet<String>> {
let mut by_file: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
for (_id, name, kind, file, _fan_in, _fan_out) in rows {
if TYPE_DEF_KINDS.iter().any(|k| *k == kind.as_str()) {
by_file
.entry(file.clone())
.or_default()
.insert(name.clone());
}
}
by_file
}

/// True when `name`/`kind` is a `method`/`property` member of an interface/type
/// declared in the same file — e.g. TS `interface Foo { bar: string }` extracts
/// `bar` as a top-level `method`-kind definition named `Foo.bar` (#1723). Every
/// language extractor qualifies interface/type members as `Owner.member`
/// (mirroring class method qualification), so the owner name is recovered from
/// the prefix before the first `.` and looked up against same-file
/// `TYPE_DEF_KINDS` declarations. Class methods use the identical `Owner.member`
/// convention but are unaffected here because `class` is not in `TYPE_DEF_KINDS`
/// — they remain subject to normal dead-code detection.
///
/// These members can never gain inbound call edges by construction, so a
/// `fan_in == 0` reading carries zero dead-code signal for them, unlike a real
/// function/method where it does. Mirrors JS `isTypeDeclarationMember`.
fn is_type_declaration_member(
name: &str,
kind: &str,
file: &str,
type_def_names_by_file: &HashMap<String, std::collections::HashSet<String>>,
) -> bool {
if kind != "method" && kind != "property" {
return false;
}
let Some(dot_idx) = name.find('.') else {
return false;
};
let owner_name = &name[..dot_idx];
type_def_names_by_file
.get(file)
.is_some_and(|names| names.contains(owner_name))
}

/// Dead sub-role classification matching JS `classifyDeadSubRole`.
fn classify_dead_sub_role(_name: &str, kind: &str, file: &str) -> &'static str {
// Leaf kinds
Expand All @@ -126,6 +175,7 @@ fn classify_dead_sub_role(_name: &str, kind: &str, file: &str) -> &'static str {
}

/// Classify a single node into a role.
#[allow(clippy::too_many_arguments)]
fn classify_node(
name: &str,
kind: &str,
Expand All @@ -135,9 +185,16 @@ fn classify_node(
is_exported: bool,
production_fan_in: u32,
has_active_file_siblings: bool,
is_type_member: bool,
median_fan_in: f64,
median_fan_out: f64,
) -> &'static str {
// Interface/type members (#1723) — never subject to call-graph dead-code
// detection, regardless of fan-in/fan-out/export status.
if is_type_member {
return "leaf";
}

// Framework entry
if FRAMEWORK_ENTRY_PREFIXES.iter().any(|p| name.starts_with(p)) {
return "entry";
Expand Down Expand Up @@ -267,10 +324,14 @@ pub(crate) fn do_classify_full(conn: &Connection) -> rusqlite::Result<RoleSummar
let tx = conn.unchecked_transaction()?;
let mut summary = RoleSummary::default();

// 1. Leaf kinds -> dead-leaf
// 1. Property kind (class/struct fields) -> dead-leaf.
// `parameter` is deliberately NOT included here (#1723): a parameter's liveness
// is a local dataflow question, not a call-graph reachability question, so
// "no incoming call edges" carries zero dead-code signal for it. Parameters are
// also excluded from the main rows query below, so they never receive a role
// at all — the same treatment as `file`/`directory` nodes.
let leaf_ids: Vec<i64> = {
let mut stmt =
tx.prepare("SELECT id FROM nodes WHERE kind IN ('parameter', 'property')")?;
let mut stmt = tx.prepare("SELECT id FROM nodes WHERE kind = 'property'")?;
let rows = stmt.query_map([], |row| row.get::<_, i64>(0))?;
rows.filter_map(|r| r.ok()).collect()
};
Expand Down Expand Up @@ -408,6 +469,11 @@ pub(crate) fn do_classify_full(conn: &Connection) -> rusqlite::Result<RoleSummar
// 5b. Compute active files (files with non-constant callables connected to the graph)
let (active_files, called_active_files) = compute_active_files(&rows);

// 5c. Compute interface/type owner names per file (#1723) — used to recognize
// method/property members of type-level declarations, which must never be
// judged dead by call-graph reachability.
let type_def_names_by_file = compute_type_def_names_by_file(&rows);

// 6. Classify and collect IDs by role
let mut ids_by_role: HashMap<&str, Vec<i64>> = HashMap::new();

Expand All @@ -423,6 +489,7 @@ pub(crate) fn do_classify_full(conn: &Connection) -> rusqlite::Result<RoleSummar
&prod_fan_in,
&active_files,
&called_active_files,
&type_def_names_by_file,
median_fan_in,
median_fan_out,
&mut ids_by_role,
Expand Down Expand Up @@ -541,12 +608,14 @@ fn query_id_counts(
}

/// Classify rows and accumulate into ids_by_role and summary.
#[allow(clippy::too_many_arguments)]
fn classify_rows(
rows: &[(i64, String, String, String, u32, u32)],
exported_ids: &std::collections::HashSet<i64>,
prod_fan_in: &HashMap<i64, u32>,
active_files: &std::collections::HashSet<String>,
called_active_files: &std::collections::HashSet<String>,
type_def_names_by_file: &HashMap<String, std::collections::HashSet<String>>,
median_fan_in: f64,
median_fan_out: f64,
ids_by_role: &mut HashMap<&'static str, Vec<i64>>,
Expand All @@ -573,6 +642,7 @@ fn classify_rows(
} else {
false
};
let is_type_member = is_type_declaration_member(name, kind, file, type_def_names_by_file);
let role = classify_node(
name,
kind,
Expand All @@ -582,6 +652,7 @@ fn classify_rows(
is_exported,
prod_fi,
has_active_siblings,
is_type_member,
median_fan_in,
median_fan_out,
);
Expand Down Expand Up @@ -629,16 +700,15 @@ fn find_neighbour_files(
}

/// Query leaf kind node IDs and callable node rows for a set of files.
/// `parameter` is intentionally excluded from the leaf query (#1723) — see
/// `do_classify_full`'s leaf_ids comment for the rationale.
fn query_nodes_for_files(
tx: &rusqlite::Transaction,
files: &[&str],
) -> rusqlite::Result<(Vec<i64>, Vec<(i64, String, String, String, u32, u32)>)> {
let ph: String = files.iter().map(|_| "?").collect::<Vec<_>>().join(",");

let leaf_sql = format!(
"SELECT id FROM nodes WHERE kind IN ('parameter', 'property') AND file IN ({})",
ph
);
let leaf_sql = format!("SELECT id FROM nodes WHERE kind = 'property' AND file IN ({})", ph);
let leaf_ids: Vec<i64> = {
let mut stmt = tx.prepare(&leaf_sql)?;
for (i, f) in files.iter().enumerate() {
Expand Down Expand Up @@ -804,6 +874,9 @@ pub(crate) fn do_classify_incremental(

let (active_files, called_active_files) = compute_active_files(&rows);

// Compute interface/type owner names per file (#1723) — see do_classify_full.
let type_def_names_by_file = compute_type_def_names_by_file(&rows);

let mut ids_by_role: HashMap<&str, Vec<i64>> = HashMap::new();

if !leaf_ids.is_empty() {
Expand All @@ -818,6 +891,7 @@ pub(crate) fn do_classify_incremental(
&prod_fan_in,
&active_files,
&called_active_files,
&type_def_names_by_file,
median_fan_in,
median_fan_out,
&mut ids_by_role,
Expand Down
2 changes: 1 addition & 1 deletion src/db/repository/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ export class Repository implements IRepository {
*/
fnDeps(
_name: string,
_opts?: { depth?: number; noTests?: boolean; file?: string; kind?: string },
_opts?: { depth?: number; noTests?: boolean; file?: string | string[]; kind?: string },
): FnDepsResult | null {
return null;
}
Expand Down
26 changes: 20 additions & 6 deletions src/db/repository/native-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import Database from 'better-sqlite3';
import { warn } from '../../infrastructure/logger.js';
import { ConfigError } from '../../shared/errors.js';
import type {
AdjacentEdgeRow,
Expand Down Expand Up @@ -45,6 +46,7 @@ import type {
TriageNodeRow,
TriageQueryOpts,
} from '../../types.js';
import { normalizeFileFilter } from '../query-builder.js';
import {
type FnDepsCallerNode,
type FnDepsEntry,
Expand Down Expand Up @@ -267,8 +269,9 @@ export class NativeRepository extends Repository {
}

findNodesWithFanIn(namePattern: string, opts: QueryOpts = {}): NodeRowWithFanIn[] {
const files = normalizeFileFilter(opts.file);
return this.#ndb
.findNodesWithFanIn(namePattern, opts.kinds ?? null, opts.file ?? null)
.findNodesWithFanIn(namePattern, opts.kinds ?? null, files.length > 0 ? files : null)
.map(toNodeRowWithFanIn);
}

Expand Down Expand Up @@ -301,9 +304,19 @@ export class NativeRepository extends Repository {
}

findNodesByScope(scopeName: string, opts: QueryOpts = {}): NodeRow[] {
return this.#ndb
.findNodesByScope(scopeName, opts.kind ?? null, opts.file ?? null)
.map(toNodeRow);
// Unlike findNodesWithFanIn/fnDeps, this native binding only accepts a single
// file (no CLI command currently wires -f/--file to it); take the first value
// rather than crash if a caller ever passes the repeatable-flag array form.
// TODO(#1815): widen the native binding to Vec<String> so multi-file scoping
// isn't silently truncated once a caller wires -f/--file to this method.
const files = normalizeFileFilter(opts.file);
if (files.length > 1) {
warn(
`findNodesByScope: received ${files.length} files, only using the first ("${files[0]}") — multi-file scoping not yet supported natively (see #1815)`,
);
}
const [file] = files;
return this.#ndb.findNodesByScope(scopeName, opts.kind ?? null, file ?? null).map(toNodeRow);
}
Comment thread
carlos-alm marked this conversation as resolved.

findNodeByQualifiedName(qualifiedName: string, opts: { file?: string } = {}): NodeRow[] {
Expand Down Expand Up @@ -526,14 +539,15 @@ export class NativeRepository extends Repository {
// ── Composite queries ──────────────────────────────────────────────
fnDeps(
name: string,
opts?: { depth?: number; noTests?: boolean; file?: string; kind?: string },
opts?: { depth?: number; noTests?: boolean; file?: string | string[]; kind?: string },
): FnDepsResult | null {
if (typeof this.#ndb.fnDeps !== 'function') return null;
const files = normalizeFileFilter(opts?.file);
const raw = this.#ndb.fnDeps(
name,
opts?.depth ?? undefined,
opts?.noTests ?? undefined,
opts?.file ?? undefined,
files.length > 0 ? files : undefined,
opts?.kind ?? undefined,
);
// Convert from native format (transitive_callers as array of groups)
Expand Down
2 changes: 1 addition & 1 deletion src/domain/analysis/dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export function fnDepsData(
opts: {
depth?: number;
noTests?: boolean;
file?: string;
file?: string | string[];
kind?: string;
limit?: number;
offset?: number;
Expand Down
15 changes: 14 additions & 1 deletion src/domain/analysis/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,24 @@ function exportsFileImpl(
"SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line",
)
: null;
// Consumers include real call/construct edges plus `imports-type` edges —
// the symbol-level edge emitted for `import type { X }` statements (source
// is the importing *file* node, since the import statement references the
// type rather than a specific function). Without this, interfaces/types
// that are only ever used as type annotations are misclassified as dead
// exports even though `codegraph deps` already reports the importing file
// via this same edge (#1724).
//
// `extends`/`implements` edges are deliberately NOT included here: they are
// resolved by symbol name only, with no file/import scoping (see
// buildClassHierarchyEdges), so they can link same-named declarations
// across unrelated files — crediting them as consumers would surface false
// positives instead of fixing false negatives.
const consumersStmt = cachedStmt(
_consumersStmtCache,
db,
`SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id
WHERE e.target_id = ? AND e.kind = 'calls'`,
WHERE e.target_id = ? AND e.kind IN ('calls', 'imports-type')`,
);
const reexportsFromStmt = cachedStmt(
_reexportsFromStmtCache,
Expand Down
2 changes: 1 addition & 1 deletion src/domain/analysis/fn-impact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ export function fnImpactData(
opts: {
depth?: number;
noTests?: boolean;
file?: string;
file?: string | string[];
kind?: string;
includeImplementors?: boolean;
limit?: number;
Expand Down
Loading
Loading