@blumintinc/eslint-plugin-blumint 1.16.2 → 1.17.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 +21 -0
- package/lib/index.js +64 -1
- package/lib/rules/enforce-boolean-naming-prefixes.js +30 -14
- package/lib/rules/enforce-cloud-function-id-length.d.ts +5 -0
- package/lib/rules/enforce-cloud-function-id-length.js +104 -0
- package/lib/rules/enforce-is-prefix-validators.d.ts +13 -0
- package/lib/rules/enforce-is-prefix-validators.js +304 -0
- package/lib/rules/enforce-m3-sentence-case.d.ts +12 -0
- package/lib/rules/enforce-m3-sentence-case.js +430 -0
- package/lib/rules/enforce-snapshot-state-narrowing.d.ts +10 -0
- package/lib/rules/enforce-snapshot-state-narrowing.js +281 -0
- package/lib/rules/enforce-types-directory-placement.d.ts +9 -0
- package/lib/rules/enforce-types-directory-placement.js +276 -0
- package/lib/rules/no-direct-function-state.d.ts +8 -0
- package/lib/rules/no-direct-function-state.js +285 -0
- package/lib/rules/no-fill-template-mutation.d.ts +1 -0
- package/lib/rules/no-fill-template-mutation.js +324 -0
- package/lib/rules/no-portal-inside-tooltip.d.ts +10 -0
- package/lib/rules/no-portal-inside-tooltip.js +219 -0
- package/lib/rules/no-redundant-boolean-callback-props.d.ts +11 -0
- package/lib/rules/no-redundant-boolean-callback-props.js +355 -0
- package/lib/rules/no-satisfies-in-frontend-bundle.d.ts +8 -0
- package/lib/rules/no-satisfies-in-frontend-bundle.js +126 -0
- package/lib/rules/no-single-dismiss-dialog-button.d.ts +7 -0
- package/lib/rules/no-single-dismiss-dialog-button.js +127 -0
- package/lib/rules/no-stablehash-react-nodes.d.ts +1 -0
- package/lib/rules/no-stablehash-react-nodes.js +325 -0
- package/lib/rules/parallelize-loop-awaits.d.ts +8 -0
- package/lib/rules/parallelize-loop-awaits.js +582 -0
- package/lib/rules/prefer-flat-transform-each-keys.d.ts +1 -0
- package/lib/rules/prefer-flat-transform-each-keys.js +228 -0
- package/lib/rules/prefer-getter-over-parameterless-method.d.ts +1 -0
- package/lib/rules/prefer-getter-over-parameterless-method.js +44 -0
- package/lib/rules/prefer-spread-over-reassembly.d.ts +5 -0
- package/lib/rules/prefer-spread-over-reassembly.js +401 -0
- package/lib/rules/prefer-sx-prop-over-system-props.d.ts +9 -0
- package/lib/rules/prefer-sx-prop-over-system-props.js +401 -0
- package/lib/rules/prefer-use-base62-id.d.ts +8 -0
- package/lib/rules/prefer-use-base62-id.js +483 -0
- package/lib/rules/prefer-use-theme.d.ts +1 -0
- package/lib/rules/prefer-use-theme.js +206 -0
- package/lib/rules/prefer-utility-function-own-file.d.ts +9 -0
- package/lib/rules/prefer-utility-function-own-file.js +505 -0
- package/lib/rules/require-props-composition.d.ts +10 -0
- package/lib/rules/require-props-composition.js +433 -0
- package/lib/rules/require-server-timestamp-for-firestore-dates.d.ts +9 -0
- package/lib/rules/require-server-timestamp-for-firestore-dates.js +313 -0
- package/package.json +1 -1
- package/release-manifest.json +190 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.enforceSnapshotStateNarrowing = void 0;
|
|
4
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
5
|
+
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const DEFAULT_SNAPSHOT_HOOKS = [
|
|
7
|
+
'useDocSnapshot',
|
|
8
|
+
'useCollectionSnapshot',
|
|
9
|
+
'useCachedDocSnapshot',
|
|
10
|
+
'useFirestore',
|
|
11
|
+
];
|
|
12
|
+
const DEFAULT_GUARD_FUNCTIONS = ['isSnapshotReady'];
|
|
13
|
+
exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
|
|
14
|
+
name: 'enforce-snapshot-state-narrowing',
|
|
15
|
+
meta: {
|
|
16
|
+
type: 'problem',
|
|
17
|
+
docs: {
|
|
18
|
+
description: 'Enforce correct narrowing of FirestoreSnapshotState<T> variables. Falsy/truthy checks are semantic bugs because all string states are truthy; raw typeof narrowing to data bypasses the isSnapshotReady abstraction.',
|
|
19
|
+
recommended: 'error',
|
|
20
|
+
},
|
|
21
|
+
hasSuggestions: true,
|
|
22
|
+
schema: [
|
|
23
|
+
{
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
snapshotHooks: {
|
|
27
|
+
type: 'array',
|
|
28
|
+
items: { type: 'string' },
|
|
29
|
+
description: 'Hook names that return FirestoreSnapshotState<T>',
|
|
30
|
+
},
|
|
31
|
+
guardFunctions: {
|
|
32
|
+
type: 'array',
|
|
33
|
+
items: { type: 'string' },
|
|
34
|
+
description: 'Canonical type guard function names',
|
|
35
|
+
},
|
|
36
|
+
excludeFiles: {
|
|
37
|
+
type: 'array',
|
|
38
|
+
items: { type: 'string' },
|
|
39
|
+
description: 'File patterns to exclude from this rule',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
messages: {
|
|
46
|
+
noFalsyCheck: "Do not use boolean coercion on FirestoreSnapshotState<T>. All string states ('idle', 'loading', 'not-found') are truthy, so '{{expression}}' does not behave as intended. Use isSnapshotReady(state) to narrow to T, or compare explicitly (e.g., state === 'loading').",
|
|
47
|
+
noRawTypeof: "Do not use '{{expression}}' to narrow FirestoreSnapshotState<T> to data. Use isSnapshotReady(state) instead to maintain the abstraction boundary.",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
defaultOptions: [{}],
|
|
51
|
+
create(context, [options]) {
|
|
52
|
+
const snapshotHooks = new Set(options?.snapshotHooks ?? DEFAULT_SNAPSHOT_HOOKS);
|
|
53
|
+
// guardFunctions is accepted in config for documentation and future extensibility
|
|
54
|
+
// but detection is purely syntactic (by hook source), not by guard function name.
|
|
55
|
+
void (options?.guardFunctions ?? DEFAULT_GUARD_FUNCTIONS);
|
|
56
|
+
const excludeFiles = options?.excludeFiles ?? [
|
|
57
|
+
'src/types/FirestoreSnapshotState.ts',
|
|
58
|
+
];
|
|
59
|
+
// Check if the current file should be excluded
|
|
60
|
+
const filename = context.getFilename();
|
|
61
|
+
if (excludeFiles.some((pattern) => filename.endsWith(pattern))) {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
// Track variable names that are assigned from snapshot hooks within each scope.
|
|
65
|
+
// We map variable name -> true (in-scope snapshot vars).
|
|
66
|
+
const snapshotVars = new Set();
|
|
67
|
+
/**
|
|
68
|
+
* Extracts the callee name from a CallExpression.
|
|
69
|
+
* Handles both simple identifiers (useDocSnapshot) and member expressions
|
|
70
|
+
* (hooks.useDocSnapshot). Returns undefined if it cannot be determined.
|
|
71
|
+
*/
|
|
72
|
+
function getCalleeName(node) {
|
|
73
|
+
const callee = node.callee;
|
|
74
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
75
|
+
return callee.name;
|
|
76
|
+
}
|
|
77
|
+
if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
78
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
79
|
+
return callee.property.name;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Returns true if the identifier refers to a variable we are tracking as a
|
|
85
|
+
* snapshot state variable.
|
|
86
|
+
*/
|
|
87
|
+
function isSnapshotVar(node) {
|
|
88
|
+
return snapshotVars.has(node.name);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Returns the source text of a node, falling back to a simple stringification.
|
|
92
|
+
*/
|
|
93
|
+
function getText(node) {
|
|
94
|
+
try {
|
|
95
|
+
return context.getSourceCode().getText(node);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return '<expression>';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Checks a BinaryExpression for raw typeof narrowing patterns that attempt
|
|
103
|
+
* to narrow the state to data:
|
|
104
|
+
* - typeof state === 'object' (bad)
|
|
105
|
+
* - typeof state !== 'string' (bad - equivalent to isSnapshotReady)
|
|
106
|
+
* - typeof state === 'object' && state !== null (the combined form is
|
|
107
|
+
* handled by visiting the BinaryExpression children individually)
|
|
108
|
+
*
|
|
109
|
+
* Allowed:
|
|
110
|
+
* - typeof state === 'string' (good - narrows to non-data states)
|
|
111
|
+
* - typeof state !== 'object' (not flagged, unusual but not a to-data check)
|
|
112
|
+
*/
|
|
113
|
+
function checkTypeofBinaryExpression(node) {
|
|
114
|
+
const { operator, left, right } = node;
|
|
115
|
+
// Pattern: typeof <expr> === <literal> or typeof <expr> !== <literal>
|
|
116
|
+
if (left.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
|
|
117
|
+
left.operator === 'typeof' &&
|
|
118
|
+
right.type === utils_1.AST_NODE_TYPES.Literal) {
|
|
119
|
+
const operand = left.argument;
|
|
120
|
+
const literal = right.value;
|
|
121
|
+
if (operand.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
122
|
+
isSnapshotVar(operand)) {
|
|
123
|
+
// typeof state === 'object' — bad: narrows to data, use isSnapshotReady
|
|
124
|
+
if (operator === '===' && literal === 'object') {
|
|
125
|
+
context.report({
|
|
126
|
+
node,
|
|
127
|
+
messageId: 'noRawTypeof',
|
|
128
|
+
data: { expression: getText(node) },
|
|
129
|
+
suggest: [
|
|
130
|
+
{
|
|
131
|
+
messageId: 'noRawTypeof',
|
|
132
|
+
data: {
|
|
133
|
+
expression: `isSnapshotReady(${operand.name})`,
|
|
134
|
+
},
|
|
135
|
+
fix(fixer) {
|
|
136
|
+
return fixer.replaceText(node, `isSnapshotReady(${operand.name})`);
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// typeof state !== 'string' — bad: equivalent to isSnapshotReady
|
|
143
|
+
else if (operator === '!==' && literal === 'string') {
|
|
144
|
+
context.report({
|
|
145
|
+
node,
|
|
146
|
+
messageId: 'noRawTypeof',
|
|
147
|
+
data: { expression: getText(node) },
|
|
148
|
+
suggest: [
|
|
149
|
+
{
|
|
150
|
+
messageId: 'noRawTypeof',
|
|
151
|
+
data: {
|
|
152
|
+
expression: `isSnapshotReady(${operand.name})`,
|
|
153
|
+
},
|
|
154
|
+
fix(fixer) {
|
|
155
|
+
return fixer.replaceText(node, `isSnapshotReady(${operand.name})`);
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// typeof state === 'string' — allowed (narrows to non-data states)
|
|
162
|
+
// typeof state !== 'object' — allowed
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Reports a falsy/truthy check on a snapshot-state identifier.
|
|
168
|
+
*/
|
|
169
|
+
function reportFalsyCheck(node, expression, varName) {
|
|
170
|
+
context.report({
|
|
171
|
+
node,
|
|
172
|
+
messageId: 'noFalsyCheck',
|
|
173
|
+
data: { expression },
|
|
174
|
+
suggest: [
|
|
175
|
+
{
|
|
176
|
+
messageId: 'noFalsyCheck',
|
|
177
|
+
data: { expression: `isSnapshotReady(${varName})` },
|
|
178
|
+
fix(fixer) {
|
|
179
|
+
// Replace the entire flagged expression with the canonical guard
|
|
180
|
+
return fixer.replaceText(node, `isSnapshotReady(${varName})`);
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
// Track variable declarations that come from snapshot hooks.
|
|
188
|
+
// Supports:
|
|
189
|
+
// const state = useDocSnapshot<T>(...) — simple assignment
|
|
190
|
+
// const [state, setState] = useCollectionSnapshot<T>(...) — array destructuring
|
|
191
|
+
VariableDeclarator(node) {
|
|
192
|
+
if (!node.init || node.init.type !== utils_1.AST_NODE_TYPES.CallExpression) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const callee = getCalleeName(node.init);
|
|
196
|
+
if (!callee || !snapshotHooks.has(callee)) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// Simple: const state = useDocSnapshot(...)
|
|
200
|
+
if (node.id.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
201
|
+
snapshotVars.add(node.id.name);
|
|
202
|
+
}
|
|
203
|
+
// Destructuring: const { state } = useDocSnapshot(...) or const [state] = ...
|
|
204
|
+
// We don't track these deeply to avoid false positives — the variable would
|
|
205
|
+
// need a different name and it is unusual to destructure a snapshot state hook result.
|
|
206
|
+
},
|
|
207
|
+
// UnaryExpression: !state or !!state
|
|
208
|
+
// For !!state: ESLint visits both the outer (!) and inner (!) nodes.
|
|
209
|
+
// We report at the outermost level only: skip when this node is the inner
|
|
210
|
+
// `!` of a `!!` expression (i.e. the parent is also a `!` UnaryExpression).
|
|
211
|
+
UnaryExpression(node) {
|
|
212
|
+
if (node.operator !== '!')
|
|
213
|
+
return;
|
|
214
|
+
// Skip the inner `!` of a `!!state` pattern — the outer `!` will report it.
|
|
215
|
+
const parent = node.parent;
|
|
216
|
+
if (parent &&
|
|
217
|
+
parent.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
|
|
218
|
+
parent.operator === '!') {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const argument = node.argument;
|
|
222
|
+
// !state — the argument is directly the snapshot var
|
|
223
|
+
if (argument.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
224
|
+
isSnapshotVar(argument)) {
|
|
225
|
+
const expression = `!${argument.name}`;
|
|
226
|
+
reportFalsyCheck(node, expression, argument.name);
|
|
227
|
+
}
|
|
228
|
+
// !!state — the argument is another `!` whose argument is the snapshot var
|
|
229
|
+
else if (argument.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
|
|
230
|
+
argument.operator === '!' &&
|
|
231
|
+
argument.argument.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
232
|
+
isSnapshotVar(argument.argument)) {
|
|
233
|
+
const varName = argument.argument.name;
|
|
234
|
+
reportFalsyCheck(node, `!!${varName}`, varName);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
// IfStatement: if (state) { ... } or if (!state) { ... }
|
|
238
|
+
// The !state case is covered by UnaryExpression. We handle if (state) here.
|
|
239
|
+
IfStatement(node) {
|
|
240
|
+
const test = node.test;
|
|
241
|
+
if (test.type === utils_1.AST_NODE_TYPES.Identifier && isSnapshotVar(test)) {
|
|
242
|
+
reportFalsyCheck(test, test.name, test.name);
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
// ConditionalExpression: state ? a : b
|
|
246
|
+
ConditionalExpression(node) {
|
|
247
|
+
const test = node.test;
|
|
248
|
+
if (test.type === utils_1.AST_NODE_TYPES.Identifier && isSnapshotVar(test)) {
|
|
249
|
+
reportFalsyCheck(test, test.name, test.name);
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
// LogicalExpression: state && expr, state || expr
|
|
253
|
+
LogicalExpression(node) {
|
|
254
|
+
const left = node.left;
|
|
255
|
+
if ((node.operator === '&&' || node.operator === '||') &&
|
|
256
|
+
left.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
257
|
+
isSnapshotVar(left)) {
|
|
258
|
+
reportFalsyCheck(left, left.name, left.name);
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
// BinaryExpression: typeof state === 'object', typeof state !== 'string'
|
|
262
|
+
BinaryExpression(node) {
|
|
263
|
+
checkTypeofBinaryExpression(node);
|
|
264
|
+
},
|
|
265
|
+
// CallExpression: Boolean(state)
|
|
266
|
+
CallExpression(node) {
|
|
267
|
+
const callee = node.callee;
|
|
268
|
+
// Boolean(state) — explicit coercion
|
|
269
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
270
|
+
callee.name === 'Boolean' &&
|
|
271
|
+
node.arguments.length === 1 &&
|
|
272
|
+
node.arguments[0].type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
273
|
+
isSnapshotVar(node.arguments[0])) {
|
|
274
|
+
const varName = node.arguments[0].name;
|
|
275
|
+
reportFalsyCheck(node, `Boolean(${varName})`, varName);
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
//# sourceMappingURL=enforce-snapshot-state-narrowing.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type Options = [
|
|
2
|
+
{
|
|
3
|
+
typesDirectory?: string;
|
|
4
|
+
excludePatterns?: string[];
|
|
5
|
+
includePaths?: string[];
|
|
6
|
+
}
|
|
7
|
+
];
|
|
8
|
+
export declare const enforceTypesDirectoryPlacement: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"typeOnlyFileOutsideTypesDir", Options, import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.enforceTypesDirectoryPlacement = void 0;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
9
|
+
const minimatch_1 = require("minimatch");
|
|
10
|
+
const createRule_1 = require("../utils/createRule");
|
|
11
|
+
const DEFAULT_TYPES_DIRECTORY = 'functions/src/types';
|
|
12
|
+
const DEFAULT_EXCLUDE_PATTERNS = [
|
|
13
|
+
'**/*.d.ts',
|
|
14
|
+
'**/*.test.ts',
|
|
15
|
+
'**/*.test.tsx',
|
|
16
|
+
'**/*.spec.ts',
|
|
17
|
+
'**/*.spec.tsx',
|
|
18
|
+
'**/__mocks__/**',
|
|
19
|
+
];
|
|
20
|
+
const DEFAULT_INCLUDE_PATHS = ['src/**', 'functions/src/**'];
|
|
21
|
+
/**
|
|
22
|
+
* Normalizes a filesystem path to use forward slashes so that
|
|
23
|
+
* Windows paths are handled uniformly alongside POSIX paths.
|
|
24
|
+
*/
|
|
25
|
+
function normalizePath(filePath) {
|
|
26
|
+
return filePath.replace(/\\/g, '/');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Returns true when the normalized path contains the given directory
|
|
30
|
+
* segment anywhere inside it (as a proper path component, not a substring).
|
|
31
|
+
* E.g. containsPathSegment('/a/functions/src/types/foo.ts', 'functions/src/types') → true
|
|
32
|
+
*/
|
|
33
|
+
function containsPathSegment(filePath, segment) {
|
|
34
|
+
const normalized = normalizePath(filePath);
|
|
35
|
+
const normalizedSegment = normalizePath(segment);
|
|
36
|
+
return (normalized.includes('/' + normalizedSegment + '/') ||
|
|
37
|
+
normalized.includes('/' + normalizedSegment) ||
|
|
38
|
+
normalized.endsWith('/' + normalizedSegment));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Derives a suggested target path under the types directory.
|
|
42
|
+
*
|
|
43
|
+
* Strategy:
|
|
44
|
+
* - For backend files (path contains `functions/src/<segment>/`): strip `functions/src/<segment>/`
|
|
45
|
+
* and prepend `functions/src/types/`.
|
|
46
|
+
* - For frontend files (path contains `src/<segment>/`): strip `src/<segment>/`
|
|
47
|
+
* and prepend `functions/src/types/`.
|
|
48
|
+
*/
|
|
49
|
+
function suggestTargetPath(filePath, typesDirectory) {
|
|
50
|
+
const normalized = normalizePath(filePath);
|
|
51
|
+
// Backend pattern: functions/src/<segment>/...
|
|
52
|
+
const backendMatch = normalized.match(/functions\/src\/([^/]+)\/(.+)/);
|
|
53
|
+
if (backendMatch) {
|
|
54
|
+
const [, , rest] = backendMatch;
|
|
55
|
+
return `${typesDirectory}/${rest}`;
|
|
56
|
+
}
|
|
57
|
+
// Frontend pattern: src/<segment>/...
|
|
58
|
+
const frontendMatch = normalized.match(/(?:^|\/)src\/([^/]+)\/(.+)/);
|
|
59
|
+
if (frontendMatch) {
|
|
60
|
+
const [, , rest] = frontendMatch;
|
|
61
|
+
return `${typesDirectory}/${rest}`;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Returns true when the given top-level statement is "type-only":
|
|
67
|
+
* - ImportDeclaration (imports alone are not runtime code)
|
|
68
|
+
* - TSTypeAliasDeclaration (bare or exported)
|
|
69
|
+
* - TSInterfaceDeclaration (bare or exported)
|
|
70
|
+
* - TSEnumDeclaration (treated as type-level per spec)
|
|
71
|
+
* - TSModuleDeclaration (declare blocks)
|
|
72
|
+
* - ExportNamedDeclaration that exports only types/interfaces/enums OR
|
|
73
|
+
* is a pure type re-export (`export type { ... } from ...` or
|
|
74
|
+
* `export { ... } from ...` with no local declarations carrying runtime code)
|
|
75
|
+
* - ExportAllDeclaration
|
|
76
|
+
*
|
|
77
|
+
* Anything else (FunctionDeclaration, ClassDeclaration, VariableDeclaration,
|
|
78
|
+
* ExpressionStatement, etc.) is runtime code and returns false.
|
|
79
|
+
*/
|
|
80
|
+
function isTypeOnlyStatement(node) {
|
|
81
|
+
switch (node.type) {
|
|
82
|
+
case utils_1.AST_NODE_TYPES.ImportDeclaration:
|
|
83
|
+
// Imports don't count as runtime code for this rule.
|
|
84
|
+
return true;
|
|
85
|
+
case utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration:
|
|
86
|
+
case utils_1.AST_NODE_TYPES.TSInterfaceDeclaration:
|
|
87
|
+
case utils_1.AST_NODE_TYPES.TSEnumDeclaration:
|
|
88
|
+
case utils_1.AST_NODE_TYPES.TSModuleDeclaration:
|
|
89
|
+
return true;
|
|
90
|
+
case utils_1.AST_NODE_TYPES.ExportNamedDeclaration: {
|
|
91
|
+
const exportNode = node;
|
|
92
|
+
// `export type { Foo }` or `export type { Foo } from '...'`
|
|
93
|
+
if (exportNode.exportKind === 'type') {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
// `export { Foo } from '...'` — pure re-export with no local declaration
|
|
97
|
+
if (exportNode.source !== null && exportNode.declaration === null) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
// `export type Foo = ...` / `export interface Foo { ... }` / `export enum Foo { ... }`
|
|
101
|
+
if (exportNode.declaration !== null) {
|
|
102
|
+
const decl = exportNode.declaration;
|
|
103
|
+
return (decl.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration ||
|
|
104
|
+
decl.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration ||
|
|
105
|
+
decl.type === utils_1.AST_NODE_TYPES.TSEnumDeclaration ||
|
|
106
|
+
decl.type === utils_1.AST_NODE_TYPES.TSModuleDeclaration);
|
|
107
|
+
}
|
|
108
|
+
// `export { Foo }` without `from` — re-exporting local bindings.
|
|
109
|
+
// Since we cannot tell whether the local binding is a type or value
|
|
110
|
+
// without the type checker, treat this as not type-only to avoid
|
|
111
|
+
// false positives.
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
case utils_1.AST_NODE_TYPES.ExportAllDeclaration:
|
|
115
|
+
// `export * from '...'` or `export type * from '...'`
|
|
116
|
+
return true;
|
|
117
|
+
default:
|
|
118
|
+
// FunctionDeclaration, ClassDeclaration, VariableDeclaration,
|
|
119
|
+
// ExpressionStatement, etc. all count as runtime code.
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* A file is "type-only" when:
|
|
125
|
+
* 1. It has at least one top-level type declaration (not just bare imports/re-exports).
|
|
126
|
+
* 2. Every top-level statement is type-only according to isTypeOnlyStatement.
|
|
127
|
+
*/
|
|
128
|
+
function isTypeOnlyFile(body) {
|
|
129
|
+
if (body.length === 0) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
let hasActualTypeDeclaration = false;
|
|
133
|
+
for (const stmt of body) {
|
|
134
|
+
if (!isTypeOnlyStatement(stmt)) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
// Must have at least one "real" type construct, not just imports/re-exports.
|
|
138
|
+
if (stmt.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration ||
|
|
139
|
+
stmt.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration ||
|
|
140
|
+
stmt.type === utils_1.AST_NODE_TYPES.TSEnumDeclaration ||
|
|
141
|
+
stmt.type === utils_1.AST_NODE_TYPES.TSModuleDeclaration) {
|
|
142
|
+
hasActualTypeDeclaration = true;
|
|
143
|
+
}
|
|
144
|
+
else if (stmt.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration) {
|
|
145
|
+
const exportNode = stmt;
|
|
146
|
+
if (exportNode.declaration !== null) {
|
|
147
|
+
const decl = exportNode.declaration;
|
|
148
|
+
if (decl.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration ||
|
|
149
|
+
decl.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration ||
|
|
150
|
+
decl.type === utils_1.AST_NODE_TYPES.TSEnumDeclaration ||
|
|
151
|
+
decl.type === utils_1.AST_NODE_TYPES.TSModuleDeclaration) {
|
|
152
|
+
hasActualTypeDeclaration = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else if (
|
|
156
|
+
// `export type { ... } from '...'` or `export { ... } from '...'`
|
|
157
|
+
// counts toward type declarations only when using the type export kind
|
|
158
|
+
exportNode.exportKind === 'type') {
|
|
159
|
+
hasActualTypeDeclaration = true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else if (stmt.type === utils_1.AST_NODE_TYPES.ExportAllDeclaration) {
|
|
163
|
+
const exportAllNode = stmt;
|
|
164
|
+
if (exportAllNode.exportKind === 'type') {
|
|
165
|
+
hasActualTypeDeclaration = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return hasActualTypeDeclaration;
|
|
170
|
+
}
|
|
171
|
+
exports.enforceTypesDirectoryPlacement = (0, createRule_1.createRule)({
|
|
172
|
+
name: 'enforce-types-directory-placement',
|
|
173
|
+
meta: {
|
|
174
|
+
type: 'suggestion',
|
|
175
|
+
docs: {
|
|
176
|
+
description: 'Enforce that type-only files (containing only type/interface/enum declarations) live under the canonical types directory',
|
|
177
|
+
recommended: 'error',
|
|
178
|
+
},
|
|
179
|
+
schema: [
|
|
180
|
+
{
|
|
181
|
+
type: 'object',
|
|
182
|
+
properties: {
|
|
183
|
+
typesDirectory: {
|
|
184
|
+
type: 'string',
|
|
185
|
+
default: DEFAULT_TYPES_DIRECTORY,
|
|
186
|
+
},
|
|
187
|
+
excludePatterns: {
|
|
188
|
+
type: 'array',
|
|
189
|
+
items: { type: 'string' },
|
|
190
|
+
default: DEFAULT_EXCLUDE_PATTERNS,
|
|
191
|
+
},
|
|
192
|
+
includePaths: {
|
|
193
|
+
type: 'array',
|
|
194
|
+
items: { type: 'string' },
|
|
195
|
+
default: DEFAULT_INCLUDE_PATHS,
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
additionalProperties: false,
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
messages: {
|
|
202
|
+
typeOnlyFileOutsideTypesDir: 'Type-only file detected outside {{typesDirectory}}/.\n\nAll files containing only type definitions must live under {{typesDirectory}}/**.\n{{suggestedPath}}' +
|
|
203
|
+
'If these types are only used by a single consumer, inline them into the consumer file instead of keeping them in a separate file.\n\nSee .claude/skills/types-placement/SKILL.md for the full types placement guide.',
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
defaultOptions: [
|
|
207
|
+
{
|
|
208
|
+
typesDirectory: DEFAULT_TYPES_DIRECTORY,
|
|
209
|
+
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
|
|
210
|
+
includePaths: DEFAULT_INCLUDE_PATHS,
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
create(context, [options]) {
|
|
214
|
+
const typesDirectory = options.typesDirectory ?? DEFAULT_TYPES_DIRECTORY;
|
|
215
|
+
const excludePatterns = options.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS;
|
|
216
|
+
const includePaths = options.includePaths ?? DEFAULT_INCLUDE_PATHS;
|
|
217
|
+
const filename = context.getFilename();
|
|
218
|
+
// Skip synthetic filenames used by RuleTester when no filename is provided.
|
|
219
|
+
if (filename === '<input>' || filename === '<text>') {
|
|
220
|
+
return {};
|
|
221
|
+
}
|
|
222
|
+
const normalizedFilename = normalizePath(filename);
|
|
223
|
+
// Skip .d.ts files first (fast path before glob matching).
|
|
224
|
+
if (normalizedFilename.endsWith('.d.ts')) {
|
|
225
|
+
return {};
|
|
226
|
+
}
|
|
227
|
+
// Skip files already inside the canonical types directory.
|
|
228
|
+
if (containsPathSegment(normalizedFilename, typesDirectory)) {
|
|
229
|
+
return {};
|
|
230
|
+
}
|
|
231
|
+
// Apply exclude patterns.
|
|
232
|
+
const excludeMatchers = excludePatterns.map((pattern) => new minimatch_1.Minimatch(pattern, { dot: true }));
|
|
233
|
+
if (excludeMatchers.some((mm) => mm.match(normalizedFilename)) ||
|
|
234
|
+
excludeMatchers.some((mm) => mm.match(path_1.default.basename(normalizedFilename)))) {
|
|
235
|
+
return {};
|
|
236
|
+
}
|
|
237
|
+
// Apply includePaths: only enforce within the configured paths.
|
|
238
|
+
if (includePaths.length > 0) {
|
|
239
|
+
const includeMatchers = includePaths.map((pattern) => new minimatch_1.Minimatch(pattern, { dot: true }));
|
|
240
|
+
const isIncluded = includeMatchers.some((mm) => mm.match(normalizedFilename) ||
|
|
241
|
+
// Also match against just the portion of the path from src/ or functions/
|
|
242
|
+
(() => {
|
|
243
|
+
const srcIdx = normalizedFilename.indexOf('/src/');
|
|
244
|
+
const funcIdx = normalizedFilename.indexOf('/functions/');
|
|
245
|
+
const relStart = srcIdx !== -1 ? srcIdx + 1 : funcIdx !== -1 ? funcIdx + 1 : -1;
|
|
246
|
+
if (relStart === -1)
|
|
247
|
+
return false;
|
|
248
|
+
return mm.match(normalizedFilename.slice(relStart));
|
|
249
|
+
})());
|
|
250
|
+
if (!isIncluded) {
|
|
251
|
+
return {};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
Program(programNode) {
|
|
256
|
+
if (!isTypeOnlyFile(programNode.body)) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const suggested = suggestTargetPath(normalizedFilename, typesDirectory);
|
|
260
|
+
const suggestedPath = suggested
|
|
261
|
+
? `This file should be moved to: ${suggested}\n\n`
|
|
262
|
+
: '';
|
|
263
|
+
const reportNode = programNode.body.length > 0 ? programNode.body[0] : programNode;
|
|
264
|
+
context.report({
|
|
265
|
+
node: reportNode,
|
|
266
|
+
messageId: 'typeOnlyFileOutsideTypesDir',
|
|
267
|
+
data: {
|
|
268
|
+
typesDirectory,
|
|
269
|
+
suggestedPath,
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
//# sourceMappingURL=enforce-types-directory-placement.js.map
|