@intentius/chant 0.0.8 → 0.0.9

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 (62) hide show
  1. package/package.json +1 -1
  2. package/src/bench.test.ts +1 -1
  3. package/src/cli/commands/doctor.ts +8 -3
  4. package/src/cli/commands/init.test.ts +44 -4
  5. package/src/cli/commands/init.ts +55 -23
  6. package/src/cli/commands/lint.ts +27 -13
  7. package/src/cli/handlers/init.ts +1 -0
  8. package/src/cli/lsp/server.ts +1 -1
  9. package/src/cli/main.ts +4 -0
  10. package/src/cli/mcp/server.test.ts +28 -2
  11. package/src/cli/mcp/tools/scaffold.ts +21 -3
  12. package/src/cli/registry.ts +1 -0
  13. package/src/cli/reporters/stylish.test.ts +212 -1
  14. package/src/cli/reporters/stylish.ts +133 -36
  15. package/src/codegen/docs-rules.test.ts +112 -0
  16. package/src/codegen/docs-rules.ts +129 -0
  17. package/src/codegen/docs.ts +3 -1
  18. package/src/codegen/generate-typescript.test.ts +64 -0
  19. package/src/codegen/generate-typescript.ts +13 -3
  20. package/src/codegen/package.ts +1 -1
  21. package/src/composite.test.ts +83 -16
  22. package/src/composite.ts +7 -5
  23. package/src/detectLexicon.test.ts +2 -2
  24. package/src/discovery/collect.test.ts +2 -2
  25. package/src/discovery/collect.ts +1 -1
  26. package/src/index.ts +1 -0
  27. package/src/lexicon-schema.ts +8 -0
  28. package/src/lexicon.ts +13 -1
  29. package/src/lint/declarative.ts +6 -0
  30. package/src/lint/engine.test.ts +287 -11
  31. package/src/lint/engine.ts +101 -23
  32. package/src/lint/rule-registry.test.ts +112 -0
  33. package/src/lint/rule-registry.ts +118 -0
  34. package/src/lint/rule.ts +8 -0
  35. package/src/lint/rules/cor017-composite-name-match.ts +2 -1
  36. package/src/lint/rules/cor018-composite-prefer-lexicon-type.ts +4 -3
  37. package/src/lint/rules/declarable-naming-convention.ts +1 -0
  38. package/src/lint/rules/evl001-non-literal-expression.ts +1 -0
  39. package/src/lint/rules/evl002-control-flow-resource.ts +1 -0
  40. package/src/lint/rules/evl003-dynamic-property-access.ts +1 -0
  41. package/src/lint/rules/evl004-spread-non-const.ts +1 -0
  42. package/src/lint/rules/evl005-resource-block-body.ts +1 -0
  43. package/src/lint/rules/evl007-invalid-siblings.ts +1 -0
  44. package/src/lint/rules/evl009-composite-no-constant.ts +1 -0
  45. package/src/lint/rules/evl010-composite-no-transform.ts +1 -0
  46. package/src/lint/rules/export-required.ts +1 -0
  47. package/src/lint/rules/file-declarable-limit.ts +1 -0
  48. package/src/lint/rules/flat-declarations.test.ts +8 -7
  49. package/src/lint/rules/flat-declarations.ts +2 -3
  50. package/src/lint/rules/no-cyclic-declarable-ref.ts +1 -0
  51. package/src/lint/rules/no-redundant-type-import.ts +1 -0
  52. package/src/lint/rules/no-redundant-value-cast.ts +1 -0
  53. package/src/lint/rules/no-string-ref.ts +1 -0
  54. package/src/lint/rules/no-unused-declarable-import.ts +1 -0
  55. package/src/lint/rules/no-unused-declarable.test.ts +8 -0
  56. package/src/lint/rules/no-unused-declarable.ts +4 -0
  57. package/src/lint/rules/single-concern-file.ts +1 -0
  58. package/src/lsp/lexicon-providers.ts +7 -0
  59. package/src/lsp/types.ts +1 -0
  60. package/src/resource-attributes.test.ts +79 -0
  61. package/src/resource-attributes.ts +42 -0
  62. package/src/runtime.ts +4 -3
@@ -29,7 +29,7 @@ describe("runLint", () => {
29
29
  },
30
30
  };
31
31
 
32
- const diagnostics = await runLint([testFile], [mockRule]);
32
+ const { diagnostics } = await runLint([testFile], [mockRule]);
33
33
 
34
34
  expect(diagnostics).toHaveLength(1);
35
35
  expect(diagnostics[0].ruleId).toBe("test-rule");
@@ -64,7 +64,7 @@ const x = 1;`
64
64
  },
65
65
  };
66
66
 
67
- const diagnostics = await runLint([testFile], [mockRule]);
67
+ const { diagnostics } = await runLint([testFile], [mockRule]);
68
68
 
69
69
  expect(diagnostics).toHaveLength(0);
70
70
  });
@@ -115,7 +115,7 @@ const x = 1;`
115
115
  },
116
116
  };
117
117
 
118
- const diagnostics = await runLint([testFile], [mockRule1, mockRule2]);
118
+ const { diagnostics } = await runLint([testFile], [mockRule1, mockRule2]);
119
119
 
120
120
  expect(diagnostics).toHaveLength(1);
121
121
  expect(diagnostics[0].ruleId).toBe("test-rule-2");
@@ -157,7 +157,7 @@ const y = 2;`
157
157
  },
158
158
  };
159
159
 
160
- const diagnostics = await runLint([testFile], [mockRule]);
160
+ const { diagnostics } = await runLint([testFile], [mockRule]);
161
161
 
162
162
  expect(diagnostics).toHaveLength(1);
163
163
  expect(diagnostics[0].line).toBe(2);
@@ -200,7 +200,7 @@ const y = 2;`
200
200
  },
201
201
  };
202
202
 
203
- const diagnostics = await runLint([testFile], [mockRule]);
203
+ const { diagnostics } = await runLint([testFile], [mockRule]);
204
204
 
205
205
  expect(diagnostics).toHaveLength(1);
206
206
  expect(diagnostics[0].line).toBe(3);
@@ -248,7 +248,7 @@ const y = 2;`
248
248
  },
249
249
  };
250
250
 
251
- const diagnostics = await runLint([testFile], [mockRule1, mockRule2]);
251
+ const { diagnostics } = await runLint([testFile], [mockRule1, mockRule2]);
252
252
 
253
253
  expect(diagnostics).toHaveLength(1);
254
254
  expect(diagnostics[0].ruleId).toBe("test-rule-2");
@@ -282,7 +282,7 @@ const x = 1;`
282
282
  },
283
283
  };
284
284
 
285
- const diagnostics = await runLint([testFile], [mockRule]);
285
+ const { diagnostics } = await runLint([testFile], [mockRule]);
286
286
 
287
287
  expect(diagnostics).toHaveLength(1);
288
288
  expect(diagnostics[0].ruleId).toBe("test-rule");
@@ -335,7 +335,7 @@ const z = 3;`
335
335
  },
336
336
  };
337
337
 
338
- const diagnostics = await runLint([testFile], [mockRule]);
338
+ const { diagnostics } = await runLint([testFile], [mockRule]);
339
339
 
340
340
  expect(diagnostics).toHaveLength(1);
341
341
  expect(diagnostics[0].line).toBe(5);
@@ -367,7 +367,7 @@ const z = 3;`
367
367
  },
368
368
  };
369
369
 
370
- const diagnostics = await runLint([testFile1, testFile2], [mockRule]);
370
+ const { diagnostics } = await runLint([testFile1, testFile2], [mockRule]);
371
371
 
372
372
  expect(diagnostics).toHaveLength(2);
373
373
  expect(diagnostics.map((d) => d.file).sort()).toEqual([testFile1, testFile2].sort());
@@ -388,7 +388,7 @@ const z = 3;`
388
388
  },
389
389
  };
390
390
 
391
- const diagnostics = await runLint([testFile], [mockRule]);
391
+ const { diagnostics } = await runLint([testFile], [mockRule]);
392
392
  expect(diagnostics).toHaveLength(0);
393
393
  });
394
394
  });
@@ -456,10 +456,286 @@ const x = 1;`
456
456
  },
457
457
  };
458
458
 
459
- const diagnostics = await runLint([testFile], [mockRule1, mockRule2, mockRule3]);
459
+ const { diagnostics } = await runLint([testFile], [mockRule1, mockRule2, mockRule3]);
460
460
 
461
461
  expect(diagnostics).toHaveLength(1);
462
462
  expect(diagnostics[0].ruleId).toBe("test-rule-3");
463
463
  });
464
464
  });
465
+
466
+ test("parses reason from disable comment with -- separator", async () => {
467
+ await withTestDir(async (testDir) => {
468
+ const testFile = join(testDir, "test.ts");
469
+ await writeFile(
470
+ testFile,
471
+ `// chant-disable test-rule -- intentional for backwards compat
472
+ const x = 1;`
473
+ );
474
+
475
+ const mockRule: LintRule = {
476
+ id: "test-rule",
477
+ severity: "error",
478
+ category: "correctness",
479
+ check: (context: LintContext): LintDiagnostic[] => {
480
+ return [
481
+ {
482
+ file: context.filePath,
483
+ line: 2,
484
+ column: 1,
485
+ ruleId: "test-rule",
486
+ severity: "error",
487
+ message: "Test error",
488
+ },
489
+ ];
490
+ },
491
+ };
492
+
493
+ const { diagnostics, suppressed } = await runLint([testFile], [mockRule]);
494
+
495
+ expect(diagnostics).toHaveLength(0);
496
+ expect(suppressed).toHaveLength(1);
497
+ expect(suppressed[0].ruleId).toBe("test-rule");
498
+ expect(suppressed[0].reason).toBe("intentional for backwards compat");
499
+ });
500
+ });
501
+
502
+ test("parses reason from disable-all comment with -- separator", async () => {
503
+ await withTestDir(async (testDir) => {
504
+ const testFile = join(testDir, "test.ts");
505
+ await writeFile(
506
+ testFile,
507
+ `// chant-disable -- entire file suppressed for migration
508
+ const x = 1;`
509
+ );
510
+
511
+ const mockRule: LintRule = {
512
+ id: "test-rule",
513
+ severity: "error",
514
+ category: "correctness",
515
+ check: (context: LintContext): LintDiagnostic[] => {
516
+ return [
517
+ {
518
+ file: context.filePath,
519
+ line: 2,
520
+ column: 1,
521
+ ruleId: "test-rule",
522
+ severity: "error",
523
+ message: "Test error",
524
+ },
525
+ ];
526
+ },
527
+ };
528
+
529
+ const { diagnostics, suppressed } = await runLint([testFile], [mockRule]);
530
+
531
+ expect(diagnostics).toHaveLength(0);
532
+ expect(suppressed).toHaveLength(1);
533
+ expect(suppressed[0].reason).toBe("entire file suppressed for migration");
534
+ });
535
+ });
536
+
537
+ test("backwards compat: no reason when -- is absent", async () => {
538
+ await withTestDir(async (testDir) => {
539
+ const testFile = join(testDir, "test.ts");
540
+ await writeFile(
541
+ testFile,
542
+ `// chant-disable test-rule
543
+ const x = 1;`
544
+ );
545
+
546
+ const mockRule: LintRule = {
547
+ id: "test-rule",
548
+ severity: "error",
549
+ category: "correctness",
550
+ check: (context: LintContext): LintDiagnostic[] => {
551
+ return [
552
+ {
553
+ file: context.filePath,
554
+ line: 2,
555
+ column: 1,
556
+ ruleId: "test-rule",
557
+ severity: "error",
558
+ message: "Test error",
559
+ },
560
+ ];
561
+ },
562
+ };
563
+
564
+ const { diagnostics, suppressed } = await runLint([testFile], [mockRule]);
565
+
566
+ expect(diagnostics).toHaveLength(0);
567
+ expect(suppressed).toHaveLength(1);
568
+ expect(suppressed[0].reason).toBeUndefined();
569
+ });
570
+ });
571
+
572
+ test("handles reason with multiple -- in text", async () => {
573
+ await withTestDir(async (testDir) => {
574
+ const testFile = join(testDir, "test.ts");
575
+ await writeFile(
576
+ testFile,
577
+ `// chant-disable test-rule -- reason with -- dashes in it
578
+ const x = 1;`
579
+ );
580
+
581
+ const mockRule: LintRule = {
582
+ id: "test-rule",
583
+ severity: "error",
584
+ category: "correctness",
585
+ check: (context: LintContext): LintDiagnostic[] => {
586
+ return [
587
+ {
588
+ file: context.filePath,
589
+ line: 2,
590
+ column: 1,
591
+ ruleId: "test-rule",
592
+ severity: "error",
593
+ message: "Test error",
594
+ },
595
+ ];
596
+ },
597
+ };
598
+
599
+ const { suppressed } = await runLint([testFile], [mockRule]);
600
+
601
+ expect(suppressed).toHaveLength(1);
602
+ expect(suppressed[0].reason).toBe("reason with -- dashes in it");
603
+ });
604
+ });
605
+
606
+ test("trailing -- with no text treats reason as undefined", async () => {
607
+ await withTestDir(async (testDir) => {
608
+ const testFile = join(testDir, "test.ts");
609
+ await writeFile(
610
+ testFile,
611
+ `// chant-disable test-rule --
612
+ const x = 1;`
613
+ );
614
+
615
+ const mockRule: LintRule = {
616
+ id: "test-rule",
617
+ severity: "error",
618
+ category: "correctness",
619
+ check: (context: LintContext): LintDiagnostic[] => {
620
+ return [
621
+ {
622
+ file: context.filePath,
623
+ line: 2,
624
+ column: 1,
625
+ ruleId: "test-rule",
626
+ severity: "error",
627
+ message: "Test error",
628
+ },
629
+ ];
630
+ },
631
+ };
632
+
633
+ const { suppressed } = await runLint([testFile], [mockRule]);
634
+
635
+ expect(suppressed).toHaveLength(1);
636
+ expect(suppressed[0].reason).toBeUndefined();
637
+ });
638
+ });
639
+
640
+ test("disable-line with reason", async () => {
641
+ await withTestDir(async (testDir) => {
642
+ const testFile = join(testDir, "test.ts");
643
+ await writeFile(testFile, `const x = 1; // chant-disable-line test-rule -- CDN must use us-east-1`);
644
+
645
+ const mockRule: LintRule = {
646
+ id: "test-rule",
647
+ severity: "error",
648
+ category: "correctness",
649
+ check: (context: LintContext): LintDiagnostic[] => {
650
+ return [
651
+ {
652
+ file: context.filePath,
653
+ line: 1,
654
+ column: 1,
655
+ ruleId: "test-rule",
656
+ severity: "error",
657
+ message: "Test error",
658
+ },
659
+ ];
660
+ },
661
+ };
662
+
663
+ const { suppressed } = await runLint([testFile], [mockRule]);
664
+
665
+ expect(suppressed).toHaveLength(1);
666
+ expect(suppressed[0].reason).toBe("CDN must use us-east-1");
667
+ });
668
+ });
669
+
670
+ test("disable-next-line with reason", async () => {
671
+ await withTestDir(async (testDir) => {
672
+ const testFile = join(testDir, "test.ts");
673
+ await writeFile(
674
+ testFile,
675
+ `// chant-disable-next-line -- legacy code, will refactor
676
+ const x = 1;`
677
+ );
678
+
679
+ const mockRule: LintRule = {
680
+ id: "test-rule",
681
+ severity: "error",
682
+ category: "correctness",
683
+ check: (context: LintContext): LintDiagnostic[] => {
684
+ return [
685
+ {
686
+ file: context.filePath,
687
+ line: 2,
688
+ column: 1,
689
+ ruleId: "test-rule",
690
+ severity: "error",
691
+ message: "Test error",
692
+ },
693
+ ];
694
+ },
695
+ };
696
+
697
+ const { suppressed } = await runLint([testFile], [mockRule]);
698
+
699
+ expect(suppressed).toHaveLength(1);
700
+ expect(suppressed[0].reason).toBe("legacy code, will refactor");
701
+ });
702
+ });
703
+
704
+ test("suppressed diagnostics contain the original diagnostic data", async () => {
705
+ await withTestDir(async (testDir) => {
706
+ const testFile = join(testDir, "test.ts");
707
+ await writeFile(
708
+ testFile,
709
+ `// chant-disable test-rule -- reason
710
+ const x = 1;`
711
+ );
712
+
713
+ const mockRule: LintRule = {
714
+ id: "test-rule",
715
+ severity: "error",
716
+ category: "correctness",
717
+ check: (context: LintContext): LintDiagnostic[] => {
718
+ return [
719
+ {
720
+ file: context.filePath,
721
+ line: 2,
722
+ column: 1,
723
+ ruleId: "test-rule",
724
+ severity: "error",
725
+ message: "Test error",
726
+ },
727
+ ];
728
+ },
729
+ };
730
+
731
+ const { suppressed } = await runLint([testFile], [mockRule]);
732
+
733
+ expect(suppressed[0].file).toBe(testFile);
734
+ expect(suppressed[0].line).toBe(2);
735
+ expect(suppressed[0].column).toBe(1);
736
+ expect(suppressed[0].ruleId).toBe("test-rule");
737
+ expect(suppressed[0].severity).toBe("error");
738
+ expect(suppressed[0].message).toBe("Test error");
739
+ });
740
+ });
465
741
  });
@@ -2,6 +2,16 @@ import type { LintRule, LintDiagnostic, LintContext } from "./rule";
2
2
  import { parseFile } from "./parser";
3
3
  import { readFileSync } from "fs";
4
4
 
5
+ /**
6
+ * Result of a lint run, separating active diagnostics from suppressed ones.
7
+ */
8
+ export interface LintRunResult {
9
+ /** Diagnostics that were not suppressed by disable directives */
10
+ diagnostics: LintDiagnostic[];
11
+ /** Diagnostics that were suppressed by disable directives, with optional reason */
12
+ suppressed: Array<LintDiagnostic & { reason?: string }>;
13
+ }
14
+
5
15
  /**
6
16
  * Represents a disable directive found in source code comments
7
17
  */
@@ -12,6 +22,8 @@ interface DisableDirective {
12
22
  type: "file" | "line" | "next-line";
13
23
  /** Specific rule IDs to disable, or undefined for all rules */
14
24
  ruleIds?: string[];
25
+ /** Optional reason for the suppression (text after ` -- `) */
26
+ reason?: string;
15
27
  }
16
28
 
17
29
  /**
@@ -24,6 +36,60 @@ interface DisableDirective {
24
36
  * - // chant-disable-line rule-id1 - disable specific rules for line
25
37
  * - // chant-disable-next-line rule-id1 - disable specific rules for next line
26
38
  */
39
+ /**
40
+ * Parse the text after a directive keyword, splitting rule IDs from an optional reason.
41
+ * Reason is separated by ` -- ` (first occurrence only).
42
+ * Trailing `--` with no text is treated as no reason.
43
+ */
44
+ function parseDirectiveText(afterDirective: string | undefined): {
45
+ ruleIds?: string[];
46
+ reason?: string;
47
+ } {
48
+ if (!afterDirective) return {};
49
+
50
+ const trimmed = afterDirective.trim();
51
+ if (!trimmed) return {};
52
+
53
+ // Handle `-- reason` at the start (no rule IDs, just reason)
54
+ if (trimmed.startsWith("-- ")) {
55
+ const reasonPart = trimmed.slice(3).trim();
56
+ return { reason: reasonPart || undefined };
57
+ }
58
+
59
+ // Handle bare `--` (no rule IDs, no reason)
60
+ if (trimmed === "--") {
61
+ return {};
62
+ }
63
+
64
+ // Handle `RULE_ID -- reason` (rule IDs + reason separated by ` -- `)
65
+ const dashIdx = trimmed.indexOf(" -- ");
66
+ if (dashIdx >= 0) {
67
+ const idsPart = trimmed.slice(0, dashIdx).trim();
68
+ const reasonPart = trimmed.slice(dashIdx + 4).trim();
69
+ const ruleIds = idsPart ? idsPart.split(/\s+/).filter(Boolean) : undefined;
70
+ const reason = reasonPart || undefined;
71
+ return {
72
+ ruleIds: ruleIds && ruleIds.length > 0 ? ruleIds : undefined,
73
+ reason,
74
+ };
75
+ }
76
+
77
+ // Handle trailing ` --` with no reason text
78
+ if (trimmed.endsWith(" --")) {
79
+ const idsPart = trimmed.slice(0, -3).trim();
80
+ const ruleIds = idsPart ? idsPart.split(/\s+/).filter(Boolean) : undefined;
81
+ return {
82
+ ruleIds: ruleIds && ruleIds.length > 0 ? ruleIds : undefined,
83
+ };
84
+ }
85
+
86
+ // No `--` at all — just rule IDs
87
+ const ruleIds = trimmed.split(/\s+/).filter(Boolean);
88
+ return {
89
+ ruleIds: ruleIds.length > 0 ? ruleIds : undefined,
90
+ };
91
+ }
92
+
27
93
  function parseDisableComments(content: string): DisableDirective[] {
28
94
  const directives: DisableDirective[] = [];
29
95
  const lines = content.split("\n");
@@ -38,25 +104,28 @@ function parseDisableComments(content: string): DisableDirective[] {
38
104
  const disableNextLineMatch = line.match(/\/\/\s*chant-disable-next-line(?:\s+(.+))?$/);
39
105
 
40
106
  if (disableNextLineMatch) {
41
- const ruleIds = disableNextLineMatch[1]?.trim().split(/\s+/).filter(Boolean);
107
+ const { ruleIds, reason } = parseDirectiveText(disableNextLineMatch[1]);
42
108
  directives.push({
43
109
  line: lineNumber + 1, // Next line
44
110
  type: "next-line",
45
- ruleIds: ruleIds && ruleIds.length > 0 ? ruleIds : undefined,
111
+ ruleIds,
112
+ reason,
46
113
  });
47
114
  } else if (disableLineMatch) {
48
- const ruleIds = disableLineMatch[1]?.trim().split(/\s+/).filter(Boolean);
115
+ const { ruleIds, reason } = parseDirectiveText(disableLineMatch[1]);
49
116
  directives.push({
50
117
  line: lineNumber,
51
118
  type: "line",
52
- ruleIds: ruleIds && ruleIds.length > 0 ? ruleIds : undefined,
119
+ ruleIds,
120
+ reason,
53
121
  });
54
122
  } else if (disableFileMatch) {
55
- const ruleIds = disableFileMatch[1]?.trim().split(/\s+/).filter(Boolean);
123
+ const { ruleIds, reason } = parseDirectiveText(disableFileMatch[1]);
56
124
  directives.push({
57
125
  line: lineNumber,
58
126
  type: "file",
59
- ruleIds: ruleIds && ruleIds.length > 0 ? ruleIds : undefined,
127
+ ruleIds,
128
+ reason,
60
129
  });
61
130
  }
62
131
  }
@@ -65,24 +134,25 @@ function parseDisableComments(content: string): DisableDirective[] {
65
134
  }
66
135
 
67
136
  /**
68
- * Check if a diagnostic should be suppressed based on disable directives
137
+ * Check if a diagnostic should be suppressed based on disable directives.
138
+ * Returns suppression info if suppressed, or null if not suppressed.
69
139
  */
70
140
  function isDiagnosticDisabled(
71
141
  diagnostic: LintDiagnostic,
72
142
  directives: DisableDirective[],
73
143
  allRuleIds: Set<string>
74
- ): boolean {
144
+ ): { suppressed: boolean; reason?: string } {
75
145
  // Check for file-level disables
76
146
  const fileDisables = directives.filter((d) => d.type === "file");
77
147
  for (const directive of fileDisables) {
78
148
  if (!directive.ruleIds) {
79
149
  // Disable all rules
80
- return true;
150
+ return { suppressed: true, reason: directive.reason };
81
151
  }
82
152
  // Check if rule ID exists before checking if it's disabled
83
153
  if (directive.ruleIds.some((id) => allRuleIds.has(id))) {
84
154
  if (directive.ruleIds.includes(diagnostic.ruleId)) {
85
- return true;
155
+ return { suppressed: true, reason: directive.reason };
86
156
  }
87
157
  }
88
158
  // Silently ignore non-existent rule IDs
@@ -95,18 +165,18 @@ function isDiagnosticDisabled(
95
165
  for (const directive of lineDisables) {
96
166
  if (!directive.ruleIds) {
97
167
  // Disable all rules
98
- return true;
168
+ return { suppressed: true, reason: directive.reason };
99
169
  }
100
170
  // Check if rule ID exists before checking if it's disabled
101
171
  if (directive.ruleIds.some((id) => allRuleIds.has(id))) {
102
172
  if (directive.ruleIds.includes(diagnostic.ruleId)) {
103
- return true;
173
+ return { suppressed: true, reason: directive.reason };
104
174
  }
105
175
  }
106
176
  // Silently ignore non-existent rule IDs
107
177
  }
108
178
 
109
- return false;
179
+ return { suppressed: false };
110
180
  }
111
181
 
112
182
  /**
@@ -114,14 +184,15 @@ function isDiagnosticDisabled(
114
184
  * @param files - Array of file paths to lint
115
185
  * @param rules - Array of lint rules to execute
116
186
  * @param ruleOptions - Optional map of rule ID to options object
117
- * @returns Array of diagnostics from all rules and files, with disable comments applied
187
+ * @returns LintRunResult with diagnostics and suppressed items
118
188
  */
119
189
  export async function runLint(
120
190
  files: string[],
121
191
  rules: LintRule[],
122
192
  ruleOptions?: Map<string, Record<string, unknown>>,
123
- ): Promise<LintDiagnostic[]> {
193
+ ): Promise<LintRunResult> {
124
194
  const allDiagnostics: LintDiagnostic[] = [];
195
+ const allSuppressed: Array<LintDiagnostic & { reason?: string }> = [];
125
196
  const allRuleIds = new Set(rules.map((r) => r.id));
126
197
 
127
198
  for (const filePath of files) {
@@ -148,21 +219,28 @@ export async function runLint(
148
219
  allDiagnostics.push(...diagnostics);
149
220
  }
150
221
 
151
- // Filter out disabled diagnostics for this file
152
- const filteredDiagnostics = allDiagnostics.filter(
153
- (diagnostic) => diagnostic.file === filePath && !isDiagnosticDisabled(diagnostic, directives, allRuleIds)
154
- );
222
+ // Partition file diagnostics into active and suppressed
223
+ const fileDiags = allDiagnostics.filter((d) => d.file === filePath);
224
+ const otherFileDiags = allDiagnostics.filter((d) => d.file !== filePath);
225
+
226
+ const keptDiags: LintDiagnostic[] = [];
227
+ for (const diagnostic of fileDiags) {
228
+ const result = isDiagnosticDisabled(diagnostic, directives, allRuleIds);
229
+ if (result.suppressed) {
230
+ allSuppressed.push({ ...diagnostic, reason: result.reason });
231
+ } else {
232
+ keptDiags.push(diagnostic);
233
+ }
234
+ }
155
235
 
156
236
  // Replace file diagnostics with filtered ones
157
- const otherFileDiagnostics = allDiagnostics.filter((d) => d.file !== filePath);
158
237
  allDiagnostics.length = 0;
159
- allDiagnostics.push(...otherFileDiagnostics, ...filteredDiagnostics);
238
+ allDiagnostics.push(...otherFileDiags, ...keptDiags);
160
239
  } catch (error) {
161
240
  // If parsing fails, skip this file and continue with others
162
- // In a real implementation, you might want to collect these errors
163
241
  continue;
164
242
  }
165
243
  }
166
244
 
167
- return allDiagnostics;
245
+ return { diagnostics: allDiagnostics, suppressed: allSuppressed };
168
246
  }