todoosy 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,518 @@
1
+ "use strict";
2
+ /**
3
+ * Todoosy Linter
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.lint = lint;
7
+ exports.lintScheme = lintScheme;
8
+ const parser_js_1 = require("./parser.js");
9
+ const VALID_DATE_FORMATS = [
10
+ /^\d{4}-\d{1,2}-\d{1,2}$/, // YYYY-MM-DD or YYYY-M-D
11
+ /^\d{2}-\d{1,2}-\d{1,2}$/, // YY-MM-DD or YY-M-D
12
+ /^\d{4}\/\d{1,2}\/\d{1,2}$/, // YYYY/MM/DD
13
+ /^\d{2}\/\d{1,2}\/\d{1,2}$/, // YY/MM/DD or MM/DD/YY (ambiguous but accepted)
14
+ /^\d{1,2}\/\d{1,2}\/\d{4}$/, // MM/DD/YYYY or DD/MM/YYYY
15
+ /^\d{1,2}\/\d{1,2}\/\d{2}$/, // MM/DD/YY or DD/MM/YY
16
+ ];
17
+ const MONTH_NAMES = new Set([
18
+ 'january', 'jan',
19
+ 'february', 'feb',
20
+ 'march', 'mar',
21
+ 'april', 'apr',
22
+ 'may',
23
+ 'june', 'jun',
24
+ 'july', 'jul',
25
+ 'august', 'aug',
26
+ 'september', 'sep',
27
+ 'october', 'oct',
28
+ 'november', 'nov',
29
+ 'december', 'dec',
30
+ ]);
31
+ // Regex for text dates: Month Day [Year] or Day Month [Year] (Year can be 2 or 4 digits)
32
+ const TEXT_DATE_REGEX = /^(january|jan|february|feb|march|mar|april|apr|may|june|jun|july|jul|august|aug|september|sep|october|oct|november|nov|december|dec)\s+(\d{1,2})(?:\s+(\d{2,4}))?$/i;
33
+ const TEXT_DATE_DAY_FIRST_REGEX = /^(\d{1,2})\s+(january|jan|february|feb|march|mar|april|apr|may|june|jun|july|jul|august|aug|september|sep|october|oct|november|nov|december|dec)(?:\s+(\d{2,4}))?$/i;
34
+ const VALID_CALENDAR_FORMATS = new Set(['yyyy-mm-dd', 'yyyy/mm/dd', 'mm/dd/yyyy', 'dd/mm/yyyy']);
35
+ const VALID_FORMATTING_STYLES = new Set(['roomy', 'balanced', 'tight']);
36
+ const PRIORITY_REGEX = /\bp(\d+)\b/gi;
37
+ const ESTIMATE_REGEX = /\b(\d+)([mhd])\b/gi;
38
+ const INVALID_PRIORITY_REGEX = /\bp([^0-9\s)][^\s)]*)\b/gi;
39
+ const INVALID_ESTIMATE_REGEX = /\b(\d+)([^mhd\s)0-9][^\s)]*)\b/gi;
40
+ const VALID_HASHTAG_REGEX = /^#[a-zA-Z][a-zA-Z0-9_-]*$/;
41
+ const HASHTAG_LIKE_REGEX = /#[^\s,)]*/g;
42
+ // Built-in progress states (normalized to lowercase)
43
+ const PROGRESS_STATES = new Set(['done', 'deleted', 'in progress', 'blocked', 'progress']);
44
+ function isValidDate(dateStr) {
45
+ // Strip soft date prefix (~) for validation
46
+ const normalizedDateStr = dateStr.startsWith('~') ? dateStr.slice(1) : dateStr;
47
+ // Check standard formats
48
+ if (VALID_DATE_FORMATS.some(regex => regex.test(normalizedDateStr))) {
49
+ return true;
50
+ }
51
+ // Check text date format (Month Day [Year])
52
+ if (TEXT_DATE_REGEX.test(normalizedDateStr)) {
53
+ const match = normalizedDateStr.match(TEXT_DATE_REGEX);
54
+ if (match) {
55
+ const day = parseInt(match[2], 10);
56
+ return day >= 1 && day <= 31;
57
+ }
58
+ }
59
+ // Check text date format (Day Month [Year])
60
+ if (TEXT_DATE_DAY_FIRST_REGEX.test(normalizedDateStr)) {
61
+ const match = normalizedDateStr.match(TEXT_DATE_DAY_FIRST_REGEX);
62
+ if (match) {
63
+ const day = parseInt(match[1], 10);
64
+ return day >= 1 && day <= 31;
65
+ }
66
+ }
67
+ return false;
68
+ }
69
+ function parseMiscLocation(misc) {
70
+ const slashIndex = misc.indexOf('/');
71
+ if (slashIndex === -1) {
72
+ return { filename: misc, heading: 'Misc' };
73
+ }
74
+ return {
75
+ filename: misc.substring(0, slashIndex),
76
+ heading: misc.substring(slashIndex + 1),
77
+ };
78
+ }
79
+ function lint(text, scheme, filename) {
80
+ const { ast } = (0, parser_js_1.parse)(text);
81
+ const warnings = [];
82
+ const lines = text.split('\n');
83
+ // Determine misc location from scheme or use default
84
+ const miscLocation = parseMiscLocation(scheme?.misc ?? 'todoosy.md/Misc');
85
+ // If no filename provided, assume it could be the misc file (backward compatibility)
86
+ const isMiscFile = filename === undefined || filename === miscLocation.filename;
87
+ let miscSectionLine = null;
88
+ let miscSectionSpan = null;
89
+ const headingsAfterMisc = [];
90
+ // Check each item
91
+ for (const item of ast.items) {
92
+ const lineIndex = item.line - 1;
93
+ const rawLine = item.raw_line;
94
+ // Check for Misc section (only relevant for the misc file)
95
+ if (isMiscFile && item.type === 'heading') {
96
+ if (miscSectionLine !== null) {
97
+ // Any heading after Misc (including duplicate Misc) is an error
98
+ headingsAfterMisc.push({ line: item.line, span: item.item_span });
99
+ }
100
+ else if (item.title_text === miscLocation.heading && item.level === 1) {
101
+ miscSectionLine = item.line;
102
+ miscSectionSpan = item.item_span;
103
+ }
104
+ }
105
+ // Check for invalid date formats in parentheses
106
+ const parenMatches = rawLine.matchAll(/\(([^)]+)\)/g);
107
+ for (const match of parenMatches) {
108
+ const content = match[1];
109
+ const parenStart = item.item_span[0] + (match.index || 0);
110
+ // Check for due dates - try text date format first (captures more), then standard format
111
+ // Text date: due [~]Month Day [Year] (Year can be 2 or 4 digits, ~ for soft dates)
112
+ const textDueMatches = content.matchAll(/\bdue\s+(~?(?:january|jan|february|feb|march|mar|april|apr|may|june|jun|july|jul|august|aug|september|sep|october|oct|november|nov|december|dec)\s+\d{1,2}(?:\s+\d{2,4})?)/gi);
113
+ const textDueIndices = new Set();
114
+ for (const textDueMatch of textDueMatches) {
115
+ textDueIndices.add(textDueMatch.index || 0);
116
+ const dateStr = textDueMatch[1];
117
+ if (!isValidDate(dateStr)) {
118
+ const tokenStart = parenStart + 1 + (textDueMatch.index || 0);
119
+ warnings.push({
120
+ code: 'INVALID_DATE_FORMAT',
121
+ message: `Invalid due date format: ${dateStr}`,
122
+ line: item.line,
123
+ column: tokenStart - item.item_span[0] + 1,
124
+ span: [tokenStart + 4, tokenStart + 4 + dateStr.length],
125
+ });
126
+ }
127
+ }
128
+ // Day-first text date: due [~]Day Month [Year] (Year can be 2 or 4 digits, ~ for soft dates)
129
+ const textDueDayFirstMatches = content.matchAll(/\bdue\s+(~?\d{1,2}\s+(?:january|jan|february|feb|march|mar|april|apr|may|june|jun|july|jul|august|aug|september|sep|october|oct|november|nov|december|dec)(?:\s+\d{2,4})?)/gi);
130
+ for (const textDueMatch of textDueDayFirstMatches) {
131
+ textDueIndices.add(textDueMatch.index || 0);
132
+ const dateStr = textDueMatch[1];
133
+ if (!isValidDate(dateStr)) {
134
+ const tokenStart = parenStart + 1 + (textDueMatch.index || 0);
135
+ warnings.push({
136
+ code: 'INVALID_DATE_FORMAT',
137
+ message: `Invalid due date format: ${dateStr}`,
138
+ line: item.line,
139
+ column: tokenStart - item.item_span[0] + 1,
140
+ span: [tokenStart + 4, tokenStart + 4 + dateStr.length],
141
+ });
142
+ }
143
+ }
144
+ // Standard date formats (single token)
145
+ // Allow optional ~ prefix for soft dates
146
+ const dueMatches = content.matchAll(/\bdue\s+(~?[^\s,)]+)/gi);
147
+ for (const dueMatch of dueMatches) {
148
+ // Skip if this was already matched as a text date
149
+ if (textDueIndices.has(dueMatch.index || 0))
150
+ continue;
151
+ const dateStr = dueMatch[1];
152
+ // Skip if this looks like the start of a text date (month name, with or without ~)
153
+ const strippedDateStr = dateStr.startsWith('~') ? dateStr.slice(1) : dateStr;
154
+ if (MONTH_NAMES.has(strippedDateStr.toLowerCase()))
155
+ continue;
156
+ // Skip if this looks like a day number (could be start of day-first date, with or without ~)
157
+ if (/^~?\d{1,2}$/.test(dateStr))
158
+ continue;
159
+ if (!isValidDate(dateStr)) {
160
+ const tokenStart = parenStart + 1 + (dueMatch.index || 0);
161
+ warnings.push({
162
+ code: 'INVALID_DATE_FORMAT',
163
+ message: `Invalid due date format: ${dateStr}`,
164
+ line: item.line,
165
+ column: tokenStart - item.item_span[0] + 1,
166
+ span: [tokenStart + 4, tokenStart + 4 + dateStr.length], // Skip 'due '
167
+ });
168
+ }
169
+ }
170
+ // Check for multiple due dates across all paren groups
171
+ const allDueDates = [];
172
+ const allParenMatches = rawLine.matchAll(/\(([^)]+)\)/g);
173
+ for (const pMatch of allParenMatches) {
174
+ const pContent = pMatch[1];
175
+ const pStart = item.item_span[0] + (pMatch.index || 0);
176
+ const matchedPositions = new Set();
177
+ // Check text dates first (month-first, year can be 2 or 4 digits) with "due" prefix
178
+ // Allow optional ~ prefix for soft dates
179
+ const textDueDatesInGroup = pContent.matchAll(/\bdue\s+(~?(?:january|jan|february|feb|march|mar|april|apr|may|june|jun|july|jul|august|aug|september|sep|october|oct|november|nov|december|dec)\s+\d{1,2}(?:\s+\d{2,4})?)/gi);
180
+ for (const tdm of textDueDatesInGroup) {
181
+ const ds = tdm[1];
182
+ if (isValidDate(ds)) {
183
+ matchedPositions.add(tdm.index || 0);
184
+ const dStart = pStart + 1 + (tdm.index || 0);
185
+ allDueDates.push({
186
+ dateStr: ds,
187
+ span: [dStart, dStart + tdm[0].length],
188
+ });
189
+ }
190
+ }
191
+ // Check day-first text dates (year can be 2 or 4 digits) with "due" prefix
192
+ // Allow optional ~ prefix for soft dates
193
+ const textDueDayFirstInGroup = pContent.matchAll(/\bdue\s+(~?\d{1,2}\s+(?:january|jan|february|feb|march|mar|april|apr|may|june|jun|july|jul|august|aug|september|sep|october|oct|november|nov|december|dec)(?:\s+\d{2,4})?)/gi);
194
+ for (const tdm of textDueDayFirstInGroup) {
195
+ const ds = tdm[1];
196
+ if (isValidDate(ds)) {
197
+ matchedPositions.add(tdm.index || 0);
198
+ const dStart = pStart + 1 + (tdm.index || 0);
199
+ allDueDates.push({
200
+ dateStr: ds,
201
+ span: [dStart, dStart + tdm[0].length],
202
+ });
203
+ }
204
+ }
205
+ // Check standard dates with "due" prefix
206
+ // Allow optional ~ prefix for soft dates
207
+ const dueDatesInGroup = pContent.matchAll(/\bdue\s+(~?[^\s,)]+)/gi);
208
+ for (const dm of dueDatesInGroup) {
209
+ // Skip if already matched as text date
210
+ if (matchedPositions.has(dm.index || 0))
211
+ continue;
212
+ const ds = dm[1];
213
+ // Skip if it's a month name (part of text date, with or without ~)
214
+ const strippedDs = ds.startsWith('~') ? ds.slice(1) : ds;
215
+ if (MONTH_NAMES.has(strippedDs.toLowerCase()))
216
+ continue;
217
+ // Skip if it's a day number (part of day-first text date, with or without ~)
218
+ if (/^~?\d{1,2}$/.test(ds))
219
+ continue;
220
+ if (isValidDate(ds)) {
221
+ matchedPositions.add(dm.index || 0);
222
+ const dStart = pStart + 1 + (dm.index || 0);
223
+ allDueDates.push({
224
+ dateStr: ds,
225
+ span: [dStart, dStart + dm[0].length],
226
+ });
227
+ }
228
+ }
229
+ // Check standalone text dates (without "due" prefix, not preceded by digit+space which indicates day-first)
230
+ // Allow optional ~ prefix for soft dates
231
+ // Exclude if preceded by "due " or "due ~"
232
+ const standaloneTextDatesInGroup = pContent.matchAll(/(?<!\bdue\s)(?<!\bdue\s~)(?<!\d\s)(~?(?:january|jan|february|feb|march|mar|april|apr|may|june|jun|july|jul|august|aug|september|sep|october|oct|november|nov|december|dec)\s+\d{1,2}(?:\s+\d{2,4})?)/gi);
233
+ for (const stm of standaloneTextDatesInGroup) {
234
+ // Skip if already matched
235
+ if (matchedPositions.has(stm.index || 0))
236
+ continue;
237
+ const ds = stm[1];
238
+ if (isValidDate(ds)) {
239
+ matchedPositions.add(stm.index || 0);
240
+ const dStart = pStart + 1 + (stm.index || 0);
241
+ allDueDates.push({
242
+ dateStr: ds,
243
+ span: [dStart, dStart + stm[0].length],
244
+ });
245
+ }
246
+ }
247
+ // Check standalone day-first text dates (without "due" prefix, not preceded by digit)
248
+ // Note: Day-first soft dates would be like ~15 Feb, but this is unusual; keep pattern simple
249
+ // Exclude if preceded by "due " or "due ~"
250
+ const standaloneDayFirstInGroup = pContent.matchAll(/(?<!\bdue\s)(?<!\bdue\s~)(?<!\d)(~?\d{1,2}\s+(?:january|jan|february|feb|march|mar|april|apr|may|june|jun|july|jul|august|aug|september|sep|october|oct|november|nov|december|dec)(?:\s+\d{2,4})?)/gi);
251
+ for (const stm of standaloneDayFirstInGroup) {
252
+ // Skip if already matched
253
+ if (matchedPositions.has(stm.index || 0))
254
+ continue;
255
+ const ds = stm[1];
256
+ if (isValidDate(ds)) {
257
+ matchedPositions.add(stm.index || 0);
258
+ const dStart = pStart + 1 + (stm.index || 0);
259
+ allDueDates.push({
260
+ dateStr: ds,
261
+ span: [dStart, dStart + stm[0].length],
262
+ });
263
+ }
264
+ }
265
+ // Check standalone ISO/slash dates (without "due" prefix)
266
+ // Use word boundary to avoid matching partial dates
267
+ // Allow optional ~ prefix for soft dates
268
+ // Exclude if preceded by "due " or "due ~"
269
+ const standaloneNumericDates = pContent.matchAll(/(?<!\bdue\s)(?<!\bdue\s~)(?<!\d)(~?\d{2,4}[-\/]\d{1,2}[-\/]\d{1,4})(?!\d)/gi);
270
+ for (const snm of standaloneNumericDates) {
271
+ // Skip if already matched
272
+ if (matchedPositions.has(snm.index || 0))
273
+ continue;
274
+ const ds = snm[1];
275
+ if (isValidDate(ds)) {
276
+ matchedPositions.add(snm.index || 0);
277
+ const dStart = pStart + 1 + (snm.index || 0);
278
+ allDueDates.push({
279
+ dateStr: ds,
280
+ span: [dStart, dStart + snm[0].length],
281
+ });
282
+ }
283
+ }
284
+ }
285
+ if (allDueDates.length > 1) {
286
+ // Warn for the second and subsequent ones
287
+ for (let i = 1; i < allDueDates.length; i++) {
288
+ warnings.push({
289
+ code: 'DUPLICATE_DUE_DATE',
290
+ message: 'Multiple due dates found, using last value',
291
+ line: item.line,
292
+ column: allDueDates[i].span[0] - item.item_span[0] + 1,
293
+ span: allDueDates[i].span,
294
+ });
295
+ }
296
+ break; // Only warn once per item
297
+ }
298
+ // Check for standalone dates (without "due" prefix)
299
+ // Standalone text dates: Month Day [Year] (not preceded by "due" or digit+space)
300
+ // Allow optional ~ prefix for soft dates
301
+ // Exclude if preceded by "due " or "due ~"
302
+ const standaloneTextDates = content.matchAll(/(?<!\bdue\s)(?<!\bdue\s~)(?<!\d\s)(~?(?:january|jan|february|feb|march|mar|april|apr|may|june|jun|july|jul|august|aug|september|sep|october|oct|november|nov|december|dec)\s+\d{1,2}(?:\s+\d{2,4})?)/gi);
303
+ for (const stdMatch of standaloneTextDates) {
304
+ const dateStr = stdMatch[1];
305
+ if (!isValidDate(dateStr)) {
306
+ const tokenStart = parenStart + 1 + (stdMatch.index || 0);
307
+ warnings.push({
308
+ code: 'INVALID_DATE_FORMAT',
309
+ message: `Invalid date format: ${dateStr}`,
310
+ line: item.line,
311
+ column: tokenStart - item.item_span[0] + 1,
312
+ span: [tokenStart, tokenStart + dateStr.length],
313
+ });
314
+ }
315
+ }
316
+ // Standalone day-first text dates: Day Month [Year] (not preceded by "due" or a digit)
317
+ // Allow optional ~ prefix for soft dates
318
+ // Exclude if preceded by "due " or "due ~"
319
+ const standaloneDayFirstDates = content.matchAll(/(?<!\bdue\s)(?<!\bdue\s~)(?<!\d)(~?\d{1,2}\s+(?:january|jan|february|feb|march|mar|april|apr|may|june|jun|july|jul|august|aug|september|sep|october|oct|november|nov|december|dec)(?:\s+\d{2,4})?)/gi);
320
+ for (const stdMatch of standaloneDayFirstDates) {
321
+ const dateStr = stdMatch[1];
322
+ if (!isValidDate(dateStr)) {
323
+ const tokenStart = parenStart + 1 + (stdMatch.index || 0);
324
+ warnings.push({
325
+ code: 'INVALID_DATE_FORMAT',
326
+ message: `Invalid date format: ${dateStr}`,
327
+ line: item.line,
328
+ column: tokenStart - item.item_span[0] + 1,
329
+ span: [tokenStart, tokenStart + dateStr.length],
330
+ });
331
+ }
332
+ }
333
+ // Check for invalid priority tokens (e.g., pX)
334
+ // Use negative lookbehind to exclude hashtags (e.g., #personal)
335
+ const invalidPriorities = content.matchAll(/(?<!#)\bp([a-zA-Z][^\s,)]*)/gi);
336
+ for (const ipMatch of invalidPriorities) {
337
+ // Skip if this is "progress" (part of "in progress" progress state)
338
+ if (ipMatch[0].toLowerCase() === 'progress')
339
+ continue;
340
+ const tokenStart = parenStart + 1 + (ipMatch.index || 0);
341
+ warnings.push({
342
+ code: 'INVALID_TOKEN',
343
+ message: `Unrecognized token in parentheses: ${ipMatch[0]}`,
344
+ line: item.line,
345
+ column: tokenStart - item.item_span[0] + 1,
346
+ span: [tokenStart, tokenStart + ipMatch[0].length],
347
+ });
348
+ }
349
+ // Check for invalid estimate tokens (e.g., 5q)
350
+ const invalidEstimates = content.matchAll(/\b(\d+)([a-zA-Z])(?![mhdMHD])\b/gi);
351
+ for (const ieMatch of invalidEstimates) {
352
+ const unit = ieMatch[2].toLowerCase();
353
+ if (unit !== 'm' && unit !== 'h' && unit !== 'd') {
354
+ const tokenStart = parenStart + 1 + (ieMatch.index || 0);
355
+ warnings.push({
356
+ code: 'INVALID_TOKEN',
357
+ message: `Unrecognized token in parentheses: ${ieMatch[0]}`,
358
+ line: item.line,
359
+ column: tokenStart - item.item_span[0] + 1,
360
+ span: [tokenStart, tokenStart + ieMatch[0].length],
361
+ });
362
+ }
363
+ }
364
+ // Check for invalid hashtags (e.g., #123, #)
365
+ const hashtagLikeMatches = content.matchAll(HASHTAG_LIKE_REGEX);
366
+ for (const htMatch of hashtagLikeMatches) {
367
+ const hashtag = htMatch[0];
368
+ if (!VALID_HASHTAG_REGEX.test(hashtag)) {
369
+ const tokenStart = parenStart + 1 + (htMatch.index || 0);
370
+ warnings.push({
371
+ code: 'INVALID_HASHTAG',
372
+ message: `Invalid hashtag format: ${hashtag} (must start with # followed by a letter)`,
373
+ line: item.line,
374
+ column: tokenStart - item.item_span[0] + 1,
375
+ span: [tokenStart, tokenStart + hashtag.length],
376
+ });
377
+ }
378
+ }
379
+ }
380
+ // Check for comment indentation (list items only)
381
+ if (item.type === 'list' && item.comments.length > 0) {
382
+ const listMatch = rawLine.match(/^(\s*)([-*]|\d+\.)\s/);
383
+ const expectedIndent = listMatch ? listMatch[1].length + listMatch[2].length + 1 : 2;
384
+ // Find comment lines in the original text
385
+ let currentOffset = item.item_span[0] + rawLine.length + 1;
386
+ for (let i = 0; i < item.comments.length; i++) {
387
+ const commentLineIndex = item.line + i;
388
+ if (commentLineIndex < lines.length) {
389
+ const commentLine = lines[commentLineIndex];
390
+ const leadingSpaces = commentLine.match(/^(\s*)/)?.[1].length || 0;
391
+ if (leadingSpaces < expectedIndent && commentLine.trim().length > 0) {
392
+ warnings.push({
393
+ code: 'COMMENT_INDENTATION',
394
+ message: 'List item comment should be indented',
395
+ line: commentLineIndex + 1,
396
+ column: 1,
397
+ span: [currentOffset, currentOffset + commentLine.length],
398
+ });
399
+ }
400
+ }
401
+ currentOffset += (lines[commentLineIndex]?.length || 0) + 1;
402
+ }
403
+ }
404
+ }
405
+ // Check for Misc section issues (only for the misc file)
406
+ if (isMiscFile) {
407
+ if (miscSectionLine === null) {
408
+ warnings.push({
409
+ code: 'MISC_MISSING',
410
+ message: `Document is missing required '# ${miscLocation.heading}' section`,
411
+ line: null,
412
+ column: null,
413
+ span: null,
414
+ });
415
+ }
416
+ else if (headingsAfterMisc.length > 0) {
417
+ // Misc is not at EOF
418
+ warnings.push({
419
+ code: 'MISC_NOT_AT_EOF',
420
+ message: `'# ${miscLocation.heading}' section must be at end of file`,
421
+ line: miscSectionLine,
422
+ column: 1,
423
+ span: miscSectionSpan,
424
+ });
425
+ // Content after Misc warning for each heading
426
+ for (const heading of headingsAfterMisc) {
427
+ warnings.push({
428
+ code: 'CONTENT_AFTER_MISC',
429
+ message: `Heading found after '# ${miscLocation.heading}' section`,
430
+ line: heading.line,
431
+ column: 1,
432
+ span: heading.span,
433
+ });
434
+ }
435
+ }
436
+ }
437
+ // Check for sequence issues (gaps and duplicates in numbered lists)
438
+ const itemMap = new Map();
439
+ for (const item of ast.items) {
440
+ itemMap.set(item.id, item);
441
+ }
442
+ for (const item of ast.items) {
443
+ if (item.children.length === 0)
444
+ continue;
445
+ // Collect numbered children
446
+ const numberedChildren = [];
447
+ for (const childId of item.children) {
448
+ const child = itemMap.get(childId);
449
+ if (child && child.marker_type === 'numbered' && child.sequence_number !== undefined) {
450
+ numberedChildren.push({ id: childId, seqNum: child.sequence_number, item: child });
451
+ }
452
+ }
453
+ if (numberedChildren.length === 0)
454
+ continue;
455
+ // Check for gaps - numbers should be consecutive starting from 1
456
+ for (let i = 0; i < numberedChildren.length; i++) {
457
+ const expected = i + 1;
458
+ const actual = numberedChildren[i].seqNum;
459
+ if (actual !== expected) {
460
+ const child = numberedChildren[i].item;
461
+ warnings.push({
462
+ code: 'SEQUENCE_GAP',
463
+ message: `Sequence gap: expected ${expected}, found ${actual}`,
464
+ line: child.line,
465
+ column: child.column,
466
+ span: child.item_span,
467
+ });
468
+ }
469
+ }
470
+ // Check for duplicates
471
+ const seqNumCounts = new Map();
472
+ for (const child of numberedChildren) {
473
+ const existing = seqNumCounts.get(child.seqNum) || [];
474
+ existing.push(child);
475
+ seqNumCounts.set(child.seqNum, existing);
476
+ }
477
+ for (const [seqNum, children] of seqNumCounts) {
478
+ if (children.length > 1) {
479
+ // Warn for all but the first occurrence
480
+ for (let i = 1; i < children.length; i++) {
481
+ const child = children[i].item;
482
+ warnings.push({
483
+ code: 'SEQUENCE_DUPLICATE',
484
+ message: `Duplicate sequence number: ${seqNum}`,
485
+ line: child.line,
486
+ column: child.column,
487
+ span: child.item_span,
488
+ });
489
+ }
490
+ }
491
+ }
492
+ }
493
+ return { warnings };
494
+ }
495
+ function lintScheme(scheme) {
496
+ const warnings = [];
497
+ // Check if calendar_format is valid
498
+ if (!VALID_CALENDAR_FORMATS.has(scheme.calendar_format.toLowerCase())) {
499
+ warnings.push({
500
+ code: 'INVALID_CALENDAR_FORMAT',
501
+ message: `Invalid calendar format: '${scheme.calendar_format}'. Valid formats are: ${[...VALID_CALENDAR_FORMATS].sort().join(', ')}`,
502
+ line: null,
503
+ column: null,
504
+ span: null,
505
+ });
506
+ }
507
+ // Check if formatting_style is valid
508
+ if (!VALID_FORMATTING_STYLES.has(scheme.formatting_style.toLowerCase())) {
509
+ warnings.push({
510
+ code: 'INVALID_FORMATTING_STYLE',
511
+ message: `Invalid formatting style: '${scheme.formatting_style}'. Valid styles are: ${[...VALID_FORMATTING_STYLES].sort().join(', ')}`,
512
+ line: null,
513
+ column: null,
514
+ span: null,
515
+ });
516
+ }
517
+ return { warnings };
518
+ }
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Todoosy Parser
3
+ */
4
+ import type { AST, ParenGroup, Warning } from './types.js';
5
+ export interface ParseResult {
6
+ ast: AST;
7
+ warnings: Warning[];
8
+ }
9
+ export declare function parseTokensInParenGroup(content: string, groupStart: number): ParenGroup;
10
+ export declare function extractParenGroups(line: string, lineStart: number): ParenGroup[];
11
+ export declare function parse(text: string): ParseResult;