@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
|
@@ -86,9 +86,9 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
|
|
|
86
86
|
export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
|
|
87
87
|
const strList = (v) => stringList(v);
|
|
88
88
|
|
|
89
|
-
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome'
|
|
89
|
+
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome'];
|
|
90
90
|
/** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
|
|
91
|
-
const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances'];
|
|
91
|
+
const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances', 'why'];
|
|
92
92
|
/** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
|
|
93
93
|
const RUNTIME_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'minInstances', 'requires', 'allows', 'forbids', 'budget', 'rules', 'goesTo', 'why', 'since', 'generatedBy', 'parent', 'coverage'];
|
|
94
94
|
|
|
@@ -197,6 +197,7 @@ function slotLiteOverlayProblems(slot, at) {
|
|
|
197
197
|
if (slot.lite === undefined) return [];
|
|
198
198
|
const ok = isPlainObject(slot.lite) && Object.keys(slot.lite).length && Object.keys(slot.lite).every((key) => LITE_OVERLAY_KEYS.includes(key))
|
|
199
199
|
&& (slot.lite.path === undefined || (typeof slot.lite.path === 'string' && slot.lite.path))
|
|
200
|
+
&& (slot.lite.why === undefined || (typeof slot.lite.why === 'string' && slot.lite.why.trim()))
|
|
200
201
|
&& ['requires', 'allows', 'forbids'].every((key) => slot.lite[key] === undefined || strList(slot.lite[key]))
|
|
201
202
|
&& (slot.lite.minInstances === undefined || (Number.isInteger(slot.lite.minInstances) && slot.lite.minInstances >= 1))
|
|
202
203
|
&& (slot.lite.requiredInstances === undefined || (isPlainObject(slot.lite.requiredInstances) && Object.values(slot.lite.requiredInstances).every((v) => strList(v) && v.length)));
|
|
@@ -211,7 +212,6 @@ function slotEntryProblems(slot, at) {
|
|
|
211
212
|
else if (typeof slot.path !== 'string' || !slot.path.endsWith('/') || slot.owner === true) bad.push(`${at}: entries belong to a directory slot that is not an owner (they name extra public entries of the owner that holds it)`);
|
|
212
213
|
else if (Array.isArray(slot.allows) && slot.entries.some((entry) => !slot.allows.includes(entry))) bad.push(`${at}: every entry must be one of the names the slot allows`);
|
|
213
214
|
}
|
|
214
|
-
if (slot.anonymousActions !== undefined && !(strList(slot.anonymousActions) && slot.anonymousActions.length > 0 && new Set(slot.anonymousActions).size === slot.anonymousActions.length && slot.anonymousActions.every(relPath) && typeof slot.path === 'string' && slot.path.endsWith('/'))) bad.push(`${at}: anonymousActions must be a non-empty list of unique relative file paths of a directory slot`);
|
|
215
215
|
if (slot.outcomeHome !== undefined && !(slot.outcomeHome === true && typeof slot.path === 'string' && slot.path && !slot.path.endsWith('/'))) bad.push(`${at}: outcomeHome is true and belongs to a file slot (the one file that declares the repository's Outcome union)`);
|
|
216
216
|
return bad;
|
|
217
217
|
}
|
|
@@ -15,7 +15,9 @@ const RULE_KINDS = Object.freeze(['codemod', 'lint', 'check', 'design']);
|
|
|
15
15
|
const FINDING_CODE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
|
|
16
16
|
const ENFORCER_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
17
17
|
const FILE_ENFORCERS = new Set(['machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
|
|
18
|
-
const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'enforcers']);
|
|
18
|
+
const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'enforcers', 'cases']);
|
|
19
|
+
const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote']);
|
|
20
|
+
const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
19
21
|
const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions']);
|
|
20
22
|
|
|
21
23
|
function vocabularyProblems(catalog, key, names, bad) {
|
|
@@ -59,6 +61,23 @@ function checkRuleText(rule, label, bad) {
|
|
|
59
61
|
if (rule.scope !== undefined && rule.scope !== 'runtime') bad.push(`${label}.scope is absent or runtime`);
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/** The dated owner cases of a rule: each {id case-N, date, source owner, ruling, quote?}, ids unique (an owner ruling recorded where the law lives). */
|
|
65
|
+
function checkRuleCases(rule, label, bad) {
|
|
66
|
+
if (rule.cases === undefined) return;
|
|
67
|
+
if (!Array.isArray(rule.cases) || !rule.cases.length) { bad.push(`${label}.cases must be a non-empty list`); return; }
|
|
68
|
+
const ids = new Set();
|
|
69
|
+
rule.cases.forEach((item, index) => {
|
|
70
|
+
const at = `${label}.cases[${index}]`;
|
|
71
|
+
if (!isPlainObject(item)) { bad.push(`${at} is not a map`); return; }
|
|
72
|
+
for (const key of Object.keys(item)) if (!CASE_KEYS.has(key)) bad.push(`${at} has unknown key ${key}`);
|
|
73
|
+
if (typeof item.id !== 'string' || !/^case-[1-9][0-9]*$/.test(item.id) || ids.has(item.id)) bad.push(`${at}.id must be a unique case-N`);
|
|
74
|
+
ids.add(item.id);
|
|
75
|
+
if (typeof item.date !== 'string' || !CASE_DATE.test(item.date)) bad.push(`${at}.date must be YYYY-MM-DD`);
|
|
76
|
+
if (item.source !== 'owner') bad.push(`${at}.source must be owner`);
|
|
77
|
+
for (const key of ['ruling', ...(item.quote === undefined ? [] : ['quote'])]) if (typeof item[key] !== 'string' || !item[key].trim()) bad.push(`${at}.${key} must be text`);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
62
81
|
function checkRuleGates(label, gates, bad) {
|
|
63
82
|
if (!gates.length) return;
|
|
64
83
|
if (!gates.includes('land')) bad.push(`${label} must run at the land gate (every rule does)`);
|
|
@@ -120,6 +139,7 @@ function checkRule(rule, index, catalog, codeOwner, bad) {
|
|
|
120
139
|
}
|
|
121
140
|
checkRuleIdentity(rule, index, catalog, at, label, bad);
|
|
122
141
|
checkRuleText(rule, label, bad);
|
|
142
|
+
checkRuleCases(rule, label, bad);
|
|
123
143
|
enumListProblems(rule, label, 'kinds', RULE_KINDS, bad);
|
|
124
144
|
bad.push(...ruleEditionProblems(rule, label));
|
|
125
145
|
const gates = enumListProblems(rule, label, 'gates', RULE_GATES, bad);
|
|
@@ -12,13 +12,12 @@ import { found, readJson } from './read.mjs';
|
|
|
12
12
|
export const FE_NO_TESTS = 'FE_NO_TESTS';
|
|
13
13
|
const SPEC_FILE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$/;
|
|
14
14
|
const TEST_DIRECTORY = /(?:^|\/)(?:e2e|__tests__|__mocks__|test-support)\//;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export const TEST_SCRIPT_NAME = /^(?:pre|post)?test(?::|$)/;
|
|
15
|
+
const TEST_TOOL_FILE = /(?:^|\/)(?:(?:vitest|jest|playwright)\.[^/]+|cypress\.config\.[^/]+|tsconfig\.e2e\.json|e2e\.ya?ml)$/;
|
|
16
|
+
const TEST_SCRIPT_NAME = /^(?:pre|post)?test(?::|$)/;
|
|
18
17
|
const TEST_RUNNER_COMMAND_SOURCE = String.raw`(?:^|[\s&|;(])(?:npx\s+)?(?:vitest|jest|playwright|cypress|mocha)(?=$|[\s&|;)])|\bnode\s+(?:--\S+\s+)*--test\b`;
|
|
19
|
-
|
|
18
|
+
const TEST_RUNNER_COMMAND = new RegExp(TEST_RUNNER_COMMAND_SOURCE);
|
|
20
19
|
const TEST_DEPENDENCY_SOURCE = String.raw`^(?:vitest|@vitest\/.+|playwright|playwright-core|@playwright\/.+|jest|@jest\/.+|ts-jest|babel-jest|@types\/jest|jest-[\w-]+|mocha|@types\/mocha|cypress|@testing-library\/.+|jsdom|@types\/jsdom|happy-dom|axe-core|@axe-core\/.+|vitest-axe|@starci\/(?:vitest|jest|playwright)-preset)$`;
|
|
21
|
-
|
|
20
|
+
const TEST_DEPENDENCY = new RegExp(TEST_DEPENDENCY_SOURCE);
|
|
22
21
|
export const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
|
23
22
|
|
|
24
23
|
/** True for a path FE_NO_TESTS owns: a spec file, a test directory or a test-tool file. */
|
package/served-locale.mjs
CHANGED
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
* unconditionally satisfies both and is wrong. That case is a review question and LOCALE-2 is where
|
|
19
19
|
* it is argued; pretending otherwise would be the rule claiming a guarantee it does not have.
|
|
20
20
|
*
|
|
21
|
-
* WHY THE FIRST RULE KEYS ON THE TERMINAL LINK RATHER THAN ON
|
|
22
|
-
*
|
|
23
|
-
* or fire on every helper beside it. The chain is identifiable by what it does: it builds the link
|
|
21
|
+
* WHY THE FIRST RULE KEYS ON THE TERMINAL LINK RATHER THAN ON A FILE NAME. The clients folder (`modules/api/graphql/clients/`,
|
|
22
|
+
* slot `fe.transport.client`) holds the clients, their options and every link, and a file-name pattern that guessed which of
|
|
23
|
+
* them assembles the chain would either miss the real chain or fire on every helper beside it. The chain is identifiable by what it does: it builds the link
|
|
24
24
|
* that talks to the network.
|
|
25
25
|
*
|
|
26
26
|
* WITH ONE EXEMPTION THAT WAS FOUND BY RUNNING IT, NOT BY THINKING ABOUT IT. "Constructs the
|
|
@@ -32,20 +32,17 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
35
|
-
import { fileOf,
|
|
36
|
-
|
|
37
|
-
/** The slots that hold transport: the app's `modules/api` and the shared api package. Neither slot names its links, see the note on `linkFileName`. */
|
|
38
|
-
const TRANSPORT_SLOTS = ["fe.modules.api", "fe.package.api"]
|
|
35
|
+
import { fileOf, isApiLayer, stem } from "./lib/scope.mjs"
|
|
39
36
|
|
|
40
37
|
/**
|
|
41
|
-
* The name of the file when it sits directly in a `links/` folder of
|
|
38
|
+
* The name of the file when it sits directly in a `links/` folder of the API layer, else null.
|
|
42
39
|
*
|
|
43
|
-
* NO SLOT NAMES A LINK. `fe.
|
|
44
|
-
*
|
|
40
|
+
* NO SLOT NAMES A LINK. The clients slots (`fe.transport.client`, `fe.package.api.client`) own their whole folder, so the API
|
|
41
|
+
* layer decides WHERE a link may be and the folder called `links` is the one fact left to the rule; the manifest would
|
|
45
42
|
* carry it as a role (`locale-link: links/locale.ts`) and this function would ask for that role instead.
|
|
46
43
|
*/
|
|
47
44
|
const linkFileName = (context) => {
|
|
48
|
-
if (!
|
|
45
|
+
if (!isApiLayer(context)) return null
|
|
49
46
|
const parts = hfsOf(context).relative(fileOf(context)).split("/")
|
|
50
47
|
return parts.at(-2) === "links" ? parts.at(-1) : null
|
|
51
48
|
}
|
|
@@ -76,7 +73,7 @@ const isLocaleLinkFile = (context) => {
|
|
|
76
73
|
*/
|
|
77
74
|
const isLinkImplementationFile = (context) => linkFileName(context) !== null
|
|
78
75
|
|
|
79
|
-
/** The header a request declares its language in. */
|
|
76
|
+
/** The header a request declares its language in (HTTP header names are case-insensitive: `X-Locale` is the same header). */
|
|
80
77
|
export const LOCALE_HEADER = "x-locale"
|
|
81
78
|
|
|
82
79
|
/** Callee names that construct the terminal link - the one that actually reaches the network. */
|
|
@@ -162,7 +159,7 @@ export const rules = {
|
|
|
162
159
|
if (isLocaleLinkFile(context)) return {}
|
|
163
160
|
return {
|
|
164
161
|
Property(node) {
|
|
165
|
-
if (propertyKeyOf(node) === LOCALE_HEADER) {
|
|
162
|
+
if (propertyKeyOf(node)?.toLowerCase() === LOCALE_HEADER) {
|
|
166
163
|
context.report({ node, messageId: "elsewhere" })
|
|
167
164
|
}
|
|
168
165
|
},
|
package/the-split.mjs
CHANGED
|
@@ -27,11 +27,14 @@ const connectedBlock = (context) => {
|
|
|
27
27
|
/**
|
|
28
28
|
* Calls that reach for the world instead of receiving it.
|
|
29
29
|
*
|
|
30
|
-
*
|
|
30
|
+
* Six families of the front-end stack: a request (SWR, `useQuery<X>Swr` / `useMutate<X>Swr` and the REST
|
|
31
|
+
* `use{Get,Post,Put,Patch,Delete}<X>Swr` of `hooks/swr`, the Apollo hooks, an operation of `modules/api` called directly), a
|
|
32
|
+
* form (react-hook-form and the `use<X>Form` hooks of `hooks/rhf`), a store (redux, the zustand `use<X>Store`), a socket
|
|
33
|
+
* (`use<X>SocketIo` of `hooks/socketio`), a toast (`use<X>WithToast` of `modules/toast`) and the translation runtime. Each is a
|
|
31
34
|
* dependency the drawing half cannot be rendered from a fixture without.
|
|
32
35
|
*/
|
|
33
36
|
const REACHES_FOR_THE_WORLD =
|
|
34
|
-
/^(?:useSWR|useSWRMutation|use[A-Za-z0-9]*Swr|useAppSelector|useDispatch|use[A-Za-z0-9]*Store|useTranslations|useLocale|useFormatter|query[A-Z][A-Za-z0-9]*|mutation[A-Z][A-Za-z0-9]*)$/
|
|
37
|
+
/^(?:useSWR|useSWRMutation|useSWRConfig|use[A-Za-z0-9]*Swr|useQuery|useLazyQuery|useMutation|useSubscription|useApolloClient|useForm|useFormContext|useController|useWatch|use[A-Za-z0-9]*Form|useAppSelector|useAppDispatch|useSelector|useDispatch|use[A-Za-z0-9]*Store|use[A-Za-z0-9]*SocketIo|use[A-Za-z0-9]*WithToast|useTranslations|useLocale|useFormatter|query[A-Z][A-Za-z0-9]*|mutation[A-Z][A-Za-z0-9]*)$/
|
|
35
38
|
|
|
36
39
|
// -- SPLIT-1 ---------------------------------------------------------------------------------------
|
|
37
40
|
|
package/transport.mjs
CHANGED
|
@@ -2,41 +2,54 @@
|
|
|
2
2
|
* The rules that hold `transport.md` (HFS R50 `FE_TRANSPORT_OWNER`, R51 `FE_HTTP_STATUS_COLLAPSE`,
|
|
3
3
|
* R52 `FE_WIRE_GENERATED`).
|
|
4
4
|
*
|
|
5
|
-
* ONE WIRE, ONE VOCABULARY. A
|
|
6
|
-
* `fe.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
5
|
+
* ONE WIRE, ONE VOCABULARY. A front end talks to its back end only through its API layer, `modules/api` (HFS slot
|
|
6
|
+
* `fe.modules.api`, or the shared api package `fe.package.api`): the auth and no-auth Apollo clients with their link chain are
|
|
7
|
+
* the transport owner (slot `fe.transport.client`: `modules/api/graphql/clients/`, or `fe.package.api.client`), one file per
|
|
8
|
+
* GraphQL operation runs on them, and the few REST calls (uploads, presigned URLs, auth endpoints) are axios calls in
|
|
9
|
+
* `modules/api/rest/`. The layer answers in exactly one vocabulary, `GraphQLResponse` (slot `fe.transport.outcome`:
|
|
10
|
+
* `modules/api/graphql/types.ts`, or `fe.package.api.outcome`). The defects the three halves prevent were measured on real
|
|
11
|
+
* apps: seven transports and six result shapes in one repository, and in another a client that folded every non-2xx into
|
|
12
|
+
* "could not read", so the state a real backend produces for a signed-out reader - 401, which must become `refused` and
|
|
13
|
+
* send the reader to sign in - was unreachable, while a test double hid it by answering 200.
|
|
13
14
|
*
|
|
14
15
|
* WHAT THESE RULES CANNOT SEE. They read one file at a time, so they cannot tell that a client's
|
|
15
16
|
* timeout is long enough or that a `refused` outcome is routed to the sign-in page - the module's
|
|
16
|
-
* spec proves that. They hold the shapes that make it provable:
|
|
17
|
-
*
|
|
17
|
+
* spec proves that. They hold the shapes that make it provable: the HTTP libraries and `fetch` only in the API layer, an
|
|
18
|
+
* abort signal on every `fetch`, no shared mutable state, a 401/403 branch where a raw `fetch` is answered, and no null
|
|
19
|
+
* standing in for a status.
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
22
|
import ts from "typescript"
|
|
21
23
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
22
|
-
import {
|
|
24
|
+
import { forbiddingConcern, packageNameOf } from "./lib/fe-stack.mjs"
|
|
25
|
+
import { globalReferences, inSlot, isApiClient, isApiLayer, isApiRest, isOutcomeModule } from "./lib/scope.mjs"
|
|
23
26
|
import { typed } from "./lib/types.mjs"
|
|
24
27
|
|
|
25
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* The canonical transport libraries and the one place each may be imported (fe-stack graphql, rest and realtime): Apollo in
|
|
30
|
+
* the API layer, axios in its REST calls, socket.io-client in `hooks/socketio`. Matched on the package a specifier names.
|
|
31
|
+
*/
|
|
32
|
+
const PLACED_TRANSPORTS = new Map([
|
|
33
|
+
["@apollo/client", { where: "the API layer (`modules/api`)", allowed: (context) => isApiLayer(context) }],
|
|
34
|
+
["axios", { where: "the REST calls of the API layer (`modules/api/rest/`)", allowed: (context) => isApiRest(context) }],
|
|
35
|
+
["socket.io-client", { where: "`hooks/socketio/`", allowed: (context) => inSlot(context, "fe.hooks.socketio") }],
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Packages that send an HTTP request or open a channel of their own and are no canonical transport: a second transport is a
|
|
40
|
+
* second owner. A package the fe-stack catalog forbids is refused by `fe-stack-import`, so it is not reported twice here.
|
|
41
|
+
*/
|
|
26
42
|
const TRANSPORT_PACKAGES = new Set([
|
|
27
|
-
"
|
|
43
|
+
"ky", "ky-universal", "got", "node-fetch", "undici", "cross-fetch", "isomorphic-fetch", "superagent", "ofetch",
|
|
28
44
|
"whatwg-fetch", "graphql-request", "urql", "next-urql", "apollo-client",
|
|
29
45
|
])
|
|
30
46
|
|
|
31
|
-
/** Package scopes and prefixes whose every package is a GraphQL/HTTP client of its own (`@
|
|
47
|
+
/** Package scopes and prefixes whose every package is a GraphQL/HTTP client of its own (`@urql/core`, `apollo-link-http`); `@apollo/client` is placed, the rest of `@apollo/` is not a front-end transport. */
|
|
32
48
|
const TRANSPORT_SCOPES = ["@apollo/", "@urql/", "apollo-"]
|
|
33
49
|
|
|
34
|
-
/** True when a
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
const name = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]
|
|
38
|
-
return TRANSPORT_PACKAGES.has(name) || TRANSPORT_SCOPES.some((prefix) => name.startsWith(prefix))
|
|
39
|
-
}
|
|
50
|
+
/** True when a package name is an HTTP client library no canonical slot owns. */
|
|
51
|
+
const isStrayTransport = (name) =>
|
|
52
|
+
!PLACED_TRANSPORTS.has(name) && (TRANSPORT_PACKAGES.has(name) || TRANSPORT_SCOPES.some((prefix) => name.startsWith(prefix)))
|
|
40
53
|
|
|
41
54
|
/** The global objects that carry the same globals as the bare names. */
|
|
42
55
|
const GLOBAL_OBJECTS = new Set(["globalThis", "window", "self", "global"])
|
|
@@ -95,38 +108,45 @@ const transportUses = (context) => {
|
|
|
95
108
|
// -- FE-TRANSPORT-1 --------------------------------------------------------------------------------
|
|
96
109
|
|
|
97
110
|
/**
|
|
98
|
-
* `fetch` is reached in
|
|
111
|
+
* `fetch` is reached only in the API layer, each canonical transport library only in its home, and no other HTTP library exists.
|
|
99
112
|
*
|
|
100
|
-
* The
|
|
101
|
-
*
|
|
102
|
-
*
|
|
113
|
+
* The API layer is every file of the slots `fe.modules.api`, `fe.transport.client`, `fe.transport.outcome` (an app's
|
|
114
|
+
* `modules/api/`) and `fe.package.api*` (the shared api package): the Apollo clients and their links, the operations and the
|
|
115
|
+
* REST calls. `@apollo/client` is imported there and nowhere else, `axios` only by its REST calls (`modules/api/rest/`),
|
|
116
|
+
* `socket.io-client` only by `hooks/socketio/`. Every other file reaches the back end through an SWR hook over an operation.
|
|
103
117
|
*/
|
|
104
118
|
export const fetchOnlyInApiClient = {
|
|
105
119
|
meta: {
|
|
106
120
|
type: "problem",
|
|
107
|
-
docs: { description: "
|
|
121
|
+
docs: { description: "`fetch` (and `Request`, `EventSource`, `sendBeacon`) lives in the API layer (`modules/api`); Apollo, axios and socket.io-client are imported only in their homes; no other HTTP library exists." },
|
|
108
122
|
schema: [],
|
|
109
123
|
messages: {
|
|
110
124
|
outside:
|
|
111
|
-
"`fetch` outside the
|
|
125
|
+
"`fetch` outside the API layer (`modules/api`, or the shared api package). The front end reaches its back end through one layer: the Apollo clients in `modules/api/graphql/clients/` carry every GraphQL operation and the axios calls in `modules/api/rest/` the few REST ones; a second path is a second place that must remember the timeout, the credential and the status mapping, and it will forget one. Call an operation of `modules/api` through its SWR hook (`hooks/swr/`).",
|
|
112
126
|
channel:
|
|
113
|
-
"`{{name}}` opens a connection outside the
|
|
127
|
+
"`{{name}}` opens a connection outside the API layer. `Request`, `EventSource` and `navigator.sendBeacon` are transports of their own; the API layer (`modules/api`) owns every request, and realtime is the socket.io client of `hooks/socketio/`. Call an operation of `modules/api` through its SWR hook.",
|
|
114
128
|
library:
|
|
115
|
-
"`{{name}}` is a second HTTP transport. The
|
|
116
|
-
|
|
129
|
+
"`{{name}}` is a second HTTP transport. The front end talks to its back end through Apollo (`modules/api/graphql`) and the axios REST calls (`modules/api/rest`); another library is another set of timeout, retry and error rules. Use an operation of `modules/api`.",
|
|
130
|
+
placed:
|
|
131
|
+
"`{{name}}` is imported outside {{where}}, the one place the front-end stack gives it. A component or hook that holds the transport itself skips the link chain (token, locale, retry, timeout) and the one result vocabulary. Call an operation of `modules/api` through its SWR hook (`hooks/swr/`), or listen through `hooks/socketio/`.",
|
|
132
|
+
xhr: "`XMLHttpRequest` is a second transport. Use an operation of the API layer (`modules/api`).",
|
|
117
133
|
},
|
|
118
134
|
},
|
|
119
135
|
create(context) {
|
|
120
|
-
const
|
|
121
|
-
const client = isApiClient(context)
|
|
136
|
+
const layer = isApiLayer(context)
|
|
122
137
|
const library = (node, source) => {
|
|
123
|
-
|
|
138
|
+
const name = packageNameOf(source)
|
|
139
|
+
if (name === null || forbiddingConcern(source)) return
|
|
140
|
+
const placed = PLACED_TRANSPORTS.get(name)
|
|
141
|
+
if (placed) {
|
|
142
|
+
if (!placed.allowed(context)) context.report({ node, messageId: "placed", data: { name, where: placed.where } })
|
|
143
|
+
} else if (isStrayTransport(name)) context.report({ node, messageId: "library", data: { name: source } })
|
|
124
144
|
}
|
|
125
145
|
return {
|
|
126
146
|
"Program:exit"() {
|
|
127
147
|
for (const use of transportUses(context)) {
|
|
128
148
|
if (use.kind === "xhr") context.report({ node: use.node, messageId: "xhr" })
|
|
129
|
-
else if (
|
|
149
|
+
else if (layer) continue
|
|
130
150
|
else if (use.kind === "fetch") context.report({ node: use.node, messageId: "outside" })
|
|
131
151
|
else context.report({ node: use.node, messageId: "channel", data: { name: use.name } })
|
|
132
152
|
}
|
|
@@ -154,11 +174,11 @@ export const fetchOnlyInApiClient = {
|
|
|
154
174
|
|
|
155
175
|
// -- FE-TRANSPORT-2 --------------------------------------------------------------------------------
|
|
156
176
|
|
|
157
|
-
/**
|
|
177
|
+
/** Every `fetch` of the API layer (a custom fetch handed to the Apollo http link, a raw call) carries an abort signal, so a hung backend cannot hang a reader. */
|
|
158
178
|
export const clientFetchHasSignal = {
|
|
159
179
|
meta: {
|
|
160
180
|
type: "problem",
|
|
161
|
-
docs: { description: "Every `fetch` in `modules/api
|
|
181
|
+
docs: { description: "Every `fetch` in the API layer (`modules/api`) passes an AbortSignal (timeout or caller)." },
|
|
162
182
|
schema: [],
|
|
163
183
|
messages: {
|
|
164
184
|
signal:
|
|
@@ -166,7 +186,7 @@ export const clientFetchHasSignal = {
|
|
|
166
186
|
},
|
|
167
187
|
},
|
|
168
188
|
create(context) {
|
|
169
|
-
if (!
|
|
189
|
+
if (!isApiLayer(context)) return {}
|
|
170
190
|
return {
|
|
171
191
|
"Program:exit"() {
|
|
172
192
|
// The global `fetch` however it is reached; a request built as `new Request(url, init)` carries its signal in `init`.
|
|
@@ -191,22 +211,11 @@ export const clientFetchHasSignal = {
|
|
|
191
211
|
},
|
|
192
212
|
}
|
|
193
213
|
|
|
194
|
-
/** The slots that make up the API layer: the app's `modules/api` and the shared api package. */
|
|
195
|
-
const API_LAYER_SLOTS = ["fe.modules.api", "fe.transport.client", "fe.transport.outcome", "fe.package.api", "fe.package.api.client", "fe.package.api.outcome"]
|
|
196
|
-
|
|
197
214
|
/**
|
|
198
|
-
* True for a file of the API layer, except the
|
|
215
|
+
* True for a file of the API layer, except the generated wire types (`graphql/__generated__/`: types generated from the be
|
|
199
216
|
* contract snapshots are data, not code the wire law governs).
|
|
200
217
|
*/
|
|
201
|
-
const isTransportFile = (context) =>
|
|
202
|
-
const slotId = slotOfFile(context)
|
|
203
|
-
if (!API_LAYER_SLOTS.includes(slotId)) return false
|
|
204
|
-
const hfs = hfsOf(context)
|
|
205
|
-
const dataFolders = (hfs.slot(slotId).allows ?? []).filter((entry) => entry.endsWith("/")).map((entry) => entry.slice(0, -1))
|
|
206
|
-
const { root } = hfs.classify(context.filename || context.getFilename())
|
|
207
|
-
const below = hfs.relative(context.filename || context.getFilename()).slice(root.length + 1).split("/")
|
|
208
|
-
return !dataFolders.includes(below[0])
|
|
209
|
-
}
|
|
218
|
+
const isTransportFile = (context) => isApiLayer(context) && !hfsOf(context).relative(context.filename || context.getFilename()).split("/").includes("__generated__")
|
|
210
219
|
|
|
211
220
|
// -- FE-TRANSPORT-3 --------------------------------------------------------------------------------
|
|
212
221
|
|
|
@@ -214,7 +223,7 @@ const isTransportFile = (context) => {
|
|
|
214
223
|
export const noSharedTransportState = {
|
|
215
224
|
meta: {
|
|
216
225
|
type: "problem",
|
|
217
|
-
docs: { description: "`modules/api/**` holds no module-level `let`/`var`." },
|
|
226
|
+
docs: { description: "The API layer (`modules/api/**`) holds no module-level `let`/`var`." },
|
|
218
227
|
schema: [],
|
|
219
228
|
messages: {
|
|
220
229
|
shared:
|
|
@@ -222,7 +231,6 @@ export const noSharedTransportState = {
|
|
|
222
231
|
},
|
|
223
232
|
},
|
|
224
233
|
create(context) {
|
|
225
|
-
const filename = context.filename || context.getFilename()
|
|
226
234
|
if (!isTransportFile(context)) return {}
|
|
227
235
|
const check = (node) => {
|
|
228
236
|
const declaration = node.type === "VariableDeclaration" ? node : node.declaration
|
|
@@ -378,9 +386,11 @@ const buildsRefused = (node) => {
|
|
|
378
386
|
}
|
|
379
387
|
|
|
380
388
|
/**
|
|
381
|
-
*
|
|
389
|
+
* A raw `fetch` answered in the transport owner maps 401 and 403 to `refused`, so "sign in" is a state a reader can reach.
|
|
382
390
|
*
|
|
383
|
-
*
|
|
391
|
+
* The Apollo clients reach the network through the http link and map an unauthenticated answer in the error link; the rule
|
|
392
|
+
* speaks only when a file of the clients folder (`fe.transport.client`, `fe.package.api.client`) calls `fetch` itself. It judges
|
|
393
|
+
* a real branch in that file: a comparison of the fetch response's status (typed as a `Response`) with 401 and with
|
|
384
394
|
* 403 (`===`, `||`, a literal list, a `switch` with both cases), whose consequent builds an object with `kind: "refused"`. The three
|
|
385
395
|
* literals lying anywhere in the file do not pass. A mapping delegated to a function in another file is not visible here and does
|
|
386
396
|
* not pass either: the client is the one file that owns the status meaning.
|
|
@@ -388,7 +398,7 @@ const buildsRefused = (node) => {
|
|
|
388
398
|
export const clientMapsAuthToRefused = {
|
|
389
399
|
meta: {
|
|
390
400
|
type: "problem",
|
|
391
|
-
docs: { description: "
|
|
401
|
+
docs: { description: "A file of the Apollo clients folder that calls `fetch` itself branches on the response status 401 and 403 and builds a `refused` outcome there." },
|
|
392
402
|
schema: [],
|
|
393
403
|
messages: {
|
|
394
404
|
refused:
|
|
@@ -561,7 +571,10 @@ const constInit = (scope, identifier) => {
|
|
|
561
571
|
return null
|
|
562
572
|
}
|
|
563
573
|
|
|
564
|
-
/**
|
|
574
|
+
/**
|
|
575
|
+
* Wire types are generated from the contract; nobody types the wire by hand, and a GraphQL document is written only in the API
|
|
576
|
+
* layer (one file per operation, `modules/api/graphql/{queries,mutations}/`), where codegen reads it.
|
|
577
|
+
*/
|
|
565
578
|
export const noHandTypedWire = {
|
|
566
579
|
meta: {
|
|
567
580
|
type: "problem",
|
|
@@ -571,12 +584,12 @@ export const noHandTypedWire = {
|
|
|
571
584
|
cast:
|
|
572
585
|
"A response body is typed by hand here. An assertion or annotation is a claim nobody checks: when the backend changes the shape this still compiles and fails at a reader's screen. Narrow the body from `unknown` (`(await response.json()) as unknown`, then a check), or use the type generated from the be contract snapshot (`be/contracts/`, imported from `__generated__/`), and validate at the client.",
|
|
573
586
|
document:
|
|
574
|
-
"A GraphQL document written
|
|
587
|
+
"A GraphQL document written outside the API layer. Each operation has one file in `modules/api/graphql/queries/` or `modules/api/graphql/mutations/` (`query-<x>.ts`, `mutation-<x>.ts`), where codegen generates its types and the contract check reads it; a component or hook calls that operation through its SWR hook.",
|
|
575
588
|
},
|
|
576
589
|
},
|
|
577
590
|
create(context) {
|
|
578
|
-
const filename = context.filename || context.getFilename()
|
|
579
591
|
const source = context.sourceCode ?? context.getSourceCode()
|
|
592
|
+
const documents = !isApiLayer(context)
|
|
580
593
|
/** Local names of the type imports that resolve into a `__generated__/` directory. */
|
|
581
594
|
const generated = new Set()
|
|
582
595
|
/** True when a type node is `unknown` or names a type imported from the generated wire module. */
|
|
@@ -612,17 +625,17 @@ export const noHandTypedWire = {
|
|
|
612
625
|
if (annotation && node.init && readsResponse(node.init) && !sanctioned(annotation)) context.report({ node, messageId: "cast" })
|
|
613
626
|
},
|
|
614
627
|
TaggedTemplateExpression(node) {
|
|
615
|
-
if (node.tag.type === "Identifier" && /^(?:gql|graphql)$/.test(node.tag.name)) {
|
|
628
|
+
if (documents && node.tag.type === "Identifier" && /^(?:gql|graphql)$/.test(node.tag.name)) {
|
|
616
629
|
context.report({ node, messageId: "document" })
|
|
617
630
|
}
|
|
618
631
|
},
|
|
619
632
|
TemplateLiteral(node) {
|
|
620
|
-
if (node.parent && node.parent.type === "TaggedTemplateExpression") return
|
|
633
|
+
if (!documents || (node.parent && node.parent.type === "TaggedTemplateExpression")) return
|
|
621
634
|
const text = node.quasis[0] && node.quasis[0].value.cooked
|
|
622
635
|
if (typeof text === "string" && GRAPHQL_TEXT.test(text)) context.report({ node, messageId: "document" })
|
|
623
636
|
},
|
|
624
637
|
Literal(node) {
|
|
625
|
-
if (typeof node.value === "string" && GRAPHQL_TEXT.test(node.value) && node.value.includes("}")) {
|
|
638
|
+
if (documents && typeof node.value === "string" && GRAPHQL_TEXT.test(node.value) && node.value.includes("}")) {
|
|
626
639
|
context.report({ node, messageId: "document" })
|
|
627
640
|
}
|
|
628
641
|
},
|
|
@@ -678,10 +691,11 @@ const literalsOf = (checker, type, name, location) => {
|
|
|
678
691
|
}
|
|
679
692
|
|
|
680
693
|
/**
|
|
681
|
-
* A result
|
|
694
|
+
* A result vocabulary is declared once per repository: `GraphQLResponse` in the file of the slot the manifest marks `outcomeHome`
|
|
695
|
+
* (`fe.transport.outcome`: `modules/api/graphql/types.ts`, or `fe.package.api.outcome`: the api package's `src/graphql/types.ts`).
|
|
682
696
|
*
|
|
683
697
|
* A union type alias is a result union when its members are object types that ALL carry the same literal-typed discriminant, in the
|
|
684
|
-
* result vocabulary: `ok` (both `true` and `false` among the members) or `kind` (a member of kind `"ok"` beside at least one failure
|
|
698
|
+
* result vocabulary: `success` or `ok` (both `true` and `false` among the members) or `kind` (a member of kind `"ok"` beside at least one failure
|
|
685
699
|
* kind of the Outcome vocabulary: refused, forbidden, invalid, not-found, unavailable). A view union that a mapper derives from an
|
|
686
700
|
* Outcome (`{ kind: "ready" } | { kind: "not-found" }`, no `ok` arm) is a screen state, not a second result vocabulary. The check reads the resolved members through the checker, so
|
|
687
701
|
* `Ok<T> | Failure` and an intersection are seen as what they are, and no name (`*Outcome`, `*Result`) decides anything.
|
|
@@ -692,11 +706,11 @@ const literalsOf = (checker, type, name, location) => {
|
|
|
692
706
|
export const oneOutcomeUnion = {
|
|
693
707
|
meta: {
|
|
694
708
|
type: "problem",
|
|
695
|
-
docs: { description: "A result union (`ok` / `kind` discriminant) is declared only in the outcome slot; elsewhere the code composes `
|
|
709
|
+
docs: { description: "A result union (`success` / `ok` / `kind` discriminant) is declared only in the outcome slot (`modules/api/graphql/types.ts`); elsewhere the code composes `GraphQLResponse<T>`." },
|
|
696
710
|
schema: [],
|
|
697
711
|
messages: {
|
|
698
712
|
second:
|
|
699
|
-
"`{{name}}` is a second result union ({{discriminant}}). The repository has one vocabulary, `
|
|
713
|
+
"`{{name}}` is a second result union ({{discriminant}}). The repository has one result vocabulary, `GraphQLResponse<T>`, declared in its one outcome home (`modules/api/graphql/types.ts`, or the api package's `src/graphql/types.ts`); a bespoke union here is a result shape the Apollo clients, the toast (`showGraphQLToast`) and the SWR hooks know nothing about. Compose `GraphQLResponse<T>` (the domain detail goes in its `data`) instead of declaring another.",
|
|
700
714
|
},
|
|
701
715
|
},
|
|
702
716
|
create(context) {
|
|
@@ -713,13 +727,13 @@ export const oneOutcomeUnion = {
|
|
|
713
727
|
if (!type.isUnion?.() || type.types.length < 2) return
|
|
714
728
|
// The members must all be object types (an object, or an intersection of them).
|
|
715
729
|
if (!type.types.every((member) => (member.flags & (ts.TypeFlags.Object | ts.TypeFlags.Intersection)) !== 0)) return
|
|
716
|
-
for (const discriminant of ["ok", "kind"]) {
|
|
730
|
+
for (const discriminant of ["success", "ok", "kind"]) {
|
|
717
731
|
const values = type.types.map((member) => literalsOf(checker, member, discriminant, tsNode))
|
|
718
732
|
if (values.some((entry) => entry === null)) continue
|
|
719
733
|
const flat = values.flat()
|
|
720
|
-
// `ok` is a discriminant only when each member pins it to one value; `{ ok: boolean }` on every member is a flag, not a tag.
|
|
734
|
+
// `success` / `ok` is a discriminant only when each member pins it to one value; `{ ok: boolean }` on every member is a flag, not a tag.
|
|
721
735
|
const isResult =
|
|
722
|
-
discriminant
|
|
736
|
+
discriminant !== "kind"
|
|
723
737
|
? values.every((entry) => entry.length === 1) && flat.includes(true) && flat.includes(false)
|
|
724
738
|
: flat.includes("ok") && flat.some((value) => value !== "ok" && RESULT_KINDS.has(value))
|
|
725
739
|
if (isResult) {
|