@diagrammo/dgmo 0.6.2 → 0.7.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.
Files changed (61) hide show
  1. package/.claude/commands/dgmo.md +231 -13
  2. package/AGENTS.md +148 -0
  3. package/dist/cli.cjs +341 -165
  4. package/dist/index.cjs +4900 -1685
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +259 -18
  7. package/dist/index.d.ts +259 -18
  8. package/dist/index.js +4642 -1436
  9. package/dist/index.js.map +1 -1
  10. package/package.json +5 -3
  11. package/src/c4/layout.ts +0 -5
  12. package/src/c4/parser.ts +0 -16
  13. package/src/c4/renderer.ts +7 -11
  14. package/src/class/layout.ts +0 -1
  15. package/src/class/parser.ts +28 -0
  16. package/src/class/renderer.ts +189 -34
  17. package/src/cli.ts +566 -25
  18. package/src/colors.ts +3 -3
  19. package/src/completion.ts +58 -0
  20. package/src/d3.ts +179 -122
  21. package/src/dgmo-router.ts +3 -58
  22. package/src/echarts.ts +96 -55
  23. package/src/er/parser.ts +30 -1
  24. package/src/er/renderer.ts +12 -7
  25. package/src/gantt/calculator.ts +677 -0
  26. package/src/gantt/parser.ts +761 -0
  27. package/src/gantt/renderer.ts +2125 -0
  28. package/src/gantt/resolver.ts +144 -0
  29. package/src/gantt/types.ts +168 -0
  30. package/src/graph/flowchart-parser.ts +27 -4
  31. package/src/graph/flowchart-renderer.ts +1 -2
  32. package/src/graph/state-parser.ts +0 -1
  33. package/src/graph/state-renderer.ts +1 -3
  34. package/src/index.ts +37 -0
  35. package/src/infra/compute.ts +0 -7
  36. package/src/infra/layout.ts +0 -2
  37. package/src/infra/parser.ts +46 -4
  38. package/src/infra/renderer.ts +49 -27
  39. package/src/initiative-status/filter.ts +63 -0
  40. package/src/initiative-status/layout.ts +319 -67
  41. package/src/initiative-status/parser.ts +200 -25
  42. package/src/initiative-status/renderer.ts +298 -35
  43. package/src/initiative-status/types.ts +6 -0
  44. package/src/kanban/parser.ts +0 -2
  45. package/src/org/layout.ts +22 -59
  46. package/src/org/renderer.ts +11 -36
  47. package/src/palettes/dracula.ts +60 -0
  48. package/src/palettes/index.ts +8 -6
  49. package/src/palettes/monokai.ts +60 -0
  50. package/src/palettes/registry.ts +4 -2
  51. package/src/sequence/parser.ts +14 -11
  52. package/src/sequence/renderer.ts +5 -6
  53. package/src/sequence/tag-resolution.ts +0 -1
  54. package/src/sharing.ts +8 -0
  55. package/src/sitemap/layout.ts +1 -14
  56. package/src/sitemap/parser.ts +1 -2
  57. package/src/sitemap/renderer.ts +4 -7
  58. package/src/utils/arrows.ts +7 -7
  59. package/src/utils/duration.ts +212 -0
  60. package/src/utils/export-container.ts +40 -0
  61. package/src/utils/legend-constants.ts +1 -0
@@ -0,0 +1,761 @@
1
+ // ============================================================
2
+ // Gantt Chart Parser
3
+ // ============================================================
4
+
5
+ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
6
+ import type { DgmoError } from '../diagnostics';
7
+ import type { TagGroup, TagEntry } from '../utils/tag-groups';
8
+ import { matchTagBlockHeading } from '../utils/tag-groups';
9
+ import { measureIndent, extractColor, parsePipeMetadata } from '../utils/parsing';
10
+ import { parseOffset } from '../utils/duration';
11
+ import type { PaletteColors } from '../palettes';
12
+ import { resolveColor } from '../colors';
13
+ import { getSeriesColors } from '../palettes';
14
+ import type {
15
+ ParsedGantt,
16
+ GanttNode,
17
+ GanttTask,
18
+ GanttGroup,
19
+ GanttParallelBlock,
20
+ GanttDependency,
21
+ GanttHolidays,
22
+ GanttEra,
23
+ GanttMarker,
24
+ GanttOptions,
25
+ Duration,
26
+ DurationUnit,
27
+ Offset,
28
+ Weekday,
29
+ } from './types';
30
+
31
+ // ── Regexes ─────────────────────────────────────────────────
32
+
33
+ /** Duration task: `30d: Label`, `1.5w: Label`, `10bd?: Label` */
34
+ const DURATION_RE = /^(\d+(?:\.\d+)?)(d|bd|w|m|q|y)(\?)?:\s*(.+)$/;
35
+
36
+ /** Explicit date task: `2024-01-15: Label` */
37
+ const EXPLICIT_DATE_RE = /^(\d{4}-\d{2}-\d{2}):\s*(.+)$/;
38
+
39
+ /** Timeline migration syntax: `2024-01-15 -> 30d: Label` */
40
+ const TIMELINE_DURATION_RE = /^(\d{4}-\d{2}-\d{2})\s*->\s*(\d+(?:\.\d+)?)(d|bd|w|m|q|y)(\?)?:\s*(.+)$/;
41
+
42
+ /** Group container: `[GroupName]` with optional pipe metadata */
43
+ const GROUP_RE = /^\[(.+?)\]\s*(.*)$/;
44
+
45
+ /** Dependency: `-> TargetName` with optional pipe metadata */
46
+ const DEPENDENCY_RE = /^->\s*(.+)$/;
47
+
48
+ /** Comment line */
49
+ const COMMENT_RE = /^\/\//;
50
+
51
+ /** Era: `era YYYY[-MM[-DD]] -> YYYY[-MM[-DD]]: Label (color?)` */
52
+ const ERA_RE = /^era\s+(\d{4}(?:-\d{2}(?:-\d{2})?)?)\s*->\s*(\d{4}(?:-\d{2}(?:-\d{2})?)?)\s*:\s*(.+)$/i;
53
+
54
+ /** Marker: `marker YYYY[-MM[-DD]]: Label (color?)` */
55
+ const MARKER_RE = /^marker\s+(\d{4}(?:-\d{2}(?:-\d{2})?)?)\s*:\s*(.+)$/i;
56
+
57
+ /** Holiday date: `2024-01-15: Label` */
58
+ const HOLIDAY_DATE_RE = /^(\d{4}-\d{2}-\d{2}):\s*(.+)$/;
59
+
60
+ /** Holiday range: `2024-12-24 -> 2024-12-31: Label` */
61
+ const HOLIDAY_RANGE_RE = /^(\d{4}-\d{2}-\d{2})\s*->\s*(\d{4}-\d{2}-\d{2}):\s*(.+)$/;
62
+
63
+ /** Workweek override: `workweek: sun-thu` */
64
+ const WORKWEEK_RE = /^workweek:\s*(.+)$/i;
65
+
66
+ /** chart: gantt */
67
+ const CHART_TYPE_RE = /^chart\s*:\s*(.+)/i;
68
+
69
+ /** Option lines */
70
+ const OPTION_RE = /^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i;
71
+
72
+ // Valid weekday names
73
+ const WEEKDAY_MAP: Record<string, Weekday> = {
74
+ mon: 'mon', tue: 'tue', wed: 'wed', thu: 'thu', fri: 'fri', sat: 'sat', sun: 'sun',
75
+ monday: 'mon', tuesday: 'tue', wednesday: 'wed', thursday: 'thu', friday: 'fri', saturday: 'sat', sunday: 'sun',
76
+ };
77
+
78
+ // ── Block Stack ─────────────────────────────────────────────
79
+
80
+ type ContainerType = 'group' | 'parallel' | 'task';
81
+
82
+ interface BlockEntry {
83
+ node: GanttGroup | GanttParallelBlock;
84
+ indent: number;
85
+ containerType: ContainerType;
86
+ }
87
+
88
+ // ── Parser ──────────────────────────────────────────────────
89
+
90
+ export function parseGantt(content: string, palette?: PaletteColors): ParsedGantt {
91
+ const lines = content.split('\n');
92
+ const diagnostics: DgmoError[] = [];
93
+
94
+ const result: ParsedGantt = {
95
+ nodes: [],
96
+ holidays: { dates: [], ranges: [], workweek: ['mon', 'tue', 'wed', 'thu', 'fri'] },
97
+ tagGroups: [],
98
+ eras: [],
99
+ markers: [],
100
+ options: {
101
+ start: null,
102
+ title: null,
103
+ titleLineNumber: null,
104
+ orientation: 'horizontal',
105
+ todayMarker: 'off',
106
+ criticalPath: false,
107
+ dependencies: false,
108
+ sort: 'default',
109
+ defaultSwimlaneGroup: null,
110
+ },
111
+ diagnostics,
112
+ error: null,
113
+ };
114
+
115
+ const fail = (line: number, message: string): ParsedGantt => {
116
+ const diag = makeDgmoError(line, message);
117
+ diagnostics.push(diag);
118
+ result.error = formatDgmoError(diag);
119
+ return result;
120
+ };
121
+
122
+ const warn = (line: number, message: string): void => {
123
+ diagnostics.push(makeDgmoError(line, message, 'warning'));
124
+ };
125
+
126
+ // ── Alias map for pipe metadata ─────────────────────────
127
+
128
+ const aliasMap = new Map<string, string>();
129
+
130
+ // ── Block stack ─────────────────────────────────────────
131
+
132
+ const blockStack: BlockEntry[] = [];
133
+
134
+ const currentContainer = (): GanttNode[] => {
135
+ if (blockStack.length === 0) return result.nodes;
136
+ const top = blockStack[blockStack.length - 1];
137
+ return top.node.children;
138
+ };
139
+
140
+ const currentGroupPath = (): string[] => {
141
+ const path: string[] = [];
142
+ for (const entry of blockStack) {
143
+ if (entry.containerType === 'group') {
144
+ path.push((entry.node as GanttGroup).name);
145
+ }
146
+ }
147
+ return path;
148
+ };
149
+
150
+ // ── State ───────────────────────────────────────────────
151
+
152
+ let seenChartType = false;
153
+ let inHeaderBlock = true; // options must come before content
154
+ let inHolidaysBlock = false;
155
+ let holidaysBlockIndent = 0;
156
+ let inTagBlock = false;
157
+ let currentTagGroup: TagGroup | null = null;
158
+ let tagBlockIndent = 0;
159
+ let lastTaskNode: (GanttNode & { kind: 'task' }) | null = null;
160
+ let taskIdCounter = 0;
161
+ const seriesColors = palette ? getSeriesColors(palette) : [];
162
+
163
+ // ── Main Parse Loop ─────────────────────────────────────
164
+
165
+ for (let i = 0; i < lines.length; i++) {
166
+ const rawLine = lines[i];
167
+ const line = rawLine.trim();
168
+ const indent = measureIndent(rawLine);
169
+ const lineNumber = i + 1;
170
+
171
+ // Skip empty lines
172
+ if (!line) {
173
+ // Empty line ends holidays/tag blocks only if at root indent
174
+ if (inHolidaysBlock && indent <= holidaysBlockIndent) {
175
+ inHolidaysBlock = false;
176
+ }
177
+ if (inTagBlock && indent <= tagBlockIndent) {
178
+ inTagBlock = false;
179
+ if (currentTagGroup) {
180
+ result.tagGroups.push(currentTagGroup);
181
+ currentTagGroup = null;
182
+ }
183
+ }
184
+ continue;
185
+ }
186
+
187
+ // ── Chart type ────────────────────────────────────────
188
+
189
+ const chartTypeMatch = line.match(CHART_TYPE_RE);
190
+ if (chartTypeMatch) {
191
+ const type = chartTypeMatch[1].trim().toLowerCase();
192
+ if (type !== 'gantt') {
193
+ return fail(lineNumber, `Expected chart type "gantt", got "${type}"`);
194
+ }
195
+ seenChartType = true;
196
+ continue;
197
+ }
198
+
199
+ // ── Holidays block ────────────────────────────────────
200
+
201
+ if (inHolidaysBlock) {
202
+ if (indent <= holidaysBlockIndent) {
203
+ inHolidaysBlock = false;
204
+ // fall through to process this line normally
205
+ } else {
206
+ // Parse holiday entries
207
+ const rangeMatch = line.match(HOLIDAY_RANGE_RE);
208
+ if (rangeMatch) {
209
+ result.holidays.ranges.push({
210
+ startDate: rangeMatch[1],
211
+ endDate: rangeMatch[2],
212
+ label: rangeMatch[3].trim(),
213
+ lineNumber,
214
+ });
215
+ continue;
216
+ }
217
+
218
+ const dateMatch = line.match(HOLIDAY_DATE_RE);
219
+ if (dateMatch) {
220
+ result.holidays.dates.push({
221
+ date: dateMatch[1],
222
+ label: dateMatch[2].trim(),
223
+ lineNumber,
224
+ });
225
+ continue;
226
+ }
227
+
228
+ const workweekMatch = line.match(WORKWEEK_RE);
229
+ if (workweekMatch) {
230
+ const days = parseWorkweek(workweekMatch[1].trim());
231
+ if (days) {
232
+ result.holidays.workweek = days;
233
+ } else {
234
+ warn(lineNumber, `Invalid workweek format: "${workweekMatch[1]}". Use day range like "sun-thu" or comma-separated days.`);
235
+ }
236
+ continue;
237
+ }
238
+
239
+ // Skip comments inside holidays
240
+ if (COMMENT_RE.test(line)) continue;
241
+
242
+ warn(lineNumber, `Unrecognized holiday entry: "${line}"`);
243
+ continue;
244
+ }
245
+ }
246
+
247
+ // ── Tag block entries ─────────────────────────────────
248
+
249
+ if (inTagBlock && currentTagGroup) {
250
+ if (indent <= tagBlockIndent) {
251
+ // End of tag block
252
+ inTagBlock = false;
253
+ result.tagGroups.push(currentTagGroup);
254
+ currentTagGroup = null;
255
+ // fall through to process this line normally
256
+ } else {
257
+ // Parse tag entry: `Value(color)` or `Value` with optional `default` suffix
258
+ if (COMMENT_RE.test(line)) continue;
259
+ let entryLine = line;
260
+ let isDefault = false;
261
+ if (entryLine.endsWith(' default') || entryLine.endsWith('\tdefault')) {
262
+ isDefault = true;
263
+ entryLine = entryLine.replace(/\s+default$/, '').trim();
264
+ }
265
+ const extracted = extractColor(entryLine, palette);
266
+ const color = extracted.color || seriesColors[currentTagGroup.entries.length % seriesColors.length] || '#888888';
267
+ currentTagGroup.entries.push({
268
+ value: extracted.label,
269
+ color,
270
+ lineNumber,
271
+ });
272
+ if (isDefault) {
273
+ currentTagGroup.defaultValue = extracted.label;
274
+ }
275
+ continue;
276
+ }
277
+ }
278
+
279
+ // ── Close blocks when indent decreases ────────────────
280
+ // CRITICAL: close blocks BEFORE matching new elements
281
+
282
+ while (blockStack.length > 0) {
283
+ const top = blockStack[blockStack.length - 1];
284
+ if (indent <= top.indent) {
285
+ blockStack.pop();
286
+ lastTaskNode = null;
287
+ } else {
288
+ break;
289
+ }
290
+ }
291
+
292
+ // ── Check if we're inside a task (for deps/comments) ──
293
+
294
+ if (lastTaskNode && indent > 0) {
295
+ // Dependency under a task
296
+ const depMatch = line.match(DEPENDENCY_RE);
297
+ if (depMatch) {
298
+ const depParts = depMatch[1].split('|');
299
+ const targetName = depParts[0].trim();
300
+ let offset: Offset | undefined;
301
+
302
+ if (depParts.length > 1) {
303
+ const meta = parsePipeMetadata(['', ...depParts.slice(1)], aliasMap);
304
+ if (meta.lag || meta.lead) {
305
+ const key = meta.lag ? 'lag' : 'lead';
306
+ return fail(lineNumber, `Unknown keyword "${key}". Use "offset: ${meta[key]}" instead.`);
307
+ }
308
+ if (meta.offset) {
309
+ const raw = meta.offset;
310
+ if (raw.trim().startsWith('+')) {
311
+ warn(lineNumber, `Invalid offset: "${raw}". Explicit "+" is not supported — use "${raw.trim().slice(1)}" instead.`);
312
+ } else {
313
+ offset = parseOffset(raw) ?? undefined;
314
+ if (!offset) {
315
+ warn(lineNumber, `Invalid offset: "${raw}". Expected format like "3bd", "-5d", or "0bd".`);
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ lastTaskNode.dependencies.push({
322
+ targetName,
323
+ offset,
324
+ lineNumber,
325
+ });
326
+ continue;
327
+ }
328
+
329
+ // Comment under a task
330
+ if (COMMENT_RE.test(line)) {
331
+ const commentText = line.replace(/^\/\/\s?/, '');
332
+ lastTaskNode.comment = lastTaskNode.comment
333
+ ? lastTaskNode.comment + '\n' + commentText
334
+ : commentText;
335
+ continue;
336
+ }
337
+ }
338
+
339
+ // ── Top-level comment ─────────────────────────────────
340
+
341
+ if (COMMENT_RE.test(line)) continue;
342
+
343
+ // ── Header options ────────────────────────────────────
344
+
345
+ if (line.toLowerCase() === 'holidays') {
346
+ inHolidaysBlock = true;
347
+ holidaysBlockIndent = indent;
348
+ inHeaderBlock = false;
349
+ continue;
350
+ }
351
+
352
+ // Tag block heading
353
+ const tagMatch = matchTagBlockHeading(line);
354
+ if (tagMatch) {
355
+ inTagBlock = true;
356
+ tagBlockIndent = indent;
357
+ inHeaderBlock = false;
358
+ currentTagGroup = {
359
+ name: tagMatch.name,
360
+ alias: tagMatch.alias,
361
+ entries: [],
362
+ lineNumber,
363
+ };
364
+ if (tagMatch.alias) {
365
+ aliasMap.set(tagMatch.alias.toLowerCase(), tagMatch.name.toLowerCase());
366
+ }
367
+ continue;
368
+ }
369
+
370
+ // Era
371
+ const eraMatch = line.match(ERA_RE);
372
+ if (eraMatch) {
373
+ const eraLabelRaw = eraMatch[3].trim();
374
+ const eraExtracted = extractColor(eraLabelRaw, palette);
375
+ result.eras.push({
376
+ startDate: eraMatch[1],
377
+ endDate: eraMatch[2],
378
+ label: eraExtracted.label,
379
+ color: eraExtracted.color || null,
380
+ });
381
+ inHeaderBlock = false;
382
+ continue;
383
+ }
384
+
385
+ // Marker
386
+ const markerMatch = line.match(MARKER_RE);
387
+ if (markerMatch) {
388
+ const markerLabelRaw = markerMatch[2].trim();
389
+ const markerExtracted = extractColor(markerLabelRaw, palette);
390
+ result.markers.push({
391
+ date: markerMatch[1],
392
+ label: markerExtracted.label,
393
+ color: markerExtracted.color || null,
394
+ lineNumber,
395
+ });
396
+ inHeaderBlock = false;
397
+ continue;
398
+ }
399
+
400
+ // Options (start, title, orientation, etc.)
401
+ const optMatch = line.match(OPTION_RE);
402
+ if (optMatch && isKnownOption(optMatch[1].toLowerCase())) {
403
+ const key = optMatch[1].toLowerCase();
404
+ const value = optMatch[2].trim();
405
+
406
+ switch (key) {
407
+ case 'start':
408
+ result.options.start = value;
409
+ break;
410
+ case 'title':
411
+ result.options.title = value;
412
+ result.options.titleLineNumber = lineNumber;
413
+ break;
414
+ case 'orientation':
415
+ if (value === 'horizontal' || value === 'vertical') {
416
+ result.options.orientation = value;
417
+ } else {
418
+ warn(lineNumber, `Invalid orientation: "${value}". Expected "horizontal" or "vertical".`);
419
+ }
420
+ break;
421
+ case 'today-marker':
422
+ if (value === 'on' || value === 'off') {
423
+ result.options.todayMarker = value;
424
+ } else if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
425
+ result.options.todayMarker = value;
426
+ } else {
427
+ warn(lineNumber, `Invalid today-marker value: "${value}". Expected "on", "off", or YYYY-MM-DD.`);
428
+ }
429
+ break;
430
+ case 'critical-path':
431
+ result.options.criticalPath = value === 'on';
432
+ break;
433
+ case 'dependencies':
434
+ result.options.dependencies = value === 'on';
435
+ break;
436
+ case 'sort':
437
+ if (value === 'tag' || value.startsWith('tag:')) {
438
+ result.options.sort = 'tag';
439
+ const colonIdx = value.indexOf(':');
440
+ if (colonIdx !== -1) {
441
+ result.options.defaultSwimlaneGroup = value.slice(colonIdx + 1).trim() || null;
442
+ }
443
+ } else {
444
+ warn(lineNumber, `Invalid sort value: "${value}". Expected "tag" or "tag:GroupName".`);
445
+ }
446
+ break;
447
+ }
448
+ continue;
449
+ }
450
+
451
+ inHeaderBlock = false;
452
+
453
+ // ── Parallel block ────────────────────────────────────
454
+
455
+ if (line === 'parallel') {
456
+ const parallel: GanttParallelBlock = {
457
+ kind: 'parallel',
458
+ lineNumber,
459
+ children: [],
460
+ };
461
+ currentContainer().push(parallel);
462
+ blockStack.push({ node: parallel, indent, containerType: 'parallel' });
463
+ lastTaskNode = null;
464
+ continue;
465
+ }
466
+
467
+ // ── Group container ───────────────────────────────────
468
+
469
+ const groupMatch = line.match(GROUP_RE);
470
+ if (groupMatch) {
471
+ // Validate nesting: group under a task is invalid
472
+ if (blockStack.length > 0 && blockStack[blockStack.length - 1].containerType === 'task') {
473
+ return fail(lineNumber, `Cannot nest a group inside a task. Groups must be inside other groups or parallel blocks.`);
474
+ }
475
+
476
+ const afterBrackets = groupMatch[2].trim();
477
+ const segments = afterBrackets ? afterBrackets.split('|') : [];
478
+
479
+ // First segment could be empty (just `[Group]`) or have metadata
480
+ let metadata: Record<string, string> = {};
481
+ let color: string | null = null;
482
+
483
+ if (segments.length > 0 && segments[0].trim()) {
484
+ // Check if first segment after brackets is pipe metadata
485
+ metadata = parsePipeMetadata(['', ...segments], aliasMap);
486
+ } else if (segments.length > 1) {
487
+ metadata = parsePipeMetadata(['', ...segments.slice(1)], aliasMap);
488
+ }
489
+
490
+ // Extract color from group name if present
491
+ const nameExtracted = extractColor(groupMatch[1], palette);
492
+ if (nameExtracted.color) {
493
+ color = nameExtracted.color;
494
+ }
495
+
496
+ const group: GanttGroup = {
497
+ name: nameExtracted.label,
498
+ color,
499
+ metadata,
500
+ lineNumber,
501
+ children: [],
502
+ };
503
+ const groupNode: GanttNode = { kind: 'group', ...group };
504
+ currentContainer().push(groupNode);
505
+ blockStack.push({
506
+ node: groupNode as GanttGroup,
507
+ indent,
508
+ containerType: 'group',
509
+ });
510
+ lastTaskNode = null;
511
+ continue;
512
+ }
513
+
514
+ // ── Timeline migration syntax: 2024-01-15 -> 30d: Label ─
515
+
516
+ const timelineDurMatch = line.match(TIMELINE_DURATION_RE);
517
+ if (timelineDurMatch) {
518
+ const startDate = timelineDurMatch[1];
519
+ const amount = parseFloat(timelineDurMatch[2]);
520
+ const unit = timelineDurMatch[3] as DurationUnit;
521
+ const uncertain = !!timelineDurMatch[4];
522
+ const labelRaw = timelineDurMatch[5];
523
+
524
+ const task = makeTask(labelRaw, { amount, unit }, uncertain, lineNumber, startDate);
525
+ if (result.error) return result;
526
+ const taskNode: GanttNode = { kind: 'task', ...task };
527
+ currentContainer().push(taskNode);
528
+ lastTaskNode = taskNode as GanttNode & { kind: 'task' };
529
+ blockStack.push({ node: taskNode as unknown as GanttGroup, indent, containerType: 'task' });
530
+ continue;
531
+ }
532
+
533
+ // ── Duration task: 30d: Label ─────────────────────────
534
+
535
+ const durMatch = line.match(DURATION_RE);
536
+ if (durMatch) {
537
+ const amount = parseFloat(durMatch[1]);
538
+ const unit = durMatch[2] as DurationUnit;
539
+ const uncertain = !!durMatch[3];
540
+ const labelRaw = durMatch[4];
541
+
542
+ const task = makeTask(labelRaw, { amount, unit }, uncertain, lineNumber);
543
+ if (result.error) return result;
544
+ const taskNode: GanttNode = { kind: 'task', ...task };
545
+ currentContainer().push(taskNode);
546
+ lastTaskNode = taskNode as GanttNode & { kind: 'task' };
547
+ blockStack.push({ node: taskNode as unknown as GanttGroup, indent, containerType: 'task' });
548
+ continue;
549
+ }
550
+
551
+ // ── Explicit date task: 2024-01-15: Label ─────────────
552
+
553
+ const explicitDateMatch = line.match(EXPLICIT_DATE_RE);
554
+ if (explicitDateMatch) {
555
+ const task = makeTask(
556
+ explicitDateMatch[2],
557
+ null, // no duration — it's a date anchor / milestone
558
+ false,
559
+ lineNumber,
560
+ explicitDateMatch[1],
561
+ );
562
+ if (result.error) return result;
563
+ // Explicit date tasks with no duration are milestones
564
+ const taskNode: GanttNode = { kind: 'task', ...task };
565
+ currentContainer().push(taskNode);
566
+ lastTaskNode = taskNode as GanttNode & { kind: 'task' };
567
+ blockStack.push({ node: taskNode as unknown as GanttGroup, indent, containerType: 'task' });
568
+ continue;
569
+ }
570
+
571
+ // ── Dependency at root level (under a task context) ───
572
+
573
+ const depMatch = line.match(DEPENDENCY_RE);
574
+ if (depMatch) {
575
+ // Dependency without a task context is an error
576
+ if (!lastTaskNode) {
577
+ return fail(lineNumber, `Dependency "-> ${depMatch[1]}" must be indented under a task.`);
578
+ }
579
+ // This happens when the dep is at the same indent as the task
580
+ const depParts = depMatch[1].split('|');
581
+ const targetName = depParts[0].trim();
582
+ let offset: Offset | undefined;
583
+
584
+ if (depParts.length > 1) {
585
+ const meta = parsePipeMetadata(['', ...depParts.slice(1)], aliasMap);
586
+ if (meta.lag || meta.lead) {
587
+ const key = meta.lag ? 'lag' : 'lead';
588
+ warn(lineNumber, `"${key}" is deprecated — use "offset: ${meta[key]}" instead.${key === 'lead' ? ' Negate the value for lead behavior: "offset: -...".' : ''}`);
589
+ }
590
+ if (meta.offset) {
591
+ const raw = meta.offset;
592
+ if (raw.trim().startsWith('+')) {
593
+ warn(lineNumber, `Invalid offset: "${raw}". Explicit "+" is not supported — use "${raw.trim().slice(1)}" instead.`);
594
+ } else {
595
+ offset = parseOffset(raw) ?? undefined;
596
+ if (!offset) {
597
+ warn(lineNumber, `Invalid offset: "${raw}". Expected format like "3bd", "-5d", or "0bd".`);
598
+ }
599
+ }
600
+ }
601
+ }
602
+
603
+ lastTaskNode.dependencies.push({ targetName, offset, lineNumber });
604
+ continue;
605
+ }
606
+
607
+ // ── Bare label = parse error ──────────────────────────
608
+
609
+ return fail(lineNumber, `Expected duration (e.g., "10d: Task"), group brackets (e.g., "[Group]"), or keyword. Got: "${line}"`);
610
+ }
611
+
612
+ // ── Finalize ────────────────────────────────────────────
613
+
614
+ // Push final tag group if still open
615
+ if (currentTagGroup) {
616
+ result.tagGroups.push(currentTagGroup);
617
+ }
618
+
619
+ // If no chart type was declared, that's acceptable (inferred from context)
620
+
621
+ // Validate sort: tag requires tag groups
622
+ if (result.options.sort === 'tag' && result.tagGroups.length === 0) {
623
+ warn(0, 'sort: tag has no effect — no tag groups defined.');
624
+ result.options.sort = 'default';
625
+ }
626
+
627
+ return result;
628
+
629
+ // ── Helper: create a task ───────────────────────────────
630
+
631
+ function makeTask(
632
+ labelRaw: string,
633
+ duration: Duration | null,
634
+ uncertain: boolean,
635
+ ln: number,
636
+ explicitStart?: string,
637
+ ): GanttTask {
638
+ const segments = labelRaw.split('|');
639
+ const label = segments[0].trim();
640
+
641
+ // Check for reserved keyword
642
+ if (label.toLowerCase() === 'parallel') {
643
+ fail(ln, `"parallel" is a reserved keyword and cannot be used as a task name.`);
644
+ }
645
+
646
+ // Parse pipe metadata
647
+ const metadata = segments.length > 1
648
+ ? parsePipeMetadata(segments, aliasMap)
649
+ : {};
650
+
651
+ // Extract progress from metadata or shorthand
652
+ let progress: number | null = null;
653
+ if (metadata.progress) {
654
+ progress = parseFloat(metadata.progress);
655
+ delete metadata.progress;
656
+ }
657
+ // Check for progress shorthand: `| 80%`
658
+ for (let j = 1; j < segments.length; j++) {
659
+ const seg = segments[j].trim();
660
+ const progressMatch = seg.match(/^(\d+)%$/);
661
+ if (progressMatch) {
662
+ progress = parseInt(progressMatch[1], 10);
663
+ }
664
+ }
665
+
666
+ // Reject lag/lead — use offset instead
667
+ if (metadata.lag || metadata.lead) {
668
+ const key = metadata.lag ? 'lag' : 'lead';
669
+ fail(ln, `Unknown keyword "${key}". Use "offset: ${metadata[key]}" instead.`);
670
+ }
671
+
672
+ // Extract task-level offset from metadata
673
+ let taskOffset: Offset | undefined;
674
+ if (metadata.offset) {
675
+ const raw = metadata.offset;
676
+ if (raw.trim().startsWith('+')) {
677
+ warn(ln, `Invalid offset: "${raw}". Explicit "+" is not supported — use "${raw.trim().slice(1)}" instead.`);
678
+ } else {
679
+ taskOffset = parseOffset(raw) ?? undefined;
680
+ if (!taskOffset) {
681
+ warn(ln, `Invalid offset: "${raw}". Expected format like "3bd", "-5d", or "0bd".`);
682
+ }
683
+ }
684
+ delete metadata.offset;
685
+ }
686
+
687
+ // Inherit metadata from parent groups (tag inheritance)
688
+ const groupPath = currentGroupPath();
689
+ const inheritedMeta: Record<string, string> = {};
690
+ for (const entry of blockStack) {
691
+ if (entry.containerType === 'group') {
692
+ const groupNode = entry.node as GanttGroup;
693
+ Object.assign(inheritedMeta, groupNode.metadata);
694
+ }
695
+ // parallel blocks are transparent for tags — skip
696
+ }
697
+ // Task's own metadata overrides inherited
698
+ const effectiveMetadata = { ...inheritedMeta, ...metadata };
699
+
700
+ const id = `task_${taskIdCounter++}`;
701
+
702
+ return {
703
+ id,
704
+ label,
705
+ duration,
706
+ explicitStart,
707
+ uncertain,
708
+ progress,
709
+ offset: taskOffset,
710
+ dependencies: [],
711
+ metadata: effectiveMetadata,
712
+ lineNumber: ln,
713
+ groupPath,
714
+ };
715
+ }
716
+ }
717
+
718
+ // ── Utility: parse workweek string ────────────────────────
719
+
720
+ function parseWorkweek(s: string): Weekday[] | null {
721
+ // Try range format: "sun-thu"
722
+ const rangeParts = s.toLowerCase().split('-');
723
+ if (rangeParts.length === 2) {
724
+ const start = WEEKDAY_MAP[rangeParts[0].trim()];
725
+ const end = WEEKDAY_MAP[rangeParts[1].trim()];
726
+ if (start && end) {
727
+ const allDays: Weekday[] = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
728
+ const startIdx = allDays.indexOf(start);
729
+ const endIdx = allDays.indexOf(end);
730
+ const days: Weekday[] = [];
731
+ let idx = startIdx;
732
+ while (true) {
733
+ days.push(allDays[idx]);
734
+ if (idx === endIdx) break;
735
+ idx = (idx + 1) % 7;
736
+ }
737
+ return days;
738
+ }
739
+ }
740
+
741
+ // Try comma-separated: "mon, tue, wed, thu, fri"
742
+ const parts = s.toLowerCase().split(',').map(p => p.trim());
743
+ const days: Weekday[] = [];
744
+ for (const part of parts) {
745
+ const day = WEEKDAY_MAP[part];
746
+ if (!day) return null;
747
+ days.push(day);
748
+ }
749
+ return days.length > 0 ? days : null;
750
+ }
751
+
752
+ // ── Known option keys ─────────────────────────────────────
753
+
754
+ const KNOWN_OPTIONS = new Set([
755
+ 'start', 'title', 'orientation', 'today-marker',
756
+ 'critical-path', 'dependencies', 'chart', 'sort',
757
+ ]);
758
+
759
+ function isKnownOption(key: string): boolean {
760
+ return KNOWN_OPTIONS.has(key);
761
+ }