Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save brandonroberts/41f07ffd7930315e82cf1518295a379e to your computer and use it in GitHub Desktop.

Select an option

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
/**
* 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