@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
|
@@ -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"]))
|
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"]))
|