diff --git a/package-lock.json b/package-lock.json index 8e956ff5daabd8..01d78bed410adb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -404,6 +404,12 @@ "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", "dev": true }, + "deep-diff": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz", + "integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==", + "dev": true + }, "defaults": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", diff --git a/package.json b/package.json index 1b418611470578..12b6d0ede18a71 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "browser-specs": "~1.40.0", "chalk": "~4.1.0", "compare-versions": "~3.6.0", + "deep-diff": "~1.0.2", "mdn-confluence": "~2.2.2", "mocha": "~9.0.0", "ora": "~5.4.0", @@ -40,6 +41,7 @@ }, "scripts": { "confluence": "node ./node_modules/mdn-confluence/main/generate.es6.js --output-dir=. --bcd-module=./index.js", + "diff": "node scripts/diff.js", "mocha": "mocha \"*/**.test.js\"", "lint": "node test/lint", "fix": "node scripts/fix", diff --git a/scripts/diff.js b/scripts/diff.js new file mode 100644 index 00000000000000..b2aea8b32db9b7 --- /dev/null +++ b/scripts/diff.js @@ -0,0 +1,280 @@ +#!/usr/bin/env node +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * @typedef {import('../types').SupportStatement} SupportStatement + */ + +'use strict'; + +const compareVersions = require('compare-versions'); +const deepDiff = require('deep-diff'); + +const chalk = require('chalk'); +const path = require('path'); + +const { argv } = require('yargs').command( + '$0 [lhs] [rhs]', + 'Compares two copies of BCD and summarizes differences', + yargs => { + yargs + .positional('lhs', { + describe: 'The first copy of BCD to compare', + type: 'string', + }) + .positional('rhs', { + describe: 'The other copy of BCD to compare', + type: 'string', + }) + .option('filter', { + describe: 'Filter to just removals', + type: 'boolean', + default: false, + }); + }, +); + +const query = (obj, path) => { + let value = obj; + for (const key of path) { + value = value[key]; + } + return value; +}; + +/** + * @param {object} lhs + * @param {object} rhs + * @param {Diff} diff + * @yields {object} and it's a great one + */ +function* explainDiff(lhs, rhs, diff) { + if (diff.kind === 'N') { + const added = query(rhs, diff.path); + if (typeof added.__compat === 'object') { + for (const [browser, support] of Object.entries(added.__compat.support)) { + yield { + entry: diff.path, + support: browser, + lhs: undefined, + rhs: support, + }; + } + for (const [status, value] of Object.entries(added.__compat.status)) { + yield { + entry: diff.path, + status, + lhs: undefined, + rhs: value, + }; + } + return; + } + } + if (diff.path[0] === 'browsers') { + // Just note which browser's data was updated. + if (diff.path.length > 1) { + const browser = diff.path[1]; + yield { browser }; + } + return; + } + + const compatIndex = diff.path.indexOf('__compat'); + if (compatIndex === -1) { + return; + } + + const entryPath = diff.path.slice(0, compatIndex); + const restPath = diff.path.slice(compatIndex + 1); + if (restPath.length === 2 && restPath[0] == 'status') { + yield { + entry: entryPath, + status: restPath[1], + lhs: diff.lhs, + rhs: diff.rhs, + }; + } else if (restPath.length >= 2 && restPath[0] == 'support') { + const browser = restPath[1]; + const statementPath = diff.path.slice(0, compatIndex + 3); + yield { + entry: entryPath, + support: browser, + lhs: query(lhs, statementPath), + rhs: query(rhs, statementPath), + }; + } +} + +/** + * @param {object} lhs + * @param {object} rhs + * @returns {object} and it's a great one + */ +const bcdDiff = (lhs, rhs) => { + const browsers = new Set(); + const compat = new Map(); + const other = new Set(); + const differences = deepDiff(lhs, rhs); + if (!differences) { + return undefined; + } + + // Pass 1: collect + for (const diff of differences) { + const diffs = Array.from(explainDiff(lhs, rhs, diff)); + if (!diffs.length) { + // Couldn't explain this diff at all + other.add(diff.path.join('.')); + continue; + } + for (const details of diffs) { + if (details.browser) { + browsers.add(details.browser); + continue; + } + if (details.entry) { + // Save the details for phase 2. + const path = details.entry.join('.'); + let list = compat.get(path); + if (!list) { + list = []; + compat.set(path, list); + } + list.push(details); + continue; + } + throw new Error('unreachable code'); + } + } + + // Pass 2: combine entry data + for (const [path, detailsList] of compat.entries()) { + const status = []; + const support = []; + for (const details of detailsList) { + if (details.status) { + status.push([details.status, details.rhs]); + } + if (details.support) { + support.push([ + details.support, + { + lhs: details.lhs, + rhs: details.rhs, + }, + ]); + } + } + const result = {}; + if (status.length) { + result.status = Object.fromEntries(status); + } + if (support.length) { + result.support = Object.fromEntries(support); + } + compat.set(path, result); + } + + if (!browsers.size && !compat.size && !other.size) { + return undefined; + } + return { + browsers: Array.from(browsers).sort(), + compat: Object.fromEntries(compat.entries()), + other: Array.from(other).sort(), + }; +}; + +/** + * @param {SupportStatement} support + * @return {string} + */ +const pretty = support => { + if (!support) { + return 'unset'; + } + if (!Array.isArray(support)) { + support = [support]; + } + support = support.map(entry => { + let range = ''; + if (entry.version_added !== undefined) { + range += entry.version_added; + } + if (entry.version_removed !== undefined) { + range += '-'; + range += entry.version_removed; + } + // TODO: flags n stuff + return range; + }); + if (support.length === 1) { + return support[0]; + } + return `(${support.join(', ')})`; +}; + +if (require.main === module) { + let lhs = require(path.resolve(argv.lhs)); + let rhs = require(path.resolve(argv.rhs)); + const diff = bcdDiff(lhs, rhs); + if (!diff) { + console.log('No differences!'); + return; + } + if (diff.compat) { + for (const [path, details] of Object.entries(diff.compat)) { + console.log(chalk.underline(path)); + if (details.support) { + for (const [browser, { lhs, rhs }] of Object.entries(details.support)) { + if (argv.filter) { + let skip = false; + if (!lhs) { + // adding a support statement + skip = true; + } else if (rhs) { + if (!lhs.version_added && rhs.version_added) { + // changing support from falsy to truthy + skip = true; + } + if (lhs.version_added === true && typeof rhs.version_added === 'string') { + // changing true to a specific version + skip = true; + } + if (typeof lhs.version_added === 'string' && typeof rhs.version_added === 'string') { + if (compareVersions.compare(lhs.version_added.replace('≤', ''), + rhs.version_added.replace('≤', ''), '>')) { + // widening claimed support + skip = true; + } + } + } + if (skip) { + continue; + } + } + const prettyLHS = pretty(lhs); + const prettyRHS = pretty(rhs); + // TODO: gross formatting required by Prettier + console.log( + ` ${chalk.bold(browser)} support set to ${chalk.green( + prettyRHS, + )} (was ${chalk.red(prettyLHS)})`, + ); + } + } + if (details.status) { + for (const [status, value] of Object.entries(details.status)) { + // TODO: gross formatting required by Prettier + console.log( + ` ${chalk.bold(status)} status set to ${chalk.green(value)}`, + ); + } + } + } + } +} else { + module.exports = { bcdDiff, pretty }; +} diff --git a/scripts/diff.test.js b/scripts/diff.test.js new file mode 100644 index 00000000000000..2b5b37c2c8bc45 --- /dev/null +++ b/scripts/diff.test.js @@ -0,0 +1,348 @@ +'use strict'; +const assert = require('assert'); +const { bcdDiff, pretty } = require('./diff.js'); + +describe('bcdDiff', () => { + // Mock data resembling BCD. Used as the lhs and cloned/updated for rhs. + const bcd = { + browsers: { + chrome: { name: 'Chrome' }, + edge: { name: 'Edge' }, + firefox: { name: 'Firefox' }, + safari: { name: 'Safari' }, + }, + api: { + Node: { + __compat: { + mdn_url: 'https://developer.mozilla.org/docs/Web/API/Node', + support: { + chrome: { version_added: '1' }, + edge: { version_added: '12', note: 'Notes on a node' }, + firefox: { version_added: '1' }, + safari: { version_added: '1' }, + }, + status: { + experimental: false, + standard_track: true, + deprecated: false, + }, + }, + localName: { + __compat: { + support: { + chrome: [ + { version_added: '50' }, + { version_added: '1', version_removed: '45' }, + ], + edge: { version_added: null }, + firefox: { version_added: false }, + safari: { version_added: '1', version_removed: '14' }, + }, + status: { + experimental: false, + standard_track: false, + deprecated: true, + }, + }, + }, + }, + }, + }; + + let lhs; + let rhs; + + beforeEach(() => { + lhs = bcd; + rhs = JSON.parse(JSON.stringify(bcd)); + }); + + it('no changes', () => { + const diff = bcdDiff(lhs, rhs); + assert.strictEqual(diff, undefined); + }); + + it('add mdn_url', () => { + rhs.api.Node.localName.__compat.mdn_url = + 'https://developer.mozilla.org/docs/Web/API/Node/localName'; + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.other, ['api.Node.localName.__compat.mdn_url']); + }); + + it('change version_added in simple statement', () => { + rhs.api.Node.__compat.support.chrome.version_added = '2'; + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.compat, { + 'api.Node': { + support: { + chrome: { + lhs: { version_added: '1' }, + rhs: { version_added: '2' }, + }, + }, + }, + }); + }); + + it('change version_added in multiple simple statement', () => { + rhs.api.Node.__compat.support.firefox.version_added = '2'; + rhs.api.Node.__compat.support.safari.version_added = '3'; + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.compat, { + 'api.Node': { + support: { + firefox: { + lhs: { version_added: '1' }, + rhs: { version_added: '2' }, + }, + safari: { + lhs: { version_added: '1' }, + rhs: { version_added: '3' }, + }, + }, + }, + }); + }); + + it('change version_added in array statement', () => { + rhs.api.Node.localName.__compat.support.chrome[0].version_added = '55'; + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.compat, { + 'api.Node.localName': { + support: { + chrome: { + lhs: [ + { version_added: '50' }, + { version_added: '1', version_removed: '45' }, + ], + rhs: [ + { version_added: '55' }, // this changed + { version_added: '1', version_removed: '45' }, + ], + }, + }, + }, + }); + }); + + it('change simple statement into array statement', () => { + rhs.api.Node.__compat.support.chrome = [ + { version_added: '80' }, + { version_added: '1', version_removed: '40' }, + ]; + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.compat, { + 'api.Node': { + support: { + chrome: { + lhs: { version_added: '1' }, + rhs: [ + { version_added: '80' }, + { version_added: '1', version_removed: '40' }, + ], + }, + }, + }, + }); + }); + + it('add entry into array statement', () => { + rhs.api.Node.localName.__compat.support.chrome.splice(0, 0, { + version_removed: '55', + }); + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.compat, { + 'api.Node.localName': { + support: { + chrome: { + lhs: [ + { version_added: '50' }, + { version_added: '1', version_removed: '45' }, + ], + rhs: [ + { version_removed: '55' }, // this was added + { version_added: '50' }, + { version_added: '1', version_removed: '45' }, + ], + }, + }, + }, + }); + }); + + it('change array statement into simple statement', () => { + rhs.api.Node.localName.__compat.support.chrome = { + version_added: '1', + }; + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.compat, { + 'api.Node.localName': { + support: { + chrome: { + lhs: [ + { version_added: '50' }, + { version_added: '1', version_removed: '45' }, + ], + rhs: { version_added: '1' }, + }, + }, + }, + }); + }); + + it('add new browser support statement', () => { + rhs.api.Node.__compat.support.opera = { + version_added: '1', + }; + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.compat, { + 'api.Node': { + support: { + opera: { + lhs: undefined, + rhs: { version_added: '1' }, + }, + }, + }, + }); + }); + + it('remove existing browser support statement', () => { + delete rhs.api.Node.__compat.support.chrome; + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.compat, { + 'api.Node': { + support: { + chrome: { + lhs: { version_added: '1' }, + rhs: undefined, + }, + }, + }, + }); + }); + + it('change experimental status', () => { + rhs.api.Node.__compat.status.experimental = true; + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.compat, { + 'api.Node': { + status: { experimental: true }, + }, + }); + }); + + it('change standard_track status', () => { + rhs.api.Node.__compat.status.standard_track = false; + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.compat, { + 'api.Node': { + status: { standard_track: false }, + }, + }); + }); + + it('change deprecated status', () => { + rhs.api.Node.__compat.status.deprecated = true; + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.compat, { + 'api.Node': { + status: { deprecated: true }, + }, + }); + }); + + it('change multiple statuses', () => { + rhs.api.Node.__compat.status.experimental = true; + rhs.api.Node.__compat.status.deprecated = true; + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.compat, { + 'api.Node': { + status: { deprecated: true, experimental: true }, + }, + }); + }); + + it('add bogus status', () => { + rhs.api.Node.__compat.status.bogus = 42; + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.compat, { + 'api.Node': { + status: { bogus: 42 }, + }, + }); + }); + + it('add new feature', () => { + rhs.api.AudioNode = { + __compat: { + support: { + firefox: { version_added: '12' }, + safari: { version_added: '13' }, + }, + status: { standard_track: true }, + }, + }; + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.compat, { + 'api.AudioNode': { + support: { + firefox: { + lhs: undefined, + rhs: { version_added: '12' }, + }, + safari: { + lhs: undefined, + rhs: { version_added: '13' }, + }, + }, + status: { standard_track: true }, + }, + }); + }); + + // Deletions aren't explained in detail, as that's not very useful. + it('delete feature', () => { + delete rhs.api.Node; + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.other, ['api.Node']); + }); + + it('delete subfeature', () => { + delete rhs.api.Node.localName; + const diff = bcdDiff(lhs, rhs); + assert.deepStrictEqual(diff.other, ['api.Node.localName']); + }); +}); + +describe('pretty', () => { + it('unset', () => { + assert.strictEqual(pretty(false), 'unset'); + assert.strictEqual(pretty(null), 'unset'); + assert.strictEqual(pretty(undefined), 'unset'); + }); + + it('just added', () => { + assert.strictEqual(pretty({ version_added: '40' }), '40'); + }); + + it('added and removed', () => { + assert.strictEqual( + pretty({ + version_added: '40', + version_removed: '45', + }), + '40-45', + ); + }); + + it('added, removed and added again', () => { + assert.strictEqual( + pretty([ + { version_added: '40', version_removed: '45' }, + { version_added: '50' }, + ]), + '(40-45, 50)', + ); // TODO not that great? + }); +});