Skip to content

Commit 719be24

Browse files
authored
feat(jsii-diff): extend reporting options (#547)
jsii-diff now prints all changes, can report changes over experimental packages as errors, and has the ability to load a set of ignore rules from a file.
1 parent c88080d commit 719be24

File tree

10 files changed

+324
-53
lines changed

10 files changed

+324
-53
lines changed

packages/jsii-diff/bin/jsii-diff.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import spec = require('jsii-spec');
44
import log4js = require('log4js');
55
import yargs = require('yargs');
66
import { compareAssemblies } from '../lib';
7+
import { classifyDiagnostics, formatDiagnostic, hasErrors } from '../lib/diagnostics';
78
import { DownloadFailure, downloadNpmPackage, showDownloadFailure } from '../lib/util';
89
import { VERSION } from '../lib/version';
910

@@ -15,6 +16,9 @@ async function main(): Promise<number> {
1516
.option('verbose', { alias: 'v', type: 'count', desc: 'Increase the verbosity of output', global: true })
1617
// tslint:disable-next-line:max-line-length
1718
.option('default-stability', { alias: 's', type: 'string', choices: ['experimental', 'stable'], desc: 'Treat unmarked APIs as', default: 'stable' })
19+
.option('experimental-errors', { alias: 'e', type: 'boolean', default: false, desc: 'Error on experimental API changes' })
20+
.option('ignore-file', { alias: 'i', type: 'string', desc: 'Ignore API changes with keys from file (file may be missing)' })
21+
.option('keys', { alias: 'k', type: 'boolean', default: false, desc: 'Show diagnostic suppression keys' })
1822
.usage('$0 <original> [updated]', 'Compare two JSII assemblies.', args => args
1923
.positional('original', {
2024
description: 'Original assembly (file, package or "npm:package@version")',
@@ -61,14 +65,16 @@ async function main(): Promise<number> {
6165
LOG.info(`Found ${mismatches.count} issues`);
6266

6367
if (mismatches.count > 0) {
68+
const diags = classifyDiagnostics(mismatches, argv["experimental-errors"], await loadFilter(argv["ignore-file"]));
69+
6470
process.stderr.write(`Original assembly: ${original.name}@${original.version}\n`);
6571
process.stderr.write(`Updated assembly: ${updated.name}@${updated.version}\n`);
6672
process.stderr.write(`API elements with incompatible changes:\n`);
67-
for (const msg of mismatches.messages()) {
68-
process.stderr.write(`- ${msg}\n`);
73+
for (const diag of diags) {
74+
process.stderr.write(`${formatDiagnostic(diag, argv.keys)}\n`);
6975
}
7076

71-
return 1;
77+
return hasErrors(diags) ? 1 : 0;
7278
}
7379

7480
return 0;
@@ -172,4 +178,16 @@ function configureLog4js(verbosity: number) {
172178
default: return 'ALL';
173179
}
174180
}
181+
}
182+
183+
async function loadFilter(filterFilename?: string): Promise<Set<string>> {
184+
if (!filterFilename) { return new Set(); }
185+
186+
try {
187+
return new Set((await fs.readFile(filterFilename, { encoding: 'utf-8' })).split('\n').map(x => x.trim()).filter(x => !x.startsWith('#')));
188+
} catch (e) {
189+
if (e.code !== 'ENOENT') { throw e; }
190+
LOG.debug(`No such file: ${filterFilename}`);
191+
return new Set();
192+
}
175193
}

packages/jsii-diff/lib/classes-ifaces.ts

Lines changed: 87 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import reflect = require('jsii-reflect');
22
import log4js = require('log4js');
33
import { Analysis, FailedAnalysis, isSuperType } from './type-analysis';
4-
import { ComparisonContext, shouldInspect } from './types';
4+
import { ComparisonContext } from './types';
55

66
const LOG = log4js.getLogger('jsii-diff');
77

@@ -14,28 +14,36 @@ const LOG = log4js.getLogger('jsii-diff');
1414
export function compareReferenceType<T extends reflect.ReferenceType>(original: T, updated: T, context: ComparisonContext) {
1515
if (original.isClassType() && updated.isClassType()) {
1616
if (updated.abstract && !original.abstract) {
17-
context.mismatches.report(original, 'has gone from non-abstract to abstract');
17+
context.mismatches.report({
18+
ruleKey: 'made-abstract',
19+
message: 'has gone from non-abstract to abstract',
20+
violator: original,
21+
});
1822
}
1923

2024
// JSII assembler has already taken care of inheritance here
2125
if (original.initializer && updated.initializer) {
22-
compareMethod(original, original.initializer, updated.initializer, context);
26+
compareMethod(original.initializer, updated.initializer, context);
2327
}
2428
}
2529

2630
if (original.docs.subclassable && !updated.docs.subclassable) {
27-
context.mismatches.report(original, 'has gone from @subclassable to non-@subclassable');
31+
context.mismatches.report({
32+
ruleKey: `remove-subclassable`,
33+
message: 'has gone from @subclassable to non-@subclassable',
34+
violator: original,
35+
});
2836
}
2937

3038
for (const [origMethod, updatedElement] of memberPairs(original, original.allMethods, updated, context)) {
3139
if (reflect.isMethod(origMethod) && reflect.isMethod(updatedElement)) {
32-
compareMethod(original, origMethod, updatedElement, context);
40+
compareMethod(origMethod, updatedElement, context);
3341
}
3442
}
3543

3644
for (const [origProp, updatedElement] of memberPairs(original, original.allProperties, updated, context)) {
3745
if (reflect.isProperty(origProp) && reflect.isProperty(updatedElement)) {
38-
compareProperty(original, origProp, updatedElement, context);
46+
compareProperty(origProp, updatedElement, context);
3947
}
4048
}
4149

@@ -67,7 +75,11 @@ function noNewAbstractMembers<T extends reflect.ReferenceType>(original: T, upda
6775
const originalMemberNames = new Set(original.allMembers.map(m => m.name));
6876
for (const name of absMemberNames) {
6977
if (!originalMemberNames.has(name)) {
70-
context.mismatches.report(original, `adds requirement for subclasses to implement '${name}'.`);
78+
context.mismatches.report({
79+
ruleKey: 'new-abstract-member',
80+
message: `adds requirement for subclasses to implement '${name}'.`,
81+
violator: updated.getMembers(true)[name]
82+
});
7183
}
7284
}
7385
}
@@ -83,7 +95,6 @@ function describeOptionalValueMatchingFailure(origType: reflect.OptionalValue, u
8395
}
8496

8597
function compareMethod<T extends (reflect.Method | reflect.Initializer)>(
86-
origClass: reflect.Type,
8798
original: T,
8899
updated: T,
89100
context: ComparisonContext) {
@@ -92,41 +103,65 @@ function compareMethod<T extends (reflect.Method | reflect.Initializer)>(
92103
if (original.static !== updated.static) {
93104
const origQual = original.static ? 'static' : 'not static';
94105
const updQual = updated.static ? 'static' : 'not static';
95-
context.mismatches.report(origClass, `${original.kind} ${original.name} was ${origQual}, is now ${updQual}.`);
106+
context.mismatches.report({
107+
ruleKey: 'changed-static',
108+
violator: original,
109+
message: `was ${origQual}, is now ${updQual}.`
110+
});
96111
}
97112

98113
if (original.async !== updated.async) {
99114
const origQual = original.async ? 'asynchronous' : 'synchronous';
100115
const updQual = updated.async ? 'asynchronous' : 'synchronous';
101-
context.mismatches.report(origClass, `${original.kind} ${original.name} was ${origQual}, is now ${updQual}`);
116+
context.mismatches.report({
117+
ruleKey: 'changed-async',
118+
violator: original,
119+
message: `was ${origQual}, is now ${updQual}`
120+
});
102121
}
103122
}
104123

105124
if (original.variadic && !updated.variadic) {
106125
// Once variadic, can never be made non-variadic anymore (because I could always have been passing N+1 arguments)
107-
context.mismatches.report(origClass, `${original.kind} ${original.name} used to be variadic, not variadic anymore.`);
126+
context.mismatches.report({
127+
ruleKey: 'changed-variadic',
128+
violator: original,
129+
message: `used to be variadic, not variadic anymore.`
130+
});
108131
}
109132

110133
if (reflect.isMethod(original) && reflect.isMethod(updated)) {
111134
const retAna = isCompatibleReturnType(original.returns, updated.returns);
112135
if (!retAna.success) {
113136
// tslint:disable-next-line:max-line-length
114-
context.mismatches.report(origClass, `${original.kind} ${original.name}, returns ${describeOptionalValueMatchingFailure(original.returns, updated.returns, retAna)}`);
137+
context.mismatches.report({
138+
ruleKey: 'change-return-type',
139+
violator: original,
140+
message: `returns ${describeOptionalValueMatchingFailure(original.returns, updated.returns, retAna)}`
141+
});
115142
}
116143
}
117144

118145
// Check that every original parameter can still be mapped to a parameter in the updated method
119146
original.parameters.forEach((param, i) => {
120147
const updatedParam = findParam(updated.parameters, i);
121148
if (updatedParam === undefined) {
122-
context.mismatches.report(origClass, `${original.kind} ${original.name} argument ${param.name}, not accepted anymore.`);
149+
context.mismatches.report({
150+
ruleKey: 'removed-argument',
151+
violator: original,
152+
message: `argument ${param.name}, not accepted anymore.`
153+
});
123154
return;
124155
}
125156

126157
const argAna = isCompatibleArgumentType(param.type, updatedParam.type);
127158
if (!argAna.success) {
128-
// tslint:disable-next-line:max-line-length
129-
context.mismatches.report(origClass, `${original.kind} ${original.name} argument ${param.name}, takes ${describeOptionalValueMatchingFailure(param, updatedParam, argAna)}`);
159+
context.mismatches.report({
160+
ruleKey: 'incompatible-argument',
161+
violator: original,
162+
// tslint:disable-next-line:max-line-length
163+
message: `argument ${param.name}, takes ${describeOptionalValueMatchingFailure(param, updatedParam, argAna)}`
164+
});
130165
return;
131166
}
132167
});
@@ -137,7 +172,11 @@ function compareMethod<T extends (reflect.Method | reflect.Initializer)>(
137172

138173
const origParam = findParam(original.parameters, i);
139174
if (!origParam || origParam.optional) {
140-
context.mismatches.report(origClass, `${original.kind} ${original.name} argument ${param.name}, newly required argument.`);
175+
context.mismatches.report({
176+
ruleKey: 'new-argument',
177+
violator: original,
178+
message: `argument ${param.name}, newly required argument.`
179+
});
141180
}
142181
});
143182
}
@@ -161,39 +200,63 @@ function findParam(parameters: reflect.Parameter[], i: number): reflect.Paramete
161200
return undefined;
162201
}
163202

164-
function compareProperty(origClass: reflect.Type, original: reflect.Property, updated: reflect.Property, context: ComparisonContext) {
203+
function compareProperty(original: reflect.Property, updated: reflect.Property, context: ComparisonContext) {
165204
if (original.static !== updated.static) {
166205
// tslint:disable-next-line:max-line-length
167-
context.mismatches.report(origClass, `property ${original.name}, used to be ${original.static ? 'static' : 'not static'}, is now ${updated.static ? 'static' : 'not static'}`);
206+
context.mismatches.report({
207+
ruleKey: 'changed-static',
208+
violator: original,
209+
message: `used to be ${original.static ? 'static' : 'not static'}, is now ${updated.static ? 'static' : 'not static'}`
210+
});
168211
}
169212

170213
const ana = isCompatibleReturnType(original, updated);
171214
if (!ana.success) {
172-
context.mismatches.report(origClass, `property ${original.name}, type ${describeOptionalValueMatchingFailure(original, updated, ana)}`);
215+
context.mismatches.report({
216+
ruleKey: 'changed-type',
217+
violator: original,
218+
message: `type ${describeOptionalValueMatchingFailure(original, updated, ana)}`
219+
});
173220
}
174221

175222
if (updated.immutable && !original.immutable) {
176-
context.mismatches.report(origClass, `property ${original.name}, used to be mutable, is now immutable`);
223+
context.mismatches.report({
224+
ruleKey: 'removed-mutability',
225+
violator: original,
226+
message: `used to be mutable, is now immutable`
227+
});
177228
}
178229
}
179230

180231
// tslint:disable-next-line:max-line-length
181232
function* memberPairs<T extends reflect.TypeMember, U extends reflect.ReferenceType>(origClass: U, xs: T[], updatedClass: U, context: ComparisonContext): IterableIterator<[T, reflect.TypeMember]> {
182-
for (const origMember of xs.filter(shouldInspect(context))) {
233+
for (const origMember of xs) {
183234
LOG.trace(`${origClass.fqn}#${origMember.name}`);
184235

185236
const updatedMember = updatedClass.allMembers.find(m => m.name === origMember.name);
186237
if (!updatedMember) {
187-
context.mismatches.report(origClass, `member ${origMember.name} has been removed`);
238+
context.mismatches.report({
239+
ruleKey: 'removed',
240+
violator: origMember,
241+
message: `has been removed`
242+
});
188243
continue;
189244
}
190245

191246
if (origMember.kind !== updatedMember.kind) {
192-
context.mismatches.report(origClass, `member ${origMember.name} changed from ${origMember.kind} to ${updatedMember.kind}`);
247+
context.mismatches.report({
248+
ruleKey: 'changed-kind',
249+
violator: origMember,
250+
message: `changed from ${origMember.kind} to ${updatedMember.kind}`
251+
});
193252
}
194253

195254
if (!origMember.protected && updatedMember.protected) {
196-
context.mismatches.report(origClass, `member ${origMember.name} changed from 'public' to 'protected'`);
255+
context.mismatches.report({
256+
ruleKey: 'hidden',
257+
violator: origMember,
258+
message: `changed from 'public' to 'protected'`
259+
});
197260
}
198261

199262
yield [origMember, updatedMember];

packages/jsii-diff/lib/diagnostics.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Stability } from "jsii-spec";
2+
import { ApiMismatch, Mismatches } from "./types";
3+
4+
export enum DiagLevel {
5+
Error = 0,
6+
Warning = 1,
7+
Skipped = 2,
8+
}
9+
10+
const LEVEL_PREFIX = {
11+
[DiagLevel.Error]: 'err ',
12+
[DiagLevel.Warning]: 'warn',
13+
[DiagLevel.Skipped]: 'skip',
14+
};
15+
16+
export interface Diagnostic {
17+
level: DiagLevel;
18+
message: string;
19+
suppressionKey: string;
20+
}
21+
22+
export function formatDiagnostic(diag: Diagnostic, includeSuppressionKey = false) {
23+
return [
24+
LEVEL_PREFIX[diag.level],
25+
'-',
26+
diag.message,
27+
...(includeSuppressionKey ? [`[${diag.suppressionKey}]`] : []),
28+
].join(' ');
29+
}
30+
31+
export function hasErrors(diags: Diagnostic[]) {
32+
return diags.some(diag => diag.level === DiagLevel.Error);
33+
}
34+
35+
/**
36+
* Classify API mismatches into a set of warnings and errors
37+
*/
38+
export function classifyDiagnostics(mismatches: Mismatches, experimentalErrors: boolean, skipFilter: Set<string>): Diagnostic[] {
39+
const ret = mismatches.mismatches.map(mis => ({ level: level(mis), message: mis.message, suppressionKey: mis.violationKey }));
40+
ret.sort((a, b) => a.level - b.level);
41+
return ret;
42+
43+
function level(mis: ApiMismatch) {
44+
if (skipFilter.has(mis.violationKey)) { return DiagLevel.Skipped; }
45+
if (mis.stability === Stability.Stable || (mis.stability === Stability.Experimental && experimentalErrors)) { return DiagLevel.Error; }
46+
return DiagLevel.Warning;
47+
}
48+
}

packages/jsii-diff/lib/enums.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import reflect = require('jsii-reflect');
2-
import { ComparisonContext, shouldInspect } from './types';
2+
import { ComparisonContext } from './types';
33

44
export function compareEnum(original: reflect.EnumType, updated: reflect.EnumType, context: ComparisonContext) {
5-
for (const origMember of original.members.filter(shouldInspect(context))) {
5+
for (const origMember of original.members) {
66
const updatedMember = updated.members.find(m => m.name === origMember.name);
77
if (!updatedMember) {
8-
context.mismatches.report(original, `member ${origMember.name} has been removed`);
8+
context.mismatches.report({
9+
ruleKey: 'removed',
10+
violator: origMember,
11+
message: `member ${origMember.name} has been removed`
12+
});
913
continue;
1014
}
1115
}

0 commit comments

Comments
 (0)