@starci/eslint-canon-fe 8.3.0 → 8.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/callback-shape.mjs +344 -0
- package/client-boundary.mjs +1 -1
- package/docs/hooks-folder.md +1 -1
- package/env-owner.mjs +120 -9
- package/function-budget.mjs +49 -0
- package/hooks-folder.mjs +149 -18
- package/icon.mjs +67 -0
- package/index.mjs +47 -0
- package/lib/own-hook-origin.mjs +53 -0
- package/lib/program-for.mjs +22 -0
- package/lib/pure-owner-helper.mjs +148 -0
- package/lib/redux-hook-origin.mjs +44 -0
- package/next-conventions.mjs +38 -35
- package/package.json +1 -1
- package/runtime/config.example.yaml +7 -6
- package/runtime/engine/config.mjs +10 -18
- package/runtime/engine/removed-vocabulary.mjs +0 -2
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- package/runtime/knowledge/hfs/slots.yaml +333 -62
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
- package/runtime/modules/models/runtimes.yaml +5 -7
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
- package/runtime/scripts/api/git/lib.mjs +4 -1
- package/runtime/scripts/api/git/merge-base.mjs +9 -0
- package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
- package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
- package/runtime/scripts/hfs/architecture/config.mjs +1 -1
- package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
- package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
- package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
- package/runtime/scripts/hfs/architecture/index.mjs +1 -1
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
- package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
- package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
- package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
- package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
- package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
- package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +4 -4
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
- package/runtime/scripts/hfs/edition-slots.mjs +2 -2
- package/runtime/scripts/hfs/manifest-shape.mjs +69 -9
- package/runtime/scripts/hfs/param-names.mjs +1 -1
- package/runtime/scripts/hfs/project-rule.mjs +3 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +158 -11
- package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/slot-imports.mjs +18 -5
- package/runtime/scripts/hfs/slots.mjs +3 -2
- package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
- package/runtime/scripts/lib/env.mjs +20 -0
- package/runtime/scripts/lib/event-contract.mjs +107 -0
- package/runtime/scripts/lib/git.mjs +1 -1
- package/runtime/scripts/lib/i18n.mjs +0 -2
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- package/runtime/scripts/lib/language.mjs +1 -1
- package/runtime/scripts/lib/list.mjs +4 -0
- package/runtime/scripts/lib/secret-patterns.mjs +60 -5
- package/runtime/scripts/lib/stack-services.mjs +1 -1
- package/runtime/scripts/lib/ts-analysis.mjs +5 -2
- package/runtime/scripts/lib/ts-ast.mjs +27 -2
- package/served-locale.mjs +80 -9
- package/size-growth.mjs +10 -6
- package/swr-family.mjs +197 -0
- package/translation.mjs +135 -7
- package/transport.mjs +126 -59
package/hooks-folder.mjs
CHANGED
|
@@ -25,8 +25,13 @@
|
|
|
25
25
|
* architecture machine that sees the whole tree.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import { importedReduxHook } from "./lib/redux-hook-origin.mjs"
|
|
29
|
+
import { importedOwnHook } from "./lib/own-hook-origin.mjs"
|
|
28
30
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
31
|
+
import { walk } from "./lib/ast.mjs"
|
|
32
|
+
import { functionOf, isFn, unwrap, variableOf } from "./lib/bindings.mjs"
|
|
29
33
|
import { baseName, classOf, fileOf, inSlot } from "./lib/scope.mjs"
|
|
34
|
+
import { rules as swrFamilyRules } from "./swr-family.mjs"
|
|
30
35
|
|
|
31
36
|
/** Names that mark a hook. */
|
|
32
37
|
const isHookName = (name) => /^use[A-Z0-9]/.test(name)
|
|
@@ -38,29 +43,153 @@ const isHookName = (name) => /^use[A-Z0-9]/.test(name)
|
|
|
38
43
|
const isSwrKeyName = (name) => /^[A-Z][A-Z0-9_]*_SWR_KEY$/.test(name)
|
|
39
44
|
|
|
40
45
|
/** A hook file name: `use<Name>.ts`. */
|
|
41
|
-
const
|
|
46
|
+
const HOOK_FILE = new RegExp(String.raw`^use[A-Z0-9]\w*\.ts$`)
|
|
47
|
+
const isHookFileName = (name) => HOOK_FILE.test(name)
|
|
42
48
|
|
|
43
49
|
/** Modules that only run on the server: server code, not a hook. */
|
|
44
|
-
const SERVER_ONLY =
|
|
50
|
+
const SERVER_ONLY = new Set(["server-only", "next/headers"])
|
|
45
51
|
|
|
46
52
|
/** The hooks families of the front-end stack: each slot names its files (`allows`) and its non-hook files (roles). */
|
|
47
53
|
const FAMILY_SLOTS = ["fe.hooks.swr", "fe.hooks.rhf", "fe.hooks.zustand", "fe.hooks.socketio", "fe.hooks.effects", "fe.hooks.reuseables"]
|
|
48
54
|
|
|
49
|
-
/**
|
|
50
|
-
const
|
|
55
|
+
/** Runtime exports and their local values; type exports do not introduce hooks or helpers. */
|
|
56
|
+
const exportedEntries = (node) => {
|
|
51
57
|
if (node.type === "ExportDefaultDeclaration") {
|
|
52
58
|
const declaration = node.declaration
|
|
53
|
-
return [
|
|
59
|
+
return [{ node, name: declaration.id?.name ?? "default", local: declaration, default: true }]
|
|
54
60
|
}
|
|
61
|
+
if (node.exportKind === "type") return []
|
|
62
|
+
if (node.type === "ExportAllDeclaration") return [{ node, name: "*", local: null }]
|
|
55
63
|
const declaration = node.declaration
|
|
56
|
-
if (!declaration)
|
|
57
|
-
|
|
64
|
+
if (!declaration) {
|
|
65
|
+
return node.specifiers.filter((specifier) => specifier.exportKind !== "type").map((specifier) => ({
|
|
66
|
+
node, name: specifier.exported.name ?? specifier.exported.value, local: node.source ? null : specifier.local,
|
|
67
|
+
default: (specifier.exported.name ?? specifier.exported.value) === "default",
|
|
68
|
+
}))
|
|
69
|
+
}
|
|
70
|
+
if (declaration.type === "FunctionDeclaration" && declaration.id) return [{ node, name: declaration.id.name, local: declaration }]
|
|
58
71
|
if (declaration.type === "VariableDeclaration") {
|
|
59
|
-
return declaration.declarations.filter((d) => d.id.type === "Identifier").map((d) => d.id.name)
|
|
72
|
+
return declaration.declarations.filter((d) => d.id.type === "Identifier").map((d) => ({ node, name: d.id.name, local: d.id }))
|
|
60
73
|
}
|
|
61
74
|
return []
|
|
62
75
|
}
|
|
63
76
|
|
|
77
|
+
/** Actual React hook APIs and SWR public entry points, rather than an arbitrary `use*` spelling. */
|
|
78
|
+
const REACT_HOOKS = new Set([
|
|
79
|
+
"use", "useState", "useReducer", "useEffect", "useLayoutEffect", "useInsertionEffect", "useRef", "useImperativeHandle",
|
|
80
|
+
"useMemo", "useCallback", "useContext", "useDebugValue", "useDeferredValue", "useTransition", "useId",
|
|
81
|
+
"useSyncExternalStore", "useActionState", "useOptimistic",
|
|
82
|
+
])
|
|
83
|
+
const SWR_HOOKS = new Map([
|
|
84
|
+
["swr", new Set(["useSWR", "useSWRConfig"])],
|
|
85
|
+
["swr/immutable", new Set(["useSWRImmutable"])],
|
|
86
|
+
["swr/infinite", new Set(["useSWRInfinite"])],
|
|
87
|
+
["swr/mutation", new Set(["useSWRMutation"])],
|
|
88
|
+
])
|
|
89
|
+
const SWR_DEFAULTS = new Map([["swr", "useSWR"], ["swr/immutable", "useSWRImmutable"], ["swr/infinite", "useSWRInfinite"], ["swr/mutation", "useSWRMutation"]])
|
|
90
|
+
/**
|
|
91
|
+
* The library hooks a composite hook may stand on (an explicit list, owner ruling 2026-10-16): next-intl, next/navigation,
|
|
92
|
+
* react-hook-form and zustand's React entry points. A name outside the list is not a hook by its spelling.
|
|
93
|
+
*/
|
|
94
|
+
const LIBRARY_HOOKS = new Map([
|
|
95
|
+
["next-intl", new Set(["useTranslations", "useLocale", "useFormatter", "useNow", "useTimeZone", "useMessages"])],
|
|
96
|
+
["next/navigation", new Set(["useRouter", "usePathname", "useSearchParams", "useParams", "useSelectedLayoutSegment", "useSelectedLayoutSegments"])],
|
|
97
|
+
["react-hook-form", new Set(["useForm", "useFormContext", "useWatch", "useController", "useFieldArray", "useFormState"])],
|
|
98
|
+
["zustand", new Set(["useStore"])],
|
|
99
|
+
["zustand/react/shallow", new Set(["useShallow"])],
|
|
100
|
+
])
|
|
101
|
+
const OWN_HOOK = "own-hooks"
|
|
102
|
+
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)))
|
|
103
|
+
const propertyName = (node) => {
|
|
104
|
+
if (!node.computed && node.property.type === "Identifier") return node.property.name
|
|
105
|
+
return node.property.type === "Literal" ? node.property.value : null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** SWR request hooks are default exports; its root also names useSWRConfig. */
|
|
109
|
+
const namedOrigin = (source, name) => {
|
|
110
|
+
if (source === "react") return { source, name }
|
|
111
|
+
if (LIBRARY_HOOKS.get(source)?.has(name)) return { source, name }
|
|
112
|
+
if (name === "default" && SWR_DEFAULTS.has(source)) return { source, name: SWR_DEFAULTS.get(source) }
|
|
113
|
+
return source === "swr" && name === "useSWRConfig" ? { source, name } : null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A runtime import records whether its value is a hook or its module namespace. */
|
|
117
|
+
const importOrigin = (definition) => {
|
|
118
|
+
const specifier = definition.node
|
|
119
|
+
const source = String(definition.parent.source.value)
|
|
120
|
+
if (definition.parent.importKind === "type" || specifier.importKind === "type") return null
|
|
121
|
+
if (source !== "react" && !SWR_HOOKS.has(source) && !LIBRARY_HOOKS.has(source)) return null
|
|
122
|
+
if (specifier.type === "ImportSpecifier") return namedOrigin(source, specifier.imported.name ?? specifier.imported.value)
|
|
123
|
+
if (specifier.type === "ImportDefaultSpecifier" && SWR_DEFAULTS.has(source)) return { source, name: SWR_DEFAULTS.get(source) }
|
|
124
|
+
return { source, name: null }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Resolve a constant alias, including destructuring a React/SWR namespace. */
|
|
128
|
+
const constantOrigin = (context, definition, variable, seen) => {
|
|
129
|
+
const declaration = definition.node
|
|
130
|
+
if (declaration.type !== "VariableDeclarator" || definition.parent.kind !== "const") return null
|
|
131
|
+
const origin = hookOrigin(context, declaration.init, seen)
|
|
132
|
+
if (declaration.id.type === "Identifier") return origin
|
|
133
|
+
if (declaration.id.type !== "ObjectPattern" || !origin || origin.name !== null) return null
|
|
134
|
+
const property = declaration.id.properties.find((entry) => entry.type === "Property" && entry.value.type === "Identifier" && entry.value.name === variable.name)
|
|
135
|
+
if (!property) return null
|
|
136
|
+
const name = property.computed || property.key.type === "Literal" ? property.key.value : property.key.name
|
|
137
|
+
return namedOrigin(origin.source, name)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Follow lexical bindings so shadowed, reassigned and unrelated `use*` values cannot prove a hook. */
|
|
141
|
+
const hookOrigin = (context, input, seen = new Set()) => {
|
|
142
|
+
const node = unwrap(input)
|
|
143
|
+
if (node?.type === "MemberExpression") {
|
|
144
|
+
const origin = hookOrigin(context, node.object, seen)
|
|
145
|
+
if (origin?.name !== null) return null
|
|
146
|
+
const name = propertyName(node)
|
|
147
|
+
return namedOrigin(origin.source, name)
|
|
148
|
+
}
|
|
149
|
+
if (node?.type !== "Identifier") return null
|
|
150
|
+
const variable = variableOf(context, node)
|
|
151
|
+
if (!variable || seen.has(variable) || variable.defs.length !== 1) return null
|
|
152
|
+
if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return null
|
|
153
|
+
const definition = variable.defs[0]
|
|
154
|
+
if (definition.type === "ImportBinding") {
|
|
155
|
+
const origin = importOrigin(definition)
|
|
156
|
+
if (origin) return origin
|
|
157
|
+
if (importedReduxHook(context, definition)) return { source: "react-redux", name: "hook" }
|
|
158
|
+
return importedOwnHook(context, definition) ? { source: OWN_HOOK, name: "hook" } : null
|
|
159
|
+
}
|
|
160
|
+
return constantOrigin(context, definition, variable, new Set([...seen, variable]))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The exported function itself must call the hook; an unused nested callback is not evidence. */
|
|
164
|
+
const callsBuiltinHook = (context, local) => {
|
|
165
|
+
const hook = functionOf(context, local)
|
|
166
|
+
if (!hook) return false
|
|
167
|
+
let found = false
|
|
168
|
+
walk(context.sourceCode, hook.body, (node) => {
|
|
169
|
+
if (found || isFn(node)) return false
|
|
170
|
+
if (node.type === "CallExpression" && builtinHook(hookOrigin(context, node.callee))) found = true
|
|
171
|
+
return undefined
|
|
172
|
+
})
|
|
173
|
+
return found
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Exactly one named, locally implemented hook; optional SWR key and type exports remain available. */
|
|
177
|
+
const checkHookExports = (context, program, exported) => {
|
|
178
|
+
const hooks = exported.filter((item) => !item.default && isHookName(item.name))
|
|
179
|
+
const defaults = exported.filter((item) => item.default)
|
|
180
|
+
for (const item of defaults) context.report({ node: item.node, messageId: "defaultExport" })
|
|
181
|
+
for (const item of exported) {
|
|
182
|
+
if (!item.default && !isHookName(item.name) && !isSwrKeyName(item.name)) {
|
|
183
|
+
context.report({ node: item.node, messageId: "export", data: { name: item.name } })
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (hooks.length === 0 && defaults.length === 0) context.report({ node: program, messageId: "missing" })
|
|
187
|
+
if (hooks.length > 1) context.report({ node: hooks[1].node, messageId: "many", data: { count: hooks.length } })
|
|
188
|
+
for (const item of hooks) {
|
|
189
|
+
if (!callsBuiltinHook(context, item.local)) context.report({ node: item.node, messageId: "fake", data: { name: item.name } })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
64
193
|
/**
|
|
65
194
|
* What the linted file is inside `hooks/`, or null when no hooks slot owns it: `stray` is a file its slot does not name
|
|
66
195
|
* (directly in `hooks/`, a non-hook name in a domain, a name outside a family's `allows`); `hookFile` a `use<Name>.ts` hook
|
|
@@ -107,6 +236,12 @@ export const hooksFolderHoldsHooksOnly = {
|
|
|
107
236
|
"`{{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`.",
|
|
108
237
|
many:
|
|
109
238
|
"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.",
|
|
239
|
+
missing:
|
|
240
|
+
"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.",
|
|
241
|
+
defaultExport:
|
|
242
|
+
"FE_HOOKS_ARE_HOOKS: export the hook as a named use* function. A default export does not identify the hook this file owns.",
|
|
243
|
+
fake:
|
|
244
|
+
"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, 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.",
|
|
110
245
|
sharedHook:
|
|
111
246
|
"`{{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`.",
|
|
112
247
|
server:
|
|
@@ -125,28 +260,22 @@ export const hooksFolderHoldsHooksOnly = {
|
|
|
125
260
|
else context.report({ node: program, messageId: "notHook", data: { name } })
|
|
126
261
|
},
|
|
127
262
|
ImportDeclaration(node) {
|
|
128
|
-
if (SERVER_ONLY.
|
|
263
|
+
if (SERVER_ONLY.has(String(node.source.value))) {
|
|
129
264
|
context.report({ node, messageId: "server", data: { source: node.source.value } })
|
|
130
265
|
}
|
|
131
266
|
},
|
|
132
267
|
"Program:exit"(program) {
|
|
133
268
|
if (judged.directlyInHooks || judged.stray || (!judged.hookFile && !judged.sharedFile)) return
|
|
134
269
|
const exported = program.body
|
|
135
|
-
.filter((node) =>
|
|
136
|
-
.flatMap(
|
|
270
|
+
.filter((node) => ["ExportNamedDeclaration", "ExportDefaultDeclaration", "ExportAllDeclaration"].includes(node.type))
|
|
271
|
+
.flatMap(exportedEntries)
|
|
137
272
|
if (judged.sharedFile) {
|
|
138
273
|
for (const item of exported) {
|
|
139
274
|
if (isHookName(item.name)) context.report({ node: item.node, messageId: "sharedHook", data: { name: item.name } })
|
|
140
275
|
}
|
|
141
276
|
return
|
|
142
277
|
}
|
|
143
|
-
|
|
144
|
-
for (const item of exported) {
|
|
145
|
-
if (!isHookName(item.name) && !isSwrKeyName(item.name) && item.name !== "default") {
|
|
146
|
-
context.report({ node: item.node, messageId: "export", data: { name: item.name } })
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
if (hooks.length > 1) context.report({ node: hooks[1].node, messageId: "many", data: { count: hooks.length } })
|
|
278
|
+
checkHookExports(context, program, exported)
|
|
150
279
|
},
|
|
151
280
|
}
|
|
152
281
|
},
|
|
@@ -155,6 +284,8 @@ export const hooksFolderHoldsHooksOnly = {
|
|
|
155
284
|
/** The rules this law contributes to the plugin. */
|
|
156
285
|
export const rules = {
|
|
157
286
|
"hooks-folder-holds-hooks-only": hooksFolderHoldsHooksOnly,
|
|
287
|
+
// Forward the family enforcers for the catalog's source-only CONTRIBUTIONS discovery.
|
|
288
|
+
...swrFamilyRules,
|
|
158
289
|
}
|
|
159
290
|
|
|
160
291
|
/** Every rule is an error. */
|
package/icon.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { isClassAttribute, staticText } from "./lib/ast.mjs"
|
|
|
16
16
|
import { canonicalPackages } from "./lib/fe-stack.mjs"
|
|
17
17
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
18
18
|
import { fileOf, inSlot, isComponentFile, isProductSource, kindOfFile, roleOfFile } from "./lib/scope.mjs"
|
|
19
|
+
import { why } from "./lib/why.mjs"
|
|
19
20
|
|
|
20
21
|
/** The one module allowed to name a glyph from a library: the entry of the leaf owner named `Icon`. */
|
|
21
22
|
const ICON_LEAF = { kind: "leaves", owner: "Icon" }
|
|
@@ -195,12 +196,78 @@ export const noDecorativeIconInMetricCell = {
|
|
|
195
196
|
},
|
|
196
197
|
}
|
|
197
198
|
|
|
199
|
+
/** The immediately preceding JSDoc declares this exported component as a brand mark. */
|
|
200
|
+
const declaresBrandMark = (sourceCode, node) => {
|
|
201
|
+
const comment = sourceCode.getCommentsBefore(node).at(-1)
|
|
202
|
+
return comment?.type === "Block" && comment.value.startsWith("*") &&
|
|
203
|
+
/(?:^|\n)\s*\*?\s*@brand-mark(?:\s|$)/.test(comment.value) &&
|
|
204
|
+
sourceCode.text.slice(comment.range[1], node.range[0]).trim() === ""
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A marker belongs to the exported component itself, never to a sibling or an enclosing file. */
|
|
208
|
+
const isDeclaredBrandMarkFunction = (sourceCode, node) => {
|
|
209
|
+
let declaration = node
|
|
210
|
+
if (node.parent?.type === "VariableDeclarator") {
|
|
211
|
+
if (node.parent.init !== node || node.parent.id.type !== "Identifier") return false
|
|
212
|
+
declaration = node.parent.parent
|
|
213
|
+
if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1) return false
|
|
214
|
+
}
|
|
215
|
+
const exported = declaration.parent
|
|
216
|
+
if (!["ExportNamedDeclaration", "ExportDefaultDeclaration"].includes(exported?.type)) return false
|
|
217
|
+
return declaresBrandMark(sourceCode, exported) || declaresBrandMark(sourceCode, declaration)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** An SVG belongs to its nearest function; marking another component cannot license it. */
|
|
221
|
+
const inDeclaredBrandMark = (sourceCode, node) => {
|
|
222
|
+
for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
|
|
223
|
+
if (["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"].includes(ancestor.type)) {
|
|
224
|
+
return isDeclaredBrandMarkFunction(sourceCode, ancestor)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return false
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Inline SVG belongs only to an explicitly declared brand mark in a leaf entry. */
|
|
231
|
+
export const noInlineSvgOutsideBrandMarkLeaf = {
|
|
232
|
+
meta: {
|
|
233
|
+
type: "problem",
|
|
234
|
+
docs: { description: "FE_ICON_INLINE_SVG: inline SVG is drawn only by an exported leaf entry component with a @brand-mark JSDoc declaration." },
|
|
235
|
+
schema: [],
|
|
236
|
+
messages: {
|
|
237
|
+
inlineSvg:
|
|
238
|
+
"FE_ICON_INLINE_SVG: inline `<svg>` draws a second glyph vocabulary. Use the Icon leaf for product glyphs. A brand mark may draw its own SVG only in a leaf entry whose exported component is immediately documented with `@brand-mark` in JSDoc.",
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
create(context) {
|
|
242
|
+
if (!isProductSource(context)) return {}
|
|
243
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode()
|
|
244
|
+
const isLeafEntry = isComponentFile(context) && kindOfFile(context) === "leaves" && roleOfFile(context) === "entry"
|
|
245
|
+
return {
|
|
246
|
+
JSXOpeningElement(node) {
|
|
247
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "svg") return
|
|
248
|
+
if (isLeafEntry && inDeclaredBrandMark(sourceCode, node)) return
|
|
249
|
+
context.report({ node, messageId: "inlineSvg" })
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The finding code for the copy-and-glyph law's inline SVG enforcer. */
|
|
256
|
+
Object.assign(why, {
|
|
257
|
+
"no-inline-svg-outside-brand-mark-leaf": {
|
|
258
|
+
code: "FE_ICON_INLINE_SVG",
|
|
259
|
+
en: "`<file>` draws an inline SVG outside an exported leaf entry component declared as a brand mark.",
|
|
260
|
+
fix: "Use the Icon leaf for product glyphs; declare an original brand mark with @brand-mark JSDoc on its exported leaf entry component.",
|
|
261
|
+
},
|
|
262
|
+
})
|
|
263
|
+
|
|
198
264
|
/** The rules this law contributes to the plugin. */
|
|
199
265
|
export const rules = {
|
|
200
266
|
"no-vendor-icon-outside-icon-leaf": noVendorIconOutsideIconLeaf,
|
|
201
267
|
"heroicons-is-the-glyph-vendor": heroiconsIsTheGlyphVendor,
|
|
202
268
|
"no-off-scale-glyph-size": noOffScaleGlyphSize,
|
|
203
269
|
"no-decorative-icon-in-metric-cell": noDecorativeIconInMetricCell,
|
|
270
|
+
"no-inline-svg-outside-brand-mark-leaf": noInlineSvgOutsideBrandMarkLeaf,
|
|
204
271
|
}
|
|
205
272
|
|
|
206
273
|
/**
|
package/index.mjs
CHANGED
|
@@ -237,3 +237,50 @@ export const starciFeConfig = ({ hfs } = {}) => {
|
|
|
237
237
|
source: { ...recommended, ...reactHooksRules(reactHooks) },
|
|
238
238
|
})
|
|
239
239
|
}
|
|
240
|
+
|
|
241
|
+
// A21 append-only registration: keep the already exported plugin and recommendation live.
|
|
242
|
+
import { recommended as functionBudgetRecommended, rules as functionBudgetRules } from "./function-budget.mjs"
|
|
243
|
+
CONTRIBUTIONS.push({ law: "function-budget", rules: functionBudgetRules, recommended: functionBudgetRecommended })
|
|
244
|
+
lawOwners.push("function-budget")
|
|
245
|
+
ruleDeclarations.push(...Object.keys(functionBudgetRules).map((name) => ({ law: "function-budget", name })))
|
|
246
|
+
Object.assign(ruleOwners, Object.fromEntries(Object.keys(functionBudgetRules).map((name) => [name, "function-budget"])))
|
|
247
|
+
Object.assign(rules, functionBudgetRules)
|
|
248
|
+
Object.assign(recommended, functionBudgetRecommended)
|
|
249
|
+
// A14 modules are also forwarded by the existing hooks-folder and transport contributions so
|
|
250
|
+
// source-only catalog discovery can find them. The appended registration gives each module
|
|
251
|
+
// ownership of those same rule instances without declaring either rule twice.
|
|
252
|
+
import { recommended as swrFamilyRecommended, rules as swrFamilyRules } from "./swr-family.mjs"
|
|
253
|
+
import { recommended as callbackShapeRecommended, rules as callbackShapeRules } from "./callback-shape.mjs"
|
|
254
|
+
|
|
255
|
+
const A14_CONTRIBUTIONS = [
|
|
256
|
+
{ law: "swr-family", rules: swrFamilyRules, recommended: swrFamilyRecommended },
|
|
257
|
+
{ law: "callback-shape", rules: callbackShapeRules, recommended: callbackShapeRecommended },
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
for (const addition of A14_CONTRIBUTIONS) {
|
|
261
|
+
const names = new Set(Object.keys(addition.rules))
|
|
262
|
+
for (const contribution of CONTRIBUTIONS) {
|
|
263
|
+
for (const name of names) {
|
|
264
|
+
if (name in contribution.rules && contribution.rules[name] !== addition.rules[name]) {
|
|
265
|
+
throw new Error("A14 registration: two laws publish different rules named " + name)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Replace contribution views only; the law module's exported maps retain their forwarding
|
|
269
|
+
// entries for the source-only catalog reader.
|
|
270
|
+
contribution.rules = Object.fromEntries(Object.entries(contribution.rules).filter(([name]) => !names.has(name)))
|
|
271
|
+
contribution.recommended = Object.fromEntries(
|
|
272
|
+
Object.entries(contribution.recommended).filter(([name]) => !names.has(name.replace(/^starci-fe\//, ""))),
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
CONTRIBUTIONS.push(addition)
|
|
276
|
+
lawOwners.push(addition.law)
|
|
277
|
+
for (const name of names) {
|
|
278
|
+
const declarations = ruleDeclarations.filter((entry) => entry.name === name)
|
|
279
|
+
if (declarations.length > 1) throw new Error("A14 registration: duplicate declarations for " + name)
|
|
280
|
+
if (declarations.length === 1) declarations[0].law = addition.law
|
|
281
|
+
else ruleDeclarations.push({ law: addition.law, name })
|
|
282
|
+
ruleOwners[name] = addition.law
|
|
283
|
+
}
|
|
284
|
+
Object.assign(rules, addition.rules)
|
|
285
|
+
Object.assign(recommended, addition.recommended)
|
|
286
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a `use*` import that comes from the app's own hooks folders (`hooks/swr`, `hooks/<domain>`, `hooks/zustand/<store>`...) by
|
|
3
|
+
* TypeScript symbol resolution, never by the spelling of the name (R56 `FE_HOOKS_ARE_HOOKS`, owner ruling 2026-10-16 "Theo y tro").
|
|
4
|
+
*
|
|
5
|
+
* A composite product hook is built from the hooks beneath it: `hooks/<domain>/use<Name>.ts` composes the `use{Query,Mutate}<X>Swr`
|
|
6
|
+
* wrappers of `hooks/swr`. The import is followed through barrels and re-exports to the declaration, and counts only when that declaration
|
|
7
|
+
* is a `use*` value that lives in a `hooks/` folder of the app (never in `node_modules`). The hook's own file is judged by this same
|
|
8
|
+
* rule when it is linted, so a fake one is refused where it is written.
|
|
9
|
+
*/
|
|
10
|
+
import ts from "typescript"
|
|
11
|
+
import { programFor } from "./program-for.mjs"
|
|
12
|
+
|
|
13
|
+
const HOOKS_FOLDER = /(^|\/)hooks\//
|
|
14
|
+
const isHookName = (name) => /^use[A-Z0-9]/.test(name)
|
|
15
|
+
|
|
16
|
+
/** The program that holds the import target: the project program has it; a program rooted at the linted file alone (its disk text may differ from the linted text) is widened with it. */
|
|
17
|
+
const widened = new WeakMap()
|
|
18
|
+
export function reaching(program, filename, targetFile) {
|
|
19
|
+
if (program.getSourceFile(targetFile)) return program
|
|
20
|
+
const key = `${filename}\0${targetFile}`
|
|
21
|
+
const cache = widened.get(program) ?? new Map()
|
|
22
|
+
widened.set(program, cache)
|
|
23
|
+
if (!cache.has(key)) cache.set(key, ts.createProgram([filename, targetFile], program.getCompilerOptions()))
|
|
24
|
+
return cache.get(key)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A declaration of a hook value: a function or a variable, not a type, interface, class or enum. */
|
|
28
|
+
const hookValue = (declaration) => ts.isFunctionDeclaration(declaration) || ts.isVariableDeclaration(declaration)
|
|
29
|
+
|
|
30
|
+
const inAppHooksFolder = (file) => {
|
|
31
|
+
const fileName = file.fileName.replaceAll("\\", "/")
|
|
32
|
+
return !fileName.includes("/node_modules/") && !file.isDeclarationFile && HOOKS_FOLDER.test(fileName)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True when the import binding is a runtime `use*` import that resolves to a hook declared in a `hooks/` folder of the app. */
|
|
36
|
+
export function importedOwnHook(context, definition) {
|
|
37
|
+
const specifier = definition.node
|
|
38
|
+
if (specifier.type !== "ImportSpecifier" || definition.parent.importKind === "type" || specifier.importKind === "type") return false
|
|
39
|
+
const name = specifier.imported.name ?? specifier.imported.value
|
|
40
|
+
if (!isHookName(name)) return false
|
|
41
|
+
const source = String(definition.parent.source.value)
|
|
42
|
+
const base = programFor(context)
|
|
43
|
+
const target = ts.resolveModuleName(source, context.filename, base.getCompilerOptions(), ts.sys).resolvedModule
|
|
44
|
+
if (!target || target.isExternalLibraryImport) return false
|
|
45
|
+
const program = reaching(base, context.filename, target.resolvedFileName)
|
|
46
|
+
const file = program.getSourceFile(target.resolvedFileName)
|
|
47
|
+
const checker = program.getTypeChecker()
|
|
48
|
+
const moduleSymbol = file && checker.getSymbolAtLocation(file)
|
|
49
|
+
const exported = moduleSymbol && checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.name === name)
|
|
50
|
+
if (!exported) return false
|
|
51
|
+
const symbol = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported
|
|
52
|
+
return (symbol.declarations ?? []).some((declaration) => hookValue(declaration) && inAppHooksFolder(declaration.getSourceFile()))
|
|
53
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** The TypeScript program a rule resolves symbols through: the lint run's own when typed linting is on, else one rooted at the linted file. */
|
|
2
|
+
import ts from "typescript"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
const programs = new WeakMap()
|
|
6
|
+
|
|
7
|
+
/** The program for a rule context; a program built here is remembered per context. */
|
|
8
|
+
export function programFor(context) {
|
|
9
|
+
if (programs.has(context)) return programs.get(context)
|
|
10
|
+
const services = context.sourceCode?.parserServices
|
|
11
|
+
if (services?.program) return services.program
|
|
12
|
+
const filename = context.filename
|
|
13
|
+
const configPath = ts.findConfigFile(path.dirname(filename), ts.sys.fileExists)
|
|
14
|
+
let options = { moduleResolution: ts.ModuleResolutionKind.Bundler, module: ts.ModuleKind.ESNext }
|
|
15
|
+
if (configPath) {
|
|
16
|
+
const config = ts.readConfigFile(configPath, ts.sys.readFile)
|
|
17
|
+
options = { ...options, ...ts.parseJsonConfigFileContent(config.config, ts.sys, path.dirname(configPath)).options }
|
|
18
|
+
}
|
|
19
|
+
const program = ts.createProgram([filename], options)
|
|
20
|
+
programs.set(context, program)
|
|
21
|
+
return program
|
|
22
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The owner exports an auth callback may use besides the exchange (R287 `FE_CALLBACK_SHAPE`, owner ruling `auth-callback-pure-helpers`,
|
|
3
|
+
* 2026-10-17 "Theo y tro"): a PURE helper of the app's `modules/routes` or `modules/auth` that computes the redirect target or validates
|
|
4
|
+
* `next`, and a static route table. Both are found by TypeScript symbol resolution to the declaration, never by the spelling of the name.
|
|
5
|
+
*
|
|
6
|
+
* A pure helper is a synchronous function that returns text (or null), holds no `await` or `yield`, reaches no I/O global (network, storage,
|
|
7
|
+
* DOM, `process`, `require`, `console`) and calls only locals, language built-ins and other pure helpers of `modules/routes` or
|
|
8
|
+
* `modules/auth` (followed through barrels). `modules/config` is the environment owner: a call to its readers is a configuration read, not I/O.
|
|
9
|
+
* Anything else it imports, a library value included, makes it impure.
|
|
10
|
+
*/
|
|
11
|
+
import ts from "typescript"
|
|
12
|
+
import { reaching } from "./own-hook-origin.mjs"
|
|
13
|
+
import { programFor } from "./program-for.mjs"
|
|
14
|
+
import { resolveImport } from "./scope.mjs"
|
|
15
|
+
|
|
16
|
+
/** Globals that reach the network, storage, the page, the process or the console. */
|
|
17
|
+
const IO_GLOBALS = new Set(["fetch", "XMLHttpRequest", "WebSocket", "EventSource", "Request", "navigator", "localStorage", "sessionStorage", "indexedDB", "document", "window", "self", "globalThis", "process", "require", "console", "setTimeout", "setInterval", "queueMicrotask", "Worker", "BroadcastChannel"])
|
|
18
|
+
|
|
19
|
+
const OWNER_FILE = /\/modules\/(?:routes|auth)(?:\/|\.tsx?$)/
|
|
20
|
+
const CONFIG_FILE = /\/modules\/config(?:\/|\.tsx?$)/
|
|
21
|
+
const EXTENSIONS = ["", ".ts", ".tsx", "/index.ts", "/index.tsx"]
|
|
22
|
+
|
|
23
|
+
const normal = (file) => file.fileName.replaceAll("\\", "/")
|
|
24
|
+
|
|
25
|
+
/** The module file an import specifier names, or null when none exists on disk. */
|
|
26
|
+
const moduleFile = (context, source) => {
|
|
27
|
+
const target = resolveImport(context, source)
|
|
28
|
+
if (target === null) return null
|
|
29
|
+
return EXTENSIONS.map((extension) => `${target}${extension}`).find((candidate) => ts.sys.fileExists(candidate)) ?? null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The declaration an export of the module resolves to, after barrels and re-exports. */
|
|
33
|
+
const exportedDeclaration = (context, source, name) => {
|
|
34
|
+
const file = moduleFile(context, source)
|
|
35
|
+
if (file === null) return null
|
|
36
|
+
const program = reaching(programFor(context), context.filename, file)
|
|
37
|
+
const checker = program.getTypeChecker()
|
|
38
|
+
const sourceFile = program.getSourceFile(file)
|
|
39
|
+
const moduleSymbol = sourceFile && checker.getSymbolAtLocation(sourceFile)
|
|
40
|
+
const exported = moduleSymbol && checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.name === name)
|
|
41
|
+
if (!exported) return null
|
|
42
|
+
const symbol = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported
|
|
43
|
+
return { checker, declaration: symbol.declarations?.[0] ?? null }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Strip `as`, `satisfies` and parentheses. */
|
|
47
|
+
const bare = (node) => {
|
|
48
|
+
let current = node
|
|
49
|
+
while (current && (ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isParenthesizedExpression(current))) current = current.expression
|
|
50
|
+
return current
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The function-like node of a function declaration or of a variable initialised with an arrow or function expression. */
|
|
54
|
+
const functionOf = (declaration) => {
|
|
55
|
+
if (ts.isFunctionDeclaration(declaration)) return declaration
|
|
56
|
+
const init = ts.isVariableDeclaration(declaration) ? bare(declaration.initializer) : null
|
|
57
|
+
return init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) ? init : null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isInside = (node, root) => node.getSourceFile() === root.getSourceFile() && node.pos >= root.pos && node.end <= root.end
|
|
61
|
+
|
|
62
|
+
/** Whether an identifier names a declaration, a property or a member rather than referring to a binding. */
|
|
63
|
+
const isNameOnly = (node) => {
|
|
64
|
+
const parent = node.parent
|
|
65
|
+
if (ts.isPropertyAccessExpression(parent)) return parent.name === node
|
|
66
|
+
if (ts.isPropertyAssignment(parent) || ts.isMethodDeclaration(parent) || ts.isPropertySignature(parent)) return parent.name === node
|
|
67
|
+
return (ts.isParameter(parent) || ts.isVariableDeclaration(parent) || ts.isFunctionDeclaration(parent) || ts.isBindingElement(parent)) && parent.name === node
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A declaration of another file: an owner helper is followed, the environment owner is a leaf, everything else is impure. */
|
|
71
|
+
const foreignPure = (env, declaration, root) => {
|
|
72
|
+
const file = normal(declaration.getSourceFile())
|
|
73
|
+
if (CONFIG_FILE.test(file)) return true
|
|
74
|
+
if (!OWNER_FILE.test(file) || declaration.getSourceFile().isDeclarationFile) return false
|
|
75
|
+
return declarationPure(env, declaration, root)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A global of the language or of the lib files is pure unless it reaches I/O. */
|
|
79
|
+
const globalPure = (symbol) => !IO_GLOBALS.has(symbol.getName())
|
|
80
|
+
|
|
81
|
+
/** Whether one identifier reference stays inside the pure surface. */
|
|
82
|
+
const referencePure = (env, node, root) => {
|
|
83
|
+
const symbol = env.checker.getSymbolAtLocation(node)
|
|
84
|
+
if (!symbol) return node.text === "undefined"
|
|
85
|
+
const target = symbol.flags & ts.SymbolFlags.Alias ? env.checker.getAliasedSymbol(symbol) : symbol
|
|
86
|
+
const declarations = target.declarations ?? []
|
|
87
|
+
if (declarations.length === 0) return node.text === "undefined"
|
|
88
|
+
return declarations.every((declaration) => {
|
|
89
|
+
const file = declaration.getSourceFile()
|
|
90
|
+
if (isInside(declaration, root)) return true
|
|
91
|
+
if (!ts.isExternalModule(file)) return globalPure(target)
|
|
92
|
+
return foreignPure(env, declaration, root)
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Walk a node: no await or yield, no impure reference; type positions are not behaviour. */
|
|
97
|
+
const nodePure = (env, node, root) => {
|
|
98
|
+
if (ts.isAwaitExpression(node) || ts.isYieldExpression(node)) return false
|
|
99
|
+
if (ts.isTypeNode(node) && !ts.isExpressionWithTypeArguments(node)) return true
|
|
100
|
+
if (ts.isIdentifier(node) && !isNameOnly(node) && !referencePure(env, node, root)) return false
|
|
101
|
+
return ts.forEachChild(node, (child) => (nodePure(env, child, root) ? undefined : true)) !== true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A declaration is pure when its function (or its constant initialiser) is. */
|
|
105
|
+
function declarationPure(env, declaration, root) {
|
|
106
|
+
if (env.seen.has(declaration)) return true
|
|
107
|
+
env.seen.add(declaration)
|
|
108
|
+
const fn = functionOf(declaration)
|
|
109
|
+
if (fn) return !fn.asteriskToken && !fn.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) && (fn.body ? nodePure(env, fn.body, fn) : false)
|
|
110
|
+
if (ts.isVariableDeclaration(declaration) && declaration.initializer) return nodePure(env, declaration.initializer, declaration.initializer)
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Whether a function's declared or inferred return type is text, null or undefined. */
|
|
115
|
+
const returnsText = (checker, declaration) => {
|
|
116
|
+
const signature = checker.getSignaturesOfType(checker.getTypeAtLocation(declaration), ts.SignatureKind.Call)[0]
|
|
117
|
+
if (!signature) return false
|
|
118
|
+
const type = checker.getReturnTypeOfSignature(signature)
|
|
119
|
+
const parts = type.isUnion() ? type.types : [type]
|
|
120
|
+
return parts.every((part) => part.flags & (ts.TypeFlags.StringLike | ts.TypeFlags.Null | ts.TypeFlags.Undefined))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A static table: a constant object whose leaves are string literals or plain templates. */
|
|
124
|
+
const isStaticTable = (node) => {
|
|
125
|
+
const value = bare(node)
|
|
126
|
+
if (!value) return false
|
|
127
|
+
if (ts.isStringLiteralLike(value)) return true
|
|
128
|
+
if (ts.isObjectLiteralExpression(value)) return value.properties.every((property) => ts.isPropertyAssignment(property) && isStaticTable(property.initializer))
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* What an import of the callback's owner modules is: `"pure"` (a pure text-returning helper), `"routes"` (a static route table) or null.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} context - The ESLint rule context.
|
|
136
|
+
* @param {string} source - The import specifier.
|
|
137
|
+
* @param {string} name - The imported name.
|
|
138
|
+
* @returns {"pure" | "routes" | null} The kind.
|
|
139
|
+
*/
|
|
140
|
+
export function ownerExportKind(context, source, name) {
|
|
141
|
+
const found = exportedDeclaration(context, source, name)
|
|
142
|
+
if (!found?.declaration) return null
|
|
143
|
+
const { checker, declaration } = found
|
|
144
|
+
if (ts.isVariableDeclaration(declaration) && !functionOf(declaration)) return declaration.parent.flags & ts.NodeFlags.Const && isStaticTable(declaration.initializer) ? "routes" : null
|
|
145
|
+
const fn = functionOf(declaration)
|
|
146
|
+
const env = { checker, seen: new Set() }
|
|
147
|
+
return fn && returnsText(checker, fn) && declarationPure(env, declaration, fn) ? "pure" : null
|
|
148
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Resolve typed Redux hook factories by their import provenance, never by a local use* name. */
|
|
2
|
+
import ts from "typescript"
|
|
3
|
+
import { programFor } from "./program-for.mjs"
|
|
4
|
+
|
|
5
|
+
const REDUX_HOOKS = new Set(["useSelector", "useDispatch", "useStore"])
|
|
6
|
+
|
|
7
|
+
function reduxImport(declaration) {
|
|
8
|
+
if (!ts.isImportSpecifier(declaration) || declaration.isTypeOnly) return false
|
|
9
|
+
const clause = declaration.parent.parent
|
|
10
|
+
return !clause.isTypeOnly && clause.parent.moduleSpecifier.text === "react-redux"
|
|
11
|
+
&& REDUX_HOOKS.has((declaration.propertyName ?? declaration.name).text)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function typedFactory(checker, declaration) {
|
|
15
|
+
if (!ts.isVariableDeclaration(declaration) || !(declaration.parent.flags & ts.NodeFlags.Const)) return false
|
|
16
|
+
const call = declaration.initializer
|
|
17
|
+
if (!call || !ts.isCallExpression(call) || call.arguments.length !== 0) return false
|
|
18
|
+
const member = call.expression
|
|
19
|
+
return ts.isPropertyAccessExpression(member) && member.name.text === "withTypes"
|
|
20
|
+
&& reduxBinding(checker, member.expression)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function reduxBinding(checker, node) {
|
|
24
|
+
const symbol = checker.getSymbolAtLocation(node)
|
|
25
|
+
return (symbol?.declarations ?? []).some(reduxImport)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A relative/barrel import must resolve to a const initialized by a real Redux hook's withTypes(). */
|
|
29
|
+
export function importedReduxHook(context, definition) {
|
|
30
|
+
const specifier = definition.node
|
|
31
|
+
if (specifier.type !== "ImportSpecifier" || definition.parent.importKind === "type" || specifier.importKind === "type") return false
|
|
32
|
+
const source = String(definition.parent.source.value)
|
|
33
|
+
const name = specifier.imported.name ?? specifier.imported.value
|
|
34
|
+
if (source === "react-redux") return REDUX_HOOKS.has(name)
|
|
35
|
+
const program = programFor(context)
|
|
36
|
+
const target = ts.resolveModuleName(source, context.filename, program.getCompilerOptions(), ts.sys).resolvedModule
|
|
37
|
+
const file = target && program.getSourceFile(target.resolvedFileName)
|
|
38
|
+
const checker = program.getTypeChecker()
|
|
39
|
+
const moduleSymbol = file && checker.getSymbolAtLocation(file)
|
|
40
|
+
const exported = moduleSymbol && checker.getExportsOfModule(moduleSymbol).find(symbol => symbol.name === name)
|
|
41
|
+
if (!exported) return false
|
|
42
|
+
const symbol = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported
|
|
43
|
+
return (symbol.declarations ?? []).some(declaration => typedFactory(checker, declaration))
|
|
44
|
+
}
|