@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.
- package/CHANGELOG.md +14 -0
- package/README.md +5 -1
- package/dist/brand-values.d.ts +28 -0
- package/dist/brand-values.js +172 -0
- package/dist/button-intent.d.ts +11 -0
- package/dist/button-intent.js +361 -0
- package/dist/callback-shape.d.ts +7 -0
- package/dist/callback-shape.js +396 -0
- package/dist/class-names.d.ts +19 -0
- package/dist/class-names.js +170 -0
- package/dist/client-boundary.d.ts +42 -0
- package/dist/client-boundary.js +404 -0
- package/dist/comments.d.ts +47 -0
- package/dist/comments.js +156 -0
- package/dist/control-height.d.ts +7 -0
- package/dist/control-height.js +83 -0
- package/dist/drawing.d.ts +31 -0
- package/dist/drawing.js +376 -0
- package/dist/env-owner.d.ts +22 -0
- package/dist/env-owner.js +291 -0
- package/dist/fe-stack.d.ts +10 -0
- package/dist/fe-stack.js +73 -0
- package/dist/file-layout.d.ts +71 -0
- package/dist/file-layout.js +465 -0
- package/dist/formatting.d.ts +25 -0
- package/dist/formatting.js +104 -0
- package/dist/function-budget.d.ts +9 -0
- package/dist/function-budget.js +48 -0
- package/dist/grammar-shim.d.ts +5 -0
- package/dist/grammar-shim.js +126 -0
- package/dist/hooks-folder.d.ts +34 -0
- package/dist/hooks-folder.js +320 -0
- package/dist/hygiene.d.ts +39 -0
- package/dist/hygiene.js +540 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +310 -0
- package/{landmark.mjs → dist/landmark.d.ts} +4 -6
- package/dist/landmark.js +6 -0
- package/dist/lib/ast.d.ts +36 -0
- package/dist/lib/ast.js +94 -0
- package/dist/lib/bindings.d.ts +84 -0
- package/dist/lib/bindings.js +168 -0
- package/dist/lib/class-text.d.ts +3 -0
- package/dist/lib/class-text.js +30 -0
- package/dist/lib/config.d.ts +81 -0
- package/dist/lib/config.js +141 -0
- package/dist/lib/drawing-scope.d.ts +5 -0
- package/{lib/drawing-scope.mjs → dist/lib/drawing-scope.js} +6 -11
- package/dist/lib/eslint-boundary.d.ts +23 -0
- package/dist/lib/eslint-boundary.js +6 -0
- package/dist/lib/fe-stack.d.ts +49 -0
- package/dist/lib/fe-stack.js +98 -0
- package/dist/lib/hfs-view.d.ts +74 -0
- package/dist/lib/hfs-view.js +6 -0
- package/dist/lib/hfs.d.ts +34 -0
- package/dist/lib/hfs.js +72 -0
- package/dist/lib/next.d.ts +4 -0
- package/dist/lib/next.js +10 -0
- package/dist/lib/own-hook-origin.d.ts +17 -0
- package/dist/lib/own-hook-origin.js +52 -0
- package/dist/lib/package-root.d.ts +4 -0
- package/dist/lib/package-root.js +10 -0
- package/dist/lib/path.d.ts +2 -0
- package/{lib/path.mjs → dist/lib/path.js} +1 -1
- package/dist/lib/platform.d.ts +26 -0
- package/{lib/platform.mjs → dist/lib/platform.js} +38 -35
- package/dist/lib/program-for.d.ts +5 -0
- package/dist/lib/program-for.js +23 -0
- package/dist/lib/pure-owner-helper.d.ts +10 -0
- package/dist/lib/pure-owner-helper.js +156 -0
- package/dist/lib/react.d.ts +16 -0
- package/dist/lib/react.js +22 -0
- package/dist/lib/redux-hook-origin.d.ts +4 -0
- package/dist/lib/redux-hook-origin.js +50 -0
- package/dist/lib/rule.d.ts +28 -0
- package/dist/lib/rule.js +5 -0
- package/dist/lib/scope.d.ts +88 -0
- package/{lib/scope.mjs → dist/lib/scope.js} +83 -93
- package/dist/lib/typed-class-values.d.ts +13 -0
- package/dist/lib/typed-class-values.js +92 -0
- package/dist/lib/types.d.ts +44 -0
- package/dist/lib/types.js +64 -0
- package/dist/lib/why.d.ts +350 -0
- package/dist/lib/why.js +350 -0
- package/dist/lint-escape-hatch.d.ts +24 -0
- package/{lint-escape-hatch.mjs → dist/lint-escape-hatch.js} +38 -45
- package/dist/lists.d.ts +31 -0
- package/dist/lists.js +202 -0
- package/dist/loading.d.ts +34 -0
- package/dist/loading.js +157 -0
- package/dist/naming.d.ts +33 -0
- package/dist/naming.js +176 -0
- package/dist/native-controls.d.ts +24 -0
- package/dist/native-controls.js +105 -0
- package/dist/next-conventions.d.ts +48 -0
- package/dist/next-conventions.js +452 -0
- package/dist/project-graph.d.ts +5 -0
- package/{project-graph.mjs → dist/project-graph.js} +8 -12
- package/dist/props-and-slots.d.ts +19 -0
- package/dist/props-and-slots.js +320 -0
- package/dist/served-locale.d.ts +38 -0
- package/dist/served-locale.js +263 -0
- package/dist/shape-slot.d.ts +18 -0
- package/dist/shape-slot.js +324 -0
- package/dist/size-and-state-budget.d.ts +46 -0
- package/dist/size-and-state-budget.js +424 -0
- package/dist/size-growth.d.ts +24 -0
- package/dist/size-growth.js +64 -0
- package/dist/sonar-parity.d.ts +5 -0
- package/{sonar-parity.mjs → dist/sonar-parity.js} +11 -7
- package/dist/surface-rules.d.ts +18 -0
- package/dist/surface-rules.js +491 -0
- package/dist/swr-family.d.ts +15 -0
- package/dist/swr-family.js +419 -0
- package/dist/the-split.d.ts +27 -0
- package/dist/the-split.js +157 -0
- package/dist/tokens.d.ts +9 -0
- package/dist/tokens.js +35 -0
- package/dist/translation.d.ts +51 -0
- package/dist/translation.js +403 -0
- package/dist/transport.d.ts +73 -0
- package/dist/transport.js +885 -0
- package/dist/type-safety.d.ts +43 -0
- package/dist/type-safety.js +139 -0
- package/dist/ui-rulings.d.ts +11 -0
- package/dist/ui-rulings.js +121 -0
- package/package.json +31 -9
- package/runtime/knowledge/hfs/canon-pins.yaml +22 -14
- package/runtime/scripts/lib/package-at.mjs +5 -5
- package/brand-values.mjs +0 -172
- package/button-intent.mjs +0 -320
- package/callback-shape.mjs +0 -332
- package/class-names.mjs +0 -149
- package/client-boundary.mjs +0 -400
- package/comments.mjs +0 -159
- package/control-height.mjs +0 -77
- package/drawing.mjs +0 -315
- package/env-owner.mjs +0 -244
- package/fe-stack.mjs +0 -86
- package/file-layout.mjs +0 -472
- package/formatting.mjs +0 -110
- package/function-budget.mjs +0 -49
- package/grammar-shim.mjs +0 -123
- package/hooks-folder.mjs +0 -300
- package/hygiene.mjs +0 -513
- package/index.mjs +0 -317
- package/lib/ast.mjs +0 -97
- package/lib/bindings.mjs +0 -159
- package/lib/class-text.mjs +0 -29
- package/lib/config.mjs +0 -142
- package/lib/fe-stack.mjs +0 -77
- package/lib/hfs.mjs +0 -49
- package/lib/next.mjs +0 -6
- package/lib/own-hook-origin.mjs +0 -53
- package/lib/program-for.mjs +0 -22
- package/lib/pure-owner-helper.mjs +0 -148
- package/lib/react.mjs +0 -28
- package/lib/redux-hook-origin.mjs +0 -44
- package/lib/typed-class-values.mjs +0 -81
- package/lib/types.mjs +0 -63
- package/lib/why.mjs +0 -352
- package/lists.mjs +0 -193
- package/loading.mjs +0 -156
- package/naming.mjs +0 -185
- package/native-controls.mjs +0 -108
- package/next-conventions.mjs +0 -447
- package/props-and-slots.mjs +0 -276
- package/served-locale.mjs +0 -243
- package/size-and-state-budget.mjs +0 -376
- package/size-growth.mjs +0 -62
- package/surface-rules.mjs +0 -443
- package/swr-family.mjs +0 -326
- package/the-split.mjs +0 -155
- package/tokens.mjs +0 -39
- package/translation.mjs +0 -392
- package/transport.mjs +0 -830
- package/type-safety.mjs +0 -147
- package/ui-rulings.mjs +0 -134
package/grammar-shim.mjs
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rule that holds `grammar-shim.md` (HFS R321, `FE_GRAMMAR_SHIM`).
|
|
3
|
-
*
|
|
4
|
-
* An app never re-exports a Grammar export under its own name. A shim, a file whose job is to hand a Grammar component
|
|
5
|
-
* on from an app path (often behind a directive the Grammar forgot), makes the app green while the defect stays in the
|
|
6
|
-
* Grammar for every other app; the defect is reported to the Grammar instead. Importing a Grammar component to render
|
|
7
|
-
* it, wrapping it in an app component and type-only re-exports are not shims. Source inside the Grammar package itself
|
|
8
|
-
* is out of scope: its entries re-export its components by design.
|
|
9
|
-
*/
|
|
10
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
11
|
-
import { dirname, resolve } from "node:path"
|
|
12
|
-
import { unwrap, variableOf } from "./lib/bindings.mjs"
|
|
13
|
-
|
|
14
|
-
/** Every published entry of the Grammar package, the root and each subpath. */
|
|
15
|
-
const GRAMMAR_SOURCE = /^@starci\/grammar(?:\/|$)/
|
|
16
|
-
/** The Grammar package's own manifest name; its source re-exports by design. */
|
|
17
|
-
const GRAMMAR_PACKAGE = "@starci/grammar"
|
|
18
|
-
|
|
19
|
-
/** The manifest name of the package owning each directory, read once per directory. */
|
|
20
|
-
const packageNames = new Map()
|
|
21
|
-
|
|
22
|
-
/** The `name` of the nearest `package.json` that declares one, or null at the filesystem root. */
|
|
23
|
-
const packageNameOf = (directory) => {
|
|
24
|
-
if (packageNames.has(directory)) return packageNames.get(directory)
|
|
25
|
-
const parent = dirname(directory)
|
|
26
|
-
const manifest = resolve(directory, "package.json")
|
|
27
|
-
let name = null
|
|
28
|
-
if (existsSync(manifest)) {
|
|
29
|
-
try {
|
|
30
|
-
const declared = JSON.parse(readFileSync(manifest, "utf8")).name
|
|
31
|
-
if (typeof declared === "string") name = declared
|
|
32
|
-
} catch {
|
|
33
|
-
name = null
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
if (name === null && parent !== directory) name = packageNameOf(parent)
|
|
37
|
-
packageNames.set(directory, name)
|
|
38
|
-
return name
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** True for a type-only import or export, declaration-wide or per specifier; a type is no component to shim. */
|
|
42
|
-
const typeOnly = (...nodes) => nodes.some((node) => node.importKind === "type" || node.exportKind === "type")
|
|
43
|
-
|
|
44
|
-
/** The Grammar import an identifier binds, `{ source, name }`, or null for a local, foreign or type-only binding. */
|
|
45
|
-
const grammarImportOf = (context, identifier) => {
|
|
46
|
-
const definition = variableOf(context, identifier)?.defs.find((entry) => entry.type === "ImportBinding")
|
|
47
|
-
if (!definition) return null
|
|
48
|
-
const source = String(definition.parent.source.value)
|
|
49
|
-
if (!GRAMMAR_SOURCE.test(source) || typeOnly(definition.parent, definition.node)) return null
|
|
50
|
-
const specifier = definition.node
|
|
51
|
-
const name = specifier.type === "ImportSpecifier" ? specifier.imported.name ?? String(specifier.imported.value) : identifier.name
|
|
52
|
-
return { source, name }
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** The Grammar import an exported value is exactly (`X`, `G.X`), or null for anything computed or wrapped. */
|
|
56
|
-
const grammarValueOf = (context, expression) => {
|
|
57
|
-
let node = unwrap(expression)
|
|
58
|
-
const path = []
|
|
59
|
-
while (node?.type === "MemberExpression" && !node.computed && node.property.type === "Identifier") {
|
|
60
|
-
path.unshift(node.property.name)
|
|
61
|
-
node = unwrap(node.object)
|
|
62
|
-
}
|
|
63
|
-
if (node?.type !== "Identifier") return null
|
|
64
|
-
const imported = grammarImportOf(context, node)
|
|
65
|
-
if (imported === null) return null
|
|
66
|
-
return path.length === 0 ? imported : { source: imported.source, name: path.join(".") }
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** The name an export specifier spells, identifier or string literal. */
|
|
70
|
-
const spelled = (node) => node.name ?? String(node.value)
|
|
71
|
-
|
|
72
|
-
const grammarShim = {
|
|
73
|
-
meta: {
|
|
74
|
-
type: "problem",
|
|
75
|
-
docs: { description: "R321: an app never re-exports a Grammar export under its own name; a Grammar defect is reported to the Grammar, not shimmed in the app." },
|
|
76
|
-
schema: [],
|
|
77
|
-
messages: {
|
|
78
|
-
FE_GRAMMAR_SHIM: "FE_GRAMMAR_SHIM: this file re-exports {{name}} of {{source}} under the app's own name. A defect in a Grammar component belongs to the Grammar: report it (the op files `blocked` naming the package, the component and the error), never shim it in the app; import the component from {{source}} where it is rendered.",
|
|
79
|
-
},
|
|
80
|
-
},
|
|
81
|
-
create(context) {
|
|
82
|
-
const filename = resolve(context.cwd, context.physicalFilename)
|
|
83
|
-
if (packageNameOf(dirname(filename)) === GRAMMAR_PACKAGE) return {}
|
|
84
|
-
const report = (node, name, source) => context.report({ node, messageId: "FE_GRAMMAR_SHIM", data: { name: name === null ? "every export" : `\`${name}\``, source } })
|
|
85
|
-
return {
|
|
86
|
-
ExportAllDeclaration(node) {
|
|
87
|
-
const source = String(node.source.value)
|
|
88
|
-
if (!GRAMMAR_SOURCE.test(source) || typeOnly(node)) return
|
|
89
|
-
report(node, node.exported ? spelled(node.exported) : null, source)
|
|
90
|
-
},
|
|
91
|
-
ExportNamedDeclaration(node) {
|
|
92
|
-
if (typeOnly(node)) return
|
|
93
|
-
if (node.source) {
|
|
94
|
-
const source = String(node.source.value)
|
|
95
|
-
if (!GRAMMAR_SOURCE.test(source)) return
|
|
96
|
-
for (const specifier of node.specifiers) {
|
|
97
|
-
if (!typeOnly(specifier)) report(specifier, spelled(specifier.local), source)
|
|
98
|
-
}
|
|
99
|
-
return
|
|
100
|
-
}
|
|
101
|
-
for (const specifier of node.specifiers) {
|
|
102
|
-
const imported = typeOnly(specifier) || specifier.local.type !== "Identifier" ? null : grammarImportOf(context, specifier.local)
|
|
103
|
-
if (imported) report(specifier, imported.name, imported.source)
|
|
104
|
-
}
|
|
105
|
-
if (node.declaration?.type !== "VariableDeclaration") return
|
|
106
|
-
for (const declarator of node.declaration.declarations) {
|
|
107
|
-
const imported = declarator.init ? grammarValueOf(context, declarator.init) : null
|
|
108
|
-
if (imported) report(declarator, imported.name, imported.source)
|
|
109
|
-
}
|
|
110
|
-
},
|
|
111
|
-
ExportDefaultDeclaration(node) {
|
|
112
|
-
const imported = grammarValueOf(context, node.declaration)
|
|
113
|
-
if (imported) report(node, imported.name, imported.source)
|
|
114
|
-
},
|
|
115
|
-
}
|
|
116
|
-
},
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** The one rule of the law. */
|
|
120
|
-
export const rules = { "no-grammar-shim": grammarShim }
|
|
121
|
-
|
|
122
|
-
/** The rule reaches consumers at error. */
|
|
123
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/hooks-folder.mjs
DELETED
|
@@ -1,300 +0,0 @@
|
|
|
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, per family of the front-end stack (each its own slot):
|
|
8
|
-
*
|
|
9
|
-
* hooks/swr/api/{graphql,rest}/{queries,mutations}/use{Query,Mutate,Get,Post,Put,Patch,Delete}<X>Swr.ts
|
|
10
|
-
* hooks/swr/api/sse/subscriptions/useSubscribe<X>Swr.ts
|
|
11
|
-
* one SWR hook per modules/api operation (fe.hooks.swr), plus SwrSideEffects.tsx
|
|
12
|
-
* hooks/rhf/use<X>Form.ts one form (fe.hooks.rhf)
|
|
13
|
-
* hooks/zustand/<store>/store.ts one local store and its use*.ts selectors (fe.hooks.zustand)
|
|
14
|
-
* hooks/socketio/ sockets.ts, SocketIoSideEffects.tsx, enums/, types/, use*.ts (fe.hooks.socketio)
|
|
15
|
-
* hooks/effects/use<X>.ts app-wide side effects (fe.hooks.effects)
|
|
16
|
-
* hooks/reuseables/use<X>.ts generic reusable hooks (fe.hooks.reuseables)
|
|
17
|
-
* hooks/<domain>/use<Name>.ts one hook per file (fe.hooks)
|
|
18
|
-
* hooks/<domain>/<domain>.shared.ts one file for the domain's non-hook helpers (key builders...)
|
|
19
|
-
*
|
|
20
|
-
* A call to the back end is never a hook's own code: it is an operation of `modules/api`, which an SWR hook wraps. The two
|
|
21
|
-
* `.tsx` files a family names (`SwrSideEffects.tsx`, `SocketIoSideEffects.tsx`, role `effects`) are the components a provider
|
|
22
|
-
* mounts once; every other file of a family carries the name its slot gives it.
|
|
23
|
-
*
|
|
24
|
-
* The rule holds the shape of one file at a time. Whether a folder holds exactly one `.shared.ts`
|
|
25
|
-
* and whether a helper name repeats across files is a repository fact, and belongs to the
|
|
26
|
-
* architecture machine that sees the whole tree.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { importedReduxHook } from "./lib/redux-hook-origin.mjs"
|
|
30
|
-
import { importedOwnHook } from "./lib/own-hook-origin.mjs"
|
|
31
|
-
import { hfsOf } from "./lib/hfs.mjs"
|
|
32
|
-
import { walk } from "./lib/ast.mjs"
|
|
33
|
-
import { functionOf, isFn, unwrap, variableOf } from "./lib/bindings.mjs"
|
|
34
|
-
import { baseName, classOf, fileOf, inSlot } from "./lib/scope.mjs"
|
|
35
|
-
import { rules as swrFamilyRules } from "./swr-family.mjs"
|
|
36
|
-
|
|
37
|
-
/** Names that mark a hook. */
|
|
38
|
-
const isHookName = (name) => /^use[A-Z0-9]/.test(name)
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The SWR key of a hook (`QUERY_<X>_SWR_KEY`, `MUTATE_<X>_SWR_KEY`, `SUBSCRIBE_<X>_SWR_KEY`, fe-stack data): the one constant a hook file exports beside its
|
|
42
|
-
* hook, so a mutation or a socket message revalidates exactly that cache entry.
|
|
43
|
-
*/
|
|
44
|
-
const isSwrKeyName = (name) => /^[A-Z][A-Z0-9_]*_SWR_KEY$/.test(name)
|
|
45
|
-
|
|
46
|
-
/** A hook file name: `use<Name>.ts`. */
|
|
47
|
-
const HOOK_FILE = new RegExp(String.raw`^use[A-Z0-9]\w*\.ts$`)
|
|
48
|
-
const isHookFileName = (name) => HOOK_FILE.test(name)
|
|
49
|
-
|
|
50
|
-
/** Modules that only run on the server: server code, not a hook. */
|
|
51
|
-
const SERVER_ONLY = new Set(["server-only", "next/headers"])
|
|
52
|
-
|
|
53
|
-
/** The hooks families of the front-end stack: each slot names its files (`allows`) and its non-hook files (roles). */
|
|
54
|
-
const FAMILY_SLOTS = ["fe.hooks.swr", "fe.hooks.rhf", "fe.hooks.zustand", "fe.hooks.socketio", "fe.hooks.effects", "fe.hooks.reuseables"]
|
|
55
|
-
|
|
56
|
-
/** Runtime exports and their local values; type exports do not introduce hooks or helpers. */
|
|
57
|
-
const exportedEntries = (node) => {
|
|
58
|
-
if (node.type === "ExportDefaultDeclaration") {
|
|
59
|
-
const declaration = node.declaration
|
|
60
|
-
return [{ node, name: declaration.id?.name ?? "default", local: declaration, default: true }]
|
|
61
|
-
}
|
|
62
|
-
if (node.exportKind === "type") return []
|
|
63
|
-
if (node.type === "ExportAllDeclaration") return [{ node, name: "*", local: null }]
|
|
64
|
-
const declaration = node.declaration
|
|
65
|
-
if (!declaration) {
|
|
66
|
-
return node.specifiers.filter((specifier) => specifier.exportKind !== "type").map((specifier) => ({
|
|
67
|
-
node, name: specifier.exported.name ?? specifier.exported.value, local: node.source ? null : specifier.local,
|
|
68
|
-
default: (specifier.exported.name ?? specifier.exported.value) === "default",
|
|
69
|
-
}))
|
|
70
|
-
}
|
|
71
|
-
if (declaration.type === "FunctionDeclaration" && declaration.id) return [{ node, name: declaration.id.name, local: declaration }]
|
|
72
|
-
if (declaration.type === "VariableDeclaration") {
|
|
73
|
-
return declaration.declarations.filter((d) => d.id.type === "Identifier").map((d) => ({ node, name: d.id.name, local: d.id }))
|
|
74
|
-
}
|
|
75
|
-
return []
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Actual React hook APIs and SWR public entry points, rather than an arbitrary `use*` spelling. */
|
|
79
|
-
const REACT_HOOKS = new Set([
|
|
80
|
-
"use", "useState", "useReducer", "useEffect", "useLayoutEffect", "useInsertionEffect", "useRef", "useImperativeHandle",
|
|
81
|
-
"useMemo", "useCallback", "useContext", "useDebugValue", "useDeferredValue", "useTransition", "useId",
|
|
82
|
-
"useSyncExternalStore", "useActionState", "useOptimistic",
|
|
83
|
-
])
|
|
84
|
-
const SWR_HOOKS = new Map([
|
|
85
|
-
["swr", new Set(["useSWR", "useSWRConfig"])],
|
|
86
|
-
["swr/immutable", new Set(["useSWRImmutable"])],
|
|
87
|
-
["swr/infinite", new Set(["useSWRInfinite"])],
|
|
88
|
-
["swr/mutation", new Set(["useSWRMutation"])],
|
|
89
|
-
["swr/subscription", new Set(["useSWRSubscription"])],
|
|
90
|
-
])
|
|
91
|
-
const SWR_DEFAULTS = new Map([["swr", "useSWR"], ["swr/immutable", "useSWRImmutable"], ["swr/infinite", "useSWRInfinite"], ["swr/mutation", "useSWRMutation"], ["swr/subscription", "useSWRSubscription"]])
|
|
92
|
-
/**
|
|
93
|
-
* The canonical library hooks a composite hook may call (R56, FE-THEME): next-intl, next/navigation,
|
|
94
|
-
* next-themes, react-hook-form and zustand's React entry points. A name outside the list is not a hook by its spelling.
|
|
95
|
-
*/
|
|
96
|
-
const LIBRARY_HOOKS = new Map([
|
|
97
|
-
["next-intl", new Set(["useTranslations", "useLocale", "useFormatter", "useNow", "useTimeZone", "useMessages"])],
|
|
98
|
-
["next/navigation", new Set(["useRouter", "usePathname", "useSearchParams", "useParams", "useSelectedLayoutSegment", "useSelectedLayoutSegments"])],
|
|
99
|
-
["next-themes", new Set(["useTheme"])],
|
|
100
|
-
["react-hook-form", new Set(["useForm", "useFormContext", "useWatch", "useController", "useFieldArray", "useFormState"])],
|
|
101
|
-
["zustand", new Set(["useStore"])],
|
|
102
|
-
["zustand/react/shallow", new Set(["useShallow"])],
|
|
103
|
-
])
|
|
104
|
-
const OWN_HOOK = "own-hooks"
|
|
105
|
-
const builtinHook = (origin) => origin?.source === OWN_HOOK || Boolean(LIBRARY_HOOKS.get(origin?.source)?.has(origin.name)) || origin?.source === "react-redux" || (origin?.source === "react" ? REACT_HOOKS.has(origin.name) : Boolean(SWR_HOOKS.get(origin?.source)?.has(origin.name)))
|
|
106
|
-
const propertyName = (node) => {
|
|
107
|
-
if (!node.computed && node.property.type === "Identifier") return node.property.name
|
|
108
|
-
return node.property.type === "Literal" ? node.property.value : null
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** SWR request hooks are default exports; its root also names useSWRConfig. */
|
|
112
|
-
const namedOrigin = (source, name) => {
|
|
113
|
-
if (source === "react") return { source, name }
|
|
114
|
-
if (LIBRARY_HOOKS.get(source)?.has(name)) return { source, name }
|
|
115
|
-
if (name === "default" && SWR_DEFAULTS.has(source)) return { source, name: SWR_DEFAULTS.get(source) }
|
|
116
|
-
return source === "swr" && name === "useSWRConfig" ? { source, name } : null
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** A runtime import records whether its value is a hook or its module namespace. */
|
|
120
|
-
const importOrigin = (definition) => {
|
|
121
|
-
const specifier = definition.node
|
|
122
|
-
const source = String(definition.parent.source.value)
|
|
123
|
-
if (definition.parent.importKind === "type" || specifier.importKind === "type") return null
|
|
124
|
-
if (source !== "react" && !SWR_HOOKS.has(source) && !LIBRARY_HOOKS.has(source)) return null
|
|
125
|
-
if (source === "next-themes" && specifier.type === "ImportDefaultSpecifier") return null
|
|
126
|
-
if (specifier.type === "ImportSpecifier") return namedOrigin(source, specifier.imported.name ?? specifier.imported.value)
|
|
127
|
-
if (specifier.type === "ImportDefaultSpecifier" && SWR_DEFAULTS.has(source)) return { source, name: SWR_DEFAULTS.get(source) }
|
|
128
|
-
return { source, name: null }
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Resolve a constant alias, including destructuring a React/SWR namespace. */
|
|
132
|
-
const constantOrigin = (context, definition, variable, seen) => {
|
|
133
|
-
const declaration = definition.node
|
|
134
|
-
if (declaration.type !== "VariableDeclarator" || definition.parent.kind !== "const") return null
|
|
135
|
-
const origin = hookOrigin(context, declaration.init, seen)
|
|
136
|
-
if (declaration.id.type === "Identifier") return origin
|
|
137
|
-
if (declaration.id.type !== "ObjectPattern" || !origin || origin.name !== null) return null
|
|
138
|
-
const property = declaration.id.properties.find((entry) => entry.type === "Property" && entry.value.type === "Identifier" && entry.value.name === variable.name)
|
|
139
|
-
if (!property) return null
|
|
140
|
-
const name = property.computed || property.key.type === "Literal" ? property.key.value : property.key.name
|
|
141
|
-
return namedOrigin(origin.source, name)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Follow lexical bindings so shadowed, reassigned and unrelated `use*` values cannot prove a hook. */
|
|
145
|
-
const hookOrigin = (context, input, seen = new Set()) => {
|
|
146
|
-
const node = unwrap(input)
|
|
147
|
-
if (node?.type === "MemberExpression") {
|
|
148
|
-
const origin = hookOrigin(context, node.object, seen)
|
|
149
|
-
if (origin?.name !== null) return null
|
|
150
|
-
const name = propertyName(node)
|
|
151
|
-
return namedOrigin(origin.source, name)
|
|
152
|
-
}
|
|
153
|
-
if (node?.type !== "Identifier") return null
|
|
154
|
-
const variable = variableOf(context, node)
|
|
155
|
-
if (!variable || seen.has(variable) || variable.defs.length !== 1) return null
|
|
156
|
-
if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return null
|
|
157
|
-
const definition = variable.defs[0]
|
|
158
|
-
if (definition.type === "ImportBinding") {
|
|
159
|
-
const origin = importOrigin(definition)
|
|
160
|
-
if (origin) return origin
|
|
161
|
-
if (importedReduxHook(context, definition)) return { source: "react-redux", name: "hook" }
|
|
162
|
-
return importedOwnHook(context, definition) ? { source: OWN_HOOK, name: "hook" } : null
|
|
163
|
-
}
|
|
164
|
-
return constantOrigin(context, definition, variable, new Set([...seen, variable]))
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** The exported function itself must call the hook; an unused nested callback is not evidence. */
|
|
168
|
-
const callsBuiltinHook = (context, local) => {
|
|
169
|
-
const hook = functionOf(context, local)
|
|
170
|
-
if (!hook) return false
|
|
171
|
-
let found = false
|
|
172
|
-
walk(context.sourceCode, hook.body, (node) => {
|
|
173
|
-
if (found || isFn(node)) return false
|
|
174
|
-
if (node.type === "CallExpression" && builtinHook(hookOrigin(context, node.callee))) found = true
|
|
175
|
-
return undefined
|
|
176
|
-
})
|
|
177
|
-
return found
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Exactly one named, locally implemented hook; optional SWR key and type exports remain available. */
|
|
181
|
-
const checkHookExports = (context, program, exported) => {
|
|
182
|
-
const hooks = exported.filter((item) => !item.default && isHookName(item.name))
|
|
183
|
-
const defaults = exported.filter((item) => item.default)
|
|
184
|
-
for (const item of defaults) context.report({ node: item.node, messageId: "defaultExport" })
|
|
185
|
-
for (const item of exported) {
|
|
186
|
-
if (!item.default && !isHookName(item.name) && !isSwrKeyName(item.name)) {
|
|
187
|
-
context.report({ node: item.node, messageId: "export", data: { name: item.name } })
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
if (hooks.length === 0 && defaults.length === 0) context.report({ node: program, messageId: "missing" })
|
|
191
|
-
if (hooks.length > 1) context.report({ node: hooks[1].node, messageId: "many", data: { count: hooks.length } })
|
|
192
|
-
for (const item of hooks) {
|
|
193
|
-
if (!callsBuiltinHook(context, item.local)) context.report({ node: item.node, messageId: "fake", data: { name: item.name } })
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* What the linted file is inside `hooks/`, or null when no hooks slot owns it: `stray` is a file its slot does not name
|
|
199
|
-
* (directly in `hooks/`, a non-hook name in a domain, a name outside a family's `allows`); `hookFile` a `use<Name>.ts` hook
|
|
200
|
-
* file; `sharedFile` a domain's `<domain>.shared.ts`.
|
|
201
|
-
*/
|
|
202
|
-
const hookFileOf = (context) => {
|
|
203
|
-
const hfs = hfsOf(context)
|
|
204
|
-
const file = fileOf(context)
|
|
205
|
-
const found = classOf(context)
|
|
206
|
-
const name = baseName(file)
|
|
207
|
-
if (inSlot(context, ...FAMILY_SLOTS)) {
|
|
208
|
-
const verdict = hfs.allows(file)
|
|
209
|
-
const stray = Boolean(verdict) && !verdict.allowed
|
|
210
|
-
const allows = (verdict?.allows ?? hfs.slot(found.slot)?.allows ?? []).join("`, `")
|
|
211
|
-
// A role the slot names (`entry`, `effects`, `store`) or a non-hook file it allows (sockets.ts, enums/, types/, barrels) is no hook file.
|
|
212
|
-
const subscriptions = ["/api/sse/subscriptions/", "/api/graphql/subscriptions/"].some((folder) => file.split(String.fromCharCode(92)).join("/").includes(folder))
|
|
213
|
-
return { stray, directlyInHooks: false, hookFile: !stray && !found.role && isHookFileName(name), sharedFile: false, family: found.slot, allows, subscriptions }
|
|
214
|
-
}
|
|
215
|
-
if (!inSlot(context, "fe.hooks")) return null
|
|
216
|
-
// A file directly in `hooks/` is its own root: the slot binds the file name as the `domain`.
|
|
217
|
-
const directlyInHooks = hfs.relative(file) === found.root
|
|
218
|
-
// The slot names the domain's entry (`index.ts`, role `entry`) and its shared helper file (`<domain>.shared.ts`, role `shared`).
|
|
219
|
-
// Both sit directly in the domain folder: a nested `x/index.ts` is a file the slot does not name.
|
|
220
|
-
const directlyInDomain = hfs.relative(file) === `${found.root}/${name}`
|
|
221
|
-
const entryFile = directlyInDomain && found.role === "entry"
|
|
222
|
-
const sharedFile = directlyInDomain && found.role === "shared"
|
|
223
|
-
const hookFile = isHookFileName(name)
|
|
224
|
-
return { stray: !hookFile && !sharedFile && !entryFile, directlyInHooks, hookFile, sharedFile, family: null, allows: "" }
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** `hooks/` holds React hooks, each under the name its family or domain gives it, and one shared helper file per domain. */
|
|
228
|
-
export const hooksFolderHoldsHooksOnly = {
|
|
229
|
-
meta: {
|
|
230
|
-
type: "problem",
|
|
231
|
-
docs: { description: "`hooks/` holds `use*.ts` hooks under the names their family slot gives (`hooks/swr`, `hooks/rhf`, `hooks/zustand`, `hooks/socketio`, `hooks/effects`, `hooks/reuseables`) and, per domain, one `<domain>.shared.ts`; nothing else." },
|
|
232
|
-
schema: [],
|
|
233
|
-
messages: {
|
|
234
|
-
domain:
|
|
235
|
-
"A file directly in `hooks/`. Hooks live in a family folder (`hooks/swr/`, `hooks/rhf/`, `hooks/zustand/<store>/`, `hooks/socketio/`, `hooks/effects/`, `hooks/reuseables/`) or in `hooks/<domain>/use<Name>.ts`; the folder is what keeps a hook findable and a shared helper next to the hooks that use it.",
|
|
236
|
-
notHook:
|
|
237
|
-
"`{{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 call to the back end is an operation of `modules/api`, wrapped by an SWR hook in `hooks/swr/`.",
|
|
238
|
-
family:
|
|
239
|
-
"`{{name}}` is not a file `{{family}}` names. This family holds `{{allows}}`: an SWR hook is `use{Query,Mutate}<X>Swr.ts` (GraphQL) or `use{Get,Post,Put,Patch,Delete}<X>Swr.ts` (REST) in its `api/<protocol>/<queries|mutations>/` folder, a form is `use<X>Form.ts`. A name outside the list is a hook nobody finds by its family, or code that is not a hook.",
|
|
240
|
-
subscription:
|
|
241
|
-
"`{{name}}` is not a file `{{family}}` names in a subscriptions folder. A stream or GraphQL subscription hook is `useSubscribe<X>Swr.ts` in `api/sse/subscriptions/` or `api/graphql/subscriptions/`, one hook per file, exporting at most its `SUBSCRIBE_<X>_SWR_KEY`.",
|
|
242
|
-
export:
|
|
243
|
-
"`{{name}}` is exported from a hook file but is not a hook. A hook file exports the hook (a function named `use...`) and at most its SWR key (`QUERY_<X>_SWR_KEY`, `MUTATE_<X>_SWR_KEY`); a helper belongs in `<domain>.shared.ts`.",
|
|
244
|
-
many:
|
|
245
|
-
"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.",
|
|
246
|
-
missing:
|
|
247
|
-
"FE_HOOKS_ARE_HOOKS: a hook file exports exactly one named use* function that calls a hook (React, Redux, SWR, a listed library hook or a use* hook of the app's own hooks folders). Non-hook helpers belong in the domain's shared helper file.",
|
|
248
|
-
defaultExport:
|
|
249
|
-
"FE_HOOKS_ARE_HOOKS: export the hook as a named use* function. A default export does not identify the hook this file owns.",
|
|
250
|
-
fake:
|
|
251
|
-
"FE_HOOKS_ARE_HOOKS: `{{name}}` must be a function that calls a real hook: a React, Redux or SWR hook, a listed library hook (next-intl, next/navigation, next-themes useTheme, react-hook-form, zustand) or a hook of the app's own `hooks/` folders. A use* name alone does not make a reader or helper a hook.",
|
|
252
|
-
sharedHook:
|
|
253
|
-
"`{{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`.",
|
|
254
|
-
server:
|
|
255
|
-
"`{{source}}` marks server-only code, which is not a hook. The front end reads its back end through the operations of `modules/api`, wrapped by the SWR hooks of `hooks/swr/`; request-scoped server APIs stay in server components.",
|
|
256
|
-
},
|
|
257
|
-
},
|
|
258
|
-
create(context) {
|
|
259
|
-
const judged = hookFileOf(context)
|
|
260
|
-
if (judged === null) return {}
|
|
261
|
-
const name = baseName(fileOf(context))
|
|
262
|
-
return {
|
|
263
|
-
Program(program) {
|
|
264
|
-
if (judged.directlyInHooks) return context.report({ node: program, messageId: "domain" })
|
|
265
|
-
if (!judged.stray) return
|
|
266
|
-
if (judged.family && judged.subscriptions) return context.report({ node: program, messageId: "subscription", data: { name, family: judged.family } })
|
|
267
|
-
if (judged.family) context.report({ node: program, messageId: "family", data: { name, family: judged.family, allows: judged.allows } })
|
|
268
|
-
else context.report({ node: program, messageId: "notHook", data: { name } })
|
|
269
|
-
},
|
|
270
|
-
ImportDeclaration(node) {
|
|
271
|
-
if (SERVER_ONLY.has(String(node.source.value))) {
|
|
272
|
-
context.report({ node, messageId: "server", data: { source: node.source.value } })
|
|
273
|
-
}
|
|
274
|
-
},
|
|
275
|
-
"Program:exit"(program) {
|
|
276
|
-
if (judged.directlyInHooks || judged.stray || (!judged.hookFile && !judged.sharedFile)) return
|
|
277
|
-
const exported = program.body
|
|
278
|
-
.filter((node) => ["ExportNamedDeclaration", "ExportDefaultDeclaration", "ExportAllDeclaration"].includes(node.type))
|
|
279
|
-
.flatMap(exportedEntries)
|
|
280
|
-
if (judged.sharedFile) {
|
|
281
|
-
for (const item of exported) {
|
|
282
|
-
if (isHookName(item.name)) context.report({ node: item.node, messageId: "sharedHook", data: { name: item.name } })
|
|
283
|
-
}
|
|
284
|
-
return
|
|
285
|
-
}
|
|
286
|
-
checkHookExports(context, program, exported)
|
|
287
|
-
},
|
|
288
|
-
}
|
|
289
|
-
},
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** The rules this law contributes to the plugin. */
|
|
293
|
-
export const rules = {
|
|
294
|
-
"hooks-folder-holds-hooks-only": hooksFolderHoldsHooksOnly,
|
|
295
|
-
// Forward the family enforcers for the catalog's source-only CONTRIBUTIONS discovery.
|
|
296
|
-
...swrFamilyRules,
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** Every rule is an error. */
|
|
300
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|