Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
687e210
feat: add graph-cli package
Mearman Oct 10, 2026
12cee88
fix(cli): mark the bin as the package's one side effect
Mearman Oct 10, 2026
4e4b3a1
feat(process-graph): carry the process-graph bin
Mearman Oct 10, 2026
1b3b270
fix(cli): pin each sibling at its workspace version
Mearman Oct 11, 2026
4e51c98
build(process-graph): bundle the building blocks behind subpath exports
Mearman Oct 11, 2026
722f748
build(knip): read the process-graph entries from its bundler
Mearman Oct 11, 2026
44c25fc
feat(process-graph): expose the React UI packages as subpaths
Mearman Oct 11, 2026
8b4994e
test(cli): expect the derived alt fragment in a drawn sequence
Mearman Oct 11, 2026
cf17951
test(cli): name the model transitions behind each drawn edge
Mearman Oct 11, 2026
4028950
build: make every building-block package private
Mearman Oct 11, 2026
764deb2
build(release): give private packages a version and tag only
Mearman Oct 11, 2026
f9a051a
build(deps): take versions from a pnpm catalog and siblings from the …
Mearman Oct 11, 2026
de99f03
test(process-graph): smoke-test the packed package as a consumer inst…
Mearman Oct 11, 2026
333522c
test(process-graph): run the store conformance kit on the bundled was…
Mearman Oct 11, 2026
ee86c7b
docs(process-graph): document the subpaths, their peers and the comma…
Mearman Oct 11, 2026
433ac4a
docs: describe one published package, catalogs and private-by-default…
Mearman Oct 11, 2026
e49e339
docs: point each internal package's README at the process-graph subpath
Mearman Oct 11, 2026
263dcda
docs(process-graph): link the barrel-policy limitation behind the sub…
Mearman Oct 11, 2026
f5467c4
test(process-graph): report why packing or installing the consumer fa…
Mearman Oct 11, 2026
21dd883
test(process-graph): install the packed consumer with pnpm
Mearman Oct 11, 2026
ccdcf2b
test(projection): run the work-growth cases without a clock bound
Mearman Oct 11, 2026
269e011
feat(cli): name an exit status for missing peer dependencies
Mearman Oct 11, 2026
17a7efa
feat(process-graph): say which peers the bin needs when they are missing
Mearman Oct 11, 2026
698f86c
docs: list the web app apart from the packages process-graph bundles
Mearman Oct 11, 2026
1afd16a
build(deps): point process-graph's browser test tooling at the vite s…
Mearman Oct 11, 2026
8d24065
fix(cli): escape terminal control characters in everything the comman…
Mearman Oct 11, 2026
176c3ea
fix(cli): keep the store's files private to their owner
Mearman Oct 11, 2026
0825f18
fix(cli): escape terminal-unsafe characters by Unicode general category
Mearman Oct 11, 2026
9094d41
build(deps): upgrade @exadev/eslint-config to 3.1.0
Mearman Oct 11, 2026
f8cf684
build(lint): declare process-graph's subpath modules as barrel entries
Mearman Oct 11, 2026
966bb26
test(cli): set the umask in a child process for the store mode checks
Mearman Oct 11, 2026
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
41 changes: 15 additions & 26 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,32 @@

## Adding a package

A new package is private and reaches consumers through a subpath of the published `process-graph` package, which bundles it. It needs no name on npm and no trusted publisher.

1. Pick the layer: `packages/core/<domain>/<name>` for the model, its analysis and ports; `packages/adapters/<domain>/<name>` for an implementation of a port against one external system; `packages/ui/<domain>/<name>` for components; `packages/targets/<name>` (flat) for a deployable entry point.
2. Copy the closest existing package's shape: `package.json` (`exports` map, script set), `tsconfig.json` plus `tsconfig.build.json` (and `tsconfig.node.json` for an isomorphic package), `eslint.config.ts`, `vitest.config.ts`, `stryker.config.ts`, `test/package.smoke.test.ts`.
3. Name it `@exadev/process-graph-<name>` and pin every internal dependency exact (`syncpack lint` enforces it). `workspace:` ranges are not used.
4. Add its `tsconfig.build.json` to the root `tsconfig.build.json` `references`.
5. If it is the first package in its layer, add that layer's workspace entry to `knip.config.ts` (an entry that matches nothing is a knip hint).
6. Write its `README.md`, then `ln -s README.md AGENTS.md && ln -s README.md CLAUDE.md`.
7. Add it to the packages table in the root `README.md`, and to the Dependabot `ignore` list only if its name does not already match `@exadev/process-graph-*`.
8. Give the manifest what the release orchestrator needs (see [Release strategy](#release-strategy)): `version` `0.0.0`, `repository.directory` set to the package's path, `publishConfig` with `access`, `provenance` and the registry as in its siblings, and each sibling dependency as the sibling's exact version.
9. Reserve the name on npm and register its trusted publisher before its first releasable commit reaches `main` (see [Bootstrapping a package name](#bootstrapping-a-package-name)).
3. Name it `@exadev/process-graph-<name>`, mark it `"private": true`, and set `version` to `0.0.0` and `repository.directory` to the package's path. Give it no `publishConfig`.
4. Add dependencies with pnpm: a sibling as `workspace:*` (`pnpm add @exadev/process-graph-<sibling>@workspace:*`), and a third-party library from the catalog in `pnpm-workspace.yaml` (`pnpm add --save-catalog <library>@<exact version>`, which adds the catalog entry when there is none). `pnpm run deps:lint` rejects anything else.
5. Add its `tsconfig.build.json` to the root `tsconfig.build.json` `references`.
6. If it is the first package in its layer, add that layer's workspace entry to `knip.config.ts` (an entry that matches nothing is a knip hint).
7. Expose it through `process-graph`, in `packages/targets/process-graph`: a module under `src/` that re-exports it, named for the subpath (`src/<subpath>.ts`), an entry for that module in `tsdown.config.ts`, the subpath in the `exports` map, the module in the subpath list in `eslint.config.ts`, a case in `src/entrypoints.unit.test.ts` and an import of the subpath in `test/package.smoke.test.ts`. Add the package to `devDependencies` at its exact version (`pnpm add -D --save-exact @exadev/process-graph-<name>@<version>`), never `workspace:*`, since that manifest is published as written. Each third-party library the new subpath needs that the root entry does not goes in `peerDependencies`, marked optional in `peerDependenciesMeta`, at the catalog's exact version (`pnpm add --save-peer --save-exact <library>@<version>`). Where a layer's names collide with another layer's on the same subpath, namespace one layer rather than shadow a name.
8. Write its `README.md`, then `ln -s README.md AGENTS.md && ln -s README.md CLAUDE.md`.
9. Add it to the internal packages table in the root `README.md`, and the subpath to the `process-graph` README.
10. Verify with the repository scripts: `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm test:smoke`, `pnpm exec knip`, `pnpm run deps:lint`.
11. Commit as `feat: add <name> package`, describing the package's role and any deliberate decision, not the process that produced it.

## Release strategy

Every publishable package versions and publishes on its own. `release-workspace.config.ts` configures [`@exadev/semantic-release-workspace`](https://github.com/ExaDev/semantic-release-workspace), which the `release` job in `.github/workflows/ci.yml` runs as `pnpm release` on every push to `main` once the other jobs have passed.
Only `process-graph` is published. `release-workspace.config.ts` configures [`@exadev/semantic-release-workspace`](https://github.com/ExaDev/semantic-release-workspace), which the `release` job in `.github/workflows/ci.yml` runs as `pnpm release` on every push to `main` once the other jobs have passed.

- The orchestrator discovers the packages from `pnpm-workspace.yaml`, orders them by their dependencies on one another and releases each after every sibling it depends on, so the `process-graph` entry point is released after the building-block packages it re-exports.
- The orchestrator discovers the packages from `pnpm-workspace.yaml`, orders them by their dependencies on one another, `devDependencies` included, and releases each after every sibling it depends on, so `process-graph` is released after every package it bundles.
- A package's release is decided from the commits that touched its own directory, using the types and release levels in `commit-types.ts`. A commit that touches no package directory releases nothing, and a commit scope has no effect on which package releases.
- Each release is a `<name>@<version>` tag, a package-level `CHANGELOG.md` and a GitHub Release. The whole run's version bumps, changelog entries and dependency-range rewrites are committed once and pushed atomically with the tags before anything is published.
- When a package releases, every dependent's sibling range is rewritten to the new version, the lockfile is regenerated and the dependent releases a patch even if nothing else about it changed, so a published manifest never names a version the repository has moved past. Sibling dependencies are exact versions rather than the `workspace:` protocol for this reason: `npm publish` ships a specifier as written.
- Publishing uses npm trusted publishing (OIDC): `id-token: write` on the job, no `NPM_TOKEN`, and no `registry-url` on `setup-node`, because that writes a token line that wins over the OIDC exchange.
- Every package gets a `<name>@<version>` tag, which the orchestrator reads to find its last release. A private package gets nothing else: it runs only `@semantic-release/npm`, which bumps its version and skips publishing because the manifest is private, so it writes no `CHANGELOG.md` and creates no GitHub Release. `process-graph` gets its `CHANGELOG.md`, its GitHub Release and its npm publish.
- When a package releases, every dependent's range on it is rewritten to the new version where the range names one, the lockfile is regenerated, and the dependent releases a patch even if nothing else about it changed. `process-graph` names each bundled package's exact version in `devDependencies`, so a change confined to a private package's directory still releases `process-graph`, as a patch, carrying the new bundle. A feature in a private package therefore reaches consumers in a `process-graph` patch release unless a commit touching `process-graph` itself says otherwise.
- The whole run's version bumps, changelog entries and dependency-range rewrites are committed once and pushed atomically with the tags before anything is published.
- Publishing uses npm trusted publishing (OIDC): `id-token: write` on the job, no `NPM_TOKEN`, and no `registry-url` on `setup-node`, because that writes a token line that wins over the OIDC exchange. `@semantic-release/npm` publishes with `npm publish`, which ships every specifier as written, which is why the `process-graph` manifest uses literal versions where the private ones use `catalog:` and `workspace:*`.
- The release commit and tags reach the protected `main` through a repository deploy key with write access (the `RELEASE_DEPLOY_KEY` secret), which the `main` ruleset lists as a bypass actor; `GITHUB_TOKEN` cannot bypass a ruleset. The `release` job checks out without persisting a credential, writes the key to the runner's temporary directory only in the step before the release, and removes it afterwards. The orchestrator pushes with plain `git push origin`, so the release step rewrites https GitHub URLs to SSH through git's per-process config and sets `GIT_SSH_COMMAND` to use only that key; `GITHUB_TOKEN` stays in that step's environment for the GitHub API calls that create releases and comment on issues and pull requests.

Release configuration lives only in `release-workspace.config.ts`. The orchestrator sets the tag format, plugins and analysis options on every per-package run, so a `release.config.*` inside a package would be overridden rather than honoured.

To see what a run would do without tagging, committing, pushing or publishing, run `pnpm release --dry-run` from the root on a clean tree and a pushed branch, with `--branches <branch>` naming it and a `GITHUB_TOKEN` in the environment.

### Bootstrapping a package name

npm has no trusted publisher settings page for a name that has never been published, so a new package's name is reserved and its trusted publisher registered by a person before CI publishes it for the first time:

```sh
npm-reserve <name> --trust ExaDev/process-graph --workflow ci.yml
```

A first publish from CI under an unregistered name fails the OIDC exchange. The run has already pushed the tag by then, so that version is skipped rather than retried; see the orchestrator's README for recovering from it.

### Adding a package to releases

A package joins releases by being a non-private workspace package: no other registration exists. Its manifest carries the fields named in the steps of [Adding a package](#adding-a-package), its first release is `1.0.0` because it has no earlier tag, and a private package is still versioned and tagged but never published.
29 changes: 20 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ pnpm build
pnpm test
```

Dependencies are added with `pnpm add`, never by editing a version by hand. Every manifest pins exact versions (`syncpack` enforces it), and the install age gate in `pnpm-workspace.yaml` is never lowered; ExaDev's own packages are the only entries in `minimumReleaseAgeExclude`.
Dependencies are added with `pnpm add`, never by editing a version by hand. Every third-party version is written once, exactly, in the catalog in `pnpm-workspace.yaml`: a private package names it as `catalog:` (`pnpm add --save-catalog <name>@<version>`) and a sibling as `workspace:*`. The published `process-graph` manifest names exact literal versions instead, because `npm publish` ships a protocol specifier verbatim; `pnpm run deps:lint` (`syncpack` and `scripts/check-published-manifests.ts`) holds both rules. The install age gate in `pnpm-workspace.yaml` is never lowered; ExaDev's own packages are the only entries in `minimumReleaseAgeExclude`.

## Commands

```sh
pnpm build # tsc -b across the workspace, dependency-ordered
pnpm build # every package, dependency-ordered; process-graph last, bundled by tsdown
pnpm lint pnpm lint:fix pnpm typecheck
pnpm test pnpm test:coverage pnpm test:smoke
pnpm test:mutation pnpm exec knip pnpm run deps:lint
Expand All @@ -39,12 +39,20 @@ 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`](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.
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`. One package is published: the unscoped [`process-graph`](packages/targets/process-graph/README.md), in the `targets` layer. Every other package is private, named `@exadev/process-graph-<name>`, and reaches consumers only bundled into `process-graph` behind one of its subpath exports, so a consumer installs one dependency and imports each part from its own subpath.

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).

## Packages

The published package:

| Package | What it is |
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`process-graph`](packages/targets/process-graph/README.md) | The one install: the model, projection, domain and DAG APIs at the root entry, layout, the GitHub issues source, the SQLite store for Node and the browser, the React UI and the command line library each behind a subpath, and the `process-graph` bin. |

The internal packages, private and bundled into `process-graph`:

| 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. |
Expand All @@ -61,8 +69,13 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config
| [`graph-react`](packages/ui/graph/react/README.md) | Headless React hooks and unstyled components: follow a stored model, derive statuses, the ready set, blocked chains, impact and explanations, project views, filter, select and navigate by keyboard. |
| [`graph-ui`](packages/ui/graph/ui/README.md) | Styled components over `graph-react` and an SVG sequence diagram, in Vanilla Extract with a typed theme contract and light and dark themes, shipped as compiled CSS. |
| [`graph-react-flow`](packages/ui/graph/react-flow/README.md) | React Flow views of the model: flowchart, swimlane with lanes as group nodes, and state machine, laid out by `graph-layout`, showing each status and the critical path; sequence projections drawn by `graph-ui`. |
| [`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. |
| [`graph-web`](packages/targets/graph-web/README.md) | The web app: an installable, offline-first PWA that syncs GitHub issues into SQLite on the device and shows the work in every view, with what is ready, what is blocked and why, linkable by address. |
| [`graph-cli`](packages/targets/graph-cli/README.md) | The command line and its library entry: sync GitHub issues into the SQLite store, ready, blocked, impact, critical path, cycles, hand-offs and status explanations, and every view drawn in Mermaid or Graphviz DOT. |

The web app, private and not bundled into `process-graph`:

| Package | What it is |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`graph-web`](packages/targets/graph-web/README.md) | The web app: an installable, offline-first PWA that syncs GitHub issues into SQLite on the device and shows the work in every view, with what is ready, what is blocked and why, linkable by address. |

## Conventions

Expand All @@ -76,12 +89,10 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config

## Releases

Each package versions and publishes independently through [`@exadev/semantic-release-workspace`](https://github.com/ExaDev/semantic-release-workspace), configured in `release-workspace.config.ts` and run by the `release` job in CI. A package releases when commits touch its own directory, or when a sibling it depends on releases; packages release in dependency order, so the entry point follows the packages it re-exports. Tags are `<name>@<version>`, and each package keeps its own `CHANGELOG.md`.
Releases run through [`@exadev/semantic-release-workspace`](https://github.com/ExaDev/semantic-release-workspace), configured in `release-workspace.config.ts` and run by the `release` job in CI. Every package versions independently and is tagged `<name>@<version>`; a package releases when commits touch its own directory, or when a sibling it depends on releases, in dependency order. Only `process-graph` is published, with its own `CHANGELOG.md` and GitHub Release; a private package gets a version and a tag and nothing else. Because `process-graph` lists every package it bundles in `devDependencies`, a change to any of them releases `process-graph` too.

The release commit and tags are pushed to the protected `main` with a repository deploy key (the `RELEASE_DEPLOY_KEY` secret) over SSH, because `GITHUB_TOKEN` cannot bypass a ruleset; the `main` ruleset lists that deploy key as a bypass actor. `GITHUB_TOKEN` is still used for the GitHub API calls that create releases and comment on issues and pull requests.

A new package name must be reserved on npm, and its trusted publisher registered, by a person before its first CI publish; the steps are in CONTRIBUTING.md.

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for adding a package, the release strategy and bootstrapping a package name.
See [CONTRIBUTING.md](CONTRIBUTING.md) for adding a package and the release strategy.
Loading