@sasjs/lint 4.3.2 → 4.3.3

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.
@@ -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
- }