@intentius/chant 0.0.5 → 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 (91) hide show
  1. package/bin/chant +20 -0
  2. package/package.json +18 -17
  3. package/src/bench.test.ts +1 -1
  4. package/src/cli/commands/__fixtures__/init-lexicon-output/src/plugin.ts +0 -25
  5. package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +0 -25
  6. package/src/cli/commands/build.ts +1 -2
  7. package/src/cli/commands/doctor.ts +8 -3
  8. package/src/cli/commands/import.ts +2 -2
  9. package/src/cli/commands/init-lexicon.test.ts +0 -3
  10. package/src/cli/commands/init-lexicon.ts +1 -79
  11. package/src/cli/commands/init.test.ts +44 -4
  12. package/src/cli/commands/init.ts +69 -26
  13. package/src/cli/commands/lint.ts +27 -13
  14. package/src/cli/commands/list.ts +2 -2
  15. package/src/cli/commands/update.ts +5 -3
  16. package/src/cli/conflict-check.test.ts +0 -1
  17. package/src/cli/handlers/dev.ts +1 -9
  18. package/src/cli/handlers/init.ts +1 -0
  19. package/src/cli/lsp/server.ts +1 -1
  20. package/src/cli/main.ts +17 -3
  21. package/src/cli/mcp/server.test.ts +233 -4
  22. package/src/cli/mcp/server.ts +6 -0
  23. package/src/cli/mcp/tools/explain.ts +134 -0
  24. package/src/cli/mcp/tools/scaffold.ts +125 -0
  25. package/src/cli/mcp/tools/search.ts +98 -0
  26. package/src/cli/registry.ts +1 -0
  27. package/src/cli/reporters/stylish.test.ts +212 -1
  28. package/src/cli/reporters/stylish.ts +133 -36
  29. package/src/codegen/docs-rules.test.ts +112 -0
  30. package/src/codegen/docs-rules.ts +129 -0
  31. package/src/codegen/docs.ts +3 -1
  32. package/src/codegen/generate-registry.test.ts +1 -1
  33. package/src/codegen/generate-registry.ts +2 -3
  34. package/src/codegen/generate-typescript.test.ts +70 -6
  35. package/src/codegen/generate-typescript.ts +15 -9
  36. package/src/codegen/generate.ts +1 -12
  37. package/src/codegen/package.ts +1 -1
  38. package/src/codegen/typecheck.ts +6 -11
  39. package/src/composite.test.ts +83 -16
  40. package/src/composite.ts +7 -5
  41. package/src/config.ts +4 -0
  42. package/src/detectLexicon.test.ts +2 -2
  43. package/src/discovery/collect.test.ts +2 -2
  44. package/src/discovery/collect.ts +1 -1
  45. package/src/index.ts +2 -1
  46. package/src/lexicon-integrity.ts +5 -4
  47. package/src/lexicon-schema.ts +8 -0
  48. package/src/lexicon.ts +15 -7
  49. package/src/lint/config.ts +8 -6
  50. package/src/lint/declarative.ts +6 -0
  51. package/src/lint/engine.test.ts +287 -11
  52. package/src/lint/engine.ts +101 -23
  53. package/src/lint/rule-registry.test.ts +112 -0
  54. package/src/lint/rule-registry.ts +118 -0
  55. package/src/lint/rule.ts +8 -0
  56. package/src/lint/rules/cor017-composite-name-match.ts +2 -1
  57. package/src/lint/rules/cor018-composite-prefer-lexicon-type.ts +4 -3
  58. package/src/lint/rules/declarable-naming-convention.ts +1 -0
  59. package/src/lint/rules/evl001-non-literal-expression.ts +1 -0
  60. package/src/lint/rules/evl002-control-flow-resource.ts +1 -0
  61. package/src/lint/rules/evl003-dynamic-property-access.ts +1 -0
  62. package/src/lint/rules/evl004-spread-non-const.ts +1 -0
  63. package/src/lint/rules/evl005-resource-block-body.ts +1 -0
  64. package/src/lint/rules/evl007-invalid-siblings.ts +1 -0
  65. package/src/lint/rules/evl009-composite-no-constant.ts +1 -0
  66. package/src/lint/rules/evl010-composite-no-transform.ts +1 -0
  67. package/src/lint/rules/export-required.ts +1 -0
  68. package/src/lint/rules/file-declarable-limit.ts +1 -0
  69. package/src/lint/rules/flat-declarations.test.ts +8 -7
  70. package/src/lint/rules/flat-declarations.ts +2 -3
  71. package/src/lint/rules/no-cyclic-declarable-ref.ts +1 -0
  72. package/src/lint/rules/no-redundant-type-import.ts +1 -0
  73. package/src/lint/rules/no-redundant-value-cast.ts +1 -0
  74. package/src/lint/rules/no-string-ref.ts +1 -0
  75. package/src/lint/rules/no-unused-declarable-import.ts +1 -0
  76. package/src/lint/rules/no-unused-declarable.test.ts +8 -0
  77. package/src/lint/rules/no-unused-declarable.ts +4 -0
  78. package/src/lint/rules/single-concern-file.ts +1 -0
  79. package/src/lsp/lexicon-providers.ts +7 -0
  80. package/src/lsp/types.ts +1 -0
  81. package/src/resource-attributes.test.ts +79 -0
  82. package/src/resource-attributes.ts +42 -0
  83. package/src/runtime-adapter.ts +158 -0
  84. package/src/runtime.ts +4 -3
  85. package/src/serializer-walker.test.ts +0 -9
  86. package/src/serializer-walker.ts +1 -3
  87. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/rollback.ts +0 -45
  88. package/src/codegen/case.test.ts +0 -30
  89. package/src/codegen/case.ts +0 -11
  90. package/src/codegen/rollback.test.ts +0 -92
  91. package/src/codegen/rollback.ts +0 -115
@@ -0,0 +1,125 @@
1
+ import type { LexiconPlugin } from "../../../lexicon";
2
+
3
+ /**
4
+ * Scaffold tool definition for MCP
5
+ */
6
+ export const scaffoldTool = {
7
+ name: "scaffold",
8
+ description: "Generate starter code for a common infrastructure pattern",
9
+ inputSchema: {
10
+ type: "object" as const,
11
+ properties: {
12
+ pattern: {
13
+ type: "string",
14
+ description: "Infrastructure pattern to scaffold (e.g. 's3-bucket', 'lambda', 'pipeline')",
15
+ },
16
+ lexicon: {
17
+ type: "string",
18
+ description: "Lexicon to use for scaffolding (e.g. 'aws', 'gitlab'). Auto-detected if omitted.",
19
+ },
20
+ template: {
21
+ type: "string",
22
+ description: "Named init template (e.g. 'node-pipeline', 'docker-build'). When provided, returns the template's source files instead of pattern-matching.",
23
+ },
24
+ },
25
+ required: ["pattern"],
26
+ },
27
+ };
28
+
29
+ /**
30
+ * Create a scaffold handler with access to loaded plugins
31
+ */
32
+ export function createScaffoldHandler(
33
+ plugins: LexiconPlugin[],
34
+ ): (params: Record<string, unknown>) => Promise<unknown> {
35
+ return async (params) => {
36
+ const pattern = params.pattern as string;
37
+ const lexiconName = params.lexicon as string | undefined;
38
+ const templateName = params.template as string | undefined;
39
+
40
+ // Try to find a matching plugin
41
+ const candidates = lexiconName
42
+ ? plugins.filter((p) => p.name === lexiconName)
43
+ : plugins;
44
+
45
+ // Search plugin init templates for a pattern match
46
+ for (const plugin of candidates) {
47
+ const templateSet = plugin.initTemplates?.(templateName);
48
+ if (!templateSet) continue;
49
+
50
+ // If a named template was requested, return all its source files
51
+ if (templateName) {
52
+ const files = Object.entries(templateSet.src).map(([filename, content]) => ({ filename, content }));
53
+ if (files.length > 0) {
54
+ return {
55
+ lexicon: plugin.name,
56
+ pattern,
57
+ template: templateName,
58
+ files,
59
+ };
60
+ }
61
+ }
62
+
63
+ // Match template filenames against the pattern (case-insensitive substring)
64
+ const lowerPattern = pattern.toLowerCase();
65
+ const matched: Array<{ filename: string; content: string }> = [];
66
+
67
+ for (const [filename, content] of Object.entries(templateSet.src)) {
68
+ if (filename.toLowerCase().includes(lowerPattern)) {
69
+ matched.push({ filename, content });
70
+ }
71
+ }
72
+
73
+ if (matched.length > 0) {
74
+ return {
75
+ lexicon: plugin.name,
76
+ pattern,
77
+ files: matched,
78
+ };
79
+ }
80
+ }
81
+
82
+ // Fall back to a generic skeleton
83
+ const configContent = `/**
84
+ * Shared configuration for ${pattern}
85
+ */
86
+
87
+ // TODO: Import resource types from your lexicon
88
+ // import { ... } from "@intentius/chant-lexicon-<name>";
89
+
90
+ export const config = {
91
+ // Add shared configuration here
92
+ };
93
+ `;
94
+
95
+ const resourceContent = `/**
96
+ * ${pattern} resource definition
97
+ */
98
+
99
+ // TODO: Import resource types from your lexicon
100
+ // import { ... } from "@intentius/chant-lexicon-<name>";
101
+ // import { config } from "./config";
102
+
103
+ // export const ${toCamelCase(pattern)} = new ResourceType({
104
+ // // Add properties here
105
+ // });
106
+ `;
107
+
108
+ return {
109
+ lexicon: lexiconName ?? null,
110
+ pattern,
111
+ files: [
112
+ { filename: "config.ts", content: configContent },
113
+ { filename: `${pattern}.ts`, content: resourceContent },
114
+ ],
115
+ note: "No lexicon-specific template found. Generic skeleton provided — fill in imports and resource types.",
116
+ };
117
+ };
118
+ }
119
+
120
+ function toCamelCase(s: string): string {
121
+ return s
122
+ .split(/[-_]/)
123
+ .map((part, i) => (i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)))
124
+ .join("");
125
+ }
@@ -0,0 +1,98 @@
1
+ import type { LexiconPlugin } from "../../../lexicon";
2
+
3
+ /**
4
+ * Search tool definition for MCP
5
+ */
6
+ export const searchTool = {
7
+ name: "search",
8
+ description: "Search the resource catalog across loaded lexicons by keyword",
9
+ inputSchema: {
10
+ type: "object" as const,
11
+ properties: {
12
+ query: {
13
+ type: "string",
14
+ description: "Search query — matches against resource type, class name, and kind",
15
+ },
16
+ lexicon: {
17
+ type: "string",
18
+ description: "Filter results to a specific lexicon (e.g. 'aws', 'gitlab')",
19
+ },
20
+ limit: {
21
+ type: "number",
22
+ description: "Maximum number of results to return (default: 20)",
23
+ },
24
+ },
25
+ required: ["query"],
26
+ },
27
+ };
28
+
29
+ interface CatalogEntry {
30
+ className: string;
31
+ resourceType: string;
32
+ kind?: string;
33
+ }
34
+
35
+ /**
36
+ * Create a search handler with access to loaded plugins
37
+ */
38
+ export function createSearchHandler(
39
+ plugins: LexiconPlugin[],
40
+ ): (params: Record<string, unknown>) => Promise<unknown> {
41
+ return async (params) => {
42
+ const query = params.query as string;
43
+ const lexiconFilter = params.lexicon as string | undefined;
44
+ const limit = (params.limit as number) ?? 20;
45
+
46
+ const lowerQuery = query.toLowerCase();
47
+ const results: Array<CatalogEntry & { lexicon: string; score: number }> = [];
48
+
49
+ const candidates = lexiconFilter
50
+ ? plugins.filter((p) => p.name === lexiconFilter)
51
+ : plugins;
52
+
53
+ for (const plugin of candidates) {
54
+ const resources = plugin.mcpResources?.() ?? [];
55
+ const catalog = resources.find((r) => r.uri === "resource-catalog");
56
+ if (!catalog) continue;
57
+
58
+ let entries: CatalogEntry[];
59
+ try {
60
+ const raw = await catalog.handler();
61
+ entries = JSON.parse(raw);
62
+ } catch {
63
+ continue;
64
+ }
65
+
66
+ for (const entry of entries) {
67
+ const fields = [
68
+ entry.resourceType?.toLowerCase() ?? "",
69
+ entry.className?.toLowerCase() ?? "",
70
+ entry.kind?.toLowerCase() ?? "",
71
+ ];
72
+
73
+ const match = fields.some((f) => f.includes(lowerQuery));
74
+ if (!match) continue;
75
+
76
+ // Score: prefix match on resourceType or className ranks higher
77
+ const isPrefix = fields.some((f) => f.startsWith(lowerQuery));
78
+ const score = isPrefix ? 1 : 0;
79
+
80
+ results.push({ ...entry, lexicon: plugin.name, score });
81
+ }
82
+ }
83
+
84
+ // Sort: prefix matches first, then alphabetical by resourceType
85
+ results.sort((a, b) => {
86
+ if (a.score !== b.score) return b.score - a.score;
87
+ return (a.resourceType ?? "").localeCompare(b.resourceType ?? "");
88
+ });
89
+
90
+ const limited = results.slice(0, limit);
91
+
92
+ return {
93
+ query,
94
+ total: results.length,
95
+ results: limited.map(({ score: _score, ...entry }) => entry),
96
+ };
97
+ };
98
+ }
@@ -14,6 +14,7 @@ export interface ParsedArgs {
14
14
  force?: boolean;
15
15
  fix: boolean;
16
16
  lexicon?: string;
17
+ template?: string;
17
18
  watch: boolean;
18
19
  verbose: boolean;
19
20
  help: boolean;
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
2
  import { formatStylish, formatSummary, formatJson, formatSarif } from "./stylish";
3
- import type { LintDiagnostic } from "../../lint/rule";
3
+ import type { LintDiagnostic, LintRule } from "../../lint/rule";
4
4
 
5
5
  describe("formatStylish", () => {
6
6
  const originalNoColor = process.env.NO_COLOR;
@@ -279,4 +279,215 @@ describe("formatSarif", () => {
279
279
  // Should have 2 unique rules
280
280
  expect(parsed.runs[0].tool.driver.rules).toHaveLength(2);
281
281
  });
282
+
283
+ test("enriches rules with descriptions when LintRule objects are provided", () => {
284
+ const diagnostics: LintDiagnostic[] = [
285
+ {
286
+ file: "test.ts",
287
+ line: 1,
288
+ column: 1,
289
+ ruleId: "COR001",
290
+ severity: "warning",
291
+ message: "Issue",
292
+ },
293
+ ];
294
+
295
+ const rules: LintRule[] = [
296
+ {
297
+ id: "COR001",
298
+ severity: "warning",
299
+ category: "style",
300
+ description: "No inline objects in Declarable constructors",
301
+ check: () => [],
302
+ },
303
+ ];
304
+
305
+ const result = formatSarif(diagnostics, rules);
306
+ const parsed = JSON.parse(result);
307
+ const sarifRule = parsed.runs[0].tool.driver.rules[0];
308
+
309
+ expect(sarifRule.shortDescription.text).toBe("No inline objects in Declarable constructors");
310
+ expect(sarifRule.fullDescription.text).toBe("No inline objects in Declarable constructors");
311
+ expect(sarifRule.helpUri).toContain("cor001");
312
+ expect(sarifRule.defaultConfiguration.level).toBe("warning");
313
+ expect(sarifRule.properties.category).toBe("style");
314
+ });
315
+
316
+ test("includes fingerprints in results", () => {
317
+ const diagnostics: LintDiagnostic[] = [
318
+ {
319
+ file: "test.ts",
320
+ line: 10,
321
+ column: 5,
322
+ ruleId: "COR001",
323
+ severity: "warning",
324
+ message: "Something",
325
+ },
326
+ ];
327
+
328
+ const result = formatSarif(diagnostics);
329
+ const parsed = JSON.parse(result);
330
+
331
+ expect(parsed.runs[0].results[0].fingerprints).toBeDefined();
332
+ expect(parsed.runs[0].results[0].fingerprints["chant/v1"]).toBe("COR001:test.ts:10:5");
333
+ });
334
+
335
+ test("includes ruleIndex in results", () => {
336
+ const diagnostics: LintDiagnostic[] = [
337
+ {
338
+ file: "test.ts",
339
+ line: 1,
340
+ column: 1,
341
+ ruleId: "COR001",
342
+ severity: "warning",
343
+ message: "Issue",
344
+ },
345
+ ];
346
+
347
+ const result = formatSarif(diagnostics);
348
+ const parsed = JSON.parse(result);
349
+
350
+ expect(parsed.runs[0].results[0].ruleIndex).toBe(0);
351
+ });
352
+
353
+ test("includes endLine/endColumn when provided", () => {
354
+ const diagnostics: LintDiagnostic[] = [
355
+ {
356
+ file: "test.ts",
357
+ line: 10,
358
+ column: 5,
359
+ endLine: 12,
360
+ endColumn: 20,
361
+ ruleId: "COR001",
362
+ severity: "warning",
363
+ message: "Something",
364
+ },
365
+ ];
366
+
367
+ const result = formatSarif(diagnostics);
368
+ const parsed = JSON.parse(result);
369
+ const region = parsed.runs[0].results[0].locations[0].physicalLocation.region;
370
+
371
+ expect(region.startLine).toBe(10);
372
+ expect(region.startColumn).toBe(5);
373
+ expect(region.endLine).toBe(12);
374
+ expect(region.endColumn).toBe(20);
375
+ });
376
+
377
+ test("omits endLine/endColumn when not provided", () => {
378
+ const diagnostics: LintDiagnostic[] = [
379
+ {
380
+ file: "test.ts",
381
+ line: 10,
382
+ column: 5,
383
+ ruleId: "COR001",
384
+ severity: "warning",
385
+ message: "Something",
386
+ },
387
+ ];
388
+
389
+ const result = formatSarif(diagnostics);
390
+ const parsed = JSON.parse(result);
391
+ const region = parsed.runs[0].results[0].locations[0].physicalLocation.region;
392
+
393
+ expect(region.endLine).toBeUndefined();
394
+ expect(region.endColumn).toBeUndefined();
395
+ });
396
+
397
+ test("includes suppressed diagnostics with suppressions array", () => {
398
+ const diagnostics: LintDiagnostic[] = [
399
+ {
400
+ file: "test.ts",
401
+ line: 1,
402
+ column: 1,
403
+ ruleId: "COR001",
404
+ severity: "warning",
405
+ message: "Active issue",
406
+ },
407
+ ];
408
+
409
+ const suppressed: Array<LintDiagnostic & { reason?: string }> = [
410
+ {
411
+ file: "test.ts",
412
+ line: 5,
413
+ column: 1,
414
+ ruleId: "COR001",
415
+ severity: "warning",
416
+ message: "Suppressed issue",
417
+ reason: "backwards compat",
418
+ },
419
+ ];
420
+
421
+ const result = formatSarif(diagnostics, undefined, suppressed);
422
+ const parsed = JSON.parse(result);
423
+
424
+ // Should have 2 results total (1 active + 1 suppressed)
425
+ expect(parsed.runs[0].results).toHaveLength(2);
426
+
427
+ // First result: no suppressions
428
+ expect(parsed.runs[0].results[0].suppressions).toBeUndefined();
429
+
430
+ // Second result: has suppressions
431
+ expect(parsed.runs[0].results[1].suppressions).toHaveLength(1);
432
+ expect(parsed.runs[0].results[1].suppressions[0].kind).toBe("inSource");
433
+ expect(parsed.runs[0].results[1].suppressions[0].justification).toBe("backwards compat");
434
+ });
435
+
436
+ test("suppressed diagnostics without reason omit justification", () => {
437
+ const suppressed: Array<LintDiagnostic & { reason?: string }> = [
438
+ {
439
+ file: "test.ts",
440
+ line: 5,
441
+ column: 1,
442
+ ruleId: "COR001",
443
+ severity: "warning",
444
+ message: "Suppressed issue",
445
+ },
446
+ ];
447
+
448
+ const result = formatSarif([], undefined, suppressed);
449
+ const parsed = JSON.parse(result);
450
+
451
+ expect(parsed.runs[0].results[0].suppressions[0].kind).toBe("inSource");
452
+ expect(parsed.runs[0].results[0].suppressions[0].justification).toBeUndefined();
453
+ });
454
+
455
+ test("falls back to rule ID when no description available", () => {
456
+ const diagnostics: LintDiagnostic[] = [
457
+ {
458
+ file: "test.ts",
459
+ line: 1,
460
+ column: 1,
461
+ ruleId: "UNKNOWN",
462
+ severity: "warning",
463
+ message: "Issue",
464
+ },
465
+ ];
466
+
467
+ const result = formatSarif(diagnostics);
468
+ const parsed = JSON.parse(result);
469
+ const sarifRule = parsed.runs[0].tool.driver.rules[0];
470
+
471
+ expect(sarifRule.shortDescription.text).toBe("UNKNOWN");
472
+ expect(sarifRule.helpUri).toContain("unknown");
473
+ });
474
+
475
+ test("converts absolute file paths to file:// URIs", () => {
476
+ const diagnostics: LintDiagnostic[] = [
477
+ {
478
+ file: "/Users/test/project/test.ts",
479
+ line: 1,
480
+ column: 1,
481
+ ruleId: "COR001",
482
+ severity: "warning",
483
+ message: "Issue",
484
+ },
485
+ ];
486
+
487
+ const result = formatSarif(diagnostics);
488
+ const parsed = JSON.parse(result);
489
+ const uri = parsed.runs[0].results[0].locations[0].physicalLocation.artifactLocation.uri;
490
+
491
+ expect(uri).toMatch(/^file:\/\//);
492
+ });
282
493
  });
@@ -1,4 +1,5 @@
1
- import type { LintDiagnostic } from "../../lint/rule";
1
+ import type { LintDiagnostic, LintRule, Severity } from "../../lint/rule";
2
+ import { pathToFileURL } from "node:url";
2
3
 
3
4
  /**
4
5
  * ANSI color codes
@@ -121,10 +122,90 @@ export function formatJson(diagnostics: LintDiagnostic[]): string {
121
122
  return JSON.stringify(diagnostics, null, 2);
122
123
  }
123
124
 
125
+ /**
126
+ * Map lint severity to SARIF level
127
+ */
128
+ function mapSeverity(severity: Severity): "error" | "warning" | "note" {
129
+ if (severity === "error") return "error";
130
+ if (severity === "warning") return "warning";
131
+ return "note";
132
+ }
133
+
134
+ /**
135
+ * Build a SARIF region object, only including endLine/endColumn when available
136
+ */
137
+ function buildRegion(diag: LintDiagnostic): Record<string, number> {
138
+ const region: Record<string, number> = {
139
+ startLine: diag.line,
140
+ startColumn: diag.column,
141
+ };
142
+ if (diag.endLine !== undefined) region.endLine = diag.endLine;
143
+ if (diag.endColumn !== undefined) region.endColumn = diag.endColumn;
144
+ return region;
145
+ }
146
+
147
+ /**
148
+ * Build a SARIF result from a diagnostic
149
+ */
150
+ function buildSarifResult(diag: LintDiagnostic, ruleIndex: Map<string, number>) {
151
+ const result: Record<string, unknown> = {
152
+ ruleId: diag.ruleId,
153
+ ruleIndex: ruleIndex.get(diag.ruleId),
154
+ level: mapSeverity(diag.severity),
155
+ message: { text: diag.message },
156
+ locations: [
157
+ {
158
+ physicalLocation: {
159
+ artifactLocation: {
160
+ uri: diag.file.startsWith("/") ? pathToFileURL(diag.file).href : diag.file,
161
+ },
162
+ region: buildRegion(diag),
163
+ },
164
+ },
165
+ ],
166
+ fingerprints: {
167
+ "chant/v1": `${diag.ruleId}:${diag.file}:${diag.line}:${diag.column}`,
168
+ },
169
+ };
170
+ return result;
171
+ }
172
+
124
173
  /**
125
174
  * Format diagnostics as SARIF (Static Analysis Results Interchange Format)
175
+ *
176
+ * @param diagnostics - Active (non-suppressed) diagnostics
177
+ * @param rules - Full list of loaded LintRules for rich metadata (optional)
178
+ * @param suppressed - Suppressed diagnostics with optional reason (optional)
179
+ * @param version - Tool version string (optional, defaults to "0.1.0")
126
180
  */
127
- export function formatSarif(diagnostics: LintDiagnostic[]): string {
181
+ export function formatSarif(
182
+ diagnostics: LintDiagnostic[],
183
+ rules?: LintRule[],
184
+ suppressed?: Array<LintDiagnostic & { reason?: string }>,
185
+ version?: string,
186
+ ): string {
187
+ // Build rule metadata from LintRule objects when available, otherwise from diagnostics
188
+ const { sarifRules, ruleIndex } = buildRuleMetadata(diagnostics, suppressed ?? [], rules);
189
+
190
+ // Build active results
191
+ const results: Record<string, unknown>[] = diagnostics.map((diag) =>
192
+ buildSarifResult(diag, ruleIndex),
193
+ );
194
+
195
+ // Append suppressed results with SARIF suppressions
196
+ if (suppressed) {
197
+ for (const diag of suppressed) {
198
+ const result = buildSarifResult(diag, ruleIndex);
199
+ result.suppressions = [
200
+ {
201
+ kind: "inSource",
202
+ ...(diag.reason ? { justification: diag.reason } : {}),
203
+ },
204
+ ];
205
+ results.push(result);
206
+ }
207
+ }
208
+
128
209
  const sarif = {
129
210
  $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
130
211
  version: "2.1.0",
@@ -133,31 +214,12 @@ export function formatSarif(diagnostics: LintDiagnostic[]): string {
133
214
  tool: {
134
215
  driver: {
135
216
  name: "chant",
136
- version: "0.1.0",
217
+ version: version ?? "0.1.0",
137
218
  informationUri: "https://chant.dev",
138
- rules: getUniqueRules(diagnostics),
219
+ rules: sarifRules,
139
220
  },
140
221
  },
141
- results: diagnostics.map((diag) => ({
142
- ruleId: diag.ruleId,
143
- level: diag.severity === "error" ? "error" : diag.severity === "warning" ? "warning" : "note",
144
- message: {
145
- text: diag.message,
146
- },
147
- locations: [
148
- {
149
- physicalLocation: {
150
- artifactLocation: {
151
- uri: diag.file,
152
- },
153
- region: {
154
- startLine: diag.line,
155
- startColumn: diag.column,
156
- },
157
- },
158
- },
159
- ],
160
- })),
222
+ results,
161
223
  },
162
224
  ],
163
225
  };
@@ -166,21 +228,56 @@ export function formatSarif(diagnostics: LintDiagnostic[]): string {
166
228
  }
167
229
 
168
230
  /**
169
- * Extract unique rules from diagnostics for SARIF
231
+ * Build SARIF rule metadata from loaded LintRules and/or diagnostics.
232
+ * Returns both the rules array and a ruleId→index map for result references.
170
233
  */
171
- function getUniqueRules(diagnostics: LintDiagnostic[]): Array<{ id: string; shortDescription: { text: string } }> {
172
- const ruleIds = new Set<string>();
173
- const rules: Array<{ id: string; shortDescription: { text: string } }> = [];
234
+ function buildRuleMetadata(
235
+ diagnostics: LintDiagnostic[],
236
+ suppressed: LintDiagnostic[],
237
+ rules?: LintRule[],
238
+ ): {
239
+ sarifRules: Record<string, unknown>[];
240
+ ruleIndex: Map<string, number>;
241
+ } {
242
+ // Build a lookup from rule objects
243
+ const ruleMap = new Map<string, LintRule>();
244
+ if (rules) {
245
+ for (const r of rules) {
246
+ ruleMap.set(r.id, r);
247
+ }
248
+ }
174
249
 
175
- for (const diag of diagnostics) {
176
- if (!ruleIds.has(diag.ruleId)) {
177
- ruleIds.add(diag.ruleId);
178
- rules.push({
179
- id: diag.ruleId,
180
- shortDescription: { text: diag.ruleId },
181
- });
250
+ // Collect all unique rule IDs from diagnostics + suppressed
251
+ const seen = new Set<string>();
252
+ const orderedIds: string[] = [];
253
+ for (const diag of [...diagnostics, ...suppressed]) {
254
+ if (!seen.has(diag.ruleId)) {
255
+ seen.add(diag.ruleId);
256
+ orderedIds.push(diag.ruleId);
257
+ }
258
+ }
259
+
260
+ const sarifRules: Record<string, unknown>[] = [];
261
+ const ruleIndex = new Map<string, number>();
262
+
263
+ for (const id of orderedIds) {
264
+ const rule = ruleMap.get(id);
265
+ const descText = rule?.description || id;
266
+ const entry: Record<string, unknown> = {
267
+ id,
268
+ shortDescription: { text: descText },
269
+ fullDescription: { text: descText },
270
+ helpUri: rule?.helpUri || `https://chant.dev/lint-rules/${id.toLowerCase()}`,
271
+ defaultConfiguration: {
272
+ level: mapSeverity(rule?.severity ?? "warning"),
273
+ },
274
+ };
275
+ if (rule?.category) {
276
+ entry.properties = { category: rule.category };
182
277
  }
278
+ ruleIndex.set(id, sarifRules.length);
279
+ sarifRules.push(entry);
183
280
  }
184
281
 
185
- return rules;
282
+ return { sarifRules, ruleIndex };
186
283
  }