@@ -22,8 +22,20 @@ export interface BrokenLink {
2222 * `update-internal-links` looks the href up exactly as written, so it can't fix these.
2323 */
2424 requiresVersionContext ?: boolean
25+ /**
26+ * Two checked versions resolved this href to genuinely different destinations, so no
27+ * single rewrite is correct for all of them. Merging keeps one target and drops the
28+ * rest, which would otherwise let the report name a destination that is only right for
29+ * one version.
30+ */
31+ hasConflictingRedirectTargets ?: boolean
2532 statusCode ?: number
2633 errorMessage ?: string
34+ /**
35+ * The versions this link is broken in. Only set on a merged report, where the same link
36+ * usually breaks in every version checked.
37+ */
38+ versions ?: string [ ]
2739}
2840
2941/**
@@ -55,6 +67,8 @@ export interface LinkReport {
5567 totalOccurrences : number
5668 timestamp : string
5769 actionUrl ?: string
70+ /** Every version this report covers. Only set on a merged report. */
71+ versionsChecked ?: string [ ]
5872}
5973
6074// ============================================================================
@@ -237,6 +251,15 @@ function isVersionOnlyRedirect(target: string, redirectTarget: string): boolean
237251 return withoutVersion === target
238252}
239253
254+ /**
255+ * Two redirect targets that differ only by version prefix are the same rename seen from
256+ * two versions, not a disagreement. `/enterprise-server@3.21/new` and
257+ * `/enterprise-server@3.17/new` both mean "the page moved to /new".
258+ */
259+ function sameDestination ( a : string , b : string ) : boolean {
260+ return a . replace ( VERSION_PREFIX_RE , '' ) === b . replace ( VERSION_PREFIX_RE , '' )
261+ }
262+
240263/**
241264 * Create a suggestion message for a redirect
242265 */
@@ -368,6 +391,84 @@ function createSummary(errorCount: number, warningCount: number, totalOccurrence
368391 return `Found ${ parts . join ( ' and ' ) } across ${ totalOccurrences } occurrence${ plural } .`
369392}
370393
394+ /**
395+ * Describe which versions a link breaks in, but only when that is news.
396+ *
397+ * Nearly every broken link breaks in every version, so printing the full list on every
398+ * group is noise that also blows past the issue body size limit. Say something only when a
399+ * link is version-specific.
400+ */
401+ export function describeVersions (
402+ versions : string [ ] | undefined ,
403+ versionsChecked : string [ ] | undefined ,
404+ ) : string | undefined {
405+ if ( ! versions ?. length || ! versionsChecked ?. length ) return undefined
406+ if ( versionsChecked . length === 1 ) return undefined
407+ if ( versions . length >= versionsChecked . length ) return undefined
408+ return versions . join ( ', ' )
409+ }
410+
411+ /**
412+ * Merge one report per version into a single report.
413+ *
414+ * The workflow used to concatenate each version's rendered Markdown, so a link broken in
415+ * every version produced an identical section per version. Merging on the link itself means
416+ * one section per real problem, with the versions recorded on the occurrence.
417+ */
418+ export function mergeInternalLinkReports (
419+ reports : { version : string ; report : LinkReport } [ ] ,
420+ options : { actionUrl ?: string ; versionsChecked ?: string [ ] } = { } ,
421+ ) : LinkReport {
422+ const merged = new Map < string , BrokenLink > ( )
423+
424+ for ( const { version, report } of reports ) {
425+ for ( const group of report . groups ) {
426+ for ( const occurrence of group . occurrences ) {
427+ const href = occurrence . href || group . target
428+ const key = `${ href } \u0000${ occurrence . file } `
429+ const existing = merged . get ( key )
430+ if ( existing ) {
431+ existing . lines = [ ...new Set ( [ ...existing . lines , ...occurrence . lines ] ) ] . sort (
432+ ( a , b ) => a - b ,
433+ )
434+ existing . versions = [ ...new Set ( [ ...( existing . versions ?? [ ] ) , version ] ) ]
435+ // A link that redirects in any version is still worth rewriting everywhere.
436+ existing . isRedirect = existing . isRedirect || occurrence . isRedirect
437+ existing . requiresVersionContext =
438+ existing . requiresVersionContext || occurrence . requiresVersionContext
439+ // Keeping the first target and dropping the rest is only safe while every
440+ // version agrees on where the page went. Today they always do, but if that ever
441+ // stops being true the report would confidently name a destination that is
442+ // right for one version and wrong for the others. Flag it instead.
443+ if (
444+ existing . redirectTarget &&
445+ occurrence . redirectTarget &&
446+ ! sameDestination ( existing . redirectTarget , occurrence . redirectTarget )
447+ ) {
448+ existing . hasConflictingRedirectTargets = true
449+ }
450+ existing . redirectTarget = existing . redirectTarget ?? occurrence . redirectTarget
451+ } else {
452+ merged . set ( key , { ...occurrence , href, versions : [ version ] } )
453+ }
454+ }
455+ }
456+ }
457+
458+ // A version with no broken links writes no report, so the files on disk undercount what
459+ // was actually checked. Callers that know the full matrix pass it in, otherwise fall back
460+ // to what was found.
461+ const versionsChecked = options . versionsChecked ?. length
462+ ? options . versionsChecked
463+ : reports . map ( ( r ) => r . version )
464+ const report = generateInternalLinkReport ( [ ...merged . values ( ) ] , options )
465+ const scope =
466+ versionsChecked . length > 1
467+ ? `\n\nChecked ${ versionsChecked . length } versions: ${ versionsChecked . join ( ', ' ) } . A link listed without a version breaks in all of them.`
468+ : ''
469+ return { ...report , versionsChecked, summary : report . summary + scope }
470+ }
471+
371472/**
372473 * Generate a report for internal links
373474 */
@@ -472,6 +573,10 @@ export function classifyFixStrategy(group: GroupedBrokenLinks): FixStrategy {
472573 if ( group . occurrences . some ( ( occ ) => occ . requiresVersionContext ) ) {
473574 return 'decide'
474575 }
576+ // Versions disagree about where the page went, so there is no single correct rewrite.
577+ if ( group . occurrences . some ( ( occ ) => occ . hasConflictingRedirectTargets ) ) {
578+ return 'decide'
579+ }
475580 return 'codemod'
476581 }
477582 // The link carries a fragment, so the stale part is likely a renamed heading.
@@ -501,15 +606,30 @@ function codemodPaths(groups: GroupedBrokenLinks[]): string[] {
501606 return [ ...paths ] . sort ( )
502607}
503608
609+ /** The union of versions across a group's occurrences. */
610+ function groupVersions ( group : GroupedBrokenLinks ) : string [ ] {
611+ const versions = new Set < string > ( )
612+ for ( const occ of group . occurrences ) {
613+ for ( const version of occ . versions ?? [ ] ) versions . add ( version )
614+ }
615+ return [ ...versions ]
616+ }
617+
504618function occurrenceCount ( groups : GroupedBrokenLinks [ ] ) : number {
505619 return groups . reduce ( ( sum , g ) => sum + g . occurrences . length , 0 )
506620}
507621
508- function renderCodemodSection ( groups : GroupedBrokenLinks [ ] ) : string {
622+ function renderCodemodSection ( groups : GroupedBrokenLinks [ ] , versionsChecked ?: string [ ] ) : string {
623+ const versionFor = ( group : GroupedBrokenLinks ) =>
624+ describeVersions ( groupVersions ( group ) , versionsChecked )
625+ const showVersions = groups . some ( ( group ) => versionFor ( group ) )
626+
509627 const rows = groups
510628 . map ( ( group ) => {
511629 const target = group . occurrences . find ( ( occ ) => occ . redirectTarget ) ?. redirectTarget ?? ''
512- return `| \`${ group . target } \` | \`${ target } \` | ${ group . occurrences . length } |`
630+ const cells = [ `\`${ group . target } \`` , `\`${ target } \`` , `${ group . occurrences . length } ` ]
631+ if ( showVersions ) cells . push ( versionFor ( group ) ?? 'all' )
632+ return `| ${ cells . join ( ' | ' ) } |`
513633 } )
514634 . join ( '\n' )
515635
@@ -542,8 +662,8 @@ Review the diff, then open a pull request.
542662<details>
543663<summary>The ${ groups . length } link${ plural } this fixes</summary>
544664
545- | From | To | Occurrences |
546- |------|-----|-------------|
665+ | From | To | Occurrences |${ showVersions ? ' Versions |' : '' }
666+ |------|-----|-------------|${ showVersions ? '----------|' : '' }
547667${ rows }
548668
549669</details>`
@@ -591,8 +711,15 @@ function renderManualSection(
591711 blurb : string ,
592712 groups : GroupedBrokenLinks [ ] ,
593713 isExternal : boolean ,
714+ versionsChecked ?: string [ ] ,
594715) : string {
595- const sections = groups . map ( ( group ) => TEMPLATES . group ( group , isExternal ) ) . join ( '\n\n' )
716+ const sections = groups
717+ . map ( ( group ) => {
718+ const versions = describeVersions ( groupVersions ( group ) , versionsChecked )
719+ const note = versions ? `\n\n**Only in:** ${ versions } ` : ''
720+ return TEMPLATES . group ( group , isExternal ) + note
721+ } )
722+ . join ( '\n\n' )
596723 return `## ${ heading } (${ groups . length } link${ groups . length === 1 ? '' : 's' } , ${ occurrenceCount ( groups ) } occurrence${ occurrenceCount ( groups ) === 1 ? '' : 's' } )
597724
598725${ blurb }
@@ -604,7 +731,11 @@ ${sections}`
604731 * Render an internal report as four buckets ordered by how much work each one costs, from
605732 * one command down to nothing at all.
606733 */
607- function renderByFixStrategy ( groups : GroupedBrokenLinks [ ] , isExternal : boolean ) : string {
734+ function renderByFixStrategy (
735+ groups : GroupedBrokenLinks [ ] ,
736+ isExternal : boolean ,
737+ versionsChecked ?: string [ ] ,
738+ ) : string {
608739 const codemod = groups . filter ( ( g ) => classifyFixStrategy ( g ) === 'codemod' )
609740 const versionless = groups . filter ( ( g ) => classifyFixStrategy ( g ) === 'versionless' )
610741 const anchors = groups . filter ( ( g ) => classifyFixStrategy ( g ) === 'anchor' )
@@ -631,14 +762,15 @@ ${summaryRows.join('\n')}
631762Work top to bottom. Bucket 1 is usually most of the report and costs one command.` ,
632763 ]
633764
634- if ( codemod . length > 0 ) parts . push ( renderCodemodSection ( codemod ) )
765+ if ( codemod . length > 0 ) parts . push ( renderCodemodSection ( codemod , versionsChecked ) )
635766 if ( anchors . length > 0 ) {
636767 parts . push (
637768 renderManualSection (
638769 '2. Stale anchors' ,
639770 'The `#fragment` does not match a heading on the target page. Usually a heading was renamed: find it and repoint the link, or drop the fragment if the section is gone. Check that the page itself still exists first, since a missing page with a fragment also lands here.' ,
640771 anchors ,
641772 isExternal ,
773+ versionsChecked ,
642774 ) ,
643775 )
644776 }
@@ -649,6 +781,7 @@ Work top to bottom. Bucket 1 is usually most of the report and costs one command
649781 'The codemod looks each link up exactly as written, and for these that lookup finds nothing: either no redirect exists at all, or the redirect only exists under a version prefix the link does not carry. Choose a destination, or add a redirect from the path as written.' ,
650782 decide ,
651783 isExternal ,
784+ versionsChecked ,
652785 ) ,
653786 )
654787 }
@@ -725,7 +858,7 @@ export function reportToMarkdown(report: LinkReport, isExternal = false): string
725858 parts . push (
726859 isExternal
727860 ? renderGroups ( report . groups , isExternal )
728- : renderByFixStrategy ( report . groups , isExternal ) ,
861+ : renderByFixStrategy ( report . groups , isExternal , report . versionsChecked ) ,
729862 )
730863 }
731864
0 commit comments