diff --git a/README.md b/README.md index 2e6f9cc..b42cf21 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,8 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config | [`graph-store-contract`](packages/core/graph/store-contract/README.md) | The persistence port as an oRPC contract: save, load and delete models, set run state, upsert items from a source, structural ready, blocked-chain and impact queries, and a change feed; with an in-memory fake and a conformance kit. | | [`graph-store-sqlite`](packages/adapters/graph/store-sqlite/README.md) | The store port on SQLite through Drizzle: one table definition, committed drizzle-kit migrations applied the same way in every runtime, recursive SQL for the structural queries, against Drizzle's async SQLite database so any driver plugs in. | | [`graph-sqlite-node`](packages/adapters/graph/sqlite-node/README.md) | The SQLite store on Node's built-in `node:sqlite` through `drizzle-orm/node-sqlite`, for the command line and servers. | -| [`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-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. | +| [`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, and carries the `process-graph` bin. | ## Conventions diff --git a/knip.config.ts b/knip.config.ts index f97958e..127d749 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -25,7 +25,8 @@ const config: KnipConfig = { project: ["src/**/*.{ts,tsx}!"], }, "packages/targets/*": { - entry: ["src/index.{ts,tsx}"], + // A target's bin is an entry point of its own, run as a process rather than imported. + entry: ["src/index.{ts,tsx}", "src/bin.ts"], project: ["src/**/*.{ts,tsx}!"], }, }, diff --git a/packages/core/graph/domain/README.md b/packages/core/graph/domain/README.md index 3add645..3bbe363 100644 --- a/packages/core/graph/domain/README.md +++ b/packages/core/graph/domain/README.md @@ -25,19 +25,20 @@ deriveStatuses(model, overlay, facts); // Map { "triage" => "done", ... } ## Functions -| Function | Result | -| --------------------------------------------- | --------------------------------------------------------------------------------------- | -| `controlDag(model)` | The `graph-dag` `Dag` over element ids, with loop edges removed. | -| `loopTransitions(model)` | The transitions that close a legal decision loop. | -| `illegalCycles(model)` | The cycles of the control graph, each with its path. | -| `evaluateBranches(model, overlay, facts)` | Each transition's `taken`, `not-taken` or `indeterminate`. | -| `deriveStatuses(model, overlay, facts)` | The `Status` of every element. | -| `readySet(model, overlay, facts)` | The elements that are `ready`. | -| `blockedBy(model, overlay, facts, id)` | The open leaves an element is waiting on. | -| `impact(model, overlay, facts, id)` | The live elements that transitively depend on `id`. | -| `whatIfDone(model, overlay, facts, id)` | The elements that become ready and every status change if `id` were done. | -| `criticalPath(model, overlay, facts, weight)` | The heaviest chain through the work still ahead. | -| `unmetOutputs(model, overlay, facts)` | Each deliverable not yet produced, with the elements and missing inputs it still needs. | +| Function | Result | +| --------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `controlDag(model)` | The `graph-dag` `Dag` over element ids, with loop edges removed. | +| `loopTransitions(model)` | The transitions that close a legal decision loop. | +| `illegalCycles(model)` | The cycles of the control graph, each with its path. | +| `evaluateBranches(model, overlay, facts)` | Each transition's `taken`, `not-taken` or `indeterminate`. | +| `deriveStatuses(model, overlay, facts)` | The `Status` of every element. | +| `readySet(model, overlay, facts)` | The elements that are `ready`. | +| `blockedBy(model, overlay, facts, id)` | The open leaves an element is waiting on. | +| `impact(model, overlay, facts, id)` | The live elements that transitively depend on `id`. | +| `explainStatus(model, overlay, facts, id)` | The rule that decided `id`'s status, what it was decided from, and the blocked-by chain. | +| `whatIfDone(model, overlay, facts, id)` | The elements that become ready and every status change if `id` were done. | +| `criticalPath(model, overlay, facts, weight)` | The heaviest chain through the work still ahead. | +| `unmetOutputs(model, overlay, facts)` | Each deliverable not yet produced, with the elements and missing inputs it still needs. | Every function that derives status throws `CycleError` (from `graph-dag`, carrying the cycles) when `illegalCycles` is not empty, `UnknownNodeError` when a transition names an element the model does not declare, and `UnsupportedConditionError` for a condition the facts port cannot serve. @@ -84,7 +85,9 @@ The `Facts` port is `(key: JsonValue) => Resolution`: an opaque key in, `{ found ## Queries -`blockedBy` starts at an element's unfinished needs and follows each one's own unfinished needs to the elements that wait on nothing further: the minimal set of open work to finish first. Cut transitions and provided artefacts are not followed. An element that is not blocked by anything returns an empty list. +`blockedBy` starts at an element's unfinished needs and follows each one's own unfinished needs to the elements that wait on nothing further: the minimal set of open work to finish first. Cut transitions and provided artefacts are not followed. An element that is not blocked by anything returns an empty list, and so does an element that is `done` or `skipped`, even one the overlay marked done ahead of its needs. + +`explainStatus` says why an element has its status. `rule` names the rule of the status table that decided it (`overlay-done`, `overlay-unknown`, `unknown-need`, `all-cut`, `needs-met` or `waiting`); `transitions` lists its incoming transitions other than loop edges, each with its branch, its source's status and whether it is cut; `artefacts` lists what it consumes, each with whether it is provided and its producers' statuses. `chain` is the blocked-by chain: the element, then every unfinished need reached from it, each with the needs it still waits on, so the links after the first that wait on nothing are exactly what `blockedBy` returns. The explanation is recorded by the derivation itself, so it never disagrees with `deriveStatuses`. `impact` follows live needs in reverse, so a branch that was not taken, a skipped element and an element already `done` carry no impact; `id` itself is not included. diff --git a/packages/core/graph/domain/src/derive.ts b/packages/core/graph/domain/src/derive.ts index a03b499..3ec7223 100644 --- a/packages/core/graph/domain/src/derive.ts +++ b/packages/core/graph/domain/src/derive.ts @@ -27,6 +27,68 @@ export interface Analysis { * For each element, the elements its progress still depends on: the sources of its incoming transitions that are not cut (the condition is not definitely false and the source is not skipped) and the producers of the artefacts it consumes that are neither provided nor produced already, except producers that are skipped. Includes elements that are done. */ readonly liveNeeds: ReadonlyMap; + /** For each element, what its status was derived from, recorded by the derivation itself so an explanation never re-derives it. */ + readonly basis: ReadonlyMap>; +} + +/** Which of the derivation's rules decided an element's status, in the order the rules are applied. */ +export const statusRules = [ + "overlay-done", + "overlay-unknown", + "unknown-need", + "all-cut", + "needs-met", + "waiting", +] as const; + +/** + * Which rule decided a status. + * + * - `overlay-done`: the overlay says `done`. + * - `overlay-unknown`: the overlay says `unknown`. + * - `unknown-need`: a live transition's condition is indeterminate or its source is `unknown`, or a consumed artefact that is not provided and has no done producer has an `unknown` producer. + * - `all-cut`: the element has incoming transitions and every one is cut, so it is `skipped`. + * - `needs-met`: every live need is done and every consumed artefact is satisfied, so it is `ready`, or `done` for a decision or pseudo-state. + * - `waiting`: anything else, so it is `blocked`. + */ +export type StatusRule = (typeof statusRules)[number]; + +/** An incoming transition of an element, as the derivation saw it. Loop edges are not incoming transitions. */ +export interface IncomingBasis { + readonly transition: I["transition"]; + readonly source: I["element"]; + /** What the transition's condition evaluated to. */ + readonly branch: Branch; + /** The derived status of the source. */ + readonly sourceStatus: Status; + /** Whether the transition is cut: its condition is definitely false or its source is skipped. A cut transition is not a need. */ + readonly cut: boolean; +} + +/** A producer of a consumed artefact and its derived status. */ +export interface ProducerBasis { + readonly element: I["element"]; + readonly status: Status; +} + +/** A consumed artefact of an activity, as the derivation saw it. */ +export interface ArtefactBasis { + readonly artefact: I["artefact"]; + /** Whether the facts port reports it provided from outside the process. */ + readonly provided: boolean; + /** The activities that produce it, in model order. Empty when none does. */ + readonly producers: readonly ProducerBasis[]; + /** Whether it is provided or a producer is done. */ + readonly satisfied: boolean; +} + +/** What one element's status was derived from. */ +export interface StatusBasis { + readonly rule: StatusRule; + /** The element's incoming transitions other than loop edges, in model order. */ + readonly transitions: readonly IncomingBasis[]; + /** The artefacts the element consumes, in the order it lists them; empty for anything but an activity. */ + readonly artefacts: readonly ArtefactBasis[]; } /** @@ -84,18 +146,26 @@ export const analyse = ({ const statuses = new Map(); const liveNeeds = new Map(); + const basis = new Map>(); for (const id of topologicalLayers(dag).layers.flat()) { const element = lookup(elements, id); - const edges = lookup(incoming, id).map((transitionId) => ({ - source: lookup(transitions, transitionId).source, - branch: lookup(branches, transitionId), - })); - const live = edges.filter( - ({ source, branch }) => - branch !== "not-taken" && lookup(statuses, source) !== "skipped", - ); + const edges = lookup(incoming, id).map((transitionId) => { + const source = lookup(transitions, transitionId).source; + const branch = lookup(branches, transitionId); + const sourceStatus = lookup(statuses, source); + + return { + transition: transitionId, + source, + branch, + sourceStatus, + cut: branch === "not-taken" || sourceStatus === "skipped", + }; + }); + const live = edges.filter(({ cut }) => !cut); const dataNeeds = new Set(); + const artefacts: ArtefactBasis[] = []; let dataUnknown = false; let dataUnmet = false; for (const artefact of element.kind === "activity" @@ -103,7 +173,18 @@ export const analyse = ({ : []) { const makers = producers.get(artefact) ?? []; const made = makers.map((maker) => lookup(statuses, maker)); - if (isProvided(facts, artefact) || made.includes("done")) { + const provided = isProvided(facts, artefact); + const satisfied = provided || made.includes("done"); + artefacts.push({ + artefact, + provided, + producers: makers.map((maker) => ({ + element: maker, + status: lookup(statuses, maker), + })), + satisfied, + }); + if (satisfied) { continue; } dataUnmet = true; @@ -120,31 +201,40 @@ export const analyse = ({ const own = overlay[id]; let status: Status; + let rule: StatusRule; if (own === "done") { status = "done"; + rule = "overlay-done"; + } else if (own === "unknown") { + status = "unknown"; + rule = "overlay-unknown"; } else if ( - own === "unknown" || dataUnknown || live.some( - ({ source, branch }) => - branch === "indeterminate" || lookup(statuses, source) === "unknown", + ({ branch, sourceStatus }) => + branch === "indeterminate" || sourceStatus === "unknown", ) ) { status = "unknown"; + rule = "unknown-need"; } else if (edges.length > 0 && live.length === 0) { status = "skipped"; + rule = "all-cut"; } else if ( !dataUnmet && - live.every(({ source }) => lookup(statuses, source) === "done") + live.every(({ sourceStatus }) => sourceStatus === "done") ) { status = element.kind === "decision" || element.kind === "pseudo" ? "done" : "ready"; + rule = "needs-met"; } else { status = "blocked"; + rule = "waiting"; } statuses.set(id, status); + basis.set(id, { rule, transitions: edges, artefacts }); } return { @@ -152,6 +242,7 @@ export const analyse = ({ branches, statuses: new Map(dag.nodes.map((id) => [id, lookup(statuses, id)])), liveNeeds, + basis, }; }; diff --git a/packages/core/graph/domain/src/explain.ts b/packages/core/graph/domain/src/explain.ts new file mode 100644 index 0000000..a680824 --- /dev/null +++ b/packages/core/graph/domain/src/explain.ts @@ -0,0 +1,86 @@ +import type { IdSpace, Model, Overlay } from "@exadev/process-graph-schema"; +import { + analyse, + type ArtefactBasis, + type IncomingBasis, + type StatusRule, +} from "./derive.ts"; +import type { Facts } from "./facts.ts"; +import { lookup } from "./lookup.ts"; +import { waitingOn } from "./progress.ts"; +import type { Status } from "./status.ts"; + +/** One element of a blocked-by chain and the unfinished needs it still waits on. */ +export interface ChainLink { + readonly element: I["element"]; + readonly status: Status; + /** The element's live needs that are not done, in model order. Empty for the open work at the end of the chain. */ + readonly waitingOn: readonly I["element"][]; +} + +/** Why an element has the status it has. */ +export interface StatusExplanation { + readonly element: I["element"]; + readonly status: Status; + /** The rule that decided the status. */ + readonly rule: StatusRule; + /** The element's incoming transitions other than loop edges, each with its branch, its source's status and whether it is cut, in model order. */ + readonly transitions: readonly IncomingBasis[]; + /** The artefacts the element consumes, each with whether it is provided and its producers' statuses. */ + readonly artefacts: readonly ArtefactBasis[]; + /** + * The blocked-by chain: the element, then every unfinished need reached from it by following unfinished needs, in model order. The links after the first that wait on nothing are the elements `blockedBy` returns. A done or skipped element waits on nothing, so its chain is the element alone. + */ + readonly chain: readonly ChainLink[]; +} + +/** + * Explains an element's status: the rule that decided it, the transitions and artefacts it was decided from, and the chain of unfinished work behind it. The explanation is the derivation's own record, so it always agrees with `deriveStatuses` and `blockedBy`. + * @param model - The model to read. + * @param overlay - Run state by element id. + * @param facts - The injected source of facts conditions read, and of which input artefacts are provided. + * @param id - The element asked about. + * @returns The explanation. + * @throws UnknownNodeError when `id` is not an element. + * @throws CycleError when the control graph has an illegal cycle. + */ +export const explainStatus = ( + model: Model, + overlay: Readonly>, + facts: Facts, + id: I["element"], +): StatusExplanation => { + const analysis = analyse({ model, overlay, facts }); + const basis = lookup(analysis.basis, id); + const reached = new Map(); + const pending = [id]; + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + if (reached.has(next)) { + continue; + } + const needs = waitingOn(analysis, next); + reached.set(next, needs); + pending.push(...needs); + } + const link = (element: I["element"]): ChainLink => ({ + element, + status: lookup(analysis.statuses, element), + waitingOn: analysis.dag.nodes.filter((node) => + lookup(reached, element).includes(node), + ), + }); + + return { + element: id, + status: lookup(analysis.statuses, id), + rule: basis.rule, + transitions: basis.transitions, + artefacts: basis.artefacts, + chain: [ + link(id), + ...analysis.dag.nodes + .filter((node) => node !== id && reached.has(node)) + .map(link), + ], + }; +}; diff --git a/packages/core/graph/domain/src/explain.unit.test.ts b/packages/core/graph/domain/src/explain.unit.test.ts new file mode 100644 index 0000000..19f24f5 --- /dev/null +++ b/packages/core/graph/domain/src/explain.unit.test.ts @@ -0,0 +1,135 @@ +import { UnknownNodeError } from "@exadev/process-graph-dag"; +import type { OpenIds } from "@exadev/process-graph-schema"; +import { describe, expect, it } from "vitest"; +import { deriveStatuses } from "./derive.ts"; +import { explainStatus } from "./explain.ts"; +import { blockedBy } from "./queries.ts"; +import { factsOf, noFacts } from "./test-support/conditions.ts"; +import { approval, chain, diamond, pipeline } from "./test-support/models.ts"; + +describe("explainStatus", () => { + it("names the overlay when it decided the status", () => { + expect(explainStatus(chain, { b: "done" }, noFacts, "b")).toMatchObject({ + element: "b", + status: "done", + rule: "overlay-done", + chain: [{ element: "b", status: "done", waitingOn: [] }], + }); + expect(explainStatus(chain, { a: "unknown" }, noFacts, "a")).toMatchObject({ + status: "unknown", + rule: "overlay-unknown", + }); + }); + + it("gives the whole chain of unfinished work behind a blocked element", () => { + expect(explainStatus(chain, {}, noFacts, "c")).toEqual({ + element: "c", + status: "blocked", + rule: "waiting", + transitions: [ + { + transition: "bc", + source: "b", + branch: "taken", + sourceStatus: "blocked", + cut: false, + }, + ], + artefacts: [], + chain: [ + { element: "c", status: "blocked", waitingOn: ["b"] }, + { element: "a", status: "ready", waitingOn: [] }, + { element: "b", status: "blocked", waitingOn: ["a"] }, + ], + }); + }); + + it("branches the chain at a join and agrees with blockedBy on its ends", () => { + const explained = explainStatus(diamond, { a: "done" }, noFacts, "d"); + + expect(explained.chain).toEqual([ + { element: "d", status: "blocked", waitingOn: ["b", "c"] }, + { element: "b", status: "ready", waitingOn: [] }, + { element: "c", status: "ready", waitingOn: [] }, + ]); + expect( + explained.chain + .slice(1) + .filter((link) => link.waitingOn.length === 0) + .map((link) => link.element), + ).toEqual(blockedBy(diamond, { a: "done" }, noFacts, "d")); + }); + + it("explains a ready element as having its needs met", () => { + expect(explainStatus(chain, { a: "done" }, noFacts, "b")).toMatchObject({ + status: "ready", + rule: "needs-met", + transitions: [{ source: "a", sourceStatus: "done", cut: false }], + chain: [{ element: "b", waitingOn: [] }], + }); + }); + + it("explains a skipped element by its cut transitions", () => { + expect( + explainStatus( + approval, + { triage: "done" }, + factsOf({ approved: false }), + "accept", + ), + ).toMatchObject({ + status: "skipped", + rule: "all-cut", + transitions: [ + { + transition: "t-accept", + source: "choose", + branch: "not-taken", + sourceStatus: "done", + cut: true, + }, + ], + }); + }); + + it("explains an unknown element by its indeterminate condition", () => { + expect( + explainStatus(approval, { triage: "done" }, noFacts, "accept"), + ).toMatchObject({ + status: "unknown", + rule: "unknown-need", + transitions: [{ transition: "t-accept", branch: "indeterminate" }], + }); + }); + + it("reports each consumed artefact with whether it is satisfied", () => { + expect(explainStatus(pipeline, {}, noFacts, "build")).toMatchObject({ + status: "blocked", + rule: "waiting", + artefacts: [ + { artefact: "spec", provided: false, producers: [], satisfied: false }, + ], + }); + expect( + explainStatus(pipeline, {}, factsOf({}, ["spec"]), "build"), + ).toMatchObject({ + status: "ready", + rule: "needs-met", + artefacts: [{ artefact: "spec", provided: true, satisfied: true }], + }); + }); + + it("agrees with deriveStatuses on every element", () => { + const overlay = { triage: "done" } as const; + const facts = factsOf({ approved: true }); + for (const [id, status] of deriveStatuses(approval, overlay, facts)) { + expect(explainStatus(approval, overlay, facts, id).status).toBe(status); + } + }); + + it("throws for an element the model does not declare", () => { + expect(() => explainStatus(chain, {}, noFacts, "z")).toThrow( + UnknownNodeError, + ); + }); +}); diff --git a/packages/core/graph/domain/src/index.ts b/packages/core/graph/domain/src/index.ts index 193838a..b182c86 100644 --- a/packages/core/graph/domain/src/index.ts +++ b/packages/core/graph/domain/src/index.ts @@ -10,7 +10,20 @@ export { type Facts, } from "./facts.ts"; export { controlDag, illegalCycles, loopTransitions } from "./control.ts"; -export { deriveStatuses } from "./derive.ts"; +export { + deriveStatuses, + statusRules, + type ArtefactBasis, + type IncomingBasis, + type ProducerBasis, + type StatusBasis, + type StatusRule, +} from "./derive.ts"; +export { + explainStatus, + type ChainLink, + type StatusExplanation, +} from "./explain.ts"; export { blockedBy, criticalPath, diff --git a/packages/core/graph/domain/src/progress.ts b/packages/core/graph/domain/src/progress.ts new file mode 100644 index 0000000..8aaff69 --- /dev/null +++ b/packages/core/graph/domain/src/progress.ts @@ -0,0 +1,19 @@ +import type { IdSpace } from "@exadev/process-graph-schema"; +import type { Analysis } from "./derive.ts"; +import { lookup } from "./lookup.ts"; +import type { Status } from "./status.ts"; + +/** Whether an element still has work ahead of it: it is neither finished nor going to be skipped. */ +export const isLive = (status: Status): boolean => + status !== "done" && status !== "skipped"; + +/** The live needs of an element that are not done: what it is still waiting for. A finished or skipped element waits on nothing, even when the overlay marked it done ahead of its needs or it consumes an artefact still to be made. */ +export const waitingOn = ( + analysis: Analysis, + id: I["element"], +): readonly I["element"][] => + isLive(lookup(analysis.statuses, id)) + ? lookup(analysis.liveNeeds, id).filter( + (need) => lookup(analysis.statuses, need) !== "done", + ) + : []; diff --git a/packages/core/graph/domain/src/queries.ts b/packages/core/graph/domain/src/queries.ts index d3aa667..7b95215 100644 --- a/packages/core/graph/domain/src/queries.ts +++ b/packages/core/graph/domain/src/queries.ts @@ -4,24 +4,12 @@ import { type CriticalPath, } from "@exadev/process-graph-dag"; import type { IdSpace, Model, Overlay } from "@exadev/process-graph-schema"; -import { analyse, type Analysis } from "./derive.ts"; +import { analyse } from "./derive.ts"; import { isProvided, type Facts } from "./facts.ts"; import { lookup } from "./lookup.ts"; +import { isLive, waitingOn } from "./progress.ts"; import type { Status } from "./status.ts"; -/** Whether an element still has work ahead of it: it is neither finished nor going to be skipped. */ -const isLive = (status: Status): boolean => - status !== "done" && status !== "skipped"; - -/** The live needs of an element that are not done: what it is still waiting for. */ -const waitingOn = ( - analysis: Analysis, - id: I["element"], -): readonly I["element"][] => - lookup(analysis.liveNeeds, id).filter( - (need) => lookup(analysis.statuses, need) !== "done", - ); - /** * The elements that can be worked on now: every element whose status is `ready`, in model order. Pure control elements (decisions and pseudo-states) are derived `done` and never appear. * @param model - The model to read. diff --git a/packages/core/graph/domain/src/queries.unit.test.ts b/packages/core/graph/domain/src/queries.unit.test.ts index 7a3e312..78569a2 100644 --- a/packages/core/graph/domain/src/queries.unit.test.ts +++ b/packages/core/graph/domain/src/queries.unit.test.ts @@ -1,3 +1,4 @@ +import { defineModel } from "@exadev/process-graph-schema"; import { describe, expect, it } from "vitest"; import { blockedBy, @@ -6,7 +7,7 @@ import { unmetOutputs, whatIfDone, } from "./queries.ts"; -import { factsOf, noFacts } from "./test-support/conditions.ts"; +import { factsOf, holds, noFacts } from "./test-support/conditions.ts"; import { approval, chain, diamond, pipeline } from "./test-support/models.ts"; describe("blockedBy", () => { @@ -25,6 +26,50 @@ describe("blockedBy", () => { expect(blockedBy(chain, { a: "done" }, noFacts, "a")).toEqual([]); }); + it("is empty for an element marked done while its needs are still open", () => { + expect(blockedBy(chain, { c: "done" }, noFacts, "c")).toEqual([]); + }); + + it("is empty for a skipped element, even one consuming an artefact still to be made", () => { + const skippedConsumer = defineModel({ + participants: [{ id: "team", label: "Team", kind: "role" }], + events: [], + actions: [], + artefacts: [{ id: "spec", name: "Spec" }], + elements: [ + { kind: "decision", id: "choose", label: "Build?", actor: "team" }, + { + kind: "activity", + id: "build", + label: "Build", + actor: "team", + consumes: ["spec"], + }, + { + kind: "activity", + id: "write", + label: "Write", + actor: "team", + produces: ["spec"], + }, + ], + transitions: [ + { + id: "t-build", + source: "choose", + target: "build", + condition: holds("wanted"), + }, + ], + fragments: [], + projections: [], + }); + + expect( + blockedBy(skippedConsumer, {}, factsOf({ wanted: false }), "build"), + ).toEqual([]); + }); + it("names an element whose state is unknown as the leaf", () => { expect(blockedBy(chain, { a: "unknown" }, noFacts, "c")).toEqual(["a"]); }); diff --git a/packages/targets/graph-cli/AGENTS.md b/packages/targets/graph-cli/AGENTS.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/targets/graph-cli/AGENTS.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/targets/graph-cli/CLAUDE.md b/packages/targets/graph-cli/CLAUDE.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/targets/graph-cli/CLAUDE.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/targets/graph-cli/README.md b/packages/targets/graph-cli/README.md new file mode 100644 index 0000000..a1fcdbf --- /dev/null +++ b/packages/targets/graph-cli/README.md @@ -0,0 +1,101 @@ +# @exadev/process-graph-cli + +[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/process-graph) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/@exadev/process-graph-cli) [![Release](https://img.shields.io/github/v/release/ExaDev/process-graph)](https://github.com/ExaDev/process-graph/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/process-graph/ci.yml?branch=main)](https://github.com/ExaDev/process-graph/actions) + +The process-graph command line, and the same commands as a library. It syncs work from GitHub issues into a SQLite store, answers what is ready, blocked, affected, on the critical path or handed off between owners, explains why an element has its status, builds and edits a model by hand, and draws the model as a flowchart, swimlane, state machine or sequence diagram in Mermaid or Graphviz DOT. + +It is a composition root and nothing else: the model, reasoning, projections, sources and store are the other packages of this repository, and this package wires them to a command line through injected ports. The unscoped [`process-graph`](../process-graph/README.md) package carries the same command line as its `process-graph` bin; this package's own bin is `process-graph-cli`. + +```sh +process-graph sync github:ExaDev github:Mearman/agent-permissions +process-graph ready +process-graph why github:ExaDev/process-graph#12 +process-graph show --view swimlane --format mermaid +``` + +## Commands + +| Command | What it does | +| ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `sync [--after ]` | Reads the scopes from their sources and merges them into the model in one write; prints what it wrote and any gaps. | +| `ready` | The work that can start now. | +| `blocked ` | The open work an element is waiting on: the ends of its blocked-by chain. | +| `impact ` | The live work that depends on an element. | +| `path` | The critical path: the longest chain of work still ahead, each element counting one step. | +| `cycles` | The cycles no decision closes. | +| `handoffs` | The transitions from one actor's work to another's, such as a dependency across repositories or organisations. | +| `why ` | The rule that decided an element's status, its needs and artefacts, and the blocked-by chain behind it. | +| `show [--view ] [--format ]` | Draws the model. Views: `flowchart` (default), `swimlane`, `state`, `sequence`. Formats: `mermaid` (default), `dot`, `json` (the projection itself). | +| `add participant