@@ -262,6 +262,149 @@ const deepMerge = (target, source) => {
262262 return target ;
263263} ;
264264
265+ /**
266+ * Collects URL fingerprints (spec_url and mdn_url) for each feature.
267+ * @param {* } contents the merged data tree.
268+ * @returns {Map<string, Set<string>> } map from feature path to its set of URL keys.
269+ */
270+ const collectFeatureUrls = ( contents ) => {
271+ /** @type {Map<string, Set<string>> } */
272+ const features = new Map ( ) ;
273+ for ( const { path, compat } of walk ( undefined , contents ) ) {
274+ /** @type {Set<string> } */
275+ const urls = new Set ( ) ;
276+ if ( compat . spec_url ) {
277+ for ( const url of toArray ( compat . spec_url ) ) {
278+ urls . add ( `spec:${ url } ` ) ;
279+ }
280+ }
281+ if ( compat . mdn_url ) {
282+ urls . add ( `mdn:${ compat . mdn_url } ` ) ;
283+ }
284+ if ( urls . size ) {
285+ features . set ( path , urls ) ;
286+ }
287+ }
288+ return features ;
289+ } ;
290+
291+ /**
292+ * Detects features that were moved (renamed) by matching shared spec_url/mdn_url
293+ * between features removed in base and features added in head. When multiple
294+ * candidates share a URL, prefers the candidate with the longest shared path
295+ * prefix (so `api.fetch.init_X` prefers `api.fetch.options_parameter.X` over
296+ * `api.Request.Request.options_parameter.X`).
297+ * @param {* } baseContents the merged base data tree.
298+ * @param {* } headContents the merged head data tree.
299+ * @returns {Map<string, string> } map from removed path to added path.
300+ */
301+ const detectMoves = ( baseContents , headContents ) => {
302+ const baseFeatures = collectFeatureUrls ( baseContents ) ;
303+ const headFeatures = collectFeatureUrls ( headContents ) ;
304+
305+ /** @type {Map<string, string[]> } */
306+ const addedByUrl = new Map ( ) ;
307+ for ( const [ path , urls ] of headFeatures ) {
308+ if ( baseFeatures . has ( path ) ) {
309+ continue ;
310+ }
311+ for ( const url of urls ) {
312+ const list = addedByUrl . get ( url ) ?? [ ] ;
313+ list . push ( path ) ;
314+ addedByUrl . set ( url , list ) ;
315+ }
316+ }
317+
318+ /** @type {Map<string, string> } */
319+ const moves = new Map ( ) ;
320+ for ( const [ removedPath , urls ] of baseFeatures ) {
321+ if ( headFeatures . has ( removedPath ) ) {
322+ continue ;
323+ }
324+ /** @type {Set<string> } */
325+ const candidates = new Set ( ) ;
326+ for ( const url of urls ) {
327+ for ( const candidate of addedByUrl . get ( url ) ?? [ ] ) {
328+ candidates . add ( candidate ) ;
329+ }
330+ }
331+ if ( candidates . size === 0 ) {
332+ continue ;
333+ }
334+
335+ const removedParts = removedPath . split ( '.' ) ;
336+ let best = '' ;
337+ let bestScore = - 1 ;
338+ for ( const candidate of candidates ) {
339+ const candidateParts = candidate . split ( '.' ) ;
340+ let score = 0 ;
341+ while (
342+ score < removedParts . length &&
343+ score < candidateParts . length &&
344+ removedParts [ score ] === candidateParts [ score ]
345+ ) {
346+ score ++ ;
347+ }
348+ if ( score > bestScore ) {
349+ best = candidate ;
350+ bestScore = score ;
351+ }
352+ }
353+ moves . set ( removedPath , best ) ;
354+ }
355+
356+ return moves ;
357+ } ;
358+
359+ /**
360+ * Formats a moved feature path as `prefix.{from → to}.suffix`, with the
361+ * differing middle segments highlighted (from in red, to in green) and the
362+ * shared head/tail segments unstyled.
363+ * @param {string } from the source path.
364+ * @param {string } to the destination path.
365+ * @param {object } options Options
366+ * @param {Format } options.format Whether to return HTML, otherwise plaintext.
367+ * @returns {string } the formatted move string.
368+ */
369+ /**
370+ * Formats a moved feature path as an inline diff, with chunks added in head
371+ * (green) and chunks present only in base (red) interleaved next to the
372+ * shared parts. Tokenizes each path so `.`/`_` separators stay attached to
373+ * the preceding word — partial-word overlaps like `er` in `parameter` and
374+ * `referrer` aren't matched.
375+ * @param {string } from the source path.
376+ * @param {string } to the destination path.
377+ * @param {object } options Options
378+ * @param {Format } options.format Whether to return HTML, otherwise plaintext.
379+ * @returns {string } the formatted move string.
380+ */
381+ const formatMove = ( from , to , options ) => {
382+ /**
383+ * Tokenizes a path into words and separators (`.`/`_`) so each can be
384+ * matched independently by the diff.
385+ * @param {string } s the path to tokenize.
386+ * @returns {string[] } interleaved word and separator tokens.
387+ */
388+ const tokenize = ( s ) => s . split ( / ( [ . _ ] ) / ) ;
389+ return diffArrays ( tokenize ( to ) , tokenize ( from ) )
390+ . map ( ( part ) => {
391+ // Note: removed/added is deliberately inverted here, to have additions
392+ // first — matching the convention used for value diffs.
393+ const value = part . value . join ( '' ) ;
394+ if ( part . removed ) {
395+ return options . format == 'html'
396+ ? `<ins style="color: green">${ value } </ins>`
397+ : styleText ( 'green' , value ) ;
398+ } else if ( part . added ) {
399+ return options . format == 'html'
400+ ? `<del style="color: red">${ value } </del>`
401+ : styleText ( 'red' , value ) ;
402+ }
403+ return value ;
404+ } )
405+ . join ( '' ) ;
406+ } ;
407+
265408/**
266409 * Print diffs
267410 * @param {string } base Base ref
@@ -334,6 +477,8 @@ const printDiffs = (base, head, options) => {
334477 }
335478 }
336479
480+ const moves = detectMoves ( baseContents , headContents ) ;
481+
337482 const baseData = flattenObject ( baseContents ) ;
338483 const headData = flattenObject ( headContents ) ;
339484
@@ -532,6 +677,23 @@ const printDiffs = (base, head, options) => {
532677 }
533678 } ;
534679
680+ if ( moves . size ) {
681+ if ( options . format === 'html' ) {
682+ console . log ( '<h4>Moved features</h4>' ) ;
683+ console . log ( '<ul>' ) ;
684+ for ( const [ from , to ] of moves ) {
685+ console . log ( `<li>${ formatMove ( from , to , options ) } </li>` ) ;
686+ }
687+ console . log ( '</ul>' ) ;
688+ } else {
689+ console . log ( styleText ( 'bold' , 'Moved features:' ) ) ;
690+ for ( const [ from , to ] of moves ) {
691+ console . log ( ` ${ formatMove ( from , to , options ) } ` ) ;
692+ }
693+ console . log ( '' ) ;
694+ }
695+ }
696+
535697 for ( const entry of entries ) {
536698 /** @type {string | null } */
537699 let previousKey = null ;
0 commit comments