@starci/eslint-canon-fe 6.0.0 → 6.0.1

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.0.1 - 2026-09-30
4
+
5
+ - **Fix: `no-hand-rolled-polling` refused a shared clock.** A timer is polling only when what it runs reads data - a call whose
6
+ value is a promise (`fetch`, the transport client, a reader, SWR's `mutate()`), an `await`, or a same-file function that does -
7
+ decided by type, not by name. nivo-fe `useNow` (a one-minute tick behind `useSyncExternalStore`) and a state countdown pass; a
8
+ timer that fetches or revalidates is still refused. The self-scheduling `setTimeout` check follows the enclosing function's
9
+ binding, not its spelling. The FE convention names no shared clock owner, so no clock rule is added.
10
+
3
11
  ## 6.0.0 - 2026-09-30
4
12
 
5
13
  - **Census fixes (starci-next-fe main, lane F0):** `one-outcome-union` treats a `kind` union as a result union only with an `ok`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "6.0.0",
3
+ "version": "6.0.1",
4
4
  "description": "The StarCi front-end lint canon: every rule its laws publish, gathered as one flat-config plugin and attached by one factory.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -18,7 +18,9 @@
18
18
  * socket), so the loop in a component is always the wrong one.
19
19
  */
20
20
 
21
+ import { functionOf, isFn } from "./lib/bindings.mjs"
21
22
  import { hfsOf } from "./lib/hfs.mjs"
23
+ import { isPromiseValued } from "./lib/types.mjs"
22
24
  import { fileOf, isSpecFile, roleOfFile, slotOfFile } from "./lib/scope.mjs"
23
25
 
24
26
  /**
@@ -167,17 +169,55 @@ const isIntervalCall = (node) => {
167
169
  )
168
170
  }
169
171
 
170
- /** No hand-written polling loop in a component or hook. */
172
+ /** The function node a timer runs: the inline callback, or the same-file function a name stands for. */
173
+ const timerCallback = (context, node) => functionOf(context, node.arguments[0])
174
+
175
+ /**
176
+ * True when running `fn` reads data: a call in its body (outside nested functions) whose value is a promise - `fetch`, the
177
+ * transport client, a reader, SWR's `mutate()`/revalidation, anything awaited - or an `await`. A tick that only reads the time or
178
+ * sets state (a shared clock, a countdown, an animation step) is not a refresh mechanism.
179
+ */
180
+ const readsData = (context, fn, seen = new Set()) => {
181
+ if (!fn || seen.has(fn)) return false
182
+ seen.add(fn)
183
+ let found = false
184
+ const visit = (current) => {
185
+ if (found || !current || typeof current.type !== "string") return
186
+ if (current !== fn && isFn(current)) return
187
+ if (current.type === "AwaitExpression") { found = true; return }
188
+ if (current.type === "CallExpression") {
189
+ if (isPromiseValued(context, current)) { found = true; return }
190
+ const local = functionOf(context, current.callee)
191
+ if (local && readsData(context, local, seen)) { found = true; return }
192
+ }
193
+ for (const [key, child] of Object.entries(current)) {
194
+ if (key === "parent") continue
195
+ if (Array.isArray(child)) child.forEach(visit)
196
+ else if (child && typeof child.type === "string") visit(child)
197
+ }
198
+ }
199
+ visit(fn.body)
200
+ return found
201
+ }
202
+
203
+ /**
204
+ * No hand-written polling loop in a component or hook.
205
+ *
206
+ * A timer is polling when what it runs READS DATA (see `readsData`, decided by the type of each call, not by a name): a
207
+ * `setInterval`, or a `setTimeout` whose callback schedules the enclosing function again. The FE convention names no shared clock
208
+ * owner (knowledge/hfs/README.md section 6: "one refresh mechanism per resource, poll or socket"), so a timer that only ticks time
209
+ * or state - nivo-fe `useNow`, a one-minute clock behind `useSyncExternalStore` - is not a second refresh mechanism.
210
+ */
171
211
  export const noHandRolledPolling = {
172
212
  meta: {
173
213
  type: "problem",
174
- docs: { description: "No `setInterval` and no self-scheduling `setTimeout`: one refresh mechanism per resource." },
214
+ docs: { description: "No `setInterval` and no self-scheduling `setTimeout` that reads data: one refresh mechanism per resource." },
175
215
  schema: [],
176
216
  messages: {
177
217
  interval:
178
- "A `setInterval` loop. This is a second refresh mechanism beside the data hook's own; two mechanisms per resource means double requests and a spinner that never settles. Refresh through the data hook (`refreshInterval`) or a socket, not a timer in the component.",
218
+ "A `setInterval` loop that reads data. This is a second refresh mechanism beside the data hook's own; two mechanisms per resource means double requests and a spinner that never settles. Refresh through the data hook (`refreshInterval`) or a socket, not a timer in the component.",
179
219
  timeout:
180
- "A `setTimeout` that schedules its own next run is a polling loop written by hand. Refresh through the data hook (`refreshInterval`) or a socket.",
220
+ "A `setTimeout` that schedules its own next run and reads data is a polling loop written by hand. Refresh through the data hook (`refreshInterval`) or a socket.",
181
221
  },
182
222
  },
183
223
  create(context) {
@@ -185,19 +225,29 @@ export const noHandRolledPolling = {
185
225
  const source = context.sourceCode || context.getSourceCode()
186
226
  return {
187
227
  CallExpression(node) {
188
- if (isIntervalCall(node)) return context.report({ node, messageId: "interval" })
228
+ if (isIntervalCall(node)) {
229
+ if (readsData(context, timerCallback(context, node))) context.report({ node, messageId: "interval" })
230
+ return
231
+ }
189
232
  const callee = node.callee
190
233
  if (callee.type !== "Identifier" || callee.name !== "setTimeout") return
191
234
  const callback = node.arguments[0]
192
235
  if (!callback || (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression")) return
193
- // The enclosing named function: does the timer's own callback call it again?
194
- const owner = source.getAncestors(node).reverse().find((ancestor) =>
195
- ["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"].includes(ancestor.type) && functionName(ancestor),
196
- )
197
- const name = owner ? functionName(owner) : null
198
- if (!name) return
199
- const calls = new RegExp(`\\b${name}\\s*\\(`).test(source.getText(callback))
200
- if (calls) context.report({ node, messageId: "timeout" })
236
+ // The enclosing function: does the timer's own callback call it again (the same binding, not the same spelling)?
237
+ const owner = source.getAncestors(node).reverse().find((ancestor) => isFn(ancestor))
238
+ if (!owner) return
239
+ let again = false
240
+ const visit = (current) => {
241
+ if (again || !current || typeof current.type !== "string") return
242
+ if (current.type === "CallExpression" && functionOf(context, current.callee) === owner) { again = true; return }
243
+ for (const [key, child] of Object.entries(current)) {
244
+ if (key === "parent") continue
245
+ if (Array.isArray(child)) child.forEach(visit)
246
+ else if (child && typeof child.type === "string") visit(child)
247
+ }
248
+ }
249
+ visit(callback.body)
250
+ if (again && (readsData(context, callback) || readsData(context, owner))) context.report({ node, messageId: "timeout" })
201
251
  },
202
252
  }
203
253
  },