feat(error/error-hierarchy): implement typed error class hierarchy
- Add typed fields to all error subclasses (taskId, cycles, field, message, prerequisite, dependent) - Set Object.setPrototypeOf(this, new.target.prototype) in all constructors - Add InvalidInputError.fromTypeBoxError() static factory for TypeBox Value.Errors() output - CircularDependencyError accepts string[][] for cycle paths - 31 unit tests covering instanceof chain, field access, .name property, and error messages
This commit is contained in:
@@ -1,44 +1,87 @@
|
||||
// Error classes — TaskgraphError, TaskNotFoundError, CircularDependencyError,
|
||||
// InvalidInputError, DuplicateNodeError, DuplicateEdgeError
|
||||
//
|
||||
// All errors extend TaskgraphError (which extends Error) and set this.name
|
||||
// to their class name. Object.setPrototypeOf is called in each constructor
|
||||
// to ensure instanceof works correctly across the prototype chain when
|
||||
// transpiled to ES5 targets or when extending built-ins.
|
||||
|
||||
export class TaskgraphError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TaskgraphError';
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskNotFoundError extends TaskgraphError {
|
||||
readonly taskId: string;
|
||||
|
||||
constructor(taskId: string) {
|
||||
super(`Task not found: ${taskId}`);
|
||||
this.name = 'TaskNotFoundError';
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
this.taskId = taskId;
|
||||
}
|
||||
}
|
||||
|
||||
export class CircularDependencyError extends TaskgraphError {
|
||||
constructor(cycle: string[]) {
|
||||
super(`Circular dependency detected: ${cycle.join(' → ')}`);
|
||||
readonly cycles: string[][];
|
||||
|
||||
constructor(cycles: string[][]) {
|
||||
const cycleDescriptions = cycles
|
||||
.map((cycle) => cycle.join(' → '))
|
||||
.join('; ');
|
||||
super(`Circular dependency detected: ${cycleDescriptions}`);
|
||||
this.name = 'CircularDependencyError';
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
this.cycles = cycles;
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidInputError extends TaskgraphError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
readonly field: string;
|
||||
override readonly message: string;
|
||||
|
||||
constructor(field: string, message: string) {
|
||||
super(`Invalid input for field "${field}": ${message}`);
|
||||
this.name = 'InvalidInputError';
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
this.field = field;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an InvalidInputError from a TypeBox Value.Errors() iterator entry.
|
||||
* TypeBox error entries have `path` (e.g. "/dependsOn") and `message` fields.
|
||||
* The path is stripped of the leading "/" to produce the field name.
|
||||
*/
|
||||
static fromTypeBoxError(error: { path: string; message: string }): InvalidInputError {
|
||||
const field = error.path.startsWith('/') ? error.path.slice(1) : error.path;
|
||||
return new InvalidInputError(field, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateNodeError extends TaskgraphError {
|
||||
constructor(nodeId: string) {
|
||||
super(`Duplicate node: ${nodeId}`);
|
||||
readonly taskId: string;
|
||||
|
||||
constructor(taskId: string) {
|
||||
super(`Duplicate node: ${taskId}`);
|
||||
this.name = 'DuplicateNodeError';
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
this.taskId = taskId;
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateEdgeError extends TaskgraphError {
|
||||
constructor(source: string, target: string) {
|
||||
super(`Duplicate edge: ${source} → ${target}`);
|
||||
readonly prerequisite: string;
|
||||
readonly dependent: string;
|
||||
|
||||
constructor(prerequisite: string, dependent: string) {
|
||||
super(`Duplicate edge: ${prerequisite} → ${dependent}`);
|
||||
this.name = 'DuplicateEdgeError';
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
this.prerequisite = prerequisite;
|
||||
this.dependent = dependent;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: error/error-hierarchy
|
||||
name: Implement typed error class hierarchy
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- setup/project-init
|
||||
scope: narrow
|
||||
@@ -16,18 +16,18 @@ Implement the custom error classes in `src/error/index.ts` per [errors-validatio
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `src/error/index.ts` exports:
|
||||
- [x] `src/error/index.ts` exports:
|
||||
- `TaskgraphError extends Error` — base class
|
||||
- `TaskNotFoundError extends TaskgraphError` with `taskId: string` field
|
||||
- `CircularDependencyError extends TaskgraphError` with `cycles: string[][]` field
|
||||
- `InvalidInputError extends TaskgraphError` with `field: string` and `message: string` fields
|
||||
- `DuplicateNodeError extends TaskgraphError` with `taskId: string` field
|
||||
- `DuplicateEdgeError extends TaskgraphError` with `prerequisite: string` and `dependent: string` fields
|
||||
- [ ] Each error class sets `this.name` to the class name
|
||||
- [ ] Each error class properly extends the prototype chain (`Object.setPrototypeOf(this, new.target.prototype)`)
|
||||
- [ ] `InvalidInputError` supports construction from TypeBox `Value.Errors()` output (receives structured field/path/value info)
|
||||
- [ ] `CircularDependencyError` receives `string[][]` where each inner array is an ordered cycle path
|
||||
- [ ] Unit tests verifying: correct `instanceof` chain, field access, `.name` property, error messages
|
||||
- [x] Each error class sets `this.name` to the class name
|
||||
- [x] Each error class properly extends the prototype chain (`Object.setPrototypeOf(this, new.target.prototype)`)
|
||||
- [x] `InvalidInputError` supports construction from TypeBox `Value.Errors()` output (receives structured field/path/value info)
|
||||
- [x] `CircularDependencyError` receives `string[][]` where each inner array is an ordered cycle path
|
||||
- [x] Unit tests verifying: correct `instanceof` chain, field access, `.name` property, error messages
|
||||
|
||||
## References
|
||||
|
||||
@@ -36,8 +36,11 @@ Implement the custom error classes in `src/error/index.ts` per [errors-validatio
|
||||
|
||||
## Notes
|
||||
|
||||
> To be filled by implementation agent
|
||||
InvalidInputError overrides `message` property to provide the validation-specific message while still calling `super()` with a combined message for the Error base. A static `fromTypeBoxError()` factory method converts TypeBox `Value.Errors()` output (with `/path` format) to the proper `field` string by stripping the leading slash.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
Implemented the full typed error class hierarchy in `src/error/index.ts`.
|
||||
- Modified: `src/error/index.ts` — rewrote skeleton to add typed fields, prototype chain setup, and TypeBox factory method
|
||||
- Created: `test/error.test.ts` — 31 unit tests covering instanceof chain, field access, .name property, error messages, and TypeBox integration
|
||||
- Tests: 36 passing (31 error-specific + 5 existing placeholders), all lint clean
|
||||
253
test/error.test.ts
Normal file
253
test/error.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
TaskgraphError,
|
||||
TaskNotFoundError,
|
||||
CircularDependencyError,
|
||||
InvalidInputError,
|
||||
DuplicateNodeError,
|
||||
DuplicateEdgeError,
|
||||
} from '../src/error/index.js';
|
||||
|
||||
describe('TaskgraphError', () => {
|
||||
it('is an instance of Error', () => {
|
||||
const err = new TaskgraphError('base error');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(TaskgraphError);
|
||||
});
|
||||
|
||||
it('sets name to TaskgraphError', () => {
|
||||
const err = new TaskgraphError('base error');
|
||||
expect(err.name).toBe('TaskgraphError');
|
||||
});
|
||||
|
||||
it('preserves the message', () => {
|
||||
const err = new TaskgraphError('something went wrong');
|
||||
expect(err.message).toBe('something went wrong');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskNotFoundError', () => {
|
||||
it('is an instance of Error, TaskgraphError, and TaskNotFoundError', () => {
|
||||
const err = new TaskNotFoundError('task-1');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(TaskgraphError);
|
||||
expect(err).toBeInstanceOf(TaskNotFoundError);
|
||||
});
|
||||
|
||||
it('sets name to TaskNotFoundError', () => {
|
||||
const err = new TaskNotFoundError('task-1');
|
||||
expect(err.name).toBe('TaskNotFoundError');
|
||||
});
|
||||
|
||||
it('exposes taskId field', () => {
|
||||
const err = new TaskNotFoundError('task-42');
|
||||
expect(err.taskId).toBe('task-42');
|
||||
});
|
||||
|
||||
it('includes taskId in the message', () => {
|
||||
const err = new TaskNotFoundError('task-42');
|
||||
expect(err.message).toContain('task-42');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CircularDependencyError', () => {
|
||||
it('is an instance of Error, TaskgraphError, and CircularDependencyError', () => {
|
||||
const err = new CircularDependencyError([['a', 'b', 'a']]);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(TaskgraphError);
|
||||
expect(err).toBeInstanceOf(CircularDependencyError);
|
||||
});
|
||||
|
||||
it('sets name to CircularDependencyError', () => {
|
||||
const err = new CircularDependencyError([['a', 'b', 'a']]);
|
||||
expect(err.name).toBe('CircularDependencyError');
|
||||
});
|
||||
|
||||
it('exposes cycles field as string[][]', () => {
|
||||
const cycles = [
|
||||
['a', 'b', 'c', 'a'],
|
||||
['x', 'y', 'x'],
|
||||
];
|
||||
const err = new CircularDependencyError(cycles);
|
||||
expect(err.cycles).toEqual(cycles);
|
||||
expect(err.cycles).toHaveLength(2);
|
||||
expect(err.cycles[0]).toEqual(['a', 'b', 'c', 'a']);
|
||||
expect(err.cycles[1]).toEqual(['x', 'y', 'x']);
|
||||
});
|
||||
|
||||
it('includes cycle path descriptions in the message', () => {
|
||||
const err = new CircularDependencyError([['a', 'b', 'c']]);
|
||||
expect(err.message).toContain('a → b → c');
|
||||
});
|
||||
|
||||
it('handles multiple cycles in message', () => {
|
||||
const err = new CircularDependencyError([
|
||||
['a', 'b', 'a'],
|
||||
['x', 'y', 'x'],
|
||||
]);
|
||||
expect(err.message).toContain('a → b → a');
|
||||
expect(err.message).toContain('x → y → x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InvalidInputError', () => {
|
||||
it('is an instance of Error, TaskgraphError, and InvalidInputError', () => {
|
||||
const err = new InvalidInputError('name', 'is required');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(TaskgraphError);
|
||||
expect(err).toBeInstanceOf(InvalidInputError);
|
||||
});
|
||||
|
||||
it('sets name to InvalidInputError', () => {
|
||||
const err = new InvalidInputError('name', 'is required');
|
||||
expect(err.name).toBe('InvalidInputError');
|
||||
});
|
||||
|
||||
it('exposes field and message fields', () => {
|
||||
const err = new InvalidInputError('dependsOn', 'must be an array');
|
||||
expect(err.field).toBe('dependsOn');
|
||||
expect(err.message).toBe('must be an array');
|
||||
});
|
||||
|
||||
it('exposes field and message as separate properties', () => {
|
||||
const err = new InvalidInputError('name', 'is required');
|
||||
// message field on InvalidInputError shadows Error.message and holds the
|
||||
// validation-specific message (per architecture spec: field + message)
|
||||
expect(err.field).toBe('name');
|
||||
expect(err.message).toBe('is required');
|
||||
});
|
||||
|
||||
it('creates from TypeBox Value.Errors() output via static method', () => {
|
||||
const typeboxError = {
|
||||
path: '/dependsOn',
|
||||
message: 'Expected array, received string',
|
||||
};
|
||||
const err = InvalidInputError.fromTypeBoxError(typeboxError);
|
||||
expect(err).toBeInstanceOf(InvalidInputError);
|
||||
expect(err.field).toBe('dependsOn');
|
||||
expect(err.message).toBe('Expected array, received string');
|
||||
});
|
||||
|
||||
it('strips leading slash from TypeBox path in fromTypeBoxError', () => {
|
||||
const typeboxError = {
|
||||
path: '/risk',
|
||||
message: 'Invalid enum value',
|
||||
};
|
||||
const err = InvalidInputError.fromTypeBoxError(typeboxError);
|
||||
expect(err.field).toBe('risk');
|
||||
});
|
||||
|
||||
it('handles nested path in fromTypeBoxError', () => {
|
||||
const typeboxError = {
|
||||
path: '/attributes/risk',
|
||||
message: 'Invalid enum value',
|
||||
};
|
||||
const err = InvalidInputError.fromTypeBoxError(typeboxError);
|
||||
expect(err.field).toBe('attributes/risk');
|
||||
});
|
||||
|
||||
it('handles path without leading slash in fromTypeBoxError', () => {
|
||||
const typeboxError = {
|
||||
path: 'name',
|
||||
message: 'Required',
|
||||
};
|
||||
const err = InvalidInputError.fromTypeBoxError(typeboxError);
|
||||
expect(err.field).toBe('name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DuplicateNodeError', () => {
|
||||
it('is an instance of Error, TaskgraphError, and DuplicateNodeError', () => {
|
||||
const err = new DuplicateNodeError('task-1');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(TaskgraphError);
|
||||
expect(err).toBeInstanceOf(DuplicateNodeError);
|
||||
});
|
||||
|
||||
it('sets name to DuplicateNodeError', () => {
|
||||
const err = new DuplicateNodeError('task-1');
|
||||
expect(err.name).toBe('DuplicateNodeError');
|
||||
});
|
||||
|
||||
it('exposes taskId field', () => {
|
||||
const err = new DuplicateNodeError('task-99');
|
||||
expect(err.taskId).toBe('task-99');
|
||||
});
|
||||
|
||||
it('includes taskId in the message', () => {
|
||||
const err = new DuplicateNodeError('task-99');
|
||||
expect(err.message).toContain('task-99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DuplicateEdgeError', () => {
|
||||
it('is an instance of Error, TaskgraphError, and DuplicateEdgeError', () => {
|
||||
const err = new DuplicateEdgeError('prereq', 'dep');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(TaskgraphError);
|
||||
expect(err).toBeInstanceOf(DuplicateEdgeError);
|
||||
});
|
||||
|
||||
it('sets name to DuplicateEdgeError', () => {
|
||||
const err = new DuplicateEdgeError('prereq', 'dep');
|
||||
expect(err.name).toBe('DuplicateEdgeError');
|
||||
});
|
||||
|
||||
it('exposes prerequisite and dependent fields', () => {
|
||||
const err = new DuplicateEdgeError('prereq-task', 'dep-task');
|
||||
expect(err.prerequisite).toBe('prereq-task');
|
||||
expect(err.dependent).toBe('dep-task');
|
||||
});
|
||||
|
||||
it('includes prerequisite and dependent in the message', () => {
|
||||
const err = new DuplicateEdgeError('prereq-task', 'dep-task');
|
||||
expect(err.message).toContain('prereq-task');
|
||||
expect(err.message).toContain('dep-task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prototype chain', () => {
|
||||
it('all subclasses are properly linked in the prototype chain', () => {
|
||||
const errors = [
|
||||
new TaskgraphError('base'),
|
||||
new TaskNotFoundError('id'),
|
||||
new CircularDependencyError([['a', 'b']]),
|
||||
new InvalidInputError('field', 'msg'),
|
||||
new DuplicateNodeError('id'),
|
||||
new DuplicateEdgeError('src', 'tgt'),
|
||||
];
|
||||
|
||||
for (const err of errors) {
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(TaskgraphError);
|
||||
// Verify Object.setPrototypeOf worked correctly
|
||||
expect(Object.getPrototypeOf(err)).not.toBe(Error.prototype);
|
||||
}
|
||||
});
|
||||
|
||||
it('catch clause works correctly for subclasses', () => {
|
||||
const throwTaskNotFound = (): never => {
|
||||
throw new TaskNotFoundError('missing');
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
try {
|
||||
throwTaskNotFound();
|
||||
} catch (e) {
|
||||
// Should be catchable as TaskgraphError
|
||||
if (e instanceof TaskgraphError) {
|
||||
throw e;
|
||||
}
|
||||
throw new Error('unexpected');
|
||||
}
|
||||
}).toThrow(TaskNotFoundError);
|
||||
});
|
||||
|
||||
it('field access works after catching as TaskgraphError', () => {
|
||||
const err: TaskgraphError = new TaskNotFoundError('task-1');
|
||||
// After narrowing via instanceof, fields should be accessible
|
||||
if (err instanceof TaskNotFoundError) {
|
||||
expect(err.taskId).toBe('task-1');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user