@starci/eslint-canon-fe 8.2.14 → 8.2.16

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 (48) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +1 -0
  3. package/client-boundary.mjs +66 -23
  4. package/docs/client-boundary.md +9 -9
  5. package/docs/hooks-folder.md +16 -4
  6. package/docs/hygiene.md +2 -2
  7. package/docs/transport.md +26 -26
  8. package/docs/type-safety.md +6 -6
  9. package/fe-stack.mjs +86 -0
  10. package/formatting.mjs +12 -6
  11. package/hooks-folder.mjs +73 -31
  12. package/hygiene.mjs +4 -4
  13. package/icon.mjs +26 -15
  14. package/index.mjs +2 -0
  15. package/lib/fe-stack.mjs +73 -0
  16. package/lib/scope.mjs +23 -2
  17. package/lib/why.mjs +23 -13
  18. package/next-conventions.mjs +4 -3
  19. package/package.json +1 -1
  20. package/runtime/knowledge/hfs/canon-pins.yaml +200 -8
  21. package/runtime/knowledge/hfs/fe-stack.yaml +221 -0
  22. package/runtime/knowledge/hfs/slots.yaml +307 -179
  23. package/runtime/knowledge/patterns/fe/folder.yaml +24 -18
  24. package/runtime/knowledge/sonar-gate.yaml +10 -2
  25. package/runtime/modules/kernel/removed-vocabulary.yaml +43 -0
  26. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +0 -2
  27. package/runtime/scripts/hfs/architecture/config.mjs +2 -1
  28. package/runtime/scripts/hfs/architecture/dead-exports.mjs +6 -2
  29. package/runtime/scripts/hfs/architecture/error-masked.mjs +10 -1
  30. package/runtime/scripts/hfs/architecture/frontend.mjs +10 -3
  31. package/runtime/scripts/hfs/architecture/hfs.mjs +6 -3
  32. package/runtime/scripts/hfs/architecture/index.mjs +2 -0
  33. package/runtime/scripts/hfs/architecture/owners.mjs +19 -16
  34. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +4 -1
  35. package/runtime/scripts/hfs/architecture/server-action.mjs +75 -20
  36. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +2 -2
  37. package/runtime/scripts/hfs/architecture/supabase.mjs +36 -210
  38. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -33
  39. package/runtime/scripts/hfs/architecture/transport-owner.mjs +110 -54
  40. package/runtime/scripts/hfs/architecture/type-context.mjs +4 -1
  41. package/runtime/scripts/hfs/edition-slots.mjs +13 -4
  42. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  43. package/runtime/scripts/hfs/rule-catalog.mjs +21 -1
  44. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +4 -5
  45. package/served-locale.mjs +10 -13
  46. package/the-split.mjs +5 -2
  47. package/transport.mjs +81 -67
  48. package/runtime/scripts/hfs/architecture/supabase-results.mjs +0 -182
package/fe-stack.mjs ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The rule that holds the front-end stack (`knowledge/hfs/fe-stack.yaml`, failure code `FE_STACK_FORBIDDEN`).
3
+ *
4
+ * ONE LIBRARY PER CONCERN. Every common front-end concern - GraphQL, REST, data hooks, toast, forms, state, realtime, i18n, UI,
5
+ * icons, motion, dates, numbers, storage, charts, markdown, code - has one canonical library, the same for the lite and the full
6
+ * edition (owner ruling 2026-10-13). A second library for a concern is a second set of conventions a reader has to know and a
7
+ * second dependency a release has to keep current, and it always arrives one import at a time. The catalog names, per concern,
8
+ * the alternatives a front end never imports; this rule refuses exactly those, read from the catalog, so the list lives once.
9
+ *
10
+ * SUPABASE IS AUTH ONLY. With a Supabase connection the front end holds the Supabase Auth client and nothing else of Supabase
11
+ * (the data path is GraphQL to the back end): `@supabase/*` is imported only by `modules/auth` (slot `fe.modules.auth`).
12
+ *
13
+ * WHAT IS LEFT TO SIBLINGS. A canonical library imported in the wrong place (Apollo outside `modules/api`, axios outside its REST
14
+ * calls, socket.io-client outside `hooks/socketio`) and `fetch` outside the API layer are `fetch-only-in-api-client`'s findings;
15
+ * a glyph library outside the icon leaf is the icon law's. Each import is judged by one rule.
16
+ */
17
+ import { forbiddingConcern, packageNameOf } from "./lib/fe-stack.mjs"
18
+ import { globalReferences, inSlot, isProductSource } from "./lib/scope.mjs"
19
+
20
+ /** The package scope only the identity-provider client may import. */
21
+ const SUPABASE_SCOPE = "@supabase/"
22
+
23
+ /**
24
+ * No import (static, re-export, dynamic or `require`) of a package the fe-stack catalog forbids, and no `@supabase/*` outside
25
+ * `modules/auth`. Type-only imports count: a type import still makes the package a dependency of the front end.
26
+ */
27
+ export const feStackImport = {
28
+ meta: {
29
+ type: "problem",
30
+ docs: { description: "A front end imports no library the fe-stack catalog forbids, and `@supabase/*` only in `modules/auth`." },
31
+ schema: [],
32
+ messages: {
33
+ forbidden:
34
+ "`{{name}}` is a forbidden alternative for the {{concern}} concern of the front-end stack. The stack has one library per concern, the same in every app and both editions: use {{canonical}} ({{pattern}}). {{why}}",
35
+ supabase:
36
+ "`{{name}}` is imported outside `modules/auth`. Supabase on the front end is the auth client only, held by `modules/auth` (the session, the access token the Apollo link attaches, sign-in and sign-out); data is read and written through the GraphQL operations of `modules/api`, never through Supabase from the browser.",
37
+ },
38
+ },
39
+ create(context) {
40
+ if (!isProductSource(context)) return {}
41
+ const authOwner = inSlot(context, "fe.modules.auth")
42
+ const judge = (node, source) => {
43
+ const name = packageNameOf(source)
44
+ if (name === null) return
45
+ const concern = forbiddingConcern(source)
46
+ if (concern) {
47
+ const named = concern.packages?.length > 0 ? concern.packages : concern.home ?? []
48
+ const canonical = named.map((entry) => `\`${entry}\``).join(", ")
49
+ context.report({ node, messageId: "forbidden", data: { name, concern: concern.id, canonical, pattern: concern.pattern ?? "", why: concern.why ?? "" } })
50
+ return
51
+ }
52
+ if (name.startsWith(SUPABASE_SCOPE) && !authOwner) context.report({ node, messageId: "supabase", data: { name } })
53
+ }
54
+ return {
55
+ ImportDeclaration(node) {
56
+ judge(node, node.source.value)
57
+ },
58
+ ExportNamedDeclaration(node) {
59
+ if (node.source) judge(node, node.source.value)
60
+ },
61
+ ExportAllDeclaration(node) {
62
+ judge(node, node.source.value)
63
+ },
64
+ ImportExpression(node) {
65
+ if (node.source.type === "Literal") judge(node, node.source.value)
66
+ },
67
+ TSImportEqualsDeclaration(node) {
68
+ // `import x = require("moment")`
69
+ if (node.moduleReference?.type === "TSExternalModuleReference" && node.moduleReference.expression?.type === "Literal") judge(node, node.moduleReference.expression.value)
70
+ },
71
+ CallExpression(node) {
72
+ // `require("moment")` where `require` is the CommonJS global.
73
+ if (node.callee.type !== "Identifier" || node.callee.name !== "require" || node.arguments[0]?.type !== "Literal") return
74
+ if (globalReferences(context, new Set(["require"])).includes(node.callee)) judge(node, node.arguments[0].value)
75
+ },
76
+ }
77
+ },
78
+ }
79
+
80
+ /** The rules this law contributes to the plugin. */
81
+ export const rules = {
82
+ "fe-stack-import": feStackImport,
83
+ }
84
+
85
+ /** Every rule is an error: a second library for a concern is the defect, not a style. */
86
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/formatting.mjs CHANGED
@@ -10,11 +10,13 @@
10
10
  *
11
11
  * WHAT IS REFUSED. `toLocaleString`, `toLocaleDateString`, `toLocaleTimeString`; `new Intl.*Format`;
12
12
  * `toFixed` when the result is shown (inside JSX or a template); a currency symbol glued to a
13
- * substitution in a template; and the date libraries (`moment`, `dayjs`, `date-fns`, `luxon`).
14
- * `modules/i18n/**` is exempt: it is where the formatters are configured.
13
+ * substitution in a template; and `dayjs` outside `modules/dayjs` (the front-end stack's date-math home; its display
14
+ * goes through the formatter). The other date libraries (`moment`, `date-fns`, `luxon`) are forbidden alternatives of the
15
+ * stack and refused by `fe-stack-import`. `modules/i18n/**` is exempt: it is where the formatters are configured.
15
16
  */
16
17
 
17
- import { fileOf, inSlot } from "./lib/scope.mjs"
18
+ import { forbiddingConcern } from "./lib/fe-stack.mjs"
19
+ import { classOf, inSlot } from "./lib/scope.mjs"
18
20
 
19
21
  /** The locale-sensitive `Date` and `Number` methods. */
20
22
  const LOCALE_METHODS = new Set(["toLocaleString", "toLocaleDateString", "toLocaleTimeString"])
@@ -23,7 +25,10 @@ const LOCALE_METHODS = new Set(["toLocaleString", "toLocaleDateString", "toLocal
23
25
  const INTL_FORMATTERS = new Set(["NumberFormat", "DateTimeFormat", "RelativeTimeFormat", "ListFormat", "PluralRules"])
24
26
 
25
27
  /** Date libraries that format on their own locale. */
26
- const DATE_LIBRARIES = /^(?:moment(?:-timezone)?|dayjs|date-fns(?:\/.+)?|luxon)$/
28
+ const DATE_LIBRARIES = /^(?:moment(?:-timezone)?|dayjs(?:\/.+)?|date-fns(?:\/.+)?|luxon)$/
29
+
30
+ /** The date-math home of the front-end stack (fe-stack `dates`): the capability `dayjs` of slot `fe.modules` (`modules/dayjs/`). */
31
+ const isDayjsHome = (context) => inSlot(context, "fe.modules") && classOf(context).bindings?.capability === "dayjs"
27
32
 
28
33
  /** A currency mark or code beside a substitution. */
29
34
  const CURRENCY = /[₫€£¥]|\b(?:VND|USD|EUR)\b/
@@ -53,7 +58,7 @@ export const useIntlFormatter = {
53
58
  currency:
54
59
  "A currency mark glued to a substitution in a template writes the symbol on the wrong side for half of the locales and skips grouping. Use `useFormatter().number(value, { style: \"currency\", currency })`.",
55
60
  library:
56
- "`{{name}}` formats dates on its own locale data, next to `next-intl`'s. One library decides the language of a date: use the `next-intl` formatter (`dateTime`, `relativeTime`).",
61
+ "`{{name}}` is imported outside `modules/dayjs`. It formats dates on its own locale data, next to `next-intl`'s, and one library decides the language of a date: do date math through `modules/dayjs` and display with the `next-intl` formatter (`dateTime`, `relativeTime`).",
57
62
  },
58
63
  },
59
64
  create(context) {
@@ -61,7 +66,8 @@ export const useIntlFormatter = {
61
66
  return {
62
67
  ImportDeclaration(node) {
63
68
  const source = String(node.source.value)
64
- if (DATE_LIBRARIES.test(source)) context.report({ node, messageId: "library", data: { name: source } })
69
+ // a date library the stack forbids is fe-stack-import's finding; dayjs is legal in its home
70
+ if (DATE_LIBRARIES.test(source) && !forbiddingConcern(source) && !isDayjsHome(context)) context.report({ node, messageId: "library", data: { name: source } })
65
71
  },
66
72
  CallExpression(node) {
67
73
  const callee = node.callee
package/hooks-folder.mjs CHANGED
@@ -4,11 +4,21 @@
4
4
  * `hooks/` MEANS REACT HOOKS. One app kept its server readers there - fetch, map, no React - and
5
5
  * had a single real hook; another had a filename-equals-export rule that gave a helper shared by
6
6
  * sixteen hooks nowhere to live, so it was copied sixteen times. Both came from the standard not
7
- * saying where the non-hook code goes. It now does:
7
+ * saying where the non-hook code goes. It now does, per family of the front-end stack (each its own slot):
8
8
  *
9
- * hooks/<domain>/use<Name>.ts one hook per file
9
+ * hooks/swr/api/{graphql,rest}/{queries,mutations}/use{Query,Mutate,Get,Post,Put,Patch,Delete}<X>Swr.ts
10
+ * one SWR hook per modules/api operation (fe.hooks.swr), plus SwrSideEffects.tsx
11
+ * hooks/rhf/use<X>Form.ts one form (fe.hooks.rhf)
12
+ * hooks/zustand/<store>/store.ts one local store and its use*.ts selectors (fe.hooks.zustand)
13
+ * hooks/socketio/ sockets.ts, SocketIoSideEffects.tsx, enums/, types/, use*.ts (fe.hooks.socketio)
14
+ * hooks/effects/use<X>.ts app-wide side effects (fe.hooks.effects)
15
+ * hooks/reuseables/use<X>.ts generic reusable hooks (fe.hooks.reuseables)
16
+ * hooks/<domain>/use<Name>.ts one hook per file (fe.hooks)
10
17
  * hooks/<domain>/<domain>.shared.ts one file for the domain's non-hook helpers (key builders...)
11
- * modules/api/<domain>/read-*.ts server readers (wrapped in `cache()`)
18
+ *
19
+ * A call to the back end is never a hook's own code: it is an operation of `modules/api`, which an SWR hook wraps. The two
20
+ * `.tsx` files a family names (`SwrSideEffects.tsx`, `SocketIoSideEffects.tsx`, role `effects`) are the components a provider
21
+ * mounts once; every other file of a family carries the name its slot gives it.
12
22
  *
13
23
  * The rule holds the shape of one file at a time. Whether a folder holds exactly one `.shared.ts`
14
24
  * and whether a helper name repeats across files is a repository fact, and belongs to the
@@ -21,9 +31,21 @@ import { baseName, classOf, fileOf, inSlot } from "./lib/scope.mjs"
21
31
  /** Names that mark a hook. */
22
32
  const isHookName = (name) => /^use[A-Z0-9]/.test(name)
23
33
 
24
- /** Modules that only run on the server: a reader, not a hook. */
34
+ /**
35
+ * The SWR key of a hook (`QUERY_<X>_SWR_KEY`, `MUTATE_<X>_SWR_KEY`, fe-stack data): the one constant a hook file exports beside its
36
+ * hook, so a mutation or a socket message revalidates exactly that cache entry.
37
+ */
38
+ const isSwrKeyName = (name) => /^[A-Z][A-Z0-9_]*_SWR_KEY$/.test(name)
39
+
40
+ /** A hook file name: `use<Name>.ts`. */
41
+ const isHookFileName = (name) => /^use[A-Z0-9]\w*\.ts$/.test(name)
42
+
43
+ /** Modules that only run on the server: server code, not a hook. */
25
44
  const SERVER_ONLY = /^(?:server-only|next\/headers)$/
26
45
 
46
+ /** The hooks families of the front-end stack: each slot names its files (`allows`) and its non-hook files (roles). */
47
+ const FAMILY_SLOTS = ["fe.hooks.swr", "fe.hooks.rhf", "fe.hooks.zustand", "fe.hooks.socketio", "fe.hooks.effects", "fe.hooks.reuseables"]
48
+
27
49
  /** The exported names a declaration introduces. */
28
50
  const exportedNames = (node) => {
29
51
  if (node.type === "ExportDefaultDeclaration") {
@@ -39,48 +61,68 @@ const exportedNames = (node) => {
39
61
  return []
40
62
  }
41
63
 
42
- /** `hooks/` holds React hooks and one shared helper file per domain. */
64
+ /**
65
+ * 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
+ * (directly in `hooks/`, a non-hook name in a domain, a name outside a family's `allows`); `hookFile` a `use<Name>.ts` hook
67
+ * file; `sharedFile` a domain's `<domain>.shared.ts`.
68
+ */
69
+ const hookFileOf = (context) => {
70
+ const hfs = hfsOf(context)
71
+ const file = fileOf(context)
72
+ const found = classOf(context)
73
+ const name = baseName(file)
74
+ if (inSlot(context, ...FAMILY_SLOTS)) {
75
+ const verdict = hfs.allows(file)
76
+ const stray = Boolean(verdict) && !verdict.allowed
77
+ const allows = (verdict?.allows ?? hfs.slot(found.slot)?.allows ?? []).join("`, `")
78
+ // A role the slot names (`entry`, `effects`, `store`) or a non-hook file it allows (sockets.ts, enums/, types/, barrels) is no hook file.
79
+ return { stray, directlyInHooks: false, hookFile: !stray && !found.role && isHookFileName(name), sharedFile: false, family: found.slot, allows }
80
+ }
81
+ if (!inSlot(context, "fe.hooks")) return null
82
+ // A file directly in `hooks/` is its own root: the slot binds the file name as the `domain`.
83
+ const directlyInHooks = hfs.relative(file) === found.root
84
+ // The slot names the domain's entry (`index.ts`, role `entry`) and its shared helper file (`<domain>.shared.ts`, role `shared`).
85
+ // Both sit directly in the domain folder: a nested `x/index.ts` is a file the slot does not name.
86
+ const directlyInDomain = hfs.relative(file) === `${found.root}/${name}`
87
+ const entryFile = directlyInDomain && found.role === "entry"
88
+ const sharedFile = directlyInDomain && found.role === "shared"
89
+ const hookFile = isHookFileName(name)
90
+ return { stray: !hookFile && !sharedFile && !entryFile, directlyInHooks, hookFile, sharedFile, family: null, allows: "" }
91
+ }
92
+
93
+ /** `hooks/` holds React hooks, each under the name its family or domain gives it, and one shared helper file per domain. */
43
94
  export const hooksFolderHoldsHooksOnly = {
44
95
  meta: {
45
96
  type: "problem",
46
- docs: { description: "`hooks/<domain>/` holds `use*.ts` hooks and one `<domain>.shared.ts`; nothing else." },
97
+ docs: { description: "`hooks/` holds `use*.ts` hooks under the names their family slot gives (`hooks/swr`, `hooks/rhf`, `hooks/zustand`, `hooks/socketio`, `hooks/effects`, `hooks/reuseables`) and, per domain, one `<domain>.shared.ts`; nothing else." },
47
98
  schema: [],
48
99
  messages: {
49
100
  domain:
50
- "A file directly in `hooks/`. Hooks live in `hooks/<domain>/use<Name>.ts`; the domain folder is what keeps a shared helper next to the hooks that use it.",
101
+ "A file directly in `hooks/`. Hooks live in a family folder (`hooks/swr/`, `hooks/rhf/`, `hooks/zustand/<store>/`, `hooks/socketio/`, `hooks/effects/`, `hooks/reuseables/`) or in `hooks/<domain>/use<Name>.ts`; the folder is what keeps a hook findable and a shared helper next to the hooks that use it.",
51
102
  notHook:
52
- "`{{name}}` is in `hooks/` but is not a React hook. Hooks are `use<Name>.ts`; the domain's non-hook helpers go in `<domain>.shared.ts`; a server reader goes in `modules/api/<domain>/read-*.ts`.",
103
+ "`{{name}}` is in `hooks/` but is not a React hook. Hooks are `use<Name>.ts`; the domain's non-hook helpers go in `<domain>.shared.ts`; a call to the back end is an operation of `modules/api`, wrapped by an SWR hook in `hooks/swr/`.",
104
+ family:
105
+ "`{{name}}` is not a file `{{family}}` names. This family holds `{{allows}}`: an SWR hook is `use{Query,Mutate}<X>Swr.ts` (GraphQL) or `use{Get,Post,Put,Patch,Delete}<X>Swr.ts` (REST) in its `api/<protocol>/<queries|mutations>/` folder, a form is `use<X>Form.ts`. A name outside the list is a hook nobody finds by its family, or code that is not a hook.",
53
106
  export:
54
- "`{{name}}` is exported from a hook file but is not a hook. A hook file exports the hook (a function named `use...`); a helper belongs in `<domain>.shared.ts`.",
107
+ "`{{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`.",
55
108
  many:
56
109
  "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.",
57
110
  sharedHook:
58
111
  "`{{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`.",
59
112
  server:
60
- "`{{source}}` marks server-only code, which is a reader, not a hook. Server readers live in `modules/api/<domain>/read-*.ts`, wrapped in React's `cache()`.",
113
+ "`{{source}}` marks server-only code, which is not a hook. The front end reads its back end through the operations of `modules/api`, wrapped by the SWR hooks of `hooks/swr/`; request-scoped server APIs stay in server components.",
61
114
  },
62
115
  },
63
116
  create(context) {
64
- // Slot `fe.hooks` owns `apps/<app>/src/hooks/<domain>/`; a file no slot puts there is not judged here.
65
- if (!inSlot(context, "fe.hooks")) return {}
66
- const file = fileOf(context)
67
- const found = classOf(context)
68
- // A file directly in `hooks/` is its own root: the slot binds the file name as the `domain`.
69
- const directlyInHooks = hfsOf(context).relative(file) === found.root
70
- const name = baseName(file)
71
- const subject = name
72
- const domain = found.bindings.domain
73
- const isHookFile = /^use[A-Z0-9]\w*\.ts$/.test(subject)
74
- // The slot names the domain's entry (`index.ts`, role `entry`) and its shared helper file (`<domain>.shared.ts`, role `shared`).
75
- // Both sit directly in the domain folder: a nested `x/index.ts` is a file the slot does not name.
76
- const directlyInDomain = hfsOf(context).relative(file) === `${found.root}/${name}`
77
- const isEntryFile = directlyInDomain && (found.role === "entry")
78
- const isSharedFile = directlyInDomain && (found.role === "shared")
79
-
117
+ const judged = hookFileOf(context)
118
+ if (judged === null) return {}
119
+ const name = baseName(fileOf(context))
80
120
  return {
81
121
  Program(program) {
82
- if (directlyInHooks) return context.report({ node: program, messageId: "domain" })
83
- if (!isHookFile && !isSharedFile && !isEntryFile) context.report({ node: program, messageId: "notHook", data: { name } })
122
+ if (judged.directlyInHooks) return context.report({ node: program, messageId: "domain" })
123
+ if (!judged.stray) return
124
+ if (judged.family) context.report({ node: program, messageId: "family", data: { name, family: judged.family, allows: judged.allows } })
125
+ else context.report({ node: program, messageId: "notHook", data: { name } })
84
126
  },
85
127
  ImportDeclaration(node) {
86
128
  if (SERVER_ONLY.test(String(node.source.value))) {
@@ -88,11 +130,11 @@ export const hooksFolderHoldsHooksOnly = {
88
130
  }
89
131
  },
90
132
  "Program:exit"(program) {
91
- if (directlyInHooks || (!isHookFile && !isSharedFile)) return
133
+ if (judged.directlyInHooks || judged.stray || (!judged.hookFile && !judged.sharedFile)) return
92
134
  const exported = program.body
93
135
  .filter((node) => node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration")
94
136
  .flatMap((node) => exportedNames(node).map((exportedName) => ({ node, name: exportedName })))
95
- if (isSharedFile) {
137
+ if (judged.sharedFile) {
96
138
  for (const item of exported) {
97
139
  if (isHookName(item.name)) context.report({ node: item.node, messageId: "sharedHook", data: { name: item.name } })
98
140
  }
@@ -100,7 +142,7 @@ export const hooksFolderHoldsHooksOnly = {
100
142
  }
101
143
  const hooks = exported.filter((item) => isHookName(item.name))
102
144
  for (const item of exported) {
103
- if (!isHookName(item.name) && item.name !== "default") {
145
+ if (!isHookName(item.name) && !isSwrKeyName(item.name) && item.name !== "default") {
104
146
  context.report({ node: item.node, messageId: "export", data: { name: item.name } })
105
147
  }
106
148
  }
package/hygiene.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  * - a timer, listener, observer, socket or subscription started by an effect that never releases it
8
8
  * keeps running after the component is gone and sets state on nothing;
9
9
  * - a fetch started in an effect has no cache, no dedupe, no error state and no cancel, which is
10
- * what SWR and the server readers already give for free (R50: client reads use SWR);
10
+ * what the SWR hooks over the `modules/api` operations already give for free (R50: reads use SWR);
11
11
  * - an empty `catch` turns a failure into a blank screen with no trace;
12
12
  * - `console.*` is a log nobody reads, in a place no log pipeline sees.
13
13
  *
@@ -413,15 +413,15 @@ const loadStartedBy = (context, effect) => {
413
413
  return found
414
414
  }
415
415
 
416
- /** No data loading inside `useEffect`; SWR on the client, a reader on the server. */
416
+ /** No data loading inside `useEffect`; data is read through an SWR hook over an operation of `modules/api`. */
417
417
  export const noDataFetchInEffect = {
418
418
  meta: {
419
419
  type: "problem",
420
- docs: { description: "An effect body starts no promise: no `await`, `fetch`, `.then`, `void load()`, `mutate()` or `refresh()`; read through SWR or a server reader." },
420
+ docs: { description: "An effect body starts no promise: no `await`, `fetch`, `.then`, `void load()`, `mutate()` or `refresh()`; read through an SWR hook (`hooks/swr`) over an operation of `modules/api`." },
421
421
  schema: [],
422
422
  messages: {
423
423
  fetch:
424
- "This effect starts a load: it awaits, calls `fetch`, chains `.then`, or calls a function that returns a promise (`void load()`, an SWR `mutate()` or `refresh()` too). An effect has no cache, no request dedupe, no error or loading state and no cancel, so two components asking for the same thing fetch twice and a slow first answer overwrites a fast second one. Data freshness comes from the SWR key, not from an effect: call the app client through SWR (`hooks/`) keyed on what changes, run `mutate()` from the event that changed the data (a handler, a socket message), and read what a route needs in a server reader (`modules/api/<domain>/read-*.ts`).",
424
+ "This effect starts a load: it awaits, calls `fetch`, chains `.then`, or calls a function that returns a promise (`void load()`, an SWR `mutate()` or `refresh()` too). An effect has no cache, no request dedupe, no error or loading state and no cancel, so two components asking for the same thing fetch twice and a slow first answer overwrites a fast second one. Data freshness comes from the SWR key, not from an effect: read through the SWR hook of the operation (`hooks/swr/`, over `modules/api`) keyed on what changes, and run `mutate()` from the event that changed the data (a handler, a socket message).",
425
425
  },
426
426
  },
427
427
  create(context) {
package/icon.mjs CHANGED
@@ -13,8 +13,9 @@
13
13
  */
14
14
 
15
15
  import { isClassAttribute, staticText } from "./lib/ast.mjs"
16
+ import { canonicalPackages } from "./lib/fe-stack.mjs"
16
17
  import { hfsOf } from "./lib/hfs.mjs"
17
- import { fileOf, isComponentFile, isProductSource, kindOfFile, roleOfFile } from "./lib/scope.mjs"
18
+ import { fileOf, inSlot, isComponentFile, isProductSource, kindOfFile, roleOfFile } from "./lib/scope.mjs"
18
19
 
19
20
  /** The one module allowed to name a glyph from a library: the entry of the leaf owner named `Icon`. */
20
21
  const ICON_LEAF = { kind: "leaves", owner: "Icon" }
@@ -44,13 +45,15 @@ const GLYPH_PACKAGES = [
44
45
  /** Package-name signal for a glyph catalogue not yet listed explicitly. */
45
46
  const GLYPH_PACKAGE_NAME = /(?:icon|glyph|lucide|feather|tabler|fortawesome)/i
46
47
 
47
- /** Upstream Heroicons plus StarCi's custom-only extension, each on the same two optical families. */
48
- const HEROICON_PACKAGES = new Set([
49
- "@heroicons/react/24/outline",
50
- "@heroicons/react/16/solid",
51
- "@starci/heroicons/24/outline",
52
- "@starci/heroicons/16/solid",
53
- ])
48
+ /** The two optical families of the heroicons family: the 24px outline heading cut and the 16px solid micro cut. */
49
+ const GLYPH_CUTS = ["24/outline", "16/solid"]
50
+
51
+ /**
52
+ * The closed glyph surface: each package of the one application icon set (fe-stack `icons`: upstream `@heroicons/react`,
53
+ * used first, and StarCi's custom-only `@starci/heroicons` for the glyphs it lacks) on each of the two cuts. Read from the
54
+ * catalog, so the set is named once.
55
+ */
56
+ const glyphSurface = () => canonicalPackages("icons").flatMap((name) => GLYPH_CUTS.map((cut) => `${name}/${cut}`))
54
57
 
55
58
  /**
56
59
  * True when the file is the entry (slot role `entry`) of the component owner named `owner` in the layer `kind`.
@@ -60,8 +63,11 @@ const isEntryOf = (context, { kind, owner }) =>
60
63
  isComponentFile(context) && kindOfFile(context) === kind && roleOfFile(context) === "entry" &&
61
64
  hfsOf(context).relative(fileOf(context)).split("/").slice(-2)[0] === owner
62
65
 
63
- /** True when this file is the icon leaf, which owns the meaning-to-glyph map. */
64
- const isIconLeafFile = (context) => isEntryOf(context, ICON_LEAF)
66
+ /**
67
+ * True when this file owns glyphs: the icon leaf, which owns the meaning-to-glyph map, or the toast module (`fe.modules.toast`),
68
+ * whose region draws the status glyph of each toast and sits in the modules tier, below every component it could ask.
69
+ */
70
+ const isIconLeafFile = (context) => isEntryOf(context, ICON_LEAF) || inSlot(context, "fe.modules.toast")
65
71
 
66
72
  /** True for the reusable repeated metric cell whose reference contains no decorative glyph. */
67
73
  const isLabelledProgressRowFile = (context) => isEntryOf(context, LABELLED_PROGRESS_ROW)
@@ -104,15 +110,19 @@ export const noVendorIconOutsideIconLeaf = {
104
110
 
105
111
  // -- ICON-7 ----------------------------------------------------------------------------------------
106
112
 
107
- /** The icon leaf owns the map, but that ownership does not license a second glyph vocabulary. */
113
+ /**
114
+ * The icon leaf owns the map, but that ownership does not license a second glyph vocabulary: every glyph comes from the
115
+ * heroicons family on its two cuts. Phosphor, Iconify, Lucide and every other glyph catalogue are refused, including the
116
+ * ones no list can name in advance.
117
+ */
108
118
  export const heroiconsIsTheGlyphVendor = {
109
119
  meta: {
110
120
  type: "problem",
111
- docs: { description: "Only the Heroicons heading and micro families may supply product glyphs." },
121
+ docs: { description: "Only the Heroicons heading and micro families (`@heroicons/react`, then `@starci/heroicons`) may supply product glyphs." },
112
122
  schema: [],
113
123
  messages: {
114
124
  vendor:
115
- "`{{source}}` is outside StarCi's closed glyph surface. Use upstream `@heroicons/react/24/outline` or `16/solid` when the icon exists; use the matching `@starci/heroicons` subpath only for a custom cut missing upstream. The StarCi package never re-exports upstream icons. Iconify, Phosphor and every other glyph catalogue are refused.",
125
+ "`{{source}}` is outside StarCi's closed glyph surface ({{surface}}). Use upstream `@heroicons/react/24/outline` or `16/solid` when the icon exists; use the matching `@starci/heroicons` subpath only for a custom cut missing upstream, and add a missing glyph there. The StarCi package never re-exports upstream icons. Phosphor, Iconify and every other glyph catalogue are refused: two sets draw the same meaning with two strokes, two grids and two weights.",
116
126
  },
117
127
  },
118
128
  create(context) {
@@ -120,8 +130,9 @@ export const heroiconsIsTheGlyphVendor = {
120
130
  return {
121
131
  ImportDeclaration(node) {
122
132
  const source = node.source && node.source.value
123
- if (!isGlyphImport(source) || HEROICON_PACKAGES.has(String(source))) return
124
- context.report({ node, messageId: "vendor", data: { source } })
133
+ const surface = glyphSurface()
134
+ if (!isGlyphImport(source) || surface.includes(String(source))) return
135
+ context.report({ node, messageId: "vendor", data: { source, surface: surface.map((name) => `\`${name}\``).join(", ") } })
125
136
  },
126
137
  }
127
138
  },
package/index.mjs CHANGED
@@ -20,6 +20,7 @@ import { recommended as clientBoundaryRecommended, rules as clientBoundaryRules
20
20
  import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
21
21
  import { recommended as classNamesRecommended, rules as classNamesRules } from "./class-names.mjs"
22
22
  import { recommended as envOwnerRecommended, rules as envOwnerRules } from "./env-owner.mjs"
23
+ import { recommended as feStackRecommended, rules as feStackRules } from "./fe-stack.mjs"
23
24
  import { recommended as fileLayoutRecommended, rules as fileLayoutRules } from "./file-layout.mjs"
24
25
  import { recommended as grammarBoundaryRecommended, rules as grammarBoundaryRules } from "./grammar-boundary.mjs"
25
26
  import { recommended as formattingRecommended, rules as formattingRules } from "./formatting.mjs"
@@ -63,6 +64,7 @@ const CONTRIBUTIONS = [
63
64
  recommended: classNamesRecommended,
64
65
  },
65
66
  { law: "env-owner", rules: envOwnerRules, recommended: envOwnerRecommended },
67
+ { law: "fe-stack", rules: feStackRules, recommended: feStackRecommended },
66
68
  { law: "file-layout", rules: fileLayoutRules, recommended: fileLayoutRecommended },
67
69
  { law: "grammar-boundary", rules: grammarBoundaryRules, recommended: grammarBoundaryRecommended },
68
70
  { law: "formatting", rules: formattingRules, recommended: formattingRecommended },
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The one door between the front-end lint rules and the fe-stack catalog (`knowledge/hfs/fe-stack.yaml`): for each common
3
+ * front-end concern the ONE canonical library, its HFS home and the alternatives a front end never imports.
4
+ *
5
+ * The catalog is read from this package's `runtime/` copy, the same way the slot manifest and the canon pins are (a byte copy
6
+ * of the runtime's knowledge file, refreshed by `scripts/hfs/sync-runtime.mjs`), so a rule in a product repository judges
7
+ * against the catalog of the canon version it installed. A rule that needs the catalog and finds none stops the lint run
8
+ * with an error naming the fix: a rule must not guess which libraries are forbidden.
9
+ */
10
+ import { readFileSync } from "node:fs"
11
+ import { dirname, join } from "node:path"
12
+ import { fileURLToPath } from "node:url"
13
+ import { parseYaml } from "../runtime/engine/yaml.mjs"
14
+
15
+ const CATALOG = join(dirname(fileURLToPath(import.meta.url)), "..", "runtime", "knowledge", "hfs", "fe-stack.yaml")
16
+
17
+ /** @type {{ concerns: ReadonlyArray<object>, forbidden: ReadonlyMap<string, object> } | null} */
18
+ let loaded = null
19
+
20
+ /**
21
+ * The parsed catalog: its concerns, and every forbidden package mapped to the concern that forbids it.
22
+ *
23
+ * @returns {{ concerns: ReadonlyArray<object>, forbidden: ReadonlyMap<string, object> }} The catalog.
24
+ */
25
+ export const feStack = () => {
26
+ if (loaded) return loaded
27
+ let text
28
+ try {
29
+ text = readFileSync(CATALOG, "utf8")
30
+ } catch (error) {
31
+ throw new Error(`@starci/eslint-canon-fe reads the fe-stack catalog from runtime/knowledge/hfs/fe-stack.yaml and it is missing (${error.code ?? error.message}): regenerate the runtime copy (scripts/hfs/sync-runtime.mjs)`)
32
+ }
33
+ const concerns = parseYaml(text)?.concerns
34
+ if (!Array.isArray(concerns) || concerns.length === 0) throw new Error("runtime/knowledge/hfs/fe-stack.yaml declares no concerns: the fe-stack catalog is malformed")
35
+ const forbidden = new Map()
36
+ for (const concern of concerns) for (const name of concern.forbids ?? []) forbidden.set(String(name), concern)
37
+ loaded = Object.freeze({ concerns: Object.freeze(concerns), forbidden })
38
+ return loaded
39
+ }
40
+
41
+ /**
42
+ * The package a module specifier names (`@scope/name` or `name`, its subpath dropped), or null for a relative, aliased,
43
+ * node-builtin or empty specifier.
44
+ *
45
+ * @param {unknown} specifier - The module specifier.
46
+ * @returns {string | null} The package name.
47
+ */
48
+ export const packageNameOf = (specifier) => {
49
+ const text = typeof specifier === "string" ? specifier : ""
50
+ if (text === "" || text.startsWith(".") || text.startsWith("/") || text.startsWith("@/") || text.startsWith("~/") || text.startsWith("node:")) return null
51
+ const parts = text.split("/")
52
+ if (text.startsWith("@")) return parts.length >= 2 && parts[1] !== "" ? `${parts[0]}/${parts[1]}` : null
53
+ return parts[0]
54
+ }
55
+
56
+ /**
57
+ * The concern whose `forbids` lists the package a specifier names, else null.
58
+ *
59
+ * @param {unknown} specifier - The module specifier.
60
+ * @returns {object | null} The concern (`{ id, packages, home, pattern, forbids, why }`).
61
+ */
62
+ export const forbiddingConcern = (specifier) => {
63
+ const name = packageNameOf(specifier)
64
+ return name === null ? null : feStack().forbidden.get(name) ?? null
65
+ }
66
+
67
+ /**
68
+ * The canonical packages of a concern.
69
+ *
70
+ * @param {string} id - The concern id (`icons`, `graphql`, ...).
71
+ * @returns {ReadonlyArray<string>} Its packages; empty for an unknown concern.
72
+ */
73
+ export const canonicalPackages = (id) => feStack().concerns.find((concern) => concern.id === id)?.packages ?? []
package/lib/scope.mjs CHANGED
@@ -60,10 +60,31 @@ export const isComponentPath = (context, file) => {
60
60
  /** The one place that may read the environment: slot `fe.modules.config`. */
61
61
  export const isConfigModule = (context) => inSlot(context, "fe.modules.config")
62
62
 
63
- /** The one place that may call `fetch`: slot `fe.transport.client` (one-app repository) or `fe.package.api.client` (shared package). */
63
+ /**
64
+ * The transport owner: the folder of the auth and no-auth Apollo clients and their link chain, slot `fe.transport.client`
65
+ * (`modules/api/graphql/clients/`, one-app repository) or `fe.package.api.client` (`src/graphql/clients/` of the shared api package).
66
+ */
64
67
  export const isApiClient = (context) => inSlot(context, "fe.transport.client", "fe.package.api.client")
65
68
 
66
- /** The file that may declare the one Outcome union: the file of the slot the manifest marks `outcomeHome`. */
69
+ /** The slots of the API layer: the app's `modules/api` (with its clients folder and its GraphQLResponse file) and the shared api package. */
70
+ const API_LAYER_SLOTS = Object.freeze(["fe.modules.api", "fe.transport.client", "fe.transport.outcome", "fe.package.api", "fe.package.api.client", "fe.package.api.outcome"])
71
+
72
+ /** True when the linted file sits in the API layer: the only place that talks to the back end (Apollo, axios, `fetch`). */
73
+ export const isApiLayer = (context) => inSlot(context, ...API_LAYER_SLOTS)
74
+
75
+ /**
76
+ * True when the linted file is a REST call of the API layer: `modules/api/rest/**` of an app (slot `fe.modules.api`) or
77
+ * `src/rest/**` of the shared api package (slot `fe.package.api`), the one home of axios.
78
+ */
79
+ export const isApiRest = (context) => {
80
+ if (!inSlot(context, "fe.modules.api", "fe.package.api")) return false
81
+ const hfs = hfsOf(context)
82
+ const file = fileOf(context)
83
+ const below = hfs.relative(file).slice(hfs.classify(file).root.length + 1).split("/")
84
+ return (below[0] === "src" ? below[1] : below[0]) === "rest"
85
+ }
86
+
87
+ /** The file that may declare the one result vocabulary (`GraphQLResponse`): the file of the slot the manifest marks `outcomeHome`. */
67
88
  export const isOutcomeModule = (context) => {
68
89
  const hfs = hfsOf(context)
69
90
  const slot = slotOfFile(context)