@diagrammo/dgmo 0.8.5 → 0.8.7
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/.claude/commands/dgmo.md +34 -27
- package/.cursorrules +20 -2
- package/.github/copilot-instructions.md +20 -2
- package/.windsurfrules +20 -2
- package/AGENTS.md +23 -3
- package/README.md +0 -1
- package/dist/cli.cjs +189 -190
- package/dist/editor.cjs +3 -18
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +3 -18
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +4 -21
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +4 -21
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +2791 -2999
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +56 -56
- package/dist/index.d.ts +56 -56
- package/dist/index.js +2786 -2992
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +1 -1
- package/docs/language-reference.md +112 -121
- package/gallery/fixtures/boxes-and-lines.dgmo +64 -0
- package/package.json +1 -1
- package/src/boxes-and-lines/collapse.ts +78 -0
- package/src/boxes-and-lines/layout.ts +319 -0
- package/src/boxes-and-lines/parser.ts +697 -0
- package/src/boxes-and-lines/renderer.ts +848 -0
- package/src/boxes-and-lines/types.ts +40 -0
- package/src/c4/parser.ts +10 -5
- package/src/c4/renderer.ts +232 -56
- package/src/chart.ts +9 -4
- package/src/cli.ts +6 -5
- package/src/completion.ts +25 -33
- package/src/d3.ts +26 -27
- package/src/dgmo-router.ts +3 -7
- package/src/echarts.ts +38 -2
- package/src/editor/keywords.ts +4 -19
- package/src/er/parser.ts +10 -4
- package/src/gantt/parser.ts +10 -4
- package/src/gantt/renderer.ts +3 -5
- package/src/index.ts +17 -26
- package/src/infra/parser.ts +10 -5
- package/src/infra/renderer.ts +2 -2
- package/src/kanban/parser.ts +10 -5
- package/src/kanban/renderer.ts +43 -18
- package/src/org/parser.ts +7 -4
- package/src/org/renderer.ts +40 -29
- package/src/sequence/parser.ts +11 -5
- package/src/sequence/renderer.ts +114 -45
- package/src/sitemap/parser.ts +8 -4
- package/src/sitemap/renderer.ts +137 -57
- package/src/utils/legend-svg.ts +44 -20
- package/src/utils/parsing.ts +1 -1
- package/src/utils/tag-groups.ts +59 -15
- package/gallery/fixtures/initiative-status-full.dgmo +0 -46
- package/gallery/fixtures/initiative-status-phases.dgmo +0 -29
- package/gallery/fixtures/initiative-status.dgmo +0 -9
- package/src/initiative-status/collapse.ts +0 -76
- package/src/initiative-status/filter.ts +0 -63
- package/src/initiative-status/layout.ts +0 -650
- package/src/initiative-status/parser.ts +0 -629
- package/src/initiative-status/renderer.ts +0 -1199
- package/src/initiative-status/types.ts +0 -57
|
@@ -1,629 +0,0 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// Initiative Status Diagram — Parser
|
|
3
|
-
// ============================================================
|
|
4
|
-
|
|
5
|
-
import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
|
|
6
|
-
import type { DgmoError } from '../diagnostics';
|
|
7
|
-
import type {
|
|
8
|
-
ParsedInitiativeStatus,
|
|
9
|
-
ISNode,
|
|
10
|
-
ISEdge,
|
|
11
|
-
ISGroup,
|
|
12
|
-
InitiativeStatus,
|
|
13
|
-
} from './types';
|
|
14
|
-
import { VALID_STATUSES, STATUS_ALIASES } from './types';
|
|
15
|
-
import { inferParticipantType } from '../sequence/participant-inference';
|
|
16
|
-
import {
|
|
17
|
-
matchTagBlockHeading,
|
|
18
|
-
injectDefaultTagMetadata,
|
|
19
|
-
validateTagValues,
|
|
20
|
-
} from '../utils/tag-groups';
|
|
21
|
-
import type { TagGroup } from '../utils/tag-groups';
|
|
22
|
-
import {
|
|
23
|
-
extractColor,
|
|
24
|
-
parseFirstLine,
|
|
25
|
-
OPTION_NOCOLON_RE,
|
|
26
|
-
} from '../utils/parsing';
|
|
27
|
-
|
|
28
|
-
// ============================================================
|
|
29
|
-
// Heuristic — does this content look like an initiative-status diagram?
|
|
30
|
-
// ============================================================
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Returns true if the content looks like an initiative-status diagram.
|
|
34
|
-
* Detects `->` arrows combined with `| done/wip/todo/na` status markers.
|
|
35
|
-
*/
|
|
36
|
-
export function looksLikeInitiativeStatus(content: string): boolean {
|
|
37
|
-
const lines = content.split('\n');
|
|
38
|
-
let hasArrow = false;
|
|
39
|
-
let hasStatus = false;
|
|
40
|
-
let hasIndentedArrow = false;
|
|
41
|
-
for (const line of lines) {
|
|
42
|
-
const trimmed = line.trim();
|
|
43
|
-
if (!trimmed || trimmed.startsWith('//')) continue;
|
|
44
|
-
if (trimmed.match(/^chart\s*:/i)) continue;
|
|
45
|
-
if (trimmed.match(/^title\s*:/i)) continue;
|
|
46
|
-
// Skip new-style first line (bare chart type name)
|
|
47
|
-
if (parseFirstLine(trimmed)) continue;
|
|
48
|
-
if (trimmed.includes('->')) hasArrow = true;
|
|
49
|
-
if (
|
|
50
|
-
/\|\s*(done|doing|wip|blocked|paused|waiting|todo|na)\s*$/i.test(trimmed)
|
|
51
|
-
)
|
|
52
|
-
hasStatus = true;
|
|
53
|
-
// Indented arrow is a strong signal — only initiative-status uses this
|
|
54
|
-
const isIndented = line.length > 0 && line !== trimmed && /^\s/.test(line);
|
|
55
|
-
if (isIndented && (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed)))
|
|
56
|
-
hasIndentedArrow = true;
|
|
57
|
-
if (hasArrow && hasStatus) return true;
|
|
58
|
-
}
|
|
59
|
-
return hasIndentedArrow;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ============================================================
|
|
63
|
-
// Metadata parser — splits comma-delimited segment into status + tags
|
|
64
|
-
// ============================================================
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Parse the metadata segment after a `|` pipe into a status keyword
|
|
68
|
-
* and key:value tag pairs. Does NOT use parsePipeMetadata() from
|
|
69
|
-
* parsing.ts — that utility drops bare words (no colon), making it
|
|
70
|
-
* incompatible with status keyword extraction.
|
|
71
|
-
*
|
|
72
|
-
* @param segment The raw text after `|` — e.g. `"wip, p: Build, t: Backend"`
|
|
73
|
-
* @param aliasMap Maps lowercase aliases to lowercase group names
|
|
74
|
-
* @param lineNum Line number for diagnostic reporting
|
|
75
|
-
* @param diagnostics Array to push warnings into
|
|
76
|
-
*/
|
|
77
|
-
export function parseNodeMetadata(
|
|
78
|
-
segment: string,
|
|
79
|
-
aliasMap: Map<string, string>,
|
|
80
|
-
lineNum?: number,
|
|
81
|
-
diagnostics?: DgmoError[]
|
|
82
|
-
): {
|
|
83
|
-
status: InitiativeStatus;
|
|
84
|
-
metadata: Record<string, string>;
|
|
85
|
-
hadStatusWord: boolean;
|
|
86
|
-
} {
|
|
87
|
-
const metadata: Record<string, string> = {};
|
|
88
|
-
let status: InitiativeStatus = null;
|
|
89
|
-
let hadStatusWord = false;
|
|
90
|
-
|
|
91
|
-
const items = segment.split(',');
|
|
92
|
-
for (const item of items) {
|
|
93
|
-
const trimmed = item.trim();
|
|
94
|
-
if (!trimmed) continue;
|
|
95
|
-
|
|
96
|
-
const colonIdx = trimmed.indexOf(':');
|
|
97
|
-
if (colonIdx >= 0) {
|
|
98
|
-
// key: value pair
|
|
99
|
-
const rawKey = trimmed.slice(0, colonIdx).trim().toLowerCase();
|
|
100
|
-
const value = trimmed.slice(colonIdx + 1).trim();
|
|
101
|
-
|
|
102
|
-
// Handle explicit `status: keyword` form
|
|
103
|
-
if (rawKey === 'status') {
|
|
104
|
-
hadStatusWord = true;
|
|
105
|
-
const lower = value.toLowerCase();
|
|
106
|
-
const canonical = STATUS_ALIASES[lower] ?? lower;
|
|
107
|
-
if (VALID_STATUSES.includes(canonical)) {
|
|
108
|
-
status = canonical as InitiativeStatus;
|
|
109
|
-
} else if (lineNum !== undefined && diagnostics) {
|
|
110
|
-
const allKnown = [...VALID_STATUSES, ...Object.keys(STATUS_ALIASES)];
|
|
111
|
-
const hint = suggest(lower, allKnown);
|
|
112
|
-
const msg = `Unknown status "${value}"${hint ? `. ${hint}` : ''}`;
|
|
113
|
-
diagnostics.push(makeDgmoError(lineNum, msg, 'warning'));
|
|
114
|
-
}
|
|
115
|
-
} else {
|
|
116
|
-
// Resolve alias to group name
|
|
117
|
-
const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
|
|
118
|
-
metadata[resolvedKey] = value;
|
|
119
|
-
}
|
|
120
|
-
} else {
|
|
121
|
-
// Bare word — check if it's a status keyword (or alias)
|
|
122
|
-
hadStatusWord = true;
|
|
123
|
-
const lower = trimmed.toLowerCase();
|
|
124
|
-
const canonical = STATUS_ALIASES[lower] ?? lower;
|
|
125
|
-
if (VALID_STATUSES.includes(canonical)) {
|
|
126
|
-
status = canonical as InitiativeStatus;
|
|
127
|
-
} else if (lineNum !== undefined && diagnostics) {
|
|
128
|
-
// Unknown bare word — likely a status typo, emit warning
|
|
129
|
-
const allKnown = [...VALID_STATUSES, ...Object.keys(STATUS_ALIASES)];
|
|
130
|
-
const hint = suggest(lower, allKnown);
|
|
131
|
-
const msg = `Unknown status "${trimmed}"${hint ? `. ${hint}` : ''}`;
|
|
132
|
-
diagnostics.push(makeDgmoError(lineNum, msg, 'warning'));
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return { status, metadata, hadStatusWord };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// ============================================================
|
|
141
|
-
// Parser
|
|
142
|
-
// ============================================================
|
|
143
|
-
|
|
144
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
145
|
-
function _parseStatus(
|
|
146
|
-
raw: string,
|
|
147
|
-
line: number,
|
|
148
|
-
diagnostics: DgmoError[]
|
|
149
|
-
): InitiativeStatus {
|
|
150
|
-
const trimmed = raw.trim().toLowerCase();
|
|
151
|
-
if (!trimmed) return 'na';
|
|
152
|
-
const canonical = STATUS_ALIASES[trimmed] ?? trimmed;
|
|
153
|
-
if (VALID_STATUSES.includes(canonical)) return canonical as InitiativeStatus;
|
|
154
|
-
|
|
155
|
-
// Unknown status — emit warning with suggestion
|
|
156
|
-
const allKnown = [...VALID_STATUSES, ...Object.keys(STATUS_ALIASES)];
|
|
157
|
-
const hint = suggest(trimmed, allKnown);
|
|
158
|
-
const msg = `Unknown status "${raw.trim()}"${hint ? `. ${hint}` : ''}`;
|
|
159
|
-
diagnostics.push(makeDgmoError(line, msg, 'warning'));
|
|
160
|
-
return null;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Measure leading whitespace (tabs = 4 spaces) */
|
|
164
|
-
function measureIndent(line: string): number {
|
|
165
|
-
let count = 0;
|
|
166
|
-
for (const ch of line) {
|
|
167
|
-
if (ch === ' ') count++;
|
|
168
|
-
else if (ch === '\t') count += 4;
|
|
169
|
-
else break;
|
|
170
|
-
}
|
|
171
|
-
return count;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
|
|
175
|
-
const result: ParsedInitiativeStatus = {
|
|
176
|
-
type: 'initiative-status',
|
|
177
|
-
title: null,
|
|
178
|
-
titleLineNumber: null,
|
|
179
|
-
nodes: [],
|
|
180
|
-
edges: [],
|
|
181
|
-
groups: [],
|
|
182
|
-
tagGroups: [],
|
|
183
|
-
options: {},
|
|
184
|
-
initialHiddenTagValues: new Map(),
|
|
185
|
-
diagnostics: [],
|
|
186
|
-
error: null,
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
const lines = content.split('\n');
|
|
190
|
-
const nodeLabels = new Set<string>();
|
|
191
|
-
let currentGroup: ISGroup | null = null;
|
|
192
|
-
let lastNodeLabel: string | null = null;
|
|
193
|
-
|
|
194
|
-
// Tag block state
|
|
195
|
-
let contentStarted = false;
|
|
196
|
-
let currentTagGroup: TagGroup | null = null;
|
|
197
|
-
const aliasMap = new Map<string, string>(); // lowercase alias → lowercase group name
|
|
198
|
-
|
|
199
|
-
const pushWarning = (lineNumber: number, message: string) => {
|
|
200
|
-
result.diagnostics.push(makeDgmoError(lineNumber, message, 'warning'));
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
for (let i = 0; i < lines.length; i++) {
|
|
204
|
-
const lineNum = i + 1; // 1-based
|
|
205
|
-
const raw = lines[i];
|
|
206
|
-
const trimmed = raw.trim();
|
|
207
|
-
|
|
208
|
-
// Skip blanks and comments
|
|
209
|
-
if (!trimmed || trimmed.startsWith('//')) continue;
|
|
210
|
-
|
|
211
|
-
// First line: chart type + optional title (new syntax: `initiative-status My Dashboard`)
|
|
212
|
-
const firstLineResult = parseFirstLine(trimmed);
|
|
213
|
-
if (firstLineResult && !contentStarted) {
|
|
214
|
-
if (firstLineResult.chartType !== 'initiative-status') {
|
|
215
|
-
const diag = makeDgmoError(
|
|
216
|
-
lineNum,
|
|
217
|
-
`Expected chart type "initiative-status", got "${firstLineResult.chartType}"`
|
|
218
|
-
);
|
|
219
|
-
result.diagnostics.push(diag);
|
|
220
|
-
result.error = formatDgmoError(diag);
|
|
221
|
-
return result;
|
|
222
|
-
}
|
|
223
|
-
if (firstLineResult.title) {
|
|
224
|
-
result.title = firstLineResult.title;
|
|
225
|
-
result.titleLineNumber = lineNum;
|
|
226
|
-
}
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// hide directive (colon syntax): `hide phase:Planning, phase:Review`
|
|
231
|
-
const hideMatch = trimmed.match(/^hide\s+(.+)/i);
|
|
232
|
-
if (hideMatch && !trimmed.match(/^hide\s*\|/)) {
|
|
233
|
-
// Parse comma-separated tag:value pairs: `phase:Planning, phase:Review`
|
|
234
|
-
const pairs = hideMatch[1].split(',');
|
|
235
|
-
for (const pair of pairs) {
|
|
236
|
-
const colonIdx = pair.indexOf(':');
|
|
237
|
-
if (colonIdx > 0) {
|
|
238
|
-
const groupKey = pair.substring(0, colonIdx).trim().toLowerCase();
|
|
239
|
-
const value = pair
|
|
240
|
-
.substring(colonIdx + 1)
|
|
241
|
-
.trim()
|
|
242
|
-
.toLowerCase();
|
|
243
|
-
if (groupKey && value) {
|
|
244
|
-
if (!result.initialHiddenTagValues.has(groupKey)) {
|
|
245
|
-
result.initialHiddenTagValues.set(groupKey, new Set());
|
|
246
|
-
}
|
|
247
|
-
result.initialHiddenTagValues.get(groupKey)!.add(value);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Options (space-separated, non-indented): `active-tag Priority`
|
|
255
|
-
if (!contentStarted && measureIndent(raw) === 0) {
|
|
256
|
-
const optMatch = trimmed.match(OPTION_NOCOLON_RE);
|
|
257
|
-
if (optMatch) {
|
|
258
|
-
const key = optMatch[1].toLowerCase();
|
|
259
|
-
const value = optMatch[2].trim();
|
|
260
|
-
// Only recognize known option keys (not node content)
|
|
261
|
-
if (key === 'active-tag') {
|
|
262
|
-
result.options[key] = value;
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Tag group heading — must be checked BEFORE group/node/edge matching
|
|
269
|
-
const tagBlockMatch = matchTagBlockHeading(trimmed);
|
|
270
|
-
if (tagBlockMatch) {
|
|
271
|
-
if (contentStarted) {
|
|
272
|
-
result.diagnostics.push(
|
|
273
|
-
makeDgmoError(
|
|
274
|
-
lineNum,
|
|
275
|
-
'Tag groups must appear before diagram content',
|
|
276
|
-
'error'
|
|
277
|
-
)
|
|
278
|
-
);
|
|
279
|
-
continue;
|
|
280
|
-
}
|
|
281
|
-
currentTagGroup = {
|
|
282
|
-
name: tagBlockMatch.name,
|
|
283
|
-
alias: tagBlockMatch.alias,
|
|
284
|
-
entries: [],
|
|
285
|
-
lineNumber: lineNum,
|
|
286
|
-
};
|
|
287
|
-
if (tagBlockMatch.alias) {
|
|
288
|
-
aliasMap.set(
|
|
289
|
-
tagBlockMatch.alias.toLowerCase(),
|
|
290
|
-
tagBlockMatch.name.toLowerCase()
|
|
291
|
-
);
|
|
292
|
-
}
|
|
293
|
-
// Handle inline values from single-line tag declaration
|
|
294
|
-
if (tagBlockMatch.inlineValues) {
|
|
295
|
-
for (const rawVal of tagBlockMatch.inlineValues) {
|
|
296
|
-
const { label, color } = extractColor(rawVal);
|
|
297
|
-
currentTagGroup.entries.push({
|
|
298
|
-
value: label,
|
|
299
|
-
color: color ?? '',
|
|
300
|
-
lineNumber: lineNum,
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
// First entry is the default
|
|
304
|
-
if (currentTagGroup.entries.length > 0) {
|
|
305
|
-
currentTagGroup.defaultValue = currentTagGroup.entries[0].value;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
result.tagGroups.push(currentTagGroup);
|
|
309
|
-
continue;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Tag group entries (indented Value(color) under tag heading — first value is the default)
|
|
313
|
-
if (currentTagGroup && !contentStarted) {
|
|
314
|
-
const indent = measureIndent(raw);
|
|
315
|
-
if (indent > 0) {
|
|
316
|
-
const { label, color } = extractColor(trimmed);
|
|
317
|
-
currentTagGroup.entries.push({
|
|
318
|
-
value: label,
|
|
319
|
-
color: color ?? '',
|
|
320
|
-
lineNumber: lineNum,
|
|
321
|
-
});
|
|
322
|
-
// First entry is the default
|
|
323
|
-
if (currentTagGroup.entries.length === 1) {
|
|
324
|
-
currentTagGroup.defaultValue = label;
|
|
325
|
-
}
|
|
326
|
-
continue;
|
|
327
|
-
}
|
|
328
|
-
// Non-indented line after tag group — close and fall through
|
|
329
|
-
currentTagGroup = null; // eslint-disable-line no-useless-assignment
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Group header: [Group Name] or [Group Name] | metadata
|
|
333
|
-
const groupMatch = trimmed.match(/^\[(.+?)\]\s*(?:\|\s*(.+))?$/);
|
|
334
|
-
if (groupMatch) {
|
|
335
|
-
contentStarted = true;
|
|
336
|
-
currentTagGroup = null;
|
|
337
|
-
// Close previous group
|
|
338
|
-
if (currentGroup) {
|
|
339
|
-
result.groups.push(currentGroup);
|
|
340
|
-
}
|
|
341
|
-
const groupMeta: Record<string, string> = {};
|
|
342
|
-
if (groupMatch[2]) {
|
|
343
|
-
// Parse pipe metadata for group (only key:value pairs, no status)
|
|
344
|
-
const items = groupMatch[2].split(',');
|
|
345
|
-
for (const item of items) {
|
|
346
|
-
const ci = item.indexOf(':');
|
|
347
|
-
if (ci >= 0) {
|
|
348
|
-
const rawKey = item.slice(0, ci).trim().toLowerCase();
|
|
349
|
-
const value = item.slice(ci + 1).trim();
|
|
350
|
-
const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
|
|
351
|
-
groupMeta[resolvedKey] = value;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
currentGroup = {
|
|
356
|
-
label: groupMatch[1],
|
|
357
|
-
nodeLabels: [],
|
|
358
|
-
lineNumber: lineNum,
|
|
359
|
-
metadata: Object.keys(groupMeta).length > 0 ? groupMeta : undefined,
|
|
360
|
-
};
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// Non-indented line closes the current group
|
|
365
|
-
const isIndented = raw.length > 0 && raw !== trimmed && /^\s/.test(raw);
|
|
366
|
-
if (!isIndented && currentGroup) {
|
|
367
|
-
result.groups.push(currentGroup);
|
|
368
|
-
currentGroup = null;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// Edge: contains `->` or labeled form `-label->`
|
|
372
|
-
if (trimmed.includes('->')) {
|
|
373
|
-
contentStarted = true;
|
|
374
|
-
currentTagGroup = null;
|
|
375
|
-
let edgeText = trimmed;
|
|
376
|
-
// Indented `-> Target` or `-label-> Target` shorthand
|
|
377
|
-
if (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed)) {
|
|
378
|
-
if (!lastNodeLabel) {
|
|
379
|
-
result.diagnostics.push(
|
|
380
|
-
makeDgmoError(
|
|
381
|
-
lineNum,
|
|
382
|
-
'Indented edge has no preceding node to use as source',
|
|
383
|
-
'warning'
|
|
384
|
-
)
|
|
385
|
-
);
|
|
386
|
-
continue;
|
|
387
|
-
}
|
|
388
|
-
edgeText = `${lastNodeLabel} ${trimmed}`;
|
|
389
|
-
}
|
|
390
|
-
const edge = parseEdgeLine(
|
|
391
|
-
edgeText,
|
|
392
|
-
lineNum,
|
|
393
|
-
aliasMap,
|
|
394
|
-
result.diagnostics
|
|
395
|
-
);
|
|
396
|
-
if (edge) result.edges.push(edge);
|
|
397
|
-
continue;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Node: everything else
|
|
401
|
-
contentStarted = true;
|
|
402
|
-
currentTagGroup = null;
|
|
403
|
-
const node = parseNodeLine(trimmed, lineNum, aliasMap, result.diagnostics);
|
|
404
|
-
if (!node) {
|
|
405
|
-
result.diagnostics.push(
|
|
406
|
-
makeDgmoError(lineNum, `Unexpected line: '${trimmed}'.`, 'warning')
|
|
407
|
-
);
|
|
408
|
-
continue;
|
|
409
|
-
}
|
|
410
|
-
lastNodeLabel = node.label;
|
|
411
|
-
if (nodeLabels.has(node.label)) {
|
|
412
|
-
result.diagnostics.push(
|
|
413
|
-
makeDgmoError(lineNum, `Duplicate node "${node.label}"`, 'warning')
|
|
414
|
-
);
|
|
415
|
-
} else {
|
|
416
|
-
nodeLabels.add(node.label);
|
|
417
|
-
}
|
|
418
|
-
// Cascade group metadata into node (group provides defaults, node overrides)
|
|
419
|
-
if (currentGroup && isIndented && currentGroup.metadata) {
|
|
420
|
-
for (const [key, val] of Object.entries(currentGroup.metadata)) {
|
|
421
|
-
if (!(key in node.metadata)) {
|
|
422
|
-
node.metadata[key] = val;
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
result.nodes.push(node);
|
|
427
|
-
// Add to current group if indented
|
|
428
|
-
if (currentGroup && isIndented) {
|
|
429
|
-
currentGroup.nodeLabels.push(node.label);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Close any trailing group
|
|
434
|
-
if (currentGroup) {
|
|
435
|
-
result.groups.push(currentGroup);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// Validate edges reference declared nodes
|
|
439
|
-
for (const edge of result.edges) {
|
|
440
|
-
if (!nodeLabels.has(edge.source)) {
|
|
441
|
-
result.diagnostics.push(
|
|
442
|
-
makeDgmoError(
|
|
443
|
-
edge.lineNumber,
|
|
444
|
-
`Edge source "${edge.source}" is not a declared node`,
|
|
445
|
-
'warning'
|
|
446
|
-
)
|
|
447
|
-
);
|
|
448
|
-
// Auto-create an implicit node
|
|
449
|
-
if (!result.nodes.some((n) => n.label === edge.source)) {
|
|
450
|
-
result.nodes.push({
|
|
451
|
-
label: edge.source,
|
|
452
|
-
status: 'na',
|
|
453
|
-
shape: inferParticipantType(edge.source),
|
|
454
|
-
lineNumber: edge.lineNumber,
|
|
455
|
-
metadata: {},
|
|
456
|
-
});
|
|
457
|
-
nodeLabels.add(edge.source);
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
if (!nodeLabels.has(edge.target)) {
|
|
461
|
-
result.diagnostics.push(
|
|
462
|
-
makeDgmoError(
|
|
463
|
-
edge.lineNumber,
|
|
464
|
-
`Edge target "${edge.target}" is not a declared node`,
|
|
465
|
-
'warning'
|
|
466
|
-
)
|
|
467
|
-
);
|
|
468
|
-
if (!result.nodes.some((n) => n.label === edge.target)) {
|
|
469
|
-
result.nodes.push({
|
|
470
|
-
label: edge.target,
|
|
471
|
-
status: 'na',
|
|
472
|
-
shape: inferParticipantType(edge.target),
|
|
473
|
-
lineNumber: edge.lineNumber,
|
|
474
|
-
metadata: {},
|
|
475
|
-
});
|
|
476
|
-
nodeLabels.add(edge.target);
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// Post-parse: inject default tag metadata and validate tag values
|
|
482
|
-
if (result.tagGroups.length > 0) {
|
|
483
|
-
injectDefaultTagMetadata(result.nodes, result.tagGroups);
|
|
484
|
-
validateTagValues(result.nodes, result.tagGroups, pushWarning, suggest);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
return result;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// ============================================================
|
|
491
|
-
// Line parsers
|
|
492
|
-
// ============================================================
|
|
493
|
-
|
|
494
|
-
function parseNodeLine(
|
|
495
|
-
trimmed: string,
|
|
496
|
-
lineNum: number,
|
|
497
|
-
aliasMap: Map<string, string>,
|
|
498
|
-
diagnostics: DgmoError[]
|
|
499
|
-
): ISNode | null {
|
|
500
|
-
// Format: <label> | <status>, <key: value>, ...
|
|
501
|
-
// or just: <label>
|
|
502
|
-
const pipeIdx = trimmed.indexOf('|');
|
|
503
|
-
if (pipeIdx >= 0) {
|
|
504
|
-
const label = trimmed.slice(0, pipeIdx).trim();
|
|
505
|
-
const metaSegment = trimmed.slice(pipeIdx + 1).trim();
|
|
506
|
-
if (!label) return null;
|
|
507
|
-
const { status, metadata, hadStatusWord } = parseNodeMetadata(
|
|
508
|
-
metaSegment,
|
|
509
|
-
aliasMap,
|
|
510
|
-
lineNum,
|
|
511
|
-
diagnostics
|
|
512
|
-
);
|
|
513
|
-
return {
|
|
514
|
-
label,
|
|
515
|
-
// Unknown status bare word → keep null; no bare word at all → default 'na'
|
|
516
|
-
status: hadStatusWord ? status : (status ?? 'na'),
|
|
517
|
-
shape: inferParticipantType(label),
|
|
518
|
-
lineNumber: lineNum,
|
|
519
|
-
metadata,
|
|
520
|
-
};
|
|
521
|
-
}
|
|
522
|
-
return {
|
|
523
|
-
label: trimmed,
|
|
524
|
-
status: 'na',
|
|
525
|
-
shape: inferParticipantType(trimmed),
|
|
526
|
-
lineNumber: lineNum,
|
|
527
|
-
metadata: {},
|
|
528
|
-
};
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
function parseEdgeLine(
|
|
532
|
-
trimmed: string,
|
|
533
|
-
lineNum: number,
|
|
534
|
-
aliasMap: Map<string, string>,
|
|
535
|
-
diagnostics: DgmoError[]
|
|
536
|
-
): ISEdge | null {
|
|
537
|
-
// Format: <source> -> <target>: <label> | <status>, <key: value>, ...
|
|
538
|
-
// or: <source> -> <target> | <status>
|
|
539
|
-
// or: <source> -> <target>: <label>
|
|
540
|
-
// or: <source> -> <target>
|
|
541
|
-
// or: <source> -<label>-> <target> [| <status>]
|
|
542
|
-
|
|
543
|
-
// Check for labeled arrow form: SOURCE -LABEL-> TARGET [| status]
|
|
544
|
-
const labeledMatch = trimmed.match(/^(\S+)\s*-(.+)->\s*(.+)$/);
|
|
545
|
-
if (labeledMatch) {
|
|
546
|
-
const source = labeledMatch[1];
|
|
547
|
-
const label = labeledMatch[2].trim();
|
|
548
|
-
let targetRest = labeledMatch[3].trim();
|
|
549
|
-
|
|
550
|
-
if (label) {
|
|
551
|
-
let status: InitiativeStatus = 'na';
|
|
552
|
-
let metadata: Record<string, string> = {};
|
|
553
|
-
const pipeIdx = targetRest.indexOf('|');
|
|
554
|
-
if (pipeIdx >= 0) {
|
|
555
|
-
const metaSegment = targetRest.slice(pipeIdx + 1).trim();
|
|
556
|
-
const parsed = parseNodeMetadata(
|
|
557
|
-
metaSegment,
|
|
558
|
-
aliasMap,
|
|
559
|
-
lineNum,
|
|
560
|
-
diagnostics
|
|
561
|
-
);
|
|
562
|
-
status = parsed.hadStatusWord
|
|
563
|
-
? (parsed.status ?? null)
|
|
564
|
-
: (parsed.status ?? 'na');
|
|
565
|
-
metadata = parsed.metadata;
|
|
566
|
-
targetRest = targetRest.slice(0, pipeIdx).trim();
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
const target = targetRest.trim();
|
|
570
|
-
if (!target) {
|
|
571
|
-
diagnostics.push(makeDgmoError(lineNum, 'Edge is missing target'));
|
|
572
|
-
return null;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
return { source, target, label, status, lineNumber: lineNum, metadata };
|
|
576
|
-
}
|
|
577
|
-
// Empty label — fall through to plain arrow parsing
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
const arrowIdx = trimmed.indexOf('->');
|
|
581
|
-
if (arrowIdx < 0) return null;
|
|
582
|
-
|
|
583
|
-
const source = trimmed.slice(0, arrowIdx).trim();
|
|
584
|
-
let rest = trimmed.slice(arrowIdx + 2).trim();
|
|
585
|
-
|
|
586
|
-
if (!source || !rest) {
|
|
587
|
-
diagnostics.push(
|
|
588
|
-
makeDgmoError(lineNum, 'Edge is missing source or target')
|
|
589
|
-
);
|
|
590
|
-
return null;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// Extract metadata from end (after |)
|
|
594
|
-
let status: InitiativeStatus = 'na';
|
|
595
|
-
let metadata: Record<string, string> = {};
|
|
596
|
-
const pipeIdx = rest.indexOf('|');
|
|
597
|
-
if (pipeIdx >= 0) {
|
|
598
|
-
const metaSegment = rest.slice(pipeIdx + 1).trim();
|
|
599
|
-
const parsed = parseNodeMetadata(
|
|
600
|
-
metaSegment,
|
|
601
|
-
aliasMap,
|
|
602
|
-
lineNum,
|
|
603
|
-
diagnostics
|
|
604
|
-
);
|
|
605
|
-
status = parsed.hadStatusWord
|
|
606
|
-
? (parsed.status ?? null)
|
|
607
|
-
: (parsed.status ?? 'na');
|
|
608
|
-
metadata = parsed.metadata;
|
|
609
|
-
rest = rest.slice(0, pipeIdx).trim();
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// Extract target and optional label (target: label)
|
|
613
|
-
let target: string;
|
|
614
|
-
let label: string | undefined;
|
|
615
|
-
const colonIdx = rest.indexOf(':');
|
|
616
|
-
if (colonIdx >= 0) {
|
|
617
|
-
target = rest.slice(0, colonIdx).trim();
|
|
618
|
-
label = rest.slice(colonIdx + 1).trim() || undefined;
|
|
619
|
-
} else {
|
|
620
|
-
target = rest.trim();
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
if (!target) {
|
|
624
|
-
diagnostics.push(makeDgmoError(lineNum, 'Edge is missing target'));
|
|
625
|
-
return null;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
return { source, target, label, status, lineNumber: lineNum, metadata };
|
|
629
|
-
}
|