@konvert7/klint 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +286 -0
- package/cli.ts +306 -0
- package/core/arch.ts +238 -0
- package/core/ast.ts +63 -0
- package/core/config.schema.ts +87 -0
- package/core/fixer.ts +44 -0
- package/core/runner.ts +119 -0
- package/core/types.ts +92 -0
- package/package.json +78 -0
- package/plugins/index.ts +6 -0
- package/plugins/sonar.ts +29 -0
- package/rules/index.ts +26 -0
- package/rules/no-async-predicate.ts +72 -0
- package/rules/no-consecutive-array-push.ts +56 -0
- package/rules/no-date-equality.ts +55 -0
- package/rules/no-floating-promise.ts +58 -0
- package/rules/no-misused-promises.ts +82 -0
- package/rules/no-nested-template-literals.ts +42 -0
- package/rules/no-object-in-template.ts +119 -0
- package/rules/no-optional-chain-on-non-nullable.ts +68 -0
- package/rules/no-single-char-class.ts +118 -0
- package/rules/no-string-match.ts +58 -0
- package/rules/no-sync-in-async.ts +35 -0
- package/rules/no-unguarded-json-parse.ts +30 -0
- package/rules/prefer-at.ts +54 -0
- package/rules/prefer-nullish-coalescing-assign.ts +68 -0
- package/rules/prefer-string-raw-regexp.ts +88 -0
- package/rules/prefer-string-raw.ts +47 -0
- package/rules/prefer-string-replaceall.ts +76 -0
- package/skill/klint-rules/SKILL.md +112 -0
- package/tools/generate-schema.ts +41 -0
package/core/arch.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { dirname, relative, resolve } from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { walkAst } from "./ast";
|
|
4
|
+
import type { ArchConfig, Severity, Violation } from "./types";
|
|
5
|
+
|
|
6
|
+
interface AliasEntry {
|
|
7
|
+
/** The prefix to match (pattern with `/*` stripped, e.g. `"@"` from `"@/*"`). */
|
|
8
|
+
prefix: string;
|
|
9
|
+
/** Resolved absolute base directory (target with `/*` stripped). */
|
|
10
|
+
base: string;
|
|
11
|
+
isWildcard: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function loadPathAliases(root: string): AliasEntry[] {
|
|
15
|
+
const tsconfigPath = resolve(root, "tsconfig.json");
|
|
16
|
+
const parsed = ts.getParsedCommandLineOfConfigFile(tsconfigPath, undefined, {
|
|
17
|
+
...ts.sys,
|
|
18
|
+
onUnRecoverableConfigFileDiagnostic: () => {},
|
|
19
|
+
});
|
|
20
|
+
if (!parsed) return [];
|
|
21
|
+
const { paths, baseUrl } = parsed.options;
|
|
22
|
+
if (!paths) return [];
|
|
23
|
+
// baseUrl is absolute when set; fall back to tsconfig dir for TS 5+ pathless baseUrl
|
|
24
|
+
const base = baseUrl ?? root;
|
|
25
|
+
const entries: AliasEntry[] = [];
|
|
26
|
+
for (const [pattern, targets] of Object.entries(paths)) {
|
|
27
|
+
if (targets.length === 0) continue;
|
|
28
|
+
const isWildcard = pattern.endsWith("/*");
|
|
29
|
+
const prefix = isWildcard ? pattern.slice(0, -2) : pattern;
|
|
30
|
+
const targetStr = targets[0];
|
|
31
|
+
const targetBase = targetStr.endsWith("/*") ? targetStr.slice(0, -2) : targetStr;
|
|
32
|
+
entries.push({ prefix, base: resolve(base, targetBase), isWildcard });
|
|
33
|
+
}
|
|
34
|
+
return entries;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveAlias(importPath: string, aliases: AliasEntry[]): string | undefined {
|
|
38
|
+
for (const alias of aliases) {
|
|
39
|
+
if (alias.isWildcard) {
|
|
40
|
+
const matchPrefix = `${alias.prefix}/`;
|
|
41
|
+
if (importPath.startsWith(matchPrefix)) {
|
|
42
|
+
return resolve(alias.base, importPath.slice(matchPrefix.length));
|
|
43
|
+
}
|
|
44
|
+
} else if (importPath === alias.prefix) {
|
|
45
|
+
return alias.base;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ImportRecord {
|
|
52
|
+
path: string;
|
|
53
|
+
resolved: string;
|
|
54
|
+
isTypeOnly: boolean;
|
|
55
|
+
line: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isBareSpecifier(path: string): boolean {
|
|
59
|
+
return !path.startsWith(".") && !path.startsWith("/");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function globToPrefix(glob: string, root: string): string {
|
|
63
|
+
return resolve(root, glob.split("/**")[0].split("/*")[0].split("*")[0]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveGlobs(
|
|
67
|
+
ref: string | string[],
|
|
68
|
+
layers: Record<string, string[]> | undefined
|
|
69
|
+
): string[] {
|
|
70
|
+
const items = Array.isArray(ref) ? ref : [ref];
|
|
71
|
+
return items.flatMap((item) => layers?.[item] ?? [item]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveLayerPrefixes(
|
|
75
|
+
ref: string | string[],
|
|
76
|
+
layers: Record<string, string[]> | undefined,
|
|
77
|
+
root: string
|
|
78
|
+
): string[] {
|
|
79
|
+
return resolveGlobs(ref, layers).map((g) => globToPrefix(g, root));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveLayerFiles(
|
|
83
|
+
ref: string | string[],
|
|
84
|
+
layers: Record<string, string[]> | undefined,
|
|
85
|
+
root: string,
|
|
86
|
+
allFiles: string[]
|
|
87
|
+
): string[] {
|
|
88
|
+
const prefixes = resolveLayerPrefixes(ref, layers, root);
|
|
89
|
+
return allFiles.filter((f) => prefixes.some((p) => f === p || f.startsWith(`${p}/`)));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function inPrefixes(absPath: string, prefixes: string[]): boolean {
|
|
93
|
+
return prefixes.some((p) => absPath === p || absPath.startsWith(`${p}/`));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function scanImports(
|
|
97
|
+
file: string,
|
|
98
|
+
content: string,
|
|
99
|
+
aliases: AliasEntry[]
|
|
100
|
+
): ImportRecord[] {
|
|
101
|
+
const records: ImportRecord[] = [];
|
|
102
|
+
const fileDir = dirname(file);
|
|
103
|
+
|
|
104
|
+
walkAst(file, content, (node, src) => {
|
|
105
|
+
let specifierNode: ts.StringLiteral | undefined;
|
|
106
|
+
let isTypeOnly = false;
|
|
107
|
+
|
|
108
|
+
if (ts.isImportDeclaration(node)) {
|
|
109
|
+
if (ts.isStringLiteral(node.moduleSpecifier)) {
|
|
110
|
+
specifierNode = node.moduleSpecifier;
|
|
111
|
+
isTypeOnly = node.importClause?.isTypeOnly ?? false;
|
|
112
|
+
}
|
|
113
|
+
} else if (
|
|
114
|
+
ts.isCallExpression(node) &&
|
|
115
|
+
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
116
|
+
node.arguments.length >= 1 &&
|
|
117
|
+
ts.isStringLiteral(node.arguments[0])
|
|
118
|
+
) {
|
|
119
|
+
specifierNode = node.arguments[0] as ts.StringLiteral;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!specifierNode) return;
|
|
123
|
+
|
|
124
|
+
const path = specifierNode.text;
|
|
125
|
+
let resolved: string;
|
|
126
|
+
if (isBareSpecifier(path)) {
|
|
127
|
+
resolved = resolveAlias(path, aliases) ?? path;
|
|
128
|
+
} else {
|
|
129
|
+
resolved = resolve(fileDir, path);
|
|
130
|
+
}
|
|
131
|
+
const { line } = src.getLineAndCharacterOfPosition(specifierNode.getStart());
|
|
132
|
+
records.push({ path, resolved, isTypeOnly, line: line + 1 });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return records;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function runArchRules(
|
|
139
|
+
arch: ArchConfig,
|
|
140
|
+
allFiles: string[],
|
|
141
|
+
fileContents: Map<string, string>,
|
|
142
|
+
root: string
|
|
143
|
+
): Violation[] {
|
|
144
|
+
const violations: Violation[] = [];
|
|
145
|
+
const layers = arch.layers;
|
|
146
|
+
const aliases = loadPathAliases(root);
|
|
147
|
+
|
|
148
|
+
for (const rule of arch.imports ?? []) {
|
|
149
|
+
const severity: Severity = rule.severity ?? "error";
|
|
150
|
+
const fromFiles = resolveLayerFiles(rule.from, layers, root, allFiles);
|
|
151
|
+
|
|
152
|
+
for (const file of fromFiles) {
|
|
153
|
+
const content = fileContents.get(file);
|
|
154
|
+
if (!content) continue;
|
|
155
|
+
|
|
156
|
+
for (const imp of scanImports(file, content, aliases)) {
|
|
157
|
+
if (isBareSpecifier(imp.resolved)) continue;
|
|
158
|
+
if (rule["type-only"] === "allow" && imp.isTypeOnly) continue;
|
|
159
|
+
|
|
160
|
+
const relFile = relative(root, file).replaceAll("\\", "/");
|
|
161
|
+
|
|
162
|
+
if (rule.deny !== undefined) {
|
|
163
|
+
const denyPrefixes = resolveLayerPrefixes(rule.deny, layers, root);
|
|
164
|
+
if (inPrefixes(imp.resolved, denyPrefixes)) {
|
|
165
|
+
violations.push({
|
|
166
|
+
file: relFile,
|
|
167
|
+
line: imp.line,
|
|
168
|
+
message: rule.message ?? "Import crosses a denied boundary",
|
|
169
|
+
rule: "arch/imports",
|
|
170
|
+
severity,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
} else if (rule.allow !== undefined) {
|
|
174
|
+
const allowPrefixes = resolveLayerPrefixes(rule.allow, layers, root);
|
|
175
|
+
if (!inPrefixes(imp.resolved, allowPrefixes)) {
|
|
176
|
+
violations.push({
|
|
177
|
+
file: relFile,
|
|
178
|
+
line: imp.line,
|
|
179
|
+
message: rule.message ?? "Import is not in the allowed list",
|
|
180
|
+
rule: "arch/imports",
|
|
181
|
+
severity,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const rule of arch.forbidden ?? []) {
|
|
190
|
+
const severity: Severity = rule.severity ?? "error";
|
|
191
|
+
const inFiles = resolveLayerFiles(rule.in, layers, root, allFiles);
|
|
192
|
+
|
|
193
|
+
for (const file of inFiles) {
|
|
194
|
+
const content = fileContents.get(file);
|
|
195
|
+
if (!content) continue;
|
|
196
|
+
const lines = content.split("\n");
|
|
197
|
+
for (let i = 0; i < lines.length; i++) {
|
|
198
|
+
if (lines[i].includes(rule.pattern)) {
|
|
199
|
+
violations.push({
|
|
200
|
+
file: relative(root, file).replaceAll("\\", "/"),
|
|
201
|
+
line: i + 1,
|
|
202
|
+
message: rule.message,
|
|
203
|
+
rule: "arch/forbidden",
|
|
204
|
+
severity,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const rule of arch.singleton ?? []) {
|
|
212
|
+
const severity: Severity = rule.severity ?? "error";
|
|
213
|
+
const onlyFile = resolve(root, rule.only);
|
|
214
|
+
const inFiles = rule.in
|
|
215
|
+
? resolveLayerFiles(rule.in, layers, root, allFiles)
|
|
216
|
+
: allFiles;
|
|
217
|
+
const scope = inFiles.filter((f) => f !== onlyFile);
|
|
218
|
+
|
|
219
|
+
for (const file of scope) {
|
|
220
|
+
const content = fileContents.get(file);
|
|
221
|
+
if (!content) continue;
|
|
222
|
+
const lines = content.split("\n");
|
|
223
|
+
for (let i = 0; i < lines.length; i++) {
|
|
224
|
+
if (lines[i].includes(rule.pattern)) {
|
|
225
|
+
violations.push({
|
|
226
|
+
file: relative(root, file).replaceAll("\\", "/"),
|
|
227
|
+
line: i + 1,
|
|
228
|
+
message: rule.message,
|
|
229
|
+
rule: "arch/singleton",
|
|
230
|
+
severity,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return violations;
|
|
238
|
+
}
|
package/core/ast.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
let _program: { key: string; program: ts.Program } | undefined;
|
|
4
|
+
let _sourceFiles: Map<string, ts.SourceFile> = new Map();
|
|
5
|
+
|
|
6
|
+
export function clearAstCache(): void {
|
|
7
|
+
_program = undefined;
|
|
8
|
+
_sourceFiles = new Map();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createProgram(files: string[], root: string): ts.Program {
|
|
12
|
+
const key = `${root}\0${[...files].sort().join("\0")}`;
|
|
13
|
+
if (_program?.key === key) return _program.program;
|
|
14
|
+
const configPath = ts.findConfigFile(root, ts.sys.fileExists);
|
|
15
|
+
let options: ts.CompilerOptions = { target: ts.ScriptTarget.Latest, strict: true };
|
|
16
|
+
if (configPath) {
|
|
17
|
+
const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
18
|
+
options = ts.parseJsonConfigFileContent(config, ts.sys, root).options;
|
|
19
|
+
}
|
|
20
|
+
const program = ts.createProgram(files, options);
|
|
21
|
+
_program = { key, program };
|
|
22
|
+
return program;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function nearestFunctionIsAsync(node: ts.Node): boolean {
|
|
26
|
+
let cur: ts.Node | undefined = node.parent;
|
|
27
|
+
while (cur) {
|
|
28
|
+
if (
|
|
29
|
+
ts.isFunctionDeclaration(cur) ||
|
|
30
|
+
ts.isFunctionExpression(cur) ||
|
|
31
|
+
ts.isArrowFunction(cur)
|
|
32
|
+
) {
|
|
33
|
+
return cur.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false;
|
|
34
|
+
}
|
|
35
|
+
cur = cur.parent;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isInsideTry(node: ts.Node): boolean {
|
|
41
|
+
let cur: ts.Node | undefined = node.parent;
|
|
42
|
+
while (cur) {
|
|
43
|
+
if (ts.isTryStatement(cur)) return true;
|
|
44
|
+
cur = cur.parent;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function walkAst(
|
|
50
|
+
filePath: string,
|
|
51
|
+
content: string,
|
|
52
|
+
visitor: (node: ts.Node, src: ts.SourceFile) => void
|
|
53
|
+
): void {
|
|
54
|
+
const cached = _sourceFiles.get(filePath);
|
|
55
|
+
const src =
|
|
56
|
+
cached ?? ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
|
57
|
+
if (!cached) _sourceFiles.set(filePath, src);
|
|
58
|
+
function recurse(node: ts.Node): void {
|
|
59
|
+
visitor(node, src);
|
|
60
|
+
ts.forEachChild(node, recurse);
|
|
61
|
+
}
|
|
62
|
+
recurse(src);
|
|
63
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { BUILT_IN_PLUGINS } from "../plugins/index";
|
|
3
|
+
import { BUILT_IN_RULES } from "../rules/index";
|
|
4
|
+
|
|
5
|
+
const builtInRuleNames = Object.keys(BUILT_IN_RULES) as [string, ...string[]];
|
|
6
|
+
const builtInPluginNames = Object.keys(BUILT_IN_PLUGINS) as [string, ...string[]];
|
|
7
|
+
const pluginRuleNames = [
|
|
8
|
+
...new Set(Object.values(BUILT_IN_PLUGINS).flatMap((p) => Object.keys(p.rules))),
|
|
9
|
+
] as [string, ...string[]];
|
|
10
|
+
const allKnownRuleNames = [...new Set([...builtInRuleNames, ...pluginRuleNames])] as [
|
|
11
|
+
string,
|
|
12
|
+
...string[],
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const SeveritySchema = z
|
|
16
|
+
.enum(["error", "warn", "off"])
|
|
17
|
+
.describe(
|
|
18
|
+
'Rule severity. "error" exits with code 2; "warn" reports but exits 0; "off" silences.'
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const RuleNameSchema = z
|
|
22
|
+
.union([z.enum(allKnownRuleNames), z.string()])
|
|
23
|
+
.describe(
|
|
24
|
+
"Built-in or plugin rule name (with autocomplete) or a custom rule name defined in klint.rules.ts."
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const RuleOptionsSchema = z
|
|
28
|
+
.object({
|
|
29
|
+
severity: SeveritySchema.optional(),
|
|
30
|
+
include: z
|
|
31
|
+
.array(z.string())
|
|
32
|
+
.optional()
|
|
33
|
+
.describe(
|
|
34
|
+
'Glob patterns scoping this rule to a subset of files. Prefix with ! to exclude. Example: ["src/hooks/**", "!src/hooks/scripts/**"]'
|
|
35
|
+
),
|
|
36
|
+
})
|
|
37
|
+
.strict()
|
|
38
|
+
.describe(
|
|
39
|
+
'Rule options object. Omit severity to default to "error". Add include to scope the rule to specific files.'
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
export const KlintConfigSchema = z
|
|
43
|
+
.object({
|
|
44
|
+
$schema: z
|
|
45
|
+
.string()
|
|
46
|
+
.optional()
|
|
47
|
+
.describe(
|
|
48
|
+
"JSON Schema reference. Use ./klint.schema.json for local validation or https://klint.dev/schema.json for the published schema."
|
|
49
|
+
),
|
|
50
|
+
root: z
|
|
51
|
+
.string()
|
|
52
|
+
.optional()
|
|
53
|
+
.describe(
|
|
54
|
+
"Root directory used to resolve include paths and report relative file names. Defaults to the directory containing klint.config.json."
|
|
55
|
+
),
|
|
56
|
+
include: z
|
|
57
|
+
.array(z.string())
|
|
58
|
+
.optional()
|
|
59
|
+
.describe(
|
|
60
|
+
'Glob patterns selecting which TypeScript files to lint. Prefix with ! to exclude. Defaults to ["."] which lints all .ts files under root. Example: ["src", "klint", "!**/node_modules/**"]'
|
|
61
|
+
),
|
|
62
|
+
plugins: z
|
|
63
|
+
.array(z.enum(builtInPluginNames))
|
|
64
|
+
.optional()
|
|
65
|
+
.describe(
|
|
66
|
+
'Named rule bundles to enable. Each plugin applies a default set of rules at "error" severity. Individual rules from the bundle can be overridden or silenced via the rules map. Available: "sonar".'
|
|
67
|
+
),
|
|
68
|
+
rules: z
|
|
69
|
+
.record(RuleNameSchema, z.union([SeveritySchema, RuleOptionsSchema]))
|
|
70
|
+
.optional()
|
|
71
|
+
.describe(
|
|
72
|
+
'Map of rule name → severity or options. Example: { "no-floating-promise": "error", "no-sync-in-async": { "severity": "warn", "include": ["src/hooks/**"] } }. Run `klint --help` for the full rule list.'
|
|
73
|
+
),
|
|
74
|
+
arch: z
|
|
75
|
+
.unknown()
|
|
76
|
+
.optional()
|
|
77
|
+
.describe(
|
|
78
|
+
"Architecture as Code constraints — layers, import boundaries, forbidden patterns, singleton locations. Parsed by the arch engine (Phase 2)."
|
|
79
|
+
),
|
|
80
|
+
})
|
|
81
|
+
.strict()
|
|
82
|
+
.describe(
|
|
83
|
+
"klint configuration. Lives at klint.yaml (or klint.config.json) next to biome.json and knip.json."
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
/** @lintignore */
|
|
87
|
+
export type KlintConfigFile = z.infer<typeof KlintConfigSchema>;
|
package/core/fixer.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { Violation } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Applies all fixable violations to their source files.
|
|
7
|
+
* Fixes are applied bottom-to-top within each file to preserve line offsets.
|
|
8
|
+
* Returns the number of fixes applied.
|
|
9
|
+
*/
|
|
10
|
+
export function applyFixes(violations: Violation[], root: string): number {
|
|
11
|
+
const byFile = new Map<string, Violation[]>();
|
|
12
|
+
for (const v of violations) {
|
|
13
|
+
if (!v.fix) continue;
|
|
14
|
+
const abs = resolve(root, v.file);
|
|
15
|
+
const existing = byFile.get(abs) ?? [];
|
|
16
|
+
existing.push(v);
|
|
17
|
+
byFile.set(abs, existing);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let applied = 0;
|
|
21
|
+
for (const [absPath, fileViolations] of byFile) {
|
|
22
|
+
const lines = readFileSync(absPath, "utf-8").split("\n");
|
|
23
|
+
// Sort by endLine descending so the largest-range fix at any position wins.
|
|
24
|
+
// This handles bottom-to-top ordering (higher lines first) AND ensures outer
|
|
25
|
+
// fixes beat inner/overlapping fixes when chained calls share a start position.
|
|
26
|
+
const sorted = [...fileViolations].sort(
|
|
27
|
+
(a, b) => (b.fix?.endLine ?? 0) - (a.fix?.endLine ?? 0)
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const used: Array<{ start: number; end: number }> = [];
|
|
31
|
+
for (const v of sorted) {
|
|
32
|
+
if (!v.fix) continue;
|
|
33
|
+
const { startLine, endLine, replacement } = v.fix;
|
|
34
|
+
if (used.some((r) => startLine <= r.end && endLine >= r.start)) continue;
|
|
35
|
+
lines.splice(startLine - 1, endLine - startLine + 1, ...replacement.split("\n"));
|
|
36
|
+
used.push({ start: startLine, end: endLine });
|
|
37
|
+
applied++;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
writeFileSync(absPath, lines.join("\n"), "utf-8");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return applied;
|
|
44
|
+
}
|
package/core/runner.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, relative, resolve } from "node:path";
|
|
3
|
+
import { BUILT_IN_PLUGINS } from "../plugins/index";
|
|
4
|
+
import { BUILT_IN_RULES } from "../rules/index";
|
|
5
|
+
import { runArchRules } from "./arch";
|
|
6
|
+
import { clearAstCache } from "./ast";
|
|
7
|
+
import type {
|
|
8
|
+
KlintConfig,
|
|
9
|
+
KlintRule,
|
|
10
|
+
RuleConfigValue,
|
|
11
|
+
Severity,
|
|
12
|
+
Violation,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
function walk(dir: string): string[] {
|
|
16
|
+
const out: string[] = [];
|
|
17
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
18
|
+
if (entry.name === "node_modules") continue;
|
|
19
|
+
const full = join(dir, entry.name);
|
|
20
|
+
if (entry.isDirectory()) out.push(...walk(full));
|
|
21
|
+
else if (entry.name.endsWith(".ts")) out.push(full.replaceAll("\\", "/"));
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveFiles(include: string[], root: string): string[] {
|
|
27
|
+
const files = new Set<string>();
|
|
28
|
+
for (const pattern of include) {
|
|
29
|
+
const dir = resolve(root, pattern.split("/**")[0].split("/*")[0]);
|
|
30
|
+
try {
|
|
31
|
+
for (const file of walk(dir)) files.add(file);
|
|
32
|
+
} catch {
|
|
33
|
+
// directory doesn't exist — skip
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return [...files];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function matchPattern(relPath: string, pattern: string): boolean {
|
|
40
|
+
const norm = relPath.replaceAll("\\", "/");
|
|
41
|
+
const p = pattern.replaceAll("\\", "/");
|
|
42
|
+
if (p.endsWith("/**")) return norm.startsWith(`${p.slice(0, -3)}/`);
|
|
43
|
+
if (p.startsWith("**/")) return norm.endsWith(p.slice(2));
|
|
44
|
+
return norm === p || norm.startsWith(`${p}/`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function applyPatterns(files: string[], patterns: string[], root: string): string[] {
|
|
48
|
+
const includes = patterns.filter((p) => !p.startsWith("!"));
|
|
49
|
+
const excludes = patterns.filter((p) => p.startsWith("!")).map((p) => p.slice(1));
|
|
50
|
+
return files.filter((file) => {
|
|
51
|
+
const rel = relative(root, file).replaceAll("\\", "/");
|
|
52
|
+
const included = includes.length === 0 || includes.some((p) => matchPattern(rel, p));
|
|
53
|
+
const excluded = excludes.some((p) => matchPattern(rel, p));
|
|
54
|
+
return included && !excluded;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveSeverity(value: RuleConfigValue): Severity {
|
|
59
|
+
return typeof value === "string" ? value : (value.severity ?? "error");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveInclude(value: RuleConfigValue): string[] | undefined {
|
|
63
|
+
return typeof value === "object" ? value.include : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function runKlint(
|
|
67
|
+
config: KlintConfig,
|
|
68
|
+
customRules: Record<string, KlintRule> = {}
|
|
69
|
+
): Violation[] {
|
|
70
|
+
clearAstCache();
|
|
71
|
+
|
|
72
|
+
// All plugin implementations are always available by their prefixed names
|
|
73
|
+
const pluginImpls: Record<string, KlintRule> = Object.assign(
|
|
74
|
+
{},
|
|
75
|
+
...Object.values(BUILT_IN_PLUGINS).map((p) => p.implementations)
|
|
76
|
+
);
|
|
77
|
+
const registry: Record<string, KlintRule> = {
|
|
78
|
+
...BUILT_IN_RULES,
|
|
79
|
+
...pluginImpls,
|
|
80
|
+
...customRules,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Plugin defaults applied first; explicit rules take precedence
|
|
84
|
+
const pluginDefaults: Record<string, RuleConfigValue> = {};
|
|
85
|
+
for (const pluginName of config.plugins ?? []) {
|
|
86
|
+
const plugin = BUILT_IN_PLUGINS[pluginName];
|
|
87
|
+
if (!plugin) throw new Error(`Unknown klint plugin: "${pluginName}"`);
|
|
88
|
+
Object.assign(pluginDefaults, plugin.rules);
|
|
89
|
+
}
|
|
90
|
+
const effectiveRules: Record<string, RuleConfigValue> = {
|
|
91
|
+
...pluginDefaults,
|
|
92
|
+
...config.rules,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const allFiles = resolveFiles(config.include, config.root);
|
|
96
|
+
const fileContents = new Map(allFiles.map((f) => [f, readFileSync(f, "utf-8")]));
|
|
97
|
+
const violations: Violation[] = [];
|
|
98
|
+
|
|
99
|
+
for (const [ruleName, configValue] of Object.entries(effectiveRules)) {
|
|
100
|
+
const severity = resolveSeverity(configValue);
|
|
101
|
+
if (severity === "off") continue;
|
|
102
|
+
|
|
103
|
+
const rule = registry[ruleName];
|
|
104
|
+
if (!rule) throw new Error(`Unknown klint rule: "${ruleName}"`);
|
|
105
|
+
|
|
106
|
+
const include = resolveInclude(configValue);
|
|
107
|
+
const files = include ? applyPatterns(allFiles, include, config.root) : allFiles;
|
|
108
|
+
|
|
109
|
+
const batch: Omit<Violation, "severity">[] = [];
|
|
110
|
+
rule.check({ files, root: config.root, fileContents }, batch);
|
|
111
|
+
for (const v of batch) violations.push({ ...v, rule: ruleName, severity });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (config.arch) {
|
|
115
|
+
violations.push(...runArchRules(config.arch, allFiles, fileContents, config.root));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return violations;
|
|
119
|
+
}
|
package/core/types.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
interface ViolationFix {
|
|
2
|
+
startLine: number;
|
|
3
|
+
endLine: number;
|
|
4
|
+
replacement: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type Severity = "error" | "warn" | "off";
|
|
8
|
+
|
|
9
|
+
/** @lintignore */
|
|
10
|
+
export interface RuleOptions {
|
|
11
|
+
severity?: Severity;
|
|
12
|
+
include?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type RuleConfigValue = Severity | RuleOptions;
|
|
16
|
+
|
|
17
|
+
export interface Violation {
|
|
18
|
+
file: string;
|
|
19
|
+
line: number;
|
|
20
|
+
rule: string;
|
|
21
|
+
message: string;
|
|
22
|
+
severity: Severity;
|
|
23
|
+
fix?: ViolationFix;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @lintignore */
|
|
27
|
+
export interface RuleContext {
|
|
28
|
+
files: string[];
|
|
29
|
+
root: string;
|
|
30
|
+
fileContents: Map<string, string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Violation as emitted by a rule — rule name and severity are stamped by the runner. */
|
|
34
|
+
export type RawViolation = Omit<Violation, "rule" | "severity">;
|
|
35
|
+
|
|
36
|
+
export interface KlintRule {
|
|
37
|
+
check: (ctx: RuleContext, violations: RawViolation[]) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A named bundle of rules with their default severities and implementations. */
|
|
41
|
+
export interface KlintPlugin {
|
|
42
|
+
name: string;
|
|
43
|
+
/** Default severity for each rule. Keys use the prefixed form e.g. "sonar/rule-name". */
|
|
44
|
+
rules: Record<string, RuleConfigValue>;
|
|
45
|
+
/** Rule implementations keyed by the same prefixed names. */
|
|
46
|
+
implementations: Record<string, KlintRule>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ArchImportRule {
|
|
50
|
+
from: string | string[];
|
|
51
|
+
deny?: string | string[];
|
|
52
|
+
allow?: string | string[];
|
|
53
|
+
"type-only"?: "allow";
|
|
54
|
+
message?: string;
|
|
55
|
+
severity?: Exclude<Severity, "off">;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ArchForbiddenRule {
|
|
59
|
+
pattern: string;
|
|
60
|
+
in: string | string[];
|
|
61
|
+
message: string;
|
|
62
|
+
severity?: Exclude<Severity, "off">;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface ArchSingletonRule {
|
|
66
|
+
pattern: string;
|
|
67
|
+
only: string;
|
|
68
|
+
message: string;
|
|
69
|
+
/** Limit scan to these files/layers. Defaults to all files. */
|
|
70
|
+
in?: string | string[];
|
|
71
|
+
severity?: Exclude<Severity, "off">;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ArchConfig {
|
|
75
|
+
layers?: Record<string, string[]>;
|
|
76
|
+
imports?: ArchImportRule[];
|
|
77
|
+
forbidden?: ArchForbiddenRule[];
|
|
78
|
+
singleton?: ArchSingletonRule[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface KlintConfig {
|
|
82
|
+
root: string;
|
|
83
|
+
include: string[];
|
|
84
|
+
plugins?: string[];
|
|
85
|
+
rules: Record<string, RuleConfigValue>;
|
|
86
|
+
arch?: ArchConfig;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const defineRule = (r: KlintRule): KlintRule => r;
|
|
90
|
+
|
|
91
|
+
/** @lintignore */
|
|
92
|
+
export const defineConfig = (c: KlintConfig): KlintConfig => c;
|