process-graph 1.0.5 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -17
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +92 -0
- package/dist/cli.d.ts +512 -0
- package/dist/cli.js +3 -0
- package/dist/control-aDk9560d.js +294 -0
- package/dist/detail-panel-B1ZmMuJT.js +341 -0
- package/dist/dist-BpCBmnMl.js +582 -0
- package/dist/dist-HLlW8vgT.js +1388 -0
- package/dist/dist-IjrgWhHQ.js +3 -0
- package/dist/errors-BZe5WzCn.js +19 -0
- package/dist/exit-codes-0ultCqdS.js +23 -0
- package/dist/graphql-C1j5wQFz.d.ts +631 -0
- package/dist/index-CCfe0csl.d.ts +353 -0
- package/dist/index-CmZOswkx.d.ts +512 -0
- package/dist/index-CoA6vXqe.d.ts +404 -0
- package/dist/index-CptUHq6F.d.ts +58 -0
- package/dist/index-CrM5qWUA.d.ts +110 -0
- package/dist/index-DhbvDgK4.d.ts +1582 -0
- package/dist/index-DricKcyl.d.ts +421 -0
- package/dist/index.d.ts +111 -4
- package/dist/index.js +929 -5
- package/dist/layout-CCM-KBdp.js +307 -0
- package/dist/layout.d.ts +8 -0
- package/dist/layout.js +2 -0
- package/dist/node-AchI1T-K.js +40 -0
- package/dist/open-CyRXJ4bt.js +29 -0
- package/dist/parse-CuipC_2K.js +41 -0
- package/dist/queries-CCexJAE2.js +2202 -0
- package/dist/react-flow/style.css +658 -0
- package/dist/react-flow.d.ts +173 -0
- package/dist/react-flow.js +450 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +107 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/router-D4gtTlXm.js +188 -0
- package/dist/schema-CVnS6HiX.js +234 -0
- package/dist/selection-BW_xXWOy.js +314 -0
- package/dist/source/github/node.d.ts +16 -0
- package/dist/source/github/node.js +2 -0
- package/dist/source/github.d.ts +49 -0
- package/dist/source/github.js +201 -0
- package/dist/source-DRDpEA8Q.js +1316 -0
- package/dist/status-Bm1CGu9C.js +11 -0
- package/dist/store/node.d.ts +24 -0
- package/dist/store/node.js +2 -0
- package/dist/store/wasm/worker.d.ts +16 -0
- package/dist/store/wasm/worker.js +98 -0
- package/dist/store/wasm.d.ts +9 -0
- package/dist/store/wasm.js +11 -0
- package/dist/store-BcR70CaO.js +1287 -0
- package/dist/store.d.ts +1328 -0
- package/dist/store.js +238 -0
- package/dist/ui/style.css +351 -0
- package/dist/ui.d.ts +235 -0
- package/dist/ui.js +2 -0
- package/dist/validate-DF3mQZww.js +125 -0
- package/package.json +148 -13
|
@@ -0,0 +1,1388 @@
|
|
|
1
|
+
import { a as validateOverlay, d as overlayStatuses, f as participantKinds } from "./schema-CVnS6HiX.js";
|
|
2
|
+
import { t as validate } from "./validate-DF3mQZww.js";
|
|
3
|
+
import { n as parseModel, t as ModelParseError } from "./parse-CuipC_2K.js";
|
|
4
|
+
import { _ as toSwimlane, b as handoffs, c as deriveStatuses, g as toStateMachine, i as readySet, m as toSequence, n as criticalPath, r as impact, s as explainStatus, t as blockedBy, y as toFlowchart } from "./queries-CCexJAE2.js";
|
|
5
|
+
import { f as CycleError, n as illegalCycles, p as UnknownNodeError } from "./control-aDk9560d.js";
|
|
6
|
+
import { l as assembleSync, t as createGitHubIssuesSource, u as createGraphSourceRouter } from "./source-DRDpEA8Q.js";
|
|
7
|
+
import { t as githubTokenFromEnvironment } from "./node-AchI1T-K.js";
|
|
8
|
+
import { f as emptyModel } from "./store-BcR70CaO.js";
|
|
9
|
+
import { t as createGraphStoreRouter } from "./router-D4gtTlXm.js";
|
|
10
|
+
import { t as openNodeGraphStore } from "./open-CyRXJ4bt.js";
|
|
11
|
+
import { t as exitCodes } from "./exit-codes-0ultCqdS.js";
|
|
12
|
+
import { ORPCError, createRouterClient } from "@orpc/server";
|
|
13
|
+
import { Argument, Command, CommanderError, Option } from "commander";
|
|
14
|
+
import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, statSync } from "node:fs";
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
//#region ../graph-cli/dist/errors.js
|
|
19
|
+
/** A failure the command line reports with its own exit status and message, and nothing else. */
|
|
20
|
+
var CliError = class extends Error {
|
|
21
|
+
/** The exit status the failure maps to. */
|
|
22
|
+
exitCode;
|
|
23
|
+
/**
|
|
24
|
+
* @param exitCode - The exit status to report.
|
|
25
|
+
* @param message - What went wrong, as one line for standard error.
|
|
26
|
+
* @param options - The underlying error, kept as the cause.
|
|
27
|
+
*/
|
|
28
|
+
constructor(exitCode, message, options) {
|
|
29
|
+
super(message, options);
|
|
30
|
+
this.name = "CliError";
|
|
31
|
+
this.exitCode = exitCode;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
/** The store's codes for a missing model or element and for a change that would leave the model invalid. */
|
|
35
|
+
const notFoundCode = "NOT_FOUND";
|
|
36
|
+
const unprocessableCode = "UNPROCESSABLE_CONTENT";
|
|
37
|
+
/** The codes a source answers with when it refuses a sync. */
|
|
38
|
+
const sourceRefusalCodes = /* @__PURE__ */ new Set([
|
|
39
|
+
"UNAUTHORIZED",
|
|
40
|
+
"TOO_MANY_REQUESTS",
|
|
41
|
+
"BAD_REQUEST"
|
|
42
|
+
]);
|
|
43
|
+
/** The issues an `UNPROCESSABLE_CONTENT` error carries, each as `code: message`, or nothing when its data has no issues. */
|
|
44
|
+
const issueLines = (data) => {
|
|
45
|
+
if (typeof data !== "object" || data === null || !("issues" in data) || !Array.isArray(data.issues)) return [];
|
|
46
|
+
return data.issues.flatMap((issue) => typeof issue === "object" && issue !== null && "code" in issue && "message" in issue && typeof issue.code === "string" && typeof issue.message === "string" ? [`${issue.code}: ${issue.message}`] : []);
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Classifies anything a command threw into its exit status and the message for standard error. A commander usage error has already printed its own message, so it is reported with an empty one.
|
|
50
|
+
* @param error - What was thrown.
|
|
51
|
+
* @returns The exit status and message.
|
|
52
|
+
*/
|
|
53
|
+
const failureOf = (error) => {
|
|
54
|
+
if (error instanceof CliError) return {
|
|
55
|
+
exitCode: error.exitCode,
|
|
56
|
+
message: error.message
|
|
57
|
+
};
|
|
58
|
+
if (error instanceof CommanderError) return {
|
|
59
|
+
exitCode: error.exitCode === exitCodes.success ? exitCodes.success : exitCodes.usage,
|
|
60
|
+
message: ""
|
|
61
|
+
};
|
|
62
|
+
if (error instanceof CycleError) return {
|
|
63
|
+
exitCode: exitCodes.invalidModel,
|
|
64
|
+
message: `${error.message}; run cycles to list them`
|
|
65
|
+
};
|
|
66
|
+
if (error instanceof UnknownNodeError) return {
|
|
67
|
+
exitCode: exitCodes.notFound,
|
|
68
|
+
message: error.message
|
|
69
|
+
};
|
|
70
|
+
if (error instanceof ModelParseError) return {
|
|
71
|
+
exitCode: exitCodes.invalidModel,
|
|
72
|
+
message: error.message
|
|
73
|
+
};
|
|
74
|
+
if (error instanceof ORPCError) {
|
|
75
|
+
const code = error.code;
|
|
76
|
+
const data = error.data;
|
|
77
|
+
if (code === notFoundCode) return {
|
|
78
|
+
exitCode: exitCodes.notFound,
|
|
79
|
+
message: error.message
|
|
80
|
+
};
|
|
81
|
+
if (code === unprocessableCode) return {
|
|
82
|
+
exitCode: exitCodes.invalidModel,
|
|
83
|
+
message: [error.message, ...issueLines(data)].join("\n")
|
|
84
|
+
};
|
|
85
|
+
if (typeof code === "string" && sourceRefusalCodes.has(code)) return {
|
|
86
|
+
exitCode: exitCodes.sourceFailure,
|
|
87
|
+
message: error.message
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
exitCode: exitCodes.failure,
|
|
92
|
+
message: error instanceof Error ? error.message : String(error)
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region ../graph-cli/dist/session.js
|
|
97
|
+
/**
|
|
98
|
+
* The oRPC client options carrying the session's signal. The key is left out when there is no signal, since the options type does not accept an explicit `undefined`.
|
|
99
|
+
* @param signal - The signal, if any.
|
|
100
|
+
*/
|
|
101
|
+
const callOptions = (signal) => signal === void 0 ? {} : { signal };
|
|
102
|
+
/**
|
|
103
|
+
* Reads the session's model and run state, if the store has the model.
|
|
104
|
+
* @param session - The store and model.
|
|
105
|
+
* @returns The stored run, or `undefined` when the store has no such model.
|
|
106
|
+
*/
|
|
107
|
+
const findRun = async (session) => {
|
|
108
|
+
try {
|
|
109
|
+
const stored = await session.store.v1.models.load({ modelId: session.modelId }, callOptions(session.signal));
|
|
110
|
+
return {
|
|
111
|
+
revision: stored.revision,
|
|
112
|
+
model: stored.model,
|
|
113
|
+
overlay: stored.overlay
|
|
114
|
+
};
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error instanceof ORPCError && error.code === "NOT_FOUND") return;
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Loads the session's model and run state.
|
|
122
|
+
* @param session - The store and model.
|
|
123
|
+
* @returns The stored run.
|
|
124
|
+
* @throws CliError with `notFound` when the store has no such model.
|
|
125
|
+
*/
|
|
126
|
+
const loadRun = async (session) => {
|
|
127
|
+
const run = await findRun(session);
|
|
128
|
+
if (run === void 0) throw new CliError(exitCodes.notFound, `no model "${session.modelId}" in the store; sync or add something first`);
|
|
129
|
+
return run;
|
|
130
|
+
};
|
|
131
|
+
/** The label an element is shown with: its own label, or for a pseudo-state, its role. */
|
|
132
|
+
const labelOf = (element) => element.kind === "pseudo" ? element.role : element.label;
|
|
133
|
+
/**
|
|
134
|
+
* Looks an element up by identifier.
|
|
135
|
+
* @param model - The model to read.
|
|
136
|
+
* @param id - The element asked about.
|
|
137
|
+
* @returns The element.
|
|
138
|
+
* @throws CliError with `notFound` when the model declares no such element.
|
|
139
|
+
*/
|
|
140
|
+
const requireElement = (model, id) => {
|
|
141
|
+
const element = model.elements.find((candidate) => candidate.id === id);
|
|
142
|
+
if (element === void 0) throw new CliError(exitCodes.notFound, `no element "${id}" in the model`);
|
|
143
|
+
return element;
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Summaries of elements, in the order given.
|
|
147
|
+
* @param model - The model the identifiers belong to.
|
|
148
|
+
* @param ids - The element identifiers.
|
|
149
|
+
*/
|
|
150
|
+
const summarise = (model, ids) => ids.map((id) => ({
|
|
151
|
+
id,
|
|
152
|
+
label: labelOf(requireElement(model, id))
|
|
153
|
+
}));
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region ../graph-cli/dist/edit.js
|
|
156
|
+
/** The model with one record appended to the part it belongs to. */
|
|
157
|
+
const withAddition = (model, addition) => {
|
|
158
|
+
if (addition.kind === "participant") return {
|
|
159
|
+
...model,
|
|
160
|
+
participants: [...model.participants, addition.record]
|
|
161
|
+
};
|
|
162
|
+
if (addition.kind === "element") return {
|
|
163
|
+
...model,
|
|
164
|
+
elements: [...model.elements, addition.record]
|
|
165
|
+
};
|
|
166
|
+
if (addition.kind === "event") return {
|
|
167
|
+
...model,
|
|
168
|
+
events: [...model.events, addition.record]
|
|
169
|
+
};
|
|
170
|
+
if (addition.kind === "artefact") return {
|
|
171
|
+
...model,
|
|
172
|
+
artefacts: [...model.artefacts, addition.record]
|
|
173
|
+
};
|
|
174
|
+
return {
|
|
175
|
+
...model,
|
|
176
|
+
transitions: [...model.transitions, addition.record]
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* Adds a record to the session's model, creating the model when the store has none. The store checks the result, so a duplicate identifier or a reference to something undeclared is rejected and nothing changes.
|
|
181
|
+
*
|
|
182
|
+
* The model is read and then saved whole, so a write made by another process between the two is overwritten.
|
|
183
|
+
* @param session - The store and model.
|
|
184
|
+
* @param addition - The record to add.
|
|
185
|
+
* @returns The revision the save took.
|
|
186
|
+
*/
|
|
187
|
+
const add = async (session, addition) => {
|
|
188
|
+
const model = (await findRun(session))?.model ?? emptyModel;
|
|
189
|
+
return session.store.v1.models.save({
|
|
190
|
+
modelId: session.modelId,
|
|
191
|
+
model: withAddition(model, addition)
|
|
192
|
+
}, callOptions(session.signal));
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* Sets an element's run state, leaving every other element's as it is.
|
|
196
|
+
* @param session - The store and model.
|
|
197
|
+
* @param id - The element.
|
|
198
|
+
* @param state - `done`, `open` or `unknown`.
|
|
199
|
+
* @returns The element, its new state and the revision the change took.
|
|
200
|
+
* @throws CliError with `notFound` when the model or the element does not exist.
|
|
201
|
+
*/
|
|
202
|
+
const setState = async (session, id, state) => {
|
|
203
|
+
const { model } = await loadRun(session);
|
|
204
|
+
requireElement(model, id);
|
|
205
|
+
const { revision } = await session.store.v1.overlay.set({
|
|
206
|
+
modelId: session.modelId,
|
|
207
|
+
overlay: { [id]: state }
|
|
208
|
+
}, callOptions(session.signal));
|
|
209
|
+
return {
|
|
210
|
+
element: id,
|
|
211
|
+
state,
|
|
212
|
+
revision
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
//#endregion
|
|
216
|
+
//#region ../graph-cli/dist/queries.js
|
|
217
|
+
/** Each element counts as one step, so the critical path is the longest chain of elements. */
|
|
218
|
+
const stepWeight = 1;
|
|
219
|
+
/**
|
|
220
|
+
* The elements that are ready: not done, with every live need done.
|
|
221
|
+
* @param session - The store, model and facts.
|
|
222
|
+
*/
|
|
223
|
+
const ready = async (session) => {
|
|
224
|
+
const { model, overlay } = await loadRun(session);
|
|
225
|
+
return { elements: summarise(model, readySet(model, overlay, session.facts)) };
|
|
226
|
+
};
|
|
227
|
+
const statusOf = (statuses, summary) => {
|
|
228
|
+
const status = statuses.get(summary.id);
|
|
229
|
+
if (status === void 0) throw new Error(`no status was derived for "${summary.id}"`);
|
|
230
|
+
return {
|
|
231
|
+
...summary,
|
|
232
|
+
status
|
|
233
|
+
};
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* What an element is waiting on.
|
|
237
|
+
* @param session - The store, model and facts.
|
|
238
|
+
* @param id - The element asked about.
|
|
239
|
+
*/
|
|
240
|
+
const blocked = async (session, id) => {
|
|
241
|
+
const { model, overlay } = await loadRun(session);
|
|
242
|
+
const element = requireElement(model, id);
|
|
243
|
+
const statuses = deriveStatuses(model, overlay, session.facts);
|
|
244
|
+
return {
|
|
245
|
+
element: statusOf(statuses, {
|
|
246
|
+
id,
|
|
247
|
+
label: labelOf(element)
|
|
248
|
+
}),
|
|
249
|
+
blockedBy: summarise(model, blockedBy(model, overlay, session.facts, id))
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
/**
|
|
253
|
+
* What depends on an element.
|
|
254
|
+
* @param session - The store, model and facts.
|
|
255
|
+
* @param id - The element asked about.
|
|
256
|
+
*/
|
|
257
|
+
const impactOf = async (session, id) => {
|
|
258
|
+
const { model, overlay } = await loadRun(session);
|
|
259
|
+
const element = requireElement(model, id);
|
|
260
|
+
const statuses = deriveStatuses(model, overlay, session.facts);
|
|
261
|
+
return {
|
|
262
|
+
element: statusOf(statuses, {
|
|
263
|
+
id,
|
|
264
|
+
label: labelOf(element)
|
|
265
|
+
}),
|
|
266
|
+
impacted: summarise(model, impact(model, overlay, session.facts, id))
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* The critical path: the longest chain through the work still ahead.
|
|
271
|
+
* @param session - The store, model and facts.
|
|
272
|
+
*/
|
|
273
|
+
const path = async (session) => {
|
|
274
|
+
const { model, overlay } = await loadRun(session);
|
|
275
|
+
const found = criticalPath(model, overlay, session.facts, () => stepWeight);
|
|
276
|
+
return {
|
|
277
|
+
path: summarise(model, found.path),
|
|
278
|
+
weight: found.weight
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
/**
|
|
282
|
+
* The cycles of the model that no decision closes, which make every status undefined.
|
|
283
|
+
* @param session - The store and model.
|
|
284
|
+
*/
|
|
285
|
+
const cycles = async (session) => {
|
|
286
|
+
const { model } = await loadRun(session);
|
|
287
|
+
return { cycles: illegalCycles(model) };
|
|
288
|
+
};
|
|
289
|
+
/**
|
|
290
|
+
* The hand-offs: transitions from one actor's work to another's.
|
|
291
|
+
* @param session - The store and model.
|
|
292
|
+
*/
|
|
293
|
+
const handoffsOf = async (session) => {
|
|
294
|
+
const { model } = await loadRun(session);
|
|
295
|
+
return { handoffs: handoffs(model) };
|
|
296
|
+
};
|
|
297
|
+
/**
|
|
298
|
+
* Why an element has its status, and the chain of unfinished work behind it.
|
|
299
|
+
* @param session - The store, model and facts.
|
|
300
|
+
* @param id - The element asked about.
|
|
301
|
+
*/
|
|
302
|
+
const why = async (session, id) => {
|
|
303
|
+
const { model, overlay } = await loadRun(session);
|
|
304
|
+
requireElement(model, id);
|
|
305
|
+
const explained = explainStatus(model, overlay, session.facts, id);
|
|
306
|
+
const chain = explained.chain.map((link) => ({
|
|
307
|
+
id: link.element,
|
|
308
|
+
label: labelOf(requireElement(model, link.element)),
|
|
309
|
+
status: link.status,
|
|
310
|
+
waitingOn: link.waitingOn
|
|
311
|
+
}));
|
|
312
|
+
const [first] = chain;
|
|
313
|
+
if (first === void 0) throw new Error(`the explanation of "${id}" has no chain`);
|
|
314
|
+
return {
|
|
315
|
+
element: {
|
|
316
|
+
id: first.id,
|
|
317
|
+
label: first.label,
|
|
318
|
+
status: first.status
|
|
319
|
+
},
|
|
320
|
+
rule: explained.rule,
|
|
321
|
+
transitions: explained.transitions,
|
|
322
|
+
artefacts: explained.artefacts,
|
|
323
|
+
chain
|
|
324
|
+
};
|
|
325
|
+
};
|
|
326
|
+
//#endregion
|
|
327
|
+
//#region ../graph-cli/dist/render.js
|
|
328
|
+
/**
|
|
329
|
+
* Text renderings of each command's result. Lists are one item per line with tab-separated fields, so they cut, sort and grep cleanly, and an empty list prints nothing. Every rendering ends with a line break when it is not empty.
|
|
330
|
+
*/
|
|
331
|
+
const lines = (rows) => rows.length === 0 ? "" : `${rows.join("\n")}\n`;
|
|
332
|
+
const elementRows = (elements) => elements.map((element) => `${element.id}\t${element.label}`);
|
|
333
|
+
/** `ready`: each ready element's identifier and label. */
|
|
334
|
+
const renderReady = (result) => lines(elementRows(result.elements));
|
|
335
|
+
/** `blocked`: each element at the end of the blocked-by chain. */
|
|
336
|
+
const renderBlocked = (result) => lines(elementRows(result.blockedBy));
|
|
337
|
+
/** `impact`: each element that depends on the one asked about. */
|
|
338
|
+
const renderImpact = (result) => lines(elementRows(result.impacted));
|
|
339
|
+
/** `path`: the critical path, one element per line in dependency order. */
|
|
340
|
+
const renderPath = (result) => lines(elementRows(result.path));
|
|
341
|
+
/** `cycles`: each illegal cycle as a closed path. */
|
|
342
|
+
const renderCycles = (result) => lines(result.cycles.map((cycle) => cycle.join(" -> ")));
|
|
343
|
+
/** `handoffs`: each hand-off's transition and the actors it passes between. */
|
|
344
|
+
const renderHandoffs = (result) => lines(result.handoffs.map((handoff) => `${handoff.transition}\t${handoff.from} -> ${handoff.to}`));
|
|
345
|
+
/** The sentence that says why each rule gives its status. `needs-met` gives a decision or pseudo-state `done` rather than `ready`, and says so. */
|
|
346
|
+
const ruleSentences = {
|
|
347
|
+
"overlay-done": "its run state is done",
|
|
348
|
+
"overlay-unknown": "its run state is unknown",
|
|
349
|
+
"unknown-need": "something it needs is unknown, or a condition on the way to it cannot be decided",
|
|
350
|
+
"all-cut": "every transition into it is cut",
|
|
351
|
+
"needs-met": "everything it needs is done",
|
|
352
|
+
waiting: "it waits on work that is not done"
|
|
353
|
+
};
|
|
354
|
+
const ruleSentence = (rule, status) => rule === "needs-met" && status === "done" ? "it is a decision or pseudo-state and everything it needs is done" : ruleSentences[rule];
|
|
355
|
+
/** `why`: the status and the rule behind it, then its needs, its artefacts and the blocked-by chain, each section only when it has something in it. */
|
|
356
|
+
const renderWhy = (result) => {
|
|
357
|
+
const { element } = result;
|
|
358
|
+
const needs = result.transitions.map((transition) => {
|
|
359
|
+
const notes = [...transition.branch === "taken" ? [] : [`condition ${transition.branch}`], ...transition.cut ? ["cut"] : []];
|
|
360
|
+
return ` ${transition.source} (${transition.sourceStatus}) through ${transition.transition}${notes.length === 0 ? "" : `, ${notes.join(", ")}`}`;
|
|
361
|
+
});
|
|
362
|
+
const artefacts = result.artefacts.map((artefact) => {
|
|
363
|
+
const producers = artefact.producers.length === 0 ? "produced by nothing" : `produced by ${artefact.producers.map((producer) => `${producer.element} (${producer.status})`).join(", ")}`;
|
|
364
|
+
return ` ${artefact.artefact}: ${artefact.provided ? "provided" : "not provided"}, ${producers}`;
|
|
365
|
+
});
|
|
366
|
+
const chain = result.chain.length > 1 || result.chain.some((link) => link.waitingOn.length > 0) ? result.chain.map((link) => ` ${link.id} (${link.status})${link.waitingOn.length === 0 ? "" : ` waits on ${link.waitingOn.join(", ")}`}`) : [];
|
|
367
|
+
return lines([
|
|
368
|
+
`${element.id} is ${element.status}: ${ruleSentence(result.rule, element.status)}`,
|
|
369
|
+
...needs.length === 0 ? [] : ["needs:", ...needs],
|
|
370
|
+
...artefacts.length === 0 ? [] : ["artefacts:", ...artefacts],
|
|
371
|
+
...chain.length === 0 ? [] : ["blocked-by chain:", ...chain]
|
|
372
|
+
]);
|
|
373
|
+
};
|
|
374
|
+
/** `add`: what was added and the revision it took. */
|
|
375
|
+
const renderAdded = (kind, id, written) => lines([`added ${kind} ${id} at revision ${String(written.revision)}`]);
|
|
376
|
+
/** `set`: the element's new run state and the revision it took. */
|
|
377
|
+
const renderSet = (result) => lines([`set ${result.element} to ${result.state} at revision ${String(result.revision)}`]);
|
|
378
|
+
/** `sync`: what was written, then each gap as its reason, target and message. */
|
|
379
|
+
const renderSync = (result) => lines([`synced ${String(result.activities)} activities, ${String(result.transitions)} transitions and ${String(result.participants)} participants into "${result.modelId}" at revision ${String(result.revision)}`, ...result.gaps.map((gap) => `gap\t${gap.reason}\t${gap.target}\t${gap.message}`)]);
|
|
380
|
+
/** `validate`: `valid`, or each issue as its code, its JSON pointer when it has one, and its message. */
|
|
381
|
+
const renderValidate = (result) => result.valid ? lines(["valid"]) : lines(result.issues.map((issue) => issue.pointer === void 0 ? `${issue.code}\t${issue.message}` : `${issue.code}\t${issue.pointer === "" ? "/" : issue.pointer}\t${issue.message}`));
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region ../graph-cli/dist/terminal-text.js
|
|
384
|
+
/**
|
|
385
|
+
* The characters that must never reach a terminal as they are, by Unicode general category: controls (`Cc`, where ESC starts the sequences that clear the screen, move the cursor or, through OSC 52, set the clipboard, and the C1 controls some terminals read as the same sequences), format characters (`Cf`, which holds every bidirectional control, the invisible operators, the soft hyphen, the interlinear annotation characters and the tag characters, all of which reorder, hide or change what is shown), the line and paragraph separators (`Zl`, `Zp`), and the unassigned and private-use code points (`Cn`, `Co`), which no font shows reliably. Text from a source (an issue title, a label, an actor's name) can carry any of them. A category, not a list of ranges, so a character Unicode adds to one of them is covered without a change here.
|
|
386
|
+
*
|
|
387
|
+
* Three are allowed: line feed and tab, which every text output uses, and the zero-width non-joiner and joiner (U+200C, U+200D), formats that emoji sequences (a family, a profession) and scripts such as Persian, Hindi and Malayalam need to be written correctly, and that cannot reorder or hide text on their own.
|
|
388
|
+
*/
|
|
389
|
+
const unsafeCharacters = /[[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cn}\p{Co}]--[\n\t\u200C\u200D]]/gv;
|
|
390
|
+
/** Code points are written in hexadecimal. */
|
|
391
|
+
const hexadecimal = 16;
|
|
392
|
+
/** A JSON `\u` escape always has four hexadecimal digits. */
|
|
393
|
+
const jsonEscapeDigits = 4;
|
|
394
|
+
/**
|
|
395
|
+
* A character's code point.
|
|
396
|
+
* @param character - One character, as the unsafe-character pattern matches it.
|
|
397
|
+
* @returns Its code point.
|
|
398
|
+
* @throws Error when given an empty string, which the pattern never matches.
|
|
399
|
+
*/
|
|
400
|
+
const codePointOf = (character) => {
|
|
401
|
+
const point = character.codePointAt(0);
|
|
402
|
+
if (point === void 0) throw new Error("an unsafe character match was empty");
|
|
403
|
+
return point;
|
|
404
|
+
};
|
|
405
|
+
/**
|
|
406
|
+
* Text made safe to write to a terminal. Each unsafe character (see `unsafeCharacters`) is written as the visible escape `\u{<hex>}` of its code point, such as `\u{1b}` for ESC, `\u{202e}` for a right-to-left override or `\u{e0041}` for a tag character, so nothing is lost and nothing acts on the terminal. Line feed, tab, the zero-width joiners and every other character, accented letters, CJK, Arabic and emoji included, pass through. A literal `\u{...}` already in the text is left as it is, so the escaped form is for reading, not for reversing.
|
|
407
|
+
* @param text - The text as written.
|
|
408
|
+
* @returns The text with every unsafe character escaped.
|
|
409
|
+
*/
|
|
410
|
+
const terminalText = (text) => text.replaceAll(unsafeCharacters, (character) => `\\u{${codePointOf(character).toString(hexadecimal)}}`);
|
|
411
|
+
/**
|
|
412
|
+
* A value as JSON that is also safe to write to a terminal. `JSON.stringify` escapes the C0 controls but writes every other unsafe character (see `unsafeCharacters`) as it is; this writes those as `\u` escapes too, a surrogate pair for a code point beyond the Basic Multilingual Plane, which leaves the JSON, and every value parsed from it, exactly the same.
|
|
413
|
+
* @param value - The value to write.
|
|
414
|
+
* @returns The JSON text, indented by two spaces and ending in a line break.
|
|
415
|
+
*/
|
|
416
|
+
const terminalJson = (value) => `${JSON.stringify(value, null, 2).replaceAll(unsafeCharacters, (character) => character.split("").map((unit) => `\\u${unit.charCodeAt(0).toString(hexadecimal).padStart(jsonEscapeDigits, "0")}`).join(""))}\n`;
|
|
417
|
+
//#endregion
|
|
418
|
+
//#region ../graph-cli/dist/emit/text.js
|
|
419
|
+
/**
|
|
420
|
+
* The Mermaid entity code for a character that would end or change the meaning of a label: quotes, statement and edge-label delimiters, the entity marker itself and HTML.
|
|
421
|
+
*/
|
|
422
|
+
const mermaidEntity = (character) => {
|
|
423
|
+
switch (character) {
|
|
424
|
+
case "#": return "#35;";
|
|
425
|
+
case "\"": return "#quot;";
|
|
426
|
+
case "<": return "#lt;";
|
|
427
|
+
case ">": return "#gt;";
|
|
428
|
+
case "&": return "#amp;";
|
|
429
|
+
case ";": return "#59;";
|
|
430
|
+
case "|": return "#124;";
|
|
431
|
+
case ":": return "#58;";
|
|
432
|
+
default: return character;
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
/**
|
|
436
|
+
* Text made safe to place inside any Mermaid label: a line break becomes a space, every control and invisible format character is written as its visible `\u{<hex>}` escape (see `terminalText`), and every character that Mermaid reads as syntax is written as its entity code, so a label from a source (an issue title, a repository name) can never break the diagram or act on the terminal it is printed to.
|
|
437
|
+
* @param text - The label as written.
|
|
438
|
+
* @returns The label as Mermaid source.
|
|
439
|
+
*/
|
|
440
|
+
const mermaidText = (text) => terminalText(text.replaceAll(/[\r\n]/gu, " ")).replaceAll(/[#"<>&;|:]/gu, mermaidEntity);
|
|
441
|
+
/**
|
|
442
|
+
* A Graphviz DOT double-quoted string: line breaks written as DOT's own centred line break, every control and invisible format character as its visible `\u{<hex>}` escape (see `terminalText`), then backslashes, the escapes' included, and quotes escaped. Any identifier or label is valid DOT once quoted this way, and reads back as the escaped text.
|
|
443
|
+
* @param text - The text as written.
|
|
444
|
+
* @returns The quoted DOT string, quotes included.
|
|
445
|
+
*/
|
|
446
|
+
const dotString = (text) => `"${text.split(/\r?\n/u).map((line) => terminalText(line).replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")).join("\\n")}"`;
|
|
447
|
+
//#endregion
|
|
448
|
+
//#region ../graph-cli/dist/emit/mermaid.js
|
|
449
|
+
/**
|
|
450
|
+
* Model identifiers are free text (`github:org/repo#12`), which Mermaid does not accept as a node identifier, so every emitter names things by position in the view instead: a prefix and the index. The view's order is the model's order, so the same model always gives the same names.
|
|
451
|
+
*/
|
|
452
|
+
const positionalIds = (prefix, ids) => new Map(ids.map((id, index) => [id, `${prefix}${String(index)}`]));
|
|
453
|
+
/** Reads a name the emitter itself assigned, which exists for every identifier the view lists. */
|
|
454
|
+
const nameIn = (names, id) => {
|
|
455
|
+
const name = names.get(id);
|
|
456
|
+
if (name === void 0) throw new Error(`"${id}" is not part of the view being emitted`);
|
|
457
|
+
return name;
|
|
458
|
+
};
|
|
459
|
+
const indent$1 = " ";
|
|
460
|
+
/** Each flowchart shape in Mermaid's syntax around a node name and quoted label: a stadium for a terminator, a rectangle for a process, a rhombus for a decision and a fork bar for a fork or join. */
|
|
461
|
+
const mermaidShapes = {
|
|
462
|
+
terminator: (name, label) => `${name}(["${label}"])`,
|
|
463
|
+
process: (name, label) => `${name}["${label}"]`,
|
|
464
|
+
decision: (name, label) => `${name}{"${label}"}`,
|
|
465
|
+
bar: (name, label) => `${name}@{ shape: fork, label: "${label}" }`
|
|
466
|
+
};
|
|
467
|
+
const mermaidNode = (name, node) => mermaidShapes[node.shape](name, mermaidText(node.label));
|
|
468
|
+
/** The edges of a flowchart, a hand-off (an edge whose ends are in different lanes) drawn thick. */
|
|
469
|
+
const mermaidEdges = (view, names, isHandoff) => view.edges.map((edge) => {
|
|
470
|
+
const arrow = isHandoff(edge.from, edge.to) ? "==>" : "-->";
|
|
471
|
+
const label = edge.label === void 0 ? "" : `|"${mermaidText(edge.label)}"|`;
|
|
472
|
+
return `${indent$1}${nameIn(names, edge.from)} ${arrow}${label} ${nameIn(names, edge.to)}`;
|
|
473
|
+
});
|
|
474
|
+
/**
|
|
475
|
+
* A flowchart as a Mermaid `flowchart`, top to bottom. Nodes are declared in the view's order, then edges, each with its guard as the edge label.
|
|
476
|
+
* @param view - The projected flowchart.
|
|
477
|
+
* @returns Mermaid source, ending with a line break.
|
|
478
|
+
*/
|
|
479
|
+
const flowchartToMermaid = (view) => {
|
|
480
|
+
const names = positionalIds("n", view.nodes.map((node) => node.id));
|
|
481
|
+
return `${[
|
|
482
|
+
"flowchart TD",
|
|
483
|
+
...view.nodes.map((node) => `${indent$1}${mermaidNode(nameIn(names, node.id), node)}`),
|
|
484
|
+
...mermaidEdges(view, names, () => false)
|
|
485
|
+
].join("\n")}\n`;
|
|
486
|
+
};
|
|
487
|
+
/**
|
|
488
|
+
* A swimlane as a Mermaid `flowchart`, left to right, with one subgraph per lane in the view's lane order (an empty lane is an empty subgraph, so it stays visible) and nodes with no actor outside every lane. Hand-offs, the edges that change lane, are drawn thick.
|
|
489
|
+
* @param view - The projected swimlane.
|
|
490
|
+
* @returns Mermaid source, ending with a line break.
|
|
491
|
+
*/
|
|
492
|
+
const swimlaneToMermaid = (view) => {
|
|
493
|
+
const names = positionalIds("n", view.nodes.map((node) => node.id));
|
|
494
|
+
const nodes = new Map(view.nodes.map((node) => [node.id, node]));
|
|
495
|
+
const laneOf = /* @__PURE__ */ new Map();
|
|
496
|
+
view.lanes.forEach((lane, index) => {
|
|
497
|
+
for (const id of lane.nodes) laneOf.set(id, index);
|
|
498
|
+
});
|
|
499
|
+
const declare = (id, depth) => {
|
|
500
|
+
const node = nodes.get(id);
|
|
501
|
+
if (node === void 0) throw new Error(`lane member "${id}" is not a node of the view`);
|
|
502
|
+
return `${depth}${mermaidNode(nameIn(names, id), node)}`;
|
|
503
|
+
};
|
|
504
|
+
return `${[
|
|
505
|
+
"flowchart LR",
|
|
506
|
+
...view.lanes.flatMap((lane, index) => [
|
|
507
|
+
`${indent$1}subgraph lane${String(index)}["${mermaidText(lane.label)}"]`,
|
|
508
|
+
...lane.nodes.map((id) => declare(id, indent$1.repeat(2))),
|
|
509
|
+
`${indent$1}end`
|
|
510
|
+
]),
|
|
511
|
+
...view.unassigned.map((id) => declare(id, indent$1)),
|
|
512
|
+
...mermaidEdges(view, names, (from, to) => {
|
|
513
|
+
const fromLane = laneOf.get(from);
|
|
514
|
+
const toLane = laneOf.get(to);
|
|
515
|
+
return fromLane !== void 0 && toLane !== void 0 && fromLane !== toLane;
|
|
516
|
+
})
|
|
517
|
+
].join("\n")}\n`;
|
|
518
|
+
};
|
|
519
|
+
/** A state machine transition's label: its event, its guard in brackets and its effect after a slash, each only when present. */
|
|
520
|
+
const transitionLabel = (transition) => [
|
|
521
|
+
transition.event,
|
|
522
|
+
transition.guard === void 0 ? void 0 : `[${transition.guard}]`,
|
|
523
|
+
transition.effect === void 0 ? void 0 : `/ ${transition.effect}`
|
|
524
|
+
].filter((part) => part !== void 0).join(" ");
|
|
525
|
+
/**
|
|
526
|
+
* A state machine as a Mermaid `stateDiagram-v2`. Initial and final pseudo-states are Mermaid's `[*]`; every other state is declared with its label. Each transition carries its event, guard and effect.
|
|
527
|
+
* @param view - The projected state machine.
|
|
528
|
+
* @returns Mermaid source, ending with a line break.
|
|
529
|
+
*/
|
|
530
|
+
const stateMachineToMermaid = (view) => {
|
|
531
|
+
const drawn = view.states.filter((state) => state.role === "state");
|
|
532
|
+
const names = new Map([...positionalIds("s", drawn.map((state) => state.id)), ...view.states.filter((state) => state.role !== "state").map((state) => [state.id, "[*]"])]);
|
|
533
|
+
return `${[
|
|
534
|
+
"stateDiagram-v2",
|
|
535
|
+
...drawn.map((state) => `${indent$1}state "${mermaidText(state.label)}" as ${nameIn(names, state.id)}`),
|
|
536
|
+
...view.transitions.map((transition) => {
|
|
537
|
+
const label = transitionLabel(transition);
|
|
538
|
+
return `${indent$1}${nameIn(names, transition.source)} --> ${nameIn(names, transition.target)}${label === "" ? "" : ` : ${mermaidText(label)}`}`;
|
|
539
|
+
})
|
|
540
|
+
].join("\n")}\n`;
|
|
541
|
+
};
|
|
542
|
+
/** The Mermaid keyword that separates one operand of a fragment from the next, for the operators with more than one operand. */
|
|
543
|
+
const operandSeparator = (operator) => operator === "par" ? "and" : "else";
|
|
544
|
+
/**
|
|
545
|
+
* A sequence diagram as a Mermaid `sequenceDiagram`. Lifelines are declared in order of first appearance, labelled through `participantLabel`; messages follow in scenario order, wrapped in the fragments the projection drew (`alt` operands separated by `else`, `par` operands by `and`), each operand headed by its guard.
|
|
546
|
+
* @param view - The projected sequence diagram.
|
|
547
|
+
* @param participantLabel - The label of a lifeline's participant.
|
|
548
|
+
* @returns Mermaid source, ending with a line break.
|
|
549
|
+
*/
|
|
550
|
+
const sequenceToMermaid = (view, participantLabel) => {
|
|
551
|
+
const names = positionalIds("p", view.lifelines);
|
|
552
|
+
const items = (list, depth) => list.flatMap((item) => {
|
|
553
|
+
const prefix = indent$1.repeat(depth);
|
|
554
|
+
if (item.kind === "message") {
|
|
555
|
+
const { message } = item;
|
|
556
|
+
return [`${prefix}${nameIn(names, message.from)}->>${nameIn(names, message.to)}: ${mermaidText(message.label)}`];
|
|
557
|
+
}
|
|
558
|
+
return [...item.operands.flatMap((operand, index) => {
|
|
559
|
+
const keyword = index === 0 ? item.operator : operandSeparator(item.operator);
|
|
560
|
+
const guard = operand.guard === void 0 ? "" : ` ${mermaidText(operand.guard)}`;
|
|
561
|
+
return [`${prefix}${keyword}${guard}`, ...items(operand.items, depth + 1)];
|
|
562
|
+
}), `${prefix}end`];
|
|
563
|
+
});
|
|
564
|
+
return `${[
|
|
565
|
+
"sequenceDiagram",
|
|
566
|
+
...view.lifelines.map((lifeline) => `${indent$1}participant ${nameIn(names, lifeline)} as ${mermaidText(participantLabel(lifeline))}`),
|
|
567
|
+
...items(view.items, 1)
|
|
568
|
+
].join("\n")}\n`;
|
|
569
|
+
};
|
|
570
|
+
//#endregion
|
|
571
|
+
//#region ../graph-cli/dist/emit/dot.js
|
|
572
|
+
const indent = " ";
|
|
573
|
+
/** A DOT attribute list from name and value pairs, each value quoted; empty when there are none. */
|
|
574
|
+
const attributes = (pairs) => pairs.length === 0 ? "" : ` [${pairs.map(([name, value]) => `${name}=${dotString(value)}`).join(", ")}]`;
|
|
575
|
+
/** A flowchart node's DOT shape: an oval terminator, a box process, a diamond decision, and a filled bar for a fork or join. */
|
|
576
|
+
const nodeShapes = {
|
|
577
|
+
terminator: [["shape", "oval"]],
|
|
578
|
+
process: [["shape", "box"]],
|
|
579
|
+
decision: [["shape", "diamond"]],
|
|
580
|
+
bar: [
|
|
581
|
+
["shape", "box"],
|
|
582
|
+
["style", "filled"],
|
|
583
|
+
["fillcolor", "black"],
|
|
584
|
+
["fontcolor", "white"]
|
|
585
|
+
]
|
|
586
|
+
};
|
|
587
|
+
const dotNode = (node, depth) => `${depth}${dotString(node.id)}${attributes([["label", node.label], ...nodeShapes[node.shape]])};`;
|
|
588
|
+
/** The edges of a flowchart, a hand-off (an edge whose ends are in different lanes) drawn bold. */
|
|
589
|
+
const dotEdges = (view, isHandoff) => view.edges.map((edge) => `${indent}${dotString(edge.from)} -> ${dotString(edge.to)}${attributes([...edge.label === void 0 ? [] : [["label", edge.label]], ...isHandoff(edge.from, edge.to) ? [["style", "bold"]] : []])};`);
|
|
590
|
+
/**
|
|
591
|
+
* A flowchart as a Graphviz `digraph`, top to bottom. Nodes are named by their model identifiers, so the DOT source reads back to the model directly.
|
|
592
|
+
* @param view - The projected flowchart.
|
|
593
|
+
* @returns DOT source, ending with a line break.
|
|
594
|
+
*/
|
|
595
|
+
const flowchartToDot = (view) => `${[
|
|
596
|
+
`digraph ${dotString("flowchart")} {`,
|
|
597
|
+
`${indent}rankdir="TB";`,
|
|
598
|
+
...view.nodes.map((node) => dotNode(node, indent)),
|
|
599
|
+
...dotEdges(view, () => false),
|
|
600
|
+
"}"
|
|
601
|
+
].join("\n")}\n`;
|
|
602
|
+
/**
|
|
603
|
+
* A swimlane as a Graphviz `digraph`, left to right, with one `cluster` subgraph per lane in the view's lane order and nodes with no actor outside every lane. An empty lane holds one invisible point so Graphviz still draws it. Hand-offs, the edges that change lane, are drawn bold.
|
|
604
|
+
* @param view - The projected swimlane.
|
|
605
|
+
* @returns DOT source, ending with a line break.
|
|
606
|
+
*/
|
|
607
|
+
const swimlaneToDot = (view) => {
|
|
608
|
+
const nodes = new Map(view.nodes.map((node) => [node.id, node]));
|
|
609
|
+
const laneOf = /* @__PURE__ */ new Map();
|
|
610
|
+
view.lanes.forEach((lane, index) => {
|
|
611
|
+
for (const id of lane.nodes) laneOf.set(id, index);
|
|
612
|
+
});
|
|
613
|
+
const declare = (id, depth) => {
|
|
614
|
+
const node = nodes.get(id);
|
|
615
|
+
if (node === void 0) throw new Error(`lane member "${id}" is not a node of the view`);
|
|
616
|
+
return dotNode(node, depth);
|
|
617
|
+
};
|
|
618
|
+
return `${[
|
|
619
|
+
`digraph ${dotString("swimlane")} {`,
|
|
620
|
+
`${indent}rankdir="LR";`,
|
|
621
|
+
...view.lanes.flatMap((lane, index) => {
|
|
622
|
+
const cluster = `cluster_lane${String(index)}`;
|
|
623
|
+
return [
|
|
624
|
+
`${indent}subgraph ${dotString(cluster)} {`,
|
|
625
|
+
`${indent.repeat(2)}label=${dotString(lane.label)};`,
|
|
626
|
+
...lane.nodes.length === 0 ? [`${indent.repeat(2)}${dotString(`${cluster}:empty`)}${attributes([["shape", "point"], ["style", "invis"]])};`] : lane.nodes.map((id) => declare(id, indent.repeat(2))),
|
|
627
|
+
`${indent}}`
|
|
628
|
+
];
|
|
629
|
+
}),
|
|
630
|
+
...view.unassigned.map((id) => declare(id, indent)),
|
|
631
|
+
...dotEdges(view, (from, to) => {
|
|
632
|
+
const fromLane = laneOf.get(from);
|
|
633
|
+
const toLane = laneOf.get(to);
|
|
634
|
+
return fromLane !== void 0 && toLane !== void 0 && fromLane !== toLane;
|
|
635
|
+
}),
|
|
636
|
+
"}"
|
|
637
|
+
].join("\n")}\n`;
|
|
638
|
+
};
|
|
639
|
+
/** A state's DOT shape: a filled point for an initial pseudo-state, a double circle for a final one, a rounded box for a state. */
|
|
640
|
+
const stateShape = (state) => {
|
|
641
|
+
if (state.role === "initial") return [["label", ""], ["shape", "point"]];
|
|
642
|
+
if (state.role === "final") return [["label", ""], ["shape", "doublecircle"]];
|
|
643
|
+
return [
|
|
644
|
+
["label", state.label],
|
|
645
|
+
["shape", "box"],
|
|
646
|
+
["style", "rounded"]
|
|
647
|
+
];
|
|
648
|
+
};
|
|
649
|
+
/**
|
|
650
|
+
* A state machine as a Graphviz `digraph`, left to right. Each transition is labelled with its event, guard and effect.
|
|
651
|
+
* @param view - The projected state machine.
|
|
652
|
+
* @returns DOT source, ending with a line break.
|
|
653
|
+
*/
|
|
654
|
+
const stateMachineToDot = (view) => `${[
|
|
655
|
+
`digraph ${dotString("state machine")} {`,
|
|
656
|
+
`${indent}rankdir="LR";`,
|
|
657
|
+
...view.states.map((state) => `${indent}${dotString(state.id)}${attributes(stateShape(state))};`),
|
|
658
|
+
...view.transitions.map((transition) => {
|
|
659
|
+
const label = transitionLabel(transition);
|
|
660
|
+
return `${indent}${dotString(transition.source)} -> ${dotString(transition.target)}${attributes(label === "" ? [] : [["label", label]])};`;
|
|
661
|
+
}),
|
|
662
|
+
"}"
|
|
663
|
+
].join("\n")}\n`;
|
|
664
|
+
const placeMessages = (items, fragments) => items.flatMap((item) => item.kind === "message" ? [{
|
|
665
|
+
message: item.message,
|
|
666
|
+
fragments
|
|
667
|
+
}] : item.operands.flatMap((operand) => placeMessages(operand.items, [...fragments, operand.guard === void 0 ? item.operator : `${item.operator} [${operand.guard}]`])));
|
|
668
|
+
/**
|
|
669
|
+
* A sequence diagram as a Graphviz `digraph`. DOT has no sequence notation, so the diagram is drawn as a grid: a column per lifeline, headed by its participant's label, with one row per message in scenario order. Each message is an arrow across its row, numbered by its step and prefixed with the fragments around it (operator and guard, outermost first), since DOT cannot draw a box around arrows.
|
|
670
|
+
* @param view - The projected sequence diagram.
|
|
671
|
+
* @param participantLabel - The label of a lifeline's participant.
|
|
672
|
+
* @returns DOT source, ending with a line break.
|
|
673
|
+
*/
|
|
674
|
+
const sequenceToDot = (view, participantLabel) => {
|
|
675
|
+
const columns = new Map(view.lifelines.map((lifeline, index) => [lifeline, index]));
|
|
676
|
+
const column = (lifeline) => {
|
|
677
|
+
const index = columns.get(lifeline);
|
|
678
|
+
if (index === void 0) throw new Error(`"${lifeline}" is not a lifeline of the view`);
|
|
679
|
+
return index;
|
|
680
|
+
};
|
|
681
|
+
const head = (index) => dotString(`p${String(index)}`);
|
|
682
|
+
const cell = (index, row) => dotString(`p${String(index)}.${String(row)}`);
|
|
683
|
+
const placed = placeMessages(view.items, []);
|
|
684
|
+
const rows = placed.map((_, row) => row + 1);
|
|
685
|
+
const pointAttributes = attributes([["shape", "point"], ["width", "0"]]);
|
|
686
|
+
return `${[
|
|
687
|
+
`digraph ${dotString("sequence")} {`,
|
|
688
|
+
`${indent}rankdir="TB";`,
|
|
689
|
+
...view.lifelines.map((lifeline, index) => `${indent}${head(index)}${attributes([["label", participantLabel(lifeline)], ["shape", "box"]])};`),
|
|
690
|
+
`${indent}{ rank="same"; ${view.lifelines.map((_, index) => `${head(index)};`).join(" ")} }`,
|
|
691
|
+
...view.lifelines.slice(1).map((_, index) => `${indent}${head(index)} -> ${head(index + 1)}${attributes([["style", "invis"]])};`),
|
|
692
|
+
...rows.flatMap((row) => [...view.lifelines.map((_, index) => `${indent}${cell(index, row)}${pointAttributes};`), `${indent}{ rank="same"; ${view.lifelines.map((_, index) => `${cell(index, row)};`).join(" ")} }`]),
|
|
693
|
+
...rows.length === 0 ? [] : view.lifelines.map((_, index) => `${indent}${[head(index), ...rows.map((row) => cell(index, row))].join(" -> ")}${attributes([["style", "dashed"], ["arrowhead", "none"]])};`),
|
|
694
|
+
...placed.map(({ message, fragments }, index) => {
|
|
695
|
+
const row = index + 1;
|
|
696
|
+
const text = fragments.length === 0 ? message.label : `${fragments.join(" / ")}: ${message.label}`;
|
|
697
|
+
return `${indent}${cell(column(message.from), row)} -> ${cell(column(message.to), row)}${attributes([["label", `${String(row)}. ${text}`], ["constraint", "false"]])};`;
|
|
698
|
+
}),
|
|
699
|
+
"}"
|
|
700
|
+
].join("\n")}\n`;
|
|
701
|
+
};
|
|
702
|
+
//#endregion
|
|
703
|
+
//#region ../graph-cli/dist/show.js
|
|
704
|
+
/** The views `show` draws, as named on the command line. */
|
|
705
|
+
const viewNames = [
|
|
706
|
+
"flowchart",
|
|
707
|
+
"swimlane",
|
|
708
|
+
"state",
|
|
709
|
+
"sequence"
|
|
710
|
+
];
|
|
711
|
+
/** The formats `show` writes. */
|
|
712
|
+
const formatNames = [
|
|
713
|
+
"json",
|
|
714
|
+
"mermaid",
|
|
715
|
+
"dot"
|
|
716
|
+
];
|
|
717
|
+
const subjectOf = (model, given) => {
|
|
718
|
+
const subject = given ?? model.projections.find((projection) => projection.notation === "stateMachine")?.subject;
|
|
719
|
+
if (subject === void 0) throw new CliError(exitCodes.usage, "the state view needs --subject <entity>: the model declares no state machine projection");
|
|
720
|
+
const participant = model.participants.find((candidate) => candidate.id === subject);
|
|
721
|
+
if (participant === void 0) throw new CliError(exitCodes.notFound, `no participant "${subject}" in the model`);
|
|
722
|
+
if (participant.kind !== "entity") throw new CliError(exitCodes.usage, `"${subject}" is a ${participant.kind}; a state machine follows an entity`);
|
|
723
|
+
return subject;
|
|
724
|
+
};
|
|
725
|
+
const scenarioOf = (model, given) => {
|
|
726
|
+
const scenario = given ?? model.projections.find((projection) => projection.notation === "sequence")?.scenario;
|
|
727
|
+
if (scenario === void 0) throw new CliError(exitCodes.usage, "the sequence view needs --scenario <transition...>: the model declares no sequence projection");
|
|
728
|
+
const declared = new Set(model.transitions.map((transition) => transition.id));
|
|
729
|
+
const unknown = scenario.filter((id) => !declared.has(id));
|
|
730
|
+
if (unknown.length > 0) throw new CliError(exitCodes.notFound, `no transition ${unknown.map((id) => `"${id}"`).join(", ")} in the model`);
|
|
731
|
+
return scenario;
|
|
732
|
+
};
|
|
733
|
+
/**
|
|
734
|
+
* Projects a model into a view.
|
|
735
|
+
* @param model - The model.
|
|
736
|
+
* @param request - The view, and the subject or scenario it needs.
|
|
737
|
+
* @returns The projection, tagged by view.
|
|
738
|
+
* @throws CliError with `usage` when a state machine or sequence diagram has nothing to follow, or the subject is not an entity, and with `notFound` when the subject or a scenario transition does not exist.
|
|
739
|
+
*/
|
|
740
|
+
const project = (model, request) => {
|
|
741
|
+
if (request.view === "flowchart") return {
|
|
742
|
+
view: "flowchart",
|
|
743
|
+
projection: toFlowchart(model)
|
|
744
|
+
};
|
|
745
|
+
if (request.view === "swimlane") return {
|
|
746
|
+
view: "swimlane",
|
|
747
|
+
projection: toSwimlane(model)
|
|
748
|
+
};
|
|
749
|
+
if (request.view === "state") return {
|
|
750
|
+
view: "state",
|
|
751
|
+
projection: toStateMachine(model, subjectOf(model, request.subject))
|
|
752
|
+
};
|
|
753
|
+
return {
|
|
754
|
+
view: "sequence",
|
|
755
|
+
projection: toSequence(model, scenarioOf(model, request.scenario))
|
|
756
|
+
};
|
|
757
|
+
};
|
|
758
|
+
/**
|
|
759
|
+
* Writes a projected view as Mermaid or DOT source.
|
|
760
|
+
* @param model - The model, for participant labels.
|
|
761
|
+
* @param projected - The view.
|
|
762
|
+
* @param format - `mermaid` or `dot`.
|
|
763
|
+
* @returns The diagram source.
|
|
764
|
+
*/
|
|
765
|
+
const emit = (model, projected, format) => {
|
|
766
|
+
const labels = new Map(model.participants.map((participant) => [participant.id, participant.label]));
|
|
767
|
+
const participantLabel = (id) => labels.get(id) ?? id;
|
|
768
|
+
const mermaid = format === "mermaid";
|
|
769
|
+
if (projected.view === "flowchart") return mermaid ? flowchartToMermaid(projected.projection) : flowchartToDot(projected.projection);
|
|
770
|
+
if (projected.view === "swimlane") return mermaid ? swimlaneToMermaid(projected.projection) : swimlaneToDot(projected.projection);
|
|
771
|
+
if (projected.view === "state") return mermaid ? stateMachineToMermaid(projected.projection) : stateMachineToDot(projected.projection);
|
|
772
|
+
return mermaid ? sequenceToMermaid(projected.projection, participantLabel) : sequenceToDot(projected.projection, participantLabel);
|
|
773
|
+
};
|
|
774
|
+
/**
|
|
775
|
+
* Draws the session's model as a view in a format.
|
|
776
|
+
* @param session - The store and model.
|
|
777
|
+
* @param request - The view, format, and the subject or scenario the view needs.
|
|
778
|
+
*/
|
|
779
|
+
const show = async (session, request) => {
|
|
780
|
+
const { model } = await loadRun(session);
|
|
781
|
+
const projected = project(model, request);
|
|
782
|
+
const text = request.format === "json" ? `${JSON.stringify(projected.projection, null, 2)}\n` : emit(model, projected, request.format);
|
|
783
|
+
return {
|
|
784
|
+
...projected,
|
|
785
|
+
format: request.format,
|
|
786
|
+
text
|
|
787
|
+
};
|
|
788
|
+
};
|
|
789
|
+
//#endregion
|
|
790
|
+
//#region ../graph-cli/dist/sync.js
|
|
791
|
+
const scopeSeparator = ":";
|
|
792
|
+
/** Groups scopes by their source prefix, keeping the order in which each prefix first appears. */
|
|
793
|
+
const bySource = (scopes) => {
|
|
794
|
+
const groups = /* @__PURE__ */ new Map();
|
|
795
|
+
for (const scope of scopes) {
|
|
796
|
+
const separator = scope.indexOf(scopeSeparator);
|
|
797
|
+
if (separator <= 0) throw new CliError(exitCodes.usage, `"${scope}" is not a scope: write <source>:<scope>, such as github:owner/repo`);
|
|
798
|
+
const prefix = scope.slice(0, separator);
|
|
799
|
+
groups.set(prefix, [...groups.get(prefix) ?? [], scope]);
|
|
800
|
+
}
|
|
801
|
+
return [...groups].map(([prefix, grouped]) => ({
|
|
802
|
+
prefix,
|
|
803
|
+
scopes: grouped
|
|
804
|
+
}));
|
|
805
|
+
};
|
|
806
|
+
/** Bound activities are the only elements a sync assembles, since a page's activities are bound by schema. */
|
|
807
|
+
const boundActivities = (pages) => {
|
|
808
|
+
const { model, overlay, gaps } = assembleSync(pages);
|
|
809
|
+
const activities = model.elements.flatMap((element) => element.kind === "activity" && element.source !== void 0 ? [{
|
|
810
|
+
...element,
|
|
811
|
+
source: element.source
|
|
812
|
+
}] : []);
|
|
813
|
+
return {
|
|
814
|
+
items: {
|
|
815
|
+
participants: model.participants,
|
|
816
|
+
activities,
|
|
817
|
+
transitions: model.transitions,
|
|
818
|
+
overlay
|
|
819
|
+
},
|
|
820
|
+
gaps
|
|
821
|
+
};
|
|
822
|
+
};
|
|
823
|
+
/**
|
|
824
|
+
* Reads every page of the scopes from their sources and merges them into the session's model in one write, creating the model when the store has none. Records already in the model are replaced by identifier and the rest are kept, so syncing again refreshes what changed.
|
|
825
|
+
*
|
|
826
|
+
* When a source fails part way, the pages read before the failure are still written, and the error says how to resume: the failing source's scopes with `--after` and the cursor of its last page read.
|
|
827
|
+
* @param session - The store and model.
|
|
828
|
+
* @param sources - The source for each identifier prefix.
|
|
829
|
+
* @param request - The scopes, and a cursor to resume after.
|
|
830
|
+
* @returns What was written, and the gaps the sources reported.
|
|
831
|
+
* @throws CliError with `usage` for a scope with no source prefix, a prefix no source serves, or `after` with scopes of more than one source; and with the source's failure, the resume command added, when a source refuses the sync.
|
|
832
|
+
*/
|
|
833
|
+
const sync = async (session, sources, request) => {
|
|
834
|
+
const groups = bySource(request.scopes);
|
|
835
|
+
if (request.after !== void 0 && groups.length > 1) throw new CliError(exitCodes.usage, "--after resumes one source's sync: give only that source's scopes");
|
|
836
|
+
const clients = groups.map((group) => {
|
|
837
|
+
const client = sources(group.prefix);
|
|
838
|
+
if (client === void 0) throw new CliError(exitCodes.usage, `no source reads "${group.prefix}" scopes`);
|
|
839
|
+
return {
|
|
840
|
+
...group,
|
|
841
|
+
client
|
|
842
|
+
};
|
|
843
|
+
});
|
|
844
|
+
const pages = [];
|
|
845
|
+
const write = async () => {
|
|
846
|
+
const { items, gaps } = boundActivities(pages);
|
|
847
|
+
const { revision } = await session.store.v1.items.upsert({
|
|
848
|
+
modelId: session.modelId,
|
|
849
|
+
items
|
|
850
|
+
}, callOptions(session.signal));
|
|
851
|
+
return {
|
|
852
|
+
modelId: session.modelId,
|
|
853
|
+
revision,
|
|
854
|
+
participants: items.participants.length,
|
|
855
|
+
activities: items.activities.length,
|
|
856
|
+
transitions: items.transitions.length,
|
|
857
|
+
gaps
|
|
858
|
+
};
|
|
859
|
+
};
|
|
860
|
+
/** Reads the sources in order, each only after the one before has finished, since a source's failure must stop the ones after it. */
|
|
861
|
+
const readFrom = async (index) => {
|
|
862
|
+
const group = clients.at(index);
|
|
863
|
+
if (group === void 0) return;
|
|
864
|
+
let cursor = index === 0 ? request.after : void 0;
|
|
865
|
+
try {
|
|
866
|
+
const stream = await group.client.v1.sync.stream(cursor === void 0 ? { scope: group.scopes } : {
|
|
867
|
+
scope: group.scopes,
|
|
868
|
+
after: cursor
|
|
869
|
+
}, callOptions(session.signal));
|
|
870
|
+
for await (const page of stream) {
|
|
871
|
+
pages.push(page);
|
|
872
|
+
cursor = page.next;
|
|
873
|
+
}
|
|
874
|
+
} catch (error) {
|
|
875
|
+
const failure = failureOf(error);
|
|
876
|
+
const saved = pages.length > 0;
|
|
877
|
+
if (saved) await write();
|
|
878
|
+
const later = clients.slice(index + 1).flatMap((next) => next.scopes);
|
|
879
|
+
throw new CliError(failure.exitCode, [
|
|
880
|
+
failure.message,
|
|
881
|
+
...saved ? ["the pages read before the failure are saved"] : [],
|
|
882
|
+
`resume with: sync ${group.scopes.join(" ")}${cursor === void 0 ? "" : ` --after ${cursor}`}`,
|
|
883
|
+
...later.length === 0 ? [] : [`then: sync ${later.join(" ")}`]
|
|
884
|
+
].join("\n"), { cause: error });
|
|
885
|
+
}
|
|
886
|
+
await readFrom(index + 1);
|
|
887
|
+
};
|
|
888
|
+
await readFrom(0);
|
|
889
|
+
return write();
|
|
890
|
+
};
|
|
891
|
+
//#endregion
|
|
892
|
+
//#region ../graph-cli/dist/validate.js
|
|
893
|
+
/** A cycle that no decision closes, as an issue: status is undefined on it. */
|
|
894
|
+
const cycleIssues = (model) => illegalCycles(model).map((cycle) => ({
|
|
895
|
+
code: "illegal-cycle",
|
|
896
|
+
message: `cycle with no decision in it: ${cycle.join(" -> ")}`
|
|
897
|
+
}));
|
|
898
|
+
const resultOf = (issues) => ({
|
|
899
|
+
valid: issues.length === 0,
|
|
900
|
+
issues
|
|
901
|
+
});
|
|
902
|
+
/**
|
|
903
|
+
* Checks the session's stored model and run state: the structural rules of `validate`, run state naming elements the model lacks, and cycles no decision closes.
|
|
904
|
+
* @param session - The store and model.
|
|
905
|
+
*/
|
|
906
|
+
const validateStored = async (session) => {
|
|
907
|
+
const { model, overlay } = await loadRun(session);
|
|
908
|
+
return resultOf([
|
|
909
|
+
...validate(model),
|
|
910
|
+
...validateOverlay(model, overlay),
|
|
911
|
+
...cycleIssues(model)
|
|
912
|
+
]);
|
|
913
|
+
};
|
|
914
|
+
/**
|
|
915
|
+
* Checks a model document, such as a file of JSON: its shape against the model schema (each problem located by JSON pointer), then the structural rules of `validate`, then cycles no decision closes. Text that is not JSON is one `schema` issue at the document itself.
|
|
916
|
+
* @param text - The document.
|
|
917
|
+
*/
|
|
918
|
+
const validateDocument = (text) => {
|
|
919
|
+
let input;
|
|
920
|
+
try {
|
|
921
|
+
input = JSON.parse(text);
|
|
922
|
+
} catch (error) {
|
|
923
|
+
return resultOf([{
|
|
924
|
+
code: "schema",
|
|
925
|
+
message: `not JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
926
|
+
pointer: ""
|
|
927
|
+
}]);
|
|
928
|
+
}
|
|
929
|
+
try {
|
|
930
|
+
return resultOf(cycleIssues(parseModel(input)));
|
|
931
|
+
} catch (error) {
|
|
932
|
+
if (!(error instanceof ModelParseError)) throw error;
|
|
933
|
+
return resultOf(error.issues.map((issue) => issue.source === "schema" ? {
|
|
934
|
+
code: "schema",
|
|
935
|
+
message: issue.message,
|
|
936
|
+
pointer: issue.pointer
|
|
937
|
+
} : {
|
|
938
|
+
code: issue.code,
|
|
939
|
+
message: issue.message
|
|
940
|
+
}));
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
//#endregion
|
|
944
|
+
//#region ../graph-cli/dist/program.js
|
|
945
|
+
/** The model a command works on when `--model` is not given. */
|
|
946
|
+
const defaultModelId = "default";
|
|
947
|
+
const exitStatusHelp = `
|
|
948
|
+
Exit status:
|
|
949
|
+
${String(exitCodes.success)} success; for validate and cycles, nothing wrong was found
|
|
950
|
+
${String(exitCodes.failure)} failure not listed below
|
|
951
|
+
${String(exitCodes.usage)} usage error: unknown command or option, missing argument, value outside its choices
|
|
952
|
+
${String(exitCodes.notFound)} the model, an element or another identifier named does not exist
|
|
953
|
+
${String(exitCodes.invalidModel)} the model has problems: validation issues, an illegal cycle, or a change the store rejected
|
|
954
|
+
${String(exitCodes.sourceFailure)} a source refused the sync: credentials, rate limit or scope
|
|
955
|
+
|
|
956
|
+
Every command writes JSON with --json. The store is a SQLite file, chosen with --data.`;
|
|
957
|
+
const readGlobals = (command, ports) => {
|
|
958
|
+
const { data, model, json } = command.optsWithGlobals();
|
|
959
|
+
return {
|
|
960
|
+
data: typeof data === "string" ? data : ports.defaultDataPath,
|
|
961
|
+
model: typeof model === "string" ? model : defaultModelId,
|
|
962
|
+
json: json === true
|
|
963
|
+
};
|
|
964
|
+
};
|
|
965
|
+
const isOneOf = (values, value) => values.some((candidate) => candidate === value);
|
|
966
|
+
/** Reads an option whose choices commander has already checked, narrowing it to its type. */
|
|
967
|
+
const choice = (values, value, name) => {
|
|
968
|
+
if (!isOneOf(values, value)) throw new Error(`--${name} was not checked against its choices`);
|
|
969
|
+
return value;
|
|
970
|
+
};
|
|
971
|
+
/**
|
|
972
|
+
* Runs the command line with the given arguments, writing only through the ports, and resolves with the exit status. It never exits the process or reads its arguments itself, so a test or a host program such as Switchboard runs it as a function.
|
|
973
|
+
* @param args - The arguments after the program name, such as `["ready", "--json"]`.
|
|
974
|
+
* @param ports - Output, store, sources, facts and the abort signal.
|
|
975
|
+
* @param identity - The name and version the program presents.
|
|
976
|
+
* @returns The exit status, one of `exitCodes`.
|
|
977
|
+
*/
|
|
978
|
+
const runCli = async (args, ports, identity) => {
|
|
979
|
+
let exitCode = exitCodes.success;
|
|
980
|
+
const writeOut = (text) => {
|
|
981
|
+
ports.stdout(terminalText(text));
|
|
982
|
+
};
|
|
983
|
+
const writeErr = (text) => {
|
|
984
|
+
ports.stderr(terminalText(text));
|
|
985
|
+
};
|
|
986
|
+
const program = new Command(identity.name);
|
|
987
|
+
program.description("Sync work from its sources into a process model, and ask what is ready, blocked, on the critical path or handed off, why, and what it looks like as a diagram.").version(identity.version).configureOutput({
|
|
988
|
+
writeOut,
|
|
989
|
+
writeErr
|
|
990
|
+
}).configureHelp({ showGlobalOptions: true }).exitOverride().option("--data <path>", "the SQLite file the store keeps", ports.defaultDataPath).option("--model <id>", "the model in the store", defaultModelId).option("--json", "write the result as JSON").addHelpText("after", exitStatusHelp).showHelpAfterError();
|
|
991
|
+
/** Writes a command's result, as JSON or text, and records its exit status. */
|
|
992
|
+
const respond = (globals, outcome) => {
|
|
993
|
+
writeOut(globals.json ? terminalJson(outcome.json) : outcome.text);
|
|
994
|
+
exitCode = outcome.exitCode ?? exitCodes.success;
|
|
995
|
+
};
|
|
996
|
+
/** Opens the store for one command, runs it, writes its result and records its exit status; the store is closed whatever happens. */
|
|
997
|
+
const withSession = (handler) => async (command, ...values) => {
|
|
998
|
+
const globals = readGlobals(command, ports);
|
|
999
|
+
const opened = await ports.openStore(globals.data);
|
|
1000
|
+
for (const warning of opened.warnings) writeErr(`warning: ${warning}\n`);
|
|
1001
|
+
try {
|
|
1002
|
+
const outcome = await handler({
|
|
1003
|
+
store: opened.client,
|
|
1004
|
+
modelId: globals.model,
|
|
1005
|
+
facts: ports.facts,
|
|
1006
|
+
signal: ports.signal
|
|
1007
|
+
}, ...values);
|
|
1008
|
+
respond(globals, outcome);
|
|
1009
|
+
} finally {
|
|
1010
|
+
await opened.close();
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
program.command("sync").description("read the scopes from their sources and merge them into the model").argument("<scope...>", "source-qualified scopes, such as github:owner or github:owner/repo").option("--after <cursor>", "resume an interrupted sync after this cursor").action(async (scopes, options, command) => {
|
|
1014
|
+
const { after } = options;
|
|
1015
|
+
await withSession(async (session) => {
|
|
1016
|
+
const result = await sync(session, ports.source, typeof after === "string" ? {
|
|
1017
|
+
scopes,
|
|
1018
|
+
after
|
|
1019
|
+
} : { scopes });
|
|
1020
|
+
return {
|
|
1021
|
+
json: result,
|
|
1022
|
+
text: renderSync(result)
|
|
1023
|
+
};
|
|
1024
|
+
})(command);
|
|
1025
|
+
});
|
|
1026
|
+
program.command("ready").description("list the work that can start now").action(async (_options, command) => {
|
|
1027
|
+
await withSession(async (session) => {
|
|
1028
|
+
const result = await ready(session);
|
|
1029
|
+
return {
|
|
1030
|
+
json: result,
|
|
1031
|
+
text: renderReady(result)
|
|
1032
|
+
};
|
|
1033
|
+
})(command);
|
|
1034
|
+
});
|
|
1035
|
+
program.command("blocked").description("list the open work an element is waiting on").argument("<id>", "the element").action(async (id, _options, command) => {
|
|
1036
|
+
await withSession(async (session) => {
|
|
1037
|
+
const result = await blocked(session, id);
|
|
1038
|
+
return {
|
|
1039
|
+
json: result,
|
|
1040
|
+
text: renderBlocked(result)
|
|
1041
|
+
};
|
|
1042
|
+
})(command);
|
|
1043
|
+
});
|
|
1044
|
+
program.command("impact").description("list the live work that depends on an element").argument("<id>", "the element").action(async (id, _options, command) => {
|
|
1045
|
+
await withSession(async (session) => {
|
|
1046
|
+
const result = await impactOf(session, id);
|
|
1047
|
+
return {
|
|
1048
|
+
json: result,
|
|
1049
|
+
text: renderImpact(result)
|
|
1050
|
+
};
|
|
1051
|
+
})(command);
|
|
1052
|
+
});
|
|
1053
|
+
program.command("path").description("list the critical path: the longest chain of work still ahead").action(async (_options, command) => {
|
|
1054
|
+
await withSession(async (session) => {
|
|
1055
|
+
const result = await path(session);
|
|
1056
|
+
return {
|
|
1057
|
+
json: result,
|
|
1058
|
+
text: renderPath(result)
|
|
1059
|
+
};
|
|
1060
|
+
})(command);
|
|
1061
|
+
});
|
|
1062
|
+
program.command("cycles").description("list the cycles no decision closes; exits with the invalid model status when there are any").action(async (_options, command) => {
|
|
1063
|
+
await withSession(async (session) => {
|
|
1064
|
+
const result = await cycles(session);
|
|
1065
|
+
return {
|
|
1066
|
+
json: result,
|
|
1067
|
+
text: renderCycles(result),
|
|
1068
|
+
exitCode: result.cycles.length === 0 ? exitCodes.success : exitCodes.invalidModel
|
|
1069
|
+
};
|
|
1070
|
+
})(command);
|
|
1071
|
+
});
|
|
1072
|
+
program.command("handoffs").description("list the transitions from one actor's work to another's").action(async (_options, command) => {
|
|
1073
|
+
await withSession(async (session) => {
|
|
1074
|
+
const result = await handoffsOf(session);
|
|
1075
|
+
return {
|
|
1076
|
+
json: result,
|
|
1077
|
+
text: renderHandoffs(result)
|
|
1078
|
+
};
|
|
1079
|
+
})(command);
|
|
1080
|
+
});
|
|
1081
|
+
program.command("why").description("explain an element's status: the rule that decided it, its needs and the blocked-by chain").argument("<id>", "the element").action(async (id, _options, command) => {
|
|
1082
|
+
await withSession(async (session) => {
|
|
1083
|
+
const result = await why(session, id);
|
|
1084
|
+
return {
|
|
1085
|
+
json: result,
|
|
1086
|
+
text: renderWhy(result)
|
|
1087
|
+
};
|
|
1088
|
+
})(command);
|
|
1089
|
+
});
|
|
1090
|
+
program.command("show").description("draw the model as a view").addOption(new Option("--view <view>", "the view to draw").choices(viewNames).default("flowchart")).addOption(new Option("--format <format>", "the format to write").choices(formatNames).default("mermaid")).option("--subject <entity>", "the entity a state view follows; defaults to the model's declared state machine projection").option("--scenario <transition...>", "the transitions a sequence view follows, in order; defaults to the model's declared sequence projection").action(async (options, command) => {
|
|
1091
|
+
const view = choice(viewNames, options.view, "view");
|
|
1092
|
+
const format = choice(formatNames, options.format, "format");
|
|
1093
|
+
const { subject, scenario } = options;
|
|
1094
|
+
await withSession(async (session) => {
|
|
1095
|
+
const result = await show(session, {
|
|
1096
|
+
view,
|
|
1097
|
+
format,
|
|
1098
|
+
...typeof subject === "string" ? { subject } : {},
|
|
1099
|
+
...Array.isArray(scenario) ? { scenario: scenario.filter((id) => typeof id === "string") } : {}
|
|
1100
|
+
});
|
|
1101
|
+
return {
|
|
1102
|
+
json: format === "json" ? result.projection : {
|
|
1103
|
+
view,
|
|
1104
|
+
format,
|
|
1105
|
+
text: result.text
|
|
1106
|
+
},
|
|
1107
|
+
text: result.text
|
|
1108
|
+
};
|
|
1109
|
+
})(command);
|
|
1110
|
+
});
|
|
1111
|
+
const addCommand = program.command("add").description("add a record to the model, creating the model if needed");
|
|
1112
|
+
const added = async (command, addition, kind) => withSession(async (session) => {
|
|
1113
|
+
const written = await add(session, addition);
|
|
1114
|
+
return {
|
|
1115
|
+
json: {
|
|
1116
|
+
added: kind,
|
|
1117
|
+
id: addition.record.id,
|
|
1118
|
+
revision: written.revision
|
|
1119
|
+
},
|
|
1120
|
+
text: renderAdded(kind, addition.record.id, written)
|
|
1121
|
+
};
|
|
1122
|
+
})(command);
|
|
1123
|
+
addCommand.command("participant").description("add a participant: a role or system that acts, or an entity with a lifecycle").argument("<id>", "its identifier").argument("<label>", "its label").addOption(new Option("--kind <kind>", "what it is").choices(participantKinds).default("role")).action(async (id, label, options, command) => {
|
|
1124
|
+
const kind = choice(participantKinds, options.kind, "kind");
|
|
1125
|
+
await added(command, {
|
|
1126
|
+
kind: "participant",
|
|
1127
|
+
record: {
|
|
1128
|
+
id,
|
|
1129
|
+
label,
|
|
1130
|
+
kind
|
|
1131
|
+
}
|
|
1132
|
+
}, "participant");
|
|
1133
|
+
});
|
|
1134
|
+
addCommand.command("activity").description("add an activity: a step of work").argument("<id>", "its identifier").argument("<label>", "its label").option("--actor <participant>", "the role or system that does it").action(async (id, label, options, command) => {
|
|
1135
|
+
const { actor } = options;
|
|
1136
|
+
await added(command, {
|
|
1137
|
+
kind: "element",
|
|
1138
|
+
record: {
|
|
1139
|
+
kind: "activity",
|
|
1140
|
+
id,
|
|
1141
|
+
label,
|
|
1142
|
+
...typeof actor === "string" ? { actor } : {}
|
|
1143
|
+
}
|
|
1144
|
+
}, "activity");
|
|
1145
|
+
});
|
|
1146
|
+
addCommand.command("decision").description("add a decision: a point where the flow branches").argument("<id>", "its identifier").argument("<label>", "its label, the question it asks").option("--actor <participant>", "the role or system that decides").action(async (id, label, options, command) => {
|
|
1147
|
+
const { actor } = options;
|
|
1148
|
+
await added(command, {
|
|
1149
|
+
kind: "element",
|
|
1150
|
+
record: {
|
|
1151
|
+
kind: "decision",
|
|
1152
|
+
id,
|
|
1153
|
+
label,
|
|
1154
|
+
...typeof actor === "string" ? { actor } : {}
|
|
1155
|
+
}
|
|
1156
|
+
}, "decision");
|
|
1157
|
+
});
|
|
1158
|
+
addCommand.command("state").description("add a state an entity rests in").argument("<id>", "its identifier").argument("<label>", "its label").option("--parent <state>", "the state it is nested in").action(async (id, label, options, command) => {
|
|
1159
|
+
const { parent } = options;
|
|
1160
|
+
await added(command, {
|
|
1161
|
+
kind: "element",
|
|
1162
|
+
record: {
|
|
1163
|
+
kind: "state",
|
|
1164
|
+
id,
|
|
1165
|
+
label,
|
|
1166
|
+
...typeof parent === "string" ? { parent } : {}
|
|
1167
|
+
}
|
|
1168
|
+
}, "state");
|
|
1169
|
+
});
|
|
1170
|
+
addCommand.command("event").description("add an event that can trigger a transition").argument("<id>", "its identifier").argument("<name>", "its name").action(async (id, name, _options, command) => {
|
|
1171
|
+
await added(command, {
|
|
1172
|
+
kind: "event",
|
|
1173
|
+
record: {
|
|
1174
|
+
id,
|
|
1175
|
+
name
|
|
1176
|
+
}
|
|
1177
|
+
}, "event");
|
|
1178
|
+
});
|
|
1179
|
+
addCommand.command("artefact").description("add an artefact that activities consume or produce").argument("<id>", "its identifier").argument("<name>", "its name").option("--deliverable", "mark it as an output the process exists to deliver").action(async (id, name, options, command) => {
|
|
1180
|
+
await added(command, {
|
|
1181
|
+
kind: "artefact",
|
|
1182
|
+
record: options.deliverable === true ? {
|
|
1183
|
+
id,
|
|
1184
|
+
name,
|
|
1185
|
+
deliverable: true
|
|
1186
|
+
} : {
|
|
1187
|
+
id,
|
|
1188
|
+
name
|
|
1189
|
+
}
|
|
1190
|
+
}, "artefact");
|
|
1191
|
+
});
|
|
1192
|
+
addCommand.command("transition").description("add a transition: the target waits on the source").argument("<source>", "the element that comes first").argument("<target>", "the element that follows").option("--id <id>", "its identifier; <source>-><target> when not given, as a source names its transitions").option("--guard <label>", "the label of the branch it takes").option("--trigger <event>", "the event that fires it").action(async (source, target, options, command) => {
|
|
1193
|
+
const { id, guard, trigger } = options;
|
|
1194
|
+
await added(command, {
|
|
1195
|
+
kind: "transition",
|
|
1196
|
+
record: {
|
|
1197
|
+
id: typeof id === "string" ? id : `${source}->${target}`,
|
|
1198
|
+
source,
|
|
1199
|
+
target,
|
|
1200
|
+
...typeof guard === "string" ? { guard } : {},
|
|
1201
|
+
...typeof trigger === "string" ? { trigger } : {}
|
|
1202
|
+
}
|
|
1203
|
+
}, "transition");
|
|
1204
|
+
});
|
|
1205
|
+
program.command("set").description("set an element's run state").argument("<id>", "the element").addArgument(new Argument("<state>", "its run state").choices(overlayStatuses)).action(async (id, state, _options, command) => {
|
|
1206
|
+
const status = choice(overlayStatuses, state, "state");
|
|
1207
|
+
await withSession(async (session) => {
|
|
1208
|
+
const result = await setState(session, id, status);
|
|
1209
|
+
return {
|
|
1210
|
+
json: result,
|
|
1211
|
+
text: renderSet(result)
|
|
1212
|
+
};
|
|
1213
|
+
})(command);
|
|
1214
|
+
});
|
|
1215
|
+
program.command("validate").description("check the stored model, or a model file, for problems; exits with the invalid model status when there are any").argument("[file]", "a model as JSON, checked instead of the stored model").action(async (file, _options, command) => {
|
|
1216
|
+
const outcome = (result) => ({
|
|
1217
|
+
json: result,
|
|
1218
|
+
text: renderValidate(result),
|
|
1219
|
+
exitCode: result.valid ? exitCodes.success : exitCodes.invalidModel
|
|
1220
|
+
});
|
|
1221
|
+
if (file === void 0) {
|
|
1222
|
+
await withSession(async (session) => outcome(await validateStored(session)))(command);
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
respond(readGlobals(command, ports), outcome(validateDocument(await ports.readFile(file, ports.signal))));
|
|
1226
|
+
});
|
|
1227
|
+
try {
|
|
1228
|
+
await program.parseAsync(args, { from: "user" });
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
const failure = failureOf(error);
|
|
1231
|
+
if (failure.message !== "") writeErr(`${failure.message}\n`);
|
|
1232
|
+
return failure.exitCode;
|
|
1233
|
+
}
|
|
1234
|
+
return exitCode;
|
|
1235
|
+
};
|
|
1236
|
+
/** The permission bits of a file mode, without its type. */
|
|
1237
|
+
const permissionBits = 511;
|
|
1238
|
+
/** The bits that give the file's group or anyone else any access. */
|
|
1239
|
+
const groupAndOtherBits = 63;
|
|
1240
|
+
/** The SQLite files a store at `path` uses: the database and its write-ahead log and shared-memory index. */
|
|
1241
|
+
const storeFiles = (path) => [
|
|
1242
|
+
path,
|
|
1243
|
+
`${path}-wal`,
|
|
1244
|
+
`${path}-shm`
|
|
1245
|
+
];
|
|
1246
|
+
/** Whether an error is the file system's report that a path does not exist, or already does. */
|
|
1247
|
+
const hasCode = (error, code) => error instanceof Error && "code" in error && error.code === code;
|
|
1248
|
+
/**
|
|
1249
|
+
* Narrows a path's mode to `mode` when its group or others have any access.
|
|
1250
|
+
* @param path - The path.
|
|
1251
|
+
* @param mode - The private mode it should have.
|
|
1252
|
+
* @returns Whether the mode was changed; `false` too when the path does not exist.
|
|
1253
|
+
*/
|
|
1254
|
+
const tighten = (path, mode) => {
|
|
1255
|
+
let current;
|
|
1256
|
+
try {
|
|
1257
|
+
current = statSync(path).mode & permissionBits;
|
|
1258
|
+
} catch (error) {
|
|
1259
|
+
if (hasCode(error, "ENOENT")) return false;
|
|
1260
|
+
throw error;
|
|
1261
|
+
}
|
|
1262
|
+
if ((current & groupAndOtherBits) === 0) return false;
|
|
1263
|
+
chmodSync(path, mode);
|
|
1264
|
+
return true;
|
|
1265
|
+
};
|
|
1266
|
+
/**
|
|
1267
|
+
* Makes a data file ready for SQLite to open. The directory is created when missing, private when it is the command line's own. The database file is created private when missing, before SQLite opens it, so the file and the sidecars SQLite derives from it are never readable by others, whatever the umask; a file that already exists is left alone unless the directory is the command line's own. In its own directory, an existing directory or store file that group or others can read is narrowed to the private mode.
|
|
1268
|
+
* @param path - The database file.
|
|
1269
|
+
* @param options - How much of the path the command line owns, and the platform.
|
|
1270
|
+
* @returns One warning line naming what was narrowed, or nothing.
|
|
1271
|
+
*/
|
|
1272
|
+
const prepareDataFile = (path, options) => {
|
|
1273
|
+
const directory = dirname(path);
|
|
1274
|
+
if (options.platform === "win32") {
|
|
1275
|
+
mkdirSync(directory, { recursive: true });
|
|
1276
|
+
return [];
|
|
1277
|
+
}
|
|
1278
|
+
mkdirSync(directory, options.ownsDirectory ? {
|
|
1279
|
+
recursive: true,
|
|
1280
|
+
mode: 448
|
|
1281
|
+
} : { recursive: true });
|
|
1282
|
+
try {
|
|
1283
|
+
closeSync(openSync(path, "wx", 384));
|
|
1284
|
+
} catch (error) {
|
|
1285
|
+
if (!hasCode(error, "EEXIST")) throw error;
|
|
1286
|
+
}
|
|
1287
|
+
if (!options.ownsDirectory) return [];
|
|
1288
|
+
const narrowed = [...tighten(directory, 448) ? [directory] : [], ...storeFiles(path).filter((file) => tighten(file, 384))];
|
|
1289
|
+
return narrowed.length === 0 ? [] : [`made ${narrowed.join(", ")} readable by its owner only, since other users could read the synced work in it`];
|
|
1290
|
+
};
|
|
1291
|
+
//#endregion
|
|
1292
|
+
//#region ../graph-cli/dist/node.js
|
|
1293
|
+
/** The variable that names the data file outright. */
|
|
1294
|
+
const dataVariable = "PROCESS_GRAPH_DATA";
|
|
1295
|
+
/** The data file's path under the data directory. */
|
|
1296
|
+
const dataFile = join("process-graph", "graph.sqlite3");
|
|
1297
|
+
/** `node:sqlite`'s path for a private database in memory, which has no directory to create. */
|
|
1298
|
+
const inMemory = ":memory:";
|
|
1299
|
+
/**
|
|
1300
|
+
* The command line's own data file: `process-graph/graph.sqlite3` under the XDG data directory (`XDG_DATA_HOME`, or `.local/share` in the home directory).
|
|
1301
|
+
* @param env - The environment.
|
|
1302
|
+
* @param home - The home directory.
|
|
1303
|
+
* @returns The path.
|
|
1304
|
+
*/
|
|
1305
|
+
const xdgDataPath = (env, home) => {
|
|
1306
|
+
const xdg = env.XDG_DATA_HOME;
|
|
1307
|
+
return join(xdg !== void 0 && xdg !== "" ? xdg : join(home, ".local", "share"), dataFile);
|
|
1308
|
+
};
|
|
1309
|
+
/**
|
|
1310
|
+
* The data file used when `--data` is not given: `PROCESS_GRAPH_DATA` when it is set and not empty, otherwise `process-graph/graph.sqlite3` under the XDG data directory (`XDG_DATA_HOME`, or `.local/share` in the home directory).
|
|
1311
|
+
* @param env - The environment.
|
|
1312
|
+
* @param home - The home directory.
|
|
1313
|
+
* @returns The path.
|
|
1314
|
+
*/
|
|
1315
|
+
const defaultDataPath = (env, home) => {
|
|
1316
|
+
const named = env[dataVariable];
|
|
1317
|
+
return named !== void 0 && named !== "" ? named : xdgDataPath(env, home);
|
|
1318
|
+
};
|
|
1319
|
+
/** A fact port that knows nothing: every condition is indeterminate and no input artefact is provided. */
|
|
1320
|
+
const noFacts = () => ({ found: false });
|
|
1321
|
+
/**
|
|
1322
|
+
* The version in a package manifest, which `--version` prints. Each bin passes its own manifest, so the entry point reports its own version rather than this package's.
|
|
1323
|
+
* @param manifest - The manifest's location, such as `new URL("../package.json", import.meta.url)` from a module in `dist/`.
|
|
1324
|
+
* @returns The version.
|
|
1325
|
+
* @throws Error when the manifest has no string `version`.
|
|
1326
|
+
*/
|
|
1327
|
+
const packageVersion = (manifest) => {
|
|
1328
|
+
const parsed = JSON.parse(readFileSync(manifest, "utf8"));
|
|
1329
|
+
if (typeof parsed !== "object" || parsed === null || !("version" in parsed) || typeof parsed.version !== "string") throw new Error(`${manifest.pathname} has no version`);
|
|
1330
|
+
return parsed.version;
|
|
1331
|
+
};
|
|
1332
|
+
/**
|
|
1333
|
+
* The ports for a Node process: the SQLite store over `node:sqlite` (creating the data file's directory when needed), the GitHub issues source for `github:` scopes, the file system and the process's streams.
|
|
1334
|
+
* @param options - Replacements for any of the defaults.
|
|
1335
|
+
* @returns The ports.
|
|
1336
|
+
*/
|
|
1337
|
+
const nodePorts = (options = {}) => {
|
|
1338
|
+
const env = options.env ?? process.env;
|
|
1339
|
+
const token = options.token ?? githubTokenFromEnvironment({ env });
|
|
1340
|
+
const home = options.home ?? homedir();
|
|
1341
|
+
const ownDataPath = xdgDataPath(env, home);
|
|
1342
|
+
return {
|
|
1343
|
+
stdout: options.stdout ?? ((text) => {
|
|
1344
|
+
process.stdout.write(text);
|
|
1345
|
+
}),
|
|
1346
|
+
stderr: options.stderr ?? ((text) => {
|
|
1347
|
+
process.stderr.write(text);
|
|
1348
|
+
}),
|
|
1349
|
+
defaultDataPath: defaultDataPath(env, home),
|
|
1350
|
+
openStore: (path) => {
|
|
1351
|
+
const warnings = path === inMemory ? [] : prepareDataFile(path, {
|
|
1352
|
+
ownsDirectory: path === ownDataPath,
|
|
1353
|
+
platform: options.platform ?? process.platform
|
|
1354
|
+
});
|
|
1355
|
+
const { store, close } = openNodeGraphStore(path);
|
|
1356
|
+
return {
|
|
1357
|
+
client: createRouterClient(createGraphStoreRouter(store)),
|
|
1358
|
+
close,
|
|
1359
|
+
warnings
|
|
1360
|
+
};
|
|
1361
|
+
},
|
|
1362
|
+
source: (prefix) => prefix === "github" ? createRouterClient(createGraphSourceRouter(createGitHubIssuesSource({
|
|
1363
|
+
token,
|
|
1364
|
+
now: options.now ?? Date.now,
|
|
1365
|
+
...options.fetch === void 0 ? {} : { fetch: options.fetch }
|
|
1366
|
+
}))) : void 0,
|
|
1367
|
+
readFile: async (path, signal) => readFile(path, signal === void 0 ? { encoding: "utf8" } : {
|
|
1368
|
+
encoding: "utf8",
|
|
1369
|
+
signal
|
|
1370
|
+
}),
|
|
1371
|
+
facts: options.facts ?? noFacts,
|
|
1372
|
+
signal: options.signal
|
|
1373
|
+
};
|
|
1374
|
+
};
|
|
1375
|
+
/**
|
|
1376
|
+
* Runs the command line as the current process: its arguments, its streams and the real ports, with an interrupt aborting whatever the command is waiting on. It resolves with the exit status rather than exiting, so the bin sets `process.exitCode` and pending output is flushed.
|
|
1377
|
+
* @param identity - The name and version the program presents.
|
|
1378
|
+
* @returns The exit status.
|
|
1379
|
+
*/
|
|
1380
|
+
const runProcess = async (identity) => {
|
|
1381
|
+
const controller = new AbortController();
|
|
1382
|
+
process.once("SIGINT", () => {
|
|
1383
|
+
controller.abort();
|
|
1384
|
+
});
|
|
1385
|
+
return runCli(process.argv.slice(2), nodePorts({ signal: controller.signal }), identity);
|
|
1386
|
+
};
|
|
1387
|
+
//#endregion
|
|
1388
|
+
export { add as A, blocked as C, path as D, impactOf as E, failureOf as F, findRun as M, loadRun as N, ready as O, CliError as P, swimlaneToMermaid as S, handoffsOf as T, stateMachineToDot as _, defaultModelId as a, sequenceToMermaid as b, validateStored as c, formatNames as d, project as f, sequenceToDot as g, flowchartToDot as h, runProcess as i, setState as j, why as k, sync as l, viewNames as m, nodePorts as n, runCli as o, show as p, packageVersion as r, validateDocument as s, defaultDataPath as t, emit as u, swimlaneToDot as v, cycles as w, stateMachineToMermaid as x, flowchartToMermaid as y };
|