@blumintinc/eslint-plugin-blumint 1.20.3 → 1.20.5
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/lib/index.js
CHANGED
|
@@ -595,6 +595,27 @@ exports.noUnnecessaryVerbSuffix = (0, createRule_1.createRule)({
|
|
|
595
595
|
// Skip the declaration identifier itself — already handled.
|
|
596
596
|
if (ref.identifier === declarationIdNode)
|
|
597
597
|
continue;
|
|
598
|
+
const refParent = ref.identifier.parent;
|
|
599
|
+
// An object-literal shorthand `{ fooBar }` desugars to
|
|
600
|
+
// `{ fooBar: fooBar }`: the one token is both the property
|
|
601
|
+
// key and its value. Renaming it would rename the KEY too,
|
|
602
|
+
// silently changing the object's shape so every
|
|
603
|
+
// `obj.fooBar` consumer reads undefined. Expand to
|
|
604
|
+
// `oldKey: newName` so only the value moves (#1352).
|
|
605
|
+
if (refParent?.type === utils_1.AST_NODE_TYPES.Property &&
|
|
606
|
+
refParent.shorthand &&
|
|
607
|
+
refParent.parent?.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
608
|
+
fixes.push(fixer.replaceText(ref.identifier, `${name}: ${suggestion}`));
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
// A re-export specifier `export { fooBar }` binds the public
|
|
612
|
+
// export name to this identifier, a cross-file contract a
|
|
613
|
+
// single-file fixer cannot rewrite. The declaration-level
|
|
614
|
+
// export guard misses this form because the declaration
|
|
615
|
+
// itself carries no `export` keyword (#1352).
|
|
616
|
+
if (refParent?.type === utils_1.AST_NODE_TYPES.ExportSpecifier) {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
598
619
|
fixes.push(renameIdentifier(fixer, sourceCode, ref.identifier, suggestion));
|
|
599
620
|
}
|
|
600
621
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.requirePropsComposition = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
4
9
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
10
|
const minimatch_1 = require("minimatch");
|
|
6
11
|
const createRule_1 = require("../utils/createRule");
|
|
@@ -305,13 +310,38 @@ function getFirstParamTypeNode(funcNode) {
|
|
|
305
310
|
}
|
|
306
311
|
return null;
|
|
307
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* The only wrappers that hand a component's props surface through unchanged, so
|
|
315
|
+
* the parameter list of the wrapped function still describes the binding's
|
|
316
|
+
* props. Every other call — `lazy`, `dynamic`, `styled(Box)`, `connect(...)()`,
|
|
317
|
+
* any `withX` — either forwards a *different* component's props or injects its
|
|
318
|
+
* own, so its argument proves nothing about the exported binding.
|
|
319
|
+
*/
|
|
320
|
+
const PROPS_PRESERVING_HOCS = new Set(['memo', 'forwardRef', 'observer']);
|
|
321
|
+
/**
|
|
322
|
+
* Matches both the bare (`memo(...)`) and React-qualified (`React.memo(...)`)
|
|
323
|
+
* callee forms. A computed or deeper member expression, and a callee that is
|
|
324
|
+
* itself a call (`connect(mapState)(...)`, `styled(Box)(...)`), never match.
|
|
325
|
+
*/
|
|
326
|
+
function isPropsPreservingHocCallee(callee) {
|
|
327
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
328
|
+
return PROPS_PRESERVING_HOCS.has(callee.name);
|
|
329
|
+
}
|
|
330
|
+
return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
331
|
+
!callee.computed &&
|
|
332
|
+
callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
333
|
+
callee.object.name === 'React' &&
|
|
334
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
335
|
+
PROPS_PRESERVING_HOCS.has(callee.property.name));
|
|
336
|
+
}
|
|
308
337
|
/**
|
|
309
338
|
* Resolve the function node for a component name in the program, following a
|
|
310
339
|
* single-identifier alias (`const Live = LiveUnmemoized`) and unwrapping a HOC
|
|
311
|
-
* call (`memo((props) => ...)`). Returns null when no function is found.
|
|
312
|
-
* `seen` set guards against alias cycles.
|
|
340
|
+
* call (`memo((props) => ...)`). Returns null when no function is found.
|
|
313
341
|
*/
|
|
314
|
-
function findComponentFunction(program, name,
|
|
342
|
+
function findComponentFunction(program, name, lookup = {}) {
|
|
343
|
+
const seen = lookup.seen ?? new Set();
|
|
344
|
+
const nextLookup = { ...lookup, seen };
|
|
315
345
|
if (seen.has(name))
|
|
316
346
|
return null;
|
|
317
347
|
seen.add(name);
|
|
@@ -338,6 +368,12 @@ function findComponentFunction(program, name, seen = new Set()) {
|
|
|
338
368
|
return init;
|
|
339
369
|
}
|
|
340
370
|
if (init.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
371
|
+
if (lookup.propsPreservingHocsOnly &&
|
|
372
|
+
!isPropsPreservingHocCallee(init.callee)) {
|
|
373
|
+
// The binding IS this call, and the call is not known to preserve
|
|
374
|
+
// props — so nothing about its props surface is provable.
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
341
377
|
const arg0 = init.arguments[0];
|
|
342
378
|
if (arg0 &&
|
|
343
379
|
(arg0.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
@@ -346,7 +382,7 @@ function findComponentFunction(program, name, seen = new Set()) {
|
|
|
346
382
|
}
|
|
347
383
|
}
|
|
348
384
|
if (init.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
349
|
-
return findComponentFunction(program, init.name,
|
|
385
|
+
return findComponentFunction(program, init.name, nextLookup);
|
|
350
386
|
}
|
|
351
387
|
}
|
|
352
388
|
}
|
|
@@ -360,9 +396,406 @@ function findComponentFunction(program, name, seen = new Set()) {
|
|
|
360
396
|
* used; imported children are left to the normal composition check.
|
|
361
397
|
*/
|
|
362
398
|
function isZeroPropComponent(program, name) {
|
|
363
|
-
const fn = findComponentFunction(program, name
|
|
399
|
+
const fn = findComponentFunction(program, name, {
|
|
400
|
+
propsPreservingHocsOnly: true,
|
|
401
|
+
});
|
|
402
|
+
return fn !== null && fn.params.length === 0;
|
|
403
|
+
}
|
|
404
|
+
const RELATIVE_SOURCE = /^\.\.?\//;
|
|
405
|
+
const MODULE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js'];
|
|
406
|
+
const DEFAULT_BINDING = 'default';
|
|
407
|
+
/**
|
|
408
|
+
* A whole-repo run re-visits the same parent/child pairs many times, so both the
|
|
409
|
+
* module resolution (misses included) and the per-binding verdict are memoized to
|
|
410
|
+
* hold the disk work to one stat/read per module. Keys are JSON-encoded tuples so
|
|
411
|
+
* no separator character can collide with a directory or module name. Only the
|
|
412
|
+
* verdicts are retained — a parsed child program is discarded as soon as it has
|
|
413
|
+
* answered, so a whole-repo run never accumulates foreign ASTs.
|
|
414
|
+
*
|
|
415
|
+
* The resolution cache stores the candidate search only (which path a specifier
|
|
416
|
+
* picks), never the file's contents or state: a resolution *miss* is safe to
|
|
417
|
+
* keep because a file created later stays unresolved, which reports. The verdict
|
|
418
|
+
* cache instead carries the resolved file's mtime/size stamp as its VALUE, so a
|
|
419
|
+
* child edited under a long-lived host (the VS Code ESLint extension, eslint_d)
|
|
420
|
+
* is re-read on the next lint rather than answering from a stale verdict. The
|
|
421
|
+
* stamp lives in the value, not the key, so an edited file replaces its entry
|
|
422
|
+
* instead of accumulating one per revision.
|
|
423
|
+
*/
|
|
424
|
+
const moduleResolutionCache = new Map();
|
|
425
|
+
const propLessBindingCache = new Map();
|
|
426
|
+
/**
|
|
427
|
+
* Stat a candidate path, returning its identity stamp when it is a file. The
|
|
428
|
+
* single stat both resolves existence and stamps the file, so proving a child
|
|
429
|
+
* prop-less never pays for two.
|
|
430
|
+
*/
|
|
431
|
+
function statModule(candidate) {
|
|
432
|
+
try {
|
|
433
|
+
const stats = fs_1.default.statSync(candidate);
|
|
434
|
+
return stats.isFile()
|
|
435
|
+
? { filePath: candidate, mtimeMs: stats.mtimeMs, size: stats.size }
|
|
436
|
+
: null;
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Resolve a relative import specifier the way a bundler would: sibling file
|
|
444
|
+
* first, then the directory's index file. Package specifiers never reach here —
|
|
445
|
+
* only a file proven to exist on disk can relax the rule.
|
|
446
|
+
*
|
|
447
|
+
* The chosen path is memoized, but the returned stamp never is: the stat that
|
|
448
|
+
* confirms the resolved file still exists is the same stat that dates it, so a
|
|
449
|
+
* cache hit costs exactly one stat (against up to eight on a miss) and a deleted
|
|
450
|
+
* file resolves to null again, which reports.
|
|
451
|
+
*/
|
|
452
|
+
function resolveRelativeModule(fromDir, source) {
|
|
453
|
+
const cacheKey = JSON.stringify([fromDir, source]);
|
|
454
|
+
const cached = moduleResolutionCache.get(cacheKey);
|
|
455
|
+
if (cached !== undefined) {
|
|
456
|
+
return cached === null ? null : statModule(cached);
|
|
457
|
+
}
|
|
458
|
+
const base = path_1.default.resolve(fromDir, source);
|
|
459
|
+
let resolved = null;
|
|
460
|
+
for (const extension of MODULE_EXTENSIONS) {
|
|
461
|
+
resolved = statModule(`${base}${extension}`);
|
|
462
|
+
if (resolved) {
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (!resolved) {
|
|
467
|
+
for (const extension of MODULE_EXTENSIONS) {
|
|
468
|
+
resolved = statModule(path_1.default.join(base, `index${extension}`));
|
|
469
|
+
if (resolved) {
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
moduleResolutionCache.set(cacheKey, resolved?.filePath ?? null);
|
|
475
|
+
return resolved;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* `undefined` means the parser has not been looked up yet; `null` means the
|
|
479
|
+
* lookup failed and must not be retried.
|
|
480
|
+
*/
|
|
481
|
+
let foreignParse;
|
|
482
|
+
/**
|
|
483
|
+
* The child module is read with the parser that backs `@typescript-eslint/utils`
|
|
484
|
+
* itself, loaded lazily and memoized (failure included) so the resolution cost is
|
|
485
|
+
* paid once per process rather than per file. A consumer install that somehow
|
|
486
|
+
* lacks the parser degrades to "not provable", which leaves the dependency in the
|
|
487
|
+
* set and keeps the rule reporting.
|
|
488
|
+
*/
|
|
489
|
+
function getForeignParse() {
|
|
490
|
+
if (foreignParse === undefined) {
|
|
491
|
+
try {
|
|
492
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
493
|
+
const estree = require('@typescript-eslint/typescript-estree');
|
|
494
|
+
foreignParse = estree?.parse ?? null;
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
foreignParse = null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return foreignParse;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Parse a child module into an AST. Only a real parse can decide whether a file
|
|
504
|
+
* declares a zero-parameter component: text that merely looks like a declaration
|
|
505
|
+
* — inside a string, a template literal, a comment, a nested scope, or a
|
|
506
|
+
* TypeScript overload signature — must never stand in for the definition.
|
|
507
|
+
* A file that cannot be read or parsed yields null, i.e. proves nothing.
|
|
508
|
+
*/
|
|
509
|
+
function parseModuleProgram(filePath) {
|
|
510
|
+
const parse = getForeignParse();
|
|
511
|
+
if (!parse) {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
return parse(fs_1.default.readFileSync(filePath, 'utf8'), {
|
|
516
|
+
jsx: true,
|
|
517
|
+
loc: false,
|
|
518
|
+
range: false,
|
|
519
|
+
comment: false,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Map an exported name to the local binding that defines it, covering both
|
|
528
|
+
* `export const X` / `export function X` and the `const X = …; export { X as Y }`
|
|
529
|
+
* split. A re-export (`export { X } from './y'`) is deliberately left
|
|
530
|
+
* unresolved: the definition lives in another module, so nothing is proven.
|
|
531
|
+
*/
|
|
532
|
+
function findExportedLocalName(program, exported) {
|
|
533
|
+
for (const stmt of program.body) {
|
|
534
|
+
if (stmt.type !== utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
|
|
535
|
+
stmt.exportKind === 'type' ||
|
|
536
|
+
stmt.source) {
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
const declaration = stmt.declaration;
|
|
540
|
+
if (declaration) {
|
|
541
|
+
if (declaration.type === utils_1.AST_NODE_TYPES.FunctionDeclaration &&
|
|
542
|
+
declaration.id?.name === exported) {
|
|
543
|
+
return exported;
|
|
544
|
+
}
|
|
545
|
+
if (declaration.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
|
|
546
|
+
for (const declarator of declaration.declarations) {
|
|
547
|
+
if (declarator.id.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
548
|
+
declarator.id.name === exported) {
|
|
549
|
+
return exported;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
for (const specifier of stmt.specifiers) {
|
|
556
|
+
if (specifier.exported.name === exported) {
|
|
557
|
+
return specifier.local.name;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Resolve `export default <expr>` to the component function it defines,
|
|
565
|
+
* unwrapping a HOC call and following an identifier alias the same way
|
|
566
|
+
* findComponentFunction does for named bindings. `lookup` carries the same
|
|
567
|
+
* restrictions, so `export default lazy(() => import('./X'))` is as unprovable
|
|
568
|
+
* as its named counterpart.
|
|
569
|
+
*/
|
|
570
|
+
function findDefaultExportFunction(program, lookup = {}) {
|
|
571
|
+
for (const stmt of program.body) {
|
|
572
|
+
if (stmt.type !== utils_1.AST_NODE_TYPES.ExportDefaultDeclaration) {
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
const declaration = stmt.declaration;
|
|
576
|
+
if (declaration.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
577
|
+
declaration.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
578
|
+
declaration.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
|
|
579
|
+
return declaration;
|
|
580
|
+
}
|
|
581
|
+
if (declaration.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
582
|
+
return findComponentFunction(program, declaration.name, lookup);
|
|
583
|
+
}
|
|
584
|
+
if (declaration.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
585
|
+
if (lookup.propsPreservingHocsOnly &&
|
|
586
|
+
!isPropsPreservingHocCallee(declaration.callee)) {
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
const arg0 = declaration.arguments[0];
|
|
590
|
+
if (!arg0) {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
if (arg0.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
594
|
+
arg0.type === utils_1.AST_NODE_TYPES.FunctionExpression) {
|
|
595
|
+
return arg0;
|
|
596
|
+
}
|
|
597
|
+
if (arg0.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
598
|
+
return findComponentFunction(program, arg0.name, lookup);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Whether the module at `filePath` provably exports `binding` as a component
|
|
607
|
+
* declared with an empty parameter list. The verdict comes from
|
|
608
|
+
* findComponentFunction — the same resolution the rule applies to in-file
|
|
609
|
+
* children — run against the child's own parsed program, so `const X = () => …`,
|
|
610
|
+
* `function X() {}`, props-preserving HOC wrappers and identifier aliases all
|
|
611
|
+
* behave identically whether the child lives in this file or a sibling one.
|
|
612
|
+
*/
|
|
613
|
+
function isPropLessExport(filePath, binding) {
|
|
614
|
+
const program = parseModuleProgram(filePath);
|
|
615
|
+
if (!program) {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
const lookup = { propsPreservingHocsOnly: true };
|
|
619
|
+
let fn = null;
|
|
620
|
+
if (binding === DEFAULT_BINDING) {
|
|
621
|
+
fn = findDefaultExportFunction(program, lookup);
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
const local = findExportedLocalName(program, binding);
|
|
625
|
+
fn = local === null ? null : findComponentFunction(program, local, lookup);
|
|
626
|
+
}
|
|
364
627
|
return fn !== null && fn.params.length === 0;
|
|
365
628
|
}
|
|
629
|
+
/**
|
|
630
|
+
* Locate the relative import that introduces `localName`. Package imports
|
|
631
|
+
* (`@mui/material`, `react`), namespace imports, type-only imports and free
|
|
632
|
+
* identifiers all return null, so they keep the rule's normal behavior.
|
|
633
|
+
*/
|
|
634
|
+
function findRelativeImport(program, localName) {
|
|
635
|
+
for (const stmt of program.body) {
|
|
636
|
+
if (stmt.type !== utils_1.AST_NODE_TYPES.ImportDeclaration ||
|
|
637
|
+
stmt.importKind === 'type' ||
|
|
638
|
+
typeof stmt.source.value !== 'string' ||
|
|
639
|
+
!RELATIVE_SOURCE.test(stmt.source.value)) {
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
for (const specifier of stmt.specifiers) {
|
|
643
|
+
if (specifier.local.name !== localName) {
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier) {
|
|
647
|
+
return { source: stmt.source.value, binding: DEFAULT_BINDING };
|
|
648
|
+
}
|
|
649
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
650
|
+
specifier.importKind !== 'type' &&
|
|
651
|
+
specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
652
|
+
return { source: stmt.source.value, binding: specifier.imported.name };
|
|
653
|
+
}
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Whether `name` is bound by a destructuring/assignment pattern. Every binding
|
|
661
|
+
* form a parameter or declarator can take is walked, so a name introduced by
|
|
662
|
+
* `const { X } = …` or `([X]) => …` counts as a binding just like `const X = …`.
|
|
663
|
+
*/
|
|
664
|
+
function patternBindsName(pattern, name) {
|
|
665
|
+
if (!pattern)
|
|
666
|
+
return false;
|
|
667
|
+
switch (pattern.type) {
|
|
668
|
+
case utils_1.AST_NODE_TYPES.Identifier:
|
|
669
|
+
return pattern.name === name;
|
|
670
|
+
case utils_1.AST_NODE_TYPES.ObjectPattern:
|
|
671
|
+
return pattern.properties.some((property) => property.type === utils_1.AST_NODE_TYPES.RestElement
|
|
672
|
+
? patternBindsName(property.argument, name)
|
|
673
|
+
: patternBindsName(property.value, name));
|
|
674
|
+
case utils_1.AST_NODE_TYPES.ArrayPattern:
|
|
675
|
+
return pattern.elements.some((element) => patternBindsName(element, name));
|
|
676
|
+
case utils_1.AST_NODE_TYPES.AssignmentPattern:
|
|
677
|
+
return patternBindsName(pattern.left, name);
|
|
678
|
+
case utils_1.AST_NODE_TYPES.RestElement:
|
|
679
|
+
return patternBindsName(pattern.argument, name);
|
|
680
|
+
default:
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Whether a declaration of `name` appears anywhere inside `root`.
|
|
686
|
+
*
|
|
687
|
+
* A module-level import only describes the JSX name when nothing between the
|
|
688
|
+
* import and the JSX site re-declares it. Scope boundaries inside the component
|
|
689
|
+
* are deliberately ignored: over-detecting a shadow costs nothing but a report
|
|
690
|
+
* (the fail-safe direction), whereas missing one silently drops a dependency the
|
|
691
|
+
* import never described (issue #1316).
|
|
692
|
+
*/
|
|
693
|
+
function isNameDeclaredWithin(root, name) {
|
|
694
|
+
let found = false;
|
|
695
|
+
function visit(node) {
|
|
696
|
+
if (found || !node || typeof node !== 'object')
|
|
697
|
+
return;
|
|
698
|
+
switch (node.type) {
|
|
699
|
+
case utils_1.AST_NODE_TYPES.VariableDeclarator:
|
|
700
|
+
if (patternBindsName(node.id, name))
|
|
701
|
+
found = true;
|
|
702
|
+
break;
|
|
703
|
+
case utils_1.AST_NODE_TYPES.FunctionDeclaration:
|
|
704
|
+
case utils_1.AST_NODE_TYPES.ClassDeclaration:
|
|
705
|
+
if (node.id?.name === name)
|
|
706
|
+
found = true;
|
|
707
|
+
break;
|
|
708
|
+
case utils_1.AST_NODE_TYPES.CatchClause:
|
|
709
|
+
if (patternBindsName(node.param, name))
|
|
710
|
+
found = true;
|
|
711
|
+
break;
|
|
712
|
+
default:
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
if (node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
|
|
716
|
+
node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
717
|
+
node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression) {
|
|
718
|
+
if (node.params.some((param) => patternBindsName(param, name))) {
|
|
719
|
+
found = true;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (found)
|
|
723
|
+
return;
|
|
724
|
+
for (const key of Object.keys(node)) {
|
|
725
|
+
if (key === 'parent')
|
|
726
|
+
continue;
|
|
727
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
728
|
+
const child = node[key];
|
|
729
|
+
if (Array.isArray(child)) {
|
|
730
|
+
for (const item of child) {
|
|
731
|
+
if (item && typeof item === 'object' && 'type' in item) {
|
|
732
|
+
visit(item);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
else if (child && typeof child === 'object' && 'type' in child) {
|
|
737
|
+
visit(child);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
visit(root);
|
|
742
|
+
return found;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* A child imported from a sibling module can be just as prop-less as an in-file
|
|
746
|
+
* one (issue #1316: `<BestOfText />`, whose module declares
|
|
747
|
+
* `export const BestOfText = () => …` and no BestOfTextProps anywhere). Demanding
|
|
748
|
+
* composition with a props type that cannot exist is unfixable, so such a child
|
|
749
|
+
* is not a composition dependency.
|
|
750
|
+
*
|
|
751
|
+
* The relaxation is deliberately narrow — it applies only when the child is
|
|
752
|
+
* imported from a *relative* path, that path resolves to a file on disk, that
|
|
753
|
+
* file's own AST positively proves a zero-parameter component, and nothing
|
|
754
|
+
* inside the rendering component re-declares the name. Every other dependency
|
|
755
|
+
* (package imports, unresolvable modules, free identifiers, shadowed names,
|
|
756
|
+
* anything the parse cannot decide with certainty) still reports, so the rule
|
|
757
|
+
* cannot be silently disabled by an unresolvable name.
|
|
758
|
+
*/
|
|
759
|
+
function isPropLessImportedComponent(program, localName, filename, componentRoot) {
|
|
760
|
+
const relativeImport = findRelativeImport(program, localName);
|
|
761
|
+
if (!relativeImport) {
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
764
|
+
// A local declaration of the same name inside the component means the JSX
|
|
765
|
+
// resolves to that binding, not the import, so the import proves nothing.
|
|
766
|
+
if (isNameDeclaredWithin(componentRoot, localName)) {
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
try {
|
|
770
|
+
const absolute = path_1.default.isAbsolute(filename)
|
|
771
|
+
? filename
|
|
772
|
+
: path_1.default.resolve(process.cwd(), filename);
|
|
773
|
+
const resolved = resolveRelativeModule(path_1.default.dirname(absolute), relativeImport.source);
|
|
774
|
+
if (!resolved) {
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
777
|
+
const cacheKey = JSON.stringify([
|
|
778
|
+
resolved.filePath,
|
|
779
|
+
relativeImport.binding,
|
|
780
|
+
]);
|
|
781
|
+
const cached = propLessBindingCache.get(cacheKey);
|
|
782
|
+
if (cached !== undefined &&
|
|
783
|
+
cached.mtimeMs === resolved.mtimeMs &&
|
|
784
|
+
cached.size === resolved.size) {
|
|
785
|
+
return cached.propLess;
|
|
786
|
+
}
|
|
787
|
+
const propLess = isPropLessExport(resolved.filePath, relativeImport.binding);
|
|
788
|
+
propLessBindingCache.set(cacheKey, {
|
|
789
|
+
mtimeMs: resolved.mtimeMs,
|
|
790
|
+
size: resolved.size,
|
|
791
|
+
propLess,
|
|
792
|
+
});
|
|
793
|
+
return propLess;
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
366
799
|
/**
|
|
367
800
|
* Resolve the type node that defines a rendered dependency's props: its
|
|
368
801
|
* `{Dep}Props` alias if one exists, otherwise the dependency component's
|
|
@@ -431,7 +864,10 @@ exports.requirePropsComposition = (0, createRule_1.createRule)({
|
|
|
431
864
|
// an absolute path — on any platform. Normalize backslashes, then match each
|
|
432
865
|
// pattern against both the full path and the repo-relative slice (from
|
|
433
866
|
// `/src/`) so absolute POSIX and Windows paths both resolve (issue #1268).
|
|
434
|
-
|
|
867
|
+
// Glob matching needs forward slashes, while resolving a relative import off
|
|
868
|
+
// disk needs the platform-native path — keep both.
|
|
869
|
+
const rawFilename = context.getFilename();
|
|
870
|
+
const filename = rawFilename.replace(/\\/g, '/');
|
|
435
871
|
const matchesTargetPath = targetPaths.some((pattern) => {
|
|
436
872
|
if ((0, minimatch_1.minimatch)(filename, pattern, { matchBase: false })) {
|
|
437
873
|
return true;
|
|
@@ -564,9 +1000,19 @@ exports.requirePropsComposition = (0, createRule_1.createRule)({
|
|
|
564
1000
|
return;
|
|
565
1001
|
}
|
|
566
1002
|
}
|
|
1003
|
+
// Drop dependencies proven prop-less on disk (issue #1316 reopened). This
|
|
1004
|
+
// runs only on the about-to-report path so a compliant file never pays for
|
|
1005
|
+
// the file reads, and it drops the dep from the *reported* set rather than
|
|
1006
|
+
// suppressing the whole report — a sibling child that genuinely needs
|
|
1007
|
+
// composition still fires.
|
|
1008
|
+
const reportableDeps = depComponents.filter((dep) => !isPropLessImportedComponent(prog, dep, rawFilename, funcNode));
|
|
1009
|
+
if (reportableDeps.length < minDependencyCount) {
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
const reportable = new Set(reportableDeps);
|
|
567
1013
|
const flaggedDeps = requireAllDependencies
|
|
568
|
-
? missingComposition
|
|
569
|
-
:
|
|
1014
|
+
? missingComposition.filter((dep) => reportable.has(dep))
|
|
1015
|
+
: reportableDeps;
|
|
570
1016
|
if (flaggedDeps.length === 0)
|
|
571
1017
|
return;
|
|
572
1018
|
context.report({
|
|
@@ -575,7 +1021,7 @@ exports.requirePropsComposition = (0, createRule_1.createRule)({
|
|
|
575
1021
|
data: {
|
|
576
1022
|
componentName,
|
|
577
1023
|
propsTypeName: propsTypeName ?? `${componentName}Props`,
|
|
578
|
-
dependencyList:
|
|
1024
|
+
dependencyList: reportableDeps.map((d) => `'${d}'`).join(', '),
|
|
579
1025
|
missingList: flaggedDeps
|
|
580
1026
|
.map((d) => `'${toPropsTypeName(d)}'`)
|
|
581
1027
|
.join(', '),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blumintinc/eslint-plugin-blumint",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.5",
|
|
4
4
|
"description": "Custom eslint rules for use within BluMint",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Brodie McGuire",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@types/pluralize": "0.0.33",
|
|
57
|
+
"@typescript-eslint/typescript-estree": "5.59.6",
|
|
57
58
|
"compromise": "14.14.4",
|
|
58
59
|
"minimatch": "10.0.1",
|
|
59
60
|
"pluralize": "8.0.0",
|
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.5",
|
|
4
|
+
"date": "2026-07-28T04:46:19.315Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "require-props-composition",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1316
|
|
11
|
+
],
|
|
12
|
+
"summary": "prove imported zero-prop children off disk (closes #1316)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.4",
|
|
18
|
+
"date": "2026-07-25T03:08:45.043Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "no-unnecessary-verb-suffix",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
1352
|
|
25
|
+
],
|
|
26
|
+
"summary": "stop the rename autofix from renaming shorthand keys and re-exported names (closes #1352)"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
2
30
|
{
|
|
3
31
|
"version": "1.20.3",
|
|
4
32
|
"date": "2026-07-25T02:54:46.197Z",
|