Skip to content

Commit 4bd6049

Browse files
committed
feat(scripts/diff-flat): detect moves
1 parent 5992a99 commit 4bd6049

1 file changed

Lines changed: 162 additions & 0 deletions

File tree

scripts/diff-flat.js

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)