@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.
@@ -0,0 +1,526 @@
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
+ /**
31
+ * The shell folder is closed, and it is closed to FOUR now.
32
+ *
33
+ * Three own a vendor interaction machine and ignore their interior. `RouteShell` owns a framework
34
+ * one: a Next segment layout is handed its page as `children`, no tier below a shell may take one,
35
+ * and a server component cannot convert it because a function does not serialise across that
36
+ * boundary. It imports no vendor at all, which is why this rule can admit it without widening what
37
+ * a vendor import means.
38
+ */
39
+ const SHELL_DIR = "/src/components/shells/"
40
+ const FRAMEWORK_SHELL = /\/src\/components\/shells\/RouteShell\//
41
+ const SHELL_FILE = /\/src\/components\/shells\/(?:ModalShell|DrawerShell|DropdownShell|RouteShell)\//
42
+
43
+ /** Named surface branches may own the vendor wrapper they project a content contract into. */
44
+ const SURFACE_BRANCH = /\/src\/components\/branches\/(?:SurfaceCard|SurfaceAccordionCard|SurfaceListCard|SurfaceFormCard)\//
45
+
46
+ const isAllowedVendorOwner = (file) =>
47
+ file.includes("/src/components/leaves/") || SHELL_FILE.test(file) || SURFACE_BRANCH.test(file)
48
+
49
+ /** True for a file inside the component tree, where the boundary applies. */
50
+ const isComponentFile = (filename) => normalizePath(filename).includes("/src/components/")
51
+
52
+ /** True when an import names the component library, subpaths included. */
53
+ const isVendorImport = (source) => typeof source === "string" && source.startsWith(VENDOR_PACKAGE_PREFIX)
54
+
55
+ /** Read a two-part JSX member such as `Modal.Body`. */
56
+ const jsxMemberName = (node) => {
57
+ if (node?.type !== "JSXMemberExpression") return null
58
+ if (node.object?.type !== "JSXIdentifier" || node.property?.type !== "JSXIdentifier") return null
59
+ return `${node.object.name}.${node.property.name}`
60
+ }
61
+
62
+ // -- VENDOR-1 · VENDOR-2 ---------------------------------------------------------------------------
63
+
64
+ /** The library belongs to two folders, and a wrapper folder that wraps nothing is misfiled. */
65
+ export const vendorBoundary = {
66
+ meta: {
67
+ type: "problem",
68
+ docs: { description: "Vendor imports belong to leaves, the two covering shells, and named surface branches." },
69
+ schema: [],
70
+ messages: {
71
+ outside:
72
+ "This component does not own a vendor primitive. Vendor imports belong to leaves, ModalShell/DrawerShell, and the named SurfaceCard family branches that project contracts into vendor bodies.",
73
+ emptyShell:
74
+ "ModalShell/DrawerShell must wrap their vendor covering primitive; otherwise this is an ordinary branch in the wrong tier.",
75
+ unknownShell:
76
+ "Only ModalShell, DrawerShell, DropdownShell and RouteShell are shells. This component must use contract + render as a branch; needing an arbitrary vendor children slot does not create another one - and the list disagreeing with canon is a gate bug, which is how this message came to name two when the tree said three.",
77
+ },
78
+ },
79
+ create(context) {
80
+ const file = normalizePath(context.filename || context.getFilename())
81
+ // Outside the component tree, a provider standing the library up for the whole application is a
82
+ // different thing from a component pulling in a widget.
83
+ if (!isComponentFile(file)) return {}
84
+ const isShellDirectory = file.includes(SHELL_DIR)
85
+ const isShell = SHELL_FILE.test(file)
86
+ const isTestFile = /\.(?:test|spec)\.[jt]sx?$/.test(file)
87
+ const isAllowed = isAllowedVendorOwner(file)
88
+ let importsVendor = false
89
+ return {
90
+ ImportDeclaration(node) {
91
+ if (!isVendorImport(node.source && node.source.value)) return
92
+ importsVendor = true
93
+ if (isAllowed) return
94
+ context.report({ node, messageId: "outside" })
95
+ },
96
+ "Program:exit"(node) {
97
+ if (isTestFile) return
98
+ if (isShellDirectory && !isShell) context.report({ node, messageId: "unknownShell" })
99
+ /*
100
+ * A shell with no vendor import is empty - except the one whose mechanic is the FRAMEWORK's.
101
+ * `RouteShell` converts the children a Next layout is handed into the component every tier
102
+ * below expects, and it imports nothing to do it. Demanding a vendor primitive there would
103
+ * force an import that means nothing, which is how a rule teaches somebody to add noise.
104
+ */
105
+ else if (isShell && !importsVendor && !FRAMEWORK_SHELL.test(file)) context.report({ node, messageId: "emptyShell" })
106
+ },
107
+ }
108
+ },
109
+ }
110
+
111
+ // -- VENDOR-6 --------------------------------------------------------------------------------------
112
+
113
+ /** A content-agnostic modal shell owns one zero-inset vendor body as the scroll region. */
114
+ export const modalShellOwnsScrollBody = {
115
+ meta: {
116
+ type: "problem",
117
+ docs: { description: "ModalShell keeps one zero-inset Modal.Body as its scroll region." },
118
+ schema: [],
119
+ messages: {
120
+ missing:
121
+ "ModalShell must keep one `Modal.Body` around its uninterpreted children: that vendor body is the dialog's scroll region, not a second content surface.",
122
+ inset:
123
+ "ModalShell's body must be `className=\"p-0\"`. The mounted contract owns layout; vendor body padding plus contract padding creates a doubled inset.",
124
+ },
125
+ },
126
+ create(context) {
127
+ const file = normalizePath(context.filename || context.getFilename())
128
+ if (!/\/src\/components\/shells\/ModalShell\/index\.tsx$/.test(file)) return {}
129
+ let hasBody = false
130
+ return {
131
+ JSXOpeningElement(node) {
132
+ if (jsxMemberName(node.name) !== "Modal.Body") return
133
+ hasBody = true
134
+ const className = (node.attributes || []).find((attribute) =>
135
+ attribute.type === "JSXAttribute"
136
+ && attribute.name?.type === "JSXIdentifier"
137
+ && attribute.name.name === "className")
138
+ if (className?.value?.type === "Literal" && className.value.value === "p-0") return
139
+ context.report({ node, messageId: "inset" })
140
+ },
141
+ "Program:exit"(node) {
142
+ if (!hasBody) context.report({ node, messageId: "missing" })
143
+ },
144
+ }
145
+ },
146
+ }
147
+
148
+ // -- VENDOR-7 --------------------------------------------------------------------------------------
149
+
150
+ /** The house Field fixes the vendor input variant used on bounded form surfaces. */
151
+ export const fieldInputUsesSecondaryVariant = {
152
+ meta: {
153
+ type: "problem",
154
+ docs: { description: "The Field leaf uses HeroUI Input's secondary surface variant." },
155
+ schema: [],
156
+ messages: {
157
+ variant:
158
+ "The house Field sits inside a bounded form surface. Its HeroUI Input must use `variant=\"secondary\"`; the default variant draws a competing field surface.",
159
+ },
160
+ },
161
+ create(context) {
162
+ const file = normalizePath(context.filename || context.getFilename())
163
+ if (!/\/src\/components\/leaves\/Field\/index\.tsx$/.test(file)) return {}
164
+ const inputBindings = new Set()
165
+ return {
166
+ ImportDeclaration(node) {
167
+ if (node.source?.value !== "@heroui/react") return
168
+ for (const specifier of node.specifiers || []) {
169
+ if (specifier.imported?.name === "Input" && specifier.local?.name) inputBindings.add(specifier.local.name)
170
+ }
171
+ },
172
+ JSXOpeningElement(node) {
173
+ const name = node.name?.type === "JSXIdentifier" ? node.name.name : null
174
+ if (!name || !inputBindings.has(name)) return
175
+ const variant = (node.attributes || []).find((attribute) =>
176
+ attribute.type === "JSXAttribute"
177
+ && attribute.name?.type === "JSXIdentifier"
178
+ && attribute.name.name === "variant")
179
+ if (variant?.value?.type === "Literal" && variant.value.value === "secondary") return
180
+ context.report({ node, messageId: "variant" })
181
+ },
182
+ }
183
+ },
184
+ }
185
+
186
+ // -- VENDOR-9 --------------------------------------------------------------------------------------
187
+
188
+ /** Field labels stay textual instead of inferring decorative glyphs from the input kind. */
189
+ export const fieldLabelIsTextOnly = {
190
+ meta: {
191
+ type: "problem",
192
+ docs: { description: "The house Field label contains no inferred kind icon." },
193
+ schema: [],
194
+ messages: {
195
+ icon:
196
+ "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.",
197
+ },
198
+ },
199
+ create(context) {
200
+ const file = normalizePath(context.filename || context.getFilename())
201
+ if (!/\/src\/components\/leaves\/Field\/index\.tsx$/.test(file)) return {}
202
+ const iconBindings = new Set()
203
+ return {
204
+ ImportDeclaration(node) {
205
+ const source = normalizePath(node.source?.value)
206
+ if (!/\/components\/leaves\/Icon$/.test(source)) return
207
+ for (const specifier of node.specifiers || []) {
208
+ if (specifier.imported?.name === "Icon" && specifier.local?.name) iconBindings.add(specifier.local.name)
209
+ }
210
+ },
211
+ JSXOpeningElement(node) {
212
+ const name = node.name?.type === "JSXIdentifier" ? node.name.name : null
213
+ if (!name || !iconBindings.has(name)) return
214
+ let ancestor = node.parent
215
+ while (ancestor) {
216
+ if (
217
+ ancestor.type === "JSXElement"
218
+ && ancestor.openingElement?.name?.type === "JSXIdentifier"
219
+ && ancestor.openingElement.name.name === "label"
220
+ ) {
221
+ context.report({ node, messageId: "icon" })
222
+ return
223
+ }
224
+ ancestor = ancestor.parent
225
+ }
226
+ },
227
+ }
228
+ },
229
+ }
230
+
231
+ // -- VENDOR-8 --------------------------------------------------------------------------------------
232
+
233
+ /** An overlay is already bounded and cannot directly mount a second named surface branch. */
234
+ export const noSurfaceBranchInOverlay = {
235
+ meta: {
236
+ type: "problem",
237
+ docs: { description: "An overlay does not directly mount a named surface branch." },
238
+ schema: [],
239
+ messages: {
240
+ nested:
241
+ "This overlay is already the bounded surface. Do not mount `{{surface}}` inside it; use headings, spacing, rows and controls directly.",
242
+ },
243
+ },
244
+ create(context) {
245
+ const file = normalizePath(context.filename || context.getFilename())
246
+ if (!file.includes("/src/components/overlays/")) return {}
247
+ return {
248
+ ImportDeclaration(node) {
249
+ const source = normalizePath(node.source?.value)
250
+ const match = source.match(/\/components\/branches\/(SurfaceCard|SurfaceAccordionCard|SurfaceListCard|SurfaceFormCard)$/)
251
+ if (match) context.report({ node, messageId: "nested", data: { surface: match[1] } })
252
+ },
253
+ }
254
+ },
255
+ }
256
+
257
+ // -- VENDOR-10 -------------------------------------------------------------------------------------
258
+
259
+ /** TextLink delegates link interaction and hover treatment to HeroUI Link. */
260
+ export const textLinkUsesHeroLink = {
261
+ meta: {
262
+ type: "problem",
263
+ docs: { description: "TextLink wraps HeroUI Link instead of hand-drawing link behavior." },
264
+ schema: [],
265
+ messages: {
266
+ missing: "TextLink must import HeroUI `Link`. The vendor owns link hover, focus and interaction states.",
267
+ handmade: "Do not rebuild a HeroUI Link with a raw button or local className. Wrap HeroUI `Link` and pass `onPress`.",
268
+ },
269
+ },
270
+ create(context) {
271
+ const file = normalizePath(context.filename || context.getFilename())
272
+ if (!/\/src\/components\/leaves\/TextLink\/index\.tsx$/.test(file)) return {}
273
+ let hasHeroLink = false
274
+ return {
275
+ ImportDeclaration(node) {
276
+ if (node.source?.value !== "@heroui/react") return
277
+ hasHeroLink ||= (node.specifiers || []).some((specifier) => specifier.imported?.name === "Link")
278
+ },
279
+ JSXOpeningElement(node) {
280
+ if (node.name?.type === "JSXIdentifier" && node.name.name === "button") context.report({ node, messageId: "handmade" })
281
+ const className = (node.attributes || []).find(
282
+ (attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "className",
283
+ )
284
+ const classText = className?.value?.type === "Literal" ? String(className.value.value || "") : ""
285
+ if (/(?:hover:|underline)/.test(classText)) {
286
+ context.report({ node: className, messageId: "handmade" })
287
+ }
288
+ },
289
+ "Program:exit"(node) {
290
+ if (!hasHeroLink) context.report({ node, messageId: "missing" })
291
+ },
292
+ }
293
+ },
294
+ }
295
+
296
+ // -- VENDOR-11 -------------------------------------------------------------------------------------
297
+
298
+ /** The account block owns the product choices; DropdownShell alone owns HeroUI mechanics. */
299
+ export const accountControlOwnsDropdown = {
300
+ meta: {
301
+ type: "problem",
302
+ docs: { description: "AccountMenu is a block over DropdownShell; only the shell imports HeroUI Dropdown." },
303
+ schema: [],
304
+ messages: {
305
+ dropdown: "DropdownShell must import HeroUI `Dropdown`; vendor mechanics belong to the shell.",
306
+ shell: "AccountMenu's pure block half must compose DropdownShell instead of importing HeroUI directly.",
307
+ vendor: "AccountMenu is a product block. Import DropdownShell; do not own HeroUI Dropdown here.",
308
+ menu: "ShellNav must render the AccountMenu block from `components/blocks/auth/AccountMenu`.",
309
+ direct: "The account IconButton may not carry an action in ShellNav. Guests open AccountMenu; its Sign in or Sign up choice opens the modal.",
310
+ pieces: "AccountMenu passes typed section data to DropdownShell; it must not import shell Section or Item pieces and rebuild vendor anatomy itself.",
311
+ },
312
+ },
313
+ create(context) {
314
+ const file = normalizePath(context.filename || context.getFilename())
315
+ const isDropdown = /\/src\/components\/shells\/DropdownShell\/index\.tsx$/.test(file)
316
+ const isMenu = /\/src\/components\/blocks\/auth\/AccountMenu\/component\.tsx$/.test(file)
317
+ const isNav = /\/src\/components\/layouts\/ShellNav\/component\.tsx$/.test(file)
318
+ if (!isDropdown && !isMenu && !isNav) return {}
319
+ let hasOwner = false
320
+ return {
321
+ ExportNamedDeclaration(node) {
322
+ if (!isDropdown) return
323
+ const source = context.sourceCode || context.getSourceCode()
324
+ if (/\bDropdownShell(?:Item|Section)\b/.test(source.getText(node))) {
325
+ context.report({ node, messageId: "pieces" })
326
+ }
327
+ },
328
+ ImportDeclaration(node) {
329
+ if (isDropdown && node.source?.value === "@heroui/react") {
330
+ hasOwner ||= (node.specifiers || []).some((specifier) => specifier.imported?.name === "Dropdown")
331
+ }
332
+ if (isMenu && node.source?.value === "@heroui/react") context.report({ node, messageId: "vendor" })
333
+ if (isMenu && /\/components\/shells\/DropdownShell$/.test(normalizePath(node.source?.value))) {
334
+ hasOwner = true
335
+ if ((node.specifiers || []).some((specifier) =>
336
+ /^(?:DropdownShellSection|DropdownShellItem)/.test(specifier.imported?.name || ""))) {
337
+ context.report({ node, messageId: "pieces" })
338
+ }
339
+ }
340
+ if (isNav && /\/components\/blocks\/auth\/AccountMenu$/.test(normalizePath(node.source?.value))) hasOwner = true
341
+ },
342
+ JSXOpeningElement(node) {
343
+ if (!isNav || node.name?.type !== "JSXIdentifier" || node.name.name !== "IconButton") return
344
+ const props = (node.attributes || []).find((attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "props")
345
+ const expression = props?.value?.type === "JSXExpressionContainer" ? props.value.expression : null
346
+ const isAccount = expression?.type === "ObjectExpression" && expression.properties.some((property) =>
347
+ property.type === "Property" && property.key?.name === "icon" && property.value?.value === "account")
348
+ const hasAction = (node.attributes || []).some((attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "on")
349
+ if (isAccount && hasAction) context.report({ node, messageId: "direct" })
350
+ },
351
+ "Program:exit"(node) {
352
+ if (hasOwner) return
353
+ context.report({ node, messageId: isDropdown ? "dropdown" : isMenu ? "shell" : "menu" })
354
+ },
355
+ }
356
+ },
357
+ }
358
+
359
+ // -- VENDOR-13 -------------------------------------------------------------------------------------
360
+
361
+ /** Checkbox.Content owns the control, indicator and label so every visible word toggles it. */
362
+ export const checkboxKeepsCompoundAnatomy = {
363
+ meta: {
364
+ type: "problem",
365
+ docs: { description: "Keep the required HeroUI Checkbox nesting inside the house leaf." },
366
+ schema: [],
367
+ messages: {
368
+ anatomy:
369
+ "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.",
370
+ },
371
+ },
372
+ create(context) {
373
+ const file = normalizePath(context.filename || context.getFilename())
374
+ if (!/\/src\/components\/leaves\/Checkbox\/index\.tsx$/.test(file)) return {}
375
+ let hasContent = false
376
+ let controlInsideContent = false
377
+ let indicatorInsideControl = false
378
+ const hasAncestor = (node, expected) => {
379
+ let ancestor = node.parent
380
+ while (ancestor) {
381
+ if (ancestor.type === "JSXElement" && memberName(ancestor.openingElement?.name) === expected) return true
382
+ ancestor = ancestor.parent
383
+ }
384
+ return false
385
+ }
386
+ return {
387
+ JSXOpeningElement(node) {
388
+ const name = memberName(node.name)
389
+ if (name === "HeroCheckbox.Content") hasContent = true
390
+ if (name === "HeroCheckbox.Control" && hasAncestor(node, "HeroCheckbox.Content")) controlInsideContent = true
391
+ if (name === "HeroCheckbox.Indicator" && hasAncestor(node, "HeroCheckbox.Control")) indicatorInsideControl = true
392
+ },
393
+ "Program:exit"(node) {
394
+ if (!hasContent || !controlInsideContent || !indicatorInsideControl) {
395
+ context.report({ node, messageId: "anatomy" })
396
+ }
397
+ },
398
+ }
399
+ },
400
+ }
401
+
402
+ // -- VENDOR-14 -------------------------------------------------------------------------------------
403
+
404
+ /** Internal StarCi navigation is an action resolved by connected code, never an href. */
405
+ export const noInternalStarciHref = {
406
+ meta: {
407
+ type: "problem",
408
+ docs: { description: "Route internal StarCi destinations through connected router.push actions." },
409
+ schema: [],
410
+ messages: {
411
+ internal:
412
+ "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.",
413
+ leaf:
414
+ "This internal-navigation leaf must not declare or render `href`; it reports `on.press` and leaves routing to its connected owner.",
415
+ },
416
+ },
417
+ create(context) {
418
+ const file = normalizePath(context.filename || context.getFilename())
419
+ if (!file.includes("/src/") || /\.(?:test|spec)\.[jt]sx?$/.test(file)) return {}
420
+ const internalOnlyLeaf = /\/src\/components\/leaves\/(?:Checkbox|NavLink|QuickActionRow|SeeMoreLink)\/index\.tsx$/.test(file)
421
+ const literalText = (node) => {
422
+ if (node?.type === "Literal" && typeof node.value === "string") return node.value
423
+ if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked || ""
424
+ return null
425
+ }
426
+ const isInternal = (value) => value.startsWith("/") || /^https?:\/\/(?:www\.)?academy\.starci\.org(?:\/|$)/i.test(value)
427
+ return {
428
+ JSXAttribute(node) {
429
+ if (node.name?.name !== "href") return
430
+ if (internalOnlyLeaf) {
431
+ context.report({ node, messageId: "leaf" })
432
+ return
433
+ }
434
+ const value = node.value?.type === "JSXExpressionContainer" ? literalText(node.value.expression) : literalText(node.value)
435
+ if (value !== null && isInternal(value)) context.report({ node, messageId: "internal" })
436
+ },
437
+ Property(node) {
438
+ const key = node.key?.type === "Literal" ? node.key.value : node.key?.name
439
+ const value = literalText(node.value)
440
+ if ((key === "href" || key === "externalHref") && value !== null && isInternal(value)) {
441
+ context.report({ node, messageId: "internal" })
442
+ }
443
+ },
444
+ TSPropertySignature(node) {
445
+ if (!internalOnlyLeaf || node.key?.name !== "href") return
446
+ context.report({ node, messageId: "leaf" })
447
+ },
448
+ }
449
+ },
450
+ }
451
+
452
+ // -- VENDOR-12 -------------------------------------------------------------------------------------
453
+
454
+ /** The auth overlay projects one already-owned column and adds no vertical inset of its own. */
455
+ export const authOverlayOwnsSingleContentHost = {
456
+ meta: {
457
+ type: "problem",
458
+ docs: { description: "Authentication has one zero-inset content host inside ModalShell." },
459
+ schema: [],
460
+ messages: {
461
+ duplicate: "SignInOverlay must project with `ContractContent`, not open another `Tree` around a panel that already owns the same host.",
462
+ missing: "SignInOverlay must import `ContractContent` for its already-hosted projection.",
463
+ inset: "`centred-page-column` must not add py/pt/pb. ModalShell is zero-inset and the auth content touches that scroll region without a second vertical padding band.",
464
+ },
465
+ },
466
+ create(context) {
467
+ const file = normalizePath(context.filename || context.getFilename())
468
+ const isOverlay = /\/src\/components\/overlays\/auth\/SignInOverlay\/component\.tsx$/.test(file)
469
+ const isContracts = /\/src\/components\/contracts\/index\.ts$/.test(file)
470
+ if (!isOverlay && !isContracts) return {}
471
+ let hasContent = false
472
+ return {
473
+ ImportDeclaration(node) {
474
+ if (!isOverlay) return
475
+ /*
476
+ * Matched by the NAME imported, not by where it came from.
477
+ *
478
+ * The path test this replaced only recognised `components/branches/Tree`,
479
+ * which is the single-app layout. A workspace that publishes the same
480
+ * branches from a package satisfied the law and failed the rule, with
481
+ * the only available remedy being an import of a module that does not
482
+ * exist there. `ContractContent` and `Tree` are canon vocabulary: a file
483
+ * importing either means the same thing wherever it resolves from.
484
+ */
485
+ for (const specifier of node.specifiers || []) {
486
+ if (specifier.imported?.name === "ContractContent") hasContent = true
487
+ if (specifier.imported?.name === "Tree") context.report({ node: specifier, messageId: "duplicate" })
488
+ }
489
+ },
490
+ Property(node) {
491
+ if (!isContracts) return
492
+ const key = node.key?.type === "Literal" ? node.key.value : node.key?.name
493
+ if (key !== "centred-page-column") return
494
+ const source = context.sourceCode || context.getSourceCode()
495
+ if (/["'`](?:py|pt|pb)-/.test(source.getText(node))) context.report({ node, messageId: "inset" })
496
+ },
497
+ "Program:exit"(node) {
498
+ if (isOverlay && !hasContent) context.report({ node, messageId: "missing" })
499
+ },
500
+ }
501
+ },
502
+ }
503
+
504
+ /** The rules this law contributes to the plugin. */
505
+ export const rules = {
506
+ "vendor-boundary": vendorBoundary,
507
+ "modal-shell-owns-scroll-body": modalShellOwnsScrollBody,
508
+ "field-input-uses-secondary-variant": fieldInputUsesSecondaryVariant,
509
+ "field-label-is-text-only": fieldLabelIsTextOnly,
510
+ "no-surface-branch-in-overlay": noSurfaceBranchInOverlay,
511
+ "text-link-uses-hero-link": textLinkUsesHeroLink,
512
+ "account-control-owns-dropdown": accountControlOwnsDropdown,
513
+ "auth-overlay-owns-single-content-host": authOverlayOwnsSingleContentHost,
514
+ "checkbox-keeps-compound-anatomy": checkboxKeepsCompoundAnatomy,
515
+ "no-internal-starci-href": noInternalStarciHref,
516
+ }
517
+
518
+ /**
519
+ * The level this law asks for, as the plugin's own opinion.
520
+ *
521
+ * A repository adopting this with history should expect the outward half to report and the inward
522
+ * half to be silent, because `shells/` will not exist yet. Creating it is the migration: every
523
+ * vendor container currently wrapped as something else moves there, and each move is what makes the
524
+ * inward check meaningful afterwards.
525
+ */
526
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))