@starci/eslint-canon-fe 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The rules that hold `props-and-slots.md`.
3
+ *
4
+ * ALMOST ALL OF THIS LAW IS HELD BY A TYPE, not by a rule. The slot aliases in `props.ts` are the
5
+ * fence: a fifth slot does not fail review, it fails to compile, and there is nothing left for a
6
+ * rule to patrol once the shape itself refuses. That is the stronger arrangement and it is why this
7
+ * module is small.
8
+ *
9
+ * What a type cannot see is a shape with no NAME. An inline object type at the parameter satisfies
10
+ * every constraint the alias imposes and is still wrong, because the wrongness is not about which
11
+ * fields exist - it is that nothing else can refer to them. A compiler has no opinion about whether
12
+ * a shape is findable.
13
+ */
14
+
15
+ import { COMPONENT_ROOTS, isContractTableFile, isInComponentTier } from "./contract.mjs"
16
+
17
+ /** True when a parameter type contains an anonymous object shape, including inside intersections. */
18
+ const isInlineObjectType = (node) => {
19
+ if (!node) return false
20
+ if (node.type === "TSTypeLiteral") return true
21
+ if (node.type === "TSParenthesizedType") return isInlineObjectType(node.typeAnnotation)
22
+ if (node.type === "TSIntersectionType" || node.type === "TSUnionType") {
23
+ return (node.types || []).some(isInlineObjectType)
24
+ }
25
+ return false
26
+ }
27
+
28
+ // -- SLOTS-3 ---------------------------------------------------------------------------------------
29
+
30
+ /** A parameter's complete shape is named in the module, never assembled at the parameter. */
31
+ export const noInlineParameterType = {
32
+ meta: {
33
+ type: "suggestion",
34
+ docs: { description: "A parameter takes one named props type, not an inline or intersected object shape." },
35
+ schema: [],
36
+ messages: {
37
+ inline:
38
+ "This parameter's shape is written inline, so it has no name and nothing can refer to it - not an import, not the twin that tests this file, not somebody looking for what this component accepts. Declare it in the module and name it here. The cost is one line; the difference is between a contract and a signature.",
39
+ },
40
+ },
41
+ create(context) {
42
+ const checkParams = (node) => {
43
+ for (const param of node.params || []) {
44
+ if (!param) continue
45
+ const declared = param.typeAnnotation?.typeAnnotation
46
+ if (!isInlineObjectType(declared)) continue
47
+ context.report({ node: param.typeAnnotation || param, messageId: "inline" })
48
+ }
49
+ }
50
+ return {
51
+ ArrowFunctionExpression: checkParams,
52
+ FunctionExpression: checkParams,
53
+ FunctionDeclaration: checkParams,
54
+ TSEmptyBodyFunctionExpression: checkParams,
55
+ }
56
+ },
57
+ }
58
+
59
+ // -- SLOTS-4 ---------------------------------------------------------------------------------------
60
+
61
+ /** The only three components where a React `children` hole is lawful. */
62
+ const CHILDREN_SHELLS = ["ModalShell", "DrawerShell", "DropdownShell", "RouteShell"]
63
+
64
+ /**
65
+ * True for a component file the slot fence governs.
66
+ *
67
+ * TWO EXEMPTIONS AND ONE LAYOUT FIX, all of which this predicate got wrong at once.
68
+ *
69
+ * It read `/src/components/` as a literal, so in a monorepo - where the same tier sits at
70
+ * `packages/ui/src/*` - the rule applied to NOTHING. That repository reported no violations, which
71
+ * looked like compliance and was silence: the fence was not holding anywhere in it.
72
+ *
73
+ * The registry table is exempt because `ContractSpec.children` is not a children hole; it is the
74
+ * NAMED CHILD GRAMMAR that replaces one. Reporting it asks the file that abolished the anonymous
75
+ * slot to stop describing what it admits instead.
76
+ *
77
+ * The three shells stay exempt for the reason canon already gives them: they pass real children
78
+ * through and arrange nothing.
79
+ *
80
+ * @param filename - the file being linted.
81
+ */
82
+ const isGoverned = (filename) => {
83
+ const path = String(filename || "").replace(/\\/g, "/")
84
+ if (isContractTableFile(path)) return false
85
+ if (CHILDREN_SHELLS.some((shell) => isInComponentTier(path, `shells/${shell}`))) return false
86
+ /*
87
+ * The bare `src` root is dropped here, and only here. `COMPONENT_ROOTS` carries it as a
88
+ * catch-all so a reader that walks up from any file still finds the table; used as a FENCE it
89
+ * matches every file under `src/`, which pulls routed pages into a rule about component slots and
90
+ * reports a page for taking children - the one thing a page legitimately does.
91
+ */
92
+ return COMPONENT_ROOTS.filter((root) => root !== "src").some((root) => path.includes(`/${root}/`))
93
+ }
94
+
95
+ /**
96
+ * A container takes `contract` and `render`, never `children`.
97
+ *
98
+ * THE TYPE CANNOT CATCH THIS ONE, which is why it is here. `props.ts` refuses a fourth slot on the
99
+ * aliases it defines, but nothing stops a file declaring its own props shape by hand and putting
100
+ * `children` in it. What the alias makes unrepresentable, a hand-written interface makes ordinary.
101
+ *
102
+ * ModalShell, DrawerShell and DropdownShell are exempt because they ignore the interior shape and
103
+ * pass it directly to the vendor body. No folder-wide exemption exists.
104
+ */
105
+ export const noChildrenSlot = {
106
+ meta: {
107
+ type: "problem",
108
+ docs: { description: "A container takes contract and render; only the three closed shells may take children." },
109
+ schema: [],
110
+ messages: {
111
+ slot:
112
+ "`children` accepts markup that has already been built, so its shape cannot be checked. Take contract + render instead. Only ModalShell, DrawerShell, DropdownShell and RouteShell may take one - the first three pass an interior straight to vendor mechanics, and RouteShell converts the children a framework layout is handed.",
113
+ },
114
+ },
115
+ create(context) {
116
+ if (!isGoverned(context.filename || context.getFilename())) return {}
117
+ return {
118
+ TSPropertySignature(node) {
119
+ if (node.key && node.key.type === "Identifier" && node.key.name === "children") {
120
+ context.report({ node: node.key, messageId: "slot" })
121
+ }
122
+ },
123
+ Property(node) {
124
+ // a destructured `children` in a parameter, which is the same slot arriving by another door
125
+ if (node.parent && node.parent.type !== "ObjectPattern") return
126
+ if (node.parent.parent && node.parent.parent.type === "VariableDeclarator") return
127
+ if (node.key && node.key.type === "Identifier" && node.key.name === "children") {
128
+ context.report({ node: node.key, messageId: "slot" })
129
+ }
130
+ },
131
+ }
132
+ },
133
+ }
134
+
135
+ // -- SLOTS-7 ---------------------------------------------------------------------------------------
136
+
137
+ /** A joined-list surface receives domain collections through named props, never a generic items lane. */
138
+ export const noSurfaceListItemsSlot = {
139
+ meta: {
140
+ type: "problem",
141
+ docs: { description: "SurfaceListCard receives collection data through named props, never items." },
142
+ schema: [],
143
+ messages: {
144
+ items:
145
+ "`items` creates a second runtime-data lane beside `props` and makes SurfaceListCard know every domain collection. Put the collection in the render component's named props type (for example `tasks`) and pass it through `props`.",
146
+ },
147
+ },
148
+ create(context) {
149
+ const filename = String(context.filename || context.getFilename()).replace(/\\/g, "/")
150
+ if (!filename.includes("/src/")) return {}
151
+ const bindings = new Set()
152
+ return {
153
+ ImportDeclaration(node) {
154
+ const source = String(node.source?.value || "").replace(/\\/g, "/")
155
+ if (!/(?:^|\/)components\/branches\/SurfaceListCard$/.test(source)) return
156
+ for (const specifier of node.specifiers || []) {
157
+ const imported = specifier.imported?.name
158
+ if (imported === "SurfaceListCard" && specifier.local?.name) bindings.add(specifier.local.name)
159
+ }
160
+ },
161
+ JSXOpeningElement(node) {
162
+ const component = node.name?.type === "JSXIdentifier" ? node.name.name : null
163
+ if (!component || !bindings.has(component)) return
164
+ for (const attribute of node.attributes || []) {
165
+ if (attribute.type !== "JSXAttribute") continue
166
+ if (attribute.name?.type === "JSXIdentifier" && attribute.name.name === "items") {
167
+ context.report({ node: attribute, messageId: "items" })
168
+ }
169
+ }
170
+ },
171
+ }
172
+ },
173
+ }
174
+
175
+ /** The rules this law contributes to the plugin. */
176
+ export const rules = {
177
+ "no-inline-parameter-type": noInlineParameterType,
178
+ "no-children-slot": noChildrenSlot,
179
+ "no-surface-list-items-slot": noSurfaceListItemsSlot,
180
+ }
181
+
182
+ /**
183
+ * The level this law asks for, as the plugin's own opinion.
184
+ *
185
+ * Exact and mechanical: it fires on a syntactic shape rather than on a judgement, so there is no
186
+ * false-positive risk that would justify adopting it at `warn`.
187
+ */
188
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/props.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * TEMPLATE - `src/components/contracts/props.ts`.
3
+ *
4
+ * Copy this file to that path unchanged. Unlike the contract table beside it, there is nothing here
5
+ * for a repository to grow: these are the slot shapes themselves, and a repository that edits them
6
+ * has changed the rules rather than filled them in.
7
+ *
8
+ * THE SLOT SHAPES, AS TYPES RATHER THAN AS A CONVENTION.
9
+ *
10
+ * Every component below a block takes a FIXED set of named slots and no others. Written as a type
11
+ * alias per tier, a fourth slot is not discouraged - it does not compile, because the alias is the
12
+ * whole shape and there is nowhere to put one.
13
+ *
14
+ * This is the difference between a rule and a fence. `interface XProps { props: XData; isLoading?: boolean }`
15
+ * is a rule: correct today, and one `extends` away from carrying a `className` next month.
16
+ * `type XProps = LeafProps<XData>` is a fence.
17
+ */
18
+ import type { ContractComponent, ContractKey } from "./index"
19
+
20
+ export type { InnerProps } from "./index"
21
+
22
+ /**
23
+ * What DATA is: anything a JSON document could hold.
24
+ *
25
+ * A function does not satisfy it, and that is the only thing stopping a component being smuggled
26
+ * through `props` - which is why handlers travel in their own slot rather than beside the data.
27
+ *
28
+ * NOTE FOR AUTHORS: a component's data must be declared with `type`, not `interface`. TypeScript
29
+ * gives an implicit index signature to a type alias and not to an interface, so an interface
30
+ * silently fails this constraint. That is not a quirk to work around - it is the constraint doing
31
+ * its job.
32
+ */
33
+ export type DataValue =
34
+ | string
35
+ | number
36
+ | boolean
37
+ | null
38
+ | undefined
39
+ | ReadonlyArray<DataValue>
40
+ | { readonly [key: string]: DataValue }
41
+
42
+ /** The shape any component's data must have: data all the way down. */
43
+ export type ComponentData = { readonly [key: string]: DataValue }
44
+
45
+ /** The shape any component's handlers must have: functions, kept apart from the data. */
46
+ export type ComponentActions = { readonly [key: string]: ((...args: Array<never>) => void) | undefined }
47
+
48
+ /**
49
+ * A LEAF's props. Three slots, no fourth.
50
+ *
51
+ * `props` - what it draws. `on` - what it does. `isLoading` - handed down, never decided here.
52
+ * No `render`: only a branch assembles. No `className`: a caller who can restyle a node has
53
+ * become its second owner, and the component now has two authors who never speak.
54
+ */
55
+ export type LeafProps<D extends ComponentData, A extends ComponentActions = ComponentActions> = {
56
+ readonly props: D
57
+ readonly on?: A
58
+ readonly isLoading?: boolean
59
+ }
60
+
61
+ /**
62
+ * A COMPOSITE's props. The same three slots a leaf has, and the absence of a fourth is the point.
63
+ *
64
+ * A composite is a CLOSED shape: it assembles leaves into an arrangement a caller can use but
65
+ * cannot rearrange. That boundary with `branch` is held by this type rather than by a convention -
66
+ * there is no `render` here, so an author who wanted a slot cannot express one and has to decide
67
+ * which tier they are actually writing. A boundary the compiler holds does not drift.
68
+ * CLOSED DOES NOT MEAN FREEHAND. A composite still assembles through a typed `Tree` contract;
69
+ * raw structural markup would create an unnamed layout outside the registry. If a caller may
70
+ * supply the content or recipe, the component is a branch rather than a composite.
71
+ *
72
+ * It is structurally identical to {@link LeafProps} and deliberately NOT an alias of it: the two
73
+ * tiers answer different questions, and a shared name would let a reader conclude they are the same
74
+ * kind of thing. What separates them is what the file may DO, not what it may accept.
75
+ */
76
+ export type CompositeProps<D extends ComponentData, A extends ComponentActions = ComponentActions> = {
77
+ readonly props: D
78
+ readonly on?: A
79
+ readonly isLoading?: boolean
80
+ }
81
+
82
+ /**
83
+ * A BRANCH's props. The same three a leaf has, plus the key it draws and the components that fill
84
+ * the slots that key declares.
85
+ *
86
+ * THIS IS THE SLOT THAT USED TO BE `children`, AND THE CHANGE IS NOT COSMETIC. `children` is markup
87
+ * that has already been built: it can be a `.map`, a ternary, a fragment, or a subtree nobody named,
88
+ * so nothing above it can say what is inside a node and no rule can check. `render` is a contract
89
+ * value in one of two lanes. This alias is the bound lane: Tree and ordinary branches receive
90
+ * named slots or an explicit projection. A data-driven surface uses {@link ContractRenderBranchProps}
91
+ * to receive a stable component type and pass changing runtime `props` into it.
92
+ *
93
+ * `contract` and `render` are one decision written twice, and `ContractComponent<K>` ties them together:
94
+ * the key fixes which slots exist and what may fill each, so a card cannot say one thing and hold
95
+ * another. A wrong leaf, wrong leaf props, a node where a leaf was asked for, a missing slot and a
96
+ * slot nobody declared are five compile errors rather than five things a reviewer has to spot.
97
+ *
98
+ * A bare callback or JSX value may not arrive: neither carries the exact key metadata that makes
99
+ * `contract` and `render` one checked decision.
100
+ *
101
+ * THERE IS NO LIST VARIANT, and there does not need to be. A slot that holds many says so in the
102
+ * table with `repeats: true`; that slot becomes an array. `restingCount` separately fixes how many
103
+ * placeholders draw while loading, because live length is dynamic and is not the skeleton count.
104
+ */
105
+ export type BranchProps<
106
+ D extends ComponentData,
107
+ K extends ContractKey,
108
+ A extends ComponentActions = ComponentActions,
109
+ > = {
110
+ readonly props: D
111
+ readonly on?: A
112
+ readonly contract: K
113
+ readonly render: ContractComponent<NoInfer<K>>
114
+ readonly isLoading?: boolean
115
+ }
116
+
117
+ /**
118
+ * A data-driven contract host's props. Runtime data stays in `props`; `render` is a stable real
119
+ * component type branded for the exact key. `SurfaceListCard` uses this lane because a repeated
120
+ * domain collection must not be closed into a new callback descriptor at every host call site.
121
+ */
122
+ export type ContractRenderBranchProps<
123
+ D extends ComponentData,
124
+ K extends ContractKey,
125
+ A extends ComponentActions = ComponentActions,
126
+ > = {
127
+ readonly props: D
128
+ readonly on?: A
129
+ readonly contract: K
130
+ readonly render: ContractComponent<NoInfer<K>, LeafProps<D, A>>
131
+ readonly isLoading?: boolean
132
+ }
133
+
134
+ /**
135
+ * A BLOCK's presentational half. Two slots.
136
+ *
137
+ * `state` is the business situation and it picks a tree; `props` is what that tree says. There is
138
+ * no `isLoading` here - a block WRITES the flag when it hands a tree down, and never receives one.
139
+ * The type is a union per state at the call site, so the data of a situation a surface is NOT in
140
+ * cannot be passed, and the data of the one it IS in cannot be omitted.
141
+ */
142
+ export type BlockProps<S extends string, D extends ComponentData> = {
143
+ readonly state: S
144
+ readonly props: D
145
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Rules for the SERVED LOCALE law - `fe/canon/patterns/served-locale.md`.
3
+ *
4
+ * WHY THESE EXIST. An application obeyed the translation law completely - every label resolved in a
5
+ * connected half, no literal below a block, both catalogues in step - and served a Vietnamese reader
6
+ * an English course on a `/vi/` URL. The chrome came from the dictionary and the content came from an
7
+ * API that was never told which language to serve, so the failure looked like a translation gap and
8
+ * was looked for in the dictionary, where there was nothing wrong. The header was missing, and no
9
+ * gate had anything to say about a header nobody wrote.
10
+ *
11
+ * WHAT THEY HOLD. That the transport chain declares a locale, and that exactly one place writes it.
12
+ * Both are file-at-a-time facts: the chain is assembled in one file, and a header is written where it
13
+ * is written.
14
+ *
15
+ * WHAT THEY CANNOT HOLD, stated rather than implied. Neither rule can tell whether the value the link
16
+ * computes is CORRECT - whether it reads the address, the cookie or a constant - because that is
17
+ * inside a function these rules only see the name of. A link that attaches `x-locale: "en"`
18
+ * unconditionally satisfies both and is wrong. That case is a review question and LOCALE-2 is where
19
+ * it is argued; pretending otherwise would be the rule claiming a guarantee it does not have.
20
+ *
21
+ * WHY THE FIRST RULE KEYS ON THE TERMINAL LINK RATHER THAN ON THE FILE PATH. A repository may name
22
+ * its client folder anything, and a path pattern that guessed wrong would either miss the real chain
23
+ * or fire on every helper beside it. The chain is identifiable by what it does: it builds the link
24
+ * that talks to the network.
25
+ *
26
+ * WITH ONE EXEMPTION THAT WAS FOUND BY RUNNING IT, NOT BY THINKING ABOUT IT. "Constructs the
27
+ * terminal link" is also true of the file that DEFINES that link, so the first version reported
28
+ * `links/http.ts` and its spec on a repository that had done everything right. There was no correct
29
+ * way to satisfy it there - a locale link inside the http link is a chain hiding in a link - and a
30
+ * rule with no correct way to satisfy it is a finding about the rule. A file under `links/` is one
31
+ * link; a chain is assembled elsewhere, which is where the locale belongs.
32
+ */
33
+
34
+ /** Forward-slash form of a filename, so Windows paths compare like every other path. */
35
+ const normalize = (filename) => String(filename || "").replace(/\\/g, "/")
36
+
37
+ /**
38
+ * The link file that owns the locale header.
39
+ *
40
+ * This is the ONE file allowed to write it, and it is recognised by path rather than by content:
41
+ * the point of the second rule is that the header appears in one place, so that place has to be
42
+ * named somewhere, and a name is what the law and the rule can both refer to.
43
+ */
44
+ export const isLocaleLinkFile = (filename) => /\/links\/locale\.[cm]?tsx?$/.test(normalize(filename))
45
+
46
+ /**
47
+ * One link's own implementation, rather than a chain of them.
48
+ *
49
+ * WHY THIS EXEMPTION EXISTS, AND IT WAS MEASURED. The first version of the chain rule keyed only on
50
+ * "constructs the terminal link", which is true of the file that DEFINES that link - `links/http.ts`
51
+ * builds `new HttpLink(...)`, because that is its whole job. Run against real source it reported
52
+ * that file and its spec, and there was no correct way to satisfy it there: attaching a locale link
53
+ * inside the http link would be a chain hiding in a link.
54
+ *
55
+ * A file under `links/` is one link. A chain is assembled somewhere else, which is exactly where the
56
+ * locale belongs.
57
+ */
58
+ export const isLinkImplementationFile = (filename) => /\/links\/[^/]+$/.test(normalize(filename))
59
+
60
+ /** A spec or test file - it asserts about production shapes rather than being one. */
61
+ export const isTestFile = (filename) => /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(normalize(filename))
62
+
63
+ /** The header a request declares its language in. */
64
+ export const LOCALE_HEADER = "x-locale"
65
+
66
+ /** Callee names that construct the terminal link - the one that actually reaches the network. */
67
+ const TERMINAL_LINK_CALLEES = new Set(["createHttpLink", "HttpLink", "createUploadLink", "BatchHttpLink"])
68
+
69
+ /** Callee names that attach the reader's locale to an operation. */
70
+ const LOCALE_LINK_CALLEES = new Set(["createAttachLocaleLink", "createLocaleLink"])
71
+
72
+ /** The callee name of a call or `new` expression, when it has a plain one. */
73
+ const calleeName = (node) => {
74
+ const callee = node && node.callee
75
+ if (!callee) return null
76
+ if (callee.type === "Identifier") return callee.name
77
+ if (callee.type === "MemberExpression" && callee.property && callee.property.type === "Identifier") {
78
+ return callee.property.name
79
+ }
80
+ return null
81
+ }
82
+
83
+ /** Every string-ish key a property can carry, so `"x-locale"` and `["x-locale"]` read the same. */
84
+ const propertyKeyOf = (property) => {
85
+ if (!property || property.type !== "Property") return null
86
+ const key = property.key
87
+ if (!key) return null
88
+ if (key.type === "Identifier" && !property.computed) return key.name
89
+ if (key.type === "Literal" && typeof key.value === "string") return key.value
90
+ return null
91
+ }
92
+
93
+ export const rules = {
94
+ "api-client-attaches-the-locale": {
95
+ meta: {
96
+ type: "problem",
97
+ docs: {
98
+ description:
99
+ "A transport chain that builds the terminal HTTP link must also attach the reader's locale, so every call declares the language it wants served.",
100
+ },
101
+ schema: [],
102
+ messages: {
103
+ missing:
104
+ "This assembles the API transport but never attaches the reader's locale, so every call it makes is served the server's default language. Add the locale link to the chain, beside the auth link and unconditionally - a guest reads in a language too. A surface that is bilingual in its chrome and monolingual in its content reads as a translation gap, which is why it gets looked for in the dictionary rather than here.",
105
+ },
106
+ },
107
+ create(context) {
108
+ const filename = context.filename || context.getFilename()
109
+ // A single link's implementation is not a chain - `links/http.ts` constructs the terminal link
110
+ // because defining it IS its job, and there is no correct way to attach a locale there. A
111
+ // spec asserts about a chain rather than being one.
112
+ if (isLinkImplementationFile(filename) || isTestFile(filename)) return {}
113
+ let terminalNode = null
114
+ let attachesLocale = false
115
+ const note = (node) => {
116
+ const name = calleeName(node)
117
+ if (name === null) return
118
+ if (TERMINAL_LINK_CALLEES.has(name)) terminalNode = terminalNode || node
119
+ if (LOCALE_LINK_CALLEES.has(name)) attachesLocale = true
120
+ }
121
+ return {
122
+ CallExpression: note,
123
+ NewExpression: note,
124
+ "Program:exit"() {
125
+ if (terminalNode && !attachesLocale) {
126
+ context.report({ node: terminalNode, messageId: "missing" })
127
+ }
128
+ },
129
+ }
130
+ },
131
+ },
132
+
133
+ "locale-header-belongs-to-the-link": {
134
+ meta: {
135
+ type: "problem",
136
+ docs: {
137
+ description:
138
+ "The locale header is written by the locale link and nowhere else, so one place answers which language a request is in.",
139
+ },
140
+ schema: [],
141
+ messages: {
142
+ elsewhere:
143
+ "Only the locale link may write `x-locale`. Setting it here is a second answer to which language this request is in, and the two diverge the first time one of them is updated - usually leaving a single hook correct and the rest of the surface on the server's default. Let the transport attach it.",
144
+ },
145
+ },
146
+ create(context) {
147
+ const filename = context.filename || context.getFilename()
148
+ if (isLocaleLinkFile(filename)) return {}
149
+ return {
150
+ Property(node) {
151
+ if (propertyKeyOf(node) === LOCALE_HEADER) {
152
+ context.report({ node, messageId: "elsewhere" })
153
+ }
154
+ },
155
+ }
156
+ },
157
+ },
158
+ }
159
+
160
+ /** Every FE canon rule is an error; see `sources/fe/index.mjs` for why there is no warn tier. */
161
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/the-split.mjs ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The rule that holds `the-split.md`.
3
+ *
4
+ * ONE RULE, AND IT WATCHES ONE HALF. The drawing half's purity is checkable: a file that fetches,
5
+ * reads a store or resolves a word is visible in what it calls. The connected half's discipline is
6
+ * not - a file that draws badly, or settles the wrong situation, looks exactly like one that does
7
+ * neither. So this rule guards the boundary from the side where a machine can see it, and the other
8
+ * side stays a matter for review.
9
+ *
10
+ * The scope is the filename. `component.tsx` is the drawing half by convention across every tier
11
+ * that splits, so the convention does the scoping and the rule needs no configuration.
12
+ */
13
+
14
+ /** Forward-slash form of a filename, so Windows paths compare like every other path. */
15
+ const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
16
+
17
+ /** The drawing half, named by the convention every split surface follows. */
18
+ const isDrawingHalf = (filename) => /(?:^|\/)component\.tsx$/.test(normalizePath(filename))
19
+
20
+ /** A connected block entrypoint and the public name its folder fixes. */
21
+ const connectedBlock = (filename) => {
22
+ const match = normalizePath(filename).match(
23
+ /\/src\/components\/blocks\/(?:[^/]+\/)*([A-Z][A-Za-z0-9]*)\/index\.tsx$/,
24
+ )
25
+ return match ? match[1] : null
26
+ }
27
+
28
+ /**
29
+ * Calls that reach for the world instead of receiving it.
30
+ *
31
+ * Four families: a request, a store, the translation runtime, and a direct query. Each is a
32
+ * dependency the drawing half cannot be rendered from a fixture without.
33
+ */
34
+ const REACHES_FOR_THE_WORLD =
35
+ /^(?:useSWR|useSWRMutation|use[A-Za-z0-9]*Swr|useAppSelector|useDispatch|use[A-Za-z0-9]*Store|useTranslations|useLocale|useFormatter|query[A-Z][A-Za-z0-9]*|mutation[A-Z][A-Za-z0-9]*)$/
36
+
37
+ // -- SPLIT-1 ---------------------------------------------------------------------------------------
38
+
39
+ /** The drawing half receives everything and asks for nothing. */
40
+ export const presentationalPurity = {
41
+ meta: {
42
+ type: "problem",
43
+ docs: { description: "The drawing half takes resolved props: no request, no store, no translation." },
44
+ schema: [],
45
+ messages: {
46
+ reaches:
47
+ "`{{name}}(...)` reaches for the world from the drawing half. This file must be renderable from a fixture - that is what makes it testable without standing the world up first, and it is the whole reason the split exists. Move the call to `index.tsx` and pass the result down as a settled situation.",
48
+ },
49
+ },
50
+ create(context) {
51
+ if (!isDrawingHalf(context.filename || context.getFilename())) return {}
52
+ return {
53
+ CallExpression(node) {
54
+ const callee = node.callee
55
+ if (!callee || callee.type !== "Identifier") return
56
+ if (!REACHES_FOR_THE_WORLD.test(callee.name)) return
57
+ context.report({ node, messageId: "reaches", data: { name: callee.name } })
58
+ },
59
+ }
60
+ },
61
+ }
62
+
63
+ // -- SPLIT-5 ---------------------------------------------------------------------------------------
64
+
65
+ /** A connected block delegates every render path to its exact pure twin. */
66
+ export const connectedBlockHasPresentationalTwin = {
67
+ meta: {
68
+ type: "problem",
69
+ docs: { description: "A connected block index renders only its exact `_X` twin from component.tsx." },
70
+ schema: [],
71
+ messages: {
72
+ missing:
73
+ "`{{block}}` reads the world, so it must import exact `{{twin}}` from `./component`. A single leaf, one tree, or no local state is not an exception.",
74
+ bypass:
75
+ "Connected `{{block}}` renders `<{{rendered}}>` directly. Resolve the world here, then render only `<{{twin}}>`; component.tsx owns the complete presentation.",
76
+ unused:
77
+ "Connected `{{block}}` imports `{{twin}}` but never renders it. Every connected render path must cross the pure twin.",
78
+ },
79
+ },
80
+ create(context) {
81
+ const block = connectedBlock(context.filename || context.getFilename())
82
+ if (!block) return {}
83
+
84
+ const twin = `_${block}`
85
+ const worldBindings = new Set()
86
+ const rendered = []
87
+ let importsTwin = false
88
+ let readsWorld = false
89
+ let rendersTwin = false
90
+
91
+ return {
92
+ ImportDeclaration(node) {
93
+ const source = typeof node.source?.value === "string" ? node.source.value : ""
94
+ for (const specifier of node.specifiers || []) {
95
+ const imported = specifier.imported?.name
96
+ const local = specifier.local?.name
97
+ if (source === "./component" && imported === twin && local === twin) importsTwin = true
98
+ if (imported && local && REACHES_FOR_THE_WORLD.test(imported)) worldBindings.add(local)
99
+ }
100
+ },
101
+ CallExpression(node) {
102
+ if (node.callee?.type !== "Identifier") return
103
+ if (worldBindings.has(node.callee.name) || REACHES_FOR_THE_WORLD.test(node.callee.name)) {
104
+ readsWorld = true
105
+ }
106
+ },
107
+ JSXOpeningElement(node) {
108
+ if (node.name?.type !== "JSXIdentifier") return
109
+ rendered.push({ name: node.name.name, node })
110
+ if (node.name.name === twin) rendersTwin = true
111
+ },
112
+ "Program:exit"(node) {
113
+ if (!readsWorld) return
114
+ if (!importsTwin) {
115
+ context.report({ node, messageId: "missing", data: { block, twin } })
116
+ return
117
+ }
118
+ for (const entry of rendered) {
119
+ if (entry.name !== twin) {
120
+ context.report({
121
+ node: entry.node,
122
+ messageId: "bypass",
123
+ data: { block, rendered: entry.name, twin },
124
+ })
125
+ }
126
+ }
127
+ if (!rendersTwin) context.report({ node, messageId: "unused", data: { block, twin } })
128
+ },
129
+ }
130
+ },
131
+ }
132
+
133
+ /** The rules this law contributes to the plugin. */
134
+ export const rules = {
135
+ "presentational-purity": presentationalPurity,
136
+ "connected-block-has-presentational-twin": connectedBlockHasPresentationalTwin,
137
+ }
138
+
139
+ /**
140
+ * The level this law asks for, as the plugin's own opinion.
141
+ *
142
+ * Exact: it fires on a call by name, in one file by name. A repository adopting it with history
143
+ * should expect each report to be a real move rather than a deletion - the call has to go
144
+ * somewhere, and where it goes is the other half.
145
+ */
146
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))