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