@starci/eslint-canon-fe 3.0.2 → 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 +7 -0
- package/file-layout.mjs +99 -5
- package/index.mjs +2 -0
- package/package.json +2 -2
- package/props-and-slots.mjs +4 -2
- package/shape-slot.mjs +340 -0
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/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
|
-
|
|
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: `
|
|
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
|
+
"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
|
+
}
|
package/props-and-slots.mjs
CHANGED
|
@@ -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
|
-
|
|
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"]))
|