Skip to content

Commit f72cfa7

Browse files
committed
feat!: add enforce-ts-extension
1 parent 38c4d40 commit f72cfa7

2 files changed

Lines changed: 135 additions & 0 deletions

File tree

index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import pkg from './package.json' with { type: 'json' };
2+
import enforceTsExtension from './rules/enforce-ts-extension.js';
23
import testRootDescribe from './rules/test-root-describe.js';
34

45
/** @type {Record<string, import('eslint').Rule.RuleModule>} */
56
const rules = {
67
'test-root-describe': testRootDescribe,
8+
'enforce-ts-extension': enforceTsExtension,
79
};
810

911
/** @type {Record<string, import('eslint').Linter.Config> } */
@@ -16,6 +18,7 @@ const configs = {
1618
},
1719
rules: {
1820
'@containerbase/test-root-describe': 'error',
21+
'@containerbase/enforce-ts-extension': 'error',
1922
},
2023
},
2124
};

rules/enforce-ts-extension.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
const VI_METHODS = new Set([
2+
'mock',
3+
'doMock',
4+
'unmock',
5+
'doUnmock',
6+
'importActual',
7+
'importMock',
8+
]);
9+
10+
/** @param {string} value */
11+
function isLocalPath(value) {
12+
return value.startsWith('.') || value.startsWith('~');
13+
}
14+
15+
/** @param {string} value */
16+
function hasExtension(value) {
17+
const lastSlash = value.lastIndexOf('/');
18+
const basename = lastSlash >= 0 ? value.slice(lastSlash + 1) : value;
19+
const dotIndex = basename.lastIndexOf('.');
20+
return dotIndex > 0 && dotIndex < basename.length - 1;
21+
}
22+
23+
/**
24+
* @param {import('estree').Literal} node
25+
* @returns {string | undefined}
26+
*/
27+
function getStringValue(node) {
28+
if (typeof node.value === 'string') {
29+
return node.value;
30+
}
31+
return undefined;
32+
}
33+
34+
/**
35+
* @param {import('eslint').Rule.RuleContext} context
36+
* @param {import('estree').Literal} node
37+
* @param {string} value
38+
*/
39+
function reportJsExtension(context, node, value) {
40+
const quote = node.raw?.[0] ?? "'";
41+
const fixed = value.slice(0, -3) + '.ts';
42+
context.report({
43+
node,
44+
messageId: 'useTsExtension',
45+
fix(fixer) {
46+
return fixer.replaceText(node, `${quote}${fixed}${quote}`);
47+
},
48+
});
49+
}
50+
51+
/**
52+
* @param {import('eslint').Rule.RuleContext} context
53+
* @param {import('estree').Literal} node
54+
*/
55+
function reportMissingExtension(context, node) {
56+
context.report({
57+
node,
58+
messageId: 'missingExtension',
59+
});
60+
}
61+
62+
/**
63+
* @param {import('eslint').Rule.RuleContext} context
64+
* @param {import('estree').Literal | null | undefined} sourceNode
65+
*/
66+
function checkLiteral(context, sourceNode) {
67+
if (!sourceNode) {
68+
return;
69+
}
70+
const value = getStringValue(sourceNode);
71+
if (!value || !isLocalPath(value)) {
72+
return;
73+
}
74+
if (value.endsWith('.js')) {
75+
reportJsExtension(context, sourceNode, value);
76+
}
77+
}
78+
79+
/** @type {import('eslint').Rule.RuleModule} */
80+
export default {
81+
meta: {
82+
type: 'problem',
83+
fixable: 'code',
84+
messages: {
85+
useTsExtension: 'Use ".ts" extension instead of ".js" for local imports',
86+
missingExtension: 'Missing file extension on local import',
87+
},
88+
},
89+
create(context) {
90+
return {
91+
ImportDeclaration(node) {
92+
checkLiteral(context, node.source);
93+
},
94+
ExportNamedDeclaration(node) {
95+
checkLiteral(context, node.source);
96+
},
97+
ExportAllDeclaration(node) {
98+
checkLiteral(context, node.source);
99+
},
100+
ImportExpression(node) {
101+
if (node.source.type === 'Literal') {
102+
checkLiteral(context, node.source);
103+
}
104+
},
105+
CallExpression(node) {
106+
const { callee } = node;
107+
if (
108+
callee.type !== 'MemberExpression' ||
109+
callee.object.type !== 'Identifier' ||
110+
callee.object.name !== 'vi' ||
111+
callee.property.type !== 'Identifier' ||
112+
!VI_METHODS.has(callee.property.name)
113+
) {
114+
return;
115+
}
116+
const [arg] = node.arguments;
117+
if (arg?.type !== 'Literal') {
118+
return;
119+
}
120+
const value = getStringValue(arg);
121+
if (!value || !isLocalPath(value)) {
122+
return;
123+
}
124+
if (value.endsWith('.js')) {
125+
reportJsExtension(context, arg, value);
126+
} else if (!hasExtension(value)) {
127+
reportMissingExtension(context, arg);
128+
}
129+
},
130+
};
131+
},
132+
};

0 commit comments

Comments
 (0)