@friggframework/devtools 2.0.0--canary.474.86c5119.0 → 2.0.0--canary.474.6a0bba7.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 (32) hide show
  1. package/infrastructure/domains/database/migration-builder.js +199 -1
  2. package/infrastructure/domains/database/migration-builder.test.js +73 -0
  3. package/infrastructure/domains/health/application/use-cases/__tests__/mismatch-analyzer-method-name.test.js +167 -0
  4. package/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js +1130 -0
  5. package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.js +6 -0
  6. package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.js +307 -1
  7. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.js +38 -5
  8. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.test.js +3 -3
  9. package/infrastructure/domains/health/docs/ACME-DEV-DRIFT-ANALYSIS.md +267 -0
  10. package/infrastructure/domains/health/docs/BUILD-VS-DEPLOYED-TEMPLATE-ANALYSIS.md +324 -0
  11. package/infrastructure/domains/health/docs/ORPHAN-DETECTION-ANALYSIS.md +386 -0
  12. package/infrastructure/domains/health/docs/SPEC-CLEANUP-COMMAND.md +1419 -0
  13. package/infrastructure/domains/health/docs/TDD-IMPLEMENTATION-SUMMARY.md +391 -0
  14. package/infrastructure/domains/health/docs/TEMPLATE-COMPARISON-IMPLEMENTATION.md +551 -0
  15. package/infrastructure/domains/health/domain/entities/issue.js +50 -1
  16. package/infrastructure/domains/health/domain/entities/issue.test.js +111 -0
  17. package/infrastructure/domains/health/domain/services/__tests__/health-score-percentage-based.test.js +380 -0
  18. package/infrastructure/domains/health/domain/services/__tests__/logical-id-mapper.test.js +672 -0
  19. package/infrastructure/domains/health/domain/services/__tests__/template-parser.test.js +496 -0
  20. package/infrastructure/domains/health/domain/services/health-score-calculator.js +174 -91
  21. package/infrastructure/domains/health/domain/services/health-score-calculator.test.js +332 -228
  22. package/infrastructure/domains/health/domain/services/logical-id-mapper.js +345 -0
  23. package/infrastructure/domains/health/domain/services/template-parser.js +245 -0
  24. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-cfn-tagged.test.js +312 -0
  25. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-multi-stack.test.js +367 -0
  26. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-relationship-analysis.test.js +432 -0
  27. package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.js +407 -20
  28. package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.test.js +698 -26
  29. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.js +108 -14
  30. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.test.js +69 -12
  31. package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.js +392 -1
  32. package/package.json +6 -6
@@ -67,6 +67,8 @@ class ReconcilePropertiesUseCase {
67
67
  * @param {Object} params
68
68
  * @param {StackIdentifier} params.stackIdentifier - Stack identifier
69
69
  * @param {string} params.logicalId - Logical resource ID
70
+ * @param {string} [params.physicalId] - Physical resource ID (required for resource mode)
71
+ * @param {string} [params.resourceType] - Resource type (required for resource mode)
70
72
  * @param {PropertyMismatch[]} params.mismatches - Property mismatches to reconcile
71
73
  * @param {string} [params.mode='template'] - Reconciliation mode
72
74
  * @returns {Promise<Object>} Batch reconciliation result
@@ -74,6 +76,8 @@ class ReconcilePropertiesUseCase {
74
76
  async reconcileMultipleProperties({
75
77
  stackIdentifier,
76
78
  logicalId,
79
+ physicalId,
80
+ resourceType,
77
81
  mismatches,
78
82
  mode = 'template',
79
83
  }) {
@@ -107,6 +111,8 @@ class ReconcilePropertiesUseCase {
107
111
  const batchResult = await this.propertyReconciler.reconcileMultipleProperties({
108
112
  stackIdentifier,
109
113
  logicalId,
114
+ physicalId,
115
+ resourceType,
110
116
  mismatches: reconcilableProperties,
111
117
  mode,
112
118
  });
@@ -12,8 +12,12 @@
12
12
  * - Generate CloudFormation template snippets
13
13
  * - Execute import operations (single or batch)
14
14
  * - Track import operation status
15
+ * - Map orphaned resources to correct logical IDs using template comparison
15
16
  */
16
17
 
18
+ const { TemplateParser } = require('../../domain/services/template-parser');
19
+ const { LogicalIdMapper } = require('../../domain/services/logical-id-mapper');
20
+
17
21
  class RepairViaImportUseCase {
18
22
  /**
19
23
  * Create use case with required dependencies
@@ -21,8 +25,11 @@ class RepairViaImportUseCase {
21
25
  * @param {Object} params
22
26
  * @param {IResourceImporter} params.resourceImporter - Resource import operations
23
27
  * @param {IResourceDetector} params.resourceDetector - Resource discovery and details
28
+ * @param {IStackRepository} params.stackRepository - CloudFormation stack operations
29
+ * @param {TemplateParser} params.templateParser - CloudFormation template parsing
30
+ * @param {LogicalIdMapper} params.logicalIdMapper - Logical ID mapping service
24
31
  */
25
- constructor({ resourceImporter, resourceDetector }) {
32
+ constructor({ resourceImporter, resourceDetector, stackRepository, templateParser, logicalIdMapper }) {
26
33
  if (!resourceImporter) {
27
34
  throw new Error('resourceImporter is required');
28
35
  }
@@ -32,6 +39,9 @@ class RepairViaImportUseCase {
32
39
 
33
40
  this.resourceImporter = resourceImporter;
34
41
  this.resourceDetector = resourceDetector;
42
+ this.stackRepository = stackRepository;
43
+ this.templateParser = templateParser || new TemplateParser();
44
+ this.logicalIdMapper = logicalIdMapper || new LogicalIdMapper({ region: 'us-east-1' });
35
45
  }
36
46
 
37
47
  /**
@@ -224,6 +234,302 @@ class RepairViaImportUseCase {
224
234
  properties: resourceDetails.properties,
225
235
  };
226
236
  }
237
+
238
+ /**
239
+ * Import orphaned resources with automatic logical ID mapping
240
+ * Uses template comparison to find correct logical IDs
241
+ *
242
+ * @param {Object} params
243
+ * @param {StackIdentifier} params.stackIdentifier - Target stack
244
+ * @param {Array} params.orphanedResources - Orphaned resources to import
245
+ * @param {string} params.buildTemplatePath - Path to .serverless/cloudformation-template-update-stack.json
246
+ * @returns {Promise<Object>} Import result with mappings
247
+ */
248
+ async importWithLogicalIdMapping({ stackIdentifier, orphanedResources, buildTemplatePath }) {
249
+ // 1. Validate build template exists
250
+ if (!buildTemplatePath) {
251
+ throw new Error('buildTemplatePath is required');
252
+ }
253
+
254
+ const fs = require('fs');
255
+ if (!fs.existsSync(buildTemplatePath)) {
256
+ throw new Error(
257
+ `Build template not found at: ${buildTemplatePath}\n\n` +
258
+ `Please run one of:\n` +
259
+ ` • serverless package\n` +
260
+ ` • frigg build\n` +
261
+ ` • frigg deploy --stage dev\n\n` +
262
+ `Then try again:\n` +
263
+ ` frigg repair --import ${stackIdentifier.stackName}`
264
+ );
265
+ }
266
+
267
+ // 2. Parse build template
268
+ const buildTemplate = this.templateParser.parseTemplate(buildTemplatePath);
269
+
270
+ // 3. Get deployed template from CloudFormation
271
+ if (!this.stackRepository) {
272
+ throw new Error('stackRepository is required for template comparison');
273
+ }
274
+
275
+ const deployedTemplate = await this.stackRepository.getTemplate(stackIdentifier);
276
+
277
+ // 4. Map orphaned resources to logical IDs
278
+ const mappings = await this.logicalIdMapper.mapOrphanedResourcesToLogicalIds({
279
+ orphanedResources,
280
+ buildTemplate,
281
+ deployedTemplate,
282
+ });
283
+
284
+ // 5. Filter out unmapped resources
285
+ const mappedResources = mappings.filter((m) => m.logicalId !== null);
286
+ const unmappedResources = mappings.filter((m) => m.logicalId === null);
287
+
288
+ if (mappedResources.length === 0) {
289
+ return {
290
+ success: false,
291
+ message: 'No resources could be mapped to logical IDs',
292
+ unmappedCount: unmappedResources.length,
293
+ unmappedResources,
294
+ };
295
+ }
296
+
297
+ // 6. Deduplicate: Select ONE resource per logical ID based on deployed template
298
+ const { selectedResources, duplicates } = this._deduplicateResourcesByLogicalId(
299
+ mappedResources,
300
+ deployedTemplate
301
+ );
302
+
303
+ // 7. Check for warnings
304
+ const multiResourceWarnings = this._checkForMultipleResources(duplicates);
305
+
306
+ // 8. Generate import-resources.json format using SELECTED resources
307
+ const resourcesToImport = selectedResources.map((mapping) => ({
308
+ ResourceType: mapping.resourceType,
309
+ LogicalResourceId: mapping.logicalId,
310
+ ResourceIdentifier: this._getResourceIdentifier(mapping),
311
+ }));
312
+
313
+ // 9. Return result with deduplication info
314
+ return {
315
+ success: true,
316
+ mappedCount: selectedResources.length,
317
+ unmappedCount: unmappedResources.length,
318
+ duplicatesRemoved: duplicates.length,
319
+ mappings: selectedResources,
320
+ unmappedResources,
321
+ duplicates, // Resources that were filtered out
322
+ resourcesToImport,
323
+ warnings: multiResourceWarnings,
324
+ buildTemplatePath,
325
+ deployedTemplatePath: 'CloudFormation (deployed)',
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Deduplicate resources: Select ONE resource per logical ID
331
+ * When multiple resources have the same logical ID, pick the one that's
332
+ * actually referenced in the deployed template.
333
+ *
334
+ * @param {Array} mappedResources - Resources with logical IDs
335
+ * @param {Object} deployedTemplate - Deployed CloudFormation template
336
+ * @returns {Object} { selectedResources, duplicates }
337
+ * @private
338
+ */
339
+ _deduplicateResourcesByLogicalId(mappedResources, deployedTemplate) {
340
+ // Group resources by logical ID
341
+ const byLogicalId = {};
342
+ mappedResources.forEach((resource) => {
343
+ if (!byLogicalId[resource.logicalId]) {
344
+ byLogicalId[resource.logicalId] = [];
345
+ }
346
+ byLogicalId[resource.logicalId].push(resource);
347
+ });
348
+
349
+ // Extract all physical IDs referenced in deployed template
350
+ const referencedIds = this._extractReferencedIdsFromTemplate(deployedTemplate);
351
+
352
+ const selectedResources = [];
353
+ const duplicates = [];
354
+
355
+ // For each logical ID, select ONE resource
356
+ Object.entries(byLogicalId).forEach(([logicalId, resources]) => {
357
+ if (resources.length === 1) {
358
+ // Only one resource - select it
359
+ selectedResources.push(resources[0]);
360
+ } else {
361
+ // Multiple resources - pick the one in deployed template
362
+ let selected = null;
363
+
364
+ // Try to find resource that's actually referenced
365
+ for (const resource of resources) {
366
+ if (this._isResourceReferenced(resource, referencedIds)) {
367
+ selected = resource;
368
+ break;
369
+ }
370
+ }
371
+
372
+ // Fallback: If none are referenced, pick first one
373
+ if (!selected) {
374
+ selected = resources[0];
375
+ }
376
+
377
+ selectedResources.push(selected);
378
+
379
+ // Mark others as duplicates
380
+ resources.forEach((r) => {
381
+ if (r.physicalId !== selected.physicalId) {
382
+ duplicates.push(r);
383
+ }
384
+ });
385
+ }
386
+ });
387
+
388
+ return { selectedResources, duplicates };
389
+ }
390
+
391
+ /**
392
+ * Extract all physical resource IDs referenced in deployed template
393
+ * Looks for hardcoded IDs in Lambda VPC configs, security group rules, etc.
394
+ * @private
395
+ */
396
+ _extractReferencedIdsFromTemplate(template) {
397
+ const referenced = {
398
+ vpcIds: new Set(),
399
+ subnetIds: new Set(),
400
+ securityGroupIds: new Set(),
401
+ };
402
+
403
+ if (!template || !template.resources) {
404
+ return referenced;
405
+ }
406
+
407
+ // Traverse all resources in template
408
+ Object.values(template.resources).forEach((resource) => {
409
+ // Lambda VPC config contains hardcoded IDs
410
+ if (
411
+ resource.Type === 'AWS::Lambda::Function' &&
412
+ resource.Properties?.VpcConfig
413
+ ) {
414
+ const { SubnetIds, SecurityGroupIds } = resource.Properties.VpcConfig;
415
+
416
+ if (SubnetIds) {
417
+ SubnetIds.forEach((id) => {
418
+ if (typeof id === 'string' && id.startsWith('subnet-')) {
419
+ referenced.subnetIds.add(id);
420
+ }
421
+ });
422
+ }
423
+
424
+ if (SecurityGroupIds) {
425
+ SecurityGroupIds.forEach((id) => {
426
+ if (typeof id === 'string' && id.startsWith('sg-')) {
427
+ referenced.securityGroupIds.add(id);
428
+ }
429
+ });
430
+ }
431
+ }
432
+
433
+ // Security group rules may reference other security groups
434
+ if (resource.Type === 'AWS::EC2::SecurityGroupIngress' ||
435
+ resource.Type === 'AWS::EC2::SecurityGroupEgress') {
436
+ const groupId = resource.Properties?.GroupId;
437
+ const sourceSecurityGroupId = resource.Properties?.SourceSecurityGroupId;
438
+
439
+ if (typeof groupId === 'string' && groupId.startsWith('sg-')) {
440
+ referenced.securityGroupIds.add(groupId);
441
+ }
442
+ if (typeof sourceSecurityGroupId === 'string' && sourceSecurityGroupId.startsWith('sg-')) {
443
+ referenced.securityGroupIds.add(sourceSecurityGroupId);
444
+ }
445
+ }
446
+ });
447
+
448
+ return referenced;
449
+ }
450
+
451
+ /**
452
+ * Check if a resource is referenced in the deployed template
453
+ * @private
454
+ */
455
+ _isResourceReferenced(resource, referencedIds) {
456
+ const { resourceType, physicalId } = resource;
457
+
458
+ if (resourceType === 'AWS::EC2::VPC') {
459
+ return referencedIds.vpcIds.has(physicalId);
460
+ }
461
+
462
+ if (resourceType === 'AWS::EC2::Subnet') {
463
+ return referencedIds.subnetIds.has(physicalId);
464
+ }
465
+
466
+ if (resourceType === 'AWS::EC2::SecurityGroup') {
467
+ return referencedIds.securityGroupIds.has(physicalId);
468
+ }
469
+
470
+ // For other resource types, we can't determine
471
+ return false;
472
+ }
473
+
474
+ /**
475
+ * Check for multiple resources of same type
476
+ * Returns warnings when user needs to manually select
477
+ * @private
478
+ */
479
+ _checkForMultipleResources(mappings) {
480
+ const warnings = [];
481
+ const byType = {};
482
+
483
+ // Group by resource type
484
+ mappings.forEach((mapping) => {
485
+ if (!byType[mapping.resourceType]) {
486
+ byType[mapping.resourceType] = [];
487
+ }
488
+ byType[mapping.resourceType].push(mapping);
489
+ });
490
+
491
+ // Check for multiples
492
+ Object.entries(byType).forEach(([type, resources]) => {
493
+ if (resources.length > 1) {
494
+ const shortType = type.replace('AWS::EC2::', '');
495
+ warnings.push({
496
+ type: 'MULTIPLE_RESOURCES',
497
+ resourceType: type,
498
+ count: resources.length,
499
+ message: `Multiple ${shortType}s detected (${resources.length}). Review relationships before importing.`,
500
+ resources: resources.map((r) => ({
501
+ physicalId: r.physicalId,
502
+ logicalId: r.logicalId,
503
+ matchMethod: r.matchMethod,
504
+ confidence: r.confidence,
505
+ })),
506
+ });
507
+ }
508
+ });
509
+
510
+ return warnings;
511
+ }
512
+
513
+ /**
514
+ * Get CloudFormation resource identifier for import
515
+ * @private
516
+ */
517
+ _getResourceIdentifier(mapping) {
518
+ const { resourceType, physicalId } = mapping;
519
+
520
+ // Map resource types to their identifier format
521
+ const identifierMap = {
522
+ 'AWS::EC2::VPC': { VpcId: physicalId },
523
+ 'AWS::EC2::Subnet': { SubnetId: physicalId },
524
+ 'AWS::EC2::SecurityGroup': { GroupId: physicalId },
525
+ 'AWS::EC2::InternetGateway': { InternetGatewayId: physicalId },
526
+ 'AWS::EC2::NatGateway': { NatGatewayId: physicalId },
527
+ 'AWS::EC2::RouteTable': { RouteTableId: physicalId },
528
+ 'AWS::EC2::VPCEndpoint': { VpcEndpointId: physicalId },
529
+ };
530
+
531
+ return identifierMap[resourceType] || { Id: physicalId };
532
+ }
227
533
  }
228
534
 
229
535
  module.exports = RepairViaImportUseCase;
@@ -19,6 +19,7 @@ const StackHealthReport = require('../../domain/entities/stack-health-report');
19
19
  const Resource = require('../../domain/entities/resource');
20
20
  const Issue = require('../../domain/entities/issue');
21
21
  const ResourceState = require('../../domain/value-objects/resource-state');
22
+ const { getPropertyMutability } = require('../../domain/services/property-mutability-config');
22
23
 
23
24
  class RunHealthCheckUseCase {
24
25
  /**
@@ -55,16 +56,27 @@ class RunHealthCheckUseCase {
55
56
  *
56
57
  * @param {Object} params
57
58
  * @param {StackIdentifier} params.stackIdentifier - Stack to check
59
+ * @param {Function} params.onProgress - Optional progress callback (step, message)
58
60
  * @returns {Promise<StackHealthReport>} Comprehensive health report
59
61
  */
60
- async execute({ stackIdentifier }) {
62
+ async execute({ stackIdentifier, onProgress }) {
63
+ // Helper to call progress callback if provided
64
+ const progress = (step, message) => {
65
+ if (onProgress) {
66
+ onProgress(step, message);
67
+ }
68
+ };
69
+
61
70
  // 1. Verify stack exists
71
+ progress('📋 Step 1/5:', 'Verifying stack exists...');
62
72
  await this.stackRepository.getStack(stackIdentifier);
63
73
 
64
74
  // 2. Detect stack-level drift
75
+ progress('🔍 Step 2/5:', 'Detecting stack drift...');
65
76
  const driftDetection = await this.stackRepository.detectStackDrift(stackIdentifier);
66
77
 
67
78
  // 3. Get all stack resources
79
+ progress('📊 Step 3/5:', 'Analyzing stack resources...');
68
80
  const stackResources = await this.stackRepository.listResources(stackIdentifier);
69
81
 
70
82
  // 4. Build resource entities with drift status
@@ -101,10 +113,23 @@ class RunHealthCheckUseCase {
101
113
  resourceDrift.propertyDifferences &&
102
114
  resourceDrift.propertyDifferences.length > 0
103
115
  ) {
104
- const propertyMismatches = this.mismatchAnalyzer.analyzePropertyMismatches(
105
- resourceDrift.propertyDifferences,
106
- stackResource.resourceType
107
- );
116
+ // Build property mutability map for this resource type
117
+ // AWS drift detection returns property paths, we need to provide mutability for each
118
+ const propertyMutabilityMap = {};
119
+ for (const propDiff of resourceDrift.propertyDifferences) {
120
+ const propertyPath = propDiff.PropertyPath.replace(/^\//, ''); // Remove leading slash
121
+ propertyMutabilityMap[propertyPath] = getPropertyMutability(
122
+ stackResource.resourceType,
123
+ propertyPath
124
+ );
125
+ }
126
+
127
+ const propertyMismatches = this.mismatchAnalyzer.analyze({
128
+ expected: resourceDrift.expectedProperties,
129
+ actual: resourceDrift.actualProperties,
130
+ propertyMutability: propertyMutabilityMap,
131
+ ignoreProperties: [],
132
+ });
108
133
 
109
134
  // Create issue for each property mismatch using factory method
110
135
  for (const mismatch of propertyMismatches) {
@@ -135,6 +160,7 @@ class RunHealthCheckUseCase {
135
160
  }
136
161
 
137
162
  // 5. Find orphaned resources (exist in cloud but not in stack)
163
+ progress('🔎 Step 4/5:', 'Checking for orphaned resources...');
138
164
  const orphanedResources = await this.resourceDetector.findOrphanedResources({
139
165
  stackIdentifier,
140
166
  stackResources,
@@ -142,11 +168,17 @@ class RunHealthCheckUseCase {
142
168
 
143
169
  for (const orphan of orphanedResources) {
144
170
  // Create resource entity for orphan
171
+ // IMPORTANT: Include both properties AND tags for template comparison
172
+ // Tags are stored in properties.tags for logical ID mapping
145
173
  const orphanResource = new Resource({
146
174
  logicalId: null, // No logical ID (not in template)
147
175
  physicalId: orphan.physicalId,
148
176
  resourceType: orphan.resourceType,
149
177
  state: ResourceState.ORPHANED,
178
+ properties: {
179
+ ...(orphan.properties || {}), // Include AWS properties
180
+ tags: orphan.tags || {}, // Include tags for logical ID matching
181
+ },
150
182
  });
151
183
 
152
184
  resources.push(orphanResource);
@@ -162,6 +194,7 @@ class RunHealthCheckUseCase {
162
194
  }
163
195
 
164
196
  // 6. Calculate health score using domain service
197
+ progress('🧮 Step 5/5:', 'Calculating health score...');
165
198
  const healthScore = this.healthScoreCalculator.calculate({ resources, issues });
166
199
 
167
200
  // 7. Build comprehensive health report (aggregate root)
@@ -33,7 +33,7 @@ describe('RunHealthCheckUseCase', () => {
33
33
  };
34
34
 
35
35
  mockMismatchAnalyzer = {
36
- analyzePropertyMismatches: jest.fn(),
36
+ analyze: jest.fn(),
37
37
  };
38
38
 
39
39
  mockHealthScoreCalculator = {
@@ -149,7 +149,7 @@ describe('RunHealthCheckUseCase', () => {
149
149
  mutability: PropertyMutability.MUTABLE,
150
150
  });
151
151
 
152
- mockMismatchAnalyzer.analyzePropertyMismatches.mockReturnValue([propertyMismatch]);
152
+ mockMismatchAnalyzer.analyze.mockReturnValue([propertyMismatch]);
153
153
 
154
154
  mockResourceDetector.findOrphanedResources.mockResolvedValue([]);
155
155
 
@@ -323,7 +323,7 @@ describe('RunHealthCheckUseCase', () => {
323
323
  mutability: PropertyMutability.MUTABLE,
324
324
  });
325
325
 
326
- mockMismatchAnalyzer.analyzePropertyMismatches.mockReturnValue([propertyMismatch]);
326
+ mockMismatchAnalyzer.analyze.mockReturnValue([propertyMismatch]);
327
327
 
328
328
  // Mock orphaned resources
329
329
  mockResourceDetector.findOrphanedResources.mockResolvedValue([