feat(graph/queries): implement query methods — hasCycles, findCycles, topologicalOrder, dependencies, dependents, taskCount, getTask

- hasCycles(): uses graphology-dag.hasCycle() as fast boolean check
- findCycles(): SCC pre-check + custom 3-color DFS for cycle path extraction
- topologicalOrder(): graphology-dag.topologicalSort + CircularDependencyError on cycle
- dependencies/dependents: inNeighbors/outNeighbors with TaskNotFoundError
- taskCount(): graph.order, getTask(): node attributes or undefined
- 45 unit tests covering all acceptance criteria
This commit is contained in:
2026-04-27 12:00:17 +00:00
parent 9ad0ec902c
commit 98cc05d266
4 changed files with 814 additions and 12 deletions

View File

@@ -2,6 +2,15 @@
import { DirectedGraph } from 'graphology';
import type { TaskGraphNodeAttributes, TaskGraphEdgeAttributes, TaskGraphSerialized } from '../schema/index.js';
import {
hasCycles as _hasCycles,
findCycles as _findCycles,
topologicalOrder as _topologicalOrder,
dependencies as _dependencies,
dependents as _dependents,
taskCount as _taskCount,
getTask as _getTask,
} from './queries.js';
/**
* Internal graph type alias for the graphology DirectedGraph with our attribute types.
@@ -130,4 +139,76 @@ export class TaskGraph {
graph._graph.import(data);
return graph;
}
// ---------------------------------------------------------------------------
// Query methods
// ---------------------------------------------------------------------------
/**
* Check whether the graph contains any cycles.
*
* Uses `graphology-dag.hasCycle()` as a fast boolean check.
*/
hasCycles(): boolean {
return _hasCycles(this._graph);
}
/**
* Find all cycle paths in the graph.
*
* Uses `stronglyConnectedComponents()` as a fast pre-check, then runs a
* custom 3-color DFS (WHITE/GREY/BLACK) to extract cycle paths.
*
* Returns **one representative cycle per back edge**, not an exhaustive
* enumeration of all simple cycles. Each inner array is an ordered node
* sequence where the last node has an edge back to the first:
* `[A, B, C]` means A → B → C → A.
*/
findCycles(): string[][] {
return _findCycles(this._graph);
}
/**
* Return task IDs in topological (prerequisite → dependent) order.
*
* Uses `graphology-dag.topologicalSort()` for the actual sort.
*
* @throws {CircularDependencyError} When the graph is cyclic, with `cycles`
* populated from `findCycles()`.
*/
topologicalOrder(): string[] {
return _topologicalOrder(this._graph);
}
/**
* Return the prerequisite task IDs for a given task.
*
* @throws {TaskNotFoundError} If `taskId` doesn't exist in the graph.
*/
dependencies(taskId: string): string[] {
return _dependencies(this._graph, taskId);
}
/**
* Return the dependent task IDs for a given task.
*
* @throws {TaskNotFoundError} If `taskId` doesn't exist in the graph.
*/
dependents(taskId: string): string[] {
return _dependents(this._graph, taskId);
}
/**
* Return the number of tasks (nodes) in the graph.
*/
taskCount(): number {
return _taskCount(this._graph);
}
/**
* Return the attributes of a task node, or `undefined` if it doesn't exist.
*/
getTask(taskId: string): TaskGraphNodeAttributes | undefined {
return _getTask(this._graph, taskId);
}
}

View File

@@ -1 +1,169 @@
// Graph queries — hasCycles, findCycles, topologicalOrder, dependencies, dependents
// Graph queries — hasCycles, findCycles, topologicalOrder, dependencies, dependents,
// taskCount, getTask
import { hasCycle, topologicalSort } from 'graphology-dag';
import { stronglyConnectedComponents } from 'graphology-components';
import type { TaskGraphInner } from './construction.js';
import type { TaskGraphNodeAttributes } from '../schema/index.js';
import { TaskNotFoundError, CircularDependencyError } from '../error/index.js';
// ---------------------------------------------------------------------------
// 3-color DFS constants for findCycles
// ---------------------------------------------------------------------------
const WHITE = 0; // unvisited
const GREY = 1; // in current recursion stack
const BLACK = 2; // finished — all descendants explored
// ---------------------------------------------------------------------------
// Query functions (operating on the inner graphology graph)
// ---------------------------------------------------------------------------
/**
* Check whether the graph contains any cycles.
*
* Uses `graphology-dag.hasCycle()` as a fast boolean check.
*/
export function hasCycles(graph: TaskGraphInner): boolean {
return hasCycle(graph);
}
/**
* Find all cycle paths in the graph.
*
* **Algorithm**:
* 1. Fast pre-check via `stronglyConnectedComponents()`: if there are zero
* multi-node SCCs (and no self-loops — which our graph config forbids),
* return `[]` immediately.
* 2. Otherwise, run a custom 3-color DFS (WHITE/GREY/BLACK). When a back
* edge is found (GREY → GREY), trace back through the recursion stack to
* extract the cycle path as an **ordered node sequence** where the last
* node has an edge back to the first.
*
* Returns **one representative cycle per back edge**, not an exhaustive
* enumeration of all simple cycles (which could be exponential).
*
* Each inner array is an ordered sequence of node IDs representing a single
* cycle: `[A, B, C]` means A → B → C → A.
*/
export function findCycles(graph: TaskGraphInner): string[][] {
// Fast pre-check: if no multi-node SCCs exist, the graph is acyclic.
// (Self-loops are prohibited by our graph config, so we only check SCCs.)
const sccs = stronglyConnectedComponents(graph);
const hasMultiNodeScc = sccs.some((component) => component.length > 1);
if (!hasMultiNodeScc) {
return [];
}
// 3-color DFS to extract cycle paths
const color = new Map<string, typeof WHITE>();
const stack: string[] = []; // current recursion stack
const cycles: string[][] = [];
for (const node of graph.nodes()) {
color.set(node, WHITE);
}
for (const startNode of graph.nodes()) {
if (color.get(startNode) !== WHITE) continue;
dfs(graph, startNode, color, stack, cycles);
}
return cycles;
}
/**
* Recursive 3-color DFS that detects back edges and extracts cycle paths.
*
* When we encounter a neighbor that is GREY (in the current recursion stack),
* we've found a back edge and can extract the cycle by slicing the recursion
* stack from that neighbor's position to the current position.
*/
function dfs(
graph: TaskGraphInner,
node: string,
color: Map<string, number>,
stack: string[],
cycles: string[][],
): void {
color.set(node, GREY);
stack.push(node);
for (const neighbor of graph.outNeighbors(node)) {
const neighborColor = color.get(neighbor);
if (neighborColor === GREY) {
// Back edge found — extract the cycle path
const cycleStart = stack.indexOf(neighbor);
const cycle = stack.slice(cycleStart);
cycles.push(cycle);
} else if (neighborColor === WHITE) {
dfs(graph, neighbor, color, stack, cycles);
}
// If BLACK, skip — this subtree is already fully explored
}
stack.pop();
color.set(node, BLACK);
}
/**
* Return task IDs in topological (prerequisite → dependent) order.
*
* Uses `graphology-dag.topologicalSort()` for the actual sort.
*
* @throws {CircularDependencyError} When the graph is cyclic, with `cycles`
* populated from `findCycles()`.
*/
export function topologicalOrder(graph: TaskGraphInner): string[] {
try {
return topologicalSort(graph);
} catch {
// Graph is cyclic — throw with cycle information
throw new CircularDependencyError(findCycles(graph));
}
}
/**
* Return the prerequisite task IDs (inNeighbors) for a given task.
*
* @throws {TaskNotFoundError} If `taskId` doesn't exist in the graph.
*/
export function dependencies(graph: TaskGraphInner, taskId: string): string[] {
if (!graph.hasNode(taskId)) {
throw new TaskNotFoundError(taskId);
}
return graph.inNeighbors(taskId);
}
/**
* Return the dependent task IDs (outNeighbors) for a given task.
*
* @throws {TaskNotFoundError} If `taskId` doesn't exist in the graph.
*/
export function dependents(graph: TaskGraphInner, taskId: string): string[] {
if (!graph.hasNode(taskId)) {
throw new TaskNotFoundError(taskId);
}
return graph.outNeighbors(taskId);
}
/**
* Return the number of tasks (nodes) in the graph.
*/
export function taskCount(graph: TaskGraphInner): number {
return graph.order;
}
/**
* Return the attributes of a task node, or `undefined` if it doesn't exist.
*/
export function getTask(
graph: TaskGraphInner,
taskId: string,
): TaskGraphNodeAttributes | undefined {
if (!graph.hasNode(taskId)) {
return undefined;
}
return graph.getNodeAttributes(taskId);
}