@starci/eslint-canon-fe 8.4.39 → 8.4.41

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 (67) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +10 -0
  3. package/app-shell.mjs +161 -0
  4. package/callback-shape.mjs +1 -13
  5. package/docs/app-shell.md +50 -0
  6. package/docs/grammar-boundary.md +25 -1
  7. package/docs/surface-rules.md +41 -0
  8. package/grammar-boundary.mjs +112 -0
  9. package/hooks-folder.mjs +9 -3
  10. package/icon.mjs +31 -1
  11. package/index.mjs +6 -0
  12. package/lib/app-shell-cluster.mjs +66 -0
  13. package/lib/app-shell-create.mjs +33 -0
  14. package/lib/app-shell-owner-source.mjs +124 -0
  15. package/lib/app-shell-owner.mjs +65 -0
  16. package/lib/app-shell-scope.mjs +79 -0
  17. package/lib/app-shell-visible.mjs +92 -0
  18. package/lib/app-shell.mjs +148 -0
  19. package/lib/bindings.mjs +15 -0
  20. package/lib/config.mjs +1 -0
  21. package/lib/fe-stack.mjs +8 -4
  22. package/lib/scope.mjs +2 -2
  23. package/lib/typed-class-values.mjs +72 -0
  24. package/lib/why.mjs +42 -2
  25. package/lint-escape-hatch.mjs +2 -2
  26. package/menu-entries.mjs +133 -0
  27. package/package.json +1 -1
  28. package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
  29. package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
  30. package/runtime/knowledge/hfs/slots.yaml +113 -53
  31. package/runtime/knowledge/sonar-gate.yaml +1 -1
  32. package/runtime/modules/goal/source-phrases.yaml +95 -0
  33. package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
  34. package/runtime/modules/models/registry.yaml +3 -1
  35. package/runtime/modules/models/runtimes.yaml +2 -0
  36. package/runtime/scripts/api/git/ls-tree.mjs +5 -0
  37. package/runtime/scripts/api/git/show.mjs +6 -0
  38. package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
  39. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
  40. package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
  41. package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
  42. package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
  43. package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
  44. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
  45. package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
  46. package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
  47. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -1
  48. package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
  49. package/runtime/scripts/hfs/declaration-slots.mjs +14 -3
  50. package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
  51. package/runtime/scripts/hfs/rule-params-shape.mjs +18 -6
  52. package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
  53. package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
  54. package/runtime/scripts/hfs/slot-side-problems.mjs +3 -3
  55. package/runtime/scripts/hfs/slots.mjs +2 -1
  56. package/runtime/scripts/hfs/view.mjs +2 -1
  57. package/runtime/scripts/lib/in-order.mjs +71 -0
  58. package/runtime/scripts/lib/language.mjs +94 -1
  59. package/runtime/scripts/lib/package-at.mjs +62 -0
  60. package/runtime/scripts/lib/same-text.mjs +5 -0
  61. package/runtime/scripts/lib/secret-patterns.mjs +58 -9
  62. package/runtime/scripts/lib/source-phrases.mjs +34 -0
  63. package/runtime/scripts/lib/ts-analysis.mjs +10 -0
  64. package/size-and-state-budget.mjs +3 -3
  65. package/surface-rules.mjs +230 -0
  66. package/swr-family.mjs +136 -7
  67. package/transport.mjs +2 -1
package/swr-family.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  /** R285: API operations are wrapped by the SWR family before product code calls them. */
2
2
  import { hfsOf } from "./lib/hfs.mjs"
3
3
  import { readFileSync, readdirSync } from "node:fs"
4
- import { join } from "node:path"
4
+ import { dirname, join, resolve } from "node:path"
5
5
  import { stringOf, walk } from "./lib/ast.mjs"
6
+ import { functionOf } from "./lib/bindings.mjs"
6
7
  import { classOf, fileOf, globalReferences, isApiLayer, isProductSource, resolveImport } from "./lib/scope.mjs"
7
8
  import { why } from "./lib/why.mjs"
8
9
 
@@ -13,7 +14,7 @@ const isSwrHook = (context) => {
13
14
  }
14
15
 
15
16
  /** Cache providers and revalidation may be used by consumers; only request hooks own operations. */
16
- const isRequestHook = (name) => /^useSWR(?:Immutable|Infinite|Mutation)?$/.test(name) || name === "default"
17
+ const isRequestHook = (name) => /^useSWR(?:Immutable|Infinite|Mutation|Subscription)?$/.test(name) || name === "default"
17
18
  const swrSource = (value) => value === "swr" || value.startsWith("swr/")
18
19
  const propertyName = (node) => node.computed ? node.property.value : node.property.name
19
20
 
@@ -68,8 +69,11 @@ export const swrOnlyInFamily = {
68
69
  },
69
70
  }
70
71
 
72
+ /** The slot of the stream reader: only a stream operation of the API layer imports it, never an SWR hook, even inside a fetcher. */
73
+ const STREAM_READER_SLOT = "fe.transport.stream"
74
+
71
75
  /** Type imports stay available; the API layer may compose its own operations and helpers. */
72
- const isOperationSource = (context, value) => {
76
+ const isOperationSource = (context, value, only) => {
73
77
  if (typeof value !== "string") return false
74
78
  const hfs = hfsOf(context)
75
79
  let target = resolveImport(context, value)
@@ -93,7 +97,43 @@ const isOperationSource = (context, value) => {
93
97
  const candidates = /\.[cm]?[jt]sx?$/.test(target) ? [target] : [`${target}.ts`, `${target}.tsx`, `${target}/index.ts`, `${target}/index.tsx`, target]
94
98
  const found = candidates.map((file) => hfs.classify(file)).filter((entry) => entry.status === "owned")
95
99
  if (found.some((entry) => ["fe.transport.outcome", "fe.package.api.outcome"].includes(entry.slot))) return false
96
- return found.some((entry) => ["fe.modules.api", "fe.package.api", "fe.transport.client", "fe.package.api.client"].includes(entry.slot))
100
+ if (only !== undefined) return found.some((entry) => entry.slot === only)
101
+ return found.some((entry) => ["fe.modules.api", "fe.package.api", "fe.transport.client", "fe.transport.stream", "fe.package.api.client"].includes(entry.slot))
102
+ }
103
+
104
+ /** The generated input-bounds module holds constants and a pure reader derived from the contract, never an operation. */
105
+ const BOUNDS_MODULE = /(?:^|\/)__generated__\/input-bounds\.ts$/
106
+ const RE_EXPORT = /export\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g
107
+ const ENTRY = /^\s*(type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/
108
+
109
+ /** True when the named export of `target` is, through a chain of relative named re-exports, defined by a generated input-bounds module. */
110
+ const isBoundsExport = (target, name, depth = 0) => {
111
+ if (depth > 4) return false
112
+ for (const file of [`${target}.ts`, `${target}/index.ts`, target]) {
113
+ if (BOUNDS_MODULE.test(file.replaceAll("\\", "/"))) return true
114
+ let text
115
+ try { text = readFileSync(file, "utf8") } catch { continue }
116
+ for (const [, list, source] of text.matchAll(RE_EXPORT)) {
117
+ if (!source.startsWith(".")) continue
118
+ for (const entry of list.split(",")) {
119
+ const [, kind, origin, exported] = ENTRY.exec(entry) ?? []
120
+ if (origin !== undefined && kind === undefined && (exported ?? origin) === name && isBoundsExport(resolve(dirname(file), source), origin, depth + 1)) return true
121
+ }
122
+ }
123
+ }
124
+ return false
125
+ }
126
+
127
+ /** An import of named values that all come from a generated input-bounds module is data, not an operation. */
128
+ const importsOnlyBounds = (context, node, value) => {
129
+ const specifiers = node.specifiers?.filter((s) => s.importKind !== "type" && s.exportKind !== "type") ?? []
130
+ if (!specifiers.length || typeof value !== "string") return false
131
+ const target = resolveImport(context, value)
132
+ if (target === null) return false
133
+ return specifiers.every((s) => {
134
+ const name = s.imported?.name ?? s.imported?.value ?? s.local?.name ?? s.local?.value
135
+ return (s.type === "ImportSpecifier" || s.type === "ExportSpecifier") && typeof name === "string" && isBoundsExport(target, name)
136
+ })
97
137
  }
98
138
 
99
139
  const unwrap = (node) => {
@@ -162,7 +202,7 @@ export const operationsOnlyInSwrHooks = {
162
202
  if (!isProductSource(context) || isApiLayer(context)) return {}
163
203
  const imports = []
164
204
  const inspect = (node, value) => {
165
- if (isOperationSource(context, value)) imports.push({ node, value })
205
+ if (isOperationSource(context, value) && !importsOnlyBounds(context, node, value)) imports.push({ node, value, reader: isOperationSource(context, value, STREAM_READER_SLOT) })
166
206
  }
167
207
  return {
168
208
  ImportDeclaration(node) {
@@ -180,7 +220,95 @@ export const operationsOnlyInSwrHooks = {
180
220
  "Program:exit"(program) {
181
221
  if (!imports.length) return
182
222
  const wrapped = isSwrHook(context) ? wrappedOperations(context, program, imports) : new Set()
183
- for (const { node, value } of imports) if (!wrapped.has(node)) context.report({ node, messageId: "outside", data: { source: value } })
223
+ for (const { node, value, reader } of imports) if (reader || !wrapped.has(node)) context.report({ node, messageId: "outside", data: { source: value } })
224
+ },
225
+ }
226
+ },
227
+ }
228
+
229
+ /** A stream operation: `modules/api/sse/<area>/stream-<channel>` or `modules/api/graphql/subscriptions/subscription-<name>`. */
230
+ const STREAM_OPERATION = /modules\/api\/(?:sse\/[^/]+\/stream-[^/]+|graphql\/subscriptions\/subscription-[^/]+)(?:\.tsx?)?$/
231
+
232
+ /** A subscription hook file of the SWR family: `useSubscribe<X>Swr.ts`. */
233
+ const isSubscriptionHook = (context) => isSwrHook(context) && /^useSubscribe[A-Z0-9]\w*Swr\.ts$/.test(fileOf(context).replaceAll("\\", "/").split("/").at(-1) ?? "")
234
+
235
+ /** The `onOpen` property of an object literal, else undefined. */
236
+ const onOpenOf = (options) =>
237
+ options?.type === "ObjectExpression"
238
+ ? options.properties.find((property) => property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "onOpen")
239
+ : undefined
240
+
241
+ /** True when a function calls `mutate` (the bound mutate of a hook or the one of `useSWRConfig`): the revalidation of the snapshot query. */
242
+ const callsMutate = (source, handler) => {
243
+ let found = false
244
+ walk(source, handler.body, (node) => {
245
+ if (found) return false
246
+ if (node.type !== "CallExpression") return undefined
247
+ const callee = unwrap(node.callee)
248
+ if ((callee?.type === "Identifier" && callee.name === "mutate") || (callee?.type === "MemberExpression" && propertyName(callee) === "mutate")) found = true
249
+ return undefined
250
+ })
251
+ return found
252
+ }
253
+
254
+ /** True when a callee is a stream operation: a named import, a member of a namespace import, or a const alias of either. */
255
+ const isStreamCallee = (source, input, locals, seen = new Set()) => {
256
+ const node = unwrap(input)
257
+ if (node?.type === "MemberExpression") {
258
+ const object = unwrap(node.object)
259
+ return object?.type === "Identifier" && locals.get(object.name) === "namespace" && bindingOf(source, object)?.defs[0]?.type === "ImportBinding"
260
+ }
261
+ if (node?.type !== "Identifier") return false
262
+ const variable = bindingOf(source, node)
263
+ if (!variable || seen.has(variable) || variable.defs.length !== 1) return false
264
+ const definition = variable.defs[0]
265
+ if (definition.type === "ImportBinding") return locals.get(variable.name) === "named"
266
+ if (definition.node.type !== "VariableDeclarator" || definition.parent.kind !== "const") return false
267
+ return isStreamCallee(source, definition.node.init, locals, new Set([...seen, variable]))
268
+ }
269
+
270
+ /**
271
+ * A stream or subscription has no replay of what it missed (graphql-ws has none, an SSE reconnect replays a bounded buffer), so the
272
+ * subscription hook revalidates the snapshot query each time the stream opens: its call of the stream operation (named, through a
273
+ * namespace or an alias) passes an `onOpen` handler that calls `mutate`, and a subscription hook that calls no stream operation at
274
+ * all is not one (FE-REALTIME-2).
275
+ */
276
+ export const subscriptionRefetchesSnapshot = {
277
+ meta: {
278
+ type: "problem",
279
+ docs: { description: "FE_SUBSCRIPTION_NO_SNAPSHOT: a subscription hook passes the stream operation an onOpen handler that revalidates the snapshot query." },
280
+ schema: [],
281
+ messages: {
282
+ missing:
283
+ "FE_SUBSCRIPTION_NO_SNAPSHOT: this call of a stream operation has no `onOpen` handler that calls `mutate`. A stream carries changes, not the state: pass `onOpen: () => void mutate(<snapshot query key>)` so the screen reads its snapshot with the query each time the stream (re)opens.",
284
+ noStream:
285
+ "FE_SUBSCRIPTION_NO_SNAPSHOT: a `useSubscribe<X>Swr` hook calls one stream operation (`modules/api/sse/<area>/stream-*.ts` or `modules/api/graphql/subscriptions/subscription-*.ts`) and revalidates its snapshot query in `onOpen`; this file calls none.",
286
+ },
287
+ },
288
+ create(context) {
289
+ if (!isProductSource(context) || !isSubscriptionHook(context)) return {}
290
+ const source = context.sourceCode ?? context.getSourceCode()
291
+ const locals = new Map()
292
+ let calls = 0
293
+ return {
294
+ ImportDeclaration(node) {
295
+ if (node.importKind === "type" || typeof node.source.value !== "string") return
296
+ const target = resolveImport(context, node.source.value)
297
+ if (target === null || !STREAM_OPERATION.test(target.replaceAll("\\", "/"))) return
298
+ for (const specifier of node.specifiers) {
299
+ if (specifier.importKind === "type") continue
300
+ if (specifier.type === "ImportSpecifier") locals.set(specifier.local.name, "named")
301
+ if (specifier.type === "ImportNamespaceSpecifier") locals.set(specifier.local.name, "namespace")
302
+ }
303
+ },
304
+ CallExpression(node) {
305
+ if (!isStreamCallee(source, node.callee, locals)) return
306
+ calls += 1
307
+ const handler = functionOf(context, onOpenOf(node.arguments[0])?.value)
308
+ if (!handler || !callsMutate(source, handler)) context.report({ node, messageId: "missing" })
309
+ },
310
+ "Program:exit"(program) {
311
+ if (calls === 0) context.report({ node: program, messageId: "noStream" })
184
312
  },
185
313
  }
186
314
  },
@@ -190,8 +318,9 @@ export const operationsOnlyInSwrHooks = {
190
318
  Object.assign(why, {
191
319
  "swr-only-in-family": { code: "FE_SWR_OUTSIDE_FAMILY", en: "A request hook is used outside the SWR family.", fix: "Call the operation's SWR wrapper hook." },
192
320
  "operations-only-in-swr-hooks": { code: "FE_OP_OUTSIDE_SWR_HOOK", en: "An API operation is consumed outside an SWR wrapper.", fix: "Wrap the operation in hooks/swr/." },
321
+ "subscription-refetches-snapshot": { code: "FE_SUBSCRIPTION_NO_SNAPSHOT", en: "A subscription hook does not revalidate its snapshot query when the stream opens.", fix: "Pass the stream operation an onOpen handler that revalidates the snapshot query key." },
193
322
  })
194
323
 
195
324
  /** R285's published lint enforcers. */
196
- export const rules = { "swr-only-in-family": swrOnlyInFamily, "operations-only-in-swr-hooks": operationsOnlyInSwrHooks }
325
+ export const rules = { "swr-only-in-family": swrOnlyInFamily, "operations-only-in-swr-hooks": operationsOnlyInSwrHooks, "subscription-refetches-snapshot": subscriptionRefetchesSnapshot }
197
326
  export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/transport.mjs CHANGED
@@ -28,12 +28,13 @@ import { typed } from "./lib/types.mjs"
28
28
 
29
29
  /**
30
30
  * The canonical transport libraries and the one place each may be imported (fe-stack graphql, rest and realtime): Apollo in
31
- * the API layer, axios in its REST calls, socket.io-client in `hooks/socketio`. Matched on the package a specifier names.
31
+ * the API layer, axios in its REST calls, graphql-ws in the Apollo clients folder, socket.io-client in `hooks/socketio`. Matched on the package a specifier names.
32
32
  */
33
33
  const PLACED_TRANSPORTS = new Map([
34
34
  ["@apollo/client", { where: "the API layer (`modules/api`)", allowed: (context) => isApiLayer(context) }],
35
35
  ["axios", { where: "the REST calls of the API layer (`modules/api/rest/`)", allowed: (context) => isApiRest(context) }],
36
36
  ["socket.io-client", { where: "`hooks/socketio/`", allowed: (context) => inSlot(context, "fe.hooks.socketio") }],
37
+ ["graphql-ws", { where: "the Apollo clients of the API layer (`modules/api/graphql/clients/`, or the shared api package's `graphql/clients/`)", allowed: (context) => isApiClient(context) }],
37
38
  ])
38
39
 
39
40
  /**