@starci/eslint-canon-fe 1.4.3 → 1.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/contract.mjs CHANGED
@@ -152,11 +152,35 @@ export const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx|js|jsx|mjs|c
152
152
  /** Product source lives under `src/`; tooling and config are out of scope. */
153
153
  export const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
154
154
 
155
+ /**
156
+ * True for a branch that draws content parsed from an authored document.
157
+ *
158
+ * WHY THE TABLE CANNOT HOLD THESE. An entry declares every slot inside it, by name - that is
159
+ * `CONTRACT-11`, and it is what makes an entry a constraint rather than a container. A markdown
160
+ * document has no such list: the children of one `ul` are whichever of a dozen node kinds the author
161
+ * typed, in whichever order, nested however deep. Any `children` record written for it would be a
162
+ * guess, and a guess in the table is worse than no entry at all, because the next reader takes it
163
+ * for a rule somebody checked.
164
+ *
165
+ * The same is true one level down, which is why the list has three members rather than one. A
166
+ * highlighted code region is a string of vendor HTML injected into the node; a rendered diagram is
167
+ * an SVG produced from authored source the same way. Neither has slots to name, and neither is a
168
+ * different case from the document that contains them.
169
+ *
170
+ * THIS IS A FOLDER EXEMPTION, exactly like the leaf one above, and it carries the same cost: anyone
171
+ * can escape these rules by filing a component here. What keeps a file out is a question a person
172
+ * asks - does this component receive its children as PARSED CONTENT rather than as a designed
173
+ * arrangement? A block that merely has many children is not this; it has a shape somebody chose, and
174
+ * a shape somebody chose is what the table is for.
175
+ */
176
+ export const isAuthoredDocumentBranch = (filename) =>
177
+ /\/src\/components\/branches\/(?:Article|MarkdownCodeBlock|MermaidDiagram)\//.test(normalizePath(filename))
178
+
155
179
  /** Product source these rules govern: under `src/`, not a test, not a leaf, not the frame. */
156
180
  export const isGovernedFile = (filename) => {
157
181
  const file = normalizePath(filename)
158
182
  if (!isSourceFile(file) || isTestFile(file)) return false
159
- return !isLeafFile(file) && !isContractFrameFile(file)
183
+ return !isLeafFile(file) && !isContractFrameFile(file) && !isAuthoredDocumentBranch(file)
160
184
  }
161
185
 
162
186
  // -- reading the table --------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
4
  "description": "The StarCi front-end lint canon: every rule its laws publish, gathered as one flat-config plugin.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -1,549 +1,618 @@
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"]))
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
+ /**
38
+ * `TableBranch` owns the vendor table, and it is a BRANCH because a leaf could not hold it.
39
+ *
40
+ * A leaf's `props` must satisfy `ComponentData`, which is JSON to the bottom. A table cell carries
41
+ * inline content - a link mid-phrase, a path set in code - and none of that survives a JSON slot, so
42
+ * a leaf could only accept cells already flattened to text. Flattening is the loss the table exists
43
+ * to prevent, which is why the owner sits here beside the surface family rather than in `leaves/`.
44
+ */
45
+ const TABLE_BRANCH = /\/src\/components\/branches\/TableBranch\//
46
+
47
+ const isAllowedVendorOwner = (file) =>
48
+ file.includes("/src/components/leaves/")
49
+ || MECHANICS_BRANCH.test(file)
50
+ || SURFACE_BRANCH.test(file)
51
+ || TABLE_BRANCH.test(file)
52
+
53
+ /** True for a file inside the component tree, where the boundary applies. */
54
+ const isComponentFile = (filename) => normalizePath(filename).includes("/src/components/")
55
+
56
+ /** True when an import names the component library, subpaths included. */
57
+ const isVendorImport = (source) => typeof source === "string" && source.startsWith(VENDOR_PACKAGE_PREFIX)
58
+
59
+ /** Read a two-part JSX member such as `Modal.Body`. */
60
+ const jsxMemberName = (node) => {
61
+ if (node?.type !== "JSXMemberExpression") return null
62
+ if (node.object?.type !== "JSXIdentifier" || node.property?.type !== "JSXIdentifier") return null
63
+ return `${node.object.name}.${node.property.name}`
64
+ }
65
+
66
+ // -- VENDOR-1 · VENDOR-2 ---------------------------------------------------------------------------
67
+
68
+ /** The library belongs to two folders, and a wrapper folder that wraps nothing is misfiled. */
69
+ export const vendorBoundary = {
70
+ meta: {
71
+ type: "problem",
72
+ docs: { description: "Vendor imports belong to leaves and named mechanics or surface branches." },
73
+ schema: [],
74
+ messages: {
75
+ outside:
76
+ "This component does not own a vendor primitive. Vendor imports belong to leaves, ModalBranch/DrawerBranch/DropdownBranch, TableBranch, and the named SurfaceCard family branches that project contracts into vendor bodies.",
77
+ emptyBranch:
78
+ "ModalBranch/DrawerBranch/DropdownBranch must wrap their vendor interaction primitive; otherwise this is an ordinary branch in a privileged mechanics path.",
79
+ legacyShell:
80
+ "`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.",
81
+ },
82
+ },
83
+ create(context) {
84
+ const file = normalizePath(context.filename || context.getFilename())
85
+ // Outside the component tree, a provider standing the library up for the whole application is a
86
+ // different thing from a component pulling in a widget.
87
+ if (!isComponentFile(file)) return {}
88
+ const isLegacyShell = file.includes(LEGACY_SHELL_DIR)
89
+ const isMechanicsBranch = MECHANICS_BRANCH.test(file)
90
+ const isTestFile = /\.(?:test|spec)\.[jt]sx?$/.test(file)
91
+ const isAllowed = isAllowedVendorOwner(file)
92
+ let importsVendor = false
93
+ return {
94
+ ImportDeclaration(node) {
95
+ if (!isVendorImport(node.source && node.source.value)) return
96
+ importsVendor = true
97
+ if (isAllowed) return
98
+ context.report({ node, messageId: "outside" })
99
+ },
100
+ "Program:exit"(node) {
101
+ if (isTestFile) return
102
+ if (isLegacyShell) context.report({ node, messageId: "legacyShell" })
103
+ else if (isMechanicsBranch && !importsVendor) context.report({ node, messageId: "emptyBranch" })
104
+ },
105
+ }
106
+ },
107
+ }
108
+
109
+ // -- VENDOR-6 --------------------------------------------------------------------------------------
110
+
111
+ /** The modal mechanics branch owns one zero-inset vendor body as the scroll region. */
112
+ export const modalBranchOwnsScrollBody = {
113
+ meta: {
114
+ type: "problem",
115
+ docs: { description: "ModalBranch keeps one zero-inset Modal.Body as its scroll region." },
116
+ schema: [],
117
+ messages: {
118
+ missing:
119
+ "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.",
120
+ inset:
121
+ "ModalBranch's body must be `className=\"p-0\"`. The mounted contract owns layout; vendor body padding plus contract padding creates a doubled inset.",
122
+ },
123
+ },
124
+ create(context) {
125
+ const file = normalizePath(context.filename || context.getFilename())
126
+ if (!/\/src\/components\/branches\/ModalBranch\/index\.tsx$/.test(file)) return {}
127
+ let hasBody = false
128
+ return {
129
+ JSXOpeningElement(node) {
130
+ if (jsxMemberName(node.name) !== "Modal.Body") return
131
+ hasBody = true
132
+ const className = (node.attributes || []).find((attribute) =>
133
+ attribute.type === "JSXAttribute"
134
+ && attribute.name?.type === "JSXIdentifier"
135
+ && attribute.name.name === "className")
136
+ if (className?.value?.type === "Literal" && className.value.value === "p-0") return
137
+ context.report({ node, messageId: "inset" })
138
+ },
139
+ "Program:exit"(node) {
140
+ if (!hasBody) context.report({ node, messageId: "missing" })
141
+ },
142
+ }
143
+ },
144
+ }
145
+
146
+ // -- VENDOR-7 --------------------------------------------------------------------------------------
147
+
148
+ /** The house Field fixes the vendor input variant used on bounded form surfaces. */
149
+ export const fieldInputUsesSecondaryVariant = {
150
+ meta: {
151
+ type: "problem",
152
+ docs: { description: "The Field leaf uses HeroUI Input's secondary surface variant." },
153
+ schema: [],
154
+ messages: {
155
+ variant:
156
+ "The house Field sits inside a bounded form surface. Its HeroUI Input must use `variant=\"secondary\"`; the default variant draws a competing field surface.",
157
+ },
158
+ },
159
+ create(context) {
160
+ const file = normalizePath(context.filename || context.getFilename())
161
+ if (!/\/src\/components\/leaves\/Field\/index\.tsx$/.test(file)) return {}
162
+ const inputBindings = new Set()
163
+ return {
164
+ ImportDeclaration(node) {
165
+ if (node.source?.value !== "@heroui/react") return
166
+ for (const specifier of node.specifiers || []) {
167
+ if (specifier.imported?.name === "Input" && specifier.local?.name) inputBindings.add(specifier.local.name)
168
+ }
169
+ },
170
+ JSXOpeningElement(node) {
171
+ const name = node.name?.type === "JSXIdentifier" ? node.name.name : null
172
+ if (!name || !inputBindings.has(name)) return
173
+ const variant = (node.attributes || []).find((attribute) =>
174
+ attribute.type === "JSXAttribute"
175
+ && attribute.name?.type === "JSXIdentifier"
176
+ && attribute.name.name === "variant")
177
+ if (variant?.value?.type === "Literal" && variant.value.value === "secondary") return
178
+ context.report({ node, messageId: "variant" })
179
+ },
180
+ }
181
+ },
182
+ }
183
+
184
+ // -- VENDOR-9 --------------------------------------------------------------------------------------
185
+
186
+ /** Field labels stay textual instead of inferring decorative glyphs from the input kind. */
187
+ export const fieldLabelIsTextOnly = {
188
+ meta: {
189
+ type: "problem",
190
+ docs: { description: "The house Field label contains no inferred kind icon." },
191
+ schema: [],
192
+ messages: {
193
+ icon:
194
+ "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.",
195
+ },
196
+ },
197
+ create(context) {
198
+ const file = normalizePath(context.filename || context.getFilename())
199
+ if (!/\/src\/components\/leaves\/Field\/index\.tsx$/.test(file)) return {}
200
+ const iconBindings = new Set()
201
+ return {
202
+ ImportDeclaration(node) {
203
+ const source = normalizePath(node.source?.value)
204
+ if (!/\/components\/leaves\/Icon$/.test(source)) return
205
+ for (const specifier of node.specifiers || []) {
206
+ if (specifier.imported?.name === "Icon" && specifier.local?.name) iconBindings.add(specifier.local.name)
207
+ }
208
+ },
209
+ JSXOpeningElement(node) {
210
+ const name = node.name?.type === "JSXIdentifier" ? node.name.name : null
211
+ if (!name || !iconBindings.has(name)) return
212
+ let ancestor = node.parent
213
+ while (ancestor) {
214
+ if (
215
+ ancestor.type === "JSXElement"
216
+ && ancestor.openingElement?.name?.type === "JSXIdentifier"
217
+ && ancestor.openingElement.name.name === "label"
218
+ ) {
219
+ context.report({ node, messageId: "icon" })
220
+ return
221
+ }
222
+ ancestor = ancestor.parent
223
+ }
224
+ },
225
+ }
226
+ },
227
+ }
228
+
229
+ // -- VENDOR-8 --------------------------------------------------------------------------------------
230
+
231
+ /** An overlay is already bounded and cannot directly mount a second named surface branch. */
232
+ export const noSurfaceBranchInOverlay = {
233
+ meta: {
234
+ type: "problem",
235
+ docs: { description: "An overlay does not directly mount a named surface branch." },
236
+ schema: [],
237
+ messages: {
238
+ nested:
239
+ "This overlay is already the bounded surface. Do not mount `{{surface}}` inside it; use headings, spacing, rows and controls directly.",
240
+ },
241
+ },
242
+ create(context) {
243
+ const file = normalizePath(context.filename || context.getFilename())
244
+ if (!file.includes("/src/components/overlays/")) return {}
245
+ return {
246
+ ImportDeclaration(node) {
247
+ const source = normalizePath(node.source?.value)
248
+ const match = source.match(/\/components\/branches\/(SurfaceCard|SurfaceAccordionCard|SurfaceListCard|SurfaceFormCard)$/)
249
+ if (match) context.report({ node, messageId: "nested", data: { surface: match[1] } })
250
+ },
251
+ }
252
+ },
253
+ }
254
+
255
+ // -- VENDOR-10 -------------------------------------------------------------------------------------
256
+
257
+ /** TextLink delegates link interaction and hover treatment to HeroUI Link. */
258
+ export const textLinkUsesHeroLink = {
259
+ meta: {
260
+ type: "problem",
261
+ docs: { description: "TextLink wraps HeroUI Link instead of hand-drawing link behavior." },
262
+ schema: [],
263
+ messages: {
264
+ missing: "TextLink must import HeroUI `Link`. The vendor owns link hover, focus and interaction states.",
265
+ handmade: "Do not rebuild a HeroUI Link with a raw button or local className. Wrap HeroUI `Link` and pass `onPress`.",
266
+ },
267
+ },
268
+ create(context) {
269
+ const file = normalizePath(context.filename || context.getFilename())
270
+ if (!/\/src\/components\/leaves\/TextLink\/index\.tsx$/.test(file)) return {}
271
+ let hasHeroLink = false
272
+ return {
273
+ ImportDeclaration(node) {
274
+ if (node.source?.value !== "@heroui/react") return
275
+ hasHeroLink ||= (node.specifiers || []).some((specifier) => specifier.imported?.name === "Link")
276
+ },
277
+ JSXOpeningElement(node) {
278
+ if (node.name?.type === "JSXIdentifier" && node.name.name === "button") context.report({ node, messageId: "handmade" })
279
+ const className = (node.attributes || []).find(
280
+ (attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "className",
281
+ )
282
+ const classText = className?.value?.type === "Literal" ? String(className.value.value || "") : ""
283
+ if (/(?:hover:|underline)/.test(classText)) {
284
+ context.report({ node: className, messageId: "handmade" })
285
+ }
286
+ },
287
+ "Program:exit"(node) {
288
+ if (!hasHeroLink) context.report({ node, messageId: "missing" })
289
+ },
290
+ }
291
+ },
292
+ }
293
+
294
+ // -- VENDOR-11 -------------------------------------------------------------------------------------
295
+
296
+ /** The account block owns the product choices; DropdownBranch alone owns HeroUI mechanics. */
297
+ export const accountControlOwnsDropdown = {
298
+ meta: {
299
+ type: "problem",
300
+ docs: { description: "AccountMenu is a block over DropdownBranch; only the branch imports HeroUI Dropdown." },
301
+ schema: [],
302
+ messages: {
303
+ dropdown: "DropdownBranch must import HeroUI `Dropdown`; vendor mechanics belong to the branch.",
304
+ branch: "AccountMenu's pure block half must compose DropdownBranch instead of importing HeroUI directly.",
305
+ vendor: "AccountMenu is a product block. Import DropdownBranch; do not own HeroUI Dropdown here.",
306
+ menu: "ShellNav must render the AccountMenu block from `components/blocks/auth/AccountMenu`.",
307
+ direct: "The account IconButton may not carry an action in ShellNav. Guests open AccountMenu; its Sign in or Sign up choice opens the modal.",
308
+ pieces: "AccountMenu passes typed section data to DropdownBranch; it must not import Branch Section or Item pieces and rebuild vendor anatomy itself.",
309
+ },
310
+ },
311
+ create(context) {
312
+ const file = normalizePath(context.filename || context.getFilename())
313
+ const isDropdown = /\/src\/components\/branches\/DropdownBranch\/index\.tsx$/.test(file)
314
+ const isMenu = /\/src\/components\/blocks\/auth\/AccountMenu\/component\.tsx$/.test(file)
315
+ const isNav = /\/src\/components\/layouts\/ShellNav\/component\.tsx$/.test(file)
316
+ if (!isDropdown && !isMenu && !isNav) return {}
317
+ let hasOwner = false
318
+ return {
319
+ ExportNamedDeclaration(node) {
320
+ if (!isDropdown) return
321
+ const source = context.sourceCode || context.getSourceCode()
322
+ if (/\bDropdownBranch(?:Item|Section)\b/.test(source.getText(node))) {
323
+ context.report({ node, messageId: "pieces" })
324
+ }
325
+ },
326
+ ImportDeclaration(node) {
327
+ if (isDropdown && node.source?.value === "@heroui/react") {
328
+ hasOwner ||= (node.specifiers || []).some((specifier) => specifier.imported?.name === "Dropdown")
329
+ }
330
+ if (isMenu && node.source?.value === "@heroui/react") context.report({ node, messageId: "vendor" })
331
+ if (isMenu && /\/components\/branches\/DropdownBranch$/.test(normalizePath(node.source?.value))) {
332
+ hasOwner = true
333
+ if ((node.specifiers || []).some((specifier) =>
334
+ /^(?:DropdownBranchSection|DropdownBranchItem)/.test(specifier.imported?.name || ""))) {
335
+ context.report({ node, messageId: "pieces" })
336
+ }
337
+ }
338
+ if (isNav && /\/components\/blocks\/auth\/AccountMenu$/.test(normalizePath(node.source?.value))) hasOwner = true
339
+ },
340
+ JSXOpeningElement(node) {
341
+ if (!isNav || node.name?.type !== "JSXIdentifier" || node.name.name !== "IconButton") return
342
+ const props = (node.attributes || []).find((attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "props")
343
+ const expression = props?.value?.type === "JSXExpressionContainer" ? props.value.expression : null
344
+ const isAccount = expression?.type === "ObjectExpression" && expression.properties.some((property) =>
345
+ property.type === "Property" && property.key?.name === "icon" && property.value?.value === "account")
346
+ const hasAction = (node.attributes || []).some((attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "on")
347
+ if (isAccount && hasAction) context.report({ node, messageId: "direct" })
348
+ },
349
+ "Program:exit"(node) {
350
+ if (hasOwner) return
351
+ context.report({ node, messageId: isDropdown ? "dropdown" : isMenu ? "branch" : "menu" })
352
+ },
353
+ }
354
+ },
355
+ }
356
+
357
+ // -- VENDOR-13 -------------------------------------------------------------------------------------
358
+
359
+ /** Checkbox.Content owns the control, indicator and label so every visible word toggles it. */
360
+ export const checkboxKeepsCompoundAnatomy = {
361
+ meta: {
362
+ type: "problem",
363
+ docs: { description: "Keep the required HeroUI Checkbox nesting inside the house leaf." },
364
+ schema: [],
365
+ messages: {
366
+ anatomy:
367
+ "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.",
368
+ },
369
+ },
370
+ create(context) {
371
+ const file = normalizePath(context.filename || context.getFilename())
372
+ if (!/\/src\/components\/leaves\/Checkbox\/index\.tsx$/.test(file)) return {}
373
+ let hasContent = false
374
+ let controlInsideContent = false
375
+ let indicatorInsideControl = false
376
+ const hasAncestor = (node, expected) => {
377
+ let ancestor = node.parent
378
+ while (ancestor) {
379
+ if (ancestor.type === "JSXElement" && memberName(ancestor.openingElement?.name) === expected) return true
380
+ ancestor = ancestor.parent
381
+ }
382
+ return false
383
+ }
384
+ return {
385
+ JSXOpeningElement(node) {
386
+ const name = memberName(node.name)
387
+ if (name === "HeroCheckbox.Content") hasContent = true
388
+ if (name === "HeroCheckbox.Control" && hasAncestor(node, "HeroCheckbox.Content")) controlInsideContent = true
389
+ if (name === "HeroCheckbox.Indicator" && hasAncestor(node, "HeroCheckbox.Control")) indicatorInsideControl = true
390
+ },
391
+ "Program:exit"(node) {
392
+ if (!hasContent || !controlInsideContent || !indicatorInsideControl) {
393
+ context.report({ node, messageId: "anatomy" })
394
+ }
395
+ },
396
+ }
397
+ },
398
+ }
399
+
400
+ // -- VENDOR-14 -------------------------------------------------------------------------------------
401
+
402
+ /** Internal StarCi navigation is an action resolved by connected code, never an href. */
403
+ export const noInternalStarciHref = {
404
+ meta: {
405
+ type: "problem",
406
+ docs: { description: "Route internal StarCi destinations through connected router.push actions." },
407
+ schema: [],
408
+ messages: {
409
+ internal:
410
+ "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.",
411
+ leaf:
412
+ "This internal-navigation leaf must not declare or render `href`; it reports `on.press` and leaves routing to its connected owner.",
413
+ },
414
+ },
415
+ create(context) {
416
+ const file = normalizePath(context.filename || context.getFilename())
417
+ if (!file.includes("/src/") || /\.(?:test|spec)\.[jt]sx?$/.test(file)) return {}
418
+ const internalOnlyLeaf = /\/src\/components\/leaves\/(?:Checkbox|NavLink|QuickActionRow|SeeMoreLink)\/index\.tsx$/.test(file)
419
+ const literalText = (node) => {
420
+ if (node?.type === "Literal" && typeof node.value === "string") return node.value
421
+ if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked || ""
422
+ return null
423
+ }
424
+ const isInternal = (value) => value.startsWith("/") || /^https?:\/\/(?:www\.)?academy\.starci\.org(?:\/|$)/i.test(value)
425
+ return {
426
+ JSXAttribute(node) {
427
+ if (node.name?.name !== "href") return
428
+ if (internalOnlyLeaf) {
429
+ context.report({ node, messageId: "leaf" })
430
+ return
431
+ }
432
+ const value = node.value?.type === "JSXExpressionContainer" ? literalText(node.value.expression) : literalText(node.value)
433
+ if (value !== null && isInternal(value)) context.report({ node, messageId: "internal" })
434
+ },
435
+ Property(node) {
436
+ const key = node.key?.type === "Literal" ? node.key.value : node.key?.name
437
+ const value = literalText(node.value)
438
+ if ((key === "href" || key === "externalHref") && value !== null && isInternal(value)) {
439
+ context.report({ node, messageId: "internal" })
440
+ }
441
+ },
442
+ TSPropertySignature(node) {
443
+ if (!internalOnlyLeaf || node.key?.name !== "href") return
444
+ context.report({ node, messageId: "leaf" })
445
+ },
446
+ }
447
+ },
448
+ }
449
+
450
+ // -- VENDOR-12 -------------------------------------------------------------------------------------
451
+
452
+ /** The auth overlay projects one already-owned column and adds no vertical inset of its own. */
453
+ export const authOverlayOwnsSingleContentHost = {
454
+ meta: {
455
+ type: "problem",
456
+ docs: { description: "Authentication has one zero-inset content host inside ModalBranch or DrawerBranch." },
457
+ schema: [],
458
+ messages: {
459
+ 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`.",
460
+ 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`.",
461
+ 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.",
462
+ },
463
+ },
464
+ create(context) {
465
+ const file = normalizePath(context.filename || context.getFilename())
466
+ const isOverlay = /\/src\/components\/overlays\/auth\/SignInOverlay\/component\.tsx$/.test(file)
467
+ const isContracts = /\/src\/components\/contracts\/index\.ts$/.test(file)
468
+ if (!isOverlay && !isContracts) return {}
469
+ let hasContent = false
470
+
471
+ /*
472
+ * Two call shapes satisfy the same invariant - one contract-driven content host, zero extra
473
+ * vertical inset - and both are recognised here.
474
+ *
475
+ * (a) THE OLD SHAPE: the overlay imports `ContractContent` and mounts it itself as the branch's
476
+ * children. `ContractContent` IS the checked host, so importing it is enough to credit the
477
+ * overlay with owning one.
478
+ *
479
+ * (b) THE NEW SHAPE: `ModalBranch`/`DrawerBranch` were migrated to take `{contract, render}`
480
+ * directly and build `<Tree contract render />` internally (SLOTS-4). An overlay that hands both
481
+ * props straight to the branch has delegated the same host to the branch itself; there is
482
+ * nothing left for the overlay to import.
483
+ *
484
+ * Matched by JSX ATTRIBUTE NAME, not by where `ModalBranch`/`DrawerBranch` resolve from - same
485
+ * reasoning VENDOR-12 already applies to `ContractContent`/`Tree` below: canon vocabulary means
486
+ * the same thing wherever it is imported from.
487
+ */
488
+ const MECHANICS_BRANCH_NAMES = new Set(["ModalBranch", "DrawerBranch"])
489
+ const hasJsxAttribute = (attributes, attrName) =>
490
+ (attributes || []).some((attribute) =>
491
+ attribute.type === "JSXAttribute"
492
+ && attribute.name?.type === "JSXIdentifier"
493
+ && attribute.name.name === attrName)
494
+ const jsxChildName = (child) => {
495
+ if (child?.type !== "JSXElement") return null
496
+ const name = child.openingElement?.name
497
+ return name?.type === "JSXIdentifier" ? name.name : null
498
+ }
499
+
500
+ return {
501
+ ImportDeclaration(node) {
502
+ if (!isOverlay) return
503
+ /*
504
+ * Matched by the NAME imported, not by where it came from - see the comment above.
505
+ */
506
+ for (const specifier of node.specifiers || []) {
507
+ if (specifier.imported?.name === "ContractContent") hasContent = true
508
+ // Opening `Tree` by hand is the one thing neither shape ever does: both shapes close the
509
+ // host either inside ContractContent or inside the mechanics branch itself.
510
+ if (specifier.imported?.name === "Tree") context.report({ node: specifier, messageId: "duplicate" })
511
+ }
512
+ },
513
+ JSXElement(node) {
514
+ if (!isOverlay) return
515
+ const name = node.openingElement?.name
516
+ if (name?.type !== "JSXIdentifier" || !MECHANICS_BRANCH_NAMES.has(name.name)) return
517
+ const attributes = node.openingElement.attributes
518
+ if (!hasJsxAttribute(attributes, "contract") || !hasJsxAttribute(attributes, "render")) return
519
+ // The branch itself now owns the host: shape (b).
520
+ hasContent = true
521
+ // A second content host nested as children, when the branch already receives contract/render
522
+ // directly, duplicates the host the branch already opened.
523
+ const nestedHost = (node.children || []).some((child) =>
524
+ jsxChildName(child) === "Tree" || jsxChildName(child) === "ContractContent")
525
+ if (nestedHost) context.report({ node, messageId: "duplicate" })
526
+ },
527
+ Property(node) {
528
+ if (!isContracts) return
529
+ const key = node.key?.type === "Literal" ? node.key.value : node.key?.name
530
+ if (key !== "centred-page-column") return
531
+ const source = context.sourceCode || context.getSourceCode()
532
+ if (/["'`](?:py|pt|pb)-/.test(source.getText(node))) context.report({ node, messageId: "inset" })
533
+ },
534
+ "Program:exit"(node) {
535
+ if (isOverlay && !hasContent) context.report({ node, messageId: "missing" })
536
+ },
537
+ }
538
+ },
539
+ }
540
+
541
+ /**
542
+ * `VENDOR-15` - a table is written through `TableBranch` or it is not written.
543
+ *
544
+ * WHY THIS RULE EXISTS BESIDE `vendor-boundary`. That rule refuses a vendor IMPORT written in the
545
+ * wrong file, so it can only see boundaries that were crossed by importing something. A table built
546
+ * by hand imports nothing at all: it is `<table>` and `<tr>` and a column of borders, and the vendor
547
+ * it stands in for is never named anywhere in the file. That is the quieter failure and the one this
548
+ * rule is for - it refuses the ABSENCE, where its neighbour refuses the presence.
549
+ *
550
+ * WHAT IT READS. The tag names of the table family, and the ARIA roles that build a table without
551
+ * them. Reading both matters because the two substitutes look nothing alike in source: one is a
552
+ * semantic `<table>` somebody thought was harmless, the other is a grid of `div`s wearing
553
+ * `role="row"`. Neither is refused inside `TableBranch`, which is the one file that may draw them.
554
+ *
555
+ * WHAT IT CANNOT SEE. A grid of plain `div`s carrying no role at all, an element name resolved at
556
+ * runtime, and a table reached through a re-export under another name. It reads what is written; a
557
+ * hand-built grid that declares neither tag nor role is invisible to it, which is the same blind
558
+ * spot every anatomy rule in this file carries.
559
+ */
560
+ const TABLE_TAGS = new Set(["table", "thead", "tbody", "tfoot", "tr", "th", "td"])
561
+ const TABLE_ROLES = new Set(["table", "grid", "row", "rowgroup", "columnheader", "rowheader", "gridcell"])
562
+
563
+ export const tablesGoThroughTableBranch = {
564
+ meta: {
565
+ type: "problem",
566
+ docs: { description: "Rows and columns are drawn by TableBranch, never rebuilt by hand." },
567
+ schema: [],
568
+ messages: {
569
+ tag: "`<{{name}}>` is table anatomy written by hand. `TableBranch` owns HeroUI `Table` and its compound parts; pass it cells and let it rebuild the header, the row-header column and the scroll container.",
570
+ role: "`role=\"{{name}}\"` builds a table out of neutral elements. `TableBranch` owns the vendor that already reports these roles; a hand-built grid loses its keyboard model and its accessible name.",
571
+ },
572
+ },
573
+ create(context) {
574
+ const file = normalizePath(context.filename || context.getFilename?.() || "")
575
+ // The owner may draw what it owns. Everything else in the component tier may not.
576
+ if (!isComponentFile(file) || TABLE_BRANCH.test(file)) return {}
577
+ return {
578
+ JSXOpeningElement(node) {
579
+ const name = node.name
580
+ if (name?.type === "JSXIdentifier" && TABLE_TAGS.has(name.name)) {
581
+ context.report({ node: name, messageId: "tag", data: { name: name.name } })
582
+ return
583
+ }
584
+ for (const attribute of node.attributes || []) {
585
+ if (attribute.type !== "JSXAttribute" || attribute.name?.name !== "role") continue
586
+ const value = attribute.value
587
+ if (value?.type !== "Literal" || typeof value.value !== "string") continue
588
+ if (!TABLE_ROLES.has(value.value)) continue
589
+ context.report({ node: attribute, messageId: "role", data: { name: value.value } })
590
+ }
591
+ },
592
+ }
593
+ },
594
+ }
595
+
596
+ /** The rules this law contributes to the plugin. */
597
+ export const rules = {
598
+ "vendor-boundary": vendorBoundary,
599
+ "modal-branch-owns-scroll-body": modalBranchOwnsScrollBody,
600
+ "field-input-uses-secondary-variant": fieldInputUsesSecondaryVariant,
601
+ "field-label-is-text-only": fieldLabelIsTextOnly,
602
+ "no-surface-branch-in-overlay": noSurfaceBranchInOverlay,
603
+ "text-link-uses-hero-link": textLinkUsesHeroLink,
604
+ "account-control-owns-dropdown": accountControlOwnsDropdown,
605
+ "auth-overlay-owns-single-content-host": authOverlayOwnsSingleContentHost,
606
+ "checkbox-keeps-compound-anatomy": checkboxKeepsCompoundAnatomy,
607
+ "no-internal-starci-href": noInternalStarciHref,
608
+ "tables-go-through-table-branch": tablesGoThroughTableBranch,
609
+ }
610
+
611
+ /**
612
+ * The level this law asks for, as the plugin's own opinion.
613
+ *
614
+ * A repository adopting this with history should expect the outward half to report and the inward
615
+ * half to be silent until named mechanics branches exist. Creating those owners is the migration;
616
+ * no anonymous wrapper tier is introduced.
617
+ */
618
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))