@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +34 -16
  3. package/brand-values.mjs +174 -0
  4. package/class-names.mjs +1 -1
  5. package/client-boundary.mjs +199 -0
  6. package/comments.mjs +144 -190
  7. package/docs/brand-values.md +30 -0
  8. package/docs/client-boundary.md +99 -0
  9. package/docs/e2e-shape.md +161 -0
  10. package/docs/env-owner.md +52 -0
  11. package/docs/formatting.md +30 -0
  12. package/docs/hooks-folder.md +29 -0
  13. package/docs/hygiene.md +111 -0
  14. package/docs/lint-escape-hatch.md +32 -0
  15. package/docs/lists.md +74 -0
  16. package/docs/native-controls.md +73 -0
  17. package/docs/next-conventions.md +206 -0
  18. package/docs/size-and-state-budget.md +73 -0
  19. package/docs/spec-quality.md +139 -0
  20. package/docs/translation.md +50 -0
  21. package/docs/transport.md +168 -0
  22. package/docs/type-safety.md +95 -0
  23. package/e2e-shape.mjs +348 -0
  24. package/env-owner.mjs +134 -0
  25. package/file-layout.mjs +1 -2
  26. package/formatting.mjs +106 -0
  27. package/hooks-folder.mjs +119 -0
  28. package/hygiene.mjs +235 -0
  29. package/icon.mjs +210 -211
  30. package/index.mjs +290 -131
  31. package/lib/ast.mjs +94 -0
  32. package/lib/path.mjs +2 -0
  33. package/lib/scope.mjs +42 -0
  34. package/lib/why.mjs +304 -0
  35. package/lint-adoption.mjs +15 -52
  36. package/lint-escape-hatch.mjs +91 -56
  37. package/lists.mjs +197 -0
  38. package/loading.mjs +163 -164
  39. package/naming.mjs +143 -143
  40. package/native-controls.mjs +112 -0
  41. package/next-conventions.mjs +346 -0
  42. package/package.json +8 -4
  43. package/served-locale.mjs +160 -160
  44. package/shape-slot.mjs +1 -2
  45. package/size-and-state-budget.mjs +196 -0
  46. package/spec-quality.mjs +239 -0
  47. package/the-split.mjs +133 -134
  48. package/tokens.mjs +267 -268
  49. package/translation.mjs +262 -122
  50. package/transport.mjs +404 -0
  51. package/type-safety.mjs +156 -74
  52. package/typography.mjs +82 -83
@@ -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
- /** 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",
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"]))