Skip to content

Commit

Permalink
feat(mutators): Add method expression mutator (#3508)
Browse files Browse the repository at this point in the history
Add the method expression mutator. It mutates methods call expressions:

- Change `.endsWith()` to `.startsWith()` and vice versa
- Change `.trimEnd()` to `.trimStart()` and vice versa
- Change `.toLowerCase()` to .` toUpperCase()` and vice versa
- Change `.toLocalLowerCase()` to `.toLocalUpperCase() ` and vice versa
- Change `.some()` to `.every() ` and vice versa
- Remove `.trim()`
- Remove `.substr()`
- Remove `.substring()`
- Remove `.sort()`
- Remove `.reverse()`
- Remove `.filter()`
- Remove `.slice()`
- Remove `.charAt()`

If you don't like this, you can disable this mutator using:

```json
{
  "mutator": {
    "excludedMutations": ["MethodExpression"]
  }
}
```

Co-authored-by: Nico Jansen <jansennico@gmail.com>
  • Loading branch information
JoshuaKGoldberg and nicojs authored May 30, 2022
1 parent 907a5d0 commit 70a4e4f
Show file tree
Hide file tree
Showing 10 changed files with 331 additions and 33 deletions.
14 changes: 7 additions & 7 deletions e2e/test/babel-transpiling/verify/verify.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@ Object {
"compileErrors": 0,
"ignored": 0,
"killed": 25,
"mutationScore": 55.55555555555556,
"mutationScoreBasedOnCoveredCode": 55.55555555555556,
"mutationScore": 53.191489361702125,
"mutationScoreBasedOnCoveredCode": 53.191489361702125,
"noCoverage": 0,
"runtimeErrors": 1,
"survived": 20,
"survived": 22,
"timeout": 0,
"totalCovered": 45,
"totalCovered": 47,
"totalDetected": 25,
"totalInvalid": 1,
"totalMutants": 46,
"totalUndetected": 20,
"totalValid": 45,
"totalMutants": 48,
"totalUndetected": 22,
"totalValid": 47,
}
`;
18 changes: 9 additions & 9 deletions e2e/test/cucumber-ts/verify/verify.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@ exports[`After running stryker on a cucumber-ts project should report expected s
Object {
"compileErrors": 0,
"ignored": 0,
"killed": 64,
"mutationScore": 45.13888888888889,
"mutationScoreBasedOnCoveredCode": 61.904761904761905,
"killed": 66,
"mutationScore": 45.89041095890411,
"mutationScoreBasedOnCoveredCode": 62.616822429906534,
"noCoverage": 39,
"runtimeErrors": 16,
"runtimeErrors": 17,
"survived": 40,
"timeout": 1,
"totalCovered": 105,
"totalDetected": 65,
"totalInvalid": 16,
"totalMutants": 160,
"totalCovered": 107,
"totalDetected": 67,
"totalInvalid": 17,
"totalMutants": 163,
"totalUndetected": 79,
"totalValid": 144,
"totalValid": 146,
}
`;
20 changes: 10 additions & 10 deletions e2e/test/jest-react-ts/verify/verify.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@ exports[`After running stryker on jest-react-ts project should report expected s
Object {
"compileErrors": 0,
"ignored": 0,
"killed": 61,
"mutationScore": 61.111111111111114,
"mutationScoreBasedOnCoveredCode": 73.33333333333333,
"noCoverage": 18,
"killed": 62,
"mutationScore": 60.36036036036037,
"mutationScoreBasedOnCoveredCode": 72.82608695652173,
"noCoverage": 19,
"runtimeErrors": 0,
"survived": 24,
"survived": 25,
"timeout": 5,
"totalCovered": 90,
"totalDetected": 66,
"totalCovered": 92,
"totalDetected": 67,
"totalInvalid": 0,
"totalMutants": 108,
"totalUndetected": 42,
"totalValid": 108,
"totalMutants": 111,
"totalUndetected": 44,
"totalValid": 111,
}
`;
14 changes: 7 additions & 7 deletions e2e/test/typescript-transpiling/verify/verify.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@ exports[`Verify stryker has ran correctly should report correct score 1`] = `
Object {
"compileErrors": 10,
"ignored": 0,
"killed": 42,
"mutationScore": 75,
"mutationScoreBasedOnCoveredCode": 91.30434782608695,
"killed": 43,
"mutationScore": 75.43859649122807,
"mutationScoreBasedOnCoveredCode": 91.48936170212765,
"noCoverage": 10,
"runtimeErrors": 0,
"survived": 4,
"timeout": 0,
"totalCovered": 46,
"totalDetected": 42,
"totalCovered": 47,
"totalDetected": 43,
"totalInvalid": 10,
"totalMutants": 66,
"totalMutants": 67,
"totalUndetected": 14,
"totalValid": 56,
"totalValid": 57,
}
`;
66 changes: 66 additions & 0 deletions packages/instrumenter/src/mutators/method-expression-mutator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import babel from '@babel/core';

import { deepCloneNode } from '../util/syntax-helpers.js';

import { NodeMutator } from './node-mutator.js';

const { types } = babel;

const replacements = new Map([
['charAt', null],
['endsWith', 'startsWith'],
['every', 'some'],
['filter', null],
['reverse', null],
['slice', null],
['sort', null],
['substr', null],
['substring', null],
['toLocaleLowerCase', 'toLocaleUpperCase'],
['toLowerCase', 'toUpperCase'],
['trim', null],
['trimEnd', 'trimStart'],
]);

for (const [key, value] of Array.from(replacements)) {
if (value) {
replacements.set(value, key);
}
}

export const methodExpressionMutator: NodeMutator = {
name: 'MethodExpression',

*mutate(path) {
if (!(path.isCallExpression() || path.isOptionalCallExpression())) {
return;
}

const { callee } = path.node;
if (!(types.isMemberExpression(callee) || types.isOptionalMemberExpression(callee)) || !types.isIdentifier(callee.property)) {
return;
}

const newName = replacements.get(callee.property.name);
if (newName === undefined) {
return;
}

if (newName === null) {
// Remove the method expression. I.e. `foo.trim()` => `foo`
yield deepCloneNode(callee.object);
return;
}

// Replace the method expression. I.e. `foo.toLowerCase()` => `foo.toUpperCase`
const nodeArguments = path.node.arguments.map((argumentNode) => deepCloneNode(argumentNode));

const mutatedCallee = types.isMemberExpression(callee)
? types.memberExpression(deepCloneNode(callee.object), types.identifier(newName), false, callee.optional)
: types.optionalMemberExpression(deepCloneNode(callee.object), types.identifier(newName), false, callee.optional);

yield types.isCallExpression(path.node)
? types.callExpression(mutatedCallee, nodeArguments)
: types.optionalCallExpression(mutatedCallee, nodeArguments, path.node.optional);
},
};
2 changes: 2 additions & 0 deletions packages/instrumenter/src/mutators/mutate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { arrayDeclarationMutator } from './array-declaration-mutator.js';
import { arrowFunctionMutator } from './arrow-function-mutator.js';
import { booleanLiteralMutator } from './boolean-literal-mutator.js';
import { equalityOperatorMutator } from './equality-operator-mutator.js';
import { methodExpressionMutator } from './method-expression-mutator.js';
import { logicalOperatorMutator } from './logical-operator-mutator.js';
import { objectLiteralMutator } from './object-literal-mutator.js';
import { unaryOperatorMutator } from './unary-operator-mutator.js';
Expand All @@ -24,6 +25,7 @@ export const allMutators: NodeMutator[] = [
conditionalExpressionMutator,
equalityOperatorMutator,
logicalOperatorMutator,
methodExpressionMutator,
objectLiteralMutator,
stringLiteralMutator,
unaryOperatorMutator,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ describe('instrumenter integration', () => {
it('should be able to instrument optional chains', async () => {
await arrangeAndActAssert('optional-chains.ts');
});
it('should be able to instrument functional chains', async () => {
await arrangeAndActAssert('functional-chains.js');
});
it('should be able to instrument a vue sample', async () => {
await arrangeAndActAssert('vue-sample.vue');
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { expect } from 'chai';

import { methodExpressionMutator as sut } from '../../../src/mutators/method-expression-mutator.js';
import { expectJSMutation } from '../../helpers/expect-mutation.js';

describe(sut.name, () => {
it('should have name "MethodExpression"', () => {
expect(sut.name).eq('MethodExpression');
});

describe('functions', () => {
it('should ignore a non-method function', () => {
expectJSMutation(sut, 'function endsWith() {} endsWith();');
});
});

describe('methods', () => {
for (const [input, output, description] of [
['text.trim();', 'text;', 'removed, non-optional'],
['text?.trim();', 'text;', 'removed, optional member'],
['text.trim?.();', 'text;', 'removed, optional call'],
['text?.trim?.();', 'text;', 'removed, optional member, optional call'],
['parent.text?.trim();', 'parent.text;', 'removed, optional member in a non-optional parent'],
['parent.text.trim?.();', 'parent.text;', 'removed, optional call in a non-optional parent'],
['parent.text?.trim?.();', 'parent.text;', 'removed, optional member, optional call in a non-optional parent'],
['parent?.text?.trim();', 'parent?.text;', 'removed, optional member in an optional parent'],
['parent?.text.trim?.();', 'parent?.text;', 'removed, optional call in an optional parent'],
['parent?.text?.trim?.();', 'parent?.text;', 'removed, optional member, optional call in an optional parent'],
['text.startsWith();', 'text.endsWith();', 'replaced, non-optional'],
['text?.startsWith();', 'text?.endsWith();', 'replaced, optional member'],
['text.startsWith?.();', 'text.endsWith?.();', 'replaced, optional call'],
['text?.startsWith?.();', 'text?.endsWith?.();', 'replaced, optional member, optional call'],
['text.startsWith("foo").valueOf();', 'text.endsWith("foo").valueOf();', 'replaced in the middle of a chain'],
['parent.text?.startsWith();', 'parent.text?.endsWith();', 'replaced, optional member in a non-optional parent'],
['parent.text.startsWith?.();', 'parent.text.endsWith?.();', 'replaced, optional call in a non-optional parent'],
['parent.text?.startsWith?.();', 'parent.text?.endsWith?.();', 'replaced, optional member, optional call in a non-optional parent'],
['parent?.text?.startsWith();', 'parent?.text?.endsWith();', 'replaced, optional member in an optional parent'],
['parent?.text.startsWith?.();', 'parent?.text.endsWith?.();', 'replaced, optional call in an optional parent'],
['parent?.text?.startsWith?.();', 'parent?.text?.endsWith?.();', 'replaced, optional member, optional call in an optional parent'],
['text.trim(abc);', 'text;', 'removed, non-optional with an argument'],
['text?.trim(abc);', 'text;', 'removed, optional member with an argument'],
['text.trim?.(abc);', 'text;', 'removed, optional call with an argument'],
['text?.trim?.(abc);', 'text;', 'removed, optional member, optional call with an argument'],
['parent.text?.trim(abc);', 'parent.text;', 'removed, optional member in a non-optional parent with an argument'],
['parent.text.trim?.(abc);', 'parent.text;', 'removed, optional call in a non-optional parent with an argument'],
['parent.text?.trim?.(abc);', 'parent.text;', 'removed, optional member, optional call in a non-optional parent with an argument'],
['parent?.text?.trim(abc);', 'parent?.text;', 'removed, optional member in an optional parent with an argument'],
['parent?.text.trim?.(abc);', 'parent?.text;', 'removed, optional call in an optional parent with an argument'],
['parent?.text?.trim?.(abc);', 'parent?.text;', 'removed, optional member, optional call in an optional parent with an argument'],
['text.startsWith(abc);', 'text.endsWith(abc);', 'replaced, non-optional with an argument'],
['text?.startsWith(abc);', 'text?.endsWith(abc);', 'replaced, optional member with an argument'],
['text.startsWith?.(abc);', 'text.endsWith?.(abc);', 'replaced, optional call with an argument'],
['text?.startsWith?.(abc);', 'text?.endsWith?.(abc);', 'replaced, optional member, optional call with an argument'],
['parent.text?.startsWith(abc);', 'parent.text?.endsWith(abc);', 'replaced, optional member in a non-optional parent with an argument'],
['parent.text.startsWith?.(abc);', 'parent.text.endsWith?.(abc);', 'replaced, optional call in a non-optional parent with an argument'],
[
'parent.text?.startsWith?.(abc);',
'parent.text?.endsWith?.(abc);',
'replaced, optional member, optional call in a non-optional parent with an argument',
],
['parent?.text?.startsWith(abc);', 'parent?.text?.endsWith(abc);', 'replaced, optional member in an optional parent with an argument'],
['parent?.text.startsWith?.(abc);', 'parent?.text.endsWith?.(abc);', 'replaced, optional call in an optional parent with an argument'],
[
'parent?.text?.startsWith?.(abc);',
'parent?.text?.endsWith?.(abc);',
'replaced, optional member, optional call in an optional parent with an argument',
],
['text.trim(abc, def);', 'text;', 'removed, non-optional with multiple arguments'],
['text?.trim(abc, def);', 'text;', 'removed, optional member with multiple arguments'],
['text.trim?.(abc, def);', 'text;', 'removed, optional call with multiple arguments'],
['text?.trim?.(abc, def);', 'text;', 'removed, optional member, optional call with multiple arguments'],
['text.trim().length;', 'text.length;', 'removed in the middle of a chain'],
['parent.text?.trim(abc, def);', 'parent.text;', 'removed, optional member in a non-optional parent with multiple arguments'],
['parent.text.trim?.(abc, def);', 'parent.text;', 'removed, optional call in a non-optional parent with multiple arguments'],
['parent.text?.trim?.(abc, def);', 'parent.text;', 'removed, optional member, optional call in a non-optional parent with multiple arguments'],
['parent?.text?.trim(abc, def);', 'parent?.text;', 'removed, optional member in an optional parent with multiple arguments'],
['parent?.text.trim?.(abc, def);', 'parent?.text;', 'removed, optional call in an optional parent with multiple arguments'],
['parent?.text?.trim?.(abc, def);', 'parent?.text;', 'removed, optional member, optional call in an optional parent with multiple arguments'],
['text.startsWith(abc, def);', 'text.endsWith(abc, def);', 'replaced, non-optional with multiple arguments'],
['text?.startsWith(abc, def);', 'text?.endsWith(abc, def);', 'replaced, optional member with multiple arguments'],
['text.startsWith?.(abc, def);', 'text.endsWith?.(abc, def);', 'replaced, optional call with multiple arguments'],
['text?.startsWith?.(abc, def);', 'text?.endsWith?.(abc, def);', 'replaced, optional member, optional call with multiple arguments'],
[
'parent.text?.startsWith(abc, def);',
'parent.text?.endsWith(abc, def);',
'replaced, optional member in a non-optional parent with multiple arguments',
],
[
'parent.text.startsWith?.(abc, def);',
'parent.text.endsWith?.(abc, def);',
'replaced, optional call in a non-optional parent with multiple arguments',
],
[
'parent.text?.startsWith?.(abc, def);',
'parent.text?.endsWith?.(abc, def);',
'replaced, optional member, optional call in a non-optional parent with multiple arguments',
],
[
'parent?.text?.startsWith(abc, def);',
'parent?.text?.endsWith(abc, def);',
'replaced, optional member in an optional parent with multiple arguments',
],
[
'parent?.text.startsWith?.(abc, def);',
'parent?.text.endsWith?.(abc, def);',
'replaced, optional call in an optional parent with multiple arguments',
],
[
'parent?.text?.startsWith?.(abc, def);',
'parent?.text?.endsWith?.(abc, def);',
'replaced, optional member, optional call in an optional parent with multiple arguments',
],
]) {
it(`should be ${description}`, () => {
expectJSMutation(sut, input, output);
});
}

for (const [key, value] of [
['endsWith', 'startsWith'],
['every', 'some'],
['toLocaleLowerCase', 'toLocaleUpperCase'],
['toLowerCase', 'toUpperCase'],
['trimEnd', 'trimStart'],
]) {
it(`should replace ${key} with ${value}`, () => {
expectJSMutation(sut, `text.${key}();`, `text.${value}();`);
});

it(`should replace ${value} with ${key}`, () => {
expectJSMutation(sut, `text.${value}();`, `text.${key}();`);
});
}

for (const method of ['charAt', 'filter', 'reverse', 'slice', 'sort', 'substr', 'substring', 'trim']) {
it(`should remove ${method}`, () => {
expectJSMutation(sut, `text.${method}();`, 'text;');
});
}

it('should ignore computed properties', () => {
expectJSMutation(sut, "text['trim']();");
});

it('should ignore new expressions', () => {
expectJSMutation(sut, 'new text.trim();');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
foo?.bar?.trim?.();

baz?.trim();

qux.trim().substring(3);

quux.trim?.().substring(3);

Loading

0 comments on commit 70a4e4f

Please sign in to comment.