@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +41 -16
  3. package/brand-values.mjs +174 -0
  4. package/class-names.mjs +1 -1
  5. package/client-boundary.mjs +199 -0
  6. package/comments.mjs +144 -190
  7. package/docs/brand-values.md +30 -0
  8. package/docs/client-boundary.md +99 -0
  9. package/docs/e2e-shape.md +161 -0
  10. package/docs/env-owner.md +52 -0
  11. package/docs/formatting.md +30 -0
  12. package/docs/hooks-folder.md +29 -0
  13. package/docs/hygiene.md +111 -0
  14. package/docs/lint-escape-hatch.md +32 -0
  15. package/docs/lists.md +74 -0
  16. package/docs/native-controls.md +73 -0
  17. package/docs/next-conventions.md +206 -0
  18. package/docs/size-and-state-budget.md +73 -0
  19. package/docs/spec-quality.md +139 -0
  20. package/docs/translation.md +50 -0
  21. package/docs/transport.md +168 -0
  22. package/docs/type-safety.md +95 -0
  23. package/e2e-shape.mjs +348 -0
  24. package/env-owner.mjs +134 -0
  25. package/file-layout.mjs +100 -7
  26. package/formatting.mjs +106 -0
  27. package/hooks-folder.mjs +119 -0
  28. package/hygiene.mjs +235 -0
  29. package/icon.mjs +210 -211
  30. package/index.mjs +292 -131
  31. package/lib/ast.mjs +94 -0
  32. package/lib/path.mjs +2 -0
  33. package/lib/scope.mjs +42 -0
  34. package/lib/why.mjs +304 -0
  35. package/lint-adoption.mjs +15 -52
  36. package/lint-escape-hatch.mjs +91 -56
  37. package/lists.mjs +197 -0
  38. package/loading.mjs +163 -164
  39. package/naming.mjs +143 -143
  40. package/native-controls.mjs +112 -0
  41. package/next-conventions.mjs +346 -0
  42. package/package.json +7 -3
  43. package/props-and-slots.mjs +4 -2
  44. package/served-locale.mjs +160 -160
  45. package/shape-slot.mjs +339 -0
  46. package/size-and-state-budget.mjs +196 -0
  47. package/spec-quality.mjs +239 -0
  48. package/the-split.mjs +133 -134
  49. package/tokens.mjs +267 -268
  50. package/translation.mjs +262 -122
  51. package/transport.mjs +404 -0
  52. package/type-safety.mjs +156 -74
  53. 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
- /** Forward-slash form of a filename, so Windows paths compare like every other path. */
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
- const TWO_FILE_TIERS = /\/src\/components\/(pages|layouts)\/([^/]+)\/(.+)$/
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: `components/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
+ "`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,