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 +9 -0
- package/README.md +116 -0
- package/dist/index.d.mts +30 -0
- package/dist/index.mjs +686 -0
- package/package.json +62 -0
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
|
package/dist/index.d.mts
ADDED
|
@@ -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
|
+
}
|