Skip to content

Commit e79545d

Browse files
committed
chore: add lint rules for component structure and naming conventions
1 parent 0217730 commit e79545d

3 files changed

Lines changed: 163 additions & 11 deletions

File tree

frontend/src/ts/components/common/Headers.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export function H2(props: {
2525
);
2626
}
2727

28+
// oxlint-disable-next-line monkeytype-rules/one-component-per-file
2829
export function H3(props: {
2930
id?: string;
3031
class?: string;

packages/oxlint-config/plugin.jsonc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
"files": ["**/*.tsx"],
2626
"rules": {
2727
"monkeytype-rules/prefer-arrow-in-component": "error",
28+
"monkeytype-rules/one-component-per-file": "error",
29+
"monkeytype-rules/component-pascal-case": "error",
2830
},
2931
},
3032
],

packages/oxlint-config/plugins/monkeytype-rules.js

Lines changed: 160 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,69 @@
11
import { defineRule } from "@oxlint/plugins";
22

3+
/**
4+
* Walk a function body looking for a ReturnStatement whose argument is
5+
* JSXElement or JSXFragment. Only traverses control flow nodes — does NOT
6+
* recurse into call arguments or JSX attribute values, preventing false
7+
* positives on functions that pass JSX as a prop/argument. Stops at nested
8+
* function boundaries so inner helpers returning JSX don't count.
9+
*/
10+
function containsJSXReturn(node) {
11+
if (!node || typeof node !== "object" || !node.type) return false;
12+
13+
// Stop at nested function boundaries
14+
if (
15+
node.type === "FunctionDeclaration" ||
16+
node.type === "FunctionExpression" ||
17+
node.type === "ArrowFunctionExpression"
18+
) {
19+
return false;
20+
}
21+
22+
// Arrow with concise body: const Foo = () => <div />
23+
if (node.type === "JSXElement" || node.type === "JSXFragment") return true;
24+
25+
// return <...>
26+
if (node.type === "ReturnStatement") {
27+
return (
28+
node.argument?.type === "JSXElement" ||
29+
node.argument?.type === "JSXFragment"
30+
);
31+
}
32+
33+
// Only recurse through control flow / block nodes, not into expressions
34+
const CONTROL_FLOW_KEYS = {
35+
BlockStatement: ["body"],
36+
Program: ["body"],
37+
IfStatement: ["consequent", "alternate"],
38+
SwitchStatement: ["cases"],
39+
SwitchCase: ["consequent"],
40+
TryStatement: ["block", "handler", "finalizer"],
41+
CatchClause: ["body"],
42+
WhileStatement: ["body"],
43+
DoWhileStatement: ["body"],
44+
ForStatement: ["body"],
45+
ForInStatement: ["body"],
46+
ForOfStatement: ["body"],
47+
LabeledStatement: ["body"],
48+
};
49+
50+
const keys = CONTROL_FLOW_KEYS[node.type];
51+
if (!keys) return false;
52+
53+
for (const key of keys) {
54+
const child = node[key];
55+
if (!child) continue;
56+
if (Array.isArray(child)) {
57+
for (const item of child) {
58+
if (containsJSXReturn(item)) return true;
59+
}
60+
} else if (containsJSXReturn(child)) {
61+
return true;
62+
}
63+
}
64+
return false;
65+
}
66+
367
const plugin = {
468
meta: {
569
name: "monkeytype-rules",
@@ -24,28 +88,24 @@ const plugin = {
2488
hasSuggestions: true,
2589
},
2690
createOnce(context) {
27-
const isPascalCase = (name) => /^[A-Z][a-zA-Z0-9]*$/.test(name);
28-
2991
const getComponentAncestor = (node) => {
3092
let current = node.parent;
3193
while (current) {
32-
// function ComponentName() { ... }
94+
// function Foo() { return <...> }
3395
if (
3496
current.type === "FunctionDeclaration" &&
35-
current.id?.name &&
36-
isPascalCase(current.id.name)
97+
containsJSXReturn(current.body)
3798
) {
38-
return current.id.name;
99+
return current.id?.name ?? "component";
39100
}
40-
// const ComponentName = () => { ... } or const ComponentName = function() { ... }
101+
// const Foo = () => { return <...> } or const Foo = function() { return <...> }
41102
if (
42103
(current.type === "ArrowFunctionExpression" ||
43104
current.type === "FunctionExpression") &&
44-
current.parent?.type === "VariableDeclarator" &&
45-
current.parent.id?.name &&
46-
isPascalCase(current.parent.id.name)
105+
containsJSXReturn(current.body ?? current) &&
106+
current.parent?.type === "VariableDeclarator"
47107
) {
48-
return current.parent.id.name;
108+
return current.parent.id?.name ?? "component";
49109
}
50110
current = current.parent;
51111
}
@@ -90,6 +150,95 @@ const plugin = {
90150
};
91151
},
92152
}),
153+
"one-component-per-file": defineRule({
154+
createOnce(context) {
155+
let exportedComponents;
156+
157+
return {
158+
before() {
159+
exportedComponents = [];
160+
},
161+
ExportNamedDeclaration(node) {
162+
// export function Foo() { return <...> }
163+
if (
164+
node.declaration?.type === "FunctionDeclaration" &&
165+
node.declaration.id?.name &&
166+
containsJSXReturn(node.declaration.body)
167+
) {
168+
exportedComponents.push({
169+
name: node.declaration.id.name,
170+
node,
171+
});
172+
return;
173+
}
174+
// export const Foo = () => <...> or export const Foo = function() { return <...> }
175+
if (node.declaration?.type === "VariableDeclaration") {
176+
for (const decl of node.declaration.declarations) {
177+
if (
178+
decl.id?.name &&
179+
(decl.init?.type === "ArrowFunctionExpression" ||
180+
decl.init?.type === "FunctionExpression") &&
181+
containsJSXReturn(decl.init.body ?? decl.init)
182+
) {
183+
exportedComponents.push({ name: decl.id.name, node });
184+
}
185+
}
186+
}
187+
},
188+
"Program:exit"() {
189+
if (exportedComponents.length > 1) {
190+
for (const { name, node } of exportedComponents.slice(1)) {
191+
context.report({
192+
node,
193+
message: `Only one exported component per file. Move \`${name}\` to its own file.`,
194+
});
195+
}
196+
}
197+
},
198+
};
199+
},
200+
}),
201+
"component-pascal-case": defineRule({
202+
createOnce(context) {
203+
const isPascalCase = (name) => /^[A-Z][a-zA-Z0-9]*$/.test(name);
204+
205+
return {
206+
FunctionDeclaration(node) {
207+
const isTopLevel =
208+
node.parent?.type === "Program" ||
209+
node.parent?.type === "ExportNamedDeclaration";
210+
if (!isTopLevel || !node.id) return;
211+
const name = node.id.name;
212+
if (!isPascalCase(name) && containsJSXReturn(node.body)) {
213+
context.report({
214+
node: node.id,
215+
message: `Component \`${name}\` should be PascalCase.`,
216+
});
217+
}
218+
},
219+
VariableDeclarator(node) {
220+
const isTopLevel =
221+
node.parent?.parent?.type === "Program" ||
222+
node.parent?.parent?.type === "ExportNamedDeclaration";
223+
if (
224+
!isTopLevel ||
225+
node.id?.type !== "Identifier" ||
226+
(node.init?.type !== "ArrowFunctionExpression" &&
227+
node.init?.type !== "FunctionExpression")
228+
) {
229+
return;
230+
}
231+
const name = node.id.name;
232+
if (!isPascalCase(name) && containsJSXReturn(node.init)) {
233+
context.report({
234+
node: node.id,
235+
message: `Component \`${name}\` should be PascalCase.`,
236+
});
237+
}
238+
},
239+
};
240+
},
241+
}),
93242
},
94243
};
95244

0 commit comments

Comments
 (0)