From 2e1cac36b78b823493d772fec821939a3cf84ba5 Mon Sep 17 00:00:00 2001 From: Matt Carvin <90224411+mcarvin8@users.noreply.github.com> Date: Fri, 29 May 2026 09:35:45 -0400 Subject: [PATCH 1/2] feat: add --min-coverage and --max-annotations flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --min-coverage (0–100): fails the command with a non-zero exit code if overall Apex line coverage is below the threshold. Reports are written before the check so the output is always available for inspection. --max-annotations (≥1, default 50): overrides the maximum number of ::warning annotations emitted by the github-actions format. Annotations beyond the cap are summarised in a ::notice line. Exposes the previously hardcoded DEFAULT_MAX_ANNOTATIONS constant as a named export so the command can reference the default in the flag definition. Internal: transformCoverageReport now returns lineRate alongside finalPaths and warnings. processDeployCoverage and processTestCoverage return line totals used to compute the overall rate. ReportRenderOptions threads maxAnnotations from the transformer through reportGenerator into generateGitHubActions. Co-Authored-By: Claude Sonnet 4.6 --- messages/transformer.transform.md | 8 ++ src/commands/acc-transformer/transform.ts | 25 ++++++- src/transformers/coverageTransformer.ts | 73 ++++++++++++++++--- .../generators/generateGitHubActions.ts | 9 ++- src/transformers/reportGenerator.ts | 15 ++-- .../acc-transformer/transform.test.ts | 31 +++++++- test/units/githubActions.test.ts | 21 ++++++ 7 files changed, 155 insertions(+), 27 deletions(-) diff --git a/messages/transformer.transform.md b/messages/transformer.transform.md index 6995729..dbb7067 100644 --- a/messages/transformer.transform.md +++ b/messages/transformer.transform.md @@ -31,3 +31,11 @@ Output format for the coverage report. # flags.ignore-package-directory.summary Ignore a package directory when looking for matching files in the coverage report. + +# flags.min-coverage.summary + +Minimum required line coverage percentage (0–100). The command exits with an error if overall coverage is below this threshold. Reports are still written before the check. + +# flags.max-annotations.summary + +Maximum number of GitHub Actions ::warning annotations to emit when using --format github-actions. Defaults to 50. Annotations beyond this limit are summarised in a ::notice line. diff --git a/src/commands/acc-transformer/transform.ts b/src/commands/acc-transformer/transform.ts index 0f20b23..2ba8424 100644 --- a/src/commands/acc-transformer/transform.ts +++ b/src/commands/acc-transformer/transform.ts @@ -5,6 +5,7 @@ import { Messages } from '@salesforce/core'; import { TransformerTransformResult } from '../../utils/types.js'; import { transformCoverageReport } from '../../transformers/coverageTransformer.js'; import { formatOptions } from '../../utils/constants.js'; +import { DEFAULT_MAX_ANNOTATIONS } from '../../transformers/generators/generateGitHubActions.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('apex-code-coverage-transformer', 'transformer.transform'); @@ -39,6 +40,18 @@ export default class TransformerTransform extends SfCommand { @@ -49,10 +62,15 @@ export default class TransformerTransform extends SfCommand 0) { warnings.forEach((warning) => { @@ -60,7 +78,6 @@ export default class TransformerTransform extends SfCommand { + options?: TransformOptions, +): Promise<{ finalPaths: string[]; warnings: string[]; lineRate: number }> { const warnings: string[] = []; const finalPaths: string[] = []; const formatAmount: number = formats.length; - let filesProcessed = 0; const jsonData = await tryReadJson(jsonFilePath, warnings); - if (!jsonData) return { finalPaths: [outputReportPath], warnings }; + if (!jsonData) return { finalPaths: [outputReportPath], warnings, lineRate: 0 }; const parsedData = JSON.parse(jsonData) as CoverageInput; const { repoRoot, packageDirectories } = await getPackageDirectories(ignoreDirs); @@ -48,27 +56,39 @@ export async function transformCoverageReport( filePathCache, }; + let processResult: ProcessResult; + if (commandType === 'DeployCoverageData') { - filesProcessed = await processDeployCoverage(parsedData as DeployCoverageData, context); + processResult = await processDeployCoverage(parsedData as DeployCoverageData, context); } else if (commandType === 'TestCoverageData') { - filesProcessed = await processTestCoverage(parsedData as TestCoverageData[], context); + processResult = await processTestCoverage(parsedData as TestCoverageData[], context); } else { throw new Error( 'The provided JSON does not match a known coverage data format from the Salesforce deploy or test command.', ); } - if (filesProcessed === 0) { + if (processResult.processed === 0) { warnings.push('None of the files listed in the coverage JSON were processed. The coverage report will be empty.'); } + const lineRate = processResult.totalLines > 0 ? processResult.coveredLines / processResult.totalLines : 0; + + const renderOptions = options?.maxAnnotations !== undefined ? { maxAnnotations: options.maxAnnotations } : undefined; + for (const [format, handler] of handlers.entries()) { const coverageObj = handler.finalize(); - const finalPath = await generateAndWriteReport(outputReportPath, coverageObj, format, formatAmount); + const finalPath = await generateAndWriteReport(outputReportPath, coverageObj, format, formatAmount, renderOptions); finalPaths.push(finalPath); } - return { finalPaths, warnings }; + if (options?.minCoverage !== undefined && lineRate * 100 < options.minCoverage) { + throw new Error( + `Coverage of ${(lineRate * 100).toFixed(2)}% is below the required minimum of ${options.minCoverage}%.`, + ); + } + + return { finalPaths, warnings, lineRate }; } function hasSourceContent( @@ -102,8 +122,20 @@ function createHandlers(formats: string[]): Map { +function countLines(lines: Record): LineTotals { + const totalLines = Object.keys(lines).length; + const coveredLines = Object.values(lines).filter((v) => v === 1).length; + return { totalLines, coveredLines }; +} + +async function processDeployCoverage( + data: DeployCoverageData, + context: CoverageProcessingContext, +): Promise { let processed = 0; + let totalLines = 0; + let coveredLines = 0; + await mapLimit(Object.keys(data), context.concurrencyLimit, async (fileName: string) => { const fileInfo = data[fileName]; const formattedName = fileName.replace(/no-map[\\/]+/, ''); @@ -118,16 +150,28 @@ async function processDeployCoverage(data: DeployCoverageData, context: Coverage const updatedLines = hasSourceContent(setCoveredResult) ? setCoveredResult.updatedLines : setCoveredResult; const sourceContent = hasSourceContent(setCoveredResult) ? setCoveredResult.sourceContent : undefined; fileInfo.s = updatedLines; + + const counts = countLines(updatedLines); + totalLines += counts.totalLines; + coveredLines += counts.coveredLines; + for (const handler of context.handlers.values()) { handler.processFile(path, formattedName, updatedLines, sourceContent); } processed++; }); - return processed; + + return { processed, totalLines, coveredLines }; } -async function processTestCoverage(data: TestCoverageData[], context: CoverageProcessingContext): Promise { +async function processTestCoverage( + data: TestCoverageData[], + context: CoverageProcessingContext, +): Promise { let processed = 0; + let totalLines = 0; + let coveredLines = 0; + await mapLimit(data, context.concurrencyLimit, async (entry: TestCoverageData) => { const formattedName = entry.name.replace(/no-map[\\/]+/, ''); const path = findFilePath(formattedName, context.filePathCache); @@ -137,11 +181,16 @@ async function processTestCoverage(data: TestCoverageData[], context: CoveragePr return; } + const counts = countLines(entry.lines); + totalLines += counts.totalLines; + coveredLines += counts.coveredLines; + const sourceContent = context.handlers.has('html') ? await readSourceFile(join(context.repoRoot, path)) : undefined; for (const handler of context.handlers.values()) { handler.processFile(path, formattedName, entry.lines, sourceContent); } processed++; }); - return processed; + + return { processed, totalLines, coveredLines }; } diff --git a/src/transformers/generators/generateGitHubActions.ts b/src/transformers/generators/generateGitHubActions.ts index 75c045f..a4fe571 100644 --- a/src/transformers/generators/generateGitHubActions.ts +++ b/src/transformers/generators/generateGitHubActions.ts @@ -1,6 +1,6 @@ import { GitHubActionsCoverageObject } from '../../utils/types.js'; -const MAX_ANNOTATIONS = 50; +export const DEFAULT_MAX_ANNOTATIONS = 50; function formatPercent(lineRate: number): string { return `${(lineRate * 100).toFixed(2)}%`; @@ -24,7 +24,10 @@ function escapeData(value: string): string { return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); } -export function generateGitHubActions(coverageObj: GitHubActionsCoverageObject): string { +export function generateGitHubActions( + coverageObj: GitHubActionsCoverageObject, + maxAnnotations = DEFAULT_MAX_ANNOTATIONS, +): string { const { summary, uncoveredLines } = coverageObj; const overall = formatPercent(summary.lineRate); @@ -34,7 +37,7 @@ export function generateGitHubActions(coverageObj: GitHubActionsCoverageObject): summary.fileCount } file${summary.fileCount === 1 ? '' : 's'})`; - const visible = uncoveredLines.slice(0, MAX_ANNOTATIONS); + const visible = uncoveredLines.slice(0, maxAnnotations); const truncated = uncoveredLines.length - visible.length; const annotations = visible.map( diff --git a/src/transformers/reportGenerator.ts b/src/transformers/reportGenerator.ts index b9e6c05..90dc756 100644 --- a/src/transformers/reportGenerator.ts +++ b/src/transformers/reportGenerator.ts @@ -16,6 +16,10 @@ import { generateHtml } from './generators/generateHtml.js'; import { generateMarkdown } from './generators/generateMarkdown.js'; import { generateGitHubActions } from './generators/generateGitHubActions.js'; +export type ReportRenderOptions = { + maxAnnotations?: number; +}; + function isXmlReportFormat(format: string): format is XmlReportFormat { return format in XML_HEADER_CONFIG; } @@ -29,11 +33,11 @@ function isXmlReportFormat(format: string): format is XmlReportFormat { * `generateReportContent` to a single decision point and avoids a long * chain of `format === ... && isXObject(...)` guards. */ -const STRING_FORMAT_RENDERERS: Record string> = { +const STRING_FORMAT_RENDERERS: Record string> = { lcovonly: (obj) => generateLcov(obj as LcovCoverageObject), html: (obj) => generateHtml(obj as HtmlCoverageObject), markdown: (obj) => generateMarkdown(obj as MarkdownCoverageObject), - 'github-actions': (obj) => generateGitHubActions(obj as GitHubActionsCoverageObject), + 'github-actions': (obj, opts) => generateGitHubActions(obj as GitHubActionsCoverageObject, opts?.maxAnnotations), json: (obj) => JSON.stringify(obj, null, 2), 'json-summary': (obj) => JSON.stringify(obj, null, 2), simplecov: (obj) => JSON.stringify(obj, null, 2), @@ -44,8 +48,9 @@ export async function generateAndWriteReport( coverageObj: AnyCoverageObject, format: string, formatAmount: number, + options?: ReportRenderOptions, ): Promise { - const content = generateReportContent(coverageObj, format); + const content = generateReportContent(coverageObj, format, options); const extension = HandlerRegistry.getExtension(format); const base = basename(outputPath, extname(outputPath)); // e.g., 'coverage' @@ -58,9 +63,9 @@ export async function generateAndWriteReport( return filePath; } -function generateReportContent(coverageObj: AnyCoverageObject, format: string): string { +function generateReportContent(coverageObj: AnyCoverageObject, format: string, options?: ReportRenderOptions): string { const renderer = STRING_FORMAT_RENDERERS[format]; - return renderer ? renderer(coverageObj) : generateXmlContent(coverageObj, format); + return renderer ? renderer(coverageObj, options) : generateXmlContent(coverageObj, format); } function generateXmlContent(coverageObj: AnyCoverageObject, format: string): string { diff --git a/test/commands/acc-transformer/transform.test.ts b/test/commands/acc-transformer/transform.test.ts index 6837dda..93305f7 100644 --- a/test/commands/acc-transformer/transform.test.ts +++ b/test/commands/acc-transformer/transform.test.ts @@ -53,7 +53,7 @@ describe('acc-transformer transform unit tests', () => { } catch (error) { if (error instanceof Error) { expect(error.message).toContain( - 'The provided JSON does not match a known coverage data format from the Salesforce deploy or test command.' + 'The provided JSON does not match a known coverage data format from the Salesforce deploy or test command.', ); } else { throw new Error('An unknown error type was thrown.'); @@ -69,7 +69,7 @@ describe('acc-transformer transform unit tests', () => { deployCoverage, 'coverage.xml', ['sonar'], - ['packaged', 'force-app', samplesPackagePath] + ['packaged', 'force-app', samplesPackagePath], ); expect(result.warnings).toContain('The file name AccountTrigger was not found in any package directory.'); }); @@ -78,7 +78,7 @@ describe('acc-transformer transform unit tests', () => { testCoverage, 'coverage.xml', ['sonar'], - ['packaged', samplesPackagePath] + ['packaged', samplesPackagePath], ); expect(result.warnings).toContain('The file name AccountTrigger was not found in any package directory.'); }); @@ -88,6 +88,31 @@ describe('acc-transformer transform unit tests', () => { it('create a jacoco report using only 1 package directory', async () => { await transformCoverageReport(deployCoverage, 'coverage.xml', ['jacoco'], ['packaged', 'force-app']); }); + it('returns the overall line rate in the result', async () => { + const result = await transformCoverageReport(deployCoverage, 'coverage.xml', ['sonar'], [samplesPackagePath]); + expect(result.lineRate).toBeGreaterThanOrEqual(0); + expect(result.lineRate).toBeLessThanOrEqual(1); + }); + it('does not throw when coverage meets the minCoverage threshold', async () => { + await expect( + transformCoverageReport(deployCoverage, 'coverage.xml', ['sonar'], [samplesPackagePath], { minCoverage: 0 }), + ).resolves.toBeDefined(); + }); + it('throws when overall coverage is below the minCoverage threshold', async () => { + await expect( + transformCoverageReport(deployCoverage, 'coverage.xml', ['sonar'], [samplesPackagePath], { minCoverage: 100 }), + ).rejects.toThrow(/below the required minimum of 100%/); + }); + it('passes maxAnnotations through to the github-actions generator', async () => { + const result = await transformCoverageReport( + deployCoverage, + 'coverage.xml', + ['github-actions'], + [samplesPackagePath], + { maxAnnotations: 1 }, + ); + expect(result.finalPaths.length).toBeGreaterThan(0); + }); it('handles source file read failure gracefully when generating HTML from test coverage', async () => { shouldSimulateReadFailureForAccountTrigger = true; try { diff --git a/test/units/githubActions.test.ts b/test/units/githubActions.test.ts index 450f307..9faa3ca 100644 --- a/test/units/githubActions.test.ts +++ b/test/units/githubActions.test.ts @@ -82,6 +82,27 @@ describe('GitHubActionsCoverageHandler unit tests', () => { } }); + it('respects a custom maxAnnotations override passed via generateAndWriteReport options', async () => { + const handler = new GitHubActionsCoverageHandler(); + handler.processFile('force-app/main/default/classes/A.cls', 'A', { '1': 0, '2': 0, '3': 0, '4': 0, '5': 0 }); + const result = handler.finalize(); + + const tmpDir = await mkdtemp(join(tmpdir(), 'gha-override-')); + try { + const outPath = await generateAndWriteReport(join(tmpDir, 'coverage.txt'), result, 'github-actions', 1, { + maxAnnotations: 2, + }); + const content = await readFile(outPath, 'utf-8'); + const warningLines = content.split('\n').filter((l) => l.startsWith('::warning')); + const noticeLines = content.split('\n').filter((l) => l.startsWith('::notice')); + expect(warningLines).toHaveLength(2); + expect(noticeLines).toHaveLength(2); // summary + truncation + expect(noticeLines[1]).toContain('3 additional uncovered line'); + } finally { + await rm(tmpDir, { recursive: true }); + } + }); + it('caps annotations at 50 and emits a truncation notice for the remainder', async () => { const handler = new GitHubActionsCoverageHandler(); // 60 uncovered lines across one file From 8fff03ae088adae00ee174df821c972d30094e32 Mon Sep 17 00:00:00 2001 From: Matt Carvin <90224411+mcarvin8@users.noreply.github.com> Date: Fri, 29 May 2026 09:41:52 -0400 Subject: [PATCH 2/2] chore: suppress sf-plugin/flag-min-max-default for --min-coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --min-coverage is intentionally optional with no default — omitting it disables the threshold check entirely. The rule does not apply here. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/acc-transformer/transform.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/acc-transformer/transform.ts b/src/commands/acc-transformer/transform.ts index 2ba8424..e3589b8 100644 --- a/src/commands/acc-transformer/transform.ts +++ b/src/commands/acc-transformer/transform.ts @@ -40,6 +40,7 @@ export default class TransformerTransform extends SfCommand