@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.
Files changed (2) hide show
  1. package/dist/index.mjs +234 -2
  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
- // Custom rules will be added here
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.1.2",
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": "^9.0.0",
45
+ "@typescript-eslint/rule-tester": "^8.0.0",
46
+ "eslint": "^9.39.4",
44
47
  "tsup": "^8.0.0",
45
- "typescript": "^5.4.0"
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": "echo \"No tests yet\""
55
+ "test": "vitest run"
52
56
  }
53
57
  }