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
1 change: 1 addition & 0 deletions coverage-service/lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export * from './providers/anthropic-provider';
export * from './providers/local-llm-provider';
export * from './prompts/test-generation';
export * from './test-paths';
export * from './prepare-test-generation';
export * from './analysis/code-analyzer';

import { AnthropicProvider } from './providers/anthropic-provider';
Expand Down
54 changes: 54 additions & 0 deletions coverage-service/lib/src/prepare-test-generation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { RepositoryProvider } from './providers/repository-provider';
import { resolveTestFileTarget } from './test-paths';

export interface PreparedTestFileContext {
existingTestPaths: string[];
existingTests: string;
testOutputPath: string;
isUpdatingExistingTest: boolean;
}

/**
* Discover existing tests for a source file and decide whether to update
* an existing test file or create a new one at the inferred path.
*/
export async function prepareTestFileContext(
repoProvider: RepositoryProvider,
repoDir: string,
sourceFile: string,
framework: string,
): Promise<PreparedTestFileContext> {
const existingTestPaths = await repoProvider.findExistingTests(
repoDir,
sourceFile,
framework,
);

const { testOutputPath, isUpdatingExistingTest } = resolveTestFileTarget(
existingTestPaths,
sourceFile,
framework,
);

const pathsToRead = isUpdatingExistingTest
? [testOutputPath]
: existingTestPaths;

const parts = await Promise.all(
pathsToRead.map((p) => repoProvider.getFileContent(repoDir, p)),
);
const existingTests =
parts
.map((content, i) =>
content.length > 0 ? `// ${pathsToRead[i]}\n${content}` : '',
)
.filter(Boolean)
.join('\n\n---\n\n') || '(none found)';

return {
existingTestPaths,
existingTests,
testOutputPath,
isUpdatingExistingTest,
};
}
42 changes: 37 additions & 5 deletions coverage-service/lib/src/prompts/test-generation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { inferJavaScriptTestFilePath, inferSourceImportPath, sourceFileExtension } from '../test-paths';
import { inferJavaScriptTestFilePath, inferSourceImportPath, inferTestFilePath, sourceFileExtension } from '../test-paths';

Check failure on line 1 in coverage-service/lib/src/prompts/test-generation.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (22)

'inferJavaScriptTestFilePath' is defined but never used. Allowed unused vars must match /^_/u

Check failure on line 1 in coverage-service/lib/src/prompts/test-generation.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (20)

'inferJavaScriptTestFilePath' is defined but never used. Allowed unused vars must match /^_/u
import type { TestGenerationContext } from '../types';

function formatRepoPackages(packages: string[]): string {
Expand Down Expand Up @@ -28,12 +28,43 @@
return ctx.exportedSymbols.map((s) => `- ${s}`).join('\n');
}

function outputTestPath(ctx: TestGenerationContext): string {
return ctx.testOutputPath ?? inferTestFilePath(ctx.file, ctx.framework);
}

function formatTestCountGuidance(ctx: TestGenerationContext): string {
const exports = ctx.exportedSymbols ?? [];
if (exports.length > 4) {
return `- Write focused tests covering **each exported symbol** (${exports.length} total: ${exports.join(', ')}). At least one test per symbol, especially for uncovered lines.`;
}
if (exports.length > 0) {
return `- Write 2–${Math.max(4, exports.length)} focused tests covering exported symbols (${exports.join(', ')}). At least one behavior per symbol when uncovered.`;
}
return '- Write 2–4 short, focused tests. One behavior per test.';
}

function formatTestOutputMode(ctx: TestGenerationContext): string {
const path = outputTestPath(ctx);
const countGuidance = formatTestCountGuidance(ctx);
if (ctx.isUpdatingExistingTest) {
return `## Mode: UPDATE existing test file
- Output path: \`${path}\` (this file already exists — do NOT create a new file elsewhere).
- Keep all existing tests that still apply; do not remove or rewrite passing tests unnecessarily.
${countGuidance}
- Do not duplicate test names or behaviors already present in Existing Tests.
- Return the **complete updated test file** (existing + new tests).`;
}
return `## Mode: CREATE new test file
- Output path: \`${path}\`
${countGuidance}`;
}

function buildPythonPrompt(ctx: TestGenerationContext, symbols: string): string {
return `You are writing Python unit tests for ONE production file. Tests must pass on first run with no manual fixes.

## Scope
- Generate ONLY the complete test file content. No markdown, no explanations, no extra files unless required for imports to work.
- Write 2–4 short, focused tests. One behavior per test.
${formatTestOutputMode(ctx)}
- Test the file: ${ctx.file}

${formatFullSourceHeader(ctx)}## Context
Expand Down Expand Up @@ -134,7 +165,7 @@
}

function buildJavaScriptPrompt(ctx: TestGenerationContext, symbols: string): string {
const testFile = inferJavaScriptTestFilePath(ctx.file);
const testFile = outputTestPath(ctx);
const importPath = inferSourceImportPath(testFile, ctx.file);
const importExt = sourceFileExtension(ctx.file);
const repoHasJest = ctx.repoPackages.some((p) => normalizePackageName(p) === 'jest');
Expand All @@ -156,9 +187,8 @@

## Scope
- Generate ONLY the complete test file content. No markdown, no explanations.
- Write 2–4 short, focused tests. One behavior per test.
${formatTestOutputMode(ctx)}
- Production file: ${ctx.file}
- Save tests to: ${testFile}

${formatFullSourceHeader(ctx)}## Context

Expand Down Expand Up @@ -290,6 +320,7 @@
return (
'You write simple, correct JavaScript unit tests that pass on first run. ' +
'Prefer node:test and node:assert unless the repo already uses Jest/Vitest. ' +
'When updating an existing test file, preserve working tests and append new cases for uncovered lines. ' +
'Use correct relative ESM import paths with the source file extension (.js, .jsx, .ts, .tsx). ' +
'Only import symbols listed as exported; never import private functions. ' +
'For CLI files with no exports, test via child_process.spawnSync instead of importing. ' +
Expand All @@ -300,6 +331,7 @@

return (
'You write simple, correct unit tests that pass on first run. Mock all external I/O. ' +
'When updating an existing test file, preserve working tests and append new cases for uncovered lines. ' +
'Use exact types and defaults from source models. Return only runnable test file code. ' +
'Declare minimal extra test-only packages on the first line as # test-deps: pkg1, pkg2'
);
Expand Down
6 changes: 5 additions & 1 deletion coverage-service/lib/src/providers/anthropic-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import type { GeneratedTest, TestGenerationContext } from '../types';

import type { TestGenerationProvider } from './test-generation-provider';

function outputPathFor(context: TestGenerationContext): string {
return context.testOutputPath ?? inferTestFilePath(context.file, context.framework);
}

export interface AnthropicProviderConfig {
apiKey: string;
model?: string;
Expand Down Expand Up @@ -43,7 +47,7 @@ export class AnthropicProvider implements TestGenerationProvider {
const content = raw.replace(/^```[\w]*\n?/gm, '').replace(/```$/gm, '').trim();

return {
filePath: inferTestFilePath(context.file, context.framework),
filePath: outputPathFor(context),
content,
targetFile: context.file,
};
Expand Down
81 changes: 39 additions & 42 deletions coverage-service/lib/src/providers/github-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { Octokit } from '@octokit/rest';

import type { ChangedFile } from '../types';
import { orderedTestFileCandidates } from '../test-paths';

Check failure on line 10 in coverage-service/lib/src/providers/github-provider.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (22)

`../test-paths` import should occur before type import of `../types`

Check failure on line 10 in coverage-service/lib/src/providers/github-provider.ts

View workflow job for this annotation

GitHub Actions / lint-typecheck-test (20)

`../test-paths` import should occur before type import of `../types`

import type {
CheckoutPROptions,
Expand Down Expand Up @@ -261,59 +262,55 @@
async findExistingTests(
repoDir: string,
sourceFile: string,
framework = 'pytest',
): Promise<string[]> {
const { readdir } = await import('fs/promises');
const baseName = sourceFile.replace(/\.[^.]+$/, '');
const dir = join(repoDir, sourceFile.split('/').slice(0, -1).join('/'));
const patterns = [
`${baseName}.test.ts`,
`${baseName}.spec.ts`,
`${baseName}.test.js`,
`${baseName}.spec.js`,
`test_${baseName.split('/').pop()}.py`,
];

const results: string[] = [];
for (const pattern of patterns) {
const full = join(dir, pattern.split('/').pop()!);
if (existsSync(full)) results.push(full);
}
const found = new Set<string>();
const add = (rel: string) => found.add(rel.replace(/\\/g, '/'));

try {
const entries = await readdir(join(repoDir, 'test'), { recursive: true });
for (const entry of entries) {
const name = String(entry);
if (
name.includes(baseName.split('/').pop()!) &&
(name.endsWith('.test.ts') ||
name.endsWith('.spec.ts') ||
name.startsWith('test_'))
) {
results.push(join(repoDir, 'test', name));
}
for (const candidate of orderedTestFileCandidates(sourceFile, framework)) {
const full = join(repoDir, candidate);
if (existsSync(full)) {
add(candidate);
}
} catch {
// no test directory
}

try {
const entries = await readdir(join(repoDir, 'tests'), { recursive: true });
for (const entry of entries) {
const name = String(entry);
if (
name.includes(baseName.split('/').pop()!) &&
(name.endsWith('.test.ts') ||
name.endsWith('.spec.ts') ||
name.startsWith('test_'))
) {
results.push(join(repoDir, 'tests', name));
const baseName = sourceFile.split('/').pop()!.replace(/\.[^.]+$/, '');

for (const testRoot of ['tests', 'test', 'src'] as const) {
try {
const entries = await readdir(join(repoDir, testRoot), {
recursive: true,
});
for (const entry of entries) {
const name = String(entry);
if (
!name.includes(baseName) ||
!(
name.endsWith('.test.ts') ||
name.endsWith('.test.tsx') ||
name.endsWith('.test.js') ||
name.endsWith('.test.jsx') ||
name.endsWith('.spec.ts') ||
name.endsWith('.spec.js') ||
name.startsWith('test_')
)
) {
continue;
}
const rel = `${testRoot}/${name}`.replace(/\\/g, '/');
const full = join(repoDir, rel);
if (existsSync(full)) {
add(rel);
}
}
} catch {
// no directory
}
} catch {
// no tests directory
}

return results;
return [...found];
}

private async runGit(args: string[], cwd?: string): Promise<void> {
Expand Down
6 changes: 5 additions & 1 deletion coverage-service/lib/src/providers/local-llm-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import type { GeneratedTest, TestGenerationContext } from '../types';

import type { TestGenerationProvider } from './test-generation-provider';

function outputPathFor(context: TestGenerationContext): string {
return context.testOutputPath ?? inferTestFilePath(context.file, context.framework);
}

export interface LocalLLMProviderConfig {
baseUrl?: string;
model?: string;
Expand Down Expand Up @@ -41,7 +45,7 @@ export class LocalLLMProvider implements TestGenerationProvider {
.trim();

return {
filePath: inferTestFilePath(context.file, context.framework),
filePath: outputPathFor(context),
content,
targetFile: context.file,
};
Expand Down
6 changes: 5 additions & 1 deletion coverage-service/lib/src/providers/openai-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import type { GeneratedTest, TestGenerationContext } from '../types';

import type { TestGenerationProvider } from './test-generation-provider';

function outputPathFor(context: TestGenerationContext): string {
return context.testOutputPath ?? inferTestFilePath(context.file, context.framework);
}

export interface OpenAIProviderConfig {
apiKey: string;
model?: string;
Expand Down Expand Up @@ -50,7 +54,7 @@ export class OpenAIProvider implements TestGenerationProvider {
const content = this.stripMarkdown(data.choices[0]?.message?.content ?? '');

return {
filePath: inferTestFilePath(context.file, context.framework),
filePath: outputPathFor(context),
content,
targetFile: context.file,
};
Expand Down
6 changes: 5 additions & 1 deletion coverage-service/lib/src/providers/repository-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,9 @@ export interface RepositoryProvider {
headBranch: string,
): Promise<ChangedFile[]>;
getFileContent(repoDir: string, filePath: string): Promise<string>;
findExistingTests(repoDir: string, sourceFile: string): Promise<string[]>;
findExistingTests(
repoDir: string,
sourceFile: string,
framework?: string,
): Promise<string[]>;
}
Loading
Loading