@almadar/eslint-plugin 2.1.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.
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ const { isOrganism, isStoryFile } = require("../utils/helpers");
3
+
4
+ module.exports = {
5
+ meta: {
6
+ type: "problem",
7
+ docs: {
8
+ description: "Disallow callback props (onXxx) in organism interfaces",
9
+ category: "Almadar Architecture",
10
+ },
11
+ messages: {
12
+ noCallbackProp: "Organism prop '{{propName}}' in '{{interfaceName}}' is a callback. Use a declarative event string prop instead (e.g., '{{suggestion}}').",
13
+ },
14
+ schema: [],
15
+ },
16
+ create(context) {
17
+ const filename = context.filename || context.getFilename();
18
+ if (!isOrganism(filename) || isStoryFile(filename)) return {};
19
+
20
+ // Whitelist Form.tsx (needs onFieldChange)
21
+ if (/\/Form\.tsx$/.test(filename)) return {};
22
+
23
+ const CALLBACK_PATTERN = /^on[A-Z]/;
24
+ const SUGGESTION_MAP = {
25
+ onSort: "sortEvent",
26
+ onSearch: "searchEvent",
27
+ onPageChange: "paginateEvent",
28
+ onSelectionChange: "selectEvent",
29
+ onRowClick: "viewEvent or navigatesTo",
30
+ onCardClick: "viewEvent or navigatesTo",
31
+ onItemAction: "itemActions[].event",
32
+ onClose: "closeEvent",
33
+ };
34
+
35
+ return {
36
+ TSPropertySignature(node) {
37
+ // Only check inside *Props interfaces
38
+ const parent = node.parent;
39
+ if (parent?.type !== "TSInterfaceBody") return;
40
+ const interfaceNode = parent.parent;
41
+ if (interfaceNode?.type !== "TSInterfaceDeclaration") return;
42
+ const interfaceName = interfaceNode.id?.name;
43
+ if (!interfaceName?.endsWith("Props")) return;
44
+
45
+ const propName = node.key?.type === "Identifier" ? node.key.name : null;
46
+ if (!propName || !CALLBACK_PATTERN.test(propName)) return;
47
+
48
+ const suggestion = SUGGESTION_MAP[propName] || propName.replace(/^on/, "").toLowerCase() + "Event";
49
+ context.report({
50
+ node,
51
+ messageId: "noCallbackProp",
52
+ data: { propName, interfaceName, suggestion },
53
+ });
54
+ },
55
+ };
56
+ },
57
+ };
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ const { isOrganism, isStoryFile } = require("../utils/helpers");
3
+
4
+ module.exports = {
5
+ meta: {
6
+ type: "problem",
7
+ docs: {
8
+ description: "Disallow data-related useState in organisms",
9
+ category: "Almadar Architecture",
10
+ },
11
+ messages: {
12
+ noDataState: "Organism '{{component}}' must not manage '{{stateName}}' state internally. Receive it via props from the trait's render-ui instead.",
13
+ },
14
+ schema: [],
15
+ },
16
+ create(context) {
17
+ const filename = context.filename || context.getFilename();
18
+ if (!isOrganism(filename) || isStoryFile(filename)) return {};
19
+
20
+ // Whitelist Form.tsx
21
+ if (/\/Form\.tsx$/.test(filename)) return {};
22
+
23
+ const DATA_STATE_PATTERN = /^(sort|filter|search|page|pagination|selected|query|paginationParams|searchTerm|searchQuery|filters)/i;
24
+
25
+ return {
26
+ VariableDeclarator(node) {
27
+ // Match: const [stateName, setStateName] = useState(...)
28
+ if (
29
+ node.init?.type === "CallExpression" &&
30
+ node.init.callee?.type === "Identifier" &&
31
+ node.init.callee.name === "useState" &&
32
+ node.id?.type === "ArrayPattern" &&
33
+ node.id.elements?.[0]?.type === "Identifier"
34
+ ) {
35
+ const stateName = node.id.elements[0].name;
36
+ if (DATA_STATE_PATTERN.test(stateName)) {
37
+ // Derive component name from filename
38
+ const match = filename.match(/\/([^/]+)\.tsx?$/);
39
+ const component = match ? match[1] : "Unknown";
40
+ context.report({
41
+ node,
42
+ messageId: "noDataState",
43
+ data: { stateName, component },
44
+ });
45
+ }
46
+ }
47
+ },
48
+ };
49
+ },
50
+ };
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ const { isStoryFile } = require("../utils/helpers");
3
+
4
+ function isGameOrganism(filename) {
5
+ return /\/organisms\/game\//.test(filename);
6
+ }
7
+
8
+ module.exports = {
9
+ meta: {
10
+ type: "suggestion",
11
+ docs: {
12
+ description: "Warn if game organisms have non-rendering useState",
13
+ category: "Almadar Architecture",
14
+ },
15
+ messages: {
16
+ nonRenderingState: "Game organism useState '{{stateName}}' does not match a rendering-only pattern. Extract game-logic state to a hook.",
17
+ },
18
+ schema: [],
19
+ },
20
+ create(context) {
21
+ const filename = context.filename || context.getFilename();
22
+ if (!isGameOrganism(filename) || isStoryFile(filename)) return {};
23
+
24
+ const RENDERING_ONLY_PATTERN = /^(hover|isHover|isDrag|isShak|tooltip|viewport|moving|displayed|isTyp|phase|visible|flash|shakeOffset|internalError|activeButtons|activeDirections|isPressed|openActionMenu|canvas|camera|animate|render|frame|scroll|zoom|pan|cursor|highlight|glow|fade|opacity|offset|position|dimension|scale|rotation)/i;
25
+
26
+ return {
27
+ VariableDeclarator(node) {
28
+ if (
29
+ node.init?.type === "CallExpression" &&
30
+ node.init.callee?.type === "Identifier" &&
31
+ node.init.callee.name === "useState" &&
32
+ node.id?.type === "ArrayPattern" &&
33
+ node.id.elements?.[0]?.type === "Identifier"
34
+ ) {
35
+ const stateName = node.id.elements[0].name;
36
+ if (!RENDERING_ONLY_PATTERN.test(stateName)) {
37
+ context.report({
38
+ node,
39
+ messageId: "nonRenderingState",
40
+ data: { stateName },
41
+ });
42
+ }
43
+ }
44
+ },
45
+ };
46
+ },
47
+ };
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+
3
+ const { isOrganism } = require("../utils/helpers");
4
+
5
+ const REQUIRED_PROPS = ["className"];
6
+ const RECOMMENDED_PROPS = ["isLoading", "error", "entity", "data", "sortBy", "sortDirection", "page", "pageSize", "totalCount", "searchValue"];
7
+
8
+ module.exports = {
9
+ meta: {
10
+ type: "suggestion",
11
+ docs: {
12
+ description: "Require closed circuit base props (className, isLoading, error, entity) on organism prop interfaces",
13
+ category: "Almadar Architecture",
14
+ },
15
+ messages: {
16
+ missingProp:
17
+ "Organism prop interface '{{interface}}' is missing '{{prop}}'. Closed circuit requires: className, isLoading, error, entity.",
18
+ },
19
+ schema: [],
20
+ },
21
+
22
+ create(context) {
23
+ const filename = context.filename;
24
+
25
+ if (!isOrganism(filename)) return {};
26
+ if (!/\.(tsx|jsx|ts)$/.test(filename)) return {};
27
+
28
+ return {
29
+ TSInterfaceDeclaration(node) {
30
+ // Only check interfaces ending with Props
31
+ if (!node.id || !/Props$/.test(node.id.name)) return;
32
+
33
+ const declaredProps = new Set();
34
+
35
+ // Collect props from the interface body
36
+ for (const member of node.body.body) {
37
+ if (
38
+ member.type === "TSPropertySignature" &&
39
+ member.key &&
40
+ member.key.type === "Identifier"
41
+ ) {
42
+ declaredProps.add(member.key.name);
43
+ }
44
+ }
45
+
46
+ // Check for extends that might provide the props
47
+ if (node.extends && node.extends.length > 0) {
48
+ // If it extends something, assume it might get props from parent
49
+ // We can't easily resolve what the parent provides, so only
50
+ // flag if it extends nothing and is missing props
51
+ return;
52
+ }
53
+
54
+ const allExpected = [...REQUIRED_PROPS, ...RECOMMENDED_PROPS];
55
+ for (const prop of allExpected) {
56
+ if (!declaredProps.has(prop)) {
57
+ context.report({
58
+ node: node.id,
59
+ messageId: "missingProp",
60
+ data: {
61
+ interface: node.id.name,
62
+ prop,
63
+ },
64
+ });
65
+ }
66
+ }
67
+ },
68
+ };
69
+ },
70
+ };
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+
3
+ const { isStoryFile } = require("../utils/helpers");
4
+
5
+ module.exports = {
6
+ meta: {
7
+ type: "suggestion",
8
+ docs: {
9
+ description: "Require displayName on exported React components",
10
+ category: "Almadar Architecture",
11
+ },
12
+ messages: {
13
+ missingDisplayName:
14
+ "Component '{{name}}' is missing displayName. Add: {{name}}.displayName = '{{name}}';",
15
+ },
16
+ schema: [],
17
+ },
18
+
19
+ create(context) {
20
+ const filename = context.filename;
21
+
22
+ if (isStoryFile(filename)) return {};
23
+ // Only check design-system and @almadar/ui component files
24
+ if (
25
+ !/(design-system|almadar-ui\/components)\//.test(filename) ||
26
+ !/\.(tsx|jsx)$/.test(filename)
27
+ ) {
28
+ return {};
29
+ }
30
+
31
+ const exportedComponents = [];
32
+ const displayNames = new Set();
33
+
34
+ return {
35
+ // Track: export const Foo = ...
36
+ ExportNamedDeclaration(node) {
37
+ if (node.declaration && node.declaration.type === "VariableDeclaration") {
38
+ for (const decl of node.declaration.declarations) {
39
+ if (
40
+ decl.id.type === "Identifier" &&
41
+ /^[A-Z]/.test(decl.id.name)
42
+ ) {
43
+ exportedComponents.push(decl.id);
44
+ }
45
+ }
46
+ }
47
+ // export function Foo
48
+ if (
49
+ node.declaration &&
50
+ node.declaration.type === "FunctionDeclaration" &&
51
+ node.declaration.id &&
52
+ /^[A-Z]/.test(node.declaration.id.name)
53
+ ) {
54
+ exportedComponents.push(node.declaration.id);
55
+ }
56
+ },
57
+
58
+ // Track: Foo.displayName = '...'
59
+ AssignmentExpression(node) {
60
+ if (
61
+ node.left.type === "MemberExpression" &&
62
+ node.left.property.type === "Identifier" &&
63
+ node.left.property.name === "displayName" &&
64
+ node.left.object.type === "Identifier"
65
+ ) {
66
+ displayNames.add(node.left.object.name);
67
+ }
68
+ },
69
+
70
+ "Program:exit"() {
71
+ for (const comp of exportedComponents) {
72
+ if (!displayNames.has(comp.name)) {
73
+ context.report({
74
+ node: comp,
75
+ messageId: "missingDisplayName",
76
+ data: { name: comp.name },
77
+ });
78
+ }
79
+ }
80
+ },
81
+ };
82
+ },
83
+ };
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+
3
+ const { isTemplateFile, isStoryFile, isOrganism } = require("../utils/helpers");
4
+
5
+ /**
6
+ * Callback props that have declarative equivalents in @almadar/ui.
7
+ */
8
+ const CALLBACK_TO_DECLARATIVE = {
9
+ onClick: "action",
10
+ onClose: "closeEvent",
11
+ onDismiss: "dismissEvent",
12
+ onRetry: "retryEvent",
13
+ onSubmit: "submitEvent",
14
+ };
15
+
16
+ module.exports = {
17
+ meta: {
18
+ type: "suggestion",
19
+ docs: {
20
+ description: "Prefer declarative event props over callback props on @almadar/ui components",
21
+ category: "Almadar Architecture",
22
+ },
23
+ messages: {
24
+ preferDeclarative:
25
+ "Prefer declarative '{{declarative}}' prop over '{{callback}}' for closed circuit integration.",
26
+ },
27
+ schema: [],
28
+ },
29
+
30
+ create(context) {
31
+ const filename = context.filename;
32
+
33
+ // Enforce in templates and organisms
34
+ if (!isTemplateFile(filename) && !isOrganism(filename)) return {};
35
+
36
+ // Stories use callbacks for testing
37
+ if (isStoryFile(filename)) return {};
38
+
39
+ return {
40
+ JSXAttribute(node) {
41
+ if (!node.name || node.name.type !== "JSXIdentifier") return;
42
+
43
+ const propName = node.name.name;
44
+ const declarative = CALLBACK_TO_DECLARATIVE[propName];
45
+
46
+ if (!declarative) return;
47
+
48
+ // Only flag if the value is a function expression
49
+ if (
50
+ node.value &&
51
+ node.value.type === "JSXExpressionContainer" &&
52
+ (node.value.expression.type === "ArrowFunctionExpression" ||
53
+ node.value.expression.type === "FunctionExpression")
54
+ ) {
55
+ context.report({
56
+ node,
57
+ messageId: "preferDeclarative",
58
+ data: { declarative, callback: propName },
59
+ });
60
+ }
61
+ },
62
+ };
63
+ },
64
+ };
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { isStoryFile, isOrganism, isTemplateFile } = require("../utils/helpers");
6
+
7
+ module.exports = {
8
+ meta: {
9
+ type: "suggestion",
10
+ docs: {
11
+ description: "Require .stories.tsx file for organism and template components",
12
+ category: "Almadar Architecture",
13
+ },
14
+ messages: {
15
+ missingStory:
16
+ "Component '{{name}}' is missing a .stories.tsx file. Every organism and template needs Storybook coverage.",
17
+ },
18
+ schema: [],
19
+ },
20
+
21
+ create(context) {
22
+ const filename = context.filename;
23
+
24
+ // Only check organisms and templates
25
+ if (!isOrganism(filename) && !isTemplateFile(filename)) return {};
26
+ if (isStoryFile(filename)) return {};
27
+ if (!/\.(tsx|jsx)$/.test(filename)) return {};
28
+
29
+ // Skip index files
30
+ if (/index\.(tsx|jsx)$/.test(filename)) return {};
31
+
32
+ return {
33
+ Program(node) {
34
+ const dir = path.dirname(filename);
35
+ const base = path.basename(filename, path.extname(filename));
36
+
37
+ // Check for sibling .stories.tsx
38
+ const storyFile = path.join(dir, `${base}.stories.tsx`);
39
+ const storyFileJsx = path.join(dir, `${base}.stories.jsx`);
40
+
41
+ // Also check stories/ subdirectory
42
+ const storiesDir = path.join(dir, "stories", `${base}.stories.tsx`);
43
+
44
+ if (
45
+ !fs.existsSync(storyFile) &&
46
+ !fs.existsSync(storyFileJsx) &&
47
+ !fs.existsSync(storiesDir)
48
+ ) {
49
+ context.report({
50
+ node,
51
+ messageId: "missingStory",
52
+ data: { name: base },
53
+ });
54
+ }
55
+ },
56
+ };
57
+ },
58
+ };
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+
3
+ const { isStoryFile, isTemplateFile, isAtomOrMolecule, isOrganism } = require("../utils/helpers");
4
+
5
+ /**
6
+ * Check if a file is inside a molecules/ directory.
7
+ */
8
+ function isMolecule(filename) {
9
+ return /\/molecules\//.test(filename);
10
+ }
11
+
12
+ /**
13
+ * Check if a file is inside an atoms/ directory (but NOT molecules/).
14
+ */
15
+ function isAtom(filename) {
16
+ return /\/atoms\//.test(filename) && !isMolecule(filename);
17
+ }
18
+
19
+ module.exports = {
20
+ meta: {
21
+ type: "suggestion",
22
+ docs: {
23
+ description: "Require useTranslate import in organism and molecule components for i18n",
24
+ category: "Almadar Architecture",
25
+ },
26
+ messages: {
27
+ missingUseTranslate:
28
+ "Component '{{name}}' should import useTranslate for i18n. Add: const { t } = useTranslate();",
29
+ },
30
+ schema: [],
31
+ },
32
+
33
+ create(context) {
34
+ const filename = context.filename;
35
+
36
+ // Only applies to .tsx files in design-system/ or almadar-ui/components/
37
+ if (!/\.(tsx)$/.test(filename)) return {};
38
+ if (!/(design-system|almadar-ui\/components)\//.test(filename)) return {};
39
+
40
+ // Skip story files, atom files, template files
41
+ if (isStoryFile(filename)) return {};
42
+ if (isAtom(filename)) return {};
43
+ if (isTemplateFile(filename)) return {};
44
+
45
+ // Only check organisms and molecules
46
+ if (!isOrganism(filename) && !isMolecule(filename)) return {};
47
+
48
+ let hasUseTranslateImport = false;
49
+ const exportedComponents = [];
50
+
51
+ return {
52
+ // Check for import of useTranslate from @almadar/ui or relative path
53
+ ImportDeclaration(node) {
54
+ const source = node.source.value;
55
+ if (
56
+ source === "@almadar/ui" ||
57
+ /useTranslate/.test(source)
58
+ ) {
59
+ for (const specifier of node.specifiers) {
60
+ if (
61
+ specifier.type === "ImportSpecifier" &&
62
+ specifier.imported.name === "useTranslate"
63
+ ) {
64
+ hasUseTranslateImport = true;
65
+ }
66
+ }
67
+ }
68
+ },
69
+
70
+ // Track exported components: export const Foo = ...
71
+ ExportNamedDeclaration(node) {
72
+ if (node.declaration && node.declaration.type === "VariableDeclaration") {
73
+ for (const decl of node.declaration.declarations) {
74
+ if (
75
+ decl.id.type === "Identifier" &&
76
+ /^[A-Z]/.test(decl.id.name)
77
+ ) {
78
+ exportedComponents.push(decl.id);
79
+ }
80
+ }
81
+ }
82
+ // export function Foo
83
+ if (
84
+ node.declaration &&
85
+ node.declaration.type === "FunctionDeclaration" &&
86
+ node.declaration.id &&
87
+ /^[A-Z]/.test(node.declaration.id.name)
88
+ ) {
89
+ exportedComponents.push(node.declaration.id);
90
+ }
91
+ },
92
+
93
+ "Program:exit"() {
94
+ if (hasUseTranslateImport) return;
95
+ if (exportedComponents.length === 0) return;
96
+
97
+ // Report on the first exported component
98
+ context.report({
99
+ node: exportedComponents[0],
100
+ messageId: "missingUseTranslate",
101
+ data: { name: exportedComponents[0].name },
102
+ });
103
+ },
104
+ };
105
+ },
106
+ };
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+
3
+ const SCREAMING_SNAKE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
4
+
5
+ module.exports = {
6
+ meta: {
7
+ type: "problem",
8
+ docs: {
9
+ description:
10
+ "EventBus event names must use SCREAMING_SNAKE_CASE to match .orb schema conventions",
11
+ category: "Almadar Services",
12
+ },
13
+ messages: {
14
+ badEventName:
15
+ "Event name '{{name}}' must be SCREAMING_SNAKE_CASE (e.g., 'LLM_REQUEST_COMPLETE'). The .orb schema uses this convention — mismatched names mean listeners won't fire.",
16
+ },
17
+ schema: [],
18
+ },
19
+
20
+ create(context) {
21
+ return {
22
+ CallExpression(node) {
23
+ const callee = node.callee;
24
+
25
+ // Match: eventBus.emit('...') or getServerEventBus().emit('...')
26
+ // Skip generic .emit() calls on non-EventBus objects (Node EventEmitter, etc.)
27
+ if (
28
+ callee.type !== "MemberExpression" ||
29
+ callee.property.type !== "Identifier" ||
30
+ callee.property.name !== "emit" ||
31
+ node.arguments.length === 0
32
+ ) return;
33
+
34
+ // Only flag calls on objects that look like EventBus instances
35
+ const obj = callee.object;
36
+ const isEventBus =
37
+ // Direct: eventBus.emit, serverEventBus.emit, bus.emit
38
+ (obj.type === "Identifier" && /[Ee]vent[Bb]us/.test(obj.name)) ||
39
+ // Getter: getServerEventBus().emit()
40
+ (obj.type === "CallExpression" &&
41
+ obj.callee.type === "Identifier" &&
42
+ /[Ee]vent[Bb]us/.test(obj.callee.name)) ||
43
+ // this.eventBus.emit or this.bus.emit (inside service classes)
44
+ (obj.type === "MemberExpression" &&
45
+ obj.property.type === "Identifier" &&
46
+ /[Ee]vent[Bb]us|^bus$/.test(obj.property.name));
47
+
48
+ if (!isEventBus) return;
49
+
50
+ const firstArg = node.arguments[0];
51
+
52
+ // Only check string literals
53
+ if (firstArg.type !== "Literal" || typeof firstArg.value !== "string") return;
54
+
55
+ const eventName = firstArg.value;
56
+
57
+ // Skip UI:* events (handled by event-naming-convention rule)
58
+ if (eventName.startsWith("UI:")) return;
59
+
60
+ // Skip wildcard
61
+ if (eventName === "*") return;
62
+
63
+ if (!SCREAMING_SNAKE.test(eventName)) {
64
+ context.report({
65
+ node: firstArg,
66
+ messageId: "badEventName",
67
+ data: { name: eventName },
68
+ });
69
+ }
70
+ },
71
+ };
72
+ },
73
+ };
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+
5
+ /**
6
+ * Files that are allowed to construct EventBus instances.
7
+ */
8
+ const ALLOWED_FILES = new Set([
9
+ "eventBus.ts",
10
+ "eventBus.js",
11
+ "eventBusTransport.ts",
12
+ "eventBusTransport.js",
13
+ "event-bus.ts",
14
+ "event-bus.js",
15
+ ]);
16
+
17
+ module.exports = {
18
+ meta: {
19
+ type: "problem",
20
+ docs: {
21
+ description:
22
+ "Prevent creating multiple EventBus instances — use the singleton from eventBus.ts",
23
+ category: "Almadar Services",
24
+ },
25
+ messages: {
26
+ duplicateEventBus:
27
+ "Don't create a new EventBus here. Import the singleton from your eventBus module. Split buses mean events emitted by the .orb schema never reach listeners on this bus.",
28
+ },
29
+ schema: [],
30
+ },
31
+
32
+ create(context) {
33
+ const filename = context.filename;
34
+ const basename = path.basename(filename);
35
+
36
+ // Allow EventBus construction in dedicated files
37
+ if (ALLOWED_FILES.has(basename)) return {};
38
+
39
+ // Also allow in test files
40
+ if (/\.(test|spec)\.(ts|tsx|js|jsx)$/.test(filename)) return {};
41
+
42
+ return {
43
+ NewExpression(node) {
44
+ if (
45
+ node.callee.type === "Identifier" &&
46
+ node.callee.name === "EventBus"
47
+ ) {
48
+ context.report({ node, messageId: "duplicateEventBus" });
49
+ }
50
+ },
51
+ };
52
+ },
53
+ };