@ethisyscore/eslint-plugin-coreconnect 1.141.5 → 1.142.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethisyscore/eslint-plugin-coreconnect",
3
- "version": "1.141.5",
3
+ "version": "1.142.1",
4
4
  "description": "ESLint rules enforcing EthisysCore plugin frontend conventions. Published so a new rule reaches every plugin on a version bump, rather than being copied into each scaffold and drifting.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ import noLocalInProgressBanner from "./rules/no-local-in-progress-banner.js";
10
10
  import noLocalMappingEditor from "./rules/no-local-mapping-editor.js";
11
11
  import noLocalSearchInput from "./rules/no-local-search-input.js";
12
12
  import noParamInNavPath from "./rules/no-param-in-nav-path.js";
13
+ import noPluginQueryClient from "./rules/no-plugin-query-client.js";
13
14
  import noSecondMarkdownRenderer, {
14
15
  MARKDOWN_RESTRICTED_IMPORTS,
15
16
  } from "./rules/no-second-markdown-renderer.js";
@@ -27,6 +28,7 @@ const rules = {
27
28
  "no-local-mapping-editor": noLocalMappingEditor,
28
29
  "no-local-search-input": noLocalSearchInput,
29
30
  "no-param-in-nav-path": noParamInNavPath,
31
+ "no-plugin-query-client": noPluginQueryClient,
30
32
  "no-second-markdown-renderer": noSecondMarkdownRenderer,
31
33
  };
32
34
 
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Disallow a plugin building its own react-query client, reaching into a client's caches, or passing the
3
+ * PlatformReact definers' `queryClient` override.
4
+ *
5
+ * PBI 5626 settled how a plugin caches in the browser (the rules the engineering skills publish):
6
+ *
7
+ * R1. Each plugin gets its own cache from the host. The host builds ONE client per plugin, shared by
8
+ * all that plugin's pages and overlays, with the host's error toasts, retries and 401 handling,
9
+ * and clears it on sign-out and on an organisation or identity switch.
10
+ * R2. A plugin cannot refresh another plugin's cached data. Another plugin's keys live in that
11
+ * plugin's own client; invalidating them here is a no-op, so there is nothing to write.
12
+ * R3. Cross-plugin updates come from backend pushes: the definers' `cacheSubscriptions` option.
13
+ *
14
+ * Each pattern below breaks one of them, invisibly at runtime:
15
+ *
16
+ * - `new QueryClient(...)` in shipped code is a second cache. The bug that started PBI 5626 was exactly
17
+ * this: one client per page bundle, so a delete on the detail page never reached the list page. It
18
+ * also misses the host's clearing, so a surface on it can show the previous organisation's data.
19
+ * - The definers' `queryClient` option is that same second client, handed to the definer. The SDK now
20
+ * ignores it while the host provides the plugin's own client; this makes the dead option visible.
21
+ * - `getQueryCache()` / `getMutationCache()` reach under react-query's public API into the whole cache,
22
+ * the route to clearing or rewriting entries wholesale rather than invalidating the plugin's own keys.
23
+ *
24
+ * Test files are exempt (`isTestFile`): a test builds a client per case, by design.
25
+ *
26
+ * @type {import("eslint").Rule.RuleModule}
27
+ */
28
+ import { isTestFile } from "./testFiles.js";
29
+
30
+ const DEFINERS = new Set([
31
+ "definePlatformReactPluginPage",
32
+ "definePlatformReactPluginOverlay",
33
+ "createPluginPageDefiner",
34
+ "createPluginOverlayDefiner",
35
+ ]);
36
+
37
+ const CACHE_ACCESSORS = new Set(["getQueryCache", "getMutationCache"]);
38
+
39
+ const REACT_QUERY = "@tanstack/react-query";
40
+
41
+ /** How many `...spread` levels of a definer's options are followed back to an object literal. */
42
+ const MAX_SPREAD_DEPTH = 3;
43
+
44
+ /** The name a call or `new` targets: `f(...)`, `ns.f(...)`. */
45
+ function targetName(callee) {
46
+ if (callee.type === "Identifier") return callee.name;
47
+ if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier") {
48
+ return callee.property.name;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ function isQueryClientKey(property) {
54
+ if (property.type !== "Property" || property.computed) return false;
55
+ const { key } = property;
56
+ return (key.type === "Identifier" && key.name === "queryClient") || (key.type === "Literal" && key.value === "queryClient");
57
+ }
58
+
59
+ /** The exported name an import specifier binds: `{ QueryClient as QC }` and `{ "QueryClient" as QC }` alike. */
60
+ function importedName(specifier) {
61
+ return specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value;
62
+ }
63
+
64
+ export default {
65
+ meta: {
66
+ type: "problem",
67
+ docs: {
68
+ description:
69
+ "Disallow a plugin-built QueryClient, the definers' queryClient override, and getQueryCache/getMutationCache - each plugin runs on the one client the host gives it",
70
+ },
71
+ messages: {
72
+ newQueryClient:
73
+ "A plugin must not build its own QueryClient: it splits the plugin's cache and escapes the host's clearing on sign-out and organisation switch. Every page and overlay runs on the client the host provides; read it with useQueryClient(). To react to another app's change, map its canonical topic in the definer's cacheSubscriptions.",
74
+ definerOverride:
75
+ "The '{{name}}' queryClient option is deprecated and ignored while the host provides the plugin's own client (any host with per-plugin clients). Remove it; set staleTime or retry on the queries themselves.",
76
+ cacheAccessor:
77
+ "'{{name}}()' reaches into the whole react-query cache. Invalidate or update this plugin's own keys through the QueryClient API (invalidateQueries, setQueryData, removeQueries) instead.",
78
+ },
79
+ schema: [],
80
+ },
81
+ create(context) {
82
+ // Test scaffolding is exempt: a test builds a client per case, by design.
83
+ if (isTestFile(context.filename ?? context.getFilename?.())) {
84
+ return {};
85
+ }
86
+
87
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
88
+
89
+ /** The variable a read of `identifier` resolves to, through ESLint's scope manager; undefined for a global. */
90
+ const variableOf = (identifier) => {
91
+ for (let scope = sourceCode.getScope(identifier); scope; scope = scope.upper) {
92
+ const variable = scope.set.get(identifier.name);
93
+ if (variable) return variable;
94
+ }
95
+ return undefined;
96
+ };
97
+
98
+ /**
99
+ * Whether `new <identifier>(...)` builds react-query's client. The binding decides: an import of
100
+ * `QueryClient` from react-query under any local name. A local class or an import from elsewhere
101
+ * that happens to be called `QueryClient` is something else. Only an unbound `QueryClient`, which
102
+ * this file cannot trace, is judged by its name.
103
+ */
104
+ const isQueryClientConstructor = (identifier) => {
105
+ const def = variableOf(identifier)?.defs[0];
106
+ if (def === undefined) return identifier.name === "QueryClient";
107
+ return (
108
+ def?.type === "ImportBinding" &&
109
+ def.node.type === "ImportSpecifier" &&
110
+ def.parent.source.value === REACT_QUERY &&
111
+ importedName(def.node) === "QueryClient"
112
+ );
113
+ };
114
+
115
+ /**
116
+ * Whether `new <ns>.QueryClient(...)` builds react-query's client. Yes unless `ns` is imported from
117
+ * another package (`import * as Vendor from "vendor"`): a namespace of react-query, or one this file
118
+ * cannot trace, is reported, as the rule did before it resolved bindings.
119
+ */
120
+ const isQueryClientMember = (callee) => {
121
+ if (targetName(callee) !== "QueryClient") return false;
122
+ if (callee.object.type !== "Identifier") return true;
123
+ const def = variableOf(callee.object)?.defs[0];
124
+ // A namespace import of react-query is the client; any other binding in this file (a local object,
125
+ // another package's namespace) is something else. An unbound name is out of reach, so it is reported.
126
+ if (def === undefined) return true;
127
+ return def.type === "ImportBinding" && def.parent.source.value === REACT_QUERY;
128
+ };
129
+
130
+ /**
131
+ * The object literal a definer's options argument is: written inline, or held in a `const` of this file.
132
+ * Options imported from another module are out of reach; the definers ignore the option on every host
133
+ * that provides a client anyway, so this rule makes the common forms visible rather than all of them.
134
+ */
135
+ const objectLiteralOf = (node) => {
136
+ if (node.type === "ObjectExpression") return node;
137
+ if (node.type !== "Identifier") return null;
138
+ const variable = variableOf(node);
139
+ const def = variable?.defs.length === 1 ? variable.defs[0] : undefined;
140
+ return def?.type === "Variable" && def.parent.kind === "const" && def.node.init?.type === "ObjectExpression"
141
+ ? def.node.init
142
+ : null;
143
+ };
144
+
145
+ // A `const` of options shared by two definer calls is one offence, reported once.
146
+ const reported = new Set();
147
+
148
+ const reportOverrides = (options, name, depth) => {
149
+ for (const property of options.properties) {
150
+ if (isQueryClientKey(property)) {
151
+ if (!reported.has(property)) {
152
+ reported.add(property);
153
+ context.report({ node: property, messageId: "definerOverride", data: { name } });
154
+ }
155
+ } else if (property.type === "SpreadElement" && depth < MAX_SPREAD_DEPTH) {
156
+ const spread = objectLiteralOf(property.argument);
157
+ if (spread) reportOverrides(spread, name, depth + 1);
158
+ }
159
+ }
160
+ };
161
+
162
+ return {
163
+ NewExpression(node) {
164
+ const { callee } = node;
165
+ const builds = callee.type === "Identifier" ? isQueryClientConstructor(callee) : isQueryClientMember(callee);
166
+ if (builds) {
167
+ context.report({ node, messageId: "newQueryClient" });
168
+ }
169
+ },
170
+ CallExpression(node) {
171
+ const name = targetName(node.callee);
172
+ if (name === null) return;
173
+
174
+ if (node.callee.type === "MemberExpression" && CACHE_ACCESSORS.has(name)) {
175
+ context.report({ node, messageId: "cacheAccessor", data: { name } });
176
+ return;
177
+ }
178
+
179
+ if (DEFINERS.has(name)) {
180
+ for (const argument of node.arguments) {
181
+ const options = objectLiteralOf(argument);
182
+ if (options) reportOverrides(options, name, 0);
183
+ }
184
+ }
185
+ },
186
+ };
187
+ },
188
+ };
@@ -2,8 +2,9 @@
2
2
  * Whether a file is test scaffolding rather than shipped code.
3
3
  *
4
4
  * Used by the two STRUCTURAL rules - `no-inline-object-type` and `no-inline-string-union` - and by
5
- * nothing else. Both exist to stop an unnamed shape being re-written slightly differently by the
6
- * next person who needs it, and that argument does not reach a test file: the shape belongs to one
5
+ * `no-plugin-query-client`, whose ban on building a QueryClient cannot reach a test: a test builds a
6
+ * client per case, by design. The two structural rules exist to stop an unnamed shape being re-written
7
+ * slightly differently by the next person who needs it, and that argument does not reach a test file: the shape belongs to one
7
8
  * mock in one file, nobody imports it, and it is deleted when the test is.
8
9
  *
9
10
  * Measured before adding this: 80 of the estate's convention findings sit in test files, 74 of them