Skip to content
Closed
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
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
17 changes: 13 additions & 4 deletions src/features/structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -777,13 +777,20 @@ function writeMedianCache(
}

function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSummary): RoleSummary {
// Leaf kinds (parameter, property) can never have callers/callees.
// Property kind (class/struct fields) can never have callers/callees.
// Classify them directly as dead-leaf without the expensive fan-in/fan-out JOINs.
//
// `parameter` is deliberately NOT included here (#1723): a parameter's liveness
// is a local dataflow question (is it referenced within its own function body),
// 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
// query below, so they never receive a role at all — the same treatment as
// `file`/`directory` nodes.
const leafRows = db
.prepare(
`SELECT n.id
FROM nodes n
WHERE n.kind IN ('parameter', 'property')`,
WHERE n.kind = 'property'`,
)
.all() as { id: number }[];

Expand Down Expand Up @@ -967,11 +974,13 @@ function classifyNodeRolesIncremental(
globalMedians = computed;
}

// 2a. Leaf kinds (parameter, property) in affected files — always dead-leaf
// 2a. Property kind (class/struct fields) in affected files — always dead-leaf.
// `parameter` is intentionally excluded (#1723) — see classifyNodeRolesFull for
// the rationale. Parameters stay unclassified (role = NULL) after the reset below.
const leafRows = db
.prepare(
`SELECT n.id FROM nodes n
WHERE n.kind IN ('parameter', 'property')
WHERE n.kind = 'property'
AND n.file IN (${placeholders})`,
)
.all(...allAffectedFiles) as { id: number }[];
Expand Down
85 changes: 80 additions & 5 deletions src/graph/classifiers/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,23 @@
* Roles: entry, core, utility, adapter, leaf, dead-*, test-only
*
* Dead sub-categories refine the coarse "dead" bucket:
* dead-leaf — parameters, properties, constants (leaf nodes by definition)
* dead-leaf — properties, constants (leaf nodes by definition)
* dead-entry — framework dispatch: CLI commands, MCP tools, event handlers
* dead-ffi — cross-language FFI boundaries (e.g. Rust napi-rs bindings)
* dead-unresolved — genuinely unreferenced callables (the real dead code)
*
* `parameter`-kind nodes never reach this module in production — callers
* (`features/structure.ts`, native `graph/classifiers/roles.rs`) exclude them
* entirely, leaving `role` unset, the same treatment as `file`/`directory`
* nodes. A parameter's liveness is a local dataflow question (is it referenced
* within its own function body), not a call-graph reachability question, so
* "no incoming call edges" carries zero dead-code signal for it (#1723).
*
* `method`/`property`-kind members of an interface/type declaration (e.g.
* `interface Foo { bar: string }`) DO reach this module, but are recognized by
* `isTypeDeclarationMember` and classified `leaf` unconditionally — they can
* never gain inbound call edges by construction, so call-graph reachability
* doesn't apply to them either (#1723).
*/

import type { DeadSubRole, Role } from '../../types.js';
Expand Down Expand Up @@ -53,6 +66,56 @@ export interface ClassifiableNode {
file?: string;
}

/**
* Compute, per file, the set of symbol names that are `TYPE_DEF_KINDS`-kind
* declarations (interface/type/struct/enum/trait/record). Used by
* `isTypeDeclarationMember` to recognize `Owner.member`-qualified nodes whose
* owner is a type-level declaration rather than a class.
*/
function computeTypeDefNamesByFile(nodes: RoleClassificationNode[]): Map<string, Set<string>> {
const byFile = new Map<string, Set<string>>();
for (const n of nodes) {
if (n.file && n.kind && TYPE_DEF_KINDS.has(n.kind)) {
let names = byFile.get(n.file);
if (!names) {
names = new Set();
byFile.set(n.file, names);
}
names.add(n.name);
}
}
return byFile;
}

/**
* True when `node` is a `method`/`property`-kind 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 — they are
* consumed via type annotations and structural typing, never calls — so a
* `fanIn === 0` reading carries zero dead-code signal for them, unlike a real
* function/method where it does. Call-edge-based reachability just doesn't
* apply to type-level declarations, so they must never be judged dead by it.
*/
function isTypeDeclarationMember(
node: RoleClassificationNode,
typeDefNamesByFile: Map<string, Set<string>>,
): boolean {
if (node.kind !== 'method' && node.kind !== 'property') return false;
if (!node.file) return false;
const dotIdx = node.name.indexOf('.');
if (dotIdx === -1) return false;
const ownerName = node.name.slice(0, dotIdx);
return typeDefNamesByFile.get(node.file)?.has(ownerName) ?? false;
}

/**
* Refine a "dead" classification into a sub-category.
*/
Expand Down Expand Up @@ -176,10 +239,21 @@ function classifyByFanShape(highIn: boolean, highOut: boolean): Role {

/**
* Apply role-classification rules to a single node.
* Order matters — framework entries are tagged first, then dead/test cases,
* then the fan-in/fan-out shape decides among the structural roles.
* Order matters — type-level members are ruled out first (they can never be
* judged by call-graph reachability at all), then framework entries, then
* dead/test cases, then the fan-in/fan-out shape decides among the structural
* roles.
*/
function classifyNodeRole(node: RoleClassificationNode, medFanIn: number, medFanOut: number): Role {
function classifyNodeRole(
node: RoleClassificationNode,
medFanIn: number,
medFanOut: number,
typeDefNamesByFile: Map<string, Set<string>>,
): Role {
// Interface/type members (#1723) — never subject to call-graph dead-code
// detection, regardless of fan-in/fan-out/export status.
if (isTypeDeclarationMember(node, typeDefNamesByFile)) return 'leaf';

if (FRAMEWORK_ENTRY_PREFIXES.some((p) => node.name.startsWith(p))) return 'entry';

if (node.fanIn === 0) {
Expand Down Expand Up @@ -217,10 +291,11 @@ export function classifyRoles(
if (nodes.length === 0) return new Map();

const { fanIn: medFanIn, fanOut: medFanOut } = medianOverrides ?? computeFanMedians(nodes);
const typeDefNamesByFile = computeTypeDefNamesByFile(nodes);

const result = new Map<string, Role>();
for (const node of nodes) {
result.set(node.id, classifyNodeRole(node, medFanIn, medFanOut));
result.set(node.id, classifyNodeRole(node, medFanIn, medFanOut, typeDefNamesByFile));
}
return result;
}
Loading
Loading