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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ packages/
└── targets/<package>/ # deployable entry points, deliberately flat
```

A package may depend on its own layer and the layers below it, never above; `no-uphill-dependency` and `no-dependency-cycle` from `@exadev/eslint-config` enforce it from the manifests, configured in `eslint.shared.ts`. Published names are `@exadev/process-graph-<name>`; the unscoped `process-graph` package is the umbrella that re-exports the model and carries the CLI.
A package may depend on its own layer and the layers below it, never above; `no-uphill-dependency` and `no-dependency-cycle` from `@exadev/eslint-config` enforce it from the manifests, configured in `eslint.shared.ts`. Published names are `@exadev/process-graph-<name>`; the unscoped [`process-graph`](packages/targets/process-graph/README.md) package, in the `targets` layer, is the primary entry point: it re-exports the model, projection, domain and DAG packages so a consumer installs one dependency.

Configuration lives at exactly one of two levels: in the package (`eslint.config.ts`, `tsconfig*.json`, Vitest projects, Stryker) or at the root (commit rules, git hooks, formatting, release).

Expand All @@ -49,6 +49,7 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config
| [`graph-schema`](packages/core/graph/schema/README.md) | The process model: typed identifier spaces, `defineModel` with compile-time reference checking, structural validation. |
| [`graph-projection`](packages/core/graph/projection/README.md) | Projections of the model into a flowchart, swimlane, state machine and sequence diagram, plus derived hand-offs. |
| [`graph-domain`](packages/core/graph/domain/README.md) | Reasoning over the model and a run overlay: control graph, illegal cycles, three-valued status, ready set, blocked-by, impact, what-if and critical path. |
| [`process-graph`](packages/targets/process-graph/README.md) | The primary entry point: re-exports the schema, projection and domain APIs flat and the DAG API as the `dag` namespace. |

## Conventions

Expand Down
4 changes: 4 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const config: KnipConfig = {
entry: ["src/index.{ts,tsx}"],
project: ["src/**/*.{ts,tsx}!"],
},
"packages/targets/*": {
entry: ["src/index.{ts,tsx}"],
project: ["src/**/*.{ts,tsx}!"],
},
},
ignoreDependencies: [
// Resolved by semantic-release via the plugin-name strings in release.config.ts's own plugins array, not a static import knip can see.
Expand Down
1 change: 1 addition & 0 deletions packages/targets/process-graph/AGENTS.md
1 change: 1 addition & 0 deletions packages/targets/process-graph/CLAUDE.md
72 changes: 72 additions & 0 deletions packages/targets/process-graph/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# process-graph

The one install for the process-graph model: a layout-free description of a process, its projections into a flowchart, a swimlane, a state machine and a sequence diagram, and reasoning over a run (what is ready, what blocks what, what finishing something changes). It re-exports the building-block packages so a consumer depends on one package and imports from one place. `src/` imports nothing Node-only and runs unchanged in a browser or a Worker.

```ts
import {
defineModel,
deriveStatuses,
toSwimlane,
type Facts,
} from "process-graph";

const model = defineModel({
participants: [
{ id: "customer", label: "Customer", kind: "role" },
{ id: "shop", label: "Shop", kind: "system" },
],
events: [],
actions: [],
artefacts: [],
elements: [
{ kind: "activity", id: "submit", label: "Submit", actor: "customer" },
{ kind: "activity", id: "pack", label: "Pack", actor: "shop" },
],
transitions: [{ id: "t1", source: "submit", target: "pack" }],
fragments: [],
projections: [],
});

// The injected source of facts a decision's condition reads; this model has no decisions.
const facts: Facts = () => ({ found: false });

toSwimlane(model).lanes; // one lane per actor, each with the elements it owns
deriveStatuses(model, { submit: "done" }, facts); // Map { "submit" => "done", "pack" => "ready" }
```

## This package or a building block

Install `process-graph` to author, load, project and reason about a model. Install a building-block package instead when only one layer is needed and the rest should stay out of the dependency tree, or when another package of this repository imports a layer directly.

| Layer | Package | Exported by this package |
| ---------- | ---------------------------------- | ------------------------ |
| Model | `@exadev/process-graph-schema` | flat |
| Projection | `@exadev/process-graph-projection` | flat |
| Domain | `@exadev/process-graph-domain` | flat |
| DAG | `@exadev/process-graph-dag` | namespaced as `dag` |

Each package's own README documents its functions and rules; this package adds no behaviour of its own.

## What is exported, and under which name

The model, projection and domain packages are exported flat, under their own names. The DAG package is exported as the namespace `dag`, in its entirety: `dag.buildDag`, `dag.topologicalLayers`, `dag.criticalPath`, `dag.CycleError` and the rest. It is the only layer namespaced, because its `criticalPath` has the same name as the domain package's, which answers a different question (the heaviest chain through the work still ahead of a run, not through a bare node set). The whole layer is namespaced rather than only the colliding name, so a name added to a layer later cannot start colliding silently.

- A name is never shadowed. Two flat layers exporting the same name is a compile error in this package, and its unit test also compares the runtime exports of each package against the entry point's.
- Types follow the same rule as values: a model, projection or domain type is a flat import, a DAG type is `dag.Dag` and so on.
- Errors thrown by the domain functions (`CycleError`, `UnknownNodeError`) are the DAG package's, so a handler imports them from `dag`.

```ts
import { criticalPath, dag } from "process-graph";

criticalPath; // the domain function: over a model and a run overlay
dag.criticalPath; // the DAG function: over a node set and a `needs` accessor
```

## 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/targets/process-graph/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,
}),
);
77 changes: 77 additions & 0 deletions packages/targets/process-graph/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{
"name": "process-graph",
"version": "0.0.0",
"description": "A process and dependency graph model with projections into flowcharts, swimlanes, state machines and sequence diagrams, and reasoning over a run: one install for the model, projection, domain and DAG packages.",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/ExaDev/process-graph.git",
"directory": "packages/targets/process-graph"
},
"homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/targets/process-graph#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": [
"process-model",
"dependency-graph",
"flowchart",
"swimlane",
"state-machine",
"sequence-diagram"
],
"dependencies": {
"@exadev/process-graph-dag": "0.0.0",
"@exadev/process-graph-domain": "0.0.0",
"@exadev/process-graph-projection": "0.0.0",
"@exadev/process-graph-schema": "0.0.0"
},
"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"
}
}
5 changes: 5 additions & 0 deletions packages/targets/process-graph/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// The primary entry point: the model, its projections and its reasoning under one install. The schema, projection and domain packages are re-exported flat. The DAG package is namespaced as `dag` because its `criticalPath` has the same name as the domain package's, which answers a different question over the model and a run overlay. The whole layer is namespaced, not only the colliding name, so the rule is one sentence and a name added to a layer later cannot start colliding silently: TypeScript rejects a duplicate `export *` name and `index.unit.test.ts` checks the runtime surface against each package's own exports.
export * from "@exadev/process-graph-schema";
export * from "@exadev/process-graph-projection";
export * from "@exadev/process-graph-domain";
export * as dag from "@exadev/process-graph-dag";
70 changes: 70 additions & 0 deletions packages/targets/process-graph/src/index.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import * as dagPackage from "@exadev/process-graph-dag";
import * as domainPackage from "@exadev/process-graph-domain";
import * as projectionPackage from "@exadev/process-graph-projection";
import * as schemaPackage from "@exadev/process-graph-schema";
import { describe, expect, test } from "vitest";
import * as entrypoint from "./index.ts";

const flatLayers = [
["schema", schemaPackage],
["projection", projectionPackage],
["domain", domainPackage],
] as const;

describe("the primary entry point's runtime export surface", () => {
test("no name is exported by more than one flat layer", () => {
const owners = new Map<string, string[]>();
for (const [layer, exports] of flatLayers) {
for (const name of Object.keys(exports)) {
owners.set(name, [...(owners.get(name) ?? []), layer]);
}
}
const duplicates = [...owners]
.filter(([, layers]) => layers.length > 1)
.map(([name, layers]) => `${name}: ${layers.join(", ")}`);

expect(duplicates).toEqual([]);
});

test.each(flatLayers)(
"every runtime export of the %s package is exported flat and is the same value",
(_layer, exports) => {
for (const [name, value] of Object.entries(exports)) {
expect(entrypoint).toHaveProperty([name], value);
}
},
);

test("the DAG package is the `dag` namespace, with every export present", () => {
for (const [name, value] of Object.entries(dagPackage)) {
expect(entrypoint.dag).toHaveProperty([name], value);
}
expect(Object.keys(entrypoint.dag).sort()).toEqual(
Object.keys(dagPackage).sort(),
);
});

test("the DAG layer's names are reachable only through the namespace", () => {
for (const name of Object.keys(dagPackage)) {
const flat = Object.getOwnPropertyDescriptor(entrypoint, name);
const fromFlatLayer = flatLayers.some(([, exports]) => name in exports);

expect(flat !== undefined).toBe(fromFlatLayer);
}
});

test("the entry point exports the flat layers and `dag`, and nothing else", () => {
const expected = [
...flatLayers.flatMap(([, exports]) => Object.keys(exports)),
"dag",
];

expect(Object.keys(entrypoint).sort()).toEqual(expected.sort());
});

test("the colliding names stay distinct functions", () => {
expect(entrypoint.criticalPath).toBe(domainPackage.criticalPath);
expect(entrypoint.dag.criticalPath).toBe(dagPackage.criticalPath);
expect(entrypoint.criticalPath).not.toBe(entrypoint.dag.criticalPath);
});
});
3 changes: 3 additions & 0 deletions packages/targets/process-graph/stryker.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { packageStrykerConfig } from "../../../stryker.shared.ts";

export default packageStrykerConfig();
42 changes: 42 additions & 0 deletions packages/targets/process-graph/test/package.smoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { expect, test } from "vitest";
// Deliberately imports the built dist/, not src/: this is the check that catches a wrong exports map or a build misconfiguration that a unit test importing straight from src/ never touches.
import {
dag,
deriveStatuses,
parseModel,
readySet,
toSwimlane,
} from "../dist/index.js";

test("the built package loads a model, projects it and derives its status through one import", () => {
const model = parseModel({
participants: [
{ id: "customer", label: "Customer", kind: "role" },
{ id: "shop", label: "Shop", kind: "system" },
],
events: [],
actions: [],
artefacts: [],
elements: [
{ kind: "activity", id: "submit", label: "Submit", actor: "customer" },
{ kind: "activity", id: "pack", label: "Pack", actor: "shop" },
],
transitions: [{ id: "t1", source: "submit", target: "pack" }],
fragments: [],
projections: [],
});
const facts = () => ({ found: false }) as const;

expect(
toSwimlane(model).lanes.map((lane) => [lane.participant, lane.nodes]),
).toEqual([
["customer", ["submit"]],
["shop", ["pack"]],
]);
expect([...deriveStatuses(model, { submit: "done" }, facts)]).toEqual([
["submit", "done"],
["pack", "ready"],
]);
expect(readySet(model, {}, facts)).toEqual(["submit"]);
expect(typeof dag.criticalPath).toBe("function");
});
26 changes: 26 additions & 0 deletions packages/targets/process-graph/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"noEmit": false,
"declaration": true,
"declarationMap": false,
"rootDir": "src",
"outDir": "dist",
"rewriteRelativeImportExtensions": true
},
"references": [
{
"path": "../../core/graph/dag/tsconfig.build.json"
},
{
"path": "../../core/graph/schema/tsconfig.build.json"
},
{
"path": "../../core/graph/projection/tsconfig.build.json"
},
{
"path": "../../core/graph/domain/tsconfig.build.json"
}
]
}
9 changes: 9 additions & 0 deletions packages/targets/process-graph/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2024"],
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/test-support/**"]
}
8 changes: 8 additions & 0 deletions packages/targets/process-graph/tsconfig.node.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2024"],
"types": ["node"]
},
"include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "*.config.ts"]
}
16 changes: 16 additions & 0 deletions packages/targets/process-graph/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
projects: [
{
extends: true,
test: { name: "unit", include: ["src/**/*.test.ts"] },
},
{
extends: true,
test: { name: "smoke", include: ["test/package.smoke.test.ts"] },
},
],
},
});
Loading