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:
2026-04-27 10:00:40 +00:00
parent bd8a7b06d0
commit ce68271f4f
3 changed files with 316 additions and 17 deletions

View File

@@ -1,44 +1,87 @@
// Error classes — TaskgraphError, TaskNotFoundError, CircularDependencyError, // Error classes — TaskgraphError, TaskNotFoundError, CircularDependencyError,
// InvalidInputError, DuplicateNodeError, DuplicateEdgeError // 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 { export class TaskgraphError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
this.name = 'TaskgraphError'; this.name = 'TaskgraphError';
Object.setPrototypeOf(this, new.target.prototype);
} }
} }
export class TaskNotFoundError extends TaskgraphError { export class TaskNotFoundError extends TaskgraphError {
readonly taskId: string;
constructor(taskId: string) { constructor(taskId: string) {
super(`Task not found: ${taskId}`); super(`Task not found: ${taskId}`);
this.name = 'TaskNotFoundError'; this.name = 'TaskNotFoundError';
Object.setPrototypeOf(this, new.target.prototype);
this.taskId = taskId;
} }
} }
export class CircularDependencyError extends TaskgraphError { export class CircularDependencyError extends TaskgraphError {
constructor(cycle: string[]) { readonly cycles: string[][];
super(`Circular dependency detected: ${cycle.join(' → ')}`);
constructor(cycles: string[][]) {
const cycleDescriptions = cycles
.map((cycle) => cycle.join(' → '))
.join('; ');
super(`Circular dependency detected: ${cycleDescriptions}`);
this.name = 'CircularDependencyError'; this.name = 'CircularDependencyError';
Object.setPrototypeOf(this, new.target.prototype);
this.cycles = cycles;
} }
} }
export class InvalidInputError extends TaskgraphError { export class InvalidInputError extends TaskgraphError {
constructor(message: string) { readonly field: string;
super(message); override readonly message: string;
constructor(field: string, message: string) {
super(`Invalid input for field "${field}": ${message}`);
this.name = 'InvalidInputError'; 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 { export class DuplicateNodeError extends TaskgraphError {
constructor(nodeId: string) { readonly taskId: string;
super(`Duplicate node: ${nodeId}`);
constructor(taskId: string) {
super(`Duplicate node: ${taskId}`);
this.name = 'DuplicateNodeError'; this.name = 'DuplicateNodeError';
Object.setPrototypeOf(this, new.target.prototype);
this.taskId = taskId;
} }
} }
export class DuplicateEdgeError extends TaskgraphError { export class DuplicateEdgeError extends TaskgraphError {
constructor(source: string, target: string) { readonly prerequisite: string;
super(`Duplicate edge: ${source}${target}`); readonly dependent: string;
constructor(prerequisite: string, dependent: string) {
super(`Duplicate edge: ${prerequisite}${dependent}`);
this.name = 'DuplicateEdgeError'; this.name = 'DuplicateEdgeError';
Object.setPrototypeOf(this, new.target.prototype);
this.prerequisite = prerequisite;
this.dependent = dependent;
} }
} }

View File

@@ -1,7 +1,7 @@
--- ---
id: error/error-hierarchy id: error/error-hierarchy
name: Implement typed error class hierarchy name: Implement typed error class hierarchy
status: pending status: completed
depends_on: depends_on:
- setup/project-init - setup/project-init
scope: narrow scope: narrow
@@ -16,18 +16,18 @@ Implement the custom error classes in `src/error/index.ts` per [errors-validatio
## Acceptance Criteria ## Acceptance Criteria
- [ ] `src/error/index.ts` exports: - [x] `src/error/index.ts` exports:
- `TaskgraphError extends Error` — base class - `TaskgraphError extends Error` — base class
- `TaskNotFoundError extends TaskgraphError` with `taskId: string` field - `TaskNotFoundError extends TaskgraphError` with `taskId: string` field
- `CircularDependencyError extends TaskgraphError` with `cycles: string[][]` field - `CircularDependencyError extends TaskgraphError` with `cycles: string[][]` field
- `InvalidInputError extends TaskgraphError` with `field: string` and `message: string` fields - `InvalidInputError extends TaskgraphError` with `field: string` and `message: string` fields
- `DuplicateNodeError extends TaskgraphError` with `taskId: string` field - `DuplicateNodeError extends TaskgraphError` with `taskId: string` field
- `DuplicateEdgeError extends TaskgraphError` with `prerequisite: string` and `dependent: string` fields - `DuplicateEdgeError extends TaskgraphError` with `prerequisite: string` and `dependent: string` fields
- [ ] Each error class sets `this.name` to the class name - [x] Each error class sets `this.name` to the class name
- [ ] Each error class properly extends the prototype chain (`Object.setPrototypeOf(this, new.target.prototype)`) - [x] 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) - [x] `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 - [x] `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] Unit tests verifying: correct `instanceof` chain, field access, `.name` property, error messages
## References ## References
@@ -36,8 +36,11 @@ Implement the custom error classes in `src/error/index.ts` per [errors-validatio
## Notes ## 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 ## 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
View 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');
}
});
});