@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,165 @@
1
+ /**
2
+ * HealthScoreCalculator Domain Service
3
+ *
4
+ * Calculates health scores (0-100) based on detected issues and resource states.
5
+ *
6
+ * Penalty System:
7
+ * - Critical issues (orphaned, missing, immutable drift): 20-30 points each
8
+ * - Warning issues (mutable drift): 10 points each
9
+ * - Info issues (missing tags): 5 points each
10
+ *
11
+ * Score is capped at 0 (cannot go negative).
12
+ */
13
+
14
+ const HealthScore = require('../value-objects/health-score');
15
+
16
+ class HealthScoreCalculator {
17
+ /**
18
+ * Default penalty values for different issue severities
19
+ * @private
20
+ */
21
+ static DEFAULT_PENALTIES = {
22
+ critical: 30, // Orphaned resources, missing resources
23
+ warning: 10, // Mutable property mismatches
24
+ info: 5, // Missing tags, minor issues
25
+ immutablePropertyMismatch: 20, // Immutable property changes (requires replacement)
26
+ };
27
+
28
+ /**
29
+ * Create a new HealthScoreCalculator
30
+ *
31
+ * @param {Object} [config={}]
32
+ * @param {Object} [config.penalties] - Custom penalty configuration
33
+ * @param {number} [config.penalties.critical] - Penalty for critical issues (default: 30)
34
+ * @param {number} [config.penalties.warning] - Penalty for warning issues (default: 10)
35
+ * @param {number} [config.penalties.info] - Penalty for info issues (default: 5)
36
+ * @param {number} [config.penalties.immutablePropertyMismatch] - Penalty for immutable property changes (default: 20)
37
+ */
38
+ constructor(config = {}) {
39
+ this.penalties = {
40
+ ...HealthScoreCalculator.DEFAULT_PENALTIES,
41
+ ...(config.penalties || {}),
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Get default penalty configuration
47
+ * @returns {Object}
48
+ */
49
+ static getDefaultPenalties() {
50
+ return { ...HealthScoreCalculator.DEFAULT_PENALTIES };
51
+ }
52
+
53
+ /**
54
+ * Calculate health score based on resources and issues
55
+ *
56
+ * @param {Object} params
57
+ * @param {Resource[]} params.resources - Resources in the stack
58
+ * @param {Issue[]} params.issues - Detected issues
59
+ * @returns {HealthScore}
60
+ */
61
+ calculate({ resources, issues }) {
62
+ const startingScore = 100;
63
+ let totalPenalty = 0;
64
+
65
+ // Calculate penalties for each issue
66
+ for (const issue of issues) {
67
+ totalPenalty += this._calculateIssuePenalty(issue);
68
+ }
69
+
70
+ // Calculate final score (capped at 0)
71
+ const finalScore = Math.max(0, startingScore - totalPenalty);
72
+
73
+ return new HealthScore(finalScore);
74
+ }
75
+
76
+ /**
77
+ * Calculate penalty for a single issue
78
+ *
79
+ * @private
80
+ * @param {Issue} issue
81
+ * @returns {number}
82
+ */
83
+ _calculateIssuePenalty(issue) {
84
+ // Special case: immutable property mismatch has higher penalty than regular critical
85
+ if (
86
+ issue.isPropertyMismatch() &&
87
+ issue.propertyMismatch &&
88
+ issue.propertyMismatch.requiresReplacement()
89
+ ) {
90
+ return this.penalties.immutablePropertyMismatch;
91
+ }
92
+
93
+ // Standard severity-based penalties
94
+ if (issue.isCritical()) {
95
+ return this.penalties.critical;
96
+ }
97
+
98
+ if (issue.isWarning()) {
99
+ return this.penalties.warning;
100
+ }
101
+
102
+ if (issue.isInfo()) {
103
+ return this.penalties.info;
104
+ }
105
+
106
+ // Fallback (should never reach here if Issue validation is correct)
107
+ return 0;
108
+ }
109
+
110
+ /**
111
+ * Explain the score calculation with detailed breakdown
112
+ *
113
+ * @param {Object} params
114
+ * @param {Resource[]} params.resources - Resources in the stack
115
+ * @param {Issue[]} params.issues - Detected issues
116
+ * @returns {Object} Explanation with breakdown
117
+ */
118
+ explainScore({ resources, issues }) {
119
+ const startingScore = 100;
120
+ let totalPenalty = 0;
121
+
122
+ // Count issues by severity
123
+ const breakdown = {
124
+ critical: { count: 0, penalty: 0 },
125
+ warning: { count: 0, penalty: 0 },
126
+ info: { count: 0, penalty: 0 },
127
+ };
128
+
129
+ // Count issues by type
130
+ const issueTypes = {};
131
+
132
+ // Process each issue
133
+ for (const issue of issues) {
134
+ const penalty = this._calculateIssuePenalty(issue);
135
+ totalPenalty += penalty;
136
+
137
+ // Track by severity
138
+ if (issue.isCritical()) {
139
+ breakdown.critical.count++;
140
+ breakdown.critical.penalty += penalty;
141
+ } else if (issue.isWarning()) {
142
+ breakdown.warning.count++;
143
+ breakdown.warning.penalty += penalty;
144
+ } else if (issue.isInfo()) {
145
+ breakdown.info.count++;
146
+ breakdown.info.penalty += penalty;
147
+ }
148
+
149
+ // Track by type
150
+ issueTypes[issue.type] = (issueTypes[issue.type] || 0) + 1;
151
+ }
152
+
153
+ const finalScore = Math.max(0, startingScore - totalPenalty);
154
+
155
+ return {
156
+ finalScore,
157
+ startingScore,
158
+ totalPenalty,
159
+ breakdown,
160
+ issueTypes,
161
+ };
162
+ }
163
+ }
164
+
165
+ module.exports = HealthScoreCalculator;
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Tests for HealthScoreCalculator Domain Service
3
+ */
4
+
5
+ const HealthScoreCalculator = require('./health-score-calculator');
6
+ const Resource = require('../entities/resource');
7
+ const Issue = require('../entities/issue');
8
+ const ResourceState = require('../value-objects/resource-state');
9
+ const PropertyMutability = require('../value-objects/property-mutability');
10
+ const PropertyMismatch = require('../entities/property-mismatch');
11
+
12
+ describe('HealthScoreCalculator', () => {
13
+ let calculator;
14
+
15
+ beforeEach(() => {
16
+ calculator = new HealthScoreCalculator();
17
+ });
18
+
19
+ describe('perfect health (100)', () => {
20
+ it('should return 100 for no resources and no issues', () => {
21
+ const score = calculator.calculate({ resources: [], issues: [] });
22
+ expect(score.value).toBe(100);
23
+ });
24
+
25
+ it('should return 100 for all healthy resources with no issues', () => {
26
+ const resources = [
27
+ new Resource({
28
+ logicalId: 'VPC',
29
+ physicalId: 'vpc-123',
30
+ resourceType: 'AWS::EC2::VPC',
31
+ state: ResourceState.IN_STACK,
32
+ }),
33
+ new Resource({
34
+ logicalId: 'Subnet',
35
+ physicalId: 'subnet-123',
36
+ resourceType: 'AWS::EC2::Subnet',
37
+ state: ResourceState.IN_STACK,
38
+ }),
39
+ ];
40
+
41
+ const score = calculator.calculate({ resources, issues: [] });
42
+ expect(score.value).toBe(100);
43
+ });
44
+ });
45
+
46
+ describe('critical issues (heavy penalties)', () => {
47
+ it('should deduct 30 points for orphaned resource', () => {
48
+ const issues = [
49
+ Issue.orphanedResource({
50
+ resourceType: 'AWS::RDS::DBCluster',
51
+ resourceId: 'orphan-123',
52
+ description: 'Orphaned cluster',
53
+ }),
54
+ ];
55
+
56
+ const score = calculator.calculate({ resources: [], issues });
57
+ expect(score.value).toBe(70); // 100 - 30
58
+ });
59
+
60
+ it('should deduct 30 points for missing resource', () => {
61
+ const issues = [
62
+ Issue.missingResource({
63
+ resourceType: 'AWS::KMS::Key',
64
+ resourceId: 'MissingKey',
65
+ description: 'Missing key',
66
+ }),
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,
86
+ }),
87
+ ];
88
+
89
+ const score = calculator.calculate({ resources: [], issues });
90
+ expect(score.value).toBe(80); // 100 - 20
91
+ });
92
+
93
+ it('should deduct 60 points for two orphaned resources', () => {
94
+ const issues = [
95
+ Issue.orphanedResource({
96
+ resourceType: 'AWS::RDS::DBCluster',
97
+ resourceId: 'orphan-1',
98
+ description: 'Orphaned cluster 1',
99
+ }),
100
+ Issue.orphanedResource({
101
+ 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
109
+ });
110
+ });
111
+
112
+ describe('warning issues (moderate penalties)', () => {
113
+ it('should deduct 10 points for mutable property mismatch', () => {
114
+ const mismatch = new PropertyMismatch({
115
+ propertyPath: 'Properties.Tags',
116
+ expectedValue: ['tag1'],
117
+ actualValue: ['tag2'],
118
+ mutability: PropertyMutability.MUTABLE,
119
+ });
120
+
121
+ const issues = [
122
+ Issue.propertyMismatch({
123
+ resourceType: 'AWS::EC2::VPC',
124
+ resourceId: 'vpc-123',
125
+ mismatch,
126
+ }),
127
+ ];
128
+
129
+ const score = calculator.calculate({ resources: [], issues });
130
+ expect(score.value).toBe(90); // 100 - 10
131
+ });
132
+
133
+ it('should deduct 20 points for two mutable property mismatches', () => {
134
+ const mismatch1 = new PropertyMismatch({
135
+ propertyPath: 'Properties.Tags',
136
+ expectedValue: ['tag1'],
137
+ actualValue: ['tag2'],
138
+ mutability: PropertyMutability.MUTABLE,
139
+ });
140
+
141
+ const mismatch2 = new PropertyMismatch({
142
+ propertyPath: 'Properties.EnableDnsSupport',
143
+ expectedValue: true,
144
+ actualValue: false,
145
+ mutability: PropertyMutability.MUTABLE,
146
+ });
147
+
148
+ const issues = [
149
+ Issue.propertyMismatch({
150
+ resourceType: 'AWS::EC2::VPC',
151
+ resourceId: 'vpc-123',
152
+ mismatch: mismatch1,
153
+ }),
154
+ Issue.propertyMismatch({
155
+ resourceType: 'AWS::EC2::VPC',
156
+ resourceId: 'vpc-123',
157
+ mismatch: mismatch2,
158
+ }),
159
+ ];
160
+
161
+ const score = calculator.calculate({ resources: [], issues });
162
+ expect(score.value).toBe(80); // 100 - 10 - 10
163
+ });
164
+ });
165
+
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
+ }),
176
+ ];
177
+
178
+ const score = calculator.calculate({ resources: [], issues });
179
+ expect(score.value).toBe(95); // 100 - 5
180
+ });
181
+
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
+ ];
199
+
200
+ const score = calculator.calculate({ resources: [], issues });
201
+ expect(score.value).toBe(90); // 100 - 5 - 5
202
+ });
203
+ });
204
+
205
+ describe('mixed severity issues', () => {
206
+ it('should correctly combine penalties from different severity levels', () => {
207
+ const orphanedIssue = Issue.orphanedResource({
208
+ resourceType: 'AWS::RDS::DBCluster',
209
+ resourceId: 'orphan-123',
210
+ description: 'Orphaned cluster',
211
+ });
212
+
213
+ const mismatch = new PropertyMismatch({
214
+ propertyPath: 'Properties.Tags',
215
+ expectedValue: ['tag1'],
216
+ actualValue: ['tag2'],
217
+ mutability: PropertyMutability.MUTABLE,
218
+ });
219
+
220
+ const warningIssue = Issue.propertyMismatch({
221
+ resourceType: 'AWS::EC2::VPC',
222
+ resourceId: 'vpc-123',
223
+ mismatch,
224
+ });
225
+
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',
232
+ });
233
+
234
+ const issues = [orphanedIssue, warningIssue, infoIssue];
235
+
236
+ const score = calculator.calculate({ resources: [], issues });
237
+ expect(score.value).toBe(55); // 100 - 30 (critical) - 10 (warning) - 5 (info)
238
+ });
239
+ });
240
+
241
+ describe('minimum score (0)', () => {
242
+ 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({
256
+ resourceType: 'AWS::RDS::DBCluster',
257
+ resourceId: 'orphan-3',
258
+ description: 'Orphaned cluster 3',
259
+ }),
260
+ Issue.orphanedResource({
261
+ resourceType: 'AWS::RDS::DBCluster',
262
+ resourceId: 'orphan-4',
263
+ description: 'Orphaned cluster 4',
264
+ }),
265
+ ];
266
+
267
+ const score = calculator.calculate({ resources: [], issues });
268
+ expect(score.value).toBe(0); // Should cap at 0, not go negative
269
+ });
270
+ });
271
+
272
+ describe('penalty configuration', () => {
273
+ it('should use custom penalty configuration', () => {
274
+ 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
279
+ },
280
+ });
281
+
282
+ const issues = [
283
+ Issue.orphanedResource({
284
+ 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();
324
+
325
+ expect(penalties).toEqual({
326
+ critical: 30,
327
+ warning: 10,
328
+ info: 5,
329
+ immutablePropertyMismatch: 20,
330
+ });
331
+ });
332
+ });
333
+
334
+ describe('explainScore', () => {
335
+ it('should explain score with breakdown', () => {
336
+ const orphanedIssue = Issue.orphanedResource({
337
+ resourceType: 'AWS::RDS::DBCluster',
338
+ resourceId: 'orphan-123',
339
+ description: 'Orphaned cluster',
340
+ });
341
+
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
+ });
354
+
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',
361
+ });
362
+
363
+ const issues = [orphanedIssue, warningIssue, infoIssue];
364
+
365
+ const explanation = calculator.explainScore({ resources: [], issues });
366
+
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
+ },
381
+ });
382
+ });
383
+
384
+ it('should explain perfect score', () => {
385
+ const explanation = calculator.explainScore({ resources: [], issues: [] });
386
+
387
+ expect(explanation).toEqual({
388
+ finalScore: 100,
389
+ startingScore: 100,
390
+ totalPenalty: 0,
391
+ breakdown: {
392
+ critical: { count: 0, penalty: 0 },
393
+ warning: { count: 0, penalty: 0 },
394
+ info: { count: 0, penalty: 0 },
395
+ },
396
+ issueTypes: {},
397
+ });
398
+ });
399
+ });
400
+ });