@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.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -0
- package/client-boundary.mjs +66 -23
- package/docs/client-boundary.md +9 -9
- package/docs/hooks-folder.md +16 -4
- package/docs/hygiene.md +2 -2
- package/docs/transport.md +26 -26
- package/docs/type-safety.md +6 -6
- package/fe-stack.mjs +86 -0
- package/formatting.mjs +12 -6
- package/hooks-folder.mjs +73 -31
- package/hygiene.mjs +4 -4
- package/icon.mjs +26 -15
- package/index.mjs +2 -0
- package/lib/fe-stack.mjs +73 -0
- package/lib/scope.mjs +23 -2
- package/lib/why.mjs +23 -13
- package/next-conventions.mjs +4 -3
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +200 -8
- package/runtime/knowledge/hfs/fe-stack.yaml +221 -0
- package/runtime/knowledge/hfs/slots.yaml +307 -179
- package/runtime/knowledge/patterns/fe/folder.yaml +24 -18
- package/runtime/knowledge/sonar-gate.yaml +10 -2
- package/runtime/modules/kernel/removed-vocabulary.yaml +43 -0
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +0 -2
- package/runtime/scripts/hfs/architecture/config.mjs +2 -1
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +6 -2
- package/runtime/scripts/hfs/architecture/error-masked.mjs +10 -1
- package/runtime/scripts/hfs/architecture/frontend.mjs +10 -3
- package/runtime/scripts/hfs/architecture/hfs.mjs +6 -3
- package/runtime/scripts/hfs/architecture/index.mjs +2 -0
- package/runtime/scripts/hfs/architecture/owners.mjs +19 -16
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +4 -1
- package/runtime/scripts/hfs/architecture/server-action.mjs +75 -20
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +2 -2
- package/runtime/scripts/hfs/architecture/supabase.mjs +36 -210
- package/runtime/scripts/hfs/architecture/tiers.mjs +1 -33
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +110 -54
- package/runtime/scripts/hfs/architecture/type-context.mjs +4 -1
- package/runtime/scripts/hfs/edition-slots.mjs +13 -4
- package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
- package/runtime/scripts/hfs/rule-catalog.mjs +21 -1
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +4 -5
- package/served-locale.mjs +10 -13
- package/the-split.mjs +5 -2
- package/transport.mjs +81 -67
- 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
|
|
14
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
65
|
-
if (
|
|
66
|
-
const
|
|
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 (!
|
|
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 || (!
|
|
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 (
|
|
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
|
|
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`;
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
/**
|
|
64
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
124
|
-
|
|
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 },
|
package/lib/fe-stack.mjs
ADDED
|
@@ -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
|
-
/**
|
|
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
|
|
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)
|