diff --git a/package.json b/package.json index c8528028c01e..741e18aeffff 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "deploy": "gulp deploy:devapp", "webdriver-manager": "webdriver-manager", "docs": "gulp docs", - "api": "gulp api-docs" + "api": "gulp api-docs", + "breaking-changes": "gulp breaking-changes" }, "version": "7.0.0-beta.0", "requiredAngularVersion": ">=7.0.0-beta.4", diff --git a/tools/gulp/gulpfile.ts b/tools/gulp/gulpfile.ts index 2def6858c270..ef56169bc9e5 100644 --- a/tools/gulp/gulpfile.ts +++ b/tools/gulp/gulpfile.ts @@ -16,6 +16,7 @@ createPackageBuildTasks(examplesPackage, ['build-examples-module']); createPackageBuildTasks(momentAdapterPackage); import './tasks/aot'; +import './tasks/breaking-changes'; import './tasks/changelog'; import './tasks/ci'; import './tasks/clean'; diff --git a/tools/gulp/tasks/breaking-changes.ts b/tools/gulp/tasks/breaking-changes.ts new file mode 100644 index 000000000000..5fc6bc65649a --- /dev/null +++ b/tools/gulp/tasks/breaking-changes.ts @@ -0,0 +1,109 @@ +import {task} from 'gulp'; +import {join, relative} from 'path'; +import {readFileSync} from 'fs'; +import {bold, red, green} from 'chalk'; +import * as ts from 'typescript'; +import * as tsutils from 'tsutils'; +import {buildConfig} from '../../package-tools/build-config'; + +// Current version from the package.json. Splits it on the dash to ignore `-beta.x` suffixes. +const packageVersion = require(join(buildConfig.projectDir, 'package.json')).version.split('-')[0]; + +// Regex used to extract versions from a string. +const versionRegex = /\d+\.\d+\.\d+/; + +/** + * Goes through all of the TypeScript files in the project and puts + * together a summary of all of the pending and expired breaking changes. + */ +task('breaking-changes', () => { + const projectDir = buildConfig.projectDir; + const configFile = ts.readJsonConfigFile(join(projectDir, 'tsconfig.json'), ts.sys.readFile); + const parsedConfig = ts.parseJsonSourceFileConfigFileContent(configFile, ts.sys, projectDir); + const summary: {[version: string]: string[]} = {}; + + // Go through all the TS files in the project. + parsedConfig.fileNames.forEach(fileName => { + const sourceFile = ts.createSourceFile(fileName, readFileSync(fileName, 'utf8'), + configFile.languageVersion); + const lineRanges = tsutils.getLineRanges(sourceFile); + + // Go through each of the comments of the file. + tsutils.forEachComment(sourceFile, (file, range) => { + const comment = file.substring(range.pos, range.end); + const versionMatch = comment.match(versionRegex); + + // Don't do any extra work if the comment doesn't indicate a breaking change. + if (!versionMatch || comment.indexOf('@breaking-change') === -1) { + return; + } + + // Use a path relative to the project root, in order to make the summary more tidy. + // Also replace escaped Windows slashes with regular forward slashes. + const pathInProject = relative(projectDir, sourceFile.fileName).replace(/\\/g, '/'); + const [version] = versionMatch; + + summary[version] = summary[version] || []; + summary[version].push(` ${pathInProject}: ${formatMessage(comment, range, lineRanges)}`); + }); + }); + + // Go through the summary and log out all of the breaking changes. + Object.keys(summary).forEach(version => { + const isExpired = hasExpired(packageVersion, version); + const status = isExpired ? red('(expired)') : green('(not expired)'); + const header = bold(`Breaking changes for ${version} ${status}:`); + const messages = summary[version].join('\n'); + + console.log(isExpired ? red(header) : header); + console.log(isExpired ? red(messages) : messages, '\n'); + }); +}); + +/** + * Formats a message to be logged out in the breaking changes summary. + * @param comment Contents of the comment that contains the breaking change. + * @param commentRange Object containing info on the position of the comment in the file. + * @param lines Ranges of the lines of code in the file. + */ +function formatMessage(comment: string, commentRange: ts.CommentRange, lines: tsutils.LineRange[]) { + const lineNumber = lines.findIndex(line => line.pos > commentRange.pos); + const messageMatch = comment.match(/@deprecated(.*)|@breaking-change(.*)/); + const message = messageMatch ? messageMatch[0] : ''; + const cleanMessage = message + .replace(/[\*\/\r\n]|@[\w-]+/g, '') + .replace(versionRegex, '') + .trim(); + + return `Line ${lineNumber}, ${cleanMessage || 'No message'}`; +} + + +/** Converts a version string into an object. */ +function parseVersion(version: string) { + const [major = 0, minor = 0, patch = 0] = version.split('.').map(segment => parseInt(segment)); + return {major, minor, patch}; +} + + +/** + * Checks whether a version has expired, based on the current version. + * @param currentVersion Current version of the package. + * @param breakingChange Version that is being checked. + */ +function hasExpired(currentVersion: string, breakingChange: string) { + if (currentVersion === breakingChange) { + return true; + } + + const current = parseVersion(currentVersion); + const target = parseVersion(breakingChange); + + return target.major < current.major || + (target.major === current.major && target.minor < current.minor) || + ( + target.major === current.major && + target.minor === current.minor && + target.patch < current.patch + ); +} diff --git a/tools/tslint-rules/breakingChangeRule.ts b/tools/tslint-rules/breakingChangeRule.ts deleted file mode 100644 index 79e547733117..000000000000 --- a/tools/tslint-rules/breakingChangeRule.ts +++ /dev/null @@ -1,77 +0,0 @@ -import * as ts from 'typescript'; -import * as Lint from 'tslint'; -import * as utils from 'tsutils'; -import * as path from 'path'; - -/** Doc tag that can be used to indicate a breaking change. */ -const BREAKING_CHANGE = '@breaking-change'; - -/** Name of the old doc tag that was being used to indicate a breaking change. */ -const DELETION_TARGET = '@deletion-target'; - -/** - * Rule ensuring that breaking changes have not expired. - * The current version is taken from the `package.json`. - */ -export class Rule extends Lint.Rules.AbstractRule { - apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { - // Split it on the dash to ignore `-beta.x` suffixes. - const packageVersion = require(path.join(process.cwd(), 'package.json')).version.split('-')[0]; - - return this.applyWithFunction(sourceFile, (ctx: Lint.WalkContext) => { - utils.forEachComment(ctx.sourceFile, (file, {pos, end}) => { - const commentText = file.substring(pos, end); - - // TODO(crisbeto): remove this check once most of the pending - // PRs start using `breaking-change`. - if (commentText.indexOf(DELETION_TARGET) > -1) { - ctx.addFailure(pos, end, `${DELETION_TARGET} has been replaced with ${BREAKING_CHANGE}.`); - return; - } - - const hasBreakingChange = commentText.indexOf(BREAKING_CHANGE) > -1; - - if (!hasBreakingChange && commentText.indexOf('@deprecated') > -1) { - ctx.addFailure(pos, end, `@deprecated marker has to have a ${BREAKING_CHANGE}.`); - } if (hasBreakingChange) { - const version = commentText.match(/\d+\.\d+\.\d+/); - - if (!version) { - ctx.addFailure(pos, end, `${BREAKING_CHANGE} must have a version.`); - } else if (this._hasExpired(packageVersion, version[0])) { - ctx.addFailure(pos, end, `Breaking change at ${version[0]} is due to be deleted. ` + - `Current version is ${packageVersion}.`); - } - } - }); - }); - } - - /** - * Checks whether a version has expired, based on the current version. - * @param currentVersion Current version of the package. - * @param breakingChange Version that is being checked. - */ - private _hasExpired(currentVersion: string, breakingChange: string) { - if (currentVersion === breakingChange) { - return true; - } - - const current = this._parseVersion(currentVersion); - const target = this._parseVersion(breakingChange); - - return target.major < current.major || - (target.major === current.major && target.minor < current.minor) || - ( - target.major === current.major && - target.minor === current.minor && - target.patch < current.patch - ); - } - - /** Converts a version string into an object. */ - private _parseVersion(version: string) { - const [major = 0, minor = 0, patch = 0] = version.split('.').map(segment => parseInt(segment)); - return {major, minor, patch}; - } -} diff --git a/tools/tslint-rules/requireBreakingChangeVersionRule.ts b/tools/tslint-rules/requireBreakingChangeVersionRule.ts new file mode 100644 index 000000000000..8d7f13e50bb1 --- /dev/null +++ b/tools/tslint-rules/requireBreakingChangeVersionRule.ts @@ -0,0 +1,38 @@ +import * as ts from 'typescript'; +import * as Lint from 'tslint'; +import * as utils from 'tsutils'; + +/** Doc tag that can be used to indicate a breaking change. */ +const BREAKING_CHANGE = '@breaking-change'; + +/** Name of the old doc tag that was being used to indicate a breaking change. */ +const DELETION_TARGET = '@deletion-target'; + +/** + * Rule that ensures that comments, indicating a deprecation + * or a breaking change, have a valid version. + */ +export class Rule extends Lint.Rules.AbstractRule { + apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { + return this.applyWithFunction(sourceFile, (ctx: Lint.WalkContext) => { + utils.forEachComment(ctx.sourceFile, (file, {pos, end}) => { + const commentText = file.substring(pos, end); + + // TODO(crisbeto): remove this check once most of the pending + // PRs start using `breaking-change`. + if (commentText.indexOf(DELETION_TARGET) > -1) { + ctx.addFailure(pos, end, `${DELETION_TARGET} has been replaced with ${BREAKING_CHANGE}.`); + return; + } + + const hasBreakingChange = commentText.indexOf(BREAKING_CHANGE) > -1; + + if (!hasBreakingChange && commentText.indexOf('@deprecated') > -1) { + ctx.addFailure(pos, end, `@deprecated marker has to have a ${BREAKING_CHANGE}.`); + } if (hasBreakingChange && !/\d+\.\d+\.\d+/.test(commentText)) { + ctx.addFailure(pos, end, `${BREAKING_CHANGE} must have a version.`); + } + }); + }); + } +} diff --git a/tslint.json b/tslint.json index bc5218663610..804529f6822f 100644 --- a/tslint.json +++ b/tslint.json @@ -101,6 +101,7 @@ "no-private-getters": true, "setters-after-getters": true, "rxjs-imports": true, + "require-breaking-change-version": true, "no-host-decorator-in-concrete": [ true, "HostBinding",