@konvert7/klint 0.4.0 → 0.6.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/core/arch.ts +47 -33
- package/core/config.schema.ts +176 -15
- package/core/rule-helpers.ts +76 -0
- package/core/types.ts +8 -0
- package/package.json +4 -1
- package/rules/no-async-predicate.ts +24 -45
- package/rules/no-consecutive-array-push.ts +5 -0
- package/rules/no-date-equality.ts +23 -43
- package/rules/no-floating-promise.ts +14 -35
- package/rules/no-misused-promises.ts +26 -49
- package/rules/no-nested-template-literals.ts +5 -0
- package/rules/no-object-in-template.ts +17 -41
- package/rules/no-optional-chain-on-non-nullable.ts +19 -40
- package/rules/no-single-char-class.ts +9 -17
- package/rules/no-string-match.ts +5 -0
- package/rules/no-sync-in-async.ts +8 -0
- package/rules/no-unguarded-json-parse.ts +5 -0
- package/rules/prefer-at.ts +9 -17
- package/rules/prefer-nullish-coalescing-assign.ts +5 -0
- package/rules/prefer-string-raw-regexp.ts +5 -0
- package/rules/prefer-string-raw.ts +9 -17
- package/rules/prefer-string-replaceall.ts +9 -18
- package/tools/generate-schema.ts +1 -18
package/core/arch.ts
CHANGED
|
@@ -196,23 +196,16 @@ export function runArchRules(
|
|
|
196
196
|
for (const rule of arch.forbidden ?? []) {
|
|
197
197
|
const severity: Severity = rule.severity ?? "error";
|
|
198
198
|
const inFiles = resolveLayerFiles(rule.in, layers, root, allFiles);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
message: rule.message,
|
|
210
|
-
rule: "arch/forbidden",
|
|
211
|
-
severity,
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
199
|
+
scanLinesForPattern(
|
|
200
|
+
inFiles,
|
|
201
|
+
rule.pattern,
|
|
202
|
+
fileContents,
|
|
203
|
+
root,
|
|
204
|
+
"arch/forbidden",
|
|
205
|
+
rule.message,
|
|
206
|
+
severity,
|
|
207
|
+
violations
|
|
208
|
+
);
|
|
216
209
|
}
|
|
217
210
|
|
|
218
211
|
for (const rule of arch.singleton ?? []) {
|
|
@@ -222,24 +215,45 @@ export function runArchRules(
|
|
|
222
215
|
? resolveLayerFiles(rule.in, layers, root, allFiles)
|
|
223
216
|
: allFiles;
|
|
224
217
|
const scope = inFiles.filter((f) => f !== onlyFile);
|
|
218
|
+
scanLinesForPattern(
|
|
219
|
+
scope,
|
|
220
|
+
rule.pattern,
|
|
221
|
+
fileContents,
|
|
222
|
+
root,
|
|
223
|
+
"arch/singleton",
|
|
224
|
+
rule.message,
|
|
225
|
+
severity,
|
|
226
|
+
violations
|
|
227
|
+
);
|
|
228
|
+
}
|
|
225
229
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
230
|
+
return violations;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function scanLinesForPattern(
|
|
234
|
+
files: string[],
|
|
235
|
+
pattern: string,
|
|
236
|
+
fileContents: Map<string, string>,
|
|
237
|
+
root: string,
|
|
238
|
+
ruleName: string,
|
|
239
|
+
message: string,
|
|
240
|
+
severity: Severity,
|
|
241
|
+
violations: Violation[]
|
|
242
|
+
): void {
|
|
243
|
+
for (const file of files) {
|
|
244
|
+
const content = fileContents.get(file);
|
|
245
|
+
if (!content) continue;
|
|
246
|
+
const lines = content.split("\n");
|
|
247
|
+
for (let i = 0; i < lines.length; i++) {
|
|
248
|
+
if (lines[i].includes(pattern)) {
|
|
249
|
+
violations.push({
|
|
250
|
+
file: relative(root, file).replaceAll("\\", "/"),
|
|
251
|
+
line: i + 1,
|
|
252
|
+
message,
|
|
253
|
+
rule: ruleName,
|
|
254
|
+
severity,
|
|
255
|
+
});
|
|
240
256
|
}
|
|
241
257
|
}
|
|
242
258
|
}
|
|
243
|
-
|
|
244
|
-
return violations;
|
|
245
259
|
}
|
package/core/config.schema.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { BUILT_IN_PLUGINS } from "../plugins/index";
|
|
3
3
|
import { BUILT_IN_RULES } from "../rules/index";
|
|
4
|
+
import type { KlintRule, RuleMeta } from "./types";
|
|
4
5
|
|
|
5
6
|
const builtInRuleNames = Object.keys(BUILT_IN_RULES) as [string, ...string[]];
|
|
6
7
|
const builtInPluginNames = Object.keys(BUILT_IN_PLUGINS) as [string, ...string[]];
|
|
@@ -12,18 +13,25 @@ const allKnownRuleNames = [...new Set([...builtInRuleNames, ...pluginRuleNames])
|
|
|
12
13
|
...string[],
|
|
13
14
|
];
|
|
14
15
|
|
|
16
|
+
/** Look up a rule's implementation across BUILT_IN_RULES and plugin implementations. */
|
|
17
|
+
function findRule(name: string): KlintRule | undefined {
|
|
18
|
+
if (BUILT_IN_RULES[name]) return BUILT_IN_RULES[name];
|
|
19
|
+
for (const plugin of Object.values(BUILT_IN_PLUGINS)) {
|
|
20
|
+
if (plugin.implementations[name]) return plugin.implementations[name];
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function metaFor(name: string): RuleMeta | undefined {
|
|
26
|
+
return findRule(name)?.meta;
|
|
27
|
+
}
|
|
28
|
+
|
|
15
29
|
const SeveritySchema = z
|
|
16
30
|
.enum(["error", "warn", "off"])
|
|
17
31
|
.describe(
|
|
18
32
|
'Rule severity. "error" exits with code 2; "warn" reports but exits 0; "off" silences.'
|
|
19
33
|
);
|
|
20
34
|
|
|
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
35
|
const RuleOptionsSchema = z
|
|
28
36
|
.object({
|
|
29
37
|
severity: SeveritySchema.optional(),
|
|
@@ -39,6 +47,165 @@ const RuleOptionsSchema = z
|
|
|
39
47
|
'Rule options object. Omit severity to default to "error". Add include to scope the rule to specific files.'
|
|
40
48
|
);
|
|
41
49
|
|
|
50
|
+
const RuleValueSchema = z.union([SeveritySchema, RuleOptionsSchema]);
|
|
51
|
+
|
|
52
|
+
// ─── arch ─────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const ArchSeveritySchema = z
|
|
55
|
+
.enum(["error", "warn"])
|
|
56
|
+
.describe(
|
|
57
|
+
'Severity for the arch rule. "error" exits with code 2; "warn" reports but exits 0. (arch rules cannot be "off" — remove the entry instead.)'
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const StringOrStringArray = z
|
|
61
|
+
.union([z.string(), z.array(z.string())])
|
|
62
|
+
.describe(
|
|
63
|
+
"Either a single layer name / glob pattern or an array of them. Layer names defined in `arch.layers` are resolved to their glob lists."
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const ArchLayersSchema = z
|
|
67
|
+
.record(z.string(), z.array(z.string()))
|
|
68
|
+
.describe(
|
|
69
|
+
"Named architectural zones. Keys are arbitrary layer names; values are glob patterns matching the files in that layer. Other arch rules reference these names instead of repeating globs."
|
|
70
|
+
)
|
|
71
|
+
.meta({
|
|
72
|
+
examples: [
|
|
73
|
+
'layers:\n core: ["src/hooks/lib/**", "src/tools/**"]\n skills: ["assets/skills/**"]\n dao: ["src/dao/**"]',
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const ArchImportRuleSchema = z
|
|
78
|
+
.object({
|
|
79
|
+
from: StringOrStringArray.describe(
|
|
80
|
+
"Source — a layer name or glob(s) for the files this rule applies to. Imports originating in `from` are checked against `deny`/`allow`."
|
|
81
|
+
),
|
|
82
|
+
deny: StringOrStringArray.optional().describe(
|
|
83
|
+
"Layer name(s) or glob(s) the source is not allowed to import from. Mutually exclusive in spirit with `allow` (use one or the other)."
|
|
84
|
+
),
|
|
85
|
+
allow: StringOrStringArray.optional().describe(
|
|
86
|
+
"Allowlist of layer name(s) / glob(s) the source may import from. Anything not matching `allow` is denied."
|
|
87
|
+
),
|
|
88
|
+
"type-only": z
|
|
89
|
+
.literal("allow")
|
|
90
|
+
.optional()
|
|
91
|
+
.describe(
|
|
92
|
+
'When set to "allow", `import type` statements that would otherwise be denied are permitted. Useful for type-only references across layers.'
|
|
93
|
+
),
|
|
94
|
+
message: z
|
|
95
|
+
.string()
|
|
96
|
+
.optional()
|
|
97
|
+
.describe(
|
|
98
|
+
"Custom error message reported when the rule is violated. Shown in the agent's event stream alongside file:line."
|
|
99
|
+
),
|
|
100
|
+
severity: ArchSeveritySchema.optional(),
|
|
101
|
+
})
|
|
102
|
+
.strict()
|
|
103
|
+
.describe(
|
|
104
|
+
"An import-boundary rule. Restricts which layers a source can or cannot import from."
|
|
105
|
+
)
|
|
106
|
+
.meta({
|
|
107
|
+
examples: [
|
|
108
|
+
'arch:\n imports:\n - from: skills\n deny: core\n message: "Skills must be self-contained and portable"\n severity: warn',
|
|
109
|
+
'arch:\n imports:\n - from: ["src/dao/**"]\n allow: ["src/dao/**", "src/prisma/**", "src/types/**"]',
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const ArchForbiddenRuleSchema = z
|
|
114
|
+
.object({
|
|
115
|
+
pattern: z
|
|
116
|
+
.string()
|
|
117
|
+
.describe(
|
|
118
|
+
"Literal substring to search for in source. No regex — exact substring match (e.g. `console.log(`)."
|
|
119
|
+
),
|
|
120
|
+
in: StringOrStringArray.describe(
|
|
121
|
+
"Layer name(s) or glob(s) the pattern is forbidden in. Files outside the scope are not checked."
|
|
122
|
+
),
|
|
123
|
+
message: z
|
|
124
|
+
.string()
|
|
125
|
+
.describe(
|
|
126
|
+
"Required error message explaining why the pattern is forbidden. Shown to the agent so it can fix the call site."
|
|
127
|
+
),
|
|
128
|
+
severity: ArchSeveritySchema.optional(),
|
|
129
|
+
})
|
|
130
|
+
.strict()
|
|
131
|
+
.describe(
|
|
132
|
+
"A forbidden-pattern rule. Blocks literal substrings (console.log, process.exit, raw SDK fetches, etc.) inside specific layers."
|
|
133
|
+
)
|
|
134
|
+
.meta({
|
|
135
|
+
examples: [
|
|
136
|
+
'arch:\n forbidden:\n - pattern: "console.log("\n in: core\n message: "Leaks into the agent event stream"',
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const ArchSingletonRuleSchema = z
|
|
141
|
+
.object({
|
|
142
|
+
pattern: z
|
|
143
|
+
.string()
|
|
144
|
+
.describe(
|
|
145
|
+
"Literal substring whose appearance is allowed only at one location. Exact substring match (e.g. `process.env.API_KEY`)."
|
|
146
|
+
),
|
|
147
|
+
only: z
|
|
148
|
+
.string()
|
|
149
|
+
.describe(
|
|
150
|
+
"The single file path where this pattern is allowed. Every other occurrence becomes a violation."
|
|
151
|
+
),
|
|
152
|
+
in: StringOrStringArray.optional().describe(
|
|
153
|
+
"Optional scope. Limit the scan to these layers/globs; defaults to all files in `include`."
|
|
154
|
+
),
|
|
155
|
+
message: z
|
|
156
|
+
.string()
|
|
157
|
+
.describe(
|
|
158
|
+
"Required error message explaining why the pattern must stay singleton (security boundary, module-of-record, etc.)."
|
|
159
|
+
),
|
|
160
|
+
severity: ArchSeveritySchema.optional(),
|
|
161
|
+
})
|
|
162
|
+
.strict()
|
|
163
|
+
.describe(
|
|
164
|
+
"A singleton-location rule. Pins a pattern to exactly one file — the only honest way to enforce a module of record."
|
|
165
|
+
)
|
|
166
|
+
.meta({
|
|
167
|
+
examples: [
|
|
168
|
+
'arch:\n singleton:\n - pattern: "process.env.API_KEY"\n only: "src/lib/auth.ts"\n message: "API key access must funnel through the auth module."',
|
|
169
|
+
],
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const ArchSchema = z
|
|
173
|
+
.object({
|
|
174
|
+
layers: ArchLayersSchema.optional(),
|
|
175
|
+
imports: z
|
|
176
|
+
.array(ArchImportRuleSchema)
|
|
177
|
+
.optional()
|
|
178
|
+
.describe(
|
|
179
|
+
"Import-boundary rules. The dependency graph your README claims you have."
|
|
180
|
+
),
|
|
181
|
+
forbidden: z
|
|
182
|
+
.array(ArchForbiddenRuleSchema)
|
|
183
|
+
.optional()
|
|
184
|
+
.describe("Forbidden-pattern rules. Block literal strings scoped to a layer."),
|
|
185
|
+
singleton: z
|
|
186
|
+
.array(ArchSingletonRuleSchema)
|
|
187
|
+
.optional()
|
|
188
|
+
.describe(
|
|
189
|
+
"Singleton-location rules. Pin a pattern to one file; every other touch is a violation."
|
|
190
|
+
),
|
|
191
|
+
})
|
|
192
|
+
.strict()
|
|
193
|
+
.describe(
|
|
194
|
+
"Architecture-as-Code constraints — layers, import boundaries, forbidden patterns, singleton locations. Enforced by the arch engine alongside the rule set."
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
function buildRulesSchema() {
|
|
198
|
+
const shape: Record<string, z.ZodType> = {};
|
|
199
|
+
for (const name of allKnownRuleNames) {
|
|
200
|
+
const meta = metaFor(name);
|
|
201
|
+
let entry: z.ZodType = RuleValueSchema.optional();
|
|
202
|
+
if (meta?.description) entry = entry.describe(meta.description);
|
|
203
|
+
if (meta?.examples?.length) entry = entry.meta({ examples: meta.examples });
|
|
204
|
+
shape[name] = entry;
|
|
205
|
+
}
|
|
206
|
+
return z.object(shape).catchall(RuleValueSchema);
|
|
207
|
+
}
|
|
208
|
+
|
|
42
209
|
export const KlintConfigSchema = z
|
|
43
210
|
.object({
|
|
44
211
|
$schema: z
|
|
@@ -65,18 +232,12 @@ export const KlintConfigSchema = z
|
|
|
65
232
|
.describe(
|
|
66
233
|
'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
234
|
),
|
|
68
|
-
rules:
|
|
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()
|
|
235
|
+
rules: buildRulesSchema()
|
|
76
236
|
.optional()
|
|
77
237
|
.describe(
|
|
78
|
-
|
|
238
|
+
'Map of rule name → severity or options. Example: { "no-floating-promise": "error", "no-sync-in-async": { "severity": "warn", "include": ["src/hooks/**"] } }.'
|
|
79
239
|
),
|
|
240
|
+
arch: ArchSchema.optional(),
|
|
80
241
|
})
|
|
81
242
|
.strict()
|
|
82
243
|
.describe(
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { createProgram } from "./ast";
|
|
4
|
+
import type { KlintRule, RawViolation, RuleMeta } from "./types";
|
|
5
|
+
import { defineRule } from "./types";
|
|
6
|
+
|
|
7
|
+
export interface AstRuleContext {
|
|
8
|
+
sourceFile: ts.SourceFile;
|
|
9
|
+
checker: ts.TypeChecker;
|
|
10
|
+
root: string;
|
|
11
|
+
report(node: ts.Node, message: string): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function defineAstRule(opts: {
|
|
15
|
+
meta: RuleMeta;
|
|
16
|
+
shouldRun?: (program: ts.Program) => boolean;
|
|
17
|
+
visit: (node: ts.Node, ctx: AstRuleContext) => void;
|
|
18
|
+
}): KlintRule {
|
|
19
|
+
return defineRule({
|
|
20
|
+
meta: opts.meta,
|
|
21
|
+
check({ files, root }, violations) {
|
|
22
|
+
const program = createProgram(files, root);
|
|
23
|
+
if (opts.shouldRun && !opts.shouldRun(program)) return;
|
|
24
|
+
const checker = program.getTypeChecker();
|
|
25
|
+
const fileSet = new Set(files);
|
|
26
|
+
|
|
27
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
28
|
+
if (!fileSet.has(sourceFile.fileName) || sourceFile.isDeclarationFile) continue;
|
|
29
|
+
|
|
30
|
+
const ctx: AstRuleContext = {
|
|
31
|
+
sourceFile,
|
|
32
|
+
checker,
|
|
33
|
+
root,
|
|
34
|
+
report(node, message) {
|
|
35
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
36
|
+
violations.push({
|
|
37
|
+
file: relative(root, sourceFile.fileName),
|
|
38
|
+
line: line + 1,
|
|
39
|
+
message,
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const walk = (node: ts.Node): void => {
|
|
45
|
+
opts.visit(node, ctx);
|
|
46
|
+
ts.forEachChild(node, walk);
|
|
47
|
+
};
|
|
48
|
+
walk(sourceFile);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Builds a single-node replacement fix preserving surrounding line text. */
|
|
55
|
+
export function buildNodeReplacementFix(
|
|
56
|
+
src: ts.SourceFile,
|
|
57
|
+
node: ts.Node,
|
|
58
|
+
replacement: string
|
|
59
|
+
): NonNullable<RawViolation["fix"]> {
|
|
60
|
+
const { line: startLine } = src.getLineAndCharacterOfPosition(node.getStart());
|
|
61
|
+
const { line: endLine } = src.getLineAndCharacterOfPosition(node.getEnd());
|
|
62
|
+
const lineStarts = src.getLineStarts();
|
|
63
|
+
const lineStart = lineStarts[startLine];
|
|
64
|
+
const lineEnd =
|
|
65
|
+
endLine + 1 < lineStarts.length ? lineStarts[endLine + 1] - 1 : src.text.length;
|
|
66
|
+
const linesText = src.text.slice(lineStart, lineEnd);
|
|
67
|
+
const nodeOffset = node.getStart() - lineStart;
|
|
68
|
+
const nodeEndOffset = node.getEnd() - lineStart;
|
|
69
|
+
const fixedLines =
|
|
70
|
+
linesText.slice(0, nodeOffset) + replacement + linesText.slice(nodeEndOffset);
|
|
71
|
+
return {
|
|
72
|
+
startLine: startLine + 1,
|
|
73
|
+
endLine: endLine + 1,
|
|
74
|
+
replacement: fixedLines,
|
|
75
|
+
};
|
|
76
|
+
}
|
package/core/types.ts
CHANGED
|
@@ -33,8 +33,16 @@ export interface RuleContext {
|
|
|
33
33
|
/** Violation as emitted by a rule — rule name and severity are stamped by the runner. */
|
|
34
34
|
export type RawViolation = Omit<Violation, "rule" | "severity">;
|
|
35
35
|
|
|
36
|
+
export interface RuleMeta {
|
|
37
|
+
/** One- or two-line description of what the rule catches. */
|
|
38
|
+
description: string;
|
|
39
|
+
/** Concrete YAML config snippets showing usage. Rendered as code blocks in docs. */
|
|
40
|
+
examples?: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
export interface KlintRule {
|
|
37
44
|
check: (ctx: RuleContext, violations: RawViolation[]) => void;
|
|
45
|
+
meta?: RuleMeta;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
/** A named bundle of rules with their default severities and implementations. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@konvert7/klint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Architecture-as-Code linter for TypeScript. Enforce layer boundaries, import rules, and singleton patterns in YAML.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
"check-write": "biome check --write",
|
|
40
40
|
"knip": "knip-bun",
|
|
41
41
|
"klint": "bun cli.ts",
|
|
42
|
+
"jscpd": "jscpd --noTips --silent",
|
|
43
|
+
"jscpd:report": "jscpd --noTips",
|
|
42
44
|
"schema": "bun tools/generate-schema.ts",
|
|
43
45
|
"lint-staged": "lint-staged",
|
|
44
46
|
"prepare": "bun .husky/install.mjs"
|
|
@@ -64,6 +66,7 @@
|
|
|
64
66
|
"@types/bun": "latest",
|
|
65
67
|
"@types/node": "latest",
|
|
66
68
|
"husky": "^9.1.7",
|
|
69
|
+
"jscpd": "4.2.3",
|
|
67
70
|
"knip": "^6.9.0",
|
|
68
71
|
"lint-staged": "^15.5.2",
|
|
69
72
|
"semantic-release": "^25.0.3"
|
|
@@ -1,57 +1,36 @@
|
|
|
1
|
-
import { relative } from "node:path";
|
|
2
1
|
import ts from "typescript";
|
|
3
|
-
import {
|
|
4
|
-
import type { RawViolation } from "../core/types";
|
|
5
|
-
import { defineRule } from "../core/types";
|
|
2
|
+
import { defineAstRule } from "../core/rule-helpers";
|
|
6
3
|
|
|
7
4
|
const PREDICATE_METHODS = new Set(["filter", "some", "every", "find", "findIndex"]);
|
|
8
5
|
|
|
9
|
-
export const noAsyncPredicate =
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
export const noAsyncPredicate = defineAstRule({
|
|
7
|
+
meta: {
|
|
8
|
+
description:
|
|
9
|
+
"Flags `async` functions passed to `.filter`/`.some`/`.every`/`.find`/`.findIndex`. Array methods coerce the returned promise to truthy, so every element appears to match.",
|
|
10
|
+
examples: ["no-async-predicate: error"],
|
|
11
|
+
},
|
|
12
|
+
visit(node, ctx) {
|
|
13
|
+
if (
|
|
14
|
+
!ts.isCallExpression(node) ||
|
|
15
|
+
!ts.isPropertyAccessExpression(node.expression) ||
|
|
16
|
+
!PREDICATE_METHODS.has(node.expression.name.text) ||
|
|
17
|
+
node.arguments.length === 0
|
|
18
|
+
)
|
|
19
|
+
return;
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
const receiverType = ctx.checker.getTypeAtLocation(node.expression.expression);
|
|
22
|
+
if (isArrayLike(receiverType) && isAsyncFunction(node.arguments[0])) {
|
|
23
|
+
const method = node.expression.name.text;
|
|
24
|
+
ctx.report(
|
|
25
|
+
node.arguments[0],
|
|
26
|
+
`Async callback passed to .${method}() — the returned Promise is always truthy, so the predicate never filters correctly. The array method cannot await it.`
|
|
27
|
+
);
|
|
18
28
|
}
|
|
19
29
|
},
|
|
20
30
|
});
|
|
21
31
|
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
checker: ts.TypeChecker,
|
|
25
|
-
root: string,
|
|
26
|
-
violations: RawViolation[]
|
|
27
|
-
): void {
|
|
28
|
-
function visit(node: ts.Node): void {
|
|
29
|
-
if (
|
|
30
|
-
ts.isCallExpression(node) &&
|
|
31
|
-
ts.isPropertyAccessExpression(node.expression) &&
|
|
32
|
-
PREDICATE_METHODS.has(node.expression.name.text) &&
|
|
33
|
-
node.arguments.length > 0
|
|
34
|
-
) {
|
|
35
|
-
const receiverType = checker.getTypeAtLocation(node.expression.expression);
|
|
36
|
-
if (isArrayLike(receiverType, checker) && isAsyncFunction(node.arguments[0])) {
|
|
37
|
-
const method = node.expression.name.text;
|
|
38
|
-
const { line } = sourceFile.getLineAndCharacterOfPosition(
|
|
39
|
-
node.arguments[0].getStart()
|
|
40
|
-
);
|
|
41
|
-
violations.push({
|
|
42
|
-
file: relative(root, sourceFile.fileName),
|
|
43
|
-
line: line + 1,
|
|
44
|
-
message: `Async callback passed to .${method}() — the returned Promise is always truthy, so the predicate never filters correctly. The array method cannot await it.`,
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
ts.forEachChild(node, visit);
|
|
49
|
-
}
|
|
50
|
-
visit(sourceFile);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function isArrayLike(type: ts.Type, checker: ts.TypeChecker): boolean {
|
|
54
|
-
if (type.isUnion()) return type.types.some((t) => isArrayLike(t, checker));
|
|
32
|
+
function isArrayLike(type: ts.Type): boolean {
|
|
33
|
+
if (type.isUnion()) return type.types.some((t) => isArrayLike(t));
|
|
55
34
|
const sym = type.getSymbol();
|
|
56
35
|
if (sym?.name === "Array" || sym?.name === "ReadonlyArray") return true;
|
|
57
36
|
const ref = type as ts.TypeReference;
|
|
@@ -4,6 +4,11 @@ import { walkAst } from "../core/ast";
|
|
|
4
4
|
import type { KlintRule } from "../core/types";
|
|
5
5
|
|
|
6
6
|
export const noConsecutiveArrayPush: KlintRule = {
|
|
7
|
+
meta: {
|
|
8
|
+
description:
|
|
9
|
+
"Flags consecutive `arr.push(x); arr.push(y);` calls — combine into `arr.push(x, y)` or `arr.push(...items)`.",
|
|
10
|
+
examples: ["no-consecutive-array-push: warn"],
|
|
11
|
+
},
|
|
7
12
|
check({ files, root, fileContents }, violations) {
|
|
8
13
|
for (const file of files) {
|
|
9
14
|
const content = fileContents.get(file) ?? "";
|
|
@@ -1,53 +1,33 @@
|
|
|
1
|
-
import { relative } from "node:path";
|
|
2
1
|
import ts from "typescript";
|
|
3
|
-
import {
|
|
4
|
-
import type { RawViolation } from "../core/types";
|
|
5
|
-
import { defineRule } from "../core/types";
|
|
2
|
+
import { defineAstRule } from "../core/rule-helpers";
|
|
6
3
|
|
|
7
|
-
export const noDateEquality =
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
export const noDateEquality = defineAstRule({
|
|
5
|
+
meta: {
|
|
6
|
+
description:
|
|
7
|
+
"Flags `Date` comparisons with `==`/`===`/`!=`/`!==` — these compare object identity, not the time value. Use `.getTime()` instead.",
|
|
8
|
+
examples: ["no-date-equality: error"],
|
|
9
|
+
},
|
|
10
|
+
visit(node, ctx) {
|
|
11
|
+
if (!ts.isBinaryExpression(node)) return;
|
|
12
|
+
const { kind } = node.operatorToken;
|
|
13
|
+
if (
|
|
14
|
+
kind !== ts.SyntaxKind.EqualsEqualsEqualsToken &&
|
|
15
|
+
kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken
|
|
16
|
+
)
|
|
17
|
+
return;
|
|
12
18
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
const leftType = ctx.checker.getTypeAtLocation(node.left);
|
|
20
|
+
const rightType = ctx.checker.getTypeAtLocation(node.right);
|
|
21
|
+
if (isDateType(leftType) && isDateType(rightType)) {
|
|
22
|
+
const op = node.operatorToken.getText(ctx.sourceFile);
|
|
23
|
+
ctx.report(
|
|
24
|
+
node,
|
|
25
|
+
`Date values compared with ${op} — this compares object references, not time values. Use .getTime() or .valueOf() instead.`
|
|
26
|
+
);
|
|
16
27
|
}
|
|
17
28
|
},
|
|
18
29
|
});
|
|
19
30
|
|
|
20
|
-
function visitFile(
|
|
21
|
-
sourceFile: ts.SourceFile,
|
|
22
|
-
checker: ts.TypeChecker,
|
|
23
|
-
root: string,
|
|
24
|
-
violations: RawViolation[]
|
|
25
|
-
): void {
|
|
26
|
-
function visit(node: ts.Node): void {
|
|
27
|
-
if (ts.isBinaryExpression(node)) {
|
|
28
|
-
const { kind } = node.operatorToken;
|
|
29
|
-
if (
|
|
30
|
-
kind === ts.SyntaxKind.EqualsEqualsEqualsToken ||
|
|
31
|
-
kind === ts.SyntaxKind.ExclamationEqualsEqualsToken
|
|
32
|
-
) {
|
|
33
|
-
const leftType = checker.getTypeAtLocation(node.left);
|
|
34
|
-
const rightType = checker.getTypeAtLocation(node.right);
|
|
35
|
-
if (isDateType(leftType) && isDateType(rightType)) {
|
|
36
|
-
const op = node.operatorToken.getText(sourceFile);
|
|
37
|
-
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
38
|
-
violations.push({
|
|
39
|
-
file: relative(root, sourceFile.fileName),
|
|
40
|
-
line: line + 1,
|
|
41
|
-
message: `Date values compared with ${op} — this compares object references, not time values. Use .getTime() or .valueOf() instead.`,
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
ts.forEachChild(node, visit);
|
|
47
|
-
}
|
|
48
|
-
visit(sourceFile);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
31
|
function isDateType(type: ts.Type): boolean {
|
|
52
32
|
if (type.getSymbol()?.name === "Date") return true;
|
|
53
33
|
if (type.isUnion()) return type.types.some(isDateType);
|
|
@@ -1,49 +1,28 @@
|
|
|
1
|
-
import { relative } from "node:path";
|
|
2
1
|
import ts from "typescript";
|
|
3
|
-
import {
|
|
4
|
-
import type { RawViolation } from "../core/types";
|
|
5
|
-
import { defineRule } from "../core/types";
|
|
2
|
+
import { defineAstRule } from "../core/rule-helpers";
|
|
6
3
|
|
|
7
|
-
export const noFloatingPromise =
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
for (const sourceFile of program.getSourceFiles()) {
|
|
14
|
-
if (!fileSet.has(sourceFile.fileName) || sourceFile.isDeclarationFile) continue;
|
|
15
|
-
visitFile(sourceFile, checker, root, violations);
|
|
16
|
-
}
|
|
4
|
+
export const noFloatingPromise = defineAstRule({
|
|
5
|
+
meta: {
|
|
6
|
+
description:
|
|
7
|
+
"Flags Promise-returning expressions that aren't awaited or `.catch()`-handled — silent failures and unpredictable execution order.",
|
|
8
|
+
examples: ["no-floating-promise: error"],
|
|
17
9
|
},
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
function visitFile(
|
|
21
|
-
sourceFile: ts.SourceFile,
|
|
22
|
-
checker: ts.TypeChecker,
|
|
23
|
-
root: string,
|
|
24
|
-
violations: RawViolation[]
|
|
25
|
-
): void {
|
|
26
|
-
function visit(node: ts.Node): void {
|
|
10
|
+
visit(node, ctx) {
|
|
27
11
|
if (
|
|
28
12
|
ts.isExpressionStatement(node) &&
|
|
29
13
|
ts.isCallExpression(node.expression) &&
|
|
30
14
|
!isHandled(node.expression)
|
|
31
15
|
) {
|
|
32
|
-
const type = checker.getTypeAtLocation(node.expression);
|
|
16
|
+
const type = ctx.checker.getTypeAtLocation(node.expression);
|
|
33
17
|
if (isPromiseLike(type)) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
message:
|
|
39
|
-
"Promise-returning call is not awaited — errors will be silently discarded and execution order is unpredictable. Use await, void, or .catch().",
|
|
40
|
-
});
|
|
18
|
+
ctx.report(
|
|
19
|
+
node,
|
|
20
|
+
"Promise-returning call is not awaited — errors will be silently discarded and execution order is unpredictable. Use await, void, or .catch()."
|
|
21
|
+
);
|
|
41
22
|
}
|
|
42
23
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
visit(sourceFile);
|
|
46
|
-
}
|
|
24
|
+
},
|
|
25
|
+
});
|
|
47
26
|
|
|
48
27
|
function isHandled(node: ts.CallExpression): boolean {
|
|
49
28
|
if (!ts.isPropertyAccessExpression(node.expression)) return false;
|
|
@@ -1,57 +1,34 @@
|
|
|
1
|
-
import { relative } from "node:path";
|
|
2
1
|
import ts from "typescript";
|
|
3
|
-
import {
|
|
4
|
-
import type { RawViolation } from "../core/types";
|
|
5
|
-
import { defineRule } from "../core/types";
|
|
2
|
+
import { defineAstRule } from "../core/rule-helpers";
|
|
6
3
|
|
|
7
|
-
export const noMisusedPromises =
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
for (const sourceFile of program.getSourceFiles()) {
|
|
14
|
-
if (!fileSet.has(sourceFile.fileName) || sourceFile.isDeclarationFile) continue;
|
|
15
|
-
visitFile(sourceFile, checker, root, violations);
|
|
16
|
-
}
|
|
4
|
+
export const noMisusedPromises = defineAstRule({
|
|
5
|
+
meta: {
|
|
6
|
+
description:
|
|
7
|
+
"Flags Promises passed where the type expects a non-thenable (e.g. an `if` condition, a `void` callback). The Promise's truthiness — not its resolved value — drives the branch.",
|
|
8
|
+
examples: ["no-misused-promises: error"],
|
|
17
9
|
},
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (!param) continue;
|
|
37
|
-
|
|
38
|
-
const paramType = checker.getTypeOfSymbolAtLocation(param, node);
|
|
39
|
-
if (expectsSyncCallback(paramType, checker)) {
|
|
40
|
-
const { line } = sourceFile.getLineAndCharacterOfPosition(arg.getStart());
|
|
41
|
-
violations.push({
|
|
42
|
-
file: relative(root, sourceFile.fileName),
|
|
43
|
-
line: line + 1,
|
|
44
|
-
message:
|
|
45
|
-
"Async function passed where a sync callback is expected — the caller cannot await it and errors will be silently lost.",
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
}
|
|
10
|
+
visit(node, ctx) {
|
|
11
|
+
if (!ts.isCallExpression(node)) return;
|
|
12
|
+
const sig = ctx.checker.getResolvedSignature(node);
|
|
13
|
+
if (!sig) return;
|
|
14
|
+
const params = sig.getParameters();
|
|
15
|
+
for (let i = 0; i < node.arguments.length; i++) {
|
|
16
|
+
const arg = node.arguments[i];
|
|
17
|
+
if (!isAsyncFunction(arg)) continue;
|
|
18
|
+
|
|
19
|
+
const param = params[Math.min(i, params.length - 1)];
|
|
20
|
+
if (!param) continue;
|
|
21
|
+
|
|
22
|
+
const paramType = ctx.checker.getTypeOfSymbolAtLocation(param, node);
|
|
23
|
+
if (expectsSyncCallback(paramType, ctx.checker)) {
|
|
24
|
+
ctx.report(
|
|
25
|
+
arg,
|
|
26
|
+
"Async function passed where a sync callback is expected — the caller cannot await it and errors will be silently lost."
|
|
27
|
+
);
|
|
49
28
|
}
|
|
50
29
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
visit(sourceFile);
|
|
54
|
-
}
|
|
30
|
+
},
|
|
31
|
+
});
|
|
55
32
|
|
|
56
33
|
function isAsyncFunction(
|
|
57
34
|
node: ts.Node
|
|
@@ -4,6 +4,11 @@ import { walkAst } from "../core/ast";
|
|
|
4
4
|
import type { KlintRule } from "../core/types";
|
|
5
5
|
|
|
6
6
|
export const noNestedTemplateLiterals: KlintRule = {
|
|
7
|
+
meta: {
|
|
8
|
+
description:
|
|
9
|
+
"Flags template literals nested inside template literals — almost always unreadable; extract to a variable.",
|
|
10
|
+
examples: ["no-nested-template-literals: warn"],
|
|
11
|
+
},
|
|
7
12
|
check({ files, root, fileContents }, violations) {
|
|
8
13
|
for (const file of files) {
|
|
9
14
|
const content = fileContents.get(file) ?? "";
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import { relative } from "node:path";
|
|
2
1
|
import ts from "typescript";
|
|
3
|
-
import {
|
|
4
|
-
import type { RawViolation } from "../core/types";
|
|
5
|
-
import { defineRule } from "../core/types";
|
|
2
|
+
import { defineAstRule } from "../core/rule-helpers";
|
|
6
3
|
|
|
7
4
|
// Builtins with a meaningful toString() that isn't [object Object]
|
|
8
5
|
const SAFE_SYMBOL_NAMES = new Set([
|
|
@@ -21,46 +18,25 @@ const SAFE_SYMBOL_NAMES = new Set([
|
|
|
21
18
|
"Symbol",
|
|
22
19
|
]);
|
|
23
20
|
|
|
24
|
-
export const noObjectInTemplate =
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
for (const sourceFile of program.getSourceFiles()) {
|
|
31
|
-
if (!fileSet.has(sourceFile.fileName) || sourceFile.isDeclarationFile) continue;
|
|
32
|
-
visitFile(sourceFile, checker, root, violations);
|
|
33
|
-
}
|
|
21
|
+
export const noObjectInTemplate = defineAstRule({
|
|
22
|
+
meta: {
|
|
23
|
+
description:
|
|
24
|
+
"Flags non-primitive objects interpolated into template literals — produces `[object Object]` unless `toString()` is overridden.",
|
|
25
|
+
examples: ["no-object-in-template: error"],
|
|
34
26
|
},
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
if (ts.isTemplateExpression(node)) {
|
|
45
|
-
for (const span of node.templateSpans) {
|
|
46
|
-
const type = checker.getTypeAtLocation(span.expression);
|
|
47
|
-
if (wouldRenderAsObjectObject(type, checker)) {
|
|
48
|
-
const { line } = sourceFile.getLineAndCharacterOfPosition(
|
|
49
|
-
span.expression.getStart()
|
|
50
|
-
);
|
|
51
|
-
violations.push({
|
|
52
|
-
file: relative(root, sourceFile.fileName),
|
|
53
|
-
line: line + 1,
|
|
54
|
-
message:
|
|
55
|
-
"Object interpolated in template literal has no custom toString() — it will render as [object Object]. Access a specific property or implement toString().",
|
|
56
|
-
});
|
|
57
|
-
}
|
|
27
|
+
visit(node, ctx) {
|
|
28
|
+
if (!ts.isTemplateExpression(node)) return;
|
|
29
|
+
for (const span of node.templateSpans) {
|
|
30
|
+
const type = ctx.checker.getTypeAtLocation(span.expression);
|
|
31
|
+
if (wouldRenderAsObjectObject(type, ctx.checker)) {
|
|
32
|
+
ctx.report(
|
|
33
|
+
span.expression,
|
|
34
|
+
"Object interpolated in template literal has no custom toString() — it will render as [object Object]. Access a specific property or implement toString()."
|
|
35
|
+
);
|
|
58
36
|
}
|
|
59
37
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
visit(sourceFile);
|
|
63
|
-
}
|
|
38
|
+
},
|
|
39
|
+
});
|
|
64
40
|
|
|
65
41
|
function wouldRenderAsObjectObject(type: ts.Type, checker: ts.TypeChecker): boolean {
|
|
66
42
|
const primitiveFlags =
|
|
@@ -1,48 +1,27 @@
|
|
|
1
|
-
import { relative } from "node:path";
|
|
2
1
|
import ts from "typescript";
|
|
3
|
-
import {
|
|
4
|
-
import type { RawViolation } from "../core/types";
|
|
5
|
-
import { defineRule } from "../core/types";
|
|
2
|
+
import { defineAstRule } from "../core/rule-helpers";
|
|
6
3
|
|
|
7
|
-
export const noOptionalChainOnNonNullable =
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (!program.getCompilerOptions().strictNullChecks) return;
|
|
13
|
-
|
|
14
|
-
const fileSet = new Set(files);
|
|
15
|
-
for (const sourceFile of program.getSourceFiles()) {
|
|
16
|
-
if (!fileSet.has(sourceFile.fileName) || sourceFile.isDeclarationFile) continue;
|
|
17
|
-
visitFile(sourceFile, checker, root, violations);
|
|
18
|
-
}
|
|
4
|
+
export const noOptionalChainOnNonNullable = defineAstRule({
|
|
5
|
+
meta: {
|
|
6
|
+
description:
|
|
7
|
+
"Flags `?.` on receivers whose static type cannot be `null` or `undefined`. The optional chain is dead code and obscures the real shape.",
|
|
8
|
+
examples: ["no-optional-chain-on-non-nullable: warn"],
|
|
19
9
|
},
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
checker: ts.TypeChecker,
|
|
25
|
-
root: string,
|
|
26
|
-
violations: RawViolation[]
|
|
27
|
-
): void {
|
|
28
|
-
function visit(node: ts.Node): void {
|
|
10
|
+
shouldRun(program) {
|
|
11
|
+
return program.getCompilerOptions().strictNullChecks === true;
|
|
12
|
+
},
|
|
13
|
+
visit(node, ctx) {
|
|
29
14
|
const receiver = getOptionalChainReceiver(node);
|
|
30
|
-
if (receiver)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
message:
|
|
38
|
-
"Optional chain (?.) on a non-nullable type — the receiver can never be null or undefined here. Use . to remove misleading dead code.",
|
|
39
|
-
});
|
|
40
|
-
}
|
|
15
|
+
if (!receiver) return;
|
|
16
|
+
const type = ctx.checker.getTypeAtLocation(receiver);
|
|
17
|
+
if (!isNullable(type)) {
|
|
18
|
+
ctx.report(
|
|
19
|
+
node,
|
|
20
|
+
"Optional chain (?.) on a non-nullable type — the receiver can never be null or undefined here. Use . to remove misleading dead code."
|
|
21
|
+
);
|
|
41
22
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
visit(sourceFile);
|
|
45
|
-
}
|
|
23
|
+
},
|
|
24
|
+
});
|
|
46
25
|
|
|
47
26
|
function getOptionalChainReceiver(node: ts.Node): ts.Expression | undefined {
|
|
48
27
|
if (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { relative } from "node:path";
|
|
2
2
|
import ts from "typescript";
|
|
3
3
|
import { walkAst } from "../core/ast";
|
|
4
|
+
import { buildNodeReplacementFix } from "../core/rule-helpers";
|
|
4
5
|
import type { KlintRule } from "../core/types";
|
|
5
6
|
|
|
6
7
|
// Unescaped metacharacters that lose their special meaning inside a character class,
|
|
@@ -63,6 +64,11 @@ function parseCharClasses(pattern: string): CharClass[] {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
export const noSingleCharClass: KlintRule = {
|
|
67
|
+
meta: {
|
|
68
|
+
description:
|
|
69
|
+
"Flags single-character character classes in regex (e.g. `/[a]/`) — drop the brackets; the regex matches the same.",
|
|
70
|
+
examples: ["sonar/no-single-char-class: warn"],
|
|
71
|
+
},
|
|
66
72
|
check({ files, root, fileContents }, violations) {
|
|
67
73
|
for (const file of files) {
|
|
68
74
|
const content = fileContents.get(file) ?? "";
|
|
@@ -90,27 +96,13 @@ export const noSingleCharClass: KlintRule = {
|
|
|
90
96
|
fixedPattern += pattern.slice(prev);
|
|
91
97
|
|
|
92
98
|
const fixedRegex = `/${fixedPattern}/${flags}`;
|
|
93
|
-
const
|
|
94
|
-
const { line: endLine } = src.getLineAndCharacterOfPosition(node.getEnd());
|
|
95
|
-
const lineStarts = src.getLineStarts();
|
|
96
|
-
const lineStart = lineStarts[line];
|
|
97
|
-
const lineEnd =
|
|
98
|
-
endLine + 1 < lineStarts.length ? lineStarts[endLine + 1] - 1 : src.text.length;
|
|
99
|
-
const linesText = src.text.slice(lineStart, lineEnd);
|
|
100
|
-
const nodeOffset = node.getStart() - lineStart;
|
|
101
|
-
const nodeEndOffset = node.getEnd() - lineStart;
|
|
102
|
-
const fixedLines =
|
|
103
|
-
linesText.slice(0, nodeOffset) + fixedRegex + linesText.slice(nodeEndOffset);
|
|
99
|
+
const fix = buildNodeReplacementFix(src, node, fixedRegex);
|
|
104
100
|
|
|
105
101
|
violations.push({
|
|
106
102
|
file: relative(root, file),
|
|
107
|
-
line:
|
|
103
|
+
line: fix.startLine,
|
|
108
104
|
message: `Character class [${toFix[0].inner}] contains a single element — remove the brackets.`,
|
|
109
|
-
fix
|
|
110
|
-
startLine: line + 1,
|
|
111
|
-
endLine: endLine + 1,
|
|
112
|
-
replacement: fixedLines,
|
|
113
|
-
},
|
|
105
|
+
fix,
|
|
114
106
|
});
|
|
115
107
|
});
|
|
116
108
|
}
|
package/rules/no-string-match.ts
CHANGED
|
@@ -4,6 +4,11 @@ import { walkAst } from "../core/ast";
|
|
|
4
4
|
import type { KlintRule } from "../core/types";
|
|
5
5
|
|
|
6
6
|
export const noStringMatch: KlintRule = {
|
|
7
|
+
meta: {
|
|
8
|
+
description:
|
|
9
|
+
"Flags `String#match` — prefer `.matchAll()` for iteration or `.test()` for boolean checks. `.match()` returns different shapes depending on the `/g` flag.",
|
|
10
|
+
examples: ["no-string-match: error"],
|
|
11
|
+
},
|
|
7
12
|
check({ files, root, fileContents }, violations) {
|
|
8
13
|
for (const file of files) {
|
|
9
14
|
const content = fileContents.get(file) ?? "";
|
|
@@ -4,6 +4,14 @@ import { nearestFunctionIsAsync, walkAst } from "../core/ast";
|
|
|
4
4
|
import type { KlintRule } from "../core/types";
|
|
5
5
|
|
|
6
6
|
export const noSyncInAsync: KlintRule = {
|
|
7
|
+
meta: {
|
|
8
|
+
description:
|
|
9
|
+
"Flags synchronous I/O (`readFileSync`, `execSync`, etc.) inside async functions — blocks the event loop when an async equivalent is available.",
|
|
10
|
+
examples: [
|
|
11
|
+
"no-sync-in-async: error",
|
|
12
|
+
'no-sync-in-async:\n severity: warn\n include: ["src/server/**"]',
|
|
13
|
+
],
|
|
14
|
+
},
|
|
7
15
|
check({ files, root, fileContents }, violations) {
|
|
8
16
|
for (const file of files) {
|
|
9
17
|
const content = fileContents.get(file) ?? "";
|
|
@@ -4,6 +4,11 @@ import { isInsideTry, walkAst } from "../core/ast";
|
|
|
4
4
|
import type { KlintRule } from "../core/types";
|
|
5
5
|
|
|
6
6
|
export const noUnguardedJsonParse: KlintRule = {
|
|
7
|
+
meta: {
|
|
8
|
+
description:
|
|
9
|
+
"Flags `JSON.parse()` calls that aren't wrapped in try/catch — malformed input crashes the process.",
|
|
10
|
+
examples: ["no-unguarded-json-parse: error"],
|
|
11
|
+
},
|
|
7
12
|
check({ files, root, fileContents }, violations) {
|
|
8
13
|
for (const file of files) {
|
|
9
14
|
const content = fileContents.get(file) ?? "";
|
package/rules/prefer-at.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { relative } from "node:path";
|
|
2
2
|
import ts from "typescript";
|
|
3
3
|
import { walkAst } from "../core/ast";
|
|
4
|
+
import { buildNodeReplacementFix } from "../core/rule-helpers";
|
|
4
5
|
import type { KlintRule } from "../core/types";
|
|
5
6
|
|
|
6
7
|
export const preferAt: KlintRule = {
|
|
8
|
+
meta: {
|
|
9
|
+
description:
|
|
10
|
+
"Flags negative-index access via `arr[arr.length - 1]` — `.at(-1)` reads cleaner for last-element access.",
|
|
11
|
+
examples: ["sonar/prefer-at: warn"],
|
|
12
|
+
},
|
|
7
13
|
check({ files, root, fileContents }, violations) {
|
|
8
14
|
for (const file of files) {
|
|
9
15
|
const content = fileContents.get(file) ?? "";
|
|
@@ -26,27 +32,13 @@ export const preferAt: KlintRule = {
|
|
|
26
32
|
if (baseText !== arg.left.expression.getText(src)) return;
|
|
27
33
|
|
|
28
34
|
const fixedCall = `${baseText}.at(-${n})`;
|
|
29
|
-
const
|
|
30
|
-
const { line: endLine } = src.getLineAndCharacterOfPosition(node.getEnd());
|
|
31
|
-
const lineStarts = src.getLineStarts();
|
|
32
|
-
const lineStart = lineStarts[line];
|
|
33
|
-
const lineEnd =
|
|
34
|
-
endLine + 1 < lineStarts.length ? lineStarts[endLine + 1] - 1 : src.text.length;
|
|
35
|
-
const linesText = src.text.slice(lineStart, lineEnd);
|
|
36
|
-
const nodeOffset = node.getStart() - lineStart;
|
|
37
|
-
const nodeEndOffset = node.getEnd() - lineStart;
|
|
38
|
-
const fixedLines =
|
|
39
|
-
linesText.slice(0, nodeOffset) + fixedCall + linesText.slice(nodeEndOffset);
|
|
35
|
+
const fix = buildNodeReplacementFix(src, node, fixedCall);
|
|
40
36
|
|
|
41
37
|
violations.push({
|
|
42
38
|
file: relative(root, file),
|
|
43
|
-
line:
|
|
39
|
+
line: fix.startLine,
|
|
44
40
|
message: `Prefer ${baseText}.at(-${n}) over ${baseText}[${baseText}.length - ${n}] for cleaner negative indexing.`,
|
|
45
|
-
fix
|
|
46
|
-
startLine: line + 1,
|
|
47
|
-
endLine: endLine + 1,
|
|
48
|
-
replacement: fixedLines,
|
|
49
|
-
},
|
|
41
|
+
fix,
|
|
50
42
|
});
|
|
51
43
|
});
|
|
52
44
|
}
|
|
@@ -4,6 +4,11 @@ import { walkAst } from "../core/ast";
|
|
|
4
4
|
import type { KlintRule } from "../core/types";
|
|
5
5
|
|
|
6
6
|
export const preferNullishCoalescingAssign: KlintRule = {
|
|
7
|
+
meta: {
|
|
8
|
+
description:
|
|
9
|
+
"Flags `x = x ?? y` patterns — `x ??= y` is the dedicated nullish assignment operator.",
|
|
10
|
+
examples: ["sonar/prefer-nullish-coalescing-assign: warn"],
|
|
11
|
+
},
|
|
7
12
|
check({ files, root, fileContents }, violations) {
|
|
8
13
|
for (const file of files) {
|
|
9
14
|
const content = fileContents.get(file) ?? "";
|
|
@@ -4,6 +4,11 @@ import { walkAst } from "../core/ast";
|
|
|
4
4
|
import type { KlintRule } from "../core/types";
|
|
5
5
|
|
|
6
6
|
export const preferStringRawRegexp: KlintRule = {
|
|
7
|
+
meta: {
|
|
8
|
+
description:
|
|
9
|
+
"Flags regex literals built from escape-heavy string concatenation — `String.raw` drops the double backslashes.",
|
|
10
|
+
examples: ["sonar/prefer-string-raw-regexp: warn"],
|
|
11
|
+
},
|
|
7
12
|
check({ files, root, fileContents }, violations) {
|
|
8
13
|
for (const file of files) {
|
|
9
14
|
const content = fileContents.get(file) ?? "";
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { relative } from "node:path";
|
|
2
2
|
import ts from "typescript";
|
|
3
3
|
import { walkAst } from "../core/ast";
|
|
4
|
+
import { buildNodeReplacementFix } from "../core/rule-helpers";
|
|
4
5
|
import type { KlintRule } from "../core/types";
|
|
5
6
|
|
|
6
7
|
export const preferStringRaw: KlintRule = {
|
|
8
|
+
meta: {
|
|
9
|
+
description:
|
|
10
|
+
"Flags string literals with multiple backslash escapes — `String.raw` is more readable (Windows paths, regex source, etc.).",
|
|
11
|
+
examples: ["sonar/prefer-string-raw: warn"],
|
|
12
|
+
},
|
|
7
13
|
check({ files, root, fileContents }, violations) {
|
|
8
14
|
for (const file of files) {
|
|
9
15
|
const content = fileContents.get(file) ?? "";
|
|
@@ -17,29 +23,15 @@ export const preferStringRaw: KlintRule = {
|
|
|
17
23
|
// Can't embed backtick, ${ (would start interpolation), or trailing \ (would escape closing backtick)
|
|
18
24
|
if (value.includes("`") || value.includes("${") || value.endsWith("\\")) return;
|
|
19
25
|
|
|
20
|
-
const { line } = src.getLineAndCharacterOfPosition(node.getStart());
|
|
21
|
-
const { line: endLine } = src.getLineAndCharacterOfPosition(node.getEnd());
|
|
22
|
-
const lineStarts = src.getLineStarts();
|
|
23
|
-
const lineStart = lineStarts[line];
|
|
24
|
-
const lineEnd =
|
|
25
|
-
endLine + 1 < lineStarts.length ? lineStarts[endLine + 1] - 1 : src.text.length;
|
|
26
|
-
const linesText = src.text.slice(lineStart, lineEnd);
|
|
27
|
-
const nodeOffset = node.getStart() - lineStart;
|
|
28
|
-
const nodeEndOffset = node.getEnd() - lineStart;
|
|
29
26
|
const fixedNode = `String.raw\`${value}\``;
|
|
30
|
-
const
|
|
31
|
-
linesText.slice(0, nodeOffset) + fixedNode + linesText.slice(nodeEndOffset);
|
|
27
|
+
const fix = buildNodeReplacementFix(src, node, fixedNode);
|
|
32
28
|
|
|
33
29
|
violations.push({
|
|
34
30
|
file: relative(root, file),
|
|
35
|
-
line:
|
|
31
|
+
line: fix.startLine,
|
|
36
32
|
message:
|
|
37
33
|
"String literal with escaped backslashes — use String.raw`...` for clarity (Sonar S6535).",
|
|
38
|
-
fix
|
|
39
|
-
startLine: line + 1,
|
|
40
|
-
endLine: endLine + 1,
|
|
41
|
-
replacement: fixedLines,
|
|
42
|
-
},
|
|
34
|
+
fix,
|
|
43
35
|
});
|
|
44
36
|
});
|
|
45
37
|
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { relative } from "node:path";
|
|
2
2
|
import ts from "typescript";
|
|
3
3
|
import { walkAst } from "../core/ast";
|
|
4
|
+
import { buildNodeReplacementFix } from "../core/rule-helpers";
|
|
4
5
|
import type { KlintRule } from "../core/types";
|
|
5
6
|
|
|
6
7
|
export const preferStringReplaceall: KlintRule = {
|
|
8
|
+
meta: {
|
|
9
|
+
description:
|
|
10
|
+
"Flags `.replace(/x/g, y)` on strings — `.replaceAll('x', y)` is clearer and faster for all-occurrence replacements.",
|
|
11
|
+
examples: ["sonar/prefer-string-replaceall: error"],
|
|
12
|
+
},
|
|
7
13
|
check({ files, root, fileContents }, violations) {
|
|
8
14
|
for (const file of files) {
|
|
9
15
|
const content = fileContents.get(file) ?? "";
|
|
@@ -32,28 +38,13 @@ export const preferStringReplaceall: KlintRule = {
|
|
|
32
38
|
? `'${pattern.replaceAll("'", "\\'")}'`
|
|
33
39
|
: `"${pattern}"`;
|
|
34
40
|
const fixedCall = `${strText}.replaceAll(${patternLit}, ${replacementText})`;
|
|
35
|
-
|
|
36
|
-
const { line: s } = src.getLineAndCharacterOfPosition(node.getStart());
|
|
37
|
-
const { line: e } = src.getLineAndCharacterOfPosition(node.getEnd());
|
|
38
|
-
const lineStarts = src.getLineStarts();
|
|
39
|
-
const lineStart = lineStarts[s];
|
|
40
|
-
const lineEnd =
|
|
41
|
-
e + 1 < lineStarts.length ? lineStarts[e + 1] - 1 : src.text.length;
|
|
42
|
-
const linesText = src.text.slice(lineStart, lineEnd);
|
|
43
|
-
const nodeOffset = node.getStart() - lineStart;
|
|
44
|
-
const nodeEndOffset = node.getEnd() - lineStart;
|
|
45
|
-
const fixedLines =
|
|
46
|
-
linesText.slice(0, nodeOffset) + fixedCall + linesText.slice(nodeEndOffset);
|
|
41
|
+
const fix = buildNodeReplacementFix(src, node, fixedCall);
|
|
47
42
|
|
|
48
43
|
violations.push({
|
|
49
44
|
file: relative(root, file),
|
|
50
|
-
line:
|
|
45
|
+
line: fix.startLine,
|
|
51
46
|
message: `Prefer \`${strText}.replaceAll(${patternLit}, ...)\` over \`.replace(/${pattern}/g, ...)\` — replaceAll() with a string is clearer and avoids regex escaping pitfalls.`,
|
|
52
|
-
fix
|
|
53
|
-
startLine: s + 1,
|
|
54
|
-
endLine: e + 1,
|
|
55
|
-
replacement: fixedLines,
|
|
56
|
-
},
|
|
47
|
+
fix,
|
|
57
48
|
});
|
|
58
49
|
});
|
|
59
50
|
}
|
package/tools/generate-schema.ts
CHANGED
|
@@ -3,27 +3,10 @@ import { writeFileSync } from "node:fs";
|
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { stringify as toYaml } from "yaml";
|
|
5
5
|
import { KlintConfigSchema } from "../core/config.schema";
|
|
6
|
-
import { BUILT_IN_PLUGINS } from "../plugins/index";
|
|
7
|
-
import { BUILT_IN_RULES } from "../rules/index";
|
|
8
|
-
|
|
9
|
-
const allKnownRuleNames = [
|
|
10
|
-
...Object.keys(BUILT_IN_RULES),
|
|
11
|
-
...Object.values(BUILT_IN_PLUGINS).flatMap((p) => Object.keys(p.rules)),
|
|
12
|
-
];
|
|
13
6
|
|
|
14
7
|
const raw = KlintConfigSchema.toJSONSchema() as Record<string, unknown>;
|
|
15
8
|
|
|
16
|
-
//
|
|
17
|
-
// Expand known rule names into explicit property entries so editors suggest them.
|
|
18
|
-
// `additionalProperties` stays to allow custom rule names without validation errors.
|
|
19
|
-
const rulesSchema = (raw.properties as Record<string, Record<string, unknown>>).rules;
|
|
20
|
-
const valueSchema = rulesSchema.additionalProperties;
|
|
21
|
-
rulesSchema.properties = Object.fromEntries(
|
|
22
|
-
allKnownRuleNames.map((name) => [name, valueSchema])
|
|
23
|
-
);
|
|
24
|
-
delete rulesSchema.propertyNames;
|
|
25
|
-
|
|
26
|
-
// Remove the 2020-12 $schema key; set Draft-07 for broad editor compatibility
|
|
9
|
+
// Set Draft-07 (broad editor compatibility) instead of 2020-12.
|
|
27
10
|
delete raw.$schema;
|
|
28
11
|
|
|
29
12
|
const schema = {
|