@starci/eslint-canon-fe 1.2.0 → 1.3.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.
@@ -1,510 +1,549 @@
1
- /**
2
- * The rule that holds `vendor-boundary.md`.
3
- *
4
- * IT IS CHECKED IN BOTH DIRECTIONS, and the second direction is the whole reason this is a policy
5
- * rather than a hole. Outward, a component that imports the library from the wrong folder is
6
- * misfiled - that half is obvious. Inward, a file sitting in a wrapper folder that imports nothing
7
- * is an ordinary component holding an exemption it does not need, and without that check the folder
8
- * becomes the place difficult things go. The first thing to opt in is always something that was
9
- * hard to place.
10
- *
11
- * THE GLYPH LIBRARY IS SOMEBODY ELSE'S RULE, deliberately. `icon.mjs` owns it, because a rule that
12
- * names one vendor protects one vendor - and the gap between two such rules is where a caret came
13
- * to be imported straight from a glyph package at a size that existed nowhere else, reported by
14
- * nothing.
15
- */
16
-
17
- /** Forward-slash form of a filename, so Windows paths compare like every other path. */
18
- const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
19
-
20
- /** Dot form of a two-part JSX member name such as `Checkbox.Content`. */
21
- const memberName = (node) => {
22
- if (node?.type !== "JSXMemberExpression") return null
23
- if (node.object?.type !== "JSXIdentifier" || node.property?.type !== "JSXIdentifier") return null
24
- return `${node.object.name}.${node.property.name}`
25
- }
26
-
27
- /** The component library this boundary owns. Its glyph counterpart belongs to `icon.mjs`. */
28
- const VENDOR_PACKAGE_PREFIX = "@heroui/"
29
-
30
- /** Named mechanics branches own the closed vendor interaction machines. */
31
- const MECHANICS_BRANCH = /\/src\/components\/branches\/(?:ModalBranch|DrawerBranch|DropdownBranch)\//
32
- const LEGACY_SHELL_DIR = "/src/components/shells/"
33
-
34
- /** Named surface branches may own the vendor wrapper they project a content contract into. */
35
- const SURFACE_BRANCH = /\/src\/components\/branches\/(?:SurfaceCard|SurfaceAccordionCard|SurfaceListCard|SurfaceFormCard)\//
36
-
37
- const isAllowedVendorOwner = (file) =>
38
- file.includes("/src/components/leaves/") || MECHANICS_BRANCH.test(file) || SURFACE_BRANCH.test(file)
39
-
40
- /** True for a file inside the component tree, where the boundary applies. */
41
- const isComponentFile = (filename) => normalizePath(filename).includes("/src/components/")
42
-
43
- /** True when an import names the component library, subpaths included. */
44
- const isVendorImport = (source) => typeof source === "string" && source.startsWith(VENDOR_PACKAGE_PREFIX)
45
-
46
- /** Read a two-part JSX member such as `Modal.Body`. */
47
- const jsxMemberName = (node) => {
48
- if (node?.type !== "JSXMemberExpression") return null
49
- if (node.object?.type !== "JSXIdentifier" || node.property?.type !== "JSXIdentifier") return null
50
- return `${node.object.name}.${node.property.name}`
51
- }
52
-
53
- // -- VENDOR-1 · VENDOR-2 ---------------------------------------------------------------------------
54
-
55
- /** The library belongs to two folders, and a wrapper folder that wraps nothing is misfiled. */
56
- export const vendorBoundary = {
57
- meta: {
58
- type: "problem",
59
- docs: { description: "Vendor imports belong to leaves and named mechanics or surface branches." },
60
- schema: [],
61
- messages: {
62
- outside:
63
- "This component does not own a vendor primitive. Vendor imports belong to leaves, ModalBranch/DrawerBranch/DropdownBranch, and the named SurfaceCard family branches that project contracts into vendor bodies.",
64
- emptyBranch:
65
- "ModalBranch/DrawerBranch/DropdownBranch must wrap their vendor interaction primitive; otherwise this is an ordinary branch in a privileged mechanics path.",
66
- legacyShell:
67
- "`components/shells` is a deleted structural tier. Move vendor mechanics into the named Branch owner; framework route content closes outside the component tier into a named contract projection.",
68
- },
69
- },
70
- create(context) {
71
- const file = normalizePath(context.filename || context.getFilename())
72
- // Outside the component tree, a provider standing the library up for the whole application is a
73
- // different thing from a component pulling in a widget.
74
- if (!isComponentFile(file)) return {}
75
- const isLegacyShell = file.includes(LEGACY_SHELL_DIR)
76
- const isMechanicsBranch = MECHANICS_BRANCH.test(file)
77
- const isTestFile = /\.(?:test|spec)\.[jt]sx?$/.test(file)
78
- const isAllowed = isAllowedVendorOwner(file)
79
- let importsVendor = false
80
- return {
81
- ImportDeclaration(node) {
82
- if (!isVendorImport(node.source && node.source.value)) return
83
- importsVendor = true
84
- if (isAllowed) return
85
- context.report({ node, messageId: "outside" })
86
- },
87
- "Program:exit"(node) {
88
- if (isTestFile) return
89
- if (isLegacyShell) context.report({ node, messageId: "legacyShell" })
90
- else if (isMechanicsBranch && !importsVendor) context.report({ node, messageId: "emptyBranch" })
91
- },
92
- }
93
- },
94
- }
95
-
96
- // -- VENDOR-6 --------------------------------------------------------------------------------------
97
-
98
- /** The modal mechanics branch owns one zero-inset vendor body as the scroll region. */
99
- export const modalBranchOwnsScrollBody = {
100
- meta: {
101
- type: "problem",
102
- docs: { description: "ModalBranch keeps one zero-inset Modal.Body as its scroll region." },
103
- schema: [],
104
- messages: {
105
- missing:
106
- "ModalBranch must keep one `Modal.Body` around its named content projection: that vendor body is the dialog's scroll region, not a second content surface.",
107
- inset:
108
- "ModalBranch's body must be `className=\"p-0\"`. The mounted contract owns layout; vendor body padding plus contract padding creates a doubled inset.",
109
- },
110
- },
111
- create(context) {
112
- const file = normalizePath(context.filename || context.getFilename())
113
- if (!/\/src\/components\/branches\/ModalBranch\/index\.tsx$/.test(file)) return {}
114
- let hasBody = false
115
- return {
116
- JSXOpeningElement(node) {
117
- if (jsxMemberName(node.name) !== "Modal.Body") return
118
- hasBody = true
119
- const className = (node.attributes || []).find((attribute) =>
120
- attribute.type === "JSXAttribute"
121
- && attribute.name?.type === "JSXIdentifier"
122
- && attribute.name.name === "className")
123
- if (className?.value?.type === "Literal" && className.value.value === "p-0") return
124
- context.report({ node, messageId: "inset" })
125
- },
126
- "Program:exit"(node) {
127
- if (!hasBody) context.report({ node, messageId: "missing" })
128
- },
129
- }
130
- },
131
- }
132
-
133
- // -- VENDOR-7 --------------------------------------------------------------------------------------
134
-
135
- /** The house Field fixes the vendor input variant used on bounded form surfaces. */
136
- export const fieldInputUsesSecondaryVariant = {
137
- meta: {
138
- type: "problem",
139
- docs: { description: "The Field leaf uses HeroUI Input's secondary surface variant." },
140
- schema: [],
141
- messages: {
142
- variant:
143
- "The house Field sits inside a bounded form surface. Its HeroUI Input must use `variant=\"secondary\"`; the default variant draws a competing field surface.",
144
- },
145
- },
146
- create(context) {
147
- const file = normalizePath(context.filename || context.getFilename())
148
- if (!/\/src\/components\/leaves\/Field\/index\.tsx$/.test(file)) return {}
149
- const inputBindings = new Set()
150
- return {
151
- ImportDeclaration(node) {
152
- if (node.source?.value !== "@heroui/react") return
153
- for (const specifier of node.specifiers || []) {
154
- if (specifier.imported?.name === "Input" && specifier.local?.name) inputBindings.add(specifier.local.name)
155
- }
156
- },
157
- JSXOpeningElement(node) {
158
- const name = node.name?.type === "JSXIdentifier" ? node.name.name : null
159
- if (!name || !inputBindings.has(name)) return
160
- const variant = (node.attributes || []).find((attribute) =>
161
- attribute.type === "JSXAttribute"
162
- && attribute.name?.type === "JSXIdentifier"
163
- && attribute.name.name === "variant")
164
- if (variant?.value?.type === "Literal" && variant.value.value === "secondary") return
165
- context.report({ node, messageId: "variant" })
166
- },
167
- }
168
- },
169
- }
170
-
171
- // -- VENDOR-9 --------------------------------------------------------------------------------------
172
-
173
- /** Field labels stay textual instead of inferring decorative glyphs from the input kind. */
174
- export const fieldLabelIsTextOnly = {
175
- meta: {
176
- type: "problem",
177
- docs: { description: "The house Field label contains no inferred kind icon." },
178
- schema: [],
179
- messages: {
180
- icon:
181
- "Field labels are text-only in the source design. Do not infer a decorative icon from the input kind; an icon belongs only to a control with its own action, such as password visibility.",
182
- },
183
- },
184
- create(context) {
185
- const file = normalizePath(context.filename || context.getFilename())
186
- if (!/\/src\/components\/leaves\/Field\/index\.tsx$/.test(file)) return {}
187
- const iconBindings = new Set()
188
- return {
189
- ImportDeclaration(node) {
190
- const source = normalizePath(node.source?.value)
191
- if (!/\/components\/leaves\/Icon$/.test(source)) return
192
- for (const specifier of node.specifiers || []) {
193
- if (specifier.imported?.name === "Icon" && specifier.local?.name) iconBindings.add(specifier.local.name)
194
- }
195
- },
196
- JSXOpeningElement(node) {
197
- const name = node.name?.type === "JSXIdentifier" ? node.name.name : null
198
- if (!name || !iconBindings.has(name)) return
199
- let ancestor = node.parent
200
- while (ancestor) {
201
- if (
202
- ancestor.type === "JSXElement"
203
- && ancestor.openingElement?.name?.type === "JSXIdentifier"
204
- && ancestor.openingElement.name.name === "label"
205
- ) {
206
- context.report({ node, messageId: "icon" })
207
- return
208
- }
209
- ancestor = ancestor.parent
210
- }
211
- },
212
- }
213
- },
214
- }
215
-
216
- // -- VENDOR-8 --------------------------------------------------------------------------------------
217
-
218
- /** An overlay is already bounded and cannot directly mount a second named surface branch. */
219
- export const noSurfaceBranchInOverlay = {
220
- meta: {
221
- type: "problem",
222
- docs: { description: "An overlay does not directly mount a named surface branch." },
223
- schema: [],
224
- messages: {
225
- nested:
226
- "This overlay is already the bounded surface. Do not mount `{{surface}}` inside it; use headings, spacing, rows and controls directly.",
227
- },
228
- },
229
- create(context) {
230
- const file = normalizePath(context.filename || context.getFilename())
231
- if (!file.includes("/src/components/overlays/")) return {}
232
- return {
233
- ImportDeclaration(node) {
234
- const source = normalizePath(node.source?.value)
235
- const match = source.match(/\/components\/branches\/(SurfaceCard|SurfaceAccordionCard|SurfaceListCard|SurfaceFormCard)$/)
236
- if (match) context.report({ node, messageId: "nested", data: { surface: match[1] } })
237
- },
238
- }
239
- },
240
- }
241
-
242
- // -- VENDOR-10 -------------------------------------------------------------------------------------
243
-
244
- /** TextLink delegates link interaction and hover treatment to HeroUI Link. */
245
- export const textLinkUsesHeroLink = {
246
- meta: {
247
- type: "problem",
248
- docs: { description: "TextLink wraps HeroUI Link instead of hand-drawing link behavior." },
249
- schema: [],
250
- messages: {
251
- missing: "TextLink must import HeroUI `Link`. The vendor owns link hover, focus and interaction states.",
252
- handmade: "Do not rebuild a HeroUI Link with a raw button or local className. Wrap HeroUI `Link` and pass `onPress`.",
253
- },
254
- },
255
- create(context) {
256
- const file = normalizePath(context.filename || context.getFilename())
257
- if (!/\/src\/components\/leaves\/TextLink\/index\.tsx$/.test(file)) return {}
258
- let hasHeroLink = false
259
- return {
260
- ImportDeclaration(node) {
261
- if (node.source?.value !== "@heroui/react") return
262
- hasHeroLink ||= (node.specifiers || []).some((specifier) => specifier.imported?.name === "Link")
263
- },
264
- JSXOpeningElement(node) {
265
- if (node.name?.type === "JSXIdentifier" && node.name.name === "button") context.report({ node, messageId: "handmade" })
266
- const className = (node.attributes || []).find(
267
- (attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "className",
268
- )
269
- const classText = className?.value?.type === "Literal" ? String(className.value.value || "") : ""
270
- if (/(?:hover:|underline)/.test(classText)) {
271
- context.report({ node: className, messageId: "handmade" })
272
- }
273
- },
274
- "Program:exit"(node) {
275
- if (!hasHeroLink) context.report({ node, messageId: "missing" })
276
- },
277
- }
278
- },
279
- }
280
-
281
- // -- VENDOR-11 -------------------------------------------------------------------------------------
282
-
283
- /** The account block owns the product choices; DropdownBranch alone owns HeroUI mechanics. */
284
- export const accountControlOwnsDropdown = {
285
- meta: {
286
- type: "problem",
287
- docs: { description: "AccountMenu is a block over DropdownBranch; only the branch imports HeroUI Dropdown." },
288
- schema: [],
289
- messages: {
290
- dropdown: "DropdownBranch must import HeroUI `Dropdown`; vendor mechanics belong to the branch.",
291
- branch: "AccountMenu's pure block half must compose DropdownBranch instead of importing HeroUI directly.",
292
- vendor: "AccountMenu is a product block. Import DropdownBranch; do not own HeroUI Dropdown here.",
293
- menu: "ShellNav must render the AccountMenu block from `components/blocks/auth/AccountMenu`.",
294
- direct: "The account IconButton may not carry an action in ShellNav. Guests open AccountMenu; its Sign in or Sign up choice opens the modal.",
295
- pieces: "AccountMenu passes typed section data to DropdownBranch; it must not import Branch Section or Item pieces and rebuild vendor anatomy itself.",
296
- },
297
- },
298
- create(context) {
299
- const file = normalizePath(context.filename || context.getFilename())
300
- const isDropdown = /\/src\/components\/branches\/DropdownBranch\/index\.tsx$/.test(file)
301
- const isMenu = /\/src\/components\/blocks\/auth\/AccountMenu\/component\.tsx$/.test(file)
302
- const isNav = /\/src\/components\/layouts\/ShellNav\/component\.tsx$/.test(file)
303
- if (!isDropdown && !isMenu && !isNav) return {}
304
- let hasOwner = false
305
- return {
306
- ExportNamedDeclaration(node) {
307
- if (!isDropdown) return
308
- const source = context.sourceCode || context.getSourceCode()
309
- if (/\bDropdownBranch(?:Item|Section)\b/.test(source.getText(node))) {
310
- context.report({ node, messageId: "pieces" })
311
- }
312
- },
313
- ImportDeclaration(node) {
314
- if (isDropdown && node.source?.value === "@heroui/react") {
315
- hasOwner ||= (node.specifiers || []).some((specifier) => specifier.imported?.name === "Dropdown")
316
- }
317
- if (isMenu && node.source?.value === "@heroui/react") context.report({ node, messageId: "vendor" })
318
- if (isMenu && /\/components\/branches\/DropdownBranch$/.test(normalizePath(node.source?.value))) {
319
- hasOwner = true
320
- if ((node.specifiers || []).some((specifier) =>
321
- /^(?:DropdownBranchSection|DropdownBranchItem)/.test(specifier.imported?.name || ""))) {
322
- context.report({ node, messageId: "pieces" })
323
- }
324
- }
325
- if (isNav && /\/components\/blocks\/auth\/AccountMenu$/.test(normalizePath(node.source?.value))) hasOwner = true
326
- },
327
- JSXOpeningElement(node) {
328
- if (!isNav || node.name?.type !== "JSXIdentifier" || node.name.name !== "IconButton") return
329
- const props = (node.attributes || []).find((attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "props")
330
- const expression = props?.value?.type === "JSXExpressionContainer" ? props.value.expression : null
331
- const isAccount = expression?.type === "ObjectExpression" && expression.properties.some((property) =>
332
- property.type === "Property" && property.key?.name === "icon" && property.value?.value === "account")
333
- const hasAction = (node.attributes || []).some((attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "on")
334
- if (isAccount && hasAction) context.report({ node, messageId: "direct" })
335
- },
336
- "Program:exit"(node) {
337
- if (hasOwner) return
338
- context.report({ node, messageId: isDropdown ? "dropdown" : isMenu ? "branch" : "menu" })
339
- },
340
- }
341
- },
342
- }
343
-
344
- // -- VENDOR-13 -------------------------------------------------------------------------------------
345
-
346
- /** Checkbox.Content owns the control, indicator and label so every visible word toggles it. */
347
- export const checkboxKeepsCompoundAnatomy = {
348
- meta: {
349
- type: "problem",
350
- docs: { description: "Keep the required HeroUI Checkbox nesting inside the house leaf." },
351
- schema: [],
352
- messages: {
353
- anatomy:
354
- "Checkbox must nest `HeroCheckbox.Control` (and its Indicator) inside `HeroCheckbox.Content`. Sibling Control and Content leave the visible label outside the checkbox's press target.",
355
- },
356
- },
357
- create(context) {
358
- const file = normalizePath(context.filename || context.getFilename())
359
- if (!/\/src\/components\/leaves\/Checkbox\/index\.tsx$/.test(file)) return {}
360
- let hasContent = false
361
- let controlInsideContent = false
362
- let indicatorInsideControl = false
363
- const hasAncestor = (node, expected) => {
364
- let ancestor = node.parent
365
- while (ancestor) {
366
- if (ancestor.type === "JSXElement" && memberName(ancestor.openingElement?.name) === expected) return true
367
- ancestor = ancestor.parent
368
- }
369
- return false
370
- }
371
- return {
372
- JSXOpeningElement(node) {
373
- const name = memberName(node.name)
374
- if (name === "HeroCheckbox.Content") hasContent = true
375
- if (name === "HeroCheckbox.Control" && hasAncestor(node, "HeroCheckbox.Content")) controlInsideContent = true
376
- if (name === "HeroCheckbox.Indicator" && hasAncestor(node, "HeroCheckbox.Control")) indicatorInsideControl = true
377
- },
378
- "Program:exit"(node) {
379
- if (!hasContent || !controlInsideContent || !indicatorInsideControl) {
380
- context.report({ node, messageId: "anatomy" })
381
- }
382
- },
383
- }
384
- },
385
- }
386
-
387
- // -- VENDOR-14 -------------------------------------------------------------------------------------
388
-
389
- /** Internal StarCi navigation is an action resolved by connected code, never an href. */
390
- export const noInternalStarciHref = {
391
- meta: {
392
- type: "problem",
393
- docs: { description: "Route internal StarCi destinations through connected router.push actions." },
394
- schema: [],
395
- messages: {
396
- internal:
397
- "Internal StarCi navigation must not use `href`. Keep the destination in connected code, report an id/press from the pure component, and call `router.push` there.",
398
- leaf:
399
- "This internal-navigation leaf must not declare or render `href`; it reports `on.press` and leaves routing to its connected owner.",
400
- },
401
- },
402
- create(context) {
403
- const file = normalizePath(context.filename || context.getFilename())
404
- if (!file.includes("/src/") || /\.(?:test|spec)\.[jt]sx?$/.test(file)) return {}
405
- const internalOnlyLeaf = /\/src\/components\/leaves\/(?:Checkbox|NavLink|QuickActionRow|SeeMoreLink)\/index\.tsx$/.test(file)
406
- const literalText = (node) => {
407
- if (node?.type === "Literal" && typeof node.value === "string") return node.value
408
- if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked || ""
409
- return null
410
- }
411
- const isInternal = (value) => value.startsWith("/") || /^https?:\/\/(?:www\.)?academy\.starci\.org(?:\/|$)/i.test(value)
412
- return {
413
- JSXAttribute(node) {
414
- if (node.name?.name !== "href") return
415
- if (internalOnlyLeaf) {
416
- context.report({ node, messageId: "leaf" })
417
- return
418
- }
419
- const value = node.value?.type === "JSXExpressionContainer" ? literalText(node.value.expression) : literalText(node.value)
420
- if (value !== null && isInternal(value)) context.report({ node, messageId: "internal" })
421
- },
422
- Property(node) {
423
- const key = node.key?.type === "Literal" ? node.key.value : node.key?.name
424
- const value = literalText(node.value)
425
- if ((key === "href" || key === "externalHref") && value !== null && isInternal(value)) {
426
- context.report({ node, messageId: "internal" })
427
- }
428
- },
429
- TSPropertySignature(node) {
430
- if (!internalOnlyLeaf || node.key?.name !== "href") return
431
- context.report({ node, messageId: "leaf" })
432
- },
433
- }
434
- },
435
- }
436
-
437
- // -- VENDOR-12 -------------------------------------------------------------------------------------
438
-
439
- /** The auth overlay projects one already-owned column and adds no vertical inset of its own. */
440
- export const authOverlayOwnsSingleContentHost = {
441
- meta: {
442
- type: "problem",
443
- docs: { description: "Authentication has one zero-inset content host inside ModalBranch." },
444
- schema: [],
445
- messages: {
446
- duplicate: "SignInOverlay must project with `ContractContent`, not open another `Tree` around a panel that already owns the same host.",
447
- missing: "SignInOverlay must import `ContractContent` for its already-hosted projection.",
448
- inset: "`centred-page-column` must not add py/pt/pb. ModalBranch is zero-inset and the auth content touches that scroll region without a second vertical padding band.",
449
- },
450
- },
451
- create(context) {
452
- const file = normalizePath(context.filename || context.getFilename())
453
- const isOverlay = /\/src\/components\/overlays\/auth\/SignInOverlay\/component\.tsx$/.test(file)
454
- const isContracts = /\/src\/components\/contracts\/index\.ts$/.test(file)
455
- if (!isOverlay && !isContracts) return {}
456
- let hasContent = false
457
- return {
458
- ImportDeclaration(node) {
459
- if (!isOverlay) return
460
- /*
461
- * Matched by the NAME imported, not by where it came from.
462
- *
463
- * The path test this replaced only recognised `components/branches/Tree`,
464
- * which is the single-app layout. A workspace that publishes the same
465
- * branches from a package satisfied the law and failed the rule, with
466
- * the only available remedy being an import of a module that does not
467
- * exist there. `ContractContent` and `Tree` are canon vocabulary: a file
468
- * importing either means the same thing wherever it resolves from.
469
- */
470
- for (const specifier of node.specifiers || []) {
471
- if (specifier.imported?.name === "ContractContent") hasContent = true
472
- if (specifier.imported?.name === "Tree") context.report({ node: specifier, messageId: "duplicate" })
473
- }
474
- },
475
- Property(node) {
476
- if (!isContracts) return
477
- const key = node.key?.type === "Literal" ? node.key.value : node.key?.name
478
- if (key !== "centred-page-column") return
479
- const source = context.sourceCode || context.getSourceCode()
480
- if (/["'`](?:py|pt|pb)-/.test(source.getText(node))) context.report({ node, messageId: "inset" })
481
- },
482
- "Program:exit"(node) {
483
- if (isOverlay && !hasContent) context.report({ node, messageId: "missing" })
484
- },
485
- }
486
- },
487
- }
488
-
489
- /** The rules this law contributes to the plugin. */
490
- export const rules = {
491
- "vendor-boundary": vendorBoundary,
492
- "modal-branch-owns-scroll-body": modalBranchOwnsScrollBody,
493
- "field-input-uses-secondary-variant": fieldInputUsesSecondaryVariant,
494
- "field-label-is-text-only": fieldLabelIsTextOnly,
495
- "no-surface-branch-in-overlay": noSurfaceBranchInOverlay,
496
- "text-link-uses-hero-link": textLinkUsesHeroLink,
497
- "account-control-owns-dropdown": accountControlOwnsDropdown,
498
- "auth-overlay-owns-single-content-host": authOverlayOwnsSingleContentHost,
499
- "checkbox-keeps-compound-anatomy": checkboxKeepsCompoundAnatomy,
500
- "no-internal-starci-href": noInternalStarciHref,
501
- }
502
-
503
- /**
504
- * The level this law asks for, as the plugin's own opinion.
505
- *
506
- * A repository adopting this with history should expect the outward half to report and the inward
507
- * half to be silent until named mechanics branches exist. Creating those owners is the migration;
508
- * no anonymous wrapper tier is introduced.
509
- */
510
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
1
+ /**
2
+ * The rule that holds `vendor-boundary.md`.
3
+ *
4
+ * IT IS CHECKED IN BOTH DIRECTIONS, and the second direction is the whole reason this is a policy
5
+ * rather than a hole. Outward, a component that imports the library from the wrong folder is
6
+ * misfiled - that half is obvious. Inward, a file sitting in a wrapper folder that imports nothing
7
+ * is an ordinary component holding an exemption it does not need, and without that check the folder
8
+ * becomes the place difficult things go. The first thing to opt in is always something that was
9
+ * hard to place.
10
+ *
11
+ * THE GLYPH LIBRARY IS SOMEBODY ELSE'S RULE, deliberately. `icon.mjs` owns it, because a rule that
12
+ * names one vendor protects one vendor - and the gap between two such rules is where a caret came
13
+ * to be imported straight from a glyph package at a size that existed nowhere else, reported by
14
+ * nothing.
15
+ */
16
+
17
+ /** Forward-slash form of a filename, so Windows paths compare like every other path. */
18
+ const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
19
+
20
+ /** Dot form of a two-part JSX member name such as `Checkbox.Content`. */
21
+ const memberName = (node) => {
22
+ if (node?.type !== "JSXMemberExpression") return null
23
+ if (node.object?.type !== "JSXIdentifier" || node.property?.type !== "JSXIdentifier") return null
24
+ return `${node.object.name}.${node.property.name}`
25
+ }
26
+
27
+ /** The component library this boundary owns. Its glyph counterpart belongs to `icon.mjs`. */
28
+ const VENDOR_PACKAGE_PREFIX = "@heroui/"
29
+
30
+ /** Named mechanics branches own the closed vendor interaction machines. */
31
+ const MECHANICS_BRANCH = /\/src\/components\/branches\/(?:ModalBranch|DrawerBranch|DropdownBranch)\//
32
+ const LEGACY_SHELL_DIR = "/src/components/shells/"
33
+
34
+ /** Named surface branches may own the vendor wrapper they project a content contract into. */
35
+ const SURFACE_BRANCH = /\/src\/components\/branches\/(?:SurfaceCard|SurfaceAccordionCard|SurfaceListCard|SurfaceFormCard)\//
36
+
37
+ const isAllowedVendorOwner = (file) =>
38
+ file.includes("/src/components/leaves/") || MECHANICS_BRANCH.test(file) || SURFACE_BRANCH.test(file)
39
+
40
+ /** True for a file inside the component tree, where the boundary applies. */
41
+ const isComponentFile = (filename) => normalizePath(filename).includes("/src/components/")
42
+
43
+ /** True when an import names the component library, subpaths included. */
44
+ const isVendorImport = (source) => typeof source === "string" && source.startsWith(VENDOR_PACKAGE_PREFIX)
45
+
46
+ /** Read a two-part JSX member such as `Modal.Body`. */
47
+ const jsxMemberName = (node) => {
48
+ if (node?.type !== "JSXMemberExpression") return null
49
+ if (node.object?.type !== "JSXIdentifier" || node.property?.type !== "JSXIdentifier") return null
50
+ return `${node.object.name}.${node.property.name}`
51
+ }
52
+
53
+ // -- VENDOR-1 · VENDOR-2 ---------------------------------------------------------------------------
54
+
55
+ /** The library belongs to two folders, and a wrapper folder that wraps nothing is misfiled. */
56
+ export const vendorBoundary = {
57
+ meta: {
58
+ type: "problem",
59
+ docs: { description: "Vendor imports belong to leaves and named mechanics or surface branches." },
60
+ schema: [],
61
+ messages: {
62
+ outside:
63
+ "This component does not own a vendor primitive. Vendor imports belong to leaves, ModalBranch/DrawerBranch/DropdownBranch, and the named SurfaceCard family branches that project contracts into vendor bodies.",
64
+ emptyBranch:
65
+ "ModalBranch/DrawerBranch/DropdownBranch must wrap their vendor interaction primitive; otherwise this is an ordinary branch in a privileged mechanics path.",
66
+ legacyShell:
67
+ "`components/shells` is a deleted structural tier. Move vendor mechanics into the named Branch owner; framework route content closes outside the component tier into a named contract projection.",
68
+ },
69
+ },
70
+ create(context) {
71
+ const file = normalizePath(context.filename || context.getFilename())
72
+ // Outside the component tree, a provider standing the library up for the whole application is a
73
+ // different thing from a component pulling in a widget.
74
+ if (!isComponentFile(file)) return {}
75
+ const isLegacyShell = file.includes(LEGACY_SHELL_DIR)
76
+ const isMechanicsBranch = MECHANICS_BRANCH.test(file)
77
+ const isTestFile = /\.(?:test|spec)\.[jt]sx?$/.test(file)
78
+ const isAllowed = isAllowedVendorOwner(file)
79
+ let importsVendor = false
80
+ return {
81
+ ImportDeclaration(node) {
82
+ if (!isVendorImport(node.source && node.source.value)) return
83
+ importsVendor = true
84
+ if (isAllowed) return
85
+ context.report({ node, messageId: "outside" })
86
+ },
87
+ "Program:exit"(node) {
88
+ if (isTestFile) return
89
+ if (isLegacyShell) context.report({ node, messageId: "legacyShell" })
90
+ else if (isMechanicsBranch && !importsVendor) context.report({ node, messageId: "emptyBranch" })
91
+ },
92
+ }
93
+ },
94
+ }
95
+
96
+ // -- VENDOR-6 --------------------------------------------------------------------------------------
97
+
98
+ /** The modal mechanics branch owns one zero-inset vendor body as the scroll region. */
99
+ export const modalBranchOwnsScrollBody = {
100
+ meta: {
101
+ type: "problem",
102
+ docs: { description: "ModalBranch keeps one zero-inset Modal.Body as its scroll region." },
103
+ schema: [],
104
+ messages: {
105
+ missing:
106
+ "ModalBranch must keep one `Modal.Body` around its named content projection: that vendor body is the dialog's scroll region, not a second content surface.",
107
+ inset:
108
+ "ModalBranch's body must be `className=\"p-0\"`. The mounted contract owns layout; vendor body padding plus contract padding creates a doubled inset.",
109
+ },
110
+ },
111
+ create(context) {
112
+ const file = normalizePath(context.filename || context.getFilename())
113
+ if (!/\/src\/components\/branches\/ModalBranch\/index\.tsx$/.test(file)) return {}
114
+ let hasBody = false
115
+ return {
116
+ JSXOpeningElement(node) {
117
+ if (jsxMemberName(node.name) !== "Modal.Body") return
118
+ hasBody = true
119
+ const className = (node.attributes || []).find((attribute) =>
120
+ attribute.type === "JSXAttribute"
121
+ && attribute.name?.type === "JSXIdentifier"
122
+ && attribute.name.name === "className")
123
+ if (className?.value?.type === "Literal" && className.value.value === "p-0") return
124
+ context.report({ node, messageId: "inset" })
125
+ },
126
+ "Program:exit"(node) {
127
+ if (!hasBody) context.report({ node, messageId: "missing" })
128
+ },
129
+ }
130
+ },
131
+ }
132
+
133
+ // -- VENDOR-7 --------------------------------------------------------------------------------------
134
+
135
+ /** The house Field fixes the vendor input variant used on bounded form surfaces. */
136
+ export const fieldInputUsesSecondaryVariant = {
137
+ meta: {
138
+ type: "problem",
139
+ docs: { description: "The Field leaf uses HeroUI Input's secondary surface variant." },
140
+ schema: [],
141
+ messages: {
142
+ variant:
143
+ "The house Field sits inside a bounded form surface. Its HeroUI Input must use `variant=\"secondary\"`; the default variant draws a competing field surface.",
144
+ },
145
+ },
146
+ create(context) {
147
+ const file = normalizePath(context.filename || context.getFilename())
148
+ if (!/\/src\/components\/leaves\/Field\/index\.tsx$/.test(file)) return {}
149
+ const inputBindings = new Set()
150
+ return {
151
+ ImportDeclaration(node) {
152
+ if (node.source?.value !== "@heroui/react") return
153
+ for (const specifier of node.specifiers || []) {
154
+ if (specifier.imported?.name === "Input" && specifier.local?.name) inputBindings.add(specifier.local.name)
155
+ }
156
+ },
157
+ JSXOpeningElement(node) {
158
+ const name = node.name?.type === "JSXIdentifier" ? node.name.name : null
159
+ if (!name || !inputBindings.has(name)) return
160
+ const variant = (node.attributes || []).find((attribute) =>
161
+ attribute.type === "JSXAttribute"
162
+ && attribute.name?.type === "JSXIdentifier"
163
+ && attribute.name.name === "variant")
164
+ if (variant?.value?.type === "Literal" && variant.value.value === "secondary") return
165
+ context.report({ node, messageId: "variant" })
166
+ },
167
+ }
168
+ },
169
+ }
170
+
171
+ // -- VENDOR-9 --------------------------------------------------------------------------------------
172
+
173
+ /** Field labels stay textual instead of inferring decorative glyphs from the input kind. */
174
+ export const fieldLabelIsTextOnly = {
175
+ meta: {
176
+ type: "problem",
177
+ docs: { description: "The house Field label contains no inferred kind icon." },
178
+ schema: [],
179
+ messages: {
180
+ icon:
181
+ "Field labels are text-only in the source design. Do not infer a decorative icon from the input kind; an icon belongs only to a control with its own action, such as password visibility.",
182
+ },
183
+ },
184
+ create(context) {
185
+ const file = normalizePath(context.filename || context.getFilename())
186
+ if (!/\/src\/components\/leaves\/Field\/index\.tsx$/.test(file)) return {}
187
+ const iconBindings = new Set()
188
+ return {
189
+ ImportDeclaration(node) {
190
+ const source = normalizePath(node.source?.value)
191
+ if (!/\/components\/leaves\/Icon$/.test(source)) return
192
+ for (const specifier of node.specifiers || []) {
193
+ if (specifier.imported?.name === "Icon" && specifier.local?.name) iconBindings.add(specifier.local.name)
194
+ }
195
+ },
196
+ JSXOpeningElement(node) {
197
+ const name = node.name?.type === "JSXIdentifier" ? node.name.name : null
198
+ if (!name || !iconBindings.has(name)) return
199
+ let ancestor = node.parent
200
+ while (ancestor) {
201
+ if (
202
+ ancestor.type === "JSXElement"
203
+ && ancestor.openingElement?.name?.type === "JSXIdentifier"
204
+ && ancestor.openingElement.name.name === "label"
205
+ ) {
206
+ context.report({ node, messageId: "icon" })
207
+ return
208
+ }
209
+ ancestor = ancestor.parent
210
+ }
211
+ },
212
+ }
213
+ },
214
+ }
215
+
216
+ // -- VENDOR-8 --------------------------------------------------------------------------------------
217
+
218
+ /** An overlay is already bounded and cannot directly mount a second named surface branch. */
219
+ export const noSurfaceBranchInOverlay = {
220
+ meta: {
221
+ type: "problem",
222
+ docs: { description: "An overlay does not directly mount a named surface branch." },
223
+ schema: [],
224
+ messages: {
225
+ nested:
226
+ "This overlay is already the bounded surface. Do not mount `{{surface}}` inside it; use headings, spacing, rows and controls directly.",
227
+ },
228
+ },
229
+ create(context) {
230
+ const file = normalizePath(context.filename || context.getFilename())
231
+ if (!file.includes("/src/components/overlays/")) return {}
232
+ return {
233
+ ImportDeclaration(node) {
234
+ const source = normalizePath(node.source?.value)
235
+ const match = source.match(/\/components\/branches\/(SurfaceCard|SurfaceAccordionCard|SurfaceListCard|SurfaceFormCard)$/)
236
+ if (match) context.report({ node, messageId: "nested", data: { surface: match[1] } })
237
+ },
238
+ }
239
+ },
240
+ }
241
+
242
+ // -- VENDOR-10 -------------------------------------------------------------------------------------
243
+
244
+ /** TextLink delegates link interaction and hover treatment to HeroUI Link. */
245
+ export const textLinkUsesHeroLink = {
246
+ meta: {
247
+ type: "problem",
248
+ docs: { description: "TextLink wraps HeroUI Link instead of hand-drawing link behavior." },
249
+ schema: [],
250
+ messages: {
251
+ missing: "TextLink must import HeroUI `Link`. The vendor owns link hover, focus and interaction states.",
252
+ handmade: "Do not rebuild a HeroUI Link with a raw button or local className. Wrap HeroUI `Link` and pass `onPress`.",
253
+ },
254
+ },
255
+ create(context) {
256
+ const file = normalizePath(context.filename || context.getFilename())
257
+ if (!/\/src\/components\/leaves\/TextLink\/index\.tsx$/.test(file)) return {}
258
+ let hasHeroLink = false
259
+ return {
260
+ ImportDeclaration(node) {
261
+ if (node.source?.value !== "@heroui/react") return
262
+ hasHeroLink ||= (node.specifiers || []).some((specifier) => specifier.imported?.name === "Link")
263
+ },
264
+ JSXOpeningElement(node) {
265
+ if (node.name?.type === "JSXIdentifier" && node.name.name === "button") context.report({ node, messageId: "handmade" })
266
+ const className = (node.attributes || []).find(
267
+ (attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "className",
268
+ )
269
+ const classText = className?.value?.type === "Literal" ? String(className.value.value || "") : ""
270
+ if (/(?:hover:|underline)/.test(classText)) {
271
+ context.report({ node: className, messageId: "handmade" })
272
+ }
273
+ },
274
+ "Program:exit"(node) {
275
+ if (!hasHeroLink) context.report({ node, messageId: "missing" })
276
+ },
277
+ }
278
+ },
279
+ }
280
+
281
+ // -- VENDOR-11 -------------------------------------------------------------------------------------
282
+
283
+ /** The account block owns the product choices; DropdownBranch alone owns HeroUI mechanics. */
284
+ export const accountControlOwnsDropdown = {
285
+ meta: {
286
+ type: "problem",
287
+ docs: { description: "AccountMenu is a block over DropdownBranch; only the branch imports HeroUI Dropdown." },
288
+ schema: [],
289
+ messages: {
290
+ dropdown: "DropdownBranch must import HeroUI `Dropdown`; vendor mechanics belong to the branch.",
291
+ branch: "AccountMenu's pure block half must compose DropdownBranch instead of importing HeroUI directly.",
292
+ vendor: "AccountMenu is a product block. Import DropdownBranch; do not own HeroUI Dropdown here.",
293
+ menu: "ShellNav must render the AccountMenu block from `components/blocks/auth/AccountMenu`.",
294
+ direct: "The account IconButton may not carry an action in ShellNav. Guests open AccountMenu; its Sign in or Sign up choice opens the modal.",
295
+ pieces: "AccountMenu passes typed section data to DropdownBranch; it must not import Branch Section or Item pieces and rebuild vendor anatomy itself.",
296
+ },
297
+ },
298
+ create(context) {
299
+ const file = normalizePath(context.filename || context.getFilename())
300
+ const isDropdown = /\/src\/components\/branches\/DropdownBranch\/index\.tsx$/.test(file)
301
+ const isMenu = /\/src\/components\/blocks\/auth\/AccountMenu\/component\.tsx$/.test(file)
302
+ const isNav = /\/src\/components\/layouts\/ShellNav\/component\.tsx$/.test(file)
303
+ if (!isDropdown && !isMenu && !isNav) return {}
304
+ let hasOwner = false
305
+ return {
306
+ ExportNamedDeclaration(node) {
307
+ if (!isDropdown) return
308
+ const source = context.sourceCode || context.getSourceCode()
309
+ if (/\bDropdownBranch(?:Item|Section)\b/.test(source.getText(node))) {
310
+ context.report({ node, messageId: "pieces" })
311
+ }
312
+ },
313
+ ImportDeclaration(node) {
314
+ if (isDropdown && node.source?.value === "@heroui/react") {
315
+ hasOwner ||= (node.specifiers || []).some((specifier) => specifier.imported?.name === "Dropdown")
316
+ }
317
+ if (isMenu && node.source?.value === "@heroui/react") context.report({ node, messageId: "vendor" })
318
+ if (isMenu && /\/components\/branches\/DropdownBranch$/.test(normalizePath(node.source?.value))) {
319
+ hasOwner = true
320
+ if ((node.specifiers || []).some((specifier) =>
321
+ /^(?:DropdownBranchSection|DropdownBranchItem)/.test(specifier.imported?.name || ""))) {
322
+ context.report({ node, messageId: "pieces" })
323
+ }
324
+ }
325
+ if (isNav && /\/components\/blocks\/auth\/AccountMenu$/.test(normalizePath(node.source?.value))) hasOwner = true
326
+ },
327
+ JSXOpeningElement(node) {
328
+ if (!isNav || node.name?.type !== "JSXIdentifier" || node.name.name !== "IconButton") return
329
+ const props = (node.attributes || []).find((attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "props")
330
+ const expression = props?.value?.type === "JSXExpressionContainer" ? props.value.expression : null
331
+ const isAccount = expression?.type === "ObjectExpression" && expression.properties.some((property) =>
332
+ property.type === "Property" && property.key?.name === "icon" && property.value?.value === "account")
333
+ const hasAction = (node.attributes || []).some((attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "on")
334
+ if (isAccount && hasAction) context.report({ node, messageId: "direct" })
335
+ },
336
+ "Program:exit"(node) {
337
+ if (hasOwner) return
338
+ context.report({ node, messageId: isDropdown ? "dropdown" : isMenu ? "branch" : "menu" })
339
+ },
340
+ }
341
+ },
342
+ }
343
+
344
+ // -- VENDOR-13 -------------------------------------------------------------------------------------
345
+
346
+ /** Checkbox.Content owns the control, indicator and label so every visible word toggles it. */
347
+ export const checkboxKeepsCompoundAnatomy = {
348
+ meta: {
349
+ type: "problem",
350
+ docs: { description: "Keep the required HeroUI Checkbox nesting inside the house leaf." },
351
+ schema: [],
352
+ messages: {
353
+ anatomy:
354
+ "Checkbox must nest `HeroCheckbox.Control` (and its Indicator) inside `HeroCheckbox.Content`. Sibling Control and Content leave the visible label outside the checkbox's press target.",
355
+ },
356
+ },
357
+ create(context) {
358
+ const file = normalizePath(context.filename || context.getFilename())
359
+ if (!/\/src\/components\/leaves\/Checkbox\/index\.tsx$/.test(file)) return {}
360
+ let hasContent = false
361
+ let controlInsideContent = false
362
+ let indicatorInsideControl = false
363
+ const hasAncestor = (node, expected) => {
364
+ let ancestor = node.parent
365
+ while (ancestor) {
366
+ if (ancestor.type === "JSXElement" && memberName(ancestor.openingElement?.name) === expected) return true
367
+ ancestor = ancestor.parent
368
+ }
369
+ return false
370
+ }
371
+ return {
372
+ JSXOpeningElement(node) {
373
+ const name = memberName(node.name)
374
+ if (name === "HeroCheckbox.Content") hasContent = true
375
+ if (name === "HeroCheckbox.Control" && hasAncestor(node, "HeroCheckbox.Content")) controlInsideContent = true
376
+ if (name === "HeroCheckbox.Indicator" && hasAncestor(node, "HeroCheckbox.Control")) indicatorInsideControl = true
377
+ },
378
+ "Program:exit"(node) {
379
+ if (!hasContent || !controlInsideContent || !indicatorInsideControl) {
380
+ context.report({ node, messageId: "anatomy" })
381
+ }
382
+ },
383
+ }
384
+ },
385
+ }
386
+
387
+ // -- VENDOR-14 -------------------------------------------------------------------------------------
388
+
389
+ /** Internal StarCi navigation is an action resolved by connected code, never an href. */
390
+ export const noInternalStarciHref = {
391
+ meta: {
392
+ type: "problem",
393
+ docs: { description: "Route internal StarCi destinations through connected router.push actions." },
394
+ schema: [],
395
+ messages: {
396
+ internal:
397
+ "Internal StarCi navigation must not use `href`. Keep the destination in connected code, report an id/press from the pure component, and call `router.push` there.",
398
+ leaf:
399
+ "This internal-navigation leaf must not declare or render `href`; it reports `on.press` and leaves routing to its connected owner.",
400
+ },
401
+ },
402
+ create(context) {
403
+ const file = normalizePath(context.filename || context.getFilename())
404
+ if (!file.includes("/src/") || /\.(?:test|spec)\.[jt]sx?$/.test(file)) return {}
405
+ const internalOnlyLeaf = /\/src\/components\/leaves\/(?:Checkbox|NavLink|QuickActionRow|SeeMoreLink)\/index\.tsx$/.test(file)
406
+ const literalText = (node) => {
407
+ if (node?.type === "Literal" && typeof node.value === "string") return node.value
408
+ if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked || ""
409
+ return null
410
+ }
411
+ const isInternal = (value) => value.startsWith("/") || /^https?:\/\/(?:www\.)?academy\.starci\.org(?:\/|$)/i.test(value)
412
+ return {
413
+ JSXAttribute(node) {
414
+ if (node.name?.name !== "href") return
415
+ if (internalOnlyLeaf) {
416
+ context.report({ node, messageId: "leaf" })
417
+ return
418
+ }
419
+ const value = node.value?.type === "JSXExpressionContainer" ? literalText(node.value.expression) : literalText(node.value)
420
+ if (value !== null && isInternal(value)) context.report({ node, messageId: "internal" })
421
+ },
422
+ Property(node) {
423
+ const key = node.key?.type === "Literal" ? node.key.value : node.key?.name
424
+ const value = literalText(node.value)
425
+ if ((key === "href" || key === "externalHref") && value !== null && isInternal(value)) {
426
+ context.report({ node, messageId: "internal" })
427
+ }
428
+ },
429
+ TSPropertySignature(node) {
430
+ if (!internalOnlyLeaf || node.key?.name !== "href") return
431
+ context.report({ node, messageId: "leaf" })
432
+ },
433
+ }
434
+ },
435
+ }
436
+
437
+ // -- VENDOR-12 -------------------------------------------------------------------------------------
438
+
439
+ /** The auth overlay projects one already-owned column and adds no vertical inset of its own. */
440
+ export const authOverlayOwnsSingleContentHost = {
441
+ meta: {
442
+ type: "problem",
443
+ docs: { description: "Authentication has one zero-inset content host inside ModalBranch or DrawerBranch." },
444
+ schema: [],
445
+ messages: {
446
+ duplicate: "SignInOverlay must project through one host only: do not open a second `Tree`/`ContractContent` inside a branch that already receives `contract`/`render` directly, and do not open `Tree` by hand instead of importing `ContractContent`.",
447
+ missing: "SignInOverlay must host its content either by importing `ContractContent` for its already-hosted projection, or by passing `contract` and `render` props directly to `ModalBranch`/`DrawerBranch`.",
448
+ inset: "`centred-page-column` must not add py/pt/pb. ModalBranch is zero-inset and the auth content touches that scroll region without a second vertical padding band.",
449
+ },
450
+ },
451
+ create(context) {
452
+ const file = normalizePath(context.filename || context.getFilename())
453
+ const isOverlay = /\/src\/components\/overlays\/auth\/SignInOverlay\/component\.tsx$/.test(file)
454
+ const isContracts = /\/src\/components\/contracts\/index\.ts$/.test(file)
455
+ if (!isOverlay && !isContracts) return {}
456
+ let hasContent = false
457
+
458
+ /*
459
+ * Two call shapes satisfy the same invariant - one contract-driven content host, zero extra
460
+ * vertical inset - and both are recognised here.
461
+ *
462
+ * (a) THE OLD SHAPE: the overlay imports `ContractContent` and mounts it itself as the branch's
463
+ * children. `ContractContent` IS the checked host, so importing it is enough to credit the
464
+ * overlay with owning one.
465
+ *
466
+ * (b) THE NEW SHAPE: `ModalBranch`/`DrawerBranch` were migrated to take `{contract, render}`
467
+ * directly and build `<Tree contract render />` internally (SLOTS-4). An overlay that hands both
468
+ * props straight to the branch has delegated the same host to the branch itself; there is
469
+ * nothing left for the overlay to import.
470
+ *
471
+ * Matched by JSX ATTRIBUTE NAME, not by where `ModalBranch`/`DrawerBranch` resolve from - same
472
+ * reasoning VENDOR-12 already applies to `ContractContent`/`Tree` below: canon vocabulary means
473
+ * the same thing wherever it is imported from.
474
+ */
475
+ const MECHANICS_BRANCH_NAMES = new Set(["ModalBranch", "DrawerBranch"])
476
+ const hasJsxAttribute = (attributes, attrName) =>
477
+ (attributes || []).some((attribute) =>
478
+ attribute.type === "JSXAttribute"
479
+ && attribute.name?.type === "JSXIdentifier"
480
+ && attribute.name.name === attrName)
481
+ const jsxChildName = (child) => {
482
+ if (child?.type !== "JSXElement") return null
483
+ const name = child.openingElement?.name
484
+ return name?.type === "JSXIdentifier" ? name.name : null
485
+ }
486
+
487
+ return {
488
+ ImportDeclaration(node) {
489
+ if (!isOverlay) return
490
+ /*
491
+ * Matched by the NAME imported, not by where it came from - see the comment above.
492
+ */
493
+ for (const specifier of node.specifiers || []) {
494
+ if (specifier.imported?.name === "ContractContent") hasContent = true
495
+ // Opening `Tree` by hand is the one thing neither shape ever does: both shapes close the
496
+ // host either inside ContractContent or inside the mechanics branch itself.
497
+ if (specifier.imported?.name === "Tree") context.report({ node: specifier, messageId: "duplicate" })
498
+ }
499
+ },
500
+ JSXElement(node) {
501
+ if (!isOverlay) return
502
+ const name = node.openingElement?.name
503
+ if (name?.type !== "JSXIdentifier" || !MECHANICS_BRANCH_NAMES.has(name.name)) return
504
+ const attributes = node.openingElement.attributes
505
+ if (!hasJsxAttribute(attributes, "contract") || !hasJsxAttribute(attributes, "render")) return
506
+ // The branch itself now owns the host: shape (b).
507
+ hasContent = true
508
+ // A second content host nested as children, when the branch already receives contract/render
509
+ // directly, duplicates the host the branch already opened.
510
+ const nestedHost = (node.children || []).some((child) =>
511
+ jsxChildName(child) === "Tree" || jsxChildName(child) === "ContractContent")
512
+ if (nestedHost) context.report({ node, messageId: "duplicate" })
513
+ },
514
+ Property(node) {
515
+ if (!isContracts) return
516
+ const key = node.key?.type === "Literal" ? node.key.value : node.key?.name
517
+ if (key !== "centred-page-column") return
518
+ const source = context.sourceCode || context.getSourceCode()
519
+ if (/["'`](?:py|pt|pb)-/.test(source.getText(node))) context.report({ node, messageId: "inset" })
520
+ },
521
+ "Program:exit"(node) {
522
+ if (isOverlay && !hasContent) context.report({ node, messageId: "missing" })
523
+ },
524
+ }
525
+ },
526
+ }
527
+
528
+ /** The rules this law contributes to the plugin. */
529
+ export const rules = {
530
+ "vendor-boundary": vendorBoundary,
531
+ "modal-branch-owns-scroll-body": modalBranchOwnsScrollBody,
532
+ "field-input-uses-secondary-variant": fieldInputUsesSecondaryVariant,
533
+ "field-label-is-text-only": fieldLabelIsTextOnly,
534
+ "no-surface-branch-in-overlay": noSurfaceBranchInOverlay,
535
+ "text-link-uses-hero-link": textLinkUsesHeroLink,
536
+ "account-control-owns-dropdown": accountControlOwnsDropdown,
537
+ "auth-overlay-owns-single-content-host": authOverlayOwnsSingleContentHost,
538
+ "checkbox-keeps-compound-anatomy": checkboxKeepsCompoundAnatomy,
539
+ "no-internal-starci-href": noInternalStarciHref,
540
+ }
541
+
542
+ /**
543
+ * The level this law asks for, as the plugin's own opinion.
544
+ *
545
+ * A repository adopting this with history should expect the outward half to report and the inward
546
+ * half to be silent until named mechanics branches exist. Creating those owners is the migration;
547
+ * no anonymous wrapper tier is introduced.
548
+ */
549
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))