From a4f42ab9248ed73e5254b61c857f1d05d696c2ec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 11:03:14 +0100 Subject: [PATCH 1/3] feat: add process-graph entry point package The unscoped process-graph package is the primary entry point, in the targets layer so it may depend on every layer below. It re-exports the schema, projection and domain packages flat and the DAG package as the dag namespace, because the DAG and domain packages both export criticalPath with different meanings. The whole DAG layer is namespaced so a later name cannot collide silently. Its smoke test loads a model, projects a swimlane and derives statuses from the built output. --- knip.config.ts | 4 + .../targets/process-graph/eslint.config.ts | 9 +++ packages/targets/process-graph/package.json | 77 +++++++++++++++++++ packages/targets/process-graph/src/index.ts | 5 ++ .../targets/process-graph/stryker.config.ts | 3 + .../process-graph/test/package.smoke.test.ts | 42 ++++++++++ .../targets/process-graph/tsconfig.build.json | 26 +++++++ packages/targets/process-graph/tsconfig.json | 9 +++ .../targets/process-graph/tsconfig.node.json | 8 ++ .../targets/process-graph/vitest.config.ts | 16 ++++ pnpm-lock.yaml | 46 +++++++++++ pnpm-workspace.yaml | 1 + tsconfig.build.json | 3 + 13 files changed, 249 insertions(+) create mode 100644 packages/targets/process-graph/eslint.config.ts create mode 100644 packages/targets/process-graph/package.json create mode 100644 packages/targets/process-graph/src/index.ts create mode 100644 packages/targets/process-graph/stryker.config.ts create mode 100644 packages/targets/process-graph/test/package.smoke.test.ts create mode 100644 packages/targets/process-graph/tsconfig.build.json create mode 100644 packages/targets/process-graph/tsconfig.json create mode 100644 packages/targets/process-graph/tsconfig.node.json create mode 100644 packages/targets/process-graph/vitest.config.ts diff --git a/knip.config.ts b/knip.config.ts index 33fac3f..a7e75c8 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -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. diff --git a/packages/targets/process-graph/eslint.config.ts b/packages/targets/process-graph/eslint.config.ts new file mode 100644 index 0000000..b3ab58a --- /dev/null +++ b/packages/targets/process-graph/eslint.config.ts @@ -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, + }), +); diff --git a/packages/targets/process-graph/package.json b/packages/targets/process-graph/package.json new file mode 100644 index 0000000..d1aa5eb --- /dev/null +++ b/packages/targets/process-graph/package.json @@ -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" + } +} diff --git a/packages/targets/process-graph/src/index.ts b/packages/targets/process-graph/src/index.ts new file mode 100644 index 0000000..42e376e --- /dev/null +++ b/packages/targets/process-graph/src/index.ts @@ -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"; diff --git a/packages/targets/process-graph/stryker.config.ts b/packages/targets/process-graph/stryker.config.ts new file mode 100644 index 0000000..3f6a9f3 --- /dev/null +++ b/packages/targets/process-graph/stryker.config.ts @@ -0,0 +1,3 @@ +import { packageStrykerConfig } from "../../../stryker.shared.ts"; + +export default packageStrykerConfig(); diff --git a/packages/targets/process-graph/test/package.smoke.test.ts b/packages/targets/process-graph/test/package.smoke.test.ts new file mode 100644 index 0000000..aed190a --- /dev/null +++ b/packages/targets/process-graph/test/package.smoke.test.ts @@ -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"); +}); diff --git a/packages/targets/process-graph/tsconfig.build.json b/packages/targets/process-graph/tsconfig.build.json new file mode 100644 index 0000000..76d317f --- /dev/null +++ b/packages/targets/process-graph/tsconfig.build.json @@ -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" + } + ] +} diff --git a/packages/targets/process-graph/tsconfig.json b/packages/targets/process-graph/tsconfig.json new file mode 100644 index 0000000..871926f --- /dev/null +++ b/packages/targets/process-graph/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024"], + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/test-support/**"] +} diff --git a/packages/targets/process-graph/tsconfig.node.json b/packages/targets/process-graph/tsconfig.node.json new file mode 100644 index 0000000..426f00b --- /dev/null +++ b/packages/targets/process-graph/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024"], + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "*.config.ts"] +} diff --git a/packages/targets/process-graph/vitest.config.ts b/packages/targets/process-graph/vitest.config.ts new file mode 100644 index 0000000..01b9391 --- /dev/null +++ b/packages/targets/process-graph/vitest.config.ts @@ -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"] }, + }, + ], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e2fdf7..c21d1c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -416,6 +416,52 @@ importers: specifier: 4.1.11 version: 4.1.11(@types/node@26.6.4)(@vitest/coverage-v8@4.1.11)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + packages/targets/process-graph: + dependencies: + '@exadev/process-graph-dag': + specifier: 0.0.0 + version: link:../../core/graph/dag + '@exadev/process-graph-domain': + specifier: 0.0.0 + version: link:../../core/graph/domain + '@exadev/process-graph-projection': + specifier: 0.0.0 + version: link:../../core/graph/projection + '@exadev/process-graph-schema': + specifier: 0.0.0 + version: link:../../core/graph/schema + devDependencies: + '@arethetypeswrong/cli': + specifier: 0.18.5 + version: 0.18.5 + '@stryker-mutator/core': + specifier: 10.0.0 + version: 10.0.0(@types/node@26.6.4) + '@stryker-mutator/typescript-checker': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(typescript@6.0.3) + '@stryker-mutator/vitest-runner': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(vitest@4.1.11) + '@types/node': + specifier: 26.6.4 + version: 26.6.4 + '@vitest/coverage-v8': + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) + eslint: + specifier: 10.12.0 + version: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) + publint: + specifier: 0.3.25 + version: 0.3.25 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 4.1.11 + version: 4.1.11(@types/node@26.6.4)(@vitest/coverage-v8@4.1.11)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + packages: '@actions/core@3.0.1': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d555876..4a9de00 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,6 +29,7 @@ minimumReleaseAgeExclude: - "@exadev/eslint-config" - "@exadev/json-compose" - "@exadev/process-graph-*" + - process-graph - cosmiconfig-extends - trilean - trilean-regex diff --git a/tsconfig.build.json b/tsconfig.build.json index 565b6e2..ee2ed55 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -12,6 +12,9 @@ }, { "path": "packages/core/graph/domain/tsconfig.build.json" + }, + { + "path": "packages/targets/process-graph/tsconfig.build.json" } ] } From fe7a3289f79372c0f674c8903d713596b9a08af6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 11:03:25 +0100 Subject: [PATCH 2/3] test: guard the entry point's export surface Compares the runtime exports of the schema, projection, domain and DAG packages against the entry point: no name is owned by two flat layers, every sibling export is present and identical, the DAG layer is reachable only through the dag namespace, and nothing else is exported. --- .../process-graph/src/index.unit.test.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 packages/targets/process-graph/src/index.unit.test.ts diff --git a/packages/targets/process-graph/src/index.unit.test.ts b/packages/targets/process-graph/src/index.unit.test.ts new file mode 100644 index 0000000..7e754e6 --- /dev/null +++ b/packages/targets/process-graph/src/index.unit.test.ts @@ -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(); + 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); + }); +}); From 220eea70cbbd7bc802fa304470f3b8be5352a7ef Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 11:03:31 +0100 Subject: [PATCH 3/3] docs: document the entry point package Explains when to install the entry point rather than a building-block package, the rule that the DAG layer is namespaced as dag and why, and adds the package to the root packages table and layer description. --- README.md | 3 +- packages/targets/process-graph/AGENTS.md | 1 + packages/targets/process-graph/CLAUDE.md | 1 + packages/targets/process-graph/README.md | 72 ++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) create mode 120000 packages/targets/process-graph/AGENTS.md create mode 120000 packages/targets/process-graph/CLAUDE.md create mode 100644 packages/targets/process-graph/README.md diff --git a/README.md b/README.md index 0d9cc75..8ae4e25 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ packages/ └── targets// # 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-`; 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-`; 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). @@ -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 diff --git a/packages/targets/process-graph/AGENTS.md b/packages/targets/process-graph/AGENTS.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/targets/process-graph/AGENTS.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/targets/process-graph/CLAUDE.md b/packages/targets/process-graph/CLAUDE.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/targets/process-graph/CLAUDE.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/targets/process-graph/README.md b/packages/targets/process-graph/README.md new file mode 100644 index 0000000..baeddcc --- /dev/null +++ b/packages/targets/process-graph/README.md @@ -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 +```