@plugjs/cov8 0.1.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/src/report.ts ADDED
@@ -0,0 +1,376 @@
1
+ import { pathToFileURL } from 'node:url'
2
+
3
+ import { parse } from '@babel/parser'
4
+ import {
5
+ isDeclaration,
6
+ isExportDeclaration,
7
+ isFile,
8
+ isIfStatement,
9
+ isImportDeclaration,
10
+ isProgram,
11
+ isTryStatement,
12
+ isTSDeclareMethod,
13
+ isTSTypeReference,
14
+ isTypeScript,
15
+ VISITOR_KEYS,
16
+ } from '@babel/types'
17
+ import { readFile } from '@plugjs/plug/fs'
18
+ import { $p } from '@plugjs/plug/logging'
19
+
20
+ import type { Logger } from '@plugjs/plug/logging'
21
+ import type { AbsolutePath } from '@plugjs/plug/paths'
22
+ import type { Comment, Node } from '@babel/types'
23
+ import type { CoverageAnalyser } from './analysis'
24
+
25
+ /* ========================================================================== *
26
+ * EXPORTED CONSTANTS AND TYPES *
27
+ * ========================================================================== */
28
+
29
+ /**
30
+ * A constant indicating that coverage was skipped (is irrelevant, for e.g.
31
+ * comment or typescript definition nodes)
32
+ */
33
+ export const COVERAGE_SKIPPED = -2
34
+
35
+ /**
36
+ * A constant indicating that coverage was intentionally ignored because of a
37
+ * specific "coverage ignore ..." comment
38
+ */
39
+ export const COVERAGE_IGNORED = -1
40
+
41
+ /** Node coverage summary */
42
+ export interface NodeCoverageResult {
43
+ /** Number of _covered_ nodes (good!) */
44
+ coveredNodes: number,
45
+ /** Number of nodes with _no coverage_ (bad!) */
46
+ missingNodes: number,
47
+ /** Number of nodes ignored by comments like `coverage ignore xxx` */
48
+ ignoredNodes: number,
49
+ /** Total number of nodes (sum of `covered`, `missing` and `ignored`) */
50
+ totalNodes: number,
51
+ /**
52
+ * Percentage of code coverage (covered as a % of total - ignored nodes)
53
+ *
54
+ * A `null` value for this field indicates that no coverage data was generated
55
+ * either because the source was all ignored or skipped (e.g. when using
56
+ * `coverage ignore file` or when covering a TS source only with types).
57
+ */
58
+ coverage: number | null,
59
+ }
60
+
61
+ /** Per-file coverage result */
62
+ export interface CoverageResult {
63
+ /** The actual code this coverage is for */
64
+ code: string,
65
+ /**
66
+ * Per _character_ coverage report:
67
+ * - `-2`: coverage skipped (comments, typescript declarations, ...)
68
+ * - `-1`: coverage ignored (when using `coverage ignore xxx`)
69
+ * - `0`: no coverage collected for this character
70
+ * - _any number greater than zero_: number of times this was covered
71
+ */
72
+ codeCoverage: number[],
73
+ /** Node coverage summary */
74
+ nodeCoverage: NodeCoverageResult,
75
+ }
76
+
77
+ /** Aggregation of {@link CoverageResult} over all files */
78
+ export type CoverageResults = Record<AbsolutePath, CoverageResult>
79
+
80
+ /** Our coverage report, per file */
81
+ export interface CoverageReport {
82
+ results: CoverageResults,
83
+ nodes: NodeCoverageResult,
84
+ }
85
+
86
+ /* ========================================================================== *
87
+ * EXPORTED CONSTANTS AND TYPES *
88
+ * ========================================================================== */
89
+
90
+ /** Tokens for `coverage ignore xxx`, this should be self-explanatory */
91
+ type IgnoreCoverage = 'test' | 'if' | 'else' | 'try' | 'catch' | 'finally' | 'next' | 'file'
92
+
93
+ /** Regular expression matching strings like `coverage ignore xxx` */
94
+ const ignoreRegexp = /^\s+(coverage|istanbul)\s+ignore\s+(test|if|else|try|catch|finally|next|file)(\s|$)/g
95
+
96
+ /* ========================================================================== *
97
+ * EXPORTED CONSTANTS AND TYPES *
98
+ * ========================================================================== */
99
+
100
+ /**
101
+ * Analyse coverage for the specified source files, using the data from the
102
+ * specified coverage files and produce a {@link CoverageReport}.
103
+ */
104
+ export async function coverageReport(
105
+ analyser: CoverageAnalyser,
106
+ sourceFiles: AbsolutePath[],
107
+ log: Logger,
108
+ ): Promise<CoverageReport> {
109
+ /* Some of our results */
110
+ const results: CoverageResults = {}
111
+ const nodes: NodeCoverageResult = {
112
+ coveredNodes: 0,
113
+ missingNodes: 0,
114
+ ignoredNodes: 0,
115
+ totalNodes: 0,
116
+ coverage: 0,
117
+ }
118
+
119
+ /*
120
+ * Here comes the interesting part: we need to parse the original soruces,
121
+ * walk their ASTs and see whether each node has been covered or not.
122
+ * We look up every node's position, (for sitemaps, map this to the position
123
+ * in the resulting file) then look at the coverage.
124
+ */
125
+ for (const file of sourceFiles) {
126
+ /* Read up the file and parse the tree in the most liberal way possible */
127
+ const url = pathToFileURL(file).toString()
128
+ const code = await readFile(file, 'utf-8')
129
+
130
+ const tree = parse(code, {
131
+ allowImportExportEverywhere: true,
132
+ allowAwaitOutsideFunction: true,
133
+ allowReturnOutsideFunction: true,
134
+ allowSuperOutsideMethod: true,
135
+ allowUndeclaredExports: true,
136
+ attachComment: true,
137
+ errorRecovery: false,
138
+ sourceType: 'unambiguous',
139
+ sourceFilename: file,
140
+ startLine: 1,
141
+ startColumn: 0,
142
+ plugins: [ 'typescript' ],
143
+ strictMode: false,
144
+ ranges: false,
145
+ tokens: false,
146
+ createParenthesizedExpressions: true,
147
+ })
148
+
149
+ const codeCoverage: number[] = new Array(code.length).fill(0)
150
+ const nodeCoverage: NodeCoverageResult = {
151
+ coveredNodes: 0,
152
+ missingNodes: 0,
153
+ ignoredNodes: 0,
154
+ totalNodes: 0,
155
+ coverage: 0,
156
+ }
157
+
158
+ /* Set the code coverage for the specified node and (optionally) its children */
159
+ const setCodeCoverage = (
160
+ node: (Node | Comment)[] | Node | Comment | undefined | null,
161
+ coverage: number,
162
+ recursive: boolean,
163
+ ): void => {
164
+ if (! node) return
165
+
166
+ if (Array.isArray(node)) {
167
+ for (const n of node) setCodeCoverage(n, coverage, recursive)
168
+ return
169
+ }
170
+
171
+ if ((node.start != null) && (node.end != null)) {
172
+ for (let i = node.start; i < node.end; i ++) {
173
+ codeCoverage[i] = coverage
174
+ }
175
+ }
176
+
177
+ if (coverage == COVERAGE_IGNORED) {
178
+ nodeCoverage.ignoredNodes++
179
+ } else if (coverage === 0) {
180
+ nodeCoverage.missingNodes++
181
+ } else if (coverage > 0) {
182
+ nodeCoverage.coveredNodes++
183
+ }
184
+
185
+ if (! recursive) return
186
+
187
+ const keys = VISITOR_KEYS[node.type] || /* coverage ignore next */ []
188
+ for (const key of keys) {
189
+ const value: Node | Node[] = (<any> node)[key]
190
+ if (Array.isArray(value)) {
191
+ for (const child of value) {
192
+ setCodeCoverage(child, coverage, true)
193
+ }
194
+ } else if (value) {
195
+ setCodeCoverage(value, coverage, true)
196
+ }
197
+ }
198
+ }
199
+
200
+ /* Recursively invoke "visitNode" on the children of a given node */
201
+ const visitChildren = (node: Node, depth: number): void => {
202
+ const keys = VISITOR_KEYS[node.type] || /* coverage ignore next */ []
203
+ for (const key of keys) {
204
+ const children: Node | null | (Node | null)[] = (<any> node)[key]
205
+ if (Array.isArray(children)) {
206
+ for (const child of children) {
207
+ if (child) visitNode(child, depth + 1)
208
+ }
209
+ } else if (children) {
210
+ visitNode(children, depth + 1)
211
+ }
212
+ }
213
+ }
214
+
215
+ /* Visit a node or ignore it depending on a condition */
216
+ const maybeIgnoreNode = (
217
+ condition: boolean,
218
+ node: Node | null | undefined,
219
+ depth: number,
220
+ ): void => {
221
+ if (condition) {
222
+ setCodeCoverage(node, COVERAGE_IGNORED, true)
223
+ } else if (node) {
224
+ visitNode(node, depth)
225
+ }
226
+ }
227
+
228
+ /* Visit a node and evaluate its coverage */
229
+ const visitNode = (node: Node, depth: number): void => {
230
+ /* See what we're doing here... */
231
+ log.trace('-'.padStart((depth * 2) + 1, ' '), node.type, `${node.loc?.start.line}:${node.loc?.start.column}`)
232
+
233
+ /* Root nodes (file and program) simply go to their children */
234
+ // coverage ignore if / we start visiting at file.program.body!
235
+ if (isFile(node)) return visitChildren(node, depth)
236
+ // coverage ignore if / we start visiting at file.program.body!
237
+ if (isProgram(node)) return visitChildren(node, depth)
238
+
239
+ /* Figure out if we have some "coverage ignore xxxx" in comments */
240
+ const ignores: IgnoreCoverage[] = []
241
+ for (const comment of node.leadingComments || []) {
242
+ for (const match of comment.value.matchAll(ignoreRegexp)) {
243
+ ignores.push(match[2] as IgnoreCoverage)
244
+ }
245
+ }
246
+
247
+ /* Skip this node if we have a "coverage ignore next" comment */
248
+ if (ignores.includes('next')) return setCodeCoverage(node, COVERAGE_IGNORED, true)
249
+
250
+ /* Typescript nodes are skipped, but children aren't in some cases */
251
+ if (isTypeScript(node)) {
252
+ /* Functions/constructors overloads */
253
+ if (isTSDeclareMethod(node)) return setCodeCoverage(node, COVERAGE_SKIPPED, true)
254
+
255
+ /* References to imported types */
256
+ if (isTSTypeReference(node)) return setCodeCoverage(node, COVERAGE_SKIPPED, true)
257
+
258
+ /* Generic typescript declarations */
259
+ if (isDeclaration(node)) return setCodeCoverage(node, COVERAGE_SKIPPED, true)
260
+
261
+ /* For things like "X as Y": the "as" node wraps the expression */
262
+ setCodeCoverage(node, COVERAGE_SKIPPED, false) // not recursive
263
+ return visitChildren(node, depth) // visit all children normally...
264
+ }
265
+
266
+ // Typescript "import type" or "export type" get skipped all together
267
+ if (isExportDeclaration(node) && (node.exportKind === 'type')) {
268
+ return setCodeCoverage(node, COVERAGE_SKIPPED, true)
269
+ }
270
+
271
+ if (isImportDeclaration(node) && (node.importKind === 'type')) {
272
+ return setCodeCoverage(node, COVERAGE_SKIPPED, true)
273
+ }
274
+
275
+ /* Ok, from here we calculate the coverage */
276
+ let coverage = 0
277
+ if (node.loc) {
278
+ const { line, column } = node.loc.start
279
+ const c = analyser.coverage(url, line, column)
280
+ // coverage ignore if / broken V8 coverage???
281
+ if (c == null) {
282
+ log.warn(`No coverage for ${node.type} at ${$p(file)}:${line}:${column}`)
283
+ } else {
284
+ coverage = c
285
+ }
286
+ }
287
+
288
+ /* Record the code coverage, and set up our variables */
289
+ setCodeCoverage(node, coverage, false)
290
+
291
+ /* The "if" node might have some ignores... */
292
+ if (isIfStatement(node)) {
293
+ maybeIgnoreNode(ignores.includes('test'), node.test, depth + 1)
294
+ maybeIgnoreNode(ignores.includes('if'), node.consequent, depth + 1)
295
+ maybeIgnoreNode(ignores.includes('else'), node.alternate, depth + 1)
296
+ return
297
+ }
298
+
299
+ /* The "try" node might have some ignores... */
300
+ if (isTryStatement(node)) {
301
+ maybeIgnoreNode(ignores.includes('try'), node.block, depth + 1)
302
+ maybeIgnoreNode(ignores.includes('catch'), node.handler, depth + 1)
303
+ maybeIgnoreNode(ignores.includes('finally'), node.finalizer, depth + 1)
304
+ return
305
+ }
306
+
307
+ /* All other nodes simply gets visited recursively*/
308
+ visitChildren(node, depth)
309
+ }
310
+
311
+ /* Start by setting the scope of our coverage in the code */
312
+ codeCoverage.fill(COVERAGE_SKIPPED) // by default, everything is skipped
313
+ setCodeCoverage(tree.program.directives, 0, true) // directives must be covered
314
+ setCodeCoverage(tree.program.body, 0, true) // program body must be covered
315
+
316
+ /* Cleanup our node statistics */
317
+ nodeCoverage.coveredNodes = 0
318
+ nodeCoverage.missingNodes = 0
319
+ nodeCoverage.ignoredNodes = 0
320
+
321
+ /* Check if one of the first comments is "coverage ignore file" */
322
+ let ignoreFileCoverage = false
323
+ for (const comment of tree.program.body[0]?.leadingComments || []) {
324
+ for (const match of comment.value.matchAll(ignoreRegexp)) {
325
+ if (match[2] === 'file') {
326
+ ignoreFileCoverage = true
327
+ break
328
+ }
329
+ }
330
+ /* Already matched "coverage ignore file", skip the rest */
331
+ if (ignoreFileCoverage) break
332
+ }
333
+
334
+ /* If we found a "coverage ignore file" at the beginning, ignore the file */
335
+ if (ignoreFileCoverage) {
336
+ setCodeCoverage(tree.program, COVERAGE_IGNORED, true)
337
+ } else {
338
+ visitChildren(tree.program, -1)
339
+ }
340
+
341
+ /*
342
+ * As comments are mixed within codes (and do not get visited in the
343
+ * tree) we force-skip them _AFTER_ the tree is visited, otherwise (for
344
+ * example) a comment within a block will be shown with the coverage of
345
+ * the block itself.
346
+ */
347
+ setCodeCoverage(tree.comments, COVERAGE_SKIPPED, false)
348
+
349
+ /* Update nodes coverage results */
350
+ updateNodeCoverageResult(nodeCoverage)
351
+
352
+ nodes.coveredNodes += nodeCoverage.coveredNodes
353
+ nodes.missingNodes += nodeCoverage.missingNodes
354
+ nodes.ignoredNodes += nodeCoverage.ignoredNodes
355
+ nodes.totalNodes += nodeCoverage.totalNodes
356
+
357
+ /* This file is done, add it to the report */
358
+ results[file] = { code, codeCoverage, nodeCoverage }
359
+ }
360
+
361
+ /* All done, return the report */
362
+ updateNodeCoverageResult(nodes)
363
+ return { results, nodes }
364
+ }
365
+
366
+ function updateNodeCoverageResult(result: NodeCoverageResult): void {
367
+ const { coveredNodes, missingNodes, ignoredNodes } = result
368
+ const totalNodes = result.totalNodes = coveredNodes + missingNodes + ignoredNodes
369
+ if (totalNodes === 0) {
370
+ result.coverage = null // No "total" nodes, means all ignored
371
+ } else if (totalNodes === ignoredNodes) {
372
+ result.coverage = null // All nodes were ignored (e.g. coverage ignore file)
373
+ } else {
374
+ result.coverage = Math.floor((100 * coveredNodes) / (totalNodes - ignoredNodes))
375
+ }
376
+ }