@slip-stream-kit/eslint-plugin 0.1.15 → 0.1.19
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/dist/index.js +475 -51
- package/dist/index.js.map +4 -4
- package/dist/rules/{component-arrow-function.d.ts → component-arrow-function/component-arrow-function.d.ts} +0 -1
- package/dist/rules/component-arrow-function/index.d.ts +1 -0
- package/dist/rules/{component-file-order.d.ts → component-file-order/component-file-order.d.ts} +0 -1
- package/dist/rules/component-file-order/index.d.ts +1 -0
- package/dist/rules/max-components-per-file/index.d.ts +1 -0
- package/dist/rules/max-components-per-file/max-components-per-file.d.ts +2 -0
- package/dist/rules/max-jsx-return-size/index.d.ts +1 -0
- package/dist/rules/max-jsx-return-size/max-jsx-return-size.d.ts +2 -0
- package/dist/rules/props-destructuring-blank-line/index.d.ts +1 -0
- package/dist/rules/{props-destructuring-blank-line.d.ts → props-destructuring-blank-line/props-destructuring-blank-line.d.ts} +0 -1
- package/dist/rules/props-destructuring-newline/index.d.ts +1 -0
- package/dist/rules/{props-destructuring-newline.d.ts → props-destructuring-newline/props-destructuring-newline.d.ts} +0 -1
- package/dist/rules/props-type-name/index.d.ts +1 -0
- package/dist/rules/props-type-name/props-type-name.d.ts +2 -0
- package/dist/rules/props-type-reference/index.d.ts +1 -0
- package/dist/rules/{props-type-reference.d.ts → props-type-reference/props-type-reference.d.ts} +0 -1
- package/dist/rules/require-component-stories/index.d.ts +1 -0
- package/dist/rules/{require-component-stories.d.ts → require-component-stories/require-component-stories.d.ts} +0 -1
- package/dist/utils/component.d.ts +30 -0
- package/package.json +3 -3
- package/readme.md +220 -9
package/dist/index.js
CHANGED
|
@@ -40,18 +40,18 @@ var getComponentName = (node) => {
|
|
|
40
40
|
}
|
|
41
41
|
return null;
|
|
42
42
|
};
|
|
43
|
-
var
|
|
43
|
+
var collectOwnReturnArguments = (node) => {
|
|
44
44
|
if (node.body.type !== "BlockStatement") {
|
|
45
|
-
return
|
|
45
|
+
return [node.body];
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
const args = [];
|
|
48
48
|
const visit = (current) => {
|
|
49
|
-
if (
|
|
49
|
+
if (!current || NESTED_SCOPES.has(current.type)) {
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
52
52
|
if (current.type === "ReturnStatement") {
|
|
53
|
-
if (
|
|
54
|
-
|
|
53
|
+
if (current.argument) {
|
|
54
|
+
args.push(current.argument);
|
|
55
55
|
}
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
@@ -81,7 +81,10 @@ var returnsJsx = (node) => {
|
|
|
81
81
|
}
|
|
82
82
|
};
|
|
83
83
|
node.body.body.forEach(visit);
|
|
84
|
-
return
|
|
84
|
+
return args;
|
|
85
|
+
};
|
|
86
|
+
var returnsJsx = (node) => {
|
|
87
|
+
return collectOwnReturnArguments(node).some(isJsxNode);
|
|
85
88
|
};
|
|
86
89
|
var isComponent = (node) => {
|
|
87
90
|
const name = getComponentName(node);
|
|
@@ -113,6 +116,39 @@ var getComponentFunction = (node) => {
|
|
|
113
116
|
}
|
|
114
117
|
return null;
|
|
115
118
|
};
|
|
119
|
+
var getAnnotatedParam = (param) => {
|
|
120
|
+
return param.type === "AssignmentPattern" ? param.left : param;
|
|
121
|
+
};
|
|
122
|
+
var getPropsTypeNameFromFunction = (node) => {
|
|
123
|
+
const firstParam = node.params[0];
|
|
124
|
+
if (!firstParam) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const inner = getAnnotatedParam(firstParam).typeAnnotation?.typeAnnotation;
|
|
128
|
+
if (inner?.type !== "TSTypeReference" || inner.typeName?.type !== "Identifier") {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
return inner.typeName.name ?? null;
|
|
132
|
+
};
|
|
133
|
+
var getComponentPropsTypeName = (node) => {
|
|
134
|
+
if (!node) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (node.type === "FunctionDeclaration") {
|
|
138
|
+
return isComponent(node) ? getPropsTypeNameFromFunction(node) : null;
|
|
139
|
+
}
|
|
140
|
+
if (node.type === "VariableDeclaration") {
|
|
141
|
+
for (const declaration of node.declarations) {
|
|
142
|
+
const fn2 = getComponentFunction(declaration.init);
|
|
143
|
+
if (fn2 && isComponent(fn2)) {
|
|
144
|
+
return getPropsTypeNameFromFunction(fn2);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const fn = getComponentFunction(node);
|
|
150
|
+
return fn && isComponent(fn) ? getPropsTypeNameFromFunction(fn) : null;
|
|
151
|
+
};
|
|
116
152
|
var unwrapExport = (statement) => {
|
|
117
153
|
if (statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration") {
|
|
118
154
|
return statement.declaration ?? null;
|
|
@@ -193,7 +229,7 @@ var matchesAnyGlob = (filename, patterns) => {
|
|
|
193
229
|
});
|
|
194
230
|
};
|
|
195
231
|
|
|
196
|
-
// src/rules/component-arrow-function.ts
|
|
232
|
+
// src/rules/component-arrow-function/component-arrow-function.ts
|
|
197
233
|
var ANONYMOUS_NAME = "component";
|
|
198
234
|
var reportName = (fn) => {
|
|
199
235
|
return getComponentName(fn) ?? ANONYMOUS_NAME;
|
|
@@ -239,7 +275,7 @@ var componentArrowFunction = {
|
|
|
239
275
|
docs: {
|
|
240
276
|
description: "Enforce that React components are declared as arrow functions, not `function` declarations or function expressions.",
|
|
241
277
|
recommended: true,
|
|
242
|
-
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin"
|
|
278
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#component-arrow-function"
|
|
243
279
|
},
|
|
244
280
|
schema: [
|
|
245
281
|
{
|
|
@@ -284,7 +320,7 @@ var componentArrowFunction = {
|
|
|
284
320
|
}
|
|
285
321
|
};
|
|
286
322
|
|
|
287
|
-
// src/rules/component-file-order.ts
|
|
323
|
+
// src/rules/component-file-order/component-file-order.ts
|
|
288
324
|
var PROPS_SUFFIX = "Props";
|
|
289
325
|
var getDirectivePrologueCount = (body) => {
|
|
290
326
|
let count = 0;
|
|
@@ -297,7 +333,7 @@ var getDirectivePrologueCount = (body) => {
|
|
|
297
333
|
}
|
|
298
334
|
return count;
|
|
299
335
|
};
|
|
300
|
-
var
|
|
336
|
+
var getTypeDeclName = (node) => {
|
|
301
337
|
if (!node) {
|
|
302
338
|
return null;
|
|
303
339
|
}
|
|
@@ -305,13 +341,12 @@ var getPropsTypeName = (node) => {
|
|
|
305
341
|
if (named.type !== "TSInterfaceDeclaration" && named.type !== "TSTypeAliasDeclaration") {
|
|
306
342
|
return null;
|
|
307
343
|
}
|
|
308
|
-
|
|
309
|
-
return name?.endsWith(PROPS_SUFFIX) ? name : null;
|
|
344
|
+
return named.id?.name ?? null;
|
|
310
345
|
};
|
|
311
346
|
var collectTopLevel = (body) => {
|
|
312
347
|
const importIndices = [];
|
|
313
348
|
const components = [];
|
|
314
|
-
const
|
|
349
|
+
const declIndexByName = /* @__PURE__ */ new Map();
|
|
315
350
|
const importedNames = /* @__PURE__ */ new Set();
|
|
316
351
|
body.forEach((statement, index) => {
|
|
317
352
|
if (statement.type === "ImportDeclaration") {
|
|
@@ -322,21 +357,24 @@ var collectTopLevel = (body) => {
|
|
|
322
357
|
return;
|
|
323
358
|
}
|
|
324
359
|
const declaration = unwrapExport(statement);
|
|
325
|
-
const
|
|
326
|
-
if (
|
|
327
|
-
|
|
360
|
+
const declName = getTypeDeclName(declaration);
|
|
361
|
+
if (declName !== null && !declIndexByName.has(declName)) {
|
|
362
|
+
declIndexByName.set(declName, index);
|
|
328
363
|
}
|
|
329
364
|
if (declaresComponent(declaration)) {
|
|
330
|
-
|
|
365
|
+
const name = getDeclaredComponentName(declaration);
|
|
366
|
+
const propsName = getComponentPropsTypeName(declaration) ?? (name === null ? null : `${name}${PROPS_SUFFIX}`);
|
|
367
|
+
components.push({ index, name, propsName });
|
|
331
368
|
}
|
|
332
369
|
});
|
|
333
|
-
return { importIndices, components,
|
|
370
|
+
return { importIndices, components, declIndexByName, importedNames };
|
|
334
371
|
};
|
|
335
372
|
var hasStrayBefore = (body, boundary, directiveCount, skipIndex) => {
|
|
336
373
|
return body.some((statement, index) => {
|
|
337
374
|
return index < boundary && index >= directiveCount && index !== skipIndex && statement.type !== "ImportDeclaration";
|
|
338
375
|
});
|
|
339
376
|
};
|
|
377
|
+
var ANONYMOUS_COMPONENT = "component";
|
|
340
378
|
var findImportOrderViolations = (importIndices, importBoundary) => {
|
|
341
379
|
return importIndices.filter((importIndex) => {
|
|
342
380
|
return importIndex > importBoundary;
|
|
@@ -344,15 +382,26 @@ var findImportOrderViolations = (importIndices, importBoundary) => {
|
|
|
344
382
|
return { index: importIndex, messageId: "importsFirst" };
|
|
345
383
|
});
|
|
346
384
|
};
|
|
347
|
-
var findAdjacencyViolations = (components,
|
|
385
|
+
var findAdjacencyViolations = (components, declIndexByName) => {
|
|
348
386
|
const violations = [];
|
|
387
|
+
const referenceCount = /* @__PURE__ */ new Map();
|
|
388
|
+
for (const component of components) {
|
|
389
|
+
if (component.propsName !== null) {
|
|
390
|
+
referenceCount.set(component.propsName, (referenceCount.get(component.propsName) ?? 0) + 1);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
349
393
|
for (const component of components) {
|
|
350
|
-
|
|
394
|
+
const propsName = component.propsName;
|
|
395
|
+
if (propsName === null || (referenceCount.get(propsName) ?? 0) > 1) {
|
|
351
396
|
continue;
|
|
352
397
|
}
|
|
353
|
-
const propsIndex =
|
|
398
|
+
const propsIndex = declIndexByName.get(propsName);
|
|
354
399
|
if (propsIndex !== void 0 && propsIndex !== component.index - 1) {
|
|
355
|
-
violations.push({
|
|
400
|
+
violations.push({
|
|
401
|
+
index: propsIndex,
|
|
402
|
+
messageId: "interfaceImmediatelyBeforeComponent",
|
|
403
|
+
data: { interface: propsName, component: component.name ?? ANONYMOUS_COMPONENT }
|
|
404
|
+
});
|
|
356
405
|
}
|
|
357
406
|
}
|
|
358
407
|
return violations;
|
|
@@ -363,14 +412,24 @@ var findAnchorViolations = (body, directiveCount, importIndices, first, firstPro
|
|
|
363
412
|
return importIndex < firstPropsIndex;
|
|
364
413
|
});
|
|
365
414
|
if (importsBeforeInterface && hasStrayBefore(body, firstPropsIndex, directiveCount, first.index)) {
|
|
366
|
-
violations.push({
|
|
415
|
+
violations.push({
|
|
416
|
+
index: firstPropsIndex,
|
|
417
|
+
messageId: "interfaceImmediatelyAfterImports",
|
|
418
|
+
// `firstPropsIndex !== undefined` implies `firstPropsName !== null` (it is derived from it).
|
|
419
|
+
data: { interface: firstPropsName, component: first.name ?? ANONYMOUS_COMPONENT }
|
|
420
|
+
});
|
|
367
421
|
}
|
|
368
422
|
const firstPropsImported = firstPropsIndex === void 0 && firstPropsName !== null && importedNames.has(firstPropsName);
|
|
369
423
|
const importsBeforeComponent = importIndices.every((importIndex) => {
|
|
370
424
|
return importIndex < first.index;
|
|
371
425
|
});
|
|
372
426
|
if (firstPropsImported && importsBeforeComponent && hasStrayBefore(body, first.index, directiveCount)) {
|
|
373
|
-
violations.push({
|
|
427
|
+
violations.push({
|
|
428
|
+
index: first.index,
|
|
429
|
+
messageId: "componentImmediatelyAfterImports",
|
|
430
|
+
// `firstPropsImported` requires `firstPropsName !== null`.
|
|
431
|
+
data: { interface: firstPropsName, component: first.name ?? ANONYMOUS_COMPONENT }
|
|
432
|
+
});
|
|
374
433
|
}
|
|
375
434
|
return violations;
|
|
376
435
|
};
|
|
@@ -380,7 +439,7 @@ var componentFileOrder = {
|
|
|
380
439
|
docs: {
|
|
381
440
|
description: "Enforce a strict top-level order in React component files: imports first, then \u2014 for each component \u2014 its props interface/type declared immediately before the component.",
|
|
382
441
|
recommended: true,
|
|
383
|
-
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin"
|
|
442
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#component-file-order"
|
|
384
443
|
},
|
|
385
444
|
schema: [
|
|
386
445
|
{
|
|
@@ -401,10 +460,14 @@ var componentFileOrder = {
|
|
|
401
460
|
}
|
|
402
461
|
],
|
|
403
462
|
messages: {
|
|
463
|
+
// Generic on purpose: fires on a misplaced import, and the constraint is "before *every*
|
|
464
|
+
// component", so naming a single component would mislead in a multi-component file.
|
|
404
465
|
importsFirst: "Imports must come before the component interface and declaration.",
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
466
|
+
// "props type" (not "interface"): the rule matches both `interface` and `type` alias props
|
|
467
|
+
// declarations, so the neutral term reads correctly for either.
|
|
468
|
+
interfaceImmediatelyBeforeComponent: "Declare the props type `{{interface}}` immediately before component `{{component}}` (no declarations between them).",
|
|
469
|
+
interfaceImmediatelyAfterImports: "Declare the props type `{{interface}}` (for component `{{component}}`) immediately after the imports, with no other declarations in between.",
|
|
470
|
+
componentImmediatelyAfterImports: "Props type `{{interface}}` is imported, so declare component `{{component}}` immediately after the imports, with no other declarations in between."
|
|
408
471
|
}
|
|
409
472
|
},
|
|
410
473
|
create(context) {
|
|
@@ -421,17 +484,17 @@ var componentFileOrder = {
|
|
|
421
484
|
Program(program) {
|
|
422
485
|
const body = program.body;
|
|
423
486
|
const directiveCount = getDirectivePrologueCount(body);
|
|
424
|
-
const { importIndices, components,
|
|
487
|
+
const { importIndices, components, declIndexByName, importedNames } = collectTopLevel(body);
|
|
425
488
|
if (components.length === 0) {
|
|
426
489
|
return;
|
|
427
490
|
}
|
|
428
491
|
const first = components[0];
|
|
429
|
-
const firstPropsName = first.
|
|
430
|
-
const firstPropsIndex = firstPropsName === null ? void 0 :
|
|
492
|
+
const firstPropsName = first.propsName;
|
|
493
|
+
const firstPropsIndex = firstPropsName === null ? void 0 : declIndexByName.get(firstPropsName);
|
|
431
494
|
const importBoundary = Math.min(first.index, firstPropsIndex ?? first.index);
|
|
432
495
|
const violations = [
|
|
433
496
|
...findImportOrderViolations(importIndices, importBoundary),
|
|
434
|
-
...findAdjacencyViolations(components,
|
|
497
|
+
...findAdjacencyViolations(components, declIndexByName),
|
|
435
498
|
...findAnchorViolations(
|
|
436
499
|
body,
|
|
437
500
|
directiveCount,
|
|
@@ -443,14 +506,274 @@ var componentFileOrder = {
|
|
|
443
506
|
)
|
|
444
507
|
];
|
|
445
508
|
for (const violation of violations) {
|
|
446
|
-
context.report({ node: body[violation.index], messageId: violation.messageId });
|
|
509
|
+
context.report({ node: body[violation.index], messageId: violation.messageId, data: violation.data });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
// src/rules/max-components-per-file/max-components-per-file.ts
|
|
517
|
+
var DEFAULT_MAX_COMPONENTS = 4;
|
|
518
|
+
var ANONYMOUS_NAME2 = "component";
|
|
519
|
+
var componentFunctionsIn = (declaration) => {
|
|
520
|
+
if (declaration.type === "FunctionDeclaration") {
|
|
521
|
+
return isComponent(declaration) ? [declaration] : [];
|
|
522
|
+
}
|
|
523
|
+
if (declaration.type === "VariableDeclaration") {
|
|
524
|
+
return declaration.declarations.map((declarator) => {
|
|
525
|
+
return getComponentFunction(declarator.init);
|
|
526
|
+
}).filter((fn2) => {
|
|
527
|
+
return fn2 !== null && isComponent(fn2);
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
const fn = getComponentFunction(declaration);
|
|
531
|
+
return fn && isComponent(fn) ? [fn] : [];
|
|
532
|
+
};
|
|
533
|
+
var collectComponents = (body) => {
|
|
534
|
+
return body.flatMap((statement) => {
|
|
535
|
+
const declaration = unwrapExport(statement);
|
|
536
|
+
return declaration ? componentFunctionsIn(declaration) : [];
|
|
537
|
+
});
|
|
538
|
+
};
|
|
539
|
+
var maxComponentsPerFile = {
|
|
540
|
+
meta: {
|
|
541
|
+
type: "suggestion",
|
|
542
|
+
docs: {
|
|
543
|
+
description: "Limit the number of React components declared in a single file; move extra components into their own files.",
|
|
544
|
+
recommended: true,
|
|
545
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#max-components-per-file"
|
|
546
|
+
},
|
|
547
|
+
schema: [
|
|
548
|
+
{
|
|
549
|
+
type: "object",
|
|
550
|
+
properties: {
|
|
551
|
+
maxComponents: {
|
|
552
|
+
type: "integer",
|
|
553
|
+
minimum: 1,
|
|
554
|
+
description: "Maximum number of component declarations a single file may contain before the rule reports."
|
|
555
|
+
},
|
|
556
|
+
paths: {
|
|
557
|
+
type: "array",
|
|
558
|
+
items: { type: "string" },
|
|
559
|
+
description: "Optional glob patterns. When provided, the rule only runs for files whose path matches one of them."
|
|
560
|
+
},
|
|
561
|
+
ignore: {
|
|
562
|
+
type: "array",
|
|
563
|
+
items: { type: "string" },
|
|
564
|
+
description: "Optional glob patterns. The rule is skipped for files whose path matches one of them, even if it also matches `paths`."
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
additionalProperties: false
|
|
568
|
+
}
|
|
569
|
+
],
|
|
570
|
+
messages: {
|
|
571
|
+
// File-scoped problem → reported once, anchored to the first component over
|
|
572
|
+
// the limit so a human or AI fix loop has a concrete node to move out.
|
|
573
|
+
tooManyComponents: "This file declares {{count}} components (max {{max}}); move components such as {{name}} into separate files."
|
|
574
|
+
}
|
|
575
|
+
},
|
|
576
|
+
create(context) {
|
|
577
|
+
const options = context.options[0] ?? {};
|
|
578
|
+
const max = options.maxComponents ?? DEFAULT_MAX_COMPONENTS;
|
|
579
|
+
const paths = options.paths ?? [];
|
|
580
|
+
const ignore = options.ignore ?? [];
|
|
581
|
+
if (ignore.length > 0 && matchesAnyGlob(context.filename, ignore)) {
|
|
582
|
+
return {};
|
|
583
|
+
}
|
|
584
|
+
if (paths.length > 0 && !matchesAnyGlob(context.filename, paths)) {
|
|
585
|
+
return {};
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
Program(program) {
|
|
589
|
+
const components = collectComponents(program.body);
|
|
590
|
+
if (components.length <= max) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const offender = components[max];
|
|
594
|
+
if (!offender) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const name = getComponentName(offender) ?? ANONYMOUS_NAME2;
|
|
598
|
+
context.report({
|
|
599
|
+
node: offender,
|
|
600
|
+
messageId: "tooManyComponents",
|
|
601
|
+
data: { count: components.length, max, name }
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
// src/rules/max-jsx-return-size/max-jsx-return-size.ts
|
|
609
|
+
var DEFAULT_MAX_ELEMENTS = 20;
|
|
610
|
+
var ANONYMOUS_NAME3 = "component";
|
|
611
|
+
var isNode = (value) => {
|
|
612
|
+
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
613
|
+
};
|
|
614
|
+
var childNodes = (node, visitorKeys) => {
|
|
615
|
+
const children = [];
|
|
616
|
+
const keys = visitorKeys[node.type] ?? Object.keys(node);
|
|
617
|
+
for (const key of keys) {
|
|
618
|
+
if (key === "parent") {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const value = node[key];
|
|
622
|
+
if (Array.isArray(value)) {
|
|
623
|
+
children.push(...value.filter(isNode));
|
|
624
|
+
} else if (isNode(value)) {
|
|
625
|
+
children.push(value);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return children;
|
|
629
|
+
};
|
|
630
|
+
var countJsxElements = (node, visitorKeys) => {
|
|
631
|
+
const self = node.type === "JSXElement" ? 1 : 0;
|
|
632
|
+
return childNodes(node, visitorKeys).reduce((total, child) => {
|
|
633
|
+
return total + countJsxElements(child, visitorKeys);
|
|
634
|
+
}, self);
|
|
635
|
+
};
|
|
636
|
+
var jsxNameToString = (node) => {
|
|
637
|
+
const name = node;
|
|
638
|
+
switch (name?.type) {
|
|
639
|
+
case "JSXIdentifier":
|
|
640
|
+
return typeof name.name === "string" ? name.name : "element";
|
|
641
|
+
case "JSXMemberExpression":
|
|
642
|
+
return `${jsxNameToString(name.object)}.${jsxNameToString(name.property)}`;
|
|
643
|
+
case "JSXNamespacedName":
|
|
644
|
+
return `${jsxNameToString(name.namespace)}:${jsxNameToString(name.name)}`;
|
|
645
|
+
default:
|
|
646
|
+
return "element";
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
var topLevelElements = (root, visitorKeys) => {
|
|
650
|
+
const elements = [];
|
|
651
|
+
const walk = (node, isRoot) => {
|
|
652
|
+
if (!isRoot && node.type === "JSXElement") {
|
|
653
|
+
elements.push(node);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
for (const child of childNodes(node, visitorKeys)) {
|
|
657
|
+
walk(child, false);
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
walk(root, true);
|
|
661
|
+
return elements;
|
|
662
|
+
};
|
|
663
|
+
var largestBlock = (root, visitorKeys) => {
|
|
664
|
+
let best = null;
|
|
665
|
+
for (const element2 of topLevelElements(root, visitorKeys)) {
|
|
666
|
+
const count = countJsxElements(element2, visitorKeys);
|
|
667
|
+
if (!best || count > best.count) {
|
|
668
|
+
best = { node: element2, count };
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (!best) {
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
const element = best.node;
|
|
675
|
+
return { name: jsxNameToString(element.openingElement?.name), line: element.loc?.start.line ?? 0, count: best.count };
|
|
676
|
+
};
|
|
677
|
+
var componentFunctionsIn2 = (declaration) => {
|
|
678
|
+
if (declaration.type === "FunctionDeclaration") {
|
|
679
|
+
return isComponent(declaration) ? [declaration] : [];
|
|
680
|
+
}
|
|
681
|
+
if (declaration.type === "VariableDeclaration") {
|
|
682
|
+
return declaration.declarations.map((declarator) => {
|
|
683
|
+
return getComponentFunction(declarator.init);
|
|
684
|
+
}).filter((fn2) => {
|
|
685
|
+
return fn2 !== null && isComponent(fn2);
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
const fn = getComponentFunction(declaration);
|
|
689
|
+
return fn && isComponent(fn) ? [fn] : [];
|
|
690
|
+
};
|
|
691
|
+
var collectComponents2 = (body) => {
|
|
692
|
+
return body.flatMap((statement) => {
|
|
693
|
+
const declaration = unwrapExport(statement);
|
|
694
|
+
return declaration ? componentFunctionsIn2(declaration) : [];
|
|
695
|
+
});
|
|
696
|
+
};
|
|
697
|
+
var maxJsxReturnSize = {
|
|
698
|
+
meta: {
|
|
699
|
+
type: "suggestion",
|
|
700
|
+
docs: {
|
|
701
|
+
description: "Warn when a component return renders too many JSX elements; extract parts into variables or sub-components.",
|
|
702
|
+
recommended: true,
|
|
703
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#max-jsx-return-size"
|
|
704
|
+
},
|
|
705
|
+
schema: [
|
|
706
|
+
{
|
|
707
|
+
type: "object",
|
|
708
|
+
properties: {
|
|
709
|
+
maxElements: {
|
|
710
|
+
type: "integer",
|
|
711
|
+
minimum: 1,
|
|
712
|
+
description: "Maximum number of JSX elements a single return may render before the rule reports."
|
|
713
|
+
},
|
|
714
|
+
paths: {
|
|
715
|
+
type: "array",
|
|
716
|
+
items: { type: "string" },
|
|
717
|
+
description: "Optional glob patterns. When provided, the rule only runs for files whose path matches one of them."
|
|
718
|
+
},
|
|
719
|
+
ignore: {
|
|
720
|
+
type: "array",
|
|
721
|
+
items: { type: "string" },
|
|
722
|
+
description: "Optional glob patterns. The rule is skipped for files whose path matches one of them, even if it also matches `paths`."
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
additionalProperties: false
|
|
726
|
+
}
|
|
727
|
+
],
|
|
728
|
+
messages: {
|
|
729
|
+
// Points at the single biggest block so a human or AI fix loop knows
|
|
730
|
+
// exactly what to lift out.
|
|
731
|
+
tooManyElements: "{{name}} renders {{count}} JSX elements in one return (max {{max}}). Extract the largest block \u2014 <{{largest}}> at line {{line}} ({{largestCount}} elements) \u2014 into a variable or a sub-component.",
|
|
732
|
+
// Fallback when no single block dominates (e.g. many flat siblings): there is
|
|
733
|
+
// nothing meaningful to point at, so advise splitting.
|
|
734
|
+
tooManyElementsFlat: "{{name}} renders {{count}} JSX elements in one return (max {{max}}). Split it into smaller sub-components or extract groups of elements into variables."
|
|
735
|
+
}
|
|
736
|
+
},
|
|
737
|
+
create(context) {
|
|
738
|
+
const options = context.options[0] ?? {};
|
|
739
|
+
const max = options.maxElements ?? DEFAULT_MAX_ELEMENTS;
|
|
740
|
+
const paths = options.paths ?? [];
|
|
741
|
+
const ignore = options.ignore ?? [];
|
|
742
|
+
if (ignore.length > 0 && matchesAnyGlob(context.filename, ignore)) {
|
|
743
|
+
return {};
|
|
744
|
+
}
|
|
745
|
+
if (paths.length > 0 && !matchesAnyGlob(context.filename, paths)) {
|
|
746
|
+
return {};
|
|
747
|
+
}
|
|
748
|
+
const visitorKeys = context.sourceCode.visitorKeys;
|
|
749
|
+
const checkComponent = (fn) => {
|
|
750
|
+
const name = getComponentName(fn) ?? ANONYMOUS_NAME3;
|
|
751
|
+
for (const argument of collectOwnReturnArguments(fn)) {
|
|
752
|
+
const count = countJsxElements(argument, visitorKeys);
|
|
753
|
+
if (count <= max) {
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
const largest = largestBlock(argument, visitorKeys);
|
|
757
|
+
if (largest && largest.count >= 2) {
|
|
758
|
+
context.report({
|
|
759
|
+
node: argument,
|
|
760
|
+
messageId: "tooManyElements",
|
|
761
|
+
data: { count, max, name, largest: largest.name, line: largest.line, largestCount: largest.count }
|
|
762
|
+
});
|
|
763
|
+
} else {
|
|
764
|
+
context.report({ node: argument, messageId: "tooManyElementsFlat", data: { count, max, name } });
|
|
447
765
|
}
|
|
448
766
|
}
|
|
449
767
|
};
|
|
768
|
+
return {
|
|
769
|
+
Program(program) {
|
|
770
|
+
collectComponents2(program.body).forEach(checkComponent);
|
|
771
|
+
}
|
|
772
|
+
};
|
|
450
773
|
}
|
|
451
774
|
};
|
|
452
775
|
|
|
453
|
-
// src/rules/props-destructuring-blank-line.ts
|
|
776
|
+
// src/rules/props-destructuring-blank-line/props-destructuring-blank-line.ts
|
|
454
777
|
var isPropsDestructuring = (statement) => {
|
|
455
778
|
if (statement.type !== "VariableDeclaration") {
|
|
456
779
|
return false;
|
|
@@ -465,7 +788,7 @@ var propsDestructuringBlankLine = {
|
|
|
465
788
|
docs: {
|
|
466
789
|
description: "Require a blank line after the `const { ... } = props` destructuring statement at the top of a React component body.",
|
|
467
790
|
recommended: true,
|
|
468
|
-
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin"
|
|
791
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#props-destructuring-blank-line"
|
|
469
792
|
},
|
|
470
793
|
fixable: "whitespace",
|
|
471
794
|
schema: [],
|
|
@@ -513,7 +836,7 @@ var propsDestructuringBlankLine = {
|
|
|
513
836
|
}
|
|
514
837
|
};
|
|
515
838
|
|
|
516
|
-
// src/rules/props-destructuring-newline.ts
|
|
839
|
+
// src/rules/props-destructuring-newline/props-destructuring-newline.ts
|
|
517
840
|
var collectBoundNames = (node, names) => {
|
|
518
841
|
if (!node) {
|
|
519
842
|
return;
|
|
@@ -547,7 +870,7 @@ var propsDestructuringNewline = {
|
|
|
547
870
|
docs: {
|
|
548
871
|
description: "Require React components to accept a single props parameter and destructure it on its own line in the body, rather than destructuring inline in the parameter list.",
|
|
549
872
|
recommended: true,
|
|
550
|
-
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin"
|
|
873
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#props-destructuring-newline"
|
|
551
874
|
},
|
|
552
875
|
fixable: "code",
|
|
553
876
|
schema: [],
|
|
@@ -624,18 +947,87 @@ ${baseIndent}}`
|
|
|
624
947
|
}
|
|
625
948
|
};
|
|
626
949
|
|
|
627
|
-
// src/rules/props-type-
|
|
628
|
-
var
|
|
629
|
-
var
|
|
630
|
-
|
|
950
|
+
// src/rules/props-type-name/props-type-name.ts
|
|
951
|
+
var PROPS_SUFFIX2 = "Props";
|
|
952
|
+
var propsTypeName = {
|
|
953
|
+
meta: {
|
|
954
|
+
type: "suggestion",
|
|
955
|
+
docs: {
|
|
956
|
+
description: "Require a React component's props type to be named `<ComponentName>Props` (e.g. `ButtonProps` for `Button`).",
|
|
957
|
+
recommended: true,
|
|
958
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#props-type-name"
|
|
959
|
+
},
|
|
960
|
+
schema: [
|
|
961
|
+
{
|
|
962
|
+
type: "object",
|
|
963
|
+
properties: {
|
|
964
|
+
paths: {
|
|
965
|
+
type: "array",
|
|
966
|
+
items: { type: "string" },
|
|
967
|
+
description: "Optional glob patterns. When provided, the rule only runs for files whose path matches one of them."
|
|
968
|
+
},
|
|
969
|
+
ignore: {
|
|
970
|
+
type: "array",
|
|
971
|
+
items: { type: "string" },
|
|
972
|
+
description: "Optional glob patterns. The rule is skipped for files whose path matches one of them, even if it also matches `paths`."
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
additionalProperties: false
|
|
976
|
+
}
|
|
977
|
+
],
|
|
978
|
+
messages: {
|
|
979
|
+
propsTypeNameMismatch: "A component's props type must be named `{{expected}}`, but it is named `{{actual}}`."
|
|
980
|
+
}
|
|
981
|
+
},
|
|
982
|
+
create(context) {
|
|
983
|
+
const options = context.options[0] ?? {};
|
|
984
|
+
const paths = options.paths ?? [];
|
|
985
|
+
const ignore = options.ignore ?? [];
|
|
986
|
+
if (ignore.length > 0 && matchesAnyGlob(context.filename, ignore)) {
|
|
987
|
+
return {};
|
|
988
|
+
}
|
|
989
|
+
if (paths.length > 0 && !matchesAnyGlob(context.filename, paths)) {
|
|
990
|
+
return {};
|
|
991
|
+
}
|
|
992
|
+
const check = (node) => {
|
|
993
|
+
if (!isComponent(node)) {
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const componentName = getComponentName(node);
|
|
997
|
+
if (componentName === null) {
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const actual = getPropsTypeNameFromFunction(node);
|
|
1001
|
+
if (actual === null) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
const expected = `${componentName}${PROPS_SUFFIX2}`;
|
|
1005
|
+
if (actual === expected) {
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
context.report({
|
|
1009
|
+
node: getAnnotatedParam(node.params[0]),
|
|
1010
|
+
messageId: "propsTypeNameMismatch",
|
|
1011
|
+
data: { expected, actual }
|
|
1012
|
+
});
|
|
1013
|
+
};
|
|
1014
|
+
return {
|
|
1015
|
+
ArrowFunctionExpression: check,
|
|
1016
|
+
FunctionDeclaration: check,
|
|
1017
|
+
FunctionExpression: check
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
631
1020
|
};
|
|
1021
|
+
|
|
1022
|
+
// src/rules/props-type-reference/props-type-reference.ts
|
|
1023
|
+
var INLINE_OBJECT_TYPE = "TSTypeLiteral";
|
|
632
1024
|
var propsTypeReference = {
|
|
633
1025
|
meta: {
|
|
634
1026
|
type: "suggestion",
|
|
635
1027
|
docs: {
|
|
636
1028
|
description: "Require a React component's props parameter to use a named type (e.g. `ButtonProps`) instead of an inline object type literal.",
|
|
637
1029
|
recommended: true,
|
|
638
|
-
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin"
|
|
1030
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#props-type-reference"
|
|
639
1031
|
},
|
|
640
1032
|
schema: [
|
|
641
1033
|
{
|
|
@@ -693,7 +1085,7 @@ var propsTypeReference = {
|
|
|
693
1085
|
}
|
|
694
1086
|
};
|
|
695
1087
|
|
|
696
|
-
// src/rules/require-component-stories.ts
|
|
1088
|
+
// src/rules/require-component-stories/require-component-stories.ts
|
|
697
1089
|
import { existsSync } from "node:fs";
|
|
698
1090
|
import path2 from "node:path";
|
|
699
1091
|
|
|
@@ -769,7 +1161,7 @@ var deriveExpectedStoryPaths = (filePath, opts) => {
|
|
|
769
1161
|
});
|
|
770
1162
|
};
|
|
771
1163
|
|
|
772
|
-
// src/rules/require-component-stories.ts
|
|
1164
|
+
// src/rules/require-component-stories/require-component-stories.ts
|
|
773
1165
|
var toStoryPathOptions = (options) => {
|
|
774
1166
|
const picked = {};
|
|
775
1167
|
if (options.storiesDir !== void 0) {
|
|
@@ -795,7 +1187,7 @@ var requireComponentStories = {
|
|
|
795
1187
|
docs: {
|
|
796
1188
|
description: "Require a co-located Storybook story for every dumb component.",
|
|
797
1189
|
recommended: true,
|
|
798
|
-
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin"
|
|
1190
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#require-component-stories"
|
|
799
1191
|
},
|
|
800
1192
|
schema: [
|
|
801
1193
|
{
|
|
@@ -899,8 +1291,11 @@ var rules = {
|
|
|
899
1291
|
"props-destructuring-newline": propsDestructuringNewline,
|
|
900
1292
|
"props-destructuring-blank-line": propsDestructuringBlankLine,
|
|
901
1293
|
"props-type-reference": propsTypeReference,
|
|
1294
|
+
"props-type-name": propsTypeName,
|
|
902
1295
|
"component-file-order": componentFileOrder,
|
|
903
1296
|
"component-arrow-function": componentArrowFunction,
|
|
1297
|
+
"max-components-per-file": maxComponentsPerFile,
|
|
1298
|
+
"max-jsx-return-size": maxJsxReturnSize,
|
|
904
1299
|
"require-component-stories": requireComponentStories
|
|
905
1300
|
};
|
|
906
1301
|
|
|
@@ -924,20 +1319,49 @@ plugin.configs.recommended = [
|
|
|
924
1319
|
[`${PLUGIN_NAME}/props-destructuring-newline`]: "error",
|
|
925
1320
|
[`${PLUGIN_NAME}/props-destructuring-blank-line`]: "error",
|
|
926
1321
|
[`${PLUGIN_NAME}/props-type-reference`]: "error",
|
|
1322
|
+
[`${PLUGIN_NAME}/props-type-name`]: "error",
|
|
927
1323
|
[`${PLUGIN_NAME}/component-file-order`]: "error",
|
|
928
1324
|
// Pages and routes are excluded: route/page modules commonly use `function`
|
|
929
1325
|
// declarations (and framework conventions like default-exported page functions).
|
|
930
1326
|
[`${PLUGIN_NAME}/component-arrow-function`]: ["error", { ignore: ["**/pages/**", "**/routes/**"] }],
|
|
931
|
-
[`${PLUGIN_NAME}/require-component-stories`]: "error"
|
|
1327
|
+
[`${PLUGIN_NAME}/require-component-stories`]: "error",
|
|
1328
|
+
// Advisory: flags returns that render too many JSX elements; extract into a
|
|
1329
|
+
// variable or sub-component. Warn-class by nature (`type: 'suggestion'`);
|
|
1330
|
+
// severity confirmed against a repo-wide dry run at the default ceiling.
|
|
1331
|
+
[`${PLUGIN_NAME}/max-jsx-return-size`]: "error",
|
|
1332
|
+
// Caps component declarations per file; extra components belong in their own
|
|
1333
|
+
// files. Pages and routes are excluded (framework conventions co-locate
|
|
1334
|
+
// route trees and default-exported page functions). Ceiling confirmed
|
|
1335
|
+
// against a repo-wide dry run at the default.
|
|
1336
|
+
[`${PLUGIN_NAME}/max-components-per-file`]: ["error", { ignore: ["**/pages/**", "**/routes/**"] }]
|
|
932
1337
|
}
|
|
933
1338
|
},
|
|
934
|
-
// Storybook stories legitimately deviate from the
|
|
935
|
-
// order (meta/args/decorators/render fns),
|
|
936
|
-
//
|
|
1339
|
+
// Storybook stories legitimately deviate from the component conventions: the
|
|
1340
|
+
// imports → *Props → component order (meta/args/decorators/render fns), and
|
|
1341
|
+
// named templates that reference the *component's* props type (e.g.
|
|
1342
|
+
// `const Template = (args: ButtonProps) => ...`) rather than their own
|
|
1343
|
+
// `<TemplateName>Props`. So both the ordering and the props-type-name rules
|
|
1344
|
+
// would only produce noise there. Every other rule stays enabled for stories.
|
|
937
1345
|
{
|
|
938
1346
|
files: ["**/*.stories.{ts,tsx}"],
|
|
939
1347
|
rules: {
|
|
940
|
-
[`${PLUGIN_NAME}/component-file-order`]: "off"
|
|
1348
|
+
[`${PLUGIN_NAME}/component-file-order`]: "off",
|
|
1349
|
+
[`${PLUGIN_NAME}/props-type-name`]: "off"
|
|
1350
|
+
}
|
|
1351
|
+
},
|
|
1352
|
+
// Dumb presentational files (`*-component.tsx`) follow the one-component-per-file
|
|
1353
|
+
// convention the props/order/stories rules already assume, so they get a tighter
|
|
1354
|
+
// ceiling of 1. This MUST come AFTER the global `**/*.tsx` block: flat config
|
|
1355
|
+
// REPLACES rule options across matching blocks (it does not merge), and `ignore`
|
|
1356
|
+
// is re-declared here so pages/routes dumb-components keep their exemption.
|
|
1357
|
+
// A repo-wide dry run found zero `*-component.tsx` files declaring >1 component.
|
|
1358
|
+
{
|
|
1359
|
+
files: ["**/*-component.tsx"],
|
|
1360
|
+
rules: {
|
|
1361
|
+
[`${PLUGIN_NAME}/max-components-per-file`]: [
|
|
1362
|
+
"error",
|
|
1363
|
+
{ maxComponents: 1, ignore: ["**/pages/**", "**/routes/**"] }
|
|
1364
|
+
]
|
|
941
1365
|
}
|
|
942
1366
|
}
|
|
943
1367
|
];
|