@esphome/compose-eslint 0.1.2 → 0.3.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.
- package/dist/index.mjs +234 -2
- package/package.json +9 -5
package/dist/index.mjs
CHANGED
|
@@ -1,12 +1,239 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import tseslint from "typescript-eslint";
|
|
3
|
+
|
|
4
|
+
// src/rules/jsx-children-intents.ts
|
|
5
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
6
|
+
|
|
7
|
+
// src/utils/intent-resolver.ts
|
|
8
|
+
import { INTRINSIC_INTENT_REGISTRY } from "@esphome/compose";
|
|
9
|
+
function getJSXTagName(node) {
|
|
10
|
+
if (node.name.type === "JSXIdentifier") {
|
|
11
|
+
return node.name.name;
|
|
12
|
+
}
|
|
13
|
+
if (node.name.type === "JSXMemberExpression") {
|
|
14
|
+
return flattenMemberExpression(node.name);
|
|
15
|
+
}
|
|
16
|
+
return `${node.name.namespace.name}:${node.name.name.name}`;
|
|
17
|
+
}
|
|
18
|
+
function flattenMemberExpression(node) {
|
|
19
|
+
if (node.object.type === "JSXMemberExpression") {
|
|
20
|
+
return `${flattenMemberExpression(node.object)}.${node.property.name}`;
|
|
21
|
+
}
|
|
22
|
+
return `${node.object.name}.${node.property.name}`;
|
|
23
|
+
}
|
|
24
|
+
function isIntrinsicElement(tagName) {
|
|
25
|
+
const firstChar = tagName[0];
|
|
26
|
+
return firstChar === firstChar.toLowerCase() && firstChar !== firstChar.toUpperCase();
|
|
27
|
+
}
|
|
28
|
+
function resolveIntrinsicIntents(tagName) {
|
|
29
|
+
const meta = INTRINSIC_INTENT_REGISTRY[tagName];
|
|
30
|
+
if (!meta) return void 0;
|
|
31
|
+
return {
|
|
32
|
+
intents: meta.intents,
|
|
33
|
+
allowedChildIntents: meta.allowedChildIntents,
|
|
34
|
+
context: meta.context,
|
|
35
|
+
contextTransparent: meta.contextTransparent ?? false
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function resolveElementIntents(openingElement, componentIntentCache) {
|
|
39
|
+
const tagName = getJSXTagName(openingElement);
|
|
40
|
+
if (isIntrinsicElement(tagName)) {
|
|
41
|
+
return resolveIntrinsicIntents(tagName);
|
|
42
|
+
}
|
|
43
|
+
const cached = componentIntentCache.get(tagName);
|
|
44
|
+
if (cached !== void 0) {
|
|
45
|
+
return cached ?? void 0;
|
|
46
|
+
}
|
|
47
|
+
return void 0;
|
|
48
|
+
}
|
|
49
|
+
function findConstrainingParent(ancestors, componentIntentCache) {
|
|
50
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
51
|
+
const ancestor = ancestors[i];
|
|
52
|
+
if (ancestor.type !== "JSXElement") continue;
|
|
53
|
+
const resolved = resolveElementIntents(ancestor.openingElement, componentIntentCache);
|
|
54
|
+
if (!resolved) continue;
|
|
55
|
+
if (resolved.allowedChildIntents === void 0) continue;
|
|
56
|
+
return { resolved, element: ancestor };
|
|
57
|
+
}
|
|
58
|
+
return void 0;
|
|
59
|
+
}
|
|
60
|
+
function findNearestContext(ancestors, componentIntentCache) {
|
|
61
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
62
|
+
const ancestor = ancestors[i];
|
|
63
|
+
if (ancestor.type !== "JSXElement") continue;
|
|
64
|
+
const resolved = resolveElementIntents(ancestor.openingElement, componentIntentCache);
|
|
65
|
+
if (!resolved) continue;
|
|
66
|
+
if (resolved.context && resolved.context.length > 0) {
|
|
67
|
+
return { context: resolved.context, element: ancestor };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return void 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/rules/jsx-children-intents.ts
|
|
74
|
+
var createRule = ESLintUtils.RuleCreator(
|
|
75
|
+
(name) => `https://github.com/xmlguy74/espcompose/blob/main/docs/rules/${name}.md`
|
|
76
|
+
);
|
|
77
|
+
var jsx_children_intents_default = createRule({
|
|
78
|
+
name: "jsx-children-intents",
|
|
79
|
+
meta: {
|
|
80
|
+
type: "problem",
|
|
81
|
+
docs: {
|
|
82
|
+
description: "Enforce valid parent-child component nesting based on declared intents"
|
|
83
|
+
},
|
|
84
|
+
messages: {
|
|
85
|
+
invalidChildIntent: '"<{{ childTag }}>" with intents [{{ childIntents }}] cannot be a child of "<{{ parentTag }}>". Parent accepts: [{{ allowedIntents }}].',
|
|
86
|
+
noIntentsOnChild: '"<{{ childTag }}>" has no declared intents but its parent "<{{ parentTag }}>" requires children with intents: [{{ allowedIntents }}].',
|
|
87
|
+
missingContextIntent: '"<{{ childTag }}>" with intents [{{ childIntents }}] is missing required context [{{ requiredContext }}] established by "<{{ contextTag }}>".',
|
|
88
|
+
noIntentsForContext: '"<{{ childTag }}>" has no declared intents but is inside a context [{{ requiredContext }}] established by "<{{ contextTag }}>".'
|
|
89
|
+
},
|
|
90
|
+
schema: []
|
|
91
|
+
},
|
|
92
|
+
defaultOptions: [],
|
|
93
|
+
create(context) {
|
|
94
|
+
const componentIntentCache = /* @__PURE__ */ new Map();
|
|
95
|
+
return {
|
|
96
|
+
JSXElement(node) {
|
|
97
|
+
const { children } = node;
|
|
98
|
+
for (const child of children) {
|
|
99
|
+
if (child.type !== "JSXElement") continue;
|
|
100
|
+
const childOpeningElement = child.openingElement;
|
|
101
|
+
const childTagName = getJSXTagName(childOpeningElement);
|
|
102
|
+
const childResolved = resolveElementIntents(childOpeningElement, componentIntentCache);
|
|
103
|
+
const ancestors = context.sourceCode.getAncestors(node);
|
|
104
|
+
const fullAncestors = [...ancestors, node];
|
|
105
|
+
const constrainingParent = findConstrainingParent(fullAncestors, componentIntentCache);
|
|
106
|
+
if (constrainingParent) {
|
|
107
|
+
const { resolved: parentResolved, element: parentElement } = constrainingParent;
|
|
108
|
+
const parentTagName = getJSXTagName(parentElement.openingElement);
|
|
109
|
+
const allowed = parentResolved.allowedChildIntents;
|
|
110
|
+
if (!childResolved) {
|
|
111
|
+
if (isIntrinsicElement(childTagName)) {
|
|
112
|
+
context.report({
|
|
113
|
+
node: childOpeningElement,
|
|
114
|
+
messageId: "noIntentsOnChild",
|
|
115
|
+
data: {
|
|
116
|
+
childTag: childTagName,
|
|
117
|
+
parentTag: parentTagName,
|
|
118
|
+
allowedIntents: allowed.join(", ")
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
const hasMatch = childResolved.intents.some((intent) => allowed.includes(intent));
|
|
124
|
+
if (!hasMatch) {
|
|
125
|
+
context.report({
|
|
126
|
+
node: childOpeningElement,
|
|
127
|
+
messageId: "invalidChildIntent",
|
|
128
|
+
data: {
|
|
129
|
+
childTag: childTagName,
|
|
130
|
+
childIntents: childResolved.intents.join(", "),
|
|
131
|
+
parentTag: parentTagName,
|
|
132
|
+
allowedIntents: allowed.join(", ")
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (childResolved?.contextTransparent) continue;
|
|
139
|
+
const nearestContext = findNearestContext(fullAncestors, componentIntentCache);
|
|
140
|
+
if (nearestContext) {
|
|
141
|
+
const { context: requiredContext, element: contextElement } = nearestContext;
|
|
142
|
+
const contextTagName = getJSXTagName(contextElement.openingElement);
|
|
143
|
+
if (!childResolved) {
|
|
144
|
+
if (isIntrinsicElement(childTagName)) {
|
|
145
|
+
context.report({
|
|
146
|
+
node: childOpeningElement,
|
|
147
|
+
messageId: "noIntentsForContext",
|
|
148
|
+
data: {
|
|
149
|
+
childTag: childTagName,
|
|
150
|
+
requiredContext: requiredContext.join(", "),
|
|
151
|
+
contextTag: contextTagName
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
const missingContext = requiredContext.filter(
|
|
157
|
+
(ctx) => !childResolved.intents.includes(ctx)
|
|
158
|
+
);
|
|
159
|
+
if (missingContext.length > 0) {
|
|
160
|
+
context.report({
|
|
161
|
+
node: childOpeningElement,
|
|
162
|
+
messageId: "missingContextIntent",
|
|
163
|
+
data: {
|
|
164
|
+
childTag: childTagName,
|
|
165
|
+
childIntents: childResolved.intents.join(", "),
|
|
166
|
+
requiredContext: missingContext.join(", "),
|
|
167
|
+
contextTag: contextTagName
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// src/rules/no-nested-functions.ts
|
|
180
|
+
import { ESLintUtils as ESLintUtils2 } from "@typescript-eslint/utils";
|
|
181
|
+
var createRule2 = ESLintUtils2.RuleCreator(
|
|
182
|
+
(name) => `https://github.com/xmlguy74/espcompose/blob/main/docs/rules/${name}.md`
|
|
183
|
+
);
|
|
184
|
+
var no_nested_functions_default = createRule2({
|
|
185
|
+
name: "no-nested-functions",
|
|
186
|
+
meta: {
|
|
187
|
+
type: "problem",
|
|
188
|
+
docs: {
|
|
189
|
+
description: "Disallow function declarations nested inside other functions. Only top-level function declarations are compiled into ESPHome scripts."
|
|
190
|
+
},
|
|
191
|
+
messages: {
|
|
192
|
+
nestedFunction: `Function declaration "{{ name }}" is nested inside another function. Move it to the module's top level so the compiler can transform it into an ESPHome script, or use an inline arrow function in the trigger prop.`
|
|
193
|
+
},
|
|
194
|
+
schema: []
|
|
195
|
+
},
|
|
196
|
+
defaultOptions: [],
|
|
197
|
+
create(context) {
|
|
198
|
+
let functionDepth = 0;
|
|
199
|
+
function enterFunction() {
|
|
200
|
+
functionDepth++;
|
|
201
|
+
}
|
|
202
|
+
function exitFunction() {
|
|
203
|
+
functionDepth--;
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
// Track all function-like boundaries (declarations, expressions, arrows)
|
|
207
|
+
FunctionDeclaration(node) {
|
|
208
|
+
if (functionDepth > 0 && node.id) {
|
|
209
|
+
context.report({
|
|
210
|
+
node,
|
|
211
|
+
messageId: "nestedFunction",
|
|
212
|
+
data: { name: node.id.name }
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
enterFunction();
|
|
216
|
+
},
|
|
217
|
+
"FunctionDeclaration:exit": exitFunction,
|
|
218
|
+
FunctionExpression: enterFunction,
|
|
219
|
+
"FunctionExpression:exit": exitFunction,
|
|
220
|
+
ArrowFunctionExpression: enterFunction,
|
|
221
|
+
"ArrowFunctionExpression:exit": exitFunction
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// src/index.ts
|
|
3
227
|
var plugin = {
|
|
4
228
|
meta: {
|
|
5
229
|
name: "@esphome/compose-eslint",
|
|
6
230
|
version: "0.0.1"
|
|
7
231
|
},
|
|
8
232
|
rules: {
|
|
9
|
-
//
|
|
233
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
234
|
+
"jsx-children-intents": jsx_children_intents_default,
|
|
235
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
236
|
+
"no-nested-functions": no_nested_functions_default
|
|
10
237
|
}
|
|
11
238
|
};
|
|
12
239
|
var recommended = [
|
|
@@ -29,7 +256,12 @@ var recommended = [
|
|
|
29
256
|
// at compile time.
|
|
30
257
|
"@typescript-eslint/no-unused-vars": ["warn", {
|
|
31
258
|
varsIgnorePattern: "^ESPCompose$"
|
|
32
|
-
}]
|
|
259
|
+
}],
|
|
260
|
+
// Enforce valid parent-child component nesting based on declared intents.
|
|
261
|
+
"@esphome/compose-eslint/jsx-children-intents": "error",
|
|
262
|
+
// Prevent nested function declarations — only top-level functions become
|
|
263
|
+
// ESPHome scripts; nested ones are silently ignored by the compiler.
|
|
264
|
+
"@esphome/compose-eslint/no-nested-functions": "error"
|
|
33
265
|
}
|
|
34
266
|
},
|
|
35
267
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esphome/compose-eslint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "ESLint plugin with custom rules for ESPHome Compose projects",
|
|
5
5
|
"main": "dist/index.mjs",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -34,20 +34,24 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"typescript-eslint": "^8.0.0"
|
|
37
|
+
"@typescript-eslint/utils": "^8.0.0",
|
|
38
|
+
"typescript-eslint": "^8.57.2",
|
|
39
|
+
"@esphome/compose": "0.3.0"
|
|
38
40
|
},
|
|
39
41
|
"peerDependencies": {
|
|
40
42
|
"eslint": ">=9.0.0"
|
|
41
43
|
},
|
|
42
44
|
"devDependencies": {
|
|
43
|
-
"eslint": "^
|
|
45
|
+
"@typescript-eslint/rule-tester": "^8.0.0",
|
|
46
|
+
"eslint": "^9.39.4",
|
|
44
47
|
"tsup": "^8.0.0",
|
|
45
|
-
"typescript": "^5.
|
|
48
|
+
"typescript": "^5.9.3",
|
|
49
|
+
"vitest": "^2.0.0"
|
|
46
50
|
},
|
|
47
51
|
"scripts": {
|
|
48
52
|
"build": "tsup src/index.ts --format esm --dts",
|
|
49
53
|
"clean": "rimraf dist",
|
|
50
54
|
"lint": "eslint src",
|
|
51
|
-
"test": "
|
|
55
|
+
"test": "vitest run"
|
|
52
56
|
}
|
|
53
57
|
}
|