Skip to content

Commit 2f22779

Browse files
committed
Add ESLint rule to enforce top-level component exports in test files
also apply it to SFT and CIT
1 parent ff39900 commit 2f22779

32 files changed

Lines changed: 221 additions & 79 deletions

File tree

apps/src/tests/.eslintrc.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const path = require('path');
2+
const Module = require('module');
3+
4+
const pluginPath = require.resolve(
5+
path.join(__dirname, 'eslint-plugin-local-rules'),
6+
);
7+
const originalResolve = Module._resolveFilename;
8+
Module._resolveFilename = function (request, ...args) {
9+
if (request === 'eslint-plugin-local-rules') {
10+
return pluginPath;
11+
}
12+
return originalResolve.call(this, request, ...args);
13+
};
14+
15+
module.exports = {
16+
overrides: [
17+
{
18+
files: [
19+
'single-feature-tests/**/*.tsx',
20+
'single-feature-tests/**/*.ts',
21+
'component-integration-tests/**/*.tsx',
22+
'component-integration-tests/**/*.ts',
23+
],
24+
plugins: ['local-rules'],
25+
rules: {
26+
'local-rules/require-top-level-exports': 'error',
27+
},
28+
},
29+
],
30+
};

apps/src/tests/component-integration-tests/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ type ParamsList = { [k: keyof typeof COMPONENT_SCENARIOS]: undefined } & {
2020
Home: undefined;
2121
};
2222

23-
function HomeScreen() {
23+
export function HomeScreen() {
2424
return (
2525
<ScrollView contentInsetAdjustmentBehavior="automatic">
2626
{Object.entries(COMPONENT_SCENARIOS).map(([key, scenarioGroup]) => (

apps/src/tests/component-integration-tests/orientation/orientation-stack-in-tabs.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const scenarioDescription: ScenarioDescription = {
2424
platforms: ['ios'],
2525
};
2626

27-
function ConfigScreen() {
27+
export function ConfigScreen() {
2828
const {
2929
routeKey: tabRouteKey,
3030
routeOptions: tabRouteOptions,
@@ -70,7 +70,7 @@ const STACK_ROUTE_CONFIGS: StackRouteConfig[] = [
7070
},
7171
];
7272

73-
function StackScreen() {
73+
export function StackScreen() {
7474
return <StackContainer routeConfigs={STACK_ROUTE_CONFIGS} />;
7575
}
7676

apps/src/tests/component-integration-tests/orientation/orientation-tabs-in-stack.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const scenarioDescription: ScenarioDescription = {
2424
platforms: ['ios'],
2525
};
2626

27-
function ConfigScreen() {
27+
export function ConfigScreen() {
2828
const {
2929
routeKey: stackRouteKey,
3030
routeOptions: stackRouteOptions,
@@ -81,7 +81,7 @@ const TAB_ROUTE_CONFIGS: TabRouteConfig[] = [
8181
},
8282
];
8383

84-
function TabsScreen() {
84+
export function TabsScreen() {
8585
return <TabsContainer routeConfigs={TAB_ROUTE_CONFIGS} />;
8686
}
8787

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module.exports = {
2+
rules: {
3+
'require-top-level-exports': require('./require-top-level-exports'),
4+
},
5+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "eslint-plugin-local-rules",
3+
"version": "1.0.0",
4+
"private": true,
5+
"main": "index.js"
6+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/** @type {import('eslint').Rule.RuleModule} */
2+
module.exports = {
3+
meta: {
4+
type: 'suggestion',
5+
docs: {
6+
description:
7+
'Require top-level React component declarations to be exported',
8+
},
9+
schema: [],
10+
messages: {
11+
requireExport: 'Top-level component "{{name}}" must be exported.',
12+
},
13+
},
14+
create(context) {
15+
return {
16+
Program(node) {
17+
const exportedNames = collectExportedNames(node);
18+
19+
for (const stmt of node.body) {
20+
const name = getComponentName(stmt);
21+
if (name === null) {
22+
continue;
23+
}
24+
25+
if (!exportedNames.has(name)) {
26+
context.report({
27+
node: stmt,
28+
messageId: 'requireExport',
29+
data: { name },
30+
});
31+
}
32+
}
33+
},
34+
};
35+
},
36+
};
37+
38+
function isPascalCase(name) {
39+
return /^[A-Z]/.test(name);
40+
}
41+
42+
function getComponentName(stmt) {
43+
if (
44+
stmt.type === 'FunctionDeclaration' &&
45+
stmt.id?.name &&
46+
isPascalCase(stmt.id.name)
47+
) {
48+
return stmt.id.name;
49+
}
50+
51+
if (stmt.type === 'VariableDeclaration' && stmt.declarations.length === 1) {
52+
const decl = stmt.declarations[0];
53+
const name = decl.id?.name;
54+
if (
55+
name &&
56+
isPascalCase(name) &&
57+
decl.init &&
58+
(decl.init.type === 'ArrowFunctionExpression' ||
59+
decl.init.type === 'FunctionExpression')
60+
) {
61+
return name;
62+
}
63+
}
64+
65+
return null;
66+
}
67+
68+
function collectExportedNames(program) {
69+
const names = new Set();
70+
71+
for (const stmt of program.body) {
72+
if (stmt.type === 'ExportNamedDeclaration') {
73+
if (stmt.declaration) {
74+
for (const name of getDeclaredNames(stmt.declaration)) {
75+
names.add(name);
76+
}
77+
}
78+
for (const specifier of stmt.specifiers) {
79+
names.add(specifier.local.name);
80+
}
81+
}
82+
83+
if (stmt.type === 'ExportDefaultDeclaration') {
84+
const decl = stmt.declaration;
85+
if (decl.type === 'Identifier') {
86+
names.add(decl.name);
87+
} else if (decl.id) {
88+
names.add(decl.id.name);
89+
}
90+
}
91+
}
92+
93+
return names;
94+
}
95+
96+
function getDeclaredNames(stmt) {
97+
if (stmt.type === 'VariableDeclaration') {
98+
return stmt.declarations.map(d => d.id?.name).filter(Boolean);
99+
}
100+
return stmt.id?.name ? [stmt.id.name] : [];
101+
}

apps/src/tests/single-feature-tests/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ type ParamsList = { [k: keyof typeof COMPONENT_SCENARIOS]: undefined } & {
2424
Home: undefined;
2525
};
2626

27-
function HomeScreen() {
27+
export function HomeScreen() {
2828
return (
2929
<ScrollView contentInsetAdjustmentBehavior="automatic"
3030
testID="single-feature-tests-scrollview">

apps/src/tests/single-feature-tests/scroll-view-marker/test-svm-configures-scroll-view.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export function App() {
3333
);
3434
}
3535

36-
function ContentScreen() {
36+
export function ContentScreen() {
3737
return (
3838
<View
3939
style={[

apps/src/tests/single-feature-tests/split/test-command-show-column.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export function App() {
2828
);
2929
}
3030

31-
function ColumnContent(props: {
31+
export function ColumnContent(props: {
3232
columnTitle: string;
3333
hostRef: React.RefObject<SplitHostCommands | null>;
3434
}) {

0 commit comments

Comments
 (0)