@sasjs/lint 4.3.1 → 4.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -92,6 +92,51 @@ Two habits catch the failures that plain coverage does not:
92
92
  - **Mutation-check your assertions.** Revert the behaviour one piece at a time and confirm a test fails. An assertion that passes on the broken code is documenting nothing. Watch for vacuous cases: for example, a "does not throw" test over a mis-split file passes while the mis-split still contains the string being searched for, so the fixture has to be shaped to fail.
93
93
  - **A negative test must fail for the reason it names.** Assert on the specific message or error, never a bare `toThrow()`.
94
94
 
95
+ ### Mutation testing
96
+
97
+ The habit above is automated, because the mutant you think to try is rarely the
98
+ one that matters. Stryker rewrites the source in small ways (an operator, a
99
+ string, a branch) and runs the tests; a mutant that survives is behaviour no
100
+ test asserts.
101
+
102
+ ```bash
103
+ npm run mutation:changed # only the files this branch changed, vs the baseline
104
+ MUTATION_FILES=src/utils/parseMacroCalls.ts npm run mutation:changed # one file
105
+ npm run mutation # whatever stryker.conf.json selects
106
+ npm run mutation:watch # the whole tree; prints a refreshed baseline
107
+ ```
108
+
109
+ The gate compares each file with `mutation-baseline.json` and fails when a file
110
+ carries MORE surviving mutants than the baseline records, so it asks "did this
111
+ branch add an untested behaviour?", not "is the repo perfect yet". A file with no
112
+ baseline must reach 80%. `mutation-pr.yml` runs the changed-file gate on every
113
+ pull request; `mutation-weekly.yml` runs the whole tree and fails if anything
114
+ regressed, which is how the baseline is refreshed (the run prints the new JSON to
115
+ paste in).
116
+
117
+ Two things to know before running it:
118
+
119
+ - the dev tree needs Node 22.18 or newer: `@stryker-mutator/core` pulls `@babel/*` 8,
120
+ whose `engines` start at 22.18. `package.json` records that in `devEngines` (dev installs
121
+ warn, published installs are unaffected - the tarball carries no devDependencies).
122
+
123
+
124
+ - `jest.stryker.config.js` differs from `jest.config.js` in switching coverage
125
+ off, and in nothing else: a skipped spec records the mutants it covers as
126
+ legitimate survivors, and the weekly refresh writes them into the baseline.
127
+ `isIgnored` therefore derives its path with `path.relative(root, file)` and
128
+ returns false for a path outside the root, and `isIgnored.spec.ts` pins
129
+ `getProjectRoot()` with a jest mock - the `ignore` package rejects an absolute
130
+ or `..`-prefixed path, which is what a sandbox run produced before.
131
+ - a run mutates the file(s) in a sandbox under `.stryker-tmp`, and `jest.config.js`
132
+ ignores that directory by a `<rootDir>`-anchored pattern - an unanchored one
133
+ makes Stryker's own dry run collect nothing.
134
+
135
+ A surviving mutant is either a missing test or an EQUIVALENT mutant (a change
136
+ that cannot alter behaviour, such as dropping a check the following regular
137
+ expression repeats). Add the spec that fails on it, or say in the PR body why it
138
+ is equivalent - do not lower the baseline.
139
+
95
140
  ## Regenerating the shipped macro list
96
141
 
97
142
  The macros SAS ships are language data, owned by `@sasjs/sas-language`. Do not hand-write them here. `npm run generate:macros` reads its `macroStatements` and `macroFunctions` groups and writes `src/utils/sasMacros.generated.ts`, which is committed so the published package carries no runtime dependency. `sasMacros.spec.ts` reads the same data independently and fails when the committed list differs, which is the drift guard. Extending that vocabulary is a PR to `@sasjs/sas-language`.
@@ -0,0 +1,69 @@
1
+ name: Mutation (changed files)
2
+
3
+ # Mutation testing answers a question coverage cannot: does any test FAIL when
4
+ # the code is wrong? Stryker flips an operator or a string in the source and runs
5
+ # the tests; a mutant that survives is behaviour nothing asserts.
6
+ #
7
+ # This job mutates only the source files the PR changed, and compares them with
8
+ # mutation-baseline.json - so it asks "did this branch add an untested
9
+ # behaviour?", not "is the repo perfect yet". The baseline is refreshed by
10
+ # mutation-weekly.yml.
11
+ #
12
+ # Cost control: Stryker runs the tests that cover the mutated file
13
+ # (`enableFindRelatedTests`), and only the mutated file is instrumented. A
14
+ # two-file run takes minutes, not the hours a whole-suite run per mutant would.
15
+
16
+ on:
17
+ pull_request:
18
+ paths:
19
+ - 'src/**'
20
+ - 'jest.config.js'
21
+ - 'jest.stryker.config.js'
22
+ - 'stryker.conf.json'
23
+ - 'mutation-baseline.json'
24
+ - 'scripts/mutation-gate.mjs'
25
+ - 'package.json'
26
+ - 'package-lock.json'
27
+
28
+ # a second push supersedes the first run rather than queueing behind it
29
+ concurrency:
30
+ group: mutation-${{ github.event.pull_request.number }}
31
+ cancel-in-progress: true
32
+
33
+ jobs:
34
+ mutation:
35
+ runs-on: ubuntu-latest
36
+ # checkout needs read, and the job writes nothing else
37
+ permissions:
38
+ contents: read
39
+ # a changed-file run takes minutes; the default cap is six hours, which would
40
+ # hold a runner on a stuck mutation run
41
+ timeout-minutes: 20
42
+
43
+ steps:
44
+ - uses: actions/checkout@v4
45
+ with:
46
+ # the diff against the base commit needs the base commit
47
+ fetch-depth: 0
48
+
49
+ - name: Use Node.js
50
+ uses: actions/setup-node@v4
51
+ with:
52
+ node-version: 22
53
+ cache: npm
54
+
55
+ - name: Install Dependencies
56
+ run: npm ci
57
+
58
+ - name: Mutate the changed files
59
+ env:
60
+ MUTATION_BASE: ${{ github.event.pull_request.base.sha }}
61
+ run: node scripts/mutation-gate.mjs pr
62
+
63
+ - name: Keep the report
64
+ if: always()
65
+ uses: actions/upload-artifact@v4
66
+ with:
67
+ name: mutation-report
68
+ path: reports/mutation/mutation.json
69
+ if-no-files-found: ignore
@@ -0,0 +1,92 @@
1
+ name: Mutation Watch
2
+
3
+ # The per-PR mutation gate compares a branch's changed files with a baseline, so
4
+ # the baseline is the thing that has to stay honest: if it is never refreshed,
5
+ # files that improve keep their old count and the gate stops asking for more.
6
+ #
7
+ # This runs the whole source tree weekly, prints the new baseline, and fails when
8
+ # any file carries MORE surviving mutants than the recorded one - which means a
9
+ # PR was merged without the gate seeing it (a push to main, a config change, a
10
+ # file the gate skipped). Failure files an issue, as audit-watch.yml does, and
11
+ # only a genuine regression does so.
12
+
13
+ on:
14
+ schedule:
15
+ - cron: '0 7 * * 1' # Mondays, 07:00 UTC
16
+ workflow_dispatch:
17
+
18
+ jobs:
19
+ watch:
20
+ runs-on: ubuntu-latest
21
+ # the whole tree takes ~20-30 minutes; bounded so a hang cannot hold a runner
22
+ # for the six-hour default
23
+ timeout-minutes: 90
24
+ permissions:
25
+ contents: read
26
+ issues: write
27
+
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+
31
+ - name: Use Node.js
32
+ uses: actions/setup-node@v4
33
+ with:
34
+ node-version: 22
35
+ cache: npm
36
+
37
+ - name: Install Dependencies
38
+ run: npm ci
39
+
40
+ - name: Mutate the whole source tree
41
+ id: watch
42
+ env:
43
+ MUTATION_WATCH_REPORT: ${{ runner.temp }}/mutation-watch-report.md
44
+ run: node scripts/mutation-gate.mjs weekly
45
+
46
+ - name: Keep the report
47
+ if: always()
48
+ uses: actions/upload-artifact@v4
49
+ with:
50
+ name: mutation-watch-report
51
+ path: |
52
+ reports/mutation/mutation.json
53
+ ${{ runner.temp }}/mutation-watch-report.md
54
+ if-no-files-found: ignore
55
+
56
+ report:
57
+ needs: watch
58
+ # a failure files an issue; a cancelled run does so only when the schedule
59
+ # cancelled it (the 90-minute timeout), so cancelling a manual run by hand
60
+ # does not claim a regression that never happened
61
+ if: >-
62
+ always() &&
63
+ (needs.watch.result == 'failure' ||
64
+ (needs.watch.result == 'cancelled' && github.event_name == 'schedule'))
65
+ runs-on: ubuntu-latest
66
+ timeout-minutes: 5
67
+ permissions:
68
+ contents: read
69
+ issues: write
70
+
71
+ steps:
72
+ - name: File an issue
73
+ env:
74
+ GH_TOKEN: ${{ github.token }}
75
+ run: |
76
+ title='Mutation watch: a file carries more surviving mutants than the baseline'
77
+ existing=$(gh issue list --state open --search "\"$title\" in:title" \
78
+ --json number --jq '.[0].number // empty')
79
+
80
+ if [ -n "$existing" ]; then
81
+ echo "already tracked in #$existing"
82
+ exit 0
83
+ fi
84
+
85
+ gh issue create --title "$title" --body \
86
+ "The weekly mutation run found a file carrying more surviving mutants than mutation-baseline.json records, so behaviour was merged without a test that fails on it.
87
+
88
+ The run names the file and each surviving mutant (file:line:column, mutator, replacement), and prints the refreshed baseline:
89
+
90
+ $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID
91
+
92
+ Close this once a spec kills them and mutation-baseline.json is updated from that run."
package/jest.config.js CHANGED
@@ -1,12 +1,61 @@
1
1
  module.exports = {
2
2
  preset: 'ts-jest',
3
3
  testEnvironment: 'node',
4
+ // A Stryker run keeps a sandbox under .stryker-tmp with a copy of src/ in it.
5
+ // Without this, any jest run that overlaps a mutation run collects every spec
6
+ // twice (measured: 946 tests instead of 464) and reports failures from files
7
+ // that are being mutated. Anchored to <rootDir> on purpose: inside the sandbox
8
+ // the whole project IS under .stryker-tmp, and an unanchored pattern would make
9
+ // Stryker's own dry run collect nothing ("No tests were executed").
10
+ testPathIgnorePatterns: [
11
+ '/node_modules/',
12
+ '<rootDir>/.stryker-tmp/',
13
+ '<rootDir>/build/'
14
+ ],
4
15
  coverageThreshold: {
5
16
  global: {
6
17
  branches: 80,
7
18
  functions: 80,
8
19
  lines: 80,
9
20
  statements: -10
21
+ },
22
+ // Per-path floors, so a change that hollows out one area cannot hide behind
23
+ // the aggregate. Every number is the coverage measured when this was written,
24
+ // rounded down, so the block is a ratchet: raise one when the area improves,
25
+ // never lower one to get a branch green. `statements` is a PERCENTAGE unless
26
+ // it is negative, where `-N` reads as "at most N uncovered statements" - so a
27
+ // plain `0` asserts nothing at all (0% coverage passes), which is why the two
28
+ // fully-covered areas ask for 100 and the three with known gaps carry a budget
29
+ // measured from the tree on the day this landed.
30
+ './src/utils/': {
31
+ branches: 95,
32
+ functions: 100,
33
+ lines: 99,
34
+ statements: -4
35
+ },
36
+ './src/rules/': {
37
+ branches: 95,
38
+ functions: 100,
39
+ lines: 100,
40
+ statements: 100
41
+ },
42
+ './src/lint/': {
43
+ branches: 85,
44
+ functions: 100,
45
+ lines: 100,
46
+ statements: -2
47
+ },
48
+ './src/format/': {
49
+ branches: 90,
50
+ functions: 100,
51
+ lines: 100,
52
+ statements: 100
53
+ },
54
+ './src/types/': {
55
+ branches: 95,
56
+ functions: 100,
57
+ lines: 96,
58
+ statements: -4
10
59
  }
11
60
  },
12
61
  collectCoverageFrom: ['src/**/{!(index|formatExample|lintExample),}.ts']
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Jest config for Stryker runs.
3
+ *
4
+ * The only difference from jest.config.js is that coverage is off: Stryker
5
+ * instruments the code itself, and the coverage thresholds would fail a
6
+ * single-mutant run. The suite itself runs in full, so the weekly baseline is
7
+ * built on every test - a skipped spec here would record mutants it covers as
8
+ * legitimate survivors and let them into the baseline permanently.
9
+ */
10
+ const base = require('./jest.config.js')
11
+
12
+ module.exports = {
13
+ ...base,
14
+ collectCoverage: false,
15
+ coverageThreshold: undefined,
16
+ collectCoverageFrom: undefined
17
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "_comment": "Surviving-mutant counts per file, used by scripts/mutation-gate.mjs. Refresh from the output of npm run mutation:watch; raise a count only with a reason in the PR that raises it.",
3
+ "generatedAt": "2026-10-25T20:06:38Z",
4
+ "files": {
5
+ "src/rules/file/noUnusedMacros.ts": {
6
+ "score": 100.0,
7
+ "survived": 0,
8
+ "killed": 37,
9
+ "timeout": 0
10
+ },
11
+ "src/utils/getIndicesOf.ts": {
12
+ "score": 100.0,
13
+ "survived": 0,
14
+ "killed": 15,
15
+ "timeout": 6
16
+ },
17
+ "src/utils/isIgnored.ts": {
18
+ "score": 100.0,
19
+ "survived": 0,
20
+ "killed": 25,
21
+ "timeout": 0
22
+ },
23
+ "src/utils/parseMacroCalls.ts": {
24
+ "score": 92.98,
25
+ "survived": 25,
26
+ "killed": 293,
27
+ "timeout": 38
28
+ }
29
+ }
30
+ }
package/package.json CHANGED
@@ -12,7 +12,10 @@
12
12
  "publishInit": "cp -r ./build/* . && rm -rf ./build && rm -rf ./src && rm tsconfig.json",
13
13
  "package:lib": "npm run build && cp ./package.json ./checkNodeVersion.js build && cp README.md build && cd build && npm version \"5.0.0\" && npm pack",
14
14
  "lint:fix": "npx prettier --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\"",
15
- "lint": "npx prettier --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\""
15
+ "lint": "npx prettier --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\"",
16
+ "mutation": "stryker run",
17
+ "mutation:changed": "node scripts/mutation-gate.mjs pr",
18
+ "mutation:watch": "node scripts/mutation-gate.mjs weekly"
16
19
  },
17
20
  "publishConfig": {
18
21
  "access": "public"
@@ -62,6 +65,8 @@
62
65
  "devDependencies": {
63
66
  "@nogoo9/gitleaks": "8.30.1-post.2",
64
67
  "@sasjs/sas-language": "0.3.0",
68
+ "@stryker-mutator/core": "10.1.1",
69
+ "@stryker-mutator/jest-runner": "10.1.1",
65
70
  "@types/jest": "30.0.0",
66
71
  "@types/node": "26.6.3",
67
72
  "jest": "30.5.2",
@@ -73,5 +78,12 @@
73
78
  "@sasjs/utils": "4.0.0",
74
79
  "ignore": "7.0.11"
75
80
  },
76
- "version": "4.3.1"
81
+ "version": "4.3.2",
82
+ "devEngines": {
83
+ "runtime": {
84
+ "name": "node",
85
+ "version": ">=22.18",
86
+ "onFail": "warn"
87
+ }
88
+ }
77
89
  }
@@ -0,0 +1,390 @@
1
+ // Runs mutation testing over the files a branch changed, and fails when a file
2
+ // carries more surviving mutants than the recorded baseline.
3
+ //
4
+ // node scripts/mutation-gate.mjs pr # changed files, compare to baseline
5
+ // node scripts/mutation-gate.mjs weekly # whole src, print the new baseline
6
+ //
7
+ // Set MUTATION_FILES to run the gate on named files instead of the diff - the
8
+ // way to check a file you are working on before committing it:
9
+ //
10
+ // MUTATION_FILES=src/utils/parseMacroCalls.ts node scripts/mutation-gate.mjs pr
11
+ //
12
+ // A surviving mutant is a change to the code that no test noticed. The baseline
13
+ // records how many each file already carries, so the gate asks "did this branch
14
+ // add an untested behaviour?", not "is the repo perfect yet" - a threshold on
15
+ // the absolute score would fail every PR until the backlog is cleared.
16
+ import { execFileSync } from 'node:child_process'
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+
20
+ const BASELINE_FILE = 'mutation-baseline.json'
21
+ const REPORT_FILE = 'reports/mutation/mutation.json'
22
+ const NEW_FILE_FLOOR = 80
23
+ const BASELINE_COMMENT =
24
+ 'Surviving-mutant counts per file, used by scripts/mutation-gate.mjs. Refresh from the output of npm run mutation:watch; raise a count only with a reason in the PR that raises it.'
25
+
26
+ const mode = process.argv[2] || 'pr'
27
+
28
+ /**
29
+ * The baseline to police a branch against.
30
+ *
31
+ * With a ref, this is the copy on the BASE branch, read out of git rather than
32
+ * taken from the working tree: the file travels inside the PR, so a branch that
33
+ * raises the number it is measured against would otherwise pass, and the weekly
34
+ * watch - which compares against the merged value - would never fire either.
35
+ */
36
+ const readBaseline = (ref) => {
37
+ if (ref) {
38
+ // A ref that cannot be resolved (a shallow clone, a typo) must fail loudly:
39
+ // swallowing it drops the run to the new-file floor and reports green, which
40
+ // is the same fail-open the changedFiles() handler refuses.
41
+ try {
42
+ execFileSync(
43
+ 'git',
44
+ ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`],
45
+ {
46
+ stdio: ['ignore', 'ignore', 'ignore']
47
+ }
48
+ )
49
+ } catch (e) {
50
+ throw new Error(
51
+ `cannot resolve the base ref '${ref}' - fetch it (fetch-depth: 0 in CI) or set MUTATION_FILES`
52
+ )
53
+ }
54
+
55
+ // Does the ref carry a baseline at all? Ask git rather than reading the
56
+ // error text of a failed `git show`, whose wording differs by version and is
57
+ // empty when stderr is swallowed.
58
+ try {
59
+ execFileSync('git', ['cat-file', '-e', `${ref}:${BASELINE_FILE}`], {
60
+ stdio: ['ignore', 'ignore', 'ignore']
61
+ })
62
+ } catch (e) {
63
+ // the ref resolves but carried no baseline: the PR that adds the file
64
+ return null
65
+ }
66
+
67
+ try {
68
+ const raw = execFileSync('git', ['show', `${ref}:${BASELINE_FILE}`], {
69
+ encoding: 'utf8',
70
+ stdio: ['ignore', 'pipe', 'pipe']
71
+ })
72
+ return JSON.parse(raw)
73
+ } catch (e) {
74
+ const detail = (e.stderr && e.stderr.toString().trim()) || e.message
75
+ throw new Error(`cannot read ${BASELINE_FILE} at '${ref}': ${detail}`)
76
+ }
77
+ }
78
+
79
+ if (!fs.existsSync(BASELINE_FILE)) return { files: {} }
80
+ return JSON.parse(fs.readFileSync(BASELINE_FILE, 'utf8'))
81
+ }
82
+
83
+ /**
84
+ * The ref a branch is policed against: MUTATION_BASE when set, otherwise the
85
+ * merge base with origin/main, so the raise-check also fires for the documented
86
+ * local run (`npm run mutation:changed` with no MUTATION_BASE) instead of
87
+ * comparing the working copy against itself.
88
+ */
89
+ const resolveBaseRef = () => {
90
+ if (process.env.MUTATION_BASE) return process.env.MUTATION_BASE
91
+ try {
92
+ return execFileSync('git', ['merge-base', 'HEAD', 'origin/main'], {
93
+ encoding: 'utf8',
94
+ stdio: ['ignore', 'pipe', 'ignore']
95
+ }).trim()
96
+ } catch (e) {
97
+ return null
98
+ }
99
+ }
100
+
101
+ /** The source files this branch changed, relative to the base branch. */
102
+ const changedFiles = () => {
103
+ const base = resolveBaseRef()
104
+ if (!base) {
105
+ console.error(
106
+ 'Could not resolve a base ref (no MUTATION_BASE, no origin/main to merge-base against).'
107
+ )
108
+ process.exit(1)
109
+ }
110
+ let diff = ''
111
+ try {
112
+ diff = execFileSync('git', ['diff', '--name-only', `${base}...HEAD`], {
113
+ encoding: 'utf8'
114
+ })
115
+ } catch (e) {
116
+ // A broken clone or a missing base sha would otherwise look like "no files
117
+ // changed" and turn the gate into a green no-op.
118
+ console.error(`Could not diff against ${base}: ${e.message}`)
119
+ console.error(
120
+ 'Fetch the base branch (fetch-depth: 0 in CI) or set MUTATION_FILES.'
121
+ )
122
+ process.exit(1)
123
+ }
124
+
125
+ const changed = diff
126
+ .split('\n')
127
+ .map((line) => line.trim())
128
+ .filter(Boolean)
129
+
130
+ const mutable = changed
131
+ .filter((line) => line.startsWith('src/'))
132
+ .filter((line) => line.endsWith('.ts'))
133
+ .filter((line) => !line.endsWith('.spec.ts'))
134
+ .filter((line) => fs.existsSync(line))
135
+
136
+ return { changed, mutable }
137
+ }
138
+
139
+ const runStryker = (mutate) => {
140
+ fs.rmSync('reports/mutation', { recursive: true, force: true })
141
+ execFileSync(
142
+ 'npx',
143
+ ['stryker', 'run', '--mutate', mutate.join(','), '--reporters', 'json'],
144
+ { stdio: 'inherit' }
145
+ )
146
+ return JSON.parse(fs.readFileSync(REPORT_FILE, 'utf8'))
147
+ }
148
+
149
+ /** Per-file totals from a Stryker JSON report. */
150
+ const summarise = (report) => {
151
+ const files = {}
152
+ for (const [name, file] of Object.entries(report.files)) {
153
+ const mutants = file.mutants || []
154
+ const survived = mutants.filter(
155
+ (m) => m.status === 'Survived' || m.status === 'NoCoverage'
156
+ )
157
+ const killed = mutants.filter((m) => m.status === 'Killed').length
158
+ const timeout = mutants.filter((m) => m.status === 'Timeout').length
159
+ // Ignored (a `// Stryker disable` comment) and CompileError mutants cannot be
160
+ // killed by any test, so counting them in the denominator reports a score
161
+ // below Stryker's own and can fail a file with no real survivor. NoCoverage is
162
+ // NOT one of them: a mutant no test executes is the untested behaviour this
163
+ // gate exists to catch, so it counts against the score and as a survivor.
164
+ const unkillable = mutants.filter(
165
+ (m) => m.status === 'CompileError' || m.status === 'Ignored'
166
+ ).length
167
+ const covered = mutants.length - unkillable
168
+ files[name] = {
169
+ // no covered mutants means no test even ran against this file: scoring that
170
+ // 100 would let a brand-new untested file pass the new-file floor
171
+ score:
172
+ covered === 0
173
+ ? 0
174
+ : Math.round(((killed + timeout) / covered) * 10000) / 100,
175
+ survived: survived.length,
176
+ killed,
177
+ timeout,
178
+ survivors: survived.map((m) => ({
179
+ line: m.location.start.line,
180
+ column: m.location.start.column,
181
+ mutator: m.mutatorName,
182
+ replacement: m.replacement,
183
+ status: m.status
184
+ }))
185
+ }
186
+ }
187
+ return files
188
+ }
189
+
190
+ const describeSurvivor = (file, m) =>
191
+ ` ${file}:${m.line}:${m.column} ${m.mutator} -> ${m.replacement}${
192
+ m.status === 'NoCoverage' ? ' (no test executes this line)' : ''
193
+ }`
194
+
195
+ if (mode === 'weekly') {
196
+ const files = summarise(runStryker(['src/**/*.ts', '!src/**/*.spec.ts']))
197
+
198
+ const baseline = readBaseline().files || {}
199
+
200
+ // A file that reached main with no baseline entry - a push that bypassed the PR
201
+ // gate, or a file added before this gate existed - is not reported by a
202
+ // survivors-only comparison. It counts as a regression while it is below the
203
+ // new-file floor.
204
+ const regressions = Object.entries(files).filter(([name, stats]) => {
205
+ const known = baseline[name]
206
+ if (known === undefined) return stats.score < NEW_FILE_FLOOR
207
+ return stats.survived > known.survived
208
+ })
209
+
210
+ console.log('\nPer-file mutation score:')
211
+ for (const [name, stats] of Object.entries(files)) {
212
+ console.log(
213
+ ` ${name}: ${stats.score}% (${stats.killed} killed, ${stats.timeout} timeout, ${stats.survived} survived)`
214
+ )
215
+ }
216
+
217
+ // Printed without the per-mutant detail, so a refresh is a diff of counts
218
+ // rather than of every survivor's line and column. The timestamp stays: it is
219
+ // the field a reader uses to judge how stale the counts are.
220
+ const nextBaseline = {
221
+ _comment: BASELINE_COMMENT,
222
+ generatedAt: new Date().toISOString(),
223
+ files: Object.fromEntries(
224
+ Object.entries(files)
225
+ .sort(([a], [b]) => (a < b ? -1 : 1))
226
+ .map(([name, stats]) => [
227
+ name,
228
+ {
229
+ score: stats.score,
230
+ survived: stats.survived,
231
+ killed: stats.killed,
232
+ timeout: stats.timeout
233
+ }
234
+ ])
235
+ )
236
+ }
237
+ const printed = JSON.stringify(nextBaseline, null, 2)
238
+ console.log('\nUpdated baseline (commit this as mutation-baseline.json):')
239
+ console.log(printed)
240
+
241
+ // the weekly workflow keeps the refreshed baseline as a job artifact, so the
242
+ // new counts survive the log
243
+ if (process.env.MUTATION_WATCH_REPORT)
244
+ fs.writeFileSync(process.env.MUTATION_WATCH_REPORT, `${printed}\n`)
245
+
246
+ if (regressions.length) {
247
+ console.error('\nSurviving mutants grew since the baseline:')
248
+ for (const [name, stats] of regressions) {
249
+ const known = baseline[name]
250
+ console.error(
251
+ known === undefined
252
+ ? ` ${name}: no baseline entry, scores ${stats.score}% (floor ${NEW_FILE_FLOOR}%)`
253
+ : ` ${name}: ${known.survived} -> ${stats.survived}`
254
+ )
255
+ stats.survivors.forEach((m) => console.error(describeSurvivor(name, m)))
256
+ }
257
+ process.exit(1)
258
+ }
259
+
260
+ console.log('\nNo file carries more surviving mutants than the baseline.')
261
+ process.exit(0)
262
+ }
263
+
264
+ // A PR that changes the gate's own configuration must still exercise it, or a
265
+ // broken config ships green: no source file changed, so the changed-file run has
266
+ // nothing to mutate. The probe is a small file with a baseline entry.
267
+ const PROBE_FILES = ['src/utils/getIndicesOf.ts']
268
+
269
+ const WATCHED_CONFIG = [
270
+ 'jest.config.js',
271
+ 'jest.stryker.config.js',
272
+ 'stryker.conf.json',
273
+ 'mutation-baseline.json',
274
+ 'scripts/mutation-gate.mjs',
275
+ 'package.json',
276
+ 'package-lock.json'
277
+ ]
278
+
279
+ let mutate = []
280
+ let configOnly = false
281
+
282
+ if (process.env.MUTATION_FILES) {
283
+ mutate = process.env.MUTATION_FILES.split(',')
284
+ .map((f) => f.trim())
285
+ .filter(Boolean)
286
+ } else {
287
+ const { changed, mutable } = changedFiles()
288
+ mutate = mutable
289
+ configOnly =
290
+ !mutable.length && changed.some((f) => WATCHED_CONFIG.includes(f))
291
+ if (configOnly) {
292
+ console.log(
293
+ 'Only the gate configuration changed; running it against the probe file.'
294
+ )
295
+ mutate = PROBE_FILES
296
+ }
297
+ }
298
+
299
+ if (!mutate.length) {
300
+ console.log('No changed source files to mutate.')
301
+ process.exit(0)
302
+ }
303
+
304
+ console.log(`Mutating the files this branch changed:\n ${mutate.join('\n ')}`)
305
+
306
+ const files = summarise(runStryker(mutate))
307
+ const baseRef = resolveBaseRef()
308
+ let baseBaseline = null
309
+ if (!baseRef) {
310
+ console.error(
311
+ 'Could not resolve a base ref (no MUTATION_BASE, no origin/main to merge-base against).'
312
+ )
313
+ process.exit(1)
314
+ }
315
+ try {
316
+ baseBaseline = readBaseline(baseRef)
317
+ } catch (e) {
318
+ console.error(e.message)
319
+ process.exit(1)
320
+ }
321
+ console.log(`Base ref: ${baseRef}`)
322
+ const headBaseline = readBaseline().files || {}
323
+ const baseline = baseBaseline ? baseBaseline.files || {} : {}
324
+ const failures = []
325
+
326
+ // The baseline travels inside the PR, so a branch can otherwise raise the number
327
+ // it is measured against - and the weekly watch compares against the merged
328
+ // value, so it would stay quiet too.
329
+ if (baseBaseline) {
330
+ Object.entries(headBaseline).forEach(([name, entry]) => {
331
+ const known = baseline[name]
332
+ if (!known) {
333
+ if (!configOnly) console.log(`${name}: added to the baseline in this PR`)
334
+ return
335
+ }
336
+ if (entry.survived > known.survived)
337
+ failures.push(
338
+ `${name}: the baseline was raised in this PR, ${known.survived} -> ${entry.survived} surviving mutants`
339
+ )
340
+ })
341
+ Object.keys(baseline).forEach((name) => {
342
+ if (headBaseline[name]) return
343
+ // A source file deleted or renamed in this PR takes its entry with it; the
344
+ // entry only has to go when the file is still there to be mutated.
345
+ if (!fs.existsSync(name)) {
346
+ console.log(`${name}: deleted, its baseline entry goes with it`)
347
+ return
348
+ }
349
+ failures.push(`${name}: removed from the baseline in this PR`)
350
+ })
351
+ }
352
+
353
+ for (const [name, stats] of Object.entries(files)) {
354
+ const known = baseline[name]
355
+
356
+ if (!known) {
357
+ if (stats.score < NEW_FILE_FLOOR) {
358
+ failures.push(
359
+ `${name} scores ${stats.score}% and has no baseline: a new file must reach ${NEW_FILE_FLOOR}%`
360
+ )
361
+ stats.survivors.forEach((m) => failures.push(describeSurvivor(name, m)))
362
+ } else {
363
+ console.log(`${name}: ${stats.score}% (new file, above the floor)`)
364
+ }
365
+ continue
366
+ }
367
+
368
+ if (stats.survived > known.survived) {
369
+ failures.push(
370
+ `${name}: ${known.survived} -> ${stats.survived} surviving mutants`
371
+ )
372
+ stats.survivors.forEach((m) => failures.push(describeSurvivor(name, m)))
373
+ } else {
374
+ console.log(
375
+ `${name}: ${stats.score}% (${stats.survived} survived, baseline ${known.survived})`
376
+ )
377
+ }
378
+ }
379
+
380
+ if (failures.length) {
381
+ console.error('\nMutation gate failed:\n' + failures.join('\n'))
382
+ console.error(
383
+ '\nA surviving mutant is a change no test noticed. Add a spec that fails on it,\n' +
384
+ 'then run `npm run mutation:changed` again. If a mutant is genuinely\n' +
385
+ 'equivalent (the change cannot alter behaviour), say so in the PR body.'
386
+ )
387
+ process.exit(1)
388
+ }
389
+
390
+ console.log('\nMutation gate passed.')
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker-js/master/packages/core/schema/stryker-schema.json",
3
+ "packageManager": "npm",
4
+ "testRunner": "jest",
5
+ "jest": {
6
+ "projectType": "custom",
7
+ "configFile": "jest.stryker.config.js",
8
+ "enableFindRelatedTests": true
9
+ },
10
+ "mutate": ["src/**/*.ts", "!src/**/*.spec.ts"],
11
+ "reporters": ["clear-text", "json"],
12
+ "jsonReporter": { "fileName": "reports/mutation/mutation.json" },
13
+ "coverageAnalysis": "perTest",
14
+ "concurrency": 4,
15
+ "timeoutMS": 30000,
16
+ "thresholds": { "high": 90, "low": 80, "break": null },
17
+ "tempDirName": ".stryker-tmp",
18
+ "cleanTempDir": true
19
+ }
@@ -27,9 +27,24 @@ const isIgnored = (fPath, configuration) => __awaiter(void 0, void 0, void 0, fu
27
27
  const config = configuration || (yield (0, _1.getLintConfig)());
28
28
  const projectRoot = yield (0, _1.getProjectRoot)();
29
29
  const gitIgnoreFilePath = path_1.default.join(projectRoot, '.gitignore');
30
- const rootPath = projectRoot + path_1.default.sep;
31
- const relativePath = fPath.replace(rootPath, '');
32
- if (fPath === projectRoot)
30
+ // path.relative rather than stripping `projectRoot + sep` from the string: a
31
+ // separator or a normalisation the caller did not apply leaves the strip with
32
+ // nothing to remove, and the `ignore` package then receives an absolute path
33
+ // and rejects it with a RangeError. path.relative is lexical - a symlinked root
34
+ // still yields a `..`-prefixed path, which the guard below catches.
35
+ const relativePath = path_1.default.relative(projectRoot, fPath);
36
+ if (relativePath === '')
37
+ return false;
38
+ // On Windows, `path.relative` returns an absolute path when the two paths sit
39
+ // on different drives, which the two guards below cannot see; passing that on
40
+ // reaches `ignore` and throws the RangeError this function exists to avoid.
41
+ if (path_1.default.isAbsolute(relativePath))
42
+ return false;
43
+ // Nothing in a project's .gitignore can match a path outside it, and the
44
+ // `ignore` package rejects a path that escapes the root ("path should be a
45
+ // path.relative()d string"), so a caller passing one gets `false` rather than
46
+ // a RangeError.
47
+ if (relativePath === '..' || relativePath.startsWith(`..${path_1.default.sep}`))
33
48
  return false;
34
49
  let gitIgnoreFileContent = '';
35
50
  if (yield (0, file_1.fileExists)(gitIgnoreFilePath))
@@ -1 +1 @@
1
- {"version":3,"file":"isIgnored.js","sourceRoot":"","sources":["../../src/utils/isIgnored.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,4CAAwD;AACxD,gDAAuB;AACvB,oDAA2B;AAC3B,wBAAiD;AAGjD;;;;;GAKG;AACI,MAAM,SAAS,GAAG,CACvB,KAAa,EACb,aAA0B,EACR,EAAE;IACpB,MAAM,MAAM,GAAG,aAAa,IAAI,CAAC,MAAM,IAAA,gBAAa,GAAE,CAAC,CAAA;IACvD,MAAM,WAAW,GAAG,MAAM,IAAA,iBAAc,GAAE,CAAA;IAC1C,MAAM,iBAAiB,GAAG,cAAI,CAAC,IAAI,CAAC,WAAW,EAAE,YAAY,CAAC,CAAA;IAC9D,MAAM,QAAQ,GAAG,WAAW,GAAG,cAAI,CAAC,GAAG,CAAA;IACvC,MAAM,YAAY,GAAG,KAAK,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAA;IAEhD,IAAI,KAAK,KAAK,WAAW;QAAE,OAAO,KAAK,CAAA;IAEvC,IAAI,oBAAoB,GAAG,EAAE,CAAA;IAE7B,IAAI,MAAM,IAAA,iBAAU,EAAC,iBAAiB,CAAC;QACrC,oBAAoB,GAAG,MAAM,IAAA,eAAQ,EAAC,iBAAiB,CAAC,CAAA;IAE1D,OAAO,IAAA,gBAAM,GAAE;SACZ,GAAG,CAAC,oBAAoB,CAAC;SACzB,GAAG,CAAC,MAAM,CAAC,UAAU,CAAC;SACtB,OAAO,CAAC,YAAY,CAAC,CAAA;AAC1B,CAAC,CAAA,CAAA;AArBY,QAAA,SAAS,aAqBrB"}
1
+ {"version":3,"file":"isIgnored.js","sourceRoot":"","sources":["../../src/utils/isIgnored.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,4CAAwD;AACxD,gDAAuB;AACvB,oDAA2B;AAC3B,wBAAiD;AAGjD;;;;;GAKG;AACI,MAAM,SAAS,GAAG,CACvB,KAAa,EACb,aAA0B,EACR,EAAE;IACpB,MAAM,MAAM,GAAG,aAAa,IAAI,CAAC,MAAM,IAAA,gBAAa,GAAE,CAAC,CAAA;IACvD,MAAM,WAAW,GAAG,MAAM,IAAA,iBAAc,GAAE,CAAA;IAC1C,MAAM,iBAAiB,GAAG,cAAI,CAAC,IAAI,CAAC,WAAW,EAAE,YAAY,CAAC,CAAA;IAE9D,6EAA6E;IAC7E,8EAA8E;IAC9E,6EAA6E;IAC7E,gFAAgF;IAChF,oEAAoE;IACpE,MAAM,YAAY,GAAG,cAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,KAAK,CAAC,CAAA;IAEtD,IAAI,YAAY,KAAK,EAAE;QAAE,OAAO,KAAK,CAAA;IAErC,8EAA8E;IAC9E,8EAA8E;IAC9E,4EAA4E;IAC5E,IAAI,cAAI,CAAC,UAAU,CAAC,YAAY,CAAC;QAAE,OAAO,KAAK,CAAA;IAE/C,yEAAyE;IACzE,2EAA2E;IAC3E,8EAA8E;IAC9E,gBAAgB;IAChB,IAAI,YAAY,KAAK,IAAI,IAAI,YAAY,CAAC,UAAU,CAAC,KAAK,cAAI,CAAC,GAAG,EAAE,CAAC;QACnE,OAAO,KAAK,CAAA;IAEd,IAAI,oBAAoB,GAAG,EAAE,CAAA;IAE7B,IAAI,MAAM,IAAA,iBAAU,EAAC,iBAAiB,CAAC;QACrC,oBAAoB,GAAG,MAAM,IAAA,eAAQ,EAAC,iBAAiB,CAAC,CAAA;IAE1D,OAAO,IAAA,gBAAM,GAAE;SACZ,GAAG,CAAC,oBAAoB,CAAC;SACzB,GAAG,CAAC,MAAM,CAAC,UAAU,CAAC;SACtB,OAAO,CAAC,YAAY,CAAC,CAAA;AAC1B,CAAC,CAAA,CAAA;AAtCY,QAAA,SAAS,aAsCrB"}