Merge graph/subgraph-and-validation: subgraph and validation methods, 43 tests
This commit is contained in:
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
109
src/graph/validation.ts
Normal 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)];
|
||||
}
|
||||
Reference in New Issue
Block a user