diff --git a/.circleci/comment.js b/.circleci/comment.js index 8aef4394bb3..cf71c0d2d7f 100644 --- a/.circleci/comment.js +++ b/.circleci/comment.js @@ -1,4 +1,5 @@ const Octokit = require('@octokit/rest'); +const fs = require('fs'); const octokit = new Octokit({ auth: `token ${process.env.GITHUB_TOKEN}` @@ -64,6 +65,7 @@ async function run() { } if (pr != null) { + let diffs = fs.readFileSync('/tmp/dist/ts-diff.txt'); await octokit.issues.createComment({ owner: 'adobe', repo: 'react-spectrum', @@ -75,5 +77,14 @@ async function run() { * [View the storybook-16](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/storybook-16/index.html) * [View the documentation](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/docs/index.html)` }); + if (diffs.length > 0) { + await octokit.issues.createComment({ + owner: 'adobe', + repo: 'react-spectrum', + issue_number: pr, + body: `## API Changes +${diffs} +`}); + } } } diff --git a/.circleci/config.yml b/.circleci/config.yml index 462ab0ff0bd..a0d2eeb32a3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -216,6 +216,57 @@ jobs: name: lint command: yarn lint + ts-build-branch: + executor: rsp-large + steps: + - restore_cache: + key: react-spectrum-{{ .Environment.CACHE_VERSION }}-{{ .Environment.CIRCLE_SHA1 }} + + - run: + name: build branch apis + command: yarn build:api-branch + + - persist_to_workspace: + root: dist + paths: + - 'branch-api/' + + ts-build-fork-point: + executor: rsp-large + steps: + - restore_cache: + key: react-spectrum-{{ .Environment.CACHE_VERSION }}-{{ .Environment.CIRCLE_SHA1 }} + + - run: + name: build fork-point apis + command: | + yarn build:api-branch --githash="origin/main" --output="base-api" && yarn build:api-branch && yarn compare:apis + + - persist_to_workspace: + root: dist + paths: + - 'base-api/' + + ts-diff: + executor: rsp-large + steps: + - restore_cache: + key: react-spectrum-{{ .Environment.CACHE_VERSION }}-{{ .Environment.CIRCLE_SHA1 }} + + - attach_workspace: + at: /tmp/dist + + - run: + name: compare api + command: | + mkdir -p dist + yarn --silent compare:apis --isCI --branch-api-dir="/tmp/dist/branch-api" --base-api-dir="/tmp/dist/base-api" | tee dist/ts-diff.txt + + - persist_to_workspace: + root: dist + paths: + - 'ts-diff.txt' + storybook: executor: rsp-large steps: @@ -349,6 +400,9 @@ jobs: - restore_cache: key: react-spectrum-{{ .Environment.CACHE_VERSION }}-{{ .Environment.CIRCLE_SHA1 }} + - attach_workspace: + at: /tmp/dist + - run: name: comment on pr command: | @@ -398,6 +452,25 @@ workflows: - lint: requires: - install + - ts-build-fork-point: + requires: + - install + filters: + branches: + ignore: main + - ts-build-branch: + requires: + - install + filters: + branches: + ignore: main + - ts-diff: + requires: + - ts-build-fork-point + - ts-build-branch + filters: + branches: + ignore: main - storybook: requires: - install @@ -450,6 +523,7 @@ workflows: branches: ignore: main requires: + - ts-diff - deploy - comment: name: comment-verdaccio diff --git a/package.json b/package.json index 5f2352c82d0..dc7ae057851 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "build:api-published": "node scripts/buildPublishedAPI.js", "build:api-branch": "node scripts/buildBranchAPI.js", "compare:apis": "node scripts/compareAPIs.js", - "check-apis": "yarn build:api-published && yarn build:api-branch && yarn compare:apis", + "check-apis": "yarn build:api-branch --githash=\"origin/main\" --output=\"base-api\" && yarn build:api-branch && yarn compare:apis", + "check-published-apis": "yarn build:api-published && yarn build:api-branch && yarn compare:apis", "bumpVersions": "node scripts/bumpVersions.js", "supportESM": "node scripts/supportESM.js" }, @@ -106,6 +107,7 @@ "core-js": "^3.0.0", "cross-env": "^7.0.2", "cross-spawn": "^7.0.3", + "diff": "^5.1.0", "delta-e": "^0.0.8", "eslint": "^7.10.0", "eslint-plugin-import": "^2.22.1", @@ -127,7 +129,6 @@ "jest-junit": "^12.0.0", "jest-matchmedia-mock": "^1.0.0", "jsdom": "^16.7.0", - "json-diff-ts": "^1.1.0", "lerna": "^3.13.2", "lfcdn": "^0.4.2", "md5": "^2.2.1", diff --git a/packages/@react-aria/dnd/src/utils.ts b/packages/@react-aria/dnd/src/utils.ts index 6008c22e62e..dd2aa128a92 100644 --- a/packages/@react-aria/dnd/src/utils.ts +++ b/packages/@react-aria/dnd/src/utils.ts @@ -62,7 +62,7 @@ function mapModality(modality: string) { modality = 'virtual'; } - if (modality === 'virtual' && 'ontouchstart' in window) { + if (modality === 'virtual' && (typeof window !== 'undefined' && 'ontouchstart' in window)) { modality = 'touch'; } diff --git a/packages/@react-spectrum/contextualhelp/src/ContextualHelp.tsx b/packages/@react-spectrum/contextualhelp/src/ContextualHelp.tsx index eb2b0ede48b..1ce6d35b191 100644 --- a/packages/@react-spectrum/contextualhelp/src/ContextualHelp.tsx +++ b/packages/@react-spectrum/contextualhelp/src/ContextualHelp.tsx @@ -11,7 +11,7 @@ */ import {ActionButton} from '@react-spectrum/button'; -import {classNames, SlotProvider} from '@react-spectrum/utils'; +import {classNames, ClearSlots, SlotProvider} from '@react-spectrum/utils'; import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; import {FocusableRef} from '@react-types/shared'; import HelpOutline from '@spectrum-icons/workflow/HelpOutline'; @@ -52,11 +52,13 @@ function ContextualHelp(props: SpectrumContextualHelpProps, ref: FocusableRef {icon} - - - {children} - - + + + + {children} + + + ); } diff --git a/packages/@react-stately/collections/src/Section.ts b/packages/@react-stately/collections/src/Section.ts index a14f62dc0b3..55d6c8bc006 100644 --- a/packages/@react-stately/collections/src/Section.ts +++ b/packages/@react-stately/collections/src/Section.ts @@ -22,6 +22,7 @@ Section.getCollectionNode = function* getCollectionNode(props: SectionProps; + case 'this': + return ; + case 'symbol': + return ; case 'identifier': return ; case 'string': @@ -88,6 +92,8 @@ export function Type({type}) { return ; case 'application': return ; + case 'typeOperator': + return ; case 'function': return ; case 'parameter': @@ -122,12 +128,24 @@ export function Type({type}) { } case 'conditional': return ; + case 'indexedAccess': + return ; + case 'keyof': + return ; default: console.log('no render component for TYPE', type); return null; } } +function TypeOperator({operator, value}) { + return {operator}{' '}; +} + +function IndexedAccess({objectType, indexType}) { + return []; +} + function StringLiteral({value}) { return {`'${value.replace(/'/, '\\\'')}'`}; } @@ -140,6 +158,14 @@ function BooleanLiteral({value}) { return {'' + value}; } +function Symbol() { + return symbol; +} + +function Keyof({keyof}) { + return {' '}; +} + function Keyword({type}) { let link = getDoc(type); if (link) { @@ -523,21 +549,6 @@ function ObjectType({properties, exact}) { let optional = property.optional; let value = property.value; - // Special handling for methods - if (value && value.type === 'function' && !optional && token === 'method') { - return ( -
- {k} - ( - - ) - {': '} - - {i < arr.length - 1 ? ',' : ''} -
- ); - } - let punc = optional ? '?: ' : ': '; return (
diff --git a/packages/dev/parcel-transformer-docs/DocsTransformer.js b/packages/dev/parcel-transformer-docs/DocsTransformer.js index 3b9deeaeb8d..e57e5c8b795 100644 --- a/packages/dev/parcel-transformer-docs/DocsTransformer.js +++ b/packages/dev/parcel-transformer-docs/DocsTransformer.js @@ -333,13 +333,28 @@ module.exports = new Transformer({ }); } + if (path.isTSTypeOperator()) { + return Object.assign(node, { + type: 'typeOperator', + operator: path.node.operator, + value: processExport(path.get('typeAnnotation')) + }); + } + + if (path.isTSThisType()) { + return Object.assign(node, { + type: 'this' + }); + } + if (path.isTSPropertySignature()) { let name = t.isStringLiteral(path.node.key) ? path.node.key.value : path.node.key.name; let docs = getJSDocs(path); + let value = processExport(path.get('typeAnnotation.typeAnnotation')); return Object.assign(node, addDocs({ type: 'property', name, - value: processExport(path.get('typeAnnotation.typeAnnotation')), + value, optional: path.node.optional || false }, docs)); } @@ -398,6 +413,10 @@ module.exports = new Transformer({ return bindings; } + if (path.isTSSymbolKeyword()) { + return Object.assign(node, {type: 'symbol'}); + } + if (path.isTSBooleanKeyword()) { return Object.assign(node, {type: 'boolean'}); } @@ -508,6 +527,19 @@ module.exports = new Transformer({ }); } + if (path.isTSModuleDeclaration()) { + // TODO: decide how we want to display something from a Global namespace + return node; + } + + if (path.isTSIndexedAccessType()) { + return Object.assign(node, { + type: 'indexedAccess', + objectType: processExport(path.get('objectType')), + indexType: processExport(path.get('indexType')) + }); + } + console.log('UNKNOWN TYPE', path.node.type); } diff --git a/scripts/buildBranchAPI.js b/scripts/buildBranchAPI.js index fd1aa1f4f8f..c678314b01d 100644 --- a/scripts/buildBranchAPI.js +++ b/scripts/buildBranchAPI.js @@ -16,6 +16,14 @@ const packageJSON = require('../package.json'); const path = require('path'); const glob = require('fast-glob'); const spawn = require('cross-spawn'); +let yargs = require('yargs'); + + +let argv = yargs + .option('verbose', {alias: 'v', type: 'boolean'}) + .option('output', {alias: 'o', type: 'string'}) + .option('githash', {type: 'string'}) + .argv; build().catch(err => { console.error(err.stack); @@ -28,6 +36,16 @@ build().catch(err => { * This is run against the current branch by copying the current branch into a temporary directory and building there */ async function build() { + let archiveDir; + if (argv.githash) { + archiveDir = tempy.directory(); + console.log('checking out archive of', argv.githash, 'into', archiveDir); + await run('sh', ['-c', `git archive ${argv.githash} | tar -x -C ${archiveDir}`], {stdio: 'inherit'}); + } + let srcDir = archiveDir ?? path.join(__dirname, '..'); + let distDir = path.join(__dirname, '..', 'dist', argv.output ?? 'branch-api'); + // if we already have a directory with a built dist, remove it so we can write cleanly into it at the end + fs.removeSync(distDir); // Create a temp directory to build the site in let dir = tempy.directory(); console.log(`Building branch api into ${dir}...`); @@ -63,7 +81,7 @@ async function build() { // Add dependencies on each published package to the package.json, and // copy the docs from the current package into the temp dir. - let packagesDir = path.join(__dirname, '..', 'packages'); + let packagesDir = path.join(srcDir, 'packages'); let packages = glob.sync('*/**/package.json', {cwd: packagesDir}); pkg.devDependencies['babel-plugin-transform-glob-import'] = '*'; @@ -77,31 +95,33 @@ async function build() { }`); // Copy necessary code and configuration over - fs.copySync(path.join(__dirname, '..', 'yarn.lock'), path.join(dir, 'yarn.lock')); + fs.copySync(path.join(srcDir, 'yarn.lock'), path.join(dir, 'yarn.lock')); + fs.copySync(path.join(srcDir, 'packages', '@adobe', 'spectrum-css-temp'), path.join(dir, 'packages', '@adobe', 'spectrum-css-temp')); + fs.copySync(path.join(srcDir, 'postcss.config.js'), path.join(dir, 'postcss.config.js')); + fs.copySync(path.join(srcDir, 'lib'), path.join(dir, 'lib')); + fs.copySync(path.join(srcDir, 'CONTRIBUTING.md'), path.join(dir, 'CONTRIBUTING.md')); + // need dev from latest on branch since it will generate the API for diffing, and in older commits it may not be able to do this or + // does it in a different format fs.copySync(path.join(__dirname, '..', 'packages', 'dev'), path.join(dir, 'packages', 'dev')); - fs.copySync(path.join(__dirname, '..', 'packages', '@adobe', 'spectrum-css-temp'), path.join(dir, 'packages', '@adobe', 'spectrum-css-temp')); fs.copySync(path.join(__dirname, '..', '.parcelrc'), path.join(dir, '.parcelrc')); - fs.copySync(path.join(__dirname, '..', 'postcss.config.js'), path.join(dir, 'postcss.config.js')); - fs.copySync(path.join(__dirname, '..', 'lib'), path.join(dir, 'lib')); - fs.copySync(path.join(__dirname, '..', 'CONTRIBUTING.md'), path.join(dir, 'CONTRIBUTING.md')); // Only copy babel patch over - let patches = fs.readdirSync(path.join(__dirname, '..', 'patches')); + let patches = fs.readdirSync(path.join(srcDir, 'patches')); let babelPatch = patches.find(name => name.startsWith('@babel')); - fs.copySync(path.join(__dirname, '..', 'patches', babelPatch), path.join(dir, 'patches', babelPatch)); + fs.copySync(path.join(srcDir, 'patches', babelPatch), path.join(dir, 'patches', babelPatch)); let excludeList = ['@react-spectrum/story-utils']; // Copy packages over to temp dir console.log('copying over'); for (let p of packages) { if (!p.includes('spectrum-css') && !p.includes('dev/')) { - let json = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'packages', p)), 'utf8'); + let json = JSON.parse(fs.readFileSync(path.join(srcDir, 'packages', p)), 'utf8'); if (json.name in excludeList) { continue; } - fs.copySync(path.join(__dirname, '..', 'packages', path.dirname(p)), path.join(dir, 'packages', path.dirname(p)), {dereference: true}); + fs.copySync(path.join(srcDir, 'packages', path.dirname(p)), path.join(dir, 'packages', path.dirname(p)), {dereference: true}); if (!p.includes('@react-types')) { delete json.types; @@ -122,11 +142,14 @@ async function build() { // Build the website console.log('building api files'); - await run('yarn', ['parcel', 'build', 'packages/@react-{spectrum,aria,stately}/*/', 'packages/@internationalized/*/', '--target', 'apiCheck'], {cwd: dir, stdio: 'inherit'}); + await run('yarn', ['parcel', 'build', 'packages/@react-{spectrum,aria,stately}/*/', 'packages/@internationalized/{message,string,date,number}', '--target', 'apiCheck'], {cwd: dir, stdio: 'inherit'}); // Copy the build back into dist, and delete the temp dir. - fs.copySync(path.join(dir, 'packages'), path.join(__dirname, '..', 'dist', 'branch-api'), {dereference: true}); + fs.copySync(path.join(dir, 'packages'), distDir, {dereference: true}); fs.removeSync(dir); + if (archiveDir) { + fs.removeSync(archiveDir); + } } function run(cmd, args, opts) { diff --git a/scripts/buildPublishedAPI.js b/scripts/buildPublishedAPI.js index 8fe8c21d603..19f1be3e7cb 100644 --- a/scripts/buildPublishedAPI.js +++ b/scripts/buildPublishedAPI.js @@ -16,6 +16,13 @@ const packageJSON = require('../package.json'); const path = require('path'); const glob = require('fast-glob'); const spawn = require('cross-spawn'); +let yargs = require('yargs'); + + +let argv = yargs + .option('verbose', {alias: 'v', type: 'boolean'}) + .option('output', {alias: 'o', type: 'string'}) + .argv; build().catch(err => { console.error(err.stack); @@ -28,6 +35,9 @@ build().catch(err => { * This is run against a downloaded copy of the last published version of each package into a temporary directory and build there */ async function build() { + let distDir = argv.output ?? path.join(__dirname, '..', 'dist', argv.output ?? 'base-api'); + // if we already have a directory with a built dist, remove it so we can write cleanly into it at the end + fs.removeSync(distDir); // Create a temp directory to build the site in let dir = tempy.directory(); console.log(`Building published api into ${dir}...`); @@ -86,6 +96,7 @@ async function build() { } }; + console.log('add packages to download from npm'); // Add dependencies on each published package to the package.json, and // copy the docs from the current package into the temp dir. let packagesDir = path.join(__dirname, '..', 'packages'); @@ -93,7 +104,17 @@ async function build() { for (let p of packages) { let json = JSON.parse(fs.readFileSync(path.join(packagesDir, p), 'utf8')); if (!json.private && json.name !== '@adobe/react-spectrum') { - pkg.dependencies[json.name] = 'latest'; + try { + // this npm view will fail if the package isn't on npm + // otherwise we want to check if there is any version that isn't a nightly + let results = JSON.parse(await run('npm', ['view', json.name, 'versions', '--json'])); + if (results.some(version => !version.includes('nightly'))) { + pkg.dependencies[json.name] = 'latest'; + console.log('added', json.name); + } + } catch (e) { + // continue + } } } pkg.devDependencies['babel-plugin-transform-glob-import'] = '*'; @@ -102,6 +123,7 @@ async function build() { fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg, false, 2)); // Install dependencies from npm + console.log('install our latest packages from npm'); await run('yarn', {cwd: dir, stdio: 'inherit'}); fs.writeFileSync(path.join(dir, 'babel.config.json'), `{ @@ -153,28 +175,32 @@ async function build() { // Build the website console.log('building api files'); - await run('yarn', ['parcel', 'build', 'packages/@react-{spectrum,aria,stately}/*/', 'packages/@internationalized/*/'], {cwd: dir, stdio: 'inherit'}); + await run('yarn', ['parcel', 'build', 'packages/@react-{spectrum,aria,stately}/*/', 'packages/@internationalized/{message,string,date,number}'], {cwd: dir, stdio: 'inherit'}); // Copy the build back into dist, and delete the temp dir. // dev/docs/node_modules has some react spectrum components, we don't want those, and i couldn't figure out how to not build them // it probably means two different versions, so there may be a bug lurking here fs.removeSync(path.join(dir, 'packages', 'dev')); fs.removeSync(path.join(dir, 'packages', '@react-spectrum', 'button', 'node_modules')); - fs.copySync(path.join(dir, 'packages'), path.join(__dirname, '..', 'dist', 'published-api')); + fs.copySync(path.join(dir, 'packages'), distDir); fs.removeSync(dir); } function run(cmd, args, opts) { return new Promise((resolve, reject) => { let child = spawn(cmd, args, opts); + let result = ''; + child.stdout?.on('data', function(data) { + result += data.toString(); + }); child.on('error', reject); - child.on('close', code => { + child.on('close', (code) => { if (code !== 0) { reject(new Error('Child process failed')); return; } - resolve(); + resolve(result); }); }); } diff --git a/scripts/compareAPIs.js b/scripts/compareAPIs.js index 8cb0f04e5d0..30422e98c81 100644 --- a/scripts/compareAPIs.js +++ b/scripts/compareAPIs.js @@ -2,360 +2,545 @@ let fs = require('fs-extra'); let fg = require('fast-glob'); let path = require('path'); -let changesets = require('json-diff-ts'); let util = require('util'); -let {walkObject} = require('walk-object'); let chalk = require('chalk'); let yargs = require('yargs'); +let Diff = require('diff'); let argv = yargs .option('verbose', {alias: 'v', type: 'boolean'}) .option('organizedBy', {choices: ['type', 'change']}) .option('rawNames', {type: 'boolean'}) + .option('package', {type: 'string'}) + .option('interface', {type: 'string'}) + .option('isCI', {type: 'boolean'}) + .option('base-api-dir', {type: 'string'}) + .option('branch-api-dir', {type: 'string'}) .argv; +let allChanged = new Set(); + +// {'useSliderState' => [ 'SliderStateOptions', 'SliderState' ], ... } +let dependantOnLinks = new Map(); +let currentlyProcessing = ''; +let depth = 0; + compare().catch(err => { console.error(err.stack); process.exit(1); }); + /** - * This takes the json files generated by the buildBranchAPI and buildPublishedAPI and diffs each of the corresponding - * json files. It outputs a JSON string that tells if each property is an addition, removal, or addition to the API. - * From this, we can determine if we've made a breaking change or introduced an API we meant to be private. - * We can high level some of this information in a series of summary messages that are color coded at the tail of the run. + * This takes the json files generated by the buildBranchAPI and buildPublishedAPI and + * reconstructs our interfaces and create a graph of all dependencies between interfaces. + * From there, we diff the reconstructed interfaces and track all that have changes. + * We use the graph to communicate what interfaces have changed as a result of a + * dependency changing. + * We build up strings of the diffs and make them easy to read in both a github comment + * as well as the local console. */ async function compare() { - let branchDir = path.join(__dirname, '..', 'dist', 'branch-api'); - let publishedDir = path.join(__dirname, '..', 'dist', 'published-api'); + let branchDir = argv['branch-api-dir'] || path.join(__dirname, '..', 'dist', 'branch-api'); + let publishedDir = argv['base-api-dir'] || path.join(__dirname, '..', 'dist', 'base-api'); if (!(fs.existsSync(branchDir) && fs.existsSync(publishedDir))) { - console.log(chalk.redBright(`you must have both a branchDir ${branchDir} and publishedDir ${publishedDir}`)); + console.log(chalk.redBright(`you must have both a branchDir ${branchDir} and baseDir ${publishedDir}`)); return; } - let summaryMessages = []; let branchAPIs = fg.sync(`${branchDir}/**/api.json`); let publishedAPIs = fg.sync(`${publishedDir}/**/api.json`); let pairs = []; - // we only care about changes to already published APIs, so find all matching pairs based on what's been published + + // find all matching pairs based on what's been published for (let pubApi of publishedAPIs) { let pubApiPath = pubApi.split(path.sep); let sharedPath = path.join(...pubApiPath.slice(pubApiPath.length - 4)); - let matchingBranchFile; + let found = false; for (let branchApi of branchAPIs) { if (branchApi.includes(sharedPath)) { - matchingBranchFile = branchApi; + found = true; pairs.push({pubApi, branchApi}); break; } } - if (!matchingBranchFile) { - summaryMessages.push({msg: `removed module ${pubApi}`, severity: 'error'}); + if (!found) { + pairs.push({pubApi, branchApi: null}); } } - let privatePackages = []; - // don't care about not private APIs, but we do care if we're about to publish a new one + + // don't care about private APIs, but we do care if we're about to publish a new one for (let branchApi of branchAPIs) { let branchApiPath = branchApi.split(path.sep); let sharedPath = path.join(...branchApiPath.slice(branchApiPath.length - 4)); - let matchingPubFile; + let found = false; for (let pubApi of publishedAPIs) { if (pubApi.includes(sharedPath)) { - matchingPubFile = pubApi; - // don't re-add to pairs + found = true; break; } } - if (!matchingPubFile) { - let json = JSON.parse(fs.readFileSync(path.join(branchApi, '..', '..', 'package.json')), 'utf8'); - if (!json.private) { - summaryMessages.push({msg: `added module ${branchApi}`, severity: 'warn'}); - } else { - privatePackages.push(branchApi); - } + let json = JSON.parse(fs.readFileSync(path.join(branchApi, '..', '..', 'package.json')), 'utf8'); + if (!found && !json.private) { + pairs.push({pubApi: null, branchApi}); } } - let count = 0; - let diffs = {}; + let allDiffs = {}; for (let pair of pairs) { - let diff = getDiff(summaryMessages, pair); - if (diff.diff.length > 0) { - count += 1; - diffs[diff.name] = diff.diff; + let {diffs, name} = getDiff(pair); + if (diffs && diffs.length > 0) { + allDiffs[name] = diffs; } } - let modulesAdded = branchAPIs.length - privatePackages.length - publishedAPIs.length; - if (modulesAdded !== 0) { - summaryMessages.push({msg: `${Math.abs(modulesAdded)} modules ${modulesAdded > 0 ? 'added' : 'removed'}`, severity: modulesAdded > 0 ? 'warn' : 'error'}); - } else { - summaryMessages.push({msg: 'no modules removed or added', severity: 'info'}); + + for (let [, diffs] of Object.entries(allDiffs)) { + for (let {result: diff, simplifiedName} of diffs) { + if (diff.length > 0) { + if (allChanged.has(simplifiedName)) { + console.log(simplifiedName, 'already in set'); + } else { + allChanged.add(simplifiedName); + } + } + } } - if (count !== 0) { - summaryMessages.push({msg: `${count} modules had changes to their API ${Object.keys(diffs).map(key => `\n - ${simplifyModuleName(key)}`)}`, severity: 'warn'}); - } else { - summaryMessages.push({msg: 'no modules changed their API', severity: 'info'}); - } - summaryMessages.push({}); - let matches = analyzeDiffs(diffs); - let moreMessages = generateMessages(matches); - [...summaryMessages, ...moreMessages].forEach(({msg, severity}) => { - if (!msg) { - console.log(''); - return; + let invertedDeps = invertDependencies(); + let messages = []; + for (let [name, diffs] of Object.entries(allDiffs)) { + let changes = []; + for (let {result: diff, simplifiedName} of diffs) { + let changedByDeps = followDependencies(simplifiedName); + if (diff.length > 0) { + let affected = followInvertedDependencies(simplifiedName, invertedDeps); + // combine export change messages + changes.push(` +#### ${simplifiedName} +${changedByDeps.length > 0 ? `changed by: + - ${changedByDeps.join('\n - ')}\n\n` : ''}${diff.length > 0 ? diff : ''}${affected.length > 0 ? ` +it changed: + - ${affected.join('\n - ')} +` : ''} +`); + } } - let color = 'default'; - switch (severity) { - case 'info': - color = 'greenBright'; - break; - case 'log': - color = 'blueBright'; - break; - case 'warn': - color = 'yellowBright'; - break; - case 'error': - color = 'redBright'; - break; - default: - color = 'defaultBright'; - break; + if (changes.length > 0) { + // combine the package change messages + messages.push(` +### ${name.replace('/dist/api.json', '').replace(/^\//, '')} +${changes.join('\n')} +----------------------------------- +` + ); } - console[severity](chalk[color](msg)); - }); + } + if (messages.length) { + messages.forEach(message => { + console.log(message); + }); + } } -function getDiff(summaryMessages, pair) { - let name = pair.branchApi.replace(/.*branch-api/, ''); - // console.log(`diffing ${name}`); - let publishedApi = fs.readJsonSync(pair.pubApi); - delete publishedApi.links; - walkObject(publishedApi, ({value, location, isLeaf}) => { - if (!isLeaf && value.id && typeof value.id === 'string') { - value.id = value.id.replace(/.*(node_modules|packages)/, ''); +// takes an interface name and follows all the interfaces dependencies to +// see if the interface changed as a result of a dependency changing +function followDependencies(iname) { + let visited = new Set(); + let changedDeps = []; + function visit(iname) { + if (visited.has(iname)) { + return; } - }); - let branchApi = fs.readJsonSync(pair.branchApi); - delete branchApi.links; - walkObject(branchApi, ({value, location, isLeaf}) => { - if (!isLeaf && value.id && typeof value.id === 'string') { - value.id = value.id.replace(/.*(node_modules|packages)/, ''); + visited.add(iname); + let dependencies = dependantOnLinks.get(iname); + if (dependencies && dependencies.length > 0) { + for (let dep of dependencies) { + if (allChanged.has(dep)) { + changedDeps.push(dep); + } + visit(dep); + } } - }); - let diff = changesets.diff(publishedApi, branchApi); - if (diff.length > 0 && argv.verbose) { - console.log(`diff found in ${name}`); - // for now print the whole diff - console.log(util.inspect(diff, {depth: null})); } + visit(iname); + return changedDeps; +} - let publishedExports = publishedApi.exports; - let branchExports = branchApi.exports; - let addedExports = Object.keys(branchExports).filter(key => !publishedExports[key]); - let removedExports = Object.keys(publishedExports).filter(key => !branchExports[key]); - if (addedExports.length > 0) { - summaryMessages.push({msg: `added exports ${addedExports} to ${pair.branchApi}`, severity: 'warn'}); - } - if (removedExports.length > 0) { - summaryMessages.push({msg: `removed exports ${removedExports} from ${pair.branchApi}`, severity: 'error'}); +function invertDependencies() { + let changedUpstream = new Map(); + for (let [key, value] of dependantOnLinks.entries()) { + for (let name of value) { + if (changedUpstream.has(name)) { + changedUpstream.get(name).push(key); + } else { + changedUpstream.set(name, [key]); + } + } } - return {diff, name}; + + return changedUpstream; } -function analyzeDiffs(diffs) { - let matches = new Map(); - let used = new Map(); - for (let [key, value] of Object.entries(diffs)) { - walkChanges(value, { - UPDATE: (change, path) => { - if (used.has(change) || !(change.key === 'type' && (change.value === 'link' || change.oldValue === 'link'))) { - return; - } - matches.set(change, [`${key}:${path}`]); - used.set(change, true); - for (let [name, diff] of Object.entries(diffs)) { - walkChanges(diff, { - UPDATE: (addChange, addPath) => { - let subDiff = changesets.diff(addChange, change); - if (subDiff.length === 0) { - // guaranteed to have the match because we added it before doing this walk - let match = matches.get(change); - if (name !== key && !used.has(addChange)) { - match.push(`${name}:${addPath}`); - used.set(addChange, true); - } - } - } - }); - } - }, - ADD: (change, path) => { - if (used.has(change)) { - return; - } - matches.set(change, [`${key}:${path}`]); - used.set(change, true); - for (let [name, diff] of Object.entries(diffs)) { - walkChanges(diff, { - ADD: (addChange, addPath) => { - let subDiff = changesets.diff(addChange, change); - if (subDiff.length === 0) { - // guaranteed to have the match because we added it before doing this walk - let match = matches.get(change); - if (name !== key && !used.has(addChange)) { - match.push(`${name}:${addPath}`); - used.set(addChange, true); - } - } - } - }); - } - }, - REMOVE: (change, path) => { - if (used.has(change)) { - return; - } - matches.set(change, [`${key}:${path}`]); - used.set(change, true); - for (let [name, diff] of Object.entries(diffs)) { - walkChanges(diff, { - REMOVE: (addChange, addPath) => { - let subDiff = changesets.diff(addChange, change); - if (subDiff.length === 0) { - // guaranteed to have the match because we added it before doing this walk - let match = matches.get(change); - if (name !== key && !used.has(addChange)) { - match.push(`${name}:${addPath}`); - used.set(addChange, true); - } - } - } - }); +// takes an interface name and follows all the interfaces dependencies to +// see if the interface changed as a result of a dependency changing +function followInvertedDependencies(iname, deps) { + let visited = new Set(); + let affectedInterfaces = []; + function visit(iname) { + if (visited.has(iname)) { + return; + } + visited.add(iname); + if (deps.has(iname)) { + let affected = deps.get(iname); + if (affected && affected.length > 0) { + for (let dep of affected) { + affectedInterfaces.push(dep); + visit(dep); } } - }); + } } - return matches; + visit(iname); + return affectedInterfaces; } -// Recursively walks a json object and calls a processing function on each node based on its type ["ADD", "REMOVE", "UPDATE"] -// tracks the path it's taken through the json object and passes that to the processing function -function walkChanges(changes, process, path = '') { - for (let change of changes) { - if (process[change.type]) { - process[change.type](change, path); +function getAPI(filePath) { + let json = fs.readJsonSync(filePath); + + return json; +} + +// bulk of the logic, read the api files, rebuild the interfaces, diff those reconstructions +function getDiff(pair) { + let name; + if (pair.branchApi) { + name = pair.branchApi.replace(/.*branch-api/, ''); + } else { + name = pair.pubApi.replace(/.*published-api/, ''); + } + + if (argv.package && !argv.package.includes(name)) { + return {diff: {}, name}; + } + if (argv.verbose) { + console.log(`diffing ${name}`); + } + let publishedApi = pair.pubApi === null ? {} : getAPI(pair.pubApi); + let branchApi = pair.branchApi === null ? {} : getAPI(pair.branchApi); + let publishedInterfaces = rebuildInterfaces(publishedApi); + let branchInterfaces = rebuildInterfaces(branchApi); + let allExportNames = [...new Set([...Object.keys(publishedApi.exports), ...Object.keys(branchApi.exports)])]; + let allInterfaces = [...new Set([...Object.keys(publishedInterfaces), ...Object.keys(branchInterfaces)])]; + let formattedPublishedInterfaces = ''; + let formattedBranchInterfaces = ''; + formattedPublishedInterfaces = formatInterfaces(publishedInterfaces, allInterfaces); + formattedBranchInterfaces = formatInterfaces(branchInterfaces, allInterfaces); + + let diffs = []; + allInterfaces.forEach((iname, index) => { + if (argv.interface && argv.interface !== iname) { + return; } - if (change.changes && change.changes.length >= 0) { - walkChanges(change.changes, process, `${path}${path.length > 0 ? `.${change.key}` : change.key}`); + let simplifiedName = allExportNames[index]; + let codeDiff = Diff.structuredPatch(iname, iname, formattedPublishedInterfaces[index], formattedBranchInterfaces[index], {newlineIsToken: true}); + if (argv.verbose) { + console.log(util.inspect(codeDiff, {depth: null})); } - } + let result = []; + let prevEnd = 1; // diff lines are 1 indexed + let lines = formattedPublishedInterfaces[index].split('\n'); + codeDiff.hunks.forEach(hunk => { + if (hunk.oldStart > prevEnd) { + result = [...result, ...lines.slice(prevEnd - 1, hunk.oldStart - 1).map((item, index) => ` ${item}`)]; + } + if (argv.isCI) { + result = [...result, ...hunk.lines]; + } else { + result = [...result, ...hunk.lines.map(line => { + if (line.startsWith('+')) { + return chalk.whiteBright.bgGreen(line); + } else if (line.startsWith('-')) { + return chalk.whiteBright.bgRed(line); + } + return line; + })]; + } + prevEnd = hunk.oldStart + hunk.oldLines; + }); + let joinedResult = ''; + if (codeDiff.hunks.length > 0) { + joinedResult = [...result, ...lines.slice(prevEnd).map((item, index) => ` ${item}`)].join('\n'); + } + if (argv.isCI && result.length > 0) { + joinedResult = `\`\`\`diff +${joinedResult} +\`\`\``; + } + diffs.push({iname, result: joinedResult, simplifiedName}); + }); + + return {diffs, name}; } -function generateMessages(matches) { - let summaryMessages = []; +// mirrors dev/docs/src/types.js for the most part +// "rendering" our types to a string instead of React components +function processType(value) { + if (!value) { + console.trace('UNTYPED', value); + return 'UNTYPED'; + } + if (Object.keys(value).length === 0) { + return '{}'; + } + if (value.type === 'any') { + return 'any'; + } + if (value.type === 'null') { + return 'null'; + } + if (value.type === 'undefined') { + return 'undefined'; + } + if (value.type === 'void') { + return 'void'; + } + if (value.type === 'unknown') { + return 'unknown'; + } + if (value.type === 'never') { + return 'never'; + } + if (value.type === 'this') { + return 'this'; + } + if (value.type === 'symbol') { + return 'symbol'; + } + if (value.type === 'identifier') { + return value.name; + } + if (value.type === 'string') { + if (value.value) { + return `'${value.value}'`; + } + return 'string'; + } + if (value.type === 'number') { + return 'number'; + } + if (value.type === 'boolean') { + return 'boolean'; + } + if (value.type === 'union') { + return value.elements.map(processType).join(' | '); + } + if (value.type === 'intersection') { + return `(${value.types.map(processType).join(' & ')})`; + } + if (value.type === 'application') { + let name = value.base.name; + if (!name) { + name = processType(value.base); + } + return `${name}<${value.typeParameters.map(processType).join(', ')}>`; + } + if (value.type === 'typeOperator') { + return `${value.operator} ${processType(value.value)}`; + } + if (value.type === 'function') { + return `(${value.parameters.map(processType).join(', ')}) => ${value.return ? processType(value.return) : 'void'}`; + } + if (value.type === 'parameter') { + return processType(value.value); + } + if (value.type === 'link') { + let name = value.id.substr(value.id.lastIndexOf(':') + 1); + if (dependantOnLinks.has(currentlyProcessing)) { + let foo = dependantOnLinks.get(currentlyProcessing); + if (!foo.includes(name)) { + foo.push(name); + } + } else { + dependantOnLinks.set(currentlyProcessing, [name]); + } + return name; + } + // interface still needed if we have it at top level? + if (value.type === 'object') { + if (value.properties) { + return `${value.exact ? '{\\' : '{'} + ${Object.values(value.properties).map(property => { + depth += 2; + let result = ' '.repeat(depth); + result = `${result}${property.indexType ? '[' : ''}${property.name}${property.indexType ? `: ${processType(property.indexType)}]` : ''}${property.optional ? '?' : ''}: ${processType(property.value)}`; + depth -= 2; + return result; + }).join('\n')} +${value.exact ? '\\}' : '}'}`; + } + return '{}'; + } + if (value.type === 'alias') { + return processType(value.value); + } + if (value.type === 'array') { + return `Array<${processType(value.elementType)}>`; + } + if (value.type === 'tuple') { + return `[${value.elements.map(processType).join(', ')}]`; + } + if (value.type === 'typeParameter') { + let typeParam = value.name; + if (value.constraint) { + typeParam = typeParam + ` extends ${processType(value.constraint)}`; + } + if (value.default) { + typeParam = typeParam + ` = ${processType(value.default)}`; + } + return typeParam; + } + if (value.type === 'component') { + let props = value.props; + if (props.type === 'application') { + props = props.base; + } + if (props.type === 'link') { + // create links provider + // props = links[props.id]; + } + return processType(props); + } + if (value.type === 'conditional') { + return `${processType(value.checkType)} extends ${processType(value.extendsType)} ? ${processType(value.trueType)}${value.falseType.type === 'conditional' ? ' :\n' : ' : '}${processType(value.falseType)}`; + } + if (value.type === 'indexedAccess') { + return `${processType(value.objectType)}[${processType(value.indexType)}]`; + } + if (value.type === 'keyof') { + return `keyof ${processType(value.keyof)}`; + } + console.log('unknown type', value); +} - if (argv.organizedBy === 'change') { - for (let [key, value] of matches) { - /** matches - * {"identifier UPDATED to link": ["/@react-aria/i18n:exports.useDateFormatter.return", "/@react-aria/textfield:exports.useTextField.parameters.1.value.base"]} - */ - let targets = value.map(loc => simplifyModuleName(loc)).map(loc => { - if (!argv.rawNames) { - return `\n - ${loc.split(':')[0]}:${getRealName(loc, loc.split(':')[1])}`; - } else { - return `\n - ${loc}`; +function rebuildInterfaces(json) { + let exports = {}; + if (!json.exports) { + return exports; + } + Object.keys(json.exports).forEach((key) => { + currentlyProcessing = key; + if (key === 'links') { + console.log('skipping links'); + return; + } + let item = json.exports[key]; + if (item?.type == null) { + // todo what to do here?? + return; + } + if (item.props?.type === 'identifier') { + // todo what to do here?? + return; + } + if (item.type === 'component') { + let compInterface = {}; + if (item.props && item.props.properties) { + Object.entries(item.props.properties).sort((([keyA], [keyB]) => keyA > keyB ? 1 : -1)).forEach(([, prop]) => { + if (prop.access === 'private') { + return; + } + let name = prop.name; + if (item.name === null) { + name = key; + } + let optional = prop.optional; + let defaultVal = prop.default; + let value = processType(prop.value); + compInterface[name] = {optional, defaultVal, value}; + }); + } else if (item.props && item.props.type === 'link') { + let prop = item.props; + let name = item.name; + if (item.name === null) { + name = key; } - }); - let severity = 'log'; - let message = `${key.key} ${key.type} to:${targets}`; - if (key.type === 'REMOVE') { - message = `${key.key} ${key.type} from:${targets}`; - severity = 'warn'; + let optional = prop.optional; + let defaultVal = prop.default; + let value = processType(prop); + compInterface[name] = {optional, defaultVal, value}; } - if (key.type === 'UPDATE') { - message = `${key.oldValue} UPDATED to ${key.value}:${targets}`; + let name = item.name ?? key; + if (item.typeParameters.length > 0) { + name = name + `<${item.typeParameters.map(processType).sort().join(', ')}>`; } - summaryMessages.push({msg: message, severity}); - } - } else { - let invertedMap = new Map(); - /** invertedMap - * {"/@react-aria/i18n:exports.useDateFormatter.return": ["identifier UPDATED to link"], - * "exports.useTextField.parameters.1.value.base": ["identifier UPDATED to link"]} - */ - for (let [key, value] of matches) { - for (let loc of value.map(simplifyModuleName)) { - let entry = invertedMap.get(loc); - if (entry) { - entry.push(key); - } else { - invertedMap.set(loc, [key]); + exports[name] = compInterface; + } else if (item.type === 'function') { + let funcInterface = {}; + Object.entries(item.parameters).sort((([keyA], [keyB]) => keyA > keyB ? 1 : -1)).forEach(([, param]) => { + if (param.access === 'private') { + return; } + let name = param.name; + let optional = param.optional; + let defaultVal = param.default; + let value = processType(param.value); + funcInterface[name] = {optional, defaultVal, value}; + }); + let returnVal = processType(item.return); + let name = item.name ?? key; + if (item.typeParameters.length > 0) { + name = name + `<${item.typeParameters.map(processType).sort().join(', ')}>`; } - } - - for (let [key, value] of invertedMap) { - let realName = getRealName(key); - let targets = value.map(change => { - let message = ''; - switch (change.type) { - case 'REMOVE': - message = chalk.redBright(`${change.key} ${change.type}D`); - break; - case 'UPDATE': - message = `${change.oldValue} UPDATED to ${change.value}`; - break; - default: - message = `${change.key} ${change.type}ED`; - break; + exports[name] = {...funcInterface, returnVal}; + } else if (item.type === 'interface') { + let funcInterface = {}; + Object.entries(item.properties).sort((([keyA], [keyB]) => keyA > keyB ? 1 : -1)).forEach(([, property]) => { + if (property.access === 'private' || property.access === 'protected') { + return; } - return `\n - ${message}`; + let name = property.name; + let optional = property.optional; + let defaultVal = property.default; + let value = processType(property.value); + // TODO: what to do with defaultVal and optional + funcInterface[name] = {optional, defaultVal, value}; }); - let severity = 'log'; - if (!argv.rawNames) { - summaryMessages.push({msg: `${key.split(':')[0]}:${realName}${targets}`, severity}); - } else { - summaryMessages.push({msg: `${key}${targets}`, severity}); + let name = item.name ?? key; + if (item.typeParameters.length > 0) { + name = name + `<${item.typeParameters.map(processType).sort().join(', ')}>`; } - } - } - return summaryMessages; -} + exports[name] = funcInterface; + } else if (item.type === 'link') { + let links = json.links; + if (links[item.id]) { + let link = links[item.id]; -/** - * Looks up the path through the json object and tries to replace hard to read values with easier to read ones - * @param diffName - /@react-aria/textfield:exports.useTextField.parameters.1.value.base - * @param type - ["ADD", "REMOVE", "UPDATE"] - * @returns {string} - /@react-aria/textfield:exports.useTextField.parameters.ref.value.base - */ -function getRealName(diffName, type = 'ADD') { - let [file, jsonPath] = diffName.split(':'); - let filePath = path.join(__dirname, '..', 'dist', type === 'REMOVE' ? 'published-api' : 'branch-api', file, 'dist', 'api.json'); - let json = JSON.parse(fs.readFileSync(filePath), 'utf8'); - let name = []; - for (let property of jsonPath.split('.')) { - json = json[property]; - name.push(json.name ?? property); - } - return name.join('.'); + let name = link.name; + let optional = link.optional; + let defaultVal = link.default; + let value = processType(link.value); + let isType = true; + exports[name] = {isType, optional, defaultVal, value}; + } + } else { + console.log('unknown top level export', item); + } + }); + return exports; } -function simplifyModuleName(apiJsonPath) { - return apiJsonPath.replace(/\/dist\/.*\.json/, ''); +function formatProp([name, prop]) { + return ` ${name}${prop.optional ? '?' : ''}: ${prop.value}${prop.defaultVal != null ? ` = ${prop.defaultVal}` : ''}`; } -function run(cmd, args, opts) { - return new Promise((resolve, reject) => { - let child = spawn(cmd, args, opts); - child.on('error', reject); - child.on('close', code => { - if (code !== 0) { - reject(new Error('Child process failed')); - return; - } - - resolve(); - }); +function formatInterfaces(interfaces, allInterfaces) { + return allInterfaces.map(name => { + if (interfaces[name]) { + let output = `${name} {\n`; + output += interfaces[name].isType ? formatProp(name, interfaces[name]) : Object.entries(interfaces[name]).map(formatProp).join('\n'); + return `${output}\n}\n`; + } else { + return '\n'; + } }); } diff --git a/yarn.lock b/yarn.lock index d5f55421987..79230d04bc1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8527,6 +8527,11 @@ diff@^5.0.0: resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== +diff@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" + integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw== + diffie-hellman@^5.0.0: version "5.0.3" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" @@ -13334,11 +13339,6 @@ jsesc@~0.5.0: resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0= -json-diff-ts@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/json-diff-ts/-/json-diff-ts-1.1.0.tgz#2e1bad9573183e89b6c213fcb58a413c90cb747e" - integrity sha512-whEzX6VSmPLg3gBZNBQBzXn2pUoW7LGVif7WF1uiMOC2AHv0XPjBcxutt6yKBZxKzYxqGdN0zXC/hGDZL2nQtw== - json-parse-better-errors@^1.0.0, json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"