@fictjs/eslint-plugin 0.0.2
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 +17 -0
- package/dist/index.cjs +815 -0
- package/dist/index.d.cts +36 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +780 -0
- package/package.json +39 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
// src/rules/no-direct-mutation.ts
|
|
2
|
+
var rule = {
|
|
3
|
+
meta: {
|
|
4
|
+
type: "suggestion",
|
|
5
|
+
docs: {
|
|
6
|
+
description: "Warn against direct mutation of $state objects",
|
|
7
|
+
recommended: true
|
|
8
|
+
},
|
|
9
|
+
messages: {
|
|
10
|
+
noDirectMutation: "Direct mutation of nested $state properties may not trigger updates. Use spread syntax or $store for deep reactivity."
|
|
11
|
+
},
|
|
12
|
+
schema: []
|
|
13
|
+
},
|
|
14
|
+
create(context) {
|
|
15
|
+
const stateVariables = /* @__PURE__ */ new Set();
|
|
16
|
+
return {
|
|
17
|
+
VariableDeclarator(node) {
|
|
18
|
+
if (node.init?.type === "CallExpression" && node.init.callee.type === "Identifier" && node.init.callee.name === "$state" && node.id.type === "Identifier") {
|
|
19
|
+
stateVariables.add(node.id.name);
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
AssignmentExpression(node) {
|
|
23
|
+
if (node.left.type === "MemberExpression") {
|
|
24
|
+
const root = getRootIdentifier(node.left);
|
|
25
|
+
if (root && stateVariables.has(root.name)) {
|
|
26
|
+
if (isDeepAccess(node.left)) {
|
|
27
|
+
context.report({
|
|
28
|
+
node,
|
|
29
|
+
messageId: "noDirectMutation"
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
function getRootIdentifier(node) {
|
|
39
|
+
let current = node;
|
|
40
|
+
while (current.type === "MemberExpression") {
|
|
41
|
+
current = current.object;
|
|
42
|
+
}
|
|
43
|
+
return current.type === "Identifier" ? current : null;
|
|
44
|
+
}
|
|
45
|
+
function isDeepAccess(node) {
|
|
46
|
+
let depth = 0;
|
|
47
|
+
let current = node;
|
|
48
|
+
while (current.type === "MemberExpression") {
|
|
49
|
+
depth++;
|
|
50
|
+
current = current.object;
|
|
51
|
+
}
|
|
52
|
+
return depth > 1;
|
|
53
|
+
}
|
|
54
|
+
var no_direct_mutation_default = rule;
|
|
55
|
+
|
|
56
|
+
// src/rules/no-empty-effect.ts
|
|
57
|
+
var rule2 = {
|
|
58
|
+
meta: {
|
|
59
|
+
type: "suggestion",
|
|
60
|
+
docs: {
|
|
61
|
+
description: "Disallow $effect bodies that do not read any reactive value (FICT-E001)",
|
|
62
|
+
recommended: true
|
|
63
|
+
},
|
|
64
|
+
messages: {
|
|
65
|
+
emptyEffect: "$effect should reference at least one reactive value (FICT-E001)."
|
|
66
|
+
},
|
|
67
|
+
schema: []
|
|
68
|
+
},
|
|
69
|
+
create(context) {
|
|
70
|
+
const builtinIgnore = /* @__PURE__ */ new Set([
|
|
71
|
+
"console",
|
|
72
|
+
"Math",
|
|
73
|
+
"Date",
|
|
74
|
+
"JSON",
|
|
75
|
+
"Number",
|
|
76
|
+
"String",
|
|
77
|
+
"Boolean",
|
|
78
|
+
"Symbol",
|
|
79
|
+
"BigInt",
|
|
80
|
+
"Reflect",
|
|
81
|
+
"RegExp"
|
|
82
|
+
]);
|
|
83
|
+
const collectLocals = (node, locals) => {
|
|
84
|
+
if (!node || node.type !== "BlockStatement") return;
|
|
85
|
+
for (const stmt of node.body) {
|
|
86
|
+
if (stmt.type === "VariableDeclaration") {
|
|
87
|
+
for (const decl of stmt.declarations) {
|
|
88
|
+
if (decl.id.type === "Identifier") {
|
|
89
|
+
locals.add(decl.id.name);
|
|
90
|
+
} else if (decl.id.type === "ObjectPattern" || decl.id.type === "ArrayPattern") {
|
|
91
|
+
const visit = (p) => {
|
|
92
|
+
if (!p) return;
|
|
93
|
+
if (p.type === "Identifier") {
|
|
94
|
+
locals.add(p.name);
|
|
95
|
+
} else if (p.type === "RestElement") {
|
|
96
|
+
visit(p.argument);
|
|
97
|
+
} else if (p.type === "ObjectPattern") {
|
|
98
|
+
for (const prop of p.properties) {
|
|
99
|
+
if (prop.type === "Property") {
|
|
100
|
+
visit(prop.value);
|
|
101
|
+
} else if (prop.type === "RestElement") {
|
|
102
|
+
visit(prop.argument);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} else if (p.type === "ArrayPattern") {
|
|
106
|
+
p.elements.forEach(visit);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
visit(decl.id);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (stmt.type === "FunctionDeclaration" && stmt.id?.name) {
|
|
114
|
+
locals.add(stmt.id.name);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const hasOuterReference = (node, locals) => {
|
|
119
|
+
let found = false;
|
|
120
|
+
const visit = (n) => {
|
|
121
|
+
if (found) return;
|
|
122
|
+
if (!n) return;
|
|
123
|
+
if (n.type === "FunctionDeclaration" || n.type === "FunctionExpression" || n.type === "ArrowFunctionExpression") {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (n.type === "Identifier") {
|
|
127
|
+
if (!locals.has(n.name) && !builtinIgnore.has(n.name)) {
|
|
128
|
+
found = true;
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (n.type === "MemberExpression") {
|
|
133
|
+
visit(n.object);
|
|
134
|
+
if (n.computed) {
|
|
135
|
+
visit(n.property);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const key of Object.keys(n)) {
|
|
140
|
+
if (key === "parent") continue;
|
|
141
|
+
const value = n[key];
|
|
142
|
+
if (!value) continue;
|
|
143
|
+
if (Array.isArray(value)) {
|
|
144
|
+
for (const child of value) {
|
|
145
|
+
if (child && typeof child.type === "string") visit(child);
|
|
146
|
+
}
|
|
147
|
+
} else if (value && typeof value.type === "string") {
|
|
148
|
+
visit(value);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
visit(node);
|
|
153
|
+
return found;
|
|
154
|
+
};
|
|
155
|
+
return {
|
|
156
|
+
CallExpression(node) {
|
|
157
|
+
if (node.callee.type === "Identifier" && node.callee.name === "$effect") {
|
|
158
|
+
const firstArg = node.arguments[0];
|
|
159
|
+
if (firstArg && (firstArg.type === "ArrowFunctionExpression" || firstArg.type === "FunctionExpression")) {
|
|
160
|
+
if (firstArg.body.type !== "BlockStatement") {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const block = firstArg.body;
|
|
164
|
+
if (block.body.length === 0) {
|
|
165
|
+
context.report({ node, messageId: "emptyEffect" });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const locals = /* @__PURE__ */ new Set();
|
|
169
|
+
firstArg.params.forEach((param) => {
|
|
170
|
+
if (param.type === "Identifier") locals.add(param.name);
|
|
171
|
+
});
|
|
172
|
+
collectLocals(block, locals);
|
|
173
|
+
if (!hasOuterReference(block, locals)) {
|
|
174
|
+
context.report({ node, messageId: "emptyEffect" });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
var no_empty_effect_default = rule2;
|
|
183
|
+
|
|
184
|
+
// src/rules/no-inline-functions.ts
|
|
185
|
+
var rule3 = {
|
|
186
|
+
meta: {
|
|
187
|
+
type: "suggestion",
|
|
188
|
+
docs: {
|
|
189
|
+
description: "Disallow inline function definitions in JSX props that may cause unnecessary re-renders",
|
|
190
|
+
recommended: true
|
|
191
|
+
},
|
|
192
|
+
messages: {
|
|
193
|
+
// Message matches DiagnosticCode.FICT_X003
|
|
194
|
+
inlineFunction: "Inline function in JSX props may cause unnecessary re-renders. Consider memoizing with $memo or moving outside the render."
|
|
195
|
+
},
|
|
196
|
+
schema: [
|
|
197
|
+
{
|
|
198
|
+
type: "object",
|
|
199
|
+
properties: {
|
|
200
|
+
allowEventHandlers: {
|
|
201
|
+
type: "boolean",
|
|
202
|
+
description: "Allow inline functions for event handlers (onClick, onChange, etc.)"
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
additionalProperties: false
|
|
206
|
+
}
|
|
207
|
+
]
|
|
208
|
+
},
|
|
209
|
+
create(context) {
|
|
210
|
+
const options = context.options[0] || {};
|
|
211
|
+
const allowEventHandlers = options.allowEventHandlers ?? true;
|
|
212
|
+
const eventHandlerPattern = /^on[A-Z]/;
|
|
213
|
+
return {
|
|
214
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- JSX node type not available in base ESLint types
|
|
215
|
+
JSXAttribute(node) {
|
|
216
|
+
if (node.value && node.value.type === "JSXExpressionContainer" && node.value.expression.type !== "JSXEmptyExpression") {
|
|
217
|
+
const expr = node.value.expression;
|
|
218
|
+
if (expr.type === "ArrowFunctionExpression" || expr.type === "FunctionExpression") {
|
|
219
|
+
if (allowEventHandlers && node.name.type === "JSXIdentifier") {
|
|
220
|
+
const attrName = node.name.name;
|
|
221
|
+
if (eventHandlerPattern.test(attrName)) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
context.report({
|
|
226
|
+
node: expr,
|
|
227
|
+
messageId: "inlineFunction"
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
var no_inline_functions_default = rule3;
|
|
236
|
+
|
|
237
|
+
// src/rules/no-memo-side-effects.ts
|
|
238
|
+
var rule4 = {
|
|
239
|
+
meta: {
|
|
240
|
+
type: "problem",
|
|
241
|
+
docs: {
|
|
242
|
+
description: "Disallow obvious side effects inside $memo callbacks (FICT-M003)",
|
|
243
|
+
recommended: true
|
|
244
|
+
},
|
|
245
|
+
messages: {
|
|
246
|
+
sideEffectInMemo: "Avoid side effects inside $memo. Move mutations/effects outside or wrap them in $effect (FICT-M003)."
|
|
247
|
+
},
|
|
248
|
+
schema: []
|
|
249
|
+
},
|
|
250
|
+
create(context) {
|
|
251
|
+
const hasSideEffect = (node) => {
|
|
252
|
+
let found = false;
|
|
253
|
+
const visit = (n) => {
|
|
254
|
+
if (found) return;
|
|
255
|
+
if (n.type === "AssignmentExpression" || n.type === "UpdateExpression") {
|
|
256
|
+
found = true;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (n.type === "CallExpression") {
|
|
260
|
+
if (n.callee.type === "Identifier" && n.callee.name === "$effect") {
|
|
261
|
+
found = true;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (n.type === "FunctionDeclaration" || n.type === "FunctionExpression" || n.type === "ArrowFunctionExpression") {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
for (const key of Object.keys(n)) {
|
|
269
|
+
if (key === "parent") continue;
|
|
270
|
+
const value = n[key];
|
|
271
|
+
if (!value) continue;
|
|
272
|
+
if (Array.isArray(value)) {
|
|
273
|
+
for (const child of value) {
|
|
274
|
+
if (child && typeof child.type === "string") visit(child);
|
|
275
|
+
}
|
|
276
|
+
} else if (value && typeof value.type === "string") {
|
|
277
|
+
visit(value);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
visit(node);
|
|
282
|
+
return found;
|
|
283
|
+
};
|
|
284
|
+
return {
|
|
285
|
+
CallExpression(node) {
|
|
286
|
+
if (node.callee.type !== "Identifier" || node.callee.name !== "$memo") return;
|
|
287
|
+
const first = node.arguments[0];
|
|
288
|
+
if (first && (first.type === "ArrowFunctionExpression" || first.type === "FunctionExpression")) {
|
|
289
|
+
const body = first.type === "ArrowFunctionExpression" && first.body.type !== "BlockStatement" ? first.body : first.body;
|
|
290
|
+
if (hasSideEffect(body)) {
|
|
291
|
+
context.report({ node, messageId: "sideEffectInMemo" });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
var no_memo_side_effects_default = rule4;
|
|
299
|
+
|
|
300
|
+
// src/rules/no-nested-components.ts
|
|
301
|
+
var rule5 = {
|
|
302
|
+
meta: {
|
|
303
|
+
type: "problem",
|
|
304
|
+
docs: {
|
|
305
|
+
description: "Disallow defining components inside other components (FICT-C003)",
|
|
306
|
+
recommended: true
|
|
307
|
+
},
|
|
308
|
+
messages: {
|
|
309
|
+
nestedComponent: "Do not define a component inside another component. Move {{name}} to module scope to avoid recreating it on every render."
|
|
310
|
+
},
|
|
311
|
+
schema: []
|
|
312
|
+
},
|
|
313
|
+
create(context) {
|
|
314
|
+
const componentStack = [];
|
|
315
|
+
const isUpperCaseName = (name) => !!name && /^[A-Z]/.test(name);
|
|
316
|
+
const getFunctionName = (node) => {
|
|
317
|
+
if (node.id?.name) {
|
|
318
|
+
return node.id.name;
|
|
319
|
+
}
|
|
320
|
+
const parent = node.parent;
|
|
321
|
+
if (parent?.type === "VariableDeclarator" && parent.id.type === "Identifier") {
|
|
322
|
+
return parent.id.name;
|
|
323
|
+
}
|
|
324
|
+
return void 0;
|
|
325
|
+
};
|
|
326
|
+
const hasJSX = (node) => {
|
|
327
|
+
let found = false;
|
|
328
|
+
const visit = (n) => {
|
|
329
|
+
if (found) return;
|
|
330
|
+
const t = n.type;
|
|
331
|
+
if (t === "JSXElement" || t === "JSXFragment") {
|
|
332
|
+
found = true;
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (n.type === "FunctionDeclaration" || n.type === "FunctionExpression" || n.type === "ArrowFunctionExpression") {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
for (const key of Object.keys(n)) {
|
|
339
|
+
if (key === "parent") continue;
|
|
340
|
+
const value = n[key];
|
|
341
|
+
if (!value) continue;
|
|
342
|
+
if (Array.isArray(value)) {
|
|
343
|
+
for (const child of value) {
|
|
344
|
+
if (child && typeof child.type === "string") visit(child);
|
|
345
|
+
}
|
|
346
|
+
} else if (value && typeof value.type === "string") {
|
|
347
|
+
visit(value);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
visit(node);
|
|
352
|
+
return found;
|
|
353
|
+
};
|
|
354
|
+
const isComponentLike = (node) => {
|
|
355
|
+
const name = getFunctionName(node);
|
|
356
|
+
if (isUpperCaseName(name)) return true;
|
|
357
|
+
if (node.type === "ArrowFunctionExpression" && node.body) {
|
|
358
|
+
const bodyType = node.body.type;
|
|
359
|
+
if (bodyType === "JSXElement" || bodyType === "JSXFragment") return true;
|
|
360
|
+
if (bodyType === "BlockStatement" && hasJSX(node.body)) return true;
|
|
361
|
+
}
|
|
362
|
+
if (node.type !== "ArrowFunctionExpression" && node.body && hasJSX(node.body))
|
|
363
|
+
return true;
|
|
364
|
+
return false;
|
|
365
|
+
};
|
|
366
|
+
const enterFunction = (node) => {
|
|
367
|
+
const parentIsComponent = componentStack[componentStack.length - 1] ?? false;
|
|
368
|
+
const currentIsComponent = isComponentLike(node);
|
|
369
|
+
if (parentIsComponent && currentIsComponent) {
|
|
370
|
+
const name = getFunctionName(node) ?? "this component";
|
|
371
|
+
context.report({
|
|
372
|
+
node,
|
|
373
|
+
messageId: "nestedComponent",
|
|
374
|
+
data: { name }
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
componentStack.push(parentIsComponent || currentIsComponent);
|
|
378
|
+
};
|
|
379
|
+
const exitFunction = () => {
|
|
380
|
+
componentStack.pop();
|
|
381
|
+
};
|
|
382
|
+
return {
|
|
383
|
+
FunctionDeclaration: enterFunction,
|
|
384
|
+
"FunctionDeclaration:exit": exitFunction,
|
|
385
|
+
FunctionExpression: enterFunction,
|
|
386
|
+
"FunctionExpression:exit": exitFunction,
|
|
387
|
+
ArrowFunctionExpression: enterFunction,
|
|
388
|
+
"ArrowFunctionExpression:exit": exitFunction
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
var no_nested_components_default = rule5;
|
|
393
|
+
|
|
394
|
+
// src/rules/no-state-in-loop.ts
|
|
395
|
+
var rule6 = {
|
|
396
|
+
meta: {
|
|
397
|
+
type: "problem",
|
|
398
|
+
docs: {
|
|
399
|
+
description: "Disallow $state declarations inside loops",
|
|
400
|
+
recommended: true
|
|
401
|
+
},
|
|
402
|
+
messages: {
|
|
403
|
+
noStateInLoop: "$state should not be declared inside a loop. Move it outside the loop."
|
|
404
|
+
},
|
|
405
|
+
schema: []
|
|
406
|
+
},
|
|
407
|
+
create(context) {
|
|
408
|
+
let loopDepth = 0;
|
|
409
|
+
return {
|
|
410
|
+
ForStatement() {
|
|
411
|
+
loopDepth++;
|
|
412
|
+
},
|
|
413
|
+
"ForStatement:exit"() {
|
|
414
|
+
loopDepth--;
|
|
415
|
+
},
|
|
416
|
+
ForInStatement() {
|
|
417
|
+
loopDepth++;
|
|
418
|
+
},
|
|
419
|
+
"ForInStatement:exit"() {
|
|
420
|
+
loopDepth--;
|
|
421
|
+
},
|
|
422
|
+
ForOfStatement() {
|
|
423
|
+
loopDepth++;
|
|
424
|
+
},
|
|
425
|
+
"ForOfStatement:exit"() {
|
|
426
|
+
loopDepth--;
|
|
427
|
+
},
|
|
428
|
+
WhileStatement() {
|
|
429
|
+
loopDepth++;
|
|
430
|
+
},
|
|
431
|
+
"WhileStatement:exit"() {
|
|
432
|
+
loopDepth--;
|
|
433
|
+
},
|
|
434
|
+
DoWhileStatement() {
|
|
435
|
+
loopDepth++;
|
|
436
|
+
},
|
|
437
|
+
"DoWhileStatement:exit"() {
|
|
438
|
+
loopDepth--;
|
|
439
|
+
},
|
|
440
|
+
CallExpression(node) {
|
|
441
|
+
if (loopDepth > 0 && node.callee.type === "Identifier" && node.callee.name === "$state") {
|
|
442
|
+
context.report({
|
|
443
|
+
node,
|
|
444
|
+
messageId: "noStateInLoop"
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
var no_state_in_loop_default = rule6;
|
|
452
|
+
|
|
453
|
+
// src/rules/no-state-destructure-write.ts
|
|
454
|
+
var rule7 = {
|
|
455
|
+
meta: {
|
|
456
|
+
type: "problem",
|
|
457
|
+
docs: {
|
|
458
|
+
description: "Disallow writing to destructured aliases from $state; write via the original state instead.",
|
|
459
|
+
recommended: true
|
|
460
|
+
},
|
|
461
|
+
messages: {
|
|
462
|
+
noWrite: "Do not write to '{name}' (destructured from $state). Update via the original state object (e.g. state.count++ or immutable update)."
|
|
463
|
+
},
|
|
464
|
+
schema: []
|
|
465
|
+
},
|
|
466
|
+
create(context) {
|
|
467
|
+
const stateVars = /* @__PURE__ */ new Set();
|
|
468
|
+
const destructuredAliases = /* @__PURE__ */ new Set();
|
|
469
|
+
const collectIds = (pattern) => {
|
|
470
|
+
const ids = [];
|
|
471
|
+
const visit = (p) => {
|
|
472
|
+
if (p.type === "Identifier") {
|
|
473
|
+
ids.push(p);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (p.type === "RestElement") {
|
|
477
|
+
visit(p.argument);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (p.type === "ObjectPattern") {
|
|
481
|
+
for (const prop of p.properties) {
|
|
482
|
+
if (prop.type === "Property") {
|
|
483
|
+
if (prop.value.type === "Identifier") {
|
|
484
|
+
ids.push(prop.value);
|
|
485
|
+
} else {
|
|
486
|
+
visit(prop.value);
|
|
487
|
+
}
|
|
488
|
+
} else if (prop.type === "RestElement") {
|
|
489
|
+
visit(prop.argument);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (p.type === "ArrayPattern") {
|
|
495
|
+
for (const el of p.elements) {
|
|
496
|
+
if (!el) continue;
|
|
497
|
+
visit(el);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
visit(pattern);
|
|
502
|
+
return ids;
|
|
503
|
+
};
|
|
504
|
+
const markDestructure = (node) => {
|
|
505
|
+
if (!node.id || node.id.type !== "ObjectPattern" && node.id.type !== "ArrayPattern") return;
|
|
506
|
+
const init = node.init;
|
|
507
|
+
if (!init) return;
|
|
508
|
+
if (init.type === "Identifier" && stateVars.has(init.name)) {
|
|
509
|
+
collectIds(node.id).forEach((id) => destructuredAliases.add(id.name));
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
const isAliasWrite = (name) => destructuredAliases.has(name);
|
|
513
|
+
return {
|
|
514
|
+
VariableDeclarator(node) {
|
|
515
|
+
if (node.init?.type === "CallExpression" && node.init.callee.type === "Identifier" && node.init.callee.name === "$state" && node.id.type === "Identifier") {
|
|
516
|
+
stateVars.add(node.id.name);
|
|
517
|
+
}
|
|
518
|
+
markDestructure(node);
|
|
519
|
+
},
|
|
520
|
+
AssignmentExpression(node) {
|
|
521
|
+
if (node.left.type === "Identifier" && isAliasWrite(node.left.name)) {
|
|
522
|
+
context.report({
|
|
523
|
+
node,
|
|
524
|
+
messageId: "noWrite",
|
|
525
|
+
data: { name: node.left.name }
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
UpdateExpression(node) {
|
|
530
|
+
if (node.argument.type === "Identifier" && isAliasWrite(node.argument.name)) {
|
|
531
|
+
context.report({
|
|
532
|
+
node,
|
|
533
|
+
messageId: "noWrite",
|
|
534
|
+
data: { name: node.argument.name }
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
var no_state_destructure_write_default = rule7;
|
|
542
|
+
|
|
543
|
+
// src/rules/require-component-return.ts
|
|
544
|
+
var rule8 = {
|
|
545
|
+
meta: {
|
|
546
|
+
type: "problem",
|
|
547
|
+
docs: {
|
|
548
|
+
description: "Require component functions to return a value (FICT-C004)",
|
|
549
|
+
recommended: true
|
|
550
|
+
},
|
|
551
|
+
messages: {
|
|
552
|
+
missingReturn: "Component should return JSX or null/undefined (FICT-C004)."
|
|
553
|
+
},
|
|
554
|
+
schema: []
|
|
555
|
+
},
|
|
556
|
+
create(context) {
|
|
557
|
+
const isUpperCaseName = (name) => !!name && /^[A-Z]/.test(name);
|
|
558
|
+
const getFunctionName = (node) => {
|
|
559
|
+
if (node.id?.name) {
|
|
560
|
+
return node.id.name;
|
|
561
|
+
}
|
|
562
|
+
const parent = node.parent;
|
|
563
|
+
if (parent?.type === "VariableDeclarator" && parent.id.type === "Identifier") {
|
|
564
|
+
return parent.id.name;
|
|
565
|
+
}
|
|
566
|
+
return void 0;
|
|
567
|
+
};
|
|
568
|
+
const hasReturn = (node) => {
|
|
569
|
+
const visit = (n) => {
|
|
570
|
+
switch (n.type) {
|
|
571
|
+
case "ReturnStatement":
|
|
572
|
+
return true;
|
|
573
|
+
case "BlockStatement":
|
|
574
|
+
return n.body.some((stmt) => visit(stmt));
|
|
575
|
+
case "IfStatement":
|
|
576
|
+
return visit(n.consequent) || !!n.alternate && visit(n.alternate);
|
|
577
|
+
case "SwitchStatement":
|
|
578
|
+
return n.cases.some((c) => c.consequent.some(visit));
|
|
579
|
+
case "WhileStatement":
|
|
580
|
+
case "DoWhileStatement":
|
|
581
|
+
case "ForStatement":
|
|
582
|
+
case "ForInStatement":
|
|
583
|
+
case "ForOfStatement":
|
|
584
|
+
return visit(n.body);
|
|
585
|
+
case "TryStatement": {
|
|
586
|
+
const t = n;
|
|
587
|
+
return t.block && visit(t.block) || !!t.handler && visit(t.handler.body) || !!t.finalizer && visit(t.finalizer);
|
|
588
|
+
}
|
|
589
|
+
default:
|
|
590
|
+
if (n.type === "FunctionDeclaration" || n.type === "FunctionExpression" || n.type === "ArrowFunctionExpression" || n.type === "ClassDeclaration" || n.type === "ClassExpression") {
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
for (const key of Object.keys(n)) {
|
|
594
|
+
if (key === "parent") continue;
|
|
595
|
+
const value = n[key];
|
|
596
|
+
if (!value) continue;
|
|
597
|
+
if (Array.isArray(value)) {
|
|
598
|
+
if (value.some((child) => child && typeof child.type === "string" && visit(child))) {
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
} else if (value && typeof value.type === "string") {
|
|
602
|
+
if (visit(value)) return true;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
return visit(node);
|
|
609
|
+
};
|
|
610
|
+
const enter = (node) => {
|
|
611
|
+
const name = getFunctionName(node);
|
|
612
|
+
const isComponent = isUpperCaseName(name);
|
|
613
|
+
if (!isComponent) return;
|
|
614
|
+
if (node.type === "ArrowFunctionExpression" && node.body.type !== "BlockStatement") {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const body = node.type === "ArrowFunctionExpression" ? node.body : node.body;
|
|
618
|
+
if (body && body.type === "BlockStatement" && !hasReturn(body)) {
|
|
619
|
+
context.report({
|
|
620
|
+
node,
|
|
621
|
+
messageId: "missingReturn"
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
return {
|
|
626
|
+
FunctionDeclaration: enter,
|
|
627
|
+
FunctionExpression: enter,
|
|
628
|
+
ArrowFunctionExpression: enter
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
var require_component_return_default = rule8;
|
|
633
|
+
|
|
634
|
+
// src/rules/require-list-key.ts
|
|
635
|
+
var rule9 = {
|
|
636
|
+
meta: {
|
|
637
|
+
type: "problem",
|
|
638
|
+
docs: {
|
|
639
|
+
description: "Require key on elements returned from Array.prototype.map in JSX (FICT-J002)",
|
|
640
|
+
recommended: true
|
|
641
|
+
},
|
|
642
|
+
messages: {
|
|
643
|
+
missingKey: "Elements returned from map() in JSX should have a stable key (FICT-J002). Add key={...}."
|
|
644
|
+
},
|
|
645
|
+
schema: []
|
|
646
|
+
},
|
|
647
|
+
create(context) {
|
|
648
|
+
const hasKeyAttribute = (node) => {
|
|
649
|
+
if (node.type === "JSXFragment") {
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
if (node.type !== "JSXElement") return false;
|
|
653
|
+
return (node.openingElement?.attributes ?? []).some((attr) => {
|
|
654
|
+
if (!attr || attr.type !== "JSXAttribute") return false;
|
|
655
|
+
return attr.name?.name === "key";
|
|
656
|
+
});
|
|
657
|
+
};
|
|
658
|
+
const collectReturnedJSX = (expr, out) => {
|
|
659
|
+
const target = expr.type === "ReturnStatement" ? expr.argument ?? void 0 : expr;
|
|
660
|
+
if (!target) return;
|
|
661
|
+
const t = target.type;
|
|
662
|
+
if (t === "JSXElement" || t === "JSXFragment") {
|
|
663
|
+
out.push(target);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (t === "ArrayExpression") {
|
|
667
|
+
for (const el of target.elements) {
|
|
668
|
+
if (!el || el.type === "SpreadElement") continue;
|
|
669
|
+
collectReturnedJSX(el, out);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (t === "ConditionalExpression") {
|
|
673
|
+
collectReturnedJSX(target.consequent, out);
|
|
674
|
+
collectReturnedJSX(target.alternate, out);
|
|
675
|
+
}
|
|
676
|
+
if (t === "LogicalExpression") {
|
|
677
|
+
collectReturnedJSX(target.right, out);
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
const mapReturnsJSXWithoutKey = (call) => {
|
|
681
|
+
const callback = call.arguments[0];
|
|
682
|
+
if (!callback) return null;
|
|
683
|
+
if (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression") {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
const returned = [];
|
|
687
|
+
if (callback.type === "ArrowFunctionExpression") {
|
|
688
|
+
const body = callback.body;
|
|
689
|
+
const bodyType = body.type;
|
|
690
|
+
if (bodyType === "JSXElement" || bodyType === "JSXFragment") {
|
|
691
|
+
returned.push(body);
|
|
692
|
+
} else if (bodyType === "BlockStatement") {
|
|
693
|
+
for (const stmt of body.body) {
|
|
694
|
+
if (stmt.type === "ReturnStatement") {
|
|
695
|
+
collectReturnedJSX(stmt, returned);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
} else {
|
|
699
|
+
collectReturnedJSX(body, returned);
|
|
700
|
+
}
|
|
701
|
+
} else {
|
|
702
|
+
for (const stmt of callback.body.body) {
|
|
703
|
+
if (stmt.type === "ReturnStatement") {
|
|
704
|
+
collectReturnedJSX(stmt, returned);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const missing = returned.find((node) => !hasKeyAttribute(node));
|
|
709
|
+
return missing ?? null;
|
|
710
|
+
};
|
|
711
|
+
return {
|
|
712
|
+
CallExpression(node) {
|
|
713
|
+
if (node.callee.type === "MemberExpression" && !node.callee.computed && node.callee.property.type === "Identifier" && node.callee.property.name === "map") {
|
|
714
|
+
const offending = mapReturnsJSXWithoutKey(node);
|
|
715
|
+
if (offending) {
|
|
716
|
+
context.report({
|
|
717
|
+
node: offending,
|
|
718
|
+
messageId: "missingKey"
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
var require_list_key_default = rule9;
|
|
727
|
+
|
|
728
|
+
// src/index.ts
|
|
729
|
+
var plugin = {
|
|
730
|
+
meta: {
|
|
731
|
+
name: "eslint-plugin-fict",
|
|
732
|
+
version: "0.0.1"
|
|
733
|
+
},
|
|
734
|
+
rules: {
|
|
735
|
+
"no-state-in-loop": no_state_in_loop_default,
|
|
736
|
+
"no-direct-mutation": no_direct_mutation_default,
|
|
737
|
+
"no-empty-effect": no_empty_effect_default,
|
|
738
|
+
"no-inline-functions": no_inline_functions_default,
|
|
739
|
+
"no-state-destructure-write": no_state_destructure_write_default,
|
|
740
|
+
"no-nested-components": no_nested_components_default,
|
|
741
|
+
"require-list-key": require_list_key_default,
|
|
742
|
+
"no-memo-side-effects": no_memo_side_effects_default,
|
|
743
|
+
"require-component-return": require_component_return_default
|
|
744
|
+
},
|
|
745
|
+
configs: {
|
|
746
|
+
recommended: {
|
|
747
|
+
plugins: ["fict"],
|
|
748
|
+
rules: {
|
|
749
|
+
"fict/no-state-in-loop": "error",
|
|
750
|
+
"fict/no-direct-mutation": "warn",
|
|
751
|
+
"fict/no-empty-effect": "warn",
|
|
752
|
+
// FICT-E001
|
|
753
|
+
"fict/no-inline-functions": "warn",
|
|
754
|
+
// FICT-X003
|
|
755
|
+
"fict/no-state-destructure-write": "error",
|
|
756
|
+
"fict/no-nested-components": "error",
|
|
757
|
+
// FICT-C003
|
|
758
|
+
"fict/require-list-key": "error",
|
|
759
|
+
// FICT-J002
|
|
760
|
+
"fict/no-memo-side-effects": "warn",
|
|
761
|
+
// FICT-M003
|
|
762
|
+
"fict/require-component-return": "warn"
|
|
763
|
+
// FICT-C004
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
var index_default = plugin;
|
|
769
|
+
export {
|
|
770
|
+
index_default as default,
|
|
771
|
+
no_direct_mutation_default as noDirectMutation,
|
|
772
|
+
no_empty_effect_default as noEmptyEffect,
|
|
773
|
+
no_inline_functions_default as noInlineFunctions,
|
|
774
|
+
no_memo_side_effects_default as noMemoSideEffects,
|
|
775
|
+
no_nested_components_default as noNestedComponents,
|
|
776
|
+
no_state_destructure_write_default as noStateDestructureWrite,
|
|
777
|
+
no_state_in_loop_default as noStateInLoop,
|
|
778
|
+
require_component_return_default as requireComponentReturn,
|
|
779
|
+
require_list_key_default as requireListKey
|
|
780
|
+
};
|