@friggframework/devtools 2.0.0--canary.474.86c5119.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
@@ -0,0 +1,330 @@
1
+ /**
2
+ * LogicalIdMapper - Map orphaned resources to their logical IDs in templates
3
+ *
4
+ * Purpose: Analyze orphaned resources and match them to the correct logical IDs
5
+ * from CloudFormation templates using tags, containment analysis, and template comparison.
6
+ */
7
+
8
+ const {
9
+ EC2Client,
10
+ DescribeSubnetsCommand,
11
+ DescribeSecurityGroupsCommand,
12
+ } = require('@aws-sdk/client-ec2');
13
+
14
+ class LogicalIdMapper {
15
+ constructor({ region = 'us-east-1' } = {}) {
16
+ this.ec2Client = new EC2Client({ region });
17
+ }
18
+
19
+ /**
20
+ * Map orphaned resources to their logical IDs in template
21
+ * @param {object} params - Mapping parameters
22
+ * @param {Array} params.orphanedResources - Orphaned resources to map
23
+ * @param {object} params.buildTemplate - Build template with logical IDs
24
+ * @param {object} params.deployedTemplate - Deployed template with hardcoded IDs
25
+ * @returns {Promise<Array>} Mappings with logical IDs
26
+ */
27
+ async mapOrphanedResourcesToLogicalIds({
28
+ orphanedResources,
29
+ buildTemplate,
30
+ deployedTemplate,
31
+ }) {
32
+ const mappings = [];
33
+
34
+ for (const orphan of orphanedResources) {
35
+ // Strategy 1: Check CloudFormation tags for logical ID
36
+ const logicalIdFromTag = this._getLogicalIdFromTags(orphan.tags);
37
+
38
+ if (logicalIdFromTag) {
39
+ mappings.push({
40
+ logicalId: logicalIdFromTag,
41
+ physicalId: orphan.physicalId,
42
+ resourceType: orphan.resourceType,
43
+ matchMethod: 'tag',
44
+ confidence: 'high',
45
+ });
46
+ continue;
47
+ }
48
+
49
+ // Strategy 2: Match by template comparison
50
+ if (orphan.resourceType === 'AWS::EC2::VPC') {
51
+ const logicalId = await this._matchVpcByContainedResources(
52
+ orphan,
53
+ buildTemplate,
54
+ deployedTemplate
55
+ );
56
+ if (logicalId) {
57
+ mappings.push({
58
+ logicalId,
59
+ physicalId: orphan.physicalId,
60
+ resourceType: orphan.resourceType,
61
+ matchMethod: 'contained-resources',
62
+ confidence: 'high',
63
+ });
64
+ continue;
65
+ }
66
+ }
67
+
68
+ if (orphan.resourceType === 'AWS::EC2::Subnet') {
69
+ const logicalId = await this._matchSubnetByVpcAndUsage(
70
+ orphan,
71
+ buildTemplate,
72
+ deployedTemplate
73
+ );
74
+ if (logicalId) {
75
+ mappings.push({
76
+ logicalId,
77
+ physicalId: orphan.physicalId,
78
+ resourceType: orphan.resourceType,
79
+ matchMethod: 'vpc-usage',
80
+ confidence: 'high',
81
+ });
82
+ continue;
83
+ }
84
+ }
85
+
86
+ if (orphan.resourceType === 'AWS::EC2::SecurityGroup') {
87
+ const logicalId = await this._matchSecurityGroupByUsage(
88
+ orphan,
89
+ buildTemplate,
90
+ deployedTemplate
91
+ );
92
+ if (logicalId) {
93
+ mappings.push({
94
+ logicalId,
95
+ physicalId: orphan.physicalId,
96
+ resourceType: orphan.resourceType,
97
+ matchMethod: 'usage',
98
+ confidence: 'medium',
99
+ });
100
+ continue;
101
+ }
102
+ }
103
+
104
+ // No match found - mark as unmapped
105
+ mappings.push({
106
+ logicalId: null,
107
+ physicalId: orphan.physicalId,
108
+ resourceType: orphan.resourceType,
109
+ matchMethod: 'none',
110
+ confidence: 'none',
111
+ });
112
+ }
113
+
114
+ return mappings;
115
+ }
116
+
117
+ /**
118
+ * Extract logical ID from CloudFormation tags
119
+ * @private
120
+ */
121
+ _getLogicalIdFromTags(tags) {
122
+ if (!tags || !Array.isArray(tags)) return null;
123
+
124
+ const logicalIdTag = tags.find(
125
+ (t) => t.Key === 'aws:cloudformation:logical-id'
126
+ );
127
+ return logicalIdTag ? logicalIdTag.Value : null;
128
+ }
129
+
130
+ /**
131
+ * Match VPC by checking if it contains expected subnets from template
132
+ * @private
133
+ */
134
+ async _matchVpcByContainedResources(
135
+ vpc,
136
+ buildTemplate,
137
+ deployedTemplate
138
+ ) {
139
+ // Get expected subnet IDs from deployed template
140
+ const expectedSubnetIds = this._extractSubnetIdsFromTemplate(
141
+ deployedTemplate
142
+ );
143
+
144
+ if (expectedSubnetIds.length === 0) {
145
+ return null;
146
+ }
147
+
148
+ // Get actual subnets in this VPC
149
+ const actualSubnets = await this._getSubnetsInVpc(vpc.physicalId);
150
+
151
+ // Check if this VPC contains ALL expected subnets
152
+ const containsExpectedSubnets = expectedSubnetIds.every((expectedId) =>
153
+ actualSubnets.some((subnet) => subnet.SubnetId === expectedId)
154
+ );
155
+
156
+ if (containsExpectedSubnets) {
157
+ // Find VPC logical ID in build template
158
+ return this._findVpcLogicalIdInTemplate(buildTemplate);
159
+ }
160
+
161
+ return null;
162
+ }
163
+
164
+ /**
165
+ * Match subnet by VPC ownership and usage in Lambda functions
166
+ * @private
167
+ */
168
+ async _matchSubnetByVpcAndUsage(subnet, buildTemplate, deployedTemplate) {
169
+ // Extract subnet IDs from deployed template Lambda VPC configs
170
+ const templateSubnetIds = this._extractSubnetIdsFromTemplate(
171
+ deployedTemplate
172
+ );
173
+
174
+ // Check if this subnet is referenced in deployed template
175
+ if (!templateSubnetIds.includes(subnet.physicalId)) {
176
+ return null;
177
+ }
178
+
179
+ // Find the position of this subnet in the template's subnet list
180
+ const subnetIndex = templateSubnetIds.indexOf(subnet.physicalId);
181
+
182
+ // Extract subnet Refs from build template
183
+ const subnetRefs = this._extractSubnetRefsFromTemplate(buildTemplate);
184
+
185
+ // Return the corresponding logical ID based on position
186
+ return subnetRefs[subnetIndex] || null;
187
+ }
188
+
189
+ /**
190
+ * Match security group by usage in Lambda functions
191
+ * @private
192
+ */
193
+ async _matchSecurityGroupByUsage(sg, buildTemplate, deployedTemplate) {
194
+ // Extract security group IDs from deployed template
195
+ const templateSgIds = this._extractSecurityGroupIdsFromTemplate(
196
+ deployedTemplate
197
+ );
198
+
199
+ // Check if this SG is referenced in deployed template
200
+ if (!templateSgIds.includes(sg.physicalId)) {
201
+ return null;
202
+ }
203
+
204
+ // Find logical ID in build template
205
+ const sgRefs = this._extractSecurityGroupRefsFromTemplate(buildTemplate);
206
+ return sgRefs[0] || null; // Usually just one Lambda SG
207
+ }
208
+
209
+ /**
210
+ * Get subnets in a VPC from AWS
211
+ * @private
212
+ */
213
+ async _getSubnetsInVpc(vpcId) {
214
+ const response = await this.ec2Client.send(
215
+ new DescribeSubnetsCommand({
216
+ Filters: [{ Name: 'vpc-id', Values: [vpcId] }],
217
+ })
218
+ );
219
+ return response.Subnets || [];
220
+ }
221
+
222
+ /**
223
+ * Extract subnet IDs from deployed template (hardcoded values)
224
+ * @private
225
+ */
226
+ _extractSubnetIdsFromTemplate(template) {
227
+ const subnetIds = new Set();
228
+
229
+ // Traverse Lambda VpcConfig sections
230
+ Object.values(template.resources || {}).forEach((resource) => {
231
+ if (
232
+ resource.Type === 'AWS::Lambda::Function' &&
233
+ resource.Properties?.VpcConfig?.SubnetIds
234
+ ) {
235
+ resource.Properties.VpcConfig.SubnetIds.forEach((id) => {
236
+ if (typeof id === 'string' && id.startsWith('subnet-')) {
237
+ subnetIds.add(id);
238
+ }
239
+ });
240
+ }
241
+ });
242
+
243
+ return Array.from(subnetIds);
244
+ }
245
+
246
+ /**
247
+ * Extract security group IDs from deployed template (hardcoded values)
248
+ * @private
249
+ */
250
+ _extractSecurityGroupIdsFromTemplate(template) {
251
+ const sgIds = new Set();
252
+
253
+ Object.values(template.resources || {}).forEach((resource) => {
254
+ if (
255
+ resource.Type === 'AWS::Lambda::Function' &&
256
+ resource.Properties?.VpcConfig?.SecurityGroupIds
257
+ ) {
258
+ resource.Properties.VpcConfig.SecurityGroupIds.forEach((id) => {
259
+ if (typeof id === 'string' && id.startsWith('sg-')) {
260
+ sgIds.add(id);
261
+ }
262
+ });
263
+ }
264
+ });
265
+
266
+ return Array.from(sgIds);
267
+ }
268
+
269
+ /**
270
+ * Extract subnet Refs from build template
271
+ * @private
272
+ */
273
+ _extractSubnetRefsFromTemplate(template) {
274
+ const subnetRefs = [];
275
+
276
+ // Find Lambda functions and extract SubnetIds Refs
277
+ Object.values(template.resources || {}).forEach((resource) => {
278
+ if (
279
+ resource.Type === 'AWS::Lambda::Function' &&
280
+ resource.Properties?.VpcConfig?.SubnetIds
281
+ ) {
282
+ resource.Properties.VpcConfig.SubnetIds.forEach((ref) => {
283
+ if (ref.Ref && ref.Ref.includes('Subnet')) {
284
+ subnetRefs.push(ref.Ref);
285
+ }
286
+ });
287
+ }
288
+ });
289
+
290
+ return subnetRefs;
291
+ }
292
+
293
+ /**
294
+ * Extract security group Refs from build template
295
+ * @private
296
+ */
297
+ _extractSecurityGroupRefsFromTemplate(template) {
298
+ const sgRefs = [];
299
+
300
+ Object.values(template.resources || {}).forEach((resource) => {
301
+ if (
302
+ resource.Type === 'AWS::Lambda::Function' &&
303
+ resource.Properties?.VpcConfig?.SecurityGroupIds
304
+ ) {
305
+ resource.Properties.VpcConfig.SecurityGroupIds.forEach((ref) => {
306
+ if (ref.Ref && ref.Ref.includes('SecurityGroup')) {
307
+ sgRefs.push(ref.Ref);
308
+ }
309
+ });
310
+ }
311
+ });
312
+
313
+ return sgRefs;
314
+ }
315
+
316
+ /**
317
+ * Find VPC logical ID in build template
318
+ * @private
319
+ */
320
+ _findVpcLogicalIdInTemplate(template) {
321
+ const vpcResources = Object.entries(template.resources || {}).filter(
322
+ ([_, resource]) => resource.Type === 'AWS::EC2::VPC'
323
+ );
324
+
325
+ // Return first VPC logical ID (usually only one)
326
+ return vpcResources.length > 0 ? vpcResources[0][0] : null;
327
+ }
328
+ }
329
+
330
+ module.exports = { LogicalIdMapper };
@@ -0,0 +1,245 @@
1
+ /**
2
+ * TemplateParser - Parse CloudFormation templates for resource extraction
3
+ *
4
+ * Purpose: Parse both build templates (.serverless/) and deployed templates (from AWS)
5
+ * to extract resource definitions, logical IDs, and Refs for import mapping.
6
+ */
7
+
8
+ class TemplateParser {
9
+ /**
10
+ * Parse CloudFormation template and extract resource definitions
11
+ * @param {string|object} template - Template path or parsed template object
12
+ * @returns {object} Parsed template with resources
13
+ */
14
+ parseTemplate(template) {
15
+ let parsedTemplate;
16
+
17
+ if (typeof template === 'string') {
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ if (!fs.existsSync(template)) {
22
+ throw new Error(`Template not found at path: ${template}`);
23
+ }
24
+
25
+ parsedTemplate = JSON.parse(fs.readFileSync(template, 'utf8'));
26
+ } else {
27
+ parsedTemplate = template;
28
+ }
29
+
30
+ return {
31
+ resources: parsedTemplate.Resources || {},
32
+ version: parsedTemplate.AWSTemplateFormatVersion,
33
+ description: parsedTemplate.Description,
34
+ outputs: parsedTemplate.Outputs || {},
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Extract VPC-related resource logical IDs from template
40
+ * @param {object} template - Parsed template object
41
+ * @returns {Array} VPC resources with logical IDs
42
+ */
43
+ getVpcResources(template) {
44
+ const vpcResourceTypes = [
45
+ 'AWS::EC2::VPC',
46
+ 'AWS::EC2::Subnet',
47
+ 'AWS::EC2::SecurityGroup',
48
+ 'AWS::EC2::InternetGateway',
49
+ 'AWS::EC2::NatGateway',
50
+ 'AWS::EC2::RouteTable',
51
+ 'AWS::EC2::VPCEndpoint',
52
+ ];
53
+
54
+ return Object.entries(template.resources)
55
+ .filter(([_, resource]) => vpcResourceTypes.includes(resource.Type))
56
+ .map(([logicalId, resource]) => ({
57
+ logicalId,
58
+ resourceType: resource.Type,
59
+ properties: resource.Properties || {},
60
+ }));
61
+ }
62
+
63
+ /**
64
+ * Extract hardcoded resource IDs from deployed template
65
+ * Finds physical IDs that are hardcoded instead of using Refs
66
+ * @param {object} template - Deployed CloudFormation template
67
+ * @returns {object} Extracted hardcoded IDs by type
68
+ */
69
+ extractHardcodedIds(template) {
70
+ const hardcodedIds = {
71
+ vpcIds: new Set(),
72
+ subnetIds: new Set(),
73
+ securityGroupIds: new Set(),
74
+ };
75
+
76
+ // Traverse template to find hardcoded IDs
77
+ Object.values(template.resources).forEach((resource) => {
78
+ this._extractIdsFromResource(resource, hardcodedIds);
79
+ });
80
+
81
+ return {
82
+ vpcIds: Array.from(hardcodedIds.vpcIds),
83
+ subnetIds: Array.from(hardcodedIds.subnetIds),
84
+ securityGroupIds: Array.from(hardcodedIds.securityGroupIds),
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Extract Refs from build template
90
+ * Finds logical IDs that are referenced via {Ref: "LogicalId"}
91
+ * @param {object} template - Build template with Refs
92
+ * @returns {object} Logical IDs mapped to expected resource types
93
+ */
94
+ extractRefs(template) {
95
+ const refs = {
96
+ vpcRefs: new Set(),
97
+ subnetRefs: new Set(),
98
+ securityGroupRefs: new Set(),
99
+ };
100
+
101
+ // Traverse template to find Ref expressions
102
+ Object.values(template.resources).forEach((resource) => {
103
+ this._extractRefsFromResource(resource, refs);
104
+ });
105
+
106
+ return {
107
+ vpcRefs: Array.from(refs.vpcRefs),
108
+ subnetRefs: Array.from(refs.subnetRefs),
109
+ securityGroupRefs: Array.from(refs.securityGroupRefs),
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Recursively extract hardcoded IDs from resource properties
115
+ * @private
116
+ */
117
+ _extractIdsFromResource(obj, hardcodedIds) {
118
+ if (typeof obj !== 'object' || obj === null) return;
119
+
120
+ Object.entries(obj).forEach(([key, value]) => {
121
+ // Check for VPC IDs
122
+ if (key === 'VpcId' && typeof value === 'string' && value.startsWith('vpc-')) {
123
+ hardcodedIds.vpcIds.add(value);
124
+ }
125
+
126
+ // Check for subnet IDs
127
+ if (
128
+ (key === 'SubnetIds' || key === 'SubnetId') &&
129
+ Array.isArray(value)
130
+ ) {
131
+ value.forEach((id) => {
132
+ if (typeof id === 'string' && id.startsWith('subnet-')) {
133
+ hardcodedIds.subnetIds.add(id);
134
+ }
135
+ });
136
+ } else if (
137
+ key === 'SubnetId' &&
138
+ typeof value === 'string' &&
139
+ value.startsWith('subnet-')
140
+ ) {
141
+ hardcodedIds.subnetIds.add(value);
142
+ }
143
+
144
+ // Check for security group IDs
145
+ if (key === 'SecurityGroupIds' && Array.isArray(value)) {
146
+ value.forEach((id) => {
147
+ if (typeof id === 'string' && id.startsWith('sg-')) {
148
+ hardcodedIds.securityGroupIds.add(id);
149
+ }
150
+ });
151
+ } else if (
152
+ key === 'GroupId' &&
153
+ typeof value === 'string' &&
154
+ value.startsWith('sg-')
155
+ ) {
156
+ hardcodedIds.securityGroupIds.add(value);
157
+ }
158
+
159
+ // Recurse into nested objects
160
+ if (typeof value === 'object') {
161
+ this._extractIdsFromResource(value, hardcodedIds);
162
+ }
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Recursively extract Refs from resource properties
168
+ * @private
169
+ */
170
+ _extractRefsFromResource(obj, refs) {
171
+ if (typeof obj !== 'object' || obj === null) return;
172
+
173
+ Object.entries(obj).forEach(([key, value]) => {
174
+ // Check for Ref expressions
175
+ if (key === 'Ref' && typeof value === 'string') {
176
+ // Determine ref type based on logical ID naming
177
+ if (value.includes('VPC') && !value.includes('Endpoint')) {
178
+ refs.vpcRefs.add(value);
179
+ } else if (value.includes('Subnet')) {
180
+ refs.subnetRefs.add(value);
181
+ } else if (value.includes('SecurityGroup')) {
182
+ refs.securityGroupRefs.add(value);
183
+ }
184
+ }
185
+
186
+ // Recurse into nested objects and arrays
187
+ if (typeof value === 'object') {
188
+ this._extractRefsFromResource(value, refs);
189
+ }
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Find logical ID for a physical ID by comparing templates
195
+ * @param {string} physicalId - Physical resource ID from AWS
196
+ * @param {object} deployedTemplate - Template with hardcoded IDs
197
+ * @param {object} buildTemplate - Template with Refs
198
+ * @returns {string|null} Matching logical ID or null
199
+ */
200
+ findLogicalIdForPhysicalId(physicalId, deployedTemplate, buildTemplate) {
201
+ // Extract hardcoded IDs and their context
202
+ const hardcodedIds = this.extractHardcodedIds(deployedTemplate);
203
+ const refs = this.extractRefs(buildTemplate);
204
+
205
+ // Determine resource type from physical ID
206
+ let logicalIdCandidates = [];
207
+ if (physicalId.startsWith('vpc-')) {
208
+ logicalIdCandidates = refs.vpcRefs;
209
+ } else if (physicalId.startsWith('subnet-')) {
210
+ logicalIdCandidates = refs.subnetRefs;
211
+ } else if (physicalId.startsWith('sg-')) {
212
+ logicalIdCandidates = refs.securityGroupRefs;
213
+ }
214
+
215
+ // For now, return first candidate (will enhance with position matching)
216
+ return logicalIdCandidates[0] || null;
217
+ }
218
+
219
+ /**
220
+ * Get build template path from project directory
221
+ * @param {string} projectPath - Project root path
222
+ * @returns {string} Path to build template
223
+ */
224
+ static getBuildTemplatePath(projectPath = process.cwd()) {
225
+ const path = require('path');
226
+ return path.join(
227
+ projectPath,
228
+ '.serverless',
229
+ 'cloudformation-template-update-stack.json'
230
+ );
231
+ }
232
+
233
+ /**
234
+ * Check if build template exists
235
+ * @param {string} projectPath - Project root path
236
+ * @returns {boolean} True if template exists
237
+ */
238
+ static buildTemplateExists(projectPath = process.cwd()) {
239
+ const fs = require('fs');
240
+ const templatePath = this.getBuildTemplatePath(projectPath);
241
+ return fs.existsSync(templatePath);
242
+ }
243
+ }
244
+
245
+ module.exports = { TemplateParser };