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,108 @@
1
+ import { NODE_ELEMENT, PROP_DIRECTIVE, parseSfc, reportAtFileOffset } from "../utils/vue-sfc.mjs";
2
+ import {
3
+ bindingsWithOffsets,
4
+ declaredAlias,
5
+ scriptTopLevelNames,
6
+ } from "../utils/vue-bindings.mjs";
7
+
8
+ /**
9
+ * Replacement for `vue/no-template-shadow` (no native oxlint equivalent — oxc#15761).
10
+ *
11
+ * A `v-for` alias or `v-slot` binding that reuses a name already in scope hides the outer one for
12
+ * the whole subtree. `<ul v-for="row in rows"><li v-for="row in row.kids">` reads as if the inner
13
+ * loop iterates the outer row, and it does — once. After that every `row` in the subtree means
14
+ * the inner one, so a later reference to the outer row silently resolves to the wrong value.
15
+ * Renaming is free; debugging this is not.
16
+ *
17
+ * This is the plugin's first CORRELATION rule: it needs both halves of the SFC. The template side
18
+ * comes from `parseSfc` as usual, and the script side from oxlint's own `Program(node)` AST, whose
19
+ * top-level bindings are exactly what `<script setup>` exposes to the template. A template alias
20
+ * is reported when it collides with EITHER an enclosing template binding or a script binding —
21
+ * both confirmed against eslint-plugin-vue.
22
+ *
23
+ * Scoping detail that matters: only ANCESTOR template bindings shadow. Two sibling subtrees may
24
+ * each bind `item` without conflict, which is why this walks with an explicit scope stack rather
25
+ * than using the flat `walkTemplate` helper the other rules share.
26
+ */
27
+
28
+ export default {
29
+ meta: {
30
+ type: "suggestion",
31
+ docs: {
32
+ description: "disallow variable declarations from shadowing variables declared in the outer scope",
33
+ recommended: true,
34
+ },
35
+ schema: [],
36
+ messages: { m: "" },
37
+ },
38
+ create(context) {
39
+ if (!context.filename.endsWith(".vue")) {
40
+ return {};
41
+ }
42
+ return {
43
+ Program(program) {
44
+ const entry = parseSfc(context.filename);
45
+ const ast = entry.descriptor.template?.ast;
46
+ if (!ast) {
47
+ return;
48
+ }
49
+ const scriptNames = scriptTopLevelNames(program);
50
+
51
+ /**
52
+ * @param node current template node
53
+ * @param outer Set of names visible from ancestors (template aliases only;
54
+ * script names are checked separately so they are never "shadowed away")
55
+ */
56
+ const visit = (node, outer) => {
57
+ if (!node) {
58
+ return;
59
+ }
60
+ let scope = outer;
61
+ if (node.type === NODE_ELEMENT) {
62
+ const declared = [];
63
+ for (const prop of node.props ?? []) {
64
+ if (prop.type !== PROP_DIRECTIVE) {
65
+ continue;
66
+ }
67
+ const alias = declaredAlias(prop);
68
+ if (!alias) {
69
+ continue;
70
+ }
71
+ for (const binding of bindingsWithOffsets(alias.text)) {
72
+ const collides =
73
+ outer.has(binding.name) || scriptNames.has(binding.name);
74
+ if (collides) {
75
+ const start =
76
+ prop.exp.loc.start.offset + alias.offset + binding.offset;
77
+ reportAtFileOffset(
78
+ context,
79
+ entry,
80
+ start,
81
+ start + binding.name.length,
82
+ `\`${binding.name}\` shadows a ${outer.has(binding.name) ? "variable declared by an enclosing template scope" : "binding from <script>"}. Every reference in this subtree resolves to the inner one — rename it.`,
83
+ );
84
+ }
85
+ declared.push(binding.name);
86
+ }
87
+ }
88
+ if (declared.length) {
89
+ scope = new Set(outer);
90
+ for (const name of declared) {
91
+ scope.add(name);
92
+ }
93
+ }
94
+ }
95
+ for (const child of node.children ?? []) {
96
+ visit(child, scope);
97
+ }
98
+ // v-if chains keep branches off `children` in a transformed AST; harmless here.
99
+ for (const branch of node.branches ?? []) {
100
+ visit(branch, scope);
101
+ }
102
+ };
103
+
104
+ visit(ast, new Set());
105
+ },
106
+ };
107
+ },
108
+ };
@@ -0,0 +1,63 @@
1
+ import {
2
+ NODE_ELEMENT,
3
+ NODE_INTERPOLATION,
4
+ parseSfc,
5
+ reportAtFileOffset,
6
+ walkTemplate,
7
+ } from "../utils/vue-sfc.mjs";
8
+
9
+ /**
10
+ * Replacement for `vue/no-textarea-mustache` (no native oxlint equivalent — oxc#15761).
11
+ *
12
+ * `<textarea>{{ value }}</textarea>` does not work the way it reads. The interpolation
13
+ * sets the element's initial child text, but a textarea's displayed value comes from its
14
+ * `value` property, and Vue does not keep the two in sync — so the field stops updating
15
+ * as soon as `value` changes. `v-model` (or `:value`) is the working form.
16
+ *
17
+ * Scope: only interpolations that are DIRECT children of the `<textarea>`. A nested
18
+ * element cannot legally appear inside a textarea, so there is nothing deeper to check,
19
+ * and walking deeper would risk flagging malformed markup twice.
20
+ */
21
+
22
+ export default {
23
+ meta: {
24
+ type: "problem",
25
+ docs: {
26
+ description: "disallow mustaches in `<textarea>`",
27
+ recommended: true,
28
+ },
29
+ schema: [],
30
+ messages: { m: "" },
31
+ },
32
+ create(context) {
33
+ if (!context.filename.endsWith(".vue")) {
34
+ return {};
35
+ }
36
+ return {
37
+ Program() {
38
+ const entry = parseSfc(context.filename);
39
+ const ast = entry.descriptor.template?.ast;
40
+ if (!ast) {
41
+ return;
42
+ }
43
+ walkTemplate(ast, (node) => {
44
+ if (node.type !== NODE_ELEMENT || node.tag !== "textarea") {
45
+ return;
46
+ }
47
+ for (const child of node.children ?? []) {
48
+ if (child.type !== NODE_INTERPOLATION) {
49
+ continue;
50
+ }
51
+ reportAtFileOffset(
52
+ context,
53
+ entry,
54
+ child.loc.start.offset,
55
+ child.loc.end.offset,
56
+ "Interpolation inside `<textarea>` only sets the initial text and then stops tracking the value. Use `v-model` (or `:value`) instead.",
57
+ );
58
+ }
59
+ });
60
+ },
61
+ };
62
+ },
63
+ };
@@ -0,0 +1,133 @@
1
+ import { NODE_ELEMENT, PROP_DIRECTIVE, parseSfc, walkTemplate } from "../utils/vue-sfc.mjs";
2
+
3
+ /**
4
+ * Replacement for `vue/no-unused-components` (no native oxlint equivalent — oxc#15761).
5
+ *
6
+ * IMPORTANT — this rule is currently INERT in this repo, by design of the upstream rule rather
7
+ * than by any choice here. `vue/no-unused-components` only inspects Options API registration
8
+ * (`export default { components: { … } }`); it says nothing about `<script setup>`, where an
9
+ * unused component import is caught by `@typescript-eslint/no-unused-vars` instead. This repo has
10
+ * **zero** SFCs with a `components:` block and **zero** non-`setup` `<script>` blocks, so upstream
11
+ * reports nothing here and neither does this port — verified both ways.
12
+ *
13
+ * It is ported anyway, rather than dropped, so the check does not silently disappear the moment
14
+ * someone adds an Options API component. That does mean its correctness rests on synthetic probes
15
+ * matched against eslint-plugin-vue, NOT on the 1581-SFC corpus — the corpus cannot exercise a
16
+ * rule whose trigger does not occur. Recorded in OXLINT_MIGRATION.md.
17
+ *
18
+ * Semantics confirmed against upstream:
19
+ * - Registration is matched case-insensitively across naming styles: `<used />` and `<used-two />`
20
+ * satisfy `Used` and `UsedTwo`, because Vue resolves kebab-case to the PascalCase registration.
21
+ * - `ignoreWhenBindingPresent` defaults to true: if the template contains ANY
22
+ * `<component :is="binding">`, the rule suppresses itself entirely, since the binding could
23
+ * resolve to any registered component. A static `is="Foo"` does not suppress.
24
+ * - The report anchors on the registration property in the script, not on anything in the template.
25
+ */
26
+
27
+ /** `UsedTwo` / `used-two` / `used_two` all normalize to `usedtwo`. */
28
+ function normalizeName(name) {
29
+ return name.replace(/[-_]/g, "").toLowerCase();
30
+ }
31
+
32
+ /** The `components: { … }` object expression from an Options API default export, or null. */
33
+ function findComponentsObject(program) {
34
+ let found = null;
35
+ const inspectObject = (object) => {
36
+ for (const property of object.properties ?? []) {
37
+ const key = property.key;
38
+ const name = key?.name ?? key?.value;
39
+ if (name === "components" && property.value?.type === "ObjectExpression") {
40
+ found = property.value;
41
+ }
42
+ }
43
+ };
44
+ for (const statement of program?.body ?? []) {
45
+ if (statement.type !== "ExportDefaultDeclaration") {
46
+ continue;
47
+ }
48
+ const declaration = statement.declaration;
49
+ if (declaration?.type === "ObjectExpression") {
50
+ inspectObject(declaration);
51
+ } else if (
52
+ declaration?.type === "CallExpression" &&
53
+ declaration.arguments?.[0]?.type === "ObjectExpression"
54
+ ) {
55
+ // `export default defineComponent({ … })`
56
+ inspectObject(declaration.arguments[0]);
57
+ }
58
+ }
59
+ return found;
60
+ }
61
+
62
+ export default {
63
+ meta: {
64
+ type: "suggestion",
65
+ docs: {
66
+ description: "disallow registering components that are not used inside templates",
67
+ recommended: true,
68
+ },
69
+ schema: [],
70
+ messages: {
71
+ unusedComponent:
72
+ 'The "{{name}}" component is registered but never used in the template. Remove the registration, or use the component.',
73
+ },
74
+ },
75
+ create(context) {
76
+ if (!context.filename.endsWith(".vue")) {
77
+ return {};
78
+ }
79
+ return {
80
+ Program(program) {
81
+ const componentsObject = findComponentsObject(program);
82
+ if (!componentsObject) {
83
+ return;
84
+ }
85
+ const entry = parseSfc(context.filename);
86
+ const ast = entry.descriptor.template?.ast;
87
+ if (!ast) {
88
+ return;
89
+ }
90
+
91
+ const usedTags = new Set();
92
+ let hasDynamicIs = false;
93
+ walkTemplate(ast, (node) => {
94
+ if (node.type !== NODE_ELEMENT) {
95
+ return;
96
+ }
97
+ usedTags.add(normalizeName(node.tag));
98
+ if (node.tag !== "component") {
99
+ return;
100
+ }
101
+ for (const prop of node.props ?? []) {
102
+ const isBoundIs =
103
+ prop.type === PROP_DIRECTIVE &&
104
+ prop.name === "bind" &&
105
+ prop.arg?.content === "is";
106
+ if (isBoundIs) {
107
+ hasDynamicIs = true;
108
+ }
109
+ }
110
+ });
111
+ // A dynamic `:is` could resolve to any registration, so upstream stays quiet.
112
+ if (hasDynamicIs) {
113
+ return;
114
+ }
115
+
116
+ for (const property of componentsObject.properties ?? []) {
117
+ const name = property.key?.name ?? property.key?.value;
118
+ if (typeof name !== "string") {
119
+ continue;
120
+ }
121
+ if (usedTags.has(normalizeName(name))) {
122
+ continue;
123
+ }
124
+ context.report({
125
+ node: property,
126
+ messageId: "unusedComponent",
127
+ data: { name },
128
+ });
129
+ }
130
+ },
131
+ };
132
+ },
133
+ };
@@ -0,0 +1,167 @@
1
+ import {
2
+ NODE_ELEMENT,
3
+ NODE_INTERPOLATION,
4
+ PROP_DIRECTIVE,
5
+ parseSfc,
6
+ reportAtFileOffset,
7
+ walkTemplate,
8
+ } from "../utils/vue-sfc.mjs";
9
+ import {
10
+ bindingsWithOffsets,
11
+ declaredAlias,
12
+ positionalGroups,
13
+ } from "../utils/vue-bindings.mjs";
14
+
15
+ /**
16
+ * Replacement for `vue/no-unused-vars` (no native oxlint equivalent — oxc#15761).
17
+ *
18
+ * Note this is the TEMPLATE rule, not `@typescript-eslint/no-unused-vars`: it flags variables
19
+ * introduced BY the template — `v-for` aliases and `v-slot` destructuring — that the template
20
+ * then never reads. An unused alias is usually a leftover from a refactor, and an unused slot
21
+ * prop often means the consumer is reading the wrong name and silently rendering nothing.
22
+ *
23
+ * Semantics confirmed against eslint-plugin-vue case by case:
24
+ * - `v-for="item in items"` with no use of `item` → reported, anchored on `item` itself.
25
+ * - `v-for` aliases are POSITIONAL — `(value, key, index)` — so upstream applies `after-used`
26
+ * logic to them: an unused alias BEFORE a used one is unavoidable and is not reported.
27
+ * `v-for="(_, index) in items"` with `index` used is therefore clean, while
28
+ * `v-for="(item, i)"` with only `item` used does report `i`. Getting this wrong produced 5
29
+ * false positives here, all of the `(_, index)` shape.
30
+ * - `v-slot`/`#name="{ a, b }"` destructuring is NOT positional, so every unused binding is
31
+ * reported regardless of order (confirmed: `{ a, b }` with only `b` used reports `a`).
32
+ * - `_` is NOT exempt. eslint-plugin-vue has an `ignorePattern` option but its default ignores
33
+ * nothing, and our ESLint config never set one — so `v-for="_ in items"` IS reported, and
34
+ * adding an underscore escape hatch here would silently diverge.
35
+ * - A use anywhere in the element's subtree counts, including inside attributes (`:key="o.id"`)
36
+ * and nested children.
37
+ *
38
+ * Binding extraction lives in utils/vue-bindings.mjs (shared with vue-no-template-shadow) and is
39
+ * a positioned scan rather than a full pattern parse. Usage detection here is a word-boundary
40
+ * scan over the subtree's expression sources, so a name that appears only inside a string
41
+ * literal counts as used. Both approximations fail toward FALSE NEGATIVES (a missed unused
42
+ * variable), never toward reporting a variable that is genuinely used.
43
+ */
44
+
45
+ /** Concatenated expression sources inside `element`'s subtree, excluding `skip`. */
46
+ function subtreeExpressionText(element, skip) {
47
+ const parts = [];
48
+ walkTemplate(element, (node) => {
49
+ if (node.type === NODE_INTERPOLATION && node.content?.content) {
50
+ parts.push(node.content.content);
51
+ return;
52
+ }
53
+ if (node.type !== NODE_ELEMENT) {
54
+ return;
55
+ }
56
+ for (const prop of node.props ?? []) {
57
+ if (prop.type !== PROP_DIRECTIVE) {
58
+ continue;
59
+ }
60
+ if (prop.exp?.content && prop.exp !== skip) {
61
+ parts.push(prop.exp.content);
62
+ }
63
+ // A dynamic argument (`:[key]`, `@[evt]`) reads a binding too.
64
+ if (prop.arg?.content && prop.arg.isStatic === false) {
65
+ parts.push(prop.arg.content);
66
+ }
67
+ }
68
+ });
69
+ return parts.join("\n");
70
+ }
71
+
72
+ function usesName(text, name) {
73
+ return new RegExp(`\\b${name}\\b`).test(text);
74
+ }
75
+
76
+ export default {
77
+ meta: {
78
+ type: "suggestion",
79
+ docs: {
80
+ description: "disallow unused variable definitions of v-for directives or scope attributes",
81
+ recommended: true,
82
+ },
83
+ schema: [],
84
+ messages: { m: "" },
85
+ },
86
+ create(context) {
87
+ if (!context.filename.endsWith(".vue")) {
88
+ return {};
89
+ }
90
+ return {
91
+ Program() {
92
+ const entry = parseSfc(context.filename);
93
+ const ast = entry.descriptor.template?.ast;
94
+ if (!ast) {
95
+ return;
96
+ }
97
+ walkTemplate(ast, (node) => {
98
+ if (node.type !== NODE_ELEMENT) {
99
+ return;
100
+ }
101
+ for (const prop of node.props ?? []) {
102
+ if (prop.type !== PROP_DIRECTIVE || !prop.exp?.content) {
103
+ continue;
104
+ }
105
+ const alias = declaredAlias(prop);
106
+ if (!alias) {
107
+ continue;
108
+ }
109
+ const usage = subtreeExpressionText(node, prop.exp);
110
+ // For `v-for`, the right-hand side is evaluated in the OUTER scope, so it
111
+ // is not a use of the alias — but it lives in the same expression node,
112
+ // which `skip` removed wholesale. Add it back so `v-for="a in a"` and
113
+ // similar do not read as unused.
114
+ const isFor = prop.name === "for";
115
+ const rhs = isFor ? prop.exp.content.slice(alias.text.length) : "";
116
+ const haystack = `${usage}\n${rhs}`;
117
+ const report = (binding, groupOffset) => {
118
+ const start =
119
+ prop.exp.loc.start.offset +
120
+ alias.offset +
121
+ groupOffset +
122
+ binding.offset;
123
+ reportAtFileOffset(
124
+ context,
125
+ entry,
126
+ start,
127
+ start + binding.name.length,
128
+ `\`${binding.name}\` is defined by this ${isFor ? "v-for" : "v-slot"} but never used in the template.`,
129
+ );
130
+ };
131
+
132
+ if (!isFor) {
133
+ // v-slot destructuring is not positional: report every unused name.
134
+ for (const binding of bindingsWithOffsets(alias.text)) {
135
+ if (!usesName(haystack, binding.name)) {
136
+ report(binding, 0);
137
+ }
138
+ }
139
+ continue;
140
+ }
141
+
142
+ // v-for: positional, so apply `after-used` — only groups AFTER the last
143
+ // group containing a used binding can be reported.
144
+ const groups = positionalGroups(alias.text).map((group) => ({
145
+ ...group,
146
+ bindings: bindingsWithOffsets(group.text),
147
+ }));
148
+ let lastUsed = -1;
149
+ groups.forEach((group, index) => {
150
+ if (group.bindings.some((b) => usesName(haystack, b.name))) {
151
+ lastUsed = index;
152
+ }
153
+ });
154
+ for (let index = lastUsed + 1; index < groups.length; index += 1) {
155
+ const group = groups[index];
156
+ for (const binding of group.bindings) {
157
+ if (!usesName(haystack, binding.name)) {
158
+ report(binding, group.offset);
159
+ }
160
+ }
161
+ }
162
+ }
163
+ });
164
+ },
165
+ };
166
+ },
167
+ };
@@ -0,0 +1,69 @@
1
+ import {
2
+ NODE_ELEMENT,
3
+ findDirective,
4
+ parseSfc,
5
+ reportAtFileOffset,
6
+ walkTemplate,
7
+ } from "../utils/vue-sfc.mjs";
8
+
9
+ /**
10
+ * Replacement for `vue/no-use-v-if-with-v-for` (no native oxlint equivalent — oxc#15761).
11
+ *
12
+ * `v-if` and `v-for` on the SAME element: in Vue 3 `v-if` has the higher priority, so it is
13
+ * evaluated BEFORE the loop variable exists. `<li v-for="u in users" v-if="u.active">` does
14
+ * not filter the list — it throws or silently reads `undefined`, because `u` is not in scope
15
+ * when the condition runs. (Vue 2 had the opposite precedence, which is why this pattern
16
+ * survives in migrated code and reads as if it works.)
17
+ *
18
+ * The fix is either a computed filtered list, or moving `v-if` to a wrapping `<template>`.
19
+ *
20
+ * Reported unconditionally when both are present, matching eslint-plugin-vue's default
21
+ * (`allowUsingIterationVar: false`). The option that permits it exists for Vue 2 semantics;
22
+ * on Vue 3 the pattern is broken regardless of whether the condition touches the iteration
23
+ * variable, so there is nothing to allow.
24
+ */
25
+
26
+ export default {
27
+ meta: {
28
+ type: "problem",
29
+ docs: {
30
+ description: "disallow use of `v-if` on the same element as `v-for`",
31
+ recommended: true,
32
+ },
33
+ schema: [],
34
+ messages: { m: "" },
35
+ },
36
+ create(context) {
37
+ if (!context.filename.endsWith(".vue")) {
38
+ return {};
39
+ }
40
+ return {
41
+ Program() {
42
+ const entry = parseSfc(context.filename);
43
+ const ast = entry.descriptor.template?.ast;
44
+ if (!ast) {
45
+ return;
46
+ }
47
+ walkTemplate(ast, (node) => {
48
+ if (node.type !== NODE_ELEMENT) {
49
+ return;
50
+ }
51
+ const vFor = findDirective(node, "for");
52
+ const vIf = findDirective(node, "if");
53
+ if (!vFor || !vIf) {
54
+ return;
55
+ }
56
+ // Anchor on the `v-if` DIRECTIVE, not its expression: that is where
57
+ // eslint-plugin-vue points (verified at exact column parity).
58
+ reportAtFileOffset(
59
+ context,
60
+ entry,
61
+ vIf.loc.start.offset,
62
+ vIf.loc.end.offset,
63
+ `\`v-if\` on the same element as \`v-for\` runs BEFORE the loop variable exists (v-if has higher priority in Vue 3), so this does not filter the list. Use a computed filtered list, or move the \`v-if\` to a wrapping <template>.`,
64
+ );
65
+ });
66
+ },
67
+ };
68
+ },
69
+ };
@@ -0,0 +1,98 @@
1
+ import {
2
+ NODE_ELEMENT,
3
+ PROP_ATTRIBUTE,
4
+ PROP_DIRECTIVE,
5
+ parseSfc,
6
+ reportAtFileOffset,
7
+ walkTemplate,
8
+ } from "../utils/vue-sfc.mjs";
9
+
10
+ /**
11
+ * Replacement for `vue/no-useless-template-attributes` (no native oxlint equivalent — oxc#15761).
12
+ *
13
+ * `<template>` is not rendered, so most attributes on it go nowhere. `<template v-if="x"
14
+ * class="row">` looks like it styles the row; the class is silently dropped and the author is
15
+ * left debugging CSS that was never applied.
16
+ *
17
+ * The attributes that DO have meaning on a `<template>`:
18
+ * - the structural directives themselves: `v-if`, `v-else-if`, `v-else`, `v-for`, `v-slot`
19
+ * (`#name`), and `v-pre`.
20
+ * - `key`, but only together with `v-for` — that is the Vue 3 iteration key, required by
21
+ * vue-require-v-for-key. A `key` WITHOUT `v-for` is handled by vue-no-template-key, so it
22
+ * is left alone here to avoid two rules reporting the same token.
23
+ *
24
+ * Everything else — static attributes, `v-bind`, `v-on`, `v-model`, `v-show` — is reported.
25
+ * A lone `<template>` with no directive at all is vue-no-lone-template's finding, not this
26
+ * rule's, so this rule only speaks when the template has at least one meaningful directive.
27
+ */
28
+
29
+ const MEANINGFUL_DIRECTIVES = new Set(["if", "else-if", "else", "for", "slot", "pre"]);
30
+
31
+ function isKeyProp(prop) {
32
+ if (prop.type === PROP_ATTRIBUTE) {
33
+ return prop.name === "key";
34
+ }
35
+ return (
36
+ prop.name === "bind" &&
37
+ prop.arg?.type === 4 /* SIMPLE_EXPRESSION */ &&
38
+ prop.arg.content === "key"
39
+ );
40
+ }
41
+
42
+ export default {
43
+ meta: {
44
+ type: "problem",
45
+ docs: {
46
+ description: "disallow useless attribute on `<template>`",
47
+ recommended: true,
48
+ },
49
+ schema: [],
50
+ messages: { m: "" },
51
+ },
52
+ create(context) {
53
+ if (!context.filename.endsWith(".vue")) {
54
+ return {};
55
+ }
56
+ return {
57
+ Program() {
58
+ const entry = parseSfc(context.filename);
59
+ const ast = entry.descriptor.template?.ast;
60
+ if (!ast) {
61
+ return;
62
+ }
63
+ walkTemplate(ast, (node) => {
64
+ if (node.type !== NODE_ELEMENT || node.tag !== "template") {
65
+ return;
66
+ }
67
+ const props = node.props ?? [];
68
+ const structural = props.filter(
69
+ (prop) =>
70
+ prop.type === PROP_DIRECTIVE && MEANINGFUL_DIRECTIVES.has(prop.name),
71
+ );
72
+ // No structural directive at all → vue-no-lone-template's finding.
73
+ if (!structural.length) {
74
+ return;
75
+ }
76
+ for (const prop of props) {
77
+ if (structural.includes(prop)) {
78
+ continue;
79
+ }
80
+ // `key` is meaningful with v-for; without it, vue-no-template-key owns it.
81
+ if (isKeyProp(prop)) {
82
+ continue;
83
+ }
84
+ const label =
85
+ prop.type === PROP_ATTRIBUTE ? prop.name : `v-${prop.name}`;
86
+ reportAtFileOffset(
87
+ context,
88
+ entry,
89
+ prop.loc.start.offset,
90
+ prop.loc.end.offset,
91
+ `\`${label}\` has no effect on \`<template>\` — the element is not rendered, so the attribute is silently dropped. Move it to the child element you meant to target.`,
92
+ );
93
+ }
94
+ });
95
+ },
96
+ };
97
+ },
98
+ };