oxlint-plugin-vue-sfc 0.1.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.
@@ -0,0 +1,80 @@
1
+ import {
2
+ NODE_ELEMENT,
3
+ findDirective,
4
+ findKeyProp,
5
+ parseSfc,
6
+ reportAtFileOffset,
7
+ walkTemplate,
8
+ } from "../utils/vue-sfc.mjs";
9
+
10
+ /**
11
+ * Replacement for `vue/require-v-for-key` (no native oxlint equivalent — oxlint's 46
12
+ * native vue rules are all script-side; every template-AST rule still lives in the
13
+ * ESLint fallback, see packages/eslint-config/libs/vue.mjs and oxc#15761).
14
+ *
15
+ * A `v-for` without a `key` makes Vue reuse DOM nodes by index, so component state
16
+ * (focus, input values, transition state) leaks across list items when the list is
17
+ * reordered or filtered. It is a correctness bug, not a style preference, which is
18
+ * why this ships as `error`.
19
+ *
20
+ * Vue 3 semantics, and the false-positive guards that matter here:
21
+ * - The key belongs on the element carrying `v-for`, INCLUDING `<template v-for>`.
22
+ * - For `<template v-for>` we also accept a key on every direct element child. That
23
+ * is the Vue 2 idiom and still works in Vue 3, so flagging it would report working
24
+ * code — and one false positive on a 1500-SFC codebase costs more trust than the
25
+ * marginal rule strictness buys.
26
+ * - `findKeyProp` accepts both the static `key` attribute and `:key` / `v-bind:key`.
27
+ * - `<template v-for>` with no element children (text only) cannot carry a keyed
28
+ * child, so it is judged on its own key alone.
29
+ */
30
+
31
+ export default {
32
+ meta: {
33
+ type: "problem",
34
+ docs: {
35
+ description: "require `v-bind:key` with `v-for` directives",
36
+ recommended: true,
37
+ },
38
+ schema: [],
39
+ messages: { m: "" },
40
+ },
41
+ create(context) {
42
+ if (!context.filename.endsWith(".vue")) {
43
+ return {};
44
+ }
45
+ return {
46
+ Program() {
47
+ const entry = parseSfc(context.filename);
48
+ const ast = entry.descriptor.template?.ast;
49
+ if (!ast) {
50
+ return;
51
+ }
52
+ walkTemplate(ast, (node) => {
53
+ if (node.type !== NODE_ELEMENT || !findDirective(node, "for")) {
54
+ return;
55
+ }
56
+ if (findKeyProp(node)) {
57
+ return;
58
+ }
59
+ // `<template v-for>`: a key on every direct element child is the Vue 2
60
+ // idiom and still correct in Vue 3, so treat it as satisfied.
61
+ if (node.tag === "template") {
62
+ const elementChildren = (node.children ?? []).filter(
63
+ (child) => child.type === NODE_ELEMENT,
64
+ );
65
+ if (elementChildren.length && elementChildren.every(findKeyProp)) {
66
+ return;
67
+ }
68
+ }
69
+ reportAtFileOffset(
70
+ context,
71
+ entry,
72
+ node.loc.start.offset,
73
+ node.loc.end.offset,
74
+ `<${node.tag}> uses v-for without a :key. Vue reuses DOM nodes by index without one, so item state leaks when the list is reordered or filtered.`,
75
+ );
76
+ });
77
+ },
78
+ };
79
+ },
80
+ };
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Replacement for `vue/require-valid-default-prop` (no native oxlint equivalent — oxc#15761).
3
+ *
4
+ * Two distinct bugs, both silent:
5
+ * 1. `{ type: Array, default: [] }` shares ONE array across every instance of the component.
6
+ * Push to it in one place and it appears everywhere. Vue requires a factory (`default: () => []`)
7
+ * precisely so each instance gets its own.
8
+ * 2. `{ type: String, default: 1 }` declares one type and defaults to another. Nothing complains
9
+ * at runtime; the component just receives a number when nothing was passed.
10
+ *
11
+ * A SCRIPT rule — oxlint's own `Program(node)` AST, correct positions, no dependency on oxc#20501.
12
+ *
13
+ * Semantics confirmed against eslint-plugin-vue:
14
+ * - Array/Object defaults must be a factory function; `null` and `undefined` are accepted.
15
+ * - Primitives may ALSO use a factory (`{ type: String, default: () => "s" }` is accepted).
16
+ * - When the default IS a factory, its RETURNED expression is type-checked instead
17
+ * (`{ type: Array, default: () => ({}) }` is reported).
18
+ * - A union `type: [String, Number]` is satisfied by a default matching ANY member.
19
+ * - A prop with no `type`, or no `default`, is never reported.
20
+ * - The report anchors on the default VALUE expression (or on the factory's returned expression).
21
+ *
22
+ * Deliberately conservative: any expression whose type cannot be determined statically — an
23
+ * identifier, a call, a conditional — is treated as UNKNOWN and never reported. A prop default
24
+ * pulled from a constant is extremely common and reporting it would be wrong, so this trades
25
+ * completeness for zero false positives.
26
+ */
27
+
28
+ const NATIVE_TYPES = new Set([
29
+ "String",
30
+ "Number",
31
+ "Boolean",
32
+ "Array",
33
+ "Object",
34
+ "Function",
35
+ "Symbol",
36
+ "BigInt",
37
+ "Date",
38
+ ]);
39
+ /** Types whose default MUST be produced by a factory, because the value would be shared. */
40
+ const REFERENCE_TYPES = new Set(["Array", "Object"]);
41
+
42
+ /** Declared prop types as a Set of native type names, or null when not statically known. */
43
+ function declaredTypes(typeNode) {
44
+ if (!typeNode) {
45
+ return null;
46
+ }
47
+ if (typeNode.type === "Identifier" && NATIVE_TYPES.has(typeNode.name)) {
48
+ return new Set([typeNode.name]);
49
+ }
50
+ if (typeNode.type === "ArrayExpression") {
51
+ const names = new Set();
52
+ for (const element of typeNode.elements ?? []) {
53
+ if (element?.type === "Identifier" && NATIVE_TYPES.has(element.name)) {
54
+ names.add(element.name);
55
+ } else {
56
+ return null; // an unrecognised member makes the whole union unknown
57
+ }
58
+ }
59
+ return names.size ? names : null;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /**
65
+ * Strip `ParenthesizedExpression` wrappers. oxc-parser preserves them, so `() => ({})` has a
66
+ * parenthesized body — and upstream anchors its report on the INNER expression, not the paren.
67
+ */
68
+ function unwrap(node) {
69
+ let current = node;
70
+ while (current?.type === "ParenthesizedExpression") {
71
+ current = current.expression;
72
+ }
73
+ return current;
74
+ }
75
+
76
+ /** The native type a value expression evaluates to, `"null"`, or null when unknown. */
77
+ function inferType(input) {
78
+ const node = unwrap(input);
79
+ if (!node) {
80
+ return null;
81
+ }
82
+ switch (node.type) {
83
+ case "ArrayExpression":
84
+ return "Array";
85
+ case "ObjectExpression":
86
+ return "Object";
87
+ case "TemplateLiteral":
88
+ return "String";
89
+ case "ArrowFunctionExpression":
90
+ case "FunctionExpression":
91
+ return "Function";
92
+ case "Literal": {
93
+ if (node.value === null) {
94
+ return "null";
95
+ }
96
+ switch (typeof node.value) {
97
+ case "string":
98
+ return "String";
99
+ case "number":
100
+ return "Number";
101
+ case "boolean":
102
+ return "Boolean";
103
+ default:
104
+ return null;
105
+ }
106
+ }
107
+ case "UnaryExpression":
108
+ // `-1` is a number; `!x` a boolean. Only handle the unambiguous numeric case.
109
+ return node.operator === "-" || node.operator === "+"
110
+ ? inferType(node.argument)
111
+ : null;
112
+ case "Identifier":
113
+ return node.name === "undefined" ? "null" : null;
114
+ default:
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /** The expression a concise/blocked arrow or function returns, or null when not determinable. */
120
+ function factoryReturn(input) {
121
+ const node = unwrap(input);
122
+ if (node.type === "ArrowFunctionExpression" && node.body?.type !== "BlockStatement") {
123
+ return unwrap(node.body);
124
+ }
125
+ const body = node.body?.body;
126
+ if (!Array.isArray(body) || body.length !== 1 || body[0].type !== "ReturnStatement") {
127
+ return null;
128
+ }
129
+ return body[0].argument ? unwrap(body[0].argument) : null;
130
+ }
131
+
132
+ /** Every `defineProps({ … })` / `props: { … }` object in the program. */
133
+ function findPropsObjects(program) {
134
+ const objects = [];
135
+ const walk = (node) => {
136
+ if (!node || typeof node !== "object") {
137
+ return;
138
+ }
139
+ if (Array.isArray(node)) {
140
+ for (const item of node) {
141
+ walk(item);
142
+ }
143
+ return;
144
+ }
145
+ if (typeof node.type !== "string") {
146
+ return;
147
+ }
148
+ if (
149
+ node.type === "CallExpression" &&
150
+ node.callee?.type === "Identifier" &&
151
+ node.callee.name === "defineProps" &&
152
+ node.arguments?.[0]?.type === "ObjectExpression"
153
+ ) {
154
+ objects.push(node.arguments[0]);
155
+ }
156
+ if (
157
+ node.type === "Property" &&
158
+ (node.key?.name ?? node.key?.value) === "props" &&
159
+ node.value?.type === "ObjectExpression"
160
+ ) {
161
+ objects.push(node.value);
162
+ }
163
+ for (const key of Object.keys(node)) {
164
+ if (key === "parent" || key === "loc" || key === "range") {
165
+ continue;
166
+ }
167
+ walk(node[key]);
168
+ }
169
+ };
170
+ walk(program);
171
+ return objects;
172
+ }
173
+
174
+ const ARTICLE_NAMES = {
175
+ String: "a string",
176
+ Number: "a number",
177
+ Boolean: "a boolean",
178
+ Array: "an array",
179
+ Object: "an object",
180
+ Function: "a function",
181
+ };
182
+
183
+ export default {
184
+ meta: {
185
+ type: "problem",
186
+ docs: {
187
+ description: "enforce props default values to be valid",
188
+ recommended: true,
189
+ },
190
+ schema: [],
191
+ messages: {
192
+ mustBeFactory:
193
+ "The default for the `{{name}}` prop is shared across every instance of this component. Use a factory: `default: () => …`.",
194
+ typeMismatch:
195
+ "The default for the `{{name}}` prop should be {{expected}} to match its declared type.",
196
+ },
197
+ },
198
+ create(context) {
199
+ return {
200
+ Program(program) {
201
+ for (const propsObject of findPropsObjects(program)) {
202
+ for (const prop of propsObject.properties ?? []) {
203
+ const name = prop.key?.name ?? prop.key?.value;
204
+ if (typeof name !== "string" || prop.value?.type !== "ObjectExpression") {
205
+ continue;
206
+ }
207
+ let typeNode = null;
208
+ let defaultNode = null;
209
+ for (const entry of prop.value.properties ?? []) {
210
+ const key = entry.key?.name ?? entry.key?.value;
211
+ if (key === "type") {
212
+ typeNode = entry.value;
213
+ } else if (key === "default") {
214
+ defaultNode = entry.value;
215
+ }
216
+ }
217
+ const expected = declaredTypes(typeNode);
218
+ if (!expected || !defaultNode) {
219
+ continue;
220
+ }
221
+
222
+ defaultNode = unwrap(defaultNode);
223
+ const defaultType = inferType(defaultNode);
224
+ // `null` / `undefined` defaults are always accepted.
225
+ if (defaultType === "null") {
226
+ continue;
227
+ }
228
+
229
+ // A factory: check what it RETURNS instead, unless Function is declared.
230
+ if (defaultType === "Function" && !expected.has("Function")) {
231
+ const returned = factoryReturn(defaultNode);
232
+ const returnedType = inferType(returned);
233
+ if (!returned || !returnedType || returnedType === "null") {
234
+ continue; // not statically determinable — stay quiet
235
+ }
236
+ if (!expected.has(returnedType)) {
237
+ context.report({
238
+ node: returned,
239
+ messageId: "typeMismatch",
240
+ data: {
241
+ name,
242
+ expected: [...expected]
243
+ .map((t) => ARTICLE_NAMES[t] ?? t)
244
+ .join(" or "),
245
+ },
246
+ });
247
+ }
248
+ continue;
249
+ }
250
+
251
+ // A bare reference-type default is shared between instances.
252
+ const needsFactory = [...expected].some((t) => REFERENCE_TYPES.has(t));
253
+ if (needsFactory && defaultType && REFERENCE_TYPES.has(defaultType)) {
254
+ context.report({
255
+ node: defaultNode,
256
+ messageId: "mustBeFactory",
257
+ data: { name },
258
+ });
259
+ continue;
260
+ }
261
+
262
+ if (!defaultType) {
263
+ continue; // unknown — never report
264
+ }
265
+ if (!expected.has(defaultType)) {
266
+ context.report({
267
+ node: defaultNode,
268
+ messageId: "typeMismatch",
269
+ data: {
270
+ name,
271
+ expected: [...expected]
272
+ .map((t) => ARTICLE_NAMES[t] ?? t)
273
+ .join(" or "),
274
+ },
275
+ });
276
+ }
277
+ }
278
+ }
279
+ },
280
+ };
281
+ },
282
+ };
@@ -0,0 +1,141 @@
1
+ import { eachTemplateExpression, parseSfc, reportAtFileOffset } from "../utils/vue-sfc.mjs";
2
+
3
+ /**
4
+ * Replacement for `vue/this-in-template` (no native oxlint equivalent — oxc#15761).
5
+ *
6
+ * Template expressions are already evaluated against the component instance, so `{{ this.x }}`
7
+ * is at best redundant. In `<script setup>` it is worse than redundant: there is no `this`
8
+ * bound the way an Options API component has one, so `this.x` reads from the wrong object (or
9
+ * `undefined`) while looking correct. It is a common artefact of pasting Options API code.
10
+ *
11
+ * Detection strips string literals before testing, which matters more than it sounds: a naive
12
+ * `\bthis\b` scan flags `:title="'use this instead'"` — user-facing copy containing the word
13
+ * "this" is common, and a rule that reports prose gets turned off. Template literals are
14
+ * stripped too, but their `${...}` substitutions are KEPT, since real `this` can hide there.
15
+ *
16
+ * Reduced scope vs eslint-plugin-vue: this is a lexical scan, not an AST walk, so it cannot
17
+ * tell `this.x` from a `this` inside a nested arrow function that legitimately rebinds it.
18
+ * In a template expression that distinction is vanishingly rare, and the failure mode is a
19
+ * false positive on `@click="() => this.x"` — which is itself the pattern the rule targets.
20
+ * Documented rather than silently approximated; see OXLINT_MIGRATION.md.
21
+ */
22
+
23
+ /**
24
+ * Blank out the CONTENTS of string literals AND comments, preserving length so offsets stay
25
+ * valid. Template-literal `${...}` substitutions are preserved (real `this` can live there).
26
+ *
27
+ * Comments are not a hypothetical: this repo has a `v-if` whose multi-line `/* … *\/` comment
28
+ * explains tab behaviour in prose containing the word "this". Scanning it produced the rule's
29
+ * only corpus hit, and it was entirely spurious.
30
+ */
31
+ function stripStringContents(source) {
32
+ const out = [...source];
33
+ let quote = null;
34
+ let depth = 0;
35
+ let comment = null; // "line" | "block" | null
36
+ for (let i = 0; i < out.length; i += 1) {
37
+ const ch = out[i];
38
+ if (comment === "line") {
39
+ out[i] = " ";
40
+ continue;
41
+ }
42
+ if (comment === "block") {
43
+ if (ch === "*" && out[i + 1] === "/") {
44
+ out[i] = " ";
45
+ out[i + 1] = " ";
46
+ i += 1;
47
+ comment = null;
48
+ continue;
49
+ }
50
+ out[i] = " ";
51
+ continue;
52
+ }
53
+ if (quote === null) {
54
+ if (ch === "/" && out[i + 1] === "/") {
55
+ comment = "line";
56
+ out[i] = " ";
57
+ continue;
58
+ }
59
+ if (ch === "/" && out[i + 1] === "*") {
60
+ comment = "block";
61
+ out[i] = " ";
62
+ out[i + 1] = " ";
63
+ i += 1;
64
+ continue;
65
+ }
66
+ if (ch === "'" || ch === '"' || ch === "`") {
67
+ quote = ch;
68
+ }
69
+ continue;
70
+ }
71
+ if (ch === "\\") {
72
+ // Blank the escape pair so `\'` cannot be mistaken for a terminator.
73
+ out[i] = " ";
74
+ if (i + 1 < out.length) {
75
+ out[i + 1] = " ";
76
+ }
77
+ i += 1;
78
+ continue;
79
+ }
80
+ if (quote === "`" && ch === "$" && out[i + 1] === "{") {
81
+ depth += 1;
82
+ i += 1;
83
+ continue;
84
+ }
85
+ if (depth > 0) {
86
+ if (ch === "}") {
87
+ depth -= 1;
88
+ }
89
+ continue; // inside ${...}: keep it intact
90
+ }
91
+ if (ch === quote) {
92
+ quote = null;
93
+ continue;
94
+ }
95
+ out[i] = " ";
96
+ }
97
+ return out.join("");
98
+ }
99
+
100
+ const THIS_RE = /\bthis\b/;
101
+
102
+ export default {
103
+ meta: {
104
+ type: "suggestion",
105
+ docs: {
106
+ description: "disallow usage of `this` in template",
107
+ recommended: false,
108
+ },
109
+ schema: [],
110
+ messages: { m: "" },
111
+ },
112
+ create(context) {
113
+ if (!context.filename.endsWith(".vue")) {
114
+ return {};
115
+ }
116
+ return {
117
+ Program() {
118
+ const entry = parseSfc(context.filename);
119
+ const ast = entry.descriptor.template?.ast;
120
+ if (!ast) {
121
+ return;
122
+ }
123
+ eachTemplateExpression(ast, (expression) => {
124
+ const source = expression.content ?? "";
125
+ const match = THIS_RE.exec(stripStringContents(source));
126
+ if (!match) {
127
+ return;
128
+ }
129
+ const start = expression.loc.start.offset + match.index;
130
+ reportAtFileOffset(
131
+ context,
132
+ entry,
133
+ start,
134
+ start + 4,
135
+ "`this` is unnecessary in a template expression — bindings are already resolved against the component. Under `<script setup>` it does not refer to the setup state at all.",
136
+ );
137
+ });
138
+ },
139
+ };
140
+ },
141
+ };
@@ -0,0 +1,112 @@
1
+ import {
2
+ NODE_ELEMENT,
3
+ PROP_DIRECTIVE,
4
+ parseSfc,
5
+ reportAtFileOffset,
6
+ walkTemplate,
7
+ } from "../utils/vue-sfc.mjs";
8
+
9
+ /**
10
+ * Replacement for `vue/use-v-on-exact` (no native oxlint equivalent — oxc#15761).
11
+ *
12
+ * `@click="save"` fires on a plain click AND on ctrl-click, because without `.exact` a handler
13
+ * ignores extra system modifiers. So pairing it with `@click.ctrl="saveAs"` means ctrl-click runs
14
+ * BOTH handlers — you get a save and a save-as from one gesture. The bare handler needs `.exact`
15
+ * to opt out.
16
+ *
17
+ * Semantics derived empirically from eslint-plugin-vue (confirmed case by case):
18
+ * - Only "system" modifiers create the conflict: `ctrl`, `shift`, `alt`, `meta`. Others
19
+ * (`prevent`, `stop`, `once`, key names like `enter`, mouse buttons) do not, so
20
+ * `@click` + `@click.prevent` is NOT reported.
21
+ * - The report lands on the handler that has NO system modifiers and no `.exact` — the one that
22
+ * needs changing — not on the modified sibling.
23
+ * - Two handlers that BOTH carry system modifiers (`@click.ctrl` + `@click.shift`) are fine:
24
+ * neither swallows the other.
25
+ * - `@click.ctrl.exact` still triggers a report on a bare `@click` sibling: `.exact` on the
26
+ * modified handler does not rescue the unmodified one.
27
+ * - Grouping is per element and per STATIC event name; a dynamic `@[evt]` is skipped since its
28
+ * name is not knowable.
29
+ */
30
+
31
+ const SYSTEM_MODIFIERS = new Set(["ctrl", "shift", "alt", "meta"]);
32
+
33
+ function modifierNames(prop) {
34
+ return (prop.modifiers ?? []).map((modifier) =>
35
+ typeof modifier === "string" ? modifier : (modifier?.content ?? ""),
36
+ );
37
+ }
38
+
39
+ export default {
40
+ meta: {
41
+ type: "problem",
42
+ docs: {
43
+ description: "enforce usage of `exact` modifier on `v-on`",
44
+ recommended: true,
45
+ },
46
+ schema: [],
47
+ messages: { m: "" },
48
+ },
49
+ create(context) {
50
+ if (!context.filename.endsWith(".vue")) {
51
+ return {};
52
+ }
53
+ return {
54
+ Program() {
55
+ const entry = parseSfc(context.filename);
56
+ const ast = entry.descriptor.template?.ast;
57
+ if (!ast) {
58
+ return;
59
+ }
60
+ walkTemplate(ast, (node) => {
61
+ if (node.type !== NODE_ELEMENT) {
62
+ return;
63
+ }
64
+ /** event name -> handlers on this element */
65
+ const byEvent = new Map();
66
+ for (const prop of node.props ?? []) {
67
+ if (prop.type !== PROP_DIRECTIVE || prop.name !== "on") {
68
+ continue;
69
+ }
70
+ const arg = prop.arg;
71
+ if (
72
+ !arg ||
73
+ arg.type !== 4 /* SIMPLE_EXPRESSION */ ||
74
+ arg.isStatic === false
75
+ ) {
76
+ continue;
77
+ }
78
+ const modifiers = modifierNames(prop);
79
+ const handler = {
80
+ prop,
81
+ hasSystem: modifiers.some((m) => SYSTEM_MODIFIERS.has(m)),
82
+ isExact: modifiers.includes("exact"),
83
+ };
84
+ const bucket = byEvent.get(arg.content);
85
+ if (bucket) {
86
+ bucket.push(handler);
87
+ } else {
88
+ byEvent.set(arg.content, [handler]);
89
+ }
90
+ }
91
+ for (const [event, handlers] of byEvent) {
92
+ if (handlers.length < 2 || !handlers.some((h) => h.hasSystem)) {
93
+ continue;
94
+ }
95
+ for (const handler of handlers) {
96
+ if (handler.hasSystem || handler.isExact) {
97
+ continue;
98
+ }
99
+ reportAtFileOffset(
100
+ context,
101
+ entry,
102
+ handler.prop.loc.start.offset,
103
+ handler.prop.loc.end.offset,
104
+ `This \`@${event}\` also fires when the modifier keys of its sibling handlers are held, so one gesture runs both. Add \`.exact\` to restrict it to ${event} with no modifiers.`,
105
+ );
106
+ }
107
+ }
108
+ });
109
+ },
110
+ };
111
+ },
112
+ };