File: Scaffolding\PackageJsonMerger.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Scaffolding;
 
/// <summary>
/// Merges scaffold-generated package.json with an existing one on disk.
/// Handles script name conflicts by adding Aspire-specific scripts under the <c>aspire:</c>
/// namespace prefix, and creates convenience aliases for non-conflicting names.
/// </summary>
internal static class PackageJsonMerger
{
    private const string ScriptsKey = "scripts";
    private const string DependenciesKey = "dependencies";
    private const string DevDependenciesKey = "devDependencies";
    private const string EnginesKey = "engines";
    private const string EnginesNodeKey = "node";
    private const string AspirePrefix = "aspire:";
 
    // package.json standard uses 2-space indentation. These options produce output
    // consistent with npm init / npm install formatting conventions.
    private static readonly JsonSerializerOptions s_jsonOptions = new()
    {
        WriteIndented = true,
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
        IndentSize = 2
    };
 
    private static readonly JsonDocumentOptions s_jsonDocumentOptions = new()
    {
        CommentHandling = JsonCommentHandling.Skip,
        AllowTrailingCommas = true
    };
 
    /// <summary>
    /// Merges scaffold-generated package.json content with existing content.
    /// Preserves all existing properties and scripts. Scaffold scripts that conflict
    /// with existing names are added under the <c>aspire:</c> prefix. Existing scripts,
    /// including <c>aspire:</c>-prefixed scripts, are preserved. Non-conflicting
    /// <c>aspire:X</c> scripts get a convenience alias <c>X</c> pointing to
    /// <c>{toolchain} run aspire:X</c>.
    /// </summary>
    /// <returns>The merged package.json content as a JSON string.</returns>
    internal static string Merge(string existingContent, string scaffoldContent, ILogger logger, string toolchainCommand = "npm")
    {
        if (string.IsNullOrWhiteSpace(existingContent))
        {
            return scaffoldContent;
        }
 
        // Phase 1: Parse inputs. If either fails, return scaffold as-is.
        JsonObject? existingJson;
        JsonObject? scaffoldJson;
        try
        {
            existingJson = JsonNode.Parse(existingContent, documentOptions: s_jsonDocumentOptions) as JsonObject;
            scaffoldJson = JsonNode.Parse(scaffoldContent, documentOptions: s_jsonDocumentOptions) as JsonObject;
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex, "Failed to parse package.json content, using scaffold output as-is.");
            return scaffoldContent;
        }
 
        if (existingJson is null || scaffoldJson is null)
        {
            return scaffoldContent;
        }
 
        // Phase 2: Merge. If merge fails, return scaffold as-is.
        try
        {
            MergeObjects(existingJson, scaffoldJson, logger, toolchainCommand);
            return existingJson.ToJsonString(s_jsonOptions);
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex, "Failed to merge package.json content, using scaffold output as-is.");
            return scaffoldContent;
        }
    }
 
    /// <summary>
    /// Merges all top-level properties from scaffold into existing.
    /// Scripts get special conflict-aware handling, dependency sections use semver-aware merging,
    /// and everything else uses deep merge.
    /// </summary>
    private static void MergeObjects(JsonObject existing, JsonObject scaffold, ILogger logger, string toolchainCommand)
    {
        // Handle scripts separately with conflict-aware logic
        var scaffoldScripts = scaffold[ScriptsKey]?.AsObject();
        if (scaffoldScripts is not null)
        {
            var existingScripts = EnsureObject(existing, ScriptsKey, logger);
            MergeScripts(existingScripts, scaffoldScripts, toolchainCommand);
        }
 
        // Handle dependency sections with semver-aware merging
        MergeDependencySection(existing, scaffold, DependenciesKey, logger);
        MergeDependencySection(existing, scaffold, DevDependenciesKey, logger);
 
        // Handle engines with overwrite semantics for "node" — since the user is running
        // "aspire init", we enforce our Node version constraint (required for ESLint 10
        // and TypeScript tooling compatibility). Other engines sub-keys are preserved.
        MergeEngines(existing, scaffold, logger);
 
        // Deep merge everything else (scalars, nested objects).
        // Array properties (e.g., "keywords") are preserved from existing — the scaffold
        // echoes the original arrays unchanged, so the existing value is always correct.
        foreach (var (key, sourceValue) in scaffold)
        {
            if (key is ScriptsKey or DependenciesKey or DevDependenciesKey or EnginesKey || sourceValue is null)
            {
                continue;
            }
 
            var targetValue = existing[key];
 
            if (targetValue is null)
            {
                // Property only in scaffold — add it (including arrays from scaffold-only)
                existing[key] = sourceValue.DeepClone();
            }
            else if (targetValue is JsonObject targetObj && sourceValue is JsonObject sourceObj)
            {
                DeepMerge(targetObj, sourceObj);
            }
            // Arrays and scalar values in existing are preserved
        }
    }
 
    /// <summary>
    /// Merges scaffold scripts into existing scripts with conflict-aware handling.
    /// </summary>
    /// <remarks>
    /// For each scaffold script:
    /// <list type="bullet">
    /// <item>Already <c>aspire:</c> prefixed → added only when missing</item>
    /// <item>Not prefixed, conflicts with existing → added as <c>aspire:{name}</c></item>
    /// <item>Not prefixed, no conflict → added with the original name</item>
    /// </list>
    /// After processing, for each <c>aspire:X</c> script where no non-prefixed <c>X</c> exists,
    /// a convenience alias is added: <c>"X": "{toolchain} run aspire:X"</c>.
    /// </remarks>
    internal static void MergeScripts(JsonObject existingScripts, JsonObject scaffoldScripts, string toolchainCommand = "npm")
    {
        foreach (var (name, value) in scaffoldScripts)
        {
            if (value is not JsonValue scriptValue || !scriptValue.TryGetValue<string>(out var command))
            {
                continue;
            }
 
            if (name.StartsWith(AspirePrefix, StringComparison.Ordinal))
            {
                existingScripts[name] ??= command;
            }
            else if (existingScripts[name] is not null)
            {
                // Conflict — add under aspire: prefix
                existingScripts[$"{AspirePrefix}{name}"] = command;
            }
            else
            {
                // No conflict — add with original name
                existingScripts[name] = command;
            }
        }
 
        // Add convenience aliases for aspire: scripts that have no non-prefixed equivalent
        AddConvenienceAliases(existingScripts, toolchainCommand);
    }
 
    /// <summary>
    /// For each <c>aspire:X</c> script, if no script named <c>X</c> exists,
    /// adds <c>"X": "{toolchain} run aspire:X"</c> as a convenience alias.
    /// </summary>
    private static void AddConvenienceAliases(JsonObject scripts, string toolchainCommand)
    {
        var normalizedToolchainCommand = string.IsNullOrWhiteSpace(toolchainCommand) ? "npm" : toolchainCommand;
 
        // Collect aspire: keys first to avoid modifying during enumeration
        var aspireScripts = new List<(string unprefixed, string prefixed)>();
        foreach (var (name, _) in scripts)
        {
            if (name.StartsWith(AspirePrefix, StringComparison.Ordinal))
            {
                var unprefixed = name[AspirePrefix.Length..];
                if (unprefixed.Length > 0)
                {
                    aspireScripts.Add((unprefixed, name));
                }
            }
        }
 
        foreach (var (unprefixed, prefixed) in aspireScripts)
        {
            if (scripts[unprefixed] is null)
            {
                scripts[unprefixed] = $"{normalizedToolchainCommand} run {prefixed}";
            }
        }
    }
 
    /// <summary>
    /// Merges a dependency section (e.g., "dependencies", "devDependencies") from scaffold into existing
    /// using semver-aware comparison. New packages are added; existing packages are upgraded only when
    /// the scaffold specifies a newer version. Unparseable version ranges (union ranges, workspace
    /// references, etc.) are preserved as-is.
    /// </summary>
    private static void MergeDependencySection(JsonObject existing, JsonObject scaffold, string sectionName, ILogger logger)
    {
        var scaffoldDeps = scaffold[sectionName]?.AsObject();
        if (scaffoldDeps is null)
        {
            return;
        }
 
        var existingDeps = EnsureObject(existing, sectionName, logger);
 
        foreach (var (packageName, versionNode) in scaffoldDeps)
        {
            if (versionNode is not JsonValue desiredValue || !desiredValue.TryGetValue<string>(out var desiredVersion))
            {
                continue;
            }
 
            var existingVersionNode = existingDeps[packageName];
            if (existingVersionNode is null)
            {
                // Preserve brownfield package shape: if a scaffolded devDependency already exists
                // as a runtime dependency, upgrade it in place instead of duplicating it.
                if (sectionName == DevDependenciesKey &&
                    TryMergeExistingDependency(existing, DependenciesKey, packageName, desiredVersion))
                {
                    continue;
                }
 
                existingDeps[packageName] = desiredVersion;
            }
            else
            {
                if (existingVersionNode is JsonValue existingValue
                    && existingValue.TryGetValue<string>(out var existingVersion)
                    && NpmVersionHelper.ShouldUpgrade(existingVersion, desiredVersion))
                {
                    existingDeps[packageName] = desiredVersion;
                }
            }
        }
    }
 
    private static bool TryMergeExistingDependency(JsonObject existing, string sectionName, string packageName, string desiredVersion)
    {
        if (existing[sectionName] is not JsonObject existingDeps)
        {
            return false;
        }
 
        var existingVersionNode = existingDeps[packageName];
        if (existingVersionNode is null)
        {
            return false;
        }
 
        if (existingVersionNode is JsonValue existingValue
            && existingValue.TryGetValue<string>(out var existingVersion)
            && NpmVersionHelper.ShouldUpgrade(existingVersion, desiredVersion))
        {
            existingDeps[packageName] = desiredVersion;
        }
 
        return true;
    }
 
    /// <summary>
    /// Merges the <c>engines</c> section from scaffold into existing. The <c>engines.node</c>
    /// constraint is always overwritten by the scaffold's value because <c>aspire init</c> requires
    /// specific Node.js versions for ESLint 10 and TypeScript tooling compatibility. Other
    /// <c>engines</c> sub-keys (e.g., <c>npm</c>) are preserved from the existing package.json.
    /// </summary>
    private static void MergeEngines(JsonObject existing, JsonObject scaffold, ILogger logger)
    {
        var scaffoldEngines = scaffold[EnginesKey]?.AsObject();
        if (scaffoldEngines is null)
        {
            return;
        }
 
        var existingEngines = EnsureObject(existing, EnginesKey, logger);
 
        foreach (var (key, value) in scaffoldEngines)
        {
            if (value is null)
            {
                continue;
            }
 
            if (key == EnginesNodeKey)
            {
                // Always overwrite engines.node — Aspire requires specific Node versions
                existingEngines[key] = value.DeepClone();
            }
            else if (existingEngines[key] is null)
            {
                existingEngines[key] = value.DeepClone();
            }
            // Other existing engine constraints are preserved
        }
    }
 
    /// <summary>
    /// Deep merges properties from source into target. Existing target values are preserved.
    /// For nested objects, recursively merges. Scalar values in target are never overwritten.
    /// </summary>
    internal static void DeepMerge(JsonObject target, JsonObject source)
    {
        foreach (var (key, sourceValue) in source)
        {
            if (sourceValue is null)
            {
                continue;
            }
 
            var targetValue = target[key];
 
            if (targetValue is null)
            {
                target[key] = sourceValue.DeepClone();
            }
            else if (targetValue is JsonObject targetObj && sourceValue is JsonObject sourceObj)
            {
                DeepMerge(targetObj, sourceObj);
            }
            // Scalar values in target are preserved
        }
    }
 
    private static JsonObject EnsureObject(JsonObject parent, string propertyName, ILogger logger)
    {
        if (parent[propertyName] is JsonObject obj)
        {
            return obj;
        }
 
        if (parent[propertyName] is not null)
        {
            logger.LogWarning(
                "Replacing non-object '{PropertyName}' value with an empty object. The original value will be lost.",
                propertyName);
        }
 
        obj = new JsonObject();
        parent[propertyName] = obj;
        return obj;
    }
}