@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +3 -5
  3. package/client-boundary.mjs +2 -5
  4. package/comments.mjs +38 -15
  5. package/docs/brand-values.md +2 -2
  6. package/docs/client-boundary.md +10 -10
  7. package/docs/env-owner.md +4 -4
  8. package/docs/formatting.md +2 -2
  9. package/docs/grammar-boundary.md +2 -2
  10. package/docs/hooks-folder.md +2 -2
  11. package/docs/hygiene.md +8 -8
  12. package/docs/lint-escape-hatch.md +2 -2
  13. package/docs/lists.md +6 -6
  14. package/docs/native-controls.md +6 -6
  15. package/docs/next-conventions.md +20 -20
  16. package/docs/size-and-state-budget.md +6 -6
  17. package/docs/size-growth.md +2 -2
  18. package/docs/status-colors.md +2 -2
  19. package/docs/translation.md +2 -2
  20. package/docs/transport.md +16 -16
  21. package/docs/type-safety.md +8 -8
  22. package/env-owner.mjs +2 -3
  23. package/file-layout.mjs +3 -43
  24. package/formatting.mjs +2 -2
  25. package/grammar-boundary.mjs +1 -2
  26. package/hooks-folder.mjs +6 -7
  27. package/index.mjs +3 -27
  28. package/lib/config.mjs +6 -22
  29. package/lib/scope.mjs +3 -12
  30. package/lib/why.mjs +0 -65
  31. package/lint-escape-hatch.mjs +3 -3
  32. package/lists.mjs +0 -4
  33. package/loading.mjs +2 -2
  34. package/native-controls.mjs +0 -4
  35. package/next-conventions.mjs +3 -7
  36. package/package.json +1 -1
  37. package/props-and-slots.mjs +4 -5
  38. package/runtime/knowledge/hfs/slots.yaml +145 -107
  39. package/runtime/scripts/lib/hfs-allows.mjs +15 -0
  40. package/runtime/scripts/lib/hfs-slots.mjs +13 -6
  41. package/runtime/scripts/lib/language.mjs +107 -0
  42. package/served-locale.mjs +2 -2
  43. package/shape-slot.mjs +2 -3
  44. package/size-and-state-budget.mjs +1 -4
  45. package/size-growth.mjs +1 -2
  46. package/status-colors.mjs +0 -2
  47. package/tokens.mjs +1 -1
  48. package/translation.mjs +1 -29
  49. package/transport.mjs +4 -8
  50. package/docs/e2e-shape.md +0 -161
  51. package/docs/spec-quality.md +0 -147
  52. package/e2e-shape.mjs +0 -345
  53. 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"]))