@miragon/design-iq-validator 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 +36 -0
- package/dist/cli.js +21 -0
- package/dist/content-B_0zdBLT.js +2 -0
- package/dist/content-BoFksK67.js +994 -0
- package/dist/validate-C9ZythtW.js +502 -0
- package/dist/validate.js +2 -0
- package/package.json +48 -0
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, posix, relative } from "node:path";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
6
|
+
//#region ../notations/index.ts
|
|
7
|
+
const NOTATIONS = [
|
|
8
|
+
{
|
|
9
|
+
id: "bpmn",
|
|
10
|
+
label: "BPMN 2.0",
|
|
11
|
+
noun: {
|
|
12
|
+
singular: "process",
|
|
13
|
+
plural: "processes"
|
|
14
|
+
},
|
|
15
|
+
extensions: [".bpmn"],
|
|
16
|
+
mediaKind: "xml",
|
|
17
|
+
docShape: "text",
|
|
18
|
+
monacoLanguage: "xml",
|
|
19
|
+
graphHints: {
|
|
20
|
+
flowEdgeKinds: ["sequenceFlow"],
|
|
21
|
+
entryNodeTypes: ["startEvent"]
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "dmn",
|
|
26
|
+
label: "DMN",
|
|
27
|
+
noun: {
|
|
28
|
+
singular: "decision",
|
|
29
|
+
plural: "decisions"
|
|
30
|
+
},
|
|
31
|
+
extensions: [".dmn"],
|
|
32
|
+
mediaKind: "xml",
|
|
33
|
+
docShape: "text",
|
|
34
|
+
monacoLanguage: "xml",
|
|
35
|
+
graphHints: {
|
|
36
|
+
flowEdgeKinds: ["informationRequirement"],
|
|
37
|
+
entryNodeTypes: []
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "wardley",
|
|
42
|
+
label: "Wardley Map",
|
|
43
|
+
noun: {
|
|
44
|
+
singular: "wardley map",
|
|
45
|
+
plural: "wardley maps"
|
|
46
|
+
},
|
|
47
|
+
extensions: [".owm", ".wmap"],
|
|
48
|
+
mediaKind: "dsl",
|
|
49
|
+
docShape: "text",
|
|
50
|
+
monacoLanguage: "plaintext",
|
|
51
|
+
graphHints: {
|
|
52
|
+
flowEdgeKinds: ["dependency"],
|
|
53
|
+
entryNodeTypes: []
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "team-topology",
|
|
58
|
+
label: "Team Topology",
|
|
59
|
+
noun: {
|
|
60
|
+
singular: "team topology",
|
|
61
|
+
plural: "team topologies"
|
|
62
|
+
},
|
|
63
|
+
extensions: [".tt", ".ttm.json"],
|
|
64
|
+
mediaKind: "json",
|
|
65
|
+
docShape: "text",
|
|
66
|
+
monacoLanguage: "json"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "event-storming",
|
|
70
|
+
label: "Event Storming",
|
|
71
|
+
noun: {
|
|
72
|
+
singular: "event storming board",
|
|
73
|
+
plural: "event storming boards"
|
|
74
|
+
},
|
|
75
|
+
extensions: [".storm"],
|
|
76
|
+
mediaKind: "dsl",
|
|
77
|
+
docShape: "text",
|
|
78
|
+
monacoLanguage: "plaintext",
|
|
79
|
+
graphHints: {
|
|
80
|
+
flowEdgeKinds: ["arrow"],
|
|
81
|
+
entryNodeTypes: []
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "context-map",
|
|
86
|
+
label: "Context Map",
|
|
87
|
+
noun: {
|
|
88
|
+
singular: "context map",
|
|
89
|
+
plural: "context maps"
|
|
90
|
+
},
|
|
91
|
+
extensions: [".cm.json"],
|
|
92
|
+
mediaKind: "json",
|
|
93
|
+
docShape: "text",
|
|
94
|
+
monacoLanguage: "json",
|
|
95
|
+
graphHints: {
|
|
96
|
+
flowEdgeKinds: [
|
|
97
|
+
"partnership",
|
|
98
|
+
"shared-kernel",
|
|
99
|
+
"customer-supplier",
|
|
100
|
+
"upstream-downstream",
|
|
101
|
+
"separate-ways"
|
|
102
|
+
],
|
|
103
|
+
entryNodeTypes: []
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "value-chain",
|
|
108
|
+
label: "Value Chain",
|
|
109
|
+
noun: {
|
|
110
|
+
singular: "value chain",
|
|
111
|
+
plural: "value chains"
|
|
112
|
+
},
|
|
113
|
+
extensions: [".vc.json"],
|
|
114
|
+
mediaKind: "json",
|
|
115
|
+
docShape: "text",
|
|
116
|
+
monacoLanguage: "json"
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: "markdown",
|
|
120
|
+
label: "Markdown",
|
|
121
|
+
noun: {
|
|
122
|
+
singular: "document",
|
|
123
|
+
plural: "documents"
|
|
124
|
+
},
|
|
125
|
+
extensions: [".md"],
|
|
126
|
+
mediaKind: "markdown",
|
|
127
|
+
docShape: "text",
|
|
128
|
+
monacoLanguage: "markdown"
|
|
129
|
+
}
|
|
130
|
+
];
|
|
131
|
+
function byId(id) {
|
|
132
|
+
return NOTATIONS.find((n) => n.id === id);
|
|
133
|
+
}
|
|
134
|
+
/** longest-suffix winner across every registered extension, so ".vc.json"
|
|
135
|
+
* beats a hypothetical ".json" — shared by byExtension and modelStem */
|
|
136
|
+
function longestMatch(path) {
|
|
137
|
+
let best;
|
|
138
|
+
for (const n of NOTATIONS) for (const ext of n.extensions) if (path.endsWith(ext) && ext.length > (best?.ext.length ?? 0)) best = {
|
|
139
|
+
notation: n,
|
|
140
|
+
ext
|
|
141
|
+
};
|
|
142
|
+
return best;
|
|
143
|
+
}
|
|
144
|
+
function byExtension(path) {
|
|
145
|
+
return longestMatch(path)?.notation;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The file stem that IS a model's id (the content contract: a process is its
|
|
149
|
+
* .bpmn file, a decision its .dmn file). Strips the FULL registered extension,
|
|
150
|
+
* so compound suffixes resolve correctly ("a.vc.json" → "a", never "a.vc");
|
|
151
|
+
* unknown extensions fall back to stripping the final dot-suffix.
|
|
152
|
+
*/
|
|
153
|
+
function modelStem(path) {
|
|
154
|
+
const base = path.split("/").pop() ?? path;
|
|
155
|
+
const ext = longestMatch(base)?.ext;
|
|
156
|
+
return ext ? base.slice(0, -ext.length) : base.replace(/\.[^.]+$/, "");
|
|
157
|
+
}
|
|
158
|
+
[...NOTATIONS.flatMap((n) => n.extensions)];
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region ../notations/bpmn-kinds.ts
|
|
161
|
+
const BPMN_ELEMENT_KIND = /* @__PURE__ */ new Map([
|
|
162
|
+
["task", "activity"],
|
|
163
|
+
["userTask", "activity"],
|
|
164
|
+
["serviceTask", "activity"],
|
|
165
|
+
["scriptTask", "activity"],
|
|
166
|
+
["businessRuleTask", "activity"],
|
|
167
|
+
["manualTask", "activity"],
|
|
168
|
+
["sendTask", "activity"],
|
|
169
|
+
["receiveTask", "activity"],
|
|
170
|
+
["callActivity", "activity"],
|
|
171
|
+
["subProcess", "activity"],
|
|
172
|
+
["adHocSubProcess", "activity"],
|
|
173
|
+
["transaction", "activity"],
|
|
174
|
+
["startEvent", "event"],
|
|
175
|
+
["endEvent", "event"],
|
|
176
|
+
["intermediateThrowEvent", "event"],
|
|
177
|
+
["intermediateCatchEvent", "event"],
|
|
178
|
+
["boundaryEvent", "event"],
|
|
179
|
+
["exclusiveGateway", "gateway"],
|
|
180
|
+
["parallelGateway", "gateway"],
|
|
181
|
+
["inclusiveGateway", "gateway"],
|
|
182
|
+
["eventBasedGateway", "gateway"],
|
|
183
|
+
["complexGateway", "gateway"]
|
|
184
|
+
]);
|
|
185
|
+
/** the kind of a flow-node tag; undefined = not a flow node (artifact, lane, …) */
|
|
186
|
+
const kindOf = (tag) => BPMN_ELEMENT_KIND.get(tag);
|
|
187
|
+
/** every flow-node tag — everything else (textAnnotation, dataObject, …) is a
|
|
188
|
+
* legal artifact but NOT a flow node; treating it as one produces false
|
|
189
|
+
* "unreachable"/"dead end" errors on perfectly valid models */
|
|
190
|
+
const BPMN_FLOW_NODE_TAGS = new Set(BPMN_ELEMENT_KIND.keys());
|
|
191
|
+
/** container tags whose children form their own flow graph */
|
|
192
|
+
const BPMN_SUB_CONTAINER_TAGS = /* @__PURE__ */ new Set([
|
|
193
|
+
"subProcess",
|
|
194
|
+
"adHocSubProcess",
|
|
195
|
+
"transaction"
|
|
196
|
+
]);
|
|
197
|
+
/** non-flow-node artifacts that still need a BPMNDI shape/edge to render.
|
|
198
|
+
* Exactly these five: plain `dataObject` is deliberately ABSENT — bpmn-js
|
|
199
|
+
* draws the dataObjectReference, not the dataObject; requiring DI for the
|
|
200
|
+
* latter would false-error every model with one. */
|
|
201
|
+
const BPMN_DI_ARTIFACT_TAGS = /* @__PURE__ */ new Set([
|
|
202
|
+
"textAnnotation",
|
|
203
|
+
"association",
|
|
204
|
+
"dataObjectReference",
|
|
205
|
+
"dataStoreReference",
|
|
206
|
+
"group"
|
|
207
|
+
]);
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region ../notations/extract.ts
|
|
210
|
+
/**
|
|
211
|
+
* extract(raw) => ModelGraph — the analysis seam of the notation registry.
|
|
212
|
+
*
|
|
213
|
+
* Every notation knows how to turn its raw file content into ONE generic
|
|
214
|
+
* graph shape. Analyses (MCP tools, future dashboards) are written against
|
|
215
|
+
* ModelGraph and automatically cover every notation that implements extract —
|
|
216
|
+
* instead of one parser per tool per notation.
|
|
217
|
+
*
|
|
218
|
+
* Node-safe: fast-xml-parser + JSON + regex only, no DOM, no editor libraries.
|
|
219
|
+
*/
|
|
220
|
+
const xml = new XMLParser({
|
|
221
|
+
ignoreAttributes: false,
|
|
222
|
+
attributeNamePrefix: "@_",
|
|
223
|
+
removeNSPrefix: true
|
|
224
|
+
});
|
|
225
|
+
/** the ONE fast-xml-parser configuration (the validator used to carry a
|
|
226
|
+
* byte-identical copy — that is how the two BPMN walks drifted) */
|
|
227
|
+
const parseXml = (raw) => xml.parse(raw);
|
|
228
|
+
const asArray = (v) => v === void 0 ? [] : Array.isArray(v) ? v : [v];
|
|
229
|
+
/**
|
|
230
|
+
* The decision a businessRuleTask delegates to. BPMN 2.0 has no standard
|
|
231
|
+
* attribute for it, so every engine invented its own — all of them are read
|
|
232
|
+
* here (namespace prefixes are already stripped by the parser):
|
|
233
|
+
*
|
|
234
|
+
* decisionRef="x" Camunda 7
|
|
235
|
+
* <extensionElements><calledDecision decisionId="x"/> Camunda 8 / Zeebe
|
|
236
|
+
* calledDecision="x" / calledElement="x" hand-written models
|
|
237
|
+
*
|
|
238
|
+
* The platform contract is the same as for callActivity: the value names the
|
|
239
|
+
* DECISION FILE STEM, so the link resolves against the repo's .dmn files.
|
|
240
|
+
*
|
|
241
|
+
* Exported as THE spelling list: the validator's link-integrity check must
|
|
242
|
+
* read exactly what the platform follows, or a spelling becomes a link that
|
|
243
|
+
* get_process reports and no check ever verifies (that drift shipped once).
|
|
244
|
+
*/
|
|
245
|
+
function decisionRefOf(task) {
|
|
246
|
+
const called = task.extensionElements?.calledDecision;
|
|
247
|
+
const fromExtension = asArray(called)[0]?.["@_decisionId"];
|
|
248
|
+
const ref = task["@_decisionRef"] ?? fromExtension ?? task["@_calledDecision"] ?? task["@_calledElement"] ?? void 0;
|
|
249
|
+
const text = ref === void 0 || ref === null ? "" : String(ref).trim();
|
|
250
|
+
return text === "" ? void 0 : text;
|
|
251
|
+
}
|
|
252
|
+
/** parse-tree in (parseXml(raw).definitions), the shared model out */
|
|
253
|
+
function readBpmn(defs) {
|
|
254
|
+
const model = {
|
|
255
|
+
containers: [],
|
|
256
|
+
nodes: [],
|
|
257
|
+
flows: [],
|
|
258
|
+
diRequired: /* @__PURE__ */ new Set(),
|
|
259
|
+
lanes: [],
|
|
260
|
+
collaborations: []
|
|
261
|
+
};
|
|
262
|
+
const walk = (el, label, id, isSubProcess) => {
|
|
263
|
+
const container = {
|
|
264
|
+
label,
|
|
265
|
+
id,
|
|
266
|
+
isSubProcess,
|
|
267
|
+
triggeredByEvent: String(el["@_triggeredByEvent"]) === "true",
|
|
268
|
+
nodes: /* @__PURE__ */ new Map(),
|
|
269
|
+
flows: []
|
|
270
|
+
};
|
|
271
|
+
model.containers.push(container);
|
|
272
|
+
for (const [tag, value] of Object.entries(el)) {
|
|
273
|
+
if (tag.startsWith("@_")) continue;
|
|
274
|
+
if (tag === "sequenceFlow") {
|
|
275
|
+
for (const f of asArray(value)) {
|
|
276
|
+
const flow = {
|
|
277
|
+
id: f["@_id"] ?? "",
|
|
278
|
+
from: f["@_sourceRef"] ?? "",
|
|
279
|
+
to: f["@_targetRef"] ?? "",
|
|
280
|
+
name: f["@_name"]
|
|
281
|
+
};
|
|
282
|
+
container.flows.push(flow);
|
|
283
|
+
model.flows.push(flow);
|
|
284
|
+
if (f["@_id"]) model.diRequired.add(f["@_id"]);
|
|
285
|
+
}
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (BPMN_FLOW_NODE_TAGS.has(tag)) {
|
|
289
|
+
for (const node of asArray(value)) {
|
|
290
|
+
const rec = node;
|
|
291
|
+
const nodeId = rec["@_id"];
|
|
292
|
+
if (!nodeId) continue;
|
|
293
|
+
const decisionRef = tag === "businessRuleTask" ? decisionRefOf(node) : void 0;
|
|
294
|
+
const entry = {
|
|
295
|
+
id: nodeId,
|
|
296
|
+
tag,
|
|
297
|
+
name: rec["@_name"],
|
|
298
|
+
...rec["@_attachedToRef"] !== void 0 ? { attachedTo: rec["@_attachedToRef"] } : {},
|
|
299
|
+
...rec["@_calledElement"] !== void 0 ? { calledElement: rec["@_calledElement"] } : {},
|
|
300
|
+
...decisionRef ? { decisionRef } : {},
|
|
301
|
+
container
|
|
302
|
+
};
|
|
303
|
+
model.nodes.push(entry);
|
|
304
|
+
container.nodes.set(nodeId, entry);
|
|
305
|
+
model.diRequired.add(nodeId);
|
|
306
|
+
if (BPMN_SUB_CONTAINER_TAGS.has(tag)) walk(node, `${tag} ${nodeId}`, nodeId, true);
|
|
307
|
+
}
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (BPMN_DI_ARTIFACT_TAGS.has(tag)) {
|
|
311
|
+
for (const node of asArray(value)) if (node["@_id"]) model.diRequired.add(node["@_id"]);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
let processIndex = 0;
|
|
316
|
+
for (const proc of asArray(defs.process)) {
|
|
317
|
+
const rec = proc;
|
|
318
|
+
const pid = rec["@_id"];
|
|
319
|
+
walk(proc, `process ${pid ?? "(anonymous)"}`, pid, false);
|
|
320
|
+
for (const lane of asArray(rec.laneSet?.lane)) model.lanes.push({
|
|
321
|
+
id: lane["@_id"],
|
|
322
|
+
name: lane["@_name"],
|
|
323
|
+
nodeIds: asArray(lane.flowNodeRef).map(String),
|
|
324
|
+
processIndex
|
|
325
|
+
});
|
|
326
|
+
processIndex++;
|
|
327
|
+
}
|
|
328
|
+
for (const collab of asArray(defs.collaboration)) {
|
|
329
|
+
const rec = collab;
|
|
330
|
+
model.collaborations.push({
|
|
331
|
+
pools: asArray(rec.participant).map((pl) => ({
|
|
332
|
+
id: pl["@_id"],
|
|
333
|
+
name: pl["@_name"],
|
|
334
|
+
processRef: pl["@_processRef"]
|
|
335
|
+
})),
|
|
336
|
+
messageFlows: asArray(rec.messageFlow).map((mf) => ({
|
|
337
|
+
id: mf["@_id"],
|
|
338
|
+
from: mf["@_sourceRef"],
|
|
339
|
+
to: mf["@_targetRef"],
|
|
340
|
+
name: mf["@_name"]
|
|
341
|
+
}))
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return model;
|
|
345
|
+
}
|
|
346
|
+
function extractBpmn(raw) {
|
|
347
|
+
const model = readBpmn(parseXml(raw).definitions ?? {});
|
|
348
|
+
const nodes = model.nodes.map((n) => ({
|
|
349
|
+
id: n.id,
|
|
350
|
+
type: n.tag,
|
|
351
|
+
name: n.name,
|
|
352
|
+
extra: {
|
|
353
|
+
...n.container.isSubProcess && n.container.id ? { parent: n.container.id } : {},
|
|
354
|
+
...n.calledElement ? { calledElement: n.calledElement } : {},
|
|
355
|
+
...n.attachedTo ? { attachedTo: n.attachedTo } : {},
|
|
356
|
+
...n.decisionRef ? { decisionRef: n.decisionRef } : {}
|
|
357
|
+
}
|
|
358
|
+
}));
|
|
359
|
+
const edges = model.flows.map((f) => ({
|
|
360
|
+
id: f.id,
|
|
361
|
+
from: f.from,
|
|
362
|
+
to: f.to,
|
|
363
|
+
kind: "sequenceFlow",
|
|
364
|
+
name: f.name
|
|
365
|
+
}));
|
|
366
|
+
for (const collab of model.collaborations) for (const mf of collab.messageFlows) edges.push({
|
|
367
|
+
id: mf.id ?? "",
|
|
368
|
+
from: mf.from ?? "",
|
|
369
|
+
to: mf.to ?? "",
|
|
370
|
+
kind: "messageFlow",
|
|
371
|
+
name: mf.name
|
|
372
|
+
});
|
|
373
|
+
return {
|
|
374
|
+
notation: "bpmn",
|
|
375
|
+
nodes,
|
|
376
|
+
edges,
|
|
377
|
+
meta: {
|
|
378
|
+
lanes: model.lanes.map((l) => ({
|
|
379
|
+
id: l.id,
|
|
380
|
+
name: l.name,
|
|
381
|
+
nodeIds: l.nodeIds
|
|
382
|
+
})),
|
|
383
|
+
pools: model.collaborations.flatMap((c) => c.pools.map((pl) => ({
|
|
384
|
+
id: pl.id ?? "",
|
|
385
|
+
name: pl.name ?? "",
|
|
386
|
+
processRef: pl.processRef ?? ""
|
|
387
|
+
})))
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* A tag's text content as a STRING. fast-xml-parser coerces tag values
|
|
393
|
+
* ("42" → 42, "true" → true), but every DMN entry is FEEL SOURCE TEXT — a
|
|
394
|
+
* rule entry `1` must stay the literal "1", never the number. Empty and
|
|
395
|
+
* self-closing tags collapse to "" (an empty entry means "any" in DMN).
|
|
396
|
+
*/
|
|
397
|
+
function textOf(value) {
|
|
398
|
+
if (value === void 0 || value === null) return "";
|
|
399
|
+
if (typeof value === "object") return textOf(value["#text"]);
|
|
400
|
+
return String(value);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* The target of an informationRequirement — requiredInput/requiredDecision,
|
|
404
|
+
* in that lookup order. THE spelling pair (it was byte-identical in the
|
|
405
|
+
* validator, the decisionRefOf duplication class): `local` means same-file
|
|
406
|
+
* ("#id"); a namespace-qualified href ("other.dmn#id") is NOT local — the
|
|
407
|
+
* validator's dangling check skips it, and extractDmn currently passes it
|
|
408
|
+
* through verbatim (known inconsistency vs analyze_decision, tracked in #86).
|
|
409
|
+
*/
|
|
410
|
+
function requirementHref(req) {
|
|
411
|
+
const href = req.requiredInput?.["@_href"] ?? req.requiredDecision?.["@_href"];
|
|
412
|
+
if (href === void 0) return void 0;
|
|
413
|
+
return {
|
|
414
|
+
href,
|
|
415
|
+
local: href.startsWith("#")
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* DMN: decisions (with their full decision-table logic), input data and the
|
|
420
|
+
* information requirements between them. The table detail rides in
|
|
421
|
+
* node.extra — deriveDecision (derive.ts) turns it into the decision view,
|
|
422
|
+
* and the simulator (@designiq/decisions) into its evaluation model, so this is
|
|
423
|
+
* the ONE DMN parser on the server side.
|
|
424
|
+
*/
|
|
425
|
+
function extractDmn(raw) {
|
|
426
|
+
const defs = parseXml(raw).definitions ?? {};
|
|
427
|
+
const nodes = [];
|
|
428
|
+
const edges = [];
|
|
429
|
+
for (const input of asArray(defs.inputData)) nodes.push({
|
|
430
|
+
id: input["@_id"] ?? "",
|
|
431
|
+
type: "inputData",
|
|
432
|
+
name: input["@_name"] ?? "",
|
|
433
|
+
extra: {
|
|
434
|
+
variable: input.variable?.["@_name"] ?? input["@_name"] ?? "",
|
|
435
|
+
typeRef: input.variable?.["@_typeRef"] ?? "string"
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
for (const decision of asArray(defs.decision)) {
|
|
439
|
+
const table = decision.decisionTable;
|
|
440
|
+
const literal = decision.literalExpression;
|
|
441
|
+
nodes.push({
|
|
442
|
+
id: decision["@_id"],
|
|
443
|
+
type: "decision",
|
|
444
|
+
name: decision["@_name"],
|
|
445
|
+
extra: {
|
|
446
|
+
variable: decision.variable?.["@_name"] ?? decision["@_name"] ?? decision["@_id"],
|
|
447
|
+
typeRef: decision.variable?.["@_typeRef"] ?? "string",
|
|
448
|
+
...table ? {
|
|
449
|
+
kind: "decisionTable",
|
|
450
|
+
hitPolicy: table["@_hitPolicy"] ?? "UNIQUE",
|
|
451
|
+
...table["@_aggregation"] ? { aggregation: table["@_aggregation"] } : {},
|
|
452
|
+
inputs: asArray(table.input).map((i, n) => ({
|
|
453
|
+
id: i["@_id"] ?? `Input_${n + 1}`,
|
|
454
|
+
label: i["@_label"] ?? textOf(i.inputExpression?.text),
|
|
455
|
+
expression: textOf(i.inputExpression?.text),
|
|
456
|
+
typeRef: i.inputExpression?.["@_typeRef"] ?? "string",
|
|
457
|
+
inputValues: splitFeelList(textOf(i.inputValues?.text))
|
|
458
|
+
})),
|
|
459
|
+
outputs: asArray(table.output).map((o, n) => ({
|
|
460
|
+
id: o["@_id"] ?? `Output_${n + 1}`,
|
|
461
|
+
name: o["@_name"] ?? o["@_label"] ?? "",
|
|
462
|
+
label: o["@_label"] ?? o["@_name"] ?? "",
|
|
463
|
+
typeRef: o["@_typeRef"] ?? "string",
|
|
464
|
+
outputValues: splitFeelList(textOf(o.outputValues?.text))
|
|
465
|
+
})),
|
|
466
|
+
rules: asArray(table.rule).map((r, n) => ({
|
|
467
|
+
id: r["@_id"] ?? `Rule_${n + 1}`,
|
|
468
|
+
description: textOf(r.description) || void 0,
|
|
469
|
+
inputEntries: asArray(r.inputEntry).map((e) => textOf(e.text)),
|
|
470
|
+
outputEntries: asArray(r.outputEntry).map((e) => textOf(e.text))
|
|
471
|
+
}))
|
|
472
|
+
} : literal ? {
|
|
473
|
+
kind: "literalExpression",
|
|
474
|
+
expression: textOf(literal.text)
|
|
475
|
+
} : { kind: "none" }
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
for (const req of asArray(decision.informationRequirement)) {
|
|
479
|
+
const target = requirementHref(req);
|
|
480
|
+
if (target && target.href) edges.push({
|
|
481
|
+
id: req["@_id"] ?? `req-${edges.length}`,
|
|
482
|
+
from: target.href.replace(/^#/, ""),
|
|
483
|
+
to: decision["@_id"],
|
|
484
|
+
kind: "informationRequirement"
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
notation: "dmn",
|
|
490
|
+
nodes,
|
|
491
|
+
edges,
|
|
492
|
+
meta: {
|
|
493
|
+
name: defs["@_name"] ?? null,
|
|
494
|
+
namespace: defs["@_namespace"] ?? null
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
/** `"a","b"` (a DMN inputValues/outputValues list) → the raw FEEL items */
|
|
499
|
+
function splitFeelList(raw) {
|
|
500
|
+
const trimmed = raw.trim();
|
|
501
|
+
if (trimmed === "") return [];
|
|
502
|
+
return trimmed.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
503
|
+
}
|
|
504
|
+
/** OWM DSL: `component Name [y, x]`, `Name -> Other`, `evolve Name x` */
|
|
505
|
+
function extractWardley(raw) {
|
|
506
|
+
const nodes = [];
|
|
507
|
+
const edges = [];
|
|
508
|
+
for (const m of raw.matchAll(/^component\s+([^[\n]+?)\s*\[\s*([\d.]+)\s*,\s*([\d.]+)\s*\]/gm)) {
|
|
509
|
+
const evolution = Number(m[3]);
|
|
510
|
+
nodes.push({
|
|
511
|
+
id: m[1] ?? "",
|
|
512
|
+
type: "component",
|
|
513
|
+
name: m[1],
|
|
514
|
+
extra: {
|
|
515
|
+
visibility: Number(m[2]),
|
|
516
|
+
evolution,
|
|
517
|
+
stage: evolution < .25 ? "genesis" : evolution < .5 ? "custom-built" : evolution < .75 ? "product" : "commodity"
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
const directive = /^(title|component|anchor|market|note|annotation|annotations|style|evolution|submap|url|pipeline)\b/;
|
|
522
|
+
let i = 0;
|
|
523
|
+
for (const m of raw.matchAll(/^([^\s>[\n][^>[\n]*?)\s*->\s*([^\n;]+)$/gm)) {
|
|
524
|
+
if (directive.test(m[0] ?? "")) continue;
|
|
525
|
+
edges.push({
|
|
526
|
+
id: `dep-${i++}`,
|
|
527
|
+
from: m[1]?.trim() ?? "",
|
|
528
|
+
to: m[2]?.trim() ?? "",
|
|
529
|
+
kind: "dependency"
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
notation: "wardley",
|
|
534
|
+
nodes,
|
|
535
|
+
edges
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
function extractTeamTopology(raw) {
|
|
539
|
+
const data = JSON.parse(raw);
|
|
540
|
+
return {
|
|
541
|
+
notation: "team-topology",
|
|
542
|
+
nodes: (data.nodes ?? []).map((n) => ({
|
|
543
|
+
id: n.id,
|
|
544
|
+
type: n.type ?? "team",
|
|
545
|
+
name: n.label ?? n.name,
|
|
546
|
+
extra: n.description ? { description: n.description } : void 0
|
|
547
|
+
})),
|
|
548
|
+
edges: (data.edges ?? []).map((e, i) => ({
|
|
549
|
+
id: e.id ?? `edge-${i}`,
|
|
550
|
+
from: e.source ?? e.from,
|
|
551
|
+
to: e.target ?? e.to,
|
|
552
|
+
kind: e.interaction ?? e.type ?? "interaction"
|
|
553
|
+
}))
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
const STORM_ID_PREFIX = {
|
|
557
|
+
event: "event",
|
|
558
|
+
command: "cmd",
|
|
559
|
+
actor: "actor",
|
|
560
|
+
aggregate: "agg",
|
|
561
|
+
policy: "policy",
|
|
562
|
+
readmodel: "read",
|
|
563
|
+
external: "ext",
|
|
564
|
+
hotspot: "hot"
|
|
565
|
+
};
|
|
566
|
+
/** stickies that can carry a pinned actor/hotspot/note — `(on <Host>)` */
|
|
567
|
+
const STORM_HOST_KINDS = /* @__PURE__ */ new Set([
|
|
568
|
+
"event",
|
|
569
|
+
"command",
|
|
570
|
+
"aggregate",
|
|
571
|
+
"policy",
|
|
572
|
+
"readmodel",
|
|
573
|
+
"external"
|
|
574
|
+
]);
|
|
575
|
+
const STORM_ATTACHABLE_KINDS = /* @__PURE__ */ new Set([
|
|
576
|
+
"actor",
|
|
577
|
+
"hotspot",
|
|
578
|
+
"note"
|
|
579
|
+
]);
|
|
580
|
+
const STORM_COORDS = /\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]/;
|
|
581
|
+
const STORM_POINTS = /\[\s*(\[[^[\]]*\](?:\s*,\s*\[[^[\]]*\])+)\s*\]/;
|
|
582
|
+
const stormSlug = (label) => label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_/, "").replace(/_$/, "") || "x";
|
|
583
|
+
/** the `(on <Host>)` / `(id <id>)` / `(color <c>)` suffixes of a sticky line;
|
|
584
|
+
* `rest` is the text with EVERY parenthesised suffix removed (size/align are
|
|
585
|
+
* note layout — irrelevant to the graph) */
|
|
586
|
+
function stormSuffixes(text) {
|
|
587
|
+
let host;
|
|
588
|
+
let rest = text;
|
|
589
|
+
const on = /\(\s*on\s+/i.exec(text);
|
|
590
|
+
if (on) {
|
|
591
|
+
const start = on.index + on[0].length;
|
|
592
|
+
const close = text.lastIndexOf(")");
|
|
593
|
+
if (close >= start && text.slice(start, close).trim()) {
|
|
594
|
+
host = text.slice(start, close).trim();
|
|
595
|
+
rest = `${text.slice(0, on.index)} ${text.slice(close + 1)}`;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const id = /\(\s*id\s+([A-Za-z0-9_-]+)\s*\)/i.exec(rest)?.[1];
|
|
599
|
+
const color = /\(\s*color\s+(#[0-9a-fA-F]{3,8}|[a-zA-Z][\w-]*)\s*\)/i.exec(rest)?.[1];
|
|
600
|
+
return {
|
|
601
|
+
host,
|
|
602
|
+
id,
|
|
603
|
+
color,
|
|
604
|
+
rest: rest.replace(/\([^)]*\)/g, " ")
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
function extractEventStorming(raw) {
|
|
608
|
+
const nodes = [];
|
|
609
|
+
const edges = [];
|
|
610
|
+
let title = null;
|
|
611
|
+
let level = null;
|
|
612
|
+
const used = /* @__PURE__ */ new Set();
|
|
613
|
+
const nameToId = /* @__PURE__ */ new Map();
|
|
614
|
+
const claim = (id) => {
|
|
615
|
+
if (!id || used.has(id)) return void 0;
|
|
616
|
+
used.add(id);
|
|
617
|
+
return id;
|
|
618
|
+
};
|
|
619
|
+
const alloc = (prefix, label) => {
|
|
620
|
+
const base = `${prefix}_${stormSlug(label)}`;
|
|
621
|
+
let id = base;
|
|
622
|
+
for (let i = 2; used.has(id); i++) id = `${base}_${i}`;
|
|
623
|
+
used.add(id);
|
|
624
|
+
return id;
|
|
625
|
+
};
|
|
626
|
+
const decode = (name) => name.replace(/\\n/g, "\n");
|
|
627
|
+
const arrows = [];
|
|
628
|
+
const pins = [];
|
|
629
|
+
for (const source of raw.replace(/\/\*[\s\S]*?\*\//g, " ").split(/\r?\n/)) {
|
|
630
|
+
const line = source.replace(/(?<!:)\/\/.*$/, "").trim();
|
|
631
|
+
if (!line) continue;
|
|
632
|
+
const kw = (/^[A-Za-z][\w-]*/.exec(line)?.[0] ?? "").toLowerCase();
|
|
633
|
+
const after = line.slice(kw.length).trim();
|
|
634
|
+
const semi = line.indexOf(";");
|
|
635
|
+
const core = (semi >= 0 ? line.slice(0, semi) : line).trim();
|
|
636
|
+
const arrowAt = core.indexOf("->");
|
|
637
|
+
if (arrowAt > 0 && !STORM_COORDS.test(core)) {
|
|
638
|
+
const from = core.slice(0, arrowAt).trim();
|
|
639
|
+
const to = core.slice(arrowAt + 2).trim();
|
|
640
|
+
if (from && to && (kw !== "title" || nameToId.has(decode(from)))) {
|
|
641
|
+
const label = semi >= 0 ? line.slice(semi + 1).trim() : "";
|
|
642
|
+
arrows.push({
|
|
643
|
+
from,
|
|
644
|
+
to,
|
|
645
|
+
...label ? { label } : {}
|
|
646
|
+
});
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (kw === "title") title = after;
|
|
651
|
+
else if (kw === "level") {
|
|
652
|
+
const value = after.toLowerCase();
|
|
653
|
+
if ([
|
|
654
|
+
"big-picture",
|
|
655
|
+
"process",
|
|
656
|
+
"design"
|
|
657
|
+
].includes(value)) level = value;
|
|
658
|
+
} else if (Object.hasOwn(STORM_ID_PREFIX, kw) || kw === "note") {
|
|
659
|
+
const at = STORM_COORDS.exec(after);
|
|
660
|
+
if (!at && after.includes("[")) continue;
|
|
661
|
+
const x = at ? Number(at[1]) : 0;
|
|
662
|
+
const y = at ? Number(at[2]) : 0;
|
|
663
|
+
if (Number.isNaN(x) || Number.isNaN(y)) continue;
|
|
664
|
+
const name = decode(at ? after.slice(0, at.index).trim() : stormSuffixes(after).rest.trim());
|
|
665
|
+
const { host, id: explicit, color } = stormSuffixes(at ? after.slice(at.index + at[0].length) : after);
|
|
666
|
+
if (kw !== "note" && !name) continue;
|
|
667
|
+
const id = kw === "note" ? alloc("note", name || "note") : claim(explicit) ?? alloc(STORM_ID_PREFIX[kw], name);
|
|
668
|
+
const node = {
|
|
669
|
+
id,
|
|
670
|
+
type: kw,
|
|
671
|
+
name,
|
|
672
|
+
extra: {
|
|
673
|
+
x,
|
|
674
|
+
y,
|
|
675
|
+
...color ? { color } : {}
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
nodes.push(node);
|
|
679
|
+
if (kw !== "note" && !nameToId.has(name)) nameToId.set(name, id);
|
|
680
|
+
if (host && STORM_ATTACHABLE_KINDS.has(kw)) pins.push({
|
|
681
|
+
node,
|
|
682
|
+
host
|
|
683
|
+
});
|
|
684
|
+
} else if (kw === "line") {
|
|
685
|
+
const points = STORM_POINTS.exec(after)?.[1];
|
|
686
|
+
const tuples = points ? [...points.matchAll(/\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]/g)] : [];
|
|
687
|
+
if (tuples.length < 2) continue;
|
|
688
|
+
nodes.push({
|
|
689
|
+
id: alloc("draw", "line"),
|
|
690
|
+
type: "drawing",
|
|
691
|
+
extra: {
|
|
692
|
+
x: Number(tuples[0][1]),
|
|
693
|
+
y: Number(tuples[0][2]),
|
|
694
|
+
points: tuples.length
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const resolve = (token) => {
|
|
700
|
+
const id = token.startsWith("#") ? token.slice(1) : nameToId.get(decode(token));
|
|
701
|
+
return id === void 0 ? void 0 : nodes.find((n) => n.id === id);
|
|
702
|
+
};
|
|
703
|
+
for (const { node, host } of pins) {
|
|
704
|
+
const target = resolve(host);
|
|
705
|
+
if (target && STORM_HOST_KINDS.has(target.type)) node.extra = {
|
|
706
|
+
...node.extra,
|
|
707
|
+
attachedTo: target.id
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
let n = 0;
|
|
711
|
+
for (const arrow of arrows) {
|
|
712
|
+
const from = resolve(arrow.from);
|
|
713
|
+
const to = resolve(arrow.to);
|
|
714
|
+
if (!from || !to || !Object.hasOwn(STORM_ID_PREFIX, from.type) || !Object.hasOwn(STORM_ID_PREFIX, to.type)) continue;
|
|
715
|
+
let id = `arrow_${++n}`;
|
|
716
|
+
while (used.has(id)) id = `arrow_${++n}`;
|
|
717
|
+
edges.push({
|
|
718
|
+
id,
|
|
719
|
+
from: from.id,
|
|
720
|
+
to: to.id,
|
|
721
|
+
kind: "arrow",
|
|
722
|
+
name: arrow.label
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
notation: "event-storming",
|
|
727
|
+
nodes,
|
|
728
|
+
edges,
|
|
729
|
+
meta: {
|
|
730
|
+
title,
|
|
731
|
+
level
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
const CM_SUBDOMAIN_TYPES = /* @__PURE__ */ new Set([
|
|
736
|
+
"core",
|
|
737
|
+
"supporting",
|
|
738
|
+
"generic"
|
|
739
|
+
]);
|
|
740
|
+
function extractContextMap(raw) {
|
|
741
|
+
const parsed = JSON.parse(raw);
|
|
742
|
+
const data = parsed !== null && typeof parsed === "object" ? parsed : {};
|
|
743
|
+
const contexts = Array.isArray(data.contexts) ? data.contexts : [];
|
|
744
|
+
const relationships = Array.isArray(data.relationships) ? data.relationships : [];
|
|
745
|
+
const text = (v) => typeof v === "string" && v !== "" ? v : void 0;
|
|
746
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
747
|
+
const roles = (v) => Array.isArray(v) ? v.filter((r) => typeof r === "string") : [];
|
|
748
|
+
const nodes = [];
|
|
749
|
+
for (const c of contexts) {
|
|
750
|
+
if (c === null || typeof c !== "object" || typeof c.id !== "string" || c.id === "") continue;
|
|
751
|
+
const subdomainType = text(c.subdomainType);
|
|
752
|
+
const team = text(c.team);
|
|
753
|
+
const description = text(c.description);
|
|
754
|
+
nodes.push({
|
|
755
|
+
id: c.id,
|
|
756
|
+
type: subdomainType && CM_SUBDOMAIN_TYPES.has(subdomainType) ? subdomainType : "context",
|
|
757
|
+
name: text(c.label),
|
|
758
|
+
extra: {
|
|
759
|
+
...team ? { team } : {},
|
|
760
|
+
...description ? { description } : {},
|
|
761
|
+
x: num(c.position?.x) ?? 0,
|
|
762
|
+
y: num(c.position?.y) ?? 0,
|
|
763
|
+
width: num(c.size?.width) ?? 0,
|
|
764
|
+
height: num(c.size?.height) ?? 0
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
const edges = [];
|
|
769
|
+
for (const [i, r] of relationships.entries()) {
|
|
770
|
+
if (r === null || typeof r !== "object") continue;
|
|
771
|
+
const upstreamRoles = roles(r.upstreamRoles);
|
|
772
|
+
const downstreamRoles = roles(r.downstreamRoles);
|
|
773
|
+
const implementationTechnology = text(r.implementationTechnology);
|
|
774
|
+
const description = text(r.description);
|
|
775
|
+
const extra = {
|
|
776
|
+
...upstreamRoles.length > 0 ? { upstreamRoles } : {},
|
|
777
|
+
...downstreamRoles.length > 0 ? { downstreamRoles } : {},
|
|
778
|
+
...implementationTechnology ? { implementationTechnology } : {},
|
|
779
|
+
...description ? { description } : {}
|
|
780
|
+
};
|
|
781
|
+
edges.push({
|
|
782
|
+
id: text(r.id) ?? `rel-${i}`,
|
|
783
|
+
from: text(r.from) ?? "",
|
|
784
|
+
to: text(r.to) ?? "",
|
|
785
|
+
kind: text(r.pattern) ?? "relationship",
|
|
786
|
+
name: text(r.label),
|
|
787
|
+
...Object.keys(extra).length > 0 ? { extra } : {}
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
notation: "context-map",
|
|
792
|
+
nodes,
|
|
793
|
+
edges,
|
|
794
|
+
meta: { title: text(data.title) ?? null }
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
function extractValueChain(raw) {
|
|
798
|
+
const data = JSON.parse(raw);
|
|
799
|
+
return {
|
|
800
|
+
notation: "value-chain",
|
|
801
|
+
nodes: (data.elements ?? []).map((el) => ({
|
|
802
|
+
id: el.id,
|
|
803
|
+
type: el.elementType ?? "element",
|
|
804
|
+
name: el.label ?? el.name
|
|
805
|
+
})),
|
|
806
|
+
edges: (data.connections ?? []).map((c, i) => ({
|
|
807
|
+
id: c.id ?? `conn-${i}`,
|
|
808
|
+
from: c.source,
|
|
809
|
+
to: c.target,
|
|
810
|
+
kind: c.connectionType ?? "connection"
|
|
811
|
+
}))
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
const EXTRACTORS = {
|
|
815
|
+
bpmn: extractBpmn,
|
|
816
|
+
dmn: extractDmn,
|
|
817
|
+
wardley: extractWardley,
|
|
818
|
+
"team-topology": extractTeamTopology,
|
|
819
|
+
"event-storming": extractEventStorming,
|
|
820
|
+
"context-map": extractContextMap,
|
|
821
|
+
"value-chain": extractValueChain
|
|
822
|
+
};
|
|
823
|
+
/** notation id or file path in, ModelGraph out; undefined = no extractor registered */
|
|
824
|
+
function extractModelGraph(notationOrPath, raw) {
|
|
825
|
+
const notation = byId(notationOrPath) ?? byExtension(notationOrPath);
|
|
826
|
+
const extractor = notation && EXTRACTORS[notation.id];
|
|
827
|
+
return extractor ? extractor(raw) : void 0;
|
|
828
|
+
}
|
|
829
|
+
//#endregion
|
|
830
|
+
//#region ../notations/content.ts
|
|
831
|
+
/**
|
|
832
|
+
* The content-repo contract (designiq.yml) — the ONE definition of "what is a
|
|
833
|
+
* content repo, and what are its models", shared by every consumer that reads
|
|
834
|
+
* a checkout: the Live Host, the MCP server, and the validator.
|
|
835
|
+
*
|
|
836
|
+
* # designiq.yml
|
|
837
|
+
* models: processes # or the legacy alias: processes: processes
|
|
838
|
+
*
|
|
839
|
+
* A repo is a content repo iff a root designiq.yml names the folder its model
|
|
840
|
+
* files live in. A model IS a file with a registered notation extension under
|
|
841
|
+
* that folder (@designiq/notations); its id is the file stem (modelStem). A
|
|
842
|
+
* process is the .bpmn special case, a decision the .dmn one. Nothing else
|
|
843
|
+
* about the layout is assumed.
|
|
844
|
+
*
|
|
845
|
+
* The contract file answers to two names (CONTENT_CONFIG_FILES, in precedence
|
|
846
|
+
* order): the first one that EXISTS at the root is THE config, the other is
|
|
847
|
+
* not even read. Selection is by existence only — a preferred file that is
|
|
848
|
+
* mid-edit or broken makes the repo "not a content repo" instead of silently
|
|
849
|
+
* handing the model folder to whatever the other file says. The second name
|
|
850
|
+
* is the legacy one: read forever, written never — the CLIs nudge a rename
|
|
851
|
+
* (legacyContentConfigFile).
|
|
852
|
+
*
|
|
853
|
+
* Node-only (fs + yaml) — imported via the "@designiq/notations/content" subpath,
|
|
854
|
+
* NEVER from the browser-safe package index.
|
|
855
|
+
*/
|
|
856
|
+
/** the documented name of the contract file — the one messages and docs say,
|
|
857
|
+
* and the one a new repo gets (the starter template carries it) */
|
|
858
|
+
const CONTENT_CONFIG_FILE = "designiq.yml";
|
|
859
|
+
/**
|
|
860
|
+
* Every name the contract file is accepted under, in precedence order. The
|
|
861
|
+
* second one is the legacy name and stays readable permanently: customer
|
|
862
|
+
* repos, the starter template and every released image <= 4.2.0 carry it.
|
|
863
|
+
*/
|
|
864
|
+
const CONTENT_CONFIG_FILES = ["designiq.yml", "bpmiq.yml"];
|
|
865
|
+
/**
|
|
866
|
+
* The NAME of the contract file at `root`: the first of CONTENT_CONFIG_FILES
|
|
867
|
+
* that exists. By existence ONLY — whether it parses is loadContentConfig's
|
|
868
|
+
* business. A present-but-broken first choice must NOT fall through to the
|
|
869
|
+
* next name: the file is plausibly mid-edit in a live session, and a fallback
|
|
870
|
+
* would silently flip the room-containment folder to a different one.
|
|
871
|
+
*/
|
|
872
|
+
function resolveContentConfigFile(root) {
|
|
873
|
+
return CONTENT_CONFIG_FILES.find((name) => isContractFile(root, name));
|
|
874
|
+
}
|
|
875
|
+
/** a regular FILE of that name — a folder that merely carries a contract name
|
|
876
|
+
* (anyone may create one in a repo whose models live at the root) must not
|
|
877
|
+
* shadow the real contract file under the other name */
|
|
878
|
+
function isContractFile(root, name) {
|
|
879
|
+
try {
|
|
880
|
+
return statSync(join(root, name)).isFile();
|
|
881
|
+
} catch {
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* The LEGACY name `root`'s contract is read from — set only when that file is
|
|
887
|
+
* the one actually in use (no designiq.yml beside it). A repo carrying both
|
|
888
|
+
* names on purpose (to stay readable by images that predate designiq.yml) is
|
|
889
|
+
* fine and gets no nudge; contentConfigConflict covers the two disagreeing.
|
|
890
|
+
*/
|
|
891
|
+
function legacyContentConfigFile(root) {
|
|
892
|
+
const used = resolveContentConfigFile(root);
|
|
893
|
+
return used === void 0 || used === "designiq.yml" ? void 0 : used;
|
|
894
|
+
}
|
|
895
|
+
/** how messages name the contract file: the documented name, then the legacy
|
|
896
|
+
* one an older repo still carries — its owner must recognise their file */
|
|
897
|
+
const CONTENT_CONFIG_NAMES = `${CONTENT_CONFIG_FILE} (or legacy ${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
|
+
* The one situation that needs a human: BOTH names exist and do not resolve
|
|
934
|
+
* to the same folder (a file naming no usable folder differs from one that
|
|
935
|
+
* does). `used` still wins — but a tool that predates it reads `ignored` and
|
|
936
|
+
* sees other models. Agreeing files are silent on purpose: a repo that must
|
|
937
|
+
* stay readable by older tools carries both for a long time. Two files that
|
|
938
|
+
* BOTH name nothing agree as well — that repo is simply not a content repo.
|
|
939
|
+
*/
|
|
940
|
+
function contentConfigConflict(root) {
|
|
941
|
+
const [used, ignored] = CONTENT_CONFIG_FILES.filter((name) => isContractFile(root, name));
|
|
942
|
+
if (used === void 0 || ignored === void 0) return void 0;
|
|
943
|
+
const usedFolder = readContentFolder(root, used);
|
|
944
|
+
const ignoredFolder = readContentFolder(root, ignored);
|
|
945
|
+
return usedFolder === ignoredFolder ? void 0 : {
|
|
946
|
+
used,
|
|
947
|
+
ignored,
|
|
948
|
+
usedFolder,
|
|
949
|
+
ignoredFolder
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Every file with a registered notation extension under the configured folder
|
|
954
|
+
* (recursive, sorted by path). The id is the file stem (modelStem) so it stays
|
|
955
|
+
* a single URL segment; ids are unique PER NOTATION (order.bpmn and order.dmn
|
|
956
|
+
* are legal separate namespaces) — a second same-notation file with the same
|
|
957
|
+
* stem elsewhere in the tree is skipped + logged rather than shadowing the
|
|
958
|
+
* first.
|
|
959
|
+
*/
|
|
960
|
+
async function discoverModels(root, cfg) {
|
|
961
|
+
const dir = join(root, cfg.models);
|
|
962
|
+
let entries;
|
|
963
|
+
try {
|
|
964
|
+
entries = await readdir(dir, {
|
|
965
|
+
withFileTypes: true,
|
|
966
|
+
recursive: true
|
|
967
|
+
});
|
|
968
|
+
} catch {
|
|
969
|
+
return [];
|
|
970
|
+
}
|
|
971
|
+
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();
|
|
972
|
+
const out = [];
|
|
973
|
+
const seen = /* @__PURE__ */ new Map();
|
|
974
|
+
for (const path of paths) {
|
|
975
|
+
const notation = byExtension(path);
|
|
976
|
+
if (!notation) continue;
|
|
977
|
+
const id = modelStem(path);
|
|
978
|
+
const key = `${notation.id}/${id}`;
|
|
979
|
+
const first = seen.get(key);
|
|
980
|
+
if (first) {
|
|
981
|
+
console.log(`content: duplicate ${notation.id} id '${id}' (${path}) — keeping ${first}`);
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
seen.set(key, path);
|
|
985
|
+
out.push({
|
|
986
|
+
id,
|
|
987
|
+
path,
|
|
988
|
+
notation: notation.id
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
return out;
|
|
992
|
+
}
|
|
993
|
+
//#endregion
|
|
994
|
+
export { byId as _, discoverModels as a, resolveContentConfigFile as c, extractModelGraph as d, parseXml as f, byExtension as g, kindOf as h, contentConfigConflict as i, XMLValidator as l, requirementHref as m, CONTENT_CONFIG_FILES as n, legacyContentConfigFile as o, readBpmn as p, CONTENT_CONFIG_NAMES as r, loadContentConfig as s, CONTENT_CONFIG_FILE as t, asArray as u };
|