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/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/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 +``` 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/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); + }); +}); 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" } ] }