@shuji-bonji/rxjs-mcp 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1042 @@
1
+ /**
2
+ * RxJS Lint Rules Database
3
+ *
4
+ * Regex-based reimplementation of eslint-plugin-rxjs-x rules.
5
+ * These rules work on code snippets without requiring ESLint runtime or TypeScript parser.
6
+ *
7
+ * Reference: https://github.com/JasonWeinzierl/eslint-plugin-rxjs-x
8
+ */
9
+ // Helper: find line number for a match index
10
+ function lineAt(code, index) {
11
+ return code.slice(0, index).split('\n').length;
12
+ }
13
+ // Helper: find all matches with indices
14
+ function findAll(code, regex) {
15
+ const matches = [];
16
+ let m;
17
+ const re = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : regex.flags + 'g');
18
+ while ((m = re.exec(code)) !== null) {
19
+ matches.push(m);
20
+ }
21
+ return matches;
22
+ }
23
+ const DOC_BASE = 'https://github.com/JasonWeinzierl/eslint-plugin-rxjs-x/blob/main/docs/rules';
24
+ // ============================================
25
+ // Recommended Rules (✅)
26
+ // ============================================
27
+ const noAsyncSubscribe = {
28
+ name: 'no-async-subscribe',
29
+ description: 'Disallow passing async functions to subscribe',
30
+ severity: 'error',
31
+ config: 'recommended',
32
+ requiresTypeInfo: true,
33
+ docUrl: `${DOC_BASE}/no-async-subscribe.md`,
34
+ check(code) {
35
+ const diagnostics = [];
36
+ // .subscribe(async (...) => ...) or .subscribe(async function
37
+ const pattern = /\.subscribe\s*\(\s*async\b/g;
38
+ for (const m of findAll(code, pattern)) {
39
+ diagnostics.push({
40
+ rule: this.name,
41
+ severity: this.severity,
42
+ message: 'Passing async function to subscribe is not allowed. Use operators like switchMap/mergeMap instead.',
43
+ line: lineAt(code, m.index),
44
+ suggestion: 'Replace with: .pipe(switchMap(async () => ...)).subscribe()',
45
+ docUrl: this.docUrl,
46
+ });
47
+ }
48
+ return diagnostics;
49
+ },
50
+ };
51
+ const noCreate = {
52
+ name: 'no-create',
53
+ description: 'Disallow Observable.create and Subject.create',
54
+ severity: 'error',
55
+ config: 'recommended',
56
+ requiresTypeInfo: true,
57
+ docUrl: `${DOC_BASE}/no-create.md`,
58
+ check(code) {
59
+ const diagnostics = [];
60
+ const pattern = /\b(Observable|Subject)\.create\s*\(/g;
61
+ for (const m of findAll(code, pattern)) {
62
+ diagnostics.push({
63
+ rule: this.name,
64
+ severity: this.severity,
65
+ message: `${m[1]}.create is deprecated. Use \`new Observable()\` constructor instead.`,
66
+ line: lineAt(code, m.index),
67
+ suggestion: 'Replace with: new Observable(subscriber => { ... })',
68
+ docUrl: this.docUrl,
69
+ });
70
+ }
71
+ return diagnostics;
72
+ },
73
+ };
74
+ const noIgnoredReplayBuffer = {
75
+ name: 'no-ignored-replay-buffer',
76
+ description: 'Disallow using ReplaySubject, publishReplay or shareReplay without specifying buffer size',
77
+ severity: 'error',
78
+ config: 'recommended',
79
+ requiresTypeInfo: false,
80
+ docUrl: `${DOC_BASE}/no-ignored-replay-buffer.md`,
81
+ check(code) {
82
+ const diagnostics = [];
83
+ // new ReplaySubject() without args
84
+ const replaySubjectPattern = /new\s+ReplaySubject\s*\(\s*\)/g;
85
+ for (const m of findAll(code, replaySubjectPattern)) {
86
+ diagnostics.push({
87
+ rule: this.name,
88
+ severity: this.severity,
89
+ message: 'ReplaySubject created without specifying buffer size. This buffers all emissions indefinitely.',
90
+ line: lineAt(code, m.index),
91
+ suggestion: 'Specify buffer size: new ReplaySubject(1)',
92
+ docUrl: this.docUrl,
93
+ });
94
+ }
95
+ // shareReplay() without args
96
+ const shareReplayPattern = /shareReplay\s*\(\s*\)/g;
97
+ for (const m of findAll(code, shareReplayPattern)) {
98
+ diagnostics.push({
99
+ rule: this.name,
100
+ severity: this.severity,
101
+ message: 'shareReplay() without config object may cause memory issues.',
102
+ line: lineAt(code, m.index),
103
+ suggestion: 'Use: shareReplay({ bufferSize: 1, refCount: true })',
104
+ docUrl: this.docUrl,
105
+ });
106
+ }
107
+ // publishReplay() without args
108
+ const publishReplayPattern = /publishReplay\s*\(\s*\)/g;
109
+ for (const m of findAll(code, publishReplayPattern)) {
110
+ diagnostics.push({
111
+ rule: this.name,
112
+ severity: this.severity,
113
+ message: 'publishReplay() without buffer size may cause memory issues.',
114
+ line: lineAt(code, m.index),
115
+ suggestion: 'Specify buffer size: publishReplay(1)',
116
+ docUrl: this.docUrl,
117
+ });
118
+ }
119
+ return diagnostics;
120
+ },
121
+ };
122
+ const noIgnoredTakewhileValue = {
123
+ name: 'no-ignored-takewhile-value',
124
+ description: 'Disallow ignoring the value within takeWhile',
125
+ severity: 'warning',
126
+ config: 'recommended',
127
+ requiresTypeInfo: false,
128
+ docUrl: `${DOC_BASE}/no-ignored-takewhile-value.md`,
129
+ check(code) {
130
+ const diagnostics = [];
131
+ // takeWhile(() => someCondition) — callback ignores its parameter
132
+ const pattern = /takeWhile\s*\(\s*\(\s*\)\s*=>/g;
133
+ for (const m of findAll(code, pattern)) {
134
+ diagnostics.push({
135
+ rule: this.name,
136
+ severity: this.severity,
137
+ message: 'takeWhile callback ignores its value parameter. Consider using takeUntil instead.',
138
+ line: lineAt(code, m.index),
139
+ suggestion: 'If not using the emitted value, use takeUntil(notifier$) instead.',
140
+ docUrl: this.docUrl,
141
+ });
142
+ }
143
+ return diagnostics;
144
+ },
145
+ };
146
+ const noImplicitAnyCatch = {
147
+ name: 'no-implicit-any-catch',
148
+ description: 'Disallow implicit any error parameters in catchError, subscribe, and tap',
149
+ severity: 'warning',
150
+ config: 'recommended',
151
+ requiresTypeInfo: true,
152
+ docUrl: `${DOC_BASE}/no-implicit-any-catch.md`,
153
+ check(code) {
154
+ const diagnostics = [];
155
+ // catchError(err => ...) or catchError((err) => ...) without type annotation
156
+ // Heuristic: parameter without `: Type`
157
+ const pattern = /catchError\s*\(\s*\(?\s*(\w+)\s*\)?\s*=>/g;
158
+ for (const m of findAll(code, pattern)) {
159
+ // Check if there's a type annotation
160
+ const paramArea = code.slice(m.index, m.index + m[0].length + 20);
161
+ if (!paramArea.includes(':')) {
162
+ diagnostics.push({
163
+ rule: this.name,
164
+ severity: this.severity,
165
+ message: `Error parameter '${m[1]}' has implicit 'any' type in catchError.`,
166
+ line: lineAt(code, m.index),
167
+ suggestion: `Add type annotation: catchError((${m[1]}: unknown) => ...)`,
168
+ docUrl: this.docUrl,
169
+ });
170
+ }
171
+ }
172
+ return diagnostics;
173
+ },
174
+ };
175
+ const noIndex = {
176
+ name: 'no-index',
177
+ description: 'Disallow importing index modules',
178
+ severity: 'error',
179
+ config: 'recommended',
180
+ requiresTypeInfo: false,
181
+ docUrl: `${DOC_BASE}/no-index.md`,
182
+ check(code) {
183
+ const diagnostics = [];
184
+ const pattern = /from\s+['"]rxjs\/index['"]/g;
185
+ for (const m of findAll(code, pattern)) {
186
+ diagnostics.push({
187
+ rule: this.name,
188
+ severity: this.severity,
189
+ message: 'Do not import from rxjs/index. Import from rxjs directly.',
190
+ line: lineAt(code, m.index),
191
+ suggestion: "Replace with: import { ... } from 'rxjs'",
192
+ docUrl: this.docUrl,
193
+ });
194
+ }
195
+ return diagnostics;
196
+ },
197
+ };
198
+ const noInternal = {
199
+ name: 'no-internal',
200
+ description: 'Disallow importing internal modules',
201
+ severity: 'error',
202
+ config: 'recommended',
203
+ requiresTypeInfo: false,
204
+ docUrl: `${DOC_BASE}/no-internal.md`,
205
+ check(code) {
206
+ const diagnostics = [];
207
+ const pattern = /from\s+['"]rxjs\/internal\b/g;
208
+ for (const m of findAll(code, pattern)) {
209
+ diagnostics.push({
210
+ rule: this.name,
211
+ severity: this.severity,
212
+ message: 'Do not import from rxjs/internal. These are private implementation details.',
213
+ line: lineAt(code, m.index),
214
+ suggestion: "Import from 'rxjs' or 'rxjs/operators' instead.",
215
+ docUrl: this.docUrl,
216
+ });
217
+ }
218
+ return diagnostics;
219
+ },
220
+ };
221
+ const noNestedSubscribe = {
222
+ name: 'no-nested-subscribe',
223
+ description: 'Disallow calling subscribe within a subscribe callback',
224
+ severity: 'error',
225
+ config: 'recommended',
226
+ requiresTypeInfo: true,
227
+ docUrl: `${DOC_BASE}/no-nested-subscribe.md`,
228
+ check(code) {
229
+ const diagnostics = [];
230
+ const subscribePattern = /\.subscribe\s*\(/g;
231
+ const matches = findAll(code, subscribePattern);
232
+ if (matches.length < 2)
233
+ return diagnostics;
234
+ // For each subscribe call, find its balanced scope and check for inner subscribes
235
+ for (let i = 0; i < matches.length; i++) {
236
+ const outerStart = matches[i].index + matches[i][0].length - 1; // position of '('
237
+ // Find balanced end of this subscribe(...)
238
+ let depth = 0;
239
+ let endIndex = -1;
240
+ for (let j = outerStart; j < code.length; j++) {
241
+ if (code[j] === '(')
242
+ depth++;
243
+ else if (code[j] === ')') {
244
+ depth--;
245
+ if (depth === 0) {
246
+ endIndex = j;
247
+ break;
248
+ }
249
+ }
250
+ }
251
+ if (endIndex === -1)
252
+ continue;
253
+ // Check if any other subscribe match falls inside this range
254
+ const innerContent = code.slice(outerStart + 1, endIndex);
255
+ const innerSubscribe = /\.subscribe\s*\(/.exec(innerContent);
256
+ if (innerSubscribe) {
257
+ const absolutePos = outerStart + 1 + innerSubscribe.index;
258
+ diagnostics.push({
259
+ rule: this.name,
260
+ severity: this.severity,
261
+ message: 'Nested subscribe detected. Use flattening operators (switchMap, mergeMap, concatMap) instead.',
262
+ line: lineAt(code, absolutePos),
263
+ suggestion: 'Refactor to: source$.pipe(switchMap(value => inner$)).subscribe()',
264
+ docUrl: this.docUrl,
265
+ });
266
+ break; // Report once per code snippet
267
+ }
268
+ }
269
+ return diagnostics;
270
+ },
271
+ };
272
+ const noShareReplay = {
273
+ name: 'no-sharereplay',
274
+ description: 'Disallow unsafe shareReplay usage (without config object)',
275
+ severity: 'error',
276
+ config: 'recommended',
277
+ requiresTypeInfo: false,
278
+ docUrl: `${DOC_BASE}/no-sharereplay.md`,
279
+ check(code) {
280
+ const diagnostics = [];
281
+ // shareReplay(number) without config object — only number arg
282
+ const pattern = /shareReplay\s*\(\s*\d+\s*\)/g;
283
+ for (const m of findAll(code, pattern)) {
284
+ diagnostics.push({
285
+ rule: this.name,
286
+ severity: this.severity,
287
+ message: 'shareReplay with number argument is unsafe. Use config object with refCount.',
288
+ line: lineAt(code, m.index),
289
+ suggestion: 'Replace with: shareReplay({ bufferSize: N, refCount: true })',
290
+ docUrl: this.docUrl,
291
+ });
292
+ }
293
+ return diagnostics;
294
+ },
295
+ };
296
+ const noSubjectUnsubscribe = {
297
+ name: 'no-subject-unsubscribe',
298
+ description: 'Disallow calling unsubscribe on subjects',
299
+ severity: 'error',
300
+ config: 'recommended',
301
+ requiresTypeInfo: true,
302
+ docUrl: `${DOC_BASE}/no-subject-unsubscribe.md`,
303
+ check(code) {
304
+ const diagnostics = [];
305
+ // Heuristic: variable names containing 'subject' or 'Subject' followed by .unsubscribe()
306
+ const pattern = /(\w*[Ss]ubject\w*)\.unsubscribe\s*\(\s*\)/g;
307
+ for (const m of findAll(code, pattern)) {
308
+ diagnostics.push({
309
+ rule: this.name,
310
+ severity: this.severity,
311
+ message: `Do not call unsubscribe() on Subject '${m[1]}'. Use .complete() instead.`,
312
+ line: lineAt(code, m.index),
313
+ suggestion: 'Call .complete() to properly terminate the Subject.',
314
+ docUrl: this.docUrl,
315
+ });
316
+ }
317
+ return diagnostics;
318
+ },
319
+ };
320
+ const noSubscribeInPipe = {
321
+ name: 'no-subscribe-in-pipe',
322
+ description: 'Disallow calling subscribe within pipe operators',
323
+ severity: 'error',
324
+ config: 'recommended',
325
+ requiresTypeInfo: true,
326
+ docUrl: `${DOC_BASE}/no-subscribe-in-pipe.md`,
327
+ check(code) {
328
+ const diagnostics = [];
329
+ // Look for .subscribe() inside .pipe()
330
+ const pipePattern = /\.pipe\s*\(/g;
331
+ for (const pipeMatch of findAll(code, pipePattern)) {
332
+ const pipeStart = pipeMatch.index;
333
+ // Find balanced content
334
+ let depth = 0;
335
+ let started = false;
336
+ for (let j = pipeStart + pipeMatch[0].length - 1; j < code.length; j++) {
337
+ if (code[j] === '(') {
338
+ depth++;
339
+ started = true;
340
+ }
341
+ else if (code[j] === ')') {
342
+ depth--;
343
+ if (started && depth === 0) {
344
+ const pipeContent = code.slice(pipeStart, j + 1);
345
+ const subInPipe = /\.subscribe\s*\(/g;
346
+ const subMatches = findAll(pipeContent, subInPipe);
347
+ for (const sm of subMatches) {
348
+ diagnostics.push({
349
+ rule: this.name,
350
+ severity: this.severity,
351
+ message: 'Do not call subscribe() inside pipe(). Use flattening operators instead.',
352
+ line: lineAt(code, pipeStart + sm.index),
353
+ suggestion: 'Use switchMap, mergeMap, or concatMap to handle inner Observables.',
354
+ docUrl: this.docUrl,
355
+ });
356
+ }
357
+ break;
358
+ }
359
+ }
360
+ }
361
+ }
362
+ return diagnostics;
363
+ },
364
+ };
365
+ const noToPromise = {
366
+ name: 'no-topromise',
367
+ description: 'Disallow use of the deprecated toPromise method',
368
+ severity: 'error',
369
+ config: 'recommended',
370
+ requiresTypeInfo: true,
371
+ docUrl: `${DOC_BASE}/no-topromise.md`,
372
+ check(code) {
373
+ const diagnostics = [];
374
+ const pattern = /\.toPromise\s*\(\s*\)/g;
375
+ for (const m of findAll(code, pattern)) {
376
+ diagnostics.push({
377
+ rule: this.name,
378
+ severity: this.severity,
379
+ message: 'toPromise() is deprecated. Use firstValueFrom() or lastValueFrom() instead.',
380
+ line: lineAt(code, m.index),
381
+ suggestion: "Replace with: firstValueFrom(observable$) or lastValueFrom(observable$)",
382
+ docUrl: this.docUrl,
383
+ });
384
+ }
385
+ return diagnostics;
386
+ },
387
+ };
388
+ const noUnsafeTakeuntil = {
389
+ name: 'no-unsafe-takeuntil',
390
+ description: 'Disallow applying operators after takeUntil',
391
+ severity: 'error',
392
+ config: 'recommended',
393
+ requiresTypeInfo: true,
394
+ docUrl: `${DOC_BASE}/no-unsafe-takeuntil.md`,
395
+ check(code) {
396
+ const diagnostics = [];
397
+ // Check if operators appear after takeUntil in a pipe
398
+ // Safe operators after takeUntil: shareReplay, share, publishReplay, finalize
399
+ const safeAfterTakeUntil = ['shareReplay', 'share', 'publishReplay', 'finalize', 'toArray', 'count', 'reduce'];
400
+ const pipePattern = /\.pipe\s*\(/g;
401
+ for (const pipeMatch of findAll(code, pipePattern)) {
402
+ const pipeStart = pipeMatch.index + pipeMatch[0].length - 1;
403
+ // Extract pipe content
404
+ let depth = 0;
405
+ let started = false;
406
+ for (let j = pipeStart; j < code.length; j++) {
407
+ if (code[j] === '(') {
408
+ depth++;
409
+ started = true;
410
+ }
411
+ else if (code[j] === ')') {
412
+ depth--;
413
+ if (started && depth === 0) {
414
+ const pipeContent = code.slice(pipeStart + 1, j);
415
+ // Find takeUntil position
416
+ const takeUntilMatch = /takeUntil\s*\(/.exec(pipeContent);
417
+ if (takeUntilMatch) {
418
+ const afterTakeUntil = pipeContent.slice(takeUntilMatch.index + takeUntilMatch[0].length);
419
+ // Find balanced end of takeUntil(...)
420
+ let tuDepth = 1;
421
+ let tuEnd = 0;
422
+ for (let k = 0; k < afterTakeUntil.length; k++) {
423
+ if (afterTakeUntil[k] === '(')
424
+ tuDepth++;
425
+ else if (afterTakeUntil[k] === ')') {
426
+ tuDepth--;
427
+ if (tuDepth === 0) {
428
+ tuEnd = k + 1;
429
+ break;
430
+ }
431
+ }
432
+ }
433
+ const remaining = afterTakeUntil.slice(tuEnd).trim();
434
+ // Check if there are operators after takeUntil
435
+ if (remaining.length > 0) {
436
+ // Check if they're safe operators
437
+ const operatorPattern = /\b(\w+)\s*\(/g;
438
+ const ops = findAll(remaining, operatorPattern);
439
+ for (const op of ops) {
440
+ if (!safeAfterTakeUntil.includes(op[1]) && op[1].length > 1) {
441
+ diagnostics.push({
442
+ rule: this.name,
443
+ severity: this.severity,
444
+ message: `Operator '${op[1]}' after takeUntil may cause issues. takeUntil should be the last operator before subscribe.`,
445
+ line: lineAt(code, pipeMatch.index + takeUntilMatch.index),
446
+ suggestion: 'Move takeUntil to the end of the pipe (before share/finalize only).',
447
+ docUrl: this.docUrl,
448
+ });
449
+ break;
450
+ }
451
+ }
452
+ }
453
+ }
454
+ break;
455
+ }
456
+ }
457
+ }
458
+ }
459
+ return diagnostics;
460
+ },
461
+ };
462
+ const preferObserver = {
463
+ name: 'prefer-observer',
464
+ description: 'Disallow passing separate handlers to subscribe and tap',
465
+ severity: 'warning',
466
+ config: 'recommended',
467
+ requiresTypeInfo: true,
468
+ docUrl: `${DOC_BASE}/prefer-observer.md`,
469
+ check(code) {
470
+ const diagnostics = [];
471
+ // .subscribe(fn, fn) — two or more function arguments
472
+ const pattern = /\.subscribe\s*\(\s*(?:\([^)]*\)|[a-zA-Z_$]\w*)\s*(?:=>.*?|(?:\{[^}]*\}))\s*,/g;
473
+ for (const m of findAll(code, pattern)) {
474
+ diagnostics.push({
475
+ rule: this.name,
476
+ severity: this.severity,
477
+ message: 'Passing separate callbacks to subscribe is deprecated. Use an Observer object instead.',
478
+ line: lineAt(code, m.index),
479
+ suggestion: 'Replace with: .subscribe({ next: fn, error: fn, complete: fn })',
480
+ docUrl: this.docUrl,
481
+ });
482
+ }
483
+ return diagnostics;
484
+ },
485
+ };
486
+ const preferRootOperators = {
487
+ name: 'prefer-root-operators',
488
+ description: "Disallow importing operators from 'rxjs/operators'",
489
+ severity: 'error',
490
+ config: 'recommended',
491
+ requiresTypeInfo: false,
492
+ docUrl: `${DOC_BASE}/prefer-root-operators.md`,
493
+ check(code) {
494
+ const diagnostics = [];
495
+ const pattern = /from\s+['"]rxjs\/operators['"]/g;
496
+ for (const m of findAll(code, pattern)) {
497
+ diagnostics.push({
498
+ rule: this.name,
499
+ severity: this.severity,
500
+ message: "Import operators from 'rxjs' directly, not from 'rxjs/operators'.",
501
+ line: lineAt(code, m.index),
502
+ suggestion: "Replace with: import { map, filter, ... } from 'rxjs'",
503
+ docUrl: this.docUrl,
504
+ });
505
+ }
506
+ return diagnostics;
507
+ },
508
+ };
509
+ const throwError = {
510
+ name: 'throw-error',
511
+ description: 'Enforce passing only Error values to throwError',
512
+ severity: 'warning',
513
+ config: 'recommended',
514
+ requiresTypeInfo: true,
515
+ docUrl: `${DOC_BASE}/throw-error.md`,
516
+ check(code) {
517
+ const diagnostics = [];
518
+ // throwError('string') or throwError(() => 'string')
519
+ const pattern = /throwError\s*\(\s*(?:\(\)\s*=>\s*)?['"`]/g;
520
+ for (const m of findAll(code, pattern)) {
521
+ diagnostics.push({
522
+ rule: this.name,
523
+ severity: this.severity,
524
+ message: 'Pass an Error instance to throwError, not a string.',
525
+ line: lineAt(code, m.index),
526
+ suggestion: "Replace with: throwError(() => new Error('message'))",
527
+ docUrl: this.docUrl,
528
+ });
529
+ }
530
+ return diagnostics;
531
+ },
532
+ };
533
+ const noIgnoredNotifier = {
534
+ name: 'no-ignored-notifier',
535
+ description: 'Disallow observables not composed from the repeatWhen or retryWhen notifier',
536
+ severity: 'warning',
537
+ config: 'recommended',
538
+ requiresTypeInfo: true,
539
+ docUrl: `${DOC_BASE}/no-ignored-notifier.md`,
540
+ check(code) {
541
+ const diagnostics = [];
542
+ // retryWhen(() => timer(1000)) — ignoring the notifications parameter
543
+ const pattern = /(retryWhen|repeatWhen)\s*\(\s*\(\s*\)\s*=>/g;
544
+ for (const m of findAll(code, pattern)) {
545
+ diagnostics.push({
546
+ rule: this.name,
547
+ severity: this.severity,
548
+ message: `${m[1]} callback ignores its notifier parameter. The notifier should be used to compose the return value.`,
549
+ line: lineAt(code, m.index),
550
+ suggestion: `Use the notifier: ${m[1]}(notifications$ => notifications$.pipe(...))`,
551
+ docUrl: this.docUrl,
552
+ });
553
+ }
554
+ return diagnostics;
555
+ },
556
+ };
557
+ const noRedundantNotify = {
558
+ name: 'no-redundant-notify',
559
+ description: 'Disallow sending redundant notifications from completed or errored observables',
560
+ severity: 'warning',
561
+ config: 'recommended',
562
+ requiresTypeInfo: true,
563
+ docUrl: `${DOC_BASE}/no-redundant-notify.md`,
564
+ check(code) {
565
+ const diagnostics = [];
566
+ // subject.complete(); subject.next() — next after complete
567
+ const completeNextPattern = /\.complete\s*\(\s*\)\s*;[^}]*\.next\s*\(/g;
568
+ for (const m of findAll(code, completeNextPattern)) {
569
+ diagnostics.push({
570
+ rule: this.name,
571
+ severity: this.severity,
572
+ message: 'Calling next() after complete() has no effect. The subject is already closed.',
573
+ line: lineAt(code, m.index),
574
+ docUrl: this.docUrl,
575
+ });
576
+ }
577
+ // subject.error(); subject.next()
578
+ const errorNextPattern = /\.error\s*\([^)]*\)\s*;[^}]*\.next\s*\(/g;
579
+ for (const m of findAll(code, errorNextPattern)) {
580
+ diagnostics.push({
581
+ rule: this.name,
582
+ severity: this.severity,
583
+ message: 'Calling next() after error() has no effect. The subject is already closed.',
584
+ line: lineAt(code, m.index),
585
+ docUrl: this.docUrl,
586
+ });
587
+ }
588
+ return diagnostics;
589
+ },
590
+ };
591
+ const noUnsafeSubjectNext = {
592
+ name: 'no-unsafe-subject-next',
593
+ description: 'Disallow unsafe optional next calls on Subjects',
594
+ severity: 'warning',
595
+ config: 'recommended',
596
+ requiresTypeInfo: true,
597
+ docUrl: `${DOC_BASE}/no-unsafe-subject-next.md`,
598
+ check(code) {
599
+ const diagnostics = [];
600
+ // subject.next() without argument (when Subject has a type parameter)
601
+ // Heuristic: new Subject<Type>() ... subject.next()
602
+ const subjectDecl = /new\s+(?:Behavior|Replay|Async)?Subject\s*<\s*(?!void)[^>]+>/g;
603
+ if (findAll(code, subjectDecl).length > 0) {
604
+ const nextWithoutArg = /\.next\s*\(\s*\)/g;
605
+ for (const m of findAll(code, nextWithoutArg)) {
606
+ diagnostics.push({
607
+ rule: this.name,
608
+ severity: this.severity,
609
+ message: 'Calling .next() without a value on a typed Subject is unsafe.',
610
+ line: lineAt(code, m.index),
611
+ suggestion: 'Pass a value: .next(value)',
612
+ docUrl: this.docUrl,
613
+ });
614
+ }
615
+ }
616
+ return diagnostics;
617
+ },
618
+ };
619
+ const noUnboundMethods = {
620
+ name: 'no-unbound-methods',
621
+ description: 'Disallow passing unbound methods to subscribe or operators',
622
+ severity: 'warning',
623
+ config: 'recommended',
624
+ requiresTypeInfo: true,
625
+ docUrl: `${DOC_BASE}/no-unbound-methods.md`,
626
+ check(code) {
627
+ const diagnostics = [];
628
+ // .subscribe(this.method) or tap(this.method)
629
+ const pattern = /(?:\.subscribe|tap)\s*\(\s*this\.\w+\s*[,)]/g;
630
+ for (const m of findAll(code, pattern)) {
631
+ diagnostics.push({
632
+ rule: this.name,
633
+ severity: this.severity,
634
+ message: 'Passing unbound method may lose `this` context.',
635
+ line: lineAt(code, m.index),
636
+ suggestion: 'Bind the method: .subscribe(this.method.bind(this)) or use arrow: .subscribe(v => this.method(v))',
637
+ docUrl: this.docUrl,
638
+ });
639
+ }
640
+ return diagnostics;
641
+ },
642
+ };
643
+ // ============================================
644
+ // Strict Rules (🔒)
645
+ // ============================================
646
+ const noExposedSubjects = {
647
+ name: 'no-exposed-subjects',
648
+ description: 'Disallow public and protected subjects',
649
+ severity: 'warning',
650
+ config: 'strict',
651
+ requiresTypeInfo: true,
652
+ docUrl: `${DOC_BASE}/no-exposed-subjects.md`,
653
+ check(code) {
654
+ const diagnostics = [];
655
+ // public subject = new Subject() or public mySubject$
656
+ const pattern = /\b(public|protected)\s+\w*(?:[Ss]ubject|\$)\w*\s*(?:[:=])/g;
657
+ for (const m of findAll(code, pattern)) {
658
+ diagnostics.push({
659
+ rule: this.name,
660
+ severity: this.severity,
661
+ message: `${m[1]} subjects should be private. Expose as Observable using .asObservable().`,
662
+ line: lineAt(code, m.index),
663
+ suggestion: 'Make the subject private and expose a public observable: public obs$ = this.subject.asObservable()',
664
+ docUrl: this.docUrl,
665
+ });
666
+ }
667
+ return diagnostics;
668
+ },
669
+ };
670
+ const noFloatingObservables = {
671
+ name: 'no-floating-observables',
672
+ description: 'Require Observables to be handled appropriately',
673
+ severity: 'warning',
674
+ config: 'strict',
675
+ requiresTypeInfo: true,
676
+ docUrl: `${DOC_BASE}/no-floating-observables.md`,
677
+ check(code) {
678
+ const diagnostics = [];
679
+ // Heuristic: .pipe(...) at end of statement without .subscribe() or assignment
680
+ // This is a simplified check
681
+ const pattern = /^\s*\w+\$?\.pipe\s*\([^]*?\)\s*;/gm;
682
+ for (const m of findAll(code, pattern)) {
683
+ if (!m[0].includes('.subscribe') && !m[0].includes('return')) {
684
+ diagnostics.push({
685
+ rule: this.name,
686
+ severity: this.severity,
687
+ message: 'Observable is not subscribed or assigned. This creates a floating Observable.',
688
+ line: lineAt(code, m.index),
689
+ suggestion: 'Subscribe to the Observable or assign it to a variable.',
690
+ docUrl: this.docUrl,
691
+ });
692
+ }
693
+ }
694
+ return diagnostics;
695
+ },
696
+ };
697
+ const noIgnoredError = {
698
+ name: 'no-ignored-error',
699
+ description: 'Disallow calling subscribe without specifying an error handler',
700
+ severity: 'warning',
701
+ config: 'strict',
702
+ requiresTypeInfo: true,
703
+ docUrl: `${DOC_BASE}/no-ignored-error.md`,
704
+ check(code) {
705
+ const diagnostics = [];
706
+ // .subscribe({ next: ... }) without error handler — observer style
707
+ const observerPattern = /\.subscribe\s*\(\s*\{[^}]*\}\s*\)/g;
708
+ for (const m of findAll(code, observerPattern)) {
709
+ const content = m[0];
710
+ if (content.includes('next') && !content.includes('error')) {
711
+ diagnostics.push({
712
+ rule: this.name,
713
+ severity: this.severity,
714
+ message: 'subscribe() is missing an error handler. Unhandled errors will be thrown.',
715
+ line: lineAt(code, m.index),
716
+ suggestion: 'Add error handler: .subscribe({ next: ..., error: err => ... })',
717
+ docUrl: this.docUrl,
718
+ });
719
+ }
720
+ }
721
+ return diagnostics;
722
+ },
723
+ };
724
+ const noIgnoredDefaultValue = {
725
+ name: 'no-ignored-default-value',
726
+ description: 'Disallow using firstValueFrom/lastValueFrom/first/last without a default value',
727
+ severity: 'warning',
728
+ config: 'strict',
729
+ requiresTypeInfo: true,
730
+ docUrl: `${DOC_BASE}/no-ignored-default-value.md`,
731
+ check(code) {
732
+ const diagnostics = [];
733
+ // firstValueFrom(obs$) without defaultValue option
734
+ const fvfPattern = /firstValueFrom\s*\(\s*\w+\$?\s*\)/g;
735
+ for (const m of findAll(code, fvfPattern)) {
736
+ diagnostics.push({
737
+ rule: this.name,
738
+ severity: this.severity,
739
+ message: 'firstValueFrom() without defaultValue will throw EmptyError if source completes empty.',
740
+ line: lineAt(code, m.index),
741
+ suggestion: 'Add defaultValue: firstValueFrom(obs$, { defaultValue: fallback })',
742
+ docUrl: this.docUrl,
743
+ });
744
+ }
745
+ // lastValueFrom(obs$)
746
+ const lvfPattern = /lastValueFrom\s*\(\s*\w+\$?\s*\)/g;
747
+ for (const m of findAll(code, lvfPattern)) {
748
+ diagnostics.push({
749
+ rule: this.name,
750
+ severity: this.severity,
751
+ message: 'lastValueFrom() without defaultValue will throw EmptyError if source completes empty.',
752
+ line: lineAt(code, m.index),
753
+ suggestion: 'Add defaultValue: lastValueFrom(obs$, { defaultValue: fallback })',
754
+ docUrl: this.docUrl,
755
+ });
756
+ }
757
+ return diagnostics;
758
+ },
759
+ };
760
+ const noShareReplayBeforeTakeuntil = {
761
+ name: 'no-sharereplay-before-takeuntil',
762
+ description: 'Disallow using shareReplay({ refCount: false }) before takeUntil',
763
+ severity: 'warning',
764
+ config: 'strict',
765
+ requiresTypeInfo: false,
766
+ docUrl: `${DOC_BASE}/no-sharereplay-before-takeuntil.md`,
767
+ check(code) {
768
+ const diagnostics = [];
769
+ // shareReplay followed by takeUntil in a pipe
770
+ const pattern = /shareReplay\s*\([^)]*\)[^]*?takeUntil\s*\(/g;
771
+ for (const m of findAll(code, pattern)) {
772
+ if (m[0].includes('refCount: false') || !m[0].includes('refCount')) {
773
+ diagnostics.push({
774
+ rule: this.name,
775
+ severity: this.severity,
776
+ message: 'shareReplay without refCount: true before takeUntil will prevent unsubscription.',
777
+ line: lineAt(code, m.index),
778
+ suggestion: 'Move shareReplay after takeUntil, or use shareReplay({ refCount: true })',
779
+ docUrl: this.docUrl,
780
+ });
781
+ }
782
+ }
783
+ return diagnostics;
784
+ },
785
+ };
786
+ const noMisusedObservables = {
787
+ name: 'no-misused-observables',
788
+ description: 'Disallow Observables in places not designed to handle them',
789
+ severity: 'warning',
790
+ config: 'strict',
791
+ requiresTypeInfo: true,
792
+ docUrl: `${DOC_BASE}/no-misused-observables.md`,
793
+ check(code) {
794
+ const diagnostics = [];
795
+ // if (observable$) — using observable in boolean context
796
+ const pattern = /\bif\s*\(\s*\w+\$\s*\)/g;
797
+ for (const m of findAll(code, pattern)) {
798
+ diagnostics.push({
799
+ rule: this.name,
800
+ severity: this.severity,
801
+ message: 'Observable used in boolean context. Observables are always truthy.',
802
+ line: lineAt(code, m.index),
803
+ suggestion: 'Did you mean to subscribe or use firstValueFrom()?',
804
+ docUrl: this.docUrl,
805
+ });
806
+ }
807
+ // await observable$ (without firstValueFrom)
808
+ const awaitPattern = /\bawait\s+\w+\$(?!\s*\.)/g;
809
+ for (const m of findAll(code, awaitPattern)) {
810
+ diagnostics.push({
811
+ rule: this.name,
812
+ severity: this.severity,
813
+ message: 'Awaiting an Observable directly does not work as expected.',
814
+ line: lineAt(code, m.index),
815
+ suggestion: 'Use: await firstValueFrom(observable$)',
816
+ docUrl: this.docUrl,
817
+ });
818
+ }
819
+ return diagnostics;
820
+ },
821
+ };
822
+ const noSubclass = {
823
+ name: 'no-subclass',
824
+ description: 'Disallow subclassing RxJS classes',
825
+ severity: 'warning',
826
+ config: 'strict',
827
+ requiresTypeInfo: true,
828
+ docUrl: `${DOC_BASE}/no-subclass.md`,
829
+ check(code) {
830
+ const diagnostics = [];
831
+ const pattern = /\bclass\s+\w+\s+extends\s+(Observable|Subject|BehaviorSubject|ReplaySubject|AsyncSubject|Subscriber)\b/g;
832
+ for (const m of findAll(code, pattern)) {
833
+ diagnostics.push({
834
+ rule: this.name,
835
+ severity: this.severity,
836
+ message: `Do not subclass ${m[1]}. Compose Observables instead.`,
837
+ line: lineAt(code, m.index),
838
+ suggestion: 'Use composition: create a service/class that wraps an Observable internally.',
839
+ docUrl: this.docUrl,
840
+ });
841
+ }
842
+ return diagnostics;
843
+ },
844
+ };
845
+ // ============================================
846
+ // Additional Rules (not in configs, but useful)
847
+ // ============================================
848
+ const finnish = {
849
+ name: 'finnish',
850
+ description: 'Enforce Finnish notation ($ suffix) for Observable variables',
851
+ severity: 'info',
852
+ config: 'strict', // Not in official recommended/strict, but commonly enabled
853
+ requiresTypeInfo: true,
854
+ docUrl: `${DOC_BASE}/finnish.md`,
855
+ check(code) {
856
+ const diagnostics = [];
857
+ // Variable assignments from Observable-returning patterns without $ suffix
858
+ // Heuristic: const/let varName = something.pipe(...) without $ in varName
859
+ const pattern = /\b(?:const|let)\s+(\w+)\s*=\s*(?:\w+\.pipe\s*\(|new\s+(?:Observable|Subject|BehaviorSubject|ReplaySubject))/g;
860
+ for (const m of findAll(code, pattern)) {
861
+ if (!m[1].endsWith('$') && !m[1].startsWith('_')) {
862
+ diagnostics.push({
863
+ rule: this.name,
864
+ severity: 'info',
865
+ message: `Observable variable '${m[1]}' should have a '$' suffix (Finnish notation).`,
866
+ line: lineAt(code, m.index),
867
+ suggestion: `Rename to: ${m[1]}$`,
868
+ docUrl: this.docUrl,
869
+ });
870
+ }
871
+ }
872
+ return diagnostics;
873
+ },
874
+ };
875
+ // ============================================
876
+ // Framework-Specific Rules
877
+ // ============================================
878
+ const frameworkRules = [
879
+ {
880
+ name: 'angular/prefer-takeuntil-destroy',
881
+ description: 'Prefer takeUntilDestroyed or takeUntil(destroy$) pattern in Angular components',
882
+ severity: 'warning',
883
+ config: 'recommended',
884
+ requiresTypeInfo: false,
885
+ docUrl: 'https://github.com/JasonWeinzierl/eslint-plugin-rxjs-angular-x',
886
+ check(code, framework) {
887
+ if (framework !== 'angular')
888
+ return [];
889
+ const diagnostics = [];
890
+ // Check for subscribe in Angular-looking code without cleanup patterns
891
+ const hasComponent = /@Component\s*\(/.test(code) || /implements\s+On(?:Init|Destroy)/.test(code) || /ngOnInit|ngOnDestroy/.test(code);
892
+ if (!hasComponent)
893
+ return [];
894
+ const hasCleanup = /takeUntil|takeUntilDestroyed|DestroyRef|ngOnDestroy/.test(code);
895
+ const subscribePattern = /\.subscribe\s*\(/g;
896
+ const subscribeCount = findAll(code, subscribePattern).length;
897
+ if (subscribeCount > 0 && !hasCleanup) {
898
+ diagnostics.push({
899
+ rule: this.name,
900
+ severity: this.severity,
901
+ message: `Found ${subscribeCount} subscribe() call(s) in Angular component without cleanup pattern.`,
902
+ line: 1,
903
+ suggestion: 'Use takeUntilDestroyed() (Angular 16+) or takeUntil(this.destroy$) with ngOnDestroy.',
904
+ docUrl: this.docUrl,
905
+ });
906
+ }
907
+ return diagnostics;
908
+ },
909
+ },
910
+ {
911
+ name: 'angular/no-async-pipe-with-subscribe',
912
+ description: 'Avoid mixing async pipe and subscribe in same component',
913
+ severity: 'info',
914
+ config: 'strict',
915
+ requiresTypeInfo: false,
916
+ docUrl: 'https://github.com/JasonWeinzierl/eslint-plugin-rxjs-angular-x',
917
+ check(code, framework) {
918
+ if (framework !== 'angular')
919
+ return [];
920
+ const diagnostics = [];
921
+ const hasAsyncPipe = /\|\s*async/.test(code);
922
+ const hasSubscribe = /\.subscribe\s*\(/.test(code);
923
+ if (hasAsyncPipe && hasSubscribe) {
924
+ diagnostics.push({
925
+ rule: this.name,
926
+ severity: 'info',
927
+ message: 'Mixing async pipe and manual subscribe in the same component. Consider using one pattern consistently.',
928
+ line: 1,
929
+ suggestion: 'Prefer async pipe for template-consumed data, or use subscribe consistently with proper cleanup.',
930
+ docUrl: this.docUrl,
931
+ });
932
+ }
933
+ return diagnostics;
934
+ },
935
+ },
936
+ {
937
+ name: 'react/no-subscribe-in-component',
938
+ description: 'Avoid manual subscribe in React components without cleanup',
939
+ severity: 'warning',
940
+ config: 'recommended',
941
+ requiresTypeInfo: false,
942
+ docUrl: 'https://github.com/JasonWeinzierl/eslint-plugin-rxjs-x',
943
+ check(code, framework) {
944
+ if (framework !== 'react')
945
+ return [];
946
+ const diagnostics = [];
947
+ const hasReact = /\buseEffect\b|\buseState\b|React\.FC|function\s+\w+.*return\s*\(/.test(code);
948
+ if (!hasReact)
949
+ return [];
950
+ const hasUseEffect = /useEffect\s*\(/.test(code);
951
+ const subscribePattern = /\.subscribe\s*\(/g;
952
+ const subscribeMatches = findAll(code, subscribePattern);
953
+ if (subscribeMatches.length > 0 && !hasUseEffect) {
954
+ diagnostics.push({
955
+ rule: this.name,
956
+ severity: this.severity,
957
+ message: 'subscribe() in React component without useEffect. Subscriptions will leak on re-renders.',
958
+ line: lineAt(code, subscribeMatches[0].index),
959
+ suggestion: 'Wrap subscribe in useEffect and return cleanup: useEffect(() => { const sub = obs$.subscribe(...); return () => sub.unsubscribe(); }, [])',
960
+ docUrl: this.docUrl,
961
+ });
962
+ }
963
+ return diagnostics;
964
+ },
965
+ },
966
+ {
967
+ name: 'vue/no-subscribe-without-unmount',
968
+ description: 'Avoid subscribe without onUnmounted cleanup in Vue',
969
+ severity: 'warning',
970
+ config: 'recommended',
971
+ requiresTypeInfo: false,
972
+ docUrl: 'https://github.com/JasonWeinzierl/eslint-plugin-rxjs-x',
973
+ check(code, framework) {
974
+ if (framework !== 'vue')
975
+ return [];
976
+ const diagnostics = [];
977
+ const hasVue = /\bonMounted\b|\bsetup\s*\(|\bonUnmounted\b|\bdefineComponent/.test(code);
978
+ if (!hasVue)
979
+ return [];
980
+ const hasCleanup = /onUnmounted|onBeforeUnmount/.test(code);
981
+ const subscribePattern = /\.subscribe\s*\(/g;
982
+ const subscribeMatches = findAll(code, subscribePattern);
983
+ if (subscribeMatches.length > 0 && !hasCleanup) {
984
+ diagnostics.push({
985
+ rule: this.name,
986
+ severity: this.severity,
987
+ message: 'subscribe() in Vue component without onUnmounted cleanup.',
988
+ line: lineAt(code, subscribeMatches[0].index),
989
+ suggestion: 'Add cleanup: onUnmounted(() => subscription.unsubscribe())',
990
+ docUrl: this.docUrl,
991
+ });
992
+ }
993
+ return diagnostics;
994
+ },
995
+ },
996
+ ];
997
+ // ============================================
998
+ // Rule Registry
999
+ // ============================================
1000
+ export const allLintRules = [
1001
+ // Recommended
1002
+ noAsyncSubscribe,
1003
+ noCreate,
1004
+ noIgnoredReplayBuffer,
1005
+ noIgnoredTakewhileValue,
1006
+ noImplicitAnyCatch,
1007
+ noIndex,
1008
+ noInternal,
1009
+ noNestedSubscribe,
1010
+ noIgnoredNotifier,
1011
+ noRedundantNotify,
1012
+ noShareReplay,
1013
+ noSubjectUnsubscribe,
1014
+ noSubscribeInPipe,
1015
+ noToPromise,
1016
+ noUnboundMethods,
1017
+ noUnsafeSubjectNext,
1018
+ noUnsafeTakeuntil,
1019
+ preferObserver,
1020
+ preferRootOperators,
1021
+ throwError,
1022
+ // Strict
1023
+ noExposedSubjects,
1024
+ noFloatingObservables,
1025
+ noIgnoredError,
1026
+ noIgnoredDefaultValue,
1027
+ noShareReplayBeforeTakeuntil,
1028
+ noMisusedObservables,
1029
+ noSubclass,
1030
+ finnish,
1031
+ // Framework-specific
1032
+ ...frameworkRules,
1033
+ ];
1034
+ /** Get rules for a given config level */
1035
+ export function getRulesForConfig(config) {
1036
+ if (config === 'strict') {
1037
+ return allLintRules;
1038
+ }
1039
+ // recommended: only rules with config === 'recommended'
1040
+ return allLintRules.filter(r => r.config === 'recommended');
1041
+ }
1042
+ //# sourceMappingURL=lint-rules.js.map