@blumintinc/eslint-plugin-blumint 1.20.11 → 1.20.13

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
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.11',
226
+ version: '1.20.13',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -237,6 +237,88 @@ function collectJsxElementNames(node) {
237
237
  visit(node);
238
238
  return names;
239
239
  }
240
+ /**
241
+ * Collect the capitalized binding names introduced by a destructuring pattern,
242
+ * following renames (`{ Slot: Renderer }`), defaults (`{ Slot = Fallback }`)
243
+ * and nesting (`{ slots: { Item } }`).
244
+ */
245
+ function collectPatternBindings(pattern, into) {
246
+ switch (pattern.type) {
247
+ case utils_1.AST_NODE_TYPES.Identifier:
248
+ if (/^[A-Z]/.test(pattern.name)) {
249
+ into.add(pattern.name);
250
+ }
251
+ break;
252
+ case utils_1.AST_NODE_TYPES.ObjectPattern:
253
+ for (const property of pattern.properties) {
254
+ if (property.type === utils_1.AST_NODE_TYPES.Property) {
255
+ collectPatternBindings(property.value, into);
256
+ }
257
+ // A `...rest` element rebinds the remaining props under a single
258
+ // lowercase-by-convention name; it introduces no component slot.
259
+ }
260
+ break;
261
+ case utils_1.AST_NODE_TYPES.AssignmentPattern:
262
+ collectPatternBindings(pattern.left, into);
263
+ break;
264
+ default:
265
+ break;
266
+ }
267
+ }
268
+ /**
269
+ * Names of JSX elements that resolve to one of the component's own props — a
270
+ * rendering strategy injected by the caller (`ViewComponent: ComponentType<T>`)
271
+ * rather than a fixed child.
272
+ *
273
+ * A props-parameter binding shadows every import, so such an element is not a
274
+ * dependency the parent can compose with: the concrete component is chosen per
275
+ * call site and the slot's accepted props are already constrained by the prop's
276
+ * own type annotation. Demanding `<Slot>Props` composition names a type that
277
+ * exists nowhere.
278
+ */
279
+ function collectPropSlotNames(funcNode) {
280
+ const slots = new Set();
281
+ const propsParam = funcNode.params[0];
282
+ if (!propsParam)
283
+ return slots;
284
+ if (propsParam.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
285
+ collectPatternBindings(propsParam, slots);
286
+ return slots;
287
+ }
288
+ // `(props: Props) => ...` — the slot may be destructured out of `props` in
289
+ // the body instead of in the signature.
290
+ if (propsParam.type !== utils_1.AST_NODE_TYPES.Identifier)
291
+ return slots;
292
+ const propsName = propsParam.name;
293
+ function visit(node) {
294
+ if (!node || typeof node !== 'object')
295
+ return;
296
+ if (node.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
297
+ node.id.type === utils_1.AST_NODE_TYPES.ObjectPattern &&
298
+ node.init?.type === utils_1.AST_NODE_TYPES.Identifier &&
299
+ node.init.name === propsName) {
300
+ collectPatternBindings(node.id, slots);
301
+ }
302
+ for (const key of Object.keys(node)) {
303
+ if (key === 'parent')
304
+ continue;
305
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
306
+ const child = node[key];
307
+ if (Array.isArray(child)) {
308
+ for (const item of child) {
309
+ if (item && typeof item === 'object' && 'type' in item) {
310
+ visit(item);
311
+ }
312
+ }
313
+ }
314
+ else if (child && typeof child === 'object' && 'type' in child) {
315
+ visit(child);
316
+ }
317
+ }
318
+ }
319
+ visit(funcNode.body);
320
+ return slots;
321
+ }
240
322
  /**
241
323
  * Find the Props type alias node that corresponds to a component by name.
242
324
  * Looks for `type <ComponentName>Props = ...` in the program body.
@@ -929,10 +1011,12 @@ exports.requirePropsComposition = (0, createRule_1.createRule)({
929
1011
  // Collect all JSX element names used in the component body
930
1012
  const body = funcNode.body ?? funcNode;
931
1013
  const allJsxNames = collectJsxElementNames(body);
1014
+ const propSlots = collectPropSlotNames(funcNode);
932
1015
  // Filter to non-excluded custom components
933
1016
  const depComponents = Array.from(allJsxNames).filter((name) => !excludeComponents.has(name) &&
934
1017
  !isDecorativeIcon(name) &&
935
1018
  name !== componentName &&
1019
+ !propSlots.has(name) &&
936
1020
  !isZeroPropComponent(prog, name));
937
1021
  if (depComponents.length < minDependencyCount) {
938
1022
  return;
@@ -13,6 +13,26 @@ const DEFAULT_OPTIONS = {
13
13
  additionalSubjectExtensions: [],
14
14
  };
15
15
  const normalizeExtension = (extension) => extension.startsWith('.') ? extension : `.${extension}`;
16
+ /**
17
+ * A suite may be split by concern into `Subject.<qualifier>.test.tsx` files that
18
+ * sit beside `Subject.tsx`. Progressively dropping trailing dot-segments lets
19
+ * such a test resolve to its subject at any qualifier depth, while never
20
+ * crossing a directory boundary — a genuinely misplaced test still reports.
21
+ * Empty prefixes are dropped so a dotfile stem cannot match a bare `.ts`.
22
+ */
23
+ const subjectBaseNamesFor = (stem) => {
24
+ const segments = stem.split('.');
25
+ const baseNames = [];
26
+ for (let depth = segments.length; depth > 0; depth--) {
27
+ const baseName = segments.slice(0, depth).join('.');
28
+ if (baseName) {
29
+ baseNames.push(baseName);
30
+ }
31
+ }
32
+ // An extensionless stem (a file named exactly ".test.ts") leaves nothing to
33
+ // probe; keep it so the report still names what was looked for.
34
+ return baseNames.length > 0 ? baseNames : [stem];
35
+ };
16
36
  exports.testFileLocationEnforcement = (0, createRule_1.createRule)({
17
37
  name: 'test-file-location-enforcement',
18
38
  meta: {
@@ -57,17 +77,22 @@ exports.testFileLocationEnforcement = (0, createRule_1.createRule)({
57
77
  }
58
78
  const directory = path_1.default.dirname(filename);
59
79
  const testFileName = path_1.default.basename(filename);
60
- const baseName = testFileName.replace(TEST_FILE_PATTERN, '');
61
- const candidates = subjectExtensions.map((extension) => path_1.default.join(directory, `${baseName}${extension}`));
62
- const hasSibling = candidates.some((candidate) => fs_1.default.existsSync(candidate));
80
+ const stem = testFileName.replace(TEST_FILE_PATTERN, '');
81
+ const baseNames = subjectBaseNamesFor(stem);
82
+ const hasSibling = baseNames.some((baseName) => subjectExtensions.some((extension) => fs_1.default.existsSync(path_1.default.join(directory, `${baseName}${extension}`))));
63
83
  if (hasSibling) {
64
84
  return;
65
85
  }
66
86
  const relativePath = path_1.default.isAbsolute(filename)
67
87
  ? path_1.default.relative(process.cwd(), filename) || filename
68
88
  : filename;
69
- const expectedNames = subjectExtensions
70
- .map((extension) => `"${baseName}${extension}"`)
89
+ // Naming the shortest prefix alongside the full stem keeps the guidance
90
+ // honest: either subject name satisfies the rule.
91
+ const reportedBaseNames = baseNames.length > 1
92
+ ? [baseNames[0], baseNames[baseNames.length - 1]]
93
+ : baseNames;
94
+ const expectedNames = reportedBaseNames
95
+ .flatMap((baseName) => subjectExtensions.map((extension) => `"${baseName}${extension}"`))
71
96
  .join(' or ');
72
97
  context.report({
73
98
  node,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.11",
3
+ "version": "1.20.13",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,32 @@
1
1
  [
2
+ {
3
+ "version": "1.20.13",
4
+ "date": "2026-07-29T06:26:25.973Z",
5
+ "rules": [
6
+ {
7
+ "name": "require-props-composition",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1374
11
+ ],
12
+ "summary": "exempt caller-injected component prop slots (closes #1374)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.12",
18
+ "date": "2026-07-28T22:32:01.811Z",
19
+ "rules": [
20
+ {
21
+ "name": "test-file-location-enforcement",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 1372
25
+ ],
26
+ "summary": "accept suite-qualifier test files (closes #1372)"
27
+ }
28
+ ]
29
+ },
2
30
  {
3
31
  "version": "1.20.11",
4
32
  "date": "2026-07-28T20:40:15.882Z",