Created
May 30, 2026 02:53
-
-
Save brandonroberts/41f07ffd7930315e82cf1518295a379e to your computer and use it in GitHub Desktop.
Reference: Angular Storybook/Vitest V8 coverage post-processor (scrubs phantom functions/branches, invalid ranges, ctor-params-as-statements from synthetic Ivy emit) — re #analogjs/analog#2349/#2296
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * REFERENCE IMPLEMENTATION — not wired into the build. | |
| * ---------------------------------------------------------------------------- | |
| * A reconstruction of the "custom mappings plugin" described in | |
| * https://github.com/analogjs/analog/issues/2349 and #2296, whose source the | |
| * reporter could not share. It is a *coverage post-processor*: it runs AFTER | |
| * `@vitest/coverage-v8` has remapped V8 ranges back to TypeScript sources, and | |
| * scrubs the synthetic entries that Angular's class-metadata emit produces when | |
| * that emit is (incorrectly) sourcemapped onto the original `.ts`. | |
| * | |
| * It exists for two reasons: | |
| * 1. As a documented reference for anyone still on a toolchain whose compiler | |
| * maps synthetic Ivy emit back onto source (e.g. published 2.5.x). | |
| * 2. As an executable specification of the four symptom classes the reporter | |
| * listed, so we can assert the OXC engine never reintroduces them. | |
| * | |
| * NOTE: with the OXC JIT/AOT engine in this package, the synthetic emit | |
| * (`.decorators`, `_jitCompile*`, `.ctorParameters`, `.propDecorators`, and in | |
| * AOT `ɵfac`/`ɵcmp`/`ɵsetClassMetadata`) is appended UNMAPPED, so V8 coverage | |
| * never attributes it to source and this post-processor finds nothing to drop. | |
| * It only does work against the older, mapped emit. | |
| * | |
| * The detector reads the TypeScript source alongside the coverage so it can | |
| * tell synthetic regions (decorators, the class header, the constructor | |
| * parameter list) from real user code — coverage data alone can't. | |
| * | |
| * Usage (standalone, rewrites coverage-final.json in place): | |
| * npx tsx angular-coverage-postprocessor.ts ./coverage/coverage-final.json | |
| * | |
| * Usage (Vitest custom coverage reporter): see the bottom of this file. | |
| */ | |
| import { parseSync } from 'oxc-parser'; | |
| import { readFileSync, writeFileSync, existsSync } from 'node:fs'; | |
| // --------------------------------------------------------------------------- | |
| // Istanbul coverage-final.json shapes (only the fields we touch). | |
| // --------------------------------------------------------------------------- | |
| interface Pos { | |
| line: number; | |
| column: number; | |
| } | |
| interface Range { | |
| start: Pos; | |
| end: Pos; | |
| } | |
| interface FnMeta { | |
| name: string; | |
| decl: Range; | |
| loc: Range; | |
| line: number; | |
| } | |
| interface BranchMeta { | |
| loc: Range; | |
| type: string; | |
| locations: Range[]; | |
| line: number; | |
| } | |
| interface FileCoverage { | |
| path: string; | |
| statementMap: Record<string, Range>; | |
| fnMap: Record<string, FnMeta>; | |
| branchMap: Record<string, BranchMeta>; | |
| s: Record<string, number>; | |
| f: Record<string, number>; | |
| b: Record<string, number[]>; | |
| [k: string]: unknown; | |
| } | |
| type CoverageMapData = Record<string, FileCoverage>; | |
| export interface ScrubStats { | |
| invalidRanges: number; // malformed / out-of-bounds locations | |
| phantomFunctions: number; // fns sitting on a decorator / class header | |
| phantomBranches: number; // branches on non-branching synthetic code | |
| ctorParamStatements: number; // statements inside the constructor param list | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Source → synthetic regions. A "synthetic region" is a byte span of the | |
| // original source onto which Angular's generated metadata code is mapped, but | |
| // which contains no real executable user statement/function/branch. | |
| // --------------------------------------------------------------------------- | |
| interface Region { | |
| start: number; | |
| end: number; | |
| kind: 'decorator' | 'classHeader' | 'ctorParams'; | |
| } | |
| function collectClasses(node: any, out: any[] = []): any[] { | |
| if (!node || typeof node !== 'object') return out; | |
| if (Array.isArray(node)) { | |
| for (const item of node) collectClasses(item, out); | |
| return out; | |
| } | |
| if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') { | |
| out.push(node); | |
| } | |
| for (const key of Object.keys(node)) { | |
| if (key === 'type' || key === 'start' || key === 'end') continue; | |
| collectClasses((node as any)[key], out); | |
| } | |
| return out; | |
| } | |
| function syntheticRegions(source: string, filename: string): Region[] { | |
| const regions: Region[] = []; | |
| let program: any; | |
| try { | |
| ({ program } = parseSync(filename, source)); | |
| } catch { | |
| return regions; // unparseable — leave coverage untouched | |
| } | |
| for (const cls of collectClasses(program.body)) { | |
| // Class-level decorators (@Component/@Directive/@Pipe/@Injectable/...). | |
| for (const dec of cls.decorators ?? []) { | |
| regions.push({ start: dec.start, end: dec.end, kind: 'decorator' }); | |
| } | |
| // The class header: `class X extends Y {` up to (not including) the body. | |
| // `ɵfac`/`ɵcmp`/factory functions get attributed here when mapped. | |
| const bodyStart = cls.body?.start ?? cls.end; | |
| regions.push({ start: cls.start, end: bodyStart, kind: 'classHeader' }); | |
| const members: any[] = cls.body?.body ?? []; | |
| for (const member of members) { | |
| // Member decorators (@Input/@Output/@ViewChild/@HostListener/...). | |
| for (const dec of member.decorators ?? []) { | |
| regions.push({ start: dec.start, end: dec.end, kind: 'decorator' }); | |
| } | |
| if ( | |
| member.type === 'MethodDefinition' && | |
| member.kind === 'constructor' | |
| ) { | |
| const params: any[] = | |
| member.value?.params?.items ?? member.value?.params ?? []; | |
| if (params.length) { | |
| // The whole parameter list — `.ctorParameters = () => [...]` is | |
| // derived from it and gets mapped back onto these spans, which is | |
| // the reporter's "constructor params as statements". | |
| const first = params[0]; | |
| const last = params[params.length - 1]; | |
| regions.push({ | |
| start: first.start, | |
| end: last.end, | |
| kind: 'ctorParams', | |
| }); | |
| } | |
| // Parameter decorators (@Optional/@Inject/@Self/...). | |
| for (const param of params) { | |
| const decs = [ | |
| ...(param.decorators ?? []), | |
| ...(param.type === 'TSParameterProperty' | |
| ? (param.parameter?.decorators ?? []) | |
| : []), | |
| ]; | |
| for (const dec of decs) { | |
| regions.push({ start: dec.start, end: dec.end, kind: 'decorator' }); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| return regions; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // loc <-> byte offset conversion. | |
| // --------------------------------------------------------------------------- | |
| function lineStarts(source: string): number[] { | |
| const starts = [0]; | |
| for (let i = 0; i < source.length; i++) { | |
| if (source[i] === '\n') starts.push(i + 1); | |
| } | |
| return starts; | |
| } | |
| function posToOffset( | |
| pos: Pos | undefined, | |
| starts: number[], | |
| len: number, | |
| ): number | null { | |
| if (!pos || typeof pos.line !== 'number' || typeof pos.column !== 'number') { | |
| return null; | |
| } | |
| const li = pos.line - 1; | |
| if (li < 0 || li >= starts.length) return null; | |
| const off = starts[li] + pos.column; | |
| return off > len ? null : off; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Predicates, one per symptom class. | |
| // --------------------------------------------------------------------------- | |
| function isInvalid( | |
| range: Range | undefined, | |
| starts: number[], | |
| len: number, | |
| ): boolean { | |
| if (!range) return true; | |
| const s = posToOffset(range.start, starts, len); | |
| const e = posToOffset(range.end, starts, len); | |
| if (s === null || e === null) return true; // out of bounds / NaN | |
| return e < s; // end before start | |
| } | |
| function inSynthetic( | |
| range: Range, | |
| regions: Region[], | |
| starts: number[], | |
| len: number, | |
| kinds?: Region['kind'][], | |
| ): boolean { | |
| const s = posToOffset(range.start, starts, len); | |
| if (s === null) return false; | |
| for (const r of regions) { | |
| if (kinds && !kinds.includes(r.kind)) continue; | |
| if (s >= r.start && s < r.end) return true; | |
| } | |
| return false; | |
| } | |
| function isDegenerate(range: Range, starts: number[], len: number): boolean { | |
| const s = posToOffset(range.start, starts, len); | |
| const e = posToOffset(range.end, starts, len); | |
| return s !== null && e !== null && e === s; // zero-width | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Core: scrub one file's coverage in place. | |
| // --------------------------------------------------------------------------- | |
| export function scrubFileCoverage( | |
| fileCov: FileCoverage, | |
| source: string, | |
| ): ScrubStats { | |
| const stats: ScrubStats = { | |
| invalidRanges: 0, | |
| phantomFunctions: 0, | |
| phantomBranches: 0, | |
| ctorParamStatements: 0, | |
| }; | |
| const starts = lineStarts(source); | |
| const len = source.length; | |
| const regions = syntheticRegions(source, fileCov.path); | |
| const dropFromMap = ( | |
| map: Record<string, unknown>, | |
| hits: Record<string, unknown>, | |
| key: string, | |
| ) => { | |
| delete map[key]; | |
| delete hits[key]; | |
| }; | |
| // 1. Statements: drop invalid ranges, and statements that live inside the | |
| // constructor parameter list ("constructor params as statements"). | |
| for (const key of Object.keys(fileCov.statementMap)) { | |
| const loc = fileCov.statementMap[key]; | |
| if (isInvalid(loc, starts, len)) { | |
| dropFromMap(fileCov.statementMap, fileCov.s, key); | |
| stats.invalidRanges++; | |
| continue; | |
| } | |
| if (inSynthetic(loc, regions, starts, len, ['ctorParams', 'decorator'])) { | |
| dropFromMap(fileCov.statementMap, fileCov.s, key); | |
| stats.ctorParamStatements++; | |
| } | |
| } | |
| // 2. Functions: drop invalid ranges and any function whose declaration sits | |
| // on a decorator or the class header (factory / metadata phantoms). | |
| for (const key of Object.keys(fileCov.fnMap)) { | |
| const fn = fileCov.fnMap[key]; | |
| const anchor = fn.decl ?? fn.loc; | |
| if (isInvalid(anchor, starts, len)) { | |
| dropFromMap(fileCov.fnMap, fileCov.f, key); | |
| stats.invalidRanges++; | |
| continue; | |
| } | |
| if (inSynthetic(anchor, regions, starts, len, ['decorator', 'classHeader'])) { | |
| dropFromMap(fileCov.fnMap, fileCov.f, key); | |
| stats.phantomFunctions++; | |
| } | |
| } | |
| // 3. Branches: drop invalid ranges, branches anchored on synthetic regions, | |
| // and "non-branching" branches whose arms are all zero-width. | |
| for (const key of Object.keys(fileCov.branchMap)) { | |
| const br = fileCov.branchMap[key]; | |
| if (isInvalid(br.loc, starts, len)) { | |
| dropFromMap(fileCov.branchMap, fileCov.b, key); | |
| stats.invalidRanges++; | |
| continue; | |
| } | |
| const onSynthetic = inSynthetic(br.loc, regions, starts, len); | |
| const arms = br.locations ?? []; | |
| const degenerate = | |
| arms.length > 0 && arms.every((l) => isDegenerate(l, starts, len)); | |
| if (onSynthetic || degenerate) { | |
| dropFromMap(fileCov.branchMap, fileCov.b, key); | |
| stats.phantomBranches++; | |
| } | |
| } | |
| return stats; | |
| } | |
| export function scrubCoverageMap( | |
| data: CoverageMapData, | |
| readSource: (path: string) => string | undefined = (p) => | |
| existsSync(p) ? readFileSync(p, 'utf8') : undefined, | |
| ): ScrubStats { | |
| const total: ScrubStats = { | |
| invalidRanges: 0, | |
| phantomFunctions: 0, | |
| phantomBranches: 0, | |
| ctorParamStatements: 0, | |
| }; | |
| for (const path of Object.keys(data)) { | |
| const source = readSource(path); | |
| if (source == null) continue; // can't verify without source — leave as-is | |
| const s = scrubFileCoverage(data[path], source); | |
| total.invalidRanges += s.invalidRanges; | |
| total.phantomFunctions += s.phantomFunctions; | |
| total.phantomBranches += s.phantomBranches; | |
| total.ctorParamStatements += s.ctorParamStatements; | |
| } | |
| return total; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // CLI: npx tsx angular-coverage-postprocessor.ts ./coverage/coverage-final.json | |
| // --------------------------------------------------------------------------- | |
| if ( | |
| typeof process !== 'undefined' && | |
| process.argv[1] && | |
| import.meta.url === `file://${process.argv[1]}` | |
| ) { | |
| const file = process.argv[2] ?? './coverage/coverage-final.json'; | |
| const data = JSON.parse(readFileSync(file, 'utf8')) as CoverageMapData; | |
| const stats = scrubCoverageMap(data); | |
| writeFileSync(file, JSON.stringify(data)); | |
| // eslint-disable-next-line no-console | |
| console.log(`[angular-coverage-postprocessor] ${file}`, stats); | |
| } | |
| /* | |
| * --- Wiring into Vitest as a custom coverage reporter --------------------- | |
| * | |
| * The V8 provider runs Istanbul reporters at the end. Add a custom reporter | |
| * that scrubs the coverage map before the lcov/html reporters serialize it: | |
| * | |
| * // vitest.config.ts | |
| * export default defineConfig({ | |
| * test: { | |
| * coverage: { | |
| * provider: 'v8', | |
| * reporter: [ | |
| * ['./reference/angular-coverage-reporter.cjs'], // scrub first | |
| * 'lcov', | |
| * 'html', | |
| * ], | |
| * }, | |
| * }, | |
| * }); | |
| * | |
| * where the reporter is a thin Istanbul ReportBase subclass: | |
| * | |
| * const { ReportBase } = require('istanbul-lib-report'); | |
| * const { scrubFileCoverage } = require('./angular-coverage-postprocessor'); | |
| * const { readFileSync } = require('node:fs'); | |
| * module.exports = class extends ReportBase { | |
| * onDetail(node) { | |
| * const fc = node.getFileCoverage().data; | |
| * try { scrubFileCoverage(fc, readFileSync(fc.path, 'utf8')); } catch {} | |
| * } | |
| * }; | |
| * | |
| * Reporters run on a shared coverage map, so scrubbing in `onDetail` of a | |
| * reporter listed *before* lcov/html cleans the data every later reporter sees. | |
| */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment