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
2 changes: 1 addition & 1 deletion crates/codegraph-core/src/native_db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1043,7 +1043,7 @@ impl NativeDatabase {
}

/// Check if a table exists in the database.
fn has_table(conn: &Connection, table: &str) -> bool {
pub(crate) fn has_table(conn: &Connection, table: &str) -> bool {
conn.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
params![table],
Expand Down
412 changes: 411 additions & 1 deletion crates/codegraph-core/src/read_queries.rs

Large diffs are not rendered by default.

125 changes: 125 additions & 0 deletions crates/codegraph-core/src/read_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,128 @@ pub struct NativeComplexityMetrics {
pub maintainability_index: Option<f64>,
pub halstead_volume: Option<f64>,
}

// ── Batched query return types ─────────────────────────────────────────

/// Kind + count pair for GROUP BY queries.
#[napi(object)]
#[derive(Debug, Clone)]
pub struct KindCount {
pub kind: String,
pub count: i32,
}

/// Role + count pair for role distribution queries.
#[napi(object)]
#[derive(Debug, Clone)]
pub struct RoleCount {
pub role: String,
pub count: i32,
}

/// File hotspot entry with fan-in/fan-out.
#[napi(object)]
#[derive(Debug, Clone)]
pub struct FileHotspot {
pub file: String,
pub fan_in: i32,
pub fan_out: i32,
}

/// Complexity summary statistics.
#[napi(object)]
#[derive(Debug, Clone)]
pub struct ComplexitySummary {
pub analyzed: i32,
pub avg_cognitive: f64,
pub avg_cyclomatic: f64,
pub max_cognitive: i32,
pub max_cyclomatic: i32,
pub avg_mi: f64,
pub min_mi: f64,
}

/// Embedding metadata.
#[napi(object)]
#[derive(Debug, Clone)]
pub struct EmbeddingInfo {
pub count: i32,
pub model: Option<String>,
pub dim: Option<i32>,
pub built_at: Option<String>,
}

/// Quality metrics for graph stats.
#[napi(object)]
#[derive(Debug, Clone)]
pub struct QualityMetrics {
pub callable_total: i32,
pub callable_with_callers: i32,
pub call_edges: i32,
pub high_conf_call_edges: i32,
}

/// Combined graph statistics — replaces ~11 separate queries in module-map.ts.
#[napi(object)]
#[derive(Debug, Clone)]
pub struct GraphStats {
pub total_nodes: i32,
pub total_edges: i32,
pub nodes_by_kind: Vec<KindCount>,
pub edges_by_kind: Vec<KindCount>,
pub role_counts: Vec<RoleCount>,
pub quality: QualityMetrics,
pub hotspots: Vec<FileHotspot>,
pub complexity: Option<ComplexitySummary>,
pub embeddings: Option<EmbeddingInfo>,
}

/// Dataflow edge with joined node info.
#[napi(object)]
#[derive(Debug, Clone)]
pub struct DataflowQueryEdge {
pub name: String,
pub kind: String,
pub file: String,
pub line: Option<i32>,
pub param_index: Option<i32>,
pub expression: Option<String>,
pub confidence: Option<f64>,
}

/// All 6 directional dataflow edge sets for a node.
#[napi(object)]
#[derive(Debug, Clone)]
pub struct DataflowEdgesResult {
pub flows_to_out: Vec<DataflowQueryEdge>,
pub flows_to_in: Vec<DataflowQueryEdge>,
pub returns_out: Vec<DataflowQueryEdge>,
pub returns_in: Vec<DataflowQueryEdge>,
pub mutates_out: Vec<DataflowQueryEdge>,
pub mutates_in: Vec<DataflowQueryEdge>,
}

/// Hotspot row from node_metrics join.
#[napi(object)]
#[derive(Debug, Clone)]
pub struct NativeHotspotRow {
pub name: String,
pub kind: String,
pub line_count: Option<i32>,
pub symbol_count: Option<i32>,
pub import_count: Option<i32>,
pub export_count: Option<i32>,
pub fan_in: Option<i32>,
pub fan_out: Option<i32>,
pub cohesion: Option<f64>,
pub file_count: Option<i32>,
}

/// Fan-in/fan-out metrics for a single node.
#[napi(object)]
#[derive(Debug, Clone)]
pub struct FanMetric {
pub node_id: i32,
pub fan_in: i32,
pub fan_out: i32,
}
41 changes: 41 additions & 0 deletions src/db/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,3 +399,44 @@ export function openRepo(
},
};
}

/**
* Open a readonly DB with an optional NativeDatabase alongside it.
*
* Returns the better-sqlite3 handle (for backwards compat) plus an optional
* NativeDatabase for modules that can use batched Rust query methods.
* Callers should use nativeDb when available and fall back to db.prepare().
*/
export function openReadonlyWithNative(customPath?: string): {
db: BetterSqlite3Database;
nativeDb: NativeDatabase | undefined;
close(): void;
} {
const db = openReadonlyOrFail(customPath);

let nativeDb: NativeDatabase | undefined;
if (isNativeAvailable()) {
try {
const dbPath = findDbPath(customPath);
const native = getNative();
nativeDb = native.NativeDatabase.openReadonly(dbPath);
} catch (e) {
debug(`openReadonlyWithNative: native path failed: ${(e as Error).message}`);
}
}

return {
db,
nativeDb,
close() {
db.close();
if (nativeDb) {
try {
nativeDb.close();
} catch {
// already closed or not closeable
}
}
},
};
}
1 change: 1 addition & 0 deletions src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
flushDeferredClose,
openDb,
openReadonlyOrFail,
openReadonlyWithNative,
openRepo,
} from './connection.js';
export { getBuildMeta, initSchema, MIGRATIONS, setBuildMeta } from './migrations.js';
Expand Down
111 changes: 103 additions & 8 deletions src/domain/analysis/module-map.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from 'node:path';
import { openReadonlyOrFail, testFilterSQL } from '../../db/index.js';
import { openReadonlyOrFail, openReadonlyWithNative, testFilterSQL } from '../../db/index.js';
import { cachedStmt } from '../../db/repository/cached-stmt.js';
import { loadConfig } from '../../infrastructure/config.js';
import { debug } from '../../infrastructure/logger.js';
Expand Down Expand Up @@ -381,20 +381,115 @@ export function moduleMapData(customDbPath: string, limit = 20, opts: { noTests?
}

export function statsData(customDbPath: string, opts: { noTests?: boolean; config?: any } = {}) {
const db = openReadonlyOrFail(customDbPath);
const { db, nativeDb, close } = openReadonlyWithNative(customDbPath);
try {
const noTests = opts.noTests || false;
const config = opts.config || loadConfig();
const testFilter = testFilterSQL('n.file', noTests);

// These always need JS (non-SQL logic)
const files = countFilesByLanguage(db, noTests);
const fileCycles = findCycles(db, { fileLevel: true, noTests });
const fnCycles = findCycles(db, { fileLevel: false, noTests });

// ── Native fast path: batch all SQL aggregations in one napi call ──
if (nativeDb?.getGraphStats) {
const s = nativeDb.getGraphStats(noTests);
const nodesByKind: Record<string, number> = {};
for (const k of s.nodesByKind) nodesByKind[k.kind] = k.count;
const edgesByKind: Record<string, number> = {};
for (const k of s.edgesByKind) edgesByKind[k.kind] = k.count;
const roles: Record<string, number> & { dead?: number } = {};
let deadTotal = 0;
for (const r of s.roleCounts) {
roles[r.role] = r.count;
if (r.role.startsWith(DEAD_ROLE_PREFIX)) deadTotal += r.count;
}
if (deadTotal > 0) roles.dead = deadTotal;

const callerCoverage =
s.quality.callableTotal > 0 ? s.quality.callableWithCallers / s.quality.callableTotal : 0;
const callConfidence =
s.quality.callEdges > 0 ? s.quality.highConfCallEdges / s.quality.callEdges : 0;

// False-positive analysis still uses JS (needs FALSE_POSITIVE_NAMES set)
const fpThreshold = config.analysis?.falsePositiveCallers ?? FALSE_POSITIVE_CALLER_THRESHOLD;
const fpRows = db
.prepare(`
SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count
FROM nodes n
LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls'
WHERE n.kind IN ('function', 'method')
GROUP BY n.id
HAVING caller_count > ?
ORDER BY caller_count DESC
`)
.all(fpThreshold) as Array<{
name: string;
file: string;
line: number;
caller_count: number;
}>;
const falsePositiveWarnings = fpRows
.filter((r) =>
FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop()! : r.name),
)
.map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count }));
let fpEdgeCount = 0;
for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount;
const falsePositiveRatio = s.quality.callEdges > 0 ? fpEdgeCount / s.quality.callEdges : 0;
const score = Math.round(
callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20,
);

return {
nodes: { total: s.totalNodes, byKind: nodesByKind },
edges: { total: s.totalEdges, byKind: edgesByKind },
files,
cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length },
hotspots: s.hotspots.map((h) => ({ file: h.file, fanIn: h.fanIn, fanOut: h.fanOut })),
embeddings: s.embeddings
? {
count: s.embeddings.count,
model: s.embeddings.model,
dim: s.embeddings.dim,
builtAt: s.embeddings.builtAt,
}
: null,
quality: {
score,
callerCoverage: {
ratio: callerCoverage,
covered: s.quality.callableWithCallers,
total: s.quality.callableTotal,
},
callConfidence: {
ratio: callConfidence,
highConf: s.quality.highConfCallEdges,
total: s.quality.callEdges,
},
falsePositiveWarnings,
},
roles,
complexity: s.complexity
? {
analyzed: s.complexity.analyzed,
avgCognitive: s.complexity.avgCognitive,
avgCyclomatic: s.complexity.avgCyclomatic,
maxCognitive: s.complexity.maxCognitive,
maxCyclomatic: s.complexity.maxCyclomatic,
avgMI: s.complexity.avgMi,
minMI: s.complexity.minMi,
}
: null,
};
}

// ── JS fallback ───────────────────────────────────────────────────
const testFilter = testFilterSQL('n.file', noTests);
const testFileIds = noTests ? buildTestFileIds(db) : null;

const { total: totalNodes, byKind: nodesByKind } = countNodesByKind(db, testFileIds);
const { total: totalEdges, byKind: edgesByKind } = countEdgesByKind(db, testFileIds);
const files = countFilesByLanguage(db, noTests);

const fileCycles = findCycles(db, { fileLevel: true, noTests });
const fnCycles = findCycles(db, { fileLevel: false, noTests });

const hotspots = findHotspots(db, noTests, 5);
const embeddings = getEmbeddingsInfo(db);
Expand All @@ -415,6 +510,6 @@ export function statsData(customDbPath: string, opts: { noTests?: boolean; confi
complexity,
};
} finally {
db.close();
close();
}
}
Loading
Loading