diff --git a/docs/configuration.md b/docs/configuration.md
index 854dd824b3..d773d2a894 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -176,9 +176,18 @@ Default: `['{src,lib}/**/*.js?(x)', '!{src,lib}/**/__tests__/**/*.js?(x)', '!{sr
Command line: `[--mutate|-m] src/**/*.js,a.js`
Config file: `"mutate": ["src/**/*.js", "a.js"]`
-With `mutate` you configure the subset of files to use for mutation testing.
-Generally speaking, these should be your own source files.
-This is optional, as you can choose to not mutate any files at all and perform a dry-run (running only your tests without mutating).
+With `mutate` you configure the subset of files to be mutated. These should be your _production code files_, and definitely not your test files.
+The default will try to guess your production code files based on sane defaults. It reads like this:
+
+* Include all js-like files inside the `src` or `lib` dir
+ * Except files inside `__tests__` directories and file names ending with `test` or `spec`.
+
+It is possible to specify exactly which code blocks to mutate by means of a _mutation range_. This can be done postfixing your file with `:startLine[:startColumn]-endLine[:endColumn]`. Some examples:
+* `"src/app.js:1-11"` will mutate lines 1 through 11 inside app.js.
+* `"src/app.js:5:4-6:4"` will mutate from line 5, column 4 through line 6 column 4 inside app.js (columns 4 are included).
+* `"src/app.js:5-6:4"` will mutate from line 5, column 0 through line 6 column 4 inside app.js (column 4 is included).
+
+*Note:* It is not possible to combine mutation range with a globbing expression in the same line.
### `mutator` [`MutatorDescriptor`]
diff --git a/e2e/test/mutation-range/jest.config.js b/e2e/test/mutation-range/jest.config.js
new file mode 100644
index 0000000000..4a5b465ecb
--- /dev/null
+++ b/e2e/test/mutation-range/jest.config.js
@@ -0,0 +1,4 @@
+module.exports = {
+ preset: 'ts-jest',
+ testEnvironment: 'node',
+};
diff --git a/e2e/test/mutation-range/package-lock.json b/e2e/test/mutation-range/package-lock.json
new file mode 100644
index 0000000000..6f5cef4bf1
--- /dev/null
+++ b/e2e/test/mutation-range/package-lock.json
@@ -0,0 +1,653 @@
+{
+ "name": "specific-mutants",
+ "requires": true,
+ "lockfileVersion": 1,
+ "dependencies": {
+ "@jest/types": {
+ "version": "25.5.0",
+ "resolved": "https://registry.npmjs.org/@jest/types/-/types-25.5.0.tgz",
+ "integrity": "sha512-OXD0RgQ86Tu3MazKo8bnrkDRaDXXMGUqd+kTtLtK1Zb7CRzQcaSRPPPV37SvYTdevXEBVxe0HXylEjs8ibkmCw==",
+ "dev": true,
+ "requires": {
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "@types/istanbul-reports": "^1.1.1",
+ "@types/yargs": "^15.0.0",
+ "chalk": "^3.0.0"
+ }
+ },
+ "@types/color-name": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
+ "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
+ "dev": true
+ },
+ "@types/istanbul-lib-coverage": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz",
+ "integrity": "sha512-hRJD2ahnnpLgsj6KWMYSrmXkM3rm2Dl1qkx6IOFD5FnuNPXJIG5L0dhgKXCYTRMGzU4n0wImQ/xfmRc4POUFlg==",
+ "dev": true
+ },
+ "@types/istanbul-lib-report": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
+ "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==",
+ "dev": true,
+ "requires": {
+ "@types/istanbul-lib-coverage": "*"
+ }
+ },
+ "@types/istanbul-reports": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.1.tgz",
+ "integrity": "sha512-UpYjBi8xefVChsCoBpKShdxTllC9pwISirfoZsUa2AAdQg/Jd2KQGtSbw+ya7GPo7x/wAPlH6JBhKhAsXUEZNA==",
+ "dev": true,
+ "requires": {
+ "@types/istanbul-lib-coverage": "*",
+ "@types/istanbul-lib-report": "*"
+ }
+ },
+ "@types/jest": {
+ "version": "25.2.1",
+ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-25.2.1.tgz",
+ "integrity": "sha512-msra1bCaAeEdkSyA0CZ6gW1ukMIvZ5YoJkdXw/qhQdsuuDlFTcEUrUw8CLCPt2rVRUfXlClVvK2gvPs9IokZaA==",
+ "dev": true,
+ "requires": {
+ "jest-diff": "^25.2.1",
+ "pretty-format": "^25.2.1"
+ }
+ },
+ "@types/node": {
+ "version": "14.0.27",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.27.tgz",
+ "integrity": "sha512-kVrqXhbclHNHGu9ztnAwSncIgJv/FaxmzXJvGXNdcCpV1b8u1/Mi6z6m0vwy0LzKeXFTPLH0NzwmoJ3fNCIq0g==",
+ "dev": true
+ },
+ "@types/yargs": {
+ "version": "15.0.4",
+ "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.4.tgz",
+ "integrity": "sha512-9T1auFmbPZoxHz0enUFlUuKRy3it01R+hlggyVUMtnCTQRunsQYifnSGb8hET4Xo8yiC0o0r1paW3ud5+rbURg==",
+ "dev": true,
+ "requires": {
+ "@types/yargs-parser": "*"
+ }
+ },
+ "@types/yargs-parser": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz",
+ "integrity": "sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw==",
+ "dev": true
+ },
+ "ajv": {
+ "version": "6.12.2",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.2.tgz",
+ "integrity": "sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==",
+ "requires": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ }
+ },
+ "ansi-regex": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
+ "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
+ "dev": true
+ },
+ "ansi-styles": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
+ "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==",
+ "dev": true,
+ "requires": {
+ "@types/color-name": "^1.1.1",
+ "color-convert": "^2.0.1"
+ }
+ },
+ "asn1": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz",
+ "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==",
+ "requires": {
+ "safer-buffer": "~2.1.0"
+ }
+ },
+ "assert-plus": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
+ "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU="
+ },
+ "asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
+ },
+ "aws-sign2": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
+ "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg="
+ },
+ "aws4": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz",
+ "integrity": "sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug=="
+ },
+ "azure-storage": {
+ "version": "2.10.3",
+ "resolved": "https://registry.npmjs.org/azure-storage/-/azure-storage-2.10.3.tgz",
+ "integrity": "sha512-IGLs5Xj6kO8Ii90KerQrrwuJKexLgSwYC4oLWmc11mzKe7Jt2E5IVg+ZQ8K53YWZACtVTMBNO3iGuA+4ipjJxQ==",
+ "requires": {
+ "browserify-mime": "~1.2.9",
+ "extend": "^3.0.2",
+ "json-edm-parser": "0.1.2",
+ "md5.js": "1.3.4",
+ "readable-stream": "~2.0.0",
+ "request": "^2.86.0",
+ "underscore": "~1.8.3",
+ "uuid": "^3.0.0",
+ "validator": "~9.4.1",
+ "xml2js": "0.2.8",
+ "xmlbuilder": "^9.0.7"
+ }
+ },
+ "bcrypt-pbkdf": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+ "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=",
+ "requires": {
+ "tweetnacl": "^0.14.3"
+ }
+ },
+ "browserify-mime": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/browserify-mime/-/browserify-mime-1.2.9.tgz",
+ "integrity": "sha1-rrGvKN5sDXpqLOQK22j/GEIq8x8="
+ },
+ "caseless": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
+ "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw="
+ },
+ "chalk": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
+ "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "requires": {
+ "delayed-stream": "~1.0.0"
+ }
+ },
+ "core-util-is": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
+ },
+ "dashdash": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
+ "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=",
+ "requires": {
+ "assert-plus": "^1.0.0"
+ }
+ },
+ "delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
+ },
+ "diff-sequences": {
+ "version": "25.2.6",
+ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz",
+ "integrity": "sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg==",
+ "dev": true
+ },
+ "ecc-jsbn": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
+ "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=",
+ "requires": {
+ "jsbn": "~0.1.0",
+ "safer-buffer": "^2.1.0"
+ }
+ },
+ "extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
+ },
+ "extsprintf": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
+ "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU="
+ },
+ "fast-deep-equal": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz",
+ "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA=="
+ },
+ "fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
+ },
+ "forever-agent": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
+ "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE="
+ },
+ "form-data": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
+ "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
+ "requires": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.6",
+ "mime-types": "^2.1.12"
+ }
+ },
+ "getpass": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
+ "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=",
+ "requires": {
+ "assert-plus": "^1.0.0"
+ }
+ },
+ "har-schema": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
+ "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI="
+ },
+ "har-validator": {
+ "version": "5.1.3",
+ "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz",
+ "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==",
+ "requires": {
+ "ajv": "^6.5.5",
+ "har-schema": "^2.0.0"
+ }
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "hash-base": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz",
+ "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==",
+ "requires": {
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.6.0",
+ "safe-buffer": "^5.2.0"
+ },
+ "dependencies": {
+ "readable-stream": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+ "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+ "requires": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ }
+ }
+ }
+ },
+ "http-signature": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
+ "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=",
+ "requires": {
+ "assert-plus": "^1.0.0",
+ "jsprim": "^1.2.2",
+ "sshpk": "^1.7.0"
+ }
+ },
+ "inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "is-typedarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+ "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo="
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
+ },
+ "isstream": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
+ "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo="
+ },
+ "jest-diff": {
+ "version": "25.5.0",
+ "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.5.0.tgz",
+ "integrity": "sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A==",
+ "dev": true,
+ "requires": {
+ "chalk": "^3.0.0",
+ "diff-sequences": "^25.2.6",
+ "jest-get-type": "^25.2.6",
+ "pretty-format": "^25.5.0"
+ }
+ },
+ "jest-get-type": {
+ "version": "25.2.6",
+ "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.2.6.tgz",
+ "integrity": "sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig==",
+ "dev": true
+ },
+ "jsbn": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
+ "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM="
+ },
+ "json-edm-parser": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/json-edm-parser/-/json-edm-parser-0.1.2.tgz",
+ "integrity": "sha1-HmCw/vG8CvZ7wNFG393lSGzWFbQ=",
+ "requires": {
+ "jsonparse": "~1.2.0"
+ }
+ },
+ "json-schema": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz",
+ "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM="
+ },
+ "json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
+ },
+ "json-stringify-safe": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+ "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus="
+ },
+ "jsonparse": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.2.0.tgz",
+ "integrity": "sha1-XAxWhRBxYOcv50ib3eoLRMK8Z70="
+ },
+ "jsprim": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz",
+ "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=",
+ "requires": {
+ "assert-plus": "1.0.0",
+ "extsprintf": "1.3.0",
+ "json-schema": "0.2.3",
+ "verror": "1.10.0"
+ }
+ },
+ "md5.js": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.4.tgz",
+ "integrity": "sha1-6b296UogpawYsENA/Fdk1bCdkB0=",
+ "requires": {
+ "hash-base": "^3.0.0",
+ "inherits": "^2.0.1"
+ }
+ },
+ "mime-db": {
+ "version": "1.44.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz",
+ "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg=="
+ },
+ "mime-types": {
+ "version": "2.1.27",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz",
+ "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==",
+ "requires": {
+ "mime-db": "1.44.0"
+ }
+ },
+ "oauth-sign": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
+ "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ=="
+ },
+ "performance-now": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns="
+ },
+ "pretty-format": {
+ "version": "25.5.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-25.5.0.tgz",
+ "integrity": "sha512-kbo/kq2LQ/A/is0PQwsEHM7Ca6//bGPPvU6UnsdDRSKTWxT/ru/xb88v4BJf6a69H+uTytOEsTusT9ksd/1iWQ==",
+ "dev": true,
+ "requires": {
+ "@jest/types": "^25.5.0",
+ "ansi-regex": "^5.0.0",
+ "ansi-styles": "^4.0.0",
+ "react-is": "^16.12.0"
+ }
+ },
+ "process-nextick-args": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz",
+ "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M="
+ },
+ "psl": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
+ "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ=="
+ },
+ "punycode": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
+ },
+ "qs": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
+ "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA=="
+ },
+ "react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz",
+ "integrity": "sha1-j5A0HmilPMySh4jaz80Rs265t44=",
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.1",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~1.0.6",
+ "string_decoder": "~0.10.x",
+ "util-deprecate": "~1.0.1"
+ },
+ "dependencies": {
+ "string_decoder": {
+ "version": "0.10.31",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+ "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
+ }
+ }
+ },
+ "request": {
+ "version": "2.88.2",
+ "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
+ "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==",
+ "requires": {
+ "aws-sign2": "~0.7.0",
+ "aws4": "^1.8.0",
+ "caseless": "~0.12.0",
+ "combined-stream": "~1.0.6",
+ "extend": "~3.0.2",
+ "forever-agent": "~0.6.1",
+ "form-data": "~2.3.2",
+ "har-validator": "~5.1.3",
+ "http-signature": "~1.2.0",
+ "is-typedarray": "~1.0.0",
+ "isstream": "~0.1.2",
+ "json-stringify-safe": "~5.0.1",
+ "mime-types": "~2.1.19",
+ "oauth-sign": "~0.9.0",
+ "performance-now": "^2.1.0",
+ "qs": "~6.5.2",
+ "safe-buffer": "^5.1.2",
+ "tough-cookie": "~2.5.0",
+ "tunnel-agent": "^0.6.0",
+ "uuid": "^3.3.2"
+ }
+ },
+ "safe-buffer": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz",
+ "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg=="
+ },
+ "safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+ },
+ "sax": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-0.5.8.tgz",
+ "integrity": "sha1-1HLbIo6zMcJQaw6MFVJK25OdEsE="
+ },
+ "sshpk": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
+ "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==",
+ "requires": {
+ "asn1": "~0.2.3",
+ "assert-plus": "^1.0.0",
+ "bcrypt-pbkdf": "^1.0.0",
+ "dashdash": "^1.12.0",
+ "ecc-jsbn": "~0.1.1",
+ "getpass": "^0.1.1",
+ "jsbn": "~0.1.0",
+ "safer-buffer": "^2.0.2",
+ "tweetnacl": "~0.14.0"
+ }
+ },
+ "string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "requires": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "supports-color": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
+ "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ },
+ "tough-cookie": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
+ "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
+ "requires": {
+ "psl": "^1.1.28",
+ "punycode": "^2.1.1"
+ }
+ },
+ "tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
+ "requires": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "tweetnacl": {
+ "version": "0.14.5",
+ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+ "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
+ },
+ "underscore": {
+ "version": "1.8.3",
+ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz",
+ "integrity": "sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI="
+ },
+ "uri-js": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",
+ "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==",
+ "requires": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
+ },
+ "uuid": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
+ "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
+ },
+ "validator": {
+ "version": "9.4.1",
+ "resolved": "https://registry.npmjs.org/validator/-/validator-9.4.1.tgz",
+ "integrity": "sha512-YV5KjzvRmSyJ1ee/Dm5UED0G+1L4GZnLN3w6/T+zZm8scVua4sOhYKWTUrKa0H/tMiJyO9QLHMPN+9mB/aMunA=="
+ },
+ "verror": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
+ "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
+ "requires": {
+ "assert-plus": "^1.0.0",
+ "core-util-is": "1.0.2",
+ "extsprintf": "^1.2.0"
+ }
+ },
+ "xml2js": {
+ "version": "0.2.8",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.2.8.tgz",
+ "integrity": "sha1-m4FpCTFjH/CdGVdUn69U9PmAs8I=",
+ "requires": {
+ "sax": "0.5.x"
+ }
+ },
+ "xmlbuilder": {
+ "version": "9.0.7",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz",
+ "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0="
+ }
+ }
+}
diff --git a/e2e/test/mutation-range/package.json b/e2e/test/mutation-range/package.json
new file mode 100644
index 0000000000..7f7d61de06
--- /dev/null
+++ b/e2e/test/mutation-range/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "mutation-range",
+ "description": "A test project for only running specific mutants, rather than all mutants",
+ "scripts": {
+ "test": "stryker run",
+ "test:unit": "jest",
+ "posttest": "mocha --require ../../tasks/ts-node-register.js verify/verify.ts"
+ }
+}
diff --git a/e2e/test/mutation-range/src/ignored.js b/e2e/test/mutation-range/src/ignored.js
new file mode 100644
index 0000000000..76def76736
--- /dev/null
+++ b/e2e/test/mutation-range/src/ignored.js
@@ -0,0 +1,3 @@
+function ignoredFile (name) {
+ return 'ignored' + name;
+}
diff --git a/e2e/test/mutation-range/src/index.js b/e2e/test/mutation-range/src/index.js
new file mode 100644
index 0000000000..fd8a88a09f
--- /dev/null
+++ b/e2e/test/mutation-range/src/index.js
@@ -0,0 +1,7 @@
+module.exports = {
+ helloWorld: () => {
+ const hello = 'hello';
+ const world = 'world';
+ return `${hello} ${world}`;
+ },
+};
diff --git a/e2e/test/mutation-range/src/pi.js b/e2e/test/mutation-range/src/pi.js
new file mode 100644
index 0000000000..71f3d75b3a
--- /dev/null
+++ b/e2e/test/mutation-range/src/pi.js
@@ -0,0 +1 @@
+exports.radius = r => 2 * pi * r;
diff --git a/e2e/test/mutation-range/stryker.conf.json b/e2e/test/mutation-range/stryker.conf.json
new file mode 100644
index 0000000000..e10eb9fc29
--- /dev/null
+++ b/e2e/test/mutation-range/stryker.conf.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "../../node_modules/@stryker-mutator/core/schema/stryker-schema.json",
+ "mutate": ["src/index.js:3:18-3:26", "src/index.js:4:18-4:25", "src/pi.js"],
+ "packageManager": "npm",
+ "testRunner": "jest",
+ "tempDirName": "stryker-tmp",
+ "concurrency": 2,
+ "coverageAnalysis": "off",
+ "reporters": ["event-recorder", "progress", "html"]
+}
diff --git a/e2e/test/mutation-range/test/index.spec.js b/e2e/test/mutation-range/test/index.spec.js
new file mode 100644
index 0000000000..8dbc64435b
--- /dev/null
+++ b/e2e/test/mutation-range/test/index.spec.js
@@ -0,0 +1,7 @@
+const helloWorld = require('../src/index').helloWorld;
+
+describe('Hello World', () => {
+ it('should output hello world', () => {
+ expect(helloWorld()).toBe('hello world');
+ });
+});
diff --git a/e2e/test/mutation-range/verify/verify.ts b/e2e/test/mutation-range/verify/verify.ts
new file mode 100644
index 0000000000..d0ac28089c
--- /dev/null
+++ b/e2e/test/mutation-range/verify/verify.ts
@@ -0,0 +1,16 @@
+import { expectMetrics } from '../../../helpers';
+
+describe('Verify stryker has ran correctly', () => {
+
+ it('should report correct score', async () => {
+ await expectMetrics({
+ ignored: 0,
+ killed: 2,
+ mutationScore: 40,
+ noCoverage: 0,
+ survived: 3,
+ timeout: 0,
+ compileErrors: 0
+ });
+ });
+});
diff --git a/packages/api/src/core/index.ts b/packages/api/src/core/index.ts
index 4ef67d5724..a712579971 100644
--- a/packages/api/src/core/index.ts
+++ b/packages/api/src/core/index.ts
@@ -9,3 +9,4 @@ export * from './stryker-options-schema';
export * from './partial-stryker-options';
export * from './instrument';
export * from './mutant-coverage';
+export * from './mutation-range';
diff --git a/packages/api/src/core/mutation-range.ts b/packages/api/src/core/mutation-range.ts
new file mode 100644
index 0000000000..326f73e22a
--- /dev/null
+++ b/packages/api/src/core/mutation-range.ts
@@ -0,0 +1,25 @@
+/**
+ * Represents a range of mutants that the instrumenter should instrument
+ */
+export interface MutationRange {
+ /**
+ * The filename of the file that this range belongs to
+ */
+ fileName: string;
+
+ /**
+ * The start of the range to instrument, by line and column number, inclusive
+ */
+ start: {
+ line: number;
+ column: number;
+ };
+
+ /**
+ * The end of the range to instrument, by line and number, inclusive
+ */
+ end: {
+ line: number;
+ column: number;
+ };
+}
diff --git a/packages/core/src/config/options-validator.ts b/packages/core/src/config/options-validator.ts
index 84a88f3413..70529d4a1c 100644
--- a/packages/core/src/config/options-validator.ts
+++ b/packages/core/src/config/options-validator.ts
@@ -1,5 +1,7 @@
import os from 'os';
+import { hasMagic } from 'glob';
+
import Ajv, { ValidateFunction } from 'ajv';
import { StrykerOptions, strykerCoreSchema } from '@stryker-mutator/api/core';
import { tokens, commonTokens } from '@stryker-mutator/api/plugin';
@@ -11,6 +13,7 @@ import { coreTokens } from '../di';
import { ConfigError } from '../errors';
import { isWarningEnabled } from '../utils/object-utils';
import { CommandTestRunner } from '../test-runner/command-test-runner';
+import { MUTATION_RANGE_REGEX } from '../input';
import { describeErrors } from './validation-errors';
@@ -85,6 +88,31 @@ export class OptionsValidator {
'Using "testRunnerNodeArgs" together with the "command" test runner is not supported, these arguments will be ignored. You can add your custom arguments by setting the "commandRunner.command" option.'
);
}
+ options.mutate.forEach((mutateString, index) => {
+ const match = MUTATION_RANGE_REGEX.exec(mutateString);
+ if (match) {
+ if (hasMagic(mutateString)) {
+ additionalErrors.push(
+ `Config option "mutate[${index}]" is invalid. Cannot combine a glob expression with a mutation range in "${mutateString}".`
+ );
+ } else {
+ const [_, _fileName, mutationRange, startLine, _startColumn, endLine, _endColumn] = match;
+ const start = parseInt(startLine, 10);
+ const end = parseInt(endLine, 10);
+ if (start < 1) {
+ additionalErrors.push(
+ `Config option "mutate[${index}]" is invalid. Mutation range "${mutationRange}" is invalid, line ${start} does not exist (lines start at 1).`
+ );
+ }
+ if (start > end) {
+ additionalErrors.push(
+ `Config option "mutate[${index}]" is invalid. Mutation range "${mutationRange}" is invalid. The "from" line number (${start}) should be less then the "to" line number (${end}).`
+ );
+ }
+ }
+ }
+ });
+
additionalErrors.forEach((error) => this.log.error(error));
this.throwErrorIfNeeded(additionalErrors);
}
diff --git a/packages/core/src/input/index.ts b/packages/core/src/input/index.ts
new file mode 100644
index 0000000000..b5f3546284
--- /dev/null
+++ b/packages/core/src/input/index.ts
@@ -0,0 +1,2 @@
+export * from './input-file-collection';
+export * from './input-file-resolver';
diff --git a/packages/core/src/input/input-file-collection.ts b/packages/core/src/input/input-file-collection.ts
index 841500ff0a..e36efc763c 100644
--- a/packages/core/src/input/input-file-collection.ts
+++ b/packages/core/src/input/input-file-collection.ts
@@ -1,16 +1,18 @@
import os from 'os';
-import { File } from '@stryker-mutator/api/core';
+import { File, MutationRange } from '@stryker-mutator/api/core';
import { Logger } from '@stryker-mutator/api/logging';
import { normalizeWhitespaces } from '@stryker-mutator/util';
export class InputFileCollection {
public readonly files: readonly File[];
public readonly filesToMutate: readonly File[];
+ public readonly mutationRanges: readonly MutationRange[];
- constructor(files: readonly File[], mutateGlobResult: readonly string[]) {
+ constructor(files: readonly File[], mutateGlobResult: readonly string[], mutationRangeToInstrument: readonly MutationRange[]) {
this.files = files;
this.filesToMutate = files.filter((file) => mutateGlobResult.some((name) => name === file.name));
+ this.mutationRanges = mutationRangeToInstrument;
}
public logFiles(log: Logger): void {
diff --git a/packages/core/src/input/input-file-resolver.ts b/packages/core/src/input/input-file-resolver.ts
index 092ee7494b..3d97e4712f 100644
--- a/packages/core/src/input/input-file-resolver.ts
+++ b/packages/core/src/input/input-file-resolver.ts
@@ -4,7 +4,7 @@ import fs from 'fs';
import { from } from 'rxjs';
import { filter, map, mergeMap, toArray } from 'rxjs/operators';
-import { File, StrykerOptions } from '@stryker-mutator/api/core';
+import { File, StrykerOptions, MutationRange } from '@stryker-mutator/api/core';
import { Logger } from '@stryker-mutator/api/logging';
import { commonTokens, tokens } from '@stryker-mutator/api/plugin';
import { SourceFile } from '@stryker-mutator/api/report';
@@ -26,6 +26,8 @@ function toReportSourceFile(file: File): SourceFile {
const IGNORE_PATTERN_CHARACTER = '!';
+export const MUTATION_RANGE_REGEX = /(.*?):((\d+)(?::(\d+))?-(\d+)(?::(\d+))?)$/;
+
/**
* When characters are represented as the octal values of its utf8 encoding
* e.g. å becomes \303\245 in git.exe output
@@ -64,9 +66,13 @@ export class InputFileResolver {
}
public async resolve(): Promise {
- const [inputFileNames, mutateFiles] = await Promise.all([this.resolveInputFiles(), this.resolveMutateFiles()]);
+ const [inputFileNames, mutateFiles, mutationRange] = await Promise.all([
+ this.resolveInputFiles(),
+ this.resolveMutateFiles(),
+ this.resolveMutationRange(),
+ ]);
const files: File[] = await this.readFiles(inputFileNames);
- const inputFileCollection = new InputFileCollection(files, mutateFiles);
+ const inputFileCollection = new InputFileCollection(files, mutateFiles, mutationRange);
this.reportAllSourceFilesRead(files);
inputFileCollection.logFiles(this.log);
return inputFileCollection;
@@ -97,6 +103,19 @@ export class InputFileResolver {
}
}
+ private resolveMutationRange(): MutationRange[] {
+ return this.mutatePatterns
+ .map((fileToMutate) => MUTATION_RANGE_REGEX.exec(fileToMutate))
+ .filter(notEmpty)
+ .map(([_, fileName, _mutationRange, startLine, startColumn = '0', endLine, endColumn = Number.MAX_SAFE_INTEGER.toString()]) => {
+ return {
+ fileName: path.resolve(fileName),
+ start: { line: parseInt(startLine) - 1, column: parseInt(startColumn) },
+ end: { line: parseInt(endLine) - 1, column: parseInt(endColumn) },
+ };
+ });
+ }
+
/**
* Takes a list of globbing patterns and expands them into files.
* If a patterns starts with a `!`, it negates the pattern.
@@ -117,6 +136,10 @@ export class InputFileResolver {
}
private async expandPattern(globbingExpression: string, logAboutUselessPatterns: boolean): Promise {
+ if (MUTATION_RANGE_REGEX.exec(globbingExpression)) {
+ globbingExpression = globbingExpression.replace(MUTATION_RANGE_REGEX, '$1');
+ }
+
const fileNames = (await glob(globbingExpression)).map((relativeFile) => path.resolve(relativeFile));
if (!fileNames.length && logAboutUselessPatterns) {
this.log.warn(`Globbing expression "${globbingExpression}" did not result in any files.`);
diff --git a/packages/core/src/process/1-prepare-executor.ts b/packages/core/src/process/1-prepare-executor.ts
index e5ff696555..83d7637136 100644
--- a/packages/core/src/process/1-prepare-executor.ts
+++ b/packages/core/src/process/1-prepare-executor.ts
@@ -3,7 +3,7 @@ import { commonTokens, Injector, tokens } from '@stryker-mutator/api/plugin';
import { LogConfigurator } from '../logging';
import { buildMainInjector, coreTokens, CliOptionsProvider } from '../di';
-import { InputFileResolver } from '../input/input-file-resolver';
+import { InputFileResolver } from '../input';
import { ConfigError } from '../errors';
import { MutantInstrumenterContext } from '.';
diff --git a/packages/core/src/process/2-mutant-instrumenter-executor.ts b/packages/core/src/process/2-mutant-instrumenter-executor.ts
index 3b454473b5..c132db4ed2 100644
--- a/packages/core/src/process/2-mutant-instrumenter-executor.ts
+++ b/packages/core/src/process/2-mutant-instrumenter-executor.ts
@@ -3,7 +3,7 @@ import { Instrumenter, InstrumentResult } from '@stryker-mutator/instrumenter';
import { File, StrykerOptions } from '@stryker-mutator/api/core';
import { MainContext, coreTokens } from '../di';
-import { InputFileCollection } from '../input/input-file-collection';
+import { InputFileCollection } from '../input';
import { Sandbox } from '../sandbox/sandbox';
import { LoggingClientContext } from '../logging';
@@ -31,7 +31,10 @@ export class MutantInstrumenterExecutor {
const instrumenter = this.injector.injectClass(Instrumenter);
// Instrument files in-memory
- const instrumentResult = await instrumenter.instrument(this.inputFiles.filesToMutate, this.options.mutator);
+ const instrumentResult = await instrumenter.instrument(this.inputFiles.filesToMutate, {
+ ...this.options.mutator,
+ mutationRanges: this.inputFiles.mutationRanges,
+ });
// Preprocess sandbox files
const preprocess = this.injector.injectFunction(createPreprocessor);
diff --git a/packages/core/src/reporters/mutation-test-report-helper.ts b/packages/core/src/reporters/mutation-test-report-helper.ts
index f66d1a068d..38bfd96023 100644
--- a/packages/core/src/reporters/mutation-test-report-helper.ts
+++ b/packages/core/src/reporters/mutation-test-report-helper.ts
@@ -21,7 +21,7 @@ import { CompleteDryRunResult, MutantRunResult, MutantRunStatus } from '@stryker
import { CheckStatus, PassedCheckResult, CheckResult } from '@stryker-mutator/api/check';
import { coreTokens } from '../di';
-import { InputFileCollection } from '../input/input-file-collection';
+import { InputFileCollection } from '../input';
import { setExitCode } from '../utils/object-utils';
import { MutantTestCoverage } from '../mutants/find-mutant-test-coverage';
import { mutatedLines, originalLines } from '../utils/mutant-utils';
diff --git a/packages/core/src/stryker-cli.ts b/packages/core/src/stryker-cli.ts
index 109c3a332b..cac806d3c8 100644
--- a/packages/core/src/stryker-cli.ts
+++ b/packages/core/src/stryker-cli.ts
@@ -67,7 +67,7 @@ export class StrykerCli {
)
.option(
'-m, --mutate ',
- 'A comma separated list of globbing expression used for selecting the files that should be mutated. Example: src/**/*.js,a.js',
+ 'A comma separated list of globbing expression used for selecting the files that should be mutated. Example: src/**/*.js,a.js. You can also specify specific lines and columns to mutate by adding :startLine[:startColumn]-endLine[:endColumn]. This will execute all mutants inside that range. It cannot be combined with glob patterns. Example: src/index.js:1:3-1:5',
list
)
.option(
diff --git a/packages/core/test/integration/input/input-file-resolver.it.spec.ts b/packages/core/test/integration/input/input-file-resolver.it.spec.ts
index b6fe430b62..c70e9c06b6 100644
--- a/packages/core/test/integration/input/input-file-resolver.it.spec.ts
+++ b/packages/core/test/integration/input/input-file-resolver.it.spec.ts
@@ -2,7 +2,7 @@ import { factory, testInjector } from '@stryker-mutator/test-helpers';
import { expect } from 'chai';
import { coreTokens } from '../../../src/di';
-import { InputFileResolver } from '../../../src/input/input-file-resolver';
+import { InputFileResolver } from '../../../src/input';
import { resolveFromRoot } from '../../helpers/test-utils';
const resolveTestResource = resolveFromRoot.bind(undefined, 'testResources', 'input-files');
diff --git a/packages/core/test/unit/config/options-validator.spec.ts b/packages/core/test/unit/config/options-validator.spec.ts
index f7000d2091..d4e2b454c2 100644
--- a/packages/core/test/unit/config/options-validator.spec.ts
+++ b/packages/core/test/unit/config/options-validator.spec.ts
@@ -185,6 +185,35 @@ describe(OptionsValidator.name, () => {
'DEPRECATED. Use of "mutator" as string is no longer needed. You can remove it from your configuration. Stryker now supports mutating of JavaScript and friend files out of the box.'
);
});
+
+ it('should accept mutationRange without a glob pattern', () => {
+ testInjector.options.mutate = ['src/index.ts:1:0-2:0'];
+ actAssertValid();
+ });
+
+ it('should not accept mutationRange for line < 1 (lines are 1 based)', () => {
+ testInjector.options.mutate = ['src/app.ts:5:0-6:0', 'src/index.ts:0:0-2:0'];
+ actValidationErrors('Config option "mutate[1]" is invalid. Mutation range "0:0-2:0" is invalid, line 0 does not exist (lines start at 1).');
+ });
+
+ it('should not accept mutationRange for start > end', () => {
+ testInjector.options.mutate = ['src/index.ts:6-5'];
+ actValidationErrors(
+ 'Config option "mutate[0]" is invalid. Mutation range "6-5" is invalid. The "from" line number (6) should be less then the "to" line number (5).'
+ );
+ });
+
+ it('should not accept mutationRange with a glob pattern', () => {
+ testInjector.options.mutate = ['src/index.*.ts:1:0-2:0'];
+ actValidationErrors(
+ 'Config option "mutate[0]" is invalid. Cannot combine a glob expression with a mutation range in "src/index.*.ts:1:0-2:0".'
+ );
+ });
+
+ it('should not accept mutationRange (with no column numbers) with a glob pattern', () => {
+ testInjector.options.mutate = ['src/index.*.ts:1-2'];
+ actValidationErrors('Config option "mutate[0]" is invalid. Cannot combine a glob expression with a mutation range in "src/index.*.ts:1-2".');
+ });
});
describe('testFramework', () => {
diff --git a/packages/core/test/unit/input/input-file-resolver.spec.ts b/packages/core/test/unit/input/input-file-resolver.spec.ts
index cd74543a0a..28ba562188 100644
--- a/packages/core/test/unit/input/input-file-resolver.spec.ts
+++ b/packages/core/test/unit/input/input-file-resolver.spec.ts
@@ -2,7 +2,7 @@ import os from 'os';
import path from 'path';
import fs from 'fs';
-import { File } from '@stryker-mutator/api/core';
+import { File, MutationRange } from '@stryker-mutator/api/core';
import { SourceFile } from '@stryker-mutator/api/report';
import { testInjector, factory, assertions, tick } from '@stryker-mutator/test-helpers';
import { childProcessAsPromised, errorToString, Task } from '@stryker-mutator/util';
@@ -10,7 +10,7 @@ import { expect } from 'chai';
import sinon from 'sinon';
import { coreTokens } from '../../../src/di';
-import { InputFileResolver } from '../../../src/input/input-file-resolver';
+import { InputFileResolver } from '../../../src/input';
import { BroadcastReporter } from '../../../src/reporters/broadcast-reporter';
import * as fileUtils from '../../../src/utils/file-utils';
import { Mock, mock } from '../../helpers/producers';
@@ -189,6 +189,46 @@ describe(InputFileResolver.name, () => {
await onGoingWork;
});
+ describe('with mutation range definitions', () => {
+ it('should remove specific mutant descriptors when matching with line and column', async () => {
+ testInjector.options.mutate = ['mute1:1:2-2:2'];
+ testInjector.options.files = ['file1', 'mute1', 'file2', 'mute2', 'file3'];
+ sut = createSut();
+ const result = await sut.resolve();
+ expect(result.filesToMutate.map((_) => _.name)).to.deep.equal([path.resolve('/mute1.js')]);
+ });
+
+ it('should parse the mutation range', async () => {
+ testInjector.options.mutate = ['mute1:1:2-2:2'];
+ testInjector.options.files = ['file1', 'mute1', 'file2', 'mute2', 'file3'];
+ sut = createSut();
+ const result = await sut.resolve();
+ const expectedRanges: MutationRange[] = [
+ {
+ start: {
+ column: 2,
+ line: 0, // internally, Stryker works 0-based
+ },
+ end: {
+ column: 2,
+ line: 1,
+ },
+ fileName: path.resolve('mute1'),
+ },
+ ];
+ expect(result.mutationRanges).deep.eq(expectedRanges);
+ });
+
+ it('should default column numbers if not present', async () => {
+ testInjector.options.mutate = ['mute1:6-12'];
+ testInjector.options.files = ['file1', 'mute1', 'file2', 'mute2', 'file3'];
+ sut = createSut();
+ const result = await sut.resolve();
+ expect(result.mutationRanges[0].start).deep.eq({ column: 0, line: 5 });
+ expect(result.mutationRanges[0].end).deep.eq({ column: Number.MAX_SAFE_INTEGER, line: 11 });
+ });
+ });
+
describe('with mutate file expressions', () => {
it('should result in the expected mutate files', async () => {
testInjector.options.mutate = ['mute*'];
diff --git a/packages/core/test/unit/process/1-prepare-executor.spec.ts b/packages/core/test/unit/process/1-prepare-executor.spec.ts
index f958caac25..d9ad286ac0 100644
--- a/packages/core/test/unit/process/1-prepare-executor.spec.ts
+++ b/packages/core/test/unit/process/1-prepare-executor.spec.ts
@@ -11,8 +11,7 @@ import { coreTokens } from '../../../src/di';
import { LogConfigurator, LoggingClientContext } from '../../../src/logging';
import * as buildMainInjectorModule from '../../../src/di/build-main-injector';
import { Timer } from '../../../src/utils/timer';
-import { InputFileResolver } from '../../../src/input/input-file-resolver';
-import { InputFileCollection } from '../../../src/input/input-file-collection';
+import { InputFileResolver, InputFileCollection } from '../../../src/input';
import { TemporaryDirectory } from '../../../src/utils/temporary-directory';
import { ConfigError } from '../../../src/errors';
@@ -29,7 +28,7 @@ describe(PrepareExecutor.name, () => {
let sut: PrepareExecutor;
beforeEach(() => {
- inputFiles = new InputFileCollection([new File('index.js', 'console.log("hello world");')], ['index.js']);
+ inputFiles = new InputFileCollection([new File('index.js', 'console.log("hello world");')], ['index.js'], []);
cliOptions = {};
timerMock = sinon.createStubInstance(Timer);
temporaryDirectoryMock = sinon.createStubInstance(TemporaryDirectory);
@@ -93,12 +92,12 @@ describe(PrepareExecutor.name, () => {
});
it('should reject when no input files where found', async () => {
- inputFileResolverMock.resolve.resolves(new InputFileCollection([], []));
+ inputFileResolverMock.resolve.resolves(new InputFileCollection([], [], []));
await expect(sut.execute()).rejectedWith(ConfigError, 'No input files found');
});
it('should not create the temp directory when no input files where found', async () => {
- inputFileResolverMock.resolve.resolves(new InputFileCollection([], []));
+ inputFileResolverMock.resolve.resolves(new InputFileCollection([], [], []));
await expect(sut.execute()).rejected;
expect(temporaryDirectoryMock.initialize).not.called;
});
diff --git a/packages/core/test/unit/process/2-mutant-instrumenter-executor.spec.ts b/packages/core/test/unit/process/2-mutant-instrumenter-executor.spec.ts
index db4c4cfcac..0f411186f6 100644
--- a/packages/core/test/unit/process/2-mutant-instrumenter-executor.spec.ts
+++ b/packages/core/test/unit/process/2-mutant-instrumenter-executor.spec.ts
@@ -9,7 +9,7 @@ import { Checker } from '@stryker-mutator/api/check';
import { I } from '@stryker-mutator/util';
import { DryRunContext, MutantInstrumenterContext, MutantInstrumenterExecutor } from '../../../src/process';
-import { InputFileCollection } from '../../../src/input/input-file-collection';
+import { InputFileCollection } from '../../../src/input';
import { coreTokens } from '../../../src/di';
import { createConcurrencyTokenProviderMock, createCheckerPoolMock, ConcurrencyTokenProviderMock } from '../../helpers/producers';
import { createCheckerFactory } from '../../../src/checker/checker-facade';
@@ -47,7 +47,7 @@ describe(MutantInstrumenterExecutor.name, () => {
preprocess: sinon.stub(),
};
sandboxFilePreprocessorMock.preprocess.resolves([mutatedFile, testFile]);
- inputFiles = new InputFileCollection([originalFile, testFile], [mutatedFile.name]);
+ inputFiles = new InputFileCollection([originalFile, testFile], [mutatedFile.name], []);
injectorMock = factory.injector();
sut = new MutantInstrumenterExecutor(injectorMock as Injector, inputFiles, testInjector.options);
injectorMock.injectClass.withArgs(Instrumenter).returns(instrumenterMock);
@@ -65,7 +65,7 @@ describe(MutantInstrumenterExecutor.name, () => {
testInjector.options.mutator.plugins = ['functionSent'];
testInjector.options.mutator.excludedMutations = ['fooMutator'];
await sut.execute();
- const expectedInstrumenterOptions: InstrumenterOptions = testInjector.options.mutator;
+ const expectedInstrumenterOptions: InstrumenterOptions = { ...testInjector.options.mutator, mutationRanges: [] };
expect(instrumenterMock.instrument).calledOnceWithExactly([originalFile], expectedInstrumenterOptions);
});
diff --git a/packages/core/test/unit/reporters/mutation-test-report-helper.spec.ts b/packages/core/test/unit/reporters/mutation-test-report-helper.spec.ts
index 6069568ec9..0759a1d2b5 100644
--- a/packages/core/test/unit/reporters/mutation-test-report-helper.spec.ts
+++ b/packages/core/test/unit/reporters/mutation-test-report-helper.spec.ts
@@ -17,7 +17,7 @@ import { CompleteDryRunResult } from '@stryker-mutator/api/test-runner';
import { CheckStatus } from '@stryker-mutator/api/check';
import { coreTokens } from '../../../src/di';
-import { InputFileCollection } from '../../../src/input/input-file-collection';
+import { InputFileCollection } from '../../../src/input';
import { MutationTestReportHelper } from '../../../src/reporters/mutation-test-report-helper';
import * as objectUtils from '../../../src/utils/object-utils';
import { createMutantTestCoverage } from '../../helpers/producers';
@@ -38,6 +38,7 @@ describe(MutationTestReportHelper.name, () => {
filesToMutate: [],
// eslint-disable-next-line @typescript-eslint/no-empty-function
logFiles: () => {},
+ mutationRanges: [],
};
dryRunResult = factory.completeDryRunResult();
});
@@ -258,7 +259,7 @@ describe(MutationTestReportHelper.name, () => {
describe('reportOne', () => {
beforeEach(() => {
- inputFiles = new InputFileCollection([new File('add.js', 'function add(a, b) {\n return a + b;\n}\n')], ['add.js']);
+ inputFiles = new InputFileCollection([new File('add.js', 'function add(a, b) {\n return a + b;\n}\n')], ['add.js'], []);
});
it('should map simple attributes to the mutant result', () => {
diff --git a/packages/instrumenter/src/instrumenter.ts b/packages/instrumenter/src/instrumenter.ts
index 9c4d6de779..bfb03fa030 100644
--- a/packages/instrumenter/src/instrumenter.ts
+++ b/packages/instrumenter/src/instrumenter.ts
@@ -2,7 +2,7 @@ import path from 'path';
import { tokens, commonTokens } from '@stryker-mutator/api/plugin';
import { Logger } from '@stryker-mutator/api/logging';
-import { File } from '@stryker-mutator/api/core';
+import { File, MutationRange } from '@stryker-mutator/api/core';
import { createParser } from './parsers';
import { transform, MutantCollector } from './transformers';
@@ -30,7 +30,7 @@ export class Instrumenter {
const parse = createParser(options);
for await (const file of files) {
const ast = await parse(file.textContent, file.name);
- transform(ast, mutantCollector, { options });
+ transform(ast, mutantCollector, { options: { ...options, mutationRanges: options.mutationRanges.map(toBabelLineNumber) } });
const mutatedContent = print(ast);
outFiles.push(new File(file.name, mutatedContent));
if (this.logger.isDebugEnabled()) {
@@ -47,3 +47,17 @@ export class Instrumenter {
};
}
}
+
+function toBabelLineNumber(range: MutationRange): MutationRange {
+ return {
+ ...range,
+ end: {
+ ...range.end,
+ line: range.end.line + 1,
+ },
+ start: {
+ ...range.start,
+ line: range.start.line + 1,
+ },
+ };
+}
diff --git a/packages/instrumenter/src/transformers/babel-transformer.ts b/packages/instrumenter/src/transformers/babel-transformer.ts
index e4d4e43175..90c67bb221 100644
--- a/packages/instrumenter/src/transformers/babel-transformer.ts
+++ b/packages/instrumenter/src/transformers/babel-transformer.ts
@@ -7,7 +7,7 @@ import { File } from '@babel/core';
import { placeMutants } from '../mutant-placers';
import { mutate } from '../mutators';
-import { instrumentationBabelHeader, isTypeNode, isImportDeclaration } from '../util/syntax-helpers';
+import { instrumentationBabelHeader, isTypeNode, isImportDeclaration, locationIncluded, locationOverlaps } from '../util/syntax-helpers';
import { AstFormat } from '../syntax';
import { AstTransformer } from '.';
@@ -20,12 +20,20 @@ export const transformBabel: AstTransformer = (
// Wrap the AST in a `new File`, so `nodePath.buildCodeFrameError` works
// https://github.com/babel/babel/issues/11889
const file = new File({ filename: originFileName }, { code: rawContent, ast: root });
+
+ // Range filters that are in scope for the current file
+ const mutantRangesForCurrentFile = options.mutationRanges.filter((mutantRange) => mutantRange.fileName === originFileName);
traverse(file.ast, {
enter(path) {
- if (isTypeNode(path) || isImportDeclaration(path) || path.isDecorator()) {
- // Don't mutate type declarations or import statements
+ // Don't mutate import statements, type definitions and nodes that don't have overlap with the current range filter
+ if (
+ isTypeNode(path) ||
+ isImportDeclaration(path) ||
+ path.isDecorator() ||
+ (mutantRangesForCurrentFile.length && mutantRangesForCurrentFile.every((range) => !locationOverlaps(range, path.node.loc!)))
+ ) {
path.skip();
- } else {
+ } else if (!mutantRangesForCurrentFile.length || mutantRangesForCurrentFile.some((range) => locationIncluded(range, path.node.loc!))) {
mutate(path, options).forEach((mutant) => {
mutantCollector.add(originFileName, mutant, offset?.position, offset?.line);
});
diff --git a/packages/instrumenter/src/transformers/transformer-options.ts b/packages/instrumenter/src/transformers/transformer-options.ts
index a216f603ae..67d1e214b4 100644
--- a/packages/instrumenter/src/transformers/transformer-options.ts
+++ b/packages/instrumenter/src/transformers/transformer-options.ts
@@ -1,3 +1,7 @@
+import { MutationRange } from '@stryker-mutator/api/core';
+
import { MutatorOptions } from '../mutators';
-export type TransformerOptions = MutatorOptions;
+export interface TransformerOptions extends MutatorOptions {
+ mutationRanges: readonly MutationRange[];
+}
diff --git a/packages/instrumenter/src/util/syntax-helpers.ts b/packages/instrumenter/src/util/syntax-helpers.ts
index ec24517f9b..e909d24ecf 100644
--- a/packages/instrumenter/src/util/syntax-helpers.ts
+++ b/packages/instrumenter/src/util/syntax-helpers.ts
@@ -201,3 +201,24 @@ const flowTypeAnnotationNodeTypes: ReadonlyArray = Object.fr
export function isImportDeclaration(path: NodePath): boolean {
return types.isTSImportEqualsDeclaration(path.node) || path.isImportDeclaration();
}
+
+/**
+ * Determines if a location (needle) is included in an other location (haystack)
+ * @param haystack The range to look in
+ * @param needle the range to search for
+ */
+export function locationIncluded(haystack: types.SourceLocation, needle: types.SourceLocation): boolean {
+ const startIncluded =
+ haystack.start.line < needle.start.line || (haystack.start.line === needle.start.line && haystack.start.column <= needle.start.column);
+ const endIncluded = haystack.end.line > needle.end.line || (haystack.end.line === needle.end.line && haystack.end.column >= needle.end.column);
+ return startIncluded && endIncluded;
+}
+
+/**
+ * Determines if two locations overlap with each other
+ */
+export function locationOverlaps(a: types.SourceLocation, b: types.SourceLocation): boolean {
+ const startIncluded = a.start.line < b.end.line || (a.start.line === b.end.line && a.start.column <= b.end.column);
+ const endIncluded = a.end.line > b.start.line || (a.end.line === b.start.line && a.end.column >= b.start.column);
+ return startIncluded && endIncluded;
+}
diff --git a/packages/instrumenter/test/helpers/factories.ts b/packages/instrumenter/test/helpers/factories.ts
index 4e4f2c8ae7..b4edfd846c 100644
--- a/packages/instrumenter/test/helpers/factories.ts
+++ b/packages/instrumenter/test/helpers/factories.ts
@@ -18,6 +18,7 @@ export function createParserOptions(overrides?: Partial): ParserO
export function createTransformerOptions(overrides?: Partial): TransformerOptions {
return {
excludedMutations: [],
+ mutationRanges: [],
...overrides,
};
}
diff --git a/packages/instrumenter/test/integration/instrumenter.it.spec.ts b/packages/instrumenter/test/integration/instrumenter.it.spec.ts
index f719d7a337..077f0ffd79 100644
--- a/packages/instrumenter/test/integration/instrumenter.it.spec.ts
+++ b/packages/instrumenter/test/integration/instrumenter.it.spec.ts
@@ -61,6 +61,53 @@ describe('instrumenter integration', () => {
});
});
+ describe('with mutation ranges', () => {
+ it('should only mutate specific mutants for the given file', async () => {
+ const fullFileName = resolveTestResource('instrumenter', 'specific-mutants.ts');
+
+ await arrangeAndActAssert('specific-mutants.ts', {
+ ...createInstrumenterOptions(),
+ mutationRanges: [
+ {
+ fileName: fullFileName,
+ start: { line: 0, column: 10 },
+ end: { line: 0, column: 15 },
+ },
+ {
+ fileName: fullFileName,
+ start: { line: 3, column: 4 },
+ end: { line: 3, column: 11 },
+ },
+ {
+ fileName: fullFileName,
+ start: { line: 7, column: 15 },
+ end: { line: 7, column: 22 },
+ },
+ {
+ fileName: fullFileName,
+ start: { line: 18, column: 2 },
+ end: { line: 19, column: 75 },
+ },
+ ],
+ });
+ });
+
+ it('should not make any mutations in a file not found in the specific mutants', async () => {
+ const fullFileName = resolveTestResource('instrumenter', 'specific-mutants.ts');
+
+ await arrangeAndActAssert('specific-no-mutants.ts', {
+ ...createInstrumenterOptions(),
+ mutationRanges: [
+ {
+ fileName: fullFileName,
+ start: { line: 1, column: 10 },
+ end: { line: 1, column: 15 },
+ },
+ ],
+ });
+ });
+ });
+
async function arrangeAndActAssert(fileName: string, options = createInstrumenterOptions()) {
const fullFileName = resolveTestResource('instrumenter', fileName);
const file = new File(fullFileName, await fsPromises.readFile(fullFileName));
diff --git a/packages/instrumenter/test/unit/instrumenter.spec.ts b/packages/instrumenter/test/unit/instrumenter.spec.ts
index 9c7e356614..63b158e5a0 100644
--- a/packages/instrumenter/test/unit/instrumenter.spec.ts
+++ b/packages/instrumenter/test/unit/instrumenter.spec.ts
@@ -37,6 +37,25 @@ describe(Instrumenter.name, () => {
expect(actualResult.files).deep.eq([new File('foo.js', output[0]), new File('bar.ts', output[1])]);
});
+ it('should convert line numbers to be 1-based (for babel internals)', async () => {
+ // Arrange
+ const { input } = arrangeTwoFiles();
+
+ // Act
+ await sut.instrument(
+ input,
+ createInstrumenterOptions({ mutationRanges: [{ fileName: 'foo.js', start: { line: 0, column: 0 }, end: { line: 6, column: 42 } }] })
+ );
+
+ // Assert
+ const actual = helper.transformerStub.getCall(0).args[2];
+ const expected: transformers.TransformerOptions = createInstrumenterOptions({
+ excludedMutations: [],
+ mutationRanges: [{ fileName: 'foo.js', start: { line: 1, column: 0 }, end: { line: 7, column: 42 } }],
+ });
+ expect(actual).deep.eq({ options: expected });
+ });
+
it('should log about instrumenting', async () => {
await sut.instrument([new File('b.js', 'foo'), new File('a.js', 'bar')], createInstrumenterOptions());
expect(testInjector.logger.debug).calledWith('Instrumenting %d source files with mutants', 2);
diff --git a/packages/instrumenter/test/unit/transformers/babel-transformer.spec.ts b/packages/instrumenter/test/unit/transformers/babel-transformer.spec.ts
index 40bad8901d..2e4f46befc 100644
--- a/packages/instrumenter/test/unit/transformers/babel-transformer.spec.ts
+++ b/packages/instrumenter/test/unit/transformers/babel-transformer.spec.ts
@@ -3,6 +3,8 @@ import { expect } from 'chai';
import { types, NodePath } from '@babel/core';
import generate from '@babel/generator';
+import { normalizeWhitespaces } from '@stryker-mutator/util';
+
import { transformerContextStub } from '../../helpers/stubs';
import { TransformerContext } from '../../../src/transformers';
import * as mutators from '../../../src/mutators';
@@ -11,6 +13,8 @@ import { MutantCollector } from '../../../src/transformers/mutant-collector';
import { transformBabel } from '../../../src/transformers/babel-transformer';
import { createJSAst, createNamedNodeMutation, createMutant, createTSAst } from '../../helpers/factories';
import { instrumentationBabelHeader } from '../../../src/util/syntax-helpers';
+import { JSAst } from '../../../src/syntax';
+import { NamedNodeMutation } from '../../../src/mutant';
describe('babel-transformer', () => {
let context: sinon.SinonStubbedInstance;
@@ -169,6 +173,82 @@ describe('babel-transformer', () => {
expectMutateNotCalledWith((t) => t.isDecorator());
});
+ describe('with mutationRanges', () => {
+ let ast: JSAst;
+ let mutant: NamedNodeMutation;
+
+ beforeEach(() => {
+ ast = createJSAst({
+ originFileName: 'foo.js',
+ rawContent:
+ 'console.log("line 1");\n' +
+ 'console.log("line 2");\n' +
+ '{\n' +
+ 'console.log("line 4");\n' +
+ 'console.log("line 5");\n' +
+ '}\n' +
+ 'console.log("line 6");\n',
+ });
+ mutant = createNamedNodeMutation({ original: types.identifier('first') });
+ mutateStub.onFirstCall().returns([mutant]);
+ });
+
+ function range(startLine: number, startColumn: number, endLine: number, endColumn: number, fileName = 'foo.js') {
+ return {
+ fileName,
+ start: { line: startLine, column: startColumn },
+ end: { line: endLine, column: endColumn },
+ };
+ }
+
+ it('should mutate a node that matches the a single line range', () => {
+ context.options.mutationRanges = [range(4, 12, 4, 20)];
+ transformBabel(ast, mutantCollectorMock, context);
+ expect(mutateStub.firstCall.args[0].toString()).eq('"line 4"');
+ });
+
+ it('should not mutate a node that does not match a single line start range', () => {
+ context.options.mutationRanges = [range(4, 13, 4, 20)];
+ transformBabel(ast, mutantCollectorMock, context);
+ expect(mutateStub).not.called;
+ });
+
+ it('should not mutate a node that does not match a single line end range', () => {
+ context.options.mutationRanges = [range(4, 12, 4, 19)];
+ transformBabel(ast, mutantCollectorMock, context);
+ expect(mutateStub).not.called;
+ });
+
+ it('should mutate a node that matches a multi line range', () => {
+ context.options.mutationRanges = [range(3, 0, 7, 0)];
+ transformBabel(ast, mutantCollectorMock, context);
+ expect(normalizeWhitespaces(mutateStub.firstCall.args[0].toString())).eq('{ console.log("line 4"); console.log("line 5"); }');
+ expect(normalizeWhitespaces(mutateStub.secondCall.args[0].toString())).eq('console.log("line 4");');
+ });
+
+ it('should not mutate a node is not in the start line range', () => {
+ context.options.mutationRanges = [range(4, 0, 7, 0)];
+ transformBabel(ast, mutantCollectorMock, context);
+ mutateStub.getCalls().forEach((call) => {
+ expect(normalizeWhitespaces(call.args[0].toString())).not.eq('{ console.log("line 4"); console.log("line 5"); }');
+ });
+ });
+
+ it('should not mutate a node is not in the end line range', () => {
+ context.options.mutationRanges = [range(2, 0, 6, 0)];
+ transformBabel(ast, mutantCollectorMock, context);
+ mutateStub.getCalls().forEach((call) => {
+ expect(normalizeWhitespaces(call.args[0].toString())).not.eq('{ console.log("line 4"); console.log("line 5"); }');
+ });
+ });
+
+ it('should still mutate other files', () => {
+ context.options.mutationRanges = [range(100, 0, 101, 0, 'bar.js')];
+ transformBabel(ast, mutantCollectorMock, context);
+ expect(mutantCollectorMock.add).calledWith('foo.js', mutant);
+ });
+ });
+
function expectMutateNotCalledWith(predicate: (nodePath: NodePath) => boolean) {
mutateStub.getCalls().forEach((call) => {
const nodePath: NodePath = call.args[0];
diff --git a/packages/instrumenter/testResources/instrumenter/specific-mutants.ts b/packages/instrumenter/testResources/instrumenter/specific-mutants.ts
new file mode 100644
index 0000000000..5f4b40bc13
--- /dev/null
+++ b/packages/instrumenter/testResources/instrumenter/specific-mutants.ts
@@ -0,0 +1,20 @@
+const a = 1 + 1;
+const b = 1 - 1;
+
+if (a === 2 && b === 0) {
+ console.log('a');
+}
+
+if (a === 2 && b === 0) {
+ console.log('b');
+}
+
+const itemWithLongName = {
+ longPropertyName1: 1,
+ longPropertyName2: 2,
+ longPropertyName3: 3,
+};
+
+const item = () =>
+ itemWithLongName.longPropertyName1 === itemWithLongName.longPropertyName2 &&
+ itemWithLongName.longPropertyName1 === itemWithLongName.longPropertyName3;
diff --git a/packages/instrumenter/testResources/instrumenter/specific-mutants.ts.out.snap b/packages/instrumenter/testResources/instrumenter/specific-mutants.ts.out.snap
new file mode 100644
index 0000000000..5d4254d371
--- /dev/null
+++ b/packages/instrumenter/testResources/instrumenter/specific-mutants.ts.out.snap
@@ -0,0 +1,76 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`instrumenter integration with mutation ranges should only mutate specific mutants for the given file 1`] = `
+"function stryNS_9fa48() {
+ var g = new Function(\\"return this\\")();
+ var ns = g.__stryker__ || (g.__stryker__ = {});
+
+ if (ns.activeMutant === undefined && g.process && g.process.env && g.process.env.__STRYKER_ACTIVE_MUTANT__) {
+ ns.activeMutant = Number(g.process.env.__STRYKER_ACTIVE_MUTANT__);
+ }
+
+ function retrieveNS() {
+ return ns;
+ }
+
+ stryNS_9fa48 = retrieveNS;
+ return retrieveNS();
+}
+
+stryNS_9fa48();
+
+function stryCov_9fa48() {
+ var ns = stryNS_9fa48();
+ var cov = ns.mutantCoverage || (ns.mutantCoverage = {
+ static: {},
+ perTest: {}
+ });
+
+ function cover() {
+ var c = cov.static;
+
+ if (ns.currentTestId) {
+ c = cov.perTest[ns.currentTestId] = cov.perTest[ns.currentTestId] || {};
+ }
+
+ var a = arguments;
+
+ for (var i = 0; i < a.length; i++) {
+ c[a[i]] = (c[a[i]] || 0) + 1;
+ }
+ }
+
+ stryCov_9fa48 = cover;
+ cover.apply(null, arguments);
+}
+
+function stryMutAct_9fa48(id) {
+ var ns = stryNS_9fa48();
+
+ function isActive(id) {
+ return ns.activeMutant === id;
+ }
+
+ stryMutAct_9fa48 = isActive;
+ return isActive(id);
+}
+
+const a = stryMutAct_9fa48(0) ? 1 - 1 : (stryCov_9fa48(0), 1 + 1);
+const b = 1 - 1;
+
+if ((stryMutAct_9fa48(3) ? a !== 2 : stryMutAct_9fa48(2) ? false : stryMutAct_9fa48(1) ? true : (stryCov_9fa48(1, 2, 3), a === 2)) && b === 0) {
+ console.log('a');
+}
+
+if (a === 2 && (stryMutAct_9fa48(6) ? b !== 0 : stryMutAct_9fa48(5) ? false : stryMutAct_9fa48(4) ? true : (stryCov_9fa48(4, 5, 6), b === 0))) {
+ console.log('b');
+}
+
+const itemWithLongName = {
+ longPropertyName1: 1,
+ longPropertyName2: 2,
+ longPropertyName3: 3
+};
+
+const item = () => stryMutAct_9fa48(9) ? itemWithLongName.longPropertyName1 === itemWithLongName.longPropertyName2 || itemWithLongName.longPropertyName1 === itemWithLongName.longPropertyName3 : stryMutAct_9fa48(8) ? false : stryMutAct_9fa48(7) ? true : (stryCov_9fa48(7, 8, 9), (stryMutAct_9fa48(12) ? itemWithLongName.longPropertyName1 !== itemWithLongName.longPropertyName2 : stryMutAct_9fa48(11) ? false : stryMutAct_9fa48(10) ? true : (stryCov_9fa48(10, 11, 12), itemWithLongName.longPropertyName1 === itemWithLongName.longPropertyName2)) && (stryMutAct_9fa48(15) ? itemWithLongName.longPropertyName1 !== itemWithLongName.longPropertyName3 : stryMutAct_9fa48(14) ? false : stryMutAct_9fa48(13) ? true : (stryCov_9fa48(13, 14, 15), itemWithLongName.longPropertyName1 === itemWithLongName.longPropertyName3)));"
+`;
diff --git a/packages/instrumenter/testResources/instrumenter/specific-no-mutants.ts b/packages/instrumenter/testResources/instrumenter/specific-no-mutants.ts
new file mode 100644
index 0000000000..f381a761d4
--- /dev/null
+++ b/packages/instrumenter/testResources/instrumenter/specific-no-mutants.ts
@@ -0,0 +1,2 @@
+const a = 1 + 1;
+const b = 1 - 1;
diff --git a/packages/instrumenter/testResources/instrumenter/specific-no-mutants.ts.out.snap b/packages/instrumenter/testResources/instrumenter/specific-no-mutants.ts.out.snap
new file mode 100644
index 0000000000..e978c86d60
--- /dev/null
+++ b/packages/instrumenter/testResources/instrumenter/specific-no-mutants.ts.out.snap
@@ -0,0 +1,60 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`instrumenter integration with mutation ranges should not make any mutations in a file not found in the specific mutants 1`] = `
+"function stryNS_9fa48() {
+ var g = new Function(\\"return this\\")();
+ var ns = g.__stryker__ || (g.__stryker__ = {});
+
+ if (ns.activeMutant === undefined && g.process && g.process.env && g.process.env.__STRYKER_ACTIVE_MUTANT__) {
+ ns.activeMutant = Number(g.process.env.__STRYKER_ACTIVE_MUTANT__);
+ }
+
+ function retrieveNS() {
+ return ns;
+ }
+
+ stryNS_9fa48 = retrieveNS;
+ return retrieveNS();
+}
+
+stryNS_9fa48();
+
+function stryCov_9fa48() {
+ var ns = stryNS_9fa48();
+ var cov = ns.mutantCoverage || (ns.mutantCoverage = {
+ static: {},
+ perTest: {}
+ });
+
+ function cover() {
+ var c = cov.static;
+
+ if (ns.currentTestId) {
+ c = cov.perTest[ns.currentTestId] = cov.perTest[ns.currentTestId] || {};
+ }
+
+ var a = arguments;
+
+ for (var i = 0; i < a.length; i++) {
+ c[a[i]] = (c[a[i]] || 0) + 1;
+ }
+ }
+
+ stryCov_9fa48 = cover;
+ cover.apply(null, arguments);
+}
+
+function stryMutAct_9fa48(id) {
+ var ns = stryNS_9fa48();
+
+ function isActive(id) {
+ return ns.activeMutant === id;
+ }
+
+ stryMutAct_9fa48 = isActive;
+ return isActive(id);
+}
+
+const a = stryMutAct_9fa48(0) ? 1 - 1 : (stryCov_9fa48(0), 1 + 1);
+const b = stryMutAct_9fa48(1) ? 1 + 1 : (stryCov_9fa48(1), 1 - 1);"
+`;