@onrails/eslint-plugin 0.1.2 → 0.1.4
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/README.md +4 -0
- package/package.json +1 -1
- package/src/index.js +221 -0
package/README.md
CHANGED
|
@@ -20,6 +20,8 @@ export default [
|
|
|
20
20
|
rules: {
|
|
21
21
|
"@onrails/no-promise-result": "warn",
|
|
22
22
|
"@onrails/no-unsafe-unwrap": "warn",
|
|
23
|
+
"@onrails/no-deprecated-synonyms": "warn",
|
|
24
|
+
"@onrails/fluent-stays-local": "warn",
|
|
23
25
|
},
|
|
24
26
|
},
|
|
25
27
|
];
|
|
@@ -46,6 +48,8 @@ export default [
|
|
|
46
48
|
|---|---|
|
|
47
49
|
| `@onrails/no-promise-result` | Flags `Promise<Result<…>>` in source — public APIs should return `ResultAsync` |
|
|
48
50
|
| `@onrails/no-unsafe-unwrap` | Flags `_unsafeUnwrap*` outside test files |
|
|
51
|
+
| `@onrails/no-deprecated-synonyms` | Flags deprecated synonyms (`chain`, `fold`, `getOrElse`, `of`, …) and suggests the canonical names |
|
|
52
|
+
| `@onrails/fluent-stays-local` | Flags `FluentResult`/`FluentMaybe` escaping as a return type, an exported binding, a stored field, or a serialize/postMessage/cache argument |
|
|
49
53
|
|
|
50
54
|
## Caveats (v0)
|
|
51
55
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onrails/eslint-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "ESLint rules for @onrails/result boundaries — flags Promise<Result<…>> in public APIs and _unsafeUnwrap*. Replaces unmaintained eslint-plugin-neverthrow.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alan R. Soares",
|
package/src/index.js
CHANGED
|
@@ -131,6 +131,223 @@ const noUnsafeUnwrap = {
|
|
|
131
131
|
},
|
|
132
132
|
};
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Deprecated synonym → canonical rename tables. Hand-copied from
|
|
136
|
+
* `@onrails/codemod` `src/boundary-spec.ts` (cross-package imports are
|
|
137
|
+
* forbidden); `packages/codemod/test/boundary-conformance.spec.ts` keeps the
|
|
138
|
+
* copies in sync with the spec and the Biome `no-deprecated-synonyms.grit`.
|
|
139
|
+
*/
|
|
140
|
+
|
|
141
|
+
/** Flagged as `x.name(…)`. `isOk` / `isErr` map to the same-named free-function guards. */
|
|
142
|
+
const DEPRECATED_METHOD_RENAMES = new Map([
|
|
143
|
+
["chain", "flatMap"],
|
|
144
|
+
["isOk", "isOk"],
|
|
145
|
+
["isErr", "isErr"],
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
/** Flagged as `name(…)`. */
|
|
149
|
+
const DEPRECATED_CALL_RENAMES = new Map([
|
|
150
|
+
["fold", "match"],
|
|
151
|
+
["matchResult", "match"],
|
|
152
|
+
["matchMaybe", "match"],
|
|
153
|
+
["getOrElse", "unwrapOr"],
|
|
154
|
+
["sequenceTupleAsync", "ResultAsync.combineTuple"],
|
|
155
|
+
["collect", "combine"],
|
|
156
|
+
["of", "ok"],
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
160
|
+
const noDeprecatedSynonyms = {
|
|
161
|
+
meta: {
|
|
162
|
+
type: "suggestion",
|
|
163
|
+
docs: {
|
|
164
|
+
description:
|
|
165
|
+
"Flag deprecated railway/maybe synonyms (chain, fold, getOrElse, of, …) and suggest the canonical onrails names. Matching is name-based; same-named functions from other libraries are flagged too.",
|
|
166
|
+
},
|
|
167
|
+
schema: [],
|
|
168
|
+
messages: {
|
|
169
|
+
rename: "{{name}}() is a deprecated onrails synonym — use {{canonical}}() instead.",
|
|
170
|
+
narrowGuard:
|
|
171
|
+
"Deprecated .{{name}}() method — await the ResultAsync and narrow with the {{name}}() free function instead.",
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
create(context) {
|
|
175
|
+
return {
|
|
176
|
+
CallExpression(node) {
|
|
177
|
+
const callee = node.callee;
|
|
178
|
+
if (callee.type === "Identifier") {
|
|
179
|
+
const canonical = DEPRECATED_CALL_RENAMES.get(callee.name);
|
|
180
|
+
if (canonical) {
|
|
181
|
+
context.report({
|
|
182
|
+
node,
|
|
183
|
+
messageId: "rename",
|
|
184
|
+
data: { name: callee.name, canonical },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (callee.type !== "MemberExpression" || callee.property.type !== "Identifier") return;
|
|
190
|
+
const name = callee.property.name;
|
|
191
|
+
const canonical = DEPRECATED_METHOD_RENAMES.get(name);
|
|
192
|
+
if (!canonical) return;
|
|
193
|
+
context.report(
|
|
194
|
+
canonical === name
|
|
195
|
+
? { node, messageId: "narrowGuard", data: { name } }
|
|
196
|
+
: { node, messageId: "rename", data: { name, canonical } },
|
|
197
|
+
);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Fluent wrapper types and serialize-sink names. Hand-copied from
|
|
205
|
+
* `@onrails/codemod` `src/boundary-spec.ts` (cross-package imports are
|
|
206
|
+
* forbidden); `packages/codemod/test/boundary-conformance.spec.ts` keeps the
|
|
207
|
+
* copies in sync.
|
|
208
|
+
*/
|
|
209
|
+
const FLUENT_WRAPPER_TYPES = new Set(["FluentResult", "FluentMaybe"]);
|
|
210
|
+
const FLUENT_ESCAPE_SINKS = new Set(["postMessage", "structuredClone", "JSON.stringify"]);
|
|
211
|
+
/** Fluent methods that exit the wrapper — a chain ending here is already plain data. */
|
|
212
|
+
const FLUENT_TERMINAL_METHODS = new Set(["toResult", "toMaybe", "toString", "match", "unwrapOr"]);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Loose parent-chain node shape for structural classification — mirrors
|
|
216
|
+
* {@link TsTypeReference} for nodes outside estree's bundled types
|
|
217
|
+
* (`TSTypeAnnotation`, `PropertyDefinition`, `VariableDeclarator`, …).
|
|
218
|
+
*
|
|
219
|
+
* @typedef {object} LooseNode
|
|
220
|
+
* @property {string} type
|
|
221
|
+
* @property {string} [name]
|
|
222
|
+
* @property {LooseNode} [parent]
|
|
223
|
+
* @property {LooseNode} [returnType]
|
|
224
|
+
* @property {LooseNode} [object]
|
|
225
|
+
* @property {LooseNode} [property]
|
|
226
|
+
* @property {LooseNode} [callee]
|
|
227
|
+
*/
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {unknown} node
|
|
231
|
+
* @returns {LooseNode}
|
|
232
|
+
*/
|
|
233
|
+
const asLoose = (node) => /** @type {LooseNode} */ (node);
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* @param {LooseNode} node
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
const calleeName = (node) => {
|
|
240
|
+
if (node.type === "Identifier") return node.name ?? "";
|
|
241
|
+
if (
|
|
242
|
+
node.type === "MemberExpression" &&
|
|
243
|
+
node.object?.type === "Identifier" &&
|
|
244
|
+
node.property?.type === "Identifier"
|
|
245
|
+
)
|
|
246
|
+
return `${node.object.name}.${node.property.name}`;
|
|
247
|
+
return "";
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* True when `node` still holds a fluent wrapper — a call chain rooted at
|
|
252
|
+
* `fluent(...)` that hasn't reached a terminal (`toResult`, `match`, …) yet.
|
|
253
|
+
* `fluent(r).toResult()` is plain data by the time it reaches a sink;
|
|
254
|
+
* `fluent(r)` and `fluent(r).map(f)` are not. Purely syntactic: a
|
|
255
|
+
* `fluent(...)` result stashed in a variable first is out of reach without
|
|
256
|
+
* type information.
|
|
257
|
+
*
|
|
258
|
+
* @param {unknown} node
|
|
259
|
+
* @returns {boolean}
|
|
260
|
+
*/
|
|
261
|
+
const isFluentChain = (node) => {
|
|
262
|
+
if (!node) return false;
|
|
263
|
+
const loose = asLoose(node);
|
|
264
|
+
if (loose.type !== "CallExpression" || !loose.callee) return false;
|
|
265
|
+
if (loose.callee.type === "Identifier") return loose.callee.name === "fluent";
|
|
266
|
+
if (loose.callee.type === "MemberExpression") {
|
|
267
|
+
const method = loose.callee.property?.name;
|
|
268
|
+
if (method && FLUENT_TERMINAL_METHODS.has(method)) return false;
|
|
269
|
+
return isFluentChain(loose.callee.object);
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
275
|
+
const fluentStaysLocal = {
|
|
276
|
+
meta: {
|
|
277
|
+
type: "problem",
|
|
278
|
+
docs: {
|
|
279
|
+
description:
|
|
280
|
+
"Disallow FluentResult/FluentMaybe escaping its function — as a return type, an exported binding, a stored field, or an argument to a serialize/postMessage/cache call.",
|
|
281
|
+
},
|
|
282
|
+
schema: [],
|
|
283
|
+
messages: {
|
|
284
|
+
returnType:
|
|
285
|
+
"{{name}} as a function return type lets the fluent wrapper escape — call toResult()/toMaybe()/toString() and return plain data instead.",
|
|
286
|
+
exported:
|
|
287
|
+
"{{name}} on an exported binding lets the fluent wrapper escape its module — call toResult()/toMaybe()/toString() before exporting.",
|
|
288
|
+
storedField:
|
|
289
|
+
"{{name}} on a stored field lets the fluent wrapper escape — call toResult()/toMaybe()/toString() before storing it.",
|
|
290
|
+
serializeArg:
|
|
291
|
+
"Passing a fluent(...) chain into {{sink}}() serializes/transfers a closure — call toResult()/toMaybe()/toString() first.",
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
create(context) {
|
|
295
|
+
return {
|
|
296
|
+
TSTypeReference(/** @type {import('eslint').Rule.Node} */ node) {
|
|
297
|
+
const typeRef = asTsTypeReference(node);
|
|
298
|
+
const typeName = typeRef.typeName;
|
|
299
|
+
if (typeName.type !== "Identifier" || !FLUENT_WRAPPER_TYPES.has(typeName.name ?? ""))
|
|
300
|
+
return;
|
|
301
|
+
const name = typeName.name;
|
|
302
|
+
|
|
303
|
+
const annotation = asLoose(node).parent;
|
|
304
|
+
if (annotation?.type !== "TSTypeAnnotation") return;
|
|
305
|
+
const owner = annotation.parent;
|
|
306
|
+
if (!owner) return;
|
|
307
|
+
|
|
308
|
+
if (
|
|
309
|
+
[
|
|
310
|
+
"FunctionDeclaration",
|
|
311
|
+
"FunctionExpression",
|
|
312
|
+
"ArrowFunctionExpression",
|
|
313
|
+
"TSMethodSignature",
|
|
314
|
+
"TSDeclareFunction",
|
|
315
|
+
"TSFunctionType",
|
|
316
|
+
"TSCallSignatureDeclaration",
|
|
317
|
+
].includes(owner.type) &&
|
|
318
|
+
owner.returnType === annotation
|
|
319
|
+
) {
|
|
320
|
+
context.report({ node, messageId: "returnType", data: { name } });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// A variable's type annotation hangs off its `id` pattern, e.g.
|
|
325
|
+
// `const chain: FluentResult<…>` attaches to the Identifier "chain",
|
|
326
|
+
// whose parent is the VariableDeclarator.
|
|
327
|
+
if (owner.type === "Identifier" && owner.parent?.type === "VariableDeclarator") {
|
|
328
|
+
if (owner.parent.parent?.parent?.type === "ExportNamedDeclaration") {
|
|
329
|
+
context.report({ node, messageId: "exported", data: { name } });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (owner.type === "PropertyDefinition" || owner.type === "TSPropertySignature") {
|
|
335
|
+
context.report({ node, messageId: "storedField", data: { name } });
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
CallExpression(node) {
|
|
339
|
+
const sink = calleeName(asLoose(node.callee));
|
|
340
|
+
if (!FLUENT_ESCAPE_SINKS.has(sink)) return;
|
|
341
|
+
for (const arg of node.arguments) {
|
|
342
|
+
if (isFluentChain(arg)) {
|
|
343
|
+
context.report({ node: arg, messageId: "serializeArg", data: { sink } });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
|
|
134
351
|
/** @type {import('eslint').ESLint.Plugin} */
|
|
135
352
|
const plugin = {
|
|
136
353
|
meta: {
|
|
@@ -140,6 +357,8 @@ const plugin = {
|
|
|
140
357
|
rules: {
|
|
141
358
|
"no-promise-result": noPromiseResult,
|
|
142
359
|
"no-unsafe-unwrap": noUnsafeUnwrap,
|
|
360
|
+
"no-deprecated-synonyms": noDeprecatedSynonyms,
|
|
361
|
+
"fluent-stays-local": fluentStaysLocal,
|
|
143
362
|
},
|
|
144
363
|
};
|
|
145
364
|
|
|
@@ -153,6 +372,8 @@ export const configs = {
|
|
|
153
372
|
rules: {
|
|
154
373
|
"@onrails/result/no-promise-result": "warn",
|
|
155
374
|
"@onrails/result/no-unsafe-unwrap": "warn",
|
|
375
|
+
"@onrails/result/no-deprecated-synonyms": "warn",
|
|
376
|
+
"@onrails/result/fluent-stays-local": "warn",
|
|
156
377
|
},
|
|
157
378
|
},
|
|
158
379
|
};
|