@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.
- package/bin/chant +20 -0
- package/package.json +18 -17
- package/src/bench.test.ts +1 -1
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/plugin.ts +0 -25
- package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +0 -25
- package/src/cli/commands/build.ts +1 -2
- package/src/cli/commands/doctor.ts +8 -3
- package/src/cli/commands/import.ts +2 -2
- package/src/cli/commands/init-lexicon.test.ts +0 -3
- package/src/cli/commands/init-lexicon.ts +1 -79
- package/src/cli/commands/init.test.ts +44 -4
- package/src/cli/commands/init.ts +69 -26
- package/src/cli/commands/lint.ts +27 -13
- package/src/cli/commands/list.ts +2 -2
- package/src/cli/commands/update.ts +5 -3
- package/src/cli/conflict-check.test.ts +0 -1
- package/src/cli/handlers/dev.ts +1 -9
- package/src/cli/handlers/init.ts +1 -0
- package/src/cli/lsp/server.ts +1 -1
- package/src/cli/main.ts +17 -3
- package/src/cli/mcp/server.test.ts +233 -4
- package/src/cli/mcp/server.ts +6 -0
- package/src/cli/mcp/tools/explain.ts +134 -0
- package/src/cli/mcp/tools/scaffold.ts +125 -0
- package/src/cli/mcp/tools/search.ts +98 -0
- package/src/cli/registry.ts +1 -0
- package/src/cli/reporters/stylish.test.ts +212 -1
- package/src/cli/reporters/stylish.ts +133 -36
- package/src/codegen/docs-rules.test.ts +112 -0
- package/src/codegen/docs-rules.ts +129 -0
- package/src/codegen/docs.ts +3 -1
- package/src/codegen/generate-registry.test.ts +1 -1
- package/src/codegen/generate-registry.ts +2 -3
- package/src/codegen/generate-typescript.test.ts +70 -6
- package/src/codegen/generate-typescript.ts +15 -9
- package/src/codegen/generate.ts +1 -12
- package/src/codegen/package.ts +1 -1
- package/src/codegen/typecheck.ts +6 -11
- package/src/composite.test.ts +83 -16
- package/src/composite.ts +7 -5
- package/src/config.ts +4 -0
- package/src/detectLexicon.test.ts +2 -2
- package/src/discovery/collect.test.ts +2 -2
- package/src/discovery/collect.ts +1 -1
- package/src/index.ts +2 -1
- package/src/lexicon-integrity.ts +5 -4
- package/src/lexicon-schema.ts +8 -0
- package/src/lexicon.ts +15 -7
- package/src/lint/config.ts +8 -6
- package/src/lint/declarative.ts +6 -0
- package/src/lint/engine.test.ts +287 -11
- package/src/lint/engine.ts +101 -23
- package/src/lint/rule-registry.test.ts +112 -0
- package/src/lint/rule-registry.ts +118 -0
- package/src/lint/rule.ts +8 -0
- package/src/lint/rules/cor017-composite-name-match.ts +2 -1
- package/src/lint/rules/cor018-composite-prefer-lexicon-type.ts +4 -3
- package/src/lint/rules/declarable-naming-convention.ts +1 -0
- package/src/lint/rules/evl001-non-literal-expression.ts +1 -0
- package/src/lint/rules/evl002-control-flow-resource.ts +1 -0
- package/src/lint/rules/evl003-dynamic-property-access.ts +1 -0
- package/src/lint/rules/evl004-spread-non-const.ts +1 -0
- package/src/lint/rules/evl005-resource-block-body.ts +1 -0
- package/src/lint/rules/evl007-invalid-siblings.ts +1 -0
- package/src/lint/rules/evl009-composite-no-constant.ts +1 -0
- package/src/lint/rules/evl010-composite-no-transform.ts +1 -0
- package/src/lint/rules/export-required.ts +1 -0
- package/src/lint/rules/file-declarable-limit.ts +1 -0
- package/src/lint/rules/flat-declarations.test.ts +8 -7
- package/src/lint/rules/flat-declarations.ts +2 -3
- package/src/lint/rules/no-cyclic-declarable-ref.ts +1 -0
- package/src/lint/rules/no-redundant-type-import.ts +1 -0
- package/src/lint/rules/no-redundant-value-cast.ts +1 -0
- package/src/lint/rules/no-string-ref.ts +1 -0
- package/src/lint/rules/no-unused-declarable-import.ts +1 -0
- package/src/lint/rules/no-unused-declarable.test.ts +8 -0
- package/src/lint/rules/no-unused-declarable.ts +4 -0
- package/src/lint/rules/single-concern-file.ts +1 -0
- package/src/lsp/lexicon-providers.ts +7 -0
- package/src/lsp/types.ts +1 -0
- package/src/resource-attributes.test.ts +79 -0
- package/src/resource-attributes.ts +42 -0
- package/src/runtime-adapter.ts +158 -0
- package/src/runtime.ts +4 -3
- package/src/serializer-walker.test.ts +0 -9
- package/src/serializer-walker.ts +1 -3
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/rollback.ts +0 -45
- package/src/codegen/case.test.ts +0 -30
- package/src/codegen/case.ts +0 -11
- package/src/codegen/rollback.test.ts +0 -92
- 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
|
+
}
|
package/src/cli/registry.ts
CHANGED
|
@@ -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(
|
|
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:
|
|
219
|
+
rules: sarifRules,
|
|
139
220
|
},
|
|
140
221
|
},
|
|
141
|
-
results
|
|
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
|
-
*
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
282
|
+
return { sarifRules, ruleIndex };
|
|
186
283
|
}
|