@konvert7/klint 0.5.0 → 0.7.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/rule-helpers.ts +76 -0
- package/package.json +3 -1
- package/plugins/sonar.ts +6 -8
- package/rules/no-async-predicate.ts +19 -45
- package/rules/no-date-equality.ts +18 -43
- package/rules/no-floating-promise.ts +10 -36
- package/rules/no-misused-promises.ts +21 -49
- package/rules/no-object-in-template.ts +12 -41
- package/rules/no-optional-chain-on-non-nullable.ts +14 -40
- package/rules/no-single-char-class.ts +4 -17
- package/rules/prefer-at.ts +4 -17
- package/rules/prefer-string-raw.ts +4 -17
- package/rules/prefer-string-replaceall.ts +4 -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
|
}
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@konvert7/klint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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,7 @@
|
|
|
39
39
|
"check-write": "biome check --write",
|
|
40
40
|
"knip": "knip-bun",
|
|
41
41
|
"klint": "bun cli.ts",
|
|
42
|
+
"jscpd": "jscpd --noTips",
|
|
42
43
|
"schema": "bun tools/generate-schema.ts",
|
|
43
44
|
"lint-staged": "lint-staged",
|
|
44
45
|
"prepare": "bun .husky/install.mjs"
|
|
@@ -64,6 +65,7 @@
|
|
|
64
65
|
"@types/bun": "latest",
|
|
65
66
|
"@types/node": "latest",
|
|
66
67
|
"husky": "^9.1.7",
|
|
68
|
+
"jscpd": "4.2.3",
|
|
67
69
|
"knip": "^6.9.0",
|
|
68
70
|
"lint-staged": "^15.5.2",
|
|
69
71
|
"semantic-release": "^25.0.3"
|
package/plugins/sonar.ts
CHANGED
|
@@ -17,13 +17,11 @@ export const sonarPlugin: KlintPlugin = {
|
|
|
17
17
|
"sonar/prefer-at": "error",
|
|
18
18
|
},
|
|
19
19
|
implementations: {
|
|
20
|
-
"sonar/prefer-string-replaceall":
|
|
21
|
-
"sonar/prefer-string-raw-regexp":
|
|
22
|
-
"sonar/prefer-string-raw":
|
|
23
|
-
"sonar/prefer-nullish-coalescing-assign":
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"sonar/no-single-char-class": { check: noSingleCharClass.check },
|
|
27
|
-
"sonar/prefer-at": { check: preferAt.check },
|
|
20
|
+
"sonar/prefer-string-replaceall": preferStringReplaceall,
|
|
21
|
+
"sonar/prefer-string-raw-regexp": preferStringRawRegexp,
|
|
22
|
+
"sonar/prefer-string-raw": preferStringRaw,
|
|
23
|
+
"sonar/prefer-nullish-coalescing-assign": preferNullishCoalescingAssign,
|
|
24
|
+
"sonar/no-single-char-class": noSingleCharClass,
|
|
25
|
+
"sonar/prefer-at": preferAt,
|
|
28
26
|
},
|
|
29
27
|
};
|
|
@@ -1,62 +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 =
|
|
6
|
+
export const noAsyncPredicate = defineAstRule({
|
|
10
7
|
meta: {
|
|
11
8
|
description:
|
|
12
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.",
|
|
13
10
|
examples: ["no-async-predicate: error"],
|
|
14
11
|
},
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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;
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
+
);
|
|
23
28
|
}
|
|
24
29
|
},
|
|
25
30
|
});
|
|
26
31
|
|
|
27
|
-
function
|
|
28
|
-
|
|
29
|
-
checker: ts.TypeChecker,
|
|
30
|
-
root: string,
|
|
31
|
-
violations: RawViolation[]
|
|
32
|
-
): void {
|
|
33
|
-
function visit(node: ts.Node): void {
|
|
34
|
-
if (
|
|
35
|
-
ts.isCallExpression(node) &&
|
|
36
|
-
ts.isPropertyAccessExpression(node.expression) &&
|
|
37
|
-
PREDICATE_METHODS.has(node.expression.name.text) &&
|
|
38
|
-
node.arguments.length > 0
|
|
39
|
-
) {
|
|
40
|
-
const receiverType = checker.getTypeAtLocation(node.expression.expression);
|
|
41
|
-
if (isArrayLike(receiverType, checker) && isAsyncFunction(node.arguments[0])) {
|
|
42
|
-
const method = node.expression.name.text;
|
|
43
|
-
const { line } = sourceFile.getLineAndCharacterOfPosition(
|
|
44
|
-
node.arguments[0].getStart()
|
|
45
|
-
);
|
|
46
|
-
violations.push({
|
|
47
|
-
file: relative(root, sourceFile.fileName),
|
|
48
|
-
line: line + 1,
|
|
49
|
-
message: `Async callback passed to .${method}() — the returned Promise is always truthy, so the predicate never filters correctly. The array method cannot await it.`,
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
ts.forEachChild(node, visit);
|
|
54
|
-
}
|
|
55
|
-
visit(sourceFile);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function isArrayLike(type: ts.Type, checker: ts.TypeChecker): boolean {
|
|
59
|
-
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));
|
|
60
34
|
const sym = type.getSymbol();
|
|
61
35
|
if (sym?.name === "Array" || sym?.name === "ReadonlyArray") return true;
|
|
62
36
|
const ref = type as ts.TypeReference;
|
|
@@ -1,58 +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 =
|
|
4
|
+
export const noDateEquality = defineAstRule({
|
|
8
5
|
meta: {
|
|
9
6
|
description:
|
|
10
7
|
"Flags `Date` comparisons with `==`/`===`/`!=`/`!==` — these compare object identity, not the time value. Use `.getTime()` instead.",
|
|
11
8
|
examples: ["no-date-equality: error"],
|
|
12
9
|
},
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
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;
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
);
|
|
21
27
|
}
|
|
22
28
|
},
|
|
23
29
|
});
|
|
24
30
|
|
|
25
|
-
function visitFile(
|
|
26
|
-
sourceFile: ts.SourceFile,
|
|
27
|
-
checker: ts.TypeChecker,
|
|
28
|
-
root: string,
|
|
29
|
-
violations: RawViolation[]
|
|
30
|
-
): void {
|
|
31
|
-
function visit(node: ts.Node): void {
|
|
32
|
-
if (ts.isBinaryExpression(node)) {
|
|
33
|
-
const { kind } = node.operatorToken;
|
|
34
|
-
if (
|
|
35
|
-
kind === ts.SyntaxKind.EqualsEqualsEqualsToken ||
|
|
36
|
-
kind === ts.SyntaxKind.ExclamationEqualsEqualsToken
|
|
37
|
-
) {
|
|
38
|
-
const leftType = checker.getTypeAtLocation(node.left);
|
|
39
|
-
const rightType = checker.getTypeAtLocation(node.right);
|
|
40
|
-
if (isDateType(leftType) && isDateType(rightType)) {
|
|
41
|
-
const op = node.operatorToken.getText(sourceFile);
|
|
42
|
-
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
43
|
-
violations.push({
|
|
44
|
-
file: relative(root, sourceFile.fileName),
|
|
45
|
-
line: line + 1,
|
|
46
|
-
message: `Date values compared with ${op} — this compares object references, not time values. Use .getTime() or .valueOf() instead.`,
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
ts.forEachChild(node, visit);
|
|
52
|
-
}
|
|
53
|
-
visit(sourceFile);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
31
|
function isDateType(type: ts.Type): boolean {
|
|
57
32
|
if (type.getSymbol()?.name === "Date") return true;
|
|
58
33
|
if (type.isUnion()) return type.types.some(isDateType);
|
|
@@ -1,54 +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 =
|
|
4
|
+
export const noFloatingPromise = defineAstRule({
|
|
8
5
|
meta: {
|
|
9
6
|
description:
|
|
10
7
|
"Flags Promise-returning expressions that aren't awaited or `.catch()`-handled — silent failures and unpredictable execution order.",
|
|
11
8
|
examples: ["no-floating-promise: error"],
|
|
12
9
|
},
|
|
13
|
-
|
|
14
|
-
const program = createProgram(files, root);
|
|
15
|
-
const checker = program.getTypeChecker();
|
|
16
|
-
const fileSet = new Set(files);
|
|
17
|
-
|
|
18
|
-
for (const sourceFile of program.getSourceFiles()) {
|
|
19
|
-
if (!fileSet.has(sourceFile.fileName) || sourceFile.isDeclarationFile) continue;
|
|
20
|
-
visitFile(sourceFile, checker, root, violations);
|
|
21
|
-
}
|
|
22
|
-
},
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
function visitFile(
|
|
26
|
-
sourceFile: ts.SourceFile,
|
|
27
|
-
checker: ts.TypeChecker,
|
|
28
|
-
root: string,
|
|
29
|
-
violations: RawViolation[]
|
|
30
|
-
): void {
|
|
31
|
-
function visit(node: ts.Node): void {
|
|
10
|
+
visit(node, ctx) {
|
|
32
11
|
if (
|
|
33
12
|
ts.isExpressionStatement(node) &&
|
|
34
13
|
ts.isCallExpression(node.expression) &&
|
|
35
14
|
!isHandled(node.expression)
|
|
36
15
|
) {
|
|
37
|
-
const type = checker.getTypeAtLocation(node.expression);
|
|
16
|
+
const type = ctx.checker.getTypeAtLocation(node.expression);
|
|
38
17
|
if (isPromiseLike(type)) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
message:
|
|
44
|
-
"Promise-returning call is not awaited — errors will be silently discarded and execution order is unpredictable. Use await, void, or .catch().",
|
|
45
|
-
});
|
|
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
|
+
);
|
|
46
22
|
}
|
|
47
23
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
visit(sourceFile);
|
|
51
|
-
}
|
|
24
|
+
},
|
|
25
|
+
});
|
|
52
26
|
|
|
53
27
|
function isHandled(node: ts.CallExpression): boolean {
|
|
54
28
|
if (!ts.isPropertyAccessExpression(node.expression)) return false;
|
|
@@ -1,63 +1,35 @@
|
|
|
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 =
|
|
4
|
+
export const noMisusedPromises = defineAstRule({
|
|
8
5
|
meta: {
|
|
9
6
|
description:
|
|
10
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.",
|
|
11
8
|
examples: ["no-misused-promises: error"],
|
|
12
9
|
},
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
for (
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
);
|
|
28
|
+
}
|
|
21
29
|
}
|
|
22
30
|
},
|
|
23
31
|
});
|
|
24
32
|
|
|
25
|
-
function visitFile(
|
|
26
|
-
sourceFile: ts.SourceFile,
|
|
27
|
-
checker: ts.TypeChecker,
|
|
28
|
-
root: string,
|
|
29
|
-
violations: RawViolation[]
|
|
30
|
-
): void {
|
|
31
|
-
function visit(node: ts.Node): void {
|
|
32
|
-
if (ts.isCallExpression(node)) {
|
|
33
|
-
const sig = checker.getResolvedSignature(node);
|
|
34
|
-
if (sig) {
|
|
35
|
-
const params = sig.getParameters();
|
|
36
|
-
for (let i = 0; i < node.arguments.length; i++) {
|
|
37
|
-
const arg = node.arguments[i];
|
|
38
|
-
if (!isAsyncFunction(arg)) continue;
|
|
39
|
-
|
|
40
|
-
const param = params[Math.min(i, params.length - 1)];
|
|
41
|
-
if (!param) continue;
|
|
42
|
-
|
|
43
|
-
const paramType = checker.getTypeOfSymbolAtLocation(param, node);
|
|
44
|
-
if (expectsSyncCallback(paramType, checker)) {
|
|
45
|
-
const { line } = sourceFile.getLineAndCharacterOfPosition(arg.getStart());
|
|
46
|
-
violations.push({
|
|
47
|
-
file: relative(root, sourceFile.fileName),
|
|
48
|
-
line: line + 1,
|
|
49
|
-
message:
|
|
50
|
-
"Async function passed where a sync callback is expected — the caller cannot await it and errors will be silently lost.",
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
ts.forEachChild(node, visit);
|
|
57
|
-
}
|
|
58
|
-
visit(sourceFile);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
33
|
function isAsyncFunction(
|
|
62
34
|
node: ts.Node
|
|
63
35
|
): node is ts.ArrowFunction | ts.FunctionExpression {
|
|
@@ -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,52 +18,26 @@ const SAFE_SYMBOL_NAMES = new Set([
|
|
|
21
18
|
"Symbol",
|
|
22
19
|
]);
|
|
23
20
|
|
|
24
|
-
export const noObjectInTemplate =
|
|
21
|
+
export const noObjectInTemplate = defineAstRule({
|
|
25
22
|
meta: {
|
|
26
23
|
description:
|
|
27
24
|
"Flags non-primitive objects interpolated into template literals — produces `[object Object]` unless `toString()` is overridden.",
|
|
28
25
|
examples: ["no-object-in-template: error"],
|
|
29
26
|
},
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
);
|
|
36
|
+
}
|
|
38
37
|
}
|
|
39
38
|
},
|
|
40
39
|
});
|
|
41
40
|
|
|
42
|
-
function visitFile(
|
|
43
|
-
sourceFile: ts.SourceFile,
|
|
44
|
-
checker: ts.TypeChecker,
|
|
45
|
-
root: string,
|
|
46
|
-
violations: RawViolation[]
|
|
47
|
-
): void {
|
|
48
|
-
function visit(node: ts.Node): void {
|
|
49
|
-
if (ts.isTemplateExpression(node)) {
|
|
50
|
-
for (const span of node.templateSpans) {
|
|
51
|
-
const type = checker.getTypeAtLocation(span.expression);
|
|
52
|
-
if (wouldRenderAsObjectObject(type, checker)) {
|
|
53
|
-
const { line } = sourceFile.getLineAndCharacterOfPosition(
|
|
54
|
-
span.expression.getStart()
|
|
55
|
-
);
|
|
56
|
-
violations.push({
|
|
57
|
-
file: relative(root, sourceFile.fileName),
|
|
58
|
-
line: line + 1,
|
|
59
|
-
message:
|
|
60
|
-
"Object interpolated in template literal has no custom toString() — it will render as [object Object]. Access a specific property or implement toString().",
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
ts.forEachChild(node, visit);
|
|
66
|
-
}
|
|
67
|
-
visit(sourceFile);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
41
|
function wouldRenderAsObjectObject(type: ts.Type, checker: ts.TypeChecker): boolean {
|
|
71
42
|
const primitiveFlags =
|
|
72
43
|
ts.TypeFlags.String |
|
|
@@ -1,53 +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 =
|
|
4
|
+
export const noOptionalChainOnNonNullable = defineAstRule({
|
|
8
5
|
meta: {
|
|
9
6
|
description:
|
|
10
7
|
"Flags `?.` on receivers whose static type cannot be `null` or `undefined`. The optional chain is dead code and obscures the real shape.",
|
|
11
8
|
examples: ["no-optional-chain-on-non-nullable: warn"],
|
|
12
9
|
},
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const checker = program.getTypeChecker();
|
|
16
|
-
|
|
17
|
-
if (!program.getCompilerOptions().strictNullChecks) return;
|
|
18
|
-
|
|
19
|
-
const fileSet = new Set(files);
|
|
20
|
-
for (const sourceFile of program.getSourceFiles()) {
|
|
21
|
-
if (!fileSet.has(sourceFile.fileName) || sourceFile.isDeclarationFile) continue;
|
|
22
|
-
visitFile(sourceFile, checker, root, violations);
|
|
23
|
-
}
|
|
10
|
+
shouldRun(program) {
|
|
11
|
+
return program.getCompilerOptions().strictNullChecks === true;
|
|
24
12
|
},
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
function visitFile(
|
|
28
|
-
sourceFile: ts.SourceFile,
|
|
29
|
-
checker: ts.TypeChecker,
|
|
30
|
-
root: string,
|
|
31
|
-
violations: RawViolation[]
|
|
32
|
-
): void {
|
|
33
|
-
function visit(node: ts.Node): void {
|
|
13
|
+
visit(node, ctx) {
|
|
34
14
|
const receiver = getOptionalChainReceiver(node);
|
|
35
|
-
if (receiver)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
message:
|
|
43
|
-
"Optional chain (?.) on a non-nullable type — the receiver can never be null or undefined here. Use . to remove misleading dead code.",
|
|
44
|
-
});
|
|
45
|
-
}
|
|
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
|
+
);
|
|
46
22
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
visit(sourceFile);
|
|
50
|
-
}
|
|
23
|
+
},
|
|
24
|
+
});
|
|
51
25
|
|
|
52
26
|
function getOptionalChainReceiver(node: ts.Node): ts.Expression | undefined {
|
|
53
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,
|
|
@@ -95,27 +96,13 @@ export const noSingleCharClass: KlintRule = {
|
|
|
95
96
|
fixedPattern += pattern.slice(prev);
|
|
96
97
|
|
|
97
98
|
const fixedRegex = `/${fixedPattern}/${flags}`;
|
|
98
|
-
const
|
|
99
|
-
const { line: endLine } = src.getLineAndCharacterOfPosition(node.getEnd());
|
|
100
|
-
const lineStarts = src.getLineStarts();
|
|
101
|
-
const lineStart = lineStarts[line];
|
|
102
|
-
const lineEnd =
|
|
103
|
-
endLine + 1 < lineStarts.length ? lineStarts[endLine + 1] - 1 : src.text.length;
|
|
104
|
-
const linesText = src.text.slice(lineStart, lineEnd);
|
|
105
|
-
const nodeOffset = node.getStart() - lineStart;
|
|
106
|
-
const nodeEndOffset = node.getEnd() - lineStart;
|
|
107
|
-
const fixedLines =
|
|
108
|
-
linesText.slice(0, nodeOffset) + fixedRegex + linesText.slice(nodeEndOffset);
|
|
99
|
+
const fix = buildNodeReplacementFix(src, node, fixedRegex);
|
|
109
100
|
|
|
110
101
|
violations.push({
|
|
111
102
|
file: relative(root, file),
|
|
112
|
-
line:
|
|
103
|
+
line: fix.startLine,
|
|
113
104
|
message: `Character class [${toFix[0].inner}] contains a single element — remove the brackets.`,
|
|
114
|
-
fix
|
|
115
|
-
startLine: line + 1,
|
|
116
|
-
endLine: endLine + 1,
|
|
117
|
-
replacement: fixedLines,
|
|
118
|
-
},
|
|
105
|
+
fix,
|
|
119
106
|
});
|
|
120
107
|
});
|
|
121
108
|
}
|
package/rules/prefer-at.ts
CHANGED
|
@@ -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
|
export const preferAt: KlintRule = {
|
|
@@ -31,27 +32,13 @@ export const preferAt: KlintRule = {
|
|
|
31
32
|
if (baseText !== arg.left.expression.getText(src)) return;
|
|
32
33
|
|
|
33
34
|
const fixedCall = `${baseText}.at(-${n})`;
|
|
34
|
-
const
|
|
35
|
-
const { line: endLine } = src.getLineAndCharacterOfPosition(node.getEnd());
|
|
36
|
-
const lineStarts = src.getLineStarts();
|
|
37
|
-
const lineStart = lineStarts[line];
|
|
38
|
-
const lineEnd =
|
|
39
|
-
endLine + 1 < lineStarts.length ? lineStarts[endLine + 1] - 1 : src.text.length;
|
|
40
|
-
const linesText = src.text.slice(lineStart, lineEnd);
|
|
41
|
-
const nodeOffset = node.getStart() - lineStart;
|
|
42
|
-
const nodeEndOffset = node.getEnd() - lineStart;
|
|
43
|
-
const fixedLines =
|
|
44
|
-
linesText.slice(0, nodeOffset) + fixedCall + linesText.slice(nodeEndOffset);
|
|
35
|
+
const fix = buildNodeReplacementFix(src, node, fixedCall);
|
|
45
36
|
|
|
46
37
|
violations.push({
|
|
47
38
|
file: relative(root, file),
|
|
48
|
-
line:
|
|
39
|
+
line: fix.startLine,
|
|
49
40
|
message: `Prefer ${baseText}.at(-${n}) over ${baseText}[${baseText}.length - ${n}] for cleaner negative indexing.`,
|
|
50
|
-
fix
|
|
51
|
-
startLine: line + 1,
|
|
52
|
-
endLine: endLine + 1,
|
|
53
|
-
replacement: fixedLines,
|
|
54
|
-
},
|
|
41
|
+
fix,
|
|
55
42
|
});
|
|
56
43
|
});
|
|
57
44
|
}
|
|
@@ -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
|
export const preferStringRaw: KlintRule = {
|
|
@@ -22,29 +23,15 @@ export const preferStringRaw: KlintRule = {
|
|
|
22
23
|
// Can't embed backtick, ${ (would start interpolation), or trailing \ (would escape closing backtick)
|
|
23
24
|
if (value.includes("`") || value.includes("${") || value.endsWith("\\")) return;
|
|
24
25
|
|
|
25
|
-
const { line } = src.getLineAndCharacterOfPosition(node.getStart());
|
|
26
|
-
const { line: endLine } = src.getLineAndCharacterOfPosition(node.getEnd());
|
|
27
|
-
const lineStarts = src.getLineStarts();
|
|
28
|
-
const lineStart = lineStarts[line];
|
|
29
|
-
const lineEnd =
|
|
30
|
-
endLine + 1 < lineStarts.length ? lineStarts[endLine + 1] - 1 : src.text.length;
|
|
31
|
-
const linesText = src.text.slice(lineStart, lineEnd);
|
|
32
|
-
const nodeOffset = node.getStart() - lineStart;
|
|
33
|
-
const nodeEndOffset = node.getEnd() - lineStart;
|
|
34
26
|
const fixedNode = `String.raw\`${value}\``;
|
|
35
|
-
const
|
|
36
|
-
linesText.slice(0, nodeOffset) + fixedNode + linesText.slice(nodeEndOffset);
|
|
27
|
+
const fix = buildNodeReplacementFix(src, node, fixedNode);
|
|
37
28
|
|
|
38
29
|
violations.push({
|
|
39
30
|
file: relative(root, file),
|
|
40
|
-
line:
|
|
31
|
+
line: fix.startLine,
|
|
41
32
|
message:
|
|
42
33
|
"String literal with escaped backslashes — use String.raw`...` for clarity (Sonar S6535).",
|
|
43
|
-
fix
|
|
44
|
-
startLine: line + 1,
|
|
45
|
-
endLine: endLine + 1,
|
|
46
|
-
replacement: fixedLines,
|
|
47
|
-
},
|
|
34
|
+
fix,
|
|
48
35
|
});
|
|
49
36
|
});
|
|
50
37
|
}
|
|
@@ -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
|
export const preferStringReplaceall: KlintRule = {
|
|
@@ -37,28 +38,13 @@ export const preferStringReplaceall: KlintRule = {
|
|
|
37
38
|
? `'${pattern.replaceAll("'", "\\'")}'`
|
|
38
39
|
: `"${pattern}"`;
|
|
39
40
|
const fixedCall = `${strText}.replaceAll(${patternLit}, ${replacementText})`;
|
|
40
|
-
|
|
41
|
-
const { line: s } = src.getLineAndCharacterOfPosition(node.getStart());
|
|
42
|
-
const { line: e } = src.getLineAndCharacterOfPosition(node.getEnd());
|
|
43
|
-
const lineStarts = src.getLineStarts();
|
|
44
|
-
const lineStart = lineStarts[s];
|
|
45
|
-
const lineEnd =
|
|
46
|
-
e + 1 < lineStarts.length ? lineStarts[e + 1] - 1 : src.text.length;
|
|
47
|
-
const linesText = src.text.slice(lineStart, lineEnd);
|
|
48
|
-
const nodeOffset = node.getStart() - lineStart;
|
|
49
|
-
const nodeEndOffset = node.getEnd() - lineStart;
|
|
50
|
-
const fixedLines =
|
|
51
|
-
linesText.slice(0, nodeOffset) + fixedCall + linesText.slice(nodeEndOffset);
|
|
41
|
+
const fix = buildNodeReplacementFix(src, node, fixedCall);
|
|
52
42
|
|
|
53
43
|
violations.push({
|
|
54
44
|
file: relative(root, file),
|
|
55
|
-
line:
|
|
45
|
+
line: fix.startLine,
|
|
56
46
|
message: `Prefer \`${strText}.replaceAll(${patternLit}, ...)\` over \`.replace(/${pattern}/g, ...)\` — replaceAll() with a string is clearer and avoids regex escaping pitfalls.`,
|
|
57
|
-
fix
|
|
58
|
-
startLine: s + 1,
|
|
59
|
-
endLine: e + 1,
|
|
60
|
-
replacement: fixedLines,
|
|
61
|
-
},
|
|
47
|
+
fix,
|
|
62
48
|
});
|
|
63
49
|
});
|
|
64
50
|
}
|