Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config

## Packages

Packages are listed here as they are added, each linking to its own README.
| Package | What it is |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [`graph-dag`](packages/core/graph/dag/README.md) | Model-independent dependency-graph primitives over a node set and a `needs` accessor: layers, ancestors, descendants, cycles, transitive reduction, critical path. |

## Conventions

Expand Down
7 changes: 6 additions & 1 deletion knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ const config: KnipConfig = {
workspaces: {
".": {
entry: ["*.config.ts"],
project: ["*.config.ts", "commit-types.ts", "eslint.shared.ts"],
project: [
"*.config.ts",
"commit-types.ts",
"eslint.shared.ts",
"stryker.shared.ts",
],
},
"packages/core/*/*": {
entry: ["src/index.{ts,tsx}"],
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@semantic-release/github": "12.0.10",
"@semantic-release/npm": "13.2.0",
"@semantic-release/release-notes-generator": "14.1.1",
"@stryker-mutator/api": "10.0.0",
"@types/node": "26.6.4",
"conventional-changelog-conventionalcommits": "9.3.1",
"eslint": "10.12.0",
Expand All @@ -70,6 +71,6 @@
"turbo": "2.11.7",
"typescript": "6.0.3",
"typescript-eslint": "8.71.0",
"vitest": "5.0.3"
"vitest": "4.1.11"
}
}
1 change: 1 addition & 0 deletions packages/core/graph/dag/AGENTS.md
1 change: 1 addition & 0 deletions packages/core/graph/dag/CLAUDE.md
78 changes: 78 additions & 0 deletions packages/core/graph/dag/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# @exadev/process-graph-dag

Dependency-graph primitives over any node set and a `needs` accessor: topological layers, ancestors, descendants, cycle detection, transitive reduction and the critical path. No runtime dependencies, no knowledge of any process model, and no recursion, so a deep graph cannot overflow the call stack. `src/` imports nothing Node-only and runs unchanged in a browser or a Worker.

A graph is built once from the data you already have, then analysed with plain functions:

```ts
import {
buildDag,
criticalPath,
findCycles,
topologicalLayers,
} from "@exadev/process-graph-dag";

const jobs: Record<string, string[]> = {
build: [],
lint: [],
test: ["build"],
release: ["test", "lint"],
};

const dag = buildDag(Object.keys(jobs), (id) => jobs[id] ?? []);

topologicalLayers(dag).layers; // [["build", "lint"], ["test"], ["release"]]
findCycles(dag); // []
criticalPath(dag, () => 1); // { path: ["build", "test", "release"], weight: 3 }
```

## Model

Ids are `string | number`, so two ids name the same node exactly when they are strictly equal. `needs(id)` lists the nodes that must come before `id`. Every ordering this package produces derives from the order `nodes` was iterated in, so results are deterministic and stable.

Nothing is absorbed silently:

- **Unknown references.** A `needs` target that is not a node is not an edge. It is returned as data in `dag.unknownReferences`, one `{ from, missing }` per distinct pair.
- **Cycles.** `buildDag` never throws for a cycle. `findCycles` reports them with their paths, and each analysis states below what it does when one exists.
- **Duplicates.** Repeated nodes and repeated needs collapse to their first occurrence. A node that needs itself keeps that edge and is a cycle of one.

## API

| Function | Returns | With a cycle |
| --------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `buildDag(nodes, needs)` | An immutable `Dag`: nodes, needs, dependents, unknown references | Builds normally. |
| `topologicalLayers(dag)` | `levels`, `layers`, `unlayered` | Layers the acyclic part; nodes on a cycle or needing one are returned in `unlayered`. |
| `ancestorsOf(dag, id)` | Transitive needs, in node order | Includes `id` itself when it is on a cycle. |
| `descendantsOf(dag, id)` | Transitive dependents, in node order | Includes `id` itself when it is on a cycle. |
| `findCycles(dag)` | One closed path per cyclic strongly connected component | Not applicable; this is the report. |
| `transitiveReduction(dag)` | A `Dag` without redundant edges | Throws `CycleError`: with a cycle the reduction is not unique. |
| `criticalPath(dag, weight)` | `{ path, weight }`, the heaviest chain | Throws `CycleError`: a chain through a cycle has no greatest weight. |

`ancestorsOf` and `descendantsOf` throw `UnknownNodeError` for an id that is not a node. `criticalPath` throws `RangeError` for a negative or non-finite weight.

### Layers

A node's level is the length of the longest `needs` chain below it: a node with no needs is level 0, every other node is one more than its highest-level need. `layers[n]` holds the level-n nodes in node order; each needs only earlier layers, so a layer can run in parallel once the previous ones are done. Unknown references contribute nothing to a level.

### Cycles

`findCycles` returns one path per strongly connected component that contains a cycle (more than one node, or a self-loop). A path lists nodes so that each needs the next and the last needs the first, repeated at the end: `["a", "b", "c", "a"]` means a needs b, b needs c and c needs a. A self-loop is `["a", "a"]`.

Which cycle is reported for a component is fixed: a shortest cycle through the component's first node in node order, ties going to the earliest needs. Paths are ordered by the position of their first node. A component may hold more cycles than the one reported; break the reported one and call again to find the rest.

### Transitive reduction

An edge from a node to one of its needs is redundant when another of its needs already depends on that need. The reduction removes every redundant edge, keeps each node's remaining needs in their original order, and has the same ancestors and descendants as the input. Unknown references pass through unchanged.

### Critical path

The path with the greatest total node weight along `needs`, returned in dependency order. `weight(id)` is called once per node, in node order. The path always runs back to a node with no needs. Ties are broken by node order: among equally heavy needs the earliest node is followed, and among equally heavy end nodes the earliest ends the path. An empty graph returns `{ path: [], weight: 0 }`.

## Commands

```sh
pnpm build # tsc -b -> dist/ (ESM + .d.ts)
pnpm test # unit tests
pnpm test:smoke # the built dist/ exercised as a real artefact
pnpm test:mutation # Stryker
```
9 changes: 9 additions & 0 deletions packages/core/graph/dag/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from "eslint/config";
import { packageLintConfig } from "../../../../eslint.shared.ts";

export default defineConfig(
packageLintConfig({
tsconfigRootDir: import.meta.dirname,
isomorphic: true,
}),
);
71 changes: 71 additions & 0 deletions packages/core/graph/dag/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"name": "@exadev/process-graph-dag",
"version": "0.0.0",
"description": "Model-independent dependency-graph primitives over a node set and a needs accessor: layers, ancestors, descendants, cycles, transitive reduction and the critical path.",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/ExaDev/process-graph.git",
"directory": "packages/core/graph/dag"
},
"homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/core/graph/dag#readme",
"bugs": {
"url": "https://github.com/ExaDev/process-graph/issues"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"publishConfig": {
"access": "public",
"provenance": true,
"registry": "https://registry.npmjs.org/"
},
"sideEffects": false,
"license": "MIT",
"scripts": {
"build": "turbo run _build",
"_build": "tsc -b tsconfig.build.json",
"lint": "turbo run _lint",
"_lint": "eslint . --cache --max-warnings 0",
"_lint:fix": "eslint . --fix --cache --max-warnings 0",
"typecheck": "turbo run _typecheck _typecheck:attw",
"_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit",
"_typecheck:attw": "attw --pack --profile esm-only",
"test": "turbo run _test",
"_test": "vitest run --project unit",
"test:coverage": "turbo run _test:coverage",
"_test:coverage": "vitest run --project unit --coverage",
"test:smoke": "turbo run _test:smoke",
"_test:smoke": "vitest run --project smoke",
"test:mutation": "turbo run _test:mutation",
"_test:mutation": "stryker run stryker.config.ts",
"prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && publint && attw --pack --profile esm-only"
},
"keywords": [
"dag",
"dependency-graph",
"topological-sort",
"critical-path",
"transitive-reduction",
"cycle-detection"
],
"devDependencies": {
"@arethetypeswrong/cli": "0.18.5",
"@stryker-mutator/core": "10.0.0",
"@stryker-mutator/typescript-checker": "10.0.0",
"@stryker-mutator/vitest-runner": "10.0.0",
"@types/node": "26.6.4",
"@vitest/coverage-v8": "4.1.11",
"eslint": "10.12.0",
"publint": "0.3.25",
"typescript": "6.0.3",
"vitest": "4.1.11"
}
}
88 changes: 88 additions & 0 deletions packages/core/graph/dag/src/critical-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { assertAcyclic } from "./cycles.ts";
import { type Dag, type NodeId, lookup } from "./dag.ts";
import { topologicalLayers } from "./layers.ts";

/**
* The most expensive chain through a graph.
*/
export interface CriticalPath<Id extends NodeId> {
/** The chain in dependency order: each node needs the one before it. Empty only for a graph with no nodes. */
readonly path: readonly Id[];
/** The sum of the weights of the nodes on `path`. */
readonly weight: number;
}

/**
* The longest weighted chain along `needs`, where a chain's weight is the sum of its nodes' weights.
*
* `weight` is called exactly once per node, in node order, and must return a finite number that is not negative. The returned path always runs back to a node with no needs and ends at the node whose chain is heaviest.
*
* Ties are broken deterministically, and the rule is part of the contract. Where several needs of a node give an equally heavy chain, the one that comes first in node order is followed. Where several nodes end an equally heavy chain, the one that comes first in node order ends it. A zero-weight node therefore never shortens the path back to the start, and a zero-weight dependent never extends the path past an earlier node of equal total.
*
* Defined for acyclic graphs. An empty graph returns `{ path: [], weight: 0 }`. Unknown references are not edges.
* @throws CycleError when the graph contains a cycle: a chain through a cycle has no greatest weight.
* @throws RangeError when `weight` returns a negative or non-finite number.
*/
export function criticalPath<Id extends NodeId>(
dag: Dag<Id>,
weight: (id: Id) => number,
): CriticalPath<Id> {
assertAcyclic(dag);
const weights = new Map<Id, number>();
for (const id of dag.nodes) {
const value = weight(id);
if (!Number.isFinite(value) || value < 0) {
throw new RangeError(
`The weight of "${String(id)}" must be a finite, non-negative number, got ${String(value)}`,
);
}
weights.set(id, value);
}

const position = new Map(dag.nodes.map((id, order) => [id, order]));
const total = new Map<Id, number>();
const previous = new Map<Id, Id>();
for (const layer of topologicalLayers(dag).layers) {
for (const id of layer) {
let best: Id | undefined;
let bestTotal = 0;
for (const need of lookup(dag.needs, id)) {
const candidate = lookup(total, need);
if (
best === undefined ||
candidate > bestTotal ||
(candidate === bestTotal &&
lookup(position, need) < lookup(position, best))
) {
best = need;
bestTotal = candidate;
}
}
total.set(id, lookup(weights, id) + bestTotal);
if (best !== undefined) {
previous.set(id, best);
}
}
}

let end: Id | undefined;
for (const id of dag.nodes) {
if (end === undefined || lookup(total, id) > lookup(total, end)) {
end = id;
}
}
if (end === undefined) {
return { path: [], weight: 0 };
}

const path: Id[] = [];
for (
let node: Id | undefined = end;
node !== undefined;
node = previous.get(node)
) {
path.push(node);
}

return { path: path.reverse(), weight: lookup(total, end) };
}
Loading