Skip to content

refactor: domain error hierarchy (ROADMAP 3.8)#431

Merged
carlos-alm merged 5 commits intomainfrom
refactor/domain-error-hierarchy
Mar 13, 2026
Merged

refactor: domain error hierarchy (ROADMAP 3.8)#431
carlos-alm merged 5 commits intomainfrom
refactor/domain-error-hierarchy

Conversation

@carlos-alm
Copy link
Contributor

Summary

  • Add src/errors.js with structured domain error hierarchy: CodegraphError base class + 7 subclasses (ParseError, DbError, ConfigError, ResolutionError, EngineError, AnalysisError, BoundaryError)
  • Replace all process.exit(1) calls in library code (embedder, db/connection, watcher, mcp/server, snapshot) with thrown domain errors
  • Replace all throw new Error() in library code (ast-analysis/shared, batch, db/query-builder, db/repository/nodes, native, snapshot) with appropriate domain error subclasses
  • CLI top-level catch formats CodegraphError with [CODE]: message prefix; MCP catch returns structured { isError, code } responses
  • CI gate commands (check, manifesto) use process.exitCode = 1 instead of process.exit(1)
  • CLI uses parseAsync() so async command errors propagate to the top-level handler
  • All error classes exported from the public API (src/index.js)

Test plan

  • New tests/unit/errors.test.js — 16 tests covering all classes, instanceof chain, option forwarding
  • Updated tests/unit/db.test.js — asserts DbError instead of process.exit spy
  • Updated tests/unit/prompt-install.test.js — asserts EngineError instead of process.exit spy
  • All existing tests pass (97/97 in affected files)
  • Lint clean (biome check passes)
  • Zero process.exit(1) remaining in library code (only in CLI entry point)
  • Zero throw new Error remaining in src/

…DMAP 3.8)

Add structured domain errors (CodegraphError base + 7 subclasses) to
replace the mix of process.exit(1), throw new Error, and console.error
scattered across library code.

- New src/errors.js with ParseError, DbError, ConfigError, ResolutionError,
  EngineError, AnalysisError, BoundaryError
- Library code throws domain errors instead of calling process.exit(1)
- CLI top-level catch formats CodegraphError with [CODE] prefix
- MCP catch returns structured { isError, code } responses
- CLI commands use parseAsync() so async errors propagate
- CI gate commands (check, manifesto) use process.exitCode instead of exit
- All error classes exported from public API (src/index.js)

Impact: 52 functions changed, 215 affected
@claude
Copy link

claude bot commented Mar 13, 2026

Claude finished @carlos-alm's task —— View job


PR Review: Domain Error Hierarchy

Todo List:

  • Phase 1: Root Cause Analysis - Understand the problem being solved
  • Phase 2: Configuration Change Detection - Check for rule relaxations
  • Phase 3: Test Integrity - Verify test quality and coverage
  • Phase 4: Code Quality - Review implementation details
  • Final Assessment - Provide rating and recommendation

Root Cause Analysis

Problem Being Solved: This PR addresses a fundamental architectural issue where the codegraph library mixed concerns between library code and CLI behavior. Previously, library functions were directly calling process.exit(1) and throwing bare Error instances, making the code unusable as a programmatic API and breaking proper error propagation.

Why This Approach: The domain error hierarchy provides:

  • Separation of concerns: Library code throws structured errors; CLI decides how to handle them
  • Programmatic API support: Callers can catch specific error types and handle them appropriately
  • Better error context: Each error carries a semantic code and optional file metadata
  • MCP integration: Structured error responses instead of process termination

Risk Assessment: ✅ LOW RISK - This is a well-architected improvement that enhances maintainability and API usability.

Code Quality Assessment

Excellent Implementation (src/errors.js:1-78)

The error hierarchy is well-designed:

  • Clean inheritance chain from base CodegraphError
  • Consistent constructor patterns with options forwarding
  • Proper use of Error cause chaining
  • Semantic error codes for programmatic handling
  • Optional file metadata for debugging context

Proper Error Replacement Patterns

Reviewed all conversions from process.exit(1) → domain errors:

  • src/db/connection.js:79-86: DbError with file context for missing database
  • src/embedder.js:124-135: ConfigError for unknown models, EngineError for missing dependencies
  • src/native.js:102-112: EngineError with cause chaining for native addon failures
  • src/mcp/server.js:34-40: ConfigError for missing MCP SDK dependency

CLI Error Handling (src/cli.js:6-14)

Perfect top-level catch implementation:

  • Formats CodegraphError instances with [CODE]: message prefix
  • Shows file context when available
  • Graceful fallback for unexpected errors
  • Only CLI entry point calls process.exit(1)

Improved CI Gate Behavior

CI commands now use process.exitCode = 1 instead of process.exit(1):

  • src/commands/check.js:16,80: Non-blocking exit code setting
  • Allows proper cleanup and testing

Test Quality Improvements

  • tests/unit/errors.test.js: Comprehensive 16-test suite covering all error classes
  • tests/unit/db.test.js:198-209: Upgraded from process.exit spy to proper DbError assertion
  • tests/unit/prompt-install.test.js: All 4 tests migrated to expect EngineError instead of process.exit
  • Tests verify instanceof chains, option forwarding, and error codes

Public API Export (src/index.js:122-132)

All error classes properly exported for programmatic usage.

Critical Concerns

None identified. This is exemplary refactoring work.

Configuration & Lint Status

  • ✅ No configuration rules relaxed
  • ✅ No test coverage thresholds lowered
  • ✅ No lint rules disabled
  • ✅ Biome configuration unchanged

Final Recommendation

  • Rating: ⭐⭐⭐⭐⭐ (5/5)
  • Action: APPROVE
  • Reasoning: This is exceptional engineering work that solves a real architectural problem with a clean, well-tested solution. The error hierarchy enables proper separation of concerns, improves the programmatic API, and maintains backward compatibility. The implementation demonstrates excellent attention to detail with comprehensive test coverage and consistent patterns throughout the codebase.

Notable Excellence:

  • Zero process.exit(1) remaining in library code ✅
  • Zero throw new Error remaining in src/
  • Comprehensive test coverage for new functionality ✅
  • Proper error cause chaining and context preservation ✅
  • Clean CLI error formatting with semantic codes ✅

This PR sets a high standard for error handling patterns in the codebase and enables better integration with tools like MCP servers and programmatic API consumers.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 13, 2026

Greptile Summary

This PR introduces a structured domain error hierarchy (src/errors.js) and migrates all process.exit(1) and bare throw new Error() calls in library code to typed subclasses, making the library safe to use programmatically and enabling structured error formatting in both the CLI and MCP layers.

  • New src/errors.js: CodegraphError base class + 7 typed subclasses (ParseError, DbError, ConfigError, ResolutionError, EngineError, AnalysisError, BoundaryError), all exported from src/index.js for programmatic callers.
  • parseAsync() fix (src/cli/index.js): The switch from program.parse() to await program.parseAsync() is a meaningful correctness fix — previously, errors thrown inside async command handlers could escape as unhandled rejections instead of reaching the top-level .catch() in cli.js.
  • CI gate pattern (src/commands/check.js, src/commands/manifesto.js): Using process.exitCode = 1 instead of process.exit(1) lets the process terminate naturally, allowing any cleanup hooks to run — appropriate for CI gate commands.
  • MCP structured responses: The catch block now embeds the error code as [CODE] message in the text content, consistent with the MCP protocol response shape.
  • Test quality: tests/unit/prompt-install.test.js uses two separate embed() calls per assertion pair rather than reusing a single rejected promise, causing mock side-effects (readline, execFileSync) to trigger twice per test case. This doesn't break any tests today but could mask regression in call-count sensitive mocks.

Confidence Score: 4/5

  • Safe to merge — the refactor is mechanically sound, all tests pass, and the parseAsync() fix is a net improvement over the previous async error-handling gap.
  • The error hierarchy design is clean and consistently applied across all 26 changed files. The one issue found (double embed() invocation in prompt-install.test.js) is a test quality concern that doesn't affect runtime correctness. No logic regressions were identified.
  • tests/unit/prompt-install.test.js — double embed() calls per assertion pair cause mock side-effects to fire twice per test.

Important Files Changed

Filename Overview
src/errors.js New domain error hierarchy — clean design with CodegraphError base + 7 typed subclasses; opts spread allows callers to override the subclass default code, which is intentional and harmless given no current callers do so.
src/cli/index.js Key correctness fix: program.parse()await program.parseAsync() ensures async command-handler rejections propagate to the top-level .catch() in cli.js; previously async errors would escape unhandled.
src/cli.js Top-level error handler now formats CodegraphError with [CODE]: message prefix and optionally prints file; falls back to the old fatal error format for unexpected errors. Clean and correct.
src/mcp/server.js MCP catch block now formats domain errors as [CODE] message in the text content and propagates isError: true; the code is embedded in the text string rather than as a top-level field, which is consistent with MCP protocol constraints.
src/snapshot.js All four throw new Error() calls replaced with correctly-typed subclasses: ConfigError for name/flag validation, DbError (with file metadata) for missing DB/snapshot files. Matches the fix requested in a previous review thread.
src/embedder.js All four process.exit(1) paths replaced with EngineError or ConfigError (with cause forwarded where applicable); auth vs generic model-load failure paths remain distinct and correct.
src/commands/check.js process.exit(1)process.exitCode = 1 for CI gate; correct pattern since the function returns normally and Node exits naturally with the set code. data.error guard now throws AnalysisError.
src/commands/manifesto.js Same process.exitCode = 1 pattern as check.js — appropriate for a CI gate that should let cleanup handlers run before the process terminates.
tests/unit/prompt-install.test.js Tests updated to assert EngineError instead of process.exit spies, but each assertion pair calls embed() twice, causing execFileSync to fire twice in the npm-install-fail case and readline to be invoked twice in TTY cases.
tests/unit/errors.test.js 16 new tests covering defaults, instanceof chain, and option forwarding for all 8 error classes. Good coverage.
tests/unit/db.test.js Updated to assert DbError properties via expect.assertions(4) + try/catch; vi import removed; the previous threading concern about silent assertions was addressed in a prior commit.
src/index.js All 8 domain error classes exported from the public API, making them available for programmatic callers to instanceof-check.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Library code\ne.g. embedder, db/connection, snapshot, native, watcher] -->|throws| B{Error type}

    B -->|ConfigError\nCONFIG_INVALID| C[domain error]
    B -->|DbError\nDB_ERROR| C
    B -->|EngineError\nENGINE_UNAVAILABLE| C
    B -->|AnalysisError\nANALYSIS_FAILED| C
    B -->|ParseError / ResolutionError\nBoundaryError| C

    C --> D{Entry point}

    D -->|CLI\ncli.js .catch| E[instanceof CodegraphError?]
    E -->|Yes| F["console.error: codegraph [CODE]: message\n+ file: path if present\nprocess.exit(1)"]
    E -->|No| G["console.error: codegraph: fatal error — msg\nprocess.exit(1)"]

    D -->|MCP\nserver.js catch| H[instanceof CodegraphError?]
    H -->|Yes| I["{ content: '[CODE] message', isError: true }"]
    H -->|No| J["{ content: 'Error: message', isError: true }"]

    D -->|CI gates\ncheck / manifesto| K["process.exitCode = 1\n(non-terminating — process exits naturally)"]
Loading

Last reviewed commit: 74396df

Comment on lines +198 to 209
it('throws DbError when DB does not exist', () => {
expect(() => openReadonlyOrFail(path.join(tmpDir, 'nonexistent.db'))).toThrow(
'No codegraph database found',
);
try {
openReadonlyOrFail(path.join(tmpDir, 'nonexistent.db'));
} catch (err) {
expect(err.name).toBe('DbError');
expect(err.code).toBe('DB_ERROR');
expect(err.file).toBeDefined();
}
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assertions inside try/catch can be silently skipped

The three expect calls inside the catch block will never run if the second call to openReadonlyOrFail somehow doesn't throw, causing the test to pass without verifying name, code, or file. Since the first expect().toThrow() already guards execution, the risk here is minimal in practice, but using expect.assertions(n) makes the intent explicit and prevents silent misses.

Suggested change
it('throws DbError when DB does not exist', () => {
expect(() => openReadonlyOrFail(path.join(tmpDir, 'nonexistent.db'))).toThrow(
'No codegraph database found',
);
try {
openReadonlyOrFail(path.join(tmpDir, 'nonexistent.db'));
} catch (err) {
expect(err.name).toBe('DbError');
expect(err.code).toBe('DB_ERROR');
expect(err.file).toBeDefined();
}
});
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();
}
});

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — switched to expect.assertions(4) with a single try/catch, removing the redundant first toThrow() call. d2794cb

src/snapshot.js Outdated
Comment on lines +50 to +51
if (!options.force) {
throw new Error(`Snapshot "${name}" already exists. Use --force to overwrite.`);
throw new DbError(`Snapshot "${name}" already exists. Use --force to overwrite.`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semantic mismatch: DbError for a user-input guard

"Snapshot already exists — use --force to overwrite" is a precondition violation driven by a missing CLI flag, not a database failure. Callers inspecting err.code === 'DB_ERROR' would reasonably expect an I/O or SQLite problem. ConfigError (code CONFIG_INVALID) better signals that the user needs to supply --force.

Suggested change
if (!options.force) {
throw new Error(`Snapshot "${name}" already exists. Use --force to overwrite.`);
throw new DbError(`Snapshot "${name}" already exists. Use --force to overwrite.`);
throw new ConfigError(`Snapshot "${name}" already exists. Use --force to overwrite.`);

You would also need to import ConfigError at the top of this file (it's already imported alongside DbError):

import { ConfigError, DbError } from './errors.js';

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — changed to ConfigError since this is a missing --force flag, not a database I/O issue. d2794cb

- Use expect.assertions(4) in db.test.js to prevent silent assertion skips
- Change snapshot "already exists" error from DbError to ConfigError
  (it's a missing --force flag, not a database failure)

Impact: 1 functions changed, 0 affected
@carlos-alm
Copy link
Contributor Author

@greptileai

@carlos-alm carlos-alm merged commit 1a729a6 into main Mar 13, 2026
14 checks passed
@carlos-alm carlos-alm deleted the refactor/domain-error-hierarchy branch March 13, 2026 07:57
@github-actions github-actions bot locked and limited conversation to collaborators Mar 13, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant