@starci/eslint-canon-fe 4.0.0 → 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 +34 -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 +1 -2
- package/formatting.mjs +106 -0
- package/hooks-folder.mjs +119 -0
- package/hygiene.mjs +235 -0
- package/icon.mjs +210 -211
- package/index.mjs +290 -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 +8 -4
- package/served-locale.mjs +160 -160
- package/shape-slot.mjs +1 -2
- 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/spec-quality.mjs
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `spec-quality.md` (HFS R67 `FE_SPEC_QUALITY`, and the "specs use the real
|
|
3
|
+
* catalogue" half of R60).
|
|
4
|
+
*
|
|
5
|
+
* A SPEC IS A CLAIM ABOUT ONE UNIT'S BEHAVIOUR. The defects these rules refuse are the ones that made
|
|
6
|
+
* specs pass while the product was wrong, or fail while it was right:
|
|
7
|
+
*
|
|
8
|
+
* - pinning a class string: the spec fails when a designer changes a token, and passes when the
|
|
9
|
+
* element that carries it is gone;
|
|
10
|
+
* - testing something other than the neighbour: a spec that imports a different unit is that
|
|
11
|
+
* unit's spec in the wrong folder, and its owner will not find it;
|
|
12
|
+
* - a spec for a barrel: there is no behaviour in `export * from`, so the spec asserts that the
|
|
13
|
+
* language works;
|
|
14
|
+
* - a double cast (`as unknown as T`) to build a value the types forbid: the spec then proves the
|
|
15
|
+
* unit against a shape it can never receive;
|
|
16
|
+
* - a connected screen with no accessibility assertion: the state a reader with a screen reader
|
|
17
|
+
* meets is the one nobody looked at;
|
|
18
|
+
* - a mocked `next-intl`: the spec then passes on the KEY, so a missing or misspelled catalogue
|
|
19
|
+
* entry is invisible until a reader sees `course.titel`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { baseName, isSpecFile } from "./lib/scope.mjs"
|
|
23
|
+
import { normalizePath } from "./lib/path.mjs"
|
|
24
|
+
|
|
25
|
+
/** The subject a spec sits beside: `Feed.test.tsx` -> `Feed`. */
|
|
26
|
+
const subjectOf = (file) => baseName(file).replace(/\.(?:test|spec)\.[cm]?tsx?$/, "")
|
|
27
|
+
|
|
28
|
+
/** The name a call is made through: `toHaveClass` in `expect(x).toHaveClass(...)`, or `axe` in `axe(x)`. */
|
|
29
|
+
const calleeName = (callee) => {
|
|
30
|
+
if (callee.type === "Identifier") return callee.name
|
|
31
|
+
if (callee.type === "MemberExpression" && !callee.computed) return callee.property.name
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Static string of a literal or an expression-free template, else null. */
|
|
36
|
+
const staticString = (node) => {
|
|
37
|
+
if (!node) return null
|
|
38
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value
|
|
39
|
+
if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value.cooked ?? ""
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// -- SPEC-1 ----------------------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** A spec does not pin class strings. */
|
|
46
|
+
export const noClassStringInSpec = {
|
|
47
|
+
meta: {
|
|
48
|
+
type: "problem",
|
|
49
|
+
docs: { description: "A component spec asserts behaviour, never a class string." },
|
|
50
|
+
schema: [],
|
|
51
|
+
messages: {
|
|
52
|
+
pinned:
|
|
53
|
+
"This spec pins a class string. It fails when a designer changes a token and passes when the element that carried it is gone - it tests the stylesheet, not the unit. Assert what the reader gets: a role, a name, a state, or the text.",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
create(context) {
|
|
57
|
+
if (!isSpecFile(context.filename || context.getFilename())) return {}
|
|
58
|
+
return {
|
|
59
|
+
CallExpression(node) {
|
|
60
|
+
const name = calleeName(node.callee)
|
|
61
|
+
if (name === "toHaveClass") return context.report({ node, messageId: "pinned" })
|
|
62
|
+
if ((name === "toHaveAttribute" || name === "getAttribute") && staticString(node.arguments[0]) === "class") {
|
|
63
|
+
return context.report({ node, messageId: "pinned" })
|
|
64
|
+
}
|
|
65
|
+
if (name === "querySelector" || name === "querySelectorAll") {
|
|
66
|
+
const selector = staticString(node.arguments[0])
|
|
67
|
+
if (selector !== null && /(?:^|[\s>+~,])\.[\w-]|\[class/.test(selector)) context.report({ node, messageId: "pinned" })
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
MemberExpression(node) {
|
|
71
|
+
if (node.computed || !["className", "classList"].includes(node.property.name)) return
|
|
72
|
+
context.report({ node, messageId: "pinned" })
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// -- SPEC-2 ----------------------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/** A spec imports the unit beside it. */
|
|
81
|
+
export const specTestsItsNeighbour = {
|
|
82
|
+
meta: {
|
|
83
|
+
type: "problem",
|
|
84
|
+
docs: { description: "A spec imports its sibling subject: `X.test.tsx` imports `./X`." },
|
|
85
|
+
schema: [],
|
|
86
|
+
messages: {
|
|
87
|
+
subject:
|
|
88
|
+
"This spec does not import its neighbour `./{{subject}}`. A spec exercises the unit beside it; one that imports something else is that unit's spec in the wrong folder, and its owner will not find it. Move it beside its subject, or test the neighbour.",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
create(context) {
|
|
92
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
93
|
+
if (!isSpecFile(file)) return {}
|
|
94
|
+
const subject = subjectOf(file)
|
|
95
|
+
const accepted = new Set([`./${subject}`, `./${subject}.tsx`, `./${subject}.ts`, `./${subject}.js`, `./${subject}.jsx`])
|
|
96
|
+
if (subject === "index") ["./", ".", "./index"].forEach((entry) => accepted.add(entry))
|
|
97
|
+
let imported = false
|
|
98
|
+
return {
|
|
99
|
+
ImportDeclaration(node) {
|
|
100
|
+
if (accepted.has(String(node.source.value))) imported = true
|
|
101
|
+
},
|
|
102
|
+
CallExpression(node) {
|
|
103
|
+
// `await import("./X")` and `require("./X")` are also an import of the subject.
|
|
104
|
+
const isImport = node.callee.type === "Import" || (node.callee.type === "Identifier" && node.callee.name === "require")
|
|
105
|
+
if (isImport && accepted.has(staticString(node.arguments[0]))) imported = true
|
|
106
|
+
},
|
|
107
|
+
ImportExpression(node) {
|
|
108
|
+
if (accepted.has(staticString(node.source))) imported = true
|
|
109
|
+
},
|
|
110
|
+
"Program:exit"(program) {
|
|
111
|
+
if (!imported) context.report({ node: program, messageId: "subject", data: { subject } })
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// -- SPEC-3 ----------------------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/** No spec for a barrel. */
|
|
120
|
+
export const noBarrelSpec = {
|
|
121
|
+
meta: {
|
|
122
|
+
type: "problem",
|
|
123
|
+
docs: { description: "No `index.test.ts` beside an `index.ts` barrel." },
|
|
124
|
+
schema: [],
|
|
125
|
+
messages: {
|
|
126
|
+
barrel:
|
|
127
|
+
"A spec for a barrel. `index.ts` holds re-exports, which have no behaviour; a spec beside one asserts that the language works. Test the units the barrel re-exports, beside each of them.",
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
create(context) {
|
|
131
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
132
|
+
if (!/(?:^|\/)index\.(?:test|spec)\.[cm]?ts$/.test(file)) return {}
|
|
133
|
+
return { Program: (node) => context.report({ node, messageId: "barrel" }) }
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// -- SPEC-4 ----------------------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/** No `as unknown as T` in a spec. */
|
|
140
|
+
export const noDoubleCastInSpec = {
|
|
141
|
+
meta: {
|
|
142
|
+
type: "problem",
|
|
143
|
+
docs: { description: "A spec builds its values with the type's own shape, not `as unknown as`." },
|
|
144
|
+
schema: [],
|
|
145
|
+
messages: {
|
|
146
|
+
double:
|
|
147
|
+
"A double cast in a spec. It builds a value the types forbid, so the spec proves the unit against a shape it can never receive. Build the value with a typed factory (`mock<T>()` or a fixture builder), or narrow from `unknown` with a check.",
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
create(context) {
|
|
151
|
+
if (!isSpecFile(context.filename || context.getFilename())) return {}
|
|
152
|
+
return {
|
|
153
|
+
TSAsExpression(node) {
|
|
154
|
+
const inner = node.expression
|
|
155
|
+
if (inner && inner.type === "TSAsExpression" && inner.typeAnnotation.type === "TSUnknownKeyword") {
|
|
156
|
+
context.report({ node, messageId: "double" })
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// -- SPEC-5 ----------------------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/** The specs of connected screens: a block's `index` and a feature's `index`. */
|
|
166
|
+
const isConnectedSpec = (file) =>
|
|
167
|
+
/(?:^|\/)index\.(?:test|spec)\.[cm]?tsx$/.test(file) &&
|
|
168
|
+
(/\/components\/blocks\//.test(file) || /\/features\/(?:pages|layouts|overlays)\//.test(file))
|
|
169
|
+
|
|
170
|
+
/** Names that run an accessibility check. */
|
|
171
|
+
const AXE_CALLS = new Set(["axe", "runAxe", "expectNoAxeViolations", "expectNoA11yViolations", "toHaveNoViolations"])
|
|
172
|
+
|
|
173
|
+
/** A connected screen's spec asserts accessibility. */
|
|
174
|
+
export const connectedSpecHasAxe = {
|
|
175
|
+
meta: {
|
|
176
|
+
type: "problem",
|
|
177
|
+
docs: { description: "The spec of a connected block or feature runs an axe assertion." },
|
|
178
|
+
schema: [],
|
|
179
|
+
messages: {
|
|
180
|
+
axe:
|
|
181
|
+
"This spec covers a connected screen but runs no accessibility assertion. The state a reader with a screen reader meets is the one nobody looked at; add `expect(await axe(container)).toHaveNoViolations()`.",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
create(context) {
|
|
185
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
186
|
+
if (!isConnectedSpec(file)) return {}
|
|
187
|
+
let ran = false
|
|
188
|
+
return {
|
|
189
|
+
CallExpression(node) {
|
|
190
|
+
const name = calleeName(node.callee)
|
|
191
|
+
if (name && AXE_CALLS.has(name)) ran = true
|
|
192
|
+
},
|
|
193
|
+
"Program:exit"(program) {
|
|
194
|
+
if (!ran) context.report({ node: program, messageId: "axe" })
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// -- SPEC-6 ----------------------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/** No mock of the translation runtime. */
|
|
203
|
+
export const noMockedTranslations = {
|
|
204
|
+
meta: {
|
|
205
|
+
type: "problem",
|
|
206
|
+
docs: { description: "A spec renders with the real catalogue; it never mocks `next-intl`." },
|
|
207
|
+
schema: [],
|
|
208
|
+
messages: {
|
|
209
|
+
mocked:
|
|
210
|
+
"A mocked `next-intl`. The spec then passes on the KEY, so a missing or misspelled catalogue entry is invisible until a reader sees `course.titel`. Render inside `NextIntlClientProvider` with the real `messages/<locale>.json`.",
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
create(context) {
|
|
214
|
+
if (!isSpecFile(context.filename || context.getFilename())) return {}
|
|
215
|
+
return {
|
|
216
|
+
CallExpression(node) {
|
|
217
|
+
const callee = node.callee
|
|
218
|
+
if (callee.type !== "MemberExpression" || callee.computed) return
|
|
219
|
+
if (!["mock", "doMock", "unstable_mockModule"].includes(callee.property.name)) return
|
|
220
|
+
if (!["vi", "jest"].includes(callee.object.name)) return
|
|
221
|
+
const target = staticString(node.arguments[0])
|
|
222
|
+
if (target !== null && /^next-intl(?:\/|$)/.test(target)) context.report({ node, messageId: "mocked" })
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The rules this law contributes to the plugin. */
|
|
229
|
+
export const rules = {
|
|
230
|
+
"no-class-string-in-spec": noClassStringInSpec,
|
|
231
|
+
"spec-tests-its-neighbour": specTestsItsNeighbour,
|
|
232
|
+
"no-barrel-spec": noBarrelSpec,
|
|
233
|
+
"no-double-cast-in-spec": noDoubleCastInSpec,
|
|
234
|
+
"connected-spec-has-axe": connectedSpecHasAxe,
|
|
235
|
+
"no-mocked-translations": noMockedTranslations,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Every rule is an error. */
|
|
239
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
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"]))
|