@@ -49,13 +49,16 @@ public static async Task SyncAsync(AzureReport report, string appsJsonPath, Canc
4949
5050 var services = report . WebServices ? . Services ?? new List < WebService > ( ) ;
5151 var discoveredIds = new HashSet < string > ( StringComparer . OrdinalIgnoreCase ) ;
52+ var discoveredHosts = new HashSet < string > ( StringComparer . OrdinalIgnoreCase ) ;
5253
5354 // ── Process every service discovered in Azure ─────────────────────────
5455 foreach ( var svc in services )
5556 {
5657 if ( string . IsNullOrWhiteSpace ( svc . Url ) ) continue ;
5758 var slug = Slugify ( svc . Name ) ;
5859 discoveredIds . Add ( slug ) ;
60+ if ( TryGetHost ( svc . Url , out var discoveredHost ) )
61+ discoveredHosts . Add ( discoveredHost ) ;
5962
6063 var techs = InferTechnologies ( svc . ResourceType ) ;
6164
@@ -103,19 +106,24 @@ public static async Task SyncAsync(AzureReport report, string appsJsonPath, Canc
103106 }
104107 }
105108
106- // ── Apps in apps.json that no longer exist in Azure ───────────────────
107- // We deliberately do NOT auto-demote apps that aren't found — the user
108- // controls status. Mark removed apps with a warning annotation in the ID.
109- // (Deleted Azure resources stay visible until the user manually removes them.)
110- var skippedUrls = new HashSet < string > ( StringComparer . OrdinalIgnoreCase ) ;
111- foreach ( var app in apps )
109+ // ── Prune Azure-hosted apps that were removed from Azure ───────────────
110+ // Only prune when discovery returned at least one service to avoid wiping
111+ // apps.json due to transient auth/config failures that produce empty data.
112+ if ( services . Count > 0 )
112113 {
113- // If this app's URL matches a discovered URL, skip marking
114- if ( ! string . IsNullOrWhiteSpace ( app . Url ) && discoveredIds . Contains ( app . Id ) ) continue ;
115- // If this app's slug matches a discovered slug, skip
116- if ( discoveredIds . Contains ( app . Id ) ) continue ;
117- // Otherwise it's a stale entry — we leave it in place but flag it
118- // (no auto-demotion as per intentional design)
114+ apps = apps
115+ . Where ( app =>
116+ {
117+ if ( ! IsAzureHostedUrl ( app . Url ) )
118+ return true ;
119+
120+ var idMatched = ! string . IsNullOrWhiteSpace ( app . Id ) && discoveredIds . Contains ( app . Id ) ;
121+ if ( idMatched )
122+ return true ;
123+
124+ return TryGetHost ( app . Url , out var appHost ) && discoveredHosts . Contains ( appHost ) ;
125+ } )
126+ . ToList ( ) ;
119127 }
120128
121129 // ── Sort and write back ───────────────────────────────────────────────
@@ -195,6 +203,27 @@ private static string Slugify(string name)
195203 return s . Trim ( '-' ) ;
196204 }
197205
206+ private static bool TryGetHost ( string ? url , out string host )
207+ {
208+ host = string . Empty ;
209+ if ( string . IsNullOrWhiteSpace ( url ) )
210+ return false ;
211+ if ( ! Uri . TryCreate ( url , UriKind . Absolute , out var uri ) || string . IsNullOrWhiteSpace ( uri . Host ) )
212+ return false ;
213+ host = uri . Host ;
214+ return true ;
215+ }
216+
217+ private static bool IsAzureHostedUrl ( string ? url )
218+ {
219+ if ( ! TryGetHost ( url , out var host ) )
220+ return false ;
221+
222+ return host . EndsWith ( ".azurewebsites.net" , StringComparison . OrdinalIgnoreCase )
223+ || host . EndsWith ( ".azurestaticapps.net" , StringComparison . OrdinalIgnoreCase )
224+ || host . EndsWith ( ".azurecontainerapps.io" , StringComparison . OrdinalIgnoreCase ) ;
225+ }
226+
198227 // ── Local model types (only used for apps.json serialisation) ─────────────
199228
200229 private class AppsWrapper
0 commit comments