@starci/eslint-canon-fe 3.0.2 → 5.1.2
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/CHANGELOG.md +35 -0
- package/README.md +41 -16
- package/brand-values.mjs +174 -0
- package/class-names.mjs +1 -1
- package/client-boundary.mjs +199 -0
- package/comments.mjs +144 -190
- package/docs/brand-values.md +30 -0
- package/docs/client-boundary.md +99 -0
- package/docs/e2e-shape.md +161 -0
- package/docs/env-owner.md +52 -0
- package/docs/formatting.md +30 -0
- package/docs/hooks-folder.md +29 -0
- package/docs/hygiene.md +111 -0
- package/docs/lint-escape-hatch.md +32 -0
- package/docs/lists.md +74 -0
- package/docs/native-controls.md +73 -0
- package/docs/next-conventions.md +206 -0
- package/docs/size-and-state-budget.md +73 -0
- package/docs/spec-quality.md +139 -0
- package/docs/translation.md +50 -0
- package/docs/transport.md +168 -0
- package/docs/type-safety.md +95 -0
- package/e2e-shape.mjs +348 -0
- package/env-owner.mjs +134 -0
- package/file-layout.mjs +100 -7
- package/formatting.mjs +106 -0
- package/hooks-folder.mjs +119 -0
- package/hygiene.mjs +235 -0
- package/icon.mjs +210 -211
- package/index.mjs +292 -131
- package/lib/ast.mjs +94 -0
- package/lib/path.mjs +2 -0
- package/lib/scope.mjs +42 -0
- package/lib/why.mjs +304 -0
- package/lint-adoption.mjs +15 -52
- package/lint-escape-hatch.mjs +91 -56
- package/lists.mjs +197 -0
- package/loading.mjs +163 -164
- package/naming.mjs +143 -143
- package/native-controls.mjs +112 -0
- package/next-conventions.mjs +346 -0
- package/package.json +7 -3
- package/props-and-slots.mjs +4 -2
- package/served-locale.mjs +160 -160
- package/shape-slot.mjs +339 -0
- package/size-and-state-budget.mjs +196 -0
- package/spec-quality.mjs +239 -0
- package/the-split.mjs +133 -134
- package/tokens.mjs +267 -268
- package/translation.mjs +262 -122
- package/transport.mjs +404 -0
- package/type-safety.mjs +156 -74
- package/typography.mjs +82 -83
package/the-split.mjs
CHANGED
|
@@ -1,153 +1,152 @@
|
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (!callee
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
type: "problem",
|
|
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
|
+
import { normalizePath } from "./lib/path.mjs"
|
|
15
|
+
|
|
16
|
+
/** The drawing half, named by the convention every split surface follows. */
|
|
17
|
+
const isDrawingHalf = (filename) => /(?:^|\/)component\.tsx$/.test(normalizePath(filename))
|
|
18
|
+
|
|
19
|
+
/** A connected block entrypoint and the public name its folder fixes. */
|
|
20
|
+
const connectedBlock = (filename) => {
|
|
21
|
+
const match = normalizePath(filename).match(
|
|
22
|
+
/\/src\/components\/blocks\/(?:[^/]+\/)*([A-Z][A-Za-z0-9]*)\/index\.tsx$/,
|
|
23
|
+
)
|
|
24
|
+
return match ? match[1] : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Calls that reach for the world instead of receiving it.
|
|
29
|
+
*
|
|
30
|
+
* Four families: a request, a store, the translation runtime, and a direct query. Each is a
|
|
31
|
+
* dependency the drawing half cannot be rendered from a fixture without.
|
|
32
|
+
*/
|
|
33
|
+
const REACHES_FOR_THE_WORLD =
|
|
34
|
+
/^(?: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]*)$/
|
|
35
|
+
|
|
36
|
+
// -- SPLIT-1 ---------------------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/** The drawing half receives everything and asks for nothing. */
|
|
39
|
+
export const presentationalPurity = {
|
|
40
|
+
meta: {
|
|
41
|
+
type: "problem",
|
|
42
|
+
docs: { description: "The drawing half takes resolved props: no request, no store, no translation." },
|
|
43
|
+
schema: [],
|
|
44
|
+
messages: {
|
|
45
|
+
reaches:
|
|
46
|
+
"`{{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.",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
create(context) {
|
|
50
|
+
if (!isDrawingHalf(context.filename || context.getFilename())) return {}
|
|
51
|
+
return {
|
|
52
|
+
CallExpression(node) {
|
|
53
|
+
const callee = node.callee
|
|
54
|
+
if (!callee || callee.type !== "Identifier") return
|
|
55
|
+
if (!REACHES_FOR_THE_WORLD.test(callee.name)) return
|
|
56
|
+
context.report({ node, messageId: "reaches", data: { name: callee.name } })
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// -- SPLIT-5 ---------------------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** A connected block delegates every render path to its exact pure twin. */
|
|
65
|
+
export const connectedBlockHasPresentationalTwin = {
|
|
66
|
+
meta: {
|
|
67
|
+
type: "problem",
|
|
69
68
|
docs: { description: "A connected block index renders only its exact `XBase` 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.",
|
|
69
|
+
schema: [],
|
|
70
|
+
messages: {
|
|
71
|
+
missing:
|
|
72
|
+
"`{{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.",
|
|
73
|
+
bypass:
|
|
74
|
+
"Connected `{{block}}` renders `<{{rendered}}>` directly. Resolve the world here, then render only `<{{twin}}>`; component.tsx owns the complete presentation.",
|
|
76
75
|
unused:
|
|
77
76
|
"Connected `{{block}}` imports `{{twin}}` but never renders it. Every connected render path must cross the pure twin.",
|
|
78
77
|
called:
|
|
79
78
|
"Connected `{{block}}` calls `{{twin}}(...)` as an ordinary function. Render `<{{twin}} />` so React owns the twin's hook lifecycle and every render keeps one stable component boundary.",
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
create(context) {
|
|
83
|
-
const block = connectedBlock(context.filename || context.getFilename())
|
|
84
|
-
if (!block) return {}
|
|
85
|
-
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
create(context) {
|
|
82
|
+
const block = connectedBlock(context.filename || context.getFilename())
|
|
83
|
+
if (!block) return {}
|
|
84
|
+
|
|
86
85
|
const twin = `${block}Base`
|
|
87
86
|
const worldBindings = new Set()
|
|
88
87
|
const rendered = []
|
|
89
88
|
const calledTwin = []
|
|
90
|
-
let importsTwin = false
|
|
91
|
-
let readsWorld = false
|
|
92
|
-
let rendersTwin = false
|
|
93
|
-
|
|
94
|
-
return {
|
|
95
|
-
ImportDeclaration(node) {
|
|
96
|
-
const source = typeof node.source?.value === "string" ? node.source.value : ""
|
|
97
|
-
for (const specifier of node.specifiers || []) {
|
|
98
|
-
const imported = specifier.imported?.name
|
|
99
|
-
const local = specifier.local?.name
|
|
100
|
-
if (source === "./component" && imported === twin && local === twin) importsTwin = true
|
|
101
|
-
if (imported && local && REACHES_FOR_THE_WORLD.test(imported)) worldBindings.add(local)
|
|
102
|
-
}
|
|
103
|
-
},
|
|
89
|
+
let importsTwin = false
|
|
90
|
+
let readsWorld = false
|
|
91
|
+
let rendersTwin = false
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
ImportDeclaration(node) {
|
|
95
|
+
const source = typeof node.source?.value === "string" ? node.source.value : ""
|
|
96
|
+
for (const specifier of node.specifiers || []) {
|
|
97
|
+
const imported = specifier.imported?.name
|
|
98
|
+
const local = specifier.local?.name
|
|
99
|
+
if (source === "./component" && imported === twin && local === twin) importsTwin = true
|
|
100
|
+
if (imported && local && REACHES_FOR_THE_WORLD.test(imported)) worldBindings.add(local)
|
|
101
|
+
}
|
|
102
|
+
},
|
|
104
103
|
CallExpression(node) {
|
|
105
104
|
if (node.callee?.type !== "Identifier") return
|
|
106
105
|
if (node.callee.name === twin) calledTwin.push(node)
|
|
107
106
|
if (worldBindings.has(node.callee.name) || REACHES_FOR_THE_WORLD.test(node.callee.name)) {
|
|
108
|
-
readsWorld = true
|
|
109
|
-
}
|
|
110
|
-
},
|
|
111
|
-
JSXOpeningElement(node) {
|
|
112
|
-
if (node.name?.type !== "JSXIdentifier") return
|
|
113
|
-
rendered.push({ name: node.name.name, node })
|
|
114
|
-
if (node.name.name === twin) rendersTwin = true
|
|
115
|
-
},
|
|
116
|
-
"Program:exit"(node) {
|
|
117
|
-
if (!readsWorld) return
|
|
118
|
-
if (!importsTwin) {
|
|
119
|
-
context.report({ node, messageId: "missing", data: { block, twin } })
|
|
120
|
-
return
|
|
121
|
-
}
|
|
107
|
+
readsWorld = true
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
JSXOpeningElement(node) {
|
|
111
|
+
if (node.name?.type !== "JSXIdentifier") return
|
|
112
|
+
rendered.push({ name: node.name.name, node })
|
|
113
|
+
if (node.name.name === twin) rendersTwin = true
|
|
114
|
+
},
|
|
115
|
+
"Program:exit"(node) {
|
|
116
|
+
if (!readsWorld) return
|
|
117
|
+
if (!importsTwin) {
|
|
118
|
+
context.report({ node, messageId: "missing", data: { block, twin } })
|
|
119
|
+
return
|
|
120
|
+
}
|
|
122
121
|
for (const entry of rendered) {
|
|
123
|
-
if (entry.name !== twin) {
|
|
124
|
-
context.report({
|
|
125
|
-
node: entry.node,
|
|
126
|
-
messageId: "bypass",
|
|
127
|
-
data: { block, rendered: entry.name, twin },
|
|
128
|
-
})
|
|
129
|
-
}
|
|
122
|
+
if (entry.name !== twin) {
|
|
123
|
+
context.report({
|
|
124
|
+
node: entry.node,
|
|
125
|
+
messageId: "bypass",
|
|
126
|
+
data: { block, rendered: entry.name, twin },
|
|
127
|
+
})
|
|
128
|
+
}
|
|
130
129
|
}
|
|
131
130
|
for (const call of calledTwin) {
|
|
132
131
|
context.report({ node: call, messageId: "called", data: { block, twin } })
|
|
133
132
|
}
|
|
134
133
|
if (!rendersTwin) context.report({ node, messageId: "unused", data: { block, twin } })
|
|
135
|
-
},
|
|
136
|
-
}
|
|
137
|
-
},
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** The rules this law contributes to the plugin. */
|
|
141
|
-
export const rules = {
|
|
142
|
-
"presentational-purity": presentationalPurity,
|
|
143
|
-
"connected-block-has-presentational-twin": connectedBlockHasPresentationalTwin,
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* The level this law asks for, as the plugin's own opinion.
|
|
148
|
-
*
|
|
149
|
-
* Exact: it fires on a call by name, in one file by name. A repository adopting it with history
|
|
150
|
-
* should expect each report to be a real move rather than a deletion - the call has to go
|
|
151
|
-
* somewhere, and where it goes is the other half.
|
|
152
|
-
*/
|
|
153
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The rules this law contributes to the plugin. */
|
|
140
|
+
export const rules = {
|
|
141
|
+
"presentational-purity": presentationalPurity,
|
|
142
|
+
"connected-block-has-presentational-twin": connectedBlockHasPresentationalTwin,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
147
|
+
*
|
|
148
|
+
* Exact: it fires on a call by name, in one file by name. A repository adopting it with history
|
|
149
|
+
* should expect each report to be a real move rather than a deletion - the call has to go
|
|
150
|
+
* somewhere, and where it goes is the other half.
|
|
151
|
+
*/
|
|
152
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|