oxlint-plugin-react-doctor 0.2.10 → 0.2.11-dev.f036b0f

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.d.ts CHANGED
@@ -82,10 +82,17 @@ interface ScopeAnalysis {
82
82
  //#region src/plugin/utils/rule-context.d.ts
83
83
  interface BaseRuleContext {
84
84
  report: (descriptor: ReportDescriptor) => void;
85
- getFilename?: () => string;
85
+ readonly filename?: string;
86
+ /**
87
+ * @deprecated Rules use `context.filename`. Read only as a fallback by
88
+ * `wrapWithSemanticContext`; ESLint implements it as a `this`-bound class
89
+ * method, so it must be called on the host context, never a detached
90
+ * reference.
91
+ */
92
+ getFilename?: () => string | undefined;
86
93
  readonly settings?: Readonly<Record<string, unknown>>;
87
94
  }
88
- interface RuleContext extends BaseRuleContext {
95
+ interface RuleContext extends Omit<BaseRuleContext, "getFilename"> {
89
96
  readonly scopes: ScopeAnalysis;
90
97
  readonly cfg: ControlFlowAnalysis;
91
98
  }
@@ -3954,23 +3961,6 @@ declare const REACT_DOCTOR_RULES: readonly [{
3954
3961
  readonly recommendation?: string;
3955
3962
  readonly create: (context: RuleContext) => RuleVisitors;
3956
3963
  };
3957
- }, {
3958
- readonly key: "react-doctor/react-compiler-destructure-method";
3959
- readonly id: "react-compiler-destructure-method";
3960
- readonly source: "react-doctor";
3961
- readonly originallyExternal: false;
3962
- readonly rule: {
3963
- readonly framework: "global";
3964
- readonly category: "Architecture";
3965
- readonly id: string;
3966
- readonly severity: RuleSeverity;
3967
- readonly requires?: ReadonlyArray<string>;
3968
- readonly disabledBy?: ReadonlyArray<string>;
3969
- readonly tags?: ReadonlyArray<string>;
3970
- readonly defaultEnabled?: boolean;
3971
- readonly recommendation?: string;
3972
- readonly create: (context: RuleContext) => RuleVisitors;
3973
- };
3974
3964
  }, {
3975
3965
  readonly key: "react-doctor/react-compiler-no-manual-memoization";
3976
3966
  readonly id: "react-compiler-no-manual-memoization";
@@ -9239,23 +9229,6 @@ declare const RULES: readonly [{
9239
9229
  readonly recommendation?: string;
9240
9230
  readonly create: (context: RuleContext) => RuleVisitors;
9241
9231
  };
9242
- }, {
9243
- readonly key: "react-doctor/react-compiler-destructure-method";
9244
- readonly id: "react-compiler-destructure-method";
9245
- readonly source: "react-doctor";
9246
- readonly originallyExternal: false;
9247
- readonly rule: {
9248
- readonly framework: "global";
9249
- readonly category: "Architecture";
9250
- readonly id: string;
9251
- readonly severity: RuleSeverity;
9252
- readonly requires?: ReadonlyArray<string>;
9253
- readonly disabledBy?: ReadonlyArray<string>;
9254
- readonly tags?: ReadonlyArray<string>;
9255
- readonly defaultEnabled?: boolean;
9256
- readonly recommendation?: string;
9257
- readonly create: (context: RuleContext) => RuleVisitors;
9258
- };
9259
9232
  }, {
9260
9233
  readonly key: "react-doctor/react-compiler-no-manual-memoization";
9261
9234
  readonly id: "react-compiler-no-manual-memoization";
package/dist/index.js CHANGED
@@ -223,7 +223,7 @@ const jsxAttributeIsNonReactDialectMarker = (openingNode) => {
223
223
  //#endregion
224
224
  //#region src/plugin/utils/define-rule.ts
225
225
  const wrapCreateForTestNoise = (create) => ((context) => {
226
- if (isTestlikeFilename(context.getFilename?.())) return {};
226
+ if (isTestlikeFilename(context.filename)) return {};
227
227
  return create(context);
228
228
  });
229
229
  const VISITOR_NODE_NAME_PATTERN = /^[A-Z]/;
@@ -813,6 +813,12 @@ const getElementType = (openingElement, settings) => {
813
813
  return baseName;
814
814
  };
815
815
  //#endregion
816
+ //#region src/plugin/utils/is-nextjs-metadata-image-route-filename.ts
817
+ const isNextjsMetadataImageRouteFilename = (rawFilename) => {
818
+ if (!rawFilename) return false;
819
+ return /^(opengraph-image|twitter-image|icon|apple-icon)\d*\.(jsx?|tsx?)$/.test(path.basename(rawFilename));
820
+ };
821
+ //#endregion
816
822
  //#region src/plugin/utils/is-hidden-from-screen-reader.ts
817
823
  const isHiddenFromScreenReader = (openingElement, settings) => {
818
824
  if (getElementType(openingElement, settings).toLowerCase() === "input") {
@@ -983,6 +989,7 @@ const altText = defineRule({
983
989
  recommendation: "Provide `alt` (or aria-label / aria-labelledby) for non-decorative images.",
984
990
  category: "Accessibility",
985
991
  create: (context) => {
992
+ if (isNextjsMetadataImageRouteFilename(context.filename)) return {};
986
993
  const settings = resolveSettings$53(context.settings);
987
994
  const checkImg = !settings.elements || settings.elements.includes("img");
988
995
  const checkObject = !settings.elements || settings.elements.includes("object");
@@ -2712,6 +2719,17 @@ const asyncAwaitInLoop = defineRule({
2712
2719
  }
2713
2720
  });
2714
2721
  //#endregion
2722
+ //#region src/plugin/constants/ts-type-position-keys.ts
2723
+ const TYPE_POSITION_CHILD_KEYS = new Set([
2724
+ "implements",
2725
+ "returnType",
2726
+ "superTypeArguments",
2727
+ "superTypeParameters",
2728
+ "typeAnnotation",
2729
+ "typeArguments",
2730
+ "typeParameters"
2731
+ ]);
2732
+ //#endregion
2715
2733
  //#region src/plugin/utils/collect-pattern-names.ts
2716
2734
  const collectPatternNames = (pattern, into) => {
2717
2735
  if (!pattern) return;
@@ -2741,14 +2759,6 @@ const collectPatternNames = (pattern, into) => {
2741
2759
  };
2742
2760
  //#endregion
2743
2761
  //#region src/plugin/utils/collect-reference-identifier-names.ts
2744
- const TYPE_POSITION_KEYS = new Set([
2745
- "typeAnnotation",
2746
- "typeParameters",
2747
- "typeArguments",
2748
- "returnType",
2749
- "superTypeArguments",
2750
- "superTypeParameters"
2751
- ]);
2752
2762
  const collectScopedReferencesInPattern = (pattern, into, shadowed) => {
2753
2763
  if (!pattern) return;
2754
2764
  if (isNodeOfType(pattern, "Identifier")) return;
@@ -2809,7 +2819,7 @@ const collectScopedReferenceIdentifierNames = (node, into, shadowed) => {
2809
2819
  if (typeof node.type === "string" && node.type.startsWith("TS")) return;
2810
2820
  for (const [key, child] of Object.entries(node)) {
2811
2821
  if (key === "parent") continue;
2812
- if (TYPE_POSITION_KEYS.has(key)) continue;
2822
+ if (TYPE_POSITION_CHILD_KEYS.has(key)) continue;
2813
2823
  if (Array.isArray(child)) {
2814
2824
  for (const item of child) if (isAstNode(item)) collectScopedReferenceIdentifierNames(item, into, shadowed);
2815
2825
  } else if (isAstNode(child)) collectScopedReferenceIdentifierNames(child, into, shadowed);
@@ -3127,7 +3137,7 @@ const asyncParallel = defineRule({
3127
3137
  severity: "warn",
3128
3138
  recommendation: "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently",
3129
3139
  create: (context) => {
3130
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
3140
+ const filename = normalizeFilename$1(context.filename ?? "");
3131
3141
  const isBrowserTestFile = BROWSER_TEST_FILE_PATTERN.test(filename);
3132
3142
  let hasTestLibraryImport = false;
3133
3143
  const shouldSkipFile = () => isBrowserTestFile || hasTestLibraryImport;
@@ -3330,7 +3340,7 @@ const buttonHasType = defineRule({
3330
3340
  recommendation: "Set `type=\"button\"` (or `\"submit\"` / `\"reset\"`) explicitly on every `<button>`.",
3331
3341
  create: (context) => {
3332
3342
  const settings = resolveSettings$48(context.settings);
3333
- const isTestlikeFile = isTestlikeFilename(context.getFilename?.());
3343
+ const isTestlikeFile = isTestlikeFilename(context.filename);
3334
3344
  return {
3335
3345
  JSXOpeningElement(node) {
3336
3346
  if (isTestlikeFile) return;
@@ -3530,7 +3540,7 @@ const clickEventsHaveKeyEvents = defineRule({
3530
3540
  recommendation: "Pair `onClick` with `onKeyUp` / `onKeyDown` / `onKeyPress` for keyboard users.",
3531
3541
  category: "Accessibility",
3532
3542
  create: (context) => {
3533
- const isTestlikeFile = isTestlikeFilename(context.getFilename?.());
3543
+ const isTestlikeFile = isTestlikeFilename(context.filename);
3534
3544
  return { JSXOpeningElement(node) {
3535
3545
  if (isTestlikeFile) return;
3536
3546
  const tag = getElementType(node, context.settings);
@@ -3791,7 +3801,7 @@ const controlHasAssociatedLabel = defineRule({
3791
3801
  category: "Accessibility",
3792
3802
  create: (context) => {
3793
3803
  const settings = resolveSettings$46(context.settings);
3794
- const isTestlikeFile = isTestlikeFilename(context.getFilename?.());
3804
+ const isTestlikeFile = isTestlikeFilename(context.filename);
3795
3805
  return { JSXElement(node) {
3796
3806
  if (isTestlikeFile) return;
3797
3807
  const opening = node.openingElement;
@@ -4928,6 +4938,33 @@ const inferReferenceFlag = (identifier) => {
4928
4938
  const setNodeScope = (node, state) => {
4929
4939
  state.nodeScope.set(node, state.currentScope);
4930
4940
  };
4941
+ const walkParameterReferences = (pattern, state) => {
4942
+ if (isNodeOfType(pattern, "AssignmentPattern")) {
4943
+ walkParameterReferences(pattern.left, state);
4944
+ const defaultValue = pattern.right ?? null;
4945
+ if (defaultValue) walk(defaultValue, state);
4946
+ return;
4947
+ }
4948
+ if (isNodeOfType(pattern, "ObjectPattern")) {
4949
+ for (const property of pattern.properties) {
4950
+ const propertyNode = property;
4951
+ if (isNodeOfType(propertyNode, "RestElement")) {
4952
+ walkParameterReferences(propertyNode.argument, state);
4953
+ continue;
4954
+ }
4955
+ if (!isNodeOfType(propertyNode, "Property")) continue;
4956
+ const propertyDetail = propertyNode;
4957
+ if (propertyDetail.computed) walk(propertyDetail.key, state);
4958
+ walkParameterReferences(propertyDetail.value, state);
4959
+ }
4960
+ return;
4961
+ }
4962
+ if (isNodeOfType(pattern, "ArrayPattern")) {
4963
+ for (const element of pattern.elements) if (element) walkParameterReferences(element, state);
4964
+ return;
4965
+ }
4966
+ if (isNodeOfType(pattern, "RestElement")) walkParameterReferences(pattern.argument, state);
4967
+ };
4931
4968
  const walk = (node, state) => {
4932
4969
  if (isFunctionLike$1(node)) {
4933
4970
  if (isNodeOfType(node, "FunctionDeclaration") && node.id) handleFunctionDeclaration(node, state);
@@ -4943,7 +4980,9 @@ const walk = (node, state) => {
4943
4980
  });
4944
4981
  tagAsBinding(state, node.id);
4945
4982
  }
4946
- handleFunctionParameters(node.params ?? [], fnScope, state);
4983
+ const functionParams = node.params ?? [];
4984
+ handleFunctionParameters(functionParams, fnScope, state);
4985
+ for (const param of functionParams) walkParameterReferences(param, state);
4947
4986
  const body = node.body;
4948
4987
  if (body) walk(body, state);
4949
4988
  popScope(state);
@@ -4985,6 +5024,7 @@ const walk = (node, state) => {
4985
5024
  const nodeRecord = node;
4986
5025
  for (const key of Object.keys(nodeRecord)) {
4987
5026
  if (key === "parent") continue;
5027
+ if (TYPE_POSITION_CHILD_KEYS.has(key)) continue;
4988
5028
  const child = nodeRecord[key];
4989
5029
  if (Array.isArray(child)) {
4990
5030
  for (const item of child) if (isAstNode(item)) walk(item, state);
@@ -5047,6 +5087,7 @@ const walk = (node, state) => {
5047
5087
  const nodeRecord = node;
5048
5088
  for (const key of Object.keys(nodeRecord)) {
5049
5089
  if (key === "parent") continue;
5090
+ if (TYPE_POSITION_CHILD_KEYS.has(key)) continue;
5050
5091
  const child = nodeRecord[key];
5051
5092
  if (Array.isArray(child)) {
5052
5093
  for (const item of child) if (isAstNode(item)) walk(item, state);
@@ -5166,14 +5207,6 @@ const isAstDescendant = (inner, outer) => {
5166
5207
  };
5167
5208
  //#endregion
5168
5209
  //#region src/plugin/semantic/closure-captures.ts
5169
- const TYPE_ONLY_CHILD_KEYS = new Set([
5170
- "implements",
5171
- "returnType",
5172
- "superTypeArguments",
5173
- "typeAnnotation",
5174
- "typeArguments",
5175
- "typeParameters"
5176
- ]);
5177
5210
  const closureCaptures = (functionNode, scopes) => {
5178
5211
  const functionScope = scopes.ownScopeFor(functionNode) ?? scopes.scopeFor(functionNode);
5179
5212
  const out = [];
@@ -5201,7 +5234,7 @@ const closureCaptures = (functionNode, scopes) => {
5201
5234
  const record = node;
5202
5235
  for (const key of Object.keys(record)) {
5203
5236
  if (key === "parent") continue;
5204
- if (TYPE_ONLY_CHILD_KEYS.has(key)) continue;
5237
+ if (TYPE_POSITION_CHILD_KEYS.has(key)) continue;
5205
5238
  const child = record[key];
5206
5239
  if (Array.isArray(child)) {
5207
5240
  for (const item of child) if (isAstNode(item)) visit(item);
@@ -5564,33 +5597,6 @@ const collectCaptureDepKeys = (callback, scopes) => {
5564
5597
  if (!depKey) continue;
5565
5598
  keys.add(depKey);
5566
5599
  }
5567
- const functionParams = callback.params ?? [];
5568
- for (const param of functionParams) {
5569
- if (!isNodeOfType(param, "AssignmentPattern")) continue;
5570
- const visitDefaultValue = (node) => {
5571
- if (isNodeOfType(node, "Identifier") || isNodeOfType(node, "MemberExpression")) {
5572
- const depKey = stringifyMemberChain(node);
5573
- if (depKey) keys.add(depKey);
5574
- }
5575
- const reference = scopes.referenceFor(node);
5576
- if (reference?.resolvedSymbol) {
5577
- const symbol = reference.resolvedSymbol;
5578
- if (!isOutsideAllFunctions(symbol)) {
5579
- const depKey = computeDepKey(reference);
5580
- if (depKey) keys.add(depKey);
5581
- }
5582
- }
5583
- const record = node;
5584
- for (const key of Object.keys(record)) {
5585
- if (key === "parent") continue;
5586
- const child = record[key];
5587
- if (Array.isArray(child)) {
5588
- for (const item of child) if (isAstNode(item)) visitDefaultValue(item);
5589
- } else if (isAstNode(child)) visitDefaultValue(child);
5590
- }
5591
- };
5592
- visitDefaultValue(param.right);
5593
- }
5594
5600
  return {
5595
5601
  keys,
5596
5602
  stableCapturedNames
@@ -8423,6 +8429,7 @@ const jsTosortedImmutable = defineRule({
8423
8429
  id: "js-tosorted-immutable",
8424
8430
  tags: ["test-noise"],
8425
8431
  severity: "warn",
8432
+ disabledBy: ["react-native"],
8426
8433
  recommendation: "Use `array.toSorted()` (ES2023) instead of `[...array].sort()` for immutable sorting without the spread allocation",
8427
8434
  create: (context) => ({ CallExpression(node) {
8428
8435
  if (!isMemberProperty(node.callee, "sort")) return;
@@ -8711,7 +8718,7 @@ const jsxFilenameExtension = defineRule({
8711
8718
  const settings = resolveSettings$34(context.settings);
8712
8719
  const allowedExtensions = normalizeExtensions(settings.extensions);
8713
8720
  const allowedList = [...allowedExtensions].map((extension) => `.${extension}`).join(", ");
8714
- const filename = context.getFilename ? normalizeFilename$1(context.getFilename()) : "fixture.tsx";
8721
+ const filename = normalizeFilename$1(context.filename ?? "fixture.tsx");
8715
8722
  const extensionOnly = path.extname(filename).slice(1);
8716
8723
  const fileHasAllowedExtension = allowedExtensions.has(extensionOnly);
8717
8724
  let didReportMismatch = false;
@@ -9495,7 +9502,7 @@ const jsxNoConstructedContextValues = defineRule({
9495
9502
  recommendation: "Memoize the context value (`useMemo`) or hoist it outside the render.",
9496
9503
  category: "Performance",
9497
9504
  create: (context) => {
9498
- const isTestlikeFile = isTestlikeFilename(context.getFilename?.());
9505
+ const isTestlikeFile = isTestlikeFilename(context.filename);
9499
9506
  return { JSXOpeningElement(node) {
9500
9507
  if (isTestlikeFile) return;
9501
9508
  if (!isProviderName(node.name)) return;
@@ -9841,7 +9848,7 @@ const jsxNoJsxAsProp = defineRule({
9841
9848
  recommendation: "Hoist the inner JSX outside the render or memoize via `useMemo`.",
9842
9849
  category: "Performance",
9843
9850
  create: (context) => {
9844
- const isTestlikeFile = isTestlikeFilename(context.getFilename?.());
9851
+ const isTestlikeFile = isTestlikeFilename(context.filename);
9845
9852
  let memoRegistry = null;
9846
9853
  return {
9847
9854
  Program(node) {
@@ -10212,7 +10219,7 @@ const jsxNoNewArrayAsProp = defineRule({
10212
10219
  recommendation: "Memoize the array (`useMemo`) or hoist it outside the component.",
10213
10220
  category: "Performance",
10214
10221
  create: (context) => {
10215
- const isTestlikeFile = isTestlikeFilename(context.getFilename?.());
10222
+ const isTestlikeFile = isTestlikeFilename(context.filename);
10216
10223
  let memoRegistry = null;
10217
10224
  return {
10218
10225
  Program(node) {
@@ -10674,7 +10681,7 @@ const jsxNoNewFunctionAsProp = defineRule({
10674
10681
  recommendation: "Memoize the callback (`useCallback`) or hoist it outside the component.",
10675
10682
  category: "Performance",
10676
10683
  create: (context) => {
10677
- const isTestlikeFile = isTestlikeFilename(context.getFilename?.());
10684
+ const isTestlikeFile = isTestlikeFilename(context.filename);
10678
10685
  let memoRegistry = null;
10679
10686
  return {
10680
10687
  Program(node) {
@@ -10980,7 +10987,7 @@ const jsxNoNewObjectAsProp = defineRule({
10980
10987
  recommendation: "Memoize the object (`useMemo`) or hoist it outside the component.",
10981
10988
  category: "Performance",
10982
10989
  create: (context) => {
10983
- const isTestlikeFile = isTestlikeFilename(context.getFilename?.());
10990
+ const isTestlikeFile = isTestlikeFilename(context.filename);
10984
10991
  let memoRegistry = null;
10985
10992
  return {
10986
10993
  Program(node) {
@@ -11785,7 +11792,7 @@ const labelHasAssociatedControl = defineRule({
11785
11792
  category: "Accessibility",
11786
11793
  create: (context) => {
11787
11794
  const settings = resolveSettings$24(context.settings);
11788
- const isTestlikeFile = isTestlikeFilename(context.getFilename?.());
11795
+ const isTestlikeFile = isTestlikeFilename(context.filename);
11789
11796
  return { JSXElement(node) {
11790
11797
  if (isTestlikeFile) return;
11791
11798
  const opening = node.openingElement;
@@ -12323,7 +12330,7 @@ const nextjsMissingMetadata = defineRule({
12323
12330
  severity: "warn",
12324
12331
  recommendation: "Add `export const metadata = { title: '...', description: '...' }` or `export async function generateMetadata()`",
12325
12332
  create: (context) => ({ Program(programNode) {
12326
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
12333
+ const filename = normalizeFilename$1(context.filename ?? "");
12327
12334
  if (!PAGE_FILE_PATTERN.test(filename)) return;
12328
12335
  if (INTERNAL_PAGE_PATH_PATTERN.test(filename)) return;
12329
12336
  if (!programNode.body?.some((statement) => {
@@ -12388,7 +12395,7 @@ const nextjsNoClientFetchForServerData = defineRule({
12388
12395
  if (!fileHasUseClient || !isHookCall$1(node, EFFECT_HOOK_NAMES$1)) return;
12389
12396
  const callback = getEffectCallback(node);
12390
12397
  if (!callback || !containsFetchCall(callback)) return;
12391
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
12398
+ const filename = normalizeFilename$1(context.filename ?? "");
12392
12399
  if (PAGE_OR_LAYOUT_FILE_PATTERN.test(filename) || PAGES_DIRECTORY_PATTERN.test(filename)) context.report({
12393
12400
  node,
12394
12401
  message: "useEffect + fetch in a page/layout — fetch data server-side with a server component instead"
@@ -12421,7 +12428,7 @@ const nextjsNoClientSideRedirect = defineRule({
12421
12428
  severity: "warn",
12422
12429
  recommendation: "Avoid redirects inside useEffect. Use an event handler, middleware, or server-side redirect (App Router: redirect() from next/navigation; Pages Router: getServerSideProps redirect)",
12423
12430
  create: (context) => {
12424
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
12431
+ const filename = normalizeFilename$1(context.filename ?? "");
12425
12432
  const isPagesRouterFile = PAGES_DIRECTORY_PATTERN.test(filename);
12426
12433
  return { CallExpression(node) {
12427
12434
  if (!isHookCall$1(node, EFFECT_HOOK_NAMES$1)) return;
@@ -12490,7 +12497,7 @@ const nextjsNoHeadImport = defineRule({
12490
12497
  recommendation: "Use the Metadata API instead: `export const metadata = { title: '...' }` or `export async function generateMetadata()`",
12491
12498
  create: (context) => ({ ImportDeclaration(node) {
12492
12499
  if (node.source?.value !== "next/head") return;
12493
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
12500
+ const filename = normalizeFilename$1(context.filename ?? "");
12494
12501
  if (!APP_DIRECTORY_PATTERN.test(filename)) return;
12495
12502
  context.report({
12496
12503
  node,
@@ -12507,7 +12514,7 @@ const nextjsNoImgElement = defineRule({
12507
12514
  severity: "warn",
12508
12515
  recommendation: "`import Image from 'next/image'` — provides automatic WebP/AVIF, lazy loading, and responsive srcset",
12509
12516
  create: (context) => {
12510
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
12517
+ const filename = normalizeFilename$1(context.filename ?? "");
12511
12518
  const isOgRoute = OG_ROUTE_PATTERN.test(filename);
12512
12519
  return { JSXOpeningElement(node) {
12513
12520
  if (isOgRoute) return;
@@ -12861,7 +12868,7 @@ const nextjsNoSideEffectInGetHandler = defineRule({
12861
12868
  resolveBinding = buildProgramBindingLookup(node);
12862
12869
  },
12863
12870
  ExportNamedDeclaration(node) {
12864
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
12871
+ const filename = normalizeFilename$1(context.filename ?? "");
12865
12872
  if (!ROUTE_HANDLER_FILE_PATTERN.test(filename)) return;
12866
12873
  if (CRON_ROUTE_PATTERN.test(filename)) return;
12867
12874
  if (!isExportedGetHandler(node)) return;
@@ -13405,9 +13412,9 @@ const findContainingNode = (analysis, node) => {
13405
13412
  //#region src/plugin/rules/state-and-effects/no-adjust-state-on-prop-change.ts
13406
13413
  const noAdjustStateOnPropChange = defineRule({
13407
13414
  id: "no-adjust-state-on-prop-change",
13408
- severity: "warn",
13415
+ severity: "error",
13409
13416
  tags: ["test-noise"],
13410
- recommendation: "Adjust the state inline during render instead of via a useEffect, or refactor the state to avoid the need entirely. See https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes",
13417
+ recommendation: "Adjust the state inline during render with a `prev`-prop comparison (`if (prop !== prevProp) { setPrevProp(prop); setX(...); }`), or refactor to remove the duplicated state. Routing the adjustment through a useEffect forces an extra render with a stale UI between the two commits. See https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes",
13411
13418
  create: (context) => ({ CallExpression(node) {
13412
13419
  if (!isUseEffect(node)) return;
13413
13420
  const analysis = getProgramAnalysis(node);
@@ -13426,7 +13433,7 @@ const noAdjustStateOnPropChange = defineRule({
13426
13433
  if (getArgsUpstreamRefs(analysis, ref).some((argRef) => isProp(analysis, argRef))) continue;
13427
13434
  context.report({
13428
13435
  node: callExpr,
13429
- message: "Avoid adjusting state when a prop changes. Instead, adjust the state directly during render, or refactor your state to avoid this need entirely."
13436
+ message: "State adjusted in a useEffect when a prop changes forces an extra render with a stale UI between the two commits. Adjust the state during render with a `prev`-prop comparison instead, or refactor to remove the duplicated state."
13430
13437
  });
13431
13438
  }
13432
13439
  } })
@@ -14093,7 +14100,7 @@ const noAutofocus = defineRule({
14093
14100
  category: "Accessibility",
14094
14101
  create: (context) => {
14095
14102
  const settings = resolveSettings$21(context.settings);
14096
- const isTestlikeFile = isTestlikeFilename(context.getFilename?.());
14103
+ const isTestlikeFile = isTestlikeFilename(context.filename);
14097
14104
  return { JSXOpeningElement(node) {
14098
14105
  if (isTestlikeFile) return;
14099
14106
  const autoFocusAttribute = node.attributes.find((attribute) => {
@@ -14467,7 +14474,7 @@ const noBarrelImport = defineRule({
14467
14474
  if (didReportForFile) return;
14468
14475
  const source = node.source?.value;
14469
14476
  if (typeof source !== "string" || !source.startsWith(".")) return;
14470
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
14477
+ const filename = normalizeFilename$1(context.filename ?? "");
14471
14478
  if (!filename) return;
14472
14479
  const importRequests = getRuntimeImportRequests(node);
14473
14480
  if (importRequests.length === 0) return;
@@ -18246,7 +18253,7 @@ const noMultiComp = defineRule({
18246
18253
  category: "Architecture",
18247
18254
  create: (context) => {
18248
18255
  const settings = resolveSettings$16(context.settings);
18249
- const isTestlikeFile = isTestlikeFilename(context.getFilename?.());
18256
+ const isTestlikeFile = isTestlikeFilename(context.filename);
18250
18257
  return { Program(node) {
18251
18258
  if (isTestlikeFile) return;
18252
18259
  const visitContext = {
@@ -20193,7 +20200,7 @@ const noSecretsInClientCode = defineRule({
20193
20200
  severity: "warn",
20194
20201
  recommendation: "Move secrets to server-only code. Public client environment variables are bundled into browser code and must not contain secrets",
20195
20202
  create: (context) => {
20196
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
20203
+ const filename = normalizeFilename$1(context.filename ?? "");
20197
20204
  const framework = getReactDoctorStringSetting(context.settings, "framework");
20198
20205
  const rootDirectory = getReactDoctorStringSetting(context.settings, "rootDirectory");
20199
20206
  let shouldUseVariableNameHeuristic = classifySecretFileExposure(filename, {
@@ -20448,7 +20455,7 @@ const noStaticElementInteractions = defineRule({
20448
20455
  category: "Accessibility",
20449
20456
  create: (context) => {
20450
20457
  const settings = resolveSettings$12(context.settings);
20451
- const isTestlikeFile = isTestlikeFilename(context.getFilename?.());
20458
+ const isTestlikeFile = isTestlikeFilename(context.filename);
20452
20459
  return { JSXOpeningElement(node) {
20453
20460
  if (isTestlikeFile) return;
20454
20461
  let hasNonBlockerHandler = false;
@@ -20525,7 +20532,7 @@ const noStringRefs = defineRule({
20525
20532
  recommendation: "Use a callback ref (`ref={(node) => { this.foo = node }}`) or `useRef` instead of string refs.",
20526
20533
  create: (context) => {
20527
20534
  const { noTemplateLiterals = false } = resolveSettings$11(context.settings);
20528
- const isTestlikeFile = isTestlikeFilename(context.getFilename?.());
20535
+ const isTestlikeFile = isTestlikeFilename(context.filename);
20529
20536
  return {
20530
20537
  JSXAttribute(node) {
20531
20538
  if (isTestlikeFile) return;
@@ -22660,7 +22667,7 @@ const isFileNameAllowed = (filename, checkJS) => {
22660
22667
  };
22661
22668
  const onlyExportComponents = defineRule({
22662
22669
  id: "only-export-components",
22663
- severity: "error",
22670
+ severity: "warn",
22664
22671
  recommendation: "Move non-component exports out of files that export components.",
22665
22672
  category: "Architecture",
22666
22673
  create: (context) => {
@@ -22671,7 +22678,7 @@ const onlyExportComponents = defineRule({
22671
22678
  allowConstantExport: settings.allowConstantExport
22672
22679
  };
22673
22680
  return { Program(node) {
22674
- if (!isFileNameAllowed(context.getFilename ? normalizeFilename$1(context.getFilename()) : void 0, settings.checkJS)) return;
22681
+ if (!isFileNameAllowed(normalizeFilename$1(context.filename ?? ""), settings.checkJS)) return;
22675
22682
  const allNodes = collectAllNodes(node);
22676
22683
  const exports = [];
22677
22684
  let hasReactExport = false;
@@ -23717,106 +23724,6 @@ const queryStableQueryClient = defineRule({
23717
23724
  }
23718
23725
  });
23719
23726
  //#endregion
23720
- //#region src/plugin/rules/architecture/react-compiler-destructure-method.ts
23721
- const HOOK_OBJECTS_WITH_METHODS = new Map([
23722
- ["useRouter", new Set([
23723
- "push",
23724
- "replace",
23725
- "back",
23726
- "forward",
23727
- "refresh",
23728
- "prefetch"
23729
- ])],
23730
- ["useNavigation", new Set([
23731
- "navigate",
23732
- "push",
23733
- "goBack",
23734
- "popToTop",
23735
- "reset",
23736
- "replace",
23737
- "dispatch"
23738
- ])],
23739
- ["useSearchParams", new Set([
23740
- "get",
23741
- "getAll",
23742
- "has",
23743
- "set"
23744
- ])]
23745
- ]);
23746
- const HOOK_IMPORT_SOURCES_WITH_UNSAFE_METHOD_DESTRUCTURING = new Map([["useNavigation", new Set(["@react-navigation/native", "@react-navigation/core"])]]);
23747
- const isUnsafeMethodDestructureHookImport = (node, hookSource) => {
23748
- const moduleSources = HOOK_IMPORT_SOURCES_WITH_UNSAFE_METHOD_DESTRUCTURING.get(hookSource);
23749
- if (!moduleSources) return false;
23750
- for (const moduleSource of moduleSources) if (isImportedFromModule(node, hookSource, moduleSource)) return true;
23751
- return false;
23752
- };
23753
- const buildHookBindingMap = (componentBody) => {
23754
- const result = /* @__PURE__ */ new Map();
23755
- if (!componentBody || !isNodeOfType(componentBody, "BlockStatement")) return result;
23756
- for (const statement of componentBody.body ?? []) {
23757
- if (!isNodeOfType(statement, "VariableDeclaration")) continue;
23758
- for (const declarator of statement.declarations ?? []) {
23759
- if (!isNodeOfType(declarator.id, "Identifier")) continue;
23760
- if (!isNodeOfType(declarator.init, "CallExpression")) continue;
23761
- const callee = declarator.init.callee;
23762
- if (!isNodeOfType(callee, "Identifier")) continue;
23763
- result.set(declarator.id.name, callee.name);
23764
- }
23765
- }
23766
- return result;
23767
- };
23768
- const reactCompilerDestructureMethod = defineRule({
23769
- id: "react-compiler-destructure-method",
23770
- tags: ["test-noise"],
23771
- severity: "warn",
23772
- recommendation: "Destructure the method up front: `const { push } = useRouter()` then call `push(...)` directly — clearer dependency graph and easier for React Compiler to memoize",
23773
- create: (context) => {
23774
- const hookBindingMapStack = [];
23775
- const isComponent = (node) => {
23776
- if (isNodeOfType(node, "FunctionDeclaration")) return Boolean(node.id?.name && isUppercaseName(node.id.name));
23777
- if (isNodeOfType(node, "VariableDeclarator")) return isComponentAssignment(node);
23778
- return false;
23779
- };
23780
- const enter = (node) => {
23781
- if (!isComponent(node)) return;
23782
- let body;
23783
- if (isNodeOfType(node, "FunctionDeclaration")) body = node.body;
23784
- else if (isNodeOfType(node, "VariableDeclarator")) {
23785
- const initializer = node.init;
23786
- body = isInlineFunctionExpression(initializer) ? initializer.body : null;
23787
- }
23788
- hookBindingMapStack.push(buildHookBindingMap(body));
23789
- };
23790
- const exit = (node) => {
23791
- if (isComponent(node)) hookBindingMapStack.pop();
23792
- };
23793
- return {
23794
- FunctionDeclaration: enter,
23795
- "FunctionDeclaration:exit": exit,
23796
- VariableDeclarator: enter,
23797
- "VariableDeclarator:exit": exit,
23798
- MemberExpression(node) {
23799
- if (hookBindingMapStack.length === 0) return;
23800
- if (node.computed) return;
23801
- if (!isNodeOfType(node.object, "Identifier")) return;
23802
- if (!isNodeOfType(node.property, "Identifier")) return;
23803
- const bindingName = node.object.name;
23804
- const methodName = node.property.name;
23805
- const hookSource = hookBindingMapStack[hookBindingMapStack.length - 1].get(bindingName);
23806
- if (!hookSource) return;
23807
- const allowedMethods = HOOK_OBJECTS_WITH_METHODS.get(hookSource);
23808
- if (!allowedMethods || !allowedMethods.has(methodName)) return;
23809
- if (isUnsafeMethodDestructureHookImport(node, hookSource)) return;
23810
- if (!isNodeOfType(node.parent, "CallExpression") || node.parent.callee !== node) return;
23811
- context.report({
23812
- node,
23813
- message: `Destructure for clarity: \`const { ${methodName} } = ${hookSource}()\` then call \`${methodName}(...)\` directly — easier for React Compiler to memoize and clearer about which methods this component depends on`
23814
- });
23815
- }
23816
- };
23817
- }
23818
- });
23819
- //#endregion
23820
23727
  //#region src/plugin/rules/architecture/react-compiler-no-manual-memoization.ts
23821
23728
  const REMOVAL_MESSAGE_BY_REACT_API_NAME = new Map([
23822
23729
  ["useMemo", "Remove `useMemo` — React Compiler auto-memoizes every value in this component. Manual `useMemo` adds noise without improving performance."],
@@ -24192,7 +24099,7 @@ const renderingSvgPrecision = defineRule({
24192
24099
  category: "Performance",
24193
24100
  recommendation: "Truncate path/points/transform decimals to 1–2 digits — sub-pixel precision adds bytes with no visible difference",
24194
24101
  create: (context) => {
24195
- const filename = context.getFilename?.();
24102
+ const filename = context.filename;
24196
24103
  const isAutoGenerated = isAutoGeneratedSvgFile(filename ? normalizeFilename$1(filename) : void 0);
24197
24104
  return { JSXAttribute(node) {
24198
24105
  if (isAutoGenerated) return;
@@ -25524,7 +25431,8 @@ const classifyPackagePlatform = (filename) => {
25524
25431
  //#endregion
25525
25432
  //#region src/plugin/utils/is-expo-managed-file.ts
25526
25433
  const isExpoManagedFileActive = (context) => {
25527
- const filename = context.getFilename?.() ? normalizeFilename$1(context.getFilename()) : void 0;
25434
+ const rawFilename = context.filename;
25435
+ const filename = rawFilename ? normalizeFilename$1(rawFilename) : void 0;
25528
25436
  if (filename) {
25529
25437
  const packagePlatform = classifyPackagePlatform(filename);
25530
25438
  if (packagePlatform === "expo") return true;
@@ -30692,7 +30600,7 @@ const serverFetchWithoutRevalidate = defineRule({
30692
30600
  let isServerSideFile = false;
30693
30601
  return {
30694
30602
  Program(node) {
30695
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
30603
+ const filename = normalizeFilename$1(context.filename ?? "");
30696
30604
  if (!APP_ROUTER_FILE_PATTERN.test(filename)) {
30697
30605
  isServerSideFile = false;
30698
30606
  return;
@@ -30805,7 +30713,7 @@ const serverHoistStaticIo = defineRule({
30805
30713
  inspectHandlerBody(context, declaration.body, `${handlerName} route handler`, collectIdentifierParams(declaration.params ?? []));
30806
30714
  },
30807
30715
  ExportDefaultDeclaration(node) {
30808
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
30716
+ const filename = normalizeFilename$1(context.filename ?? "");
30809
30717
  if (!PAGES_ROUTER_API_PATH_PATTERN.test(filename)) return;
30810
30718
  const declaration = node.declaration;
30811
30719
  if (!declaration || !isNodeOfType(declaration, "FunctionDeclaration") && !isNodeOfType(declaration, "FunctionExpression") && !isNodeOfType(declaration, "ArrowFunctionExpression")) return;
@@ -31356,7 +31264,7 @@ const tanstackStartMissingHeadContent = defineRule({
31356
31264
  };
31357
31265
  return {
31358
31266
  Program(node) {
31359
- const filename = context.getFilename?.() ?? "";
31267
+ const filename = context.filename ?? "";
31360
31268
  if (!TANSTACK_ROOT_ROUTE_FILE_PATTERN.test(filename)) return;
31361
31269
  const statements = node.body ?? [];
31362
31270
  for (const statement of statements) collectImportBindings(statement);
@@ -31366,17 +31274,17 @@ const tanstackStartMissingHeadContent = defineRule({
31366
31274
  }
31367
31275
  },
31368
31276
  ImportDeclaration(node) {
31369
- const filename = context.getFilename?.() ?? "";
31277
+ const filename = context.filename ?? "";
31370
31278
  if (!TANSTACK_ROOT_ROUTE_FILE_PATTERN.test(filename)) return;
31371
31279
  collectImportBindings(node);
31372
31280
  },
31373
31281
  VariableDeclarator(node) {
31374
- const filename = context.getFilename?.() ?? "";
31282
+ const filename = context.filename ?? "";
31375
31283
  if (!TANSTACK_ROOT_ROUTE_FILE_PATTERN.test(filename)) return;
31376
31284
  collectVariableAlias(node);
31377
31285
  },
31378
31286
  JSXOpeningElement(node) {
31379
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
31287
+ const filename = normalizeFilename$1(context.filename ?? "");
31380
31288
  if (!TANSTACK_ROOT_ROUTE_FILE_PATTERN.test(filename)) return;
31381
31289
  if (isNodeOfType(node.name, "JSXIdentifier")) {
31382
31290
  if (node.name.name === DOCUMENT_HEAD_ELEMENT_NAME) hasDocumentHeadElement = true;
@@ -31391,7 +31299,7 @@ const tanstackStartMissingHeadContent = defineRule({
31391
31299
  if (isInsideDocumentHeadElement(node) && isCustomJsxElementName(node.name)) hasCustomHeadChildElement = true;
31392
31300
  },
31393
31301
  "Program:exit"(programNode) {
31394
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
31302
+ const filename = normalizeFilename$1(context.filename ?? "");
31395
31303
  if (!TANSTACK_ROOT_ROUTE_FILE_PATTERN.test(filename)) return;
31396
31304
  if (hasDocumentHeadElement && !hasHeadContentElement && !hasCustomHeadChildElement) context.report({
31397
31305
  node: programNode,
@@ -31410,7 +31318,7 @@ const tanstackStartNoAnchorElement = defineRule({
31410
31318
  severity: "warn",
31411
31319
  recommendation: "`import { Link } from '@tanstack/react-router'` — enables type-safe routes, preloading via `preload=\"intent\"`, and client-side navigation",
31412
31320
  create: (context) => ({ JSXOpeningElement(node) {
31413
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
31321
+ const filename = normalizeFilename$1(context.filename ?? "");
31414
31322
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
31415
31323
  if (!isNodeOfType(node.name, "JSXIdentifier") || node.name.name !== "a") return;
31416
31324
  const hrefAttribute = (node.attributes ?? []).find((attribute) => isNodeOfType(attribute, "JSXAttribute") && isNodeOfType(attribute.name, "JSXIdentifier") && attribute.name.name === "href");
@@ -31484,7 +31392,7 @@ const tanstackStartNoNavigateInRender = defineRule({
31484
31392
  const isEventHandlerAttribute = (node) => isNodeOfType(node, "JSXAttribute") && isNodeOfType(node.name, "JSXIdentifier") && typeof node.name.name === "string" && node.name.name.startsWith("on") && UPPERCASE_PATTERN.test(node.name.name.charAt(2));
31485
31393
  return {
31486
31394
  CallExpression(node) {
31487
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
31395
+ const filename = normalizeFilename$1(context.filename ?? "");
31488
31396
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
31489
31397
  if (isDeferredHookCall(node)) deferredCallbackDepth++;
31490
31398
  if (deferredCallbackDepth > 0 || eventHandlerDepth > 0) return;
@@ -31494,17 +31402,17 @@ const tanstackStartNoNavigateInRender = defineRule({
31494
31402
  });
31495
31403
  },
31496
31404
  "CallExpression:exit"(node) {
31497
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
31405
+ const filename = normalizeFilename$1(context.filename ?? "");
31498
31406
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
31499
31407
  if (isDeferredHookCall(node)) deferredCallbackDepth = Math.max(0, deferredCallbackDepth - 1);
31500
31408
  },
31501
31409
  JSXAttribute(node) {
31502
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
31410
+ const filename = normalizeFilename$1(context.filename ?? "");
31503
31411
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
31504
31412
  if (isEventHandlerAttribute(node)) eventHandlerDepth++;
31505
31413
  },
31506
31414
  "JSXAttribute:exit"(node) {
31507
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
31415
+ const filename = normalizeFilename$1(context.filename ?? "");
31508
31416
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
31509
31417
  if (isEventHandlerAttribute(node)) eventHandlerDepth = Math.max(0, eventHandlerDepth - 1);
31510
31418
  }
@@ -31585,7 +31493,7 @@ const tanstackStartNoUseEffectFetch = defineRule({
31585
31493
  severity: "warn",
31586
31494
  recommendation: "Fetch data in the route `loader` instead — the router coordinates loading before rendering to avoid waterfalls",
31587
31495
  create: (context) => ({ CallExpression(node) {
31588
- const filename = normalizeFilename$1(context.getFilename?.() ?? "");
31496
+ const filename = normalizeFilename$1(context.filename ?? "");
31589
31497
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
31590
31498
  if (!isHookCall$1(node, EFFECT_HOOK_NAMES$1)) return;
31591
31499
  const callback = node.arguments?.[0];
@@ -34308,17 +34216,6 @@ const reactDoctorRules = [
34308
34216
  category: "TanStack Query"
34309
34217
  }
34310
34218
  },
34311
- {
34312
- key: "react-doctor/react-compiler-destructure-method",
34313
- id: "react-compiler-destructure-method",
34314
- source: "react-doctor",
34315
- originallyExternal: false,
34316
- rule: {
34317
- ...reactCompilerDestructureMethod,
34318
- framework: "global",
34319
- category: "Architecture"
34320
- }
34321
- },
34322
34219
  {
34323
34220
  key: "react-doctor/react-compiler-no-manual-memoization",
34324
34221
  id: "react-compiler-no-manual-memoization",
@@ -35254,7 +35151,7 @@ const ruleRegistry = Object.fromEntries(reactDoctorRules.map((rule) => [rule.id,
35254
35151
  const WEB_FILE_EXTENSION_PATTERN = /\.web\.[cm]?[jt]sx?$/;
35255
35152
  const NATIVE_FILE_EXTENSION_PATTERN = /\.(?:ios|android|native)\.[cm]?[jt]sx?$/;
35256
35153
  const isReactNativeFileActive = (context) => {
35257
- const rawFilename = context.getFilename?.();
35154
+ const rawFilename = context.filename;
35258
35155
  if (!rawFilename) return true;
35259
35156
  const filename = normalizeFilename$1(rawFilename);
35260
35157
  if (NATIVE_FILE_EXTENSION_PATTERN.test(filename)) return true;
@@ -35742,7 +35639,9 @@ const wrapWithSemanticContext = (rule) => ({
35742
35639
  };
35743
35640
  const enrichedContext = {
35744
35641
  report: baseContext.report,
35745
- getFilename: baseContext.getFilename,
35642
+ get filename() {
35643
+ return baseContext.filename ?? baseContext.getFilename?.();
35644
+ },
35746
35645
  settings: baseContext.settings,
35747
35646
  get scopes() {
35748
35647
  return getScopes();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxlint-plugin-react-doctor",
3
- "version": "0.2.10",
3
+ "version": "0.2.11-dev.f036b0f",
4
4
  "description": "oxlint plugin for React Doctor: diagnose React codebases for security, performance, correctness, accessibility, bundle-size, and architecture issues",
5
5
  "keywords": [
6
6
  "accessibility",