@diagrammo/dgmo 0.2.18 → 0.2.20

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.
@@ -0,0 +1,470 @@
1
+ import type { DgmoError } from '../diagnostics';
2
+ import { makeDgmoError } from '../diagnostics';
3
+
4
+ // ============================================================
5
+ // Types
6
+ // ============================================================
7
+
8
+ /**
9
+ * Async or sync file reader. Receives an absolute path, returns content.
10
+ * Throwing means "file not found".
11
+ */
12
+ export type ReadFileFn = (path: string) => string | Promise<string>;
13
+
14
+ export interface ResolveImportsResult {
15
+ content: string;
16
+ diagnostics: DgmoError[];
17
+ }
18
+
19
+ // ============================================================
20
+ // Constants
21
+ // ============================================================
22
+
23
+ const MAX_DEPTH = 10;
24
+ const IMPORT_RE = /^(\s+)import:\s+(.+\.dgmo)\s*$/i;
25
+ const TAGS_RE = /^tags:\s+(.+\.dgmo)\s*$/i;
26
+ const HEADER_RE = /^(chart|title)\s*:/i;
27
+ const OPTION_RE = /^[a-z][a-z0-9-]*\s*:/i;
28
+ const GROUP_HEADING_RE = /^##\s+/;
29
+
30
+ // ============================================================
31
+ // Path Helpers (pure string ops — no Node `path` dependency)
32
+ // ============================================================
33
+
34
+ function dirname(filePath: string): string {
35
+ const last = filePath.lastIndexOf('/');
36
+ return last > 0 ? filePath.substring(0, last) : '/';
37
+ }
38
+
39
+ function resolvePath(base: string, relative: string): string {
40
+ const parts = dirname(base).split('/');
41
+ for (const seg of relative.split('/')) {
42
+ if (seg === '..') {
43
+ if (parts.length > 1) parts.pop();
44
+ } else if (seg !== '.' && seg !== '') {
45
+ parts.push(seg);
46
+ }
47
+ }
48
+ return parts.join('/') || '/';
49
+ }
50
+
51
+ // ============================================================
52
+ // Tag Group Extraction
53
+ // ============================================================
54
+
55
+ interface TagGroupBlock {
56
+ name: string; // lowercased for comparison
57
+ lines: string[]; // raw lines including heading + entries
58
+ }
59
+
60
+ /**
61
+ * Extract ## tag group blocks from content lines.
62
+ * Returns blocks in order, each with its heading + indented entries.
63
+ */
64
+ function extractTagGroups(lines: string[]): TagGroupBlock[] {
65
+ const blocks: TagGroupBlock[] = [];
66
+ let current: TagGroupBlock | null = null;
67
+
68
+ for (const line of lines) {
69
+ const trimmed = line.trim();
70
+ if (GROUP_HEADING_RE.test(trimmed)) {
71
+ // Extract group name (everything after "## " up to optional alias/color)
72
+ const nameMatch = trimmed.match(/^##\s+(.+?)(?:\s+alias\s+\w+)?(?:\s*\([^)]+\))?\s*$/);
73
+ const name = nameMatch ? nameMatch[1].trim().toLowerCase() : trimmed.substring(3).trim().toLowerCase();
74
+ current = { name, lines: [line] };
75
+ blocks.push(current);
76
+ } else if (current) {
77
+ if (trimmed === '' || trimmed.startsWith('//')) {
78
+ // Blank line or comment ends the tag group
79
+ current = null;
80
+ } else if (line.match(/^\s+/)) {
81
+ // Indented = tag entry
82
+ current.lines.push(line);
83
+ } else {
84
+ // Non-indented non-heading = end of tag group
85
+ current = null;
86
+ }
87
+ }
88
+ }
89
+
90
+ return blocks;
91
+ }
92
+
93
+ // ============================================================
94
+ // Header Stripping
95
+ // ============================================================
96
+
97
+ interface ParsedHeader {
98
+ /** Lines that are NOT header/tags/tag-groups — the "content" body */
99
+ contentLines: string[];
100
+ tagGroups: TagGroupBlock[];
101
+ tagsDirective: string | null;
102
+ }
103
+
104
+ /**
105
+ * Separate an imported file into header (stripped) and content body.
106
+ * Also extracts tag groups and tags: directive for merging.
107
+ */
108
+ function parseFileHeader(lines: string[]): ParsedHeader {
109
+ const tagGroups = extractTagGroups(lines);
110
+ const tagGroupLineSet = new Set<number>();
111
+ for (const group of tagGroups) {
112
+ // Find where this group starts in lines
113
+ for (let i = 0; i < lines.length; i++) {
114
+ if (lines[i] === group.lines[0]) {
115
+ for (let j = 0; j < group.lines.length; j++) {
116
+ tagGroupLineSet.add(i + j);
117
+ }
118
+ break;
119
+ }
120
+ }
121
+ }
122
+
123
+ let tagsDirective: string | null = null;
124
+ const contentLines: string[] = [];
125
+ let headerDone = false;
126
+
127
+ for (let i = 0; i < lines.length; i++) {
128
+ // Skip tag group lines
129
+ if (tagGroupLineSet.has(i)) continue;
130
+
131
+ const trimmed = lines[i].trim();
132
+
133
+ // Skip blank/comment lines in header region
134
+ if (!headerDone && (trimmed === '' || trimmed.startsWith('//'))) continue;
135
+
136
+ // Header lines
137
+ if (!headerDone) {
138
+ if (HEADER_RE.test(trimmed)) continue;
139
+
140
+ const tagsMatch = trimmed.match(TAGS_RE);
141
+ if (tagsMatch) {
142
+ tagsDirective = tagsMatch[1].trim();
143
+ continue;
144
+ }
145
+
146
+ // Other option-like header lines (non-indented key: value)
147
+ if (OPTION_RE.test(trimmed) && !trimmed.startsWith('##') && !lines[i].match(/^\s/)) {
148
+ // Check it's not a content line (node with metadata)
149
+ const key = trimmed.split(':')[0].trim().toLowerCase();
150
+ if (key !== 'chart' && key !== 'title' && !trimmed.includes('|')) {
151
+ continue;
152
+ }
153
+ }
154
+
155
+ headerDone = true;
156
+ }
157
+
158
+ contentLines.push(lines[i]);
159
+ }
160
+
161
+ return { contentLines, tagGroups, tagsDirective };
162
+ }
163
+
164
+ // ============================================================
165
+ // Main Resolver
166
+ // ============================================================
167
+
168
+ /**
169
+ * Pre-processes org chart content, resolving `tags:` and `import:` directives.
170
+ *
171
+ * @param content - Raw .dgmo file content
172
+ * @param filePath - Absolute path of the file (for relative path resolution)
173
+ * @param readFileFn - Function to read files (sync or async)
174
+ * @returns Merged content with all imports resolved + diagnostics
175
+ */
176
+ export async function resolveOrgImports(
177
+ content: string,
178
+ filePath: string,
179
+ readFileFn: ReadFileFn,
180
+ ): Promise<ResolveImportsResult> {
181
+ const diagnostics: DgmoError[] = [];
182
+ const result = await resolveFile(content, filePath, readFileFn, diagnostics, new Set([filePath]), 0);
183
+ return { content: result, diagnostics };
184
+ }
185
+
186
+ async function resolveFile(
187
+ content: string,
188
+ filePath: string,
189
+ readFileFn: ReadFileFn,
190
+ diagnostics: DgmoError[],
191
+ ancestorChain: Set<string>,
192
+ depth: number,
193
+ ): Promise<string> {
194
+ const lines = content.split('\n');
195
+
196
+ // ---- Step 1: Identify header, tags directive, inline tag groups ----
197
+ const headerLines: string[] = [];
198
+ let tagsDirective: string | null = null;
199
+ const inlineTagGroups = extractTagGroups(lines);
200
+ const bodyStartIndex = findBodyStart(lines);
201
+
202
+ // Collect header lines (chart:, title:, options, tags:)
203
+ for (let i = 0; i < bodyStartIndex; i++) {
204
+ const trimmed = lines[i].trim();
205
+ if (trimmed === '' || trimmed.startsWith('//')) {
206
+ headerLines.push(lines[i]);
207
+ continue;
208
+ }
209
+ if (GROUP_HEADING_RE.test(trimmed)) continue; // skip inline tag group headings
210
+ if (lines[i] !== trimmed) continue; // skip tag group entries (indented lines)
211
+
212
+ const tagsMatch = trimmed.match(TAGS_RE);
213
+ if (tagsMatch) {
214
+ tagsDirective = tagsMatch[1].trim();
215
+ continue;
216
+ }
217
+
218
+ headerLines.push(lines[i]);
219
+ }
220
+
221
+ // ---- Step 2: Resolve tags: directive ----
222
+ let tagsFileGroups: TagGroupBlock[] = [];
223
+ if (tagsDirective) {
224
+ const tagsPath = resolvePath(filePath, tagsDirective);
225
+ try {
226
+ const tagsContent = await readFileFn(tagsPath);
227
+ const tagsLines = tagsContent.split('\n');
228
+ tagsFileGroups = extractTagGroups(tagsLines);
229
+ } catch {
230
+ diagnostics.push(
231
+ makeDgmoError(0, `Tags file not found: ${tagsDirective}`)
232
+ );
233
+ }
234
+ }
235
+
236
+ // ---- Step 3: Resolve import: directives in body ----
237
+ const bodyLines = lines.slice(bodyStartIndex);
238
+ const resolvedBodyLines: string[] = [];
239
+ const importedTagGroups: TagGroupBlock[] = [];
240
+
241
+ for (let i = 0; i < bodyLines.length; i++) {
242
+ const line = bodyLines[i];
243
+ const lineNumber = bodyStartIndex + i + 1; // 1-based for diagnostics
244
+ const importMatch = line.match(IMPORT_RE);
245
+
246
+ if (!importMatch) {
247
+ // Pass through — skip inline tag group lines (already extracted above)
248
+ const trimmed = line.trim();
249
+ if (GROUP_HEADING_RE.test(trimmed) || (inlineTagGroups.length > 0 && isTagGroupEntry(line, bodyLines, i))) {
250
+ continue;
251
+ }
252
+ resolvedBodyLines.push(line);
253
+ continue;
254
+ }
255
+
256
+ const indent = importMatch[1];
257
+ const importRelPath = importMatch[2].trim();
258
+ const importAbsPath = resolvePath(filePath, importRelPath);
259
+
260
+ // Depth check
261
+ if (depth >= MAX_DEPTH) {
262
+ diagnostics.push(
263
+ makeDgmoError(lineNumber, `Import depth limit exceeded (${MAX_DEPTH}): ${importRelPath}`)
264
+ );
265
+ continue;
266
+ }
267
+
268
+ // Circular check
269
+ if (ancestorChain.has(importAbsPath)) {
270
+ const chain = [...ancestorChain, importAbsPath].map(p => p.split('/').pop()).join(' -> ');
271
+ diagnostics.push(
272
+ makeDgmoError(lineNumber, `Circular import detected: ${chain}`)
273
+ );
274
+ continue;
275
+ }
276
+
277
+ // Read imported file
278
+ let importedContent: string;
279
+ try {
280
+ importedContent = await readFileFn(importAbsPath);
281
+ } catch {
282
+ diagnostics.push(
283
+ makeDgmoError(lineNumber, `Import file not found: ${importRelPath}`)
284
+ );
285
+ continue;
286
+ }
287
+
288
+ // Recurse to resolve nested imports
289
+ const nestedChain = new Set(ancestorChain);
290
+ nestedChain.add(importAbsPath);
291
+ const resolved = await resolveFile(
292
+ importedContent,
293
+ importAbsPath,
294
+ readFileFn,
295
+ diagnostics,
296
+ nestedChain,
297
+ depth + 1,
298
+ );
299
+
300
+ // Strip header, extract tag groups from resolved content
301
+ const resolvedLines = resolved.split('\n');
302
+ const parsed = parseFileHeader(resolvedLines);
303
+
304
+ // Collect tag groups from imported file (lowest priority)
305
+ for (const group of parsed.tagGroups) {
306
+ importedTagGroups.push(group);
307
+ }
308
+
309
+ // Re-indent and insert content lines
310
+ const importedContentLines = parsed.contentLines.filter(
311
+ (l) => l.trim() !== '' // skip trailing blank lines
312
+ );
313
+
314
+ // Trim trailing empty lines but keep internal structure
315
+ let lastNonEmpty = importedContentLines.length - 1;
316
+ while (lastNonEmpty >= 0 && importedContentLines[lastNonEmpty].trim() === '') {
317
+ lastNonEmpty--;
318
+ }
319
+ const trimmedImported = importedContentLines.slice(0, lastNonEmpty + 1);
320
+
321
+ for (const importedLine of trimmedImported) {
322
+ if (importedLine.trim() === '') {
323
+ resolvedBodyLines.push('');
324
+ } else {
325
+ resolvedBodyLines.push(indent + importedLine);
326
+ }
327
+ }
328
+ }
329
+
330
+ // ---- Step 4: Merge tag groups with precedence ----
331
+ // Priority: inline > tags file > imported files
332
+ const mergedGroups = mergeTagGroups(inlineTagGroups, tagsFileGroups, importedTagGroups);
333
+
334
+ // ---- Step 5: Rebuild output ----
335
+ const outputLines: string[] = [];
336
+
337
+ // Header lines (chart:, title:, options — no tags: or tag groups)
338
+ for (const line of headerLines) {
339
+ outputLines.push(line);
340
+ }
341
+
342
+ // Merged tag groups
343
+ if (mergedGroups.length > 0) {
344
+ // Ensure blank line before tag groups if header has content
345
+ if (outputLines.length > 0 && outputLines[outputLines.length - 1].trim() !== '') {
346
+ outputLines.push('');
347
+ }
348
+ for (const group of mergedGroups) {
349
+ for (const line of group.lines) {
350
+ outputLines.push(line);
351
+ }
352
+ outputLines.push(''); // blank line between groups
353
+ }
354
+ }
355
+
356
+ // Body content
357
+ // Ensure blank line separator
358
+ if (resolvedBodyLines.length > 0 && outputLines.length > 0 && outputLines[outputLines.length - 1].trim() !== '') {
359
+ outputLines.push('');
360
+ }
361
+ for (const line of resolvedBodyLines) {
362
+ outputLines.push(line);
363
+ }
364
+
365
+ return outputLines.join('\n');
366
+ }
367
+
368
+ // ============================================================
369
+ // Helpers
370
+ // ============================================================
371
+
372
+ /**
373
+ * Find the index where the body (non-header, non-tag-group content) starts.
374
+ */
375
+ function findBodyStart(lines: string[]): number {
376
+ let inTagGroup = false;
377
+
378
+ for (let i = 0; i < lines.length; i++) {
379
+ const trimmed = lines[i].trim();
380
+
381
+ if (trimmed === '' || trimmed.startsWith('//')) {
382
+ if (inTagGroup) inTagGroup = false;
383
+ continue;
384
+ }
385
+
386
+ // Tag group heading
387
+ if (GROUP_HEADING_RE.test(trimmed)) {
388
+ inTagGroup = true;
389
+ continue;
390
+ }
391
+
392
+ // Tag group entry (indented under heading)
393
+ if (inTagGroup && lines[i].match(/^\s+/)) {
394
+ continue;
395
+ }
396
+
397
+ if (inTagGroup) {
398
+ inTagGroup = false;
399
+ }
400
+
401
+ // Header directives
402
+ if (HEADER_RE.test(trimmed)) continue;
403
+ if (TAGS_RE.test(trimmed)) continue;
404
+
405
+ // Option-like lines (non-indented key: value before content)
406
+ if (OPTION_RE.test(trimmed) && !lines[i].match(/^\s/) && !trimmed.includes('|')) {
407
+ const key = trimmed.split(':')[0].trim().toLowerCase();
408
+ if (key !== 'chart' && key !== 'title') {
409
+ continue;
410
+ }
411
+ }
412
+
413
+ // This is the first body line
414
+ return i;
415
+ }
416
+
417
+ return lines.length;
418
+ }
419
+
420
+ /**
421
+ * Check if a line is a tag group entry (indented line under a ## heading).
422
+ */
423
+ function isTagGroupEntry(line: string, allLines: string[], index: number): boolean {
424
+ if (!line.match(/^\s+/)) return false;
425
+ // Walk backwards to find the nearest non-blank, non-comment, non-entry line
426
+ for (let i = index - 1; i >= 0; i--) {
427
+ const prev = allLines[i].trim();
428
+ if (prev === '' || prev.startsWith('//')) continue;
429
+ if (GROUP_HEADING_RE.test(prev)) return true;
430
+ if (allLines[i].match(/^\s+/)) continue; // another entry
431
+ return false;
432
+ }
433
+ return false;
434
+ }
435
+
436
+ /**
437
+ * Merge tag groups from three sources with priority:
438
+ * inline (highest) > tags file > imported files (lowest).
439
+ *
440
+ * On name conflict (case-insensitive), higher priority wins.
441
+ * New groups from lower priority are added.
442
+ */
443
+ function mergeTagGroups(
444
+ inline: TagGroupBlock[],
445
+ tagsFile: TagGroupBlock[],
446
+ imported: TagGroupBlock[],
447
+ ): TagGroupBlock[] {
448
+ const seen = new Map<string, TagGroupBlock>();
449
+
450
+ // Inline first (highest priority)
451
+ for (const group of inline) {
452
+ seen.set(group.name, group);
453
+ }
454
+
455
+ // Tags file (medium priority — only add if not overridden)
456
+ for (const group of tagsFile) {
457
+ if (!seen.has(group.name)) {
458
+ seen.set(group.name, group);
459
+ }
460
+ }
461
+
462
+ // Imported files (lowest priority — only add if not present)
463
+ for (const group of imported) {
464
+ if (!seen.has(group.name)) {
465
+ seen.set(group.name, group);
466
+ }
467
+ }
468
+
469
+ return Array.from(seen.values());
470
+ }
@@ -3,6 +3,8 @@
3
3
  // ============================================================
4
4
 
5
5
  import { inferParticipantType } from './participant-inference';
6
+ import type { DgmoError } from '../diagnostics';
7
+ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
6
8
 
7
9
  /**
8
10
  * Participant types that can be declared via "Name is a type" syntax.
@@ -140,6 +142,7 @@ export interface ParsedSequenceDgmo {
140
142
  groups: SequenceGroup[];
141
143
  sections: SequenceSection[];
142
144
  options: Record<string, string>;
145
+ diagnostics: DgmoError[];
143
146
  error: string | null;
144
147
  }
145
148
 
@@ -235,12 +238,26 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
235
238
  groups: [],
236
239
  sections: [],
237
240
  options: {},
241
+ diagnostics: [],
238
242
  error: null,
239
243
  };
240
244
 
241
- if (!content || !content.trim()) {
242
- result.error = 'Empty content';
245
+ const fail = (line: number, message: string): ParsedSequenceDgmo => {
246
+ const diag = makeDgmoError(line, message);
247
+ result.diagnostics.push(diag);
248
+ result.error = formatDgmoError(diag);
243
249
  return result;
250
+ };
251
+
252
+ /** Push a recoverable error and continue parsing. */
253
+ const pushError = (line: number, message: string): void => {
254
+ const diag = makeDgmoError(line, message);
255
+ result.diagnostics.push(diag);
256
+ if (!result.error) result.error = formatDgmoError(diag);
257
+ };
258
+
259
+ if (!content || !content.trim()) {
260
+ return fail(0, 'Empty content');
244
261
  }
245
262
 
246
263
  const lines = content.split('\n');
@@ -286,8 +303,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
286
303
  if (groupMatch) {
287
304
  const groupColor = groupMatch[2]?.trim();
288
305
  if (groupColor && groupColor.startsWith('#')) {
289
- result.error = `Line ${lineNumber}: Use a named color instead of hex (e.g., blue, red, teal)`;
290
- return result;
306
+ pushError(lineNumber, 'Use a named color instead of hex (e.g., blue, red, teal)');
307
+ continue;
291
308
  }
292
309
  contentStarted = true;
293
310
  activeGroup = {
@@ -310,8 +327,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
310
327
 
311
328
  // Reject # as comment syntax (## is for group headings, handled above)
312
329
  if (trimmed.startsWith('#') && !trimmed.startsWith('##')) {
313
- result.error = `Line ${lineNumber}: Use // for comments. # is reserved for group headings (##)`;
314
- return result;
330
+ pushError(lineNumber, 'Use // for comments. # is reserved for group headings (##)');
331
+ continue;
315
332
  }
316
333
 
317
334
  // Parse section dividers — "== Label ==" or "== Label(color) =="
@@ -327,8 +344,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
327
344
  const labelRaw = sectionMatch[1].trim();
328
345
  const colorMatch = labelRaw.match(/^(.+?)\(([^)]+)\)$/);
329
346
  if (colorMatch && colorMatch[2].trim().startsWith('#')) {
330
- result.error = `Line ${lineNumber}: Use a named color instead of hex (e.g., blue, red, teal)`;
331
- return result;
347
+ pushError(lineNumber, 'Use a named color instead of hex (e.g., blue, red, teal)');
348
+ continue;
332
349
  }
333
350
  contentStarted = true;
334
351
  const section: SequenceSection = {
@@ -355,16 +372,15 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
355
372
  if (key === 'chart') {
356
373
  hasExplicitChart = true;
357
374
  if (value.toLowerCase() !== 'sequence') {
358
- result.error = `Expected chart type "sequence", got "${value}"`;
359
- return result;
375
+ return fail(lineNumber, `Expected chart type "sequence", got "${value}"`);
360
376
  }
361
377
  continue;
362
378
  }
363
379
 
364
380
  // Enforce headers-before-content
365
381
  if (contentStarted) {
366
- result.error = `Line ${lineNumber}: Options like '${key}: ${value}' must appear before the first message or declaration`;
367
- return result;
382
+ pushError(lineNumber, `Options like '${key}: ${value}' must appear before the first message or declaration`);
383
+ continue;
368
384
  }
369
385
 
370
386
  if (key === 'title') {
@@ -415,11 +431,11 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
415
431
  if (activeGroup && !activeGroup.participantIds.includes(id)) {
416
432
  const existingGroup = participantGroupMap.get(id);
417
433
  if (existingGroup) {
418
- result.error = `Line ${lineNumber}: Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`;
419
- return result;
434
+ pushError(lineNumber, `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`);
435
+ } else {
436
+ activeGroup.participantIds.push(id);
437
+ participantGroupMap.set(id, activeGroup.name);
420
438
  }
421
- activeGroup.participantIds.push(id);
422
- participantGroupMap.set(id, activeGroup.name);
423
439
  }
424
440
  continue;
425
441
  }
@@ -444,11 +460,11 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
444
460
  if (activeGroup && !activeGroup.participantIds.includes(id)) {
445
461
  const existingGroup = participantGroupMap.get(id);
446
462
  if (existingGroup) {
447
- result.error = `Line ${lineNumber}: Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`;
448
- return result;
463
+ pushError(lineNumber, `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`);
464
+ } else {
465
+ activeGroup.participantIds.push(id);
466
+ participantGroupMap.set(id, activeGroup.name);
449
467
  }
450
- activeGroup.participantIds.push(id);
451
- participantGroupMap.set(id, activeGroup.name);
452
468
  }
453
469
  continue;
454
470
  }
@@ -468,11 +484,11 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
468
484
  if (!activeGroup.participantIds.includes(id)) {
469
485
  const existingGroup = participantGroupMap.get(id);
470
486
  if (existingGroup) {
471
- result.error = `Line ${lineNumber}: Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`;
472
- return result;
487
+ pushError(lineNumber, `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`);
488
+ } else {
489
+ activeGroup.participantIds.push(id);
490
+ participantGroupMap.set(id, activeGroup.name);
473
491
  }
474
- activeGroup.participantIds.push(id);
475
- participantGroupMap.set(id, activeGroup.name);
476
492
  }
477
493
  continue;
478
494
  }
@@ -499,8 +515,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
499
515
  // Reject "async" keyword prefix — use ~> instead
500
516
  const asyncPrefixMatch = trimmed.match(/^async\s+(.+)$/i);
501
517
  if (asyncPrefixMatch && ARROW_PATTERN.test(asyncPrefixMatch[1])) {
502
- result.error = `Line ${lineNumber}: Use ~> for async messages: A ~> B: message`;
503
- return result;
518
+ pushError(lineNumber, 'Use ~> for async messages: A ~> B: message');
519
+ continue;
504
520
  }
505
521
 
506
522
  // Match ~> (async arrow) or -> (sync arrow)
@@ -614,8 +630,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
614
630
  if (blockStack.length > 0 && blockStack[blockStack.length - 1].indent === indent) {
615
631
  const top = blockStack[blockStack.length - 1];
616
632
  if (top.block.type === 'parallel') {
617
- result.error = `Line ${lineNumber}: parallel blocks don't support else if — list all concurrent messages directly inside the block`;
618
- return result;
633
+ pushError(lineNumber, "parallel blocks don't support else if — list all concurrent messages directly inside the block");
634
+ continue;
619
635
  }
620
636
  if (top.block.type === 'if') {
621
637
  const branch: ElseIfBranch = { label: elseIfMatch[1].trim(), children: [] };
@@ -633,8 +649,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
633
649
  if (blockStack.length > 0 && blockStack[blockStack.length - 1].indent === indent) {
634
650
  const top = blockStack[blockStack.length - 1];
635
651
  if (top.block.type === 'parallel') {
636
- result.error = `Line ${lineNumber}: parallel blocks don't support else — list all concurrent messages directly inside the block`;
637
- return result;
652
+ pushError(lineNumber, "parallel blocks don't support else — list all concurrent messages directly inside the block");
653
+ continue;
638
654
  }
639
655
  if (top.block.type === 'if') {
640
656
  top.inElse = true;
@@ -712,9 +728,50 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
712
728
  // Check if raw content has arrow patterns for inference
713
729
  const hasArrows = lines.some((line) => ARROW_PATTERN.test(line.trim()));
714
730
  if (!hasArrows) {
715
- result.error =
716
- 'No "chart: sequence" header and no sequence content detected';
717
- return result;
731
+ return fail(1, 'No "chart: sequence" header and no sequence content detected');
732
+ }
733
+ }
734
+
735
+ const warn = (line: number, message: string): void => {
736
+ result.diagnostics.push(makeDgmoError(line, message, 'warning'));
737
+ };
738
+
739
+ // Warn about unused participants (only when the diagram has messages)
740
+ if (result.messages.length > 0) {
741
+ const usedIds = new Set<string>();
742
+ for (const msg of result.messages) {
743
+ usedIds.add(msg.from);
744
+ usedIds.add(msg.to);
745
+ }
746
+ // Walk elements recursively to find note participant references
747
+ const walkElements = (elements: SequenceElement[]): void => {
748
+ for (const el of elements) {
749
+ if (isSequenceNote(el)) {
750
+ usedIds.add(el.participantId);
751
+ } else if (isSequenceBlock(el)) {
752
+ walkElements(el.children);
753
+ walkElements(el.elseChildren);
754
+ if (el.elseIfBranches) {
755
+ for (const branch of el.elseIfBranches) {
756
+ walkElements(branch.children);
757
+ }
758
+ }
759
+ }
760
+ }
761
+ };
762
+ walkElements(result.elements);
763
+
764
+ for (const p of result.participants) {
765
+ if (!usedIds.has(p.id)) {
766
+ warn(p.lineNumber, `Participant "${p.label}" is declared but never used in any message or note`);
767
+ }
768
+ }
769
+ }
770
+
771
+ // Warn about empty groups
772
+ for (const group of result.groups) {
773
+ if (group.participantIds.length === 0) {
774
+ warn(group.lineNumber, `Group "${group.name}" has no participants`);
718
775
  }
719
776
  }
720
777