Skip to content

Commit

Permalink
feat(instrumenter): Implement new mutant placing algorithm (#2964)
Browse files Browse the repository at this point in the history
Ensure all mutants are only placed in the code once.
  • Loading branch information
nicojs authored Jul 1, 2021
1 parent f99b1ae commit 24b8bc9
Show file tree
Hide file tree
Showing 54 changed files with 1,252 additions and 1,378 deletions.
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { NodePath, types } from '@babel/core';

import { Mutant } from '../mutant';
import { createMutatedAst, mutantTestExpression, mutationCoverageSequenceExpression } from '../util/syntax-helpers';
import { mutantTestExpression, mutationCoverageSequenceExpression } from '../util/syntax-helpers';

import { MutantPlacer } from './mutant-placer';

function nameAnonymousClassOrFunctionExpression(path: NodePath<types.Expression>) {
/**
* Will set the identifier of anonymous function expressions if is located in a variable declaration.
* Will treat input as readonly. Returns undefined if not needed.
* @example
* const a = function() { }
* becomes
* const a = function a() {}
*/
function classOrFunctionExpressionNamedIfNeeded(path: NodePath<types.Expression>): types.Expression | undefined {
if ((path.isFunctionExpression() || path.isClassExpression()) && !path.node.id) {
if (path.parentPath.isVariableDeclarator() && types.isIdentifier(path.parentPath.node.id)) {
path.node.id = path.parentPath.node.id;
Expand All @@ -17,26 +24,35 @@ function nameAnonymousClassOrFunctionExpression(path: NodePath<types.Expression>
path.node.id = path.parentPath.node.key;
}
}
return;
}

function nameIfAnonymous(path: NodePath<types.Expression>): NodePath<types.Expression> {
nameAnonymousClassOrFunctionExpression(path);
/**
* Will set the identifier of anonymous arrow function expressions if is located in a variable declaration.
* Will treat input as readonly. Returns undefined if not needed.
* @example
* const a = () => { }
* becomes
* const a = (() => { const a = () => {}; return a; })()
*/
function arrowFunctionExpressionNamedIfNeeded(path: NodePath<types.Expression>): types.Expression | undefined {
if (path.isArrowFunctionExpression() && path.parentPath.isVariableDeclarator() && types.isIdentifier(path.parentPath.node.id)) {
path.replaceWith(
types.callExpression(
types.arrowFunctionExpression(
[],
types.blockStatement([
types.variableDeclaration('const', [types.variableDeclarator(path.parentPath.node.id, path.node)]),
types.returnStatement(path.parentPath.node.id),
])
),
[]
)
return types.callExpression(
types.arrowFunctionExpression(
[],
types.blockStatement([
types.variableDeclaration('const', [types.variableDeclarator(path.parentPath.node.id, path.node)]),
types.returnStatement(path.parentPath.node.id),
])
),
[]
);
}
return;
}

return path;
function nameIfAnonymous(path: NodePath<types.Expression>): types.Expression {
return classOrFunctionExpressionNamedIfNeeded(path) ?? arrowFunctionExpressionNamedIfNeeded(path) ?? path.node;
}

function isValidParent(child: NodePath<types.Expression>) {
Expand All @@ -48,31 +64,24 @@ function isValidParent(child: NodePath<types.Expression>) {
}

/**
* Places the mutants with a conditional expression: `global.activeMutant === 1? mutatedCode : regularCode`;
* Places the mutants with a conditional expression: `global.activeMutant === 1? mutatedCode : originalCode`;
*/
const expressionMutantPlacer: MutantPlacer = (path: NodePath, mutants: Mutant[]): boolean => {
if (path.isExpression() && isValidParent(path)) {
// First calculated the mutated ast before we start to apply mutants.
const appliedMutants = mutants.map((mutant) => ({
mutant,
ast: createMutatedAst<types.BinaryExpression>(path as NodePath<types.BinaryExpression>, mutant),
}));

export const expressionMutantPlacer: MutantPlacer<types.Expression> = {
name: 'expressionMutantPlacer',
canPlace(path) {
return path.isExpression() && isValidParent(path);
},
place(path, appliedMutants) {
// Make sure anonymous functions and classes keep their 'name' property
path.replaceWith(nameIfAnonymous(path));
let expression = nameIfAnonymous(path);

// Add the mutation coverage expression
path.replaceWith(mutationCoverageSequenceExpression(mutants, path.node));
expression = mutationCoverageSequenceExpression(appliedMutants.keys(), expression);

// Now apply the mutants
for (const appliedMutant of appliedMutants) {
path.replaceWith(types.conditionalExpression(mutantTestExpression(appliedMutant.mutant.id), appliedMutant.ast, path.node));
for (const [mutant, appliedMutant] of appliedMutants) {
expression = types.conditionalExpression(mutantTestExpression(mutant.id), appliedMutant, expression);
}
return true;
} else {
return false;
}
path.replaceWith(expression);
},
};

// Export it after initializing so `fn.name` is properly set
export { expressionMutantPlacer };
48 changes: 4 additions & 44 deletions packages/instrumenter/src/mutant-placers/index.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,8 @@
import path from 'path';

export * from './mutant-placer';
import { NodePath } from '@babel/core';

import { Mutant } from '../mutant';

import { expressionMutantPlacer } from './expression-mutant-placer';
import { MutantPlacer } from './mutant-placer';
import { statementMutantPlacer } from './statement-mutant-placer';
import { expressionMutantPlacer } from './expression-mutant-placer';
import { switchCaseMutantPlacer } from './switch-case-mutant-placer';

export const MUTANT_PLACERS = Object.freeze([expressionMutantPlacer, statementMutantPlacer, switchCaseMutantPlacer]);

/**
* Represents a mutant placer, tries to place a mutant in the AST with corresponding mutation switch and mutant covering expression
* @see https://github.com/stryker-mutator/stryker-js/issues/1514
* @param node The ast node to try and replace with a mutated
* @param mutants The mutants to place in the AST node
* @param fileName The name of the file where the mutants are placed
* @param mutantPlacers The mutant placers to use (for unit testing purposes)
*/
export function placeMutants(node: NodePath, mutants: Mutant[], fileName: string, mutantPlacers: readonly MutantPlacer[] = MUTANT_PLACERS): boolean {
if (mutants.length) {
for (const placer of mutantPlacers) {
try {
if (placer(node, mutants)) {
return true;
}
} catch (error) {
const location = `${path.relative(process.cwd(), fileName)}:${node.node.loc?.start.line}:${node.node.loc?.start.column}`;
const message = `${placer.name} could not place mutants with type(s): "${mutants.map((mutant) => mutant.mutatorName).join(', ')}"`;
const errorMessage = `${location} ${message}. Either remove this file from the list of files to be mutated, or ignore the mutators. Please report this issue at https://github.com/stryker-mutator/stryker-js/issues/new?assignees=&labels=%F0%9F%90%9B+Bug&template=bug_report.md&title=${encodeURIComponent(
message
)}.`;
let builtError = new Error(errorMessage);
try {
// `buildCodeFrameError` is kind of flaky, see https://github.com/stryker-mutator/stryker-js/issues/2695
builtError = node.buildCodeFrameError(errorMessage);
} catch {
// Idle, regular error will have to suffice
}
throw builtError;
}
}
}
return false;
}
export * from './mutant-placer';
export * from './throw-placement-error';
export const allMutantPlacers: readonly MutantPlacer[] = Object.freeze([expressionMutantPlacer, statementMutantPlacer, switchCaseMutantPlacer]);
7 changes: 6 additions & 1 deletion packages/instrumenter/src/mutant-placers/mutant-placer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { NodePath } from '@babel/core';
import * as types from '@babel/types';

import { Mutant } from '../mutant';

export type MutantPlacer = (node: NodePath, mutants: Mutant[]) => boolean;
export interface MutantPlacer<TNode extends types.Node = types.Node> {
name: string;
canPlace(path: NodePath): boolean;
place(path: NodePath<TNode>, appliedMutants: Map<Mutant, TNode>): void;
}
43 changes: 15 additions & 28 deletions packages/instrumenter/src/mutant-placers/statement-mutant-placer.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,26 @@
import { types } from '@babel/core';

import { mutantTestExpression, createMutatedAst, mutationCoverageSequenceExpression } from '../util/syntax-helpers';
import { mutantTestExpression, mutationCoverageSequenceExpression } from '../util/syntax-helpers';

import { MutantPlacer } from './mutant-placer';

/**
* Mutant placer that places mutants in statements that allow it.
* It uses an `if` statement to do so
*/
const statementMutantPlacer: MutantPlacer = (path, mutants) => {
if (path.isStatement()) {
// First transform the mutated ast before we start to apply mutants.
const appliedMutants = mutants.map((mutant) => ({
mutant,
ast: createMutatedAst(path, mutant),
}));

const instrumentedAst = appliedMutants.reduce(
// Add if statements per mutant
(prev: types.Statement, { ast, mutant }) => types.ifStatement(mutantTestExpression(mutant.id), types.blockStatement([ast]), prev),
path.isBlockStatement()
? types.blockStatement([types.expressionStatement(mutationCoverageSequenceExpression(mutants)), ...path.node.body])
: types.blockStatement([types.expressionStatement(mutationCoverageSequenceExpression(mutants)), path.node])
);
if (path.isBlockStatement()) {
path.replaceWith(types.blockStatement([instrumentedAst]));
} else {
path.replaceWith(instrumentedAst);
export const statementMutantPlacer: MutantPlacer<types.Statement> = {
name: 'statementMutantPlacer',
canPlace(path) {
return path.isStatement();
},
place(path, appliedMutants) {
let statement: types.Statement = types.blockStatement([
types.expressionStatement(mutationCoverageSequenceExpression(appliedMutants.keys())),
...(path.isBlockStatement() ? path.node.body : [path.node]),
]);
for (const [mutant, appliedMutant] of appliedMutants) {
statement = types.ifStatement(mutantTestExpression(mutant.id), types.blockStatement([appliedMutant]), statement);
}

return true;
} else {
return false;
}
path.replaceWith(path.isBlockStatement() ? types.blockStatement([statement]) : statement);
},
};

// Export it after initializing so `fn.name` is properly set
export { statementMutantPlacer };
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { types } from '@babel/core';

import { createMutatedAst, mutantTestExpression, mutationCoverageSequenceExpression } from '../util';
import { mutantTestExpression, mutationCoverageSequenceExpression } from '../util';

import { MutantPlacer } from './mutant-placer';

Expand All @@ -14,31 +14,19 @@ import { MutantPlacer } from './mutant-placer';
* break;
* }
*/
const switchCaseMutantPlacer: MutantPlacer = (path, mutants): boolean => {
if (path.isSwitchCase()) {
// First transform the mutated ast before we start to apply mutants.
const appliedMutants = mutants.map((mutant) => {
const ast = createMutatedAst(path, mutant);
if (!types.isSwitchCase(ast)) {
throw new Error(`${switchCaseMutantPlacer.name} can only place SwitchCase syntax`);
}
return {
ast,
mutant,
};
});

const instrumentedConsequent = appliedMutants.reduce(
// Add if statements per mutant
(prev: types.Statement, { ast, mutant }) => types.ifStatement(mutantTestExpression(mutant.id), types.blockStatement(ast.consequent), prev),
types.blockStatement([types.expressionStatement(mutationCoverageSequenceExpression(mutants)), ...path.node.consequent])
);
path.replaceWith(types.switchCase(path.node.test, [instrumentedConsequent]));
return true;
}

return false;
export const switchCaseMutantPlacer: MutantPlacer<types.SwitchCase> = {
name: 'switchCaseMutantPlacer',
canPlace(path) {
return path.isSwitchCase();
},
place(path, appliedMutants) {
let consequence: types.Statement = types.blockStatement([
types.expressionStatement(mutationCoverageSequenceExpression(appliedMutants.keys())),
...path.node.consequent,
]);
for (const [mutant, appliedMutant] of appliedMutants) {
consequence = types.ifStatement(mutantTestExpression(mutant.id), types.blockStatement(appliedMutant.consequent), consequence);
}
path.replaceWith(types.switchCase(path.node.test, [consequence]));
},
};

// Export it after initializing so `fn.name` is properly set
export { switchCaseMutantPlacer };
28 changes: 28 additions & 0 deletions packages/instrumenter/src/mutant-placers/throw-placement-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import path from 'path';

import { NodePath } from '@babel/core';
import { PropertyPathBuilder } from '@stryker-mutator/util';
import { StrykerOptions } from '@stryker-mutator/api/core';

import { Mutant } from '../mutant';

import { MutantPlacer } from './mutant-placer';

export function throwPlacementError(error: Error, nodePath: NodePath, placer: MutantPlacer, mutants: Mutant[], fileName: string): never {
const location = `${path.relative(process.cwd(), fileName)}:${nodePath.node.loc?.start.line}:${nodePath.node.loc?.start.column}`;
const message = `${placer.name} could not place mutants with type(s): "${mutants.map((mutant) => mutant.mutatorName).join(', ')}"`;
const errorMessage = `${location} ${message}. Either remove this file from the list of files to be mutated, or exclude the mutator (using ${PropertyPathBuilder.create<StrykerOptions>()
.prop('mutator')
.prop('excludedMutations')
.build()}). Please report this issue at https://github.com/stryker-mutator/stryker-js/issues/new?assignees=&labels=%F0%9F%90%9B+Bug&template=bug_report.md&title=${encodeURIComponent(
message
)}. Original error: ${error.stack}`;
let builtError = new Error(errorMessage);
try {
// `buildCodeFrameError` is kind of flaky, see https://github.com/stryker-mutator/stryker-js/issues/2695
builtError = nodePath.buildCodeFrameError(errorMessage);
} catch {
// Idle, regular error will have to suffice
}
throw builtError;
}
Loading

0 comments on commit 24b8bc9

Please sign in to comment.