diff --git a/tools/coverage/coverage-integrity.mjs b/tools/coverage/coverage-integrity.mjs new file mode 100644 index 000000000..ca74632c0 --- /dev/null +++ b/tools/coverage/coverage-integrity.mjs @@ -0,0 +1,486 @@ +import { createRequire } from 'node:module'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const require = createRequire(import.meta.url); +const { createCoverageMap } = require('istanbul-lib-coverage'); + +export const COVERAGE_COLLECTION_FAILURE = 'Failed to collect coverage'; +const ANSI_ESCAPE = /\u001b\[[0-?]*[ -/]*[@-~]/g; +const COVERAGE_METRICS = [ + 'statements', + 'branches', + 'functions', + 'lines', +]; + +function toPosix(filePath) { + return filePath.split(path.sep).join('/'); +} + +function hasDeclareModifier(statement) { + const modifiers = ts.canHaveModifiers(statement) + ? (ts.getModifiers(statement) ?? []) + : []; + return modifiers.some( + (modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword + ); +} + +export function hasRuntimeOwnedStatement(sourceText, fileName = 'source.ts') { + const source = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + return source.statements.some((statement) => { + if (hasDeclareModifier(statement)) { + return false; + } + + return !( + ts.isImportDeclaration(statement) || + ts.isImportEqualsDeclaration(statement) || + ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement) || + ts.isExportDeclaration(statement) || + ts.isEmptyStatement(statement) + ); + }); +} + +export function createCoverageOutputScanner(maxCharacters = 4096) { + let tail = ''; + let collectionFailed = false; + let ansiState = 'text'; + + function stripAnsi(chunk) { + const input = String(chunk).replace(ANSI_ESCAPE, ''); + let output = ''; + + for (let index = 0; index < input.length; index += 1) { + const character = input[index]; + const code = character.charCodeAt(0); + + if (ansiState === 'text') { + if (code === 0x1b) { + ansiState = 'escape'; + } else { + output += character; + } + continue; + } + + if (ansiState === 'escape') { + if (character === '[') { + ansiState = 'parameters'; + } else { + output += '\u001b'; + ansiState = 'text'; + index -= 1; + } + continue; + } + + if (ansiState === 'parameters') { + if (code >= 0x30 && code <= 0x3f) { + continue; + } + if (code >= 0x20 && code <= 0x2f) { + ansiState = 'intermediates'; + continue; + } + if (code >= 0x40 && code <= 0x7e) { + ansiState = 'text'; + continue; + } + + ansiState = 'text'; + index -= 1; + continue; + } + + if (code >= 0x20 && code <= 0x2f) { + continue; + } + if (code >= 0x40 && code <= 0x7e) { + ansiState = 'text'; + continue; + } + + ansiState = 'text'; + index -= 1; + } + + return output; + } + + return { + get collectionFailed() { + return collectionFailed; + }, + get tail() { + return tail; + }, + push(chunk) { + const output = `${tail}${stripAnsi(chunk)}`; + if (output.includes(COVERAGE_COLLECTION_FAILURE)) { + collectionFailed = true; + } + tail = output.slice(-maxCharacters); + }, + }; +} + +function isExcludedSource(filePath) { + const file = toPosix(filePath); + return ( + /\.(spec|test)\.ts$/.test(file) || + file.endsWith('.d.ts') || + file.endsWith('/test-setup.ts') || + file.includes('/test-stubs/') || + /\.generated\./.test(file) || + file.includes('/environments/') || + file.endsWith('/index.ts') + ); +} + +function runtimeOwningSourceFiles(workspaceRoot, sourceRoot) { + const absoluteSourceRoot = path.resolve(workspaceRoot, sourceRoot); + const runtimeFiles = []; + const directories = [absoluteSourceRoot]; + + while (directories.length > 0) { + const directory = directories.pop(); + for (const name of readdirSync(directory)) { + const filePath = path.join(directory, name); + const stats = statSync(filePath); + + if (stats.isDirectory()) { + directories.push(filePath); + continue; + } + if ( + !stats.isFile() || + !filePath.endsWith('.ts') || + isExcludedSource(filePath) + ) { + continue; + } + if ( + hasRuntimeOwnedStatement( + readFileSync(filePath, 'utf8'), + filePath + ) + ) { + runtimeFiles.push(path.resolve(filePath)); + } + } + } + + return runtimeFiles.sort(); +} + +function isObjectRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function indexCoverageFilesByAbsolutePath(coverageMap, workspaceRoot) { + const filesByAbsolutePath = new Map(); + const duplicateAbsolutePaths = new Set(); + + for (const reportedPath of coverageMap.files().sort()) { + const absolutePath = path.resolve(workspaceRoot, reportedPath); + if (filesByAbsolutePath.has(absolutePath)) { + duplicateAbsolutePaths.add(absolutePath); + continue; + } + filesByAbsolutePath.set(absolutePath, reportedPath); + } + + return { + duplicateAbsolutePaths: [...duplicateAbsolutePaths].sort(), + filesByAbsolutePath, + }; +} + +export function validateProjectCoverage({ workspaceRoot, project }) { + const reportPath = path.resolve( + workspaceRoot, + 'coverage', + project.root, + 'coverage-final.json' + ); + const relativeReportPath = toPosix( + path.relative(workspaceRoot, reportPath) + ); + + if (!existsSync(reportPath)) { + return { + errors: [ + `${project.name} did not produce ${relativeReportPath}.`, + ], + report: undefined, + }; + } + + let data; + try { + data = JSON.parse(readFileSync(reportPath, 'utf8')); + } catch (error) { + return { + errors: [ + `${project.name} coverage report ${relativeReportPath} contains invalid JSON: ${error.message}`, + ], + report: undefined, + }; + } + + if ( + !isObjectRecord(data) || + Object.values(data).some((entry) => !isObjectRecord(entry)) + ) { + return { + errors: [ + `${project.name} coverage report ${relativeReportPath} must contain a JSON object mapping source paths to JSON objects.`, + ], + report: undefined, + }; + } + + let coverageMap; + try { + coverageMap = createCoverageMap(data); + for (const filePath of coverageMap.files()) { + coverageMap.fileCoverageFor(filePath).toSummary(); + } + } catch (error) { + return { + errors: [ + `${project.name} coverage report ${relativeReportPath} is not valid Istanbul coverage: ${error.message}`, + ], + report: undefined, + }; + } + + const { + duplicateAbsolutePaths, + filesByAbsolutePath: reportedFiles, + } = indexCoverageFilesByAbsolutePath(coverageMap, workspaceRoot); + if (duplicateAbsolutePaths.length > 0) { + return { + errors: duplicateAbsolutePaths.map( + (filePath) => + `${project.name} coverage report ${relativeReportPath} contains a duplicate entry for ${toPosix( + path.relative(workspaceRoot, filePath) + )} after path normalization.` + ), + report: undefined, + }; + } + + const errors = []; + for (const filePath of runtimeOwningSourceFiles( + workspaceRoot, + project.sourceRoot + )) { + const reportedPath = reportedFiles.get(filePath); + const relativeSourcePath = toPosix( + path.relative(workspaceRoot, filePath) + ); + if (reportedPath === undefined) { + errors.push( + `${project.name} coverage report is missing runtime-owning source ${relativeSourcePath}.` + ); + continue; + } + + const summary = coverageMap + .fileCoverageFor(reportedPath) + .toSummary() + .toJSON(); + const hasUsableInstrumentation = [ + summary.statements, + summary.functions, + summary.branches, + ].some((metric) => metric.total > 0); + if (!hasUsableInstrumentation) { + errors.push( + `${project.name} coverage report ${relativeReportPath} has no usable instrumentation for runtime-owning source ${relativeSourcePath}.` + ); + } + } + + return { + errors, + report: { + data, + path: reportPath, + project, + }, + }; +} + +export function validateRequiredProjectReports({ projects, workspaceRoot }) { + const errors = []; + const reports = []; + + for (const project of projects) { + const result = validateProjectCoverage({ project, workspaceRoot }); + errors.push(...result.errors); + if (result.report) { + reports.push(result.report); + } + } + + return { errors, reports }; +} + +function formatConfigurationValue(value) { + return typeof value === 'string' ? JSON.stringify(value) : String(value); +} + +function validateCoverageRatchetConfiguration(ratchet) { + const errors = []; + + for (const metric of COVERAGE_METRICS) { + const value = ratchet?.merged?.[metric]; + if ( + typeof value !== 'number' || + !Number.isFinite(value) || + value < 0 || + value > 100 + ) { + errors.push( + `Invalid coverage ratchet merged.${metric}: expected a finite number between 0 and 100, received ${formatConfigurationValue(value)}.` + ); + } + } + + const criticalFiles = ratchet?.criticalFiles; + if (!Array.isArray(criticalFiles)) { + errors.push( + `Invalid coverage ratchet criticalFiles: expected an array, received ${formatConfigurationValue(criticalFiles)}.` + ); + return errors; + } + + for (const [index, criticalFile] of criticalFiles.entries()) { + const criticalPath = criticalFile?.path; + const hasValidPath = + typeof criticalPath === 'string' && + criticalPath.trim().length > 0; + if (!hasValidPath) { + errors.push( + `Invalid coverage ratchet criticalFiles[${index}].path: expected a non-empty string, received ${formatConfigurationValue(criticalPath)}.` + ); + } + const filePath = hasValidPath + ? toPosix(criticalPath) + : `criticalFiles[${index}]`; + const minimumCovered = + criticalFile?.statements?.minimumCovered; + if ( + typeof minimumCovered !== 'number' || + !Number.isFinite(minimumCovered) || + !Number.isInteger(minimumCovered) || + minimumCovered < 0 + ) { + errors.push( + `Invalid coverage ratchet ${filePath} statements.minimumCovered: expected a non-negative integer, received ${formatConfigurationValue(minimumCovered)}.` + ); + } + + const minimumPercent = + criticalFile?.statements?.minimumPercent; + if ( + typeof minimumPercent !== 'number' || + !Number.isFinite(minimumPercent) || + minimumPercent < 0 || + minimumPercent > 100 + ) { + errors.push( + `Invalid coverage ratchet ${filePath} statements.minimumPercent: expected a finite number between 0 and 100, received ${formatConfigurationValue(minimumPercent)}.` + ); + } + } + + return errors; +} + +export function evaluateCoverageRatchets({ + coverageData, + mergedSummary, + ratchet, + workspaceRoot, +}) { + const configurationErrors = + validateCoverageRatchetConfiguration(ratchet); + if (configurationErrors.length > 0) { + return configurationErrors; + } + + const errors = []; + + for (const metric of COVERAGE_METRICS) { + const required = ratchet.merged[metric]; + const observed = mergedSummary[metric]?.pct; + if (!Number.isFinite(observed) || observed < required) { + errors.push( + `Merged ${metric} coverage regressed: observed ${observed}%, required at least ${required}%.` + ); + } + } + + const coverageMap = createCoverageMap(coverageData); + const { + duplicateAbsolutePaths, + filesByAbsolutePath: reportedFiles, + } = indexCoverageFilesByAbsolutePath(coverageMap, workspaceRoot); + if (duplicateAbsolutePaths.length > 0) { + return [ + ...errors, + ...duplicateAbsolutePaths.map( + (filePath) => + `Merged coverage contains a duplicate entry for ${toPosix( + path.relative(workspaceRoot, filePath) + )} after path normalization.` + ), + ]; + } + + for (const criticalFile of ratchet.criticalFiles) { + const filePath = path.resolve(workspaceRoot, criticalFile.path); + const relativePath = toPosix(path.relative(workspaceRoot, filePath)); + const reportedPath = reportedFiles.get(filePath); + if (reportedPath === undefined) { + errors.push( + `Critical coverage ${relativePath} is missing from merged coverage.` + ); + continue; + } + + const statements = coverageMap + .fileCoverageFor(reportedPath) + .toSummary() + .toJSON().statements; + const minimumCovered = criticalFile.statements.minimumCovered; + if (statements.covered < minimumCovered) { + errors.push( + `Critical coverage ${relativePath} statements covered regressed: observed ${statements.covered}, required at least ${minimumCovered}.` + ); + } + + const minimumPercent = criticalFile.statements.minimumPercent; + if (statements.pct < minimumPercent) { + errors.push( + `Critical coverage ${relativePath} statements percent regressed: observed ${statements.pct}%, required at least ${minimumPercent}%.` + ); + } + } + + return errors; +} diff --git a/tools/coverage/coverage-integrity.test.mjs b/tools/coverage/coverage-integrity.test.mjs new file mode 100644 index 000000000..e1f553d04 --- /dev/null +++ b/tools/coverage/coverage-integrity.test.mjs @@ -0,0 +1,739 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import { + createCoverageOutputScanner, + evaluateCoverageRatchets, + hasRuntimeOwnedStatement, + validateProjectCoverage, + validateRequiredProjectReports, +} from './coverage-integrity.mjs'; + +const temporaryRoots = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function makeProjectFixture({ + name = 'example', + projectRoot = `libs/${name}`, + workspaceRoot = undefined, +} = {}) { + const ownsWorkspace = workspaceRoot === undefined; + const fixtureRoot = + workspaceRoot ?? + mkdtempSync(path.join(tmpdir(), 'iptvnator-coverage-integrity-')); + if (ownsWorkspace) { + temporaryRoots.push(fixtureRoot); + } + + const sourceRoot = `${projectRoot}/src`; + const sourcePath = path.join(fixtureRoot, sourceRoot, 'runtime.ts'); + const reportPath = path.join( + fixtureRoot, + 'coverage', + projectRoot, + 'coverage-final.json' + ); + + mkdirSync(path.dirname(sourcePath), { recursive: true }); + mkdirSync(path.dirname(reportPath), { recursive: true }); + writeFileSync(sourcePath, 'export const runtime = true;\n'); + + const project = { + name, + root: projectRoot, + sourceRoot, + }; + + return { + project, + reportPath, + sourcePath, + workspaceRoot: fixtureRoot, + }; +} + +function coverageEntry(filePath, hits = [0]) { + const statementMap = Object.fromEntries( + hits.map((_, index) => [ + index, + { + start: { line: index + 1, column: 0 }, + end: { line: index + 1, column: 28 }, + }, + ]) + ); + const statementHits = Object.fromEntries( + hits.map((hit, index) => [index, hit]) + ); + + return { + path: filePath, + statementMap, + fnMap: {}, + branchMap: {}, + s: statementHits, + f: {}, + b: {}, + }; +} + +function functionOnlyCoverageEntry(filePath) { + return { + path: filePath, + statementMap: {}, + fnMap: { + 0: { + name: 'runtime', + decl: { + start: { line: 1, column: 7 }, + end: { line: 1, column: 15 }, + }, + loc: { + start: { line: 1, column: 7 }, + end: { line: 1, column: 28 }, + }, + line: 1, + }, + }, + branchMap: {}, + s: {}, + f: { 0: 1 }, + b: {}, + }; +} + +function writeCompleteReport(fixture, reportKey = fixture.sourcePath) { + writeFileSync( + fixture.reportPath, + JSON.stringify({ + [reportKey]: coverageEntry(fixture.sourcePath), + }) + ); +} + +function fullyCoveredSummary() { + return { + statements: { covered: 1, total: 1, pct: 100 }, + branches: { covered: 0, total: 0, pct: 100 }, + functions: { covered: 0, total: 0, pct: 100 }, + lines: { covered: 1, total: 1, pct: 100 }, + }; +} + +function ratchetWithCriticalFiles(criticalFiles) { + return { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles, + }; +} + +describe('hasRuntimeOwnedStatement', () => { + it('excludes type-only, import-only, and pure re-export source', () => { + assert.equal( + hasRuntimeOwnedStatement( + 'import type { Settings } from "./settings";' + ), + false + ); + assert.equal( + hasRuntimeOwnedStatement( + 'import settings = require("./settings");' + ), + false + ); + assert.equal( + hasRuntimeOwnedStatement('interface Settings { enabled: boolean }'), + false + ); + assert.equal( + hasRuntimeOwnedStatement('export type Mode = "live" | "vod";'), + false + ); + assert.equal( + hasRuntimeOwnedStatement('export { value } from "./value";'), + false + ); + assert.equal( + hasRuntimeOwnedStatement('declare const injected: string;'), + false + ); + }); + + it('includes emitted declarations and executable statements', () => { + assert.equal( + hasRuntimeOwnedStatement('export const enabled = true;'), + true + ); + assert.equal( + hasRuntimeOwnedStatement( + 'export class RuntimeBoundary { start() {} }' + ), + true + ); + assert.equal(hasRuntimeOwnedStatement('console.log("runtime");'), true); + }); +}); + +describe('createCoverageOutputScanner', () => { + it('detects an ANSI-colored marker split across chunks', () => { + const scanner = createCoverageOutputScanner(128); + + scanner.push('\u001b[31mFailed to collect'); + scanner.push(' coverage from /workspace/effects.ts\u001b[39m'); + + assert.equal(scanner.collectionFailed, true); + }); + + it('detects a marker across a split ANSI escape sequence', () => { + const scanner = createCoverageOutputScanner(128); + + scanner.push('Failed to \u001b[3'); + scanner.push('1mcollect coverage from /workspace/effects.ts'); + + assert.equal(scanner.collectionFailed, true); + assert.equal(scanner.tail.includes('\u001b'), false); + }); + + it('keeps only a bounded rolling tail', () => { + const scanner = createCoverageOutputScanner(32); + + scanner.push('x'.repeat(256)); + + assert.equal(scanner.tail.length, 32); + assert.equal(scanner.collectionFailed, false); + }); + + it('detects the marker before trimming a long chunk', () => { + const scanner = createCoverageOutputScanner(32); + + scanner.push(`Failed to collect coverage${'x'.repeat(256)}`); + + assert.equal(scanner.tail.length, 32); + assert.equal(scanner.collectionFailed, true); + }); +}); + +describe('validateProjectCoverage', () => { + it('accepts a report containing every runtime-owning file', () => { + const fixture = makeProjectFixture(); + const relativeReportKey = path.relative( + fixture.workspaceRoot, + fixture.sourcePath + ); + writeCompleteReport(fixture, relativeReportKey); + + const result = validateProjectCoverage(fixture); + + assert.deepEqual(result.errors, []); + assert.equal(result.report?.project.name, 'example'); + assert.equal(result.report?.path, fixture.reportPath); + }); + + it('reports a runtime-owning source omitted from a valid report', () => { + const fixture = makeProjectFixture(); + writeFileSync(fixture.reportPath, '{}'); + + const result = validateProjectCoverage(fixture); + + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /example/); + assert.match(result.errors[0], /libs\/example\/src\/runtime\.ts/); + }); + + it('rejects a malformed Istanbul coverage entry', () => { + const fixture = makeProjectFixture(); + writeFileSync( + fixture.reportPath, + JSON.stringify({ [fixture.sourcePath]: {} }) + ); + + const result = validateProjectCoverage(fixture); + + assert.equal(result.report, undefined); + assert.match( + result.errors[0], + /example.*coverage\/libs\/example\/coverage-final\.json.*valid Istanbul/ + ); + }); + + it('rejects duplicate report paths after normalization', () => { + const fixture = makeProjectFixture(); + const relativePath = path.relative( + fixture.workspaceRoot, + fixture.sourcePath + ); + writeFileSync( + fixture.reportPath, + JSON.stringify({ + [relativePath]: coverageEntry(relativePath), + [fixture.sourcePath]: coverageEntry(fixture.sourcePath), + }) + ); + + const result = validateProjectCoverage(fixture); + + assert.equal(result.report, undefined); + assert.equal(result.errors.length, 1); + assert.match( + result.errors[0], + /example.*coverage\/libs\/example\/coverage-final\.json.*duplicate.*libs\/example\/src\/runtime\.ts.*normalization/ + ); + }); + + it('rejects a runtime source without usable instrumentation', () => { + const fixture = makeProjectFixture(); + writeFileSync( + fixture.reportPath, + JSON.stringify({ + [fixture.sourcePath]: coverageEntry(fixture.sourcePath, []), + }) + ); + + const result = validateProjectCoverage(fixture); + + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /example/); + assert.match(result.errors[0], /libs\/example\/src\/runtime\.ts/); + assert.match(result.errors[0], /usable instrumentation/); + }); + + it('accepts function-only instrumentation for a runtime source', () => { + const fixture = makeProjectFixture(); + writeFileSync( + fixture.sourcePath, + 'export function runtime() {}\n' + ); + writeFileSync( + fixture.reportPath, + JSON.stringify({ + [fixture.sourcePath]: functionOnlyCoverageEntry( + fixture.sourcePath + ), + }) + ); + + const result = validateProjectCoverage(fixture); + + assert.deepEqual(result.errors, []); + assert.equal(result.report?.project.name, 'example'); + }); + + it('does not require excluded or type-only TypeScript sources', () => { + const fixture = makeProjectFixture(); + const excludedSources = [ + 'feature.spec.ts', + 'feature.test.ts', + 'ambient.d.ts', + 'test-setup.ts', + 'test-stubs/runtime.ts', + 'feature.generated.ts', + 'environments/environment.ts', + 'index.ts', + ]; + for (const relativePath of excludedSources) { + const filePath = path.join( + fixture.workspaceRoot, + fixture.project.sourceRoot, + relativePath + ); + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, 'export const omitted = true;\n'); + } + writeFileSync( + path.join( + fixture.workspaceRoot, + fixture.project.sourceRoot, + 'contract.ts' + ), + 'export interface Contract { enabled: boolean }\n' + ); + writeCompleteReport(fixture); + + assert.deepEqual(validateProjectCoverage(fixture).errors, []); + }); + + it('reports missing, malformed, and non-object project reports', () => { + const missing = makeProjectFixture(); + const malformed = makeProjectFixture(); + const array = makeProjectFixture(); + const nullValue = makeProjectFixture(); + writeFileSync(malformed.reportPath, '{ invalid json'); + writeFileSync(array.reportPath, '[]'); + writeFileSync(nullValue.reportPath, 'null'); + + assert.match( + validateProjectCoverage(missing).errors[0], + /example.*did not produce.*coverage\/libs\/example\/coverage-final\.json/ + ); + assert.match( + validateProjectCoverage(malformed).errors[0], + /example.*invalid JSON/ + ); + assert.match( + validateProjectCoverage(array).errors[0], + /example.*JSON object/ + ); + assert.match( + validateProjectCoverage(nullValue).errors[0], + /example.*JSON object/ + ); + }); + + it('requires every configured report and preserves policy order', () => { + const first = makeProjectFixture({ + name: 'first', + projectRoot: 'libs/first', + }); + const missing = makeProjectFixture({ + name: 'missing', + projectRoot: 'libs/missing', + workspaceRoot: first.workspaceRoot, + }); + const last = makeProjectFixture({ + name: 'last', + projectRoot: 'libs/last', + workspaceRoot: first.workspaceRoot, + }); + writeCompleteReport(first); + writeCompleteReport(last); + + const result = validateRequiredProjectReports({ + projects: [last.project, missing.project, first.project], + workspaceRoot: first.workspaceRoot, + }); + + assert.deepEqual( + result.reports.map((report) => report.project.name), + ['last', 'first'] + ); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /missing/); + }); +}); + +describe('evaluateCoverageRatchets', () => { + it('reports all aggregate percentage regressions', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: { + statements: { covered: 68, total: 100, pct: 68 }, + branches: { covered: 57, total: 100, pct: 57 }, + functions: { covered: 67, total: 100, pct: 67 }, + lines: { covered: 69, total: 100, pct: 69 }, + }, + ratchet: { + merged: { + statements: 68.86, + branches: 58.66, + functions: 67.19, + lines: 69.2, + }, + criticalFiles: [], + }, + workspaceRoot: '/workspace', + }); + + assert.deepEqual(errors, [ + 'Merged statements coverage regressed: observed 68%, required at least 68.86%.', + 'Merged branches coverage regressed: observed 57%, required at least 58.66%.', + 'Merged functions coverage regressed: observed 67%, required at least 67.19%.', + 'Merged lines coverage regressed: observed 69%, required at least 69.2%.', + ]); + }); + + it('rejects missing, non-numeric, and out-of-range aggregate minima', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + branches: '58.66', + functions: -1, + lines: 101, + }, + criticalFiles: [], + }, + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 4); + assert.match(errors[0], /merged\.statements.*undefined/); + assert.match(errors[1], /merged\.branches.*"58\.66"/); + assert.match(errors[2], /merged\.functions.*-1/); + assert.match(errors[3], /merged\.lines.*101/); + }); + + it('accepts an explicitly empty criticalFiles array', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: ratchetWithCriticalFiles([]), + workspaceRoot: '/workspace', + }); + + assert.deepEqual(errors, []); + }); + + it('requires an explicit criticalFiles array', () => { + const merged = ratchetWithCriticalFiles([]).merged; + const ratchets = [ + { merged }, + { merged, criticalFiles: null }, + ]; + + for (const ratchet of ratchets) { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet, + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 1); + assert.match(errors[0], /criticalFiles.*array/); + } + }); + + it('rejects a non-array criticalFiles container without throwing', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: ratchetWithCriticalFiles({}), + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 1); + assert.match(errors[0], /criticalFiles.*array.*object/); + }); + + it('requires a non-empty string path for every critical file', () => { + for (const invalidPath of [undefined, null, 42, '', ' ']) { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: ratchetWithCriticalFiles([ + { + path: invalidPath, + statements: { + minimumCovered: 0, + minimumPercent: 0, + }, + }, + ]), + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 1); + assert.match(errors[0], /criticalFiles\[0\]\.path/); + } + }); + + it('rejects invalid critical-file statement minima', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [ + { + path: 'apps/missing-values.ts', + statements: {}, + }, + { + path: 'apps/string-values.ts', + statements: { + minimumCovered: '2', + minimumPercent: '75', + }, + }, + { + path: 'apps/out-of-range.ts', + statements: { + minimumCovered: -1, + minimumPercent: 101, + }, + }, + { + path: 'apps/fractional.ts', + statements: { + minimumCovered: 1.5, + minimumPercent: 75, + }, + }, + ], + }, + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 7); + assert.match( + errors[0], + /apps\/missing-values\.ts.*minimumCovered.*undefined/ + ); + assert.match( + errors[1], + /apps\/missing-values\.ts.*minimumPercent.*undefined/ + ); + assert.match( + errors[2], + /apps\/string-values\.ts.*minimumCovered.*"2"/ + ); + assert.match( + errors[3], + /apps\/string-values\.ts.*minimumPercent.*"75"/ + ); + assert.match( + errors[4], + /apps\/out-of-range\.ts.*minimumCovered.*-1/ + ); + assert.match( + errors[5], + /apps\/out-of-range\.ts.*minimumPercent.*101/ + ); + assert.match( + errors[6], + /apps\/fractional\.ts.*minimumCovered.*1\.5/ + ); + }); + + it('requires both covered statements and percentage for a critical file', () => { + const workspaceRoot = '/workspace'; + const filePath = path.join(workspaceRoot, 'apps/runtime.ts'); + const errors = evaluateCoverageRatchets({ + coverageData: { + [filePath]: coverageEntry(filePath, [1, 0]), + }, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [ + { + path: 'apps/runtime.ts', + statements: { + minimumCovered: 2, + minimumPercent: 75, + }, + }, + ], + }, + workspaceRoot, + }); + + assert.deepEqual(errors, [ + 'Critical coverage apps/runtime.ts statements covered regressed: observed 1, required at least 2.', + 'Critical coverage apps/runtime.ts statements percent regressed: observed 50%, required at least 75%.', + ]); + }); + + it('looks up a relative Istanbul key by its normalized critical path', () => { + const workspaceRoot = process.cwd(); + const relativePath = 'apps/runtime.ts'; + const errors = evaluateCoverageRatchets({ + coverageData: { + [relativePath]: coverageEntry(relativePath, [1, 1]), + }, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [ + { + path: relativePath, + statements: { + minimumCovered: 2, + minimumPercent: 100, + }, + }, + ], + }, + workspaceRoot, + }); + + assert.deepEqual(errors, []); + }); + + it('rejects duplicate Istanbul keys after path normalization', () => { + const workspaceRoot = process.cwd(); + const relativePath = 'apps/runtime.ts'; + const absolutePath = path.join(workspaceRoot, relativePath); + const errors = evaluateCoverageRatchets({ + coverageData: { + [relativePath]: coverageEntry(relativePath, [1]), + [absolutePath]: coverageEntry(absolutePath, [1]), + }, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [], + }, + workspaceRoot, + }); + + assert.equal(errors.length, 1); + assert.match( + errors[0], + /duplicate.*apps\/runtime\.ts.*normalization/ + ); + }); + + it('reports a critical file missing from merged coverage', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [ + { + path: 'apps/missing.ts', + statements: { + minimumCovered: 1, + minimumPercent: 0, + }, + }, + ], + }, + workspaceRoot: '/workspace', + }); + + assert.deepEqual(errors, [ + 'Critical coverage apps/missing.ts is missing from merged coverage.', + ]); + }); +});