@diagrammo/dgmo 0.31.0 → 0.32.1

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.
Files changed (72) hide show
  1. package/.cursorrules +4 -1
  2. package/.github/copilot-instructions.md +4 -1
  3. package/.windsurfrules +4 -1
  4. package/SKILL.md +4 -1
  5. package/dist/advanced.cjs +1297 -358
  6. package/dist/advanced.d.cts +117 -15
  7. package/dist/advanced.d.ts +117 -15
  8. package/dist/advanced.js +1291 -358
  9. package/dist/auto.cjs +1087 -316
  10. package/dist/auto.js +98 -98
  11. package/dist/auto.mjs +1087 -316
  12. package/dist/cli.cjs +140 -140
  13. package/dist/index.cjs +1090 -397
  14. package/dist/index.js +1090 -397
  15. package/docs/ai-integration.md +4 -1
  16. package/docs/language-reference.md +282 -27
  17. package/gallery/fixtures/boxes-and-lines.dgmo +2 -2
  18. package/gallery/fixtures/c4-full.dgmo +4 -5
  19. package/gallery/fixtures/c4.dgmo +2 -3
  20. package/package.json +7 -1
  21. package/src/advanced.ts +7 -0
  22. package/src/boxes-and-lines/focus.ts +257 -0
  23. package/src/boxes-and-lines/layout-search.ts +131 -65
  24. package/src/boxes-and-lines/layout.ts +7 -1
  25. package/src/boxes-and-lines/parser.ts +19 -4
  26. package/src/boxes-and-lines/renderer.ts +54 -3
  27. package/src/c4/parser.ts +8 -7
  28. package/src/chart-type-registry.ts +129 -4
  29. package/src/chart-types.ts +4 -4
  30. package/src/chart.ts +18 -1
  31. package/src/colors.ts +225 -2
  32. package/src/cycle/parser.ts +2 -7
  33. package/src/d3.ts +67 -54
  34. package/src/diagnostics.ts +17 -0
  35. package/src/dimensions.ts +9 -13
  36. package/src/echarts.ts +42 -14
  37. package/src/er/parser.ts +6 -1
  38. package/src/gantt/parser.ts +44 -7
  39. package/src/graph/flowchart-parser.ts +77 -3
  40. package/src/graph/state-renderer.ts +2 -2
  41. package/src/infra/parser.ts +80 -0
  42. package/src/journey-map/parser.ts +8 -7
  43. package/src/kanban/parser.ts +8 -7
  44. package/src/map/context-labels.ts +134 -27
  45. package/src/map/geo.ts +10 -2
  46. package/src/map/layout.ts +259 -4
  47. package/src/map/parser.ts +2 -0
  48. package/src/map/renderer.ts +22 -11
  49. package/src/map/resolver.ts +68 -19
  50. package/src/mindmap/parser.ts +15 -7
  51. package/src/mindmap/renderer.ts +50 -12
  52. package/src/org/parser.ts +8 -7
  53. package/src/org/renderer.ts +22 -7
  54. package/src/palettes/color-utils.ts +12 -2
  55. package/src/palettes/index.ts +1 -0
  56. package/src/pert/renderer.ts +2 -2
  57. package/src/pyramid/parser.ts +2 -7
  58. package/src/quadrant/renderer.ts +2 -2
  59. package/src/raci/parser.ts +2 -7
  60. package/src/raci/renderer.ts +4 -4
  61. package/src/ring/parser.ts +2 -7
  62. package/src/sequence/parser.ts +18 -7
  63. package/src/sequence/renderer.ts +4 -4
  64. package/src/sitemap/parser.ts +8 -7
  65. package/src/sitemap/renderer.ts +2 -2
  66. package/src/tech-radar/parser.ts +2 -7
  67. package/src/timeline/renderer.ts +15 -5
  68. package/src/utils/parsing.ts +13 -1
  69. package/src/utils/scaling.ts +38 -81
  70. package/src/utils/tag-groups.ts +38 -0
  71. package/src/visualizations/parse.ts +6 -1
  72. package/src/wireframe/parser.ts +6 -1
package/src/er/parser.ts CHANGED
@@ -377,7 +377,12 @@ export function parseERDiagram(
377
377
  // Tag group entries (indented under tag heading)
378
378
  if (currentTagGroup && !contentStarted && indent > 0) {
379
379
  const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
380
- const { label, color } = extractColor(cleanEntry, palette);
380
+ const { label, color } = extractColor(
381
+ cleanEntry,
382
+ palette,
383
+ result.diagnostics,
384
+ lineNumber
385
+ );
381
386
  // Bare value (no explicit color) → keep it; finalized below.
382
387
  if (isDefault) {
383
388
  currentTagGroup.defaultValue = label;
@@ -200,6 +200,8 @@ export function parseGantt(
200
200
  syntaxMode: 'new', // always new-mode at 1.0
201
201
  };
202
202
 
203
+ // Bespoke (not the shared makeFail, Story 111.4): gantt accumulates into a
204
+ // local `diagnostics` array, not `result.diagnostics`.
203
205
  const fail = (line: number, message: string): ParsedGantt => {
204
206
  const diag = makeDgmoError(line, message);
205
207
  diagnostics.push(diag);
@@ -396,7 +398,12 @@ export function parseGantt(
396
398
  if (eraEntryMatch) {
397
399
  // Capture groups 1-3 guaranteed by successful regex match.
398
400
  const eraLabelRaw = eraEntryMatch[3]!.trim();
399
- const eraExtracted = extractColor(eraLabelRaw, palette);
401
+ const eraExtracted = extractColor(
402
+ eraLabelRaw,
403
+ palette,
404
+ diagnostics,
405
+ lineNumber
406
+ );
400
407
  result.eras.push({
401
408
  startDate: eraEntryMatch[1]!,
402
409
  endDate: eraEntryMatch[2]!,
@@ -423,7 +430,12 @@ export function parseGantt(
423
430
  if (markerEntryMatch) {
424
431
  // Capture groups 1-2 guaranteed by successful regex match.
425
432
  const markerLabelRaw = markerEntryMatch[2]!.trim();
426
- const markerExtracted = extractColor(markerLabelRaw, palette);
433
+ const markerExtracted = extractColor(
434
+ markerLabelRaw,
435
+ palette,
436
+ diagnostics,
437
+ lineNumber
438
+ );
427
439
  result.markers.push({
428
440
  date: markerEntryMatch[1]!,
429
441
  label: markerExtracted.label,
@@ -451,7 +463,12 @@ export function parseGantt(
451
463
  // First entry is the default unless another is marked `default`
452
464
  if (COMMENT_RE.test(line)) continue;
453
465
  const { text: cleanEntry, isDefault } = stripDefaultModifier(line);
454
- const extracted = extractColor(cleanEntry, palette);
466
+ const extracted = extractColor(
467
+ cleanEntry,
468
+ palette,
469
+ diagnostics,
470
+ lineNumber
471
+ );
455
472
  const color =
456
473
  extracted.color ||
457
474
  seriesColors[currentTagGroup.entries.length % seriesColors.length] ||
@@ -710,7 +727,12 @@ export function parseGantt(
710
727
  const startOff = parseOffsetPrefix('+' + eraOffsetMatch[1]!);
711
728
  const endOff = parseOffsetPrefix('+' + eraOffsetMatch[2]!);
712
729
  const eraLabelRaw = eraOffsetMatch[3]!.trim();
713
- const eraExtracted = extractColor(eraLabelRaw, palette);
730
+ const eraExtracted = extractColor(
731
+ eraLabelRaw,
732
+ palette,
733
+ diagnostics,
734
+ lineNumber
735
+ );
714
736
  result.eras.push({
715
737
  startDate: '',
716
738
  endDate: '',
@@ -728,7 +750,12 @@ export function parseGantt(
728
750
  if (markerOffsetMatch) {
729
751
  const dateOff = parseOffsetPrefix('+' + markerOffsetMatch[1]!);
730
752
  const markerLabelRaw = markerOffsetMatch[2]!.trim();
731
- const markerExtracted = extractColor(markerLabelRaw, palette);
753
+ const markerExtracted = extractColor(
754
+ markerLabelRaw,
755
+ palette,
756
+ diagnostics,
757
+ lineNumber
758
+ );
732
759
  result.markers.push({
733
760
  date: '',
734
761
  label: markerExtracted.label,
@@ -825,7 +852,12 @@ export function parseGantt(
825
852
  if (eraMatch) {
826
853
  // Capture groups 1-3 guaranteed by successful regex match.
827
854
  const eraLabelRaw = eraMatch[3]!.trim();
828
- const eraExtracted = extractColor(eraLabelRaw, palette);
855
+ const eraExtracted = extractColor(
856
+ eraLabelRaw,
857
+ palette,
858
+ diagnostics,
859
+ lineNumber
860
+ );
829
861
  result.eras.push({
830
862
  startDate: eraMatch[1]!,
831
863
  endDate: eraMatch[2]!,
@@ -848,7 +880,12 @@ export function parseGantt(
848
880
  if (markerMatch) {
849
881
  // Capture groups 1-2 guaranteed by successful regex match.
850
882
  const markerLabelRaw = markerMatch[2]!.trim();
851
- const markerExtracted = extractColor(markerLabelRaw, palette);
883
+ const markerExtracted = extractColor(
884
+ markerLabelRaw,
885
+ palette,
886
+ diagnostics,
887
+ lineNumber
888
+ );
852
889
  result.markers.push({
853
890
  date: markerMatch[1]!,
854
891
  label: markerExtracted.label,
@@ -100,6 +100,29 @@ function parseNodeRef(text: string): NodeRef | null {
100
100
  return null;
101
101
  }
102
102
 
103
+ /**
104
+ * Match a leading shape token plus any trailing text. Used to SALVAGE a node
105
+ * that `parseNodeRef` rejects because of trailing junk — most commonly an
106
+ * unsupported tag/metadata suffix the AI emits (`(Denied) s: Denied`).
107
+ * Flowcharts have no metadata/tag-group system (color a node inline instead:
108
+ * `(Denied red)`), so rather than silently drop the node AND its edge we keep
109
+ * the node, strip the suffix, and let the caller warn. The shape regexes are
110
+ * non-greedy so the FIRST balanced delimiter wins.
111
+ */
112
+ function parseNodeRefLoose(
113
+ text: string
114
+ ): { ref: NodeRef; trailing: string } | null {
115
+ const t = text.trim();
116
+ // Order mirrors parseNodeRef: subroutine/document before process.
117
+ const shapeRe =
118
+ /^(\[\[.+?\]\]|\[.+?~\]|\[.+?\]|\(.+?\)|<.+?>|\/.+?\/)\s+(\S.*)$/;
119
+ const m = t.match(shapeRe);
120
+ if (!m) return null;
121
+ const ref = parseNodeRef(m[1]!);
122
+ if (!ref) return null;
123
+ return { ref, trailing: m[2]!.trim() };
124
+ }
125
+
103
126
  /**
104
127
  * Split a line into segments around arrow tokens.
105
128
  * Arrows: `->`, `-label->`, and long-dash variants like `-->`, `--->`,
@@ -270,6 +293,11 @@ export function parseFlowchart(
270
293
  const notes: GraphNote[] = [];
271
294
  let contentStarted = false;
272
295
  let firstLineParsed = false;
296
+ // The last node id seen on the PREVIOUS content line — the implicit source
297
+ // for a line that begins with a bare arrow (`(Start)` then `-> Next` on its
298
+ // own line). Without this, the same-indent pop empties the indent stack and
299
+ // the leading-arrow line is dropped, orphaning the prior node.
300
+ let prevLineLastNodeId: string | null = null;
273
301
 
274
302
  // Per-parse alias literal → canonical node id (TD-18). Per C8:
275
303
  // never persisted, fresh each parse.
@@ -287,6 +315,22 @@ export function parseFlowchart(
287
315
  return { seg: m[1]!.trim(), alias: m[2]! };
288
316
  }
289
317
 
318
+ // Lines we've already warned about an unsupported node suffix on, so a chain
319
+ // with several salvaged nodes doesn't emit duplicate warnings per line.
320
+ const suffixWarnedLines = new Set<number>();
321
+ function warnUnsupportedSuffix(lineNumber: number, trailing: string): void {
322
+ if (suffixWarnedLines.has(lineNumber)) return;
323
+ suffixWarnedLines.add(lineNumber);
324
+ result.diagnostics.push(
325
+ makeDgmoError(
326
+ lineNumber,
327
+ `Ignored unsupported text after a node shape: "${trailing}". Flowcharts have no tag groups or node metadata; node colors are assigned automatically by shape (e.g. terminals green/red, decisions yellow). Remove the suffix.`,
328
+ 'warning',
329
+ 'W_FLOWCHART_NODE_SUFFIX'
330
+ )
331
+ );
332
+ }
333
+
290
334
  function getOrCreateNode(ref: NodeRef, lineNumber: number): GraphNode {
291
335
  const key = ref.id;
292
336
  const existing = nodeMap.get(key);
@@ -371,6 +415,13 @@ export function parseFlowchart(
371
415
  // Split line into segments around arrows
372
416
  const segments = splitArrows(trimmed);
373
417
 
418
+ // A line that begins with a bare arrow (first segment empty) is a
419
+ // continuation of the previous line's chain — its source is the last node
420
+ // on the previous content line when the indent stack offers nothing.
421
+ const startsWithArrow = segments.length >= 2 && segments[0]!.trim() === '';
422
+ const effectiveSource =
423
+ implicitSourceId ?? (startsWithArrow ? prevLineLastNodeId : null);
424
+
374
425
  if (segments.length === 1) {
375
426
  // Single node reference, no arrows. May carry an `as <alias>`
376
427
  // postfix per TD-18.
@@ -383,6 +434,15 @@ export function parseFlowchart(
383
434
  indentStack.push({ nodeId: node.id, indent });
384
435
  return node.id;
385
436
  }
437
+ // Salvage a shape with an unsupported trailing suffix (e.g. a tag/
438
+ // metadata assignment `(Denied) s: Denied`) — keep the node, warn.
439
+ const loose = parseNodeRefLoose(peeled.seg);
440
+ if (loose) {
441
+ warnUnsupportedSuffix(lineNumber, loose.trailing);
442
+ const node = getOrCreateNode(loose.ref, lineNumber);
443
+ indentStack.push({ nodeId: node.id, indent });
444
+ return node.id;
445
+ }
386
446
  // Bare-token alias reference: `os` (no brackets) on its own line
387
447
  // resolves to the canonical id and pushes onto the indent stack.
388
448
  const aliasResolved = nameAliasMap.get(peeled.seg.trim());
@@ -431,18 +491,28 @@ export function parseFlowchart(
431
491
  }
432
492
  }
433
493
  }
494
+ if (!ref) {
495
+ // Salvage a shape with an unsupported trailing suffix (most often a
496
+ // tag/metadata assignment like `(Denied) s: Denied`) so the node AND
497
+ // its edge survive instead of being silently dropped.
498
+ const loose = parseNodeRefLoose(peeled.seg);
499
+ if (loose) {
500
+ warnUnsupportedSuffix(lineNumber, loose.trailing);
501
+ ref = loose.ref;
502
+ }
503
+ }
434
504
  if (!ref) continue;
435
505
 
436
506
  const node = getOrCreateNode(ref, lineNumber);
437
507
  if (peeled.alias) nameAliasMap.set(peeled.alias, node.id);
438
508
 
439
509
  if (pendingArrow !== null) {
440
- const sourceId = lastNodeId ?? implicitSourceId;
510
+ const sourceId = lastNodeId ?? effectiveSource;
441
511
  if (sourceId) {
442
512
  addEdge(sourceId, node.id, lineNumber, pendingArrow.label);
443
513
  }
444
514
  pendingArrow = null;
445
- } else if (lastNodeId === null && implicitSourceId === null) {
515
+ } else if (lastNodeId === null && effectiveSource === null) {
446
516
  // First node in chain, no arrow yet — just register
447
517
  }
448
518
 
@@ -571,7 +641,11 @@ export function parseFlowchart(
571
641
  }
572
642
 
573
643
  // Content line (nodes and edges)
574
- processContentLine(trimmed, lineNumber, indent);
644
+ const lastId = processContentLine(trimmed, lineNumber, indent);
645
+ // Remember this line's tail so a following bare-arrow line can attach to
646
+ // it (leading-arrow continuation). Keep the prior value when a line
647
+ // produces no node so blank/unparseable lines don't break the chain.
648
+ if (lastId) prevLineLastNodeId = lastId;
575
649
  }
576
650
 
577
651
  // Validation: no nodes found
@@ -7,7 +7,7 @@ import { appendArrowheadMarkers } from '../utils/arrow-markers';
7
7
  import { fitDiagramToCanvas } from '../utils/fit-canvas';
8
8
  import { FONT_FAMILY } from '../fonts';
9
9
  import type { PaletteColors } from '../palettes';
10
- import { contrastText, mix, shapeFill } from '../palettes/color-utils';
10
+ import { contrastText, mix, shapeFill, themeBaseBg } from '../palettes/color-utils';
11
11
  import type { ParsedGraph } from './types';
12
12
  import type { LayoutResult, LayoutNode } from './layout';
13
13
  import { parseState } from './state-parser';
@@ -211,7 +211,7 @@ export function renderState(
211
211
  const gh = group.height + sGroupExtraPadding * 2 + sGroupLabelFontSize + 4;
212
212
 
213
213
  const fillColor = group.color
214
- ? mix(group.color, isDark ? palette.surface : palette.bg, 10)
214
+ ? mix(group.color, themeBaseBg(palette, isDark), 10)
215
215
  : isDark
216
216
  ? palette.surface
217
217
  : mix(palette.border, palette.bg, 30);
@@ -1066,9 +1066,89 @@ export function parseInfra(content: string): ParsedInfra {
1066
1066
 
1067
1067
  validateTagGroupNames(result.tagGroups, warn, setError);
1068
1068
 
1069
+ checkReachability(result);
1070
+
1069
1071
  return result;
1070
1072
  }
1071
1073
 
1074
+ /**
1075
+ * Reachability lint: an infra diagram traces request traffic flowing inward
1076
+ * from an `internet`/`edge` entry, so every node must have a directed path
1077
+ * back to an entry. A node nothing routes to carries no traffic — it's dead
1078
+ * and doesn't belong. Emits `warning`-severity diagnostics (never blocks
1079
+ * rendering). Mirrors the compute model: an edge targeting a `[Group]`
1080
+ * delivers to all of that group's children.
1081
+ */
1082
+ function checkReachability(result: Writable<ParsedInfra>): void {
1083
+ if (result.nodes.length === 0) return;
1084
+
1085
+ const entries = result.nodes.filter((n) => n.isEdge);
1086
+
1087
+ // No entry at all: the rule can't be satisfied. One diagnostic, not N.
1088
+ if (entries.length === 0) {
1089
+ const line = result.titleLineNumber ?? result.nodes[0]?.lineNumber ?? 1;
1090
+ result.diagnostics.push(
1091
+ makeDgmoError(
1092
+ line,
1093
+ `Infra diagram has no 'internet' or 'edge' entry point — an infra diagram traces request traffic from an entry inward, so without one nothing carries traffic. Add an 'internet' or 'edge' node and route from it.`,
1094
+ 'warning',
1095
+ 'W_INFRA_NO_ENTRY'
1096
+ )
1097
+ );
1098
+ return;
1099
+ }
1100
+
1101
+ // groupId -> child node ids (for expanding edges that target a [Group]).
1102
+ const groupChildMap = new Map<string, string[]>();
1103
+ for (const node of result.nodes) {
1104
+ if (node.groupId) {
1105
+ const list = groupChildMap.get(node.groupId) ?? [];
1106
+ list.push(node.id);
1107
+ // node.groupId is already a normalized id from parse time.
1108
+ // eslint-disable-next-line name-normalize/required-at-insertion
1109
+ groupChildMap.set(node.groupId, list);
1110
+ }
1111
+ }
1112
+
1113
+ // Outbound adjacency: an edge to a group reaches every child of that group.
1114
+ const outbound = new Map<string, string[]>();
1115
+ for (const edge of result.edges) {
1116
+ const targets = groupChildMap.get(edge.targetId) ?? [edge.targetId];
1117
+ const list = outbound.get(edge.sourceId) ?? [];
1118
+ list.push(...targets);
1119
+ outbound.set(edge.sourceId, list);
1120
+ }
1121
+
1122
+ // BFS from every entry node.
1123
+ const reachable = new Set<string>();
1124
+ const queue: string[] = [];
1125
+ for (const entry of entries) {
1126
+ reachable.add(entry.id);
1127
+ queue.push(entry.id);
1128
+ }
1129
+ while (queue.length > 0) {
1130
+ const current = queue.shift()!;
1131
+ for (const next of outbound.get(current) ?? []) {
1132
+ if (!reachable.has(next)) {
1133
+ reachable.add(next);
1134
+ queue.push(next);
1135
+ }
1136
+ }
1137
+ }
1138
+
1139
+ for (const node of result.nodes) {
1140
+ if (node.isEdge || reachable.has(node.id)) continue;
1141
+ result.diagnostics.push(
1142
+ makeDgmoError(
1143
+ node.lineNumber,
1144
+ `'${node.label}' is unreachable from an 'internet'/'edge' entry — no request traffic flows to it, so it's dead on an infra diagram. Connect it downstream of an entry, or remove it.`,
1145
+ 'warning',
1146
+ 'W_INFRA_UNREACHABLE'
1147
+ )
1148
+ );
1149
+ }
1150
+ }
1151
+
1072
1152
  // ============================================================
1073
1153
  // Symbol extraction (for completion API)
1074
1154
  // ============================================================
@@ -5,6 +5,7 @@ import {
5
5
  formatDgmoError,
6
6
  journeyBareScoreRemovedMessage,
7
7
  makeDgmoError,
8
+ makeFail,
8
9
  METADATA_DIAGNOSTIC_CODES,
9
10
  pipeOperatorRemovedMessage,
10
11
  suggest,
@@ -75,12 +76,7 @@ export function parseJourneyMap(
75
76
  error: null,
76
77
  };
77
78
 
78
- const fail = (line: number, message: string): ParsedJourneyMap => {
79
- const diag = makeDgmoError(line, message);
80
- result.diagnostics.push(diag);
81
- result.error = formatDgmoError(diag);
82
- return result;
83
- };
79
+ const fail = makeFail(result);
84
80
 
85
81
  const warn = (line: number, message: string): void => {
86
82
  result.diagnostics.push(makeDgmoError(line, message, 'warning'));
@@ -266,7 +262,12 @@ export function parseJourneyMap(
266
262
  if (currentTagGroup && !contentStarted) {
267
263
  if (indent > 0) {
268
264
  const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
269
- const { label, color } = extractColor(cleanEntry, palette);
265
+ const { label, color } = extractColor(
266
+ cleanEntry,
267
+ palette,
268
+ result.diagnostics,
269
+ lineNumber
270
+ );
270
271
  // Bare value (no explicit color) → keep it; finalized below.
271
272
  if (isDefault) {
272
273
  currentTagGroup.defaultValue = label;
@@ -2,6 +2,7 @@ import type { PaletteColors } from '../palettes';
2
2
  import {
3
3
  formatDgmoError,
4
4
  makeDgmoError,
5
+ makeFail,
5
6
  METADATA_DIAGNOSTIC_CODES,
6
7
  pipeOperatorRemovedMessage,
7
8
  suggest,
@@ -77,12 +78,7 @@ export function parseKanban(
77
78
  error: null,
78
79
  };
79
80
 
80
- const fail = (line: number, message: string): ParsedKanban => {
81
- const diag = makeDgmoError(line, message);
82
- result.diagnostics.push(diag);
83
- result.error = formatDgmoError(diag);
84
- return result;
85
- };
81
+ const fail = makeFail(result);
86
82
 
87
83
  const warn = (line: number, message: string): void => {
88
84
  result.diagnostics.push(makeDgmoError(line, message, 'warning'));
@@ -218,7 +214,12 @@ export function parseKanban(
218
214
  const indent = measureIndent(line);
219
215
  if (indent > 0) {
220
216
  const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
221
- const { label, color } = extractColor(cleanEntry, palette);
217
+ const { label, color } = extractColor(
218
+ cleanEntry,
219
+ palette,
220
+ result.diagnostics,
221
+ lineNumber
222
+ );
222
223
  // Bare value (no explicit color) → keep it; finalized below.
223
224
  if (isDefault) {
224
225
  currentTagGroup.defaultValue = label;