@starci/eslint-canon-fe 8.2.13 → 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.
- package/CHANGELOG.md +19 -1
- 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 +25 -17
- 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 +207 -15
- package/runtime/knowledge/hfs/fe-stack.yaml +218 -0
- package/runtime/knowledge/hfs/slots.yaml +319 -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 +48 -0
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +0 -2
- package/runtime/scripts/hfs/architecture/config.mjs +3 -2
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
13
|
+
## 8.2.14 - 2026-10-12
|
|
14
|
+
|
|
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.
|
|
16
|
+
|
|
3
17
|
## 8.2.13 - 2026-10-12
|
|
4
18
|
|
|
5
19
|
- Changed: bundled canon pins carry @starci/grammar 0.9.12 (the secondary tab track keeps its padding).
|
|
@@ -12,9 +26,13 @@
|
|
|
12
26
|
|
|
13
27
|
- Changed: bundled canon pins carry @starci/grammar 0.9.10 (the grammar ships without spec suites).
|
|
14
28
|
|
|
29
|
+
## 8.2.10 - 2026-10-12
|
|
30
|
+
|
|
31
|
+
- Changed: refresh the bundled canon pins for CLI 1.2.9, HFS 4.2.9 and the BE lint canon 3.2.10 after correcting the CLI runtime install pin; ESLint rule implementations are unchanged.
|
|
32
|
+
|
|
15
33
|
## 8.2.9 - 2026-10-12
|
|
16
34
|
|
|
17
|
-
- Changed: refresh the bundled canon pins for grammar 0.9.9;
|
|
35
|
+
- Changed: refresh the bundled runtime and canon pins for grammar 0.9.9, stylelint-canon 2.0.8, CLI 1.2.8 and HFS 4.2.8; ESLint rule implementations are unchanged.
|
|
18
36
|
|
|
19
37
|
## 8.2.8 - 2026-10-12
|
|
20
38
|
|
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.
|
package/client-boundary.mjs
CHANGED
|
@@ -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 {
|
|
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) =>
|
|
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,
|
|
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`,
|
|
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
|
|
96
|
-
const serverOnlyReason = (
|
|
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`,
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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,
|
package/docs/client-boundary.md
CHANGED
|
@@ -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/`),
|
|
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,
|
|
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
|
|
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:**
|
|
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
|
|
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/
|
|
61
|
+
**Invalid** (`src/modules/i18n/request-locale.ts`)
|
|
62
62
|
|
|
63
63
|
```ts
|
|
64
64
|
import { headers } from "next/headers"
|
|
65
|
-
export const
|
|
65
|
+
export const requestLocale = async () => (await headers()).get("accept-language")
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
**Valid** (`src/modules/
|
|
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
|
|
73
|
+
export const requestLocale = async () => (await headers()).get("accept-language")
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
**Finding code:** `FE_SERVER_ONLY_MARK`
|
package/docs/hooks-folder.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
**
|
|
27
|
+
**Invalid** (`src/hooks/swr/api/graphql/queries/useGetCourseSwr.ts`: a REST name in the GraphQL queries folder)
|
|
28
28
|
|
|
29
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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/
|
|
19
|
+
**Valid** (`src/hooks/swr/api/graphql/queries/useQueryCourseSwr.ts`)
|
|
20
20
|
|
|
21
21
|
```ts
|
|
22
|
-
const
|
|
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)
|
|
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
|
|
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
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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.
|
|
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/
|
|
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/
|
|
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
|
|
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
|
|
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 `
|
|
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 {
|
|
188
|
-
export type
|
|
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 `
|
|
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 `
|
|
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.
|
package/docs/type-safety.md
CHANGED
|
@@ -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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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)
|