@violetflux/eslint-plugin-kerros 0.2.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/dist/index.cjs +2484 -0
- package/dist/index.d.cts +37 -0
- package/dist/index.d.mts +37 -0
- package/dist/index.mjs +2454 -0
- package/package.json +53 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2484 @@
|
|
|
1
|
+
Object.defineProperties(exports, {
|
|
2
|
+
__esModule: { value: true },
|
|
3
|
+
[Symbol.toStringTag]: { value: "Module" }
|
|
4
|
+
});
|
|
5
|
+
//#region \0rolldown/runtime.js
|
|
6
|
+
var __create = Object.create;
|
|
7
|
+
var __defProp = Object.defineProperty;
|
|
8
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
9
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
10
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
11
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
14
|
+
key = keys[i];
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
16
|
+
get: ((k) => from[k]).bind(null, key),
|
|
17
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return to;
|
|
21
|
+
};
|
|
22
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
23
|
+
value: mod,
|
|
24
|
+
enumerable: true
|
|
25
|
+
}) : target, mod));
|
|
26
|
+
//#endregion
|
|
27
|
+
let _typescript_eslint_parser = require("@typescript-eslint/parser");
|
|
28
|
+
_typescript_eslint_parser = __toESM(_typescript_eslint_parser, 1);
|
|
29
|
+
let typescript = require("typescript");
|
|
30
|
+
typescript = __toESM(typescript, 1);
|
|
31
|
+
let _typescript_eslint_utils = require("@typescript-eslint/utils");
|
|
32
|
+
//#region src/internal/ast.ts
|
|
33
|
+
const transparentExpressionTypes = /* @__PURE__ */ new Set([
|
|
34
|
+
"ChainExpression",
|
|
35
|
+
"TSAsExpression",
|
|
36
|
+
"TSInstantiationExpression",
|
|
37
|
+
"TSNonNullExpression",
|
|
38
|
+
"TSSatisfiesExpression",
|
|
39
|
+
"TSTypeAssertion"
|
|
40
|
+
]);
|
|
41
|
+
/** Remove syntax-only expression wrappers without changing runtime ownership. */
|
|
42
|
+
function unwrapExpression(node) {
|
|
43
|
+
let current = node;
|
|
44
|
+
while (transparentExpressionTypes.has(current.type) && "expression" in current) current = current.expression;
|
|
45
|
+
return current;
|
|
46
|
+
}
|
|
47
|
+
/** Return the direct expressions produced by a selector body. */
|
|
48
|
+
function getReturnedExpressions(selector) {
|
|
49
|
+
if (selector.body.type !== "BlockStatement") return [selector.body];
|
|
50
|
+
const expressions = [];
|
|
51
|
+
const visit = (node) => {
|
|
52
|
+
if (node !== selector.body && (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression" || node.type === "FunctionDeclaration")) return;
|
|
53
|
+
if (node.type === "ReturnStatement" && node.argument) {
|
|
54
|
+
expressions.push(node.argument);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const key of Object.keys(node)) {
|
|
58
|
+
if (key === "parent" || key === "range" || key === "loc") continue;
|
|
59
|
+
const value = node[key];
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
for (const child of value) if (child && typeof child === "object" && "type" in child) visit(child);
|
|
62
|
+
} else if (value && typeof value === "object" && "type" in value) visit(value);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
visit(selector.body);
|
|
66
|
+
return expressions;
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/internal/rule.ts
|
|
70
|
+
const createRule = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/violetflux/kerros/tree/main/packages/eslint-plugin-kerros/src/rules/${name}.ts`);
|
|
71
|
+
/** Require complete TypeScript parser services for every Kerros rule. */
|
|
72
|
+
function getTypeServices(context) {
|
|
73
|
+
try {
|
|
74
|
+
return _typescript_eslint_utils.ESLintUtils.getParserServices(context);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new Error("Kerros ESLint rules require typed linting. Configure @typescript-eslint/parser with parserOptions.projectService.");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/internal/kerros-types.ts
|
|
81
|
+
const markerNames = {
|
|
82
|
+
externalStoreProvider: "externalStoreProviderMarker",
|
|
83
|
+
storeHook: "storeHookMarker",
|
|
84
|
+
storeInstanceHook: "storeInstanceHookMarker"
|
|
85
|
+
};
|
|
86
|
+
/** Test whether a declaration belongs to the Kerros runtime package. */
|
|
87
|
+
function isKerrosSourceFile(sourceFile) {
|
|
88
|
+
const filename = sourceFile.fileName.replaceAll("\\", "/");
|
|
89
|
+
if (filename.includes("/node_modules/@violetflux/kerros/")) return true;
|
|
90
|
+
return filename.endsWith("/src/index.tsx") && sourceFile.text.includes("declare const storeHookMarker: unique symbol") && sourceFile.text.includes("declare const storeInstanceHookMarker: unique symbol") && sourceFile.text.includes("declare const externalStoreProviderMarker: unique symbol");
|
|
91
|
+
}
|
|
92
|
+
/** Create type-backed Kerros identity checks shared across one TypeScript Program. */
|
|
93
|
+
function createKerrosProgramTools(program) {
|
|
94
|
+
const checker = program.getTypeChecker();
|
|
95
|
+
/** Resolve aliases until the declaration that owns the type identity. */
|
|
96
|
+
const resolveSymbol = (input) => {
|
|
97
|
+
let symbol = input;
|
|
98
|
+
while ((symbol.flags & typescript.default.SymbolFlags.Alias) !== 0) symbol = checker.getAliasedSymbol(symbol);
|
|
99
|
+
return symbol;
|
|
100
|
+
};
|
|
101
|
+
/** Read and de-alias the symbol referenced by a TypeScript node. */
|
|
102
|
+
const getTsSymbol = (node) => {
|
|
103
|
+
const symbol = (typescript.default.isIdentifier(node) && typescript.default.isShorthandPropertyAssignment(node.parent) ? checker.getShorthandAssignmentValueSymbol(node.parent) : void 0) ?? checker.getSymbolAtLocation(node);
|
|
104
|
+
return symbol ? resolveSymbol(symbol) : void 0;
|
|
105
|
+
};
|
|
106
|
+
/** Verify that a computed property comes from Kerros' private unique symbol. */
|
|
107
|
+
const isMarkerProperty = (property, marker) => {
|
|
108
|
+
return property.declarations?.some((declaration) => {
|
|
109
|
+
if (!typescript.default.isPropertySignature(declaration) || !typescript.default.isComputedPropertyName(declaration.name)) return false;
|
|
110
|
+
const inputSymbol = checker.getSymbolAtLocation(declaration.name.expression);
|
|
111
|
+
if (!inputSymbol) return false;
|
|
112
|
+
const symbol = resolveSymbol(inputSymbol);
|
|
113
|
+
if (symbol.getName() !== markerNames[marker]) return false;
|
|
114
|
+
return symbol.declarations?.some((markerDeclaration) => {
|
|
115
|
+
if (!typescript.default.isVariableDeclaration(markerDeclaration)) return false;
|
|
116
|
+
return isKerrosSourceFile(markerDeclaration.getSourceFile());
|
|
117
|
+
}) === true;
|
|
118
|
+
}) === true;
|
|
119
|
+
};
|
|
120
|
+
/** Test a TypeScript type for one nominal Kerros marker. */
|
|
121
|
+
const hasMarker = (type, marker) => {
|
|
122
|
+
return type.getProperties().some((property) => isMarkerProperty(property, marker));
|
|
123
|
+
};
|
|
124
|
+
/** Read the payload carried by one nominal Kerros marker. */
|
|
125
|
+
const getMarkerType = (type, marker, location) => {
|
|
126
|
+
const property = type.getProperties().find((candidate) => isMarkerProperty(candidate, marker));
|
|
127
|
+
return property ? checker.getTypeOfSymbolAtLocation(property, location) : void 0;
|
|
128
|
+
};
|
|
129
|
+
/** Resolve an inline, named, or imported model function. */
|
|
130
|
+
const getModelFunction = (input) => {
|
|
131
|
+
const seen = /* @__PURE__ */ new Set();
|
|
132
|
+
/** Follow syntax wrappers and variable aliases to a concrete function body. */
|
|
133
|
+
const resolve = (node) => {
|
|
134
|
+
if (typescript.default.isArrowFunction(node) || typescript.default.isFunctionExpression(node)) return node;
|
|
135
|
+
if (typescript.default.isFunctionDeclaration(node) && node.body) return node;
|
|
136
|
+
if (typescript.default.isParenthesizedExpression(node) || typescript.default.isAsExpression(node) || typescript.default.isNonNullExpression(node) || typescript.default.isSatisfiesExpression(node)) return resolve(node.expression);
|
|
137
|
+
const symbol = getTsSymbol(node);
|
|
138
|
+
if (!symbol || seen.has(symbol)) return void 0;
|
|
139
|
+
seen.add(symbol);
|
|
140
|
+
for (const declaration of symbol.declarations ?? []) {
|
|
141
|
+
if (typescript.default.isFunctionDeclaration(declaration) && declaration.body) return declaration;
|
|
142
|
+
if (typescript.default.isMethodDeclaration(declaration) && declaration.body) return declaration;
|
|
143
|
+
if (typescript.default.isPropertyAssignment(declaration)) {
|
|
144
|
+
const fn = resolve(declaration.initializer);
|
|
145
|
+
if (fn) return fn;
|
|
146
|
+
}
|
|
147
|
+
if (typescript.default.isVariableDeclaration(declaration) && declaration.initializer) {
|
|
148
|
+
const fn = resolve(declaration.initializer);
|
|
149
|
+
if (fn) return fn;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
return resolve(input);
|
|
154
|
+
};
|
|
155
|
+
/** Test a call against an API declaration owned by React's type package. */
|
|
156
|
+
const isReactCall = (node, name) => {
|
|
157
|
+
const symbol = getTsSymbol(node.expression);
|
|
158
|
+
if (symbol?.getName() !== name) return false;
|
|
159
|
+
return symbol.declarations?.some((declaration) => {
|
|
160
|
+
return declaration.getSourceFile().fileName.replaceAll("\\", "/").includes("/node_modules/@types/react/");
|
|
161
|
+
}) === true;
|
|
162
|
+
};
|
|
163
|
+
/** Read a tuple member type from a factory return type. */
|
|
164
|
+
const getTupleMemberType = (type, index, location) => {
|
|
165
|
+
const property = checker.getPropertyOfType(type, String(index));
|
|
166
|
+
return property ? checker.getTypeOfSymbolAtLocation(property, location) : void 0;
|
|
167
|
+
};
|
|
168
|
+
/** Classify a call by the nominal markers on its resolved return tuple. */
|
|
169
|
+
const getFactoryKind = (node) => {
|
|
170
|
+
const inputSymbol = checker.getSymbolAtLocation(node.expression);
|
|
171
|
+
if (!inputSymbol) return void 0;
|
|
172
|
+
const symbol = resolveSymbol(inputSymbol);
|
|
173
|
+
const name = symbol.getName();
|
|
174
|
+
if (name !== "createStore" && name !== "bindStore" || !symbol.declarations?.some((declaration) => isKerrosSourceFile(declaration.getSourceFile()))) return;
|
|
175
|
+
const signature = checker.getResolvedSignature(node);
|
|
176
|
+
if (!signature) return void 0;
|
|
177
|
+
const returnType = checker.getReturnTypeOfSignature(signature);
|
|
178
|
+
const hookType = getTupleMemberType(returnType, 0, node);
|
|
179
|
+
if (!hookType || !hasMarker(hookType, "storeHook")) return void 0;
|
|
180
|
+
const instanceType = getTupleMemberType(returnType, 2, node);
|
|
181
|
+
if (name === "createStore" && !instanceType) return "createStore";
|
|
182
|
+
if (name !== "bindStore" || !instanceType) return void 0;
|
|
183
|
+
const providerType = getTupleMemberType(returnType, 1, node);
|
|
184
|
+
return providerType && hasMarker(providerType, "externalStoreProvider") && hasMarker(instanceType, "storeInstanceHook") ? "bindStore" : void 0;
|
|
185
|
+
};
|
|
186
|
+
/** Test whether a call invokes a nominal Kerros Store Hook. */
|
|
187
|
+
const isStoreHookCall = (node) => {
|
|
188
|
+
return hasMarker(checker.getTypeAtLocation(node.expression), "storeHook");
|
|
189
|
+
};
|
|
190
|
+
/** Test whether a call invokes a nominal Kerros Store instance Hook. */
|
|
191
|
+
const isStoreInstanceHookCall = (node) => {
|
|
192
|
+
return hasMarker(checker.getTypeAtLocation(node.expression), "storeInstanceHook");
|
|
193
|
+
};
|
|
194
|
+
return {
|
|
195
|
+
checker,
|
|
196
|
+
getFactoryKind,
|
|
197
|
+
getMarkerType,
|
|
198
|
+
getModelFunction,
|
|
199
|
+
getTsSymbol,
|
|
200
|
+
hasMarker,
|
|
201
|
+
isReactCall,
|
|
202
|
+
isStoreInstanceHookCall,
|
|
203
|
+
isStoreHookCall,
|
|
204
|
+
resolveSymbol
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/** Create ESTree adapters for the Kerros identity checks in one rule context. */
|
|
208
|
+
function createKerrosTypeTools(context) {
|
|
209
|
+
const services = getTypeServices(context);
|
|
210
|
+
const programTools = createKerrosProgramTools(services.program);
|
|
211
|
+
const { checker, getTsSymbol, resolveSymbol } = programTools;
|
|
212
|
+
/** Read an ESTree node's TypeScript type. */
|
|
213
|
+
const getType = (node) => {
|
|
214
|
+
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
|
|
215
|
+
return checker.getTypeAtLocation(tsNode);
|
|
216
|
+
};
|
|
217
|
+
/** Read the TypeScript node corresponding to an ESTree node. */
|
|
218
|
+
const getTsNode = (node) => {
|
|
219
|
+
return services.esTreeNodeToTSNodeMap.get(node);
|
|
220
|
+
};
|
|
221
|
+
/** Classify an ESTree call through the shared Program identity checks. */
|
|
222
|
+
const getFactoryKind = (node) => {
|
|
223
|
+
const tsNode = getTsNode(node);
|
|
224
|
+
return typescript.default.isCallExpression(tsNode) ? programTools.getFactoryKind(tsNode) : void 0;
|
|
225
|
+
};
|
|
226
|
+
/** Resolve an ESTree model reference to its TypeScript implementation. */
|
|
227
|
+
const getModelFunction = (node) => {
|
|
228
|
+
return programTools.getModelFunction(getTsNode(node));
|
|
229
|
+
};
|
|
230
|
+
/** Test whether an ESTree call invokes a nominal Kerros Store Hook. */
|
|
231
|
+
const isStoreHookCall = (node) => {
|
|
232
|
+
const tsNode = getTsNode(node);
|
|
233
|
+
return typescript.default.isCallExpression(tsNode) && programTools.isStoreHookCall(tsNode);
|
|
234
|
+
};
|
|
235
|
+
/** Test whether an ESTree call invokes a nominal Kerros Store instance Hook. */
|
|
236
|
+
const isStoreInstanceHookCall = (node) => {
|
|
237
|
+
const tsNode = getTsNode(node);
|
|
238
|
+
return typescript.default.isCallExpression(tsNode) && programTools.isStoreInstanceHookCall(tsNode);
|
|
239
|
+
};
|
|
240
|
+
/** Resolve an identifier to its non-alias TypeScript symbol. */
|
|
241
|
+
const getIdentifierSymbol = (node) => {
|
|
242
|
+
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
|
|
243
|
+
const symbol = (typescript.default.isIdentifier(tsNode) && typescript.default.isShorthandPropertyAssignment(tsNode.parent) ? checker.getShorthandAssignmentValueSymbol(tsNode.parent) : void 0) ?? checker.getSymbolAtLocation(tsNode);
|
|
244
|
+
return symbol ? resolveSymbol(symbol) : void 0;
|
|
245
|
+
};
|
|
246
|
+
return {
|
|
247
|
+
checker,
|
|
248
|
+
getFactoryKind,
|
|
249
|
+
getIdentifierSymbol,
|
|
250
|
+
getMarkerType: programTools.getMarkerType,
|
|
251
|
+
getModelFunction,
|
|
252
|
+
getTsNode,
|
|
253
|
+
getTsSymbol,
|
|
254
|
+
getType,
|
|
255
|
+
hasMarker: programTools.hasMarker,
|
|
256
|
+
isReactCall: programTools.isReactCall,
|
|
257
|
+
isStoreInstanceHookCall,
|
|
258
|
+
isStoreHookCall,
|
|
259
|
+
services
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/** Test whether a declaration is owned directly by a source file. */
|
|
263
|
+
function isModuleDeclaration(declaration) {
|
|
264
|
+
let current = declaration;
|
|
265
|
+
while (current.parent && !typescript.default.isSourceFile(current.parent)) {
|
|
266
|
+
if (typescript.default.isFunctionLike(current.parent) || typescript.default.isBlock(current.parent)) return false;
|
|
267
|
+
current = current.parent;
|
|
268
|
+
}
|
|
269
|
+
return typescript.default.isSourceFile(current.parent);
|
|
270
|
+
}
|
|
271
|
+
/** Read a type's visible property across unions and generic constraints. */
|
|
272
|
+
function getTypeProperty(checker, type, name) {
|
|
273
|
+
const direct = checker.getPropertyOfType(type, name);
|
|
274
|
+
if (direct) return direct;
|
|
275
|
+
if (type.isUnion()) for (const member of type.types) {
|
|
276
|
+
const property = getTypeProperty(checker, member, name);
|
|
277
|
+
if (property) return property;
|
|
278
|
+
}
|
|
279
|
+
const constraint = checker.getBaseConstraintOfType(type);
|
|
280
|
+
return constraint && constraint !== type ? getTypeProperty(checker, constraint, name) : void 0;
|
|
281
|
+
}
|
|
282
|
+
//#endregion
|
|
283
|
+
//#region src/rules/binding-naming.ts
|
|
284
|
+
/** Extract the model-derived Store name used by createStore bindings. */
|
|
285
|
+
function getCreateStoreName(call) {
|
|
286
|
+
const model = call.arguments[0];
|
|
287
|
+
if (!model || model.type !== "Identifier") return void 0;
|
|
288
|
+
return /^use(?<name>[A-Z][A-Za-z0-9]*)Model$/u.exec(model.name)?.groups?.name;
|
|
289
|
+
}
|
|
290
|
+
/** Extract the explicit display name used by bindStore bindings. */
|
|
291
|
+
function getBindStoreName(call) {
|
|
292
|
+
const input = call.arguments[0];
|
|
293
|
+
return input?.type === "Literal" && typeof input.value === "string" ? input.value : void 0;
|
|
294
|
+
}
|
|
295
|
+
/** Read one identifier from a binding tuple, leaving holes untouched. */
|
|
296
|
+
function getElement(pattern, index) {
|
|
297
|
+
const element = pattern.elements[index];
|
|
298
|
+
return element?.type === "Identifier" ? element : void 0;
|
|
299
|
+
}
|
|
300
|
+
const bindingNaming = createRule({
|
|
301
|
+
name: "binding-naming",
|
|
302
|
+
meta: {
|
|
303
|
+
type: "problem",
|
|
304
|
+
docs: { description: "Keep Kerros Hook and Provider binding names aligned." },
|
|
305
|
+
schema: [],
|
|
306
|
+
messages: {
|
|
307
|
+
destructureBinding: "Kerros factory results must be destructured.",
|
|
308
|
+
hookName: "The Store Hook must be named useXxx.",
|
|
309
|
+
providerName: "The Provider name must match the Store Hook.",
|
|
310
|
+
instanceName: "The instance Hook name must match the Store Hook."
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
defaultOptions: [],
|
|
314
|
+
create(context) {
|
|
315
|
+
const { getFactoryKind } = createKerrosTypeTools(context);
|
|
316
|
+
return { CallExpression(node) {
|
|
317
|
+
const kind = getFactoryKind(node);
|
|
318
|
+
if (!kind) return;
|
|
319
|
+
const expression = unwrapExpression(node);
|
|
320
|
+
const declarator = expression.parent;
|
|
321
|
+
if (declarator?.type !== "VariableDeclarator" || declarator.init !== expression) {
|
|
322
|
+
context.report({
|
|
323
|
+
node,
|
|
324
|
+
messageId: "destructureBinding"
|
|
325
|
+
});
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (declarator.id.type !== "ArrayPattern") {
|
|
329
|
+
context.report({
|
|
330
|
+
node: declarator.id,
|
|
331
|
+
messageId: "destructureBinding"
|
|
332
|
+
});
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const hook = getElement(declarator.id, 0);
|
|
336
|
+
const provider = getElement(declarator.id, 1);
|
|
337
|
+
const instance = getElement(declarator.id, 2);
|
|
338
|
+
const explicitName = kind === "createStore" ? getCreateStoreName(node) : getBindStoreName(node);
|
|
339
|
+
const hookMatch = hook && /^use(?<name>[A-Z][A-Za-z0-9]*)$/u.exec(hook.name);
|
|
340
|
+
const providerMatch = provider && /^(?<name>[A-Z][A-Za-z0-9]*)Provider$/u.exec(provider.name);
|
|
341
|
+
const instanceMatch = instance && /^use(?<name>[A-Z][A-Za-z0-9]*)Instance$/u.exec(instance.name);
|
|
342
|
+
const inferredName = hookMatch?.groups?.name ?? providerMatch?.groups?.name ?? instanceMatch?.groups?.name;
|
|
343
|
+
const name = explicitName ?? inferredName;
|
|
344
|
+
if (hook && (!hookMatch || name && hookMatch.groups?.name !== name)) context.report({
|
|
345
|
+
node: hook,
|
|
346
|
+
messageId: "hookName"
|
|
347
|
+
});
|
|
348
|
+
if (provider && (!providerMatch || name && providerMatch.groups?.name !== name)) context.report({
|
|
349
|
+
node: provider,
|
|
350
|
+
messageId: "providerName"
|
|
351
|
+
});
|
|
352
|
+
if (instance && (!instanceMatch || name && instanceMatch.groups?.name !== name)) context.report({
|
|
353
|
+
node: instance,
|
|
354
|
+
messageId: "instanceName"
|
|
355
|
+
});
|
|
356
|
+
} };
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
//#endregion
|
|
360
|
+
//#region src/rules/factory-at-module-scope.ts
|
|
361
|
+
const nestedScopeTypes = /* @__PURE__ */ new Set([
|
|
362
|
+
"ArrowFunctionExpression",
|
|
363
|
+
"BlockStatement",
|
|
364
|
+
"CatchClause",
|
|
365
|
+
"ConditionalExpression",
|
|
366
|
+
"DoWhileStatement",
|
|
367
|
+
"ForInStatement",
|
|
368
|
+
"ForOfStatement",
|
|
369
|
+
"ForStatement",
|
|
370
|
+
"FunctionDeclaration",
|
|
371
|
+
"FunctionExpression",
|
|
372
|
+
"IfStatement",
|
|
373
|
+
"LogicalExpression",
|
|
374
|
+
"PropertyDefinition",
|
|
375
|
+
"StaticBlock",
|
|
376
|
+
"SwitchCase",
|
|
377
|
+
"SwitchStatement",
|
|
378
|
+
"TryStatement",
|
|
379
|
+
"WhileStatement"
|
|
380
|
+
]);
|
|
381
|
+
/** Test whether a factory call executes unconditionally at module scope. */
|
|
382
|
+
function isModuleScopeCall(node) {
|
|
383
|
+
let current = node.parent;
|
|
384
|
+
while (current && current.type !== "Program") {
|
|
385
|
+
if (nestedScopeTypes.has(current.type)) return false;
|
|
386
|
+
current = current.parent;
|
|
387
|
+
}
|
|
388
|
+
return current?.type === "Program";
|
|
389
|
+
}
|
|
390
|
+
const factoryAtModuleScope = createRule({
|
|
391
|
+
name: "factory-at-module-scope",
|
|
392
|
+
meta: {
|
|
393
|
+
type: "problem",
|
|
394
|
+
docs: { description: "Require Kerros factories to run once at module scope." },
|
|
395
|
+
schema: [],
|
|
396
|
+
messages: { moduleScope: "Kerros factories must be called at module scope." }
|
|
397
|
+
},
|
|
398
|
+
defaultOptions: [],
|
|
399
|
+
create(context) {
|
|
400
|
+
const { getFactoryKind } = createKerrosTypeTools(context);
|
|
401
|
+
return { CallExpression(node) {
|
|
402
|
+
if (getFactoryKind(node) && !isModuleScopeCall(node)) context.report({
|
|
403
|
+
node,
|
|
404
|
+
messageId: "moduleScope"
|
|
405
|
+
});
|
|
406
|
+
} };
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
//#endregion
|
|
410
|
+
//#region src/rules/model-convention.ts
|
|
411
|
+
const modelNamePattern = /^use[A-Z][A-Za-z0-9]*Model$/u;
|
|
412
|
+
const modelConvention = createRule({
|
|
413
|
+
name: "model-convention",
|
|
414
|
+
meta: {
|
|
415
|
+
type: "problem",
|
|
416
|
+
docs: { description: "Require createStore models to be named module-level Hooks." },
|
|
417
|
+
schema: [],
|
|
418
|
+
messages: {
|
|
419
|
+
anonymousModel: "createStore requires a named model Hook.",
|
|
420
|
+
modelName: "The model Hook must be named useXxxModel.",
|
|
421
|
+
moduleModel: "The model Hook must be declared at module scope."
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
defaultOptions: [],
|
|
425
|
+
create(context) {
|
|
426
|
+
const { getFactoryKind, getIdentifierSymbol } = createKerrosTypeTools(context);
|
|
427
|
+
return { CallExpression(node) {
|
|
428
|
+
if (getFactoryKind(node) !== "createStore") return;
|
|
429
|
+
const model = node.arguments[0];
|
|
430
|
+
if (!model || model.type === "SpreadElement") return;
|
|
431
|
+
if (model.type !== "Identifier") {
|
|
432
|
+
context.report({
|
|
433
|
+
node: model,
|
|
434
|
+
messageId: "anonymousModel"
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (!modelNamePattern.test(model.name)) context.report({
|
|
439
|
+
node: model,
|
|
440
|
+
messageId: "modelName"
|
|
441
|
+
});
|
|
442
|
+
const symbol = getIdentifierSymbol(model);
|
|
443
|
+
if (symbol && symbol.declarations?.every((declaration) => !isModuleDeclaration(declaration))) context.report({
|
|
444
|
+
node: model,
|
|
445
|
+
messageId: "moduleModel"
|
|
446
|
+
});
|
|
447
|
+
} };
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/rules/no-broad-store-access.ts
|
|
452
|
+
const objectEnumerationMethods = /* @__PURE__ */ new Set([
|
|
453
|
+
"entries",
|
|
454
|
+
"keys",
|
|
455
|
+
"values"
|
|
456
|
+
]);
|
|
457
|
+
/** Test whether a call enumerates or serializes its Store snapshot argument. */
|
|
458
|
+
function isBroadConsumer(parent, node) {
|
|
459
|
+
if (parent?.type !== "CallExpression" || !parent.arguments.includes(node)) return false;
|
|
460
|
+
const { callee } = parent;
|
|
461
|
+
if (callee.type !== "MemberExpression" || callee.computed) return false;
|
|
462
|
+
if (callee.object.type === "Identifier" && callee.property.type === "Identifier") {
|
|
463
|
+
if (callee.object.name === "Object" && objectEnumerationMethods.has(callee.property.name)) return true;
|
|
464
|
+
return callee.object.name === "JSON" && callee.property.name === "stringify";
|
|
465
|
+
}
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
/** Test whether object syntax expands the complete Store snapshot. */
|
|
469
|
+
function isBroadSyntax(parent, node) {
|
|
470
|
+
if (parent?.type === "SpreadElement" && parent.argument === node) return true;
|
|
471
|
+
if (parent?.type !== "VariableDeclarator" || parent.init !== node || parent.id.type !== "ObjectPattern") return false;
|
|
472
|
+
return parent.id.properties.some((property) => property.type === "RestElement");
|
|
473
|
+
}
|
|
474
|
+
const noBroadStoreAccess = createRule({
|
|
475
|
+
name: "no-broad-store-access",
|
|
476
|
+
meta: {
|
|
477
|
+
type: "problem",
|
|
478
|
+
docs: { description: "Prevent complete Store enumeration and serialization." },
|
|
479
|
+
schema: [],
|
|
480
|
+
messages: { broadAccess: "Do not enumerate or spread the complete Store snapshot." }
|
|
481
|
+
},
|
|
482
|
+
defaultOptions: [],
|
|
483
|
+
create(context) {
|
|
484
|
+
const { isStoreHookCall } = createKerrosTypeTools(context);
|
|
485
|
+
return { CallExpression(node) {
|
|
486
|
+
if (node.arguments.length > 0 || !isStoreHookCall(node)) return;
|
|
487
|
+
if (isBroadSyntax(node.parent, node) || isBroadConsumer(node.parent, node)) context.report({
|
|
488
|
+
node,
|
|
489
|
+
messageId: "broadAccess"
|
|
490
|
+
});
|
|
491
|
+
} };
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
//#endregion
|
|
495
|
+
//#region src/internal/store-dependency-graph.ts
|
|
496
|
+
const programGraphCache = /* @__PURE__ */ new WeakMap();
|
|
497
|
+
const sourceDependencyCache = /* @__PURE__ */ new WeakMap();
|
|
498
|
+
/** Visit a TypeScript tree iteratively so large source files do not consume the call stack. */
|
|
499
|
+
function forEachTsNode(root, visit) {
|
|
500
|
+
const stack = [root];
|
|
501
|
+
while (stack.length > 0) {
|
|
502
|
+
const node = stack.pop();
|
|
503
|
+
if (!node) continue;
|
|
504
|
+
visit(node);
|
|
505
|
+
const children = [];
|
|
506
|
+
typescript.default.forEachChild(node, (child) => {
|
|
507
|
+
children.push(child);
|
|
508
|
+
});
|
|
509
|
+
for (let index = children.length - 1; index >= 0; index -= 1) stack.push(children[index]);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/** Resolve the first tuple binding that owns a direct factory result. */
|
|
513
|
+
function getFactoryHookBinding(call) {
|
|
514
|
+
let expression = call;
|
|
515
|
+
let parent = expression.parent;
|
|
516
|
+
while ((typescript.default.isParenthesizedExpression(parent) || typescript.default.isAsExpression(parent) || typescript.default.isTypeAssertionExpression(parent) || typescript.default.isNonNullExpression(parent) || typescript.default.isSatisfiesExpression(parent)) && parent.expression === expression) {
|
|
517
|
+
expression = parent;
|
|
518
|
+
parent = expression.parent;
|
|
519
|
+
}
|
|
520
|
+
if (!typescript.default.isVariableDeclaration(parent) || parent.initializer !== expression || !typescript.default.isArrayBindingPattern(parent.name)) return;
|
|
521
|
+
const first = parent.name.elements[0];
|
|
522
|
+
return first && typescript.default.isBindingElement(first) && typescript.default.isIdentifier(first.name) ? first.name : void 0;
|
|
523
|
+
}
|
|
524
|
+
/** Collect real Kerros Store bindings from all user source files in one Program. */
|
|
525
|
+
function collectStoreNodes(program) {
|
|
526
|
+
const tools = createKerrosProgramTools(program);
|
|
527
|
+
const nodes = [];
|
|
528
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
529
|
+
if (sourceFile.isDeclarationFile || program.isSourceFileDefaultLibrary(sourceFile) || program.isSourceFileFromExternalLibrary(sourceFile)) continue;
|
|
530
|
+
forEachTsNode(sourceFile, (node) => {
|
|
531
|
+
if (!typescript.default.isCallExpression(node)) return;
|
|
532
|
+
const kind = tools.getFactoryKind(node);
|
|
533
|
+
const binding = kind ? getFactoryHookBinding(node) : void 0;
|
|
534
|
+
const hook = binding ? tools.getTsSymbol(binding) : void 0;
|
|
535
|
+
if (!kind || !hook) return;
|
|
536
|
+
const modelInput = kind === "createStore" ? node.arguments[0] : void 0;
|
|
537
|
+
const model = modelInput ? tools.getModelFunction(modelInput) : void 0;
|
|
538
|
+
nodes.push({
|
|
539
|
+
call: node,
|
|
540
|
+
hook,
|
|
541
|
+
id: nodes.length,
|
|
542
|
+
kind,
|
|
543
|
+
model,
|
|
544
|
+
name: hook.getName()
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
nodes,
|
|
550
|
+
tools
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
/** Collect Store Hook calls reached synchronously from one createStore model. */
|
|
554
|
+
function collectModelDependencies(source, storesByHook, tools) {
|
|
555
|
+
const dependencies = [];
|
|
556
|
+
const visitedFunctions = /* @__PURE__ */ new Set();
|
|
557
|
+
const pendingFunctions = source.model ? [source.model] : [];
|
|
558
|
+
while (pendingFunctions.length > 0) {
|
|
559
|
+
const fn = pendingFunctions.pop();
|
|
560
|
+
if (!fn || visitedFunctions.has(fn)) continue;
|
|
561
|
+
visitedFunctions.add(fn);
|
|
562
|
+
const root = fn.body;
|
|
563
|
+
if (!root) continue;
|
|
564
|
+
const stack = [root];
|
|
565
|
+
while (stack.length > 0) {
|
|
566
|
+
const node = stack.pop();
|
|
567
|
+
if (!node) continue;
|
|
568
|
+
if (node !== root && typescript.default.isFunctionLike(node)) continue;
|
|
569
|
+
if (typescript.default.isCallExpression(node)) {
|
|
570
|
+
const hook = tools.getTsSymbol(node.expression);
|
|
571
|
+
const target = hook ? storesByHook.get(hook) : void 0;
|
|
572
|
+
if (target) dependencies.push({
|
|
573
|
+
site: node,
|
|
574
|
+
source,
|
|
575
|
+
target
|
|
576
|
+
});
|
|
577
|
+
else {
|
|
578
|
+
const calledFunction = tools.getModelFunction(node.expression);
|
|
579
|
+
if (calledFunction && !visitedFunctions.has(calledFunction)) pendingFunctions.push(calledFunction);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const children = [];
|
|
583
|
+
typescript.default.forEachChild(node, (child) => {
|
|
584
|
+
children.push(child);
|
|
585
|
+
});
|
|
586
|
+
for (let index = children.length - 1; index >= 0; index -= 1) stack.push(children[index]);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return dependencies;
|
|
590
|
+
}
|
|
591
|
+
/** Find strongly connected components with iterative Kosaraju passes. */
|
|
592
|
+
function getStronglyConnectedComponents(adjacency) {
|
|
593
|
+
const visited = new Uint8Array(adjacency.length);
|
|
594
|
+
const finishOrder = [];
|
|
595
|
+
for (let start = 0; start < adjacency.length; start += 1) {
|
|
596
|
+
if (visited[start] === 1) continue;
|
|
597
|
+
visited[start] = 1;
|
|
598
|
+
const stack = [{
|
|
599
|
+
index: 0,
|
|
600
|
+
node: start
|
|
601
|
+
}];
|
|
602
|
+
while (stack.length > 0) {
|
|
603
|
+
const frame = stack.at(-1);
|
|
604
|
+
if (!frame) break;
|
|
605
|
+
const neighbor = adjacency[frame.node][frame.index];
|
|
606
|
+
if (neighbor !== void 0) {
|
|
607
|
+
frame.index += 1;
|
|
608
|
+
if (visited[neighbor] === 0) {
|
|
609
|
+
visited[neighbor] = 1;
|
|
610
|
+
stack.push({
|
|
611
|
+
index: 0,
|
|
612
|
+
node: neighbor
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
finishOrder.push(frame.node);
|
|
618
|
+
stack.pop();
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const reverse = Array.from({ length: adjacency.length }, () => []);
|
|
622
|
+
for (let source = 0; source < adjacency.length; source += 1) for (const target of adjacency[source]) reverse[target].push(source);
|
|
623
|
+
visited.fill(0);
|
|
624
|
+
const components = [];
|
|
625
|
+
for (let index = finishOrder.length - 1; index >= 0; index -= 1) {
|
|
626
|
+
const start = finishOrder[index];
|
|
627
|
+
if (visited[start] === 1) continue;
|
|
628
|
+
const component = [];
|
|
629
|
+
const stack = [start];
|
|
630
|
+
visited[start] = 1;
|
|
631
|
+
while (stack.length > 0) {
|
|
632
|
+
const node = stack.pop();
|
|
633
|
+
if (node === void 0) continue;
|
|
634
|
+
component.push(node);
|
|
635
|
+
for (const neighbor of reverse[node]) if (visited[neighbor] === 0) {
|
|
636
|
+
visited[neighbor] = 1;
|
|
637
|
+
stack.push(neighbor);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
components.push(component);
|
|
641
|
+
}
|
|
642
|
+
return components;
|
|
643
|
+
}
|
|
644
|
+
/** Compare dependency sites by file and source position for stable diagnostics. */
|
|
645
|
+
function compareDependencies(left, right) {
|
|
646
|
+
const leftFile = left.site.getSourceFile().fileName;
|
|
647
|
+
const rightFile = right.site.getSourceFile().fileName;
|
|
648
|
+
return leftFile.localeCompare(rightFile) || left.site.getStart() - right.site.getStart() || left.target.id - right.target.id;
|
|
649
|
+
}
|
|
650
|
+
/** Select bounded, deterministic diagnostics from cyclic graph components. */
|
|
651
|
+
function collectCyclicDependencies(nodes, dependencies) {
|
|
652
|
+
const adjacencySets = Array.from({ length: nodes.length }, () => /* @__PURE__ */ new Set());
|
|
653
|
+
const dependenciesBySource = /* @__PURE__ */ new Map();
|
|
654
|
+
for (const dependency of dependencies) {
|
|
655
|
+
adjacencySets[dependency.source.id].add(dependency.target.id);
|
|
656
|
+
const existing = dependenciesBySource.get(dependency.source.id) ?? [];
|
|
657
|
+
existing.push(dependency);
|
|
658
|
+
dependenciesBySource.set(dependency.source.id, existing);
|
|
659
|
+
}
|
|
660
|
+
const components = getStronglyConnectedComponents(adjacencySets.map((targets) => [...targets]));
|
|
661
|
+
const cyclicDependencies = [];
|
|
662
|
+
for (const component of components) {
|
|
663
|
+
if (!(component.length > 1 || component[0] !== void 0 && adjacencySets[component[0]].has(component[0]))) continue;
|
|
664
|
+
const members = new Set(component);
|
|
665
|
+
component.sort((left, right) => left - right);
|
|
666
|
+
for (const source of component) {
|
|
667
|
+
const dependency = (dependenciesBySource.get(source) ?? []).filter((candidate) => members.has(candidate.target.id)).sort(compareDependencies)[0];
|
|
668
|
+
if (dependency) cyclicDependencies.push(dependency);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return cyclicDependencies.sort(compareDependencies);
|
|
672
|
+
}
|
|
673
|
+
/** Build and cache the complete Store graph once for a TypeScript Program. */
|
|
674
|
+
function getProgramStoreGraph(program) {
|
|
675
|
+
const cached = programGraphCache.get(program);
|
|
676
|
+
if (cached) return cached;
|
|
677
|
+
const { nodes, tools } = collectStoreNodes(program);
|
|
678
|
+
const storesByHook = new Map(nodes.map((node) => [node.hook, node]));
|
|
679
|
+
const graph = { cyclicDependencies: collectCyclicDependencies(nodes, nodes.flatMap((node) => {
|
|
680
|
+
return node.kind === "createStore" ? collectModelDependencies(node, storesByHook, tools) : [];
|
|
681
|
+
})) };
|
|
682
|
+
programGraphCache.set(program, graph);
|
|
683
|
+
return graph;
|
|
684
|
+
}
|
|
685
|
+
/** Read cached cyclic dependency sites belonging to one current source file. */
|
|
686
|
+
function getCyclicStoreDependencies(program, sourceFile) {
|
|
687
|
+
let sourceFiles = sourceDependencyCache.get(program);
|
|
688
|
+
if (!sourceFiles) {
|
|
689
|
+
sourceFiles = /* @__PURE__ */ new WeakMap();
|
|
690
|
+
sourceDependencyCache.set(program, sourceFiles);
|
|
691
|
+
}
|
|
692
|
+
const cached = sourceFiles.get(sourceFile);
|
|
693
|
+
if (cached) return cached;
|
|
694
|
+
const dependencies = getProgramStoreGraph(program).cyclicDependencies.filter((dependency) => dependency.site.getSourceFile() === sourceFile).map((dependency) => ({
|
|
695
|
+
site: dependency.site,
|
|
696
|
+
source: dependency.source.name,
|
|
697
|
+
target: dependency.target.name
|
|
698
|
+
}));
|
|
699
|
+
sourceFiles.set(sourceFile, dependencies);
|
|
700
|
+
return dependencies;
|
|
701
|
+
}
|
|
702
|
+
//#endregion
|
|
703
|
+
//#region src/rules/no-cyclic-store-dependency.ts
|
|
704
|
+
const noCyclicStoreDependency = createRule({
|
|
705
|
+
name: "no-cyclic-store-dependency",
|
|
706
|
+
meta: {
|
|
707
|
+
type: "problem",
|
|
708
|
+
docs: { description: "Prevent createStore models from forming Store dependency cycles." },
|
|
709
|
+
schema: [],
|
|
710
|
+
messages: { cyclicDependency: "Store \"{{source}}\" depends on \"{{target}}\" in a dependency cycle." }
|
|
711
|
+
},
|
|
712
|
+
defaultOptions: [],
|
|
713
|
+
create(context) {
|
|
714
|
+
const services = getTypeServices(context);
|
|
715
|
+
const program = services.program;
|
|
716
|
+
const sourceFile = services.esTreeNodeToTSNodeMap.get(context.sourceCode.ast);
|
|
717
|
+
return { "Program:exit"() {
|
|
718
|
+
if (!typescript.default.isSourceFile(sourceFile)) return;
|
|
719
|
+
for (const dependency of getCyclicStoreDependencies(program, sourceFile)) {
|
|
720
|
+
const node = services.tsNodeToESTreeNodeMap.get(dependency.site);
|
|
721
|
+
if (!node) continue;
|
|
722
|
+
context.report({
|
|
723
|
+
node,
|
|
724
|
+
messageId: "cyclicDependency",
|
|
725
|
+
data: {
|
|
726
|
+
source: dependency.source,
|
|
727
|
+
target: dependency.target
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
} };
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
//#endregion
|
|
735
|
+
//#region src/internal/semantic.ts
|
|
736
|
+
const arrayMutationMethods = /* @__PURE__ */ new Set([
|
|
737
|
+
"copyWithin",
|
|
738
|
+
"fill",
|
|
739
|
+
"pop",
|
|
740
|
+
"push",
|
|
741
|
+
"reverse",
|
|
742
|
+
"shift",
|
|
743
|
+
"sort",
|
|
744
|
+
"splice",
|
|
745
|
+
"unshift"
|
|
746
|
+
]);
|
|
747
|
+
const mapMutationMethods = /* @__PURE__ */ new Set([
|
|
748
|
+
"clear",
|
|
749
|
+
"delete",
|
|
750
|
+
"set"
|
|
751
|
+
]);
|
|
752
|
+
const setMutationMethods = /* @__PURE__ */ new Set([
|
|
753
|
+
"add",
|
|
754
|
+
"clear",
|
|
755
|
+
"delete"
|
|
756
|
+
]);
|
|
757
|
+
/** Build separate dynamic call-site contexts for one local function. */
|
|
758
|
+
function getFunctionCallSiteContexts(target, edges) {
|
|
759
|
+
const incoming = /* @__PURE__ */ new Map();
|
|
760
|
+
for (const edge of edges) {
|
|
761
|
+
const existing = incoming.get(edge.callee) ?? [];
|
|
762
|
+
existing.push(edge);
|
|
763
|
+
incoming.set(edge.callee, existing);
|
|
764
|
+
}
|
|
765
|
+
const contexts = [];
|
|
766
|
+
/** Trace callers independently so states from different invocation paths never merge globally. */
|
|
767
|
+
const trace = (fn, calls, stack) => {
|
|
768
|
+
const edgesForFunction = incoming.get(fn) ?? [];
|
|
769
|
+
let advanced = false;
|
|
770
|
+
for (const edge of edgesForFunction) {
|
|
771
|
+
if (stack.has(edge.caller)) continue;
|
|
772
|
+
advanced = true;
|
|
773
|
+
const nextCalls = new Map(calls);
|
|
774
|
+
nextCalls.set(fn, edge.site);
|
|
775
|
+
const nextStack = new Set(stack);
|
|
776
|
+
nextStack.add(edge.caller);
|
|
777
|
+
trace(edge.caller, nextCalls, nextStack);
|
|
778
|
+
}
|
|
779
|
+
if (!advanced) contexts.push(calls);
|
|
780
|
+
};
|
|
781
|
+
trace(target, /* @__PURE__ */ new Map(), /* @__PURE__ */ new Set([target]));
|
|
782
|
+
return contexts;
|
|
783
|
+
}
|
|
784
|
+
/** Track assignment sources and resolve definitions that reach a concrete reference point. */
|
|
785
|
+
function createReferenceOriginTracker(program) {
|
|
786
|
+
const events = /* @__PURE__ */ new Map();
|
|
787
|
+
/** Find the function execution scope containing one syntax node. */
|
|
788
|
+
const getOwner = (input) => {
|
|
789
|
+
let node = input.parent;
|
|
790
|
+
while (node) {
|
|
791
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionDeclaration" || node.type === "FunctionExpression") return node;
|
|
792
|
+
node = node.parent;
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
/** Test whether one syntax range contains another. */
|
|
796
|
+
const contains = (container, target) => {
|
|
797
|
+
return container.range[0] <= target.range[0] && container.range[1] >= target.range[1];
|
|
798
|
+
};
|
|
799
|
+
/** Merge branch states without duplicating the same reaching write. */
|
|
800
|
+
const merge = (left, right) => {
|
|
801
|
+
return [.../* @__PURE__ */ new Set([...left, ...right])];
|
|
802
|
+
};
|
|
803
|
+
/** Test whether a simple statement cannot continue into its following sibling. */
|
|
804
|
+
const terminates = (node) => {
|
|
805
|
+
if (node.type === "ReturnStatement" || node.type === "ThrowStatement") return true;
|
|
806
|
+
if (node.type === "BlockStatement") {
|
|
807
|
+
const last = node.body.at(-1);
|
|
808
|
+
return last ? terminates(last) : false;
|
|
809
|
+
}
|
|
810
|
+
if (node.type === "IfStatement" && node.alternate) return terminates(node.consequent) && terminates(node.alternate);
|
|
811
|
+
if (node.type === "LabeledStatement") return terminates(node.body);
|
|
812
|
+
return false;
|
|
813
|
+
};
|
|
814
|
+
/** Record one initializer or assignment after its right-hand side is evaluated. */
|
|
815
|
+
const record = (symbol, source, write) => {
|
|
816
|
+
const existing = events.get(symbol) ?? [];
|
|
817
|
+
existing.push({
|
|
818
|
+
owner: getOwner(write),
|
|
819
|
+
source,
|
|
820
|
+
write
|
|
821
|
+
});
|
|
822
|
+
events.set(symbol, existing);
|
|
823
|
+
};
|
|
824
|
+
/** Resolve the possible definitions reaching one symbol reference. */
|
|
825
|
+
const resolve = (symbol, reference, calls) => {
|
|
826
|
+
const symbolEvents = events.get(symbol) ?? [];
|
|
827
|
+
let functions = [];
|
|
828
|
+
let parent = reference.parent;
|
|
829
|
+
while (parent) {
|
|
830
|
+
if (parent.type === "ArrowFunctionExpression" || parent.type === "FunctionDeclaration" || parent.type === "FunctionExpression") functions.push(parent);
|
|
831
|
+
parent = parent.parent;
|
|
832
|
+
}
|
|
833
|
+
functions.reverse();
|
|
834
|
+
if (calls && calls.size > 0) {
|
|
835
|
+
const dynamicFunctions = [];
|
|
836
|
+
const seenFunctions = /* @__PURE__ */ new Set();
|
|
837
|
+
let fn = getOwner(reference);
|
|
838
|
+
while (fn && !seenFunctions.has(fn)) {
|
|
839
|
+
seenFunctions.add(fn);
|
|
840
|
+
dynamicFunctions.unshift(fn);
|
|
841
|
+
const site = calls.get(fn);
|
|
842
|
+
fn = site ? getOwner(site) : void 0;
|
|
843
|
+
}
|
|
844
|
+
if (dynamicFunctions.length > 0) functions = dynamicFunctions;
|
|
845
|
+
}
|
|
846
|
+
/** Apply straight-line writes in runtime order up to an optional point. */
|
|
847
|
+
const applyEvents = (container, owner, state, limit = container.range[1]) => {
|
|
848
|
+
const applicable = symbolEvents.filter((event) => {
|
|
849
|
+
return event.owner === owner && contains(container, event.write) && event.write.range[1] <= limit;
|
|
850
|
+
}).sort((left, right) => {
|
|
851
|
+
return left.write.range[1] - right.write.range[1] || right.write.range[0] - left.write.range[0];
|
|
852
|
+
});
|
|
853
|
+
for (const event of applicable) state = [event];
|
|
854
|
+
return state;
|
|
855
|
+
};
|
|
856
|
+
/** Evaluate one statement completely, merging simple conditional branches. */
|
|
857
|
+
const flowFull = (node, owner, state) => {
|
|
858
|
+
if (node.type === "BlockStatement") return flowSequence(node.body, owner, state);
|
|
859
|
+
if (node.type !== "IfStatement") return applyEvents(node, owner, state);
|
|
860
|
+
const tested = applyEvents(node.test, owner, state);
|
|
861
|
+
const consequent = flowFull(node.consequent, owner, tested);
|
|
862
|
+
const alternate = node.alternate ? flowFull(node.alternate, owner, tested) : tested;
|
|
863
|
+
const consequentContinues = !terminates(node.consequent);
|
|
864
|
+
const alternateContinues = !node.alternate || !terminates(node.alternate);
|
|
865
|
+
if (!consequentContinues) return alternateContinues ? alternate : [];
|
|
866
|
+
if (!alternateContinues) return consequent;
|
|
867
|
+
return merge(consequent, alternate);
|
|
868
|
+
};
|
|
869
|
+
/** Evaluate one statement only until the requested reference point. */
|
|
870
|
+
const flowUntil = (node, owner, state, target) => {
|
|
871
|
+
if (node.type === "BlockStatement") return flowSequence(node.body, owner, state, target);
|
|
872
|
+
if (node.type !== "IfStatement") return applyEvents(node, owner, state, target.range[0]);
|
|
873
|
+
if (contains(node.test, target)) return applyEvents(node.test, owner, state, target.range[0]);
|
|
874
|
+
const tested = applyEvents(node.test, owner, state);
|
|
875
|
+
if (contains(node.consequent, target)) return flowUntil(node.consequent, owner, tested, target);
|
|
876
|
+
if (node.alternate && contains(node.alternate, target)) return flowUntil(node.alternate, owner, tested, target);
|
|
877
|
+
return tested;
|
|
878
|
+
};
|
|
879
|
+
/** Evaluate a lexical statement sequence, stopping before one nested target. */
|
|
880
|
+
function flowSequence(nodes, owner, input, target) {
|
|
881
|
+
let state = input;
|
|
882
|
+
for (const node of nodes) {
|
|
883
|
+
if (target && contains(node, target)) return flowUntil(node, owner, state, target);
|
|
884
|
+
if (target && node.range[0] >= target.range[0]) return state;
|
|
885
|
+
state = flowFull(node, owner, state);
|
|
886
|
+
}
|
|
887
|
+
return state;
|
|
888
|
+
}
|
|
889
|
+
/** Evaluate one program or function scope up to a nested function/reference. */
|
|
890
|
+
const flowScope = (root, target, state) => {
|
|
891
|
+
const owner = root.type === "Program" ? void 0 : root;
|
|
892
|
+
if (root.type === "Program") return flowSequence(root.body, owner, state, target);
|
|
893
|
+
return root.body.type === "BlockStatement" ? flowSequence(root.body.body, owner, state, target) : flowUntil(root.body, owner, state, target);
|
|
894
|
+
};
|
|
895
|
+
let state = [];
|
|
896
|
+
let root = program;
|
|
897
|
+
for (const fn of functions) {
|
|
898
|
+
state = flowScope(root, calls?.get(fn) ?? fn, state);
|
|
899
|
+
root = fn;
|
|
900
|
+
}
|
|
901
|
+
state = flowScope(root, reference, state);
|
|
902
|
+
return state.map((event) => event.source);
|
|
903
|
+
};
|
|
904
|
+
return {
|
|
905
|
+
record,
|
|
906
|
+
resolve
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
/** Return an inline selector from a nominal Store Hook call. */
|
|
910
|
+
function getInlineSelector(node, isStoreHookCall) {
|
|
911
|
+
if (!isStoreHookCall(node)) return void 0;
|
|
912
|
+
const selector = node.arguments[0];
|
|
913
|
+
return selector?.type === "ArrowFunctionExpression" || selector?.type === "FunctionExpression" ? selector : void 0;
|
|
914
|
+
}
|
|
915
|
+
/** Visit a syntax subtree while ignoring parser metadata and optional nested functions. */
|
|
916
|
+
function visitSubtree(root, visitor, skipNestedFunctions = false) {
|
|
917
|
+
const visit = (node) => {
|
|
918
|
+
if (node !== root && skipNestedFunctions && (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression" || node.type === "FunctionDeclaration")) return;
|
|
919
|
+
visitor(node);
|
|
920
|
+
for (const key of Object.keys(node)) {
|
|
921
|
+
if (key === "parent" || key === "range" || key === "loc") continue;
|
|
922
|
+
const value = node[key];
|
|
923
|
+
if (Array.isArray(value)) {
|
|
924
|
+
for (const child of value) if (child && typeof child === "object" && "type" in child) visit(child);
|
|
925
|
+
} else if (value && typeof value === "object" && "type" in value) visit(value);
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
visit(root);
|
|
929
|
+
}
|
|
930
|
+
/** Return the statically known property name for member access. */
|
|
931
|
+
function getMemberName(node) {
|
|
932
|
+
if (!node.computed && node.property.type === "Identifier") return node.property.name;
|
|
933
|
+
if (node.computed && node.property.type === "Literal" && typeof node.property.value === "string") return node.property.value;
|
|
934
|
+
}
|
|
935
|
+
/** Classify runtime built-ins by TypeScript's default-library declarations. */
|
|
936
|
+
function getBuiltinTypeKind(checker, program, inputType) {
|
|
937
|
+
const type = checker.getBaseConstraintOfType(inputType) ?? inputType;
|
|
938
|
+
if (type.isUnion() || type.isIntersection()) {
|
|
939
|
+
for (const member of type.types) {
|
|
940
|
+
const kind = getBuiltinTypeKind(checker, program, member);
|
|
941
|
+
if (kind) return kind;
|
|
942
|
+
}
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (checker.isArrayType(type) || checker.isTupleType(type)) return "array";
|
|
946
|
+
if ((type.flags & typescript.default.TypeFlags.StringLike) !== 0) return "string";
|
|
947
|
+
for (const symbol of [type.getSymbol(), type.aliasSymbol]) {
|
|
948
|
+
const name = symbol?.getName();
|
|
949
|
+
if (!(symbol?.declarations?.some((declaration) => {
|
|
950
|
+
return program.isSourceFileDefaultLibrary(declaration.getSourceFile());
|
|
951
|
+
}) === true)) continue;
|
|
952
|
+
if (name === "Map" || name === "ReadonlyMap") return "map";
|
|
953
|
+
if (name === "Set" || name === "ReadonlySet") return "set";
|
|
954
|
+
if (name === "String") return "string";
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
/** Test whether a call invokes a known mutable collection method. */
|
|
958
|
+
function isMutableCollectionCall(node, checker, program, getType) {
|
|
959
|
+
const callee = unwrapExpression(node.callee);
|
|
960
|
+
if (callee.type !== "MemberExpression") return false;
|
|
961
|
+
const name = getMemberName(callee);
|
|
962
|
+
if (!name) return false;
|
|
963
|
+
const kind = getBuiltinTypeKind(checker, program, getType(callee.object));
|
|
964
|
+
if (kind === "array") return arrayMutationMethods.has(name);
|
|
965
|
+
if (kind === "map") return mapMutationMethods.has(name);
|
|
966
|
+
if (kind === "set") return setMutationMethods.has(name);
|
|
967
|
+
return false;
|
|
968
|
+
}
|
|
969
|
+
/** Test whether TypeScript proves a value is primitive across unions and constraints. */
|
|
970
|
+
function isPrimitiveType(checker, inputType) {
|
|
971
|
+
const type = checker.getBaseConstraintOfType(inputType) ?? inputType;
|
|
972
|
+
if (type.isUnion()) return type.types.every((member) => isPrimitiveType(checker, member));
|
|
973
|
+
if (type.isIntersection()) return type.types.some((member) => isPrimitiveType(checker, member));
|
|
974
|
+
const primitiveFlags = typescript.default.TypeFlags.StringLike | typescript.default.TypeFlags.NumberLike | typescript.default.TypeFlags.BigIntLike | typescript.default.TypeFlags.BooleanLike | typescript.default.TypeFlags.ESSymbolLike | typescript.default.TypeFlags.Null | typescript.default.TypeFlags.Undefined | typescript.default.TypeFlags.Void | typescript.default.TypeFlags.Never;
|
|
975
|
+
return (type.flags & primitiveFlags) !== 0;
|
|
976
|
+
}
|
|
977
|
+
//#endregion
|
|
978
|
+
//#region src/internal/typescript.ts
|
|
979
|
+
/** Remove TypeScript expression wrappers that preserve runtime identity. */
|
|
980
|
+
function unwrapTsExpression(input) {
|
|
981
|
+
let node = input;
|
|
982
|
+
while (typescript.default.isParenthesizedExpression(node) || typescript.default.isAsExpression(node) || typescript.default.isTypeAssertionExpression(node) || typescript.default.isNonNullExpression(node) || typescript.default.isSatisfiesExpression(node)) node = node.expression;
|
|
983
|
+
return node;
|
|
984
|
+
}
|
|
985
|
+
/** Collect direct function return values without entering nested callbacks. */
|
|
986
|
+
function getTsReturnExpressions(fn) {
|
|
987
|
+
if (typescript.default.isArrowFunction(fn) && !typescript.default.isBlock(fn.body)) return [fn.body];
|
|
988
|
+
const expressions = [];
|
|
989
|
+
if (!fn.body) return expressions;
|
|
990
|
+
/** Scan control-flow branches owned by the current function. */
|
|
991
|
+
const visit = (node) => {
|
|
992
|
+
if (node !== fn.body && typescript.default.isFunctionLike(node)) return;
|
|
993
|
+
if (typescript.default.isReturnStatement(node) && node.expression) {
|
|
994
|
+
expressions.push(node.expression);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
typescript.default.forEachChild(node, visit);
|
|
998
|
+
};
|
|
999
|
+
visit(fn.body);
|
|
1000
|
+
return expressions;
|
|
1001
|
+
}
|
|
1002
|
+
//#endregion
|
|
1003
|
+
//#region src/rules/no-effect-event-action.ts
|
|
1004
|
+
const noEffectEventAction = createRule({
|
|
1005
|
+
name: "no-effect-event-action",
|
|
1006
|
+
meta: {
|
|
1007
|
+
type: "problem",
|
|
1008
|
+
docs: { description: "Prevent React Effect Events from becoming public Store actions." },
|
|
1009
|
+
schema: [],
|
|
1010
|
+
messages: { effectEventAction: "A useEffectEvent function cannot be exposed as a Store action." }
|
|
1011
|
+
},
|
|
1012
|
+
defaultOptions: [],
|
|
1013
|
+
create(context) {
|
|
1014
|
+
const { getFactoryKind, getIdentifierSymbol, getModelFunction, getTsNode, getTsSymbol, isReactCall, services } = createKerrosTypeTools(context);
|
|
1015
|
+
const origins = createReferenceOriginTracker(context.sourceCode.ast);
|
|
1016
|
+
const candidates = [];
|
|
1017
|
+
/** Test whether one model exposes an Effect Event in its returned Store object. */
|
|
1018
|
+
const exposesEffectEvent = (model) => {
|
|
1019
|
+
const writes = /* @__PURE__ */ new Map();
|
|
1020
|
+
if (!model.body) return false;
|
|
1021
|
+
/** Record one local value source at its execution point. */
|
|
1022
|
+
const record = (symbol, source, write) => {
|
|
1023
|
+
let conditional = false;
|
|
1024
|
+
let current = write.parent;
|
|
1025
|
+
while (current && current !== model) {
|
|
1026
|
+
if (typescript.default.isIfStatement(current) || typescript.default.isConditionalExpression(current) || typescript.default.isSwitchStatement(current) || typescript.default.isForStatement(current) || typescript.default.isForInStatement(current) || typescript.default.isForOfStatement(current) || typescript.default.isWhileStatement(current) || typescript.default.isTryStatement(current)) {
|
|
1027
|
+
conditional = true;
|
|
1028
|
+
break;
|
|
1029
|
+
}
|
|
1030
|
+
current = current.parent;
|
|
1031
|
+
}
|
|
1032
|
+
writes.set(symbol, [...writes.get(symbol) ?? [], {
|
|
1033
|
+
conditional,
|
|
1034
|
+
source,
|
|
1035
|
+
write
|
|
1036
|
+
}]);
|
|
1037
|
+
};
|
|
1038
|
+
/** Resolve straight-line overwrites while retaining conditional alternatives. */
|
|
1039
|
+
const getReachingWrites = (symbol, reference) => {
|
|
1040
|
+
let reaching = [];
|
|
1041
|
+
for (const candidate of writes.get(symbol) ?? []) {
|
|
1042
|
+
if (candidate.write.end > reference) continue;
|
|
1043
|
+
reaching = candidate.conditional ? [...reaching, candidate] : [candidate];
|
|
1044
|
+
}
|
|
1045
|
+
return reaching;
|
|
1046
|
+
};
|
|
1047
|
+
/** Prefer the shared flow tracker for current-file references, with TS fallback cross-file. */
|
|
1048
|
+
const getSources = (symbol, reference) => {
|
|
1049
|
+
const estreeReference = services.tsNodeToESTreeNodeMap.get(reference);
|
|
1050
|
+
if (estreeReference) {
|
|
1051
|
+
const expressions = [];
|
|
1052
|
+
for (const source of origins.resolve(symbol, estreeReference)) {
|
|
1053
|
+
const tsSource = getTsNode(source);
|
|
1054
|
+
if (typescript.default.isExpression(tsSource)) expressions.push(tsSource);
|
|
1055
|
+
}
|
|
1056
|
+
return expressions;
|
|
1057
|
+
}
|
|
1058
|
+
return getReachingWrites(symbol, reference.getStart()).map((source) => source.source);
|
|
1059
|
+
};
|
|
1060
|
+
/** Record local aliases and assignments owned by this model invocation. */
|
|
1061
|
+
const collectWrites = (node) => {
|
|
1062
|
+
if (node !== model.body && typescript.default.isFunctionLike(node)) return;
|
|
1063
|
+
if (typescript.default.isVariableDeclaration(node) && typescript.default.isIdentifier(node.name) && node.initializer) {
|
|
1064
|
+
const symbol = getTsSymbol(node.name);
|
|
1065
|
+
if (symbol) record(symbol, node.initializer, node);
|
|
1066
|
+
} else if (typescript.default.isBinaryExpression(node) && node.operatorToken.kind === typescript.default.SyntaxKind.EqualsToken && typescript.default.isIdentifier(node.left)) {
|
|
1067
|
+
const symbol = getTsSymbol(node.left);
|
|
1068
|
+
if (symbol) record(symbol, node.right, node);
|
|
1069
|
+
}
|
|
1070
|
+
typescript.default.forEachChild(node, collectWrites);
|
|
1071
|
+
};
|
|
1072
|
+
collectWrites(model.body);
|
|
1073
|
+
/** Resolve local aliases and logical branches of returned Store objects. */
|
|
1074
|
+
function getStoreObjects(input, reference = input.getStart(), seen = /* @__PURE__ */ new Set()) {
|
|
1075
|
+
const node = unwrapTsExpression(input);
|
|
1076
|
+
if (typescript.default.isObjectLiteralExpression(node)) return [node];
|
|
1077
|
+
if (typescript.default.isConditionalExpression(node)) return [...getStoreObjects(node.whenTrue, reference, seen), ...getStoreObjects(node.whenFalse, reference, seen)];
|
|
1078
|
+
if (typescript.default.isBinaryExpression(node) && (node.operatorToken.kind === typescript.default.SyntaxKind.AmpersandAmpersandToken || node.operatorToken.kind === typescript.default.SyntaxKind.BarBarToken || node.operatorToken.kind === typescript.default.SyntaxKind.QuestionQuestionToken)) return [...getStoreObjects(node.left, reference, seen), ...getStoreObjects(node.right, reference, seen)];
|
|
1079
|
+
if (typescript.default.isCommaListExpression(node)) {
|
|
1080
|
+
const value = node.elements.at(-1);
|
|
1081
|
+
return value ? getStoreObjects(value, reference, seen) : [];
|
|
1082
|
+
}
|
|
1083
|
+
if (!typescript.default.isIdentifier(node)) return [];
|
|
1084
|
+
const symbol = getTsSymbol(node);
|
|
1085
|
+
if (!symbol || seen.has(symbol)) return [];
|
|
1086
|
+
seen.add(symbol);
|
|
1087
|
+
const objects = getSources(symbol, node).flatMap((source) => {
|
|
1088
|
+
return getStoreObjects(source, source.getStart(), seen);
|
|
1089
|
+
});
|
|
1090
|
+
seen.delete(symbol);
|
|
1091
|
+
return objects;
|
|
1092
|
+
}
|
|
1093
|
+
/** Read a statically named TypeScript object property. */
|
|
1094
|
+
function getPropertyName(node) {
|
|
1095
|
+
if (typescript.default.isIdentifier(node) || typescript.default.isStringLiteral(node) || typescript.default.isNumericLiteral(node)) return node.text;
|
|
1096
|
+
}
|
|
1097
|
+
/** Test whether an expression originates from React useEffectEvent. */
|
|
1098
|
+
function isEffectEvent(input, reference = input.getStart(), seen = /* @__PURE__ */ new Set()) {
|
|
1099
|
+
const node = unwrapTsExpression(input);
|
|
1100
|
+
if (typescript.default.isCallExpression(node)) return isReactCall(node, "useEffectEvent");
|
|
1101
|
+
if (typescript.default.isConditionalExpression(node)) return isEffectEvent(node.whenTrue, reference, seen) || isEffectEvent(node.whenFalse, reference, seen);
|
|
1102
|
+
if (typescript.default.isBinaryExpression(node) && (node.operatorToken.kind === typescript.default.SyntaxKind.AmpersandAmpersandToken || node.operatorToken.kind === typescript.default.SyntaxKind.BarBarToken || node.operatorToken.kind === typescript.default.SyntaxKind.QuestionQuestionToken)) return isEffectEvent(node.left, reference, seen) || isEffectEvent(node.right, reference, seen);
|
|
1103
|
+
if (typescript.default.isPropertyAccessExpression(node) || typescript.default.isElementAccessExpression(node)) {
|
|
1104
|
+
const name = typescript.default.isPropertyAccessExpression(node) ? node.name.text : node.argumentExpression && typescript.default.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : void 0;
|
|
1105
|
+
if (!name) return false;
|
|
1106
|
+
return getStoreObjects(node.expression, reference).some((object) => {
|
|
1107
|
+
return object.properties.some((property) => {
|
|
1108
|
+
if (typescript.default.isShorthandPropertyAssignment(property)) return property.name.text === name && isEffectEvent(property.name);
|
|
1109
|
+
return typescript.default.isPropertyAssignment(property) && getPropertyName(property.name) === name && isEffectEvent(property.initializer);
|
|
1110
|
+
});
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
if (!typescript.default.isIdentifier(node)) return false;
|
|
1114
|
+
const symbol = getTsSymbol(node);
|
|
1115
|
+
if (!symbol || seen.has(symbol)) return false;
|
|
1116
|
+
seen.add(symbol);
|
|
1117
|
+
const effectEvent = getSources(symbol, node).some((source) => {
|
|
1118
|
+
return isEffectEvent(source, source.getStart(), seen);
|
|
1119
|
+
});
|
|
1120
|
+
seen.delete(symbol);
|
|
1121
|
+
return effectEvent;
|
|
1122
|
+
}
|
|
1123
|
+
/** Test Store properties and recursively expanded object spreads. */
|
|
1124
|
+
function objectContainsEffectEvent(object, seen = /* @__PURE__ */ new Set()) {
|
|
1125
|
+
if (seen.has(object)) return false;
|
|
1126
|
+
seen.add(object);
|
|
1127
|
+
for (const property of object.properties) {
|
|
1128
|
+
if (typescript.default.isSpreadAssignment(property)) {
|
|
1129
|
+
if (getStoreObjects(property.expression, property.getStart()).some((spread) => {
|
|
1130
|
+
return objectContainsEffectEvent(spread, seen);
|
|
1131
|
+
})) return true;
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
const value = typescript.default.isPropertyAssignment(property) ? property.initializer : typescript.default.isShorthandPropertyAssignment(property) ? property.name : void 0;
|
|
1135
|
+
if (value && isEffectEvent(value)) return true;
|
|
1136
|
+
}
|
|
1137
|
+
return false;
|
|
1138
|
+
}
|
|
1139
|
+
for (const returned of getTsReturnExpressions(model)) if (getStoreObjects(returned).some((object) => objectContainsEffectEvent(object))) return true;
|
|
1140
|
+
return false;
|
|
1141
|
+
};
|
|
1142
|
+
return {
|
|
1143
|
+
VariableDeclarator(node) {
|
|
1144
|
+
if (node.id.type !== "Identifier" || !node.init) return;
|
|
1145
|
+
const symbol = getIdentifierSymbol(node.id);
|
|
1146
|
+
if (symbol) origins.record(symbol, node.init, node);
|
|
1147
|
+
},
|
|
1148
|
+
AssignmentExpression(node) {
|
|
1149
|
+
if (node.left.type !== "Identifier") return;
|
|
1150
|
+
const symbol = getIdentifierSymbol(node.left);
|
|
1151
|
+
if (symbol) origins.record(symbol, node.right, node);
|
|
1152
|
+
},
|
|
1153
|
+
CallExpression(node) {
|
|
1154
|
+
if (getFactoryKind(node) !== "createStore") return;
|
|
1155
|
+
const model = node.arguments[0];
|
|
1156
|
+
if (!model || model.type === "SpreadElement") return;
|
|
1157
|
+
const declaration = getModelFunction(model);
|
|
1158
|
+
if (declaration) candidates.push({
|
|
1159
|
+
declaration,
|
|
1160
|
+
node: model
|
|
1161
|
+
});
|
|
1162
|
+
},
|
|
1163
|
+
"Program:exit"() {
|
|
1164
|
+
for (const candidate of candidates) if (exposesEffectEvent(candidate.declaration)) context.report({
|
|
1165
|
+
node: candidate.node,
|
|
1166
|
+
messageId: "effectEventAction"
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
//#endregion
|
|
1173
|
+
//#region src/rules/no-provider-key-prop.ts
|
|
1174
|
+
const noProviderKeyProp = createRule({
|
|
1175
|
+
name: "no-provider-key-prop",
|
|
1176
|
+
meta: {
|
|
1177
|
+
type: "problem",
|
|
1178
|
+
docs: { description: "Prevent createStore models from consuming React key as a Provider prop." },
|
|
1179
|
+
schema: [],
|
|
1180
|
+
messages: { keyProp: "React key is not a Provider prop and cannot be consumed by a model." }
|
|
1181
|
+
},
|
|
1182
|
+
defaultOptions: [],
|
|
1183
|
+
create(context) {
|
|
1184
|
+
const { checker, getFactoryKind, getType } = createKerrosTypeTools(context);
|
|
1185
|
+
return { CallExpression(node) {
|
|
1186
|
+
if (getFactoryKind(node) !== "createStore") return;
|
|
1187
|
+
const model = node.arguments[0];
|
|
1188
|
+
if (!model || model.type === "SpreadElement") return;
|
|
1189
|
+
const props = getType(model).getCallSignatures()[0]?.getParameters()[0];
|
|
1190
|
+
const declaration = props?.valueDeclaration ?? props?.declarations?.[0];
|
|
1191
|
+
if (!props || !declaration) return;
|
|
1192
|
+
const propsType = checker.getTypeOfSymbolAtLocation(props, declaration);
|
|
1193
|
+
if (getTypeProperty(checker, propsType, "key")) context.report({
|
|
1194
|
+
node: model,
|
|
1195
|
+
messageId: "keyProp"
|
|
1196
|
+
});
|
|
1197
|
+
} };
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
//#endregion
|
|
1201
|
+
//#region src/rules/no-render-instance-snapshot.ts
|
|
1202
|
+
const deferredReactHooks = /* @__PURE__ */ new Set([
|
|
1203
|
+
"useEffect",
|
|
1204
|
+
"useEffectEvent",
|
|
1205
|
+
"useInsertionEffect",
|
|
1206
|
+
"useLayoutEffect"
|
|
1207
|
+
]);
|
|
1208
|
+
const deferredGlobals = /* @__PURE__ */ new Set([
|
|
1209
|
+
"queueMicrotask",
|
|
1210
|
+
"setInterval",
|
|
1211
|
+
"setTimeout"
|
|
1212
|
+
]);
|
|
1213
|
+
const renderFunctionPattern$1 = /^(?:[A-Z]|use[A-Z])/u;
|
|
1214
|
+
/** Return the declaration identifier that owns a local function. */
|
|
1215
|
+
function getFunctionIdentifier$2(node) {
|
|
1216
|
+
if (node.type !== "ArrowFunctionExpression" && node.id) return node.id;
|
|
1217
|
+
const parent = node.parent;
|
|
1218
|
+
return parent?.type === "VariableDeclarator" && parent.id.type === "Identifier" ? parent.id : void 0;
|
|
1219
|
+
}
|
|
1220
|
+
/** Test whether an attribute is an intrinsic element event callback. */
|
|
1221
|
+
function isIntrinsicEventAttribute(node) {
|
|
1222
|
+
if (node.name.type !== "JSXIdentifier" || !/^on[A-Z]/u.test(node.name.name)) return false;
|
|
1223
|
+
const opening = node.parent;
|
|
1224
|
+
return opening?.type === "JSXOpeningElement" && opening.name.type === "JSXIdentifier" && /^[a-z]/u.test(opening.name.name);
|
|
1225
|
+
}
|
|
1226
|
+
const noRenderInstanceSnapshot = createRule({
|
|
1227
|
+
name: "no-render-instance-snapshot",
|
|
1228
|
+
meta: {
|
|
1229
|
+
type: "problem",
|
|
1230
|
+
docs: { description: "Prevent direct external Store snapshot reads during render." },
|
|
1231
|
+
schema: [],
|
|
1232
|
+
messages: { renderSnapshot: "Subscribe with the Store Hook instead of reading getSnapshot during render." }
|
|
1233
|
+
},
|
|
1234
|
+
defaultOptions: [],
|
|
1235
|
+
create(context) {
|
|
1236
|
+
const { checker, getIdentifierSymbol, isStoreInstanceHookCall, services } = createKerrosTypeTools(context);
|
|
1237
|
+
const functions = /* @__PURE__ */ new Set();
|
|
1238
|
+
const deferredFunctions = /* @__PURE__ */ new Set();
|
|
1239
|
+
const deferredSymbols = /* @__PURE__ */ new Set();
|
|
1240
|
+
const origins = createReferenceOriginTracker(context.sourceCode.ast);
|
|
1241
|
+
const snapshotReaders = /* @__PURE__ */ new Map();
|
|
1242
|
+
const calls = [];
|
|
1243
|
+
const immediateJsxCallbacks = [];
|
|
1244
|
+
const snapshots = [];
|
|
1245
|
+
/** Find the function whose body contains a syntax node. */
|
|
1246
|
+
const getOwner = (input) => {
|
|
1247
|
+
let node = input;
|
|
1248
|
+
while (node) {
|
|
1249
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionDeclaration" || node.type === "FunctionExpression") return node;
|
|
1250
|
+
node = node.parent;
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
/** Resolve a TypeScript symbol through import and export aliases. */
|
|
1254
|
+
const getResolvedSymbol = (node) => {
|
|
1255
|
+
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
|
|
1256
|
+
let symbol = checker.getSymbolAtLocation(tsNode);
|
|
1257
|
+
while (symbol && (symbol.flags & typescript.default.SymbolFlags.Alias) !== 0) symbol = checker.getAliasedSymbol(symbol);
|
|
1258
|
+
return symbol;
|
|
1259
|
+
};
|
|
1260
|
+
/** Test whether a call targets one of React's deferred callback Hooks. */
|
|
1261
|
+
const isDeferredReactCall = (node) => {
|
|
1262
|
+
const symbol = getResolvedSymbol(node.callee);
|
|
1263
|
+
if (!symbol || !deferredReactHooks.has(symbol.getName())) return false;
|
|
1264
|
+
return symbol.declarations?.some((declaration) => {
|
|
1265
|
+
return declaration.getSourceFile().fileName.replaceAll("\\", "/").includes("/node_modules/@types/react/");
|
|
1266
|
+
}) === true;
|
|
1267
|
+
};
|
|
1268
|
+
/** Test whether a call targets a real global scheduling function. */
|
|
1269
|
+
const isDeferredGlobalCall = (node) => {
|
|
1270
|
+
const symbol = getResolvedSymbol(node.callee);
|
|
1271
|
+
if (!symbol || !deferredGlobals.has(symbol.getName())) return false;
|
|
1272
|
+
return symbol.declarations?.some((declaration) => {
|
|
1273
|
+
const sourceFile = declaration.getSourceFile();
|
|
1274
|
+
const filename = sourceFile.fileName.replaceAll("\\", "/");
|
|
1275
|
+
return services.program.isSourceFileDefaultLibrary(sourceFile) || filename.includes("/node_modules/@types/node/");
|
|
1276
|
+
}) === true;
|
|
1277
|
+
};
|
|
1278
|
+
/** Test whether a call defers its first callback beyond render. */
|
|
1279
|
+
const isDeferredCall = (node) => {
|
|
1280
|
+
return isDeferredReactCall(node) || isDeferredGlobalCall(node);
|
|
1281
|
+
};
|
|
1282
|
+
/** Mark a direct function or referenced local function as deferred. */
|
|
1283
|
+
const markDeferred = (node) => {
|
|
1284
|
+
if (!node) return;
|
|
1285
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") {
|
|
1286
|
+
deferredFunctions.add(node);
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
if (node.type === "Identifier") {
|
|
1290
|
+
const symbol = getIdentifierSymbol(node);
|
|
1291
|
+
if (symbol) deferredSymbols.add(symbol);
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
/** Collect a function node for the final local call graph. */
|
|
1295
|
+
const collectFunction = (node) => {
|
|
1296
|
+
functions.add(node);
|
|
1297
|
+
};
|
|
1298
|
+
return {
|
|
1299
|
+
ArrowFunctionExpression: collectFunction,
|
|
1300
|
+
FunctionDeclaration: collectFunction,
|
|
1301
|
+
FunctionExpression: collectFunction,
|
|
1302
|
+
VariableDeclarator(node) {
|
|
1303
|
+
if (!node.init) return;
|
|
1304
|
+
if (node.id.type === "Identifier") {
|
|
1305
|
+
const symbol = getIdentifierSymbol(node.id);
|
|
1306
|
+
if (symbol) origins.record(symbol, node.init, node);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
if (node.id.type !== "ObjectPattern") return;
|
|
1310
|
+
for (const property of node.id.properties) {
|
|
1311
|
+
if (property.type !== "Property") continue;
|
|
1312
|
+
const key = property.key;
|
|
1313
|
+
const name = !property.computed && key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : void 0;
|
|
1314
|
+
const value = property.value.type === "AssignmentPattern" ? property.value.left : property.value;
|
|
1315
|
+
if (name !== "getSnapshot" || value.type !== "Identifier") continue;
|
|
1316
|
+
const symbol = getIdentifierSymbol(value);
|
|
1317
|
+
if (symbol) snapshotReaders.set(symbol, node.init);
|
|
1318
|
+
}
|
|
1319
|
+
},
|
|
1320
|
+
AssignmentExpression(node) {
|
|
1321
|
+
if (node.left.type !== "Identifier") return;
|
|
1322
|
+
const symbol = getIdentifierSymbol(node.left);
|
|
1323
|
+
if (symbol) origins.record(symbol, node.right, node);
|
|
1324
|
+
},
|
|
1325
|
+
JSXAttribute(node) {
|
|
1326
|
+
if (node.name.type !== "JSXIdentifier" || !/^on[A-Z]/u.test(node.name.name) || node.value?.type !== "JSXExpressionContainer") return;
|
|
1327
|
+
const callback = node.value.expression.type === "JSXEmptyExpression" ? void 0 : node.value.expression;
|
|
1328
|
+
if (!callback) return;
|
|
1329
|
+
if (isIntrinsicEventAttribute(node)) markDeferred(callback);
|
|
1330
|
+
else immediateJsxCallbacks.push({
|
|
1331
|
+
caller: getOwner(node.parent),
|
|
1332
|
+
node: callback,
|
|
1333
|
+
site: node
|
|
1334
|
+
});
|
|
1335
|
+
},
|
|
1336
|
+
CallExpression(node) {
|
|
1337
|
+
const caller = getOwner(node.parent);
|
|
1338
|
+
calls.push({
|
|
1339
|
+
caller,
|
|
1340
|
+
node
|
|
1341
|
+
});
|
|
1342
|
+
if (isDeferredCall(node)) {
|
|
1343
|
+
const callback = node.arguments[0];
|
|
1344
|
+
if (callback?.type !== "SpreadElement") markDeferred(callback);
|
|
1345
|
+
}
|
|
1346
|
+
const callee = unwrapExpression(node.callee);
|
|
1347
|
+
if (callee.type === "Identifier") {
|
|
1348
|
+
snapshots.push({
|
|
1349
|
+
kind: "reader",
|
|
1350
|
+
node,
|
|
1351
|
+
source: callee,
|
|
1352
|
+
owner: caller
|
|
1353
|
+
});
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
if (callee.type !== "MemberExpression" || getMemberName(callee) !== "getSnapshot") return;
|
|
1357
|
+
snapshots.push({
|
|
1358
|
+
kind: "instance",
|
|
1359
|
+
node,
|
|
1360
|
+
source: callee.object,
|
|
1361
|
+
owner: caller
|
|
1362
|
+
});
|
|
1363
|
+
},
|
|
1364
|
+
"Program:exit"() {
|
|
1365
|
+
const functionsBySymbol = /* @__PURE__ */ new Map();
|
|
1366
|
+
const rendered = /* @__PURE__ */ new Set();
|
|
1367
|
+
for (const fn of functions) {
|
|
1368
|
+
const identifier = getFunctionIdentifier$2(fn);
|
|
1369
|
+
if (!identifier) {
|
|
1370
|
+
if (fn.parent?.type === "ExportDefaultDeclaration") rendered.add(fn);
|
|
1371
|
+
continue;
|
|
1372
|
+
}
|
|
1373
|
+
const symbol = getIdentifierSymbol(identifier);
|
|
1374
|
+
if (symbol) {
|
|
1375
|
+
functionsBySymbol.set(symbol, fn);
|
|
1376
|
+
if (deferredSymbols.has(symbol)) deferredFunctions.add(fn);
|
|
1377
|
+
}
|
|
1378
|
+
if (renderFunctionPattern$1.test(identifier.name) && !deferredFunctions.has(fn)) rendered.add(fn);
|
|
1379
|
+
}
|
|
1380
|
+
/** Resolve a local function expression or identifier used as a callback. */
|
|
1381
|
+
const resolveFunction = (input) => {
|
|
1382
|
+
const node = unwrapExpression(input);
|
|
1383
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") return node;
|
|
1384
|
+
if (node.type !== "Identifier") return void 0;
|
|
1385
|
+
const symbol = getIdentifierSymbol(node);
|
|
1386
|
+
return symbol ? functionsBySymbol.get(symbol) : void 0;
|
|
1387
|
+
};
|
|
1388
|
+
const localEdges = [];
|
|
1389
|
+
for (const { caller, node } of calls) {
|
|
1390
|
+
if (!caller) continue;
|
|
1391
|
+
const callee = resolveFunction(node.callee);
|
|
1392
|
+
if (callee) localEdges.push({
|
|
1393
|
+
callee,
|
|
1394
|
+
caller,
|
|
1395
|
+
site: node
|
|
1396
|
+
});
|
|
1397
|
+
if (isDeferredCall(node)) continue;
|
|
1398
|
+
for (const argument of node.arguments) {
|
|
1399
|
+
if (argument.type === "SpreadElement") continue;
|
|
1400
|
+
const callback = resolveFunction(argument);
|
|
1401
|
+
if (callback) localEdges.push({
|
|
1402
|
+
callee: callback,
|
|
1403
|
+
caller,
|
|
1404
|
+
site: node
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
for (const { caller, node, site } of immediateJsxCallbacks) {
|
|
1409
|
+
const callback = resolveFunction(node);
|
|
1410
|
+
if (caller && callback) localEdges.push({
|
|
1411
|
+
callee: callback,
|
|
1412
|
+
caller,
|
|
1413
|
+
site
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
let changed = true;
|
|
1417
|
+
while (changed) {
|
|
1418
|
+
changed = false;
|
|
1419
|
+
for (const edge of localEdges) {
|
|
1420
|
+
if (!rendered.has(edge.caller) || rendered.has(edge.callee)) continue;
|
|
1421
|
+
rendered.add(edge.callee);
|
|
1422
|
+
changed = true;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
const renderedEdges = localEdges.filter((edge) => rendered.has(edge.caller));
|
|
1426
|
+
/** Test whether an expression is a local alias of a Store instance Hook result. */
|
|
1427
|
+
const isInstanceDerived = (input, calls, seen = /* @__PURE__ */ new Set()) => {
|
|
1428
|
+
const node = unwrapExpression(input);
|
|
1429
|
+
if (node.type === "CallExpression") return isStoreInstanceHookCall(node);
|
|
1430
|
+
if (node.type === "AssignmentExpression") return isInstanceDerived(node.right, calls, seen);
|
|
1431
|
+
if (node.type !== "Identifier") return false;
|
|
1432
|
+
const symbol = getIdentifierSymbol(node);
|
|
1433
|
+
if (!symbol || seen.has(symbol)) return false;
|
|
1434
|
+
const sources = origins.resolve(symbol, node, calls);
|
|
1435
|
+
if (sources.length === 0) return false;
|
|
1436
|
+
seen.add(symbol);
|
|
1437
|
+
const derived = sources.some((source) => isInstanceDerived(source, calls, seen));
|
|
1438
|
+
seen.delete(symbol);
|
|
1439
|
+
return derived;
|
|
1440
|
+
};
|
|
1441
|
+
/** Test whether an identifier comes from destructuring an instance getSnapshot method. */
|
|
1442
|
+
const isSnapshotReaderDerived = (input, calls, seen = /* @__PURE__ */ new Set()) => {
|
|
1443
|
+
const node = unwrapExpression(input);
|
|
1444
|
+
if (node.type !== "Identifier") return false;
|
|
1445
|
+
const symbol = getIdentifierSymbol(node);
|
|
1446
|
+
if (!symbol || seen.has(symbol)) return false;
|
|
1447
|
+
const instance = snapshotReaders.get(symbol);
|
|
1448
|
+
if (instance) return isInstanceDerived(instance, calls);
|
|
1449
|
+
const sources = origins.resolve(symbol, node, calls);
|
|
1450
|
+
if (sources.length === 0) return false;
|
|
1451
|
+
seen.add(symbol);
|
|
1452
|
+
const derived = sources.some((source) => isSnapshotReaderDerived(source, calls, seen));
|
|
1453
|
+
seen.delete(symbol);
|
|
1454
|
+
return derived;
|
|
1455
|
+
};
|
|
1456
|
+
for (const snapshot of snapshots) {
|
|
1457
|
+
if (!snapshot.owner || !rendered.has(snapshot.owner)) continue;
|
|
1458
|
+
if (getFunctionCallSiteContexts(snapshot.owner, renderedEdges).some((calls) => {
|
|
1459
|
+
return snapshot.kind === "instance" ? isInstanceDerived(snapshot.source, calls) : isSnapshotReaderDerived(snapshot.source, calls);
|
|
1460
|
+
})) context.report({
|
|
1461
|
+
node: snapshot.node,
|
|
1462
|
+
messageId: "renderSnapshot"
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
});
|
|
1469
|
+
//#endregion
|
|
1470
|
+
//#region src/rules/no-store-mutation.ts
|
|
1471
|
+
/** Return the declaration identifier that owns a local function. */
|
|
1472
|
+
function getFunctionIdentifier$1(node) {
|
|
1473
|
+
if (node.type !== "ArrowFunctionExpression" && node.id) return node.id;
|
|
1474
|
+
const parent = node.parent;
|
|
1475
|
+
return parent?.type === "VariableDeclarator" && parent.id.type === "Identifier" ? parent.id : void 0;
|
|
1476
|
+
}
|
|
1477
|
+
/** Collect every identifier introduced by one binding pattern. */
|
|
1478
|
+
function collectPatternIdentifiers$1(pattern, identifiers) {
|
|
1479
|
+
if (pattern.type === "Identifier") {
|
|
1480
|
+
identifiers.push(pattern);
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
if (pattern.type === "RestElement") {
|
|
1484
|
+
collectPatternIdentifiers$1(pattern.argument, identifiers);
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (pattern.type === "AssignmentPattern") {
|
|
1488
|
+
collectPatternIdentifiers$1(pattern.left, identifiers);
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (pattern.type !== "ObjectPattern" && pattern.type !== "ArrayPattern") return;
|
|
1492
|
+
for (const property of pattern.type === "ObjectPattern" ? pattern.properties : pattern.elements) {
|
|
1493
|
+
if (!property) continue;
|
|
1494
|
+
if (property.type === "Property") collectPatternIdentifiers$1(property.value, identifiers);
|
|
1495
|
+
else collectPatternIdentifiers$1(property, identifiers);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
const noStoreMutation = createRule({
|
|
1499
|
+
name: "no-store-mutation",
|
|
1500
|
+
meta: {
|
|
1501
|
+
type: "problem",
|
|
1502
|
+
docs: { description: "Prevent mutation of selector-free Store snapshots." },
|
|
1503
|
+
schema: [{
|
|
1504
|
+
type: "object",
|
|
1505
|
+
additionalProperties: false,
|
|
1506
|
+
properties: { deepAliases: { type: "boolean" } }
|
|
1507
|
+
}],
|
|
1508
|
+
messages: { mutation: "Store snapshots are immutable." }
|
|
1509
|
+
},
|
|
1510
|
+
defaultOptions: [{ deepAliases: true }],
|
|
1511
|
+
create(context, [options]) {
|
|
1512
|
+
const { checker, getIdentifierSymbol, getType, isStoreHookCall, services } = createKerrosTypeTools(context);
|
|
1513
|
+
const origins = createReferenceOriginTracker(context.sourceCode.ast);
|
|
1514
|
+
const functions = /* @__PURE__ */ new Set();
|
|
1515
|
+
const calls = [];
|
|
1516
|
+
const candidates = [];
|
|
1517
|
+
const maxAliasDepth = options.deepAliases === false ? 1 : Number.POSITIVE_INFINITY;
|
|
1518
|
+
/** Find the function whose body contains a syntax node. */
|
|
1519
|
+
const getOwner = (input) => {
|
|
1520
|
+
let node = input;
|
|
1521
|
+
while (node) {
|
|
1522
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionDeclaration" || node.type === "FunctionExpression") return node;
|
|
1523
|
+
node = node.parent;
|
|
1524
|
+
}
|
|
1525
|
+
};
|
|
1526
|
+
/** Collect a function node for the final local call graph. */
|
|
1527
|
+
const collectFunction = (node) => {
|
|
1528
|
+
functions.add(node);
|
|
1529
|
+
};
|
|
1530
|
+
/** Test whether an expression comes from a selector-free Store Hook snapshot. */
|
|
1531
|
+
const isSnapshotDerived = (input, remainingAliases = maxAliasDepth, seen = /* @__PURE__ */ new Set(), calls) => {
|
|
1532
|
+
const node = unwrapExpression(input);
|
|
1533
|
+
if (node.type === "CallExpression") {
|
|
1534
|
+
const selector = node.arguments[0];
|
|
1535
|
+
return (node.arguments.length === 0 || node.arguments.length === 1 && selector?.type !== "SpreadElement" && (getType(selector).flags & typescript.default.TypeFlags.Undefined) !== 0) && isStoreHookCall(node);
|
|
1536
|
+
}
|
|
1537
|
+
if (node.type === "AssignmentExpression") return isSnapshotDerived(node.right, remainingAliases, seen, calls);
|
|
1538
|
+
if (node.type === "MemberExpression") return isSnapshotDerived(node.object, remainingAliases, seen, calls);
|
|
1539
|
+
if (node.type !== "Identifier" || remainingAliases <= 0) return false;
|
|
1540
|
+
const symbol = getIdentifierSymbol(node);
|
|
1541
|
+
if (!symbol || seen.has(symbol)) return false;
|
|
1542
|
+
const sources = origins.resolve(symbol, node, calls);
|
|
1543
|
+
if (sources.length === 0) return false;
|
|
1544
|
+
seen.add(symbol);
|
|
1545
|
+
const derived = sources.some((source) => {
|
|
1546
|
+
return isSnapshotDerived(source, remainingAliases - 1, seen, calls);
|
|
1547
|
+
});
|
|
1548
|
+
seen.delete(symbol);
|
|
1549
|
+
return derived;
|
|
1550
|
+
};
|
|
1551
|
+
return {
|
|
1552
|
+
ArrowFunctionExpression: collectFunction,
|
|
1553
|
+
FunctionDeclaration: collectFunction,
|
|
1554
|
+
FunctionExpression: collectFunction,
|
|
1555
|
+
VariableDeclarator(node) {
|
|
1556
|
+
if (!node.init) return;
|
|
1557
|
+
const identifiers = [];
|
|
1558
|
+
collectPatternIdentifiers$1(node.id, identifiers);
|
|
1559
|
+
for (const identifier of identifiers) {
|
|
1560
|
+
const symbol = getIdentifierSymbol(identifier);
|
|
1561
|
+
if (symbol) origins.record(symbol, node.init, node);
|
|
1562
|
+
}
|
|
1563
|
+
},
|
|
1564
|
+
AssignmentExpression(node) {
|
|
1565
|
+
if (node.left.type === "Identifier") {
|
|
1566
|
+
const symbol = getIdentifierSymbol(node.left);
|
|
1567
|
+
if (symbol) origins.record(symbol, node.right, node);
|
|
1568
|
+
} else if (node.left.type === "MemberExpression") candidates.push({
|
|
1569
|
+
expression: node.left,
|
|
1570
|
+
node,
|
|
1571
|
+
owner: getOwner(node.parent)
|
|
1572
|
+
});
|
|
1573
|
+
},
|
|
1574
|
+
UpdateExpression(node) {
|
|
1575
|
+
candidates.push({
|
|
1576
|
+
expression: node.argument,
|
|
1577
|
+
node,
|
|
1578
|
+
owner: getOwner(node.parent)
|
|
1579
|
+
});
|
|
1580
|
+
},
|
|
1581
|
+
UnaryExpression(node) {
|
|
1582
|
+
if (node.operator === "delete") candidates.push({
|
|
1583
|
+
expression: node.argument,
|
|
1584
|
+
node,
|
|
1585
|
+
owner: getOwner(node.parent)
|
|
1586
|
+
});
|
|
1587
|
+
},
|
|
1588
|
+
CallExpression(node) {
|
|
1589
|
+
calls.push({
|
|
1590
|
+
caller: getOwner(node.parent),
|
|
1591
|
+
node
|
|
1592
|
+
});
|
|
1593
|
+
if (!isMutableCollectionCall(node, checker, services.program, getType)) return;
|
|
1594
|
+
const callee = unwrapExpression(node.callee);
|
|
1595
|
+
if (callee.type === "MemberExpression") candidates.push({
|
|
1596
|
+
expression: callee.object,
|
|
1597
|
+
node,
|
|
1598
|
+
owner: getOwner(node.parent)
|
|
1599
|
+
});
|
|
1600
|
+
},
|
|
1601
|
+
"Program:exit"() {
|
|
1602
|
+
const functionsBySymbol = /* @__PURE__ */ new Map();
|
|
1603
|
+
for (const fn of functions) {
|
|
1604
|
+
const identifier = getFunctionIdentifier$1(fn);
|
|
1605
|
+
if (!identifier) continue;
|
|
1606
|
+
const symbol = getIdentifierSymbol(identifier);
|
|
1607
|
+
if (symbol) functionsBySymbol.set(symbol, fn);
|
|
1608
|
+
}
|
|
1609
|
+
/** Resolve a directly called local function. */
|
|
1610
|
+
const resolveFunction = (input) => {
|
|
1611
|
+
const node = unwrapExpression(input);
|
|
1612
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") return node;
|
|
1613
|
+
if (node.type !== "Identifier") return void 0;
|
|
1614
|
+
const symbol = getIdentifierSymbol(node);
|
|
1615
|
+
return symbol ? functionsBySymbol.get(symbol) : void 0;
|
|
1616
|
+
};
|
|
1617
|
+
const localEdges = [];
|
|
1618
|
+
for (const { caller, node } of calls) {
|
|
1619
|
+
const callee = resolveFunction(node.callee);
|
|
1620
|
+
if (caller && callee) localEdges.push({
|
|
1621
|
+
callee,
|
|
1622
|
+
caller,
|
|
1623
|
+
site: node
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
for (const candidate of candidates) if ((candidate.owner ? getFunctionCallSiteContexts(candidate.owner, localEdges) : [/* @__PURE__ */ new Map()]).some((calls) => {
|
|
1627
|
+
return isSnapshotDerived(candidate.expression, maxAliasDepth, /* @__PURE__ */ new Set(), calls);
|
|
1628
|
+
})) context.report({
|
|
1629
|
+
node: candidate.node,
|
|
1630
|
+
messageId: "mutation"
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
});
|
|
1636
|
+
//#endregion
|
|
1637
|
+
//#region src/rules/no-unstable-bound-store.ts
|
|
1638
|
+
const renderFunctionPattern = /^(?:[A-Z]|use[A-Z])/u;
|
|
1639
|
+
/** Find the nearest function that owns one JSX reference. */
|
|
1640
|
+
function getOwner(input) {
|
|
1641
|
+
let node = input.parent;
|
|
1642
|
+
while (node) {
|
|
1643
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionDeclaration" || node.type === "FunctionExpression") return node;
|
|
1644
|
+
node = node.parent;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
/** Read the declaration identifier for one local function. */
|
|
1648
|
+
function getFunctionIdentifier(node) {
|
|
1649
|
+
if (node.type !== "ArrowFunctionExpression" && node.id) return node.id;
|
|
1650
|
+
const parent = node.parent;
|
|
1651
|
+
return parent?.type === "VariableDeclarator" && parent.id.type === "Identifier" ? parent.id : void 0;
|
|
1652
|
+
}
|
|
1653
|
+
/** Test whether a function is a component, Hook, or anonymous default render root. */
|
|
1654
|
+
function isRenderRoot(node) {
|
|
1655
|
+
if (!node) return false;
|
|
1656
|
+
const identifier = getFunctionIdentifier(node);
|
|
1657
|
+
if (identifier) return renderFunctionPattern.test(identifier.name);
|
|
1658
|
+
return node.parent?.type === "ExportDefaultDeclaration";
|
|
1659
|
+
}
|
|
1660
|
+
/** Collect every identifier introduced by one binding pattern. */
|
|
1661
|
+
function collectPatternIdentifiers(pattern, identifiers) {
|
|
1662
|
+
if (pattern.type === "Identifier") {
|
|
1663
|
+
identifiers.push(pattern);
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
if (pattern.type === "RestElement") {
|
|
1667
|
+
collectPatternIdentifiers(pattern.argument, identifiers);
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
if (pattern.type === "AssignmentPattern") {
|
|
1671
|
+
collectPatternIdentifiers(pattern.left, identifiers);
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
if (pattern.type !== "ObjectPattern" && pattern.type !== "ArrayPattern") return;
|
|
1675
|
+
for (const property of pattern.type === "ObjectPattern" ? pattern.properties : pattern.elements) {
|
|
1676
|
+
if (!property) continue;
|
|
1677
|
+
if (property.type === "Property") collectPatternIdentifiers(property.value, identifiers);
|
|
1678
|
+
else collectPatternIdentifiers(property, identifiers);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
const noUnstableBoundStore = createRule({
|
|
1682
|
+
name: "no-unstable-bound-store",
|
|
1683
|
+
meta: {
|
|
1684
|
+
type: "problem",
|
|
1685
|
+
docs: { description: "Require stable Store instances for bindStore Providers." },
|
|
1686
|
+
schema: [],
|
|
1687
|
+
messages: { unstableStore: "The Provider store prop must have stable identity." }
|
|
1688
|
+
},
|
|
1689
|
+
defaultOptions: [],
|
|
1690
|
+
create(context) {
|
|
1691
|
+
const { checker, getIdentifierSymbol, getTsNode, getType, hasMarker, isReactCall, services } = createKerrosTypeTools(context);
|
|
1692
|
+
const origins = createReferenceOriginTracker(context.sourceCode.ast);
|
|
1693
|
+
const refCurrents = createReferenceOriginTracker(context.sourceCode.ast);
|
|
1694
|
+
/** Read a parameter or binding-element default; null means a parameter without a default. */
|
|
1695
|
+
const getParameterDefault = (input) => {
|
|
1696
|
+
let node = input;
|
|
1697
|
+
while (node) {
|
|
1698
|
+
if (typescript.default.isBindingElement(node) && node.initializer) return node.initializer;
|
|
1699
|
+
if (typescript.default.isParameter(node)) return node.initializer ?? null;
|
|
1700
|
+
if (typescript.default.isVariableDeclaration(node) || typescript.default.isFunctionLike(node.parent)) return void 0;
|
|
1701
|
+
node = node.parent;
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
/** Test whether a destructured value is the lazy state owned by React useState. */
|
|
1705
|
+
const isLazyStateBinding = (declaration) => {
|
|
1706
|
+
if (!typescript.default.isBindingElement(declaration) || !typescript.default.isArrayBindingPattern(declaration.parent) || declaration.parent.elements[0] !== declaration) return false;
|
|
1707
|
+
const variable = declaration.parent.parent;
|
|
1708
|
+
if (!typescript.default.isVariableDeclaration(variable) || !variable.initializer) return false;
|
|
1709
|
+
const initializer = variable.initializer;
|
|
1710
|
+
if (!typescript.default.isCallExpression(initializer) || !isReactCall(initializer, "useState")) return false;
|
|
1711
|
+
const initialState = initializer.arguments[0];
|
|
1712
|
+
return initialState !== void 0 && checker.getTypeAtLocation(initialState).getCallSignatures().length > 0;
|
|
1713
|
+
};
|
|
1714
|
+
/** Test whether an expression is a ref object created by React useRef. */
|
|
1715
|
+
const isStableRef = (input, seen = /* @__PURE__ */ new Set()) => {
|
|
1716
|
+
const node = unwrapExpression(input);
|
|
1717
|
+
if (node.type === "CallExpression") {
|
|
1718
|
+
const tsNode = getTsNode(node);
|
|
1719
|
+
return typescript.default.isCallExpression(tsNode) && isReactCall(tsNode, "useRef");
|
|
1720
|
+
}
|
|
1721
|
+
if (node.type !== "Identifier") return false;
|
|
1722
|
+
const symbol = getIdentifierSymbol(node);
|
|
1723
|
+
if (!symbol || seen.has(symbol)) return false;
|
|
1724
|
+
seen.add(symbol);
|
|
1725
|
+
const sources = origins.resolve(symbol, node);
|
|
1726
|
+
const stable = sources.length > 0 && sources.every((source) => isStableRef(source, seen));
|
|
1727
|
+
seen.delete(symbol);
|
|
1728
|
+
return stable;
|
|
1729
|
+
};
|
|
1730
|
+
/** Prove a Provider value is retained outside the current render at this reference point. */
|
|
1731
|
+
const isStable = (input, seen = /* @__PURE__ */ new Set()) => {
|
|
1732
|
+
const node = unwrapExpression(input);
|
|
1733
|
+
if (isPrimitiveType(checker, getType(node))) return true;
|
|
1734
|
+
if (node.type === "ThisExpression") return true;
|
|
1735
|
+
if (node.type === "ObjectExpression" || node.type === "ArrayExpression" || node.type === "NewExpression" || node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression" || node.type === "ClassExpression") return false;
|
|
1736
|
+
if (node.type === "CallExpression") {
|
|
1737
|
+
const tsNode = getTsNode(node);
|
|
1738
|
+
return typescript.default.isCallExpression(tsNode) && isReactCall(tsNode, "useMemo");
|
|
1739
|
+
}
|
|
1740
|
+
if (node.type === "MemberExpression") {
|
|
1741
|
+
if (!node.computed && node.property.type === "Identifier" && node.property.name === "current" && node.object.type === "Identifier") {
|
|
1742
|
+
const symbol = getIdentifierSymbol(node.object);
|
|
1743
|
+
const sources = symbol ? refCurrents.resolve(symbol, node) : [];
|
|
1744
|
+
if (sources.length > 0) return sources.every((source) => isStable(source, seen));
|
|
1745
|
+
return isStableRef(node.object);
|
|
1746
|
+
}
|
|
1747
|
+
return isStable(node.object, seen);
|
|
1748
|
+
}
|
|
1749
|
+
if (node.type === "ConditionalExpression") return isStable(node.consequent, seen) && isStable(node.alternate, seen);
|
|
1750
|
+
if (node.type === "LogicalExpression") return isStable(node.left, seen) && isStable(node.right, seen);
|
|
1751
|
+
if (node.type === "SequenceExpression") {
|
|
1752
|
+
const value = node.expressions.at(-1);
|
|
1753
|
+
return value ? isStable(value, seen) : true;
|
|
1754
|
+
}
|
|
1755
|
+
if (node.type !== "Identifier") return false;
|
|
1756
|
+
const symbol = getIdentifierSymbol(node);
|
|
1757
|
+
if (!symbol || seen.has(symbol)) return false;
|
|
1758
|
+
if (symbol.declarations?.some(isModuleDeclaration)) return true;
|
|
1759
|
+
if (symbol.declarations?.some(isLazyStateBinding)) return true;
|
|
1760
|
+
seen.add(symbol);
|
|
1761
|
+
const sources = origins.resolve(symbol, node);
|
|
1762
|
+
if (sources.length > 0) {
|
|
1763
|
+
const stable = sources.every((source) => isStable(source, seen));
|
|
1764
|
+
seen.delete(symbol);
|
|
1765
|
+
return stable;
|
|
1766
|
+
}
|
|
1767
|
+
let stable = false;
|
|
1768
|
+
for (const declaration of symbol.declarations ?? []) {
|
|
1769
|
+
const defaultValue = getParameterDefault(declaration);
|
|
1770
|
+
if (defaultValue !== void 0) {
|
|
1771
|
+
const expression = defaultValue ? services.tsNodeToESTreeNodeMap.get(defaultValue) : void 0;
|
|
1772
|
+
stable = expression ? isStable(expression, seen) : true;
|
|
1773
|
+
break;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
seen.delete(symbol);
|
|
1777
|
+
return stable;
|
|
1778
|
+
};
|
|
1779
|
+
return {
|
|
1780
|
+
VariableDeclarator(node) {
|
|
1781
|
+
if (!node.init) return;
|
|
1782
|
+
const identifiers = [];
|
|
1783
|
+
collectPatternIdentifiers(node.id, identifiers);
|
|
1784
|
+
for (const identifier of identifiers) {
|
|
1785
|
+
const symbol = getIdentifierSymbol(identifier);
|
|
1786
|
+
if (symbol) origins.record(symbol, node.init, node);
|
|
1787
|
+
}
|
|
1788
|
+
},
|
|
1789
|
+
AssignmentExpression(node) {
|
|
1790
|
+
if (node.left.type === "Identifier") {
|
|
1791
|
+
const symbol = getIdentifierSymbol(node.left);
|
|
1792
|
+
if (symbol) origins.record(symbol, node.right, node);
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
if (node.left.type !== "MemberExpression" || node.left.object.type !== "Identifier" || node.left.computed || node.left.property.type !== "Identifier" || node.left.property.name !== "current") return;
|
|
1796
|
+
const symbol = getIdentifierSymbol(node.left.object);
|
|
1797
|
+
if (symbol) refCurrents.record(symbol, node.right, node);
|
|
1798
|
+
},
|
|
1799
|
+
JSXAttribute(node) {
|
|
1800
|
+
if (node.name.type !== "JSXIdentifier" || node.name.name !== "store") return;
|
|
1801
|
+
const opening = node.parent;
|
|
1802
|
+
if (opening?.type !== "JSXOpeningElement" || !hasMarker(getType(opening.name), "externalStoreProvider")) return;
|
|
1803
|
+
if (!isRenderRoot(getOwner(node))) return;
|
|
1804
|
+
if (node.value?.type !== "JSXExpressionContainer" || node.value.expression.type === "JSXEmptyExpression") return;
|
|
1805
|
+
if (!isStable(node.value.expression)) context.report({
|
|
1806
|
+
node: node.value.expression,
|
|
1807
|
+
messageId: "unstableStore"
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
});
|
|
1813
|
+
//#endregion
|
|
1814
|
+
//#region src/rules/no-unstable-selector-value.ts
|
|
1815
|
+
/** Collect object literals that directly form selector return branches. */
|
|
1816
|
+
function collectSelectionObjects(input, objects, getInitializer, seen = /* @__PURE__ */ new Set()) {
|
|
1817
|
+
const node = unwrapExpression(input);
|
|
1818
|
+
if (seen.has(node)) return;
|
|
1819
|
+
seen.add(node);
|
|
1820
|
+
if (node.type === "ObjectExpression") {
|
|
1821
|
+
objects.push(node);
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
if (node.type === "Identifier") {
|
|
1825
|
+
const initializer = getInitializer(node);
|
|
1826
|
+
if (initializer) collectSelectionObjects(initializer, objects, getInitializer, seen);
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
if (node.type === "ConditionalExpression") {
|
|
1830
|
+
collectSelectionObjects(node.consequent, objects, getInitializer, seen);
|
|
1831
|
+
collectSelectionObjects(node.alternate, objects, getInitializer, seen);
|
|
1832
|
+
} else if (node.type === "LogicalExpression") {
|
|
1833
|
+
collectSelectionObjects(node.left, objects, getInitializer, seen);
|
|
1834
|
+
collectSelectionObjects(node.right, objects, getInitializer, seen);
|
|
1835
|
+
} else if (node.type === "SequenceExpression") {
|
|
1836
|
+
const result = node.expressions.at(-1);
|
|
1837
|
+
if (result) collectSelectionObjects(result, objects, getInitializer, seen);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
const noUnstableSelectorValue = createRule({
|
|
1841
|
+
name: "no-unstable-selector-value",
|
|
1842
|
+
meta: {
|
|
1843
|
+
type: "problem",
|
|
1844
|
+
docs: { description: "Prevent selector fields from allocating unstable references." },
|
|
1845
|
+
schema: [],
|
|
1846
|
+
messages: { unstableValue: "Selector fields must not create a new reference on every call." }
|
|
1847
|
+
},
|
|
1848
|
+
defaultOptions: [],
|
|
1849
|
+
create(context) {
|
|
1850
|
+
const { checker, getIdentifierSymbol, getType, isStoreHookCall, services } = createKerrosTypeTools(context);
|
|
1851
|
+
/** Resolve a non-module variable to the expression assigned in its declaration. */
|
|
1852
|
+
const getLocalInitializer = (node) => {
|
|
1853
|
+
const declaration = getIdentifierSymbol(node)?.declarations?.find(typescript.default.isVariableDeclaration);
|
|
1854
|
+
if (!declaration?.initializer || isModuleDeclaration(declaration)) return void 0;
|
|
1855
|
+
return services.tsNodeToESTreeNodeMap.get(declaration.initializer);
|
|
1856
|
+
};
|
|
1857
|
+
/** Test whether an expression preserves a primitive or previously cached reference. */
|
|
1858
|
+
const isStable = (input, seen = /* @__PURE__ */ new Set()) => {
|
|
1859
|
+
const node = unwrapExpression(input);
|
|
1860
|
+
if (node.type === "Literal") return !("regex" in node);
|
|
1861
|
+
if (node.type === "MemberExpression") return true;
|
|
1862
|
+
if (node.type === "ConditionalExpression") return isStable(node.consequent, seen) && isStable(node.alternate, seen);
|
|
1863
|
+
if (node.type === "LogicalExpression") return isStable(node.left, seen) && isStable(node.right, seen);
|
|
1864
|
+
if (node.type === "SequenceExpression") {
|
|
1865
|
+
const result = node.expressions.at(-1);
|
|
1866
|
+
return result ? isStable(result, seen) : true;
|
|
1867
|
+
}
|
|
1868
|
+
if (node.type === "Identifier") {
|
|
1869
|
+
const symbol = getIdentifierSymbol(node);
|
|
1870
|
+
if (symbol?.declarations?.some(isModuleDeclaration)) return true;
|
|
1871
|
+
if (isPrimitiveType(checker, getType(node))) return true;
|
|
1872
|
+
if (!symbol || seen.has(symbol)) return false;
|
|
1873
|
+
seen.add(symbol);
|
|
1874
|
+
const declaration = symbol.declarations?.find(typescript.default.isVariableDeclaration);
|
|
1875
|
+
const initializer = declaration?.initializer ? services.tsNodeToESTreeNodeMap.get(declaration.initializer) : void 0;
|
|
1876
|
+
const stable = initializer ? isStable(initializer, seen) : false;
|
|
1877
|
+
seen.delete(symbol);
|
|
1878
|
+
return stable;
|
|
1879
|
+
}
|
|
1880
|
+
if (node.type === "ObjectExpression" || node.type === "ArrayExpression" || node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression" || node.type === "ClassExpression" || node.type === "NewExpression" || node.type === "JSXElement" || node.type === "JSXFragment") return false;
|
|
1881
|
+
return isPrimitiveType(checker, getType(node));
|
|
1882
|
+
};
|
|
1883
|
+
return { CallExpression(node) {
|
|
1884
|
+
const selector = getInlineSelector(node, isStoreHookCall);
|
|
1885
|
+
if (!selector) return;
|
|
1886
|
+
const objects = [];
|
|
1887
|
+
for (const expression of getReturnedExpressions(selector)) collectSelectionObjects(expression, objects, getLocalInitializer);
|
|
1888
|
+
for (const object of objects) for (const property of object.properties) if (property.type === "Property" && !isStable(property.value)) context.report({
|
|
1889
|
+
node: property.value,
|
|
1890
|
+
messageId: "unstableValue"
|
|
1891
|
+
});
|
|
1892
|
+
} };
|
|
1893
|
+
}
|
|
1894
|
+
});
|
|
1895
|
+
//#endregion
|
|
1896
|
+
//#region src/rules/no-whole-store-selector.ts
|
|
1897
|
+
/** Collect simple local aliases of a selector's complete Store parameter. */
|
|
1898
|
+
function getStoreAliases(selector, parameter, getIdentifierSymbol) {
|
|
1899
|
+
const aliases = /* @__PURE__ */ new Set();
|
|
1900
|
+
const parameterSymbol = getIdentifierSymbol(parameter);
|
|
1901
|
+
if (parameterSymbol) aliases.add(parameterSymbol);
|
|
1902
|
+
if (selector.body.type !== "BlockStatement") return aliases;
|
|
1903
|
+
const declarations = [];
|
|
1904
|
+
const collectDeclarations = (node) => {
|
|
1905
|
+
if (node !== selector.body && (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression" || node.type === "FunctionDeclaration")) return;
|
|
1906
|
+
if (node.type === "VariableDeclarator") declarations.push(node);
|
|
1907
|
+
for (const key of Object.keys(node)) {
|
|
1908
|
+
if (key === "parent" || key === "range" || key === "loc") continue;
|
|
1909
|
+
const value = node[key];
|
|
1910
|
+
if (Array.isArray(value)) {
|
|
1911
|
+
for (const child of value) if (child && typeof child === "object" && "type" in child) collectDeclarations(child);
|
|
1912
|
+
} else if (value && typeof value === "object" && "type" in value) collectDeclarations(value);
|
|
1913
|
+
}
|
|
1914
|
+
};
|
|
1915
|
+
collectDeclarations(selector.body);
|
|
1916
|
+
let changed = true;
|
|
1917
|
+
while (changed) {
|
|
1918
|
+
changed = false;
|
|
1919
|
+
for (const declaration of declarations) {
|
|
1920
|
+
if (declaration.id.type !== "Identifier" || !declaration.init) continue;
|
|
1921
|
+
const value = unwrapExpression(declaration.init);
|
|
1922
|
+
if (value.type !== "Identifier") continue;
|
|
1923
|
+
const valueSymbol = getIdentifierSymbol(value);
|
|
1924
|
+
const declarationSymbol = getIdentifierSymbol(declaration.id);
|
|
1925
|
+
if (valueSymbol && declarationSymbol && aliases.has(valueSymbol) && !aliases.has(declarationSymbol)) {
|
|
1926
|
+
aliases.add(declarationSymbol);
|
|
1927
|
+
changed = true;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
return aliases;
|
|
1932
|
+
}
|
|
1933
|
+
/** Test whether an expression returns or embeds the complete Store value. */
|
|
1934
|
+
function containsWholeStore(node, aliases, getIdentifierSymbol) {
|
|
1935
|
+
const expression = unwrapExpression(node);
|
|
1936
|
+
if (expression.type === "Identifier") {
|
|
1937
|
+
const symbol = getIdentifierSymbol(expression);
|
|
1938
|
+
return symbol ? aliases.has(symbol) : false;
|
|
1939
|
+
}
|
|
1940
|
+
if (expression.type === "ObjectExpression") return expression.properties.some((property) => {
|
|
1941
|
+
return property.type === "SpreadElement" ? containsWholeStore(property.argument, aliases, getIdentifierSymbol) : containsWholeStore(property.value, aliases, getIdentifierSymbol);
|
|
1942
|
+
});
|
|
1943
|
+
if (expression.type === "ArrayExpression") return expression.elements.some((element) => {
|
|
1944
|
+
return element?.type === "SpreadElement" ? containsWholeStore(element.argument, aliases, getIdentifierSymbol) : element ? containsWholeStore(element, aliases, getIdentifierSymbol) : false;
|
|
1945
|
+
});
|
|
1946
|
+
if (expression.type === "ConditionalExpression") return containsWholeStore(expression.consequent, aliases, getIdentifierSymbol) || containsWholeStore(expression.alternate, aliases, getIdentifierSymbol);
|
|
1947
|
+
if (expression.type === "LogicalExpression") return containsWholeStore(expression.left, aliases, getIdentifierSymbol) || containsWholeStore(expression.right, aliases, getIdentifierSymbol);
|
|
1948
|
+
if (expression.type === "SequenceExpression") {
|
|
1949
|
+
const result = expression.expressions.at(-1);
|
|
1950
|
+
return result ? containsWholeStore(result, aliases, getIdentifierSymbol) : false;
|
|
1951
|
+
}
|
|
1952
|
+
if (expression.type === "AssignmentExpression") return containsWholeStore(expression.right, aliases, getIdentifierSymbol);
|
|
1953
|
+
if (expression.type === "AwaitExpression" || expression.type === "YieldExpression") return expression.argument ? containsWholeStore(expression.argument, aliases, getIdentifierSymbol) : false;
|
|
1954
|
+
return false;
|
|
1955
|
+
}
|
|
1956
|
+
const noWholeStoreSelector = createRule({
|
|
1957
|
+
name: "no-whole-store-selector",
|
|
1958
|
+
meta: {
|
|
1959
|
+
type: "problem",
|
|
1960
|
+
docs: { description: "Prevent selectors from returning the complete Store." },
|
|
1961
|
+
schema: [],
|
|
1962
|
+
messages: { wholeStore: "A selector cannot return or wrap the complete Store." }
|
|
1963
|
+
},
|
|
1964
|
+
defaultOptions: [],
|
|
1965
|
+
create(context) {
|
|
1966
|
+
const { getIdentifierSymbol, isStoreHookCall } = createKerrosTypeTools(context);
|
|
1967
|
+
return { CallExpression(node) {
|
|
1968
|
+
if (!isStoreHookCall(node)) return;
|
|
1969
|
+
const selector = node.arguments[0];
|
|
1970
|
+
if (!selector || selector.type === "SpreadElement" || selector.type !== "ArrowFunctionExpression" && selector.type !== "FunctionExpression") return;
|
|
1971
|
+
const parameter = selector.params[0];
|
|
1972
|
+
if (!parameter || parameter.type !== "Identifier") return;
|
|
1973
|
+
const aliases = getStoreAliases(selector, parameter, getIdentifierSymbol);
|
|
1974
|
+
if (getReturnedExpressions(selector).some((expression) => {
|
|
1975
|
+
return containsWholeStore(expression, aliases, getIdentifierSymbol);
|
|
1976
|
+
})) context.report({
|
|
1977
|
+
node: selector,
|
|
1978
|
+
messageId: "wholeStore"
|
|
1979
|
+
});
|
|
1980
|
+
} };
|
|
1981
|
+
}
|
|
1982
|
+
});
|
|
1983
|
+
//#endregion
|
|
1984
|
+
//#region src/rules/prefer-bind-store.ts
|
|
1985
|
+
const preferBindStore = createRule({
|
|
1986
|
+
name: "prefer-bind-store",
|
|
1987
|
+
meta: {
|
|
1988
|
+
type: "suggestion",
|
|
1989
|
+
docs: { description: "Prefer bindStore when a model delegates to React useSyncExternalStore." },
|
|
1990
|
+
schema: [],
|
|
1991
|
+
messages: { bindExternalStore: "Use bindStore for an existing external Store." }
|
|
1992
|
+
},
|
|
1993
|
+
defaultOptions: [],
|
|
1994
|
+
create(context) {
|
|
1995
|
+
const { getFactoryKind, getModelFunction, isReactCall } = createKerrosTypeTools(context);
|
|
1996
|
+
/** Test only calls executed directly by the model body, excluding returned callbacks. */
|
|
1997
|
+
const readsExternalStore = (model) => {
|
|
1998
|
+
if (!model.body) return false;
|
|
1999
|
+
let found = false;
|
|
2000
|
+
/** Scan the model body without crossing into nested function lifecycles. */
|
|
2001
|
+
const visit = (node) => {
|
|
2002
|
+
if (found) return;
|
|
2003
|
+
if (node !== model && typescript.default.isFunctionLike(node)) return;
|
|
2004
|
+
if (typescript.default.isCallExpression(node) && isReactCall(node, "useSyncExternalStore")) {
|
|
2005
|
+
found = true;
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
typescript.default.forEachChild(node, visit);
|
|
2009
|
+
};
|
|
2010
|
+
visit(model.body);
|
|
2011
|
+
return found;
|
|
2012
|
+
};
|
|
2013
|
+
return { CallExpression(node) {
|
|
2014
|
+
if (getFactoryKind(node) !== "createStore") return;
|
|
2015
|
+
const model = node.arguments[0];
|
|
2016
|
+
if (!model || model.type === "SpreadElement") return;
|
|
2017
|
+
const declaration = getModelFunction(model);
|
|
2018
|
+
if (declaration && readsExternalStore(declaration)) context.report({
|
|
2019
|
+
node: model,
|
|
2020
|
+
messageId: "bindExternalStore"
|
|
2021
|
+
});
|
|
2022
|
+
} };
|
|
2023
|
+
}
|
|
2024
|
+
});
|
|
2025
|
+
//#endregion
|
|
2026
|
+
//#region src/rules/pure-selector.ts
|
|
2027
|
+
const globalPureFunctions = /* @__PURE__ */ new Set([
|
|
2028
|
+
"BigInt",
|
|
2029
|
+
"Boolean",
|
|
2030
|
+
"Number",
|
|
2031
|
+
"String",
|
|
2032
|
+
"decodeURI",
|
|
2033
|
+
"decodeURIComponent",
|
|
2034
|
+
"encodeURI",
|
|
2035
|
+
"encodeURIComponent",
|
|
2036
|
+
"isFinite",
|
|
2037
|
+
"isNaN",
|
|
2038
|
+
"parseFloat",
|
|
2039
|
+
"parseInt"
|
|
2040
|
+
]);
|
|
2041
|
+
const pureStaticMethods = {
|
|
2042
|
+
Array: /* @__PURE__ */ new Set(["isArray"]),
|
|
2043
|
+
JSON: /* @__PURE__ */ new Set(["parse", "stringify"]),
|
|
2044
|
+
Number: /* @__PURE__ */ new Set([
|
|
2045
|
+
"isFinite",
|
|
2046
|
+
"isInteger",
|
|
2047
|
+
"isNaN",
|
|
2048
|
+
"isSafeInteger",
|
|
2049
|
+
"parseFloat",
|
|
2050
|
+
"parseInt"
|
|
2051
|
+
]),
|
|
2052
|
+
Object: /* @__PURE__ */ new Set([
|
|
2053
|
+
"entries",
|
|
2054
|
+
"getOwnPropertyDescriptor",
|
|
2055
|
+
"getOwnPropertyDescriptors",
|
|
2056
|
+
"getOwnPropertyNames",
|
|
2057
|
+
"getOwnPropertySymbols",
|
|
2058
|
+
"getPrototypeOf",
|
|
2059
|
+
"hasOwn",
|
|
2060
|
+
"is",
|
|
2061
|
+
"isExtensible",
|
|
2062
|
+
"isFrozen",
|
|
2063
|
+
"isSealed",
|
|
2064
|
+
"keys",
|
|
2065
|
+
"values"
|
|
2066
|
+
]),
|
|
2067
|
+
Promise: /* @__PURE__ */ new Set([
|
|
2068
|
+
"all",
|
|
2069
|
+
"allSettled",
|
|
2070
|
+
"any",
|
|
2071
|
+
"race",
|
|
2072
|
+
"reject",
|
|
2073
|
+
"resolve"
|
|
2074
|
+
]),
|
|
2075
|
+
String: /* @__PURE__ */ new Set([
|
|
2076
|
+
"fromCharCode",
|
|
2077
|
+
"fromCodePoint",
|
|
2078
|
+
"raw"
|
|
2079
|
+
])
|
|
2080
|
+
};
|
|
2081
|
+
const readonlyMethods = /* @__PURE__ */ new Set([
|
|
2082
|
+
"at",
|
|
2083
|
+
"concat",
|
|
2084
|
+
"endsWith",
|
|
2085
|
+
"entries",
|
|
2086
|
+
"every",
|
|
2087
|
+
"filter",
|
|
2088
|
+
"find",
|
|
2089
|
+
"findIndex",
|
|
2090
|
+
"findLast",
|
|
2091
|
+
"findLastIndex",
|
|
2092
|
+
"flat",
|
|
2093
|
+
"flatMap",
|
|
2094
|
+
"forEach",
|
|
2095
|
+
"get",
|
|
2096
|
+
"has",
|
|
2097
|
+
"includes",
|
|
2098
|
+
"indexOf",
|
|
2099
|
+
"join",
|
|
2100
|
+
"keys",
|
|
2101
|
+
"lastIndexOf",
|
|
2102
|
+
"map",
|
|
2103
|
+
"match",
|
|
2104
|
+
"matchAll",
|
|
2105
|
+
"reduce",
|
|
2106
|
+
"reduceRight",
|
|
2107
|
+
"replace",
|
|
2108
|
+
"replaceAll",
|
|
2109
|
+
"search",
|
|
2110
|
+
"slice",
|
|
2111
|
+
"some",
|
|
2112
|
+
"split",
|
|
2113
|
+
"startsWith",
|
|
2114
|
+
"substring",
|
|
2115
|
+
"substr",
|
|
2116
|
+
"toLocaleLowerCase",
|
|
2117
|
+
"toLocaleUpperCase",
|
|
2118
|
+
"toLowerCase",
|
|
2119
|
+
"toReversed",
|
|
2120
|
+
"toSorted",
|
|
2121
|
+
"toSpliced",
|
|
2122
|
+
"toString",
|
|
2123
|
+
"toUpperCase",
|
|
2124
|
+
"trim",
|
|
2125
|
+
"trimEnd",
|
|
2126
|
+
"trimStart",
|
|
2127
|
+
"valueOf",
|
|
2128
|
+
"values",
|
|
2129
|
+
"with"
|
|
2130
|
+
]);
|
|
2131
|
+
const pureSelector = createRule({
|
|
2132
|
+
name: "pure-selector",
|
|
2133
|
+
meta: {
|
|
2134
|
+
type: "problem",
|
|
2135
|
+
docs: { description: "Prevent side effects and mutable operations inside selectors." },
|
|
2136
|
+
schema: [],
|
|
2137
|
+
messages: { impureSelector: "Selectors must be pure." }
|
|
2138
|
+
},
|
|
2139
|
+
defaultOptions: [],
|
|
2140
|
+
create(context) {
|
|
2141
|
+
const { checker, getIdentifierSymbol, getType, isStoreHookCall, services } = createKerrosTypeTools(context);
|
|
2142
|
+
/** Test whether an identifier is the matching JavaScript global declaration. */
|
|
2143
|
+
const isGlobal = (node, names) => {
|
|
2144
|
+
if (!names.has(node.name)) return false;
|
|
2145
|
+
return getIdentifierSymbol(node)?.declarations?.some((declaration) => {
|
|
2146
|
+
return services.program.isSourceFileDefaultLibrary(declaration.getSourceFile());
|
|
2147
|
+
}) === true;
|
|
2148
|
+
};
|
|
2149
|
+
/** Resolve a callback through local declarations and symbol-safe aliases. */
|
|
2150
|
+
const resolveCallback = (input, seen = /* @__PURE__ */ new Set()) => {
|
|
2151
|
+
const node = unwrapExpression(input);
|
|
2152
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") return node;
|
|
2153
|
+
if (node.type !== "Identifier") return void 0;
|
|
2154
|
+
const symbol = getIdentifierSymbol(node);
|
|
2155
|
+
if (!symbol || seen.has(symbol)) return void 0;
|
|
2156
|
+
seen.add(symbol);
|
|
2157
|
+
for (const declaration of symbol.declarations ?? []) {
|
|
2158
|
+
if (typescript.default.isFunctionDeclaration(declaration)) {
|
|
2159
|
+
const callback = services.tsNodeToESTreeNodeMap.get(declaration);
|
|
2160
|
+
if (callback.type === "FunctionDeclaration") return callback;
|
|
2161
|
+
}
|
|
2162
|
+
if (typescript.default.isVariableDeclaration(declaration) && declaration.initializer) {
|
|
2163
|
+
const initializer = services.tsNodeToESTreeNodeMap.get(declaration.initializer);
|
|
2164
|
+
const callback = resolveCallback(initializer, seen);
|
|
2165
|
+
if (callback) return callback;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
};
|
|
2169
|
+
/** Test whether a call belongs to a small, side-effect-free built-in surface. */
|
|
2170
|
+
const isKnownPureCall = (node) => {
|
|
2171
|
+
const callee = unwrapExpression(node.callee);
|
|
2172
|
+
if (callee.type === "Identifier") return isGlobal(callee, globalPureFunctions);
|
|
2173
|
+
if (callee.type !== "MemberExpression") return false;
|
|
2174
|
+
const name = getMemberName(callee);
|
|
2175
|
+
if (!name) return false;
|
|
2176
|
+
const object = unwrapExpression(callee.object);
|
|
2177
|
+
if (object.type === "Identifier" && object.name in pureStaticMethods) {
|
|
2178
|
+
const objectName = object.name;
|
|
2179
|
+
return pureStaticMethods[objectName].has(name) && isGlobal(object, /* @__PURE__ */ new Set([objectName]));
|
|
2180
|
+
}
|
|
2181
|
+
if (object.type === "Identifier" && object.name === "Math") return name !== "random" && isGlobal(object, /* @__PURE__ */ new Set(["Math"]));
|
|
2182
|
+
const kind = getBuiltinTypeKind(checker, services.program, getType(callee.object));
|
|
2183
|
+
return readonlyMethods.has(name) && kind !== void 0;
|
|
2184
|
+
};
|
|
2185
|
+
return { CallExpression(node) {
|
|
2186
|
+
const selector = getInlineSelector(node, isStoreHookCall);
|
|
2187
|
+
if (!selector) return;
|
|
2188
|
+
if (selector.async || selector.generator) context.report({
|
|
2189
|
+
node: selector,
|
|
2190
|
+
messageId: "impureSelector"
|
|
2191
|
+
});
|
|
2192
|
+
const visitedCallbacks = /* @__PURE__ */ new Set();
|
|
2193
|
+
/** Scan one synchronous collection callback at most once. */
|
|
2194
|
+
function scanCallback(callback) {
|
|
2195
|
+
if (visitedCallbacks.has(callback)) return;
|
|
2196
|
+
visitedCallbacks.add(callback);
|
|
2197
|
+
visitSubtree(callback.body, checkNode, true);
|
|
2198
|
+
}
|
|
2199
|
+
/** Report one syntax node that executes as part of the selector call. */
|
|
2200
|
+
function checkNode(child) {
|
|
2201
|
+
if (child.type === "AssignmentExpression" || child.type === "UpdateExpression" || child.type === "AwaitExpression" || child.type === "NewExpression" && child.parent?.type !== "ThrowStatement" || child.type === "YieldExpression" || child.type === "ThrowStatement" || child.type === "UnaryExpression" && child.operator === "delete") {
|
|
2202
|
+
context.report({
|
|
2203
|
+
node: child,
|
|
2204
|
+
messageId: "impureSelector"
|
|
2205
|
+
});
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
if (child.type !== "CallExpression") return;
|
|
2209
|
+
if (!(!isMutableCollectionCall(child, checker, services.program, getType) && isKnownPureCall(child))) {
|
|
2210
|
+
context.report({
|
|
2211
|
+
node: child,
|
|
2212
|
+
messageId: "impureSelector"
|
|
2213
|
+
});
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
for (const argument of child.arguments) {
|
|
2217
|
+
if (argument.type === "SpreadElement") continue;
|
|
2218
|
+
const callback = resolveCallback(argument);
|
|
2219
|
+
if (callback) scanCallback(callback);
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
visitSubtree(selector.body, checkNode, true);
|
|
2223
|
+
} };
|
|
2224
|
+
}
|
|
2225
|
+
});
|
|
2226
|
+
//#endregion
|
|
2227
|
+
//#region src/rules/require-cached-snapshot.ts
|
|
2228
|
+
/** Test whether a declaration executes inside one snapshot reader invocation. */
|
|
2229
|
+
function isInsideFunction(node, owner) {
|
|
2230
|
+
let current = node;
|
|
2231
|
+
while (current) {
|
|
2232
|
+
if (current === owner) return true;
|
|
2233
|
+
current = current.parent;
|
|
2234
|
+
}
|
|
2235
|
+
return false;
|
|
2236
|
+
}
|
|
2237
|
+
const requireCachedSnapshot = createRule({
|
|
2238
|
+
name: "require-cached-snapshot",
|
|
2239
|
+
meta: {
|
|
2240
|
+
type: "problem",
|
|
2241
|
+
docs: { description: "Require bindStore snapshots to preserve reference identity between updates." },
|
|
2242
|
+
schema: [],
|
|
2243
|
+
messages: { uncachedSnapshot: "getSnapshot must return a cached snapshot reference." }
|
|
2244
|
+
},
|
|
2245
|
+
defaultOptions: [],
|
|
2246
|
+
create(context) {
|
|
2247
|
+
const { checker, getFactoryKind, getMarkerType, getTsNode, getTsSymbol } = createKerrosTypeTools(context);
|
|
2248
|
+
/** Resolve property functions through methods, arrow properties, and shorthand identifiers. */
|
|
2249
|
+
const getImplementations = (symbol) => {
|
|
2250
|
+
const implementations = /* @__PURE__ */ new Set();
|
|
2251
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2252
|
+
/** Follow one syntax node to a concrete function body or referenced symbol. */
|
|
2253
|
+
const resolveNode = (node) => {
|
|
2254
|
+
if (typescript.default.isFunctionDeclaration(node) || typescript.default.isMethodDeclaration(node) || typescript.default.isGetAccessorDeclaration(node) || typescript.default.isArrowFunction(node) || typescript.default.isFunctionExpression(node)) {
|
|
2255
|
+
if (node.body) implementations.add(node);
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
if ((typescript.default.isVariableDeclaration(node) || typescript.default.isPropertyDeclaration(node)) && node.initializer) {
|
|
2259
|
+
resolveNode(node.initializer);
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
if (typescript.default.isPropertyAssignment(node)) {
|
|
2263
|
+
resolveNode(node.initializer);
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2266
|
+
if (typescript.default.isShorthandPropertyAssignment(node)) {
|
|
2267
|
+
const value = checker.getShorthandAssignmentValueSymbol(node);
|
|
2268
|
+
if (value) resolveSymbol(value);
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
if (typescript.default.isIdentifier(node)) {
|
|
2272
|
+
const value = getTsSymbol(node);
|
|
2273
|
+
if (value) resolveSymbol(value);
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
if (typescript.default.isParenthesizedExpression(node) || typescript.default.isAsExpression(node) || typescript.default.isNonNullExpression(node) || typescript.default.isSatisfiesExpression(node)) resolveNode(node.expression);
|
|
2277
|
+
};
|
|
2278
|
+
/** Follow a symbol's declarations once to avoid recursive aliases. */
|
|
2279
|
+
function resolveSymbol(candidate) {
|
|
2280
|
+
if (seen.has(candidate)) return;
|
|
2281
|
+
seen.add(candidate);
|
|
2282
|
+
for (const declaration of candidate.declarations ?? []) resolveNode(declaration);
|
|
2283
|
+
}
|
|
2284
|
+
resolveSymbol(symbol);
|
|
2285
|
+
return implementations;
|
|
2286
|
+
};
|
|
2287
|
+
/** Prove that a snapshot value is primitive or allocated outside the reader invocation. */
|
|
2288
|
+
const isCached = (input, owner, seen = /* @__PURE__ */ new Set()) => {
|
|
2289
|
+
const node = unwrapTsExpression(input);
|
|
2290
|
+
const type = checker.getTypeAtLocation(node);
|
|
2291
|
+
if (isPrimitiveType(checker, type)) return true;
|
|
2292
|
+
if (typescript.default.isObjectLiteralExpression(node) || typescript.default.isArrayLiteralExpression(node) || typescript.default.isNewExpression(node) || typescript.default.isArrowFunction(node) || typescript.default.isFunctionExpression(node) || typescript.default.isClassExpression(node) || typescript.default.isJsxElement(node) || typescript.default.isJsxSelfClosingElement(node) || typescript.default.isJsxFragment(node) || typescript.default.isRegularExpressionLiteral(node)) return false;
|
|
2293
|
+
if (node.kind === typescript.default.SyntaxKind.ThisKeyword) return true;
|
|
2294
|
+
if (typescript.default.isPropertyAccessExpression(node) || typescript.default.isElementAccessExpression(node)) {
|
|
2295
|
+
const symbol = (typescript.default.isPropertyAccessExpression(node) ? checker.getSymbolAtLocation(node.name) : checker.getSymbolAtLocation(node.argumentExpression)) ?? checker.getSymbolAtLocation(node);
|
|
2296
|
+
const getters = symbol?.declarations?.filter(typescript.default.isGetAccessorDeclaration) ?? [];
|
|
2297
|
+
if (getters.length > 0) {
|
|
2298
|
+
if (!symbol || seen.has(symbol)) return false;
|
|
2299
|
+
seen.add(symbol);
|
|
2300
|
+
const cached = getters.every((getter) => {
|
|
2301
|
+
const returns = getTsReturnExpressions(getter);
|
|
2302
|
+
return returns.length > 0 && returns.every((value) => isCached(value, getter, seen));
|
|
2303
|
+
});
|
|
2304
|
+
seen.delete(symbol);
|
|
2305
|
+
return cached;
|
|
2306
|
+
}
|
|
2307
|
+
if (symbol?.declarations?.some(typescript.default.isPropertyDeclaration)) return true;
|
|
2308
|
+
return isCached(node.expression, owner, seen);
|
|
2309
|
+
}
|
|
2310
|
+
if (typescript.default.isConditionalExpression(node)) return isCached(node.whenTrue, owner, seen) && isCached(node.whenFalse, owner, seen);
|
|
2311
|
+
if (typescript.default.isBinaryExpression(node) && (node.operatorToken.kind === typescript.default.SyntaxKind.AmpersandAmpersandToken || node.operatorToken.kind === typescript.default.SyntaxKind.BarBarToken || node.operatorToken.kind === typescript.default.SyntaxKind.QuestionQuestionToken)) return isCached(node.left, owner, seen) && isCached(node.right, owner, seen);
|
|
2312
|
+
if (!typescript.default.isIdentifier(node)) return false;
|
|
2313
|
+
const symbol = getTsSymbol(node);
|
|
2314
|
+
if (!symbol || seen.has(symbol)) return false;
|
|
2315
|
+
seen.add(symbol);
|
|
2316
|
+
let cached = false;
|
|
2317
|
+
for (const declaration of symbol.declarations ?? []) {
|
|
2318
|
+
if (!isInsideFunction(declaration, owner)) {
|
|
2319
|
+
cached = true;
|
|
2320
|
+
break;
|
|
2321
|
+
}
|
|
2322
|
+
if (typescript.default.isVariableDeclaration(declaration) && declaration.initializer) {
|
|
2323
|
+
cached = isCached(declaration.initializer, owner, seen);
|
|
2324
|
+
if (cached) break;
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
seen.delete(symbol);
|
|
2328
|
+
return cached;
|
|
2329
|
+
};
|
|
2330
|
+
return { CallExpression(node) {
|
|
2331
|
+
if (getFactoryKind(node) !== "bindStore") return;
|
|
2332
|
+
const tsNode = getTsNode(node);
|
|
2333
|
+
if (!typescript.default.isCallExpression(tsNode)) return;
|
|
2334
|
+
const signature = checker.getResolvedSignature(tsNode);
|
|
2335
|
+
if (!signature) return;
|
|
2336
|
+
const returnType = checker.getReturnTypeOfSignature(signature);
|
|
2337
|
+
const providerProperty = checker.getPropertyOfType(returnType, "1");
|
|
2338
|
+
const providerType = providerProperty ? checker.getTypeOfSymbolAtLocation(providerProperty, tsNode) : void 0;
|
|
2339
|
+
const storeType = providerType ? getMarkerType(providerType, "externalStoreProvider", tsNode) : void 0;
|
|
2340
|
+
const snapshot = storeType ? getTypeProperty(checker, storeType, "getSnapshot") : void 0;
|
|
2341
|
+
if (!snapshot) return;
|
|
2342
|
+
if ([...getImplementations(snapshot)].some((implementation) => {
|
|
2343
|
+
return getTsReturnExpressions(implementation).some((value) => !isCached(value, implementation));
|
|
2344
|
+
})) context.report({
|
|
2345
|
+
node,
|
|
2346
|
+
messageId: "uncachedSnapshot"
|
|
2347
|
+
});
|
|
2348
|
+
} };
|
|
2349
|
+
}
|
|
2350
|
+
});
|
|
2351
|
+
//#endregion
|
|
2352
|
+
//#region src/rules/require-immediate-store-access.ts
|
|
2353
|
+
const transparentParents = /* @__PURE__ */ new Set([
|
|
2354
|
+
"ChainExpression",
|
|
2355
|
+
"TSAsExpression",
|
|
2356
|
+
"TSInstantiationExpression",
|
|
2357
|
+
"TSNonNullExpression",
|
|
2358
|
+
"TSSatisfiesExpression",
|
|
2359
|
+
"TSTypeAssertion"
|
|
2360
|
+
]);
|
|
2361
|
+
/** Find the first parent that changes how a Store snapshot is consumed. */
|
|
2362
|
+
function getConsumptionParent(node) {
|
|
2363
|
+
let current = node;
|
|
2364
|
+
let parent = current.parent;
|
|
2365
|
+
while (parent && transparentParents.has(parent.type)) {
|
|
2366
|
+
current = parent;
|
|
2367
|
+
parent = current.parent;
|
|
2368
|
+
}
|
|
2369
|
+
return {
|
|
2370
|
+
current,
|
|
2371
|
+
parent
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
/** Test whether the snapshot is read immediately without retaining the Proxy. */
|
|
2375
|
+
function hasImmediateAccess(node) {
|
|
2376
|
+
const { current, parent } = getConsumptionParent(node);
|
|
2377
|
+
if (!parent) return false;
|
|
2378
|
+
if (parent.type === "MemberExpression" && parent.object === current) return true;
|
|
2379
|
+
return parent.type === "VariableDeclarator" && parent.init === current && parent.id.type === "ObjectPattern";
|
|
2380
|
+
}
|
|
2381
|
+
//#endregion
|
|
2382
|
+
//#region src/index.ts
|
|
2383
|
+
const rules = {
|
|
2384
|
+
"binding-naming": bindingNaming,
|
|
2385
|
+
"factory-at-module-scope": factoryAtModuleScope,
|
|
2386
|
+
"model-convention": modelConvention,
|
|
2387
|
+
"no-broad-store-access": noBroadStoreAccess,
|
|
2388
|
+
"no-cyclic-store-dependency": noCyclicStoreDependency,
|
|
2389
|
+
"no-effect-event-action": noEffectEventAction,
|
|
2390
|
+
"no-provider-key-prop": noProviderKeyProp,
|
|
2391
|
+
"no-render-instance-snapshot": noRenderInstanceSnapshot,
|
|
2392
|
+
"no-store-mutation": noStoreMutation,
|
|
2393
|
+
"no-unstable-bound-store": noUnstableBoundStore,
|
|
2394
|
+
"no-unstable-selector-value": noUnstableSelectorValue,
|
|
2395
|
+
"no-whole-store-selector": noWholeStoreSelector,
|
|
2396
|
+
"prefer-bind-store": preferBindStore,
|
|
2397
|
+
"pure-selector": pureSelector,
|
|
2398
|
+
"require-cached-snapshot": requireCachedSnapshot,
|
|
2399
|
+
"require-immediate-store-access": createRule({
|
|
2400
|
+
name: "require-immediate-store-access",
|
|
2401
|
+
meta: {
|
|
2402
|
+
type: "problem",
|
|
2403
|
+
docs: { description: "Require immediate property access for selector-free Store Hooks." },
|
|
2404
|
+
schema: [],
|
|
2405
|
+
messages: { immediateAccess: "Immediately destructure or read a property from a selector-free Store Hook." }
|
|
2406
|
+
},
|
|
2407
|
+
defaultOptions: [],
|
|
2408
|
+
create(context) {
|
|
2409
|
+
const { getType, isStoreHookCall } = createKerrosTypeTools(context);
|
|
2410
|
+
return { CallExpression(node) {
|
|
2411
|
+
const selector = node.arguments[0];
|
|
2412
|
+
if ((!selector || node.arguments.length === 1 && selector.type !== "SpreadElement" && (getType(selector).flags & typescript.default.TypeFlags.Undefined) !== 0) && isStoreHookCall(node) && !hasImmediateAccess(node)) context.report({
|
|
2413
|
+
node,
|
|
2414
|
+
messageId: "immediateAccess"
|
|
2415
|
+
});
|
|
2416
|
+
} };
|
|
2417
|
+
}
|
|
2418
|
+
}),
|
|
2419
|
+
"selector-parameter-name": createRule({
|
|
2420
|
+
name: "selector-parameter-name",
|
|
2421
|
+
meta: {
|
|
2422
|
+
type: "suggestion",
|
|
2423
|
+
docs: { description: "Use s as the conventional Kerros selector parameter name." },
|
|
2424
|
+
schema: [],
|
|
2425
|
+
messages: { parameterName: "Name the Store selector parameter s." }
|
|
2426
|
+
},
|
|
2427
|
+
defaultOptions: [],
|
|
2428
|
+
create(context) {
|
|
2429
|
+
const { isStoreHookCall } = createKerrosTypeTools(context);
|
|
2430
|
+
return { CallExpression(node) {
|
|
2431
|
+
if (!isStoreHookCall(node)) return;
|
|
2432
|
+
const selector = node.arguments[0];
|
|
2433
|
+
if (!selector || selector.type === "SpreadElement" || selector.type !== "ArrowFunctionExpression" && selector.type !== "FunctionExpression") return;
|
|
2434
|
+
const parameter = selector.params[0];
|
|
2435
|
+
if (parameter && (parameter.type !== "Identifier" || parameter.name !== "s")) context.report({
|
|
2436
|
+
node: parameter,
|
|
2437
|
+
messageId: "parameterName"
|
|
2438
|
+
});
|
|
2439
|
+
} };
|
|
2440
|
+
}
|
|
2441
|
+
})
|
|
2442
|
+
};
|
|
2443
|
+
const plugin = {
|
|
2444
|
+
meta: {
|
|
2445
|
+
name: "@violetflux/eslint-plugin-kerros",
|
|
2446
|
+
version: "0.2.0"
|
|
2447
|
+
},
|
|
2448
|
+
rules
|
|
2449
|
+
};
|
|
2450
|
+
const recommendedRules = Object.fromEntries(Object.keys(rules).map((name) => [`kerros/${name}`, "error"]));
|
|
2451
|
+
const fastRules = {
|
|
2452
|
+
...recommendedRules,
|
|
2453
|
+
"kerros/no-cyclic-store-dependency": "off",
|
|
2454
|
+
"kerros/no-store-mutation": ["error", { deepAliases: false }],
|
|
2455
|
+
"kerros/no-unstable-selector-value": "off",
|
|
2456
|
+
"kerros/require-cached-snapshot": "off"
|
|
2457
|
+
};
|
|
2458
|
+
const configs = {
|
|
2459
|
+
fastTypeChecked: {
|
|
2460
|
+
name: "kerros/fast-type-checked",
|
|
2461
|
+
files: ["**/*.{ts,tsx,mts,cts}"],
|
|
2462
|
+
languageOptions: {
|
|
2463
|
+
parser: _typescript_eslint_parser.default,
|
|
2464
|
+
parserOptions: { projectService: true }
|
|
2465
|
+
},
|
|
2466
|
+
plugins: { kerros: plugin },
|
|
2467
|
+
rules: fastRules
|
|
2468
|
+
},
|
|
2469
|
+
recommendedTypeChecked: {
|
|
2470
|
+
name: "kerros/recommended-type-checked",
|
|
2471
|
+
files: ["**/*.{ts,tsx,mts,cts}"],
|
|
2472
|
+
languageOptions: {
|
|
2473
|
+
parser: _typescript_eslint_parser.default,
|
|
2474
|
+
parserOptions: { projectService: true }
|
|
2475
|
+
},
|
|
2476
|
+
plugins: { kerros: plugin },
|
|
2477
|
+
rules: recommendedRules
|
|
2478
|
+
}
|
|
2479
|
+
};
|
|
2480
|
+
plugin.configs = configs;
|
|
2481
|
+
//#endregion
|
|
2482
|
+
exports.configs = configs;
|
|
2483
|
+
exports.default = plugin;
|
|
2484
|
+
exports.rules = rules;
|