@starci/eslint-canon-fe 9.2.0 → 9.4.0

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 (178) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +5 -1
  3. package/dist/brand-values.d.ts +28 -0
  4. package/dist/brand-values.js +172 -0
  5. package/dist/button-intent.d.ts +11 -0
  6. package/dist/button-intent.js +361 -0
  7. package/dist/callback-shape.d.ts +7 -0
  8. package/dist/callback-shape.js +396 -0
  9. package/dist/class-names.d.ts +19 -0
  10. package/dist/class-names.js +170 -0
  11. package/dist/client-boundary.d.ts +42 -0
  12. package/dist/client-boundary.js +404 -0
  13. package/dist/comments.d.ts +47 -0
  14. package/dist/comments.js +156 -0
  15. package/dist/control-height.d.ts +7 -0
  16. package/dist/control-height.js +83 -0
  17. package/dist/drawing.d.ts +31 -0
  18. package/dist/drawing.js +376 -0
  19. package/dist/env-owner.d.ts +22 -0
  20. package/dist/env-owner.js +291 -0
  21. package/dist/fe-stack.d.ts +10 -0
  22. package/dist/fe-stack.js +73 -0
  23. package/dist/file-layout.d.ts +71 -0
  24. package/dist/file-layout.js +465 -0
  25. package/dist/formatting.d.ts +25 -0
  26. package/dist/formatting.js +104 -0
  27. package/dist/function-budget.d.ts +9 -0
  28. package/dist/function-budget.js +48 -0
  29. package/dist/grammar-shim.d.ts +5 -0
  30. package/dist/grammar-shim.js +126 -0
  31. package/dist/hooks-folder.d.ts +34 -0
  32. package/dist/hooks-folder.js +320 -0
  33. package/dist/hygiene.d.ts +39 -0
  34. package/dist/hygiene.js +540 -0
  35. package/dist/index.d.ts +105 -0
  36. package/dist/index.js +310 -0
  37. package/{landmark.mjs → dist/landmark.d.ts} +4 -6
  38. package/dist/landmark.js +6 -0
  39. package/dist/lib/ast.d.ts +36 -0
  40. package/dist/lib/ast.js +94 -0
  41. package/dist/lib/bindings.d.ts +84 -0
  42. package/dist/lib/bindings.js +168 -0
  43. package/dist/lib/class-text.d.ts +3 -0
  44. package/dist/lib/class-text.js +30 -0
  45. package/dist/lib/config.d.ts +81 -0
  46. package/dist/lib/config.js +141 -0
  47. package/dist/lib/drawing-scope.d.ts +5 -0
  48. package/{lib/drawing-scope.mjs → dist/lib/drawing-scope.js} +6 -11
  49. package/dist/lib/eslint-boundary.d.ts +23 -0
  50. package/dist/lib/eslint-boundary.js +6 -0
  51. package/dist/lib/fe-stack.d.ts +49 -0
  52. package/dist/lib/fe-stack.js +98 -0
  53. package/dist/lib/hfs-view.d.ts +74 -0
  54. package/dist/lib/hfs-view.js +6 -0
  55. package/dist/lib/hfs.d.ts +34 -0
  56. package/dist/lib/hfs.js +72 -0
  57. package/dist/lib/next.d.ts +4 -0
  58. package/dist/lib/next.js +10 -0
  59. package/dist/lib/own-hook-origin.d.ts +17 -0
  60. package/dist/lib/own-hook-origin.js +52 -0
  61. package/dist/lib/package-root.d.ts +4 -0
  62. package/dist/lib/package-root.js +10 -0
  63. package/dist/lib/path.d.ts +2 -0
  64. package/{lib/path.mjs → dist/lib/path.js} +1 -1
  65. package/dist/lib/platform.d.ts +26 -0
  66. package/{lib/platform.mjs → dist/lib/platform.js} +38 -35
  67. package/dist/lib/program-for.d.ts +5 -0
  68. package/dist/lib/program-for.js +23 -0
  69. package/dist/lib/pure-owner-helper.d.ts +10 -0
  70. package/dist/lib/pure-owner-helper.js +156 -0
  71. package/dist/lib/react.d.ts +16 -0
  72. package/dist/lib/react.js +22 -0
  73. package/dist/lib/redux-hook-origin.d.ts +4 -0
  74. package/dist/lib/redux-hook-origin.js +50 -0
  75. package/dist/lib/rule.d.ts +28 -0
  76. package/dist/lib/rule.js +5 -0
  77. package/dist/lib/scope.d.ts +88 -0
  78. package/{lib/scope.mjs → dist/lib/scope.js} +83 -93
  79. package/dist/lib/typed-class-values.d.ts +13 -0
  80. package/dist/lib/typed-class-values.js +92 -0
  81. package/dist/lib/types.d.ts +44 -0
  82. package/dist/lib/types.js +64 -0
  83. package/dist/lib/why.d.ts +350 -0
  84. package/dist/lib/why.js +350 -0
  85. package/dist/lint-escape-hatch.d.ts +24 -0
  86. package/{lint-escape-hatch.mjs → dist/lint-escape-hatch.js} +38 -45
  87. package/dist/lists.d.ts +31 -0
  88. package/dist/lists.js +202 -0
  89. package/dist/loading.d.ts +34 -0
  90. package/dist/loading.js +157 -0
  91. package/dist/naming.d.ts +33 -0
  92. package/dist/naming.js +176 -0
  93. package/dist/native-controls.d.ts +24 -0
  94. package/dist/native-controls.js +105 -0
  95. package/dist/next-conventions.d.ts +48 -0
  96. package/dist/next-conventions.js +452 -0
  97. package/dist/project-graph.d.ts +5 -0
  98. package/{project-graph.mjs → dist/project-graph.js} +8 -12
  99. package/dist/props-and-slots.d.ts +19 -0
  100. package/dist/props-and-slots.js +320 -0
  101. package/dist/served-locale.d.ts +38 -0
  102. package/dist/served-locale.js +263 -0
  103. package/dist/shape-slot.d.ts +18 -0
  104. package/dist/shape-slot.js +324 -0
  105. package/dist/size-and-state-budget.d.ts +46 -0
  106. package/dist/size-and-state-budget.js +424 -0
  107. package/dist/size-growth.d.ts +24 -0
  108. package/dist/size-growth.js +64 -0
  109. package/dist/sonar-parity.d.ts +5 -0
  110. package/{sonar-parity.mjs → dist/sonar-parity.js} +11 -7
  111. package/dist/surface-rules.d.ts +18 -0
  112. package/dist/surface-rules.js +491 -0
  113. package/dist/swr-family.d.ts +15 -0
  114. package/dist/swr-family.js +419 -0
  115. package/dist/the-split.d.ts +27 -0
  116. package/dist/the-split.js +157 -0
  117. package/dist/tokens.d.ts +9 -0
  118. package/dist/tokens.js +35 -0
  119. package/dist/translation.d.ts +51 -0
  120. package/dist/translation.js +403 -0
  121. package/dist/transport.d.ts +73 -0
  122. package/dist/transport.js +885 -0
  123. package/dist/type-safety.d.ts +43 -0
  124. package/dist/type-safety.js +139 -0
  125. package/dist/ui-rulings.d.ts +11 -0
  126. package/dist/ui-rulings.js +121 -0
  127. package/package.json +31 -9
  128. package/runtime/knowledge/hfs/canon-pins.yaml +22 -14
  129. package/runtime/scripts/lib/package-at.mjs +5 -5
  130. package/brand-values.mjs +0 -172
  131. package/button-intent.mjs +0 -320
  132. package/callback-shape.mjs +0 -332
  133. package/class-names.mjs +0 -149
  134. package/client-boundary.mjs +0 -400
  135. package/comments.mjs +0 -159
  136. package/control-height.mjs +0 -77
  137. package/drawing.mjs +0 -315
  138. package/env-owner.mjs +0 -244
  139. package/fe-stack.mjs +0 -86
  140. package/file-layout.mjs +0 -472
  141. package/formatting.mjs +0 -110
  142. package/function-budget.mjs +0 -49
  143. package/grammar-shim.mjs +0 -123
  144. package/hooks-folder.mjs +0 -300
  145. package/hygiene.mjs +0 -513
  146. package/index.mjs +0 -317
  147. package/lib/ast.mjs +0 -97
  148. package/lib/bindings.mjs +0 -159
  149. package/lib/class-text.mjs +0 -29
  150. package/lib/config.mjs +0 -142
  151. package/lib/fe-stack.mjs +0 -77
  152. package/lib/hfs.mjs +0 -49
  153. package/lib/next.mjs +0 -6
  154. package/lib/own-hook-origin.mjs +0 -53
  155. package/lib/program-for.mjs +0 -22
  156. package/lib/pure-owner-helper.mjs +0 -148
  157. package/lib/react.mjs +0 -28
  158. package/lib/redux-hook-origin.mjs +0 -44
  159. package/lib/typed-class-values.mjs +0 -81
  160. package/lib/types.mjs +0 -63
  161. package/lib/why.mjs +0 -352
  162. package/lists.mjs +0 -193
  163. package/loading.mjs +0 -156
  164. package/naming.mjs +0 -185
  165. package/native-controls.mjs +0 -108
  166. package/next-conventions.mjs +0 -447
  167. package/props-and-slots.mjs +0 -276
  168. package/served-locale.mjs +0 -243
  169. package/size-and-state-budget.mjs +0 -376
  170. package/size-growth.mjs +0 -62
  171. package/surface-rules.mjs +0 -443
  172. package/swr-family.mjs +0 -326
  173. package/the-split.mjs +0 -155
  174. package/tokens.mjs +0 -39
  175. package/translation.mjs +0 -392
  176. package/transport.mjs +0 -830
  177. package/type-safety.mjs +0 -147
  178. package/ui-rulings.mjs +0 -134
package/transport.mjs DELETED
@@ -1,830 +0,0 @@
1
- /**
2
- * The rules that hold `transport.md` (HFS R50 `FE_TRANSPORT_OWNER`, R51 `FE_HTTP_STATUS_COLLAPSE`,
3
- * R52 `FE_WIRE_GENERATED`).
4
- *
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.
14
- *
15
- * WHAT THESE RULES CANNOT SEE. They read one file at a time, so they cannot tell that a client's
16
- * timeout is long enough or that a `refused` outcome is routed to the sign-in page - the module's
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.
20
- */
21
-
22
- import ts from "typescript"
23
- import { rules as callbackShapeRules } from "./callback-shape.mjs"
24
- import { hfsOf } from "./lib/hfs.mjs"
25
- import { forbiddingConcern, packageNameOf } from "./lib/fe-stack.mjs"
26
- import { globalReferences, inSlot, isApiClient, isApiLayer, isApiRest, isOutcomeModule } from "./lib/scope.mjs"
27
- import { typed } from "./lib/types.mjs"
28
-
29
- /**
30
- * The canonical transport libraries and the one place each may be imported (fe-stack graphql, rest and realtime): Apollo in
31
- * the API layer, axios in its REST calls, graphql-ws in the Apollo clients folder, socket.io-client in `hooks/socketio`. Matched on the package a specifier names.
32
- */
33
- const PLACED_TRANSPORTS = new Map([
34
- ["@apollo/client", { where: "the API layer (`modules/api`)", allowed: (context) => isApiLayer(context) }],
35
- ["axios", { where: "the REST calls of the API layer (`modules/api/rest/`)", allowed: (context) => isApiRest(context) }],
36
- ["socket.io-client", { where: "`hooks/socketio/`", allowed: (context) => inSlot(context, "fe.hooks.socketio") }],
37
- ["graphql-ws", { where: "the Apollo clients of the API layer (`modules/api/graphql/clients/`, or the shared api package's `graphql/clients/`)", allowed: (context) => isApiClient(context) }],
38
- ])
39
-
40
- /**
41
- * Packages that send an HTTP request or open a channel of their own and are no canonical transport: a second transport is a
42
- * second owner. A package the fe-stack catalog forbids is refused by `fe-stack-import`, so it is not reported twice here.
43
- */
44
- const TRANSPORT_PACKAGES = new Set([
45
- "ky", "ky-universal", "got", "node-fetch", "undici", "cross-fetch", "isomorphic-fetch", "superagent", "ofetch",
46
- "whatwg-fetch", "graphql-request", "urql", "next-urql", "apollo-client",
47
- ])
48
-
49
- /** 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. */
50
- const TRANSPORT_SCOPES = ["@apollo/", "@urql/", "apollo-"]
51
-
52
- /** True when a package name is an HTTP client library no canonical slot owns. */
53
- const isStrayTransport = (name) =>
54
- !PLACED_TRANSPORTS.has(name) && (TRANSPORT_PACKAGES.has(name) || TRANSPORT_SCOPES.some((prefix) => name.startsWith(prefix)))
55
-
56
- /** The global objects that carry the same globals as the bare names. */
57
- const GLOBAL_OBJECTS = new Set(["globalThis", "window", "self", "global"])
58
-
59
- /** The bare globals that open a connection. */
60
- const TRANSPORT_GLOBALS = new Set(["fetch", "Request", "EventSource", "WebSocket", "XMLHttpRequest", "navigator"])
61
-
62
- /** The static name of a member's property, else null. */
63
- const propertyName = (member) => {
64
- if (!member.computed) return member.property.type === "Identifier" ? member.property.name : null
65
- return member.property.type === "Literal" && typeof member.property.value === "string" ? member.property.value : null
66
- }
67
-
68
- /** The call of a value used directly as a callee, else null. */
69
- const calledBy = (node) => (node.parent?.type === "CallExpression" && node.parent.callee === node ? node.parent : null)
70
-
71
- /** True when a value is used directly as a constructor. */
72
- const constructedBy = (node) => node.parent?.type === "NewExpression" && node.parent.callee === node
73
-
74
- /** A use of one global transport, or null when the value does not open a connection. */
75
- const transportUse = (node, name) => {
76
- if (name === "fetch") return { node, kind: "fetch", name, call: calledBy(node) }
77
- // A reference may save the constructor for a later call. Native realtime has no FE owner, even in the API layer.
78
- if (name === "WebSocket") return { node, kind: "websocket", name, call: null }
79
- if ((name === "Request" || name === "EventSource") && constructedBy(node)) return { node, kind: "channel", name, call: null }
80
- if (name === "XMLHttpRequest" && constructedBy(node)) return { node, kind: "xhr", name, call: null }
81
- if (name !== "navigator") return null
82
- const parent = node.parent
83
- if (parent?.type !== "MemberExpression" || parent.object !== node || propertyName(parent) !== "sendBeacon") return null
84
- return { node: parent, kind: "channel", name: "navigator.sendBeacon", call: calledBy(parent) }
85
- }
86
-
87
- /** References obtained by destructuring a global object; aliasing keeps the same transport ownership. */
88
- const destructuredTransports = (pattern) => {
89
- const uses = []
90
- for (const property of pattern.properties) {
91
- if (property.type !== "Property") continue
92
- let name = null
93
- if (!property.computed && property.key.type === "Identifier") name = property.key.name
94
- else if (property.key.type === "Literal") name = property.key.value
95
- if (name !== "fetch" && name !== "WebSocket") continue
96
- if (name === "fetch" && (property.computed || property.key.type !== "Identifier")) continue
97
- uses.push(transportUse(property, name))
98
- }
99
- return uses
100
- }
101
-
102
- /** References reached through a global object, including static element access and destructuring. */
103
- const globalObjectTransports = (id) => {
104
- const parent = id.parent
105
- if (parent?.type === "MemberExpression" && parent.object === id) {
106
- const use = transportUse(parent, propertyName(parent))
107
- return use ? [use] : []
108
- }
109
- if (parent?.type === "VariableDeclarator" && parent.init === id && parent.id.type === "ObjectPattern") {
110
- return destructuredTransports(parent.id)
111
- }
112
- return []
113
- }
114
-
115
- /**
116
- * Every use of a global transport in the file, found by scope resolution (an imported, declared or parameter `fetch` is not
117
- * one): `fetch` however it is reached (`fetch(...)`, `globalThis.fetch`, `window.fetch`, `const f = fetch`,
118
- * `const { fetch } = globalThis`), `new Request`, `new EventSource`, `navigator.sendBeacon`, `new XMLHttpRequest`,
119
- * and a native `WebSocket` constructor reached directly or saved under another name.
120
- *
121
- * @param {object} context - The ESLint rule context.
122
- * @returns {Array<{ node: object, kind: "fetch" | "channel" | "websocket" | "xhr", name: string, call: object | null }>} The uses; `call` is the call of `fetch` when it is called there.
123
- */
124
- const transportUses = (context) => {
125
- const uses = []
126
- for (const id of globalReferences(context, new Set([...TRANSPORT_GLOBALS, ...GLOBAL_OBJECTS]))) {
127
- // `typeof fetch` names the type of the function; it does not call it.
128
- if (id.parent?.type === "TSTypeQuery") continue
129
- if (GLOBAL_OBJECTS.has(id.name)) uses.push(...globalObjectTransports(id))
130
- else {
131
- const use = transportUse(id, id.name)
132
- if (use) uses.push(use)
133
- }
134
- }
135
- return uses
136
- }
137
-
138
- // -- FE-TRANSPORT-1 --------------------------------------------------------------------------------
139
-
140
- /**
141
- * `fetch` is reached only in the API layer, each canonical transport library only in its home, and no other HTTP library exists.
142
- *
143
- * The API layer is every file of the slots `fe.modules.api`, `fe.transport.client`, `fe.transport.outcome` (an app's
144
- * `modules/api/`) and `fe.package.api*` (the shared api package): the Apollo clients and their links, the operations and the
145
- * REST calls. `@apollo/client` is imported there and nowhere else, `axios` only by its REST calls (`modules/api/rest/`),
146
- * `socket.io-client` only by `hooks/socketio/`. Native `WebSocket` is refused everywhere, including the API layer.
147
- * Every other file reaches the back end through an SWR hook over an operation.
148
- */
149
- export const fetchOnlyInApiClient = {
150
- meta: {
151
- type: "problem",
152
- docs: { description: "`fetch` (and `Request`, `EventSource`, `sendBeacon`) lives in the API layer (`modules/api`); native WebSocket is refused; Apollo, axios and socket.io-client are imported only in their homes; no other HTTP library exists." },
153
- schema: [],
154
- messages: {
155
- outside:
156
- "`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/`).",
157
- channel:
158
- "`{{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.",
159
- library:
160
- "`{{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`.",
161
- placed:
162
- "`{{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/`.",
163
- xhr: "`XMLHttpRequest` is a second transport. Use an operation of the API layer (`modules/api`).",
164
- websocket: "Native `WebSocket` is a second realtime transport. Use socket.io-client in `hooks/socketio/`, the front end's realtime owner.",
165
- },
166
- },
167
- create(context) {
168
- const layer = isApiLayer(context)
169
- const library = (node, source) => {
170
- const name = packageNameOf(source)
171
- if (name === null || forbiddingConcern(source)) return
172
- const placed = PLACED_TRANSPORTS.get(name)
173
- if (placed) {
174
- if (!placed.allowed(context)) context.report({ node, messageId: "placed", data: { name, where: placed.where } })
175
- } else if (isStrayTransport(name)) context.report({ node, messageId: "library", data: { name: source } })
176
- }
177
- return {
178
- "Program:exit"() {
179
- for (const use of transportUses(context)) {
180
- if (use.kind === "xhr") context.report({ node: use.node, messageId: "xhr" })
181
- else if (use.kind === "websocket") context.report({ node: use.node, messageId: "websocket" })
182
- else if (layer) continue
183
- else if (use.kind === "fetch") context.report({ node: use.node, messageId: "outside" })
184
- else context.report({ node: use.node, messageId: "channel", data: { name: use.name } })
185
- }
186
- },
187
- ImportDeclaration(node) {
188
- if (node.importKind !== "type") library(node, node.source.value)
189
- },
190
- ExportNamedDeclaration(node) {
191
- if (node.source && node.exportKind !== "type") library(node, node.source.value)
192
- },
193
- ExportAllDeclaration(node) {
194
- if (node.exportKind !== "type") library(node, node.source.value)
195
- },
196
- ImportExpression(node) {
197
- if (node.source.type === "Literal") library(node, node.source.value)
198
- },
199
- CallExpression(node) {
200
- // `require("axios")` where `require` is the CommonJS global.
201
- if (node.callee.type !== "Identifier" || node.callee.name !== "require" || node.arguments[0]?.type !== "Literal") return
202
- if (globalReferences(context, new Set(["require"])).includes(node.callee)) library(node, node.arguments[0].value)
203
- },
204
- }
205
- },
206
- }
207
-
208
- // -- FE-TRANSPORT-2 --------------------------------------------------------------------------------
209
-
210
- /** 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. */
211
- export const clientFetchHasSignal = {
212
- meta: {
213
- type: "problem",
214
- docs: { description: "Every `fetch` in the API layer (`modules/api`) passes an AbortSignal (timeout or caller)." },
215
- schema: [],
216
- messages: {
217
- signal:
218
- "This `fetch` carries no `signal`. A request with no abort signal waits as long as the backend does, and the reader waits with it. Pass `signal` (an `AbortSignal.timeout(...)` combined with the caller's).",
219
- },
220
- },
221
- create(context) {
222
- if (!isApiLayer(context)) return {}
223
- return {
224
- "Program:exit"() {
225
- // The global `fetch` however it is reached; a request built as `new Request(url, init)` carries its signal in `init`.
226
- for (const { call } of transportUses(context)) {
227
- if (!call) continue
228
- const options = call.arguments[1]
229
- if (!options) {
230
- context.report({ node: call, messageId: "signal" })
231
- continue
232
- }
233
- // A spread or a computed value may carry it; only a literal object that plainly lacks it is wrong.
234
- if (options.type !== "ObjectExpression") continue
235
- const carries = options.properties.some(
236
- (property) =>
237
- property.type === "SpreadElement" ||
238
- (property.type === "Property" && property.key.type === "Identifier" && property.key.name === "signal"),
239
- )
240
- if (!carries) context.report({ node: call, messageId: "signal" })
241
- }
242
- },
243
- }
244
- },
245
- }
246
-
247
- /**
248
- * True for a file of the API layer, except the generated wire types (`graphql/__generated__/`: types generated from the be
249
- * contract snapshots are data, not code the wire law governs).
250
- */
251
- const isTransportFile = (context) => isApiLayer(context) && !hfsOf(context).relative(context.filename || context.getFilename()).split("/").includes("__generated__")
252
-
253
- // -- FE-TRANSPORT-3 --------------------------------------------------------------------------------
254
-
255
- /** No module-level mutable state in the API layer: a token or locale in a `let` leaks across requests. */
256
- export const noSharedTransportState = {
257
- meta: {
258
- type: "problem",
259
- docs: { description: "The API layer (`modules/api/**`) holds no module-level `let`/`var`." },
260
- schema: [],
261
- messages: {
262
- shared:
263
- "A module-level `{{kind}}` in the API layer. A token or locale kept there is shared by every request on the server and by every reader in one tab, so one reader's credential can answer another's request. Pass the credential as a parameter or read it from context.",
264
- },
265
- },
266
- create(context) {
267
- if (!isTransportFile(context)) return {}
268
- const check = (node) => {
269
- const declaration = node.type === "VariableDeclaration" ? node : node.declaration
270
- if (!declaration || declaration.type !== "VariableDeclaration" || declaration.kind === "const") return
271
- context.report({ node: declaration, messageId: "shared", data: { kind: declaration.kind } })
272
- }
273
- return {
274
- "Program > VariableDeclaration": check,
275
- "Program > ExportNamedDeclaration": check,
276
- }
277
- },
278
- }
279
-
280
- // -- FE-TRANSPORT-4 --------------------------------------------------------------------------------
281
-
282
- /** Removes the wrappers that do not change what an expression is. */
283
- const unwrap = (node) => {
284
- let current = node
285
- while (
286
- current &&
287
- (current.type === "TSAsExpression" || current.type === "TSNonNullExpression" || current.type === "TSSatisfiesExpression" || current.type === "ChainExpression")
288
- ) {
289
- current = current.expression
290
- }
291
- return current
292
- }
293
-
294
- /** The variable a name binds to, walking up from the scope of a node. */
295
- const variableOf = (context, identifier) => {
296
- let scope = (context.sourceCode ?? context.getSourceCode()).getScope(identifier)
297
- while (scope) {
298
- const variable = scope.set.get(identifier.name)
299
- if (variable) return variable
300
- scope = scope.upper
301
- }
302
- return null
303
- }
304
-
305
- /** True when the type of an expression is a fetch `Response`: it has `ok`, `status` and `headers`. */
306
- const isResponseLike = (context, node) => {
307
- const { checker, toTs } = typed(context)
308
- const tsNode = toTs(node)
309
- if (!tsNode) return false
310
- const type = checker.getTypeAtLocation(tsNode)
311
- const parts = type.isUnion?.() ? type.types : [type]
312
- return parts.some((part) => ["ok", "status", "headers"].every((name) => part.getProperty?.(name)))
313
- }
314
-
315
- /**
316
- * True when an expression is the HTTP status of a fetch response: `response.status` where the object's type is a Response, or a
317
- * local bound to one (`const status = response.status`, `const { status } = response`). A parameter that is only typed `number` is
318
- * not: the rule does not guess what a bare number means.
319
- */
320
- /**
321
- * A parameter stands for the response status when its function is called in this file, and every call passes a response status in
322
- * that position: `const failureForResponse = (status: number) => ...` called as `failureForResponse(response.status)`.
323
- */
324
- const functionBinding = (fn) => {
325
- if (fn.type === "FunctionDeclaration") return fn.id
326
- if (fn.parent?.type === "VariableDeclarator" && fn.parent.init === fn) return fn.parent.id
327
- return null
328
- }
329
-
330
- const isStatusParameter = (context, definition, depth) => {
331
- const fn = definition.node
332
- const index = fn.params?.findIndex((param) => param === definition.name || (param.type === "AssignmentPattern" && param.left === definition.name))
333
- if (index === undefined || index < 0) return false
334
- const binding = functionBinding(fn)
335
- if (!binding || binding.type !== "Identifier") return false
336
- const variable = variableOf(context, binding)
337
- const calls = (variable?.references ?? []).map((reference) => reference.identifier.parent).filter((parent) => parent?.type === "CallExpression" && parent.callee.type === "Identifier" && parent.callee.name === binding.name)
338
- return calls.length > 0 && calls.every((call) => call.arguments[index] !== undefined && isResponseStatus(context, call.arguments[index], depth + 1))
339
- }
340
-
341
- /** The static name or value of an object property key. */
342
- const keyValue = (key) => {
343
- if (key?.type === "Identifier") return key.name
344
- if (key?.type === "Literal") return key.value
345
- return null
346
- }
347
-
348
- const isResponseStatus = (context, node, depth = 0) => {
349
- const expression = unwrap(node)
350
- if (!expression || depth > 4) return false
351
- if (expression.type === "MemberExpression") return propertyName(expression) === "status" && isResponseLike(context, unwrap(expression.object))
352
- if (expression.type !== "Identifier") return false
353
- const definition = variableOf(context, expression)?.defs[0]
354
- if (definition?.type === "Parameter") return isStatusParameter(context, definition, depth)
355
- if (definition?.type !== "Variable") return false
356
- const declarator = definition.node
357
- if (declarator.id === definition.name) return declarator.init ? isResponseStatus(context, declarator.init, depth + 1) : false
358
- if (declarator.id.type !== "ObjectPattern" || !declarator.init) return false
359
- const bound = declarator.id.properties.find(
360
- (property) => property.type === "Property" && (property.value === definition.name || (property.value.type === "AssignmentPattern" && property.value.left === definition.name)),
361
- )
362
- const keyName = keyValue(bound?.key)
363
- return keyName === "status" && isResponseLike(context, unwrap(declarator.init.type === "AwaitExpression" ? declarator.init.argument : declarator.init))
364
- }
365
-
366
- /** The numbers a literal list stands for (`[401, 403]`, `new Set([401, 403])`, or a const bound to one), else null. */
367
- const numbersOf = (context, node, depth = 0) => {
368
- const expression = unwrap(node)
369
- if (!expression || depth > 3) return null
370
- if (expression.type === "ArrayExpression") {
371
- const values = expression.elements.map((element) => (element && element.type === "Literal" && typeof element.value === "number" ? element.value : null))
372
- return values.includes(null) ? null : new Set(values)
373
- }
374
- if (expression.type === "NewExpression" && expression.callee.type === "Identifier" && expression.callee.name === "Set" && expression.arguments.length === 1) {
375
- return numbersOf(context, expression.arguments[0], depth + 1)
376
- }
377
- if (expression.type === "Identifier") {
378
- const declarator = variableOf(context, expression)?.defs[0]?.node
379
- return declarator?.type === "VariableDeclarator" && declarator.init ? numbersOf(context, declarator.init, depth + 1) : null
380
- }
381
- return null
382
- }
383
-
384
- /**
385
- * The status codes a test is true for, when it is a plain status comparison: `status === 401`, `a || b` of such, or a literal list
386
- * containing the status (`[401, 403].includes(status)`, `new Set([401, 403]).has(status)`). Null for anything else.
387
- */
388
- const comparedCodes = (context, expression) => {
389
- for (const [side, other] of [[expression.left, expression.right], [expression.right, expression.left]]) {
390
- const literal = unwrap(other)
391
- if (literal?.type === "Literal" && typeof literal.value === "number" && isResponseStatus(context, side)) return new Set([literal.value])
392
- }
393
- return null
394
- }
395
-
396
- const includedCodes = (context, expression) => {
397
- if (expression.callee.type !== "MemberExpression" || expression.arguments.length !== 1) return null
398
- const method = propertyName(expression.callee)
399
- if ((method === "includes" || method === "has") && isResponseStatus(context, expression.arguments[0])) return numbersOf(context, expression.callee.object)
400
- return null
401
- }
402
-
403
- const codesOf = (context, test) => {
404
- const expression = unwrap(test)
405
- if (!expression) return null
406
- if (expression.type === "LogicalExpression" && expression.operator === "||") {
407
- const left = codesOf(context, expression.left)
408
- const right = codesOf(context, expression.right)
409
- return left && right ? new Set([...left, ...right]) : null
410
- }
411
- if (expression.type === "BinaryExpression" && (expression.operator === "===" || expression.operator === "==")) {
412
- return comparedCodes(context, expression)
413
- }
414
- if (expression.type === "CallExpression") return includedCodes(context, expression)
415
- return null
416
- }
417
-
418
- /** A single node that spells the refused kind in an outcome property or constructor call. */
419
- const namesRefused = (node) => {
420
- if (node.type === "Property" && !node.computed) {
421
- const value = unwrap(node.value)
422
- return keyValue(node.key) === "kind" && value?.type === "Literal" && value.value === "refused"
423
- }
424
- return node.type === "CallExpression" && node.arguments[0]?.type === "Literal" && node.arguments[0].value === "refused"
425
- }
426
-
427
- /** True when a subtree builds an outcome of kind `"refused"`: an object whose `kind` is that literal, or a call whose first argument is it. */
428
- const buildsRefused = (node) => {
429
- let found = false
430
- const visit = (current) => {
431
- if (found || !current || typeof current.type !== "string") return
432
- // A constructor of the Outcome vocabulary named by its kind: `failed("refused", { ... })`.
433
- if (namesRefused(current)) found = true
434
- for (const [name, child] of Object.entries(current)) {
435
- if (name === "parent") continue
436
- if (Array.isArray(child)) child.forEach(visit)
437
- else if (typeof child?.type === "string") visit(child)
438
- }
439
- }
440
- visit(node)
441
- return found
442
- }
443
-
444
- /**
445
- * A raw `fetch` answered in the transport owner maps 401 and 403 to `refused`, so "sign in" is a state a reader can reach.
446
- *
447
- * The Apollo clients reach the network through the http link and map an unauthenticated answer in the error link; the rule
448
- * speaks only when a file of the clients folder (`fe.transport.client`, `fe.package.api.client`) calls `fetch` itself. It judges
449
- * a real branch in that file: a comparison of the fetch response's status (typed as a `Response`) with 401 and with
450
- * 403 (`===`, `||`, a literal list, a `switch` with both cases), whose consequent builds an object with `kind: "refused"`. The three
451
- * literals lying anywhere in the file do not pass. A mapping delegated to a function in another file is not visible here and does
452
- * not pass either: the client is the one file that owns the status meaning.
453
- */
454
- export const clientMapsAuthToRefused = {
455
- meta: {
456
- type: "problem",
457
- 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." },
458
- schema: [],
459
- messages: {
460
- refused:
461
- "This client calls `fetch` but has no branch that turns a 401 and a 403 status of the response into an outcome of kind `refused`. Without that branch a signed-out reader is reported as \"could not load\" and never reaches the sign-in state. Compare `response.status` with 401 and 403 and return `{ ok: false, kind: \"refused\" }` there.",
462
- },
463
- },
464
- create(context) {
465
- if (!isApiClient(context)) return {}
466
- const covered = new Set()
467
- const cover = (test, consequent) => {
468
- const codes = codesOf(context, test)
469
- if (codes && buildsRefused(consequent)) codes.forEach((code) => covered.add(code))
470
- }
471
- return {
472
- IfStatement: (node) => cover(node.test, node.consequent),
473
- ConditionalExpression: (node) => cover(node.test, node.consequent),
474
- SwitchStatement(node) {
475
- if (!isResponseStatus(context, node.discriminant)) return
476
- let pending = new Set()
477
- for (const entry of node.cases) {
478
- const label = unwrap(entry.test)
479
- if (label?.type === "Literal" && typeof label.value === "number") pending.add(label.value)
480
- if (entry.consequent.length === 0) continue
481
- if (entry.consequent.some(buildsRefused)) pending.forEach((code) => covered.add(code))
482
- pending = new Set()
483
- }
484
- },
485
- "Program:exit"(program) {
486
- const fetches = transportUses(context).some((use) => use.kind === "fetch")
487
- if (fetches && !(covered.has(401) && covered.has(403))) context.report({ node: program, messageId: "refused" })
488
- },
489
- }
490
- },
491
- }
492
-
493
- // -- FE-STATUS-1 -----------------------------------------------------------------------------------
494
-
495
- /** The object of `x.ok`, else null. */
496
- const okSubject = (node) => {
497
- if (!node || node.type !== "MemberExpression" || node.computed || node.property.name !== "ok") return null
498
- return node.object
499
- }
500
-
501
- /** True when a value carries nothing: `null`, `undefined`, `void 0`, nothing, `[]`, `{}`, `false`. */
502
- const isEmptyValue = (node) => {
503
- if (!node) return true
504
- if (node.type === "Literal") return node.value === null || node.value === false
505
- if (node.type === "Identifier") return node.name === "undefined"
506
- if (node.type === "UnaryExpression") return node.operator === "void"
507
- if (node.type === "ArrayExpression") return node.elements.length === 0
508
- if (node.type === "ObjectExpression") return node.properties.length === 0
509
- return false
510
- }
511
-
512
- /**
513
- * The response a "not ok" test is about, else null.
514
- *
515
- * `!res.ok`, `res.ok === false`, `res.ok !== true` and, since a status compare is the same collapse
516
- * spelled with a number, `res.status !== 200`, `res.status >= 400` or `res.status === 404`.
517
- */
518
- const booleanFailureSubject = (side, other, operator) => {
519
- const subject = okSubject(side)
520
- if (!subject || other.type !== "Literal") return null
521
- if ((operator === "===" || operator === "==") && other.value === false) return subject
522
- if ((operator === "!==" || operator === "!=") && other.value === true) return subject
523
- return null
524
- }
525
-
526
- const statusFailureSubject = (side, other) => {
527
- const isStatus = side.type === "MemberExpression" && !side.computed && side.property.name === "status"
528
- return isStatus && other.type === "Literal" && typeof other.value === "number" ? side.object : null
529
- }
530
-
531
- const failureSubject = (test) => {
532
- if (test.type === "UnaryExpression" && test.operator === "!") return okSubject(test.argument)
533
- if (test.type !== "BinaryExpression") return null
534
- for (const [side, other] of [
535
- [test.left, test.right],
536
- [test.right, test.left],
537
- ]) {
538
- const subject = booleanFailureSubject(side, other, test.operator)
539
- if (subject) return subject
540
- if (side === test.left) {
541
- const status = statusFailureSubject(side, other)
542
- if (status) return status
543
- }
544
- }
545
- return null
546
- }
547
-
548
- /** Statements of a branch body. */
549
- const bodyStatements = (node) => (node.type === "BlockStatement" ? node.body : [node])
550
-
551
- /** True when a subtree mentions a status, an Outcome reason or kind, or the tested value itself, so it can tell the failures apart. */
552
- const inspectsResponse = (text, subjectName) =>
553
- /\bstatus\b|\.(?:reason|kind)\b/.test(text) || (subjectName !== null && new RegExp(String.raw`\b${subjectName}\b`).test(text))
554
-
555
- /** A failure (a non-ok HTTP response or a failed `Outcome`) is not one branch, and never `null`: its reason survives to the screen. */
556
- export const noFailureCollapse = {
557
- meta: {
558
- type: "problem",
559
- docs: { description: "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." },
560
- schema: [],
561
- messages: {
562
- empty:
563
- "A failure becomes an empty value here (`null`, `undefined`, an empty list or object). An empty value says nothing about WHY: a refused, not-found, invalid and unavailable result all look the same, so the screen can show only one message. Keep the reason: pass the `Outcome` (or its reason code) on and let the screen choose the translated message. A raw HTTP response is first mapped to an `Outcome` by status: 401/403 `refused`, 404 `not-found`, 422 `invalid`, the rest `unavailable`.",
564
- collapse:
565
- "Every failure takes this one branch and its reason is never read. For an `Outcome`, branch on its reason code (`refused`, `not-found`, `invalid`, `unavailable`); for an HTTP response, map `status` to that reason first (401/403 `refused`, 404 `not-found`, 422 `invalid`, the rest `unavailable`).",
566
- raw: "The server's own text is used as the reason. That text is written for a developer, may leak internals, and is in the wrong language. Keep the reason code (from the `Outcome`, or mapped from the HTTP status) and let the screen translate it.",
567
- },
568
- },
569
- create(context) {
570
- const source = context.sourceCode || context.getSourceCode()
571
- return {
572
- IfStatement(node) {
573
- const subject = failureSubject(node.test)
574
- if (!subject) return
575
- const subjectName = subject.type === "Identifier" ? subject.name : null
576
- const statements = bodyStatements(node.consequent)
577
- const returns = statements.filter((statement) => statement.type === "ReturnStatement")
578
- if (returns.some((statement) => isEmptyValue(statement.argument))) {
579
- context.report({ node, messageId: "empty" })
580
- return
581
- }
582
- // A branch on one named code (`status === 404`) is already inspecting the response.
583
- const test = node.test
584
- const general =
585
- test.type !== "BinaryExpression" || !/\.status\b/.test(source.getText(test)) || !/^={2,3}$/.test(test.operator)
586
- const jumps = statements.filter((s) => s.type === "ReturnStatement" || s.type === "ThrowStatement")
587
- if (general && jumps.length > 0 && !jumps.some((s) => inspectsResponse(source.getText(s), subjectName))) {
588
- context.report({ node, messageId: "collapse" })
589
- }
590
- },
591
- ConditionalExpression(node) {
592
- if (okSubject(node.test) && isEmptyValue(node.alternate)) return context.report({ node, messageId: "empty" })
593
- if (failureSubject(node.test) && isEmptyValue(node.consequent)) context.report({ node, messageId: "empty" })
594
- },
595
- Property(node) {
596
- if (node.computed || node.key.type !== "Identifier" || !["reason", "message"].includes(node.key.name)) return
597
- const text = source.getText(node.value)
598
- const rawText =
599
- /\.statusText\b/.test(text) || (node.value.type === "AwaitExpression" && /\.(?:text|json)\(\)/.test(text))
600
- if (rawText) context.report({ node: node.value, messageId: "raw" })
601
- },
602
- }
603
- },
604
- }
605
-
606
- // -- FE-WIRE-1 -------------------------------------------------------------------------------------
607
-
608
- /** `x.json()`, possibly awaited. */
609
- const isJsonRead = (node) => {
610
- let current = node
611
- while (current && (current.type === "AwaitExpression" || current.type === "TSNonNullExpression")) {
612
- current = current.argument || current.expression
613
- }
614
- return Boolean(
615
- current &&
616
- current.type === "CallExpression" &&
617
- current.callee.type === "MemberExpression" &&
618
- !current.callee.computed &&
619
- current.callee.property.name === "json",
620
- )
621
- }
622
-
623
- /** A GraphQL operation written as text. */
624
- const GRAPHQL_TEXT = /^\s*(?:query|mutation|subscription|fragment)\b[^{]*\{/
625
-
626
- /** The leftmost identifier of a type name: `Course` in `Course`, `Gql` in `Gql.CourseQuery`. */
627
- const typeRoot = (name) => (name.type === "TSQualifiedName" ? typeRoot(name.left) : name)
628
-
629
- /** The value an identifier is initialised with when it is a `const` of this file's scope chain, else null. */
630
- const constInit = (scope, identifier) => {
631
- for (let current = scope; current; current = current.upper) {
632
- const variable = current.set.get(identifier.name)
633
- if (!variable) continue
634
- const definition = variable.defs[0]
635
- return definition?.type === "Variable" && definition.parent?.kind === "const" && definition.node.id.type === "Identifier" ? definition.node.init : null
636
- }
637
- return null
638
- }
639
-
640
- /**
641
- * Wire types are generated from the contract; nobody types the wire by hand, and a GraphQL document is written only in the API
642
- * layer (one file per operation, `modules/api/graphql/{queries,mutations}/`), where codegen reads it.
643
- */
644
- export const noHandTypedWire = {
645
- meta: {
646
- type: "problem",
647
- docs: { description: "A transport response body is narrowed from `unknown` or typed by a generated wire type, never by a hand-written one." },
648
- schema: [],
649
- messages: {
650
- cast:
651
- "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.",
652
- document:
653
- "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.",
654
- },
655
- },
656
- create(context) {
657
- const source = context.sourceCode ?? context.getSourceCode()
658
- const documents = !isApiLayer(context)
659
- /** Local names of the type imports that resolve into a `__generated__/` directory. */
660
- const generated = new Set()
661
- /** True when a type node is `unknown` or names a type imported from the generated wire module. */
662
- const sanctioned = (type) => {
663
- if (type.type === "TSUnknownKeyword") return true
664
- if (type.type !== "TSTypeReference") return false
665
- const root = typeRoot(type.typeName)
666
- return root.type === "Identifier" && (root.name === "const" || generated.has(root.name))
667
- }
668
- /** Whether the value is a `.json()` read, directly or through a const initialised with one. */
669
- const readsResponse = (node) => {
670
- if (isJsonRead(node)) return true
671
- let current = node
672
- while (current && (current.type === "AwaitExpression" || current.type === "TSNonNullExpression")) current = current.argument || current.expression
673
- if (current?.type !== "Identifier") return false
674
- const init = constInit(source.getScope(node), current)
675
- return Boolean(init) && isJsonRead(init)
676
- }
677
- const castCheck = (node) => {
678
- if (readsResponse(node.expression) && !sanctioned(node.typeAnnotation)) context.report({ node, messageId: "cast" })
679
- }
680
- return {
681
- Program(program) {
682
- for (const statement of program.body) {
683
- if (statement.type !== "ImportDeclaration" || !String(statement.source.value).split("/").includes("__generated__")) continue
684
- for (const specifier of statement.specifiers) generated.add(specifier.local.name)
685
- }
686
- },
687
- TSAsExpression: castCheck,
688
- TSTypeAssertion: castCheck,
689
- VariableDeclarator(node) {
690
- const annotation = node.id.type === "Identifier" ? node.id.typeAnnotation?.typeAnnotation : null
691
- if (annotation && node.init && readsResponse(node.init) && !sanctioned(annotation)) context.report({ node, messageId: "cast" })
692
- },
693
- TaggedTemplateExpression(node) {
694
- if (documents && node.tag.type === "Identifier" && /^(?:gql|graphql)$/.test(node.tag.name)) {
695
- context.report({ node, messageId: "document" })
696
- }
697
- },
698
- TemplateLiteral(node) {
699
- if (!documents || (node.parent && node.parent.type === "TaggedTemplateExpression")) return
700
- const text = node.quasis[0] && node.quasis[0].value.cooked
701
- if (typeof text === "string" && GRAPHQL_TEXT.test(text)) context.report({ node, messageId: "document" })
702
- },
703
- Literal(node) {
704
- if (documents && typeof node.value === "string" && GRAPHQL_TEXT.test(node.value) && node.value.includes("}")) {
705
- context.report({ node, messageId: "document" })
706
- }
707
- },
708
- }
709
- },
710
- }
711
-
712
- // -- TRANSPORT-7 -----------------------------------------------------------------------------------
713
-
714
- /** The kinds of `Outcome<T>` (HFS section 6.2): a reader is owed a screen for each. */
715
- const OUTCOME_KINDS = ["ok", "refused", "invalid", "not-found", "unavailable"]
716
-
717
- /** A switch over an Outcome names every kind, so a failure never falls into the success branch or a blank one. */
718
- export const outcomeKindsExhaustive = {
719
- meta: {
720
- type: "problem",
721
- docs: { description: "A `switch` over an Outcome's `kind` has a case for every kind." },
722
- schema: [],
723
- messages: {
724
- missing:
725
- "This `switch` handles `ok` but not {{missing}}. Each kind is a screen a reader can land on: refused sends them to sign in, invalid shows what to fix, not-found says it is gone, unavailable says try again. A missing case is a blank page, or a `default` that shows the wrong one. Write every case; do not rely on `default`.",
726
- },
727
- },
728
- create(context) {
729
- return {
730
- SwitchStatement(node) {
731
- const subject = node.discriminant
732
- if (subject.type !== "MemberExpression" || subject.computed || subject.property.type !== "Identifier" || subject.property.name !== "kind") return
733
- const cases = new Set(node.cases.filter((entry) => entry.test && entry.test.type === "Literal").map((entry) => entry.test.value))
734
- if (!cases.has("ok")) return
735
- const missing = OUTCOME_KINDS.filter((kind) => !cases.has(kind))
736
- if (missing.length > 0) context.report({ node, messageId: "missing", data: { missing: missing.map((kind) => `\`${kind}\``).join(", ") } })
737
- },
738
- }
739
- },
740
- }
741
-
742
- // -- FE-OUTCOME-1 ---------------------------------------------------------------------------------
743
-
744
- /** The `kind` values of an Outcome failure (HFS section 6.2, plus the session-accepted-but-denied `forbidden`): a `kind` union that carries one is a result union. */
745
- const RESULT_KINDS = new Set([...OUTCOME_KINDS, "forbidden"])
746
-
747
- const LITERAL_FLAGS = ts.TypeFlags.StringLiteral | ts.TypeFlags.BooleanLiteral
748
-
749
- /** The literal values of a property of an object type, or null when the property is missing or not literal-typed. */
750
- const literalsOf = (checker, type, name, location) => {
751
- const property = type.getProperty?.(name)
752
- if (!property) return null
753
- const propertyType = checker.getTypeOfSymbolAtLocation(property, location)
754
- const parts = propertyType.isUnion?.() ? propertyType.types : [propertyType]
755
- if (!parts.every((part) => (part.flags & LITERAL_FLAGS) !== 0)) return null
756
- return parts.map((part) => (part.flags & ts.TypeFlags.BooleanLiteral ? part.intrinsicName === "true" : part.value))
757
- }
758
-
759
- /**
760
- * A result vocabulary is declared once per repository: `GraphQLResponse` in the file of the slot the manifest marks `outcomeHome`
761
- * (`fe.transport.outcome`: `modules/api/graphql/types.ts`, or `fe.package.api.outcome`: the api package's `src/graphql/types.ts`).
762
- *
763
- * A union type alias is a result union when its members are object types that ALL carry the same literal-typed discriminant, in the
764
- * result vocabulary: `success` or `ok` (both `true` and `false` among the members) or `kind` (a member of kind `"ok"` beside at least one failure
765
- * kind of the Outcome vocabulary: refused, forbidden, invalid, not-found, unavailable). A view union that a mapper derives from an
766
- * 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
767
- * `Ok<T> | Failure` and an intersection are seen as what they are, and no name (`*Outcome`, `*Result`) decides anything.
768
- * A UI state union (`{ status: "idle" } | { status: "saving" }`, `{ type: ... }`, a `kind` of menu items) is not result
769
- * vocabulary and passes. An alias that only composes the one union (`type Read = Outcome<Course>`, `Extract<Outcome<T>, ...>`) is not
770
- * a union type node and passes; `Outcome<T> | { ok: false; kind: "conflict" }` adds an arm to the one union and is refused.
771
- */
772
- export const oneOutcomeUnion = {
773
- meta: {
774
- type: "problem",
775
- 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>`." },
776
- schema: [],
777
- messages: {
778
- second:
779
- "`{{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.",
780
- },
781
- },
782
- create(context) {
783
- if (isOutcomeModule(context)) return {}
784
- const { checker, toTs } = typed(context)
785
- return {
786
- TSTypeAliasDeclaration(node) {
787
- let annotation = node.typeAnnotation
788
- while (annotation.type === "TSParenthesizedType") annotation = annotation.typeAnnotation
789
- if (annotation.type !== "TSUnionType") return
790
- const tsNode = toTs(node)
791
- if (!tsNode) return
792
- const type = checker.getTypeAtLocation(tsNode.name)
793
- if (!type.isUnion?.() || type.types.length < 2) return
794
- // The members must all be object types (an object, or an intersection of them).
795
- if (!type.types.every((member) => (member.flags & (ts.TypeFlags.Object | ts.TypeFlags.Intersection)) !== 0)) return
796
- for (const discriminant of ["success", "ok", "kind"]) {
797
- const values = type.types.map((member) => literalsOf(checker, member, discriminant, tsNode))
798
- if (values.some((entry) => entry === null)) continue
799
- const flat = values.flat()
800
- // `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.
801
- const isResult =
802
- discriminant !== "kind"
803
- ? values.every((entry) => entry.length === 1) && flat.includes(true) && flat.includes(false)
804
- : flat.includes("ok") && flat.some((value) => value !== "ok" && RESULT_KINDS.has(value))
805
- if (isResult) {
806
- context.report({ node: node.id, messageId: "second", data: { name: node.id.name, discriminant: `\`${discriminant}\` discriminant` } })
807
- return
808
- }
809
- }
810
- },
811
- }
812
- },
813
- }
814
-
815
- /** The rules this law contributes to the plugin. */
816
- export const rules = {
817
- "fetch-only-in-api-client": fetchOnlyInApiClient,
818
- "client-fetch-has-signal": clientFetchHasSignal,
819
- "no-shared-transport-state": noSharedTransportState,
820
- "client-maps-auth-to-refused": clientMapsAuthToRefused,
821
- "no-failure-collapse": noFailureCollapse,
822
- "no-hand-typed-wire": noHandTypedWire,
823
- "outcome-kinds-exhaustive": outcomeKindsExhaustive,
824
- "one-outcome-union": oneOutcomeUnion,
825
- // Forward the callback enforcer for the catalog's source-only CONTRIBUTIONS discovery.
826
- ...callbackShapeRules,
827
- }
828
-
829
- /** Every rule is an error: a second transport or a status collapse is the defect, not a style. */
830
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))