Skip to content

Commit 9755887

Browse files
authored
feat: Add ESLint rule enforcing PageShell/PageHeader in feature pages (#1621)
* feat: Add ESLint rule enforcing PageShell/PageHeader in feature pages Custom ESLint rule that warns when feature page files under src/features/**/pages/ don't import PageShell and PageHeader from @/shared. Set to "warn" to avoid blocking other teammates' PRs while pages are being aligned. Excludes dashboard, economy, cookbook, mcp-config, tenants, manifests pages per PRD, plus test files, dialogs, tab components, and .ts utility files. * fix: Address CodeRabbit review feedback on ESLint rule - Normalize filename separators for Windows compatibility - Remove overly broad 'component' substring exclusion - Support relative imports (../page-header, ../../page-shell) for nested page files - Add test case for nested relative imports * fix: Tighten ESLint rule exclusion patterns and import matching - Anchor dialog exclusion to file-name pattern (*-dialog.tsx) instead of broad substring match - Anchor excluded features to /features/<name>/pages/ path pattern - Require @/shared boundary (exact or prefix) instead of substring - Require relative prefix in page-header/page-shell import regexes * test: Add Windows path normalization test case --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent e5ee9a8 commit 9755887

3 files changed

Lines changed: 203 additions & 0 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
module.exports = {
2+
meta: {
3+
type: 'suggestion',
4+
docs: {
5+
description: 'Enforce PageShell and PageHeader usage in feature pages',
6+
},
7+
schema: [],
8+
},
9+
create(context) {
10+
const filename = (context.filename || context.getFilename()).replace(/\\/g, '/')
11+
// Only apply to feature page files
12+
if (!filename.includes('/features/') || !filename.includes('/pages/')) {
13+
return {}
14+
}
15+
// Only apply to .tsx files (skip .ts utility/types files)
16+
if (!filename.endsWith('.tsx')) {
17+
return {}
18+
}
19+
// Skip test files, dialog files, and tab components
20+
const isDialogFile =
21+
/(?:^|\/)[^/]*-dialog\.tsx$/.test(filename) || /\/dialogs?\//.test(filename)
22+
if (
23+
filename.includes('.test.') ||
24+
filename.includes('.spec.') ||
25+
isDialogFile ||
26+
filename.includes('/tabs/')
27+
) {
28+
return {}
29+
}
30+
// Skip hub/dashboard pages (excluded from alignment per PRD)
31+
if (
32+
/\/features\/(?:dashboard|economy|cookbook|mcp-config|tenants|manifests)\/pages\//.test(
33+
filename,
34+
)
35+
) {
36+
return {}
37+
}
38+
39+
let hasPageShell = false
40+
let hasPageHeader = false
41+
42+
return {
43+
ImportDeclaration(node) {
44+
const source = node.source.value
45+
const isPageImport =
46+
source === '@/shared' ||
47+
source.startsWith('@/shared/') ||
48+
/^(?:\.\/|\.\.\/)+page-header$/.test(source) ||
49+
/^(?:\.\/|\.\.\/)+page-shell$/.test(source)
50+
if (isPageImport) {
51+
node.specifiers.forEach((spec) => {
52+
if (spec.imported && spec.imported.name === 'PageShell') hasPageShell = true
53+
if (spec.imported && spec.imported.name === 'PageHeader') hasPageHeader = true
54+
})
55+
}
56+
},
57+
'Program:exit'() {
58+
if (!hasPageShell) {
59+
context.report({
60+
loc: { line: 1, column: 0 },
61+
message: 'Feature pages must import PageShell from @/shared',
62+
})
63+
}
64+
if (!hasPageHeader) {
65+
context.report({
66+
loc: { line: 1, column: 0 },
67+
message: 'Feature pages must import PageHeader from @/shared',
68+
})
69+
}
70+
},
71+
}
72+
},
73+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
const { RuleTester } = require('eslint')
2+
const rule = require('./require-page-structure.cjs')
3+
4+
const ruleTester = new RuleTester({
5+
languageOptions: {
6+
ecmaVersion: 2022,
7+
sourceType: 'module',
8+
},
9+
})
10+
11+
ruleTester.run('require-page-structure', rule, {
12+
valid: [
13+
{
14+
code: `
15+
import { PageShell } from '@/shared/page-shell'
16+
import { PageHeader } from '@/shared/page-header'
17+
export default function AccountsPage() { return null }
18+
`,
19+
filename: '/app/src/features/accounts/pages/index.tsx',
20+
},
21+
{
22+
code: `
23+
import { PageShell, PageHeader } from '@/shared'
24+
export default function AccountsPage() { return null }
25+
`,
26+
filename: '/app/src/features/accounts/pages/index.tsx',
27+
},
28+
{
29+
// Windows path normalization
30+
code: `
31+
import { PageShell } from '@/shared/page-shell'
32+
import { PageHeader } from '@/shared/page-header'
33+
export default function AccountsPage() { return null }
34+
`,
35+
filename: 'C:\\app\\src\\features\\accounts\\pages\\index.tsx',
36+
},
37+
{
38+
// Non-feature file should be ignored
39+
code: `export default function App() { return null }`,
40+
filename: '/app/src/components/App.tsx',
41+
},
42+
{
43+
// Dashboard page is excluded
44+
code: `export default function Dashboard() { return null }`,
45+
filename: '/app/src/features/dashboard/pages/index.tsx',
46+
},
47+
{
48+
// Test file is excluded
49+
code: `import { render } from '@testing-library/react'`,
50+
filename: '/app/src/features/accounts/pages/index.test.tsx',
51+
},
52+
{
53+
// Dialog file is excluded
54+
code: `export function CreateDialog() { return null }`,
55+
filename: '/app/src/features/accounts/pages/create-dialog.tsx',
56+
},
57+
{
58+
// Nested page importing via relative path
59+
code: `
60+
import { PageShell } from '../../page-shell'
61+
import { PageHeader } from '../../page-header'
62+
export default function NodePage() { return null }
63+
`,
64+
filename: '/app/src/features/reference-data/pages/nodes/index.tsx',
65+
},
66+
{
67+
// Economy page is excluded
68+
code: `export default function EconomyPage() { return null }`,
69+
filename: '/app/src/features/economy/pages/economy-overview-page.tsx',
70+
},
71+
{
72+
// .ts utility files are excluded
73+
code: `export const COLUMNS = ['name', 'status']`,
74+
filename: '/app/src/features/accounts/pages/types.ts',
75+
},
76+
{
77+
// Tab component files are excluded
78+
code: `export function OverviewTab() { return null }`,
79+
filename: '/app/src/features/parties/pages/tabs/overview-tab.tsx',
80+
},
81+
],
82+
invalid: [
83+
{
84+
code: `
85+
import { DataTable } from '@/shared/data-table'
86+
export default function AccountsPage() { return null }
87+
`,
88+
filename: '/app/src/features/accounts/pages/index.tsx',
89+
errors: [
90+
{ message: 'Feature pages must import PageShell from @/shared' },
91+
{ message: 'Feature pages must import PageHeader from @/shared' },
92+
],
93+
},
94+
{
95+
code: `
96+
import { PageShell } from '@/shared/page-shell'
97+
export default function AccountsPage() { return null }
98+
`,
99+
filename: '/app/src/features/accounts/pages/index.tsx',
100+
errors: [{ message: 'Feature pages must import PageHeader from @/shared' }],
101+
},
102+
{
103+
code: `
104+
import { PageHeader } from '@/shared/page-header'
105+
export default function AccountsPage() { return null }
106+
`,
107+
filename: '/app/src/features/accounts/pages/index.tsx',
108+
errors: [{ message: 'Feature pages must import PageShell from @/shared' }],
109+
},
110+
],
111+
})
112+
113+
console.log('All tests passed!')

frontend/eslint.config.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import reactRefresh from 'eslint-plugin-react-refresh'
55
import tseslint from 'typescript-eslint'
66
import { defineConfig, globalIgnores } from 'eslint/config'
77
import prettier from 'eslint-config-prettier'
8+
import { createRequire } from 'module'
9+
const require = createRequire(import.meta.url)
10+
const requirePageStructure = require('./eslint-rules/require-page-structure.cjs')
811

912
export default defineConfig([
1013
globalIgnores(['dist', 'src/api/gen']),
@@ -34,4 +37,18 @@ export default defineConfig([
3437
'react-refresh/only-export-components': 'off',
3538
},
3639
},
40+
// Enforce PageShell and PageHeader usage in feature pages
41+
{
42+
files: ['src/features/**/pages/**/*.{ts,tsx}'],
43+
plugins: {
44+
meridian: {
45+
rules: {
46+
'require-page-structure': requirePageStructure,
47+
},
48+
},
49+
},
50+
rules: {
51+
'meridian/require-page-structure': 'warn',
52+
},
53+
},
3754
])

0 commit comments

Comments
 (0)