@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.
Files changed (92) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +19 -1
  3. package/callback-shape.mjs +344 -0
  4. package/client-boundary.mjs +1 -1
  5. package/docs/hooks-folder.md +1 -1
  6. package/docs/size-and-state-budget.md +6 -6
  7. package/drawing.mjs +65 -28
  8. package/env-owner.mjs +120 -9
  9. package/function-budget.mjs +49 -0
  10. package/hooks-folder.mjs +149 -18
  11. package/icon.mjs +67 -0
  12. package/index.mjs +47 -0
  13. package/lib/own-hook-origin.mjs +53 -0
  14. package/lib/program-for.mjs +22 -0
  15. package/lib/pure-owner-helper.mjs +148 -0
  16. package/lib/redux-hook-origin.mjs +44 -0
  17. package/next-conventions.mjs +38 -35
  18. package/package.json +1 -1
  19. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  20. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  21. package/runtime/knowledge/hfs/slots.yaml +430 -92
  22. package/runtime/knowledge/patterns/fe/folder.yaml +4 -0
  23. package/runtime/knowledge/sonar-gate.yaml +1 -1
  24. package/runtime/modules/models/runtimes.yaml +1 -1
  25. package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
  26. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  27. package/runtime/scripts/api/git/diff.mjs +5 -0
  28. package/runtime/scripts/api/git/lib.mjs +3 -0
  29. package/runtime/scripts/api/git/merge-base.mjs +9 -0
  30. package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
  31. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +103 -0
  32. package/runtime/scripts/hfs/architecture/backend.mjs +22 -10
  33. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  34. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  35. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  36. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  37. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  38. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  39. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  40. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  41. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  42. package/runtime/scripts/hfs/architecture/dead-exports.mjs +23 -50
  43. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  44. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  45. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  46. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  47. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  48. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  49. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  50. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  51. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +49 -25
  52. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  53. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  54. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  55. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  56. package/runtime/scripts/hfs/architecture/register-once.mjs +15 -7
  57. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  58. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  59. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  60. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +214 -17
  61. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  62. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  63. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  64. package/runtime/scripts/hfs/architecture/tiers.mjs +11 -4
  65. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  66. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +9 -13
  67. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  68. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  69. package/runtime/scripts/hfs/manifest-shape.mjs +91 -6
  70. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  71. package/runtime/scripts/hfs/project-rule.mjs +3 -1
  72. package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
  73. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  74. package/runtime/scripts/hfs/rules/secrets.mjs +26 -0
  75. package/runtime/scripts/hfs/slot-imports.mjs +18 -3
  76. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  77. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  78. package/runtime/scripts/hfs/slots.mjs +1 -0
  79. package/runtime/scripts/lib/env.mjs +20 -0
  80. package/runtime/scripts/lib/event-contract.mjs +107 -0
  81. package/runtime/scripts/lib/json-clone.mjs +61 -0
  82. package/runtime/scripts/lib/list.mjs +4 -0
  83. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  84. package/runtime/scripts/lib/stack-services.mjs +1 -1
  85. package/runtime/scripts/lib/ts-analysis.mjs +23 -0
  86. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  87. package/served-locale.mjs +80 -9
  88. package/size-and-state-budget.mjs +11 -8
  89. package/size-growth.mjs +10 -6
  90. package/swr-family.mjs +197 -0
  91. package/translation.mjs +135 -7
  92. 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
- if (isEnvObject(node)) context.report({ node, messageId: "env" })
175
+ members.push(node)
74
176
  },
75
177
  VariableDeclarator(node) {
76
- // `const { env } = process` reads the environment without the member expression.
77
- if (node.id.type !== "ObjectPattern" || !node.init) return
78
- if (node.init.type !== "Identifier" || node.init.name !== "process") return
79
- const reads = node.id.properties.some(
80
- (property) => property.type === "Property" && property.key.type === "Identifier" && property.key.name === "env",
81
- )
82
- if (reads) context.report({ node, messageId: "env" })
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 isHookFileName = (name) => /^use[A-Z0-9]\w*\.ts$/.test(name)
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 = /^(?:server-only|next\/headers)$/
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
- /** The exported names a declaration introduces. */
50
- const exportedNames = (node) => {
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 [declaration && declaration.id ? declaration.id.name : "default"]
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) return node.specifiers.map((specifier) => specifier.exported.name)
57
- if (declaration.type === "FunctionDeclaration" && declaration.id) return [declaration.id.name]
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.test(String(node.source.value))) {
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) => node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration")
136
- .flatMap((node) => exportedNames(node).map((exportedName) => ({ node, name: exportedName })))
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
- const hooks = exported.filter((item) => isHookName(item.name))
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
+ }