@miragon/design-iq-mcp 5.0.0-rc.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/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/http.js +148 -0
- package/dist/server.js +62 -0
- package/dist/tools-V_22sqM1.js +1890 -0
- package/package.json +58 -0
|
@@ -0,0 +1,1890 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, posix, relative } from "node:path";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import { XMLParser } from "fast-xml-parser";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
//#region ../notations/bpmn-kinds.ts
|
|
10
|
+
const BPMN_ELEMENT_KIND = /* @__PURE__ */ new Map([
|
|
11
|
+
["task", "activity"],
|
|
12
|
+
["userTask", "activity"],
|
|
13
|
+
["serviceTask", "activity"],
|
|
14
|
+
["scriptTask", "activity"],
|
|
15
|
+
["businessRuleTask", "activity"],
|
|
16
|
+
["manualTask", "activity"],
|
|
17
|
+
["sendTask", "activity"],
|
|
18
|
+
["receiveTask", "activity"],
|
|
19
|
+
["callActivity", "activity"],
|
|
20
|
+
["subProcess", "activity"],
|
|
21
|
+
["adHocSubProcess", "activity"],
|
|
22
|
+
["transaction", "activity"],
|
|
23
|
+
["startEvent", "event"],
|
|
24
|
+
["endEvent", "event"],
|
|
25
|
+
["intermediateThrowEvent", "event"],
|
|
26
|
+
["intermediateCatchEvent", "event"],
|
|
27
|
+
["boundaryEvent", "event"],
|
|
28
|
+
["exclusiveGateway", "gateway"],
|
|
29
|
+
["parallelGateway", "gateway"],
|
|
30
|
+
["inclusiveGateway", "gateway"],
|
|
31
|
+
["eventBasedGateway", "gateway"],
|
|
32
|
+
["complexGateway", "gateway"]
|
|
33
|
+
]);
|
|
34
|
+
/** the kind of a flow-node tag; undefined = not a flow node (artifact, lane, …) */
|
|
35
|
+
const kindOf = (tag) => BPMN_ELEMENT_KIND.get(tag);
|
|
36
|
+
/** every flow-node tag — everything else (textAnnotation, dataObject, …) is a
|
|
37
|
+
* legal artifact but NOT a flow node; treating it as one produces false
|
|
38
|
+
* "unreachable"/"dead end" errors on perfectly valid models */
|
|
39
|
+
const BPMN_FLOW_NODE_TAGS = new Set(BPMN_ELEMENT_KIND.keys());
|
|
40
|
+
/** container tags whose children form their own flow graph */
|
|
41
|
+
const BPMN_SUB_CONTAINER_TAGS = /* @__PURE__ */ new Set([
|
|
42
|
+
"subProcess",
|
|
43
|
+
"adHocSubProcess",
|
|
44
|
+
"transaction"
|
|
45
|
+
]);
|
|
46
|
+
/** non-flow-node artifacts that still need a BPMNDI shape/edge to render.
|
|
47
|
+
* Exactly these five: plain `dataObject` is deliberately ABSENT — bpmn-js
|
|
48
|
+
* draws the dataObjectReference, not the dataObject; requiring DI for the
|
|
49
|
+
* latter would false-error every model with one. */
|
|
50
|
+
const BPMN_DI_ARTIFACT_TAGS = /* @__PURE__ */ new Set([
|
|
51
|
+
"textAnnotation",
|
|
52
|
+
"association",
|
|
53
|
+
"dataObjectReference",
|
|
54
|
+
"dataStoreReference",
|
|
55
|
+
"group"
|
|
56
|
+
]);
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region ../notations/index.ts
|
|
59
|
+
const NOTATIONS = [
|
|
60
|
+
{
|
|
61
|
+
id: "bpmn",
|
|
62
|
+
label: "BPMN 2.0",
|
|
63
|
+
noun: {
|
|
64
|
+
singular: "process",
|
|
65
|
+
plural: "processes"
|
|
66
|
+
},
|
|
67
|
+
extensions: [".bpmn"],
|
|
68
|
+
mediaKind: "xml",
|
|
69
|
+
docShape: "text",
|
|
70
|
+
monacoLanguage: "xml",
|
|
71
|
+
graphHints: {
|
|
72
|
+
flowEdgeKinds: ["sequenceFlow"],
|
|
73
|
+
entryNodeTypes: ["startEvent"]
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "dmn",
|
|
78
|
+
label: "DMN",
|
|
79
|
+
noun: {
|
|
80
|
+
singular: "decision",
|
|
81
|
+
plural: "decisions"
|
|
82
|
+
},
|
|
83
|
+
extensions: [".dmn"],
|
|
84
|
+
mediaKind: "xml",
|
|
85
|
+
docShape: "text",
|
|
86
|
+
monacoLanguage: "xml",
|
|
87
|
+
graphHints: {
|
|
88
|
+
flowEdgeKinds: ["informationRequirement"],
|
|
89
|
+
entryNodeTypes: []
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "wardley",
|
|
94
|
+
label: "Wardley Map",
|
|
95
|
+
noun: {
|
|
96
|
+
singular: "wardley map",
|
|
97
|
+
plural: "wardley maps"
|
|
98
|
+
},
|
|
99
|
+
extensions: [".owm", ".wmap"],
|
|
100
|
+
mediaKind: "dsl",
|
|
101
|
+
docShape: "text",
|
|
102
|
+
monacoLanguage: "plaintext",
|
|
103
|
+
graphHints: {
|
|
104
|
+
flowEdgeKinds: ["dependency"],
|
|
105
|
+
entryNodeTypes: []
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: "team-topology",
|
|
110
|
+
label: "Team Topology",
|
|
111
|
+
noun: {
|
|
112
|
+
singular: "team topology",
|
|
113
|
+
plural: "team topologies"
|
|
114
|
+
},
|
|
115
|
+
extensions: [".tt", ".ttm.json"],
|
|
116
|
+
mediaKind: "json",
|
|
117
|
+
docShape: "text",
|
|
118
|
+
monacoLanguage: "json"
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "event-storming",
|
|
122
|
+
label: "Event Storming",
|
|
123
|
+
noun: {
|
|
124
|
+
singular: "event storming board",
|
|
125
|
+
plural: "event storming boards"
|
|
126
|
+
},
|
|
127
|
+
extensions: [".storm"],
|
|
128
|
+
mediaKind: "dsl",
|
|
129
|
+
docShape: "text",
|
|
130
|
+
monacoLanguage: "plaintext",
|
|
131
|
+
graphHints: {
|
|
132
|
+
flowEdgeKinds: ["arrow"],
|
|
133
|
+
entryNodeTypes: []
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: "context-map",
|
|
138
|
+
label: "Context Map",
|
|
139
|
+
noun: {
|
|
140
|
+
singular: "context map",
|
|
141
|
+
plural: "context maps"
|
|
142
|
+
},
|
|
143
|
+
extensions: [".cm.json"],
|
|
144
|
+
mediaKind: "json",
|
|
145
|
+
docShape: "text",
|
|
146
|
+
monacoLanguage: "json",
|
|
147
|
+
graphHints: {
|
|
148
|
+
flowEdgeKinds: [
|
|
149
|
+
"partnership",
|
|
150
|
+
"shared-kernel",
|
|
151
|
+
"customer-supplier",
|
|
152
|
+
"upstream-downstream",
|
|
153
|
+
"separate-ways"
|
|
154
|
+
],
|
|
155
|
+
entryNodeTypes: []
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: "value-chain",
|
|
160
|
+
label: "Value Chain",
|
|
161
|
+
noun: {
|
|
162
|
+
singular: "value chain",
|
|
163
|
+
plural: "value chains"
|
|
164
|
+
},
|
|
165
|
+
extensions: [".vc.json"],
|
|
166
|
+
mediaKind: "json",
|
|
167
|
+
docShape: "text",
|
|
168
|
+
monacoLanguage: "json"
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: "markdown",
|
|
172
|
+
label: "Markdown",
|
|
173
|
+
noun: {
|
|
174
|
+
singular: "document",
|
|
175
|
+
plural: "documents"
|
|
176
|
+
},
|
|
177
|
+
extensions: [".md"],
|
|
178
|
+
mediaKind: "markdown",
|
|
179
|
+
docShape: "text",
|
|
180
|
+
monacoLanguage: "markdown"
|
|
181
|
+
}
|
|
182
|
+
];
|
|
183
|
+
function byId(id) {
|
|
184
|
+
return NOTATIONS.find((n) => n.id === id);
|
|
185
|
+
}
|
|
186
|
+
/** longest-suffix winner across every registered extension, so ".vc.json"
|
|
187
|
+
* beats a hypothetical ".json" — shared by byExtension and modelStem */
|
|
188
|
+
function longestMatch(path) {
|
|
189
|
+
let best;
|
|
190
|
+
for (const n of NOTATIONS) for (const ext of n.extensions) if (path.endsWith(ext) && ext.length > (best?.ext.length ?? 0)) best = {
|
|
191
|
+
notation: n,
|
|
192
|
+
ext
|
|
193
|
+
};
|
|
194
|
+
return best;
|
|
195
|
+
}
|
|
196
|
+
function byExtension(path) {
|
|
197
|
+
return longestMatch(path)?.notation;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The file stem that IS a model's id (the content contract: a process is its
|
|
201
|
+
* .bpmn file, a decision its .dmn file). Strips the FULL registered extension,
|
|
202
|
+
* so compound suffixes resolve correctly ("a.vc.json" → "a", never "a.vc");
|
|
203
|
+
* unknown extensions fall back to stripping the final dot-suffix.
|
|
204
|
+
*/
|
|
205
|
+
function modelStem(path) {
|
|
206
|
+
const base = path.split("/").pop() ?? path;
|
|
207
|
+
const ext = longestMatch(base)?.ext;
|
|
208
|
+
return ext ? base.slice(0, -ext.length) : base.replace(/\.[^.]+$/, "");
|
|
209
|
+
}
|
|
210
|
+
[...NOTATIONS.flatMap((n) => n.extensions)];
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region ../notations/extract.ts
|
|
213
|
+
/**
|
|
214
|
+
* extract(raw) => ModelGraph — the analysis seam of the notation registry.
|
|
215
|
+
*
|
|
216
|
+
* Every notation knows how to turn its raw file content into ONE generic
|
|
217
|
+
* graph shape. Analyses (MCP tools, future dashboards) are written against
|
|
218
|
+
* ModelGraph and automatically cover every notation that implements extract —
|
|
219
|
+
* instead of one parser per tool per notation.
|
|
220
|
+
*
|
|
221
|
+
* Node-safe: fast-xml-parser + JSON + regex only, no DOM, no editor libraries.
|
|
222
|
+
*/
|
|
223
|
+
const xml = new XMLParser({
|
|
224
|
+
ignoreAttributes: false,
|
|
225
|
+
attributeNamePrefix: "@_",
|
|
226
|
+
removeNSPrefix: true
|
|
227
|
+
});
|
|
228
|
+
/** the ONE fast-xml-parser configuration (the validator used to carry a
|
|
229
|
+
* byte-identical copy — that is how the two BPMN walks drifted) */
|
|
230
|
+
const parseXml = (raw) => xml.parse(raw);
|
|
231
|
+
const asArray = (v) => v === void 0 ? [] : Array.isArray(v) ? v : [v];
|
|
232
|
+
/**
|
|
233
|
+
* The decision a businessRuleTask delegates to. BPMN 2.0 has no standard
|
|
234
|
+
* attribute for it, so every engine invented its own — all of them are read
|
|
235
|
+
* here (namespace prefixes are already stripped by the parser):
|
|
236
|
+
*
|
|
237
|
+
* decisionRef="x" Camunda 7
|
|
238
|
+
* <extensionElements><calledDecision decisionId="x"/> Camunda 8 / Zeebe
|
|
239
|
+
* calledDecision="x" / calledElement="x" hand-written models
|
|
240
|
+
*
|
|
241
|
+
* The platform contract is the same as for callActivity: the value names the
|
|
242
|
+
* DECISION FILE STEM, so the link resolves against the repo's .dmn files.
|
|
243
|
+
*
|
|
244
|
+
* Exported as THE spelling list: the validator's link-integrity check must
|
|
245
|
+
* read exactly what the platform follows, or a spelling becomes a link that
|
|
246
|
+
* get_process reports and no check ever verifies (that drift shipped once).
|
|
247
|
+
*/
|
|
248
|
+
function decisionRefOf(task) {
|
|
249
|
+
const called = task.extensionElements?.calledDecision;
|
|
250
|
+
const fromExtension = asArray(called)[0]?.["@_decisionId"];
|
|
251
|
+
const ref = task["@_decisionRef"] ?? fromExtension ?? task["@_calledDecision"] ?? task["@_calledElement"] ?? void 0;
|
|
252
|
+
const text = ref === void 0 || ref === null ? "" : String(ref).trim();
|
|
253
|
+
return text === "" ? void 0 : text;
|
|
254
|
+
}
|
|
255
|
+
/** parse-tree in (parseXml(raw).definitions), the shared model out */
|
|
256
|
+
function readBpmn(defs) {
|
|
257
|
+
const model = {
|
|
258
|
+
containers: [],
|
|
259
|
+
nodes: [],
|
|
260
|
+
flows: [],
|
|
261
|
+
diRequired: /* @__PURE__ */ new Set(),
|
|
262
|
+
lanes: [],
|
|
263
|
+
collaborations: []
|
|
264
|
+
};
|
|
265
|
+
const walk = (el, label, id, isSubProcess) => {
|
|
266
|
+
const container = {
|
|
267
|
+
label,
|
|
268
|
+
id,
|
|
269
|
+
isSubProcess,
|
|
270
|
+
triggeredByEvent: String(el["@_triggeredByEvent"]) === "true",
|
|
271
|
+
nodes: /* @__PURE__ */ new Map(),
|
|
272
|
+
flows: []
|
|
273
|
+
};
|
|
274
|
+
model.containers.push(container);
|
|
275
|
+
for (const [tag, value] of Object.entries(el)) {
|
|
276
|
+
if (tag.startsWith("@_")) continue;
|
|
277
|
+
if (tag === "sequenceFlow") {
|
|
278
|
+
for (const f of asArray(value)) {
|
|
279
|
+
const flow = {
|
|
280
|
+
id: f["@_id"] ?? "",
|
|
281
|
+
from: f["@_sourceRef"] ?? "",
|
|
282
|
+
to: f["@_targetRef"] ?? "",
|
|
283
|
+
name: f["@_name"]
|
|
284
|
+
};
|
|
285
|
+
container.flows.push(flow);
|
|
286
|
+
model.flows.push(flow);
|
|
287
|
+
if (f["@_id"]) model.diRequired.add(f["@_id"]);
|
|
288
|
+
}
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (BPMN_FLOW_NODE_TAGS.has(tag)) {
|
|
292
|
+
for (const node of asArray(value)) {
|
|
293
|
+
const rec = node;
|
|
294
|
+
const nodeId = rec["@_id"];
|
|
295
|
+
if (!nodeId) continue;
|
|
296
|
+
const decisionRef = tag === "businessRuleTask" ? decisionRefOf(node) : void 0;
|
|
297
|
+
const entry = {
|
|
298
|
+
id: nodeId,
|
|
299
|
+
tag,
|
|
300
|
+
name: rec["@_name"],
|
|
301
|
+
...rec["@_attachedToRef"] !== void 0 ? { attachedTo: rec["@_attachedToRef"] } : {},
|
|
302
|
+
...rec["@_calledElement"] !== void 0 ? { calledElement: rec["@_calledElement"] } : {},
|
|
303
|
+
...decisionRef ? { decisionRef } : {},
|
|
304
|
+
container
|
|
305
|
+
};
|
|
306
|
+
model.nodes.push(entry);
|
|
307
|
+
container.nodes.set(nodeId, entry);
|
|
308
|
+
model.diRequired.add(nodeId);
|
|
309
|
+
if (BPMN_SUB_CONTAINER_TAGS.has(tag)) walk(node, `${tag} ${nodeId}`, nodeId, true);
|
|
310
|
+
}
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (BPMN_DI_ARTIFACT_TAGS.has(tag)) {
|
|
314
|
+
for (const node of asArray(value)) if (node["@_id"]) model.diRequired.add(node["@_id"]);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
let processIndex = 0;
|
|
319
|
+
for (const proc of asArray(defs.process)) {
|
|
320
|
+
const rec = proc;
|
|
321
|
+
const pid = rec["@_id"];
|
|
322
|
+
walk(proc, `process ${pid ?? "(anonymous)"}`, pid, false);
|
|
323
|
+
for (const lane of asArray(rec.laneSet?.lane)) model.lanes.push({
|
|
324
|
+
id: lane["@_id"],
|
|
325
|
+
name: lane["@_name"],
|
|
326
|
+
nodeIds: asArray(lane.flowNodeRef).map(String),
|
|
327
|
+
processIndex
|
|
328
|
+
});
|
|
329
|
+
processIndex++;
|
|
330
|
+
}
|
|
331
|
+
for (const collab of asArray(defs.collaboration)) {
|
|
332
|
+
const rec = collab;
|
|
333
|
+
model.collaborations.push({
|
|
334
|
+
pools: asArray(rec.participant).map((pl) => ({
|
|
335
|
+
id: pl["@_id"],
|
|
336
|
+
name: pl["@_name"],
|
|
337
|
+
processRef: pl["@_processRef"]
|
|
338
|
+
})),
|
|
339
|
+
messageFlows: asArray(rec.messageFlow).map((mf) => ({
|
|
340
|
+
id: mf["@_id"],
|
|
341
|
+
from: mf["@_sourceRef"],
|
|
342
|
+
to: mf["@_targetRef"],
|
|
343
|
+
name: mf["@_name"]
|
|
344
|
+
}))
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return model;
|
|
348
|
+
}
|
|
349
|
+
function extractBpmn(raw) {
|
|
350
|
+
const model = readBpmn(parseXml(raw).definitions ?? {});
|
|
351
|
+
const nodes = model.nodes.map((n) => ({
|
|
352
|
+
id: n.id,
|
|
353
|
+
type: n.tag,
|
|
354
|
+
name: n.name,
|
|
355
|
+
extra: {
|
|
356
|
+
...n.container.isSubProcess && n.container.id ? { parent: n.container.id } : {},
|
|
357
|
+
...n.calledElement ? { calledElement: n.calledElement } : {},
|
|
358
|
+
...n.attachedTo ? { attachedTo: n.attachedTo } : {},
|
|
359
|
+
...n.decisionRef ? { decisionRef: n.decisionRef } : {}
|
|
360
|
+
}
|
|
361
|
+
}));
|
|
362
|
+
const edges = model.flows.map((f) => ({
|
|
363
|
+
id: f.id,
|
|
364
|
+
from: f.from,
|
|
365
|
+
to: f.to,
|
|
366
|
+
kind: "sequenceFlow",
|
|
367
|
+
name: f.name
|
|
368
|
+
}));
|
|
369
|
+
for (const collab of model.collaborations) for (const mf of collab.messageFlows) edges.push({
|
|
370
|
+
id: mf.id ?? "",
|
|
371
|
+
from: mf.from ?? "",
|
|
372
|
+
to: mf.to ?? "",
|
|
373
|
+
kind: "messageFlow",
|
|
374
|
+
name: mf.name
|
|
375
|
+
});
|
|
376
|
+
return {
|
|
377
|
+
notation: "bpmn",
|
|
378
|
+
nodes,
|
|
379
|
+
edges,
|
|
380
|
+
meta: {
|
|
381
|
+
lanes: model.lanes.map((l) => ({
|
|
382
|
+
id: l.id,
|
|
383
|
+
name: l.name,
|
|
384
|
+
nodeIds: l.nodeIds
|
|
385
|
+
})),
|
|
386
|
+
pools: model.collaborations.flatMap((c) => c.pools.map((pl) => ({
|
|
387
|
+
id: pl.id ?? "",
|
|
388
|
+
name: pl.name ?? "",
|
|
389
|
+
processRef: pl.processRef ?? ""
|
|
390
|
+
})))
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* A tag's text content as a STRING. fast-xml-parser coerces tag values
|
|
396
|
+
* ("42" → 42, "true" → true), but every DMN entry is FEEL SOURCE TEXT — a
|
|
397
|
+
* rule entry `1` must stay the literal "1", never the number. Empty and
|
|
398
|
+
* self-closing tags collapse to "" (an empty entry means "any" in DMN).
|
|
399
|
+
*/
|
|
400
|
+
function textOf(value) {
|
|
401
|
+
if (value === void 0 || value === null) return "";
|
|
402
|
+
if (typeof value === "object") return textOf(value["#text"]);
|
|
403
|
+
return String(value);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* The target of an informationRequirement — requiredInput/requiredDecision,
|
|
407
|
+
* in that lookup order. THE spelling pair (it was byte-identical in the
|
|
408
|
+
* validator, the decisionRefOf duplication class): `local` means same-file
|
|
409
|
+
* ("#id"); a namespace-qualified href ("other.dmn#id") is NOT local — the
|
|
410
|
+
* validator's dangling check skips it, and extractDmn currently passes it
|
|
411
|
+
* through verbatim (known inconsistency vs analyze_decision, tracked in #86).
|
|
412
|
+
*/
|
|
413
|
+
function requirementHref(req) {
|
|
414
|
+
const href = req.requiredInput?.["@_href"] ?? req.requiredDecision?.["@_href"];
|
|
415
|
+
if (href === void 0) return void 0;
|
|
416
|
+
return {
|
|
417
|
+
href,
|
|
418
|
+
local: href.startsWith("#")
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* DMN: decisions (with their full decision-table logic), input data and the
|
|
423
|
+
* information requirements between them. The table detail rides in
|
|
424
|
+
* node.extra — deriveDecision (derive.ts) turns it into the decision view,
|
|
425
|
+
* and the simulator (@designiq/decisions) into its evaluation model, so this is
|
|
426
|
+
* the ONE DMN parser on the server side.
|
|
427
|
+
*/
|
|
428
|
+
function extractDmn(raw) {
|
|
429
|
+
const defs = parseXml(raw).definitions ?? {};
|
|
430
|
+
const nodes = [];
|
|
431
|
+
const edges = [];
|
|
432
|
+
for (const input of asArray(defs.inputData)) nodes.push({
|
|
433
|
+
id: input["@_id"] ?? "",
|
|
434
|
+
type: "inputData",
|
|
435
|
+
name: input["@_name"] ?? "",
|
|
436
|
+
extra: {
|
|
437
|
+
variable: input.variable?.["@_name"] ?? input["@_name"] ?? "",
|
|
438
|
+
typeRef: input.variable?.["@_typeRef"] ?? "string"
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
for (const decision of asArray(defs.decision)) {
|
|
442
|
+
const table = decision.decisionTable;
|
|
443
|
+
const literal = decision.literalExpression;
|
|
444
|
+
nodes.push({
|
|
445
|
+
id: decision["@_id"],
|
|
446
|
+
type: "decision",
|
|
447
|
+
name: decision["@_name"],
|
|
448
|
+
extra: {
|
|
449
|
+
variable: decision.variable?.["@_name"] ?? decision["@_name"] ?? decision["@_id"],
|
|
450
|
+
typeRef: decision.variable?.["@_typeRef"] ?? "string",
|
|
451
|
+
...table ? {
|
|
452
|
+
kind: "decisionTable",
|
|
453
|
+
hitPolicy: table["@_hitPolicy"] ?? "UNIQUE",
|
|
454
|
+
...table["@_aggregation"] ? { aggregation: table["@_aggregation"] } : {},
|
|
455
|
+
inputs: asArray(table.input).map((i, n) => ({
|
|
456
|
+
id: i["@_id"] ?? `Input_${n + 1}`,
|
|
457
|
+
label: i["@_label"] ?? textOf(i.inputExpression?.text),
|
|
458
|
+
expression: textOf(i.inputExpression?.text),
|
|
459
|
+
typeRef: i.inputExpression?.["@_typeRef"] ?? "string",
|
|
460
|
+
inputValues: splitFeelList(textOf(i.inputValues?.text))
|
|
461
|
+
})),
|
|
462
|
+
outputs: asArray(table.output).map((o, n) => ({
|
|
463
|
+
id: o["@_id"] ?? `Output_${n + 1}`,
|
|
464
|
+
name: o["@_name"] ?? o["@_label"] ?? "",
|
|
465
|
+
label: o["@_label"] ?? o["@_name"] ?? "",
|
|
466
|
+
typeRef: o["@_typeRef"] ?? "string",
|
|
467
|
+
outputValues: splitFeelList(textOf(o.outputValues?.text))
|
|
468
|
+
})),
|
|
469
|
+
rules: asArray(table.rule).map((r, n) => ({
|
|
470
|
+
id: r["@_id"] ?? `Rule_${n + 1}`,
|
|
471
|
+
description: textOf(r.description) || void 0,
|
|
472
|
+
inputEntries: asArray(r.inputEntry).map((e) => textOf(e.text)),
|
|
473
|
+
outputEntries: asArray(r.outputEntry).map((e) => textOf(e.text))
|
|
474
|
+
}))
|
|
475
|
+
} : literal ? {
|
|
476
|
+
kind: "literalExpression",
|
|
477
|
+
expression: textOf(literal.text)
|
|
478
|
+
} : { kind: "none" }
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
for (const req of asArray(decision.informationRequirement)) {
|
|
482
|
+
const target = requirementHref(req);
|
|
483
|
+
if (target && target.href) edges.push({
|
|
484
|
+
id: req["@_id"] ?? `req-${edges.length}`,
|
|
485
|
+
from: target.href.replace(/^#/, ""),
|
|
486
|
+
to: decision["@_id"],
|
|
487
|
+
kind: "informationRequirement"
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
notation: "dmn",
|
|
493
|
+
nodes,
|
|
494
|
+
edges,
|
|
495
|
+
meta: {
|
|
496
|
+
name: defs["@_name"] ?? null,
|
|
497
|
+
namespace: defs["@_namespace"] ?? null
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
/** `"a","b"` (a DMN inputValues/outputValues list) → the raw FEEL items */
|
|
502
|
+
function splitFeelList(raw) {
|
|
503
|
+
const trimmed = raw.trim();
|
|
504
|
+
if (trimmed === "") return [];
|
|
505
|
+
return trimmed.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
506
|
+
}
|
|
507
|
+
/** OWM DSL: `component Name [y, x]`, `Name -> Other`, `evolve Name x` */
|
|
508
|
+
function extractWardley(raw) {
|
|
509
|
+
const nodes = [];
|
|
510
|
+
const edges = [];
|
|
511
|
+
for (const m of raw.matchAll(/^component\s+([^[\n]+?)\s*\[\s*([\d.]+)\s*,\s*([\d.]+)\s*\]/gm)) {
|
|
512
|
+
const evolution = Number(m[3]);
|
|
513
|
+
nodes.push({
|
|
514
|
+
id: m[1] ?? "",
|
|
515
|
+
type: "component",
|
|
516
|
+
name: m[1],
|
|
517
|
+
extra: {
|
|
518
|
+
visibility: Number(m[2]),
|
|
519
|
+
evolution,
|
|
520
|
+
stage: evolution < .25 ? "genesis" : evolution < .5 ? "custom-built" : evolution < .75 ? "product" : "commodity"
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
const directive = /^(title|component|anchor|market|note|annotation|annotations|style|evolution|submap|url|pipeline)\b/;
|
|
525
|
+
let i = 0;
|
|
526
|
+
for (const m of raw.matchAll(/^([^\s>[\n][^>[\n]*?)\s*->\s*([^\n;]+)$/gm)) {
|
|
527
|
+
if (directive.test(m[0] ?? "")) continue;
|
|
528
|
+
edges.push({
|
|
529
|
+
id: `dep-${i++}`,
|
|
530
|
+
from: m[1]?.trim() ?? "",
|
|
531
|
+
to: m[2]?.trim() ?? "",
|
|
532
|
+
kind: "dependency"
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
return {
|
|
536
|
+
notation: "wardley",
|
|
537
|
+
nodes,
|
|
538
|
+
edges
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
function extractTeamTopology(raw) {
|
|
542
|
+
const data = JSON.parse(raw);
|
|
543
|
+
return {
|
|
544
|
+
notation: "team-topology",
|
|
545
|
+
nodes: (data.nodes ?? []).map((n) => ({
|
|
546
|
+
id: n.id,
|
|
547
|
+
type: n.type ?? "team",
|
|
548
|
+
name: n.label ?? n.name,
|
|
549
|
+
extra: n.description ? { description: n.description } : void 0
|
|
550
|
+
})),
|
|
551
|
+
edges: (data.edges ?? []).map((e, i) => ({
|
|
552
|
+
id: e.id ?? `edge-${i}`,
|
|
553
|
+
from: e.source ?? e.from,
|
|
554
|
+
to: e.target ?? e.to,
|
|
555
|
+
kind: e.interaction ?? e.type ?? "interaction"
|
|
556
|
+
}))
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
const STORM_ID_PREFIX = {
|
|
560
|
+
event: "event",
|
|
561
|
+
command: "cmd",
|
|
562
|
+
actor: "actor",
|
|
563
|
+
aggregate: "agg",
|
|
564
|
+
policy: "policy",
|
|
565
|
+
readmodel: "read",
|
|
566
|
+
external: "ext",
|
|
567
|
+
hotspot: "hot"
|
|
568
|
+
};
|
|
569
|
+
/** stickies that can carry a pinned actor/hotspot/note — `(on <Host>)` */
|
|
570
|
+
const STORM_HOST_KINDS = /* @__PURE__ */ new Set([
|
|
571
|
+
"event",
|
|
572
|
+
"command",
|
|
573
|
+
"aggregate",
|
|
574
|
+
"policy",
|
|
575
|
+
"readmodel",
|
|
576
|
+
"external"
|
|
577
|
+
]);
|
|
578
|
+
const STORM_ATTACHABLE_KINDS = /* @__PURE__ */ new Set([
|
|
579
|
+
"actor",
|
|
580
|
+
"hotspot",
|
|
581
|
+
"note"
|
|
582
|
+
]);
|
|
583
|
+
const STORM_COORDS = /\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]/;
|
|
584
|
+
const STORM_POINTS = /\[\s*(\[[^[\]]*\](?:\s*,\s*\[[^[\]]*\])+)\s*\]/;
|
|
585
|
+
const stormSlug = (label) => label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_/, "").replace(/_$/, "") || "x";
|
|
586
|
+
/** the `(on <Host>)` / `(id <id>)` / `(color <c>)` suffixes of a sticky line;
|
|
587
|
+
* `rest` is the text with EVERY parenthesised suffix removed (size/align are
|
|
588
|
+
* note layout — irrelevant to the graph) */
|
|
589
|
+
function stormSuffixes(text) {
|
|
590
|
+
let host;
|
|
591
|
+
let rest = text;
|
|
592
|
+
const on = /\(\s*on\s+/i.exec(text);
|
|
593
|
+
if (on) {
|
|
594
|
+
const start = on.index + on[0].length;
|
|
595
|
+
const close = text.lastIndexOf(")");
|
|
596
|
+
if (close >= start && text.slice(start, close).trim()) {
|
|
597
|
+
host = text.slice(start, close).trim();
|
|
598
|
+
rest = `${text.slice(0, on.index)} ${text.slice(close + 1)}`;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const id = /\(\s*id\s+([A-Za-z0-9_-]+)\s*\)/i.exec(rest)?.[1];
|
|
602
|
+
const color = /\(\s*color\s+(#[0-9a-fA-F]{3,8}|[a-zA-Z][\w-]*)\s*\)/i.exec(rest)?.[1];
|
|
603
|
+
return {
|
|
604
|
+
host,
|
|
605
|
+
id,
|
|
606
|
+
color,
|
|
607
|
+
rest: rest.replace(/\([^)]*\)/g, " ")
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
function extractEventStorming(raw) {
|
|
611
|
+
const nodes = [];
|
|
612
|
+
const edges = [];
|
|
613
|
+
let title = null;
|
|
614
|
+
let level = null;
|
|
615
|
+
const used = /* @__PURE__ */ new Set();
|
|
616
|
+
const nameToId = /* @__PURE__ */ new Map();
|
|
617
|
+
const claim = (id) => {
|
|
618
|
+
if (!id || used.has(id)) return void 0;
|
|
619
|
+
used.add(id);
|
|
620
|
+
return id;
|
|
621
|
+
};
|
|
622
|
+
const alloc = (prefix, label) => {
|
|
623
|
+
const base = `${prefix}_${stormSlug(label)}`;
|
|
624
|
+
let id = base;
|
|
625
|
+
for (let i = 2; used.has(id); i++) id = `${base}_${i}`;
|
|
626
|
+
used.add(id);
|
|
627
|
+
return id;
|
|
628
|
+
};
|
|
629
|
+
const decode = (name) => name.replace(/\\n/g, "\n");
|
|
630
|
+
const arrows = [];
|
|
631
|
+
const pins = [];
|
|
632
|
+
for (const source of raw.replace(/\/\*[\s\S]*?\*\//g, " ").split(/\r?\n/)) {
|
|
633
|
+
const line = source.replace(/(?<!:)\/\/.*$/, "").trim();
|
|
634
|
+
if (!line) continue;
|
|
635
|
+
const kw = (/^[A-Za-z][\w-]*/.exec(line)?.[0] ?? "").toLowerCase();
|
|
636
|
+
const after = line.slice(kw.length).trim();
|
|
637
|
+
const semi = line.indexOf(";");
|
|
638
|
+
const core = (semi >= 0 ? line.slice(0, semi) : line).trim();
|
|
639
|
+
const arrowAt = core.indexOf("->");
|
|
640
|
+
if (arrowAt > 0 && !STORM_COORDS.test(core)) {
|
|
641
|
+
const from = core.slice(0, arrowAt).trim();
|
|
642
|
+
const to = core.slice(arrowAt + 2).trim();
|
|
643
|
+
if (from && to && (kw !== "title" || nameToId.has(decode(from)))) {
|
|
644
|
+
const label = semi >= 0 ? line.slice(semi + 1).trim() : "";
|
|
645
|
+
arrows.push({
|
|
646
|
+
from,
|
|
647
|
+
to,
|
|
648
|
+
...label ? { label } : {}
|
|
649
|
+
});
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (kw === "title") title = after;
|
|
654
|
+
else if (kw === "level") {
|
|
655
|
+
const value = after.toLowerCase();
|
|
656
|
+
if ([
|
|
657
|
+
"big-picture",
|
|
658
|
+
"process",
|
|
659
|
+
"design"
|
|
660
|
+
].includes(value)) level = value;
|
|
661
|
+
} else if (Object.hasOwn(STORM_ID_PREFIX, kw) || kw === "note") {
|
|
662
|
+
const at = STORM_COORDS.exec(after);
|
|
663
|
+
if (!at && after.includes("[")) continue;
|
|
664
|
+
const x = at ? Number(at[1]) : 0;
|
|
665
|
+
const y = at ? Number(at[2]) : 0;
|
|
666
|
+
if (Number.isNaN(x) || Number.isNaN(y)) continue;
|
|
667
|
+
const name = decode(at ? after.slice(0, at.index).trim() : stormSuffixes(after).rest.trim());
|
|
668
|
+
const { host, id: explicit, color } = stormSuffixes(at ? after.slice(at.index + at[0].length) : after);
|
|
669
|
+
if (kw !== "note" && !name) continue;
|
|
670
|
+
const id = kw === "note" ? alloc("note", name || "note") : claim(explicit) ?? alloc(STORM_ID_PREFIX[kw], name);
|
|
671
|
+
const node = {
|
|
672
|
+
id,
|
|
673
|
+
type: kw,
|
|
674
|
+
name,
|
|
675
|
+
extra: {
|
|
676
|
+
x,
|
|
677
|
+
y,
|
|
678
|
+
...color ? { color } : {}
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
nodes.push(node);
|
|
682
|
+
if (kw !== "note" && !nameToId.has(name)) nameToId.set(name, id);
|
|
683
|
+
if (host && STORM_ATTACHABLE_KINDS.has(kw)) pins.push({
|
|
684
|
+
node,
|
|
685
|
+
host
|
|
686
|
+
});
|
|
687
|
+
} else if (kw === "line") {
|
|
688
|
+
const points = STORM_POINTS.exec(after)?.[1];
|
|
689
|
+
const tuples = points ? [...points.matchAll(/\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]/g)] : [];
|
|
690
|
+
if (tuples.length < 2) continue;
|
|
691
|
+
nodes.push({
|
|
692
|
+
id: alloc("draw", "line"),
|
|
693
|
+
type: "drawing",
|
|
694
|
+
extra: {
|
|
695
|
+
x: Number(tuples[0][1]),
|
|
696
|
+
y: Number(tuples[0][2]),
|
|
697
|
+
points: tuples.length
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const resolve = (token) => {
|
|
703
|
+
const id = token.startsWith("#") ? token.slice(1) : nameToId.get(decode(token));
|
|
704
|
+
return id === void 0 ? void 0 : nodes.find((n) => n.id === id);
|
|
705
|
+
};
|
|
706
|
+
for (const { node, host } of pins) {
|
|
707
|
+
const target = resolve(host);
|
|
708
|
+
if (target && STORM_HOST_KINDS.has(target.type)) node.extra = {
|
|
709
|
+
...node.extra,
|
|
710
|
+
attachedTo: target.id
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
let n = 0;
|
|
714
|
+
for (const arrow of arrows) {
|
|
715
|
+
const from = resolve(arrow.from);
|
|
716
|
+
const to = resolve(arrow.to);
|
|
717
|
+
if (!from || !to || !Object.hasOwn(STORM_ID_PREFIX, from.type) || !Object.hasOwn(STORM_ID_PREFIX, to.type)) continue;
|
|
718
|
+
let id = `arrow_${++n}`;
|
|
719
|
+
while (used.has(id)) id = `arrow_${++n}`;
|
|
720
|
+
edges.push({
|
|
721
|
+
id,
|
|
722
|
+
from: from.id,
|
|
723
|
+
to: to.id,
|
|
724
|
+
kind: "arrow",
|
|
725
|
+
name: arrow.label
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
return {
|
|
729
|
+
notation: "event-storming",
|
|
730
|
+
nodes,
|
|
731
|
+
edges,
|
|
732
|
+
meta: {
|
|
733
|
+
title,
|
|
734
|
+
level
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
const CM_SUBDOMAIN_TYPES$1 = /* @__PURE__ */ new Set([
|
|
739
|
+
"core",
|
|
740
|
+
"supporting",
|
|
741
|
+
"generic"
|
|
742
|
+
]);
|
|
743
|
+
function extractContextMap(raw) {
|
|
744
|
+
const parsed = JSON.parse(raw);
|
|
745
|
+
const data = parsed !== null && typeof parsed === "object" ? parsed : {};
|
|
746
|
+
const contexts = Array.isArray(data.contexts) ? data.contexts : [];
|
|
747
|
+
const relationships = Array.isArray(data.relationships) ? data.relationships : [];
|
|
748
|
+
const text = (v) => typeof v === "string" && v !== "" ? v : void 0;
|
|
749
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
750
|
+
const roles = (v) => Array.isArray(v) ? v.filter((r) => typeof r === "string") : [];
|
|
751
|
+
const nodes = [];
|
|
752
|
+
for (const c of contexts) {
|
|
753
|
+
if (c === null || typeof c !== "object" || typeof c.id !== "string" || c.id === "") continue;
|
|
754
|
+
const subdomainType = text(c.subdomainType);
|
|
755
|
+
const team = text(c.team);
|
|
756
|
+
const description = text(c.description);
|
|
757
|
+
nodes.push({
|
|
758
|
+
id: c.id,
|
|
759
|
+
type: subdomainType && CM_SUBDOMAIN_TYPES$1.has(subdomainType) ? subdomainType : "context",
|
|
760
|
+
name: text(c.label),
|
|
761
|
+
extra: {
|
|
762
|
+
...team ? { team } : {},
|
|
763
|
+
...description ? { description } : {},
|
|
764
|
+
x: num(c.position?.x) ?? 0,
|
|
765
|
+
y: num(c.position?.y) ?? 0,
|
|
766
|
+
width: num(c.size?.width) ?? 0,
|
|
767
|
+
height: num(c.size?.height) ?? 0
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
const edges = [];
|
|
772
|
+
for (const [i, r] of relationships.entries()) {
|
|
773
|
+
if (r === null || typeof r !== "object") continue;
|
|
774
|
+
const upstreamRoles = roles(r.upstreamRoles);
|
|
775
|
+
const downstreamRoles = roles(r.downstreamRoles);
|
|
776
|
+
const implementationTechnology = text(r.implementationTechnology);
|
|
777
|
+
const description = text(r.description);
|
|
778
|
+
const extra = {
|
|
779
|
+
...upstreamRoles.length > 0 ? { upstreamRoles } : {},
|
|
780
|
+
...downstreamRoles.length > 0 ? { downstreamRoles } : {},
|
|
781
|
+
...implementationTechnology ? { implementationTechnology } : {},
|
|
782
|
+
...description ? { description } : {}
|
|
783
|
+
};
|
|
784
|
+
edges.push({
|
|
785
|
+
id: text(r.id) ?? `rel-${i}`,
|
|
786
|
+
from: text(r.from) ?? "",
|
|
787
|
+
to: text(r.to) ?? "",
|
|
788
|
+
kind: text(r.pattern) ?? "relationship",
|
|
789
|
+
name: text(r.label),
|
|
790
|
+
...Object.keys(extra).length > 0 ? { extra } : {}
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
notation: "context-map",
|
|
795
|
+
nodes,
|
|
796
|
+
edges,
|
|
797
|
+
meta: { title: text(data.title) ?? null }
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
function extractValueChain(raw) {
|
|
801
|
+
const data = JSON.parse(raw);
|
|
802
|
+
return {
|
|
803
|
+
notation: "value-chain",
|
|
804
|
+
nodes: (data.elements ?? []).map((el) => ({
|
|
805
|
+
id: el.id,
|
|
806
|
+
type: el.elementType ?? "element",
|
|
807
|
+
name: el.label ?? el.name
|
|
808
|
+
})),
|
|
809
|
+
edges: (data.connections ?? []).map((c, i) => ({
|
|
810
|
+
id: c.id ?? `conn-${i}`,
|
|
811
|
+
from: c.source,
|
|
812
|
+
to: c.target,
|
|
813
|
+
kind: c.connectionType ?? "connection"
|
|
814
|
+
}))
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
const EXTRACTORS = {
|
|
818
|
+
bpmn: extractBpmn,
|
|
819
|
+
dmn: extractDmn,
|
|
820
|
+
wardley: extractWardley,
|
|
821
|
+
"team-topology": extractTeamTopology,
|
|
822
|
+
"event-storming": extractEventStorming,
|
|
823
|
+
"context-map": extractContextMap,
|
|
824
|
+
"value-chain": extractValueChain
|
|
825
|
+
};
|
|
826
|
+
/** notation id or file path in, ModelGraph out; undefined = no extractor registered */
|
|
827
|
+
function extractModelGraph(notationOrPath, raw) {
|
|
828
|
+
const notation = byId(notationOrPath) ?? byExtension(notationOrPath);
|
|
829
|
+
const extractor = notation && EXTRACTORS[notation.id];
|
|
830
|
+
return extractor ? extractor(raw) : void 0;
|
|
831
|
+
}
|
|
832
|
+
//#endregion
|
|
833
|
+
//#region ../notations/refs.ts
|
|
834
|
+
const REF_EMITTERS = { bpmn: (graph) => {
|
|
835
|
+
const refs = [];
|
|
836
|
+
for (const n of graph.nodes) {
|
|
837
|
+
const calls = n.extra?.calledElement;
|
|
838
|
+
if (n.type === "callActivity" && typeof calls === "string" && calls) refs.push({
|
|
839
|
+
fromElement: n.id,
|
|
840
|
+
rel: "calls",
|
|
841
|
+
to: {
|
|
842
|
+
id: calls,
|
|
843
|
+
notation: "bpmn"
|
|
844
|
+
},
|
|
845
|
+
strength: "required"
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
for (const n of graph.nodes) {
|
|
849
|
+
const decides = n.extra?.decisionRef;
|
|
850
|
+
if (n.type === "businessRuleTask" && typeof decides === "string" && decides) refs.push({
|
|
851
|
+
fromElement: n.id,
|
|
852
|
+
rel: "decides",
|
|
853
|
+
to: {
|
|
854
|
+
id: decides,
|
|
855
|
+
notation: "dmn"
|
|
856
|
+
},
|
|
857
|
+
strength: "required"
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
return refs;
|
|
861
|
+
} };
|
|
862
|
+
/**
|
|
863
|
+
* THE generic refs dispatch: every outgoing cross-model reference of the
|
|
864
|
+
* graph; [] when the notation emits none. Registering a notation's emitter
|
|
865
|
+
* here is the whole integration — resolver, index, validation and the
|
|
866
|
+
* release PR section pick it up with zero consumer changes.
|
|
867
|
+
*/
|
|
868
|
+
function refsOf(graph) {
|
|
869
|
+
return REF_EMITTERS[graph.notation]?.(graph) ?? [];
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Every name the contract file is accepted under, in precedence order. The
|
|
873
|
+
* second one is the legacy name and stays readable permanently: customer
|
|
874
|
+
* repos, the starter template and every released image <= 4.2.0 carry it.
|
|
875
|
+
*/
|
|
876
|
+
const CONTENT_CONFIG_FILES = ["designiq.yml", "bpmiq.yml"];
|
|
877
|
+
/**
|
|
878
|
+
* The NAME of the contract file at `root`: the first of CONTENT_CONFIG_FILES
|
|
879
|
+
* that exists. By existence ONLY — whether it parses is loadContentConfig's
|
|
880
|
+
* business. A present-but-broken first choice must NOT fall through to the
|
|
881
|
+
* next name: the file is plausibly mid-edit in a live session, and a fallback
|
|
882
|
+
* would silently flip the room-containment folder to a different one.
|
|
883
|
+
*/
|
|
884
|
+
function resolveContentConfigFile(root) {
|
|
885
|
+
return CONTENT_CONFIG_FILES.find((name) => isContractFile(root, name));
|
|
886
|
+
}
|
|
887
|
+
/** a regular FILE of that name — a folder that merely carries a contract name
|
|
888
|
+
* (anyone may create one in a repo whose models live at the root) must not
|
|
889
|
+
* shadow the real contract file under the other name */
|
|
890
|
+
function isContractFile(root, name) {
|
|
891
|
+
try {
|
|
892
|
+
return statSync(join(root, name)).isFile();
|
|
893
|
+
} catch {
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
`${CONTENT_CONFIG_FILES[1]}`;
|
|
898
|
+
/**
|
|
899
|
+
* The folder ONE contract file names — `models:`, the legacy `processes:` key
|
|
900
|
+
* stays a full alias (existing repos remain valid unchanged). `undefined` for
|
|
901
|
+
* an unreadable/unparseable/ill-typed file.
|
|
902
|
+
*/
|
|
903
|
+
function readContentFolder(root, name) {
|
|
904
|
+
let parsed;
|
|
905
|
+
try {
|
|
906
|
+
parsed = parse(readFileSync(join(root, name), "utf8"));
|
|
907
|
+
} catch {
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const raw = parsed;
|
|
911
|
+
const folder = raw?.models ?? raw?.processes;
|
|
912
|
+
if (typeof folder !== "string" || folder.trim().length === 0) return void 0;
|
|
913
|
+
const normalized = posix.normalize(folder.trim()).replace(/\/+$/, "");
|
|
914
|
+
if (normalized === "" || normalized === ".") return ".";
|
|
915
|
+
if (normalized.startsWith("/") || normalized.split("/").includes("..")) return void 0;
|
|
916
|
+
return normalized;
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Read + validate the root contract file (resolveContentConfigFile picks which
|
|
920
|
+
* name). `undefined` means "not a content repo" — an unreadable/unparseable/
|
|
921
|
+
* ill-typed config must degrade to that, never crash a listing (the file is
|
|
922
|
+
* plausibly mid-edit in a live session).
|
|
923
|
+
*/
|
|
924
|
+
function loadContentConfig(root) {
|
|
925
|
+
const name = resolveContentConfigFile(root);
|
|
926
|
+
const folder = name === void 0 ? void 0 : readContentFolder(root, name);
|
|
927
|
+
return folder === void 0 ? void 0 : {
|
|
928
|
+
models: folder,
|
|
929
|
+
processes: folder
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Every file with a registered notation extension under the configured folder
|
|
934
|
+
* (recursive, sorted by path). The id is the file stem (modelStem) so it stays
|
|
935
|
+
* a single URL segment; ids are unique PER NOTATION (order.bpmn and order.dmn
|
|
936
|
+
* are legal separate namespaces) — a second same-notation file with the same
|
|
937
|
+
* stem elsewhere in the tree is skipped + logged rather than shadowing the
|
|
938
|
+
* first.
|
|
939
|
+
*/
|
|
940
|
+
async function discoverModels(root, cfg) {
|
|
941
|
+
const dir = join(root, cfg.models);
|
|
942
|
+
let entries;
|
|
943
|
+
try {
|
|
944
|
+
entries = await readdir(dir, {
|
|
945
|
+
withFileTypes: true,
|
|
946
|
+
recursive: true
|
|
947
|
+
});
|
|
948
|
+
} catch {
|
|
949
|
+
return [];
|
|
950
|
+
}
|
|
951
|
+
const paths = entries.filter((e) => e.isFile()).map((e) => join(cfg.models, relative(dir, join(e.parentPath, e.name)))).filter((p) => !p.split("/").some((s) => s.startsWith(".") || s === "node_modules")).sort();
|
|
952
|
+
const out = [];
|
|
953
|
+
const seen = /* @__PURE__ */ new Map();
|
|
954
|
+
for (const path of paths) {
|
|
955
|
+
const notation = byExtension(path);
|
|
956
|
+
if (!notation) continue;
|
|
957
|
+
const id = modelStem(path);
|
|
958
|
+
const key = `${notation.id}/${id}`;
|
|
959
|
+
const first = seen.get(key);
|
|
960
|
+
if (first) {
|
|
961
|
+
console.log(`content: duplicate ${notation.id} id '${id}' (${path}) — keeping ${first}`);
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
seen.set(key, path);
|
|
965
|
+
out.push({
|
|
966
|
+
id,
|
|
967
|
+
path,
|
|
968
|
+
notation: notation.id
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
return out;
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Build the reference index of a checkout: discover every model, run its
|
|
975
|
+
* extract + refs capabilities, resolve each reference against the discovered
|
|
976
|
+
* artifacts. Degrades per file (an unreadable/broken model simply emits no
|
|
977
|
+
* refs) — an index build must never fail a listing or a release.
|
|
978
|
+
*/
|
|
979
|
+
async function buildRepoIndex(root, cfg) {
|
|
980
|
+
const artifacts = await discoverModels(root, cfg);
|
|
981
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
982
|
+
for (const a of artifacts) byKey.set(`${a.notation}/${a.id}`, a);
|
|
983
|
+
const byId = (id, notation) => notation ? byKey.get(`${notation}/${id}`) : artifacts.find((a) => a.id === id);
|
|
984
|
+
const refs = [];
|
|
985
|
+
for (const artifact of artifacts) {
|
|
986
|
+
let emitted;
|
|
987
|
+
try {
|
|
988
|
+
const raw = readFileSync(join(root, artifact.path), "utf8");
|
|
989
|
+
const graph = extractModelGraph(artifact.path, raw);
|
|
990
|
+
emitted = graph ? refsOf(graph) : [];
|
|
991
|
+
} catch {
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
for (const ref of emitted) refs.push({
|
|
995
|
+
from: {
|
|
996
|
+
path: artifact.path,
|
|
997
|
+
notation: artifact.notation,
|
|
998
|
+
...ref.fromElement ? { element: ref.fromElement } : {}
|
|
999
|
+
},
|
|
1000
|
+
rel: ref.rel,
|
|
1001
|
+
to: ref.to,
|
|
1002
|
+
strength: ref.strength,
|
|
1003
|
+
resolved: byId(ref.to.id, ref.to.notation)
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
return {
|
|
1007
|
+
artifacts,
|
|
1008
|
+
refs,
|
|
1009
|
+
byId,
|
|
1010
|
+
outgoing: (path) => refs.filter((r) => r.from.path === path),
|
|
1011
|
+
incoming: (path) => refs.filter((r) => r.resolved?.path === path)
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
/** every .bpmn under the configured folder — a process IS its BPMN file */
|
|
1015
|
+
async function discoverProcesses(root, cfg) {
|
|
1016
|
+
return (await discoverModels(root, cfg)).filter((m) => m.notation === "bpmn").map(({ id, path }) => ({
|
|
1017
|
+
id,
|
|
1018
|
+
path
|
|
1019
|
+
}));
|
|
1020
|
+
}
|
|
1021
|
+
//#endregion
|
|
1022
|
+
//#region ../contracts/src/todo-anchor.ts
|
|
1023
|
+
const OPEN = "<!-- bpmiq:todo v1";
|
|
1024
|
+
const CLOSE = "-->";
|
|
1025
|
+
function parseAnchor(text) {
|
|
1026
|
+
const start = text.indexOf(OPEN);
|
|
1027
|
+
if (start === -1) return null;
|
|
1028
|
+
const end = text.indexOf(CLOSE, start);
|
|
1029
|
+
if (end === -1) return null;
|
|
1030
|
+
const anchor = {
|
|
1031
|
+
process: "",
|
|
1032
|
+
file: null,
|
|
1033
|
+
elements: [],
|
|
1034
|
+
processVersion: null
|
|
1035
|
+
};
|
|
1036
|
+
for (const raw of text.slice(start + 18, end).split("\n")) {
|
|
1037
|
+
const line = raw.trim();
|
|
1038
|
+
const sep = line.indexOf(": ");
|
|
1039
|
+
if (sep === -1) continue;
|
|
1040
|
+
const key = line.slice(0, sep);
|
|
1041
|
+
const value = line.slice(sep + 2).trim();
|
|
1042
|
+
if (key === "process") anchor.process = value;
|
|
1043
|
+
else if (key === "file") anchor.file = value;
|
|
1044
|
+
else if (key === "version") anchor.processVersion = value;
|
|
1045
|
+
else if (key === "element") {
|
|
1046
|
+
const pipe = value.indexOf(" | ");
|
|
1047
|
+
if (pipe === -1) anchor.elements.push({
|
|
1048
|
+
id: value.replace(/ \|$/, "").trim(),
|
|
1049
|
+
name: null
|
|
1050
|
+
});
|
|
1051
|
+
else anchor.elements.push({
|
|
1052
|
+
id: value.slice(0, pipe).trim(),
|
|
1053
|
+
name: value.slice(pipe + 3).trim() || null
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
return anchor.process ? anchor : null;
|
|
1058
|
+
}
|
|
1059
|
+
//#endregion
|
|
1060
|
+
//#region ../github-app/src/todos.ts
|
|
1061
|
+
/**
|
|
1062
|
+
* The GitHub-side todo vocabulary: labels and the issues-list row shape. This
|
|
1063
|
+
* is TRACKER vocabulary, not platform wire types — ports/issue-tracker.ts
|
|
1064
|
+
* deliberately assumes no numbers, labels or markdown (Jira has neither), so
|
|
1065
|
+
* this lives with the GitHub plumbing, not in @designiq/contracts. The body
|
|
1066
|
+
* MARKUP (anchor block, attribution line) stays adapter-owned per that port.
|
|
1067
|
+
*
|
|
1068
|
+
* Zero imports — the read-only MCP server can adopt this without pulling the
|
|
1069
|
+
* App/JWT half of the package into its published bundle.
|
|
1070
|
+
*/
|
|
1071
|
+
/** the label every platform-managed todo carries */
|
|
1072
|
+
const TODO_LABEL = "todo";
|
|
1073
|
+
/** the per-process label that makes the tracker-side filter cheap */
|
|
1074
|
+
const processLabel = (processId) => `process:${processId}`;
|
|
1075
|
+
/** the `labels=` filter for listing: every todo, or one process's */
|
|
1076
|
+
const todoLabelQuery = (processId) => processId ? `${TODO_LABEL},${processLabel(processId)}` : TODO_LABEL;
|
|
1077
|
+
const isPullRequestRow = (row) => row.pull_request !== void 0;
|
|
1078
|
+
//#endregion
|
|
1079
|
+
//#region ../mcp-kit/index.ts
|
|
1080
|
+
/** a successful tool result: strings pass through, everything else is
|
|
1081
|
+
* pretty-printed JSON in the first text block */
|
|
1082
|
+
const ok = (value) => ({ content: [{
|
|
1083
|
+
type: "text",
|
|
1084
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
1085
|
+
}] });
|
|
1086
|
+
/** an errored tool result — `message` must be agent-actionable text */
|
|
1087
|
+
const fail = (message) => ({
|
|
1088
|
+
content: [{
|
|
1089
|
+
type: "text",
|
|
1090
|
+
text: message
|
|
1091
|
+
}],
|
|
1092
|
+
isError: true
|
|
1093
|
+
});
|
|
1094
|
+
/**
|
|
1095
|
+
* Wrap a tool handler: thrown errors become `fail(...)` results. The prefix is
|
|
1096
|
+
* a deliberate per-server policy, not an accident of the old copies: the Live
|
|
1097
|
+
* Host passes none (its AppErrors carry actionable, agent-readable messages —
|
|
1098
|
+
* validation findings, conflict guidance, authz denials), the read-only server
|
|
1099
|
+
* marks everything unexpected.
|
|
1100
|
+
*/
|
|
1101
|
+
const safe$1 = (fn, opts = {}) => async (args) => {
|
|
1102
|
+
try {
|
|
1103
|
+
return await fn(args ?? {});
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
return fail(`${opts.prefix ?? ""}${err.message}`);
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
/** tool annotations: repo-local read */
|
|
1109
|
+
const READ = {
|
|
1110
|
+
readOnlyHint: true,
|
|
1111
|
+
openWorldHint: false
|
|
1112
|
+
};
|
|
1113
|
+
//#endregion
|
|
1114
|
+
//#region ../notations/derive.ts
|
|
1115
|
+
/**
|
|
1116
|
+
* The derive capability: ModelGraph → human/agent-readable views.
|
|
1117
|
+
*
|
|
1118
|
+
* deriveView(graph) is the GENERIC dispatch — one common core (name, summary,
|
|
1119
|
+
* stats, rich payload in `detail`) for every notation with a registered
|
|
1120
|
+
* deriver; consumers that want "a view of whatever this model is" (listings,
|
|
1121
|
+
* get_view) call it and never gate on the notation.
|
|
1122
|
+
*
|
|
1123
|
+
* deriveProcess(graph) => DerivedProcess is the rich BPMN view — the
|
|
1124
|
+
* "process.yaml on-the-fly": the slim content contract has NO hand-written
|
|
1125
|
+
* process metadata, so this turns the generic ModelGraph (extract.ts) into
|
|
1126
|
+
* name, roles (BPMN lanes), steps/events/gateways, flow and sub-process calls.
|
|
1127
|
+
* deriveDecision(graph) is its DMN sibling (decision table + DRD wiring).
|
|
1128
|
+
* Consumers that need those FIELDS keep calling them directly.
|
|
1129
|
+
*
|
|
1130
|
+
* Pure + browser-safe: operates on the already-parsed ModelGraph, no fs, no
|
|
1131
|
+
* XML — this module is imported eagerly by the web SPA
|
|
1132
|
+
* (CI: notations-index-and-derive-stay-browser-safe).
|
|
1133
|
+
*/
|
|
1134
|
+
/**
|
|
1135
|
+
* Derive the process view from a BPMN ModelGraph. Non-BPMN graphs yield an empty
|
|
1136
|
+
* process (only BPMN carries process semantics); callers gate on graph.notation.
|
|
1137
|
+
*/
|
|
1138
|
+
function deriveProcess(graph) {
|
|
1139
|
+
const meta = graph.meta ?? {};
|
|
1140
|
+
const lanes = meta.lanes ?? [];
|
|
1141
|
+
const pools = (meta.pools ?? []).map((p) => ({
|
|
1142
|
+
id: p.id,
|
|
1143
|
+
name: p.name || null
|
|
1144
|
+
}));
|
|
1145
|
+
const roleOf = /* @__PURE__ */ new Map();
|
|
1146
|
+
for (const lane of lanes) for (const nodeId of lane.nodeIds) roleOf.set(nodeId, lane.name ?? null);
|
|
1147
|
+
const element = (n) => {
|
|
1148
|
+
const calls = n.extra?.calledElement ?? void 0;
|
|
1149
|
+
const decides = n.extra?.decisionRef ?? void 0;
|
|
1150
|
+
return {
|
|
1151
|
+
id: n.id,
|
|
1152
|
+
name: n.name ?? null,
|
|
1153
|
+
type: n.type,
|
|
1154
|
+
...roleOf.has(n.id) ? { role: roleOf.get(n.id) ?? null } : {},
|
|
1155
|
+
...calls ? { calls } : {},
|
|
1156
|
+
...decides ? { decides } : {}
|
|
1157
|
+
};
|
|
1158
|
+
};
|
|
1159
|
+
const steps = graph.nodes.filter((n) => kindOf(n.type) === "activity").map(element);
|
|
1160
|
+
const events = graph.nodes.filter((n) => kindOf(n.type) === "event").map(element);
|
|
1161
|
+
const gateways = graph.nodes.filter((n) => kindOf(n.type) === "gateway").map(element);
|
|
1162
|
+
const flows = graph.edges.map((e) => ({
|
|
1163
|
+
id: e.id,
|
|
1164
|
+
from: e.from,
|
|
1165
|
+
to: e.to,
|
|
1166
|
+
name: e.name ?? null,
|
|
1167
|
+
kind: e.kind
|
|
1168
|
+
}));
|
|
1169
|
+
const calls = graph.nodes.filter((n) => n.type === "callActivity" && n.extra?.calledElement).map((n) => ({
|
|
1170
|
+
id: n.id,
|
|
1171
|
+
name: n.name ?? null,
|
|
1172
|
+
calledElement: String(n.extra?.calledElement)
|
|
1173
|
+
}));
|
|
1174
|
+
const decisions = graph.nodes.filter((n) => n.extra?.decisionRef).map((n) => ({
|
|
1175
|
+
id: n.id,
|
|
1176
|
+
name: n.name ?? null,
|
|
1177
|
+
decisionRef: String(n.extra?.decisionRef)
|
|
1178
|
+
}));
|
|
1179
|
+
const roles = lanes.map((l) => ({
|
|
1180
|
+
id: l.id,
|
|
1181
|
+
name: l.name ?? null,
|
|
1182
|
+
stepIds: l.nodeIds
|
|
1183
|
+
}));
|
|
1184
|
+
return {
|
|
1185
|
+
name: pools.length === 1 ? pools[0]?.name || null : null,
|
|
1186
|
+
pools,
|
|
1187
|
+
roles,
|
|
1188
|
+
steps,
|
|
1189
|
+
events,
|
|
1190
|
+
gateways,
|
|
1191
|
+
flows,
|
|
1192
|
+
calls,
|
|
1193
|
+
decisions,
|
|
1194
|
+
stats: {
|
|
1195
|
+
steps: steps.length,
|
|
1196
|
+
events: events.length,
|
|
1197
|
+
gateways: gateways.length,
|
|
1198
|
+
flows: flows.length,
|
|
1199
|
+
roles: roles.length
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
const HIT_POLICIES = /* @__PURE__ */ new Set([
|
|
1204
|
+
"UNIQUE",
|
|
1205
|
+
"FIRST",
|
|
1206
|
+
"ANY",
|
|
1207
|
+
"PRIORITY",
|
|
1208
|
+
"COLLECT",
|
|
1209
|
+
"RULE ORDER",
|
|
1210
|
+
"OUTPUT ORDER"
|
|
1211
|
+
]);
|
|
1212
|
+
const AGGREGATIONS = /* @__PURE__ */ new Set([
|
|
1213
|
+
"SUM",
|
|
1214
|
+
"MIN",
|
|
1215
|
+
"MAX",
|
|
1216
|
+
"COUNT"
|
|
1217
|
+
]);
|
|
1218
|
+
/**
|
|
1219
|
+
* Derive the decision view from a DMN ModelGraph. Non-DMN graphs yield an
|
|
1220
|
+
* empty decision (only DMN carries decision semantics); callers gate on
|
|
1221
|
+
* graph.notation, exactly like deriveProcess.
|
|
1222
|
+
*/
|
|
1223
|
+
function deriveDecision(graph) {
|
|
1224
|
+
const meta = graph.meta ?? {};
|
|
1225
|
+
const inputData = graph.nodes.filter((n) => n.type === "inputData").map((n) => ({
|
|
1226
|
+
id: n.id,
|
|
1227
|
+
name: n.name || null,
|
|
1228
|
+
variable: String(n.extra?.variable ?? n.name ?? n.id),
|
|
1229
|
+
typeRef: String(n.extra?.typeRef ?? "string")
|
|
1230
|
+
}));
|
|
1231
|
+
const inputDataIds = new Set(inputData.map((i) => i.id));
|
|
1232
|
+
const decisions = graph.nodes.filter((n) => n.type === "decision").map((n) => {
|
|
1233
|
+
const extra = n.extra ?? {};
|
|
1234
|
+
const sources = graph.edges.filter((e) => e.kind === "informationRequirement" && e.to === n.id);
|
|
1235
|
+
const hitPolicy = String(extra.hitPolicy ?? "");
|
|
1236
|
+
const aggregation = String(extra.aggregation ?? "");
|
|
1237
|
+
const rules = extra.rules ?? [];
|
|
1238
|
+
return {
|
|
1239
|
+
id: n.id,
|
|
1240
|
+
name: n.name || null,
|
|
1241
|
+
variable: String(extra.variable ?? n.name ?? n.id),
|
|
1242
|
+
typeRef: String(extra.typeRef ?? "string"),
|
|
1243
|
+
kind: extra.kind ?? "none",
|
|
1244
|
+
...HIT_POLICIES.has(hitPolicy) ? { hitPolicy } : {},
|
|
1245
|
+
...AGGREGATIONS.has(aggregation) ? { aggregation } : {},
|
|
1246
|
+
inputs: (extra.inputs ?? []).map((i) => ({ ...i })),
|
|
1247
|
+
outputs: (extra.outputs ?? []).map((o) => ({ ...o })),
|
|
1248
|
+
rules: rules.map((r) => ({
|
|
1249
|
+
id: String(r.id ?? ""),
|
|
1250
|
+
when: r.inputEntries ?? [],
|
|
1251
|
+
then: r.outputEntries ?? [],
|
|
1252
|
+
...r.description ? { description: String(r.description) } : {}
|
|
1253
|
+
})),
|
|
1254
|
+
...extra.expression ? { expression: String(extra.expression) } : {},
|
|
1255
|
+
requires: {
|
|
1256
|
+
inputData: sources.filter((e) => inputDataIds.has(e.from)).map((e) => e.from),
|
|
1257
|
+
decisions: sources.filter((e) => !inputDataIds.has(e.from)).map((e) => e.from)
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
});
|
|
1261
|
+
return {
|
|
1262
|
+
name: meta.name || null,
|
|
1263
|
+
decisions,
|
|
1264
|
+
inputData,
|
|
1265
|
+
stats: {
|
|
1266
|
+
decisions: decisions.length,
|
|
1267
|
+
inputData: inputData.length,
|
|
1268
|
+
rules: decisions.reduce((sum, d) => sum + d.rules.length, 0)
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
/** "3 steps, 1 gateway" — deterministic stats prose for the summary line */
|
|
1273
|
+
const counted = (stats) => Object.entries(stats).map(([k, v]) => `${v} ${v === 1 ? k.replace(/ies$/, "y").replace(/s$/, "") : k}`).join(", ");
|
|
1274
|
+
/** the sticky kinds of an event storming board — the nodes with a place on
|
|
1275
|
+
* the timeline (notes and drawings are annotations). The .storm vocabulary,
|
|
1276
|
+
* mirrored here so this eager module stays free of extract.ts values */
|
|
1277
|
+
const STORM_STICKY_KINDS = /* @__PURE__ */ new Set([
|
|
1278
|
+
"event",
|
|
1279
|
+
"command",
|
|
1280
|
+
"actor",
|
|
1281
|
+
"aggregate",
|
|
1282
|
+
"policy",
|
|
1283
|
+
"readmodel",
|
|
1284
|
+
"external",
|
|
1285
|
+
"hotspot"
|
|
1286
|
+
]);
|
|
1287
|
+
/** the board read the way a facilitator reads it — stickies left to right
|
|
1288
|
+
* (x, then y, then id: the schema-model's own sortByTimeline rule) */
|
|
1289
|
+
function stormTimeline(graph) {
|
|
1290
|
+
const pos = (n) => [Number(n.extra?.x ?? 0), Number(n.extra?.y ?? 0)];
|
|
1291
|
+
return graph.nodes.filter((n) => STORM_STICKY_KINDS.has(n.type)).sort((a, b) => {
|
|
1292
|
+
const [ax, ay] = pos(a);
|
|
1293
|
+
const [bx, by] = pos(b);
|
|
1294
|
+
return ax - bx || ay - by || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
1295
|
+
}).map((n) => ({
|
|
1296
|
+
id: n.id,
|
|
1297
|
+
type: n.type,
|
|
1298
|
+
name: n.name ?? null
|
|
1299
|
+
}));
|
|
1300
|
+
}
|
|
1301
|
+
/** the context-map vocabulary (@miragon/context-maps-schema-model
|
|
1302
|
+
* SUBDOMAIN_TYPES / RELATIONSHIP_PATTERNS), in the notation's own order —
|
|
1303
|
+
* mirrored here like the storm kinds so this eager module stays zero-dep */
|
|
1304
|
+
const CM_SUBDOMAIN_TYPES = [
|
|
1305
|
+
"core",
|
|
1306
|
+
"supporting",
|
|
1307
|
+
"generic"
|
|
1308
|
+
];
|
|
1309
|
+
const CM_RELATIONSHIP_PATTERNS = [
|
|
1310
|
+
"partnership",
|
|
1311
|
+
"shared-kernel",
|
|
1312
|
+
"customer-supplier",
|
|
1313
|
+
"upstream-downstream",
|
|
1314
|
+
"separate-ways"
|
|
1315
|
+
];
|
|
1316
|
+
const DERIVERS = {
|
|
1317
|
+
bpmn: (graph) => {
|
|
1318
|
+
const d = deriveProcess(graph);
|
|
1319
|
+
return {
|
|
1320
|
+
notation: "bpmn",
|
|
1321
|
+
name: d.name,
|
|
1322
|
+
summary: `Process with ${counted(d.stats)}`,
|
|
1323
|
+
stats: { ...d.stats },
|
|
1324
|
+
detail: d
|
|
1325
|
+
};
|
|
1326
|
+
},
|
|
1327
|
+
dmn: (graph) => {
|
|
1328
|
+
const d = deriveDecision(graph);
|
|
1329
|
+
return {
|
|
1330
|
+
notation: "dmn",
|
|
1331
|
+
name: d.name,
|
|
1332
|
+
summary: `Decision model with ${counted(d.stats)}`,
|
|
1333
|
+
stats: { ...d.stats },
|
|
1334
|
+
detail: d
|
|
1335
|
+
};
|
|
1336
|
+
},
|
|
1337
|
+
wardley: (graph) => {
|
|
1338
|
+
const stats = {
|
|
1339
|
+
components: graph.nodes.length,
|
|
1340
|
+
dependencies: graph.edges.length
|
|
1341
|
+
};
|
|
1342
|
+
return {
|
|
1343
|
+
notation: "wardley",
|
|
1344
|
+
name: null,
|
|
1345
|
+
summary: `Wardley map with ${counted(stats)}`,
|
|
1346
|
+
stats
|
|
1347
|
+
};
|
|
1348
|
+
},
|
|
1349
|
+
"team-topology": (graph) => {
|
|
1350
|
+
const stats = {
|
|
1351
|
+
teams: graph.nodes.length,
|
|
1352
|
+
interactions: graph.edges.length
|
|
1353
|
+
};
|
|
1354
|
+
return {
|
|
1355
|
+
notation: "team-topology",
|
|
1356
|
+
name: null,
|
|
1357
|
+
summary: `Team topology with ${counted(stats)}`,
|
|
1358
|
+
stats
|
|
1359
|
+
};
|
|
1360
|
+
},
|
|
1361
|
+
"event-storming": (graph) => {
|
|
1362
|
+
const count = (type) => graph.nodes.filter((n) => n.type === type).length;
|
|
1363
|
+
const stats = {
|
|
1364
|
+
events: count("event"),
|
|
1365
|
+
commands: count("command"),
|
|
1366
|
+
actors: count("actor"),
|
|
1367
|
+
aggregates: count("aggregate"),
|
|
1368
|
+
policies: count("policy"),
|
|
1369
|
+
readmodels: count("readmodel"),
|
|
1370
|
+
externals: count("external"),
|
|
1371
|
+
hotspots: count("hotspot"),
|
|
1372
|
+
notes: count("note"),
|
|
1373
|
+
drawings: count("drawing"),
|
|
1374
|
+
arrows: graph.edges.length
|
|
1375
|
+
};
|
|
1376
|
+
const present = Object.fromEntries(Object.entries(stats).filter(([, v]) => v > 0));
|
|
1377
|
+
const title = graph.meta?.title;
|
|
1378
|
+
return {
|
|
1379
|
+
notation: "event-storming",
|
|
1380
|
+
name: typeof title === "string" ? title : null,
|
|
1381
|
+
summary: `Event storming board with ${Object.keys(present).length > 0 ? counted(present) : "no elements"}`,
|
|
1382
|
+
stats,
|
|
1383
|
+
detail: {
|
|
1384
|
+
level: graph.meta?.level ?? null,
|
|
1385
|
+
timeline: stormTimeline(graph)
|
|
1386
|
+
}
|
|
1387
|
+
};
|
|
1388
|
+
},
|
|
1389
|
+
"context-map": (graph) => {
|
|
1390
|
+
const stats = {
|
|
1391
|
+
contexts: graph.nodes.length,
|
|
1392
|
+
relationships: graph.edges.length
|
|
1393
|
+
};
|
|
1394
|
+
for (const t of CM_SUBDOMAIN_TYPES) stats[t] = graph.nodes.filter((n) => n.type === t).length;
|
|
1395
|
+
for (const p of CM_RELATIONSHIP_PATTERNS) stats[p] = graph.edges.filter((e) => e.kind === p).length;
|
|
1396
|
+
const breakdown = (keys) => {
|
|
1397
|
+
const present = keys.filter((k) => (stats[k] ?? 0) > 0).map((k) => `${stats[k]} ${k}`);
|
|
1398
|
+
return present.length > 0 ? ` (${present.join(", ")})` : "";
|
|
1399
|
+
};
|
|
1400
|
+
const title = graph.meta?.title;
|
|
1401
|
+
return {
|
|
1402
|
+
notation: "context-map",
|
|
1403
|
+
name: typeof title === "string" ? title : null,
|
|
1404
|
+
summary: `Context map with ${counted({ contexts: stats.contexts })}${breakdown(CM_SUBDOMAIN_TYPES)}, ${counted({ relationships: stats.relationships })}${breakdown(CM_RELATIONSHIP_PATTERNS)}`,
|
|
1405
|
+
stats,
|
|
1406
|
+
detail: {
|
|
1407
|
+
contexts: graph.nodes.map((n) => ({
|
|
1408
|
+
id: n.id,
|
|
1409
|
+
name: n.name ?? null,
|
|
1410
|
+
subdomainType: CM_SUBDOMAIN_TYPES.includes(n.type) ? n.type : null,
|
|
1411
|
+
team: n.extra?.team ?? null,
|
|
1412
|
+
description: n.extra?.description ?? null
|
|
1413
|
+
})),
|
|
1414
|
+
relationships: graph.edges.map((e) => ({
|
|
1415
|
+
id: e.id,
|
|
1416
|
+
from: e.from,
|
|
1417
|
+
to: e.to,
|
|
1418
|
+
pattern: e.kind,
|
|
1419
|
+
upstreamRoles: e.extra?.upstreamRoles ?? [],
|
|
1420
|
+
downstreamRoles: e.extra?.downstreamRoles ?? [],
|
|
1421
|
+
label: e.name ?? null,
|
|
1422
|
+
implementationTechnology: e.extra?.implementationTechnology ?? null
|
|
1423
|
+
}))
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
},
|
|
1427
|
+
"value-chain": (graph) => {
|
|
1428
|
+
const stats = {
|
|
1429
|
+
elements: graph.nodes.length,
|
|
1430
|
+
connections: graph.edges.length
|
|
1431
|
+
};
|
|
1432
|
+
return {
|
|
1433
|
+
notation: "value-chain",
|
|
1434
|
+
name: null,
|
|
1435
|
+
summary: `Value chain with ${counted(stats)}`,
|
|
1436
|
+
stats
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
};
|
|
1440
|
+
/**
|
|
1441
|
+
* THE generic derive dispatch: the view of whatever notation `graph` carries;
|
|
1442
|
+
* `undefined` when no deriver is registered (the caller falls back to the
|
|
1443
|
+
* bare listing row). Registering a notation's deriver here is the whole
|
|
1444
|
+
* integration — no consumer changes.
|
|
1445
|
+
*/
|
|
1446
|
+
function deriveView(graph) {
|
|
1447
|
+
return DERIVERS[graph.notation]?.(graph);
|
|
1448
|
+
}
|
|
1449
|
+
/** whether a notation has a registered deriver — lets tool copy and
|
|
1450
|
+
* capability listings stay registry-driven instead of hand-enumerated */
|
|
1451
|
+
function hasDeriver(notation) {
|
|
1452
|
+
return notation in DERIVERS;
|
|
1453
|
+
}
|
|
1454
|
+
//#endregion
|
|
1455
|
+
//#region tools.ts
|
|
1456
|
+
/**
|
|
1457
|
+
* designiq-mcp-server — tool definitions, shared by both transports:
|
|
1458
|
+
* server.ts → stdio (local: Claude Code picks it up via .mcp.json)
|
|
1459
|
+
* http.ts → Streamable HTTP (remote: fly.io, any MCP client via URL)
|
|
1460
|
+
*
|
|
1461
|
+
* Read-only by construction: only readFileSync + the content-repo discovery,
|
|
1462
|
+
* no write path. All tools carry readOnlyHint so clients may auto-approve them.
|
|
1463
|
+
* The one opt-in exception to "repo-local" is list_todos (registered ONLY when
|
|
1464
|
+
* DESIGNIQ_TODOS_REPO + DESIGNIQ_TODOS_TOKEN are set — also read under the
|
|
1465
|
+
* legacy BPM_TODOS_* names): a read-only GET against the content // legacy-name-ok
|
|
1466
|
+
* repo's issue tracker — the zero-auth default stays untouched.
|
|
1467
|
+
*
|
|
1468
|
+
* The content contract is minimal (@designiq/notations/content): a repo is a
|
|
1469
|
+
* content repo iff it has a root designiq.yml (legacy name: bpmiq.yml) naming // legacy-name-ok
|
|
1470
|
+
* its models folder; a process IS a .bpmn file there. There is NO hand-written
|
|
1471
|
+
* process.yaml — the process view (name, roles, steps, flow, sub-process calls)
|
|
1472
|
+
* is DERIVED from the BPMN on the fly (@designiq/notations/derive). A new
|
|
1473
|
+
* notation with an extractor is automatically analyzable here.
|
|
1474
|
+
*
|
|
1475
|
+
* Content root: pass --root <dir> (server.ts) or DESIGNIQ_CONTENT_ROOT (the
|
|
1476
|
+
* legacy BPM_CONTENT_ROOT is still read) — any content repo works, the bundled // legacy-name-ok
|
|
1477
|
+
* process-documentation is only the default.
|
|
1478
|
+
*/
|
|
1479
|
+
/** The content root the environment names — undefined when it names none. Read
|
|
1480
|
+
* under two names: DESIGNIQ_CONTENT_ROOT wins, BPM_CONTENT_ROOT keeps working. // legacy-name-ok
|
|
1481
|
+
* An EMPTY new name must not shadow a set old one (compose passes "" for an
|
|
1482
|
+
* unset variable). An empty value with nothing behind it stays what it always
|
|
1483
|
+
* was — "" (the cwd), NOT the bundled example — under either name, so swapping
|
|
1484
|
+
* one name for the other never moves the root. */
|
|
1485
|
+
function contentRootFromEnv(env) {
|
|
1486
|
+
return env.DESIGNIQ_CONTENT_ROOT || (env.BPM_CONTENT_ROOT ?? env.DESIGNIQ_CONTENT_ROOT);
|
|
1487
|
+
}
|
|
1488
|
+
/** This file lives at packages/mcp/; the example content repo is process-documentation/. */
|
|
1489
|
+
const DEFAULT_ROOT = contentRootFromEnv(process.env) ?? join(dirname(fileURLToPath(import.meta.url)), "..", "..", "process-documentation");
|
|
1490
|
+
const readText = (path) => {
|
|
1491
|
+
try {
|
|
1492
|
+
return readFileSync(path, "utf8");
|
|
1493
|
+
} catch {
|
|
1494
|
+
return null;
|
|
1495
|
+
}
|
|
1496
|
+
};
|
|
1497
|
+
const safe = (fn) => safe$1(fn, { prefix: "Unexpected error: " });
|
|
1498
|
+
/** every tool here is read-only and repo-local */
|
|
1499
|
+
const READ_ONLY = READ;
|
|
1500
|
+
/** what "flow" means in this graph's notation — undefined = no flow semantics */
|
|
1501
|
+
function flowKindsOf(graph) {
|
|
1502
|
+
const kinds = byId(graph.notation)?.graphHints?.flowEdgeKinds;
|
|
1503
|
+
return kinds && kinds.length > 0 ? kinds : void 0;
|
|
1504
|
+
}
|
|
1505
|
+
/** node -> outgoing flow-edge targets, per the notation's graphHints */
|
|
1506
|
+
function flowTargets(graph, kinds) {
|
|
1507
|
+
const out = /* @__PURE__ */ new Map();
|
|
1508
|
+
for (const e of graph.edges) {
|
|
1509
|
+
if (!kinds.includes(e.kind)) continue;
|
|
1510
|
+
(out.get(e.from) ?? out.set(e.from, []).get(e.from)).push(e.to);
|
|
1511
|
+
}
|
|
1512
|
+
return out;
|
|
1513
|
+
}
|
|
1514
|
+
/** all simple start→end paths through the notation's flow edges, cycle-cut, capped */
|
|
1515
|
+
function enumeratePaths(graph, kinds, max) {
|
|
1516
|
+
const out = flowTargets(graph, kinds);
|
|
1517
|
+
const label = new Map(graph.nodes.map((n) => [n.id, n.name ? `${n.name} (${n.type})` : `${n.id} (${n.type})`]));
|
|
1518
|
+
const entryTypes = byId(graph.notation)?.graphHints?.entryNodeTypes ?? [];
|
|
1519
|
+
const hasIncoming = new Set([...out.values()].flat());
|
|
1520
|
+
const starts = entryTypes.length > 0 ? graph.nodes.filter((n) => entryTypes.includes(n.type) && !n.extra?.parent) : graph.nodes.filter((n) => !hasIncoming.has(n.id) && (out.get(n.id)?.length ?? 0) > 0);
|
|
1521
|
+
const paths = [];
|
|
1522
|
+
let truncated = false;
|
|
1523
|
+
const walk = (node, seen, path) => {
|
|
1524
|
+
if (paths.length >= max) {
|
|
1525
|
+
truncated = true;
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
const next = out.get(node) ?? [];
|
|
1529
|
+
if (next.length === 0) {
|
|
1530
|
+
paths.push(path);
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
for (const target of next) {
|
|
1534
|
+
if (seen.has(target)) continue;
|
|
1535
|
+
walk(target, new Set(seen).add(target), [...path, label.get(target) ?? target]);
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
for (const s of starts) walk(s.id, /* @__PURE__ */ new Set([s.id]), [label.get(s.id) ?? s.id]);
|
|
1539
|
+
return {
|
|
1540
|
+
paths,
|
|
1541
|
+
truncated
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
/** cycles over the notation's flow edges (DFS back-edge detection) */
|
|
1545
|
+
function findCycles(graph, kinds) {
|
|
1546
|
+
const out = flowTargets(graph, kinds);
|
|
1547
|
+
const label = new Map(graph.nodes.map((n) => [n.id, n.name ?? n.id]));
|
|
1548
|
+
const cycles = [];
|
|
1549
|
+
const seenCycles = /* @__PURE__ */ new Set();
|
|
1550
|
+
const visit = (node, stack) => {
|
|
1551
|
+
const at = stack.indexOf(node);
|
|
1552
|
+
if (at >= 0) {
|
|
1553
|
+
const cycle = stack.slice(at);
|
|
1554
|
+
const key = [...cycle].sort().join("|");
|
|
1555
|
+
if (!seenCycles.has(key)) {
|
|
1556
|
+
seenCycles.add(key);
|
|
1557
|
+
cycles.push([...cycle, node].map((id) => label.get(id) ?? id));
|
|
1558
|
+
}
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
for (const next of out.get(node) ?? []) visit(next, [...stack, node]);
|
|
1562
|
+
};
|
|
1563
|
+
for (const n of graph.nodes) visit(n.id, []);
|
|
1564
|
+
return cycles;
|
|
1565
|
+
}
|
|
1566
|
+
/** The list_todos gate, used by BOTH entry points (server.ts, http.ts — the
|
|
1567
|
+
* composition roots read env, this module doesn't): undefined unless BOTH a
|
|
1568
|
+
* tracker repo (DESIGNIQ_TODOS_REPO) and a token (DESIGNIQ_TODOS_TOKEN) are
|
|
1569
|
+
* set — the server stays zero-auth by default, the tool simply does not exist
|
|
1570
|
+
* without the opt-in.
|
|
1571
|
+
*
|
|
1572
|
+
* Each of the two is also read under its legacy BPM_TODOS_* name (the new name // legacy-name-ok
|
|
1573
|
+
* wins) and resolved on its own: a half-migrated environment (repo under one
|
|
1574
|
+
* name, token under the other) still opts in rather than silently losing the
|
|
1575
|
+
* tool. Empty counts as unset (compose passes "" for an unset variable), so an
|
|
1576
|
+
* empty new name never shadows a set old one. */
|
|
1577
|
+
function todosConfigFromEnv(env) {
|
|
1578
|
+
const repo = env.DESIGNIQ_TODOS_REPO || env.BPM_TODOS_REPO;
|
|
1579
|
+
const token = env.DESIGNIQ_TODOS_TOKEN || env.BPM_TODOS_TOKEN;
|
|
1580
|
+
return repo && token ? {
|
|
1581
|
+
repo,
|
|
1582
|
+
token,
|
|
1583
|
+
apiUrl: env.GITHUB_API_URL
|
|
1584
|
+
} : void 0;
|
|
1585
|
+
}
|
|
1586
|
+
/** Build a fully configured, read-only MCP server over the repo at `root`. */
|
|
1587
|
+
function createMcpServer(root = DEFAULT_ROOT, todos, contributions = []) {
|
|
1588
|
+
const config = () => loadContentConfig(root);
|
|
1589
|
+
const processes = async () => {
|
|
1590
|
+
const cfg = config();
|
|
1591
|
+
return cfg ? discoverProcesses(root, cfg) : [];
|
|
1592
|
+
};
|
|
1593
|
+
const findProcess = async (id) => (await processes()).find((p) => p.id === id) ?? null;
|
|
1594
|
+
/** any-notation lookup; a stem shared across notations resolves bpmn-first
|
|
1595
|
+
* (back-compat: process ids keep meaning what they always meant) */
|
|
1596
|
+
const findModel = async (id, notation) => {
|
|
1597
|
+
const cfg = config();
|
|
1598
|
+
if (!cfg) return null;
|
|
1599
|
+
const matches = (await discoverModels(root, cfg)).filter((m) => m.id === id && (!notation || m.notation === notation));
|
|
1600
|
+
return matches.find((m) => m.notation === "bpmn") ?? matches[0] ?? null;
|
|
1601
|
+
};
|
|
1602
|
+
const unknownModel = async (id, notation) => {
|
|
1603
|
+
const cfg = config();
|
|
1604
|
+
const listed = (cfg ? await discoverModels(root, cfg) : []).filter((m) => !notation || m.notation === notation).map((m) => `${m.id} (${m.notation})`).join(", ") || "(none)";
|
|
1605
|
+
const what = notation ? byId(notation)?.noun.singular ?? notation : "model";
|
|
1606
|
+
return fail(`Unknown ${what} '${id}'. Available: ${listed}.`);
|
|
1607
|
+
};
|
|
1608
|
+
/** parse a discovered process's .bpmn into a ModelGraph, or a failure result */
|
|
1609
|
+
const graphOf = (proc) => {
|
|
1610
|
+
const raw = readText(join(root, proc.path));
|
|
1611
|
+
if (raw === null) return fail(`No such model file: ${proc.path}.`);
|
|
1612
|
+
const graph = extractModelGraph(proc.path, raw);
|
|
1613
|
+
if (!graph) return fail(`No extractor for '${proc.path}'.`);
|
|
1614
|
+
return graph;
|
|
1615
|
+
};
|
|
1616
|
+
const isGraph = (g) => !("content" in g);
|
|
1617
|
+
/** the registry nouns for user-facing copy ("wardley map" / "team topologies") */
|
|
1618
|
+
const nounOf = (notation) => byId(notation)?.noun.singular ?? notation;
|
|
1619
|
+
const pluralOf = (notation) => byId(notation)?.noun.plural ?? notation;
|
|
1620
|
+
const notAContentRepo = () => fail(`No usable designiq.yml (or legacy bpmiq.yml) at the content root — not a content repo. Expected a root designiq.yml naming the models folder.`);
|
|
1621
|
+
const unknownProcess = async (id) => fail(`Unknown process '${id}'. Available: ${(await processes()).map((p) => p.id).join(", ") || "(none)"}.`);
|
|
1622
|
+
const server = new McpServer({
|
|
1623
|
+
name: "designiq-mcp",
|
|
1624
|
+
version: "0.2.0"
|
|
1625
|
+
});
|
|
1626
|
+
server.registerTool("list_models", {
|
|
1627
|
+
description: "List EVERY model file of the repository, grouped by notation (bpmn, dmn, wardley, team-topology, …) — the registry-wide superset of list_processes. Each row: id (file stem), path, and — for notations with a registered deriver — the model's own name, summary and stats. Use to see which notations a repo contains before reaching for the notation-specific tools.",
|
|
1628
|
+
annotations: READ_ONLY
|
|
1629
|
+
}, safe(async () => {
|
|
1630
|
+
const cfg = config();
|
|
1631
|
+
if (!cfg) return notAContentRepo();
|
|
1632
|
+
const models = await discoverModels(root, cfg);
|
|
1633
|
+
if (models.length === 0) return ok("No model files found under the configured models folder.");
|
|
1634
|
+
const grouped = {};
|
|
1635
|
+
for (const m of models) {
|
|
1636
|
+
let view;
|
|
1637
|
+
try {
|
|
1638
|
+
const raw = readText(join(root, m.path));
|
|
1639
|
+
const graph = raw === null ? void 0 : extractModelGraph(m.path, raw);
|
|
1640
|
+
view = graph ? deriveView(graph) : void 0;
|
|
1641
|
+
} catch {
|
|
1642
|
+
view = void 0;
|
|
1643
|
+
}
|
|
1644
|
+
(grouped[m.notation] ??= []).push({
|
|
1645
|
+
id: m.id,
|
|
1646
|
+
path: m.path,
|
|
1647
|
+
...view?.name ? { name: view.name } : {},
|
|
1648
|
+
...view ? {
|
|
1649
|
+
summary: view.summary,
|
|
1650
|
+
stats: view.stats
|
|
1651
|
+
} : {}
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
return ok({ models: grouped });
|
|
1655
|
+
}));
|
|
1656
|
+
server.registerTool("list_processes", {
|
|
1657
|
+
description: "List all modeled business processes — every .bpmn file under the repo's designiq.yml models folder. Each row: id (file name without extension), derived name, the file path, and a count of steps/events/gateways/roles. Use to get a portfolio overview or to find a process id before calling get_process, get_model, who_owns or enumerate_paths.",
|
|
1658
|
+
annotations: READ_ONLY
|
|
1659
|
+
}, safe(async () => {
|
|
1660
|
+
if (!config()) return notAContentRepo();
|
|
1661
|
+
const procs = await processes();
|
|
1662
|
+
if (procs.length === 0) return ok("No processes found — no .bpmn files under the configured processes folder.");
|
|
1663
|
+
return ok(procs.map((p) => {
|
|
1664
|
+
const graph = graphOf(p);
|
|
1665
|
+
if (!isGraph(graph)) return {
|
|
1666
|
+
id: p.id,
|
|
1667
|
+
path: p.path,
|
|
1668
|
+
error: "could not parse BPMN"
|
|
1669
|
+
};
|
|
1670
|
+
const d = deriveProcess(graph);
|
|
1671
|
+
return {
|
|
1672
|
+
id: p.id,
|
|
1673
|
+
name: d.name ?? p.id,
|
|
1674
|
+
path: p.path,
|
|
1675
|
+
...d.stats
|
|
1676
|
+
};
|
|
1677
|
+
}));
|
|
1678
|
+
}));
|
|
1679
|
+
server.registerTool("get_process", {
|
|
1680
|
+
description: "Get one process in full: the process view DERIVED from its BPMN — name, roles (BPMN lanes = owning teams), steps (activities with their role), events, gateways, the sequence/message flow, and the sub-processes it calls (callActivity → calledElement). Use for walkthroughs and any deep question about a single process.",
|
|
1681
|
+
inputSchema: z.object({ id: z.string().describe("Process id = the .bpmn file name without extension, e.g. order-to-cash") }),
|
|
1682
|
+
annotations: READ_ONLY
|
|
1683
|
+
}, safe(async ({ id }) => {
|
|
1684
|
+
if (!config()) return notAContentRepo();
|
|
1685
|
+
const proc = await findProcess(id);
|
|
1686
|
+
if (!proc) return unknownProcess(id);
|
|
1687
|
+
const graph = graphOf(proc);
|
|
1688
|
+
if (!isGraph(graph)) return graph;
|
|
1689
|
+
return ok({
|
|
1690
|
+
id: proc.id,
|
|
1691
|
+
path: proc.path,
|
|
1692
|
+
...deriveProcess(graph)
|
|
1693
|
+
});
|
|
1694
|
+
}));
|
|
1695
|
+
server.registerTool("get_model", {
|
|
1696
|
+
description: "Parse ANY model file into its generic graph: nodes (id, type, name), edges and meta — BPMN flows and lanes, DMN requirements, Wardley components/dependencies, … Use to see the raw structure, or to ground any 'how does X work' answer in the actual model.",
|
|
1697
|
+
inputSchema: z.object({
|
|
1698
|
+
id: z.string().describe("Model id = file stem (from list_models), e.g. order-to-cash"),
|
|
1699
|
+
notation: z.string().optional().describe("registry notation id — disambiguates a stem shared across notations")
|
|
1700
|
+
}),
|
|
1701
|
+
annotations: READ_ONLY
|
|
1702
|
+
}, safe(async ({ id, notation }) => {
|
|
1703
|
+
if (!config()) return notAContentRepo();
|
|
1704
|
+
const m = await findModel(id, notation);
|
|
1705
|
+
if (!m) return unknownModel(id, notation);
|
|
1706
|
+
const graph = graphOf(m);
|
|
1707
|
+
return isGraph(graph) ? ok({
|
|
1708
|
+
id: m.id,
|
|
1709
|
+
file: m.path,
|
|
1710
|
+
...graph
|
|
1711
|
+
}) : graph;
|
|
1712
|
+
}));
|
|
1713
|
+
server.registerTool("get_view", {
|
|
1714
|
+
description: `The derived view of ANY model — its own name, a one-line summary, stats, and the rich notation payload in \`detail\` where one exists (process view, decision tables, …). The notation-agnostic sibling of get_process: works for every notation with extract+derive capabilities (${NOTATIONS.filter((n) => hasDeriver(n.id)).map((n) => n.id).join(", ")}).`,
|
|
1715
|
+
inputSchema: z.object({
|
|
1716
|
+
id: z.string().describe("Model id = file stem (from list_models), e.g. tea-shop"),
|
|
1717
|
+
notation: z.string().optional().describe("registry notation id — disambiguates a stem shared across notations")
|
|
1718
|
+
}),
|
|
1719
|
+
annotations: READ_ONLY
|
|
1720
|
+
}, safe(async ({ id, notation }) => {
|
|
1721
|
+
if (!config()) return notAContentRepo();
|
|
1722
|
+
const m = await findModel(id, notation);
|
|
1723
|
+
if (!m) return unknownModel(id, notation);
|
|
1724
|
+
const graph = graphOf(m);
|
|
1725
|
+
if (!isGraph(graph)) return graph;
|
|
1726
|
+
const view = deriveView(graph);
|
|
1727
|
+
if (!view) return fail(`a ${nounOf(m.notation)} has no registered deriver — get_view needs the derive capability.`);
|
|
1728
|
+
return ok({
|
|
1729
|
+
id: m.id,
|
|
1730
|
+
path: m.path,
|
|
1731
|
+
...view
|
|
1732
|
+
});
|
|
1733
|
+
}));
|
|
1734
|
+
server.registerTool("enumerate_paths", {
|
|
1735
|
+
description: "Enumerate the possible start→end paths through a model's flow (cycle-safe, capped) — BPMN sequence flows, Wardley dependencies, … per the notation's graph hints. Each path is the ordered list of element names. Use for walkthroughs ('what are the ways an order can go?'), test-case derivation, and spotting unexpected shortcuts or dead branches.",
|
|
1736
|
+
inputSchema: z.object({
|
|
1737
|
+
id: z.string().describe("Model id = file stem, e.g. order-to-cash"),
|
|
1738
|
+
notation: z.string().optional().describe("registry notation id — disambiguates a stem shared across notations"),
|
|
1739
|
+
max: z.number().int().min(1).max(100).optional().describe("Maximum number of paths to return (default 20)")
|
|
1740
|
+
}),
|
|
1741
|
+
annotations: READ_ONLY
|
|
1742
|
+
}, safe(async ({ id, notation, max }) => {
|
|
1743
|
+
if (!config()) return notAContentRepo();
|
|
1744
|
+
const m = await findModel(id, notation);
|
|
1745
|
+
if (!m) return unknownModel(id, notation);
|
|
1746
|
+
const graph = graphOf(m);
|
|
1747
|
+
if (!isGraph(graph)) return graph;
|
|
1748
|
+
const kinds = flowKindsOf(graph);
|
|
1749
|
+
if (!kinds) return ok(`${pluralOf(m.notation)} have no flow semantics (no graphHints) — nothing to enumerate.`);
|
|
1750
|
+
const { paths, truncated } = enumeratePaths(graph, kinds, max ?? 20);
|
|
1751
|
+
return ok({
|
|
1752
|
+
id: m.id,
|
|
1753
|
+
pathCount: paths.length,
|
|
1754
|
+
truncated,
|
|
1755
|
+
paths,
|
|
1756
|
+
...paths.length === 0 ? { note: "no start→end chains over the notation's flow edges — isolated or cyclic-only elements are not paths" } : {}
|
|
1757
|
+
});
|
|
1758
|
+
}));
|
|
1759
|
+
server.registerTool("find_cycles", {
|
|
1760
|
+
description: "Detect cycles (loops) in a model's flow — BPMN rework/retry loops, circular Wardley dependencies, circular DMN requirements — per the notation's graph hints. Returns each cycle as the ordered list of element names. Use when analyzing complexity, rework cost, or 'why does this case never finish'.",
|
|
1761
|
+
inputSchema: z.object({
|
|
1762
|
+
id: z.string().describe("Model id = file stem, e.g. order-to-cash"),
|
|
1763
|
+
notation: z.string().optional().describe("registry notation id — disambiguates a stem shared across notations")
|
|
1764
|
+
}),
|
|
1765
|
+
annotations: READ_ONLY
|
|
1766
|
+
}, safe(async ({ id, notation }) => {
|
|
1767
|
+
if (!config()) return notAContentRepo();
|
|
1768
|
+
const m = await findModel(id, notation);
|
|
1769
|
+
if (!m) return unknownModel(id, notation);
|
|
1770
|
+
const graph = graphOf(m);
|
|
1771
|
+
if (!isGraph(graph)) return graph;
|
|
1772
|
+
const kinds = flowKindsOf(graph);
|
|
1773
|
+
if (!kinds) return ok(`${pluralOf(m.notation)} have no flow semantics (no graphHints) — nothing to check.`);
|
|
1774
|
+
const cycles = findCycles(graph, kinds);
|
|
1775
|
+
return ok(cycles.length === 0 ? `No cycles in ${m.id}'s flow.` : {
|
|
1776
|
+
id: m.id,
|
|
1777
|
+
cycles
|
|
1778
|
+
});
|
|
1779
|
+
}));
|
|
1780
|
+
server.registerTool("who_owns", {
|
|
1781
|
+
description: "Resolve ownership of a process from its BPMN lanes — the roles/teams that own its steps (each lane, with the steps it contains) plus the pools (participants). Use for 'who owns X', 'who does what in X', or handoff questions. Note: on the slim contract ownership is whatever the model's lanes say; a process with no lanes has no modeled owner.",
|
|
1782
|
+
inputSchema: z.object({ id: z.string().describe("Process id, e.g. order-to-cash") }),
|
|
1783
|
+
annotations: READ_ONLY
|
|
1784
|
+
}, safe(async ({ id }) => {
|
|
1785
|
+
if (!config()) return notAContentRepo();
|
|
1786
|
+
const proc = await findProcess(id);
|
|
1787
|
+
if (!proc) return unknownProcess(id);
|
|
1788
|
+
const graph = graphOf(proc);
|
|
1789
|
+
if (!isGraph(graph)) return graph;
|
|
1790
|
+
const d = deriveProcess(graph);
|
|
1791
|
+
if (d.roles.length === 0 && d.pools.length === 0) return ok(`Process '${proc.id}' has no lanes or pools — no owning team is modeled in the BPMN.`);
|
|
1792
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n.name ?? n.id]));
|
|
1793
|
+
return ok({
|
|
1794
|
+
id: proc.id,
|
|
1795
|
+
pools: d.pools,
|
|
1796
|
+
roles: d.roles.map((r) => ({
|
|
1797
|
+
role: r.name,
|
|
1798
|
+
steps: r.stepIds.map((s) => byId.get(s) ?? s)
|
|
1799
|
+
}))
|
|
1800
|
+
});
|
|
1801
|
+
}));
|
|
1802
|
+
server.registerTool("which_processes_use", {
|
|
1803
|
+
description: "Impact analysis across the portfolio: find processes whose id, derived name, role/lane names, step names, or sub-process calls (calledElement) match a query — case-insensitive substring. Use for 'what calls invoice-handling', 'which processes have a Billing lane', 'what touches the credit check'.",
|
|
1804
|
+
inputSchema: z.object({ query: z.string().describe("Case-insensitive substring, e.g. 'invoice-handling', 'Billing', 'credit'") }),
|
|
1805
|
+
annotations: READ_ONLY
|
|
1806
|
+
}, safe(async ({ query }) => {
|
|
1807
|
+
if (!config()) return notAContentRepo();
|
|
1808
|
+
const q = query.toLowerCase();
|
|
1809
|
+
const procs = await processes();
|
|
1810
|
+
const hits = [];
|
|
1811
|
+
for (const proc of procs) {
|
|
1812
|
+
const graph = graphOf(proc);
|
|
1813
|
+
if (!isGraph(graph)) continue;
|
|
1814
|
+
const d = deriveProcess(graph);
|
|
1815
|
+
const matches = [];
|
|
1816
|
+
const check = (field, value) => {
|
|
1817
|
+
if (typeof value === "string" && value.toLowerCase().includes(q)) matches.push({
|
|
1818
|
+
field,
|
|
1819
|
+
value
|
|
1820
|
+
});
|
|
1821
|
+
};
|
|
1822
|
+
check("id", proc.id);
|
|
1823
|
+
check("name", d.name);
|
|
1824
|
+
for (const r of d.roles) check("role", r.name);
|
|
1825
|
+
for (const s of d.steps) check("step", s.name);
|
|
1826
|
+
for (const c of d.calls) check("calls", c.calledElement);
|
|
1827
|
+
if (matches.length > 0) hits.push({
|
|
1828
|
+
id: proc.id,
|
|
1829
|
+
name: d.name ?? proc.id,
|
|
1830
|
+
matches
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
if (hits.length === 0) return ok(`No process references '${query}' in its id, name, roles, steps or sub-process calls (checked ${procs.length} process(es)).`);
|
|
1834
|
+
return ok(hits);
|
|
1835
|
+
}));
|
|
1836
|
+
server.registerTool("which_models_use", {
|
|
1837
|
+
description: "Reference-level impact analysis across EVERY notation: all models whose typed cross-model references (callActivity calls, businessRuleTask decides, …) point at the given model id (file stem, exact match) — the repo-wide reference index behind it also flags dangling references. The registry-wide sibling of which_processes_use; use for 'what breaks if I change or delete this model?'.",
|
|
1838
|
+
inputSchema: z.object({ id: z.string().describe("Target model id = file stem (from list_models), e.g. 'credit-check'") }),
|
|
1839
|
+
annotations: READ_ONLY
|
|
1840
|
+
}, safe(async ({ id }) => {
|
|
1841
|
+
const cfg = config();
|
|
1842
|
+
if (!cfg) return notAContentRepo();
|
|
1843
|
+
const index = await buildRepoIndex(root, cfg);
|
|
1844
|
+
const hits = index.refs.filter((r) => r.to.id === id).map((r) => ({
|
|
1845
|
+
from: r.from.path,
|
|
1846
|
+
notation: r.from.notation,
|
|
1847
|
+
...r.from.element ? { element: r.from.element } : {},
|
|
1848
|
+
rel: r.rel,
|
|
1849
|
+
resolved: r.resolved !== void 0
|
|
1850
|
+
}));
|
|
1851
|
+
if (hits.length === 0) return ok(`No model references '${id}' (checked ${index.refs.length} reference(s) across the repo).`);
|
|
1852
|
+
return ok({
|
|
1853
|
+
target: id,
|
|
1854
|
+
referencedBy: hits
|
|
1855
|
+
});
|
|
1856
|
+
}));
|
|
1857
|
+
if (todos) {
|
|
1858
|
+
const { repo: todosRepo, token: todosToken } = todos;
|
|
1859
|
+
const api = (todos.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
|
|
1860
|
+
server.registerTool("list_todos", {
|
|
1861
|
+
description: "List the OPEN model-anchored todos of this content repo — work items filed from the live model into the repo's issue tracker (label 'todo'). Each row carries the tracker id and URL, title, assignees, createdAt and the parsed anchor (process, model file, anchored BPMN elements). Optional filter on one process. Use for 'what is open on process X' or to cross-check a model answer against known open discrepancies.",
|
|
1862
|
+
inputSchema: z.object({ process: z.string().optional().describe("Only todos anchored to this process id, e.g. order-to-cash") }),
|
|
1863
|
+
annotations: {
|
|
1864
|
+
readOnlyHint: true,
|
|
1865
|
+
openWorldHint: true
|
|
1866
|
+
}
|
|
1867
|
+
}, safe(async ({ process: processId }) => {
|
|
1868
|
+
const res = await fetch(`${api}/repos/${todosRepo}/issues?state=open&labels=${encodeURIComponent(todoLabelQuery(processId))}&per_page=100`, { headers: {
|
|
1869
|
+
accept: "application/vnd.github+json",
|
|
1870
|
+
authorization: `Bearer ${todosToken}`,
|
|
1871
|
+
"user-agent": "designiq-mcp"
|
|
1872
|
+
} });
|
|
1873
|
+
if (!res.ok) return fail(`Tracker query on ${todosRepo} failed: ${res.status} ${await res.text()}`);
|
|
1874
|
+
const todos = (await res.json()).filter((row) => !isPullRequestRow(row)).map((row) => ({
|
|
1875
|
+
id: String(row.number),
|
|
1876
|
+
url: row.html_url,
|
|
1877
|
+
title: row.title,
|
|
1878
|
+
anchor: parseAnchor(row.body ?? ""),
|
|
1879
|
+
assignees: (row.assignees ?? []).map((a) => a.login),
|
|
1880
|
+
createdAt: row.created_at
|
|
1881
|
+
}));
|
|
1882
|
+
if (todos.length === 0) return ok(`No open todos${processId ? ` for process '${processId}'` : ""} in ${todosRepo}.`);
|
|
1883
|
+
return ok(todos);
|
|
1884
|
+
}));
|
|
1885
|
+
}
|
|
1886
|
+
for (const contribute of contributions) contribute(server, root);
|
|
1887
|
+
return server;
|
|
1888
|
+
}
|
|
1889
|
+
//#endregion
|
|
1890
|
+
export { createMcpServer as n, todosConfigFromEnv as r, DEFAULT_ROOT as t };
|