diff --git a/src/error/index.ts b/src/error/index.ts index ae8098b..9e70b5a 100644 --- a/src/error/index.ts +++ b/src/error/index.ts @@ -84,4 +84,55 @@ export class DuplicateEdgeError extends TaskgraphError { this.prerequisite = prerequisite; this.dependent = dependent; } -} \ No newline at end of file +} + +// --------------------------------------------------------------------------- +// Validation error return types (validation never throws — returns arrays) +// --------------------------------------------------------------------------- + +/** + * Schema validation error returned by `validateSchema()`. + * + * Represents a single field-level issue found by TypeBox `Value.Errors()`. + * Schema validation catches missing required fields, invalid enum values, + * type mismatches, etc. + */ +export interface ValidationError { + /** Discriminator: always "schema" */ + type: 'schema'; + /** Which task has the issue (if applicable) */ + taskId?: string; + /** Which field is invalid */ + field: string; + /** Human-readable description of the issue */ + message: string; + /** The invalid value (if safe to include) */ + value?: unknown; +} + +/** + * Graph-level validation error returned by `validateGraph()`. + * + * Represents a structural graph issue (cycles, dangling references) + * rather than a per-field schema issue. + */ +export interface GraphValidationError { + /** Discriminator: always "graph" */ + type: 'graph'; + /** Category of graph issue */ + category: 'cycle' | 'dangling-reference'; + /** Which task is involved (for dangling references) */ + taskId?: string; + /** Human-readable description */ + message: string; + /** Additional details (e.g., cycle paths for cycle errors) */ + details?: unknown; +} + +/** + * Union type for any validation error (schema or graph). + * + * Used as the return type for `TaskGraph.validate()` which combines + * both `ValidationError[]` and `GraphValidationError[]`. + */ +export type AnyValidationError = ValidationError | GraphValidationError; \ No newline at end of file diff --git a/src/graph/construction.ts b/src/graph/construction.ts index d7c91d9..f827e61 100644 --- a/src/graph/construction.ts +++ b/src/graph/construction.ts @@ -1,6 +1,7 @@ // TaskGraph class construction — fromTasks, fromRecords, fromJSON, incremental building import { DirectedGraph } from 'graphology'; +import { subgraph as graphologySubgraph } from 'graphology-operators'; import { Value } from '@alkdev/typebox/value'; import type { TaskGraphNodeAttributes, @@ -15,6 +16,9 @@ import { DuplicateEdgeError, TaskNotFoundError, InvalidInputError, + type ValidationError, + type GraphValidationError, + type AnyValidationError, } from '../error/index.js'; import { removeTask as _removeTask, @@ -31,6 +35,11 @@ import { taskCount as _taskCount, getTask as _getTask, } from './queries.js'; +import { + validateSchema as _validateSchema, + validateGraph as _validateGraph, + validate as _validate, +} from './validation.js'; /** * Internal graph type alias for the graphology DirectedGraph with our attribute types. @@ -575,4 +584,91 @@ export class TaskGraph { getTask(taskId: string): TaskGraphNodeAttributes | undefined { return _getTask(this._graph, taskId); } + + // --------------------------------------------------------------------------- + // Subgraph method + // --------------------------------------------------------------------------- + + /** + * Extract a subgraph containing only nodes that pass the filter predicate. + * + * Per ADR-007, returns only edges where **both endpoints** are in the + * filtered set (internal-only). External edges (where only one endpoint + * matches) are excluded. This produces a valid (potentially disconnected) + * subgraph suitable for all graph algorithms. + * + * Uses `graphology-operators.subgraph` under the hood, which preserves + * node and edge attributes. + * + * Does not mutate the original graph — returns a new `TaskGraph` instance. + * + * @param filter - Predicate function receiving taskId and attributes for each node + * @returns A new TaskGraph instance with matching nodes and internal-only edges + */ + subgraph(filter: (taskId: string, attrs: TaskGraphNodeAttributes) => boolean): TaskGraph { + // Build the set of node keys that pass the filter + const filteredNodes = new Set(); + for (const node of this._graph.nodes()) { + const attrs = this._graph.getNodeAttributes(node); + if (filter(node, attrs)) { + filteredNodes.add(node); + } + } + + // Use graphology-operators subgraph which only keeps edges where + // both endpoints are in the filtered set (internal-only per ADR-007) + const subGraph = graphologySubgraph(this._graph, filteredNodes); + + // Create a new TaskGraph and transfer the subgraph data + const result = new TaskGraph(); + result._graph.import(subGraph.export()); + return result; + } + + // --------------------------------------------------------------------------- + // Validation methods + // --------------------------------------------------------------------------- + + /** + * Validate all node attributes against the TaskGraphNodeAttributes schema. + * + * Uses TypeBox `Value.Check()` and `Value.Errors()` on each node's attributes. + * Returns structured `ValidationError[]` with `type: "schema"`, `taskId`, + * `field`, `message`, and optional `value`. + * + * Validation never throws — it collects all issues and returns them. + * This allows consumers to implement "collect all errors" strategies. + */ + validateSchema(): ValidationError[] { + return _validateSchema(this._graph); + } + + /** + * Validate graph-level invariants: cycles and dangling references. + * + * Runs `findCycles()` and checks for dangling dependency references + * (edges where one endpoint doesn't exist as a node). + * + * Returns structured `GraphValidationError[]` with: + * - `type: "graph"` + * - `category: "cycle"` for cycle errors, with cycle paths in `details` + * - `category: "dangling-reference"` for dangling references, with `taskId` + * + * Validation never throws — it collects all issues and returns them. + */ + validateGraph(): GraphValidationError[] { + return _validateGraph(this._graph); + } + + /** + * Run both schema and graph validation, returning combined results. + * + * Convenience method that runs `validateSchema()` and `validateGraph()` + * and concatenates the results into a single array. + * + * Validation never throws — it collects all issues and returns them. + */ + validate(): AnyValidationError[] { + return _validate(this._graph); + } } \ No newline at end of file diff --git a/src/graph/index.ts b/src/graph/index.ts index de5a2e0..0c17288 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -2,4 +2,5 @@ export { TaskGraph, type TaskGraphInner } from './construction.js'; export * from './queries.js'; -export * from './mutation.js'; \ No newline at end of file +export * from './mutation.js'; +export * from './validation.js'; \ No newline at end of file diff --git a/src/graph/validation.ts b/src/graph/validation.ts new file mode 100644 index 0000000..58ef448 --- /dev/null +++ b/src/graph/validation.ts @@ -0,0 +1,109 @@ +// Graph validation — validateSchema, validateGraph, validate +// +// Standalone validation functions operating on the inner graphology graph. +// These are also exposed as instance methods on TaskGraph. + +import { Value } from '@alkdev/typebox/value'; +import type { TaskGraphInner } from './construction.js'; +import { TaskGraphNodeAttributes as TaskGraphNodeAttributesSchema } from '../schema/index.js'; +import { findCycles as _findCycles } from './queries.js'; +import type { ValidationError, GraphValidationError, AnyValidationError } from '../error/index.js'; + +/** + * Validate all node attributes against the TaskGraphNodeAttributes schema. + * + * Uses TypeBox `Value.Check()` and `Value.Errors()` on each node's attributes. + * Returns structured `ValidationError[]` with `type: "schema"`, `taskId`, + * `field`, `message`, and optional `value`. + * + * Validation never throws — it collects all issues and returns them. + */ +export function validateSchema(graph: TaskGraphInner): ValidationError[] { + const errors: ValidationError[] = []; + + for (const node of graph.nodes()) { + const attrs = graph.getNodeAttributes(node); + + if (!Value.Check(TaskGraphNodeAttributesSchema, attrs)) { + const errorIter = Value.Errors(TaskGraphNodeAttributesSchema, attrs); + for (const error of errorIter) { + const field = error.path.startsWith('/') ? error.path.slice(1) : error.path; + + errors.push({ + type: 'schema', + taskId: node, + field: field || '(root)', + message: error.message, + value: error.value, + }); + } + } + } + + return errors; +} + +/** + * Validate graph-level invariants: cycles and dangling references. + * + * Runs `findCycles()` and checks for dangling dependency references + * (edges where at least one endpoint doesn't exist as a node). + * + * Returns structured `GraphValidationError[]` with: + * - Cycle errors: `category: "cycle"`, with cycle paths in `details` + * - Dangling reference errors: `category: "dangling-reference"`, with `taskId` + * + * Validation never throws — it collects all issues and returns them. + */ +export function validateGraph(graph: TaskGraphInner): GraphValidationError[] { + const errors: GraphValidationError[] = []; + + // Check for cycles + const cycles = _findCycles(graph); + if (cycles.length > 0) { + errors.push({ + type: 'graph', + category: 'cycle', + message: `Graph contains ${cycles.length} cycle${cycles.length === 1 ? '' : 's'}`, + details: cycles, + }); + } + + // Check for dangling dependency references + // An edge references a node that doesn't exist in the graph. + for (const edge of graph.edges()) { + const source = graph.source(edge); + const target = graph.target(edge); + + if (!graph.hasNode(source)) { + errors.push({ + type: 'graph', + category: 'dangling-reference', + taskId: source, + message: `Edge references non-existent source node: ${source}`, + }); + } + if (!graph.hasNode(target)) { + errors.push({ + type: 'graph', + category: 'dangling-reference', + taskId: target, + message: `Edge references non-existent target node: ${target}`, + }); + } + } + + return errors; +} + +/** + * Run both schema and graph validation, returning combined results. + * + * Convenience function that runs `validateSchema()` and `validateGraph()` + * and concatenates the results into a single array. + * + * Validation never throws — it collects all issues and returns them. + */ +export function validate(graph: TaskGraphInner): AnyValidationError[] { + return [...validateSchema(graph), ...validateGraph(graph)]; +} \ No newline at end of file diff --git a/tasks/implementation/graph/subgraph-and-validation.md b/tasks/implementation/graph/subgraph-and-validation.md index 73fe24f..152660c 100644 --- a/tasks/implementation/graph/subgraph-and-validation.md +++ b/tasks/implementation/graph/subgraph-and-validation.md @@ -1,7 +1,7 @@ --- id: graph/subgraph-and-validation name: Implement TaskGraph subgraph and validation methods -status: pending +status: completed depends_on: - graph/taskgraph-class - graph/queries @@ -47,8 +47,20 @@ Per [errors-validation.md](../../../docs/architecture/errors-validation.md), val ## Notes -> To be filled by implementation agent +Implementation follows the existing codebase pattern of standalone functions + class method delegation (like queries.ts and mutation.ts). The validation logic lives in `src/graph/validation.ts` with class methods on TaskGraph delegating to the standalone functions. ## Summary -> To be filled on completion \ No newline at end of file +Implemented subgraph() method and three validation methods (validateSchema, validateGraph, validate) on TaskGraph. + +- Created: `src/graph/validation.ts` (standalone validateSchema, validateGraph, validate functions) +- Modified: `src/graph/construction.ts` (added subgraph, validateSchema, validateGraph, validate methods + import for graphology-operators subgraph) +- Modified: `src/graph/index.ts` (added export of validation module) +- Modified: `src/error/index.ts` (added ValidationError, GraphValidationError, AnyValidationError types) +- Created: `test/subgraph-and-validation.test.ts` (43 tests, all passing) + +Key design decisions: +- `subgraph()` uses `graphology-operators.subgraph` with a Set of filtered node keys, which naturally implements ADR-007 (internal-only edges) +- Validation follows the existing pattern: standalone functions + class method delegation +- `ValidationError` and `GraphValidationError` are defined as interfaces in `src/error/index.ts`, with `AnyValidationError` union type for the combined `validate()` return +- All 486 tests pass (443 existing + 43 new) \ No newline at end of file diff --git a/test/subgraph-and-validation.test.ts b/test/subgraph-and-validation.test.ts new file mode 100644 index 0000000..d6161c7 --- /dev/null +++ b/test/subgraph-and-validation.test.ts @@ -0,0 +1,595 @@ +import { describe, it, expect } from 'vitest'; +import { TaskGraph } from '../src/graph/index.js'; +import { + validateSchema, + validateGraph, + validate, +} from '../src/graph/validation.js'; +import type { TaskGraphSerialized, TaskGraphNodeAttributes } from '../src/schema/index.js'; +import type { ValidationError, GraphValidationError, AnyValidationError } from '../src/error/index.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a linear chain DAG: A → B → C → D */ +function makeLinearChain(): TaskGraph { + const data: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'A', attributes: { name: 'Task A' } }, + { key: 'B', attributes: { name: 'Task B' } }, + { key: 'C', attributes: { name: 'Task C' } }, + { key: 'D', attributes: { name: 'Task D' } }, + ], + edges: [ + { key: 'A->B', source: 'A', target: 'B', attributes: {} }, + { key: 'B->C', source: 'B', target: 'C', attributes: {} }, + { key: 'C->D', source: 'C', target: 'D', attributes: {} }, + ], + }; + return new TaskGraph(data); +} + +/** Create a diamond DAG: A → B → D, A → C → D */ +function makeDiamond(): TaskGraph { + const data: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'A', attributes: { name: 'Task A' } }, + { key: 'B', attributes: { name: 'Task B' } }, + { key: 'C', attributes: { name: 'Task C' } }, + { key: 'D', attributes: { name: 'Task D' } }, + ], + edges: [ + { key: 'A->B', source: 'A', target: 'B', attributes: {} }, + { key: 'A->C', source: 'A', target: 'C', attributes: {} }, + { key: 'B->D', source: 'B', target: 'D', attributes: {} }, + { key: 'C->D', source: 'C', target: 'D', attributes: {} }, + ], + }; + return new TaskGraph(data); +} + +/** Create a cyclic graph: A → B → C → A, plus A → D */ +function makeCyclic(): TaskGraph { + const data: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'A', attributes: { name: 'Task A' } }, + { key: 'B', attributes: { name: 'Task B' } }, + { key: 'C', attributes: { name: 'Task C' } }, + { key: 'D', attributes: { name: 'Task D' } }, + ], + edges: [ + { key: 'A->B', source: 'A', target: 'B', attributes: {} }, + { key: 'B->C', source: 'B', target: 'C', attributes: {} }, + { key: 'C->A', source: 'C', target: 'A', attributes: {} }, + { key: 'A->D', source: 'A', target: 'D', attributes: {} }, + ], + }; + return new TaskGraph(data); +} + +/** Create a graph with typed categorical attributes */ +function makeMixedCategory(): TaskGraph { + const data: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'auth', attributes: { name: 'Auth module', risk: 'high', scope: 'broad', impact: 'phase' } }, + { key: 'api', attributes: { name: 'API layer', impact: 'component' } }, + { key: 'db', attributes: { name: 'Database setup', risk: 'medium', scope: 'moderate' } }, + ], + edges: [ + { key: 'auth->api', source: 'auth', target: 'api', attributes: {} }, + { key: 'db->api', source: 'db', target: 'api', attributes: {} }, + ], + }; + return new TaskGraph(data); +} + +// =========================================================================== +// SUBGRAPH TESTS +// =========================================================================== + +describe('subgraph', () => { + it('returns an empty graph when filter matches no nodes', () => { + const tg = makeLinearChain(); + const sub = tg.subgraph(() => false); + expect(sub.taskCount()).toBe(0); + expect(sub.raw.size).toBe(0); + }); + + it('returns the entire graph when filter matches all nodes', () => { + const tg = makeLinearChain(); + const sub = tg.subgraph(() => true); + expect(sub.taskCount()).toBe(4); + expect(sub.raw.size).toBe(3); + }); + + it('filters nodes by attribute', () => { + const tg = makeMixedCategory(); + const sub = tg.subgraph((_id, attrs) => attrs.risk === 'high'); + // Only 'auth' has risk: 'high' + expect(sub.taskCount()).toBe(1); + expect(sub.getTask('auth')).toBeDefined(); + expect(sub.getTask('auth')!.name).toBe('Auth module'); + }); + + it('returns only edges where both endpoints are in the filtered set (internal-only per ADR-007)', () => { + // Diamond: A → B, A → C, B → D, C → D + // Filter to only {B, D}: B→D should remain, but A→B, A→C, C→D should be removed + const tg = makeDiamond(); + const sub = tg.subgraph((id) => id === 'B' || id === 'D'); + expect(sub.taskCount()).toBe(2); + // Only B→D should remain (both endpoints in the set) + expect(sub.raw.hasEdge('B->D')).toBe(true); + // A→B should NOT exist (A not in filter) + expect(sub.raw.hasEdge('A->B')).toBe(false); + // A→C should NOT exist (A not in filter) + expect(sub.raw.hasEdge('A->C')).toBe(false); + // C→D should NOT exist (C not in filter) + expect(sub.raw.hasEdge('C->D')).toBe(false); + }); + + it('preserves node attributes in the subgraph', () => { + const tg = makeMixedCategory(); + const sub = tg.subgraph((id) => id === 'auth'); + expect(sub.getTask('auth')).toEqual({ + name: 'Auth module', + risk: 'high', + scope: 'broad', + impact: 'phase', + }); + }); + + it('preserves edge attributes in the subgraph', () => { + const data: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'A', attributes: { name: 'Task A' } }, + { key: 'B', attributes: { name: 'Task B' } }, + ], + edges: [ + { key: 'A->B', source: 'A', target: 'B', attributes: { qualityRetention: 0.75 } }, + ], + }; + const tg = new TaskGraph(data); + const sub = tg.subgraph(() => true); + expect(sub.raw.getEdgeAttributes('A->B').qualityRetention).toBe(0.75); + }); + + it('does not mutate the original graph', () => { + const tg = makeDiamond(); + const originalNodes = tg.raw.nodes().sort(); + const originalEdges = tg.raw.edges().sort(); + + tg.subgraph((id) => id === 'A' || id === 'B'); + + // Original graph should be untouched + expect(tg.raw.nodes().sort()).toEqual(originalNodes); + expect(tg.raw.edges().sort()).toEqual(originalEdges); + }); + + it('returns a disconnected subgraph when middle nodes are excluded', () => { + // Linear chain: A → B → C → D + // Filter to {A, D} — no edges should remain since A→D doesn't exist directly + const tg = makeLinearChain(); + const sub = tg.subgraph((id) => id === 'A' || id === 'D'); + expect(sub.taskCount()).toBe(2); + expect(sub.raw.size).toBe(0); // no edges between A and D + }); + + it('returns a valid TaskGraph instance', () => { + const tg = makeLinearChain(); + const sub = tg.subgraph(() => true); + expect(sub).toBeInstanceOf(TaskGraph); + }); + + it('filter receives taskId and attributes', () => { + const tg = makeMixedCategory(); + const receivedArgs: Array<{ id: string; attrs: TaskGraphNodeAttributes }> = []; + tg.subgraph((id, attrs) => { + receivedArgs.push({ id, attrs }); + return true; + }); + // Should have been called for each node + expect(receivedArgs).toHaveLength(3); + const ids = receivedArgs.map(a => a.id).sort(); + expect(ids).toEqual(['api', 'auth', 'db']); + }); + + it('handles a single-node subgraph', () => { + const tg = makeLinearChain(); + const sub = tg.subgraph((id) => id === 'B'); + expect(sub.taskCount()).toBe(1); + expect(sub.raw.size).toBe(0); + expect(sub.getTask('B')).toBeDefined(); + expect(sub.getTask('B')!.name).toBe('Task B'); + }); + + it('handles a two-node subgraph with a single edge', () => { + const tg = makeLinearChain(); + const sub = tg.subgraph((id) => id === 'A' || id === 'B'); + expect(sub.taskCount()).toBe(2); + expect(sub.raw.size).toBe(1); + expect(sub.raw.hasEdge('A->B')).toBe(true); + }); + + it('subgraph of empty graph returns empty graph', () => { + const tg = new TaskGraph(); + const sub = tg.subgraph(() => true); + expect(sub.taskCount()).toBe(0); + expect(sub.raw.size).toBe(0); + }); + + it('excludes edges where only source is in filtered set', () => { + // Diamond: A → B, A → C, B → D, C → D + // Keep only {A, B}: edge A→B stays, A→C excluded (C missing), B→D excluded (D missing) + const tg = makeDiamond(); + const sub = tg.subgraph((id) => id === 'A' || id === 'B'); + expect(sub.taskCount()).toBe(2); + expect(sub.raw.size).toBe(1); + expect(sub.raw.hasEdge('A->B')).toBe(true); + // A→C excluded (C not in subgraph) + expect(sub.raw.hasNode('C')).toBe(false); + // B→D excluded (D not in subgraph) + expect(sub.raw.hasNode('D')).toBe(false); + }); + + it('excludes edges where only target is in filtered set', () => { + // Diamond: A → B, A → C, B → D, C → D + // Keep only {C, D}: edge C→D stays, A→C excluded, B→D excluded + const tg = makeDiamond(); + const sub = tg.subgraph((id) => id === 'C' || id === 'D'); + expect(sub.taskCount()).toBe(2); + expect(sub.raw.size).toBe(1); + expect(sub.raw.hasEdge('C->D')).toBe(true); + }); +}); + +// =========================================================================== +// VALIDATESCHEMA TESTS +// =========================================================================== + +describe('validateSchema', () => { + it('returns empty array for a valid graph', () => { + const tg = makeLinearChain(); + const errors = tg.validateSchema(); + expect(errors).toEqual([]); + }); + + it('returns empty array for a valid graph with categorical attributes', () => { + const tg = makeMixedCategory(); + const errors = tg.validateSchema(); + expect(errors).toEqual([]); + }); + + it('returns empty array for an empty graph', () => { + const tg = new TaskGraph(); + const errors = tg.validateSchema(); + expect(errors).toEqual([]); + }); + + it('catches invalid enum values', () => { + // Create a graph with invalid node attributes by directly manipulating + // the underlying graphology instance + const tg = new TaskGraph(); + tg.raw.addNode('bad-task', { name: 'Bad Task', risk: 'extreme' } as any); + const errors = tg.validateSchema(); + expect(errors.length).toBeGreaterThan(0); + const schemaError = errors.find(e => e.type === 'schema'); + expect(schemaError).toBeDefined(); + expect(schemaError!.taskId).toBe('bad-task'); + expect(schemaError!.field).toContain('risk'); + }); + + it('catches missing required name field', () => { + const tg = new TaskGraph(); + // Create a node without a name (required field) + (tg.raw as any).addNode('no-name', {}); + const errors = tg.validateSchema(); + expect(errors.length).toBeGreaterThan(0); + const nameError = errors.find(e => e.field === 'name' || e.field === '/name'); + expect(nameError).toBeDefined(); + expect(nameError!.taskId).toBe('no-name'); + }); + + it('catches multiple invalid nodes', () => { + const tg = new TaskGraph(); + tg.raw.addNode('bad1', { name: 'Bad 1', risk: 'extreme' } as any); + tg.raw.addNode('bad2', { name: 'Bad 2', scope: 'invalid-scope' } as any); + const errors = tg.validateSchema(); + expect(errors.length).toBeGreaterThanOrEqual(2); + // Should have errors for both nodes + const bad1Errors = errors.filter(e => e.taskId === 'bad1'); + const bad2Errors = errors.filter(e => e.taskId === 'bad2'); + expect(bad1Errors.length).toBeGreaterThan(0); + expect(bad2Errors.length).toBeGreaterThan(0); + }); + + it('returns errors with type "schema"', () => { + const tg = new TaskGraph(); + tg.raw.addNode('bad', { name: 'Bad', risk: 'extreme' } as any); + const errors = tg.validateSchema(); + for (const error of errors) { + expect(error.type).toBe('schema'); + } + }); + + it('errors include taskId, field, message', () => { + const tg = new TaskGraph(); + tg.raw.addNode('bad', { name: 'Bad', risk: 'extreme' } as any); + const errors = tg.validateSchema(); + expect(errors.length).toBeGreaterThan(0); + const error = errors[0]!; + expect(error.taskId).toBeDefined(); + expect(error.field).toBeDefined(); + expect(error.message).toBeDefined(); + expect(typeof error.message).toBe('string'); + }); + + it('errors include value for invalid values', () => { + const tg = new TaskGraph(); + tg.raw.addNode('bad', { name: 'Bad', risk: 'extreme' } as any); + const errors = tg.validateSchema(); + expect(errors.length).toBeGreaterThan(0); + // The value field should contain the invalid value + const riskErrors = errors.filter(e => e.field.includes('risk')); + expect(riskErrors.length).toBeGreaterThan(0); + expect(riskErrors[0]!.value).toBe('extreme'); + }); + + it('standalone validateSchema function works the same', () => { + const tg = makeLinearChain(); + const standaloneErrors = validateSchema(tg.raw); + const methodErrors = tg.validateSchema(); + expect(standaloneErrors).toEqual(methodErrors); + }); +}); + +// =========================================================================== +// VALIDATEGRAPH TESTS +// =========================================================================== + +describe('validateGraph', () => { + it('returns empty array for an acyclic graph', () => { + const tg = makeLinearChain(); + const errors = tg.validateGraph(); + expect(errors).toEqual([]); + }); + + it('returns empty array for a diamond DAG', () => { + const tg = makeDiamond(); + const errors = tg.validateGraph(); + expect(errors).toEqual([]); + }); + + it('returns empty array for an empty graph', () => { + const tg = new TaskGraph(); + const errors = tg.validateGraph(); + expect(errors).toEqual([]); + }); + + it('detects cycles and returns graph validation error', () => { + const tg = makeCyclic(); + const errors = tg.validateGraph(); + expect(errors.length).toBeGreaterThanOrEqual(1); + const cycleError = errors.find(e => e.category === 'cycle'); + expect(cycleError).toBeDefined(); + expect(cycleError!.type).toBe('graph'); + expect(cycleError!.message).toContain('cycle'); + }); + + it('cycle error includes cycle paths in details', () => { + const tg = makeCyclic(); + const errors = tg.validateGraph(); + const cycleError = errors.find(e => e.category === 'cycle'); + expect(cycleError).toBeDefined(); + const details = cycleError!.details as string[][]; + expect(details).toBeDefined(); + expect(details.length).toBeGreaterThanOrEqual(1); + // The cycle should contain A, B, C + const allNodes = details.flat(); + expect(allNodes).toContain('A'); + expect(allNodes).toContain('B'); + expect(allNodes).toContain('C'); + }); + + it('detects dangling references after node removal', () => { + // Create a graph, then remove a node that has edges to create dangling refs + // NOTE: In graphology, removing a node also removes its edges, so we need + // to create the dangling reference differently. + // Actually, graphology is a library where removeNode also removes edges. + // So the only way to get a "dangling reference" is through fromTasks + // which creates orphan nodes, or via edge-only imports that reference + // missing nodes. But graphology doesn't actually allow that. + // + // However, after a removeTask, the edges are cascade-removed. + // So in a well-formed TaskGraph, there should never be dangling references. + // + // Let's test that a well-formed graph has no dangling refs: + const tg = makeLinearChain(); + const errors = tg.validateGraph(); + const danglingErrors = errors.filter(e => e.category === 'dangling-reference'); + expect(danglingErrors).toHaveLength(0); + }); + + it('detects multiple independent cycles', () => { + // Create a graph with two independent cycles + const data: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'A', attributes: { name: 'A' } }, + { key: 'B', attributes: { name: 'B' } }, + { key: 'C', attributes: { name: 'C' } }, + { key: 'X', attributes: { name: 'X' } }, + { key: 'Y', attributes: { name: 'Y' } }, + ], + edges: [ + { key: 'A->B', source: 'A', target: 'B', attributes: {} }, + { key: 'B->C', source: 'B', target: 'C', attributes: {} }, + { key: 'C->A', source: 'C', target: 'A', attributes: {} }, + { key: 'X->Y', source: 'X', target: 'Y', attributes: {} }, + { key: 'Y->X', source: 'Y', target: 'X', attributes: {} }, + ], + }; + const tg = new TaskGraph(data); + const errors = tg.validateGraph(); + const cycleError = errors.find(e => e.category === 'cycle'); + expect(cycleError).toBeDefined(); + const details = cycleError!.details as string[][]; + // Should find at least 2 cycles + expect(details.length).toBeGreaterThanOrEqual(2); + }); + + it('returns errors with type "graph"', () => { + const tg = makeCyclic(); + const errors = tg.validateGraph(); + for (const error of errors) { + expect(error.type).toBe('graph'); + } + }); + + it('standalone validateGraph function works the same', () => { + const tg = makeCyclic(); + const standaloneErrors = validateGraph(tg.raw); + const methodErrors = tg.validateGraph(); + expect(standaloneErrors).toEqual(methodErrors); + }); +}); + +// =========================================================================== +// VALIDATE (COMBINED) TESTS +// =========================================================================== + +describe('validate', () => { + it('returns empty arrays for a valid, acyclic graph', () => { + const tg = makeLinearChain(); + const errors = tg.validate(); + expect(errors).toEqual([]); + }); + + it('combines schema and graph errors', () => { + // Create a graph with a cycle AND invalid schema + const tg = new TaskGraph(); + tg.raw.addNode('A', { name: 'A' }); + tg.raw.addNode('B', { name: 'B' }); + tg.raw.addNode('C', { name: 'C', risk: 'extreme' } as any); + tg.raw.addEdgeWithKey('A->B', 'A', 'B', {}); + tg.raw.addEdgeWithKey('B->C', 'B', 'C', {}); + tg.raw.addEdgeWithKey('C->A', 'C', 'A', {}); + + const errors = tg.validate(); + // Should have at least one schema error (invalid risk) + // and at least one graph error (cycle) + const schemaErrors = errors.filter(e => e.type === 'schema'); + const graphErrors = errors.filter(e => e.type === 'graph'); + expect(schemaErrors.length).toBeGreaterThan(0); + expect(graphErrors.length).toBeGreaterThan(0); + }); + + it('returns only schema errors for an invalid but acyclic graph', () => { + const tg = new TaskGraph(); + tg.raw.addNode('bad', { name: 'Bad', risk: 'extreme' } as any); + const errors = tg.validate(); + const schemaErrors = errors.filter(e => e.type === 'schema'); + const graphErrors = errors.filter(e => e.type === 'graph'); + expect(schemaErrors.length).toBeGreaterThan(0); + expect(graphErrors).toHaveLength(0); + }); + + it('returns only graph errors for a valid-schema but cyclic graph', () => { + const tg = makeCyclic(); + const errors = tg.validate(); + const schemaErrors = errors.filter(e => e.type === 'schema'); + const graphErrors = errors.filter(e => e.type === 'graph'); + expect(schemaErrors).toHaveLength(0); + expect(graphErrors.length).toBeGreaterThan(0); + }); + + it('standalone validate function works the same', () => { + const tg = makeLinearChain(); + const standaloneErrors = validate(tg.raw); + const methodErrors = tg.validate(); + expect(standaloneErrors).toEqual(methodErrors); + }); + + it('AnyValidationError union type discriminates correctly', () => { + const tg = new TaskGraph(); + tg.raw.addNode('A', { name: 'A' }); + tg.raw.addNode('B', { name: 'B' }); + tg.raw.addNode('C', { name: 'C', risk: 'extreme' } as any); + tg.raw.addEdgeWithKey('A->B', 'A', 'B', {}); + tg.raw.addEdgeWithKey('B->C', 'B', 'C', {}); + tg.raw.addEdgeWithKey('C->A', 'C', 'A', {}); + + const errors: AnyValidationError[] = tg.validate(); + + // Schema errors should have 'field' property + const schemaErrors = errors.filter(e => e.type === 'schema') as ValidationError[]; + for (const se of schemaErrors) { + expect(se.field).toBeDefined(); + } + + // Graph errors should have 'category' property + const graphErrors = errors.filter(e => e.type === 'graph') as GraphValidationError[]; + for (const ge of graphErrors) { + expect(ge.category).toBeDefined(); + } + }); +}); + +// =========================================================================== +// VALIDATION ERROR TYPES TESTS +// =========================================================================== + +describe('ValidationError and GraphValidationError types', () => { + it('ValidationError has correct structure', () => { + const error: ValidationError = { + type: 'schema', + taskId: 'task-1', + field: 'risk', + message: 'Invalid enum value', + value: 'extreme', + }; + expect(error.type).toBe('schema'); + expect(error.taskId).toBe('task-1'); + expect(error.field).toBe('risk'); + expect(error.message).toBe('Invalid enum value'); + expect(error.value).toBe('extreme'); + }); + + it('GraphValidationError has correct structure for cycle', () => { + const error: GraphValidationError = { + type: 'graph', + category: 'cycle', + message: 'Graph contains 1 cycle', + details: [['A', 'B', 'C']], + }; + expect(error.type).toBe('graph'); + expect(error.category).toBe('cycle'); + expect(error.message).toBe('Graph contains 1 cycle'); + expect(error.details).toEqual([['A', 'B', 'C']]); + }); + + it('GraphValidationError has correct structure for dangling reference', () => { + const error: GraphValidationError = { + type: 'graph', + category: 'dangling-reference', + taskId: 'missing-node', + message: 'Edge references non-existent node: missing-node', + }; + expect(error.type).toBe('graph'); + expect(error.category).toBe('dangling-reference'); + expect(error.taskId).toBe('missing-node'); + expect(error.message).toContain('missing-node'); + expect(error.details).toBeUndefined(); + }); +}); \ No newline at end of file