diff --git a/scripts/diff-flat.js b/scripts/diff-flat.js index 76eceac084f45d..294d70b5200a7e 100644 --- a/scripts/diff-flat.js +++ b/scripts/diff-flat.js @@ -18,6 +18,8 @@ import { hideBin } from 'yargs/helpers'; import { spawn, walk } from '../utils/index.js'; import { addVersionLast, applyMirroring, transformMD } from './build/index.js'; +import { deepMerge } from './lib/deep-merge.js'; +import { collectFeatures, detectMoves, getAt } from './lib/detect-moves.js'; import { getMergeBase, getFileContent, getGitDiffStatuses } from './lib/git.js'; import dataFolders from './lib/data-folders.js'; @@ -228,38 +230,97 @@ const diffKeys = (key, lastKey, options) => { }; /** - * Deeply merges a source object into a target object. - * @param {*} target The target object to merge into. - * @param {*} source The source object to merge. - * @returns {*} the target object with source merged. + * Writes a value at a dot-separated path within a tree, creating intermediate + * plain objects as needed. + * @param {*} root the root object. + * @param {string} path dot-separated path. + * @param {*} value the value to set. + * @returns {void} */ -const deepMerge = (target, source) => { - if (typeof target !== 'object' || target === null) { - return source; - } - if (typeof source !== 'object' || source === null) { - return source; +const setAt = (root, path, value) => { + const parts = path.split('.'); + let node = root; + for (let i = 0; i < parts.length - 1; i++) { + if (typeof node[parts[i]] !== 'object' || node[parts[i]] === null) { + node[parts[i]] = {}; + } + node = node[parts[i]]; } + node[parts[parts.length - 1]] = value; +}; - for (const key of Object.keys(source)) { - const sourceValue = source[key]; - const targetValue = target[key]; - - if (Array.isArray(sourceValue) && Array.isArray(targetValue)) { - target[key] = targetValue.concat(sourceValue); - } else if ( - typeof sourceValue === 'object' && - typeof targetValue === 'object' && - sourceValue !== null && - targetValue !== null - ) { - target[key] = deepMerge({ ...targetValue }, sourceValue); +/** + * Relocates each move's `__compat` block from its source path to its + * destination path within the base tree. After projection, the diff treats + * each move as if the feature had always lived at the new path with the + * old values, so a pure rename produces no add/remove noise. + * @param {*} baseContents the base data tree (mutated). + * @param {Map} moves source → destination paths. + * @returns {void} + */ +const projectMoves = (baseContents, moves) => { + for (const [from, to] of moves) { + const source = getAt(baseContents, from); + if (!source || typeof source !== 'object' || !source.__compat) { + continue; + } + const dest = getAt(baseContents, to); + if (dest && typeof dest === 'object') { + dest.__compat = source.__compat; } else { - target[key] = sourceValue; + setAt(baseContents, to, { __compat: source.__compat }); } + delete source.__compat; } +}; - return target; +/** + * Formats a moved feature path as `prefix.{from → to}.suffix`, with the + * differing middle segments highlighted (from in red, to in green) and the + * shared head/tail segments unstyled. + * @param {string} from the source path. + * @param {string} to the destination path. + * @param {object} options Options + * @param {Format} options.format Whether to return HTML, otherwise plaintext. + * @returns {string} the formatted move string. + */ +/** + * Formats a moved feature path as an inline diff, with chunks added in head + * (green) and chunks present only in base (red) interleaved next to the + * shared parts. Tokenizes each path so `.`/`_` separators stay attached to + * the preceding word — partial-word overlaps like `er` in `parameter` and + * `referrer` aren't matched. + * @param {string} from the source path. + * @param {string} to the destination path. + * @param {object} options Options + * @param {Format} options.format Whether to return HTML, otherwise plaintext. + * @returns {string} the formatted move string. + */ +const formatMove = (from, to, options) => { + /** + * Tokenizes a path into words and separators (`.`/`_`) so each can be + * matched independently by the diff. + * @param {string} s the path to tokenize. + * @returns {string[]} interleaved word and separator tokens. + */ + const tokenize = (s) => s.split(/([._])/); + return diffArrays(tokenize(to), tokenize(from)) + .map((part) => { + // Note: removed/added is deliberately inverted here, to have additions + // first — matching the convention used for value diffs. + const value = part.value.join(''); + if (part.removed) { + return options.format == 'html' + ? `${value}` + : styleText('green', value); + } else if (part.added) { + return options.format == 'html' + ? `${value}` + : styleText('red', value); + } + return value; + }) + .join(''); }; /** @@ -334,6 +395,20 @@ const printDiffs = (base, head, options) => { } } + const moves = detectMoves(baseContents, headContents); + + const baseFeaturePaths = collectFeatures(baseContents); + const headFeaturePaths = collectFeatures(headContents); + const movedDests = new Set(moves.values()); + const addedFeatures = [...headFeaturePaths.keys()] + .filter((p) => !baseFeaturePaths.has(p) && !movedDests.has(p)) + .sort(); + const removedFeatures = [...baseFeaturePaths.keys()] + .filter((p) => !headFeaturePaths.has(p) && !moves.has(p)) + .sort(); + + projectMoves(baseContents, moves); + const baseData = flattenObject(baseContents); const headData = flattenObject(headContents); @@ -354,23 +429,16 @@ const printDiffs = (base, head, options) => { const commonName = options.format === 'html' ? `

${prefix}

` : `${prefix}`; - let lastKey = ''; - - for (const key of keys) { - const baseValue = JSON.stringify(baseData[key] ?? null); - const headValue = JSON.stringify(headData[key] ?? null); - if (baseValue === headValue) { - continue; - } - if (!lastKey) { - lastKey = key; - } - const keyDiff = diffKeys( - key.slice(prefix.length), - lastKey.slice(prefix.length), - options, - ); - + /** + * Renders a colored inline diff between two stringified field values, + * matching the convention used elsewhere: green for additions in head, red + * for removals from base. Returns an empty string when the diff would be + * empty (e.g. null → "mirror" / "false"). + * @param {string} baseValue stringified base value (or `"null"`). + * @param {string} headValue stringified head value (or `"null"`). + * @returns {string} the colored diff string. + */ + const formatValueDiff = (baseValue, headValue) => { const splitRegexp = /(?<=^")|(?<=[\],/ ])|(?=[[,/ ])|(?="$)|(?<=\d)(?=−)|(?<=−)(?=\d)|(?=#)/; let headValueForDiff = headValue; @@ -379,19 +447,19 @@ const printDiffs = (base, head, options) => { if (baseValue == 'null') { baseValueForDiff = ''; if (headValue == '"mirror"' || headValue == '"false"') { - // Ignore initial "mirror"/"false" values. headValueForDiff = ''; } } else if (headValue == 'null') { headValueForDiff = ''; } - const valueDiff = diffArrays( + return diffArrays( headValueForDiff.split(splitRegexp), baseValueForDiff.split(splitRegexp), ) .map((part) => { - // Note: removed/added is deliberately inversed here, to have additions first. + // Note: removed/added is deliberately inverted here, to have + // additions first. const value = part.value.join(''); if (part.removed) { return options.format == 'html' @@ -402,11 +470,57 @@ const printDiffs = (base, head, options) => { ? `${value}` : styleText('red', value); } - return value; }) .join(''); + }; + + /** @type {Set} */ + const consumedKeys = new Set(); + for (const [, to] of moves) { + consumedKeys.add(`${to}.__compat.description`); + } + for (const path of [...addedFeatures, ...removedFeatures]) { + consumedKeys.add(`${path}.__compat.description`); + } + /** + * Returns the colored description diff at a feature path, or empty if + * unchanged. + * @param {string} path the feature path. + * @returns {string} the colored description diff (or empty). + */ + const featureDescriptionDiff = (path) => { + const key = `${path}.__compat.description`; + const baseValue = JSON.stringify(baseData[key] ?? null); + const headValue = JSON.stringify(headData[key] ?? null); + if (baseValue === headValue) { + return ''; + } + return formatValueDiff(baseValue, headValue); + }; + + let lastKey = ''; + + for (const key of keys) { + if (consumedKeys.has(key)) { + continue; + } + const baseValue = JSON.stringify(baseData[key] ?? null); + const headValue = JSON.stringify(headData[key] ?? null); + if (baseValue === headValue) { + continue; + } + if (!lastKey) { + lastKey = key; + } + const keyDiff = diffKeys( + key.slice(prefix.length), + lastKey.slice(prefix.length), + options, + ); + + const valueDiff = formatValueDiff(baseValue, headValue); const value = valueDiff; if (!value.length) { @@ -447,7 +561,12 @@ const printDiffs = (base, head, options) => { lastKey = key; } - if (groups.size === 0) { + if ( + groups.size === 0 && + !addedFeatures.length && + !removedFeatures.length && + !moves.size + ) { console.log('✔ No changes.'); return; } @@ -532,6 +651,105 @@ const printDiffs = (base, head, options) => { } }; + /** + * @typedef {object} ListingItem + * @property {string} section section header. + * @property {string} rendered styled key (path or move). + * @property {number} visibleLen visible length of `rendered` (no styling). + * @property {string} desc styled description diff (or empty). + */ + + /** @type {ListingItem[]} */ + const listingItems = []; + for (const path of addedFeatures) { + const lastDot = path.lastIndexOf('.'); + const parent = lastDot === -1 ? '' : path.slice(0, lastDot + 1); + const leaf = lastDot === -1 ? path : path.slice(lastDot + 1); + const styledLeaf = + options.format === 'html' + ? `${leaf}` + : styleText('green', leaf); + listingItems.push({ + section: 'New features', + rendered: `${parent}${styledLeaf}`, + visibleLen: path.length, + desc: featureDescriptionDiff(path), + }); + } + for (const path of removedFeatures) { + const lastDot = path.lastIndexOf('.'); + const parent = lastDot === -1 ? '' : path.slice(0, lastDot + 1); + const leaf = lastDot === -1 ? path : path.slice(lastDot + 1); + const styledLeaf = + options.format === 'html' + ? `${leaf}` + : styleText('red', leaf); + listingItems.push({ + section: 'Removed features', + rendered: `${parent}${styledLeaf}`, + visibleLen: path.length, + desc: featureDescriptionDiff(path), + }); + } + for (const [from, to] of moves) { + const rendered = formatMove(from, to, options); + const visibleLen = + options.format === 'html' + ? rendered.replace(/<[^>]+>/g, '').length + : stripAnsi(rendered).length; + listingItems.push({ + section: 'Moved features', + rendered, + visibleLen, + desc: featureDescriptionDiff(to), + }); + } + + if (listingItems.length) { + const maxLen = Math.max(...listingItems.map((i) => i.visibleLen)); + const hasAnyDesc = listingItems.some((i) => i.desc); + let lastSection = ''; + for (const item of listingItems) { + if (item.section !== lastSection) { + if (lastSection) { + console.log(''); + } + const title = `${item.section}:`; + const styledTitle = + options.format === 'html' + ? `${title}` + : styleText('bold', title); + let header = styledTitle; + if (hasAnyDesc) { + const padding = ' '.repeat(Math.max(1, maxLen + 3 - title.length)); + const descLabel = 'description ='; + header += + padding + + (options.format === 'html' + ? `${descLabel}` + : styleText('italic', descLabel)); + } + console.log(header); + lastSection = item.section; + } + let line = ` ${item.rendered}`; + if (item.desc) { + const padding = ' '.repeat(1 + maxLen - item.visibleLen); + const styledDesc = + options.format === 'html' + ? `${item.desc}` + : styleText('italic', item.desc); + line += padding + styledDesc; + } + console.log(line); + } + console.log(''); + } + + if (addedFeatures.length || removedFeatures.length || moves.size) { + console.log(''); + } + for (const entry of entries) { /** @type {string | null} */ let previousKey = null; diff --git a/scripts/lib/deep-merge.js b/scripts/lib/deep-merge.js new file mode 100644 index 00000000000000..8aa425d5519549 --- /dev/null +++ b/scripts/lib/deep-merge.js @@ -0,0 +1,37 @@ +/* This file is a part of @mdn/browser-compat-data + * See LICENSE file for more information. */ + +/** + * Deeply merges a source object into a target object. + * @param {*} target The target object to merge into. + * @param {*} source The source object to merge. + * @returns {*} the target object with source merged. + */ +export const deepMerge = (target, source) => { + if (typeof target !== 'object' || target === null) { + return source; + } + if (typeof source !== 'object' || source === null) { + return source; + } + + for (const key of Object.keys(source)) { + const sourceValue = source[key]; + const targetValue = target[key]; + + if (Array.isArray(sourceValue) && Array.isArray(targetValue)) { + target[key] = targetValue.concat(sourceValue); + } else if ( + typeof sourceValue === 'object' && + typeof targetValue === 'object' && + sourceValue !== null && + targetValue !== null + ) { + target[key] = deepMerge({ ...targetValue }, sourceValue); + } else { + target[key] = sourceValue; + } + } + + return target; +}; diff --git a/scripts/lib/detect-moves.js b/scripts/lib/detect-moves.js new file mode 100644 index 00000000000000..cba646464aa695 --- /dev/null +++ b/scripts/lib/detect-moves.js @@ -0,0 +1,234 @@ +/* This file is a part of @mdn/browser-compat-data + * See LICENSE file for more information. */ + +import { walk } from '../../utils/index.js'; + +/** + * Converts a value to an array unless it already is one. + * @param {*} value array or any value. + * @returns {*[]} the array, or an array with the value as a single item. + */ +const toArray = (value) => (Array.isArray(value) ? value : [value]); + +/** + * Collects URL fingerprints (spec_url and mdn_url) for each feature, and + * includes features without URLs as empty entries so they're available to + * the token-based fallback matcher. + * @param {*} contents the merged data tree. + * @returns {Map>} map from feature path to URL set (possibly empty). + */ +export const collectFeatures = (contents) => { + /** @type {Map>} */ + const features = new Map(); + for (const { path, compat } of walk(undefined, contents)) { + /** @type {Set} */ + const urls = new Set(); + if (compat.spec_url) { + for (const url of toArray(compat.spec_url)) { + urls.add(`spec:${url}`); + } + } + if (compat.mdn_url) { + urls.add(`mdn:${compat.mdn_url}`); + } + features.set(path, urls); + } + return features; +}; + +/** + * Tokenizes a feature path's leaf segment into lowercase words, splitting on + * `_`, `.` and camelCase boundaries. Returns a Set so each word counts once + * per feature. + * @param {string} path the feature path. + * @returns {Set} the leaf tokens. + */ +export const tokenizeLeaf = (path) => { + const leaf = path.split('.').pop() ?? ''; + return new Set( + leaf + .split(/[_.]+|(?=[A-Z])/) + .filter(Boolean) + .map((w) => w.toLowerCase()), + ); +}; + +/** + * Reads the value at a dot-separated path within a tree. + * @param {*} root the root object. + * @param {string} path dot-separated path. + * @returns {*} the value, or undefined if any segment is missing. + */ +export const getAt = (root, path) => { + let node = root; + for (const part of path.split('.')) { + if (typeof node !== 'object' || node === null) { + return undefined; + } + node = node[part]; + } + return node; +}; + +/** + * Detects features that were moved (renamed) in two passes: + * 1. Match by shared spec_url/mdn_url, with longest-shared-path-prefix as + * tiebreaker when multiple candidates share a URL. + * 2. For features still unmatched, match by common ancestor path plus + * shared non-scaffold leaf words (`keepalive`, `signal`, etc.). + * Scaffold tokens — those appearing in more than half of unmatched + * removed or added features (e.g. `init`, `parameter`) — are ignored. + * @param {*} baseContents the merged base data tree. + * @param {*} headContents the merged head data tree. + * @returns {Map} map from removed path to added path. + */ +export const detectMoves = (baseContents, headContents) => { + const baseFeatures = collectFeatures(baseContents); + const headFeatures = collectFeatures(headContents); + + /** @type {Map} */ + const addedByUrl = new Map(); + for (const [path, urls] of headFeatures) { + if (baseFeatures.has(path)) { + continue; + } + for (const url of urls) { + const list = addedByUrl.get(url) ?? []; + list.push(path); + addedByUrl.set(url, list); + } + } + + /** @type {Map} */ + const moves = new Map(); + /** @type {Set} */ + const matchedDests = new Set(); + for (const [removedPath, urls] of baseFeatures) { + if (headFeatures.has(removedPath) || urls.size === 0) { + continue; + } + /** @type {Set} */ + const candidates = new Set(); + for (const url of urls) { + for (const candidate of addedByUrl.get(url) ?? []) { + candidates.add(candidate); + } + } + if (candidates.size === 0) { + continue; + } + + const removedParts = removedPath.split('.'); + let best = ''; + let bestScore = -1; + for (const candidate of candidates) { + const candidateParts = candidate.split('.'); + let score = 0; + while ( + score < removedParts.length && + score < candidateParts.length && + removedParts[score] === candidateParts[score] + ) { + score++; + } + if (score > bestScore) { + best = candidate; + bestScore = score; + } + } + moves.set(removedPath, best); + matchedDests.add(best); + } + + // Pass 2: token + common-ancestor matching for the rest. + const unmatchedRemoved = [...baseFeatures.keys()].filter( + (p) => !headFeatures.has(p) && !moves.has(p), + ); + const unmatchedAdded = [...headFeatures.keys()].filter( + (p) => !baseFeatures.has(p) && !matchedDests.has(p), + ); + if (unmatchedRemoved.length === 0 || unmatchedAdded.length === 0) { + return moves; + } + + /** @type {Map>} */ + const removedTokens = new Map(); + /** @type {Map>} */ + const addedTokens = new Map(); + /** @type {Map} */ + const removedFreq = new Map(); + /** @type {Map} */ + const addedFreq = new Map(); + for (const p of unmatchedRemoved) { + const tokens = tokenizeLeaf(p); + removedTokens.set(p, tokens); + for (const t of tokens) { + removedFreq.set(t, (removedFreq.get(t) ?? 0) + 1); + } + } + for (const p of unmatchedAdded) { + const tokens = tokenizeLeaf(p); + addedTokens.set(p, tokens); + for (const t of tokens) { + addedFreq.set(t, (addedFreq.get(t) ?? 0) + 1); + } + } + /** + * @param {string} token + * @returns {boolean} true if the token is too common to be distinctive. + */ + const isScaffold = (token) => + (removedFreq.get(token) ?? 0) > unmatchedRemoved.length / 2 || + (addedFreq.get(token) ?? 0) > unmatchedAdded.length / 2; + + for (const removedPath of unmatchedRemoved) { + const rTokens = /** @type {Set} */ (removedTokens.get(removedPath)); + const rParts = removedPath.split('.'); + let best = ''; + let bestScore = -1; + + for (const addedPath of unmatchedAdded) { + if (matchedDests.has(addedPath)) { + continue; + } + const aTokens = /** @type {Set} */ (addedTokens.get(addedPath)); + const aParts = addedPath.split('.'); + + let ancestor = 0; + while ( + ancestor < rParts.length - 1 && + ancestor < aParts.length - 1 && + rParts[ancestor] === aParts[ancestor] + ) { + ancestor++; + } + if (ancestor === 0) { + continue; + } + + let tokenScore = 0; + for (const t of rTokens) { + if (aTokens.has(t) && !isScaffold(t)) { + const freq = (removedFreq.get(t) ?? 0) + (addedFreq.get(t) ?? 0) || 1; + tokenScore += 1 / freq; + } + } + if (tokenScore === 0) { + continue; + } + + const score = ancestor * 1000 + tokenScore; + if (score > bestScore) { + best = addedPath; + bestScore = score; + } + } + + if (best) { + moves.set(removedPath, best); + matchedDests.add(best); + } + } + + return moves; +}; diff --git a/scripts/release/changes.js b/scripts/release/changes.js index 648e89aa277b16..333eefcf17749c 100644 --- a/scripts/release/changes.js +++ b/scripts/release/changes.js @@ -9,10 +9,19 @@ * @property {string} feature */ +/** + * @typedef {object} FeatureRename + * @property {number} number + * @property {string} url + * @property {string} from + * @property {string} to + */ + /** * @typedef {object} Changes * @property {FeatureChange[]} added * @property {FeatureChange[]} removed + * @property {FeatureRename[]} renamed */ import { styleText } from 'node:util'; @@ -21,6 +30,7 @@ import cliProgress from 'cli-progress'; import diffFeatures from '../diff-features.js'; +import { detectRenames } from './detect-renames.js'; import { queryPRs } from './utils.js'; /** @@ -31,6 +41,14 @@ import { queryPRs } from './utils.js'; const featureBullet = (obj) => `- \`${obj.feature}\` ([#${obj.number}](${obj.url}))`; +/** + * Format a feature rename in Markdown + * @param {FeatureRename} obj The rename to format + * @returns {string} The formatted rename + */ +const renameBullet = (obj) => + `- \`${obj.from}\` to \`${obj.to}\` ([#${obj.number}](${obj.url}))`; + /** * Format all the feature changes in Markdown * @param {Changes} changes The changes to format @@ -40,6 +58,14 @@ export const formatChanges = (changes) => { /** @type {string[]} */ const output = []; + if (changes.renamed.length) { + output.push('### Renamings', ''); + for (const rename of changes.renamed) { + output.push(renameBullet(rename)); + } + output.push(''); + } + if (changes.removed.length) { output.push('### Removals', ''); for (const removal of changes.removed) { @@ -74,7 +100,7 @@ const pullsFromGitHub = (fromDate) => /** * Get the diff from the pull request * @param {FeatureChange} pull The pull request to test - * @returns {Promise<{ added: string[]; removed: string[] }>} The changes from the pull request + * @returns {Promise<{ added: string[]; removed: string[]; renamed: Array<{ from: string; to: string }> }>} The changes from the pull request */ const getDiff = async (pull) => { let diff; @@ -87,25 +113,44 @@ const getDiff = async (pull) => { ); } - if (diff.added.length && diff.removed.length) { - console.log( - ` | #${pull.number} - ${styleText('blue', `(${styleText('green', `${diff.added.length} added`)}, ${styleText('red', `${diff.removed.length} removed`)})`)}`, - ); - } else if (diff.added.length) { - console.log( - ` | #${pull.number} - ${styleText('blue', `(${styleText('green', `${diff.added.length} added`)})`)}`, - ); - } else if (diff.removed.length) { - console.log( - ` | #${pull.number} - ${styleText('blue', `(${styleText('red', `${diff.removed.length} removed`)})`)}`, - ); - } else { - console.log( - ` | #${pull.number} - ${styleText('blue', '(No feature count changes)')}`, - ); + /** @type {Array<{ from: string; to: string }>} */ + let renamed = []; + if (pull.mergeCommit) { + try { + renamed = detectRenames(pull.mergeCommit, diff.added, diff.removed); + } catch (e) { + console.error( + styleText( + 'yellow', + `(Failed to detect renames for #${pull.number}: ${String(e)})`, + ), + ); + } + } + + if (renamed.length) { + const renamedFrom = new Set(renamed.map((r) => r.from)); + const renamedTo = new Set(renamed.map((r) => r.to)); + diff.added = diff.added.filter((f) => !renamedTo.has(f)); + diff.removed = diff.removed.filter((f) => !renamedFrom.has(f)); } - return diff; + /** @type {string[]} */ + const counts = []; + if (diff.added.length) { + counts.push(styleText('green', `${diff.added.length} added`)); + } + if (diff.removed.length) { + counts.push(styleText('red', `${diff.removed.length} removed`)); + } + if (renamed.length) { + counts.push(styleText('cyan', `${renamed.length} renamed`)); + } + console.log( + ` | #${pull.number} - ${styleText('blue', counts.length ? `(${counts.join(', ')})` : '(No feature count changes)')}`, + ); + + return { ...diff, renamed }; }; /** @@ -124,6 +169,7 @@ export const getChanges = async (date) => { const changes = { added: [], removed: [], + renamed: [], }; progressBar.start(pulls.length, 0); @@ -148,6 +194,15 @@ export const getChanges = async (date) => { })), ); + changes.renamed.push( + ...diff.renamed.map(({ from, to }) => ({ + number: pull.number, + url: pull.url, + from, + to, + })), + ); + progressBar.increment(); }), ); @@ -157,6 +212,7 @@ export const getChanges = async (date) => { changes.added.sort((a, b) => a.feature.localeCompare(b.feature)); changes.removed.sort((a, b) => a.feature.localeCompare(b.feature)); + changes.renamed.sort((a, b) => a.from.localeCompare(b.from)); return changes; }; diff --git a/scripts/release/detect-renames.js b/scripts/release/detect-renames.js new file mode 100644 index 00000000000000..5c9f8ba88c9623 --- /dev/null +++ b/scripts/release/detect-renames.js @@ -0,0 +1,83 @@ +/* This file is a part of @mdn/browser-compat-data + * See LICENSE file for more information. */ + +/** @import {CompatData} from '../../types/types.js' */ + +import dataFolders from '../lib/data-folders.js'; +import { deepMerge } from '../lib/deep-merge.js'; +import { detectMoves } from '../lib/detect-moves.js'; +import { getFileContent, getGitDiffStatuses } from '../lib/git.js'; + +/** + * Build a base/head BCD content tree pair for a single PR by reading the + * JSON files that changed between `mergeCommit^` and `mergeCommit`. + * @param {string} mergeCommit the merge commit hash. + * @returns {{ base: CompatData; head: CompatData }} the merged trees. + */ +const loadTrees = (mergeCommit) => { + /** @type {CompatData} */ + const base = /** @type {*} */ ({}); + /** @type {CompatData} */ + const head = /** @type {*} */ ({}); + + for (const status of getGitDiffStatuses(`${mergeCommit}^`, mergeCommit)) { + if ( + !( + status.headPath.endsWith('.json') && + dataFolders.some((folder) => status.headPath.startsWith(`${folder}/`)) + ) + ) { + continue; + } + + const baseFileContents = /** @type {CompatData} */ ( + status.value !== 'A' + ? JSON.parse(getFileContent(`${mergeCommit}^`, status.basePath)) + : {} + ); + const headFileContents = /** @type {CompatData} */ ( + status.value !== 'D' + ? JSON.parse(getFileContent(mergeCommit, status.headPath)) + : {} + ); + + deepMerge(base, baseFileContents); + deepMerge(head, headFileContents); + } + + return { base, head }; +}; + +/** + * Detect rename pairs within a single PR by reading its changed JSON files + * and running the shared move-detection heuristic. + * + * Only pairs where `from` is in the PR's `removed` list and `to` is in the + * PR's `added` list are returned — this filters out spurious matches where + * the heuristic would otherwise pair an unrelated removal with an unrelated + * addition that happen to share a URL. + * @param {string} mergeCommit the PR's merge commit hash. + * @param {string[]} added paths added by the PR (per `diffFeatures`). + * @param {string[]} removed paths removed by the PR (per `diffFeatures`). + * @returns {Array<{ from: string; to: string }>} detected renames. + */ +export const detectRenames = (mergeCommit, added, removed) => { + if (added.length === 0 || removed.length === 0) { + return []; + } + + const { base, head } = loadTrees(mergeCommit); + const moves = detectMoves(base, head); + + const addedSet = new Set(added); + const removedSet = new Set(removed); + + /** @type {Array<{ from: string; to: string }>} */ + const renames = []; + for (const [from, to] of moves) { + if (removedSet.has(from) && addedSet.has(to)) { + renames.push({ from, to }); + } + } + return renames; +};