@starci/eslint-canon-fe 4.0.0 → 6.0.0
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 +111 -0
- package/README.md +33 -16
- package/brand-values.mjs +172 -0
- package/class-names.mjs +9 -9
- package/client-boundary.mjs +287 -0
- package/comments.mjs +137 -190
- package/docs/brand-values.md +30 -0
- package/docs/client-boundary.md +124 -0
- package/docs/e2e-shape.md +161 -0
- package/docs/env-owner.md +52 -0
- package/docs/formatting.md +30 -0
- package/docs/grammar-boundary.md +33 -0
- package/docs/hooks-folder.md +29 -0
- package/docs/hygiene.md +122 -0
- package/docs/lint-escape-hatch.md +34 -0
- package/docs/lists.md +74 -0
- package/docs/native-controls.md +73 -0
- package/docs/next-conventions.md +232 -0
- package/docs/size-and-state-budget.md +73 -0
- package/docs/size-growth.md +29 -0
- package/docs/spec-quality.md +147 -0
- package/docs/status-colors.md +32 -0
- package/docs/translation.md +54 -0
- package/docs/transport.md +195 -0
- package/docs/type-safety.md +95 -0
- package/e2e-shape.mjs +345 -0
- package/env-owner.mjs +134 -0
- package/file-layout.mjs +105 -104
- package/formatting.mjs +104 -0
- package/grammar-boundary.mjs +85 -9
- package/hooks-folder.mjs +120 -0
- package/hygiene.mjs +513 -0
- package/icon.mjs +212 -212
- package/index.mjs +243 -142
- package/landmark.mjs +0 -11
- package/lib/ast.mjs +94 -0
- package/lib/bindings.mjs +144 -0
- package/lib/config.mjs +79 -0
- package/lib/hfs.mjs +48 -0
- package/lib/next.mjs +6 -0
- package/lib/path.mjs +2 -0
- package/lib/platform.mjs +66 -0
- package/lib/react.mjs +28 -0
- package/lib/scope.mjs +142 -0
- package/lib/status-tones.generated.mjs +9 -0
- package/lib/types.mjs +63 -0
- package/lib/why.mjs +334 -0
- package/lint-escape-hatch.mjs +93 -56
- package/lists.mjs +197 -0
- package/loading.mjs +156 -164
- package/naming.mjs +149 -143
- package/native-controls.mjs +112 -0
- package/next-conventions.mjs +430 -0
- package/package.json +15 -6
- package/props-and-slots.mjs +19 -13
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/slots.yaml +1072 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-allows.mjs +42 -0
- package/runtime/scripts/lib/hfs-slots.mjs +722 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/next-contract.mjs +14 -0
- package/runtime/scripts/lib/path-key.mjs +17 -0
- package/runtime/scripts/lib/recorded-lines.mjs +27 -0
- package/served-locale.mjs +174 -160
- package/shape-slot.mjs +22 -21
- package/size-and-state-budget.mjs +214 -0
- package/size-growth.mjs +58 -0
- package/spec-quality.mjs +318 -0
- package/status-colors.mjs +139 -0
- package/the-split.mjs +133 -134
- package/tokens.mjs +259 -268
- package/translation.mjs +295 -122
- package/transport.mjs +752 -0
- package/type-safety.mjs +147 -74
- package/typography.mjs +78 -83
- package/vendor-boundary.mjs +5 -27
- package/lint-adoption.mjs +0 -116
package/hooks-folder.mjs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
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 { hfsOf } from "./lib/hfs.mjs"
|
|
19
|
+
import { baseName, classOf, fileOf, inSlot, isSpecFile } from "./lib/scope.mjs"
|
|
20
|
+
|
|
21
|
+
/** Names that mark a hook. */
|
|
22
|
+
const isHookName = (name) => /^use[A-Z0-9]/.test(name)
|
|
23
|
+
|
|
24
|
+
/** Modules that only run on the server: a reader, not a hook. */
|
|
25
|
+
const SERVER_ONLY = /^(?:server-only|next\/headers)$/
|
|
26
|
+
|
|
27
|
+
/** The exported names a declaration introduces. */
|
|
28
|
+
const exportedNames = (node) => {
|
|
29
|
+
if (node.type === "ExportDefaultDeclaration") {
|
|
30
|
+
const declaration = node.declaration
|
|
31
|
+
return [declaration && declaration.id ? declaration.id.name : "default"]
|
|
32
|
+
}
|
|
33
|
+
const declaration = node.declaration
|
|
34
|
+
if (!declaration) return node.specifiers.map((specifier) => specifier.exported.name)
|
|
35
|
+
if (declaration.type === "FunctionDeclaration" && declaration.id) return [declaration.id.name]
|
|
36
|
+
if (declaration.type === "VariableDeclaration") {
|
|
37
|
+
return declaration.declarations.filter((d) => d.id.type === "Identifier").map((d) => d.id.name)
|
|
38
|
+
}
|
|
39
|
+
return []
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `hooks/` holds React hooks and one shared helper file per domain. */
|
|
43
|
+
export const hooksFolderHoldsHooksOnly = {
|
|
44
|
+
meta: {
|
|
45
|
+
type: "problem",
|
|
46
|
+
docs: { description: "`hooks/<domain>/` holds `use*.ts` hooks and one `<domain>.shared.ts`; nothing else." },
|
|
47
|
+
schema: [],
|
|
48
|
+
messages: {
|
|
49
|
+
domain:
|
|
50
|
+
"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.",
|
|
51
|
+
notHook:
|
|
52
|
+
"`{{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`.",
|
|
53
|
+
export:
|
|
54
|
+
"`{{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`.",
|
|
55
|
+
many:
|
|
56
|
+
"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.",
|
|
57
|
+
sharedHook:
|
|
58
|
+
"`{{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`.",
|
|
59
|
+
server:
|
|
60
|
+
"`{{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()`.",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
create(context) {
|
|
64
|
+
// Slot `fe.hooks` owns `apps/<app>/src/hooks/<domain>/`; a file no slot puts there is not judged here.
|
|
65
|
+
if (!inSlot(context, "fe.hooks")) return {}
|
|
66
|
+
const file = fileOf(context)
|
|
67
|
+
const found = classOf(context)
|
|
68
|
+
// A file directly in `hooks/` is its own root: the slot binds the file name as the `domain`.
|
|
69
|
+
const directlyInHooks = hfsOf(context).relative(file) === found.root
|
|
70
|
+
const name = baseName(file)
|
|
71
|
+
const spec = isSpecFile(file)
|
|
72
|
+
const subject = spec ? name.replace(/\.(?:test|spec)\.(?:ts|tsx)$/, ".ts") : name
|
|
73
|
+
const domain = found.bindings.domain
|
|
74
|
+
const isHookFile = /^use[A-Z0-9]\w*\.ts$/.test(subject)
|
|
75
|
+
// The slot names the domain's entry (`index.ts`, role `entry`) and its shared helper file (`<domain>.shared.ts`, role `shared`).
|
|
76
|
+
// Both sit directly in the domain folder: a nested `x/index.ts` is a file the slot does not name.
|
|
77
|
+
const directlyInDomain = hfsOf(context).relative(file) === `${found.root}/${name}`
|
|
78
|
+
const isEntryFile = directlyInDomain && (found.role === "entry" || (spec && subject === "index.ts"))
|
|
79
|
+
const isSharedFile = directlyInDomain && (found.role === "shared" || (spec && subject === `${domain}.shared.ts`))
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
Program(program) {
|
|
83
|
+
if (directlyInHooks) return context.report({ node: program, messageId: "domain" })
|
|
84
|
+
if (!isHookFile && !isSharedFile && !isEntryFile) context.report({ node: program, messageId: "notHook", data: { name } })
|
|
85
|
+
},
|
|
86
|
+
ImportDeclaration(node) {
|
|
87
|
+
if (SERVER_ONLY.test(String(node.source.value)) && !spec) {
|
|
88
|
+
context.report({ node, messageId: "server", data: { source: node.source.value } })
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"Program:exit"(program) {
|
|
92
|
+
if (spec || directlyInHooks || (!isHookFile && !isSharedFile)) return
|
|
93
|
+
const exported = program.body
|
|
94
|
+
.filter((node) => node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration")
|
|
95
|
+
.flatMap((node) => exportedNames(node).map((exportedName) => ({ node, name: exportedName })))
|
|
96
|
+
if (isSharedFile) {
|
|
97
|
+
for (const item of exported) {
|
|
98
|
+
if (isHookName(item.name)) context.report({ node: item.node, messageId: "sharedHook", data: { name: item.name } })
|
|
99
|
+
}
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
const hooks = exported.filter((item) => isHookName(item.name))
|
|
103
|
+
for (const item of exported) {
|
|
104
|
+
if (!isHookName(item.name) && item.name !== "default") {
|
|
105
|
+
context.report({ node: item.node, messageId: "export", data: { name: item.name } })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (hooks.length > 1) context.report({ node: hooks[1].node, messageId: "many", data: { count: hooks.length } })
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The rules this law contributes to the plugin. */
|
|
115
|
+
export const rules = {
|
|
116
|
+
"hooks-folder-holds-hooks-only": hooksFolderHoldsHooksOnly,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Every rule is an error. */
|
|
120
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/hygiene.mjs
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
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 subscription, 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, listener, observer, socket or subscription started by an effect that never releases it
|
|
8
|
+
* keeps running after the component is 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
|
+
* DECIDED BY RESOLUTION, NOT BY SPELLING. An effect is a call whose callee IS React's `useEffect` / `useLayoutEffect`
|
|
15
|
+
* export (the import is followed); `setTimeout`, `addEventListener`, `new ResizeObserver` are the platform's when their
|
|
16
|
+
* symbol is declared by the default library, not by a homonym in the repository; "the same handle" is the same scope
|
|
17
|
+
* binding (or member path), not the same identifier text; "a promise" is what the type checker says.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { walk } from "./lib/ast.mjs"
|
|
21
|
+
import { calleeParts, functionOf, isFn, keyOf, parentOf, unwrap } from "./lib/bindings.mjs"
|
|
22
|
+
import { isPlatform, isUnresolvedType, propertyNamesOf } from "./lib/platform.mjs"
|
|
23
|
+
import { EFFECT_HOOKS, reactHookOf } from "./lib/react.mjs"
|
|
24
|
+
import { isProductSource } from "./lib/scope.mjs"
|
|
25
|
+
import { isPromiseValued } from "./lib/types.mjs"
|
|
26
|
+
|
|
27
|
+
// -- HYGIENE-1 -------------------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** The platform functions that start a timer or a frame callback and hand back a handle. */
|
|
30
|
+
const HANDLE_STARTERS = new Map([
|
|
31
|
+
["setTimeout", "timer"],
|
|
32
|
+
["setInterval", "timer"],
|
|
33
|
+
["requestAnimationFrame", "frame"],
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
/** The platform functions that release such a handle, per kind. */
|
|
37
|
+
const HANDLE_RELEASERS = { timer: new Set(["clearTimeout", "clearInterval"]), frame: new Set(["cancelAnimationFrame"]) }
|
|
38
|
+
|
|
39
|
+
/** The observers that hold their targets until they are disconnected. */
|
|
40
|
+
const OBSERVERS = new Set(["ResizeObserver", "IntersectionObserver", "MutationObserver"])
|
|
41
|
+
|
|
42
|
+
/** The connections that stay open until they are closed. */
|
|
43
|
+
const CHANNELS = new Set(["WebSocket", "EventSource", "BroadcastChannel"])
|
|
44
|
+
|
|
45
|
+
/** The methods a subscription object releases itself with. */
|
|
46
|
+
const SUBSCRIPTION_RELEASES = ["unsubscribe", "off", "close"]
|
|
47
|
+
|
|
48
|
+
/** The global object's names: a timer or listener called through any of them is the same platform call. */
|
|
49
|
+
const GLOBAL_OBJECTS = new Set(["window", "globalThis", "self"])
|
|
50
|
+
|
|
51
|
+
/** `receiver` is absent or names the global object: `setTimeout(...)`, `window.setTimeout(...)`. */
|
|
52
|
+
const onGlobalObject = (receiver) => {
|
|
53
|
+
if (!receiver) return true
|
|
54
|
+
const inner = unwrap(receiver)
|
|
55
|
+
return inner.type === "Identifier" && GLOBAL_OBJECTS.has(inner.name)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The nearest function around a node, or null at module level. */
|
|
59
|
+
const nearestFunction = (node) => {
|
|
60
|
+
for (let current = node.parent; current; current = current.parent) if (isFn(current)) return current
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Where the value of a start call is kept: `{ key }` for `const id = ...` / `id = ...` / `ref.current = ...`,
|
|
66
|
+
* `{ container }` for `list.push(...)` / `set.add(...)`, or null when the handle is dropped.
|
|
67
|
+
*/
|
|
68
|
+
const handleOf = (context, call) => {
|
|
69
|
+
const parent = parentOf(call)
|
|
70
|
+
if (!parent) return null
|
|
71
|
+
if (parent.type === "VariableDeclarator" && unwrap(parent.init) === call && parent.id.type === "Identifier") return { key: keyOf(context, parent.id) }
|
|
72
|
+
if (parent.type === "AssignmentExpression" && unwrap(parent.right) === call) {
|
|
73
|
+
const key = keyOf(context, parent.left)
|
|
74
|
+
return key ? { key } : null
|
|
75
|
+
}
|
|
76
|
+
if (parent.type === "CallExpression" && parent.arguments.some((argument) => unwrap(argument) === call)) {
|
|
77
|
+
const parts = calleeParts(parent)
|
|
78
|
+
if (parts?.receiver && (parts.name === "push" || parts.name === "add")) {
|
|
79
|
+
const container = keyOf(context, parts.receiver)
|
|
80
|
+
return container ? { container } : null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The static key of an event type: `s:resize` for a string literal, the binding key for an identifier. */
|
|
87
|
+
const typeKeyOf = (context, node) => {
|
|
88
|
+
const inner = unwrap(node)
|
|
89
|
+
if (inner?.type === "Literal" && typeof inner.value === "string") return `s:${inner.value}`
|
|
90
|
+
if (inner?.type === "TemplateLiteral" && inner.expressions.length === 0) return `s:${inner.quasis[0].value.cooked}`
|
|
91
|
+
return keyOf(context, inner)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The key of the object whose `.signal` an `addEventListener` option object passes (`{ signal: controller.signal }`), or null. */
|
|
95
|
+
const signalOwnerOf = (context, options) => {
|
|
96
|
+
const object = unwrap(options)
|
|
97
|
+
if (object?.type !== "ObjectExpression") return null
|
|
98
|
+
const property = object.properties.find((entry) => entry.type === "Property" && !entry.computed && entry.key.type === "Identifier" && entry.key.name === "signal")
|
|
99
|
+
if (!property) return null
|
|
100
|
+
let value = unwrap(property.value)
|
|
101
|
+
if (value.type === "Identifier") {
|
|
102
|
+
const definition = context.sourceCode.getScope(value).set.get(value.name)?.defs[0]
|
|
103
|
+
if (definition?.type === "Variable" && definition.node.init) value = unwrap(definition.node.init)
|
|
104
|
+
}
|
|
105
|
+
return value.type === "MemberExpression" && !value.computed && value.property.type === "Identifier" && value.property.name === "signal" ? keyOf(context, value.object) : null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every resource a call or `new` starts that must be released, or an empty list. */
|
|
109
|
+
const resourcesOf = (context, node) => {
|
|
110
|
+
const found = []
|
|
111
|
+
if (node.type === "NewExpression") {
|
|
112
|
+
const callee = unwrap(node.callee)
|
|
113
|
+
if (callee.type !== "Identifier" || !(OBSERVERS.has(callee.name) || CHANNELS.has(callee.name)) || !isPlatform(context, callee)) return found
|
|
114
|
+
found.push({ kind: OBSERVERS.has(callee.name) ? "observer" : "channel", node, name: callee.name, handle: handleOf(context, node) })
|
|
115
|
+
return found
|
|
116
|
+
}
|
|
117
|
+
const parts = calleeParts(node)
|
|
118
|
+
if (!parts) return found
|
|
119
|
+
const callee = unwrap(node.callee)
|
|
120
|
+
if (HANDLE_STARTERS.has(parts.name) && onGlobalObject(parts.receiver) && isPlatform(context, callee)) {
|
|
121
|
+
found.push({ kind: HANDLE_STARTERS.get(parts.name), node, name: parts.name, handle: handleOf(context, node) })
|
|
122
|
+
} else if (parts.name === "addEventListener" && node.arguments.length >= 2 && isPlatform(context, callee)) {
|
|
123
|
+
const listener = unwrap(node.arguments[1])
|
|
124
|
+
found.push({
|
|
125
|
+
kind: "listener",
|
|
126
|
+
node,
|
|
127
|
+
name: "addEventListener",
|
|
128
|
+
target: parts.receiver ? keyOf(context, parts.receiver) : "g:window",
|
|
129
|
+
type: typeKeyOf(context, node.arguments[0]),
|
|
130
|
+
typeText: context.sourceCode.getText(node.arguments[0]),
|
|
131
|
+
listener: listener && !isFn(listener) ? keyOf(context, listener) : null,
|
|
132
|
+
listenerText: context.sourceCode.getText(node.arguments[1]),
|
|
133
|
+
signalOwner: signalOwnerOf(context, node.arguments[2]),
|
|
134
|
+
receiverText: parts.receiver ? context.sourceCode.getText(parts.receiver) : null,
|
|
135
|
+
})
|
|
136
|
+
} else if (!reactHookOf(context, node)) {
|
|
137
|
+
// a subscription object is decided by its type, and only once the call is known to sit in an effect or a subscribe
|
|
138
|
+
const handle = handleOf(context, node)
|
|
139
|
+
if (handle?.key) found.push({ kind: "subscription", node, name: context.sourceCode.getText(node.callee), handle })
|
|
140
|
+
}
|
|
141
|
+
return found
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** True when the value of a call is a subscription object: it carries `unsubscribe`, `off` or `close` and is neither a promise, a DOM node nor a window. */
|
|
145
|
+
const isSubscriptionObject = (context, call) => {
|
|
146
|
+
const names = propertyNamesOf(context, call)
|
|
147
|
+
return SUBSCRIPTION_RELEASES.some((name) => names.has(name)) && !names.has("then") && !names.has("nodeType") && !names.has("document") && !isUnresolvedType(context, call)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The ids of the statements' identifiers around a release: what a `list.forEach(clearTimeout)` mentions besides the releaser. */
|
|
151
|
+
const nearKeys = (context, node) => {
|
|
152
|
+
let root = node
|
|
153
|
+
for (let current = node; current; current = current.parent) {
|
|
154
|
+
root = current
|
|
155
|
+
if (current.type.endsWith("Statement") || current.type === "VariableDeclaration" || isFn(current.parent)) break
|
|
156
|
+
}
|
|
157
|
+
const keys = new Set()
|
|
158
|
+
walk(context.sourceCode, root, (child) => {
|
|
159
|
+
if (child.type === "Identifier" || child.type === "MemberExpression") {
|
|
160
|
+
const key = keyOf(context, child)
|
|
161
|
+
if (key) keys.add(key)
|
|
162
|
+
}
|
|
163
|
+
return undefined
|
|
164
|
+
})
|
|
165
|
+
return keys
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Every release the given functions perform (following calls to same-file functions), as comparable records. */
|
|
169
|
+
const releasesIn = (context, roots) => {
|
|
170
|
+
const releases = []
|
|
171
|
+
const visited = new Set()
|
|
172
|
+
const queue = [...roots]
|
|
173
|
+
while (queue.length > 0) {
|
|
174
|
+
const fn = queue.shift()
|
|
175
|
+
if (!fn || visited.has(fn)) continue
|
|
176
|
+
visited.add(fn)
|
|
177
|
+
walk(context.sourceCode, fn, (node) => {
|
|
178
|
+
if (node.type === "Identifier" && [...Object.values(HANDLE_RELEASERS)].some((set) => set.has(node.name))) {
|
|
179
|
+
const parent = parentOf(node)
|
|
180
|
+
const asCallee = parent?.type === "CallExpression" && unwrap(parent.callee) === node
|
|
181
|
+
if (!asCallee && !(parent?.type === "MemberExpression" && parent.property === node)) releases.push({ kind: "release-ref", name: node.name, near: nearKeys(context, node) })
|
|
182
|
+
}
|
|
183
|
+
if (node.type !== "CallExpression") return undefined
|
|
184
|
+
const parts = calleeParts(node)
|
|
185
|
+
if (!parts) return undefined
|
|
186
|
+
const args = node.arguments
|
|
187
|
+
const isHandleReleaser = Object.values(HANDLE_RELEASERS).some((set) => set.has(parts.name))
|
|
188
|
+
if (isHandleReleaser && onGlobalObject(parts.receiver)) releases.push({ kind: "release", name: parts.name, arg: args[0] ? keyOf(context, args[0]) : null })
|
|
189
|
+
else if (parts.name === "removeEventListener" && args.length >= 2) {
|
|
190
|
+
releases.push({ kind: "remove", target: parts.receiver ? keyOf(context, parts.receiver) : "g:window", type: typeKeyOf(context, args[0]), listener: keyOf(context, args[1]) })
|
|
191
|
+
} else if (parts.receiver && ["disconnect", "close", "unsubscribe", "off", "abort"].includes(parts.name)) {
|
|
192
|
+
releases.push({ kind: "method", name: parts.name, receiver: keyOf(context, parts.receiver) })
|
|
193
|
+
} else if (!parts.receiver) {
|
|
194
|
+
const target = functionOf(context, node.callee)
|
|
195
|
+
if (target) queue.push(target)
|
|
196
|
+
}
|
|
197
|
+
return undefined
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
return releases
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** True when the releases contain the one that ends this resource. */
|
|
204
|
+
const isReleased = (resource, releases) => {
|
|
205
|
+
const { handle } = resource
|
|
206
|
+
switch (resource.kind) {
|
|
207
|
+
case "timer":
|
|
208
|
+
case "frame": {
|
|
209
|
+
if (!handle) return false
|
|
210
|
+
const names = HANDLE_RELEASERS[resource.kind]
|
|
211
|
+
return releases.some((release) => {
|
|
212
|
+
if (release.kind === "release") return names.has(release.name) && handle.key !== undefined && release.arg === handle.key
|
|
213
|
+
if (release.kind === "release-ref") return names.has(release.name) && handle.container !== undefined && release.near.has(handle.container)
|
|
214
|
+
return false
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
case "listener":
|
|
218
|
+
if (resource.signalOwner) return releases.some((release) => release.kind === "method" && release.name === "abort" && release.receiver === resource.signalOwner)
|
|
219
|
+
if (!resource.listener || !resource.target || !resource.type) return false
|
|
220
|
+
return releases.some((release) => release.kind === "remove" && release.target === resource.target && release.type === resource.type && release.listener === resource.listener)
|
|
221
|
+
case "observer":
|
|
222
|
+
return Boolean(handle?.key) && releases.some((release) => release.kind === "method" && release.name === "disconnect" && release.receiver === handle.key)
|
|
223
|
+
case "channel":
|
|
224
|
+
return Boolean(handle?.key) && releases.some((release) => release.kind === "method" && release.name === "close" && release.receiver === handle.key)
|
|
225
|
+
default:
|
|
226
|
+
return Boolean(handle?.key) && releases.some((release) => release.kind === "method" && SUBSCRIPTION_RELEASES.includes(release.name) && release.receiver === handle.key)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** What to write to release a resource, for the message. */
|
|
231
|
+
const releaseHint = (resource) => {
|
|
232
|
+
switch (resource.kind) {
|
|
233
|
+
case "timer":
|
|
234
|
+
return "keep the handle (`const id = " + resource.name + "(...)`) and return `() => clearTimeout(id)` (or `clearInterval`)"
|
|
235
|
+
case "frame":
|
|
236
|
+
return "keep the id (`const id = requestAnimationFrame(...)`) and return `() => cancelAnimationFrame(id)`"
|
|
237
|
+
case "listener":
|
|
238
|
+
return resource.signalOwner
|
|
239
|
+
? "return `() => controller.abort()` on the controller whose signal you passed"
|
|
240
|
+
: `name the listener once and return \`() => ${resource.receiverText ? `${resource.receiverText}.` : ""}removeEventListener(${resource.typeText}, handler)\` with the same target, type and handler (or pass \`{ signal }\` and abort it)`
|
|
241
|
+
case "observer":
|
|
242
|
+
return "return `() => observer.disconnect()` on the observer you stored"
|
|
243
|
+
case "channel":
|
|
244
|
+
return "return `() => connection.close()` on the connection you stored"
|
|
245
|
+
default:
|
|
246
|
+
return "return `() => subscription.unsubscribe()` (or `off` / `close`) on the subscription you stored"
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** True when the observer is `.observe`d somewhere under the owner (an observer that observes nothing holds nothing). */
|
|
251
|
+
const isObserved = (context, resource, owner) => {
|
|
252
|
+
if (!resource.handle?.key) {
|
|
253
|
+
const parent = parentOf(resource.node)
|
|
254
|
+
return parent?.type === "MemberExpression" && parent.property.type === "Identifier" && parent.property.name === "observe"
|
|
255
|
+
}
|
|
256
|
+
let observed = false
|
|
257
|
+
walk(context.sourceCode, owner, (node) => {
|
|
258
|
+
if (observed) return false
|
|
259
|
+
if (node.type === "CallExpression") {
|
|
260
|
+
const parts = calleeParts(node)
|
|
261
|
+
if (parts?.receiver && parts.name === "observe" && keyOf(context, parts.receiver) === resource.handle.key) observed = true
|
|
262
|
+
}
|
|
263
|
+
return undefined
|
|
264
|
+
})
|
|
265
|
+
return observed
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The functions an owner returns as its cleanup (every top-level `return`, or the expression body of an arrow). */
|
|
269
|
+
const cleanupsOf = (context, owner) => {
|
|
270
|
+
const cleanups = []
|
|
271
|
+
const push = (expression) => {
|
|
272
|
+
const inner = unwrap(expression)
|
|
273
|
+
if (!inner) return
|
|
274
|
+
if (inner.type === "ConditionalExpression") {
|
|
275
|
+
push(inner.consequent)
|
|
276
|
+
push(inner.alternate)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
const fn = functionOf(context, inner)
|
|
280
|
+
if (fn) cleanups.push(fn)
|
|
281
|
+
}
|
|
282
|
+
if (owner.body.type !== "BlockStatement") {
|
|
283
|
+
push(owner.body)
|
|
284
|
+
return cleanups
|
|
285
|
+
}
|
|
286
|
+
walk(context.sourceCode, owner.body, (node) => {
|
|
287
|
+
if (node !== owner.body && isFn(node)) return false
|
|
288
|
+
if (node.type === "ReturnStatement" && node.argument) push(node.argument)
|
|
289
|
+
return undefined
|
|
290
|
+
})
|
|
291
|
+
return cleanups
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Every effect and `useSyncExternalStore` subscribe in the file starts things that must end with it: a resource started
|
|
296
|
+
* by one is released by the cleanup it returns, against the same handle, target and listener.
|
|
297
|
+
*/
|
|
298
|
+
export const effectSubscriptionNeedsCleanup = {
|
|
299
|
+
meta: {
|
|
300
|
+
type: "problem",
|
|
301
|
+
docs: { description: "A timer, frame, listener, observer, socket or subscription started in an effect or a store subscribe is released, against the same handle, by the cleanup it returns." },
|
|
302
|
+
schema: [],
|
|
303
|
+
messages: {
|
|
304
|
+
unreleased:
|
|
305
|
+
"`{{what}}` is started here but the cleanup this {{owner}} returns does not release it against the same {{same}}. On unmount, and before the next run, the old one is still live: it fires later and sets state on a component that is gone, or keeps a listener, observer or connection open. In the cleanup: {{hint}}.",
|
|
306
|
+
orphan:
|
|
307
|
+
"`{{what}}` is started outside an effect and nothing in this function releases it. 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 release it in the returned cleanup, or keep the handle and clear it in this same function.",
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
create(context) {
|
|
311
|
+
if (!isProductSource(context)) return {}
|
|
312
|
+
const owners = new Map()
|
|
313
|
+
const resources = []
|
|
314
|
+
return {
|
|
315
|
+
CallExpression(node) {
|
|
316
|
+
const hook = reactHookOf(context, node)
|
|
317
|
+
if (hook && (EFFECT_HOOKS.has(hook) || hook === "useSyncExternalStore") && node.arguments[0]) {
|
|
318
|
+
const fn = functionOf(context, node.arguments[0])
|
|
319
|
+
if (fn) owners.set(fn, EFFECT_HOOKS.has(hook) ? "effect" : "subscribe")
|
|
320
|
+
}
|
|
321
|
+
resources.push(...resourcesOf(context, node))
|
|
322
|
+
},
|
|
323
|
+
NewExpression(node) {
|
|
324
|
+
resources.push(...resourcesOf(context, node))
|
|
325
|
+
},
|
|
326
|
+
"Program:exit"(program) {
|
|
327
|
+
for (const resource of resources) {
|
|
328
|
+
let owner = null
|
|
329
|
+
for (let current = resource.node.parent; current && !owner; current = current.parent) if (isFn(current) && owners.has(current)) owner = current
|
|
330
|
+
const what = resource.kind === "listener" ? `addEventListener(${resource.typeText}, ${resource.listenerText})` : resource.kind === "subscription" ? resource.name : resource.kind === "observer" || resource.kind === "channel" ? `new ${resource.name}(...)` : `${resource.name}(...)`
|
|
331
|
+
if (!owner) {
|
|
332
|
+
if (resource.kind !== "timer" && resource.kind !== "frame") continue
|
|
333
|
+
const scope = nearestFunction(resource.node) ?? program
|
|
334
|
+
if (!isReleased(resource, releasesIn(context, [scope]))) context.report({ node: resource.node, messageId: "orphan", data: { what } })
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
if (resource.kind === "observer" && !isObserved(context, resource, owner)) continue
|
|
338
|
+
if (resource.kind === "subscription" && !isSubscriptionObject(context, resource.node)) continue
|
|
339
|
+
if (isReleased(resource, releasesIn(context, cleanupsOf(context, owner)))) continue
|
|
340
|
+
const same = resource.kind === "listener" ? "target, event type and listener" : "handle"
|
|
341
|
+
context.report({ node: resource.node, messageId: "unreleased", data: { what, owner: owners.get(owner) === "effect" ? "effect" : "subscribe", same, hint: releaseHint(resource) } })
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// -- HYGIENE-2 -------------------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
/** The call at the bottom of a chain: `a().b().c()` starts at `a()`. */
|
|
351
|
+
const rootCallOf = (call) => {
|
|
352
|
+
let current = call
|
|
353
|
+
for (;;) {
|
|
354
|
+
const callee = unwrap(current.callee)
|
|
355
|
+
if (callee.type === "MemberExpression") {
|
|
356
|
+
const object = unwrap(callee.object)
|
|
357
|
+
if (object.type === "CallExpression") {
|
|
358
|
+
current = object
|
|
359
|
+
continue
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return current
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** True when a `.then` / `.catch` / `.finally` sits anywhere in the call chain. */
|
|
367
|
+
const isThenChained = (call) => {
|
|
368
|
+
let current = call
|
|
369
|
+
for (;;) {
|
|
370
|
+
const callee = unwrap(current.callee)
|
|
371
|
+
if (callee.type !== "MemberExpression") return false
|
|
372
|
+
if (!callee.computed && callee.property.type === "Identifier" && ["then", "catch", "finally"].includes(callee.property.name)) return true
|
|
373
|
+
const object = unwrap(callee.object)
|
|
374
|
+
if (object.type !== "CallExpression") return false
|
|
375
|
+
current = object
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* True when a call started by the effect body is a data load: it yields a promise, is `.then`-ed, or is `void`-ed
|
|
381
|
+
* while nothing says what it is. A platform call other than `fetch` (`audio.play()`, `clipboard.writeText(...)`) is not
|
|
382
|
+
* a data load and is left alone.
|
|
383
|
+
*/
|
|
384
|
+
const startsLoad = (context, call) => {
|
|
385
|
+
const root = rootCallOf(call)
|
|
386
|
+
const rootCallee = unwrap(root.callee)
|
|
387
|
+
const isPlatformCall = (rootCallee.type === "Identifier" || rootCallee.type === "MemberExpression") && isPlatform(context, rootCallee)
|
|
388
|
+
if (isPlatformCall && calleeParts(root)?.name !== "fetch") return false
|
|
389
|
+
if (isPromiseValued(context, call) || isThenChained(call)) return true
|
|
390
|
+
const parent = parentOf(call)
|
|
391
|
+
return parent?.type === "UnaryExpression" && parent.operator === "void" && isUnresolvedType(context, call)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The first load the effect callback starts synchronously (an immediately-invoked function counts; a listener, timer callback or the cleanup does not). */
|
|
395
|
+
const loadStartedBy = (context, effect) => {
|
|
396
|
+
let found = null
|
|
397
|
+
walk(context.sourceCode, effect.body, (node) => {
|
|
398
|
+
if (found) return false
|
|
399
|
+
if (node !== effect.body && isFn(node)) {
|
|
400
|
+
const parent = parentOf(node)
|
|
401
|
+
return parent?.type === "CallExpression" && unwrap(parent.callee) === node ? undefined : false
|
|
402
|
+
}
|
|
403
|
+
if (node.type === "AwaitExpression") {
|
|
404
|
+
found = node
|
|
405
|
+
return false
|
|
406
|
+
}
|
|
407
|
+
if (node.type === "CallExpression" && startsLoad(context, node)) {
|
|
408
|
+
found = node
|
|
409
|
+
return false
|
|
410
|
+
}
|
|
411
|
+
return undefined
|
|
412
|
+
})
|
|
413
|
+
return found
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** No data loading inside `useEffect`; SWR on the client, a reader on the server. */
|
|
417
|
+
export const noDataFetchInEffect = {
|
|
418
|
+
meta: {
|
|
419
|
+
type: "problem",
|
|
420
|
+
docs: { description: "An effect body starts no promise: no `await`, `fetch`, `.then`, `void load()`, `mutate()` or `refresh()`; read through SWR or a server reader." },
|
|
421
|
+
schema: [],
|
|
422
|
+
messages: {
|
|
423
|
+
fetch:
|
|
424
|
+
"This effect starts a load: it awaits, calls `fetch`, chains `.then`, or calls a function that returns a promise (`void load()`, an SWR `mutate()` or `refresh()` too). 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. Data freshness comes from the SWR key, not from an effect: call the app client through SWR (`hooks/`) keyed on what changes, run `mutate()` from the event that changed the data (a handler, a socket message), and read what a route needs in a server reader (`modules/api/<domain>/read-*.ts`).",
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
create(context) {
|
|
428
|
+
if (!isProductSource(context)) return {}
|
|
429
|
+
return {
|
|
430
|
+
CallExpression(node) {
|
|
431
|
+
const hook = reactHookOf(context, node)
|
|
432
|
+
if (!hook || !EFFECT_HOOKS.has(hook) || !node.arguments[0]) return
|
|
433
|
+
const effect = functionOf(context, node.arguments[0])
|
|
434
|
+
if (!effect) return
|
|
435
|
+
const found = loadStartedBy(context, effect)
|
|
436
|
+
if (found) context.report({ node: found, messageId: "fetch" })
|
|
437
|
+
},
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// -- HYGIENE-3 -------------------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
/** A `catch` block with nothing in it, or a promise `.catch` handler with nothing in it. */
|
|
445
|
+
export const noEmptyCatch = {
|
|
446
|
+
meta: {
|
|
447
|
+
type: "problem",
|
|
448
|
+
docs: { description: "A `catch` that does nothing hides the failure; handle it or return a typed outcome." },
|
|
449
|
+
schema: [],
|
|
450
|
+
messages: {
|
|
451
|
+
empty:
|
|
452
|
+
"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.",
|
|
453
|
+
promise:
|
|
454
|
+
"This `.catch` handler does nothing, so a rejected promise disappears. Turn it into a typed outcome the caller renders, or let it propagate.",
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
create(context) {
|
|
458
|
+
if (!isProductSource(context)) return {}
|
|
459
|
+
return {
|
|
460
|
+
CatchClause(node) {
|
|
461
|
+
if (node.body.body.length === 0) context.report({ node, messageId: "empty" })
|
|
462
|
+
},
|
|
463
|
+
CallExpression(node) {
|
|
464
|
+
const callee = node.callee
|
|
465
|
+
if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier" || callee.property.name !== "catch") return
|
|
466
|
+
const handler = node.arguments[0]
|
|
467
|
+
if (!handler || !isFn(handler)) return
|
|
468
|
+
const empty =
|
|
469
|
+
(handler.body.type === "BlockStatement" && handler.body.body.length === 0) ||
|
|
470
|
+
(handler.body.type === "Identifier" && handler.body.name === "undefined") ||
|
|
471
|
+
(handler.body.type === "Literal" && handler.body.value === null)
|
|
472
|
+
if (empty) context.report({ node: handler, messageId: "promise" })
|
|
473
|
+
},
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// -- HYGIENE-4 -------------------------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
/** No `console.*` in product source. */
|
|
481
|
+
export const noConsole = {
|
|
482
|
+
meta: {
|
|
483
|
+
type: "problem",
|
|
484
|
+
docs: { description: "No `console.*` call in product source." },
|
|
485
|
+
schema: [],
|
|
486
|
+
messages: {
|
|
487
|
+
console:
|
|
488
|
+
"`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`.",
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
create(context) {
|
|
492
|
+
if (!isProductSource(context)) return {}
|
|
493
|
+
return {
|
|
494
|
+
CallExpression(node) {
|
|
495
|
+
const callee = node.callee
|
|
496
|
+
if (callee.type !== "MemberExpression" || callee.object.type !== "Identifier" || callee.object.name !== "console") return
|
|
497
|
+
const method = callee.property.type === "Identifier" ? callee.property.name : "*"
|
|
498
|
+
context.report({ node, messageId: "console", data: { method } })
|
|
499
|
+
},
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** The rules this law contributes to the plugin. */
|
|
505
|
+
export const rules = {
|
|
506
|
+
"effect-subscription-needs-cleanup": effectSubscriptionNeedsCleanup,
|
|
507
|
+
"no-data-fetch-in-effect": noDataFetchInEffect,
|
|
508
|
+
"no-empty-catch": noEmptyCatch,
|
|
509
|
+
"no-console": noConsole,
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Every rule is an error. */
|
|
513
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|