Skip to content

build: rework breaking changes tooling #12950

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 4, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions tools/gulp/gulpfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
109 changes: 109 additions & 0 deletions tools/gulp/tasks/breaking-changes.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 0 additions & 77 deletions tools/tslint-rules/breakingChangeRule.ts

This file was deleted.

38 changes: 38 additions & 0 deletions tools/tslint-rules/requireBreakingChangeVersionRule.ts
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions tslint.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down