@starci/eslint-canon-fe 8.2.14 → 8.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +1 -0
  3. package/client-boundary.mjs +66 -23
  4. package/docs/client-boundary.md +9 -9
  5. package/docs/hooks-folder.md +16 -4
  6. package/docs/hygiene.md +2 -2
  7. package/docs/transport.md +26 -26
  8. package/docs/type-safety.md +6 -6
  9. package/fe-stack.mjs +86 -0
  10. package/formatting.mjs +12 -6
  11. package/hooks-folder.mjs +73 -31
  12. package/hygiene.mjs +4 -4
  13. package/icon.mjs +26 -15
  14. package/index.mjs +2 -0
  15. package/lib/fe-stack.mjs +73 -0
  16. package/lib/scope.mjs +23 -2
  17. package/lib/why.mjs +23 -13
  18. package/next-conventions.mjs +4 -3
  19. package/package.json +1 -1
  20. package/runtime/knowledge/hfs/canon-pins.yaml +200 -8
  21. package/runtime/knowledge/hfs/fe-stack.yaml +221 -0
  22. package/runtime/knowledge/hfs/slots.yaml +307 -179
  23. package/runtime/knowledge/patterns/fe/folder.yaml +24 -18
  24. package/runtime/knowledge/sonar-gate.yaml +10 -2
  25. package/runtime/modules/kernel/removed-vocabulary.yaml +43 -0
  26. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +0 -2
  27. package/runtime/scripts/hfs/architecture/config.mjs +2 -1
  28. package/runtime/scripts/hfs/architecture/dead-exports.mjs +6 -2
  29. package/runtime/scripts/hfs/architecture/error-masked.mjs +10 -1
  30. package/runtime/scripts/hfs/architecture/frontend.mjs +10 -3
  31. package/runtime/scripts/hfs/architecture/hfs.mjs +6 -3
  32. package/runtime/scripts/hfs/architecture/index.mjs +2 -0
  33. package/runtime/scripts/hfs/architecture/owners.mjs +19 -16
  34. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +4 -1
  35. package/runtime/scripts/hfs/architecture/server-action.mjs +75 -20
  36. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +2 -2
  37. package/runtime/scripts/hfs/architecture/supabase.mjs +36 -210
  38. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -33
  39. package/runtime/scripts/hfs/architecture/transport-owner.mjs +110 -54
  40. package/runtime/scripts/hfs/architecture/type-context.mjs +4 -1
  41. package/runtime/scripts/hfs/edition-slots.mjs +13 -4
  42. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  43. package/runtime/scripts/hfs/rule-catalog.mjs +21 -1
  44. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +4 -5
  45. package/served-locale.mjs +10 -13
  46. package/the-split.mjs +5 -2
  47. package/transport.mjs +81 -67
  48. package/runtime/scripts/hfs/architecture/supabase-results.mjs +0 -182
@@ -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', 'anonymousActions'];
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
- export const TEST_TOOL_FILE = /(?:^|\/)(?:(?:vitest|jest|playwright)\.[^/]+|cypress\.config\.[^/]+|tsconfig\.e2e\.json|e2e\.ya?ml)$/;
16
- // The edition rule (rules/edition.mjs, L01) applies the same lists to the whole app tree in lite: they are the test vocabulary.
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
- export const TEST_RUNNER_COMMAND = new RegExp(TEST_RUNNER_COMMAND_SOURCE);
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
- export const TEST_DEPENDENCY = new RegExp(TEST_DEPENDENCY_SOURCE);
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 THE SLOT. A repository may name
22
- * its client folder anything below `modules/api`, and a pattern that guessed wrong would either miss the real chain
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, inSlot, stem } from "./lib/scope.mjs"
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 a transport slot, else null.
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.modules.api` and `fe.package.api` own their whole tree, so the transport slots decide WHERE a
44
- * link may be (a transport file) and the folder called `links` is the one fact left to the rule; the manifest would
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 (!inSlot(context, ...TRANSPORT_SLOTS)) return null
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
- * Four families: a request, a store, the translation runtime, and a direct query. Each is a
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 repository talks to its backend through exactly one module, the api client (HFS slot
6
- * `fe.transport.client`: `modules/api/client.ts`, or `fe.package.api.client`: the shared api package's `src/client.ts`),
7
- * and that module answers in exactly one vocabulary, `Outcome<T>` (slot `fe.transport.outcome` / `fe.package.api.outcome`). The
8
- * defects the three halves prevent were measured on real apps: seven transports and six result
9
- * shapes in one repository, and in another a client that folded every non-2xx into "could not
10
- * read", so the state a real backend produces for a signed-out reader - 401, which must become
11
- * `refused` and send the reader to sign in - was unreachable, while a test double hid it by
12
- * answering 200.
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: one `fetch`, an abort signal on
17
- * it, no shared mutable state, a 401/403 branch, and no null standing in for a status.
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 { globalReferences, isApiClient, isOutcomeModule, slotOfFile } from "./lib/scope.mjs"
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
- /** Packages that send an HTTP request: a second transport is a second owner. Matched on the module specifier, never on a file name. */
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
- "axios", "ky", "ky-universal", "got", "node-fetch", "undici", "cross-fetch", "isomorphic-fetch", "superagent", "ofetch",
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 (`@apollo/client`, `@urql/core`, `apollo-link-http`). */
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 module specifier resolves to an HTTP client library. */
35
- const isTransportLibrary = (specifier) => {
36
- const parts = String(specifier).split("/")
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 one file per repository, and no other HTTP library exists.
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 one file is the one whose HFS slot is `fe.transport.client` (`apps/<app>/src/modules/api/client.ts`, a one-app repository)
101
- * or `fe.package.api.client` (`packages/<family>-api/src/client.ts`, the shared client of a multi-app repository). Every other
102
- * file, including another file of the same package, is a finding.
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: "The repository's single `fetch` (and `Request`, `EventSource`, `sendBeacon`) lives in the api client slot." },
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 api client (`modules/api/client.ts`, or the shared api package's `src/client.ts`). The repository has exactly one transport; a second one is a second place that must remember the timeout, the credential and the status mapping, and it will forget one. Call the client and take its `Outcome`.",
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 api client. `Request`, `EventSource` and `navigator.sendBeacon` are transports of their own; the repository's one client owns every request. Call the client and take its `Outcome`.",
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 repository's one client (`modules/api/client.ts` or the api package's `src/client.ts`) is built on `fetch`; another library is another set of timeout, retry and error rules. Use the client.",
116
- xhr: "`XMLHttpRequest` is a second transport. Use the repository's api client.",
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 filename = context.filename || context.getFilename()
121
- const client = isApiClient(context)
136
+ const layer = isApiLayer(context)
122
137
  const library = (node, source) => {
123
- if (typeof source === "string" && isTransportLibrary(source)) context.report({ node, messageId: "library", data: { name: source } })
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 (client) continue
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
- /** The client's `fetch` carries an abort signal, so a hung backend cannot hang a reader. */
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/client.ts` passes an AbortSignal (timeout or caller)." },
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 (!isApiClient(context)) return {}
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 data folders its slot allows (`__generated__/`: the types generated from the be
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
- * The client maps 401 and 403 to `refused`, so "sign in" is a state a reader can reach.
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
- * It judges a real branch in the client file: a comparison of the fetch response's status (typed as a `Response`) with 401 and with
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: "The api client branches on the response status 401 and 403 and builds a `refused` outcome there." },
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
- /** Wire types are generated from the contract; nobody types the wire by hand. */
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 inline in TypeScript. Documents live in `.graphql` files so codegen can generate their types and the contract check can read them.",
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 union is declared once per repository: `Outcome<T>` in the file of the slot the manifest marks `outcomeHome` (`fe.transport.outcome`, `fe.package.api.outcome`, or `fe.modules.db.outcome` in a lite app).
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 `Outcome<T>`." },
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, `Outcome<T>`, declared in the one Outcome home of the repository (`modules/api/outcome.ts`, the api package's `src/outcome.ts`, or `modules/db/outcome.ts` in a lite app); a bespoke union here is a result shape the client, the status mapping and `outcome-kinds-exhaustive` know nothing about. Compose `Outcome<T>` (add the domain detail as its second parameter) instead of declaring another.",
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 === "ok"
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) {