@almadar/eslint-plugin 2.2.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
@@ -8,6 +8,7 @@ const templateNoIteration = require("./rules/template-no-iteration");
8
8
  const templateSerializableProps = require("./rules/template-serializable-props");
9
9
  const noTypeProp = require("./rules/no-type-prop");
10
10
  const noAsAny = require("./rules/no-as-any");
11
+ const noEntityStringType = require("./rules/no-entity-string-type");
11
12
  const noImportGenerated = require("./rules/no-import-generated");
12
13
  const noUseNavigate = require("./rules/no-use-navigate");
13
14
  const requireEventBus = require("./rules/require-event-bus");
@@ -22,8 +23,12 @@ const organismNoAutoFetch = require("./rules/organism-no-auto-fetch");
22
23
  const organismNoCallbackProps = require("./rules/organism-no-callback-props");
23
24
  const organismExtendsEntityDisplay = require("./rules/organism-extends-entity-display");
24
25
  const organismRenderingStateOnly = require("./rules/organism-rendering-state-only");
26
+ const organismRequireEntityRow = require("./rules/organism-require-entity-row");
25
27
 
26
- // Service rules
28
+ // Atom/molecule rules
29
+ const atomMoleculeNoEntityProp = require("./rules/atom-molecule-no-entity-prop");
30
+
31
+ // Service rules - Phase 1-3 (Implemented)
27
32
  const serviceLazySingleton = require("./rules/service-lazy-singleton");
28
33
  const serviceSingletonResettable = require("./rules/service-singleton-resettable");
29
34
  const serviceNoModuleSideEffects = require("./rules/service-no-module-side-effects");
@@ -32,6 +37,16 @@ const serviceEventNameScreaming = require("./rules/service-event-name-screaming"
32
37
  const serviceIntegrationExtendsBase = require("./rules/service-integration-extends-base");
33
38
  const serviceStoreIsolation = require("./rules/service-store-isolation");
34
39
 
40
+ // Service rules - Phase 4 (New)
41
+ const serviceBarrelComplete = require("./rules/service-barrel-complete");
42
+ const serviceSubpathMatchTsup = require("./rules/service-subpath-match-tsup");
43
+ const serviceNoWorkspaceRefs = require("./rules/service-no-workspace-refs");
44
+ const serviceExternalDeclared = require("./rules/service-external-declared");
45
+
46
+ // Service rules - Additional (New)
47
+ const serviceUseEventbus = require("./rules/service-use-eventbus");
48
+ const serviceNoInlineQueries = require("./rules/service-no-inline-queries");
49
+
35
50
  const plugin = {
36
51
  meta: {
37
52
  name: "@almadar/eslint-plugin",
@@ -47,6 +62,7 @@ const plugin = {
47
62
  "template-serializable-props": templateSerializableProps,
48
63
  "no-type-prop": noTypeProp,
49
64
  "no-as-any": noAsAny,
65
+ "no-entity-string-type": noEntityStringType,
50
66
  "no-import-generated": noImportGenerated,
51
67
  "no-use-navigate": noUseNavigate,
52
68
  "require-event-bus": requireEventBus,
@@ -61,8 +77,12 @@ const plugin = {
61
77
  "organism-no-callback-props": organismNoCallbackProps,
62
78
  "organism-extends-entity-display": organismExtendsEntityDisplay,
63
79
  "organism-rendering-state-only": organismRenderingStateOnly,
80
+ "organism-require-entity-row": organismRequireEntityRow,
64
81
 
65
- // Service rules
82
+ // Atom/molecule rules
83
+ "atom-molecule-no-entity-prop": atomMoleculeNoEntityProp,
84
+
85
+ // Service rules - Phase 1-3
66
86
  "service-lazy-singleton": serviceLazySingleton,
67
87
  "service-singleton-resettable": serviceSingletonResettable,
68
88
  "service-no-module-side-effects": serviceNoModuleSideEffects,
@@ -70,6 +90,16 @@ const plugin = {
70
90
  "service-event-name-screaming": serviceEventNameScreaming,
71
91
  "service-integration-extends-base": serviceIntegrationExtendsBase,
72
92
  "service-store-isolation": serviceStoreIsolation,
93
+
94
+ // Service rules - Phase 4
95
+ "service-barrel-complete": serviceBarrelComplete,
96
+ "service-subpath-match-tsup": serviceSubpathMatchTsup,
97
+ "service-no-workspace-refs": serviceNoWorkspaceRefs,
98
+ "service-external-declared": serviceExternalDeclared,
99
+
100
+ // Service rules - Additional
101
+ "service-use-eventbus": serviceUseEventbus,
102
+ "service-no-inline-queries": serviceNoInlineQueries,
73
103
  },
74
104
 
75
105
  configs: {},
@@ -90,8 +120,10 @@ plugin.configs.recommended = {
90
120
  "almadar/template-serializable-props": "error",
91
121
  "almadar/no-type-prop": "error",
92
122
  "almadar/no-as-any": "error",
123
+ "almadar/no-entity-string-type": "error",
93
124
  "almadar/no-import-generated": "error",
94
125
  "almadar/no-use-navigate": "error",
126
+ "almadar/atom-molecule-no-entity-prop": "error",
95
127
 
96
128
  // Warn-level — conventions that should be followed
97
129
  "almadar/require-event-bus": "warn",
@@ -108,6 +140,7 @@ plugin.configs.recommended = {
108
140
  "almadar/organism-no-callback-props": "error",
109
141
  "almadar/organism-extends-entity-display": "error",
110
142
  "almadar/organism-rendering-state-only": "warn",
143
+ "almadar/organism-require-entity-row": "error",
111
144
  },
112
145
  };
113
146
 
@@ -147,6 +180,7 @@ plugin.configs.organisms = {
147
180
  "almadar/organism-rendering-state-only": "warn",
148
181
  "almadar/require-event-bus": "error",
149
182
  "almadar/require-closed-circuit-props": "error",
183
+ "almadar/organism-require-entity-row": "error",
150
184
  },
151
185
  };
152
186
 
@@ -154,6 +188,7 @@ plugin.configs.organisms = {
154
188
  plugin.configs.services = {
155
189
  plugins: { almadar: plugin },
156
190
  rules: {
191
+ // Phase 1-3
157
192
  "almadar/service-lazy-singleton": "error",
158
193
  "almadar/service-singleton-resettable": "error",
159
194
  "almadar/service-no-module-side-effects": "error",
@@ -161,13 +196,22 @@ plugin.configs.services = {
161
196
  "almadar/service-event-name-screaming": "error",
162
197
  "almadar/service-integration-extends-base": "error",
163
198
  "almadar/service-store-isolation": "error",
199
+ // Phase 4
200
+ "almadar/service-barrel-complete": "warn",
201
+ "almadar/service-subpath-match-tsup": "warn",
202
+ "almadar/service-no-workspace-refs": "error",
203
+ "almadar/service-external-declared": "error",
204
+ // Additional
205
+ "almadar/service-use-eventbus": "warn",
206
+ "almadar/service-no-inline-queries": "error",
164
207
  },
165
208
  };
166
209
 
167
- // Services strict config — all service rules as error (same for now, diverges as warn-level rules are added)
210
+ // Services strict config — all service rules as error
168
211
  plugin.configs["services-strict"] = {
169
212
  plugins: { almadar: plugin },
170
213
  rules: {
214
+ // Phase 1-3
171
215
  "almadar/service-lazy-singleton": "error",
172
216
  "almadar/service-singleton-resettable": "error",
173
217
  "almadar/service-no-module-side-effects": "error",
@@ -175,6 +219,14 @@ plugin.configs["services-strict"] = {
175
219
  "almadar/service-event-name-screaming": "error",
176
220
  "almadar/service-integration-extends-base": "error",
177
221
  "almadar/service-store-isolation": "error",
222
+ // Phase 4
223
+ "almadar/service-barrel-complete": "error",
224
+ "almadar/service-subpath-match-tsup": "error",
225
+ "almadar/service-no-workspace-refs": "error",
226
+ "almadar/service-external-declared": "error",
227
+ // Additional
228
+ "almadar/service-use-eventbus": "error",
229
+ "almadar/service-no-inline-queries": "error",
178
230
  },
179
231
  };
180
232
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@almadar/eslint-plugin",
3
- "version": "2.2.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,84 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Rule: no-entity-string-type
5
+ *
6
+ * Prevents typing the `entity` prop as `string` in TypeScript interfaces and type aliases.
7
+ * Entity props must carry actual data (objects, arrays, or domain types), never string identifiers.
8
+ */
9
+
10
+ module.exports = {
11
+ meta: {
12
+ type: "problem",
13
+ docs: {
14
+ description:
15
+ "Disallow typing the 'entity' prop as 'string' — entity carries data, not a type name",
16
+ category: "Almadar Architecture",
17
+ },
18
+ messages: {
19
+ noEntityString:
20
+ "The 'entity' prop must not be typed as 'string'. Use a proper entity type (object, array, or domain interface) instead.",
21
+ },
22
+ schema: [],
23
+ },
24
+
25
+ create(context) {
26
+ /**
27
+ * Check if a type annotation contains 'string' at any level
28
+ * (direct TSStringKeyword, or inside a union/intersection).
29
+ */
30
+ function typeIncludesString(typeNode) {
31
+ if (!typeNode) return false;
32
+
33
+ // Direct string keyword
34
+ if (typeNode.type === "TSStringKeyword") return true;
35
+
36
+ // Union: string | Foo
37
+ if (typeNode.type === "TSUnionType") {
38
+ return typeNode.types.some(typeIncludesString);
39
+ }
40
+
41
+ // Intersection: string & Foo (unlikely but defensive)
42
+ if (typeNode.type === "TSIntersectionType") {
43
+ return typeNode.types.some(typeIncludesString);
44
+ }
45
+
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * Check a property signature (interface member or type literal member)
51
+ */
52
+ function checkPropertySignature(node) {
53
+ // Only check properties named "entity"
54
+ const key = node.key;
55
+ if (!key) return;
56
+
57
+ const propName =
58
+ key.type === "Identifier"
59
+ ? key.name
60
+ : key.type === "Literal"
61
+ ? String(key.value)
62
+ : null;
63
+
64
+ if (propName !== "entity") return;
65
+
66
+ // Check the type annotation
67
+ const typeAnnotation = node.typeAnnotation;
68
+ if (!typeAnnotation) return;
69
+
70
+ const typeNode =
71
+ typeAnnotation.type === "TSTypeAnnotation"
72
+ ? typeAnnotation.typeAnnotation
73
+ : typeAnnotation;
74
+
75
+ if (typeIncludesString(typeNode)) {
76
+ context.report({ node, messageId: "noEntityString" });
77
+ }
78
+ }
79
+
80
+ return {
81
+ TSPropertySignature: checkPropertySignature,
82
+ };
83
+ },
84
+ };
@@ -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
+ };
@@ -3,7 +3,7 @@
3
3
  const { isOrganism } = require("../utils/helpers");
4
4
 
5
5
  const REQUIRED_PROPS = ["className"];
6
- const RECOMMENDED_PROPS = ["isLoading", "error", "entity", "data", "sortBy", "sortDirection", "page", "pageSize", "totalCount", "searchValue"];
6
+ const RECOMMENDED_PROPS = ["isLoading", "error", "entity", "sortBy", "sortDirection", "page", "pageSize", "totalCount", "searchValue"];
7
7
 
8
8
  module.exports = {
9
9
  meta: {
@@ -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.
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * @fileoverview Rule to ensure all public functions are exported from barrel files (index.ts)
5
+ */
6
+
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+
10
+ module.exports = {
11
+ meta: {
12
+ type: "problem",
13
+ docs: {
14
+ description: "Ensure all public functions are exported from barrel files (index.ts)",
15
+ category: "Almadar Services",
16
+ recommended: true,
17
+ },
18
+ messages: {
19
+ missingFromBarrel: "Public export '{{name}}' from '{{source}}' is not re-exported in '{{barrel}}'.",
20
+ notExported: "Function '{{name}}' is declared but not exported. Add export keyword or include in index.ts.",
21
+ },
22
+ schema: [
23
+ {
24
+ type: "object",
25
+ properties: {
26
+ barrelFile: {
27
+ type: "string",
28
+ default: "index.ts",
29
+ },
30
+ ignorePrivate: {
31
+ type: "boolean",
32
+ default: true,
33
+ },
34
+ },
35
+ additionalProperties: false,
36
+ },
37
+ ],
38
+ },
39
+
40
+ create(context) {
41
+ const options = context.options[0] || {};
42
+ const barrelFileName = options.barrelFile || "index.ts";
43
+ void options.ignorePrivate;
44
+
45
+ const filename = context.filename;
46
+ const dir = path.dirname(filename);
47
+ const barrelPath = path.join(dir, barrelFileName);
48
+
49
+ // Skip if this IS the barrel file itself
50
+ if (path.basename(filename) === barrelFileName) {
51
+ return {};
52
+ }
53
+
54
+ // Skip if no barrel file exists
55
+ if (!fs.existsSync(barrelPath)) {
56
+ return {};
57
+ }
58
+
59
+ // Read barrel file content
60
+ let barrelContent = "";
61
+ try {
62
+ barrelContent = fs.readFileSync(barrelPath, "utf-8");
63
+ } catch {
64
+ return {};
65
+ }
66
+
67
+ // Track exports in this file
68
+ const fileExports = new Set();
69
+
70
+ return {
71
+ // Export declarations: export function foo() {}
72
+ ExportNamedDeclaration(node) {
73
+ if (node.declaration) {
74
+ if (node.declaration.type === "FunctionDeclaration" && node.declaration.id) {
75
+ fileExports.add(node.declaration.id.name);
76
+ } else if (node.declaration.type === "VariableDeclaration") {
77
+ node.declaration.declarations.forEach((decl) => {
78
+ if (decl.id && decl.id.type === "Identifier") {
79
+ fileExports.add(decl.id.name);
80
+ }
81
+ });
82
+ } else if (node.declaration.type === "ClassDeclaration" && node.declaration.id) {
83
+ fileExports.add(node.declaration.id.name);
84
+ }
85
+ }
86
+ // Handle: export { foo, bar }
87
+ if (node.specifiers) {
88
+ node.specifiers.forEach((spec) => {
89
+ if (spec.exported && spec.exported.type === "Identifier") {
90
+ fileExports.add(spec.exported.name);
91
+ }
92
+ });
93
+ }
94
+ },
95
+
96
+ // Export default: export default foo
97
+ ExportDefaultDeclaration(node) {
98
+ if (node.declaration && node.declaration.type === "Identifier") {
99
+ fileExports.add(node.declaration.name);
100
+ }
101
+ },
102
+
103
+ // Check function declarations at end of file
104
+ "Program:exit"() {
105
+ fileExports.forEach((name) => {
106
+ // Check if this export is in the barrel file
107
+ // Look for: export { name } or export * from './this-file'
108
+ const exportPattern = new RegExp(`export\\s*\\{[^}]*\\b${name}\\b`, "i");
109
+ const starExportPattern = new RegExp(`export\\s*\\*\\s*from\\s*['"]\\.\\/${path.basename(filename, path.extname(filename))}['"]`, "i");
110
+
111
+ const isInBarrel = exportPattern.test(barrelContent) ||
112
+ starExportPattern.test(barrelContent);
113
+
114
+ if (!isInBarrel) {
115
+ context.report({
116
+ node: context.getSourceCode().ast,
117
+ messageId: "missingFromBarrel",
118
+ data: {
119
+ name,
120
+ source: path.basename(filename),
121
+ barrel: barrelFileName,
122
+ },
123
+ });
124
+ }
125
+ });
126
+ },
127
+ };
128
+ },
129
+ };
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * @fileoverview Rule to ensure all imports are declared in package.json dependencies
5
+ */
6
+
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+
10
+ module.exports = {
11
+ meta: {
12
+ type: "problem",
13
+ docs: {
14
+ description: "Ensure all imports are declared in package.json dependencies",
15
+ category: "Almadar Services",
16
+ recommended: true,
17
+ },
18
+ messages: {
19
+ undeclaredImport: "Import '{{package}}' is not declared in package.json dependencies. Add to dependencies, devDependencies, or peerDependencies.",
20
+ missingPackageJson: "Could not find package.json to validate imports.",
21
+ },
22
+ schema: [
23
+ {
24
+ type: "object",
25
+ properties: {
26
+ allowBuiltin: {
27
+ type: "boolean",
28
+ default: true,
29
+ },
30
+ allowInternal: {
31
+ type: "boolean",
32
+ default: true,
33
+ },
34
+ },
35
+ additionalProperties: false,
36
+ },
37
+ ],
38
+ },
39
+
40
+ create(context) {
41
+ const options = context.options[0] || {};
42
+ const allowBuiltin = options.allowBuiltin !== false;
43
+ const allowInternal = options.allowInternal !== false;
44
+
45
+ // Find package.json
46
+ let dir = path.dirname(context.filename);
47
+ let pkgPath = null;
48
+ let pkg = null;
49
+
50
+ while (dir !== path.dirname(dir)) {
51
+ const candidate = path.join(dir, "package.json");
52
+ if (fs.existsSync(candidate)) {
53
+ pkgPath = candidate;
54
+ break;
55
+ }
56
+ dir = path.dirname(dir);
57
+ }
58
+
59
+ if (!pkgPath) {
60
+ return {};
61
+ }
62
+
63
+ try {
64
+ pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
65
+ } catch {
66
+ return {};
67
+ }
68
+
69
+ // Collect all declared dependencies
70
+ const declaredDeps = new Set();
71
+ ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"].forEach((field) => {
72
+ if (pkg[field]) {
73
+ Object.keys(pkg[field]).forEach((dep) => declaredDeps.add(dep));
74
+ }
75
+ });
76
+
77
+ // Node.js built-in modules
78
+ const builtinModules = new Set([
79
+ "assert", "buffer", "child_process", "cluster", "console", "constants",
80
+ "crypto", "dgram", "dns", "domain", "events", "fs", "http", "https",
81
+ "module", "net", "os", "path", "punycode", "querystring", "readline",
82
+ "repl", "stream", "string_decoder", "sys", "timers", "tls", "tty",
83
+ "url", "util", "v8", "vm", "zlib", "worker_threads", "perf_hooks",
84
+ "async_hooks", "http2", "diagnostics_channel", "stream/web",
85
+ ]);
86
+
87
+ return {
88
+ ImportDeclaration(node) {
89
+ const source = node.source.value;
90
+
91
+ // Skip relative imports
92
+ if (source.startsWith(".") || source.startsWith("/")) {
93
+ return;
94
+ }
95
+
96
+ // Skip internal package imports (e.g., @almadar/* in monorepo)
97
+ if (allowInternal && source.startsWith("@almadar/")) {
98
+ // Only check if it's actually in the monorepo
99
+ // For now, assume internal packages are allowed
100
+ return;
101
+ }
102
+
103
+ // Extract package name (handle @scope/pkg and scoped packages with subpaths)
104
+ let packageName = source;
105
+ if (source.startsWith("@")) {
106
+ // @scope/pkg or @scope/pkg/subpath
107
+ const parts = source.split("/");
108
+ packageName = `${parts[0]}/${parts[1]}`;
109
+ } else {
110
+ // pkg or pkg/subpath
111
+ packageName = source.split("/")[0];
112
+ }
113
+
114
+ // Skip built-in modules
115
+ if (allowBuiltin && builtinModules.has(packageName)) {
116
+ return;
117
+ }
118
+
119
+ // Skip Node: prefixed imports
120
+ if (packageName.startsWith("node:")) {
121
+ return;
122
+ }
123
+
124
+ // Check if declared
125
+ if (!declaredDeps.has(packageName)) {
126
+ context.report({
127
+ node,
128
+ messageId: "undeclaredImport",
129
+ data: { package: packageName },
130
+ });
131
+ }
132
+ },
133
+
134
+ // Handle require() calls
135
+ CallExpression(node) {
136
+ if (
137
+ node.callee.type === "Identifier" &&
138
+ node.callee.name === "require" &&
139
+ node.arguments.length > 0 &&
140
+ node.arguments[0].type === "Literal" &&
141
+ typeof node.arguments[0].value === "string"
142
+ ) {
143
+ const source = node.arguments[0].value;
144
+
145
+ if (source.startsWith(".") || source.startsWith("/")) {
146
+ return;
147
+ }
148
+
149
+ if (allowInternal && source.startsWith("@almadar/")) {
150
+ return;
151
+ }
152
+
153
+ let packageName = source;
154
+ if (source.startsWith("@")) {
155
+ const parts = source.split("/");
156
+ packageName = `${parts[0]}/${parts[1]}`;
157
+ } else {
158
+ packageName = source.split("/")[0];
159
+ }
160
+
161
+ if (!declaredDeps.has(packageName)) {
162
+ context.report({
163
+ node,
164
+ messageId: "undeclaredImport",
165
+ data: { package: packageName },
166
+ });
167
+ }
168
+ }
169
+ },
170
+ };
171
+ },
172
+ };
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * @fileoverview Rule to prevent inline Firestore queries in route handlers
5
+ */
6
+
7
+ module.exports = {
8
+ meta: {
9
+ type: "problem",
10
+ docs: {
11
+ description: "Prevent inline Firestore queries embedded in route handlers",
12
+ category: "Almadar Services",
13
+ recommended: true,
14
+ },
15
+ messages: {
16
+ inlineFirestore: "Inline Firestore query detected. Extract to DataService or use StoreContract abstraction.",
17
+ directDbAccess: "Direct database access in route handler. Use service layer (DataService) instead.",
18
+ firestoreImport: "Firestore imports should not be used directly in route files. Use service abstraction.",
19
+ },
20
+ schema: [
21
+ {
22
+ type: "object",
23
+ properties: {
24
+ routePatterns: {
25
+ type: "array",
26
+ items: { type: "string" },
27
+ default: ["routes/", "src/routes/", "api/"],
28
+ },
29
+ },
30
+ additionalProperties: false,
31
+ },
32
+ ],
33
+ },
34
+
35
+ create(context) {
36
+ const options = context.options[0] || {};
37
+ const routePatterns = options.routePatterns || ["routes/", "src/routes/", "api/"];
38
+
39
+ const filename = context.filename;
40
+
41
+ // Check if this is a route file
42
+ const isRouteFile = routePatterns.some((pattern) => filename.includes(pattern));
43
+
44
+ // Also check if it looks like a route handler (contains route handler patterns)
45
+ let hasRouteHandlerPattern = false;
46
+
47
+ return {
48
+ // Check for Firestore imports
49
+ ImportDeclaration(node) {
50
+ const source = node.source.value;
51
+
52
+ if (source.includes("firebase-admin") || source.includes("@google-cloud/firestore")) {
53
+ if (isRouteFile) {
54
+ context.report({
55
+ node,
56
+ messageId: "firestoreImport",
57
+ });
58
+ }
59
+ }
60
+ },
61
+
62
+ // Check for Express route handler patterns
63
+ "CallExpression[callee.object.name='app'][callee.property.name=/^(get|post|put|delete|patch)$/]"(_node) {
64
+ hasRouteHandlerPattern = true;
65
+ },
66
+
67
+ "CallExpression[callee.object.name='router'][callee.property.name=/^(get|post|put|delete|patch)$/]"(_node) {
68
+ hasRouteHandlerPattern = true;
69
+ },
70
+
71
+ // Check for Firestore method calls
72
+ "CallExpression[callee.type='MemberExpression']"(node) {
73
+ const callee = node.callee;
74
+
75
+ // Check for db.collection(), doc.get(), etc.
76
+ if (callee.property && callee.property.type === "Identifier") {
77
+ const methodName = callee.property.name;
78
+
79
+ const firestoreMethods = [
80
+ "collection", "doc", "get", "set", "update", "delete",
81
+ "where", "orderBy", "limit", "startAfter", "endBefore",
82
+ "add", "batch", "runTransaction"
83
+ ];
84
+
85
+ if (firestoreMethods.includes(methodName)) {
86
+ // Check if this is being called on a Firestore-like object
87
+ const objectName = callee.object.name ||
88
+ (callee.object.property && callee.object.property.name);
89
+
90
+ const firestoreObjectNames = ["db", "firestore", "admin", "firestoreDb", "firebase"];
91
+
92
+ if (objectName && firestoreObjectNames.some(name =>
93
+ objectName.toLowerCase().includes(name)
94
+ )) {
95
+ if (isRouteFile || hasRouteHandlerPattern) {
96
+ context.report({
97
+ node,
98
+ messageId: "inlineFirestore",
99
+ });
100
+ }
101
+ }
102
+ }
103
+ }
104
+ },
105
+
106
+ // Check for direct db access patterns
107
+ "MemberExpression[object.name='db']"(node) {
108
+ if (isRouteFile || hasRouteHandlerPattern) {
109
+ context.report({
110
+ node,
111
+ messageId: "directDbAccess",
112
+ });
113
+ }
114
+ },
115
+ };
116
+ },
117
+ };
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * @fileoverview Rule to prevent workspace:* references in published packages
5
+ */
6
+
7
+ module.exports = {
8
+ meta: {
9
+ type: "problem",
10
+ docs: {
11
+ description: "Prevent workspace:* references in published package.json dependencies",
12
+ category: "Almadar Services",
13
+ recommended: true,
14
+ },
15
+ messages: {
16
+ workspaceRef: "Workspace reference '{{ref}}' found in '{{field}}'. Use explicit version for published packages.",
17
+ workspaceProtocol: "workspace: protocol found in '{{field}}'. This will fail when package is published.",
18
+ },
19
+ schema: [],
20
+ },
21
+
22
+ create(context) {
23
+ // Only check package.json files
24
+ if (!context.filename.endsWith("package.json")) {
25
+ return {};
26
+ }
27
+
28
+ return {
29
+ Program(node) {
30
+ const sourceCode = context.getSourceCode();
31
+ const text = sourceCode.getText();
32
+
33
+ let pkg;
34
+ try {
35
+ pkg = JSON.parse(text);
36
+ } catch {
37
+ return;
38
+ }
39
+
40
+ // Check if this is a private package
41
+ if (pkg.private === true) {
42
+ return;
43
+ }
44
+
45
+ // Check all dependency fields
46
+ const depFields = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
47
+
48
+ for (const field of depFields) {
49
+ const deps = pkg[field];
50
+ if (!deps || typeof deps !== "object") {
51
+ continue;
52
+ }
53
+
54
+ for (const [, version] of Object.entries(deps)) {
55
+ // Check for workspace:*
56
+ if (version === "workspace:*" || version === "workspace:") {
57
+ context.report({
58
+ node,
59
+ messageId: "workspaceRef",
60
+ data: { ref: version, field },
61
+ });
62
+ }
63
+ // Check for any workspace: protocol
64
+ if (typeof version === "string" && version.startsWith("workspace:")) {
65
+ context.report({
66
+ node,
67
+ messageId: "workspaceProtocol",
68
+ data: { field },
69
+ });
70
+ }
71
+ }
72
+ }
73
+ },
74
+ };
75
+ },
76
+ };
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * @fileoverview Rule to ensure package.json exports match tsup build configuration
5
+ */
6
+
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+
10
+ module.exports = {
11
+ meta: {
12
+ type: "problem",
13
+ docs: {
14
+ description: "Ensure package.json exports match tsup build entries",
15
+ category: "Almadar Services",
16
+ recommended: true,
17
+ },
18
+ messages: {
19
+ exportMismatch: "package.json export '{{exportPath}}' does not match any tsup entry.",
20
+ missingExport: "tsup entry '{{entry}}' is not exported in package.json.",
21
+ invalidPattern: "Export pattern '{{pattern}}' does not match expected format (./dist/*.js).",
22
+ },
23
+ schema: [],
24
+ },
25
+
26
+ create(context) {
27
+ // Only check package.json files
28
+ if (!context.filename.endsWith("package.json")) {
29
+ return {};
30
+ }
31
+
32
+ const dir = path.dirname(context.filename);
33
+ const tsupConfigPath = path.join(dir, "tsup.config.ts");
34
+
35
+ // Skip if no tsup config
36
+ if (!fs.existsSync(tsupConfigPath)) {
37
+ return {};
38
+ }
39
+
40
+ return {
41
+ Program(node) {
42
+ const sourceCode = context.getSourceCode();
43
+ const text = sourceCode.getText();
44
+
45
+ let pkg;
46
+ try {
47
+ pkg = JSON.parse(text);
48
+ } catch {
49
+ return;
50
+ }
51
+
52
+ // Read tsup config (simplified - just check if entries exist)
53
+ let tsupContent = "";
54
+ try {
55
+ tsupContent = fs.readFileSync(tsupConfigPath, "utf-8");
56
+ } catch {
57
+ return;
58
+ }
59
+
60
+ // Extract entry points from tsup config
61
+ // Look for: entry: ['src/index.ts'] or entry: { main: 'src/index.ts' }
62
+ const entryMatch = tsupContent.match(/entry\s*:\s*(\[[^\]]+\]|\{[^}]+\})/);
63
+ if (!entryMatch) {
64
+ return;
65
+ }
66
+
67
+ const exports = pkg.exports || {};
68
+
69
+ // Check each export has a corresponding tsup entry
70
+ for (const [exportPath] of Object.entries(exports)) {
71
+ if (exportPath.startsWith("./")) {
72
+ // Check if there's a matching entry in tsup
73
+ const hasMatchingEntry = tsupContent.includes(exportPath.replace(/^\.\//, "src/")) ||
74
+ tsupContent.includes(exportPath.replace(/^\.\//, "")) ||
75
+ (exportPath === "." && tsupContent.includes("src/index"));
76
+
77
+ if (!hasMatchingEntry) {
78
+ context.report({
79
+ node,
80
+ messageId: "exportMismatch",
81
+ data: { exportPath },
82
+ });
83
+ }
84
+ }
85
+ }
86
+ },
87
+ };
88
+ },
89
+ };
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * @fileoverview Rule to enforce EventBus usage over direct function calls between modules
5
+ */
6
+
7
+ const path = require("path");
8
+
9
+ module.exports = {
10
+ meta: {
11
+ type: "suggestion",
12
+ docs: {
13
+ description: "Enforce EventBus usage over direct function calls between modules",
14
+ category: "Almadar Services",
15
+ recommended: true,
16
+ },
17
+ messages: {
18
+ directCall: "Cross-module function call to '{{function}}' from '{{module}}'. Consider using EventBus for loose coupling.",
19
+ directImport: "Direct import from service module '{{module}}'. Use EventBus or service factory instead.",
20
+ useEventBus: "Use getGlobalEventBus() or eventBus.emit() for cross-module communication instead of direct function calls.",
21
+ },
22
+ schema: [
23
+ {
24
+ type: "object",
25
+ properties: {
26
+ servicePaths: {
27
+ type: "array",
28
+ items: { type: "string" },
29
+ default: ["src/services/", "src/lib/"],
30
+ },
31
+ allowSameDirectory: {
32
+ type: "boolean",
33
+ default: true,
34
+ },
35
+ },
36
+ additionalProperties: false,
37
+ },
38
+ ],
39
+ },
40
+
41
+ create(context) {
42
+ const options = context.options[0] || {};
43
+ const servicePaths = options.servicePaths || ["src/services/", "src/lib/"];
44
+ const allowSameDirectory = options.allowSameDirectory !== false;
45
+
46
+ const filename = context.filename;
47
+ const currentDir = path.dirname(filename);
48
+
49
+ // Track imports from service directories
50
+ const serviceImports = new Map();
51
+
52
+ return {
53
+ ImportDeclaration(node) {
54
+ const source = node.source.value;
55
+
56
+ // Check if importing from a service path
57
+ const isServiceImport = servicePaths.some(sp =>
58
+ source.includes(sp) || source.includes("/services/")
59
+ );
60
+
61
+ if (!isServiceImport) {
62
+ return;
63
+ }
64
+
65
+ // Allow same directory imports
66
+ if (allowSameDirectory) {
67
+ const importDir = path.dirname(path.resolve(currentDir, source));
68
+ if (importDir === currentDir) {
69
+ return;
70
+ }
71
+ }
72
+
73
+ // Track the imported bindings
74
+ node.specifiers.forEach((spec) => {
75
+ if (spec.type === "ImportSpecifier" && spec.imported) {
76
+ serviceImports.set(spec.local.name, {
77
+ source,
78
+ imported: spec.imported.name,
79
+ });
80
+ } else if (spec.type === "ImportDefaultSpecifier") {
81
+ serviceImports.set(spec.local.name, {
82
+ source,
83
+ imported: "default",
84
+ });
85
+ } else if (spec.type === "ImportNamespaceSpecifier") {
86
+ serviceImports.set(spec.local.name, {
87
+ source,
88
+ imported: "*",
89
+ });
90
+ }
91
+ });
92
+
93
+ // Report direct service imports
94
+ if (source.includes("/services/") && !source.startsWith(".")) {
95
+ context.report({
96
+ node,
97
+ messageId: "directImport",
98
+ data: { module: source },
99
+ });
100
+ }
101
+ },
102
+
103
+ // Check for direct function calls to imported service functions
104
+ CallExpression(node) {
105
+ if (node.callee.type === "Identifier") {
106
+ const name = node.callee.name;
107
+ const importInfo = serviceImports.get(name);
108
+
109
+ if (importInfo && importInfo.source.includes("/services/")) {
110
+ context.report({
111
+ node,
112
+ messageId: "directCall",
113
+ data: {
114
+ function: name,
115
+ module: importInfo.source,
116
+ },
117
+ });
118
+ }
119
+ }
120
+
121
+ // Check for member expressions: Service.method()
122
+ if (
123
+ node.callee.type === "MemberExpression" &&
124
+ node.callee.object.type === "Identifier"
125
+ ) {
126
+ const objectName = node.callee.object.name;
127
+ const importInfo = serviceImports.get(objectName);
128
+
129
+ if (importInfo && importInfo.source.includes("/services/")) {
130
+ const methodName = node.callee.property.name || node.callee.property.value;
131
+ context.report({
132
+ node,
133
+ messageId: "directCall",
134
+ data: {
135
+ function: `${objectName}.${methodName}`,
136
+ module: importInfo.source,
137
+ },
138
+ });
139
+ }
140
+ }
141
+ },
142
+ };
143
+ },
144
+ };