@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
@@ -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
  }
@@ -0,0 +1,112 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { generateRuleCatalog, generateRuleDetailPage } from "./docs-rules";
3
+ import type { RuleEntry } from "../lint/rule-registry";
4
+
5
+ const mockEntries: RuleEntry[] = [
6
+ {
7
+ id: "COR001",
8
+ description: "No inline objects in Declarable constructors",
9
+ category: "style",
10
+ defaultSeverity: "warning",
11
+ source: "core",
12
+ phase: "pre-synth",
13
+ hasAutoFix: false,
14
+ },
15
+ {
16
+ id: "WAW018",
17
+ description: "S3 bucket missing public access block",
18
+ category: "security",
19
+ defaultSeverity: "error",
20
+ source: "aws",
21
+ phase: "post-synth",
22
+ hasAutoFix: false,
23
+ helpUri: "https://chant.dev/lint-rules/waw018",
24
+ },
25
+ {
26
+ id: "COR008",
27
+ description: "Export required for declarable instances",
28
+ category: "correctness",
29
+ defaultSeverity: "warning",
30
+ source: "core",
31
+ phase: "pre-synth",
32
+ hasAutoFix: true,
33
+ },
34
+ ];
35
+
36
+ describe("generateRuleCatalog", () => {
37
+ test("generates valid MDX with frontmatter", () => {
38
+ const mdx = generateRuleCatalog(mockEntries);
39
+
40
+ expect(mdx).toContain("---");
41
+ expect(mdx).toContain('title: "Rule Reference"');
42
+ expect(mdx).toContain("**3** rules");
43
+ });
44
+
45
+ test("groups rules by category", () => {
46
+ const mdx = generateRuleCatalog(mockEntries);
47
+
48
+ expect(mdx).toContain("## Correctness");
49
+ expect(mdx).toContain("## Security");
50
+ expect(mdx).toContain("## Style");
51
+ });
52
+
53
+ test("includes table headers", () => {
54
+ const mdx = generateRuleCatalog(mockEntries);
55
+
56
+ expect(mdx).toContain("| ID | Description | Severity | Phase | Fix |");
57
+ });
58
+
59
+ test("renders rule entries with correct data", () => {
60
+ const mdx = generateRuleCatalog(mockEntries);
61
+
62
+ expect(mdx).toContain("COR001");
63
+ expect(mdx).toContain("No inline objects");
64
+ expect(mdx).toContain("WAW018");
65
+ expect(mdx).toContain("public access");
66
+ });
67
+
68
+ test("marks auto-fix rules", () => {
69
+ const mdx = generateRuleCatalog(mockEntries);
70
+ const lines = mdx.split("\n");
71
+ const cor008Line = lines.find((l) => l.includes("COR008"));
72
+ expect(cor008Line).toContain("Yes");
73
+ });
74
+
75
+ test("renders helpUri as link", () => {
76
+ const mdx = generateRuleCatalog(mockEntries);
77
+ expect(mdx).toContain("[`WAW018`](https://chant.dev/lint-rules/waw018)");
78
+ });
79
+ });
80
+
81
+ describe("generateRuleDetailPage", () => {
82
+ test("generates valid MDX with frontmatter", () => {
83
+ const mdx = generateRuleDetailPage(mockEntries[0]);
84
+
85
+ expect(mdx).toContain("---");
86
+ expect(mdx).toContain("COR001");
87
+ expect(mdx).toContain("No inline objects");
88
+ });
89
+
90
+ test("includes metadata table", () => {
91
+ const mdx = generateRuleDetailPage(mockEntries[0]);
92
+
93
+ expect(mdx).toContain("| **Severity** | warning |");
94
+ expect(mdx).toContain("| **Category** | style |");
95
+ expect(mdx).toContain("| **Phase** | pre-synth |");
96
+ });
97
+
98
+ test("includes configuration example", () => {
99
+ const mdx = generateRuleDetailPage(mockEntries[0]);
100
+
101
+ expect(mdx).toContain("## Configuration");
102
+ expect(mdx).toContain('"COR001"');
103
+ });
104
+
105
+ test("includes disable syntax", () => {
106
+ const mdx = generateRuleDetailPage(mockEntries[0]);
107
+
108
+ expect(mdx).toContain("## Disabling");
109
+ expect(mdx).toContain("chant-disable COR001");
110
+ expect(mdx).toContain("chant-disable-next-line COR001 -- reason");
111
+ });
112
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Rule documentation auto-generation.
3
+ *
4
+ * Generates MDX pages from a RuleEntry registry:
5
+ * - A catalog page with all rules grouped by category
6
+ * - Per-rule detail pages with configuration examples and disable syntax
7
+ */
8
+
9
+ import type { RuleEntry } from "../lint/rule-registry";
10
+
11
+ /**
12
+ * Generate a rule catalog MDX page with all rules grouped by category.
13
+ */
14
+ export function generateRuleCatalog(
15
+ entries: RuleEntry[],
16
+ displayName = "chant",
17
+ ): string {
18
+ const lines: string[] = [
19
+ "---",
20
+ `title: "Rule Reference"`,
21
+ `description: "Complete reference of all lint rules and post-synth checks"`,
22
+ "---",
23
+ "",
24
+ `This page lists all **${entries.length}** rules available in ${displayName}.`,
25
+ "",
26
+ ];
27
+
28
+ // Group by category
29
+ const byCategory = new Map<string, RuleEntry[]>();
30
+ for (const entry of entries) {
31
+ const cat = entry.category;
32
+ const existing = byCategory.get(cat) ?? [];
33
+ existing.push(entry);
34
+ byCategory.set(cat, existing);
35
+ }
36
+
37
+ // Render each category
38
+ const categoryOrder = ["correctness", "security", "style", "performance"];
39
+ const sortedCategories = [...byCategory.keys()].sort(
40
+ (a, b) => (categoryOrder.indexOf(a) ?? 99) - (categoryOrder.indexOf(b) ?? 99),
41
+ );
42
+
43
+ for (const cat of sortedCategories) {
44
+ const catEntries = byCategory.get(cat)!;
45
+ const title = cat.charAt(0).toUpperCase() + cat.slice(1);
46
+
47
+ lines.push(
48
+ `## ${title}`,
49
+ "",
50
+ "| ID | Description | Severity | Phase | Fix |",
51
+ "|----|-------------|----------|-------|-----|",
52
+ );
53
+
54
+ for (const entry of catEntries.sort((a, b) => a.id.localeCompare(b.id))) {
55
+ const fix = entry.hasAutoFix ? "Yes" : "";
56
+ const link = entry.helpUri
57
+ ? `[\`${entry.id}\`](${entry.helpUri})`
58
+ : `\`${entry.id}\``;
59
+ lines.push(
60
+ `| ${link} | ${entry.description} | ${entry.defaultSeverity} | ${entry.phase} | ${fix} |`,
61
+ );
62
+ }
63
+
64
+ lines.push("");
65
+ }
66
+
67
+ return lines.join("\n");
68
+ }
69
+
70
+ /**
71
+ * Generate a per-rule detail MDX page.
72
+ */
73
+ export function generateRuleDetailPage(entry: RuleEntry): string {
74
+ const lines: string[] = [
75
+ "---",
76
+ `title: "${entry.id}: ${entry.description}"`,
77
+ `description: "${entry.description}"`,
78
+ "---",
79
+ "",
80
+ `# ${entry.id}`,
81
+ "",
82
+ entry.description,
83
+ "",
84
+ "| Property | Value |",
85
+ "|----------|-------|",
86
+ `| **ID** | \`${entry.id}\` |`,
87
+ `| **Severity** | ${entry.defaultSeverity} |`,
88
+ `| **Category** | ${entry.category} |`,
89
+ `| **Phase** | ${entry.phase} |`,
90
+ `| **Source** | ${entry.source} |`,
91
+ `| **Auto-fix** | ${entry.hasAutoFix ? "Yes" : "No"} |`,
92
+ "",
93
+ ];
94
+
95
+ // Configuration example
96
+ lines.push(
97
+ "## Configuration",
98
+ "",
99
+ "Override severity in your `chant.config.ts`:",
100
+ "",
101
+ "```ts",
102
+ "// chant.config.ts",
103
+ `rules: {`,
104
+ ` "${entry.id}": "warning", // or "error", "info", "off"`,
105
+ `}`,
106
+ "```",
107
+ "",
108
+ );
109
+
110
+ // Disable syntax
111
+ lines.push(
112
+ "## Disabling",
113
+ "",
114
+ "Suppress this rule with inline comments:",
115
+ "",
116
+ "```ts",
117
+ `// chant-disable ${entry.id}`,
118
+ `// ... entire file suppressed for ${entry.id}`,
119
+ "",
120
+ `const x = 1; // chant-disable-line ${entry.id}`,
121
+ "",
122
+ `// chant-disable-next-line ${entry.id} -- reason for suppression`,
123
+ `const y = 2;`,
124
+ "```",
125
+ "",
126
+ );
127
+
128
+ return lines.join("\n");
129
+ }
@@ -32,7 +32,7 @@ export interface DocsConfig {
32
32
  /** Custom sections to append to overview page */
33
33
  extraSections?: Array<{ title: string; content: string }>;
34
34
  /** Standalone pages added to the sidebar after Overview */
35
- extraPages?: Array<{ slug: string; title: string; description?: string; content: string }>;
35
+ extraPages?: Array<{ slug: string; title: string; description?: string; content: string; sidebar?: boolean }>;
36
36
  /** Slugs of auto-generated pages to suppress (e.g. "pseudo-parameters") */
37
37
  suppressPages?: string[];
38
38
  /** Source directory for scanning rule files (defaults to srcDir sibling of distDir) */
@@ -385,6 +385,7 @@ function buildSidebar(
385
385
  // Extra pages from lexicon config (appear after Overview)
386
386
  if (config.extraPages) {
387
387
  for (const page of config.extraPages) {
388
+ if (page.sidebar === false) continue;
388
389
  items.push({ label: page.title, slug: page.slug });
389
390
  }
390
391
  }
@@ -447,6 +448,7 @@ function generateOverview(
447
448
  // Extra pages listed first in reference links
448
449
  if (config.extraPages && config.extraPages.length > 0) {
449
450
  for (const page of config.extraPages) {
451
+ if (page.sidebar === false) continue;
450
452
  lines.push(`- [${page.title}](./${page.slug})`);
451
453
  }
452
454
  }