@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.
@@ -0,0 +1,68 @@
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 noOptionalChainOnNonNullable = defineRule({
8
+ check({ files, root }, violations) {
9
+ const program = createProgram(files, root);
10
+ const checker = program.getTypeChecker();
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
+ }
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
+ const receiver = getOptionalChainReceiver(node);
30
+ if (receiver) {
31
+ const type = checker.getTypeAtLocation(receiver);
32
+ if (!isNullable(type)) {
33
+ const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
34
+ violations.push({
35
+ file: relative(root, sourceFile.fileName),
36
+ line: line + 1,
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
+ }
41
+ }
42
+ ts.forEachChild(node, visit);
43
+ }
44
+ visit(sourceFile);
45
+ }
46
+
47
+ function getOptionalChainReceiver(node: ts.Node): ts.Expression | undefined {
48
+ if (
49
+ (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) &&
50
+ node.questionDotToken
51
+ ) {
52
+ return node.expression;
53
+ }
54
+ if (ts.isCallExpression(node) && node.questionDotToken) {
55
+ return node.expression;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ function isNullable(type: ts.Type): boolean {
61
+ if (
62
+ type.flags &
63
+ (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Any | ts.TypeFlags.Unknown)
64
+ )
65
+ return true;
66
+ if (type.isUnion()) return type.types.some(isNullable);
67
+ return false;
68
+ }
@@ -0,0 +1,118 @@
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
+ // Unescaped metacharacters that lose their special meaning inside a character class,
7
+ // making [.] a valid shorthand for a literal dot without needing \.
8
+ const METACHAR_EXCEPTIONS = new Set([".", "*", "+", "?", "{", "}", "(", ")", "|", "$"]);
9
+
10
+ interface CharClass {
11
+ start: number; // index of '[' in pattern
12
+ end: number; // index of ']' in pattern
13
+ inner: string; // the single token inside
14
+ }
15
+
16
+ function parseCharClasses(pattern: string): CharClass[] {
17
+ const results: CharClass[] = [];
18
+ let i = 0;
19
+ while (i < pattern.length) {
20
+ if (pattern[i] === "\\") {
21
+ i += 2;
22
+ continue;
23
+ }
24
+ if (pattern[i] !== "[") {
25
+ i++;
26
+ continue;
27
+ }
28
+ const start = i++;
29
+ // Negated classes have different semantics — skip
30
+ if (i < pattern.length && pattern[i] === "^") {
31
+ while (i < pattern.length && pattern[i] !== "]") {
32
+ if (pattern[i] === "\\") i++;
33
+ i++;
34
+ }
35
+ i++;
36
+ continue;
37
+ }
38
+ const tokens: string[] = [];
39
+ while (i < pattern.length && pattern[i] !== "]") {
40
+ if (pattern[i] === "\\") {
41
+ const s = i++;
42
+ if (i >= pattern.length) break;
43
+ const c = pattern[i];
44
+ if (c === "u" && i + 5 <= pattern.length) {
45
+ i += 5; // \uXXXX
46
+ } else if (c === "x" && i + 3 <= pattern.length) {
47
+ i += 3; // \xXX
48
+ } else if (c === "c" && i + 1 < pattern.length) {
49
+ i += 2; // \cX
50
+ } else {
51
+ i++;
52
+ }
53
+ tokens.push(pattern.slice(s, i));
54
+ } else {
55
+ tokens.push(pattern[i++]);
56
+ }
57
+ }
58
+ if (i >= pattern.length) break;
59
+ const end = i++;
60
+ if (tokens.length === 1) results.push({ start, end, inner: tokens[0] });
61
+ }
62
+ return results;
63
+ }
64
+
65
+ export const noSingleCharClass: KlintRule = {
66
+ check({ files, root, fileContents }, violations) {
67
+ for (const file of files) {
68
+ const content = fileContents.get(file) ?? "";
69
+ walkAst(file, content, (node, src) => {
70
+ if (!ts.isRegularExpressionLiteral(node)) return;
71
+
72
+ const regexSrc = node.getText(src);
73
+ const lastSlash = regexSrc.lastIndexOf("/");
74
+ const pattern = regexSrc.slice(1, lastSlash);
75
+ const flags = regexSrc.slice(lastSlash + 1);
76
+
77
+ const allClasses = parseCharClasses(pattern);
78
+ const toFix = allClasses.filter((c) => !METACHAR_EXCEPTIONS.has(c.inner));
79
+ if (toFix.length === 0) return;
80
+
81
+ // Build fixed pattern: replace [token] with token for non-exceptions
82
+ let fixedPattern = "";
83
+ let prev = 0;
84
+ for (const cls of allClasses) {
85
+ if (METACHAR_EXCEPTIONS.has(cls.inner)) continue;
86
+ fixedPattern += pattern.slice(prev, cls.start);
87
+ fixedPattern += cls.inner;
88
+ prev = cls.end + 1;
89
+ }
90
+ fixedPattern += pattern.slice(prev);
91
+
92
+ const fixedRegex = `/${fixedPattern}/${flags}`;
93
+ const { line } = src.getLineAndCharacterOfPosition(node.getStart());
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);
104
+
105
+ violations.push({
106
+ file: relative(root, file),
107
+ line: line + 1,
108
+ 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
+ },
114
+ });
115
+ });
116
+ }
117
+ },
118
+ };
@@ -0,0 +1,58 @@
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 noStringMatch: 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 (
12
+ ts.isCallExpression(node) &&
13
+ ts.isPropertyAccessExpression(node.expression) &&
14
+ node.expression.name.text === "match" &&
15
+ node.arguments.length === 1 &&
16
+ ts.isRegularExpressionLiteral(node.arguments[0])
17
+ ) {
18
+ const flags = regexFlags(node.arguments[0].text);
19
+ if (!flags.includes("g")) {
20
+ const strText = (
21
+ node.expression as ts.PropertyAccessExpression
22
+ ).expression.getText(src);
23
+ const regexText = node.arguments[0].getText(src);
24
+ const { line: s } = src.getLineAndCharacterOfPosition(node.getStart());
25
+ const { line: e } = src.getLineAndCharacterOfPosition(node.getEnd());
26
+ const fix =
27
+ s === e
28
+ ? (() => {
29
+ const lineStart = src.getPositionOfLineAndCharacter(s, 0);
30
+ const nlPos = src.text.indexOf("\n", lineStart);
31
+ const lineText = src.text.slice(
32
+ lineStart,
33
+ nlPos === -1 ? undefined : nlPos
34
+ );
35
+ const fixed = lineText.replace(
36
+ node.getText(src),
37
+ `new RegExp(${regexText}).exec(${strText})`
38
+ );
39
+ return { startLine: s + 1, endLine: e + 1, replacement: fixed };
40
+ })()
41
+ : undefined;
42
+ violations.push({
43
+ file: relative(root, file),
44
+ line: s + 1,
45
+ message: `Use RegExp.exec() instead of String.match() for non-global regexes — use new RegExp(${regexText}).exec(${strText}) instead.`,
46
+ fix,
47
+ });
48
+ }
49
+ }
50
+ });
51
+ }
52
+ },
53
+ };
54
+
55
+ function regexFlags(literal: string): string {
56
+ const last = literal.lastIndexOf("/");
57
+ return last > 0 ? literal.slice(last + 1) : "";
58
+ }
@@ -0,0 +1,35 @@
1
+ import { relative } from "node:path";
2
+ import ts from "typescript";
3
+ import { nearestFunctionIsAsync, walkAst } from "../core/ast";
4
+ import type { KlintRule } from "../core/types";
5
+
6
+ export const noSyncInAsync: 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.isCallExpression(node)) {
12
+ const callee = node.expression;
13
+ let name: string | null = null;
14
+ if (ts.isIdentifier(callee)) {
15
+ name = callee.text;
16
+ } else if (ts.isPropertyAccessExpression(callee)) {
17
+ name = callee.name.text;
18
+ }
19
+ if (
20
+ name?.endsWith("Sync") &&
21
+ name !== "existsSync" &&
22
+ nearestFunctionIsAsync(node)
23
+ ) {
24
+ const { line } = src.getLineAndCharacterOfPosition(node.getStart());
25
+ violations.push({
26
+ file: relative(root, file),
27
+ line: line + 1,
28
+ message: `${name}() blocks the event loop inside an async function — use the async equivalent from node:fs/promises.`,
29
+ });
30
+ }
31
+ }
32
+ });
33
+ }
34
+ },
35
+ };
@@ -0,0 +1,30 @@
1
+ import { relative } from "node:path";
2
+ import ts from "typescript";
3
+ import { isInsideTry, walkAst } from "../core/ast";
4
+ import type { KlintRule } from "../core/types";
5
+
6
+ export const noUnguardedJsonParse: 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 (
12
+ ts.isCallExpression(node) &&
13
+ ts.isPropertyAccessExpression(node.expression) &&
14
+ node.expression.name.text === "parse" &&
15
+ ts.isIdentifier(node.expression.expression) &&
16
+ node.expression.expression.text === "JSON" &&
17
+ !isInsideTry(node)
18
+ ) {
19
+ const { line } = src.getLineAndCharacterOfPosition(node.getStart());
20
+ violations.push({
21
+ file: relative(root, file),
22
+ line: line + 1,
23
+ message:
24
+ "JSON.parse() called without a surrounding try/catch — a malformed payload will throw an unhandled exception.",
25
+ });
26
+ }
27
+ });
28
+ }
29
+ },
30
+ };
@@ -0,0 +1,54 @@
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 preferAt: 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
+ // Match: base[base.length - n]
12
+ if (!ts.isElementAccessExpression(node)) return;
13
+
14
+ const arg = node.argumentExpression;
15
+ if (!ts.isBinaryExpression(arg)) return;
16
+ if (arg.operatorToken.kind !== ts.SyntaxKind.MinusToken) return;
17
+ if (!ts.isPropertyAccessExpression(arg.left)) return;
18
+ if (arg.left.name.text !== "length") return;
19
+ if (!ts.isNumericLiteral(arg.right)) return;
20
+
21
+ const n = Number(arg.right.text);
22
+ // n=0 changes semantics: arr[arr.length - 0] is undefined, arr.at(-0) is arr[0]
23
+ if (!Number.isInteger(n) || n <= 0) return;
24
+
25
+ const baseText = node.expression.getText(src);
26
+ if (baseText !== arg.left.expression.getText(src)) return;
27
+
28
+ const fixedCall = `${baseText}.at(-${n})`;
29
+ const { line } = src.getLineAndCharacterOfPosition(node.getStart());
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);
40
+
41
+ violations.push({
42
+ file: relative(root, file),
43
+ line: line + 1,
44
+ 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
+ },
50
+ });
51
+ });
52
+ }
53
+ },
54
+ };
@@ -0,0 +1,68 @@
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 preferNullishCoalescingAssign: 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 (
12
+ !ts.isIfStatement(node) ||
13
+ node.elseStatement !== undefined ||
14
+ !ts.isPrefixUnaryExpression(node.expression) ||
15
+ node.expression.operator !== ts.SyntaxKind.ExclamationToken
16
+ )
17
+ return;
18
+
19
+ const assignExpr = extractAssignment(node.thenStatement);
20
+ if (!assignExpr) return;
21
+
22
+ const xText = node.expression.operand.getText(src);
23
+ if (assignExpr.left.getText(src) !== xText) return;
24
+
25
+ const yText = assignExpr.right.getText(src);
26
+ const { line: s } = src.getLineAndCharacterOfPosition(node.getStart());
27
+ const { line: e } = src.getLineAndCharacterOfPosition(node.getEnd());
28
+ const indent = getIndent(src, node);
29
+
30
+ violations.push({
31
+ file: relative(root, file),
32
+ line: s + 1,
33
+ message: `Prefer \`${xText} ??= ${yText}\` over \`if (!${xText}) ${xText} = ${yText}\` — ??= only assigns when null or undefined.`,
34
+ fix: {
35
+ startLine: s + 1,
36
+ endLine: e + 1,
37
+ replacement: `${indent}${xText} ??= ${yText};`,
38
+ },
39
+ });
40
+ });
41
+ }
42
+ },
43
+ };
44
+
45
+ function extractAssignment(stmt: ts.Statement): ts.BinaryExpression | undefined {
46
+ if (ts.isExpressionStatement(stmt)) {
47
+ return isAssign(stmt.expression) ? stmt.expression : undefined;
48
+ }
49
+ if (ts.isBlock(stmt) && stmt.statements.length === 1) {
50
+ const inner = stmt.statements[0];
51
+ if (ts.isExpressionStatement(inner) && isAssign(inner.expression)) {
52
+ return inner.expression;
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ function isAssign(node: ts.Expression): node is ts.BinaryExpression {
59
+ return (
60
+ ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken
61
+ );
62
+ }
63
+
64
+ function getIndent(src: ts.SourceFile, node: ts.Node): string {
65
+ const { line } = src.getLineAndCharacterOfPosition(node.getStart());
66
+ const lineStart = src.getPositionOfLineAndCharacter(line, 0);
67
+ return new RegExp(/^(\s*)/).exec(src.text.slice(lineStart, node.getStart()))?.[1] ?? "";
68
+ }
@@ -0,0 +1,88 @@
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 preferStringRawRegexp: 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 (
12
+ !ts.isNewExpression(node) ||
13
+ !ts.isIdentifier(node.expression) ||
14
+ node.expression.text !== "RegExp" ||
15
+ !node.arguments ||
16
+ node.arguments.length === 0
17
+ )
18
+ return;
19
+
20
+ const arg = node.arguments[0];
21
+ if (!ts.isNoSubstitutionTemplateLiteral(arg) && !ts.isTemplateExpression(arg))
22
+ return;
23
+
24
+ if (!hasDoubleBackslash(arg as ts.TemplateLiteral, src)) return;
25
+
26
+ const { line } = src.getLineAndCharacterOfPosition(node.getStart());
27
+ const { line: argStartLine } = src.getLineAndCharacterOfPosition(arg.getStart());
28
+ const { line: argEndLine } = src.getLineAndCharacterOfPosition(arg.getEnd());
29
+
30
+ const lineStarts = src.getLineStarts();
31
+ const lineStart = lineStarts[argStartLine];
32
+ const lineEnd =
33
+ argEndLine + 1 < lineStarts.length
34
+ ? lineStarts[argEndLine + 1] - 1
35
+ : src.text.length;
36
+ const linesText = src.text.slice(lineStart, lineEnd);
37
+
38
+ const argOffset = arg.getStart() - lineStart;
39
+ const argEndOffset = arg.getEnd() - lineStart;
40
+ const fixedArg = toStringRaw(arg as ts.TemplateLiteral, src);
41
+ const fixedLines =
42
+ linesText.slice(0, argOffset) + fixedArg + linesText.slice(argEndOffset);
43
+
44
+ violations.push({
45
+ file: relative(root, file),
46
+ line: line + 1,
47
+ message:
48
+ "Use String.raw`...` for RegExp template argument to avoid double backslashes (Sonar S7780).",
49
+ fix: {
50
+ startLine: argStartLine + 1,
51
+ endLine: argEndLine + 1,
52
+ replacement: fixedLines,
53
+ },
54
+ });
55
+ });
56
+ }
57
+ },
58
+ };
59
+
60
+ function hasDoubleBackslash(node: ts.TemplateLiteral, src: ts.SourceFile): boolean {
61
+ if (ts.isNoSubstitutionTemplateLiteral(node)) {
62
+ return node.getText(src).includes("\\\\");
63
+ }
64
+ if (node.head.getText(src).includes("\\\\")) return true;
65
+ for (const span of node.templateSpans) {
66
+ if (span.literal.getText(src).includes("\\\\")) return true;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ function toStringRaw(node: ts.TemplateLiteral, src: ts.SourceFile): string {
72
+ if (ts.isNoSubstitutionTemplateLiteral(node)) {
73
+ const raw = node.getText(src);
74
+ const inner = raw.slice(1, -1);
75
+ return `String.raw\`${inner.replaceAll("\\\\", "\\")}\``;
76
+ }
77
+ let result = "String.raw`";
78
+ const headSrc = node.head.getText(src);
79
+ result += `${headSrc.slice(1, -2).replaceAll("\\\\", "\\")}\${`;
80
+ for (const span of node.templateSpans) {
81
+ result += span.expression.getText(src);
82
+ const litSrc = span.literal.getText(src);
83
+ const isMiddle = ts.isTemplateMiddle(span.literal);
84
+ const litContent = isMiddle ? litSrc.slice(1, -2) : litSrc.slice(1, -1);
85
+ result += `}${litContent.replaceAll("\\\\", "\\")}${isMiddle ? "${" : "`"}`;
86
+ }
87
+ return result;
88
+ }
@@ -0,0 +1,47 @@
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 preferStringRaw: 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.isStringLiteral(node)) return;
12
+
13
+ const sourceText = node.getText(src);
14
+ if (!sourceText.includes("\\\\")) return;
15
+
16
+ const value = node.text;
17
+ // Can't embed backtick, ${ (would start interpolation), or trailing \ (would escape closing backtick)
18
+ if (value.includes("`") || value.includes("${") || value.endsWith("\\")) return;
19
+
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
+ const fixedNode = `String.raw\`${value}\``;
30
+ const fixedLines =
31
+ linesText.slice(0, nodeOffset) + fixedNode + linesText.slice(nodeEndOffset);
32
+
33
+ violations.push({
34
+ file: relative(root, file),
35
+ line: line + 1,
36
+ message:
37
+ "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
+ },
43
+ });
44
+ });
45
+ }
46
+ },
47
+ };
@@ -0,0 +1,76 @@
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 preferStringReplaceall: 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 (
12
+ !ts.isCallExpression(node) ||
13
+ !ts.isPropertyAccessExpression(node.expression) ||
14
+ node.expression.name.text !== "replace" ||
15
+ node.arguments.length !== 2 ||
16
+ !ts.isRegularExpressionLiteral(node.arguments[0])
17
+ )
18
+ return;
19
+
20
+ const regexSrc = node.arguments[0].getText(src);
21
+ const flags = extractFlags(regexSrc);
22
+ const pattern = extractPattern(regexSrc);
23
+
24
+ if (flags !== "g") return;
25
+ if (!isPlainLiteral(pattern)) return;
26
+
27
+ const strText = (
28
+ node.expression as ts.PropertyAccessExpression
29
+ ).expression.getText(src);
30
+ const replacementText = node.arguments[1].getText(src);
31
+ const patternLit = pattern.includes('"')
32
+ ? `'${pattern.replaceAll("'", "\\'")}'`
33
+ : `"${pattern}"`;
34
+ 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);
47
+
48
+ violations.push({
49
+ file: relative(root, file),
50
+ line: s + 1,
51
+ 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
+ },
57
+ });
58
+ });
59
+ }
60
+ },
61
+ };
62
+
63
+ function extractFlags(literal: string): string {
64
+ const last = literal.lastIndexOf("/");
65
+ return last > 0 ? literal.slice(last + 1) : "";
66
+ }
67
+
68
+ function extractPattern(literal: string): string {
69
+ return literal.slice(1, literal.lastIndexOf("/"));
70
+ }
71
+
72
+ function isPlainLiteral(pattern: string): boolean {
73
+ // Strip escaped-backslash pairs (\\) before checking — \\ is a plain literal, not a metachar
74
+ const stripped = pattern.replaceAll("\\\\", "");
75
+ return !/[.*+?[\]{}()|^$\\]/.test(stripped);
76
+ }