@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +33 -16
  3. package/brand-values.mjs +172 -0
  4. package/class-names.mjs +9 -9
  5. package/client-boundary.mjs +287 -0
  6. package/comments.mjs +137 -190
  7. package/docs/brand-values.md +30 -0
  8. package/docs/client-boundary.md +124 -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/grammar-boundary.md +33 -0
  13. package/docs/hooks-folder.md +29 -0
  14. package/docs/hygiene.md +122 -0
  15. package/docs/lint-escape-hatch.md +34 -0
  16. package/docs/lists.md +74 -0
  17. package/docs/native-controls.md +73 -0
  18. package/docs/next-conventions.md +232 -0
  19. package/docs/size-and-state-budget.md +73 -0
  20. package/docs/size-growth.md +29 -0
  21. package/docs/spec-quality.md +147 -0
  22. package/docs/status-colors.md +32 -0
  23. package/docs/translation.md +54 -0
  24. package/docs/transport.md +195 -0
  25. package/docs/type-safety.md +95 -0
  26. package/e2e-shape.mjs +345 -0
  27. package/env-owner.mjs +134 -0
  28. package/file-layout.mjs +105 -104
  29. package/formatting.mjs +104 -0
  30. package/grammar-boundary.mjs +85 -9
  31. package/hooks-folder.mjs +120 -0
  32. package/hygiene.mjs +513 -0
  33. package/icon.mjs +212 -212
  34. package/index.mjs +243 -142
  35. package/landmark.mjs +0 -11
  36. package/lib/ast.mjs +94 -0
  37. package/lib/bindings.mjs +144 -0
  38. package/lib/config.mjs +79 -0
  39. package/lib/hfs.mjs +48 -0
  40. package/lib/next.mjs +6 -0
  41. package/lib/path.mjs +2 -0
  42. package/lib/platform.mjs +66 -0
  43. package/lib/react.mjs +28 -0
  44. package/lib/scope.mjs +142 -0
  45. package/lib/status-tones.generated.mjs +9 -0
  46. package/lib/types.mjs +63 -0
  47. package/lib/why.mjs +334 -0
  48. package/lint-escape-hatch.mjs +93 -56
  49. package/lists.mjs +197 -0
  50. package/loading.mjs +156 -164
  51. package/naming.mjs +149 -143
  52. package/native-controls.mjs +112 -0
  53. package/next-conventions.mjs +430 -0
  54. package/package.json +15 -6
  55. package/props-and-slots.mjs +19 -13
  56. package/runtime/engine/plain-object.mjs +5 -0
  57. package/runtime/engine/runtime-root.mjs +32 -0
  58. package/runtime/engine/yaml.mjs +161 -0
  59. package/runtime/knowledge/hfs/slots.yaml +1072 -0
  60. package/runtime/scripts/lib/git.mjs +53 -0
  61. package/runtime/scripts/lib/glob.mjs +23 -0
  62. package/runtime/scripts/lib/hfs-allows.mjs +42 -0
  63. package/runtime/scripts/lib/hfs-slots.mjs +722 -0
  64. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  65. package/runtime/scripts/lib/next-contract.mjs +14 -0
  66. package/runtime/scripts/lib/path-key.mjs +17 -0
  67. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  68. package/served-locale.mjs +174 -160
  69. package/shape-slot.mjs +22 -21
  70. package/size-and-state-budget.mjs +214 -0
  71. package/size-growth.mjs +58 -0
  72. package/spec-quality.mjs +318 -0
  73. package/status-colors.mjs +139 -0
  74. package/the-split.mjs +133 -134
  75. package/tokens.mjs +259 -268
  76. package/translation.mjs +295 -122
  77. package/transport.mjs +752 -0
  78. package/type-safety.mjs +147 -74
  79. package/typography.mjs +78 -83
  80. package/vendor-boundary.mjs +5 -27
  81. package/lint-adoption.mjs +0 -116
@@ -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"]))