build: rework breaking changes tooling (#12950)

* Reworks the breaking change tslint rule to remove the logic that checks that there are no expired breaking changes.
* Adds a new script that can be used to get an overview of the pending and expired breaking changes.
This commit is contained in:
Kristiyan Kostadinov
2018-09-04 20:46:26 +02:00
committed by Jeremy Elbourn
parent c618f03a34
commit 0ab720a69d
6 changed files with 151 additions and 78 deletions
+2 -1
View File
@@ -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",
+1
View File
@@ -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';
+109
View File
@@ -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
);
}
-77
View File
@@ -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<any>) => {
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};
}
}
@@ -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<any>) => {
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.`);
}
});
});
}
}
+1
View File
@@ -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",