diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d584396..50f2b1fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,34 @@ jobs: - name: Run tests run: npm test + typecheck: + runs-on: ubuntu-latest + name: TypeScript type check + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Install dependencies + shell: bash + run: | + for attempt in 1 2 3; do + npm install && break + if [ "$attempt" -lt 3 ]; then + echo "::warning::npm install attempt $attempt failed, retrying in 15s..." + sleep 15 + else + echo "::error::npm install failed after 3 attempts" + exit 1 + fi + done + + - name: Type check + run: npm run typecheck + rust-check: runs-on: ubuntu-latest name: Rust compile check @@ -93,7 +121,7 @@ jobs: ci-pipeline: if: always() - needs: [lint, test, rust-check] + needs: [lint, test, typecheck, rust-check] runs-on: ubuntu-latest name: CI Testing Pipeline steps: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9b253139..2dc364de 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -217,6 +217,9 @@ jobs: node scripts/sync-native-versions.js --strip echo "Packaging version $VERSION" + - name: Build TypeScript + run: npm run build + - name: Disable prepublishOnly run: npm pkg delete scripts.prepublishOnly @@ -395,6 +398,9 @@ jobs: node scripts/sync-native-versions.js echo "Publishing version $VERSION" + - name: Build TypeScript + run: npm run build + - name: Disable prepublishOnly run: npm pkg delete scripts.prepublishOnly diff --git a/.gitignore b/.gitignore index 596889f8..9acd21d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ .codegraph/ +dist/ +.tsbuildinfo *.db coverage/ .env diff --git a/package.json b/package.json index ec41e221..eda1fb14 100644 --- a/package.json +++ b/package.json @@ -3,22 +3,23 @@ "version": "3.2.0", "description": "Local code graph CLI — parse codebases with tree-sitter, build dependency graphs, query them", "type": "module", - "main": "src/index.js", + "main": "dist/index.js", "exports": { ".": { - "import": "./src/index.js", - "require": "./src/index.cjs", - "default": "./src/index.cjs" + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.cjs" }, "./cli": { - "import": "./src/cli.js" + "import": "./dist/cli.js" }, "./package.json": "./package.json" }, "bin": { - "codegraph": "./src/cli.js" + "codegraph": "./dist/cli.js" }, "files": [ + "dist/", "src/", "grammars/", "LICENSE", @@ -28,14 +29,18 @@ "node": ">=20" }, "scripts": { + "build": "tsc", "build:wasm": "node scripts/build-wasm.js", + "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "lint": "biome check src/ tests/", "lint:fix": "biome check --write src/ tests/", "format": "biome format --write src/ tests/", - "prepare": "npm run build:wasm && husky && npm run deps:tree", + "prepack": "npm run build", + "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true});require('fs').rmSync('.tsbuildinfo',{force:true})\"", + "prepare": "npm run build:wasm && npm run build && husky && npm run deps:tree", "deps:tree": "node scripts/gen-deps.cjs", "release": "commit-and-tag-version", "release:dry-run": "commit-and-tag-version --dry-run", @@ -102,6 +107,7 @@ "tree-sitter-ruby": "^0.23.1", "tree-sitter-rust": "^0.24.0", "tree-sitter-typescript": "^0.23.2", + "typescript": "^5.9.3", "vitest": "^4.0.18" }, "license": "Apache-2.0" diff --git a/src/cli/commands/diff-impact.js b/src/cli/commands/diff-impact.js index 49456dba..52dcc97c 100644 --- a/src/cli/commands/diff-impact.js +++ b/src/cli/commands/diff-impact.js @@ -13,6 +13,7 @@ export const command = { ['--staged', 'Analyze staged changes instead of unstaged'], ['--depth ', 'Max transitive caller depth', '3'], ['-f, --format ', 'Output format: text, mermaid, json', 'text'], + ['--no-implementations', 'Exclude interface/trait implementors from blast radius'], ], execute([ref], opts, ctx) { diffImpact(opts.db, { @@ -20,6 +21,7 @@ export const command = { staged: opts.staged, depth: parseInt(opts.depth, 10), format: opts.format, + includeImplementors: opts.implementations !== false, ...ctx.resolveQueryOpts(opts), }); }, diff --git a/src/cli/commands/fn-impact.js b/src/cli/commands/fn-impact.js index 5d2d5943..ff547ccf 100644 --- a/src/cli/commands/fn-impact.js +++ b/src/cli/commands/fn-impact.js @@ -14,6 +14,7 @@ export const command = { collectFile, ], ['-k, --kind ', 'Filter to a specific symbol kind'], + ['--no-implementations', 'Exclude interface/trait implementors from blast radius'], ], validate([_name], opts) { if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { @@ -25,6 +26,7 @@ export const command = { depth: parseInt(opts.depth, 10), file: opts.file, kind: opts.kind, + includeImplementors: opts.implementations !== false, ...ctx.resolveQueryOpts(opts), }); }, diff --git a/src/cli/commands/implementations.js b/src/cli/commands/implementations.js new file mode 100644 index 00000000..3c4e6ba3 --- /dev/null +++ b/src/cli/commands/implementations.js @@ -0,0 +1,29 @@ +import { collectFile } from '../../db/query-builder.js'; +import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; +import { implementations } from '../../presentation/queries-cli.js'; + +export const command = { + name: 'implementations ', + description: 'List all concrete types implementing a given interface or trait', + queryOpts: true, + options: [ + [ + '-f, --file ', + 'Scope search to symbols in this file (partial match, repeatable)', + collectFile, + ], + ['-k, --kind ', 'Filter to a specific symbol kind'], + ], + validate([_name], opts) { + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + return `Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`; + } + }, + execute([name], opts, ctx) { + implementations(name, opts.db, { + file: opts.file, + kind: opts.kind, + ...ctx.resolveQueryOpts(opts), + }); + }, +}; diff --git a/src/cli/commands/interfaces.js b/src/cli/commands/interfaces.js new file mode 100644 index 00000000..7812a631 --- /dev/null +++ b/src/cli/commands/interfaces.js @@ -0,0 +1,29 @@ +import { collectFile } from '../../db/query-builder.js'; +import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; +import { interfaces } from '../../presentation/queries-cli.js'; + +export const command = { + name: 'interfaces ', + description: 'List all interfaces and traits that a class or struct implements', + queryOpts: true, + options: [ + [ + '-f, --file ', + 'Scope search to symbols in this file (partial match, repeatable)', + collectFile, + ], + ['-k, --kind ', 'Filter to a specific symbol kind'], + ], + validate([_name], opts) { + if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { + return `Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`; + } + }, + execute([name], opts, ctx) { + interfaces(name, opts.db, { + file: opts.file, + kind: opts.kind, + ...ctx.resolveQueryOpts(opts), + }); + }, +}; diff --git a/src/db/index.js b/src/db/index.js index 82ffe2d2..7d938e1d 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -31,9 +31,11 @@ export { findCrossFileCallTargets, findDistinctCallers, findFileNodes, + findImplementors, findImportDependents, findImportSources, findImportTargets, + findInterfaces, findIntraFileCallEdges, findNodeById, findNodeByQualifiedName, diff --git a/src/db/repository/edges.js b/src/db/repository/edges.js index a652b56b..81902d43 100644 --- a/src/db/repository/edges.js +++ b/src/db/repository/edges.js @@ -15,6 +15,8 @@ const _findCrossFileCallTargetsStmt = new WeakMap(); const _countCrossFileCallersStmt = new WeakMap(); const _getClassAncestorsStmt = new WeakMap(); const _findIntraFileCallEdgesStmt = new WeakMap(); +const _findImplementorsStmt = new WeakMap(); +const _findInterfacesStmt = new WeakMap(); // ─── Call-edge queries ────────────────────────────────────────────────── @@ -260,6 +262,42 @@ export function getClassHierarchy(db, classNodeId) { return ancestors; } +// ─── Implements-edge queries ────────────────────────────────────────── + +/** + * Find all concrete types that implement a given interface/trait node. + * Follows incoming 'implements' edges (source = implementor, target = interface). + * @param {object} db + * @param {number} nodeId - The interface/trait node ID + * @returns {{ id: number, name: string, kind: string, file: string, line: number }[]} + */ +export function findImplementors(db, nodeId) { + return cachedStmt( + _findImplementorsStmt, + db, + `SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line + FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind = 'implements'`, + ).all(nodeId); +} + +/** + * Find all interfaces/traits that a given class/struct implements. + * Follows outgoing 'implements' edges (source = class, target = interface). + * @param {object} db + * @param {number} nodeId - The class/struct node ID + * @returns {{ id: number, name: string, kind: string, file: string, line: number }[]} + */ +export function findInterfaces(db, nodeId) { + return cachedStmt( + _findInterfacesStmt, + db, + `SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line + FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'implements'`, + ).all(nodeId); +} + /** * Find intra-file call edges (caller → callee within the same file). * Used by explainFileImpl for data flow visualization. diff --git a/src/db/repository/index.js b/src/db/repository/index.js index 27483ae7..b96618b8 100644 --- a/src/db/repository/index.js +++ b/src/db/repository/index.js @@ -17,9 +17,11 @@ export { findCallers, findCrossFileCallTargets, findDistinctCallers, + findImplementors, findImportDependents, findImportSources, findImportTargets, + findInterfaces, findIntraFileCallEdges, getClassHierarchy, } from './edges.js'; diff --git a/src/domain/analysis/context.js b/src/domain/analysis/context.js index e8b5a869..8f0f083f 100644 --- a/src/domain/analysis/context.js +++ b/src/domain/analysis/context.js @@ -5,8 +5,10 @@ import { findCrossFileCallTargets, findDbPath, findFileNodes, + findImplementors, findImportSources, findImportTargets, + findInterfaces, findIntraFileCallEdges, findNodeChildren, findNodesByFile, @@ -107,6 +109,31 @@ function buildCallers(db, node, noTests) { })); } +const INTERFACE_LIKE_KINDS = new Set(['interface', 'trait']); +const IMPLEMENTOR_KINDS = new Set(['class', 'struct', 'record', 'enum']); + +function buildImplementationInfo(db, node, noTests) { + // For interfaces/traits: show who implements them + if (INTERFACE_LIKE_KINDS.has(node.kind)) { + let impls = findImplementors(db, node.id); + if (noTests) impls = impls.filter((n) => !isTestFile(n.file)); + return { + implementors: impls.map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })), + }; + } + // For classes/structs: show what they implement + if (IMPLEMENTOR_KINDS.has(node.kind)) { + let ifaces = findInterfaces(db, node.id); + if (noTests) ifaces = ifaces.filter((n) => !isTestFile(n.file)); + if (ifaces.length > 0) { + return { + implements: ifaces.map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })), + }; + } + } + return {}; +} + function buildRelatedTests(db, node, getFileLines, includeTests) { const testCallerRows = findCallers(db, node.id); const testCallers = testCallerRows.filter((c) => isTestFile(c.file)); @@ -337,6 +364,7 @@ export function contextData(name, customDbPath, opts = {}) { const relatedTests = buildRelatedTests(db, node, getFileLines, includeTests); const complexityMetrics = getComplexityMetrics(db, node.id); const nodeChildren = getNodeChildrenSafe(db, node.id); + const implInfo = buildImplementationInfo(db, node, noTests); return { name: node.name, @@ -352,6 +380,7 @@ export function contextData(name, customDbPath, opts = {}) { callees, callers, relatedTests, + ...implInfo, }; }); diff --git a/src/domain/analysis/exports.js b/src/domain/analysis/exports.js index 7bebac40..c3877985 100644 --- a/src/domain/analysis/exports.js +++ b/src/domain/analysis/exports.js @@ -30,7 +30,15 @@ export function exportsData(file, customDbPath, opts = {}) { if (fileResults.length === 0) { return paginateResult( - { file, results: [], reexports: [], totalExported: 0, totalInternal: 0, totalUnused: 0 }, + { + file, + results: [], + reexports: [], + reexportedSymbols: [], + totalExported: 0, + totalInternal: 0, + totalUnused: 0, + }, 'results', { limit: opts.limit, offset: opts.offset }, ); @@ -42,6 +50,7 @@ export function exportsData(file, customDbPath, opts = {}) { file: first.file, results: first.results, reexports: first.reexports, + reexportedSymbols: first.reexportedSymbols, totalExported: first.totalExported, totalInternal: first.totalInternal, totalUnused: first.totalUnused, @@ -83,9 +92,7 @@ function exportsFileImpl(db, target, noTests, getFileLines, unused) { } const internalCount = symbols.length - exported.length; - const results = exported.map((s) => { - const fileLines = getFileLines(fn.file); - + const buildSymbolResult = (s, fileLines) => { let consumers = db .prepare( `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id @@ -105,7 +112,9 @@ function exportsFileImpl(db, target, noTests, getFileLines, unused) { consumers: consumers.map((c) => ({ name: c.name, file: c.file, line: c.line })), consumerCount: consumers.length, }; - }); + }; + + const results = exported.map((s) => buildSymbolResult(s, getFileLines(fn.file))); const totalUnused = results.filter((r) => r.consumerCount === 0).length; @@ -118,15 +127,44 @@ function exportsFileImpl(db, target, noTests, getFileLines, unused) { .all(fn.id) .map((r) => ({ file: r.file })); + // For barrel files: gather symbols re-exported from target modules + const reexportTargets = db + .prepare( + `SELECT DISTINCT n.id, n.file FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'reexports'`, + ) + .all(fn.id); + + const reexportedSymbols = []; + for (const target of reexportTargets) { + const targetExported = hasExportedCol + ? db + .prepare( + "SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line", + ) + .all(target.file) + : []; + for (const s of targetExported) { + const fileLines = getFileLines(target.file); + reexportedSymbols.push({ + ...buildSymbolResult(s, fileLines), + originFile: target.file, + }); + } + } + let filteredResults = results; + let filteredReexported = reexportedSymbols; if (unused) { filteredResults = results.filter((r) => r.consumerCount === 0); + filteredReexported = reexportedSymbols.filter((r) => r.consumerCount === 0); } return { file: fn.file, results: filteredResults, reexports, + reexportedSymbols: filteredReexported, totalExported: exported.length, totalInternal: internalCount, totalUnused, diff --git a/src/domain/analysis/impact.js b/src/domain/analysis/impact.js index 6bdd5464..88fa3e60 100644 --- a/src/domain/analysis/impact.js +++ b/src/domain/analysis/impact.js @@ -5,6 +5,7 @@ import { findDbPath, findDistinctCallers, findFileNodes, + findImplementors, findImportDependents, findNodeById, openReadonlyOrFail, @@ -21,19 +22,51 @@ import { findMatchingNodes } from './symbol-lookup.js'; // ─── Shared BFS: transitive callers ──────────────────────────────────── +const INTERFACE_LIKE_KINDS = new Set(['interface', 'trait']); + /** * BFS traversal to find transitive callers of a node. + * When an interface/trait node is encountered (either as the start node or + * during traversal), its concrete implementors are also added to the frontier + * so that changes to an interface signature propagate to all implementors. * * @param {import('better-sqlite3').Database} db - Open read-only SQLite database handle (not a Repository) * @param {number} startId - Starting node ID - * @param {{ noTests?: boolean, maxDepth?: number, onVisit?: (caller: object, parentId: number, depth: number) => void }} options + * @param {{ noTests?: boolean, maxDepth?: number, includeImplementors?: boolean, onVisit?: (caller: object, parentId: number, depth: number) => void }} options * @returns {{ totalDependents: number, levels: Record> }} */ -export function bfsTransitiveCallers(db, startId, { noTests = false, maxDepth = 3, onVisit } = {}) { +export function bfsTransitiveCallers( + db, + startId, + { noTests = false, maxDepth = 3, includeImplementors = true, onVisit } = {}, +) { const visited = new Set([startId]); const levels = {}; let frontier = [startId]; + // Seed: if start node is an interface/trait, include its implementors at depth 1 + if (includeImplementors) { + const startNode = findNodeById(db, startId); + if (startNode && INTERFACE_LIKE_KINDS.has(startNode.kind)) { + const impls = findImplementors(db, startId); + for (const impl of impls) { + if (!visited.has(impl.id) && (!noTests || !isTestFile(impl.file))) { + visited.add(impl.id); + frontier.push(impl.id); + if (!levels[1]) levels[1] = []; + levels[1].push({ + name: impl.name, + kind: impl.kind, + file: impl.file, + line: impl.line, + viaImplements: true, + }); + if (onVisit) onVisit({ ...impl, viaImplements: true }, startId, 1); + } + } + } + } + for (let d = 1; d <= maxDepth; d++) { const nextFrontier = []; for (const fid of frontier) { @@ -46,6 +79,26 @@ export function bfsTransitiveCallers(db, startId, { noTests = false, maxDepth = levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line }); if (onVisit) onVisit(c, fid, d); } + + // If a caller is an interface/trait, also pull in its implementors + if (includeImplementors && INTERFACE_LIKE_KINDS.has(c.kind)) { + const impls = findImplementors(db, c.id); + for (const impl of impls) { + if (!visited.has(impl.id) && (!noTests || !isTestFile(impl.file))) { + visited.add(impl.id); + nextFrontier.push(impl.id); + if (!levels[d]) levels[d] = []; + levels[d].push({ + name: impl.name, + kind: impl.kind, + file: impl.file, + line: impl.line, + viaImplements: true, + }); + if (onVisit) onVisit({ ...impl, viaImplements: true }, c.id, d); + } + } + } } } frontier = nextFrontier; @@ -118,8 +171,14 @@ export function fnImpactData(name, customDbPath, opts = {}) { return { name, results: [] }; } + const includeImplementors = opts.includeImplementors !== false; + const results = nodes.map((node) => { - const { levels, totalDependents } = bfsTransitiveCallers(db, node.id, { noTests, maxDepth }); + const { levels, totalDependents } = bfsTransitiveCallers(db, node.id, { + noTests, + maxDepth, + includeImplementors, + }); return { ...normalizeSymbol(node, db, hc), levels, diff --git a/src/domain/analysis/implementations.js b/src/domain/analysis/implementations.js new file mode 100644 index 00000000..487f1948 --- /dev/null +++ b/src/domain/analysis/implementations.js @@ -0,0 +1,98 @@ +import { findImplementors, findInterfaces, openReadonlyOrFail } from '../../db/index.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { CORE_SYMBOL_KINDS } from '../../shared/kinds.js'; +import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; +import { findMatchingNodes } from './symbol-lookup.js'; + +/** + * Find all concrete types implementing a given interface/trait. + * + * @param {string} name - Interface/trait name (partial match) + * @param {string|undefined} customDbPath + * @param {{ noTests?: boolean, file?: string, kind?: string, limit?: number, offset?: number }} opts + * @returns {{ name: string, results: Array<{ name: string, kind: string, file: string, line: number, implementors: Array<{ name: string, kind: string, file: string, line: number }> }> }} + */ +export function implementationsData(name, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const hc = new Map(); + + const nodes = findMatchingNodes(db, name, { + noTests, + file: opts.file, + kind: opts.kind, + kinds: opts.kind ? undefined : CORE_SYMBOL_KINDS, + }); + if (nodes.length === 0) { + return { name, results: [] }; + } + + const results = nodes.map((node) => { + let implementors = findImplementors(db, node.id); + if (noTests) implementors = implementors.filter((n) => !isTestFile(n.file)); + + return { + ...normalizeSymbol(node, db, hc), + implementors: implementors.map((impl) => ({ + name: impl.name, + kind: impl.kind, + file: impl.file, + line: impl.line, + })), + }; + }); + + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +/** + * Find all interfaces/traits that a given class/struct implements. + * + * @param {string} name - Class/struct name (partial match) + * @param {string|undefined} customDbPath + * @param {{ noTests?: boolean, file?: string, kind?: string, limit?: number, offset?: number }} opts + * @returns {{ name: string, results: Array<{ name: string, kind: string, file: string, line: number, interfaces: Array<{ name: string, kind: string, file: string, line: number }> }> }} + */ +export function interfacesData(name, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const hc = new Map(); + + const nodes = findMatchingNodes(db, name, { + noTests, + file: opts.file, + kind: opts.kind, + kinds: opts.kind ? undefined : CORE_SYMBOL_KINDS, + }); + if (nodes.length === 0) { + return { name, results: [] }; + } + + const results = nodes.map((node) => { + let interfaces = findInterfaces(db, node.id); + if (noTests) interfaces = interfaces.filter((n) => !isTestFile(n.file)); + + return { + ...normalizeSymbol(node, db, hc), + interfaces: interfaces.map((iface) => ({ + name: iface.name, + kind: iface.kind, + file: iface.file, + line: iface.line, + })), + }; + }); + + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} diff --git a/src/domain/graph/builder/stages/build-edges.js b/src/domain/graph/builder/stages/build-edges.js index 47d75320..afd7acdb 100644 --- a/src/domain/graph/builder/stages/build-edges.js +++ b/src/domain/graph/builder/stages/build-edges.js @@ -384,13 +384,19 @@ function buildReceiverEdge(ctx, call, caller, relPath, seenCallEdges, allEdgeRow // ── Class hierarchy edges ─────────────────────────────────────────────── +const HIERARCHY_SOURCE_KINDS = new Set(['class', 'struct', 'record', 'enum']); +const EXTENDS_TARGET_KINDS = new Set(['class', 'struct', 'trait', 'record']); +const IMPLEMENTS_TARGET_KINDS = new Set(['interface', 'trait', 'class']); + function buildClassHierarchyEdges(ctx, relPath, symbols, allEdgeRows) { for (const cls of symbols.classes) { if (cls.extends) { - const sourceRow = (ctx.nodesByNameAndFile.get(`${cls.name}|${relPath}`) || []).find( - (n) => n.kind === 'class', + const sourceRow = (ctx.nodesByNameAndFile.get(`${cls.name}|${relPath}`) || []).find((n) => + HIERARCHY_SOURCE_KINDS.has(n.kind), + ); + const targetRows = (ctx.nodesByName.get(cls.extends) || []).filter((n) => + EXTENDS_TARGET_KINDS.has(n.kind), ); - const targetRows = (ctx.nodesByName.get(cls.extends) || []).filter((n) => n.kind === 'class'); if (sourceRow) { for (const t of targetRows) { allEdgeRows.push([sourceRow.id, t.id, 'extends', 1.0, 0]); @@ -399,11 +405,11 @@ function buildClassHierarchyEdges(ctx, relPath, symbols, allEdgeRows) { } if (cls.implements) { - const sourceRow = (ctx.nodesByNameAndFile.get(`${cls.name}|${relPath}`) || []).find( - (n) => n.kind === 'class', + const sourceRow = (ctx.nodesByNameAndFile.get(`${cls.name}|${relPath}`) || []).find((n) => + HIERARCHY_SOURCE_KINDS.has(n.kind), ); - const targetRows = (ctx.nodesByName.get(cls.implements) || []).filter( - (n) => n.kind === 'interface' || n.kind === 'class', + const targetRows = (ctx.nodesByName.get(cls.implements) || []).filter((n) => + IMPLEMENTS_TARGET_KINDS.has(n.kind), ); if (sourceRow) { for (const t of targetRows) { diff --git a/src/domain/queries.js b/src/domain/queries.js index 15b77a39..7a3c6207 100644 --- a/src/domain/queries.js +++ b/src/domain/queries.js @@ -32,6 +32,7 @@ export { fnImpactData, impactAnalysisData, } from './analysis/impact.js'; +export { implementationsData, interfacesData } from './analysis/implementations.js'; export { FALSE_POSITIVE_CALLER_THRESHOLD, FALSE_POSITIVE_NAMES, diff --git a/src/mcp/tool-registry.js b/src/mcp/tool-registry.js index c81baee8..e29613ad 100644 --- a/src/mcp/tool-registry.js +++ b/src/mcp/tool-registry.js @@ -758,6 +758,45 @@ const BASE_TOOLS = [ }, }, }, + { + name: 'implementations', + description: + 'List all concrete types (classes, structs, records) that implement a given interface or trait', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Interface/trait name (partial match)' }, + file: { type: 'string', description: 'Scope to file (partial match)' }, + kind: { + type: 'string', + enum: EVERY_SYMBOL_KIND, + description: 'Filter by symbol kind', + }, + no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, + }, + required: ['name'], + }, + }, + { + name: 'interfaces', + description: 'List all interfaces and traits that a given class, struct, or record implements', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Class/struct name (partial match)' }, + file: { type: 'string', description: 'Scope to file (partial match)' }, + kind: { + type: 'string', + enum: EVERY_SYMBOL_KIND, + description: 'Filter by symbol kind', + }, + no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, + }, + required: ['name'], + }, + }, { name: 'ast_query', description: diff --git a/src/mcp/tools/implementations.js b/src/mcp/tools/implementations.js new file mode 100644 index 00000000..cad07967 --- /dev/null +++ b/src/mcp/tools/implementations.js @@ -0,0 +1,14 @@ +import { effectiveLimit, effectiveOffset } from '../middleware.js'; + +export const name = 'implementations'; + +export async function handler(args, ctx) { + const { implementationsData } = await ctx.getQueries(); + return implementationsData(args.name, ctx.dbPath, { + file: args.file, + kind: args.kind, + noTests: args.no_tests, + limit: effectiveLimit(args, name), + offset: effectiveOffset(args), + }); +} diff --git a/src/mcp/tools/index.js b/src/mcp/tools/index.js index 70454da0..65c0f0d8 100644 --- a/src/mcp/tools/index.js +++ b/src/mcp/tools/index.js @@ -23,6 +23,8 @@ import * as fileExports from './file-exports.js'; import * as findCycles from './find-cycles.js'; import * as fnImpact from './fn-impact.js'; import * as impactAnalysis from './impact-analysis.js'; +import * as implementations from './implementations.js'; +import * as interfaces from './interfaces.js'; import * as listFunctions from './list-functions.js'; import * as listRepos from './list-repos.js'; import * as moduleMap from './module-map.js'; @@ -69,5 +71,7 @@ export const TOOL_HANDLERS = new Map([ [check.name, check], [astQuery.name, astQuery], [brief.name, brief], + [implementations.name, implementations], + [interfaces.name, interfaces], [listRepos.name, listRepos], ]); diff --git a/src/mcp/tools/interfaces.js b/src/mcp/tools/interfaces.js new file mode 100644 index 00000000..518925b3 --- /dev/null +++ b/src/mcp/tools/interfaces.js @@ -0,0 +1,14 @@ +import { effectiveLimit, effectiveOffset } from '../middleware.js'; + +export const name = 'interfaces'; + +export async function handler(args, ctx) { + const { interfacesData } = await ctx.getQueries(); + return interfacesData(args.name, ctx.dbPath, { + file: args.file, + kind: args.kind, + noTests: args.no_tests, + limit: effectiveLimit(args, name), + offset: effectiveOffset(args), + }); +} diff --git a/src/presentation/queries-cli.js b/src/presentation/queries-cli.js index 8e77abfd..cbadc75d 100644 --- a/src/presentation/queries-cli.js +++ b/src/presentation/queries-cli.js @@ -4,7 +4,7 @@ * The actual implementations live in queries-cli/ split by concern: * path.js — symbolPath * overview.js — stats, moduleMap, roles - * inspect.js — where, queryName, context, children, explain + * inspect.js — where, queryName, context, children, explain, implementations, interfaces * impact.js — fileDeps, fnDeps, impactAnalysis, fnImpact, diffImpact * exports.js — fileExports */ @@ -18,6 +18,8 @@ export { fnDeps, fnImpact, impactAnalysis, + implementations, + interfaces, moduleMap, queryName, roles, diff --git a/src/presentation/queries-cli/exports.js b/src/presentation/queries-cli/exports.js index fe06f731..b3a5eff5 100644 --- a/src/presentation/queries-cli/exports.js +++ b/src/presentation/queries-cli/exports.js @@ -30,11 +30,39 @@ function printExportSymbols(results) { } } +function printReexportedSymbols(reexportedSymbols) { + // Group by origin file + const byOrigin = new Map(); + for (const sym of reexportedSymbols) { + if (!byOrigin.has(sym.originFile)) byOrigin.set(sym.originFile, []); + byOrigin.get(sym.originFile).push(sym); + } + + for (const [originFile, syms] of byOrigin) { + console.log(`\n from ${originFile}:`); + for (const sym of syms) { + const icon = kindIcon(sym.kind); + const sig = sym.signature?.params ? `(${sym.signature.params})` : ''; + const role = sym.role ? ` [${sym.role}]` : ''; + console.log(` ${icon} ${sym.name}${sig}${role} :${sym.line}`); + if (sym.consumers.length === 0) { + console.log(' (no consumers)'); + } else { + for (const c of sym.consumers) { + console.log(` <- ${c.name} (${c.file}:${c.line})`); + } + } + } + } +} + export function fileExports(file, customDbPath, opts = {}) { const data = exportsData(file, customDbPath, opts); if (outputResult(data, 'results', opts)) return; - if (data.results.length === 0) { + const hasReexported = data.reexportedSymbols && data.reexportedSymbols.length > 0; + + if (data.results.length === 0 && !hasReexported) { if (opts.unused) { console.log(`No unused exports found for "${file}".`); } else { @@ -43,11 +71,31 @@ export function fileExports(file, customDbPath, opts = {}) { return; } - printExportHeader(data, opts); - printExportSymbols(data.results); + if (data.results.length > 0) { + printExportHeader(data, opts); + printExportSymbols(data.results); + } + + if (hasReexported) { + const totalReexported = data.reexportedSymbols.length; + if (data.results.length === 0) { + if (opts.unused) { + console.log( + `\n# ${data.file} — barrel file (${totalReexported} unused re-exported symbol${totalReexported !== 1 ? 's' : ''} from sub-modules)\n`, + ); + } else { + console.log( + `\n# ${data.file} — barrel file (${totalReexported} re-exported symbol${totalReexported !== 1 ? 's' : ''} from sub-modules)\n`, + ); + } + } else { + console.log(`\n Re-exported symbols (${totalReexported} from sub-modules):`); + } + printReexportedSymbols(data.reexportedSymbols); + } if (data.reexports.length > 0) { - console.log(`\n Re-exports: ${data.reexports.map((r) => r.file).join(', ')}`); + console.log(`\n Re-exported by: ${data.reexports.map((r) => r.file).join(', ')}`); } console.log(); } diff --git a/src/presentation/queries-cli/index.js b/src/presentation/queries-cli/index.js index 40aae323..43089395 100644 --- a/src/presentation/queries-cli/index.js +++ b/src/presentation/queries-cli/index.js @@ -1,5 +1,13 @@ export { fileExports } from './exports.js'; export { diffImpact, fileDeps, fnDeps, fnImpact, impactAnalysis } from './impact.js'; -export { children, context, explain, queryName, where } from './inspect.js'; +export { + children, + context, + explain, + implementations, + interfaces, + queryName, + where, +} from './inspect.js'; export { moduleMap, roles, stats } from './overview.js'; export { symbolPath } from './path.js'; diff --git a/src/presentation/queries-cli/inspect.js b/src/presentation/queries-cli/inspect.js index 59b85d63..f57c0dd6 100644 --- a/src/presentation/queries-cli/inspect.js +++ b/src/presentation/queries-cli/inspect.js @@ -2,6 +2,8 @@ import { childrenData, contextData, explainData, + implementationsData, + interfacesData, kindIcon, queryNameData, whereData, @@ -181,6 +183,22 @@ function renderContextResult(r) { console.log(); } + if (r.implementors && r.implementors.length > 0) { + console.log(`## Implementors (${r.implementors.length})`); + for (const impl of r.implementors) { + console.log(` ${kindIcon(impl.kind)} ${impl.name} ${impl.file}:${impl.line}`); + } + console.log(); + } + + if (r.implements && r.implements.length > 0) { + console.log(`## Implements (${r.implements.length})`); + for (const iface of r.implements) { + console.log(` ${kindIcon(iface.kind)} ${iface.name} ${iface.file}:${iface.line}`); + } + console.log(); + } + if (r.relatedTests.length > 0) { console.log('## Related Tests'); for (const t of r.relatedTests) { @@ -327,3 +345,49 @@ export function explain(target, customDbPath, opts = {}) { } } } + +export function implementations(name, customDbPath, opts = {}) { + const data = implementationsData(name, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No symbol matching "${name}"`); + return; + } + + for (const r of data.results) { + console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}`); + if (r.implementors.length === 0) { + console.log(' (no implementors found)'); + } else { + console.log(` Implementors (${r.implementors.length}):`); + for (const impl of r.implementors) { + console.log(` ${kindIcon(impl.kind)} ${impl.name} ${impl.file}:${impl.line}`); + } + } + } + console.log(); +} + +export function interfaces(name, customDbPath, opts = {}) { + const data = interfacesData(name, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No symbol matching "${name}"`); + return; + } + + for (const r of data.results) { + console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}`); + if (r.interfaces.length === 0) { + console.log(' (no interfaces/traits found)'); + } else { + console.log(` Implements (${r.interfaces.length}):`); + for (const iface of r.interfaces) { + console.log(` ${kindIcon(iface.kind)} ${iface.name} ${iface.file}:${iface.line}`); + } + } + } + console.log(); +} diff --git a/tests/integration/exports.test.js b/tests/integration/exports.test.js index 623f19b6..084399b6 100644 --- a/tests/integration/exports.test.js +++ b/tests/integration/exports.test.js @@ -199,4 +199,36 @@ describe('exportsData', () => { expect(data._pagination.total).toBe(1); expect(data._pagination.hasMore).toBe(false); }); + + test('barrel file shows re-exported symbols from target modules', () => { + const data = exportsData('barrel.js', dbPath); + expect(data.file).toBe('barrel.js'); + // barrel.js has no own exports + expect(data.results).toEqual([]); + expect(data.totalExported).toBe(0); + // but it surfaces re-exported symbols from lib.js + expect(data.reexportedSymbols.length).toBe(3); // add, multiply, unusedFn + const names = data.reexportedSymbols.map((s) => s.name).sort(); + expect(names).toEqual(['add', 'multiply', 'unusedFn']); + // each re-exported symbol has originFile + for (const sym of data.reexportedSymbols) { + expect(sym.originFile).toBe('lib.js'); + } + // consumer info is preserved + const addSym = data.reexportedSymbols.find((s) => s.name === 'add'); + expect(addSym.consumerCount).toBe(2); + }); + + test('barrel file --unused filters re-exported symbols', () => { + const data = exportsData('barrel.js', dbPath, { unused: true }); + expect(data.results).toEqual([]); + expect(data.reexportedSymbols.length).toBe(1); + expect(data.reexportedSymbols[0].name).toBe('unusedFn'); + expect(data.reexportedSymbols[0].consumerCount).toBe(0); + }); + + test('reexportedSymbols is empty array for non-barrel files', () => { + const data = exportsData('lib.js', dbPath); + expect(data.reexportedSymbols).toEqual([]); + }); }); diff --git a/tests/integration/implementations.test.js b/tests/integration/implementations.test.js new file mode 100644 index 00000000..bdd56389 --- /dev/null +++ b/tests/integration/implementations.test.js @@ -0,0 +1,221 @@ +/** + * Integration tests for interface/trait implementation tracking. + * + * Test graph: + * + * Files: types.ts, service.ts, handler.ts + * + * Nodes: + * Serializable (interface, types.ts:1) + * Printable (interface, types.ts:10) + * UserService (class, service.ts:1) — implements Serializable, Printable + * AdminService (class, service.ts:20) — implements Serializable + * handleUser (function, handler.ts:1) — calls UserService + * + * Edges: + * UserService --implements--> Serializable + * UserService --implements--> Printable + * AdminService --implements--> Serializable + * handleUser --calls------> UserService + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { initSchema } from '../../src/db/index.js'; +import { + contextData, + fnImpactData, + implementationsData, + interfacesData, +} from '../../src/domain/queries.js'; + +// ─── Helpers ─────────────────────────────────────────────────────────── + +function insertNode(db, name, kind, file, line) { + return db + .prepare('INSERT INTO nodes (name, kind, file, line) VALUES (?, ?, ?, ?)') + .run(name, kind, file, line).lastInsertRowid; +} + +function insertEdge(db, sourceId, targetId, kind, confidence = 1.0) { + db.prepare( + 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, 0)', + ).run(sourceId, targetId, kind, confidence); +} + +// ─── Fixture DB ──────────────────────────────────────────────────────── + +let tmpDir, dbPath; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-impl-')); + fs.mkdirSync(path.join(tmpDir, '.codegraph')); + dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // File nodes + insertNode(db, 'types.ts', 'file', 'types.ts', 0); + insertNode(db, 'service.ts', 'file', 'service.ts', 0); + insertNode(db, 'handler.ts', 'file', 'handler.ts', 0); + + // Interface nodes + const serializable = insertNode(db, 'Serializable', 'interface', 'types.ts', 1); + const printable = insertNode(db, 'Printable', 'interface', 'types.ts', 10); + + // Class nodes + const userService = insertNode(db, 'UserService', 'class', 'service.ts', 1); + const adminService = insertNode(db, 'AdminService', 'class', 'service.ts', 20); + + // Function nodes + const handleUser = insertNode(db, 'handleUser', 'function', 'handler.ts', 1); + + // Implements edges + insertEdge(db, userService, serializable, 'implements'); + insertEdge(db, userService, printable, 'implements'); + insertEdge(db, adminService, serializable, 'implements'); + + // Call edges + insertEdge(db, handleUser, userService, 'calls'); + + db.close(); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── Tests ───────────────────────────────────────────────────────────── + +describe('implementationsData', () => { + test('finds all implementors of an interface', () => { + const data = implementationsData('Serializable', dbPath); + expect(data.results).toHaveLength(1); + const result = data.results[0]; + expect(result.name).toBe('Serializable'); + expect(result.kind).toBe('interface'); + expect(result.implementors).toHaveLength(2); + const names = result.implementors.map((i) => i.name).sort(); + expect(names).toEqual(['AdminService', 'UserService']); + }); + + test('finds single implementor for Printable', () => { + const data = implementationsData('Printable', dbPath); + expect(data.results).toHaveLength(1); + expect(data.results[0].implementors).toHaveLength(1); + expect(data.results[0].implementors[0].name).toBe('UserService'); + }); + + test('returns empty implementors for a class', () => { + const data = implementationsData('UserService', dbPath); + expect(data.results).toHaveLength(1); + // UserService is a class, not an interface — nobody implements it + expect(data.results[0].implementors).toHaveLength(0); + }); + + test('returns empty results for unknown name', () => { + const data = implementationsData('NonExistent', dbPath); + expect(data.results).toHaveLength(0); + }); +}); + +describe('interfacesData', () => { + test('finds all interfaces a class implements', () => { + const data = interfacesData('UserService', dbPath); + expect(data.results).toHaveLength(1); + const result = data.results[0]; + expect(result.name).toBe('UserService'); + expect(result.kind).toBe('class'); + expect(result.interfaces).toHaveLength(2); + const names = result.interfaces.map((i) => i.name).sort(); + expect(names).toEqual(['Printable', 'Serializable']); + }); + + test('finds single interface for AdminService', () => { + const data = interfacesData('AdminService', dbPath); + expect(data.results).toHaveLength(1); + expect(data.results[0].interfaces).toHaveLength(1); + expect(data.results[0].interfaces[0].name).toBe('Serializable'); + }); + + test('returns empty interfaces for an interface node', () => { + const data = interfacesData('Serializable', dbPath); + expect(data.results).toHaveLength(1); + // An interface doesn't implement anything + expect(data.results[0].interfaces).toHaveLength(0); + }); +}); + +describe('contextData with implementation info', () => { + test('includes implementors for interface nodes', () => { + const data = contextData('Serializable', dbPath, { kind: 'interface' }); + expect(data.results).toHaveLength(1); + const result = data.results[0]; + expect(result.implementors).toBeDefined(); + expect(result.implementors).toHaveLength(2); + const names = result.implementors.map((i) => i.name).sort(); + expect(names).toEqual(['AdminService', 'UserService']); + }); + + test('includes implements for class nodes', () => { + const data = contextData('UserService', dbPath); + expect(data.results).toHaveLength(1); + const result = data.results[0]; + expect(result.implements).toBeDefined(); + expect(result.implements).toHaveLength(2); + }); + + test('omits implementation info for plain functions', () => { + const data = contextData('handleUser', dbPath); + expect(data.results).toHaveLength(1); + const result = data.results[0]; + expect(result.implementors).toBeUndefined(); + expect(result.implements).toBeUndefined(); + }); +}); + +describe('fnImpactData with implementors in blast radius', () => { + test('interface impact includes implementors', () => { + const data = fnImpactData('Serializable', dbPath, { depth: 3, kind: 'interface' }); + expect(data.results).toHaveLength(1); + const result = data.results[0]; + // Serializable is an interface, so BFS should seed with implementors + expect(result.totalDependents).toBeGreaterThanOrEqual(2); + const allNames = Object.values(result.levels) + .flat() + .map((n) => n.name); + expect(allNames).toContain('UserService'); + expect(allNames).toContain('AdminService'); + }); + + test('--no-implementations excludes implementors', () => { + const data = fnImpactData('Serializable', dbPath, { + depth: 3, + kind: 'interface', + includeImplementors: false, + }); + expect(data.results).toHaveLength(1); + const result = data.results[0]; + // Without implementors, an interface with no direct callers has no dependents + const allNames = Object.values(result.levels) + .flat() + .map((n) => n.name); + expect(allNames).not.toContain('UserService'); + expect(allNames).not.toContain('AdminService'); + }); + + test('implementor callers appear transitively', () => { + const data = fnImpactData('Serializable', dbPath, { depth: 5, kind: 'interface' }); + expect(data.results).toHaveLength(1); + const allNames = Object.values(data.results[0].levels) + .flat() + .map((n) => n.name); + // handleUser calls UserService which implements Serializable + expect(allNames).toContain('handleUser'); + }); +}); diff --git a/tests/unit/index-exports.test.js b/tests/unit/index-exports.test.js index ecd06bdb..919af2e2 100644 --- a/tests/unit/index-exports.test.js +++ b/tests/unit/index-exports.test.js @@ -10,7 +10,7 @@ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'u describe('index.js re-exports', () => { it('package.json exports map points to CJS wrapper', () => { expect(pkg.exports['.']).toBeDefined(); - expect(pkg.exports['.'].require).toBe('./src/index.cjs'); + expect(pkg.exports['.'].require).toBe('./dist/index.cjs'); }); it('all re-exports resolve without errors', async () => { diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index 2a7b78dd..c3a1a147 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -41,6 +41,8 @@ const ALL_TOOL_NAMES = [ 'check', 'ast_query', 'brief', + 'implementations', + 'interfaces', 'list_repos', ]; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..613956a9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "compilerOptions": { + /* Language & Environment */ + "target": "es2022", + "lib": ["es2022"], + + /* Modules */ + "module": "nodenext", + "moduleResolution": "nodenext", + "rootDir": "./src", + "baseUrl": "./src", + "paths": { + "#shared/*": ["./shared/*"], + "#infrastructure/*": ["./infrastructure/*"], + "#db/*": ["./db/*"], + "#domain/*": ["./domain/*"], + "#features/*": ["./features/*"], + "#presentation/*": ["./presentation/*"], + "#graph/*": ["./graph/*"], + "#mcp/*": ["./mcp/*"], + "#ast-analysis/*": ["./ast-analysis/*"] + }, + + /* Emit */ + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + + /* Incremental */ + "incremental": true, + "tsBuildInfoFile": "./.tsbuildinfo", + + /* Interop */ + "esModuleInterop": true, + "allowImportingTsExtensions": false, + "verbatimModuleSyntax": false, + "isolatedModules": true, + + /* Type Checking — strict mode */ + "strict": true, + "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": true, + "exactOptionalPropertyTypes": false, + + /* Migration support */ + "allowJs": true, + "checkJs": false, + + /* Misc */ + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "grammars", "tests", "scripts", "crates"] +}