@starci/eslint-canon-fe 6.0.2 → 7.0.0
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 +12 -0
- package/README.md +3 -5
- package/client-boundary.mjs +2 -5
- package/comments.mjs +38 -15
- package/docs/brand-values.md +2 -2
- package/docs/client-boundary.md +10 -10
- package/docs/env-owner.md +4 -4
- package/docs/formatting.md +2 -2
- package/docs/grammar-boundary.md +2 -2
- package/docs/hooks-folder.md +2 -2
- package/docs/hygiene.md +8 -8
- package/docs/lint-escape-hatch.md +2 -2
- package/docs/lists.md +6 -6
- package/docs/native-controls.md +6 -6
- package/docs/next-conventions.md +20 -20
- package/docs/size-and-state-budget.md +6 -6
- package/docs/size-growth.md +2 -2
- package/docs/status-colors.md +2 -2
- package/docs/translation.md +2 -2
- package/docs/transport.md +16 -16
- package/docs/type-safety.md +8 -8
- package/env-owner.mjs +2 -3
- package/file-layout.mjs +3 -43
- package/formatting.mjs +2 -2
- package/grammar-boundary.mjs +1 -2
- package/hooks-folder.mjs +6 -7
- package/index.mjs +3 -27
- package/lib/config.mjs +6 -22
- package/lib/scope.mjs +3 -12
- package/lib/why.mjs +0 -65
- package/lint-escape-hatch.mjs +3 -3
- package/lists.mjs +0 -4
- package/loading.mjs +2 -2
- package/native-controls.mjs +0 -4
- package/next-conventions.mjs +3 -7
- package/package.json +1 -1
- package/props-and-slots.mjs +4 -5
- package/runtime/knowledge/hfs/slots.yaml +145 -107
- package/runtime/scripts/lib/hfs-allows.mjs +15 -0
- package/runtime/scripts/lib/hfs-slots.mjs +13 -6
- package/runtime/scripts/lib/language.mjs +107 -0
- package/served-locale.mjs +2 -2
- package/shape-slot.mjs +2 -3
- package/size-and-state-budget.mjs +1 -4
- package/size-growth.mjs +1 -2
- package/status-colors.mjs +0 -2
- package/tokens.mjs +1 -1
- package/translation.mjs +1 -29
- package/transport.mjs +4 -8
- package/docs/e2e-shape.md +0 -161
- package/docs/spec-quality.md +0 -147
- package/e2e-shape.mjs +0 -345
- package/spec-quality.mjs +0 -318
package/spec-quality.mjs
DELETED
|
@@ -1,318 +0,0 @@
|
|
|
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 { existsSync, readFileSync } from "node:fs"
|
|
23
|
-
import { dirname, join } from "node:path"
|
|
24
|
-
import ts from "typescript"
|
|
25
|
-
import { hfsOf } from "./lib/hfs.mjs"
|
|
26
|
-
import { baseName, isSpecFile, resolveImport } from "./lib/scope.mjs"
|
|
27
|
-
import { normalizePath } from "./lib/path.mjs"
|
|
28
|
-
|
|
29
|
-
/** The subject a spec sits beside: `Feed.test.tsx` -> `Feed`. */
|
|
30
|
-
const subjectOf = (file) => baseName(file).replace(/\.(?:test|spec)\.[cm]?tsx?$/, "")
|
|
31
|
-
|
|
32
|
-
/** The name a call is made through: `toHaveClass` in `expect(x).toHaveClass(...)`, or `axe` in `axe(x)`. */
|
|
33
|
-
const calleeName = (callee) => {
|
|
34
|
-
if (callee.type === "Identifier") return callee.name
|
|
35
|
-
if (callee.type === "MemberExpression" && !callee.computed) return callee.property.name
|
|
36
|
-
return null
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Static string of a literal or an expression-free template, else null. */
|
|
40
|
-
const staticString = (node) => {
|
|
41
|
-
if (!node) return null
|
|
42
|
-
if (node.type === "Literal" && typeof node.value === "string") return node.value
|
|
43
|
-
if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value.cooked ?? ""
|
|
44
|
-
return null
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// -- SPEC-1 ----------------------------------------------------------------------------------------
|
|
48
|
-
|
|
49
|
-
/** A spec does not pin class strings. */
|
|
50
|
-
export const noClassStringInSpec = {
|
|
51
|
-
meta: {
|
|
52
|
-
type: "problem",
|
|
53
|
-
docs: { description: "A component spec asserts behaviour, never a class string." },
|
|
54
|
-
schema: [],
|
|
55
|
-
messages: {
|
|
56
|
-
pinned:
|
|
57
|
-
"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.",
|
|
58
|
-
},
|
|
59
|
-
},
|
|
60
|
-
create(context) {
|
|
61
|
-
if (!isSpecFile(context.filename || context.getFilename())) return {}
|
|
62
|
-
return {
|
|
63
|
-
CallExpression(node) {
|
|
64
|
-
const name = calleeName(node.callee)
|
|
65
|
-
if (name === "toHaveClass") return context.report({ node, messageId: "pinned" })
|
|
66
|
-
if ((name === "toHaveAttribute" || name === "getAttribute") && staticString(node.arguments[0]) === "class") {
|
|
67
|
-
return context.report({ node, messageId: "pinned" })
|
|
68
|
-
}
|
|
69
|
-
if (name === "querySelector" || name === "querySelectorAll") {
|
|
70
|
-
const selector = staticString(node.arguments[0])
|
|
71
|
-
if (selector !== null && /(?:^|[\s>+~,])\.[\w-]|\[class/.test(selector)) context.report({ node, messageId: "pinned" })
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
MemberExpression(node) {
|
|
75
|
-
if (node.computed || !["className", "classList"].includes(node.property.name)) return
|
|
76
|
-
context.report({ node, messageId: "pinned" })
|
|
77
|
-
},
|
|
78
|
-
}
|
|
79
|
-
},
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// -- SPEC-2 ----------------------------------------------------------------------------------------
|
|
83
|
-
|
|
84
|
-
/** A spec imports the unit beside it. */
|
|
85
|
-
export const specTestsItsNeighbour = {
|
|
86
|
-
meta: {
|
|
87
|
-
type: "problem",
|
|
88
|
-
docs: { description: "A spec imports its sibling subject: `X.test.tsx` imports `./X`." },
|
|
89
|
-
schema: [],
|
|
90
|
-
messages: {
|
|
91
|
-
subject:
|
|
92
|
-
"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.",
|
|
93
|
-
},
|
|
94
|
-
},
|
|
95
|
-
create(context) {
|
|
96
|
-
const file = normalizePath(context.filename || context.getFilename())
|
|
97
|
-
if (!isSpecFile(file)) return {}
|
|
98
|
-
const subject = subjectOf(file)
|
|
99
|
-
const accepted = new Set([`./${subject}`, `./${subject}.tsx`, `./${subject}.ts`, `./${subject}.js`, `./${subject}.jsx`])
|
|
100
|
-
if (subject === "index") ["./", ".", "./index"].forEach((entry) => accepted.add(entry))
|
|
101
|
-
let imported = false
|
|
102
|
-
return {
|
|
103
|
-
ImportDeclaration(node) {
|
|
104
|
-
if (accepted.has(String(node.source.value))) imported = true
|
|
105
|
-
},
|
|
106
|
-
CallExpression(node) {
|
|
107
|
-
// `await import("./X")` and `require("./X")` are also an import of the subject.
|
|
108
|
-
const isImport = node.callee.type === "Import" || (node.callee.type === "Identifier" && node.callee.name === "require")
|
|
109
|
-
if (isImport && accepted.has(staticString(node.arguments[0]))) imported = true
|
|
110
|
-
},
|
|
111
|
-
ImportExpression(node) {
|
|
112
|
-
if (accepted.has(staticString(node.source))) imported = true
|
|
113
|
-
},
|
|
114
|
-
"Program:exit"(program) {
|
|
115
|
-
if (!imported) context.report({ node: program, messageId: "subject", data: { subject } })
|
|
116
|
-
},
|
|
117
|
-
}
|
|
118
|
-
},
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// -- SPEC-3 ----------------------------------------------------------------------------------------
|
|
122
|
-
|
|
123
|
-
/** The siblings `index.spec.ts(x)` can be the spec of. */
|
|
124
|
-
const INDEX_SIBLINGS = ["index.ts", "index.tsx"]
|
|
125
|
-
|
|
126
|
-
/** A statement that holds no behaviour: an import or an export declaration (a re-export, `export {}`, `export type {}`). */
|
|
127
|
-
const isBarrelStatement = (statement) => ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)
|
|
128
|
-
|
|
129
|
-
/** True when the file holds nothing but import and export declarations. */
|
|
130
|
-
const isBarrelSource = (text) => {
|
|
131
|
-
const source = ts.createSourceFile("index.tsx", text, ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX)
|
|
132
|
-
return source.statements.every(isBarrelStatement)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** No spec for a barrel: the spec's `index.ts(x)` sibling holds only imports and re-exports. */
|
|
136
|
-
export const noBarrelSpec = {
|
|
137
|
-
meta: {
|
|
138
|
-
type: "problem",
|
|
139
|
-
docs: { description: "No `index.spec.ts` beside an `index.ts` that is only a barrel." },
|
|
140
|
-
schema: [],
|
|
141
|
-
messages: {
|
|
142
|
-
barrel:
|
|
143
|
-
"A spec for a barrel. The sibling `index.ts` holds only imports and 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. (An `index.ts` that holds implementation - a function, a class or a value - may have this spec.)",
|
|
144
|
-
},
|
|
145
|
-
},
|
|
146
|
-
create(context) {
|
|
147
|
-
const file = normalizePath(context.filename || context.getFilename())
|
|
148
|
-
if (!isSpecFile(file) || subjectOf(file) !== "index") return {}
|
|
149
|
-
const sibling = INDEX_SIBLINGS.map((name) => join(dirname(file), name)).find((candidate) => existsSync(candidate))
|
|
150
|
-
if (!sibling || !isBarrelSource(readFileSync(sibling, "utf8"))) return {}
|
|
151
|
-
return { Program: (node) => context.report({ node, messageId: "barrel" }) }
|
|
152
|
-
},
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// -- SPEC-4 ----------------------------------------------------------------------------------------
|
|
156
|
-
|
|
157
|
-
/** No `as unknown as T` in a spec. */
|
|
158
|
-
export const noDoubleCastInSpec = {
|
|
159
|
-
meta: {
|
|
160
|
-
type: "problem",
|
|
161
|
-
docs: { description: "A spec builds its values with the type's own shape, not `as unknown as`." },
|
|
162
|
-
schema: [],
|
|
163
|
-
messages: {
|
|
164
|
-
double:
|
|
165
|
-
"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.",
|
|
166
|
-
},
|
|
167
|
-
},
|
|
168
|
-
create(context) {
|
|
169
|
-
if (!isSpecFile(context.filename || context.getFilename())) return {}
|
|
170
|
-
return {
|
|
171
|
-
TSAsExpression(node) {
|
|
172
|
-
const inner = node.expression
|
|
173
|
-
if (inner && inner.type === "TSAsExpression" && inner.typeAnnotation.type === "TSUnknownKeyword") {
|
|
174
|
-
context.report({ node, messageId: "double" })
|
|
175
|
-
}
|
|
176
|
-
},
|
|
177
|
-
}
|
|
178
|
-
},
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// -- SPEC-5 ----------------------------------------------------------------------------------------
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* The spec of a connected screen: the spec of the entry (`index.tsx`, role `entry`) of a block or of a feature. The slot answers
|
|
185
|
-
* both: the sibling the spec is named after is classified, and it must be a component-tier entry in the `blocks` layer or a
|
|
186
|
-
* `fe.feature` entry.
|
|
187
|
-
*/
|
|
188
|
-
const isConnectedSpec = (context, file) => {
|
|
189
|
-
const subject = subjectOf(file)
|
|
190
|
-
const hfs = hfsOf(context)
|
|
191
|
-
const entry = hfs.classify(join(dirname(file), `${subject}.tsx`))
|
|
192
|
-
if (entry.role !== "entry") return false
|
|
193
|
-
return (entry.slot === "fe.components" && entry.kind === "blocks") || entry.slot === "fe.feature"
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** Names that run an accessibility check. */
|
|
197
|
-
const AXE_CALLS = new Set(["axe", "runAxe", "expectNoAxeViolations", "expectNoA11yViolations", "toHaveNoViolations"])
|
|
198
|
-
|
|
199
|
-
/** A connected screen's spec asserts accessibility. */
|
|
200
|
-
export const connectedSpecHasAxe = {
|
|
201
|
-
meta: {
|
|
202
|
-
type: "problem",
|
|
203
|
-
docs: { description: "The spec of a connected block or feature runs an axe assertion." },
|
|
204
|
-
schema: [],
|
|
205
|
-
messages: {
|
|
206
|
-
axe:
|
|
207
|
-
"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()`.",
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
create(context) {
|
|
211
|
-
const file = normalizePath(context.filename || context.getFilename())
|
|
212
|
-
if (!isSpecFile(file) || !isConnectedSpec(context, file)) return {}
|
|
213
|
-
let ran = false
|
|
214
|
-
return {
|
|
215
|
-
CallExpression(node) {
|
|
216
|
-
const name = calleeName(node.callee)
|
|
217
|
-
if (name && AXE_CALLS.has(name)) ran = true
|
|
218
|
-
},
|
|
219
|
-
"Program:exit"(program) {
|
|
220
|
-
if (!ran) context.report({ node: program, messageId: "axe" })
|
|
221
|
-
},
|
|
222
|
-
}
|
|
223
|
-
},
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// -- SPEC-6 ----------------------------------------------------------------------------------------
|
|
227
|
-
|
|
228
|
-
/** The functions a spec mocks a module with. */
|
|
229
|
-
const MOCK_CALLS = new Set(["mock", "doMock", "unstable_mockModule"])
|
|
230
|
-
|
|
231
|
-
/** Every node under `root` (inclusive), through the parser's visitor keys. */
|
|
232
|
-
const walk = (root, keys, visit) => {
|
|
233
|
-
if (!root || typeof root.type !== "string") return
|
|
234
|
-
visit(root)
|
|
235
|
-
for (const key of keys[root.type] ?? []) {
|
|
236
|
-
const child = root[key]
|
|
237
|
-
for (const item of Array.isArray(child) ? child : [child]) walk(item, keys, visit)
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** True when the specifier is the app's message catalogue: a file under `messages/` of the i18n module slot. */
|
|
242
|
-
const isCatalogSpecifier = (context, specifier) => {
|
|
243
|
-
const target = resolveImport(context, specifier)
|
|
244
|
-
if (!target) return false
|
|
245
|
-
const hfs = hfsOf(context)
|
|
246
|
-
const { slot, root } = hfs.classify(target)
|
|
247
|
-
return slot === "fe.modules.i18n" && hfs.relative(target).startsWith(`${root}/messages/`)
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** A spec renders with the real catalogue; a server helper's mock serves it. */
|
|
251
|
-
export const noMockedTranslations = {
|
|
252
|
-
meta: {
|
|
253
|
-
type: "problem",
|
|
254
|
-
docs: { description: "A spec renders with the real catalogue; it never mocks `next-intl` (a `next-intl/server` mock must serve the real catalogue)." },
|
|
255
|
-
schema: [],
|
|
256
|
-
messages: {
|
|
257
|
-
mocked:
|
|
258
|
-
"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`.",
|
|
259
|
-
mockedServer:
|
|
260
|
-
"A `next-intl/server` mock that does not serve the real catalogue. A server helper has no provider to render inside, so the mock is allowed - but its factory must build the translator from the app's real messages (import `modules/i18n/messages/<locale>.json` and pass it to `createTranslator` from `next-intl`); a literal or key-echoing translator passes on the KEY and hides a missing or misspelled entry.",
|
|
261
|
-
},
|
|
262
|
-
},
|
|
263
|
-
create(context) {
|
|
264
|
-
const file = context.filename || context.getFilename()
|
|
265
|
-
if (!isSpecFile(file)) return {}
|
|
266
|
-
const source = context.sourceCode ?? context.getSourceCode()
|
|
267
|
-
const keys = source.visitorKeys
|
|
268
|
-
const catalogBindings = new Set()
|
|
269
|
-
const mocks = []
|
|
270
|
-
const hoisted = []
|
|
271
|
-
return {
|
|
272
|
-
ImportDeclaration(node) {
|
|
273
|
-
if (!isCatalogSpecifier(context, String(node.source.value))) return
|
|
274
|
-
for (const specifier of node.specifiers) catalogBindings.add(specifier.local.name)
|
|
275
|
-
},
|
|
276
|
-
CallExpression(node) {
|
|
277
|
-
const callee = node.callee
|
|
278
|
-
if (callee.type !== "MemberExpression" || callee.computed) return
|
|
279
|
-
if (["vi", "jest"].includes(callee.object.name) && callee.property.name === "hoisted") hoisted.push(node)
|
|
280
|
-
if (!MOCK_CALLS.has(callee.property.name) || !["vi", "jest"].includes(callee.object.name)) return
|
|
281
|
-
const target = staticString(node.arguments[0])
|
|
282
|
-
if (target !== null && /^next-intl(?:\/|$)/.test(target)) mocks.push({ node, target })
|
|
283
|
-
},
|
|
284
|
-
"Program:exit"() {
|
|
285
|
-
// The catalogue is served when a catalogue binding, or a dynamic import of the catalogue, is used by the factory or by a `vi.hoisted` block.
|
|
286
|
-
const serves = (root) => {
|
|
287
|
-
let found = false
|
|
288
|
-
walk(root, keys, (inner) => {
|
|
289
|
-
if (inner.type === "Identifier" && catalogBindings.has(inner.name)) found = true
|
|
290
|
-
const specifier = inner.type === "ImportExpression" ? staticString(inner.source) : null
|
|
291
|
-
if (specifier !== null && isCatalogSpecifier(context, specifier)) found = true
|
|
292
|
-
})
|
|
293
|
-
return found
|
|
294
|
-
}
|
|
295
|
-
for (const { node, target } of mocks) {
|
|
296
|
-
if (target === "next-intl/server") {
|
|
297
|
-
const factory = node.arguments[1]
|
|
298
|
-
if (factory && (serves(factory) || hoisted.some(serves))) continue
|
|
299
|
-
context.report({ node, messageId: "mockedServer" })
|
|
300
|
-
} else context.report({ node, messageId: "mocked" })
|
|
301
|
-
}
|
|
302
|
-
},
|
|
303
|
-
}
|
|
304
|
-
},
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/** The rules this law contributes to the plugin. */
|
|
308
|
-
export const rules = {
|
|
309
|
-
"no-class-string-in-spec": noClassStringInSpec,
|
|
310
|
-
"spec-tests-its-neighbour": specTestsItsNeighbour,
|
|
311
|
-
"no-barrel-spec": noBarrelSpec,
|
|
312
|
-
"no-double-cast-in-spec": noDoubleCastInSpec,
|
|
313
|
-
"connected-spec-has-axe": connectedSpecHasAxe,
|
|
314
|
-
"no-mocked-translations": noMockedTranslations,
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/** Every rule is an error. */
|
|
318
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|