0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
CI / Shell Format Check (push) Waiting to run
CI / Check Ruby (3.4) (push) Waiting to run
CI / CI Config (push) Waiting to run
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Blocked by required conditions
CI / Build on Node ${{ matrix.node }} (push) Blocked by required conditions
CI / Style Check (push) Waiting to run
CI / Generate Assets (push) Waiting to run
CI / Check Python (3.14) (push) Waiting to run
CI / Check Python (3.9) (push) Waiting to run
CI / Build Docs (push) Waiting to run
CI / Code Scan Action (push) Waiting to run
CI / Site tests (push) Waiting to run
CI / webui tests (push) Waiting to run
CI / Run Integration Tests (push) Waiting to run
CI / Run Smoke Tests (push) Waiting to run
CI / Go Tests (push) Waiting to run
CI / Share Test (push) Waiting to run
CI / Redteam (Production API) (push) Waiting to run
CI / Redteam (Staging API) (push) Waiting to run
CI / GitHub Actions Lint (push) Waiting to run
CI / Check Ruby (3.0) (push) Waiting to run
release-please / release-please (push) Waiting to run
release-please / build (push) Blocked by required conditions
release-please / publish-npm (push) Blocked by required conditions
release-please / publish-npm-backfill (push) Waiting to run
release-please / docker (push) Blocked by required conditions
release-please / publish-code-scan-action (push) Blocked by required conditions
release-please / attest-code-scan-action (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
554 lines
15 KiB
TypeScript
554 lines
15 KiB
TypeScript
import { execFileSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
export interface ChangedFile {
|
|
path: string;
|
|
status: string;
|
|
}
|
|
|
|
export interface CoverageThresholds {
|
|
branches: number;
|
|
functions: number;
|
|
lines: number;
|
|
statements: number;
|
|
}
|
|
|
|
interface Position {
|
|
line: number;
|
|
}
|
|
|
|
interface StatementLocation {
|
|
start: Position;
|
|
}
|
|
|
|
interface FileCoverage {
|
|
b?: Record<string, number[]>;
|
|
branchMap?: Record<string, unknown>;
|
|
f?: Record<string, number>;
|
|
fnMap?: Record<string, unknown>;
|
|
path?: string;
|
|
s?: Record<string, number>;
|
|
statementMap?: Record<string, StatementLocation>;
|
|
}
|
|
|
|
type CoverageMap = Record<string, FileCoverage>;
|
|
|
|
export interface CoverageReportConfig {
|
|
coverageFile: string;
|
|
criticalFiles: string[];
|
|
criticalPrefixes: string[];
|
|
excludeFiles: string[];
|
|
excludePrefixes: string[];
|
|
name: string;
|
|
sourcePrefix: string;
|
|
}
|
|
|
|
interface CoverageTotals {
|
|
covered: number;
|
|
pct: number;
|
|
total: number;
|
|
}
|
|
|
|
export interface FileCoverageSummary {
|
|
branches: CoverageTotals;
|
|
functions: CoverageTotals;
|
|
lines: CoverageTotals;
|
|
statements: CoverageTotals;
|
|
}
|
|
|
|
interface CheckedFile {
|
|
file: string;
|
|
reason: string;
|
|
summary: FileCoverageSummary;
|
|
}
|
|
|
|
interface CoverageFailure {
|
|
file: string;
|
|
message: string;
|
|
reason: string;
|
|
}
|
|
|
|
interface CoverageRatchetResult {
|
|
checkedFiles: CheckedFile[];
|
|
failures: CoverageFailure[];
|
|
skippedFiles: string[];
|
|
}
|
|
|
|
interface CliOptions {
|
|
baseRef?: string;
|
|
reports: string[];
|
|
}
|
|
|
|
interface GithubPullRequestEvent {
|
|
pull_request?: {
|
|
base?: {
|
|
sha?: unknown;
|
|
};
|
|
};
|
|
}
|
|
|
|
export const DEFAULT_COVERAGE_THRESHOLDS: CoverageThresholds = {
|
|
branches: 70,
|
|
functions: 80,
|
|
lines: 80,
|
|
statements: 80,
|
|
};
|
|
|
|
export const COVERAGE_RATCHET_REPORTS: CoverageReportConfig[] = [
|
|
{
|
|
name: 'backend',
|
|
coverageFile: 'coverage/coverage-final.json',
|
|
sourcePrefix: 'src/',
|
|
excludePrefixes: ['src/app/', 'src/__mocks__/'],
|
|
excludeFiles: ['src/entrypoint.ts', 'src/main.ts', 'src/migrate.ts'],
|
|
criticalPrefixes: ['src/assertions/', 'src/matchers/', 'src/util/config/'],
|
|
criticalFiles: ['src/evaluator.ts', 'src/evaluatorHelpers.ts', 'src/prompts.ts'],
|
|
},
|
|
{
|
|
name: 'frontend',
|
|
coverageFile: 'src/app/coverage/coverage-final.json',
|
|
sourcePrefix: 'src/app/src/',
|
|
excludePrefixes: [],
|
|
excludeFiles: ['src/app/src/setupTests.ts'],
|
|
criticalPrefixes: ['src/app/src/store/', 'src/app/src/stores/', 'src/app/src/tests/'],
|
|
criticalFiles: ['src/app/src/utils/api.ts'],
|
|
},
|
|
];
|
|
|
|
function git(args: string[], cwd: string): string {
|
|
return execFileSync('git', args, {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
}
|
|
|
|
function hasGitRef(ref: string, cwd: string): boolean {
|
|
try {
|
|
git(['rev-parse', '--verify', '--quiet', ref], cwd);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function fetchGitRef(ref: string, cwd: string): void {
|
|
if (hasGitRef(ref, cwd)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
git(['fetch', '--depth=1', 'origin', ref], cwd);
|
|
} catch {
|
|
// The ref may already be unavailable to this checkout; later diff candidates can still work.
|
|
}
|
|
}
|
|
|
|
function fetchGithubBaseRef(cwd: string): void {
|
|
const baseRef = process.env.GITHUB_BASE_REF;
|
|
if (!baseRef) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
git(['fetch', '--depth=1', 'origin', `${baseRef}:refs/remotes/origin/${baseRef}`], cwd);
|
|
} catch {
|
|
// The local checkout may already have enough history, and forks may not allow this fetch.
|
|
}
|
|
}
|
|
|
|
export function readGithubPullRequestBaseSha(
|
|
eventPath = process.env.GITHUB_EVENT_PATH,
|
|
): string | undefined {
|
|
if (!eventPath) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8')) as GithubPullRequestEvent;
|
|
const baseSha = event.pull_request?.base?.sha;
|
|
return typeof baseSha === 'string' && baseSha.length > 0 ? baseSha : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function parseChangedFileList(output: string): ChangedFile[] {
|
|
return output
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const [status, ...paths] = line.split('\t');
|
|
const normalizedStatus = status.charAt(0);
|
|
const changedPath =
|
|
normalizedStatus === 'R' || normalizedStatus === 'C' ? paths[1] : paths[0];
|
|
|
|
return {
|
|
path: normalizeSlashes(changedPath),
|
|
status: normalizedStatus,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function getChangedFiles(cwd: string, baseRef?: string): ChangedFile[] {
|
|
fetchGithubBaseRef(cwd);
|
|
|
|
const diffCommands: string[][] = [];
|
|
const isGithubActions = process.env.GITHUB_ACTIONS === 'true';
|
|
const githubBaseSha = readGithubPullRequestBaseSha();
|
|
|
|
if (baseRef) {
|
|
fetchGitRef(baseRef, cwd);
|
|
diffCommands.push(['diff', '--name-status', '--diff-filter=ACMRTUXB', `${baseRef}...HEAD`]);
|
|
}
|
|
|
|
if (githubBaseSha) {
|
|
fetchGitRef(githubBaseSha, cwd);
|
|
diffCommands.push([
|
|
'diff',
|
|
'--name-status',
|
|
'--diff-filter=ACMRTUXB',
|
|
`${githubBaseSha}...HEAD`,
|
|
]);
|
|
}
|
|
|
|
if (isGithubActions && hasGitRef('HEAD^1', cwd)) {
|
|
diffCommands.push(['diff', '--name-status', '--diff-filter=ACMRTUXB', 'HEAD^1', 'HEAD']);
|
|
}
|
|
|
|
const githubBaseRef = process.env.GITHUB_BASE_REF;
|
|
if (githubBaseRef) {
|
|
diffCommands.push([
|
|
'diff',
|
|
'--name-status',
|
|
'--diff-filter=ACMRTUXB',
|
|
`origin/${githubBaseRef}...HEAD`,
|
|
]);
|
|
}
|
|
|
|
diffCommands.push(['diff', '--name-status', '--diff-filter=ACMRTUXB', 'origin/main...HEAD']);
|
|
|
|
if (!isGithubActions && hasGitRef('HEAD^', cwd)) {
|
|
diffCommands.push(['diff', '--name-status', '--diff-filter=ACMRTUXB', 'HEAD^', 'HEAD']);
|
|
}
|
|
|
|
for (const args of diffCommands) {
|
|
try {
|
|
return parseChangedFileList(git(args, cwd));
|
|
} catch {
|
|
// Try the next base candidate.
|
|
}
|
|
}
|
|
|
|
throw new Error('Unable to determine changed files for coverage ratchets');
|
|
}
|
|
|
|
function normalizeSlashes(filePath: string): string {
|
|
return filePath.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
}
|
|
|
|
export function normalizeCoveragePath(filePath: string, repoRoot: string): string {
|
|
const normalizedRoot = normalizeSlashes(path.resolve(repoRoot));
|
|
const absolutePath = path.isAbsolute(filePath)
|
|
? path.resolve(filePath)
|
|
: path.resolve(repoRoot, filePath);
|
|
const normalizedPath = normalizeSlashes(absolutePath);
|
|
|
|
if (normalizedPath.startsWith(`${normalizedRoot}/`)) {
|
|
return normalizedPath.slice(normalizedRoot.length + 1);
|
|
}
|
|
|
|
return normalizeSlashes(filePath);
|
|
}
|
|
|
|
function isSourceFile(filePath: string): boolean {
|
|
return (
|
|
(filePath.endsWith('.ts') || filePath.endsWith('.tsx')) &&
|
|
!filePath.endsWith('.d.ts') &&
|
|
!filePath.endsWith('.test.ts') &&
|
|
!filePath.endsWith('.test.tsx') &&
|
|
!filePath.endsWith('.spec.ts') &&
|
|
!filePath.endsWith('.spec.tsx') &&
|
|
!filePath.endsWith('.stories.tsx')
|
|
);
|
|
}
|
|
|
|
function isReportSourceFile(report: CoverageReportConfig, filePath: string): boolean {
|
|
return (
|
|
isSourceFile(filePath) &&
|
|
filePath.startsWith(report.sourcePrefix) &&
|
|
!report.excludeFiles.includes(filePath) &&
|
|
!report.excludePrefixes.some((prefix) => filePath.startsWith(prefix))
|
|
);
|
|
}
|
|
|
|
function isCriticalPath(report: CoverageReportConfig, filePath: string): boolean {
|
|
return (
|
|
report.criticalFiles.includes(filePath) ||
|
|
report.criticalPrefixes.some((prefix) => filePath.startsWith(prefix))
|
|
);
|
|
}
|
|
|
|
function pct(covered: number, total: number): number {
|
|
return total === 0 ? 100 : (covered / total) * 100;
|
|
}
|
|
|
|
export function summarizeFileCoverage(fileCoverage: FileCoverage): FileCoverageSummary {
|
|
const statementMap = fileCoverage.statementMap ?? {};
|
|
const statements = Object.keys(statementMap);
|
|
const coveredStatements = statements.filter((id) => (fileCoverage.s?.[id] ?? 0) > 0).length;
|
|
|
|
const functions = Object.keys(fileCoverage.fnMap ?? {});
|
|
const coveredFunctions = functions.filter((id) => (fileCoverage.f?.[id] ?? 0) > 0).length;
|
|
|
|
const branches = Object.keys(fileCoverage.branchMap ?? {});
|
|
const branchHits = branches.flatMap((id) => fileCoverage.b?.[id] ?? []);
|
|
const coveredBranches = branchHits.filter((hit) => hit > 0).length;
|
|
|
|
const lineCoverage = new Map<number, boolean>();
|
|
for (const statementId of statements) {
|
|
const line = statementMap[statementId]?.start.line;
|
|
if (typeof line !== 'number') {
|
|
continue;
|
|
}
|
|
lineCoverage.set(
|
|
line,
|
|
lineCoverage.get(line) === true || (fileCoverage.s?.[statementId] ?? 0) > 0,
|
|
);
|
|
}
|
|
|
|
const coveredLines = [...lineCoverage.values()].filter(Boolean).length;
|
|
|
|
return {
|
|
branches: {
|
|
covered: coveredBranches,
|
|
total: branchHits.length,
|
|
pct: pct(coveredBranches, branchHits.length),
|
|
},
|
|
functions: {
|
|
covered: coveredFunctions,
|
|
total: functions.length,
|
|
pct: pct(coveredFunctions, functions.length),
|
|
},
|
|
lines: {
|
|
covered: coveredLines,
|
|
total: lineCoverage.size,
|
|
pct: pct(coveredLines, lineCoverage.size),
|
|
},
|
|
statements: {
|
|
covered: coveredStatements,
|
|
total: statements.length,
|
|
pct: pct(coveredStatements, statements.length),
|
|
},
|
|
};
|
|
}
|
|
|
|
function formatPct(value: number): string {
|
|
return value.toFixed(2);
|
|
}
|
|
|
|
function belowThresholds(summary: FileCoverageSummary, thresholds: CoverageThresholds): string[] {
|
|
const failures: string[] = [];
|
|
|
|
for (const metric of Object.keys(thresholds) as (keyof CoverageThresholds)[]) {
|
|
if (summary[metric].pct < thresholds[metric]) {
|
|
failures.push(
|
|
`${metric} ${formatPct(summary[metric].pct)}% < ${thresholds[metric]}% ` +
|
|
`(${summary[metric].covered}/${summary[metric].total})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
export function evaluateCoverageRatchets({
|
|
changedFiles,
|
|
coverageMap,
|
|
repoRoot,
|
|
report,
|
|
thresholds = DEFAULT_COVERAGE_THRESHOLDS,
|
|
}: {
|
|
changedFiles: ChangedFile[];
|
|
coverageMap: CoverageMap;
|
|
repoRoot: string;
|
|
report: CoverageReportConfig;
|
|
thresholds?: CoverageThresholds;
|
|
}): CoverageRatchetResult {
|
|
const coverageByFile = new Map<string, FileCoverage>();
|
|
|
|
for (const [coveragePath, fileCoverage] of Object.entries(coverageMap)) {
|
|
coverageByFile.set(
|
|
normalizeCoveragePath(fileCoverage.path ?? coveragePath, repoRoot),
|
|
fileCoverage,
|
|
);
|
|
}
|
|
|
|
const checkedFiles: CheckedFile[] = [];
|
|
const failures: CoverageFailure[] = [];
|
|
const skippedFiles: string[] = [];
|
|
|
|
for (const changedFile of changedFiles) {
|
|
const changedPath = normalizeCoveragePath(changedFile.path, repoRoot);
|
|
if (!isReportSourceFile(report, changedPath)) {
|
|
continue;
|
|
}
|
|
|
|
const reason =
|
|
changedFile.status === 'A'
|
|
? 'new source file'
|
|
: isCriticalPath(report, changedPath)
|
|
? 'critical path'
|
|
: undefined;
|
|
|
|
if (!reason) {
|
|
skippedFiles.push(changedPath);
|
|
continue;
|
|
}
|
|
|
|
const fileCoverage = coverageByFile.get(changedPath);
|
|
if (!fileCoverage) {
|
|
failures.push({
|
|
file: changedPath,
|
|
reason,
|
|
message: `No coverage entry found for ${changedPath}`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const summary = summarizeFileCoverage(fileCoverage);
|
|
if (summary.statements.total === 0) {
|
|
skippedFiles.push(changedPath);
|
|
continue;
|
|
}
|
|
|
|
checkedFiles.push({ file: changedPath, reason, summary });
|
|
|
|
const coverageFailures = belowThresholds(summary, thresholds);
|
|
if (coverageFailures.length > 0) {
|
|
failures.push({
|
|
file: changedPath,
|
|
reason,
|
|
message: coverageFailures.join(', '),
|
|
});
|
|
}
|
|
}
|
|
|
|
return { checkedFiles, failures, skippedFiles };
|
|
}
|
|
|
|
function readCoverageMap(coverageFile: string): CoverageMap {
|
|
return JSON.parse(fs.readFileSync(coverageFile, 'utf8')) as CoverageMap;
|
|
}
|
|
|
|
function readFlagValue(argv: string[], index: number, flag: string): string {
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith('--')) {
|
|
throw new Error(`Missing value for ${flag}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseArgs(argv: string[]): CliOptions {
|
|
const options: CliOptions = { reports: [] };
|
|
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
|
|
if (arg === '--base') {
|
|
options.baseRef = readFlagValue(argv, i, arg);
|
|
i += 1;
|
|
} else if (arg === '--report') {
|
|
options.reports.push(readFlagValue(argv, i, arg));
|
|
i += 1;
|
|
} else if (arg === '--help' || arg === '-h') {
|
|
console.log(
|
|
'Usage: tsx scripts/checkCoverageRatchets.ts [--base <ref>] [--report backend|frontend]',
|
|
);
|
|
process.exit(0);
|
|
} else {
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
export function runCoverageRatchetCli(argv: string[], cwd = process.cwd()): number {
|
|
const options = parseArgs(argv);
|
|
const reportNames = new Set(COVERAGE_RATCHET_REPORTS.map((report) => report.name));
|
|
const unknownReports = options.reports.filter((report) => !reportNames.has(report));
|
|
if (unknownReports.length > 0) {
|
|
throw new Error(`Unknown coverage report(s): ${unknownReports.join(', ')}`);
|
|
}
|
|
|
|
const selectedReports =
|
|
options.reports.length > 0
|
|
? COVERAGE_RATCHET_REPORTS.filter((report) => options.reports.includes(report.name))
|
|
: COVERAGE_RATCHET_REPORTS;
|
|
|
|
let failureCount = 0;
|
|
const availableReports: CoverageReportConfig[] = [];
|
|
|
|
for (const report of selectedReports) {
|
|
const coverageFile = path.resolve(cwd, report.coverageFile);
|
|
if (!fs.existsSync(coverageFile)) {
|
|
const message = `[coverage-ratchet] ${report.name}: missing ${report.coverageFile}`;
|
|
if (options.reports.length > 0) {
|
|
console.error(message);
|
|
failureCount += 1;
|
|
} else {
|
|
console.log(`${message} (skipped)`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
availableReports.push(report);
|
|
}
|
|
|
|
if (failureCount > 0 || availableReports.length === 0) {
|
|
return failureCount === 0 ? 0 : 1;
|
|
}
|
|
|
|
const changedFiles = getChangedFiles(cwd, options.baseRef);
|
|
|
|
for (const report of availableReports) {
|
|
const coverageFile = path.resolve(cwd, report.coverageFile);
|
|
const result = evaluateCoverageRatchets({
|
|
changedFiles,
|
|
coverageMap: readCoverageMap(coverageFile),
|
|
repoRoot: cwd,
|
|
report,
|
|
});
|
|
|
|
if (result.checkedFiles.length === 0) {
|
|
console.log(
|
|
`[coverage-ratchet] ${report.name}: no new or critical changed source files to check`,
|
|
);
|
|
} else {
|
|
console.log(
|
|
`[coverage-ratchet] ${report.name}: checked ${result.checkedFiles.length} file(s)`,
|
|
);
|
|
}
|
|
|
|
for (const failure of result.failures) {
|
|
failureCount += 1;
|
|
console.error(`[coverage-ratchet] ${failure.file} (${failure.reason}): ${failure.message}`);
|
|
}
|
|
}
|
|
|
|
return failureCount === 0 ? 0 : 1;
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
try {
|
|
process.exitCode = runCoverageRatchetCli(process.argv.slice(2));
|
|
} catch (error) {
|
|
console.error(`[coverage-ratchet] ${error instanceof Error ? error.message : String(error)}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|