Skip to content

Commit a111fa5

Browse files
committed
build: rework breaking changes tooling
* 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 193c2d0 commit a111fa5

File tree

6 files changed

+150
-78
lines changed

6 files changed

+150
-78
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"deploy": "gulp deploy:devapp",
2323
"webdriver-manager": "webdriver-manager",
2424
"docs": "gulp docs",
25-
"api": "gulp api-docs"
25+
"api": "gulp api-docs",
26+
"breaking-changes": "gulp breaking-changes"
2627
},
2728
"version": "7.0.0-beta.0",
2829
"requiredAngularVersion": ">=7.0.0-beta.4",

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

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
@@ -101,6 +101,7 @@
101101
"no-private-getters": true,
102102
"setters-after-getters": true,
103103
"rxjs-imports": true,
104+
"require-breaking-change-version": true,
104105
"no-host-decorator-in-concrete": [
105106
true,
106107
"HostBinding",

0 commit comments

Comments
 (0)