@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.
- package/CHANGELOG.md +11 -0
- package/README.md +10 -0
- package/app-shell.mjs +161 -0
- package/callback-shape.mjs +1 -13
- package/docs/app-shell.md +50 -0
- package/docs/grammar-boundary.md +25 -1
- package/docs/surface-rules.md +41 -0
- package/grammar-boundary.mjs +112 -0
- package/hooks-folder.mjs +9 -3
- package/icon.mjs +31 -1
- package/index.mjs +6 -0
- package/lib/app-shell-cluster.mjs +66 -0
- package/lib/app-shell-create.mjs +33 -0
- package/lib/app-shell-owner-source.mjs +124 -0
- package/lib/app-shell-owner.mjs +65 -0
- package/lib/app-shell-scope.mjs +79 -0
- package/lib/app-shell-visible.mjs +92 -0
- package/lib/app-shell.mjs +148 -0
- package/lib/bindings.mjs +15 -0
- package/lib/config.mjs +1 -0
- package/lib/fe-stack.mjs +8 -4
- package/lib/scope.mjs +2 -2
- package/lib/typed-class-values.mjs +72 -0
- package/lib/why.mjs +42 -2
- package/lint-escape-hatch.mjs +2 -2
- package/menu-entries.mjs +133 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
- package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
- package/runtime/knowledge/hfs/slots.yaml +113 -53
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/goal/source-phrases.yaml +95 -0
- package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
- package/runtime/modules/models/registry.yaml +3 -1
- package/runtime/modules/models/runtimes.yaml +2 -0
- package/runtime/scripts/api/git/ls-tree.mjs +5 -0
- package/runtime/scripts/api/git/show.mjs +6 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
- package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
- package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
- package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -1
- package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
- package/runtime/scripts/hfs/declaration-slots.mjs +14 -3
- package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +18 -6
- package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +3 -3
- package/runtime/scripts/hfs/slots.mjs +2 -1
- package/runtime/scripts/hfs/view.mjs +2 -1
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +94 -1
- package/runtime/scripts/lib/package-at.mjs +62 -0
- package/runtime/scripts/lib/same-text.mjs +5 -0
- package/runtime/scripts/lib/secret-patterns.mjs +58 -9
- package/runtime/scripts/lib/source-phrases.mjs +34 -0
- package/runtime/scripts/lib/ts-analysis.mjs +10 -0
- package/size-and-state-budget.mjs +3 -3
- package/surface-rules.mjs +230 -0
- package/swr-family.mjs +136 -7
- 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) =>
|
|
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
|
/**
|