@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
package/e2e-shape.mjs
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
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 path, 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 { isE2eFile } from "./lib/scope.mjs"
|
|
20
|
+
import { baseName } from "./lib/scope.mjs"
|
|
21
|
+
import { normalizePath } from "./lib/path.mjs"
|
|
22
|
+
|
|
23
|
+
/** The three viewports every e2e project set covers, as `width x height`. */
|
|
24
|
+
export const VIEWPORTS = Object.freeze(["1440x900", "768x1024", "390x844"])
|
|
25
|
+
|
|
26
|
+
/** A file that reads like a spec, by any of the common spellings. */
|
|
27
|
+
const SPEC_LIKE = /(?:\.|-)(?:e2e-)?(?:spec|test)\.[cm]?tsx?$/
|
|
28
|
+
|
|
29
|
+
/** The one legal spec path: `e2e/<area>/<name>.e2e-spec.ts`. */
|
|
30
|
+
const SPEC_PATH = /(?:^|\/)e2e\/[^/]+\/[^/]+\.e2e-spec\.ts$/
|
|
31
|
+
|
|
32
|
+
/** Helper folders: not specs, and never spec-named. */
|
|
33
|
+
const HELPER_PATH = /(?:^|\/)e2e\/(?:support|fixtures)\//
|
|
34
|
+
|
|
35
|
+
/** An absolute filesystem path: a drive, a UNC share, or a well-known root. */
|
|
36
|
+
const ABSOLUTE_PATH = /^(?:[A-Za-z]:[\\/]|\\\\|\/(?:Users|home|tmp|mnt|var|opt|etc|root|d|c)\/)/
|
|
37
|
+
|
|
38
|
+
/** A `..` segment in a path string. */
|
|
39
|
+
const PARENT_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/
|
|
40
|
+
|
|
41
|
+
/** Static string of a literal or an expression-free template, else null. */
|
|
42
|
+
const staticString = (node) => {
|
|
43
|
+
if (!node) return null
|
|
44
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value
|
|
45
|
+
if (node.type === "TemplateLiteral") return node.quasis.map((quasi) => quasi.value.cooked ?? "").join("")
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Every string in a subtree, so `path.join(__dirname, "..", "..")` is read as its parts. */
|
|
50
|
+
const stringsIn = (node, source) => {
|
|
51
|
+
const found = []
|
|
52
|
+
const walk = (current) => {
|
|
53
|
+
if (!current || typeof current.type !== "string") return
|
|
54
|
+
const text = staticString(current)
|
|
55
|
+
if (text !== null) found.push(text)
|
|
56
|
+
for (const key of source.visitorKeys[current.type] ?? []) {
|
|
57
|
+
const child = current[key]
|
|
58
|
+
if (Array.isArray(child)) child.forEach(walk)
|
|
59
|
+
else walk(child)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
walk(node)
|
|
63
|
+
return found
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// -- E2E-1 -----------------------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/** Specs live at `e2e/<area>/<name>.e2e-spec.ts`. */
|
|
69
|
+
export const e2eSpecLocation = {
|
|
70
|
+
meta: {
|
|
71
|
+
type: "problem",
|
|
72
|
+
docs: { description: "An e2e spec is `e2e/<area>/<name>.e2e-spec.ts`; helpers live under `support/` or `fixtures/`." },
|
|
73
|
+
schema: [],
|
|
74
|
+
messages: {
|
|
75
|
+
location:
|
|
76
|
+
"`{{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.",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
create(context) {
|
|
80
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
81
|
+
if (!/(?:^|\/)e2e\//.test(file)) return {}
|
|
82
|
+
const name = baseName(file)
|
|
83
|
+
const helper = HELPER_PATH.test(file)
|
|
84
|
+
const specLike = SPEC_LIKE.test(name)
|
|
85
|
+
const legal = SPEC_PATH.test(file) && !helper
|
|
86
|
+
if ((specLike && !legal) || (helper && /\.e2e-spec\./.test(name))) {
|
|
87
|
+
return { Program: (node) => context.report({ node, messageId: "location", data: { name } }) }
|
|
88
|
+
}
|
|
89
|
+
return {}
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// -- E2E-2 -----------------------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/** No absolute path in e2e source. */
|
|
96
|
+
export const e2eNoAbsolutePath = {
|
|
97
|
+
meta: {
|
|
98
|
+
type: "problem",
|
|
99
|
+
docs: { description: "No absolute filesystem path in e2e source." },
|
|
100
|
+
schema: [],
|
|
101
|
+
messages: {
|
|
102
|
+
absolute:
|
|
103
|
+
"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.",
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
create(context) {
|
|
107
|
+
if (!isE2eFile(context.filename || context.getFilename())) return {}
|
|
108
|
+
const check = (node, text) => {
|
|
109
|
+
if (typeof text === "string" && ABSOLUTE_PATH.test(text)) context.report({ node, messageId: "absolute", data: { path: text } })
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
Literal: (node) => check(node, node.value),
|
|
113
|
+
TemplateElement: (node) => check(node, node.value && node.value.cooked),
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// -- E2E-3 -----------------------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/** Container tooling that an e2e suite must not depend on. */
|
|
121
|
+
const DOCKER_MODULE = /^(?:dockerode|testcontainers|@testcontainers\/.+|docker-compose)$/
|
|
122
|
+
|
|
123
|
+
/** A command or string that starts Docker. */
|
|
124
|
+
const DOCKER_TEXT = /\bdocker(?:-compose)?\b|\bdocker\s+compose\b/i
|
|
125
|
+
|
|
126
|
+
/** No Docker in e2e. */
|
|
127
|
+
export const e2eNoDocker = {
|
|
128
|
+
meta: {
|
|
129
|
+
type: "problem",
|
|
130
|
+
docs: { description: "An e2e suite does not start or require Docker." },
|
|
131
|
+
schema: [],
|
|
132
|
+
messages: {
|
|
133
|
+
docker:
|
|
134
|
+
"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.",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
create(context) {
|
|
138
|
+
if (!isE2eFile(context.filename || context.getFilename())) return {}
|
|
139
|
+
return {
|
|
140
|
+
ImportDeclaration(node) {
|
|
141
|
+
if (DOCKER_MODULE.test(String(node.source.value))) context.report({ node, messageId: "docker" })
|
|
142
|
+
},
|
|
143
|
+
Literal(node) {
|
|
144
|
+
if (typeof node.value === "string" && DOCKER_TEXT.test(node.value)) context.report({ node, messageId: "docker" })
|
|
145
|
+
},
|
|
146
|
+
TemplateElement(node) {
|
|
147
|
+
if (DOCKER_TEXT.test((node.value && node.value.cooked) || "")) context.report({ node, messageId: "docker" })
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// -- E2E-4 -----------------------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/** Filesystem calls that write, move or remove. */
|
|
156
|
+
const FS_WRITE = new Set([
|
|
157
|
+
"writeFile",
|
|
158
|
+
"writeFileSync",
|
|
159
|
+
"appendFile",
|
|
160
|
+
"appendFileSync",
|
|
161
|
+
"mkdir",
|
|
162
|
+
"mkdirSync",
|
|
163
|
+
"rm",
|
|
164
|
+
"rmSync",
|
|
165
|
+
"rmdir",
|
|
166
|
+
"rmdirSync",
|
|
167
|
+
"copyFile",
|
|
168
|
+
"copyFileSync",
|
|
169
|
+
"cp",
|
|
170
|
+
"cpSync",
|
|
171
|
+
"rename",
|
|
172
|
+
"renameSync",
|
|
173
|
+
"unlink",
|
|
174
|
+
"unlinkSync",
|
|
175
|
+
"createWriteStream",
|
|
176
|
+
])
|
|
177
|
+
|
|
178
|
+
/** The name a call is made through: `writeFile` or `fs.writeFile` or `fs.promises.writeFile`. */
|
|
179
|
+
const calleeName = (callee) => {
|
|
180
|
+
if (callee.type === "Identifier") return callee.name
|
|
181
|
+
if (callee.type === "MemberExpression" && !callee.computed) return callee.property.name
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** No e2e write outside the repository's own tree. */
|
|
186
|
+
export const e2eNoCrossRepoWrite = {
|
|
187
|
+
meta: {
|
|
188
|
+
type: "problem",
|
|
189
|
+
docs: { description: "An e2e file write never targets an absolute path or climbs out of the repository." },
|
|
190
|
+
schema: [],
|
|
191
|
+
messages: {
|
|
192
|
+
write:
|
|
193
|
+
"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.",
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
create(context) {
|
|
197
|
+
if (!isE2eFile(context.filename || context.getFilename())) return {}
|
|
198
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
199
|
+
return {
|
|
200
|
+
CallExpression(node) {
|
|
201
|
+
const name = calleeName(node.callee)
|
|
202
|
+
if (!name || !FS_WRITE.has(name)) return
|
|
203
|
+
const strings = node.arguments.flatMap((argument) => stringsIn(argument, source))
|
|
204
|
+
if (strings.some((text) => ABSOLUTE_PATH.test(text) || PARENT_SEGMENT.test(text) || text === "..")) {
|
|
205
|
+
context.report({ node, messageId: "write" })
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// -- E2E-5 -----------------------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
/** The root identifier of a member chain: `test` in `test.describe.skip`. */
|
|
215
|
+
const chainRoot = (node) => {
|
|
216
|
+
let current = node
|
|
217
|
+
while (current && current.type === "MemberExpression") current = current.object
|
|
218
|
+
return current && current.type === "Identifier" ? current.name : null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** No skipped test: a missing environment is a failure. */
|
|
222
|
+
export const e2eNoSkip = {
|
|
223
|
+
meta: {
|
|
224
|
+
type: "problem",
|
|
225
|
+
docs: { description: "No `test.skip`, `test.fixme` or `describe.skip`: a missing environment fails the run." },
|
|
226
|
+
schema: [],
|
|
227
|
+
messages: {
|
|
228
|
+
skip:
|
|
229
|
+
"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.",
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
create(context) {
|
|
233
|
+
if (!isE2eFile(context.filename || context.getFilename())) return {}
|
|
234
|
+
return {
|
|
235
|
+
CallExpression(node) {
|
|
236
|
+
const callee = node.callee
|
|
237
|
+
if (callee.type !== "MemberExpression" || callee.computed) return
|
|
238
|
+
if (!["skip", "fixme"].includes(callee.property.name)) return
|
|
239
|
+
if (["test", "it", "describe"].includes(chainRoot(callee.object))) context.report({ node, messageId: "skip" })
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// -- E2E-6 -----------------------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
/** True when a function is a module-level helper: declared, or bound to a top-level `const`. */
|
|
248
|
+
const isModuleLevel = (node) => {
|
|
249
|
+
let current = node.parent
|
|
250
|
+
if (node.type !== "FunctionDeclaration") {
|
|
251
|
+
if (!current || current.type !== "VariableDeclarator") return false
|
|
252
|
+
current = current.parent && current.parent.parent
|
|
253
|
+
}
|
|
254
|
+
return Boolean(current) && ["Program", "ExportNamedDeclaration", "ExportDefaultDeclaration"].includes(current.type)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Helpers carry types; nothing is `any`. */
|
|
258
|
+
export const e2eTypedHelpers = {
|
|
259
|
+
meta: {
|
|
260
|
+
type: "problem",
|
|
261
|
+
docs: { description: "e2e helper parameters are typed and nothing is `any`." },
|
|
262
|
+
schema: [],
|
|
263
|
+
messages: {
|
|
264
|
+
untyped:
|
|
265
|
+
"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.",
|
|
266
|
+
any: "`any` in e2e source. It turns the compiler off for the value a test asserts on. Type it, or narrow from `unknown`.",
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
create(context) {
|
|
270
|
+
if (!isE2eFile(context.filename || context.getFilename())) return {}
|
|
271
|
+
const checkParams = (node) => {
|
|
272
|
+
if (!isModuleLevel(node)) return
|
|
273
|
+
for (const parameter of node.params) {
|
|
274
|
+
const target = parameter.type === "AssignmentPattern" ? parameter.left : parameter
|
|
275
|
+
// A default value gives the parameter a type the compiler infers.
|
|
276
|
+
if (parameter.type === "AssignmentPattern" && parameter.right.type === "Literal") continue
|
|
277
|
+
if (target.type === "RestElement") {
|
|
278
|
+
if (!target.typeAnnotation) context.report({ node: target, messageId: "untyped" })
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
if (!target.typeAnnotation) context.report({ node: target, messageId: "untyped" })
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
FunctionDeclaration: checkParams,
|
|
286
|
+
ArrowFunctionExpression: checkParams,
|
|
287
|
+
FunctionExpression: checkParams,
|
|
288
|
+
TSAnyKeyword(node) {
|
|
289
|
+
context.report({ node, messageId: "any" })
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// -- E2E-7 -----------------------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
/** Every Playwright config declares exactly the three viewports. */
|
|
298
|
+
export const playwrightViewports = {
|
|
299
|
+
meta: {
|
|
300
|
+
type: "problem",
|
|
301
|
+
docs: { description: `playwright.config declares the viewports ${VIEWPORTS.join(", ")} and no others.` },
|
|
302
|
+
schema: [],
|
|
303
|
+
messages: {
|
|
304
|
+
missing:
|
|
305
|
+
"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.",
|
|
306
|
+
extra:
|
|
307
|
+
"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.",
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
create(context) {
|
|
311
|
+
if (!/^playwright\.config\.[cm]?[jt]s$/.test(baseName(context.filename || context.getFilename()))) return {}
|
|
312
|
+
const seen = new Map()
|
|
313
|
+
return {
|
|
314
|
+
Property(node) {
|
|
315
|
+
if (node.computed || node.key.type !== "Identifier" || node.key.name !== "viewport") return
|
|
316
|
+
if (node.value.type !== "ObjectExpression") return
|
|
317
|
+
const field = (name) => {
|
|
318
|
+
const property = node.value.properties.find((p) => p.type === "Property" && p.key.type === "Identifier" && p.key.name === name)
|
|
319
|
+
return property && property.value.type === "Literal" ? property.value.value : null
|
|
320
|
+
}
|
|
321
|
+
const width = field("width")
|
|
322
|
+
const height = field("height")
|
|
323
|
+
if (typeof width === "number" && typeof height === "number") seen.set(`${width}x${height}`, node)
|
|
324
|
+
},
|
|
325
|
+
"Program:exit"(program) {
|
|
326
|
+
for (const size of VIEWPORTS) if (!seen.has(size)) context.report({ node: program, messageId: "missing", data: { size } })
|
|
327
|
+
for (const [size, node] of seen) if (!VIEWPORTS.includes(size)) context.report({ node, messageId: "extra", data: { size } })
|
|
328
|
+
},
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The rules this law contributes to the plugin. */
|
|
334
|
+
export const rules = {
|
|
335
|
+
"e2e-spec-location": e2eSpecLocation,
|
|
336
|
+
"e2e-no-absolute-path": e2eNoAbsolutePath,
|
|
337
|
+
"e2e-no-docker": e2eNoDocker,
|
|
338
|
+
"e2e-no-cross-repo-write": e2eNoCrossRepoWrite,
|
|
339
|
+
"e2e-no-skip": e2eNoSkip,
|
|
340
|
+
"e2e-typed-helpers": e2eTypedHelpers,
|
|
341
|
+
"playwright-viewports": playwrightViewports,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Which tree these rules govern: the factory attaches them to `e2e/**`, not to `src/**`. */
|
|
345
|
+
export const scope = "e2e"
|
|
346
|
+
|
|
347
|
+
/** Every rule is an error. */
|
|
348
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/env-owner.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `env-owner.md` (HFS R49, `FE_ENV_OWNER`).
|
|
3
|
+
*
|
|
4
|
+
* TWO FACTS, ONE OWNER. The environment is read in exactly one place, `modules/config`, and what it
|
|
5
|
+
* reads has no default that stands in for a real deployment value. They are one law because they
|
|
6
|
+
* fail together: an app with nine copies of `process.env.API_URL ?? "http://localhost:3068"` had
|
|
7
|
+
* nine readers and nine silent defaults, so a production build with the variable missing talked to
|
|
8
|
+
* a developer's laptop and looked healthy.
|
|
9
|
+
*
|
|
10
|
+
* The rules cannot see whether `modules/config` throws when a variable is missing in production -
|
|
11
|
+
* that is a behaviour, and the module's own spec is where it is proven. What they hold is the
|
|
12
|
+
* shape that makes it provable: one reader, and no literal fallback next to it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { isConfigModule, isSpecFile } from "./lib/scope.mjs"
|
|
16
|
+
|
|
17
|
+
/** `process.env`, or `import.meta.env`. */
|
|
18
|
+
const isEnvObject = (node) => {
|
|
19
|
+
if (!node || node.type !== "MemberExpression" || node.computed) return false
|
|
20
|
+
const name = node.property && node.property.name
|
|
21
|
+
if (name !== "env") return false
|
|
22
|
+
const object = node.object
|
|
23
|
+
if (object.type === "Identifier" && object.name === "process") return true
|
|
24
|
+
return (
|
|
25
|
+
object.type === "MetaProperty" && object.meta.name === "import" && object.property.name === "meta"
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The variable name of `process.env.NAME` / `process.env["NAME"]`, or null. */
|
|
30
|
+
const envVariableName = (node) => {
|
|
31
|
+
if (!node || node.type !== "MemberExpression" || !isEnvObject(node.object)) return null
|
|
32
|
+
if (!node.computed && node.property.type === "Identifier") return node.property.name
|
|
33
|
+
if (node.computed && node.property.type === "Literal" && typeof node.property.value === "string") {
|
|
34
|
+
return node.property.value
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Static text of a literal or an expression-free template, else null. */
|
|
40
|
+
const staticText = (node) => {
|
|
41
|
+
if (!node) return null
|
|
42
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value
|
|
43
|
+
if (node.type === "TemplateLiteral") {
|
|
44
|
+
return node.quasis.map((quasi) => quasi.value.cooked ?? "").join("")
|
|
45
|
+
}
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** An absolute or protocol-relative URL: what a fallback must never carry. */
|
|
50
|
+
const URL_TEXT = /^(?:https?:)?\/\/\S+/i
|
|
51
|
+
|
|
52
|
+
/** Variable names whose value is a secret or an address: a default for one is a hole in a deployment. */
|
|
53
|
+
const SENSITIVE_NAME = /(?:SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE_?KEY|URL|URI|HOST|ENDPOINT|ORIGIN)$/i
|
|
54
|
+
|
|
55
|
+
// -- FE-ENV-1 --------------------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/** Only `modules/config` reads the environment. */
|
|
58
|
+
export const noEnvOutsideConfig = {
|
|
59
|
+
meta: {
|
|
60
|
+
type: "problem",
|
|
61
|
+
docs: { description: "`process.env` and `import.meta.env` are read only in `modules/config`." },
|
|
62
|
+
schema: [],
|
|
63
|
+
messages: {
|
|
64
|
+
env:
|
|
65
|
+
"This file reads the environment. Only `modules/config` may: it is the one place that can fail loudly when a production variable is missing. Read the value from the config module's typed export instead.",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
create(context) {
|
|
69
|
+
const filename = context.filename || context.getFilename()
|
|
70
|
+
if (isConfigModule(filename) || isSpecFile(filename)) return {}
|
|
71
|
+
return {
|
|
72
|
+
MemberExpression(node) {
|
|
73
|
+
if (isEnvObject(node)) context.report({ node, messageId: "env" })
|
|
74
|
+
},
|
|
75
|
+
VariableDeclarator(node) {
|
|
76
|
+
// `const { env } = process` reads the environment without the member expression.
|
|
77
|
+
if (node.id.type !== "ObjectPattern" || !node.init) return
|
|
78
|
+
if (node.init.type !== "Identifier" || node.init.name !== "process") return
|
|
79
|
+
const reads = node.id.properties.some(
|
|
80
|
+
(property) => property.type === "Property" && property.key.type === "Identifier" && property.key.name === "env",
|
|
81
|
+
)
|
|
82
|
+
if (reads) context.report({ node, messageId: "env" })
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// -- FE-ENV-2 --------------------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/** A fallback never stands in for a deployment address or secret. */
|
|
91
|
+
export const noHardcodedEndpointFallback = {
|
|
92
|
+
meta: {
|
|
93
|
+
type: "problem",
|
|
94
|
+
docs: { description: "No URL literal as a fallback, and no literal default for an address or secret variable." },
|
|
95
|
+
schema: [],
|
|
96
|
+
messages: {
|
|
97
|
+
url:
|
|
98
|
+
"A URL literal used as a fallback (`?? \"http://...\"`, `|| \"http://...\"` or a parameter default). A missing variable then silently points production at a developer's machine. Read the address from `modules/config`, which throws when it is absent.",
|
|
99
|
+
variable:
|
|
100
|
+
"`{{name}}` is an address or secret variable with a literal default. A default here makes a misconfigured deployment look healthy. Remove the default and let `modules/config` fail loudly when it is missing.",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
create(context) {
|
|
104
|
+
if (isSpecFile(context.filename || context.getFilename())) return {}
|
|
105
|
+
return {
|
|
106
|
+
LogicalExpression(node) {
|
|
107
|
+
if (node.operator !== "??" && node.operator !== "||") return
|
|
108
|
+
const text = staticText(node.right)
|
|
109
|
+
if (text === null) return
|
|
110
|
+
if (URL_TEXT.test(text)) {
|
|
111
|
+
context.report({ node: node.right, messageId: "url" })
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
const name = envVariableName(node.left)
|
|
115
|
+
if (name && text !== "" && SENSITIVE_NAME.test(name)) {
|
|
116
|
+
context.report({ node: node.right, messageId: "variable", data: { name } })
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
AssignmentPattern(node) {
|
|
120
|
+
const text = staticText(node.right)
|
|
121
|
+
if (text !== null && URL_TEXT.test(text)) context.report({ node: node.right, messageId: "url" })
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The rules this law contributes to the plugin. */
|
|
128
|
+
export const rules = {
|
|
129
|
+
"no-env-outside-config": noEnvOutsideConfig,
|
|
130
|
+
"no-hardcoded-endpoint-fallback": noHardcodedEndpointFallback,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Both are exact syntactic shapes, and both are errors. */
|
|
134
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/file-layout.mjs
CHANGED
|
@@ -12,14 +12,15 @@
|
|
|
12
12
|
* to rather than merely refusing.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
15
|
+
import { normalizePath } from "./lib/path.mjs"
|
|
17
16
|
|
|
18
17
|
/** Tiers whose folder holds exactly the two halves of one surface. */
|
|
19
|
-
|
|
18
|
+
// `features/` is the current root for route-facing surfaces (knowledge FE-FOLDER-1); `components/` is the
|
|
19
|
+
// earlier Academy layout, still accepted so existing trees stay valid.
|
|
20
|
+
const TWO_FILE_TIERS = /\/src\/(?:components|features)\/(pages|layouts)\/([^/]+)\/(.+)$/
|
|
20
21
|
|
|
21
22
|
/** Overlays carry a feature category between the tier and the surface. */
|
|
22
|
-
const TWO_FILE_TIERS_WITH_CATEGORY = /\/src\/components\/(overlays)\/[^/]+\/([^/]+)\/(.+)$/
|
|
23
|
+
const TWO_FILE_TIERS_WITH_CATEGORY = /\/src\/(?:components|features)\/(overlays)\/[^/]+\/([^/]+)\/(.+)$/
|
|
23
24
|
|
|
24
25
|
/** The two halves, and the twin test of each, are the whole of what a surface folder may hold. */
|
|
25
26
|
const ALLOWED_IN_SURFACE_FOLDER = /^(?:component|index|classNames)(?:\.spec)?\.tsx?$/
|
|
@@ -265,11 +266,11 @@ const ROUTE_TREE_TEST = /\.spec\.(?:tsx?|jsx?)$/
|
|
|
265
266
|
export const routeTreeHoldsRoutesOnly = {
|
|
266
267
|
meta: {
|
|
267
268
|
type: "problem",
|
|
268
|
-
docs: { description: "`src/app/**` holds framework route files only; a page owner lives in `components/pages/<Name
|
|
269
|
+
docs: { description: "`src/app/**` holds framework route files only; a page owner lives in `features/pages/<Name>/` (or the earlier `components/pages/<Name>/`)." },
|
|
269
270
|
schema: [],
|
|
270
271
|
messages: {
|
|
271
272
|
stray:
|
|
272
|
-
"`app/{{rest}}` is not a route file. A file under `app/` names which page renders at which URL - `page`, `layout`, `loading`, `error` and their siblings are the framework's slots, and `{{basename}}` is not one of them. This is a component, so it belongs where its siblings are: `
|
|
273
|
+
"`app/{{rest}}` is not a route file. A file under `app/` names which page renders at which URL - `page`, `layout`, `loading`, `error` and their siblings are the framework's slots, and `{{basename}}` is not one of them. This is a component, so it belongs where its siblings are: `features/pages/<Name>/` for a screen, `components/blocks/<category>/<Name>/` for a domain sentence. Left here it is the one place nobody looks, and the route has quietly become a second page.",
|
|
273
274
|
},
|
|
274
275
|
},
|
|
275
276
|
create(context) {
|
|
@@ -290,7 +291,7 @@ export const routeTreeHoldsRoutesOnly = {
|
|
|
290
291
|
}
|
|
291
292
|
|
|
292
293
|
/** The rules this law contributes to the plugin. */
|
|
293
|
-
const TIER_PATH = /\/(?:src\/components|packages\/[^/]+\/src|apps\/[^/]+\/src\/(?:components\/)?)\/(leaves|composites|branches|blocks|layouts|overlays|pages)\//
|
|
294
|
+
const TIER_PATH = /\/(?:src\/components|src\/features|packages\/[^/]+\/src|apps\/[^/]+\/src\/(?:components\/|features\/)?)\/(leaves|composites|branches|blocks|layouts|overlays|pages)\//
|
|
294
295
|
|
|
295
296
|
function propertyName(node) {
|
|
296
297
|
if (node.key?.type === "Identifier") return node.key.name
|
|
@@ -390,11 +391,103 @@ export const unitTestColocated = {
|
|
|
390
391
|
},
|
|
391
392
|
}
|
|
392
393
|
|
|
394
|
+
/**
|
|
395
|
+
* The component a route slot mounts takes the slot's own name.
|
|
396
|
+
*
|
|
397
|
+
* `page.tsx` default-exports `Page`, `layout.tsx` default-exports `Layout`, and so on through
|
|
398
|
+
* the component slots. The filename already tells the framework what the file is; the export
|
|
399
|
+
* name is what the reader and the search results get, and a slot that calls its component
|
|
400
|
+
* `LandingPage` or `AuthenticationRoute` invents a second name for a file that already has a
|
|
401
|
+
* fixed one. The page OWNER - the component that draws the screen - keeps its own name and
|
|
402
|
+
* lives in `components/pages/`; the slot is a shell that mounts it, and a shell named `Page`
|
|
403
|
+
* says exactly that.
|
|
404
|
+
*
|
|
405
|
+
* `default` is deliberately absent: a parallel-route default mirrors whichever page it stands
|
|
406
|
+
* in for, so its name is a decision rather than a constant. `route`, `providers`, `globals`
|
|
407
|
+
* and the asset slots export no component and are not here.
|
|
408
|
+
*/
|
|
409
|
+
const ROUTE_SLOT_NAME = {
|
|
410
|
+
page: "Page",
|
|
411
|
+
layout: "Layout",
|
|
412
|
+
template: "Template",
|
|
413
|
+
loading: "Loading",
|
|
414
|
+
error: "Error",
|
|
415
|
+
"global-error": "GlobalError",
|
|
416
|
+
"not-found": "NotFound",
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const ROUTE_SLOT_FILE = new RegExp(
|
|
420
|
+
`^(?:${Object.keys(ROUTE_SLOT_NAME).join("|")})(?:\\.[a-z-]+)?\\.tsx$`,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
export const routeSlotFixedName = {
|
|
424
|
+
meta: {
|
|
425
|
+
type: "problem",
|
|
426
|
+
docs: { description: "A route slot file default-exports a component named after the slot." },
|
|
427
|
+
schema: [],
|
|
428
|
+
messages: {
|
|
429
|
+
wrong:
|
|
430
|
+
"`{{basename}}` default-exports `{{actual}}`. A route slot takes the slot's own name - rename it `{{expected}}` and keep the drawing in the page owner under `features/pages/` (the slot is a shell that mounts it).",
|
|
431
|
+
anonymous:
|
|
432
|
+
"`{{basename}}` default-exports an anonymous component. A route slot takes the slot's own name - `const {{expected}} = () => <Owner />` then `export default {{expected}}`.",
|
|
433
|
+
missing:
|
|
434
|
+
"`{{basename}}` has no default export. A route slot's job is to mount its component - default-export `{{expected}}`.",
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
create(context) {
|
|
438
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
439
|
+
const hit = file.match(ROUTE_TREE)
|
|
440
|
+
if (!hit) return {}
|
|
441
|
+
const basename = hit[1].slice(hit[1].lastIndexOf("/") + 1)
|
|
442
|
+
if (ROUTE_TREE_TEST.test(basename)) return {}
|
|
443
|
+
const slot = basename.match(ROUTE_SLOT_FILE)
|
|
444
|
+
if (!slot) return {}
|
|
445
|
+
const expected = ROUTE_SLOT_NAME[slot[0].split(".")[0]]
|
|
446
|
+
let seen = false
|
|
447
|
+
const report = (node, actual) =>
|
|
448
|
+
context.report({
|
|
449
|
+
node,
|
|
450
|
+
messageId: actual === null ? "anonymous" : "wrong",
|
|
451
|
+
data: { basename, actual: actual ?? "(anonymous)", expected },
|
|
452
|
+
})
|
|
453
|
+
return {
|
|
454
|
+
ExportDefaultDeclaration(node) {
|
|
455
|
+
seen = true
|
|
456
|
+
const declaration = node.declaration
|
|
457
|
+
if (declaration.type === "Identifier") {
|
|
458
|
+
if (declaration.name !== expected) report(node, declaration.name)
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
if (
|
|
462
|
+
(declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration") &&
|
|
463
|
+
declaration.id
|
|
464
|
+
) {
|
|
465
|
+
if (declaration.id.name !== expected) report(node, declaration.id.name)
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
report(node, null)
|
|
469
|
+
},
|
|
470
|
+
ExportNamedDeclaration(node) {
|
|
471
|
+
for (const specifier of node.specifiers || []) {
|
|
472
|
+
if (specifier.exported?.name !== "default") continue
|
|
473
|
+
seen = true
|
|
474
|
+
const local = specifier.local?.name ?? null
|
|
475
|
+
if (local !== expected) report(node, local)
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
"Program:exit"(node) {
|
|
479
|
+
if (!seen) context.report({ node, messageId: "missing", data: { basename, expected } })
|
|
480
|
+
},
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
}
|
|
484
|
+
|
|
393
485
|
export const rules = {
|
|
394
486
|
"no-shell-tier": noShellTier,
|
|
395
487
|
"source-tier-marker-matches-folder": sourceTierMarkerMatchesFolder,
|
|
396
488
|
"surface-folder-two-files-only": surfaceFolderTwoFilesOnly,
|
|
397
489
|
"route-tree-holds-routes-only": routeTreeHoldsRoutesOnly,
|
|
490
|
+
"route-slot-fixed-name": routeSlotFixedName,
|
|
398
491
|
"no-helper-folder-in-components": noHelperFolderInComponents,
|
|
399
492
|
"export-matches-folder": exportMatchesFolder,
|
|
400
493
|
"no-runtime-namespace": noRuntimeNamespace,
|