@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +41 -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 +100 -7
  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 +292 -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 +7 -3
  43. package/props-and-slots.mjs +4 -2
  44. package/served-locale.mjs +160 -160
  45. package/shape-slot.mjs +339 -0
  46. package/size-and-state-budget.mjs +196 -0
  47. package/spec-quality.mjs +239 -0
  48. package/the-split.mjs +133 -134
  49. package/tokens.mjs +267 -268
  50. package/translation.mjs +262 -122
  51. package/transport.mjs +404 -0
  52. package/type-safety.mjs +156 -74
  53. package/typography.mjs +82 -83
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The rules that hold `size-and-state-budget.md` (HFS R65, `FE_SIZE_AND_STATE_BUDGET`).
3
+ *
4
+ * A BUDGET IS A SMELL DETECTOR, NOT A STYLE. A component past 300 lines, or one that holds more than
5
+ * six pieces of state or reads six data hooks, is doing the work of several units: one repository's
6
+ * status-flow component held 20 `useState`s and three data sources in one 900-line file, and no
7
+ * reviewer could say what it did. The numbers are the same for every app so that "too big" is a
8
+ * measurement, not an opinion.
9
+ *
10
+ * WHAT THE LINT HOLDS, AND WHAT IT LEAVES. Absolute limits per file and per component are file-local
11
+ * facts and are held here. "Size may not GROW against the parent commit" (`HFS_SIZE_GROWTH`) is a
12
+ * repository fact needing the previous revision, so it belongs to the architecture machine; the two
13
+ * are complementary, not duplicates - this catches the new giant, that catches the old one growing.
14
+ *
15
+ * POLLING. A hand-rolled `setInterval` or self-scheduling `setTimeout` is a second refresh mechanism
16
+ * beside the data hook's own, and two mechanisms per resource means double requests and a spinner
17
+ * that never settles. Each resource refreshes by exactly one mechanism (a SWR `refreshInterval`, or a
18
+ * socket), so the loop in a component is always the wrong one.
19
+ */
20
+
21
+ import { isSpecFile } from "./lib/scope.mjs"
22
+ import { normalizePath } from "./lib/path.mjs"
23
+
24
+ /** The largest a component file may be. */
25
+ export const MAX_COMPONENT_LINES = 300
26
+
27
+ /** The most `useState` calls one connected unit may hold. */
28
+ export const MAX_STATE_HOOKS = 6
29
+
30
+ /** The most data hooks one connected unit may read. */
31
+ export const MAX_DATA_HOOKS = 6
32
+
33
+ /** A hook that reads remote data. */
34
+ const DATA_HOOK = /^use\w*(?:Swr|SWR|Query|Mutation|Fetch|Infinite|Subscription)\w*$/
35
+
36
+ /** A state hook. */
37
+ const STATE_HOOK = /^(?:useState|useReducer)$/
38
+
39
+ /** The name a call is made through: `useState` or `React.useState`. */
40
+ const calleeName = (callee) => {
41
+ if (callee.type === "Identifier") return callee.name
42
+ if (callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && callee.object.name === "React") {
43
+ return callee.property.name
44
+ }
45
+ return null
46
+ }
47
+
48
+ /** The name a function is bound to, when it has one. */
49
+ const functionName = (node) => {
50
+ if (node.id) return node.id.name
51
+ const parent = node.parent
52
+ if (parent && parent.type === "VariableDeclarator" && parent.id.type === "Identifier") return parent.id.name
53
+ return null
54
+ }
55
+
56
+ /** A component or hook, by the naming convention React itself relies on. */
57
+ const isUnitName = (name) => Boolean(name) && (/^[A-Z]/.test(name) || /^use[A-Z0-9]/.test(name))
58
+
59
+ // -- BUDGET-1 --------------------------------------------------------------------------------------
60
+
61
+ /** A component file is at most 300 lines. */
62
+ export const componentLineBudget = {
63
+ meta: {
64
+ type: "suggestion",
65
+ docs: { description: `A component file has at most ${MAX_COMPONENT_LINES} lines.` },
66
+ schema: [],
67
+ messages: {
68
+ lines:
69
+ "This component file has {{count}} lines; the budget is {{max}}. A file this size is several units in one: split the drawing from the data, and the sections from each other, so each part can be named, tested and reviewed on its own.",
70
+ },
71
+ },
72
+ create(context) {
73
+ const filename = normalizePath(context.filename || context.getFilename())
74
+ if (!filename.endsWith(".tsx") || isSpecFile(filename) || !filename.includes("/src/")) return {}
75
+ const source = context.sourceCode || context.getSourceCode()
76
+ return {
77
+ Program(node) {
78
+ // A trailing newline is not a line of code.
79
+ const count = source.lines.length - (source.lines[source.lines.length - 1] === "" ? 1 : 0)
80
+ if (count > MAX_COMPONENT_LINES) context.report({ node, messageId: "lines", data: { count, max: MAX_COMPONENT_LINES } })
81
+ },
82
+ }
83
+ },
84
+ }
85
+
86
+ // -- BUDGET-2 --------------------------------------------------------------------------------------
87
+
88
+ /** A connected unit holds at most six state hooks and reads at most six data hooks. */
89
+ export const unitHookBudget = {
90
+ meta: {
91
+ type: "suggestion",
92
+ docs: { description: `A unit has at most ${MAX_STATE_HOOKS} state hooks and ${MAX_DATA_HOOKS} data hooks.` },
93
+ schema: [],
94
+ messages: {
95
+ state:
96
+ "`{{name}}` holds {{count}} state hooks; the budget is {{max}}. That much local state is several concerns tangled together: group what changes together into a reducer or a hook of its own, and derive the rest instead of storing it.",
97
+ data:
98
+ "`{{name}}` reads {{count}} data hooks; the budget is {{max}}. A unit that gathers this many resources is a page pretending to be a block: give each slot its own connected block.",
99
+ },
100
+ },
101
+ create(context) {
102
+ if (isSpecFile(context.filename || context.getFilename())) return {}
103
+ /** One frame per function being walked; only a component or hook frame accumulates. */
104
+ const frames = []
105
+ const enter = (node) => frames.push({ node, name: functionName(node), state: 0, data: 0 })
106
+ const exit = () => {
107
+ const frame = frames.pop()
108
+ if (!frame || !isUnitName(frame.name)) return
109
+ const at = frame.node.id || frame.node
110
+ if (frame.state > MAX_STATE_HOOKS) {
111
+ context.report({ node: at, messageId: "state", data: { name: frame.name, count: frame.state, max: MAX_STATE_HOOKS } })
112
+ }
113
+ if (frame.data > MAX_DATA_HOOKS) {
114
+ context.report({ node: at, messageId: "data", data: { name: frame.name, count: frame.data, max: MAX_DATA_HOOKS } })
115
+ }
116
+ }
117
+ return {
118
+ FunctionDeclaration: enter,
119
+ FunctionExpression: enter,
120
+ ArrowFunctionExpression: enter,
121
+ "FunctionDeclaration:exit": exit,
122
+ "FunctionExpression:exit": exit,
123
+ "ArrowFunctionExpression:exit": exit,
124
+ CallExpression(node) {
125
+ const name = calleeName(node.callee)
126
+ if (!name) return
127
+ // Attribute the call to the nearest enclosing component or hook.
128
+ const owner = [...frames].reverse().find((frame) => isUnitName(frame.name))
129
+ if (!owner) return
130
+ if (STATE_HOOK.test(name)) owner.state += 1
131
+ else if (DATA_HOOK.test(name)) owner.data += 1
132
+ },
133
+ }
134
+ },
135
+ }
136
+
137
+ // -- BUDGET-3 --------------------------------------------------------------------------------------
138
+
139
+ /** `setInterval`, or `window.setInterval`. */
140
+ const isIntervalCall = (node) => {
141
+ const callee = node.callee
142
+ if (callee.type === "Identifier") return callee.name === "setInterval"
143
+ return (
144
+ callee.type === "MemberExpression" &&
145
+ !callee.computed &&
146
+ callee.property.name === "setInterval" &&
147
+ callee.object.type === "Identifier" &&
148
+ ["window", "globalThis", "self"].includes(callee.object.name)
149
+ )
150
+ }
151
+
152
+ /** No hand-written polling loop in a component or hook. */
153
+ export const noHandRolledPolling = {
154
+ meta: {
155
+ type: "problem",
156
+ docs: { description: "No `setInterval` and no self-scheduling `setTimeout`: one refresh mechanism per resource." },
157
+ schema: [],
158
+ messages: {
159
+ interval:
160
+ "A `setInterval` loop. This is a second refresh mechanism beside the data hook's own; two mechanisms per resource means double requests and a spinner that never settles. Refresh through the data hook (`refreshInterval`) or a socket, not a timer in the component.",
161
+ timeout:
162
+ "A `setTimeout` that schedules its own next run is a polling loop written by hand. Refresh through the data hook (`refreshInterval`) or a socket.",
163
+ },
164
+ },
165
+ create(context) {
166
+ if (isSpecFile(context.filename || context.getFilename())) return {}
167
+ const source = context.sourceCode || context.getSourceCode()
168
+ return {
169
+ CallExpression(node) {
170
+ if (isIntervalCall(node)) return context.report({ node, messageId: "interval" })
171
+ const callee = node.callee
172
+ if (callee.type !== "Identifier" || callee.name !== "setTimeout") return
173
+ const callback = node.arguments[0]
174
+ if (!callback || (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression")) return
175
+ // The enclosing named function: does the timer's own callback call it again?
176
+ const owner = source.getAncestors(node).reverse().find((ancestor) =>
177
+ ["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"].includes(ancestor.type) && functionName(ancestor),
178
+ )
179
+ const name = owner ? functionName(owner) : null
180
+ if (!name) return
181
+ const calls = new RegExp(`\\b${name}\\s*\\(`).test(source.getText(callback))
182
+ if (calls) context.report({ node, messageId: "timeout" })
183
+ },
184
+ }
185
+ },
186
+ }
187
+
188
+ /** The rules this law contributes to the plugin. */
189
+ export const rules = {
190
+ "component-line-budget": componentLineBudget,
191
+ "unit-hook-budget": unitHookBudget,
192
+ "no-hand-rolled-polling": noHandRolledPolling,
193
+ }
194
+
195
+ /** Every rule is an error: the budget is the same for every app, and there is no baseline. */
196
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
@@ -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"]))