From 17e5740c2c98f52c12de5d9a7c05afab1de1dd4a Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sat, 15 Aug 2026 15:39:01 -0600 Subject: [PATCH] fix(stats): native coupling hotspots exclude structural edges, matching map (#2388) The native fetch_file_hotspots query counted every edge touching a file node -- including contains/parameter_of/receiver -- while the TypeScript path already excluded them. A file that merely declares many symbols ranked as a coupling hotspot ahead of genuinely central modules, and since native is the default engine, most users saw the inflated number. Hoisted the exclusion list into a shared NON_COUPLING_EDGE_KINDS constant in shared/kinds.ts (module-map.ts's four SQL fan-in/fan-out queries now derive from it instead of repeating the literal), and mirrored the identical filter in graph_read.rs's fetch_file_hotspots so the two engines can't drift again. docs check acknowledged: internal stats aggregation bugfix, no README/CLAUDE.md/ROADMAP surface area changed. Impact: 2 functions changed, 5 affected --- .../src/db/repository/graph_read.rs | 22 +++++++++++++++---- src/domain/analysis/module-map.ts | 17 +++++++++----- src/shared/kinds.ts | 9 ++++++++ tests/integration/queries.test.ts | 22 +++++++++++++++++++ 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/crates/codegraph-core/src/db/repository/graph_read.rs b/crates/codegraph-core/src/db/repository/graph_read.rs index 0f2e0ead5..ad48131c0 100644 --- a/crates/codegraph-core/src/db/repository/graph_read.rs +++ b/crates/codegraph-core/src/db/repository/graph_read.rs @@ -597,17 +597,31 @@ fn fetch_quality_metrics( }) } +/// Edge kinds representing structural containment rather than cross-symbol +/// coupling (a parent declaring a child, a function declaring its own +/// parameter, a method's implicit receiver) — excluded from fan-in/fan-out +/// hotspot counts so a file that merely declares many symbols doesn't +/// outrank one with genuine cross-file dependencies. Mirrors +/// `NON_COUPLING_EDGE_KINDS` in `src/shared/kinds.ts` (#2388) — keep both +/// lists in sync. +const NON_COUPLING_EDGE_KINDS: &[&str] = &["contains", "parameter_of", "receiver"]; + fn fetch_file_hotspots( conn: &rusqlite::Connection, tf_n_file: &str, ) -> napi::Result> { + let edge_filter = NON_COUPLING_EDGE_KINDS + .iter() + .map(|k| format!("'{k}'")) + .collect::>() + .join(", "); let sql = format!( "SELECT n.file, \ - (SELECT COUNT(*) FROM edges WHERE target_id = n.id) as fan_in, \ - (SELECT COUNT(*) FROM edges WHERE source_id = n.id) as fan_out \ + (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ({edge_filter})) as fan_in, \ + (SELECT COUNT(*) FROM edges WHERE source_id = n.id AND kind NOT IN ({edge_filter})) as fan_out \ FROM nodes n WHERE n.kind = 'file' {tf_n_file} \ - ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id) \ - + (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC \ + ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ({edge_filter})) \ + + (SELECT COUNT(*) FROM edges WHERE source_id = n.id AND kind NOT IN ({edge_filter})) DESC \ LIMIT 5", ); let mut stmt = conn diff --git a/src/domain/analysis/module-map.ts b/src/domain/analysis/module-map.ts index e069a199a..8e8a04061 100644 --- a/src/domain/analysis/module-map.ts +++ b/src/domain/analysis/module-map.ts @@ -8,11 +8,18 @@ import { } from '../../db/index.js'; import { debug } from '../../infrastructure/logger.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import { DEAD_ROLE_PREFIX } from '../../shared/kinds.js'; +import { DEAD_ROLE_PREFIX, NON_COUPLING_EDGE_KINDS } from '../../shared/kinds.js'; import type { BetterSqlite3Database, NativeDatabase } from '../../types.js'; import { findCycles } from '../graph/cycles.js'; import { LANGUAGE_REGISTRY } from '../parser.js'; +// SQL fragment excluding structural-containment edges from coupling counts — +// built once from the shared NON_COUPLING_EDGE_KINDS constant so this file's +// four fan-in/fan-out queries (findHotspots, moduleMapData) and the native +// path (crates/codegraph-core/src/db/repository/graph_read.rs) cannot drift +// out of sync again (#2388). +const NON_COUPLING_EDGE_FILTER = `kind NOT IN (${NON_COUPLING_EDGE_KINDS.map((k) => `'${k}'`).join(', ')})`; + export const FALSE_POSITIVE_NAMES = new Set([ 'run', 'get', @@ -123,12 +130,12 @@ function findHotspots(db: BetterSqlite3Database, noTests: boolean, limit: number FROM nodes n LEFT JOIN ( SELECT target_id, COUNT(*) AS cnt FROM edges - WHERE kind NOT IN ('contains', 'parameter_of', 'receiver') + WHERE ${NON_COUPLING_EDGE_FILTER} GROUP BY target_id ) fi ON fi.target_id = n.id LEFT JOIN ( SELECT source_id, COUNT(*) AS cnt FROM edges - WHERE kind NOT IN ('contains', 'parameter_of', 'receiver') + WHERE ${NON_COUPLING_EDGE_FILTER} GROUP BY source_id ) fo ON fo.source_id = n.id WHERE n.kind = 'file' ${testFilter} @@ -325,12 +332,12 @@ export function moduleMapData(customDbPath: string, limit = 20, opts: { noTests? FROM nodes n LEFT JOIN ( SELECT source_id, COUNT(*) AS cnt FROM edges - WHERE kind NOT IN ('contains', 'parameter_of', 'receiver') + WHERE ${NON_COUPLING_EDGE_FILTER} GROUP BY source_id ) fo ON fo.source_id = n.id LEFT JOIN ( SELECT target_id, COUNT(*) AS cnt FROM edges - WHERE kind NOT IN ('contains', 'parameter_of', 'receiver') + WHERE ${NON_COUPLING_EDGE_FILTER} GROUP BY target_id ) fi ON fi.target_id = n.id WHERE n.kind = 'file' diff --git a/src/shared/kinds.ts b/src/shared/kinds.ts index 1b644659c..07a2985a0 100644 --- a/src/shared/kinds.ts +++ b/src/shared/kinds.ts @@ -82,6 +82,15 @@ export const STRUCTURAL_EDGE_KINDS: readonly StructuralEdgeKind[] = [ // Full set for MCP enum and validation export const EVERY_EDGE_KIND: readonly EdgeKind[] = [...CORE_EDGE_KINDS, ...STRUCTURAL_EDGE_KINDS]; +// Edge kinds that represent structural containment rather than cross-symbol +// coupling (a parent declaring a child, a function declaring its own +// parameter, a method's implicit receiver) — excluded from fan-in/fan-out +// coupling metrics (`codegraph map`, `stats`' coupling hotspots) so a file +// that merely declares many symbols doesn't outrank one with genuine +// cross-file dependencies (#2388). Single source of truth for both the +// native and TypeScript aggregation paths in module-map.ts. +export const NON_COUPLING_EDGE_KINDS: readonly EdgeKind[] = ['contains', ...STRUCTURAL_EDGE_KINDS]; + // Dead sub-categories — refine the coarse "dead" bucket export const DEAD_ROLE_PREFIX = 'dead'; export const DEAD_SUB_ROLES: readonly DeadSubRole[] = [ diff --git a/tests/integration/queries.test.ts b/tests/integration/queries.test.ts index 38672ced0..894bed20d 100644 --- a/tests/integration/queries.test.ts +++ b/tests/integration/queries.test.ts @@ -903,6 +903,28 @@ describe('expanded edge types', () => { expect(authNode.inEdges).toBe(2); }); + test('statsData hotspots exclude structural edges from coupling, matching moduleMapData (#2388)', () => { + // Regression test for #2388: the native `stats` path counted every edge + // touching a file node (including contains/parameter_of/receiver), while + // moduleMapData's JS path correctly excluded them — same graph, two + // different fan-in/fan-out numbers for the same file. auth.js has a + // `contains` edge as its SOURCE (auth.js -> UserService) that must not + // inflate fanOut, exactly mirroring the moduleMapData test above. + const mapData = moduleMapData(dbPath); + const authMapNode = mapData.topNodes.find((n) => n.file === 'auth.js'); + expect(authMapNode).toBeDefined(); + + const stats = statsData(dbPath); + const authHotspot = stats.hotspots.find((h) => h.file === 'auth.js'); + expect(authHotspot).toBeDefined(); + expect(authHotspot.fanIn).toBe(authMapNode.inEdges); + expect(authHotspot.fanOut).toBe(authMapNode.outEdges); + // auth.js is imported by middleware.js and auth.test.js → fanIn = 2; + // its only outgoing edge is the structural contains edge → fanOut = 0. + expect(authHotspot.fanIn).toBe(2); + expect(authHotspot.fanOut).toBe(0); + }); + test('queryNameData returns new edge kinds in callers/callees', () => { // authenticate has a parameter_of edge from userId const authData = queryNameData('authenticate', dbPath);