@md2do/core 0.2.2 → 0.2.3

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 (44) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/coverage/coverage-final.json +9 -9
  3. package/coverage/index.html +14 -14
  4. package/coverage/lcov-report/index.html +14 -14
  5. package/coverage/lcov-report/src/filters/index.html +1 -1
  6. package/coverage/lcov-report/src/filters/index.ts.html +1 -1
  7. package/coverage/lcov-report/src/index.html +1 -1
  8. package/coverage/lcov-report/src/index.ts.html +1 -1
  9. package/coverage/lcov-report/src/parser/index.html +9 -9
  10. package/coverage/lcov-report/src/parser/index.ts.html +334 -124
  11. package/coverage/lcov-report/src/parser/patterns.ts.html +1 -1
  12. package/coverage/lcov-report/src/scanner/index.html +7 -7
  13. package/coverage/lcov-report/src/scanner/index.ts.html +208 -70
  14. package/coverage/lcov-report/src/sorting/index.html +1 -1
  15. package/coverage/lcov-report/src/sorting/index.ts.html +1 -1
  16. package/coverage/lcov-report/src/utils/dates.ts.html +21 -21
  17. package/coverage/lcov-report/src/utils/id.ts.html +8 -8
  18. package/coverage/lcov-report/src/utils/index.html +1 -1
  19. package/coverage/lcov-report/src/writer/index.html +1 -1
  20. package/coverage/lcov-report/src/writer/index.ts.html +1 -1
  21. package/coverage/lcov.info +479 -342
  22. package/coverage/src/filters/index.html +1 -1
  23. package/coverage/src/filters/index.ts.html +1 -1
  24. package/coverage/src/index.html +1 -1
  25. package/coverage/src/index.ts.html +1 -1
  26. package/coverage/src/parser/index.html +9 -9
  27. package/coverage/src/parser/index.ts.html +334 -124
  28. package/coverage/src/parser/patterns.ts.html +1 -1
  29. package/coverage/src/scanner/index.html +7 -7
  30. package/coverage/src/scanner/index.ts.html +208 -70
  31. package/coverage/src/sorting/index.html +1 -1
  32. package/coverage/src/sorting/index.ts.html +1 -1
  33. package/coverage/src/utils/dates.ts.html +21 -21
  34. package/coverage/src/utils/id.ts.html +8 -8
  35. package/coverage/src/utils/index.html +1 -1
  36. package/coverage/src/writer/index.html +1 -1
  37. package/coverage/src/writer/index.ts.html +1 -1
  38. package/dist/index.js +88 -0
  39. package/dist/index.mjs +88 -0
  40. package/package.json +1 -1
  41. package/src/parser/index.ts +70 -0
  42. package/src/scanner/index.ts +46 -0
  43. package/tests/parser/index.test.ts +107 -1
  44. package/tests/scanner/index.test.ts +94 -4
package/dist/index.js CHANGED
@@ -235,6 +235,42 @@ function cleanTaskText(text) {
235
235
  }
236
236
  function parseTask(line, lineNumber, file, context) {
237
237
  const warnings = [];
238
+ if (/^\s*[*+]\s+\[[ xX]\]/.test(line)) {
239
+ warnings.push({
240
+ file,
241
+ line: lineNumber,
242
+ text: line.trim(),
243
+ reason: "Unsupported bullet marker (* or +). Use dash (-) for task lists."
244
+ });
245
+ return { task: null, warnings };
246
+ }
247
+ if (/^\s*-\s+\[[xX]\s+\]/.test(line) || /^\s*-\s+\[\s+[xX]\]/.test(line)) {
248
+ warnings.push({
249
+ file,
250
+ line: lineNumber,
251
+ text: line.trim(),
252
+ reason: "Malformed checkbox with extra spaces. Use [x] or [ ] without extra spaces."
253
+ });
254
+ return { task: null, warnings };
255
+ }
256
+ if (/^\s*-\s+\[[ xX]\][^\s]/.test(line)) {
257
+ warnings.push({
258
+ file,
259
+ line: lineNumber,
260
+ text: line.trim(),
261
+ reason: 'Missing space after checkbox. Use "- [x] Task" format.'
262
+ });
263
+ return { task: null, warnings };
264
+ }
265
+ if (/^\s*-\[[ xX]\]/.test(line)) {
266
+ warnings.push({
267
+ file,
268
+ line: lineNumber,
269
+ text: line.trim(),
270
+ reason: 'Missing space before checkbox. Use "- [x] Task" format.'
271
+ });
272
+ return { task: null, warnings };
273
+ }
238
274
  const taskMatch = line.match(PATTERNS.TASK_CHECKBOX);
239
275
  if (!taskMatch?.[0] || !taskMatch[2]) {
240
276
  return { task: null, warnings };
@@ -254,6 +290,22 @@ function parseTask(line, lineNumber, file, context) {
254
290
  line: lineNumber
255
291
  });
256
292
  }
293
+ if (!completed && !dueDateResult.date && !context.currentDate) {
294
+ warnings.push({
295
+ file,
296
+ line: lineNumber,
297
+ text: fullText.trim(),
298
+ reason: "Task has no due date. Add [due: YYYY-MM-DD] or place under a heading with a date."
299
+ });
300
+ }
301
+ if (completed && !completedDate) {
302
+ warnings.push({
303
+ file,
304
+ line: lineNumber,
305
+ text: fullText.trim(),
306
+ reason: "Completed task missing completion date. Add [completed: YYYY-MM-DD]."
307
+ });
308
+ }
257
309
  const cleanText = cleanTaskText(fullText);
258
310
  const id = generateTaskId(file, lineNumber, cleanText);
259
311
  const task = {
@@ -299,6 +351,7 @@ var MarkdownScanner = class {
299
351
  scanFile(filePath, content) {
300
352
  const tasks = [];
301
353
  const warnings = [];
354
+ const todoistIds = /* @__PURE__ */ new Map();
302
355
  const context = {};
303
356
  const project = extractProjectFromPath(filePath);
304
357
  if (project !== void 0) context.project = project;
@@ -318,6 +371,22 @@ var MarkdownScanner = class {
318
371
  const result = parseTask(line, lineNumber, filePath, context);
319
372
  if (result.task) {
320
373
  tasks.push(result.task);
374
+ if (result.task.todoistId) {
375
+ const existing = todoistIds.get(result.task.todoistId);
376
+ if (existing) {
377
+ warnings.push({
378
+ file: filePath,
379
+ line: lineNumber,
380
+ text: result.task.text,
381
+ reason: `Duplicate Todoist ID [todoist:${result.task.todoistId}]. Also found at ${existing.file}:${existing.line}.`
382
+ });
383
+ } else {
384
+ todoistIds.set(result.task.todoistId, {
385
+ file: filePath,
386
+ line: lineNumber
387
+ });
388
+ }
389
+ }
321
390
  }
322
391
  if (result.warnings.length > 0) {
323
392
  warnings.push(...result.warnings);
@@ -337,10 +406,29 @@ var MarkdownScanner = class {
337
406
  scanFiles(files) {
338
407
  const allTasks = [];
339
408
  const allWarnings = [];
409
+ const todoistIds = /* @__PURE__ */ new Map();
340
410
  for (const file of files) {
341
411
  const result = this.scanFile(file.path, file.content);
342
412
  allTasks.push(...result.tasks);
343
413
  allWarnings.push(...result.warnings);
414
+ for (const task of result.tasks) {
415
+ if (task.todoistId) {
416
+ const existing = todoistIds.get(task.todoistId);
417
+ if (existing && existing.file !== task.file) {
418
+ allWarnings.push({
419
+ file: task.file,
420
+ line: task.line,
421
+ text: task.text,
422
+ reason: `Duplicate Todoist ID [todoist:${task.todoistId}] across files. Also found at ${existing.file}:${existing.line}.`
423
+ });
424
+ } else if (!existing) {
425
+ todoistIds.set(task.todoistId, {
426
+ file: task.file,
427
+ line: task.line
428
+ });
429
+ }
430
+ }
431
+ }
344
432
  }
345
433
  return {
346
434
  tasks: allTasks,
package/dist/index.mjs CHANGED
@@ -178,6 +178,42 @@ function cleanTaskText(text) {
178
178
  }
179
179
  function parseTask(line, lineNumber, file, context) {
180
180
  const warnings = [];
181
+ if (/^\s*[*+]\s+\[[ xX]\]/.test(line)) {
182
+ warnings.push({
183
+ file,
184
+ line: lineNumber,
185
+ text: line.trim(),
186
+ reason: "Unsupported bullet marker (* or +). Use dash (-) for task lists."
187
+ });
188
+ return { task: null, warnings };
189
+ }
190
+ if (/^\s*-\s+\[[xX]\s+\]/.test(line) || /^\s*-\s+\[\s+[xX]\]/.test(line)) {
191
+ warnings.push({
192
+ file,
193
+ line: lineNumber,
194
+ text: line.trim(),
195
+ reason: "Malformed checkbox with extra spaces. Use [x] or [ ] without extra spaces."
196
+ });
197
+ return { task: null, warnings };
198
+ }
199
+ if (/^\s*-\s+\[[ xX]\][^\s]/.test(line)) {
200
+ warnings.push({
201
+ file,
202
+ line: lineNumber,
203
+ text: line.trim(),
204
+ reason: 'Missing space after checkbox. Use "- [x] Task" format.'
205
+ });
206
+ return { task: null, warnings };
207
+ }
208
+ if (/^\s*-\[[ xX]\]/.test(line)) {
209
+ warnings.push({
210
+ file,
211
+ line: lineNumber,
212
+ text: line.trim(),
213
+ reason: 'Missing space before checkbox. Use "- [x] Task" format.'
214
+ });
215
+ return { task: null, warnings };
216
+ }
181
217
  const taskMatch = line.match(PATTERNS.TASK_CHECKBOX);
182
218
  if (!taskMatch?.[0] || !taskMatch[2]) {
183
219
  return { task: null, warnings };
@@ -197,6 +233,22 @@ function parseTask(line, lineNumber, file, context) {
197
233
  line: lineNumber
198
234
  });
199
235
  }
236
+ if (!completed && !dueDateResult.date && !context.currentDate) {
237
+ warnings.push({
238
+ file,
239
+ line: lineNumber,
240
+ text: fullText.trim(),
241
+ reason: "Task has no due date. Add [due: YYYY-MM-DD] or place under a heading with a date."
242
+ });
243
+ }
244
+ if (completed && !completedDate) {
245
+ warnings.push({
246
+ file,
247
+ line: lineNumber,
248
+ text: fullText.trim(),
249
+ reason: "Completed task missing completion date. Add [completed: YYYY-MM-DD]."
250
+ });
251
+ }
200
252
  const cleanText = cleanTaskText(fullText);
201
253
  const id = generateTaskId(file, lineNumber, cleanText);
202
254
  const task = {
@@ -242,6 +294,7 @@ var MarkdownScanner = class {
242
294
  scanFile(filePath, content) {
243
295
  const tasks = [];
244
296
  const warnings = [];
297
+ const todoistIds = /* @__PURE__ */ new Map();
245
298
  const context = {};
246
299
  const project = extractProjectFromPath(filePath);
247
300
  if (project !== void 0) context.project = project;
@@ -261,6 +314,22 @@ var MarkdownScanner = class {
261
314
  const result = parseTask(line, lineNumber, filePath, context);
262
315
  if (result.task) {
263
316
  tasks.push(result.task);
317
+ if (result.task.todoistId) {
318
+ const existing = todoistIds.get(result.task.todoistId);
319
+ if (existing) {
320
+ warnings.push({
321
+ file: filePath,
322
+ line: lineNumber,
323
+ text: result.task.text,
324
+ reason: `Duplicate Todoist ID [todoist:${result.task.todoistId}]. Also found at ${existing.file}:${existing.line}.`
325
+ });
326
+ } else {
327
+ todoistIds.set(result.task.todoistId, {
328
+ file: filePath,
329
+ line: lineNumber
330
+ });
331
+ }
332
+ }
264
333
  }
265
334
  if (result.warnings.length > 0) {
266
335
  warnings.push(...result.warnings);
@@ -280,10 +349,29 @@ var MarkdownScanner = class {
280
349
  scanFiles(files) {
281
350
  const allTasks = [];
282
351
  const allWarnings = [];
352
+ const todoistIds = /* @__PURE__ */ new Map();
283
353
  for (const file of files) {
284
354
  const result = this.scanFile(file.path, file.content);
285
355
  allTasks.push(...result.tasks);
286
356
  allWarnings.push(...result.warnings);
357
+ for (const task of result.tasks) {
358
+ if (task.todoistId) {
359
+ const existing = todoistIds.get(task.todoistId);
360
+ if (existing && existing.file !== task.file) {
361
+ allWarnings.push({
362
+ file: task.file,
363
+ line: task.line,
364
+ text: task.text,
365
+ reason: `Duplicate Todoist ID [todoist:${task.todoistId}] across files. Also found at ${existing.file}:${existing.line}.`
366
+ });
367
+ } else if (!existing) {
368
+ todoistIds.set(task.todoistId, {
369
+ file: task.file,
370
+ line: task.line
371
+ });
372
+ }
373
+ }
374
+ }
287
375
  }
288
376
  return {
289
377
  tasks: allTasks,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@md2do/core",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Core parsing, filtering, scanning, and file writing for md2do",
5
5
  "keywords": [
6
6
  "markdown",
@@ -208,6 +208,53 @@ export function parseTask(
208
208
  ): { task: Task | null; warnings: Warning[] } {
209
209
  const warnings: Warning[] = [];
210
210
 
211
+ // Detect malformed checkboxes and provide helpful warnings
212
+ // Check for asterisk/plus bullet markers (not supported)
213
+ if (/^\s*[*+]\s+\[[ xX]\]/.test(line)) {
214
+ warnings.push({
215
+ file,
216
+ line: lineNumber,
217
+ text: line.trim(),
218
+ reason:
219
+ 'Unsupported bullet marker (* or +). Use dash (-) for task lists.',
220
+ });
221
+ return { task: null, warnings };
222
+ }
223
+
224
+ // Check for extra spaces inside checkbox: [x ] or [ x]
225
+ if (/^\s*-\s+\[[xX]\s+\]/.test(line) || /^\s*-\s+\[\s+[xX]\]/.test(line)) {
226
+ warnings.push({
227
+ file,
228
+ line: lineNumber,
229
+ text: line.trim(),
230
+ reason:
231
+ 'Malformed checkbox with extra spaces. Use [x] or [ ] without extra spaces.',
232
+ });
233
+ return { task: null, warnings };
234
+ }
235
+
236
+ // Check for missing space after checkbox: [x]Task
237
+ if (/^\s*-\s+\[[ xX]\][^\s]/.test(line)) {
238
+ warnings.push({
239
+ file,
240
+ line: lineNumber,
241
+ text: line.trim(),
242
+ reason: 'Missing space after checkbox. Use "- [x] Task" format.',
243
+ });
244
+ return { task: null, warnings };
245
+ }
246
+
247
+ // Check for missing space before checkbox: -[x]
248
+ if (/^\s*-\[[ xX]\]/.test(line)) {
249
+ warnings.push({
250
+ file,
251
+ line: lineNumber,
252
+ text: line.trim(),
253
+ reason: 'Missing space before checkbox. Use "- [x] Task" format.',
254
+ });
255
+ return { task: null, warnings };
256
+ }
257
+
211
258
  // Check if line is a task
212
259
  const taskMatch = line.match(PATTERNS.TASK_CHECKBOX);
213
260
  if (!taskMatch?.[0] || !taskMatch[2]) {
@@ -237,6 +284,29 @@ export function parseTask(
237
284
  });
238
285
  }
239
286
 
287
+ // Warn about missing dates
288
+ // Incomplete tasks without any date (no explicit due date and no context date)
289
+ if (!completed && !dueDateResult.date && !context.currentDate) {
290
+ warnings.push({
291
+ file,
292
+ line: lineNumber,
293
+ text: fullText.trim(),
294
+ reason:
295
+ 'Task has no due date. Add [due: YYYY-MM-DD] or place under a heading with a date.',
296
+ });
297
+ }
298
+
299
+ // Completed tasks should have completion dates
300
+ if (completed && !completedDate) {
301
+ warnings.push({
302
+ file,
303
+ line: lineNumber,
304
+ text: fullText.trim(),
305
+ reason:
306
+ 'Completed task missing completion date. Add [completed: YYYY-MM-DD].',
307
+ });
308
+ }
309
+
240
310
  // Clean text (remove metadata markers)
241
311
  const cleanText = cleanTaskText(fullText);
242
312
 
@@ -74,6 +74,9 @@ export class MarkdownScanner {
74
74
  const tasks: Task[] = [];
75
75
  const warnings: Warning[] = [];
76
76
 
77
+ // Track Todoist IDs to detect duplicates
78
+ const todoistIds = new Map<string, { file: string; line: number }>();
79
+
77
80
  // Initialize context from file path
78
81
  const context: ParsingContext = {};
79
82
 
@@ -105,6 +108,24 @@ export class MarkdownScanner {
105
108
 
106
109
  if (result.task) {
107
110
  tasks.push(result.task);
111
+
112
+ // Check for duplicate Todoist IDs
113
+ if (result.task.todoistId) {
114
+ const existing = todoistIds.get(result.task.todoistId);
115
+ if (existing) {
116
+ warnings.push({
117
+ file: filePath,
118
+ line: lineNumber,
119
+ text: result.task.text,
120
+ reason: `Duplicate Todoist ID [todoist:${result.task.todoistId}]. Also found at ${existing.file}:${existing.line}.`,
121
+ });
122
+ } else {
123
+ todoistIds.set(result.task.todoistId, {
124
+ file: filePath,
125
+ line: lineNumber,
126
+ });
127
+ }
128
+ }
108
129
  }
109
130
 
110
131
  if (result.warnings.length > 0) {
@@ -131,10 +152,35 @@ export class MarkdownScanner {
131
152
  const allTasks: Task[] = [];
132
153
  const allWarnings: Warning[] = [];
133
154
 
155
+ // Track Todoist IDs across all files
156
+ const todoistIds = new Map<string, { file: string; line: number }>();
157
+
134
158
  for (const file of files) {
135
159
  const result = this.scanFile(file.path, file.content);
136
160
  allTasks.push(...result.tasks);
137
161
  allWarnings.push(...result.warnings);
162
+
163
+ // Check for duplicate Todoist IDs across files
164
+ for (const task of result.tasks) {
165
+ if (task.todoistId) {
166
+ const existing = todoistIds.get(task.todoistId);
167
+ if (existing && existing.file !== task.file) {
168
+ // Only add warning if duplicate is in a different file
169
+ // (same-file duplicates are already caught by scanFile)
170
+ allWarnings.push({
171
+ file: task.file,
172
+ line: task.line,
173
+ text: task.text,
174
+ reason: `Duplicate Todoist ID [todoist:${task.todoistId}] across files. Also found at ${existing.file}:${existing.line}.`,
175
+ });
176
+ } else if (!existing) {
177
+ todoistIds.set(task.todoistId, {
178
+ file: task.file,
179
+ line: task.line,
180
+ });
181
+ }
182
+ }
183
+ }
138
184
  }
139
185
 
140
186
  return {
@@ -419,7 +419,7 @@ describe('parseTask', () => {
419
419
  const result = parseTask('- [ ] Task [due: tomorrow]', 1, file, {});
420
420
 
421
421
  expect(result.task?.dueDate).toBeUndefined();
422
- expect(result.warnings).toHaveLength(1);
422
+ expect(result.warnings).toHaveLength(2); // Relative date + missing date warnings
423
423
  expect(result.warnings[0]?.reason).toContain(
424
424
  'Relative due date without context',
425
425
  );
@@ -535,4 +535,110 @@ describe('parseTask', () => {
535
535
  expect(result.task?.completedDate).toBeInstanceOf(Date);
536
536
  });
537
537
  });
538
+
539
+ describe('Malformed checkbox warnings', () => {
540
+ it('should warn on asterisk bullet marker', () => {
541
+ const result = parseTask('* [x] Task with asterisk', 1, file, {});
542
+ expect(result.task).toBeNull();
543
+ expect(result.warnings).toHaveLength(1);
544
+ expect(result.warnings[0]?.reason).toContain(
545
+ 'Unsupported bullet marker (* or +)',
546
+ );
547
+ expect(result.warnings[0]?.file).toBe(file);
548
+ expect(result.warnings[0]?.line).toBe(1);
549
+ });
550
+
551
+ it('should warn on plus bullet marker', () => {
552
+ const result = parseTask('+ [ ] Task with plus', 1, file, {});
553
+ expect(result.task).toBeNull();
554
+ expect(result.warnings).toHaveLength(1);
555
+ expect(result.warnings[0]?.reason).toContain(
556
+ 'Unsupported bullet marker (* or +)',
557
+ );
558
+ });
559
+
560
+ it('should warn on extra space after x', () => {
561
+ const result = parseTask('- [x ] Task with extra space', 1, file, {});
562
+ expect(result.task).toBeNull();
563
+ expect(result.warnings).toHaveLength(1);
564
+ expect(result.warnings[0]?.reason).toContain('Malformed checkbox');
565
+ });
566
+
567
+ it('should warn on extra space before x', () => {
568
+ const result = parseTask('- [ x] Task with extra space', 1, file, {});
569
+ expect(result.task).toBeNull();
570
+ expect(result.warnings).toHaveLength(1);
571
+ expect(result.warnings[0]?.reason).toContain('Malformed checkbox');
572
+ });
573
+
574
+ it('should warn on missing space before checkbox', () => {
575
+ const result = parseTask('-[x] Task without space', 1, file, {});
576
+ expect(result.task).toBeNull();
577
+ expect(result.warnings).toHaveLength(1);
578
+ expect(result.warnings[0]?.reason).toContain(
579
+ 'Missing space before checkbox',
580
+ );
581
+ });
582
+
583
+ it('should warn on missing space after checkbox', () => {
584
+ const result = parseTask('- [x]Task without space', 1, file, {});
585
+ expect(result.task).toBeNull();
586
+ expect(result.warnings).toHaveLength(1);
587
+ expect(result.warnings[0]?.reason).toContain(
588
+ 'Missing space after checkbox',
589
+ );
590
+ });
591
+ });
592
+
593
+ describe('Missing date warnings', () => {
594
+ it('should warn on incomplete task without date or context', () => {
595
+ const result = parseTask('- [ ] Task without date', 1, file, {});
596
+ expect(result.task).not.toBeNull();
597
+ expect(result.warnings).toHaveLength(1);
598
+ expect(result.warnings[0]?.reason).toContain('Task has no due date');
599
+ });
600
+
601
+ it('should not warn on task with explicit due date', () => {
602
+ const result = parseTask('- [ ] Task [due: 2026-01-30]', 1, file, {});
603
+ expect(result.task).not.toBeNull();
604
+ expect(result.warnings).toHaveLength(0);
605
+ });
606
+
607
+ it('should not warn on task with context date', () => {
608
+ const context: ParsingContext = {
609
+ currentDate: new Date('2026-01-30'),
610
+ };
611
+ const result = parseTask('- [ ] Task with context', 1, file, context);
612
+ expect(result.task).not.toBeNull();
613
+ expect(result.warnings).toHaveLength(0);
614
+ });
615
+
616
+ it('should not warn on completed tasks without date', () => {
617
+ const result = parseTask('- [x] Completed task', 1, file, {});
618
+ expect(result.task).not.toBeNull();
619
+ // Should only warn about missing completion date, not due date
620
+ expect(result.warnings).toHaveLength(1);
621
+ expect(result.warnings[0]?.reason).toContain('completion date');
622
+ });
623
+
624
+ it('should warn on completed task without completion date', () => {
625
+ const result = parseTask('- [x] Completed task', 1, file, {});
626
+ expect(result.task).not.toBeNull();
627
+ expect(result.warnings).toHaveLength(1);
628
+ expect(result.warnings[0]?.reason).toContain(
629
+ 'Completed task missing completion date',
630
+ );
631
+ });
632
+
633
+ it('should not warn on completed task with completion date', () => {
634
+ const result = parseTask(
635
+ '- [x] Task [completed: 2026-01-25]',
636
+ 1,
637
+ file,
638
+ {},
639
+ );
640
+ expect(result.task).not.toBeNull();
641
+ expect(result.warnings).toHaveLength(0);
642
+ });
643
+ });
538
644
  });
@@ -65,7 +65,7 @@ describe('MarkdownScanner', () => {
65
65
 
66
66
  describe('Basic task scanning', () => {
67
67
  it('should scan simple unchecked task', () => {
68
- const content = '- [ ] Simple task';
68
+ const content = '- [ ] Simple task [due: 2026-01-30]';
69
69
  const result = scanner.scanFile('test.md', content);
70
70
 
71
71
  expect(result.tasks).toHaveLength(1);
@@ -281,7 +281,7 @@ No tasks at all.
281
281
  const result = scanner.scanFile('test.md', content);
282
282
 
283
283
  expect(result.tasks[0]?.dueDate).toBeUndefined();
284
- expect(result.warnings).toHaveLength(1);
284
+ expect(result.warnings).toHaveLength(2); // Relative date + missing date warnings
285
285
  expect(result.warnings[0]?.reason).toContain(
286
286
  'Relative due date without context',
287
287
  );
@@ -461,9 +461,9 @@ Line 4
461
461
 
462
462
  const result = scanner.scanFiles(files);
463
463
 
464
- expect(result.warnings).toHaveLength(2);
464
+ expect(result.warnings).toHaveLength(4); // 2 relative date + 2 missing date warnings
465
465
  expect(result.warnings[0]?.file).toBe('file1.md');
466
- expect(result.warnings[1]?.file).toBe('file2.md');
466
+ expect(result.warnings[2]?.file).toBe('file2.md');
467
467
  });
468
468
 
469
469
  it('should handle empty file array', () => {
@@ -558,4 +558,94 @@ Line 4
558
558
  expect(result.tasks).toHaveLength(0);
559
559
  });
560
560
  });
561
+
562
+ describe('Duplicate Todoist ID warnings', () => {
563
+ it('should warn on duplicate Todoist ID within same file', () => {
564
+ const content = `
565
+ - [ ] First task [todoist:123456] [due: 2026-01-30]
566
+ - [ ] Second task [todoist:123456] [due: 2026-01-31]
567
+ `.trim();
568
+ const result = scanner.scanFile('test.md', content);
569
+
570
+ expect(result.tasks).toHaveLength(2);
571
+ expect(result.warnings).toHaveLength(1);
572
+ expect(result.warnings[0]?.reason).toContain('Duplicate Todoist ID');
573
+ expect(result.warnings[0]?.reason).toContain('123456');
574
+ expect(result.warnings[0]?.line).toBe(2);
575
+ });
576
+
577
+ it('should not warn on unique Todoist IDs', () => {
578
+ const content = `
579
+ - [ ] First task [todoist:111111] [due: 2026-01-30]
580
+ - [ ] Second task [todoist:222222] [due: 2026-01-31]
581
+ - [ ] Third task [todoist:333333] [due: 2026-02-01]
582
+ `.trim();
583
+ const result = scanner.scanFile('test.md', content);
584
+
585
+ expect(result.tasks).toHaveLength(3);
586
+ expect(result.warnings).toHaveLength(0);
587
+ });
588
+
589
+ it('should warn on multiple duplicates', () => {
590
+ const content = `
591
+ - [ ] Task A [todoist:111] [due: 2026-01-30]
592
+ - [ ] Task B [todoist:222] [due: 2026-01-31]
593
+ - [ ] Task C [todoist:111] [due: 2026-02-01]
594
+ - [ ] Task D [todoist:222] [due: 2026-02-02]
595
+ - [ ] Task E [todoist:111] [due: 2026-02-03]
596
+ `.trim();
597
+ const result = scanner.scanFile('test.md', content);
598
+
599
+ expect(result.tasks).toHaveLength(5);
600
+ // Should have 3 warnings: C duplicates A, D duplicates B, E duplicates A
601
+ expect(result.warnings).toHaveLength(3);
602
+ });
603
+
604
+ it('should track duplicates across multiple files', () => {
605
+ const files = [
606
+ {
607
+ path: 'file1.md',
608
+ content: '- [ ] Task in file 1 [todoist:123456] [due: 2026-01-30]',
609
+ },
610
+ {
611
+ path: 'file2.md',
612
+ content: '- [ ] Task in file 2 [todoist:123456] [due: 2026-01-31]',
613
+ },
614
+ ];
615
+
616
+ const result = scanner.scanFiles(files);
617
+
618
+ expect(result.tasks).toHaveLength(2);
619
+ expect(result.warnings).toHaveLength(1);
620
+ expect(result.warnings[0]?.reason).toContain('Duplicate Todoist ID');
621
+ expect(result.warnings[0]?.reason).toContain('across files');
622
+ expect(result.warnings[0]?.file).toBe('file2.md');
623
+ });
624
+
625
+ it('should not warn when same file scanned separately', () => {
626
+ const content = '- [ ] Task [todoist:123456] [due: 2026-01-30]';
627
+
628
+ // Scan same file multiple times as if they were different files
629
+ const result1 = scanner.scanFile('test1.md', content);
630
+ const result2 = scanner.scanFile('test2.md', content);
631
+
632
+ // Each scan should have no warnings (they're separate scans)
633
+ expect(result1.warnings).toHaveLength(0);
634
+ expect(result2.warnings).toHaveLength(0);
635
+ });
636
+
637
+ it('should include location information in warning', () => {
638
+ const content = `
639
+ - [ ] First task [todoist:999] [due: 2026-01-30]
640
+ - [ ] Second task [due: 2026-01-31]
641
+ - [ ] Third task [todoist:999] [due: 2026-02-01]
642
+ `.trim();
643
+ const result = scanner.scanFile('backlog.md', content);
644
+
645
+ expect(result.warnings).toHaveLength(1);
646
+ expect(result.warnings[0]?.file).toBe('backlog.md');
647
+ expect(result.warnings[0]?.line).toBe(3);
648
+ expect(result.warnings[0]?.reason).toContain('backlog.md:1');
649
+ });
650
+ });
561
651
  });