@starci/eslint-canon-fe 8.2.14 → 8.2.15

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 +10 -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 +25 -17
  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 +201 -14
  21. package/runtime/knowledge/hfs/fe-stack.yaml +218 -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 +48 -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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 8.2.15 - 2026-10-13
4
+
5
+ - Changed: carry the runtime mirror of HFS manifest 3.0.0 (one front-end standard for the lite and the full edition, owner ruling 2026-10-13) and the canon pins for Grammar 0.9.14, HFS 4.2.14, jest-preset 2.2.7 and CLI 1.2.14.
6
+ - Added: `fe-stack-import` (law `fe-stack`, finding code `FE_STACK_FORBIDDEN`): an import (static, re-export, dynamic, `require`, type-only included) of a package any concern of the fe-stack catalog (`runtime/knowledge/hfs/fe-stack.yaml`) forbids, and `@supabase/*` outside `modules/auth` (slot `fe.modules.auth`), are refused. The catalog is read from the runtime copy; a missing copy stops the lint run with the fix.
7
+ - Changed: the transport law follows the Apollo GraphQL + axios API layer. `fetch` (and `Request`, `EventSource`, `sendBeacon`) is legal in every file of the API layer (`modules/api`, the shared api package), `@apollo/client` only there, `axios` only in its REST calls (`modules/api/rest/`), `socket.io-client` only in `hooks/socketio/` (`fetch-only-in-api-client`, message `placed`); `client-fetch-has-signal` and `no-shared-transport-state` judge the whole layer (the generated `graphql/__generated__/` excepted); `no-hand-typed-wire` admits GraphQL documents in the API layer (one file per operation) and refuses them elsewhere; `one-outcome-union` names `GraphQLResponse` in `modules/api/graphql/types.ts` as the one vocabulary and also reads a `success` discriminant. The `modules/db` outcome home and the server reader role are gone.
8
+ - Changed: `hooks-folder-holds-hooks-only` judges the hooks families `hooks/{swr,rhf,zustand,socketio,effects,reuseables}` by the files their slots name (message `family`); `SwrSideEffects.tsx` and `SocketIoSideEffects.tsx` are legal `.tsx` files of `hooks/`.
9
+ - Changed: `use-client-only-at-boundary` admits the client state a layout mounts once (`components/providers/<name>/index.tsx`, `redux/ReduxProvider.tsx`, `modules/toast/toast.tsx`, the two side-effect components); `client-no-server-import` and `server-module-marks-server-only` drop the server reader role.
10
+ - Changed: `presentational-purity` recognises the stack's data, form, state, socket and toast hooks (`use{Query,Mutate,Get,Post,Put,Patch,Delete}<X>Swr`, `use<X>Form`, `useForm`, `useAppDispatch`, `use<X>SocketIo`, `use<X>WithToast`, the Apollo hooks); the served-locale rules find the links of the Apollo clients folder and read the header name case-insensitively; `use-intl-formatter` admits `dayjs` in `modules/dayjs`.
11
+ - Changed: `heroicons-is-the-glyph-vendor` is replaced by `one-icon-set`: product glyphs come only from the fe-stack icon set (`@phosphor-icons/react`, subpaths included); Heroicons and `@starci/heroicons` are refused like any second catalogue. A package the catalog forbids by name (date libraries, i18n stacks, glyph sets, transports) is reported once, by `fe-stack-import`.
12
+
3
13
  ## 8.2.14 - 2026-10-12
4
14
 
5
15
  - Changed: carry the merged runtime mirror and canon pins for Grammar 0.9.13, Stylelint canon 2.0.9, HFS 4.2.13 and CLI 1.2.13. The bundled HFS manifest 2.2.0 recognizes declaration-only ambient owners; ESLint rule implementations are unchanged.
package/README.md CHANGED
@@ -22,6 +22,7 @@ recommended set lifted to `error`), typed linting, and the inline-directive fenc
22
22
  `reportUnusedDisableDirectives`). The repository states nothing.
23
23
 
24
24
  - The **Sonar parity** law (R235): the smells SonarCloud measures that a lint rule can decide, `no-await-in-loop`, `prefer-code-point`, `prefer-string-raw`, `no-nested-conditional`, `no-void-operator`, `no-unused-import`, `prefer-export-from` and `no-unused-prop-types`, plus the typescript-eslint rules `no-deprecated`, `no-base-to-string` and `require-await` the factory turns on (they read types, so the source block is typed). A Next `matcher` string keeps its escapes (the segment config is read statically); `*.spec.ts` and `*.e2e-spec.ts` are outside the scan, so outside these rules.
25
+ - The **fe-stack** law: `fe-stack-import` refuses an import of a library the front-end stack catalog forbids (one canonical library per concern, read from `runtime/knowledge/hfs/fe-stack.yaml`) and `@supabase/*` outside `modules/auth`.
25
26
  - The **ignore** block: `node_modules`, `.next`, `dist`, `coverage`, `__generated__` and `.starci/`.
26
27
  - The **source** block governs `apps/*/src/**` and `packages/*/src/**` with types (`parserOptions.projectService`) and
27
28
  `settings.starci.hfs`, the slot view path-scoped rules ask (`lib/hfs.mjs` `hfsOf`), never a path pattern.
@@ -10,19 +10,34 @@
10
10
  * The directive is allowed in exactly the places the framework or the interaction requires:
11
11
  * - the `index.tsx` of an interactive block (or overlay), which is the connected half;
12
12
  * - a leaf, whose interactivity is intrinsic (a menu, a tooltip trigger);
13
- * - `error.tsx` and `global-error.tsx`, which Next requires to be client components.
13
+ * - `error.tsx` and `global-error.tsx`, which Next requires to be client components;
14
+ * - the client state of the front-end stack, which only ever runs in the browser: a provider of
15
+ * `components/providers/<name>/index.tsx`, every file of `hooks/` (the SWR, form, store, socket and effect hooks and the
16
+ * side-effect components), the redux store (`redux/`) and the toast module (`modules/toast/`). A directive there states
17
+ * what the file already is; it ships nothing a server component would have kept.
14
18
  * Everything below a client block is already on the client and needs no directive; everything
15
19
  * above it is a server component and must not have one.
16
20
  */
17
21
 
18
22
  import ts from "typescript"
19
- import { classifyImport, fileOf, isProductSource, kindOfFile, roleOfFile, slotOfFile, tierOfFile } from "./lib/scope.mjs"
23
+ import { isProductSource, kindOfFile, roleOfFile, slotOfFile, tierOfFile } from "./lib/scope.mjs"
20
24
  import { typed } from "./lib/types.mjs"
21
25
 
22
- /** The route roles that are client-only by the framework's own rule: `error` and `global-error`. */
23
26
  /** Route-tree roles that are client components by construction: the error boundaries, and the providers wrapper a layout renders. */
24
27
  const FRAMEWORK_CLIENT_ROLES = new Set(["error", "global-error", "providers"])
25
28
 
29
+ /** The slots of client state outside the hooks tier: the redux store and the toast module (fe-stack state-global, toast). */
30
+ const CLIENT_STATE_SLOTS = new Set(["fe.redux", "fe.modules.toast"])
31
+
32
+ /**
33
+ * The client state of the front-end stack, which runs only in the browser, so the directive states what the file already is: a
34
+ * provider entry (`fe.components.providers`), every file of the hooks tier, the redux store and the toast module.
35
+ */
36
+ const isClientState = (context) =>
37
+ (slotOfFile(context) === "fe.components.providers" && roleOfFile(context) === "entry") ||
38
+ tierOfFile(context) === "hooks" ||
39
+ CLIENT_STATE_SLOTS.has(slotOfFile(context))
40
+
26
41
  /** True when the program opens with the `"use client"` directive. */
27
42
  const clientDirective = (program) =>
28
43
  program.body.find(
@@ -41,7 +56,8 @@ const isInteractionBoundary = (context) => {
41
56
  }
42
57
 
43
58
  /** Where the directive is a legitimate boundary. */
44
- const isBoundary = (context) => (slotOfFile(context) === "fe.route" && FRAMEWORK_CLIENT_ROLES.has(roleOfFile(context))) || isInteractionBoundary(context)
59
+ const isBoundary = (context) =>
60
+ (slotOfFile(context) === "fe.route" && FRAMEWORK_CLIENT_ROLES.has(roleOfFile(context))) || isInteractionBoundary(context) || isClientState(context)
45
61
 
46
62
  /** The slot a file fills in the route tree (its role in `fe.route`, or the page or layout owner of a feature), when it fills one. */
47
63
  const routeFill = (context) => {
@@ -55,13 +71,13 @@ const routeFill = (context) => {
55
71
  export const useClientOnlyAtBoundary = {
56
72
  meta: {
57
73
  type: "problem",
58
- docs: { description: "`\"use client\"` appears only at a block index, a leaf, or a Next error boundary." },
74
+ docs: { description: "`\"use client\"` appears only at a block index, a leaf, a Next error boundary, or the client state of the stack (a provider, `hooks/`, `redux/`, `modules/toast/`)." },
59
75
  schema: [],
60
76
  messages: {
61
77
  slot:
62
78
  "`\"use client\"` on a {{slot}}. Routes, pages and layouts are server components: making one a client component ships its whole subtree, including the message catalog, to every reader and stops it reading data on the server. Move the interaction into a block below it and put the directive on that block's `index.tsx`; context providers go in the route tree's `providers.tsx`.",
63
79
  elsewhere:
64
- "`\"use client\"` here is not at an interaction boundary. The directive belongs on the `index.tsx` of the interactive block (or overlay), on a leaf whose interaction is intrinsic, on `error.tsx`/`global-error.tsx`, or on the route tree's `providers.tsx` (the context providers a layout renders around its children). Below a client block the file is already client; above one it must stay a server component.",
80
+ "`\"use client\"` here is not at an interaction boundary. The directive belongs on the `index.tsx` of the interactive block (or overlay), on a leaf whose interaction is intrinsic, on `error.tsx`/`global-error.tsx`, on the route tree's `providers.tsx` (the context providers a layout renders around its children), or on the client state of the stack (`components/providers/<name>/index.tsx`, `hooks/`, `redux/`, `modules/toast/`). Below a client block the file is already client; above one it must stay a server component.",
65
81
  },
66
82
  },
67
83
  create(context) {
@@ -92,25 +108,18 @@ const serverApiReason = (source) => {
92
108
  return null
93
109
  }
94
110
 
95
- /** A server-only source: the marker package, a server API, a server reader (role `reader` of slot `fe.modules.api`). */
96
- const serverOnlyReason = (context, source) => {
97
- if (source === "server-only") return "`server-only`"
98
- const api = serverApiReason(source)
99
- if (api) return api
100
- const target = classifyImport(context, source)
101
- if (target?.slot === "fe.modules.api" && target.role === "reader") return `the server reader \`${source}\``
102
- return null
103
- }
111
+ /** A server-only source: the marker package or a server API. */
112
+ const serverOnlyReason = (source) => (source === "server-only" ? "`server-only`" : serverApiReason(source))
104
113
 
105
114
  /** A client component imports nothing that only exists on the server. */
106
115
  export const clientNoServerImport = {
107
116
  meta: {
108
117
  type: "problem",
109
- docs: { description: "A `\"use client\"` file does not import `server-only`, `next/headers`, a Node built-in or a server reader." },
118
+ docs: { description: "A `\"use client\"` file does not import `server-only`, `next/headers`, `next/server`, `next-intl/server` or a Node built-in." },
110
119
  schema: [],
111
120
  messages: {
112
121
  server:
113
- "This client component imports {{what}}, which exists only on the server. The build either fails or, worse, pulls server code and its secrets toward the browser bundle. Read the data in a server component or a server reader and pass the result down as props, or call the app client through SWR.",
122
+ "This client component imports {{what}}, which exists only on the server. The build either fails or, worse, pulls server code and its secrets toward the browser bundle. Read the data through an SWR hook of `hooks/swr` over an operation of `modules/api`, and keep request-scoped server APIs in server components.",
114
123
  },
115
124
  },
116
125
  create(context) {
@@ -121,7 +130,7 @@ export const clientNoServerImport = {
121
130
  },
122
131
  ImportDeclaration(node) {
123
132
  if (!client || node.importKind === "type") return
124
- const what = serverOnlyReason(context, String(node.source.value))
133
+ const what = serverOnlyReason(String(node.source.value))
125
134
  if (what) context.report({ node, messageId: "server", data: { what } })
126
135
  },
127
136
  }
@@ -139,7 +148,7 @@ const isMarkerStatement = (statement) =>
139
148
 
140
149
  /** True when a TypeScript source file opens with `import "server-only"`. */
141
150
  const tsFileIsMarked = (file) => {
142
- // a file-level directive ("use server") is the prologue: the marker is the first statement after it
151
+ // a file-level directive ("use strict") is the prologue: the marker is the first statement after it
143
152
  const first = file.statements.find((statement) => !(ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)))
144
153
  return Boolean(first) && ts.isImportDeclaration(first) && !first.importClause && ts.isStringLiteral(first.moduleSpecifier) && first.moduleSpecifier.text === "server-only"
145
154
  }
@@ -177,14 +186,13 @@ export const serverModuleMarksServerOnly = {
177
186
  // A block or a page is a server or client COMPONENT, not a server module: whether client code reaches the server through it is
178
187
  // the machine's multi-hop question (FE_CLIENT_REACHES_SERVER). The marker belongs to the modules that hold server work.
179
188
  if (COMPONENT_TIERS.has(tierOfFile(context))) return {}
180
- const reader = roleOfFile(context) === "reader"
181
189
  let client = false
182
190
  let marked = false
183
191
  const uses = []
184
192
  return {
185
193
  Program(program) {
186
194
  client = clientDirective(program) !== null
187
- // a file-level directive ("use server") is the prologue, not a statement: the marker is the first statement after it
195
+ // a file-level directive ("use strict") is the prologue, not a statement: the marker is the first statement after it
188
196
  marked = isMarkerStatement(program.body.find((statement) => !(statement.type === "ExpressionStatement" && typeof statement.directive === "string")))
189
197
  },
190
198
  "ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration"(node) {
@@ -204,14 +212,48 @@ export const serverModuleMarksServerOnly = {
204
212
  "Program:exit"() {
205
213
  // a "use client" file that imports the server is client-no-server-import's finding, not a missing marker
206
214
  if (client || marked) return
207
- // A server reader (`read-*.ts`, role `reader` of the api slot) is server-only by its role, whatever it imports.
208
- if (reader && uses.length === 0) context.report({ node: context.sourceCode.ast, messageId: "mark", data: { what: "the request (it is a server reader)" } })
209
215
  for (const use of uses) context.report({ node: use.node, messageId: "mark", data: { what: use.what } })
210
216
  },
211
217
  }
212
218
  },
213
219
  }
214
220
 
221
+ // -- FE-CLIENT-2c (R243 FE_SERVER_ACTION_FORBIDDEN) ------------------------------------------------
222
+
223
+ /**
224
+ * The front end serves no API: no `"use server"` directive, at the top of a module or as the first statement of a function.
225
+ * Reads and writes reach the back end through the operations of `modules/api` and the SWR hooks; a write that must run on a
226
+ * server is a GraphQL mutation of the back end.
227
+ */
228
+ export const noServerAction = {
229
+ meta: {
230
+ type: "problem",
231
+ docs: { description: "No `\"use server\"` directive (module or function level): the front end holds no Server Action." },
232
+ schema: [],
233
+ messages: {
234
+ action:
235
+ "`\"use server\"` makes this a Server Action: an HTTP endpoint the front end serves, with its own input parsing, credential and error mapping beside the back end's. The front end serves no API. Write it as a GraphQL mutation of the back end, call it through its operation in `modules/api/graphql/mutations/` and its SWR hook (`hooks/swr/`), and report the outcome through `modules/toast`.",
236
+ },
237
+ },
238
+ create(context) {
239
+ const check = (statements) => {
240
+ for (const statement of statements ?? []) {
241
+ if (statement.type !== "ExpressionStatement" || typeof statement.directive !== "string") return
242
+ if (statement.directive === "use server") context.report({ node: statement, messageId: "action" })
243
+ }
244
+ }
245
+ const body = (node) => {
246
+ if (node.body?.type === "BlockStatement") check(node.body.body)
247
+ }
248
+ return {
249
+ Program: (program) => check(program.body),
250
+ FunctionDeclaration: body,
251
+ FunctionExpression: body,
252
+ ArrowFunctionExpression: body,
253
+ }
254
+ },
255
+ }
256
+
215
257
  // -- FE-CLIENT-3 -----------------------------------------------------------------------------------
216
258
 
217
259
  /** Web storage exists only in the browser and only under `modules/`, which guards it. */
@@ -348,6 +390,7 @@ export const rules = {
348
390
  "use-client-only-at-boundary": useClientOnlyAtBoundary,
349
391
  "client-no-server-import": clientNoServerImport,
350
392
  "server-module-marks-server-only": serverModuleMarksServerOnly,
393
+ "no-server-action": noServerAction,
351
394
  "web-storage-only-in-modules": webStorageOnlyInModules,
352
395
  "no-dangerous-html": noDangerousHtml,
353
396
  "response-cookie-attributes": responseCookieAttributes,
@@ -2,13 +2,13 @@
2
2
 
3
3
  Law module: `client-boundary.mjs`. Catalogue: R55 FE_CLIENT_BOUNDARY.
4
4
 
5
- Server first. `"use client"` appears only on the `index.tsx` of an interactive block (or overlay), on a leaf whose interaction is intrinsic, on a branch or leaf of a workspace package (`packages/<pkg>/src/{branches,leaves}/`, where the grammar tiers sit directly under `src/`), and on `error.tsx` / `global-error.tsx`, which Next requires to be client components. Routes, pages and layouts are server components.
5
+ Server first. `"use client"` appears only on the `index.tsx` of an interactive block (or overlay), on a leaf whose interaction is intrinsic, on a branch or leaf of a workspace package (`packages/<pkg>/src/{branches,leaves}/`, where the grammar tiers sit directly under `src/`), on `error.tsx` / `global-error.tsx`, which Next requires to be client components, on the route tree's `providers.tsx`, and on the client state a layout mounts once: a provider (`components/providers/<name>/index.tsx`), `redux/ReduxProvider.tsx`, the toast region `modules/toast/toast.tsx` and the side-effect components `hooks/swr/SwrSideEffects.tsx` and `hooks/socketio/SocketIoSideEffects.tsx`. Routes, pages and layouts are server components; the hooks, slices and modules below a client boundary are already client and carry no directive.
6
6
 
7
7
  Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
8
 
9
9
  ## `starci-fe/use-client-only-at-boundary`
10
10
 
11
- `"use client"` appears only at a block index, a leaf, or a Next error boundary.
11
+ `"use client"` appears only at a block index, a leaf, a Next error boundary, or the client state a layout mounts once (a provider, a side-effect component, the redux provider, the toast region).
12
12
 
13
13
  **Invalid** (`src/app/[locale]/layout.tsx`)
14
14
 
@@ -32,7 +32,7 @@ export const Feed = () => null
32
32
 
33
33
  ## `starci-fe/client-no-server-import`
34
34
 
35
- A `"use client"` file does not import `server-only`, `next/headers`, `next/server`, `next-intl/server`, a Node built-in or a server reader (sub-check `FE_CLIENT_SERVER_IMPORT`).
35
+ A `"use client"` file does not import `server-only`, `next/headers`, `next/server`, `next-intl/server` or a Node built-in (sub-check `FE_CLIENT_SERVER_IMPORT`).
36
36
 
37
37
  **Invalid** (`src/components/blocks/Feed/index.tsx`)
38
38
 
@@ -50,27 +50,27 @@ import useSWR from "swr"
50
50
 
51
51
  **Finding code:** `FE_CLIENT_SERVER_IMPORT`
52
52
 
53
- **Why:** The client component at `<file>` imports code that exists only on the server (`server-only`, `next/headers`, Node modules, a server reader).
53
+ **Why:** A client component in `<file>` imports code that exists only on the server (`server-only`, `next/headers`, a Node module, a server-only module).
54
54
 
55
- **Fix:** Read data in a server component or server reader and pass it down as props; or use SWR calling the app client.
55
+ **Fix:** Read the data through an SWR hook of `hooks/swr` over an operation of `modules/api`; keep request-scoped server APIs in server components.
56
56
 
57
57
  ## `starci-fe/server-module-marks-server-only`
58
58
 
59
59
  A module that imports `next/headers`, `next/server` (a value import), `next-intl/server`, a Node built-in, or a module that is itself server-only starts with `import "server-only"` (sub-check `FE_SERVER_ONLY_MARK`). A module is server-only when the TypeScript module resolution of the import specifier reaches a source file whose first statement is `import "server-only"`; `import type` and `import { type X }` carry no runtime and are not judged. Route files are server components by construction and need no marker: they are recognized by their slot (`fe.route`, `fe.source-root-pinned`), not by their name. A `"use client"` file that imports the server is `client-no-server-import`'s finding. The multi-hop client reachability (a client block reaching a marked module through a hook) is the architecture machine's.
60
60
 
61
- **Invalid** (`src/modules/api/courses/read-courses.ts`)
61
+ **Invalid** (`src/modules/i18n/request-locale.ts`)
62
62
 
63
63
  ```ts
64
64
  import { headers } from "next/headers"
65
- export const readCourses = async () => (await headers()).get("x-locale")
65
+ export const requestLocale = async () => (await headers()).get("accept-language")
66
66
  ```
67
67
 
68
- **Valid** (`src/modules/api/courses/read-courses.ts`)
68
+ **Valid** (`src/modules/i18n/request-locale.ts`)
69
69
 
70
70
  ```ts
71
71
  import "server-only"
72
72
  import { headers } from "next/headers"
73
- export const readCourses = async () => (await headers()).get("x-locale")
73
+ export const requestLocale = async () => (await headers()).get("accept-language")
74
74
  ```
75
75
 
76
76
  **Finding code:** `FE_SERVER_ONLY_MARK`
@@ -2,13 +2,13 @@
2
2
 
3
3
  Law module: `hooks-folder.mjs`. Catalogue: R56 FE_HOOKS_ARE_HOOKS (lint half).
4
4
 
5
- `hooks/<domain>/use<Name>.ts` holds one hook per file; `hooks/<domain>/<domain>.shared.ts` holds the domain's non-hook helpers; server readers live in `modules/api/<domain>/read-*.ts`. Whether a folder has exactly one shared file and whether a helper name repeats across files belongs to the architecture machine.
5
+ `hooks/` holds the hook families of the front-end stack, each its own slot whose `allows` name its files: `hooks/swr/` (`api/{graphql,rest}/{queries,mutations}/use{Query,Mutate,Get,Post,Put,Patch,Delete}<X>Swr.ts`, one SWR hook per `modules/api` operation, and `SwrSideEffects.tsx`), `hooks/rhf/use<X>Form.ts`, `hooks/zustand/<store>/` (`store.ts` and `use*.ts`), `hooks/socketio/` (`sockets.ts`, `SocketIoSideEffects.tsx`, `enums/`, `types/`, `use*.ts`), `hooks/effects/use*.ts` and `hooks/reuseables/use*.ts`. Beside them, `hooks/<domain>/use<Name>.ts` holds one hook per file and `hooks/<domain>/<domain>.shared.ts` the domain's non-hook helpers. The two side-effect components are the only `.tsx` files of `hooks/`. A call to the back end is an operation of `modules/api`, never a hook's own code. Whether a folder has exactly one shared file and whether a helper name repeats across files belongs to the architecture machine.
6
6
 
7
7
  Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
8
 
9
9
  ## `starci-fe/hooks-folder-holds-hooks-only`
10
10
 
11
- `hooks/<domain>/` holds `use*.ts` hooks and one `<domain>.shared.ts`; nothing else.
11
+ `hooks/` holds `use*.ts` hooks under the names their family slot gives and, per domain, one `<domain>.shared.ts`; nothing else. A hook file exports one hook; `server-only` and `next/headers` are never imported there.
12
12
 
13
13
  **Invalid** (`src/hooks/course/readCourse.ts`)
14
14
 
@@ -24,6 +24,18 @@ export const useCourse = () => 1
24
24
 
25
25
  **Finding code:** `FE_HOOKS_ARE_HOOKS`
26
26
 
27
- **Why:** `<file>` in `hooks/` is not a React hook. Server readers belong in `modules/api`, helpers in `<domain>.shared.ts`.
27
+ **Invalid** (`src/hooks/swr/api/graphql/queries/useGetCourseSwr.ts`: a REST name in the GraphQL queries folder)
28
28
 
29
- **Fix:** One hook per `use*.ts` file; shared helpers go into `<domain>.shared.ts`; server readers go into `modules/api/<domain>/read-*.ts`.
29
+ ```ts
30
+ export const useGetCourseSwr = () => 1
31
+ ```
32
+
33
+ **Valid** (`src/hooks/swr/api/graphql/queries/useQueryCourseSwr.ts`)
34
+
35
+ ```ts
36
+ export const useQueryCourseSwr = () => 1
37
+ ```
38
+
39
+ **Why:** `<file>` inside `hooks/` is not a React hook, or is not a file its hooks family names. A call to the back end goes to `modules/api`, a helper to `<domain>.shared.ts`.
40
+
41
+ **Fix:** One hook per `use*.ts` file under the name its family gives it (`hooks/swr/api/graphql/queries/useQuery<X>Swr.ts`, `hooks/rhf/use<X>Form.ts`, ...); a shared helper goes in `<domain>.shared.ts`; a back-end call is an operation of `modules/api`.
package/docs/hygiene.md CHANGED
@@ -57,7 +57,7 @@ const subscribe = (listener: () => void) => {
57
57
 
58
58
  ## `starci-fe/no-data-fetch-in-effect`
59
59
 
60
- An effect body starts no promise: no `await`, `fetch`, `.then`, `void load()`, `mutate()` or `refresh()`; read through SWR or a server reader. The call is judged when it runs synchronously in the body (an immediately-invoked function counts); a promise started inside a listener or timer callback the effect registers, or in the cleanup, is that callback's work. A call is a load when its type is a promise, when it is `.then`-ed, or when it is `void`-ed and untyped; a platform promise other than `fetch` (`audio.play()`, `clipboard.writeText()`) is not a data load. Data freshness comes from the SWR key: an SWR `mutate()` or `refresh()` called directly in the effect body is a finding too; run it from the event that changed the data (a handler, a socket message).
60
+ 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`. The call is judged when it runs synchronously in the body (an immediately-invoked function counts); a promise started inside a listener or timer callback the effect registers, or in the cleanup, is that callback's work. A call is a load when its type is a promise, when it is `.then`-ed, or when it is `void`-ed and untyped; a platform promise other than `fetch` (`audio.play()`, `clipboard.writeText()`) is not a data load. Data freshness comes from the SWR key: an SWR `mutate()` or `refresh()` called directly in the effect body is a finding too; run it from the event that changed the data (a handler, a socket message).
61
61
 
62
62
  **Invalid** (`src/components/blocks/Feed/component.tsx`)
63
63
 
@@ -75,7 +75,7 @@ const { data } = useSWR(key, fetcher)
75
75
 
76
76
  **Why:** `useEffect` in `<file>` loads data (`await`, `fetch`, `.then`): no cache, no request merging, no loading/error state, no cancellation.
77
77
 
78
- **Fix:** Read on the client with SWR calling the app client (`hooks/`), and on the route side with a server reader `modules/api/<domain>/read-*.ts`.
78
+ **Fix:** Read with an SWR hook of `hooks/swr` over an operation of `modules/api`, keyed by what changes; call `mutate()` from the event that changes the data (handler, socket message).
79
79
 
80
80
  ## `starci-fe/no-empty-catch`
81
81
 
package/docs/transport.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  Law module: `transport.mjs`. Catalogue: R50 FE_TRANSPORT_OWNER, R51 FE_HTTP_STATUS_COLLAPSE, R52 FE_WIRE_GENERATED.
4
4
 
5
- One repository, one client, one result vocabulary. The client is the file of HFS slot `fe.transport.client` (`apps/<app>/src/modules/api/client.ts`, a one-app repository) or `fe.package.api.client` (`packages/<family>-api/src/client.ts`, the shared client of a multi-app repository); the vocabulary is `Outcome<T>` (`ok`, `refused`, `invalid`, `not-found`, `unavailable`), declared in the file of the slot the manifest marks `outcomeHome`: `fe.transport.outcome`, `fe.package.api.outcome`, or `fe.modules.db.outcome` (`modules/db/outcome.ts`) in a lite app, whose db owner is the transport. The rules read the slot of the file being linted, never its path. 401 and 403 become `refused`; nothing collapses a status into `null`; wire types are generated from the contract copy in `modules/api/contract/`.
5
+ One front end, one API layer, one result vocabulary (lite and full alike). The API layer is `modules/api` (slot `fe.modules.api`, or the shared api package `fe.package.api`): the transport owner is the folder of the auth and no-auth Apollo clients and their link chain (slot `fe.transport.client`: `apps/<app>/src/modules/api/graphql/clients/`, or `fe.package.api.client`: `packages/<family>-api/src/graphql/clients/`), one file per GraphQL operation runs on them (`graphql/queries/query-<x>.ts`, `graphql/mutations/mutation-<x>.ts`), and the REST calls (uploads, presigned URLs, auth endpoints) are axios calls in `rest/<area>/`. `fetch` is reached only in the API layer, `@apollo/client` is imported only there, `axios` only by its REST calls and `socket.io-client` only by `hooks/socketio/`; everything else reaches the back end through the SWR hooks of `hooks/swr/`. The vocabulary is `GraphQLResponse<T>` (`success`, `message`, `error`, `data`), declared in the file of the slot the manifest marks `outcomeHome`: `fe.transport.outcome` (`modules/api/graphql/types.ts`) or `fe.package.api.outcome`. The rules read the slot of the file being linted, never its path. A raw `fetch` answered in the clients folder maps 401 and 403 to `refused`; nothing collapses a status into `null`; wire types are generated from the be contract snapshot into `graphql/__generated__/`, and a GraphQL document is written only in the API layer.
6
6
 
7
7
  Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
8
 
9
9
  ## `starci-fe/fetch-only-in-api-client`
10
10
 
11
- The repository's single `fetch` (and `Request`, `EventSource`, `navigator.sendBeacon`) lives in the api client slot. The global is found by scope resolution: `fetch(...)`, `globalThis.fetch`, `window.fetch`, `self.fetch`, `const f = fetch` and `const { fetch } = globalThis` all count, a local or imported `fetch` does not. A fetch library is refused by module specifier (axios, ky, got, undici, ofetch, graphql-request, urql, `@apollo/*`, ...; `import`, `export ... from`, `import()` and `require`), also inside the client. Another file of the api package (`src/transport.ts`) is not the client.
11
+ `fetch` (and `Request`, `EventSource`, `navigator.sendBeacon`) lives in the API layer (`modules/api`, or the shared api package). The global is found by scope resolution: `fetch(...)`, `globalThis.fetch`, `window.fetch`, `self.fetch`, `const f = fetch` and `const { fetch } = globalThis` all count, a local or imported `fetch` does not. The canonical transports are placed by module specifier (`import`, `export ... from`, `import()` and `require`): `@apollo/client` only in the API layer, `axios` only in its REST calls (`modules/api/rest/`), `socket.io-client` only in `hooks/socketio/` (`placed`). Another HTTP library (got, undici, ofetch, `@apollo/server`, ...) is refused everywhere (`library`); one the fe-stack catalog forbids by name (ky, graphql-request, urql, ...) is `fe-stack-import`'s finding.
12
12
 
13
13
  **Invalid** (`src/hooks/course/useCourse.ts`)
14
14
 
@@ -16,29 +16,29 @@ The repository's single `fetch` (and `Request`, `EventSource`, `navigator.sendBe
16
16
  const r = await fetch(url)
17
17
  ```
18
18
 
19
- **Valid** (`src/hooks/course/useCourse.ts`)
19
+ **Valid** (`src/hooks/swr/api/graphql/queries/useQueryCourseSwr.ts`)
20
20
 
21
21
  ```ts
22
- const r = await client.get(url)
22
+ const swr = useSWR([QUERY_COURSE_SWR_KEY, id], () => queryCourse({ request: { id } }))
23
23
  ```
24
24
 
25
25
  **Finding code:** `FE_TRANSPORT_OWNER`
26
26
 
27
- **Why:** `fetch` (or `Request`, `EventSource`, `sendBeacon`, another HTTP library) in `<file>` is outside the single client (`modules/api/client.ts`, or `src/client.ts` of the shared api package). Each repo has exactly one transport path.
27
+ **Why:** `<file>` reaches the back end outside the API layer: `fetch` (or `Request`, `EventSource`, `sendBeacon`, another HTTP library) outside `modules/api`, or Apollo, axios or socket.io-client outside its home (`modules/api`, `modules/api/rest`, `hooks/socketio`).
28
28
 
29
- **Fix:** Call the repo's client and receive `Outcome<T>`; do not call `fetch` yourself.
29
+ **Fix:** Call an operation of `modules/api` (Apollo GraphQL, or axios in `modules/api/rest`) through its SWR hook in `hooks/swr`; listen to realtime through `hooks/socketio`.
30
30
 
31
31
  ## `starci-fe/client-fetch-has-signal`
32
32
 
33
- Every `fetch` in `modules/api/client.ts` passes an AbortSignal (timeout or caller).
33
+ Every `fetch` in the API layer (`modules/api`: a custom fetch handed to the Apollo http link, a raw call) passes an AbortSignal (timeout or caller).
34
34
 
35
- **Invalid** (`src/modules/api/client.ts`)
35
+ **Invalid** (`src/modules/api/graphql/clients/links/http.ts`)
36
36
 
37
37
  ```ts
38
38
  fetch(url, { method: "GET" })
39
39
  ```
40
40
 
41
- **Valid** (`src/modules/api/client.ts`)
41
+ **Valid** (`src/modules/api/graphql/clients/links/http.ts`)
42
42
 
43
43
  ```ts
44
44
  fetch(url, { method: "GET", signal: AbortSignal.timeout(8000) })
@@ -54,13 +54,13 @@ fetch(url, { method: "GET", signal: AbortSignal.timeout(8000) })
54
54
 
55
55
  `modules/api/**` holds no module-level `let`/`var`.
56
56
 
57
- **Invalid** (`src/modules/api/client.ts`)
57
+ **Invalid** (`src/modules/api/graphql/clients/links/http.ts`)
58
58
 
59
59
  ```ts
60
60
  let token = null
61
61
  ```
62
62
 
63
- **Valid** (`src/modules/api/client.ts`)
63
+ **Valid** (`src/modules/api/graphql/clients/links/http.ts`)
64
64
 
65
65
  ```ts
66
66
  export const request = (token: string) => token
@@ -76,13 +76,13 @@ export const request = (token: string) => token
76
76
 
77
77
  The api client branches on the response status: a comparison of the fetch response's status (typed as a `Response`) with 401 and 403 (`===`, `||`, a literal list `.includes`/`.has`, or a `switch` with both cases) whose consequent builds an object with `kind: "refused"`. The three literals lying anywhere in the file do not pass, and neither does a mapping delegated to a function in another file.
78
78
 
79
- **Invalid** (`src/modules/api/client.ts`)
79
+ **Invalid** (`src/modules/api/graphql/clients/links/http.ts`)
80
80
 
81
81
  ```ts
82
82
  const r = await fetch(u, { signal })
83
83
  ```
84
84
 
85
- **Valid** (`src/modules/api/client.ts`)
85
+ **Valid** (`src/modules/api/graphql/clients/links/http.ts`)
86
86
 
87
87
  ```ts
88
88
  const r = await fetch(u, { signal })
@@ -99,13 +99,13 @@ if (r.status === 401 || r.status === 403) return { ok: false, kind: "refused" }
99
99
 
100
100
  A failure, a non-ok HTTP response or a failed `Outcome`, is not folded into one branch or into an empty value; its reason is read and kept (an HTTP status is mapped first: 401/403 `refused`, 404 `not-found`, 422 `invalid`, the rest `unavailable`).
101
101
 
102
- **Invalid** (`src/modules/api/client.ts`)
102
+ **Invalid** (`src/modules/api/graphql/clients/links/http.ts`)
103
103
 
104
104
  ```ts
105
105
  if (!res.ok) return null
106
106
  ```
107
107
 
108
- **Valid** (`src/modules/api/client.ts`)
108
+ **Valid** (`src/modules/api/graphql/clients/links/http.ts`)
109
109
 
110
110
  ```ts
111
111
  if (!res.ok) return toOutcome(res.status)
@@ -119,15 +119,15 @@ if (!res.ok) return toOutcome(res.status)
119
119
 
120
120
  ## `starci-fe/no-hand-typed-wire`
121
121
 
122
- A transport response body is narrowed from `unknown` or typed by a generated wire type (a type imported from a `__generated__/` module), never by a hand-written one: `(await response.json()) as unknown` is the sanctioned entry and does not fire; an assertion or annotation of a `.json()` read (directly, or through a `const` initialised with one) to any other type does. Inline GraphQL documents are also refused. A hand-declared interface is not judged by its name; only a response read typed by hand is.
122
+ A transport response body is narrowed from `unknown` or typed by a generated wire type (a type imported from a `__generated__/` module), never by a hand-written one: `(await response.json()) as unknown` is the sanctioned entry and does not fire; an assertion or annotation of a `.json()` read (directly, or through a `const` initialised with one) to any other type does. A GraphQL document (a `gql`/`graphql` tagged template or a string that reads as one) is written only in the API layer, one file per operation (`graphql/queries/query-<x>.ts`, `graphql/mutations/mutation-<x>.ts`), where codegen reads it; outside the layer it is refused. A hand-declared interface is not judged by its name; only a response read typed by hand is.
123
123
 
124
- **Invalid** (`src/modules/api/course/read-course.ts`)
124
+ **Invalid** (`src/modules/api/graphql/queries/query-course.ts`)
125
125
 
126
126
  ```ts
127
127
  const x = (await res.json()) as Course
128
128
  ```
129
129
 
130
- **Valid** (`src/modules/api/course/read-course.ts`)
130
+ **Valid** (`src/modules/api/graphql/queries/query-course.ts`)
131
131
 
132
132
  ```ts
133
133
  import type { CourseQuery } from "../__generated__/graphql"
@@ -137,9 +137,9 @@ const x: CourseQuery = parse(raw)
137
137
 
138
138
  **Finding code:** `FE_WIRE_GENERATED`
139
139
 
140
- **Why:** `<file>` hand-types a wire type or casts a response. Use types generated from `contract/`.
140
+ **Why:** `<file>` hand-types a wire type, casts a response, or writes a GraphQL document outside the API layer. Use the types generated from `be/contracts/`.
141
141
 
142
- **Fix:** Run codegen from the contract copy at `modules/api/contract/` and import the generated types; put GraphQL documents in `.graphql` files.
142
+ **Fix:** Run codegen from the be contract snapshot (`be/contracts/`) and import the generated types; write each GraphQL document in its operation file under `modules/api/graphql/queries` or `modules/api/graphql/mutations`.
143
143
 
144
144
  ## `starci-fe/outcome-kinds-exhaustive`
145
145
 
@@ -171,9 +171,9 @@ switch (outcome.kind) {
171
171
 
172
172
  ## `starci-fe/one-outcome-union`
173
173
 
174
- A result union (`ok` / `kind` discriminant) is declared only in the outcome-home slot (`outcomeHome: true` in the slot manifest); elsewhere the code composes `Outcome<T>`.
174
+ A result union (`success` / `ok` / `kind` discriminant) is declared only in the outcome-home slot (`outcomeHome: true` in the slot manifest: `modules/api/graphql/types.ts`, or the api package's `src/graphql/types.ts`); elsewhere the code composes `GraphQLResponse<T>`.
175
175
 
176
- A union type alias is a result union when its resolved members are all object types that carry the same literal-typed discriminant in the result vocabulary: `ok` (each member pins it to one value, `true` and `false` both present) or `kind` (at least one literal of the Outcome vocabulary: `ok`, `refused`, `forbidden`, `invalid`, `not-found`, `unavailable`). The members are read through the type checker, so `Ok<T> | Failure` and intersections are seen as what they are; no name (`*Outcome`, `*Result`) decides anything. UI state unions (`status`, `state`, `type`, a `kind` of tree nodes or menu items) are not result vocabulary and pass; so does an alias that only composes the one union (`type Read = Outcome<Course>`, `Exclude<Outcome<T>, ...>`). `Outcome<T> | { ok: false; kind: "conflict" }` adds an arm to the one union and is refused.
176
+ A union type alias is a result union when its resolved members are all object types that carry the same literal-typed discriminant in the result vocabulary: `success` or `ok` (each member pins it to one value, `true` and `false` both present) or `kind` (at least one literal of the Outcome vocabulary: `ok`, `refused`, `forbidden`, `invalid`, `not-found`, `unavailable`). The members are read through the type checker, so `Ok<T> | Failure` and intersections are seen as what they are; no name (`*Outcome`, `*Result`) decides anything. UI state unions (`status`, `state`, `type`, a `kind` of tree nodes or menu items) are not result vocabulary and pass; so does an alias that only composes the one union (`type Read = Outcome<Course>`, `Exclude<Outcome<T>, ...>`). `Outcome<T> | { ok: false; kind: "conflict" }` adds an arm to the one union and is refused.
177
177
 
178
178
  **Invalid** (`src/hooks/invite/useInvite.ts`)
179
179
 
@@ -184,12 +184,12 @@ export type InviteOutcome = { ok: true } | { ok: false; kind: "refused" }
184
184
  **Valid** (`src/hooks/invite/useInvite.ts`)
185
185
 
186
186
  ```ts
187
- import type { Outcome } from "@/modules/api/outcome"
188
- export type InviteOutcome = Outcome<{ id: string }>
187
+ import type { GraphQLResponse } from "@/modules/api/graphql/types"
188
+ export type InviteResponse = GraphQLResponse<{ id: string }>
189
189
  ```
190
190
 
191
191
  **Finding code:** `FE_HTTP_STATUS_COLLAPSE`
192
192
 
193
- **Why:** `<file>` declares another result union (`ok` or `kind`) outside `outcome.ts`. A repo has only one `Outcome<T>`.
193
+ **Why:** `<file>` declares another result union (`success`, `ok` or `kind`) outside `modules/api/graphql/types.ts`. A repo has only one result vocabulary, `GraphQLResponse<T>`.
194
194
 
195
- **Fix:** Use `Outcome<T>` from `modules/api/outcome.ts` (or the shared api package); add business detail through a second parameter instead of declaring a new union.
195
+ **Fix:** Use `GraphQLResponse<T>` of `modules/api/graphql/types.ts` (or the shared api package); put the business detail in its `data` instead of declaring a new union.
@@ -10,13 +10,13 @@ Every rule below is an error in `starciFeConfig`; none can be switched off or su
10
10
 
11
11
  No cast through `unknown`; it turns checking off at the boundary.
12
12
 
13
- **Invalid** (`src/modules/api/read-course.ts`)
13
+ **Invalid** (`src/modules/api/graphql/queries/query-course.ts`)
14
14
 
15
15
  ```tsx
16
16
  const row = payload as unknown as CourseRow
17
17
  ```
18
18
 
19
- **Valid** (`src/modules/api/read-course.ts`)
19
+ **Valid** (`src/modules/api/graphql/queries/query-course.ts`)
20
20
 
21
21
  ```tsx
22
22
  const row = parseCourseRow(payload)
@@ -32,13 +32,13 @@ const row = parseCourseRow(payload)
32
32
 
33
33
  No `as T` or `<T>x`; narrow with a check, a guard or a parser.
34
34
 
35
- **Invalid** (`src/modules/api/read-course.ts`)
35
+ **Invalid** (`src/modules/api/graphql/queries/query-course.ts`)
36
36
 
37
37
  ```tsx
38
38
  const row = payload as CourseRow
39
39
  ```
40
40
 
41
- **Valid** (`src/modules/api/read-course.ts`)
41
+ **Valid** (`src/modules/api/graphql/queries/query-course.ts`)
42
42
 
43
43
  ```tsx
44
44
  const row = isCourseRow(payload) ? payload : null
@@ -76,13 +76,13 @@ const first = rows[0] ?? fallbackRow
76
76
 
77
77
  No `any`; use a real type or `unknown` and narrow.
78
78
 
79
- **Invalid** (`src/modules/api/read-course.ts`)
79
+ **Invalid** (`src/modules/api/graphql/queries/query-course.ts`)
80
80
 
81
81
  ```tsx
82
82
  const parse = (value: any) => value.id
83
83
  ```
84
84
 
85
- **Valid** (`src/modules/api/read-course.ts`)
85
+ **Valid** (`src/modules/api/graphql/queries/query-course.ts`)
86
86
 
87
87
  ```tsx
88
88
  const parse = (value: unknown) => (isCourseRow(value) ? value.id : null)