@blumintinc/eslint-plugin-blumint 1.16.2 → 1.17.1

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.
Files changed (51) hide show
  1. package/README.md +21 -0
  2. package/lib/index.js +64 -1
  3. package/lib/rules/enforce-boolean-naming-prefixes.js +30 -14
  4. package/lib/rules/enforce-cloud-function-id-length.d.ts +5 -0
  5. package/lib/rules/enforce-cloud-function-id-length.js +104 -0
  6. package/lib/rules/enforce-is-prefix-validators.d.ts +13 -0
  7. package/lib/rules/enforce-is-prefix-validators.js +304 -0
  8. package/lib/rules/enforce-m3-sentence-case.d.ts +12 -0
  9. package/lib/rules/enforce-m3-sentence-case.js +430 -0
  10. package/lib/rules/enforce-snapshot-state-narrowing.d.ts +10 -0
  11. package/lib/rules/enforce-snapshot-state-narrowing.js +281 -0
  12. package/lib/rules/enforce-types-directory-placement.d.ts +9 -0
  13. package/lib/rules/enforce-types-directory-placement.js +276 -0
  14. package/lib/rules/no-direct-function-state.d.ts +8 -0
  15. package/lib/rules/no-direct-function-state.js +285 -0
  16. package/lib/rules/no-fill-template-mutation.d.ts +1 -0
  17. package/lib/rules/no-fill-template-mutation.js +324 -0
  18. package/lib/rules/no-hungarian.js +18 -26
  19. package/lib/rules/no-portal-inside-tooltip.d.ts +10 -0
  20. package/lib/rules/no-portal-inside-tooltip.js +219 -0
  21. package/lib/rules/no-redundant-boolean-callback-props.d.ts +11 -0
  22. package/lib/rules/no-redundant-boolean-callback-props.js +355 -0
  23. package/lib/rules/no-satisfies-in-frontend-bundle.d.ts +8 -0
  24. package/lib/rules/no-satisfies-in-frontend-bundle.js +126 -0
  25. package/lib/rules/no-single-dismiss-dialog-button.d.ts +7 -0
  26. package/lib/rules/no-single-dismiss-dialog-button.js +127 -0
  27. package/lib/rules/no-stablehash-react-nodes.d.ts +1 -0
  28. package/lib/rules/no-stablehash-react-nodes.js +325 -0
  29. package/lib/rules/parallelize-loop-awaits.d.ts +8 -0
  30. package/lib/rules/parallelize-loop-awaits.js +582 -0
  31. package/lib/rules/prefer-flat-transform-each-keys.d.ts +1 -0
  32. package/lib/rules/prefer-flat-transform-each-keys.js +228 -0
  33. package/lib/rules/prefer-getter-over-parameterless-method.d.ts +1 -0
  34. package/lib/rules/prefer-getter-over-parameterless-method.js +44 -0
  35. package/lib/rules/prefer-spread-over-reassembly.d.ts +5 -0
  36. package/lib/rules/prefer-spread-over-reassembly.js +401 -0
  37. package/lib/rules/prefer-sx-prop-over-system-props.d.ts +9 -0
  38. package/lib/rules/prefer-sx-prop-over-system-props.js +401 -0
  39. package/lib/rules/prefer-use-base62-id.d.ts +8 -0
  40. package/lib/rules/prefer-use-base62-id.js +483 -0
  41. package/lib/rules/prefer-use-theme.d.ts +1 -0
  42. package/lib/rules/prefer-use-theme.js +206 -0
  43. package/lib/rules/prefer-utility-function-own-file.d.ts +9 -0
  44. package/lib/rules/prefer-utility-function-own-file.js +505 -0
  45. package/lib/rules/react-memoize-literals.js +106 -1
  46. package/lib/rules/require-props-composition.d.ts +10 -0
  47. package/lib/rules/require-props-composition.js +433 -0
  48. package/lib/rules/require-server-timestamp-for-firestore-dates.d.ts +9 -0
  49. package/lib/rules/require-server-timestamp-for-firestore-dates.js +313 -0
  50. package/package.json +1 -1
  51. package/release-manifest.json +212 -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
@@ -0,0 +1,8 @@
1
+ import { TSESLint } from '@typescript-eslint/utils';
2
+ type Options = [
3
+ {
4
+ functionPatterns?: string[];
5
+ }
6
+ ];
7
+ export declare const noDirectFunctionState: TSESLint.RuleModule<"noDirectFunctionState", Options, TSESLint.RuleListener>;
8
+ export {};