slopfighter 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,61 @@
1
+ import { lineCol } from './_util.mjs';
2
+
3
+ // Blocks of 2+ consecutive `//` lines whose stripped content parses like
4
+ // code (has `=`, `;`, `(`, `{`, etc.) → classic AI artifact left after
5
+ // "let me also try" iterations.
6
+ const MIN_BLOCK = 2;
7
+ const CODE_TOKENS = /[=;(){}[\]]|=>|\?\?|::|<-/;
8
+
9
+ export default {
10
+ id: 'commented-out-code',
11
+ severity: 'warn',
12
+ description: 'Blocks of commented-out code left after AI iterations',
13
+ check(ctx) {
14
+ const { source, sourceFile } = ctx;
15
+ const findings = [];
16
+ const lines = source.split(/\r?\n/);
17
+
18
+ let blockStart = -1;
19
+ let blockLines = 0;
20
+ let blockCodeLines = 0;
21
+ let blockEndPos = 0;
22
+
23
+ for (let i = 0; i < lines.length; i++) {
24
+ const trimmed = lines[i].trim();
25
+ const isComment = trimmed.startsWith('//');
26
+ const stripped = isComment ? trimmed.replace(/^\/\/+\s?/, '').trim() : '';
27
+ const looksLikeCode = isComment && stripped.length > 0 && CODE_TOKENS.test(stripped);
28
+ // commented-out import/export/return/etc. → also code-like
29
+ const looksLikeKeyword = isComment && /^(import|export|return|if|else|for|while|const|let|var|function|class|throw)\b/.test(stripped);
30
+
31
+ if (isComment && (looksLikeCode || looksLikeKeyword)) {
32
+ if (blockStart === -1) blockStart = i;
33
+ blockLines++;
34
+ blockCodeLines++;
35
+ } else if (isComment) {
36
+ // ordinary comment line — extend block but don't count as code
37
+ if (blockStart !== -1) blockLines++;
38
+ } else {
39
+ flush(i - 1);
40
+ blockStart = -1;
41
+ blockLines = 0;
42
+ blockCodeLines = 0;
43
+ }
44
+ }
45
+ flush(lines.length - 1);
46
+ return findings;
47
+
48
+ function flush(endLine) {
49
+ if (blockStart === -1) return;
50
+ if (blockCodeLines >= MIN_BLOCK) {
51
+ findings.push({
52
+ message: `${blockCodeLines}-line commented-out code block — remove or restore`,
53
+ line: blockStart + 1,
54
+ column: 1,
55
+ endLine: endLine + 1,
56
+ endColumn: 1,
57
+ });
58
+ }
59
+ }
60
+ },
61
+ };
@@ -0,0 +1,37 @@
1
+ import { walk, nodeRange } from './_util.mjs';
2
+
3
+ // `"foo" + "bar"` — two string literals concatenated. Either it should
4
+ // be a single literal, or a template if it's going to grow. AI often
5
+ // fragments strings for readability and forgets to merge.
6
+ export default {
7
+ id: 'const-string-concat',
8
+ severity: 'info',
9
+ description: 'Two adjacent string literals joined with `+` — merge them',
10
+ check(ctx) {
11
+ const { sourceFile, ts } = ctx;
12
+ const findings = [];
13
+
14
+ walk(sourceFile, (node) => {
15
+ if (node.kind !== ts.SyntaxKind.BinaryExpression) return;
16
+ if (node.operatorToken.kind !== ts.SyntaxKind.PlusToken) return;
17
+ if (!isStringLit(node.left, ts)) return;
18
+ if (!isStringLit(node.right, ts)) return;
19
+
20
+ const range = nodeRange(sourceFile, node);
21
+ findings.push({
22
+ message: 'two string literals joined with `+` — write a single literal or template',
23
+ line: range.line,
24
+ column: range.column,
25
+ });
26
+ });
27
+ return findings;
28
+ },
29
+ };
30
+
31
+ function isStringLit(n, ts) {
32
+ if (!n) return false;
33
+ return (
34
+ n.kind === ts.SyntaxKind.StringLiteral ||
35
+ n.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral
36
+ );
37
+ }
@@ -0,0 +1,58 @@
1
+ import { walk, lineCol } from './_util.mjs';
2
+
3
+ // Heuristic: an imported name is dead if it appears only once in the file
4
+ // (its own import statement). Cheap, no scope tracking, false-positive-low
5
+ // for typical AI code.
6
+ export default {
7
+ id: 'dead-imports',
8
+ severity: 'warn',
9
+ description: 'Imported names that are never referenced in the file',
10
+ check(ctx) {
11
+ const { sourceFile, source, ts } = ctx;
12
+ const findings = [];
13
+
14
+ walk(sourceFile, (node) => {
15
+ if (node.kind !== ts.SyntaxKind.ImportDeclaration) return;
16
+ const clause = node.importClause;
17
+ if (!clause) return; // side-effect import
18
+
19
+ const names = [];
20
+ // default import: import Foo from 'x'
21
+ if (clause.name) names.push(clause.name);
22
+ // named bindings: import { a, b as c } from 'x' OR import * as ns from 'x'
23
+ const nb = clause.namedBindings;
24
+ if (nb) {
25
+ if (nb.kind === ts.SyntaxKind.NamespaceImport) {
26
+ names.push(nb.name);
27
+ } else if (nb.kind === ts.SyntaxKind.NamedImports) {
28
+ for (const el of nb.elements) names.push(el.name);
29
+ }
30
+ }
31
+
32
+ for (const id of names) {
33
+ const text = id.text;
34
+ const re = new RegExp(`\\b${escapeRegex(text)}\\b`, 'g');
35
+ let count = 0;
36
+ let m;
37
+ while ((m = re.exec(source)) !== null) {
38
+ count++;
39
+ if (count > 1) break;
40
+ }
41
+ if (count <= 1) {
42
+ const pos = id.getStart(sourceFile);
43
+ const loc = lineCol(sourceFile, pos);
44
+ findings.push({
45
+ message: `import "${text}" is never used in this file`,
46
+ line: loc.line,
47
+ column: loc.column,
48
+ });
49
+ }
50
+ }
51
+ });
52
+ return findings;
53
+ },
54
+ };
55
+
56
+ function escapeRegex(s) {
57
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
58
+ }
@@ -0,0 +1,43 @@
1
+ import { walk, nodeRange } from './_util.mjs';
2
+
3
+ // `if (x) { return a; } else { return b; }` — the `else` is dead because
4
+ // the `if` branch already returned. Flatten it.
5
+ // Also catches `if (x) return; else doThing();`.
6
+ export default {
7
+ id: 'else-after-return',
8
+ severity: 'info',
9
+ description: 'else-block after a returning if-block — unnecessary nesting',
10
+ check(ctx) {
11
+ const { sourceFile, ts } = ctx;
12
+ const findings = [];
13
+
14
+ walk(sourceFile, (node) => {
15
+ if (node.kind !== ts.SyntaxKind.IfStatement) return;
16
+ if (!node.elseStatement) return;
17
+ if (!alwaysExits(node.thenStatement, ts)) return;
18
+
19
+ const range = nodeRange(sourceFile, node.elseStatement);
20
+ findings.push({
21
+ message: '`else` after a returning `if` — drop the else and unindent',
22
+ line: range.line,
23
+ column: range.column,
24
+ });
25
+ });
26
+ return findings;
27
+ },
28
+ };
29
+
30
+ function alwaysExits(stmt, ts) {
31
+ if (!stmt) return false;
32
+ const k = stmt.kind;
33
+ if (k === ts.SyntaxKind.ReturnStatement) return true;
34
+ if (k === ts.SyntaxKind.ThrowStatement) return true;
35
+ if (k === ts.SyntaxKind.ContinueStatement) return true;
36
+ if (k === ts.SyntaxKind.BreakStatement) return true;
37
+ if (k === ts.SyntaxKind.Block) {
38
+ const stmts = stmt.statements;
39
+ if (stmts.length === 0) return false;
40
+ return alwaysExits(stmts[stmts.length - 1], ts);
41
+ }
42
+ return false;
43
+ }
@@ -0,0 +1,50 @@
1
+ import { walk, lineCol } from './_util.mjs';
2
+
3
+ // 8+ line JSDoc over a trivial getter / setter / one-line function is
4
+ // almost always AI padding. Threshold tuned to not flag genuine API docs
5
+ // for non-trivial functions.
6
+ const MIN_JSDOC_LINES = 6;
7
+ const MAX_BODY_LINES = 3;
8
+
9
+ export default {
10
+ id: 'excessive-jsdoc',
11
+ severity: 'info',
12
+ description: 'JSDoc blocks longer than the function body itself',
13
+ check(ctx) {
14
+ const { sourceFile, source, ts } = ctx;
15
+ const findings = [];
16
+
17
+ walk(sourceFile, (node) => {
18
+ if (
19
+ node.kind !== ts.SyntaxKind.FunctionDeclaration &&
20
+ node.kind !== ts.SyntaxKind.MethodDeclaration &&
21
+ node.kind !== ts.SyntaxKind.ArrowFunction
22
+ ) return;
23
+
24
+ const leading = ts.getLeadingCommentRanges(source, node.getFullStart()) || [];
25
+ const jsdoc = leading.find(
26
+ (c) => c.kind === ts.SyntaxKind.MultiLineCommentTrivia && source.slice(c.pos, c.pos + 3) === '/**'
27
+ );
28
+ if (!jsdoc) return;
29
+
30
+ const docText = source.slice(jsdoc.pos, jsdoc.end);
31
+ const docLines = docText.split(/\r?\n/).length;
32
+ if (docLines < MIN_JSDOC_LINES) return;
33
+
34
+ const body = node.body;
35
+ if (!body) return;
36
+ const bodyStart = lineCol(sourceFile, body.getStart(sourceFile)).line;
37
+ const bodyEnd = lineCol(sourceFile, body.getEnd()).line;
38
+ const bodyLines = bodyEnd - bodyStart;
39
+ if (bodyLines > MAX_BODY_LINES) return;
40
+
41
+ const loc = lineCol(sourceFile, jsdoc.pos);
42
+ findings.push({
43
+ message: `${docLines}-line JSDoc for ${bodyLines}-line body — trim or drop`,
44
+ line: loc.line,
45
+ column: loc.column,
46
+ });
47
+ });
48
+ return findings;
49
+ },
50
+ };
@@ -0,0 +1,30 @@
1
+ import { walk, lineCol } from './_util.mjs';
2
+
3
+ // Explicit `: any` is almost always AI-generated laziness when a more
4
+ // specific type was knowable. Skipped if there's a `// eslint-disable` /
5
+ // `// slopfighter-disable` style comment on the same line.
6
+ export default {
7
+ id: 'explicit-any',
8
+ severity: 'warn',
9
+ description: 'Explicit `any` type annotations',
10
+ check(ctx) {
11
+ const { sourceFile, source, ts } = ctx;
12
+ if (!sourceFile.fileName.match(/\.(ts|tsx)$/)) return [];
13
+ const findings = [];
14
+
15
+ walk(sourceFile, (node) => {
16
+ if (node.kind !== ts.SyntaxKind.AnyKeyword) return;
17
+ const pos = node.getStart(sourceFile);
18
+ const loc = lineCol(sourceFile, pos);
19
+ // skip if the line has a disable marker
20
+ const lineText = source.split(/\r?\n/)[loc.line - 1] || '';
21
+ if (/slopfighter-disable|eslint-disable|@ts-ignore|@ts-expect-error/.test(lineText)) return;
22
+ findings.push({
23
+ message: 'avoid `any` — pick a real type or use `unknown`',
24
+ line: loc.line,
25
+ column: loc.column,
26
+ });
27
+ });
28
+ return findings;
29
+ },
30
+ };
@@ -0,0 +1,46 @@
1
+ import { walk, nodeRange } from './_util.mjs';
2
+
3
+ // Manager/Helper/Utils/Service-suffixed class with <= 1 real method is a
4
+ // premature abstraction. Common AI tic: wraps a single function in a class
5
+ // "for future expansion".
6
+ const SUFFIXES = ['Manager', 'Helper', 'Utils', 'Util', 'Handler', 'Wrapper', 'Provider'];
7
+
8
+ export default {
9
+ id: 'future-proof-naming',
10
+ severity: 'warn',
11
+ description: 'Manager/Helper/Utils-class with only one method — just use a function',
12
+ check(ctx) {
13
+ const { sourceFile, ts } = ctx;
14
+ const findings = [];
15
+
16
+ walk(sourceFile, (node) => {
17
+ if (node.kind !== ts.SyntaxKind.ClassDeclaration) return;
18
+ if (!node.name) return;
19
+ // implements/extends → polymorphic, the suffix is meaningful
20
+ if (node.heritageClauses && node.heritageClauses.length > 0) return;
21
+ const name = node.name.text;
22
+ const suffix = SUFFIXES.find((s) => name.endsWith(s));
23
+ if (!suffix) return;
24
+
25
+ const members = node.members || [];
26
+ const methods = members.filter(
27
+ (m) => m.kind === ts.SyntaxKind.MethodDeclaration || m.kind === ts.SyntaxKind.PropertyDeclaration
28
+ );
29
+ const realMethods = methods.filter((m) => m.kind === ts.SyntaxKind.MethodDeclaration);
30
+ if (realMethods.length > 1) return;
31
+ // ignore if it has stored state (multiple property declarations)
32
+ const props = methods.filter((m) => m.kind === ts.SyntaxKind.PropertyDeclaration);
33
+ if (props.length >= 2) return;
34
+
35
+ const range = nodeRange(sourceFile, node);
36
+ findings.push({
37
+ message: `class "${name}" has ${realMethods.length} method(s) — convert to a plain function`,
38
+ line: range.line,
39
+ column: range.column,
40
+ endLine: range.endLine,
41
+ endColumn: range.endColumn,
42
+ });
43
+ });
44
+ return findings;
45
+ },
46
+ };
@@ -0,0 +1,39 @@
1
+ import paddingComments from './padding-comments.mjs';
2
+ import uselessTryCatch from './useless-try-catch.mjs';
3
+ import deadImports from './dead-imports.mjs';
4
+ import explicitAny from './explicit-any.mjs';
5
+ import futureProofNaming from './future-proof-naming.mjs';
6
+ import alwaysTrueConditional from './always-true-conditional.mjs';
7
+ import excessiveJsdoc from './excessive-jsdoc.mjs';
8
+ import redundantErrorRethrow from './redundant-error-rethrow.mjs';
9
+ import singleMethodClass from './single-method-class.mjs';
10
+ import overDefensiveNullCheck from './over-defensive-null-check.mjs';
11
+ import commentedOutCode from './commented-out-code.mjs';
12
+ import unnecessaryAsync from './unnecessary-async.mjs';
13
+ import trivialArrowWrapper from './trivial-arrow-wrapper.mjs';
14
+ import redundantAwait from './redundant-await.mjs';
15
+ import returnUndefined from './return-undefined.mjs';
16
+ import elseAfterReturn from './else-after-return.mjs';
17
+ import redundantBooleanCast from './redundant-boolean-cast.mjs';
18
+ import constStringConcat from './const-string-concat.mjs';
19
+
20
+ export const allRules = [
21
+ paddingComments,
22
+ uselessTryCatch,
23
+ deadImports,
24
+ explicitAny,
25
+ futureProofNaming,
26
+ alwaysTrueConditional,
27
+ excessiveJsdoc,
28
+ redundantErrorRethrow,
29
+ singleMethodClass,
30
+ overDefensiveNullCheck,
31
+ commentedOutCode,
32
+ unnecessaryAsync,
33
+ trivialArrowWrapper,
34
+ redundantAwait,
35
+ returnUndefined,
36
+ elseAfterReturn,
37
+ redundantBooleanCast,
38
+ constStringConcat,
39
+ ];
@@ -0,0 +1,81 @@
1
+ import { walk, nodeRange } from './_util.mjs';
2
+
3
+ // `if (x !== null && x !== undefined && x)` — three checks where one
4
+ // suffices. Or `if (x != null) { if (x !== undefined) {...} }` — nested
5
+ // nullish guards. AI loves these.
6
+ export default {
7
+ id: 'over-defensive-null-check',
8
+ severity: 'info',
9
+ description: 'Redundant null/undefined checks chained or nested',
10
+ check(ctx) {
11
+ const { sourceFile, ts } = ctx;
12
+ const findings = [];
13
+
14
+ walk(sourceFile, (node) => {
15
+ if (node.kind !== ts.SyntaxKind.IfStatement) return;
16
+ const cond = node.expression;
17
+ const nullishOps = countNullishOps(cond, ts);
18
+ if (nullishOps.identifiers.size === 1 && nullishOps.count >= 3) {
19
+ const range = nodeRange(sourceFile, cond);
20
+ findings.push({
21
+ message: `triple nullish check on the same identifier — \`if (${[...nullishOps.identifiers][0]} != null)\` is enough`,
22
+ line: range.line,
23
+ column: range.column,
24
+ });
25
+ }
26
+ });
27
+ return findings;
28
+ },
29
+ };
30
+
31
+ function countNullishOps(node, ts) {
32
+ const idents = new Set();
33
+ let count = 0;
34
+ visit(node);
35
+ return { count, identifiers: idents };
36
+
37
+ function visit(n) {
38
+ if (!n) return;
39
+ if (n.kind === ts.SyntaxKind.BinaryExpression) {
40
+ const op = n.operatorToken.kind;
41
+ if (
42
+ op === ts.SyntaxKind.AmpersandAmpersandToken ||
43
+ op === ts.SyntaxKind.BarBarToken
44
+ ) {
45
+ visit(n.left); visit(n.right);
46
+ return;
47
+ }
48
+ if (
49
+ op === ts.SyntaxKind.EqualsEqualsToken ||
50
+ op === ts.SyntaxKind.EqualsEqualsEqualsToken ||
51
+ op === ts.SyntaxKind.ExclamationEqualsToken ||
52
+ op === ts.SyntaxKind.ExclamationEqualsEqualsToken
53
+ ) {
54
+ const ident = pickIdent(n, ts);
55
+ const literal = pickNullishLiteral(n, ts);
56
+ if (ident && literal) {
57
+ idents.add(ident);
58
+ count++;
59
+ }
60
+ }
61
+ return;
62
+ }
63
+ // bare identifier as truthiness check on same var counts as one extra
64
+ if (n.kind === ts.SyntaxKind.Identifier) {
65
+ idents.add(n.text);
66
+ count++;
67
+ }
68
+ }
69
+ }
70
+
71
+ function pickIdent(bin, ts) {
72
+ if (bin.left.kind === ts.SyntaxKind.Identifier) return bin.left.text;
73
+ if (bin.right.kind === ts.SyntaxKind.Identifier) return bin.right.text;
74
+ return null;
75
+ }
76
+ function pickNullishLiteral(bin, ts) {
77
+ const isNullish = (n) =>
78
+ n.kind === ts.SyntaxKind.NullKeyword ||
79
+ (n.kind === ts.SyntaxKind.Identifier && n.text === 'undefined');
80
+ return isNullish(bin.left) || isNullish(bin.right);
81
+ }
@@ -0,0 +1,131 @@
1
+ import { walk, lineCol, deCamel } from './_util.mjs';
2
+
3
+ // A comment is "padding" when its content tokens overlap fully with the
4
+ // following declaration's name (allowing verb-form variation).
5
+ // Classic AI tic: `// Get user name` over `function getUserName()`.
6
+ const STOPWORDS = new Set([
7
+ 'this', 'a', 'an', 'the', 'is', 'are', 'that', 'which', 'to', 'of', 'in', 'on',
8
+ 'and', 'or', 'for', 'with', 'function', 'method', 'class', 'helper', 'simple',
9
+ 'utility', 'just', 'simply', 'used',
10
+ ]);
11
+ const VERBS = new Set([
12
+ 'get', 'gets', 'set', 'sets', 'return', 'returns', 'fetch', 'fetches',
13
+ 'retrieve', 'retrieves', 'compute', 'computes', 'calculate', 'calculates',
14
+ 'create', 'creates', 'build', 'builds', 'make', 'makes', 'handle', 'handles',
15
+ 'process', 'processes', 'check', 'checks', 'validate', 'validates',
16
+ 'parse', 'parses', 'load', 'loads', 'save', 'saves', 'read', 'reads',
17
+ 'write', 'writes', 'find', 'finds', 'add', 'adds', 'remove', 'removes',
18
+ ]);
19
+
20
+ export default {
21
+ id: 'padding-comments',
22
+ severity: 'warn',
23
+ description: 'Comments that just restate the function/variable name',
24
+ check(ctx) {
25
+ const { sourceFile, source, ts } = ctx;
26
+ const findings = [];
27
+
28
+ walk(sourceFile, (node) => {
29
+ const name = getDeclName(node, ts);
30
+ if (!name) return;
31
+
32
+ const leading = ts.getLeadingCommentRanges(source, node.getFullStart()) || [];
33
+ for (const c of leading) {
34
+ const raw = source.slice(c.pos, c.end);
35
+ // skip JSDoc — excessive-jsdoc covers it
36
+ if (raw.startsWith('/**')) continue;
37
+ const inner = stripCommentMarkers(raw);
38
+ if (!inner) continue;
39
+ if (looksLikePadding(inner, name)) {
40
+ const loc = lineCol(sourceFile, c.pos);
41
+ findings.push({
42
+ message: `Comment "${truncate(inner, 50)}" just restates "${name}". Drop it.`,
43
+ line: loc.line,
44
+ column: loc.column,
45
+ fix: { start: c.pos, end: c.end + skipTrailingNewline(source, c.end), replacement: '' },
46
+ });
47
+ }
48
+ }
49
+ });
50
+ return findings;
51
+ },
52
+ };
53
+
54
+ function getDeclName(node, ts) {
55
+ if (!node) return null;
56
+ const k = node.kind;
57
+ if (k === ts.SyntaxKind.FunctionDeclaration && node.name) return node.name.text;
58
+ if (k === ts.SyntaxKind.ClassDeclaration && node.name) return node.name.text;
59
+ if (k === ts.SyntaxKind.MethodDeclaration && node.name && node.name.text) return node.name.text;
60
+ if (k === ts.SyntaxKind.VariableStatement) {
61
+ // only match `const foo = () => ...` / `const foo = function() ...`,
62
+ // NOT `const Typescript = { ... }` (section labels in config objects)
63
+ const decl = node.declarationList?.declarations?.[0];
64
+ if (!decl || !decl.name || !decl.name.text) return null;
65
+ const init = decl.initializer;
66
+ if (!init) return null;
67
+ if (
68
+ init.kind === ts.SyntaxKind.ArrowFunction ||
69
+ init.kind === ts.SyntaxKind.FunctionExpression
70
+ ) {
71
+ return decl.name.text;
72
+ }
73
+ return null;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ function stripCommentMarkers(text) {
79
+ return text
80
+ .replace(/^\/\*+/, '')
81
+ .replace(/\*+\/$/, '')
82
+ .replace(/^\/\/+/, '')
83
+ .split('\n')
84
+ .map((l) => l.replace(/^\s*\*+\s?/, '').trim())
85
+ .filter(Boolean)
86
+ .join(' ')
87
+ .trim();
88
+ }
89
+
90
+ function tokenize(text) {
91
+ return text
92
+ .toLowerCase()
93
+ .replace(/[^a-z0-9\s]+/g, ' ')
94
+ .split(/\s+/)
95
+ .filter(Boolean);
96
+ }
97
+
98
+ function looksLikePadding(commentText, name) {
99
+ const cwords = tokenize(commentText);
100
+ if (cwords.length === 0 || cwords.length > 14) return false;
101
+
102
+ const nameWords = tokenize(deCamel(name));
103
+ const nset = new Set(nameWords.filter((w) => !STOPWORDS.has(w)));
104
+ const cset = new Set(cwords.filter((w) => !STOPWORDS.has(w)));
105
+ if (nset.size === 0) return false;
106
+
107
+ const commentHasVerb = [...cset].some((w) => VERBS.has(w));
108
+ for (const w of nset) {
109
+ if (cset.has(w)) continue;
110
+ if (cset.has(w + 's')) continue;
111
+ if (cset.has(w.replace(/s$/, ''))) continue;
112
+ if (VERBS.has(w) && commentHasVerb) continue;
113
+ return false; // name token absent → not padding
114
+ }
115
+
116
+ // Comment may add at most a couple of "extra" content words.
117
+ const extras = [...cset].filter(
118
+ (c) => !nset.has(c) && !VERBS.has(c) && !nset.has(c.replace(/s$/, ''))
119
+ );
120
+ return extras.length <= 2;
121
+ }
122
+
123
+ function truncate(s, n) {
124
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
125
+ }
126
+
127
+ function skipTrailingNewline(source, pos) {
128
+ if (source[pos] === '\r' && source[pos + 1] === '\n') return 2;
129
+ if (source[pos] === '\n' || source[pos] === '\r') return 1;
130
+ return 0;
131
+ }
@@ -0,0 +1,51 @@
1
+ import { walk, nodeRange } from './_util.mjs';
2
+
3
+ // `return await x` outside of a try/catch adds an extra microtask and a
4
+ // useless stack frame. Inside try/catch it's meaningful (catches the
5
+ // rejection), so we exclude that case.
6
+ export default {
7
+ id: 'redundant-await',
8
+ severity: 'info',
9
+ description: '`return await x` outside try — drop the await',
10
+ check(ctx) {
11
+ const { sourceFile, ts } = ctx;
12
+ const findings = [];
13
+
14
+ walk(sourceFile, (node) => {
15
+ if (node.kind !== ts.SyntaxKind.ReturnStatement) return;
16
+ if (!node.expression || node.expression.kind !== ts.SyntaxKind.AwaitExpression) return;
17
+ // walk up the parent chain — if any TryStatement contains us inside its tryBlock, skip
18
+ if (insideTryBlock(node, ts)) return;
19
+ const range = nodeRange(sourceFile, node);
20
+ findings.push({
21
+ message: '`return await x` outside try/catch — just `return x`',
22
+ line: range.line,
23
+ column: range.column,
24
+ });
25
+ });
26
+ return findings;
27
+ },
28
+ };
29
+
30
+ function insideTryBlock(node, ts) {
31
+ let p = node.parent;
32
+ while (p) {
33
+ if (p.kind === ts.SyntaxKind.TryStatement && p.tryBlock && containsNode(p.tryBlock, node)) {
34
+ return true;
35
+ }
36
+ // stop at function boundary — await is scoped to enclosing fn
37
+ if (
38
+ p.kind === ts.SyntaxKind.FunctionDeclaration ||
39
+ p.kind === ts.SyntaxKind.FunctionExpression ||
40
+ p.kind === ts.SyntaxKind.ArrowFunction ||
41
+ p.kind === ts.SyntaxKind.MethodDeclaration
42
+ ) return false;
43
+ p = p.parent;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ function containsNode(parent, target) {
49
+ if (!parent || !target) return false;
50
+ return target.pos >= parent.pos && target.end <= parent.end;
51
+ }