@@ -431,8 +431,7 @@ function escapeWorkflowValue(value) {
431431 . replaceAll ( "," , "%2C" )
432432}
433433
434- export function formatAnnotations ( blockingMutants , packageRoot ) {
435- const perFile = new Map ( )
434+ export function formatAnnotations ( blockingMutants , packageRoot , state = { total : 0 , perFile : new Map ( ) } ) {
436435 const annotations = [ ]
437436
438437 for ( const mutant of blockingMutants . sort ( ( left , right ) => {
@@ -441,9 +440,8 @@ export function formatAnnotations(blockingMutants, packageRoot) {
441440 } ) ) {
442441 const repositoryPath = path . posix . join ( packageRoot , mutant . filePath . replaceAll ( "\\" , "/" ) )
443442 const key = `${ repositoryPath } :${ mutant . location . start . line } `
444- const fileCount = perFile . get ( repositoryPath ) ?? 0
445- if ( annotations . some ( ( annotation ) => annotation . key === key ) || fileCount >= 7 || annotations . length >= 20 )
446- continue
443+ const fileCount = state . perFile . get ( repositoryPath ) ?? 0
444+ if ( annotations . some ( ( annotation ) => annotation . key === key ) || fileCount >= 7 || state . total >= 20 ) continue
447445
448446 const replacement = String ( mutant . replacement ?? "" )
449447 . replace ( / \s + / g, " " )
@@ -455,17 +453,59 @@ export function formatAnnotations(blockingMutants, packageRoot) {
455453 line : mutant . location . start . line ,
456454 message :
457455 `${ mutant . status } ${ mutant . mutatorName } mutant${ replacement ? ` (replacement: ${ replacement } )` : "" } . ` +
458- "Add or strengthen a focused test that fails under this mutation, or add a maintainer-approved targeted exclusion with a reason ." ,
456+ "See the job summary for the complete list and resolution guidance ." ,
459457 } )
460- perFile . set ( repositoryPath , fileCount + 1 )
458+ state . perFile . set ( repositoryPath , fileCount + 1 )
459+ state . total ++
461460 }
462461
463462 return annotations
464463}
465464
466- function appendSummary ( rows , failures ) {
467- if ( ! process . env . GITHUB_STEP_SUMMARY ) return
465+ function markdownCell ( value ) {
466+ return String ( value ?? "—" )
467+ . replace ( / \s + / g, " " )
468+ . trim ( )
469+ . replaceAll ( "|" , "\\|" )
470+ . slice ( 0 , 120 )
471+ }
472+
473+ export function testsFromMutationReport ( report , fallback = [ ] ) {
474+ const testFiles = Object . keys ( report . testFiles ?? { } )
475+ return testFiles . length > 0 ? testFiles : fallback
476+ }
477+
478+ export function formatBlockingMutants ( blockingMutants , packageRoot ) {
479+ const grouped = new Map ( )
480+ for ( const mutant of [ ...blockingMutants ] . sort ( ( left , right ) => {
481+ const pathOrder = left . filePath . localeCompare ( right . filePath )
482+ return pathOrder || left . location . start . line - right . location . start . line
483+ } ) ) {
484+ const repositoryPath = path . posix . join ( packageRoot , mutant . filePath . replaceAll ( "\\" , "/" ) )
485+ const group = grouped . get ( repositoryPath ) ?? [ ]
486+ group . push ( mutant )
487+ grouped . set ( repositoryPath , group )
488+ }
468489
490+ const lines = [ ]
491+ for ( const [ filePath , mutants ] of grouped ) {
492+ lines . push (
493+ `#### \`${ filePath } \`` ,
494+ "" ,
495+ "| Line | Status | Mutator | Replacement |" ,
496+ "| ---: | --- | --- | --- |" ,
497+ )
498+ for ( const mutant of mutants ) {
499+ lines . push (
500+ `| ${ mutant . location . start . line } | ${ markdownCell ( mutant . status ) } | ${ markdownCell ( mutant . mutatorName ) } | ${ markdownCell ( mutant . replacement ) } |` ,
501+ )
502+ }
503+ lines . push ( "" )
504+ }
505+ return lines
506+ }
507+
508+ export function formatSummary ( rows , failures , manifest = { } ) {
469509 const lines = [
470510 "## Changed-code mutation testing" ,
471511 "" ,
@@ -478,8 +518,89 @@ function appendSummary(rows, failures) {
478518 )
479519 }
480520 if ( rows . length === 0 ) lines . push ( "| — | 0 | 0 | 0 | 0 | 0 | 0 | Not applicable |" )
481- if ( failures . length > 0 ) lines . push ( "" , ...failures . map ( ( failure ) => `- ${ failure } ` ) )
482- fs . appendFileSync ( process . env . GITHUB_STEP_SUMMARY , `${ lines . join ( "\n" ) } \n` )
521+
522+ if ( rows . length > 0 ) {
523+ lines . push ( "" , "### Focused tests" )
524+ for ( const row of rows ) {
525+ const cwd = row . runRoot ?? row . root
526+ if ( row . testFiles ?. length > 0 ) {
527+ lines . push ( `- **${ row . id } ** (cwd \`${ cwd } \`): ${ row . testFiles . map ( ( file ) => `\`${ file } \`` ) . join ( ", " ) } ` )
528+ } else {
529+ lines . push (
530+ `- **${ row . id } ** (cwd \`${ cwd } \`): the mutation run did not complete far enough to report its selected tests; use the exact reproduction command below.` ,
531+ )
532+ }
533+ }
534+ }
535+
536+ const blockingRows = rows . filter ( ( row ) => row . blocking ?. length > 0 )
537+ if ( blockingRows . length > 0 ) {
538+ lines . push (
539+ "" ,
540+ "### All surviving and uncovered mutants" ,
541+ "" ,
542+ "Annotations highlight up to 20 unique locations (maximum 7 per file). This summary lists every blocking mutant." ,
543+ "" ,
544+ )
545+ for ( const row of blockingRows ) {
546+ lines . push ( `### ${ row . id } ` , "" , ...formatBlockingMutants ( row . blocking , row . runRoot ?? row . root ) )
547+ }
548+ lines . push (
549+ "### Resolve a mutation gap" ,
550+ "" ,
551+ "Add or strengthen a focused test that fails under the mutation. If the mutant is equivalent, request maintainer approval for the narrowest mutator-specific exclusion and explain why it cannot change behavior:" ,
552+ "" ,
553+ "```ts" ,
554+ "// Stryker disable next-line ConditionalExpression: normalized input cannot reach the alternate branch" ,
555+ "const result = condition ? value : fallback" ,
556+ "```" ,
557+ "" ,
558+ "Broad `all` exclusions and exclusions without a concrete reason are rejected by the gate." ,
559+ )
560+ }
561+
562+ if ( manifest . baseSha && manifest . headSha ) {
563+ lines . push (
564+ "" ,
565+ "### Reproduce locally" ,
566+ "" ,
567+ "From a full checkout containing both commits:" ,
568+ "" ,
569+ "```bash" ,
570+ "pnpm install --frozen-lockfile" ,
571+ `node scripts/stryker-diff.mjs ci --base ${ manifest . baseSha } --head ${ manifest . headSha } ` ,
572+ "```" ,
573+ )
574+ }
575+
576+ if ( rows . length > 0 ) {
577+ lines . push ( "" , "### Mutation reports" , "" )
578+ for ( const row of rows ) lines . push ( `- **${ row . id } :** \`${ row . reportPath } \`` )
579+ lines . push (
580+ "" ,
581+ "The workflow uploads generated reports in the `changed-code-mutation-report` artifact. A direct artifact link appears below after upload." ,
582+ )
583+ }
584+
585+ if ( failures . length > 0 ) {
586+ lines . push (
587+ "" ,
588+ "### Failures" ,
589+ "" ,
590+ ...failures . map ( ( failure ) => {
591+ const detail =
592+ failure . length > 4_000 ? `${ failure . slice ( 0 , 4_000 ) } \n[truncated; see the step log]` : failure
593+ return `- ${ detail . replaceAll ( "\n" , "\n " ) } `
594+ } ) ,
595+ )
596+ }
597+
598+ return `${ lines . join ( "\n" ) } \n`
599+ }
600+
601+ function appendSummary ( rows , failures , manifest ) {
602+ if ( ! process . env . GITHUB_STEP_SUMMARY ) return
603+ fs . appendFileSync ( process . env . GITHUB_STEP_SUMMARY , formatSummary ( rows , failures , manifest ) )
483604}
484605
485606export function evaluateReport ( report , packageEntry ) {
@@ -508,9 +629,13 @@ export function evaluateReport(report, packageEntry) {
508629export function runManifest ( repoRoot , manifest , reportRoot ) {
509630 const rows = [ ]
510631 const failures = [ ]
632+ const annotationState = { total : 0 , perFile : new Map ( ) }
511633
512634 for ( const packageEntry of manifest . packages ) {
513635 let counts
636+ const reportPath = path
637+ . relative ( repoRoot , path . join ( reportRoot , packageEntry . id , "mutation.html" ) )
638+ . replaceAll ( "\\" , "/" )
514639 try {
515640 const reportDirectory = path . join ( reportRoot , packageEntry . id )
516641 fs . mkdirSync ( reportDirectory , { recursive : true } )
@@ -531,6 +656,11 @@ export function runManifest(repoRoot, manifest, reportRoot) {
531656 if ( generatedMutants === 0 ) {
532657 rows . push ( {
533658 id : packageEntry . id ,
659+ root : packageEntry . root ,
660+ runRoot : packageEntry . runRoot ,
661+ selectors : packageEntry . selectors ,
662+ testFiles : packageEntry . testFiles ?? [ ] ,
663+ reportPath,
534664 changedLines : packageEntry . changedExecutableLines ,
535665 valid : 0 ,
536666 killed : 0 ,
@@ -543,17 +673,27 @@ export function runManifest(repoRoot, manifest, reportRoot) {
543673 }
544674
545675 runStryker ( repoRoot , packageEntry , reportRoot , false )
546- const reportPath = path . join ( reportRoot , packageEntry . id , "mutation.json" )
547- const report = JSON . parse ( fs . readFileSync ( reportPath , "utf8" ) )
676+ const jsonReportPath = path . join ( reportRoot , packageEntry . id , "mutation.json" )
677+ const report = JSON . parse ( fs . readFileSync ( jsonReportPath , "utf8" ) )
678+ packageEntry . testFiles = testsFromMutationReport ( report , packageEntry . testFiles )
548679 counts = mutantCounts ( report )
549- for ( const annotation of formatAnnotations ( counts . blocking , packageEntry . runRoot ?? packageEntry . root ) ) {
680+ for ( const annotation of formatAnnotations (
681+ counts . blocking ,
682+ packageEntry . runRoot ?? packageEntry . root ,
683+ annotationState ,
684+ ) ) {
550685 console . log (
551686 `::error file=${ escapeWorkflowValue ( annotation . file ) } ,line=${ annotation . line } ,title=Mutation test gap::${ escapeWorkflowValue ( annotation . message ) } ` ,
552687 )
553688 }
554689 evaluateReport ( report , packageEntry )
555690 rows . push ( {
556691 id : packageEntry . id ,
692+ root : packageEntry . root ,
693+ runRoot : packageEntry . runRoot ,
694+ selectors : packageEntry . selectors ,
695+ testFiles : packageEntry . testFiles ?? [ ] ,
696+ reportPath,
557697 changedLines : packageEntry . changedExecutableLines ,
558698 ...counts ,
559699 result : "Passed" ,
@@ -562,18 +702,24 @@ export function runManifest(repoRoot, manifest, reportRoot) {
562702 failures . push ( error . message )
563703 rows . push ( {
564704 id : packageEntry . id ,
705+ root : packageEntry . root ,
706+ runRoot : packageEntry . runRoot ,
707+ selectors : packageEntry . selectors ,
708+ testFiles : packageEntry . testFiles ?? [ ] ,
709+ reportPath,
565710 changedLines : packageEntry . changedExecutableLines ,
566711 valid : counts ?. valid ?? 0 ,
567712 killed : counts ?. killed ?? 0 ,
568713 timeout : counts ?. timeout ?? 0 ,
569714 survived : counts ?. survived ?? 0 ,
570715 noCoverage : counts ?. noCoverage ?? 0 ,
716+ blocking : counts ?. blocking ?? [ ] ,
571717 result : "Failed" ,
572718 } )
573719 }
574720 }
575721
576- appendSummary ( rows , failures )
722+ appendSummary ( rows , failures , manifest )
577723 if ( failures . length > 0 ) throw new Error ( failures . join ( "\n" ) )
578724 return rows
579725}
@@ -596,7 +742,7 @@ function main() {
596742 const reportRoot = path . resolve ( repoRoot , argument ( "--reports" ) ?? "reports/mutation" )
597743 const manifest = selectFromGit ( repoRoot , baseSha , headSha )
598744 if ( manifest . packages . length === 0 ) {
599- appendSummary ( [ ] , [ ] )
745+ appendSummary ( [ ] , [ ] , manifest )
600746 console . log ( "No changed executable lines in mutation-tested packages; mutation testing is not applicable." )
601747 return
602748 }
0 commit comments