@@ -304,14 +304,23 @@ export function preferDirectTestFiles(testFiles, sourceFiles) {
304304 return direct . length > 0 ? direct : testFiles
305305}
306306
307- function discoverRelatedTestFiles ( repoRoot , packageEntry , reportDirectory ) {
307+ export function resolveVitestBinary ( repoRoot , packageEntry ) {
308+ const packageRoot = path . join ( repoRoot , packageEntry . root )
309+ const runRoot = path . join ( repoRoot , packageEntry . runRoot ?? packageEntry . root )
310+ const candidates = [ ...new Set ( [ runRoot , packageRoot , repoRoot ] ) ] . map ( ( root ) =>
311+ path . join ( root , "node_modules/.bin/vitest" ) ,
312+ )
313+ return candidates . find ( ( candidate ) => fs . existsSync ( candidate ) ) ?? candidates . at ( - 1 )
314+ }
315+
316+ export function discoverRelatedTestFiles ( repoRoot , packageEntry , reportDirectory ) {
308317 const packageRoot = path . join ( repoRoot , packageEntry . root )
309318 const runRoot = path . join ( repoRoot , packageEntry . runRoot ?? packageEntry . root )
310319 const outputFile = path . join ( reportDirectory , "vitest-related.json" )
311320 const configFile = path . relative ( runRoot , path . join ( packageRoot , packageEntry . vitestConfig ) ) . replaceAll ( "\\" , "/" )
312321 const sourceFiles = [ ...new Set ( packageEntry . selectors . map ( selectorFile ) ) ]
313322 const result = spawnSync (
314- path . join ( repoRoot , "node_modules/.bin/vitest" ) ,
323+ resolveVitestBinary ( repoRoot , packageEntry ) ,
315324 [ "related" , ...sourceFiles , "--run" , "--config" , configFile , "--reporter=json" , `--outputFile=${ outputFile } ` ] ,
316325 {
317326 cwd : runRoot ,
@@ -325,6 +334,9 @@ function discoverRelatedTestFiles(repoRoot, packageEntry, reportDirectory) {
325334 if ( result . error ?. code === "ETIMEDOUT" ) {
326335 throw new Error ( `${ packageEntry . id } related-test discovery exceeded 5 minutes` )
327336 }
337+ if ( result . error ) {
338+ throw new Error ( `${ packageEntry . id } related-test discovery could not start: ${ result . error . message } ` )
339+ }
328340 if ( result . status !== 0 ) {
329341 throw new Error (
330342 `${ packageEntry . id } related-test discovery failed:\n${ stripAnsi ( `${ result . stdout ?? "" } ${ result . stderr ?? "" } ` ) . trim ( ) } ` ,
@@ -378,6 +390,11 @@ function runStryker(repoRoot, packageEntry, reportRoot, dryRunOnly) {
378390 `${ packageEntry . id } mutation run exceeded 12 minutes. Split the PR or obtain a maintainer-reviewed narrow exclusion.` ,
379391 )
380392 }
393+ if ( result . error ) {
394+ throw new Error (
395+ `${ packageEntry . id } Stryker ${ dryRunOnly ? "preflight" : "run" } could not start: ${ result . error . message } ` ,
396+ )
397+ }
381398 if ( result . status !== 0 ) {
382399 throw new Error (
383400 `${ packageEntry . id } Stryker ${ dryRunOnly ? "preflight" : "run" } failed:\n${ stripAnsi ( output ) . trim ( ) } ` ,
@@ -405,17 +422,19 @@ export function mutantCounts(report) {
405422 return counts
406423}
407424
408- function escapeWorkflowValue ( value ) {
409- return String ( value )
410- . replaceAll ( "%" , "%25" )
411- . replaceAll ( "\r" , "%0D" )
412- . replaceAll ( "\n" , "%0A" )
413- . replaceAll ( ":" , "%3A" )
414- . replaceAll ( "," , "%2C" )
425+ function escapeWorkflowData ( value ) {
426+ return String ( value ) . replaceAll ( "%" , "%25" ) . replaceAll ( "\r" , "%0D" ) . replaceAll ( "\n" , "%0A" )
427+ }
428+
429+ function escapeWorkflowProperty ( value ) {
430+ return escapeWorkflowData ( value ) . replaceAll ( ":" , "%3A" ) . replaceAll ( "," , "%2C" )
415431}
416432
417- export function formatAnnotations ( blockingMutants , packageRoot ) {
418- const perFile = new Map ( )
433+ export function formatAnnotationCommand ( annotation ) {
434+ return `::error file=${ escapeWorkflowProperty ( annotation . file ) } ,line=${ annotation . line } ,title=Mutation test gap::${ escapeWorkflowData ( annotation . message ) } `
435+ }
436+
437+ export function formatAnnotations ( blockingMutants , packageRoot , state = { total : 0 , perFile : new Map ( ) } ) {
419438 const annotations = [ ]
420439
421440 for ( const mutant of blockingMutants . sort ( ( left , right ) => {
@@ -424,9 +443,8 @@ export function formatAnnotations(blockingMutants, packageRoot) {
424443 } ) ) {
425444 const repositoryPath = path . posix . join ( packageRoot , mutant . filePath . replaceAll ( "\\" , "/" ) )
426445 const key = `${ repositoryPath } :${ mutant . location . start . line } `
427- const fileCount = perFile . get ( repositoryPath ) ?? 0
428- if ( annotations . some ( ( annotation ) => annotation . key === key ) || fileCount >= 7 || annotations . length >= 20 )
429- continue
446+ const fileCount = state . perFile . get ( repositoryPath ) ?? 0
447+ if ( annotations . some ( ( annotation ) => annotation . key === key ) || fileCount >= 7 || state . total >= 20 ) continue
430448
431449 const replacement = String ( mutant . replacement ?? "" )
432450 . replace ( / \s + / g, " " )
@@ -438,17 +456,59 @@ export function formatAnnotations(blockingMutants, packageRoot) {
438456 line : mutant . location . start . line ,
439457 message :
440458 `${ mutant . status } ${ mutant . mutatorName } mutant${ replacement ? ` (replacement: ${ replacement } )` : "" } . ` +
441- "Add or strengthen a focused test that fails under this mutation, or add a maintainer-approved targeted exclusion with a reason ." ,
459+ "See the job summary for the complete list and resolution guidance ." ,
442460 } )
443- perFile . set ( repositoryPath , fileCount + 1 )
461+ state . perFile . set ( repositoryPath , fileCount + 1 )
462+ state . total ++
444463 }
445464
446465 return annotations
447466}
448467
449- function appendSummary ( rows , failures ) {
450- if ( ! process . env . GITHUB_STEP_SUMMARY ) return
468+ function markdownCell ( value ) {
469+ return String ( value ?? "—" )
470+ . replace ( / \s + / g, " " )
471+ . trim ( )
472+ . replaceAll ( "|" , "\\|" )
473+ . slice ( 0 , 120 )
474+ }
475+
476+ export function testsFromMutationReport ( report , fallback = [ ] ) {
477+ const testFiles = Object . keys ( report . testFiles ?? { } )
478+ return testFiles . length > 0 ? testFiles : fallback
479+ }
480+
481+ export function formatBlockingMutants ( blockingMutants , packageRoot ) {
482+ const grouped = new Map ( )
483+ for ( const mutant of [ ...blockingMutants ] . sort ( ( left , right ) => {
484+ const pathOrder = left . filePath . localeCompare ( right . filePath )
485+ return pathOrder || left . location . start . line - right . location . start . line
486+ } ) ) {
487+ const repositoryPath = path . posix . join ( packageRoot , mutant . filePath . replaceAll ( "\\" , "/" ) )
488+ const group = grouped . get ( repositoryPath ) ?? [ ]
489+ group . push ( mutant )
490+ grouped . set ( repositoryPath , group )
491+ }
492+
493+ const lines = [ ]
494+ for ( const [ filePath , mutants ] of grouped ) {
495+ lines . push (
496+ `#### \`${ filePath } \`` ,
497+ "" ,
498+ "| Line | Status | Mutator | Replacement |" ,
499+ "| ---: | --- | --- | --- |" ,
500+ )
501+ for ( const mutant of mutants ) {
502+ lines . push (
503+ `| ${ mutant . location . start . line } | ${ markdownCell ( mutant . status ) } | ${ markdownCell ( mutant . mutatorName ) } | ${ markdownCell ( mutant . replacement ) } |` ,
504+ )
505+ }
506+ lines . push ( "" )
507+ }
508+ return lines
509+ }
451510
511+ export function formatSummary ( rows , failures , manifest = { } ) {
452512 const lines = [
453513 "## Changed-code mutation testing" ,
454514 "" ,
@@ -461,8 +521,89 @@ function appendSummary(rows, failures) {
461521 )
462522 }
463523 if ( rows . length === 0 ) lines . push ( "| — | 0 | 0 | 0 | 0 | 0 | 0 | Not applicable |" )
464- if ( failures . length > 0 ) lines . push ( "" , ...failures . map ( ( failure ) => `- ${ failure } ` ) )
465- fs . appendFileSync ( process . env . GITHUB_STEP_SUMMARY , `${ lines . join ( "\n" ) } \n` )
524+
525+ if ( rows . length > 0 ) {
526+ lines . push ( "" , "### Focused tests" )
527+ for ( const row of rows ) {
528+ const cwd = row . runRoot ?? row . root
529+ if ( row . testFiles ?. length > 0 ) {
530+ lines . push ( `- **${ row . id } ** (cwd \`${ cwd } \`): ${ row . testFiles . map ( ( file ) => `\`${ file } \`` ) . join ( ", " ) } ` )
531+ } else {
532+ lines . push (
533+ `- **${ row . id } ** (cwd \`${ cwd } \`): the mutation run did not complete far enough to report its selected tests; use the exact reproduction command below.` ,
534+ )
535+ }
536+ }
537+ }
538+
539+ const blockingRows = rows . filter ( ( row ) => row . blocking ?. length > 0 )
540+ if ( blockingRows . length > 0 ) {
541+ lines . push (
542+ "" ,
543+ "### All surviving and uncovered mutants" ,
544+ "" ,
545+ "Annotations highlight up to 20 unique locations (maximum 7 per file). This summary lists every blocking mutant." ,
546+ "" ,
547+ )
548+ for ( const row of blockingRows ) {
549+ lines . push ( `### ${ row . id } ` , "" , ...formatBlockingMutants ( row . blocking , row . runRoot ?? row . root ) )
550+ }
551+ lines . push (
552+ "### Resolve a mutation gap" ,
553+ "" ,
554+ "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:" ,
555+ "" ,
556+ "```ts" ,
557+ "// Stryker disable next-line ConditionalExpression: normalized input cannot reach the alternate branch" ,
558+ "const result = condition ? value : fallback" ,
559+ "```" ,
560+ "" ,
561+ "Broad `all` exclusions and exclusions without a concrete reason are rejected by the gate." ,
562+ )
563+ }
564+
565+ if ( manifest . baseSha && manifest . headSha ) {
566+ lines . push (
567+ "" ,
568+ "### Reproduce locally" ,
569+ "" ,
570+ "From a full checkout containing both commits:" ,
571+ "" ,
572+ "```bash" ,
573+ "pnpm install --frozen-lockfile" ,
574+ `node scripts/stryker-diff.mjs ci --base ${ manifest . baseSha } --head ${ manifest . headSha } ` ,
575+ "```" ,
576+ )
577+ }
578+
579+ if ( rows . length > 0 ) {
580+ lines . push ( "" , "### Mutation reports" , "" )
581+ for ( const row of rows ) lines . push ( `- **${ row . id } :** \`${ row . reportPath } \`` )
582+ lines . push (
583+ "" ,
584+ "The workflow uploads generated reports in the `changed-code-mutation-report` artifact. A direct artifact link appears below after upload." ,
585+ )
586+ }
587+
588+ if ( failures . length > 0 ) {
589+ lines . push (
590+ "" ,
591+ "### Failures" ,
592+ "" ,
593+ ...failures . map ( ( failure ) => {
594+ const detail =
595+ failure . length > 4_000 ? `${ failure . slice ( 0 , 4_000 ) } \n[truncated; see the step log]` : failure
596+ return `- ${ detail . replaceAll ( "\n" , "\n " ) } `
597+ } ) ,
598+ )
599+ }
600+
601+ return `${ lines . join ( "\n" ) } \n`
602+ }
603+
604+ function appendSummary ( rows , failures , manifest ) {
605+ if ( ! process . env . GITHUB_STEP_SUMMARY ) return
606+ fs . appendFileSync ( process . env . GITHUB_STEP_SUMMARY , formatSummary ( rows , failures , manifest ) )
466607}
467608
468609export function evaluateReport ( report , packageEntry ) {
@@ -491,9 +632,13 @@ export function evaluateReport(report, packageEntry) {
491632export function runManifest ( repoRoot , manifest , reportRoot ) {
492633 const rows = [ ]
493634 const failures = [ ]
635+ const annotationState = { total : 0 , perFile : new Map ( ) }
494636
495637 for ( const packageEntry of manifest . packages ) {
496638 let counts
639+ const reportPath = path
640+ . relative ( repoRoot , path . join ( reportRoot , packageEntry . id , "mutation.html" ) )
641+ . replaceAll ( "\\" , "/" )
497642 try {
498643 const reportDirectory = path . join ( reportRoot , packageEntry . id )
499644 fs . mkdirSync ( reportDirectory , { recursive : true } )
@@ -514,6 +659,11 @@ export function runManifest(repoRoot, manifest, reportRoot) {
514659 if ( generatedMutants === 0 ) {
515660 rows . push ( {
516661 id : packageEntry . id ,
662+ root : packageEntry . root ,
663+ runRoot : packageEntry . runRoot ,
664+ selectors : packageEntry . selectors ,
665+ testFiles : packageEntry . testFiles ?? [ ] ,
666+ reportPath,
517667 changedLines : packageEntry . changedExecutableLines ,
518668 valid : 0 ,
519669 killed : 0 ,
@@ -526,17 +676,25 @@ export function runManifest(repoRoot, manifest, reportRoot) {
526676 }
527677
528678 runStryker ( repoRoot , packageEntry , reportRoot , false )
529- const reportPath = path . join ( reportRoot , packageEntry . id , "mutation.json" )
530- const report = JSON . parse ( fs . readFileSync ( reportPath , "utf8" ) )
679+ const jsonReportPath = path . join ( reportRoot , packageEntry . id , "mutation.json" )
680+ const report = JSON . parse ( fs . readFileSync ( jsonReportPath , "utf8" ) )
681+ packageEntry . testFiles = testsFromMutationReport ( report , packageEntry . testFiles )
531682 counts = mutantCounts ( report )
532- for ( const annotation of formatAnnotations ( counts . blocking , packageEntry . runRoot ?? packageEntry . root ) ) {
533- console . log (
534- `::error file=${ escapeWorkflowValue ( annotation . file ) } ,line=${ annotation . line } ,title=Mutation test gap::${ escapeWorkflowValue ( annotation . message ) } ` ,
535- )
683+ for ( const annotation of formatAnnotations (
684+ counts . blocking ,
685+ packageEntry . runRoot ?? packageEntry . root ,
686+ annotationState ,
687+ ) ) {
688+ console . log ( formatAnnotationCommand ( annotation ) )
536689 }
537690 evaluateReport ( report , packageEntry )
538691 rows . push ( {
539692 id : packageEntry . id ,
693+ root : packageEntry . root ,
694+ runRoot : packageEntry . runRoot ,
695+ selectors : packageEntry . selectors ,
696+ testFiles : packageEntry . testFiles ?? [ ] ,
697+ reportPath,
540698 changedLines : packageEntry . changedExecutableLines ,
541699 ...counts ,
542700 result : "Passed" ,
@@ -545,18 +703,24 @@ export function runManifest(repoRoot, manifest, reportRoot) {
545703 failures . push ( error . message )
546704 rows . push ( {
547705 id : packageEntry . id ,
706+ root : packageEntry . root ,
707+ runRoot : packageEntry . runRoot ,
708+ selectors : packageEntry . selectors ,
709+ testFiles : packageEntry . testFiles ?? [ ] ,
710+ reportPath,
548711 changedLines : packageEntry . changedExecutableLines ,
549712 valid : counts ?. valid ?? 0 ,
550713 killed : counts ?. killed ?? 0 ,
551714 timeout : counts ?. timeout ?? 0 ,
552715 survived : counts ?. survived ?? 0 ,
553716 noCoverage : counts ?. noCoverage ?? 0 ,
717+ blocking : counts ?. blocking ?? [ ] ,
554718 result : "Failed" ,
555719 } )
556720 }
557721 }
558722
559- appendSummary ( rows , failures )
723+ appendSummary ( rows , failures , manifest )
560724 if ( failures . length > 0 ) throw new Error ( failures . join ( "\n" ) )
561725 return rows
562726}
@@ -579,7 +743,7 @@ function main() {
579743 const reportRoot = path . resolve ( repoRoot , argument ( "--reports" ) ?? "reports/mutation" )
580744 const manifest = selectFromGit ( repoRoot , baseSha , headSha )
581745 if ( manifest . packages . length === 0 ) {
582- appendSummary ( [ ] , [ ] )
746+ appendSummary ( [ ] , [ ] , manifest )
583747 console . log ( "No changed executable lines in mutation-tested packages; mutation testing is not applicable." )
584748 return
585749 }
0 commit comments