eslint-plugin-harlanzw 0.5.21 → 0.6.0

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 (3) hide show
  1. package/README.md +2 -0
  2. package/dist/index.mjs +235 -54
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -60,6 +60,8 @@ The rules are organized into the following categories:
60
60
  | [`vue-no-reactive-destructuring`](./src/rules/vue-no-reactive-destructuring.md) | avoid destructuring reactive objects |
61
61
  | [`vue-no-ref-access-in-templates`](./src/rules/vue-no-ref-access-in-templates.md) | don't use `.value` in Vue templates |
62
62
  | [`vue-no-torefs-on-props`](./src/rules/vue-no-torefs-on-props.md) | don't use `toRefs()` on the props object |
63
+ | [`vue-no-reactivity-after-await`](./src/rules/vue-no-reactivity-after-await.md) | disallow subscription APIs (`watch`, `computed`, etc.) after `await` in async functions |
64
+ | [`vue-no-async-lifecycle-hook`](./src/rules/vue-no-async-lifecycle-hook.md) | disallow async callbacks in Vue lifecycle hooks |
63
65
  | [`vue-no-resolve-component-in-composables`](./src/rules/vue-no-resolve-component-in-composables.ts) | disallow `resolveComponent()`/`resolveDirective()` outside top-level `<script setup>` |
64
66
  | [`vue-require-composable-prefix`](./src/rules/vue-require-composable-prefix.ts) | enforce `use*` prefix for functions using Vue reactivity |
65
67
  | **AI Deslop** | |
package/dist/index.mjs CHANGED
@@ -4,7 +4,7 @@ import process from 'node:process';
4
4
  import { TextSourceCodeBase, ConfigCommentParser, Directive, VisitNodeStep } from '@eslint/plugin-kit';
5
5
  import { AST_NODE_TYPES } from '@typescript-eslint/utils';
6
6
 
7
- const version = "0.5.21";
7
+ const version = "0.6.0";
8
8
 
9
9
  const STRENGTH_PATTERNS = {
10
10
  strong: ["never", "must", "always", "under no circumstances", "absolutely", "required", "mandatory", "forbidden", "prohibited"],
@@ -1531,6 +1531,8 @@ const aiDeslopCodeLang = {
1531
1531
  continue;
1532
1532
  const code = match[1];
1533
1533
  const lastChar = code.at(-1);
1534
+ if (!lastChar)
1535
+ continue;
1534
1536
  const lang = LANG_MAP[lastChar];
1535
1537
  if (!lang)
1536
1538
  continue;
@@ -1628,7 +1630,8 @@ const aiDeslopFiller = {
1628
1630
  const REGEX_4$2 = /[.*+?^${}()|[\]\\]/g;
1629
1631
  const REGEX_3$4 = /(?:\bnot|n't|no longer|more than|rather than)\s+$/;
1630
1632
  const REGEX_2$b = /^[-*>\s#\d.]+$/;
1631
- const REGEX_1$q = /\.\s+$/;
1633
+ const REGEX_1$q = /[.:]\s+$/;
1634
+ const NEGATION_AFTER = /^(?:never|no|nothing|none|nobody|nowhere)\b/;
1632
1635
  const COMPILED$2 = HEDGING_WORDS.map((word) => {
1633
1636
  const escaped = word.replace(REGEX_4$2, "\\$&");
1634
1637
  return { regex: new RegExp(`\\b${escaped}\\s+`, "gi"), word };
@@ -1668,6 +1671,8 @@ const aiDeslopHedging = {
1668
1671
  continue;
1669
1672
  if (word === "just" && REGEX_3$4.test(line.slice(Math.max(0, match.index - 20), match.index)))
1670
1673
  continue;
1674
+ if (NEGATION_AFTER.test(line.slice(match.index + match[0].length)))
1675
+ continue;
1671
1676
  const startOffset = lineNode.position.start.offset + match.index;
1672
1677
  const endOffset = startOffset + match[0].length;
1673
1678
  const textBefore = line.slice(0, match.index);
@@ -1800,6 +1805,8 @@ function report(context, lineIdx, col, messageId, fixStart, fixEnd, replacement)
1800
1805
 
1801
1806
  const REGEX_2$a = /!(?!\[)/g;
1802
1807
  const REGEX_1$o = /\w/;
1808
+ const REGEX_WORD_END = /(\w+)$/;
1809
+ const REGEX_UPPER_START = /^[A-Z]/;
1803
1810
  const aiDeslopNoExclamation = {
1804
1811
  meta: {
1805
1812
  type: "suggestion",
@@ -1830,6 +1837,10 @@ const aiDeslopNoExclamation = {
1830
1837
  continue;
1831
1838
  if (match.index === 0 || !REGEX_1$o.test(line[match.index - 1]))
1832
1839
  continue;
1840
+ const beforeBang = line.slice(0, match.index);
1841
+ const wordMatch = beforeBang.match(REGEX_WORD_END);
1842
+ if (wordMatch && REGEX_UPPER_START.test(wordMatch[1]))
1843
+ continue;
1833
1844
  const startOffset = lineNode.position.start.offset + match.index;
1834
1845
  const endOffset = startOffset + 1;
1835
1846
  context.report({
@@ -2841,11 +2852,12 @@ const promptUnclosedCodeFence = {
2841
2852
 
2842
2853
  const REGEX_2$5 = /<([a-z_][\w-]*)>/gi;
2843
2854
  const REGEX_1$b = /<\/([a-z_][\w-]*)>/gi;
2855
+ const REGEX_INLINE_CODE = /`[^`]+`/g;
2844
2856
  const promptUnclosedTag = {
2845
2857
  meta: {
2846
2858
  type: "problem",
2847
2859
  docs: { description: "Detect mismatched XML-style tags" },
2848
- fixable: "code",
2860
+ fixable: void 0,
2849
2861
  schema: [],
2850
2862
  messages: {
2851
2863
  unclosed: "Mismatched XML tag: <{{tag}}> appears {{openCount}} time(s) but </{{tag}}> appears {{closeCount}} time(s)."
@@ -2858,7 +2870,7 @@ const promptUnclosedTag = {
2858
2870
  const lines = sourceCode.lines;
2859
2871
  const codeBlockLines = getCodeBlockLines(lines);
2860
2872
  const frontmatterEnd = getFrontmatterEnd(lines);
2861
- const filteredText = lines.filter((_, i) => !shouldSkipLine(i, codeBlockLines, frontmatterEnd)).join("\n");
2873
+ const filteredText = lines.filter((_, i) => !shouldSkipLine(i, codeBlockLines, frontmatterEnd)).map((line) => line.replace(REGEX_INLINE_CODE, "")).join("\n");
2862
2874
  const openTags = /* @__PURE__ */ new Map();
2863
2875
  const closeTags = /* @__PURE__ */ new Map();
2864
2876
  let match;
@@ -2872,22 +2884,13 @@ const promptUnclosedTag = {
2872
2884
  const tag = match[1].toLowerCase();
2873
2885
  closeTags.set(tag, (closeTags.get(tag) ?? 0) + 1);
2874
2886
  }
2875
- const docEnd = node.position.end.offset;
2876
2887
  for (const [tag, count] of openTags) {
2877
2888
  const closeCount = closeTags.get(tag) ?? 0;
2878
2889
  if (count !== closeCount) {
2879
- const missing = count - closeCount;
2880
2890
  context.report({
2881
2891
  node,
2882
2892
  messageId: "unclosed",
2883
- data: { tag, openCount: String(count), closeCount: String(closeCount) },
2884
- ...missing > 0 && {
2885
- fix(fixer) {
2886
- const closingTags = Array.from({ length: missing }).fill(`</${tag}>`).join("\n");
2887
- return fixer.insertTextAfterRange([docEnd, docEnd], `
2888
- ${closingTags}`);
2889
- }
2890
- }
2893
+ data: { tag, openCount: String(count), closeCount: String(closeCount) }
2891
2894
  });
2892
2895
  }
2893
2896
  }
@@ -3211,9 +3214,11 @@ const hasDocs = [
3211
3214
  "nuxt-prefer-navigate-to-over-router-push-replace",
3212
3215
  "nuxt-prefer-nuxt-link-over-router-link",
3213
3216
  "use-composables-must-use-reactivity",
3217
+ "vue-no-async-lifecycle-hook",
3214
3218
  "vue-no-nested-reactivity",
3215
3219
  "vue-no-passing-refs-as-props",
3216
3220
  "vue-no-reactive-destructuring",
3221
+ "vue-no-reactivity-after-await",
3217
3222
  "vue-no-ref-access-in-templates",
3218
3223
  "vue-no-torefs-on-props"
3219
3224
  ];
@@ -3636,9 +3641,9 @@ function createReactivityChecker(vueImports, nonVueImports) {
3636
3641
 
3637
3642
  const REGEX_2$4 = /[^\u0020-\u007F]/;
3638
3643
  const REGEX_1$6 = /[^\u0020-\u007F]/;
3639
- const RULE_NAME$n = "link-ascii-only";
3644
+ const RULE_NAME$p = "link-ascii-only";
3640
3645
  const linkAsciiOnly = createEslintRule({
3641
- name: RULE_NAME$n,
3646
+ name: RULE_NAME$p,
3642
3647
  meta: {
3643
3648
  type: "suggestion",
3644
3649
  docs: {
@@ -3714,9 +3719,9 @@ const REGEX_4$1 = /[A-Z]/;
3714
3719
  const REGEX_3$2 = /%[0-9A-F]{2}/i;
3715
3720
  const REGEX_2$3 = /[A-Z]/;
3716
3721
  const REGEX_1$5 = /%[0-9A-F]{2}/i;
3717
- const RULE_NAME$m = "link-lowercase";
3722
+ const RULE_NAME$o = "link-lowercase";
3718
3723
  const linkLowercase = createEslintRule({
3719
- name: RULE_NAME$m,
3724
+ name: RULE_NAME$o,
3720
3725
  meta: {
3721
3726
  type: "suggestion",
3722
3727
  docs: {
@@ -3795,7 +3800,7 @@ const linkLowercase = createEslintRule({
3795
3800
  const REGEX_3$1 = /\/+/g;
3796
3801
  const REGEX_2$2 = /\/{2,}/;
3797
3802
  const REGEX_1$4 = /\/{2,}/;
3798
- const RULE_NAME$l = "link-no-double-slashes";
3803
+ const RULE_NAME$n = "link-no-double-slashes";
3799
3804
  function fixDoubleSlashesInUrl(url) {
3800
3805
  if (url.startsWith("//") || url.includes("://"))
3801
3806
  return url;
@@ -3815,7 +3820,7 @@ function fixDoubleSlashesInUrl(url) {
3815
3820
  return `${path.replace(REGEX_3$1, "/")}${search}${hash}`;
3816
3821
  }
3817
3822
  const linkNoDoubleSlashes = createEslintRule({
3818
- name: RULE_NAME$l,
3823
+ name: RULE_NAME$n,
3819
3824
  meta: {
3820
3825
  type: "problem",
3821
3826
  docs: {
@@ -3891,9 +3896,9 @@ const linkNoDoubleSlashes = createEslintRule({
3891
3896
  }
3892
3897
  });
3893
3898
 
3894
- const RULE_NAME$k = "link-no-underscores";
3899
+ const RULE_NAME$m = "link-no-underscores";
3895
3900
  const linkNoUnderscores = createEslintRule({
3896
- name: RULE_NAME$k,
3901
+ name: RULE_NAME$m,
3897
3902
  meta: {
3898
3903
  type: "suggestion",
3899
3904
  docs: {
@@ -3971,9 +3976,9 @@ const REGEX_4 = /\s/;
3971
3976
  const REGEX_3 = /\s/g;
3972
3977
  const REGEX_2$1 = /\s/;
3973
3978
  const REGEX_1$3 = /\s/g;
3974
- const RULE_NAME$j = "link-no-whitespace";
3979
+ const RULE_NAME$l = "link-no-whitespace";
3975
3980
  const linkNoWhitespace = createEslintRule({
3976
- name: RULE_NAME$j,
3981
+ name: RULE_NAME$l,
3977
3982
  meta: {
3978
3983
  type: "suggestion",
3979
3984
  docs: {
@@ -4047,7 +4052,7 @@ const linkNoWhitespace = createEslintRule({
4047
4052
  }
4048
4053
  });
4049
4054
 
4050
- const RULE_NAME$i = "link-require-descriptive-text";
4055
+ const RULE_NAME$k = "link-require-descriptive-text";
4051
4056
  const BAD_LINK_TEXTS = /* @__PURE__ */ new Set([
4052
4057
  "click here",
4053
4058
  "click this",
@@ -4093,7 +4098,7 @@ function getVueLinkUrl(node) {
4093
4098
  return null;
4094
4099
  }
4095
4100
  const linkRequireDescriptiveText = createEslintRule({
4096
- name: RULE_NAME$i,
4101
+ name: RULE_NAME$k,
4097
4102
  meta: {
4098
4103
  type: "suggestion",
4099
4104
  docs: {
@@ -4169,9 +4174,9 @@ const linkRequireDescriptiveText = createEslintRule({
4169
4174
  }
4170
4175
  });
4171
4176
 
4172
- const RULE_NAME$h = "link-require-href";
4177
+ const RULE_NAME$j = "link-require-href";
4173
4178
  const linkRequireHref = createEslintRule({
4174
- name: RULE_NAME$h,
4179
+ name: RULE_NAME$j,
4175
4180
  meta: {
4176
4181
  type: "problem",
4177
4182
  docs: {
@@ -4239,12 +4244,12 @@ const linkRequireHref = createEslintRule({
4239
4244
  }
4240
4245
  });
4241
4246
 
4242
- const RULE_NAME$g = "link-trailing-slash";
4247
+ const RULE_NAME$i = "link-trailing-slash";
4243
4248
  function shouldSkipUrl(url) {
4244
4249
  return url.startsWith("#") || url.includes(":") || url === "/" || url === "";
4245
4250
  }
4246
4251
  const linkTrailingSlash = createEslintRule({
4247
- name: RULE_NAME$g,
4252
+ name: RULE_NAME$i,
4248
4253
  meta: {
4249
4254
  type: "suggestion",
4250
4255
  docs: {
@@ -4359,9 +4364,9 @@ const linkTrailingSlash = createEslintRule({
4359
4364
  }
4360
4365
  });
4361
4366
 
4362
- const RULE_NAME$f = "nuxt-await-navigate-to";
4367
+ const RULE_NAME$h = "nuxt-await-navigate-to";
4363
4368
  const nuxtAwaitNavigateTo = createEslintRule({
4364
- name: RULE_NAME$f,
4369
+ name: RULE_NAME$h,
4365
4370
  meta: {
4366
4371
  type: "problem",
4367
4372
  docs: {
@@ -4517,7 +4522,7 @@ function isInConsequentBranch(node, ternary) {
4517
4522
  return false;
4518
4523
  }
4519
4524
 
4520
- const RULE_NAME$e = "nuxt-no-random";
4525
+ const RULE_NAME$g = "nuxt-no-random";
4521
4526
  function isMathRandomCall(node) {
4522
4527
  return node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && node.callee.object.name === "Math" && node.callee.property.type === "Identifier" && node.callee.property.name === "random";
4523
4528
  }
@@ -4548,7 +4553,7 @@ function reportRandom(context, node, template) {
4548
4553
  }
4549
4554
  }
4550
4555
  const nuxtNoRandom = createEslintRule({
4551
- name: RULE_NAME$e,
4556
+ name: RULE_NAME$g,
4552
4557
  meta: {
4553
4558
  type: "problem",
4554
4559
  docs: {
@@ -4587,9 +4592,9 @@ const nuxtNoRandom = createEslintRule({
4587
4592
  }
4588
4593
  });
4589
4594
 
4590
- const RULE_NAME$d = "nuxt-no-redundant-import-meta";
4595
+ const RULE_NAME$f = "nuxt-no-redundant-import-meta";
4591
4596
  const nuxtNoRedundantImportMeta = createEslintRule({
4592
- name: RULE_NAME$d,
4597
+ name: RULE_NAME$f,
4593
4598
  meta: {
4594
4599
  type: "problem",
4595
4600
  docs: {
@@ -4627,7 +4632,7 @@ const nuxtNoRedundantImportMeta = createEslintRule({
4627
4632
  });
4628
4633
 
4629
4634
  const REGEX_1$2 = /\n\s*\n\s*\n/g;
4630
- const RULE_NAME$c = "nuxt-no-side-effects-in-async-data-handler";
4635
+ const RULE_NAME$e = "nuxt-no-side-effects-in-async-data-handler";
4631
4636
  const SIDE_EFFECT_PATTERNS = /* @__PURE__ */ new Set([
4632
4637
  // Store/State mutations ($ prefix makes these unambiguous)
4633
4638
  "$patch",
@@ -4737,7 +4742,7 @@ function findSideEffectsInFunction(functionNode) {
4737
4742
  return sideEffects;
4738
4743
  }
4739
4744
  const nuxtNoSideEffectsInAsyncDataHandler = createEslintRule({
4740
- name: RULE_NAME$c,
4745
+ name: RULE_NAME$e,
4741
4746
  meta: {
4742
4747
  type: "problem",
4743
4748
  docs: {
@@ -4828,9 +4833,9 @@ ${indent}${callOnceBlock}`)
4828
4833
  }
4829
4834
  });
4830
4835
 
4831
- const RULE_NAME$b = "nuxt-no-side-effects-in-setup";
4836
+ const RULE_NAME$d = "nuxt-no-side-effects-in-setup";
4832
4837
  const nuxtNoSideEffectsInSetup = createEslintRule({
4833
- name: RULE_NAME$b,
4838
+ name: RULE_NAME$d,
4834
4839
  meta: {
4835
4840
  type: "problem",
4836
4841
  docs: {
@@ -4921,7 +4926,7 @@ ${indent}})`;
4921
4926
  }
4922
4927
  });
4923
4928
 
4924
- const RULE_NAME$a = "nuxt-no-unsafe-date";
4929
+ const RULE_NAME$c = "nuxt-no-unsafe-date";
4925
4930
  function isDateNowCall(node) {
4926
4931
  return node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && node.callee.object.name === "Date" && node.callee.property.type === "Identifier" && node.callee.property.name === "now";
4927
4932
  }
@@ -4952,7 +4957,7 @@ function isChainedWithStableMethod(node) {
4952
4957
  return grandparent?.type === "CallExpression" && grandparent.callee === parent;
4953
4958
  }
4954
4959
  const nuxtNoUnsafeDate = createEslintRule({
4955
- name: RULE_NAME$a,
4960
+ name: RULE_NAME$c,
4956
4961
  meta: {
4957
4962
  type: "problem",
4958
4963
  docs: {
@@ -5016,9 +5021,9 @@ const nuxtNoUnsafeDate = createEslintRule({
5016
5021
  }
5017
5022
  });
5018
5023
 
5019
- const RULE_NAME$9 = "nuxt-prefer-navigate-to-over-router-push-replace";
5024
+ const RULE_NAME$b = "nuxt-prefer-navigate-to-over-router-push-replace";
5020
5025
  const nuxtPreferNavigateToOverRouterPushReplace = createEslintRule({
5021
- name: RULE_NAME$9,
5026
+ name: RULE_NAME$b,
5022
5027
  meta: {
5023
5028
  type: "suggestion",
5024
5029
  docs: {
@@ -5083,9 +5088,9 @@ const nuxtPreferNavigateToOverRouterPushReplace = createEslintRule({
5083
5088
 
5084
5089
  const REGEX_2 = /router-link|RouterLink/gi;
5085
5090
  const REGEX_1$1 = /router-link|RouterLink/gi;
5086
- const RULE_NAME$8 = "nuxt-prefer-nuxt-link-over-router-link";
5091
+ const RULE_NAME$a = "nuxt-prefer-nuxt-link-over-router-link";
5087
5092
  const nuxtPreferNuxtLinkOverRouterLink = createEslintRule({
5088
- name: RULE_NAME$8,
5093
+ name: RULE_NAME$a,
5089
5094
  meta: {
5090
5095
  type: "suggestion",
5091
5096
  docs: {
@@ -5156,7 +5161,7 @@ const nuxtPreferNuxtLinkOverRouterLink = createEslintRule({
5156
5161
  }
5157
5162
  });
5158
5163
 
5159
- const RULE_NAME$7 = "nuxt-ui-prefer-shorthand-css";
5164
+ const RULE_NAME$9 = "nuxt-ui-prefer-shorthand-css";
5160
5165
  const REPLACEMENTS = {
5161
5166
  // text
5162
5167
  "text-[var(--ui-text)]": "text-default",
@@ -5213,7 +5218,7 @@ function findVerboseClasses(value) {
5213
5218
  return results;
5214
5219
  }
5215
5220
  const nuxtUiPreferShorthandCss = createEslintRule({
5216
- name: RULE_NAME$7,
5221
+ name: RULE_NAME$9,
5217
5222
  meta: {
5218
5223
  type: "suggestion",
5219
5224
  docs: {
@@ -5362,9 +5367,60 @@ const nuxtUiPreferShorthandCss = createEslintRule({
5362
5367
  }
5363
5368
  });
5364
5369
 
5365
- const RULE_NAME$6 = "vue-no-faux-composables";
5370
+ const RULE_NAME$8 = "vue-no-async-lifecycle-hook";
5371
+ const LIFECYCLE_HOOKS = /* @__PURE__ */ new Set([
5372
+ "onBeforeMount",
5373
+ "onMounted",
5374
+ "onBeforeUnmount",
5375
+ "onUnmounted",
5376
+ "onBeforeUpdate",
5377
+ "onUpdated",
5378
+ "onActivated",
5379
+ "onDeactivated",
5380
+ "onErrorCaptured",
5381
+ "onRenderTracked",
5382
+ "onRenderTriggered"
5383
+ // Note: onServerPrefetch is intentionally excluded — Vue awaits it
5384
+ ]);
5385
+ const vueNoAsyncLifecycleHook = createEslintRule({
5386
+ name: RULE_NAME$8,
5387
+ meta: {
5388
+ type: "problem",
5389
+ docs: {
5390
+ description: "disallow async callbacks in Vue lifecycle hooks \u2014 code after await runs as a detached microtask that Vue does not await"
5391
+ },
5392
+ schema: [],
5393
+ messages: {
5394
+ noAsyncLifecycleHook: "{{hook}}() with an async callback is unsafe \u2014 Vue does not await lifecycle callbacks, so code after await runs as a detached microtask that may not execute during the lifecycle phase."
5395
+ }
5396
+ },
5397
+ defaultOptions: [],
5398
+ create: (context) => {
5399
+ return {
5400
+ CallExpression(node) {
5401
+ if (node.callee.type !== "Identifier")
5402
+ return;
5403
+ const hook = node.callee.name;
5404
+ if (!LIFECYCLE_HOOKS.has(hook))
5405
+ return;
5406
+ const callback = node.arguments[0];
5407
+ if (!callback)
5408
+ return;
5409
+ if ((callback.type === "ArrowFunctionExpression" || callback.type === "FunctionExpression") && callback.async) {
5410
+ context.report({
5411
+ node,
5412
+ messageId: "noAsyncLifecycleHook",
5413
+ data: { hook }
5414
+ });
5415
+ }
5416
+ }
5417
+ };
5418
+ }
5419
+ });
5420
+
5421
+ const RULE_NAME$7 = "vue-no-faux-composables";
5366
5422
  const vueNoFauxComposables = createEslintRule({
5367
- name: RULE_NAME$6,
5423
+ name: RULE_NAME$7,
5368
5424
  meta: {
5369
5425
  type: "problem",
5370
5426
  docs: {
@@ -5433,9 +5489,9 @@ const vueNoFauxComposables = createEslintRule({
5433
5489
  }
5434
5490
  });
5435
5491
 
5436
- const RULE_NAME$5 = "vue-no-nested-reactivity";
5492
+ const RULE_NAME$6 = "vue-no-nested-reactivity";
5437
5493
  const vueNoNestedReactivity = createEslintRule({
5438
- name: RULE_NAME$5,
5494
+ name: RULE_NAME$6,
5439
5495
  meta: {
5440
5496
  type: "problem",
5441
5497
  docs: {
@@ -5680,9 +5736,9 @@ const vueNoNestedReactivity = createEslintRule({
5680
5736
  }
5681
5737
  });
5682
5738
 
5683
- const RULE_NAME$4 = "vue-no-passing-refs-as-props";
5739
+ const RULE_NAME$5 = "vue-no-passing-refs-as-props";
5684
5740
  const vueNoPassingRefsAsProps = createEslintRule({
5685
- name: RULE_NAME$4,
5741
+ name: RULE_NAME$5,
5686
5742
  meta: {
5687
5743
  type: "problem",
5688
5744
  docs: {
@@ -5789,6 +5845,127 @@ const vueNoReactiveDestructuring = createEslintRule({
5789
5845
  }
5790
5846
  });
5791
5847
 
5848
+ const RULE_NAME$4 = "vue-no-reactivity-after-await";
5849
+ const SUBSCRIPTION_APIS = /* @__PURE__ */ new Set([
5850
+ // Vue watchers
5851
+ "watch",
5852
+ "watchEffect",
5853
+ "watchPostEffect",
5854
+ "watchSyncEffect",
5855
+ // Vue computed
5856
+ "computed",
5857
+ // VueUse watchers
5858
+ "whenever",
5859
+ "watchArray",
5860
+ "watchAtMost",
5861
+ "watchDebounced",
5862
+ "watchDeep",
5863
+ "watchIgnorable",
5864
+ "watchImmediate",
5865
+ "watchOnce",
5866
+ "watchPausable",
5867
+ "watchThrottled",
5868
+ "watchTriggerable",
5869
+ "watchWithFilter",
5870
+ "debouncedWatch",
5871
+ "throttledWatch",
5872
+ // VueUse computed
5873
+ "computedAsync",
5874
+ "computedEager",
5875
+ "computedInject",
5876
+ "computedWithControl",
5877
+ "reactiveComputed"
5878
+ ]);
5879
+ function getEnclosingAsyncFunction(node) {
5880
+ let parent = node.parent;
5881
+ while (parent) {
5882
+ if (parent.type === "FunctionDeclaration" || parent.type === "FunctionExpression" || parent.type === "ArrowFunctionExpression") {
5883
+ return parent.async ? parent : null;
5884
+ }
5885
+ parent = parent.parent;
5886
+ }
5887
+ return null;
5888
+ }
5889
+ function hasAwaitBefore(callNode, asyncFn) {
5890
+ const body = asyncFn.body;
5891
+ if (body.type !== "BlockStatement")
5892
+ return false;
5893
+ const callStart = callNode.range[0];
5894
+ let found = false;
5895
+ function walkStatements(stmts) {
5896
+ for (const stmt of stmts) {
5897
+ if (found)
5898
+ return;
5899
+ walkNode(stmt);
5900
+ }
5901
+ }
5902
+ function walkNode(node) {
5903
+ if (found)
5904
+ return;
5905
+ if (node.range[0] >= callStart)
5906
+ return;
5907
+ if (node.type === "AwaitExpression") {
5908
+ found = true;
5909
+ return;
5910
+ }
5911
+ if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") {
5912
+ return;
5913
+ }
5914
+ for (const key of Object.keys(node)) {
5915
+ if (key === "parent")
5916
+ continue;
5917
+ const child = node[key];
5918
+ if (child && typeof child === "object") {
5919
+ if (Array.isArray(child)) {
5920
+ for (const item of child) {
5921
+ if (item && typeof item.type === "string")
5922
+ walkNode(item);
5923
+ }
5924
+ } else if (typeof child.type === "string") {
5925
+ walkNode(child);
5926
+ }
5927
+ }
5928
+ }
5929
+ }
5930
+ walkStatements(body.body);
5931
+ return found;
5932
+ }
5933
+ const vueNoReactivityAfterAwait = createEslintRule({
5934
+ name: RULE_NAME$4,
5935
+ meta: {
5936
+ type: "problem",
5937
+ docs: {
5938
+ description: "disallow Vue subscription APIs (watch, computed, etc.) after await in async functions \u2014 they lose component instance context and become orphaned"
5939
+ },
5940
+ schema: [],
5941
+ messages: {
5942
+ noReactivityAfterAwait: "{{name}}() called after await loses Vue component instance context and will never be auto-stopped on unmount. Move it before any await or wrap in effectScope()."
5943
+ }
5944
+ },
5945
+ defaultOptions: [],
5946
+ create: (context) => {
5947
+ return {
5948
+ CallExpression(node) {
5949
+ if (node.callee.type !== "Identifier")
5950
+ return;
5951
+ const name = node.callee.name;
5952
+ if (!SUBSCRIPTION_APIS.has(name))
5953
+ return;
5954
+ const asyncFn = getEnclosingAsyncFunction(node);
5955
+ if (!asyncFn)
5956
+ return;
5957
+ if (hasAwaitBefore(node, asyncFn)) {
5958
+ context.report({
5959
+ node,
5960
+ messageId: "noReactivityAfterAwait",
5961
+ data: { name }
5962
+ });
5963
+ }
5964
+ }
5965
+ };
5966
+ }
5967
+ });
5968
+
5792
5969
  const RULE_NAME$3 = "vue-no-ref-access-in-templates";
5793
5970
  const vueNoRefAccessInTemplates = createEslintRule({
5794
5971
  name: RULE_NAME$3,
@@ -6162,10 +6339,12 @@ const plugin = {
6162
6339
  "prompt-unresolved-reference": promptUnresolvedReference,
6163
6340
  "prompt-vague-term": promptVagueTerm,
6164
6341
  "prompt-weak-instruction": promptWeakInstruction,
6342
+ "vue-no-async-lifecycle-hook": vueNoAsyncLifecycleHook,
6165
6343
  "vue-no-faux-composables": vueNoFauxComposables,
6166
6344
  "vue-no-nested-reactivity": vueNoNestedReactivity,
6167
6345
  "vue-no-passing-refs-as-props": vueNoPassingRefsAsProps,
6168
6346
  "vue-no-reactive-destructuring": vueNoReactiveDestructuring,
6347
+ "vue-no-reactivity-after-await": vueNoReactivityAfterAwait,
6169
6348
  "vue-no-ref-access-in-templates": vueNoRefAccessInTemplates,
6170
6349
  "vue-no-resolve-component-in-composables": vueNoResolveComponentInComposables,
6171
6350
  "vue-no-torefs-on-props": vueNoTorefsOnProps,
@@ -6310,10 +6489,12 @@ plugin.configs.vue = [
6310
6489
  ignores: CODE_IGNORES,
6311
6490
  plugins: { harlanzw: plugin },
6312
6491
  rules: {
6492
+ "harlanzw/vue-no-async-lifecycle-hook": "error",
6313
6493
  "harlanzw/vue-no-faux-composables": "error",
6314
6494
  "harlanzw/vue-no-nested-reactivity": "error",
6315
6495
  "harlanzw/vue-no-passing-refs-as-props": "error",
6316
6496
  "harlanzw/vue-no-reactive-destructuring": "error",
6497
+ "harlanzw/vue-no-reactivity-after-await": "error",
6317
6498
  "harlanzw/vue-no-ref-access-in-templates": "warn",
6318
6499
  "harlanzw/vue-no-resolve-component-in-composables": "error",
6319
6500
  "harlanzw/vue-no-torefs-on-props": "warn",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eslint-plugin-harlanzw",
3
3
  "type": "module",
4
- "version": "0.5.21",
4
+ "version": "0.6.0",
5
5
  "description": "Harlan's opinionated ESLint rules",
6
6
  "author": "Harlan Wilton <harlan@harlanzw.com>",
7
7
  "license": "MIT",
@@ -32,14 +32,14 @@
32
32
  "@eslint/plugin-kit": "^0.6.1"
33
33
  },
34
34
  "devDependencies": {
35
- "@antfu/eslint-config": "^7.7.2",
35
+ "@antfu/eslint-config": "^7.7.3",
36
36
  "@antfu/ni": "^29.0.0",
37
37
  "@antfu/utils": "^9.3.0",
38
38
  "@types/eslint": "^9.6.1",
39
39
  "@types/node": "^25.5.0",
40
- "@typescript-eslint/typescript-estree": "^8.57.0",
41
- "@typescript-eslint/utils": "^8.57.0",
42
- "bumpp": "^11.0.0",
40
+ "@typescript-eslint/typescript-estree": "^8.57.1",
41
+ "@typescript-eslint/utils": "^8.57.1",
42
+ "bumpp": "^11.0.1",
43
43
  "eslint": "^10.0.3",
44
44
  "eslint-vitest-rule-tester": "^3.1.0",
45
45
  "jsonc-eslint-parser": "^3.1.0",