Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions coverage-service/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,13 @@ WORKER_FORCE_SHUTDOWN=false
MAX_MEMORY_MB=2048
MAX_CPU_CORES=2

# Coverage workflow
# TEST_THRESHOLD accepts 80 (percent) or 0.8 (fraction). Diff coverage is preferred; overall coverage is the fallback.
# Coverage workflow / optimization
# TARGET_DIFF_COVERAGE accepts 80 (percent) or 0.8 (fraction). Falls back to TEST_THRESHOLD.
TARGET_DIFF_COVERAGE=80
TEST_THRESHOLD=80
MAX_OPTIMIZATION_ITERATIONS=8
MAX_GENERATION_ATTEMPTS=3
MAX_REPAIR_ATTEMPTS=3
MIN_COVERAGE_GAIN=1
ENABLE_REPAIR_LOOP=true
MAX_FILES_PER_ITERATION=10
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
-- CreateEnum
CREATE TYPE "GeneratedTestStatus" AS ENUM ('PENDING', 'PASSING', 'FAILED');

-- CreateTable
CREATE TABLE "CoverageIteration" (
"id" TEXT NOT NULL,
"prRunId" TEXT NOT NULL,
"iteration" INTEGER NOT NULL,
"coverageBefore" DOUBLE PRECISION NOT NULL,
"coverageAfter" DOUBLE PRECISION,
"coverageGain" DOUBLE PRECISION,
"generatedTests" INTEGER NOT NULL DEFAULT 0,
"failedTests" INTEGER NOT NULL DEFAULT 0,
"stopReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "CoverageIteration_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "CoverageGap" (
"id" TEXT NOT NULL,
"iterationId" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"coverage" DOUBLE PRECISION NOT NULL,
"missingLines" INTEGER[],
"priority" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "CoverageGap_pkey" PRIMARY KEY ("id")
);

-- AlterTable
ALTER TABLE "GeneratedTestArtifact" ADD COLUMN "coverageIterationId" TEXT,
ADD COLUMN "status" "GeneratedTestStatus" NOT NULL DEFAULT 'PENDING',
ADD COLUMN "repairAttempts" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "coverageContribution" DOUBLE PRECISION,
ADD COLUMN "failureReason" TEXT;

-- CreateIndex
CREATE UNIQUE INDEX "CoverageIteration_prRunId_iteration_key" ON "CoverageIteration"("prRunId", "iteration");

-- CreateIndex
CREATE INDEX "CoverageIteration_prRunId_idx" ON "CoverageIteration"("prRunId");

-- CreateIndex
CREATE INDEX "CoverageGap_iterationId_idx" ON "CoverageGap"("iterationId");

-- CreateIndex
CREATE INDEX "GeneratedTestArtifact_coverageIterationId_idx" ON "GeneratedTestArtifact"("coverageIterationId");

-- AddForeignKey
ALTER TABLE "CoverageIteration" ADD CONSTRAINT "CoverageIteration_prRunId_fkey" FOREIGN KEY ("prRunId") REFERENCES "PullRequestRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "CoverageGap" ADD CONSTRAINT "CoverageGap_iterationId_fkey" FOREIGN KEY ("iterationId") REFERENCES "CoverageIteration"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "GeneratedTestArtifact" ADD CONSTRAINT "GeneratedTestArtifact_coverageIterationId_fkey" FOREIGN KEY ("coverageIterationId") REFERENCES "CoverageIteration"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- Backfill status from passed column
UPDATE "GeneratedTestArtifact" SET "status" = 'PASSING' WHERE "passed" = true;
UPDATE "GeneratedTestArtifact" SET "status" = 'FAILED' WHERE "passed" = false;
67 changes: 57 additions & 10 deletions coverage-service/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ model PullRequestRun {
completedAt DateTime?
coverageResult CoverageResult?
generatedTests GeneratedTestArtifact[]
coverageIterations CoverageIteration[]
executionLogs ExecutionLog[]
llmUsage LlmUsageRecord[]

Expand Down Expand Up @@ -110,17 +111,63 @@ model TestGenerationLog {
createdAt DateTime @default(now())
}

enum GeneratedTestStatus {
PENDING
PASSING
FAILED
}

model CoverageIteration {
id String @id @default(cuid())
prRunId String
prRun PullRequestRun @relation(fields: [prRunId], references: [id], onDelete: Cascade)
iteration Int
coverageBefore Float
coverageAfter Float?
coverageGain Float?
generatedTests Int @default(0)
failedTests Int @default(0)
stopReason String?
createdAt DateTime @default(now())
gaps CoverageGap[]
testArtifacts GeneratedTestArtifact[]

@@unique([prRunId, iteration])
@@index([prRunId])
}

model CoverageGap {
id String @id @default(cuid())
iterationId String
iteration CoverageIteration @relation(fields: [iterationId], references: [id], onDelete: Cascade)
filePath String
coverage Float
missingLines Int[]
priority Float
createdAt DateTime @default(now())

@@index([iterationId])
}

model GeneratedTestArtifact {
id String @id @default(cuid())
prRunId String?
prRun PullRequestRun? @relation(fields: [prRunId], references: [id], onDelete: Cascade)
testGenerationRunId String? @unique
testGenerationRun TestGenerationRun? @relation(fields: [testGenerationRunId], references: [id], onDelete: Cascade)
filePath String
targetFile String
content String @db.Text
passed Boolean?
createdAt DateTime @default(now())
id String @id @default(cuid())
prRunId String?
prRun PullRequestRun? @relation(fields: [prRunId], references: [id], onDelete: Cascade)
coverageIterationId String?
coverageIteration CoverageIteration? @relation(fields: [coverageIterationId], references: [id], onDelete: SetNull)
testGenerationRunId String? @unique
testGenerationRun TestGenerationRun? @relation(fields: [testGenerationRunId], references: [id], onDelete: Cascade)
filePath String
targetFile String
content String @db.Text
passed Boolean?
status GeneratedTestStatus @default(PENDING)
repairAttempts Int @default(0)
coverageContribution Float?
failureReason String? @db.Text
createdAt DateTime @default(now())

@@index([coverageIterationId])
}

model ExecutionLog {
Expand Down
57 changes: 56 additions & 1 deletion coverage-service/lib/src/coverage/coverage-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
/** Parse TEST_THRESHOLD — accepts 80 (percent) or 0.8 (fraction). Default 80. */
/** Parse TARGET_DIFF_COVERAGE or TEST_THRESHOLD — accepts 80 (percent) or 0.8 (fraction). Default 80. */
export function getTargetDiffCoveragePercent(): number {
const targetRaw = process.env.TARGET_DIFF_COVERAGE?.trim();
if (targetRaw) {
const value = parseFloat(targetRaw);
if (!Number.isNaN(value)) {
return value <= 1 ? value * 100 : value;
}
}
return getTestThresholdPercent();
}

/** @deprecated Use getTargetDiffCoveragePercent — kept for backward compatibility. */
export function getTestThresholdPercent(): number {
const raw = process.env.TEST_THRESHOLD?.trim();
if (!raw) return 80;
Expand All @@ -17,3 +29,46 @@ export function getMaxGenerationAttempts(): number {
const value = parseInt(raw, 10);
return Number.isNaN(value) || value < 1 ? 3 : value;
}

/** Parse MAX_OPTIMIZATION_ITERATIONS. Default 8. */
export function getMaxOptimizationIterations(): number {
const raw = process.env.MAX_OPTIMIZATION_ITERATIONS?.trim();
if (!raw) return 8;

const value = parseInt(raw, 10);
return Number.isNaN(value) || value < 1 ? 8 : value;
}

/** Parse MAX_REPAIR_ATTEMPTS. Default 3. */
export function getMaxRepairAttempts(): number {
const raw = process.env.MAX_REPAIR_ATTEMPTS?.trim();
if (!raw) return 3;

const value = parseInt(raw, 10);
return Number.isNaN(value) || value < 1 ? 3 : value;
}

/** Parse MIN_COVERAGE_GAIN (percentage points). Default 1. */
export function getMinCoverageGain(): number {
const raw = process.env.MIN_COVERAGE_GAIN?.trim();
if (!raw) return 1;

const value = parseFloat(raw);
return Number.isNaN(value) || value < 0 ? 1 : value;
}

/** Parse ENABLE_REPAIR_LOOP. Default true. */
export function isRepairLoopEnabled(): boolean {
const raw = process.env.ENABLE_REPAIR_LOOP?.trim()?.toLowerCase();
if (!raw) return true;
return raw !== 'false' && raw !== '0' && raw !== 'no';
}

/** Max files to process per optimization iteration. Default 10. */
export function getMaxFilesPerIteration(): number {
const raw = process.env.MAX_FILES_PER_ITERATION?.trim();
if (!raw) return 10;

const value = parseInt(raw, 10);
return Number.isNaN(value) || value < 1 ? 10 : value;
}
121 changes: 121 additions & 0 deletions coverage-service/lib/src/coverage/coverage-gap-analysis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { ChangedFile, DiffCoverageReport } from '../types';

Check failure on line 1 in coverage-service/lib/src/coverage/coverage-gap-analysis.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (20)

There should be at least one empty line between import groups

Check failure on line 1 in coverage-service/lib/src/coverage/coverage-gap-analysis.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (22)

There should be at least one empty line between import groups
import { pathsMatch } from './cobertura-parser';

export interface CoverageGapEntry {
file: string;
coverage: number;
uncoveredLines: number[];
uncoveredPercentage: number;
/** Estimated file complexity (line count of source). */
complexity: number;
/** Priority score assigned by CoveragePriorityService. */
priority?: number;
}

export interface CoverageGapsReport {
files: CoverageGapEntry[];
totalUncoveredLines: number;
}

/** Extract per-file coverage gaps from a diff-cover report. */
export function getCoverageGaps(
report: DiffCoverageReport,
thresholdPercent: number,
): CoverageGapsReport {
const files: CoverageGapEntry[] = [];

for (const entry of report.fileCoverage) {
const effective = entry.diffCoveragePercent ?? entry.lineCoveragePercent;
const uncoveredLines =
entry.uncoveredLines.length > 0
? entry.uncoveredLines
: report.uncoveredLines
.filter((l) => pathsMatch(l.file, entry.file))
.map((l) => l.line);

if (effective >= thresholdPercent && uncoveredLines.length === 0) {
continue;
}

const uncoveredPercentage = Math.max(0, 100 - effective);

files.push({
file: entry.file,
coverage: effective,
uncoveredLines: [...new Set(uncoveredLines)].sort((a, b) => a - b),
uncoveredPercentage,
complexity: 0,
});
}

// Files with uncovered lines but no fileCoverage entry
const coveredFiles = new Set(files.map((f) => f.file));
for (const { file, line } of report.uncoveredLines) {
if (coveredFiles.has(file)) continue;
const existing = files.find((f) => pathsMatch(f.file, file));
if (existing) {
if (!existing.uncoveredLines.includes(line)) {
existing.uncoveredLines.push(line);
existing.uncoveredLines.sort((a, b) => a - b);
}
} else {
files.push({
file,
coverage: 0,
uncoveredLines: [line],
uncoveredPercentage: 100,
complexity: 0,
});
coveredFiles.add(file);
}
}

const totalUncoveredLines = files.reduce(
(sum, f) => sum + f.uncoveredLines.length,
0,
);

return { files, totalUncoveredLines };
}

/** Map coverage gaps to changed source files eligible for test generation. */
export function gapsToChangedFiles(
gaps: CoverageGapEntry[],
sourceFiles: ChangedFile[],
): ChangedFile[] {
const result: ChangedFile[] = [];
for (const gap of gaps) {
const match = sourceFiles.find((f) => pathsMatch(f.path, gap.file));
if (match && !result.some((r) => pathsMatch(r.path, match.path))) {
result.push(match);
}
}
return result;
}

/** Detect stagnation: two consecutive iterations with gain below minimum. */
export function shouldStopForStagnation(
recentGains: number[],
minGain: number,
): boolean {
if (recentGains.length < 2) return false;
const lastTwo = recentGains.slice(-2);
return lastTwo.every((g) => g < minGain);
}

export type OptimizationStopReason =
| 'target_reached'
| 'max_iterations'
| 'plateau'
| 'no_gaps'
| 'unrecoverable_failure'
| 'no_llm';

export function formatGapSummary(gaps: CoverageGapEntry[]): string {
return gaps
.map(
(g) =>
`- ${g.file}: ${g.uncoveredLines.slice(0, 20).join(',')}${g.uncoveredLines.length > 20 ? '…' : ''}`,
)
.join('\n');
}
39 changes: 39 additions & 0 deletions coverage-service/lib/src/coverage/coverage-priority.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { CoverageGapEntry } from './coverage-gap-analysis';

export interface PriorityContext {
/** Number of previous failed generation/repair attempts per file. */
failureCounts: Map<string, number>;
/** Source line counts per file for complexity scoring. */
sourceLineCounts: Map<string, number>;
}

/**
* Score and sort coverage gaps for the next optimization iteration.
*
* priority = missingDiffLines * 10 + uncoveredPercentage - failedAttempts * 5
*/
export function prioritizeCoverageGaps(
gaps: CoverageGapEntry[],
context: PriorityContext,
): CoverageGapEntry[] {
const scored = gaps.map((gap) => {
const missingLines = gap.uncoveredLines.length;
const failedAttempts = context.failureCounts.get(gap.file) ?? 0;
const lineCount = context.sourceLineCounts.get(gap.file) ?? 0;
const complexityPenalty = lineCount > 500 ? 5 : lineCount > 200 ? 2 : 0;

const priority =
missingLines * 10 +
gap.uncoveredPercentage -
failedAttempts * 5 -
complexityPenalty;

return {
...gap,
complexity: lineCount,
priority,
};
});

return scored.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
}
Loading
Loading