@starci/eslint-canon-fe 9.2.0 → 9.4.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 (178) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +5 -1
  3. package/dist/brand-values.d.ts +28 -0
  4. package/dist/brand-values.js +172 -0
  5. package/dist/button-intent.d.ts +11 -0
  6. package/dist/button-intent.js +361 -0
  7. package/dist/callback-shape.d.ts +7 -0
  8. package/dist/callback-shape.js +396 -0
  9. package/dist/class-names.d.ts +19 -0
  10. package/dist/class-names.js +170 -0
  11. package/dist/client-boundary.d.ts +42 -0
  12. package/dist/client-boundary.js +404 -0
  13. package/dist/comments.d.ts +47 -0
  14. package/dist/comments.js +156 -0
  15. package/dist/control-height.d.ts +7 -0
  16. package/dist/control-height.js +83 -0
  17. package/dist/drawing.d.ts +31 -0
  18. package/dist/drawing.js +376 -0
  19. package/dist/env-owner.d.ts +22 -0
  20. package/dist/env-owner.js +291 -0
  21. package/dist/fe-stack.d.ts +10 -0
  22. package/dist/fe-stack.js +73 -0
  23. package/dist/file-layout.d.ts +71 -0
  24. package/dist/file-layout.js +465 -0
  25. package/dist/formatting.d.ts +25 -0
  26. package/dist/formatting.js +104 -0
  27. package/dist/function-budget.d.ts +9 -0
  28. package/dist/function-budget.js +48 -0
  29. package/dist/grammar-shim.d.ts +5 -0
  30. package/dist/grammar-shim.js +126 -0
  31. package/dist/hooks-folder.d.ts +34 -0
  32. package/dist/hooks-folder.js +320 -0
  33. package/dist/hygiene.d.ts +39 -0
  34. package/dist/hygiene.js +540 -0
  35. package/dist/index.d.ts +105 -0
  36. package/dist/index.js +310 -0
  37. package/{landmark.mjs → dist/landmark.d.ts} +4 -6
  38. package/dist/landmark.js +6 -0
  39. package/dist/lib/ast.d.ts +36 -0
  40. package/dist/lib/ast.js +94 -0
  41. package/dist/lib/bindings.d.ts +84 -0
  42. package/dist/lib/bindings.js +168 -0
  43. package/dist/lib/class-text.d.ts +3 -0
  44. package/dist/lib/class-text.js +30 -0
  45. package/dist/lib/config.d.ts +81 -0
  46. package/dist/lib/config.js +141 -0
  47. package/dist/lib/drawing-scope.d.ts +5 -0
  48. package/{lib/drawing-scope.mjs → dist/lib/drawing-scope.js} +6 -11
  49. package/dist/lib/eslint-boundary.d.ts +23 -0
  50. package/dist/lib/eslint-boundary.js +6 -0
  51. package/dist/lib/fe-stack.d.ts +49 -0
  52. package/dist/lib/fe-stack.js +98 -0
  53. package/dist/lib/hfs-view.d.ts +74 -0
  54. package/dist/lib/hfs-view.js +6 -0
  55. package/dist/lib/hfs.d.ts +34 -0
  56. package/dist/lib/hfs.js +72 -0
  57. package/dist/lib/next.d.ts +4 -0
  58. package/dist/lib/next.js +10 -0
  59. package/dist/lib/own-hook-origin.d.ts +17 -0
  60. package/dist/lib/own-hook-origin.js +52 -0
  61. package/dist/lib/package-root.d.ts +4 -0
  62. package/dist/lib/package-root.js +10 -0
  63. package/dist/lib/path.d.ts +2 -0
  64. package/{lib/path.mjs → dist/lib/path.js} +1 -1
  65. package/dist/lib/platform.d.ts +26 -0
  66. package/{lib/platform.mjs → dist/lib/platform.js} +38 -35
  67. package/dist/lib/program-for.d.ts +5 -0
  68. package/dist/lib/program-for.js +23 -0
  69. package/dist/lib/pure-owner-helper.d.ts +10 -0
  70. package/dist/lib/pure-owner-helper.js +156 -0
  71. package/dist/lib/react.d.ts +16 -0
  72. package/dist/lib/react.js +22 -0
  73. package/dist/lib/redux-hook-origin.d.ts +4 -0
  74. package/dist/lib/redux-hook-origin.js +50 -0
  75. package/dist/lib/rule.d.ts +28 -0
  76. package/dist/lib/rule.js +5 -0
  77. package/dist/lib/scope.d.ts +88 -0
  78. package/{lib/scope.mjs → dist/lib/scope.js} +83 -93
  79. package/dist/lib/typed-class-values.d.ts +13 -0
  80. package/dist/lib/typed-class-values.js +92 -0
  81. package/dist/lib/types.d.ts +44 -0
  82. package/dist/lib/types.js +64 -0
  83. package/dist/lib/why.d.ts +350 -0
  84. package/dist/lib/why.js +350 -0
  85. package/dist/lint-escape-hatch.d.ts +24 -0
  86. package/{lint-escape-hatch.mjs → dist/lint-escape-hatch.js} +38 -45
  87. package/dist/lists.d.ts +31 -0
  88. package/dist/lists.js +202 -0
  89. package/dist/loading.d.ts +34 -0
  90. package/dist/loading.js +157 -0
  91. package/dist/naming.d.ts +33 -0
  92. package/dist/naming.js +176 -0
  93. package/dist/native-controls.d.ts +24 -0
  94. package/dist/native-controls.js +105 -0
  95. package/dist/next-conventions.d.ts +48 -0
  96. package/dist/next-conventions.js +452 -0
  97. package/dist/project-graph.d.ts +5 -0
  98. package/{project-graph.mjs → dist/project-graph.js} +8 -12
  99. package/dist/props-and-slots.d.ts +19 -0
  100. package/dist/props-and-slots.js +320 -0
  101. package/dist/served-locale.d.ts +38 -0
  102. package/dist/served-locale.js +263 -0
  103. package/dist/shape-slot.d.ts +18 -0
  104. package/dist/shape-slot.js +324 -0
  105. package/dist/size-and-state-budget.d.ts +46 -0
  106. package/dist/size-and-state-budget.js +424 -0
  107. package/dist/size-growth.d.ts +24 -0
  108. package/dist/size-growth.js +64 -0
  109. package/dist/sonar-parity.d.ts +5 -0
  110. package/{sonar-parity.mjs → dist/sonar-parity.js} +11 -7
  111. package/dist/surface-rules.d.ts +18 -0
  112. package/dist/surface-rules.js +491 -0
  113. package/dist/swr-family.d.ts +15 -0
  114. package/dist/swr-family.js +419 -0
  115. package/dist/the-split.d.ts +27 -0
  116. package/dist/the-split.js +157 -0
  117. package/dist/tokens.d.ts +9 -0
  118. package/dist/tokens.js +35 -0
  119. package/dist/translation.d.ts +51 -0
  120. package/dist/translation.js +403 -0
  121. package/dist/transport.d.ts +73 -0
  122. package/dist/transport.js +885 -0
  123. package/dist/type-safety.d.ts +43 -0
  124. package/dist/type-safety.js +139 -0
  125. package/dist/ui-rulings.d.ts +11 -0
  126. package/dist/ui-rulings.js +121 -0
  127. package/package.json +31 -9
  128. package/runtime/knowledge/hfs/canon-pins.yaml +22 -14
  129. package/runtime/scripts/lib/package-at.mjs +5 -5
  130. package/brand-values.mjs +0 -172
  131. package/button-intent.mjs +0 -320
  132. package/callback-shape.mjs +0 -332
  133. package/class-names.mjs +0 -149
  134. package/client-boundary.mjs +0 -400
  135. package/comments.mjs +0 -159
  136. package/control-height.mjs +0 -77
  137. package/drawing.mjs +0 -315
  138. package/env-owner.mjs +0 -244
  139. package/fe-stack.mjs +0 -86
  140. package/file-layout.mjs +0 -472
  141. package/formatting.mjs +0 -110
  142. package/function-budget.mjs +0 -49
  143. package/grammar-shim.mjs +0 -123
  144. package/hooks-folder.mjs +0 -300
  145. package/hygiene.mjs +0 -513
  146. package/index.mjs +0 -317
  147. package/lib/ast.mjs +0 -97
  148. package/lib/bindings.mjs +0 -159
  149. package/lib/class-text.mjs +0 -29
  150. package/lib/config.mjs +0 -142
  151. package/lib/fe-stack.mjs +0 -77
  152. package/lib/hfs.mjs +0 -49
  153. package/lib/next.mjs +0 -6
  154. package/lib/own-hook-origin.mjs +0 -53
  155. package/lib/program-for.mjs +0 -22
  156. package/lib/pure-owner-helper.mjs +0 -148
  157. package/lib/react.mjs +0 -28
  158. package/lib/redux-hook-origin.mjs +0 -44
  159. package/lib/typed-class-values.mjs +0 -81
  160. package/lib/types.mjs +0 -63
  161. package/lib/why.mjs +0 -352
  162. package/lists.mjs +0 -193
  163. package/loading.mjs +0 -156
  164. package/naming.mjs +0 -185
  165. package/native-controls.mjs +0 -108
  166. package/next-conventions.mjs +0 -447
  167. package/props-and-slots.mjs +0 -276
  168. package/served-locale.mjs +0 -243
  169. package/size-and-state-budget.mjs +0 -376
  170. package/size-growth.mjs +0 -62
  171. package/surface-rules.mjs +0 -443
  172. package/swr-family.mjs +0 -326
  173. package/the-split.mjs +0 -155
  174. package/tokens.mjs +0 -39
  175. package/translation.mjs +0 -392
  176. package/transport.mjs +0 -830
  177. package/type-safety.mjs +0 -147
  178. package/ui-rulings.mjs +0 -134
package/hygiene.mjs DELETED
@@ -1,513 +0,0 @@
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 the SWR hooks over the `modules/api` operations already give for free (R50: 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`; data is read through an SWR hook over an operation of `modules/api`. */
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 an SWR hook (`hooks/swr`) over an operation of `modules/api`." },
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: read through the SWR hook of the operation (`hooks/swr/`, over `modules/api`) keyed on what changes, and run `mutate()` from the event that changed the data (a handler, a socket message).",
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"]))