@starci/eslint-canon-fe 6.0.2 → 7.1.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 +18 -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 +18 -18
- 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 +5 -27
- package/lib/config.mjs +6 -22
- package/lib/scope.mjs +3 -12
- package/lib/why.mjs +3 -68
- 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/project-graph.mjs +23 -0
- package/props-and-slots.mjs +4 -5
- package/runtime/knowledge/hfs/slots.yaml +145 -107
- package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
- package/runtime/scripts/checks/architecture/backend.mjs +350 -0
- package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
- package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
- package/runtime/scripts/checks/architecture/clones.mjs +200 -0
- package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
- package/runtime/scripts/checks/architecture/config.mjs +310 -0
- package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
- package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
- package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
- package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
- package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
- package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
- package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
- package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
- package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
- package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
- package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
- package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
- package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
- package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
- package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
- package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
- package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
- package/runtime/scripts/checks/architecture/index.mjs +321 -0
- package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
- package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
- package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
- package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
- package/runtime/scripts/checks/architecture/owners.mjs +95 -0
- package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
- package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
- package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
- package/runtime/scripts/checks/architecture/registration.mjs +319 -0
- package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
- package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
- package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
- package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
- package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
- package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
- package/runtime/scripts/checks/architecture/surface.mjs +91 -0
- package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
- package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
- package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
- package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
- package/runtime/scripts/checks/architecture/typescript.mjs +573 -0
- package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
- package/runtime/scripts/checks/common.mjs +44 -0
- package/runtime/scripts/checks/typescript-programs.mjs +82 -0
- package/runtime/scripts/lib/hfs-allows.mjs +15 -0
- package/runtime/scripts/lib/hfs-path-findings.mjs +84 -0
- package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
- package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
- package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +29 -0
- package/runtime/scripts/lib/hfs-slots.mjs +13 -6
- package/runtime/scripts/lib/json.mjs +22 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/project-graph.mjs +0 -0
- package/runtime/scripts/lib/project-rule.mjs +39 -0
- package/runtime/scripts/lib/repo-identity.mjs +50 -0
- package/runtime/scripts/lib/secret-patterns.mjs +44 -0
- package/runtime/scripts/lib/stack-services.mjs +217 -0
- package/runtime/scripts/lib/test-secrets.mjs +120 -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 +13 -17
- 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/e2e-shape.mjs
DELETED
|
@@ -1,345 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rules that hold `e2e-shape.md` (HFS R66, `FE_E2E_SHAPE`).
|
|
3
|
-
*
|
|
4
|
-
* THESE RULES GOVERN THE E2E TREE, NOT `src/`. `starciFeConfig` attaches them to `e2e/**` and
|
|
5
|
-
* `playwright.config.*`; every rule here also scopes itself by HFS slot (tier e2e, the Playwright config role), so a config that reaches it by
|
|
6
|
-
* mistake governs nothing it should not.
|
|
7
|
-
*
|
|
8
|
-
* WHY THE SHAPE IS FIXED. An e2e suite is the only proof that the whole app works, and it is run by
|
|
9
|
-
* hand, so it rots quietly: absolute paths that exist on one machine, a Docker dependency that makes
|
|
10
|
-
* it unrunnable in CI, a helper that writes into the OTHER repository's tree, a `test.skip` that
|
|
11
|
-
* turns a missing environment into a green run, and helpers whose untyped parameters hide the fields
|
|
12
|
-
* a test depends on. One layout - `e2e/<area>/<name>.e2e-spec.ts`, three viewports, typed helpers -
|
|
13
|
-
* is what lets the land gate check it statically even though it cannot run it.
|
|
14
|
-
*
|
|
15
|
-
* WHAT IS NOT HERE. That `typecheck:e2e` runs before `test:e2e` is a `package.json` fact (the
|
|
16
|
-
* architecture machine reads it); that the specs pass is the run itself.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { baseName, classOf, fileOf, inSlot, isE2eSource, roleOfFile } from "./lib/scope.mjs"
|
|
20
|
-
|
|
21
|
-
/** The three viewports every e2e project set covers, as `width x height`. */
|
|
22
|
-
export const VIEWPORTS = Object.freeze(["1440x900", "768x1024", "390x844"])
|
|
23
|
-
|
|
24
|
-
/** A file that reads like a spec, by any of the common spellings. */
|
|
25
|
-
const SPEC_LIKE = /(?:\.|-)(?:e2e-)?(?:spec|test)\.[cm]?tsx?$/
|
|
26
|
-
|
|
27
|
-
/** A helper file that is named as a spec. */
|
|
28
|
-
const E2E_SPEC_NAME = /\.e2e-spec\./
|
|
29
|
-
|
|
30
|
-
/** The slots of the e2e tree: the specs (`fe.e2e`) and their helpers (`fe.e2e-support`). */
|
|
31
|
-
const E2E_SLOTS = ["fe.e2e", "fe.e2e-support"]
|
|
32
|
-
|
|
33
|
-
/** An absolute filesystem path: a drive, a UNC share, or a well-known root. */
|
|
34
|
-
const ABSOLUTE_PATH = /^(?:[A-Za-z]:[\\/]|\\\\|\/(?:Users|home|tmp|mnt|var|opt|etc|root|d|c)\/)/
|
|
35
|
-
|
|
36
|
-
/** A `..` segment in a path string. */
|
|
37
|
-
const PARENT_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/
|
|
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") return node.quasis.map((quasi) => quasi.value.cooked ?? "").join("")
|
|
44
|
-
return null
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Every string in a subtree, so `path.join(__dirname, "..", "..")` is read as its parts. */
|
|
48
|
-
const stringsIn = (node, source) => {
|
|
49
|
-
const found = []
|
|
50
|
-
const walk = (current) => {
|
|
51
|
-
if (!current || typeof current.type !== "string") return
|
|
52
|
-
const text = staticString(current)
|
|
53
|
-
if (text !== null) found.push(text)
|
|
54
|
-
for (const key of source.visitorKeys[current.type] ?? []) {
|
|
55
|
-
const child = current[key]
|
|
56
|
-
if (Array.isArray(child)) child.forEach(walk)
|
|
57
|
-
else walk(child)
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
walk(node)
|
|
61
|
-
return found
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// -- E2E-1 -----------------------------------------------------------------------------------------
|
|
65
|
-
|
|
66
|
-
/** Specs live at `e2e/<area>/<name>.e2e-spec.ts`. */
|
|
67
|
-
export const e2eSpecLocation = {
|
|
68
|
-
meta: {
|
|
69
|
-
type: "problem",
|
|
70
|
-
docs: { description: "An e2e spec is `e2e/<area>/<name>.e2e-spec.ts`; helpers live under `support/` or `fixtures/`." },
|
|
71
|
-
schema: [],
|
|
72
|
-
messages: {
|
|
73
|
-
location:
|
|
74
|
-
"`{{name}}` is not at `e2e/<area>/<name>.e2e-spec.ts`. One shape - an area folder, the `.e2e-spec.ts` suffix - is what lets the gate find every spec without running them; helpers go in `e2e/support/` or `e2e/fixtures/` and are never named like a spec.",
|
|
75
|
-
},
|
|
76
|
-
},
|
|
77
|
-
create(context) {
|
|
78
|
-
const name = baseName(fileOf(context))
|
|
79
|
-
const found = classOf(context)
|
|
80
|
-
// A helper slot (`fe.e2e-support`) owns no spec: a helper named like one is a spec in the wrong place.
|
|
81
|
-
const helperNamedSpec = inSlot(context, "fe.e2e-support") && E2E_SPEC_NAME.test(name)
|
|
82
|
-
// A spec-looking file no slot owns but that sits inside the e2e tree (the nearest slot is an e2e slot, matched past the repository root)
|
|
83
|
-
// is a spec off the one place `fe.e2e` gives it. A file outside the e2e tree is not this rule's business.
|
|
84
|
-
const misplacedSpec = found.status === "no-slot" && E2E_SLOTS.includes(found.nearest?.slot) && found.nearest.matchedDepth >= 1 && SPEC_LIKE.test(name)
|
|
85
|
-
if (helperNamedSpec || misplacedSpec) return { Program: (node) => context.report({ node, messageId: "location", data: { name } }) }
|
|
86
|
-
return {}
|
|
87
|
-
},
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// -- E2E-2 -----------------------------------------------------------------------------------------
|
|
91
|
-
|
|
92
|
-
/** No absolute path in e2e source. */
|
|
93
|
-
export const e2eNoAbsolutePath = {
|
|
94
|
-
meta: {
|
|
95
|
-
type: "problem",
|
|
96
|
-
docs: { description: "No absolute filesystem path in e2e source." },
|
|
97
|
-
schema: [],
|
|
98
|
-
messages: {
|
|
99
|
-
absolute:
|
|
100
|
-
"An absolute path (`{{path}}`). It exists on one machine and nowhere else, so the suite passes locally and cannot run in CI or on another machine. Build the path from the config or the test's own directory.",
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
create(context) {
|
|
104
|
-
if (!isE2eSource(context)) return {}
|
|
105
|
-
const check = (node, text) => {
|
|
106
|
-
if (typeof text === "string" && ABSOLUTE_PATH.test(text)) context.report({ node, messageId: "absolute", data: { path: text } })
|
|
107
|
-
}
|
|
108
|
-
return {
|
|
109
|
-
Literal: (node) => check(node, node.value),
|
|
110
|
-
TemplateElement: (node) => check(node, node.value && node.value.cooked),
|
|
111
|
-
}
|
|
112
|
-
},
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// -- E2E-3 -----------------------------------------------------------------------------------------
|
|
116
|
-
|
|
117
|
-
/** Container tooling that an e2e suite must not depend on. */
|
|
118
|
-
const DOCKER_MODULE = /^(?:dockerode|testcontainers|@testcontainers\/.+|docker-compose)$/
|
|
119
|
-
|
|
120
|
-
/** A command or string that starts Docker. */
|
|
121
|
-
const DOCKER_TEXT = /\bdocker(?:-compose)?\b|\bdocker\s+compose\b/i
|
|
122
|
-
|
|
123
|
-
/** No Docker in e2e. */
|
|
124
|
-
export const e2eNoDocker = {
|
|
125
|
-
meta: {
|
|
126
|
-
type: "problem",
|
|
127
|
-
docs: { description: "An e2e suite does not start or require Docker." },
|
|
128
|
-
schema: [],
|
|
129
|
-
messages: {
|
|
130
|
-
docker:
|
|
131
|
-
"Docker in an e2e suite. A suite that needs a container runtime cannot run where the app can, and it hides the real prerequisite (a running app and backend) behind a tool. Point the suite at a URL from the config and let the environment provide the stack.",
|
|
132
|
-
},
|
|
133
|
-
},
|
|
134
|
-
create(context) {
|
|
135
|
-
if (!isE2eSource(context)) return {}
|
|
136
|
-
return {
|
|
137
|
-
ImportDeclaration(node) {
|
|
138
|
-
if (DOCKER_MODULE.test(String(node.source.value))) context.report({ node, messageId: "docker" })
|
|
139
|
-
},
|
|
140
|
-
Literal(node) {
|
|
141
|
-
if (typeof node.value === "string" && DOCKER_TEXT.test(node.value)) context.report({ node, messageId: "docker" })
|
|
142
|
-
},
|
|
143
|
-
TemplateElement(node) {
|
|
144
|
-
if (DOCKER_TEXT.test((node.value && node.value.cooked) || "")) context.report({ node, messageId: "docker" })
|
|
145
|
-
},
|
|
146
|
-
}
|
|
147
|
-
},
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// -- E2E-4 -----------------------------------------------------------------------------------------
|
|
151
|
-
|
|
152
|
-
/** Filesystem calls that write, move or remove. */
|
|
153
|
-
const FS_WRITE = new Set([
|
|
154
|
-
"writeFile",
|
|
155
|
-
"writeFileSync",
|
|
156
|
-
"appendFile",
|
|
157
|
-
"appendFileSync",
|
|
158
|
-
"mkdir",
|
|
159
|
-
"mkdirSync",
|
|
160
|
-
"rm",
|
|
161
|
-
"rmSync",
|
|
162
|
-
"rmdir",
|
|
163
|
-
"rmdirSync",
|
|
164
|
-
"copyFile",
|
|
165
|
-
"copyFileSync",
|
|
166
|
-
"cp",
|
|
167
|
-
"cpSync",
|
|
168
|
-
"rename",
|
|
169
|
-
"renameSync",
|
|
170
|
-
"unlink",
|
|
171
|
-
"unlinkSync",
|
|
172
|
-
"createWriteStream",
|
|
173
|
-
])
|
|
174
|
-
|
|
175
|
-
/** The name a call is made through: `writeFile` or `fs.writeFile` or `fs.promises.writeFile`. */
|
|
176
|
-
const calleeName = (callee) => {
|
|
177
|
-
if (callee.type === "Identifier") return callee.name
|
|
178
|
-
if (callee.type === "MemberExpression" && !callee.computed) return callee.property.name
|
|
179
|
-
return null
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** No e2e write outside the repository's own tree. */
|
|
183
|
-
export const e2eNoCrossRepoWrite = {
|
|
184
|
-
meta: {
|
|
185
|
-
type: "problem",
|
|
186
|
-
docs: { description: "An e2e file write never targets an absolute path or climbs out of the repository." },
|
|
187
|
-
schema: [],
|
|
188
|
-
messages: {
|
|
189
|
-
write:
|
|
190
|
-
"This file operation writes to a path that is absolute or climbs out with `..`. An e2e run that writes into another repository (the backend's tree, a shared folder) changes state it does not own, and its result depends on a checkout layout. Write under this repository's own output folder.",
|
|
191
|
-
},
|
|
192
|
-
},
|
|
193
|
-
create(context) {
|
|
194
|
-
if (!isE2eSource(context)) return {}
|
|
195
|
-
const source = context.sourceCode || context.getSourceCode()
|
|
196
|
-
return {
|
|
197
|
-
CallExpression(node) {
|
|
198
|
-
const name = calleeName(node.callee)
|
|
199
|
-
if (!name || !FS_WRITE.has(name)) return
|
|
200
|
-
const strings = node.arguments.flatMap((argument) => stringsIn(argument, source))
|
|
201
|
-
if (strings.some((text) => ABSOLUTE_PATH.test(text) || PARENT_SEGMENT.test(text) || text === "..")) {
|
|
202
|
-
context.report({ node, messageId: "write" })
|
|
203
|
-
}
|
|
204
|
-
},
|
|
205
|
-
}
|
|
206
|
-
},
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// -- E2E-5 -----------------------------------------------------------------------------------------
|
|
210
|
-
|
|
211
|
-
/** The root identifier of a member chain: `test` in `test.describe.skip`. */
|
|
212
|
-
const chainRoot = (node) => {
|
|
213
|
-
let current = node
|
|
214
|
-
while (current && current.type === "MemberExpression") current = current.object
|
|
215
|
-
return current && current.type === "Identifier" ? current.name : null
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** No skipped test: a missing environment is a failure. */
|
|
219
|
-
export const e2eNoSkip = {
|
|
220
|
-
meta: {
|
|
221
|
-
type: "problem",
|
|
222
|
-
docs: { description: "No `test.skip`, `test.fixme` or `describe.skip`: a missing environment fails the run." },
|
|
223
|
-
schema: [],
|
|
224
|
-
messages: {
|
|
225
|
-
skip:
|
|
226
|
-
"A skipped test. Skipping because the environment is missing turns \"the suite cannot run\" into a green run, which is the one result an e2e suite must never give. Fix the environment, or delete the test.",
|
|
227
|
-
},
|
|
228
|
-
},
|
|
229
|
-
create(context) {
|
|
230
|
-
if (!isE2eSource(context)) return {}
|
|
231
|
-
return {
|
|
232
|
-
CallExpression(node) {
|
|
233
|
-
const callee = node.callee
|
|
234
|
-
if (callee.type !== "MemberExpression" || callee.computed) return
|
|
235
|
-
if (!["skip", "fixme"].includes(callee.property.name)) return
|
|
236
|
-
if (["test", "it", "describe"].includes(chainRoot(callee.object))) context.report({ node, messageId: "skip" })
|
|
237
|
-
},
|
|
238
|
-
}
|
|
239
|
-
},
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// -- E2E-6 -----------------------------------------------------------------------------------------
|
|
243
|
-
|
|
244
|
-
/** True when a function is a module-level helper: declared, or bound to a top-level `const`. */
|
|
245
|
-
const isModuleLevel = (node) => {
|
|
246
|
-
let current = node.parent
|
|
247
|
-
if (node.type !== "FunctionDeclaration") {
|
|
248
|
-
if (!current || current.type !== "VariableDeclarator") return false
|
|
249
|
-
current = current.parent && current.parent.parent
|
|
250
|
-
}
|
|
251
|
-
return Boolean(current) && ["Program", "ExportNamedDeclaration", "ExportDefaultDeclaration"].includes(current.type)
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** Helpers carry types; nothing is `any`. */
|
|
255
|
-
export const e2eTypedHelpers = {
|
|
256
|
-
meta: {
|
|
257
|
-
type: "problem",
|
|
258
|
-
docs: { description: "e2e helper parameters are typed and nothing is `any`." },
|
|
259
|
-
schema: [],
|
|
260
|
-
messages: {
|
|
261
|
-
untyped:
|
|
262
|
-
"A helper parameter with no type. A test depends on the fields a helper takes and returns; untyped, a renamed field fails at run time in a test nobody touched. Annotate the parameter.",
|
|
263
|
-
any: "`any` in e2e source. It turns the compiler off for the value a test asserts on. Type it, or narrow from `unknown`.",
|
|
264
|
-
},
|
|
265
|
-
},
|
|
266
|
-
create(context) {
|
|
267
|
-
if (!isE2eSource(context)) return {}
|
|
268
|
-
const checkParams = (node) => {
|
|
269
|
-
if (!isModuleLevel(node)) return
|
|
270
|
-
for (const parameter of node.params) {
|
|
271
|
-
const target = parameter.type === "AssignmentPattern" ? parameter.left : parameter
|
|
272
|
-
// A default value gives the parameter a type the compiler infers.
|
|
273
|
-
if (parameter.type === "AssignmentPattern" && parameter.right.type === "Literal") continue
|
|
274
|
-
if (target.type === "RestElement") {
|
|
275
|
-
if (!target.typeAnnotation) context.report({ node: target, messageId: "untyped" })
|
|
276
|
-
continue
|
|
277
|
-
}
|
|
278
|
-
if (!target.typeAnnotation) context.report({ node: target, messageId: "untyped" })
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
return {
|
|
282
|
-
FunctionDeclaration: checkParams,
|
|
283
|
-
ArrowFunctionExpression: checkParams,
|
|
284
|
-
FunctionExpression: checkParams,
|
|
285
|
-
TSAnyKeyword(node) {
|
|
286
|
-
context.report({ node, messageId: "any" })
|
|
287
|
-
},
|
|
288
|
-
}
|
|
289
|
-
},
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// -- E2E-7 -----------------------------------------------------------------------------------------
|
|
293
|
-
|
|
294
|
-
/** Every Playwright config declares exactly the three viewports. */
|
|
295
|
-
export const playwrightViewports = {
|
|
296
|
-
meta: {
|
|
297
|
-
type: "problem",
|
|
298
|
-
docs: { description: `playwright.config declares the viewports ${VIEWPORTS.join(", ")} and no others.` },
|
|
299
|
-
schema: [],
|
|
300
|
-
messages: {
|
|
301
|
-
missing:
|
|
302
|
-
"The Playwright config does not declare the viewport {{size}}. Every app is proved at 1440x900, 768x1024 and 390x844 so a layout that only works on a desktop screen fails here.",
|
|
303
|
-
extra:
|
|
304
|
-
"The Playwright config declares the viewport {{size}}, which is not one of 1440x900, 768x1024, 390x844. A fourth size is a fourth thing to keep passing that no design covers.",
|
|
305
|
-
},
|
|
306
|
-
},
|
|
307
|
-
create(context) {
|
|
308
|
-
if (roleOfFile(context) !== "playwright") return {}
|
|
309
|
-
const seen = new Map()
|
|
310
|
-
return {
|
|
311
|
-
Property(node) {
|
|
312
|
-
if (node.computed || node.key.type !== "Identifier" || node.key.name !== "viewport") return
|
|
313
|
-
if (node.value.type !== "ObjectExpression") return
|
|
314
|
-
const field = (name) => {
|
|
315
|
-
const property = node.value.properties.find((p) => p.type === "Property" && p.key.type === "Identifier" && p.key.name === name)
|
|
316
|
-
return property && property.value.type === "Literal" ? property.value.value : null
|
|
317
|
-
}
|
|
318
|
-
const width = field("width")
|
|
319
|
-
const height = field("height")
|
|
320
|
-
if (typeof width === "number" && typeof height === "number") seen.set(`${width}x${height}`, node)
|
|
321
|
-
},
|
|
322
|
-
"Program:exit"(program) {
|
|
323
|
-
for (const size of VIEWPORTS) if (!seen.has(size)) context.report({ node: program, messageId: "missing", data: { size } })
|
|
324
|
-
for (const [size, node] of seen) if (!VIEWPORTS.includes(size)) context.report({ node, messageId: "extra", data: { size } })
|
|
325
|
-
},
|
|
326
|
-
}
|
|
327
|
-
},
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/** The rules this law contributes to the plugin. */
|
|
331
|
-
export const rules = {
|
|
332
|
-
"e2e-spec-location": e2eSpecLocation,
|
|
333
|
-
"e2e-no-absolute-path": e2eNoAbsolutePath,
|
|
334
|
-
"e2e-no-docker": e2eNoDocker,
|
|
335
|
-
"e2e-no-cross-repo-write": e2eNoCrossRepoWrite,
|
|
336
|
-
"e2e-no-skip": e2eNoSkip,
|
|
337
|
-
"e2e-typed-helpers": e2eTypedHelpers,
|
|
338
|
-
"playwright-viewports": playwrightViewports,
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** Which tree these rules govern: the factory attaches them to `e2e/**`, not to `src/**`. */
|
|
342
|
-
export const scope = "e2e"
|
|
343
|
-
|
|
344
|
-
/** Every rule is an error. */
|
|
345
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
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"]))
|