@nowline/core 0.2.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 (69) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +84 -0
  3. package/dist/generated/ast.d.ts +958 -0
  4. package/dist/generated/ast.d.ts.map +1 -0
  5. package/dist/generated/ast.js +795 -0
  6. package/dist/generated/ast.js.map +1 -0
  7. package/dist/generated/grammar.d.ts +7 -0
  8. package/dist/generated/grammar.d.ts.map +1 -0
  9. package/dist/generated/grammar.js +2509 -0
  10. package/dist/generated/grammar.js.map +1 -0
  11. package/dist/generated/module.d.ts +14 -0
  12. package/dist/generated/module.d.ts.map +1 -0
  13. package/dist/generated/module.js +21 -0
  14. package/dist/generated/module.js.map +1 -0
  15. package/dist/i18n/codes.d.ts +3 -0
  16. package/dist/i18n/codes.d.ts.map +1 -0
  17. package/dist/i18n/codes.js +54 -0
  18. package/dist/i18n/codes.js.map +1 -0
  19. package/dist/i18n/index.d.ts +17 -0
  20. package/dist/i18n/index.d.ts.map +1 -0
  21. package/dist/i18n/index.js +82 -0
  22. package/dist/i18n/index.js.map +1 -0
  23. package/dist/i18n/messages.en.d.ts +96 -0
  24. package/dist/i18n/messages.en.d.ts.map +1 -0
  25. package/dist/i18n/messages.en.js +57 -0
  26. package/dist/i18n/messages.en.js.map +1 -0
  27. package/dist/i18n/messages.fr-CA.d.ts +3 -0
  28. package/dist/i18n/messages.fr-CA.d.ts.map +1 -0
  29. package/dist/i18n/messages.fr-CA.js +11 -0
  30. package/dist/i18n/messages.fr-CA.js.map +1 -0
  31. package/dist/i18n/messages.fr-FR.d.ts +3 -0
  32. package/dist/i18n/messages.fr-FR.d.ts.map +1 -0
  33. package/dist/i18n/messages.fr-FR.js +11 -0
  34. package/dist/i18n/messages.fr-FR.js.map +1 -0
  35. package/dist/i18n/messages.fr.d.ts +3 -0
  36. package/dist/i18n/messages.fr.d.ts.map +1 -0
  37. package/dist/i18n/messages.fr.js +55 -0
  38. package/dist/i18n/messages.fr.js.map +1 -0
  39. package/dist/index.d.ts +9 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +8 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/language/include-resolver.d.ts +46 -0
  44. package/dist/language/include-resolver.d.ts.map +1 -0
  45. package/dist/language/include-resolver.js +306 -0
  46. package/dist/language/include-resolver.js.map +1 -0
  47. package/dist/language/nowline-module.d.ts +18 -0
  48. package/dist/language/nowline-module.d.ts.map +1 -0
  49. package/dist/language/nowline-module.js +63 -0
  50. package/dist/language/nowline-module.js.map +1 -0
  51. package/dist/language/nowline-validator.d.ts +65 -0
  52. package/dist/language/nowline-validator.d.ts.map +1 -0
  53. package/dist/language/nowline-validator.js +1654 -0
  54. package/dist/language/nowline-validator.js.map +1 -0
  55. package/package.json +37 -0
  56. package/src/generated/ast.ts +1178 -0
  57. package/src/generated/grammar.ts +2511 -0
  58. package/src/generated/module.ts +25 -0
  59. package/src/i18n/codes.ts +102 -0
  60. package/src/i18n/index.ts +102 -0
  61. package/src/i18n/messages.en.ts +86 -0
  62. package/src/i18n/messages.fr-CA.ts +13 -0
  63. package/src/i18n/messages.fr-FR.ts +13 -0
  64. package/src/i18n/messages.fr.ts +91 -0
  65. package/src/index.ts +22 -0
  66. package/src/language/include-resolver.ts +470 -0
  67. package/src/language/nowline-module.ts +114 -0
  68. package/src/language/nowline-validator.ts +1991 -0
  69. package/src/language/nowline.langium +217 -0
@@ -0,0 +1,1991 @@
1
+ import type {
2
+ AstNode,
3
+ Properties,
4
+ ValidationAcceptor,
5
+ ValidationChecks,
6
+ ValidationSeverity,
7
+ } from 'langium';
8
+ import { GrammarUtils } from 'langium';
9
+ import type {
10
+ AnchorDeclaration,
11
+ CalendarBlock,
12
+ DefaultDeclaration,
13
+ DefaultEntityType,
14
+ EntityProperty,
15
+ FootnoteDeclaration,
16
+ GroupBlock,
17
+ GroupContent,
18
+ IncludeDeclaration,
19
+ IncludeOption,
20
+ ItemDeclaration,
21
+ MilestoneDeclaration,
22
+ NowlineDirective,
23
+ NowlineFile,
24
+ ParallelBlock,
25
+ ParallelContent,
26
+ PersonDeclaration,
27
+ RoadmapDeclaration,
28
+ RoadmapEntry,
29
+ ScaleBlock,
30
+ SizeDeclaration,
31
+ StatusDeclaration,
32
+ StyleProperty,
33
+ SwimlaneContent,
34
+ SwimlaneDeclaration,
35
+ SymbolDeclaration,
36
+ TeamDeclaration,
37
+ } from '../generated/ast.js';
38
+ import {
39
+ isAnchorDeclaration,
40
+ isCalendarBlock,
41
+ isDefaultDeclaration,
42
+ isDescriptionDirective,
43
+ isFootnoteDeclaration,
44
+ isGroupBlock,
45
+ isItemDeclaration,
46
+ isLabelDeclaration,
47
+ isMilestoneDeclaration,
48
+ isParallelBlock,
49
+ isPersonDeclaration,
50
+ isPersonMemberRef,
51
+ isSizeDeclaration,
52
+ isStatusDeclaration,
53
+ isStyleDeclaration,
54
+ isSwimlaneDeclaration,
55
+ isSymbolDeclaration,
56
+ isTeamDeclaration,
57
+ } from '../generated/ast.js';
58
+ import type { MessageArgs, MessageCode } from '../i18n/index.js';
59
+ import { tr } from '../i18n/index.js';
60
+ import type { NowlineAstType, NowlineServices } from './nowline-module.js';
61
+
62
+ const SUPPORTED_VERSION = 'v1';
63
+
64
+ // Built-in status vocabulary. `active` and `completed` are international-
65
+ // friendly aliases for `in-progress` and `done` respectively — they
66
+ // canonicalize at the layout boundary (see statusFromProp in layout.ts) so
67
+ // downstream consumers see a single normalized form. Both spellings remain
68
+ // valid input.
69
+ const BUILTIN_STATUSES = new Set([
70
+ 'planned',
71
+ 'in-progress',
72
+ 'active',
73
+ 'done',
74
+ 'completed',
75
+ 'at-risk',
76
+ 'blocked',
77
+ ]);
78
+
79
+ const STYLE_PROP_KEYS = new Set([
80
+ 'bg',
81
+ 'fg',
82
+ 'text',
83
+ 'border',
84
+ 'icon',
85
+ 'shadow',
86
+ 'font',
87
+ 'weight',
88
+ 'italic',
89
+ 'text-size',
90
+ 'padding',
91
+ 'spacing',
92
+ 'header-height',
93
+ 'corner-radius',
94
+ 'bracket',
95
+ 'header-position',
96
+ 'capacity-icon',
97
+ 'timeline-position',
98
+ 'minor-grid',
99
+ ]);
100
+
101
+ // Built-in capacity-icon vocabulary. Renderer-curated SVG glyphs (plus 'multiplier'
102
+ // which renders as the U+00D7 text character and 'none' which suppresses the glyph).
103
+ const BUILTIN_CAPACITY_ICONS = new Set([
104
+ 'none',
105
+ 'multiplier',
106
+ 'person',
107
+ 'people',
108
+ 'points',
109
+ 'time',
110
+ ]);
111
+
112
+ // Built-in icon: vocabulary. Superset of capacity-icon names plus the entity-decoration
113
+ // icons currently shipped by the renderer.
114
+ const BUILTIN_ICON_NAMES = new Set([...BUILTIN_CAPACITY_ICONS, 'shield', 'warning', 'lock']);
115
+
116
+ // `utilization-warn-at:` / `utilization-over-at:` accept the literal `none`
117
+ // to opt out of that color band (per specs/dsl.md rule 17d). Numeric forms
118
+ // (positive percent, decimal, integer) are validated separately via
119
+ // POSITIVE_NUMBER_RE / POSITIVE_PERCENT_RE.
120
+ const UTILIZATION_NONE = 'none';
121
+
122
+ const STYLE_PROP_ENUMS: Record<string, Set<string>> = {
123
+ border: new Set(['solid', 'dashed', 'dotted']),
124
+ shadow: new Set(['none', 'subtle', 'soft', 'hard']),
125
+ font: new Set(['sans', 'serif', 'mono']),
126
+ weight: new Set(['thin', 'light', 'normal', 'bold']),
127
+ italic: new Set(['true', 'false']),
128
+ 'text-size': new Set(['none', 'xs', 'sm', 'md', 'lg', 'xl']),
129
+ padding: new Set(['none', 'xs', 'sm', 'md', 'lg', 'xl']),
130
+ spacing: new Set(['none', 'xs', 'sm', 'md', 'lg', 'xl']),
131
+ 'header-height': new Set(['none', 'xs', 'sm', 'md', 'lg', 'xl']),
132
+ 'corner-radius': new Set(['none', 'xs', 'sm', 'md', 'lg', 'xl', 'full']),
133
+ bracket: new Set(['none', 'solid', 'dashed']),
134
+ 'header-position': new Set(['beside', 'above']),
135
+ 'timeline-position': new Set(['top', 'bottom', 'both']),
136
+ 'minor-grid': new Set(['true', 'false']),
137
+ };
138
+
139
+ // Built-in color vocabulary. `grey` and `violet` are international-friendly
140
+ // aliases for `gray` and `purple` — they canonicalize at the theme
141
+ // boundary (resolveColor in packages/layout/src/themes/index.ts) so themes
142
+ // don't grow new fields. Both spellings remain valid input.
143
+ const COLOR_NAMES = new Set([
144
+ 'red',
145
+ 'blue',
146
+ 'yellow',
147
+ 'green',
148
+ 'orange',
149
+ 'purple',
150
+ 'violet',
151
+ 'gray',
152
+ 'grey',
153
+ 'navy',
154
+ 'white',
155
+ 'none',
156
+ ]);
157
+
158
+ const CALENDAR_MODES = new Set(['business', 'full', 'custom']);
159
+
160
+ const CALENDAR_FIELDS = new Set([
161
+ 'days-per-week',
162
+ 'days-per-month',
163
+ 'days-per-quarter',
164
+ 'days-per-year',
165
+ ]);
166
+
167
+ const SCALE_FIELDS = new Set(['name', 'label-every', 'label']);
168
+
169
+ const DEFAULT_ENTITY_TYPES = new Set([
170
+ 'item',
171
+ 'label',
172
+ 'swimlane',
173
+ 'roadmap',
174
+ 'milestone',
175
+ 'footnote',
176
+ 'anchor',
177
+ 'parallel',
178
+ 'group',
179
+ ]);
180
+
181
+ // Banned properties per entity type on `default <entity>` lines.
182
+ // `capacity` on `default swimlane` is banned because each lane's budget must be
183
+ // explicit at its declaration site (per dsl.md). `capacity` on `default item` is
184
+ // allowed (and a useful "every item consumes 1 unit by default" lever).
185
+ const DEFAULT_BANNED: Record<DefaultEntityType, Set<string>> = {
186
+ item: new Set([
187
+ 'size',
188
+ 'duration',
189
+ 'after',
190
+ 'before',
191
+ 'remaining',
192
+ 'link',
193
+ 'description',
194
+ 'owner',
195
+ ]),
196
+ milestone: new Set(['date', 'after', 'link', 'description']),
197
+ anchor: new Set(['date', 'link', 'description']),
198
+ footnote: new Set(['on', 'link', 'description']),
199
+ label: new Set(['link', 'description']),
200
+ swimlane: new Set(['capacity']),
201
+ roadmap: new Set(),
202
+ parallel: new Set(),
203
+ group: new Set(),
204
+ };
205
+
206
+ // Properties every entity declaration may carry (`specs/dsl.md` § Universal Properties).
207
+ // `description` is technically a sub-directive (`description "text"` indented under the
208
+ // entity), not an EntityProperty, but we accept it here too: authors who type
209
+ // `description:"text"` as a property aren't doing anything meaningful, but the typo is
210
+ // common enough that warning on it adds noise rather than signal — the value is at
211
+ // least carried into the AST and could be surfaced later if we choose to.
212
+ const UNIVERSAL_ENTITY_PROPS = new Set(['labels', 'link', 'style', 'description']);
213
+
214
+ // Properties each entity declaration's `properties: EntityProperty[]` bag actually
215
+ // consumes downstream. Sourced from (a) the property tables in `specs/dsl.md` and
216
+ // (b) every `propValue(...)`/`propValues(...)` call site in `packages/layout/`. Keys
217
+ // outside this set + `UNIVERSAL_ENTITY_PROPS` are silently dropped by the layout —
218
+ // `checkUnknownEntityProperties` warns on them so authors notice typos like
219
+ // `now:2026-01-25` on a `roadmap` line.
220
+ //
221
+ // Raw style props (`bg`, `fg`, ...) intentionally stay OUT of this map — they are
222
+ // rejected as errors by `checkNoRawStyleProperties` and the unknown-property check
223
+ // short-circuits on them so authors don't see a duplicate diagnostic.
224
+ const ENTITY_KNOWN_PROPS: Record<string, Set<string>> = {
225
+ RoadmapDeclaration: new Set([
226
+ 'author',
227
+ 'start',
228
+ 'length',
229
+ 'scale',
230
+ 'calendar',
231
+ 'logo',
232
+ 'logo-size',
233
+ ]),
234
+ AnchorDeclaration: new Set(['date']),
235
+ SwimlaneDeclaration: new Set([
236
+ 'owner',
237
+ 'capacity',
238
+ 'utilization-warn-at',
239
+ 'utilization-over-at',
240
+ 'status',
241
+ 'after',
242
+ 'before',
243
+ ]),
244
+ // `date:` and `start:` on items are read by `packages/layout/src/layout.ts`
245
+ // (`resolveChildStart`, `walkNode`) to pin an item to a fixed start date. Not
246
+ // documented in `specs/dsl.md` yet — left allow-listed so authors don't see
247
+ // a spurious warning for a working code path. See plan's out-of-scope note.
248
+ ItemDeclaration: new Set([
249
+ 'size',
250
+ 'duration',
251
+ 'status',
252
+ 'owner',
253
+ 'after',
254
+ 'before',
255
+ 'remaining',
256
+ 'capacity',
257
+ 'date',
258
+ 'start',
259
+ ]),
260
+ // size/duration/remaining/capacity on parallel/group are already errors via
261
+ // `checkNoComputedProperties`, so we don't list them here.
262
+ ParallelBlock: new Set(['owner', 'after', 'before', 'status']),
263
+ GroupBlock: new Set(['owner', 'after', 'before', 'status']),
264
+ MilestoneDeclaration: new Set(['date', 'after']),
265
+ FootnoteDeclaration: new Set(['on']),
266
+ PersonDeclaration: new Set(),
267
+ TeamDeclaration: new Set(),
268
+ LabelDeclaration: new Set(),
269
+ SizeDeclaration: new Set(['effort']),
270
+ StatusDeclaration: new Set(),
271
+ };
272
+
273
+ // Levenshtein distance with an early-exit cap: returns `cap + 1` once the running
274
+ // minimum exceeds the cap so we can short-circuit the matcher in the common case
275
+ // where the typo is much further than 2 edits from any valid key. The helper is
276
+ // only used by `checkUnknownEntityProperties` for the "did you mean?" suggestion.
277
+ function levenshteinCapped(a: string, b: string, cap: number): number {
278
+ if (a === b) return 0;
279
+ const an = a.length;
280
+ const bn = b.length;
281
+ if (Math.abs(an - bn) > cap) return cap + 1;
282
+ if (an === 0) return bn;
283
+ if (bn === 0) return an;
284
+ let prev = new Array<number>(bn + 1);
285
+ let curr = new Array<number>(bn + 1);
286
+ for (let j = 0; j <= bn; j++) prev[j] = j;
287
+ for (let i = 1; i <= an; i++) {
288
+ curr[0] = i;
289
+ let rowMin = i;
290
+ for (let j = 1; j <= bn; j++) {
291
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
292
+ curr[j] = Math.min(
293
+ prev[j] + 1, // deletion
294
+ curr[j - 1] + 1, // insertion
295
+ prev[j - 1] + cost, // substitution
296
+ );
297
+ if (curr[j] < rowMin) rowMin = curr[j];
298
+ }
299
+ if (rowMin > cap) return cap + 1;
300
+ const tmp = prev;
301
+ prev = curr;
302
+ curr = tmp;
303
+ }
304
+ return prev[bn];
305
+ }
306
+
307
+ // Pick the closest known key within `cap` edits of `key`, or undefined. Ties are
308
+ // broken by the order of `candidates`, so callers should pass entity-specific keys
309
+ // first when both lists are searched.
310
+ function suggestKey(key: string, candidates: Iterable<string>, cap = 2): string | undefined {
311
+ let best: string | undefined;
312
+ let bestDist = cap + 1;
313
+ for (const c of candidates) {
314
+ const d = levenshteinCapped(key, c, cap);
315
+ if (d < bestDist) {
316
+ best = c;
317
+ bestDist = d;
318
+ if (d === 0) break;
319
+ }
320
+ }
321
+ return best;
322
+ }
323
+
324
+ function propKey(prop: { key: string }): string {
325
+ return prop.key.endsWith(':') ? prop.key.slice(0, -1) : prop.key;
326
+ }
327
+
328
+ // Matches duration literals including decimals, e.g. `2w`, `0.5d`, `1.5m`.
329
+ const DURATION_RE = /^\d+(?:\.\d+)?[dwmqy]$/;
330
+ const PERCENTAGE_RE = /^\d+%$/;
331
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
332
+ const HEX_COLOR_RE = /^#[0-9a-fA-F]{3,8}$/;
333
+ const VERSION_RE = /^v\d+$/;
334
+ const INTEGER_RE = /^\d+$/;
335
+ const BARE_DURATION_SUFFIX_RE = /^[dwmqy]$/;
336
+ // Capacity numeric forms — the grammar already separates DECIMAL/INTEGER/PERCENTAGE
337
+ // terminals, but the validator needs to enforce the spec's "positive number" rule
338
+ // and to differentiate where percent is allowed (item) vs. banned (swimlane).
339
+ const POSITIVE_NUMBER_RE = /^\d+(\.\d+)?$/;
340
+ const POSITIVE_PERCENT_RE = /^\d+(\.\d+)?%$/;
341
+ // Disambiguated forms used by `utilization-warn-at:` / `utilization-over-at:`.
342
+ // A decimal-fraction MUST include the dot so its meaning is unambiguous; a
343
+ // bare integer matches the integer regex and is rejected by the validator
344
+ // with a hint to switch to either the percent or decimal-fraction form.
345
+ const POSITIVE_DECIMAL_FRACTION_RE = /^\d+\.\d+$/;
346
+ const POSITIVE_INTEGER_RE = /^\d+$/;
347
+ // ASCII printable, length 1-3 — used by the glyph declaration validator for the
348
+ // `ascii:"..."` fallback property after Langium has stripped the surrounding quotes.
349
+ const ASCII_FALLBACK_RE = /^[\x20-\x7E]{1,3}$/;
350
+
351
+ // BCP-47 language tag, simplified to the subset Nowline cares about: a 2- or 3-letter
352
+ // primary language subtag followed by an optional region subtag (2 letters or 3 digits).
353
+ // Permissive enough for `fr`, `fr-CA`, `zh-CN`, `es-419` while rejecting obvious typos.
354
+ // The runtime resolver does the actual fallback work; this regex just gates input shape.
355
+ const BCP47_RE = /^[a-zA-Z]{2,3}(-[a-zA-Z]{2}|-\d{3})?$/;
356
+
357
+ const INCLUDE_MODES = new Set(['merge', 'ignore', 'isolate']);
358
+
359
+ // Allow-listed property keys on the `nowline` directive line. The directive existed
360
+ // historically as `nowline v1` only; properties are a forward-extension point but stay
361
+ // restrictive so authors get an early error instead of typos rendering silently.
362
+ const DIRECTIVE_FIELDS = new Set(['locale']);
363
+
364
+ type StartState =
365
+ | { kind: 'valid'; iso: string; date: Date }
366
+ | { kind: 'invalid' }
367
+ | { kind: 'missing' };
368
+
369
+ function resolveLocalStart(file: NowlineFile | undefined): StartState {
370
+ const prop = file?.roadmapDecl?.properties.find((p) => propKey(p) === 'start');
371
+ if (!prop) return { kind: 'missing' };
372
+ const raw = prop.value;
373
+ if (!raw || !DATE_RE.test(raw)) return { kind: 'invalid' };
374
+ const d = new Date(raw);
375
+ if (Number.isNaN(d.getTime())) return { kind: 'invalid' };
376
+ return { kind: 'valid', iso: raw, date: d };
377
+ }
378
+
379
+ function displayName(node: { name?: string; title?: string }): string {
380
+ return node.name ?? node.title ?? '<unnamed>';
381
+ }
382
+
383
+ /**
384
+ * Emit a localized validator diagnostic.
385
+ *
386
+ * Validator runs at parse time when only the file's locale is in scope.
387
+ * The CLI re-formats with the operator's locale before printing —
388
+ * see `specs/localization.md` for the two-chain model. We always
389
+ * format the en-US text now so non-CLI consumers (LSP, tests) still
390
+ * see usable copy, and stash `{ code, args }` in `data` so the CLI
391
+ * can swap in the operator-locale text downstream.
392
+ */
393
+ type DiagnosticInfo = {
394
+ node: AstNode;
395
+ property?: Properties<AstNode>;
396
+ keyword?: string;
397
+ index?: number;
398
+ };
399
+
400
+ function acceptTr<K extends MessageCode>(
401
+ accept: ValidationAcceptor,
402
+ severity: ValidationSeverity,
403
+ info: DiagnosticInfo,
404
+ code: K,
405
+ ...args: MessageArgs<K>
406
+ ): void {
407
+ accept(severity, tr('en-US', code, ...args), {
408
+ ...info,
409
+ data: { code, args },
410
+ });
411
+ }
412
+
413
+ export function registerValidationChecks(services: NowlineServices): void {
414
+ const registry = services.validation.ValidationRegistry;
415
+ const validator = services.validation.NowlineValidator;
416
+ const checks: ValidationChecks<NowlineAstType> = {
417
+ NowlineFile: [
418
+ validator.checkFileStructure,
419
+ validator.checkUniqueIdentifiers,
420
+ validator.checkSwimlaneRequired,
421
+ validator.checkIndentationConsistency,
422
+ validator.checkRoadmapOnlyKeywordsPosition,
423
+ validator.checkForwardReferences,
424
+ validator.checkReferenceResolution,
425
+ validator.checkCircularDependencies,
426
+ validator.checkDuplicateSizeIds,
427
+ validator.checkCalendarBlockConsistency,
428
+ validator.checkPersonDeclarations,
429
+ validator.checkDuplicateSymbolIds,
430
+ validator.checkSymbolReferences,
431
+ ],
432
+ NowlineDirective: [validator.checkDirectiveVersion, validator.checkDirectiveProperties],
433
+ IncludeOption: [validator.checkIncludeMode],
434
+ IncludeDeclaration: [validator.checkIncludeDuplicateOptions],
435
+ EntityProperty: [validator.checkPropertyValues],
436
+ RoadmapDeclaration: [
437
+ validator.checkEntityIdOrTitle,
438
+ validator.checkRoadmapProperties,
439
+ validator.checkNoRawStyleProperties,
440
+ validator.checkUnknownEntityProperties,
441
+ ],
442
+ AnchorDeclaration: [
443
+ validator.checkEntityIdOrTitle,
444
+ validator.checkAnchorRequiredDate,
445
+ validator.checkAnchorAgainstStart,
446
+ validator.checkNoRawStyleProperties,
447
+ validator.checkNoFootnoteProperty,
448
+ validator.checkUnknownEntityProperties,
449
+ ],
450
+ SwimlaneDeclaration: [
451
+ validator.checkEntityIdOrTitle,
452
+ validator.checkNoRawStyleProperties,
453
+ validator.checkUtilizationOrdering,
454
+ validator.checkNoFootnoteProperty,
455
+ validator.checkUnknownEntityProperties,
456
+ ],
457
+ ItemDeclaration: [
458
+ validator.checkEntityIdOrTitle,
459
+ validator.checkItemRequiredDuration,
460
+ validator.checkNoRawStyleProperties,
461
+ validator.checkNoFootnoteProperty,
462
+ validator.checkUnknownEntityProperties,
463
+ ],
464
+ ParallelBlock: [
465
+ validator.checkParallelMinChildren,
466
+ validator.checkNoComputedProperties,
467
+ validator.checkNoRawStyleProperties,
468
+ validator.checkNoFootnoteProperty,
469
+ validator.checkUnknownEntityProperties,
470
+ ],
471
+ GroupBlock: [
472
+ validator.checkGroupMinChildren,
473
+ validator.checkNoComputedProperties,
474
+ validator.checkNoRawStyleProperties,
475
+ validator.checkNoFootnoteProperty,
476
+ validator.checkUnknownEntityProperties,
477
+ ],
478
+ MilestoneDeclaration: [
479
+ validator.checkEntityIdOrTitle,
480
+ validator.checkMilestoneRequirement,
481
+ validator.checkMilestoneAgainstStart,
482
+ validator.checkNoRawStyleProperties,
483
+ validator.checkNoFootnoteProperty,
484
+ validator.checkUnknownEntityProperties,
485
+ ],
486
+ FootnoteDeclaration: [
487
+ validator.checkEntityIdOrTitle,
488
+ validator.checkFootnoteOn,
489
+ validator.checkNoRawStyleProperties,
490
+ validator.checkUnknownEntityProperties,
491
+ ],
492
+ PersonDeclaration: [
493
+ validator.checkEntityIdOrTitle,
494
+ validator.checkNoRawStyleProperties,
495
+ validator.checkNoFootnoteProperty,
496
+ validator.checkUnknownEntityProperties,
497
+ ],
498
+ TeamDeclaration: [
499
+ validator.checkEntityIdOrTitle,
500
+ validator.checkNoRawStyleProperties,
501
+ validator.checkNoFootnoteProperty,
502
+ validator.checkUnknownEntityProperties,
503
+ ],
504
+ StyleDeclaration: [validator.checkEntityIdOrTitle],
505
+ StyleProperty: [validator.checkStylePropertyEnum],
506
+ SymbolDeclaration: [validator.checkEntityIdOrTitle, validator.checkSymbolDeclaration],
507
+ LabelDeclaration: [
508
+ validator.checkEntityIdOrTitle,
509
+ validator.checkNoRawStyleProperties,
510
+ validator.checkUnknownEntityProperties,
511
+ ],
512
+ SizeDeclaration: [
513
+ validator.checkEntityIdOrTitle,
514
+ validator.checkSizeDeclaration,
515
+ validator.checkNoRawStyleProperties,
516
+ validator.checkUnknownEntityProperties,
517
+ ],
518
+ StatusDeclaration: [
519
+ validator.checkEntityIdOrTitle,
520
+ validator.checkStatusDeclaration,
521
+ validator.checkNoRawStyleProperties,
522
+ validator.checkUnknownEntityProperties,
523
+ ],
524
+ DefaultDeclaration: [validator.checkDefaultDeclaration, validator.checkUtilizationOrdering],
525
+ CalendarBlock: [validator.checkCalendarBlock],
526
+ ScaleBlock: [validator.checkScaleBlock],
527
+ };
528
+ registry.register(checks, validator);
529
+ }
530
+
531
+ export class NowlineValidator {
532
+ // --- Structural Rule 4: Section order ---
533
+ checkFileStructure(file: NowlineFile, accept: ValidationAcceptor): void {
534
+ // Use CST-aware lookup so the `config:` / `roadmap:` substrings inside
535
+ // INCLUDE_OPTION_KEY tokens (e.g. `config:isolate`) aren't mistaken for
536
+ // the top-level `config` section marker. See issue #1.
537
+ const configOffset = file.hasConfig
538
+ ? GrammarUtils.findNodeForKeyword(file.$cstNode, 'config')?.offset
539
+ : undefined;
540
+ const roadmapOffset = file.roadmapDecl?.$cstNode?.offset;
541
+
542
+ if (
543
+ configOffset !== undefined &&
544
+ roadmapOffset !== undefined &&
545
+ configOffset > roadmapOffset
546
+ ) {
547
+ acceptTr(accept, 'error', { node: file, property: 'hasConfig' }, 'NL.E0001');
548
+ }
549
+
550
+ for (const inc of file.includes) {
551
+ const incOffset = inc.$cstNode?.offset ?? 0;
552
+ if (configOffset !== undefined && incOffset > configOffset) {
553
+ acceptTr(accept, 'error', { node: inc }, 'NL.E0002');
554
+ }
555
+ if (roadmapOffset !== undefined && incOffset > roadmapOffset) {
556
+ acceptTr(accept, 'error', { node: inc }, 'NL.E0003');
557
+ }
558
+ }
559
+ }
560
+
561
+ // --- Structural Rules 8/9/10: label/duration/status must live in roadmap section ---
562
+ // Parser enforces this by construction (these keywords are only valid as RoadmapEntry),
563
+ // so we mainly need to forbid custom statuses referenced before their declaration (rule 15).
564
+ checkRoadmapOnlyKeywordsPosition(_file: NowlineFile, _accept: ValidationAcceptor): void {
565
+ // No-op: the grammar places label/duration/status under RoadmapEntry.
566
+ // Any attempt to declare them before `roadmap` surfaces as a parse error.
567
+ }
568
+
569
+ // --- Rule 5: Directive version ---
570
+ checkDirectiveVersion(directive: NowlineDirective, accept: ValidationAcceptor): void {
571
+ if (!VERSION_RE.test(directive.version)) {
572
+ acceptTr(accept, 'error', { node: directive, property: 'version' }, 'NL.E0100', {
573
+ version: directive.version,
574
+ });
575
+ return;
576
+ }
577
+ const num = parseInt(directive.version.slice(1), 10);
578
+ const supportedNum = parseInt(SUPPORTED_VERSION.slice(1), 10);
579
+ if (num > supportedNum) {
580
+ acceptTr(accept, 'error', { node: directive, property: 'version' }, 'NL.E0101', {
581
+ version: directive.version,
582
+ supported: SUPPORTED_VERSION,
583
+ });
584
+ }
585
+ }
586
+
587
+ // --- Directive properties (locale) ---
588
+ // Validates the optional key:value pairs on the `nowline` directive line. Today the
589
+ // only allowed key is `locale:` (BCP-47 tag); unknown keys are rejected so typos
590
+ // surface immediately. Duplicate keys are also rejected.
591
+ checkDirectiveProperties(directive: NowlineDirective, accept: ValidationAcceptor): void {
592
+ const seen = new Set<string>();
593
+ for (const prop of directive.properties) {
594
+ const key = propKey(prop);
595
+ if (!DIRECTIVE_FIELDS.has(key)) {
596
+ acceptTr(accept, 'error', { node: prop, property: 'key' }, 'NL.E0102', {
597
+ key,
598
+ allowed: [...DIRECTIVE_FIELDS].join(', '),
599
+ });
600
+ continue;
601
+ }
602
+ if (seen.has(key)) {
603
+ acceptTr(accept, 'error', { node: prop, property: 'key' }, 'NL.E0103', { key });
604
+ }
605
+ seen.add(key);
606
+
607
+ if (key === 'locale') {
608
+ const raw = prop.value;
609
+ if (!BCP47_RE.test(raw)) {
610
+ acceptTr(accept, 'error', { node: prop, property: 'value' }, 'NL.E0104', {
611
+ value: raw,
612
+ });
613
+ }
614
+ }
615
+ }
616
+ }
617
+
618
+ // --- Rule 3: Id or title required ---
619
+ checkEntityIdOrTitle(
620
+ node: AstNode & { name?: string; title?: string },
621
+ accept: ValidationAcceptor,
622
+ ): void {
623
+ if (!node.name && !node.title) {
624
+ acceptTr(accept, 'error', { node }, 'NL.E0301', { type: node.$type });
625
+ }
626
+ }
627
+
628
+ // --- Rule 2: Unique identifiers ---
629
+ checkUniqueIdentifiers(file: NowlineFile, accept: ValidationAcceptor): void {
630
+ const seen = new Map<string, AstNode>();
631
+
632
+ const register = (name: string | undefined, node: AstNode) => {
633
+ if (!name) return;
634
+ const existing = seen.get(name);
635
+ if (existing) {
636
+ acceptTr(accept, 'error', { node }, 'NL.E0300', {
637
+ name,
638
+ location: locationOf(existing),
639
+ });
640
+ } else {
641
+ seen.set(name, node);
642
+ }
643
+ };
644
+
645
+ if (file.roadmapDecl?.name) {
646
+ register(file.roadmapDecl.name, file.roadmapDecl);
647
+ }
648
+
649
+ for (const entry of file.roadmapEntries) {
650
+ registerEntity(entry, register);
651
+ }
652
+
653
+ for (const entry of file.configEntries) {
654
+ if (isStyleDeclaration(entry) && entry.name) {
655
+ register(entry.name, entry);
656
+ }
657
+ }
658
+ }
659
+
660
+ // --- Rule 7: Mixed tabs and spaces in indentation ---
661
+ checkIndentationConsistency(file: NowlineFile, accept: ValidationAcceptor): void {
662
+ const text = file.$document?.textDocument.getText() ?? file.$cstNode?.text;
663
+ if (!text) return;
664
+ const lines = text.split(/\r?\n/);
665
+ for (let i = 0; i < lines.length; i++) {
666
+ const line = lines[i];
667
+ if (line.length === 0) continue;
668
+ const match = line.match(/^([\t ]+)/);
669
+ if (!match) continue;
670
+ const indent = match[1];
671
+ if (indent.includes('\t') && indent.includes(' ')) {
672
+ acceptTr(accept, 'error', { node: file }, 'NL.E0005', { line: i + 1 });
673
+ return;
674
+ }
675
+ }
676
+ }
677
+
678
+ // --- Rule 6: At least one swimlane ---
679
+ checkSwimlaneRequired(file: NowlineFile, accept: ValidationAcceptor): void {
680
+ const hasSwimlane = file.roadmapEntries.some(isSwimlaneDeclaration);
681
+ if (file.roadmapDecl && !hasSwimlane) {
682
+ acceptTr(accept, 'error', { node: file.roadmapDecl }, 'NL.E0004');
683
+ }
684
+ }
685
+
686
+ // --- Include rules 5/6: mode values ---
687
+ checkIncludeMode(option: IncludeOption, accept: ValidationAcceptor): void {
688
+ if (!INCLUDE_MODES.has(option.value)) {
689
+ acceptTr(accept, 'error', { node: option, property: 'value' }, 'NL.E0200', {
690
+ value: option.value,
691
+ });
692
+ }
693
+ }
694
+
695
+ // --- Include rule 4 (partial): Duplicate include options ---
696
+ checkIncludeDuplicateOptions(inc: IncludeDeclaration, accept: ValidationAcceptor): void {
697
+ const keys = new Set<string>();
698
+ for (const opt of inc.options) {
699
+ const normalized = opt.key.replace(/:$/, '');
700
+ if (keys.has(normalized)) {
701
+ acceptTr(accept, 'error', { node: opt }, 'NL.E0201', { key: normalized });
702
+ }
703
+ keys.add(normalized);
704
+ }
705
+ }
706
+
707
+ // --- Property value validation (general) ---
708
+ checkPropertyValues(prop: EntityProperty, accept: ValidationAcceptor): void {
709
+ const key = propKey(prop);
710
+ const val = prop.value;
711
+ const vals = prop.values;
712
+ const allValues = val ? [val] : vals;
713
+
714
+ switch (key) {
715
+ case 'status':
716
+ // Forward/resolution validated at file scope (checkForwardReferences).
717
+ break;
718
+
719
+ case 'duration':
720
+ // `duration:` is literal-only — no alias lookup. Authors who
721
+ // want a named alias use `size:NAME` instead, which resolves
722
+ // to the size's `effort:` (and divides by `capacity:` at
723
+ // layout time, m5).
724
+ if (val && !DURATION_RE.test(val)) {
725
+ acceptTr(accept, 'error', { node: prop, property: 'value' }, 'NL.E0400', {
726
+ value: val,
727
+ });
728
+ }
729
+ break;
730
+
731
+ case 'size':
732
+ if (val && !isIdentifier(val)) {
733
+ acceptTr(accept, 'error', { node: prop, property: 'value' }, 'NL.E0401', {
734
+ value: val,
735
+ });
736
+ }
737
+ break;
738
+
739
+ case 'effort':
740
+ if (val && !DURATION_RE.test(val)) {
741
+ acceptTr(accept, 'error', { node: prop, property: 'value' }, 'NL.E0402', {
742
+ value: val,
743
+ });
744
+ }
745
+ break;
746
+
747
+ case 'remaining': {
748
+ // Accepts either a percent (`30%`, validated 0-100) or a
749
+ // single-engineer effort literal (`1w`, `0.5d`). The literal
750
+ // form is normalized to a percent at layout time using the
751
+ // item's resolved total effort (m5); overflow there emits a
752
+ // soft warning and clamps the rendered bar to 100%.
753
+ if (val) {
754
+ if (PERCENTAGE_RE.test(val)) {
755
+ const pct = parseFloat(val);
756
+ if (pct < 0 || pct > 100) {
757
+ acceptTr(
758
+ accept,
759
+ 'error',
760
+ { node: prop, property: 'value' },
761
+ 'NL.E0404',
762
+ { value: val },
763
+ );
764
+ }
765
+ } else if (!DURATION_RE.test(val)) {
766
+ acceptTr(accept, 'error', { node: prop, property: 'value' }, 'NL.E0403', {
767
+ value: val,
768
+ });
769
+ }
770
+ }
771
+ break;
772
+ }
773
+
774
+ case 'date':
775
+ case 'start':
776
+ if (val && (!DATE_RE.test(val) || Number.isNaN(new Date(val).getTime()))) {
777
+ acceptTr(accept, 'error', { node: prop, property: 'value' }, 'NL.E0405', {
778
+ key,
779
+ value: val,
780
+ });
781
+ }
782
+ break;
783
+
784
+ case 'scale':
785
+ if (val && !DURATION_RE.test(val)) {
786
+ acceptTr(accept, 'error', { node: prop, property: 'value' }, 'NL.E0406', {
787
+ value: val,
788
+ });
789
+ }
790
+ break;
791
+
792
+ case 'calendar':
793
+ if (val && !CALENDAR_MODES.has(val)) {
794
+ acceptTr(accept, 'error', { node: prop, property: 'value' }, 'NL.E0407', {
795
+ value: val,
796
+ });
797
+ }
798
+ break;
799
+
800
+ case 'on':
801
+ case 'after':
802
+ case 'before':
803
+ if (allValues.length === 0) {
804
+ acceptTr(accept, 'error', { node: prop }, 'NL.E0408', { key });
805
+ }
806
+ break;
807
+
808
+ case 'capacity': {
809
+ if (!val) break;
810
+ const parent = prop.$container;
811
+ const parentKind = capacityParentKind(parent);
812
+ // Parallel/group ban is reported by checkNoComputedProperties; skip here.
813
+ if (parentKind === 'invalid') break;
814
+ if (parentKind === 'lane') {
815
+ if (!POSITIVE_NUMBER_RE.test(val) || parseFloat(val) <= 0) {
816
+ accept(
817
+ 'error',
818
+ `Invalid swimlane capacity "${val}". Use a positive integer (e.g. capacity:5) or decimal (e.g. capacity:1.5). Percent literals are not allowed on swimlanes.`,
819
+ { node: prop, property: 'value' },
820
+ );
821
+ }
822
+ } else {
823
+ if (POSITIVE_PERCENT_RE.test(val)) {
824
+ const pct = parseFloat(val);
825
+ if (pct <= 0) {
826
+ accept('error', `Item capacity "${val}" must be positive.`, {
827
+ node: prop,
828
+ property: 'value',
829
+ });
830
+ }
831
+ } else if (POSITIVE_NUMBER_RE.test(val)) {
832
+ if (parseFloat(val) <= 0) {
833
+ accept('error', `Item capacity "${val}" must be positive.`, {
834
+ node: prop,
835
+ property: 'value',
836
+ });
837
+ }
838
+ } else {
839
+ accept(
840
+ 'error',
841
+ `Invalid item capacity "${val}". Use a positive integer (capacity:2), decimal (capacity:0.5), or percent literal (capacity:50%).`,
842
+ { node: prop, property: 'value' },
843
+ );
844
+ }
845
+ }
846
+ break;
847
+ }
848
+
849
+ case 'overcapacity': {
850
+ // Removed in m9. Suppression is now expressed via
851
+ // `utilization-warn-at:none` / `utilization-over-at:none`
852
+ // (see specs/dsl.md § Capacity → Utilization thresholds).
853
+ accept(
854
+ 'error',
855
+ `"overcapacity:" was removed. Use "utilization-over-at:none" (and/or "utilization-warn-at:none") to suppress the lane utilization underline.`,
856
+ { node: prop },
857
+ );
858
+ break;
859
+ }
860
+
861
+ case 'utilization-warn-at':
862
+ case 'utilization-over-at': {
863
+ if (!val) break;
864
+ if (!isUtilizationAllowedHere(prop.$container)) {
865
+ accept(
866
+ 'error',
867
+ `"${key}:" is only valid on "swimlane" or "default swimlane".`,
868
+ { node: prop },
869
+ );
870
+ break;
871
+ }
872
+ if (val === UTILIZATION_NONE) break;
873
+ if (POSITIVE_PERCENT_RE.test(val)) {
874
+ if (parseFloat(val) <= 0) {
875
+ accept('error', `${key} value "${val}" must be positive.`, {
876
+ node: prop,
877
+ property: 'value',
878
+ });
879
+ }
880
+ break;
881
+ }
882
+ // Decimals must include the dot to read as fractions; bare
883
+ // integers are ambiguous (`80` could mean 80% or 8000% as a
884
+ // fraction) so we reject them with a hint to disambiguate.
885
+ if (POSITIVE_DECIMAL_FRACTION_RE.test(val)) {
886
+ if (parseFloat(val) <= 0) {
887
+ accept('error', `${key} value "${val}" must be positive.`, {
888
+ node: prop,
889
+ property: 'value',
890
+ });
891
+ }
892
+ break;
893
+ }
894
+ if (POSITIVE_INTEGER_RE.test(val)) {
895
+ accept(
896
+ 'error',
897
+ `Ambiguous ${key} value "${val}". Use the percent form ("${val}%") or the decimal-fraction form ("0.${val}") to make the intent explicit.`,
898
+ { node: prop, property: 'value' },
899
+ );
900
+ break;
901
+ }
902
+ accept(
903
+ 'error',
904
+ `Invalid ${key} value "${val}". Use a positive percent (e.g. 80%), a positive decimal fraction (e.g. 0.8), or "none" to opt out.`,
905
+ { node: prop, property: 'value' },
906
+ );
907
+ break;
908
+ }
909
+
910
+ case 'capacity-icon':
911
+ // Value-form rule (built-in / symbol id / string literal) and
912
+ // forward-reference rule are enforced together by
913
+ // checkSymbolReferences at file scope so style blocks and
914
+ // default-declaration property positions share one code path.
915
+ break;
916
+
917
+ case 'icon':
918
+ // Same handling as capacity-icon.
919
+ break;
920
+
921
+ default:
922
+ if (STYLE_PROP_KEYS.has(key)) {
923
+ if (key === 'bg' || key === 'fg' || key === 'text') {
924
+ if (val && !isColorValue(val)) {
925
+ accept(
926
+ 'error',
927
+ `Invalid color "${val}" for "${key}". Use a named color, hex value, or "none".`,
928
+ {
929
+ node: prop,
930
+ property: 'value',
931
+ },
932
+ );
933
+ }
934
+ } else if (key in STYLE_PROP_ENUMS) {
935
+ const allowed = STYLE_PROP_ENUMS[key];
936
+ if (val && !allowed.has(val) && !isColorValue(val)) {
937
+ accept(
938
+ 'error',
939
+ `Invalid value "${val}" for "${key}". Allowed: ${[...allowed].join(', ')}.`,
940
+ {
941
+ node: prop,
942
+ property: 'value',
943
+ },
944
+ );
945
+ }
946
+ }
947
+ }
948
+ break;
949
+ }
950
+ }
951
+
952
+ // --- Rule 11: Anchor requires date: ---
953
+ checkAnchorRequiredDate(anchor: AnchorDeclaration, accept: ValidationAcceptor): void {
954
+ const dateProp = anchor.properties.find((p) => propKey(p) === 'date');
955
+ if (!dateProp) {
956
+ acceptTr(accept, 'error', { node: anchor }, 'NL.E0500', { name: displayName(anchor) });
957
+ }
958
+ }
959
+
960
+ // --- R2 + R3: anchor must not precede roadmap start; dated roadmap requires start: ---
961
+ checkAnchorAgainstStart(anchor: AnchorDeclaration, accept: ValidationAcceptor): void {
962
+ const dateProp = anchor.properties.find((p) => propKey(p) === 'date');
963
+ if (!dateProp?.value) return;
964
+ const raw = dateProp.value;
965
+ if (!DATE_RE.test(raw)) return;
966
+ const anchorDate = new Date(raw);
967
+ if (Number.isNaN(anchorDate.getTime())) return;
968
+
969
+ const start = resolveLocalStart(anchor.$container);
970
+ switch (start.kind) {
971
+ case 'invalid':
972
+ return;
973
+ case 'missing':
974
+ acceptTr(accept, 'error', { node: dateProp, property: 'value' }, 'NL.E0501', {
975
+ name: displayName(anchor),
976
+ });
977
+ return;
978
+ case 'valid':
979
+ if (anchorDate < start.date) {
980
+ acceptTr(accept, 'error', { node: dateProp, property: 'value' }, 'NL.E0502', {
981
+ name: displayName(anchor),
982
+ date: raw,
983
+ start: start.iso,
984
+ });
985
+ }
986
+ return;
987
+ }
988
+ }
989
+
990
+ // --- Rule 12: Milestone requires date: or after: ---
991
+ checkMilestoneRequirement(milestone: MilestoneDeclaration, accept: ValidationAcceptor): void {
992
+ const hasDate = milestone.properties.some((p) => propKey(p) === 'date');
993
+ const hasAfter = milestone.properties.some((p) => propKey(p) === 'after');
994
+ if (!hasDate && !hasAfter) {
995
+ acceptTr(accept, 'error', { node: milestone }, 'NL.E0503', {
996
+ name: displayName(milestone),
997
+ });
998
+ }
999
+ }
1000
+
1001
+ // --- R2 + R3: dated milestone must not precede roadmap start ---
1002
+ checkMilestoneAgainstStart(milestone: MilestoneDeclaration, accept: ValidationAcceptor): void {
1003
+ const dateProp = milestone.properties.find((p) => propKey(p) === 'date');
1004
+ if (!dateProp?.value) return;
1005
+ const raw = dateProp.value;
1006
+ if (!DATE_RE.test(raw)) return;
1007
+ const milestoneDate = new Date(raw);
1008
+ if (Number.isNaN(milestoneDate.getTime())) return;
1009
+
1010
+ const start = resolveLocalStart(milestone.$container);
1011
+ switch (start.kind) {
1012
+ case 'invalid':
1013
+ return;
1014
+ case 'missing':
1015
+ accept(
1016
+ 'error',
1017
+ `Milestone "${displayName(milestone)}" has a date but the roadmap is missing "start:". Add start:YYYY-MM-DD to the roadmap.`,
1018
+ {
1019
+ node: dateProp,
1020
+ property: 'value',
1021
+ },
1022
+ );
1023
+ return;
1024
+ case 'valid':
1025
+ if (milestoneDate < start.date) {
1026
+ acceptTr(accept, 'error', { node: dateProp, property: 'value' }, 'NL.E0504', {
1027
+ name: displayName(milestone),
1028
+ date: raw,
1029
+ start: start.iso,
1030
+ });
1031
+ }
1032
+ return;
1033
+ }
1034
+ }
1035
+
1036
+ // --- Rule 13: Footnote requires on ---
1037
+ checkFootnoteOn(footnote: FootnoteDeclaration, accept: ValidationAcceptor): void {
1038
+ const hasOn = footnote.properties.some((p) => propKey(p) === 'on');
1039
+ if (!hasOn) {
1040
+ acceptTr(accept, 'error', { node: footnote }, 'NL.E0505');
1041
+ }
1042
+ }
1043
+
1044
+ // --- Rule 13a: `footnote:` is not a valid property on host entities ---
1045
+ // The spec attaches footnotes via the `on:` property on the footnote
1046
+ // declaration only; there is no forward `footnote:` property on the
1047
+ // referenced item / swimlane / etc. Surface a fix-it style error so
1048
+ // authors who reach for the (legacy, undocumented) forward form get
1049
+ // a clear nudge toward the spec-mandated direction.
1050
+ checkNoFootnoteProperty(
1051
+ node: {
1052
+ $type: string;
1053
+ properties: EntityProperty[];
1054
+ name?: string;
1055
+ title?: string;
1056
+ },
1057
+ accept: ValidationAcceptor,
1058
+ ): void {
1059
+ for (const prop of node.properties) {
1060
+ if (propKey(prop) === 'footnote') {
1061
+ accept(
1062
+ 'error',
1063
+ `Property "footnote:" is not valid on ${describeNode(node)}. ` +
1064
+ 'Footnotes attach via "on:" on the footnote declaration — ' +
1065
+ 'declare `footnote <id> on:<target-id>` instead.',
1066
+ { node: prop },
1067
+ );
1068
+ }
1069
+ }
1070
+ }
1071
+
1072
+ // --- Rule 10: Item requires either size: or duration: ---
1073
+ checkItemRequiredDuration(item: ItemDeclaration, accept: ValidationAcceptor): void {
1074
+ const hasDuration = item.properties.some((p) => propKey(p) === 'duration');
1075
+ const hasSize = item.properties.some((p) => propKey(p) === 'size');
1076
+ if (!hasDuration && !hasSize) {
1077
+ acceptTr(accept, 'error', { node: item }, 'NL.E0600', { name: displayName(item) });
1078
+ }
1079
+ }
1080
+
1081
+ // --- Parallel/group rule 3: size/duration/remaining/capacity not valid on parallel/group ---
1082
+ checkNoComputedProperties(node: ParallelBlock | GroupBlock, accept: ValidationAcceptor): void {
1083
+ for (const prop of node.properties) {
1084
+ const key = propKey(prop);
1085
+ if (key === 'size' || key === 'duration' || key === 'remaining' || key === 'capacity') {
1086
+ accept(
1087
+ 'error',
1088
+ `"${key}" is not valid on ${node.$type === 'ParallelBlock' ? 'parallel' : 'group'} (computed from children).`,
1089
+ {
1090
+ node: prop,
1091
+ },
1092
+ );
1093
+ }
1094
+ }
1095
+ }
1096
+
1097
+ // --- Parallel/group rule 1: Parallel requires >= 2 children ---
1098
+ checkParallelMinChildren(node: ParallelBlock, accept: ValidationAcceptor): void {
1099
+ const children = node.content.filter((c) => !isDescriptionDirective(c));
1100
+ if (children.length === 0) {
1101
+ accept('error', 'Parallel block must contain at least 2 children.', { node });
1102
+ } else if (children.length === 1) {
1103
+ accept(
1104
+ 'warning',
1105
+ 'Parallel block has only 1 child. Use at least 2 for parallel execution.',
1106
+ { node },
1107
+ );
1108
+ }
1109
+ }
1110
+
1111
+ // --- Parallel/group rule 2: Group requires >= 1 child ---
1112
+ checkGroupMinChildren(node: GroupBlock, accept: ValidationAcceptor): void {
1113
+ const children = node.content.filter((c) => !isDescriptionDirective(c));
1114
+ if (children.length === 0) {
1115
+ accept('error', 'Group must contain at least 1 child.', { node });
1116
+ }
1117
+ }
1118
+
1119
+ // --- Rule 20: Raw style properties banned on roadmap-section entities ---
1120
+ checkNoRawStyleProperties(
1121
+ node: {
1122
+ $type: string;
1123
+ properties: EntityProperty[];
1124
+ name?: string;
1125
+ title?: string;
1126
+ },
1127
+ accept: ValidationAcceptor,
1128
+ ): void {
1129
+ for (const prop of node.properties) {
1130
+ const key = propKey(prop);
1131
+ if (STYLE_PROP_KEYS.has(key)) {
1132
+ accept(
1133
+ 'error',
1134
+ `Raw style property "${key}" is not allowed on ${describeNode(node)}. ` +
1135
+ `Declare a named style in config and reference it via "style:id".`,
1136
+ { node: prop },
1137
+ );
1138
+ }
1139
+ }
1140
+ }
1141
+
1142
+ // Warn on property keys the runtime never reads. Severity is `warning` so a
1143
+ // file that happened to render with a bogus key (e.g. `roadmap r now:2026-01-25`)
1144
+ // keeps parsing — promoting to error would break files that work today even
1145
+ // though the bogus key was a no-op all along.
1146
+ //
1147
+ // Skips:
1148
+ // - raw style keys (already an error from `checkNoRawStyleProperties`)
1149
+ // - `footnote` (already an error from `checkNoFootnoteProperty`)
1150
+ // - sizing keys on parallel/group (already an error from `checkNoComputedProperties`)
1151
+ //
1152
+ // Suggests the closest known key within 2 edits when one exists, so the
1153
+ // existing `DID_YOU_MEAN_RE` in `packages/cli/src/diagnostics/adapt.ts` picks
1154
+ // it up as a structured suggestion alongside the rendered message.
1155
+ checkUnknownEntityProperties(
1156
+ node: {
1157
+ $type: string;
1158
+ properties: EntityProperty[];
1159
+ name?: string;
1160
+ title?: string;
1161
+ },
1162
+ accept: ValidationAcceptor,
1163
+ ): void {
1164
+ const known = ENTITY_KNOWN_PROPS[node.$type];
1165
+ if (!known) return;
1166
+ const computedBanned =
1167
+ node.$type === 'ParallelBlock' || node.$type === 'GroupBlock'
1168
+ ? new Set(['size', 'duration', 'remaining', 'capacity'])
1169
+ : null;
1170
+ for (const prop of node.properties) {
1171
+ const key = propKey(prop);
1172
+ if (known.has(key)) continue;
1173
+ if (UNIVERSAL_ENTITY_PROPS.has(key)) continue;
1174
+ if (STYLE_PROP_KEYS.has(key)) continue;
1175
+ if (key === 'footnote') continue;
1176
+ if (computedBanned?.has(key)) continue;
1177
+ const candidates: string[] = [...known, ...UNIVERSAL_ENTITY_PROPS];
1178
+ const suggested = suggestKey(key, candidates) ?? '';
1179
+ acceptTr(accept, 'warning', { node: prop, property: 'key' }, 'NL.W0700', {
1180
+ key,
1181
+ entity: describeNode(node),
1182
+ suggested,
1183
+ });
1184
+ }
1185
+ }
1186
+
1187
+ // --- Roadmap declaration specific property checks ---
1188
+ // Property-level validation of start:/date: lives in the generic EntityProperty check;
1189
+ // this hook is kept for future roadmap-scoped rules (and to make registration symmetric).
1190
+ checkRoadmapProperties(_roadmap: RoadmapDeclaration, _accept: ValidationAcceptor): void {
1191
+ // intentionally empty
1192
+ }
1193
+
1194
+ // --- Rule 18: Style property enum values ---
1195
+ // Value forms accepted by `icon:` and `capacity-icon:` (built-in identifier,
1196
+ // symbol name, or inline string literal) plus forward-reference resolution are
1197
+ // enforced by checkSymbolReferences at file scope so style blocks and
1198
+ // `default <entity>` lines share a single code path.
1199
+ checkStylePropertyEnum(prop: StyleProperty, accept: ValidationAcceptor): void {
1200
+ const key = propKey(prop);
1201
+ const val = prop.value;
1202
+
1203
+ if (key === 'bg' || key === 'fg' || key === 'text') {
1204
+ if (!isColorValue(val)) {
1205
+ accept(
1206
+ 'error',
1207
+ `Invalid color "${val}" for "${key}". Use a named color, hex value, or "none".`,
1208
+ {
1209
+ node: prop,
1210
+ property: 'value',
1211
+ },
1212
+ );
1213
+ }
1214
+ } else if (key in STYLE_PROP_ENUMS) {
1215
+ const allowed = STYLE_PROP_ENUMS[key];
1216
+ if (!allowed.has(val)) {
1217
+ accept(
1218
+ 'error',
1219
+ `Invalid value "${val}" for "${key}". Allowed: ${[...allowed].join(', ')}.`,
1220
+ {
1221
+ node: prop,
1222
+ property: 'value',
1223
+ },
1224
+ );
1225
+ }
1226
+ } else if (!STYLE_PROP_KEYS.has(key)) {
1227
+ accept('error', `Unknown style property "${key}".`, {
1228
+ node: prop,
1229
+ property: 'key',
1230
+ });
1231
+ }
1232
+ }
1233
+
1234
+ // --- Size declaration: rule 5 (effort: required), rule 4 (id format) ---
1235
+ checkSizeDeclaration(decl: SizeDeclaration, accept: ValidationAcceptor): void {
1236
+ const effortProp = decl.properties.find((p) => propKey(p) === 'effort');
1237
+ if (!effortProp) {
1238
+ accept('error', `Size "${displayName(decl)}" requires an "effort:" property.`, {
1239
+ node: decl,
1240
+ });
1241
+ }
1242
+
1243
+ if (decl.name) {
1244
+ if (DURATION_RE.test(decl.name) || BARE_DURATION_SUFFIX_RE.test(decl.name)) {
1245
+ accept(
1246
+ 'error',
1247
+ `Size id "${decl.name}" collides with the raw duration pattern. Choose a different name (e.g. "xs", "small", "quarter").`,
1248
+ { node: decl, property: 'name' },
1249
+ );
1250
+ }
1251
+ }
1252
+ }
1253
+
1254
+ // --- Status declaration: id collision with built-ins ---
1255
+ checkStatusDeclaration(decl: StatusDeclaration, accept: ValidationAcceptor): void {
1256
+ if (decl.name) {
1257
+ if (BUILTIN_STATUSES.has(decl.name)) {
1258
+ accept(
1259
+ 'error',
1260
+ `Status id "${decl.name}" collides with the built-in status value. Built-ins: ${[...BUILTIN_STATUSES].join(', ')}.`,
1261
+ { node: decl, property: 'name' },
1262
+ );
1263
+ }
1264
+ }
1265
+ }
1266
+
1267
+ // --- Rule 5: Duplicate size ids ---
1268
+ checkDuplicateSizeIds(file: NowlineFile, accept: ValidationAcceptor): void {
1269
+ const seen = new Map<string, SizeDeclaration>();
1270
+ for (const entry of file.roadmapEntries) {
1271
+ if (isSizeDeclaration(entry) && entry.name) {
1272
+ const existing = seen.get(entry.name);
1273
+ if (existing) {
1274
+ accept(
1275
+ 'error',
1276
+ `Duplicate size id "${entry.name}". First declared at ${locationOf(existing)}.`,
1277
+ { node: entry, property: 'name' },
1278
+ );
1279
+ } else {
1280
+ seen.set(entry.name, entry);
1281
+ }
1282
+ }
1283
+ }
1284
+ }
1285
+
1286
+ // --- Rule 17d (ordering half): utilization-warn-at <= utilization-over-at ---
1287
+ // Runs on both `SwimlaneDeclaration` and `DefaultDeclaration` (when its
1288
+ // entityType is `swimlane`). The value-form check in checkPropertyValues
1289
+ // has already run by the time this fires; this method only re-reads the
1290
+ // values and compares fractions when both are numeric. `none` on either
1291
+ // side opts that side out and skips the comparison (the spec treats the
1292
+ // two thresholds as independent — see specs/dsl.md rule 17d).
1293
+ checkUtilizationOrdering(
1294
+ decl: SwimlaneDeclaration | DefaultDeclaration,
1295
+ accept: ValidationAcceptor,
1296
+ ): void {
1297
+ if (isDefaultDeclaration(decl) && decl.entityType !== 'swimlane') return;
1298
+ const warnProp = decl.properties.find((p) => propKey(p) === 'utilization-warn-at');
1299
+ const overProp = decl.properties.find((p) => propKey(p) === 'utilization-over-at');
1300
+ if (!warnProp || !overProp) return;
1301
+ const warn = parseUtilizationFraction(warnProp.value);
1302
+ const over = parseUtilizationFraction(overProp.value);
1303
+ if (warn === null || over === null) return;
1304
+ if (warn > over) {
1305
+ accept(
1306
+ 'error',
1307
+ `utilization-warn-at (${warnProp.value}) must be ≤ utilization-over-at (${overProp.value}). Warn fires below over; if both fire at the same point, the warn band collapses to zero.`,
1308
+ { node: warnProp, property: 'value' },
1309
+ );
1310
+ }
1311
+ }
1312
+
1313
+ // --- Defaults rules 21-23: entity-type whitelist, duplicate-per-entity, banned props ---
1314
+ checkDefaultDeclaration(decl: DefaultDeclaration, accept: ValidationAcceptor): void {
1315
+ if (!DEFAULT_ENTITY_TYPES.has(decl.entityType)) {
1316
+ accept(
1317
+ 'error',
1318
+ `"${decl.entityType}" is not a supported entity type for default. Allowed: ${[...DEFAULT_ENTITY_TYPES].join(', ')}.`,
1319
+ { node: decl, property: 'entityType' },
1320
+ );
1321
+ return;
1322
+ }
1323
+
1324
+ // Duplicate default <entity> within the same file.
1325
+ const file = decl.$container;
1326
+ let firstIdx = -1;
1327
+ for (let i = 0; i < file.configEntries.length; i++) {
1328
+ const other = file.configEntries[i];
1329
+ if (isDefaultDeclaration(other) && other.entityType === decl.entityType) {
1330
+ if (firstIdx < 0) {
1331
+ firstIdx = i;
1332
+ } else if (other === decl) {
1333
+ accept(
1334
+ 'error',
1335
+ `Duplicate "default ${decl.entityType}" declaration. Only one is allowed per entity type per file.`,
1336
+ { node: decl },
1337
+ );
1338
+ break;
1339
+ }
1340
+ }
1341
+ }
1342
+
1343
+ const banned = DEFAULT_BANNED[decl.entityType];
1344
+ if (banned) {
1345
+ for (const prop of decl.properties) {
1346
+ const key = propKey(prop);
1347
+ if (banned.has(key)) {
1348
+ accept(
1349
+ 'error',
1350
+ `"${key}" cannot be set on "default ${decl.entityType}". Identity-defining, sizing, sequencing, reference, and prose properties must be explicit on each entity.`,
1351
+ { node: prop },
1352
+ );
1353
+ }
1354
+ }
1355
+ }
1356
+ }
1357
+
1358
+ // --- Rule 7 (calendar): calendar block only valid when roadmap calendar:custom ---
1359
+ // Rule 8: custom calendar requires all four fields.
1360
+ checkCalendarBlockConsistency(file: NowlineFile, accept: ValidationAcceptor): void {
1361
+ const calendarBlocks = file.configEntries.filter(isCalendarBlock);
1362
+ const calendarProp = file.roadmapDecl?.properties.find((p) => propKey(p) === 'calendar');
1363
+ const calendarMode = calendarProp?.value;
1364
+
1365
+ if (calendarBlocks.length > 0 && calendarMode !== 'custom') {
1366
+ for (const block of calendarBlocks) {
1367
+ accept(
1368
+ 'error',
1369
+ `A "calendar" config block is only valid when the roadmap declares calendar:custom.`,
1370
+ { node: block },
1371
+ );
1372
+ }
1373
+ }
1374
+
1375
+ if (calendarMode === 'custom' && calendarBlocks.length === 0) {
1376
+ accept(
1377
+ 'error',
1378
+ `calendar:custom requires a "calendar" config block with days-per-week, days-per-month, days-per-quarter, and days-per-year.`,
1379
+ { node: file.roadmapDecl!, property: 'properties' },
1380
+ );
1381
+ }
1382
+
1383
+ if (calendarMode === 'custom' && calendarBlocks.length > 0) {
1384
+ for (const block of calendarBlocks) {
1385
+ const presentKeys = new Set(block.properties.map((p) => propKey(p)));
1386
+ for (const field of CALENDAR_FIELDS) {
1387
+ if (!presentKeys.has(field)) {
1388
+ accept(
1389
+ 'error',
1390
+ `calendar:custom requires "${field}" in the calendar config block.`,
1391
+ { node: block },
1392
+ );
1393
+ }
1394
+ }
1395
+ }
1396
+ }
1397
+ }
1398
+
1399
+ // --- Rule 9: calendar block property values must be positive integers ---
1400
+ checkCalendarBlock(block: CalendarBlock, accept: ValidationAcceptor): void {
1401
+ const seen = new Set<string>();
1402
+ for (const prop of block.properties) {
1403
+ const key = propKey(prop);
1404
+ if (!CALENDAR_FIELDS.has(key)) {
1405
+ accept(
1406
+ 'error',
1407
+ `Unknown calendar property "${key}". Allowed: ${[...CALENDAR_FIELDS].join(', ')}.`,
1408
+ { node: prop, property: 'key' },
1409
+ );
1410
+ continue;
1411
+ }
1412
+ if (seen.has(key)) {
1413
+ accept('error', `Duplicate calendar property "${key}".`, {
1414
+ node: prop,
1415
+ property: 'key',
1416
+ });
1417
+ }
1418
+ seen.add(key);
1419
+
1420
+ if (!INTEGER_RE.test(prop.value) || parseInt(prop.value, 10) <= 0) {
1421
+ accept('error', `"${key}" must be a positive integer, got "${prop.value}".`, {
1422
+ node: prop,
1423
+ property: 'value',
1424
+ });
1425
+ }
1426
+ }
1427
+ }
1428
+
1429
+ // --- Scale block property validation ---
1430
+ checkScaleBlock(block: ScaleBlock, accept: ValidationAcceptor): void {
1431
+ const seen = new Set<string>();
1432
+ for (const prop of block.properties) {
1433
+ const key = propKey(prop);
1434
+ if (!SCALE_FIELDS.has(key)) {
1435
+ accept(
1436
+ 'error',
1437
+ `Unknown scale property "${key}". Allowed: ${[...SCALE_FIELDS].join(', ')}.`,
1438
+ { node: prop, property: 'key' },
1439
+ );
1440
+ continue;
1441
+ }
1442
+ if (seen.has(key)) {
1443
+ accept('error', `Duplicate scale property "${key}".`, {
1444
+ node: prop,
1445
+ property: 'key',
1446
+ });
1447
+ }
1448
+ seen.add(key);
1449
+
1450
+ if (key === 'label-every') {
1451
+ if (!INTEGER_RE.test(prop.value) || parseInt(prop.value, 10) <= 0) {
1452
+ accept(
1453
+ 'error',
1454
+ `"label-every" must be a positive integer, got "${prop.value}".`,
1455
+ { node: prop, property: 'value' },
1456
+ );
1457
+ }
1458
+ }
1459
+ }
1460
+ }
1461
+
1462
+ // --- Rule 15: size:/status: references resolve to earlier declarations ---
1463
+ checkForwardReferences(file: NowlineFile, accept: ValidationAcceptor): void {
1464
+ // Declarations in source order across the file.
1465
+ const sizeOrder = new Map<string, number>();
1466
+ const statusOrder = new Map<string, number>();
1467
+
1468
+ for (let i = 0; i < file.roadmapEntries.length; i++) {
1469
+ const entry = file.roadmapEntries[i];
1470
+ if (isSizeDeclaration(entry) && entry.name) {
1471
+ if (!sizeOrder.has(entry.name)) sizeOrder.set(entry.name, i);
1472
+ } else if (isStatusDeclaration(entry) && entry.name) {
1473
+ if (!statusOrder.has(entry.name)) statusOrder.set(entry.name, i);
1474
+ }
1475
+ }
1476
+
1477
+ for (let i = 0; i < file.roadmapEntries.length; i++) {
1478
+ const entry = file.roadmapEntries[i];
1479
+ visitPropertiesDeep(entry, (prop) => {
1480
+ const key = propKey(prop);
1481
+ if (key === 'size' && prop.value) {
1482
+ const val = prop.value;
1483
+ const declIdx = sizeOrder.get(val);
1484
+ if (declIdx === undefined) {
1485
+ accept(
1486
+ 'error',
1487
+ `Size "${val}" is not declared. Add "size ${val} effort:<literal>" earlier in the roadmap section.`,
1488
+ { node: prop, property: 'value' },
1489
+ );
1490
+ } else if (declIdx >= i) {
1491
+ accept(
1492
+ 'error',
1493
+ `Size "${val}" is referenced before its declaration. Move "size ${val}" above this entity.`,
1494
+ { node: prop, property: 'value' },
1495
+ );
1496
+ }
1497
+ } else if (key === 'status' && prop.value) {
1498
+ const val = prop.value;
1499
+ if (BUILTIN_STATUSES.has(val)) return;
1500
+ const declIdx = statusOrder.get(val);
1501
+ if (declIdx === undefined) {
1502
+ accept(
1503
+ 'error',
1504
+ `Status "${val}" is not a built-in and has no declaration. Add "status ${val}" earlier in the roadmap section.`,
1505
+ { node: prop, property: 'value' },
1506
+ );
1507
+ } else if (declIdx >= i) {
1508
+ accept(
1509
+ 'error',
1510
+ `Status "${val}" is referenced before its declaration. Move "status ${val}" above this entity.`,
1511
+ { node: prop, property: 'value' },
1512
+ );
1513
+ }
1514
+ }
1515
+ });
1516
+ }
1517
+ }
1518
+
1519
+ // --- Rules 24/1 (reference): after/before/on must resolve to declared ids ---
1520
+ // `owner:` is intentionally NOT checked here. Per specs/dsl.md ("Declarations
1521
+ // are optional"), `owner:sam` is valid even if no `person sam` declaration
1522
+ // exists — it renders as the bare id. Sequencing properties (`after`,
1523
+ // `before`, `on`) stay strict because a phantom dependency silently breaks
1524
+ // the timeline / footnote target.
1525
+ checkReferenceResolution(file: NowlineFile, accept: ValidationAcceptor): void {
1526
+ const declaredIds = collectReferenceableIds(file);
1527
+
1528
+ const visit = (entry: RoadmapEntry) => {
1529
+ visitPropertiesDeep(entry, (prop) => {
1530
+ const key = propKey(prop);
1531
+ if (key !== 'after' && key !== 'before' && key !== 'on') return;
1532
+ const vals = prop.value ? [prop.value] : prop.values;
1533
+ for (const v of vals) {
1534
+ if (!v) continue;
1535
+ if (!declaredIds.has(v)) {
1536
+ accept(
1537
+ 'error',
1538
+ `${key}: reference "${v}" does not resolve to any declared entity in this file.`,
1539
+ { node: prop },
1540
+ );
1541
+ }
1542
+ }
1543
+ });
1544
+ };
1545
+
1546
+ for (const entry of file.roadmapEntries) {
1547
+ visit(entry);
1548
+ }
1549
+ }
1550
+
1551
+ // --- Rule 25: circular dependencies in after/before graph ---
1552
+ checkCircularDependencies(file: NowlineFile, accept: ValidationAcceptor): void {
1553
+ // Build forward-edge graph: id -> set of ids that must finish before id can start.
1554
+ // `after:x` on entity y means y depends on x (edge y -> x).
1555
+ // `before:y` on entity x means x must finish before y starts, so y depends on x (edge y -> x).
1556
+ const deps = new Map<string, Set<string>>();
1557
+ const addDep = (node: string, dep: string) => {
1558
+ if (!deps.has(node)) deps.set(node, new Set());
1559
+ deps.get(node)!.add(dep);
1560
+ };
1561
+
1562
+ const indexDependents = (
1563
+ idName: string | undefined,
1564
+ props: EntityProperty[],
1565
+ file: NowlineFile,
1566
+ ) => {
1567
+ if (!idName) return;
1568
+ for (const prop of props) {
1569
+ const key = propKey(prop);
1570
+ if (key === 'after') {
1571
+ const refs = prop.value ? [prop.value] : prop.values;
1572
+ for (const r of refs) if (r) addDep(idName, r);
1573
+ } else if (key === 'before') {
1574
+ const refs = prop.value ? [prop.value] : prop.values;
1575
+ for (const r of refs) if (r) addDep(r, idName);
1576
+ }
1577
+ }
1578
+ void file;
1579
+ };
1580
+
1581
+ const visitEntry = (entry: AstNode): void => {
1582
+ const id = (entry as { name?: string }).name;
1583
+ const props = (entry as { properties?: EntityProperty[] }).properties ?? [];
1584
+ indexDependents(id, props, file);
1585
+
1586
+ if (isSwimlaneDeclaration(entry)) {
1587
+ for (const c of entry.content) visitEntry(c);
1588
+ } else if (isParallelBlock(entry) || isGroupBlock(entry)) {
1589
+ for (const c of entry.content) visitEntry(c);
1590
+ }
1591
+ };
1592
+
1593
+ for (const entry of file.roadmapEntries) visitEntry(entry);
1594
+
1595
+ // DFS for cycles.
1596
+ const WHITE = 0,
1597
+ GRAY = 1,
1598
+ BLACK = 2;
1599
+ const color = new Map<string, number>();
1600
+ for (const id of deps.keys()) color.set(id, WHITE);
1601
+
1602
+ const reported = new Set<string>();
1603
+ const dfs = (node: string, path: string[]): void => {
1604
+ color.set(node, GRAY);
1605
+ path.push(node);
1606
+ for (const dep of deps.get(node) ?? []) {
1607
+ const c = color.get(dep) ?? WHITE;
1608
+ if (c === GRAY) {
1609
+ const cycleStart = path.indexOf(dep);
1610
+ const cycle = path.slice(cycleStart).concat(dep);
1611
+ const key = [...cycle].sort().join('→');
1612
+ if (!reported.has(key)) {
1613
+ reported.add(key);
1614
+ accept('error', `Circular dependency detected: ${cycle.join(' → ')}.`, {
1615
+ node: file.roadmapDecl ?? file,
1616
+ });
1617
+ }
1618
+ } else if (c === WHITE) {
1619
+ dfs(dep, path);
1620
+ }
1621
+ }
1622
+ path.pop();
1623
+ color.set(node, BLACK);
1624
+ };
1625
+
1626
+ for (const node of deps.keys()) {
1627
+ if ((color.get(node) ?? WHITE) === WHITE) dfs(node, []);
1628
+ }
1629
+ }
1630
+
1631
+ // --- Rules 30-32: Person declare-once + bare top-level warning ---
1632
+ checkPersonDeclarations(file: NowlineFile, accept: ValidationAcceptor): void {
1633
+ type DeclSite = { node: PersonDeclaration; isDeclaration: boolean };
1634
+ const declarations = new Map<string, DeclSite[]>();
1635
+
1636
+ const isRealDeclaration = (p: PersonDeclaration): boolean => {
1637
+ return Boolean(p.title) || p.properties.length > 0 || p.description !== undefined;
1638
+ };
1639
+
1640
+ const visitPerson = (p: PersonDeclaration) => {
1641
+ if (!p.name) return;
1642
+ const isDecl = isRealDeclaration(p);
1643
+ if (!declarations.has(p.name)) declarations.set(p.name, []);
1644
+ declarations.get(p.name)!.push({ node: p, isDeclaration: isDecl });
1645
+ if (!isDecl && p.$container.$type === 'NowlineFile') {
1646
+ accept(
1647
+ 'warning',
1648
+ `Bare "person ${p.name}" at roadmap top level has no declaration. Either add properties (title, link, etc.) or remove the line.`,
1649
+ { node: p },
1650
+ );
1651
+ }
1652
+ };
1653
+
1654
+ const visitTeam = (t: TeamDeclaration) => {
1655
+ for (const c of t.content) {
1656
+ if (isPersonDeclaration(c)) visitPerson(c);
1657
+ else if (isTeamDeclaration(c)) visitTeam(c);
1658
+ }
1659
+ };
1660
+
1661
+ for (const entry of file.roadmapEntries) {
1662
+ if (isPersonDeclaration(entry)) visitPerson(entry);
1663
+ else if (isTeamDeclaration(entry)) visitTeam(entry);
1664
+ }
1665
+
1666
+ for (const [name, sites] of declarations) {
1667
+ const decls = sites.filter((s) => s.isDeclaration);
1668
+ if (decls.length > 1) {
1669
+ for (let i = 1; i < decls.length; i++) {
1670
+ accept(
1671
+ 'error',
1672
+ `Person "${name}" is declared more than once. First declaration at ${locationOf(decls[0].node)}.`,
1673
+ { node: decls[i].node },
1674
+ );
1675
+ }
1676
+ }
1677
+ }
1678
+ }
1679
+
1680
+ // --- Rules 17f / 17g / 17h / 17i: per-declaration symbol checks ---
1681
+ // Note: Langium's default ValueConverter strips surrounding quotes from STRING
1682
+ // tokens before they reach the AST, so unicode:"💰" arrives here as just "💰"
1683
+ // — we validate the *content* (length / ASCII range) rather than presence of
1684
+ // quotes. The grammar already restricts `unicode:` and `ascii:` to PropertyAtom,
1685
+ // so the only quoteless form an author can pass is a bare identifier like
1686
+ // `unicode:foo`, which we treat permissively (it's a single-grapheme literal).
1687
+ checkSymbolDeclaration(decl: SymbolDeclaration, accept: ValidationAcceptor): void {
1688
+ if (decl.name && BUILTIN_ICON_NAMES.has(decl.name)) {
1689
+ accept(
1690
+ 'error',
1691
+ `Symbol id "${decl.name}" collides with a built-in icon name. Reserved built-ins: ${[...BUILTIN_ICON_NAMES].sort().join(', ')}.`,
1692
+ { node: decl, property: 'name' },
1693
+ );
1694
+ }
1695
+
1696
+ const unicodeProp = decl.properties.find((p) => propKey(p) === 'unicode');
1697
+ if (!unicodeProp) {
1698
+ accept(
1699
+ 'error',
1700
+ `Symbol "${displayName(decl)}" requires a "unicode:" property (e.g. unicode:"💰" or unicode:"\\u{1F464}").`,
1701
+ { node: decl },
1702
+ );
1703
+ } else if (!unicodeProp.value || unicodeProp.value.length === 0) {
1704
+ accept('error', `Symbol "${displayName(decl)}" unicode: must be a non-empty value.`, {
1705
+ node: unicodeProp,
1706
+ property: 'value',
1707
+ });
1708
+ }
1709
+
1710
+ const asciiProp = decl.properties.find((p) => propKey(p) === 'ascii');
1711
+ if (asciiProp) {
1712
+ const raw = asciiProp.value ?? '';
1713
+ if (!ASCII_FALLBACK_RE.test(raw)) {
1714
+ accept(
1715
+ 'error',
1716
+ `Symbol "${displayName(decl)}" ascii: must be 1-3 ASCII characters (got ${raw.length} character${raw.length === 1 ? '' : 's'}).`,
1717
+ { node: asciiProp, property: 'value' },
1718
+ );
1719
+ }
1720
+ }
1721
+
1722
+ for (const prop of decl.properties) {
1723
+ const key = propKey(prop);
1724
+ if (key !== 'unicode' && key !== 'ascii' && key !== 'link' && key !== 'description') {
1725
+ accept(
1726
+ 'error',
1727
+ `Unknown symbol property "${key}". Allowed: unicode, ascii, link, description.`,
1728
+ { node: prop, property: 'key' },
1729
+ );
1730
+ }
1731
+ }
1732
+ }
1733
+
1734
+ // --- Rule 17j: duplicate symbol ids in the same file ---
1735
+ checkDuplicateSymbolIds(file: NowlineFile, accept: ValidationAcceptor): void {
1736
+ const seen = new Map<string, SymbolDeclaration>();
1737
+ for (const entry of file.configEntries) {
1738
+ if (isSymbolDeclaration(entry) && entry.name) {
1739
+ const existing = seen.get(entry.name);
1740
+ if (existing) {
1741
+ accept(
1742
+ 'error',
1743
+ `Duplicate symbol id "${entry.name}". First declared at ${locationOf(existing)}.`,
1744
+ { node: entry, property: 'name' },
1745
+ );
1746
+ } else {
1747
+ seen.set(entry.name, entry);
1748
+ }
1749
+ }
1750
+ }
1751
+ }
1752
+
1753
+ // --- Rule 17k: icon: / capacity-icon: references resolve to a built-in,
1754
+ // a quoted Unicode literal, or an earlier symbol declaration. Forward
1755
+ // references are an error. Walks both StyleDeclaration.properties and
1756
+ // DefaultDeclaration.properties so style blocks and default <entity>
1757
+ // lines share one path.
1758
+ checkSymbolReferences(file: NowlineFile, accept: ValidationAcceptor): void {
1759
+ const symbolOrder = new Map<string, number>();
1760
+ for (let i = 0; i < file.configEntries.length; i++) {
1761
+ const entry = file.configEntries[i];
1762
+ if (isSymbolDeclaration(entry) && entry.name) {
1763
+ if (!symbolOrder.has(entry.name)) symbolOrder.set(entry.name, i);
1764
+ }
1765
+ }
1766
+
1767
+ const checkRef = (
1768
+ entryIdx: number,
1769
+ key: string,
1770
+ val: string | undefined,
1771
+ propNode: AstNode,
1772
+ ) => {
1773
+ if (key !== 'icon' && key !== 'capacity-icon') return;
1774
+ if (!val) return;
1775
+ // Inline Unicode literals (`capacity-icon:"💰"`) reach the AST as their
1776
+ // unquoted content — the only way to tell them from an identifier
1777
+ // reference is by checking the character set. Anything that doesn't
1778
+ // look like an identifier is treated as a literal and
1779
+ // accepted as-is. Authors who genuinely want to write a literal that
1780
+ // happens to spell a real identifier should declare a symbol instead.
1781
+ if (!isIdentifier(val)) return;
1782
+ if (key === 'capacity-icon' && BUILTIN_CAPACITY_ICONS.has(val)) return;
1783
+ if (key === 'icon' && BUILTIN_ICON_NAMES.has(val)) return;
1784
+ const declIdx = symbolOrder.get(val);
1785
+ if (declIdx === undefined) {
1786
+ const builtins =
1787
+ key === 'capacity-icon'
1788
+ ? [...BUILTIN_CAPACITY_ICONS].sort().join(', ')
1789
+ : [...BUILTIN_ICON_NAMES].sort().join(', ');
1790
+ accept(
1791
+ 'error',
1792
+ `${key}: "${val}" is neither a built-in (${builtins}) nor a declared symbol. Add "symbol ${val} unicode:..." earlier in config or use a quoted Unicode literal.`,
1793
+ { node: propNode, property: 'value' },
1794
+ );
1795
+ } else if (declIdx >= entryIdx) {
1796
+ accept(
1797
+ 'error',
1798
+ `${key}: symbol "${val}" is referenced before its declaration. Move "symbol ${val}" above this entry.`,
1799
+ { node: propNode, property: 'value' },
1800
+ );
1801
+ }
1802
+ };
1803
+
1804
+ for (let i = 0; i < file.configEntries.length; i++) {
1805
+ const entry = file.configEntries[i];
1806
+ if (isStyleDeclaration(entry)) {
1807
+ for (const sp of entry.properties) {
1808
+ checkRef(i, propKey(sp), sp.value, sp);
1809
+ }
1810
+ } else if (isDefaultDeclaration(entry)) {
1811
+ for (const ep of entry.properties) {
1812
+ checkRef(i, propKey(ep), ep.value, ep);
1813
+ }
1814
+ }
1815
+ }
1816
+ }
1817
+ }
1818
+
1819
+ // --- Helpers ---
1820
+
1821
+ function isColorValue(val: string): boolean {
1822
+ return COLOR_NAMES.has(val) || HEX_COLOR_RE.test(val);
1823
+ }
1824
+
1825
+ function isIdentifier(val: string): boolean {
1826
+ return /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(val);
1827
+ }
1828
+
1829
+ type CapacityParentKind = 'lane' | 'item' | 'invalid';
1830
+
1831
+ function capacityParentKind(parent: AstNode | undefined): CapacityParentKind {
1832
+ if (!parent) return 'invalid';
1833
+ if (isSwimlaneDeclaration(parent)) return 'lane';
1834
+ if (isItemDeclaration(parent)) return 'item';
1835
+ if (isDefaultDeclaration(parent)) {
1836
+ return parent.entityType === 'swimlane' ? 'lane' : 'item';
1837
+ }
1838
+ return 'invalid';
1839
+ }
1840
+
1841
+ function isUtilizationAllowedHere(parent: AstNode | undefined): boolean {
1842
+ if (!parent) return false;
1843
+ if (isSwimlaneDeclaration(parent)) return true;
1844
+ if (isDefaultDeclaration(parent) && parent.entityType === 'swimlane') return true;
1845
+ return false;
1846
+ }
1847
+
1848
+ /**
1849
+ * Parse a numeric utilization-threshold value into a fraction. Mirrors the
1850
+ * accepted value forms in checkPropertyValues (case `utilization-warn-at` /
1851
+ * `utilization-over-at`):
1852
+ *
1853
+ * - `'none'` → null (opt-out; ordering check skips this side).
1854
+ * - positive percent (`80%`) → 0.8.
1855
+ * - positive decimal fraction (`0.8`) → 0.8 (or `1.25` → 1.25 for the
1856
+ * intentionally-stretched-over case `utilization-over-at:125%`).
1857
+ * - bare integer or anything else → null. Bare integers are rejected with
1858
+ * a disambiguation hint by the value-form check before this helper runs;
1859
+ * returning null here makes the ordering check skip that side instead of
1860
+ * double-reporting.
1861
+ */
1862
+ function parseUtilizationFraction(val: string | undefined): number | null {
1863
+ if (!val || val === UTILIZATION_NONE) return null;
1864
+ if (POSITIVE_PERCENT_RE.test(val)) {
1865
+ const n = parseFloat(val);
1866
+ return Number.isFinite(n) && n > 0 ? n / 100 : null;
1867
+ }
1868
+ if (POSITIVE_DECIMAL_FRACTION_RE.test(val)) {
1869
+ const n = parseFloat(val);
1870
+ return Number.isFinite(n) && n > 0 ? n : null;
1871
+ }
1872
+ return null;
1873
+ }
1874
+
1875
+ function locationOf(node: AstNode): string {
1876
+ const cst = node.$cstNode;
1877
+ if (cst) {
1878
+ return `line ${cst.range.start.line + 1}`;
1879
+ }
1880
+ return 'unknown location';
1881
+ }
1882
+
1883
+ function describeNode(node: { $type: string; name?: string; title?: string }): string {
1884
+ const kind = node.$type.replace(/Declaration$|Block$/, '').toLowerCase();
1885
+ const label = node.name ?? node.title;
1886
+ return label ? `${kind} "${label}"` : kind;
1887
+ }
1888
+
1889
+ function registerEntity(
1890
+ entry: RoadmapEntry,
1891
+ register: (name: string | undefined, node: AstNode) => void,
1892
+ ): void {
1893
+ if (isSwimlaneDeclaration(entry)) {
1894
+ register(entry.name, entry);
1895
+ for (const child of entry.content) {
1896
+ registerSwimlaneContent(child, register);
1897
+ }
1898
+ } else if (isPersonDeclaration(entry)) {
1899
+ register(entry.name, entry);
1900
+ } else if (isTeamDeclaration(entry)) {
1901
+ registerTeam(entry, register);
1902
+ } else if (isAnchorDeclaration(entry)) {
1903
+ register(entry.name, entry);
1904
+ } else if (isMilestoneDeclaration(entry)) {
1905
+ register(entry.name, entry);
1906
+ } else if (isFootnoteDeclaration(entry)) {
1907
+ register(entry.name, entry);
1908
+ } else if (isLabelDeclaration(entry)) {
1909
+ register(entry.name, entry);
1910
+ } else if (isSizeDeclaration(entry)) {
1911
+ register(entry.name, entry);
1912
+ } else if (isStatusDeclaration(entry)) {
1913
+ register(entry.name, entry);
1914
+ }
1915
+ }
1916
+
1917
+ function registerTeam(
1918
+ team: TeamDeclaration,
1919
+ register: (name: string | undefined, node: AstNode) => void,
1920
+ ): void {
1921
+ register(team.name, team);
1922
+ for (const member of team.content) {
1923
+ if (isTeamDeclaration(member)) {
1924
+ registerTeam(member, register);
1925
+ }
1926
+ // PersonMemberRef and bare person <id> are references, not declarations — skip.
1927
+ }
1928
+ }
1929
+
1930
+ function registerSwimlaneContent(
1931
+ child: SwimlaneContent | GroupContent | ParallelContent,
1932
+ register: (name: string | undefined, node: AstNode) => void,
1933
+ ): void {
1934
+ if (isItemDeclaration(child)) {
1935
+ register(child.name, child);
1936
+ } else if (isParallelBlock(child)) {
1937
+ register(child.name, child);
1938
+ for (const pc of child.content) {
1939
+ registerSwimlaneContent(pc, register);
1940
+ }
1941
+ } else if (isGroupBlock(child)) {
1942
+ register(child.name, child);
1943
+ for (const gc of child.content) {
1944
+ registerSwimlaneContent(gc, register);
1945
+ }
1946
+ }
1947
+ }
1948
+
1949
+ function visitPropertiesDeep(node: AstNode, visit: (prop: EntityProperty) => void): void {
1950
+ const walk = (n: AstNode) => {
1951
+ const props = (n as unknown as { properties?: EntityProperty[] }).properties;
1952
+ if (Array.isArray(props)) {
1953
+ for (const p of props) visit(p);
1954
+ }
1955
+ if (isSwimlaneDeclaration(n)) {
1956
+ for (const c of n.content) walk(c);
1957
+ } else if (isParallelBlock(n) || isGroupBlock(n)) {
1958
+ for (const c of n.content) walk(c);
1959
+ } else if (isTeamDeclaration(n)) {
1960
+ for (const c of n.content) {
1961
+ if (isTeamDeclaration(c) || isPersonDeclaration(c)) walk(c);
1962
+ }
1963
+ }
1964
+ };
1965
+ walk(node);
1966
+ }
1967
+
1968
+ function collectReferenceableIds(file: NowlineFile): Set<string> {
1969
+ const ids = new Set<string>();
1970
+ if (file.roadmapDecl?.name) ids.add(file.roadmapDecl.name);
1971
+
1972
+ const addEntry = (entry: AstNode): void => {
1973
+ const name = (entry as { name?: string }).name;
1974
+ if (name) ids.add(name);
1975
+ if (isSwimlaneDeclaration(entry)) {
1976
+ for (const c of entry.content) addEntry(c);
1977
+ } else if (isParallelBlock(entry) || isGroupBlock(entry)) {
1978
+ for (const c of entry.content) addEntry(c);
1979
+ } else if (isTeamDeclaration(entry)) {
1980
+ for (const c of entry.content) {
1981
+ if (isTeamDeclaration(c) || isPersonDeclaration(c)) addEntry(c);
1982
+ else if (isPersonMemberRef(c)) {
1983
+ // Don't register member refs — they reference persons declared elsewhere.
1984
+ }
1985
+ }
1986
+ }
1987
+ };
1988
+
1989
+ for (const entry of file.roadmapEntries) addEntry(entry);
1990
+ return ids;
1991
+ }