@friggframework/devtools 2.0.0--canary.474.6ec870b.0 → 2.0.0--canary.474.d64c550.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. package/infrastructure/domains/health/application/ports/IPropertyReconciler.js +164 -0
  2. package/infrastructure/domains/health/application/ports/IResourceDetector.js +129 -0
  3. package/infrastructure/domains/health/application/ports/IResourceImporter.js +142 -0
  4. package/infrastructure/domains/health/application/ports/IStackRepository.js +131 -0
  5. package/infrastructure/domains/health/application/ports/index.js +26 -0
  6. package/infrastructure/domains/health/domain/entities/issue.js +250 -0
  7. package/infrastructure/domains/health/domain/entities/issue.test.js +417 -0
  8. package/infrastructure/domains/health/domain/entities/property-mismatch.js +7 -4
  9. package/infrastructure/domains/health/domain/entities/property-mismatch.test.js +28 -4
  10. package/infrastructure/domains/health/domain/entities/resource.js +159 -0
  11. package/infrastructure/domains/health/domain/entities/resource.test.js +432 -0
  12. package/infrastructure/domains/health/domain/entities/stack-health-report.js +306 -0
  13. package/infrastructure/domains/health/domain/entities/stack-health-report.test.js +601 -0
  14. package/infrastructure/domains/health/domain/services/health-score-calculator.js +165 -0
  15. package/infrastructure/domains/health/domain/services/health-score-calculator.test.js +400 -0
  16. package/infrastructure/domains/health/domain/services/mismatch-analyzer.js +234 -0
  17. package/infrastructure/domains/health/domain/services/mismatch-analyzer.test.js +431 -0
  18. package/infrastructure/domains/health/domain/value-objects/stack-identifier.js +13 -0
  19. package/infrastructure/domains/health/domain/value-objects/stack-identifier.test.js +29 -0
  20. package/package.json +6 -6
@@ -0,0 +1,234 @@
1
+ /**
2
+ * MismatchAnalyzer Domain Service
3
+ *
4
+ * Analyzes differences between expected (CloudFormation template) and actual
5
+ * (cloud resource) property values to detect drift.
6
+ *
7
+ * Features:
8
+ * - Deep object comparison
9
+ * - Array comparison (order-sensitive)
10
+ * - Primitive value comparison
11
+ * - Type mismatch detection
12
+ * - Property mutability tracking
13
+ * - Ignore specific properties
14
+ * - Nested property path tracking
15
+ */
16
+
17
+ const PropertyMismatch = require('../entities/property-mismatch');
18
+ const PropertyMutability = require('../value-objects/property-mutability');
19
+
20
+ class MismatchAnalyzer {
21
+ /**
22
+ * Analyze differences between expected and actual property values
23
+ *
24
+ * @param {Object} params
25
+ * @param {Object} params.expected - Expected properties from CloudFormation template
26
+ * @param {Object} params.actual - Actual properties from cloud resource
27
+ * @param {Object} params.propertyMutability - Map of property paths to PropertyMutability instances
28
+ * @param {string[]} [params.ignoreProperties=[]] - Property paths to ignore
29
+ * @returns {PropertyMismatch[]} Array of detected mismatches
30
+ */
31
+ analyze({ expected, actual, propertyMutability, ignoreProperties = [] }) {
32
+ const mismatches = [];
33
+
34
+ // Recursively compare objects
35
+ this._compareObjects({
36
+ expected,
37
+ actual,
38
+ propertyMutability,
39
+ ignoreProperties,
40
+ currentPath: '',
41
+ mismatches,
42
+ });
43
+
44
+ return mismatches;
45
+ }
46
+
47
+ /**
48
+ * Recursively compare two objects
49
+ *
50
+ * @private
51
+ */
52
+ _compareObjects({
53
+ expected,
54
+ actual,
55
+ propertyMutability,
56
+ ignoreProperties,
57
+ currentPath,
58
+ mismatches,
59
+ }) {
60
+ // Get all unique property keys from both objects
61
+ const allKeys = new Set([
62
+ ...Object.keys(expected || {}),
63
+ ...Object.keys(actual || {}),
64
+ ]);
65
+
66
+ for (const key of allKeys) {
67
+ const propertyPath = currentPath ? `${currentPath}.${key}` : key;
68
+
69
+ // Skip ignored properties
70
+ if (ignoreProperties.includes(propertyPath)) {
71
+ continue;
72
+ }
73
+
74
+ const expectedValue = expected?.[key];
75
+ const actualValue = actual?.[key];
76
+
77
+ // Check if values are different
78
+ if (!this._areValuesEqual(expectedValue, actualValue)) {
79
+ // Check if both are objects (and not arrays or null)
80
+ if (
81
+ this._isPlainObject(expectedValue) &&
82
+ this._isPlainObject(actualValue)
83
+ ) {
84
+ // Recursively compare nested objects
85
+ this._compareObjects({
86
+ expected: expectedValue,
87
+ actual: actualValue,
88
+ propertyMutability,
89
+ ignoreProperties,
90
+ currentPath: propertyPath,
91
+ mismatches,
92
+ });
93
+ } else {
94
+ // Create a mismatch for this property
95
+ const mutability =
96
+ propertyMutability[propertyPath] || PropertyMutability.MUTABLE;
97
+
98
+ const mismatch = new PropertyMismatch({
99
+ propertyPath,
100
+ expectedValue,
101
+ actualValue,
102
+ mutability,
103
+ });
104
+
105
+ mismatches.push(mismatch);
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Check if two values are equal
113
+ *
114
+ * @private
115
+ * @param {*} value1
116
+ * @param {*} value2
117
+ * @returns {boolean}
118
+ */
119
+ _areValuesEqual(value1, value2) {
120
+ // Handle null/undefined equivalence
121
+ if (this._isNullish(value1) && this._isNullish(value2)) {
122
+ return true;
123
+ }
124
+
125
+ // Handle different types
126
+ if (typeof value1 !== typeof value2) {
127
+ return false;
128
+ }
129
+
130
+ // Handle primitives
131
+ if (
132
+ typeof value1 === 'string' ||
133
+ typeof value1 === 'number' ||
134
+ typeof value1 === 'boolean'
135
+ ) {
136
+ return value1 === value2;
137
+ }
138
+
139
+ // Handle arrays
140
+ if (Array.isArray(value1) && Array.isArray(value2)) {
141
+ return this._areArraysEqual(value1, value2);
142
+ }
143
+
144
+ // Handle plain objects
145
+ if (this._isPlainObject(value1) && this._isPlainObject(value2)) {
146
+ return this._areObjectsEqual(value1, value2);
147
+ }
148
+
149
+ // Handle dates
150
+ if (value1 instanceof Date && value2 instanceof Date) {
151
+ return value1.getTime() === value2.getTime();
152
+ }
153
+
154
+ // Fallback: strict equality
155
+ return value1 === value2;
156
+ }
157
+
158
+ /**
159
+ * Check if value is null or undefined
160
+ *
161
+ * @private
162
+ * @param {*} value
163
+ * @returns {boolean}
164
+ */
165
+ _isNullish(value) {
166
+ return value === null || value === undefined;
167
+ }
168
+
169
+ /**
170
+ * Check if value is a plain object (not array, not null, not Date, etc.)
171
+ *
172
+ * @private
173
+ * @param {*} value
174
+ * @returns {boolean}
175
+ */
176
+ _isPlainObject(value) {
177
+ return (
178
+ typeof value === 'object' &&
179
+ value !== null &&
180
+ !Array.isArray(value) &&
181
+ !(value instanceof Date) &&
182
+ Object.getPrototypeOf(value) === Object.prototype
183
+ );
184
+ }
185
+
186
+ /**
187
+ * Deep equality check for arrays (order-sensitive)
188
+ *
189
+ * @private
190
+ * @param {Array} arr1
191
+ * @param {Array} arr2
192
+ * @returns {boolean}
193
+ */
194
+ _areArraysEqual(arr1, arr2) {
195
+ if (arr1.length !== arr2.length) {
196
+ return false;
197
+ }
198
+
199
+ for (let i = 0; i < arr1.length; i++) {
200
+ if (!this._areValuesEqual(arr1[i], arr2[i])) {
201
+ return false;
202
+ }
203
+ }
204
+
205
+ return true;
206
+ }
207
+
208
+ /**
209
+ * Deep equality check for plain objects
210
+ *
211
+ * @private
212
+ * @param {Object} obj1
213
+ * @param {Object} obj2
214
+ * @returns {boolean}
215
+ */
216
+ _areObjectsEqual(obj1, obj2) {
217
+ const keys1 = Object.keys(obj1);
218
+ const keys2 = Object.keys(obj2);
219
+
220
+ if (keys1.length !== keys2.length) {
221
+ return false;
222
+ }
223
+
224
+ for (const key of keys1) {
225
+ if (!this._areValuesEqual(obj1[key], obj2[key])) {
226
+ return false;
227
+ }
228
+ }
229
+
230
+ return true;
231
+ }
232
+ }
233
+
234
+ module.exports = MismatchAnalyzer;
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Tests for MismatchAnalyzer Domain Service
3
+ */
4
+
5
+ const MismatchAnalyzer = require('./mismatch-analyzer');
6
+ const PropertyMutability = require('../value-objects/property-mutability');
7
+
8
+ describe('MismatchAnalyzer', () => {
9
+ let analyzer;
10
+
11
+ beforeEach(() => {
12
+ analyzer = new MismatchAnalyzer();
13
+ });
14
+
15
+ describe('primitive value comparison', () => {
16
+ it('should detect no mismatch for identical strings', () => {
17
+ const mismatches = analyzer.analyze({
18
+ expected: { name: 'my-vpc' },
19
+ actual: { name: 'my-vpc' },
20
+ propertyMutability: { name: PropertyMutability.MUTABLE },
21
+ });
22
+
23
+ expect(mismatches).toHaveLength(0);
24
+ });
25
+
26
+ it('should detect mismatch for different strings', () => {
27
+ const mismatches = analyzer.analyze({
28
+ expected: { name: 'my-vpc-v1' },
29
+ actual: { name: 'my-vpc-v2' },
30
+ propertyMutability: { name: PropertyMutability.MUTABLE },
31
+ });
32
+
33
+ expect(mismatches).toHaveLength(1);
34
+ expect(mismatches[0].propertyPath).toBe('name');
35
+ expect(mismatches[0].expectedValue).toBe('my-vpc-v1');
36
+ expect(mismatches[0].actualValue).toBe('my-vpc-v2');
37
+ expect(mismatches[0].mutability.value).toBe('MUTABLE');
38
+ });
39
+
40
+ it('should detect mismatch for different numbers', () => {
41
+ const mismatches = analyzer.analyze({
42
+ expected: { maxSize: 10 },
43
+ actual: { maxSize: 20 },
44
+ propertyMutability: { maxSize: PropertyMutability.MUTABLE },
45
+ });
46
+
47
+ expect(mismatches).toHaveLength(1);
48
+ expect(mismatches[0].propertyPath).toBe('maxSize');
49
+ expect(mismatches[0].expectedValue).toBe(10);
50
+ expect(mismatches[0].actualValue).toBe(20);
51
+ });
52
+
53
+ it('should detect mismatch for different booleans', () => {
54
+ const mismatches = analyzer.analyze({
55
+ expected: { enableDnsSupport: true },
56
+ actual: { enableDnsSupport: false },
57
+ propertyMutability: { enableDnsSupport: PropertyMutability.MUTABLE },
58
+ });
59
+
60
+ expect(mismatches).toHaveLength(1);
61
+ expect(mismatches[0].propertyPath).toBe('enableDnsSupport');
62
+ expect(mismatches[0].expectedValue).toBe(true);
63
+ expect(mismatches[0].actualValue).toBe(false);
64
+ });
65
+ });
66
+
67
+ describe('null and undefined handling', () => {
68
+ it('should detect no mismatch for both null', () => {
69
+ const mismatches = analyzer.analyze({
70
+ expected: { description: null },
71
+ actual: { description: null },
72
+ propertyMutability: { description: PropertyMutability.MUTABLE },
73
+ });
74
+
75
+ expect(mismatches).toHaveLength(0);
76
+ });
77
+
78
+ it('should detect mismatch for null vs value', () => {
79
+ const mismatches = analyzer.analyze({
80
+ expected: { description: null },
81
+ actual: { description: 'Production VPC' },
82
+ propertyMutability: { description: PropertyMutability.MUTABLE },
83
+ });
84
+
85
+ expect(mismatches).toHaveLength(1);
86
+ expect(mismatches[0].propertyPath).toBe('description');
87
+ expect(mismatches[0].expectedValue).toBeNull();
88
+ expect(mismatches[0].actualValue).toBe('Production VPC');
89
+ });
90
+
91
+ it('should handle undefined as equivalent to null', () => {
92
+ const mismatches = analyzer.analyze({
93
+ expected: { description: undefined },
94
+ actual: { description: null },
95
+ propertyMutability: { description: PropertyMutability.MUTABLE },
96
+ });
97
+
98
+ expect(mismatches).toHaveLength(0);
99
+ });
100
+ });
101
+
102
+ describe('nested object comparison', () => {
103
+ it('should detect no mismatch for identical nested objects', () => {
104
+ const mismatches = analyzer.analyze({
105
+ expected: {
106
+ tags: {
107
+ Environment: 'production',
108
+ Team: 'platform',
109
+ },
110
+ },
111
+ actual: {
112
+ tags: {
113
+ Environment: 'production',
114
+ Team: 'platform',
115
+ },
116
+ },
117
+ propertyMutability: { tags: PropertyMutability.MUTABLE },
118
+ });
119
+
120
+ expect(mismatches).toHaveLength(0);
121
+ });
122
+
123
+ it('should detect mismatch in nested object property', () => {
124
+ const mismatches = analyzer.analyze({
125
+ expected: {
126
+ tags: {
127
+ Environment: 'production',
128
+ Team: 'platform',
129
+ },
130
+ },
131
+ actual: {
132
+ tags: {
133
+ Environment: 'staging',
134
+ Team: 'platform',
135
+ },
136
+ },
137
+ propertyMutability: { 'tags.Environment': PropertyMutability.MUTABLE },
138
+ });
139
+
140
+ expect(mismatches).toHaveLength(1);
141
+ expect(mismatches[0].propertyPath).toBe('tags.Environment');
142
+ expect(mismatches[0].expectedValue).toBe('production');
143
+ expect(mismatches[0].actualValue).toBe('staging');
144
+ });
145
+
146
+ it('should detect mismatch when nested property is missing', () => {
147
+ const mismatches = analyzer.analyze({
148
+ expected: {
149
+ tags: {
150
+ Environment: 'production',
151
+ Team: 'platform',
152
+ },
153
+ },
154
+ actual: {
155
+ tags: {
156
+ Environment: 'production',
157
+ },
158
+ },
159
+ propertyMutability: { 'tags.Team': PropertyMutability.MUTABLE },
160
+ });
161
+
162
+ expect(mismatches).toHaveLength(1);
163
+ expect(mismatches[0].propertyPath).toBe('tags.Team');
164
+ expect(mismatches[0].expectedValue).toBe('platform');
165
+ expect(mismatches[0].actualValue).toBeUndefined();
166
+ });
167
+
168
+ it('should detect mismatch when nested property is added', () => {
169
+ const mismatches = analyzer.analyze({
170
+ expected: {
171
+ tags: {
172
+ Environment: 'production',
173
+ },
174
+ },
175
+ actual: {
176
+ tags: {
177
+ Environment: 'production',
178
+ Team: 'platform',
179
+ },
180
+ },
181
+ propertyMutability: { 'tags.Team': PropertyMutability.MUTABLE },
182
+ });
183
+
184
+ expect(mismatches).toHaveLength(1);
185
+ expect(mismatches[0].propertyPath).toBe('tags.Team');
186
+ expect(mismatches[0].expectedValue).toBeUndefined();
187
+ expect(mismatches[0].actualValue).toBe('platform');
188
+ });
189
+ });
190
+
191
+ describe('array comparison', () => {
192
+ it('should detect no mismatch for identical arrays', () => {
193
+ const mismatches = analyzer.analyze({
194
+ expected: { subnets: ['subnet-1', 'subnet-2'] },
195
+ actual: { subnets: ['subnet-1', 'subnet-2'] },
196
+ propertyMutability: { subnets: PropertyMutability.MUTABLE },
197
+ });
198
+
199
+ expect(mismatches).toHaveLength(0);
200
+ });
201
+
202
+ it('should detect mismatch for different array values', () => {
203
+ const mismatches = analyzer.analyze({
204
+ expected: { subnets: ['subnet-1', 'subnet-2'] },
205
+ actual: { subnets: ['subnet-1', 'subnet-3'] },
206
+ propertyMutability: { subnets: PropertyMutability.MUTABLE },
207
+ });
208
+
209
+ expect(mismatches).toHaveLength(1);
210
+ expect(mismatches[0].propertyPath).toBe('subnets');
211
+ expect(mismatches[0].expectedValue).toEqual(['subnet-1', 'subnet-2']);
212
+ expect(mismatches[0].actualValue).toEqual(['subnet-1', 'subnet-3']);
213
+ });
214
+
215
+ it('should detect mismatch for different array lengths', () => {
216
+ const mismatches = analyzer.analyze({
217
+ expected: { subnets: ['subnet-1', 'subnet-2'] },
218
+ actual: { subnets: ['subnet-1'] },
219
+ propertyMutability: { subnets: PropertyMutability.MUTABLE },
220
+ });
221
+
222
+ expect(mismatches).toHaveLength(1);
223
+ expect(mismatches[0].propertyPath).toBe('subnets');
224
+ expect(mismatches[0].expectedValue).toEqual(['subnet-1', 'subnet-2']);
225
+ expect(mismatches[0].actualValue).toEqual(['subnet-1']);
226
+ });
227
+
228
+ it('should detect mismatch for different array order (order-sensitive)', () => {
229
+ const mismatches = analyzer.analyze({
230
+ expected: { subnets: ['subnet-1', 'subnet-2'] },
231
+ actual: { subnets: ['subnet-2', 'subnet-1'] },
232
+ propertyMutability: { subnets: PropertyMutability.MUTABLE },
233
+ });
234
+
235
+ expect(mismatches).toHaveLength(1);
236
+ expect(mismatches[0].propertyPath).toBe('subnets');
237
+ });
238
+
239
+ it('should handle arrays of objects', () => {
240
+ const mismatches = analyzer.analyze({
241
+ expected: {
242
+ tags: [
243
+ { Key: 'Environment', Value: 'production' },
244
+ { Key: 'Team', Value: 'platform' },
245
+ ],
246
+ },
247
+ actual: {
248
+ tags: [
249
+ { Key: 'Environment', Value: 'staging' },
250
+ { Key: 'Team', Value: 'platform' },
251
+ ],
252
+ },
253
+ propertyMutability: { tags: PropertyMutability.MUTABLE },
254
+ });
255
+
256
+ expect(mismatches).toHaveLength(1);
257
+ expect(mismatches[0].propertyPath).toBe('tags');
258
+ });
259
+ });
260
+
261
+ describe('type mismatch', () => {
262
+ it('should detect type mismatch (string vs number)', () => {
263
+ const mismatches = analyzer.analyze({
264
+ expected: { port: '8080' },
265
+ actual: { port: 8080 },
266
+ propertyMutability: { port: PropertyMutability.MUTABLE },
267
+ });
268
+
269
+ expect(mismatches).toHaveLength(1);
270
+ expect(mismatches[0].propertyPath).toBe('port');
271
+ expect(mismatches[0].expectedValue).toBe('8080');
272
+ expect(mismatches[0].actualValue).toBe(8080);
273
+ });
274
+
275
+ it('should detect type mismatch (object vs array)', () => {
276
+ const mismatches = analyzer.analyze({
277
+ expected: { data: {} },
278
+ actual: { data: [] },
279
+ propertyMutability: { data: PropertyMutability.MUTABLE },
280
+ });
281
+
282
+ expect(mismatches).toHaveLength(1);
283
+ expect(mismatches[0].propertyPath).toBe('data');
284
+ });
285
+ });
286
+
287
+ describe('property mutability', () => {
288
+ it('should use MUTABLE mutability by default', () => {
289
+ const mismatches = analyzer.analyze({
290
+ expected: { name: 'vpc-v1' },
291
+ actual: { name: 'vpc-v2' },
292
+ propertyMutability: {}, // No mutability specified
293
+ });
294
+
295
+ expect(mismatches).toHaveLength(1);
296
+ expect(mismatches[0].mutability.value).toBe('MUTABLE');
297
+ });
298
+
299
+ it('should apply IMMUTABLE mutability', () => {
300
+ const mismatches = analyzer.analyze({
301
+ expected: { bucketName: 'bucket-v1' },
302
+ actual: { bucketName: 'bucket-v2' },
303
+ propertyMutability: { bucketName: PropertyMutability.IMMUTABLE },
304
+ });
305
+
306
+ expect(mismatches).toHaveLength(1);
307
+ expect(mismatches[0].mutability.value).toBe('IMMUTABLE');
308
+ });
309
+
310
+ it('should apply CONDITIONAL mutability', () => {
311
+ const mismatches = analyzer.analyze({
312
+ expected: { engineVersion: '13.7' },
313
+ actual: { engineVersion: '13.8' },
314
+ propertyMutability: { engineVersion: PropertyMutability.CONDITIONAL },
315
+ });
316
+
317
+ expect(mismatches).toHaveLength(1);
318
+ expect(mismatches[0].mutability.value).toBe('CONDITIONAL');
319
+ });
320
+ });
321
+
322
+ describe('multiple mismatches', () => {
323
+ it('should detect multiple mismatches in same object', () => {
324
+ const mismatches = analyzer.analyze({
325
+ expected: {
326
+ name: 'vpc-v1',
327
+ cidr: '10.0.0.0/16',
328
+ enableDnsSupport: true,
329
+ },
330
+ actual: {
331
+ name: 'vpc-v2',
332
+ cidr: '10.1.0.0/16',
333
+ enableDnsSupport: true,
334
+ },
335
+ propertyMutability: {
336
+ name: PropertyMutability.MUTABLE,
337
+ cidr: PropertyMutability.IMMUTABLE,
338
+ enableDnsSupport: PropertyMutability.MUTABLE,
339
+ },
340
+ });
341
+
342
+ expect(mismatches).toHaveLength(2);
343
+
344
+ const nameMismatch = mismatches.find((m) => m.propertyPath === 'name');
345
+ expect(nameMismatch).toBeDefined();
346
+ expect(nameMismatch.mutability.value).toBe('MUTABLE');
347
+
348
+ const cidrMismatch = mismatches.find((m) => m.propertyPath === 'cidr');
349
+ expect(cidrMismatch).toBeDefined();
350
+ expect(cidrMismatch.mutability.value).toBe('IMMUTABLE');
351
+ });
352
+ });
353
+
354
+ describe('ignore properties', () => {
355
+ it('should ignore specified properties', () => {
356
+ const mismatches = analyzer.analyze({
357
+ expected: {
358
+ name: 'my-vpc',
359
+ lastModified: '2024-01-01',
360
+ },
361
+ actual: {
362
+ name: 'my-vpc',
363
+ lastModified: '2024-01-15',
364
+ },
365
+ propertyMutability: { name: PropertyMutability.MUTABLE },
366
+ ignoreProperties: ['lastModified'],
367
+ });
368
+
369
+ expect(mismatches).toHaveLength(0);
370
+ });
371
+
372
+ it('should ignore nested properties', () => {
373
+ const mismatches = analyzer.analyze({
374
+ expected: {
375
+ config: {
376
+ name: 'production',
377
+ timestamp: '2024-01-01',
378
+ },
379
+ },
380
+ actual: {
381
+ config: {
382
+ name: 'production',
383
+ timestamp: '2024-01-15',
384
+ },
385
+ },
386
+ propertyMutability: { 'config.name': PropertyMutability.MUTABLE },
387
+ ignoreProperties: ['config.timestamp'],
388
+ });
389
+
390
+ expect(mismatches).toHaveLength(0);
391
+ });
392
+ });
393
+
394
+ describe('empty objects', () => {
395
+ it('should detect no mismatch for both empty objects', () => {
396
+ const mismatches = analyzer.analyze({
397
+ expected: {},
398
+ actual: {},
399
+ propertyMutability: {},
400
+ });
401
+
402
+ expect(mismatches).toHaveLength(0);
403
+ });
404
+
405
+ it('should detect mismatch when expected is empty but actual has properties', () => {
406
+ const mismatches = analyzer.analyze({
407
+ expected: {},
408
+ actual: { name: 'my-vpc' },
409
+ propertyMutability: { name: PropertyMutability.MUTABLE },
410
+ });
411
+
412
+ expect(mismatches).toHaveLength(1);
413
+ expect(mismatches[0].propertyPath).toBe('name');
414
+ expect(mismatches[0].expectedValue).toBeUndefined();
415
+ expect(mismatches[0].actualValue).toBe('my-vpc');
416
+ });
417
+
418
+ it('should detect mismatch when actual is empty but expected has properties', () => {
419
+ const mismatches = analyzer.analyze({
420
+ expected: { name: 'my-vpc' },
421
+ actual: {},
422
+ propertyMutability: { name: PropertyMutability.MUTABLE },
423
+ });
424
+
425
+ expect(mismatches).toHaveLength(1);
426
+ expect(mismatches[0].propertyPath).toBe('name');
427
+ expect(mismatches[0].expectedValue).toBe('my-vpc');
428
+ expect(mismatches[0].actualValue).toBeUndefined();
429
+ });
430
+ });
431
+ });
@@ -152,6 +152,19 @@ class StackIdentifier {
152
152
  return `${this.stackName} (${this.region})`;
153
153
  }
154
154
 
155
+ /**
156
+ * Serialize to JSON
157
+ *
158
+ * @returns {Object}
159
+ */
160
+ toJSON() {
161
+ return {
162
+ stackName: this.stackName,
163
+ region: this.region,
164
+ accountId: this.accountId,
165
+ };
166
+ }
167
+
155
168
  /**
156
169
  * Create StackIdentifier from ARN
157
170
  *