Skip to content

Commit 64fdaec

Browse files
crisbetojelbourn
authored andcommitted
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.
1 parent f7dd0eb commit 64fdaec

File tree

6 files changed

+151
-78
lines changed

6 files changed

+151
-78
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"deploy": "gulp deploy:devapp",
1919
"webdriver-manager": "webdriver-manager",
2020
"docs": "gulp docs",
21-
"api": "gulp api-docs"
21+
"api": "gulp api-docs",
22+
"breaking-changes": "gulp breaking-changes"
2223
},
2324
"license": "MIT",
2425
"engines": {

tools/gulp/gulpfile.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ createPackageBuildTasks(examplesPackage, ['build-examples-module']);
1616
createPackageBuildTasks(momentAdapterPackage);
1717

1818
import './tasks/aot';
19+
import './tasks/breaking-changes';
1920
import './tasks/changelog';
2021
import './tasks/ci';
2122
import './tasks/clean';

tools/gulp/tasks/breaking-changes.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {task} from 'gulp';
2+
import {join, relative} from 'path';
3+
import {readFileSync} from 'fs';
4+
import {bold, red, green} from 'chalk';
5+
import * as ts from 'typescript';
6+
import * as tsutils from 'tsutils';
7+
import {buildConfig} from '../../package-tools/build-config';
8+
9+
// Current version from the package.json. Splits it on the dash to ignore `-beta.x` suffixes.
10+
const packageVersion = require(join(buildConfig.projectDir, 'package.json')).version.split('-')[0];
11+
12+
// Regex used to extract versions from a string.
13+
const versionRegex = /\d+\.\d+\.\d+/;
14+
15+
/**
16+
* Goes through all of the TypeScript files in the project and puts
17+
* together a summary of all of the pending and expired breaking changes.
18+
*/
19+
task('breaking-changes', () => {
20+
const projectDir = buildConfig.projectDir;
21+
const configFile = ts.readJsonConfigFile(join(projectDir, 'tsconfig.json'), ts.sys.readFile);
22+
const parsedConfig = ts.parseJsonSourceFileConfigFileContent(configFile, ts.sys, projectDir);
23+
const summary: {[version: string]: string[]} = {};
24+
25+
// Go through all the TS files in the project.
26+
parsedConfig.fileNames.forEach(fileName => {
27+
const sourceFile = ts.createSourceFile(fileName, readFileSync(fileName, 'utf8'),
28+
configFile.languageVersion);
29+
const lineRanges = tsutils.getLineRanges(sourceFile);
30+
31+
// Go through each of the comments of the file.
32+
tsutils.forEachComment(sourceFile, (file, range) => {
33+
const comment = file.substring(range.pos, range.end);
34+
const versionMatch = comment.match(versionRegex);
35+
36+
// Don't do any extra work if the comment doesn't indicate a breaking change.
37+
if (!versionMatch || comment.indexOf('@breaking-change') === -1) {
38+
return;
39+
}
40+
41+
// Use a path relative to the project root, in order to make the summary more tidy.
42+
// Also replace escaped Windows slashes with regular forward slashes.
43+
const pathInProject = relative(projectDir, sourceFile.fileName).replace(/\\/g, '/');
44+
const [version] = versionMatch;
45+
46+
summary[version] = summary[version] || [];
47+
summary[version].push(` ${pathInProject}: ${formatMessage(comment, range, lineRanges)}`);
48+
});
49+
});
50+
51+
// Go through the summary and log out all of the breaking changes.
52+
Object.keys(summary).forEach(version => {
53+
const isExpired = hasExpired(packageVersion, version);
54+
const status = isExpired ? red('(expired)') : green('(not expired)');
55+
const header = bold(`Breaking changes for ${version} ${status}:`);
56+
const messages = summary[version].join('\n');
57+
58+
console.log(isExpired ? red(header) : header);
59+
console.log(isExpired ? red(messages) : messages, '\n');
60+
});
61+
});
62+
63+
/**
64+
* Formats a message to be logged out in the breaking changes summary.
65+
* @param comment Contents of the comment that contains the breaking change.
66+
* @param commentRange Object containing info on the position of the comment in the file.
67+
* @param lines Ranges of the lines of code in the file.
68+
*/
69+
function formatMessage(comment: string, commentRange: ts.CommentRange, lines: tsutils.LineRange[]) {
70+
const lineNumber = lines.findIndex(line => line.pos > commentRange.pos);
71+
const messageMatch = comment.match(/@deprecated(.*)|@breaking-change(.*)/);
72+
const message = messageMatch ? messageMatch[0] : '';
73+
const cleanMessage = message
74+
.replace(/[\*\/\r\n]|@[\w-]+/g, '')
75+
.replace(versionRegex, '')
76+
.trim();
77+
78+
return `Line ${lineNumber}, ${cleanMessage || 'No message'}`;
79+
}
80+
81+
82+
/** Converts a version string into an object. */
83+
function parseVersion(version: string) {
84+
const [major = 0, minor = 0, patch = 0] = version.split('.').map(segment => parseInt(segment));
85+
return {major, minor, patch};
86+
}
87+
88+
89+
/**
90+
* Checks whether a version has expired, based on the current version.
91+
* @param currentVersion Current version of the package.
92+
* @param breakingChange Version that is being checked.
93+
*/
94+
function hasExpired(currentVersion: string, breakingChange: string) {
95+
if (currentVersion === breakingChange) {
96+
return true;
97+
}
98+
99+
const current = parseVersion(currentVersion);
100+
const target = parseVersion(breakingChange);
101+
102+
return target.major < current.major ||
103+
(target.major === current.major && target.minor < current.minor) ||
104+
(
105+
target.major === current.major &&
106+
target.minor === current.minor &&
107+
target.patch < current.patch
108+
);
109+
}

tools/tslint-rules/breakingChangeRule.ts

Lines changed: 0 additions & 77 deletions
This file was deleted.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import * as ts from 'typescript';
2+
import * as Lint from 'tslint';
3+
import * as utils from 'tsutils';
4+
5+
/** Doc tag that can be used to indicate a breaking change. */
6+
const BREAKING_CHANGE = '@breaking-change';
7+
8+
/** Name of the old doc tag that was being used to indicate a breaking change. */
9+
const DELETION_TARGET = '@deletion-target';
10+
11+
/**
12+
* Rule that ensures that comments, indicating a deprecation
13+
* or a breaking change, have a valid version.
14+
*/
15+
export class Rule extends Lint.Rules.AbstractRule {
16+
apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
17+
return this.applyWithFunction(sourceFile, (ctx: Lint.WalkContext<any>) => {
18+
utils.forEachComment(ctx.sourceFile, (file, {pos, end}) => {
19+
const commentText = file.substring(pos, end);
20+
21+
// TODO(crisbeto): remove this check once most of the pending
22+
// PRs start using `breaking-change`.
23+
if (commentText.indexOf(DELETION_TARGET) > -1) {
24+
ctx.addFailure(pos, end, `${DELETION_TARGET} has been replaced with ${BREAKING_CHANGE}.`);
25+
return;
26+
}
27+
28+
const hasBreakingChange = commentText.indexOf(BREAKING_CHANGE) > -1;
29+
30+
if (!hasBreakingChange && commentText.indexOf('@deprecated') > -1) {
31+
ctx.addFailure(pos, end, `@deprecated marker has to have a ${BREAKING_CHANGE}.`);
32+
} if (hasBreakingChange && !/\d+\.\d+\.\d+/.test(commentText)) {
33+
ctx.addFailure(pos, end, `${BREAKING_CHANGE} must have a version.`);
34+
}
35+
});
36+
});
37+
}
38+
}

tslint.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
"no-exposed-todo": true,
100100
"setters-after-getters": true,
101101
"rxjs-imports": true,
102+
"require-breaking-change-version": true,
102103
"no-host-decorator-in-concrete": [
103104
true,
104105
"HostBinding",

0 commit comments

Comments
 (0)