@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.
Files changed (80) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/callback-shape.mjs +344 -0
  3. package/client-boundary.mjs +1 -1
  4. package/docs/hooks-folder.md +1 -1
  5. package/env-owner.mjs +120 -9
  6. package/function-budget.mjs +49 -0
  7. package/hooks-folder.mjs +149 -18
  8. package/icon.mjs +67 -0
  9. package/index.mjs +47 -0
  10. package/lib/own-hook-origin.mjs +53 -0
  11. package/lib/program-for.mjs +22 -0
  12. package/lib/pure-owner-helper.mjs +148 -0
  13. package/lib/redux-hook-origin.mjs +44 -0
  14. package/next-conventions.mjs +38 -35
  15. package/package.json +1 -1
  16. package/runtime/config.example.yaml +7 -6
  17. package/runtime/engine/config.mjs +10 -18
  18. package/runtime/engine/removed-vocabulary.mjs +0 -2
  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 +333 -62
  22. package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
  23. package/runtime/knowledge/sonar-gate.yaml +1 -1
  24. package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
  25. package/runtime/modules/models/runtimes.yaml +5 -7
  26. package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
  27. package/runtime/scripts/api/git/lib.mjs +4 -1
  28. package/runtime/scripts/api/git/merge-base.mjs +9 -0
  29. package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
  30. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
  31. package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
  32. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  33. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  34. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  35. package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
  36. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  37. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  38. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  39. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  40. package/runtime/scripts/hfs/architecture/index.mjs +1 -1
  41. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
  42. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  43. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  44. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  45. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  46. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
  47. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  48. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
  49. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
  50. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  51. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  52. package/runtime/scripts/hfs/architecture/tiers.mjs +4 -4
  53. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
  54. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  55. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  56. package/runtime/scripts/hfs/manifest-shape.mjs +69 -9
  57. package/runtime/scripts/hfs/param-names.mjs +1 -1
  58. package/runtime/scripts/hfs/project-rule.mjs +3 -1
  59. package/runtime/scripts/hfs/rule-catalog.mjs +158 -11
  60. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  61. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  62. package/runtime/scripts/hfs/slot-imports.mjs +18 -5
  63. package/runtime/scripts/hfs/slots.mjs +3 -2
  64. package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
  65. package/runtime/scripts/lib/env.mjs +20 -0
  66. package/runtime/scripts/lib/event-contract.mjs +107 -0
  67. package/runtime/scripts/lib/git.mjs +1 -1
  68. package/runtime/scripts/lib/i18n.mjs +0 -2
  69. package/runtime/scripts/lib/json-clone.mjs +61 -0
  70. package/runtime/scripts/lib/language.mjs +1 -1
  71. package/runtime/scripts/lib/list.mjs +4 -0
  72. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  73. package/runtime/scripts/lib/stack-services.mjs +1 -1
  74. package/runtime/scripts/lib/ts-analysis.mjs +5 -2
  75. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  76. package/served-locale.mjs +80 -9
  77. package/size-growth.mjs +10 -6
  78. package/swr-family.mjs +197 -0
  79. package/translation.mjs +135 -7
  80. 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 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
+ }
@@ -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
+ }