@starci/eslint-canon-fe 1.4.1 → 1.4.2
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/contract.mjs +1363 -1295
- package/contracts.ts +1 -1
- package/file-layout.mjs +414 -310
- package/icon.mjs +10 -96
- package/landmark.mjs +242 -242
- package/lint-adoption.mjs +116 -116
- package/naming.mjs +36 -7
- package/package.json +38 -38
- package/props-and-slots.mjs +278 -14
- package/props.ts +33 -33
- package/served-locale.mjs +1 -1
- package/the-split.mjs +2 -2
- package/typography.mjs +83 -83
- package/vendor-boundary.mjs +85 -62
package/file-layout.mjs
CHANGED
|
@@ -1,310 +1,414 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rules that hold `file-layout.md`.
|
|
3
|
-
*
|
|
4
|
-
* Four rules locking the same habit: stuffing a cluster into the folder of one screen. It always
|
|
5
|
-
* starts harmlessly - "only this page uses it" - and ends as a page folder holding four components,
|
|
6
|
-
* a constants folder, a utils folder and three hand-copied resting shapes, at which point the
|
|
7
|
-
* screen is a second codebase with its own private vocabulary that nobody else can reuse.
|
|
8
|
-
*
|
|
9
|
-
* Every rule here reads the PATH rather than the contents, which is what makes them cheap and
|
|
10
|
-
* exact. The cost of that choice is stated where it bites: a path rule cannot tell a component from
|
|
11
|
-
* a helper, only a folder from a folder, so each rule below names the destination it sends things
|
|
12
|
-
* to rather than merely refusing.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
16
|
-
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
17
|
-
|
|
18
|
-
/** Tiers whose folder holds exactly the two halves of one surface. */
|
|
19
|
-
const TWO_FILE_TIERS = /\/src\/components\/(pages|layouts)\/([^/]+)\/(.+)$/
|
|
20
|
-
|
|
21
|
-
/** Overlays carry a feature category between the tier and the surface. */
|
|
22
|
-
const TWO_FILE_TIERS_WITH_CATEGORY = /\/src\/components\/(overlays)\/[^/]+\/([^/]+)\/(.+)$/
|
|
23
|
-
|
|
24
|
-
/** The two halves, and the twin test of each, are the whole of what a surface folder may hold. */
|
|
25
|
-
const ALLOWED_IN_SURFACE_FOLDER = /^(?:component|index)(?:\.
|
|
26
|
-
|
|
27
|
-
/** Folders that are not component code, whatever they are nested inside. */
|
|
28
|
-
const NON_COMPONENT_FOLDERS = /\/src\/components\/.*\/(constants|utils|types|hooks)\//
|
|
29
|
-
|
|
30
|
-
/** Whether the path sits in ONE surface folder, and what that folder is named. */
|
|
31
|
-
const surfaceFolder = (filename) => {
|
|
32
|
-
const file = normalizePath(filename)
|
|
33
|
-
const hit = file.match(TWO_FILE_TIERS) || file.match(TWO_FILE_TIERS_WITH_CATEGORY)
|
|
34
|
-
return hit ? { tier: hit[1], name: hit[2], rest: hit[3] } : null
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// -- FILE-2 --------------------------------------------------------------------------------------
|
|
38
|
-
|
|
39
|
-
/** A page, layout or overlay folder holds its two halves and nothing else. */
|
|
40
|
-
export const surfaceFolderTwoFilesOnly = {
|
|
41
|
-
meta: {
|
|
42
|
-
type: "problem",
|
|
43
|
-
docs: { description: "A page/layout/overlay folder holds `component.tsx` + `index.tsx` and nothing else." },
|
|
44
|
-
schema: [],
|
|
45
|
-
messages: {
|
|
46
|
-
extra:
|
|
47
|
-
"`{{tier}}/{{name}}/` contains `{{rest}}` - a surface folder holds its two halves ONLY (`component.tsx` is the shape, `index.tsx` is the wiring). Whatever this is, it has a real home: a component of its own goes to `blocks/<category>/`, a closed arrangement to `composites/`, a fetch to `hooks/`, a pure helper to `modules/utils/`, a shape to `modules/types/`, copy or a config map to `resources/`. \"Only this screen uses it\" is how a folder becomes a second codebase.",
|
|
48
|
-
},
|
|
49
|
-
},
|
|
50
|
-
create(context) {
|
|
51
|
-
const folder = surfaceFolder(context.filename || context.getFilename())
|
|
52
|
-
if (!folder || ALLOWED_IN_SURFACE_FOLDER.test(folder.rest)) return {}
|
|
53
|
-
return {
|
|
54
|
-
Program(node) {
|
|
55
|
-
context.report({ node, messageId: "extra", data: folder })
|
|
56
|
-
},
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// -- FILE-3 --------------------------------------------------------------------------------------
|
|
62
|
-
|
|
63
|
-
/** What is not component code does not live in the component tree. */
|
|
64
|
-
export const noHelperFolderInComponents = {
|
|
65
|
-
meta: {
|
|
66
|
-
type: "problem",
|
|
67
|
-
docs: { description: "`constants/` `utils/` `types/` `hooks/` are not component folders." },
|
|
68
|
-
schema: [],
|
|
69
|
-
messages: {
|
|
70
|
-
helper:
|
|
71
|
-
"`{{kind}}/` under `src/components/**` - this is not component code, so it does not live in the component tree. A fetch is a `hooks/`, a pure function is a `modules/utils/`, a shape is a `modules/types/`, copy or a config map is a `resources/`. Left here it stays invisible to everyone who would have reused it, so the second author writes it again and the two drift.",
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
create(context) {
|
|
75
|
-
const hit = normalizePath(context.filename || context.getFilename()).match(NON_COMPONENT_FOLDERS)
|
|
76
|
-
if (!hit) return {}
|
|
77
|
-
return {
|
|
78
|
-
Program(node) {
|
|
79
|
-
context.report({ node, messageId: "helper", data: { kind: hit[1] } })
|
|
80
|
-
},
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// -- FILE-1 --------------------------------------------------------------------------------------
|
|
86
|
-
|
|
87
|
-
/** A PascalCase folder exports a family whose name belongs to the folder. */
|
|
88
|
-
export const exportMatchesFolder = {
|
|
89
|
-
meta: {
|
|
90
|
-
type: "suggestion",
|
|
91
|
-
docs: { description: "A PascalCase folder exports a direct named-export family matching the folder." },
|
|
92
|
-
schema: [],
|
|
93
|
-
messages: {
|
|
94
|
-
mismatch:
|
|
95
|
-
"`index.tsx` in folder `{{folder}}` has no direct named export belonging to the folder's family (exports: {{names}}). The path is supposed to predict the name, so a reader who knows one knows the other - export `{{folder}}`, or a member like `{{folder}}Root`.",
|
|
96
|
-
},
|
|
97
|
-
},
|
|
98
|
-
create(context) {
|
|
99
|
-
const hit = normalizePath(context.filename || context.getFilename()).match(/\/([A-Z][A-Za-z0-9]*)\/index\.tsx?$/)
|
|
100
|
-
if (!hit) return {}
|
|
101
|
-
const folder = hit[1]
|
|
102
|
-
const names = new Set()
|
|
103
|
-
const belongsToFamily = (name) =>
|
|
104
|
-
name === folder || (name.startsWith(folder) && name.length > folder.length && /^[A-Z]/.test(name.slice(folder.length)))
|
|
105
|
-
return {
|
|
106
|
-
ExportNamedDeclaration(node) {
|
|
107
|
-
const declaration = node.declaration
|
|
108
|
-
if (declaration && declaration.type === "VariableDeclaration") {
|
|
109
|
-
declaration.declarations.forEach((one) => one.id && one.id.name && names.add(one.id.name))
|
|
110
|
-
}
|
|
111
|
-
if (declaration && declaration.type === "FunctionDeclaration" && declaration.id) names.add(declaration.id.name)
|
|
112
|
-
for (const specifier of node.specifiers || []) {
|
|
113
|
-
if (specifier.exported && specifier.exported.name) names.add(specifier.exported.name)
|
|
114
|
-
}
|
|
115
|
-
},
|
|
116
|
-
"Program:exit"(node) {
|
|
117
|
-
if (names.size === 0 || [...names].some(belongsToFamily)) return
|
|
118
|
-
context.report({ node, messageId: "mismatch", data: { folder, names: [...names].join(", ") } })
|
|
119
|
-
},
|
|
120
|
-
}
|
|
121
|
-
},
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// -- FILE-4 --------------------------------------------------------------------------------------
|
|
125
|
-
|
|
126
|
-
/** Members that make an object literal look like a component family rather than data. */
|
|
127
|
-
const looksLikeComponentMember = (name) => /^[A-Z]/.test(String(name))
|
|
128
|
-
|
|
129
|
-
/** A folder exports a family, never one runtime object standing in for a namespace. */
|
|
130
|
-
export const noRuntimeNamespace = {
|
|
131
|
-
meta: {
|
|
132
|
-
type: "problem",
|
|
133
|
-
docs: { description: "A component family is exported as members, not as one runtime object." },
|
|
134
|
-
schema: [],
|
|
135
|
-
messages: {
|
|
136
|
-
namespace:
|
|
137
|
-
"`{{name}}` is a runtime namespace: one object holding {{members}}. It bundles as a single unit, so a call site importing one member links them all and nothing can be dropped from the build. Export the members directly - the dotted call site is a convenience the bundler pays for.",
|
|
138
|
-
},
|
|
139
|
-
},
|
|
140
|
-
create(context) {
|
|
141
|
-
return {
|
|
142
|
-
ExportNamedDeclaration(node) {
|
|
143
|
-
const declaration = node.declaration
|
|
144
|
-
if (!declaration || declaration.type !== "VariableDeclaration") return
|
|
145
|
-
for (const one of declaration.declarations) {
|
|
146
|
-
const name = one.id && one.id.name
|
|
147
|
-
const init = one.init && one.init.type === "TSAsExpression" ? one.init.expression : one.init
|
|
148
|
-
if (!name || !/^[A-Z]/.test(name) || !init || init.type !== "ObjectExpression") continue
|
|
149
|
-
const members = init.properties
|
|
150
|
-
.filter((property) => property.type === "Property" && !property.computed)
|
|
151
|
-
.map((property) => (property.key.type === "Identifier" ? property.key.name : null))
|
|
152
|
-
.filter(Boolean)
|
|
153
|
-
if (members.length < 2 || !members.every(looksLikeComponentMember)) continue
|
|
154
|
-
context.report({ node: one.id, messageId: "namespace", data: { name, members: members.join(", ") } })
|
|
155
|
-
}
|
|
156
|
-
},
|
|
157
|
-
}
|
|
158
|
-
},
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// -- FILE-5 --------------------------------------------------------------------------------------
|
|
162
|
-
|
|
163
|
-
/** Tiers that know a feature, and therefore belong to the app that owns it. */
|
|
164
|
-
const FEATURE_TIERS = /\/packages\/[^/]+\/src\/(blocks|overlays|pages|layouts)\//
|
|
165
|
-
|
|
166
|
-
/** Tiers that know no feature, and therefore belong to the shared package. */
|
|
167
|
-
const VOCABULARY_TIERS = /\/apps\/[^/]+\/src\/(?:components\/)?(contracts|leaves|composites|branches
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* In a monorepo, the shared package stops below the block.
|
|
171
|
-
*
|
|
172
|
-
* WHY A PATH RULE IS ENOUGH HERE. The feature line is already drawn by the tier name: a leaf knows
|
|
173
|
-
* no domain and a block is a domain sentence, so which side of the workspace each belongs on
|
|
174
|
-
* follows from the folder it is already in. Nothing needs to read the file.
|
|
175
|
-
*
|
|
176
|
-
* WHAT IT COST TO LEARN. A shared package carried one `blocks/FleetRow/` while its own header
|
|
177
|
-
* insisted that "a block carries feature meaning and therefore belongs to the app that owns the
|
|
178
|
-
* feature". The document was right, the tree disagreed, and nothing turned red for either of them -
|
|
179
|
-
* which is the whole argument for this rule rather than the paragraph.
|
|
180
|
-
*/
|
|
181
|
-
export const monorepoTierBelongsToItsSide = {
|
|
182
|
-
meta: {
|
|
183
|
-
type: "problem",
|
|
184
|
-
docs: {
|
|
185
|
-
description:
|
|
186
|
-
"In a monorepo the shared package holds contracts, leaves, composites
|
|
187
|
-
},
|
|
188
|
-
schema: [],
|
|
189
|
-
messages: {
|
|
190
|
-
featureInPackage:
|
|
191
|
-
"`{{tier}}/` knows a feature, so it belongs to the app that owns that feature, not to the shared package - every app that never wanted this domain now ships it. Move it to `apps/<app>/src/components/{{tier}}/`.",
|
|
192
|
-
vocabularyInApp:
|
|
193
|
-
"`{{tier}}/` knows no feature, so one copy belongs to every app - left here, the second app writes it again and the two drift with nothing to notice. Move it to `packages/ui/src/{{tier}}/`.",
|
|
194
|
-
},
|
|
195
|
-
},
|
|
196
|
-
create(context) {
|
|
197
|
-
const file = normalizePath(context.filename || context.getFilename())
|
|
198
|
-
const inPackage = file.match(FEATURE_TIERS)
|
|
199
|
-
const inApp = file.match(VOCABULARY_TIERS)
|
|
200
|
-
return {
|
|
201
|
-
Program(node) {
|
|
202
|
-
if (inPackage) {
|
|
203
|
-
context.report({ node, messageId: "featureInPackage", data: { tier: inPackage[1] } })
|
|
204
|
-
return
|
|
205
|
-
}
|
|
206
|
-
if (inApp) context.report({ node, messageId: "vocabularyInApp", data: { tier: inApp[1] } })
|
|
207
|
-
},
|
|
208
|
-
}
|
|
209
|
-
},
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// -- FILE-6 --------------------------------------------------------------------------------------
|
|
213
|
-
|
|
214
|
-
/** Anything under a routing tree, in either workspace shape. */
|
|
215
|
-
const ROUTE_TREE = /\/src\/app\/(.+)$/
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* What a routing tree is allowed to contain.
|
|
219
|
-
*
|
|
220
|
-
* Next decides these names, not this law: `page`, `layout`, `template`, `loading`, `error`,
|
|
221
|
-
* `not-found`, `global-error`, `default`, `route`, `sitemap`, `robots`, `manifest`, `opengraph-image`
|
|
222
|
-
* and `icon` are framework slots. Two more are admitted because the root layout mounts them and
|
|
223
|
-
* there is nowhere else they could go: `providers`, the client boundary above every route, and
|
|
224
|
-
* `globals`, the stylesheet the document imports. Everything else in that folder is a file somebody
|
|
225
|
-
* put there.
|
|
226
|
-
*/
|
|
227
|
-
const ROUTE_TREE_FILES =
|
|
228
|
-
/^(?:page|layout|template|loading|error|not-found|global-error|default|route|providers|globals|middleware|sitemap|robots|manifest|opengraph-image|twitter-image|apple-icon|icon|favicon)(?:\.[a-z-]+)?\.(?:tsx?|jsx?|css|json)$/
|
|
229
|
-
|
|
230
|
-
/** `app/api/**` is server code, not a screen; `_private/**` is Next's own opt-out folder. */
|
|
231
|
-
const ROUTE_TREE_EXEMPT = /^(?:api\/|_)/
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* A test sitting beside the thing it tests.
|
|
235
|
-
*
|
|
236
|
-
* EXEMPT, because a test is not a component and cannot become the second page this rule exists to
|
|
237
|
-
* prevent - it ships in no bundle and no route renders it. Refusing it would push route tests out of
|
|
238
|
-
* the one directory where the next reader looks for them, which is the opposite of the twin-test
|
|
239
|
-
* habit every other tier here follows.
|
|
240
|
-
*
|
|
241
|
-
* The name is deliberately not required to match `page` or `layout`. A route's tests split by
|
|
242
|
-
* CONCERN - what the screen renders, who may reach it, where the boundary sits - and forcing them
|
|
243
|
-
* into one `page.
|
|
244
|
-
*/
|
|
245
|
-
const ROUTE_TREE_TEST = /\.
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* A file under `app/` names which page renders at which URL, and does nothing else.
|
|
249
|
-
*
|
|
250
|
-
* WHY THIS RULE EXISTS RATHER THAN THE PARAGRAPH ALONE. The law said "the route file mounts and
|
|
251
|
-
* nothing else" and had said so for as long as the tier list existed. Nothing checked it. A page
|
|
252
|
-
* owner was written to `app/<segment>/fleet-page.tsx`, carried through a build, a lint run, a
|
|
253
|
-
* typecheck, four sealed screenshots and an approval, and arrived at the edge of a production write
|
|
254
|
-
* with every gate green - because every gate was reading rules, and this one was only prose.
|
|
255
|
-
*
|
|
256
|
-
* WHY IT READS THE FILENAME AND NOT THE CONTENTS. "Drawing" is not a property this can measure: a
|
|
257
|
-
* route that mounts one component and a route that arranges six both return JSX. What CAN be
|
|
258
|
-
* measured exactly is whether a file in the routing tree is one of the framework's own slots. A page
|
|
259
|
-
* owner is a component, a component has a name, and a named component belongs in the tier that
|
|
260
|
-
* groups it with its siblings - `components/pages/<Name>/`, where the next author looks for it.
|
|
261
|
-
*
|
|
262
|
-
* The cost is stated where it bites: a route file that draws inside `page.tsx` still passes. That is
|
|
263
|
-
* FILE-2's `component.tsx` split to catch, and no path rule can see it.
|
|
264
|
-
*/
|
|
265
|
-
export const routeTreeHoldsRoutesOnly = {
|
|
266
|
-
meta: {
|
|
267
|
-
type: "problem",
|
|
268
|
-
docs: { description: "`src/app/**` holds framework route files only; a page owner lives in `components/pages/<Name>/`." },
|
|
269
|
-
schema: [],
|
|
270
|
-
messages: {
|
|
271
|
-
stray:
|
|
272
|
-
"`app/{{rest}}` is not a route file. A file under `app/` names which page renders at which URL - `page`, `layout`, `loading`, `error` and their siblings are the framework's slots, and `{{basename}}` is not one of them. This is a component, so it belongs where its siblings are: `components/pages/<Name>/` for a screen, `components/blocks/<category>/<Name>/` for a domain sentence. Left here it is the one place nobody looks, and the route has quietly become a second page.",
|
|
273
|
-
},
|
|
274
|
-
},
|
|
275
|
-
create(context) {
|
|
276
|
-
const file = normalizePath(context.filename || context.getFilename())
|
|
277
|
-
const hit = file.match(ROUTE_TREE)
|
|
278
|
-
if (!hit) return {}
|
|
279
|
-
const rest = hit[1]
|
|
280
|
-
if (ROUTE_TREE_EXEMPT.test(rest)) return {}
|
|
281
|
-
const basename = rest.slice(rest.lastIndexOf("/") + 1)
|
|
282
|
-
if (ROUTE_TREE_TEST.test(basename)) return {}
|
|
283
|
-
if (ROUTE_TREE_FILES.test(basename)) return {}
|
|
284
|
-
return {
|
|
285
|
-
Program(node) {
|
|
286
|
-
context.report({ node, messageId: "stray", data: { rest, basename } })
|
|
287
|
-
},
|
|
288
|
-
}
|
|
289
|
-
},
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** The rules this law contributes to the plugin. */
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
"
|
|
297
|
-
"
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `file-layout.md`.
|
|
3
|
+
*
|
|
4
|
+
* Four rules locking the same habit: stuffing a cluster into the folder of one screen. It always
|
|
5
|
+
* starts harmlessly - "only this page uses it" - and ends as a page folder holding four components,
|
|
6
|
+
* a constants folder, a utils folder and three hand-copied resting shapes, at which point the
|
|
7
|
+
* screen is a second codebase with its own private vocabulary that nobody else can reuse.
|
|
8
|
+
*
|
|
9
|
+
* Every rule here reads the PATH rather than the contents, which is what makes them cheap and
|
|
10
|
+
* exact. The cost of that choice is stated where it bites: a path rule cannot tell a component from
|
|
11
|
+
* a helper, only a folder from a folder, so each rule below names the destination it sends things
|
|
12
|
+
* to rather than merely refusing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
16
|
+
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
17
|
+
|
|
18
|
+
/** Tiers whose folder holds exactly the two halves of one surface. */
|
|
19
|
+
const TWO_FILE_TIERS = /\/src\/components\/(pages|layouts)\/([^/]+)\/(.+)$/
|
|
20
|
+
|
|
21
|
+
/** Overlays carry a feature category between the tier and the surface. */
|
|
22
|
+
const TWO_FILE_TIERS_WITH_CATEGORY = /\/src\/components\/(overlays)\/[^/]+\/([^/]+)\/(.+)$/
|
|
23
|
+
|
|
24
|
+
/** The two halves, and the twin test of each, are the whole of what a surface folder may hold. */
|
|
25
|
+
const ALLOWED_IN_SURFACE_FOLDER = /^(?:component|index)(?:\.spec)?\.tsx?$/
|
|
26
|
+
|
|
27
|
+
/** Folders that are not component code, whatever they are nested inside. */
|
|
28
|
+
const NON_COMPONENT_FOLDERS = /\/src\/components\/.*\/(constants|utils|types|hooks)\//
|
|
29
|
+
|
|
30
|
+
/** Whether the path sits in ONE surface folder, and what that folder is named. */
|
|
31
|
+
const surfaceFolder = (filename) => {
|
|
32
|
+
const file = normalizePath(filename)
|
|
33
|
+
const hit = file.match(TWO_FILE_TIERS) || file.match(TWO_FILE_TIERS_WITH_CATEGORY)
|
|
34
|
+
return hit ? { tier: hit[1], name: hit[2], rest: hit[3] } : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// -- FILE-2 --------------------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/** A page, layout or overlay folder holds its two halves and nothing else. */
|
|
40
|
+
export const surfaceFolderTwoFilesOnly = {
|
|
41
|
+
meta: {
|
|
42
|
+
type: "problem",
|
|
43
|
+
docs: { description: "A page/layout/overlay folder holds `component.tsx` + `index.tsx` and nothing else." },
|
|
44
|
+
schema: [],
|
|
45
|
+
messages: {
|
|
46
|
+
extra:
|
|
47
|
+
"`{{tier}}/{{name}}/` contains `{{rest}}` - a surface folder holds its two halves ONLY (`component.tsx` is the shape, `index.tsx` is the wiring). Whatever this is, it has a real home: a component of its own goes to `blocks/<category>/`, a closed arrangement to `composites/`, a fetch to `hooks/`, a pure helper to `modules/utils/`, a shape to `modules/types/`, copy or a config map to `resources/`. \"Only this screen uses it\" is how a folder becomes a second codebase.",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
create(context) {
|
|
51
|
+
const folder = surfaceFolder(context.filename || context.getFilename())
|
|
52
|
+
if (!folder || ALLOWED_IN_SURFACE_FOLDER.test(folder.rest)) return {}
|
|
53
|
+
return {
|
|
54
|
+
Program(node) {
|
|
55
|
+
context.report({ node, messageId: "extra", data: folder })
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// -- FILE-3 --------------------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/** What is not component code does not live in the component tree. */
|
|
64
|
+
export const noHelperFolderInComponents = {
|
|
65
|
+
meta: {
|
|
66
|
+
type: "problem",
|
|
67
|
+
docs: { description: "`constants/` `utils/` `types/` `hooks/` are not component folders." },
|
|
68
|
+
schema: [],
|
|
69
|
+
messages: {
|
|
70
|
+
helper:
|
|
71
|
+
"`{{kind}}/` under `src/components/**` - this is not component code, so it does not live in the component tree. A fetch is a `hooks/`, a pure function is a `modules/utils/`, a shape is a `modules/types/`, copy or a config map is a `resources/`. Left here it stays invisible to everyone who would have reused it, so the second author writes it again and the two drift.",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
create(context) {
|
|
75
|
+
const hit = normalizePath(context.filename || context.getFilename()).match(NON_COMPONENT_FOLDERS)
|
|
76
|
+
if (!hit) return {}
|
|
77
|
+
return {
|
|
78
|
+
Program(node) {
|
|
79
|
+
context.report({ node, messageId: "helper", data: { kind: hit[1] } })
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// -- FILE-1 --------------------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/** A PascalCase folder exports a family whose name belongs to the folder. */
|
|
88
|
+
export const exportMatchesFolder = {
|
|
89
|
+
meta: {
|
|
90
|
+
type: "suggestion",
|
|
91
|
+
docs: { description: "A PascalCase folder exports a direct named-export family matching the folder." },
|
|
92
|
+
schema: [],
|
|
93
|
+
messages: {
|
|
94
|
+
mismatch:
|
|
95
|
+
"`index.tsx` in folder `{{folder}}` has no direct named export belonging to the folder's family (exports: {{names}}). The path is supposed to predict the name, so a reader who knows one knows the other - export `{{folder}}`, or a member like `{{folder}}Root`.",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
create(context) {
|
|
99
|
+
const hit = normalizePath(context.filename || context.getFilename()).match(/\/([A-Z][A-Za-z0-9]*)\/index\.tsx?$/)
|
|
100
|
+
if (!hit) return {}
|
|
101
|
+
const folder = hit[1]
|
|
102
|
+
const names = new Set()
|
|
103
|
+
const belongsToFamily = (name) =>
|
|
104
|
+
name === folder || (name.startsWith(folder) && name.length > folder.length && /^[A-Z]/.test(name.slice(folder.length)))
|
|
105
|
+
return {
|
|
106
|
+
ExportNamedDeclaration(node) {
|
|
107
|
+
const declaration = node.declaration
|
|
108
|
+
if (declaration && declaration.type === "VariableDeclaration") {
|
|
109
|
+
declaration.declarations.forEach((one) => one.id && one.id.name && names.add(one.id.name))
|
|
110
|
+
}
|
|
111
|
+
if (declaration && declaration.type === "FunctionDeclaration" && declaration.id) names.add(declaration.id.name)
|
|
112
|
+
for (const specifier of node.specifiers || []) {
|
|
113
|
+
if (specifier.exported && specifier.exported.name) names.add(specifier.exported.name)
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
"Program:exit"(node) {
|
|
117
|
+
if (names.size === 0 || [...names].some(belongsToFamily)) return
|
|
118
|
+
context.report({ node, messageId: "mismatch", data: { folder, names: [...names].join(", ") } })
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// -- FILE-4 --------------------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/** Members that make an object literal look like a component family rather than data. */
|
|
127
|
+
const looksLikeComponentMember = (name) => /^[A-Z]/.test(String(name))
|
|
128
|
+
|
|
129
|
+
/** A folder exports a family, never one runtime object standing in for a namespace. */
|
|
130
|
+
export const noRuntimeNamespace = {
|
|
131
|
+
meta: {
|
|
132
|
+
type: "problem",
|
|
133
|
+
docs: { description: "A component family is exported as members, not as one runtime object." },
|
|
134
|
+
schema: [],
|
|
135
|
+
messages: {
|
|
136
|
+
namespace:
|
|
137
|
+
"`{{name}}` is a runtime namespace: one object holding {{members}}. It bundles as a single unit, so a call site importing one member links them all and nothing can be dropped from the build. Export the members directly - the dotted call site is a convenience the bundler pays for.",
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
create(context) {
|
|
141
|
+
return {
|
|
142
|
+
ExportNamedDeclaration(node) {
|
|
143
|
+
const declaration = node.declaration
|
|
144
|
+
if (!declaration || declaration.type !== "VariableDeclaration") return
|
|
145
|
+
for (const one of declaration.declarations) {
|
|
146
|
+
const name = one.id && one.id.name
|
|
147
|
+
const init = one.init && one.init.type === "TSAsExpression" ? one.init.expression : one.init
|
|
148
|
+
if (!name || !/^[A-Z]/.test(name) || !init || init.type !== "ObjectExpression") continue
|
|
149
|
+
const members = init.properties
|
|
150
|
+
.filter((property) => property.type === "Property" && !property.computed)
|
|
151
|
+
.map((property) => (property.key.type === "Identifier" ? property.key.name : null))
|
|
152
|
+
.filter(Boolean)
|
|
153
|
+
if (members.length < 2 || !members.every(looksLikeComponentMember)) continue
|
|
154
|
+
context.report({ node: one.id, messageId: "namespace", data: { name, members: members.join(", ") } })
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// -- FILE-5 --------------------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/** Tiers that know a feature, and therefore belong to the app that owns it. */
|
|
164
|
+
const FEATURE_TIERS = /\/packages\/[^/]+\/src\/(blocks|overlays|pages|layouts)\//
|
|
165
|
+
|
|
166
|
+
/** Tiers that know no feature, and therefore belong to the shared package. */
|
|
167
|
+
const VOCABULARY_TIERS = /\/apps\/[^/]+\/src\/(?:components\/)?(contracts|leaves|composites|branches)\//
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* In a monorepo, the shared package stops below the block.
|
|
171
|
+
*
|
|
172
|
+
* WHY A PATH RULE IS ENOUGH HERE. The feature line is already drawn by the tier name: a leaf knows
|
|
173
|
+
* no domain and a block is a domain sentence, so which side of the workspace each belongs on
|
|
174
|
+
* follows from the folder it is already in. Nothing needs to read the file.
|
|
175
|
+
*
|
|
176
|
+
* WHAT IT COST TO LEARN. A shared package carried one `blocks/FleetRow/` while its own header
|
|
177
|
+
* insisted that "a block carries feature meaning and therefore belongs to the app that owns the
|
|
178
|
+
* feature". The document was right, the tree disagreed, and nothing turned red for either of them -
|
|
179
|
+
* which is the whole argument for this rule rather than the paragraph.
|
|
180
|
+
*/
|
|
181
|
+
export const monorepoTierBelongsToItsSide = {
|
|
182
|
+
meta: {
|
|
183
|
+
type: "problem",
|
|
184
|
+
docs: {
|
|
185
|
+
description:
|
|
186
|
+
"In a monorepo the shared package holds contracts, leaves, composites and branches; blocks, overlays, layouts and pages belong to the app that owns the feature.",
|
|
187
|
+
},
|
|
188
|
+
schema: [],
|
|
189
|
+
messages: {
|
|
190
|
+
featureInPackage:
|
|
191
|
+
"`{{tier}}/` knows a feature, so it belongs to the app that owns that feature, not to the shared package - every app that never wanted this domain now ships it. Move it to `apps/<app>/src/components/{{tier}}/`.",
|
|
192
|
+
vocabularyInApp:
|
|
193
|
+
"`{{tier}}/` knows no feature, so one copy belongs to every app - left here, the second app writes it again and the two drift with nothing to notice. Move it to `packages/ui/src/{{tier}}/`.",
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
create(context) {
|
|
197
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
198
|
+
const inPackage = file.match(FEATURE_TIERS)
|
|
199
|
+
const inApp = file.match(VOCABULARY_TIERS)
|
|
200
|
+
return {
|
|
201
|
+
Program(node) {
|
|
202
|
+
if (inPackage) {
|
|
203
|
+
context.report({ node, messageId: "featureInPackage", data: { tier: inPackage[1] } })
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
if (inApp) context.report({ node, messageId: "vocabularyInApp", data: { tier: inApp[1] } })
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// -- FILE-6 --------------------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
/** Anything under a routing tree, in either workspace shape. */
|
|
215
|
+
const ROUTE_TREE = /\/src\/app\/(.+)$/
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* What a routing tree is allowed to contain.
|
|
219
|
+
*
|
|
220
|
+
* Next decides these names, not this law: `page`, `layout`, `template`, `loading`, `error`,
|
|
221
|
+
* `not-found`, `global-error`, `default`, `route`, `sitemap`, `robots`, `manifest`, `opengraph-image`
|
|
222
|
+
* and `icon` are framework slots. Two more are admitted because the root layout mounts them and
|
|
223
|
+
* there is nowhere else they could go: `providers`, the client boundary above every route, and
|
|
224
|
+
* `globals`, the stylesheet the document imports. Everything else in that folder is a file somebody
|
|
225
|
+
* put there.
|
|
226
|
+
*/
|
|
227
|
+
const ROUTE_TREE_FILES =
|
|
228
|
+
/^(?:page|layout|template|loading|error|not-found|global-error|default|route|providers|globals|middleware|sitemap|robots|manifest|opengraph-image|twitter-image|apple-icon|icon|favicon)(?:\.[a-z-]+)?\.(?:tsx?|jsx?|css|json)$/
|
|
229
|
+
|
|
230
|
+
/** `app/api/**` is server code, not a screen; `_private/**` is Next's own opt-out folder. */
|
|
231
|
+
const ROUTE_TREE_EXEMPT = /^(?:api\/|_)/
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A test sitting beside the thing it tests.
|
|
235
|
+
*
|
|
236
|
+
* EXEMPT, because a test is not a component and cannot become the second page this rule exists to
|
|
237
|
+
* prevent - it ships in no bundle and no route renders it. Refusing it would push route tests out of
|
|
238
|
+
* the one directory where the next reader looks for them, which is the opposite of the twin-test
|
|
239
|
+
* habit every other tier here follows.
|
|
240
|
+
*
|
|
241
|
+
* The name is deliberately not required to match `page` or `layout`. A route's tests split by
|
|
242
|
+
* CONCERN - what the screen renders, who may reach it, where the boundary sits - and forcing them
|
|
243
|
+
* into one `page.spec.tsx` would buy nothing but a longer file.
|
|
244
|
+
*/
|
|
245
|
+
const ROUTE_TREE_TEST = /\.spec\.(?:tsx?|jsx?)$/
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* A file under `app/` names which page renders at which URL, and does nothing else.
|
|
249
|
+
*
|
|
250
|
+
* WHY THIS RULE EXISTS RATHER THAN THE PARAGRAPH ALONE. The law said "the route file mounts and
|
|
251
|
+
* nothing else" and had said so for as long as the tier list existed. Nothing checked it. A page
|
|
252
|
+
* owner was written to `app/<segment>/fleet-page.tsx`, carried through a build, a lint run, a
|
|
253
|
+
* typecheck, four sealed screenshots and an approval, and arrived at the edge of a production write
|
|
254
|
+
* with every gate green - because every gate was reading rules, and this one was only prose.
|
|
255
|
+
*
|
|
256
|
+
* WHY IT READS THE FILENAME AND NOT THE CONTENTS. "Drawing" is not a property this can measure: a
|
|
257
|
+
* route that mounts one component and a route that arranges six both return JSX. What CAN be
|
|
258
|
+
* measured exactly is whether a file in the routing tree is one of the framework's own slots. A page
|
|
259
|
+
* owner is a component, a component has a name, and a named component belongs in the tier that
|
|
260
|
+
* groups it with its siblings - `components/pages/<Name>/`, where the next author looks for it.
|
|
261
|
+
*
|
|
262
|
+
* The cost is stated where it bites: a route file that draws inside `page.tsx` still passes. That is
|
|
263
|
+
* FILE-2's `component.tsx` split to catch, and no path rule can see it.
|
|
264
|
+
*/
|
|
265
|
+
export const routeTreeHoldsRoutesOnly = {
|
|
266
|
+
meta: {
|
|
267
|
+
type: "problem",
|
|
268
|
+
docs: { description: "`src/app/**` holds framework route files only; a page owner lives in `components/pages/<Name>/`." },
|
|
269
|
+
schema: [],
|
|
270
|
+
messages: {
|
|
271
|
+
stray:
|
|
272
|
+
"`app/{{rest}}` is not a route file. A file under `app/` names which page renders at which URL - `page`, `layout`, `loading`, `error` and their siblings are the framework's slots, and `{{basename}}` is not one of them. This is a component, so it belongs where its siblings are: `components/pages/<Name>/` for a screen, `components/blocks/<category>/<Name>/` for a domain sentence. Left here it is the one place nobody looks, and the route has quietly become a second page.",
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
create(context) {
|
|
276
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
277
|
+
const hit = file.match(ROUTE_TREE)
|
|
278
|
+
if (!hit) return {}
|
|
279
|
+
const rest = hit[1]
|
|
280
|
+
if (ROUTE_TREE_EXEMPT.test(rest)) return {}
|
|
281
|
+
const basename = rest.slice(rest.lastIndexOf("/") + 1)
|
|
282
|
+
if (ROUTE_TREE_TEST.test(basename)) return {}
|
|
283
|
+
if (ROUTE_TREE_FILES.test(basename)) return {}
|
|
284
|
+
return {
|
|
285
|
+
Program(node) {
|
|
286
|
+
context.report({ node, messageId: "stray", data: { rest, basename } })
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The rules this law contributes to the plugin. */
|
|
293
|
+
const TIER_PATH = /\/(?:src\/components|packages\/[^/]+\/src|apps\/[^/]+\/src\/(?:components\/)?)\/(leaves|composites|branches|blocks|layouts|overlays|pages)\//
|
|
294
|
+
|
|
295
|
+
function propertyName(node) {
|
|
296
|
+
if (node.key?.type === "Identifier") return node.key.name
|
|
297
|
+
if (node.key?.type === "Literal") return node.key.value
|
|
298
|
+
return null
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** `meta.shape` must tell the same truth as the source path. */
|
|
302
|
+
export const sourceTierMarkerMatchesFolder = {
|
|
303
|
+
meta: {
|
|
304
|
+
type: "problem",
|
|
305
|
+
docs: { description: "A source tier marker matches the component folder that owns it." },
|
|
306
|
+
schema: [],
|
|
307
|
+
messages: {
|
|
308
|
+
mismatch:
|
|
309
|
+
"This file sits in `{{tier}}/` but declares `meta.shape = {{shape}}`. A source marker is evidence, not a second classification; change the owner or the marker so the path and source agree.",
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
create(context) {
|
|
313
|
+
const hit = normalizePath(context.filename || context.getFilename()).match(TIER_PATH)
|
|
314
|
+
if (!hit) return {}
|
|
315
|
+
const expected = {
|
|
316
|
+
blocks: "block",
|
|
317
|
+
branches: "branch",
|
|
318
|
+
composites: "composite",
|
|
319
|
+
contracts: "contract",
|
|
320
|
+
layouts: "layout",
|
|
321
|
+
leaves: "leaf",
|
|
322
|
+
overlays: "overlay",
|
|
323
|
+
pages: "page",
|
|
324
|
+
}[hit[1]]
|
|
325
|
+
return {
|
|
326
|
+
ExportNamedDeclaration(node) {
|
|
327
|
+
const declaration = node.declaration
|
|
328
|
+
if (!declaration || declaration.type !== "VariableDeclaration") return
|
|
329
|
+
for (const item of declaration.declarations || []) {
|
|
330
|
+
if (item.id?.type !== "Identifier" || item.id.name !== "meta") continue
|
|
331
|
+
let init = item.init
|
|
332
|
+
while (init && (init.type === "TSAsExpression" || init.type === "TSSatisfiesExpression")) init = init.expression
|
|
333
|
+
if (!init || init.type !== "ObjectExpression") continue
|
|
334
|
+
const shapeProperty = init.properties.find((property) =>
|
|
335
|
+
property.type === "Property" && !property.computed && propertyName(property) === "shape")
|
|
336
|
+
const shape = shapeProperty?.value?.type === "Literal" ? shapeProperty.value.value : null
|
|
337
|
+
if (typeof shape === "string" && shape !== expected) {
|
|
338
|
+
context.report({ node: shapeProperty.value, messageId: "mismatch", data: { tier: hit[1], shape } })
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const SHELL_TIER = /\/(?:src\/components|packages\/[^/]+\/src|apps\/[^/]+\/src\/(?:components\/)?)\/shells\//
|
|
347
|
+
|
|
348
|
+
/** `shells/` no longer exists; vendor mechanics are closed named branches. */
|
|
349
|
+
export const noShellTier = {
|
|
350
|
+
meta: {
|
|
351
|
+
type: "problem",
|
|
352
|
+
docs: { description: "The component vocabulary has no shell tier." },
|
|
353
|
+
schema: [],
|
|
354
|
+
messages: {
|
|
355
|
+
shell:
|
|
356
|
+
"`shells/` is an untyped branch exemption. Move the owner to a named branch, replace children with typed contract content, and keep vendor mechanics closed there.",
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
create(context) {
|
|
360
|
+
if (!SHELL_TIER.test(normalizePath(context.filename || context.getFilename()))) return {}
|
|
361
|
+
return { Program(node) { context.report({ node, messageId: "shell" }) } }
|
|
362
|
+
},
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// -- FILE-9 --------------------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
const UNIT_TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
|
|
368
|
+
const SEPARATE_FRONTEND_TEST_TREE = /\/(?:src\/tests|tests?\/unit|e2e)(?:\/|$)/
|
|
369
|
+
|
|
370
|
+
/** Frontend units are `.spec.` twins beside the source they exercise. */
|
|
371
|
+
export const unitTestColocated = {
|
|
372
|
+
meta: {
|
|
373
|
+
type: "problem",
|
|
374
|
+
docs: { description: "Frontend unit tests are colocated `.spec.` files; frontend owns no separate test tree." },
|
|
375
|
+
schema: [],
|
|
376
|
+
messages: {
|
|
377
|
+
suffix: "`{{name}}` uses the generic `.test.` suffix. A frontend unit is a colocated `.spec.` twin beside its production owner.",
|
|
378
|
+
bucket: "`{{path}}` files a frontend unit in a separate test bucket. Move the cases beside their production owner; only backend E2E owns a separate test tree.",
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
create(context) {
|
|
382
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
383
|
+
const match = file.match(UNIT_TEST_FILE)
|
|
384
|
+
if (!match) return {}
|
|
385
|
+
return {
|
|
386
|
+
Program(node) {
|
|
387
|
+
if (match[1] === "test") context.report({ node, messageId: "suffix", data: { name: file.slice(file.lastIndexOf("/") + 1) } })
|
|
388
|
+
if (SEPARATE_FRONTEND_TEST_TREE.test(file)) context.report({ node, messageId: "bucket", data: { path: file } })
|
|
389
|
+
},
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export const rules = {
|
|
395
|
+
"no-shell-tier": noShellTier,
|
|
396
|
+
"source-tier-marker-matches-folder": sourceTierMarkerMatchesFolder,
|
|
397
|
+
"surface-folder-two-files-only": surfaceFolderTwoFilesOnly,
|
|
398
|
+
"route-tree-holds-routes-only": routeTreeHoldsRoutesOnly,
|
|
399
|
+
"no-helper-folder-in-components": noHelperFolderInComponents,
|
|
400
|
+
"export-matches-folder": exportMatchesFolder,
|
|
401
|
+
"no-runtime-namespace": noRuntimeNamespace,
|
|
402
|
+
"monorepo-tier-belongs-to-its-side": monorepoTierBelongsToItsSide,
|
|
403
|
+
"unit-test-colocated": unitTestColocated,
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
408
|
+
*
|
|
409
|
+
* A consuming repository's `eslint.config.mjs` stays the authority on what is actually switched on.
|
|
410
|
+
* `export-matches-folder` is the one worth adopting at `warn` first in an existing tree: it fires
|
|
411
|
+
* on every folder whose convention predates the rule, and that count is a migration rather than a
|
|
412
|
+
* defect.
|
|
413
|
+
*/
|
|
414
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|