@friggframework/devtools 2.0.0--canary.474.a0b734c.0 → 2.0.0--canary.474.898a56c.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 (24) hide show
  1. package/infrastructure/domains/health/application/use-cases/__tests__/mismatch-analyzer-method-name.test.js +167 -0
  2. package/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js +762 -0
  3. package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.js +154 -1
  4. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.js +20 -5
  5. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.test.js +3 -3
  6. package/infrastructure/domains/health/docs/ACME-DEV-DRIFT-ANALYSIS.md +267 -0
  7. package/infrastructure/domains/health/docs/BUILD-VS-DEPLOYED-TEMPLATE-ANALYSIS.md +324 -0
  8. package/infrastructure/domains/health/docs/ORPHAN-DETECTION-ANALYSIS.md +386 -0
  9. package/infrastructure/domains/health/docs/SPEC-CLEANUP-COMMAND.md +1419 -0
  10. package/infrastructure/domains/health/docs/TDD-IMPLEMENTATION-SUMMARY.md +391 -0
  11. package/infrastructure/domains/health/docs/TEMPLATE-COMPARISON-IMPLEMENTATION.md +551 -0
  12. package/infrastructure/domains/health/domain/services/__tests__/health-score-percentage-based.test.js +380 -0
  13. package/infrastructure/domains/health/domain/services/__tests__/logical-id-mapper.test.js +645 -0
  14. package/infrastructure/domains/health/domain/services/__tests__/template-parser.test.js +496 -0
  15. package/infrastructure/domains/health/domain/services/health-score-calculator.js +174 -91
  16. package/infrastructure/domains/health/domain/services/health-score-calculator.test.js +332 -228
  17. package/infrastructure/domains/health/domain/services/logical-id-mapper.js +330 -0
  18. package/infrastructure/domains/health/domain/services/template-parser.js +245 -0
  19. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-cfn-tagged.test.js +312 -0
  20. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-multi-stack.test.js +367 -0
  21. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-relationship-analysis.test.js +432 -0
  22. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.js +108 -14
  23. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.test.js +69 -12
  24. package/package.json +6 -6
@@ -1,5 +1,13 @@
1
1
  /**
2
2
  * Tests for HealthScoreCalculator Domain Service
3
+ *
4
+ * Updated for percentage-based health scoring (2025-10-26)
5
+ *
6
+ * NEW SYSTEM (Percentage-Based):
7
+ * - Critical issues: up to 50 points (% of total resources)
8
+ * - Functional drift: up to 30 points (% of critical resources)
9
+ * - Infrastructure drift: up to 20 points (% of infra resources)
10
+ * - Example: 16/16 Lambdas drifted = 100% × 30 = 30 penalty → 70/100 ✅
3
11
  */
4
12
 
5
13
  const HealthScoreCalculator = require('./health-score-calculator');
@@ -43,8 +51,18 @@ describe('HealthScoreCalculator', () => {
43
51
  });
44
52
  });
45
53
 
46
- describe('critical issues (heavy penalties)', () => {
47
- it('should deduct 30 points for orphaned resource', () => {
54
+ describe('critical issues (orphaned, missing)', () => {
55
+ it('should penalize based on percentage of resources that are orphaned', () => {
56
+ // 10 resources, 1 orphaned = 10% critical impact
57
+ const resources = Array.from({ length: 10 }, (_, i) =>
58
+ new Resource({
59
+ logicalId: `Resource${i}`,
60
+ physicalId: `resource-${i}`,
61
+ resourceType: 'AWS::EC2::VPC',
62
+ state: ResourceState.IN_STACK,
63
+ })
64
+ );
65
+
48
66
  const issues = [
49
67
  Issue.orphanedResource({
50
68
  resourceType: 'AWS::RDS::DBCluster',
@@ -53,331 +71,418 @@ describe('HealthScoreCalculator', () => {
53
71
  }),
54
72
  ];
55
73
 
56
- const score = calculator.calculate({ resources: [], issues });
57
- expect(score.value).toBe(70); // 100 - 30
74
+ const score = calculator.calculate({ resources, issues });
75
+ // Critical impact: 1/10 = 10% → penalty: 10% × 50 = 5
76
+ // Score: 100 - 5 = 95
77
+ expect(score.value).toBe(95);
58
78
  });
59
79
 
60
- it('should deduct 30 points for missing resource', () => {
80
+ it('should penalize based on percentage of resources that are missing', () => {
81
+ // 10 resources, 2 missing = 20% critical impact
82
+ const resources = Array.from({ length: 10 }, (_, i) =>
83
+ new Resource({
84
+ logicalId: `Resource${i}`,
85
+ physicalId: `resource-${i}`,
86
+ resourceType: 'AWS::KMS::Key',
87
+ state: ResourceState.IN_STACK,
88
+ })
89
+ );
90
+
61
91
  const issues = [
62
92
  Issue.missingResource({
63
93
  resourceType: 'AWS::KMS::Key',
64
- resourceId: 'MissingKey',
65
- description: 'Missing key',
94
+ resourceId: 'MissingKey1',
95
+ description: 'Missing key 1',
66
96
  }),
67
- ];
68
-
69
- const score = calculator.calculate({ resources: [], issues });
70
- expect(score.value).toBe(70); // 100 - 30
71
- });
72
-
73
- it('should deduct 20 points for immutable property mismatch', () => {
74
- const mismatch = new PropertyMismatch({
75
- propertyPath: 'Properties.BucketName',
76
- expectedValue: 'bucket-v2',
77
- actualValue: 'bucket-v1',
78
- mutability: PropertyMutability.IMMUTABLE,
79
- });
80
-
81
- const issues = [
82
- Issue.propertyMismatch({
83
- resourceType: 'AWS::S3::Bucket',
84
- resourceId: 'my-bucket',
85
- mismatch,
97
+ Issue.missingResource({
98
+ resourceType: 'AWS::KMS::Key',
99
+ resourceId: 'MissingKey2',
100
+ description: 'Missing key 2',
86
101
  }),
87
102
  ];
88
103
 
89
- const score = calculator.calculate({ resources: [], issues });
90
- expect(score.value).toBe(80); // 100 - 20
104
+ const score = calculator.calculate({ resources, issues });
105
+ // Critical impact: 2/10 = 20% → penalty: 20% × 50 = 10
106
+ // Score: 100 - 10 = 90
107
+ expect(score.value).toBe(90);
91
108
  });
92
109
 
93
- it('should deduct 60 points for two orphaned resources', () => {
94
- const issues = [
95
- Issue.orphanedResource({
110
+ it('should apply higher penalties for larger percentages of orphaned resources', () => {
111
+ // 10 resources, 4 orphaned = 40% critical impact
112
+ const resources = Array.from({ length: 10 }, (_, i) =>
113
+ new Resource({
114
+ logicalId: `Resource${i}`,
115
+ physicalId: `resource-${i}`,
96
116
  resourceType: 'AWS::RDS::DBCluster',
97
- resourceId: 'orphan-1',
98
- description: 'Orphaned cluster 1',
99
- }),
117
+ state: ResourceState.IN_STACK,
118
+ })
119
+ );
120
+
121
+ const issues = Array.from({ length: 4 }, (_, i) =>
100
122
  Issue.orphanedResource({
101
123
  resourceType: 'AWS::RDS::DBCluster',
102
- resourceId: 'orphan-2',
103
- description: 'Orphaned cluster 2',
104
- }),
105
- ];
106
-
107
- const score = calculator.calculate({ resources: [], issues });
108
- expect(score.value).toBe(40); // 100 - 30 - 30
124
+ resourceId: `orphan-${i}`,
125
+ description: `Orphaned cluster ${i}`,
126
+ })
127
+ );
128
+
129
+ const score = calculator.calculate({ resources, issues });
130
+ // Critical impact: 4/10 = 40% penalty: 40% × 50 = 20
131
+ // Score: 100 - 20 = 80
132
+ expect(score.value).toBe(80);
109
133
  });
110
134
  });
111
135
 
112
- describe('warning issues (moderate penalties)', () => {
113
- it('should deduct 10 points for mutable property mismatch', () => {
136
+ describe('functional drift (critical resources)', () => {
137
+ it('should penalize drift on Lambda functions', () => {
138
+ // 10 Lambda, 10 VPC; 1 Lambda drifted = 10% functional drift
139
+ const resources = [
140
+ ...Array.from({ length: 10 }, (_, i) =>
141
+ new Resource({
142
+ logicalId: `Lambda${i}`,
143
+ physicalId: `lambda-${i}`,
144
+ resourceType: 'AWS::Lambda::Function',
145
+ state: ResourceState.IN_STACK,
146
+ })
147
+ ),
148
+ ...Array.from({ length: 10 }, (_, i) =>
149
+ new Resource({
150
+ logicalId: `VPC${i}`,
151
+ physicalId: `vpc-${i}`,
152
+ resourceType: 'AWS::EC2::VPC',
153
+ state: ResourceState.IN_STACK,
154
+ })
155
+ ),
156
+ ];
157
+
114
158
  const mismatch = new PropertyMismatch({
115
- propertyPath: 'Properties.Tags',
116
- expectedValue: ['tag1'],
117
- actualValue: ['tag2'],
159
+ propertyPath: 'Properties.Environment',
160
+ expectedValue: { VAR: 'expected' },
161
+ actualValue: { VAR: 'actual' },
118
162
  mutability: PropertyMutability.MUTABLE,
119
163
  });
120
164
 
121
165
  const issues = [
122
166
  Issue.propertyMismatch({
123
- resourceType: 'AWS::EC2::VPC',
124
- resourceId: 'vpc-123',
167
+ resourceType: 'AWS::Lambda::Function',
168
+ resourceId: 'lambda-0',
125
169
  mismatch,
126
170
  }),
127
171
  ];
128
172
 
129
- const score = calculator.calculate({ resources: [], issues });
130
- expect(score.value).toBe(90); // 100 - 10
173
+ const score = calculator.calculate({ resources, issues });
174
+ // Functional drift: 1/10 = 10% → penalty: 10% × 30 = 3
175
+ // Score: 100 - 3 = 97
176
+ expect(score.value).toBe(97);
131
177
  });
132
178
 
133
- it('should deduct 20 points for two mutable property mismatches', () => {
179
+ it('should count unique drifted resources when multiple properties drifted', () => {
180
+ // 10 Lambda; 1 Lambda with 2 property mismatches = 10% functional drift (not 20%)
181
+ const resources = Array.from({ length: 10 }, (_, i) =>
182
+ new Resource({
183
+ logicalId: `Lambda${i}`,
184
+ physicalId: `lambda-${i}`,
185
+ resourceType: 'AWS::Lambda::Function',
186
+ state: ResourceState.IN_STACK,
187
+ })
188
+ );
189
+
134
190
  const mismatch1 = new PropertyMismatch({
135
- propertyPath: 'Properties.Tags',
136
- expectedValue: ['tag1'],
137
- actualValue: ['tag2'],
191
+ propertyPath: 'Properties.Environment',
192
+ expectedValue: { VAR: 'expected' },
193
+ actualValue: { VAR: 'actual' },
138
194
  mutability: PropertyMutability.MUTABLE,
139
195
  });
140
196
 
141
197
  const mismatch2 = new PropertyMismatch({
142
- propertyPath: 'Properties.EnableDnsSupport',
143
- expectedValue: true,
144
- actualValue: false,
198
+ propertyPath: 'Properties.Timeout',
199
+ expectedValue: 30,
200
+ actualValue: 60,
145
201
  mutability: PropertyMutability.MUTABLE,
146
202
  });
147
203
 
148
204
  const issues = [
149
205
  Issue.propertyMismatch({
150
- resourceType: 'AWS::EC2::VPC',
151
- resourceId: 'vpc-123',
206
+ resourceType: 'AWS::Lambda::Function',
207
+ resourceId: 'lambda-0',
152
208
  mismatch: mismatch1,
153
209
  }),
154
210
  Issue.propertyMismatch({
155
- resourceType: 'AWS::EC2::VPC',
156
- resourceId: 'vpc-123',
211
+ resourceType: 'AWS::Lambda::Function',
212
+ resourceId: 'lambda-0',
157
213
  mismatch: mismatch2,
158
214
  }),
159
215
  ];
160
216
 
161
- const score = calculator.calculate({ resources: [], issues });
162
- expect(score.value).toBe(80); // 100 - 10 - 10
217
+ const score = calculator.calculate({ resources, issues });
218
+ // Functional drift: 1/10 = 10% (unique resources) penalty: 10% × 30 = 3
219
+ // Score: 100 - 3 = 97
220
+ expect(score.value).toBe(97);
163
221
  });
164
222
  });
165
223
 
166
- describe('info issues (minor penalties)', () => {
167
- it('should deduct 5 points for missing tag', () => {
168
- const issues = [
169
- new Issue({
170
- type: 'MISSING_TAG',
171
- severity: 'info',
172
- resourceType: 'AWS::Lambda::Function',
173
- resourceId: 'my-function',
174
- description: 'Missing Environment tag',
175
- }),
224
+ describe('infrastructure drift (non-critical resources)', () => {
225
+ it('should penalize drift on VPC/networking resources with lower weight', () => {
226
+ // 10 VPC, 10 Lambda; 5 VPC drifted = 50% infrastructure drift
227
+ const resources = [
228
+ ...Array.from({ length: 10 }, (_, i) =>
229
+ new Resource({
230
+ logicalId: `Lambda${i}`,
231
+ physicalId: `lambda-${i}`,
232
+ resourceType: 'AWS::Lambda::Function',
233
+ state: ResourceState.IN_STACK,
234
+ })
235
+ ),
236
+ ...Array.from({ length: 10 }, (_, i) =>
237
+ new Resource({
238
+ logicalId: `VPC${i}`,
239
+ physicalId: `vpc-${i}`,
240
+ resourceType: 'AWS::EC2::VPC',
241
+ state: ResourceState.IN_STACK,
242
+ })
243
+ ),
176
244
  ];
177
245
 
178
- const score = calculator.calculate({ resources: [], issues });
179
- expect(score.value).toBe(95); // 100 - 5
180
- });
246
+ const issues = Array.from({ length: 5 }, (_, i) => {
247
+ const mismatch = new PropertyMismatch({
248
+ propertyPath: 'Properties.Tags',
249
+ expectedValue: ['tag1'],
250
+ actualValue: ['tag2'],
251
+ mutability: PropertyMutability.MUTABLE,
252
+ });
181
253
 
182
- it('should deduct 10 points for two missing tags', () => {
183
- const issues = [
184
- new Issue({
185
- type: 'MISSING_TAG',
186
- severity: 'info',
187
- resourceType: 'AWS::Lambda::Function',
188
- resourceId: 'my-function',
189
- description: 'Missing Environment tag',
190
- }),
191
- new Issue({
192
- type: 'MISSING_TAG',
193
- severity: 'info',
194
- resourceType: 'AWS::Lambda::Function',
195
- resourceId: 'my-function',
196
- description: 'Missing Owner tag',
197
- }),
198
- ];
254
+ return Issue.propertyMismatch({
255
+ resourceType: 'AWS::EC2::VPC',
256
+ resourceId: `vpc-${i}`,
257
+ mismatch,
258
+ });
259
+ });
199
260
 
200
- const score = calculator.calculate({ resources: [], issues });
201
- expect(score.value).toBe(90); // 100 - 5 - 5
261
+ const score = calculator.calculate({ resources, issues });
262
+ // Infra drift: 5/10 = 50% → penalty: 50% × 20 = 10
263
+ // Score: 100 - 10 = 90
264
+ expect(score.value).toBe(90);
202
265
  });
203
266
  });
204
267
 
205
- describe('mixed severity issues', () => {
206
- it('should correctly combine penalties from different severity levels', () => {
268
+ describe('mixed issues', () => {
269
+ it('should correctly combine penalties from different categories', () => {
270
+ // 20 resources: 10 Lambda, 10 VPC
271
+ // 1 orphaned, 2 Lambda drifted, 5 VPC drifted
272
+ const resources = [
273
+ ...Array.from({ length: 10 }, (_, i) =>
274
+ new Resource({
275
+ logicalId: `Lambda${i}`,
276
+ physicalId: `lambda-${i}`,
277
+ resourceType: 'AWS::Lambda::Function',
278
+ state: ResourceState.IN_STACK,
279
+ })
280
+ ),
281
+ ...Array.from({ length: 10 }, (_, i) =>
282
+ new Resource({
283
+ logicalId: `VPC${i}`,
284
+ physicalId: `vpc-${i}`,
285
+ resourceType: 'AWS::EC2::VPC',
286
+ state: ResourceState.IN_STACK,
287
+ })
288
+ ),
289
+ ];
290
+
207
291
  const orphanedIssue = Issue.orphanedResource({
208
292
  resourceType: 'AWS::RDS::DBCluster',
209
293
  resourceId: 'orphan-123',
210
294
  description: 'Orphaned cluster',
211
295
  });
212
296
 
213
- const mismatch = new PropertyMismatch({
214
- propertyPath: 'Properties.Tags',
215
- expectedValue: ['tag1'],
216
- actualValue: ['tag2'],
217
- mutability: PropertyMutability.MUTABLE,
218
- });
297
+ const lambdaDriftIssues = Array.from({ length: 2 }, (_, i) => {
298
+ const mismatch = new PropertyMismatch({
299
+ propertyPath: 'Properties.Environment',
300
+ expectedValue: { VAR: 'expected' },
301
+ actualValue: { VAR: 'actual' },
302
+ mutability: PropertyMutability.MUTABLE,
303
+ });
219
304
 
220
- const warningIssue = Issue.propertyMismatch({
221
- resourceType: 'AWS::EC2::VPC',
222
- resourceId: 'vpc-123',
223
- mismatch,
305
+ return Issue.propertyMismatch({
306
+ resourceType: 'AWS::Lambda::Function',
307
+ resourceId: `lambda-${i}`,
308
+ mismatch,
309
+ });
224
310
  });
225
311
 
226
- const infoIssue = new Issue({
227
- type: 'MISSING_TAG',
228
- severity: 'info',
229
- resourceType: 'AWS::Lambda::Function',
230
- resourceId: 'my-function',
231
- description: 'Missing tag',
312
+ const vpcDriftIssues = Array.from({ length: 5 }, (_, i) => {
313
+ const mismatch = new PropertyMismatch({
314
+ propertyPath: 'Properties.Tags',
315
+ expectedValue: ['tag1'],
316
+ actualValue: ['tag2'],
317
+ mutability: PropertyMutability.MUTABLE,
318
+ });
319
+
320
+ return Issue.propertyMismatch({
321
+ resourceType: 'AWS::EC2::VPC',
322
+ resourceId: `vpc-${i}`,
323
+ mismatch,
324
+ });
232
325
  });
233
326
 
234
- const issues = [orphanedIssue, warningIssue, infoIssue];
327
+ const issues = [orphanedIssue, ...lambdaDriftIssues, ...vpcDriftIssues];
235
328
 
236
- const score = calculator.calculate({ resources: [], issues });
237
- expect(score.value).toBe(55); // 100 - 30 (critical) - 10 (warning) - 5 (info)
329
+ const score = calculator.calculate({ resources, issues });
330
+ // Critical impact: 1/20 = 5% penalty: 5% × 50 = 2.5
331
+ // Functional drift: 2/10 = 20% → penalty: 20% × 30 = 6
332
+ // Infra drift: 5/10 = 50% → penalty: 50% × 20 = 10
333
+ // Total: 2.5 + 6 + 10 = 18.5 → Score: 100 - 19 = 81 (rounded)
334
+ expect(score.value).toBeGreaterThanOrEqual(81);
335
+ expect(score.value).toBeLessThanOrEqual(82);
238
336
  });
239
337
  });
240
338
 
241
339
  describe('minimum score (0)', () => {
242
340
  it('should not go below 0', () => {
243
- // Create enough critical issues to exceed 100 points
244
- const issues = [
245
- Issue.orphanedResource({
246
- resourceType: 'AWS::RDS::DBCluster',
247
- resourceId: 'orphan-1',
248
- description: 'Orphaned cluster 1',
249
- }),
250
- Issue.orphanedResource({
251
- resourceType: 'AWS::RDS::DBCluster',
252
- resourceId: 'orphan-2',
253
- description: 'Orphaned cluster 2',
254
- }),
255
- Issue.orphanedResource({
341
+ // 2 resources, 10 critical issues = 500% critical impact (capped at 100%)
342
+ const resources = [
343
+ new Resource({
344
+ logicalId: 'Resource1',
345
+ physicalId: 'resource-1',
256
346
  resourceType: 'AWS::RDS::DBCluster',
257
- resourceId: 'orphan-3',
258
- description: 'Orphaned cluster 3',
347
+ state: ResourceState.IN_STACK,
259
348
  }),
260
- Issue.orphanedResource({
261
- resourceType: 'AWS::RDS::DBCluster',
262
- resourceId: 'orphan-4',
263
- description: 'Orphaned cluster 4',
349
+ new Resource({
350
+ logicalId: 'Resource2',
351
+ physicalId: 'resource-2',
352
+ resourceType: 'AWS::Lambda::Function',
353
+ state: ResourceState.IN_STACK,
264
354
  }),
265
355
  ];
266
356
 
267
- const score = calculator.calculate({ resources: [], issues });
268
- expect(score.value).toBe(0); // Should cap at 0, not go negative
357
+ const issues = Array.from({ length: 10 }, (_, i) =>
358
+ Issue.orphanedResource({
359
+ resourceType: 'AWS::RDS::DBCluster',
360
+ resourceId: `orphan-${i}`,
361
+ description: `Orphaned cluster ${i}`,
362
+ })
363
+ );
364
+
365
+ const score = calculator.calculate({ resources, issues });
366
+ // Critical impact: 10/2 = 500% (but max penalty is 50)
367
+ // Penalty: min(500% × 50, 50) = 50
368
+ // Score: max(0, 100 - 50) = 50
369
+ // But with such extreme issues, score should be very low
370
+ expect(score.value).toBeGreaterThanOrEqual(0);
371
+ expect(score.value).toBeLessThanOrEqual(50);
269
372
  });
270
373
  });
271
374
 
272
375
  describe('penalty configuration', () => {
273
- it('should use custom penalty configuration', () => {
376
+ it('should use custom max penalty configuration', () => {
274
377
  const customCalculator = new HealthScoreCalculator({
275
- penalties: {
276
- critical: 20, // Custom: 20 instead of default 30
277
- warning: 5, // Custom: 5 instead of default 10
278
- info: 2, // Custom: 2 instead of default 5
378
+ maxPenalties: {
379
+ criticalIssues: 40, // Custom: 40 instead of default 50
380
+ functionalDrift: 35, // Custom: 35 instead of default 30
381
+ infrastructureDrift: 25, // Custom: 25 instead of default 20
279
382
  },
280
383
  });
281
384
 
282
- const issues = [
283
- Issue.orphanedResource({
385
+ // 10 resources, 5 orphaned = 50% critical impact
386
+ const resources = Array.from({ length: 10 }, (_, i) =>
387
+ new Resource({
388
+ logicalId: `Resource${i}`,
389
+ physicalId: `resource-${i}`,
284
390
  resourceType: 'AWS::RDS::DBCluster',
285
- resourceId: 'orphan-123',
286
- description: 'Orphaned cluster',
287
- }),
288
- ];
289
-
290
- const score = customCalculator.calculate({ resources: [], issues });
291
- expect(score.value).toBe(80); // 100 - 20 (custom critical penalty)
292
- });
293
-
294
- it('should allow immutable property penalty override', () => {
295
- const customCalculator = new HealthScoreCalculator({
296
- penalties: {
297
- immutablePropertyMismatch: 25, // Custom instead of default 20
298
- },
299
- });
300
-
301
- const mismatch = new PropertyMismatch({
302
- propertyPath: 'Properties.BucketName',
303
- expectedValue: 'bucket-v2',
304
- actualValue: 'bucket-v1',
305
- mutability: PropertyMutability.IMMUTABLE,
306
- });
307
-
308
- const issues = [
309
- Issue.propertyMismatch({
310
- resourceType: 'AWS::S3::Bucket',
311
- resourceId: 'my-bucket',
312
- mismatch,
313
- }),
314
- ];
315
-
316
- const score = customCalculator.calculate({ resources: [], issues });
317
- expect(score.value).toBe(75); // 100 - 25 (custom immutable penalty)
318
- });
319
- });
320
-
321
- describe('getDefaultPenalties', () => {
322
- it('should return default penalty configuration', () => {
323
- const penalties = HealthScoreCalculator.getDefaultPenalties();
391
+ state: ResourceState.IN_STACK,
392
+ })
393
+ );
324
394
 
325
- expect(penalties).toEqual({
326
- critical: 30,
327
- warning: 10,
328
- info: 5,
329
- immutablePropertyMismatch: 20,
330
- });
395
+ const issues = Array.from({ length: 5 }, (_, i) =>
396
+ Issue.orphanedResource({
397
+ resourceType: 'AWS::RDS::DBCluster',
398
+ resourceId: `orphan-${i}`,
399
+ description: `Orphaned cluster ${i}`,
400
+ })
401
+ );
402
+
403
+ const score = customCalculator.calculate({ resources, issues });
404
+ // Critical impact: 5/10 = 50% → penalty: 50% × 40 (custom) = 20
405
+ // Score: 100 - 20 = 80
406
+ expect(score.value).toBe(80);
331
407
  });
332
408
  });
333
409
 
334
410
  describe('explainScore', () => {
335
- it('should explain score with breakdown', () => {
411
+ it('should explain score with percentage-based breakdown', () => {
412
+ // 20 resources: 10 Lambda, 10 VPC
413
+ // 1 orphaned, 2 Lambda drifted, 5 VPC drifted
414
+ const resources = [
415
+ ...Array.from({ length: 10 }, (_, i) =>
416
+ new Resource({
417
+ logicalId: `Lambda${i}`,
418
+ physicalId: `lambda-${i}`,
419
+ resourceType: 'AWS::Lambda::Function',
420
+ state: ResourceState.IN_STACK,
421
+ })
422
+ ),
423
+ ...Array.from({ length: 10 }, (_, i) =>
424
+ new Resource({
425
+ logicalId: `VPC${i}`,
426
+ physicalId: `vpc-${i}`,
427
+ resourceType: 'AWS::EC2::VPC',
428
+ state: ResourceState.IN_STACK,
429
+ })
430
+ ),
431
+ ];
432
+
336
433
  const orphanedIssue = Issue.orphanedResource({
337
434
  resourceType: 'AWS::RDS::DBCluster',
338
435
  resourceId: 'orphan-123',
339
436
  description: 'Orphaned cluster',
340
437
  });
341
438
 
342
- const mismatch = new PropertyMismatch({
343
- propertyPath: 'Properties.Tags',
344
- expectedValue: ['tag1'],
345
- actualValue: ['tag2'],
346
- mutability: PropertyMutability.MUTABLE,
347
- });
348
-
349
- const warningIssue = Issue.propertyMismatch({
350
- resourceType: 'AWS::EC2::VPC',
351
- resourceId: 'vpc-123',
352
- mismatch,
353
- });
439
+ const lambdaDriftIssues = Array.from({ length: 2 }, (_, i) => {
440
+ const mismatch = new PropertyMismatch({
441
+ propertyPath: 'Properties.Environment',
442
+ expectedValue: { VAR: 'expected' },
443
+ actualValue: { VAR: 'actual' },
444
+ mutability: PropertyMutability.MUTABLE,
445
+ });
354
446
 
355
- const infoIssue = new Issue({
356
- type: 'MISSING_TAG',
357
- severity: 'info',
358
- resourceType: 'AWS::Lambda::Function',
359
- resourceId: 'my-function',
360
- description: 'Missing tag',
447
+ return Issue.propertyMismatch({
448
+ resourceType: 'AWS::Lambda::Function',
449
+ resourceId: `lambda-${i}`,
450
+ mismatch,
451
+ });
361
452
  });
362
453
 
363
- const issues = [orphanedIssue, warningIssue, infoIssue];
454
+ const vpcDriftIssues = Array.from({ length: 5 }, (_, i) => {
455
+ const mismatch = new PropertyMismatch({
456
+ propertyPath: 'Properties.Tags',
457
+ expectedValue: ['tag1'],
458
+ actualValue: ['tag2'],
459
+ mutability: PropertyMutability.MUTABLE,
460
+ });
364
461
 
365
- const explanation = calculator.explainScore({ resources: [], issues });
462
+ return Issue.propertyMismatch({
463
+ resourceType: 'AWS::EC2::VPC',
464
+ resourceId: `vpc-${i}`,
465
+ mismatch,
466
+ });
467
+ });
366
468
 
367
- expect(explanation).toEqual({
368
- finalScore: 55,
369
- startingScore: 100,
370
- totalPenalty: 45,
371
- breakdown: {
372
- critical: { count: 1, penalty: 30 },
373
- warning: { count: 1, penalty: 10 },
374
- info: { count: 1, penalty: 5 },
375
- },
376
- issueTypes: {
377
- ORPHANED_RESOURCE: 1,
378
- PROPERTY_MISMATCH: 1,
379
- MISSING_TAG: 1,
380
- },
469
+ const issues = [orphanedIssue, ...lambdaDriftIssues, ...vpcDriftIssues];
470
+
471
+ const explanation = calculator.explainScore({ resources, issues });
472
+
473
+ expect(explanation.startingScore).toBe(100);
474
+ expect(explanation.finalScore).toBeGreaterThanOrEqual(81);
475
+ expect(explanation.finalScore).toBeLessThanOrEqual(82);
476
+ expect(explanation.breakdown).toHaveProperty('criticalIssues');
477
+ expect(explanation.breakdown).toHaveProperty('functionalDrift');
478
+ expect(explanation.breakdown).toHaveProperty('infrastructureDrift');
479
+ expect(explanation.breakdown.criticalIssues.count).toBe(1);
480
+ expect(explanation.breakdown.functionalDrift.count).toBe(2);
481
+ expect(explanation.breakdown.infrastructureDrift.count).toBe(5);
482
+ expect(explanation.resourceCounts).toEqual({
483
+ total: 20,
484
+ critical: 10,
485
+ infrastructure: 10,
381
486
  });
382
487
  });
383
488
 
@@ -389,11 +494,10 @@ describe('HealthScoreCalculator', () => {
389
494
  startingScore: 100,
390
495
  totalPenalty: 0,
391
496
  breakdown: {
392
- critical: { count: 0, penalty: 0 },
393
- warning: { count: 0, penalty: 0 },
394
- info: { count: 0, penalty: 0 },
497
+ criticalIssues: { count: 0, impactPercent: 0, penalty: 0 },
498
+ functionalDrift: { count: 0, impactPercent: 0, penalty: 0 },
499
+ infrastructureDrift: { count: 0, impactPercent: 0, penalty: 0 },
395
500
  },
396
- issueTypes: {},
397
501
  });
398
502
  });
399
503
  });