@sasjs/lint 4.3.0 → 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.
- package/.github/CONTRIBUTING.md +45 -0
- package/.github/workflows/mutation-pr.yml +69 -0
- package/.github/workflows/mutation-weekly.yml +92 -0
- package/README.md +15 -3
- package/jest.config.js +49 -0
- package/jest.stryker.config.js +17 -0
- package/mutation-baseline.json +30 -0
- package/package.json +14 -2
- package/rules/file/noUndeclaredMacros.js +9 -2
- package/rules/file/noUndeclaredMacros.js.map +1 -1
- package/rules/file/noUnusedMacros.js +15 -7
- package/rules/file/noUnusedMacros.js.map +1 -1
- package/sasjslint-schema.json +1 -1
- package/scripts/mutation-gate.mjs +390 -0
- package/stryker.conf.json +19 -0
- package/utils/headerMacros.d.ts +22 -6
- package/utils/headerMacros.js +135 -43
- package/utils/headerMacros.js.map +1 -1
- package/utils/isIgnored.js +18 -3
- package/utils/isIgnored.js.map +1 -1
- package/utils/parseMacroCalls.d.ts +24 -3
- package/utils/parseMacroCalls.js +64 -4
- package/utils/parseMacroCalls.js.map +1 -1
|
@@ -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
|
+
}
|
package/utils/headerMacros.d.ts
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
import { LintConfig } from '../types';
|
|
2
2
|
/**
|
|
3
|
-
* A macro that is declared in the
|
|
4
|
-
* its `@li` entry.
|
|
3
|
+
* A macro that is declared in the `<h4> SAS Macros </h4>` section of a header,
|
|
4
|
+
* along with the position of its `@li` entry.
|
|
5
5
|
*/
|
|
6
6
|
export interface HeaderMacro {
|
|
7
7
|
name: string;
|
|
8
8
|
lineNumber: number;
|
|
9
9
|
startColumnNumber: number;
|
|
10
10
|
endColumnNumber: number;
|
|
11
|
-
section: 'sasMacros' | 'otherMacros';
|
|
12
11
|
}
|
|
13
12
|
/**
|
|
14
|
-
* Reads the macros declared in the
|
|
15
|
-
*
|
|
13
|
+
* Reads the macros declared in the `<h4> SAS Macros </h4>` section of a file
|
|
14
|
+
* header - the compile dependencies, and the only list `sasjs compile` reads.
|
|
16
15
|
* @param {string} text - the text content of the file.
|
|
17
16
|
* @param {LintConfig} config - the lint configuration, used for line endings.
|
|
18
17
|
* @returns {HeaderMacro[]} the declared macros, in the order they appear.
|
|
@@ -33,6 +32,22 @@ export declare const getHeaderMacros: (text: string, config?: LintConfig) => Hea
|
|
|
33
32
|
* @returns {string} the updated text content.
|
|
34
33
|
*/
|
|
35
34
|
export declare const addMacrosToHeader: (text: string, config: LintConfig, macros: string[]) => string;
|
|
35
|
+
/**
|
|
36
|
+
* Writes the entries of the `<h4> SAS Macros </h4>` section back in their
|
|
37
|
+
* canonical shape, in place: `@li <file>` at the section's indentation.
|
|
38
|
+
*
|
|
39
|
+
* A mistyped tag (`@il`) and an entry indented to a different width are both
|
|
40
|
+
* read as declarations, so no diagnostic points at them and no other pass
|
|
41
|
+
* would touch them - this is the pass that repairs them. It edits only the
|
|
42
|
+
* lines whose text differs, so it never adds or removes a line, never re-spaces
|
|
43
|
+
* the section, and returns the text unchanged when there is nothing to repair
|
|
44
|
+
* or when the section cannot be rewritten safely.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} text - the text content of the file.
|
|
47
|
+
* @param {LintConfig} config - the lint configuration, used for line endings.
|
|
48
|
+
* @returns {string} the updated text content.
|
|
49
|
+
*/
|
|
50
|
+
export declare const repairHeaderList: (text: string, config: LintConfig) => string;
|
|
36
51
|
/**
|
|
37
52
|
* Removes macro names from the `<h4> SAS Macros </h4>` section of a file
|
|
38
53
|
* header. The section header itself is kept, so an empty list remains a valid
|
|
@@ -41,7 +56,8 @@ export declare const addMacrosToHeader: (text: string, config: LintConfig, macro
|
|
|
41
56
|
*
|
|
42
57
|
* The file is returned unchanged when it has no Doxygen header, when the header
|
|
43
58
|
* has no `SAS Macros` section, when the section cannot be rewritten safely, or
|
|
44
|
-
* when none of the named macros are listed.
|
|
59
|
+
* when none of the named macros are listed. Normalising the entries is the job
|
|
60
|
+
* of `repairHeaderList`.
|
|
45
61
|
*
|
|
46
62
|
* @param {string} text - the text content of the file.
|
|
47
63
|
* @param {LintConfig} config - the lint configuration, used for line endings.
|