@starci/eslint-canon-fe 1.0.0

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/contracts.ts ADDED
@@ -0,0 +1,312 @@
1
+ /**
2
+ * TEMPLATE - `src/components/contracts/index.ts`.
3
+ *
4
+ * Copy this file to that path and grow the table. Everything above the table is the SCAFFOLDING and
5
+ * is the same in every repository; the entries themselves are the repository's own vocabulary, and
6
+ * the seed below exists only to show each feature once.
7
+ *
8
+ * The law this file holds is `fe/canon/patterns/contract.md`. The rules that patrol what a type
9
+ * cannot see are in `contract.mjs`.
10
+ *
11
+ * WHY THIS IS A TYPE AND NOT A CONVENTION. Two of the constraints here are closed unions, so a
12
+ * value outside them is not forbidden - it is unrepresentable. That single property is what makes a
13
+ * whole family of patrol rules unnecessary: there is nothing to police when the bad value cannot be
14
+ * typed, and nothing to argue about when the compiler has already refused.
15
+ */
16
+ import type { ComponentType, ReactNode } from "react"
17
+
18
+ /**
19
+ * What a component drawn inside a node receives. It never receives the key that placed it: the node
20
+ * is drawn by the branch, and a child that could read the key could also disagree with it.
21
+ *
22
+ * Declared here rather than in `props.ts` because the component types below are built from it, and
23
+ * this file cannot import that one without the two forming a cycle.
24
+ */
25
+ export type InnerProps = { readonly isLoading?: boolean }
26
+
27
+ /**
28
+ * The closed set of classes a node may lay its children out with.
29
+ *
30
+ * `gap-[13px]` is not forbidden - it is UNREPRESENTABLE, because it is not a member. Grow this list
31
+ * deliberately: a new member is a decision about the house scale, and it should read as one in the
32
+ * diff rather than arriving inside a component nobody reviewed closely.
33
+ */
34
+ export type LayoutClassName =
35
+ | "flex" | "grid" | "flex-col" | "flex-row" | "flex-wrap" | "relative"
36
+ | "items-center" | "items-baseline" | "items-start"
37
+ | "justify-between" | "justify-center" | "[&>*]:w-full"
38
+ | "gap-1" | "gap-2" | "gap-3" | "gap-4" | "gap-6" | "gap-8"
39
+ | "grid-cols-1" | "sm:grid-cols-2"
40
+ | "mx-auto" | "w-full" | "text-center"
41
+ | "px-3" | "px-4" | "py-2" | "py-3" | "py-6" | "p-0" | "p-4" | "p-6"
42
+ | "rounded-xl" | "rounded-2xl" | "rounded-full"
43
+ | "bg-surface" | "bg-default" | "shadow-surface"
44
+ // Positional selectors are accepted HERE and only here - see the note on the table below.
45
+ | "[&>*]:px-4" | "[&>*]:py-3"
46
+ | "[&>*:first-child]:pt-4" | "[&>*:last-child]:pb-4"
47
+ | "[&>*:first-child]:grow" | "[&>*:last-child]:grow"
48
+ | "[&>*:nth-child(2)]:min-w-0" | "[&>*:nth-child(2)]:grow"
49
+
50
+ /**
51
+ * The elements a node may open.
52
+ *
53
+ * A node is not always a `div`: a run of days IS a list, and a field with a submit IS a form, and an
54
+ * element chosen for meaning cannot be swapped for a neutral one without changing what assistive
55
+ * technology reports.
56
+ *
57
+ * THIS UNION IS LOAD-BEARING FOR A REASON THAT IS NOT OBVIOUS. Without it the frame draws only
58
+ * `div`, so any shape needing `<ul>` has nowhere lawful to live and gets filed among the leaves
59
+ * instead - where it may write its own classes. That is how an entire tier fills up with
60
+ * arrangements, and the duplication is invisible afterwards because no rule reads both places.
61
+ *
62
+ * `main` AND `ol` WERE ADDED FROM A CONSUMING REPOSITORY, which is the part worth recording. A
63
+ * repository had grown its own copy of this union under a different NAME - `ContractHostTag` - with
64
+ * `main` and `ol` present and `li`, `header` and `footer` missing. Neither list contained the other,
65
+ * so both were wrong and neither could tell: the scaffolding above the table is supposed to be
66
+ * identical everywhere, and a renamed type is a divergence no import ever reports. The members are
67
+ * merged here, at the source, and the repository takes this name.
68
+ *
69
+ * `main` is the document's one landmark. `ol` is `ul` where the sequence is the meaning - module
70
+ * three follows module two - and it is useless without the `li` beside it, which is the trap the
71
+ * other repository hit: it admitted both list containers and not the item, so a list row had to be
72
+ * a `div`, which is invalid HTML and silent. A `<ul>` whose children are not `<li>` stops being
73
+ * announced as a list at all, and nothing turns red, because a `div` is never wrong on its own.
74
+ */
75
+ export type ContractHost =
76
+ | "div" | "ul" | "ol" | "li" | "form" | "nav" | "main" | "section" | "header" | "footer" | "aside"
77
+
78
+ /**
79
+ * ONE CHILD SLOT. Either the child is another node, or it is a leaf.
80
+ *
81
+ * A node child names a KEY, and the key is checked against this table. A leaf child cannot: a leaf
82
+ * is not a node and has no key. It names the leaf and the props it must take, and the leaf declares
83
+ * the same pair - so the table never imports a component, and the compiler still matches them.
84
+ *
85
+ * `repeats: true` says the live slot is an array. `restingCount` is separate because it is the
86
+ * loading cardinality, not a claim about the dynamic live length.
87
+ */
88
+ export type ChildCardinality =
89
+ | { readonly repeats?: false; readonly restingCount?: never }
90
+ | { readonly repeats: true; readonly restingCount: number }
91
+
92
+ export type ChildSpec = ChildCardinality & {
93
+ readonly contract?: string | ReadonlyArray<string>
94
+ readonly leaf?: string | ReadonlyArray<string>
95
+ readonly props?: Readonly<Record<string, string | number | boolean | null>>
96
+ readonly optional?: true
97
+ }
98
+
99
+ /** One entry: a node's own classes, what sits inside it, the element it opens, and why. */
100
+ export interface ContractSpec {
101
+ /** The classes of the node itself. Not a prop, not reachable by a caller. */
102
+ readonly classNames: ReadonlyArray<LayoutClassName>
103
+ /**
104
+ * WHAT SITS INSIDE THIS NODE, position by position, in the order it appears.
105
+ *
106
+ * THIS IS THE CHILD CONTRACT. It was a sentence in the key's name once, which meant a reader
107
+ * could check it and nothing else could. Written as a list, every position has a type.
108
+ *
109
+ * A position is one of two things, and the difference is the difference between the tiers:
110
+ *
111
+ * - `{ contract }`, when the child is another node. A key this table does not define is a
112
+ * compile error here, rather than an empty box on a screen somebody has to open to notice.
113
+ * - `{ leaf, props }`, when the child is a leaf. Leaves have no key, so the slot names the
114
+ * leaf and the props it must take instead.
115
+ *
116
+ * SLOTS ARE NAMED, NOT COUNTED. A record rather than a list, because a position is a fact about
117
+ * the tree and a number is only a fact about the file: insert a child into a list and every
118
+ * slot after it silently means something else. A name survives the insertion, reads at the call
119
+ * site without counting, and gives the reason a thing to talk about.
120
+ *
121
+ * The whole tree above the leaves is therefore declared in this file, and a shape can be read
122
+ * without opening a single component.
123
+ */
124
+ readonly children: { readonly [slot: string]: ChildSpec }
125
+ /**
126
+ * The element this node opens. Absent means `div` - the neutral box, which is most nodes.
127
+ *
128
+ * Stated here rather than passed by a caller: two call sites of one key that disagreed about
129
+ * the element would be two different nodes wearing one name.
130
+ */
131
+ readonly host?: ContractHost
132
+ /**
133
+ * Why the children of this node sit the way they do, in one sentence.
134
+ *
135
+ * A REASON, never a restatement of the key: "a row of chips" only says the key again; "the tags
136
+ * wrap onto their own line before the title does" is the fact that made the node exist.
137
+ */
138
+ readonly why: string
139
+ }
140
+
141
+ /**
142
+ * Build the table.
143
+ *
144
+ * A function rather than a bare literal so the keys are checked in one place and stay literal
145
+ * without an `as const` at the call site.
146
+ *
147
+ * THE CONSTRAINT IS SELF-REFERENTIAL, and that is what makes the child contract real: `children` is
148
+ * narrowed to `keyof T`, so a child naming a key this table does not define is a compile error in
149
+ * this file rather than an empty node on a screen somebody has to open to notice.
150
+ */
151
+ const buildContracts = <
152
+ const T extends {
153
+ readonly [K in keyof T]: ContractSpec & {
154
+ readonly children: {
155
+ readonly [slot: string]: ChildCardinality & {
156
+ readonly contract?: keyof T | ReadonlyArray<keyof T>
157
+ readonly leaf?: string | ReadonlyArray<string>
158
+ readonly props?: Readonly<Record<string, string | number | boolean | null>>
159
+ readonly optional?: true
160
+ }
161
+ }
162
+ }
163
+ },
164
+ >(contracts: T): T => contracts
165
+
166
+ /**
167
+ * THE TABLE. Every node the design layer may draw, and the reason each one holds its children the
168
+ * way it does.
169
+ *
170
+ * KEEP THE NAMES CHILD-FIXING. A key whose name does not say what belongs inside it stops teaching
171
+ * anything, and its reason decays into a label the moment a second screen uses it.
172
+ *
173
+ * THE NAME IS NO LONGER WHAT HOLDS THE CHILD CONTRACT, and that is a reversal worth recording. The
174
+ * child map was retired once because contents arrived as markup, which a rule cannot read: a `.map`,
175
+ * a ternary and an unnamed subtree all look identical to it. Contents arrive as COMPONENTS now, so
176
+ * `children` states each position and the compiler checks it. The name still has a job - it is what
177
+ * a reader sees first - but it is no longer the only fence.
178
+ *
179
+ * POSITIONAL SELECTORS ARE ACCEPTED HERE, and only here. Naming a child instead of counting to it
180
+ * would be better. The objection stands - insert something in the middle and `nth-child(2)` is
181
+ * silently wrong - but the children are now declared right here, so whoever inserts one is looking
182
+ * at the selector while they do it.
183
+ *
184
+ * The entries below are a SEED, not an inventory: one row of leaves, one node holding another node,
185
+ * one that names its host. Delete them once the repository has its own.
186
+ */
187
+ export const CONTRACTS = buildContracts({
188
+ "glyph-title-fact-row": {
189
+ classNames: ["flex", "flex-row", "items-center", "gap-2", "[&>*:nth-child(2)]:min-w-0", "[&>*:nth-child(2)]:grow"],
190
+ children: {
191
+ glyph: { leaf: "icon", props: { size: "sm" } },
192
+ title: { leaf: "text", props: { size: "md", tone: "default" } },
193
+ fact: { leaf: "text", props: { size: "sm", tone: "muted" } },
194
+ },
195
+ why: "The glyph identifies the row faster than its name does, so it leads the line and the fact trails it - and the name between them takes the slack, because a long one must clip rather than push the figure off the end of the row.",
196
+ },
197
+ "heading-over-body": {
198
+ classNames: ["flex", "flex-col", "gap-3"],
199
+ children: {
200
+ heading: { leaf: "heading", props: { level: 3 } },
201
+ body: { contract: "glyph-title-fact-row" },
202
+ },
203
+ why: "The seam here is the only thing telling a reader that the content below belongs to this heading and not to the one above it.",
204
+ },
205
+ "weekday-run": {
206
+ classNames: ["flex", "flex-row", "flex-wrap", "items-center", "gap-2"],
207
+ children: {
208
+ day: { leaf: "day-cell", props: { size: "sm" }, repeats: true, restingCount: 6 },
209
+ },
210
+ host: "ul",
211
+ why: "A run of equal columns only reads as one span of time while the columns stay on one line, so it wraps as a whole rather than letting a single day drop away from the six beside it.",
212
+ },
213
+ })
214
+
215
+ /** Every key in the table. A key not in this union is a compile error at the call site. */
216
+ export type ContractKey = keyof typeof CONTRACTS
217
+
218
+ /**
219
+ * Read one entry, widened to the shared shape.
220
+ *
221
+ * @param name - The key to read.
222
+ */
223
+ export const contractSpec = (name: ContractKey): ContractSpec => CONTRACTS[name]
224
+
225
+ /** Every key, in declaration order, so gates and tests can walk the vocabulary. */
226
+ export const CONTRACT_KEYS: ReadonlyArray<ContractKey> = Object.keys(CONTRACTS) as Array<ContractKey>
227
+
228
+ /** Checked named slots for one key. Content records are values, not pretend React components. */
229
+ export type ContractSlots<K extends ContractKey> = {
230
+ readonly kind: "slots"
231
+ readonly meta: { readonly shape: "contract"; readonly contract: K }
232
+ readonly slots: ChildrenOf<K>
233
+ }
234
+
235
+ /** A branch-owned node that has already drawn wrapper mechanics a content contract cannot express. */
236
+ export type ContractProjection<K extends ContractKey> = {
237
+ readonly kind: "projection"
238
+ readonly meta: { readonly shape: "contract"; readonly contract: K }
239
+ readonly project: () => ReactNode
240
+ }
241
+
242
+ /** A real component type whose runtime props stay separate from its exact contract identity. */
243
+ export type ContractRenderComponent<K extends ContractKey, P> = ComponentType<P> & {
244
+ readonly kind: "component"
245
+ readonly meta: { readonly shape: "contract"; readonly contract: K }
246
+ }
247
+
248
+ /** Checked bound content used when a caller has already supplied every slot value. */
249
+ export type BoundContractComponent<K extends ContractKey> = ContractSlots<K> | ContractProjection<K>
250
+
251
+ /** Bound content by default; supplying `P` selects the stable component-type lane. */
252
+ export type ContractComponent<K extends ContractKey, P = undefined> = [P] extends [undefined]
253
+ ? BoundContractComponent<K>
254
+ : ContractRenderComponent<K, P>
255
+
256
+ /**
257
+ * A leaf, typed by its NAME and the props it takes. It has no key, because it is not a node.
258
+ *
259
+ * The name is what separates two leaves that happen to accept the same props: a slot asking for a
260
+ * glyph and a slot asking for a one-word label are not interchangeable, and without the name the
261
+ * compiler would have no way to say so.
262
+ *
263
+ * THE NAME LIVES ON THE COMPONENT'S OWN METADATA, beside the tier marker every component already
264
+ * carries. As a property rather than a module export, because a module export is invisible to the
265
+ * type of the thing being passed - and this whole arrangement is worth having only if the compiler
266
+ * can read it at the call site.
267
+ *
268
+ * ```tsx
269
+ * export const Icon = ({ props, isLoading }: IconProps) => ...
270
+ * Icon.meta = { tier: "leaf", name: "icon" } as const
271
+ * ```
272
+ */
273
+ export type LeafComponent<N extends string, P> = {
274
+ (): ReactNode
275
+ readonly meta: { readonly shape: "leaf"; readonly name: N; readonly props: P }
276
+ }
277
+
278
+ type ContractChild<S> = S extends { readonly contract: infer K }
279
+ ? K extends ContractKey
280
+ ? ContractComponent<K>
281
+ : K extends ReadonlyArray<infer A extends ContractKey> ? ContractComponent<A> : never
282
+ : never
283
+
284
+ type LeafChild<S> = S extends { readonly leaf: infer N }
285
+ ? (N extends ReadonlyArray<infer A> ? A : N) extends infer L extends string
286
+ ? LeafComponent<L, S extends { readonly props: infer P } ? P : Readonly<Record<never, never>>>
287
+ : never
288
+ : never
289
+
290
+ type OneChild<S> = ContractChild<S> | LeafChild<S>
291
+ type ChildValue<S> = S extends { readonly repeats: true } ? ReadonlyArray<OneChild<S>> : OneChild<S>
292
+
293
+ type RequiredSlots<K extends ContractKey> = {
294
+ [S in keyof (typeof CONTRACTS)[K]["children"]]:
295
+ (typeof CONTRACTS)[K]["children"][S] extends { readonly optional: true } ? never : S
296
+ }[keyof (typeof CONTRACTS)[K]["children"]]
297
+
298
+ type OptionalSlots<K extends ContractKey> = Exclude<keyof (typeof CONTRACTS)[K]["children"], RequiredSlots<K>>
299
+
300
+ /**
301
+ * The components a key requires inside it, by slot name.
302
+ *
303
+ * This is the child contract as the compiler sees it. A leaf of the wrong name, a leaf with the
304
+ * wrong props, a node where the entry asked for a leaf, a missing slot and a slot the entry never
305
+ * declared are five different compile errors at the call site rather than five things a reviewer
306
+ * has to notice.
307
+ */
308
+ export type ChildrenOf<K extends ContractKey> = {
309
+ readonly [S in RequiredSlots<K>]: ChildValue<(typeof CONTRACTS)[K]["children"][S]>
310
+ } & {
311
+ readonly [S in OptionalSlots<K>]?: ChildValue<(typeof CONTRACTS)[K]["children"][S]>
312
+ }
@@ -0,0 +1,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)(?:\.test)?\.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
+ // -- LAYOUT-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
+ // -- LAYOUT-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
+ // -- LAYOUT-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
+ // -- LAYOUT-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
+ // -- LAYOUT-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|shells)\//
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, branches and shells; 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
+ // -- LAYOUT-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.test.tsx` would buy nothing but a longer file.
244
+ */
245
+ const ROUTE_TREE_TEST = /\.test\.(?: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
+ * LAYOUT-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
+ export const rules = {
294
+ "surface-folder-two-files-only": surfaceFolderTwoFilesOnly,
295
+ "route-tree-holds-routes-only": routeTreeHoldsRoutesOnly,
296
+ "no-helper-folder-in-components": noHelperFolderInComponents,
297
+ "export-matches-folder": exportMatchesFolder,
298
+ "no-runtime-namespace": noRuntimeNamespace,
299
+ "monorepo-tier-belongs-to-its-side": monorepoTierBelongsToItsSide,
300
+ }
301
+
302
+ /**
303
+ * The level this law asks for, as the plugin's own opinion.
304
+ *
305
+ * A consuming repository's `eslint.config.mjs` stays the authority on what is actually switched on.
306
+ * `export-matches-folder` is the one worth adopting at `warn` first in an existing tree: it fires
307
+ * on every folder whose convention predates the rule, and that count is a migration rather than a
308
+ * defect.
309
+ */
310
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))