@starci/eslint-canon-fe 1.4.1 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,24 +27,15 @@ const memberName = (node) => {
27
27
  /** The component library this boundary owns. Its glyph counterpart belongs to `icon.mjs`. */
28
28
  const VENDOR_PACKAGE_PREFIX = "@heroui/"
29
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)\//
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/"
42
33
 
43
34
  /** Named surface branches may own the vendor wrapper they project a content contract into. */
44
35
  const SURFACE_BRANCH = /\/src\/components\/branches\/(?:SurfaceCard|SurfaceAccordionCard|SurfaceListCard|SurfaceFormCard)\//
45
36
 
46
37
  const isAllowedVendorOwner = (file) =>
47
- file.includes("/src/components/leaves/") || SHELL_FILE.test(file) || SURFACE_BRANCH.test(file)
38
+ file.includes("/src/components/leaves/") || MECHANICS_BRANCH.test(file) || SURFACE_BRANCH.test(file)
48
39
 
49
40
  /** True for a file inside the component tree, where the boundary applies. */
50
41
  const isComponentFile = (filename) => normalizePath(filename).includes("/src/components/")
@@ -65,15 +56,15 @@ const jsxMemberName = (node) => {
65
56
  export const vendorBoundary = {
66
57
  meta: {
67
58
  type: "problem",
68
- docs: { description: "Vendor imports belong to leaves, the two covering shells, and named surface branches." },
59
+ docs: { description: "Vendor imports belong to leaves and named mechanics or surface branches." },
69
60
  schema: [],
70
61
  messages: {
71
62
  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.",
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.",
77
68
  },
78
69
  },
79
70
  create(context) {
@@ -81,8 +72,8 @@ export const vendorBoundary = {
81
72
  // Outside the component tree, a provider standing the library up for the whole application is a
82
73
  // different thing from a component pulling in a widget.
83
74
  if (!isComponentFile(file)) return {}
84
- const isShellDirectory = file.includes(SHELL_DIR)
85
- const isShell = SHELL_FILE.test(file)
75
+ const isLegacyShell = file.includes(LEGACY_SHELL_DIR)
76
+ const isMechanicsBranch = MECHANICS_BRANCH.test(file)
86
77
  const isTestFile = /\.(?:test|spec)\.[jt]sx?$/.test(file)
87
78
  const isAllowed = isAllowedVendorOwner(file)
88
79
  let importsVendor = false
@@ -95,14 +86,8 @@ export const vendorBoundary = {
95
86
  },
96
87
  "Program:exit"(node) {
97
88
  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" })
89
+ if (isLegacyShell) context.report({ node, messageId: "legacyShell" })
90
+ else if (isMechanicsBranch && !importsVendor) context.report({ node, messageId: "emptyBranch" })
106
91
  },
107
92
  }
108
93
  },
@@ -110,22 +95,22 @@ export const vendorBoundary = {
110
95
 
111
96
  // -- VENDOR-6 --------------------------------------------------------------------------------------
112
97
 
113
- /** A content-agnostic modal shell owns one zero-inset vendor body as the scroll region. */
114
- export const modalShellOwnsScrollBody = {
98
+ /** The modal mechanics branch owns one zero-inset vendor body as the scroll region. */
99
+ export const modalBranchOwnsScrollBody = {
115
100
  meta: {
116
101
  type: "problem",
117
- docs: { description: "ModalShell keeps one zero-inset Modal.Body as its scroll region." },
102
+ docs: { description: "ModalBranch keeps one zero-inset Modal.Body as its scroll region." },
118
103
  schema: [],
119
104
  messages: {
120
105
  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.",
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.",
122
107
  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.",
108
+ "ModalBranch's body must be `className=\"p-0\"`. The mounted contract owns layout; vendor body padding plus contract padding creates a doubled inset.",
124
109
  },
125
110
  },
126
111
  create(context) {
127
112
  const file = normalizePath(context.filename || context.getFilename())
128
- if (!/\/src\/components\/shells\/ModalShell\/index\.tsx$/.test(file)) return {}
113
+ if (!/\/src\/components\/branches\/ModalBranch\/index\.tsx$/.test(file)) return {}
129
114
  let hasBody = false
130
115
  return {
131
116
  JSXOpeningElement(node) {
@@ -295,24 +280,24 @@ export const textLinkUsesHeroLink = {
295
280
 
296
281
  // -- VENDOR-11 -------------------------------------------------------------------------------------
297
282
 
298
- /** The account block owns the product choices; DropdownShell alone owns HeroUI mechanics. */
283
+ /** The account block owns the product choices; DropdownBranch alone owns HeroUI mechanics. */
299
284
  export const accountControlOwnsDropdown = {
300
285
  meta: {
301
286
  type: "problem",
302
- docs: { description: "AccountMenu is a block over DropdownShell; only the shell imports HeroUI Dropdown." },
287
+ docs: { description: "AccountMenu is a block over DropdownBranch; only the branch imports HeroUI Dropdown." },
303
288
  schema: [],
304
289
  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.",
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.",
308
293
  menu: "ShellNav must render the AccountMenu block from `components/blocks/auth/AccountMenu`.",
309
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.",
310
- pieces: "AccountMenu passes typed section data to DropdownShell; it must not import shell Section or Item pieces and rebuild vendor anatomy itself.",
295
+ pieces: "AccountMenu passes typed section data to DropdownBranch; it must not import Branch Section or Item pieces and rebuild vendor anatomy itself.",
311
296
  },
312
297
  },
313
298
  create(context) {
314
299
  const file = normalizePath(context.filename || context.getFilename())
315
- const isDropdown = /\/src\/components\/shells\/DropdownShell\/index\.tsx$/.test(file)
300
+ const isDropdown = /\/src\/components\/branches\/DropdownBranch\/index\.tsx$/.test(file)
316
301
  const isMenu = /\/src\/components\/blocks\/auth\/AccountMenu\/component\.tsx$/.test(file)
317
302
  const isNav = /\/src\/components\/layouts\/ShellNav\/component\.tsx$/.test(file)
318
303
  if (!isDropdown && !isMenu && !isNav) return {}
@@ -321,7 +306,7 @@ export const accountControlOwnsDropdown = {
321
306
  ExportNamedDeclaration(node) {
322
307
  if (!isDropdown) return
323
308
  const source = context.sourceCode || context.getSourceCode()
324
- if (/\bDropdownShell(?:Item|Section)\b/.test(source.getText(node))) {
309
+ if (/\bDropdownBranch(?:Item|Section)\b/.test(source.getText(node))) {
325
310
  context.report({ node, messageId: "pieces" })
326
311
  }
327
312
  },
@@ -330,10 +315,10 @@ export const accountControlOwnsDropdown = {
330
315
  hasOwner ||= (node.specifiers || []).some((specifier) => specifier.imported?.name === "Dropdown")
331
316
  }
332
317
  if (isMenu && node.source?.value === "@heroui/react") context.report({ node, messageId: "vendor" })
333
- if (isMenu && /\/components\/shells\/DropdownShell$/.test(normalizePath(node.source?.value))) {
318
+ if (isMenu && /\/components\/branches\/DropdownBranch$/.test(normalizePath(node.source?.value))) {
334
319
  hasOwner = true
335
320
  if ((node.specifiers || []).some((specifier) =>
336
- /^(?:DropdownShellSection|DropdownShellItem)/.test(specifier.imported?.name || ""))) {
321
+ /^(?:DropdownBranchSection|DropdownBranchItem)/.test(specifier.imported?.name || ""))) {
337
322
  context.report({ node, messageId: "pieces" })
338
323
  }
339
324
  }
@@ -350,7 +335,7 @@ export const accountControlOwnsDropdown = {
350
335
  },
351
336
  "Program:exit"(node) {
352
337
  if (hasOwner) return
353
- context.report({ node, messageId: isDropdown ? "dropdown" : isMenu ? "shell" : "menu" })
338
+ context.report({ node, messageId: isDropdown ? "dropdown" : isMenu ? "branch" : "menu" })
354
339
  },
355
340
  }
356
341
  },
@@ -455,12 +440,12 @@ export const noInternalStarciHref = {
455
440
  export const authOverlayOwnsSingleContentHost = {
456
441
  meta: {
457
442
  type: "problem",
458
- docs: { description: "Authentication has one zero-inset content host inside ModalShell." },
443
+ docs: { description: "Authentication has one zero-inset content host inside ModalBranch or DrawerBranch." },
459
444
  schema: [],
460
445
  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.",
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.",
464
449
  },
465
450
  },
466
451
  create(context) {
@@ -469,24 +454,63 @@ export const authOverlayOwnsSingleContentHost = {
469
454
  const isContracts = /\/src\/components\/contracts\/index\.ts$/.test(file)
470
455
  if (!isOverlay && !isContracts) return {}
471
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
+
472
487
  return {
473
488
  ImportDeclaration(node) {
474
489
  if (!isOverlay) return
475
490
  /*
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.
491
+ * Matched by the NAME imported, not by where it came from - see the comment above.
484
492
  */
485
493
  for (const specifier of node.specifiers || []) {
486
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.
487
497
  if (specifier.imported?.name === "Tree") context.report({ node: specifier, messageId: "duplicate" })
488
498
  }
489
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
+ },
490
514
  Property(node) {
491
515
  if (!isContracts) return
492
516
  const key = node.key?.type === "Literal" ? node.key.value : node.key?.name
@@ -504,7 +528,7 @@ export const authOverlayOwnsSingleContentHost = {
504
528
  /** The rules this law contributes to the plugin. */
505
529
  export const rules = {
506
530
  "vendor-boundary": vendorBoundary,
507
- "modal-shell-owns-scroll-body": modalShellOwnsScrollBody,
531
+ "modal-branch-owns-scroll-body": modalBranchOwnsScrollBody,
508
532
  "field-input-uses-secondary-variant": fieldInputUsesSecondaryVariant,
509
533
  "field-label-is-text-only": fieldLabelIsTextOnly,
510
534
  "no-surface-branch-in-overlay": noSurfaceBranchInOverlay,
@@ -519,8 +543,7 @@ export const rules = {
519
543
  * The level this law asks for, as the plugin's own opinion.
520
544
  *
521
545
  * 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.
546
+ * half to be silent until named mechanics branches exist. Creating those owners is the migration;
547
+ * no anonymous wrapper tier is introduced.
525
548
  */
526
549
  export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))