@@ -11,8 +11,7 @@ import type { Context } from './api/context';
11
11
import { versionNumber } from './cli/version' ;
12
12
import { debug , info , warning , error } from './logging' ;
13
13
import { ToolkitError } from './toolkit/error' ;
14
- import { loadTreeFromDir , some } from './tree' ;
15
- import { flatMap } from './util' ;
14
+ import { ConstructTreeNode , loadTreeFromDir } from './tree' ;
16
15
import { cdkCacheDir } from './util/directories' ;
17
16
import { formatErrorMessage } from './util/format-error' ;
18
17
@@ -84,116 +83,189 @@ export interface NoticesFilterFilterOptions {
84
83
readonly bootstrappedEnvironments : BootstrappedEnvironment [ ] ;
85
84
}
86
85
87
- export class NoticesFilter {
86
+ export abstract class NoticesFilter {
88
87
public static filter ( options : NoticesFilterFilterOptions ) : FilteredNotice [ ] {
89
- return [
90
- ...this . findForCliVersion ( options . data , options . cliVersion ) ,
91
- ...this . findForFrameworkVersion ( options . data , options . outDir ) ,
92
- ...this . findForBootstrapVersion ( options . data , options . bootstrappedEnvironments ) ,
88
+ const components = [
89
+ ...NoticesFilter . constructTreeComponents ( options . outDir ) ,
90
+ ...NoticesFilter . otherComponents ( options ) ,
93
91
] ;
92
+
93
+ return NoticesFilter . findForNamedComponents ( options . data , components ) ;
94
94
}
95
95
96
- private static findForCliVersion ( data : Notice [ ] , cliVersion : string ) : FilteredNotice [ ] {
97
- return flatMap ( data , notice => {
98
- const affectedComponent = notice . components . find ( component => component . name === 'cli' ) ;
99
- const affectedRange = affectedComponent ?. version ;
96
+ /**
97
+ * From a set of input options, return the notices components we are searching for
98
+ */
99
+ private static otherComponents ( options : NoticesFilterFilterOptions ) : ActualComponent [ ] {
100
+ return [
101
+ // CLI
102
+ {
103
+ name : 'cli' ,
104
+ version : options . cliVersion ,
105
+ } ,
106
+
107
+ // Node version
108
+ {
109
+ name : 'node' ,
110
+ version : process . version . replace ( / ^ v / , '' ) , // remove the 'v' prefix.
111
+ dynamicName : 'node' ,
112
+ } ,
113
+
114
+ // Bootstrap environments
115
+ ...options . bootstrappedEnvironments . flatMap ( env => {
116
+ const semverBootstrapVersion = semver . coerce ( env . bootstrapStackVersion ) ;
117
+ if ( ! semverBootstrapVersion ) {
118
+ // we don't throw because notices should never crash the cli.
119
+ warning ( `While filtering notices, could not coerce bootstrap version '${ env . bootstrapStackVersion } ' into semver` ) ;
120
+ return [ ] ;
121
+ }
100
122
101
- if ( affectedRange == null ) {
102
- return [ ] ;
103
- }
123
+ return [ {
124
+ name : 'bootstrap' ,
125
+ version : `${ semverBootstrapVersion } ` ,
126
+ dynamicName : 'ENVIRONMENTS' ,
127
+ dynamicValue : env . environment . name ,
128
+ } ] ;
129
+ } ) ,
130
+ ] ;
131
+ }
104
132
105
- if ( ! semver . satisfies ( cliVersion , affectedRange ) ) {
106
- return [ ] ;
133
+ /**
134
+ * Based on a set of component names, find all notices that match one of the given components
135
+ */
136
+ private static findForNamedComponents ( data : Notice [ ] , actualComponents : ActualComponent [ ] ) : FilteredNotice [ ] {
137
+ return data . flatMap ( notice => {
138
+ const ors = this . resolveAliases ( normalizeComponents ( notice . components ) ) ;
139
+
140
+ // Find the first set of the disjunctions of which all components match against the actual components.
141
+ // Return the actual components we found so that we can inject their dynamic values. A single filter
142
+ // component can match more than one actual component
143
+ for ( const ands of ors ) {
144
+ const matched = ands . map ( affected => actualComponents . filter ( actual =>
145
+ NoticesFilter . componentNameMatches ( affected , actual ) && semver . satisfies ( actual . version , affected . version ) ) ) ;
146
+
147
+ // For every clause in the filter we matched one or more components
148
+ if ( matched . every ( xs => xs . length > 0 ) ) {
149
+ const ret = new FilteredNotice ( notice ) ;
150
+ NoticesFilter . addDynamicValues ( matched . flatMap ( x => x ) , ret ) ;
151
+ return [ ret ] ;
152
+ }
107
153
}
108
154
109
- return [ new FilteredNotice ( notice ) ] ;
155
+ return [ ] ;
110
156
} ) ;
111
157
}
112
158
113
- private static findForFrameworkVersion ( data : Notice [ ] , outDir : string ) : FilteredNotice [ ] {
114
- const tree = loadTreeFromDir ( outDir ) ;
115
- return flatMap ( data , notice => {
116
- // A match happens when:
117
- //
118
- // 1. The version of the node matches the version in the notice, interpreted
119
- // as a semver range.
120
- //
121
- // AND
122
- //
123
- // 2. The name in the notice is a prefix of the node name when the query ends in '.',
124
- // or the two names are exactly the same, otherwise.
125
-
126
- const matched = some ( tree , node => {
127
- return this . resolveAliases ( notice . components ) . some ( component =>
128
- compareNames ( component . name , node . constructInfo ?. fqn ) &&
129
- compareVersions ( component . version , node . constructInfo ?. version ) ) ;
130
- } ) ;
131
-
132
- if ( ! matched ) {
133
- return [ ] ;
134
- }
135
-
136
- return [ new FilteredNotice ( notice ) ] ;
159
+ /**
160
+ * Whether the given "affected component" name applies to the given actual component name.
161
+ *
162
+ * The name matches if the name is exactly the same, or the name in the notice
163
+ * is a prefix of the node name when the query ends in '.'.
164
+ */
165
+ private static componentNameMatches ( pattern : Component , actual : ActualComponent ) : boolean {
166
+ return pattern . name . endsWith ( '.' ) ? actual . name . startsWith ( pattern . name ) : pattern . name === actual . name ;
167
+ }
137
168
138
- function compareNames ( pattern : string , target : string | undefined ) : boolean {
139
- if ( target == null ) {
140
- return false ;
141
- }
142
- return pattern . endsWith ( '.' ) ? target . startsWith ( pattern ) : pattern === target ;
169
+ /**
170
+ * Adds dynamic values from the given ActualComponents
171
+ *
172
+ * If there are multiple components with the same dynamic name, they are joined
173
+ * by a comma.
174
+ */
175
+ private static addDynamicValues ( comps : ActualComponent [ ] , notice : FilteredNotice ) {
176
+ const dynamicValues : Record < string , string [ ] > = { } ;
177
+ for ( const comp of comps ) {
178
+ if ( comp . dynamicName ) {
179
+ dynamicValues [ comp . dynamicName ] = dynamicValues [ comp . dynamicName ] ?? [ ] ;
180
+ dynamicValues [ comp . dynamicName ] . push ( comp . dynamicValue ?? comp . version ) ;
143
181
}
182
+ }
183
+ for ( const [ key , values ] of Object . entries ( dynamicValues ) ) {
184
+ notice . addDynamicValue ( key , values . join ( ',' ) ) ;
185
+ }
186
+ }
144
187
145
- function compareVersions ( pattern : string , target : string | undefined ) : boolean {
146
- return semver . satisfies ( target ?? '' , pattern ) ;
188
+ /**
189
+ * Treat 'framework' as an alias for either `aws-cdk-lib.` or `@aws-cdk/core.`.
190
+ *
191
+ * Because it's EITHER `aws-cdk-lib` or `@aws-cdk/core`, we need to add multiple
192
+ * arrays at the top level.
193
+ */
194
+ private static resolveAliases ( ors : Component [ ] [ ] ) : Component [ ] [ ] {
195
+ return ors . flatMap ( ands => {
196
+ const hasFramework = ands . find ( c => c . name === 'framework' ) ;
197
+ if ( ! hasFramework ) {
198
+ return [ ands ] ;
147
199
}
200
+
201
+ return [
202
+ ands . map ( c => c . name === 'framework' ? { ...c , name : '@aws-cdk/core.' } : c ) ,
203
+ ands . map ( c => c . name === 'framework' ? { ...c , name : 'aws-cdk-lib.' } : c ) ,
204
+ ] ;
148
205
} ) ;
149
206
}
150
207
151
- private static findForBootstrapVersion ( data : Notice [ ] , bootstrappedEnvironments : BootstrappedEnvironment [ ] ) : FilteredNotice [ ] {
152
- return flatMap ( data , notice => {
153
- const affectedComponent = notice . components . find ( component => component . name === 'bootstrap' ) ;
154
- const affectedRange = affectedComponent ?. version ;
208
+ /**
209
+ * Load the construct tree from the given directory and return its components
210
+ */
211
+ private static constructTreeComponents ( manifestDir : string ) : ActualComponent [ ] {
212
+ const tree = loadTreeFromDir ( manifestDir ) ;
213
+ if ( ! tree ) {
214
+ return [ ] ;
215
+ }
155
216
156
- if ( affectedRange == null ) {
157
- return [ ] ;
158
- }
217
+ const ret : ActualComponent [ ] = [ ] ;
218
+ recurse ( tree ) ;
219
+ return ret ;
159
220
160
- const affected = bootstrappedEnvironments . filter ( i => {
161
- const semverBootstrapVersion = semver . coerce ( i . bootstrapStackVersion ) ;
162
- if ( ! semverBootstrapVersion ) {
163
- // we don't throw because notices should never crash the cli.
164
- warning ( `While filtering notices, could not coerce bootstrap version '${ i . bootstrapStackVersion } ' into semver` ) ;
165
- return false ;
166
- }
167
-
168
- return semver . satisfies ( semverBootstrapVersion , affectedRange ) ;
169
- } ) ;
221
+ function recurse ( x : ConstructTreeNode ) {
222
+ if ( x . constructInfo ?. fqn && x . constructInfo ?. version ) {
223
+ ret . push ( {
224
+ name : x . constructInfo ?. fqn ,
225
+ version : x . constructInfo ?. version ,
226
+ } ) ;
227
+ }
170
228
171
- if ( affected . length === 0 ) {
172
- return [ ] ;
229
+ for ( const child of Object . values ( x . children ?? { } ) ) {
230
+ recurse ( child ) ;
173
231
}
232
+ }
233
+ }
234
+ }
174
235
175
- const filtered = new FilteredNotice ( notice ) ;
176
- filtered . addDynamicValue ( 'ENVIRONMENTS' , affected . map ( s => s . environment . name ) . join ( ',' ) ) ;
236
+ interface ActualComponent {
237
+ /**
238
+ * Name of the component
239
+ */
240
+ readonly name : string ;
177
241
178
- return [ filtered ] ;
179
- } ) ;
180
- }
242
+ /**
243
+ * Version of the component
244
+ */
245
+ readonly version : string ;
181
246
182
- private static resolveAliases ( components : Component [ ] ) : Component [ ] {
183
- return flatMap ( components , component => {
184
- if ( component . name === 'framework' ) {
185
- return [ {
186
- name : '@aws-cdk/core.' ,
187
- version : component . version ,
188
- } , {
189
- name : 'aws-cdk-lib.' ,
190
- version : component . version ,
191
- } ] ;
192
- } else {
193
- return [ component ] ;
194
- }
195
- } ) ;
196
- }
247
+ /**
248
+ * If matched, under what name should it be added to the set of dynamic values
249
+ *
250
+ * These will be used to substitute placeholders in the message string, where
251
+ * placeholders look like `{resolve:XYZ}`.
252
+ *
253
+ * If there is more than one component with the same dynamic name, they are
254
+ * joined by ','.
255
+ *
256
+ * @default - Don't add to the set of dynamic values.
257
+ */
258
+ readonly dynamicName ?: string ;
259
+
260
+ /**
261
+ * If matched, what we should put in the set of dynamic values insstead of the version.
262
+ *
263
+ * Only used if `dynamicName` is set; by default we will add the actual version
264
+ * of the component.
265
+ *
266
+ * @default - The version.
267
+ */
268
+ readonly dynamicValue ?: string ;
197
269
}
198
270
199
271
/**
@@ -327,18 +399,44 @@ export class Notices {
327
399
328
400
export interface Component {
329
401
name : string ;
402
+
403
+ /**
404
+ * The range of affected versions
405
+ */
330
406
version : string ;
331
407
}
332
408
333
409
export interface Notice {
334
410
title : string ;
335
411
issueNumber : number ;
336
412
overview : string ;
337
- components : Component [ ] ;
413
+ /**
414
+ * A set of affected components
415
+ *
416
+ * The canonical form of a list of components is in Disjunctive Normal Form
417
+ * (i.e., an OR of ANDs). This is the form when the list of components is a
418
+ * doubly nested array: the notice matches if all components of at least one
419
+ * of the top-level array matches.
420
+ *
421
+ * If the `components` is a single-level array, it is evaluated as an OR; it
422
+ * matches if any of the components matches.
423
+ */
424
+ components : Array < Component | Component [ ] > ;
338
425
schemaVersion : string ;
339
426
severity ?: string ;
340
427
}
341
428
429
+ /**
430
+ * Normalizes the given components structure into DNF form
431
+ */
432
+ function normalizeComponents ( xs : Array < Component | Component [ ] > ) : Component [ ] [ ] {
433
+ return xs . map ( x => Array . isArray ( x ) ? x : [ x ] ) ;
434
+ }
435
+
436
+ function renderConjunction ( xs : Component [ ] ) : string {
437
+ return xs . map ( c => `${ c . name } : ${ c . version } ` ) . join ( ' AND ' ) ;
438
+ }
439
+
342
440
/**
343
441
* Notice after passing the filter. A filter can augment a notice with
344
442
* dynamic values as it has access to the dynamic matching data.
@@ -354,7 +452,7 @@ export class FilteredNotice {
354
452
}
355
453
356
454
public format ( ) : string {
357
- const componentsValue = this . notice . components . map ( c => ` ${ c . name } : ${ c . version } ` ) . join ( ', ' ) ;
455
+ const componentsValue = normalizeComponents ( this . notice . components ) . map ( renderConjunction ) . join ( ', ' ) ;
358
456
return this . resolveDynamicValues ( [
359
457
`${ this . notice . issueNumber } \t${ this . notice . title } ` ,
360
458
this . formatOverview ( ) ,
0 commit comments