@starci/eslint-canon-fe 4.0.0 → 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 (52) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +34 -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 +1 -2
  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 +290 -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 +8 -4
  43. package/served-locale.mjs +160 -160
  44. package/shape-slot.mjs +1 -2
  45. package/size-and-state-budget.mjs +196 -0
  46. package/spec-quality.mjs +239 -0
  47. package/the-split.mjs +133 -134
  48. package/tokens.mjs +267 -268
  49. package/translation.mjs +262 -122
  50. package/transport.mjs +404 -0
  51. package/type-safety.mjs +156 -74
  52. 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,8 +12,7 @@
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
18
  // `features/` is the current root for route-facing surfaces (knowledge FE-FOLDER-1); `components/` is the
package/formatting.mjs ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The rule that holds `formatting.md` (HFS R59 `FE_I18N_PLACEMENT`, sub-check `FE_I18N_FORMATTER`).
3
+ *
4
+ * THE LOCALE HAS ONE OWNER. `next-intl` knows the reader's locale and time zone and hands them to
5
+ * `useFormatter()` / `getFormatter()`. Every other way of turning a number or a date into text either
6
+ * guesses the locale (`toLocaleString()` with no argument uses the SERVER's on the server and the
7
+ * browser's on the client, which is the hydration mismatch that makes a price flicker), hard-codes
8
+ * one (`"en-US"` in a Vietnamese product), or builds a private formatter that ignores the app's
9
+ * currency and date styles. Money and dates are the two places a wrong format is a wrong fact.
10
+ *
11
+ * WHAT IS REFUSED. `toLocaleString`, `toLocaleDateString`, `toLocaleTimeString`; `new Intl.*Format`;
12
+ * `toFixed` when the result is shown (inside JSX or a template); a currency symbol glued to a
13
+ * substitution in a template; and the date libraries (`moment`, `dayjs`, `date-fns`, `luxon`).
14
+ * `modules/i18n/**` is exempt: it is where the formatters are configured.
15
+ */
16
+
17
+ import { isSpecFile } from "./lib/scope.mjs"
18
+ import { normalizePath } from "./lib/path.mjs"
19
+
20
+ /** The locale-sensitive `Date` and `Number` methods. */
21
+ const LOCALE_METHODS = new Set(["toLocaleString", "toLocaleDateString", "toLocaleTimeString"])
22
+
23
+ /** The `Intl` formatters a product must not build itself. */
24
+ const INTL_FORMATTERS = new Set(["NumberFormat", "DateTimeFormat", "RelativeTimeFormat", "ListFormat", "PluralRules"])
25
+
26
+ /** Date libraries that format on their own locale. */
27
+ const DATE_LIBRARIES = /^(?:moment(?:-timezone)?|dayjs|date-fns(?:\/.+)?|luxon)$/
28
+
29
+ /** A currency mark or code beside a substitution. */
30
+ const CURRENCY = /[₫€£¥]|\b(?:VND|USD|EUR)\b/
31
+
32
+ /** True when `node` is displayed: it sits in a JSX expression or a template literal. */
33
+ const isDisplayed = (node) => {
34
+ for (let current = node.parent; current; current = current.parent) {
35
+ if (current.type === "JSXExpressionContainer" || current.type === "TemplateLiteral") return true
36
+ if (current.type === "ArrowFunctionExpression" || current.type === "FunctionExpression" || current.type === "FunctionDeclaration") return false
37
+ }
38
+ return false
39
+ }
40
+
41
+ /** Numbers, money and dates are formatted by the `next-intl` formatter. */
42
+ export const useIntlFormatter = {
43
+ meta: {
44
+ type: "problem",
45
+ docs: { description: "Format numbers, money and dates with `useFormatter` / `getFormatter` from `next-intl`." },
46
+ schema: [],
47
+ messages: {
48
+ method:
49
+ "`{{name}}` formats with a locale this file does not own: with no argument it is the server's on the server and the browser's in the browser (a price that flickers on hydration), with a string it is one language for every reader. Use `useFormatter().number(...)` / `.dateTime(...)` (or `getFormatter()` on the server).",
50
+ intl:
51
+ "`new Intl.{{name}}` builds a private formatter that ignores the app's locale, time zone and named formats. Use the `next-intl` formatter, and put a shared format in `modules/i18n` once.",
52
+ fixed:
53
+ "`.toFixed()` in displayed text writes a number with a dot decimal separator and no grouping to every reader. Use `useFormatter().number(value, { maximumFractionDigits })`.",
54
+ currency:
55
+ "A currency mark glued to a substitution in a template writes the symbol on the wrong side for half of the locales and skips grouping. Use `useFormatter().number(value, { style: \"currency\", currency })`.",
56
+ library:
57
+ "`{{name}}` formats dates on its own locale data, next to `next-intl`'s. One library decides the language of a date: use the `next-intl` formatter (`dateTime`, `relativeTime`).",
58
+ },
59
+ },
60
+ create(context) {
61
+ const file = normalizePath(context.filename || context.getFilename())
62
+ if (isSpecFile(file) || /\/modules\/i18n\//.test(file)) return {}
63
+ return {
64
+ ImportDeclaration(node) {
65
+ const source = String(node.source.value)
66
+ if (DATE_LIBRARIES.test(source)) context.report({ node, messageId: "library", data: { name: source } })
67
+ },
68
+ CallExpression(node) {
69
+ const callee = node.callee
70
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
71
+ const name = callee.property.name
72
+ if (LOCALE_METHODS.has(name)) context.report({ node, messageId: "method", data: { name } })
73
+ else if (name === "toFixed" && isDisplayed(node)) context.report({ node, messageId: "fixed" })
74
+ },
75
+ NewExpression(node) {
76
+ const callee = node.callee
77
+ if (
78
+ callee.type === "MemberExpression" &&
79
+ callee.object.type === "Identifier" &&
80
+ callee.object.name === "Intl" &&
81
+ callee.property.type === "Identifier" &&
82
+ INTL_FORMATTERS.has(callee.property.name)
83
+ ) {
84
+ context.report({ node, messageId: "intl", data: { name: callee.property.name } })
85
+ }
86
+ },
87
+ TemplateLiteral(node) {
88
+ if (node.expressions.length === 0) return
89
+ const glued = node.quasis.some((quasi, index) => {
90
+ const text = quasi.value.cooked ?? ""
91
+ if (index < node.expressions.length && text.endsWith("$")) return true
92
+ return CURRENCY.test(text)
93
+ })
94
+ if (glued) context.report({ node, messageId: "currency" })
95
+ },
96
+ }
97
+ },
98
+ }
99
+
100
+ /** The rules this law contributes to the plugin. */
101
+ export const rules = {
102
+ "use-intl-formatter": useIntlFormatter,
103
+ }
104
+
105
+ /** Every rule is an error. */
106
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))