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