diff --git a/src/db.js b/src/db.js index f4de972d..7b67a9cf 100644 --- a/src/db.js +++ b/src/db.js @@ -52,9 +52,12 @@ export { hasCoChanges, hasDataflowTable, hasEmbeddings, + InMemoryRepository, iterateFunctionNodes, listFunctionNodes, purgeFileData, purgeFilesData, + Repository, + SqliteRepository, upsertCoChangeMeta, } from './db/repository/index.js'; diff --git a/src/db/repository/base.js b/src/db/repository/base.js new file mode 100644 index 00000000..d13ffcfa --- /dev/null +++ b/src/db/repository/base.js @@ -0,0 +1,201 @@ +/** + * Abstract Repository base class. + * + * Defines the contract for all graph data access. Every method throws + * "not implemented" by default — concrete subclasses override what they support. + */ +export class Repository { + // ── Node lookups ──────────────────────────────────────────────────── + /** @param {number} id @returns {object|undefined} */ + findNodeById(_id) { + throw new Error('not implemented'); + } + + /** @param {string} file @returns {object[]} */ + findNodesByFile(_file) { + throw new Error('not implemented'); + } + + /** @param {string} fileLike @returns {object[]} */ + findFileNodes(_fileLike) { + throw new Error('not implemented'); + } + + /** @param {string} namePattern @param {object} [opts] @returns {object[]} */ + findNodesWithFanIn(_namePattern, _opts) { + throw new Error('not implemented'); + } + + /** @returns {number} */ + countNodes() { + throw new Error('not implemented'); + } + + /** @returns {number} */ + countEdges() { + throw new Error('not implemented'); + } + + /** @returns {number} */ + countFiles() { + throw new Error('not implemented'); + } + + /** @param {string} name @param {string} kind @param {string} file @param {number} line @returns {number|undefined} */ + getNodeId(_name, _kind, _file, _line) { + throw new Error('not implemented'); + } + + /** @param {string} name @param {string} file @param {number} line @returns {number|undefined} */ + getFunctionNodeId(_name, _file, _line) { + throw new Error('not implemented'); + } + + /** @param {string} file @returns {{ id: number, name: string, kind: string, line: number }[]} */ + bulkNodeIdsByFile(_file) { + throw new Error('not implemented'); + } + + /** @param {number} parentId @returns {object[]} */ + findNodeChildren(_parentId) { + throw new Error('not implemented'); + } + + /** @param {string} scopeName @param {object} [opts] @returns {object[]} */ + findNodesByScope(_scopeName, _opts) { + throw new Error('not implemented'); + } + + /** @param {string} qualifiedName @param {object} [opts] @returns {object[]} */ + findNodeByQualifiedName(_qualifiedName, _opts) { + throw new Error('not implemented'); + } + + /** @param {object} [opts] @returns {object[]} */ + listFunctionNodes(_opts) { + throw new Error('not implemented'); + } + + /** @param {object} [opts] @returns {IterableIterator} */ + iterateFunctionNodes(_opts) { + throw new Error('not implemented'); + } + + /** @param {object} [opts] @returns {object[]} */ + findNodesForTriage(_opts) { + throw new Error('not implemented'); + } + + // ── Edge queries ──────────────────────────────────────────────────── + /** @param {number} nodeId @returns {object[]} */ + findCallees(_nodeId) { + throw new Error('not implemented'); + } + + /** @param {number} nodeId @returns {object[]} */ + findCallers(_nodeId) { + throw new Error('not implemented'); + } + + /** @param {number} nodeId @returns {object[]} */ + findDistinctCallers(_nodeId) { + throw new Error('not implemented'); + } + + /** @param {number} nodeId @returns {object[]} */ + findAllOutgoingEdges(_nodeId) { + throw new Error('not implemented'); + } + + /** @param {number} nodeId @returns {object[]} */ + findAllIncomingEdges(_nodeId) { + throw new Error('not implemented'); + } + + /** @param {number} nodeId @returns {string[]} */ + findCalleeNames(_nodeId) { + throw new Error('not implemented'); + } + + /** @param {number} nodeId @returns {string[]} */ + findCallerNames(_nodeId) { + throw new Error('not implemented'); + } + + /** @param {number} nodeId @returns {{ file: string, edge_kind: string }[]} */ + findImportTargets(_nodeId) { + throw new Error('not implemented'); + } + + /** @param {number} nodeId @returns {{ file: string, edge_kind: string }[]} */ + findImportSources(_nodeId) { + throw new Error('not implemented'); + } + + /** @param {number} nodeId @returns {object[]} */ + findImportDependents(_nodeId) { + throw new Error('not implemented'); + } + + /** @param {string} file @returns {Set} */ + findCrossFileCallTargets(_file) { + throw new Error('not implemented'); + } + + /** @param {number} nodeId @param {string} file @returns {number} */ + countCrossFileCallers(_nodeId, _file) { + throw new Error('not implemented'); + } + + /** @param {number} classNodeId @returns {Set} */ + getClassHierarchy(_classNodeId) { + throw new Error('not implemented'); + } + + /** @param {string} file @returns {{ caller_name: string, callee_name: string }[]} */ + findIntraFileCallEdges(_file) { + throw new Error('not implemented'); + } + + // ── Graph-read queries ────────────────────────────────────────────── + /** @returns {{ id: number, name: string, kind: string, file: string }[]} */ + getCallableNodes() { + throw new Error('not implemented'); + } + + /** @returns {{ source_id: number, target_id: number }[]} */ + getCallEdges() { + throw new Error('not implemented'); + } + + /** @returns {{ id: number, name: string, file: string }[]} */ + getFileNodesAll() { + throw new Error('not implemented'); + } + + /** @returns {{ source_id: number, target_id: number }[]} */ + getImportEdges() { + throw new Error('not implemented'); + } + + // ── Optional table checks (default: false/undefined) ──────────────── + /** @returns {boolean} */ + hasCfgTables() { + throw new Error('not implemented'); + } + + /** @returns {boolean} */ + hasEmbeddings() { + throw new Error('not implemented'); + } + + /** @returns {boolean} */ + hasDataflowTable() { + throw new Error('not implemented'); + } + + /** @param {number} nodeId @returns {object|undefined} */ + getComplexityForNode(_nodeId) { + throw new Error('not implemented'); + } +} diff --git a/src/db/repository/in-memory-repository.js b/src/db/repository/in-memory-repository.js new file mode 100644 index 00000000..526bd28d --- /dev/null +++ b/src/db/repository/in-memory-repository.js @@ -0,0 +1,584 @@ +import { ConfigError } from '../../errors.js'; +import { CORE_SYMBOL_KINDS, EVERY_SYMBOL_KIND, VALID_ROLES } from '../../kinds.js'; +import { Repository } from './base.js'; + +/** + * Escape LIKE special characters so they are treated as literals. + * Mirrors the `escapeLike` function in `nodes.js`. + * @param {string} s + * @returns {string} + */ +function escapeLike(s) { + return s.replace(/[%_\\]/g, '\\$&'); +} + +/** + * Convert a SQL LIKE pattern to a RegExp (case-insensitive). + * Supports `%` (any chars) and `_` (single char). + * @param {string} pattern + * @returns {RegExp} + */ +function likeToRegex(pattern) { + let regex = ''; + for (let i = 0; i < pattern.length; i++) { + const ch = pattern[i]; + if (ch === '\\' && i + 1 < pattern.length) { + // Escaped literal + regex += pattern[++i].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } else if (ch === '%') { + regex += '.*'; + } else if (ch === '_') { + regex += '.'; + } else { + regex += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + } + return new RegExp(`^${regex}$`, 'i'); +} + +/** + * In-memory Repository implementation backed by Maps. + * No SQLite dependency — suitable for fast unit tests. + */ +export class InMemoryRepository extends Repository { + #nodes = new Map(); // id → node object + #edges = new Map(); // id → edge object + #complexity = new Map(); // node_id → complexity metrics + #nextNodeId = 1; + #nextEdgeId = 1; + + // ── Mutation (test setup only) ──────────────────────────────────── + + /** + * Add a node. Returns the auto-assigned id. + * @param {object} attrs - { name, kind, file, line, end_line?, parent_id?, exported?, qualified_name?, scope?, visibility?, role? } + * @returns {number} + */ + addNode(attrs) { + const id = this.#nextNodeId++; + this.#nodes.set(id, { + id, + name: attrs.name, + kind: attrs.kind, + file: attrs.file, + line: attrs.line, + end_line: attrs.end_line ?? null, + parent_id: attrs.parent_id ?? null, + exported: attrs.exported ?? null, + qualified_name: attrs.qualified_name ?? null, + scope: attrs.scope ?? null, + visibility: attrs.visibility ?? null, + role: attrs.role ?? null, + }); + return id; + } + + /** + * Add an edge. Returns the auto-assigned id. + * @param {object} attrs - { source_id, target_id, kind, confidence?, dynamic? } + * @returns {number} + */ + addEdge(attrs) { + const id = this.#nextEdgeId++; + this.#edges.set(id, { + id, + source_id: attrs.source_id, + target_id: attrs.target_id, + kind: attrs.kind, + confidence: attrs.confidence ?? null, + dynamic: attrs.dynamic ?? 0, + }); + return id; + } + + /** + * Add complexity metrics for a node. + * @param {number} nodeId + * @param {object} metrics - { cognitive, cyclomatic, max_nesting, maintainability_index?, halstead_volume? } + */ + addComplexity(nodeId, metrics) { + this.#complexity.set(nodeId, { + cognitive: metrics.cognitive ?? 0, + cyclomatic: metrics.cyclomatic ?? 0, + max_nesting: metrics.max_nesting ?? 0, + maintainability_index: metrics.maintainability_index ?? 0, + halstead_volume: metrics.halstead_volume ?? 0, + }); + } + + // ── Node lookups ────────────────────────────────────────────────── + + findNodeById(id) { + return this.#nodes.get(id) ?? undefined; + } + + findNodesByFile(file) { + return [...this.#nodes.values()] + .filter((n) => n.file === file && n.kind !== 'file') + .sort((a, b) => a.line - b.line); + } + + findFileNodes(fileLike) { + const re = likeToRegex(fileLike); + return [...this.#nodes.values()].filter((n) => n.kind === 'file' && re.test(n.file)); + } + + findNodesWithFanIn(namePattern, opts = {}) { + const re = likeToRegex(namePattern); + let nodes = [...this.#nodes.values()].filter((n) => re.test(n.name)); + + if (opts.kinds) { + nodes = nodes.filter((n) => opts.kinds.includes(n.kind)); + } + if (opts.file) { + const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`); + nodes = nodes.filter((n) => fileRe.test(n.file)); + } + + // Compute fan-in per node + const fanInMap = this.#computeFanIn(); + return nodes.map((n) => ({ ...n, fan_in: fanInMap.get(n.id) ?? 0 })); + } + + countNodes() { + return this.#nodes.size; + } + + countEdges() { + return this.#edges.size; + } + + countFiles() { + const files = new Set(); + for (const n of this.#nodes.values()) { + files.add(n.file); + } + return files.size; + } + + getNodeId(name, kind, file, line) { + for (const n of this.#nodes.values()) { + if (n.name === name && n.kind === kind && n.file === file && n.line === line) { + return n.id; + } + } + return undefined; + } + + getFunctionNodeId(name, file, line) { + for (const n of this.#nodes.values()) { + if ( + n.name === name && + (n.kind === 'function' || n.kind === 'method') && + n.file === file && + n.line === line + ) { + return n.id; + } + } + return undefined; + } + + bulkNodeIdsByFile(file) { + return [...this.#nodes.values()] + .filter((n) => n.file === file) + .map((n) => ({ id: n.id, name: n.name, kind: n.kind, line: n.line })); + } + + findNodeChildren(parentId) { + return [...this.#nodes.values()] + .filter((n) => n.parent_id === parentId) + .sort((a, b) => a.line - b.line) + .map((n) => ({ + name: n.name, + kind: n.kind, + line: n.line, + end_line: n.end_line, + qualified_name: n.qualified_name, + scope: n.scope, + visibility: n.visibility, + })); + } + + findNodesByScope(scopeName, opts = {}) { + let nodes = [...this.#nodes.values()].filter((n) => n.scope === scopeName); + + if (opts.kind) { + nodes = nodes.filter((n) => n.kind === opts.kind); + } + if (opts.file) { + const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`); + nodes = nodes.filter((n) => fileRe.test(n.file)); + } + + return nodes.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); + } + + findNodeByQualifiedName(qualifiedName, opts = {}) { + let nodes = [...this.#nodes.values()].filter((n) => n.qualified_name === qualifiedName); + + if (opts.file) { + const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`); + nodes = nodes.filter((n) => fileRe.test(n.file)); + } + + return nodes.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); + } + + listFunctionNodes(opts = {}) { + return [...this.#iterateFunctionNodesImpl(opts)]; + } + + *iterateFunctionNodes(opts = {}) { + yield* this.#iterateFunctionNodesImpl(opts); + } + + findNodesForTriage(opts = {}) { + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + throw new ConfigError( + `Invalid kind: ${opts.kind} (expected one of ${EVERY_SYMBOL_KIND.join(', ')})`, + ); + } + if (opts.role && !VALID_ROLES.includes(opts.role)) { + throw new ConfigError( + `Invalid role: ${opts.role} (expected one of ${VALID_ROLES.join(', ')})`, + ); + } + const kindsToUse = opts.kind ? [opts.kind] : ['function', 'method', 'class']; + let nodes = [...this.#nodes.values()].filter((n) => kindsToUse.includes(n.kind)); + + if (opts.noTests) { + nodes = nodes.filter( + (n) => + !n.file.includes('.test.') && + !n.file.includes('.spec.') && + !n.file.includes('__test__') && + !n.file.includes('__tests__') && + !n.file.includes('.stories.'), + ); + } + if (opts.file) { + const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`); + nodes = nodes.filter((n) => fileRe.test(n.file)); + } + if (opts.role) { + nodes = nodes.filter((n) => n.role === opts.role); + } + + const fanInMap = this.#computeFanIn(); + return nodes + .sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line) + .map((n) => { + const cx = this.#complexity.get(n.id); + return { + id: n.id, + name: n.name, + kind: n.kind, + file: n.file, + line: n.line, + end_line: n.end_line, + role: n.role, + fan_in: fanInMap.get(n.id) ?? 0, + cognitive: cx?.cognitive ?? 0, + mi: cx?.maintainability_index ?? 0, + cyclomatic: cx?.cyclomatic ?? 0, + max_nesting: cx?.max_nesting ?? 0, + churn: 0, // no co-change data in-memory + }; + }); + } + + // ── Edge queries ────────────────────────────────────────────────── + + findCallees(nodeId) { + const seen = new Set(); + const results = []; + for (const e of this.#edges.values()) { + if (e.source_id === nodeId && e.kind === 'calls' && !seen.has(e.target_id)) { + seen.add(e.target_id); + const n = this.#nodes.get(e.target_id); + if (n) + results.push({ + id: n.id, + name: n.name, + kind: n.kind, + file: n.file, + line: n.line, + end_line: n.end_line, + }); + } + } + return results; + } + + findCallers(nodeId) { + const results = []; + for (const e of this.#edges.values()) { + if (e.target_id === nodeId && e.kind === 'calls') { + const n = this.#nodes.get(e.source_id); + if (n) results.push({ id: n.id, name: n.name, kind: n.kind, file: n.file, line: n.line }); + } + } + return results; + } + + findDistinctCallers(nodeId) { + const seen = new Set(); + const results = []; + for (const e of this.#edges.values()) { + if (e.target_id === nodeId && e.kind === 'calls' && !seen.has(e.source_id)) { + seen.add(e.source_id); + const n = this.#nodes.get(e.source_id); + if (n) results.push({ id: n.id, name: n.name, kind: n.kind, file: n.file, line: n.line }); + } + } + return results; + } + + findAllOutgoingEdges(nodeId) { + const results = []; + for (const e of this.#edges.values()) { + if (e.source_id === nodeId) { + const n = this.#nodes.get(e.target_id); + if (n) + results.push({ + name: n.name, + kind: n.kind, + file: n.file, + line: n.line, + edge_kind: e.kind, + }); + } + } + return results; + } + + findAllIncomingEdges(nodeId) { + const results = []; + for (const e of this.#edges.values()) { + if (e.target_id === nodeId) { + const n = this.#nodes.get(e.source_id); + if (n) + results.push({ + name: n.name, + kind: n.kind, + file: n.file, + line: n.line, + edge_kind: e.kind, + }); + } + } + return results; + } + + findCalleeNames(nodeId) { + const names = new Set(); + for (const e of this.#edges.values()) { + if (e.source_id === nodeId && e.kind === 'calls') { + const n = this.#nodes.get(e.target_id); + if (n) names.add(n.name); + } + } + return [...names].sort(); + } + + findCallerNames(nodeId) { + const names = new Set(); + for (const e of this.#edges.values()) { + if (e.target_id === nodeId && e.kind === 'calls') { + const n = this.#nodes.get(e.source_id); + if (n) names.add(n.name); + } + } + return [...names].sort(); + } + + findImportTargets(nodeId) { + const results = []; + for (const e of this.#edges.values()) { + if (e.source_id === nodeId && (e.kind === 'imports' || e.kind === 'imports-type')) { + const n = this.#nodes.get(e.target_id); + if (n) results.push({ file: n.file, edge_kind: e.kind }); + } + } + return results; + } + + findImportSources(nodeId) { + const results = []; + for (const e of this.#edges.values()) { + if (e.target_id === nodeId && (e.kind === 'imports' || e.kind === 'imports-type')) { + const n = this.#nodes.get(e.source_id); + if (n) results.push({ file: n.file, edge_kind: e.kind }); + } + } + return results; + } + + findImportDependents(nodeId) { + const results = []; + for (const e of this.#edges.values()) { + if (e.target_id === nodeId && (e.kind === 'imports' || e.kind === 'imports-type')) { + const n = this.#nodes.get(e.source_id); + if (n) results.push({ ...n }); + } + } + return results; + } + + findCrossFileCallTargets(file) { + const targets = new Set(); + for (const e of this.#edges.values()) { + if (e.kind !== 'calls') continue; + const caller = this.#nodes.get(e.source_id); + const target = this.#nodes.get(e.target_id); + if (caller && target && target.file === file && caller.file !== file) { + targets.add(e.target_id); + } + } + return targets; + } + + countCrossFileCallers(nodeId, file) { + let count = 0; + for (const e of this.#edges.values()) { + if (e.target_id === nodeId && e.kind === 'calls') { + const caller = this.#nodes.get(e.source_id); + if (caller && caller.file !== file) count++; + } + } + return count; + } + + getClassHierarchy(classNodeId) { + const ancestors = new Set(); + const queue = [classNodeId]; + while (queue.length > 0) { + const current = queue.shift(); + for (const e of this.#edges.values()) { + if (e.source_id === current && e.kind === 'extends') { + const target = this.#nodes.get(e.target_id); + if (target && !ancestors.has(target.id)) { + ancestors.add(target.id); + queue.push(target.id); + } + } + } + } + return ancestors; + } + + findIntraFileCallEdges(file) { + const results = []; + for (const e of this.#edges.values()) { + if (e.kind !== 'calls') continue; + const caller = this.#nodes.get(e.source_id); + const callee = this.#nodes.get(e.target_id); + if (caller && callee && caller.file === file && callee.file === file) { + results.push({ caller_name: caller.name, callee_name: callee.name }); + } + } + const lineByName = new Map(); + for (const n of this.#nodes.values()) { + if (n.file === file) lineByName.set(n.name, n.line); + } + return results.sort((a, b) => { + return (lineByName.get(a.caller_name) ?? 0) - (lineByName.get(b.caller_name) ?? 0); + }); + } + + // ── Graph-read queries ──────────────────────────────────────────── + + getCallableNodes() { + return [...this.#nodes.values()] + .filter((n) => CORE_SYMBOL_KINDS.includes(n.kind)) + .map((n) => ({ id: n.id, name: n.name, kind: n.kind, file: n.file })); + } + + getCallEdges() { + return [...this.#edges.values()] + .filter((e) => e.kind === 'calls') + .map((e) => ({ source_id: e.source_id, target_id: e.target_id })); + } + + getFileNodesAll() { + return [...this.#nodes.values()] + .filter((n) => n.kind === 'file') + .map((n) => ({ id: n.id, name: n.name, file: n.file })); + } + + getImportEdges() { + return [...this.#edges.values()] + .filter((e) => e.kind === 'imports' || e.kind === 'imports-type') + .map((e) => ({ source_id: e.source_id, target_id: e.target_id })); + } + + // ── Optional table checks ───────────────────────────────────────── + + hasCfgTables() { + return false; + } + + hasEmbeddings() { + return false; + } + + hasDataflowTable() { + return false; + } + + getComplexityForNode(nodeId) { + return this.#complexity.get(nodeId); + } + + // ── Private helpers ─────────────────────────────────────────────── + + /** Compute fan-in (incoming 'calls' edge count) for all nodes. */ + #computeFanIn() { + const fanIn = new Map(); + for (const e of this.#edges.values()) { + if (e.kind === 'calls') { + fanIn.set(e.target_id, (fanIn.get(e.target_id) ?? 0) + 1); + } + } + return fanIn; + } + + /** Internal generator for function/method/class listing with filters. */ + *#iterateFunctionNodesImpl(opts = {}) { + let nodes = [...this.#nodes.values()].filter((n) => + ['function', 'method', 'class'].includes(n.kind), + ); + + if (opts.file) { + const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`); + nodes = nodes.filter((n) => fileRe.test(n.file)); + } + if (opts.pattern) { + const patternRe = likeToRegex(`%${escapeLike(opts.pattern)}%`); + nodes = nodes.filter((n) => patternRe.test(n.name)); + } + if (opts.noTests) { + nodes = nodes.filter( + (n) => + !n.file.includes('.test.') && + !n.file.includes('.spec.') && + !n.file.includes('__test__') && + !n.file.includes('__tests__') && + !n.file.includes('.stories.'), + ); + } + + nodes.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); + for (const n of nodes) { + yield { + name: n.name, + kind: n.kind, + file: n.file, + line: n.line, + end_line: n.end_line, + role: n.role, + }; + } + } +} diff --git a/src/db/repository/index.js b/src/db/repository/index.js index c5041408..27483ae7 100644 --- a/src/db/repository/index.js +++ b/src/db/repository/index.js @@ -1,10 +1,10 @@ // Barrel re-export for repository/ modules. +export { Repository } from './base.js'; export { purgeFileData, purgeFilesData } from './build-stmts.js'; export { cachedStmt } from './cached-stmt.js'; export { deleteCfgForNode, getCfgBlocks, getCfgEdges, hasCfgTables } from './cfg.js'; export { getCoChangeMeta, hasCoChanges, upsertCoChangeMeta } from './cochange.js'; - export { getComplexityForNode } from './complexity.js'; export { hasDataflowTable } from './dataflow.js'; export { @@ -25,6 +25,7 @@ export { } from './edges.js'; export { getEmbeddingCount, getEmbeddingMeta, hasEmbeddings } from './embeddings.js'; export { getCallableNodes, getCallEdges, getFileNodesAll, getImportEdges } from './graph-read.js'; +export { InMemoryRepository } from './in-memory-repository.js'; export { bulkNodeIdsByFile, countEdges, @@ -43,3 +44,4 @@ export { iterateFunctionNodes, listFunctionNodes, } from './nodes.js'; +export { SqliteRepository } from './sqlite-repository.js'; diff --git a/src/db/repository/sqlite-repository.js b/src/db/repository/sqlite-repository.js new file mode 100644 index 00000000..c6b556e9 --- /dev/null +++ b/src/db/repository/sqlite-repository.js @@ -0,0 +1,219 @@ +import { Repository } from './base.js'; +import { hasCfgTables } from './cfg.js'; +import { getComplexityForNode } from './complexity.js'; +import { hasDataflowTable } from './dataflow.js'; +import { + countCrossFileCallers, + findAllIncomingEdges, + findAllOutgoingEdges, + findCalleeNames, + findCallees, + findCallerNames, + findCallers, + findCrossFileCallTargets, + findDistinctCallers, + findImportDependents, + findImportSources, + findImportTargets, + findIntraFileCallEdges, + getClassHierarchy, +} from './edges.js'; +import { hasEmbeddings } from './embeddings.js'; +import { getCallableNodes, getCallEdges, getFileNodesAll, getImportEdges } from './graph-read.js'; +import { + bulkNodeIdsByFile, + countEdges, + countFiles, + countNodes, + findFileNodes, + findNodeById, + findNodeByQualifiedName, + findNodeChildren, + findNodesByFile, + findNodesByScope, + findNodesForTriage, + findNodesWithFanIn, + getFunctionNodeId, + getNodeId, + iterateFunctionNodes, + listFunctionNodes, +} from './nodes.js'; + +/** + * SqliteRepository — wraps existing `fn(db, ...)` repository functions + * behind the Repository interface so callers can use `repo.method(...)`. + */ +export class SqliteRepository extends Repository { + #db; + + /** @param {object} db - better-sqlite3 Database instance */ + constructor(db) { + super(); + this.#db = db; + } + + /** Expose the underlying db for code that still needs raw access. */ + get db() { + return this.#db; + } + + // ── Node lookups ────────────────────────────────────────────────── + + findNodeById(id) { + return findNodeById(this.#db, id); + } + + findNodesByFile(file) { + return findNodesByFile(this.#db, file); + } + + findFileNodes(fileLike) { + return findFileNodes(this.#db, fileLike); + } + + findNodesWithFanIn(namePattern, opts) { + return findNodesWithFanIn(this.#db, namePattern, opts); + } + + countNodes() { + return countNodes(this.#db); + } + + countEdges() { + return countEdges(this.#db); + } + + countFiles() { + return countFiles(this.#db); + } + + getNodeId(name, kind, file, line) { + return getNodeId(this.#db, name, kind, file, line); + } + + getFunctionNodeId(name, file, line) { + return getFunctionNodeId(this.#db, name, file, line); + } + + bulkNodeIdsByFile(file) { + return bulkNodeIdsByFile(this.#db, file); + } + + findNodeChildren(parentId) { + return findNodeChildren(this.#db, parentId); + } + + findNodesByScope(scopeName, opts) { + return findNodesByScope(this.#db, scopeName, opts); + } + + findNodeByQualifiedName(qualifiedName, opts) { + return findNodeByQualifiedName(this.#db, qualifiedName, opts); + } + + listFunctionNodes(opts) { + return listFunctionNodes(this.#db, opts); + } + + iterateFunctionNodes(opts) { + return iterateFunctionNodes(this.#db, opts); + } + + findNodesForTriage(opts) { + return findNodesForTriage(this.#db, opts); + } + + // ── Edge queries ────────────────────────────────────────────────── + + findCallees(nodeId) { + return findCallees(this.#db, nodeId); + } + + findCallers(nodeId) { + return findCallers(this.#db, nodeId); + } + + findDistinctCallers(nodeId) { + return findDistinctCallers(this.#db, nodeId); + } + + findAllOutgoingEdges(nodeId) { + return findAllOutgoingEdges(this.#db, nodeId); + } + + findAllIncomingEdges(nodeId) { + return findAllIncomingEdges(this.#db, nodeId); + } + + findCalleeNames(nodeId) { + return findCalleeNames(this.#db, nodeId); + } + + findCallerNames(nodeId) { + return findCallerNames(this.#db, nodeId); + } + + findImportTargets(nodeId) { + return findImportTargets(this.#db, nodeId); + } + + findImportSources(nodeId) { + return findImportSources(this.#db, nodeId); + } + + findImportDependents(nodeId) { + return findImportDependents(this.#db, nodeId); + } + + findCrossFileCallTargets(file) { + return findCrossFileCallTargets(this.#db, file); + } + + countCrossFileCallers(nodeId, file) { + return countCrossFileCallers(this.#db, nodeId, file); + } + + getClassHierarchy(classNodeId) { + return getClassHierarchy(this.#db, classNodeId); + } + + findIntraFileCallEdges(file) { + return findIntraFileCallEdges(this.#db, file); + } + + // ── Graph-read queries ──────────────────────────────────────────── + + getCallableNodes() { + return getCallableNodes(this.#db); + } + + getCallEdges() { + return getCallEdges(this.#db); + } + + getFileNodesAll() { + return getFileNodesAll(this.#db); + } + + getImportEdges() { + return getImportEdges(this.#db); + } + + // ── Optional table checks ───────────────────────────────────────── + + hasCfgTables() { + return hasCfgTables(this.#db); + } + + hasEmbeddings() { + return hasEmbeddings(this.#db); + } + + hasDataflowTable() { + return hasDataflowTable(this.#db); + } + + getComplexityForNode(nodeId) { + return getComplexityForNode(this.#db, nodeId); + } +} diff --git a/tests/helpers/fixtures.js b/tests/helpers/fixtures.js new file mode 100644 index 00000000..e9225b12 --- /dev/null +++ b/tests/helpers/fixtures.js @@ -0,0 +1,144 @@ +import { InMemoryRepository } from '../../src/db/repository/in-memory-repository.js'; + +/** + * Fluent builder for constructing test graphs quickly. + * + * Usage: + * const { repo, ids } = createTestRepo() + * .fn('authenticate', 'auth.js', 10) + * .fn('authMiddleware', 'middleware.js', 5) + * .calls('authMiddleware', 'authenticate') + * .build(); + */ +class TestRepoBuilder { + #pending = { nodes: [], edges: [], complexity: [] }; + + /** + * Add a function node. + * @param {string} name + * @param {string} file + * @param {number} line + * @param {object} [extra] - Additional node attrs (role, end_line, scope, etc.) + */ + fn(name, file, line, extra = {}) { + return this.#addNode(name, 'function', file, line, extra); + } + + /** + * Add a method node. + */ + method(name, file, line, extra = {}) { + return this.#addNode(name, 'method', file, line, extra); + } + + /** + * Add a class node. + */ + cls(name, file, line, extra = {}) { + return this.#addNode(name, 'class', file, line, extra); + } + + /** + * Add a file node. + */ + file(filePath) { + return this.#addNode(filePath, 'file', filePath, 0); + } + + /** + * Add an arbitrary node. + */ + node(name, kind, file, line, extra = {}) { + return this.#addNode(name, kind, file, line, extra); + } + + /** + * Add a 'calls' edge between two named nodes. + */ + calls(sourceName, targetName) { + this.#pending.edges.push({ source: sourceName, target: targetName, kind: 'calls' }); + return this; + } + + /** + * Add an 'imports' edge. + */ + imports(sourceName, targetName) { + this.#pending.edges.push({ source: sourceName, target: targetName, kind: 'imports' }); + return this; + } + + /** + * Add an 'extends' edge. + */ + extends(sourceName, targetName) { + this.#pending.edges.push({ source: sourceName, target: targetName, kind: 'extends' }); + return this; + } + + /** + * Add an edge of any kind. + */ + edge(sourceName, targetName, kind) { + this.#pending.edges.push({ source: sourceName, target: targetName, kind }); + return this; + } + + /** + * Add complexity metrics for a named node. + */ + complexity(name, metrics) { + this.#pending.complexity.push({ name, metrics }); + return this; + } + + /** + * Build the InMemoryRepository and return { repo, ids }. + * `ids` maps node names to their auto-assigned IDs. + */ + build() { + const repo = new InMemoryRepository(); + const ids = new Map(); + + // Add nodes + for (const n of this.#pending.nodes) { + if (ids.has(n.name)) { + throw new Error( + `Duplicate node name: "${n.name}" — use unique names or qualify with file path`, + ); + } + ids.set(n.name, repo.addNode(n)); + } + + // Add edges + for (const e of this.#pending.edges) { + const sourceId = ids.get(e.source); + const targetId = ids.get(e.target); + if (sourceId == null) throw new Error(`Unknown source node: "${e.source}"`); + if (targetId == null) throw new Error(`Unknown target node: "${e.target}"`); + repo.addEdge({ source_id: sourceId, target_id: targetId, kind: e.kind }); + } + + // Add complexity + for (const c of this.#pending.complexity) { + const nodeId = ids.get(c.name); + if (nodeId == null) throw new Error(`Unknown node for complexity: "${c.name}"`); + repo.addComplexity(nodeId, c.metrics); + } + + return { repo, ids }; + } + + #addNode(name, kind, file, line, extra = {}) { + this.#pending.nodes.push({ name, kind, file, line, ...extra }); + return this; + } +} + +/** + * Create a new TestRepoBuilder. + * @returns {TestRepoBuilder} + */ +export function createTestRepo() { + return new TestRepoBuilder(); +} diff --git a/tests/unit/in-memory-repository.test.js b/tests/unit/in-memory-repository.test.js new file mode 100644 index 00000000..dc0c3ff4 --- /dev/null +++ b/tests/unit/in-memory-repository.test.js @@ -0,0 +1,551 @@ +import { describe, expect, it } from 'vitest'; +import { InMemoryRepository } from '../../src/db/repository/in-memory-repository.js'; +import { createTestRepo } from '../helpers/fixtures.js'; + +describe('InMemoryRepository', () => { + // ── Test graph ────────────────────────────────────────────────────── + // foo (function, src/foo.js:1, core) ← called by bar, Baz + // bar (method, src/bar.js:10, utility) → calls foo + // Baz (class, src/baz.js:20, entry) → calls foo + // qux (interface, src/qux.js:30) + // testFn (function, tests/foo.test.js:1) + function makeRepo() { + return createTestRepo() + .fn('foo', 'src/foo.js', 1, { role: 'core' }) + .method('bar', 'src/bar.js', 10, { role: 'utility' }) + .cls('Baz', 'src/baz.js', 20, { role: 'entry' }) + .node('qux', 'interface', 'src/qux.js', 30) + .fn('testFn', 'tests/foo.test.js', 1) + .calls('bar', 'foo') + .calls('Baz', 'foo') + .complexity('foo', { cognitive: 5, cyclomatic: 3, max_nesting: 2 }) + .build(); + } + + describe('countNodes / countEdges / countFiles', () => { + it('counts correctly', () => { + const { repo } = makeRepo(); + expect(repo.countNodes()).toBe(5); + expect(repo.countEdges()).toBe(2); + expect(repo.countFiles()).toBe(5); + }); + }); + + describe('findNodeById', () => { + it('returns node by id', () => { + const { repo, ids } = makeRepo(); + const node = repo.findNodeById(ids.get('foo')); + expect(node).toBeDefined(); + expect(node.name).toBe('foo'); + expect(node.kind).toBe('function'); + }); + + it('returns undefined for missing id', () => { + const { repo } = makeRepo(); + expect(repo.findNodeById(9999)).toBeUndefined(); + }); + }); + + describe('findNodesByFile', () => { + it('returns non-file nodes for a file, sorted by line', () => { + const { repo } = makeRepo(); + const nodes = repo.findNodesByFile('src/foo.js'); + expect(nodes.length).toBe(1); + expect(nodes[0].name).toBe('foo'); + }); + + it('excludes file-kind nodes', () => { + const { repo } = createTestRepo().file('src/app.js').fn('main', 'src/app.js', 1).build(); + const nodes = repo.findNodesByFile('src/app.js'); + expect(nodes.length).toBe(1); + expect(nodes[0].name).toBe('main'); + }); + }); + + describe('findFileNodes', () => { + it('finds file-kind nodes matching LIKE pattern', () => { + const { repo } = createTestRepo() + .file('src/app.js') + .file('src/utils.js') + .fn('main', 'src/app.js', 1) + .build(); + const nodes = repo.findFileNodes('%app%'); + expect(nodes.length).toBe(1); + expect(nodes[0].name).toBe('src/app.js'); + }); + }); + + describe('findNodesWithFanIn', () => { + it('returns nodes with fan-in count', () => { + const { repo } = makeRepo(); + const rows = repo.findNodesWithFanIn('%foo%'); + const foo = rows.find((r) => r.name === 'foo'); + expect(foo).toBeDefined(); + expect(foo.fan_in).toBe(2); + }); + + it('filters by kinds', () => { + const { repo } = makeRepo(); + const rows = repo.findNodesWithFanIn('%foo%', { kinds: ['method'] }); + expect(rows.length).toBe(0); + }); + + it('filters by file', () => { + const { repo } = makeRepo(); + const rows = repo.findNodesWithFanIn('%foo%', { file: 'src' }); + expect(rows.every((r) => r.file.includes('src'))).toBe(true); + }); + }); + + describe('getNodeId / getFunctionNodeId', () => { + it('getNodeId returns id for exact tuple match', () => { + const { repo, ids } = makeRepo(); + expect(repo.getNodeId('foo', 'function', 'src/foo.js', 1)).toBe(ids.get('foo')); + }); + + it('getNodeId returns undefined for no match', () => { + const { repo } = makeRepo(); + expect(repo.getNodeId('nope', 'function', 'x.js', 1)).toBeUndefined(); + }); + + it('getFunctionNodeId restricts to function/method', () => { + const { repo, ids } = makeRepo(); + expect(repo.getFunctionNodeId('foo', 'src/foo.js', 1)).toBe(ids.get('foo')); + expect(repo.getFunctionNodeId('Baz', 'src/baz.js', 20)).toBeUndefined(); // class, not function + }); + }); + + describe('bulkNodeIdsByFile', () => { + it('returns all nodes in a file', () => { + const { repo } = makeRepo(); + const rows = repo.bulkNodeIdsByFile('src/foo.js'); + expect(rows.length).toBe(1); + expect(rows[0].name).toBe('foo'); + }); + }); + + describe('findNodeChildren', () => { + it('finds children by parent_id', () => { + const repo2 = new InMemoryRepository(); + const classId = repo2.addNode({ + name: 'MyClass', + kind: 'class', + file: 'src/cls.js', + line: 1, + }); + repo2.addNode({ + name: 'doThing', + kind: 'method', + file: 'src/cls.js', + line: 5, + parent_id: classId, + }); + const children = repo2.findNodeChildren(classId); + expect(children.length).toBe(1); + expect(children[0].name).toBe('doThing'); + }); + }); + + describe('findNodesByScope', () => { + it('filters by scope name', () => { + const repo = new InMemoryRepository(); + repo.addNode({ name: 'MyClass', kind: 'class', file: 'src/cls.js', line: 1 }); + repo.addNode({ + name: 'doThing', + kind: 'method', + file: 'src/cls.js', + line: 5, + scope: 'MyClass', + }); + repo.addNode({ + name: 'helper', + kind: 'function', + file: 'src/cls.js', + line: 20, + scope: 'MyClass', + }); + repo.addNode({ name: 'other', kind: 'function', file: 'src/other.js', line: 1 }); + + const scoped = repo.findNodesByScope('MyClass'); + expect(scoped.length).toBe(2); + }); + + it('filters by scope + kind', () => { + const repo = new InMemoryRepository(); + repo.addNode({ + name: 'doThing', + kind: 'method', + file: 'src/cls.js', + line: 5, + scope: 'MyClass', + }); + repo.addNode({ + name: 'helper', + kind: 'function', + file: 'src/cls.js', + line: 20, + scope: 'MyClass', + }); + + const methods = repo.findNodesByScope('MyClass', { kind: 'method' }); + expect(methods.length).toBe(1); + expect(methods[0].name).toBe('doThing'); + }); + }); + + describe('findNodeByQualifiedName', () => { + it('finds nodes by qualified name', () => { + const repo = new InMemoryRepository(); + repo.addNode({ + name: 'format', + kind: 'method', + file: 'src/a.js', + line: 10, + qualified_name: 'DateHelper.format', + }); + repo.addNode({ + name: 'format', + kind: 'method', + file: 'src/b.js', + line: 20, + qualified_name: 'DateHelper.format', + }); + + const nodes = repo.findNodeByQualifiedName('DateHelper.format'); + expect(nodes.length).toBe(2); + }); + + it('filters by file', () => { + const repo = new InMemoryRepository(); + repo.addNode({ + name: 'format', + kind: 'method', + file: 'src/a.js', + line: 10, + qualified_name: 'DateHelper.format', + }); + repo.addNode({ + name: 'format', + kind: 'method', + file: 'src/b.js', + line: 20, + qualified_name: 'DateHelper.format', + }); + + const nodes = repo.findNodeByQualifiedName('DateHelper.format', { file: 'a.js' }); + expect(nodes.length).toBe(1); + }); + }); + + describe('listFunctionNodes / iterateFunctionNodes', () => { + it('returns function/method/class nodes', () => { + const { repo } = makeRepo(); + const rows = repo.listFunctionNodes(); + expect(rows.length).toBe(4); // foo, bar, Baz, testFn + expect(rows.every((r) => ['function', 'method', 'class'].includes(r.kind))).toBe(true); + }); + + it('filters by file', () => { + const { repo } = makeRepo(); + const rows = repo.listFunctionNodes({ file: 'foo' }); + expect(rows.every((r) => r.file.includes('foo'))).toBe(true); + }); + + it('filters by pattern', () => { + const { repo } = makeRepo(); + const rows = repo.listFunctionNodes({ pattern: 'Baz' }); + expect(rows.length).toBe(1); + expect(rows[0].name).toBe('Baz'); + }); + + it('excludes test files when noTests is set', () => { + const { repo } = makeRepo(); + const rows = repo.listFunctionNodes({ noTests: true }); + expect(rows.every((r) => !r.file.includes('.test.'))).toBe(true); + expect(rows.length).toBe(3); + }); + + it('orders by file, line', () => { + const { repo } = makeRepo(); + const rows = repo.listFunctionNodes(); + for (let i = 1; i < rows.length; i++) { + const prev = `${rows[i - 1].file}:${String(rows[i - 1].line).padStart(6, '0')}`; + const curr = `${rows[i].file}:${String(rows[i].line).padStart(6, '0')}`; + expect(prev <= curr).toBe(true); + } + }); + + it('iterateFunctionNodes returns an iterator', () => { + const { repo } = makeRepo(); + const rows = [...repo.iterateFunctionNodes()]; + expect(rows.length).toBe(4); + }); + + it('iterateFunctionNodes respects filters', () => { + const { repo } = makeRepo(); + const rows = [...repo.iterateFunctionNodes({ noTests: true })]; + expect(rows.length).toBe(3); + }); + }); + + describe('findNodesForTriage', () => { + it('returns nodes with triage signals', () => { + const { repo } = makeRepo(); + const rows = repo.findNodesForTriage(); + expect(rows.length).toBe(4); + const foo = rows.find((r) => r.name === 'foo'); + expect(foo.fan_in).toBe(2); + expect(foo.cognitive).toBe(5); + }); + + it('excludes test files when noTests is set', () => { + const { repo } = makeRepo(); + const rows = repo.findNodesForTriage({ noTests: true }); + expect(rows.every((r) => !r.file.includes('.test.'))).toBe(true); + }); + + it('filters by kind', () => { + const { repo } = makeRepo(); + const rows = repo.findNodesForTriage({ kind: 'class' }); + expect(rows.length).toBe(1); + expect(rows[0].name).toBe('Baz'); + }); + + it('filters by role', () => { + const { repo } = makeRepo(); + const rows = repo.findNodesForTriage({ role: 'core' }); + expect(rows.length).toBe(1); + expect(rows[0].name).toBe('foo'); + }); + + it('throws on invalid kind', () => { + const { repo } = makeRepo(); + expect(() => repo.findNodesForTriage({ kind: 'bogus' })).toThrow('Invalid kind'); + }); + + it('throws on invalid role', () => { + const { repo } = makeRepo(); + expect(() => repo.findNodesForTriage({ role: 'supervisor' })).toThrow('Invalid role'); + }); + }); + + // ── Edge queries ────────────────────────────────────────────────── + + describe('findCallees / findCallers', () => { + it('finds callees', () => { + const { repo, ids } = makeRepo(); + const callees = repo.findCallees(ids.get('bar')); + expect(callees.length).toBe(1); + expect(callees[0].name).toBe('foo'); + }); + + it('finds callers', () => { + const { repo, ids } = makeRepo(); + const callers = repo.findCallers(ids.get('foo')); + expect(callers.length).toBe(2); + const names = callers.map((c) => c.name).sort(); + expect(names).toEqual(['Baz', 'bar']); + }); + }); + + describe('findDistinctCallers', () => { + it('deduplicates callers', () => { + const { repo, ids } = createTestRepo() + .fn('target', 'src/t.js', 1) + .fn('caller', 'src/c.js', 1) + .calls('caller', 'target') + .calls('caller', 'target') // duplicate edge + .build(); + const callers = repo.findDistinctCallers(ids.get('target')); + expect(callers.length).toBe(1); + }); + }); + + describe('findAllOutgoingEdges / findAllIncomingEdges', () => { + it('returns all outgoing edges with edge_kind', () => { + const { repo, ids } = makeRepo(); + const edges = repo.findAllOutgoingEdges(ids.get('bar')); + expect(edges.length).toBe(1); + expect(edges[0].edge_kind).toBe('calls'); + expect(edges[0].name).toBe('foo'); + }); + + it('returns all incoming edges with edge_kind', () => { + const { repo, ids } = makeRepo(); + const edges = repo.findAllIncomingEdges(ids.get('foo')); + expect(edges.length).toBe(2); + expect(edges.every((e) => e.edge_kind === 'calls')).toBe(true); + }); + }); + + describe('findCalleeNames / findCallerNames', () => { + it('returns sorted callee names', () => { + const { repo, ids } = createTestRepo() + .fn('main', 'src/m.js', 1) + .fn('beta', 'src/b.js', 1) + .fn('alpha', 'src/a.js', 1) + .calls('main', 'beta') + .calls('main', 'alpha') + .build(); + expect(repo.findCalleeNames(ids.get('main'))).toEqual(['alpha', 'beta']); + }); + + it('returns sorted caller names', () => { + const { repo, ids } = makeRepo(); + expect(repo.findCallerNames(ids.get('foo'))).toEqual(['Baz', 'bar']); + }); + }); + + describe('import edge queries', () => { + it('finds import targets and sources', () => { + const { repo, ids } = createTestRepo() + .file('src/app.js') + .file('src/utils.js') + .imports('src/app.js', 'src/utils.js') + .build(); + const targets = repo.findImportTargets(ids.get('src/app.js')); + expect(targets.length).toBe(1); + expect(targets[0].file).toBe('src/utils.js'); + + const sources = repo.findImportSources(ids.get('src/utils.js')); + expect(sources.length).toBe(1); + expect(sources[0].file).toBe('src/app.js'); + }); + + it('finds import dependents', () => { + const { repo, ids } = createTestRepo() + .file('src/app.js') + .file('src/utils.js') + .imports('src/app.js', 'src/utils.js') + .build(); + const deps = repo.findImportDependents(ids.get('src/utils.js')); + expect(deps.length).toBe(1); + expect(deps[0].name).toBe('src/app.js'); + }); + }); + + describe('findCrossFileCallTargets', () => { + it('returns set of IDs called from other files', () => { + const { repo, ids } = makeRepo(); + const targets = repo.findCrossFileCallTargets('src/foo.js'); + expect(targets.size).toBe(1); + expect(targets.has(ids.get('foo'))).toBe(true); + }); + }); + + describe('countCrossFileCallers', () => { + it('counts callers from different files', () => { + const { repo, ids } = makeRepo(); + expect(repo.countCrossFileCallers(ids.get('foo'), 'src/foo.js')).toBe(2); + }); + }); + + describe('getClassHierarchy', () => { + it('returns empty set for no extends', () => { + const { repo, ids } = makeRepo(); + expect(repo.getClassHierarchy(ids.get('Baz')).size).toBe(0); + }); + + it('resolves multi-level hierarchy', () => { + const { repo, ids } = createTestRepo() + .cls('Child', 'src/c.js', 1) + .cls('Parent', 'src/p.js', 1) + .cls('Grandparent', 'src/g.js', 1) + .extends('Child', 'Parent') + .extends('Parent', 'Grandparent') + .build(); + const ancestors = repo.getClassHierarchy(ids.get('Child')); + expect(ancestors.size).toBe(2); + expect(ancestors.has(ids.get('Parent'))).toBe(true); + expect(ancestors.has(ids.get('Grandparent'))).toBe(true); + }); + + it('handles diamond inheritance', () => { + const { repo, ids } = createTestRepo() + .cls('D', 'src/d.js', 1) + .cls('B1', 'src/b1.js', 1) + .cls('B2', 'src/b2.js', 1) + .cls('Top', 'src/top.js', 1) + .extends('D', 'B1') + .extends('D', 'B2') + .extends('B1', 'Top') + .extends('B2', 'Top') + .build(); + const ancestors = repo.getClassHierarchy(ids.get('D')); + expect(ancestors.size).toBe(3); + }); + }); + + describe('findIntraFileCallEdges', () => { + it('returns intra-file call pairs', () => { + const { repo } = createTestRepo() + .fn('a', 'src/f.js', 1) + .fn('b', 'src/f.js', 10) + .fn('c', 'src/other.js', 1) + .calls('a', 'b') + .calls('a', 'c') + .build(); + const edges = repo.findIntraFileCallEdges('src/f.js'); + expect(edges.length).toBe(1); + expect(edges[0]).toEqual({ caller_name: 'a', callee_name: 'b' }); + }); + }); + + // ── Graph-read queries ──────────────────────────────────────────── + + describe('getCallableNodes', () => { + it('returns core symbol kind nodes', () => { + const { repo } = makeRepo(); + const nodes = repo.getCallableNodes(); + // foo, bar, Baz, qux, testFn — all are core kinds + expect(nodes.length).toBe(5); + }); + }); + + describe('getCallEdges / getImportEdges', () => { + it('returns call edges', () => { + const { repo } = makeRepo(); + expect(repo.getCallEdges().length).toBe(2); + }); + + it('returns import edges', () => { + const { repo } = createTestRepo().file('a.js').file('b.js').imports('a.js', 'b.js').build(); + expect(repo.getImportEdges().length).toBe(1); + }); + }); + + describe('getFileNodesAll', () => { + it('returns file-kind nodes', () => { + const { repo } = createTestRepo().file('src/a.js').fn('main', 'src/a.js', 1).build(); + const files = repo.getFileNodesAll(); + expect(files.length).toBe(1); + expect(files[0].name).toBe('src/a.js'); + }); + }); + + // ── Optional table checks ───────────────────────────────────────── + + describe('optional table stubs', () => { + it('returns false for hasCfgTables, hasEmbeddings, hasDataflowTable', () => { + const { repo } = makeRepo(); + expect(repo.hasCfgTables()).toBe(false); + expect(repo.hasEmbeddings()).toBe(false); + expect(repo.hasDataflowTable()).toBe(false); + }); + }); + + describe('getComplexityForNode', () => { + it('returns complexity metrics', () => { + const { repo, ids } = makeRepo(); + const cx = repo.getComplexityForNode(ids.get('foo')); + expect(cx.cognitive).toBe(5); + expect(cx.cyclomatic).toBe(3); + expect(cx.max_nesting).toBe(2); + }); + + it('returns undefined for nodes without complexity', () => { + const { repo, ids } = makeRepo(); + expect(repo.getComplexityForNode(ids.get('bar'))).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/repository-parity.test.js b/tests/unit/repository-parity.test.js new file mode 100644 index 00000000..33bf21ed --- /dev/null +++ b/tests/unit/repository-parity.test.js @@ -0,0 +1,466 @@ +import Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { initSchema } from '../../src/db/migrations.js'; +import { InMemoryRepository } from '../../src/db/repository/in-memory-repository.js'; +import { SqliteRepository } from '../../src/db/repository/sqlite-repository.js'; + +/** + * Parity tests — run the same assertions against both SqliteRepository and + * InMemoryRepository to verify behavioral equivalence. + */ + +function seedSqliteRepo() { + const db = new Database(':memory:'); + initSchema(db); + + const insertNode = db.prepare( + 'INSERT INTO nodes (name, kind, file, line, end_line, role, scope, qualified_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + ); + insertNode.run('foo', 'function', 'src/foo.js', 1, 15, 'core', null, 'foo'); + insertNode.run('bar', 'method', 'src/bar.js', 10, 30, 'utility', 'BarClass', 'BarClass.bar'); + insertNode.run('Baz', 'class', 'src/baz.js', 20, 50, 'entry', null, 'Baz'); + insertNode.run('qux', 'interface', 'src/qux.js', 30, 40, null, null, null); + insertNode.run('testFn', 'function', 'tests/foo.test.js', 1, 10, null, null, null); + insertNode.run('jestFn', 'function', 'src/__tests__/helper.js', 1, 10, null, null, null); + insertNode.run('storyFn', 'function', 'src/Button.stories.js', 1, 10, null, null, null); + // File-kind node for findFileNodes / getFileNodesAll + db.prepare('INSERT INTO nodes (name, kind, file, line) VALUES (?, ?, ?, ?)').run( + 'src/foo.js', + 'file', + 'src/foo.js', + 0, + ); + // Child node with parent_id for findNodeChildren + db.prepare( + 'INSERT INTO nodes (name, kind, file, line, end_line, parent_id, scope) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run('bazMethod', 'method', 'src/baz.js', 25, 35, null, 'Baz'); + + const fooId = db.prepare("SELECT id FROM nodes WHERE name = 'foo'").get().id; + const barId = db.prepare("SELECT id FROM nodes WHERE name = 'bar'").get().id; + const bazId = db.prepare("SELECT id FROM nodes WHERE name = 'Baz'").get().id; + const fooFileId = db.prepare("SELECT id FROM nodes WHERE name = 'src/foo.js'").get().id; + const bazMethodId = db.prepare("SELECT id FROM nodes WHERE name = 'bazMethod'").get().id; + // Set parent_id for bazMethod -> Baz + db.prepare('UPDATE nodes SET parent_id = ? WHERE id = ?').run(bazId, bazMethodId); + + const insertEdge = db.prepare('INSERT INTO edges (source_id, target_id, kind) VALUES (?, ?, ?)'); + insertEdge.run(barId, fooId, 'calls'); + insertEdge.run(bazId, fooId, 'calls'); + insertEdge.run(bazId, barId, 'extends'); + // Import edge: foo.js imports bar.js + insertEdge.run(fooId, barId, 'imports'); + + db.prepare( + 'INSERT INTO function_complexity (node_id, cognitive, cyclomatic, max_nesting, maintainability_index, halstead_volume) VALUES (?, ?, ?, ?, ?, ?)', + ).run(fooId, 5, 3, 2, 80, 100); + + return { + repo: new SqliteRepository(db), + ids: { foo: fooId, bar: barId, baz: bazId, fooFile: fooFileId, bazMethod: bazMethodId }, + }; +} + +function seedInMemoryRepo() { + const repo = new InMemoryRepository(); + const fooId = repo.addNode({ + name: 'foo', + kind: 'function', + file: 'src/foo.js', + line: 1, + end_line: 15, + role: 'core', + qualified_name: 'foo', + }); + const barId = repo.addNode({ + name: 'bar', + kind: 'method', + file: 'src/bar.js', + line: 10, + end_line: 30, + role: 'utility', + scope: 'BarClass', + qualified_name: 'BarClass.bar', + }); + const bazId = repo.addNode({ + name: 'Baz', + kind: 'class', + file: 'src/baz.js', + line: 20, + end_line: 50, + role: 'entry', + qualified_name: 'Baz', + }); + repo.addNode({ name: 'qux', kind: 'interface', file: 'src/qux.js', line: 30, end_line: 40 }); + repo.addNode({ + name: 'testFn', + kind: 'function', + file: 'tests/foo.test.js', + line: 1, + end_line: 10, + }); + repo.addNode({ + name: 'jestFn', + kind: 'function', + file: 'src/__tests__/helper.js', + line: 1, + end_line: 10, + }); + repo.addNode({ + name: 'storyFn', + kind: 'function', + file: 'src/Button.stories.js', + line: 1, + end_line: 10, + }); + // File-kind node for findFileNodes / getFileNodesAll + const fooFileId = repo.addNode({ name: 'src/foo.js', kind: 'file', file: 'src/foo.js', line: 0 }); + // Child node with parent_id for findNodeChildren + const bazMethodId = repo.addNode({ + name: 'bazMethod', + kind: 'method', + file: 'src/baz.js', + line: 25, + end_line: 35, + parent_id: bazId, + scope: 'Baz', + }); + + repo.addEdge({ source_id: barId, target_id: fooId, kind: 'calls' }); + repo.addEdge({ source_id: bazId, target_id: fooId, kind: 'calls' }); + repo.addEdge({ source_id: bazId, target_id: barId, kind: 'extends' }); + // Import edge: foo imports bar + repo.addEdge({ source_id: fooId, target_id: barId, kind: 'imports' }); + + repo.addComplexity(fooId, { + cognitive: 5, + cyclomatic: 3, + max_nesting: 2, + maintainability_index: 80, + halstead_volume: 100, + }); + + return { + repo, + ids: { foo: fooId, bar: barId, baz: bazId, fooFile: fooFileId, bazMethod: bazMethodId }, + }; +} + +describe.each([ + { label: 'SqliteRepository', seed: seedSqliteRepo }, + { label: 'InMemoryRepository', seed: seedInMemoryRepo }, +])('Repository parity: $label', ({ seed }) => { + let repo; + let ids; + let dbToClose; + + beforeEach(() => { + const result = seed(); + repo = result.repo; + ids = result.ids; + if (repo.db) dbToClose = repo.db; + }); + + afterEach(() => { + if (dbToClose) dbToClose.close(); + }); + + // ── Counts ────────────────────────────────────────────────────────── + + it('countNodes', () => { + expect(repo.countNodes()).toBe(9); + }); + + it('countEdges', () => { + expect(repo.countEdges()).toBe(4); // 2 calls + 1 extends + 1 imports + }); + + it('countFiles', () => { + expect(repo.countFiles()).toBe(7); // foo.js, bar.js, baz.js, qux.js, foo.test.js, __tests__/helper.js, Button.stories.js + }); + + // ── Node lookups ──────────────────────────────────────────────────── + + it('findNodeById', () => { + const node = repo.findNodeById(ids.foo); + expect(node).toBeDefined(); + expect(node.name).toBe('foo'); + expect(node.kind).toBe('function'); + }); + + it('findNodeById returns undefined for missing', () => { + expect(repo.findNodeById(9999)).toBeUndefined(); + }); + + it('findNodesByFile', () => { + const nodes = repo.findNodesByFile('src/foo.js'); + expect(nodes.length).toBe(1); + expect(nodes[0].name).toBe('foo'); + }); + + it('getNodeId', () => { + expect(repo.getNodeId('foo', 'function', 'src/foo.js', 1)).toBe(ids.foo); + expect(repo.getNodeId('nope', 'function', 'x.js', 1)).toBeUndefined(); + }); + + it('getFunctionNodeId', () => { + expect(repo.getFunctionNodeId('foo', 'src/foo.js', 1)).toBe(ids.foo); + // class is not function/method + expect(repo.getFunctionNodeId('Baz', 'src/baz.js', 20)).toBeUndefined(); + }); + + it('bulkNodeIdsByFile', () => { + const rows = repo.bulkNodeIdsByFile('src/foo.js'); + expect(rows.length).toBe(2); // foo (function) + src/foo.js (file) + expect(rows.map((r) => r.name).sort()).toEqual(['foo', 'src/foo.js']); + }); + + // ── Listing / iteration ───────────────────────────────────────────── + + it('listFunctionNodes returns fn/method/class', () => { + const rows = repo.listFunctionNodes(); + expect(rows.length).toBe(7); // foo, bar, Baz, testFn, bazMethod, jestFn, storyFn + expect(rows.every((r) => ['function', 'method', 'class'].includes(r.kind))).toBe(true); + }); + + it('listFunctionNodes excludes tests', () => { + const rows = repo.listFunctionNodes({ noTests: true }); + expect(rows.length).toBe(4); // foo, bar, Baz, bazMethod + expect(rows.every((r) => !r.file.includes('.test.'))).toBe(true); + expect(rows.every((r) => !r.file.includes('__tests__'))).toBe(true); + expect(rows.every((r) => !r.file.includes('.stories.'))).toBe(true); + }); + + it('listFunctionNodes filters by pattern', () => { + const rows = repo.listFunctionNodes({ pattern: 'Baz' }); + expect(rows.length).toBe(2); // Baz + bazMethod (LIKE is case-insensitive) + }); + + it('iterateFunctionNodes matches listFunctionNodes', () => { + const list = repo.listFunctionNodes(); + const iter = [...repo.iterateFunctionNodes()]; + expect(iter.length).toBe(list.length); + }); + + // ── Edge queries ──────────────────────────────────────────────────── + + it('findCallees', () => { + const callees = repo.findCallees(ids.bar); + expect(callees.length).toBe(1); + expect(callees[0].name).toBe('foo'); + }); + + it('findCallers', () => { + const callers = repo.findCallers(ids.foo); + expect(callers.length).toBe(2); + const names = callers.map((c) => c.name).sort(); + expect(names).toEqual(['Baz', 'bar']); + }); + + it('findDistinctCallers', () => { + const callers = repo.findDistinctCallers(ids.foo); + expect(callers.length).toBe(2); + }); + + it('findCalleeNames / findCallerNames', () => { + const calleeNames = repo.findCalleeNames(ids.bar); + expect(calleeNames).toEqual(['foo']); + + const callerNames = repo.findCallerNames(ids.foo); + expect(callerNames).toEqual(['Baz', 'bar']); + }); + + it('findAllOutgoingEdges', () => { + const edges = repo.findAllOutgoingEdges(ids.baz); + expect(edges.length).toBe(2); // calls foo, extends bar + const kinds = edges.map((e) => e.edge_kind).sort(); + expect(kinds).toEqual(['calls', 'extends']); + }); + + it('findAllIncomingEdges', () => { + const edges = repo.findAllIncomingEdges(ids.foo); + expect(edges.length).toBe(2); + expect(edges.every((e) => e.edge_kind === 'calls')).toBe(true); + }); + + it('findCrossFileCallTargets', () => { + const targets = repo.findCrossFileCallTargets('src/foo.js'); + expect(targets.size).toBe(1); + expect(targets.has(ids.foo)).toBe(true); + }); + + it('countCrossFileCallers', () => { + expect(repo.countCrossFileCallers(ids.foo, 'src/foo.js')).toBe(2); + }); + + it('findIntraFileCallEdges', () => { + // No intra-file calls in our graph (foo, bar, Baz are all different files) + const edges = repo.findIntraFileCallEdges('src/foo.js'); + expect(edges.length).toBe(0); + }); + + // ── Class hierarchy ───────────────────────────────────────────────── + + it('getClassHierarchy', () => { + const ancestors = repo.getClassHierarchy(ids.baz); + expect(ancestors.size).toBe(1); // Baz extends bar + expect(ancestors.has(ids.bar)).toBe(true); + }); + + // ── Graph-read queries ────────────────────────────────────────────── + + it('getCallEdges', () => { + const edges = repo.getCallEdges(); + expect(edges.length).toBe(2); // only 'calls' edges, not 'extends' + }); + + // ── Complexity ────────────────────────────────────────────────────── + + it('getComplexityForNode', () => { + const cx = repo.getComplexityForNode(ids.foo); + expect(cx).toBeDefined(); + expect(cx.cognitive).toBe(5); + expect(cx.cyclomatic).toBe(3); + expect(cx.max_nesting).toBe(2); + }); + + it('getComplexityForNode returns undefined for no data', () => { + expect(repo.getComplexityForNode(ids.bar)).toBeUndefined(); + }); + + // ── Optional table checks ────────────────────────────────────────── + + it('hasDataflowTable returns false', () => { + expect(repo.hasDataflowTable()).toBe(false); + }); + + // ── Validation ──────────────────────────────────────────────────── + + it('findNodesForTriage throws on invalid kind', () => { + expect(() => repo.findNodesForTriage({ kind: 'bogus' })).toThrow('Invalid kind'); + }); + + it('findNodesForTriage throws on invalid role', () => { + expect(() => repo.findNodesForTriage({ role: 'supervisor' })).toThrow('Invalid role'); + }); + + // ── Scope / qualified-name lookups ────────────────────────────────── + + it('findNodesByScope', () => { + const nodes = repo.findNodesByScope('BarClass'); + expect(nodes.length).toBe(1); // only bar has scope 'BarClass' + expect(nodes[0].name).toBe('bar'); + }); + + it('findNodesByScope with file filter', () => { + const nodes = repo.findNodesByScope('BarClass', { file: 'bar.js' }); + expect(nodes.length).toBe(1); + expect(nodes[0].name).toBe('bar'); + }); + + it('findNodeByQualifiedName', () => { + const nodes = repo.findNodeByQualifiedName('BarClass.bar'); + expect(nodes.length).toBe(1); + expect(nodes[0].name).toBe('bar'); + }); + + it('findNodeByQualifiedName with file filter', () => { + const nodes = repo.findNodeByQualifiedName('BarClass.bar', { file: 'bar.js' }); + expect(nodes.length).toBe(1); + expect(nodes[0].name).toBe('bar'); + }); + + // ── LIKE escaping ───────────────────────────────────────────────── + + it('findNodesByScope treats _ as literal in file filter', () => { + // "_" is a LIKE wildcard — must not match arbitrary single chars + const nodes = repo.findNodesByScope('BarClass', { file: '_ar.js' }); + expect(nodes.length).toBe(0); + }); + + // ── Fan-in ───────────────────────────────────────────────────────── + + it('findNodesWithFanIn', () => { + const nodes = repo.findNodesWithFanIn('%foo%'); + expect(nodes.length).toBeGreaterThanOrEqual(1); + const foo = nodes.find((n) => n.name === 'foo'); + expect(foo).toBeDefined(); + expect(foo.fan_in).toBe(2); // bar calls foo, Baz calls foo + }); + + // ── File nodes ──────────────────────────────────────────────────── + + it('findFileNodes', () => { + const nodes = repo.findFileNodes('%foo%'); + expect(nodes.length).toBe(1); + expect(nodes[0].kind).toBe('file'); + expect(nodes[0].file).toBe('src/foo.js'); + }); + + it('getFileNodesAll', () => { + const nodes = repo.getFileNodesAll(); + expect(nodes.length).toBe(1); + expect(nodes[0].file).toBe('src/foo.js'); + }); + + // ── Node children ───────────────────────────────────────────────── + + it('findNodeChildren', () => { + const children = repo.findNodeChildren(ids.baz); + expect(children.length).toBe(1); + expect(children[0].name).toBe('bazMethod'); + expect(children[0].kind).toBe('method'); + }); + + it('findNodeChildren returns empty for leaf', () => { + expect(repo.findNodeChildren(ids.foo).length).toBe(0); + }); + + // ── Import queries ──────────────────────────────────────────────── + + it('findImportTargets', () => { + const targets = repo.findImportTargets(ids.foo); + expect(targets.length).toBe(1); + expect(targets[0].file).toBe('src/bar.js'); + expect(targets[0].edge_kind).toBe('imports'); + }); + + it('findImportSources', () => { + const sources = repo.findImportSources(ids.bar); + expect(sources.length).toBe(1); + expect(sources[0].file).toBe('src/foo.js'); + }); + + it('findImportDependents', () => { + const deps = repo.findImportDependents(ids.bar); + expect(deps.length).toBe(1); + expect(deps[0].name).toBe('foo'); + }); + + it('getImportEdges', () => { + const edges = repo.getImportEdges(); + expect(edges.length).toBe(1); + expect(edges[0].source_id).toBe(ids.foo); + expect(edges[0].target_id).toBe(ids.bar); + }); + + // ── Callable nodes ──────────────────────────────────────────────── + + it('getCallableNodes', () => { + const nodes = repo.getCallableNodes(); + // CORE_SYMBOL_KINDS includes function, method, class, interface, etc. + expect(nodes.length).toBeGreaterThanOrEqual(7); + expect(nodes.every((n) => n.id && n.name && n.kind && n.file)).toBe(true); + }); + + // ── Optional table checks ───────────────────────────────────────── + + it('hasCfgTables', () => { + // SQLite returns true (initSchema creates cfg tables), InMemory returns false. + // Both are correct — just verify the method exists and returns a boolean. + expect(typeof repo.hasCfgTables()).toBe('boolean'); + }); + + it('hasEmbeddings returns false', () => { + expect(repo.hasEmbeddings()).toBe(false); + }); +});