@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/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"]))