@kernlang/review 3.1.9 → 3.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/cache.js +143 -2
  2. package/dist/cache.js.map +1 -1
  3. package/dist/call-graph.d.ts +4 -1
  4. package/dist/call-graph.js +290 -25
  5. package/dist/call-graph.js.map +1 -1
  6. package/dist/external-tools.d.ts +23 -4
  7. package/dist/external-tools.js +68 -12
  8. package/dist/external-tools.js.map +1 -1
  9. package/dist/file-context.d.ts +6 -0
  10. package/dist/file-context.js +6 -1
  11. package/dist/file-context.js.map +1 -1
  12. package/dist/graph.js +149 -39
  13. package/dist/graph.js.map +1 -1
  14. package/dist/index.d.ts +27 -3
  15. package/dist/index.js +254 -41
  16. package/dist/index.js.map +1 -1
  17. package/dist/inferrer.d.ts +5 -0
  18. package/dist/inferrer.js +1 -1
  19. package/dist/inferrer.js.map +1 -1
  20. package/dist/mappers/ts-concepts.js +31 -6
  21. package/dist/mappers/ts-concepts.js.map +1 -1
  22. package/dist/public-api.d.ts +73 -0
  23. package/dist/public-api.js +351 -0
  24. package/dist/public-api.js.map +1 -0
  25. package/dist/reporter.d.ts +5 -0
  26. package/dist/reporter.js +119 -84
  27. package/dist/reporter.js.map +1 -1
  28. package/dist/review-health.d.ts +38 -0
  29. package/dist/review-health.js +60 -0
  30. package/dist/review-health.js.map +1 -0
  31. package/dist/rules/a11y.d.ts +10 -0
  32. package/dist/rules/a11y.js +294 -0
  33. package/dist/rules/a11y.js.map +1 -0
  34. package/dist/rules/async.d.ts +8 -0
  35. package/dist/rules/async.js +142 -0
  36. package/dist/rules/async.js.map +1 -0
  37. package/dist/rules/base.js +112 -87
  38. package/dist/rules/base.js.map +1 -1
  39. package/dist/rules/confidence.d.ts +2 -2
  40. package/dist/rules/confidence.js +32 -15
  41. package/dist/rules/confidence.js.map +1 -1
  42. package/dist/rules/dead-code.d.ts +2 -1
  43. package/dist/rules/dead-code.js +49 -3
  44. package/dist/rules/dead-code.js.map +1 -1
  45. package/dist/rules/index.d.ts +12 -0
  46. package/dist/rules/index.js +414 -4
  47. package/dist/rules/index.js.map +1 -1
  48. package/dist/rules/ink.js +41 -0
  49. package/dist/rules/ink.js.map +1 -1
  50. package/dist/rules/kern-source-cross-file.d.ts +2 -0
  51. package/dist/rules/kern-source-cross-file.js +102 -0
  52. package/dist/rules/kern-source-cross-file.js.map +1 -0
  53. package/dist/rules/kern-source.js +145 -18
  54. package/dist/rules/kern-source.js.map +1 -1
  55. package/dist/rules/nextjs-app-router.d.ts +11 -0
  56. package/dist/rules/nextjs-app-router.js +1182 -0
  57. package/dist/rules/nextjs-app-router.js.map +1 -0
  58. package/dist/rules/nextjs.js +266 -7
  59. package/dist/rules/nextjs.js.map +1 -1
  60. package/dist/rules/perf.d.ts +11 -0
  61. package/dist/rules/perf.js +131 -0
  62. package/dist/rules/perf.js.map +1 -0
  63. package/dist/rules/react-composition.d.ts +12 -0
  64. package/dist/rules/react-composition.js +741 -0
  65. package/dist/rules/react-composition.js.map +1 -0
  66. package/dist/rules/react-hooks.d.ts +11 -0
  67. package/dist/rules/react-hooks.js +429 -0
  68. package/dist/rules/react-hooks.js.map +1 -0
  69. package/dist/rules/react.js +265 -49
  70. package/dist/rules/react.js.map +1 -1
  71. package/dist/rules/security-v5.d.ts +11 -0
  72. package/dist/rules/security-v5.js +200 -0
  73. package/dist/rules/security-v5.js.map +1 -0
  74. package/dist/rules/utils.d.ts +52 -1
  75. package/dist/rules/utils.js +159 -0
  76. package/dist/rules/utils.js.map +1 -1
  77. package/dist/semantic-diff.js +1 -1
  78. package/dist/semantic-diff.js.map +1 -1
  79. package/dist/taint-ast.js +260 -10
  80. package/dist/taint-ast.js.map +1 -1
  81. package/dist/taint-crossfile.d.ts +30 -2
  82. package/dist/taint-crossfile.js +280 -59
  83. package/dist/taint-crossfile.js.map +1 -1
  84. package/dist/taint-findings.js +3 -0
  85. package/dist/taint-findings.js.map +1 -1
  86. package/dist/taint-types.d.ts +4 -3
  87. package/dist/taint-types.js +70 -6
  88. package/dist/taint-types.js.map +1 -1
  89. package/dist/taint.d.ts +1 -1
  90. package/dist/taint.js +1 -1
  91. package/dist/taint.js.map +1 -1
  92. package/dist/types.d.ts +98 -0
  93. package/dist/types.js.map +1 -1
  94. package/package.json +3 -3
@@ -0,0 +1,741 @@
1
+ /**
2
+ * React composition rules — catch prop-drilling and "parent rerenders child
3
+ * that doesn't depend on parent state" antipatterns.
4
+ *
5
+ * These rules push toward the `children` prop pattern, which preserves
6
+ * element identity across parent renders and lets React skip reconciliation
7
+ * of unchanged subtrees.
8
+ */
9
+ import { existsSync, readFileSync } from 'fs';
10
+ import { dirname, resolve } from 'path';
11
+ import { Node, Project, SyntaxKind } from 'ts-morph';
12
+ import { finding, nodeSpan } from './utils.js';
13
+ /** Is this node a React component function? (Capitalized name + returns JSX) */
14
+ function isComponentFunction(node) {
15
+ let name = '';
16
+ if (Node.isFunctionDeclaration(node)) {
17
+ name = node.getName() ?? '';
18
+ }
19
+ else {
20
+ // Arrow/function expression — look at the parent variable declaration
21
+ const parent = node.getParent();
22
+ if (parent && Node.isVariableDeclaration(parent)) {
23
+ const n = parent.getNameNode();
24
+ if (Node.isIdentifier(n))
25
+ name = n.getText();
26
+ }
27
+ }
28
+ if (!name || !/^[A-Z]/.test(name))
29
+ return { name, isComponent: false };
30
+ // Must contain JSX somewhere in the body
31
+ const body = node.getBody();
32
+ if (!body)
33
+ return { name, isComponent: false };
34
+ const hasJsx = body.getDescendantsOfKind(SyntaxKind.JsxOpeningElement).length > 0 ||
35
+ body.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement).length > 0 ||
36
+ body.getDescendantsOfKind(SyntaxKind.JsxFragment).length > 0;
37
+ return { name, isComponent: hasJsx };
38
+ }
39
+ /** Extract destructured prop names from the first parameter of a component function. */
40
+ function getDestructuredPropBindings(fn) {
41
+ const params = fn.getParameters();
42
+ if (params.length === 0)
43
+ return undefined;
44
+ const nameNode = params[0].getNameNode();
45
+ if (!Node.isObjectBindingPattern(nameNode))
46
+ return undefined;
47
+ const bindings = [];
48
+ for (const el of nameNode.getElements()) {
49
+ // Use the property name if aliased, otherwise the binding name
50
+ const propName = el.getPropertyNameNode()?.getText() ?? el.getNameNode().getText();
51
+ const localName = el.getNameNode().getText();
52
+ bindings.push({ propName, localName });
53
+ }
54
+ return bindings;
55
+ }
56
+ function getPropsParamName(fn) {
57
+ const params = fn.getParameters();
58
+ if (params.length === 0)
59
+ return undefined;
60
+ const nameNode = params[0].getNameNode();
61
+ if (Node.isIdentifier(nameNode))
62
+ return nameNode.getText();
63
+ return undefined;
64
+ }
65
+ function iterComponentFunctions(ctx) {
66
+ const results = [];
67
+ for (const fn of ctx.sourceFile.getFunctions()) {
68
+ const info = isComponentFunction(fn);
69
+ if (info.isComponent)
70
+ results.push(fn);
71
+ }
72
+ for (const stmt of ctx.sourceFile.getVariableStatements()) {
73
+ for (const decl of stmt.getDeclarations()) {
74
+ const init = decl.getInitializer();
75
+ if (!init)
76
+ continue;
77
+ if (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) {
78
+ const info = isComponentFunction(init);
79
+ if (info.isComponent)
80
+ results.push(init);
81
+ }
82
+ }
83
+ }
84
+ return results;
85
+ }
86
+ // ── Rule: children-not-used ──────────────────────────────────────────────
87
+ // Component accepts `children` in its destructured props but never renders it.
88
+ function childrenNotUsed(ctx) {
89
+ const findings = [];
90
+ for (const fn of iterComponentFunctions(ctx)) {
91
+ const propBindings = getDestructuredPropBindings(fn);
92
+ if (!propBindings?.some((p) => p.propName === 'children'))
93
+ continue;
94
+ const body = fn.getBody();
95
+ if (!body)
96
+ continue;
97
+ // Look for any identifier reference to `children` in the body
98
+ let rendered = false;
99
+ for (const id of body.getDescendantsOfKind(SyntaxKind.Identifier)) {
100
+ if (id.getText() !== 'children')
101
+ continue;
102
+ // Skip the declaration in the parameter binding — we want usage, not the binding itself
103
+ const parent = id.getParent();
104
+ if (parent && Node.isBindingElement(parent))
105
+ continue;
106
+ rendered = true;
107
+ break;
108
+ }
109
+ if (!rendered) {
110
+ const { name } = isComponentFunction(fn);
111
+ // Autofix: remove the `children` entry from the destructured props
112
+ // pattern. Only applies when the binding pattern is simple (no renames,
113
+ // defaults, or rest — those are fine, we just leave them alone here).
114
+ let autofixAction;
115
+ const firstParam = fn.getParameters()[0];
116
+ if (firstParam) {
117
+ const nameNode = firstParam.getNameNode();
118
+ if (Node.isObjectBindingPattern(nameNode)) {
119
+ const elements = nameNode.getElements();
120
+ const remaining = elements.filter((el) => {
121
+ const propName = el.getPropertyNameNode()?.getText() ?? el.getNameNode().getText();
122
+ return propName !== 'children';
123
+ });
124
+ // Reconstruct a clean `{ a, b, c }` pattern using each element's
125
+ // original text. Preserves renames, defaults, and rest operators.
126
+ const rebuilt = `{ ${remaining.map((el) => el.getText()).join(', ')} }`;
127
+ autofixAction = {
128
+ type: 'replace',
129
+ span: nodeSpan(nameNode, ctx.filePath),
130
+ replacement: rebuilt,
131
+ description: `Remove unused 'children' from the props destructuring`,
132
+ };
133
+ }
134
+ }
135
+ findings.push(finding('children-not-used', 'warning', 'pattern', `'${name}' destructures 'children' from props but never renders it — dead API or forgotten {children}`, ctx.filePath, fn.getStartLineNumber(), 1, {
136
+ suggestion: `Render {children} in the JSX output, or remove 'children' from the props destructuring if the component should not accept children`,
137
+ ...(autofixAction ? { autofix: autofixAction } : {}),
138
+ }));
139
+ }
140
+ }
141
+ return findings;
142
+ }
143
+ // ── Rule: prop-drill-passthrough ─────────────────────────────────────────
144
+ // Component receives >= 3 props, body is a single JSX element, and >= 2 of
145
+ // those props are passed unchanged to that element without being read anywhere
146
+ // else. Suggest `children` or context.
147
+ function getSingleReturnedJsx(fn) {
148
+ const body = fn.getBody();
149
+ if (!body)
150
+ return undefined;
151
+ // Case 1: arrow function with implicit return — body IS the JSX
152
+ if (Node.isJsxElement(body))
153
+ return body.getOpeningElement();
154
+ if (Node.isJsxSelfClosingElement(body))
155
+ return body;
156
+ if (Node.isJsxFragment(body))
157
+ return undefined; // fragments have multiple children
158
+ // Case 2: block body — look for a single return statement at the top level
159
+ if (Node.isBlock(body)) {
160
+ const statements = body.getStatements();
161
+ // Allow preamble (const x = ..., hook calls) but require the LAST statement to be a return with a single JSX root
162
+ const ret = statements.find((s) => Node.isReturnStatement(s));
163
+ if (!ret || !Node.isReturnStatement(ret))
164
+ return undefined;
165
+ const expr = ret.getExpression();
166
+ if (!expr)
167
+ return undefined;
168
+ // Walk through parentheses
169
+ let unwrapped = expr;
170
+ while (Node.isParenthesizedExpression(unwrapped)) {
171
+ unwrapped = unwrapped.getExpression();
172
+ }
173
+ if (Node.isJsxElement(unwrapped))
174
+ return unwrapped.getOpeningElement();
175
+ if (Node.isJsxSelfClosingElement(unwrapped))
176
+ return unwrapped;
177
+ }
178
+ return undefined;
179
+ }
180
+ function analyzePassthroughComponent(fn) {
181
+ const propBindings = getDestructuredPropBindings(fn) ?? [];
182
+ const propsParamName = getPropsParamName(fn);
183
+ if (propBindings.length === 0 && !propsParamName)
184
+ return undefined;
185
+ const root = getSingleReturnedJsx(fn);
186
+ if (!root)
187
+ return undefined;
188
+ const tag = root.getTagNameNode().getText();
189
+ if (!/^[A-Z]/.test(tag))
190
+ return undefined;
191
+ const bindingByLocal = new Map(propBindings.map((b) => [b.localName, b]));
192
+ const passedToChild = new Map();
193
+ for (const attr of root.getAttributes()) {
194
+ if (!Node.isJsxAttribute(attr))
195
+ continue;
196
+ const init = attr.getInitializer();
197
+ if (!init)
198
+ continue;
199
+ if (!Node.isJsxExpression(init))
200
+ continue;
201
+ const expr = init.getExpression();
202
+ if (!expr)
203
+ continue;
204
+ if (Node.isIdentifier(expr)) {
205
+ const binding = bindingByLocal.get(expr.getText());
206
+ if (binding) {
207
+ passedToChild.set(binding.propName, { attrExpr: expr, localName: binding.localName });
208
+ }
209
+ continue;
210
+ }
211
+ if (propsParamName && Node.isPropertyAccessExpression(expr)) {
212
+ const obj = expr.getExpression();
213
+ if (Node.isIdentifier(obj) && obj.getText() === propsParamName) {
214
+ passedToChild.set(expr.getName(), { attrExpr: expr });
215
+ }
216
+ }
217
+ }
218
+ if (passedToChild.size < 2)
219
+ return undefined;
220
+ const body = fn.getBody();
221
+ if (!body)
222
+ return undefined;
223
+ const consumedProps = new Set();
224
+ for (const [propName, { attrExpr, localName }] of passedToChild) {
225
+ if (propName === 'children')
226
+ continue;
227
+ if (localName) {
228
+ for (const id of body.getDescendantsOfKind(SyntaxKind.Identifier)) {
229
+ if (id.getText() !== localName)
230
+ continue;
231
+ const parent = id.getParent();
232
+ if (parent && Node.isBindingElement(parent))
233
+ continue;
234
+ if (parent && Node.isJsxAttribute(parent) && parent.getNameNode() === id)
235
+ continue;
236
+ if (id === attrExpr)
237
+ continue;
238
+ consumedProps.add(propName);
239
+ break;
240
+ }
241
+ }
242
+ else if (propsParamName) {
243
+ for (const access of body.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
244
+ if (access === attrExpr)
245
+ continue;
246
+ const obj = access.getExpression();
247
+ if (Node.isIdentifier(obj) && obj.getText() === propsParamName && access.getName() === propName) {
248
+ consumedProps.add(propName);
249
+ break;
250
+ }
251
+ }
252
+ }
253
+ }
254
+ const passthroughProps = [...passedToChild.keys()].filter((p) => p !== 'children' && !consumedProps.has(p));
255
+ if (passthroughProps.length < 2)
256
+ return undefined;
257
+ const info = isComponentFunction(fn);
258
+ return {
259
+ componentName: info.name,
260
+ childTag: tag,
261
+ passthroughProps,
262
+ };
263
+ }
264
+ function findComponentFunctionByName(sourceFile, componentName) {
265
+ for (const fn of sourceFile.getFunctions()) {
266
+ const info = isComponentFunction(fn);
267
+ if (info.isComponent && info.name === componentName)
268
+ return fn;
269
+ }
270
+ for (const stmt of sourceFile.getVariableStatements()) {
271
+ for (const decl of stmt.getDeclarations()) {
272
+ const init = decl.getInitializer();
273
+ if (!init)
274
+ continue;
275
+ if (!Node.isArrowFunction(init) && !Node.isFunctionExpression(init))
276
+ continue;
277
+ const info = isComponentFunction(init);
278
+ if (info.isComponent && info.name === componentName)
279
+ return init;
280
+ }
281
+ }
282
+ return undefined;
283
+ }
284
+ function isMemoCall(expr) {
285
+ return Node.isCallExpression(expr) && ['memo', 'React.memo'].includes(expr.getExpression().getText());
286
+ }
287
+ function findVariableDeclarationByName(sourceFile, variableName) {
288
+ for (const stmt of sourceFile.getVariableStatements()) {
289
+ for (const decl of stmt.getDeclarations()) {
290
+ if (decl.getName() === variableName)
291
+ return decl;
292
+ }
293
+ }
294
+ return undefined;
295
+ }
296
+ function findImportBinding(ctx, localName) {
297
+ for (const decl of ctx.sourceFile.getImportDeclarations()) {
298
+ const defaultImport = decl.getDefaultImport();
299
+ if (defaultImport?.getText() === localName) {
300
+ return { importDecl: decl, importedName: 'default', isDefault: true };
301
+ }
302
+ for (const named of decl.getNamedImports()) {
303
+ const boundLocal = named.getAliasNode()?.getText() ?? named.getNameNode().getText();
304
+ if (boundLocal === localName) {
305
+ return { importDecl: decl, importedName: named.getNameNode().getText(), isDefault: false };
306
+ }
307
+ }
308
+ }
309
+ return undefined;
310
+ }
311
+ function findDefaultExportedComponentFunction(sourceFile) {
312
+ for (const fn of sourceFile.getFunctions()) {
313
+ const info = isComponentFunction(fn);
314
+ if (info.isComponent && fn.isDefaultExport())
315
+ return fn;
316
+ }
317
+ for (const assign of sourceFile.getExportAssignments()) {
318
+ const expr = assign.getExpression();
319
+ if (!expr)
320
+ continue;
321
+ if (Node.isIdentifier(expr)) {
322
+ const resolved = findComponentFunctionByName(sourceFile, expr.getText());
323
+ if (resolved)
324
+ return resolved;
325
+ }
326
+ if (isMemoCall(expr)) {
327
+ const firstArg = expr.getArguments()[0];
328
+ if (firstArg && (Node.isArrowFunction(firstArg) || Node.isFunctionExpression(firstArg))) {
329
+ const info = isComponentFunction(firstArg);
330
+ if (info.isComponent)
331
+ return firstArg;
332
+ }
333
+ }
334
+ }
335
+ return undefined;
336
+ }
337
+ function findImportedComponentFunction(sourceFile, binding) {
338
+ return binding.isDefault
339
+ ? findDefaultExportedComponentFunction(sourceFile)
340
+ : findComponentFunctionByName(sourceFile, binding.importedName);
341
+ }
342
+ function isMemoizedExport(sourceFile, binding) {
343
+ if (binding.isDefault) {
344
+ for (const assign of sourceFile.getExportAssignments()) {
345
+ const expr = assign.getExpression();
346
+ if (!expr)
347
+ continue;
348
+ if (isMemoCall(expr))
349
+ return true;
350
+ if (Node.isIdentifier(expr)) {
351
+ const decl = findVariableDeclarationByName(sourceFile, expr.getText());
352
+ if (decl && isMemoCall(decl.getInitializer()))
353
+ return true;
354
+ }
355
+ }
356
+ return false;
357
+ }
358
+ const decl = findVariableDeclarationByName(sourceFile, binding.importedName);
359
+ return !!decl && isMemoCall(decl.getInitializer());
360
+ }
361
+ function resolveImportedSourceFile(ctx, importDecl) {
362
+ let resolved;
363
+ try {
364
+ resolved = importDecl.getModuleSpecifierSourceFile() ?? undefined;
365
+ }
366
+ catch {
367
+ return undefined;
368
+ }
369
+ if (resolved) {
370
+ // The main Project caches resolved source files across reviewFile calls. If the file on disk
371
+ // changed since the last review (watch mode, test re-runs), refresh it so the rule sees fresh content.
372
+ try {
373
+ resolved.refreshFromFileSystemSync();
374
+ }
375
+ catch {
376
+ // File may have been deleted — caller will decide.
377
+ }
378
+ return resolved;
379
+ }
380
+ const spec = importDecl.getModuleSpecifierValue();
381
+ if (!spec.startsWith('.'))
382
+ return undefined;
383
+ const baseDir = dirname(ctx.filePath);
384
+ const candidates = [];
385
+ if (/\.[cm]?[jt]sx?$/.test(spec)) {
386
+ candidates.push(resolve(baseDir, spec));
387
+ if (spec.endsWith('.js')) {
388
+ candidates.push(resolve(baseDir, `${spec.slice(0, -3)}.ts`));
389
+ candidates.push(resolve(baseDir, `${spec.slice(0, -3)}.tsx`));
390
+ }
391
+ else if (spec.endsWith('.jsx')) {
392
+ candidates.push(resolve(baseDir, `${spec.slice(0, -4)}.tsx`));
393
+ }
394
+ }
395
+ else {
396
+ candidates.push(resolve(baseDir, `${spec}.ts`));
397
+ candidates.push(resolve(baseDir, `${spec}.tsx`));
398
+ candidates.push(resolve(baseDir, `${spec}/index.ts`));
399
+ candidates.push(resolve(baseDir, `${spec}/index.tsx`));
400
+ }
401
+ for (const candidate of candidates) {
402
+ if (!existsSync(candidate))
403
+ continue;
404
+ const auxProject = new Project({
405
+ useInMemoryFileSystem: true,
406
+ skipAddingFilesFromTsConfig: true,
407
+ compilerOptions: { target: 99, module: 99, moduleResolution: 100, jsx: 4 },
408
+ });
409
+ return auxProject.createSourceFile(candidate, readFileSync(candidate, 'utf-8'), { overwrite: true });
410
+ }
411
+ return undefined;
412
+ }
413
+ function propDrillPassthrough(ctx) {
414
+ const findings = [];
415
+ for (const fn of iterComponentFunctions(ctx)) {
416
+ const analysis = analyzePassthroughComponent(fn);
417
+ if (analysis) {
418
+ const passthroughCount = analysis.passthroughProps.length;
419
+ findings.push(finding('prop-drill-passthrough', 'warning', 'pattern', `'${analysis.componentName}' passes ${passthroughCount} prop${passthroughCount === 1 ? '' : 's'} (${analysis.passthroughProps.join(', ')}) through to <${analysis.childTag}> without reading ${passthroughCount === 1 ? 'it' : 'them'} — consider 'children' prop or React context`, ctx.filePath, fn.getStartLineNumber(), 1, {
420
+ suggestion: `Accept <${analysis.childTag} .../> as the 'children' prop, or move the shared data into a React context. Passing props through an intermediate component forces it to re-render whenever any of them change.`,
421
+ }));
422
+ }
423
+ }
424
+ return findings;
425
+ }
426
+ // ── Rule: prop-drill-chain ───────────────────────────────────────────────
427
+ // Current file passes props into an imported wrapper component that itself
428
+ // passes those same props onward without reading them. Walks up to MAX_HOPS
429
+ // imported components to detect drilling that spans 3+ files, not just 2.
430
+ const MAX_PROP_DRILL_HOPS = 3;
431
+ function walkPropDrillChain(initialCarriedProps, initialBinding, ctx) {
432
+ const hops = [];
433
+ const visitedFiles = new Set([ctx.filePath]);
434
+ const analysisCache = new Map();
435
+ let currentCarriedProps = initialCarriedProps;
436
+ let currentBinding = initialBinding;
437
+ let currentSf;
438
+ for (let hopIdx = 0; hopIdx < MAX_PROP_DRILL_HOPS; hopIdx++) {
439
+ if (!currentBinding)
440
+ break;
441
+ currentSf = resolveImportedSourceFile(hopIdx === 0 ? ctx : { ...ctx, filePath: currentSf.getFilePath(), sourceFile: currentSf }, currentBinding.importDecl);
442
+ if (!currentSf)
443
+ break;
444
+ const nextFilePath = currentSf.getFilePath();
445
+ if (visitedFiles.has(nextFilePath))
446
+ break;
447
+ visitedFiles.add(nextFilePath);
448
+ const importedFn = findImportedComponentFunction(currentSf, currentBinding);
449
+ if (!importedFn)
450
+ break;
451
+ const cacheKey = `${nextFilePath}::${currentBinding.importedName}::${currentBinding.isDefault}`;
452
+ let analysis = analysisCache.get(cacheKey);
453
+ if (analysis === undefined) {
454
+ analysis = analyzePassthroughComponent(importedFn);
455
+ analysisCache.set(cacheKey, analysis);
456
+ }
457
+ if (!analysis)
458
+ break;
459
+ const sharedProps = currentCarriedProps.filter((p) => analysis.passthroughProps.includes(p));
460
+ if (sharedProps.length < 2)
461
+ break;
462
+ hops.push({
463
+ componentName: analysis.componentName,
464
+ childTag: analysis.childTag,
465
+ filePath: nextFilePath,
466
+ props: sharedProps,
467
+ });
468
+ const nextCtx = { ...ctx, filePath: nextFilePath, sourceFile: currentSf };
469
+ const nextBinding = findImportBinding(nextCtx, analysis.childTag);
470
+ if (!nextBinding)
471
+ break;
472
+ currentCarriedProps = sharedProps;
473
+ currentBinding = nextBinding;
474
+ }
475
+ return hops;
476
+ }
477
+ function propDrillChain(ctx) {
478
+ const findings = [];
479
+ for (const fn of iterComponentFunctions(ctx)) {
480
+ const localAnalysis = analyzePassthroughComponent(fn);
481
+ if (!localAnalysis)
482
+ continue;
483
+ const binding = findImportBinding(ctx, localAnalysis.childTag);
484
+ if (!binding)
485
+ continue;
486
+ const hops = walkPropDrillChain(localAnalysis.passthroughProps, binding, ctx);
487
+ if (hops.length === 0)
488
+ continue;
489
+ const firstHop = hops[0];
490
+ const sharedProps = firstHop.props;
491
+ // Describe the chain: local → first imported wrapper → ... → last wrapper's child
492
+ const chainDesc = hops.length === 1
493
+ ? `<${localAnalysis.childTag}>, which then passes them through to <${firstHop.childTag}>`
494
+ : `<${localAnalysis.childTag}> → ${hops.map((h) => `<${h.componentName}>`).join(' → ')} → <${hops[hops.length - 1].childTag}>`;
495
+ findings.push(finding('prop-drill-chain', 'warning', 'pattern', `'${localAnalysis.componentName}' drills props (${sharedProps.join(', ')}) across ${hops.length + 1} component${hops.length + 1 === 1 ? '' : 's'}: ${chainDesc}`, ctx.filePath, fn.getStartLineNumber(), 1, {
496
+ suggestion: 'Collapse the intermediate wrappers, switch to children-based composition, or lift the shared data into React context so the props stop crossing multiple component boundaries',
497
+ }));
498
+ }
499
+ return findings;
500
+ }
501
+ // ── Rule: memoized-child-inline-prop ─────────────────────────────────────
502
+ // Inline object/array/function props create a new identity every render and
503
+ // defeat React.memo's shallow prop comparison for that child.
504
+ function collectMemoizedComponentNames(ctx) {
505
+ const names = new Set();
506
+ for (const decl of ctx.sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
507
+ if (!isMemoCall(decl.getInitializer()))
508
+ continue;
509
+ const nameNode = decl.getNameNode();
510
+ if (Node.isIdentifier(nameNode) && /^[A-Z]/.test(nameNode.getText())) {
511
+ names.add(nameNode.getText());
512
+ }
513
+ }
514
+ return names;
515
+ }
516
+ function memoizedChildInlineProp(ctx) {
517
+ const findings = [];
518
+ const memoizedNames = collectMemoizedComponentNames(ctx);
519
+ const memoizedImportCache = new Map();
520
+ for (const fn of iterComponentFunctions(ctx)) {
521
+ const body = fn.getBody();
522
+ if (!body)
523
+ continue;
524
+ const jsxNodes = [
525
+ ...body.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
526
+ ...body.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
527
+ ];
528
+ for (const jsx of jsxNodes) {
529
+ const tag = jsx.getTagNameNode().getText();
530
+ let isMemoizedChild = memoizedNames.has(tag);
531
+ if (!isMemoizedChild) {
532
+ if (!memoizedImportCache.has(tag)) {
533
+ const binding = findImportBinding(ctx, tag);
534
+ const importedSf = binding ? resolveImportedSourceFile(ctx, binding.importDecl) : undefined;
535
+ memoizedImportCache.set(tag, !!(binding && importedSf && isMemoizedExport(importedSf, binding)));
536
+ }
537
+ isMemoizedChild = memoizedImportCache.get(tag) ?? false;
538
+ }
539
+ if (!isMemoizedChild)
540
+ continue;
541
+ const unstableProps = [];
542
+ for (const attr of jsx.getAttributes()) {
543
+ if (!Node.isJsxAttribute(attr))
544
+ continue;
545
+ const attrName = attr.getNameNode().getText();
546
+ const init = attr.getInitializer();
547
+ if (!init || !Node.isJsxExpression(init))
548
+ continue;
549
+ const expr = init.getExpression();
550
+ if (!expr)
551
+ continue;
552
+ const isUnstable = Node.isArrowFunction(expr) ||
553
+ Node.isFunctionExpression(expr) ||
554
+ Node.isObjectLiteralExpression(expr) ||
555
+ Node.isArrayLiteralExpression(expr);
556
+ if (isUnstable)
557
+ unstableProps.push(attrName);
558
+ }
559
+ if (unstableProps.length === 0)
560
+ continue;
561
+ findings.push(finding('memoized-child-inline-prop', 'warning', 'pattern', `<${tag}> is memoized with React.memo, but inline prop${unstableProps.length === 1 ? '' : 's'} (${unstableProps.join(', ')}) create a new identity every render and defeat memoization`, ctx.filePath, jsx.getStartLineNumber(), 1, {
562
+ suggestion: 'Hoist static literals, memoize object/array props with useMemo, and memoize callback props with useCallback before passing them to a memoized child',
563
+ }));
564
+ }
565
+ }
566
+ return findings;
567
+ }
568
+ // ── Rule: memoized-child-inline-children ─────────────────────────────────
569
+ // Inline JSX children create fresh React element objects every render, so a
570
+ // React.memo child receiving them through `children` cannot bail out.
571
+ function memoizedChildInlineChildren(ctx) {
572
+ const findings = [];
573
+ const memoizedNames = collectMemoizedComponentNames(ctx);
574
+ const memoizedImportCache = new Map();
575
+ for (const fn of iterComponentFunctions(ctx)) {
576
+ const body = fn.getBody();
577
+ if (!body)
578
+ continue;
579
+ for (const jsx of body.getDescendantsOfKind(SyntaxKind.JsxElement)) {
580
+ const opening = jsx.getOpeningElement();
581
+ const tag = opening.getTagNameNode().getText();
582
+ let isMemoizedChild = memoizedNames.has(tag);
583
+ if (!isMemoizedChild) {
584
+ if (!memoizedImportCache.has(tag)) {
585
+ const binding = findImportBinding(ctx, tag);
586
+ const importedSf = binding ? resolveImportedSourceFile(ctx, binding.importDecl) : undefined;
587
+ memoizedImportCache.set(tag, !!(binding && importedSf && isMemoizedExport(importedSf, binding)));
588
+ }
589
+ isMemoizedChild = memoizedImportCache.get(tag) ?? false;
590
+ }
591
+ if (!isMemoizedChild)
592
+ continue;
593
+ const unstableChildren = jsx.getJsxChildren().filter((child) => Node.isJsxElement(child) ||
594
+ Node.isJsxSelfClosingElement(child) ||
595
+ Node.isJsxFragment(child) ||
596
+ (Node.isJsxExpression(child) &&
597
+ (() => {
598
+ const expr = child.getExpression();
599
+ return (expr != null &&
600
+ (Node.isArrowFunction(expr) ||
601
+ Node.isFunctionExpression(expr) ||
602
+ Node.isObjectLiteralExpression(expr) ||
603
+ Node.isArrayLiteralExpression(expr)));
604
+ })()));
605
+ if (unstableChildren.length === 0)
606
+ continue;
607
+ findings.push(finding('memoized-child-inline-children', 'warning', 'pattern', `<${tag}> is memoized with React.memo, but its inline children create new React element identities every render and defeat memoization`, ctx.filePath, opening.getStartLineNumber(), 1, {
608
+ suggestion: 'Hoist the child subtree outside the parent render, memoize it with useMemo, or restructure the component so the memoized child receives stable primitive props instead of inline children',
609
+ }));
610
+ }
611
+ }
612
+ return findings;
613
+ }
614
+ // ── Rule: parent-rerender-via-state ──────────────────────────────────────
615
+ // Component holds useState AND renders a child component that receives NEITHER
616
+ // the state variables NOR the setters. That child will re-render on every
617
+ // state change for no reason — lifting it to `children` preserves its element
618
+ // identity and avoids the re-render.
619
+ /**
620
+ * Get the DIRECT-child JSX elements of the top-level return. Skips nested
621
+ * descendants, elements inside callbacks (map renderers), and elements deep
622
+ * in conditional branches. This is the key guard against false positives:
623
+ * we only care about JSX that the parent component's own render produces
624
+ * positionally — those are the elements that could be lifted to `children`.
625
+ */
626
+ function getDirectChildrenOfReturn(root) {
627
+ // If the root is already a self-closing element, there are no direct JSX children.
628
+ if (Node.isJsxSelfClosingElement(root))
629
+ return [root];
630
+ // Root is a JsxOpeningElement — walk its parent JsxElement children once.
631
+ const parent = root.getParent();
632
+ if (!parent || !Node.isJsxElement(parent))
633
+ return [root];
634
+ const result = [root];
635
+ for (const child of parent.getJsxChildren()) {
636
+ if (Node.isJsxElement(child)) {
637
+ result.push(child.getOpeningElement());
638
+ }
639
+ else if (Node.isJsxSelfClosingElement(child)) {
640
+ result.push(child);
641
+ }
642
+ // Skip JsxExpression / JsxText / JsxFragment content — too dynamic to reason about
643
+ }
644
+ return result;
645
+ }
646
+ /**
647
+ * Does this expression text mention any of the state variables? Wraps each
648
+ * variable in \b boundaries and tests the combined text. Handles callbacks
649
+ * too (e.g. onClick={() => setCount(c => c + 1)} — we treat ANY reference
650
+ * to setCount as a legitimate state dependency).
651
+ */
652
+ function mentionsStateVars(text, stateVars) {
653
+ for (const v of stateVars) {
654
+ if (new RegExp(`\\b${v}\\b`).test(text))
655
+ return true;
656
+ }
657
+ return false;
658
+ }
659
+ function parentRerenderViaState(ctx) {
660
+ const findings = [];
661
+ for (const fn of iterComponentFunctions(ctx)) {
662
+ const body = fn.getBody();
663
+ if (!body)
664
+ continue;
665
+ // Collect state variable names AND setter names from useState/useReducer.
666
+ // Both the value and the setter count as "state refs" — a child that
667
+ // receives `setCount` is wiring to state and should NOT be flagged.
668
+ const stateVars = new Set();
669
+ for (const decl of body.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
670
+ const init = decl.getInitializer();
671
+ if (!init || !Node.isCallExpression(init))
672
+ continue;
673
+ const calleeText = init.getExpression().getText();
674
+ const calleeName = calleeText.includes('.') ? calleeText.split('.').pop() : calleeText;
675
+ if (calleeName !== 'useState' && calleeName !== 'useReducer')
676
+ continue;
677
+ const nameNode = decl.getNameNode();
678
+ if (!Node.isArrayBindingPattern(nameNode))
679
+ continue;
680
+ for (const el of nameNode.getElements()) {
681
+ if (Node.isBindingElement(el)) {
682
+ stateVars.add(el.getNameNode().getText());
683
+ }
684
+ }
685
+ }
686
+ if (stateVars.size === 0)
687
+ continue;
688
+ // Already composing with children? Skip — the user is on the correct path.
689
+ const propBindings = getDestructuredPropBindings(fn);
690
+ const alreadyComposesChildren = propBindings?.some((p) => p.propName === 'children') ?? false;
691
+ if (alreadyComposesChildren)
692
+ continue;
693
+ // Require a clean single-root returned JSX tree. Fragments, conditional
694
+ // returns, and dynamic structures are too ambiguous to reason about
695
+ // without a real dataflow pass — skip them.
696
+ const root = getSingleReturnedJsx(fn);
697
+ if (!root)
698
+ continue;
699
+ // Only look at the DIRECT children of the returned root. Nested helper
700
+ // JSX inside map callbacks, conditional branches, or deep descendants
701
+ // are not flaggable — they may close over state transitively.
702
+ const candidates = getDirectChildrenOfReturn(root);
703
+ for (const el of candidates) {
704
+ const tag = el.getTagNameNode().getText();
705
+ if (!/^[A-Z]/.test(tag))
706
+ continue; // HTML element — not a rerender target we care about
707
+ // Does this child receive any state var (or setter) via attributes?
708
+ // Scan the entire attribute bag's text in one pass so callback props
709
+ // like onClick={() => setCount(c => c + 1)} count as state-dependent.
710
+ const attrsText = el
711
+ .getAttributes()
712
+ .map((a) => (Node.isJsxAttribute(a) ? a.getText() : ''))
713
+ .join(' ');
714
+ if (mentionsStateVars(attrsText, stateVars))
715
+ continue;
716
+ // Is this element inside a JsxExpression that references state (a
717
+ // conditional render like `{count > 0 && <Child />}` or a map based
718
+ // on state)? Walk up the JSX container chain.
719
+ const containingExpr = el.getFirstAncestorByKind(SyntaxKind.JsxExpression);
720
+ if (containingExpr && mentionsStateVars(containingExpr.getText(), stateVars))
721
+ continue;
722
+ // Flag: this direct child never sees state and re-renders unnecessarily.
723
+ const info = isComponentFunction(fn);
724
+ findings.push(finding('parent-rerender-via-state', 'info', 'pattern', `<${tag}> is rendered by '${info.name}' but does not receive any of its state variables (${[...stateVars].slice(0, 3).join(', ')}${stateVars.size > 3 ? '…' : ''}) — it re-renders on every state change. Consider lifting it to the 'children' prop so React can reuse the element.`, ctx.filePath, el.getStartLineNumber(), 1, {
725
+ suggestion: `Accept <${tag}> as the 'children' prop of '${info.name}' and render it with {children}. The caller composes: <${info.name}><${tag} /></${info.name}>. React will reuse the child element across re-renders.`,
726
+ }));
727
+ break; // one finding per component is enough — avoid noise
728
+ }
729
+ }
730
+ return findings;
731
+ }
732
+ // ── Exported composition rules ───────────────────────────────────────────
733
+ export const reactCompositionRules = [
734
+ childrenNotUsed,
735
+ propDrillPassthrough,
736
+ propDrillChain,
737
+ memoizedChildInlineProp,
738
+ memoizedChildInlineChildren,
739
+ parentRerenderViaState,
740
+ ];
741
+ //# sourceMappingURL=react-composition.js.map