@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/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@konvert7/klint",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Architecture-as-Code linter for TypeScript. Enforce layer boundaries, import rules, and singleton patterns in YAML.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"klint": "cli.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"cli.ts",
|
|
11
|
+
"core",
|
|
12
|
+
"rules",
|
|
13
|
+
"plugins",
|
|
14
|
+
"skill",
|
|
15
|
+
"tools",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"bun": ">=1.3.0"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"architecture",
|
|
23
|
+
"linter",
|
|
24
|
+
"typescript",
|
|
25
|
+
"bun",
|
|
26
|
+
"agent",
|
|
27
|
+
"agentic",
|
|
28
|
+
"arch-as-code"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/konvert7/klint.git"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"type-check": "tsc --noEmit",
|
|
37
|
+
"test": "bun test",
|
|
38
|
+
"check": "biome check",
|
|
39
|
+
"check-write": "biome check --write",
|
|
40
|
+
"knip": "knip-bun",
|
|
41
|
+
"klint": "bun cli.ts",
|
|
42
|
+
"lint-staged": "lint-staged",
|
|
43
|
+
"prepare": "bun .husky/install.mjs"
|
|
44
|
+
},
|
|
45
|
+
"lint-staged": {
|
|
46
|
+
"*.ts": [
|
|
47
|
+
"bun run check"
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@clack/prompts": "^1.3.0",
|
|
52
|
+
"typescript": "^5.9.3",
|
|
53
|
+
"yaml": "^2.9.0",
|
|
54
|
+
"zod": "^4.4.3"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@biomejs/biome": "2.4.7",
|
|
58
|
+
"@commitlint/cli": "^20.5.3",
|
|
59
|
+
"@commitlint/config-conventional": "^20.5.3",
|
|
60
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
61
|
+
"@semantic-release/git": "^10.0.1",
|
|
62
|
+
"@semantic-release/github": "^12.0.6",
|
|
63
|
+
"@types/bun": "latest",
|
|
64
|
+
"@types/node": "latest",
|
|
65
|
+
"husky": "^9.1.7",
|
|
66
|
+
"knip": "^6.9.0",
|
|
67
|
+
"lint-staged": "^15.5.2",
|
|
68
|
+
"semantic-release": "^25.0.3"
|
|
69
|
+
},
|
|
70
|
+
"directories": {
|
|
71
|
+
"test": "tests"
|
|
72
|
+
},
|
|
73
|
+
"author": "Konvert7",
|
|
74
|
+
"bugs": {
|
|
75
|
+
"url": "https://github.com/konvert7/klint/issues"
|
|
76
|
+
},
|
|
77
|
+
"homepage": "https://github.com/konvert7/klint#readme"
|
|
78
|
+
}
|
package/plugins/index.ts
ADDED
package/plugins/sonar.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { KlintPlugin } from "../core/types";
|
|
2
|
+
import { noSingleCharClass } from "../rules/no-single-char-class";
|
|
3
|
+
import { preferAt } from "../rules/prefer-at";
|
|
4
|
+
import { preferNullishCoalescingAssign } from "../rules/prefer-nullish-coalescing-assign";
|
|
5
|
+
import { preferStringRaw } from "../rules/prefer-string-raw";
|
|
6
|
+
import { preferStringRawRegexp } from "../rules/prefer-string-raw-regexp";
|
|
7
|
+
import { preferStringReplaceall } from "../rules/prefer-string-replaceall";
|
|
8
|
+
|
|
9
|
+
export const sonarPlugin: KlintPlugin = {
|
|
10
|
+
name: "sonar",
|
|
11
|
+
rules: {
|
|
12
|
+
"sonar/prefer-string-replaceall": "error",
|
|
13
|
+
"sonar/prefer-string-raw-regexp": "error",
|
|
14
|
+
"sonar/prefer-string-raw": "error",
|
|
15
|
+
"sonar/prefer-nullish-coalescing-assign": "error",
|
|
16
|
+
"sonar/no-single-char-class": "error",
|
|
17
|
+
"sonar/prefer-at": "error",
|
|
18
|
+
},
|
|
19
|
+
implementations: {
|
|
20
|
+
"sonar/prefer-string-replaceall": { check: preferStringReplaceall.check },
|
|
21
|
+
"sonar/prefer-string-raw-regexp": { check: preferStringRawRegexp.check },
|
|
22
|
+
"sonar/prefer-string-raw": { check: preferStringRaw.check },
|
|
23
|
+
"sonar/prefer-nullish-coalescing-assign": {
|
|
24
|
+
check: preferNullishCoalescingAssign.check,
|
|
25
|
+
},
|
|
26
|
+
"sonar/no-single-char-class": { check: noSingleCharClass.check },
|
|
27
|
+
"sonar/prefer-at": { check: preferAt.check },
|
|
28
|
+
},
|
|
29
|
+
};
|
package/rules/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { KlintRule } from "../core/types";
|
|
2
|
+
import { noAsyncPredicate } from "./no-async-predicate";
|
|
3
|
+
import { noConsecutiveArrayPush } from "./no-consecutive-array-push";
|
|
4
|
+
import { noDateEquality } from "./no-date-equality";
|
|
5
|
+
import { noFloatingPromise } from "./no-floating-promise";
|
|
6
|
+
import { noMisusedPromises } from "./no-misused-promises";
|
|
7
|
+
import { noNestedTemplateLiterals } from "./no-nested-template-literals";
|
|
8
|
+
import { noObjectInTemplate } from "./no-object-in-template";
|
|
9
|
+
import { noOptionalChainOnNonNullable } from "./no-optional-chain-on-non-nullable";
|
|
10
|
+
import { noStringMatch } from "./no-string-match";
|
|
11
|
+
import { noSyncInAsync } from "./no-sync-in-async";
|
|
12
|
+
import { noUnguardedJsonParse } from "./no-unguarded-json-parse";
|
|
13
|
+
|
|
14
|
+
export const BUILT_IN_RULES: Record<string, KlintRule> = {
|
|
15
|
+
"no-unguarded-json-parse": noUnguardedJsonParse,
|
|
16
|
+
"no-sync-in-async": noSyncInAsync,
|
|
17
|
+
"no-floating-promise": noFloatingPromise,
|
|
18
|
+
"no-misused-promises": noMisusedPromises,
|
|
19
|
+
"no-async-predicate": noAsyncPredicate,
|
|
20
|
+
"no-date-equality": noDateEquality,
|
|
21
|
+
"no-optional-chain-on-non-nullable": noOptionalChainOnNonNullable,
|
|
22
|
+
"no-object-in-template": noObjectInTemplate,
|
|
23
|
+
"no-nested-template-literals": noNestedTemplateLiterals,
|
|
24
|
+
"no-consecutive-array-push": noConsecutiveArrayPush,
|
|
25
|
+
"no-string-match": noStringMatch,
|
|
26
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { createProgram } from "../core/ast";
|
|
4
|
+
import type { RawViolation } from "../core/types";
|
|
5
|
+
import { defineRule } from "../core/types";
|
|
6
|
+
|
|
7
|
+
const PREDICATE_METHODS = new Set(["filter", "some", "every", "find", "findIndex"]);
|
|
8
|
+
|
|
9
|
+
export const noAsyncPredicate = defineRule({
|
|
10
|
+
check({ files, root }, violations) {
|
|
11
|
+
const program = createProgram(files, root);
|
|
12
|
+
const checker = program.getTypeChecker();
|
|
13
|
+
const fileSet = new Set(files);
|
|
14
|
+
|
|
15
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
16
|
+
if (!fileSet.has(sourceFile.fileName) || sourceFile.isDeclarationFile) continue;
|
|
17
|
+
visitFile(sourceFile, checker, root, violations);
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function visitFile(
|
|
23
|
+
sourceFile: ts.SourceFile,
|
|
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));
|
|
55
|
+
const sym = type.getSymbol();
|
|
56
|
+
if (sym?.name === "Array" || sym?.name === "ReadonlyArray") return true;
|
|
57
|
+
const ref = type as ts.TypeReference;
|
|
58
|
+
if (ref.target) {
|
|
59
|
+
const targetSym = ref.target.getSymbol();
|
|
60
|
+
if (targetSym?.name === "Array" || targetSym?.name === "ReadonlyArray") return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isAsyncFunction(
|
|
66
|
+
node: ts.Node
|
|
67
|
+
): node is ts.ArrowFunction | ts.FunctionExpression {
|
|
68
|
+
return (
|
|
69
|
+
(ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
|
|
70
|
+
(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { walkAst } from "../core/ast";
|
|
4
|
+
import type { KlintRule } from "../core/types";
|
|
5
|
+
|
|
6
|
+
export const noConsecutiveArrayPush: KlintRule = {
|
|
7
|
+
check({ files, root, fileContents }, violations) {
|
|
8
|
+
for (const file of files) {
|
|
9
|
+
const content = fileContents.get(file) ?? "";
|
|
10
|
+
walkAst(file, content, (node, src) => {
|
|
11
|
+
const statements =
|
|
12
|
+
ts.isBlock(node) || ts.isSourceFile(node) ? node.statements : null;
|
|
13
|
+
if (!statements) return;
|
|
14
|
+
|
|
15
|
+
let runStart = -1;
|
|
16
|
+
let runReceiver = "";
|
|
17
|
+
|
|
18
|
+
const flush = (upTo: number) => {
|
|
19
|
+
if (runStart !== -1 && upTo - runStart >= 2) {
|
|
20
|
+
const { line } = src.getLineAndCharacterOfPosition(
|
|
21
|
+
statements[runStart].getStart()
|
|
22
|
+
);
|
|
23
|
+
violations.push({
|
|
24
|
+
file: relative(root, file),
|
|
25
|
+
line: line + 1,
|
|
26
|
+
message: `${upTo - runStart} consecutive .push() calls on \`${runReceiver}\` — combine into a single .push(a, b, …) call.`,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
runStart = -1;
|
|
30
|
+
runReceiver = "";
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < statements.length; i++) {
|
|
34
|
+
const receiver = getPushReceiver(statements[i], src);
|
|
35
|
+
if (receiver && receiver === runReceiver) {
|
|
36
|
+
// continue the run
|
|
37
|
+
} else {
|
|
38
|
+
flush(i);
|
|
39
|
+
runStart = receiver ? i : -1;
|
|
40
|
+
runReceiver = receiver ?? "";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
flush(statements.length);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function getPushReceiver(node: ts.Statement, src: ts.SourceFile): string | null {
|
|
50
|
+
if (!ts.isExpressionStatement(node)) return null;
|
|
51
|
+
const expr = node.expression;
|
|
52
|
+
if (!ts.isCallExpression(expr)) return null;
|
|
53
|
+
if (!ts.isPropertyAccessExpression(expr.expression)) return null;
|
|
54
|
+
if (expr.expression.name.text !== "push") return null;
|
|
55
|
+
return expr.expression.expression.getText(src);
|
|
56
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { createProgram } from "../core/ast";
|
|
4
|
+
import type { RawViolation } from "../core/types";
|
|
5
|
+
import { defineRule } from "../core/types";
|
|
6
|
+
|
|
7
|
+
export const noDateEquality = defineRule({
|
|
8
|
+
check({ files, root }, violations) {
|
|
9
|
+
const program = createProgram(files, root);
|
|
10
|
+
const checker = program.getTypeChecker();
|
|
11
|
+
const fileSet = new Set(files);
|
|
12
|
+
|
|
13
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
14
|
+
if (!fileSet.has(sourceFile.fileName) || sourceFile.isDeclarationFile) continue;
|
|
15
|
+
visitFile(sourceFile, checker, root, violations);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
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 {
|
|
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
|
+
function isDateType(type: ts.Type): boolean {
|
|
52
|
+
if (type.getSymbol()?.name === "Date") return true;
|
|
53
|
+
if (type.isUnion()) return type.types.some(isDateType);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { createProgram } from "../core/ast";
|
|
4
|
+
import type { RawViolation } from "../core/types";
|
|
5
|
+
import { defineRule } from "../core/types";
|
|
6
|
+
|
|
7
|
+
export const noFloatingPromise = defineRule({
|
|
8
|
+
check({ files, root }, violations) {
|
|
9
|
+
const program = createProgram(files, root);
|
|
10
|
+
const checker = program.getTypeChecker();
|
|
11
|
+
const fileSet = new Set(files);
|
|
12
|
+
|
|
13
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
14
|
+
if (!fileSet.has(sourceFile.fileName) || sourceFile.isDeclarationFile) continue;
|
|
15
|
+
visitFile(sourceFile, checker, root, violations);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
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 {
|
|
27
|
+
if (
|
|
28
|
+
ts.isExpressionStatement(node) &&
|
|
29
|
+
ts.isCallExpression(node.expression) &&
|
|
30
|
+
!isHandled(node.expression)
|
|
31
|
+
) {
|
|
32
|
+
const type = checker.getTypeAtLocation(node.expression);
|
|
33
|
+
if (isPromiseLike(type)) {
|
|
34
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
35
|
+
violations.push({
|
|
36
|
+
file: relative(root, sourceFile.fileName),
|
|
37
|
+
line: line + 1,
|
|
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
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
ts.forEachChild(node, visit);
|
|
44
|
+
}
|
|
45
|
+
visit(sourceFile);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isHandled(node: ts.CallExpression): boolean {
|
|
49
|
+
if (!ts.isPropertyAccessExpression(node.expression)) return false;
|
|
50
|
+
const name = node.expression.name.text;
|
|
51
|
+
return name === "catch" || name === "finally";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isPromiseLike(type: ts.Type): boolean {
|
|
55
|
+
if (type.getSymbol()?.name === "Promise") return true;
|
|
56
|
+
if (type.isUnion()) return type.types.some(isPromiseLike);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { createProgram } from "../core/ast";
|
|
4
|
+
import type { RawViolation } from "../core/types";
|
|
5
|
+
import { defineRule } from "../core/types";
|
|
6
|
+
|
|
7
|
+
export const noMisusedPromises = defineRule({
|
|
8
|
+
check({ files, root }, violations) {
|
|
9
|
+
const program = createProgram(files, root);
|
|
10
|
+
const checker = program.getTypeChecker();
|
|
11
|
+
const fileSet = new Set(files);
|
|
12
|
+
|
|
13
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
14
|
+
if (!fileSet.has(sourceFile.fileName) || sourceFile.isDeclarationFile) continue;
|
|
15
|
+
visitFile(sourceFile, checker, root, violations);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
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 {
|
|
27
|
+
if (ts.isCallExpression(node)) {
|
|
28
|
+
const sig = checker.getResolvedSignature(node);
|
|
29
|
+
if (sig) {
|
|
30
|
+
const params = sig.getParameters();
|
|
31
|
+
for (let i = 0; i < node.arguments.length; i++) {
|
|
32
|
+
const arg = node.arguments[i];
|
|
33
|
+
if (!isAsyncFunction(arg)) continue;
|
|
34
|
+
|
|
35
|
+
const param = params[Math.min(i, params.length - 1)];
|
|
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
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
ts.forEachChild(node, visit);
|
|
52
|
+
}
|
|
53
|
+
visit(sourceFile);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isAsyncFunction(
|
|
57
|
+
node: ts.Node
|
|
58
|
+
): node is ts.ArrowFunction | ts.FunctionExpression {
|
|
59
|
+
return (
|
|
60
|
+
(ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
|
|
61
|
+
(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function expectsSyncCallback(type: ts.Type, checker: ts.TypeChecker): boolean {
|
|
66
|
+
if (type.isUnion()) {
|
|
67
|
+
const callable = type.types.filter(
|
|
68
|
+
(t) => !(t.flags & ts.TypeFlags.Undefined) && !(t.flags & ts.TypeFlags.Null)
|
|
69
|
+
);
|
|
70
|
+
return callable.length > 0 && callable.every((t) => expectsSyncCallback(t, checker));
|
|
71
|
+
}
|
|
72
|
+
const sigs = type.getCallSignatures();
|
|
73
|
+
if (sigs.length === 0) return false;
|
|
74
|
+
return sigs.every((sig) => !returnsPromise(checker.getReturnTypeOfSignature(sig)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function returnsPromise(type: ts.Type): boolean {
|
|
78
|
+
const name = type.getSymbol()?.name;
|
|
79
|
+
if (name === "Promise" || name === "PromiseLike") return true;
|
|
80
|
+
if (type.isUnion()) return type.types.some(returnsPromise);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { walkAst } from "../core/ast";
|
|
4
|
+
import type { KlintRule } from "../core/types";
|
|
5
|
+
|
|
6
|
+
export const noNestedTemplateLiterals: KlintRule = {
|
|
7
|
+
check({ files, root, fileContents }, violations) {
|
|
8
|
+
for (const file of files) {
|
|
9
|
+
const content = fileContents.get(file) ?? "";
|
|
10
|
+
walkAst(file, content, (node, src) => {
|
|
11
|
+
if (!ts.isTemplateExpression(node)) return;
|
|
12
|
+
for (const span of node.templateSpans) {
|
|
13
|
+
findNestedTemplate(span.expression, src, file, root, violations);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function findNestedTemplate(
|
|
21
|
+
node: ts.Node,
|
|
22
|
+
src: ts.SourceFile,
|
|
23
|
+
file: string,
|
|
24
|
+
root: string,
|
|
25
|
+
violations: ReturnType<typeof Array.prototype.slice>
|
|
26
|
+
): void {
|
|
27
|
+
if (ts.isTemplateExpression(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
28
|
+
const { line } = src.getLineAndCharacterOfPosition(node.getStart());
|
|
29
|
+
violations.push({
|
|
30
|
+
file: relative(root, file),
|
|
31
|
+
line: line + 1,
|
|
32
|
+
message:
|
|
33
|
+
"Nested template literal — extract the inner template to a variable to improve readability.",
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// Don't descend into tagged templates — they are a single semantic unit
|
|
38
|
+
if (ts.isTaggedTemplateExpression(node)) return;
|
|
39
|
+
ts.forEachChild(node, (child) =>
|
|
40
|
+
findNestedTemplate(child, src, file, root, violations)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { createProgram } from "../core/ast";
|
|
4
|
+
import type { RawViolation } from "../core/types";
|
|
5
|
+
import { defineRule } from "../core/types";
|
|
6
|
+
|
|
7
|
+
// Builtins with a meaningful toString() that isn't [object Object]
|
|
8
|
+
const SAFE_SYMBOL_NAMES = new Set([
|
|
9
|
+
"Date",
|
|
10
|
+
"RegExp",
|
|
11
|
+
"Error",
|
|
12
|
+
"TypeError",
|
|
13
|
+
"RangeError",
|
|
14
|
+
"SyntaxError",
|
|
15
|
+
"ReferenceError",
|
|
16
|
+
"Array",
|
|
17
|
+
"Map",
|
|
18
|
+
"Set",
|
|
19
|
+
"URL",
|
|
20
|
+
"URLSearchParams",
|
|
21
|
+
"Symbol",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export const noObjectInTemplate = defineRule({
|
|
25
|
+
check({ files, root }, violations) {
|
|
26
|
+
const program = createProgram(files, root);
|
|
27
|
+
const checker = program.getTypeChecker();
|
|
28
|
+
const fileSet = new Set(files);
|
|
29
|
+
|
|
30
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
31
|
+
if (!fileSet.has(sourceFile.fileName) || sourceFile.isDeclarationFile) continue;
|
|
32
|
+
visitFile(sourceFile, checker, root, violations);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function visitFile(
|
|
38
|
+
sourceFile: ts.SourceFile,
|
|
39
|
+
checker: ts.TypeChecker,
|
|
40
|
+
root: string,
|
|
41
|
+
violations: RawViolation[]
|
|
42
|
+
): void {
|
|
43
|
+
function visit(node: ts.Node): void {
|
|
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
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
ts.forEachChild(node, visit);
|
|
61
|
+
}
|
|
62
|
+
visit(sourceFile);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function wouldRenderAsObjectObject(type: ts.Type, checker: ts.TypeChecker): boolean {
|
|
66
|
+
const primitiveFlags =
|
|
67
|
+
ts.TypeFlags.String |
|
|
68
|
+
ts.TypeFlags.Number |
|
|
69
|
+
ts.TypeFlags.Boolean |
|
|
70
|
+
ts.TypeFlags.BigInt |
|
|
71
|
+
ts.TypeFlags.Null |
|
|
72
|
+
ts.TypeFlags.Undefined |
|
|
73
|
+
ts.TypeFlags.StringLiteral |
|
|
74
|
+
ts.TypeFlags.NumberLiteral |
|
|
75
|
+
ts.TypeFlags.BooleanLiteral |
|
|
76
|
+
ts.TypeFlags.BigIntLiteral |
|
|
77
|
+
ts.TypeFlags.Any |
|
|
78
|
+
ts.TypeFlags.Unknown |
|
|
79
|
+
ts.TypeFlags.Never |
|
|
80
|
+
ts.TypeFlags.Void |
|
|
81
|
+
ts.TypeFlags.ESSymbol |
|
|
82
|
+
ts.TypeFlags.UniqueESSymbol |
|
|
83
|
+
ts.TypeFlags.Enum |
|
|
84
|
+
ts.TypeFlags.EnumLiteral;
|
|
85
|
+
|
|
86
|
+
if (type.flags & primitiveFlags) return false;
|
|
87
|
+
|
|
88
|
+
if (type.isUnion()) {
|
|
89
|
+
const meaningful = type.types.filter(
|
|
90
|
+
(t) => !(t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined))
|
|
91
|
+
);
|
|
92
|
+
return (
|
|
93
|
+
meaningful.length > 0 &&
|
|
94
|
+
meaningful.every((t) => wouldRenderAsObjectObject(t, checker))
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Functions coerce to their source string — unusual but not [object Object]
|
|
99
|
+
if (type.getCallSignatures().length > 0) return false;
|
|
100
|
+
|
|
101
|
+
// Known builtins with meaningful toString
|
|
102
|
+
const sym = type.getSymbol();
|
|
103
|
+
if (sym && SAFE_SYMBOL_NAMES.has(sym.name)) return false;
|
|
104
|
+
|
|
105
|
+
// Check for a custom toString not originating from TypeScript's built-in lib
|
|
106
|
+
const toStringProp = checker.getPropertyOfType(type, "toString");
|
|
107
|
+
if (!toStringProp) return true;
|
|
108
|
+
|
|
109
|
+
const decls = toStringProp.getDeclarations() ?? [];
|
|
110
|
+
const hasCustomToString = decls.some((d) => {
|
|
111
|
+
const fileName = d.getSourceFile().fileName;
|
|
112
|
+
return (
|
|
113
|
+
!fileName.includes("/typescript/lib/lib") &&
|
|
114
|
+
!fileName.includes("\\typescript\\lib\\lib")
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return !hasCustomToString;
|
|
119
|
+
}
|