@@ -261,8 +261,17 @@ async function analyzeChangeSet(
261261 return result ;
262262 }
263263
264- // EarlyValidation failures still have Changes populated — fall through to process them
264+ // Recoverable failures (EarlyValidation, Template format errors) still have Changes populated.
265+ // However, if Changes is empty despite the recoverable classification, treat as incomplete.
265266 if ( isRecoverableFailure ( changeSet . StatusReason ) ) {
267+ if ( ! changeSet . Changes || changeSet . Changes . length === 0 ) {
268+ print . warn ( `Recoverable failure but no Changes populated: ${ changeSet . StatusReason } ` ) ;
269+ return {
270+ changes : [ ] ,
271+ skipped : true ,
272+ skipReason : `Changeset failed with no usable changes: ${ changeSet . StatusReason } ` ,
273+ } ;
274+ }
266275 print . warn ( `Nested changeset FAILED (recoverable): ${ changeSet . StatusReason } ` ) ;
267276 } else if ( isRoot ) {
268277 // Root changeset FAILED because a nested changeset failed. The root's StatusReason
@@ -299,6 +308,16 @@ async function analyzeChangeSet(
299308 const changeInfo : ResourceChangeWithNested = { ...rc } ;
300309
301310 // Check if this is a nested stack with its own changeset
311+ if ( rc . ResourceType === 'AWS::CloudFormation::Stack' && ! rc . ChangeSetId ) {
312+ // Stack change without a nested changeset — can't inspect its contents.
313+ // This happens when IncludeNestedStacks creates the root changeset but the
314+ // nested changeset failed before being created. Track as incomplete.
315+ const stackLabel = rc . LogicalResourceId || rc . PhysicalResourceId || 'unknown' ;
316+ print . warn ( `Nested stack ${ stackLabel } has no changeset — tracking as incomplete` ) ;
317+ skippedStacks . push ( stackLabel ) ;
318+ result . changes . push ( changeInfo ) ;
319+ continue ;
320+ }
302321 if ( rc . ResourceType === 'AWS::CloudFormation::Stack' && rc . ChangeSetId && rc . PhysicalResourceId ) {
303322 try {
304323 // Extract stack name and changeset name from ARNs using parseArn utility
@@ -317,25 +336,34 @@ async function analyzeChangeSet(
317336 ChangeSetName : changeSetName ,
318337 } ) ,
319338 ) ;
320- const terminalStatuses = [ 'CREATE_COMPLETE' , 'FAILED' , 'DELETE_COMPLETE' ] ;
321- const maxPollAttempts = 30 ;
322- for ( let attempt = 0 ; ! terminalStatuses . includes ( nestedChangeSet . Status ! ) && attempt < maxPollAttempts ; attempt ++ ) {
339+ const terminalStatuses = [ 'CREATE_COMPLETE' , 'FAILED' , 'DELETE_COMPLETE' , 'DELETE_FAILED' ] ;
340+ const NESTED_POLL_MAX_ATTEMPTS = 30 ;
341+ const NESTED_POLL_INTERVAL_MS = 2000 ;
342+ for ( let attempt = 0 ; ! terminalStatuses . includes ( nestedChangeSet . Status ?? '' ) && attempt < NESTED_POLL_MAX_ATTEMPTS ; attempt ++ ) {
323343 print . debug ( `Nested changeset ${ stackName } is ${ nestedChangeSet . Status } , waiting...` ) ;
324- await new Promise ( ( resolve ) => setTimeout ( resolve , 2000 ) ) ;
344+ await new Promise ( ( resolve ) => setTimeout ( resolve , NESTED_POLL_INTERVAL_MS ) ) ;
325345 nestedChangeSet = await cfn . send (
326346 new DescribeChangeSetCommand ( {
327347 StackName : stackName ,
328348 ChangeSetName : changeSetName ,
329349 } ) ,
330350 ) ;
331351 }
332- if ( ! terminalStatuses . includes ( nestedChangeSet . Status ! ) ) {
352+ if ( ! terminalStatuses . includes ( nestedChangeSet . Status ?? '' ) ) {
333353 print . warn ( `Nested changeset ${ stackName } did not reach terminal status (${ nestedChangeSet . Status } )` ) ;
334354 skippedStacks . push ( stackName ) ;
335355 result . changes . push ( changeInfo ) ;
336356 continue ;
337357 }
338358
359+ // Deleted or failed-to-delete changesets can't be analyzed — track as incomplete
360+ if ( nestedChangeSet . Status === 'DELETE_COMPLETE' || nestedChangeSet . Status === 'DELETE_FAILED' ) {
361+ print . warn ( `Nested changeset ${ stackName } was deleted (${ nestedChangeSet . Status } ) — tracking as incomplete` ) ;
362+ skippedStacks . push ( stackName ) ;
363+ result . changes . push ( changeInfo ) ;
364+ continue ;
365+ }
366+
339367 // Print nested changeset details
340368 if ( nestedChangeSet . Changes && nestedChangeSet . Changes . length > 0 ) {
341369 print . debug ( `Nested Stack: ${ stackName } ` ) ;
@@ -371,11 +399,16 @@ async function analyzeChangeSet(
371399 print . debug ( `Processed ${ nestedResult . changes . length } nested changes` ) ;
372400 }
373401 } catch ( error : any ) {
374- // Log error and track as incomplete
402+ // Log error and track as incomplete. Use LogicalResourceId as fallback
403+ // since extractStackNameFromArn could throw on malformed ARNs.
375404 print . warn ( `⚠ Could not fetch nested changeset for ${ rc . LogicalResourceId } : ${ error . message } ` ) ;
376405 print . debug ( `Stack ARN: ${ rc . PhysicalResourceId } ` ) ;
377406 print . debug ( `ChangeSet ID: ${ rc . ChangeSetId } ` ) ;
378- skippedStacks . push ( extractStackNameFromArn ( rc . PhysicalResourceId ) ) ;
407+ try {
408+ skippedStacks . push ( extractStackNameFromArn ( rc . PhysicalResourceId ) ) ;
409+ } catch {
410+ skippedStacks . push ( rc . LogicalResourceId || 'unknown' ) ;
411+ }
379412 }
380413 }
381414
0 commit comments