diff --git a/src/ast-analysis/shared.js b/src/ast-analysis/shared.js index 6d7ed6d..f5d8e0b 100644 --- a/src/ast-analysis/shared.js +++ b/src/ast-analysis/shared.js @@ -2,6 +2,7 @@ * Shared utilities for AST analysis modules (complexity, CFG, dataflow, AST nodes). */ +import { ConfigError } from '../errors.js'; import { LANGUAGE_REGISTRY } from '../parser.js'; // ─── Generic Rule Factory ───────────────────────────────────────────────── @@ -18,7 +19,7 @@ export function makeRules(defaults, overrides, label) { const validKeys = new Set(Object.keys(defaults)); for (const key of Object.keys(overrides)) { if (!validKeys.has(key)) { - throw new Error(`${label} rules: unknown key "${key}"`); + throw new ConfigError(`${label} rules: unknown key "${key}"`); } } return { ...defaults, ...overrides }; @@ -61,10 +62,10 @@ export const CFG_DEFAULTS = { export function makeCfgRules(overrides) { const rules = makeRules(CFG_DEFAULTS, overrides, 'CFG'); if (!(rules.functionNodes instanceof Set) || rules.functionNodes.size === 0) { - throw new Error('CFG rules: functionNodes must be a non-empty Set'); + throw new ConfigError('CFG rules: functionNodes must be a non-empty Set'); } if (!(rules.forNodes instanceof Set)) { - throw new Error('CFG rules: forNodes must be a Set'); + throw new ConfigError('CFG rules: forNodes must be a Set'); } return rules; } @@ -136,7 +137,7 @@ export const DATAFLOW_DEFAULTS = { export function makeDataflowRules(overrides) { const rules = makeRules(DATAFLOW_DEFAULTS, overrides, 'Dataflow'); if (!(rules.functionNodes instanceof Set) || rules.functionNodes.size === 0) { - throw new Error('Dataflow rules: functionNodes must be a non-empty Set'); + throw new ConfigError('Dataflow rules: functionNodes must be a non-empty Set'); } return rules; } diff --git a/src/batch.js b/src/batch.js index cdb25df..fb4ce88 100644 --- a/src/batch.js +++ b/src/batch.js @@ -7,6 +7,7 @@ import { complexityData } from './complexity.js'; import { dataflowData } from './dataflow.js'; +import { ConfigError } from './errors.js'; import { flowData } from './flow.js'; import { contextData, @@ -53,7 +54,7 @@ export const BATCH_COMMANDS = { export function batchData(command, targets, customDbPath, opts = {}) { const entry = BATCH_COMMANDS[command]; if (!entry) { - throw new Error( + throw new ConfigError( `Unknown batch command "${command}". Valid commands: ${Object.keys(BATCH_COMMANDS).join(', ')}`, ); } diff --git a/src/cli.js b/src/cli.js index 72e9ced..6318f0e 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,8 +1,14 @@ #!/usr/bin/env node import { run } from './cli/index.js'; +import { CodegraphError } from './errors.js'; run().catch((err) => { - console.error(`codegraph: fatal error — ${err.message || err}`); + if (err instanceof CodegraphError) { + console.error(`codegraph [${err.code}]: ${err.message}`); + if (err.file) console.error(` file: ${err.file}`); + } else { + console.error(`codegraph: fatal error — ${err.message || err}`); + } process.exit(1); }); diff --git a/src/cli/commands/ast.js b/src/cli/commands/ast.js index 92140a3..cc9124b 100644 --- a/src/cli/commands/ast.js +++ b/src/cli/commands/ast.js @@ -1,3 +1,5 @@ +import { ConfigError } from '../../errors.js'; + export const command = { name: 'ast [pattern]', description: 'Search stored AST nodes (calls, new, string, regex, throw, await) by pattern', @@ -9,8 +11,7 @@ export const command = { async execute([pattern], opts, ctx) { const { AST_NODE_KINDS, astQuery } = await import('../../ast.js'); if (opts.kind && !AST_NODE_KINDS.includes(opts.kind)) { - console.error(`Invalid AST kind "${opts.kind}". Valid: ${AST_NODE_KINDS.join(', ')}`); - process.exit(1); + throw new ConfigError(`Invalid AST kind "${opts.kind}". Valid: ${AST_NODE_KINDS.join(', ')}`); } astQuery(pattern, opts.db, { kind: opts.kind, diff --git a/src/cli/commands/batch.js b/src/cli/commands/batch.js index fe75c5c..7637b5f 100644 --- a/src/cli/commands/batch.js +++ b/src/cli/commands/batch.js @@ -1,6 +1,7 @@ import fs from 'node:fs'; import { BATCH_COMMANDS, multiBatchData, splitTargets } from '../../batch.js'; import { batch } from '../../commands/batch.js'; +import { ConfigError } from '../../errors.js'; import { EVERY_SYMBOL_KIND } from '../../queries.js'; export const command = { @@ -40,13 +41,13 @@ export const command = { targets = splitTargets(positionalTargets); } } catch (err) { - console.error(`Failed to parse targets: ${err.message}`); - process.exit(1); + throw new ConfigError(`Failed to parse targets: ${err.message}`, { cause: err }); } if (!targets || targets.length === 0) { - console.error('No targets provided. Pass targets as arguments, --from-file, or --stdin.'); - process.exit(1); + throw new ConfigError( + 'No targets provided. Pass targets as arguments, --from-file, or --stdin.', + ); } const batchOpts = { diff --git a/src/cli/commands/check.js b/src/cli/commands/check.js index 4c79fc1..78edb0b 100644 --- a/src/cli/commands/check.js +++ b/src/cli/commands/check.js @@ -1,3 +1,4 @@ +import { ConfigError } from '../../errors.js'; import { EVERY_SYMBOL_KIND } from '../../queries.js'; export const command = { @@ -27,8 +28,9 @@ export const command = { if (!isDiffMode && !opts.rules) { if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); - process.exit(1); + throw new ConfigError( + `Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`, + ); } const { manifesto } = await import('../../commands/manifesto.js'); manifesto(opts.db, { @@ -58,8 +60,9 @@ export const command = { if (opts.rules) { if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); - process.exit(1); + throw new ConfigError( + `Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`, + ); } const { manifesto } = await import('../../commands/manifesto.js'); manifesto(opts.db, { diff --git a/src/cli/commands/co-change.js b/src/cli/commands/co-change.js index 9118ed0..ef4885b 100644 --- a/src/cli/commands/co-change.js +++ b/src/cli/commands/co-change.js @@ -1,3 +1,5 @@ +import { AnalysisError } from '../../errors.js'; + export const command = { name: 'co-change [file]', description: @@ -32,8 +34,7 @@ export const command = { if (opts.json) { console.log(JSON.stringify(result, null, 2)); } else if (result.error) { - console.error(result.error); - process.exit(1); + throw new AnalysisError(result.error); } else { console.log( `\nCo-change analysis complete: ${result.pairsFound} pairs from ${result.commitsScanned} commits (since: ${result.since})\n`, diff --git a/src/cli/commands/registry.js b/src/cli/commands/registry.js index c218155..9e516d9 100644 --- a/src/cli/commands/registry.js +++ b/src/cli/commands/registry.js @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { ConfigError } from '../../errors.js'; import { listRepos, pruneRegistry, @@ -54,8 +55,7 @@ export const command = { if (removed) { console.log(`Removed "${name}" from registry.`); } else { - console.error(`Repository "${name}" not found in registry.`); - process.exit(1); + throw new ConfigError(`Repository "${name}" not found in registry.`); } }, }, diff --git a/src/cli/commands/snapshot.js b/src/cli/commands/snapshot.js index b2dc345..8dd0093 100644 --- a/src/cli/commands/snapshot.js +++ b/src/cli/commands/snapshot.js @@ -12,13 +12,8 @@ export const command = { ['--force', 'Overwrite existing snapshot'], ], execute([name], opts, ctx) { - try { - const result = snapshotSave(name, { dbPath: opts.db, force: opts.force }); - console.log(`Snapshot saved: ${result.name} (${ctx.formatSize(result.size)})`); - } catch (err) { - console.error(err.message); - process.exit(1); - } + const result = snapshotSave(name, { dbPath: opts.db, force: opts.force }); + console.log(`Snapshot saved: ${result.name} (${ctx.formatSize(result.size)})`); }, }, { @@ -26,13 +21,8 @@ export const command = { description: 'Restore a snapshot over the current graph database', options: [['-d, --db ', 'Path to graph.db']], execute([name], opts) { - try { - snapshotRestore(name, { dbPath: opts.db }); - console.log(`Snapshot "${name}" restored.`); - } catch (err) { - console.error(err.message); - process.exit(1); - } + snapshotRestore(name, { dbPath: opts.db }); + console.log(`Snapshot "${name}" restored.`); }, }, { @@ -43,23 +33,18 @@ export const command = { ['-j, --json', 'Output as JSON'], ], execute(_args, opts, ctx) { - try { - const snapshots = snapshotList({ dbPath: opts.db }); - if (opts.json) { - console.log(JSON.stringify(snapshots, null, 2)); - } else if (snapshots.length === 0) { - console.log('No snapshots found.'); - } else { - console.log(`Snapshots (${snapshots.length}):\n`); - for (const s of snapshots) { - console.log( - ` ${s.name.padEnd(30)} ${ctx.formatSize(s.size).padStart(10)} ${s.createdAt.toISOString()}`, - ); - } + const snapshots = snapshotList({ dbPath: opts.db }); + if (opts.json) { + console.log(JSON.stringify(snapshots, null, 2)); + } else if (snapshots.length === 0) { + console.log('No snapshots found.'); + } else { + console.log(`Snapshots (${snapshots.length}):\n`); + for (const s of snapshots) { + console.log( + ` ${s.name.padEnd(30)} ${ctx.formatSize(s.size).padStart(10)} ${s.createdAt.toISOString()}`, + ); } - } catch (err) { - console.error(err.message); - process.exit(1); } }, }, @@ -68,13 +53,8 @@ export const command = { description: 'Delete a saved snapshot', options: [['-d, --db ', 'Path to graph.db']], execute([name], opts) { - try { - snapshotDelete(name, { dbPath: opts.db }); - console.log(`Snapshot "${name}" deleted.`); - } catch (err) { - console.error(err.message); - process.exit(1); - } + snapshotDelete(name, { dbPath: opts.db }); + console.log(`Snapshot "${name}" deleted.`); }, }, ], diff --git a/src/cli/commands/triage.js b/src/cli/commands/triage.js index eb8946d..a334475 100644 --- a/src/cli/commands/triage.js +++ b/src/cli/commands/triage.js @@ -1,3 +1,4 @@ +import { ConfigError } from '../../errors.js'; import { EVERY_SYMBOL_KIND, VALID_ROLES } from '../../queries.js'; export const command = { @@ -46,20 +47,17 @@ export const command = { } if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { - console.error(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); - process.exit(1); + throw new ConfigError(`Invalid kind "${opts.kind}". Valid: ${EVERY_SYMBOL_KIND.join(', ')}`); } if (opts.role && !VALID_ROLES.includes(opts.role)) { - console.error(`Invalid role "${opts.role}". Valid: ${VALID_ROLES.join(', ')}`); - process.exit(1); + throw new ConfigError(`Invalid role "${opts.role}". Valid: ${VALID_ROLES.join(', ')}`); } let weights; if (opts.weights) { try { weights = JSON.parse(opts.weights); - } catch { - console.error('Invalid --weights JSON'); - process.exit(1); + } catch (err) { + throw new ConfigError('Invalid --weights JSON', { cause: err }); } } const { triage } = await import('../../commands/triage.js'); diff --git a/src/cli/index.js b/src/cli/index.js index 83fecb9..52936b4 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { Command } from 'commander'; +import { ConfigError } from '../errors.js'; import { setVerbose } from '../logger.js'; import { checkForUpdates, printUpdateNotification } from '../update-check.js'; import { applyQueryOpts, config, formatSize, resolveNoTests } from './shared/options.js'; @@ -68,8 +69,7 @@ function registerCommand(parent, def) { if (def.validate) { const err = def.validate(args, opts, ctx); if (err) { - console.error(err); - process.exit(1); + throw new ConfigError(err); } } @@ -112,7 +112,7 @@ async function discoverCommands() { export async function run() { await discoverCommands(); - program.parse(); + await program.parseAsync(); } export { program, registerCommand, ctx }; diff --git a/src/commands/check.js b/src/commands/check.js index b3ae6d1..114b09f 100644 --- a/src/commands/check.js +++ b/src/commands/check.js @@ -1,19 +1,19 @@ import { checkData } from '../check.js'; +import { AnalysisError } from '../errors.js'; import { outputResult } from '../infrastructure/result-formatter.js'; /** - * CLI formatter — prints check results and exits with code 1 on failure. + * CLI formatter — prints check results and sets exitCode 1 on failure. */ export function check(customDbPath, opts = {}) { const data = checkData(customDbPath, opts); if (data.error) { - console.error(data.error); - process.exit(1); + throw new AnalysisError(data.error); } if (outputResult(data, null, opts)) { - if (!data.passed) process.exit(1); + if (!data.passed) process.exitCode = 1; return; } @@ -77,6 +77,6 @@ export function check(customDbPath, opts = {}) { console.log(`\n ${s.total} predicates | ${s.passed} passed | ${s.failed} failed\n`); if (!data.passed) { - process.exit(1); + process.exitCode = 1; } } diff --git a/src/commands/manifesto.js b/src/commands/manifesto.js index 8044f61..0ccf1d1 100644 --- a/src/commands/manifesto.js +++ b/src/commands/manifesto.js @@ -2,13 +2,13 @@ import { outputResult } from '../infrastructure/result-formatter.js'; import { manifestoData } from '../manifesto.js'; /** - * CLI formatter — prints manifesto results and exits with code 1 on failure. + * CLI formatter — prints manifesto results and sets exitCode 1 on failure. */ export function manifesto(customDbPath, opts = {}) { const data = manifestoData(customDbPath, opts); if (outputResult(data, 'violations', opts)) { - if (!data.passed) process.exit(1); + if (!data.passed) process.exitCode = 1; return; } @@ -72,6 +72,6 @@ export function manifesto(customDbPath, opts = {}) { console.log(); if (!data.passed) { - process.exit(1); + process.exitCode = 1; } } diff --git a/src/db/connection.js b/src/db/connection.js index beffdc4..d8b34c2 100644 --- a/src/db/connection.js +++ b/src/db/connection.js @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import Database from 'better-sqlite3'; +import { DbError } from '../errors.js'; import { warn } from '../logger.js'; function isProcessAlive(pid) { @@ -78,11 +79,10 @@ export function findDbPath(customPath) { export function openReadonlyOrFail(customPath) { const dbPath = findDbPath(customPath); if (!fs.existsSync(dbPath)) { - console.error( - `No codegraph database found at ${dbPath}.\n` + - `Run "codegraph build" first to analyze your codebase.`, + throw new DbError( + `No codegraph database found at ${dbPath}.\nRun "codegraph build" first to analyze your codebase.`, + { file: dbPath }, ); - process.exit(1); } return new Database(dbPath, { readonly: true }); } diff --git a/src/db/query-builder.js b/src/db/query-builder.js index 29b8768..2f43e75 100644 --- a/src/db/query-builder.js +++ b/src/db/query-builder.js @@ -1,3 +1,4 @@ +import { DbError } from '../errors.js'; import { EVERY_EDGE_KIND } from '../kinds.js'; // ─── Validation Helpers ───────────────────────────────────────────── @@ -12,13 +13,13 @@ const SAFE_SELECT_TOKEN_RE = function validateAlias(alias) { if (!SAFE_ALIAS_RE.test(alias)) { - throw new Error(`Invalid SQL alias: ${alias}`); + throw new DbError(`Invalid SQL alias: ${alias}`); } } function validateColumn(column) { if (!SAFE_COLUMN_RE.test(column)) { - throw new Error(`Invalid SQL column: ${column}`); + throw new DbError(`Invalid SQL column: ${column}`); } } @@ -26,7 +27,7 @@ function validateOrderBy(clause) { const terms = clause.split(',').map((t) => t.trim()); for (const term of terms) { if (!SAFE_ORDER_TERM_RE.test(term)) { - throw new Error(`Invalid ORDER BY term: ${term}`); + throw new DbError(`Invalid ORDER BY term: ${term}`); } } } @@ -51,14 +52,14 @@ function validateSelectCols(cols) { const tokens = splitTopLevelCommas(cols); for (const token of tokens) { if (!SAFE_SELECT_TOKEN_RE.test(token)) { - throw new Error(`Invalid SELECT expression: ${token}`); + throw new DbError(`Invalid SELECT expression: ${token}`); } } } function validateEdgeKind(edgeKind) { if (!EVERY_EDGE_KIND.includes(edgeKind)) { - throw new Error( + throw new DbError( `Invalid edge kind: ${edgeKind} (expected one of ${EVERY_EDGE_KIND.join(', ')})`, ); } diff --git a/src/db/repository/nodes.js b/src/db/repository/nodes.js index 7fa3d03..af4a347 100644 --- a/src/db/repository/nodes.js +++ b/src/db/repository/nodes.js @@ -1,3 +1,4 @@ +import { ConfigError } from '../../errors.js'; import { EVERY_SYMBOL_KIND, VALID_ROLES } from '../../kinds.js'; import { NodeQuery } from '../query-builder.js'; import { cachedStmt } from './cached-stmt.js'; @@ -37,10 +38,12 @@ export function findNodesWithFanIn(db, namePattern, opts = {}) { */ export function findNodesForTriage(db, opts = {}) { if (opts.kind && !EVERY_SYMBOL_KIND.includes(opts.kind)) { - throw new Error(`Invalid kind: ${opts.kind} (expected one of ${EVERY_SYMBOL_KIND.join(', ')})`); + throw new ConfigError( + `Invalid kind: ${opts.kind} (expected one of ${EVERY_SYMBOL_KIND.join(', ')})`, + ); } if (opts.role && !VALID_ROLES.includes(opts.role)) { - throw new Error(`Invalid role: ${opts.role} (expected one of ${VALID_ROLES.join(', ')})`); + throw new ConfigError(`Invalid role: ${opts.role} (expected one of ${VALID_ROLES.join(', ')})`); } const kindsToUse = opts.kind ? [opts.kind] : ['function', 'method', 'class']; diff --git a/src/embedder.js b/src/embedder.js index 3e03ee1..f8fbc52 100644 --- a/src/embedder.js +++ b/src/embedder.js @@ -10,6 +10,7 @@ import { openDb, openReadonlyOrFail, } from './db.js'; +import { ConfigError, DbError, EngineError } from './errors.js'; import { info, warn } from './logger.js'; import { normalizeSymbol } from './queries.js'; @@ -123,8 +124,7 @@ function getModelConfig(modelKey) { const key = modelKey || DEFAULT_MODEL; const config = MODELS[key]; if (!config) { - console.error(`Unknown model: ${key}. Available: ${Object.keys(MODELS).join(', ')}`); - process.exit(1); + throw new ConfigError(`Unknown model: ${key}. Available: ${Object.keys(MODELS).join(', ')}`); } return config; } @@ -263,13 +263,14 @@ async function loadTransformers() { if (installed) { try { return await import(pkg); - } catch { - console.error(`\n${pkg} was installed but failed to load. Please check your environment.`); - process.exit(1); + } catch (loadErr) { + throw new EngineError( + `${pkg} was installed but failed to load. Please check your environment.`, + { cause: loadErr }, + ); } } - console.error(`Semantic search requires ${pkg}.\n` + `Install it with: npm install ${pkg}`); - process.exit(1); + throw new EngineError(`Semantic search requires ${pkg}.\nInstall it with: npm install ${pkg}`); } } @@ -304,20 +305,20 @@ async function loadModel(modelKey) { } catch (err) { const msg = err.message || String(err); if (msg.includes('Unauthorized') || msg.includes('401') || msg.includes('gated')) { - console.error( - `\nModel "${config.name}" requires authentication.\n` + + throw new EngineError( + `Model "${config.name}" requires authentication.\n` + `This model is gated on HuggingFace and needs an access token.\n\n` + `Options:\n` + ` 1. Set HF_TOKEN env var: export HF_TOKEN=hf_...\n` + - ` 2. Use a public model instead: codegraph embed --model minilm\n`, - ); - } else { - console.error( - `\nFailed to load model "${config.name}": ${msg}\n` + - `Try a different model: codegraph embed --model minilm\n`, + ` 2. Use a public model instead: codegraph embed --model minilm`, + { cause: err }, ); } - process.exit(1); + throw new EngineError( + `Failed to load model "${config.name}": ${msg}\n` + + `Try a different model: codegraph embed --model minilm`, + { cause: err }, + ); } activeModel = config.name; info('Model loaded.'); @@ -413,11 +414,10 @@ export async function buildEmbeddings(rootDir, modelKey, customDbPath, options = const dbPath = customDbPath || findDbPath(null); if (!fs.existsSync(dbPath)) { - console.error( - `No codegraph database found at ${dbPath}.\n` + - `Run "codegraph build" first to analyze your codebase.`, + throw new DbError( + `No codegraph database found at ${dbPath}.\nRun "codegraph build" first to analyze your codebase.`, + { file: dbPath }, ); - process.exit(1); } const db = openDb(dbPath); diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..0a39844 --- /dev/null +++ b/src/errors.js @@ -0,0 +1,78 @@ +/** + * Domain error hierarchy for codegraph. + * + * Library code throws these instead of calling process.exit() or throwing + * bare Error instances. The CLI top-level catch formats them for humans; + * MCP returns structured { isError, code } responses. + */ + +export class CodegraphError extends Error { + /** @type {string} */ + code; + + /** @type {string|undefined} */ + file; + + /** + * @param {string} message + * @param {object} [opts] + * @param {string} [opts.code] + * @param {string} [opts.file] - Related file path, if applicable + * @param {Error} [opts.cause] - Original error that triggered this one + */ + constructor(message, { code = 'CODEGRAPH_ERROR', file, cause } = {}) { + super(message, { cause }); + this.name = 'CodegraphError'; + this.code = code; + this.file = file; + } +} + +export class ParseError extends CodegraphError { + constructor(message, opts = {}) { + super(message, { code: 'PARSE_FAILED', ...opts }); + this.name = 'ParseError'; + } +} + +export class DbError extends CodegraphError { + constructor(message, opts = {}) { + super(message, { code: 'DB_ERROR', ...opts }); + this.name = 'DbError'; + } +} + +export class ConfigError extends CodegraphError { + constructor(message, opts = {}) { + super(message, { code: 'CONFIG_INVALID', ...opts }); + this.name = 'ConfigError'; + } +} + +export class ResolutionError extends CodegraphError { + constructor(message, opts = {}) { + super(message, { code: 'RESOLUTION_FAILED', ...opts }); + this.name = 'ResolutionError'; + } +} + +export class EngineError extends CodegraphError { + constructor(message, opts = {}) { + super(message, { code: 'ENGINE_UNAVAILABLE', ...opts }); + this.name = 'EngineError'; + } +} + +export class AnalysisError extends CodegraphError { + constructor(message, opts = {}) { + super(message, { code: 'ANALYSIS_FAILED', ...opts }); + this.name = 'AnalysisError'; + } +} + +export class BoundaryError extends CodegraphError { + constructor(message, opts = {}) { + super(message, { code: 'BOUNDARY_VIOLATION', ...opts }); + this.name = 'BoundaryError'; + } +} diff --git a/src/index.js b/src/index.js index 15a018f..bca2cec 100644 --- a/src/index.js +++ b/src/index.js @@ -24,6 +24,16 @@ export { EXTENSIONS, IGNORE_DIRS } from './constants.js'; export { findCycles } from './cycles.js'; export { dataflowData } from './dataflow.js'; export { buildEmbeddings, hybridSearchData, multiSearchData, searchData } from './embedder.js'; +export { + AnalysisError, + BoundaryError, + CodegraphError, + ConfigError, + DbError, + EngineError, + ParseError, + ResolutionError, +} from './errors.js'; export { exportDOT, exportJSON, exportMermaid } from './export.js'; export { flowData, listEntryPointsData } from './flow.js'; export { EVERY_EDGE_KIND, EVERY_SYMBOL_KIND } from './kinds.js'; diff --git a/src/mcp/server.js b/src/mcp/server.js index 135c08a..3a39aed 100644 --- a/src/mcp/server.js +++ b/src/mcp/server.js @@ -7,6 +7,7 @@ import { createRequire } from 'node:module'; import { findDbPath } from '../db.js'; +import { CodegraphError, ConfigError } from '../errors.js'; import { MCP_MAX_LIMIT } from '../paginate.js'; import { buildToolList } from './tool-registry.js'; import { TOOL_HANDLERS } from './tools/index.js'; @@ -33,11 +34,9 @@ export async function startMCPServer(customDbPath, options = {}) { ListToolsRequestSchema = types.ListToolsRequestSchema; CallToolRequestSchema = types.CallToolRequestSchema; } catch { - console.error( - 'MCP server requires @modelcontextprotocol/sdk.\n' + - 'Install it with: npm install @modelcontextprotocol/sdk', + throw new ConfigError( + 'MCP server requires @modelcontextprotocol/sdk.\nInstall it with: npm install @modelcontextprotocol/sdk', ); - process.exit(1); } // Connect transport FIRST so the server can receive the client's @@ -75,12 +74,12 @@ export async function startMCPServer(customDbPath, options = {}) { const { name, arguments: args } = request.params; try { if (!multiRepo && args.repo) { - throw new Error( + throw new ConfigError( 'Multi-repo access is disabled. Restart with `codegraph mcp --multi-repo` to access other repositories.', ); } if (!multiRepo && name === 'list_repos') { - throw new Error( + throw new ConfigError( 'Multi-repo access is disabled. Restart with `codegraph mcp --multi-repo` to list repositories.', ); } @@ -88,12 +87,12 @@ export async function startMCPServer(customDbPath, options = {}) { let dbPath = customDbPath || undefined; if (args.repo) { if (allowedRepos && !allowedRepos.includes(args.repo)) { - throw new Error(`Repository "${args.repo}" is not in the allowed repos list.`); + throw new ConfigError(`Repository "${args.repo}" is not in the allowed repos list.`); } const { resolveRepoDbPath } = await import('../registry.js'); const resolved = resolveRepoDbPath(args.repo); if (!resolved) - throw new Error( + throw new ConfigError( `Repository "${args.repo}" not found in registry or its database is missing.`, ); dbPath = resolved; @@ -117,7 +116,10 @@ export async function startMCPServer(customDbPath, options = {}) { if (result?.content) return result; // pass-through MCP responses return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } catch (err) { - return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true }; + const code = err instanceof CodegraphError ? err.code : 'UNKNOWN_ERROR'; + const text = + err instanceof CodegraphError ? `[${code}] ${err.message}` : `Error: ${err.message}`; + return { content: [{ type: 'text', text }], isError: true }; } }); diff --git a/src/native.js b/src/native.js index ce435f9..7de86d9 100644 --- a/src/native.js +++ b/src/native.js @@ -8,6 +8,7 @@ import { createRequire } from 'node:module'; import os from 'node:os'; +import { EngineError } from './errors.js'; let _cached; // undefined = not yet tried, null = failed, object = module let _loadError = null; @@ -101,9 +102,10 @@ export function getNativePackageVersion() { export function getNative() { const mod = loadNative(); if (!mod) { - throw new Error( + throw new EngineError( `Native codegraph-core not available: ${_loadError?.message || 'unknown error'}. ` + 'Install the platform package or use --engine wasm.', + { cause: _loadError }, ); } return mod; diff --git a/src/snapshot.js b/src/snapshot.js index 43d46b0..0ce12bf 100644 --- a/src/snapshot.js +++ b/src/snapshot.js @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import Database from 'better-sqlite3'; import { findDbPath } from './db.js'; +import { ConfigError, DbError } from './errors.js'; import { debug } from './logger.js'; const NAME_RE = /^[a-zA-Z0-9_-]+$/; @@ -12,7 +13,7 @@ const NAME_RE = /^[a-zA-Z0-9_-]+$/; */ export function validateSnapshotName(name) { if (!name || !NAME_RE.test(name)) { - throw new Error( + throw new ConfigError( `Invalid snapshot name "${name}". Use only letters, digits, hyphens, and underscores.`, ); } @@ -39,7 +40,7 @@ export function snapshotSave(name, options = {}) { validateSnapshotName(name); const dbPath = options.dbPath || findDbPath(); if (!fs.existsSync(dbPath)) { - throw new Error(`Database not found: ${dbPath}`); + throw new DbError(`Database not found: ${dbPath}`, { file: dbPath }); } const dir = snapshotsDir(dbPath); @@ -47,7 +48,7 @@ export function snapshotSave(name, options = {}) { if (fs.existsSync(dest)) { if (!options.force) { - throw new Error(`Snapshot "${name}" already exists. Use --force to overwrite.`); + throw new ConfigError(`Snapshot "${name}" already exists. Use --force to overwrite.`); } fs.unlinkSync(dest); debug(`Deleted existing snapshot: ${dest}`); @@ -82,7 +83,7 @@ export function snapshotRestore(name, options = {}) { const src = path.join(dir, `${name}.db`); if (!fs.existsSync(src)) { - throw new Error(`Snapshot "${name}" not found at ${src}`); + throw new DbError(`Snapshot "${name}" not found at ${src}`, { file: src }); } // Remove WAL/SHM sidecar files for a clean restore @@ -141,7 +142,7 @@ export function snapshotDelete(name, options = {}) { const target = path.join(dir, `${name}.db`); if (!fs.existsSync(target)) { - throw new Error(`Snapshot "${name}" not found at ${target}`); + throw new DbError(`Snapshot "${name}" not found at ${target}`, { file: target }); } fs.unlinkSync(target); diff --git a/src/watcher.js b/src/watcher.js index 32c80e5..aad62fe 100644 --- a/src/watcher.js +++ b/src/watcher.js @@ -4,6 +4,7 @@ import { readFileSafe } from './builder.js'; import { appendChangeEvents, buildChangeEvent, diffSymbols } from './change-journal.js'; import { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js'; import { closeDb, getNodeId as getNodeIdQuery, initSchema, openDb } from './db.js'; +import { DbError } from './errors.js'; import { appendJournalEntries } from './journal.js'; import { info, warn } from './logger.js'; import { createParseTreeCache, getActiveEngine, parseFileIncremental } from './parser.js'; @@ -162,8 +163,7 @@ async function updateFile(_db, rootDir, filePath, stmts, engineOpts, cache) { export async function watchProject(rootDir, opts = {}) { const dbPath = path.join(rootDir, '.codegraph', 'graph.db'); if (!fs.existsSync(dbPath)) { - console.error('No graph.db found. Run `codegraph build` first.'); - process.exit(1); + throw new DbError('No graph.db found. Run `codegraph build` first.', { file: dbPath }); } const db = openDb(dbPath); diff --git a/tests/unit/db.test.js b/tests/unit/db.test.js index 10fcbcd..47dc393 100644 --- a/tests/unit/db.test.js +++ b/tests/unit/db.test.js @@ -6,7 +6,7 @@ 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, it, vi } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { closeDb, findDbPath, @@ -195,20 +195,16 @@ describe('build_meta', () => { }); describe('openReadonlyOrFail', () => { - it('exits with error when DB does not exist', () => { - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('process.exit'); - }); - const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - expect(() => openReadonlyOrFail(path.join(tmpDir, 'nonexistent.db'))).toThrow('process.exit'); - expect(exitSpy).toHaveBeenCalledWith(1); - expect(stderrSpy).toHaveBeenCalled(); - const errorMsg = stderrSpy.mock.calls[0][0]; - expect(errorMsg).toContain('No codegraph database found'); - - exitSpy.mockRestore(); - stderrSpy.mockRestore(); + it('throws DbError when DB does not exist', () => { + expect.assertions(4); + try { + openReadonlyOrFail(path.join(tmpDir, 'nonexistent.db')); + } catch (err) { + expect(err.message).toContain('No codegraph database found'); + expect(err.name).toBe('DbError'); + expect(err.code).toBe('DB_ERROR'); + expect(err.file).toBeDefined(); + } }); it('returns a readonly database when DB exists', () => { diff --git a/tests/unit/errors.test.js b/tests/unit/errors.test.js new file mode 100644 index 0000000..3714df5 --- /dev/null +++ b/tests/unit/errors.test.js @@ -0,0 +1,69 @@ +/** + * Unit tests for the domain error hierarchy (src/errors.js). + */ + +import { describe, expect, it } from 'vitest'; +import { + AnalysisError, + BoundaryError, + CodegraphError, + ConfigError, + DbError, + EngineError, + ParseError, + ResolutionError, +} from '../../src/errors.js'; + +describe('CodegraphError', () => { + it('sets defaults', () => { + const err = new CodegraphError('boom'); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(CodegraphError); + expect(err.name).toBe('CodegraphError'); + expect(err.code).toBe('CODEGRAPH_ERROR'); + expect(err.message).toBe('boom'); + expect(err.file).toBeUndefined(); + expect(err.cause).toBeUndefined(); + }); + + it('accepts opts', () => { + const cause = new Error('root'); + const err = new CodegraphError('msg', { code: 'CUSTOM', file: 'foo.js', cause }); + expect(err.code).toBe('CUSTOM'); + expect(err.file).toBe('foo.js'); + expect(err.cause).toBe(cause); + }); +}); + +describe('subclasses', () => { + const cases = [ + { Class: ParseError, name: 'ParseError', code: 'PARSE_FAILED' }, + { Class: DbError, name: 'DbError', code: 'DB_ERROR' }, + { Class: ConfigError, name: 'ConfigError', code: 'CONFIG_INVALID' }, + { Class: ResolutionError, name: 'ResolutionError', code: 'RESOLUTION_FAILED' }, + { Class: EngineError, name: 'EngineError', code: 'ENGINE_UNAVAILABLE' }, + { Class: AnalysisError, name: 'AnalysisError', code: 'ANALYSIS_FAILED' }, + { Class: BoundaryError, name: 'BoundaryError', code: 'BOUNDARY_VIOLATION' }, + ]; + + for (const { Class, name, code } of cases) { + it(`${name} has correct defaults and instanceof chain`, () => { + const err = new Class('test'); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(CodegraphError); + expect(err).toBeInstanceOf(Class); + expect(err.name).toBe(name); + expect(err.code).toBe(code); + expect(err.message).toBe('test'); + }); + + it(`${name} forwards file and cause`, () => { + const cause = new Error('root'); + const err = new Class('msg', { file: 'bar.js', cause }); + expect(err.file).toBe('bar.js'); + expect(err.cause).toBe(cause); + // code should stay as the subclass default + expect(err.code).toBe(code); + }); + } +}); diff --git a/tests/unit/prompt-install.test.js b/tests/unit/prompt-install.test.js index d758350..6a36c2d 100644 --- a/tests/unit/prompt-install.test.js +++ b/tests/unit/prompt-install.test.js @@ -34,7 +34,7 @@ describe('loadTransformers install prompt', () => { vi.restoreAllMocks(); }); - test('non-TTY: prints error and exits without prompting', async () => { + test('non-TTY: throws EngineError without prompting', async () => { process.stdin.isTTY = undefined; const rlFactory = vi.fn(); @@ -46,15 +46,18 @@ describe('loadTransformers install prompt', () => { const { embed } = await import('../../src/embedder.js'); - await expect(embed(['test'], 'minilm')).rejects.toThrow('process.exit(1)'); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('Semantic search requires @huggingface/transformers'), + await expect(embed(['test'], 'minilm')).rejects.toThrow( + 'Semantic search requires @huggingface/transformers', ); + await expect(embed(['test'], 'minilm')).rejects.toMatchObject({ + name: 'EngineError', + code: 'ENGINE_UNAVAILABLE', + }); // readline should NOT have been called — no prompt in non-TTY expect(rlFactory).not.toHaveBeenCalled(); }); - test('TTY + user declines: prints error and exits', async () => { + test('TTY + user declines: throws EngineError', async () => { process.stdin.isTTY = true; vi.doMock('node:readline', () => ({ @@ -70,13 +73,16 @@ describe('loadTransformers install prompt', () => { const { embed } = await import('../../src/embedder.js'); - await expect(embed(['test'], 'minilm')).rejects.toThrow('process.exit(1)'); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('Semantic search requires @huggingface/transformers'), + await expect(embed(['test'], 'minilm')).rejects.toThrow( + 'Semantic search requires @huggingface/transformers', ); + await expect(embed(['test'], 'minilm')).rejects.toMatchObject({ + name: 'EngineError', + code: 'ENGINE_UNAVAILABLE', + }); }); - test('TTY + user accepts but npm install fails: prints error and exits', async () => { + test('TTY + user accepts but npm install fails: throws EngineError', async () => { process.stdin.isTTY = true; const execMock = vi.fn(() => { @@ -95,15 +101,18 @@ describe('loadTransformers install prompt', () => { const { embed } = await import('../../src/embedder.js'); - await expect(embed(['test'], 'minilm')).rejects.toThrow('process.exit(1)'); + await expect(embed(['test'], 'minilm')).rejects.toThrow( + 'Semantic search requires @huggingface/transformers', + ); + await expect(embed(['test'], 'minilm')).rejects.toMatchObject({ + name: 'EngineError', + code: 'ENGINE_UNAVAILABLE', + }); expect(execMock).toHaveBeenCalledWith( 'npm', ['install', '@huggingface/transformers'], expect.objectContaining({ stdio: 'inherit', timeout: 300_000 }), ); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('Semantic search requires @huggingface/transformers'), - ); }); test('TTY + install succeeds: retries import and loads module', async () => {