@starci/eslint-canon-fe 3.0.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,13 @@ props, served locales, component splits, design tokens, translations, type safet
30
30
  class-name ownership. Reusable class names belong in a colocated `classNames.ts` module and should
31
31
  be composed with HeroUI `cn` using one utility token per argument.
32
32
 
33
+ The `shape-slot` law holds the split surfaces (blocks, pages, layouts, overlays under `components/`
34
+ or `features/`): the pure `XBase` takes `{ state, props, on }` of atoms (`base-props-atom`), only its
35
+ sibling `index.tsx` imports it and never re-exports it (`base-import-pair`), `XState` names drawn
36
+ shapes and never a data status (`no-data-status-shape`), and a block renders each slot's
37
+ loading/forbidden/error/empty through `SlotView` (`slot-status-through-slotview`). Reference tree:
38
+ `.claude/examples/shape-slot`.
39
+
33
40
  Grammar is a business-neutral HeroUI-backed component package. Its components accept ordinary
34
41
  typed React props and children.
35
42
 
package/class-names.mjs CHANGED
@@ -11,7 +11,8 @@ const isCn = (node) => node?.type === "Identifier" && node.name === "cn"
11
11
 
12
12
  const importedClassNames = (context) => {
13
13
  const imports = new Set()
14
- for (const statement of context.getSourceCode().ast.body) {
14
+ const sourceCode = context.sourceCode || context.getSourceCode()
15
+ for (const statement of sourceCode.ast.body) {
15
16
  if (statement.type !== "ImportDeclaration" || statement.source.value !== "./classNames") continue
16
17
  for (const specifier of statement.specifiers) imports.add(specifier.local.name)
17
18
  }
package/file-layout.mjs CHANGED
@@ -16,10 +16,12 @@
16
16
  const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
17
17
 
18
18
  /** Tiers whose folder holds exactly the two halves of one surface. */
19
- const TWO_FILE_TIERS = /\/src\/components\/(pages|layouts)\/([^/]+)\/(.+)$/
19
+ // `features/` is the current root for route-facing surfaces (knowledge FE-FOLDER-1); `components/` is the
20
+ // earlier Academy layout, still accepted so existing trees stay valid.
21
+ const TWO_FILE_TIERS = /\/src\/(?:components|features)\/(pages|layouts)\/([^/]+)\/(.+)$/
20
22
 
21
23
  /** Overlays carry a feature category between the tier and the surface. */
22
- const TWO_FILE_TIERS_WITH_CATEGORY = /\/src\/components\/(overlays)\/[^/]+\/([^/]+)\/(.+)$/
24
+ const TWO_FILE_TIERS_WITH_CATEGORY = /\/src\/(?:components|features)\/(overlays)\/[^/]+\/([^/]+)\/(.+)$/
23
25
 
24
26
  /** The two halves, and the twin test of each, are the whole of what a surface folder may hold. */
25
27
  const ALLOWED_IN_SURFACE_FOLDER = /^(?:component|index|classNames)(?:\.spec)?\.tsx?$/
@@ -265,11 +267,11 @@ const ROUTE_TREE_TEST = /\.spec\.(?:tsx?|jsx?)$/
265
267
  export const routeTreeHoldsRoutesOnly = {
266
268
  meta: {
267
269
  type: "problem",
268
- docs: { description: "`src/app/**` holds framework route files only; a page owner lives in `components/pages/<Name>/`." },
270
+ docs: { description: "`src/app/**` holds framework route files only; a page owner lives in `features/pages/<Name>/` (or the earlier `components/pages/<Name>/`)." },
269
271
  schema: [],
270
272
  messages: {
271
273
  stray:
272
- "`app/{{rest}}` is not a route file. A file under `app/` names which page renders at which URL - `page`, `layout`, `loading`, `error` and their siblings are the framework's slots, and `{{basename}}` is not one of them. This is a component, so it belongs where its siblings are: `components/pages/<Name>/` for a screen, `components/blocks/<category>/<Name>/` for a domain sentence. Left here it is the one place nobody looks, and the route has quietly become a second page.",
274
+ "`app/{{rest}}` is not a route file. A file under `app/` names which page renders at which URL - `page`, `layout`, `loading`, `error` and their siblings are the framework's slots, and `{{basename}}` is not one of them. This is a component, so it belongs where its siblings are: `features/pages/<Name>/` for a screen, `components/blocks/<category>/<Name>/` for a domain sentence. Left here it is the one place nobody looks, and the route has quietly become a second page.",
273
275
  },
274
276
  },
275
277
  create(context) {
@@ -290,7 +292,7 @@ export const routeTreeHoldsRoutesOnly = {
290
292
  }
291
293
 
292
294
  /** The rules this law contributes to the plugin. */
293
- const TIER_PATH = /\/(?:src\/components|packages\/[^/]+\/src|apps\/[^/]+\/src\/(?:components\/)?)\/(leaves|composites|branches|blocks|layouts|overlays|pages)\//
295
+ const TIER_PATH = /\/(?:src\/components|src\/features|packages\/[^/]+\/src|apps\/[^/]+\/src\/(?:components\/|features\/)?)\/(leaves|composites|branches|blocks|layouts|overlays|pages)\//
294
296
 
295
297
  function propertyName(node) {
296
298
  if (node.key?.type === "Identifier") return node.key.name
@@ -390,11 +392,103 @@ export const unitTestColocated = {
390
392
  },
391
393
  }
392
394
 
395
+ /**
396
+ * The component a route slot mounts takes the slot's own name.
397
+ *
398
+ * `page.tsx` default-exports `Page`, `layout.tsx` default-exports `Layout`, and so on through
399
+ * the component slots. The filename already tells the framework what the file is; the export
400
+ * name is what the reader and the search results get, and a slot that calls its component
401
+ * `LandingPage` or `AuthenticationRoute` invents a second name for a file that already has a
402
+ * fixed one. The page OWNER - the component that draws the screen - keeps its own name and
403
+ * lives in `components/pages/`; the slot is a shell that mounts it, and a shell named `Page`
404
+ * says exactly that.
405
+ *
406
+ * `default` is deliberately absent: a parallel-route default mirrors whichever page it stands
407
+ * in for, so its name is a decision rather than a constant. `route`, `providers`, `globals`
408
+ * and the asset slots export no component and are not here.
409
+ */
410
+ const ROUTE_SLOT_NAME = {
411
+ page: "Page",
412
+ layout: "Layout",
413
+ template: "Template",
414
+ loading: "Loading",
415
+ error: "Error",
416
+ "global-error": "GlobalError",
417
+ "not-found": "NotFound",
418
+ }
419
+
420
+ const ROUTE_SLOT_FILE = new RegExp(
421
+ `^(?:${Object.keys(ROUTE_SLOT_NAME).join("|")})(?:\\.[a-z-]+)?\\.tsx$`,
422
+ )
423
+
424
+ export const routeSlotFixedName = {
425
+ meta: {
426
+ type: "problem",
427
+ docs: { description: "A route slot file default-exports a component named after the slot." },
428
+ schema: [],
429
+ messages: {
430
+ wrong:
431
+ "`{{basename}}` default-exports `{{actual}}`. A route slot takes the slot's own name - rename it `{{expected}}` and keep the drawing in the page owner under `features/pages/` (the slot is a shell that mounts it).",
432
+ anonymous:
433
+ "`{{basename}}` default-exports an anonymous component. A route slot takes the slot's own name - `const {{expected}} = () => <Owner />` then `export default {{expected}}`.",
434
+ missing:
435
+ "`{{basename}}` has no default export. A route slot's job is to mount its component - default-export `{{expected}}`.",
436
+ },
437
+ },
438
+ create(context) {
439
+ const file = normalizePath(context.filename || context.getFilename())
440
+ const hit = file.match(ROUTE_TREE)
441
+ if (!hit) return {}
442
+ const basename = hit[1].slice(hit[1].lastIndexOf("/") + 1)
443
+ if (ROUTE_TREE_TEST.test(basename)) return {}
444
+ const slot = basename.match(ROUTE_SLOT_FILE)
445
+ if (!slot) return {}
446
+ const expected = ROUTE_SLOT_NAME[slot[0].split(".")[0]]
447
+ let seen = false
448
+ const report = (node, actual) =>
449
+ context.report({
450
+ node,
451
+ messageId: actual === null ? "anonymous" : "wrong",
452
+ data: { basename, actual: actual ?? "(anonymous)", expected },
453
+ })
454
+ return {
455
+ ExportDefaultDeclaration(node) {
456
+ seen = true
457
+ const declaration = node.declaration
458
+ if (declaration.type === "Identifier") {
459
+ if (declaration.name !== expected) report(node, declaration.name)
460
+ return
461
+ }
462
+ if (
463
+ (declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration") &&
464
+ declaration.id
465
+ ) {
466
+ if (declaration.id.name !== expected) report(node, declaration.id.name)
467
+ return
468
+ }
469
+ report(node, null)
470
+ },
471
+ ExportNamedDeclaration(node) {
472
+ for (const specifier of node.specifiers || []) {
473
+ if (specifier.exported?.name !== "default") continue
474
+ seen = true
475
+ const local = specifier.local?.name ?? null
476
+ if (local !== expected) report(node, local)
477
+ }
478
+ },
479
+ "Program:exit"(node) {
480
+ if (!seen) context.report({ node, messageId: "missing", data: { basename, expected } })
481
+ },
482
+ }
483
+ },
484
+ }
485
+
393
486
  export const rules = {
394
487
  "no-shell-tier": noShellTier,
395
488
  "source-tier-marker-matches-folder": sourceTierMarkerMatchesFolder,
396
489
  "surface-folder-two-files-only": surfaceFolderTwoFilesOnly,
397
490
  "route-tree-holds-routes-only": routeTreeHoldsRoutesOnly,
491
+ "route-slot-fixed-name": routeSlotFixedName,
398
492
  "no-helper-folder-in-components": noHelperFolderInComponents,
399
493
  "export-matches-folder": exportMatchesFolder,
400
494
  "no-runtime-namespace": noRuntimeNamespace,
package/index.mjs CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  import { recommended as namingRecommended, rules as namingRules } from "./naming.mjs"
37
37
  import { recommended as propsRecommended, rules as propsRules } from "./props-and-slots.mjs"
38
38
  import { recommended as servedLocaleRecommended, rules as servedLocaleRules } from "./served-locale.mjs"
39
+ import { recommended as shapeSlotRecommended, rules as shapeSlotRules } from "./shape-slot.mjs"
39
40
  import { recommended as splitRecommended, rules as splitRules } from "./the-split.mjs"
40
41
  import { recommended as tokensRecommended, rules as tokensRules } from "./tokens.mjs"
41
42
  import { recommended as translationRecommended, rules as translationRules } from "./translation.mjs"
@@ -71,6 +72,7 @@ const CONTRIBUTIONS = [
71
72
  { law: "naming", rules: namingRules, recommended: namingRecommended },
72
73
  { law: "props-and-slots", rules: propsRules, recommended: propsRecommended },
73
74
  { law: "served-locale", rules: servedLocaleRules, recommended: servedLocaleRecommended },
75
+ { law: "shape-slot", rules: shapeSlotRules, recommended: shapeSlotRecommended },
74
76
  { law: "the-split", rules: splitRules, recommended: splitRecommended },
75
77
  { law: "tokens", rules: tokensRules, recommended: tokensRecommended },
76
78
  { law: "translation", rules: translationRules, recommended: translationRecommended },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "3.0.1",
3
+ "version": "4.0.0",
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",
@@ -33,4 +33,4 @@
33
33
  "starci",
34
34
  "canon"
35
35
  ]
36
- }
36
+ }
@@ -47,7 +47,7 @@ const componentNameOf = (node) => {
47
47
  export const publicComponentSignature = {
48
48
  meta: {
49
49
  type: "problem",
50
- docs: { description: "Exported React components use one parameter named props with a matching XProps type." },
50
+ docs: { description: "Exported React components use one parameter named props with a matching XProps type (XBase may also take XBaseProps)." },
51
51
  schema: [],
52
52
  messages: {
53
53
  parameter: "Exported component {{name}} must be an arrow const with exactly one parameter named props.",
@@ -63,7 +63,9 @@ export const publicComponentSignature = {
63
63
  return
64
64
  }
65
65
  const type = params[0].typeAnnotation?.typeAnnotation
66
- const expected = [name.endsWith("Base") ? `${name.slice(0, -4)}Props` : `${name}Props`]
66
+ // XBase pairs with XBaseProps so the connected X keeps XProps for itself; XProps stays accepted for
67
+ // existing twins.
68
+ const expected = name.endsWith("Base") ? [`${name}Props`, `${name.slice(0, -4)}Props`] : [`${name}Props`]
67
69
  const actual = type?.type === "TSTypeReference" && type.typeName?.type === "Identifier" ? type.typeName.name : null
68
70
  if (!actual || !expected.includes(actual)) context.report({ node: params[0], messageId: "type", data: { name, expected: expected.join(" or ") } })
69
71
  }
package/shape-slot.mjs ADDED
@@ -0,0 +1,340 @@
1
+ /**
2
+ * The rules that hold the shape-slot law (example: examples/shape-slot).
3
+ *
4
+ * TWO AXES, AND ONLY ONE IS DRAWN. A surface's `state` is its SHAPE: a layout the owner approves as
5
+ * one drawing. A slot's data status (loading, forbidden, error, empty) is not a shape; it is a
6
+ * recipe applied per api through `SlotView`, and it is never drawn. Mixing the two is what made
7
+ * every block ship four hand-drawn variants that disagreed with each other.
8
+ *
9
+ * THE PURE HALF TAKES ATOMS. `component.tsx` receives `{ state, props, on }`: `props` is data
10
+ * (strings, numbers, booleans, arrays, plain objects), `on` is actions whose arguments are data,
11
+ * and the children it renders are written inside it, never handed in. A layout alone also takes
12
+ * `children`, because the router puts the page there.
13
+ *
14
+ * ONLY ITS OWN INDEX MAY IMPORT IT. Other tiers use the connected export, so the pure half stays a
15
+ * private drawing surface that audit can render from a fixture.
16
+ *
17
+ * Every rule reads one file. Types imported from elsewhere are trusted as data; only names this
18
+ * file declares are followed, which is exact for the tree the convention produces and silent,
19
+ * never wrong, beyond it.
20
+ */
21
+
22
+ /** Forward-slash form of a filename, so Windows paths compare like every other path. */
23
+ const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
24
+
25
+ /** The pure half of a split tier, and which tier it is. */
26
+ const pureHalf = (filename) => {
27
+ const match = normalizePath(filename).match(
28
+ /\/src\/(?:components|features)\/(blocks|pages|layouts|overlays)\/(?:[^/]+\/)*[A-Z][A-Za-z0-9]*\/component\.tsx$/,
29
+ )
30
+ return match ? match[1] : null
31
+ }
32
+
33
+ /** Specs render the pure half from fixtures; that is the point of it. */
34
+ const isSpec = (filename) => /\.(spec|test)\.[cm]?[jt]sx?$/.test(normalizePath(filename))
35
+
36
+ /** Type names that carry a rendered tree rather than data. */
37
+ const NON_ATOM_TYPE = /^(?:ReactNode|ReactElement|ReactPortal|JSX\.Element|Element|ComponentType|FC|FunctionComponent|ElementType|ComponentProps|RenderFunction)$/
38
+
39
+ /** The printed name of a type reference, qualified names included. */
40
+ const typeNameOf = (node) => {
41
+ const name = node?.typeName
42
+ if (!name) return ""
43
+ if (name.type === "Identifier") return name.name
44
+ if (name.type === "TSQualifiedName") return `${typeNameOf({ typeName: name.left })}.${name.right.name}`
45
+ return ""
46
+ }
47
+
48
+ /** Every type this file declares, by name, so references can be followed locally. */
49
+ const collectLocalTypes = (program) => {
50
+ const types = new Map()
51
+ const visit = (statement) => {
52
+ const declaration = statement?.type === "ExportNamedDeclaration" ? statement.declaration : statement
53
+ if (declaration?.type === "TSTypeAliasDeclaration") types.set(declaration.id.name, declaration.typeAnnotation)
54
+ if (declaration?.type === "TSInterfaceDeclaration") types.set(declaration.id.name, declaration.body)
55
+ }
56
+ for (const statement of program.body) visit(statement)
57
+ return types
58
+ }
59
+
60
+ /** The members of an object-shaped type node, following local names and intersections. */
61
+ const membersOf = (node, types, seen = new Set()) => {
62
+ if (!node) return []
63
+ if (node.type === "TSTypeLiteral") return node.members
64
+ if (node.type === "TSInterfaceBody") return node.body
65
+ if (node.type === "TSIntersectionType") return node.types.flatMap((part) => membersOf(part, types, seen))
66
+ if (node.type === "TSTypeReference") {
67
+ const name = typeNameOf(node)
68
+ if (seen.has(name) || !types.has(name)) return []
69
+ seen.add(name)
70
+ return membersOf(types.get(name), types, seen)
71
+ }
72
+ return []
73
+ }
74
+
75
+ /** The key a property signature declares. */
76
+ const keyOf = (member) => (member.key?.type === "Identifier" ? member.key.name : member.key?.value)
77
+
78
+ // -- SHAPE-SLOT-1 ----------------------------------------------------------------------------------
79
+
80
+ /** The pure half takes `{ state, props, on }` of atoms; only a layout also takes `children`. */
81
+ export const basePropsAtom = {
82
+ meta: {
83
+ type: "problem",
84
+ docs: { description: "A pure `XBase` takes `state`, atom `props` and `on` actions; no component or tree crosses in." },
85
+ schema: [],
86
+ messages: {
87
+ tree:
88
+ "`{{path}}` hands a rendered tree (`{{type}}`) into the pure half. Write the child inside `component.tsx` and pass it atoms (ids, strings, flags); a tree handed in cannot be drawn, audited or reused.",
89
+ action:
90
+ "`{{path}}` is a function inside `props`. Actions live in `on`, so `props` stays pure data that a fixture can hold.",
91
+ onData:
92
+ "`on.{{key}}` is not an action. `on` holds functions only; data belongs in `props`.",
93
+ member:
94
+ "`{{key}}` is not part of the pure contract. A pure half takes `state`, `props` and `on`{{children}}.",
95
+ },
96
+ },
97
+ create(context) {
98
+ const tier = pureHalf(context.filename || context.getFilename())
99
+ if (!tier) return {}
100
+ let types = new Map()
101
+
102
+ /** Walk a data type: report trees and functions anywhere inside it. */
103
+ const checkData = (node, path, seen) => {
104
+ if (!node) return
105
+ switch (node.type) {
106
+ case "TSFunctionType":
107
+ context.report({ node, messageId: "action", data: { path } })
108
+ return
109
+ case "TSTypeReference": {
110
+ const name = typeNameOf(node)
111
+ if (NON_ATOM_TYPE.test(name)) {
112
+ context.report({ node, messageId: "tree", data: { path, type: name } })
113
+ return
114
+ }
115
+ for (const argument of node.typeArguments?.params ?? node.typeParameters?.params ?? []) checkData(argument, path, seen)
116
+ if (types.has(name) && !seen.has(name)) {
117
+ seen.add(name)
118
+ checkData(types.get(name), path, seen)
119
+ }
120
+ return
121
+ }
122
+ case "TSUnionType":
123
+ case "TSIntersectionType":
124
+ for (const part of node.types) checkData(part, path, seen)
125
+ return
126
+ case "TSArrayType":
127
+ checkData(node.elementType, path, seen)
128
+ return
129
+ case "TSTypeOperator":
130
+ checkData(node.typeAnnotation, path, seen)
131
+ return
132
+ case "TSTypeLiteral":
133
+ case "TSInterfaceBody":
134
+ for (const member of node.members ?? node.body) {
135
+ if (member.type === "TSMethodSignature") {
136
+ context.report({ node: member, messageId: "action", data: { path: `${path}.${keyOf(member)}` } })
137
+ } else if (member.type === "TSPropertySignature") {
138
+ checkData(member.typeAnnotation?.typeAnnotation, `${path}.${keyOf(member)}`, seen)
139
+ }
140
+ }
141
+ return
142
+ default:
143
+ }
144
+ }
145
+
146
+ /** Walk `on`: every member is a function whose parameters are data. */
147
+ const checkActions = (node) => {
148
+ for (const member of membersOf(node, types)) {
149
+ const key = keyOf(member)
150
+ const type = member.type === "TSMethodSignature" ? member : member.typeAnnotation?.typeAnnotation
151
+ if (type?.type !== "TSFunctionType" && type?.type !== "TSMethodSignature") {
152
+ context.report({ node: member, messageId: "onData", data: { key } })
153
+ continue
154
+ }
155
+ for (const parameter of type.params ?? type.parameters ?? []) {
156
+ checkData(parameter.typeAnnotation?.typeAnnotation, `on.${key}(${parameter.name ?? "arg"})`, new Set())
157
+ }
158
+ }
159
+ }
160
+
161
+ return {
162
+ Program(node) {
163
+ types = collectLocalTypes(node)
164
+ },
165
+ VariableDeclarator(node) {
166
+ if (node.id?.type !== "Identifier" || !/^[A-Z][A-Za-z0-9]*Base$/.test(node.id.name)) return
167
+ const fn = node.init
168
+ if (fn?.type !== "ArrowFunctionExpression" && fn?.type !== "FunctionExpression") return
169
+ const annotation = fn.params?.[0]?.typeAnnotation?.typeAnnotation
170
+ for (const member of membersOf(annotation, types)) {
171
+ const key = keyOf(member)
172
+ const type = member.typeAnnotation?.typeAnnotation
173
+ if (key === "state") continue
174
+ if (key === "props") checkData(type, "props", new Set())
175
+ else if (key === "on") checkActions(type)
176
+ else if (key === "children" && tier === "layouts") continue
177
+ else {
178
+ context.report({
179
+ node: member,
180
+ messageId: "member",
181
+ data: { key, children: tier === "layouts" ? ", plus the router's `children`" : "" },
182
+ })
183
+ }
184
+ }
185
+ },
186
+ }
187
+ },
188
+ }
189
+
190
+ // -- SHAPE-SLOT-2 ----------------------------------------------------------------------------------
191
+
192
+ /** Only the sibling `index.tsx` (and its specs) may reach `component.tsx`; only `X` leaves the folder. */
193
+ export const baseImportPair = {
194
+ meta: {
195
+ type: "problem",
196
+ docs: { description: "A pure `component.tsx` is imported only by its sibling `index.tsx`, which never re-exports `XBase`." },
197
+ schema: [],
198
+ messages: {
199
+ reach:
200
+ "`{{source}}` reaches into another surface's pure half. Import that surface's connected export from its folder; the pure half is a private drawing surface.",
201
+ reexport:
202
+ "`index.tsx` re-exports `{{name}}`. The folder exports only its connected `X`; the pure `XBase` stays behind it so no other tier can bypass the owner.",
203
+ },
204
+ },
205
+ create(context) {
206
+ const filename = normalizePath(context.filename || context.getFilename())
207
+ const base = filename.split("/").pop() || ""
208
+ const isOwnIndex = base === "index.tsx"
209
+ const spec = isSpec(filename)
210
+ const inTier = /\/src\/(?:components|features)\/(blocks|pages|layouts|overlays)\//.test(filename)
211
+ const reachesPure = (source) => /(?:^|\/)component(?:\.tsx)?$/.test(source)
212
+
213
+ const check = (node, source) => {
214
+ if (typeof source !== "string" || !reachesPure(source)) return
215
+ const isSibling = source === "./component" || source === "./component.tsx"
216
+ if (isSibling && (isOwnIndex || spec)) return
217
+ if (isSibling && !inTier) return
218
+ context.report({ node, messageId: "reach", data: { source } })
219
+ }
220
+
221
+ return {
222
+ ImportDeclaration(node) {
223
+ check(node, node.source?.value)
224
+ },
225
+ ExportAllDeclaration(node) {
226
+ check(node, node.source?.value)
227
+ },
228
+ ExportNamedDeclaration(node) {
229
+ const source = node.source?.value
230
+ if (!source) return
231
+ check(node, source)
232
+ if (!isOwnIndex || !inTier || node.exportKind === "type") return
233
+ for (const specifier of node.specifiers ?? []) {
234
+ const name = specifier.exported?.name ?? specifier.local?.name
235
+ if (specifier.exportKind !== "type" && /^[A-Z][A-Za-z0-9]*Base$/.test(name)) {
236
+ context.report({ node: specifier, messageId: "reexport", data: { name } })
237
+ }
238
+ }
239
+ },
240
+ }
241
+ },
242
+ }
243
+
244
+ // -- SHAPE-SLOT-3 ----------------------------------------------------------------------------------
245
+
246
+ /** Words that name a data status or an open/closed lifecycle, never a drawn shape. */
247
+ const DATA_STATUS = new Set([
248
+ "pending", "loading", "failed", "error", "errored", "empty", "forbidden", "unavailable",
249
+ "skeleton", "closed", "idle", "refreshing", "retrying",
250
+ ])
251
+
252
+ /** A surface's `XState` names shapes only. */
253
+ export const noDataStatusShape = {
254
+ meta: {
255
+ type: "problem",
256
+ docs: { description: "`XState` of a pure half names drawn shapes, never a data status or open/closed." },
257
+ schema: [],
258
+ messages: {
259
+ status:
260
+ "`{{name}}` lists \"{{value}}\" as a shape. Loading, forbidden, error and empty are a slot's data status: pass them as `Slot<T>` in `props` and render them through `SlotView`. Open/closed is an atom (`isOpen`). A shape is a layout the owner approves as one drawing.",
261
+ },
262
+ },
263
+ create(context) {
264
+ if (!pureHalf(context.filename || context.getFilename())) return {}
265
+ const check = (name, node) => {
266
+ const members = node?.type === "TSUnionType" ? node.types : [node]
267
+ for (const member of members) {
268
+ const value = member?.type === "TSLiteralType" ? member.literal?.value : undefined
269
+ if (typeof value === "string" && DATA_STATUS.has(value)) {
270
+ context.report({ node: member, messageId: "status", data: { name, value } })
271
+ }
272
+ }
273
+ }
274
+ return {
275
+ TSTypeAliasDeclaration(node) {
276
+ if (/^[A-Z][A-Za-z0-9]*State$/.test(node.id.name)) check(node.id.name, node.typeAnnotation)
277
+ },
278
+ }
279
+ },
280
+ }
281
+
282
+ // -- SHAPE-SLOT-4 ----------------------------------------------------------------------------------
283
+
284
+ /** Flags that are a slot's data status. */
285
+ const STATUS_FLAG = /^(?:isLoading|isError|isForbidden|isPending|isValidating|isEmpty)$/
286
+
287
+ /** The name a test expression reads, if it is a status flag. */
288
+ const statusFlagOf = (node) => {
289
+ if (!node) return null
290
+ if (node.type === "UnaryExpression") return statusFlagOf(node.argument)
291
+ if (node.type === "ChainExpression") return statusFlagOf(node.expression)
292
+ if (node.type === "Identifier" && STATUS_FLAG.test(node.name)) return node.name
293
+ if (node.type === "MemberExpression" && node.property?.type === "Identifier" && STATUS_FLAG.test(node.property.name)) {
294
+ return node.property.name
295
+ }
296
+ if (node.type === "LogicalExpression" || node.type === "BinaryExpression") return statusFlagOf(node.left) ?? statusFlagOf(node.right)
297
+ return null
298
+ }
299
+
300
+ /** A block's pure half never branches on a data status itself. */
301
+ export const slotStatusThroughSlotView = {
302
+ meta: {
303
+ type: "problem",
304
+ docs: { description: "A block's pure half renders data status through `SlotView`, never its own branch." },
305
+ schema: [],
306
+ messages: {
307
+ branch:
308
+ "This branches on `{{flag}}` by hand. Every slot renders its status through `SlotView`, the one recipe: a second branch drifts from it and becomes one more variant somebody draws.",
309
+ },
310
+ },
311
+ create(context) {
312
+ if (pureHalf(context.filename || context.getFilename()) !== "blocks") return {}
313
+ const check = (node, test) => {
314
+ const flag = statusFlagOf(test)
315
+ if (flag) context.report({ node, messageId: "branch", data: { flag } })
316
+ }
317
+ return {
318
+ ConditionalExpression(node) {
319
+ check(node, node.test)
320
+ },
321
+ IfStatement(node) {
322
+ check(node, node.test)
323
+ },
324
+ LogicalExpression(node) {
325
+ if (node.operator === "&&" && node.parent?.type === "JSXExpressionContainer") check(node, node.left)
326
+ },
327
+ }
328
+ },
329
+ }
330
+
331
+ /** The rules this law contributes to the plugin. */
332
+ export const rules = {
333
+ "base-props-atom": basePropsAtom,
334
+ "base-import-pair": baseImportPair,
335
+ "no-data-status-shape": noDataStatusShape,
336
+ "slot-status-through-slotview": slotStatusThroughSlotView,
337
+ }
338
+
339
+ /** Every FE canon rule is an error; existing debt is fixed before adoption. */
340
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))