oxlint-plugin-immutable 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +68 -0
  2. package/dist/index.js +801 -0
  3. package/package.json +32 -0
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # oxlint-plugin-immutable
2
+
3
+ Immutability and functional-style lint rules ported from [eslint-plugin-functional](https://github.com/eslint-functional/eslint-plugin-functional) to [Oxlint's JS plugin API](https://oxc.rs/docs/guide/usage/linter/plugins). Also works with ESLint flat config via the bundled `eslintCompatPlugin` wrapper.
4
+
5
+ > **Note:** Rules that require the TypeScript type checker (deep immutability analysis) are not implemented — Oxlint's JS plugin API operates on AST only. The `prefer-immutable-types` rule uses `ReadonlyShallow` enforcement mode, and `type-declaration-immutability` uses structural AST analysis.
6
+
7
+ ## Requirements
8
+
9
+ - Oxlint `>= 1.0.0` (requires `jsPlugins` support)
10
+ - Node.js `>= 18`
11
+
12
+ ## Installation
13
+
14
+ ```sh
15
+ npm add -D oxlint-plugin-immutable
16
+ # or
17
+ bun add -d oxlint-plugin-immutable
18
+ ```
19
+
20
+ ## Usage with Oxlint
21
+
22
+ Add the plugin to `jsPlugins` and enable the rules you want:
23
+
24
+ ```json
25
+ // .oxlintrc.json
26
+ {
27
+ "jsPlugins": ["./node_modules/oxlint-plugin-immutable/dist/index.js"],
28
+ "rules": {
29
+ "oxlint-plugin-immutable/no-let": "error",
30
+ "oxlint-plugin-immutable/no-throw-statements": "error",
31
+ "oxlint-plugin-immutable/type-declaration-immutability": "error",
32
+ "oxlint-plugin-immutable/immutable-data": "error"
33
+ }
34
+ }
35
+ ```
36
+
37
+ ## Usage with ESLint
38
+
39
+ ```js
40
+ // eslint.config.mjs
41
+ import immutablePlugin from "oxlint-plugin-immutable/dist/index.js";
42
+
43
+ export default [
44
+ {
45
+ plugins: { functional: immutablePlugin },
46
+ rules: immutablePlugin.configs.recommended.rules,
47
+ },
48
+ ];
49
+ ```
50
+
51
+ ## Rules
52
+
53
+ The **Recommended** column shows the severity used in `plugin.configs.recommended`. Rules with `—` are implemented but not included in the recommended config.
54
+
55
+ | Rule | Description | Recommended |
56
+ |---|---|---|
57
+ | `no-let` | Disallow `let` declarations — prefer `const` | error |
58
+ | `no-throw-statements` | Disallow `throw` statements | error |
59
+ | `prefer-property-signatures` | Prefer property signatures over method signatures in types | error |
60
+ | `prefer-immutable-types` | Enforce `ReadonlyShallow` on parameters, return types, and variables (AST-only) | error |
61
+ | `type-declaration-immutability` | Type aliases and interfaces must be at least `ReadonlyShallow` | error |
62
+ | `no-promise-reject` | Disallow `Promise.reject()` | — |
63
+ | `immutable-data` | Disallow mutating methods on arrays/objects (`push`, `splice`, etc.) | — |
64
+ | `readonly-type` | Enforce `readonly` on TypeScript type members | — |
65
+
66
+ ## License
67
+
68
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,801 @@
1
+ // node_modules/.bun/@oxlint+plugins@1.43.0/node_modules/@oxlint/plugins/index.js
2
+ var EMPTY_VISITOR = {};
3
+ function eslintCompatPlugin(plugin) {
4
+ if (typeof plugin != "object" || !plugin)
5
+ throw Error("Plugin must be an object");
6
+ let { rules } = plugin;
7
+ if (typeof rules != "object" || !rules)
8
+ throw Error("Plugin must have an object as `rules` property");
9
+ for (let ruleName in rules)
10
+ Object.hasOwn(rules, ruleName) && convertRule(rules[ruleName]);
11
+ return plugin;
12
+ }
13
+ function convertRule(rule) {
14
+ if (typeof rule != "object" || !rule)
15
+ throw Error("Rule must be an object");
16
+ if ("create" in rule)
17
+ return;
18
+ let context = null, visitor, beforeHook;
19
+ rule.create = (eslintContext) => (context === null && ({ context, visitor, beforeHook } = createContextAndVisitor(rule)), Object.defineProperties(context, {
20
+ id: { value: eslintContext.id },
21
+ options: { value: eslintContext.options },
22
+ report: { value: eslintContext.report }
23
+ }), Object.setPrototypeOf(context, Object.getPrototypeOf(eslintContext)), beforeHook !== null && beforeHook() === false ? EMPTY_VISITOR : visitor);
24
+ }
25
+ var FILE_CONTEXT = Object.freeze({
26
+ get filename() {
27
+ throw Error("Cannot access `context.filename` in `createOnce`");
28
+ },
29
+ getFilename() {
30
+ throw Error("Cannot call `context.getFilename` in `createOnce`");
31
+ },
32
+ get physicalFilename() {
33
+ throw Error("Cannot access `context.physicalFilename` in `createOnce`");
34
+ },
35
+ getPhysicalFilename() {
36
+ throw Error("Cannot call `context.getPhysicalFilename` in `createOnce`");
37
+ },
38
+ get cwd() {
39
+ throw Error("Cannot access `context.cwd` in `createOnce`");
40
+ },
41
+ getCwd() {
42
+ throw Error("Cannot call `context.getCwd` in `createOnce`");
43
+ },
44
+ get sourceCode() {
45
+ throw Error("Cannot access `context.sourceCode` in `createOnce`");
46
+ },
47
+ getSourceCode() {
48
+ throw Error("Cannot call `context.getSourceCode` in `createOnce`");
49
+ },
50
+ get languageOptions() {
51
+ throw Error("Cannot access `context.languageOptions` in `createOnce`");
52
+ },
53
+ get settings() {
54
+ throw Error("Cannot access `context.settings` in `createOnce`");
55
+ },
56
+ extend(extension) {
57
+ return Object.freeze(Object.assign(Object.create(this), extension));
58
+ },
59
+ get parserOptions() {
60
+ throw Error("Cannot access `context.parserOptions` in `createOnce`");
61
+ },
62
+ get parserPath() {
63
+ throw Error("Cannot access `context.parserPath` in `createOnce`");
64
+ }
65
+ });
66
+ function createContextAndVisitor(rule) {
67
+ let { createOnce } = rule;
68
+ if (createOnce == null)
69
+ throw Error("Rules must define either a `create` or `createOnce` method");
70
+ if (typeof createOnce != "function")
71
+ throw Error("Rule `createOnce` property must be a function");
72
+ let context = Object.create(FILE_CONTEXT, {
73
+ id: {
74
+ value: "",
75
+ enumerable: true,
76
+ configurable: true
77
+ },
78
+ options: {
79
+ value: null,
80
+ enumerable: true,
81
+ configurable: true
82
+ },
83
+ report: {
84
+ value: null,
85
+ enumerable: true,
86
+ configurable: true
87
+ }
88
+ }), { before: beforeHook, after: afterHook, ...visitor } = createOnce.call(rule, context);
89
+ if (beforeHook === undefined)
90
+ beforeHook = null;
91
+ else if (beforeHook !== null && typeof beforeHook != "function")
92
+ throw Error("`before` property of visitor must be a function if defined");
93
+ if (afterHook != null) {
94
+ if (typeof afterHook != "function")
95
+ throw Error("`after` property of visitor must be a function if defined");
96
+ let maxAttrs = -1;
97
+ for (let key2 in visitor) {
98
+ if (!Object.hasOwn(visitor, key2) || !key2.endsWith(":exit"))
99
+ continue;
100
+ let end = key2.length - 5, count = 0;
101
+ for (let i = 0;i < end; i++) {
102
+ let c = key2.charCodeAt(i);
103
+ (c === 91 || c === 46 || c === 58) && count++;
104
+ }
105
+ count > maxAttrs && (maxAttrs = count);
106
+ }
107
+ let key = `Program${"[type]".repeat(maxAttrs + 1)}:exit`;
108
+ visitor[key] = (_node) => afterHook();
109
+ }
110
+ return {
111
+ context,
112
+ visitor,
113
+ beforeHook
114
+ };
115
+ }
116
+
117
+ // packages/plugin-functional/src/utils/ignore.ts
118
+ function asPatterns(pattern) {
119
+ if (pattern == null) {
120
+ return [];
121
+ }
122
+ const values = Array.isArray(pattern) ? pattern : [pattern];
123
+ return values.map((value) => new RegExp(value, "u"));
124
+ }
125
+ function shouldIgnorePattern(name, pattern) {
126
+ if (!name) {
127
+ return false;
128
+ }
129
+ const patterns = asPatterns(pattern);
130
+ return patterns.some((regex) => regex.test(name));
131
+ }
132
+ function isIgnoredViaIdentifierPattern(node, pattern) {
133
+ if (!node) {
134
+ return false;
135
+ }
136
+ if (node.type === "Identifier") {
137
+ return shouldIgnorePattern(node.name, pattern);
138
+ }
139
+ if (node.type === "MemberExpression" && node.property?.type === "Identifier") {
140
+ return shouldIgnorePattern(node.property.name, pattern);
141
+ }
142
+ return false;
143
+ }
144
+
145
+ // packages/plugin-functional/src/rules/immutable-data.ts
146
+ var arrayMutators = new Set(["copyWithin", "fill", "pop", "push", "reverse", "shift", "sort", "splice", "unshift"]);
147
+ var mapAndSetMutators = new Set(["add", "clear", "delete", "set"]);
148
+ function isInsideClass(node) {
149
+ let current = node?.parent;
150
+ while (current) {
151
+ if (current.type === "ClassBody" || current.type === "ClassDeclaration" || current.type === "ClassExpression") {
152
+ return true;
153
+ }
154
+ current = current.parent;
155
+ }
156
+ return false;
157
+ }
158
+ function isImmediateMutation(node) {
159
+ const root = findRootExpression(node);
160
+ return root?.type === "ObjectExpression" || root?.type === "ArrayExpression";
161
+ }
162
+ function findRootExpression(node) {
163
+ let current = node;
164
+ while (current?.type === "MemberExpression") {
165
+ current = current.object;
166
+ }
167
+ return current;
168
+ }
169
+ function getMutatorName(node) {
170
+ if (node.callee?.type !== "MemberExpression") {
171
+ return null;
172
+ }
173
+ if (node.callee.property?.type === "Identifier") {
174
+ return node.callee.property.name;
175
+ }
176
+ return null;
177
+ }
178
+ var immutable_data_default = {
179
+ meta: {
180
+ type: "suggestion",
181
+ docs: {
182
+ description: "Disallow mutating existing data structures.",
183
+ recommended: "error"
184
+ },
185
+ schema: [
186
+ {
187
+ type: "object",
188
+ properties: {
189
+ ignoreClasses: { type: "boolean" },
190
+ ignoreMapsAndSets: { type: "boolean" },
191
+ ignoreImmediateMutation: { type: "boolean" },
192
+ ignoreNonConstDeclarations: { type: "boolean" },
193
+ ignoreIdentifierPattern: {
194
+ anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }]
195
+ }
196
+ },
197
+ additionalProperties: false
198
+ }
199
+ ],
200
+ messages: {
201
+ generic: "Modifying an existing object/array is not allowed.",
202
+ object: "Modifying properties of an existing object is not allowed.",
203
+ array: "Modifying an array is not allowed.",
204
+ map: "Modifying a map is not allowed.",
205
+ set: "Modifying a set is not allowed."
206
+ }
207
+ },
208
+ defaultOptions: [
209
+ {
210
+ ignoreClasses: false,
211
+ ignoreMapsAndSets: false,
212
+ ignoreImmediateMutation: true,
213
+ ignoreNonConstDeclarations: false
214
+ }
215
+ ],
216
+ createOnce(context) {
217
+ const getOpts = () => context.options?.[0] ?? {};
218
+ const shouldIgnoreNode = (node) => {
219
+ const options = getOpts();
220
+ if (options?.ignoreClasses && isInsideClass(node)) {
221
+ return true;
222
+ }
223
+ if (options?.ignoreImmediateMutation && isImmediateMutation(node)) {
224
+ return true;
225
+ }
226
+ if (isIgnoredViaIdentifierPattern(findRootExpression(node), options?.ignoreIdentifierPattern)) {
227
+ return true;
228
+ }
229
+ return false;
230
+ };
231
+ return {
232
+ AssignmentExpression(node) {
233
+ if (node.left?.type !== "MemberExpression" || shouldIgnoreNode(node.left)) {
234
+ return;
235
+ }
236
+ context.report({ node, messageId: "generic" });
237
+ },
238
+ UnaryExpression(node) {
239
+ if (node.operator !== "delete" || node.argument?.type !== "MemberExpression" || shouldIgnoreNode(node.argument)) {
240
+ return;
241
+ }
242
+ context.report({ node, messageId: "generic" });
243
+ },
244
+ UpdateExpression(node) {
245
+ if (node.argument?.type !== "MemberExpression" || shouldIgnoreNode(node.argument)) {
246
+ return;
247
+ }
248
+ context.report({ node, messageId: "generic" });
249
+ },
250
+ CallExpression(node) {
251
+ const mutator = getMutatorName(node);
252
+ if (!mutator || node.callee?.type !== "MemberExpression" || shouldIgnoreNode(node.callee)) {
253
+ return;
254
+ }
255
+ if (arrayMutators.has(mutator)) {
256
+ context.report({ node, messageId: "array" });
257
+ return;
258
+ }
259
+ if (mapAndSetMutators.has(mutator)) {
260
+ if (getOpts()?.ignoreMapsAndSets) {
261
+ return;
262
+ }
263
+ context.report({ node, messageId: mutator === "add" ? "set" : "map" });
264
+ }
265
+ }
266
+ };
267
+ }
268
+ };
269
+
270
+ // packages/plugin-functional/src/utils/tree.ts
271
+ function getEnclosingFunction(node) {
272
+ let current = node?.parent;
273
+ while (current != null) {
274
+ if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") {
275
+ return current;
276
+ }
277
+ current = current.parent;
278
+ }
279
+ return null;
280
+ }
281
+ function isInForLoopInitializer(node) {
282
+ const parent = node?.parent;
283
+ if (parent?.type === "ForStatement") {
284
+ return parent.init === node;
285
+ }
286
+ if (parent?.type === "ForInStatement" || parent?.type === "ForOfStatement") {
287
+ return parent.left === node;
288
+ }
289
+ return false;
290
+ }
291
+
292
+ // packages/plugin-functional/src/rules/no-let.ts
293
+ var no_let_default = {
294
+ meta: {
295
+ type: "suggestion",
296
+ docs: {
297
+ description: "Disallow mutable variables declared with let.",
298
+ recommended: "error"
299
+ },
300
+ schema: [
301
+ {
302
+ type: "object",
303
+ properties: {
304
+ allowInForLoopInit: { type: "boolean" }
305
+ },
306
+ additionalProperties: false
307
+ }
308
+ ],
309
+ messages: {
310
+ generic: "Unexpected let, use const instead."
311
+ }
312
+ },
313
+ defaultOptions: [{ allowInForLoopInit: false }],
314
+ createOnce(context) {
315
+ return {
316
+ VariableDeclaration(node) {
317
+ if (node.kind !== "let") {
318
+ return;
319
+ }
320
+ const allowInForLoopInit = context.options?.[0]?.allowInForLoopInit === true;
321
+ if (allowInForLoopInit && isInForLoopInitializer(node)) {
322
+ return;
323
+ }
324
+ context.report({ node, messageId: "generic" });
325
+ }
326
+ };
327
+ }
328
+ };
329
+
330
+ // packages/plugin-functional/src/rules/no-promise-reject.ts
331
+ var no_promise_reject_default = {
332
+ meta: {
333
+ type: "suggestion",
334
+ docs: {
335
+ description: "Disallow Promise.reject and reject callbacks.",
336
+ recommended: false
337
+ },
338
+ schema: [],
339
+ messages: {
340
+ generic: "Unexpected rejection, resolve an error instead."
341
+ }
342
+ },
343
+ defaultOptions: [{}],
344
+ createOnce(context) {
345
+ return {
346
+ CallExpression(node) {
347
+ if (node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && node.callee.object.name === "Promise" && node.callee.property?.type === "Identifier" && node.callee.property.name === "reject") {
348
+ context.report({ node, messageId: "generic" });
349
+ }
350
+ },
351
+ NewExpression(node) {
352
+ if (node.callee?.type !== "Identifier" || node.callee.name !== "Promise") {
353
+ return;
354
+ }
355
+ const firstArg = node.arguments?.[0];
356
+ if ((firstArg?.type === "ArrowFunctionExpression" || firstArg?.type === "FunctionExpression") && firstArg.params?.[1]) {
357
+ context.report({ node: firstArg.params[1], messageId: "generic" });
358
+ }
359
+ }
360
+ };
361
+ }
362
+ };
363
+
364
+ // packages/plugin-functional/src/rules/no-throw-statements.ts
365
+ var no_throw_statements_default = {
366
+ meta: {
367
+ type: "suggestion",
368
+ docs: {
369
+ description: "Disallow throw statements.",
370
+ recommended: "error"
371
+ },
372
+ schema: [
373
+ {
374
+ type: "object",
375
+ properties: {
376
+ allowInAsyncFunctions: { type: "boolean" }
377
+ },
378
+ additionalProperties: false
379
+ }
380
+ ],
381
+ messages: {
382
+ generic: "Unexpected throw, throwing exceptions is not functional."
383
+ }
384
+ },
385
+ defaultOptions: [{ allowInAsyncFunctions: false }],
386
+ createOnce(context) {
387
+ return {
388
+ ThrowStatement(node) {
389
+ const allowInAsyncFunctions = context.options?.[0]?.allowInAsyncFunctions === true;
390
+ if (allowInAsyncFunctions) {
391
+ const fn = getEnclosingFunction(node);
392
+ if (fn?.async === true) {
393
+ return;
394
+ }
395
+ }
396
+ context.report({ node, messageId: "generic" });
397
+ }
398
+ };
399
+ }
400
+ };
401
+
402
+ // packages/plugin-functional/src/utils/immutability.ts
403
+ function isIdentifierTypeReference(node, name) {
404
+ return node?.type === "TSTypeReference" && node.typeName?.type === "Identifier" && node.typeName.name === name;
405
+ }
406
+ function hasReadonlyModifier(property) {
407
+ if (!property) {
408
+ return false;
409
+ }
410
+ if (property.type === "TSPropertySignature" || property.type === "TSIndexSignature") {
411
+ return property.readonly === true;
412
+ }
413
+ return false;
414
+ }
415
+ function isReadonlyArrayType(typeNode) {
416
+ if (!typeNode) {
417
+ return false;
418
+ }
419
+ if (typeNode.type === "TSArrayType") {
420
+ return false;
421
+ }
422
+ if (typeNode.type === "TSTypeOperator" && typeNode.operator === "readonly" && typeNode.typeAnnotation?.type === "TSArrayType") {
423
+ return true;
424
+ }
425
+ if (isIdentifierTypeReference(typeNode, "ReadonlyArray")) {
426
+ return true;
427
+ }
428
+ if (isIdentifierTypeReference(typeNode, "ReadonlyMap") || isIdentifierTypeReference(typeNode, "ReadonlySet")) {
429
+ return true;
430
+ }
431
+ return false;
432
+ }
433
+ function isMemberReadonly(member) {
434
+ if (!member) {
435
+ return true;
436
+ }
437
+ if (member.type === "TSPropertySignature") {
438
+ if (!member.readonly) {
439
+ return false;
440
+ }
441
+ if (member.typeAnnotation?.typeAnnotation?.type === "TSArrayType") {
442
+ return false;
443
+ }
444
+ if (member.typeAnnotation?.typeAnnotation && member.typeAnnotation.typeAnnotation.type !== "TSArrayType" && !isReadonlyArrayType(member.typeAnnotation.typeAnnotation) && (member.typeAnnotation.typeAnnotation.type === "TSTypeReference" || member.typeAnnotation.typeAnnotation.type === "TSArrayType" || member.typeAnnotation.typeAnnotation.type === "TSTypeOperator")) {
445
+ const typeNode = member.typeAnnotation.typeAnnotation;
446
+ if (typeNode.type === "TSArrayType") {
447
+ return false;
448
+ }
449
+ }
450
+ return true;
451
+ }
452
+ if (member.type === "TSIndexSignature") {
453
+ return member.readonly === true;
454
+ }
455
+ return true;
456
+ }
457
+ function isShallowReadonly(typeNode) {
458
+ if (!typeNode) {
459
+ return false;
460
+ }
461
+ if (isReadonlyArrayType(typeNode)) {
462
+ return true;
463
+ }
464
+ if (typeNode.type === "TSTypeLiteral") {
465
+ return typeNode.members.every((member) => isMemberReadonly(member));
466
+ }
467
+ if (typeNode.type === "TSInterfaceBody") {
468
+ return typeNode.body.every((member) => isMemberReadonly(member));
469
+ }
470
+ return false;
471
+ }
472
+
473
+ // packages/plugin-functional/src/rules/prefer-immutable-types.ts
474
+ function getCategoryOption(option, key) {
475
+ const value = option?.[key] ?? {};
476
+ return {
477
+ enforcement: value.enforcement ?? "ReadonlyShallow",
478
+ ignoreNamePattern: value.ignoreNamePattern
479
+ };
480
+ }
481
+ function getParamName(param) {
482
+ if (param?.type === "Identifier") {
483
+ return param.name;
484
+ }
485
+ return;
486
+ }
487
+ function reportIfNotShallowReadonly(context, node, typeNode, messageId, ignoreNamePattern, name) {
488
+ if (ignoreNamePattern && shouldIgnorePattern(name, ignoreNamePattern)) {
489
+ return;
490
+ }
491
+ if (!typeNode || !isShallowReadonly(typeNode)) {
492
+ context.report({ node, messageId });
493
+ }
494
+ }
495
+ var prefer_immutable_types_default = {
496
+ meta: {
497
+ type: "suggestion",
498
+ docs: {
499
+ description: "Require shallow readonly types for parameters, return types, and variables.",
500
+ recommended: "error"
501
+ },
502
+ schema: [
503
+ {
504
+ type: "object",
505
+ properties: {
506
+ parameters: { type: "object" },
507
+ returnTypes: { type: "object" },
508
+ variables: { type: "object" }
509
+ },
510
+ additionalProperties: false
511
+ }
512
+ ],
513
+ messages: {
514
+ parameter: "Parameter should be typed as ReadonlyShallow.",
515
+ returnType: "Return type should be typed as ReadonlyShallow.",
516
+ variable: "Variable should be typed as ReadonlyShallow.",
517
+ property: "Property should be typed as ReadonlyShallow."
518
+ }
519
+ },
520
+ defaultOptions: [
521
+ {
522
+ parameters: { enforcement: "ReadonlyShallow" },
523
+ returnTypes: { enforcement: "ReadonlyShallow" },
524
+ variables: { enforcement: "ReadonlyShallow" }
525
+ }
526
+ ],
527
+ createOnce(context) {
528
+ const getOpts = () => {
529
+ const option = context.options?.[0] ?? {};
530
+ return {
531
+ parameters: getCategoryOption(option, "parameters"),
532
+ returnTypes: getCategoryOption(option, "returnTypes"),
533
+ variables: getCategoryOption(option, "variables")
534
+ };
535
+ };
536
+ const checkFunction = (node) => {
537
+ const { parameters, returnTypes } = getOpts();
538
+ if (parameters.enforcement === "ReadonlyShallow") {
539
+ for (const param of node.params ?? []) {
540
+ const typeNode = param?.typeAnnotation?.typeAnnotation;
541
+ reportIfNotShallowReadonly(context, param, typeNode, "parameter", parameters.ignoreNamePattern, getParamName(param));
542
+ }
543
+ }
544
+ if (returnTypes.enforcement === "ReadonlyShallow") {
545
+ const typeNode = node.returnType?.typeAnnotation;
546
+ reportIfNotShallowReadonly(context, node, typeNode, "returnType", returnTypes.ignoreNamePattern);
547
+ }
548
+ };
549
+ return {
550
+ FunctionDeclaration: checkFunction,
551
+ FunctionExpression: checkFunction,
552
+ ArrowFunctionExpression: checkFunction,
553
+ TSPropertySignature(node) {
554
+ const { variables } = getOpts();
555
+ if (variables.enforcement !== "ReadonlyShallow") {
556
+ return;
557
+ }
558
+ const typeNode = node.typeAnnotation?.typeAnnotation;
559
+ const name = node.key?.type === "Identifier" ? node.key.name : undefined;
560
+ reportIfNotShallowReadonly(context, node, typeNode, "property", variables.ignoreNamePattern, name);
561
+ },
562
+ VariableDeclarator(node) {
563
+ const { variables } = getOpts();
564
+ if (variables.enforcement !== "ReadonlyShallow") {
565
+ return;
566
+ }
567
+ const typeNode = node.id?.typeAnnotation?.typeAnnotation;
568
+ const name = node.id?.type === "Identifier" ? node.id.name : undefined;
569
+ reportIfNotShallowReadonly(context, node, typeNode, "variable", variables.ignoreNamePattern, name);
570
+ }
571
+ };
572
+ }
573
+ };
574
+
575
+ // packages/plugin-functional/src/rules/prefer-property-signatures.ts
576
+ function hasReadonlyWrapper(node) {
577
+ let current = node?.parent;
578
+ while (current) {
579
+ if (current.type === "TSTypeReference" && current.typeName?.type === "Identifier" && current.typeName.name === "Readonly") {
580
+ return true;
581
+ }
582
+ current = current.parent;
583
+ }
584
+ return false;
585
+ }
586
+ var prefer_property_signatures_default = {
587
+ meta: {
588
+ type: "suggestion",
589
+ docs: {
590
+ description: "Prefer property signatures over method signatures.",
591
+ recommended: "error"
592
+ },
593
+ schema: [
594
+ {
595
+ type: "object",
596
+ properties: {
597
+ ignoreIfReadonlyWrapped: { type: "boolean" }
598
+ },
599
+ additionalProperties: false
600
+ }
601
+ ],
602
+ fixable: "code",
603
+ messages: {
604
+ generic: "Use a property signature instead of a method signature."
605
+ }
606
+ },
607
+ defaultOptions: [{ ignoreIfReadonlyWrapped: false }],
608
+ createOnce(context) {
609
+ return {
610
+ TSMethodSignature(node) {
611
+ const ignoreIfReadonlyWrapped = context.options?.[0]?.ignoreIfReadonlyWrapped === true;
612
+ if (ignoreIfReadonlyWrapped && hasReadonlyWrapper(node)) {
613
+ return;
614
+ }
615
+ context.report({
616
+ node,
617
+ messageId: "generic",
618
+ fix(fixer) {
619
+ const sourceCode = context.sourceCode;
620
+ const keyText = sourceCode.getText(node.key);
621
+ const optional = node.optional ? "?" : "";
622
+ const typeParams = node.typeParameters ? sourceCode.getText(node.typeParameters) : "";
623
+ const params = node.params.map((param) => sourceCode.getText(param)).join(", ");
624
+ const returnType = node.returnType ? sourceCode.getText(node.returnType.typeAnnotation) : "void";
625
+ const replacement = `${keyText}${optional}: ${typeParams}(${params}) => ${returnType}`;
626
+ return fixer.replaceText(node, replacement);
627
+ }
628
+ });
629
+ }
630
+ };
631
+ }
632
+ };
633
+
634
+ // packages/plugin-functional/src/rules/readonly-type.ts
635
+ var readonly_type_default = {
636
+ meta: {
637
+ type: "suggestion",
638
+ docs: {
639
+ description: "Require readonly modifier on type literal properties.",
640
+ recommended: "error"
641
+ },
642
+ schema: [],
643
+ fixable: "code",
644
+ messages: {
645
+ missingReadonly: "Property should have a readonly modifier."
646
+ }
647
+ },
648
+ defaultOptions: [],
649
+ createOnce(context) {
650
+ return {
651
+ TSTypeLiteral(node) {
652
+ for (const member of node.members ?? []) {
653
+ if ((member.type === "TSPropertySignature" || member.type === "TSIndexSignature") && member.readonly !== true) {
654
+ context.report({
655
+ node: member,
656
+ messageId: "missingReadonly",
657
+ fix(fixer) {
658
+ return fixer.insertTextBefore(member, "readonly ");
659
+ }
660
+ });
661
+ }
662
+ }
663
+ }
664
+ };
665
+ }
666
+ };
667
+
668
+ // packages/plugin-functional/src/rules/type-declaration-immutability.ts
669
+ function membersAreReadonly(members) {
670
+ return members.every((member) => {
671
+ if (member.type === "TSPropertySignature") {
672
+ if (!hasReadonlyModifier(member)) {
673
+ return false;
674
+ }
675
+ const typeNode = member.typeAnnotation?.typeAnnotation;
676
+ if (typeNode?.type === "TSArrayType") {
677
+ return false;
678
+ }
679
+ if (typeNode && typeNode.type !== "TSArrayType" && typeNode.type !== "TSTypeLiteral") {
680
+ if (typeNode.type === "TSTypeReference" || typeNode.type === "TSTypeOperator") {
681
+ if (typeNode.type === "TSTypeOperator" && typeNode.operator === "readonly") {
682
+ return true;
683
+ }
684
+ if (isReadonlyArrayType(typeNode)) {
685
+ return true;
686
+ }
687
+ }
688
+ }
689
+ return true;
690
+ }
691
+ if (member.type === "TSIndexSignature") {
692
+ return hasReadonlyModifier(member);
693
+ }
694
+ return true;
695
+ });
696
+ }
697
+ function shouldCheckName(name, identifiers) {
698
+ if (!identifiers) {
699
+ return true;
700
+ }
701
+ return !shouldIgnorePattern(name, identifiers);
702
+ }
703
+ var type_declaration_immutability_default = {
704
+ meta: {
705
+ type: "suggestion",
706
+ docs: {
707
+ description: "Enforce ReadonlyShallow immutability on type declarations.",
708
+ recommended: "error"
709
+ },
710
+ schema: [
711
+ {
712
+ type: "object",
713
+ properties: {
714
+ identifiers: {
715
+ anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }]
716
+ },
717
+ ignoreInterfaces: { type: "boolean" }
718
+ },
719
+ additionalProperties: false
720
+ }
721
+ ],
722
+ messages: {
723
+ notReadonly: "Type declaration should be at least ReadonlyShallow."
724
+ }
725
+ },
726
+ defaultOptions: [{ ignoreInterfaces: false }],
727
+ createOnce(context) {
728
+ const getOpts = () => context.options?.[0] ?? {};
729
+ return {
730
+ TSTypeAliasDeclaration(node) {
731
+ const option = getOpts();
732
+ const name = node.id?.name;
733
+ if (!shouldCheckName(name, option?.identifiers)) {
734
+ return;
735
+ }
736
+ const typeAnnotation = node.typeAnnotation;
737
+ if (typeAnnotation?.type === "TSTypeLiteral") {
738
+ if (!membersAreReadonly(typeAnnotation.members ?? [])) {
739
+ context.report({ node, messageId: "notReadonly" });
740
+ }
741
+ return;
742
+ }
743
+ if (typeAnnotation?.type === "TSIntersectionType") {
744
+ const literalConstituents = (typeAnnotation.types ?? []).filter((t) => t.type === "TSTypeLiteral");
745
+ if (literalConstituents.length === 0) {
746
+ return;
747
+ }
748
+ for (const literal of literalConstituents) {
749
+ if (!membersAreReadonly(literal.members ?? [])) {
750
+ context.report({ node, messageId: "notReadonly" });
751
+ return;
752
+ }
753
+ }
754
+ }
755
+ },
756
+ TSInterfaceDeclaration(node) {
757
+ const option = getOpts();
758
+ if (option?.ignoreInterfaces === true) {
759
+ return;
760
+ }
761
+ const name = node.id?.name;
762
+ if (!shouldCheckName(name, option?.identifiers)) {
763
+ return;
764
+ }
765
+ if (!membersAreReadonly(node.body?.body ?? [])) {
766
+ context.report({ node, messageId: "notReadonly" });
767
+ }
768
+ }
769
+ };
770
+ }
771
+ };
772
+
773
+ // packages/plugin-functional/src/index.ts
774
+ var plugin = eslintCompatPlugin({
775
+ meta: { name: "oxlint-plugin-immutable" },
776
+ rules: {
777
+ "no-let": no_let_default,
778
+ "no-throw-statements": no_throw_statements_default,
779
+ "prefer-property-signatures": prefer_property_signatures_default,
780
+ "prefer-immutable-types": prefer_immutable_types_default,
781
+ "type-declaration-immutability": type_declaration_immutability_default,
782
+ "no-promise-reject": no_promise_reject_default,
783
+ "immutable-data": immutable_data_default,
784
+ "readonly-type": readonly_type_default
785
+ },
786
+ configs: {
787
+ recommended: {
788
+ rules: {
789
+ "functional/no-let": "error",
790
+ "functional/no-throw-statements": "error",
791
+ "functional/prefer-property-signatures": "error",
792
+ "functional/prefer-immutable-types": ["error", { parameters: { enforcement: "ReadonlyShallow" } }],
793
+ "functional/type-declaration-immutability": ["error", { ignoreInterfaces: false }]
794
+ }
795
+ }
796
+ }
797
+ });
798
+ var src_default = plugin;
799
+ export {
800
+ src_default as default
801
+ };
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "oxlint-plugin-immutable",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "Immutability lint rules ported from eslint-plugin-functional to Oxlint's JS plugin API. Also compatible with ESLint flat config.",
6
+ "license": "MIT",
7
+ "main": "./dist/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md"
16
+ ],
17
+ "keywords": [
18
+ "oxlint",
19
+ "eslint",
20
+ "functional",
21
+ "immutability",
22
+ "readonly",
23
+ "lint"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/takinprofit/biome-plugins"
28
+ },
29
+ "engines": {
30
+ "node": ">=18.0.0"
31
+ }
32
+ }