feat(graph/subgraph-and-validation): implement subgraph and validation methods

- Add subgraph() method using graphology-operators.subgraph (ADR-007: internal-only edges)
- Add validateSchema() using TypeBox Value.Check/Value.Errors
- Add validateGraph() detecting cycles and dangling references
- Add validate() combining both validations
- Define ValidationError, GraphValidationError, AnyValidationError types in error module
- Add standalone validation functions in src/graph/validation.ts
- Export validation module from src/graph/index.ts
- Add 43 unit tests for subgraph filtering and validation
This commit is contained in:
2026-04-27 12:41:51 +00:00
parent b0d943f4e6
commit c3649256cc
6 changed files with 869 additions and 5 deletions

View File

@@ -84,4 +84,55 @@ export class DuplicateEdgeError extends TaskgraphError {
this.prerequisite = prerequisite;
this.dependent = dependent;
}
}
}
// ---------------------------------------------------------------------------
// 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;

View File

@@ -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<string>();
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);
}
}

View File

@@ -2,4 +2,5 @@
export { TaskGraph, type TaskGraphInner } from './construction.js';
export * from './queries.js';
export * from './mutation.js';
export * from './mutation.js';
export * from './validation.js';

109
src/graph/validation.ts Normal file
View File

@@ -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)];
}