@almadar/eslint-plugin 2.3.0 → 2.4.2

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,13 @@ 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");
30
+
31
+ // Behavior rules
32
+ const behaviorNoOrganisms = require("./rules/behavior-no-organisms");
26
33
 
27
34
  // Service rules - Phase 1-3 (Implemented)
28
35
  const serviceLazySingleton = require("./rules/service-lazy-singleton");
@@ -73,6 +80,13 @@ const plugin = {
73
80
  "organism-no-callback-props": organismNoCallbackProps,
74
81
  "organism-extends-entity-display": organismExtendsEntityDisplay,
75
82
  "organism-rendering-state-only": organismRenderingStateOnly,
83
+ "organism-require-entity-row": organismRequireEntityRow,
84
+
85
+ // Atom/molecule rules
86
+ "atom-molecule-no-entity-prop": atomMoleculeNoEntityProp,
87
+
88
+ // Behavior rules
89
+ "behavior-no-organisms": behaviorNoOrganisms,
76
90
 
77
91
  // Service rules - Phase 1-3
78
92
  "service-lazy-singleton": serviceLazySingleton,
@@ -115,6 +129,7 @@ plugin.configs.recommended = {
115
129
  "almadar/no-entity-string-type": "error",
116
130
  "almadar/no-import-generated": "error",
117
131
  "almadar/no-use-navigate": "error",
132
+ "almadar/atom-molecule-no-entity-prop": "error",
118
133
 
119
134
  // Warn-level — conventions that should be followed
120
135
  "almadar/require-event-bus": "warn",
@@ -125,12 +140,16 @@ plugin.configs.recommended = {
125
140
  "almadar/template-extends-base": "warn",
126
141
  "almadar/require-translate": "warn",
127
142
 
143
+ // Behavior rules — restrict to atoms/molecules only
144
+ "almadar/behavior-no-organisms": "error",
145
+
128
146
  // Organism rules — enforce dumb organism pattern
129
147
  "almadar/organism-no-data-state": "error",
130
148
  "almadar/organism-no-auto-fetch": "error",
131
149
  "almadar/organism-no-callback-props": "error",
132
150
  "almadar/organism-extends-entity-display": "error",
133
151
  "almadar/organism-rendering-state-only": "warn",
152
+ "almadar/organism-require-entity-row": "error",
134
153
  },
135
154
  };
136
155
 
@@ -170,6 +189,7 @@ plugin.configs.organisms = {
170
189
  "almadar/organism-rendering-state-only": "warn",
171
190
  "almadar/require-event-bus": "error",
172
191
  "almadar/require-closed-circuit-props": "error",
192
+ "almadar/organism-require-entity-row": "error",
173
193
  },
174
194
  };
175
195
 
@@ -219,4 +239,17 @@ plugin.configs["services-strict"] = {
219
239
  },
220
240
  };
221
241
 
242
+ // Behaviors config — lint .orb behavior schemas for pattern tier compliance
243
+ // Requires: jsonc-eslint-parser (pnpm add -D jsonc-eslint-parser)
244
+ // Usage in eslint.config.js: { files: ["**/*.orb"], ...almadar.configs.behaviors }
245
+ plugin.configs.behaviors = {
246
+ plugins: { almadar: plugin },
247
+ languageOptions: {
248
+ parser: require("jsonc-eslint-parser"),
249
+ },
250
+ rules: {
251
+ "almadar/behavior-no-organisms": "error",
252
+ },
253
+ };
254
+
222
255
  module.exports = plugin;
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.2",
4
4
  "private": false,
5
5
  "description": "Custom ESLint rules for Almadar component architecture",
6
6
  "main": "index.js",
@@ -12,11 +12,20 @@
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
+ "dependencies": {
26
+ "jsonc-eslint-parser": "3.1.0"
27
+ },
28
+ "scripts": {
29
+ "lint": "eslint index.js rules/ utils/"
21
30
  }
22
31
  }
@@ -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,166 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * behavior-no-organisms
5
+ *
6
+ * Behaviors (.orb files in behaviors/) must only use atom and molecule patterns.
7
+ * Organisms and templates are forbidden, with exceptions for:
8
+ * - Game-related patterns (category "game" in component-mapping.json)
9
+ * - form / form-section (explicitly allowed by convention)
10
+ *
11
+ * Requires: jsonc-eslint-parser configured for *.orb files.
12
+ *
13
+ * To regenerate BLOCKED_PATTERNS from component-mapping.json:
14
+ * node -e "
15
+ * const m = require('../almadar-patterns/src/component-mapping.json').mappings;
16
+ * const blocked = Object.entries(m)
17
+ * .filter(([k, v]) =>
18
+ * (v.importPath.includes('/organisms/') || v.importPath.includes('/templates/'))
19
+ * && v.category !== 'game'
20
+ * && !['form', 'form-section'].includes(k))
21
+ * .map(([k]) => k);
22
+ * console.log(JSON.stringify(blocked, null, 2));
23
+ * "
24
+ */
25
+
26
+ // Organism patterns: importPath contains /organisms/, category !== "game"
27
+ // Template patterns: importPath contains /templates/, not game-related
28
+ // Excludes: form, form-section (allowed by convention)
29
+ const BLOCKED_PATTERNS = new Set([
30
+ // ── Organisms (non-game, non-form) ────────────────────────────────
31
+ "page-header",
32
+ "entity-list",
33
+ "entity-table",
34
+ "entity-cards",
35
+ "stats",
36
+ "detail-panel",
37
+ "wizard-container",
38
+ "confirm-dialog",
39
+ "master-detail",
40
+ "dashboard-grid",
41
+ "runtime-debugger",
42
+ "button-pattern",
43
+ "custom-pattern",
44
+ "drawer-slot",
45
+ "header",
46
+ "layout-pattern",
47
+ "modal-slot",
48
+ "navigation",
49
+ "orbital-visualization",
50
+ "section",
51
+ "sidebar",
52
+ "split",
53
+ "table",
54
+ "toast-slot",
55
+ "u-i-slot-renderer",
56
+ "split-pane",
57
+ "tabbed-container",
58
+ "collapsible-section",
59
+ "camera3-d",
60
+ "lighting3-d",
61
+ "scene3-d",
62
+ "canvas3-d-error-boundary",
63
+ "canvas3-d-loading-state",
64
+ "model-loader",
65
+ "physics-object3-d",
66
+ "feature-renderer",
67
+ "feature-renderer3-d",
68
+ "unit-renderer",
69
+ "trait-slot",
70
+ "trait-state-viewer",
71
+ "content-renderer",
72
+ "jazari-state-machine",
73
+ "state-machine-view",
74
+ "book-chapter-view",
75
+ "book-cover-page",
76
+ "book-nav-bar",
77
+ "book-table-of-contents",
78
+ "book-viewer",
79
+ "simulation-controls",
80
+ "simulation-graph",
81
+ "chart",
82
+ "timeline",
83
+ "media-gallery",
84
+ "signature-pad",
85
+ "document-viewer",
86
+ "graph-canvas",
87
+ "code-viewer",
88
+ "event-log",
89
+ "object-rule-panel",
90
+ "rule-editor",
91
+ "action-palette",
92
+ "sequence-bar",
93
+ "code-view",
94
+ "state-architect-board",
95
+ "state-node",
96
+ "transition-arrow",
97
+ "variable-panel",
98
+
99
+ // ── Templates (non-game) ──────────────────────────────────────────
100
+ "auth-layout",
101
+ "counter-template",
102
+ "dashboard-layout",
103
+ "generic-app-template",
104
+ ]);
105
+
106
+ // Alternative names that map to the same organism
107
+ const ALIASES = {
108
+ form: null, // explicitly allowed
109
+ "form-section": null, // explicitly allowed
110
+ };
111
+
112
+ module.exports = {
113
+ meta: {
114
+ type: "problem",
115
+ docs: {
116
+ description:
117
+ "Behavior schemas must only use atom and molecule patterns. " +
118
+ "Organisms and templates (except games and form-section) are forbidden.",
119
+ category: "Almadar Architecture",
120
+ },
121
+ messages: {
122
+ blockedPattern:
123
+ 'Pattern "{{pattern}}" is an organism/template. ' +
124
+ "Behaviors must only use atoms and molecules. " +
125
+ "Replace with a molecule-level equivalent or restructure the schema.",
126
+ },
127
+ schema: [],
128
+ },
129
+
130
+ create(context) {
131
+ const filename = context.filename || context.getFilename();
132
+
133
+ // Only apply to .orb files inside behaviors/ directories
134
+ if (!filename.endsWith(".orb") || !filename.includes("behaviors")) {
135
+ return {};
136
+ }
137
+
138
+ return {
139
+ // jsonc-eslint-parser provides JSONProperty AST nodes
140
+ JSONProperty(node) {
141
+ // Look for "type": "<pattern-name>" properties
142
+ if (
143
+ node.key &&
144
+ node.key.type === "JSONLiteral" &&
145
+ node.key.value === "type" &&
146
+ node.value &&
147
+ node.value.type === "JSONLiteral" &&
148
+ typeof node.value.value === "string"
149
+ ) {
150
+ const pattern = node.value.value;
151
+
152
+ // Skip explicitly allowed aliases
153
+ if (pattern in ALIASES) return;
154
+
155
+ if (BLOCKED_PATTERNS.has(pattern)) {
156
+ context.report({
157
+ node: node.value,
158
+ messageId: "blockedPattern",
159
+ data: { pattern },
160
+ });
161
+ }
162
+ }
163
+ },
164
+ };
165
+ },
166
+ };
@@ -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(/^\.\//, "")) ||