@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/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"]))
|
package/hooks-folder.mjs
ADDED
|
@@ -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"]))
|