@almadar/eslint-plugin 2.3.0 → 2.4.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/index.js CHANGED
@@ -23,6 +23,10 @@ const organismNoAutoFetch = require("./rules/organism-no-auto-fetch");
23
23
  const organismNoCallbackProps = require("./rules/organism-no-callback-props");
24
24
  const organismExtendsEntityDisplay = require("./rules/organism-extends-entity-display");
25
25
  const organismRenderingStateOnly = require("./rules/organism-rendering-state-only");
26
+ const organismRequireEntityRow = require("./rules/organism-require-entity-row");
27
+
28
+ // Atom/molecule rules
29
+ const atomMoleculeNoEntityProp = require("./rules/atom-molecule-no-entity-prop");
26
30
 
27
31
  // Service rules - Phase 1-3 (Implemented)
28
32
  const serviceLazySingleton = require("./rules/service-lazy-singleton");
@@ -73,6 +77,10 @@ const plugin = {
73
77
  "organism-no-callback-props": organismNoCallbackProps,
74
78
  "organism-extends-entity-display": organismExtendsEntityDisplay,
75
79
  "organism-rendering-state-only": organismRenderingStateOnly,
80
+ "organism-require-entity-row": organismRequireEntityRow,
81
+
82
+ // Atom/molecule rules
83
+ "atom-molecule-no-entity-prop": atomMoleculeNoEntityProp,
76
84
 
77
85
  // Service rules - Phase 1-3
78
86
  "service-lazy-singleton": serviceLazySingleton,
@@ -115,6 +123,7 @@ plugin.configs.recommended = {
115
123
  "almadar/no-entity-string-type": "error",
116
124
  "almadar/no-import-generated": "error",
117
125
  "almadar/no-use-navigate": "error",
126
+ "almadar/atom-molecule-no-entity-prop": "error",
118
127
 
119
128
  // Warn-level — conventions that should be followed
120
129
  "almadar/require-event-bus": "warn",
@@ -131,6 +140,7 @@ plugin.configs.recommended = {
131
140
  "almadar/organism-no-callback-props": "error",
132
141
  "almadar/organism-extends-entity-display": "error",
133
142
  "almadar/organism-rendering-state-only": "warn",
143
+ "almadar/organism-require-entity-row": "error",
134
144
  },
135
145
  };
136
146
 
@@ -170,6 +180,7 @@ plugin.configs.organisms = {
170
180
  "almadar/organism-rendering-state-only": "warn",
171
181
  "almadar/require-event-bus": "error",
172
182
  "almadar/require-closed-circuit-props": "error",
183
+ "almadar/organism-require-entity-row": "error",
173
184
  },
174
185
  };
175
186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@almadar/eslint-plugin",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "private": false,
5
5
  "description": "Custom ESLint rules for Almadar component architecture",
6
6
  "main": "index.js",
@@ -12,11 +12,17 @@
12
12
  "peerDependencies": {
13
13
  "eslint": ">=8.0.0"
14
14
  },
15
+ "devDependencies": {
16
+ "eslint": "10.0.0"
17
+ },
15
18
  "repository": {
16
19
  "type": "git",
17
20
  "url": "https://github.com/almadar-io/almadar-eslint.git"
18
21
  },
19
22
  "publishConfig": {
20
23
  "access": "public"
24
+ },
25
+ "scripts": {
26
+ "lint": "eslint index.js rules/ utils/"
21
27
  }
22
28
  }
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ const { isAtomOrMolecule, isStoryFile } = require("../utils/helpers");
3
+
4
+ /**
5
+ * Exceptions: molecules that legitimately need entity-like array data props
6
+ * because they are data-iteration components (mini-organisms promoted to molecules).
7
+ */
8
+ const EXCEPTED_FILES = new Set([
9
+ "DataGrid",
10
+ "DataList",
11
+ "ButtonGroup", // uses entity as string name for event context
12
+ "FilterGroup", // uses entity as string name for filter events
13
+ "SearchInput", // uses entity as string name for search events
14
+ ]);
15
+
16
+ function isExcepted(filename) {
17
+ for (const name of EXCEPTED_FILES) {
18
+ if (filename.endsWith(`/${name}.tsx`) || filename.endsWith(`/${name}.jsx`)) {
19
+ return true;
20
+ }
21
+ }
22
+ return false;
23
+ }
24
+
25
+ module.exports = {
26
+ meta: {
27
+ type: "problem",
28
+ docs: {
29
+ description:
30
+ "Disallow 'entity' prop on atoms and molecules. Atoms/molecules are pure UI building blocks that accept normal typed props. Only organisms and templates should accept 'entity'.",
31
+ category: "Almadar Architecture",
32
+ },
33
+ messages: {
34
+ noEntityProp:
35
+ "Atoms and molecules must not accept an 'entity' prop. Use normal typed props instead (e.g., markers, nodes, items). Only organisms and templates should be entity-aware.",
36
+ },
37
+ schema: [],
38
+ },
39
+
40
+ create(context) {
41
+ const filename = context.filename || context.getFilename();
42
+ if (!isAtomOrMolecule(filename)) return {};
43
+ if (isStoryFile(filename)) return {};
44
+ if (isExcepted(filename)) return {};
45
+
46
+ return {
47
+ // Check interface declarations: interface FooProps { entity?: ... }
48
+ TSPropertySignature(node) {
49
+ if (
50
+ node.key?.type === "Identifier" &&
51
+ node.key.name === "entity"
52
+ ) {
53
+ // Only flag if inside an interface ending with "Props"
54
+ const parent = node.parent?.parent;
55
+ if (
56
+ parent?.type === "TSInterfaceDeclaration" &&
57
+ parent.id?.name?.endsWith("Props")
58
+ ) {
59
+ context.report({ node, messageId: "noEntityProp" });
60
+ }
61
+ }
62
+ },
63
+
64
+ // Check type alias declarations: type FooProps = { entity?: ... }
65
+ TSTypeAliasDeclaration(node) {
66
+ if (!node.id?.name?.endsWith("Props")) return;
67
+ const typeAnnotation = node.typeAnnotation;
68
+ if (typeAnnotation?.type !== "TSTypeLiteral") return;
69
+
70
+ for (const member of typeAnnotation.members) {
71
+ if (
72
+ member.type === "TSPropertySignature" &&
73
+ member.key?.type === "Identifier" &&
74
+ member.key.name === "entity"
75
+ ) {
76
+ context.report({ node: member, messageId: "noEntityProp" });
77
+ }
78
+ }
79
+ },
80
+ };
81
+ },
82
+ };
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ const { isOrganism, isStoryFile, isGameOrganism } = require("../utils/helpers");
3
+
4
+ /**
5
+ * Find the outermost JSX element returned from a .map() callback.
6
+ * Handles: arrow with expression body, arrow with block body + return,
7
+ * and conditional expressions.
8
+ */
9
+ function getReturnedJSX(callbackBody) {
10
+ if (
11
+ callbackBody.type === "JSXElement" ||
12
+ callbackBody.type === "JSXFragment"
13
+ ) {
14
+ return callbackBody;
15
+ }
16
+
17
+ if (callbackBody.type === "ConditionalExpression") {
18
+ return (
19
+ getReturnedJSX(callbackBody.consequent) ||
20
+ getReturnedJSX(callbackBody.alternate)
21
+ );
22
+ }
23
+
24
+ if (callbackBody.type === "BlockStatement") {
25
+ for (const stmt of callbackBody.body) {
26
+ if (stmt.type === "ReturnStatement" && stmt.argument) {
27
+ const jsx = getReturnedJSX(stmt.argument);
28
+ if (jsx) return jsx;
29
+ }
30
+ }
31
+ }
32
+
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * Check if a JSX element has a specific attribute (by name).
38
+ */
39
+ function hasJSXAttribute(jsxElement, attrName) {
40
+ if (jsxElement.type !== "JSXElement") return false;
41
+ return jsxElement.openingElement.attributes.some(
42
+ (attr) =>
43
+ attr.type === "JSXAttribute" &&
44
+ attr.name?.type === "JSXIdentifier" &&
45
+ attr.name.name === attrName
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Check if a JSX element has key={param.id} where param is the callback's
51
+ * first parameter. This distinguishes entity iteration (key={hero.id})
52
+ * from utility iteration (key={field}, key={index}, key={actionIdx}).
53
+ */
54
+ function hasEntityKeyPattern(jsxElement, callbackParam) {
55
+ if (jsxElement.type !== "JSXElement") return false;
56
+ if (!callbackParam) return false;
57
+
58
+ // Get the callback parameter name
59
+ const paramName =
60
+ callbackParam.type === "Identifier"
61
+ ? callbackParam.name
62
+ : callbackParam.type === "ObjectPattern"
63
+ ? null // destructured param, check differently
64
+ : null;
65
+
66
+ for (const attr of jsxElement.openingElement.attributes) {
67
+ if (attr.type !== "JSXAttribute") continue;
68
+ if (attr.name?.type !== "JSXIdentifier" || attr.name.name !== "key") continue;
69
+
70
+ const value = attr.value;
71
+ if (!value || value.type !== "JSXExpressionContainer") continue;
72
+
73
+ const expr = value.expression;
74
+
75
+ // key={param.id} pattern
76
+ if (
77
+ paramName &&
78
+ expr.type === "MemberExpression" &&
79
+ expr.object?.type === "Identifier" &&
80
+ expr.object.name === paramName &&
81
+ expr.property?.type === "Identifier" &&
82
+ expr.property.name === "id"
83
+ ) {
84
+ return true;
85
+ }
86
+
87
+ // key={param?.id} optional chaining
88
+ if (
89
+ paramName &&
90
+ expr.type === "ChainExpression" &&
91
+ expr.expression?.type === "MemberExpression" &&
92
+ expr.expression.object?.type === "Identifier" &&
93
+ expr.expression.object.name === paramName &&
94
+ expr.expression.property?.type === "Identifier" &&
95
+ expr.expression.property.name === "id"
96
+ ) {
97
+ return true;
98
+ }
99
+
100
+ // Destructured: key={id} when param is { id, ... }
101
+ if (
102
+ callbackParam.type === "ObjectPattern" &&
103
+ expr.type === "Identifier" &&
104
+ expr.name === "id"
105
+ ) {
106
+ // Verify 'id' is actually destructured from the param
107
+ const hasId = callbackParam.properties.some(
108
+ (p) =>
109
+ p.type === "Property" &&
110
+ p.key?.type === "Identifier" &&
111
+ p.key.name === "id"
112
+ );
113
+ if (hasId) return true;
114
+ }
115
+ }
116
+
117
+ return false;
118
+ }
119
+
120
+ module.exports = {
121
+ meta: {
122
+ type: "problem",
123
+ docs: {
124
+ description:
125
+ "Require data-entity-row attribute on the outermost JSX element returned from .map() in organisms when iterating over entities",
126
+ category: "Almadar Architecture",
127
+ },
128
+ messages: {
129
+ missingEntityRow:
130
+ "Entity row element in .map() is missing 'data-entity-row' attribute. Add it to the root DOM element of each entity card so the verification system can detect entity rows.",
131
+ },
132
+ schema: [],
133
+ },
134
+ create(context) {
135
+ const filename = context.filename || context.getFilename();
136
+
137
+ if (!isOrganism(filename) || isStoryFile(filename)) return {};
138
+ if (isGameOrganism(filename)) return {};
139
+
140
+ return {
141
+ CallExpression(node) {
142
+ if (
143
+ node.callee.type !== "MemberExpression" ||
144
+ node.callee.property.type !== "Identifier" ||
145
+ node.callee.property.name !== "map"
146
+ ) {
147
+ return;
148
+ }
149
+
150
+ const callback = node.arguments[0];
151
+ if (!callback) return;
152
+
153
+ let body;
154
+ if (
155
+ callback.type === "ArrowFunctionExpression" ||
156
+ callback.type === "FunctionExpression"
157
+ ) {
158
+ body = callback.body;
159
+ } else {
160
+ return;
161
+ }
162
+
163
+ const jsxElement = getReturnedJSX(body);
164
+ if (!jsxElement || jsxElement.type !== "JSXElement") return;
165
+
166
+ // Only flag entity iteration: key={param.id} pattern
167
+ const callbackParam = callback.params[0];
168
+ if (!hasEntityKeyPattern(jsxElement, callbackParam)) return;
169
+
170
+ if (!hasJSXAttribute(jsxElement, "data-entity-row")) {
171
+ context.report({
172
+ node: jsxElement.openingElement,
173
+ messageId: "missingEntityRow",
174
+ });
175
+ }
176
+ },
177
+ };
178
+ },
179
+ };
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- const { isStoryFile, isTemplateFile, isAtomOrMolecule, isOrganism } = require("../utils/helpers");
3
+ const { isStoryFile, isTemplateFile, isOrganism } = require("../utils/helpers");
4
4
 
5
5
  /**
6
6
  * Check if a file is inside a molecules/ directory.
@@ -40,7 +40,7 @@ module.exports = {
40
40
  create(context) {
41
41
  const options = context.options[0] || {};
42
42
  const barrelFileName = options.barrelFile || "index.ts";
43
- const ignorePrivate = options.ignorePrivate !== false;
43
+ void options.ignorePrivate;
44
44
 
45
45
  const filename = context.filename;
46
46
  const dir = path.dirname(filename);
@@ -60,7 +60,7 @@ module.exports = {
60
60
  let barrelContent = "";
61
61
  try {
62
62
  barrelContent = fs.readFileSync(barrelPath, "utf-8");
63
- } catch (err) {
63
+ } catch {
64
64
  return {};
65
65
  }
66
66
 
@@ -60,11 +60,11 @@ module.exports = {
60
60
  },
61
61
 
62
62
  // Check for Express route handler patterns
63
- "CallExpression[callee.object.name='app'][callee.property.name=/^(get|post|put|delete|patch)$/]"(node) {
63
+ "CallExpression[callee.object.name='app'][callee.property.name=/^(get|post|put|delete|patch)$/]"(_node) {
64
64
  hasRouteHandlerPattern = true;
65
65
  },
66
66
 
67
- "CallExpression[callee.object.name='router'][callee.property.name=/^(get|post|put|delete|patch)$/]"(node) {
67
+ "CallExpression[callee.object.name='router'][callee.property.name=/^(get|post|put|delete|patch)$/]"(_node) {
68
68
  hasRouteHandlerPattern = true;
69
69
  },
70
70
 
@@ -51,7 +51,7 @@ module.exports = {
51
51
  continue;
52
52
  }
53
53
 
54
- for (const [name, version] of Object.entries(deps)) {
54
+ for (const [, version] of Object.entries(deps)) {
55
55
  // Check for workspace:*
56
56
  if (version === "workspace:*" || version === "workspace:") {
57
57
  context.report({
@@ -67,12 +67,8 @@ module.exports = {
67
67
  const exports = pkg.exports || {};
68
68
 
69
69
  // Check each export has a corresponding tsup entry
70
- for (const [exportPath, exportConfig] of Object.entries(exports)) {
70
+ for (const [exportPath] of Object.entries(exports)) {
71
71
  if (exportPath.startsWith("./")) {
72
- const expectedPattern = exportPath === "."
73
- ? "index.js"
74
- : `${exportPath.replace(/^\.\//, "/")}.js`;
75
-
76
72
  // Check if there's a matching entry in tsup
77
73
  const hasMatchingEntry = tsupContent.includes(exportPath.replace(/^\.\//, "src/")) ||
78
74
  tsupContent.includes(exportPath.replace(/^\.\//, "")) ||