From ce68271f4ff52f011d3b10ebbd024010d390cb54 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Mon, 27 Apr 2026 10:00:40 +0000 Subject: [PATCH] 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 --- src/error/index.ts | 59 +++- tasks/implementation/error/error-hierarchy.md | 21 +- test/error.test.ts | 253 ++++++++++++++++++ 3 files changed, 316 insertions(+), 17 deletions(-) create mode 100644 test/error.test.ts diff --git a/src/error/index.ts b/src/error/index.ts index ecb3d5c..ae8098b 100644 --- a/src/error/index.ts +++ b/src/error/index.ts @@ -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; } } \ No newline at end of file diff --git a/tasks/implementation/error/error-hierarchy.md b/tasks/implementation/error/error-hierarchy.md index 4fb3fb2..cf983b7 100644 --- a/tasks/implementation/error/error-hierarchy.md +++ b/tasks/implementation/error/error-hierarchy.md @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/test/error.test.ts b/test/error.test.ts new file mode 100644 index 0000000..6aa1b17 --- /dev/null +++ b/test/error.test.ts @@ -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'); + } + }); +}); \ No newline at end of file