Skip to content

Commit

Permalink
feat(rule): Add no-unsafe-catch rule.
Browse files Browse the repository at this point in the history
  • Loading branch information
cartant committed May 16, 2018
1 parent cf01189 commit b3a3e01
Show file tree
Hide file tree
Showing 10 changed files with 443 additions and 0 deletions.
165 changes: 165 additions & 0 deletions source/rules/rxjsNoUnsafeCatchRule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* @license Use of this source code is governed by an MIT-style license that
* can be found in the LICENSE file at https://github.com/cartant/rxjs-tslint-rules
*/
/*tslint:disable:no-use-before-declare*/

import * as Lint from "tslint";
import * as ts from "typescript";
import * as tsutils from "tsutils";
import * as decamelize from "decamelize";

import { couldBeType, isReferenceType } from "../support/util";

export class Rule extends Lint.Rules.TypedRule {

public static metadata: Lint.IRuleMetadata = {
description: "Disallows unsafe catch usage in effects and epics.",
options: {
properties: {
observable: {
oneOf: [
{ type: "string" },
{ type: "array", items: { type: "string" } }
]
}
},
type: "object"
},
optionsDescription: Lint.Utils.dedent`
An optional object with an optional \`observable\` property.
The property can be specifed as a regular expression string or as an array of words and is used to identify the action observables from which effects and epics are composed.`,
requiresTypeInfo: true,
ruleName: "rxjs-no-unsafe-catch",
type: "functionality",
typescriptOnly: true
};

public static FAILURE_STRING = "Unsafe catch usage in effects and epics is forbidden";

public applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): Lint.RuleFailure[] {
return this.applyWithWalker(new Walker(sourceFile, this.getOptions(), program));
}
}

export class Walker extends Lint.ProgramAwareRuleWalker {

public static METHODS_REGEXP = /(ofType|pipe)/;
public static DEFAULT_OBSERVABLE = "action(s|\\$)?";

private observableRegExp: RegExp;

public static createRegExp(value: any): RegExp | null {

if (!value || !value.length) {
return null;
}
const flags = "i";
if (typeof value === "string") {
return new RegExp(value, flags);
}
const words = value as string[];
const joined = words.map(word => `(\\b|_)${word}(\\b|_)`).join("|");
return new RegExp(`(${joined})`, flags);
}

constructor(sourceFile: ts.SourceFile, rawOptions: Lint.IOptions, program: ts.Program) {

super(sourceFile, rawOptions, program);

const [options] = this.getOptions();
if (options && (options.allow || options.disallow)) {
this.observableRegExp = new RegExp(options.observable || Walker.DEFAULT_OBSERVABLE, "i");
} else {
this.observableRegExp = new RegExp(Walker.DEFAULT_OBSERVABLE, "i");
}
}

protected visitCallExpression(node: ts.CallExpression): void {

const { expression: propertyAccessExpression } = node;
if (tsutils.isPropertyAccessExpression(propertyAccessExpression)) {

const { expression: identifier } = propertyAccessExpression;
if (tsutils.isIdentifier(identifier)) {

const propertyName = propertyAccessExpression.name.getText();
const identifierText = identifier.getText();
const typeChecker = this.getTypeChecker();
const type = typeChecker.getTypeAtLocation(identifier);

if (isReferenceType(type) &&
this.observableRegExp.test(identifierText) &&
Walker.METHODS_REGEXP.test(propertyName) &&
couldBeType(type.target, "Observable")) {

switch (propertyName) {
case "ofType":
this.walkPatchedTypes(node);
break;
case "pipe":
this.walkPipedTypes(node);
break;
default:
break;
}
}
}
}

super.visitCallExpression(node);
}

private walkPatchedOperators(node: ts.Node): void {

let name: ts.Identifier | undefined = undefined;
for (let parent = node.parent; parent; parent = parent.parent) {
if (tsutils.isCallExpression(parent)) {
if (name) {
switch (name.getText()) {
case "catch":
this.addFailureAtNode(name, Rule.FAILURE_STRING);
break;
case "pipe":
this.walkPipedOperators(parent);
break;
default:
break;
}
}
} else if (tsutils.isPropertyAccessExpression(parent)) {
name = parent.name;
} else {
break;
}
}
}

private walkPatchedTypes(node: ts.CallExpression): void {

this.walkPatchedOperators(node);
}

private walkPipedOperators(node: ts.CallExpression): void {
node.arguments.forEach(arg => {
if (tsutils.isCallExpression(arg)) {
const { expression } = arg;
if (tsutils.isIdentifier(expression) && (expression.getText() === "catchError")) {
this.addFailureAtNode(expression, Rule.FAILURE_STRING);
}
}
});
}

private walkPipedTypes(node: ts.CallExpression): void {

node.arguments.forEach(arg => {
if (tsutils.isCallExpression(arg)) {
const { expression } = arg;
if (tsutils.isIdentifier(expression) && (expression.getText() === "ofType")) {
this.walkPipedOperators(node);
}
}
});
}
}
89 changes: 89 additions & 0 deletions test/v5/fixtures/no-unsafe-catch/default/fixture.ts.lint
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Observable } from "rxjs/Observable";
import { catchError, switchMap, tap } from "rxjs/operators";
import "rxjs/add/observable/empty";
import "rxjs/add/observable/of";
import "rxjs/add/operator/catch";
import "rxjs/add/operator/do";
import "rxjs/add/operator/switchMap";

declare module "rxjs/Observable" {
interface Observable<T> {
ofType(type: string, ...moreTypes: string[]): Observable<T>;
}
}

function ofType<T>(type: string, ...moreTypes: string[]): (source: Observable<T>) => Observable<T> {
return source => source;
}

type Actions = Observable<any>;
const actions = Observable.of({});
const empty = Observable.empty() as Observable<never>;

const safePatchedEffect = actions.ofType("DO_SOMETHING")
.do(() => {})
.switchMap(() => empty.catch(() => empty));
const unsafePatchedEffect = actions.ofType("DO_SOMETHING")
.do(() => {})
.switchMap(() => empty)
.catch(() => empty);
~~~~~ [no-unsafe-catch]

const safePipedEffect = actions.ofType("DO_SOMETHING").pipe(
tap(() => {}),
switchMap(() => empty.pipe(catchError(() => empty)))
);
const unsafePipedEffect = actions.ofType("DO_SOMETHING").pipe(
tap(() => {}),
switchMap(() => empty),
catchError(() => empty)
~~~~~~~~~~ [no-unsafe-catch]
);

const safePatchedEpic = (action$: Actions) => action$.ofType("DO_SOMETHING")
.do(() => {})
.switchMap(() => empty.catch(() => empty));
const unsafePatchedEpic = (action$: Actions) => action$.ofType("DO_SOMETHING")
.do(() => {})
.switchMap(() => empty)
.catch(() => empty);
~~~~~ [no-unsafe-catch]

const safePipedEpic = (action$: Actions) => action$.ofType("DO_SOMETHING").pipe(
tap(() => {}),
switchMap(() => empty.pipe(catchError(() => empty)))
);
const unsafePipedEpic = (action$: Actions) => action$.ofType("DO_SOMETHING").pipe(
tap(() => {}),
switchMap(() => empty),
catchError(() => empty)
~~~~~~~~~~ [no-unsafe-catch]
);

const safePipedOfTypeEffect = actions.pipe(
ofType("DO_SOMETHING"),
tap(() => {}),
switchMap(() => empty.pipe(catchError(() => empty)))
);
const unsafePipedOfTypeEffect = actions.pipe(
ofType("DO_SOMETHING"),
tap(() => {}),
switchMap(() => empty),
catchError(() => empty)
~~~~~~~~~~ [no-unsafe-catch]
);

const safePipedOfTypeEpic = (action$: Actions) => action$.pipe(
ofType("DO_SOMETHING"),
tap(() => {}),
switchMap(() => empty.pipe(catchError(() => empty)))
);
const unsafePipedOfTypeEpic = (action$: Actions) => action$.pipe(
ofType("DO_SOMETHING"),
tap(() => {}),
switchMap(() => empty),
catchError(() => empty)
~~~~~~~~~~ [no-unsafe-catch]
);

[no-unsafe-catch]: Unsafe catch usage in effects and epics is forbidden
13 changes: 13 additions & 0 deletions test/v5/fixtures/no-unsafe-catch/default/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"baseUrl": ".",
"lib": ["es2015"],
"noEmit": true,
"paths": {
"rxjs": ["../../node_modules/rxjs"]
},
"skipLibCheck": true,
"target": "es5"
},
"include": ["fixture.ts"]
}
8 changes: 8 additions & 0 deletions test/v5/fixtures/no-unsafe-catch/default/tslint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"defaultSeverity": "error",
"jsRules": {},
"rules": {
"rxjs-no-unsafe-catch": { "severity": "error" }
},
"rulesDirectory": "../../../../../build/rules"
}
89 changes: 89 additions & 0 deletions test/v6-compat/fixtures/no-unsafe-catch/default/fixture.ts.lint
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Observable } from "rxjs/Observable";
import { catchError, switchMap, tap } from "rxjs/operators";
import "rxjs/add/observable/empty";
import "rxjs/add/observable/of";
import "rxjs/add/operator/catch";
import "rxjs/add/operator/do";
import "rxjs/add/operator/switchMap";

declare module "rxjs/internal/Observable" {
interface Observable<T> {
ofType(type: string, ...moreTypes: string[]): Observable<T>;
}
}

function ofType<T>(type: string, ...moreTypes: string[]): (source: Observable<T>) => Observable<T> {
return source => source;
}

type Actions = Observable<any>;
const actions = Observable.of({});
const empty = Observable.empty() as Observable<never>;

const safePatchedEffect = actions.ofType("DO_SOMETHING")
.do(() => {})
.switchMap(() => empty.catch(() => empty));
const unsafePatchedEffect = actions.ofType("DO_SOMETHING")
.do(() => {})
.switchMap(() => empty)
.catch(() => empty);
~~~~~ [no-unsafe-catch]

const safePipedEffect = actions.ofType("DO_SOMETHING").pipe(
tap(() => {}),
switchMap(() => empty.pipe(catchError(() => empty)))
);
const unsafePipedEffect = actions.ofType("DO_SOMETHING").pipe(
tap(() => {}),
switchMap(() => empty),
catchError(() => empty)
~~~~~~~~~~ [no-unsafe-catch]
);

const safePatchedEpic = (action$: Actions) => action$.ofType("DO_SOMETHING")
.do(() => {})
.switchMap(() => empty.catch(() => empty));
const unsafePatchedEpic = (action$: Actions) => action$.ofType("DO_SOMETHING")
.do(() => {})
.switchMap(() => empty)
.catch(() => empty);
~~~~~ [no-unsafe-catch]

const safePipedEpic = (action$: Actions) => action$.ofType("DO_SOMETHING").pipe(
tap(() => {}),
switchMap(() => empty.pipe(catchError(() => empty)))
);
const unsafePipedEpic = (action$: Actions) => action$.ofType("DO_SOMETHING").pipe(
tap(() => {}),
switchMap(() => empty),
catchError(() => empty)
~~~~~~~~~~ [no-unsafe-catch]
);

const safePipedOfTypeEffect = actions.pipe(
ofType("DO_SOMETHING"),
tap(() => {}),
switchMap(() => empty.pipe(catchError(() => empty)))
);
const unsafePipedOfTypeEffect = actions.pipe(
ofType("DO_SOMETHING"),
tap(() => {}),
switchMap(() => empty),
catchError(() => empty)
~~~~~~~~~~ [no-unsafe-catch]
);

const safePipedOfTypeEpic = (action$: Actions) => action$.pipe(
ofType("DO_SOMETHING"),
tap(() => {}),
switchMap(() => empty.pipe(catchError(() => empty)))
);
const unsafePipedOfTypeEpic = (action$: Actions) => action$.pipe(
ofType("DO_SOMETHING"),
tap(() => {}),
switchMap(() => empty),
catchError(() => empty)
~~~~~~~~~~ [no-unsafe-catch]
);

[no-unsafe-catch]: Unsafe catch usage in effects and epics is forbidden
13 changes: 13 additions & 0 deletions test/v6-compat/fixtures/no-unsafe-catch/default/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"baseUrl": ".",
"lib": ["es2015"],
"noEmit": true,
"paths": {
"rxjs": ["../../node_modules/rxjs"]
},
"skipLibCheck": true,
"target": "es5"
},
"include": ["fixture.ts"]
}
8 changes: 8 additions & 0 deletions test/v6-compat/fixtures/no-unsafe-catch/default/tslint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"defaultSeverity": "error",
"jsRules": {},
"rules": {
"rxjs-no-unsafe-catch": { "severity": "error" }
},
"rulesDirectory": "../../../../../build/rules"
}
Loading

0 comments on commit b3a3e01

Please sign in to comment.