eslint-plugin-harlanzw 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Harlan Wilton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # eslint-plugin-harlanzw
2
+
3
+ [![npm version][npm-version-src]][npm-version-href]
4
+ [![npm downloads][npm-downloads-src]][npm-downloads-href]
5
+ [![License][license-src]][license-href]
6
+
7
+ Harlan's ESLint rules for Vue projects.
8
+
9
+ <p align="center">
10
+ <table>
11
+ <tbody>
12
+ <td align="center">
13
+ <sub>Made possible by my <a href="https://github.com/sponsors/harlan-zw">Sponsor Program 💖</a><br> Follow me <a href="https://twitter.com/harlan_zw">@harlan_zw</a> 🐦</sub><br>
14
+ </td>
15
+ </tbody>
16
+ </table>
17
+ </p>
18
+
19
+ ## Rules
20
+
21
+ > **Note:** These rules are experimental and may change. They will be submitted to the official Vue ESLint plugin for consideration.
22
+
23
+ <!-- rules:start -->
24
+ - [`vue-no-faux-composables`](./src/rules/vue-no-faux-composables.md) - stop fake composables that don't use Vue reactivity
25
+ - [`vue-no-nested-reactivity`](./src/rules/vue-no-nested-reactivity.md) - don't mix `ref()` and `reactive()` together
26
+ - [`vue-no-passing-refs-as-props`](./src/rules/vue-no-passing-refs-as-props.md) - don't pass refs as props - unwrap them first
27
+ - [`vue-no-ref-access-in-templates`](./src/rules/vue-no-ref-access-in-templates.md) - don't use `.value` in Vue templates
28
+ - [`vue-no-torefs-on-props`](./src/rules/vue-no-torefs-on-props.md) - don't use `toRefs()` on the props object
29
+ <!-- rules:end -->
30
+
31
+ ## Installation
32
+
33
+ Install the plugin:
34
+
35
+ ```bash
36
+ pnpm add -D eslint-plugin-harlanzw
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ### With @antfu/eslint-config
42
+
43
+ ```js
44
+ // eslint.config.js
45
+ import antfu from '@antfu/eslint-config'
46
+ import harlanzw from 'eslint-plugin-harlanzw'
47
+
48
+ export default antfu(
49
+ {
50
+ vue: true,
51
+ },
52
+ {
53
+ plugins: {
54
+ harlanzw
55
+ },
56
+ rules: {
57
+ 'harlanzw/vue-no-faux-composables': 'error',
58
+ 'harlanzw/vue-no-nested-reactivity': 'error',
59
+ 'harlanzw/vue-no-passing-refs-as-props': 'error',
60
+ 'harlanzw/vue-no-ref-access-in-templates': 'error',
61
+ 'harlanzw/vue-no-torefs-on-props': 'error'
62
+ }
63
+ }
64
+ )
65
+ ```
66
+
67
+ ### Standalone Usage
68
+
69
+ Add the plugin to your ESLint configuration:
70
+
71
+ ```js
72
+ // eslint.config.js
73
+ import harlanzw from 'eslint-plugin-harlanzw'
74
+
75
+ export default [
76
+ {
77
+ plugins: {
78
+ harlanzw
79
+ },
80
+ rules: {
81
+ 'harlanzw/vue-no-faux-composables': 'error',
82
+ 'harlanzw/vue-no-nested-reactivity': 'error',
83
+ 'harlanzw/vue-no-passing-refs-as-props': 'error',
84
+ 'harlanzw/vue-no-ref-access-in-templates': 'error',
85
+ 'harlanzw/vue-no-torefs-on-props': 'error'
86
+ }
87
+ }
88
+ ]
89
+ ```
90
+
91
+
92
+ ## Sponsors
93
+
94
+ <p align="center">
95
+ <a href="https://raw.githubusercontent.com/harlan-zw/static/main/sponsors.svg">
96
+ <img src='https://raw.githubusercontent.com/harlan-zw/static/main/sponsors.svg'/>
97
+ </a>
98
+ </p>
99
+
100
+ ## Credits
101
+
102
+ This plugin is based on [eslint-plugin-antfu](https://github.com/antfu/eslint-plugin-antfu) by Anthony Fu.
103
+
104
+ ## License
105
+
106
+ Licensed under the [MIT license](https://github.com/harlan-zw/eslint-plugin-harlanzw/blob/main/LICENSE).
107
+
108
+ <!-- Badges -->
109
+
110
+ [npm-version-src]: https://img.shields.io/npm/v/eslint-plugin-harlanzw?style=flat&colorA=080f12&colorB=1fa669
111
+ [npm-version-href]: https://npmjs.com/package/eslint-plugin-harlanzw
112
+ [npm-downloads-src]: https://img.shields.io/npm/dm/eslint-plugin-harlanzw?style=flat&colorA=080f12&colorB=1fa669
113
+ [npm-downloads-href]: https://npmjs.com/package/eslint-plugin-harlanzw
114
+
115
+ [license-src]: https://img.shields.io/github/license/harlan-zw/eslint-plugin-harlanzw.svg?style=flat&colorA=080f12&colorB=1fa669
116
+ [license-href]: https://github.com/harlan-zw/eslint-plugin-harlanzw/blob/main/LICENSE
@@ -0,0 +1,30 @@
1
+ import { Rule, Linter } from 'eslint';
2
+
3
+ type RuleModule<T extends readonly unknown[]> = Rule.RuleModule & {
4
+ defaultOptions: T;
5
+ };
6
+
7
+ declare const plugin: {
8
+ meta: {
9
+ name: string;
10
+ version: string;
11
+ };
12
+ rules: {
13
+ 'vue-no-faux-composables': RuleModule<[]>;
14
+ 'vue-no-nested-reactivity': RuleModule<[]>;
15
+ 'vue-no-passing-refs-as-props': RuleModule<[]>;
16
+ 'vue-no-ref-access-in-templates': RuleModule<[]>;
17
+ 'vue-no-torefs-on-props': RuleModule<[]>;
18
+ };
19
+ };
20
+
21
+ type RuleDefinitions = typeof plugin['rules'];
22
+ type RuleOptions = {
23
+ [K in keyof RuleDefinitions]: RuleDefinitions[K]['defaultOptions'];
24
+ };
25
+ type Rules = {
26
+ [K in keyof RuleOptions]: Linter.RuleEntry<RuleOptions[K]>;
27
+ };
28
+
29
+ export { plugin as default };
30
+ export type { RuleOptions, Rules };
package/dist/index.mjs ADDED
@@ -0,0 +1,686 @@
1
+ const version = "0.0.0";
2
+
3
+ const hasDocs = [
4
+ "use-composables-must-use-reactivity",
5
+ "vue-no-nested-reactivity",
6
+ "vue-no-passing-refs-as-props",
7
+ "vue-no-ref-access-in-templates",
8
+ "vue-no-torefs-on-props"
9
+ ];
10
+ const blobUrl = "https://github.com/harlan-zw/eslint-plugin-harlanzw/blob/main/src/rules/";
11
+ function RuleCreator(urlCreator) {
12
+ return function createNamedRule({
13
+ name,
14
+ meta,
15
+ ...rule
16
+ }) {
17
+ return createRule({
18
+ meta: {
19
+ ...meta,
20
+ docs: {
21
+ ...meta.docs,
22
+ url: urlCreator(name)
23
+ }
24
+ },
25
+ ...rule
26
+ });
27
+ };
28
+ }
29
+ function createRule({
30
+ create,
31
+ defaultOptions,
32
+ meta
33
+ }) {
34
+ return {
35
+ create: ((context) => {
36
+ const optionsWithDefault = context.options.map((options, index) => {
37
+ return {
38
+ ...defaultOptions[index] || {},
39
+ ...options || {}
40
+ };
41
+ });
42
+ return create(context, optionsWithDefault);
43
+ }),
44
+ defaultOptions,
45
+ meta
46
+ };
47
+ }
48
+ const createEslintRule = RuleCreator(
49
+ (ruleName) => hasDocs.includes(ruleName) ? `${blobUrl}${ruleName}.md` : `${blobUrl}${ruleName}.test.ts`
50
+ );
51
+
52
+ const VUE_REACTIVITY_APIS = /* @__PURE__ */ new Set([
53
+ // Core reactivity
54
+ "ref",
55
+ "reactive",
56
+ "computed",
57
+ "watch",
58
+ "watchEffect",
59
+ "readonly",
60
+ "watchPostEffect",
61
+ "watchSyncEffect",
62
+ "onWatcherCleanup",
63
+ // Shallow variants
64
+ "shallowRef",
65
+ "shallowReactive",
66
+ "shallowReadonly",
67
+ // Utilities
68
+ "toRef",
69
+ "toRefs",
70
+ "unref",
71
+ "toRaw",
72
+ "markRaw",
73
+ // Type checking
74
+ "isRef",
75
+ "isReactive",
76
+ "isReadonly",
77
+ // Advanced
78
+ "customRef",
79
+ "triggerRef",
80
+ "effectScope",
81
+ "getCurrentScope",
82
+ "onScopeDispose"
83
+ ]);
84
+ function isRefCall(node) {
85
+ return node.callee.type === "Identifier" && node.callee.name === "ref";
86
+ }
87
+ function isInVueTemplateString(node) {
88
+ let parent = node.parent;
89
+ while (parent) {
90
+ if (parent.type === "TemplateLiteral") {
91
+ const grandparent = parent.parent;
92
+ if (grandparent?.type === "TaggedTemplateExpression") {
93
+ const tag = grandparent.tag;
94
+ if (tag.type === "Identifier" && tag.name === "html") {
95
+ return true;
96
+ }
97
+ }
98
+ }
99
+ parent = parent.parent;
100
+ }
101
+ return false;
102
+ }
103
+ function getParserServices(context) {
104
+ const legacy = context.sourceCode;
105
+ return legacy?.parserServices || context.parserServices;
106
+ }
107
+ function isVueParser(context) {
108
+ const parserServices = getParserServices(context);
109
+ return !!parserServices?.defineTemplateBodyVisitor;
110
+ }
111
+ function defineTemplateBodyVisitor(context, templateVisitor, scriptVisitor) {
112
+ const parserServices = getParserServices(context);
113
+ if (!parserServices?.defineTemplateBodyVisitor) {
114
+ return {};
115
+ }
116
+ return parserServices.defineTemplateBodyVisitor(
117
+ templateVisitor,
118
+ scriptVisitor
119
+ );
120
+ }
121
+ function isReactivityCall(node, vueImports) {
122
+ if (node.callee.type === "Identifier") {
123
+ return VUE_REACTIVITY_APIS.has(node.callee.name) && vueImports.has(node.callee.name);
124
+ }
125
+ return false;
126
+ }
127
+ function isComposableCall(node) {
128
+ if (node.callee.type === "Identifier") {
129
+ return /^use[A-Z_]/.test(node.callee.name);
130
+ }
131
+ return false;
132
+ }
133
+ function isComposableName(name) {
134
+ return /^use[A-Z_]/.test(name);
135
+ }
136
+
137
+ const RULE_NAME$4 = "vue-no-faux-composables";
138
+ const vueNoFauxComposables = createEslintRule({
139
+ name: RULE_NAME$4,
140
+ meta: {
141
+ type: "problem",
142
+ docs: {
143
+ description: "enforce that composables must use Vue reactivity APIs"
144
+ },
145
+ schema: [],
146
+ messages: {
147
+ mustUseReactivity: "Functions starting with use must implement reactivity APIs (use*, ref, reactive, computed, watch, etc.)"
148
+ }
149
+ },
150
+ defaultOptions: [],
151
+ create: (context) => {
152
+ const vueImports = /* @__PURE__ */ new Set();
153
+ const composableFunctions = /* @__PURE__ */ new Map();
154
+ function hasReactivityInStatement(stmt) {
155
+ if (!stmt)
156
+ return false;
157
+ switch (stmt.type) {
158
+ case "ExpressionStatement":
159
+ return hasReactivityInExpression(stmt.expression);
160
+ case "VariableDeclaration":
161
+ return stmt.declarations.some((decl) => hasReactivityInExpression(decl.init));
162
+ case "ReturnStatement":
163
+ return hasReactivityInExpression(stmt.argument);
164
+ case "BlockStatement":
165
+ return stmt.body.some((s) => hasReactivityInStatement(s));
166
+ case "IfStatement":
167
+ return hasReactivityInStatement(stmt.consequent) || (stmt.alternate ? hasReactivityInStatement(stmt.alternate) : false);
168
+ case "WhileStatement":
169
+ case "DoWhileStatement":
170
+ return hasReactivityInStatement(stmt.body);
171
+ case "ForStatement":
172
+ case "ForInStatement":
173
+ case "ForOfStatement":
174
+ return hasReactivityInStatement(stmt.body);
175
+ case "TryStatement":
176
+ return hasReactivityInStatement(stmt.block) || (stmt.handler ? hasReactivityInStatement(stmt.handler.body) : false) || (stmt.finalizer ? hasReactivityInStatement(stmt.finalizer) : false);
177
+ case "SwitchStatement":
178
+ return stmt.cases.some((switchCase) => switchCase.consequent.some((s) => hasReactivityInStatement(s)));
179
+ default:
180
+ return false;
181
+ }
182
+ }
183
+ function hasReactivityInExpression(expr) {
184
+ if (!expr)
185
+ return false;
186
+ switch (expr.type) {
187
+ case "CallExpression":
188
+ if (isReactivityCall(expr, vueImports) || isComposableCall(expr))
189
+ return true;
190
+ return false;
191
+ case "ObjectExpression":
192
+ return expr.properties.some((prop) => prop.type === "Property" && hasReactivityInExpression(prop.value));
193
+ case "ArrayExpression":
194
+ return expr.elements.some((elem) => hasReactivityInExpression(elem));
195
+ default:
196
+ return false;
197
+ }
198
+ }
199
+ function checkFunctionForReactivity(functionNode, functionName) {
200
+ if (!functionNode.body || functionNode.body.type !== "BlockStatement")
201
+ return;
202
+ const hasReactivity = functionNode.body.body.some((stmt) => hasReactivityInStatement(stmt));
203
+ if (!hasReactivity) {
204
+ context.report({
205
+ node: functionNode,
206
+ messageId: "mustUseReactivity",
207
+ data: { name: functionName }
208
+ });
209
+ }
210
+ }
211
+ return {
212
+ Program() {
213
+ vueImports.clear();
214
+ composableFunctions.clear();
215
+ },
216
+ ImportDeclaration(node) {
217
+ if (node.source.value === "vue") {
218
+ node.specifiers.forEach((spec) => {
219
+ if (spec.type === "ImportSpecifier") {
220
+ const imported = spec.imported;
221
+ if (imported.type === "Identifier") {
222
+ vueImports.add(imported.name);
223
+ }
224
+ }
225
+ });
226
+ }
227
+ },
228
+ "Program:exit": function() {
229
+ for (const [name, functionNode] of composableFunctions)
230
+ checkFunctionForReactivity(functionNode, name);
231
+ },
232
+ FunctionDeclaration(node) {
233
+ if (node.id && isComposableName(node.id.name))
234
+ composableFunctions.set(node.id.name, node);
235
+ },
236
+ VariableDeclarator(node) {
237
+ if (node.id.type === "Identifier" && isComposableName(node.id.name) && (node.init?.type === "FunctionExpression" || node.init?.type === "ArrowFunctionExpression")) {
238
+ composableFunctions.set(node.id.name, node.init);
239
+ }
240
+ },
241
+ ExportNamedDeclaration(node) {
242
+ if (node.declaration?.type === "FunctionDeclaration" && node.declaration.id && isComposableName(node.declaration.id.name)) {
243
+ composableFunctions.set(node.declaration.id.name, node.declaration);
244
+ }
245
+ }
246
+ };
247
+ }
248
+ });
249
+
250
+ const RULE_NAME$3 = "vue-no-nested-reactivity";
251
+ const vueNoNestedReactivity = createEslintRule({
252
+ name: RULE_NAME$3,
253
+ meta: {
254
+ type: "problem",
255
+ docs: {
256
+ description: "disallow nested reactivity patterns like reactive({ foo: ref() }) or ref({ foo: reactive() })"
257
+ },
258
+ fixable: void 0,
259
+ schema: [],
260
+ messages: {
261
+ noNestedInRef: "Avoid nesting reactivity primitives inside ref().",
262
+ noNestedInReactive: "Avoid nesting reactivity primitives inside reactive().",
263
+ noNestedInShallowRef: "Avoid nesting reactivity primitives inside shallowRef().",
264
+ noNestedInShallowReactive: "Avoid nesting reactivity primitives inside shallowReactive().",
265
+ noNestedInComputed: "Avoid nesting reactivity primitives inside computed().",
266
+ noNestedInWatch: "Avoid nesting reactivity primitives inside watch().",
267
+ noNestedInWatchEffect: "Avoid nesting reactivity primitives inside watchEffect()."
268
+ }
269
+ },
270
+ defaultOptions: [],
271
+ create: (context) => {
272
+ const reactiveAPIs = /* @__PURE__ */ new Set(["ref", "reactive", "shallowRef", "shallowReactive", "computed", "watch", "watchEffect"]);
273
+ const vueImports = /* @__PURE__ */ new Set();
274
+ const reactiveVariables = /* @__PURE__ */ new Map();
275
+ function getMessageId(outerType) {
276
+ switch (outerType) {
277
+ case "ref":
278
+ return "noNestedInRef";
279
+ case "reactive":
280
+ return "noNestedInReactive";
281
+ case "shallowRef":
282
+ return "noNestedInShallowRef";
283
+ case "shallowReactive":
284
+ return "noNestedInShallowReactive";
285
+ case "computed":
286
+ return "noNestedInComputed";
287
+ case "watch":
288
+ return "noNestedInWatch";
289
+ case "watchEffect":
290
+ return "noNestedInWatchEffect";
291
+ default:
292
+ return "noNestedInReactive";
293
+ }
294
+ }
295
+ function isReactiveCall(node) {
296
+ if (node.type === "CallExpression" && node.callee.type === "Identifier") {
297
+ const name = node.callee.name;
298
+ if (reactiveAPIs.has(name) && vueImports.has(name)) {
299
+ return name;
300
+ }
301
+ }
302
+ return null;
303
+ }
304
+ function checkObjectExpressionForReactivity(obj, outerType) {
305
+ for (const prop of obj.properties) {
306
+ if (prop.type === "Property") {
307
+ if (prop.value.type === "CallExpression") {
308
+ const innerType = isReactiveCall(prop.value);
309
+ if (innerType && innerType !== outerType) {
310
+ context.report({
311
+ node: prop.value,
312
+ messageId: getMessageId(outerType)
313
+ });
314
+ }
315
+ } else if (prop.value.type === "ObjectExpression") {
316
+ checkObjectExpressionForReactivity(prop.value, outerType);
317
+ } else if (prop.value.type === "Identifier") {
318
+ const varType = reactiveVariables.get(prop.value.name);
319
+ if (varType && varType !== outerType) {
320
+ context.report({
321
+ node: prop.value,
322
+ messageId: getMessageId(outerType)
323
+ });
324
+ }
325
+ } else if (prop.shorthand && prop.key.type === "Identifier") {
326
+ const varType = reactiveVariables.get(prop.key.name);
327
+ if (varType && varType !== outerType) {
328
+ context.report({
329
+ node: prop.key,
330
+ messageId: getMessageId(outerType)
331
+ });
332
+ }
333
+ }
334
+ }
335
+ }
336
+ }
337
+ function checkComputedCallback(node) {
338
+ if (node.callee.type === "Identifier" && node.callee.name === "computed" && vueImports.has("computed")) {
339
+ if (node.arguments.length > 0) {
340
+ const callback = node.arguments[0];
341
+ if (callback.type === "ArrowFunctionExpression" || callback.type === "FunctionExpression") {
342
+ if (callback.body.type === "BlockStatement") {
343
+ for (const stmt of callback.body.body) {
344
+ if (stmt.type === "ReturnStatement" && stmt.argument) {
345
+ checkReturnForReactivity(stmt.argument);
346
+ }
347
+ }
348
+ } else {
349
+ checkReturnForReactivity(callback.body);
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }
355
+ function checkReturnForReactivity(node) {
356
+ if (node.type === "CallExpression") {
357
+ const reactiveType = isReactiveCall(node);
358
+ if (reactiveType) {
359
+ context.report({
360
+ node,
361
+ messageId: "noNestedInComputed"
362
+ });
363
+ }
364
+ } else if (node.type === "Identifier") {
365
+ const varType = reactiveVariables.get(node.name);
366
+ if (varType) {
367
+ context.report({
368
+ node,
369
+ messageId: "noNestedInComputed"
370
+ });
371
+ }
372
+ } else if (node.type === "ObjectExpression") {
373
+ for (const prop of node.properties) {
374
+ if (prop.type === "Property") {
375
+ if (prop.value.type === "CallExpression") {
376
+ const reactiveType = isReactiveCall(prop.value);
377
+ if (reactiveType) {
378
+ context.report({
379
+ node: prop.value,
380
+ messageId: "noNestedInComputed"
381
+ });
382
+ }
383
+ } else if (prop.value.type === "Identifier") {
384
+ const varType = reactiveVariables.get(prop.value.name);
385
+ if (varType) {
386
+ context.report({
387
+ node: prop.value,
388
+ messageId: "noNestedInComputed"
389
+ });
390
+ }
391
+ }
392
+ }
393
+ }
394
+ }
395
+ }
396
+ function checkForNestedReactivity(node, outerType) {
397
+ if (!node.arguments.length)
398
+ return;
399
+ const arg = node.arguments[0];
400
+ if (arg.type === "ObjectExpression") {
401
+ checkObjectExpressionForReactivity(arg, outerType);
402
+ } else if (arg.type === "Identifier") {
403
+ const varType = reactiveVariables.get(arg.name);
404
+ if (varType && varType !== outerType) {
405
+ context.report({
406
+ node: arg,
407
+ messageId: getMessageId(outerType)
408
+ });
409
+ }
410
+ }
411
+ }
412
+ return {
413
+ Program() {
414
+ vueImports.clear();
415
+ reactiveVariables.clear();
416
+ },
417
+ ImportDeclaration(node) {
418
+ if (node.source.value === "vue") {
419
+ node.specifiers.forEach((spec) => {
420
+ if (spec.type === "ImportSpecifier") {
421
+ const imported = spec.imported;
422
+ if (imported.type === "Identifier") {
423
+ vueImports.add(imported.name);
424
+ }
425
+ }
426
+ });
427
+ }
428
+ },
429
+ CallExpression(node) {
430
+ const reactiveType = isReactiveCall(node);
431
+ if (reactiveType) {
432
+ checkForNestedReactivity(node, reactiveType);
433
+ }
434
+ checkComputedCallback(node);
435
+ },
436
+ VariableDeclarator(node) {
437
+ if (node.id.type === "Identifier" && node.init?.type === "CallExpression") {
438
+ const reactiveType = isReactiveCall(node.init);
439
+ if (reactiveType) {
440
+ reactiveVariables.set(node.id.name, reactiveType);
441
+ }
442
+ }
443
+ }
444
+ };
445
+ }
446
+ });
447
+
448
+ const RULE_NAME$2 = "vue-no-passing-refs-as-props";
449
+ const vueNoPassingRefsAsProps = createEslintRule({
450
+ name: RULE_NAME$2,
451
+ meta: {
452
+ type: "problem",
453
+ docs: {
454
+ description: "disallow passing refs as props to Vue components"
455
+ },
456
+ schema: [],
457
+ messages: {
458
+ noPassingRefsAsProps: "Avoid passing refs as props. Pass the unwrapped value using ref.value or use reactive() instead."
459
+ }
460
+ },
461
+ defaultOptions: [],
462
+ create: (context) => {
463
+ const refProperties = /* @__PURE__ */ new Map();
464
+ function isRefProperty(objectName, propertyName) {
465
+ const properties = refProperties.get(objectName);
466
+ return properties?.has(propertyName) ?? false;
467
+ }
468
+ return {
469
+ Program() {
470
+ refProperties.clear();
471
+ },
472
+ // Track object properties assigned from ref() calls
473
+ VariableDeclarator(node) {
474
+ if (node.id.type === "Identifier" && node.init?.type === "ObjectExpression") {
475
+ const objectName = node.id.name;
476
+ const objectProperties = /* @__PURE__ */ new Set();
477
+ for (const property of node.init.properties) {
478
+ if (property.type === "Property" && property.key.type === "Identifier" && property.value.type === "CallExpression" && isRefCall(property.value)) {
479
+ objectProperties.add(property.key.name);
480
+ }
481
+ }
482
+ if (objectProperties.size > 0) {
483
+ refProperties.set(objectName, objectProperties);
484
+ }
485
+ }
486
+ },
487
+ // Check for ref property access in template expressions
488
+ MemberExpression(node) {
489
+ if (isInVueTemplateString(node) && node.object.type === "Identifier" && node.property.type === "Identifier" && isRefProperty(node.object.name, node.property.name)) {
490
+ const parent = node.parent;
491
+ if (parent?.type === "MemberExpression" && parent.property.type === "Identifier" && parent.property.name === "value") {
492
+ return;
493
+ }
494
+ context.report({
495
+ node,
496
+ messageId: "noPassingRefsAsProps"
497
+ });
498
+ }
499
+ }
500
+ };
501
+ }
502
+ });
503
+
504
+ const RULE_NAME$1 = "vue-no-ref-access-in-templates";
505
+ const vueNoRefAccessInTemplates = createEslintRule({
506
+ name: RULE_NAME$1,
507
+ meta: {
508
+ type: "suggestion",
509
+ docs: {
510
+ description: "disallow accessing ref.value in Vue templates"
511
+ },
512
+ fixable: void 0,
513
+ schema: [],
514
+ messages: {
515
+ noRefAccessInTemplate: "Avoid unpacking refs in templates for cleaner separation of reactivity."
516
+ }
517
+ },
518
+ defaultOptions: [],
519
+ create: (context) => {
520
+ const refVariables = /* @__PURE__ */ new Set();
521
+ const objectRefs = /* @__PURE__ */ new Map();
522
+ function isRefAccess(node) {
523
+ if (node.object.type === "Identifier" && refVariables.has(node.object.name) && node.property.type === "Identifier" && node.property.name === "value") {
524
+ return true;
525
+ }
526
+ if (node.object.type === "MemberExpression" && node.object.object.type === "Identifier" && node.object.property.type === "Identifier" && node.property.type === "Identifier" && node.property.name === "value") {
527
+ const objectName = node.object.object.name;
528
+ const propertyName = node.object.property.name;
529
+ const objectRefProperties = objectRefs.get(objectName);
530
+ return objectRefProperties?.has(propertyName) || false;
531
+ }
532
+ return false;
533
+ }
534
+ if (isVueParser(context)) {
535
+ const scriptVisitor = {
536
+ Program() {
537
+ refVariables.clear();
538
+ objectRefs.clear();
539
+ },
540
+ VariableDeclarator(node) {
541
+ if (node.init?.type === "CallExpression" && isRefCall(node.init)) {
542
+ if (node.id.type === "Identifier") {
543
+ refVariables.add(node.id.name);
544
+ }
545
+ }
546
+ if (node.init?.type === "ObjectExpression" && node.id.type === "Identifier") {
547
+ const objectName = node.id.name;
548
+ const refProperties = /* @__PURE__ */ new Set();
549
+ for (const property of node.init.properties) {
550
+ if (property.type === "Property" && property.key.type === "Identifier" && property.value.type === "CallExpression" && isRefCall(property.value)) {
551
+ refProperties.add(property.key.name);
552
+ }
553
+ }
554
+ if (refProperties.size > 0) {
555
+ objectRefs.set(objectName, refProperties);
556
+ }
557
+ }
558
+ },
559
+ MemberExpression(node) {
560
+ if (isInVueTemplateString(node) && isRefAccess(node)) {
561
+ context.report({
562
+ node,
563
+ messageId: "noRefAccessInTemplate"
564
+ });
565
+ }
566
+ }
567
+ };
568
+ const templateVisitor = {
569
+ MemberExpression(node) {
570
+ if (isRefAccess(node)) {
571
+ context.report({
572
+ node,
573
+ messageId: "noRefAccessInTemplate"
574
+ });
575
+ }
576
+ }
577
+ };
578
+ return defineTemplateBodyVisitor(context, templateVisitor, scriptVisitor);
579
+ }
580
+ return {
581
+ Program() {
582
+ refVariables.clear();
583
+ objectRefs.clear();
584
+ },
585
+ VariableDeclarator(node) {
586
+ if (node.init?.type === "CallExpression" && isRefCall(node.init)) {
587
+ if (node.id.type === "Identifier") {
588
+ refVariables.add(node.id.name);
589
+ }
590
+ }
591
+ if (node.init?.type === "ObjectExpression" && node.id.type === "Identifier") {
592
+ const objectName = node.id.name;
593
+ const refProperties = /* @__PURE__ */ new Set();
594
+ for (const property of node.init.properties) {
595
+ if (property.type === "Property" && property.key.type === "Identifier" && property.value.type === "CallExpression" && isRefCall(property.value)) {
596
+ refProperties.add(property.key.name);
597
+ }
598
+ }
599
+ if (refProperties.size > 0) {
600
+ objectRefs.set(objectName, refProperties);
601
+ }
602
+ }
603
+ },
604
+ MemberExpression(node) {
605
+ if (isInVueTemplateString(node) && isRefAccess(node)) {
606
+ context.report({
607
+ node,
608
+ messageId: "noRefAccessInTemplate"
609
+ });
610
+ }
611
+ }
612
+ };
613
+ }
614
+ });
615
+
616
+ const RULE_NAME = "vue-no-torefs-on-props";
617
+ const vueNoTorefsOnProps = createEslintRule({
618
+ name: RULE_NAME,
619
+ meta: {
620
+ type: "suggestion",
621
+ docs: {
622
+ description: "disallow using toRefs on props object in Vue"
623
+ },
624
+ schema: [],
625
+ messages: {
626
+ noToRefsOnProps: "Using toRefs() on all props can be an antipattern as props are already reactive. Destruct props directory and wrap as refs individually if needed."
627
+ }
628
+ },
629
+ defaultOptions: [],
630
+ create: (context) => {
631
+ const propsVariables = /* @__PURE__ */ new Set();
632
+ function isPropsRelated(node) {
633
+ return propsVariables.has(node.name);
634
+ }
635
+ function checkToRefsCall(node) {
636
+ if (node.callee.type === "Identifier" && node.callee.name === "toRefs") {
637
+ if (node.arguments.length > 0) {
638
+ const firstArg = node.arguments[0];
639
+ if (firstArg.type === "Identifier" && isPropsRelated(firstArg)) {
640
+ context.report({
641
+ node,
642
+ messageId: "noToRefsOnProps"
643
+ });
644
+ }
645
+ }
646
+ }
647
+ }
648
+ return {
649
+ Program() {
650
+ propsVariables.clear();
651
+ },
652
+ // Track variables assigned from defineProps()
653
+ VariableDeclarator(node) {
654
+ if (node.init?.type === "CallExpression") {
655
+ const callExpr = node.init;
656
+ if (callExpr.callee.type === "Identifier" && callExpr.callee.name === "defineProps") {
657
+ if (node.id.type === "Identifier") {
658
+ propsVariables.add(node.id.name);
659
+ }
660
+ }
661
+ }
662
+ },
663
+ // Check for toRefs calls
664
+ CallExpression(node) {
665
+ checkToRefsCall(node);
666
+ }
667
+ };
668
+ }
669
+ });
670
+
671
+ const plugin = {
672
+ meta: {
673
+ name: "harlanzw",
674
+ version
675
+ },
676
+ // @keep-sorted
677
+ rules: {
678
+ "vue-no-faux-composables": vueNoFauxComposables,
679
+ "vue-no-nested-reactivity": vueNoNestedReactivity,
680
+ "vue-no-passing-refs-as-props": vueNoPassingRefsAsProps,
681
+ "vue-no-ref-access-in-templates": vueNoRefAccessInTemplates,
682
+ "vue-no-torefs-on-props": vueNoTorefsOnProps
683
+ }
684
+ };
685
+
686
+ export { plugin as default };
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "eslint-plugin-harlanzw",
3
+ "type": "module",
4
+ "version": "0.0.1",
5
+ "description": "Harlan's opinionated ESLint rules",
6
+ "author": "Harlan Wilton <harlan@harlanzw.com>",
7
+ "license": "MIT",
8
+ "funding": "https://github.com/sponsors/harlan-zw",
9
+ "homepage": "https://github.com/harlan-zw/eslint-plugin-harlanzw#readme",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/harlan-zw/eslint-plugin-harlanzw.git"
13
+ },
14
+ "bugs": "https://github.com/harlan-zw/eslint-plugin-harlanzw/issues",
15
+ "keywords": [
16
+ "eslint-plugin"
17
+ ],
18
+ "sideEffects": false,
19
+ "exports": {
20
+ ".": "./dist/index.mjs"
21
+ },
22
+ "main": "./dist/index.mjs",
23
+ "module": "./dist/index.mjs",
24
+ "types": "./dist/index.d.mts",
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "peerDependencies": {
29
+ "eslint": "*"
30
+ },
31
+ "devDependencies": {
32
+ "@antfu/eslint-config": "^5.2.1",
33
+ "@antfu/ni": "^25.0.0",
34
+ "@antfu/utils": "^9.2.0",
35
+ "@types/eslint": "^9.6.1",
36
+ "@types/node": "^24.3.0",
37
+ "@typescript-eslint/typescript-estree": "^8.39.1",
38
+ "@typescript-eslint/utils": "^8.39.1",
39
+ "bumpp": "^10.2.3",
40
+ "eslint": "^9.33.0",
41
+ "eslint-vitest-rule-tester": "^2.2.1",
42
+ "jsonc-eslint-parser": "^2.4.0",
43
+ "tsup": "^8.5.0",
44
+ "tsx": "^4.20.4",
45
+ "typescript": "^5.9.2",
46
+ "unbuild": "^3.6.1",
47
+ "vite": "^7.1.2",
48
+ "vitest": "^3.2.4",
49
+ "vue": "^3.5.18"
50
+ },
51
+ "resolutions": {
52
+ "eslint-plugin-harlanzw": "workspace:*"
53
+ },
54
+ "scripts": {
55
+ "build": "unbuild",
56
+ "dev": "unbuild --stub",
57
+ "lint": "pnpm run dev && eslint . --fix",
58
+ "release": "pnpm build && bumpp && pnpm -r publish",
59
+ "test": "vitest",
60
+ "typecheck": "tsc --noEmit"
61
+ }
62
+ }