@sasjs/lint 4.3.2 → 4.4.0
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.
- package/README.md +15 -1
- package/package.json +22 -3
- package/rules/file/hasDoxygenHeader.d.ts +0 -3
- package/rules/file/hasDoxygenHeader.js +88 -37
- package/rules/file/hasDoxygenHeader.js.map +1 -1
- package/rules/file/index.d.ts +1 -0
- package/rules/file/index.js +3 -1
- package/rules/file/index.js.map +1 -1
- package/rules/file/noNestedCommentsInHeader.d.ts +6 -0
- package/rules/file/noNestedCommentsInHeader.js +105 -0
- package/rules/file/noNestedCommentsInHeader.js.map +1 -0
- package/sasjslint-schema.json +17 -0
- package/types/LintConfig.js +3 -0
- package/types/LintConfig.js.map +1 -1
- package/utils/getLintConfig.d.ts +1 -0
- package/utils/getLintConfig.js +1 -0
- package/utils/getLintConfig.js.map +1 -1
- package/utils/getPosition.d.ts +14 -0
- package/utils/getPosition.js +25 -0
- package/utils/getPosition.js.map +1 -0
- package/utils/index.d.ts +1 -0
- package/utils/index.js +1 -0
- package/utils/index.js.map +1 -1
- package/.all-contributorsrc +0 -123
- package/.git-hooks/commit-msg +0 -18
- package/.git-hooks/pre-commit +0 -18
- package/.github/CONTRIBUTING.md +0 -201
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -21
- package/.github/dependabot.yml +0 -7
- package/.github/workflows/audit-watch.yml +0 -68
- package/.github/workflows/build.yml +0 -66
- package/.github/workflows/mutation-pr.yml +0 -69
- package/.github/workflows/mutation-weekly.yml +0 -92
- package/.github/workflows/publish.yml +0 -169
- package/.gitpod.yml +0 -8
- package/.pre-commit-config.yaml +0 -16
- package/.prettierrc +0 -6
- package/.sasjslint +0 -13
- package/jest.config.js +0 -62
- package/jest.stryker.config.js +0 -17
- package/mutation-baseline.json +0 -30
- package/scripts/audit-watch.mjs +0 -249
- package/scripts/generate-macro-names.mjs +0 -101
- package/scripts/mutation-gate.mjs +0 -390
- package/stryker.conf.json +0 -19
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Generates `src/utils/sasMacros.generated.ts` from the SAS language data.
|
|
3
|
-
*
|
|
4
|
-
* The list of macros that ship with SAS is not lint's to maintain: it is
|
|
5
|
-
* language data, and `@sasjs/sas-language` owns it. This script reads the
|
|
6
|
-
* `macroStatements` and `macroFunctions` groups and emits the names the
|
|
7
|
-
* `noUndeclaredMacros` rule treats as declared.
|
|
8
|
-
*
|
|
9
|
-
* The output is committed so that the published package carries no dependency
|
|
10
|
-
* on the data package, and `src/utils/sasMacros.spec.ts` regenerates the list
|
|
11
|
-
* from the same data and fails when the two differ, so a refresh upstream
|
|
12
|
-
* cannot leave the constant stale.
|
|
13
|
-
*
|
|
14
|
-
* Run with `npm run generate:macros`.
|
|
15
|
-
*/
|
|
16
|
-
import { readFile, writeFile } from 'node:fs/promises'
|
|
17
|
-
import path from 'node:path'
|
|
18
|
-
import { fileURLToPath } from 'node:url'
|
|
19
|
-
|
|
20
|
-
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
21
|
-
const PACKAGE = '@sasjs/sas-language'
|
|
22
|
-
const GROUPS = ['macroStatements', 'macroFunctions']
|
|
23
|
-
const TARGET = path.join(ROOT, 'src', 'utils', 'sasMacros.generated.ts')
|
|
24
|
-
|
|
25
|
-
const indexPath = (group) =>
|
|
26
|
-
path.join(
|
|
27
|
-
ROOT,
|
|
28
|
-
'node_modules',
|
|
29
|
-
...PACKAGE.split('/'),
|
|
30
|
-
'data',
|
|
31
|
-
`${group}.index.json`
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Every macro name a group carries, as a bare lower-case name. Compound
|
|
36
|
-
* entries such as `%DO %UNTIL` are dropped: they are a spelling of the
|
|
37
|
-
* `%DO` statement and no source token can match them.
|
|
38
|
-
*/
|
|
39
|
-
const collectMacroNames = async () => {
|
|
40
|
-
const names = new Set()
|
|
41
|
-
|
|
42
|
-
for (const group of GROUPS) {
|
|
43
|
-
let parsed
|
|
44
|
-
try {
|
|
45
|
-
parsed = JSON.parse(await readFile(indexPath(group), 'utf8'))
|
|
46
|
-
} catch (error) {
|
|
47
|
-
throw new Error(
|
|
48
|
-
`cannot read ${PACKAGE}/data/${group}.index.json - run 'npm install' first (${error.message})`
|
|
49
|
-
)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (!Array.isArray(parsed?.entries)) {
|
|
53
|
-
throw new Error(
|
|
54
|
-
`${PACKAGE}/data/${group}.index.json has an unexpected shape: expected { entries: [] }`
|
|
55
|
-
)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
for (const entry of parsed.entries) {
|
|
59
|
-
const name = String(entry?.[0] ?? '')
|
|
60
|
-
.replace(/^%/, '')
|
|
61
|
-
.trim()
|
|
62
|
-
.toLowerCase()
|
|
63
|
-
if (!name || /\s/.test(name)) continue
|
|
64
|
-
names.add(name)
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return [...names].sort()
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* The generated file's text.
|
|
73
|
-
*/
|
|
74
|
-
const render = (names) =>
|
|
75
|
-
[
|
|
76
|
-
'// Generated by scripts/generate-macro-names.mjs from @sasjs/sas-language.',
|
|
77
|
-
'// Do not edit. Regenerate with `npm run generate:macros`.',
|
|
78
|
-
'',
|
|
79
|
-
'/**',
|
|
80
|
-
' * The macro names that ship with SAS, lower-cased and without the leading',
|
|
81
|
-
' * `%`, as published in the macroStatements and macroFunctions groups of',
|
|
82
|
-
' * `@sasjs/sas-language`.',
|
|
83
|
-
' */',
|
|
84
|
-
'export const sasMacros: readonly string[] = [',
|
|
85
|
-
...names.map((name) => ` '${name}',`),
|
|
86
|
-
']',
|
|
87
|
-
''
|
|
88
|
-
].join('\n')
|
|
89
|
-
|
|
90
|
-
const main = async () => {
|
|
91
|
-
const names = await collectMacroNames()
|
|
92
|
-
await writeFile(TARGET, render(names))
|
|
93
|
-
console.log(
|
|
94
|
-
`written: src/utils/sasMacros.generated.ts (${names.length} names)`
|
|
95
|
-
)
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
main().catch((error) => {
|
|
99
|
-
console.error(`generation failed: ${error.message}`)
|
|
100
|
-
process.exit(1)
|
|
101
|
-
})
|
|
@@ -1,390 +0,0 @@
|
|
|
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.')
|
package/stryker.conf.json
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
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
|
-
}
|