@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
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The rule that holds `hooks-folder.md` (HFS R56 `FE_HOOKS_ARE_HOOKS`, lint half).
3
+ *
4
+ * `hooks/` MEANS REACT HOOKS. One app kept its server readers there - fetch, map, no React - and
5
+ * had a single real hook; another had a filename-equals-export rule that gave a helper shared by
6
+ * sixteen hooks nowhere to live, so it was copied sixteen times. Both came from the standard not
7
+ * saying where the non-hook code goes. It now does:
8
+ *
9
+ * hooks/<domain>/use<Name>.ts one hook per file
10
+ * hooks/<domain>/<domain>.shared.ts one file for the domain's non-hook helpers (key builders...)
11
+ * modules/api/<domain>/read-*.ts server readers (wrapped in `cache()`)
12
+ *
13
+ * The rule holds the shape of one file at a time. Whether a folder holds exactly one `.shared.ts`
14
+ * and whether a helper name repeats across files is a repository fact, and belongs to the
15
+ * architecture machine that sees the whole tree.
16
+ */
17
+
18
+ import { baseName, isSpecFile } from "./lib/scope.mjs"
19
+ import { normalizePath } from "./lib/path.mjs"
20
+
21
+ /** The path below the first `hooks/` folder, as segments, or null when the file is not in one. */
22
+ const belowHooks = (file) => {
23
+ const match = /\/src\/hooks\/(.+)$/.exec(file) || /\/hooks\/(.+)$/.exec(file)
24
+ return match ? match[1].split("/") : null
25
+ }
26
+
27
+ /** Names that mark a hook. */
28
+ const isHookName = (name) => /^use[A-Z0-9]/.test(name)
29
+
30
+ /** Modules that only run on the server: a reader, not a hook. */
31
+ const SERVER_ONLY = /^(?:server-only|next\/headers)$/
32
+
33
+ /** The exported names a declaration introduces. */
34
+ const exportedNames = (node) => {
35
+ if (node.type === "ExportDefaultDeclaration") {
36
+ const declaration = node.declaration
37
+ return [declaration && declaration.id ? declaration.id.name : "default"]
38
+ }
39
+ const declaration = node.declaration
40
+ if (!declaration) return node.specifiers.map((specifier) => specifier.exported.name)
41
+ if (declaration.type === "FunctionDeclaration" && declaration.id) return [declaration.id.name]
42
+ if (declaration.type === "VariableDeclaration") {
43
+ return declaration.declarations.filter((d) => d.id.type === "Identifier").map((d) => d.id.name)
44
+ }
45
+ return []
46
+ }
47
+
48
+ /** `hooks/` holds React hooks and one shared helper file per domain. */
49
+ export const hooksFolderHoldsHooksOnly = {
50
+ meta: {
51
+ type: "problem",
52
+ docs: { description: "`hooks/<domain>/` holds `use*.ts` hooks and one `<domain>.shared.ts`; nothing else." },
53
+ schema: [],
54
+ messages: {
55
+ domain:
56
+ "A file directly in `hooks/`. Hooks live in `hooks/<domain>/use<Name>.ts`; the domain folder is what keeps a shared helper next to the hooks that use it.",
57
+ notHook:
58
+ "`{{name}}` is in `hooks/` but is not a React hook. Hooks are `use<Name>.ts`; the domain's non-hook helpers go in `<domain>.shared.ts`; a server reader goes in `modules/api/<domain>/read-*.ts`.",
59
+ export:
60
+ "`{{name}}` is exported from a hook file but is not a hook. A hook file exports the hook (a function named `use...`); a helper belongs in `<domain>.shared.ts`.",
61
+ many:
62
+ "This file exports {{count}} hooks. One hook per file keeps a hook findable by its name and keeps a change to one from re-running the tests of the others.",
63
+ sharedHook:
64
+ "`{{name}}` is a hook exported from the shared helper file. That file holds non-hook helpers; a hook there is a hook nobody will find. Give it its own `use<Name>.ts`.",
65
+ server:
66
+ "`{{source}}` marks server-only code, which is a reader, not a hook. Server readers live in `modules/api/<domain>/read-*.ts`, wrapped in React's `cache()`.",
67
+ },
68
+ },
69
+ create(context) {
70
+ const file = normalizePath(context.filename || context.getFilename())
71
+ const segments = belowHooks(file)
72
+ if (!segments) return {}
73
+ const name = baseName(file)
74
+ const spec = isSpecFile(file)
75
+ const subject = spec ? name.replace(/\.(?:test|spec)\.(?:ts|tsx)$/, ".ts") : name
76
+ const domain = segments.length >= 2 ? segments[segments.length - 2] : null
77
+ const isHookFile = /^use[A-Z0-9]\w*\.ts$/.test(subject)
78
+ const isSharedFile = domain !== null && subject === `${domain}.shared.ts`
79
+
80
+ return {
81
+ Program(program) {
82
+ if (segments.length < 2) return context.report({ node: program, messageId: "domain" })
83
+ if (!isHookFile && !isSharedFile) context.report({ node: program, messageId: "notHook", data: { name } })
84
+ },
85
+ ImportDeclaration(node) {
86
+ if (SERVER_ONLY.test(String(node.source.value)) && !spec) {
87
+ context.report({ node, messageId: "server", data: { source: node.source.value } })
88
+ }
89
+ },
90
+ "Program:exit"(program) {
91
+ if (spec || (!isHookFile && !isSharedFile)) return
92
+ const exported = program.body
93
+ .filter((node) => node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration")
94
+ .flatMap((node) => exportedNames(node).map((exportedName) => ({ node, name: exportedName })))
95
+ if (isSharedFile) {
96
+ for (const item of exported) {
97
+ if (isHookName(item.name)) context.report({ node: item.node, messageId: "sharedHook", data: { name: item.name } })
98
+ }
99
+ return
100
+ }
101
+ const hooks = exported.filter((item) => isHookName(item.name))
102
+ for (const item of exported) {
103
+ if (!isHookName(item.name) && item.name !== "default") {
104
+ context.report({ node: item.node, messageId: "export", data: { name: item.name } })
105
+ }
106
+ }
107
+ if (hooks.length > 1) context.report({ node: hooks[1].node, messageId: "many", data: { count: hooks.length } })
108
+ },
109
+ }
110
+ },
111
+ }
112
+
113
+ /** The rules this law contributes to the plugin. */
114
+ export const rules = {
115
+ "hooks-folder-holds-hooks-only": hooksFolderHoldsHooksOnly,
116
+ }
117
+
118
+ /** Every rule is an error. */
119
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/hygiene.mjs ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * The rules that hold `hygiene.md`: the small runtime habits that pass every type check and fail in a
3
+ * reader's browser (HFS R65 `FE_SIZE_AND_STATE_BUDGET` for the timer, catch and console rules, R50
4
+ * `FE_TRANSPORT_OWNER` for the fetch rule).
5
+ *
6
+ * FOUR HABITS, ONE CAUSE: work started by a component that nobody owns the end of.
7
+ * - a timer started outside an effect, or in one that never clears it, fires after the component is
8
+ * gone and sets state on nothing;
9
+ * - a fetch started in an effect has no cache, no dedupe, no error state and no cancel, which is
10
+ * what SWR and the server readers already give for free (R50: client reads use SWR);
11
+ * - an empty `catch` turns a failure into a blank screen with no trace;
12
+ * - `console.*` is a log nobody reads, in a place no log pipeline sees.
13
+ */
14
+
15
+ import { calleeName, effectCallback, isEffectCall, isFunction, walk } from "./lib/ast.mjs"
16
+ import { isSpecFile } from "./lib/scope.mjs"
17
+ import { normalizePath } from "./lib/path.mjs"
18
+
19
+ /** Product source outside specs. */
20
+ const isGoverned = (filename) => {
21
+ const file = normalizePath(filename)
22
+ return file.includes("/src/") && !isSpecFile(file)
23
+ }
24
+
25
+ /** The timer functions, bare or on the global. */
26
+ const TIMERS = new Set(["setTimeout", "setInterval", "window.setTimeout", "window.setInterval", "globalThis.setTimeout", "globalThis.setInterval"])
27
+
28
+ // -- HYGIENE-1 -------------------------------------------------------------------------------------
29
+
30
+ /** The effect callback that encloses `node`, crossing any function boundary, or null. */
31
+ const enclosingEffect = (node) => {
32
+ for (let current = node.parent; current; current = current.parent) {
33
+ if (isFunction(current) && current.parent?.type === "CallExpression" && isEffectCall(current.parent) && effectCallback(current.parent) === current) {
34
+ return current
35
+ }
36
+ }
37
+ return null
38
+ }
39
+
40
+ /** The value an effect callback returns at its own top level (the cleanup), or null. */
41
+ const cleanupOf = (effect) => {
42
+ if (effect.body.type !== "BlockStatement") return null
43
+ const last = [...effect.body.body].reverse().find((statement) => statement.type === "ReturnStatement")
44
+ return last?.argument ?? null
45
+ }
46
+
47
+ const CLEARERS = new Set(["clearTimeout", "clearInterval", "window.clearTimeout", "window.clearInterval", "globalThis.clearTimeout", "globalThis.clearInterval"])
48
+
49
+ /** The identifier a timer call's handle is stored in (`const id = ...` or `id = ...`), or null. */
50
+ const handleOf = (call) => {
51
+ const parent = call.parent
52
+ if (parent?.type === "VariableDeclarator" && parent.init === call && parent.id.type === "Identifier") return parent.id.name
53
+ if (parent?.type === "AssignmentExpression" && parent.right === call && parent.left.type === "Identifier") return parent.left.name
54
+ return null
55
+ }
56
+
57
+ /** The function a function directly returns (arrow expression body, or its last top-level `return`), or null. */
58
+ const returnedFunction = (fn) => {
59
+ if (fn.body.type !== "BlockStatement") return isFunction(fn.body) ? fn.body : null
60
+ const last = [...fn.body.body].reverse().find((statement) => statement.type === "ReturnStatement")
61
+ return last?.argument && isFunction(last.argument) ? last.argument : null
62
+ }
63
+
64
+ /**
65
+ * True when a function enclosing the timer call returns a closure that clears the same handle: the
66
+ * `subscribe` of `useSyncExternalStore`, or any owner that hands back its own unsubscribe.
67
+ */
68
+ const clearedByReturnedCleanup = (call, source) => {
69
+ const handle = handleOf(call)
70
+ if (!handle) return false
71
+ for (let current = call.parent; current; current = current.parent) {
72
+ if (current.type !== "FunctionExpression" && current.type !== "ArrowFunctionExpression" && current.type !== "FunctionDeclaration") continue
73
+ const cleanup = returnedFunction(current)
74
+ if (!cleanup) continue
75
+ let clears = false
76
+ walk(source, cleanup, (node) => {
77
+ if (node.type === "CallExpression" && CLEARERS.has(calleeName(node.callee)) && node.arguments[0]?.type === "Identifier" && node.arguments[0].name === handle) clears = true
78
+ })
79
+ if (clears) return true
80
+ }
81
+ return false
82
+ }
83
+
84
+ /** A timer belongs in an effect that clears it, or in a function whose returned cleanup clears it. */
85
+ export const timerNeedsEffectCleanup = {
86
+ meta: {
87
+ type: "problem",
88
+ docs: { description: "`setTimeout` and `setInterval` live in an effect whose cleanup clears them." },
89
+ schema: [],
90
+ messages: {
91
+ orphan:
92
+ "`{{timer}}` is started outside an effect. Nothing ends it when the component unmounts, so it fires later and sets state on a component that is gone. Start it in a `useEffect` and clear it in the cleanup, or move it into a hook that does.",
93
+ noCleanup:
94
+ "`{{timer}}` is started in an effect that returns no cleanup which clears it. On unmount or on the next run the old timer is still pending. Return `() => clearTimeout(id)` (or `clearInterval`).",
95
+ },
96
+ },
97
+ create(context) {
98
+ const filename = context.filename || context.getFilename()
99
+ // Non-React modules (the API client's timeout, the theme bootstrap) own their timers and clear them in `finally`.
100
+ if (!isGoverned(filename) || /\/modules\//.test(normalizePath(filename))) return {}
101
+ const source = context.sourceCode ?? context.getSourceCode()
102
+ return {
103
+ CallExpression(node) {
104
+ const name = calleeName(node.callee)
105
+ if (!name || !TIMERS.has(name)) return
106
+ if (clearedByReturnedCleanup(node, source)) return
107
+ const effect = enclosingEffect(node)
108
+ if (!effect) return context.report({ node, messageId: "orphan", data: { timer: name } })
109
+ const cleanup = cleanupOf(effect)
110
+ const clears = cleanup && /\bclear(?:Timeout|Interval)\b/.test(source.getText(cleanup))
111
+ if (!clears) context.report({ node, messageId: "noCleanup", data: { timer: name } })
112
+ },
113
+ }
114
+ },
115
+ }
116
+
117
+ // -- HYGIENE-2 -------------------------------------------------------------------------------------
118
+
119
+ /** A fetch in an effect: `await`, `fetch(...)`, `.then(...)`, outside the cleanup function. */
120
+ const fetchesIn = (sourceCode, effect) => {
121
+ const cleanup = cleanupOf(effect)
122
+ let found = null
123
+ walk(sourceCode, effect.body, (node) => {
124
+ if (found) return false
125
+ if (node === cleanup) return false
126
+ if (node.type === "AwaitExpression") found = node
127
+ else if (node.type === "CallExpression") {
128
+ const name = calleeName(node.callee)
129
+ if (name === "fetch" || name === "window.fetch" || (node.callee.type === "MemberExpression" && node.callee.property.type === "Identifier" && node.callee.property.name === "then")) {
130
+ found = node
131
+ }
132
+ }
133
+ return undefined
134
+ })
135
+ return found
136
+ }
137
+
138
+ /** No data loading inside `useEffect`; SWR on the client, a reader on the server. */
139
+ export const noDataFetchInEffect = {
140
+ meta: {
141
+ type: "problem",
142
+ docs: { description: "No `await`, `fetch` or `.then` inside a `useEffect`; read through SWR or a server reader." },
143
+ schema: [],
144
+ messages: {
145
+ fetch:
146
+ "This effect loads data (`await`, `fetch` or `.then`). An effect has no cache, no request dedupe, no error or loading state and no cancel, so two components asking for the same thing fetch twice and a slow first answer overwrites a fast second one. Client reads use SWR calling the app client (`hooks/`); reads a route needs go through a server reader (`modules/api/<domain>/read-*.ts`).",
147
+ },
148
+ },
149
+ create(context) {
150
+ if (!isGoverned(context.filename || context.getFilename())) return {}
151
+ const source = context.sourceCode ?? context.getSourceCode()
152
+ return {
153
+ CallExpression(node) {
154
+ if (!isEffectCall(node)) return
155
+ const effect = effectCallback(node)
156
+ if (!effect) return
157
+ const found = fetchesIn(source, effect)
158
+ if (found) context.report({ node: found, messageId: "fetch" })
159
+ },
160
+ }
161
+ },
162
+ }
163
+
164
+ // -- HYGIENE-3 -------------------------------------------------------------------------------------
165
+
166
+ /** A `catch` block with nothing in it, or a promise `.catch` handler with nothing in it. */
167
+ export const noEmptyCatch = {
168
+ meta: {
169
+ type: "problem",
170
+ docs: { description: "A `catch` that does nothing hides the failure; handle it or return a typed outcome." },
171
+ schema: [],
172
+ messages: {
173
+ empty:
174
+ "This `catch` does nothing. The failure disappears: no message for the reader, no trace for the team. Return a typed outcome that carries the cause, show the error state, or rethrow.",
175
+ promise:
176
+ "This `.catch` handler does nothing, so a rejected promise disappears. Turn it into a typed outcome the caller renders, or let it propagate.",
177
+ },
178
+ },
179
+ create(context) {
180
+ if (!isGoverned(context.filename || context.getFilename())) return {}
181
+ return {
182
+ CatchClause(node) {
183
+ if (node.body.body.length === 0) context.report({ node, messageId: "empty" })
184
+ },
185
+ CallExpression(node) {
186
+ const callee = node.callee
187
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier" || callee.property.name !== "catch") return
188
+ const handler = node.arguments[0]
189
+ if (!handler || !isFunction(handler)) return
190
+ const empty =
191
+ (handler.body.type === "BlockStatement" && handler.body.body.length === 0) ||
192
+ (handler.body.type === "Identifier" && handler.body.name === "undefined") ||
193
+ (handler.body.type === "Literal" && handler.body.value === null)
194
+ if (empty) context.report({ node: handler, messageId: "promise" })
195
+ },
196
+ }
197
+ },
198
+ }
199
+
200
+ // -- HYGIENE-4 -------------------------------------------------------------------------------------
201
+
202
+ /** No `console.*` in product source. */
203
+ export const noConsole = {
204
+ meta: {
205
+ type: "problem",
206
+ docs: { description: "No `console.*` call in product source." },
207
+ schema: [],
208
+ messages: {
209
+ console:
210
+ "`console.{{method}}` in product source. It reaches no log pipeline, leaks internals into a reader's devtools and hides the real handling. Return a typed outcome, render the error state, or let the error reach `error.tsx`.",
211
+ },
212
+ },
213
+ create(context) {
214
+ if (!isGoverned(context.filename || context.getFilename())) return {}
215
+ return {
216
+ CallExpression(node) {
217
+ const callee = node.callee
218
+ if (callee.type !== "MemberExpression" || callee.object.type !== "Identifier" || callee.object.name !== "console") return
219
+ const method = callee.property.type === "Identifier" ? callee.property.name : "*"
220
+ context.report({ node, messageId: "console", data: { method } })
221
+ },
222
+ }
223
+ },
224
+ }
225
+
226
+ /** The rules this law contributes to the plugin. */
227
+ export const rules = {
228
+ "timer-needs-effect-cleanup": timerNeedsEffectCleanup,
229
+ "no-data-fetch-in-effect": noDataFetchInEffect,
230
+ "no-empty-catch": noEmptyCatch,
231
+ "no-console": noConsole,
232
+ }
233
+
234
+ /** Every rule is an error. */
235
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))