@starci/eslint-canon-fe 8.2.18 → 8.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 +15 -0
- package/README.md +19 -1
- package/callback-shape.mjs +344 -0
- package/client-boundary.mjs +1 -1
- package/docs/hooks-folder.md +1 -1
- package/docs/size-and-state-budget.md +6 -6
- package/drawing.mjs +65 -28
- 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/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- package/runtime/knowledge/hfs/slots.yaml +430 -92
- package/runtime/knowledge/patterns/fe/folder.yaml +4 -0
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/models/runtimes.yaml +1 -1
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
- package/runtime/scripts/api/git/cat-file.mjs +5 -0
- package/runtime/scripts/api/git/diff.mjs +5 -0
- package/runtime/scripts/api/git/lib.mjs +3 -0
- 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 +103 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +22 -10
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
- package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
- package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
- package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
- package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
- package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
- package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +23 -50
- package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
- package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
- 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/i18n-keys.mjs +16 -3
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +49 -25
- 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 +15 -7
- package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
- package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
- package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +214 -17
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
- 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 +11 -4
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +9 -13
- 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 +91 -6
- package/runtime/scripts/hfs/path-findings.mjs +32 -2
- package/runtime/scripts/hfs/project-rule.mjs +3 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
- package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
- package/runtime/scripts/hfs/rules/secrets.mjs +26 -0
- package/runtime/scripts/hfs/slot-imports.mjs +18 -3
- package/runtime/scripts/hfs/slot-required.mjs +13 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/lib/env.mjs +20 -0
- package/runtime/scripts/lib/event-contract.mjs +107 -0
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- 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 +23 -0
- package/runtime/scripts/lib/ts-ast.mjs +27 -2
- package/served-locale.mjs +80 -9
- package/size-and-state-budget.mjs +11 -8
- package/size-growth.mjs +10 -6
- package/swr-family.mjs +197 -0
- package/translation.mjs +135 -7
- package/transport.mjs +126 -59
package/env-owner.mjs
CHANGED
|
@@ -52,6 +52,101 @@ const URL_TEXT = /^(?:https?:)?\/\/\S+/i
|
|
|
52
52
|
/** Variable names whose value is a secret or an address: a default for one is a hole in a deployment. */
|
|
53
53
|
const SENSITIVE_NAME = /(?:SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE_?KEY|URL|URI|HOST|ENDPOINT|ORIGIN)$/i
|
|
54
54
|
|
|
55
|
+
/** Type assertions and optional chains preserve the identity of the wrapped value. */
|
|
56
|
+
const unwrapped = (node) => {
|
|
57
|
+
while (node && ["TSAsExpression", "TSTypeAssertion", "TSNonNullExpression", "TSSatisfiesExpression", "ChainExpression"].includes(node.type)) node = node.expression
|
|
58
|
+
return node
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The binding of this identifier, rather than every variable with the same spelling. */
|
|
62
|
+
const bindingOf = (sourceCode, identifier) => {
|
|
63
|
+
for (let scope = sourceCode.getScope(identifier); scope; scope = scope.upper) {
|
|
64
|
+
const variable = scope.set.get(identifier.name)
|
|
65
|
+
if (variable) return variable
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Prior writes in the use's execution scope or its parents; a closure can capture a later initializer. */
|
|
71
|
+
const writeBefore = (sourceCode, variable, node) => {
|
|
72
|
+
const scopes = new Set()
|
|
73
|
+
for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) scopes.add(scope.variableScope)
|
|
74
|
+
const writes = variable.references.filter((reference) => reference.isWrite()
|
|
75
|
+
&& scopes.has(reference.from.variableScope)
|
|
76
|
+
&& !(reference.writeExpr && reference.writeExpr.range[0] <= node.range[0] && node.range[1] <= reference.writeExpr.range[1]))
|
|
77
|
+
return writes
|
|
78
|
+
.filter((reference) => reference.identifier.range[0] < node.range[0])
|
|
79
|
+
.sort((left, right) => right.identifier.range[0] - left.identifier.range[0])[0]
|
|
80
|
+
?? writes.find((reference) => reference.init)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Property extraction between a write's source and its destructured binding. */
|
|
84
|
+
const propertyPath = (identifier) => {
|
|
85
|
+
const properties = []
|
|
86
|
+
for (let node = identifier; node.parent; node = node.parent) {
|
|
87
|
+
const parent = node.parent
|
|
88
|
+
if (parent.type === "Property" && parent.value === node) properties.unshift(parent)
|
|
89
|
+
else if (parent.type !== "ObjectPattern" && !(parent.type === "AssignmentPattern" && parent.left === node)) break
|
|
90
|
+
}
|
|
91
|
+
return properties
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Known environment containers and constant computed keys, followed through local aliases. */
|
|
95
|
+
const environmentIdentity = (sourceCode) => {
|
|
96
|
+
const textOf = (node, seen = new Set()) => {
|
|
97
|
+
node = unwrapped(node)
|
|
98
|
+
if (!node || seen.has(node)) return null
|
|
99
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value
|
|
100
|
+
if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0].value.cooked
|
|
101
|
+
if (node.type !== "Identifier") return null
|
|
102
|
+
const variable = bindingOf(sourceCode, node)
|
|
103
|
+
if (!variable) return null
|
|
104
|
+
const write = writeBefore(sourceCode, variable, node)
|
|
105
|
+
if (!write?.writeExpr || propertyPath(write.identifier).length > 0) return null
|
|
106
|
+
return textOf(write.writeExpr, new Set([...seen, node]))
|
|
107
|
+
}
|
|
108
|
+
const keyOf = (node) => node.computed ? textOf(node.key ?? node.property) : (node.key ?? node.property).name ?? (node.key ?? node.property).value
|
|
109
|
+
const propertyKind = (kind, key) => {
|
|
110
|
+
if (kind === "global" && key === "process") return "process"
|
|
111
|
+
if ((kind === "process" || kind === "meta") && key === "env") return "env"
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
const kindOf = (node, seen = new Set()) => {
|
|
115
|
+
node = unwrapped(node)
|
|
116
|
+
if (!node || seen.has(node)) return null
|
|
117
|
+
if (node.type === "MetaProperty" && node.meta.name === "import" && node.property.name === "meta") return "meta"
|
|
118
|
+
if (node.type === "MemberExpression") return propertyKind(kindOf(node.object, new Set([...seen, node])), keyOf(node))
|
|
119
|
+
if (node.type !== "Identifier") return null
|
|
120
|
+
const variable = bindingOf(sourceCode, node)
|
|
121
|
+
if (!variable || variable.defs.length === 0) {
|
|
122
|
+
if (node.name === "process") return "process"
|
|
123
|
+
return ["globalThis", "global"].includes(node.name) ? "global" : null
|
|
124
|
+
}
|
|
125
|
+
const imported = variable.defs.find((definition) => definition.type === "ImportBinding" && ["process", "node:process"].includes(definition.parent.source.value))
|
|
126
|
+
if (imported) {
|
|
127
|
+
if (imported.parent.importKind === "type" || imported.node.importKind === "type") return null
|
|
128
|
+
if (imported.node.type !== "ImportSpecifier") return "process"
|
|
129
|
+
const name = imported.node.imported.name ?? imported.node.imported.value
|
|
130
|
+
return name === "default" ? "process" : name === "env" ? "env" : null
|
|
131
|
+
}
|
|
132
|
+
const write = writeBefore(sourceCode, variable, node)
|
|
133
|
+
if (!write?.writeExpr) return null
|
|
134
|
+
let kind = kindOf(write.writeExpr, new Set([...seen, node]))
|
|
135
|
+
for (const property of propertyPath(write.identifier)) kind = propertyKind(kind, keyOf(property))
|
|
136
|
+
return kind
|
|
137
|
+
}
|
|
138
|
+
const readsEnv = (pattern, kind) => {
|
|
139
|
+
if (pattern.type === "AssignmentPattern") return readsEnv(pattern.left, kind)
|
|
140
|
+
if (pattern.type !== "ObjectPattern") return false
|
|
141
|
+
return pattern.properties.some((property) => {
|
|
142
|
+
if (property.type !== "Property") return false
|
|
143
|
+
const valueKind = propertyKind(kind, keyOf(property))
|
|
144
|
+
return valueKind === "env" || readsEnv(property.value, valueKind)
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
return { kindOf, readsEnv }
|
|
148
|
+
}
|
|
149
|
+
|
|
55
150
|
// -- FE-ENV-1 --------------------------------------------------------------------------------------
|
|
56
151
|
|
|
57
152
|
/** Only `modules/config` reads the environment. */
|
|
@@ -66,20 +161,36 @@ export const noEnvOutsideConfig = {
|
|
|
66
161
|
},
|
|
67
162
|
},
|
|
68
163
|
create(context) {
|
|
69
|
-
const filename = context.filename || context.getFilename()
|
|
70
164
|
if (isConfigModule(context)) return {}
|
|
165
|
+
const members = []
|
|
166
|
+
const destructures = []
|
|
71
167
|
return {
|
|
168
|
+
ImportDeclaration(node) {
|
|
169
|
+
if (node.importKind === "type" || !["process", "node:process"].includes(node.source.value)) return
|
|
170
|
+
for (const specifier of node.specifiers) {
|
|
171
|
+
if (specifier.type === "ImportSpecifier" && specifier.importKind !== "type" && (specifier.imported.name ?? specifier.imported.value) === "env") context.report({ node: specifier, messageId: "env" })
|
|
172
|
+
}
|
|
173
|
+
},
|
|
72
174
|
MemberExpression(node) {
|
|
73
|
-
|
|
175
|
+
members.push(node)
|
|
74
176
|
},
|
|
75
177
|
VariableDeclarator(node) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (
|
|
178
|
+
if (node.id.type === "ObjectPattern" && node.init) destructures.push({ node, pattern: node.id, value: node.init })
|
|
179
|
+
},
|
|
180
|
+
AssignmentExpression(node) {
|
|
181
|
+
if (node.operator === "=" && node.left.type === "ObjectPattern") destructures.push({ node, pattern: node.left, value: node.right })
|
|
182
|
+
},
|
|
183
|
+
AssignmentPattern(node) {
|
|
184
|
+
if (node.left.type === "ObjectPattern") destructures.push({ node, pattern: node.left, value: node.right })
|
|
185
|
+
},
|
|
186
|
+
"Program:exit"() {
|
|
187
|
+
const { kindOf, readsEnv } = environmentIdentity(context.sourceCode ?? context.getSourceCode())
|
|
188
|
+
for (const node of members) {
|
|
189
|
+
if (kindOf(node) === "env") context.report({ node, messageId: "env" })
|
|
190
|
+
}
|
|
191
|
+
for (const { node, pattern, value } of destructures) {
|
|
192
|
+
if (readsEnv(pattern, kindOf(value))) context.report({ node, messageId: "env" })
|
|
193
|
+
}
|
|
83
194
|
},
|
|
84
195
|
}
|
|
85
196
|
},
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R291 HFS_FUNCTION_BUDGET: every function body stays within the common line budget.
|
|
3
|
+
*
|
|
4
|
+
* Count the physical span of the body, including braces, comments, blank lines and nested functions.
|
|
5
|
+
* Signatures and parameters are outside the body. An expression arrow has an expression body and
|
|
6
|
+
* counts the expression and its wrapping parentheses, including comments and blanks inside them.
|
|
7
|
+
*/
|
|
8
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
9
|
+
|
|
10
|
+
/** Functions, methods, constructors, accessors and callbacks all receive the same body budget. */
|
|
11
|
+
export const functionLineBudget = {
|
|
12
|
+
meta: {
|
|
13
|
+
type: "problem",
|
|
14
|
+
docs: { description: "A function body stays within ruleParams.functionLines, counting comments and blank lines." },
|
|
15
|
+
schema: [],
|
|
16
|
+
messages: {
|
|
17
|
+
lines: "HFS_FUNCTION_BUDGET: this function body has {{lines}} lines; the budget is {{max}}. Split the work into functions with one responsibility each.",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
create(context) {
|
|
21
|
+
// C08 adds this common parameter to the shipped manifest; keep the owner-decided cap while it lands.
|
|
22
|
+
const max = hfsOf(context).ruleParams.functionLines ?? 80
|
|
23
|
+
if (!Number.isInteger(max) || max < 1) throw new RangeError("ruleParams.functionLines must be a positive integer")
|
|
24
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
25
|
+
const check = (node) => {
|
|
26
|
+
if (!node.body) return
|
|
27
|
+
let start = node.body.loc.start.line
|
|
28
|
+
let end = node.body.loc.end.line
|
|
29
|
+
if (node.type === "ArrowFunctionExpression" && node.body.type !== "BlockStatement") {
|
|
30
|
+
const arrow = source.getTokenBefore(node.body, { filter: (token) => token.value === "=>" })
|
|
31
|
+
start = source.getTokenAfter(arrow, { includeComments: true }).loc.start.line
|
|
32
|
+
end = node.loc.end.line
|
|
33
|
+
}
|
|
34
|
+
const lines = end - start + 1
|
|
35
|
+
if (lines > max) context.report({ node: node.body, messageId: "lines", data: { lines, max } })
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
FunctionDeclaration: check,
|
|
39
|
+
FunctionExpression: check,
|
|
40
|
+
ArrowFunctionExpression: check,
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The rule this law contributes to the plugin. */
|
|
46
|
+
export const rules = { "function-line-budget": functionLineBudget }
|
|
47
|
+
|
|
48
|
+
/** The function budget is an error in both editions. */
|
|
49
|
+
export const recommended = { "starci-fe/function-line-budget": "error" }
|
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
|
+
}
|