@almadar/eslint-plugin 2.2.0 → 2.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/index.js +44 -3
- package/package.json +1 -1
- package/rules/no-entity-string-type.js +84 -0
- package/rules/require-closed-circuit-props.js +1 -1
- package/rules/service-barrel-complete.js +129 -0
- package/rules/service-external-declared.js +172 -0
- package/rules/service-no-inline-queries.js +117 -0
- package/rules/service-no-workspace-refs.js +76 -0
- package/rules/service-subpath-match-tsup.js +93 -0
- package/rules/service-use-eventbus.js +144 -0
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");
|
|
@@ -23,7 +24,7 @@ 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");
|
|
25
26
|
|
|
26
|
-
// Service rules
|
|
27
|
+
// Service rules - Phase 1-3 (Implemented)
|
|
27
28
|
const serviceLazySingleton = require("./rules/service-lazy-singleton");
|
|
28
29
|
const serviceSingletonResettable = require("./rules/service-singleton-resettable");
|
|
29
30
|
const serviceNoModuleSideEffects = require("./rules/service-no-module-side-effects");
|
|
@@ -32,6 +33,16 @@ const serviceEventNameScreaming = require("./rules/service-event-name-screaming"
|
|
|
32
33
|
const serviceIntegrationExtendsBase = require("./rules/service-integration-extends-base");
|
|
33
34
|
const serviceStoreIsolation = require("./rules/service-store-isolation");
|
|
34
35
|
|
|
36
|
+
// Service rules - Phase 4 (New)
|
|
37
|
+
const serviceBarrelComplete = require("./rules/service-barrel-complete");
|
|
38
|
+
const serviceSubpathMatchTsup = require("./rules/service-subpath-match-tsup");
|
|
39
|
+
const serviceNoWorkspaceRefs = require("./rules/service-no-workspace-refs");
|
|
40
|
+
const serviceExternalDeclared = require("./rules/service-external-declared");
|
|
41
|
+
|
|
42
|
+
// Service rules - Additional (New)
|
|
43
|
+
const serviceUseEventbus = require("./rules/service-use-eventbus");
|
|
44
|
+
const serviceNoInlineQueries = require("./rules/service-no-inline-queries");
|
|
45
|
+
|
|
35
46
|
const plugin = {
|
|
36
47
|
meta: {
|
|
37
48
|
name: "@almadar/eslint-plugin",
|
|
@@ -47,6 +58,7 @@ const plugin = {
|
|
|
47
58
|
"template-serializable-props": templateSerializableProps,
|
|
48
59
|
"no-type-prop": noTypeProp,
|
|
49
60
|
"no-as-any": noAsAny,
|
|
61
|
+
"no-entity-string-type": noEntityStringType,
|
|
50
62
|
"no-import-generated": noImportGenerated,
|
|
51
63
|
"no-use-navigate": noUseNavigate,
|
|
52
64
|
"require-event-bus": requireEventBus,
|
|
@@ -62,7 +74,7 @@ const plugin = {
|
|
|
62
74
|
"organism-extends-entity-display": organismExtendsEntityDisplay,
|
|
63
75
|
"organism-rendering-state-only": organismRenderingStateOnly,
|
|
64
76
|
|
|
65
|
-
// Service rules
|
|
77
|
+
// Service rules - Phase 1-3
|
|
66
78
|
"service-lazy-singleton": serviceLazySingleton,
|
|
67
79
|
"service-singleton-resettable": serviceSingletonResettable,
|
|
68
80
|
"service-no-module-side-effects": serviceNoModuleSideEffects,
|
|
@@ -70,6 +82,16 @@ const plugin = {
|
|
|
70
82
|
"service-event-name-screaming": serviceEventNameScreaming,
|
|
71
83
|
"service-integration-extends-base": serviceIntegrationExtendsBase,
|
|
72
84
|
"service-store-isolation": serviceStoreIsolation,
|
|
85
|
+
|
|
86
|
+
// Service rules - Phase 4
|
|
87
|
+
"service-barrel-complete": serviceBarrelComplete,
|
|
88
|
+
"service-subpath-match-tsup": serviceSubpathMatchTsup,
|
|
89
|
+
"service-no-workspace-refs": serviceNoWorkspaceRefs,
|
|
90
|
+
"service-external-declared": serviceExternalDeclared,
|
|
91
|
+
|
|
92
|
+
// Service rules - Additional
|
|
93
|
+
"service-use-eventbus": serviceUseEventbus,
|
|
94
|
+
"service-no-inline-queries": serviceNoInlineQueries,
|
|
73
95
|
},
|
|
74
96
|
|
|
75
97
|
configs: {},
|
|
@@ -90,6 +112,7 @@ plugin.configs.recommended = {
|
|
|
90
112
|
"almadar/template-serializable-props": "error",
|
|
91
113
|
"almadar/no-type-prop": "error",
|
|
92
114
|
"almadar/no-as-any": "error",
|
|
115
|
+
"almadar/no-entity-string-type": "error",
|
|
93
116
|
"almadar/no-import-generated": "error",
|
|
94
117
|
"almadar/no-use-navigate": "error",
|
|
95
118
|
|
|
@@ -154,6 +177,7 @@ plugin.configs.organisms = {
|
|
|
154
177
|
plugin.configs.services = {
|
|
155
178
|
plugins: { almadar: plugin },
|
|
156
179
|
rules: {
|
|
180
|
+
// Phase 1-3
|
|
157
181
|
"almadar/service-lazy-singleton": "error",
|
|
158
182
|
"almadar/service-singleton-resettable": "error",
|
|
159
183
|
"almadar/service-no-module-side-effects": "error",
|
|
@@ -161,13 +185,22 @@ plugin.configs.services = {
|
|
|
161
185
|
"almadar/service-event-name-screaming": "error",
|
|
162
186
|
"almadar/service-integration-extends-base": "error",
|
|
163
187
|
"almadar/service-store-isolation": "error",
|
|
188
|
+
// Phase 4
|
|
189
|
+
"almadar/service-barrel-complete": "warn",
|
|
190
|
+
"almadar/service-subpath-match-tsup": "warn",
|
|
191
|
+
"almadar/service-no-workspace-refs": "error",
|
|
192
|
+
"almadar/service-external-declared": "error",
|
|
193
|
+
// Additional
|
|
194
|
+
"almadar/service-use-eventbus": "warn",
|
|
195
|
+
"almadar/service-no-inline-queries": "error",
|
|
164
196
|
},
|
|
165
197
|
};
|
|
166
198
|
|
|
167
|
-
// Services strict config — all service rules as error
|
|
199
|
+
// Services strict config — all service rules as error
|
|
168
200
|
plugin.configs["services-strict"] = {
|
|
169
201
|
plugins: { almadar: plugin },
|
|
170
202
|
rules: {
|
|
203
|
+
// Phase 1-3
|
|
171
204
|
"almadar/service-lazy-singleton": "error",
|
|
172
205
|
"almadar/service-singleton-resettable": "error",
|
|
173
206
|
"almadar/service-no-module-side-effects": "error",
|
|
@@ -175,6 +208,14 @@ plugin.configs["services-strict"] = {
|
|
|
175
208
|
"almadar/service-event-name-screaming": "error",
|
|
176
209
|
"almadar/service-integration-extends-base": "error",
|
|
177
210
|
"almadar/service-store-isolation": "error",
|
|
211
|
+
// Phase 4
|
|
212
|
+
"almadar/service-barrel-complete": "error",
|
|
213
|
+
"almadar/service-subpath-match-tsup": "error",
|
|
214
|
+
"almadar/service-no-workspace-refs": "error",
|
|
215
|
+
"almadar/service-external-declared": "error",
|
|
216
|
+
// Additional
|
|
217
|
+
"almadar/service-use-eventbus": "error",
|
|
218
|
+
"almadar/service-no-inline-queries": "error",
|
|
178
219
|
},
|
|
179
220
|
};
|
|
180
221
|
|
package/package.json
CHANGED
|
@@ -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
|
+
};
|
|
@@ -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", "
|
|
6
|
+
const RECOMMENDED_PROPS = ["isLoading", "error", "entity", "sortBy", "sortDirection", "page", "pageSize", "totalCount", "searchValue"];
|
|
7
7
|
|
|
8
8
|
module.exports = {
|
|
9
9
|
meta: {
|
|
@@ -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
|
+
const ignorePrivate = options.ignorePrivate !== false;
|
|
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 (err) {
|
|
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 [name, 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,93 @@
|
|
|
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, exportConfig] of Object.entries(exports)) {
|
|
71
|
+
if (exportPath.startsWith("./")) {
|
|
72
|
+
const expectedPattern = exportPath === "."
|
|
73
|
+
? "index.js"
|
|
74
|
+
: `${exportPath.replace(/^\.\//, "/")}.js`;
|
|
75
|
+
|
|
76
|
+
// Check if there's a matching entry in tsup
|
|
77
|
+
const hasMatchingEntry = tsupContent.includes(exportPath.replace(/^\.\//, "src/")) ||
|
|
78
|
+
tsupContent.includes(exportPath.replace(/^\.\//, "")) ||
|
|
79
|
+
(exportPath === "." && tsupContent.includes("src/index"));
|
|
80
|
+
|
|
81
|
+
if (!hasMatchingEntry) {
|
|
82
|
+
context.report({
|
|
83
|
+
node,
|
|
84
|
+
messageId: "exportMismatch",
|
|
85
|
+
data: { exportPath },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
};
|
|
@@ -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
|
+
};
|