@md2do/core 0.2.2 → 0.3.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 (49) hide show
  1. package/CHANGELOG.md +31 -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.d.mts +64 -3
  39. package/dist/index.d.ts +64 -3
  40. package/dist/index.js +154 -0
  41. package/dist/index.mjs +152 -0
  42. package/package.json +1 -1
  43. package/src/index.ts +3 -0
  44. package/src/parser/index.ts +103 -0
  45. package/src/scanner/index.ts +54 -0
  46. package/src/types/index.ts +31 -2
  47. package/src/warnings/filter.ts +93 -0
  48. package/tests/parser/index.test.ts +107 -1
  49. package/tests/scanner/index.test.ts +94 -4
@@ -126,9 +126,14 @@ export function extractDueDate(
126
126
  return {
127
127
  date: undefined,
128
128
  warning: {
129
+ severity: 'warning',
130
+ source: 'md2do',
131
+ ruleId: 'relative-date-no-context',
129
132
  file: '', // Will be filled in by caller
130
133
  line: 0, // Will be filled in by caller
131
134
  text: text.trim(),
135
+ message:
136
+ 'Relative due date without context date from heading. Add a heading with a date above this task.',
132
137
  reason:
133
138
  'Relative due date without context date from heading. Add a heading with a date above this task.',
134
139
  },
@@ -208,6 +213,71 @@ export function parseTask(
208
213
  ): { task: Task | null; warnings: Warning[] } {
209
214
  const warnings: Warning[] = [];
210
215
 
216
+ // Detect malformed checkboxes and provide helpful warnings
217
+ // Check for asterisk/plus bullet markers (not supported)
218
+ if (/^\s*[*+]\s+\[[ xX]\]/.test(line)) {
219
+ warnings.push({
220
+ severity: 'warning',
221
+ source: 'md2do',
222
+ ruleId: 'unsupported-bullet',
223
+ file,
224
+ line: lineNumber,
225
+ text: line.trim(),
226
+ message:
227
+ 'Unsupported bullet marker (* or +). Use dash (-) for task lists.',
228
+ reason:
229
+ 'Unsupported bullet marker (* or +). Use dash (-) for task lists.',
230
+ });
231
+ return { task: null, warnings };
232
+ }
233
+
234
+ // Check for extra spaces inside checkbox: [x ] or [ x]
235
+ if (/^\s*-\s+\[[xX]\s+\]/.test(line) || /^\s*-\s+\[\s+[xX]\]/.test(line)) {
236
+ warnings.push({
237
+ severity: 'warning',
238
+ source: 'md2do',
239
+ ruleId: 'malformed-checkbox',
240
+ file,
241
+ line: lineNumber,
242
+ text: line.trim(),
243
+ message:
244
+ 'Malformed checkbox with extra spaces. Use [x] or [ ] without extra spaces.',
245
+ reason:
246
+ 'Malformed checkbox with extra spaces. Use [x] or [ ] without extra spaces.',
247
+ });
248
+ return { task: null, warnings };
249
+ }
250
+
251
+ // Check for missing space after checkbox: [x]Task
252
+ if (/^\s*-\s+\[[ xX]\][^\s]/.test(line)) {
253
+ warnings.push({
254
+ severity: 'warning',
255
+ source: 'md2do',
256
+ ruleId: 'missing-space-after',
257
+ file,
258
+ line: lineNumber,
259
+ text: line.trim(),
260
+ message: 'Missing space after checkbox. Use "- [x] Task" format.',
261
+ reason: 'Missing space after checkbox. Use "- [x] Task" format.',
262
+ });
263
+ return { task: null, warnings };
264
+ }
265
+
266
+ // Check for missing space before checkbox: -[x]
267
+ if (/^\s*-\[[ xX]\]/.test(line)) {
268
+ warnings.push({
269
+ severity: 'warning',
270
+ source: 'md2do',
271
+ ruleId: 'missing-space-before',
272
+ file,
273
+ line: lineNumber,
274
+ text: line.trim(),
275
+ message: 'Missing space before checkbox. Use "- [x] Task" format.',
276
+ reason: 'Missing space before checkbox. Use "- [x] Task" format.',
277
+ });
278
+ return { task: null, warnings };
279
+ }
280
+
211
281
  // Check if line is a task
212
282
  const taskMatch = line.match(PATTERNS.TASK_CHECKBOX);
213
283
  if (!taskMatch?.[0] || !taskMatch[2]) {
@@ -237,6 +307,39 @@ export function parseTask(
237
307
  });
238
308
  }
239
309
 
310
+ // Warn about missing dates
311
+ // Incomplete tasks without any date (no explicit due date and no context date)
312
+ if (!completed && !dueDateResult.date && !context.currentDate) {
313
+ warnings.push({
314
+ severity: 'info',
315
+ source: 'md2do',
316
+ ruleId: 'missing-due-date',
317
+ file,
318
+ line: lineNumber,
319
+ text: fullText.trim(),
320
+ message:
321
+ 'Task has no due date. Add [due: YYYY-MM-DD] or place under a heading with a date.',
322
+ reason:
323
+ 'Task has no due date. Add [due: YYYY-MM-DD] or place under a heading with a date.',
324
+ });
325
+ }
326
+
327
+ // Completed tasks should have completion dates
328
+ if (completed && !completedDate) {
329
+ warnings.push({
330
+ severity: 'info',
331
+ source: 'md2do',
332
+ ruleId: 'missing-completed-date',
333
+ file,
334
+ line: lineNumber,
335
+ text: fullText.trim(),
336
+ message:
337
+ 'Completed task missing completion date. Add [completed: YYYY-MM-DD].',
338
+ reason:
339
+ 'Completed task missing completion date. Add [completed: YYYY-MM-DD].',
340
+ });
341
+ }
342
+
240
343
  // Clean text (remove metadata markers)
241
344
  const cleanText = cleanTaskText(fullText);
242
345
 
@@ -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,28 @@ 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
+ severity: 'error',
118
+ source: 'md2do',
119
+ ruleId: 'duplicate-todoist-id',
120
+ file: filePath,
121
+ line: lineNumber,
122
+ text: result.task.text,
123
+ message: `Duplicate Todoist ID [todoist:${result.task.todoistId}]. Also found at ${existing.file}:${existing.line}.`,
124
+ reason: `Duplicate Todoist ID [todoist:${result.task.todoistId}]. Also found at ${existing.file}:${existing.line}.`,
125
+ });
126
+ } else {
127
+ todoistIds.set(result.task.todoistId, {
128
+ file: filePath,
129
+ line: lineNumber,
130
+ });
131
+ }
132
+ }
108
133
  }
109
134
 
110
135
  if (result.warnings.length > 0) {
@@ -131,10 +156,39 @@ export class MarkdownScanner {
131
156
  const allTasks: Task[] = [];
132
157
  const allWarnings: Warning[] = [];
133
158
 
159
+ // Track Todoist IDs across all files
160
+ const todoistIds = new Map<string, { file: string; line: number }>();
161
+
134
162
  for (const file of files) {
135
163
  const result = this.scanFile(file.path, file.content);
136
164
  allTasks.push(...result.tasks);
137
165
  allWarnings.push(...result.warnings);
166
+
167
+ // Check for duplicate Todoist IDs across files
168
+ for (const task of result.tasks) {
169
+ if (task.todoistId) {
170
+ const existing = todoistIds.get(task.todoistId);
171
+ if (existing && existing.file !== task.file) {
172
+ // Only add warning if duplicate is in a different file
173
+ // (same-file duplicates are already caught by scanFile)
174
+ allWarnings.push({
175
+ severity: 'error',
176
+ source: 'md2do',
177
+ ruleId: 'duplicate-todoist-id',
178
+ file: task.file,
179
+ line: task.line,
180
+ text: task.text,
181
+ message: `Duplicate Todoist ID [todoist:${task.todoistId}] across files. Also found at ${existing.file}:${existing.line}.`,
182
+ reason: `Duplicate Todoist ID [todoist:${task.todoistId}] across files. Also found at ${existing.file}:${existing.line}.`,
183
+ });
184
+ } else if (!existing) {
185
+ todoistIds.set(task.todoistId, {
186
+ file: task.file,
187
+ line: task.line,
188
+ });
189
+ }
190
+ }
191
+ }
138
192
  }
139
193
 
140
194
  return {
@@ -65,9 +65,38 @@ export interface ScanResult {
65
65
  };
66
66
  }
67
67
 
68
+ export type WarningSeverity = 'info' | 'warning' | 'error';
69
+
70
+ export type WarningCode =
71
+ | 'unsupported-bullet' // * or + instead of -
72
+ | 'malformed-checkbox' // [x ] or [ x]
73
+ | 'missing-space-after' // -[x]Task
74
+ | 'missing-space-before' // -[x] Task
75
+ | 'relative-date-no-context' // [due: tomorrow] without heading date
76
+ | 'missing-due-date' // Incomplete task with no due date
77
+ | 'missing-completed-date' // [x] without [completed: date]
78
+ | 'duplicate-todoist-id' // Same Todoist ID in multiple tasks
79
+ | 'file-read-error'; // Failed to read file
80
+
68
81
  export interface Warning {
82
+ // Position
69
83
  file: string;
70
84
  line: number;
71
- text: string;
72
- reason: string;
85
+ column?: number;
86
+
87
+ // Classification
88
+ severity: WarningSeverity;
89
+ source: 'md2do';
90
+ ruleId: WarningCode;
91
+
92
+ // Content
93
+ message: string; // User-facing message
94
+ text?: string; // The actual text that triggered it
95
+
96
+ // Documentation (optional - for future use)
97
+ url?: string;
98
+
99
+ // Legacy field for backward compatibility (deprecated)
100
+ /** @deprecated Use message instead */
101
+ reason?: string;
73
102
  }
@@ -0,0 +1,93 @@
1
+ import type { Warning } from '../types/index.js';
2
+
3
+ export interface WarningFilterConfig {
4
+ enabled?: boolean | undefined;
5
+ rules?: Record<string, 'error' | 'warn' | 'info' | 'off'> | undefined;
6
+ }
7
+
8
+ /**
9
+ * Filter warnings based on configuration rules
10
+ *
11
+ * This function applies warning configuration rules to filter out disabled warnings
12
+ * and optionally all warnings if globally disabled.
13
+ *
14
+ * @param warnings - Array of warnings to filter
15
+ * @param config - Warning configuration with enabled flag and rule overrides
16
+ * @returns Filtered array of warnings
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * const config = {
21
+ * enabled: true,
22
+ * rules: {
23
+ * 'missing-due-date': 'off',
24
+ * 'duplicate-todoist-id': 'error',
25
+ * },
26
+ * };
27
+ *
28
+ * const filtered = filterWarnings(allWarnings, config);
29
+ * // Returns warnings except those with ruleId 'missing-due-date'
30
+ * ```
31
+ */
32
+ export function filterWarnings(
33
+ warnings: Warning[],
34
+ config: WarningFilterConfig = {},
35
+ ): Warning[] {
36
+ // If warnings are globally disabled, return empty array
37
+ if (config.enabled === false) {
38
+ return [];
39
+ }
40
+
41
+ // If no rules specified, return all warnings
42
+ if (!config.rules) {
43
+ return warnings;
44
+ }
45
+
46
+ // Filter based on rule configuration
47
+ return warnings.filter((warning) => {
48
+ const level = config.rules?.[warning.ruleId];
49
+
50
+ // If rule is not configured, include the warning (default: show)
51
+ if (level === undefined) {
52
+ return true;
53
+ }
54
+
55
+ // Exclude if rule is set to 'off'
56
+ return level !== 'off';
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Group warnings by severity
62
+ *
63
+ * Useful for displaying warnings in order of importance or
64
+ * treating errors differently from warnings.
65
+ *
66
+ * @param warnings - Array of warnings to group
67
+ * @returns Object with warnings grouped by severity level
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * const grouped = groupWarningsBySeverity(warnings);
72
+ * console.log(`${grouped.error.length} errors`);
73
+ * console.log(`${grouped.warning.length} warnings`);
74
+ * console.log(`${grouped.info.length} info messages`);
75
+ * ```
76
+ */
77
+ export function groupWarningsBySeverity(warnings: Warning[]): {
78
+ error: Warning[];
79
+ warning: Warning[];
80
+ info: Warning[];
81
+ } {
82
+ return warnings.reduce(
83
+ (acc, warning) => {
84
+ acc[warning.severity].push(warning);
85
+ return acc;
86
+ },
87
+ { error: [], warning: [], info: [] } as {
88
+ error: Warning[];
89
+ warning: Warning[];
90
+ info: Warning[];
91
+ },
92
+ );
93
+ }
@@ -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
  });