@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.
- package/comments.mjs +190 -0
- package/contract.mjs +1199 -0
- package/contracts.ts +312 -0
- package/file-layout.mjs +310 -0
- package/icon.mjs +309 -0
- package/index.mjs +118 -0
- package/landmark.mjs +242 -0
- package/lint-adoption.mjs +116 -0
- package/lint-escape-hatch.mjs +56 -0
- package/loading.mjs +164 -0
- package/naming.mjs +150 -0
- package/package.json +38 -0
- package/props-and-slots.mjs +188 -0
- package/props.ts +145 -0
- package/served-locale.mjs +161 -0
- package/the-split.mjs +146 -0
- package/tokens.mjs +268 -0
- package/translation.mjs +122 -0
- package/type-safety.mjs +74 -0
- package/typography.mjs +83 -0
- package/vendor-boundary.mjs +526 -0
|
@@ -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"]))
|