From c89402df79faf781b6cd19a2ee1216705bb987c2 Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Mon, 15 Jun 2026 19:45:40 -0500 Subject: [PATCH 01/49] Enable VS source debugging for SDK-style projects (full PDB + F5 wiring) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sdk.props: DebugType=full for Debug, set BEFORE the Microsoft.NET.Sdk import so its '==empty -> portable' default is pre-empted. Under VS's .NET-Framework csc this yields a Windows/full PDB and source breakpoints bind; a portable PDB makes VS bind at the method entry only (one startup hit, never on the line). Release/CLI fall through to portable. - Sdk.targets: remove the LaunchProfiles capability so the C# project system's launcher doesn't own F5 (the NanoDebugger flavor + rule route F5 to the device); surface the NanoDebugger property-page rule via PropertyPageSchema. - Add Sdk/Rules/NanoDebugger.xaml and pack it. Proven by the SDK-style POC (deploy + F5 + source breakpoints on a real ESP32_S3_OCTAL) — nanoframework/Home#1784. Demo: https://youtu.be/9qvXsgXCrjM Co-Authored-By: Claude Opus 4.8 --- .../Sdk/Rules/NanoDebugger.xaml | 42 +++++++++++++++++++ src/nanoFramework.NET.Sdk/Sdk/Sdk.props | 12 ++++++ src/nanoFramework.NET.Sdk/Sdk/Sdk.targets | 14 +++++++ .../nanoFramework.NET.Sdk.csproj | 3 ++ 4 files changed, 71 insertions(+) create mode 100644 src/nanoFramework.NET.Sdk/Sdk/Rules/NanoDebugger.xaml diff --git a/src/nanoFramework.NET.Sdk/Sdk/Rules/NanoDebugger.xaml b/src/nanoFramework.NET.Sdk/Sdk/Rules/NanoDebugger.xaml new file mode 100644 index 0000000..9482dc1 --- /dev/null +++ b/src/nanoFramework.NET.Sdk/Sdk/Rules/NanoDebugger.xaml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/nanoFramework.NET.Sdk/Sdk/Sdk.props b/src/nanoFramework.NET.Sdk/Sdk/Sdk.props index 5df4567..2a10d65 100644 --- a/src/nanoFramework.NET.Sdk/Sdk/Sdk.props +++ b/src/nanoFramework.NET.Sdk/Sdk/Sdk.props @@ -26,6 +26,18 @@ $(NoWarn);NU1701 + + + full diff --git a/src/nanoFramework.NET.Sdk/Sdk/Sdk.targets b/src/nanoFramework.NET.Sdk/Sdk/Sdk.targets index 27a0451..60da2aa 100644 --- a/src/nanoFramework.NET.Sdk/Sdk/Sdk.targets +++ b/src/nanoFramework.NET.Sdk/Sdk/Sdk.targets @@ -81,9 +81,23 @@ + + + + + + + + Project + + + $(TargetName) diff --git a/src/nanoFramework.NET.Sdk/nanoFramework.NET.Sdk.csproj b/src/nanoFramework.NET.Sdk/nanoFramework.NET.Sdk.csproj index 7f6b522..cd35d09 100644 --- a/src/nanoFramework.NET.Sdk/nanoFramework.NET.Sdk.csproj +++ b/src/nanoFramework.NET.Sdk/nanoFramework.NET.Sdk.csproj @@ -77,6 +77,9 @@ + + + From ecb7c02d07ba3237657833271ae33fa402808a86 Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Mon, 15 Jun 2026 20:06:59 -0500 Subject: [PATCH 02/49] Target v2 firmware: MDP 4.x (NFMRK2) + net8.0 task TFM - Sdk.props: bump the auto-injected nanoFramework.Tools.MetadataProcessor.MsBuildTask 3.0.29 -> 4.0.0-preview.94 (4.x emits the v2 PE format NFMRK2; 3.0.x emits v1/NFMRK1). - Sdk.targets: MDP task TFM for Core MSBuild net6.0 -> net8.0 (4.x ships net8.0 + net472). Validated: SmokeTest built with VS MSBuild against the modified SDK emits a v2 PE (NFMRK2) and a Windows/full PDB ('Microsoft C/C++ MSF') so source breakpoints bind. Co-Authored-By: Claude Opus 4.8 --- src/nanoFramework.NET.Sdk/Sdk/Sdk.props | 4 +++- src/nanoFramework.NET.Sdk/Sdk/Sdk.targets | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/nanoFramework.NET.Sdk/Sdk/Sdk.props b/src/nanoFramework.NET.Sdk/Sdk/Sdk.props index 2a10d65..a9892c5 100644 --- a/src/nanoFramework.NET.Sdk/Sdk/Sdk.props +++ b/src/nanoFramework.NET.Sdk/Sdk/Sdk.props @@ -134,7 +134,9 @@ - 3.0.29 + + 4.0.0-preview.94 - - <_NfMdpTasksTFM Condition="'$(MSBuildRuntimeType)' == 'Core'">net6.0 + + <_NfMdpTasksTFM Condition="'$(MSBuildRuntimeType)' == 'Core'">net8.0 <_NfMdpTasksTFM Condition="'$(_NfMdpTasksTFM)' == ''">net472 <_NfMdpTasksDir Condition="'$(NF_MDP_MSBUILDTASK_PATH)' == '' and '$(DisableNanoFrameworkMDP)' != 'true' and '$(PkgnanoFramework_Tools_MetadataProcessor_MsBuildTask)' != ''">$(PkgnanoFramework_Tools_MetadataProcessor_MsBuildTask)\lib\$(_NfMdpTasksTFM)\ From 6689c72e31c8a1d20ac9595ae1ec943f034398a2 Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Mon, 15 Jun 2026 22:01:31 -0500 Subject: [PATCH 03/49] Add NanoMigrate converter under tools/ (moved from the extension plan dir) Relocate the legacy .nfproj -> SDK-style .csproj migration tool into this repo, where it belongs alongside the SDK. Hardened for a safe single-repo fleet run: - Idempotent + reentrant: skips projects whose root already has an Sdk attribute; re-running is a no-op (never re-converts or empties an already-converted project). - Default output is .csproj (deletes the original .nfproj); namespace-agnostic parsing. - Emits the correct versionless reference. - Derives PackageReference id+version from the packages\.\ HintPath folder (alias table is fallback only); skips with a review note instead of emitting Version="". - Deletes a hand-written Properties/AssemblyInfo.cs (avoids duplicate-attribute errors). - Rewrites the .sln entry (project-type GUID + .nfproj->.csproj path), line-scoped so a mixed/partial repo and reruns stay correct. - Never clobbers an existing .bak backup. Co-Authored-By: Claude Opus 4.8 --- tools/NanoMigrate/Converter.cs | 432 ++++++++++++++++++ tools/NanoMigrate/NanoMigrate.csproj | 17 + tools/NanoMigrate/Program.cs | 339 ++++++++++++++ tools/NanoMigrate/Support.cs | 132 ++++++ tools/NanoMigrate/nano-migrate.py | 137 ++++++ .../nanoframework-sdk-migration.skill | Bin 0 -> 23551 bytes tools/NanoMigrate/nuget.config | 8 + 7 files changed, 1065 insertions(+) create mode 100644 tools/NanoMigrate/Converter.cs create mode 100644 tools/NanoMigrate/NanoMigrate.csproj create mode 100644 tools/NanoMigrate/Program.cs create mode 100644 tools/NanoMigrate/Support.cs create mode 100644 tools/NanoMigrate/nano-migrate.py create mode 100644 tools/NanoMigrate/nanoframework-sdk-migration.skill create mode 100644 tools/NanoMigrate/nuget.config diff --git a/tools/NanoMigrate/Converter.cs b/tools/NanoMigrate/Converter.cs new file mode 100644 index 0000000..7a17f6c --- /dev/null +++ b/tools/NanoMigrate/Converter.cs @@ -0,0 +1,432 @@ +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace NanoFramework.Migrate; + +/// Outcome of converting a single project. +internal sealed class ConvertResult +{ + public required string OutputPath { get; init; } + public List Review { get; } = new(); +} + +/// +/// Converts one legacy .nfproj into an SDK-style project. Faithful to the +/// reference rules: drop project-system boilerplate and SDK-supplied defaults, +/// fold packages.config into PackageReference, fold .nuspec metadata into Pack +/// properties, drop default Compile globs and a hand-written AssemblyInfo.cs, +/// and "fail loud" — anything it cannot confidently convert is surfaced for a +/// human rather than guessed. +/// +internal static class Converter +{ + private static readonly XNamespace Ns = "http://schemas.microsoft.com/developer/msbuild/2003"; + + // Project-system boilerplate and properties the SDK now supplies itself. + private static readonly HashSet DropProps = new(StringComparer.Ordinal) + { + "ProjectTypeGuids", "ProjectGuid", "FileAlignment", "AppDesignerFolder", + "NanoFrameworkProjectSystemPath", "TargetFrameworkVersion", "OldToolsVersion", + "Configuration", "Platform", + }; + + // Properties carried through verbatim when present. + private static readonly HashSet KeepProps = new(StringComparer.Ordinal) + { + "RootNamespace", "AssemblyName", "DocumentationFile", "DefineConstants", "LangVersion", + "Description", "Authors", "PackageTags", "Copyright", + }; + + // Legacy names whose NuGet package id differs from X. + private static readonly Dictionary LegacyPkgAliases = new(StringComparer.Ordinal) + { + ["mscorlib"] = "nanoFramework.CoreLibrary", + ["System"] = "nanoFramework.CoreLibrary", + }; + + // Matches a NuGet "packages\.\" folder segment inside a HintPath. + // is greedy-then-split: the version is the tail starting at the first + // dotted segment that begins with a digit, so prerelease/build suffixes + // (e.g. "2.0.0-preview.52") stay attached to the version, not the id. + private static readonly Regex HintPathPackage = new( + @"[\\/]packages[\\/](?[^\\/]+)[\\/]", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // The SDK reference is versionless; the concrete version is pinned via a + // global.json `msbuild-sdks` entry, not the Sdk attribute. + private const string SdkReference = "nanoFramework.NET.Sdk"; + + public static ConvertResult Convert(string nfproj, Options o) + { + var projDir = Path.GetDirectoryName(Path.GetFullPath(nfproj))!; + var root = XElement.Load(nfproj); + + // Idempotency guard: an SDK-style project already has an Sdk attribute on + // the root. Treat it as already-converted and skip without touching disk, + // so a second run over a repo is a true no-op rather than destructive. + if (root.Attribute("Sdk") is not null) + return new ConvertResult { OutputPath = Path.GetFullPath(nfproj) } + .With(new[] { "already SDK-style; skipped" }); + + var pkgs = LoadPackagesConfig(projDir); + + var props = new List>(); // discovery order, deduped + var pkgRefs = new Dictionary(StringComparer.OrdinalIgnoreCase); + var projRefs = new List(); + var keepItems = new List(); + var review = new List(); + + void SetProp(string k, string? v) + { + if (string.IsNullOrEmpty(v)) return; + if (props.Any(p => p.Key == k)) return; + props.Add(new(k, v)); + } + + // Read by local name so the converter works whether the input uses the + // legacy MSBuild namespace or none at all. + foreach (var pg in root.Elements().Where(e => e.Name.LocalName == "PropertyGroup")) + foreach (var el in pg.Elements()) + { + var tag = el.Name.LocalName; + if (DropProps.Contains(tag)) continue; + if (KeepProps.Contains(tag)) SetProp(tag, el.Value); + } + + foreach (var ig in root.Elements().Where(e => e.Name.LocalName == "ItemGroup")) + foreach (var el in ig.Elements()) + { + var tag = el.Name.LocalName; + var inc = (string?)el.Attribute("Include") ?? ""; + switch (tag) + { + case "Reference": + { + var rawName = inc.Split(',')[0].Trim(); + // Prefer id+version parsed straight from the HintPath folder. + var fromHint = InferFromHintPath(el); + if (fromHint is not null) + { + pkgRefs[fromHint.Value.id] = fromHint.Value.ver; + break; + } + // Fallback: resolve the package id via the alias table, then + // look up its version in packages.config. + var name = LegacyPkgAliases.GetValueOrDefault(rawName, rawName); + var ver = pkgs.GetValueOrDefault(name) ?? pkgs.GetValueOrDefault(rawName); + if (ver is not null) pkgRefs[name] = ver; + else + review.Add($"Reference without resolvable version: {inc} " + + "(no HintPath or packages.config entry; map to a PackageReference manually)"); + break; + } + case "PackageReference": + { + var ver = (string?)el.Attribute("Version") ?? pkgs.GetValueOrDefault(inc); + if (ver is not null) pkgRefs[inc] = ver; + else + review.Add($"PackageReference without resolvable version: {inc} " + + "(add a Version manually)"); + break; + } + case "ProjectReference": + projRefs.Add(inc); + break; + case "Compile": + if (!IsDefaultCompile(inc) || el.Attribute("Link") is not null) + keepItems.Add(el); + break; + case "None": + if (inc != "packages.config" && !inc.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)) + keepItems.Add(el); + break; + case "EmbeddedResource": + case "Content": + keepItems.Add(el); + break; + default: + review.Add($"Unhandled item <{tag} Include='{inc}'>"); + break; + } + } + + FoldNuspec(projDir, SetProp); + + var xml = Emit(props, pkgRefs, projRefs, keepItems, o); + + var outPath = Path.ChangeExtension(Path.GetFullPath(nfproj), o.Ext); + if (!o.DryRun) + { + // Never clobber an existing backup: the original first-run .bak must + // survive reruns. + if (!o.NoBackup && !File.Exists(nfproj + ".bak")) File.Copy(nfproj, nfproj + ".bak", overwrite: false); + File.WriteAllText(outPath, xml, new UTF8Encoding(false)); + // If we emitted a .csproj alongside, retire the original .nfproj. + if (!string.Equals(outPath, Path.GetFullPath(nfproj), StringComparison.OrdinalIgnoreCase)) + { + File.Delete(nfproj); + // Point any .sln entries at the new .csproj (idempotent). + UpdateSolutions(projDir, nfproj, outPath); + } + var pc = Path.Combine(projDir, "packages.config"); + if (File.Exists(pc)) File.Delete(pc); + // The SDK default **/*.cs glob plus generated assembly info would + // otherwise produce duplicate-attribute build errors, so delete a + // hand-written AssemblyInfo.cs from disk (dropping the Compile item + // is not enough). + DeleteAssemblyInfo(projDir); + } + + return new ConvertResult { OutputPath = outPath }.With(review); + } + + // Parses the "packages\.\" folder segment of a HintPath into a + // (id, version) pair. The version is the suffix that begins at the first + // dotted segment starting with a digit; everything before it is the id. + private static (string id, string ver)? InferFromHintPath(XElement reference) + { + var hint = reference.Elements().FirstOrDefault(e => e.Name.LocalName == "HintPath")?.Value; + if (string.IsNullOrEmpty(hint)) return null; + var m = HintPathPackage.Match(hint); + if (!m.Success) return null; + return SplitPackageFolder(m.Groups["folder"].Value); + } + + // "nanoFramework.System.Device.Gpio.1.1.57" -> ("nanoFramework.System.Device.Gpio", "1.1.57") + // "nanoFramework.CoreLibrary.2.0.0-preview.52" -> ("nanoFramework.CoreLibrary", "2.0.0-preview.52") + private static (string id, string ver)? SplitPackageFolder(string folder) + { + var parts = folder.Split('.'); + for (int i = 0; i < parts.Length; i++) + { + if (parts[i].Length > 0 && char.IsDigit(parts[i][0])) + { + if (i == 0) return null; // no id before the version + var id = string.Join('.', parts[..i]); + var ver = string.Join('.', parts[i..]); + return (id, ver); + } + } + return null; // no version segment found + } + + private static Dictionary LoadPackagesConfig(string projDir) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var pc = Path.Combine(projDir, "packages.config"); + if (!File.Exists(pc)) return result; + foreach (var p in XElement.Load(pc).Elements().Where(e => e.Name.LocalName == "package")) + { + var id = (string?)p.Attribute("id"); + var ver = (string?)p.Attribute("version"); + if (id is not null && ver is not null) result[id] = ver; + } + return result; + } + + // Removes a hand-written AssemblyInfo.cs from disk. With the SDK's default + // **/*.cs glob and GenerateAssemblyInfo, leaving it would cause duplicate + // assembly-attribute build errors. Idempotent: a missing file is a no-op. + private static void DeleteAssemblyInfo(string projDir) + { + foreach (var rel in new[] { Path.Combine("Properties", "AssemblyInfo.cs"), "AssemblyInfo.cs" }) + { + var path = Path.Combine(projDir, rel); + if (File.Exists(path)) File.Delete(path); + } + } + + // Project-type GUIDs: legacy nanoFramework flavor -> SDK-style C#. + private const string NfprojTypeGuid = "{11A8DD76-328B-46DF-9F39-F559912D0360}"; + private const string CsprojTypeGuid = "{9A19103F-16F7-4668-BE54-9A1E7A4F7556}"; + + // Rewrites .sln entries that reference the converted .nfproj so they point at + // the new .csproj and use the SDK-style project-type GUID. Searches walking up + // from the project directory to the repo root (the dir containing .git), plus + // any .sln in the project's own directory tree. Only entries still pointing at + // the .nfproj are touched, so re-running is a no-op. + private static void UpdateSolutions(string projDir, string nfproj, string csproj) + { + var slns = FindSolutionFiles(projDir); + if (slns.Count == 0) return; + + var nfName = Path.GetFileName(nfproj); // e.g. Foo.nfproj + var csName = Path.GetFileName(csproj); // e.g. Foo.csproj + + foreach (var sln in slns) + { + string text; + try { text = File.ReadAllText(sln); } + catch { continue; } + var slnDir = Path.GetDirectoryName(Path.GetFullPath(sln))!; + + // The path stored in the .sln is relative to the .sln directory and is + // wrapped in double quotes. Match the *quoted* path so we rewrite the + // specific project entry rather than blanket-replacing a substring that + // might collide with another project's name. + var relPath = Path.GetRelativePath(slnDir, nfproj); + var candidates = new[] + { + relPath, // OS-native separators + relPath.Replace('/', '\\'), + relPath.Replace('\\', '/'), + nfName, // fallback: bare filename in the sln dir + }; + + // Rewrite line-by-line so the GUID swap stays scoped to *this* + // project's entry. A blanket GUID replace would wrongly flip other, + // still-unconverted nanoFramework projects in a shared solution. + var lines = text.Split('\n'); + var changed = false; + for (int li = 0; li < lines.Length; li++) + { + var line = lines[li]; + // Only the project's declaration line names its path. + if (!line.TrimStart().StartsWith("Project(", StringComparison.OrdinalIgnoreCase)) continue; + + var matched = false; + foreach (var path in candidates.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var quoted = "\"" + path + "\""; + if (!line.Contains(quoted, StringComparison.OrdinalIgnoreCase)) continue; + var quotedReplacement = "\"" + path[..^nfName.Length] + csName + "\""; + line = ReplaceIgnoreCase(line, quoted, quotedReplacement); + matched = true; + } + if (!matched) continue; + + // Swap the legacy project-type GUID for the SDK-style one, but only + // on the line we just retargeted. + if (line.Contains(NfprojTypeGuid, StringComparison.OrdinalIgnoreCase)) + line = ReplaceIgnoreCase(line, NfprojTypeGuid, CsprojTypeGuid); + + lines[li] = line; + changed = true; + } + if (!changed) continue; + + var updated = string.Join('\n', lines); + if (!string.Equals(updated, text, StringComparison.Ordinal)) + File.WriteAllText(sln, updated, new UTF8Encoding(false)); + } + } + + private static List FindSolutionFiles(string projDir) + { + var found = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Walk up to the repo root (the directory containing .git), collecting + // .sln files at each level. + var dir = new DirectoryInfo(Path.GetFullPath(projDir)); + while (dir is not null) + { + foreach (var sln in Directory.EnumerateFiles(dir.FullName, "*.sln", SearchOption.TopDirectoryOnly)) + found.Add(Path.GetFullPath(sln)); + if (Directory.Exists(Path.Combine(dir.FullName, ".git")) + || File.Exists(Path.Combine(dir.FullName, ".git"))) + break; // reached the repo root + dir = dir.Parent; + } + + // Also any .sln anywhere in the project's own directory tree. + foreach (var sln in Directory.EnumerateFiles(projDir, "*.sln", SearchOption.AllDirectories)) + found.Add(Path.GetFullPath(sln)); + + return found.ToList(); + } + + private static string ReplaceIgnoreCase(string input, string search, string replacement) + { + var sb = new StringBuilder(); + int i = 0; + while (i < input.Length) + { + var idx = input.IndexOf(search, i, StringComparison.OrdinalIgnoreCase); + if (idx < 0) { sb.Append(input, i, input.Length - i); break; } + sb.Append(input, i, idx - i).Append(replacement); + i = idx + search.Length; + } + return sb.ToString(); + } + + private static bool IsDefaultCompile(string inc) + { + var baseName = inc.TrimStart('.', '\\'); + if (!inc.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) return false; + // A hand-written AssemblyInfo.cs collides with GenerateAssemblyInfo → drop it. + if (baseName.Replace('\\', '/').EndsWith("Properties/AssemblyInfo.cs", StringComparison.OrdinalIgnoreCase)) + return true; + return !baseName.Contains('\\'); + } + + private static void FoldNuspec(string projDir, Action setProp) + { + var nuspec = Directory.EnumerateFiles(projDir, "*.nuspec").FirstOrDefault(); + if (nuspec is null) return; + var meta = XElement.Load(nuspec).Descendants().FirstOrDefault(e => e.Name.LocalName == "metadata"); + if (meta is null) return; + foreach (var (xml, msb) in new[] + { + ("id", "PackageId"), ("description", "Description"), ("authors", "Authors"), + ("tags", "PackageTags"), ("projectUrl", "PackageProjectUrl"), + }) + { + var e = meta.Elements().FirstOrDefault(x => x.Name.LocalName == xml); + if (e is not null && !string.IsNullOrEmpty(e.Value)) setProp(msb, e.Value); + } + } + + private static string Emit( + List> props, + Dictionary pkgRefs, + List projRefs, + List keepItems, + Options o) + { + var sb = new StringBuilder(); + // Versionless SDK reference; the version is pinned via global.json msbuild-sdks. + sb.Append($"\n\n"); + sb.Append(" \n"); + sb.Append($" {o.Tfm}\n"); + foreach (var kv in props) + sb.Append($" <{kv.Key}>{Escape(kv.Value)}\n"); + sb.Append(" \n\n"); + + if (pkgRefs.Count > 0) + { + sb.Append(" \n"); + foreach (var kv in pkgRefs.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) + sb.Append($" \n"); + sb.Append(" \n\n"); + } + if (projRefs.Count > 0) + { + sb.Append(" \n"); + foreach (var r in projRefs) + sb.Append($" \n"); + sb.Append(" \n\n"); + } + if (keepItems.Count > 0) + { + sb.Append(" \n"); + foreach (var el in keepItems) + { + var attrs = string.Join(" ", el.Attributes().Select(a => $"{a.Name.LocalName}=\"{Escape(a.Value)}\"")); + sb.Append($" <{el.Name.LocalName} {attrs} />\n"); + } + sb.Append(" \n\n"); + } + sb.Append("\n"); + return sb.ToString(); + } + + private static string Escape(string s) => s + .Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """); + + private static ConvertResult With(this ConvertResult r, IEnumerable review) + { + r.Review.AddRange(review); + return r; + } +} diff --git a/tools/NanoMigrate/NanoMigrate.csproj b/tools/NanoMigrate/NanoMigrate.csproj new file mode 100644 index 0000000..5e950f5 --- /dev/null +++ b/tools/NanoMigrate/NanoMigrate.csproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0 + enable + enable + nano-migrate + NanoFramework.Migrate + true + + false + + + + diff --git a/tools/NanoMigrate/Program.cs b/tools/NanoMigrate/Program.cs new file mode 100644 index 0000000..fa6351d --- /dev/null +++ b/tools/NanoMigrate/Program.cs @@ -0,0 +1,339 @@ +// nano-migrate — convert legacy nanoFramework .nfproj projects to the SDK-style +// MSBuild project system, one project at a time or across an entire cloned fleet. +// +// SCOPE: project-system migration ONLY. This tool does NOT touch OTA, modular +// firmware packaging, runtimes/{rid}/native layouts, or ABI manifests. It moves +// a repo from the legacy flavored .nfproj format onto an SDK-style project that +// composes over the nanoFramework SDK, folds packages.config into PackageReference, +// and folds .nuspec metadata into MSBuild Pack properties. Nothing more. +// +// BCL-only: no external NuGet dependencies, so it builds and runs fully offline +// once the .NET SDK is present. + +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Xml.Linq; + +namespace NanoFramework.Migrate; + +internal static class Program +{ + private const string MsbuildNs = "http://schemas.microsoft.com/developer/msbuild/2003"; + + private static int Main(string[] args) + { + if (args.Length == 0 || IsHelp(args[0])) + { + PrintUsage(); + return args.Length == 0 ? 1 : 0; + } + + var opts = Options.Parse(args.Skip(1)); + try + { + return args[0].ToLowerInvariant() switch + { + "migrate" => CmdMigrate(opts), + "clone" => CmdClone(opts), + "fleet" => CmdFleet(opts), + _ => Fail($"unknown command '{args[0]}'"), + }; + } + catch (UserError ue) + { + return Fail(ue.Message); + } + } + + // ───────────────────────────── migrate ───────────────────────────── + + private static int CmdMigrate(Options o) + { + var path = o.Positional ?? throw new UserError("migrate needs a path to a .nfproj or a directory"); + var targets = ResolveProjects(path); + if (targets.Count == 0) throw new UserError($"no .nfproj found under '{path}'"); + + var allReview = new List(); + foreach (var nf in targets) + { + var result = Converter.Convert(nf, o); + Console.WriteLine(o.DryRun + ? $"would convert {nf} -> {result.OutputPath}" + : $"converted {nf} -> {result.OutputPath}"); + foreach (var r in result.Review) allReview.Add($" [{Path.GetFileName(nf)}] {r}"); + } + + if (allReview.Count > 0) + { + Console.WriteLine("\nMANUAL REVIEW NEEDED:"); + Console.WriteLine(string.Join("\n", allReview)); + return 2; + } + return 0; + } + + private static List ResolveProjects(string path) + { + if (File.Exists(path) && path.EndsWith(".nfproj", StringComparison.OrdinalIgnoreCase)) + return new List { Path.GetFullPath(path) }; + if (Directory.Exists(path)) + return Directory.EnumerateFiles(path, "*.nfproj", SearchOption.AllDirectories) + .Select(Path.GetFullPath).OrderBy(p => p).ToList(); + return new List(); + } + + // ───────────────────────────── clone ───────────────────────────── + + private static int CmdClone(Options o) + { + var outDir = o.Positional ?? "./nano-repos"; + Directory.CreateDirectory(outDir); + + Console.WriteLine($"enumerating {o.Org} repositories matching '{o.Filter}*'..."); + var repos = GitHub.ListOrgRepos(o.Org, o.Token, o.IncludeArchived) + .Where(r => r.Name.StartsWith(o.Filter, StringComparison.OrdinalIgnoreCase)) + .OrderBy(r => r.Name).ToList(); + + if (repos.Count == 0) throw new UserError( + $"no repos matched '{o.Filter}*' in org '{o.Org}'. " + + "Check the org name and filter, or pass --token to lift the API rate limit."); + + Console.WriteLine($"found {repos.Count} repositories. cloning into {outDir} ..."); + int ok = 0, skipped = 0, failed = 0; + foreach (var r in repos) + { + var dest = Path.Combine(outDir, r.Name); + if (Directory.Exists(dest)) { Console.WriteLine($" skip {r.Name} (already present)"); skipped++; continue; } + var (code, _, err) = Run("git", $"clone --depth 1 {r.CloneUrl} \"{dest}\"", outDir); + if (code == 0) { Console.WriteLine($" cloned {r.Name}"); ok++; } + else { Console.WriteLine($" FAIL {r.Name}: {err.Trim().Split('\n').LastOrDefault()}"); failed++; } + } + Console.WriteLine($"\ndone. cloned {ok}, skipped {skipped}, failed {failed}."); + return failed > 0 ? 2 : 0; + } + + // ───────────────────────────── fleet ───────────────────────────── + + private static int CmdFleet(Options o) + { + var reposDir = o.Positional ?? throw new UserError("fleet needs a path to a directory of cloned repos"); + if (!Directory.Exists(reposDir)) throw new UserError($"directory not found: {reposDir}"); + if (o.Commit && o.Branch is null) throw new UserError("--commit requires --branch"); + // nanoFramework workflow: branch names must not start with "develop" (they + // collide with upstream develop-* branches). + if (o.Branch is not null && o.Branch.StartsWith("develop", StringComparison.OrdinalIgnoreCase)) + throw new UserError("branch name must not start with 'develop' (nanoFramework workflow); " + + "use something like 'sdk-migration' or 'issue-123'"); + // In a git repo the commit history already preserves the pre-migration file, + // so a .bak alongside it is just noise in the diff. Skip backups when committing. + if (o.Commit) o.NoBackup = true; + + var repoDirs = Directory.EnumerateDirectories(reposDir) + .Where(d => Directory.EnumerateFiles(d, "*.nfproj", SearchOption.AllDirectories).Any()) + .OrderBy(d => d).ToList(); + if (repoDirs.Count == 0) throw new UserError($"no repos containing .nfproj found under '{reposDir}'"); + + var report = new List(); + foreach (var repo in repoDirs) + { + var rr = new RepoReport { Name = Path.GetFileName(repo) }; + try + { + if (o.Branch is not null && !o.DryRun) + { + var (code, _, err) = Run("git", $"checkout -B {o.Branch}", repo); + if (code != 0) { rr.Error = $"git checkout failed: {err.Trim()}"; report.Add(rr); continue; } + } + + foreach (var nf in Directory.EnumerateFiles(repo, "*.nfproj", SearchOption.AllDirectories).OrderBy(p => p)) + { + rr.Projects++; + var result = Converter.Convert(nf, o); + var rel = Path.GetRelativePath(repo, nf); + foreach (var item in result.Review) rr.Review.Add($"{rel}: {item}"); + } + + if (o.Commit && !o.DryRun) + { + Run("git", "add -A", repo); + var msgFile = WriteCommitMessage(repo, o); + var signOff = o.SignOff ? "-s " : ""; + var (code, _, err) = Run("git", $"commit {signOff}-F \"{msgFile}\"", repo); + File.Delete(msgFile); + rr.Committed = code == 0; + if (code != 0 && !err.Contains("nothing to commit")) + { + rr.Error = err.Contains("Please tell me who you are") || err.Contains("user.name") + ? "git commit failed: set git user.name/user.email (real name) so the " + + "Signed-off-by line is valid, or pass --no-sign-off" + : $"git commit: {err.Trim()}"; + } + } + } + catch (Exception ex) + { + rr.Error = ex.Message; + } + report.Add(rr); + var status = rr.Error is not null ? "ERROR" : rr.Review.Count > 0 ? "review" : "ok"; + Console.WriteLine($" [{status,6}] {rr.Name} ({rr.Projects} project(s), {rr.Review.Count} review item(s))"); + } + + WriteReport(report, o, reposDir); + var errored = report.Count(r => r.Error is not null); + Console.WriteLine($"\n{report.Count} repos processed, {errored} with errors. report: {o.Report}"); + return errored > 0 ? 2 : 0; + } + + private static void WriteReport(List report, Options o, string reposDir) + { + var sb = new StringBuilder(); + sb.AppendLine("# nanoFramework SDK-style migration — fleet report\n"); + sb.AppendLine($"- Source: `{Path.GetFullPath(reposDir)}`"); + sb.AppendLine($"- Mode: {(o.DryRun ? "dry-run (no files written)" : "applied")}" + + (o.Branch is not null ? $", branch `{o.Branch}`" : "") + + (o.Commit ? ", committed" : "")); + sb.AppendLine($"- SDK `nanoFramework.NET.Sdk` (versionless), TFM `{o.Tfm}`, output extension `{o.Ext}`\n"); + + int total = report.Count, clean = report.Count(r => r.Error is null && r.Review.Count == 0); + int needsReview = report.Count(r => r.Error is null && r.Review.Count > 0); + int errors = report.Count(r => r.Error is not null); + sb.AppendLine("## Summary\n"); + sb.AppendLine($"| Repos | Clean | Needs review | Errored |"); + sb.AppendLine($"|------:|------:|-------------:|--------:|"); + sb.AppendLine($"| {total} | {clean} | {needsReview} | {errors} |\n"); + + if (errors > 0) + { + sb.AppendLine("## Errored repos\n"); + foreach (var r in report.Where(r => r.Error is not null)) + sb.AppendLine($"- **{r.Name}** — {r.Error}"); + sb.AppendLine(); + } + + if (needsReview > 0) + { + sb.AppendLine("## Repos needing manual review\n"); + sb.AppendLine("These migrated, but the tool could not confidently resolve everything. " + + "Each line is something a human should confirm before merging.\n"); + foreach (var r in report.Where(r => r.Error is null && r.Review.Count > 0)) + { + sb.AppendLine($"### {r.Name}\n"); + foreach (var item in r.Review) sb.AppendLine($"- {item}"); + sb.AppendLine(); + } + } + + if (clean > 0) + { + sb.AppendLine("## Clean migrations\n"); + sb.AppendLine("Converted with no items flagged for review:\n"); + foreach (var r in report.Where(r => r.Error is null && r.Review.Count == 0)) + sb.AppendLine($"- {r.Name} ({r.Projects} project(s))" + + (r.Committed ? " — committed" : "")); + sb.AppendLine(); + } + + File.WriteAllText(o.Report, sb.ToString()); + } + + // ───────────────────────────── helpers ───────────────────────────── + + // Builds a commit message that follows the nanoFramework guidance: a short + // summary (<= 50 chars), a blank line, a body wrapped at 72 columns, and an + // optional "Fix #" trailer. Returns the path to a temp message file. + private static string WriteCommitMessage(string repo, Options o) + { + var summary = o.CommitMessage ?? "Migrate project system to SDK-style"; + if (summary.Length > 50) summary = summary[..50].TrimEnd(); + + var body = Wrap( + "Convert the legacy .nfproj project system to an SDK-style MSBuild project: " + + "drop project-system boilerplate, fold packages.config into PackageReference, " + + "and fold .nuspec metadata into MSBuild Pack properties. " + + "No functional code changes.", 72); + + var sb = new StringBuilder(); + sb.Append(summary).Append("\n\n").Append(body).Append('\n'); + if (o.Issue is not null) sb.Append("\nFix #").Append(o.Issue).Append('\n'); + + var path = Path.GetTempFileName(); + File.WriteAllText(path, sb.ToString()); + return path; + } + + private static string Wrap(string text, int width) + { + var sb = new StringBuilder(); + int lineLen = 0; + foreach (var word in text.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + if (lineLen > 0 && lineLen + 1 + word.Length > width) { sb.Append('\n'); lineLen = 0; } + else if (lineLen > 0) { sb.Append(' '); lineLen++; } + sb.Append(word); lineLen += word.Length; + } + return sb.ToString(); + } + + internal static (int code, string stdout, string stderr) Run(string file, string args, string cwd) + { + var psi = new ProcessStartInfo(file, args) + { + WorkingDirectory = cwd, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + using var p = Process.Start(psi)!; + var so = p.StandardOutput.ReadToEnd(); + var se = p.StandardError.ReadToEnd(); + p.WaitForExit(); + return (p.ExitCode, so, se); + } + + private static bool IsHelp(string a) => a is "-h" or "--help" or "help"; + private static int Fail(string msg) { Console.Error.WriteLine($"error: {msg}"); return 1; } + + private static void PrintUsage() => Console.WriteLine(""" + nano-migrate — migrate nanoFramework projects to the SDK-style project system + + USAGE + nano-migrate migrate Convert a .nfproj, or every .nfproj under a directory. + nano-migrate clone Clone all matching repos from a GitHub org. + nano-migrate fleet Migrate every .nfproj across cloned repos; write a report. + + COMMON OPTIONS + --sdk Accepted for back-compat but ignored: the SDK reference + is versionless (pinned via global.json msbuild-sdks). + --tfm Target framework moniker (default netnano1.0) + --ext Output extension: .nfproj or .csproj (default .csproj) + --no-backup Don't write a .nfproj.bak (implied by fleet --commit). + --dry-run Analyse and report only; write nothing. + + clone OPTIONS + --org GitHub org (default nanoframework) + --filter Repo name prefix to match (default lib-) + --token GitHub token (or env GITHUB_TOKEN) to raise the API rate limit. + --include-archived Include archived repositories (skipped by default). + + fleet OPTIONS + --report Markdown report path (default migration-report.md) + --branch Create/reset this git branch in each repo (must not start with 'develop'). + --commit Commit the changes (requires --branch). Uses a contribution-compliant + message and signs off (Signed-off-by) by default. + --message Commit summary line (kept <= 50 chars). + --issue Reference an issue: adds a "Fix #" trailer to the commit. + --no-sign-off Don't add a Signed-off-by line. + + SCOPE + Project-system migration only. Does NOT produce OTA artifacts, modular + firmware packaging, runtimes/{rid}/native layouts, or ABI manifests. + + EXAMPLES + nano-migrate migrate ./lib-CoreLibrary + nano-migrate migrate ./MyDevice/MyDevice.nfproj --ext .csproj + nano-migrate clone ./nano-repos --token $GITHUB_TOKEN + nano-migrate fleet ./nano-repos --branch sdk-migration --commit --dry-run + """); +} diff --git a/tools/NanoMigrate/Support.cs b/tools/NanoMigrate/Support.cs new file mode 100644 index 0000000..b5b8a34 --- /dev/null +++ b/tools/NanoMigrate/Support.cs @@ -0,0 +1,132 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +namespace NanoFramework.Migrate; + +/// A user-facing error that prints cleanly without a stack trace. +internal sealed class UserError(string message) : Exception(message); + +/// Per-repo outcome accumulated by the fleet command. +internal sealed class RepoReport +{ + public required string Name { get; init; } + public int Projects { get; set; } + public List Review { get; } = new(); + public bool Committed { get; set; } + public string? Error { get; set; } +} + +/// Parsed command-line options shared across commands. +internal sealed class Options +{ + public string? Positional { get; private set; } + // Parsed but no longer emitted: the SDK reference is versionless (the version + // is pinned via global.json msbuild-sdks). Kept for CLI back-compat. + public string Sdk { get; private set; } = "2.0.0"; + public string Tfm { get; private set; } = "netnano1.0"; // the TFM recognized by the .NET SDK / NuGet + // Default to .csproj: a normal run produces Foo.csproj and deletes Foo.nfproj. + public string Ext { get; private set; } = ".csproj"; + public bool DryRun { get; private set; } + public bool NoBackup { get; internal set; } + + // clone + public string Org { get; private set; } = "nanoframework"; + public string Filter { get; private set; } = "lib-"; + public string? Token { get; private set; } = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); + public bool IncludeArchived { get; private set; } + + // fleet + public string Report { get; private set; } = "migration-report.md"; + public string? Branch { get; private set; } + public bool Commit { get; private set; } + public string? CommitMessage { get; private set; } + public string? Issue { get; private set; } // referenced as "Fix #" in the commit + public bool SignOff { get; private set; } = true; // nanoFramework recommends Signed-off-by + + public static Options Parse(IEnumerable args) + { + var o = new Options(); + var list = args.ToList(); + for (int i = 0; i < list.Count; i++) + { + var a = list[i]; + string Next(string name) => ++i < list.Count + ? list[i] + : throw new UserError($"{name} requires a value"); + + switch (a) + { + case "--sdk": o.Sdk = Next(a); break; + case "--tfm": o.Tfm = Next(a); break; + case "--ext": + o.Ext = Next(a); + if (o.Ext is not (".nfproj" or ".csproj")) + throw new UserError("--ext must be .nfproj or .csproj"); + break; + case "--dry-run": case "--no-write": o.DryRun = true; break; + case "--no-backup": o.NoBackup = true; break; + case "--org": o.Org = Next(a); break; + case "--filter": o.Filter = Next(a); break; + case "--token": o.Token = Next(a); break; + case "--include-archived": o.IncludeArchived = true; break; + case "--report": o.Report = Next(a); break; + case "--branch": o.Branch = Next(a); break; + case "--commit": o.Commit = true; break; + case "--message": o.CommitMessage = Next(a); break; + case "--issue": o.Issue = Next(a).TrimStart('#'); break; + case "--no-sign-off": o.SignOff = false; break; + default: + if (a.StartsWith('-')) throw new UserError($"unknown option '{a}'"); + if (o.Positional is not null) throw new UserError($"unexpected argument '{a}'"); + o.Positional = a; + break; + } + } + return o; + } +} + +/// Minimal GitHub REST client (BCL only) for listing org repositories. +internal static class GitHub +{ + internal sealed record Repo(string Name, string CloneUrl, bool Archived); + + public static List ListOrgRepos(string org, string? token, bool includeArchived) + { + using var http = new HttpClient(); + http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("nano-migrate", "1.0")); + http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + if (!string.IsNullOrEmpty(token)) + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var repos = new List(); + for (int page = 1; ; page++) + { + var url = $"https://api.github.com/orgs/{org}/repos?per_page=100&page={page}&type=public"; + using var resp = http.GetAsync(url).GetAwaiter().GetResult(); + if (!resp.IsSuccessStatusCode) + { + var hint = resp.StatusCode == System.Net.HttpStatusCode.Forbidden + ? " (rate limited — pass --token or set GITHUB_TOKEN)" : ""; + throw new UserError($"GitHub API returned {(int)resp.StatusCode} {resp.StatusCode}{hint}"); + } + + var json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + using var doc = JsonDocument.Parse(json); + var arr = doc.RootElement; + if (arr.GetArrayLength() == 0) break; + + foreach (var e in arr.EnumerateArray()) + { + var archived = e.TryGetProperty("archived", out var ar) && ar.GetBoolean(); + if (archived && !includeArchived) continue; + repos.Add(new Repo( + e.GetProperty("name").GetString()!, + e.GetProperty("clone_url").GetString()!, + archived)); + } + if (arr.GetArrayLength() < 100) break; + } + return repos; + } +} diff --git a/tools/NanoMigrate/nano-migrate.py b/tools/NanoMigrate/nano-migrate.py new file mode 100644 index 0000000..65a6aa3 --- /dev/null +++ b/tools/NanoMigrate/nano-migrate.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""nano-migrate: convert a legacy .nfproj to an SDK-style nanoFramework project.""" +import sys, re, json, shutil, xml.etree.ElementTree as ET +from pathlib import Path + +NS = "http://schemas.microsoft.com/developer/msbuild/2003" +ET.register_namespace("", NS) +Q = lambda t: f"{{{NS}}}{t}" + +DROP_PROPS = { + "ProjectTypeGuids","ProjectGuid","FileAlignment","AppDesignerFolder", + "NanoFrameworkProjectSystemPath","TargetFrameworkVersion","OldToolsVersion", + "Configuration","Platform", # SDK supplies defaults +} +KEEP_PROPS = { # carried through verbatim if present + "RootNamespace","AssemblyName","DocumentationFile","DefineConstants","LangVersion", +} + +def load_packages_config(proj_dir): + pc = proj_dir / "packages.config" + refs = {} + if pc.exists(): + for p in ET.parse(pc).getroot().findall("package"): + refs[p.get("id")] = p.get("version") + return refs + +# legacy names whose NuGet package id differs from X +LEGACY_PKG_ALIASES = { + "mscorlib": "nanoFramework.CoreLibrary", + "System": "nanoFramework.CoreLibrary", +} + +def is_default_compile(inc): # default glob already covers plain **/*.cs + base = inc.lstrip(".\\") + if not inc.endswith(".cs"): + return False + # a hand-written AssemblyInfo.cs collides with GenerateAssemblyInfo → must be dropped + if base.replace("\\", "/").endswith("Properties/AssemblyInfo.cs"): + return True # treat as default → dropped (SDK regenerates it) + return "\\" not in base + +def convert(nfproj: Path, sdk_version: str, tfm: str, out_ext: str): + proj_dir = nfproj.parent + tree = ET.parse(nfproj); root = tree.getroot() + pkgs = load_packages_config(proj_dir) + + props, pkg_refs, proj_refs, keep_items, review = {}, {}, [], [], [] + + for pg in root.findall(Q("PropertyGroup")): + for el in list(pg): + tag = el.tag.replace(f"{{{NS}}}","") + if tag in DROP_PROPS: continue + if tag in KEEP_PROPS or tag in ("Description","Authors","PackageTags","Copyright"): + props[tag] = el.text + + for ig in root.findall(Q("ItemGroup")): + for el in list(ig): + tag = el.tag.replace(f"{{{NS}}}","") + inc = el.get("Include","") + if tag == "Reference": + name = inc.split(",")[0] + name = LEGACY_PKG_ALIASES.get(name, name) + ver = pkgs.get(name) or pkgs.get(inc.split(",")[0]) + if ver: pkg_refs[name] = ver + else: review.append(f"Reference without resolvable version: {inc} " + f"(map to a PackageReference manually)") + elif tag == "PackageReference": + pkg_refs[inc] = el.get("Version") or pkgs.get(inc,"") + elif tag == "ProjectReference": + proj_refs.append(inc) + elif tag == "Compile": + if not is_default_compile(inc) or el.get("Link"): + keep_items.append(("Compile", el)) # non-default → preserve + elif tag == "None": + if inc not in ("packages.config",) and not inc.endswith(".nuspec"): + keep_items.append(("None", el)) + elif tag in ("EmbeddedResource","Content"): + keep_items.append((tag, el)) + else: + review.append(f"Unhandled item <{tag} Include='{inc}'>") + + # fold nuspec + nuspec = next(proj_dir.glob("*.nuspec"), None) + if nuspec: + meta = ET.parse(nuspec).getroot().find(".//{*}metadata") + if meta is not None: + for k_xml, k_msb in (("id","PackageId"),("description","Description"), + ("authors","Authors"),("tags","PackageTags"), + ("projectUrl","PackageProjectUrl")): + e = meta.find(f"{{*}}{k_xml}") + if e is not None and e.text: props.setdefault(k_msb, e.text) + + # emit + lines = [f'','',' ', + f' {tfm}'] + for k,v in props.items(): + if v: lines.append(f" <{k}>{v}") + lines += [' ',''] + if pkg_refs: + lines.append(' ') + for n,v in sorted(pkg_refs.items()): + lines.append(f' ') + lines += [' ',''] + if proj_refs: + lines.append(' ') + for r in proj_refs: lines.append(f' ') + lines += [' ',''] + if keep_items: + lines.append(' ') + for tag, el in keep_items: + attrs = " ".join(f'{k}="{v}"' for k,v in el.attrib.items()) + lines.append(f' <{tag} {attrs} />') + lines += [' ',''] + lines.append('') + + out = nfproj.with_suffix(out_ext) + shutil.copy2(nfproj, nfproj.with_suffix(nfproj.suffix + ".bak")) + out.write_text("\n".join(lines), encoding="utf-8") + (proj_dir / "packages.config").unlink(missing_ok=True) + return out, review + +if __name__ == "__main__": + import argparse + ap = argparse.ArgumentParser() + ap.add_argument("path"); ap.add_argument("--sdk", default="2.0.0") + ap.add_argument("--tfm", default="netnano1.0") + ap.add_argument("--ext", default=".nfproj", choices=[".nfproj",".csproj"]) + a = ap.parse_args() + targets = [Path(a.path)] if a.path.endswith(".nfproj") else list(Path(a.path).rglob("*.nfproj")) + total_review = [] + for nf in targets: + out, review = convert(nf, a.sdk, a.tfm, a.ext) + print(f"converted {nf} -> {out}") + for r in review: total_review.append(f" [{nf.name}] {r}") + if total_review: + print("\nMANUAL REVIEW NEEDED:"); print("\n".join(total_review)) + sys.exit(2) diff --git a/tools/NanoMigrate/nanoframework-sdk-migration.skill b/tools/NanoMigrate/nanoframework-sdk-migration.skill new file mode 100644 index 0000000000000000000000000000000000000000..a9b5395a50dff5969faee654ee66066259eaa977 GIT binary patch literal 23551 zcmbT+Q*bU!)Ft59w(aDNPHfw@ZQHhO+qRt(+qUhT`D!kv>c5()x$VB~UDfN^OF08yt=cl{d0$B6& z%NUEbwN&>MCU>)iaIpQN)g<{2mkownVWWoD^rgG`9(mzL>S~YYTaT;hLT${I(i5LX zjmzM6y{+k84SVOtah=uunwO17zB&it7vK^m9+!tdtMDEVam&rbx>uviP=@I&B5}AI(>it>Pz;|}9xTJXv2ik!5&ABFl_BO4*Ro%? z6bF9@Jp+jxVB}^Snan#9=fMs7XjdM(fU@Q+7<{41^xeBcA?nmnd_TndvqmILb(H4B ziicu{X{)CRAAz_n16c(Fl%8%!x2m6#<-*CvPJRozXx!zOjYMsOJAJ%C)$f1W*~{0f zcQ6!~70zXKTTE|f7C&ZuZ*61n&l3?)$aeOdAs_jox_&x?2jSz(8)#YRkmY=)4F zs6wq@cWLP2T2$J$;iGW0RbBsN8rXJg$eX^@*%`KWg^oV_Jr+eybZ}f4af*sQZ&J<3y6!ux({agYcWTKz7eu9A6ZS{BxY* zLS$pz)9g-t%krL3-^5#3tOv0C_UVuJz9H8`GkHKa+V*MOIjg@JWgLtb=}IvBfN>JF zjLf#~q;aa_6A8UO)6{)7P*>l}W|hq`KZQC@*bhZv+4i67I)2SCjg-Gzy|P4o+Pu?& z7G(ZWX}L$@IBTV?-AhC1ZZO{UWJ~iL`RauRz&4mRjQp{8)BdV*6`j!F>azcUCq1Kh zquqL5{MI_zPsUVA(S9L#JcNM+#b@&<6Gh2HVfUs4F;l##Y@8v%kB%9HX+cpin7_UV zK2q6Ar@9|BK|B{IYb9v&9)L2?4?jvs2mqZ1c{o?KqM@MCl}m}8ecZuFk~UQI9)MQ4 zI{x<{@6WW+=&lEJt55sa^a#|DlyK6EZXnKT0o0AMa)a$^6VlJ-G4&hw=W{oGLg6W6 z5f2FmHDy#396Fy#4fc=tGrFsCVDbHf;69pLX1l2{pKx(-PkY#RRK z>ou1&k%tPZpEEI1>N`S#$%BS#9)RcPcz#Xp@2Hu3hn*|CNg5@;VDL8M)UBphQeh(ff1@s(UfXz3{FcR9LsJW~D!Iz(05L`q@`c`uaKk_Vjae zbF*{IXzT@r)kxSC#{2fp(=(tUsXuB0(?k`+My)oAxf=4=56V4lR9it;w!rlOtPo`a zctEo++N_t0ObJGLHi%x&XO`$ z2}O&+3Q6@>k7^jG&ubx~jw?w_KtLFmGekE8nH8DEyi;UB!{u74uxvgIy5Y5X$z_Bg z{cMPpNsU~CIP&|D_GD{MiH65ls&$|38Pdn9T9H=L#97Sgl%DRk&)4q zDp=kHT~z$PxN7d7Jf-@QSIJ|WxqC(!ba%pUon!DT!Lx!o3`FtC=`M(TQRhCpzYzoS zNo&djE_r)qNT0%_SW<2iRxH2#$i1lV{Z#j^bmBJY?)mhBsi8{F(K#l;MCj;|6Hoz! z9S8T?>wU*>kfFqI4+ufztmdsQf?AJH+>Npg@+zvBA#Z1;6&9Fs>rVNr1F)|TEiq`_ zF88of2vO&0-MkUauV&)Vq*=k6#A>0X&97|B`_UZ(p->$b)R=BtNZw$*k4n+uF7>`= z1F!KDSqOfaS?xOAkE?3RwFsBOt~yZAmW`izflj(yeOzUoYVBvY<<*BG+W9D0F9@SntEX&@HH zI$V$Nq`YB_)UL?v!3?$XJ+SS(?X+5)Tx+Kl38yV~#+bXsqt21&p(DM5;!296_$RiI zut%Wp0eSfZ1%Z3c0b+DBe9YL>TyUPm;m@u~tysD|)WP~S3lA8M+V|@f%l;|uRmxRF z)!gG#_I_>}FWoF7{OO%IbG6jBo%!B^Uh{53kA27hKERnmx)O@0y zCh_l}?*qsd&{J6e;lw4TvHHlbORQ9ukY#`fW2&i;=w@wJri-FYW3YWv_0{|Dzvk6M zM)FA5LU5JfKMZ!0p~q+D|CVv02)m+NdKaAt1*5Q$9TQ{(_>h*ATyD9Fp=enzlR(d7 zH|n$cxbTze@^V@6r$H&enJ*XO{eQO@`el#=2y#&)g7;)+>iyzo5nQ*UDxSIq+&84( z27O*tRHsvLOcY;Ypqho4)r|4;kwZh{2$7`iSf_TOGs*j9PR~P+QjmTkF>O-$qLV3~ zbZqO+v9olrA$u9zXcZo(a3BsF{%U(+C^W1vS%kObhx<`rPtV4>FLQbCC(0Or=ZL@% zY^GX61MO4YhqsoyduQB_@O?)(Zn4$mi=YYl20|rE6EkjFk0x;${>sk+qwiv%-gL~1 zwrh}~U%+&aFvcCJxGbZ81`=sWB2+Y%)V!q7`NVC;u+_2@QQ!ikC|fZVo6!e&3*JPW z%8zuZFH`c0T+2&yWDO`AAN!?WH!^Lk&}OOInD>#G&67IloY&J(Q8O6iUFcF06edPU zq6355#LOZLR(f6qkk6IS`sTyH4in55XG!19GNEem^1qk=lFJ3$7|8wZ=sQ!9qC+U) z`&2*KbN;1D-H>q&A(`kEd99t^G1!`2*r`7l|7o}r5P2-(cF|zYO?7dUJ;&THZoqO~ zAGE%Y{88ufDMl^ojKu=-p5TRmeG}i?y} zx%qWtsD&R~Eit#nWD7#b`sXYC@1V;oZ zCpbI(zEi$i^lI}#px5A5+$v-Qs47a1Fx=LBJZ8TK%$~i(Z^OCtQ%m+C2WBJ z!6QQ|3E=SE)relL!L)eS#>NeJ0Jf%NNTq&6&Vc=#IS`K}ZX|35q=OYY?<86RD3z9Qy)P zKJ#E8*wjIez}_3o1`nWt>-c3DBOOXm60C)k-;!53Pjz$&6FuKXs-8&G2j1^j^R5h`3WPXQs&U2QpEc)#! z-5jRiW&i9$j=}tLMWX*2d9!g_Z9NpPXtAGCG~SM?n_#ASD;b&9YJyM|=Ip|AyPy<0 z(hl_nd7DQNna_uax2A5r_$X1-NkqIy3}Bc9ho^2gym3_=7~}u<9s(9K(O!5B{t`^m zgJdFq$Ak@2i;dEo<0+Hm(Os2;eq1tME$b9^U-_(@_TA$N(RR4O3zWQWZa!g>xKS^j z78$}7gI#otur_uI!x}sR>UWVqou0`I6A@&(EkVCnxyVkNVq^}lrT+rZ7A1b4wIt&)nk=-<>JA7i&u z%&0L}`e;K+b=kZS!Ae#Ywx=bOAHG>%qCrjRf%dY*%&@$mkDMJ`(#$T}W%v#A+;pU> zgZfB+mpam3Vz*hGC4}URy1L;N*XZqDl#Y?7Kd7T5bq1aW!nhC?GO-&mUj2pzK|wrs`#@t@kO)X{gdx zZz_>b?r25MRw;3#_djb;%XTZT($dN~t849ey8F>;qk>g_%S_Z+w>U}f+NHXbt=!O1 zFKe}@Dm3XZgpTMxw^pekQR9aY$}qG&617Qv$n>Ztxmi%YTCc!RKi<*X`*-*NcJ=;w zIR7X+dHedh`liC(tfbkgEFOyOT6^ui=&_91)h3&Z@w-l~F*@vRx)TH$v_|0O;>@#s4NIXo9_81fac|hkRze9xQ(1G(-GE8u8t$Z!%i74GA98sGy ze=S}?k$o!gKqLdufsQ4fe9C*akdNZ6tW@509vi$L_(x2P)0#Aons|?>>J?Jg8sUme zD(svjF;~^!{i)Vs7LSC7Qe^E#2?KhyQ770$b`a`hUQ;EOdAmOmUaOQ^#T=-e7_dmw zsG-tPro3Y0m`L`K-S7_~&UfW%>O`8UX2JYZLa)c>xoYRIw#8w?Rej1(a3lS`t?7e# zlG;~at5en6m|by*TkSCR*_z9Z@W?mI&-Ag;CN`;8gjM1na5%Esk$Gc}Z%e$-ZYo`$U^3ScGUH zP(e*y>bg}wnn%{RcV>>+QNHy_@((h2Cs-szzD6^?&jbO|$IUe9G>~UoZlj*crsz8| zn!FnEq2JeuI#=%TUDeDQ<;`{vcG_(I?ND`o?y-=Uz3YNEYY)!B)gOemg&X22D8$+e zb?d+*@Vo|HI*6v=4UH9>i*_Z*gccg;En*uK`tt?1yvaL{;$ONDy|V3578jP371o ztMZjRm9WD1A>u!#!HX)66n988Hm;6d;^^YtB1*Z=Q?~|Xxj02fK`Pt{Eqyygty`YE zm_U&*v*N9SJC!v$jg=q!UWZ;N1(=^!;1?EfTUtBzqcMKn@odO*9B3Gwx7ZMT>%sb5|t z=ga321K}NZ4GSj>F@Y^%FhOzDSpN`zic<&;RBYAiM5XS+i*^=tob7zfDLl_*0yzw} zJv=lmh+xx;^nn{~iD7bT(-Ar~3pfPIxRBs*_2tq}$1gpA7reF7*R(*M>r^NW!TC_^ zvSQp7$eS9Fg;1A#Imv=p-r4?>`dBdRno8Lt?^*aSQv=&Z9JvM19Q<>#JOpx&4F9!rL;=D*eYEASDk};1gfwQiG9xO-g z3+7bWq{>$H{j1q%qTEPkXdb^i2z}E`J2`Cs_ni)pzY6UxtkyaVdN0W4?dhQMrA7-z zt@@QvHH1y})3ryku)*bq3-loEXIIT)&O{xESq)g!Ll95=4<&HOi+3oi7toQU1_B4p z4bZB@@*`dYgJa(Z)~6(V2*c$elf60>2OD%2B|%cGb3S8+D=~KZIo*9Ac_)t$!Vibg z88bnG34tT%6%c53*GTPucpo#!CLWeQgo`1f4o0e&LMJezpG4KVa)bav+evr@d;tw9 zjJCflQv@?0@F+$8h1dp6!KG3(-3Q18e|)tinQ;XlnB1YkQ$60mnQo+MrVoHggL+sX zyb8JUyp~F1H!IB|YqE`6k}D3z@)R2)sW`u48gLnTVYA+TaaVw9iVb9CL$9KVn4z@j z*s6rVaRPxHePrI}6#@tgo`-;9-6VP~q9;Be_=dtZvikx_d&QL8a9S(xh>%(dMkhGX zg=xt|j7?H83XoH3y=a_bMf8t%)D4^Qk({K>BaEQTPAn{A=vNxS9|imsO7ySX{+Q!D zt9en^>~rE$yRxN(bF`UwBo8akN?81w)i~dsHI5FV(qZNj?b99)XFZWhlz*n)0zzG3 zx00aE7^C;UK}^q5ttxXBoG&;vdoLqV;K@zAM4()%2v4+96$oPdjJS)Pe2}Lc<&oh( zE=<7ddO(1nhfo?*t|vo+C)!a>?|2s9P}50eQxM}4PvJlv7jZoZ^<(ng2 zBEe9)l*h1wPo)Rt3YG361cM+%6hOk_uN(UH0#riNLNE2~fh`UW*HzDym*JmPbL1dK zOPPjLTXO^ySY5S>)Jxi2phTvIp;-x|PUx_pDYm3oqoQ$c@J*!M>z#7)zUP_Ol+g1( zZ_@we<~Z^OITezqR*}_VA^k1B%X|+IzHA{76lx>e^P<%jpBa_Fz!nT9ynES~9MWNr zpXMw9w$geTjEAG>@uohH{=N_xfKJE9WKqiUFpU`vnVQbYJH+=i^}+R&l5^xUS&Jb7 zPU4T-9Q7x>gM25hqaLzZyHZoTU#l(-`&7%^hF82i zT^#zMCf_?jiPpjW-|fOK|2hT#=I5yguVfv60UQzy;?39JQwtu2!HV(lY|wrPo02X$ zQ^Ar$8|?F0IksM$OA8?%qeqU9^uy>VYcR!x@SSh_#?d;taMGFOW>(6(^ByrIdl%{LN9u&<5ZPi17y37S)Y}5%sVx^c&9T=#fDGu5bC1j)6hj29S2^&p^EVb8! zO#($&cNAub<4W&)Qs`O)Immx*27<-gB%k^H7EI2f~q6HnS>PEJxDRt;1J zbN@cj;gCG6M_7JXp$39zXDX6AI-IwKSa5=b{BuzBrNv|&a~MFxwGs<*Px&;$`1YQA zAPD4T4R;^pH%0^@$p=DW)suZn$KBxlEMm?x?-94oA+DqJJ}5&L5^(pti*7$0XCsKm z9K(L!PURwvbv^5`=m2hvC`@YILycUMEMX@% zg-+x{9%;kAxS*2MKA1 zz2=S8+zA*frof{m|7&vd!9Dva)6sTTVXJ6_@Dy3%_8>nk@fKU8WJ0Dpz$=qb15Mo z9Z(vDN9Ma)CH-^D@;viY5J(2EIs?ph?e@I`Tc)w+p3+DIGU?w$lJlzU2sq70|Nohz z$R(WzD-nQz;)sEO1pnW2l(D^?i<701tBa+bIl$Q7*1^Wo@V|idU!E$}l#M?cL+-s& zXK8|zZmMuxWrDk=$H1=deKlHpH83-#WQ;M5wvl{Ju*s5@A{_>Hnk0V1bN?HewV>&AFFDkrLFq= zrjBm-pW`_-!YW0!t?bWOuaowi^yy};K!8^@}lLmI3H&u^J?A_r%PPhI`_)>4&ZO{^& z@Z^DEWAJ+GskFoP?b=gc;{M@x4p|IQe^)pLre&{mLfQSp-%4Y(W%Sx+-8jlg?w>vN zHdamB$!8V$@R5~XJkajlH309Dz8fhVWXlYOR1%bxjK>coy_DnG^A zb{j;^nzXSg@3#%@z4tZk422jeQv&@Rs?iGYw0VSy>$f<-l>w#7}O{k-6PT zwjo{>(C~c;^zyjxg~osV_VSX?mAj`(6#t`1iED^TkM)~oZ^yic)W<~Iw#!ii?E^!I z$|L(+S>s2N2v#$n5u|k&jp7=*d3o-m#-;5{$|rZvad0F;F#S1)tB#tt{dkE(+sl`* zlcna~Wsc@8)?i-NM~_&hAzhPu*IcVx3TM3~t zu@f}6I%-A?MhwMpS#wRU!<`h*dFTY8;j@J~0MSS3H^9rU%_jY1x?tFfoG>dj*OT>lC4zV5YuLzzBBb>c<+3Zwfz?^|6c;k{BmZ# z@0MyCl`{N;B{0|P%_G)`s5S@k;OS4;<2Gg{uqNt82PKR%6)fe zqo>_sA2j}>UTN8bC;)&knw*FHI7+(dkwZ`R*P-H19?EQy+)DT01k%lf_>?`VYGc_m zXg4j03hC2Fu*Z3c0|}32rg+GdF)3^YJc;O`;*rxLm^1NqC!<}bW){nF`=mz}^+V#6 zC%J#mKRd8la|G5H5vf#C?}a=f=XjVumT~B%_DTR_qei;G>;iqnL1sutQ)c;M?C_)9 zycZO3y(nXiX~$4LJB&0b9{U38(+hI#Eo??W&iJbD>PsyEIglRW!!kIkKtY9}MJUzE z^Rp?fcUFp_iCkf5F?WvtSqIj;x^GrdMN~GA!l9)Xnkr$E6ni*tI@5>Z+{NAX&J3^=1ns7QyY3RagYP1m0r6&cSO+p^>Vg z2r+hgG5mD3Q;ZrXb9ZZaktcZAsP2I%vWvkg9Bz5`q)PkgU>ox@DaX~}KJ`6N33;1o z&yO%tuGn|Bc9DDzL`d`}k=`_rX@kDmRy%?0a&0Y>`kNx`fNfvbUaymzrDnkASTt=m zPRfHrcc6;;BFW>z%B$|sLLf8Zx%6Xx&EA#5z4R)i7?P~e*~0*?&_Zche9#kWs1_gB z(z!sfsK{PBi7L|inL;;+rtOD@_mk8tp{CaQ0@q39E++|FZ$-!V+G^bA)R@En=NUXx zoCC!#zE>C_?73LGc3Xv>Xs@_ z1IHQ>a#5UH_mzYBJz}ra^Ppx`H{@FTKqH`gp(E2Rylg^%6!Iz4`YE;6KAjMA$I&~O zz@gdNFK>`+9Xvcu(BU{J37}L$-5%`e^od;#&#ZYoZStrxcwB^mO0I{|E;k-{ z;rW+~BtYX{>N(vKO)U>D2@_Q}t1P$sih|u&b))TlHus(>*Y6JBF97O%pG*+mXCNKX z#}8>!INxZ#i1!7sGWT_3X!u%di^F&R_v>v#{P&PK>~-!2Zs5Uf90dqBxo> zJvrKfq51YIrhdXBt2^=*YJ<^JYdh0BK7@>sVke@ZWJ*FU9P(7hBU4-f)Zfh=!}NH- ze8t!-a7H}YK|oE>^XUWe-{+m@_Y)@tzL+1%y3gS!&vvb?z8fMs2PKZKXR|%>O+^-iNTQfv)bc(LY01Feeyplz0PJ-V9q;0L zS;NaYG>)QvdmWnAWkrI}^_BzKwXL>fJnLgIy*hdGaK_)p?fD8%d3GO5Q}A zVjQVGeM~>iD3X^GNWrNx>9lY7+1|vJhQzx87$>Rx557K!@-!Ku@}o{*=%RxyEHW|2 z_0`haSC($3HlNB4QCP>Hz zVCH3emeQHx14c|f)n)t%`wS$l%>(0%AfpX;1no|Btb(P|;iKZPcyMX+(g#%rZ&5lQ zS{J&bsD)q)g%rGcDDN89a{>Zc^goY7!+pKDXTUlK^nmB2i``DP0P(Wg!nvS9I!MP9 zu=_67%dCI$%1p2RMDgm;BMSGUz#m#KOg&IQ!_907HT1t`?T5H@?wo7@N?Gn22}wu` zEVFr##}ckkDhbLgyy%x^z+aVhthw)R#KKihfKDz;kTwJ-ZuS zA981TLuj#!;C2J@rK!^8<12^VTX(29(?>T=AyrBjaCsO_mw&s5Zk9sfI2Dzj`~qP# zei`%m<;@Yn=WrF(N-g}{o(DKJ<|P!s)$2k_xl{JDOyK_edJ`+^`+i(sT2nwsy0if* zPme6x!UXgR-7ALZ<@WR&Ntn12*dllDZS9BCNEJ=?H24o`-2a`|87{KYX^z6S8~SmM zw9y0{nsITr-e!C3S3K`aPZSMO+QqvTy~s;R$|bFgee}mQouun}Px8J&R{$;dsmK>W zEm(?$T%fpY!?T5ql`;RDDpkM2Wk~=i4t{_yu9T>IK=B4D?jJIPsTVQkHso#fEo=36-AZ&Q?gASR383aiqx@DSv4sBE57Bk-wFFzTDsu z-!(PxC~&vcN3#(*EdxdVDnyK4?x2%4NRvFZyyugQegEsTT=9{h6*cr zN8nzh)WNbQ6-ae`=4~ezu1_D~)29f9cECWgbQEcY3ukq?i{VI~z63sq_Iv3T?o0~; zz&6o6^io72H9fy^>smwvaePM((-OQ}U-in=9CQb*aYw`iR~12YZ+!wa1#YvITEuBK z1s^6vjW+*yR!%+ug*G^orU$P92lc@UsXiJ@m%^9?dTNJSZ`ce{E4nP<*^^5T6jOd} zcY)#<5`fP^%IhTv6yuKs;w{<3ldgzfGPN)Jqqn6cHH$cZ)bddj#*@RDvh9rS!l&?6ty+*^EFpf6p@ z)03>xf1W;wzB+ri!BZc`e4-bVaQSkA!zS35yiC;pcWpbChqIn?V2m)w3i|zMVG=aR(LUf1iD= z*@SjQT{od#J@AmYuu!GDO6mTfFo@2xGvTerm@%Hu=ZF0@3)MJiXri^%h+nBkT`~XtsJ#z&0 z&7}DGIFR1$AMq$mSc}ue|5X+0kER~Qp;%jj^ju;NW5eY9J-)CxzL%Zth}<}Sbtce5 zG?uD^_YB0x_3`!h_^@^SYw_{>e7>P6X1QPwt9f?Ke?hp@Q(iJ&g^#W(wna97x%_`2 z|7Q-iVX{9*xKhJ7!(ceG;`m<&+YLItF9cI znA}MMT-m|RFV_9U1LkZE5;P;S{~qtGW^?)A|25zqbxKq2Rbw7LI>22pk<<~o`FozkhJot+rUb!O!i4TV$yxg(*yEs z#>w{#c$PGtR%aujMuQ|u&YV>KCJ-eybDmLG$5)_~fSLeYkXjZ52`lhq<~d9vie#Q6Ehn&|N_U+6 z*gDJLfbAUVG6niWN_iD3PAojd}wrS&(*#FLM zlPU2osuB|5W__5)%f;9oz)&Wx70{`$jZ+rEN?(Ow@EEGKH?yAAtLOf{kBNrp>)n>+5~vh^~aZ zsC?=Yogd~33BnZbY~oHvDLGjT{sQX_A@ux*8@!eDcc{=tdPu~xXC-YkeG(-`5X05o zHUxb!J+3gF=Y!MQ_zUs%qmdp4j~PcWnnTfyATJ5%N0zoq_+URE)}J6Ay@=t>%TC;6 zWW|Es9c?nedYK2|E?e9hTlFvd?JisF@9mc`ko~evJU%!n-*M59a%l!)Qq8R`Te`O3 z@JO`z+qYIZM8HHoPb9%+LNG*Dy7JJpF)oDM0|)<#E#|(zVAVC0MrBl&5#up_*@%9<1v5pGNsM>ogj|C ziA2-c=|gnD2zo7RK{jSunsk`SAHCfSckCn*dIn35+#GzU=A$iTW4Ic?)Y zV-^oNm?H(U7VVLUZ;6xc%6UT|~l$ceo5T3UgQ;^31d4$d;E#H@nFA}*p~gWOLi5sjX6 z?3rUQ9e{n^K7`UD%WtSE~U4oVQ9Qnr4|lfAyXeMkP0=x zfcn1&6X3;rRPCCNvcT1l>|@liGzi0W%?BHlAmvZ92;cCHQ3*E9eXoXcon) zk2>LCwjm)zCeH9_hB`aWIz|4_8d+QWCQ#}i-rKddX#d{3$EE;UKWAd}6rgZo{1B{5K_&L<^YiR2U45jl&7Paa*hGs4)aHuJQ^7 zxx{rH)Fcv2onl0rNW@q6Ey-&YaCnGUa%D?dC@EAz*4v`c^ASsz5c^R+)FfH-I$hWY zaXYtp{ClNHAm~*l-FcwSP~pajO~Bu5Koq7T2U?MF4{Wsa1pKVjppS@ujNtz1EhcSR z^(245@PSDSxx5e$G^Tp7vy0!?%Dmo;r^#&lL3ulhN31QFb{h3E?h@eTd$4Q-lHXGH zrESo~99Dbgp&zV5_MwkbLSqM!vPm-5K$G@n8T$E-u828)4=9gt)+u1RSGNiPZbqw6 zs!KooRg*=pJg*acbN%|X%JcVjpU&~_`VTy1*g6-UEM%`IaHl(?=D^bvpP{#pB zEjXML?f`%bm5A@;1P&W!=%lz?O(ek{XXyyI#`c>5za1`7^+28-R zLK`M1psKa|6xe&j-Uo(L9izdA^%Ux{_hJ*{e;&l-1$XF7CUL0QZJ|f=jq2mp_o6S> zU|z9<)S1+!3Ki^y68${ckO}WaJD9$kuDXb1>T}#q5b3{*0REc5!(h6{DakV8bUWy= zQ*kZTIL}fTev-k5o6#;aT5IfzZ-fZ ziFI>YK4(&-wiKw< zpUd0HP10#s!*sf_jM~S^c^j-&rY;k!bq2)0tuPogT5=C0@b)XiULA`rXZnEJJOc>H ztf6~@X1ql|v&*#in39FW3e6q0Zh#@1tojCkKVQa0z}2*#DUp!o@0A-7)Tg4^-GG=pY-Aw5fP zvvt|0Fb$|1@D6x3sG5~(V`1zl@@w1&`ZsyIM2C?Coc03Al z4)ReRbd$Y8;ybbQ^t1F5OY1LX^uCMU<+&D`sBQ?&F6CYQG_!@lf9FQs)er(5&oa+b zxijH3IOmGLhbqDg<)kLx4kF9S4QsI3j6^Ic0+LuNl3K;jV;29kBmpbWT6GC4MRDxi z+$(yY1~wbvZLDH#OevG4x(=pBKECBf-R}q=UcsMLq+f0wucdpjV6S>6ma#Z9=dgI5 zRo8C&af??6cDy^P>}K)txU7}fD%kF7KG7S6;MXOb;15>jy5sdjAUFl z73o~Ayd_*fKfD5O+KjPYKL{swQul8TsgjmL_i$tPOLu zp0DbZNv*9Y(QQ{-SW5s8b9tRfm(Avz1$2NCwF;g5E&uzg>0@=p!3bd4wHX~~2F1V_ zv=+<73_o7esZ22^Z1z&lu=yJ|W8Zl>lLcN2>Lby_zG_IO`p*s~t?c*Cjs|SqbsQsC? zqkQ%=gW}SQ=6n z!IGyhsIKOW-x!x-OP%4ywfLYL^r?Yid8O zx-0OE=2%3Rx6oQ1)Pulf3{lke!*@kiRnw@j2|(N}hnq${y}C$xY!E7gmscwZxn^z!# zh%O<&38go6O$^1HvXsI>I8B9^AU7&ih)ET!I|OyZY23q0h&s{F65EWe>eI%mB^f54?Oi0&FB)GGgfLxi%TBsGF@%e=Wd^C5g&mCO z=)OGOe7yX_`FPxnhLVs$plyHAU(qsPphGO)s-}wrJZ5$4{wX_E>~#D9hQ0Oild!!O4f!x zgEt-2aO)Ir33%i8C^a9{CKilWSAwu+E1wEDSm4ULkfYf)0&u5vugj6*4v_eAPM;{=#d_)RgBI&A)@=9F8k1Lz4e24ioQB}YNoY+IZ!BdZwN1_+-%i1RH>E< z@|E3Aq=y;|G_=xc*jN1~@k3r2z(~fvo&lS?&0sO8cm2bMGA!6;U-(fy;G~8`&yd}) zU^+DkNM1%>^yOOOzP6~SA-9g3b;zcy zI^UtW0nUY&Ur`72GOFTg54bBTlo^oTMjFs(;hfV#sY5^-yY2drxXJ!@o>NgYrM0U? zc1y~k)O{$QOH@XT#tH*Ys+DST zsh%la-j#a5Y||&#l)K&(3X2^+lG~_tE{3=;E)Z< z3tm}^*U7YJrCn?6P%E5TTW?^b5R$lm zIr$Ide@fjt-hc}{JP?pPEf5gT|A*8G+uOOBI=PrS{bzN(T02f#9q4^;%0xLbGHlGL zw&j0pZKhww-2PW5XBiY{lkM>t26uM}?moCf@FBtNZlnKIcDUR2~BYA62iIzWtP^3u^BWp&6CRWX~S%pVqSExT=_!gMUk^|8TD=I7H7D zL!&j2xK5{8u1~YkFSRjHxKv&q(hKlFCTD%=yVV!aqSY3FlY|LYOx9b{43TIPLM{2?lF! z!7-@A{acWPc!xm-RRAPS5z@k#OIU1Foe#cwP4~9@H94?JT9m_P+m_rlR^&AlJ*+73 zix82f(y9xwdv796fckCO>TVusb1en-$43A7(nKT%D>5uwh0=j^YM1!+I9>r3J^lqC zvMah_oIV4S3QT^0@^PUtMSMt47CXuOl))gI3A%>zULtcF!WyH*a5hdW^|ar{10UXc z>mW`KRKt(M8}aOq=P)q%OlydjG{m}Ngqo=;E70LZ4VugGgfRhM-bf6|1ncjRY|j*G zlZ~w2QyBCVOtTCe<67}6^O1iDhE;U`8}VmfMGa-WK##lkxRIDbo8 zEk;c1(xs>7MjhI<(&lxJf&u|u16sKRXx$-j^>8Hm>Fx@7iwNV5Dcp0_Rz;v24B9rBOmyyizC)%5IT3&enK${t)Cjo| z_5w(?p4Y~lN2^e=#xx+_g82aa&4G$|S`^Xtit1-9ohwc<)91zqf;&n)QqRs#h0Wgz zKc?m6W-VlrVKCz-?T+ z-9kA8u`cjrs8ih1EQ5?kLDjEisf;&vQXc{R8XL2ilOart-f%2wab^nX<{OkJw%0Ji z!WzzjU+!N$4FOVvGTH7lOR+K7hk=dfgK0^Et6hcNkZbj66yYIMCru|6qCvXjb{BE#jHZ~vpDewn_-)`uJ#Q>}Jl+zP8QHr8i%JmkaSrFcDHNNJjL zr6$*F{0a|nk4vpD4lRp^ctw?dFmgn!f@mpAt(DM3WvaD1azIVLVgz6W+a{##jaUL+ z6rUPkGSn0_+825WK-x5mZQQm`PSUJs8P29_#)?HLzco(i7ahJa7}i+Jjv#p&Ir%Qm z1cV40c&>JobYc3dpG7uCqs2rkVq=GGxU&}dWYgA%X>VJki1{K^>L`&opuSnY8r?Io zPfNa_W<=->^m{^&9~@qycJL8$c6zWMu$bMiHZb`_7_rbnN>who0`q#Ai1u~f*0kO? zi04KuCxQ$9f%vE-&hY$Ums11kic!Rfe}Zk)|LHvr&B-V;_#@Nm;|fBM3x0n?bd6|B zN9Gc;DZwR*hBFTvt)x1=Xoi_zwe1JlngAZ1x?izY9b*K~9E+Pcys-;(w&DrVu>+lI zPSimGF=!G($#2Z&C2^eV3#J8(Kl2Vc_2T%J6G0eg&4wI8vwa>QO@8|^@sqODq~id#Gc*-G00+t`xp3{#RF zZ)pN;YF9yKC9l0>F9rCsfmf}{UPjCav7LFKgE-kFpy!S|OrzMw=CSA%MUpMT(YuMK z#&;Q=S@0f%4eit73&{qf<6B20_<#v8v@URs?2L3Y0{u=uGo#=|DTdP{(n5N(f0xsa zr*S45eOa1G|NW$TLHG=rIx%3nrUjY?v(wbOV2t<09e|pfM!o~qnUq#+#p+h{V?^fk zDHr)8X27T>Uj)lGWxK){(iks2C*`~zUpLe=m{Bo)cw614J>7dMJ- zUTMr3dkm=;GQL>kS%uUc{_!zcG_yKOU$U@9S3Zp+?eNd!%`I3uD>I3)HZz^j`%6aB zHEX#=H*#o{@J!M9PmaN-JfvY&T#<%ftnQ2*YVf!W-0w%jmFQ zUgZh-D9Gq$%>rhoy7|l-im-_$bMTV)qXuh>e=Z!>k}XwX)l>Jxg~mVa`Bjb8nEGu} zoeP8!mFXm8IMN@Og1^OA!udeo>|1co=3K?^T!tV5<%PyM+7j6%MP0vi7w4zo`?+uP zJG(FXbruB6`M)m{TAI@*nbc_)V?)|zAR%E&%zd@~Xj&uGBdP7hGwC#QM-2!G1B;F; zA6_;l%+zaUY)JIQ-p$E3J%pcOl* z!G$d5qO8i97zDpi`RJ~^;q|VlTLj;KEUl^5QxkXRS!Hl`n!Uj}q${!Sgr1OqNHH_6 z!!P&cAF^~vBCozqP}HJYZw*%YgNjKq+TPt5E7XmVOq)i1w@X-&qgT|h!&(&_#0fi- z!x;o<#C))DM7hor`fSRy>7~PL6yPnbSNPEK!xUNJ&_i33>+KzKaYIfK?hjl8$GGnW z#{P9ls^>7TdMLZiAQ=Vs{!};OW@ndg)H||{*GnZ+-BJ)J4;stO)o@@!sY{iVlTl*l z+==GuyV5Fta^?*M@sfVwkOKiOJ%xR%R`(OXipxF}`ngSS@`|hXhd64YSQ`e5yFTRN z@4w_999%UtbnSnc-Z|An0$Xx5ldvI(CDRC@2|1|hLY!`pV8g3{Fr`Ap%@NTI+juT+ zeFMz0gxQ1NN&QJC+)GFXGYE_}?-Q57rLQzSDt}k`I37y9;n1@9kczm3X~*IEc8*>S zqra(PWT2EO9UgV{s)IG^YR4!W1Sv(*L+LF_y70HeEx{gl4wZHF(mguQj1WUtW;2$f zNV(VTAefsZ12;vHg1+WhVe_Try>E4=wjFw?u8DQHqgbS*JvLD z%UUml?Nn=pq>XG2E!=ohy*SaK?m|qaf@g%dsX{E;KrU^3PJYNXk;1Mn_=RUy$(6^| z1q;K;Wk(!!rT_}ifbt;`gL382vUt9ET;D-(yIcx`F}5`;z1a!BiZ4FRNr9a~`Kj80 z$Jo5BeEzZs$jqr%TK)w5$1uxnYJB|-3IKph{NE)+Ks{VsoZZ}i2U$saj?e`K+{Ysm zS4}Y)@=C}Zo(1VMrd+Jpph^E7cRuUg*I$)9k6Hj<`}iO59zq`&ms%3;>4s!3Az_Y( zN58w|*w^wcbJ-*7AC+{iW=xMZ!{En$o592*&x5-SJRjcE7=w@gh4eNZ7l}-fiyE}t zM83Ysd%Ue%1oBv*aN`Qa=sHgH5-VowZo4l{WNPEoP!%?F>LOZbPv`{002$G3q&V#S zSvhT@ja-7FIUyw;%{7}CRjifDc&3d4EIEj7I^7qzAqOV6%RfPMc`=C=(T_*g%HUwv zKxlSpxzsqenr04FUfq07968QV6*@v2ofv7o%0jO{no4_8n?vWflq<&b;VE%o@FjU?O*2vGp14!MSSnnGqE9<>f4DIaABfRm8zmv9(X$Wlx;q9J%4foF> zC*2asrgl=_ZnLC z-t-sWO6hzSQ3Z1++Dbochf~amXqoT!#WJh(Gi5F$`;${0wbnQsB?X-h84FQvFYN6@ zcd(@4}q>0AtaSF{@c|lNr^DkHV3ApVU(o5$dhQ(gY8=aFt{g4 z$t|OrKS}(gP}_bA-+-|{P*yIa_1sfYPdGN@heu^T#IRrl_=%k@{zLNY9z~NQe`fO8 zEH&XmsgbRtXIKIs_eV}g!XNskwz^jOp5prs-Z*ILY|m-2%R5VRE0LgjM!wj z<7%rlXzebAQ81CwrlJ+`@w|-aKsT6p!y^5ZvVCzDg?J`P?3JMF>-^6rdy9j@NSuUE zQBTV&Z}qBBabZ2aO2c?$nXwNKBTzxYQxAjrMQ=C6`hv}x2v7O)6%-h_wMYpPY@!aX z$E2{Gh|M@orDMlX%yD-7OCs4~B(YJ(2F4xNX09 z3XFVYcvM7)WAvS&G~p1~h+FSz$sG*wSvhQRLbZDVDxv@46UL%!F3p#|ztmB{ zkuNATk`=86vDglMB+`j?srW`BFQzf}QIYnOyvRs=i-^&|;hsVawtc=W8h+>2t<$dq zOLPLs>d)zVlzOdx{k_0;k@7&ZiFyCR)GG9#^Y{hWgFqQ0Qs#MJM$8+?(fez9QcMw1 zvTPGyI{X=UKOSW@8XxY3NJlyf$>(*4zRcs>xv|^jHzHfJy1)X@q^2}B!-aHgwzF^T ztd|jkIQO`k9@ag|8TZFJj9`!TWdy=C6eBX^S)AnHXt|j0Y%2~AcLA%juM752^gazK z1BG@mQi?A%0Z8lDt-{9vN@;>W;ZzoynQAV+5~`E9YVAiUKe`RWA>O$rvCeWruRR|K z2ZBKOt51q<~E&Bk`|Ytka>Il^in&>c>6701$e+z<cA93hIxE&3`cAWP%tmMp?snWtr$&}^CLDvEo@x|WjZ&xL%znj6@H zPnxh!*uzW^%1rHIe2xnkq5S5B3jZ)!iNh=~Ohh*^uSOjL3Am?g?(ZrxkHjLf!BKEc z?xQE=aqo1&%$5&)dXNdR~qZlj{@G;1oiVa#=J?JiWa*2NzaCE zOu=k&L|;3%9{PPbe4F|DD<)-Qvaf5)ptdM)4ViSV#s`_Zh4y0plA^2F0@dn0G4QOj z-mp{_v#F(Aup5CxLQ# zR)0Ktxcv%uZ78j3X5Cjboy2?m^c}_;%)YsK{1MsH;QISViDUNKdWaZBCS&7eGkp1> zW<51B1WfuQDQx-J&!@+PFuxNlNU?;M%}(r>XFcaaIR2Fale`jNdSJ?s=~h_c$@_=t5~es-xC z3;K%YlJ8Su42Gfc!$7LNfsee48QQGSeb^o4Brr#6$-`s>dd01QiKA<@}S=m|}#qer@croRF zWv)Qs~6*RbxAGPwXP>^%is%OW|MU3@4DBMc;3o-D813d1mdcE-Qy8oKfVFmeY@ zR#TLWZh6e9Q|vR2G*2DWx=tcjs`sw?{6ruM*E5pA_nq?8LKKCft989xUZrnbbZ613 zDGX9FX#WV2XCyF0SDV`|bi-_@{wnl}u0=(%^Y)DnTzFiW3$f+%C?%TshB})e>W%y3 z4zcJsq>&NY6}2lrc0DpH;kTy=PwAWsJdLZ|==%1gp$bGG1pRZl;@`jOe_KJ|--drj zy!cOxf2t1spFIGeATZ*$Eb_my_%Ahx{>1!Ql=~NElIq{a{PPmsKXHHNKL3SV;P}_L zf1L^aC+E+Xo4+`r(*HT<@85C$B>fq@{6#uY`_D;#g))C~{@hmo;xuXh=bZn)$^Oav ibH)F~i_!hhd4IqDHB^z1|7H#KcNYf%0Ofjr`}!Zmz>v8B literal 0 HcmV?d00001 diff --git a/tools/NanoMigrate/nuget.config b/tools/NanoMigrate/nuget.config new file mode 100644 index 0000000..73deaeb --- /dev/null +++ b/tools/NanoMigrate/nuget.config @@ -0,0 +1,8 @@ + + + + + + + From 5ce0c08143a7115c427e3fc82890da689f0cb93f Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Mon, 15 Jun 2026 22:07:24 -0500 Subject: [PATCH 04/49] NanoMigrate: exit cleanly when there is nothing to convert (reentrancy) A fully-converted tree has no .nfproj left; return 0 with a 'nothing to convert' message instead of erroring, so re-running the converter over a repo is a safe no-op. Verified at fleet scale: 143 .nfproj converted in one pass; re-run is a clean no-op; a mixed tree converts only the remaining .nfproj and leaves existing .csproj untouched. Co-Authored-By: Claude Opus 4.8 --- tools/NanoMigrate/Program.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/NanoMigrate/Program.cs b/tools/NanoMigrate/Program.cs index fa6351d..190043d 100644 --- a/tools/NanoMigrate/Program.cs +++ b/tools/NanoMigrate/Program.cs @@ -52,7 +52,13 @@ private static int CmdMigrate(Options o) { var path = o.Positional ?? throw new UserError("migrate needs a path to a .nfproj or a directory"); var targets = ResolveProjects(path); - if (targets.Count == 0) throw new UserError($"no .nfproj found under '{path}'"); + // Reentrant: a fully-converted tree has no .nfproj left. Exit cleanly (0) rather than + // erroring, so re-running the converter over a repo is a safe no-op. + if (targets.Count == 0) + { + Console.WriteLine($"nothing to convert: no .nfproj found under '{path}' (already SDK-style?)."); + return 0; + } var allReview = new List(); foreach (var nf in targets) From 46677603fe67e4fae0abb7b3f8b907372f7c553b Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Mon, 15 Jun 2026 22:25:48 -0500 Subject: [PATCH 05/49] Split the SDK props/targets into focused modular includes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoid the large monolithic Sdk.props (~150 lines) and Sdk.targets (~478 lines) by carving them into focused modules, mirroring the POC layout. Behavior-preserving (relocate-only, no logic rewrites); verified. - Sdk.props (76 lines) — thin orchestrator: keeps the DebugType=full Debug pre-emption BEFORE the Microsoft.NET.Sdk props import (it must pre-empt the SDK's portable default), owns the import chain, sets _NfSdkDir/_NfSdkRoot, imports the TFM module. - Sdk.targets (117 lines) — thin orchestrator: keeps the IsCoreAssembly GenerateAssembly* suppressions before the SDK import, then imports the MDP + Capabilities modules. - nanoFramework.Tfm.props (72) — TFM identity (netnano1.0, .NETnanoFramework, monikers) + compiler/TFM-shaping props (NoStdLib, TargetingClr2Framework, AssetTargetFallback, etc.). - nanoFramework.Mdp.targets (412) — the Metadata Processor pipeline: auto-injected MDP PackageReference (IsImplicitlyDefined, 4.0.0-preview.94), _NfMdpTasksTFM (net8.0/net472), and all PE/pdbx/resource/clean targets. - nanoFramework.Capabilities.targets (39) — CPS capabilities (remove LaunchProfiles, add NanoCSharpProject) + the NanoDebugger.xaml PropertyPageSchema. - csproj packs the three new modules alongside Sdk.props/Sdk.targets/Rules. Verified: repacked 1.0.0; SmokeTest builds under VS MSBuild (PE=NFMRK2, PDB=Microsoft C/C++ MSF Windows/full) and under dotnet build (PE=NFMRK2). Co-Authored-By: Claude Opus 4.8 --- src/nanoFramework.NET.Sdk/Sdk/Sdk.props | 114 +---- src/nanoFramework.NET.Sdk/Sdk/Sdk.targets | 395 +---------------- .../Sdk/nanoFramework.Capabilities.targets | 39 ++ .../Sdk/nanoFramework.Mdp.targets | 412 ++++++++++++++++++ .../Sdk/nanoFramework.Tfm.props | 72 +++ .../nanoFramework.NET.Sdk.csproj | 12 +- 6 files changed, 571 insertions(+), 473 deletions(-) create mode 100644 src/nanoFramework.NET.Sdk/Sdk/nanoFramework.Capabilities.targets create mode 100644 src/nanoFramework.NET.Sdk/Sdk/nanoFramework.Mdp.targets create mode 100644 src/nanoFramework.NET.Sdk/Sdk/nanoFramework.Tfm.props diff --git a/src/nanoFramework.NET.Sdk/Sdk/Sdk.props b/src/nanoFramework.NET.Sdk/Sdk/Sdk.props index a9892c5..6fb2ede 100644 --- a/src/nanoFramework.NET.Sdk/Sdk/Sdk.props +++ b/src/nanoFramework.NET.Sdk/Sdk/Sdk.props @@ -1,32 +1,24 @@ - netnano1.0 - - - true - - - net - - - $(NoWarn);NU1701 - - + + + <_NfSdkDir>$(MSBuildThisFileDirectory) @@ -54,42 +48,10 @@ - - - - - .NETnanoFramework - v1.0 - .NETnanoFramework,Version=$(TargetFrameworkVersion) - .NET nanoFramework 1.0 - .NETnanoFramework,Version=$(TargetFrameworkVersion) - - - - + + - - 13.0 - - - true - true - false - - - false - true - - - true - - - <_TargetFrameworkDirectories>$(_NfSdkDir) - <_FullFrameworkReferenceAssemblyPaths>$(_NfSdkDir) - + @@ -102,49 +64,13 @@ true - - - - - - - - - - - false - false - - - - - $(ProjectDir)Stubs - - - - - - - 4.0.0-preview.94 - - - - - diff --git a/src/nanoFramework.NET.Sdk/Sdk/Sdk.targets b/src/nanoFramework.NET.Sdk/Sdk/Sdk.targets index a6f4193..9fb4951 100644 --- a/src/nanoFramework.NET.Sdk/Sdk/Sdk.targets +++ b/src/nanoFramework.NET.Sdk/Sdk/Sdk.targets @@ -1,10 +1,17 @@ @@ -36,29 +43,6 @@ $(ProjectDir)coreAssembly.rsp - - - - - - - true - true - true - true - true - - - false - false - false - false - false - - - - @@ -78,32 +62,19 @@ - + + + + - - - - - + - + + + - - - Project - - - - - - $(TargetName) - $(TargetName) - $(NanoGenerateStubsDirectory)\$(NanoGenerateStubsRootName) - + @@ -143,336 +114,4 @@ $(CommandLineArgsForDesignTimeEvaluation) -define:$(DefineConstants) - - - - - <_NfSdkTasksDir Condition="'$(MSBuildRuntimeType)' == 'Core'">$(_NfSdkRoot)tasks\net8.0\ - <_NfSdkTasksDir Condition="'$(MSBuildRuntimeType)' != 'Core'">$(_NfSdkRoot)tasks\net472\ - - - - - - - - - - - - - - - - <_NfMdpTasksTFM Condition="'$(MSBuildRuntimeType)' == 'Core'">net8.0 - <_NfMdpTasksTFM Condition="'$(_NfMdpTasksTFM)' == ''">net472 - - <_NfMdpTasksDir Condition="'$(NF_MDP_MSBUILDTASK_PATH)' == '' and '$(DisableNanoFrameworkMDP)' != 'true' and '$(PkgnanoFramework_Tools_MetadataProcessor_MsBuildTask)' != ''">$(PkgnanoFramework_Tools_MetadataProcessor_MsBuildTask)\lib\$(_NfMdpTasksTFM)\ - <_NfMdpTasksDir Condition="'$(NF_MDP_MSBUILDTASK_PATH)' != ''">$(NF_MDP_MSBUILDTASK_PATH)\ - - - - - - - - - $(StartProgram) - $(ProjectDir)$(IntermediateOutputPath)$(TargetName) - $(OutDir)$(TargetName) - - - - - - - Compile;CopyToOutDir - - - BuildOnlySettings; - PrepareForBuild; - PreBuildEvent; - ResolveReferences; - PrepareResources; - ResolveKeySource; - Compile; - GenerateSerializationAssemblies; - CreateSatelliteAssemblies; - GetTargetPath; - PrepareForRun; - IncrementalClean; - MetaDataProcessor; - PostBuildEvent - - - - MetaDataProcessor; - CopyFilesToOutputDirectory; - CopyNanoFrameworkFiles; - CopyBackNanoFrameworkDlls - - - - PrepareResourceNames; - ResGen; - CompileLicxFiles; - NanoResourceGenerator - - - - BeforeResolveReferences; - ResolveProjectReferences; - ResolveAssemblyReferences; - ResolveRuntimeDependencies; - AfterResolveReferences - - - - - - - - $(CoreBuildDependsOn);MetaDataProcessorCompile - $(PrepareResourcesDependsOn);NanoResourceGenerator - $(CleanDependsOn);NanoCLR_CleanExtraFiles - $(ResolveReferencesDependsOn);ResolveRuntimeDependencies - $(CoreBuildDependsOn);NFMDP_CreateDatabaseAndDependencyMap - true - - $(ProjectDir)$(IntermediateOutputPath)$(TargetFileName) - $(ProjectDir)$(IntermediateOutputPath)$(TargetName).pe - $(ProjectDir)$(IntermediateOutputPath)$(TargetName) - $(ProjectDir)$(IntermediateOutputPath)$(TargetName).strings - $(ProjectDir)$(IntermediateOutputPath)$(TargetName)_exports.txt - $(ProjectDir)$(IntermediateOutputPath)$(TargetName)_dependency_map.xml - $(AssemblyName) - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - $(NanoGenerateSkeletonProjectName) - $(NanoGenerateStubsRootName) - $(NanoGenerateSkeletonFile) - false - - - - - - - - - - - - - - - - - - - - - $(NanoGenerateSkeletonProjectName) - $(NanoGenerateStubsRootName) - $(NanoGenerateSkeletonFile) - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/nanoFramework.NET.Sdk/Sdk/nanoFramework.Capabilities.targets b/src/nanoFramework.NET.Sdk/Sdk/nanoFramework.Capabilities.targets new file mode 100644 index 0000000..e8a9851 --- /dev/null +++ b/src/nanoFramework.NET.Sdk/Sdk/nanoFramework.Capabilities.targets @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + Project + + + + diff --git a/src/nanoFramework.NET.Sdk/Sdk/nanoFramework.Mdp.targets b/src/nanoFramework.NET.Sdk/Sdk/nanoFramework.Mdp.targets new file mode 100644 index 0000000..3d66526 --- /dev/null +++ b/src/nanoFramework.NET.Sdk/Sdk/nanoFramework.Mdp.targets @@ -0,0 +1,412 @@ + + + + + + + + false + false + + + + + $(ProjectDir)Stubs + + + + + + + + 4.0.0-preview.94 + + + + + + + + + + + + true + true + true + true + true + + + false + false + false + false + false + + + + + + + + $(TargetName) + $(TargetName) + $(NanoGenerateStubsDirectory)\$(NanoGenerateStubsRootName) + + + + + + + <_NfSdkTasksDir Condition="'$(MSBuildRuntimeType)' == 'Core'">$(_NfSdkRoot)tasks\net8.0\ + <_NfSdkTasksDir Condition="'$(MSBuildRuntimeType)' != 'Core'">$(_NfSdkRoot)tasks\net472\ + + + + + + + + + + + + + + + + <_NfMdpTasksTFM Condition="'$(MSBuildRuntimeType)' == 'Core'">net8.0 + <_NfMdpTasksTFM Condition="'$(_NfMdpTasksTFM)' == ''">net472 + + <_NfMdpTasksDir Condition="'$(NF_MDP_MSBUILDTASK_PATH)' == '' and '$(DisableNanoFrameworkMDP)' != 'true' and '$(PkgnanoFramework_Tools_MetadataProcessor_MsBuildTask)' != ''">$(PkgnanoFramework_Tools_MetadataProcessor_MsBuildTask)\lib\$(_NfMdpTasksTFM)\ + <_NfMdpTasksDir Condition="'$(NF_MDP_MSBUILDTASK_PATH)' != ''">$(NF_MDP_MSBUILDTASK_PATH)\ + + + + + + + + + $(StartProgram) + $(ProjectDir)$(IntermediateOutputPath)$(TargetName) + $(OutDir)$(TargetName) + + + + + + + Compile;CopyToOutDir + + + BuildOnlySettings; + PrepareForBuild; + PreBuildEvent; + ResolveReferences; + PrepareResources; + ResolveKeySource; + Compile; + GenerateSerializationAssemblies; + CreateSatelliteAssemblies; + GetTargetPath; + PrepareForRun; + IncrementalClean; + MetaDataProcessor; + PostBuildEvent + + + + MetaDataProcessor; + CopyFilesToOutputDirectory; + CopyNanoFrameworkFiles; + CopyBackNanoFrameworkDlls + + + + PrepareResourceNames; + ResGen; + CompileLicxFiles; + NanoResourceGenerator + + + + BeforeResolveReferences; + ResolveProjectReferences; + ResolveAssemblyReferences; + ResolveRuntimeDependencies; + AfterResolveReferences + + + + + + + + $(CoreBuildDependsOn);MetaDataProcessorCompile + $(PrepareResourcesDependsOn);NanoResourceGenerator + $(CleanDependsOn);NanoCLR_CleanExtraFiles + $(ResolveReferencesDependsOn);ResolveRuntimeDependencies + $(CoreBuildDependsOn);NFMDP_CreateDatabaseAndDependencyMap + true + + $(ProjectDir)$(IntermediateOutputPath)$(TargetFileName) + $(ProjectDir)$(IntermediateOutputPath)$(TargetName).pe + $(ProjectDir)$(IntermediateOutputPath)$(TargetName) + $(ProjectDir)$(IntermediateOutputPath)$(TargetName).strings + $(ProjectDir)$(IntermediateOutputPath)$(TargetName)_exports.txt + $(ProjectDir)$(IntermediateOutputPath)$(TargetName)_dependency_map.xml + $(AssemblyName) + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $(NanoGenerateSkeletonProjectName) + $(NanoGenerateStubsRootName) + $(NanoGenerateSkeletonFile) + false + + + + + + + + + + + + + + + + + + + + + $(NanoGenerateSkeletonProjectName) + $(NanoGenerateStubsRootName) + $(NanoGenerateSkeletonFile) + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/nanoFramework.NET.Sdk/Sdk/nanoFramework.Tfm.props b/src/nanoFramework.NET.Sdk/Sdk/nanoFramework.Tfm.props new file mode 100644 index 0000000..3696813 --- /dev/null +++ b/src/nanoFramework.NET.Sdk/Sdk/nanoFramework.Tfm.props @@ -0,0 +1,72 @@ + + + + + + + + netnano1.0 + + + true + + + net + + + $(NoWarn);NU1701 + + + + + + + + .NETnanoFramework + v1.0 + .NETnanoFramework,Version=$(TargetFrameworkVersion) + .NET nanoFramework 1.0 + .NETnanoFramework,Version=$(TargetFrameworkVersion) + + + + + + + 13.0 + + + true + true + false + + + false + true + + + true + + + <_TargetFrameworkDirectories>$(_NfSdkDir) + <_FullFrameworkReferenceAssemblyPaths>$(_NfSdkDir) + + + diff --git a/src/nanoFramework.NET.Sdk/nanoFramework.NET.Sdk.csproj b/src/nanoFramework.NET.Sdk/nanoFramework.NET.Sdk.csproj index cd35d09..334a3c8 100644 --- a/src/nanoFramework.NET.Sdk/nanoFramework.NET.Sdk.csproj +++ b/src/nanoFramework.NET.Sdk/nanoFramework.NET.Sdk.csproj @@ -56,6 +56,11 @@ Sdk/ Sdk.props Sdk.targets + nanoFramework.Tfm.props + nanoFramework.Mdp.targets + nanoFramework.Capabilities.targets + Rules/ + NanoDebugger.xaml tasks/ net472/ nanoFramework.Tools.BuildTasks.dll @@ -73,10 +78,15 @@ \ - + + + + + + From a733b93d5aebe7666e19abdc00df171a45fcb6c7 Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Mon, 15 Jun 2026 22:37:17 -0500 Subject: [PATCH 06/49] NanoMigrate: Spectre.Console UI + --glob filtering + test-first workflow Make the converter pleasant and safe to drive interactively, and add glob scoping. - Spectre.Console (0.57.0): title rule, status spinner, a color-coded summary table (Project | Result | Packages | Notes), a grouped "manual review" panel, and a tally line. Degrades gracefully when output is redirected / non-interactive. - --glob : filter .nfproj by path relative to the input dir (*, **, ? supported); default = all recursively. Works for migrate and fleet. - Test-first: --dry-run renders an exact preview (target .csproj, files to delete, .sln edits) and writes nothing; a real interactive run confirms once ("Proceed with N conversions?") unless --yes or non-interactive (CI proceeds automatically). - Richer ConvertResult (status enum + packages/deletions/sln/error) feeds the preview; all real-run side effects unchanged. Exit codes: 0 clean, 2 review-flagged, 1 error. - nuget.config now includes nuget.org (Spectre.Console must restore once; offline thereafter). Co-Authored-By: Claude Opus 4.8 --- tools/NanoMigrate/Converter.cs | 91 ++++- tools/NanoMigrate/NanoMigrate.csproj | 6 +- tools/NanoMigrate/Program.cs | 490 ++++++++++++++++++++++----- tools/NanoMigrate/Support.cs | 65 ++++ tools/NanoMigrate/nuget.config | 6 +- 5 files changed, 567 insertions(+), 91 deletions(-) diff --git a/tools/NanoMigrate/Converter.cs b/tools/NanoMigrate/Converter.cs index 7a17f6c..6f6539b 100644 --- a/tools/NanoMigrate/Converter.cs +++ b/tools/NanoMigrate/Converter.cs @@ -4,11 +4,41 @@ namespace NanoFramework.Migrate; +/// How a single project ended up after a (dry-run or real) conversion. +internal enum ConvertStatus +{ + Converted, // converted cleanly + Skipped, // already SDK-style; nothing to do + Review, // converted, but flagged items need a human + Error, // threw while converting +} + /// Outcome of converting a single project. internal sealed class ConvertResult { public required string OutputPath { get; init; } public List Review { get; } = new(); + + /// True when the project was already SDK-style and was left untouched. + public bool AlreadySdk { get; set; } + + /// Resolved PackageReferences (id -> version) the emitted project will carry. + public List> Packages { get; } = new(); + + /// Files this conversion deletes (or, in dry-run, would delete). + public List DeletedFiles { get; } = new(); + + /// .sln files this conversion retargets (or, in dry-run, would retarget). + public List UpdatedSolutions { get; } = new(); + + /// Set when the conversion threw; used to render a red Error row. + public string? Error { get; set; } + + public ConvertStatus Status => + Error is not null ? ConvertStatus.Error + : AlreadySdk ? ConvertStatus.Skipped + : Review.Count > 0 ? ConvertStatus.Review + : ConvertStatus.Converted; } /// @@ -66,8 +96,11 @@ public static ConvertResult Convert(string nfproj, Options o) // the root. Treat it as already-converted and skip without touching disk, // so a second run over a repo is a true no-op rather than destructive. if (root.Attribute("Sdk") is not null) - return new ConvertResult { OutputPath = Path.GetFullPath(nfproj) } - .With(new[] { "already SDK-style; skipped" }); + { + var skipped = new ConvertResult { OutputPath = Path.GetFullPath(nfproj), AlreadySdk = true }; + skipped.Review.Add("already SDK-style; skipped"); + return skipped; + } var pkgs = LoadPackagesConfig(projDir); @@ -156,6 +189,23 @@ void SetProp(string k, string? v) var xml = Emit(props, pkgRefs, projRefs, keepItems, o); var outPath = Path.ChangeExtension(Path.GetFullPath(nfproj), o.Ext); + var nfFull = Path.GetFullPath(nfproj); + var replacingNfproj = !string.Equals(outPath, nfFull, StringComparison.OrdinalIgnoreCase); + + var result = new ConvertResult { OutputPath = outPath }; + result.Review.AddRange(review); + result.Packages.AddRange(pkgRefs.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)); + + // Compute the set of files that will be (or, in dry-run, would be) removed + // and the solutions that will be retargeted. This drives the dry-run + // preview and is identical to what the real run acts on. + if (replacingNfproj) result.DeletedFiles.Add(nfFull); + var pc = Path.Combine(projDir, "packages.config"); + if (File.Exists(pc)) result.DeletedFiles.Add(Path.GetFullPath(pc)); + foreach (var ai in ExistingAssemblyInfo(projDir)) result.DeletedFiles.Add(ai); + if (replacingNfproj) + foreach (var sln in SolutionsReferencing(projDir, nfFull)) result.UpdatedSolutions.Add(sln); + if (!o.DryRun) { // Never clobber an existing backup: the original first-run .bak must @@ -163,13 +213,12 @@ void SetProp(string k, string? v) if (!o.NoBackup && !File.Exists(nfproj + ".bak")) File.Copy(nfproj, nfproj + ".bak", overwrite: false); File.WriteAllText(outPath, xml, new UTF8Encoding(false)); // If we emitted a .csproj alongside, retire the original .nfproj. - if (!string.Equals(outPath, Path.GetFullPath(nfproj), StringComparison.OrdinalIgnoreCase)) + if (replacingNfproj) { File.Delete(nfproj); // Point any .sln entries at the new .csproj (idempotent). UpdateSolutions(projDir, nfproj, outPath); } - var pc = Path.Combine(projDir, "packages.config"); if (File.Exists(pc)) File.Delete(pc); // The SDK default **/*.cs glob plus generated assembly info would // otherwise produce duplicate-attribute build errors, so delete a @@ -178,7 +227,33 @@ void SetProp(string k, string? v) DeleteAssemblyInfo(projDir); } - return new ConvertResult { OutputPath = outPath }.With(review); + return result; + } + + // Paths of any hand-written AssemblyInfo.cs that exist on disk (the files + // DeleteAssemblyInfo would remove). Used to preview deletions in dry-run. + private static IEnumerable ExistingAssemblyInfo(string projDir) + { + foreach (var rel in new[] { Path.Combine("Properties", "AssemblyInfo.cs"), "AssemblyInfo.cs" }) + { + var path = Path.Combine(projDir, rel); + if (File.Exists(path)) yield return Path.GetFullPath(path); + } + } + + // The .sln files that currently still reference the .nfproj (i.e. those + // UpdateSolutions would rewrite). Used to preview .sln edits in dry-run. + private static IEnumerable SolutionsReferencing(string projDir, string nfproj) + { + var nfName = Path.GetFileName(nfproj); + foreach (var sln in FindSolutionFiles(projDir)) + { + string text; + try { text = File.ReadAllText(sln); } + catch { continue; } + if (text.Contains(nfName, StringComparison.OrdinalIgnoreCase)) + yield return sln; + } } // Parses the "packages\.\" folder segment of a HintPath into a @@ -423,10 +498,4 @@ private static string Emit( private static string Escape(string s) => s .Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """); - - private static ConvertResult With(this ConvertResult r, IEnumerable review) - { - r.Review.AddRange(review); - return r; - } } diff --git a/tools/NanoMigrate/NanoMigrate.csproj b/tools/NanoMigrate/NanoMigrate.csproj index 5e950f5..c3babac 100644 --- a/tools/NanoMigrate/NanoMigrate.csproj +++ b/tools/NanoMigrate/NanoMigrate.csproj @@ -11,7 +11,11 @@ false - + + + + + diff --git a/tools/NanoMigrate/Program.cs b/tools/NanoMigrate/Program.cs index 190043d..5efc0b5 100644 --- a/tools/NanoMigrate/Program.cs +++ b/tools/NanoMigrate/Program.cs @@ -7,20 +7,18 @@ // composes over the nanoFramework SDK, folds packages.config into PackageReference, // and folds .nuspec metadata into MSBuild Pack properties. Nothing more. // -// BCL-only: no external NuGet dependencies, so it builds and runs fully offline -// once the .NET SDK is present. +// The only external dependency is Spectre.Console, which drives the CLI +// presentation (rules, progress, tables, panels). It degrades gracefully when +// output is redirected or the terminal is non-interactive. using System.Diagnostics; using System.Text; -using System.Text.Json; -using System.Xml.Linq; +using Spectre.Console; namespace NanoFramework.Migrate; internal static class Program { - private const string MsbuildNs = "http://schemas.microsoft.com/developer/msbuild/2003"; - private static int Main(string[] args) { if (args.Length == 0 || IsHelp(args[0])) @@ -50,53 +48,223 @@ private static int Main(string[] args) private static int CmdMigrate(Options o) { + Header("NanoMigrate"); var path = o.Positional ?? throw new UserError("migrate needs a path to a .nfproj or a directory"); - var targets = ResolveProjects(path); - // Reentrant: a fully-converted tree has no .nfproj left. Exit cleanly (0) rather than - // erroring, so re-running the converter over a repo is a safe no-op. + var targets = ResolveProjects(path, o.Glob); + + // Reentrant: a fully-converted tree has no .nfproj left. Exit cleanly (0) + // rather than erroring, so re-running the converter over a repo is a safe + // no-op. if (targets.Count == 0) { - Console.WriteLine($"nothing to convert: no .nfproj found under '{path}' (already SDK-style?)."); + var why = o.Glob is null + ? $"no .nfproj found under '{Esc(path)}' (already SDK-style?)." + : $"no .nfproj matched glob '{Esc(o.Glob)}' under '{Esc(path)}'."; + AnsiConsole.MarkupLine($"[grey]nothing to convert: {why}[/]"); + return 0; + } + + // Determine the base directory glob/relative paths are reported against. + var baseDir = Directory.Exists(path) ? Path.GetFullPath(path) + : Path.GetDirectoryName(Path.GetFullPath(path))!; + + AnsiConsole.MarkupLine(o.DryRun + ? $"[yellow]Dry run[/] — analysing [bold]{targets.Count}[/] project(s) under [blue]{Esc(baseDir)}[/]. Nothing will be written." + : $"Found [bold]{targets.Count}[/] project(s) to convert under [blue]{Esc(baseDir)}[/]."); + AnsiConsole.WriteLine(); + + // Real, interactive runs confirm once before touching disk. In dry-run or + // when stdin is redirected (CI/automation) we proceed without prompting so + // nothing blocks. --yes also skips the prompt. + if (!o.DryRun && !o.AssumeYes && IsInteractive() + && !AnsiConsole.Confirm($"Proceed with {targets.Count} conversion(s)?")) + { + AnsiConsole.MarkupLine("[grey]aborted; nothing written.[/]"); return 0; } - var allReview = new List(); - foreach (var nf in targets) + var results = ProcessProjects(targets, baseDir, o); + + RenderSummaryTable(results, baseDir, o); + RenderReviewNotes(results, baseDir); + RenderTally(results, o); + + var errors = results.Count(r => r.Result.Status == ConvertStatus.Error); + var flagged = results.Count(r => r.Result.Status == ConvertStatus.Review); + if (errors > 0) return 1; + return flagged > 0 ? 2 : 0; + } + + // Runs every conversion, surfacing progress with a spinner. Any per-project + // exception is captured as an Error row rather than aborting the batch. + private static List ProcessProjects(List targets, string baseDir, Options o) + { + var results = new List(targets.Count); + + void RunAll(Action? report) { - var result = Converter.Convert(nf, o); - Console.WriteLine(o.DryRun - ? $"would convert {nf} -> {result.OutputPath}" - : $"converted {nf} -> {result.OutputPath}"); - foreach (var r in result.Review) allReview.Add($" [{Path.GetFileName(nf)}] {r}"); + foreach (var nf in targets) + { + var rel = Path.GetRelativePath(baseDir, nf); + report?.Invoke(rel); + ConvertResult result; + try + { + result = Converter.Convert(nf, o); + } + catch (Exception ex) + { + result = new ConvertResult { OutputPath = nf, Error = ex.Message }; + } + results.Add(new ProjectOutcome(nf, result)); + } } - if (allReview.Count > 0) + // Status() needs an interactive, non-redirected console; otherwise just + // run straight through (Spectre would no-op the spinner anyway). + if (IsInteractive()) + { + AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .Start(o.DryRun ? "Analysing…" : "Converting…", ctx => + RunAll(rel => ctx.Status($"{(o.DryRun ? "Analysing" : "Converting")} [blue]{Esc(rel)}[/]…"))); + } + else { - Console.WriteLine("\nMANUAL REVIEW NEEDED:"); - Console.WriteLine(string.Join("\n", allReview)); - return 2; + RunAll(null); } - return 0; + return results; } - private static List ResolveProjects(string path) + // ───────────────────────────── rendering ───────────────────────────── + + private static void RenderSummaryTable(List results, string baseDir, Options o) + { + var table = new Table().Border(TableBorder.Rounded).Expand(); + table.Title = new TableTitle(o.DryRun ? "Migration preview (dry run)" : "Migration summary"); + table.AddColumn("Project"); + table.AddColumn("Result"); + table.AddColumn("Packages"); + table.AddColumn("Notes"); + + foreach (var (nf, result) in results) + { + var rel = Path.GetRelativePath(baseDir, nf); + var (label, color) = StatusLabel(result.Status); + + var pkgs = result.Packages.Count == 0 + ? "[grey]—[/]" + : string.Join("\n", result.Packages.Select(p => $"{Esc(p.Key)} [grey]{Esc(p.Value)}[/]")); + + var notes = BuildNotesCell(result, o); + + table.AddRow( + new Markup($"[blue]{Esc(rel)}[/]"), + new Markup($"[{color}]{label}[/]"), + new Markup(pkgs), + new Markup(notes)); + } + + AnsiConsole.Write(table); + } + + // The Notes cell: in dry-run it previews what WOULD change (target path, + // deletions, .sln edits); otherwise it shows the count of review flags. + private static string BuildNotesCell(ConvertResult result, Options o) + { + if (result.Status == ConvertStatus.Error) + return $"[red]{Esc(result.Error ?? "error")}[/]"; + if (result.Status == ConvertStatus.Skipped) + return "[grey]already SDK-style[/]"; + + var lines = new List(); + if (o.DryRun) + { + lines.Add($"[grey]→[/] {Esc(Path.GetFileName(result.OutputPath))}"); + foreach (var d in result.DeletedFiles) + lines.Add($"[red]delete[/] {Esc(Path.GetFileName(d))}"); + foreach (var s in result.UpdatedSolutions) + lines.Add($"[yellow]edit[/] {Esc(Path.GetFileName(s))}"); + } + if (result.Review.Count > 0) + lines.Add($"[yellow]{result.Review.Count} item(s) need review[/]"); + return lines.Count == 0 ? "[green]clean[/]" : string.Join("\n", lines); + } + + // Review notes get a clearly-visible yellow panel, grouped per project, so + // they are never buried in the table. + private static void RenderReviewNotes(List results, string baseDir) + { + var flagged = results.Where(r => r.Result.Review.Count > 0 + && r.Result.Status == ConvertStatus.Review).ToList(); + if (flagged.Count == 0) return; + + var sb = new StringBuilder(); + foreach (var (nf, result) in flagged) + { + var rel = Path.GetRelativePath(baseDir, nf); + sb.Append($"[bold]{Esc(rel)}[/]\n"); + foreach (var item in result.Review) + sb.Append($" [yellow]•[/] {Esc(item)}\n"); + } + + var panel = new Panel(sb.ToString().TrimEnd('\n')) + { + Header = new PanelHeader("[yellow]MANUAL REVIEW NEEDED[/]"), + Border = BoxBorder.Rounded, + BorderStyle = new Style(Color.Yellow), + Expand = true, + }; + AnsiConsole.WriteLine(); + AnsiConsole.Write(panel); + } + + private static void RenderTally(List results, Options o) + { + int converted = results.Count(r => r.Result.Status == ConvertStatus.Converted); + int skipped = results.Count(r => r.Result.Status == ConvertStatus.Skipped); + int flagged = results.Count(r => r.Result.Status == ConvertStatus.Review); + int errors = results.Count(r => r.Result.Status == ConvertStatus.Error); + var verb = o.DryRun ? "would convert" : "converted"; + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine( + $"[green]{verb} {converted}[/] • [grey]skipped {skipped}[/] • " + + $"[yellow]flagged {flagged}[/] • [red]errors {errors}[/] • total {results.Count}"); + } + + private static (string label, string color) StatusLabel(ConvertStatus s) => s switch + { + ConvertStatus.Converted => ("Converted", "green"), + ConvertStatus.Skipped => ("Skipped", "grey"), + ConvertStatus.Review => ("Review", "yellow"), + ConvertStatus.Error => ("Error", "red"), + _ => ("?", "white"), + }; + + private static List ResolveProjects(string path, string? glob) { if (File.Exists(path) && path.EndsWith(".nfproj", StringComparison.OrdinalIgnoreCase)) return new List { Path.GetFullPath(path) }; - if (Directory.Exists(path)) - return Directory.EnumerateFiles(path, "*.nfproj", SearchOption.AllDirectories) - .Select(Path.GetFullPath).OrderBy(p => p).ToList(); - return new List(); + if (!Directory.Exists(path)) return new List(); + + var baseDir = Path.GetFullPath(path); + return Directory.EnumerateFiles(path, "*.nfproj", SearchOption.AllDirectories) + .Select(Path.GetFullPath) + .Where(p => glob is null || Glob.IsMatch(Path.GetRelativePath(baseDir, p), glob)) + .OrderBy(p => p) + .ToList(); } // ───────────────────────────── clone ───────────────────────────── private static int CmdClone(Options o) { + Header("NanoMigrate · clone"); var outDir = o.Positional ?? "./nano-repos"; Directory.CreateDirectory(outDir); - Console.WriteLine($"enumerating {o.Org} repositories matching '{o.Filter}*'..."); + AnsiConsole.MarkupLine($"Enumerating [bold]{Esc(o.Org)}[/] repositories matching '[blue]{Esc(o.Filter)}*[/]'…"); var repos = GitHub.ListOrgRepos(o.Org, o.Token, o.IncludeArchived) .Where(r => r.Name.StartsWith(o.Filter, StringComparison.OrdinalIgnoreCase)) .OrderBy(r => r.Name).ToList(); @@ -105,17 +273,50 @@ private static int CmdClone(Options o) $"no repos matched '{o.Filter}*' in org '{o.Org}'. " + "Check the org name and filter, or pass --token to lift the API rate limit."); - Console.WriteLine($"found {repos.Count} repositories. cloning into {outDir} ..."); + AnsiConsole.MarkupLine($"Found [bold]{repos.Count}[/] repositories. Cloning into [blue]{Esc(outDir)}[/]…"); + AnsiConsole.WriteLine(); + + var table = new Table().Border(TableBorder.Rounded).Expand(); + table.Title = new TableTitle("Clone results"); + table.AddColumn("Repository"); + table.AddColumn("Result"); + int ok = 0, skipped = 0, failed = 0; - foreach (var r in repos) + void CloneAll(Action? report) { - var dest = Path.Combine(outDir, r.Name); - if (Directory.Exists(dest)) { Console.WriteLine($" skip {r.Name} (already present)"); skipped++; continue; } - var (code, _, err) = Run("git", $"clone --depth 1 {r.CloneUrl} \"{dest}\"", outDir); - if (code == 0) { Console.WriteLine($" cloned {r.Name}"); ok++; } - else { Console.WriteLine($" FAIL {r.Name}: {err.Trim().Split('\n').LastOrDefault()}"); failed++; } + foreach (var r in repos) + { + report?.Invoke(r.Name); + var dest = Path.Combine(outDir, r.Name); + if (Directory.Exists(dest)) + { + table.AddRow(new Markup($"[blue]{Esc(r.Name)}[/]"), new Markup("[grey]skipped (already present)[/]")); + skipped++; continue; + } + var (code, _, err) = Run("git", $"clone --depth 1 {r.CloneUrl} \"{dest}\"", outDir); + if (code == 0) + { + table.AddRow(new Markup($"[blue]{Esc(r.Name)}[/]"), new Markup("[green]cloned[/]")); + ok++; + } + else + { + var msg = err.Trim().Split('\n').LastOrDefault() ?? "git clone failed"; + table.AddRow(new Markup($"[blue]{Esc(r.Name)}[/]"), new Markup($"[red]FAIL[/] {Esc(msg)}")); + failed++; + } + } } - Console.WriteLine($"\ndone. cloned {ok}, skipped {skipped}, failed {failed}."); + + if (IsInteractive()) + AnsiConsole.Status().Spinner(Spinner.Known.Dots) + .Start("Cloning…", ctx => CloneAll(name => ctx.Status($"Cloning [blue]{Esc(name)}[/]…"))); + else + CloneAll(null); + + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[green]cloned {ok}[/] • [grey]skipped {skipped}[/] • [red]failed {failed}[/]"); return failed > 0 ? 2 : 0; } @@ -123,6 +324,7 @@ private static int CmdClone(Options o) private static int CmdFleet(Options o) { + Header("NanoMigrate · fleet"); var reposDir = o.Positional ?? throw new UserError("fleet needs a path to a directory of cloned repos"); if (!Directory.Exists(reposDir)) throw new UserError($"directory not found: {reposDir}"); if (o.Commit && o.Branch is null) throw new UserError("--commit requires --branch"); @@ -135,63 +337,157 @@ private static int CmdFleet(Options o) // so a .bak alongside it is just noise in the diff. Skip backups when committing. if (o.Commit) o.NoBackup = true; + // A repo qualifies if it contains at least one .nfproj that survives the + // glob filter (default: any .nfproj). + bool RepoMatches(string d) => + NfprojUnder(d, o.Glob).Any(); + var repoDirs = Directory.EnumerateDirectories(reposDir) - .Where(d => Directory.EnumerateFiles(d, "*.nfproj", SearchOption.AllDirectories).Any()) + .Where(RepoMatches) .OrderBy(d => d).ToList(); - if (repoDirs.Count == 0) throw new UserError($"no repos containing .nfproj found under '{reposDir}'"); + if (repoDirs.Count == 0) + throw new UserError(o.Glob is null + ? $"no repos containing .nfproj found under '{reposDir}'" + : $"no repos with .nfproj matching glob '{o.Glob}' found under '{reposDir}'"); + + if (o.DryRun) + AnsiConsole.MarkupLine($"[yellow]Dry run[/] — {repoDirs.Count} repo(s); nothing will be written."); + else + AnsiConsole.MarkupLine($"Processing [bold]{repoDirs.Count}[/] repo(s) under [blue]{Esc(reposDir)}[/]" + + (o.Branch is not null ? $" on branch [blue]{Esc(o.Branch)}[/]" : "") + + (o.Commit ? ", committing" : "") + "."); + AnsiConsole.WriteLine(); var report = new List(); - foreach (var repo in repoDirs) + + void FleetAll(Action? progress) { - var rr = new RepoReport { Name = Path.GetFileName(repo) }; - try + foreach (var repo in repoDirs) { - if (o.Branch is not null && !o.DryRun) + progress?.Invoke(Path.GetFileName(repo)); + var rr = new RepoReport { Name = Path.GetFileName(repo) }; + try { - var (code, _, err) = Run("git", $"checkout -B {o.Branch}", repo); - if (code != 0) { rr.Error = $"git checkout failed: {err.Trim()}"; report.Add(rr); continue; } - } + if (o.Branch is not null && !o.DryRun) + { + var (code, _, err) = Run("git", $"checkout -B {o.Branch}", repo); + if (code != 0) { rr.Error = $"git checkout failed: {err.Trim()}"; report.Add(rr); continue; } + } - foreach (var nf in Directory.EnumerateFiles(repo, "*.nfproj", SearchOption.AllDirectories).OrderBy(p => p)) - { - rr.Projects++; - var result = Converter.Convert(nf, o); - var rel = Path.GetRelativePath(repo, nf); - foreach (var item in result.Review) rr.Review.Add($"{rel}: {item}"); - } + foreach (var nf in NfprojUnder(repo, o.Glob).OrderBy(p => p)) + { + rr.Projects++; + var result = Converter.Convert(nf, o); + var rel = Path.GetRelativePath(repo, nf); + foreach (var item in result.Review) rr.Review.Add($"{rel}: {item}"); + } - if (o.Commit && !o.DryRun) - { - Run("git", "add -A", repo); - var msgFile = WriteCommitMessage(repo, o); - var signOff = o.SignOff ? "-s " : ""; - var (code, _, err) = Run("git", $"commit {signOff}-F \"{msgFile}\"", repo); - File.Delete(msgFile); - rr.Committed = code == 0; - if (code != 0 && !err.Contains("nothing to commit")) + if (o.Commit && !o.DryRun) { - rr.Error = err.Contains("Please tell me who you are") || err.Contains("user.name") - ? "git commit failed: set git user.name/user.email (real name) so the " - + "Signed-off-by line is valid, or pass --no-sign-off" - : $"git commit: {err.Trim()}"; + Run("git", "add -A", repo); + var msgFile = WriteCommitMessage(repo, o); + var signOff = o.SignOff ? "-s " : ""; + var (code, _, err) = Run("git", $"commit {signOff}-F \"{msgFile}\"", repo); + File.Delete(msgFile); + rr.Committed = code == 0; + if (code != 0 && !err.Contains("nothing to commit")) + { + rr.Error = err.Contains("Please tell me who you are") || err.Contains("user.name") + ? "git commit failed: set git user.name/user.email (real name) so the " + + "Signed-off-by line is valid, or pass --no-sign-off" + : $"git commit: {err.Trim()}"; + } } } + catch (Exception ex) + { + rr.Error = ex.Message; + } + report.Add(rr); } - catch (Exception ex) - { - rr.Error = ex.Message; - } - report.Add(rr); - var status = rr.Error is not null ? "ERROR" : rr.Review.Count > 0 ? "review" : "ok"; - Console.WriteLine($" [{status,6}] {rr.Name} ({rr.Projects} project(s), {rr.Review.Count} review item(s))"); } + if (IsInteractive()) + AnsiConsole.Status().Spinner(Spinner.Known.Dots) + .Start("Migrating…", ctx => FleetAll(name => ctx.Status($"Migrating [blue]{Esc(name)}[/]…"))); + else + FleetAll(null); + + RenderFleetTable(report); + RenderFleetReview(report); + WriteReport(report, o, reposDir); var errored = report.Count(r => r.Error is not null); - Console.WriteLine($"\n{report.Count} repos processed, {errored} with errors. report: {o.Report}"); + var needsReview = report.Count(r => r.Error is null && r.Review.Count > 0); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine( + $"[bold]{report.Count}[/] repo(s) processed • " + + $"[yellow]{needsReview} need review[/] • [red]{errored} with errors[/] • " + + $"report: [blue]{Esc(o.Report)}[/]"); return errored > 0 ? 2 : 0; } + private static void RenderFleetTable(List report) + { + var table = new Table().Border(TableBorder.Rounded).Expand(); + table.Title = new TableTitle("Fleet results"); + table.AddColumn("Repository"); + table.AddColumn("Result"); + table.AddColumn("Projects"); + table.AddColumn("Review"); + + foreach (var rr in report) + { + string label, color; + if (rr.Error is not null) { label = "Error"; color = "red"; } + else if (rr.Review.Count > 0) { label = "Review"; color = "yellow"; } + else { label = rr.Committed ? "OK (committed)" : "OK"; color = "green"; } + + var note = rr.Error is not null ? $" [red]{Esc(rr.Error.Split('\n')[0])}[/]" : ""; + table.AddRow( + new Markup($"[blue]{Esc(rr.Name)}[/]"), + new Markup($"[{color}]{label}[/]{note}"), + new Markup(rr.Projects.ToString()), + new Markup(rr.Review.Count == 0 ? "[grey]—[/]" : $"[yellow]{rr.Review.Count}[/]")); + } + AnsiConsole.Write(table); + } + + private static void RenderFleetReview(List report) + { + var flagged = report.Where(r => r.Error is null && r.Review.Count > 0).ToList(); + if (flagged.Count == 0) return; + + var sb = new StringBuilder(); + foreach (var rr in flagged) + { + sb.Append($"[bold]{Esc(rr.Name)}[/]\n"); + foreach (var item in rr.Review) sb.Append($" [yellow]•[/] {Esc(item)}\n"); + } + var panel = new Panel(sb.ToString().TrimEnd('\n')) + { + Header = new PanelHeader("[yellow]MANUAL REVIEW NEEDED[/]"), + Border = BoxBorder.Rounded, + BorderStyle = new Style(Color.Yellow), + Expand = true, + }; + AnsiConsole.WriteLine(); + AnsiConsole.Write(panel); + } + + // Enumerates the .nfproj under a repo that survive the glob filter (matched + // against the path relative to the repo directory). + private static IEnumerable NfprojUnder(string repoDir, string? glob) + { + var baseDir = Path.GetFullPath(repoDir); + foreach (var nf in Directory.EnumerateFiles(repoDir, "*.nfproj", SearchOption.AllDirectories)) + { + var full = Path.GetFullPath(nf); + if (glob is null || Glob.IsMatch(Path.GetRelativePath(baseDir, full), glob)) + yield return full; + } + } + private static void WriteReport(List report, Options o, string reposDir) { var sb = new StringBuilder(); @@ -298,10 +594,34 @@ internal static (int code, string stdout, string stderr) Run(string file, string return (p.ExitCode, so, se); } + // A "title rule" header. Spectre renders this as a centred rule when the + // terminal is wide enough, and degrades to plain text when redirected. + private static void Header(string title) + { + var rule = new Rule($"[bold]{Esc(title)}[/]") { Justification = Justify.Left }; + AnsiConsole.Write(rule); + AnsiConsole.WriteLine(); + } + + // Escapes interpolated text (notably file paths) so Spectre markup like + // "[" in a path can't be interpreted as styling — guards against injection. + private static string Esc(string s) => Markup.Escape(s); + + // Interactive == an attached terminal whose stdin is not redirected. We use + // this to decide whether to show a spinner and whether to prompt. In + // non-interactive contexts (CI, piped input) we never prompt or block. + private static bool IsInteractive() => + !Console.IsInputRedirected && AnsiConsole.Profile.Capabilities.Interactive; + private static bool IsHelp(string a) => a is "-h" or "--help" or "help"; - private static int Fail(string msg) { Console.Error.WriteLine($"error: {msg}"); return 1; } - private static void PrintUsage() => Console.WriteLine(""" + private static int Fail(string msg) + { + AnsiConsole.MarkupLine($"[red]error:[/] {Esc(msg)}"); + return 1; + } + + private static void PrintUsage() => AnsiConsole.WriteLine(""" nano-migrate — migrate nanoFramework projects to the SDK-style project system USAGE @@ -309,13 +629,24 @@ private static void PrintUsage() => Console.WriteLine(""" nano-migrate clone Clone all matching repos from a GitHub org. nano-migrate fleet Migrate every .nfproj across cloned repos; write a report. + RECOMMENDED WORKFLOW + Dry-run a directory first to preview every change, review the table, then + run for real: + nano-migrate migrate ./samples --dry-run + nano-migrate migrate ./samples # prompts once before writing + COMMON OPTIONS --sdk Accepted for back-compat but ignored: the SDK reference is versionless (pinned via global.json msbuild-sdks). --tfm Target framework moniker (default netnano1.0) --ext Output extension: .nfproj or .csproj (default .csproj) --no-backup Don't write a .nfproj.bak (implied by fleet --commit). - --dry-run Analyse and report only; write nothing. + --dry-run Analyse and preview only; write nothing. + --glob Only convert .nfproj whose path (relative to ) + matches the glob. Supports *, ** and ?. Default: all + .nfproj recursively. Example: --glob "Beginner/**". + --yes, -y Skip the interactive "Proceed?" confirmation on a real run. + (Non-interactive/redirected runs never prompt regardless.) clone OPTIONS --org GitHub org (default nanoframework) @@ -331,6 +662,7 @@ message and signs off (Signed-off-by) by default. --message Commit summary line (kept <= 50 chars). --issue Reference an issue: adds a "Fix #" trailer to the commit. --no-sign-off Don't add a Signed-off-by line. + --glob Only convert matching .nfproj within each repo (see above). SCOPE Project-system migration only. Does NOT produce OTA artifacts, modular @@ -338,8 +670,12 @@ message and signs off (Signed-off-by) by default. EXAMPLES nano-migrate migrate ./lib-CoreLibrary - nano-migrate migrate ./MyDevice/MyDevice.nfproj --ext .csproj + nano-migrate migrate ./samples --glob "Beginner/**" --dry-run + nano-migrate migrate ./MyDevice/MyDevice.nfproj --ext .csproj --yes nano-migrate clone ./nano-repos --token $GITHUB_TOKEN nano-migrate fleet ./nano-repos --branch sdk-migration --commit --dry-run """); } + +/// Pairs a source .nfproj path with its conversion outcome. +internal readonly record struct ProjectOutcome(string Nfproj, ConvertResult Result); diff --git a/tools/NanoMigrate/Support.cs b/tools/NanoMigrate/Support.cs index b5b8a34..e547634 100644 --- a/tools/NanoMigrate/Support.cs +++ b/tools/NanoMigrate/Support.cs @@ -1,5 +1,6 @@ using System.Net.Http.Headers; using System.Text.Json; +using System.Text.RegularExpressions; namespace NanoFramework.Migrate; @@ -28,6 +29,11 @@ internal sealed class Options public string Ext { get; private set; } = ".csproj"; public bool DryRun { get; private set; } public bool NoBackup { get; internal set; } + // Glob filter (relative to the input directory) selecting which .nfproj to + // convert. Null means "all .nfproj recursively" (the original behavior). + public string? Glob { get; private set; } + // Skip the interactive "Proceed?" confirmation for real (non-dry-run) runs. + public bool AssumeYes { get; private set; } // clone public string Org { get; private set; } = "nanoframework"; @@ -65,6 +71,8 @@ string Next(string name) => ++i < list.Count break; case "--dry-run": case "--no-write": o.DryRun = true; break; case "--no-backup": o.NoBackup = true; break; + case "--glob": o.Glob = Next(a); break; + case "--yes": case "-y": o.AssumeYes = true; break; case "--org": o.Org = Next(a); break; case "--filter": o.Filter = Next(a); break; case "--token": o.Token = Next(a); break; @@ -130,3 +138,60 @@ public static List ListOrgRepos(string org, string? token, bool includeArc return repos; } } + +/// +/// Small hand-rolled glob matcher (no external dependency). Matches a path that +/// is relative to the input directory against a pattern supporting: +/// * any run of characters except a path separator +/// ** any run of characters including path separators (spanning directories) +/// ? exactly one character except a path separator +/// Matching is case-insensitive and separator-insensitive ('/' and '\' are +/// treated as equivalent), and is anchored to the whole relative path. +/// +internal static class Glob +{ + public static bool IsMatch(string relativePath, string pattern) + { + var input = Normalize(relativePath); + var regex = ToRegex(Normalize(pattern)); + return regex.IsMatch(input); + } + + private static string Normalize(string s) => s.Replace('\\', '/').Trim('/'); + + // Translates a normalized ('/'-separated) glob into an anchored, case-insensitive + // regex. Literal characters are escaped; only *, ** and ? carry meaning. + private static Regex ToRegex(string pattern) + { + var sb = new System.Text.StringBuilder("^"); + for (int i = 0; i < pattern.Length; i++) + { + var c = pattern[i]; + switch (c) + { + case '*': + if (i + 1 < pattern.Length && pattern[i + 1] == '*') + { + i++; // consume the second '*' + // "**/" (or trailing "**") spans directories; collapse any + // following slash so "Beginner/**" also matches "Beginner". + if (i + 1 < pattern.Length && pattern[i + 1] == '/') i++; + sb.Append(".*"); + } + else + { + sb.Append("[^/]*"); // single star: stay within a segment + } + break; + case '?': + sb.Append("[^/]"); + break; + default: + sb.Append(Regex.Escape(c.ToString())); + break; + } + } + sb.Append('$'); + return new Regex(sb.ToString(), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } +} diff --git a/tools/NanoMigrate/nuget.config b/tools/NanoMigrate/nuget.config index 73deaeb..8edd5d5 100644 --- a/tools/NanoMigrate/nuget.config +++ b/tools/NanoMigrate/nuget.config @@ -1,8 +1,10 @@ - + + From cc60824b9f69118209734becaa9b42aa2782c69b Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Mon, 15 Jun 2026 22:40:44 -0500 Subject: [PATCH 07/49] Replace the binary .skill with a proper text skill; drop nano-migrate.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration skill shipped as a binary .skill (a zip bundling a stale copy of the converter source). Replace it with a proper, installable text skill under skills/nanoframework-sdk-migration: - SKILL.md — frontmatter (name + trigger-rich description) and a concise, action-oriented body that drives the tool via `dotnet nano migrate` with the test-first workflow (dry-run a directory, review, scope with --glob, then run for real) — no bundled/stale source. - references/migration-rules.md, references/contributing-compliance.md — the conversion rules and contribution/PR conventions. Also delete the obsolete Python reference converter (tools/NanoMigrate/nano-migrate.py); the hardened C# tool is canonical. Co-Authored-By: Claude Opus 4.8 --- skills/nanoframework-sdk-migration/SKILL.md | 79 +++++++++ .../references/contributing-compliance.md | 167 ++++++++++++++++++ .../references/migration-rules.md | 156 ++++++++++++++++ tools/NanoMigrate/nano-migrate.py | 137 -------------- .../nanoframework-sdk-migration.skill | Bin 23551 -> 0 bytes 5 files changed, 402 insertions(+), 137 deletions(-) create mode 100644 skills/nanoframework-sdk-migration/SKILL.md create mode 100644 skills/nanoframework-sdk-migration/references/contributing-compliance.md create mode 100644 skills/nanoframework-sdk-migration/references/migration-rules.md delete mode 100644 tools/NanoMigrate/nano-migrate.py delete mode 100644 tools/NanoMigrate/nanoframework-sdk-migration.skill diff --git a/skills/nanoframework-sdk-migration/SKILL.md b/skills/nanoframework-sdk-migration/SKILL.md new file mode 100644 index 0000000..62a0e39 --- /dev/null +++ b/skills/nanoframework-sdk-migration/SKILL.md @@ -0,0 +1,79 @@ +--- +name: nanoframework-sdk-migration +description: >- + Migrate .NET nanoFramework projects from the legacy custom .nfproj project system to the + SDK-style MSBuild project system (, netnano1.0, + PackageReference). Use whenever converting a nanoFramework library or app to SDK-style, + folding packages.config into PackageReference or .nuspec into MSBuild Pack properties, or + bulk-migrating many repos at once (the nanoframework/lib-* fleet). Trigger for any mention of + ".nfproj", "SDK-style nanoFramework", "convert nfproj to csproj", "migrate the nano + libraries", or a fleet/bulk migration — even if the word "skill" is never used. + SCOPE: project-system migration only; this does NOT cover OTA updates or modular firmware + packaging. +--- + +# nanoFramework SDK-style project migration + +Convert a nanoFramework repo from the legacy flavored `.nfproj` project system onto SDK-style +projects that compose over `nanoFramework.NET.Sdk`. The mechanical conversion is done by the +**NanoMigrate** tool (in this repo at `tools/NanoMigrate`, surfaced as `dotnet nano migrate`). The +tool is **idempotent + reentrant**: it skips already-SDK-style projects and re-running over a tree +is a safe no-op, so a partial or repeated migration is never destructive. + +## Scope — read this first + +Project-system migration **only**. Do not add, and do not ask the tool to touch: OTA update +artifacts, modular/firmware packaging, `runtimes/{rid}/native` layouts, or ABI manifests. The +exact per-project transformation rules are in +[references/migration-rules.md](references/migration-rules.md); contribution + PR conventions are +in [references/contributing-compliance.md](references/contributing-compliance.md). + +## Always test a directory first, then migrate for real + +1. **Dry-run** to preview every change (writes nothing): + ``` + dotnet nano migrate --dry-run + # or, from this repo: dotnet run --project tools/NanoMigrate -- migrate --dry-run + ``` + Review the preview table: the target `.csproj`, the resolved `PackageReference`s, the files + that would be deleted, the `.sln` edits, and anything in the yellow **manual review** panel. +2. **Scope** with a glob if you only want part of the tree (`*`, `**`, `?`; relative to ``): + ``` + dotnet nano migrate --glob "Beginner/**" --dry-run + ``` +3. **Run for real** once the preview is right: + ``` + dotnet nano migrate # interactive: confirms once. Add --yes for CI/unattended. + ``` +4. **Re-run is safe** — reentrant. A partially-converted tree converts only the remaining + `.nfproj` and leaves existing `.csproj` untouched. + +## What the tool does per project + +(Full detail in [references/migration-rules.md](references/migration-rules.md).) + +- Emits `` with `netnano1.0`. +- Maps `packages.config` / HintPath versions to `PackageReference` — the package id comes from the + `packages\.\` folder, so nano package ids resolve correctly (not the bare assembly + name). +- Folds `.nuspec` metadata into MSBuild `Pack` properties. +- Deletes `packages.config` and a hand-written `Properties/AssemblyInfo.cs` (the SDK generates it). +- Rewrites the `.sln` entry: project-type GUID → the SDK C#/CPS GUID, path `.nfproj` → `.csproj`. +- Writes a `.nfproj.bak` backup (unless `--no-backup`). + +## Manual review + +Entries in the yellow "manual review" panel did not resolve automatically — typically a +`` with no HintPath and no matching `packages.config` entry. Add the right +`PackageReference` by hand, then re-run (a no-op for everything already converted). + +## Fleet migration (many repos) + +``` +dotnet run --project tools/NanoMigrate -- fleet [--glob "..."] [--branch ] [--commit] +``` + +Walks each repo, converts every matching `.nfproj`, and (with `--branch`/`--commit`) commits on a +branch. Open PRs from the nanoFramework org template — see +[references/contributing-compliance.md](references/contributing-compliance.md). Convert leaf-first +(dependencies before dependents); branch names must not start with `develop`; never target `main`. diff --git a/skills/nanoframework-sdk-migration/references/contributing-compliance.md b/skills/nanoframework-sdk-migration/references/contributing-compliance.md new file mode 100644 index 0000000..493a185 --- /dev/null +++ b/skills/nanoframework-sdk-migration/references/contributing-compliance.md @@ -0,0 +1,167 @@ +# Contributing-compliance rules for migration PRs + +These rules make a migration land as a contribution the nanoFramework project +will accept. They are distilled from the official contributing docs: + +- Contribution workflow — +- C# Coding Style — +- Labels — +- Contributor License Agreement — + +Treat this as the checklist the migration must satisfy before a PR goes up. The +`nano-migrate` tool enforces the parts it can (commit format, sign-off, branch +naming); the rest is on the contributor. + +## Contents + +- [Before you start](#before-you-start) +- [Branching](#branching) +- [Scope of the change](#scope-of-the-change) +- [Commits](#commits) +- [Build and test clean](#build-and-test-clean) +- [Opening the PR](#opening-the-pr) +- [C# coding style (only if you touch .cs)](#c-coding-style-only-if-you-touch-cs) +- [What the tool does vs. what you do](#what-the-tool-does-vs-what-you-do) + +## Before you start + +- **Sign the CLA.** Every contribution (code, config, docs — including a project + migration) requires a signed Contributor License Agreement. Without it the PR + cannot be merged. +- **Use your real name.** Commits must use a real name; anonymous or pseudonymous + contributions are not accepted. Set it once: + `git config --global user.name "Your Real Name"` and a real + `git config --global user.email`. +- **File or find an issue for non-trivial work.** A fleet-wide project-system + migration is not trivial — open a tracking issue (or reuse one) and get team + agreement before opening PRs. Trivial one-off fixes can skip this. +- **Work from a fork.** The project uses a fork-based workflow: fork the repo, + clone your fork, add `upstream` pointing at `nanoframework/`, and sync + `main` from upstream before branching. + +## Branching + +- Branch off **`main`**: `git checkout -b main`. +- Name the branch for its intent, e.g. `sdk-migration` or `issue-123`. +- **Never** start a branch name with `develop` — it collides with upstream + `develop-*` branches. (`nano-migrate fleet` refuses such names.) + +## Scope of the change + +- This migration is a **structural** change to the project system, not a style + change. That is allowed. **Do not** bundle in code reformatting or style-only + edits — the project explicitly does **not** accept style-only PRs and asks + contributors to preserve the existing style of any file they touch. +- **Do not modify `.cs` source** as part of the migration. `nano-migrate` only + rewrites `.nfproj`, `packages.config`, and `.nuspec`; keep it that way. If a + repo genuinely needs a code change, that is a separate PR with its own issue. +- **Keep `.editorconfig`.** Each repo carries an `.editorconfig` (and + `spelling_exclusion.dic`) from the project template that encodes the style + rules. Don't delete or reformat around it. If a repo is missing it, copy the + current one from the `.github` template repo rather than inventing one. +- **Generated project XML**: the rule for non-code files is consistency. The + SDK-style project the tool emits uses standard two-space MSBuild indentation, + which is the broadly-accepted convention for a brand-new SDK-style file. +- **Versioning**: the tool does not fold `` from `.nuspec`. Leave the + repo's existing versioning (typically Nerdbank.GitVersioning via `version.json`) + in place; don't hard-code a version into the project. + +## Commits + +Format every commit like this (the tool does this automatically for fleet +commits): + +```text +Summarize the change in 50 characters or less + +Body explaining what changed and why, wrapped at 72 columns. For a +migration: what was converted and that there are no functional code +changes. + +Fix # + +Signed-off-by: Your Real Name +``` + +- Summary line **≤ 50 characters**, body wrapped at **72**. +- Reference the tracking issue with a `Fix #` trailer when there is one + (`nano-migrate ... --issue `). +- Include a **`Signed-off-by`** line (`git commit -s`). The tool signs off by + default; this requires a configured real name/email. +- **Factor commits sensibly** — not one giant commit of unrelated things, not a + swarm of tiny ones. Rebase away accidental whitespace/reformatting before the + PR. A clean migration is usually a single commit per repo. + +## Build and test clean + +A migration is not done until the repo **builds clean and all tests pass**: + +- `dotnet build` (or the repo's build) must restore and build with no errors. +- Run the repo's unit tests; they must pass, including any that exercise the + migrated project. +- Rebase on `upstream/main` if upstream moved while you worked, then re-build and + re-test before pushing. + +Do not open (or mark ready) a PR whose build is red. + +## Opening the PR + +- Push the branch to **your fork**, then open a PR against the upstream repo's + **`main`** branch. The tool stops at the local commit — it does not push or + open PRs; do that with the maintainers' normal flow. +- **Follow the PR template** that GitHub shows; it doubles as a submission + checklist. Fill in every section. +- A migration PR is typically **`area-Config-and-Build`** (project system / + build). For org-wide tooling/coordination, `area-Infrastructure-and-Organization` + may also apply. Maintainers manage the lifecycle labels (`code review`, + `do not merge`, `merge OK`); you generally don't set those yourself. +- It's fine to open a **`[WIP]`** PR early to start feedback, and it's fine to be + asked to **squash** commits before merge. +- Expect review comments — they are normal and not a rejection. Resolve feedback + and keep the branch building green. + +## C# coding style (only if you touch .cs) + +The migration should not change `.cs` files. But if you must (e.g. you drop a +hand-written `Properties/AssemblyInfo.cs` and need to re-express a custom +assembly attribute, or add a small fix), the change must follow the project's C# +style. Key rules: + +- **Visual Studio defaults**, Allman braces (each brace on its own line), braces + even on single-line blocks, **4 spaces** indentation, no tabs. +- `_camelCase` for private/internal fields; `s_` for static, `t_` for thread + static; `static readonly` (in that order) where possible. +- Always state visibility, visibility first (`private string _foo`, `public + abstract`). Avoid `this.` unless required. +- `using` directives at the top, outside the namespace, sorted alphabetically. +- Use language keywords, not BCL type names (`int`, `string`, not `Int32`, + `String`). Use `var` only when the type is obvious. Use `nameof(...)` over + string literals. `PascalCase` for constants. +- One empty line maximum between members; a blank line after a closing `}` unless + another `}` or an `else`/continuation follows; no spurious whitespace. +- **XML doc comments (`///`) on all public members**, including params and + returns. Prefer documenting exceptions in the comment and keep thrown-exception + message strings empty to save PE space (except for tooling like `nanoff`). +- **Include the file license header** (the two-line simplified form is fine). +- Prefer full words over abbreviations; for accepted acronyms follow Pascal + casing (`HttpSomething`, not `HTTPSomething`). +- If an existing file already uses a different convention (e.g. `m_member`), keep + that file's existing style. + +Re-run the project's `.editorconfig` formatting (in VS, `Ctrl+K, Ctrl+D`) before +committing any code change. + +## What the tool does vs. what you do + +| Requirement | `nano-migrate` | You | +|--------------------------------------|-----------------------|-----| +| Convert project files | ✅ automatic | | +| Commit message format (50/72, Fix #) | ✅ `--issue` | | +| `Signed-off-by` sign-off | ✅ default (needs real name in git config) | | +| Reject `develop*` branch names | ✅ guard | | +| Leave `.cs` and `.editorconfig` alone| ✅ by design | | +| Sign the CLA | | ✅ | +| Open/track the issue | | ✅ | +| Build clean + tests pass | | ✅ | +| Push to fork, open PR, fill template | | ✅ | +| Apply/respond to review | | ✅ | diff --git a/skills/nanoframework-sdk-migration/references/migration-rules.md b/skills/nanoframework-sdk-migration/references/migration-rules.md new file mode 100644 index 0000000..ff5bff9 --- /dev/null +++ b/skills/nanoframework-sdk-migration/references/migration-rules.md @@ -0,0 +1,156 @@ +# Migration rules reference + +The precise rules the converter (`tools/NanoMigrate`) applies, the reasoning +behind each, and the edge cases you may meet. Read this when a `MANUAL REVIEW +NEEDED` item appears, when output looks surprising, or when changing the rules. + +## Contents + +- [Property handling](#property-handling) +- [Reference and package handling](#reference-and-package-handling) +- [Compile items and AssemblyInfo](#compile-items-and-assemblyinfo) +- [Other item types](#other-item-types) +- [.nuspec folding](#nuspec-folding) +- [File operations and safety](#file-operations-and-safety) +- [Fail-loud philosophy](#fail-loud-philosophy) +- [Dependency ordering for the fleet](#dependency-ordering-for-the-fleet) +- [Things intentionally out of scope](#things-intentionally-out-of-scope) + +## Property handling + +The legacy project carries properties that the SDK now supplies, plus boilerplate +the flavored project system needed. These are **dropped**: + +`ProjectTypeGuids`, `ProjectGuid`, `FileAlignment`, `AppDesignerFolder`, +`NanoFrameworkProjectSystemPath`, `TargetFrameworkVersion`, `OldToolsVersion`, +`Configuration`, `Platform`. + +`TargetFrameworkVersion` (`v1.0`) is replaced by a single `` +(default `netnano1.0`, override with `--tfm`). `Configuration`/`Platform` defaults +come from the SDK. The `NFProjectSystem` import disappears entirely — the +`Sdk="nanoFramework.Sdk/"` attribute replaces it. + +These are **carried through verbatim** when present: + +`RootNamespace`, `AssemblyName`, `DocumentationFile`, `DefineConstants`, +`LangVersion`, plus packaging metadata `Description`, `Authors`, `PackageTags`, +`Copyright`. + +Any other property is **not** copied. If a repo relies on an unusual property, +add it back by hand after reviewing whether the SDK already provides it. (This is +a conservative default: copying unknown properties blindly risks dragging legacy +assumptions into the SDK build.) + +## Reference and package handling + +The legacy system used `` items resolved through `packages.config`. +The SDK system uses ``. Conversion: + +1. For each ``, strip any assembly-name suffix after a + comma, giving the bare name `X`. +2. Apply legacy aliases: `mscorlib` and `System` both map to the package + `nanoFramework.CoreLibrary`, because the legacy reference name does not match + the NuGet package id. +3. Resolve the version from `packages.config` (by package id, then by the raw + reference name). +4. If that fails, **fall back** to inferring the version from the `HintPath` + folder segment (e.g. `packages\nanoFramework.CoreLibrary.1.15.0\lib\...` → + `1.15.0`). This inference is always flagged for review so a human confirms it. +5. If neither works, emit a review line and leave the reference for manual mapping. + +`` items already present are carried through (version taken from +the item, else from `packages.config`). `PackageReference`s are emitted sorted by +package id for stable diffs. + +## Compile items and AssemblyInfo + +SDK-style projects glob `**/*.cs` by default, so explicit `` items for +files the glob already covers are redundant and are **dropped**. The rule: + +- A `Compile Include="Foo.cs"` (top-level, no folder separator) → dropped. +- A `Compile Include="Internal\Helper.cs"` (in a subfolder) → **kept**, because + legacy projects sometimes list files that the default glob would still pick up + but whose presence is load-bearing in unusual layouts; preserving them is the + safe choice. +- A `Compile` with a `Link` attribute → kept (it is a linked file, not a default). +- `Properties\AssemblyInfo.cs` → **dropped even though it is hand-written**, + because the SDK's `GenerateAssemblyInfo` produces assembly attributes and a + checked-in `AssemblyInfo.cs` causes duplicate-attribute build errors. If the + repo needs custom assembly attributes, set them as MSBuild properties + (``, ``, etc.) or disable `GenerateAssemblyInfo` deliberately. + +## Other item types + +- `None Include="packages.config"` and `None Include="*.nuspec"` → dropped (both + are obsolete after migration). +- Other `None`, plus `EmbeddedResource` and `Content` → kept verbatim (attributes + preserved, emitted self-closing). +- `ProjectReference` → kept verbatim. +- Any other item element → emitted as a review line ("Unhandled item…") so a + human decides. The tool does not silently drop or transform unknown items. + +## .nuspec folding + +If a `.nuspec` sits next to the project, its `` is mapped into MSBuild +Pack properties so `dotnet pack` reproduces the package: + +| .nuspec | MSBuild property | +|----------------|-----------------------| +| `id` | `PackageId` | +| `description` | `Description` | +| `authors` | `Authors` | +| `tags` | `PackageTags` | +| `projectUrl` | `PackageProjectUrl` | + +Existing project properties win (the fold uses set-if-absent), so a value already +on the project is not overwritten. `version` is intentionally **not** folded: +versioning is usually handled by Nerdbank.GitVersioning or a CI property, and +hard-coding it from an old `.nuspec` would regress that. Confirm the repo's +versioning strategy carries over. + +## File operations and safety + +- By default a `.nfproj.bak` is written next to each converted project. + `--no-backup` skips it; `fleet --commit` skips it automatically (git history is + the backup). +- With the default `--ext .nfproj`, the project is rewritten in place. With + `--ext .csproj`, a new `.csproj` is written and the original `.nfproj` is + removed. +- `packages.config` is deleted after its contents are folded into + `PackageReference`s. +- `--dry-run` performs the full analysis and prints the review list but writes + nothing and touches no git state — safe to run anywhere, any number of times. + +## Fail-loud philosophy + +The tool never guesses when it is unsure. Every uncertain case becomes a review +line rather than a silent transformation. This matters at fleet scale: a silent +wrong guess multiplied across 100+ repos is far more expensive than a review line +a human clears in seconds. When triaging a fleet report, treat the review list as +the real work and the clean set as already done. + +## Dependency ordering for the fleet + +Migrate leaf-first so a dependency is already available as an SDK-style package +when its dependents build: + +1. `nanoFramework.CoreLibrary` (the root; may need a special SDK variant). +2. Base/runtime libraries (`nanoFramework.Runtime.*`, `System.*` bindings). +3. Device and protocol libraries that depend on the base. +4. Higher-level IoT bindings and aggregates last. + +Within a single dry-run the order does not matter (nothing is published), but it +does matter for the publish/PR sequence: do not merge a dependent before the +dependency it relies on has shipped an SDK-style package. + +## Things intentionally out of scope + +The tool stops at the project system. It does not, and should not be extended to: + +- generate OTA update artifacts or manifests, +- produce `runtimes/{rid}/native/` layouts or relocatable native modules, +- emit ABI/module compatibility manifests, +- build firmware or invoke the metadata processor. + +Those belong to separate, later phases. Keeping this tool narrow is what makes it +safe to run unattended across the whole fleet. diff --git a/tools/NanoMigrate/nano-migrate.py b/tools/NanoMigrate/nano-migrate.py deleted file mode 100644 index 65a6aa3..0000000 --- a/tools/NanoMigrate/nano-migrate.py +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env python3 -"""nano-migrate: convert a legacy .nfproj to an SDK-style nanoFramework project.""" -import sys, re, json, shutil, xml.etree.ElementTree as ET -from pathlib import Path - -NS = "http://schemas.microsoft.com/developer/msbuild/2003" -ET.register_namespace("", NS) -Q = lambda t: f"{{{NS}}}{t}" - -DROP_PROPS = { - "ProjectTypeGuids","ProjectGuid","FileAlignment","AppDesignerFolder", - "NanoFrameworkProjectSystemPath","TargetFrameworkVersion","OldToolsVersion", - "Configuration","Platform", # SDK supplies defaults -} -KEEP_PROPS = { # carried through verbatim if present - "RootNamespace","AssemblyName","DocumentationFile","DefineConstants","LangVersion", -} - -def load_packages_config(proj_dir): - pc = proj_dir / "packages.config" - refs = {} - if pc.exists(): - for p in ET.parse(pc).getroot().findall("package"): - refs[p.get("id")] = p.get("version") - return refs - -# legacy names whose NuGet package id differs from X -LEGACY_PKG_ALIASES = { - "mscorlib": "nanoFramework.CoreLibrary", - "System": "nanoFramework.CoreLibrary", -} - -def is_default_compile(inc): # default glob already covers plain **/*.cs - base = inc.lstrip(".\\") - if not inc.endswith(".cs"): - return False - # a hand-written AssemblyInfo.cs collides with GenerateAssemblyInfo → must be dropped - if base.replace("\\", "/").endswith("Properties/AssemblyInfo.cs"): - return True # treat as default → dropped (SDK regenerates it) - return "\\" not in base - -def convert(nfproj: Path, sdk_version: str, tfm: str, out_ext: str): - proj_dir = nfproj.parent - tree = ET.parse(nfproj); root = tree.getroot() - pkgs = load_packages_config(proj_dir) - - props, pkg_refs, proj_refs, keep_items, review = {}, {}, [], [], [] - - for pg in root.findall(Q("PropertyGroup")): - for el in list(pg): - tag = el.tag.replace(f"{{{NS}}}","") - if tag in DROP_PROPS: continue - if tag in KEEP_PROPS or tag in ("Description","Authors","PackageTags","Copyright"): - props[tag] = el.text - - for ig in root.findall(Q("ItemGroup")): - for el in list(ig): - tag = el.tag.replace(f"{{{NS}}}","") - inc = el.get("Include","") - if tag == "Reference": - name = inc.split(",")[0] - name = LEGACY_PKG_ALIASES.get(name, name) - ver = pkgs.get(name) or pkgs.get(inc.split(",")[0]) - if ver: pkg_refs[name] = ver - else: review.append(f"Reference without resolvable version: {inc} " - f"(map to a PackageReference manually)") - elif tag == "PackageReference": - pkg_refs[inc] = el.get("Version") or pkgs.get(inc,"") - elif tag == "ProjectReference": - proj_refs.append(inc) - elif tag == "Compile": - if not is_default_compile(inc) or el.get("Link"): - keep_items.append(("Compile", el)) # non-default → preserve - elif tag == "None": - if inc not in ("packages.config",) and not inc.endswith(".nuspec"): - keep_items.append(("None", el)) - elif tag in ("EmbeddedResource","Content"): - keep_items.append((tag, el)) - else: - review.append(f"Unhandled item <{tag} Include='{inc}'>") - - # fold nuspec - nuspec = next(proj_dir.glob("*.nuspec"), None) - if nuspec: - meta = ET.parse(nuspec).getroot().find(".//{*}metadata") - if meta is not None: - for k_xml, k_msb in (("id","PackageId"),("description","Description"), - ("authors","Authors"),("tags","PackageTags"), - ("projectUrl","PackageProjectUrl")): - e = meta.find(f"{{*}}{k_xml}") - if e is not None and e.text: props.setdefault(k_msb, e.text) - - # emit - lines = [f'','',' ', - f' {tfm}'] - for k,v in props.items(): - if v: lines.append(f" <{k}>{v}") - lines += [' ',''] - if pkg_refs: - lines.append(' ') - for n,v in sorted(pkg_refs.items()): - lines.append(f' ') - lines += [' ',''] - if proj_refs: - lines.append(' ') - for r in proj_refs: lines.append(f' ') - lines += [' ',''] - if keep_items: - lines.append(' ') - for tag, el in keep_items: - attrs = " ".join(f'{k}="{v}"' for k,v in el.attrib.items()) - lines.append(f' <{tag} {attrs} />') - lines += [' ',''] - lines.append('') - - out = nfproj.with_suffix(out_ext) - shutil.copy2(nfproj, nfproj.with_suffix(nfproj.suffix + ".bak")) - out.write_text("\n".join(lines), encoding="utf-8") - (proj_dir / "packages.config").unlink(missing_ok=True) - return out, review - -if __name__ == "__main__": - import argparse - ap = argparse.ArgumentParser() - ap.add_argument("path"); ap.add_argument("--sdk", default="2.0.0") - ap.add_argument("--tfm", default="netnano1.0") - ap.add_argument("--ext", default=".nfproj", choices=[".nfproj",".csproj"]) - a = ap.parse_args() - targets = [Path(a.path)] if a.path.endswith(".nfproj") else list(Path(a.path).rglob("*.nfproj")) - total_review = [] - for nf in targets: - out, review = convert(nf, a.sdk, a.tfm, a.ext) - print(f"converted {nf} -> {out}") - for r in review: total_review.append(f" [{nf.name}] {r}") - if total_review: - print("\nMANUAL REVIEW NEEDED:"); print("\n".join(total_review)) - sys.exit(2) diff --git a/tools/NanoMigrate/nanoframework-sdk-migration.skill b/tools/NanoMigrate/nanoframework-sdk-migration.skill deleted file mode 100644 index a9b5395a50dff5969faee654ee66066259eaa977..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23551 zcmbT+Q*bU!)Ft59w(aDNPHfw@ZQHhO+qRt(+qUhT`D!kv>c5()x$VB~UDfN^OF08yt=cl{d0$B6& z%NUEbwN&>MCU>)iaIpQN)g<{2mkownVWWoD^rgG`9(mzL>S~YYTaT;hLT${I(i5LX zjmzM6y{+k84SVOtah=uunwO17zB&it7vK^m9+!tdtMDEVam&rbx>uviP=@I&B5}AI(>it>Pz;|}9xTJXv2ik!5&ABFl_BO4*Ro%? z6bF9@Jp+jxVB}^Snan#9=fMs7XjdM(fU@Q+7<{41^xeBcA?nmnd_TndvqmILb(H4B ziicu{X{)CRAAz_n16c(Fl%8%!x2m6#<-*CvPJRozXx!zOjYMsOJAJ%C)$f1W*~{0f zcQ6!~70zXKTTE|f7C&ZuZ*61n&l3?)$aeOdAs_jox_&x?2jSz(8)#YRkmY=)4F zs6wq@cWLP2T2$J$;iGW0RbBsN8rXJg$eX^@*%`KWg^oV_Jr+eybZ}f4af*sQZ&J<3y6!ux({agYcWTKz7eu9A6ZS{BxY* zLS$pz)9g-t%krL3-^5#3tOv0C_UVuJz9H8`GkHKa+V*MOIjg@JWgLtb=}IvBfN>JF zjLf#~q;aa_6A8UO)6{)7P*>l}W|hq`KZQC@*bhZv+4i67I)2SCjg-Gzy|P4o+Pu?& z7G(ZWX}L$@IBTV?-AhC1ZZO{UWJ~iL`RauRz&4mRjQp{8)BdV*6`j!F>azcUCq1Kh zquqL5{MI_zPsUVA(S9L#JcNM+#b@&<6Gh2HVfUs4F;l##Y@8v%kB%9HX+cpin7_UV zK2q6Ar@9|BK|B{IYb9v&9)L2?4?jvs2mqZ1c{o?KqM@MCl}m}8ecZuFk~UQI9)MQ4 zI{x<{@6WW+=&lEJt55sa^a#|DlyK6EZXnKT0o0AMa)a$^6VlJ-G4&hw=W{oGLg6W6 z5f2FmHDy#396Fy#4fc=tGrFsCVDbHf;69pLX1l2{pKx(-PkY#RRK z>ou1&k%tPZpEEI1>N`S#$%BS#9)RcPcz#Xp@2Hu3hn*|CNg5@;VDL8M)UBphQeh(ff1@s(UfXz3{FcR9LsJW~D!Iz(05L`q@`c`uaKk_Vjae zbF*{IXzT@r)kxSC#{2fp(=(tUsXuB0(?k`+My)oAxf=4=56V4lR9it;w!rlOtPo`a zctEo++N_t0ObJGLHi%x&XO`$ z2}O&+3Q6@>k7^jG&ubx~jw?w_KtLFmGekE8nH8DEyi;UB!{u74uxvgIy5Y5X$z_Bg z{cMPpNsU~CIP&|D_GD{MiH65ls&$|38Pdn9T9H=L#97Sgl%DRk&)4q zDp=kHT~z$PxN7d7Jf-@QSIJ|WxqC(!ba%pUon!DT!Lx!o3`FtC=`M(TQRhCpzYzoS zNo&djE_r)qNT0%_SW<2iRxH2#$i1lV{Z#j^bmBJY?)mhBsi8{F(K#l;MCj;|6Hoz! z9S8T?>wU*>kfFqI4+ufztmdsQf?AJH+>Npg@+zvBA#Z1;6&9Fs>rVNr1F)|TEiq`_ zF88of2vO&0-MkUauV&)Vq*=k6#A>0X&97|B`_UZ(p->$b)R=BtNZw$*k4n+uF7>`= z1F!KDSqOfaS?xOAkE?3RwFsBOt~yZAmW`izflj(yeOzUoYVBvY<<*BG+W9D0F9@SntEX&@HH zI$V$Nq`YB_)UL?v!3?$XJ+SS(?X+5)Tx+Kl38yV~#+bXsqt21&p(DM5;!296_$RiI zut%Wp0eSfZ1%Z3c0b+DBe9YL>TyUPm;m@u~tysD|)WP~S3lA8M+V|@f%l;|uRmxRF z)!gG#_I_>}FWoF7{OO%IbG6jBo%!B^Uh{53kA27hKERnmx)O@0y zCh_l}?*qsd&{J6e;lw4TvHHlbORQ9ukY#`fW2&i;=w@wJri-FYW3YWv_0{|Dzvk6M zM)FA5LU5JfKMZ!0p~q+D|CVv02)m+NdKaAt1*5Q$9TQ{(_>h*ATyD9Fp=enzlR(d7 zH|n$cxbTze@^V@6r$H&enJ*XO{eQO@`el#=2y#&)g7;)+>iyzo5nQ*UDxSIq+&84( z27O*tRHsvLOcY;Ypqho4)r|4;kwZh{2$7`iSf_TOGs*j9PR~P+QjmTkF>O-$qLV3~ zbZqO+v9olrA$u9zXcZo(a3BsF{%U(+C^W1vS%kObhx<`rPtV4>FLQbCC(0Or=ZL@% zY^GX61MO4YhqsoyduQB_@O?)(Zn4$mi=YYl20|rE6EkjFk0x;${>sk+qwiv%-gL~1 zwrh}~U%+&aFvcCJxGbZ81`=sWB2+Y%)V!q7`NVC;u+_2@QQ!ikC|fZVo6!e&3*JPW z%8zuZFH`c0T+2&yWDO`AAN!?WH!^Lk&}OOInD>#G&67IloY&J(Q8O6iUFcF06edPU zq6355#LOZLR(f6qkk6IS`sTyH4in55XG!19GNEem^1qk=lFJ3$7|8wZ=sQ!9qC+U) z`&2*KbN;1D-H>q&A(`kEd99t^G1!`2*r`7l|7o}r5P2-(cF|zYO?7dUJ;&THZoqO~ zAGE%Y{88ufDMl^ojKu=-p5TRmeG}i?y} zx%qWtsD&R~Eit#nWD7#b`sXYC@1V;oZ zCpbI(zEi$i^lI}#px5A5+$v-Qs47a1Fx=LBJZ8TK%$~i(Z^OCtQ%m+C2WBJ z!6QQ|3E=SE)relL!L)eS#>NeJ0Jf%NNTq&6&Vc=#IS`K}ZX|35q=OYY?<86RD3z9Qy)P zKJ#E8*wjIez}_3o1`nWt>-c3DBOOXm60C)k-;!53Pjz$&6FuKXs-8&G2j1^j^R5h`3WPXQs&U2QpEc)#! z-5jRiW&i9$j=}tLMWX*2d9!g_Z9NpPXtAGCG~SM?n_#ASD;b&9YJyM|=Ip|AyPy<0 z(hl_nd7DQNna_uax2A5r_$X1-NkqIy3}Bc9ho^2gym3_=7~}u<9s(9K(O!5B{t`^m zgJdFq$Ak@2i;dEo<0+Hm(Os2;eq1tME$b9^U-_(@_TA$N(RR4O3zWQWZa!g>xKS^j z78$}7gI#otur_uI!x}sR>UWVqou0`I6A@&(EkVCnxyVkNVq^}lrT+rZ7A1b4wIt&)nk=-<>JA7i&u z%&0L}`e;K+b=kZS!Ae#Ywx=bOAHG>%qCrjRf%dY*%&@$mkDMJ`(#$T}W%v#A+;pU> zgZfB+mpam3Vz*hGC4}URy1L;N*XZqDl#Y?7Kd7T5bq1aW!nhC?GO-&mUj2pzK|wrs`#@t@kO)X{gdx zZz_>b?r25MRw;3#_djb;%XTZT($dN~t849ey8F>;qk>g_%S_Z+w>U}f+NHXbt=!O1 zFKe}@Dm3XZgpTMxw^pekQR9aY$}qG&617Qv$n>Ztxmi%YTCc!RKi<*X`*-*NcJ=;w zIR7X+dHedh`liC(tfbkgEFOyOT6^ui=&_91)h3&Z@w-l~F*@vRx)TH$v_|0O;>@#s4NIXo9_81fac|hkRze9xQ(1G(-GE8u8t$Z!%i74GA98sGy ze=S}?k$o!gKqLdufsQ4fe9C*akdNZ6tW@509vi$L_(x2P)0#Aons|?>>J?Jg8sUme zD(svjF;~^!{i)Vs7LSC7Qe^E#2?KhyQ770$b`a`hUQ;EOdAmOmUaOQ^#T=-e7_dmw zsG-tPro3Y0m`L`K-S7_~&UfW%>O`8UX2JYZLa)c>xoYRIw#8w?Rej1(a3lS`t?7e# zlG;~at5en6m|by*TkSCR*_z9Z@W?mI&-Ag;CN`;8gjM1na5%Esk$Gc}Z%e$-ZYo`$U^3ScGUH zP(e*y>bg}wnn%{RcV>>+QNHy_@((h2Cs-szzD6^?&jbO|$IUe9G>~UoZlj*crsz8| zn!FnEq2JeuI#=%TUDeDQ<;`{vcG_(I?ND`o?y-=Uz3YNEYY)!B)gOemg&X22D8$+e zb?d+*@Vo|HI*6v=4UH9>i*_Z*gccg;En*uK`tt?1yvaL{;$ONDy|V3578jP371o ztMZjRm9WD1A>u!#!HX)66n988Hm;6d;^^YtB1*Z=Q?~|Xxj02fK`Pt{Eqyygty`YE zm_U&*v*N9SJC!v$jg=q!UWZ;N1(=^!;1?EfTUtBzqcMKn@odO*9B3Gwx7ZMT>%sb5|t z=ga321K}NZ4GSj>F@Y^%FhOzDSpN`zic<&;RBYAiM5XS+i*^=tob7zfDLl_*0yzw} zJv=lmh+xx;^nn{~iD7bT(-Ar~3pfPIxRBs*_2tq}$1gpA7reF7*R(*M>r^NW!TC_^ zvSQp7$eS9Fg;1A#Imv=p-r4?>`dBdRno8Lt?^*aSQv=&Z9JvM19Q<>#JOpx&4F9!rL;=D*eYEASDk};1gfwQiG9xO-g z3+7bWq{>$H{j1q%qTEPkXdb^i2z}E`J2`Cs_ni)pzY6UxtkyaVdN0W4?dhQMrA7-z zt@@QvHH1y})3ryku)*bq3-loEXIIT)&O{xESq)g!Ll95=4<&HOi+3oi7toQU1_B4p z4bZB@@*`dYgJa(Z)~6(V2*c$elf60>2OD%2B|%cGb3S8+D=~KZIo*9Ac_)t$!Vibg z88bnG34tT%6%c53*GTPucpo#!CLWeQgo`1f4o0e&LMJezpG4KVa)bav+evr@d;tw9 zjJCflQv@?0@F+$8h1dp6!KG3(-3Q18e|)tinQ;XlnB1YkQ$60mnQo+MrVoHggL+sX zyb8JUyp~F1H!IB|YqE`6k}D3z@)R2)sW`u48gLnTVYA+TaaVw9iVb9CL$9KVn4z@j z*s6rVaRPxHePrI}6#@tgo`-;9-6VP~q9;Be_=dtZvikx_d&QL8a9S(xh>%(dMkhGX zg=xt|j7?H83XoH3y=a_bMf8t%)D4^Qk({K>BaEQTPAn{A=vNxS9|imsO7ySX{+Q!D zt9en^>~rE$yRxN(bF`UwBo8akN?81w)i~dsHI5FV(qZNj?b99)XFZWhlz*n)0zzG3 zx00aE7^C;UK}^q5ttxXBoG&;vdoLqV;K@zAM4()%2v4+96$oPdjJS)Pe2}Lc<&oh( zE=<7ddO(1nhfo?*t|vo+C)!a>?|2s9P}50eQxM}4PvJlv7jZoZ^<(ng2 zBEe9)l*h1wPo)Rt3YG361cM+%6hOk_uN(UH0#riNLNE2~fh`UW*HzDym*JmPbL1dK zOPPjLTXO^ySY5S>)Jxi2phTvIp;-x|PUx_pDYm3oqoQ$c@J*!M>z#7)zUP_Ol+g1( zZ_@we<~Z^OITezqR*}_VA^k1B%X|+IzHA{76lx>e^P<%jpBa_Fz!nT9ynES~9MWNr zpXMw9w$geTjEAG>@uohH{=N_xfKJE9WKqiUFpU`vnVQbYJH+=i^}+R&l5^xUS&Jb7 zPU4T-9Q7x>gM25hqaLzZyHZoTU#l(-`&7%^hF82i zT^#zMCf_?jiPpjW-|fOK|2hT#=I5yguVfv60UQzy;?39JQwtu2!HV(lY|wrPo02X$ zQ^Ar$8|?F0IksM$OA8?%qeqU9^uy>VYcR!x@SSh_#?d;taMGFOW>(6(^ByrIdl%{LN9u&<5ZPi17y37S)Y}5%sVx^c&9T=#fDGu5bC1j)6hj29S2^&p^EVb8! zO#($&cNAub<4W&)Qs`O)Immx*27<-gB%k^H7EI2f~q6HnS>PEJxDRt;1J zbN@cj;gCG6M_7JXp$39zXDX6AI-IwKSa5=b{BuzBrNv|&a~MFxwGs<*Px&;$`1YQA zAPD4T4R;^pH%0^@$p=DW)suZn$KBxlEMm?x?-94oA+DqJJ}5&L5^(pti*7$0XCsKm z9K(L!PURwvbv^5`=m2hvC`@YILycUMEMX@% zg-+x{9%;kAxS*2MKA1 zz2=S8+zA*frof{m|7&vd!9Dva)6sTTVXJ6_@Dy3%_8>nk@fKU8WJ0Dpz$=qb15Mo z9Z(vDN9Ma)CH-^D@;viY5J(2EIs?ph?e@I`Tc)w+p3+DIGU?w$lJlzU2sq70|Nohz z$R(WzD-nQz;)sEO1pnW2l(D^?i<701tBa+bIl$Q7*1^Wo@V|idU!E$}l#M?cL+-s& zXK8|zZmMuxWrDk=$H1=deKlHpH83-#WQ;M5wvl{Ju*s5@A{_>Hnk0V1bN?HewV>&AFFDkrLFq= zrjBm-pW`_-!YW0!t?bWOuaowi^yy};K!8^@}lLmI3H&u^J?A_r%PPhI`_)>4&ZO{^& z@Z^DEWAJ+GskFoP?b=gc;{M@x4p|IQe^)pLre&{mLfQSp-%4Y(W%Sx+-8jlg?w>vN zHdamB$!8V$@R5~XJkajlH309Dz8fhVWXlYOR1%bxjK>coy_DnG^A zb{j;^nzXSg@3#%@z4tZk422jeQv&@Rs?iGYw0VSy>$f<-l>w#7}O{k-6PT zwjo{>(C~c;^zyjxg~osV_VSX?mAj`(6#t`1iED^TkM)~oZ^yic)W<~Iw#!ii?E^!I z$|L(+S>s2N2v#$n5u|k&jp7=*d3o-m#-;5{$|rZvad0F;F#S1)tB#tt{dkE(+sl`* zlcna~Wsc@8)?i-NM~_&hAzhPu*IcVx3TM3~t zu@f}6I%-A?MhwMpS#wRU!<`h*dFTY8;j@J~0MSS3H^9rU%_jY1x?tFfoG>dj*OT>lC4zV5YuLzzBBb>c<+3Zwfz?^|6c;k{BmZ# z@0MyCl`{N;B{0|P%_G)`s5S@k;OS4;<2Gg{uqNt82PKR%6)fe zqo>_sA2j}>UTN8bC;)&knw*FHI7+(dkwZ`R*P-H19?EQy+)DT01k%lf_>?`VYGc_m zXg4j03hC2Fu*Z3c0|}32rg+GdF)3^YJc;O`;*rxLm^1NqC!<}bW){nF`=mz}^+V#6 zC%J#mKRd8la|G5H5vf#C?}a=f=XjVumT~B%_DTR_qei;G>;iqnL1sutQ)c;M?C_)9 zycZO3y(nXiX~$4LJB&0b9{U38(+hI#Eo??W&iJbD>PsyEIglRW!!kIkKtY9}MJUzE z^Rp?fcUFp_iCkf5F?WvtSqIj;x^GrdMN~GA!l9)Xnkr$E6ni*tI@5>Z+{NAX&J3^=1ns7QyY3RagYP1m0r6&cSO+p^>Vg z2r+hgG5mD3Q;ZrXb9ZZaktcZAsP2I%vWvkg9Bz5`q)PkgU>ox@DaX~}KJ`6N33;1o z&yO%tuGn|Bc9DDzL`d`}k=`_rX@kDmRy%?0a&0Y>`kNx`fNfvbUaymzrDnkASTt=m zPRfHrcc6;;BFW>z%B$|sLLf8Zx%6Xx&EA#5z4R)i7?P~e*~0*?&_Zche9#kWs1_gB z(z!sfsK{PBi7L|inL;;+rtOD@_mk8tp{CaQ0@q39E++|FZ$-!V+G^bA)R@En=NUXx zoCC!#zE>C_?73LGc3Xv>Xs@_ z1IHQ>a#5UH_mzYBJz}ra^Ppx`H{@FTKqH`gp(E2Rylg^%6!Iz4`YE;6KAjMA$I&~O zz@gdNFK>`+9Xvcu(BU{J37}L$-5%`e^od;#&#ZYoZStrxcwB^mO0I{|E;k-{ z;rW+~BtYX{>N(vKO)U>D2@_Q}t1P$sih|u&b))TlHus(>*Y6JBF97O%pG*+mXCNKX z#}8>!INxZ#i1!7sGWT_3X!u%di^F&R_v>v#{P&PK>~-!2Zs5Uf90dqBxo> zJvrKfq51YIrhdXBt2^=*YJ<^JYdh0BK7@>sVke@ZWJ*FU9P(7hBU4-f)Zfh=!}NH- ze8t!-a7H}YK|oE>^XUWe-{+m@_Y)@tzL+1%y3gS!&vvb?z8fMs2PKZKXR|%>O+^-iNTQfv)bc(LY01Feeyplz0PJ-V9q;0L zS;NaYG>)QvdmWnAWkrI}^_BzKwXL>fJnLgIy*hdGaK_)p?fD8%d3GO5Q}A zVjQVGeM~>iD3X^GNWrNx>9lY7+1|vJhQzx87$>Rx557K!@-!Ku@}o{*=%RxyEHW|2 z_0`haSC($3HlNB4QCP>Hz zVCH3emeQHx14c|f)n)t%`wS$l%>(0%AfpX;1no|Btb(P|;iKZPcyMX+(g#%rZ&5lQ zS{J&bsD)q)g%rGcDDN89a{>Zc^goY7!+pKDXTUlK^nmB2i``DP0P(Wg!nvS9I!MP9 zu=_67%dCI$%1p2RMDgm;BMSGUz#m#KOg&IQ!_907HT1t`?T5H@?wo7@N?Gn22}wu` zEVFr##}ckkDhbLgyy%x^z+aVhthw)R#KKihfKDz;kTwJ-ZuS zA981TLuj#!;C2J@rK!^8<12^VTX(29(?>T=AyrBjaCsO_mw&s5Zk9sfI2Dzj`~qP# zei`%m<;@Yn=WrF(N-g}{o(DKJ<|P!s)$2k_xl{JDOyK_edJ`+^`+i(sT2nwsy0if* zPme6x!UXgR-7ALZ<@WR&Ntn12*dllDZS9BCNEJ=?H24o`-2a`|87{KYX^z6S8~SmM zw9y0{nsITr-e!C3S3K`aPZSMO+QqvTy~s;R$|bFgee}mQouun}Px8J&R{$;dsmK>W zEm(?$T%fpY!?T5ql`;RDDpkM2Wk~=i4t{_yu9T>IK=B4D?jJIPsTVQkHso#fEo=36-AZ&Q?gASR383aiqx@DSv4sBE57Bk-wFFzTDsu z-!(PxC~&vcN3#(*EdxdVDnyK4?x2%4NRvFZyyugQegEsTT=9{h6*cr zN8nzh)WNbQ6-ae`=4~ezu1_D~)29f9cECWgbQEcY3ukq?i{VI~z63sq_Iv3T?o0~; zz&6o6^io72H9fy^>smwvaePM((-OQ}U-in=9CQb*aYw`iR~12YZ+!wa1#YvITEuBK z1s^6vjW+*yR!%+ug*G^orU$P92lc@UsXiJ@m%^9?dTNJSZ`ce{E4nP<*^^5T6jOd} zcY)#<5`fP^%IhTv6yuKs;w{<3ldgzfGPN)Jqqn6cHH$cZ)bddj#*@RDvh9rS!l&?6ty+*^EFpf6p@ z)03>xf1W;wzB+ri!BZc`e4-bVaQSkA!zS35yiC;pcWpbChqIn?V2m)w3i|zMVG=aR(LUf1iD= z*@SjQT{od#J@AmYuu!GDO6mTfFo@2xGvTerm@%Hu=ZF0@3)MJiXri^%h+nBkT`~XtsJ#z&0 z&7}DGIFR1$AMq$mSc}ue|5X+0kER~Qp;%jj^ju;NW5eY9J-)CxzL%Zth}<}Sbtce5 zG?uD^_YB0x_3`!h_^@^SYw_{>e7>P6X1QPwt9f?Ke?hp@Q(iJ&g^#W(wna97x%_`2 z|7Q-iVX{9*xKhJ7!(ceG;`m<&+YLItF9cI znA}MMT-m|RFV_9U1LkZE5;P;S{~qtGW^?)A|25zqbxKq2Rbw7LI>22pk<<~o`FozkhJot+rUb!O!i4TV$yxg(*yEs z#>w{#c$PGtR%aujMuQ|u&YV>KCJ-eybDmLG$5)_~fSLeYkXjZ52`lhq<~d9vie#Q6Ehn&|N_U+6 z*gDJLfbAUVG6niWN_iD3PAojd}wrS&(*#FLM zlPU2osuB|5W__5)%f;9oz)&Wx70{`$jZ+rEN?(Ow@EEGKH?yAAtLOf{kBNrp>)n>+5~vh^~aZ zsC?=Yogd~33BnZbY~oHvDLGjT{sQX_A@ux*8@!eDcc{=tdPu~xXC-YkeG(-`5X05o zHUxb!J+3gF=Y!MQ_zUs%qmdp4j~PcWnnTfyATJ5%N0zoq_+URE)}J6Ay@=t>%TC;6 zWW|Es9c?nedYK2|E?e9hTlFvd?JisF@9mc`ko~evJU%!n-*M59a%l!)Qq8R`Te`O3 z@JO`z+qYIZM8HHoPb9%+LNG*Dy7JJpF)oDM0|)<#E#|(zVAVC0MrBl&5#up_*@%9<1v5pGNsM>ogj|C ziA2-c=|gnD2zo7RK{jSunsk`SAHCfSckCn*dIn35+#GzU=A$iTW4Ic?)Y zV-^oNm?H(U7VVLUZ;6xc%6UT|~l$ceo5T3UgQ;^31d4$d;E#H@nFA}*p~gWOLi5sjX6 z?3rUQ9e{n^K7`UD%WtSE~U4oVQ9Qnr4|lfAyXeMkP0=x zfcn1&6X3;rRPCCNvcT1l>|@liGzi0W%?BHlAmvZ92;cCHQ3*E9eXoXcon) zk2>LCwjm)zCeH9_hB`aWIz|4_8d+QWCQ#}i-rKddX#d{3$EE;UKWAd}6rgZo{1B{5K_&L<^YiR2U45jl&7Paa*hGs4)aHuJQ^7 zxx{rH)Fcv2onl0rNW@q6Ey-&YaCnGUa%D?dC@EAz*4v`c^ASsz5c^R+)FfH-I$hWY zaXYtp{ClNHAm~*l-FcwSP~pajO~Bu5Koq7T2U?MF4{Wsa1pKVjppS@ujNtz1EhcSR z^(245@PSDSxx5e$G^Tp7vy0!?%Dmo;r^#&lL3ulhN31QFb{h3E?h@eTd$4Q-lHXGH zrESo~99Dbgp&zV5_MwkbLSqM!vPm-5K$G@n8T$E-u828)4=9gt)+u1RSGNiPZbqw6 zs!KooRg*=pJg*acbN%|X%JcVjpU&~_`VTy1*g6-UEM%`IaHl(?=D^bvpP{#pB zEjXML?f`%bm5A@;1P&W!=%lz?O(ek{XXyyI#`c>5za1`7^+28-R zLK`M1psKa|6xe&j-Uo(L9izdA^%Ux{_hJ*{e;&l-1$XF7CUL0QZJ|f=jq2mp_o6S> zU|z9<)S1+!3Ki^y68${ckO}WaJD9$kuDXb1>T}#q5b3{*0REc5!(h6{DakV8bUWy= zQ*kZTIL}fTev-k5o6#;aT5IfzZ-fZ ziFI>YK4(&-wiKw< zpUd0HP10#s!*sf_jM~S^c^j-&rY;k!bq2)0tuPogT5=C0@b)XiULA`rXZnEJJOc>H ztf6~@X1ql|v&*#in39FW3e6q0Zh#@1tojCkKVQa0z}2*#DUp!o@0A-7)Tg4^-GG=pY-Aw5fP zvvt|0Fb$|1@D6x3sG5~(V`1zl@@w1&`ZsyIM2C?Coc03Al z4)ReRbd$Y8;ybbQ^t1F5OY1LX^uCMU<+&D`sBQ?&F6CYQG_!@lf9FQs)er(5&oa+b zxijH3IOmGLhbqDg<)kLx4kF9S4QsI3j6^Ic0+LuNl3K;jV;29kBmpbWT6GC4MRDxi z+$(yY1~wbvZLDH#OevG4x(=pBKECBf-R}q=UcsMLq+f0wucdpjV6S>6ma#Z9=dgI5 zRo8C&af??6cDy^P>}K)txU7}fD%kF7KG7S6;MXOb;15>jy5sdjAUFl z73o~Ayd_*fKfD5O+KjPYKL{swQul8TsgjmL_i$tPOLu zp0DbZNv*9Y(QQ{-SW5s8b9tRfm(Avz1$2NCwF;g5E&uzg>0@=p!3bd4wHX~~2F1V_ zv=+<73_o7esZ22^Z1z&lu=yJ|W8Zl>lLcN2>Lby_zG_IO`p*s~t?c*Cjs|SqbsQsC? zqkQ%=gW}SQ=6n z!IGyhsIKOW-x!x-OP%4ywfLYL^r?Yid8O zx-0OE=2%3Rx6oQ1)Pulf3{lke!*@kiRnw@j2|(N}hnq${y}C$xY!E7gmscwZxn^z!# zh%O<&38go6O$^1HvXsI>I8B9^AU7&ih)ET!I|OyZY23q0h&s{F65EWe>eI%mB^f54?Oi0&FB)GGgfLxi%TBsGF@%e=Wd^C5g&mCO z=)OGOe7yX_`FPxnhLVs$plyHAU(qsPphGO)s-}wrJZ5$4{wX_E>~#D9hQ0Oild!!O4f!x zgEt-2aO)Ir33%i8C^a9{CKilWSAwu+E1wEDSm4ULkfYf)0&u5vugj6*4v_eAPM;{=#d_)RgBI&A)@=9F8k1Lz4e24ioQB}YNoY+IZ!BdZwN1_+-%i1RH>E< z@|E3Aq=y;|G_=xc*jN1~@k3r2z(~fvo&lS?&0sO8cm2bMGA!6;U-(fy;G~8`&yd}) zU^+DkNM1%>^yOOOzP6~SA-9g3b;zcy zI^UtW0nUY&Ur`72GOFTg54bBTlo^oTMjFs(;hfV#sY5^-yY2drxXJ!@o>NgYrM0U? zc1y~k)O{$QOH@XT#tH*Ys+DST zsh%la-j#a5Y||&#l)K&(3X2^+lG~_tE{3=;E)Z< z3tm}^*U7YJrCn?6P%E5TTW?^b5R$lm zIr$Ide@fjt-hc}{JP?pPEf5gT|A*8G+uOOBI=PrS{bzN(T02f#9q4^;%0xLbGHlGL zw&j0pZKhww-2PW5XBiY{lkM>t26uM}?moCf@FBtNZlnKIcDUR2~BYA62iIzWtP^3u^BWp&6CRWX~S%pVqSExT=_!gMUk^|8TD=I7H7D zL!&j2xK5{8u1~YkFSRjHxKv&q(hKlFCTD%=yVV!aqSY3FlY|LYOx9b{43TIPLM{2?lF! z!7-@A{acWPc!xm-RRAPS5z@k#OIU1Foe#cwP4~9@H94?JT9m_P+m_rlR^&AlJ*+73 zix82f(y9xwdv796fckCO>TVusb1en-$43A7(nKT%D>5uwh0=j^YM1!+I9>r3J^lqC zvMah_oIV4S3QT^0@^PUtMSMt47CXuOl))gI3A%>zULtcF!WyH*a5hdW^|ar{10UXc z>mW`KRKt(M8}aOq=P)q%OlydjG{m}Ngqo=;E70LZ4VugGgfRhM-bf6|1ncjRY|j*G zlZ~w2QyBCVOtTCe<67}6^O1iDhE;U`8}VmfMGa-WK##lkxRIDbo8 zEk;c1(xs>7MjhI<(&lxJf&u|u16sKRXx$-j^>8Hm>Fx@7iwNV5Dcp0_Rz;v24B9rBOmyyizC)%5IT3&enK${t)Cjo| z_5w(?p4Y~lN2^e=#xx+_g82aa&4G$|S`^Xtit1-9ohwc<)91zqf;&n)QqRs#h0Wgz zKc?m6W-VlrVKCz-?T+ z-9kA8u`cjrs8ih1EQ5?kLDjEisf;&vQXc{R8XL2ilOart-f%2wab^nX<{OkJw%0Ji z!WzzjU+!N$4FOVvGTH7lOR+K7hk=dfgK0^Et6hcNkZbj66yYIMCru|6qCvXjb{BE#jHZ~vpDewn_-)`uJ#Q>}Jl+zP8QHr8i%JmkaSrFcDHNNJjL zr6$*F{0a|nk4vpD4lRp^ctw?dFmgn!f@mpAt(DM3WvaD1azIVLVgz6W+a{##jaUL+ z6rUPkGSn0_+825WK-x5mZQQm`PSUJs8P29_#)?HLzco(i7ahJa7}i+Jjv#p&Ir%Qm z1cV40c&>JobYc3dpG7uCqs2rkVq=GGxU&}dWYgA%X>VJki1{K^>L`&opuSnY8r?Io zPfNa_W<=->^m{^&9~@qycJL8$c6zWMu$bMiHZb`_7_rbnN>who0`q#Ai1u~f*0kO? zi04KuCxQ$9f%vE-&hY$Ums11kic!Rfe}Zk)|LHvr&B-V;_#@Nm;|fBM3x0n?bd6|B zN9Gc;DZwR*hBFTvt)x1=Xoi_zwe1JlngAZ1x?izY9b*K~9E+Pcys-;(w&DrVu>+lI zPSimGF=!G($#2Z&C2^eV3#J8(Kl2Vc_2T%J6G0eg&4wI8vwa>QO@8|^@sqODq~id#Gc*-G00+t`xp3{#RF zZ)pN;YF9yKC9l0>F9rCsfmf}{UPjCav7LFKgE-kFpy!S|OrzMw=CSA%MUpMT(YuMK z#&;Q=S@0f%4eit73&{qf<6B20_<#v8v@URs?2L3Y0{u=uGo#=|DTdP{(n5N(f0xsa zr*S45eOa1G|NW$TLHG=rIx%3nrUjY?v(wbOV2t<09e|pfM!o~qnUq#+#p+h{V?^fk zDHr)8X27T>Uj)lGWxK){(iks2C*`~zUpLe=m{Bo)cw614J>7dMJ- zUTMr3dkm=;GQL>kS%uUc{_!zcG_yKOU$U@9S3Zp+?eNd!%`I3uD>I3)HZz^j`%6aB zHEX#=H*#o{@J!M9PmaN-JfvY&T#<%ftnQ2*YVf!W-0w%jmFQ zUgZh-D9Gq$%>rhoy7|l-im-_$bMTV)qXuh>e=Z!>k}XwX)l>Jxg~mVa`Bjb8nEGu} zoeP8!mFXm8IMN@Og1^OA!udeo>|1co=3K?^T!tV5<%PyM+7j6%MP0vi7w4zo`?+uP zJG(FXbruB6`M)m{TAI@*nbc_)V?)|zAR%E&%zd@~Xj&uGBdP7hGwC#QM-2!G1B;F; zA6_;l%+zaUY)JIQ-p$E3J%pcOl* z!G$d5qO8i97zDpi`RJ~^;q|VlTLj;KEUl^5QxkXRS!Hl`n!Uj}q${!Sgr1OqNHH_6 z!!P&cAF^~vBCozqP}HJYZw*%YgNjKq+TPt5E7XmVOq)i1w@X-&qgT|h!&(&_#0fi- z!x;o<#C))DM7hor`fSRy>7~PL6yPnbSNPEK!xUNJ&_i33>+KzKaYIfK?hjl8$GGnW z#{P9ls^>7TdMLZiAQ=Vs{!};OW@ndg)H||{*GnZ+-BJ)J4;stO)o@@!sY{iVlTl*l z+==GuyV5Fta^?*M@sfVwkOKiOJ%xR%R`(OXipxF}`ngSS@`|hXhd64YSQ`e5yFTRN z@4w_999%UtbnSnc-Z|An0$Xx5ldvI(CDRC@2|1|hLY!`pV8g3{Fr`Ap%@NTI+juT+ zeFMz0gxQ1NN&QJC+)GFXGYE_}?-Q57rLQzSDt}k`I37y9;n1@9kczm3X~*IEc8*>S zqra(PWT2EO9UgV{s)IG^YR4!W1Sv(*L+LF_y70HeEx{gl4wZHF(mguQj1WUtW;2$f zNV(VTAefsZ12;vHg1+WhVe_Try>E4=wjFw?u8DQHqgbS*JvLD z%UUml?Nn=pq>XG2E!=ohy*SaK?m|qaf@g%dsX{E;KrU^3PJYNXk;1Mn_=RUy$(6^| z1q;K;Wk(!!rT_}ifbt;`gL382vUt9ET;D-(yIcx`F}5`;z1a!BiZ4FRNr9a~`Kj80 z$Jo5BeEzZs$jqr%TK)w5$1uxnYJB|-3IKph{NE)+Ks{VsoZZ}i2U$saj?e`K+{Ysm zS4}Y)@=C}Zo(1VMrd+Jpph^E7cRuUg*I$)9k6Hj<`}iO59zq`&ms%3;>4s!3Az_Y( zN58w|*w^wcbJ-*7AC+{iW=xMZ!{En$o592*&x5-SJRjcE7=w@gh4eNZ7l}-fiyE}t zM83Ysd%Ue%1oBv*aN`Qa=sHgH5-VowZo4l{WNPEoP!%?F>LOZbPv`{002$G3q&V#S zSvhT@ja-7FIUyw;%{7}CRjifDc&3d4EIEj7I^7qzAqOV6%RfPMc`=C=(T_*g%HUwv zKxlSpxzsqenr04FUfq07968QV6*@v2ofv7o%0jO{no4_8n?vWflq<&b;VE%o@FjU?O*2vGp14!MSSnnGqE9<>f4DIaABfRm8zmv9(X$Wlx;q9J%4foF> zC*2asrgl=_ZnLC z-t-sWO6hzSQ3Z1++Dbochf~amXqoT!#WJh(Gi5F$`;${0wbnQsB?X-h84FQvFYN6@ zcd(@4}q>0AtaSF{@c|lNr^DkHV3ApVU(o5$dhQ(gY8=aFt{g4 z$t|OrKS}(gP}_bA-+-|{P*yIa_1sfYPdGN@heu^T#IRrl_=%k@{zLNY9z~NQe`fO8 zEH&XmsgbRtXIKIs_eV}g!XNskwz^jOp5prs-Z*ILY|m-2%R5VRE0LgjM!wj z<7%rlXzebAQ81CwrlJ+`@w|-aKsT6p!y^5ZvVCzDg?J`P?3JMF>-^6rdy9j@NSuUE zQBTV&Z}qBBabZ2aO2c?$nXwNKBTzxYQxAjrMQ=C6`hv}x2v7O)6%-h_wMYpPY@!aX z$E2{Gh|M@orDMlX%yD-7OCs4~B(YJ(2F4xNX09 z3XFVYcvM7)WAvS&G~p1~h+FSz$sG*wSvhQRLbZDVDxv@46UL%!F3p#|ztmB{ zkuNATk`=86vDglMB+`j?srW`BFQzf}QIYnOyvRs=i-^&|;hsVawtc=W8h+>2t<$dq zOLPLs>d)zVlzOdx{k_0;k@7&ZiFyCR)GG9#^Y{hWgFqQ0Qs#MJM$8+?(fez9QcMw1 zvTPGyI{X=UKOSW@8XxY3NJlyf$>(*4zRcs>xv|^jHzHfJy1)X@q^2}B!-aHgwzF^T ztd|jkIQO`k9@ag|8TZFJj9`!TWdy=C6eBX^S)AnHXt|j0Y%2~AcLA%juM752^gazK z1BG@mQi?A%0Z8lDt-{9vN@;>W;ZzoynQAV+5~`E9YVAiUKe`RWA>O$rvCeWruRR|K z2ZBKOt51q<~E&Bk`|Ytka>Il^in&>c>6701$e+z<cA93hIxE&3`cAWP%tmMp?snWtr$&}^CLDvEo@x|WjZ&xL%znj6@H zPnxh!*uzW^%1rHIe2xnkq5S5B3jZ)!iNh=~Ohh*^uSOjL3Am?g?(ZrxkHjLf!BKEc z?xQE=aqo1&%$5&)dXNdR~qZlj{@G;1oiVa#=J?JiWa*2NzaCE zOu=k&L|;3%9{PPbe4F|DD<)-Qvaf5)ptdM)4ViSV#s`_Zh4y0plA^2F0@dn0G4QOj z-mp{_v#F(Aup5CxLQ# zR)0Ktxcv%uZ78j3X5Cjboy2?m^c}_;%)YsK{1MsH;QISViDUNKdWaZBCS&7eGkp1> zW<51B1WfuQDQx-J&!@+PFuxNlNU?;M%}(r>XFcaaIR2Fale`jNdSJ?s=~h_c$@_=t5~es-xC z3;K%YlJ8Su42Gfc!$7LNfsee48QQGSeb^o4Brr#6$-`s>dd01QiKA<@}S=m|}#qer@croRF zWv)Qs~6*RbxAGPwXP>^%is%OW|MU3@4DBMc;3o-D813d1mdcE-Qy8oKfVFmeY@ zR#TLWZh6e9Q|vR2G*2DWx=tcjs`sw?{6ruM*E5pA_nq?8LKKCft989xUZrnbbZ613 zDGX9FX#WV2XCyF0SDV`|bi-_@{wnl}u0=(%^Y)DnTzFiW3$f+%C?%TshB})e>W%y3 z4zcJsq>&NY6}2lrc0DpH;kTy=PwAWsJdLZ|==%1gp$bGG1pRZl;@`jOe_KJ|--drj zy!cOxf2t1spFIGeATZ*$Eb_my_%Ahx{>1!Ql=~NElIq{a{PPmsKXHHNKL3SV;P}_L zf1L^aC+E+Xo4+`r(*HT<@85C$B>fq@{6#uY`_D;#g))C~{@hmo;xuXh=bZn)$^Oav ibH)F~i_!hhd4IqDHB^z1|7H#KcNYf%0Ofjr`}!Zmz>v8B From a647853fa8ca9fd3e0a09e928f06a7873032c629 Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Mon, 15 Jun 2026 23:08:22 -0500 Subject: [PATCH 08/49] Restructure NanoMigrate into Core (library) + Cli (Spectre.Cli) + Tests; NuGet-ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the single console project into a small solution that separates the conversion engine from presentation, with unit tests, all packable. - NanoMigrate.Core (net8.0 library, no console deps): IProjectConverter/ProjectConverter, ConversionOptions, ConvertResult/ConvertStatus (results as data), ProjectScanner, Glob, FleetService (git side-effects behind IGitRunner). Packs as nanoFramework.Migrate.Core. - NanoMigrate.Cli (net8.0, PackAsTool nano-migrate): Spectre.Console.Cli CommandApp with typed Migrate/Clone/Fleet commands (auto-generated help), Rendering/ holds all AnsiConsole output. Packs as the nanoFramework.Migrate tool. - NanoMigrate.Tests (xUnit): 28 tests over the engine — HintPath id/version, glob, idempotency, packages.config mapping, unresolved-ref review note, .sln rewrite, full convert. All pass. Behavior + exit-code contract preserved (logic moved, not rewritten). Spectre pinned to 0.55.0 (latest with a matching Spectre.Console.Cli). One real glob fix: `Beginner/**` now matches `Beginner` itself, per the documented intent + a test. Co-Authored-By: Claude Opus 4.8 --- tools/NanoMigrate/NanoMigrate.csproj | 21 - tools/NanoMigrate/NanoMigrate.sln | 71 ++ tools/NanoMigrate/Program.cs | 681 ------------------ tools/NanoMigrate/Support.cs | 197 ----- .../src/NanoMigrate.Cli/Cli/CloneCommand.cs | 94 +++ .../src/NanoMigrate.Cli/Cli/FleetCommand.cs | 148 ++++ .../src/NanoMigrate.Cli/Cli/MigrateCommand.cs | 157 ++++ .../NanoMigrate/src/NanoMigrate.Cli/GitHub.cs | 49 ++ .../NanoMigrate.Cli/NanoMigrate.Cli.csproj | 46 ++ .../src/NanoMigrate.Cli/ProcessRunner.cs | 34 + .../src/NanoMigrate.Cli/Program.cs | 66 ++ .../Rendering/ConsoleSupport.cs | 26 + .../Rendering/FleetRenderer.cs | 82 +++ .../Rendering/MigrateRenderer.cs | 117 +++ .../src/NanoMigrate.Cli/UserError.cs | 4 + .../src/NanoMigrate.Core/ConversionOptions.cs | 38 + .../src/NanoMigrate.Core/ConvertResult.cs | 52 ++ .../src/NanoMigrate.Core/FleetService.cs | 241 +++++++ .../NanoMigrate/src/NanoMigrate.Core/Glob.cs | 69 ++ .../src/NanoMigrate.Core/IProjectConverter.cs | 15 + .../NanoMigrate.Core/NanoMigrate.Core.csproj | 28 + .../NanoMigrate.Core/ProjectConverter.cs} | 47 +- .../src/NanoMigrate.Core/ProjectScanner.cs | 45 ++ .../src/NanoMigrate.Core/RepoReport.cs | 11 + .../tests/NanoMigrate.Tests/ConverterTests.cs | 183 +++++ .../tests/NanoMigrate.Tests/GlobTests.cs | 33 + .../tests/NanoMigrate.Tests/HintPathTests.cs | 50 ++ .../NanoMigrate.Tests.csproj | 22 + .../NanoMigrate.Tests/ProjectScannerTests.cs | 56 ++ .../NanoMigrate.Tests/SolutionRewriteTests.cs | 71 ++ .../tests/NanoMigrate.Tests/TempDir.cs | 28 + 31 files changed, 1842 insertions(+), 940 deletions(-) delete mode 100644 tools/NanoMigrate/NanoMigrate.csproj create mode 100644 tools/NanoMigrate/NanoMigrate.sln delete mode 100644 tools/NanoMigrate/Program.cs delete mode 100644 tools/NanoMigrate/Support.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Cli/Cli/CloneCommand.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Cli/Cli/FleetCommand.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Cli/Cli/MigrateCommand.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Cli/GitHub.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj create mode 100644 tools/NanoMigrate/src/NanoMigrate.Cli/ProcessRunner.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Cli/Program.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/ConsoleSupport.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/MigrateRenderer.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Cli/UserError.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/ConvertResult.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/FleetService.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/Glob.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/IProjectConverter.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/NanoMigrate.Core.csproj rename tools/NanoMigrate/{Converter.cs => src/NanoMigrate.Core/ProjectConverter.cs} (92%) create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/ProjectScanner.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/RepoReport.cs create mode 100644 tools/NanoMigrate/tests/NanoMigrate.Tests/ConverterTests.cs create mode 100644 tools/NanoMigrate/tests/NanoMigrate.Tests/GlobTests.cs create mode 100644 tools/NanoMigrate/tests/NanoMigrate.Tests/HintPathTests.cs create mode 100644 tools/NanoMigrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj create mode 100644 tools/NanoMigrate/tests/NanoMigrate.Tests/ProjectScannerTests.cs create mode 100644 tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionRewriteTests.cs create mode 100644 tools/NanoMigrate/tests/NanoMigrate.Tests/TempDir.cs diff --git a/tools/NanoMigrate/NanoMigrate.csproj b/tools/NanoMigrate/NanoMigrate.csproj deleted file mode 100644 index c3babac..0000000 --- a/tools/NanoMigrate/NanoMigrate.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - Exe - net8.0 - enable - enable - nano-migrate - NanoFramework.Migrate - true - - false - - - - - - - - diff --git a/tools/NanoMigrate/NanoMigrate.sln b/tools/NanoMigrate/NanoMigrate.sln new file mode 100644 index 0000000..d2c3de4 --- /dev/null +++ b/tools/NanoMigrate/NanoMigrate.sln @@ -0,0 +1,71 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NanoMigrate.Core", "src\NanoMigrate.Core\NanoMigrate.Core.csproj", "{AEBCAB7C-FF89-4263-A46D-5802D29DD602}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NanoMigrate.Cli", "src\NanoMigrate.Cli\NanoMigrate.Cli.csproj", "{6560B18A-C816-4E38-B604-C4E771FD6534}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NanoMigrate.Tests", "tests\NanoMigrate.Tests\NanoMigrate.Tests.csproj", "{2EB1343E-3AF7-4584-AE5D-606ADF7C891E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Debug|x64.ActiveCfg = Debug|Any CPU + {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Debug|x64.Build.0 = Debug|Any CPU + {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Debug|x86.ActiveCfg = Debug|Any CPU + {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Debug|x86.Build.0 = Debug|Any CPU + {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Release|Any CPU.Build.0 = Release|Any CPU + {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Release|x64.ActiveCfg = Release|Any CPU + {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Release|x64.Build.0 = Release|Any CPU + {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Release|x86.ActiveCfg = Release|Any CPU + {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Release|x86.Build.0 = Release|Any CPU + {6560B18A-C816-4E38-B604-C4E771FD6534}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6560B18A-C816-4E38-B604-C4E771FD6534}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6560B18A-C816-4E38-B604-C4E771FD6534}.Debug|x64.ActiveCfg = Debug|Any CPU + {6560B18A-C816-4E38-B604-C4E771FD6534}.Debug|x64.Build.0 = Debug|Any CPU + {6560B18A-C816-4E38-B604-C4E771FD6534}.Debug|x86.ActiveCfg = Debug|Any CPU + {6560B18A-C816-4E38-B604-C4E771FD6534}.Debug|x86.Build.0 = Debug|Any CPU + {6560B18A-C816-4E38-B604-C4E771FD6534}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6560B18A-C816-4E38-B604-C4E771FD6534}.Release|Any CPU.Build.0 = Release|Any CPU + {6560B18A-C816-4E38-B604-C4E771FD6534}.Release|x64.ActiveCfg = Release|Any CPU + {6560B18A-C816-4E38-B604-C4E771FD6534}.Release|x64.Build.0 = Release|Any CPU + {6560B18A-C816-4E38-B604-C4E771FD6534}.Release|x86.ActiveCfg = Release|Any CPU + {6560B18A-C816-4E38-B604-C4E771FD6534}.Release|x86.Build.0 = Release|Any CPU + {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Debug|x64.ActiveCfg = Debug|Any CPU + {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Debug|x64.Build.0 = Debug|Any CPU + {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Debug|x86.ActiveCfg = Debug|Any CPU + {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Debug|x86.Build.0 = Debug|Any CPU + {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Release|Any CPU.Build.0 = Release|Any CPU + {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Release|x64.ActiveCfg = Release|Any CPU + {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Release|x64.Build.0 = Release|Any CPU + {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Release|x86.ActiveCfg = Release|Any CPU + {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {AEBCAB7C-FF89-4263-A46D-5802D29DD602} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {6560B18A-C816-4E38-B604-C4E771FD6534} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {2EB1343E-3AF7-4584-AE5D-606ADF7C891E} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/tools/NanoMigrate/Program.cs b/tools/NanoMigrate/Program.cs deleted file mode 100644 index 5efc0b5..0000000 --- a/tools/NanoMigrate/Program.cs +++ /dev/null @@ -1,681 +0,0 @@ -// nano-migrate — convert legacy nanoFramework .nfproj projects to the SDK-style -// MSBuild project system, one project at a time or across an entire cloned fleet. -// -// SCOPE: project-system migration ONLY. This tool does NOT touch OTA, modular -// firmware packaging, runtimes/{rid}/native layouts, or ABI manifests. It moves -// a repo from the legacy flavored .nfproj format onto an SDK-style project that -// composes over the nanoFramework SDK, folds packages.config into PackageReference, -// and folds .nuspec metadata into MSBuild Pack properties. Nothing more. -// -// The only external dependency is Spectre.Console, which drives the CLI -// presentation (rules, progress, tables, panels). It degrades gracefully when -// output is redirected or the terminal is non-interactive. - -using System.Diagnostics; -using System.Text; -using Spectre.Console; - -namespace NanoFramework.Migrate; - -internal static class Program -{ - private static int Main(string[] args) - { - if (args.Length == 0 || IsHelp(args[0])) - { - PrintUsage(); - return args.Length == 0 ? 1 : 0; - } - - var opts = Options.Parse(args.Skip(1)); - try - { - return args[0].ToLowerInvariant() switch - { - "migrate" => CmdMigrate(opts), - "clone" => CmdClone(opts), - "fleet" => CmdFleet(opts), - _ => Fail($"unknown command '{args[0]}'"), - }; - } - catch (UserError ue) - { - return Fail(ue.Message); - } - } - - // ───────────────────────────── migrate ───────────────────────────── - - private static int CmdMigrate(Options o) - { - Header("NanoMigrate"); - var path = o.Positional ?? throw new UserError("migrate needs a path to a .nfproj or a directory"); - var targets = ResolveProjects(path, o.Glob); - - // Reentrant: a fully-converted tree has no .nfproj left. Exit cleanly (0) - // rather than erroring, so re-running the converter over a repo is a safe - // no-op. - if (targets.Count == 0) - { - var why = o.Glob is null - ? $"no .nfproj found under '{Esc(path)}' (already SDK-style?)." - : $"no .nfproj matched glob '{Esc(o.Glob)}' under '{Esc(path)}'."; - AnsiConsole.MarkupLine($"[grey]nothing to convert: {why}[/]"); - return 0; - } - - // Determine the base directory glob/relative paths are reported against. - var baseDir = Directory.Exists(path) ? Path.GetFullPath(path) - : Path.GetDirectoryName(Path.GetFullPath(path))!; - - AnsiConsole.MarkupLine(o.DryRun - ? $"[yellow]Dry run[/] — analysing [bold]{targets.Count}[/] project(s) under [blue]{Esc(baseDir)}[/]. Nothing will be written." - : $"Found [bold]{targets.Count}[/] project(s) to convert under [blue]{Esc(baseDir)}[/]."); - AnsiConsole.WriteLine(); - - // Real, interactive runs confirm once before touching disk. In dry-run or - // when stdin is redirected (CI/automation) we proceed without prompting so - // nothing blocks. --yes also skips the prompt. - if (!o.DryRun && !o.AssumeYes && IsInteractive() - && !AnsiConsole.Confirm($"Proceed with {targets.Count} conversion(s)?")) - { - AnsiConsole.MarkupLine("[grey]aborted; nothing written.[/]"); - return 0; - } - - var results = ProcessProjects(targets, baseDir, o); - - RenderSummaryTable(results, baseDir, o); - RenderReviewNotes(results, baseDir); - RenderTally(results, o); - - var errors = results.Count(r => r.Result.Status == ConvertStatus.Error); - var flagged = results.Count(r => r.Result.Status == ConvertStatus.Review); - if (errors > 0) return 1; - return flagged > 0 ? 2 : 0; - } - - // Runs every conversion, surfacing progress with a spinner. Any per-project - // exception is captured as an Error row rather than aborting the batch. - private static List ProcessProjects(List targets, string baseDir, Options o) - { - var results = new List(targets.Count); - - void RunAll(Action? report) - { - foreach (var nf in targets) - { - var rel = Path.GetRelativePath(baseDir, nf); - report?.Invoke(rel); - ConvertResult result; - try - { - result = Converter.Convert(nf, o); - } - catch (Exception ex) - { - result = new ConvertResult { OutputPath = nf, Error = ex.Message }; - } - results.Add(new ProjectOutcome(nf, result)); - } - } - - // Status() needs an interactive, non-redirected console; otherwise just - // run straight through (Spectre would no-op the spinner anyway). - if (IsInteractive()) - { - AnsiConsole.Status() - .Spinner(Spinner.Known.Dots) - .Start(o.DryRun ? "Analysing…" : "Converting…", ctx => - RunAll(rel => ctx.Status($"{(o.DryRun ? "Analysing" : "Converting")} [blue]{Esc(rel)}[/]…"))); - } - else - { - RunAll(null); - } - return results; - } - - // ───────────────────────────── rendering ───────────────────────────── - - private static void RenderSummaryTable(List results, string baseDir, Options o) - { - var table = new Table().Border(TableBorder.Rounded).Expand(); - table.Title = new TableTitle(o.DryRun ? "Migration preview (dry run)" : "Migration summary"); - table.AddColumn("Project"); - table.AddColumn("Result"); - table.AddColumn("Packages"); - table.AddColumn("Notes"); - - foreach (var (nf, result) in results) - { - var rel = Path.GetRelativePath(baseDir, nf); - var (label, color) = StatusLabel(result.Status); - - var pkgs = result.Packages.Count == 0 - ? "[grey]—[/]" - : string.Join("\n", result.Packages.Select(p => $"{Esc(p.Key)} [grey]{Esc(p.Value)}[/]")); - - var notes = BuildNotesCell(result, o); - - table.AddRow( - new Markup($"[blue]{Esc(rel)}[/]"), - new Markup($"[{color}]{label}[/]"), - new Markup(pkgs), - new Markup(notes)); - } - - AnsiConsole.Write(table); - } - - // The Notes cell: in dry-run it previews what WOULD change (target path, - // deletions, .sln edits); otherwise it shows the count of review flags. - private static string BuildNotesCell(ConvertResult result, Options o) - { - if (result.Status == ConvertStatus.Error) - return $"[red]{Esc(result.Error ?? "error")}[/]"; - if (result.Status == ConvertStatus.Skipped) - return "[grey]already SDK-style[/]"; - - var lines = new List(); - if (o.DryRun) - { - lines.Add($"[grey]→[/] {Esc(Path.GetFileName(result.OutputPath))}"); - foreach (var d in result.DeletedFiles) - lines.Add($"[red]delete[/] {Esc(Path.GetFileName(d))}"); - foreach (var s in result.UpdatedSolutions) - lines.Add($"[yellow]edit[/] {Esc(Path.GetFileName(s))}"); - } - if (result.Review.Count > 0) - lines.Add($"[yellow]{result.Review.Count} item(s) need review[/]"); - return lines.Count == 0 ? "[green]clean[/]" : string.Join("\n", lines); - } - - // Review notes get a clearly-visible yellow panel, grouped per project, so - // they are never buried in the table. - private static void RenderReviewNotes(List results, string baseDir) - { - var flagged = results.Where(r => r.Result.Review.Count > 0 - && r.Result.Status == ConvertStatus.Review).ToList(); - if (flagged.Count == 0) return; - - var sb = new StringBuilder(); - foreach (var (nf, result) in flagged) - { - var rel = Path.GetRelativePath(baseDir, nf); - sb.Append($"[bold]{Esc(rel)}[/]\n"); - foreach (var item in result.Review) - sb.Append($" [yellow]•[/] {Esc(item)}\n"); - } - - var panel = new Panel(sb.ToString().TrimEnd('\n')) - { - Header = new PanelHeader("[yellow]MANUAL REVIEW NEEDED[/]"), - Border = BoxBorder.Rounded, - BorderStyle = new Style(Color.Yellow), - Expand = true, - }; - AnsiConsole.WriteLine(); - AnsiConsole.Write(panel); - } - - private static void RenderTally(List results, Options o) - { - int converted = results.Count(r => r.Result.Status == ConvertStatus.Converted); - int skipped = results.Count(r => r.Result.Status == ConvertStatus.Skipped); - int flagged = results.Count(r => r.Result.Status == ConvertStatus.Review); - int errors = results.Count(r => r.Result.Status == ConvertStatus.Error); - var verb = o.DryRun ? "would convert" : "converted"; - - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine( - $"[green]{verb} {converted}[/] • [grey]skipped {skipped}[/] • " - + $"[yellow]flagged {flagged}[/] • [red]errors {errors}[/] • total {results.Count}"); - } - - private static (string label, string color) StatusLabel(ConvertStatus s) => s switch - { - ConvertStatus.Converted => ("Converted", "green"), - ConvertStatus.Skipped => ("Skipped", "grey"), - ConvertStatus.Review => ("Review", "yellow"), - ConvertStatus.Error => ("Error", "red"), - _ => ("?", "white"), - }; - - private static List ResolveProjects(string path, string? glob) - { - if (File.Exists(path) && path.EndsWith(".nfproj", StringComparison.OrdinalIgnoreCase)) - return new List { Path.GetFullPath(path) }; - if (!Directory.Exists(path)) return new List(); - - var baseDir = Path.GetFullPath(path); - return Directory.EnumerateFiles(path, "*.nfproj", SearchOption.AllDirectories) - .Select(Path.GetFullPath) - .Where(p => glob is null || Glob.IsMatch(Path.GetRelativePath(baseDir, p), glob)) - .OrderBy(p => p) - .ToList(); - } - - // ───────────────────────────── clone ───────────────────────────── - - private static int CmdClone(Options o) - { - Header("NanoMigrate · clone"); - var outDir = o.Positional ?? "./nano-repos"; - Directory.CreateDirectory(outDir); - - AnsiConsole.MarkupLine($"Enumerating [bold]{Esc(o.Org)}[/] repositories matching '[blue]{Esc(o.Filter)}*[/]'…"); - var repos = GitHub.ListOrgRepos(o.Org, o.Token, o.IncludeArchived) - .Where(r => r.Name.StartsWith(o.Filter, StringComparison.OrdinalIgnoreCase)) - .OrderBy(r => r.Name).ToList(); - - if (repos.Count == 0) throw new UserError( - $"no repos matched '{o.Filter}*' in org '{o.Org}'. " + - "Check the org name and filter, or pass --token to lift the API rate limit."); - - AnsiConsole.MarkupLine($"Found [bold]{repos.Count}[/] repositories. Cloning into [blue]{Esc(outDir)}[/]…"); - AnsiConsole.WriteLine(); - - var table = new Table().Border(TableBorder.Rounded).Expand(); - table.Title = new TableTitle("Clone results"); - table.AddColumn("Repository"); - table.AddColumn("Result"); - - int ok = 0, skipped = 0, failed = 0; - void CloneAll(Action? report) - { - foreach (var r in repos) - { - report?.Invoke(r.Name); - var dest = Path.Combine(outDir, r.Name); - if (Directory.Exists(dest)) - { - table.AddRow(new Markup($"[blue]{Esc(r.Name)}[/]"), new Markup("[grey]skipped (already present)[/]")); - skipped++; continue; - } - var (code, _, err) = Run("git", $"clone --depth 1 {r.CloneUrl} \"{dest}\"", outDir); - if (code == 0) - { - table.AddRow(new Markup($"[blue]{Esc(r.Name)}[/]"), new Markup("[green]cloned[/]")); - ok++; - } - else - { - var msg = err.Trim().Split('\n').LastOrDefault() ?? "git clone failed"; - table.AddRow(new Markup($"[blue]{Esc(r.Name)}[/]"), new Markup($"[red]FAIL[/] {Esc(msg)}")); - failed++; - } - } - } - - if (IsInteractive()) - AnsiConsole.Status().Spinner(Spinner.Known.Dots) - .Start("Cloning…", ctx => CloneAll(name => ctx.Status($"Cloning [blue]{Esc(name)}[/]…"))); - else - CloneAll(null); - - AnsiConsole.Write(table); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"[green]cloned {ok}[/] • [grey]skipped {skipped}[/] • [red]failed {failed}[/]"); - return failed > 0 ? 2 : 0; - } - - // ───────────────────────────── fleet ───────────────────────────── - - private static int CmdFleet(Options o) - { - Header("NanoMigrate · fleet"); - var reposDir = o.Positional ?? throw new UserError("fleet needs a path to a directory of cloned repos"); - if (!Directory.Exists(reposDir)) throw new UserError($"directory not found: {reposDir}"); - if (o.Commit && o.Branch is null) throw new UserError("--commit requires --branch"); - // nanoFramework workflow: branch names must not start with "develop" (they - // collide with upstream develop-* branches). - if (o.Branch is not null && o.Branch.StartsWith("develop", StringComparison.OrdinalIgnoreCase)) - throw new UserError("branch name must not start with 'develop' (nanoFramework workflow); " - + "use something like 'sdk-migration' or 'issue-123'"); - // In a git repo the commit history already preserves the pre-migration file, - // so a .bak alongside it is just noise in the diff. Skip backups when committing. - if (o.Commit) o.NoBackup = true; - - // A repo qualifies if it contains at least one .nfproj that survives the - // glob filter (default: any .nfproj). - bool RepoMatches(string d) => - NfprojUnder(d, o.Glob).Any(); - - var repoDirs = Directory.EnumerateDirectories(reposDir) - .Where(RepoMatches) - .OrderBy(d => d).ToList(); - if (repoDirs.Count == 0) - throw new UserError(o.Glob is null - ? $"no repos containing .nfproj found under '{reposDir}'" - : $"no repos with .nfproj matching glob '{o.Glob}' found under '{reposDir}'"); - - if (o.DryRun) - AnsiConsole.MarkupLine($"[yellow]Dry run[/] — {repoDirs.Count} repo(s); nothing will be written."); - else - AnsiConsole.MarkupLine($"Processing [bold]{repoDirs.Count}[/] repo(s) under [blue]{Esc(reposDir)}[/]" - + (o.Branch is not null ? $" on branch [blue]{Esc(o.Branch)}[/]" : "") - + (o.Commit ? ", committing" : "") + "."); - AnsiConsole.WriteLine(); - - var report = new List(); - - void FleetAll(Action? progress) - { - foreach (var repo in repoDirs) - { - progress?.Invoke(Path.GetFileName(repo)); - var rr = new RepoReport { Name = Path.GetFileName(repo) }; - try - { - if (o.Branch is not null && !o.DryRun) - { - var (code, _, err) = Run("git", $"checkout -B {o.Branch}", repo); - if (code != 0) { rr.Error = $"git checkout failed: {err.Trim()}"; report.Add(rr); continue; } - } - - foreach (var nf in NfprojUnder(repo, o.Glob).OrderBy(p => p)) - { - rr.Projects++; - var result = Converter.Convert(nf, o); - var rel = Path.GetRelativePath(repo, nf); - foreach (var item in result.Review) rr.Review.Add($"{rel}: {item}"); - } - - if (o.Commit && !o.DryRun) - { - Run("git", "add -A", repo); - var msgFile = WriteCommitMessage(repo, o); - var signOff = o.SignOff ? "-s " : ""; - var (code, _, err) = Run("git", $"commit {signOff}-F \"{msgFile}\"", repo); - File.Delete(msgFile); - rr.Committed = code == 0; - if (code != 0 && !err.Contains("nothing to commit")) - { - rr.Error = err.Contains("Please tell me who you are") || err.Contains("user.name") - ? "git commit failed: set git user.name/user.email (real name) so the " - + "Signed-off-by line is valid, or pass --no-sign-off" - : $"git commit: {err.Trim()}"; - } - } - } - catch (Exception ex) - { - rr.Error = ex.Message; - } - report.Add(rr); - } - } - - if (IsInteractive()) - AnsiConsole.Status().Spinner(Spinner.Known.Dots) - .Start("Migrating…", ctx => FleetAll(name => ctx.Status($"Migrating [blue]{Esc(name)}[/]…"))); - else - FleetAll(null); - - RenderFleetTable(report); - RenderFleetReview(report); - - WriteReport(report, o, reposDir); - var errored = report.Count(r => r.Error is not null); - var needsReview = report.Count(r => r.Error is null && r.Review.Count > 0); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine( - $"[bold]{report.Count}[/] repo(s) processed • " - + $"[yellow]{needsReview} need review[/] • [red]{errored} with errors[/] • " - + $"report: [blue]{Esc(o.Report)}[/]"); - return errored > 0 ? 2 : 0; - } - - private static void RenderFleetTable(List report) - { - var table = new Table().Border(TableBorder.Rounded).Expand(); - table.Title = new TableTitle("Fleet results"); - table.AddColumn("Repository"); - table.AddColumn("Result"); - table.AddColumn("Projects"); - table.AddColumn("Review"); - - foreach (var rr in report) - { - string label, color; - if (rr.Error is not null) { label = "Error"; color = "red"; } - else if (rr.Review.Count > 0) { label = "Review"; color = "yellow"; } - else { label = rr.Committed ? "OK (committed)" : "OK"; color = "green"; } - - var note = rr.Error is not null ? $" [red]{Esc(rr.Error.Split('\n')[0])}[/]" : ""; - table.AddRow( - new Markup($"[blue]{Esc(rr.Name)}[/]"), - new Markup($"[{color}]{label}[/]{note}"), - new Markup(rr.Projects.ToString()), - new Markup(rr.Review.Count == 0 ? "[grey]—[/]" : $"[yellow]{rr.Review.Count}[/]")); - } - AnsiConsole.Write(table); - } - - private static void RenderFleetReview(List report) - { - var flagged = report.Where(r => r.Error is null && r.Review.Count > 0).ToList(); - if (flagged.Count == 0) return; - - var sb = new StringBuilder(); - foreach (var rr in flagged) - { - sb.Append($"[bold]{Esc(rr.Name)}[/]\n"); - foreach (var item in rr.Review) sb.Append($" [yellow]•[/] {Esc(item)}\n"); - } - var panel = new Panel(sb.ToString().TrimEnd('\n')) - { - Header = new PanelHeader("[yellow]MANUAL REVIEW NEEDED[/]"), - Border = BoxBorder.Rounded, - BorderStyle = new Style(Color.Yellow), - Expand = true, - }; - AnsiConsole.WriteLine(); - AnsiConsole.Write(panel); - } - - // Enumerates the .nfproj under a repo that survive the glob filter (matched - // against the path relative to the repo directory). - private static IEnumerable NfprojUnder(string repoDir, string? glob) - { - var baseDir = Path.GetFullPath(repoDir); - foreach (var nf in Directory.EnumerateFiles(repoDir, "*.nfproj", SearchOption.AllDirectories)) - { - var full = Path.GetFullPath(nf); - if (glob is null || Glob.IsMatch(Path.GetRelativePath(baseDir, full), glob)) - yield return full; - } - } - - private static void WriteReport(List report, Options o, string reposDir) - { - var sb = new StringBuilder(); - sb.AppendLine("# nanoFramework SDK-style migration — fleet report\n"); - sb.AppendLine($"- Source: `{Path.GetFullPath(reposDir)}`"); - sb.AppendLine($"- Mode: {(o.DryRun ? "dry-run (no files written)" : "applied")}" - + (o.Branch is not null ? $", branch `{o.Branch}`" : "") - + (o.Commit ? ", committed" : "")); - sb.AppendLine($"- SDK `nanoFramework.NET.Sdk` (versionless), TFM `{o.Tfm}`, output extension `{o.Ext}`\n"); - - int total = report.Count, clean = report.Count(r => r.Error is null && r.Review.Count == 0); - int needsReview = report.Count(r => r.Error is null && r.Review.Count > 0); - int errors = report.Count(r => r.Error is not null); - sb.AppendLine("## Summary\n"); - sb.AppendLine($"| Repos | Clean | Needs review | Errored |"); - sb.AppendLine($"|------:|------:|-------------:|--------:|"); - sb.AppendLine($"| {total} | {clean} | {needsReview} | {errors} |\n"); - - if (errors > 0) - { - sb.AppendLine("## Errored repos\n"); - foreach (var r in report.Where(r => r.Error is not null)) - sb.AppendLine($"- **{r.Name}** — {r.Error}"); - sb.AppendLine(); - } - - if (needsReview > 0) - { - sb.AppendLine("## Repos needing manual review\n"); - sb.AppendLine("These migrated, but the tool could not confidently resolve everything. " - + "Each line is something a human should confirm before merging.\n"); - foreach (var r in report.Where(r => r.Error is null && r.Review.Count > 0)) - { - sb.AppendLine($"### {r.Name}\n"); - foreach (var item in r.Review) sb.AppendLine($"- {item}"); - sb.AppendLine(); - } - } - - if (clean > 0) - { - sb.AppendLine("## Clean migrations\n"); - sb.AppendLine("Converted with no items flagged for review:\n"); - foreach (var r in report.Where(r => r.Error is null && r.Review.Count == 0)) - sb.AppendLine($"- {r.Name} ({r.Projects} project(s))" - + (r.Committed ? " — committed" : "")); - sb.AppendLine(); - } - - File.WriteAllText(o.Report, sb.ToString()); - } - - // ───────────────────────────── helpers ───────────────────────────── - - // Builds a commit message that follows the nanoFramework guidance: a short - // summary (<= 50 chars), a blank line, a body wrapped at 72 columns, and an - // optional "Fix #" trailer. Returns the path to a temp message file. - private static string WriteCommitMessage(string repo, Options o) - { - var summary = o.CommitMessage ?? "Migrate project system to SDK-style"; - if (summary.Length > 50) summary = summary[..50].TrimEnd(); - - var body = Wrap( - "Convert the legacy .nfproj project system to an SDK-style MSBuild project: " - + "drop project-system boilerplate, fold packages.config into PackageReference, " - + "and fold .nuspec metadata into MSBuild Pack properties. " - + "No functional code changes.", 72); - - var sb = new StringBuilder(); - sb.Append(summary).Append("\n\n").Append(body).Append('\n'); - if (o.Issue is not null) sb.Append("\nFix #").Append(o.Issue).Append('\n'); - - var path = Path.GetTempFileName(); - File.WriteAllText(path, sb.ToString()); - return path; - } - - private static string Wrap(string text, int width) - { - var sb = new StringBuilder(); - int lineLen = 0; - foreach (var word in text.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - { - if (lineLen > 0 && lineLen + 1 + word.Length > width) { sb.Append('\n'); lineLen = 0; } - else if (lineLen > 0) { sb.Append(' '); lineLen++; } - sb.Append(word); lineLen += word.Length; - } - return sb.ToString(); - } - - internal static (int code, string stdout, string stderr) Run(string file, string args, string cwd) - { - var psi = new ProcessStartInfo(file, args) - { - WorkingDirectory = cwd, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - using var p = Process.Start(psi)!; - var so = p.StandardOutput.ReadToEnd(); - var se = p.StandardError.ReadToEnd(); - p.WaitForExit(); - return (p.ExitCode, so, se); - } - - // A "title rule" header. Spectre renders this as a centred rule when the - // terminal is wide enough, and degrades to plain text when redirected. - private static void Header(string title) - { - var rule = new Rule($"[bold]{Esc(title)}[/]") { Justification = Justify.Left }; - AnsiConsole.Write(rule); - AnsiConsole.WriteLine(); - } - - // Escapes interpolated text (notably file paths) so Spectre markup like - // "[" in a path can't be interpreted as styling — guards against injection. - private static string Esc(string s) => Markup.Escape(s); - - // Interactive == an attached terminal whose stdin is not redirected. We use - // this to decide whether to show a spinner and whether to prompt. In - // non-interactive contexts (CI, piped input) we never prompt or block. - private static bool IsInteractive() => - !Console.IsInputRedirected && AnsiConsole.Profile.Capabilities.Interactive; - - private static bool IsHelp(string a) => a is "-h" or "--help" or "help"; - - private static int Fail(string msg) - { - AnsiConsole.MarkupLine($"[red]error:[/] {Esc(msg)}"); - return 1; - } - - private static void PrintUsage() => AnsiConsole.WriteLine(""" - nano-migrate — migrate nanoFramework projects to the SDK-style project system - - USAGE - nano-migrate migrate Convert a .nfproj, or every .nfproj under a directory. - nano-migrate clone Clone all matching repos from a GitHub org. - nano-migrate fleet Migrate every .nfproj across cloned repos; write a report. - - RECOMMENDED WORKFLOW - Dry-run a directory first to preview every change, review the table, then - run for real: - nano-migrate migrate ./samples --dry-run - nano-migrate migrate ./samples # prompts once before writing - - COMMON OPTIONS - --sdk Accepted for back-compat but ignored: the SDK reference - is versionless (pinned via global.json msbuild-sdks). - --tfm Target framework moniker (default netnano1.0) - --ext Output extension: .nfproj or .csproj (default .csproj) - --no-backup Don't write a .nfproj.bak (implied by fleet --commit). - --dry-run Analyse and preview only; write nothing. - --glob Only convert .nfproj whose path (relative to ) - matches the glob. Supports *, ** and ?. Default: all - .nfproj recursively. Example: --glob "Beginner/**". - --yes, -y Skip the interactive "Proceed?" confirmation on a real run. - (Non-interactive/redirected runs never prompt regardless.) - - clone OPTIONS - --org GitHub org (default nanoframework) - --filter Repo name prefix to match (default lib-) - --token GitHub token (or env GITHUB_TOKEN) to raise the API rate limit. - --include-archived Include archived repositories (skipped by default). - - fleet OPTIONS - --report Markdown report path (default migration-report.md) - --branch Create/reset this git branch in each repo (must not start with 'develop'). - --commit Commit the changes (requires --branch). Uses a contribution-compliant - message and signs off (Signed-off-by) by default. - --message Commit summary line (kept <= 50 chars). - --issue Reference an issue: adds a "Fix #" trailer to the commit. - --no-sign-off Don't add a Signed-off-by line. - --glob Only convert matching .nfproj within each repo (see above). - - SCOPE - Project-system migration only. Does NOT produce OTA artifacts, modular - firmware packaging, runtimes/{rid}/native layouts, or ABI manifests. - - EXAMPLES - nano-migrate migrate ./lib-CoreLibrary - nano-migrate migrate ./samples --glob "Beginner/**" --dry-run - nano-migrate migrate ./MyDevice/MyDevice.nfproj --ext .csproj --yes - nano-migrate clone ./nano-repos --token $GITHUB_TOKEN - nano-migrate fleet ./nano-repos --branch sdk-migration --commit --dry-run - """); -} - -/// Pairs a source .nfproj path with its conversion outcome. -internal readonly record struct ProjectOutcome(string Nfproj, ConvertResult Result); diff --git a/tools/NanoMigrate/Support.cs b/tools/NanoMigrate/Support.cs deleted file mode 100644 index e547634..0000000 --- a/tools/NanoMigrate/Support.cs +++ /dev/null @@ -1,197 +0,0 @@ -using System.Net.Http.Headers; -using System.Text.Json; -using System.Text.RegularExpressions; - -namespace NanoFramework.Migrate; - -/// A user-facing error that prints cleanly without a stack trace. -internal sealed class UserError(string message) : Exception(message); - -/// Per-repo outcome accumulated by the fleet command. -internal sealed class RepoReport -{ - public required string Name { get; init; } - public int Projects { get; set; } - public List Review { get; } = new(); - public bool Committed { get; set; } - public string? Error { get; set; } -} - -/// Parsed command-line options shared across commands. -internal sealed class Options -{ - public string? Positional { get; private set; } - // Parsed but no longer emitted: the SDK reference is versionless (the version - // is pinned via global.json msbuild-sdks). Kept for CLI back-compat. - public string Sdk { get; private set; } = "2.0.0"; - public string Tfm { get; private set; } = "netnano1.0"; // the TFM recognized by the .NET SDK / NuGet - // Default to .csproj: a normal run produces Foo.csproj and deletes Foo.nfproj. - public string Ext { get; private set; } = ".csproj"; - public bool DryRun { get; private set; } - public bool NoBackup { get; internal set; } - // Glob filter (relative to the input directory) selecting which .nfproj to - // convert. Null means "all .nfproj recursively" (the original behavior). - public string? Glob { get; private set; } - // Skip the interactive "Proceed?" confirmation for real (non-dry-run) runs. - public bool AssumeYes { get; private set; } - - // clone - public string Org { get; private set; } = "nanoframework"; - public string Filter { get; private set; } = "lib-"; - public string? Token { get; private set; } = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); - public bool IncludeArchived { get; private set; } - - // fleet - public string Report { get; private set; } = "migration-report.md"; - public string? Branch { get; private set; } - public bool Commit { get; private set; } - public string? CommitMessage { get; private set; } - public string? Issue { get; private set; } // referenced as "Fix #" in the commit - public bool SignOff { get; private set; } = true; // nanoFramework recommends Signed-off-by - - public static Options Parse(IEnumerable args) - { - var o = new Options(); - var list = args.ToList(); - for (int i = 0; i < list.Count; i++) - { - var a = list[i]; - string Next(string name) => ++i < list.Count - ? list[i] - : throw new UserError($"{name} requires a value"); - - switch (a) - { - case "--sdk": o.Sdk = Next(a); break; - case "--tfm": o.Tfm = Next(a); break; - case "--ext": - o.Ext = Next(a); - if (o.Ext is not (".nfproj" or ".csproj")) - throw new UserError("--ext must be .nfproj or .csproj"); - break; - case "--dry-run": case "--no-write": o.DryRun = true; break; - case "--no-backup": o.NoBackup = true; break; - case "--glob": o.Glob = Next(a); break; - case "--yes": case "-y": o.AssumeYes = true; break; - case "--org": o.Org = Next(a); break; - case "--filter": o.Filter = Next(a); break; - case "--token": o.Token = Next(a); break; - case "--include-archived": o.IncludeArchived = true; break; - case "--report": o.Report = Next(a); break; - case "--branch": o.Branch = Next(a); break; - case "--commit": o.Commit = true; break; - case "--message": o.CommitMessage = Next(a); break; - case "--issue": o.Issue = Next(a).TrimStart('#'); break; - case "--no-sign-off": o.SignOff = false; break; - default: - if (a.StartsWith('-')) throw new UserError($"unknown option '{a}'"); - if (o.Positional is not null) throw new UserError($"unexpected argument '{a}'"); - o.Positional = a; - break; - } - } - return o; - } -} - -/// Minimal GitHub REST client (BCL only) for listing org repositories. -internal static class GitHub -{ - internal sealed record Repo(string Name, string CloneUrl, bool Archived); - - public static List ListOrgRepos(string org, string? token, bool includeArchived) - { - using var http = new HttpClient(); - http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("nano-migrate", "1.0")); - http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); - if (!string.IsNullOrEmpty(token)) - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - - var repos = new List(); - for (int page = 1; ; page++) - { - var url = $"https://api.github.com/orgs/{org}/repos?per_page=100&page={page}&type=public"; - using var resp = http.GetAsync(url).GetAwaiter().GetResult(); - if (!resp.IsSuccessStatusCode) - { - var hint = resp.StatusCode == System.Net.HttpStatusCode.Forbidden - ? " (rate limited — pass --token or set GITHUB_TOKEN)" : ""; - throw new UserError($"GitHub API returned {(int)resp.StatusCode} {resp.StatusCode}{hint}"); - } - - var json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - using var doc = JsonDocument.Parse(json); - var arr = doc.RootElement; - if (arr.GetArrayLength() == 0) break; - - foreach (var e in arr.EnumerateArray()) - { - var archived = e.TryGetProperty("archived", out var ar) && ar.GetBoolean(); - if (archived && !includeArchived) continue; - repos.Add(new Repo( - e.GetProperty("name").GetString()!, - e.GetProperty("clone_url").GetString()!, - archived)); - } - if (arr.GetArrayLength() < 100) break; - } - return repos; - } -} - -/// -/// Small hand-rolled glob matcher (no external dependency). Matches a path that -/// is relative to the input directory against a pattern supporting: -/// * any run of characters except a path separator -/// ** any run of characters including path separators (spanning directories) -/// ? exactly one character except a path separator -/// Matching is case-insensitive and separator-insensitive ('/' and '\' are -/// treated as equivalent), and is anchored to the whole relative path. -/// -internal static class Glob -{ - public static bool IsMatch(string relativePath, string pattern) - { - var input = Normalize(relativePath); - var regex = ToRegex(Normalize(pattern)); - return regex.IsMatch(input); - } - - private static string Normalize(string s) => s.Replace('\\', '/').Trim('/'); - - // Translates a normalized ('/'-separated) glob into an anchored, case-insensitive - // regex. Literal characters are escaped; only *, ** and ? carry meaning. - private static Regex ToRegex(string pattern) - { - var sb = new System.Text.StringBuilder("^"); - for (int i = 0; i < pattern.Length; i++) - { - var c = pattern[i]; - switch (c) - { - case '*': - if (i + 1 < pattern.Length && pattern[i + 1] == '*') - { - i++; // consume the second '*' - // "**/" (or trailing "**") spans directories; collapse any - // following slash so "Beginner/**" also matches "Beginner". - if (i + 1 < pattern.Length && pattern[i + 1] == '/') i++; - sb.Append(".*"); - } - else - { - sb.Append("[^/]*"); // single star: stay within a segment - } - break; - case '?': - sb.Append("[^/]"); - break; - default: - sb.Append(Regex.Escape(c.ToString())); - break; - } - } - sb.Append('$'); - return new Regex(sb.ToString(), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - } -} diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/CloneCommand.cs b/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/CloneCommand.cs new file mode 100644 index 0000000..fc37c8d --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/CloneCommand.cs @@ -0,0 +1,94 @@ +using System.ComponentModel; +using Spectre.Console; +using Spectre.Console.Cli; +using static NanoFramework.Migrate.Cli.Rendering.ConsoleSupport; + +namespace NanoFramework.Migrate.Cli.Commands; + +internal sealed class CloneSettings : CommandSettings +{ + [CommandArgument(0, "[out-dir]")] + [Description("Directory to clone into (default ./nano-repos).")] + public string? OutDir { get; init; } + + [CommandOption("--org ")] + [Description("GitHub org (default nanoframework).")] + public string Org { get; init; } = "nanoframework"; + + [CommandOption("--filter ")] + [Description("Repo name prefix to match (default lib-).")] + public string Filter { get; init; } = "lib-"; + + [CommandOption("--token ")] + [Description("GitHub token (or env GITHUB_TOKEN) to raise the API rate limit.")] + public string? Token { get; init; } = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); + + [CommandOption("--include-archived")] + [Description("Include archived repositories (skipped by default).")] + public bool IncludeArchived { get; init; } +} + +internal sealed class CloneCommand : Command +{ + protected override int Execute(CommandContext context, CloneSettings settings, CancellationToken cancellationToken) + { + Header("NanoMigrate · clone"); + var outDir = settings.OutDir ?? "./nano-repos"; + Directory.CreateDirectory(outDir); + + AnsiConsole.MarkupLine($"Enumerating [bold]{Esc(settings.Org)}[/] repositories matching '[blue]{Esc(settings.Filter)}*[/]'…"); + var repos = GitHub.ListOrgRepos(settings.Org, settings.Token, settings.IncludeArchived) + .Where(r => r.Name.StartsWith(settings.Filter, StringComparison.OrdinalIgnoreCase)) + .OrderBy(r => r.Name).ToList(); + + if (repos.Count == 0) throw new UserError( + $"no repos matched '{settings.Filter}*' in org '{settings.Org}'. " + + "Check the org name and filter, or pass --token to lift the API rate limit."); + + AnsiConsole.MarkupLine($"Found [bold]{repos.Count}[/] repositories. Cloning into [blue]{Esc(outDir)}[/]…"); + AnsiConsole.WriteLine(); + + var table = new Table().Border(TableBorder.Rounded).Expand(); + table.Title = new TableTitle("Clone results"); + table.AddColumn("Repository"); + table.AddColumn("Result"); + + int ok = 0, skipped = 0, failed = 0; + void CloneAll(Action? report) + { + foreach (var r in repos) + { + report?.Invoke(r.Name); + var dest = Path.Combine(outDir, r.Name); + if (Directory.Exists(dest)) + { + table.AddRow(new Markup($"[blue]{Esc(r.Name)}[/]"), new Markup("[grey]skipped (already present)[/]")); + skipped++; continue; + } + var (code, _, err) = ProcessRunner.Run("git", $"clone --depth 1 {r.CloneUrl} \"{dest}\"", outDir); + if (code == 0) + { + table.AddRow(new Markup($"[blue]{Esc(r.Name)}[/]"), new Markup("[green]cloned[/]")); + ok++; + } + else + { + var msg = err.Trim().Split('\n').LastOrDefault() ?? "git clone failed"; + table.AddRow(new Markup($"[blue]{Esc(r.Name)}[/]"), new Markup($"[red]FAIL[/] {Esc(msg)}")); + failed++; + } + } + } + + if (IsInteractive()) + AnsiConsole.Status().Spinner(Spinner.Known.Dots) + .Start("Cloning…", ctx => CloneAll(name => ctx.Status($"Cloning [blue]{Esc(name)}[/]…"))); + else + CloneAll(null); + + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[green]cloned {ok}[/] • [grey]skipped {skipped}[/] • [red]failed {failed}[/]"); + return failed > 0 ? 2 : 0; + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/FleetCommand.cs b/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/FleetCommand.cs new file mode 100644 index 0000000..9f9a020 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/FleetCommand.cs @@ -0,0 +1,148 @@ +using System.ComponentModel; +using NanoFramework.Migrate.Core; +using NanoFramework.Migrate.Cli.Rendering; +using Spectre.Console; +using Spectre.Console.Cli; +using static NanoFramework.Migrate.Cli.Rendering.ConsoleSupport; + +namespace NanoFramework.Migrate.Cli.Commands; + +internal sealed class FleetSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("A directory of cloned repos; every .nfproj across them is migrated.")] + public string ReposDir { get; init; } = ""; + + [CommandOption("--sdk ")] + [Description("Accepted for back-compat but ignored: the SDK reference is versionless (pinned via global.json msbuild-sdks).")] + public string Sdk { get; init; } = "2.0.0"; + + [CommandOption("--tfm ")] + [Description("Target framework moniker (default netnano1.0).")] + public string Tfm { get; init; } = "netnano1.0"; + + [CommandOption("--ext ")] + [Description("Output extension: .nfproj or .csproj (default .csproj).")] + public string Ext { get; init; } = ".csproj"; + + [CommandOption("--no-backup")] + [Description("Don't write a .nfproj.bak (implied by --commit).")] + public bool NoBackup { get; init; } + + [CommandOption("--dry-run|--no-write")] + [Description("Analyse and preview only; write nothing.")] + public bool DryRun { get; init; } + + [CommandOption("--glob ")] + [Description("Only convert matching .nfproj within each repo. Supports *, ** and ?.")] + public string? Glob { get; init; } + + [CommandOption("--report ")] + [Description("Markdown report path (default migration-report.md).")] + public string Report { get; init; } = "migration-report.md"; + + [CommandOption("--branch ")] + [Description("Create/reset this git branch in each repo (must not start with 'develop').")] + public string? Branch { get; init; } + + [CommandOption("--commit")] + [Description("Commit the changes (requires --branch). Uses a contribution-compliant message and signs off (Signed-off-by) by default.")] + public bool Commit { get; init; } + + [CommandOption("--message ")] + [Description("Commit summary line (kept <= 50 chars).")] + public string? Message { get; init; } + + [CommandOption("--issue ")] + [Description("Reference an issue: adds a \"Fix #\" trailer to the commit.")] + public string? Issue { get; init; } + + [CommandOption("--no-sign-off")] + [Description("Don't add a Signed-off-by line.")] + public bool NoSignOff { get; init; } + + public override ValidationResult Validate() + { + if (Ext is not (".nfproj" or ".csproj")) + return ValidationResult.Error("--ext must be .nfproj or .csproj"); + return ValidationResult.Success(); + } + + public FleetOptions ToFleetOptions() => new() + { + Conversion = new ConversionOptions + { + Sdk = Sdk, + Tfm = Tfm, + Ext = Ext, + NoBackup = NoBackup, + DryRun = DryRun, + Glob = Glob, + }, + Report = Report, + Branch = Branch, + Commit = Commit, + CommitMessage = Message, + Issue = Issue?.TrimStart('#'), + SignOff = !NoSignOff, + }; +} + +internal sealed class FleetCommand : Command +{ + protected override int Execute(CommandContext context, FleetSettings settings, CancellationToken cancellationToken) + { + Header("NanoMigrate · fleet"); + + var o = settings.ToFleetOptions(); + var reposDir = settings.ReposDir; + var service = new FleetService(new ProjectConverter(), new ProcessRunner()); + + // Core validates and discovers; surface its messages as clean usage errors. + List repoDirs; + try + { + repoDirs = service.ResolveRepos(reposDir, o); + } + catch (ArgumentException ex) + { + throw new UserError(ex.Message); + } + + if (o.Conversion.DryRun) + AnsiConsole.MarkupLine($"[yellow]Dry run[/] — {repoDirs.Count} repo(s); nothing will be written."); + else + AnsiConsole.MarkupLine($"Processing [bold]{repoDirs.Count}[/] repo(s) under [blue]{Esc(reposDir)}[/]" + + (o.Branch is not null ? $" on branch [blue]{Esc(o.Branch)}[/]" : "") + + (o.Commit ? ", committing" : "") + "."); + AnsiConsole.WriteLine(); + + List report; + if (IsInteractive()) + { + List? captured = null; + AnsiConsole.Status().Spinner(Spinner.Known.Dots) + .Start("Migrating…", ctx => captured = service.Process(repoDirs, o, + name => ctx.Status($"Migrating [blue]{Esc(name)}[/]…"))); + report = captured!; + } + else + { + report = service.Process(repoDirs, o); + } + + FleetRenderer.RenderFleetTable(report); + FleetRenderer.RenderFleetTree(report); + FleetRenderer.RenderFleetReview(report); + + service.WriteReport(report, o, reposDir); + var errored = report.Count(r => r.Error is not null); + var needsReview = report.Count(r => r.Error is null && r.Review.Count > 0); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine( + $"[bold]{report.Count}[/] repo(s) processed • " + + $"[yellow]{needsReview} need review[/] • [red]{errored} with errors[/] • " + + $"report: [blue]{Esc(o.Report)}[/]"); + return errored > 0 ? 2 : 0; + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/MigrateCommand.cs b/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/MigrateCommand.cs new file mode 100644 index 0000000..6f5fb64 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/MigrateCommand.cs @@ -0,0 +1,157 @@ +using System.ComponentModel; +using NanoFramework.Migrate.Core; +using NanoFramework.Migrate.Cli.Rendering; +using Spectre.Console; +using Spectre.Console.Cli; +using static NanoFramework.Migrate.Cli.Rendering.ConsoleSupport; + +namespace NanoFramework.Migrate.Cli.Commands; + +internal sealed class MigrateSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("A .nfproj file, or a directory under which every .nfproj is converted.")] + public string Path { get; init; } = ""; + + [CommandOption("--sdk ")] + [Description("Accepted for back-compat but ignored: the SDK reference is versionless (pinned via global.json msbuild-sdks).")] + public string Sdk { get; init; } = "2.0.0"; + + [CommandOption("--tfm ")] + [Description("Target framework moniker (default netnano1.0).")] + public string Tfm { get; init; } = "netnano1.0"; + + [CommandOption("--ext ")] + [Description("Output extension: .nfproj or .csproj (default .csproj).")] + public string Ext { get; init; } = ".csproj"; + + [CommandOption("--no-backup")] + [Description("Don't write a .nfproj.bak.")] + public bool NoBackup { get; init; } + + [CommandOption("--dry-run|--no-write")] + [Description("Analyse and preview only; write nothing.")] + public bool DryRun { get; init; } + + [CommandOption("--glob ")] + [Description("Only convert .nfproj whose path (relative to ) matches the glob. Supports *, ** and ?. Example: \"Beginner/**\".")] + public string? Glob { get; init; } + + [CommandOption("-y|--yes")] + [Description("Skip the interactive \"Proceed?\" confirmation on a real run. (Non-interactive runs never prompt regardless.)")] + public bool AssumeYes { get; init; } + + public override ValidationResult Validate() + { + if (Ext is not (".nfproj" or ".csproj")) + return ValidationResult.Error("--ext must be .nfproj or .csproj"); + return ValidationResult.Success(); + } + + public ConversionOptions ToConversionOptions() => new() + { + Sdk = Sdk, + Tfm = Tfm, + Ext = Ext, + NoBackup = NoBackup, + DryRun = DryRun, + Glob = Glob, + }; +} + +internal sealed class MigrateCommand : Command +{ + private readonly IProjectConverter _converter = new ProjectConverter(); + + protected override int Execute(CommandContext context, MigrateSettings settings, CancellationToken cancellationToken) + { + Header("NanoMigrate"); + + var o = settings.ToConversionOptions(); + var path = settings.Path; + var targets = ProjectScanner.ResolveProjects(path, o.Glob); + + // Reentrant: a fully-converted tree has no .nfproj left. Exit cleanly (0) + // rather than erroring, so re-running the converter over a repo is a safe + // no-op. + if (targets.Count == 0) + { + var why = o.Glob is null + ? $"no .nfproj found under '{Esc(path)}' (already SDK-style?)." + : $"no .nfproj matched glob '{Esc(o.Glob)}' under '{Esc(path)}'."; + AnsiConsole.MarkupLine($"[grey]nothing to convert: {why}[/]"); + return 0; + } + + // Determine the base directory glob/relative paths are reported against. + var baseDir = Directory.Exists(path) ? Path.GetFullPath(path) + : Path.GetDirectoryName(Path.GetFullPath(path))!; + + AnsiConsole.MarkupLine(o.DryRun + ? $"[yellow]Dry run[/] — analysing [bold]{targets.Count}[/] project(s) under [blue]{Esc(baseDir)}[/]. Nothing will be written." + : $"Found [bold]{targets.Count}[/] project(s) to convert under [blue]{Esc(baseDir)}[/]."); + AnsiConsole.WriteLine(); + + // Real, interactive runs confirm once before touching disk. In dry-run or + // when stdin is redirected (CI/automation) we proceed without prompting so + // nothing blocks. --yes also skips the prompt. + if (!o.DryRun && !settings.AssumeYes && IsInteractive() + && !AnsiConsole.Confirm($"Proceed with {targets.Count} conversion(s)?")) + { + AnsiConsole.MarkupLine("[grey]aborted; nothing written.[/]"); + return 0; + } + + var results = ProcessProjects(targets, baseDir, o); + + MigrateRenderer.RenderSummaryTable(results, baseDir, o.DryRun); + MigrateRenderer.RenderReviewNotes(results, baseDir); + MigrateRenderer.RenderTally(results, o.DryRun); + + var errors = results.Count(r => r.Result.Status == ConvertStatus.Error); + var flagged = results.Count(r => r.Result.Status == ConvertStatus.Review); + if (errors > 0) return 1; + return flagged > 0 ? 2 : 0; + } + + // Runs every conversion, surfacing progress with a spinner. Any per-project + // exception is captured as an Error row rather than aborting the batch. + private List ProcessProjects(List targets, string baseDir, ConversionOptions o) + { + var results = new List(targets.Count); + + void RunAll(Action? report) + { + foreach (var nf in targets) + { + var rel = Path.GetRelativePath(baseDir, nf); + report?.Invoke(rel); + ConvertResult result; + try + { + result = _converter.Convert(nf, o); + } + catch (Exception ex) + { + result = new ConvertResult { OutputPath = nf, Error = ex.Message }; + } + results.Add(new ProjectOutcome(nf, result)); + } + } + + // Status() needs an interactive, non-redirected console; otherwise just + // run straight through (Spectre would no-op the spinner anyway). + if (IsInteractive()) + { + AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .Start(o.DryRun ? "Analysing…" : "Converting…", ctx => + RunAll(rel => ctx.Status($"{(o.DryRun ? "Analysing" : "Converting")} [blue]{Esc(rel)}[/]…"))); + } + else + { + RunAll(null); + } + return results; + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/GitHub.cs b/tools/NanoMigrate/src/NanoMigrate.Cli/GitHub.cs new file mode 100644 index 0000000..2122f79 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/GitHub.cs @@ -0,0 +1,49 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +namespace NanoFramework.Migrate.Cli; + +/// Minimal GitHub REST client (BCL only) for listing org repositories. +internal static class GitHub +{ + internal sealed record Repo(string Name, string CloneUrl, bool Archived); + + public static List ListOrgRepos(string org, string? token, bool includeArchived) + { + using var http = new HttpClient(); + http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("nano-migrate", "1.0")); + http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + if (!string.IsNullOrEmpty(token)) + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var repos = new List(); + for (int page = 1; ; page++) + { + var url = $"https://api.github.com/orgs/{org}/repos?per_page=100&page={page}&type=public"; + using var resp = http.GetAsync(url).GetAwaiter().GetResult(); + if (!resp.IsSuccessStatusCode) + { + var hint = resp.StatusCode == System.Net.HttpStatusCode.Forbidden + ? " (rate limited — pass --token or set GITHUB_TOKEN)" : ""; + throw new UserError($"GitHub API returned {(int)resp.StatusCode} {resp.StatusCode}{hint}"); + } + + var json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + using var doc = JsonDocument.Parse(json); + var arr = doc.RootElement; + if (arr.GetArrayLength() == 0) break; + + foreach (var e in arr.EnumerateArray()) + { + var archived = e.TryGetProperty("archived", out var ar) && ar.GetBoolean(); + if (archived && !includeArchived) continue; + repos.Add(new Repo( + e.GetProperty("name").GetString()!, + e.GetProperty("clone_url").GetString()!, + archived)); + } + if (arr.GetArrayLength() < 100) break; + } + return repos; + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj b/tools/NanoMigrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj new file mode 100644 index 0000000..19ed62d --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj @@ -0,0 +1,46 @@ + + + + Exe + net8.0 + enable + enable + nano-migrate + NanoFramework.Migrate.Cli + true + + false + + + + + true + nano-migrate + nanoFramework.Migrate + nanoFramework Migrate (CLI) + Command-line tool that migrates legacy nanoFramework .nfproj projects to the SDK-style MSBuild project system — one project, a directory, or an entire cloned fleet. Built on the nanoFramework.Migrate.Core engine. + nanoFramework + nanoFramework + https://github.com/nanoframework/nanoframework-sdk + https://github.com/nanoframework/nanoframework-sdk + git + MIT + nanoFramework;MSBuild;SDK;migration;nfproj;dotnet-tool;IoT;embedded;C# + Copyright (c) .NET Foundation and Contributors + false + + + + + + + + + + + + + diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/ProcessRunner.cs b/tools/NanoMigrate/src/NanoMigrate.Cli/ProcessRunner.cs new file mode 100644 index 0000000..0895a51 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/ProcessRunner.cs @@ -0,0 +1,34 @@ +using System.Diagnostics; +using NanoFramework.Migrate.Core; + +namespace NanoFramework.Migrate.Cli; + +/// +/// Runs external processes. Also implements so Core's +/// fleet orchestration can issue git commands without depending on +/// itself. +/// +internal sealed class ProcessRunner : IGitRunner +{ + public GitResult Run(string args, string workingDirectory) + { + var (code, so, se) = Run("git", args, workingDirectory); + return new GitResult(code, so, se); + } + + public static (int code, string stdout, string stderr) Run(string file, string args, string cwd) + { + var psi = new ProcessStartInfo(file, args) + { + WorkingDirectory = cwd, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + using var p = Process.Start(psi)!; + var so = p.StandardOutput.ReadToEnd(); + var se = p.StandardError.ReadToEnd(); + p.WaitForExit(); + return (p.ExitCode, so, se); + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Program.cs b/tools/NanoMigrate/src/NanoMigrate.Cli/Program.cs new file mode 100644 index 0000000..4f30bab --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/Program.cs @@ -0,0 +1,66 @@ +// nano-migrate — convert legacy nanoFramework .nfproj projects to the SDK-style +// MSBuild project system, one project at a time or across an entire cloned fleet. +// +// SCOPE: project-system migration ONLY. This tool does NOT touch OTA, modular +// firmware packaging, runtimes/{rid}/native layouts, or ABI manifests. It moves +// a repo from the legacy flavored .nfproj format onto an SDK-style project that +// composes over the nanoFramework SDK, folds packages.config into PackageReference, +// and folds .nuspec metadata into MSBuild Pack properties. Nothing more. +// +// The conversion engine lives in NanoMigrate.Core (a console-free library). This +// project is a thin Spectre.Console.Cli front end over it. + +using NanoFramework.Migrate.Cli; +using NanoFramework.Migrate.Cli.Commands; +using Spectre.Console; +using Spectre.Console.Cli; + +// No arguments: show help and signal misuse (exit 1), matching the original. +if (args.Length == 0) +{ + BuildApp(out _).Run(new[] { "--help" }); + return 1; +} + +var app = BuildApp(out _); +try +{ + return app.Run(args); +} +catch (UserError ue) +{ + AnsiConsole.MarkupLine($"[red]error:[/] {Markup.Escape(ue.Message)}"); + return 1; +} +catch (Exception ex) +{ + // Spectre's own parse/validation errors surface here (PropagateExceptions). + AnsiConsole.MarkupLine($"[red]error:[/] {Markup.Escape(ex.Message)}"); + return 1; +} + +static CommandApp BuildApp(out CommandApp app) +{ + var a = new CommandApp(); + a.Configure(config => + { + config.SetApplicationName("nano-migrate"); + // Surface exceptions to Program so UserError maps to exit 1 and parse + // errors render cleanly without a stack trace. + config.PropagateExceptions(); + + config.AddCommand("migrate") + .WithDescription("Convert a .nfproj, or every .nfproj under a directory.") + .WithExample("migrate", "./samples", "--glob", "Beginner/**", "--dry-run"); + + config.AddCommand("clone") + .WithDescription("Clone all matching repos from a GitHub org.") + .WithExample("clone", "./nano-repos", "--token", "$GITHUB_TOKEN"); + + config.AddCommand("fleet") + .WithDescription("Migrate every .nfproj across cloned repos; write a report.") + .WithExample("fleet", "./nano-repos", "--branch", "sdk-migration", "--commit", "--dry-run"); + }); + app = a; + return a; +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/ConsoleSupport.cs b/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/ConsoleSupport.cs new file mode 100644 index 0000000..a3939d4 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/ConsoleSupport.cs @@ -0,0 +1,26 @@ +using Spectre.Console; + +namespace NanoFramework.Migrate.Cli.Rendering; + +/// Shared, low-level console helpers. The only consumers of AnsiConsole. +internal static class ConsoleSupport +{ + // A "title rule" header. Spectre renders this as a centred rule when the + // terminal is wide enough, and degrades to plain text when redirected. + public static void Header(string title) + { + var rule = new Rule($"[bold]{Esc(title)}[/]") { Justification = Justify.Left }; + AnsiConsole.Write(rule); + AnsiConsole.WriteLine(); + } + + // Escapes interpolated text (notably file paths) so Spectre markup like + // "[" in a path can't be interpreted as styling — guards against injection. + public static string Esc(string s) => Markup.Escape(s); + + // Interactive == an attached terminal whose stdin is not redirected. We use + // this to decide whether to show a spinner and whether to prompt. In + // non-interactive contexts (CI, piped input) we never prompt or block. + public static bool IsInteractive() => + !Console.IsInputRedirected && AnsiConsole.Profile.Capabilities.Interactive; +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs b/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs new file mode 100644 index 0000000..5277905 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs @@ -0,0 +1,82 @@ +using System.Text; +using NanoFramework.Migrate.Core; +using Spectre.Console; +using static NanoFramework.Migrate.Cli.Rendering.ConsoleSupport; + +namespace NanoFramework.Migrate.Cli.Rendering; + +/// Spectre presentation for the fleet command. Consumes Core's RepoReport data. +internal static class FleetRenderer +{ + public static void RenderFleetTable(List report) + { + var table = new Table().Border(TableBorder.Rounded).Expand(); + table.Title = new TableTitle("Fleet results"); + table.AddColumn("Repository"); + table.AddColumn("Result"); + table.AddColumn("Projects"); + table.AddColumn("Review"); + + foreach (var rr in report) + { + string label, color; + if (rr.Error is not null) { label = "Error"; color = "red"; } + else if (rr.Review.Count > 0) { label = "Review"; color = "yellow"; } + else { label = rr.Committed ? "OK (committed)" : "OK"; color = "green"; } + + var note = rr.Error is not null ? $" [red]{Esc(rr.Error.Split('\n')[0])}[/]" : ""; + table.AddRow( + new Markup($"[blue]{Esc(rr.Name)}[/]"), + new Markup($"[{color}]{label}[/]{note}"), + new Markup(rr.Projects.ToString()), + new Markup(rr.Review.Count == 0 ? "[grey]—[/]" : $"[yellow]{rr.Review.Count}[/]")); + } + AnsiConsole.Write(table); + } + + // A tree view of the fleet: one branch per repo, with each repo's review + // items as leaves. Complements the summary table for at-a-glance structure. + public static void RenderFleetTree(List report) + { + if (report.Count == 0) return; + + var tree = new Tree("[bold]Fleet[/]"); + foreach (var rr in report) + { + string label, color; + if (rr.Error is not null) { label = "error"; color = "red"; } + else if (rr.Review.Count > 0) { label = "review"; color = "yellow"; } + else { label = rr.Committed ? "ok (committed)" : "ok"; color = "green"; } + + var node = tree.AddNode($"[blue]{Esc(rr.Name)}[/] [{color}]{label}[/] [grey]({rr.Projects} project(s))[/]"); + if (rr.Error is not null) + node.AddNode($"[red]{Esc(rr.Error.Split('\n')[0])}[/]"); + foreach (var item in rr.Review) + node.AddNode($"[yellow]•[/] {Esc(item)}"); + } + AnsiConsole.WriteLine(); + AnsiConsole.Write(tree); + } + + public static void RenderFleetReview(List report) + { + var flagged = report.Where(r => r.Error is null && r.Review.Count > 0).ToList(); + if (flagged.Count == 0) return; + + var sb = new StringBuilder(); + foreach (var rr in flagged) + { + sb.Append($"[bold]{Esc(rr.Name)}[/]\n"); + foreach (var item in rr.Review) sb.Append($" [yellow]•[/] {Esc(item)}\n"); + } + var panel = new Panel(sb.ToString().TrimEnd('\n')) + { + Header = new PanelHeader("[yellow]MANUAL REVIEW NEEDED[/]"), + Border = BoxBorder.Rounded, + BorderStyle = new Style(Color.Yellow), + Expand = true, + }; + AnsiConsole.WriteLine(); + AnsiConsole.Write(panel); + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/MigrateRenderer.cs b/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/MigrateRenderer.cs new file mode 100644 index 0000000..c7c2683 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/MigrateRenderer.cs @@ -0,0 +1,117 @@ +using System.Text; +using NanoFramework.Migrate.Core; +using Spectre.Console; +using static NanoFramework.Migrate.Cli.Rendering.ConsoleSupport; + +namespace NanoFramework.Migrate.Cli.Rendering; + +/// Pairs a source .nfproj path with its conversion outcome. +internal readonly record struct ProjectOutcome(string Nfproj, ConvertResult Result); + +/// Spectre presentation for the migrate command. Consumes Core's data. +internal static class MigrateRenderer +{ + public static void RenderSummaryTable(List results, string baseDir, bool dryRun) + { + var table = new Table().Border(TableBorder.Rounded).Expand(); + table.Title = new TableTitle(dryRun ? "Migration preview (dry run)" : "Migration summary"); + table.AddColumn("Project"); + table.AddColumn("Result"); + table.AddColumn("Packages"); + table.AddColumn("Notes"); + + foreach (var (nf, result) in results) + { + var rel = Path.GetRelativePath(baseDir, nf); + var (label, color) = StatusLabel(result.Status); + + var pkgs = result.Packages.Count == 0 + ? "[grey]—[/]" + : string.Join("\n", result.Packages.Select(p => $"{Esc(p.Key)} [grey]{Esc(p.Value)}[/]")); + + var notes = BuildNotesCell(result, dryRun); + + table.AddRow( + new Markup($"[blue]{Esc(rel)}[/]"), + new Markup($"[{color}]{label}[/]"), + new Markup(pkgs), + new Markup(notes)); + } + + AnsiConsole.Write(table); + } + + // The Notes cell: in dry-run it previews what WOULD change (target path, + // deletions, .sln edits); otherwise it shows the count of review flags. + private static string BuildNotesCell(ConvertResult result, bool dryRun) + { + if (result.Status == ConvertStatus.Error) + return $"[red]{Esc(result.Error ?? "error")}[/]"; + if (result.Status == ConvertStatus.Skipped) + return "[grey]already SDK-style[/]"; + + var lines = new List(); + if (dryRun) + { + lines.Add($"[grey]→[/] {Esc(Path.GetFileName(result.OutputPath))}"); + foreach (var d in result.DeletedFiles) + lines.Add($"[red]delete[/] {Esc(Path.GetFileName(d))}"); + foreach (var s in result.UpdatedSolutions) + lines.Add($"[yellow]edit[/] {Esc(Path.GetFileName(s))}"); + } + if (result.Review.Count > 0) + lines.Add($"[yellow]{result.Review.Count} item(s) need review[/]"); + return lines.Count == 0 ? "[green]clean[/]" : string.Join("\n", lines); + } + + // Review notes get a clearly-visible yellow panel, grouped per project, so + // they are never buried in the table. + public static void RenderReviewNotes(List results, string baseDir) + { + var flagged = results.Where(r => r.Result.Review.Count > 0 + && r.Result.Status == ConvertStatus.Review).ToList(); + if (flagged.Count == 0) return; + + var sb = new StringBuilder(); + foreach (var (nf, result) in flagged) + { + var rel = Path.GetRelativePath(baseDir, nf); + sb.Append($"[bold]{Esc(rel)}[/]\n"); + foreach (var item in result.Review) + sb.Append($" [yellow]•[/] {Esc(item)}\n"); + } + + var panel = new Panel(sb.ToString().TrimEnd('\n')) + { + Header = new PanelHeader("[yellow]MANUAL REVIEW NEEDED[/]"), + Border = BoxBorder.Rounded, + BorderStyle = new Style(Color.Yellow), + Expand = true, + }; + AnsiConsole.WriteLine(); + AnsiConsole.Write(panel); + } + + public static void RenderTally(List results, bool dryRun) + { + int converted = results.Count(r => r.Result.Status == ConvertStatus.Converted); + int skipped = results.Count(r => r.Result.Status == ConvertStatus.Skipped); + int flagged = results.Count(r => r.Result.Status == ConvertStatus.Review); + int errors = results.Count(r => r.Result.Status == ConvertStatus.Error); + var verb = dryRun ? "would convert" : "converted"; + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine( + $"[green]{verb} {converted}[/] • [grey]skipped {skipped}[/] • " + + $"[yellow]flagged {flagged}[/] • [red]errors {errors}[/] • total {results.Count}"); + } + + private static (string label, string color) StatusLabel(ConvertStatus s) => s switch + { + ConvertStatus.Converted => ("Converted", "green"), + ConvertStatus.Skipped => ("Skipped", "grey"), + ConvertStatus.Review => ("Review", "yellow"), + ConvertStatus.Error => ("Error", "red"), + _ => ("?", "white"), + }; +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/UserError.cs b/tools/NanoMigrate/src/NanoMigrate.Cli/UserError.cs new file mode 100644 index 0000000..694c99c --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/UserError.cs @@ -0,0 +1,4 @@ +namespace NanoFramework.Migrate.Cli; + +/// A user-facing error that prints cleanly without a stack trace. +internal sealed class UserError(string message) : Exception(message); diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs b/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs new file mode 100644 index 0000000..2a2e36d --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs @@ -0,0 +1,38 @@ +namespace NanoFramework.Migrate.Core; + +/// +/// The knobs that drive a conversion. This is the engine-facing options type — it +/// is intentionally NOT the CLI settings type, so the engine carries no console or +/// command-line dependency. The CLI maps its parsed settings onto this record. +/// +public sealed record ConversionOptions +{ + /// + /// Output extension for the emitted project. Either .csproj or + /// .nfproj. Default .csproj: a normal run produces Foo.csproj and + /// retires Foo.nfproj. + /// + public string Ext { get; init; } = ".csproj"; + + /// Target framework moniker written into the emitted project. + public string Tfm { get; init; } = "netnano1.0"; + + /// + /// Accepted for back-compat but no longer emitted: the SDK reference is + /// versionless (the version is pinned via global.json msbuild-sdks). + /// + public string Sdk { get; init; } = "2.0.0"; + + /// Analyse and preview only; write nothing to disk. + public bool DryRun { get; init; } + + /// Don't write a .nfproj.bak alongside the converted project. + public bool NoBackup { get; init; } + + /// + /// Glob filter (relative to the input directory) selecting which .nfproj + /// to convert. Null means "all .nfproj recursively" (the default). + /// Supports *, ** and ?. + /// + public string? Glob { get; init; } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/ConvertResult.cs b/tools/NanoMigrate/src/NanoMigrate.Core/ConvertResult.cs new file mode 100644 index 0000000..99fdb1a --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/ConvertResult.cs @@ -0,0 +1,52 @@ +namespace NanoFramework.Migrate.Core; + +/// How a single project ended up after a (dry-run or real) conversion. +public enum ConvertStatus +{ + /// Converted cleanly. + Converted, + + /// Already SDK-style; nothing to do. + Skipped, + + /// Converted, but flagged items need a human. + Review, + + /// Threw while converting. + Error, +} + +/// +/// Outcome of converting a single project, expressed as DATA. The engine never +/// writes to the console — the CLI consumes this to render its presentation. +/// +public sealed class ConvertResult +{ + /// The path the conversion emits (or, in dry-run, would emit). + public required string OutputPath { get; init; } + + /// Items the tool could not confidently resolve; each needs a human. + public List Review { get; } = new(); + + /// True when the project was already SDK-style and was left untouched. + public bool AlreadySdk { get; set; } + + /// Resolved PackageReferences (id -> version) the emitted project will carry. + public List> Packages { get; } = new(); + + /// Files this conversion deletes (or, in dry-run, would delete). + public List DeletedFiles { get; } = new(); + + /// .sln files this conversion retargets (or, in dry-run, would retarget). + public List UpdatedSolutions { get; } = new(); + + /// Set when the conversion threw; used to render a red Error row. + public string? Error { get; set; } + + /// Derived status, computed from the data above. + public ConvertStatus Status => + Error is not null ? ConvertStatus.Error + : AlreadySdk ? ConvertStatus.Skipped + : Review.Count > 0 ? ConvertStatus.Review + : ConvertStatus.Converted; +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/FleetService.cs b/tools/NanoMigrate/src/NanoMigrate.Core/FleetService.cs new file mode 100644 index 0000000..acf70ec --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/FleetService.cs @@ -0,0 +1,241 @@ +using System.Text; + +namespace NanoFramework.Migrate.Core; + +/// Result of a single git invocation. Mirrors a process exit. +public readonly record struct GitResult(int Code, string Stdout, string Stderr); + +/// +/// Runs git in a working directory. The engine stays process-free by delegating +/// the actual side effect to an implementation supplied by the host (the CLI). +/// +public interface IGitRunner +{ + GitResult Run(string args, string workingDirectory); +} + +/// The knobs that drive a fleet run, layered over the per-project options. +public sealed record FleetOptions +{ + /// Per-project conversion options applied to every project in the fleet. + public ConversionOptions Conversion { get; init; } = new(); + + /// Markdown report path. + public string Report { get; init; } = "migration-report.md"; + + /// Create/reset this git branch in each repo. Must not start with 'develop'. + public string? Branch { get; init; } + + /// Commit the changes (requires ). + public bool Commit { get; init; } + + /// Commit summary line (kept <= 50 chars). + public string? CommitMessage { get; init; } + + /// Referenced as "Fix #<n>" in the commit. + public string? Issue { get; init; } + + /// nanoFramework recommends a Signed-off-by line; on by default. + public bool SignOff { get; init; } = true; +} + +/// +/// Pure fleet orchestration: validate options, discover repos, convert every +/// project in each, optionally branch and commit (via an injected +/// ), and produce a markdown report. Never writes to the +/// console; progress is surfaced through an optional callback so a host can drive +/// a spinner. +/// +public sealed class FleetService +{ + private readonly IProjectConverter _converter; + private readonly IGitRunner _git; + + public FleetService(IProjectConverter converter, IGitRunner git) + { + _converter = converter; + _git = git; + } + + /// + /// Validates the fleet options against the repos directory, throwing + /// with a user-facing message on any problem. + /// Mirrors the original command's up-front checks. + /// + public List ResolveRepos(string reposDir, FleetOptions o) + { + if (!Directory.Exists(reposDir)) throw new ArgumentException($"directory not found: {reposDir}"); + if (o.Commit && o.Branch is null) throw new ArgumentException("--commit requires --branch"); + // nanoFramework workflow: branch names must not start with "develop" (they + // collide with upstream develop-* branches). + if (o.Branch is not null && o.Branch.StartsWith("develop", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("branch name must not start with 'develop' (nanoFramework workflow); " + + "use something like 'sdk-migration' or 'issue-123'"); + + // A repo qualifies if it contains at least one .nfproj that survives the + // glob filter (default: any .nfproj). + bool RepoMatches(string d) => ProjectScanner.NfprojUnder(d, o.Conversion.Glob).Any(); + + var repoDirs = Directory.EnumerateDirectories(reposDir) + .Where(RepoMatches) + .OrderBy(d => d).ToList(); + if (repoDirs.Count == 0) + throw new ArgumentException(o.Conversion.Glob is null + ? $"no repos containing .nfproj found under '{reposDir}'" + : $"no repos with .nfproj matching glob '{o.Conversion.Glob}' found under '{reposDir}'"); + return repoDirs; + } + + /// + /// Processes every repo: optionally branch, convert each project, optionally + /// commit. (if supplied) is invoked with each repo + /// name before it is processed. Returns one per repo. + /// + public List Process(List repoDirs, FleetOptions o, Action? progress = null) + { + // In a git repo the commit history already preserves the pre-migration file, + // so a .bak alongside it is just noise in the diff. Skip backups when committing. + var conv = o.Commit ? o.Conversion with { NoBackup = true } : o.Conversion; + + var report = new List(); + foreach (var repo in repoDirs) + { + progress?.Invoke(Path.GetFileName(repo)); + var rr = new RepoReport { Name = Path.GetFileName(repo) }; + try + { + if (o.Branch is not null && !conv.DryRun) + { + var checkout = _git.Run($"checkout -B {o.Branch}", repo); + if (checkout.Code != 0) { rr.Error = $"git checkout failed: {checkout.Stderr.Trim()}"; report.Add(rr); continue; } + } + + foreach (var nf in ProjectScanner.NfprojUnder(repo, conv.Glob).OrderBy(p => p)) + { + rr.Projects++; + var result = _converter.Convert(nf, conv); + var rel = Path.GetRelativePath(repo, nf); + foreach (var item in result.Review) rr.Review.Add($"{rel}: {item}"); + } + + if (o.Commit && !conv.DryRun) + { + _git.Run("add -A", repo); + var msgFile = WriteCommitMessage(o); + var signOff = o.SignOff ? "-s " : ""; + var commit = _git.Run($"commit {signOff}-F \"{msgFile}\"", repo); + File.Delete(msgFile); + rr.Committed = commit.Code == 0; + if (commit.Code != 0 && !commit.Stderr.Contains("nothing to commit")) + { + rr.Error = commit.Stderr.Contains("Please tell me who you are") || commit.Stderr.Contains("user.name") + ? "git commit failed: set git user.name/user.email (real name) so the " + + "Signed-off-by line is valid, or pass --no-sign-off" + : $"git commit: {commit.Stderr.Trim()}"; + } + } + } + catch (Exception ex) + { + rr.Error = ex.Message; + } + report.Add(rr); + } + return report; + } + + /// Builds the markdown fleet report and writes it to o.Report. + public void WriteReport(List report, FleetOptions o, string reposDir) + { + File.WriteAllText(o.Report, BuildReport(report, o, reposDir)); + } + + /// Builds the markdown fleet report as a string (pure; no I/O). + public static string BuildReport(List report, FleetOptions o, string reposDir) + { + var sb = new StringBuilder(); + sb.AppendLine("# nanoFramework SDK-style migration — fleet report\n"); + sb.AppendLine($"- Source: `{Path.GetFullPath(reposDir)}`"); + sb.AppendLine($"- Mode: {(o.Conversion.DryRun ? "dry-run (no files written)" : "applied")}" + + (o.Branch is not null ? $", branch `{o.Branch}`" : "") + + (o.Commit ? ", committed" : "")); + sb.AppendLine($"- SDK `nanoFramework.NET.Sdk` (versionless), TFM `{o.Conversion.Tfm}`, output extension `{o.Conversion.Ext}`\n"); + + int total = report.Count, clean = report.Count(r => r.Error is null && r.Review.Count == 0); + int needsReview = report.Count(r => r.Error is null && r.Review.Count > 0); + int errors = report.Count(r => r.Error is not null); + sb.AppendLine("## Summary\n"); + sb.AppendLine($"| Repos | Clean | Needs review | Errored |"); + sb.AppendLine($"|------:|------:|-------------:|--------:|"); + sb.AppendLine($"| {total} | {clean} | {needsReview} | {errors} |\n"); + + if (errors > 0) + { + sb.AppendLine("## Errored repos\n"); + foreach (var r in report.Where(r => r.Error is not null)) + sb.AppendLine($"- **{r.Name}** — {r.Error}"); + sb.AppendLine(); + } + + if (needsReview > 0) + { + sb.AppendLine("## Repos needing manual review\n"); + sb.AppendLine("These migrated, but the tool could not confidently resolve everything. " + + "Each line is something a human should confirm before merging.\n"); + foreach (var r in report.Where(r => r.Error is null && r.Review.Count > 0)) + { + sb.AppendLine($"### {r.Name}\n"); + foreach (var item in r.Review) sb.AppendLine($"- {item}"); + sb.AppendLine(); + } + } + + if (clean > 0) + { + sb.AppendLine("## Clean migrations\n"); + sb.AppendLine("Converted with no items flagged for review:\n"); + foreach (var r in report.Where(r => r.Error is null && r.Review.Count == 0)) + sb.AppendLine($"- {r.Name} ({r.Projects} project(s))" + + (r.Committed ? " — committed" : "")); + sb.AppendLine(); + } + + return sb.ToString(); + } + + // Builds a commit message that follows the nanoFramework guidance: a short + // summary (<= 50 chars), a blank line, a body wrapped at 72 columns, and an + // optional "Fix #" trailer. Returns the path to a temp message file. + private static string WriteCommitMessage(FleetOptions o) + { + var summary = o.CommitMessage ?? "Migrate project system to SDK-style"; + if (summary.Length > 50) summary = summary[..50].TrimEnd(); + + var body = Wrap( + "Convert the legacy .nfproj project system to an SDK-style MSBuild project: " + + "drop project-system boilerplate, fold packages.config into PackageReference, " + + "and fold .nuspec metadata into MSBuild Pack properties. " + + "No functional code changes.", 72); + + var sb = new StringBuilder(); + sb.Append(summary).Append("\n\n").Append(body).Append('\n'); + if (o.Issue is not null) sb.Append("\nFix #").Append(o.Issue).Append('\n'); + + var path = Path.GetTempFileName(); + File.WriteAllText(path, sb.ToString()); + return path; + } + + private static string Wrap(string text, int width) + { + var sb = new StringBuilder(); + int lineLen = 0; + foreach (var word in text.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + if (lineLen > 0 && lineLen + 1 + word.Length > width) { sb.Append('\n'); lineLen = 0; } + else if (lineLen > 0) { sb.Append(' '); lineLen++; } + sb.Append(word); lineLen += word.Length; + } + return sb.ToString(); + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Glob.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Glob.cs new file mode 100644 index 0000000..5080fb1 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Glob.cs @@ -0,0 +1,69 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace NanoFramework.Migrate.Core; + +/// +/// Small hand-rolled glob matcher (no external dependency). Matches a path that +/// is relative to the input directory against a pattern supporting: +/// * any run of characters except a path separator +/// ** any run of characters including path separators (spanning directories) +/// ? exactly one character except a path separator +/// Matching is case-insensitive and separator-insensitive ('/' and '\' are +/// treated as equivalent), and is anchored to the whole relative path. +/// +public static class Glob +{ + public static bool IsMatch(string relativePath, string pattern) + { + var input = Normalize(relativePath); + var regex = ToRegex(Normalize(pattern)); + return regex.IsMatch(input); + } + + private static string Normalize(string s) => s.Replace('\\', '/').Trim('/'); + + // Translates a normalized ('/'-separated) glob into an anchored, case-insensitive + // regex. Literal characters are escaped; only *, ** and ? carry meaning. + private static Regex ToRegex(string pattern) + { + var sb = new StringBuilder("^"); + for (int i = 0; i < pattern.Length; i++) + { + var c = pattern[i]; + switch (c) + { + // "/**" spans directories AND the leading separator is optional, so + // "Beginner/**" also matches "Beginner" itself. Emit "(/.*)?" and + // consume the slash, both stars, and any trailing slash. + case '/' when i + 2 < pattern.Length && pattern[i + 1] == '*' && pattern[i + 2] == '*': + i += 2; // consume '/' is current; skip both '*' + if (i + 1 < pattern.Length && pattern[i + 1] == '/') i++; + sb.Append("(/.*)?"); + break; + case '*': + if (i + 1 < pattern.Length && pattern[i + 1] == '*') + { + i++; // consume the second '*' + // "**/" (or trailing "**") spans directories; collapse any + // following slash so "Beginner/**" also matches "Beginner". + if (i + 1 < pattern.Length && pattern[i + 1] == '/') i++; + sb.Append(".*"); + } + else + { + sb.Append("[^/]*"); // single star: stay within a segment + } + break; + case '?': + sb.Append("[^/]"); + break; + default: + sb.Append(Regex.Escape(c.ToString())); + break; + } + } + sb.Append('$'); + return new Regex(sb.ToString(), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/IProjectConverter.cs b/tools/NanoMigrate/src/NanoMigrate.Core/IProjectConverter.cs new file mode 100644 index 0000000..91a005f --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/IProjectConverter.cs @@ -0,0 +1,15 @@ +namespace NanoFramework.Migrate.Core; + +/// +/// Converts a single legacy .nfproj into an SDK-style project. The result +/// is returned as data; implementations never write to the console. +/// +public interface IProjectConverter +{ + /// + /// Converts the project at per . + /// In dry-run mode nothing is written; the returned + /// still describes exactly what a real run would change. + /// + ConvertResult Convert(string nfprojPath, ConversionOptions options); +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/NanoMigrate.Core.csproj b/tools/NanoMigrate/src/NanoMigrate.Core/NanoMigrate.Core.csproj new file mode 100644 index 0000000..e6c42c8 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/NanoMigrate.Core.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + NanoFramework.Migrate.Core + NanoMigrate.Core + true + + + + + nanoFramework.Migrate.Core + nanoFramework Migrate (Core) + Conversion engine that migrates legacy nanoFramework .nfproj projects to the SDK-style MSBuild project system: folds packages.config into PackageReference and .nuspec metadata into Pack properties. Console-free library; see the nano-migrate CLI for an interactive front end. + nanoFramework + nanoFramework + https://github.com/nanoframework/nanoframework-sdk + https://github.com/nanoframework/nanoframework-sdk + git + MIT + nanoFramework;MSBuild;SDK;migration;nfproj;IoT;embedded;C# + Copyright (c) .NET Foundation and Contributors + false + + + diff --git a/tools/NanoMigrate/Converter.cs b/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs similarity index 92% rename from tools/NanoMigrate/Converter.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs index 6f6539b..9432506 100644 --- a/tools/NanoMigrate/Converter.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs @@ -2,44 +2,7 @@ using System.Text.RegularExpressions; using System.Xml.Linq; -namespace NanoFramework.Migrate; - -/// How a single project ended up after a (dry-run or real) conversion. -internal enum ConvertStatus -{ - Converted, // converted cleanly - Skipped, // already SDK-style; nothing to do - Review, // converted, but flagged items need a human - Error, // threw while converting -} - -/// Outcome of converting a single project. -internal sealed class ConvertResult -{ - public required string OutputPath { get; init; } - public List Review { get; } = new(); - - /// True when the project was already SDK-style and was left untouched. - public bool AlreadySdk { get; set; } - - /// Resolved PackageReferences (id -> version) the emitted project will carry. - public List> Packages { get; } = new(); - - /// Files this conversion deletes (or, in dry-run, would delete). - public List DeletedFiles { get; } = new(); - - /// .sln files this conversion retargets (or, in dry-run, would retarget). - public List UpdatedSolutions { get; } = new(); - - /// Set when the conversion threw; used to render a red Error row. - public string? Error { get; set; } - - public ConvertStatus Status => - Error is not null ? ConvertStatus.Error - : AlreadySdk ? ConvertStatus.Skipped - : Review.Count > 0 ? ConvertStatus.Review - : ConvertStatus.Converted; -} +namespace NanoFramework.Migrate.Core; /// /// Converts one legacy .nfproj into an SDK-style project. Faithful to the @@ -49,7 +12,7 @@ Error is not null ? ConvertStatus.Error /// and "fail loud" — anything it cannot confidently convert is surfaced for a /// human rather than guessed. /// -internal static class Converter +public sealed class ProjectConverter : IProjectConverter { private static readonly XNamespace Ns = "http://schemas.microsoft.com/developer/msbuild/2003"; @@ -87,8 +50,10 @@ internal static class Converter // global.json `msbuild-sdks` entry, not the Sdk attribute. private const string SdkReference = "nanoFramework.NET.Sdk"; - public static ConvertResult Convert(string nfproj, Options o) + public ConvertResult Convert(string nfprojPath, ConversionOptions options) { + var nfproj = nfprojPath; + var o = options; var projDir = Path.GetDirectoryName(Path.GetFullPath(nfproj))!; var root = XElement.Load(nfproj); @@ -457,7 +422,7 @@ private static string Emit( Dictionary pkgRefs, List projRefs, List keepItems, - Options o) + ConversionOptions o) { var sb = new StringBuilder(); // Versionless SDK reference; the version is pinned via global.json msbuild-sdks. diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/ProjectScanner.cs b/tools/NanoMigrate/src/NanoMigrate.Core/ProjectScanner.cs new file mode 100644 index 0000000..d3842b7 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/ProjectScanner.cs @@ -0,0 +1,45 @@ +namespace NanoFramework.Migrate.Core; + +/// +/// Enumerates .nfproj files under a path, with an optional glob filter. +/// Pure file-system discovery — no console, no side effects. +/// +public static class ProjectScanner +{ + /// + /// Resolves the set of .nfproj targets for a path. If + /// is a single .nfproj file it is returned alone; if it is a directory, + /// every .nfproj beneath it (optionally filtered by , + /// matched against the path relative to the directory) is returned, sorted. + /// A non-existent path yields an empty list. + /// + public static List ResolveProjects(string path, string? glob) + { + if (File.Exists(path) && path.EndsWith(".nfproj", StringComparison.OrdinalIgnoreCase)) + return new List { Path.GetFullPath(path) }; + if (!Directory.Exists(path)) return new List(); + + var baseDir = Path.GetFullPath(path); + return Directory.EnumerateFiles(path, "*.nfproj", SearchOption.AllDirectories) + .Select(Path.GetFullPath) + .Where(p => glob is null || Glob.IsMatch(Path.GetRelativePath(baseDir, p), glob)) + .OrderBy(p => p) + .ToList(); + } + + /// + /// Enumerates the .nfproj under a repo directory that survive the glob + /// filter (matched against the path relative to the repo directory). Returns + /// absolute, full paths. + /// + public static IEnumerable NfprojUnder(string repoDir, string? glob) + { + var baseDir = Path.GetFullPath(repoDir); + foreach (var nf in Directory.EnumerateFiles(repoDir, "*.nfproj", SearchOption.AllDirectories)) + { + var full = Path.GetFullPath(nf); + if (glob is null || Glob.IsMatch(Path.GetRelativePath(baseDir, full), glob)) + yield return full; + } + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/RepoReport.cs b/tools/NanoMigrate/src/NanoMigrate.Core/RepoReport.cs new file mode 100644 index 0000000..f7fd62f --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/RepoReport.cs @@ -0,0 +1,11 @@ +namespace NanoFramework.Migrate.Core; + +/// Per-repo outcome accumulated by the fleet command. Pure data. +public sealed class RepoReport +{ + public required string Name { get; init; } + public int Projects { get; set; } + public List Review { get; } = new(); + public bool Committed { get; set; } + public string? Error { get; set; } +} diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/ConverterTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/ConverterTests.cs new file mode 100644 index 0000000..0a9cee0 --- /dev/null +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/ConverterTests.cs @@ -0,0 +1,183 @@ +using NanoFramework.Migrate.Core; +using Xunit; + +namespace NanoFramework.Migrate.Tests; + +public class ConverterTests +{ + private static readonly IProjectConverter Converter = new ProjectConverter(); + + [Fact] + public void Already_sdk_style_project_is_skipped_and_not_written() + { + using var dir = new TempDir(); + const string sdkStyle = """ + + + netnano1.0 + + + """; + var nfproj = dir.File("Already.nfproj", sdkStyle); + var before = File.ReadAllText(nfproj); + + // A non-dry-run so we can prove nothing on disk changed. + var result = Converter.Convert(nfproj, new ConversionOptions()); + + Assert.Equal(ConvertStatus.Skipped, result.Status); + Assert.True(result.AlreadySdk); + Assert.True(File.Exists(nfproj)); // not deleted/renamed + Assert.Equal(before, File.ReadAllText(nfproj)); // not rewritten + Assert.False(File.Exists(nfproj + ".bak")); // no backup written + Assert.False(File.Exists(dir.Combine("Already.csproj"))); // nothing emitted + } + + [Fact] + public void PackageReference_version_comes_from_packages_config_when_absent() + { + using var dir = new TempDir(); + dir.File("packages.config", """ + + + + + """); + var nfproj = dir.File("Sample.nfproj", """ + + Sample + + + + + """); + + var result = Converter.Convert(nfproj, new ConversionOptions { DryRun = true }); + + Assert.Contains(result.Packages, + p => p.Key == "nanoFramework.System.Text" && p.Value == "1.2.54"); + } + + [Fact] + public void Legacy_alias_mscorlib_maps_to_corelibrary_when_no_hintpath() + { + using var dir = new TempDir(); + dir.File("packages.config", """ + + + + + """); + var nfproj = dir.File("Sample.nfproj", """ + + Sample + + + + + """); + + var result = Converter.Convert(nfproj, new ConversionOptions { DryRun = true }); + + Assert.Contains(result.Packages, + p => p.Key == "nanoFramework.CoreLibrary" && p.Value == "1.15.5"); + Assert.Empty(result.Review); + } + + [Fact] + public void Unresolved_reference_is_flagged_for_review_and_no_blank_version_emitted() + { + using var dir = new TempDir(); + // No HintPath, no packages.config — the version cannot be resolved. + var nfproj = dir.File("Sample.nfproj", """ + + Sample + + + + + """); + + var outPath = dir.Combine("Sample.csproj"); + var result = Converter.Convert(nfproj, new ConversionOptions()); // real run so we can read the emitted file + + Assert.Equal(ConvertStatus.Review, result.Status); + Assert.Contains(result.Review, r => r.Contains("SomeRandomLib")); + + var emitted = File.ReadAllText(outPath); + Assert.DoesNotContain("Version=\"\"", emitted); // never emit a blank version + Assert.DoesNotContain("SomeRandomLib", emitted); // the unresolved ref is not carried over + } + + [Fact] + public void Full_convert_emits_sdk_project_deletes_inputs_and_writes_backup() + { + using var dir = new TempDir(); + dir.File("packages.config", """ + + + + + """); + dir.File(Path.Combine("Properties", "AssemblyInfo.cs"), + "using System.Reflection;\n[assembly: AssemblyTitle(\"Sample\")]\n"); + var nfproj = dir.File("Sample.nfproj", """ + + + {00000000-0000-0000-0000-000000000000} + Sample + Sample + + + + + + + + + + + + + """); + + var result = Converter.Convert(nfproj, new ConversionOptions()); + + var csproj = dir.Combine("Sample.csproj"); + Assert.Equal(ConvertStatus.Converted, result.Status); + + // Emitted SDK-style project. + Assert.True(File.Exists(csproj)); + var emitted = File.ReadAllText(csproj); + Assert.Contains("", emitted); + Assert.Contains("netnano1.0", emitted); + Assert.Contains("Sample", emitted); + Assert.Contains("nanoFramework.CoreLibrary", emitted); + Assert.DoesNotContain("ProjectGuid", emitted); // boilerplate dropped + + // Inputs removed. + Assert.False(File.Exists(nfproj)); // .nfproj retired + Assert.False(File.Exists(dir.Combine("packages.config"))); + Assert.False(File.Exists(dir.Combine(Path.Combine("Properties", "AssemblyInfo.cs")))); + + // Backup of the original .nfproj written. + Assert.True(File.Exists(nfproj + ".bak")); + } + + [Fact] + public void Dry_run_writes_nothing() + { + using var dir = new TempDir(); + var nfproj = dir.File("Sample.nfproj", """ + + Sample + + """); + + var result = Converter.Convert(nfproj, new ConversionOptions { DryRun = true }); + + Assert.Equal(ConvertStatus.Converted, result.Status); + Assert.True(File.Exists(nfproj)); // original untouched + Assert.False(File.Exists(dir.Combine("Sample.csproj"))); // nothing emitted + Assert.False(File.Exists(nfproj + ".bak")); // no backup + } +} diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/GlobTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/GlobTests.cs new file mode 100644 index 0000000..bc15ff1 --- /dev/null +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/GlobTests.cs @@ -0,0 +1,33 @@ +using NanoFramework.Migrate.Core; +using Xunit; + +namespace NanoFramework.Migrate.Tests; + +public class GlobTests +{ + [Theory] + // single star: stays within a path segment + [InlineData("Foo.nfproj", "*.nfproj", true)] + [InlineData("Foo.csproj", "*.nfproj", false)] + [InlineData("sub/Foo.nfproj", "*.nfproj", false)] // '*' does not cross a separator + [InlineData("sub/Foo.nfproj", "*/*.nfproj", true)] + // double star: spans directories + [InlineData("a/b/c/Foo.nfproj", "**/*.nfproj", true)] + [InlineData("Foo.nfproj", "**/*.nfproj", true)] // ** can match zero directories + [InlineData("Beginner/x/y.nfproj", "Beginner/**", true)] + [InlineData("Other/x/y.nfproj", "Beginner/**", false)] + // the "Beginner/** matches Beginner itself" rule + [InlineData("Beginner", "Beginner/**", true)] + // single char wildcard + [InlineData("Foo1.nfproj", "Foo?.nfproj", true)] + [InlineData("Foo12.nfproj", "Foo?.nfproj", false)] // '?' is exactly one char + [InlineData("Foo/.nfproj", "Foo?.nfproj", false)] // '?' does not match a separator + // separator-insensitive + [InlineData("a\\b\\Foo.nfproj", "a/b/*.nfproj", true)] + // case-insensitive + [InlineData("FOO.NFPROJ", "*.nfproj", true)] + public void IsMatch(string path, string pattern, bool expected) + { + Assert.Equal(expected, Glob.IsMatch(path, pattern)); + } +} diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/HintPathTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/HintPathTests.cs new file mode 100644 index 0000000..00cd3d1 --- /dev/null +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/HintPathTests.cs @@ -0,0 +1,50 @@ +using NanoFramework.Migrate.Core; +using Xunit; + +namespace NanoFramework.Migrate.Tests; + +public class HintPathTests +{ + private static readonly IProjectConverter Converter = new ProjectConverter(); + + private static string NfprojWithHintPath(string hintPath, string referenceInclude) => $""" + + + Sample + + + + {hintPath} + + + + """; + + [Fact] + public void HintPath_splits_id_and_version_at_first_numeric_segment() + { + using var dir = new TempDir(); + var nfproj = dir.File("Sample.nfproj", NfprojWithHintPath( + @"..\packages\nanoFramework.System.Device.Gpio.1.1.57\lib\System.Device.Gpio.dll", + "System.Device.Gpio")); + + var result = Converter.Convert(nfproj, new ConversionOptions { DryRun = true }); + + Assert.Contains(result.Packages, + p => p.Key == "nanoFramework.System.Device.Gpio" && p.Value == "1.1.57"); + } + + [Fact] + public void HintPath_keeps_prerelease_suffix_attached_to_version() + { + using var dir = new TempDir(); + var nfproj = dir.File("Sample.nfproj", NfprojWithHintPath( + @"..\packages\nanoFramework.CoreLibrary.2.0.0-preview.52\lib\mscorlib.dll", + "mscorlib")); + + var result = Converter.Convert(nfproj, new ConversionOptions { DryRun = true }); + + Assert.Contains(result.Packages, + p => p.Key == "nanoFramework.CoreLibrary" && p.Value == "2.0.0-preview.52"); + } +} diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj b/tools/NanoMigrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj new file mode 100644 index 0000000..9c75951 --- /dev/null +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + NanoFramework.Migrate.Tests + false + true + + + + + + + + + + + + + diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/ProjectScannerTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/ProjectScannerTests.cs new file mode 100644 index 0000000..d09774b --- /dev/null +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/ProjectScannerTests.cs @@ -0,0 +1,56 @@ +using NanoFramework.Migrate.Core; +using Xunit; + +namespace NanoFramework.Migrate.Tests; + +public class ProjectScannerTests +{ + private const string Stub = ""; + + [Fact] + public void ResolveProjects_returns_single_file_when_path_is_an_nfproj() + { + using var dir = new TempDir(); + var nfproj = dir.File("Solo.nfproj", Stub); + + var targets = ProjectScanner.ResolveProjects(nfproj, glob: null); + + Assert.Single(targets); + Assert.Equal(Path.GetFullPath(nfproj), targets[0]); + } + + [Fact] + public void ResolveProjects_enumerates_directory_recursively_sorted() + { + using var dir = new TempDir(); + dir.File(Path.Combine("b", "Two.nfproj"), Stub); + dir.File(Path.Combine("a", "One.nfproj"), Stub); + dir.File("ReadMe.txt", "not a project"); + + var targets = ProjectScanner.ResolveProjects(dir.Path, glob: null); + + Assert.Equal(2, targets.Count); + Assert.EndsWith("One.nfproj", targets[0]); // sorted: a/ before b/ + Assert.EndsWith("Two.nfproj", targets[1]); + } + + [Fact] + public void ResolveProjects_applies_glob_filter() + { + using var dir = new TempDir(); + dir.File(Path.Combine("Beginner", "Blink.nfproj"), Stub); + dir.File(Path.Combine("Advanced", "Threads.nfproj"), Stub); + + var targets = ProjectScanner.ResolveProjects(dir.Path, glob: "Beginner/**"); + + Assert.Single(targets); + Assert.EndsWith("Blink.nfproj", targets[0]); + } + + [Fact] + public void ResolveProjects_returns_empty_for_missing_path() + { + var targets = ProjectScanner.ResolveProjects(Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid()), glob: null); + Assert.Empty(targets); + } +} diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionRewriteTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionRewriteTests.cs new file mode 100644 index 0000000..92ebb07 --- /dev/null +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionRewriteTests.cs @@ -0,0 +1,71 @@ +using NanoFramework.Migrate.Core; +using Xunit; + +namespace NanoFramework.Migrate.Tests; + +public class SolutionRewriteTests +{ + private static readonly IProjectConverter Converter = new ProjectConverter(); + + private const string LegacyGuid = "{11A8DD76-328B-46DF-9F39-F559912D0360}"; + private const string SdkGuid = "{9A19103F-16F7-4668-BE54-9A1E7A4F7556}"; + + private const string Nfproj = """ + + Sample + + """; + + private static string SolutionText(string projectGuid) => $$""" + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{{projectGuid}}") = "Sample", "Sample.nfproj", "{AAAAAAAA-0000-0000-0000-000000000001}" + EndProject + Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Other", "Other.csproj", "{BBBBBBBB-0000-0000-0000-000000000002}" + EndProject + Global + EndGlobal + """; + + [Fact] + public void Convert_rewrites_solution_guid_and_extension_line_scoped() + { + using var dir = new TempDir(); + var nfproj = dir.File("Sample.nfproj", Nfproj); + var sln = dir.File("Sample.sln", SolutionText(LegacyGuid)); + + Converter.Convert(nfproj, new ConversionOptions()); + + var updated = File.ReadAllText(sln); + + // .nfproj retargeted to .csproj and the legacy GUID swapped for the SDK one. + Assert.Contains("\"Sample.csproj\"", updated); + Assert.DoesNotContain("Sample.nfproj", updated); + Assert.Contains(SdkGuid, updated); + + // Line-scoped: the unrelated Other.csproj entry (which already used the SDK + // GUID) is untouched and there is exactly one SDK-GUID Sample line. + Assert.Contains("\"Other.csproj\"", updated); + Assert.DoesNotContain(LegacyGuid, updated); + } + + [Fact] + public void Rerun_over_already_converted_solution_is_a_noop() + { + using var dir = new TempDir(); + var nfproj = dir.File("Sample.nfproj", Nfproj); + var sln = dir.File("Sample.sln", SolutionText(LegacyGuid)); + + // First conversion rewrites the .sln and retires the .nfproj. + Converter.Convert(nfproj, new ConversionOptions()); + var afterFirst = File.ReadAllText(sln); + + // Re-running the converter on the emitted .csproj must be a true no-op for + // the solution (idempotent / reentrant). + var csproj = dir.Combine("Sample.csproj"); + var result = Converter.Convert(csproj, new ConversionOptions()); + + // The csproj is now SDK-style, so convert skips it; the .sln stays put. + Assert.Equal(ConvertStatus.Skipped, result.Status); + Assert.Equal(afterFirst, File.ReadAllText(sln)); + } +} diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/TempDir.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/TempDir.cs new file mode 100644 index 0000000..c66fad6 --- /dev/null +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/TempDir.cs @@ -0,0 +1,28 @@ +namespace NanoFramework.Migrate.Tests; + +/// A throwaway directory that deletes itself (recursively) on Dispose. +internal sealed class TempDir : IDisposable +{ + public string Path { get; } + + public TempDir() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "nanomig-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string File(string relative, string content) + { + var full = System.IO.Path.Combine(Path, relative); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(full)!); + System.IO.File.WriteAllText(full, content); + return full; + } + + public string Combine(string relative) => System.IO.Path.Combine(Path, relative); + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { /* best effort */ } + } +} From 4d1fb24088946d76407a39557e9042db630b176d Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Mon, 15 Jun 2026 23:20:26 -0500 Subject: [PATCH 09/49] NanoMigrate: solution-aware migration (.sln + .slnx input/output) Migrate can now be driven by solutions, and updates them: - A .sln or .slnx can be the input: only the projects referenced by that solution are converted, and the solution is retargeted (classic .sln: project-type GUID {11A8DD76..}->{9A19103F..} + .nfproj->.csproj path; .slnx: Path .nfproj->.csproj). - Directory input, no solution: discover all .sln/.slnx. None -> loose directory mode (unchanged). One or more -> the user selects which solution(s) to migrate (Spectre MultiSelectionPrompt: all/none/multiple); only the chosen solutions' projects convert and those solutions update. - Glob input: find matching .nfproj, then discover the solutions that reference them by analysing solution content; confirm; multi-select when several are affected; update only those solutions. - --solution forces a single target; --yes / non-interactive selects all affected and proceeds. Pure logic in Core (SolutionFile parser for both formats, SolutionScanner, SolutionRewriter, SolutionDiscovery, MigrationPlanner) with 16 new unit tests (44 total, all green); prompts/rendering in the Cli. Idempotent: re-running a converted solution is a no-op. Co-Authored-By: Claude Opus 4.8 --- .../src/NanoMigrate.Cli/Cli/MigrateCommand.cs | 185 ++++++++- .../Rendering/MigrateRenderer.cs | 38 ++ .../src/NanoMigrate.Core/ConversionOptions.cs | 10 + .../src/NanoMigrate.Core/MigrationPlan.cs | 198 ++++++++++ .../src/NanoMigrate.Core/ProjectConverter.cs | 134 ++----- .../src/NanoMigrate.Core/SolutionDiscovery.cs | 34 ++ .../src/NanoMigrate.Core/SolutionFile.cs | 115 ++++++ .../src/NanoMigrate.Core/SolutionRewriter.cs | 190 ++++++++++ .../src/NanoMigrate.Core/SolutionScanner.cs | 40 ++ .../tests/NanoMigrate.Tests/SolutionTests.cs | 355 ++++++++++++++++++ 10 files changed, 1182 insertions(+), 117 deletions(-) create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/MigrationPlan.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/SolutionDiscovery.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/SolutionFile.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/SolutionRewriter.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/SolutionScanner.cs create mode 100644 tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionTests.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/MigrateCommand.cs b/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/MigrateCommand.cs index 6f5fb64..e5c20f7 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/MigrateCommand.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/MigrateCommand.cs @@ -10,9 +10,15 @@ namespace NanoFramework.Migrate.Cli.Commands; internal sealed class MigrateSettings : CommandSettings { [CommandArgument(0, "")] - [Description("A .nfproj file, or a directory under which every .nfproj is converted.")] + [Description("A .nfproj file, a solution (.sln/.slnx), or a directory. " + + "For a solution, only its referenced .nfproj are converted and the solution is retargeted. " + + "For a directory, discovered solutions drive the selection; with no solution found, every .nfproj under the directory is converted.")] public string Path { get; init; } = ""; + [CommandOption("--solution ")] + [Description("Migrate only this solution (.sln or .slnx): convert just its referenced .nfproj and retarget that one solution. Overrides directory discovery.")] + public string? Solution { get; init; } + [CommandOption("--sdk ")] [Description("Accepted for back-compat but ignored: the SDK reference is versionless (pinned via global.json msbuild-sdks).")] public string Sdk { get; init; } = "2.0.0"; @@ -34,11 +40,11 @@ internal sealed class MigrateSettings : CommandSettings public bool DryRun { get; init; } [CommandOption("--glob ")] - [Description("Only convert .nfproj whose path (relative to ) matches the glob. Supports *, ** and ?. Example: \"Beginner/**\".")] + [Description("Only convert .nfproj whose path (relative to ) matches the glob; the solutions referencing any matched project are updated. Supports *, ** and ?. Example: \"Beginner/**\".")] public string? Glob { get; init; } [CommandOption("-y|--yes")] - [Description("Skip the interactive \"Proceed?\" confirmation on a real run. (Non-interactive runs never prompt regardless.)")] + [Description("Skip interactive prompts (Proceed? / solution selection): select all affected solutions and proceed. (Non-interactive runs never prompt regardless.)")] public bool AssumeYes { get; init; } public override ValidationResult Validate() @@ -68,44 +74,183 @@ protected override int Execute(CommandContext context, MigrateSettings settings, Header("NanoMigrate"); var o = settings.ToConversionOptions(); - var path = settings.Path; - var targets = ProjectScanner.ResolveProjects(path, o.Glob); + var plan = MigrationPlanner.Plan(settings.Path, settings.Solution, settings.Glob); + + // Solution-aware plans (explicit solution, directory-with-solutions, glob) + // are handled by their own path; loose/single plans keep the historical flow. + return plan.Kind switch + { + PlanKind.SingleProject or PlanKind.LooseDirectory => RunLoose(settings, o, plan), + _ => RunSolutionScoped(settings, o, plan), + }; + } - // Reentrant: a fully-converted tree has no .nfproj left. Exit cleanly (0) - // rather than erroring, so re-running the converter over a repo is a safe - // no-op. + // The historical flow: a single .nfproj or a directory with no solutions. The + // converter retargets any solutions it finds by walking up the tree. + private int RunLoose(MigrateSettings settings, ConversionOptions o, MigrationPlan plan) + { + var targets = plan.LooseProjects.ToList(); + + // Reentrant: a fully-converted tree has no .nfproj left. Exit cleanly (0). if (targets.Count == 0) { var why = o.Glob is null - ? $"no .nfproj found under '{Esc(path)}' (already SDK-style?)." - : $"no .nfproj matched glob '{Esc(o.Glob)}' under '{Esc(path)}'."; + ? $"no .nfproj found under '{Esc(settings.Path)}' (already SDK-style?)." + : $"no .nfproj matched glob '{Esc(o.Glob)}' under '{Esc(settings.Path)}'."; AnsiConsole.MarkupLine($"[grey]nothing to convert: {why}[/]"); return 0; } - // Determine the base directory glob/relative paths are reported against. - var baseDir = Directory.Exists(path) ? Path.GetFullPath(path) - : Path.GetDirectoryName(Path.GetFullPath(path))!; + var baseDir = BaseDirFor(settings.Path); AnsiConsole.MarkupLine(o.DryRun ? $"[yellow]Dry run[/] — analysing [bold]{targets.Count}[/] project(s) under [blue]{Esc(baseDir)}[/]. Nothing will be written." : $"Found [bold]{targets.Count}[/] project(s) to convert under [blue]{Esc(baseDir)}[/]."); AnsiConsole.WriteLine(); - // Real, interactive runs confirm once before touching disk. In dry-run or - // when stdin is redirected (CI/automation) we proceed without prompting so - // nothing blocks. --yes also skips the prompt. - if (!o.DryRun && !settings.AssumeYes && IsInteractive() - && !AnsiConsole.Confirm($"Proceed with {targets.Count} conversion(s)?")) + if (!Confirm($"Proceed with {targets.Count} conversion(s)?", settings, o)) return AbortedExit(); + + var results = ProcessProjects(targets, baseDir, o); + return Report(results, baseDir, o, rewritten: Array.Empty()); + } + + // The solution-aware flow: pick the candidate solutions (multi-select / confirm), + // convert their .nfproj, then retarget exactly those solutions ourselves. + private int RunSolutionScoped(MigrateSettings settings, ConversionOptions o, MigrationPlan plan) + { + var baseDir = BaseDirFor(settings.Path); + + if (plan.Candidates.Count == 0) { - AnsiConsole.MarkupLine("[grey]aborted; nothing written.[/]"); + AnsiConsole.MarkupLine("[grey]nothing to convert: the selected solution(s) reference no .nfproj (already SDK-style?).[/]"); return 0; } - var results = ProcessProjects(targets, baseDir, o); + // Show what is in scope before any prompt. + MigrateRenderer.RenderCandidateSolutions(plan.Candidates, baseDir); + + var chosen = SelectSolutions(plan, settings, o); + if (chosen.Count == 0) + { + AnsiConsole.MarkupLine("[grey]aborted; no solution selected, nothing written.[/]"); + return 0; + } + + var targets = MigrationPlan.ProjectsOf(chosen); + if (targets.Count == 0) + { + AnsiConsole.MarkupLine("[grey]nothing to convert: the chosen solution(s) reference no .nfproj.[/]"); + return 0; + } + + AnsiConsole.MarkupLine(o.DryRun + ? $"[yellow]Dry run[/] — analysing [bold]{targets.Count}[/] project(s) across [bold]{chosen.Count}[/] solution(s). Nothing will be written." + : $"Converting [bold]{targets.Count}[/] project(s) across [bold]{chosen.Count}[/] solution(s)."); + AnsiConsole.WriteLine(); + + if (!Confirm($"Proceed with {targets.Count} conversion(s) and update {chosen.Count} solution(s)?", settings, o)) + return AbortedExit(); + + // The host owns solution retargeting here, so the converter must not also + // walk up and rewrite solutions the user did not select. + var convOpts = o with { SkipSolutionRewrite = true }; + var results = ProcessProjects(targets, baseDir, convOpts); + + // Retarget exactly the chosen solutions to the converted projects. Only + // projects that actually converted (a .csproj now exists / would exist) are + // handed to the rewriter; idempotent on re-run. + var converted = results + .Where(r => r.Result.Status is ConvertStatus.Converted or ConvertStatus.Review) + .Select(r => r.Nfproj) + .ToList(); + var rewritten = new List(); + if (!o.DryRun && converted.Count > 0) + { + foreach (var c in chosen) + if (SolutionRewriter.RewriteFile(c.Solution, converted)) + rewritten.Add(c.Solution.Path); + } + + return Report(results, baseDir, o, rewritten); + } + + // Decides which candidate solutions to operate on. Explicit single solution: no + // choice. Otherwise multi-select when interactive; non-interactive / --yes + // selects all affected (announcing it). + private static List SelectSolutions(MigrationPlan plan, MigrateSettings settings, ConversionOptions o) + { + // An explicit single solution (positional .sln/.slnx or --solution) is the + // only candidate; there is nothing to choose. + if (!plan.RequiresSelection) + return plan.Candidates.ToList(); + + var all = plan.Candidates.ToList(); + + // Only one affected solution: a simple confirm, not a multi-select. + if (all.Count == 1) + { + if (settings.AssumeYes || o.DryRun || !IsInteractive()) + { + if (!settings.AssumeYes && !o.DryRun) + AnsiConsole.MarkupLine("[grey]non-interactive: selecting the only affected solution.[/]"); + return all; + } + var fmt = all[0].Solution.Format == SolutionFormat.Xml ? "slnx" : "sln"; + return AnsiConsole.Confirm($"Migrate solution '{Esc(Path.GetFileName(all[0].Solution.Path))}' ({fmt})?") + ? all : new List(); + } + + // Several solutions: CI / non-interactive selects all and proceeds. + if (settings.AssumeYes || o.DryRun || !IsInteractive()) + { + if (!settings.AssumeYes && !o.DryRun) + AnsiConsole.MarkupLine($"[grey]non-interactive: selecting all {all.Count} affected solution(s).[/]"); + return all; + } + + // Interactive: present the multi-select. Picking none aborts cleanly. + var prompt = new MultiSelectionPrompt() + .Title("Select the solution(s) to migrate") + .NotRequired() // picking none is allowed (aborts) + .PageSize(15) + .InstructionsText("[grey](space to toggle, enter to confirm; pick none to abort)[/]") + .UseConverter(c => + { + var fmt = c.Solution.Format == SolutionFormat.Xml ? "slnx" : "sln"; + return $"{Path.GetFileName(c.Solution.Path)} ({fmt}, {c.NanoProjects.Count} project(s))"; + }); + prompt.AddChoices(all); + return AnsiConsole.Prompt(prompt); + } + + // The shared confirm gate. Returns true when we should proceed. In dry-run, when + // --yes is set, or in a non-interactive context we never block. + private static bool Confirm(string question, MigrateSettings settings, ConversionOptions o) + { + if (o.DryRun || settings.AssumeYes || !IsInteractive()) return true; + return AnsiConsole.Confirm(question); + } + + private static int AbortedExit() + { + AnsiConsole.MarkupLine("[grey]aborted; nothing written.[/]"); + return 0; + } + + // The base directory glob/relative paths are reported against. + private static string BaseDirFor(string path) => + Directory.Exists(path) ? Path.GetFullPath(path) + : File.Exists(path) ? Path.GetDirectoryName(Path.GetFullPath(path))! + : Path.GetFullPath(path); + + // Renders the summary/review/tally and (for solution-scoped runs) the rewritten + // solutions, then maps the outcome to the exit code (0 clean / 2 review / 1 error). + private static int Report(List results, string baseDir, ConversionOptions o, IReadOnlyList rewritten) + { MigrateRenderer.RenderSummaryTable(results, baseDir, o.DryRun); MigrateRenderer.RenderReviewNotes(results, baseDir); + MigrateRenderer.RenderRewrittenSolutions(rewritten, baseDir); MigrateRenderer.RenderTally(results, o.DryRun); var errors = results.Count(r => r.Result.Status == ConvertStatus.Error); diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/MigrateRenderer.cs b/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/MigrateRenderer.cs index c7c2683..c298149 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/MigrateRenderer.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/MigrateRenderer.cs @@ -106,6 +106,44 @@ public static void RenderTally(List results, bool dryRun) + $"[yellow]flagged {flagged}[/] • [red]errors {errors}[/] • total {results.Count}"); } + // A tree of the candidate solutions and the .nfproj each would convert, so the + // user sees what is in scope before any prompt. Used for solution-input, + // directory-with-solutions, and glob-scoped plans. + public static void RenderCandidateSolutions(IReadOnlyList candidates, string baseDir) + { + if (candidates.Count == 0) return; + + var tree = new Tree("[bold]Affected solution(s)[/]"); + foreach (var c in candidates) + { + var fmt = c.Solution.Format == SolutionFormat.Xml ? "slnx" : "sln"; + var slnRel = RelOrName(baseDir, c.Solution.Path); + var node = tree.AddNode($"[yellow]{Esc(slnRel)}[/] [grey]({fmt}, {c.NanoProjects.Count} project(s))[/]"); + foreach (var nf in c.NanoProjects) + node.AddNode($"[blue]{Esc(RelOrName(baseDir, nf))}[/]"); + } + AnsiConsole.Write(tree); + AnsiConsole.WriteLine(); + } + + // The solutions actually retargeted by a real run (grouped notice). Pure + // presentation over the rewrite results. + public static void RenderRewrittenSolutions(IReadOnlyList rewritten, string baseDir) + { + if (rewritten.Count == 0) return; + AnsiConsole.WriteLine(); + foreach (var sln in rewritten) + AnsiConsole.MarkupLine($"[yellow]updated[/] {Esc(RelOrName(baseDir, sln))}"); + } + + // A path relative to baseDir when it sits underneath it; otherwise the bare + // file name. Keeps output readable whether or not the file is inside the tree. + private static string RelOrName(string baseDir, string path) + { + var rel = Path.GetRelativePath(baseDir, path); + return rel.StartsWith("..", StringComparison.Ordinal) ? Path.GetFileName(path) : rel; + } + private static (string label, string color) StatusLabel(ConvertStatus s) => s switch { ConvertStatus.Converted => ("Converted", "green"), diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs b/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs index 2a2e36d..4e4001e 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs @@ -35,4 +35,14 @@ public sealed record ConversionOptions /// Supports *, ** and ?. /// public string? Glob { get; init; } + + /// + /// Suppress the converter's built-in solution retargeting (its walk-up + /// discovery + rewrite). Set when the host drives a solution-scoped + /// migration and rewrites the chosen solutions itself via + /// , so the converter never touches a solution the + /// user did not select. The preview + /// list is still populated. Default false preserves the historical behavior. + /// + public bool SkipSolutionRewrite { get; init; } } diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/MigrationPlan.cs b/tools/NanoMigrate/src/NanoMigrate.Core/MigrationPlan.cs new file mode 100644 index 0000000..4e88b0e --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/MigrationPlan.cs @@ -0,0 +1,198 @@ +namespace NanoFramework.Migrate.Core; + +/// +/// How the migration target was specified, which decides whether the CLI must +/// prompt the user to choose among candidate solutions. +/// +public enum PlanKind +{ + /// Input was a single .nfproj file (no solution awareness). + SingleProject, + + /// + /// A directory with no solutions found: fall back to converting every + /// .nfproj loosely (the historical directory behavior). + /// + LooseDirectory, + + /// + /// Input was an explicit solution (positional .sln/.slnx or + /// --solution): convert only that solution's projects, update only it. + /// No prompt. + /// + ExplicitSolution, + + /// + /// A directory where solutions were discovered: the user must choose which + /// solution(s) to migrate (multi-select), unless non-interactive/--yes. + /// + DirectoryWithSolutions, + + /// + /// A glob narrowed the projects; the affected solutions (those referencing a + /// matched project) are presented for confirmation / multi-select. + /// + GlobScoped, +} + +/// +/// A candidate solution surfaced by the planner, paired with the matched +/// .nfproj projects it references (the projects that would be converted and +/// for which this solution would be retargeted). +/// +public sealed class SolutionCandidate +{ + public required SolutionFile Solution { get; init; } + + /// The matched .nfproj (absolute paths) this solution references. + public required IReadOnlyList NanoProjects { get; init; } +} + +/// +/// The pure result of planning a migration. Carries enough data for the CLI to +/// render a preview and (when ) prompt the user — +/// the planner itself does no console I/O and no prompting. +/// +public sealed class MigrationPlan +{ + /// How the target was specified. + public required PlanKind Kind { get; init; } + + /// + /// Candidate solutions the user may choose among (empty for + /// / ). + /// + public IReadOnlyList Candidates { get; init; } = Array.Empty(); + + /// + /// For project/loose-directory plans: the .nfproj to convert when no + /// solution is involved. Empty for solution-scoped plans (those projects come + /// from the chosen candidates). + /// + public IReadOnlyList LooseProjects { get; init; } = Array.Empty(); + + /// + /// True when the CLI must let the user pick among + /// (more than one candidate, or a glob scoping that warrants confirmation). For + /// an explicit single solution this is false (no choice to make). + /// + public bool RequiresSelection => Kind is PlanKind.DirectoryWithSolutions or PlanKind.GlobScoped; + + /// + /// Flattens a chosen set of candidates into the distinct .nfproj projects + /// to convert (sorted, absolute). For non-solution plans the caller uses + /// instead. + /// + public static List ProjectsOf(IEnumerable chosen) => + chosen.SelectMany(c => c.NanoProjects) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) + .ToList(); +} + +/// +/// Builds a from the command inputs and the file system. +/// All branching about solutions vs loose conversion lives here, so the CLI only +/// renders the plan and (when required) prompts. No console, no prompting. +/// +public static class MigrationPlanner +{ + /// + /// Plans a migration. + /// + /// — the positional argument: a .nfproj, a + /// solution (.sln/.slnx), or a directory. + /// — the --solution override; + /// when set, only that solution is touched. + /// — narrows the .nfproj within a directory + /// and triggers solution discovery over the matched projects. + /// + /// + public static MigrationPlan Plan(string path, string? explicitSolution, string? glob) + { + // 1. An explicit --solution always wins: convert only its .nfproj, touch + // only it. (Honoured even if the positional path is a directory.) + if (!string.IsNullOrEmpty(explicitSolution)) + return ExplicitSolutionPlan(explicitSolution); + + // 2. Positional path is itself a solution file. + if (File.Exists(path) && SolutionFile.IsSolutionPath(path)) + return ExplicitSolutionPlan(path); + + // 3. Positional path is a single .nfproj file: no solution awareness. + if (File.Exists(path) && path.EndsWith(".nfproj", StringComparison.OrdinalIgnoreCase)) + return new MigrationPlan + { + Kind = PlanKind.SingleProject, + LooseProjects = new[] { Path.GetFullPath(path) }, + }; + + // 4. Directory. + if (Directory.Exists(path)) + return DirectoryPlan(path, glob); + + // Non-existent path: nothing to do (empty loose plan). + return new MigrationPlan { Kind = PlanKind.LooseDirectory }; + } + + private static MigrationPlan ExplicitSolutionPlan(string solutionPath) + { + var sln = SolutionFile.Load(solutionPath); + var nano = sln.NanoProjects(); + return new MigrationPlan + { + Kind = PlanKind.ExplicitSolution, + Candidates = new[] + { + new SolutionCandidate { Solution = sln, NanoProjects = nano }, + }, + }; + } + + private static MigrationPlan DirectoryPlan(string dir, string? glob) + { + var solutions = SolutionScanner.Load(dir); + + // GLOB MODE: scope to the matched projects, then discover which solutions + // reference any of them. Even with no solutions found we honour the glob via + // a loose conversion of the matched projects. + if (glob is not null) + { + var matched = ProjectScanner.NfprojUnder(dir, glob) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) + .ToList(); + var affected = SolutionDiscovery.Referencing(solutions, matched); + if (affected.Count == 0) + return new MigrationPlan { Kind = PlanKind.LooseDirectory, LooseProjects = matched }; + + var matchedSet = new HashSet(matched, StringComparer.OrdinalIgnoreCase); + return new MigrationPlan + { + Kind = PlanKind.GlobScoped, + Candidates = affected.Select(s => new SolutionCandidate + { + Solution = s, + NanoProjects = s.NanoProjects() + .Where(p => matchedSet.Contains(Path.GetFullPath(p))) + .ToList(), + }).ToList(), + }; + } + + // NO GLOB: 0 solutions => loose directory; >=1 => the user chooses. + if (solutions.Count == 0) + { + var all = ProjectScanner.ResolveProjects(dir, glob: null); + return new MigrationPlan { Kind = PlanKind.LooseDirectory, LooseProjects = all }; + } + + return new MigrationPlan + { + Kind = PlanKind.DirectoryWithSolutions, + Candidates = solutions + .Where(s => s.NanoProjects().Count > 0) // only solutions with .nfproj to convert + .Select(s => new SolutionCandidate { Solution = s, NanoProjects = s.NanoProjects() }) + .ToList(), + }; + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs b/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs index 9432506..a4e19dc 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs @@ -168,6 +168,9 @@ void SetProp(string k, string? v) var pc = Path.Combine(projDir, "packages.config"); if (File.Exists(pc)) result.DeletedFiles.Add(Path.GetFullPath(pc)); foreach (var ai in ExistingAssemblyInfo(projDir)) result.DeletedFiles.Add(ai); + // The .sln/.slnx that still reference the .nfproj are what a rewrite would + // touch. We always populate this preview list (even when SkipSolutionRewrite + // is set, so the host can render it); the actual rewrite below is gated. if (replacingNfproj) foreach (var sln in SolutionsReferencing(projDir, nfFull)) result.UpdatedSolutions.Add(sln); @@ -181,8 +184,9 @@ void SetProp(string k, string? v) if (replacingNfproj) { File.Delete(nfproj); - // Point any .sln entries at the new .csproj (idempotent). - UpdateSolutions(projDir, nfproj, outPath); + // Point any .sln/.slnx entries at the new .csproj (idempotent), + // unless the host is driving solution rewriting itself. + if (!o.SkipSolutionRewrite) UpdateSolutions(projDir, nfFull); } if (File.Exists(pc)) File.Delete(pc); // The SDK default **/*.cs glob plus generated assembly info would @@ -206,17 +210,19 @@ private static IEnumerable ExistingAssemblyInfo(string projDir) } } - // The .sln files that currently still reference the .nfproj (i.e. those - // UpdateSolutions would rewrite). Used to preview .sln edits in dry-run. + // The .sln/.slnx files that currently still reference the .nfproj (i.e. those + // UpdateSolutions would rewrite). Used to preview solution edits in dry-run. + // Resolves entries through the SolutionFile parser so both classic and XML + // solutions are considered and only genuine references count. private static IEnumerable SolutionsReferencing(string projDir, string nfproj) { - var nfName = Path.GetFileName(nfproj); + var target = Path.GetFullPath(nfproj); foreach (var sln in FindSolutionFiles(projDir)) { - string text; - try { text = File.ReadAllText(sln); } + SolutionFile parsed; + try { parsed = SolutionFile.Load(sln); } catch { continue; } - if (text.Contains(nfName, StringComparison.OrdinalIgnoreCase)) + if (parsed.ProjectPaths.Any(p => string.Equals(Path.GetFullPath(p), target, StringComparison.OrdinalIgnoreCase))) yield return sln; } } @@ -277,78 +283,21 @@ private static void DeleteAssemblyInfo(string projDir) } } - // Project-type GUIDs: legacy nanoFramework flavor -> SDK-style C#. - private const string NfprojTypeGuid = "{11A8DD76-328B-46DF-9F39-F559912D0360}"; - private const string CsprojTypeGuid = "{9A19103F-16F7-4668-BE54-9A1E7A4F7556}"; - - // Rewrites .sln entries that reference the converted .nfproj so they point at - // the new .csproj and use the SDK-style project-type GUID. Searches walking up - // from the project directory to the repo root (the dir containing .git), plus - // any .sln in the project's own directory tree. Only entries still pointing at - // the .nfproj are touched, so re-running is a no-op. - private static void UpdateSolutions(string projDir, string nfproj, string csproj) + // Rewrites .sln/.slnx entries that reference the converted .nfproj so they point + // at the new .csproj (classic .sln also swaps the project-type GUID). Searches + // walking up from the project directory to the repo root (the dir containing + // .git), plus any solution in the project's own directory tree. The actual + // retarget is delegated to SolutionRewriter, which is line/path-scoped and + // idempotent, so re-running is a no-op. + private static void UpdateSolutions(string projDir, string nfproj) { - var slns = FindSolutionFiles(projDir); - if (slns.Count == 0) return; - - var nfName = Path.GetFileName(nfproj); // e.g. Foo.nfproj - var csName = Path.GetFileName(csproj); // e.g. Foo.csproj - - foreach (var sln in slns) + var converted = new[] { Path.GetFullPath(nfproj) }; + foreach (var sln in FindSolutionFiles(projDir)) { - string text; - try { text = File.ReadAllText(sln); } + SolutionFile parsed; + try { parsed = SolutionFile.Load(sln); } catch { continue; } - var slnDir = Path.GetDirectoryName(Path.GetFullPath(sln))!; - - // The path stored in the .sln is relative to the .sln directory and is - // wrapped in double quotes. Match the *quoted* path so we rewrite the - // specific project entry rather than blanket-replacing a substring that - // might collide with another project's name. - var relPath = Path.GetRelativePath(slnDir, nfproj); - var candidates = new[] - { - relPath, // OS-native separators - relPath.Replace('/', '\\'), - relPath.Replace('\\', '/'), - nfName, // fallback: bare filename in the sln dir - }; - - // Rewrite line-by-line so the GUID swap stays scoped to *this* - // project's entry. A blanket GUID replace would wrongly flip other, - // still-unconverted nanoFramework projects in a shared solution. - var lines = text.Split('\n'); - var changed = false; - for (int li = 0; li < lines.Length; li++) - { - var line = lines[li]; - // Only the project's declaration line names its path. - if (!line.TrimStart().StartsWith("Project(", StringComparison.OrdinalIgnoreCase)) continue; - - var matched = false; - foreach (var path in candidates.Distinct(StringComparer.OrdinalIgnoreCase)) - { - var quoted = "\"" + path + "\""; - if (!line.Contains(quoted, StringComparison.OrdinalIgnoreCase)) continue; - var quotedReplacement = "\"" + path[..^nfName.Length] + csName + "\""; - line = ReplaceIgnoreCase(line, quoted, quotedReplacement); - matched = true; - } - if (!matched) continue; - - // Swap the legacy project-type GUID for the SDK-style one, but only - // on the line we just retargeted. - if (line.Contains(NfprojTypeGuid, StringComparison.OrdinalIgnoreCase)) - line = ReplaceIgnoreCase(line, NfprojTypeGuid, CsprojTypeGuid); - - lines[li] = line; - changed = true; - } - if (!changed) continue; - - var updated = string.Join('\n', lines); - if (!string.Equals(updated, text, StringComparison.Ordinal)) - File.WriteAllText(sln, updated, new UTF8Encoding(false)); + SolutionRewriter.RewriteFile(parsed, converted); } } @@ -356,40 +305,31 @@ private static List FindSolutionFiles(string projDir) { var found = new HashSet(StringComparer.OrdinalIgnoreCase); + void AddSolutionsIn(string d, SearchOption option) + { + foreach (var pattern in new[] { "*.sln", "*.slnx" }) + foreach (var sln in Directory.EnumerateFiles(d, pattern, option)) + found.Add(Path.GetFullPath(sln)); + } + // Walk up to the repo root (the directory containing .git), collecting - // .sln files at each level. + // solution files at each level. var dir = new DirectoryInfo(Path.GetFullPath(projDir)); while (dir is not null) { - foreach (var sln in Directory.EnumerateFiles(dir.FullName, "*.sln", SearchOption.TopDirectoryOnly)) - found.Add(Path.GetFullPath(sln)); + AddSolutionsIn(dir.FullName, SearchOption.TopDirectoryOnly); if (Directory.Exists(Path.Combine(dir.FullName, ".git")) || File.Exists(Path.Combine(dir.FullName, ".git"))) break; // reached the repo root dir = dir.Parent; } - // Also any .sln anywhere in the project's own directory tree. - foreach (var sln in Directory.EnumerateFiles(projDir, "*.sln", SearchOption.AllDirectories)) - found.Add(Path.GetFullPath(sln)); + // Also any solution anywhere in the project's own directory tree. + AddSolutionsIn(projDir, SearchOption.AllDirectories); return found.ToList(); } - private static string ReplaceIgnoreCase(string input, string search, string replacement) - { - var sb = new StringBuilder(); - int i = 0; - while (i < input.Length) - { - var idx = input.IndexOf(search, i, StringComparison.OrdinalIgnoreCase); - if (idx < 0) { sb.Append(input, i, input.Length - i); break; } - sb.Append(input, i, idx - i).Append(replacement); - i = idx + search.Length; - } - return sb.ToString(); - } - private static bool IsDefaultCompile(string inc) { var baseName = inc.TrimStart('.', '\\'); diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionDiscovery.cs b/tools/NanoMigrate/src/NanoMigrate.Core/SolutionDiscovery.cs new file mode 100644 index 0000000..4135787 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/SolutionDiscovery.cs @@ -0,0 +1,34 @@ +namespace NanoFramework.Migrate.Core; + +/// +/// Given a set of target .nfproj paths, finds which discovered solutions +/// reference any of them — by parsing each solution's project list and intersecting +/// it with the targets. Pure analysis over already-loaded solution models. +/// +public static class SolutionDiscovery +{ + /// + /// Returns the solutions (from ) that reference at + /// least one of . Comparison is by absolute + /// path, case-insensitive. Unrelated solutions are excluded. + /// + public static List Referencing( + IEnumerable solutions, + IEnumerable targetNfprojPaths) + { + var targets = new HashSet( + targetNfprojPaths.Select(Path.GetFullPath), StringComparer.OrdinalIgnoreCase); + if (targets.Count == 0) return new List(); + + return solutions + .Where(s => s.ProjectPaths.Any(p => targets.Contains(Path.GetFullPath(p)))) + .ToList(); + } + + /// + /// Loads every solution under and returns those that + /// reference at least one of . + /// + public static List FindReferencing(string dir, IEnumerable targetNfprojPaths) => + Referencing(SolutionScanner.Load(dir), targetNfprojPaths); +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionFile.cs b/tools/NanoMigrate/src/NanoMigrate.Core/SolutionFile.cs new file mode 100644 index 0000000..27a0bf9 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/SolutionFile.cs @@ -0,0 +1,115 @@ +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace NanoFramework.Migrate.Core; + +/// The on-disk format of a Visual Studio solution. +public enum SolutionFormat +{ + /// Classic line-based .sln (each project has a type GUID). + Classic, + + /// XML .slnx (path-based; no per-project type GUID). + Xml, +} + +/// +/// Parsed model of a Visual Studio solution — both the classic .sln line +/// format and the newer XML .slnx format. Exposes the referenced project +/// paths (absolute) and the format. Pure: parsing reads the file, nothing else. +/// +public sealed class SolutionFile +{ + // The project-declaration line of a classic .sln: + // Project("{type-guid}") = "Name", "rel\path.ext", "{project-guid}" + // We only need the *path* (the second quoted value). The capture is + // separator- and extension-agnostic; callers filter on .nfproj/.csproj. + private static readonly Regex ClassicProjectLine = new( + "^\\s*Project\\(\"\\{[^}]*\\}\"\\)\\s*=\\s*\"[^\"]*\"\\s*,\\s*\"(?[^\"]+)\"", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// Absolute path to the solution file. + public string Path { get; } + + /// Whether this is a classic .sln or an XML .slnx. + public SolutionFormat Format { get; } + + /// + /// Every project the solution references, as an absolute path. Solution folders + /// (which in a classic .sln are "projects" whose path equals the folder + /// name, with no separator and no project extension) are excluded. + /// + public IReadOnlyList ProjectPaths { get; } + + private SolutionFile(string path, SolutionFormat format, IReadOnlyList projectPaths) + { + Path = path; + Format = format; + ProjectPaths = projectPaths; + } + + /// True if the file extension denotes a solution (.sln or .slnx). + public static bool IsSolutionPath(string path) => + path.EndsWith(".sln", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".slnx", StringComparison.OrdinalIgnoreCase); + + /// Loads and parses the solution at . + public static SolutionFile Load(string solutionPath) + { + var full = System.IO.Path.GetFullPath(solutionPath); + var dir = System.IO.Path.GetDirectoryName(full)!; + var text = File.ReadAllText(full); + + return full.EndsWith(".slnx", StringComparison.OrdinalIgnoreCase) + ? new SolutionFile(full, SolutionFormat.Xml, ParseSlnx(text, dir)) + : new SolutionFile(full, SolutionFormat.Classic, ParseClassic(text, dir)); + } + + /// + /// The .nfproj projects referenced by the solution (absolute paths). + /// + public IReadOnlyList NanoProjects() => + ProjectPaths.Where(p => p.EndsWith(".nfproj", StringComparison.OrdinalIgnoreCase)).ToList(); + + // Classic .sln: scan project-declaration lines, resolve each quoted relative + // path against the solution directory. Skip solution folders (the path has no + // directory separator AND no recognised project extension — e.g. "src"). + private static List ParseClassic(string text, string slnDir) + { + var result = new List(); + foreach (var raw in text.Split('\n')) + { + var m = ClassicProjectLine.Match(raw); + if (!m.Success) continue; + var rel = m.Groups["path"].Value.Trim(); + if (IsSolutionFolderEntry(rel)) continue; + result.Add(System.IO.Path.GetFullPath(System.IO.Path.Combine(slnDir, rel))); + } + return result; + } + + // A classic .sln solution-folder entry uses the folder name as its "path" — no + // separators and no project file extension. Real project entries always carry + // a project extension (the path ends with .*proj). + private static bool IsSolutionFolderEntry(string rel) + { + var hasSeparator = rel.Contains('\\') || rel.Contains('/'); + var looksLikeProject = rel.EndsWith("proj", StringComparison.OrdinalIgnoreCase); + return !hasSeparator && !looksLikeProject; + } + + // .slnx: an XML document of nested / elements. + // We collect every regardless of nesting depth. + private static List ParseSlnx(string text, string slnDir) + { + var result = new List(); + var root = XElement.Parse(text); + foreach (var proj in root.DescendantsAndSelf().Where(e => e.Name.LocalName == "Project")) + { + var rel = (string?)proj.Attribute("Path"); + if (string.IsNullOrWhiteSpace(rel)) continue; + result.Add(System.IO.Path.GetFullPath(System.IO.Path.Combine(slnDir, rel.Trim()))); + } + return result; + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionRewriter.cs b/tools/NanoMigrate/src/NanoMigrate.Core/SolutionRewriter.cs new file mode 100644 index 0000000..0703fe4 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/SolutionRewriter.cs @@ -0,0 +1,190 @@ +using System.Text; + +namespace NanoFramework.Migrate.Core; + +/// +/// Retargets a solution's converted projects from .nfproj to .csproj. +/// Handles both the classic line-based .sln (path + project-type GUID) and +/// the XML .slnx (path only). Pure transform: works on +/// strings; wraps it with file I/O. +/// +/// Idempotent / reentrant: only entries still pointing at a .nfproj that is +/// in the converted set are touched, so a solution already pointing at the +/// .csproj is left byte-for-byte unchanged. +/// +public static class SolutionRewriter +{ + // Project-type GUIDs: legacy nanoFramework flavor -> SDK-style C#. + public const string NfprojTypeGuid = "{11A8DD76-328B-46DF-9F39-F559912D0360}"; + public const string CsprojTypeGuid = "{9A19103F-16F7-4668-BE54-9A1E7A4F7556}"; + + /// + /// Returns the rewritten text of , retargeting any + /// entry whose referenced .nfproj (resolved against ) + /// is in (absolute paths). The returned + /// string equals the input when nothing matched (no-op). + /// + public static string Rewrite( + string solutionText, + string solutionDir, + SolutionFormat format, + IEnumerable convertedNfprojPaths) + { + var converted = new HashSet( + convertedNfprojPaths.Select(Path.GetFullPath), StringComparer.OrdinalIgnoreCase); + if (converted.Count == 0) return solutionText; + + return format == SolutionFormat.Xml + ? RewriteSlnx(solutionText, solutionDir, converted) + : RewriteClassic(solutionText, solutionDir, converted); + } + + /// + /// Rewrites the solution file in place when at least one entry was retargeted. + /// Returns true if the file changed. A no-op leaves the file untouched (no write). + /// + public static bool RewriteFile(SolutionFile solution, IEnumerable convertedNfprojPaths) + { + string text; + try { text = File.ReadAllText(solution.Path); } + catch { return false; } + + var dir = Path.GetDirectoryName(solution.Path)!; + var updated = Rewrite(text, dir, solution.Format, convertedNfprojPaths); + if (string.Equals(updated, text, StringComparison.Ordinal)) return false; + + File.WriteAllText(solution.Path, updated, new UTF8Encoding(false)); + return true; + } + + // Classic .sln: line-scoped path + GUID swap. We rewrite line-by-line so the + // GUID swap stays scoped to the project's own declaration line — a blanket + // GUID replace would wrongly flip other, still-unconverted nanoFramework + // projects in a shared solution. + private static string RewriteClassic(string text, string slnDir, HashSet converted) + { + var lines = text.Split('\n'); + var changed = false; + for (int li = 0; li < lines.Length; li++) + { + var line = lines[li]; + if (!line.TrimStart().StartsWith("Project(", StringComparison.OrdinalIgnoreCase)) continue; + + // The .sln path is relative to the .sln dir and wrapped in quotes. Match + // the *quoted* path so we rewrite the specific entry rather than blanket- + // replacing a substring that might collide with another project's name. + var matched = false; + foreach (var nf in converted) + { + var rel = Path.GetRelativePath(slnDir, nf); + var nfName = Path.GetFileName(nf); + var csName = Path.ChangeExtension(nfName, ".csproj"); + + foreach (var candidate in QuotedCandidates(rel, nfName)) + { + if (!line.Contains(candidate, StringComparison.OrdinalIgnoreCase)) continue; + var replacement = "\"" + candidate[1..^(nfName.Length + 1)] + csName + "\""; + line = ReplaceIgnoreCase(line, candidate, replacement); + matched = true; + } + } + if (!matched) continue; + + // Swap the legacy project-type GUID for the SDK-style one, scoped to the + // line we just retargeted. + if (line.Contains(NfprojTypeGuid, StringComparison.OrdinalIgnoreCase)) + line = ReplaceIgnoreCase(line, NfprojTypeGuid, CsprojTypeGuid); + + lines[li] = line; + changed = true; + } + return changed ? string.Join('\n', lines) : text; + } + + // The quoted path forms a .sln might use for one project: relative path in + // OS-native and both separator styles, plus the bare filename fallback. Each is + // returned wrapped in the surrounding double quotes. + private static IEnumerable QuotedCandidates(string rel, string nfName) => + new[] + { + "\"" + rel + "\"", + "\"" + rel.Replace('/', '\\') + "\"", + "\"" + rel.Replace('\\', '/') + "\"", + "\"" + nfName + "\"", + }.Distinct(StringComparer.OrdinalIgnoreCase); + + // .slnx: path-based, no per-project GUID. Rewrite the Path attribute value of + // every whose resolved path is in the converted set. + // We edit the raw text (rather than re-serialising the XML) so formatting, + // attribute order, and comments are preserved. + private static string RewriteSlnx(string text, string slnDir, HashSet converted) + { + var sb = new StringBuilder(text.Length); + int i = 0; + var changed = false; + + while (i < text.Length) + { + // Find the next Path="..." (any attribute literally named Path). + var idx = IndexOfPathAttr(text, i); + if (idx < 0) { sb.Append(text, i, text.Length - i); break; } + + // idx points at "Path"; locate the opening quote after '='. + var eq = text.IndexOf('=', idx); + var openQuoteRel = eq < 0 ? -1 : text.IndexOf('"', eq); + if (openQuoteRel < 0) { sb.Append(text, i, idx + 4 - i); i = idx + 4; continue; } + var closeQuote = text.IndexOf('"', openQuoteRel + 1); + if (closeQuote < 0) { sb.Append(text, i, idx + 4 - i); i = idx + 4; continue; } + + var rel = text.Substring(openQuoteRel + 1, closeQuote - openQuoteRel - 1); + // Append everything up to and including the opening quote unchanged. + sb.Append(text, i, openQuoteRel + 1 - i); + + if (rel.EndsWith(".nfproj", StringComparison.OrdinalIgnoreCase)) + { + var abs = Path.GetFullPath(Path.Combine(slnDir, rel.Trim())); + if (converted.Contains(abs)) + { + rel = rel[..^".nfproj".Length] + ".csproj"; + changed = true; + } + } + sb.Append(rel); + sb.Append('"'); + i = closeQuote + 1; + } + + return changed ? sb.ToString() : text; + } + + // Finds the next occurrence of a literal Path attribute name (case-insensitive, + // word-bounded so it doesn't match e.g. "HintPath"). Returns the index of the + // 'P', or -1. + private static int IndexOfPathAttr(string text, int start) + { + int from = start; + while (true) + { + var idx = text.IndexOf("Path", from, StringComparison.OrdinalIgnoreCase); + if (idx < 0) return -1; + var prev = idx == 0 ? ' ' : text[idx - 1]; + // Reject when preceded by a name char (e.g. HintPath, FooPath). + if (!(char.IsLetterOrDigit(prev) || prev == '_')) return idx; + from = idx + 4; + } + } + + private static string ReplaceIgnoreCase(string input, string search, string replacement) + { + var sb = new StringBuilder(); + int i = 0; + while (i < input.Length) + { + var idx = input.IndexOf(search, i, StringComparison.OrdinalIgnoreCase); + if (idx < 0) { sb.Append(input, i, input.Length - i); break; } + sb.Append(input, i, idx - i).Append(replacement); + i = idx + search.Length; + } + return sb.ToString(); + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionScanner.cs b/tools/NanoMigrate/src/NanoMigrate.Core/SolutionScanner.cs new file mode 100644 index 0000000..10adc53 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/SolutionScanner.cs @@ -0,0 +1,40 @@ +namespace NanoFramework.Migrate.Core; + +/// +/// Discovers Visual Studio solution files (.sln and .slnx) beneath a +/// directory. Pure file-system discovery — no console, no side effects. +/// +public static class SolutionScanner +{ + /// + /// Finds every .sln and .slnx under + /// (recursively), returning absolute paths, sorted. A non-existent directory + /// yields an empty list. + /// + public static List Find(string dir) + { + if (!Directory.Exists(dir)) return new List(); + + var found = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var pattern in new[] { "*.sln", "*.slnx" }) + foreach (var sln in Directory.EnumerateFiles(dir, pattern, SearchOption.AllDirectories)) + found.Add(Path.GetFullPath(sln)); + + return found.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToList(); + } + + /// + /// Loads every solution under . Files that fail to parse + /// (malformed XML, unreadable) are skipped rather than aborting discovery. + /// + public static List Load(string dir) + { + var result = new List(); + foreach (var path in Find(dir)) + { + try { result.Add(SolutionFile.Load(path)); } + catch { /* skip a solution we cannot parse */ } + } + return result; + } +} diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionTests.cs new file mode 100644 index 0000000..d171916 --- /dev/null +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionTests.cs @@ -0,0 +1,355 @@ +using NanoFramework.Migrate.Core; +using Xunit; + +namespace NanoFramework.Migrate.Tests; + +public class SolutionTests +{ + private const string LegacyGuid = "{11A8DD76-328B-46DF-9F39-F559912D0360}"; + private const string SdkGuid = "{9A19103F-16F7-4668-BE54-9A1E7A4F7556}"; + private const string CsharpGuid = "{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}"; + private const string FolderGuid = "{2150E333-8FDC-42A3-9474-1A3956D46DE8}"; + + private const string Nfproj = """ + + Sample + + """; + + // A classic .sln referencing one .nfproj and one ordinary .csproj, plus a + // solution-folder entry (which must not be treated as a project). + private static string ClassicSln(string nfprojRel = "Foo.nfproj") => $$""" + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{{FolderGuid}}") = "src", "src", "{AAAAAAAA-0000-0000-0000-0000000000FF}" + EndProject + Project("{{LegacyGuid}}") = "Foo", "{{nfprojRel}}", "{AAAAAAAA-0000-0000-0000-000000000001}" + EndProject + Project("{{CsharpGuid}}") = "Bar", "Bar\Bar.csproj", "{BBBBBBBB-0000-0000-0000-000000000002}" + EndProject + Global + EndGlobal + """; + + // An .slnx referencing two .nfproj (one nested under a folder) and one .csproj. + private static string Slnx() => """ + + + + + + + + """; + + // ---- SolutionFile parsing ------------------------------------------------- + + [Fact] + public void Parse_classic_sln_yields_project_paths_and_format() + { + using var dir = new TempDir(); + var sln = dir.File("App.sln", ClassicSln()); + + var parsed = SolutionFile.Load(sln); + + Assert.Equal(SolutionFormat.Classic, parsed.Format); + // The solution folder ("src") is excluded; the two real projects remain. + Assert.Equal(2, parsed.ProjectPaths.Count); + Assert.Contains(parsed.ProjectPaths, p => p.EndsWith("Foo.nfproj")); + Assert.Contains(parsed.ProjectPaths, p => p.EndsWith("Bar.csproj")); + Assert.All(parsed.ProjectPaths, p => Assert.True(Path.IsPathFullyQualified(p))); + } + + [Fact] + public void Parse_slnx_yields_project_paths_and_format() + { + using var dir = new TempDir(); + var slnx = dir.File("App.slnx", Slnx()); + + var parsed = SolutionFile.Load(slnx); + + Assert.Equal(SolutionFormat.Xml, parsed.Format); + Assert.Equal(3, parsed.ProjectPaths.Count); + Assert.Contains(parsed.ProjectPaths, p => p.EndsWith("Foo.nfproj")); // nested under + Assert.Contains(parsed.ProjectPaths, p => p.EndsWith("Bar.nfproj")); + Assert.Contains(parsed.ProjectPaths, p => p.EndsWith("Baz.csproj")); + } + + [Fact] + public void NanoProjects_returns_only_nfproj() + { + using var dir = new TempDir(); + var classic = SolutionFile.Load(dir.File("A.sln", ClassicSln())); + var slnx = SolutionFile.Load(dir.File("B.slnx", Slnx())); + + Assert.Single(classic.NanoProjects()); + Assert.All(classic.NanoProjects(), p => Assert.EndsWith(".nfproj", p)); + + Assert.Equal(2, slnx.NanoProjects().Count); + Assert.All(slnx.NanoProjects(), p => Assert.EndsWith(".nfproj", p)); + } + + // ---- SolutionScanner ------------------------------------------------------ + + [Fact] + public void Scanner_finds_both_sln_and_slnx_recursively() + { + using var dir = new TempDir(); + dir.File("Top.sln", ClassicSln()); + dir.File(Path.Combine("nested", "Inner.slnx"), Slnx()); + dir.File("readme.txt", "not a solution"); + + var found = SolutionScanner.Find(dir.Path); + + Assert.Equal(2, found.Count); + Assert.Contains(found, p => p.EndsWith("Top.sln")); + Assert.Contains(found, p => p.EndsWith("Inner.slnx")); + } + + // ---- SolutionRewriter: classic .sln -------------------------------------- + + [Fact] + public void Rewriter_classic_retargets_path_and_guid_for_converted_only() + { + using var dir = new TempDir(); + var sln = dir.File("App.sln", ClassicSln()); + var converted = new[] { dir.Combine("Foo.nfproj") }; + + var changed = SolutionRewriter.RewriteFile(SolutionFile.Load(sln), converted); + var updated = File.ReadAllText(sln); + + Assert.True(changed); + Assert.Contains("\"Foo.csproj\"", updated); + Assert.DoesNotContain("Foo.nfproj", updated); + Assert.Contains(SdkGuid, updated); + Assert.DoesNotContain(LegacyGuid, updated); + // The unrelated Bar.csproj entry is untouched. + Assert.Contains("Bar\\Bar.csproj", updated); + } + + [Fact] + public void Rewriter_classic_is_a_noop_on_rerun() + { + using var dir = new TempDir(); + var sln = dir.File("App.sln", ClassicSln()); + var converted = new[] { dir.Combine("Foo.nfproj") }; + + SolutionRewriter.RewriteFile(SolutionFile.Load(sln), converted); + var afterFirst = File.ReadAllText(sln); + + // Second pass: the solution already points at Foo.csproj, so no change. + var changed = SolutionRewriter.RewriteFile(SolutionFile.Load(sln), converted); + + Assert.False(changed); + Assert.Equal(afterFirst, File.ReadAllText(sln)); + } + + [Fact] + public void Rewriter_classic_leaves_unconverted_nfproj_alone() + { + using var dir = new TempDir(); + // Two .nfproj in the same solution; only one is in the converted set. + var text = $$""" + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{{LegacyGuid}}") = "Foo", "Foo.nfproj", "{AAAAAAAA-0000-0000-0000-000000000001}" + EndProject + Project("{{LegacyGuid}}") = "Keep", "Keep.nfproj", "{AAAAAAAA-0000-0000-0000-000000000003}" + EndProject + Global + EndGlobal + """; + var sln = dir.File("App.sln", text); + + SolutionRewriter.RewriteFile(SolutionFile.Load(sln), new[] { dir.Combine("Foo.nfproj") }); + var updated = File.ReadAllText(sln); + + Assert.Contains("\"Foo.csproj\"", updated); + Assert.Contains("\"Keep.nfproj\"", updated); // untouched + Assert.Contains(SdkGuid, updated); // Foo flipped + Assert.Contains(LegacyGuid, updated); // Keep's legacy GUID survives + } + + // ---- SolutionRewriter: .slnx --------------------------------------------- + + [Fact] + public void Rewriter_slnx_retargets_path_only_for_converted_only() + { + using var dir = new TempDir(); + var slnx = dir.File("App.slnx", Slnx()); + // Convert only the nested Foo.nfproj; leave Bar.nfproj as-is. + var converted = new[] { dir.Combine(Path.Combine("Foo", "Foo.nfproj")) }; + + var changed = SolutionRewriter.RewriteFile(SolutionFile.Load(slnx), converted); + var updated = File.ReadAllText(slnx); + + Assert.True(changed); + Assert.Contains("Foo/Foo.csproj", updated); + Assert.DoesNotContain("Foo/Foo.nfproj", updated); + Assert.Contains("Bar/Bar.nfproj", updated); // not in the converted set + // .slnx is path-based: no project-type GUID is ever introduced. + Assert.DoesNotContain(SdkGuid, updated); + Assert.DoesNotContain(LegacyGuid, updated); + } + + [Fact] + public void Rewriter_slnx_is_a_noop_on_rerun() + { + using var dir = new TempDir(); + var slnx = dir.File("App.slnx", Slnx()); + var converted = new[] + { + dir.Combine(Path.Combine("Foo", "Foo.nfproj")), + dir.Combine(Path.Combine("Bar", "Bar.nfproj")), + }; + + SolutionRewriter.RewriteFile(SolutionFile.Load(slnx), converted); + var afterFirst = File.ReadAllText(slnx); + + var changed = SolutionRewriter.RewriteFile(SolutionFile.Load(slnx), converted); + + Assert.False(changed); + Assert.Equal(afterFirst, File.ReadAllText(slnx)); + } + + // ---- SolutionDiscovery ---------------------------------------------------- + + [Fact] + public void Discovery_returns_solutions_referencing_target_and_excludes_unrelated() + { + using var dir = new TempDir(); + // Solution A references Foo.nfproj; solution B references an unrelated project. + var aText = $$""" + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{{LegacyGuid}}") = "Foo", "Foo\Foo.nfproj", "{AAAAAAAA-0000-0000-0000-000000000001}" + EndProject + Global + EndGlobal + """; + var bText = $$""" + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{{LegacyGuid}}") = "Other", "Other\Other.nfproj", "{AAAAAAAA-0000-0000-0000-000000000002}" + EndProject + Global + EndGlobal + """; + dir.File("A.sln", aText); + dir.File("B.sln", bText); + dir.File(Path.Combine("Foo", "Foo.nfproj"), Nfproj); + + var target = dir.Combine(Path.Combine("Foo", "Foo.nfproj")); + var matches = SolutionDiscovery.FindReferencing(dir.Path, new[] { target }); + + Assert.Single(matches); + Assert.EndsWith("A.sln", matches[0].Path); + } + + // ---- MigrationPlanner ----------------------------------------------------- + + [Fact] + public void Planner_solution_input_yields_that_solutions_projects() + { + using var dir = new TempDir(); + var slnx = dir.File("App.slnx", Slnx()); + dir.File(Path.Combine("Foo", "Foo.nfproj"), Nfproj); + dir.File(Path.Combine("Bar", "Bar.nfproj"), Nfproj); + + var plan = MigrationPlanner.Plan(slnx, explicitSolution: null, glob: null); + + Assert.Equal(PlanKind.ExplicitSolution, plan.Kind); + Assert.False(plan.RequiresSelection); + var projects = MigrationPlan.ProjectsOf(plan.Candidates); + Assert.Equal(2, projects.Count); + Assert.All(projects, p => Assert.EndsWith(".nfproj", p)); + } + + [Fact] + public void Planner_explicit_solution_option_overrides_directory() + { + using var dir = new TempDir(); + var sln = dir.File("App.sln", ClassicSln("Foo\\Foo.nfproj")); + dir.File(Path.Combine("Foo", "Foo.nfproj"), Nfproj); + + var plan = MigrationPlanner.Plan(dir.Path, explicitSolution: sln, glob: null); + + Assert.Equal(PlanKind.ExplicitSolution, plan.Kind); + Assert.Single(plan.Candidates); + Assert.EndsWith("App.sln", plan.Candidates[0].Solution.Path); + } + + [Fact] + public void Planner_directory_with_solutions_lists_candidates() + { + using var dir = new TempDir(); + dir.File("One.sln", ClassicSln("Foo\\Foo.nfproj")); + dir.File(Path.Combine("sub", "Two.slnx"), """ + + """); + dir.File(Path.Combine("Foo", "Foo.nfproj"), Nfproj); + dir.File(Path.Combine("sub", "Quux", "Quux.nfproj"), Nfproj); + + var plan = MigrationPlanner.Plan(dir.Path, explicitSolution: null, glob: null); + + Assert.Equal(PlanKind.DirectoryWithSolutions, plan.Kind); + Assert.True(plan.RequiresSelection); + Assert.Equal(2, plan.Candidates.Count); + } + + [Fact] + public void Planner_directory_without_solutions_falls_back_to_loose() + { + using var dir = new TempDir(); + dir.File(Path.Combine("a", "One.nfproj"), Nfproj); + dir.File(Path.Combine("b", "Two.nfproj"), Nfproj); + + var plan = MigrationPlanner.Plan(dir.Path, explicitSolution: null, glob: null); + + Assert.Equal(PlanKind.LooseDirectory, plan.Kind); + Assert.False(plan.RequiresSelection); + Assert.Equal(2, plan.LooseProjects.Count); + Assert.Empty(plan.Candidates); + } + + [Fact] + public void Planner_glob_yields_only_affected_solutions() + { + using var dir = new TempDir(); + // A.sln references Foo.nfproj (matched); B.sln references Bar.nfproj (not). + dir.File("A.sln", $$""" + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{{LegacyGuid}}") = "Foo", "Foo\Foo.nfproj", "{AAAAAAAA-0000-0000-0000-000000000001}" + EndProject + Global + EndGlobal + """); + dir.File("B.sln", $$""" + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{{LegacyGuid}}") = "Bar", "Bar\Bar.nfproj", "{AAAAAAAA-0000-0000-0000-000000000002}" + EndProject + Global + EndGlobal + """); + dir.File(Path.Combine("Foo", "Foo.nfproj"), Nfproj); + dir.File(Path.Combine("Bar", "Bar.nfproj"), Nfproj); + + var plan = MigrationPlanner.Plan(dir.Path, explicitSolution: null, glob: "Foo/**"); + + Assert.Equal(PlanKind.GlobScoped, plan.Kind); + Assert.True(plan.RequiresSelection); + Assert.Single(plan.Candidates); + Assert.EndsWith("A.sln", plan.Candidates[0].Solution.Path); + Assert.Single(plan.Candidates[0].NanoProjects); + Assert.EndsWith("Foo.nfproj", plan.Candidates[0].NanoProjects[0]); + } + + [Fact] + public void Planner_single_nfproj_is_not_solution_scoped() + { + using var dir = new TempDir(); + var nfproj = dir.File("Solo.nfproj", Nfproj); + + var plan = MigrationPlanner.Plan(nfproj, explicitSolution: null, glob: null); + + Assert.Equal(PlanKind.SingleProject, plan.Kind); + Assert.False(plan.RequiresSelection); + Assert.Single(plan.LooseProjects); + } +} From b4de030528b17c71d8f91d3b78aa0e32e884b1ff Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Mon, 15 Jun 2026 23:22:01 -0500 Subject: [PATCH 10/49] skill: document solution-aware migration (.sln/.slnx input, selection) Co-Authored-By: Claude Opus 4.8 --- skills/nanoframework-sdk-migration/SKILL.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/skills/nanoframework-sdk-migration/SKILL.md b/skills/nanoframework-sdk-migration/SKILL.md index 62a0e39..690d5fe 100644 --- a/skills/nanoframework-sdk-migration/SKILL.md +++ b/skills/nanoframework-sdk-migration/SKILL.md @@ -48,6 +48,19 @@ in [references/contributing-compliance.md](references/contributing-compliance.md 4. **Re-run is safe** — reentrant. A partially-converted tree converts only the remaining `.nfproj` and leaves existing `.csproj` untouched. +## Solutions (.sln / .slnx) + +Migration is solution-aware and keeps solutions in sync: + +- **Pass a solution** (`migrate .sln` or `.slnx`) to convert only the projects it references + and retarget the solution itself (classic `.sln`: project-type GUID + `.nfproj`→`.csproj`; + `.slnx`: the `Path`). +- **Pass a directory**: if it contains solutions you are asked to pick which one(s) to migrate + (all / none / several); if it contains none, every `.nfproj` under it is converted (loose mode). +- **With `--glob`**: the tool finds the matching projects, discovers the solutions that reference + them, and (after you confirm / select) updates only those solutions. +- `--solution ` forces a single target; `--yes` (or non-interactive) selects all affected. + ## What the tool does per project (Full detail in [references/migration-rules.md](references/migration-rules.md).) From ce08e9c90e9c7a167ffff17dbc7c8b11134a1c8a Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Mon, 15 Jun 2026 23:37:33 -0500 Subject: [PATCH 11/49] NanoMigrate: packages.config-first resolution + Central Package Management support Two converter improvements; the real Samples dry-run goes from 55 review-flags to 0. - packages.config is now the authoritative PackageReference source: when present, emit one PackageReference per (id+version verbatim) and drop the legacy assembly elements. This fixes the assembly-name vs package-id mismatch (e.g. assembly System.Net.Http is shipped by package nanoFramework.System.Net.Http.Server; mscorlib by nanoFramework.CoreLibrary). The +HintPath path remains a fallback only when there is no packages.config. - Central Package Management: detect a Directory.Packages.props (ManagePackageVersionsCentrally) above the project; when active, emit versionless PackageReference and seed missing entries into the nearest central props (idempotent). Never crashes on already-central / versionless inputs. - Carry through nanoFramework unit-test markers (ProjectCapability TestContainer, IsTestProject, TestProjectType, RunSettingsFilePath) so migrated test projects stay test projects. Tests: +11 (55 total, all green). Dry-run over the real Samples repo: 152 convert, 0 flagged. Co-Authored-By: Claude Opus 4.8 --- .../src/NanoMigrate.Core/ConversionOptions.cs | 12 ++ .../src/NanoMigrate.Core/ConvertResult.cs | 13 ++ .../src/NanoMigrate.Core/ProjectConverter.cs | 195 ++++++++++++++++- .../tests/NanoMigrate.Tests/CpmTests.cs | 199 ++++++++++++++++++ .../PackageResolutionTests.cs | 164 +++++++++++++++ 5 files changed, 580 insertions(+), 3 deletions(-) create mode 100644 tools/NanoMigrate/tests/NanoMigrate.Tests/CpmTests.cs create mode 100644 tools/NanoMigrate/tests/NanoMigrate.Tests/PackageResolutionTests.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs b/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs index 4e4001e..84b4041 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs @@ -45,4 +45,16 @@ public sealed record ConversionOptions /// list is still populated. Default false preserves the historical behavior. /// public bool SkipSolutionRewrite { get; init; } + + /// + /// Central Package Management (CPM) override. null (the default) means + /// auto-detect: the converter walks up from the project directory for a + /// Directory.Packages.props with + /// <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>. + /// Set true/false to force CPM on/off regardless of what is on disk. + /// When CPM is active the emitted PackageReference elements are versionless + /// and each referenced id is ensured to have a PackageVersion entry in the + /// nearest Directory.Packages.props. + /// + public bool? CentralPackageManagement { get; init; } } diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/ConvertResult.cs b/tools/NanoMigrate/src/NanoMigrate.Core/ConvertResult.cs index 99fdb1a..c0a6b4f 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/ConvertResult.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/ConvertResult.cs @@ -40,6 +40,19 @@ public sealed class ConvertResult /// .sln files this conversion retargets (or, in dry-run, would retarget). public List UpdatedSolutions { get; } = new(); + /// + /// Under Central Package Management, the Directory.Packages.props this + /// conversion adds PackageVersion entries to (or, in dry-run, would add). + /// Null when CPM is inactive or no entries are missing. + /// + public string? UpdatedPackagesProps { get; set; } + + /// + /// The PackageVersion entries (id -> version) added to the central + /// Directory.Packages.props under CPM (or, in dry-run, that would be added). + /// + public List> AddedPackageVersions { get; } = new(); + /// Set when the conversion threw; used to render a red Error row. public string? Error { get; set; } diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs b/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs index a4e19dc..67c3f20 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs @@ -29,6 +29,9 @@ public sealed class ProjectConverter : IProjectConverter { "RootNamespace", "AssemblyName", "DocumentationFile", "DefineConstants", "LangVersion", "Description", "Authors", "PackageTags", "Copyright", + // nanoFramework unit-test projects: these keep the migrated project a test + // project (the SDK does not infer them). + "IsTestProject", "TestProjectType", "RunSettingsFilePath", }; // Legacy names whose NuGet package id differs from X. @@ -68,6 +71,20 @@ public ConvertResult Convert(string nfprojPath, ConversionOptions options) } var pkgs = LoadPackagesConfig(projDir); + // packages.config — when present and non-empty — IS the authoritative + // dependency list. In that mode legacy assembly names are NOT + // mapped to packages (their names routinely differ from the NuGet package + // id, e.g. assembly "System.Net.Http" ships in package + // "nanoFramework.System.Net.Http.Server"); the packages.config ids+versions + // are taken verbatim instead, eliminating the name/id mismatch. + var packagesConfigAuthoritative = pkgs.Count > 0; + + // Detect Central Package Management: an explicit option override wins; + // otherwise walk up for a Directory.Packages.props with + // ManagePackageVersionsCentrally=true. + var cpmPropsPath = FindDirectoryPackagesProps(projDir); + var cpmActive = o.CentralPackageManagement + ?? (cpmPropsPath is not null && IsCpmEnabled(cpmPropsPath)); var props = new List>(); // discovery order, deduped var pkgRefs = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -75,6 +92,13 @@ public ConvertResult Convert(string nfprojPath, ConversionOptions options) var keepItems = new List(); var review = new List(); + // Seed package references straight from packages.config when it is the + // authoritative source. Drop the legacy elements entirely in + // this mode (handled in the item loop below). + if (packagesConfigAuthoritative) + foreach (var kv in pkgs) + pkgRefs[kv.Key] = kv.Value; + void SetProp(string k, string? v) { if (string.IsNullOrEmpty(v)) return; @@ -101,6 +125,11 @@ void SetProp(string k, string? v) { case "Reference": { + // packages.config is authoritative: it already carries the + // full dependency set, so legacy assembly names + // are dropped rather than (mis)mapped to a package id. + if (packagesConfigAuthoritative) break; + var rawName = inc.Split(',')[0].Trim(); // Prefer id+version parsed straight from the HintPath folder. var fromHint = InferFromHintPath(el); @@ -121,8 +150,13 @@ void SetProp(string k, string? v) } case "PackageReference": { + // A versionless is valid under CPM; carry + // it through (its version, if any, comes from the central + // props). Otherwise resolve a version from the attribute or + // packages.config; only flag when truly unresolvable. var ver = (string?)el.Attribute("Version") ?? pkgs.GetValueOrDefault(inc); if (ver is not null) pkgRefs[inc] = ver; + else if (cpmActive) pkgRefs.TryAdd(inc, ""); // version lives in central props else review.Add($"PackageReference without resolvable version: {inc} " + "(add a Version manually)"); @@ -143,6 +177,12 @@ void SetProp(string k, string? v) case "Content": keepItems.Add(el); break; + case "ProjectCapability": + // nanoFramework test projects mark themselves with + // ; this is a + // meaningful item the SDK does not supply, so carry it through. + keepItems.Add(el); + break; default: review.Add($"Unhandled item <{tag} Include='{inc}'>"); break; @@ -151,7 +191,7 @@ void SetProp(string k, string? v) FoldNuspec(projDir, SetProp); - var xml = Emit(props, pkgRefs, projRefs, keepItems, o); + var xml = Emit(props, pkgRefs, projRefs, keepItems, o, cpmActive); var outPath = Path.ChangeExtension(Path.GetFullPath(nfproj), o.Ext); var nfFull = Path.GetFullPath(nfproj); @@ -161,6 +201,23 @@ void SetProp(string k, string? v) result.Review.AddRange(review); result.Packages.AddRange(pkgRefs.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)); + // Under CPM, each referenced id must have a in the nearest + // Directory.Packages.props. Compute the ids whose version we know (from + // packages.config / HintPath) that are missing from the central props, and + // record them; the real run adds them idempotently below. Versionless ids + // (no resolvable version) cannot seed a PackageVersion and are left as-is. + var cpmAdditions = new List>(); + if (cpmActive && cpmPropsPath is not null) + { + var existing = LoadPackageVersionIds(cpmPropsPath); + foreach (var kv in pkgRefs.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(kv.Value) && !existing.Contains(kv.Key)) + cpmAdditions.Add(kv); + foreach (var add in cpmAdditions) + result.AddedPackageVersions.Add(add); + if (cpmAdditions.Count > 0) result.UpdatedPackagesProps = cpmPropsPath; + } + // Compute the set of files that will be (or, in dry-run, would be) removed // and the solutions that will be retargeted. This drives the dry-run // preview and is identical to what the real run acts on. @@ -180,6 +237,10 @@ void SetProp(string k, string? v) // survive reruns. if (!o.NoBackup && !File.Exists(nfproj + ".bak")) File.Copy(nfproj, nfproj + ".bak", overwrite: false); File.WriteAllText(outPath, xml, new UTF8Encoding(false)); + // Add any missing entries to the central props, + // idempotently (existing entries are neither duplicated nor reordered). + if (cpmActive && cpmPropsPath is not null && cpmAdditions.Count > 0) + AddPackageVersions(cpmPropsPath, cpmAdditions); // If we emitted a .csproj alongside, retire the original .nfproj. if (replacingNfproj) { @@ -271,6 +332,129 @@ private static Dictionary LoadPackagesConfig(string projDir) return result; } + // Walks UP from the project directory looking for the nearest + // Directory.Packages.props (the file MSBuild itself would pick for CPM). Returns + // its full path, or null if none exists in the ancestor chain. Pure: read-only. + private static string? FindDirectoryPackagesProps(string projDir) + { + var dir = new DirectoryInfo(Path.GetFullPath(projDir)); + while (dir is not null) + { + var candidate = Path.Combine(dir.FullName, "Directory.Packages.props"); + if (File.Exists(candidate)) return candidate; + dir = dir.Parent; + } + return null; + } + + // True when a Directory.Packages.props opts into CPM via + // true. + // A malformed/unreadable file is treated as "not enabled" (never throws). + private static bool IsCpmEnabled(string propsPath) + { + try + { + var root = XElement.Load(propsPath); + var flag = root.Descendants() + .FirstOrDefault(e => e.Name.LocalName == "ManagePackageVersionsCentrally"); + return flag is not null + && bool.TryParse(flag.Value.Trim(), out var on) && on; + } + catch { return false; } + } + + // The set of package ids already declared as in a + // Directory.Packages.props. Used to avoid duplicating entries on add/re-run. + private static HashSet LoadPackageVersionIds(string propsPath) + { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + foreach (var pv in XElement.Load(propsPath).Descendants() + .Where(e => e.Name.LocalName == "PackageVersion")) + { + var id = (string?)pv.Attribute("Include"); + if (!string.IsNullOrEmpty(id)) ids.Add(id); + } + } + catch { /* unreadable props → treat as having no entries */ } + return ids; + } + + // Idempotently inserts entries for + // the given (id, version) pairs into the nearest ItemGroup of a + // Directory.Packages.props. Existing entries are neither duplicated nor + // reordered; only genuinely-missing ids are appended. Preserves the file's + // namespace and overall shape. Caller has already filtered to missing ids. + private static void AddPackageVersions(string propsPath, IEnumerable> additions) + { + var toAdd = additions.ToList(); + if (toAdd.Count == 0) return; + + var doc = System.Xml.Linq.XDocument.Load(propsPath, System.Xml.Linq.LoadOptions.PreserveWhitespace); + var root = doc.Root!; + var ns = root.Name.Namespace; + var existing = root.Descendants().Where(e => e.Name.LocalName == "PackageVersion") + .Select(e => (string?)e.Attribute("Include")) + .Where(s => !string.IsNullOrEmpty(s)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Reuse the ItemGroup that already holds PackageVersion items; otherwise the + // first ItemGroup; otherwise create a fresh one. + var group = root.Elements().FirstOrDefault(e => e.Name.LocalName == "ItemGroup" + && e.Elements().Any(c => c.Name.LocalName == "PackageVersion")) + ?? root.Elements().FirstOrDefault(e => e.Name.LocalName == "ItemGroup"); + if (group is null) + { + group = new XElement(ns + "ItemGroup"); + root.Add(group); + } + + // Match the indentation of an existing PackageVersion so appended entries + // line up; fall back to a sensible default for a freshly-created group. + var indent = ElementIndent(group.Elements() + .FirstOrDefault(c => c.Name.LocalName == "PackageVersion")) ?? " "; + + // The last PackageVersion to append after (so new entries sit with the rest, + // before the ItemGroup's closing tag rather than after its trailing newline). + var anchor = group.Elements().LastOrDefault(c => c.Name.LocalName == "PackageVersion"); + + foreach (var kv in toAdd) + { + if (existing.Contains(kv.Key)) continue; // idempotent guard + var pv = new XElement(ns + "PackageVersion", + new XAttribute("Include", kv.Key), + new XAttribute("Version", kv.Value)); + if (anchor is not null) + { + anchor.AddAfterSelf(pv); + anchor.AddAfterSelf(new System.Xml.Linq.XText("\n" + indent)); + } + else + { + group.AddFirst(pv); + group.AddFirst(new System.Xml.Linq.XText("\n" + indent)); + } + anchor = pv; + existing.Add(kv.Key); + } + + doc.Save(propsPath); + } + + // The leading-whitespace indentation of an element, read from the immediately + // preceding whitespace text node. Null if there is no such node (or no element). + private static string? ElementIndent(XElement? el) + { + if (el?.PreviousNode is System.Xml.Linq.XText t) + { + var s = t.Value; + var nl = s.LastIndexOf('\n'); + return nl >= 0 ? s[(nl + 1)..] : s; + } + return null; + } + // Removes a hand-written AssemblyInfo.cs from disk. With the SDK's default // **/*.cs glob and GenerateAssemblyInfo, leaving it would cause duplicate // assembly-attribute build errors. Idempotent: a missing file is a no-op. @@ -362,7 +546,8 @@ private static string Emit( Dictionary pkgRefs, List projRefs, List keepItems, - ConversionOptions o) + ConversionOptions o, + bool cpmActive) { var sb = new StringBuilder(); // Versionless SDK reference; the version is pinned via global.json msbuild-sdks. @@ -377,7 +562,11 @@ private static string Emit( { sb.Append(" \n"); foreach (var kv in pkgRefs.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) - sb.Append($" \n"); + // Under CPM the version belongs in the central props, not here — a + // Version attribute would trip NU1008. Otherwise pin it inline. + sb.Append(cpmActive + ? $" \n" + : $" \n"); sb.Append(" \n\n"); } if (projRefs.Count > 0) diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/CpmTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/CpmTests.cs new file mode 100644 index 0000000..428c4ac --- /dev/null +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/CpmTests.cs @@ -0,0 +1,199 @@ +using NanoFramework.Migrate.Core; +using Xunit; + +namespace NanoFramework.Migrate.Tests; + +/// +/// Central Package Management (CPM) behavior: versionless PackageReferences in the +/// project, central PackageVersion entries in Directory.Packages.props, idempotency, +/// and no-crash on already-central inputs. +/// +public class CpmTests +{ + private static readonly IProjectConverter Converter = new ProjectConverter(); + + private const string CpmPropsEnabled = """ + + + true + + + + + """; + + private const string LegacyNfproj = """ + + Sample + + + + + + """; + + private const string LegacyPackagesConfig = """ + + + + + + """; + + [Fact] + public void Cpm_emits_versionless_PackageReferences_and_seeds_central_props() + { + using var dir = new TempDir(); + var props = dir.File("Directory.Packages.props", CpmPropsEnabled); + dir.File(Path.Combine("Sample", "packages.config"), LegacyPackagesConfig); + var nfproj = dir.File(Path.Combine("Sample", "Sample.nfproj"), LegacyNfproj); + + var result = Converter.Convert(nfproj, new ConversionOptions()); + + Assert.Equal(ConvertStatus.Converted, result.Status); + Assert.Empty(result.Review); + + // Project: versionless references, never with a Version attribute. + var emitted = File.ReadAllText(dir.Combine(Path.Combine("Sample", "Sample.csproj"))); + Assert.Contains("", emitted); + Assert.Contains("", emitted); + Assert.DoesNotContain("Version=", emitted); // no version on any PackageReference + + // Central props: gained a PackageVersion for each referenced id. + var propsXml = File.ReadAllText(props); + Assert.Contains("", propsXml); + Assert.Contains("", propsXml); + + // Result preview reflects the central-props edit. + Assert.Equal(Path.GetFullPath(props), result.UpdatedPackagesProps); + Assert.Equal(2, result.AddedPackageVersions.Count); + } + + [Fact] + public void Cpm_second_project_with_same_ids_adds_no_duplicate_PackageVersion() + { + using var dir = new TempDir(); + var props = dir.File("Directory.Packages.props", CpmPropsEnabled); + + // Project A seeds the central props. + dir.File(Path.Combine("A", "packages.config"), LegacyPackagesConfig); + var a = dir.File(Path.Combine("A", "A.nfproj"), LegacyNfproj); + var resultA = Converter.Convert(a, new ConversionOptions()); + Assert.Equal(2, resultA.AddedPackageVersions.Count); + var propsAfterA = File.ReadAllText(props); + + // Project B references the same ids; the props already has them, so nothing + // is added and the file is byte-for-byte unchanged (idempotent). + dir.File(Path.Combine("B", "packages.config"), LegacyPackagesConfig); + var b = dir.File(Path.Combine("B", "B.nfproj"), LegacyNfproj); + var resultB = Converter.Convert(b, new ConversionOptions()); + + Assert.Empty(resultB.AddedPackageVersions); + Assert.Null(resultB.UpdatedPackagesProps); + var propsAfterB = File.ReadAllText(props); + Assert.Equal(propsAfterA, propsAfterB); // central props untouched the second time + + // Exactly one PackageVersion per id (no duplicates). + Assert.Equal(1, CountOccurrences(propsAfterB, "Include=\"nanoFramework.CoreLibrary\"")); + Assert.Equal(1, CountOccurrences(propsAfterB, "Include=\"nanoFramework.System.Device.Gpio\"")); + } + + [Fact] + public void Cpm_does_not_duplicate_a_preexisting_PackageVersion() + { + using var dir = new TempDir(); + // Central props already pins one of the two ids. + var props = dir.File("Directory.Packages.props", """ + + + true + + + + + + """); + dir.File(Path.Combine("Sample", "packages.config"), LegacyPackagesConfig); + var nfproj = dir.File(Path.Combine("Sample", "Sample.nfproj"), LegacyNfproj); + + var result = Converter.Convert(nfproj, new ConversionOptions()); + + var propsXml = File.ReadAllText(props); + // The pre-existing entry appears exactly once (not duplicated). + Assert.Equal(1, CountOccurrences(propsXml, "Include=\"nanoFramework.CoreLibrary\"")); + // The genuinely-missing id was added. + Assert.Contains("", propsXml); + // Only the missing id is recorded as an addition. + Assert.Single(result.AddedPackageVersions); + Assert.Equal("nanoFramework.System.Device.Gpio", result.AddedPackageVersions[0].Key); + } + + [Fact] + public void Cpm_disabled_props_does_not_trigger_versionless_emission() + { + using var dir = new TempDir(); + // A Directory.Packages.props that does NOT enable CPM. + dir.File("Directory.Packages.props", """ + + + false + + + """); + dir.File(Path.Combine("Sample", "packages.config"), LegacyPackagesConfig); + var nfproj = dir.File(Path.Combine("Sample", "Sample.nfproj"), LegacyNfproj); + + Converter.Convert(nfproj, new ConversionOptions()); + + var emitted = File.ReadAllText(dir.Combine(Path.Combine("Sample", "Sample.csproj"))); + // Versions are pinned inline because CPM is not active. + Assert.Contains("", emitted); + } + + [Fact] + public void Cpm_does_not_crash_when_input_already_under_central_props() + { + using var dir = new TempDir(); + dir.File("Directory.Packages.props", CpmPropsEnabled); + // An already-SDK-style project sitting under the central props: it is skipped + // by the Sdk-attribute guard and must not throw. + var csproj = dir.File(Path.Combine("Sample", "Sample.csproj"), """ + + netnano1.0 + + + + + """); + + var ex = Record.Exception(() => Converter.Convert(csproj, new ConversionOptions())); + Assert.Null(ex); + var result = Converter.Convert(csproj, new ConversionOptions()); + Assert.Equal(ConvertStatus.Skipped, result.Status); + } + + [Fact] + public void Cpm_option_override_forces_versionless_without_props_on_disk() + { + using var dir = new TempDir(); + // No Directory.Packages.props at all, but the option forces CPM on. + dir.File("packages.config", LegacyPackagesConfig); + var nfproj = dir.File("Sample.nfproj", LegacyNfproj); + + var result = Converter.Convert(nfproj, + new ConversionOptions { DryRun = true, CentralPackageManagement = true }); + + // Dry run writes nothing; assert via the result data. + Assert.Empty(result.Review); + Assert.Contains(result.Packages, p => p.Key == "nanoFramework.CoreLibrary"); + // No central props on disk → nothing to update, but it must not crash. + Assert.Null(result.UpdatedPackagesProps); + } + + private static int CountOccurrences(string haystack, string needle) + { + int count = 0, i = 0; + while ((i = haystack.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) { count++; i += needle.Length; } + return count; + } +} diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/PackageResolutionTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/PackageResolutionTests.cs new file mode 100644 index 0000000..19e1d88 --- /dev/null +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/PackageResolutionTests.cs @@ -0,0 +1,164 @@ +using NanoFramework.Migrate.Core; +using Xunit; + +namespace NanoFramework.Migrate.Tests; + +/// +/// Tests for the packages.config-first resolution strategy and Central Package +/// Management (CPM) support added to . +/// +public class PackageResolutionTests +{ + private static readonly IProjectConverter Converter = new ProjectConverter(); + + // A WiFiAP-like project whose elements are bare assembly names that + // routinely differ from the NuGet package id, while packages.config carries the + // real nano package ids+versions. + private const string WiFiApLikeNfproj = """ + + + WifiAP + WifiAP + + + + + + + + + """; + + private const string WiFiApLikePackagesConfig = """ + + + + + + + + """; + + [Fact] + public void PackagesConfig_is_authoritative_emits_exactly_its_ids_and_versions() + { + using var dir = new TempDir(); + dir.File("packages.config", WiFiApLikePackagesConfig); + var nfproj = dir.File("WifiAP.nfproj", WiFiApLikeNfproj); + + var result = Converter.Convert(nfproj, new ConversionOptions { DryRun = true }); + + Assert.Equal(ConvertStatus.Converted, result.Status); + Assert.Empty(result.Review); // no assembly/id mismatch flags + + // The emitted package set is exactly the packages.config entries, verbatim. + var expected = new Dictionary + { + ["nanoFramework.System.Device.Gpio"] = "1.1.57", + ["nanoFramework.System.Net.Http.Server"] = "1.5.207", + ["nanoFramework.System.Threading"] = "1.1.52", + ["nanoFramework.CoreLibrary"] = "1.17.11", + }; + Assert.Equal(expected.Count, result.Packages.Count); + foreach (var kv in expected) + Assert.Contains(result.Packages, p => p.Key == kv.Key && p.Value == kv.Value); + + // The bare assembly names from never become packages. + Assert.DoesNotContain(result.Packages, p => p.Key == "System.Device.Gpio"); + Assert.DoesNotContain(result.Packages, p => p.Key == "System.Net.Http"); + Assert.DoesNotContain(result.Packages, p => p.Key == "System.Threading"); + Assert.DoesNotContain(result.Packages, p => p.Key == "mscorlib"); + } + + [Fact] + public void PackagesConfig_first_emits_no_Reference_elements() + { + using var dir = new TempDir(); + dir.File("packages.config", WiFiApLikePackagesConfig); + var nfproj = dir.File("WifiAP.nfproj", WiFiApLikeNfproj); + + Converter.Convert(nfproj, new ConversionOptions()); // real run + + var emitted = File.ReadAllText(dir.Combine("WifiAP.csproj")); + Assert.DoesNotContain(" + Sample + + + ..\packages\nanoFramework.System.Device.Gpio.1.1.57\lib\System.Device.Gpio.dll + + + + """); + + var result = Converter.Convert(nfproj, new ConversionOptions { DryRun = true }); + + Assert.Empty(result.Review); + Assert.Contains(result.Packages, + p => p.Key == "nanoFramework.System.Device.Gpio" && p.Value == "1.1.57"); + } + + [Fact] + public void No_packages_config_unresolved_reference_is_flagged() + { + using var dir = new TempDir(); + // No packages.config and no HintPath: unresolvable → review, never blank version. + var nfproj = dir.File("Sample.nfproj", """ + + Sample + + + + + """); + + var result = Converter.Convert(nfproj, new ConversionOptions()); + + Assert.Equal(ConvertStatus.Review, result.Status); + Assert.Contains(result.Review, r => r.Contains("MysteryLib")); + var emitted = File.ReadAllText(dir.Combine("Sample.csproj")); + Assert.DoesNotContain("Version=\"\"", emitted); + } + + [Fact] + public void ProjectCapability_TestContainer_is_preserved_not_flagged() + { + using var dir = new TempDir(); + dir.File("packages.config", """ + + + + + + """); + var nfproj = dir.File("NFUnitTest.nfproj", """ + + + + + + NFUnitTest + true + UnitTest + + + """); + + Converter.Convert(nfproj, new ConversionOptions()); + + var emitted = File.ReadAllText(dir.Combine("NFUnitTest.csproj")); + Assert.Contains("", emitted); + Assert.Contains("true", emitted); + Assert.Contains("UnitTest", emitted); + } +} From 1fc58bd47bfb9f6ec31cbdc6c102c9b1c1de2e73 Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Mon, 15 Jun 2026 23:43:05 -0500 Subject: [PATCH 12/49] NanoMigrate: pass through all non-boilerplate properties (fixes dropped OutputType) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The converter only carried a fixed keep-list of properties and silently dropped the rest, so Exe was lost — every app project became a library and failed to build (CS8805 for top-level-statement apps). Switch to pass-through: keep every property except the project-system boilerplate (DropProps) and the TFM the converter emits itself. Adds an OutputType regression test (56 total, all green). Co-Authored-By: Claude Opus 4.8 --- .../src/NanoMigrate.Core/ProjectConverter.cs | 19 +++++----- .../NanoMigrate.Tests/OutputTypeTests.cs | 37 +++++++++++++++++++ 2 files changed, 47 insertions(+), 9 deletions(-) create mode 100644 tools/NanoMigrate/tests/NanoMigrate.Tests/OutputTypeTests.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs b/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs index 67c3f20..ef410d2 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs @@ -24,14 +24,14 @@ public sealed class ProjectConverter : IProjectConverter "Configuration", "Platform", }; - // Properties carried through verbatim when present. - private static readonly HashSet KeepProps = new(StringComparer.Ordinal) + // The converter emits the TFM itself (see Emit), so a source TargetFramework is not + // carried through. Every other non-boilerplate property is passed through verbatim + // (OutputType, LangVersion, Nullable, SignAssembly, IsTestProject, custom props, …) — + // silently dropping unknown properties was a bug (e.g. OutputType=Exe was lost, turning + // app projects into libraries → CS8805). + private static readonly HashSet EmittedProps = new(StringComparer.Ordinal) { - "RootNamespace", "AssemblyName", "DocumentationFile", "DefineConstants", "LangVersion", - "Description", "Authors", "PackageTags", "Copyright", - // nanoFramework unit-test projects: these keep the migrated project a test - // project (the SDK does not infer them). - "IsTestProject", "TestProjectType", "RunSettingsFilePath", + "TargetFramework", }; // Legacy names whose NuGet package id differs from X. @@ -112,8 +112,9 @@ void SetProp(string k, string? v) foreach (var el in pg.Elements()) { var tag = el.Name.LocalName; - if (DropProps.Contains(tag)) continue; - if (KeepProps.Contains(tag)) SetProp(tag, el.Value); + if (DropProps.Contains(tag)) continue; // project-system boilerplate the SDK supplies + if (EmittedProps.Contains(tag)) continue; // the converter emits these itself (e.g. TargetFramework) + SetProp(tag, el.Value); // pass through everything else (OutputType, LangVersion, …) } foreach (var ig in root.Elements().Where(e => e.Name.LocalName == "ItemGroup")) diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/OutputTypeTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/OutputTypeTests.cs new file mode 100644 index 0000000..564b4d8 --- /dev/null +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/OutputTypeTests.cs @@ -0,0 +1,37 @@ +using NanoFramework.Migrate.Core; +using Xunit; + +namespace NanoFramework.Migrate.Tests; + +public class OutputTypeTests +{ + private static readonly IProjectConverter Converter = new ProjectConverter(); + + [Fact] + public void OutputType_Exe_is_preserved_so_apps_stay_executables() + { + using var dir = new TempDir(); + dir.File("packages.config", """ + + + + + """); + var nfproj = dir.File("App.nfproj", """ + + + Exe + App + + + """); + + var result = Converter.Convert(nfproj, new ConversionOptions()); + + Assert.Equal(ConvertStatus.Converted, result.Status); + var csproj = File.ReadAllText(dir.Combine("App.csproj")); + // Regression: OutputType used to be silently dropped, turning apps into libraries + // (CS8805 for top-level-statement apps). It must survive conversion. + Assert.Contains("Exe", csproj); + } +} From b50a97cb949a56920e1816223dd3defe988ec474 Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Tue, 16 Jun 2026 00:18:36 -0500 Subject: [PATCH 13/49] NanoMigrate: harden item/import handling so migrated samples build Spot-building migrated samples surfaced several conversion-caused build errors; fix them so the output compiles: - Default-globbed EmbeddedResource/Content/None: drop plain ones (SDK globs them), rewrite metadata-bearing ones from Include= to Update= (fixes NETSDK1022 duplicate items), keep Link/external as Include. Emit child metadata (Generator/LastGenOutput/...). - Over-globbing: emit for on-disk .cs not in the legacy explicit subset (fixes CS0101/CS0111 duplicate types). - Native-stub libraries: keep Properties/AssemblyInfo.cs + set GenerateAssemblyInfo=false when it declares [assembly: AssemblyNativeVersion] (the SDK never generates it). - Shared projects: carry through .projitems (real source); flag other unknown imports. Tests: +13 (69 total, all green). On a full migrated copy, the representative set across shapes (app, resx, library, unit-test, shared-project, interop) builds to NFMRK2. Remaining non-building projects are out-of-scope (desktop .NET FW projects, pre-existing package-version conflicts, a missing-dep sample, a bin\ HintPath sibling reference). Co-Authored-By: Claude Opus 4.8 --- .../src/NanoMigrate.Core/ProjectConverter.cs | 230 ++++++++++++- .../tests/NanoMigrate.Tests/ItemGlobTests.cs | 306 ++++++++++++++++++ 2 files changed, 522 insertions(+), 14 deletions(-) create mode 100644 tools/NanoMigrate/tests/NanoMigrate.Tests/ItemGlobTests.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs b/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs index ef410d2..6cc787a 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs @@ -91,6 +91,16 @@ public ConvertResult Convert(string nfprojPath, ConversionOptions options) var projRefs = new List(); var keepItems = new List(); var review = new List(); + // Normalized relative paths of .cs files the legacy project explicitly + // compiled and that the SDK would also glob by default. Used to detect a + // legacy project that compiled only a SUBSET of the .cs files on disk: the + // unlisted ones must be excluded via , else the SDK's + // **/*.cs glob compiles files the project never built (duplicate types). + var explicitCompile = new HashSet(StringComparer.OrdinalIgnoreCase); + // Top-level projects to carry through verbatim — notably a Shared + // Project (.projitems) import, whose source files the SDK would otherwise + // never see (dropping it leaves the consuming project missing those types). + var imports = new List(); // Seed package references straight from packages.config when it is the // authoritative source. Drop the legacy elements entirely in @@ -117,6 +127,23 @@ void SetProp(string k, string? v) SetProp(tag, el.Value); // pass through everything else (OutputType, LangVersion, …) } + // Top-level elements. The NFProjectSystem.* props/targets imports are + // SDK-supplied boilerplate and are dropped. A Shared Project (.projitems) import + // carries real source into the project and MUST be preserved, else its types go + // missing (CS0246). Anything else unrecognized is surfaced for review. + foreach (var imp in root.Elements().Where(e => e.Name.LocalName == "Import")) + { + var project = (string?)imp.Attribute("Project") ?? ""; + if (project.Contains("NFProjectSystem", StringComparison.OrdinalIgnoreCase)) continue; + if (project.EndsWith(".projitems", StringComparison.OrdinalIgnoreCase)) + { + imports.Add(project); + continue; + } + if (!string.IsNullOrWhiteSpace(project)) + review.Add($"Unhandled (carry it over manually if needed)"); + } + foreach (var ig in root.Elements().Where(e => e.Name.LocalName == "ItemGroup")) foreach (var el in ig.Elements()) { @@ -167,16 +194,25 @@ void SetProp(string k, string? v) projRefs.Add(inc); break; case "Compile": - if (!IsDefaultCompile(inc) || el.Attribute("Link") is not null) + // Explicit Compile of a hand-written AssemblyInfo / default-globbed + // .cs is dropped (the SDK globs **/*.cs). Keep only non-default + // paths or links the SDK would NOT glob. (Files on disk that the + // legacy project deliberately did NOT compile are excluded via a + // Compile Remove computed after the loop — see explicitCompile.) + if (el.Attribute("Link") is not null || !IsDefaultCompile(inc)) keepItems.Add(el); + else + explicitCompile.Add(NormalizeRel(inc)); break; case "None": - if (inc != "packages.config" && !inc.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)) - keepItems.Add(el); + // packages.config and the .nuspec are folded away, never carried. + if (inc == "packages.config" || inc.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)) + break; + KeepOrUpdateDefaultGlobItem(el, keepItems); break; case "EmbeddedResource": case "Content": - keepItems.Add(el); + KeepOrUpdateDefaultGlobItem(el, keepItems); break; case "ProjectCapability": // nanoFramework test projects mark themselves with @@ -192,7 +228,30 @@ void SetProp(string k, string? v) FoldNuspec(projDir, SetProp); - var xml = Emit(props, pkgRefs, projRefs, keepItems, o, cpmActive); + // A hand-written AssemblyInfo.cs that carries nanoFramework-specific + // attributes the SDK's GenerateAssemblyInfo does NOT emit (notably + // AssemblyNativeVersion, required by the MetadataProcessor when generating + // interop/skeleton stubs) must be KEPT rather than deleted. In that case we + // turn GenerateAssemblyInfo off so the hand-written file is authoritative + // and the standard attributes it also declares don't collide with generated + // ones. Otherwise the file is deleted as before (it would duplicate the + // SDK-generated assembly attributes). + var keepAssemblyInfo = HasNanoAssemblyInfo(projDir); + if (keepAssemblyInfo) SetProp("GenerateAssemblyInfo", "false"); + + // Compile-subset exclusion: when the legacy project explicitly compiled a + // strict subset of the .cs files present on disk, the unlisted files must be + // removed from the SDK's default **/*.cs glob. Without this the SDK compiles + // source the project never built (e.g. an alternate/draft copy of a class), + // producing duplicate-definition errors. Only meaningful when the project + // had explicit Compile items in the first place. + var compileRemoves = new List(); + if (explicitCompile.Count > 0) + foreach (var rel in OnDiskDefaultCompile(projDir)) + if (!explicitCompile.Contains(rel)) + compileRemoves.Add(rel); + + var xml = Emit(props, pkgRefs, projRefs, keepItems, compileRemoves, imports, o, cpmActive); var outPath = Path.ChangeExtension(Path.GetFullPath(nfproj), o.Ext); var nfFull = Path.GetFullPath(nfproj); @@ -225,7 +284,8 @@ void SetProp(string k, string? v) if (replacingNfproj) result.DeletedFiles.Add(nfFull); var pc = Path.Combine(projDir, "packages.config"); if (File.Exists(pc)) result.DeletedFiles.Add(Path.GetFullPath(pc)); - foreach (var ai in ExistingAssemblyInfo(projDir)) result.DeletedFiles.Add(ai); + if (!keepAssemblyInfo) + foreach (var ai in ExistingAssemblyInfo(projDir)) result.DeletedFiles.Add(ai); // The .sln/.slnx that still reference the .nfproj are what a rewrite would // touch. We always populate this preview list (even when SkipSolutionRewrite // is set, so the host can render it); the actual rewrite below is gated. @@ -254,8 +314,11 @@ void SetProp(string k, string? v) // The SDK default **/*.cs glob plus generated assembly info would // otherwise produce duplicate-attribute build errors, so delete a // hand-written AssemblyInfo.cs from disk (dropping the Compile item - // is not enough). - DeleteAssemblyInfo(projDir); + // is not enough). EXCEPTION: an AssemblyInfo.cs that carries + // nanoFramework-specific attributes the SDK never generates is kept + // (with GenerateAssemblyInfo=false set above) so those attributes + // survive — deleting it would break MetadataProcessor stub generation. + if (!keepAssemblyInfo) DeleteAssemblyInfo(projDir); } return result; @@ -517,12 +580,117 @@ void AddSolutionsIn(string d, SearchOption option) private static bool IsDefaultCompile(string inc) { - var baseName = inc.TrimStart('.', '\\'); if (!inc.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) return false; + var baseName = inc.TrimStart('.', '\\', '/'); // A hand-written AssemblyInfo.cs collides with GenerateAssemblyInfo → drop it. if (baseName.Replace('\\', '/').EndsWith("Properties/AssemblyInfo.cs", StringComparison.OrdinalIgnoreCase)) return true; - return !baseName.Contains('\\'); + // The SDK globs **/*.cs recursively, so any relative under-project path is a + // default-globbed compile item (not just files in the project root). + return IsDefaultGlobPath(inc); + } + + // A path is "default-globbed" — i.e. the SDK's implicit **/* globs would already + // pick it up — when it is relative, rooted under the project directory, and does + // not escape it via "..". Such items must NOT be re-Included explicitly (that + // duplicates the globbed item); rooted/external paths and links are NOT globbed + // and so are kept verbatim. + private static bool IsDefaultGlobPath(string inc) + { + if (string.IsNullOrWhiteSpace(inc)) return false; + var norm = inc.Replace('\\', '/').Trim(); + if (norm.StartsWith("../") || norm == "..") return false; // escapes project dir + if (norm.StartsWith('/')) return false; // rooted (unix) + if (norm.Length >= 2 && norm[1] == ':') return false; // rooted (drive letter) + if (norm.Contains("/../")) return false; // climbs out partway + if (norm.Contains('*') || norm.Contains('?')) return false; // already a glob + return true; + } + + // Decides how a default-globbable item (None / EmbeddedResource / Content) should + // be carried into the SDK project, per the rules: + // - external/rooted path or a Link present → keep as Include (SDK won't glob it); + // - default-globbed path WITH metadata → rewrite Include→Update so the + // metadata attaches to the SDK's already-globbed item (no duplicate); + // - default-globbed path WITHOUT metadata → drop entirely (SDK globs it plain). + private static void KeepOrUpdateDefaultGlobItem(XElement el, List keepItems) + { + var inc = (string?)el.Attribute("Include") ?? ""; + var hasLink = el.Attribute("Link") is not null + || el.Elements().Any(e => e.Name.LocalName == "Link"); + + if (hasLink || !IsDefaultGlobPath(inc)) + { + keepItems.Add(el); // SDK won't glob this — keep the explicit Include + return; + } + + var hasMetadata = el.Elements().Any() || el.Attributes().Any(a => a.Name.LocalName != "Include"); + if (!hasMetadata) + return; // SDK globs it plainly — drop the redundant item + + // Rewrite Include → Update so the metadata lands on the globbed item without + // re-including (which would trip NETSDK1022 for EmbeddedResource/Content). + el.Attribute("Include")!.Remove(); + el.SetAttributeValue("Update", inc); + keepItems.Add(el); + } + + // Normalizes a project-relative path for comparison against on-disk enumeration: + // forward slashes, leading "./" stripped, no surrounding whitespace. + private static string NormalizeRel(string inc) + { + var s = inc.Replace('\\', '/').Trim(); + while (s.StartsWith("./")) s = s[2..]; + return s; + } + + // True when a hand-written AssemblyInfo.cs on disk declares an attribute the SDK's + // GenerateAssemblyInfo does not emit (AssemblyNativeVersion). Such a file must be + // kept (with GenerateAssemblyInfo disabled) rather than deleted. + private static bool HasNanoAssemblyInfo(string projDir) + { + foreach (var rel in new[] { Path.Combine("Properties", "AssemblyInfo.cs"), "AssemblyInfo.cs" }) + { + var path = Path.Combine(projDir, rel); + if (!File.Exists(path)) continue; + try + { + if (File.ReadAllText(path).Contains("AssemblyNativeVersion", StringComparison.Ordinal)) + return true; + } + catch { /* unreadable → treat as ordinary, deletable AssemblyInfo */ } + } + return false; + } + + // Project-relative paths (forward-slashed) of every .cs file on disk that the SDK + // would compile by default: all **/*.cs under the project directory, excluding + // bin/obj and a hand-written AssemblyInfo.cs. (A deletable AssemblyInfo is removed + // from disk by the converter, and a kept nano-specific one is authoritative — in + // both cases it must not appear in the Compile Remove list.) Used to detect files + // the legacy project deliberately did not compile. + private static IEnumerable OnDiskDefaultCompile(string projDir) + { + var root = Path.GetFullPath(projDir); + IEnumerable files; + try { files = Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories); } + catch { yield break; } + + foreach (var f in files) + { + var rel = Path.GetRelativePath(root, f).Replace('\\', '/'); + var lower = rel.ToLowerInvariant(); + if (lower.StartsWith("bin/") || lower.Contains("/bin/")) continue; + if (lower.StartsWith("obj/") || lower.Contains("/obj/")) continue; + // A deletable AssemblyInfo.cs is removed from disk by the converter, so it + // is neither globbed nor needs a Remove. A KEPT one is authoritative and + // must not be Removed either. + if (rel.EndsWith("Properties/AssemblyInfo.cs", StringComparison.OrdinalIgnoreCase) + || rel.Equals("AssemblyInfo.cs", StringComparison.OrdinalIgnoreCase)) + continue; + yield return rel; + } } private static void FoldNuspec(string projDir, Action setProp) @@ -547,6 +715,8 @@ private static string Emit( Dictionary pkgRefs, List projRefs, List keepItems, + List compileRemoves, + List imports, ConversionOptions o, bool cpmActive) { @@ -581,16 +751,48 @@ private static string Emit( { sb.Append(" \n"); foreach (var el in keepItems) - { - var attrs = string.Join(" ", el.Attributes().Select(a => $"{a.Name.LocalName}=\"{Escape(a.Value)}\"")); - sb.Append($" <{el.Name.LocalName} {attrs} />\n"); - } + AppendItem(sb, el); + sb.Append(" \n\n"); + } + // Files the legacy project deliberately did not compile but that the SDK's + // **/*.cs glob would otherwise pick up: exclude them so the converted project + // builds the same source set as the original. + if (compileRemoves.Count > 0) + { + sb.Append(" \n"); + foreach (var rel in compileRemoves) + sb.Append($" \n"); sb.Append(" \n\n"); } + // Shared Project imports carry source into the project; emit them with the + // Shared label MSBuild/VS expects for a .projitems import. + foreach (var import in imports) + sb.Append($" \n"); + if (imports.Count > 0) sb.Append('\n'); sb.Append("\n"); return sb.ToString(); } + // Renders one kept item element, preserving its attributes AND its child-element + // metadata (Generator, LastGenOutput, CopyToOutputDirectory, Link, …). Earlier the + // emitter wrote attributes only, silently dropping the child metadata that, for an + // Update item, is the whole point of carrying it through. + private static void AppendItem(StringBuilder sb, XElement el) + { + var attrs = string.Join(" ", el.Attributes().Select(a => $"{a.Name.LocalName}=\"{Escape(a.Value)}\"")); + var children = el.Elements().ToList(); + var name = el.Name.LocalName; + if (children.Count == 0) + { + sb.Append($" <{name} {attrs} />\n"); + return; + } + sb.Append($" <{name} {attrs}>\n"); + foreach (var c in children) + sb.Append($" <{c.Name.LocalName}>{Escape(c.Value)}\n"); + sb.Append($" \n"); + } + private static string Escape(string s) => s .Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """); } diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/ItemGlobTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/ItemGlobTests.cs new file mode 100644 index 0000000..8103560 --- /dev/null +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/ItemGlobTests.cs @@ -0,0 +1,306 @@ +using NanoFramework.Migrate.Core; +using Xunit; + +namespace NanoFramework.Migrate.Tests; + +/// +/// Tests for the converter hardening that keeps migrated samples building: the SDK +/// globs Compile/EmbeddedResource/Content/None by default, so an explicit +/// Include= of a default-located file duplicates the globbed item (NETSDK1022) +/// or loses an intended exclusion. These cover the Include→Update / drop / keep +/// decisions, the Compile-subset Remove, a kept nano AssemblyInfo, and the +/// Shared Project import. +/// +public class ItemGlobTests +{ + private static readonly IProjectConverter Converter = new ProjectConverter(); + + // Convert a project (real run so we can read the emitted .csproj) and return the + // emitted text. Seeds a packages.config so packages.config-authoritative mode is + // exercised, matching real samples. + private static string ConvertAndRead(TempDir dir, string nfprojBody, string? csName = null) + { + dir.File("packages.config", """ + + + + + """); + var nfproj = dir.File((csName ?? "Sample") + ".nfproj", nfprojBody); + var result = Converter.Convert(nfproj, new ConversionOptions()); + Assert.Null(result.Error); + return File.ReadAllText(dir.Combine((csName ?? "Sample") + ".csproj")); + } + + [Fact] + public void EmbeddedResource_with_metadata_becomes_Update_not_Include() + { + using var dir = new TempDir(); + var emitted = ConvertAndRead(dir, """ + + Sample + + + nFResXFileCodeGenerator + Resources.Designer.cs + + + + """); + + // Update (not Include) so the metadata attaches to the SDK-globbed resx + // without re-including it — re-including would trip NETSDK1022. + Assert.Contains("", emitted); + Assert.DoesNotContain("nFResXFileCodeGenerator", emitted); + Assert.Contains("Resources.Designer.cs", emitted); + } + + [Fact] + public void EmbeddedResource_without_metadata_is_dropped() + { + using var dir = new TempDir(); + var emitted = ConvertAndRead(dir, """ + + Sample + + + + + """); + + // No metadata → the SDK globs it plainly, so the explicit item is dropped + // entirely (keeping it would duplicate the globbed item). + Assert.DoesNotContain("Plain.resx", emitted); + } + + [Fact] + public void None_with_metadata_becomes_Update() + { + using var dir = new TempDir(); + var emitted = ConvertAndRead(dir, """ + + Sample + + + PreserveNewest + + + + """); + + Assert.Contains("", emitted); + Assert.Contains("PreserveNewest", emitted); + } + + [Fact] + public void None_without_metadata_is_dropped() + { + using var dir = new TempDir(); + var emitted = ConvertAndRead(dir, """ + + Sample + + + + + """); + + Assert.DoesNotContain("favicon.png", emitted); + } + + [Fact] + public void Item_with_Link_is_kept_as_Include() + { + using var dir = new TempDir(); + var emitted = ConvertAndRead(dir, """ + + Sample + + + shared.txt + + + + """); + + // A linked file is NOT default-globbed, so it must stay an Include with its Link. + Assert.Contains("Include=\"..\\Shared\\shared.txt\"", emitted); + Assert.Contains("shared.txt", emitted); + Assert.DoesNotContain("Update=", emitted); + } + + [Fact] + public void External_rooted_path_is_kept_as_Include() + { + using var dir = new TempDir(); + var emitted = ConvertAndRead(dir, """ + + Sample + + + + + """); + + // Outside the project dir → SDK won't glob it → keep verbatim (no dup, no drop). + Assert.Contains("Include=\"..\\Outside\\external.resx\"", emitted); + } + + [Fact] + public void Content_with_metadata_becomes_Update() + { + using var dir = new TempDir(); + var emitted = ConvertAndRead(dir, """ + + Sample + + + Always + + + + """); + + Assert.Contains("", emitted); + Assert.Contains("Always", emitted); + } + + [Fact] + public void Compile_subset_emits_Remove_for_unlisted_on_disk_cs() + { + using var dir = new TempDir(); + // On disk there are TWO .cs files, but the legacy project compiles only one. + // The SDK globs **/*.cs, so the unlisted file must be Removed or it gets + // compiled (duplicate definitions in real samples). + dir.File("Test.cs", "// compiled\n"); + dir.File("Alternate.cs", "// NOT compiled by the legacy project\n"); + var emitted = ConvertAndRead(dir, """ + + Sample + + + + + """); + + Assert.Contains("", emitted); + Assert.DoesNotContain("Remove=\"Test.cs\"", emitted); // the listed file is not removed + } + + [Fact] + public void Compile_listing_all_on_disk_cs_emits_no_Remove() + { + using var dir = new TempDir(); + dir.File("Program.cs", "// compiled\n"); + var emitted = ConvertAndRead(dir, """ + + Sample + + + + + """); + + // The explicit set equals the on-disk set → nothing to exclude. + Assert.DoesNotContain("Compile Remove", emitted); + } + + [Fact] + public void AssemblyInfo_with_native_version_is_kept_and_generation_disabled() + { + using var dir = new TempDir(); + var aiPath = dir.File(Path.Combine("Properties", "AssemblyInfo.cs"), + "using System.Reflection;\n[assembly: AssemblyNativeVersion(\"1.0.0.0\")]\n"); + var nfproj = dir.File("Lib.nfproj", """ + + + Library + Lib + + + + + + """); + + var result = Converter.Convert(nfproj, new ConversionOptions()); + var emitted = File.ReadAllText(dir.Combine("Lib.csproj")); + + // The MetadataProcessor needs AssemblyNativeVersion when generating stubs, and + // the SDK never generates it — so the file is KEPT (not deleted) and generation + // is turned off so the standard attributes don't collide with generated ones. + Assert.True(File.Exists(aiPath)); // not deleted + Assert.Contains("false", emitted); + Assert.DoesNotContain(aiPath, result.DeletedFiles); // not previewed as a deletion + } + + [Fact] + public void Ordinary_AssemblyInfo_is_still_deleted_and_generation_left_on() + { + using var dir = new TempDir(); + var aiPath = dir.File(Path.Combine("Properties", "AssemblyInfo.cs"), + "using System.Reflection;\n[assembly: AssemblyTitle(\"Sample\")]\n"); + var nfproj = dir.File("Sample.nfproj", """ + + Sample + + + + + """); + + Converter.Convert(nfproj, new ConversionOptions()); + var emitted = File.ReadAllText(dir.Combine("Sample.csproj")); + + // No nano-specific attribute → original behavior: delete the file, let the SDK + // generate the assembly info. + Assert.False(File.Exists(aiPath)); + Assert.DoesNotContain("GenerateAssemblyInfo", emitted); + } + + [Fact] + public void Shared_project_import_is_carried_through() + { + using var dir = new TempDir(); + var emitted = ConvertAndRead(dir, """ + + + $(MSBuildExtensionsPath)\nanoFramework\v1.0\ + + + Sample + + + + """); + + // The Shared Project import carries source the SDK would otherwise never see — + // it must survive. The NFProjectSystem.* imports are SDK boilerplate, dropped. + Assert.Contains("", emitted); + Assert.DoesNotContain("NFProjectSystem", emitted); + } + + [Fact] + public void Unknown_top_level_import_is_flagged_for_review() + { + using var dir = new TempDir(); + dir.File("packages.config", """ + + + + + """); + var nfproj = dir.File("Sample.nfproj", """ + + Sample + + + """); + + var result = Converter.Convert(nfproj, new ConversionOptions { DryRun = true }); + + Assert.Contains(result.Review, r => r.Contains("Custom.targets")); + } +} From 58b5afd165fc1f74d4ec02cb3e5c8ba6cf7fbf99 Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Tue, 16 Jun 2026 00:38:48 -0500 Subject: [PATCH 14/49] SDK: also define NANOFRAMEWORK_1_0 for source compatibility The legacy NFProjectSystem defined NANOFRAMEWORK_1_0; the SDK only defined NETNANO1_0, so existing source with #if NANOFRAMEWORK_1_0 compiled the wrong branch (e.g. pulled System.Linq). Define both. Verified: a sample using #if !NANOFRAMEWORK_1_0 now builds. Co-Authored-By: Claude Opus 4.8 --- src/nanoFramework.NET.Sdk/Sdk/Sdk.targets | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/nanoFramework.NET.Sdk/Sdk/Sdk.targets b/src/nanoFramework.NET.Sdk/Sdk/Sdk.targets index 9fb4951..6ba18ce 100644 --- a/src/nanoFramework.NET.Sdk/Sdk/Sdk.targets +++ b/src/nanoFramework.NET.Sdk/Sdk/Sdk.targets @@ -15,10 +15,13 @@ --> - + + + + - $(DefineConstants);NETNANO1_0 + $(DefineConstants);NETNANO1_0;NANOFRAMEWORK_1_0 From 48b8b405a531d6e33b95b6972f2c49229b3aa7f5 Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Tue, 16 Jun 2026 00:54:04 -0500 Subject: [PATCH 15/49] Add the dotnet-nano umbrella tool (built-in migrate + external nanoff wrapper) Scaffold the single `dotnet nano ` CLI that ships with the SDK (PackAsTool, ToolCommandName=nano, PackageId=nanoFramework.Tool), per docs nano-tool.md. - Spectre.Console.Cli CommandApp host with examples + descriptions; exit-code mapping. - Built-in `migrate` runs in-proc over the conversion engine. To stay DRY, the shared MigrateCommand/MigrateSettings/renderer were factored out of NanoMigrate.Cli into a new NanoMigrate.Cli.Commands library that both the standalone nano-migrate CLI and the umbrella reference (one implementation). NanoMigrate.Core flows transitively. - External tools: IExternalTool + ExternalToolResolver (resolution order bundled -> installed/PATH -> user cache -> download, behind an injectable environment seam; download is a stubbed interface). NanoffTool wraps nanoff; nano-tools.json (embedded) pins it. `flash` maps --target/--port onto nanoff and errors cleanly when nanoff is absent. - deploy/monitor/devices are discoverable placeholders ("use VS/VS Code for now"). - nanoFramework.Tool.slnx covers the tool, its tests, and the NanoMigrate projects it references; the netstandard2.0 SDK solution is left untouched. Verified: both solutions build; 69 NanoMigrate + 8 umbrella tests pass; `nano --help` lists all commands; `nano migrate --help` shows the engine options; `nano flash` errors cleanly without nanoff; `dotnet pack` produces the tool package (command `nano`); umbrella dry-run writes nothing. Co-Authored-By: Claude Opus 4.8 --- nanoFramework.Tool.slnx | 14 ++ .../Commands/FlashCommand.cs | 92 ++++++++++ .../Commands/PlaceholderCommand.cs | 39 ++++ .../ExternalTools/ExternalToolBase.cs | 61 +++++++ .../ExternalTools/ExternalToolResolver.cs | 112 ++++++++++++ .../ExternalTools/IExternalTool.cs | 54 ++++++ .../ExternalTools/NanoffTool.cs | 13 ++ .../ExternalTools/ToolEnvironment.cs | 70 ++++++++ .../ExternalTools/ToolManifest.cs | 68 +++++++ .../ExternalTools/nano-tools.json | 12 ++ src/nanoFramework.Tool/Program.cs | 70 ++++++++ .../nanoFramework.Tool.csproj | 54 ++++++ .../ExternalToolResolverTests.cs | 170 ++++++++++++++++++ .../nanoFramework.Tool.Tests.csproj | 22 +++ tools/NanoMigrate/NanoMigrate.sln | 15 ++ .../MigrateCommand.cs | 4 +- .../NanoMigrate.Cli.Commands.csproj | 39 ++++ .../Rendering/ConsoleSupport.cs | 2 +- .../Rendering/MigrateRenderer.cs | 4 +- .../NanoMigrate.Cli/NanoMigrate.Cli.csproj | 5 +- 20 files changed, 914 insertions(+), 6 deletions(-) create mode 100644 nanoFramework.Tool.slnx create mode 100644 src/nanoFramework.Tool/Commands/FlashCommand.cs create mode 100644 src/nanoFramework.Tool/Commands/PlaceholderCommand.cs create mode 100644 src/nanoFramework.Tool/ExternalTools/ExternalToolBase.cs create mode 100644 src/nanoFramework.Tool/ExternalTools/ExternalToolResolver.cs create mode 100644 src/nanoFramework.Tool/ExternalTools/IExternalTool.cs create mode 100644 src/nanoFramework.Tool/ExternalTools/NanoffTool.cs create mode 100644 src/nanoFramework.Tool/ExternalTools/ToolEnvironment.cs create mode 100644 src/nanoFramework.Tool/ExternalTools/ToolManifest.cs create mode 100644 src/nanoFramework.Tool/ExternalTools/nano-tools.json create mode 100644 src/nanoFramework.Tool/Program.cs create mode 100644 src/nanoFramework.Tool/nanoFramework.Tool.csproj create mode 100644 test/nanoFramework.Tool.Tests/ExternalToolResolverTests.cs create mode 100644 test/nanoFramework.Tool.Tests/nanoFramework.Tool.Tests.csproj rename tools/NanoMigrate/src/{NanoMigrate.Cli/Cli => NanoMigrate.Cli.Commands}/MigrateCommand.cs (99%) create mode 100644 tools/NanoMigrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj rename tools/NanoMigrate/src/{NanoMigrate.Cli => NanoMigrate.Cli.Commands}/Rendering/ConsoleSupport.cs (96%) rename tools/NanoMigrate/src/{NanoMigrate.Cli => NanoMigrate.Cli.Commands}/Rendering/MigrateRenderer.cs (98%) diff --git a/nanoFramework.Tool.slnx b/nanoFramework.Tool.slnx new file mode 100644 index 0000000..1506d0f --- /dev/null +++ b/nanoFramework.Tool.slnx @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/nanoFramework.Tool/Commands/FlashCommand.cs b/src/nanoFramework.Tool/Commands/FlashCommand.cs new file mode 100644 index 0000000..e936c58 --- /dev/null +++ b/src/nanoFramework.Tool/Commands/FlashCommand.cs @@ -0,0 +1,92 @@ +using System.ComponentModel; +using NanoFramework.Tool.ExternalTools; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace NanoFramework.Tool.Commands; + +public sealed class FlashSettings : CommandSettings +{ + [CommandOption("-t|--target ")] + [Description("The target board/firmware name to flash (passed to nanoff --target).")] + public string? Target { get; init; } + + [CommandOption("-p|--port ")] + [Description("The serial port to use (passed to nanoff --serialport).")] + public string? Port { get; init; } + + [CommandOption("--update")] + [Description("Update the device firmware rather than a clean flash (nanoff --update).")] + public bool Update { get; init; } + + public override ValidationResult Validate() => + Target is null + ? ValidationResult.Error("specify a --target (or pass nanoff args after --).") + : ValidationResult.Success(); +} + +/// +/// dotnet nano flash — resolves and runs the external nanoff flasher +/// with mapped args. If nanoff cannot be resolved, fails with a clear Spectre error +/// (no hang, no stack dump). +/// +public sealed class FlashCommand : Command +{ + private readonly IExternalTool _nanoff; + + // Spectre's default activator needs a single, no-argument constructor. The real + // nanoff wrapper is wired here; the resolution logic it relies on is covered by + // ExternalToolResolverTests directly. + public FlashCommand() => _nanoff = BuildNanoff(); + + private static IExternalTool BuildNanoff() + { + var manifest = ToolManifest.LoadEmbedded(); + // No downloader is wired yet: when nothing resolves (bundled / PATH / cache), + // resolution returns null and the command renders a clear "install nanoff" + // error rather than attempting an unimplemented network fetch. Swap in a real + // IToolDownloader here once pinned-release fetch lands (see ExternalToolResolver). + var resolver = new ExternalToolResolver(new SystemToolEnvironment(), downloader: null); + return new NanoffTool(manifest, resolver); + } + + protected override int Execute(CommandContext context, FlashSettings settings, CancellationToken cancellationToken) + { + var resolution = _nanoff.ResolvePath(); + if (resolution is null) + { + AnsiConsole.MarkupLine($"[red]error:[/] could not find [bold]{Markup.Escape(_nanoff.Name)}[/]" + + (string.IsNullOrEmpty(_nanoff.Version) ? "" : $" [grey]{Markup.Escape(_nanoff.Version)}[/]") + "."); + AnsiConsole.MarkupLine("[grey]Looked for: a bundled binary (tools/" + Markup.Escape(_nanoff.Name) + + "/), an installed tool / PATH, and the user cache.[/]"); + AnsiConsole.MarkupLine("[grey]Install it with[/] [blue]dotnet tool install -g nanoff[/] [grey]and retry.[/]"); + return 1; + } + + var args = MapArgs(settings, context.Remaining.Raw); + AnsiConsole.MarkupLine($"[grey]→ running[/] [blue]{Markup.Escape(_nanoff.Name)}[/] " + + $"[grey]({resolution.Value.Source})[/] {Markup.Escape(string.Join(' ', args))}"); + + try + { + return _nanoff.Run(args); + } + catch (ExternalToolNotFoundException ex) + { + AnsiConsole.MarkupLine($"[red]error:[/] {Markup.Escape(ex.Message)}"); + return 1; + } + } + + // Maps the umbrella's friendly options onto nanoff's CLI surface. Any args after + // a literal "--" are passed straight through to nanoff (remaining.Raw). + private static List MapArgs(FlashSettings s, IReadOnlyList passthrough) + { + var args = new List(); + if (s.Target is not null) { args.Add("--target"); args.Add(s.Target); } + if (s.Port is not null) { args.Add("--serialport"); args.Add(s.Port); } + if (s.Update) args.Add("--update"); + args.AddRange(passthrough); + return args; + } +} diff --git a/src/nanoFramework.Tool/Commands/PlaceholderCommand.cs b/src/nanoFramework.Tool/Commands/PlaceholderCommand.cs new file mode 100644 index 0000000..a882ba0 --- /dev/null +++ b/src/nanoFramework.Tool/Commands/PlaceholderCommand.cs @@ -0,0 +1,39 @@ +using Spectre.Console; +using Spectre.Console.Cli; + +namespace NanoFramework.Tool.Commands; + +/// +/// A minimal placeholder for a built-in command that is on the design's command +/// surface (so it shows in --help) but is not implemented in the CLI yet. +/// Prints a clear "use VS / VS Code for now" notice and returns non-zero. +/// +public abstract class PlaceholderCommand : Command +{ + protected abstract string Verb { get; } + + protected override int Execute(CommandContext context, CancellationToken cancellationToken) + { + AnsiConsole.MarkupLine($"[yellow]'{Markup.Escape(Verb)}' is not yet implemented in the CLI.[/]"); + AnsiConsole.MarkupLine("[grey]Use Visual Studio or VS Code for now.[/]"); + return 1; + } +} + +/// dotnet nano deploy — placeholder (build Deploy target + device comms). +public sealed class DeployCommand : PlaceholderCommand +{ + protected override string Verb => "deploy"; +} + +/// dotnet nano monitor — placeholder (device output monitor). +public sealed class MonitorCommand : PlaceholderCommand +{ + protected override string Verb => "monitor"; +} + +/// dotnet nano devices — placeholder (list connected nanoFramework devices). +public sealed class DevicesCommand : PlaceholderCommand +{ + protected override string Verb => "devices"; +} diff --git a/src/nanoFramework.Tool/ExternalTools/ExternalToolBase.cs b/src/nanoFramework.Tool/ExternalTools/ExternalToolBase.cs new file mode 100644 index 0000000..a040815 --- /dev/null +++ b/src/nanoFramework.Tool/ExternalTools/ExternalToolBase.cs @@ -0,0 +1,61 @@ +using System.Diagnostics; + +namespace NanoFramework.Tool.ExternalTools; + +/// +/// Shared behaviour: pin the entry, resolve via +/// , and run the resolved executable as a child +/// process (output streamed straight through to the inherited console). Concrete +/// tools only supply their manifest name; is the first. +/// +public abstract class ExternalToolBase : IExternalTool +{ + private readonly ToolManifest _manifest; + private readonly ExternalToolResolver _resolver; + + protected ExternalToolBase(ToolManifest manifest, ExternalToolResolver resolver) + { + _manifest = manifest; + _resolver = resolver; + } + + public abstract string Name { get; } + + private ToolManifestEntry Entry => + _manifest.Find(Name) ?? new ToolManifestEntry { Name = Name }; + + public string Version => Entry.Version; + + public ToolResolution? ResolvePath() => _resolver.Resolve(Entry); + + public int Run(IReadOnlyList args) + { + var resolved = ResolvePath() + ?? throw new ExternalToolNotFoundException( + Name, + $"could not resolve '{Name}'" + + (string.IsNullOrEmpty(Version) ? "" : $" {Version}") + + ". Looked for a bundled binary (tools/" + Name + "/), an installed tool / PATH, and the user cache."); + + return RunExecutable(resolved.ExecutablePath, args); + } + + // Runs the executable inheriting stdio so the wrapped tool's own output appears + // live. Overridable for tests. Returns the process exit code. + protected virtual int RunExecutable(string executablePath, IReadOnlyList args) + { + var psi = new ProcessStartInfo(executablePath) + { + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + }; + foreach (var a in args) + psi.ArgumentList.Add(a); + + using var process = Process.Start(psi) + ?? throw new ExternalToolNotFoundException(Name, $"failed to start '{executablePath}'."); + process.WaitForExit(); + return process.ExitCode; + } +} diff --git a/src/nanoFramework.Tool/ExternalTools/ExternalToolResolver.cs b/src/nanoFramework.Tool/ExternalTools/ExternalToolResolver.cs new file mode 100644 index 0000000..396e65b --- /dev/null +++ b/src/nanoFramework.Tool/ExternalTools/ExternalToolResolver.cs @@ -0,0 +1,112 @@ +namespace NanoFramework.Tool.ExternalTools; + +/// +/// Implements the documented external-tool resolution order, purely against an +/// so it is fully unit-testable: +/// +/// a binary bundled in the tool package under tools/<name>/, +/// a globally/locally installed tool or one on PATH, +/// a pinned release downloaded to the per-user cache. +/// +/// The first step that yields an existing executable wins. Download (step 3) is +/// resolution-only here: if a previously-fetched binary already sits in the cache it +/// is returned; performing the actual network fetch is delegated to . +/// +public sealed class ExternalToolResolver(IToolEnvironment environment, IToolDownloader? downloader = null) +{ + private readonly IToolEnvironment _env = environment; + private readonly IToolDownloader? _downloader = downloader; + + /// + /// Resolve to a runnable executable, or null if no step + /// (and no fetch) produces one. + /// + public ToolResolution? Resolve(ToolManifestEntry entry) + { + // (1) Bundled: tools// + if (TryBundled(entry.Name, out var bundled)) + return new ToolResolution(bundled!, ToolSource.Bundled); + + // (2) Installed / on PATH. + if (TryOnPath(entry.Name, out var onPath)) + return new ToolResolution(onPath!, ToolSource.InstalledOrPath); + + // (3) Cache: an already-downloaded pinned release. + if (TryCache(entry, out var cached)) + return new ToolResolution(cached!, ToolSource.Cache); + + // (3b) Nothing on disk yet — attempt a pinned download into the cache, then + // re-probe the cache. The downloader is a stub by default. + if (_downloader is not null && _downloader.Download(entry, CacheDirFor(entry))) + { + if (TryCache(entry, out var fetched)) + return new ToolResolution(fetched!, ToolSource.Cache); + } + + return null; + } + + private bool TryBundled(string name, out string? path) + { + var dir = Path.Combine(_env.BaseDirectory, "tools", name); + return TryProbeDir(dir, name, out path); + } + + private bool TryOnPath(string name, out string? path) + { + foreach (var dir in _env.PathDirectories) + if (TryProbeDir(dir, name, out path)) + return true; + path = null; + return false; + } + + private bool TryCache(ToolManifestEntry entry, out string? path) => + TryProbeDir(CacheDirFor(entry), entry.Name, out path); + + // The cache slot for a pinned tool: //. + private string CacheDirFor(ToolManifestEntry entry) => + Path.Combine(_env.CacheDirectory, entry.Name, string.IsNullOrEmpty(entry.Version) ? "latest" : entry.Version); + + // Probe a directory for any of the executable name candidates for the tool. + private bool TryProbeDir(string dir, string name, out string? path) + { + foreach (var exe in _env.ExecutableNamesFor(name)) + { + var candidate = Path.Combine(dir, exe); + if (_env.FileExists(candidate)) + { + path = candidate; + return true; + } + } + path = null; + return false; + } +} + +/// Fetches a pinned release into a cache directory. Implementations may hit the network. +public interface IToolDownloader +{ + /// + /// Download the pinned release described by into + /// . Returns true if, afterwards, an executable is + /// present in . + /// + bool Download(ToolManifestEntry entry, string targetDir); +} + +/// +/// The default downloader. The actual pinned-release fetch is not yet implemented; +/// it throws a clear so the gap is explicit +/// rather than a silent failure. Resolution steps 1 and 2 are fully functional. +/// +public sealed class NotImplementedToolDownloader : IToolDownloader +{ + public bool Download(ToolManifestEntry entry, string targetDir) => + // TODO: fetch entry.Source (NuGet/GitHub release) pinned to entry.Version, + // verify by version/hash, unpack into targetDir, then return true. + throw new NotImplementedException( + $"Downloading '{entry.Name}' {entry.Version} from '{entry.Source}' to the user cache is not yet implemented. " + + "Install the tool (dotnet tool / PATH) or bundle it under tools/ for now."); +} diff --git a/src/nanoFramework.Tool/ExternalTools/IExternalTool.cs b/src/nanoFramework.Tool/ExternalTools/IExternalTool.cs new file mode 100644 index 0000000..360e571 --- /dev/null +++ b/src/nanoFramework.Tool/ExternalTools/IExternalTool.cs @@ -0,0 +1,54 @@ +namespace NanoFramework.Tool.ExternalTools; + +/// How an external tool's executable was located. +public enum ToolSource +{ + /// A binary bundled inside the tool package under tools/<name>/. + Bundled, + + /// A globally/locally installed tool or an executable found on PATH. + InstalledOrPath, + + /// A pinned release downloaded into the per-user cache. + Cache, +} + +/// The outcome of resolving an external tool to a runnable executable. +/// The full path to the resolved executable. +/// Which resolution step produced the path. +public readonly record struct ToolResolution(string ExecutablePath, ToolSource Source); + +/// +/// An external, prebuilt tool wrapped under dotnet nano (e.g. nanoff). +/// We do not rebuild these — we resolve a pinned binary, map args, run it, and render +/// its output through the shared Spectre reporter. +/// +public interface IExternalTool +{ + /// The tool name (e.g. nanoff); also the bundled sub-folder name. + string Name { get; } + + /// The version this tool is pinned to (from the manifest). + string Version { get; } + + /// + /// Resolve the tool to a runnable executable using the documented order: + /// (1) bundled, (2) installed/PATH, (3) downloaded to the user cache. + /// Returns null when the tool cannot be resolved (caller renders an error). + /// + ToolResolution? ResolvePath(); + + /// + /// Run the resolved tool with the given arguments. Throws + /// when the tool cannot be resolved. + /// Returns the process exit code. + /// + int Run(IReadOnlyList args); +} + +/// Raised when an external tool cannot be resolved through any step. +public sealed class ExternalToolNotFoundException(string toolName, string message) + : Exception(message) +{ + public string ToolName { get; } = toolName; +} diff --git a/src/nanoFramework.Tool/ExternalTools/NanoffTool.cs b/src/nanoFramework.Tool/ExternalTools/NanoffTool.cs new file mode 100644 index 0000000..c2f63e7 --- /dev/null +++ b/src/nanoFramework.Tool/ExternalTools/NanoffTool.cs @@ -0,0 +1,13 @@ +namespace NanoFramework.Tool.ExternalTools; + +/// +/// Wraps the prebuilt nanoff firmware flasher. Not rebuilt here — resolved +/// (bundled / installed / cached) and run with mapped args by the flash command. +/// +public sealed class NanoffTool(ToolManifest manifest, ExternalToolResolver resolver) + : ExternalToolBase(manifest, resolver) +{ + public const string ToolName = "nanoff"; + + public override string Name => ToolName; +} diff --git a/src/nanoFramework.Tool/ExternalTools/ToolEnvironment.cs b/src/nanoFramework.Tool/ExternalTools/ToolEnvironment.cs new file mode 100644 index 0000000..abbcea4 --- /dev/null +++ b/src/nanoFramework.Tool/ExternalTools/ToolEnvironment.cs @@ -0,0 +1,70 @@ +using System.Runtime.InteropServices; + +namespace NanoFramework.Tool.ExternalTools; + +/// +/// The host-environment seams the resolver depends on, behind an interface so the +/// resolution order (bundled → installed/PATH → cache) can be unit-tested without a +/// real filesystem, PATH, or network. is the +/// production implementation. +/// +public interface IToolEnvironment +{ + /// The directory the running tool package lives in (root of tools/). + string BaseDirectory { get; } + + /// The per-user cache root downloaded releases are unpacked into. + string CacheDirectory { get; } + + /// The directories on PATH, in order. + IReadOnlyList PathDirectories { get; } + + /// The executable file-name candidates for a bare tool name (e.g. nanoff, nanoff.exe). + IReadOnlyList ExecutableNamesFor(string toolName); + + /// True if a file exists at the given path. + bool FileExists(string path); +} + +/// The production over the real machine. +public sealed class SystemToolEnvironment : IToolEnvironment +{ + public string BaseDirectory { get; } + public string CacheDirectory { get; } + + public SystemToolEnvironment(string? baseDirectory = null, string? cacheDirectory = null) + { + BaseDirectory = baseDirectory ?? AppContext.BaseDirectory; + CacheDirectory = cacheDirectory ?? DefaultCacheDirectory(); + } + + private static string DefaultCacheDirectory() + { + // Per-user, OS-appropriate cache: ~/.nanoframework/tools (or %LOCALAPPDATA% on Windows). + var root = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(root, ".nanoframework", "tools"); + } + + public IReadOnlyList PathDirectories + { + get + { + var path = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(path)) return Array.Empty(); + return path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + } + + public IReadOnlyList ExecutableNamesFor(string toolName) + { + // On Windows a bare name resolves through PATHEXT; we only probe the common + // launcher extensions for an external CLI here. + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return new[] { toolName + ".exe", toolName + ".cmd", toolName + ".bat", toolName }; + return new[] { toolName }; + } + + public bool FileExists(string path) => File.Exists(path); +} diff --git a/src/nanoFramework.Tool/ExternalTools/ToolManifest.cs b/src/nanoFramework.Tool/ExternalTools/ToolManifest.cs new file mode 100644 index 0000000..16ade88 --- /dev/null +++ b/src/nanoFramework.Tool/ExternalTools/ToolManifest.cs @@ -0,0 +1,68 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace NanoFramework.Tool.ExternalTools; + +/// One external tool entry from nano-tools.json. +public sealed class ToolManifestEntry +{ + [JsonPropertyName("name")] + public string Name { get; init; } = ""; + + /// The pinned version resolution/fetch is locked to. + [JsonPropertyName("version")] + public string Version { get; init; } = ""; + + [JsonPropertyName("description")] + public string? Description { get; init; } + + /// The NuGet/dotnet-tool package id, when the tool ships as a tool package. + [JsonPropertyName("packageId")] + public string? PackageId { get; init; } + + /// A download source (URL) for the pinned release; used by cache fetch. + [JsonPropertyName("source")] + public string? Source { get; init; } +} + +/// The parsed nano-tools.json: the list of external tools + pins. +public sealed class ToolManifest +{ + [JsonPropertyName("tools")] + public IReadOnlyList Tools { get; init; } = Array.Empty(); + + private static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + /// Find the pinned entry for a tool by name (case-insensitive), or null. + public ToolManifestEntry? Find(string name) => + Tools.FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase)); + + /// Parse a manifest from JSON text. + public static ToolManifest Parse(string json) => + JsonSerializer.Deserialize(json, Options) ?? new ToolManifest(); + + /// + /// Load the manifest embedded in the tool package (ExternalTools/nano-tools.json). + /// Falls back to an empty manifest if, for any reason, the resource is missing. + /// + public static ToolManifest LoadEmbedded(Assembly? assembly = null) + { + assembly ??= typeof(ToolManifest).Assembly; + var name = assembly.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith("nano-tools.json", StringComparison.OrdinalIgnoreCase)); + if (name is null) + return new ToolManifest(); + + using var stream = assembly.GetManifestResourceStream(name); + if (stream is null) + return new ToolManifest(); + using var reader = new StreamReader(stream); + return Parse(reader.ReadToEnd()); + } +} diff --git a/src/nanoFramework.Tool/ExternalTools/nano-tools.json b/src/nanoFramework.Tool/ExternalTools/nano-tools.json new file mode 100644 index 0000000..026c160 --- /dev/null +++ b/src/nanoFramework.Tool/ExternalTools/nano-tools.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://nanoframework.net/schemas/nano-tools.schema.json", + "tools": [ + { + "name": "nanoff", + "version": "2.5.78", + "description": "nanoFramework firmware flasher.", + "packageId": "nanoff", + "source": "https://www.nuget.org/api/v2/package/nanoff/2.5.78" + } + ] +} diff --git a/src/nanoFramework.Tool/Program.cs b/src/nanoFramework.Tool/Program.cs new file mode 100644 index 0000000..9b26219 --- /dev/null +++ b/src/nanoFramework.Tool/Program.cs @@ -0,0 +1,70 @@ +// dotnet nano — the single umbrella CLI for nanoFramework. +// +// One entry point, two kinds of command: +// • built-in managed commands hosted in-proc over an engine library +// (migrate → NanoMigrate.Core, reusing the shared MigrateCommand verbatim; +// deploy / monitor / devices are registered placeholders for now), and +// • external prebuilt tools wrapped under the same namespace +// (flash → nanoff, resolved and run via the ExternalTools layer). +// +// This file is a thin Spectre.Console.Cli CommandApp host: it wires the command +// surface, descriptions and examples, surfaces exceptions cleanly, and maps the +// command result to the process exit code. + +using NanoFramework.Migrate.Cli.Commands; +using NanoFramework.Tool.Commands; +using Spectre.Console; +using Spectre.Console.Cli; + +var app = BuildApp(); + +// No arguments: show help and signal misuse (exit 1). +if (args.Length == 0) +{ + app.Run(new[] { "--help" }); + return 1; +} + +try +{ + return app.Run(args); +} +catch (Exception ex) +{ + // Spectre's parse/validation errors and any uncaught command error surface here + // (PropagateExceptions) and render cleanly without a stack trace. + AnsiConsole.MarkupLine($"[red]error:[/] {Markup.Escape(ex.Message)}"); + return 1; +} + +static CommandApp BuildApp() +{ + var app = new CommandApp(); + app.Configure(config => + { + config.SetApplicationName("nano"); + // Surface exceptions to the catch above so parse errors render cleanly. + config.PropagateExceptions(); + + // Built-in: migrate (in-proc, over NanoMigrate.Core via the shared command). + config.AddCommand("migrate") + .WithDescription("Convert legacy .nfproj projects to the SDK-style project system.") + .WithExample("migrate", "./samples", "--glob", "Beginner/**", "--dry-run"); + + // External: flash (wraps the prebuilt nanoff flasher). + config.AddCommand("flash") + .WithDescription("Flash device firmware (wraps the external nanoff tool).") + .WithExample("flash", "--target", "ESP32_REV0", "--port", "COM5"); + + // Built-in placeholders (command surface per the design; not yet in the CLI). + config.AddCommand("deploy") + .WithDescription("Deploy a built nanoFramework app to a device. (not yet implemented)"); + + config.AddCommand("monitor") + .WithDescription("Monitor device output. (not yet implemented)"); + + config.AddCommand("devices") + .WithDescription("List connected nanoFramework devices. (not yet implemented)"); + }); + return app; +} diff --git a/src/nanoFramework.Tool/nanoFramework.Tool.csproj b/src/nanoFramework.Tool/nanoFramework.Tool.csproj new file mode 100644 index 0000000..1cd47e8 --- /dev/null +++ b/src/nanoFramework.Tool/nanoFramework.Tool.csproj @@ -0,0 +1,54 @@ + + + + Exe + net8.0 + enable + enable + nano + NanoFramework.Tool + true + + false + + + + + true + nano + nanoFramework.Tool + nanoFramework CLI (dotnet nano) + The single `dotnet nano <command>` CLI for nanoFramework. Hosts built-in managed commands (migrate, deploy, monitor, devices) and wraps prebuilt external tools (e.g. nanoff via `flash`) under one uniform Spectre.Console UX. + nanoFramework + nanoFramework + https://github.com/nanoframework/nanoframework-sdk + https://github.com/nanoframework/nanoframework-sdk + git + MIT + nanoFramework;CLI;dotnet-tool;migrate;flash;nanoff;IoT;embedded;C# + Copyright (c) .NET Foundation and Contributors + false + + + + + + + + + + + + + + + + + + + diff --git a/test/nanoFramework.Tool.Tests/ExternalToolResolverTests.cs b/test/nanoFramework.Tool.Tests/ExternalToolResolverTests.cs new file mode 100644 index 0000000..0d42609 --- /dev/null +++ b/test/nanoFramework.Tool.Tests/ExternalToolResolverTests.cs @@ -0,0 +1,170 @@ +using NanoFramework.Tool.ExternalTools; +using Xunit; + +namespace NanoFramework.Tool.Tests; + +public class ExternalToolResolverTests +{ + private static readonly ToolManifestEntry Nanoff = new() + { + Name = "nanoff", + Version = "2.5.78", + Source = "https://example/nanoff/2.5.78", + }; + + // A pure in-memory IToolEnvironment: the set of "existing" files is explicit, so + // the resolution order can be exercised without a real filesystem, PATH, or network. + private sealed class FakeEnv : IToolEnvironment + { + private readonly HashSet _files; + + public FakeEnv(params string[] existingFiles) => + _files = new HashSet(existingFiles, StringComparer.OrdinalIgnoreCase); + + public string BaseDirectory => "/pkg"; + public string CacheDirectory => "/cache"; + public IReadOnlyList PathDirectories { get; init; } = new[] { "/usr/bin", "/usr/local/bin" }; + + // Mirror the non-Windows single-name behaviour so paths in tests are stable + // across the OS the suite runs on. + public IReadOnlyList ExecutableNamesFor(string toolName) => new[] { toolName }; + + public bool FileExists(string path) => _files.Contains(Norm(path)); + + private static string Norm(string p) => p.Replace('\\', '/'); + } + + // A spy downloader: records whether it was asked to fetch, and can "materialise" + // a cached binary to simulate a successful download. + private sealed class SpyDownloader(FakeEnvMutable? cache = null) : IToolDownloader + { + public int Calls { get; private set; } + private readonly FakeEnvMutable? _cache = cache; + + public bool Download(ToolManifestEntry entry, string targetDir) + { + Calls++; + if (_cache is null) return false; + _cache.Add($"{targetDir}/{entry.Name}"); + return true; + } + } + + // A mutable variant so the spy downloader can add the fetched file mid-resolve. + private sealed class FakeEnvMutable : IToolEnvironment + { + private readonly HashSet _files = new(StringComparer.OrdinalIgnoreCase); + public string BaseDirectory => "/pkg"; + public string CacheDirectory => "/cache"; + public IReadOnlyList PathDirectories => Array.Empty(); + public IReadOnlyList ExecutableNamesFor(string toolName) => new[] { toolName }; + public void Add(string path) => _files.Add(path.Replace('\\', '/')); + public bool FileExists(string path) => _files.Contains(path.Replace('\\', '/')); + } + + [Fact] + public void Bundled_wins_over_path_and_cache() + { + // Bundled, PATH, and cache all have the binary; bundled must win. + var env = new FakeEnv( + "/pkg/tools/nanoff/nanoff", + "/usr/bin/nanoff", + "/cache/nanoff/2.5.78/nanoff"); + var resolver = new ExternalToolResolver(env); + + var result = resolver.Resolve(Nanoff); + + Assert.NotNull(result); + Assert.Equal(ToolSource.Bundled, result!.Value.Source); + Assert.Equal("/pkg/tools/nanoff/nanoff", result.Value.ExecutablePath.Replace('\\', '/')); + } + + [Fact] + public void Path_wins_over_cache_when_not_bundled() + { + // No bundled binary: PATH must be preferred over the cache. + var env = new FakeEnv( + "/usr/local/bin/nanoff", + "/cache/nanoff/2.5.78/nanoff"); + var resolver = new ExternalToolResolver(env); + + var result = resolver.Resolve(Nanoff); + + Assert.NotNull(result); + Assert.Equal(ToolSource.InstalledOrPath, result!.Value.Source); + Assert.Equal("/usr/local/bin/nanoff", result.Value.ExecutablePath.Replace('\\', '/')); + } + + [Fact] + public void Cache_used_when_neither_bundled_nor_on_path() + { + // Only the pinned cache slot has the binary. + var env = new FakeEnv("/cache/nanoff/2.5.78/nanoff"); + var resolver = new ExternalToolResolver(env); + + var result = resolver.Resolve(Nanoff); + + Assert.NotNull(result); + Assert.Equal(ToolSource.Cache, result!.Value.Source); + } + + [Fact] + public void Unresolvable_returns_null_and_does_not_download_without_a_downloader() + { + // Nothing anywhere and no downloader: resolution fails cleanly (null). + var env = new FakeEnv(/* no files */); + var resolver = new ExternalToolResolver(env); + + Assert.Null(resolver.Resolve(Nanoff)); + } + + [Fact] + public void Download_is_attempted_only_after_the_first_three_steps_miss() + { + // Bundled present: the downloader must never be consulted. + var env = new FakeEnv("/pkg/tools/nanoff/nanoff"); + var spy = new SpyDownloader(); + var resolver = new ExternalToolResolver(env, spy); + + var result = resolver.Resolve(Nanoff); + + Assert.Equal(ToolSource.Bundled, result!.Value.Source); + Assert.Equal(0, spy.Calls); + } + + [Fact] + public void Download_materialises_into_cache_and_resolves_when_nothing_else_found() + { + // Nothing on disk; the downloader fetches into the cache and resolution then + // finds the freshly-materialised cache binary. + var env = new FakeEnvMutable(); + var spy = new SpyDownloader(env); + var resolver = new ExternalToolResolver(env, spy); + + var result = resolver.Resolve(Nanoff); + + Assert.Equal(1, spy.Calls); + Assert.NotNull(result); + Assert.Equal(ToolSource.Cache, result!.Value.Source); + Assert.Equal("/cache/nanoff/2.5.78/nanoff", result.Value.ExecutablePath.Replace('\\', '/')); + } + + [Fact] + public void Embedded_manifest_pins_nanoff() + { + // The shipped manifest is loadable from the tool assembly and pins nanoff. + var manifest = ToolManifest.LoadEmbedded(typeof(NanoffTool).Assembly); + + var entry = manifest.Find("nanoff"); + Assert.NotNull(entry); + Assert.False(string.IsNullOrWhiteSpace(entry!.Version)); + } + + [Fact] + public void NotImplemented_downloader_throws_a_clear_message() + { + var downloader = new NotImplementedToolDownloader(); + var ex = Assert.Throws(() => downloader.Download(Nanoff, "/cache/nanoff/2.5.78")); + Assert.Contains("nanoff", ex.Message); + } +} diff --git a/test/nanoFramework.Tool.Tests/nanoFramework.Tool.Tests.csproj b/test/nanoFramework.Tool.Tests/nanoFramework.Tool.Tests.csproj new file mode 100644 index 0000000..88750c7 --- /dev/null +++ b/test/nanoFramework.Tool.Tests/nanoFramework.Tool.Tests.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + NanoFramework.Tool.Tests + false + true + + + + + + + + + + + + + diff --git a/tools/NanoMigrate/NanoMigrate.sln b/tools/NanoMigrate/NanoMigrate.sln index d2c3de4..518f9b5 100644 --- a/tools/NanoMigrate/NanoMigrate.sln +++ b/tools/NanoMigrate/NanoMigrate.sln @@ -13,6 +13,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NanoMigrate.Tests", "tests\NanoMigrate.Tests\NanoMigrate.Tests.csproj", "{2EB1343E-3AF7-4584-AE5D-606ADF7C891E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NanoMigrate.Cli.Commands", "src\NanoMigrate.Cli.Commands\NanoMigrate.Cli.Commands.csproj", "{838A3335-D3A2-4B3A-A69E-038CCB8CAB49}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -59,6 +61,18 @@ Global {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Release|x64.Build.0 = Release|Any CPU {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Release|x86.ActiveCfg = Release|Any CPU {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Release|x86.Build.0 = Release|Any CPU + {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Debug|Any CPU.Build.0 = Debug|Any CPU + {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Debug|x64.ActiveCfg = Debug|Any CPU + {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Debug|x64.Build.0 = Debug|Any CPU + {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Debug|x86.ActiveCfg = Debug|Any CPU + {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Debug|x86.Build.0 = Debug|Any CPU + {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Release|Any CPU.ActiveCfg = Release|Any CPU + {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Release|Any CPU.Build.0 = Release|Any CPU + {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Release|x64.ActiveCfg = Release|Any CPU + {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Release|x64.Build.0 = Release|Any CPU + {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Release|x86.ActiveCfg = Release|Any CPU + {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -67,5 +81,6 @@ Global {AEBCAB7C-FF89-4263-A46D-5802D29DD602} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {6560B18A-C816-4E38-B604-C4E771FD6534} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {2EB1343E-3AF7-4584-AE5D-606ADF7C891E} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {838A3335-D3A2-4B3A-A69E-038CCB8CAB49} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/MigrateCommand.cs b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs similarity index 99% rename from tools/NanoMigrate/src/NanoMigrate.Cli/Cli/MigrateCommand.cs rename to tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs index e5c20f7..d8e119d 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/MigrateCommand.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs @@ -7,7 +7,7 @@ namespace NanoFramework.Migrate.Cli.Commands; -internal sealed class MigrateSettings : CommandSettings +public sealed class MigrateSettings : CommandSettings { [CommandArgument(0, "")] [Description("A .nfproj file, a solution (.sln/.slnx), or a directory. " @@ -65,7 +65,7 @@ public override ValidationResult Validate() }; } -internal sealed class MigrateCommand : Command +public sealed class MigrateCommand : Command { private readonly IProjectConverter _converter = new ProjectConverter(); diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj new file mode 100644 index 0000000..ea96a90 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj @@ -0,0 +1,39 @@ + + + + net8.0 + enable + enable + NanoFramework.Migrate.Cli + NanoMigrate.Cli.Commands + true + + + + + nanoFramework.Migrate.Cli.Commands + nanoFramework Migrate (shared Spectre command) + The shared `migrate` Spectre.Console.Cli command and its rendering, sitting over the nanoFramework.Migrate.Core engine. Referenced by both the standalone `nano-migrate` CLI and the `dotnet nano` umbrella tool so there is one MigrateCommand implementation. + nanoFramework + nanoFramework + https://github.com/nanoframework/nanoframework-sdk + https://github.com/nanoframework/nanoframework-sdk + git + MIT + nanoFramework;MSBuild;SDK;migration;nfproj;Spectre;IoT;embedded;C# + Copyright (c) .NET Foundation and Contributors + false + + + + + + + + + + + + + diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/ConsoleSupport.cs b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/ConsoleSupport.cs similarity index 96% rename from tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/ConsoleSupport.cs rename to tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/ConsoleSupport.cs index a3939d4..c039cd7 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/ConsoleSupport.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/ConsoleSupport.cs @@ -3,7 +3,7 @@ namespace NanoFramework.Migrate.Cli.Rendering; /// Shared, low-level console helpers. The only consumers of AnsiConsole. -internal static class ConsoleSupport +public static class ConsoleSupport { // A "title rule" header. Spectre renders this as a centred rule when the // terminal is wide enough, and degrades to plain text when redirected. diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/MigrateRenderer.cs b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs similarity index 98% rename from tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/MigrateRenderer.cs rename to tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs index c298149..1226af7 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/MigrateRenderer.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs @@ -6,10 +6,10 @@ namespace NanoFramework.Migrate.Cli.Rendering; /// Pairs a source .nfproj path with its conversion outcome. -internal readonly record struct ProjectOutcome(string Nfproj, ConvertResult Result); +public readonly record struct ProjectOutcome(string Nfproj, ConvertResult Result); /// Spectre presentation for the migrate command. Consumes Core's data. -internal static class MigrateRenderer +public static class MigrateRenderer { public static void RenderSummaryTable(List results, string baseDir, bool dryRun) { diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj b/tools/NanoMigrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj index 19ed62d..cdf4c0d 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj @@ -40,7 +40,10 @@ - + + From 87f26ee2b2313cda0ccbdc0c0ec837459f35f43a Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Tue, 16 Jun 2026 11:31:59 -0500 Subject: [PATCH 16/49] NanoMigrate: build-verification, user-confirmed rollback, and a clean command - Verify: after a real migration, build the affected solution(s)/project(s) via dotnet (SolutionBuilder behind IBuildRunner); --verify/--no-verify (on for real runs, off for dry-run). A failed build offers rollback. - Rollback: a git-independent journal (.nanomigrate/rollback-/ + manifest.json) backs up every file to be modified/deleted before any write; on verify failure it prompts (interactive) or advises `rollback ` (non-interactive, never auto-reverts); an explicit `rollback` command reverts the last recorded migration. Restores .nfproj/packages.config/AssemblyInfo/.sln byte-equal and deletes the generated .csproj. - Clean: `clean ` removes *.nfproj.bak and .nanomigrate/ leftovers (confirm / --yes). - Logic in Core (RollbackJournal, SolutionBuilder, BackupCleaner, Verification, MigrationJournaling); commands in Cli.Commands (CleanCommand, RollbackCommand, MigrateRegistration) shared by the standalone nano-migrate CLI and the nano umbrella. clean/rollback are top-level commands (Spectre.Cli 0.55 can't host a default/branch command that also takes a positional). Tests: +22 (91 total) + 8 umbrella, all green; both solutions build. Co-Authored-By: Claude Opus 4.8 --- src/nanoFramework.Tool/Program.cs | 7 +- .../NanoMigrate.Cli.Commands/CleanCommand.cs | 78 +++++ .../MigrateCommand.cs | 172 +++++++++- .../MigrateRegistration.cs | 37 +++ .../Rendering/MigrateRenderer.cs | 47 +++ .../RollbackCommand.cs | 65 ++++ .../src/NanoMigrate.Cli/Program.cs | 6 +- .../src/NanoMigrate.Core/BackupCleaner.cs | 119 +++++++ .../src/NanoMigrate.Core/ConversionOptions.cs | 15 + .../NanoMigrate.Core/MigrationJournaling.cs | 60 ++++ .../src/NanoMigrate.Core/RollbackJournal.cs | 305 ++++++++++++++++++ .../src/NanoMigrate.Core/SolutionBuilder.cs | 195 +++++++++++ .../src/NanoMigrate.Core/Verification.cs | 76 +++++ .../tests/NanoMigrate.Tests/CleanTests.cs | 87 +++++ .../tests/NanoMigrate.Tests/RollbackTests.cs | 159 +++++++++ .../tests/NanoMigrate.Tests/VerifyTests.cs | 155 +++++++++ 16 files changed, 1571 insertions(+), 12 deletions(-) create mode 100644 tools/NanoMigrate/src/NanoMigrate.Cli.Commands/CleanCommand.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateRegistration.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Cli.Commands/RollbackCommand.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/BackupCleaner.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/MigrationJournaling.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/RollbackJournal.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/SolutionBuilder.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/Verification.cs create mode 100644 tools/NanoMigrate/tests/NanoMigrate.Tests/CleanTests.cs create mode 100644 tools/NanoMigrate/tests/NanoMigrate.Tests/RollbackTests.cs create mode 100644 tools/NanoMigrate/tests/NanoMigrate.Tests/VerifyTests.cs diff --git a/src/nanoFramework.Tool/Program.cs b/src/nanoFramework.Tool/Program.cs index 9b26219..c9473f7 100644 --- a/src/nanoFramework.Tool/Program.cs +++ b/src/nanoFramework.Tool/Program.cs @@ -47,9 +47,10 @@ static CommandApp BuildApp() config.PropagateExceptions(); // Built-in: migrate (in-proc, over NanoMigrate.Core via the shared command). - config.AddCommand("migrate") - .WithDescription("Convert legacy .nfproj projects to the SDK-style project system.") - .WithExample("migrate", "./samples", "--glob", "Beginner/**", "--dry-run"); + // The shared registration adds `migrate` plus the sibling `clean` and + // `rollback` commands, so the umbrella `nano` matches `nano-migrate`. + MigrateRegistration.Add(config, + "Convert legacy .nfproj projects to the SDK-style project system."); // External: flash (wraps the prebuilt nanoff flasher). config.AddCommand("flash") diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/CleanCommand.cs b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/CleanCommand.cs new file mode 100644 index 0000000..c828049 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/CleanCommand.cs @@ -0,0 +1,78 @@ +using System.ComponentModel; +using NanoFramework.Migrate.Core; +using NanoFramework.Migrate.Cli.Rendering; +using Spectre.Console; +using Spectre.Console.Cli; +using static NanoFramework.Migrate.Cli.Rendering.ConsoleSupport; + +namespace NanoFramework.Migrate.Cli.Commands; + +public sealed class CleanSettings : AssumeYesSettings +{ + [CommandArgument(0, "[path]")] + [Description("A directory (or file) under which migration leftovers are removed: " + + "all *.nfproj.bak files and any .nanomigrate/ rollback folders. Defaults to the current directory.")] + public string Path { get; init; } = "."; +} + +/// +/// Removes migration leftovers under a path: every *.nfproj.bak and every +/// .nanomigrate rollback folder/journal. Previews what will go and confirms +/// before deleting (skip with --yes; non-interactive proceeds). Pure file +/// logic lives in ; this is the thin presentation/prompt. +/// +public sealed class CleanCommand : Command +{ + protected override int Execute(CommandContext context, CleanSettings settings, CancellationToken cancellationToken) + { + Header("NanoMigrate · clean"); + + var root = System.IO.Path.GetFullPath(settings.Path); + var plan = BackupCleaner.Plan(root); + + if (plan.IsEmpty) + { + AnsiConsole.MarkupLine($"[grey]nothing to clean under '{Esc(root)}'.[/]"); + return 0; + } + + // Show exactly what will be deleted before any prompt. + var tree = new Tree($"[bold]Leftovers under[/] [blue]{Esc(root)}[/]"); + if (plan.BackupFiles.Count > 0) + { + var node = tree.AddNode($"[yellow]{plan.BackupFiles.Count}[/] backup file(s)"); + foreach (var f in plan.BackupFiles) + node.AddNode($"[grey]{Esc(System.IO.Path.GetRelativePath(root, f))}[/]"); + } + if (plan.RollbackFolders.Count > 0) + { + var node = tree.AddNode($"[yellow]{plan.RollbackFolders.Count}[/] rollback folder(s)"); + foreach (var d in plan.RollbackFolders) + node.AddNode($"[grey]{Esc(System.IO.Path.GetRelativePath(root, d))}[/]"); + } + AnsiConsole.Write(tree); + AnsiConsole.WriteLine(); + + if (!Confirm($"Delete {plan.Total} leftover item(s)?", settings.AssumeYes)) + { + AnsiConsole.MarkupLine("[grey]aborted; nothing deleted.[/]"); + return 0; + } + + var result = BackupCleaner.Remove(plan); + foreach (var p in result.Problems) + AnsiConsole.MarkupLine($"[red]clean issue:[/] {Esc(p)}"); + + AnsiConsole.MarkupLine( + $"[green]removed {result.Total} item(s)[/] " + + $"([yellow]{result.RemovedBackups.Count}[/] backup file(s), " + + $"[yellow]{result.RemovedFolders.Count}[/] rollback folder(s))."); + return result.Problems.Count > 0 ? 1 : 0; + } + + private static bool Confirm(string question, bool assumeYes) + { + if (assumeYes || !IsInteractive()) return true; + return AnsiConsole.Confirm(question, defaultValue: false); + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs index d8e119d..4be55c9 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs @@ -7,7 +7,21 @@ namespace NanoFramework.Migrate.Cli.Commands; -public sealed class MigrateSettings : CommandSettings +/// +/// A small shared base so the migrate, clean and rollback +/// commands declare the common -y|--yes flag once. (The commands are sibling +/// top-level commands rather than subcommands of migrate because Spectre.Cli +/// cannot host a command that is both a branch/default AND takes a positional +/// argument, and migrate <path> must keep working.) +/// +public abstract class AssumeYesSettings : CommandSettings +{ + [CommandOption("-y|--yes")] + [Description("Skip interactive prompts: proceed with the default action. (Non-interactive runs never prompt regardless.)")] + public bool AssumeYes { get; init; } +} + +public sealed class MigrateSettings : AssumeYesSettings { [CommandArgument(0, "")] [Description("A .nfproj file, a solution (.sln/.slnx), or a directory. " @@ -43,14 +57,20 @@ public sealed class MigrateSettings : CommandSettings [Description("Only convert .nfproj whose path (relative to ) matches the glob; the solutions referencing any matched project are updated. Supports *, ** and ?. Example: \"Beginner/**\".")] public string? Glob { get; init; } - [CommandOption("-y|--yes")] - [Description("Skip interactive prompts (Proceed? / solution selection): select all affected solutions and proceed. (Non-interactive runs never prompt regardless.)")] - public bool AssumeYes { get; init; } + [CommandOption("--verify")] + [Description("After a real migration, build the affected solution(s)/project(s) to verify the result; a failed build offers a rollback. Default: on for real runs, off for --dry-run.")] + public bool Verify { get; init; } + + [CommandOption("--no-verify")] + [Description("Skip the post-migration build verification.")] + public bool NoVerify { get; init; } public override ValidationResult Validate() { if (Ext is not (".nfproj" or ".csproj")) return ValidationResult.Error("--ext must be .nfproj or .csproj"); + if (Verify && NoVerify) + return ValidationResult.Error("--verify and --no-verify are mutually exclusive"); return ValidationResult.Success(); } @@ -62,6 +82,9 @@ public override ValidationResult Validate() NoBackup = NoBackup, DryRun = DryRun, Glob = Glob, + // --verify forces on, --no-verify forces off, neither leaves the default + // (on for real runs, off for dry-run). + Verify = Verify ? true : NoVerify ? false : (bool?)null, }; } @@ -110,8 +133,24 @@ private int RunLoose(MigrateSettings settings, ConversionOptions o, MigrationPla if (!Confirm($"Proceed with {targets.Count} conversion(s)?", settings, o)) return AbortedExit(); + // Record a rollback journal (real runs only): back up every file the + // conversions will modify or delete BEFORE the converter touches disk. + var journal = OpenJournal(baseDir, o); + if (journal is not null) + RecordJournal(journal, targets, o, extraSolutions: null); + var results = ProcessProjects(targets, baseDir, o); - return Report(results, baseDir, o, rewritten: Array.Empty()); + journal?.Save(); + + var report = Report(results, baseDir, o, rewritten: Array.Empty()); + + // Verification targets in loose mode are the converted projects themselves + // (no host-owned solution set). A failed verify can roll the run back. + var verifyTargets = results + .Where(r => r.Result.Status is ConvertStatus.Converted or ConvertStatus.Review) + .Select(r => r.Result.OutputPath) + .ToList(); + return VerifyAndMaybeRollback(report, verifyTargets, journal, baseDir, settings, o); } // The solution-aware flow: pick the candidate solutions (multi-select / confirm), @@ -154,6 +193,13 @@ private int RunSolutionScoped(MigrateSettings settings, ConversionOptions o, Mig // The host owns solution retargeting here, so the converter must not also // walk up and rewrite solutions the user did not select. var convOpts = o with { SkipSolutionRewrite = true }; + + // Record a rollback journal (real runs only): back up the files the + // conversions touch PLUS the chosen solutions the host will rewrite itself. + var journal = OpenJournal(baseDir, o); + if (journal is not null) + RecordJournal(journal, targets, convOpts, extraSolutions: chosen.Select(c => c.Solution.Path)); + var results = ProcessProjects(targets, baseDir, convOpts); // Retarget exactly the chosen solutions to the converted projects. Only @@ -172,7 +218,121 @@ private int RunSolutionScoped(MigrateSettings settings, ConversionOptions o, Mig rewritten.Add(c.Solution.Path); } - return Report(results, baseDir, o, rewritten); + journal?.Save(); + var report = Report(results, baseDir, o, rewritten); + + // Verify by building exactly the chosen solutions. + var verifyTargets = chosen.Select(c => c.Solution.Path).ToList(); + return VerifyAndMaybeRollback(report, verifyTargets, journal, baseDir, settings, o); + } + + // Opens a rollback journal rooted at baseDir for real runs only; dry-runs return + // null (nothing is written, nothing to reverse). + private static RollbackJournal? OpenJournal(string baseDir, ConversionOptions o) => + o.DryRun ? null : RollbackJournal.Start(baseDir); + + // Backs up — into the journal — every file the conversions will modify or delete, + // and records the files they will create, by analysing each target with a dry-run + // preview pass FIRST (before any real write). extraSolutions are host-rewritten + // solutions to back up as well. + private void RecordJournal(RollbackJournal journal, List targets, ConversionOptions o, + IEnumerable? extraSolutions) + { + var dryOpts = o with { DryRun = true }; + var extras = extraSolutions?.ToList(); + foreach (var nf in targets) + { + ConvertResult preview; + try { preview = _converter.Convert(nf, dryOpts); } + catch { continue; } // a project that fails to analyse is converted (and reported) normally; just not journaled + if (preview.AlreadySdk) continue; + MigrationJournaling.Record(journal, preview, extras); + extras = null; // back up the host solutions once, not per project + } + } + + // Runs the verification build (when enabled) and, on failure, applies the rollback + // policy: prompt-and-revert interactively, or keep-and-advise non-interactively. + // Maps to the final exit code. When verification is off/clean, returns the + // already-computed migrate exit code unchanged. + private int VerifyAndMaybeRollback(int migrateExit, List verifyTargets, + RollbackJournal? journal, string baseDir, MigrateSettings settings, ConversionOptions o) + { + if (!o.VerifyEffective || verifyTargets.Count == 0) + return migrateExit; + + var builder = new SolutionBuilder(); + List outcomes; + bool toolMissing = false; + if (IsInteractive()) + { + List? captured = null; + AnsiConsole.Status().Spinner(Spinner.Known.Dots).Start("Verifying build…", ctx => + captured = builder.VerifyAll(verifyTargets, + t => ctx.Status($"Building [blue]{Esc(Path.GetFileName(t))}[/]…"), + () => toolMissing = true)); + outcomes = captured!; + } + else + { + outcomes = builder.VerifyAll(verifyTargets, null, () => toolMissing = true); + } + + if (toolMissing) + AnsiConsole.MarkupLine("[yellow]warning:[/] dotnet not found on PATH — build verification skipped."); + + MigrateRenderer.RenderVerifyTable(outcomes, baseDir); + + var verifyOutcome = Verification.Evaluate(outcomes); + if (verifyOutcome != VerifyOutcome.Failed) + return migrateExit; + + var failed = Verification.FailedCount(outcomes); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[red]Build verification failed for {failed} target(s).[/]"); + + var interactive = IsInteractive() && !settings.AssumeYes; + bool? answer = null; + if (interactive) + answer = AnsiConsole.Confirm( + $"Migration verification failed for {failed} target(s). Roll back all changes?", defaultValue: false); + else if (settings.AssumeYes) + // --yes is about skipping prompts to PROCEED, never to silently destroy a + // migration; a failed verify under --yes keeps the changes (advise rollback). + answer = false; + + var decision = Verification.Decide(verifyOutcome, interactive, answer); + switch (decision) + { + case RollbackDecision.RollBack: + ApplyRollback(journal, baseDir); + AnsiConsole.MarkupLine("[green]All changes rolled back.[/]"); + return 1; + case RollbackDecision.KeepInteractive: + AnsiConsole.MarkupLine( + $"[yellow]Changes kept.[/] Run [bold]rollback \"{Esc(baseDir)}\"[/] later to revert."); + return 1; + case RollbackDecision.KeepNonInteractive: + default: + AnsiConsole.MarkupLine( + $"[yellow]Changes kept (non-interactive).[/] Run [bold]rollback \"{Esc(baseDir)}\"[/] to revert."); + return 1; + } + } + + // Reverts the just-recorded run from the live journal (preferred), falling back to + // the latest saved manifest under baseDir. Removes the backup set on success. + private static void ApplyRollback(RollbackJournal? journal, string baseDir) + { + RollbackResult? result = null; + if (journal is not null && !journal.IsEmpty && File.Exists(journal.ManifestPath)) + result = RollbackJournal.ApplyAndCleanup(journal.ManifestPath); + if (result is null) + { + var latest = RollbackJournal.ManifestPaths(baseDir).FirstOrDefault(); + if (latest is not null) result = RollbackJournal.ApplyAndCleanup(latest); + } + if (result is not null) MigrateRenderer.RenderRollbackResult(result, baseDir); } // Decides which candidate solutions to operate on. Explicit single solution: no diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateRegistration.cs b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateRegistration.cs new file mode 100644 index 0000000..9aab362 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateRegistration.cs @@ -0,0 +1,37 @@ +using Spectre.Console.Cli; + +namespace NanoFramework.Migrate.Cli.Commands; + +/// +/// Registers the migrate command surface once, so BOTH front ends — the standalone +/// nano-migrate exe and the umbrella nano tool — expose the same verbs: +/// migrate (the conversion, with a positional <path>), plus the +/// sibling clean and rollback commands. +/// +/// These are top-level commands rather than subcommands of migrate on purpose: +/// Spectre.Console.Cli (0.55) cannot host a default/branch command that ALSO takes a +/// positional argument (the first token is parsed as a subcommand selector, breaking +/// migrate <path>), and preserving migrate <path> is required. +/// +public static class MigrateRegistration +{ + /// + /// Adds migrate, clean and rollback to . + /// is the host's command name (nano or + /// nano-migrate), used only to phrase examples. + /// + public static void Add(IConfigurator config, string migrateDescription) + { + config.AddCommand("migrate") + .WithDescription(migrateDescription + " Related: 'clean' and 'rollback'.") + .WithExample("migrate", "./samples", "--glob", "Beginner/**", "--dry-run"); + + config.AddCommand("clean") + .WithDescription("Remove migration leftovers (*.nfproj.bak files and .nanomigrate/ rollback folders) under a path.") + .WithExample("clean", "./samples", "--yes"); + + config.AddCommand("rollback") + .WithDescription("Revert the last recorded migration under a path (restore originals, delete created projects).") + .WithExample("rollback", "./samples"); + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs index 1226af7..aa41bce 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs @@ -144,6 +144,53 @@ private static string RelOrName(string baseDir, string path) return rel.StartsWith("..", StringComparison.Ordinal) ? Path.GetFileName(path) : rel; } + // The verification build results: one row per target with a Pass/Fail/Skipped + // badge and (on failure) a short error tail. Pure presentation over Core data. + public static void RenderVerifyTable(IReadOnlyList outcomes, string baseDir) + { + if (outcomes.Count == 0) return; + + var table = new Table().Border(TableBorder.Rounded).Expand(); + table.Title = new TableTitle("Build verification"); + table.AddColumn("Target"); + table.AddColumn("Result"); + table.AddColumn("Details"); + + foreach (var o in outcomes) + { + var (label, color) = o.Skipped ? ("Skipped", "grey") + : o.Succeeded ? ("Pass", "green") + : ("Fail", "red"); + var details = o.Skipped ? Esc(o.Message ?? "skipped") + : o.Succeeded ? "[grey]—[/]" + : $"[red]exit {o.ExitCode}[/]\n{Esc(Truncate(o.ErrorTail, 600))}"; + table.AddRow( + new Markup($"[blue]{Esc(RelOrName(baseDir, o.Target))}[/]"), + new Markup($"[{color}]{label}[/]"), + new Markup(details)); + } + AnsiConsole.WriteLine(); + AnsiConsole.Write(table); + } + + // The result of a rollback: what was restored/deleted (grouped notice). + public static void RenderRollbackResult(RollbackResult result, string baseDir) + { + AnsiConsole.WriteLine(); + foreach (var r in result.Restored) + AnsiConsole.MarkupLine($"[green]restored[/] {Esc(RelOrName(baseDir, r))}"); + foreach (var d in result.Deleted) + AnsiConsole.MarkupLine($"[yellow]removed[/] {Esc(RelOrName(baseDir, d))}"); + foreach (var p in result.Problems) + AnsiConsole.MarkupLine($"[red]rollback issue:[/] {Esc(p)}"); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[bold]rolled back {result.Total} file(s)[/] " + + $"([green]{result.Restored.Count} restored[/], [yellow]{result.Deleted.Count} removed[/])."); + } + + private static string Truncate(string s, int max) => + s.Length <= max ? s : s[..max] + "…"; + private static (string label, string color) StatusLabel(ConvertStatus s) => s switch { ConvertStatus.Converted => ("Converted", "green"), diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/RollbackCommand.cs b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/RollbackCommand.cs new file mode 100644 index 0000000..fbc12be --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/RollbackCommand.cs @@ -0,0 +1,65 @@ +using System.ComponentModel; +using NanoFramework.Migrate.Core; +using NanoFramework.Migrate.Cli.Rendering; +using Spectre.Console; +using Spectre.Console.Cli; +using static NanoFramework.Migrate.Cli.Rendering.ConsoleSupport; + +namespace NanoFramework.Migrate.Cli.Commands; + +public sealed class RollbackSettings : AssumeYesSettings +{ + [CommandArgument(0, "[path]")] + [Description("The directory whose last recorded migration is reverted (looks for a " + + ".nanomigrate/ journal under it). Defaults to the current directory.")] + public string Path { get; init; } = "."; +} + +/// +/// Reverts the last recorded migration under a path by reading its rollback journal +/// (restore backed-up originals, delete created files). Idempotent and safe: with no +/// journal it reports "nothing to roll back" and exits 0. The reversal logic lives in +/// ; this is the thin presentation/prompt. +/// +public sealed class RollbackCommand : Command +{ + protected override int Execute(CommandContext context, RollbackSettings settings, CancellationToken cancellationToken) + { + Header("NanoMigrate · rollback"); + + var root = System.IO.Path.GetFullPath(settings.Path); + var manifestPath = RollbackJournal.ManifestPaths(root).FirstOrDefault(); + if (manifestPath is null) + { + AnsiConsole.MarkupLine($"[grey]nothing to roll back under '{Esc(root)}' (no migration journal found).[/]"); + return 0; + } + + var manifest = RollbackJournal.FindLatest(root); + var count = manifest?.Entries.Count ?? 0; + AnsiConsole.MarkupLine( + $"Found a recorded migration ([blue]{Esc(manifest?.Id ?? "?")}[/]) with [bold]{count}[/] action(s)."); + + if (!Confirm("Roll back the last recorded migration?", settings.AssumeYes)) + { + AnsiConsole.MarkupLine("[grey]aborted; nothing reverted.[/]"); + return 0; + } + + var result = RollbackJournal.ApplyAndCleanup(manifestPath); + if (result is null) + { + AnsiConsole.MarkupLine("[red]error:[/] the rollback journal could not be read."); + return 1; + } + + MigrateRenderer.RenderRollbackResult(result, root); + return result.Problems.Count > 0 ? 1 : 0; + } + + private static bool Confirm(string question, bool assumeYes) + { + if (assumeYes || !IsInteractive()) return true; + return AnsiConsole.Confirm(question, defaultValue: false); + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Program.cs b/tools/NanoMigrate/src/NanoMigrate.Cli/Program.cs index 4f30bab..c6ce07f 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli/Program.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/Program.cs @@ -49,9 +49,9 @@ static CommandApp BuildApp(out CommandApp app) // errors render cleanly without a stack trace. config.PropagateExceptions(); - config.AddCommand("migrate") - .WithDescription("Convert a .nfproj, or every .nfproj under a directory.") - .WithExample("migrate", "./samples", "--glob", "Beginner/**", "--dry-run"); + // migrate + clean + rollback, registered once and shared with the umbrella tool. + MigrateRegistration.Add(config, + "Convert a .nfproj, or every .nfproj under a directory."); config.AddCommand("clone") .WithDescription("Clone all matching repos from a GitHub org.") diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/BackupCleaner.cs b/tools/NanoMigrate/src/NanoMigrate.Core/BackupCleaner.cs new file mode 100644 index 0000000..4a86a3a --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/BackupCleaner.cs @@ -0,0 +1,119 @@ +namespace NanoFramework.Migrate.Core; + +/// The leftovers a clean would remove, gathered for preview before deletion. +public sealed class CleanPlan +{ + /// The root the scan was rooted at (absolute). + public required string Root { get; init; } + + /// *.nfproj.bak files found under the root. + public List BackupFiles { get; } = new(); + + /// .nanomigrate rollback folders found under the root. + public List RollbackFolders { get; } = new(); + + /// Total leftovers (backup files + rollback folders). + public int Total => BackupFiles.Count + RollbackFolders.Count; + + /// True when there is nothing to remove. + public bool IsEmpty => Total == 0; +} + +/// The tally of an executed clean. +public sealed class CleanResult +{ + /// *.nfproj.bak files actually deleted. + public List RemovedBackups { get; } = new(); + + /// .nanomigrate folders actually removed. + public List RemovedFolders { get; } = new(); + + /// Non-fatal problems (a file that could not be deleted, etc.). + public List Problems { get; } = new(); + + /// Total leftovers removed. + public int Total => RemovedBackups.Count + RemovedFolders.Count; +} + +/// +/// Finds and removes migration leftovers under a path: every *.nfproj.bak +/// file and every .nanomigrate rollback folder/journal. Pure file logic — +/// the planning () and the removal () are +/// separate so a command can preview, confirm, then act. Idempotent and safe: a +/// tree with no leftovers yields an empty plan and a no-op clean. +/// +public static class BackupCleaner +{ + /// + /// Scans (recursively) for migration leftovers. A + /// non-existent root yields an empty plan. .nanomigrate folders are + /// reported as folders (their contents are not enumerated as individual files). + /// + public static CleanPlan Plan(string root) + { + var full = Path.GetFullPath(root); + var plan = new CleanPlan { Root = full }; + if (!Directory.Exists(full) && !File.Exists(full)) return plan; + + // A single file argument: only its sibling .bak makes sense; treat its dir as root. + var scanRoot = Directory.Exists(full) ? full : Path.GetDirectoryName(full)!; + + foreach (var bak in SafeEnumerateFiles(scanRoot, "*.nfproj.bak")) + plan.BackupFiles.Add(Path.GetFullPath(bak)); + + foreach (var dir in SafeEnumerateDirectories(scanRoot, RollbackJournal.FolderName)) + plan.RollbackFolders.Add(Path.GetFullPath(dir)); + + plan.BackupFiles.Sort(StringComparer.OrdinalIgnoreCase); + plan.RollbackFolders.Sort(StringComparer.OrdinalIgnoreCase); + return plan; + } + + /// + /// Removes everything in . Returns a tally; per-item + /// failures are collected as problems rather than aborting the sweep. + /// + public static CleanResult Remove(CleanPlan plan) + { + var result = new CleanResult(); + + foreach (var bak in plan.BackupFiles) + { + try + { + if (File.Exists(bak)) File.Delete(bak); + result.RemovedBackups.Add(bak); + } + catch (Exception ex) { result.Problems.Add($"could not delete {bak}: {ex.Message}"); } + } + + foreach (var dir in plan.RollbackFolders) + { + try + { + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + result.RemovedFolders.Add(dir); + } + catch (Exception ex) { result.Problems.Add($"could not remove {dir}: {ex.Message}"); } + } + + return result; + } + + private static IEnumerable SafeEnumerateFiles(string root, string pattern) + { + try { return Directory.EnumerateFiles(root, pattern, SearchOption.AllDirectories).ToList(); } + catch { return Array.Empty(); } + } + + private static IEnumerable SafeEnumerateDirectories(string root, string name) + { + try + { + return Directory.EnumerateDirectories(root, name, SearchOption.AllDirectories) + .Where(d => string.Equals(Path.GetFileName(d), name, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + catch { return Array.Empty(); } + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs b/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs index 84b4041..ff61874 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs @@ -46,6 +46,21 @@ public sealed record ConversionOptions /// public bool SkipSolutionRewrite { get; init; } + /// + /// After a real (non-dry-run) migration, verify the result by building the + /// affected solution(s)/project(s) with the dotnet CLI. null (the + /// default) means "verify on for real runs, off for dry-run"; set + /// true/false to force it on/off. A failed verification is what + /// triggers the rollback prompt. + /// + public bool? Verify { get; init; } + + /// + /// Resolves against : dry-run never + /// verifies; otherwise the default is on. + /// + public bool VerifyEffective => !DryRun && (Verify ?? true); + /// /// Central Package Management (CPM) override. null (the default) means /// auto-detect: the converter walks up from the project directory for a diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/MigrationJournaling.cs b/tools/NanoMigrate/src/NanoMigrate.Core/MigrationJournaling.cs new file mode 100644 index 0000000..f9af990 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/MigrationJournaling.cs @@ -0,0 +1,60 @@ +namespace NanoFramework.Migrate.Core; + +/// +/// Bridges a (dry-run) preview into a +/// : backs up every file the conversion will modify or +/// delete and records the files it will create. This must run BEFORE the real +/// conversion touches disk, so the backups hold the true originals. +/// +/// The converter already computes, in dry-run, exactly what a real run acts on: +/// (the .nfproj, packages.config and any +/// hand-written AssemblyInfo.cs it removes), +/// (the .csproj it creates) and / +/// (the files it rewrites). We +/// journal all of those. +/// +public static class MigrationJournaling +{ + /// + /// Records, into , the rollback entries implied by a + /// dry-run of converting one project. Optionally also + /// backs up the host-driven solutions in + /// (the solutions a solution-scoped run rewrites itself). + /// + public static void Record(RollbackJournal journal, ConvertResult preview, + IEnumerable? extraSolutionsToBackup = null) + { + // Files removed by the conversion (original .nfproj, packages.config, + // AssemblyInfo.cs): back up so they can be restored. + foreach (var deleted in preview.DeletedFiles) + journal.BackupBeforeChange(deleted); + + // Solutions the conversion rewrites: back up so the original references and + // GUIDs can be restored. + foreach (var sln in preview.UpdatedSolutions) + journal.BackupBeforeChange(sln); + + // A central Directory.Packages.props the conversion appends to: back up. + if (preview.UpdatedPackagesProps is not null) + journal.BackupBeforeChange(preview.UpdatedPackagesProps); + + // The .csproj the conversion creates (only when it differs from the source, + // i.e. it is a genuinely new file): record for deletion on rollback. + var output = Path.GetFullPath(preview.OutputPath); + var createsNewFile = !preview.DeletedFiles.Any(d => + string.Equals(Path.GetFullPath(d), output, StringComparison.OrdinalIgnoreCase)) + ? true + // When OutputPath equals a deleted source (same extension run) nothing new is created. + : false; + // The .csproj is "new" whenever it is not already the original being deleted. + if (createsNewFile && !File.Exists(output)) + journal.RecordCreated(output); + else if (createsNewFile) + // Output already exists on disk (e.g. a prior partial run): treat as modified. + journal.BackupBeforeChange(output); + + if (extraSolutionsToBackup is not null) + foreach (var sln in extraSolutionsToBackup) + journal.BackupBeforeChange(sln); + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/RollbackJournal.cs b/tools/NanoMigrate/src/NanoMigrate.Core/RollbackJournal.cs new file mode 100644 index 0000000..f82390f --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/RollbackJournal.cs @@ -0,0 +1,305 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace NanoFramework.Migrate.Core; + +/// +/// One recorded action in a rollback journal: either a file the migration CREATED +/// (which a rollback deletes) or a file it MODIFIED/DELETED whose original content +/// was backed up (which a rollback restores). +/// +public sealed class RollbackEntry +{ + /// The kind of action that was recorded. + public RollbackAction Action { get; set; } + + /// + /// The live (original) path the action concerns: the file that was created (for + /// ) or the file that was modified/deleted and + /// must be restored (for ). + /// + public string OriginalPath { get; set; } = ""; + + /// + /// For : the path of the backup copy that + /// holds the original bytes. Null for . + /// + public string? BackupPath { get; set; } +} + +/// What a recorded entry does on rollback. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum RollbackAction +{ + /// The migration created this file; rolling back deletes it. + Created, + + /// The migration modified or deleted this file; rolling back restores it from the backup. + Restore, +} + +/// +/// The on-disk manifest of a single migration run: an id, a timestamp, and the +/// ordered list of recorded actions. Serialized as JSON inside the run's backup +/// folder so a later rollback can reverse the run without git. +/// +public sealed class RollbackManifest +{ + /// The run id (also the backup folder name suffix). + public string Id { get; set; } = ""; + + /// When the run was started (UTC, round-trip format). + public string CreatedUtc { get; set; } = ""; + + /// The recorded actions, in the order they were registered. + public List Entries { get; set; } = new(); +} + +/// +/// Records — and reverses — the file-level effects of a migration run so a real +/// run is reversible without git. Before anything is changed the command asks the +/// journal to back up every file that will be modified or deleted and to remember +/// every file that will be created; the backups + a JSON manifest live under +/// .nanomigrate/rollback-<id>/ beneath a chosen root. A later rollback +/// reads the manifest, restores the backups, and deletes the created files. +/// +/// Pure file logic — no console. Idempotent and safe: restoring an already-restored +/// tree, or rolling back when there is no journal, is a clean no-op. +/// +public sealed class RollbackJournal +{ + /// The folder that holds all migration rollback sets (.nanomigrate). + public const string FolderName = ".nanomigrate"; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly RollbackManifest _manifest; + private int _backupSeq; + + /// The root the .nanomigrate folder is created under. + public string Root { get; } + + /// This run's id. + public string Id => _manifest.Id; + + /// The per-run set folder: <root>/.nanomigrate/rollback-<id>. + public string SetDirectory => Path.Combine(Root, FolderName, "rollback-" + _manifest.Id); + + /// The manifest file path inside . + public string ManifestPath => Path.Combine(SetDirectory, "manifest.json"); + + /// The recorded entries so far. + public IReadOnlyList Entries => _manifest.Entries; + + private RollbackJournal(string root, RollbackManifest manifest) + { + Root = root; + _manifest = manifest; + } + + /// + /// Starts a fresh journal rooted at . The backup folder is + /// created lazily on the first ; an empty run that + /// records nothing leaves no folder behind. + /// + public static RollbackJournal Start(string root) + { + var id = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss") + "-" + Guid.NewGuid().ToString("N")[..8]; + return new RollbackJournal(Path.GetFullPath(root), new RollbackManifest + { + Id = id, + CreatedUtc = DateTime.UtcNow.ToString("O"), + }); + } + + /// + /// Backs up the original bytes of a file that the migration is about to MODIFY or + /// DELETE, recording a restore entry. A missing source is ignored (nothing to + /// restore). Re-recording the same original path is a no-op so the FIRST backup — + /// the true original — always wins. + /// + public void BackupBeforeChange(string path) + { + var full = Path.GetFullPath(path); + if (!File.Exists(full)) return; + if (_manifest.Entries.Any(e => e.Action == RollbackAction.Restore + && PathEquals(e.OriginalPath, full))) + return; + + Directory.CreateDirectory(SetDirectory); + var backupName = $"{_backupSeq++:D4}-{Path.GetFileName(full)}.bak"; + var backupPath = Path.Combine(SetDirectory, backupName); + File.Copy(full, backupPath, overwrite: true); + + _manifest.Entries.Add(new RollbackEntry + { + Action = RollbackAction.Restore, + OriginalPath = full, + BackupPath = backupPath, + }); + } + + /// + /// Records that the migration CREATED (so a rollback + /// deletes it). Recording the same path twice is a no-op. + /// + public void RecordCreated(string path) + { + var full = Path.GetFullPath(path); + if (_manifest.Entries.Any(e => e.Action == RollbackAction.Created + && PathEquals(e.OriginalPath, full))) + return; + _manifest.Entries.Add(new RollbackEntry + { + Action = RollbackAction.Created, + OriginalPath = full, + }); + } + + /// True when nothing has been recorded yet. + public bool IsEmpty => _manifest.Entries.Count == 0; + + /// + /// Writes the manifest to disk so the run can later be rolled back. A journal that + /// recorded nothing writes nothing (no empty folder is left behind). + /// + public void Save() + { + if (IsEmpty) return; + Directory.CreateDirectory(SetDirectory); + File.WriteAllText(ManifestPath, JsonSerializer.Serialize(_manifest, JsonOpts), new UTF8Encoding(false)); + } + + /// + /// The newest saved manifest under 's .nanomigrate + /// folder, or null when none exists. "Newest" is by the manifest's recorded UTC + /// timestamp (folder names sort the same way, so this is stable). + /// + public static RollbackManifest? FindLatest(string root) + { + var sets = ManifestPaths(root); + RollbackManifest? newest = null; + foreach (var path in sets) + { + var m = TryLoadManifest(path); + if (m is null) continue; + if (newest is null + || string.Compare(m.CreatedUtc, newest.CreatedUtc, StringComparison.Ordinal) > 0) + newest = m; + } + return newest; + } + + /// Every saved manifest under , newest first. + public static IReadOnlyList ManifestPaths(string root) + { + var dir = Path.Combine(Path.GetFullPath(root), FolderName); + if (!Directory.Exists(dir)) return Array.Empty(); + return Directory.EnumerateFiles(dir, "manifest.json", SearchOption.AllDirectories) + .OrderByDescending(p => p, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static RollbackManifest? TryLoadManifest(string manifestPath) + { + try { return JsonSerializer.Deserialize(File.ReadAllText(manifestPath), JsonOpts); } + catch { return null; } + } + + /// + /// Reverses the run described by : created files are + /// deleted and modified/deleted files are restored from their backups. Restores + /// run before deletes so a restore can never be clobbered. Idempotent: a created + /// file already gone, or a restore target already matching its backup, is a no-op. + /// Returns a tally of what was reverted (and any per-entry problems). + /// + public static RollbackResult Apply(RollbackManifest manifest) + { + var result = new RollbackResult(); + + // Restore originals first (modified/deleted files), then remove created files. + foreach (var e in manifest.Entries.Where(e => e.Action == RollbackAction.Restore)) + { + try + { + if (e.BackupPath is null || !File.Exists(e.BackupPath)) + { + result.Problems.Add($"missing backup for {e.OriginalPath}"); + continue; + } + var dir = Path.GetDirectoryName(e.OriginalPath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + File.Copy(e.BackupPath, e.OriginalPath, overwrite: true); + result.Restored.Add(e.OriginalPath); + } + catch (Exception ex) + { + result.Problems.Add($"restore failed for {e.OriginalPath}: {ex.Message}"); + } + } + + foreach (var e in manifest.Entries.Where(e => e.Action == RollbackAction.Created)) + { + try + { + if (File.Exists(e.OriginalPath)) + { + File.Delete(e.OriginalPath); + result.Deleted.Add(e.OriginalPath); + } + } + catch (Exception ex) + { + result.Problems.Add($"delete failed for {e.OriginalPath}: {ex.Message}"); + } + } + + return result; + } + + /// + /// Loads the manifest at , reverses it, and on + /// success removes its backup set folder. Returns the result, or null when the + /// manifest could not be read. + /// + public static RollbackResult? ApplyAndCleanup(string manifestPath) + { + var manifest = TryLoadManifest(manifestPath); + if (manifest is null) return null; + var result = Apply(manifest); + + // Remove the run's backup set once reverted (best-effort). + try + { + var setDir = Path.GetDirectoryName(manifestPath); + if (setDir is not null && Directory.Exists(setDir)) Directory.Delete(setDir, recursive: true); + } + catch { /* leave the set if it cannot be removed; clean can mop up later */ } + + return result; + } + + private static bool PathEquals(string a, string b) => + string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase); +} + +/// The tally of a rollback: files restored, files deleted, and any problems. +public sealed class RollbackResult +{ + /// Original files restored from backups. + public List Restored { get; } = new(); + + /// Created files removed. + public List Deleted { get; } = new(); + + /// Non-fatal problems encountered per entry (a missing backup, etc.). + public List Problems { get; } = new(); + + /// Total files touched (restored + deleted). + public int Total => Restored.Count + Deleted.Count; +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionBuilder.cs b/tools/NanoMigrate/src/NanoMigrate.Core/SolutionBuilder.cs new file mode 100644 index 0000000..9ac07f7 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/SolutionBuilder.cs @@ -0,0 +1,195 @@ +using System.Diagnostics; + +namespace NanoFramework.Migrate.Core; + +/// +/// The outcome of verifying one target (a solution or a project) by building it. +/// +public sealed class BuildOutcome +{ + /// The solution/project that was built (absolute path). + public required string Target { get; init; } + + /// True when the build succeeded (exit code 0). + public bool Succeeded { get; init; } + + /// + /// True when the build could not be attempted at all — e.g. the dotnet + /// CLI is not on PATH. Such a target is reported as skipped, not failed, + /// and never triggers rollback. + /// + public bool Skipped { get; init; } + + /// The process exit code (or a synthetic non-zero when skipped/errored). + public int ExitCode { get; init; } + + /// A short tail of the build output, surfaced on failure for diagnosis. + public string ErrorTail { get; init; } = ""; + + /// A skipped/setup message (e.g. "dotnet not found on PATH"). + public string? Message { get; init; } +} + +/// +/// Verifies a migration by building the affected solution(s)/project(s) with the +/// dotnet CLI on PATH. Captures the exit code and a short error tail so the +/// command can render pass/fail and decide whether to offer a rollback. +/// +/// The build invocation is isolated behind so the +/// result interpretation () is unit-testable without a real +/// build. The absence of dotnet is tolerated: the target is marked skipped +/// (a warning), never failed. +/// +public sealed class SolutionBuilder +{ + /// How many trailing non-empty output lines to keep as the error tail. + public const int ErrorTailLines = 12; + + private readonly IBuildRunner _runner; + + /// Builds via the real dotnet CLI on PATH. + public SolutionBuilder() : this(new DotnetBuildRunner()) { } + + /// Builds via the supplied runner (used by tests to fake the process). + public SolutionBuilder(IBuildRunner runner) => _runner = runner; + + /// + /// Builds every target and returns one each. When + /// dotnet is unavailable, every target is reported skipped and + /// (if given) is invoked once. + /// + public List VerifyAll(IEnumerable targets, Action? onProgress = null, + Action? onSkippedToolMissing = null) + { + var results = new List(); + if (!_runner.IsAvailable) + { + onSkippedToolMissing?.Invoke(); + foreach (var t in targets) + results.Add(new BuildOutcome + { + Target = Path.GetFullPath(t), + Skipped = true, + ExitCode = -1, + Message = "dotnet not found on PATH; verification skipped", + }); + return results; + } + + foreach (var t in targets) + { + var full = Path.GetFullPath(t); + onProgress?.Invoke(full); + var (code, stdout, stderr) = _runner.Build(full); + results.Add(Interpret(full, code, stdout, stderr)); + } + return results; + } + + /// + /// Turns a raw build result (exit code + output streams) into a + /// . Pure and side-effect-free, so the pass/fail and + /// error-tail logic can be unit-tested without spawning a process. + /// + public static BuildOutcome Interpret(string target, int exitCode, string stdout, string stderr) + { + var succeeded = exitCode == 0; + return new BuildOutcome + { + Target = Path.GetFullPath(target), + Succeeded = succeeded, + ExitCode = exitCode, + ErrorTail = succeeded ? "" : Tail(stdout, stderr, ErrorTailLines), + }; + } + + /// + /// The last non-empty lines of the build output, + /// preferring lines that look like errors/warnings when there are many. Joined + /// with newlines; empty when both streams are blank. + /// + public static string Tail(string stdout, string stderr, int lines) + { + var all = (stdout ?? "") + .Replace("\r\n", "\n").Split('\n') + .Concat((stderr ?? "").Replace("\r\n", "\n").Split('\n')) + .Select(l => l.TrimEnd()) + .Where(l => l.Length > 0) + .ToList(); + if (all.Count == 0) return ""; + + // Prefer error/warning lines if present; otherwise just the tail. + var errs = all.Where(l => l.Contains("error", StringComparison.OrdinalIgnoreCase) + || l.Contains(": error", StringComparison.OrdinalIgnoreCase)).ToList(); + var pick = errs.Count > 0 ? errs : all; + return string.Join("\n", pick.Skip(Math.Max(0, pick.Count - lines))); + } +} + +/// +/// Abstracts the actual dotnet build invocation so the result interpretation +/// is testable without a real build. +/// +public interface IBuildRunner +{ + /// True when a build can be attempted (the dotnet CLI is on PATH). + bool IsAvailable { get; } + + /// Builds , returning the exit code and output streams. + (int exitCode, string stdout, string stderr) Build(string target); +} + +/// The production runner: shells the dotnet already on PATH. +public sealed class DotnetBuildRunner : IBuildRunner +{ + private readonly Lazy _available = new(ProbeDotnet); + + public bool IsAvailable => _available.Value; + + public (int exitCode, string stdout, string stderr) Build(string target) + { + var dir = Path.GetDirectoryName(Path.GetFullPath(target)) ?? "."; + var psi = new ProcessStartInfo("dotnet", $"build \"{target}\" --nologo") + { + WorkingDirectory = dir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + try + { + using var p = Process.Start(psi)!; + var so = p.StandardOutput.ReadToEnd(); + var se = p.StandardError.ReadToEnd(); + p.WaitForExit(); + return (p.ExitCode, so, se); + } + catch (Exception ex) + { + return (-1, "", ex.Message); + } + } + + // True when `dotnet --version` runs and exits 0. Cached for the runner's lifetime. + private static bool ProbeDotnet() + { + try + { + var psi = new ProcessStartInfo("dotnet", "--version") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + using var p = Process.Start(psi); + if (p is null) return false; + p.StandardOutput.ReadToEnd(); + p.StandardError.ReadToEnd(); + p.WaitForExit(); + return p.ExitCode == 0; + } + catch { return false; } + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Verification.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Verification.cs new file mode 100644 index 0000000..cb78ecb --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Verification.cs @@ -0,0 +1,76 @@ +namespace NanoFramework.Migrate.Core; + +/// What the command should do after the verification build(s) ran. +public enum VerifyOutcome +{ + /// Verification was not run (dry-run, --no-verify, or no targets). + NotRun, + + /// Every attempted target built (some may have been skipped). + Passed, + + /// At least one target failed to build — a rollback should be considered. + Failed, +} + +/// +/// The pure decision logic that turns a set of into a +/// verify outcome and, when it failed, into the rollback action the command should +/// take. Kept free of console/prompts so the failed-verify → rollback decision path +/// is unit-testable: the command renders/prompts around these answers. +/// +public static class Verification +{ + /// + /// The outcome of a verification pass. A failed target (built and returned + /// non-zero) means ; targets that were only + /// skipped (no dotnet) never count as failures. + /// + public static VerifyOutcome Evaluate(IReadOnlyList outcomes) + { + if (outcomes.Count == 0) return VerifyOutcome.NotRun; + if (outcomes.All(o => o.Skipped)) return VerifyOutcome.NotRun; + return outcomes.Any(o => !o.Succeeded && !o.Skipped) + ? VerifyOutcome.Failed + : VerifyOutcome.Passed; + } + + /// The number of targets that actually failed to build. + public static int FailedCount(IReadOnlyList outcomes) => + outcomes.Count(o => !o.Succeeded && !o.Skipped); + + /// + /// The rollback decision after a verification pass, given whether the session is + /// interactive and (for the interactive case) the user's yes/no answer supplied + /// as . This is the pure policy: + /// + /// verify did not fail → ; + /// failed + interactive + user said yes → ; + /// failed + interactive + user said no → ; + /// failed + non-interactive → + /// (never auto-roll-back silently; tell the user to run rollback). + /// + /// + public static RollbackDecision Decide(VerifyOutcome outcome, bool interactive, bool? userSaidYes) + { + if (outcome != VerifyOutcome.Failed) return RollbackDecision.None; + if (!interactive) return RollbackDecision.KeepNonInteractive; + return userSaidYes == true ? RollbackDecision.RollBack : RollbackDecision.KeepInteractive; + } +} + +/// What the command does about rollback after a verification. +public enum RollbackDecision +{ + /// No rollback needed (verification passed or did not run). + None, + + /// Roll back: restore originals and delete created files. + RollBack, + + /// Verification failed but the user (interactively) chose to keep the changes. + KeepInteractive, + + /// Verification failed in a non-interactive run: keep changes, exit non-zero, advise rollback. + KeepNonInteractive, +} diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/CleanTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/CleanTests.cs new file mode 100644 index 0000000..948f2a2 --- /dev/null +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/CleanTests.cs @@ -0,0 +1,87 @@ +using NanoFramework.Migrate.Core; +using Xunit; + +namespace NanoFramework.Migrate.Tests; + +public class CleanTests +{ + private static readonly IProjectConverter Converter = new ProjectConverter(); + + private const string Nfproj = """ + + Sample + + """; + + [Fact] + public void Clean_removes_all_nfproj_bak_and_nanomigrate_folders_and_reports_count() + { + using var dir = new TempDir(); + + // A real conversion leaves a Sample.nfproj.bak behind. + var nfproj = dir.File("a/Sample.nfproj", Nfproj); + Converter.Convert(nfproj, new ConversionOptions()); + Assert.True(File.Exists(nfproj + ".bak")); + + // A second, nested project's backup, plus a rollback journal folder. + var nfproj2 = dir.File("b/Other.nfproj", Nfproj); + Converter.Convert(nfproj2, new ConversionOptions()); + var journal = RollbackJournal.Start(dir.Path); + journal.BackupBeforeChange(dir.File("c/some.txt", "x")); + journal.Save(); + Assert.True(Directory.Exists(Path.Combine(dir.Path, RollbackJournal.FolderName))); + + var plan = BackupCleaner.Plan(dir.Path); + Assert.Equal(2, plan.BackupFiles.Count); + Assert.Single(plan.RollbackFolders); + Assert.Equal(3, plan.Total); + + var result = BackupCleaner.Remove(plan); + Assert.Empty(result.Problems); + Assert.Equal(3, result.Total); + + // Everything is gone; a re-plan finds nothing (idempotent). + Assert.False(File.Exists(nfproj + ".bak")); + Assert.False(File.Exists(nfproj2 + ".bak")); + Assert.False(Directory.Exists(Path.Combine(dir.Path, RollbackJournal.FolderName))); + Assert.True(BackupCleaner.Plan(dir.Path).IsEmpty); + } + + [Fact] + public void Clean_on_a_tree_with_no_leftovers_is_an_empty_noop() + { + using var dir = new TempDir(); + dir.File("Sample.csproj", ""); + + var plan = BackupCleaner.Plan(dir.Path); + Assert.True(plan.IsEmpty); + + var result = BackupCleaner.Remove(plan); + Assert.Equal(0, result.Total); + Assert.Empty(result.Problems); + } + + [Fact] + public void Clean_on_nonexistent_path_yields_empty_plan() + { + var plan = BackupCleaner.Plan(Path.Combine(Path.GetTempPath(), "nanomig-does-not-exist-" + Guid.NewGuid())); + Assert.True(plan.IsEmpty); + } + + [Fact] + public void Clean_does_not_touch_unrelated_files() + { + using var dir = new TempDir(); + var keep = dir.File("Keep.nfproj", Nfproj); // a live project, NOT a .bak + var keepCs = dir.File("Sample.csproj", ""); + dir.File("Sample.nfproj.bak", "backup"); // a leftover to remove + + var plan = BackupCleaner.Plan(dir.Path); + Assert.Single(plan.BackupFiles); + BackupCleaner.Remove(plan); + + Assert.True(File.Exists(keep)); + Assert.True(File.Exists(keepCs)); + Assert.False(File.Exists(dir.Combine("Sample.nfproj.bak"))); + } +} diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/RollbackTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/RollbackTests.cs new file mode 100644 index 0000000..5390e14 --- /dev/null +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/RollbackTests.cs @@ -0,0 +1,159 @@ +using System.Text; +using NanoFramework.Migrate.Core; +using Xunit; + +namespace NanoFramework.Migrate.Tests; + +public class RollbackTests +{ + private static readonly IProjectConverter Converter = new ProjectConverter(); + + private const string Nfproj = """ + + + {AAAAAAAA-0000-0000-0000-000000000001} + Sample + + + + + + """; + + private const string PackagesConfig = """ + + + + + """; + + private const string AssemblyInfo = """ + using System.Reflection; + [assembly: AssemblyTitle("Sample")] + """; + + private static string SolutionText() => """ + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{11A8DD76-328B-46DF-9F39-F559912D0360}") = "Sample", "Sample.nfproj", "{AAAAAAAA-0000-0000-0000-000000000001}" + EndProject + Global + EndGlobal + """; + + // Mirrors what MigrateCommand does on a real run: dry-run preview FIRST (to know + // what changes), back up / record into the journal, then the real conversion. + private static RollbackJournal MigrateWithJournal(string root, string nfproj, ConversionOptions o) + { + var journal = RollbackJournal.Start(root); + var preview = Converter.Convert(nfproj, o with { DryRun = true }); + MigrationJournaling.Record(journal, preview); + Converter.Convert(nfproj, o); + journal.Save(); + return journal; + } + + [Fact] + public void Rollback_restores_nfproj_packages_assemblyinfo_and_sln_byte_for_byte_and_deletes_csproj() + { + using var dir = new TempDir(); + var nfproj = dir.File("Sample.nfproj", Nfproj); + var pc = dir.File("packages.config", PackagesConfig); + var ai = dir.File(Path.Combine("Properties", "AssemblyInfo.cs"), AssemblyInfo); + var sln = dir.File("Sample.sln", SolutionText()); + var csproj = dir.Combine("Sample.csproj"); + + // Capture the exact original bytes so the round-trip can be proven byte-equal. + var nfprojBytes = File.ReadAllBytes(nfproj); + var pcBytes = File.ReadAllBytes(pc); + var aiBytes = File.ReadAllBytes(ai); + var slnBytes = File.ReadAllBytes(sln); + + var journal = MigrateWithJournal(dir.Path, nfproj, new ConversionOptions()); + + // The migration happened: .csproj created, originals gone, .sln retargeted. + Assert.True(File.Exists(csproj)); + Assert.False(File.Exists(nfproj)); + Assert.False(File.Exists(pc)); + Assert.False(File.Exists(ai)); + Assert.Contains("Sample.csproj", File.ReadAllText(sln)); + + // Roll the run back from its saved manifest. + var result = RollbackJournal.ApplyAndCleanup(journal.ManifestPath); + Assert.NotNull(result); + Assert.Empty(result!.Problems); + + // The created .csproj is gone. + Assert.False(File.Exists(csproj)); + + // Every original is restored byte-for-byte. + Assert.True(File.Exists(nfproj)); + Assert.Equal(nfprojBytes, File.ReadAllBytes(nfproj)); + Assert.True(File.Exists(pc)); + Assert.Equal(pcBytes, File.ReadAllBytes(pc)); + Assert.True(File.Exists(ai)); + Assert.Equal(aiBytes, File.ReadAllBytes(ai)); + Assert.True(File.Exists(sln)); + Assert.Equal(slnBytes, File.ReadAllBytes(sln)); + } + + [Fact] + public void Rollback_is_safe_with_no_journal() + { + using var dir = new TempDir(); + + // No .nanomigrate folder exists → nothing to roll back. + Assert.Empty(RollbackJournal.ManifestPaths(dir.Path)); + Assert.Null(RollbackJournal.FindLatest(dir.Path)); + } + + [Fact] + public void Apply_is_idempotent_second_apply_is_a_noop() + { + using var dir = new TempDir(); + var nfproj = dir.File("Sample.nfproj", Nfproj); + dir.File("packages.config", PackagesConfig); + var nfprojBytes = File.ReadAllBytes(nfproj); + + var journal = MigrateWithJournal(dir.Path, nfproj, new ConversionOptions()); + var manifest = RollbackJournal.FindLatest(dir.Path)!; + + var first = RollbackJournal.Apply(manifest); + Assert.Empty(first.Problems); + Assert.Equal(nfprojBytes, File.ReadAllBytes(nfproj)); // restored + + // A second apply over the already-restored tree is a clean no-op (the created + // file is already gone; the restore just rewrites identical bytes). + var second = RollbackJournal.Apply(manifest); + Assert.Empty(second.Problems); + Assert.Equal(nfprojBytes, File.ReadAllBytes(nfproj)); + } + + [Fact] + public void Empty_journal_writes_nothing() + { + using var dir = new TempDir(); + var journal = RollbackJournal.Start(dir.Path); + Assert.True(journal.IsEmpty); + journal.Save(); + Assert.False(Directory.Exists(Path.Combine(dir.Path, RollbackJournal.FolderName))); + } + + [Fact] + public void BackupBeforeChange_keeps_the_first_original_when_recorded_twice() + { + using var dir = new TempDir(); + var file = dir.File("a.txt", "original"); + var journal = RollbackJournal.Start(dir.Path); + + journal.BackupBeforeChange(file); + File.WriteAllText(file, "changed"); + journal.BackupBeforeChange(file); // must NOT overwrite the first backup + + journal.Save(); + var manifest = RollbackJournal.FindLatest(dir.Path)!; + RollbackJournal.Apply(manifest); + + // The very first backup (the true original) is what gets restored. + Assert.Equal("original", File.ReadAllText(file)); + } +} diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/VerifyTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/VerifyTests.cs new file mode 100644 index 0000000..1d7b8ed --- /dev/null +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/VerifyTests.cs @@ -0,0 +1,155 @@ +using NanoFramework.Migrate.Core; +using Xunit; + +namespace NanoFramework.Migrate.Tests; + +public class VerifyTests +{ + // A fake build runner so the result/decision logic is testable without spawning a + // real build: it returns a scripted (exit code, stdout, stderr) per target. + private sealed class FakeRunner : IBuildRunner + { + private readonly Dictionary _byName; + public bool IsAvailable { get; } + + public FakeRunner(bool available, Dictionary? byName = null) + { + IsAvailable = available; + _byName = byName ?? new(); + } + + public (int exitCode, string stdout, string stderr) Build(string target) + { + var key = Path.GetFileName(target); + return _byName.TryGetValue(key, out var r) ? r : (0, "ok", ""); + } + } + + [Fact] + public void Interpret_success_has_no_error_tail() + { + var outcome = SolutionBuilder.Interpret("Foo.sln", 0, "Build succeeded", ""); + Assert.True(outcome.Succeeded); + Assert.False(outcome.Skipped); + Assert.Equal("", outcome.ErrorTail); + } + + [Fact] + public void Interpret_failure_captures_error_tail() + { + var outcome = SolutionBuilder.Interpret("Foo.sln", 1, + "line1\nFoo.cs(3,5): error CS1002: ; expected\nBuild FAILED", ""); + Assert.False(outcome.Succeeded); + Assert.Equal(1, outcome.ExitCode); + Assert.Contains("error CS1002", outcome.ErrorTail); + } + + [Fact] + public void Tail_prefers_error_lines_and_caps_length() + { + var stdout = string.Join("\n", Enumerable.Range(0, 50).Select(i => $"info line {i}")) + + "\nProgram.cs(1,1): error CS0103: missing\n"; + var tail = SolutionBuilder.Tail(stdout, "", SolutionBuilder.ErrorTailLines); + Assert.Contains("error CS0103", tail); + Assert.True(tail.Split('\n').Length <= SolutionBuilder.ErrorTailLines); + } + + [Fact] + public void VerifyAll_when_dotnet_missing_marks_every_target_skipped_and_signals_once() + { + var builder = new SolutionBuilder(new FakeRunner(available: false)); + var signalled = 0; + var outcomes = builder.VerifyAll(new[] { "A.sln", "B.sln" }, onSkippedToolMissing: () => signalled++); + + Assert.Equal(2, outcomes.Count); + Assert.All(outcomes, o => Assert.True(o.Skipped)); + Assert.Equal(1, signalled); + Assert.Equal(VerifyOutcome.NotRun, Verification.Evaluate(outcomes)); + } + + [Fact] + public void VerifyAll_mixed_results_evaluate_to_failed() + { + var runner = new FakeRunner(available: true, new() + { + ["Good.sln"] = (0, "Build succeeded", ""), + ["Bad.sln"] = (1, "X.cs: error CS1: boom", ""), + }); + var outcomes = new SolutionBuilder(runner).VerifyAll(new[] { "Good.sln", "Bad.sln" }); + + Assert.True(outcomes[0].Succeeded); + Assert.False(outcomes[1].Succeeded); + Assert.Equal(VerifyOutcome.Failed, Verification.Evaluate(outcomes)); + Assert.Equal(1, Verification.FailedCount(outcomes)); + } + + [Fact] + public void VerifyAll_all_pass_evaluates_to_passed() + { + var runner = new FakeRunner(available: true, new() + { + ["A.sln"] = (0, "ok", ""), + ["B.sln"] = (0, "ok", ""), + }); + var outcomes = new SolutionBuilder(runner).VerifyAll(new[] { "A.sln", "B.sln" }); + Assert.Equal(VerifyOutcome.Passed, Verification.Evaluate(outcomes)); + } + + // The failed-verify -> rollback DECISION path (pure policy, no prompt). + [Theory] + [InlineData(VerifyOutcome.Passed, true, true, RollbackDecision.None)] + [InlineData(VerifyOutcome.NotRun, true, true, RollbackDecision.None)] + [InlineData(VerifyOutcome.Failed, true, true, RollbackDecision.RollBack)] + [InlineData(VerifyOutcome.Failed, true, false, RollbackDecision.KeepInteractive)] + [InlineData(VerifyOutcome.Failed, false, null, RollbackDecision.KeepNonInteractive)] + public void Decide_maps_outcome_and_answer_to_action( + VerifyOutcome outcome, bool interactive, bool? userSaidYes, RollbackDecision expected) + { + Assert.Equal(expected, Verification.Decide(outcome, interactive, userSaidYes)); + } + + [Fact] + public void NonInteractive_failed_verify_never_rolls_back_even_if_yes() + { + // Non-interactive runs must never auto-roll-back silently. + Assert.Equal(RollbackDecision.KeepNonInteractive, + Verification.Decide(VerifyOutcome.Failed, interactive: false, userSaidYes: true)); + } + + // A real dotnet build over a trivial buildable vs. an unbuildable project. Skipped + // (asserted no-op) when the dotnet CLI is not on PATH so the suite is portable. + [Fact] + public void Real_build_passes_for_a_buildable_project_and_fails_for_a_broken_one() + { + var runner = new DotnetBuildRunner(); + if (!runner.IsAvailable) return; // no dotnet → nothing to assert (tolerated) + + var builder = new SolutionBuilder(runner); + + using var good = new TempDir(); + var goodProj = good.File("Good.csproj", """ + + + net8.0 + + + """); + good.File("Program.cs", "class P { static void M() { } }"); + var goodOutcome = builder.VerifyAll(new[] { goodProj }); + Assert.True(goodOutcome[0].Succeeded, goodOutcome[0].ErrorTail); + + using var bad = new TempDir(); + var badProj = bad.File("Bad.csproj", """ + + + net8.0 + + + """); + bad.File("Program.cs", "class P { this is not valid C# ;;; }"); + var badOutcome = builder.VerifyAll(new[] { badProj }); + Assert.False(badOutcome[0].Succeeded); + Assert.NotEqual("", badOutcome[0].ErrorTail); + Assert.Equal(VerifyOutcome.Failed, Verification.Evaluate(badOutcome)); + } +} From d72da89f91f78e46f3223ed1de551e0f549b76a3 Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Tue, 16 Jun 2026 11:56:21 -0500 Subject: [PATCH 17/49] NanoMigrate: read/write solutions via Microsoft.VisualStudio.SolutionPersistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt the official .NET Foundation solution library (1.0.52) for .sln AND .slnx, so we no longer hand-maintain solution parsing/writing: - SolutionFile reads both formats via SolutionSerializers (stream-based), exposing the same surface (ProjectPaths, NanoProjects, Format). SolutionRewriter retargets by mutating the SolutionModel (path .nfproj->.csproj; SDK C# type GUID on classic .sln; cleared on .slnx) and re-serializing — the hand-rolled regex/line/text editors are deleted. Idempotent no-op returns original bytes. - Compatibility shim: the .slnx serializer rejects a typeless for the unknown .nfproj extension, so we inject the legacy nano type GUID and retry; VS-authored .slnx parse directly. Project files (.csproj/.nfproj) intentionally keep the controlled emit / XElement read: adopting Microsoft.Build in a self-contained net8.0 tool is fragile (MSBuildLocator finds no MSBuild against an SDK-only host) and shipping its runtime assemblies pulls a high-severity advisory + a heavy toolset. The csproj emitter we own is small and low-maintenance. Tests: +3 round-trip (parse -> retarget -> reparse sees .csproj, classic + slnx + VS-typed slnx); 94 total + 8 umbrella, all green; both solutions build; the packed nano tool bundles the library. Co-Authored-By: Claude Opus 4.8 --- .../NanoMigrate.Core/NanoMigrate.Core.csproj | 7 + .../src/NanoMigrate.Core/SolutionFile.cs | 120 ++++++----- .../src/NanoMigrate.Core/SolutionRewriter.cs | 188 ++++++------------ .../tests/NanoMigrate.Tests/SolutionTests.cs | 61 ++++++ 4 files changed, 201 insertions(+), 175 deletions(-) diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/NanoMigrate.Core.csproj b/tools/NanoMigrate/src/NanoMigrate.Core/NanoMigrate.Core.csproj index e6c42c8..872095d 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/NanoMigrate.Core.csproj +++ b/tools/NanoMigrate/src/NanoMigrate.Core/NanoMigrate.Core.csproj @@ -25,4 +25,11 @@ false + + + + + diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionFile.cs b/tools/NanoMigrate/src/NanoMigrate.Core/SolutionFile.cs index 27a0bf9..d092caf 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionFile.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/SolutionFile.cs @@ -1,5 +1,7 @@ -using System.Text.RegularExpressions; +using System.Text; using System.Xml.Linq; +using Microsoft.VisualStudio.SolutionPersistence.Model; +using Microsoft.VisualStudio.SolutionPersistence.Serializer; namespace NanoFramework.Migrate.Core; @@ -9,24 +11,26 @@ public enum SolutionFormat /// Classic line-based .sln (each project has a type GUID). Classic, - /// XML .slnx (path-based; no per-project type GUID). + /// XML .slnx (path-based). Xml, } /// /// Parsed model of a Visual Studio solution — both the classic .sln line -/// format and the newer XML .slnx format. Exposes the referenced project -/// paths (absolute) and the format. Pure: parsing reads the file, nothing else. +/// format and the newer XML .slnx format. Backed by the .NET Foundation +/// Microsoft.VisualStudio.SolutionPersistence serializers: the library does +/// all of the parsing and (on retarget) re-serialization; this type only exposes the +/// referenced project paths (absolute) and the format, and drives the nfproj→csproj +/// retarget through the model. /// public sealed class SolutionFile { - // The project-declaration line of a classic .sln: - // Project("{type-guid}") = "Name", "rel\path.ext", "{project-guid}" - // We only need the *path* (the second quoted value). The capture is - // separator- and extension-agnostic; callers filter on .nfproj/.csproj. - private static readonly Regex ClassicProjectLine = new( - "^\\s*Project\\(\"\\{[^}]*\\}\"\\)\\s*=\\s*\"[^\"]*\"\\s*,\\s*\"(?[^\"]+)\"", - RegexOptions.Compiled | RegexOptions.IgnoreCase); + // The legacy nanoFramework project-type GUID. A flavored .nfproj carries this in a + // classic .sln, and VS writes it as the Type attribute in a .slnx. The serializer + // does not know the .nfproj extension, so a .slnx that references a bare .nfproj + // (no Type attribute) cannot be parsed as-is; we inject this Type for those + // entries before handing the text to the library (see LoadSlnxModel). + internal const string NfprojTypeGuid = "{11A8DD76-328B-46DF-9F39-F559912D0360}"; /// Absolute path to the solution file. public string Path { get; } @@ -36,8 +40,7 @@ public sealed class SolutionFile /// /// Every project the solution references, as an absolute path. Solution folders - /// (which in a classic .sln are "projects" whose path equals the folder - /// name, with no separator and no project extension) are excluded. + /// are excluded (the library models them separately from projects). /// public IReadOnlyList ProjectPaths { get; } @@ -58,11 +61,14 @@ public static SolutionFile Load(string solutionPath) { var full = System.IO.Path.GetFullPath(solutionPath); var dir = System.IO.Path.GetDirectoryName(full)!; - var text = File.ReadAllText(full); + var isXml = full.EndsWith(".slnx", StringComparison.OrdinalIgnoreCase); - return full.EndsWith(".slnx", StringComparison.OrdinalIgnoreCase) - ? new SolutionFile(full, SolutionFormat.Xml, ParseSlnx(text, dir)) - : new SolutionFile(full, SolutionFormat.Classic, ParseClassic(text, dir)); + var model = LoadModel(full, isXml); + var paths = model.SolutionProjects + .Select(p => System.IO.Path.GetFullPath(System.IO.Path.Combine(dir, p.FilePath))) + .ToList(); + + return new SolutionFile(full, isXml ? SolutionFormat.Xml : SolutionFormat.Classic, paths); } /// @@ -71,45 +77,67 @@ public static SolutionFile Load(string solutionPath) public IReadOnlyList NanoProjects() => ProjectPaths.Where(p => p.EndsWith(".nfproj", StringComparison.OrdinalIgnoreCase)).ToList(); - // Classic .sln: scan project-declaration lines, resolve each quoted relative - // path against the solution directory. Skip solution folders (the path has no - // directory separator AND no recognised project extension — e.g. "src"). - private static List ParseClassic(string text, string slnDir) + // ---- Library bridge ------------------------------------------------------ + + /// + /// Reads a solution into the library's , picking the + /// serializer by extension. For .slnx the bare-.nfproj compatibility + /// shim is applied so a solution that references a flavored project without an + /// explicit Type attribute still parses (the serializer does not know the + /// .nfproj extension on its own). + /// + internal static SolutionModel LoadModel(string fullPath, bool isXml) => + isXml ? LoadSlnxModel(File.ReadAllText(fullPath)) : OpenFromText(isXml: false, File.ReadAllText(fullPath)); + + // Classic .sln: the serializer carries the project-type GUID per entry, so no shim + // is needed. .slnx: try a straight parse; if the serializer rejects an unknown + // .nfproj project type, inject the nanoFramework Type attribute on bare .nfproj + // elements and retry. This is the only place we touch the raw text, and + // only to make an otherwise-unparseable solution ingestible by the library. + internal static SolutionModel LoadSlnxModel(string text) { - var result = new List(); - foreach (var raw in text.Split('\n')) + try + { + return OpenFromText(isXml: true, text); + } + catch (SolutionException) { - var m = ClassicProjectLine.Match(raw); - if (!m.Success) continue; - var rel = m.Groups["path"].Value.Trim(); - if (IsSolutionFolderEntry(rel)) continue; - result.Add(System.IO.Path.GetFullPath(System.IO.Path.Combine(slnDir, rel))); + var patched = InjectNfprojTypeAttribute(text); + if (patched is null) throw; + return OpenFromText(isXml: true, patched); } - return result; } - // A classic .sln solution-folder entry uses the folder name as its "path" — no - // separators and no project file extension. Real project entries always carry - // a project extension (the path ends with .*proj). - private static bool IsSolutionFolderEntry(string rel) + // Opens solution text into the model via the matching typed serializer (chosen by + // format). The library's stream-based open avoids any temp file. + internal static SolutionModel OpenFromText(bool isXml, string text) { - var hasSeparator = rel.Contains('\\') || rel.Contains('/'); - var looksLikeProject = rel.EndsWith("proj", StringComparison.OrdinalIgnoreCase); - return !hasSeparator && !looksLikeProject; + using var ms = new MemoryStream(new UTF8Encoding(false).GetBytes(text)); + return isXml + ? SolutionSerializers.SlnXml.OpenAsync(ms, CancellationToken.None).GetAwaiter().GetResult() + : SolutionSerializers.SlnFileV12.OpenAsync(ms, CancellationToken.None).GetAwaiter().GetResult(); } - // .slnx: an XML document of nested / elements. - // We collect every regardless of nesting depth. - private static List ParseSlnx(string text, string slnDir) + // Adds Type="{nfproj GUID}" to every element that lacks + // a Type attribute, so the SolutionPersistence .slnx serializer can resolve the + // otherwise-unknown extension. Returns null when nothing needed patching (so the + // original error is preserved). Preserves all other formatting via XDocument. + internal static string? InjectNfprojTypeAttribute(string slnxText) { - var result = new List(); - var root = XElement.Parse(text); - foreach (var proj in root.DescendantsAndSelf().Where(e => e.Name.LocalName == "Project")) + XDocument doc; + try { doc = XDocument.Parse(slnxText, LoadOptions.PreserveWhitespace); } + catch { return null; } + + var changed = false; + foreach (var proj in doc.Descendants().Where(e => e.Name.LocalName == "Project")) { - var rel = (string?)proj.Attribute("Path"); - if (string.IsNullOrWhiteSpace(rel)) continue; - result.Add(System.IO.Path.GetFullPath(System.IO.Path.Combine(slnDir, rel.Trim()))); + var path = (string?)proj.Attribute("Path"); + if (path is null || !path.EndsWith(".nfproj", StringComparison.OrdinalIgnoreCase)) continue; + var hasType = proj.Attributes().Any(a => a.Name.LocalName == "Type"); + if (hasType) continue; + proj.SetAttributeValue("Type", NfprojTypeGuid); + changed = true; } - return result; + return changed ? doc.ToString(SaveOptions.DisableFormatting) : null; } } diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionRewriter.cs b/tools/NanoMigrate/src/NanoMigrate.Core/SolutionRewriter.cs index 0703fe4..c720db4 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionRewriter.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/SolutionRewriter.cs @@ -1,20 +1,29 @@ using System.Text; +using Microsoft.VisualStudio.SolutionPersistence.Model; +using Microsoft.VisualStudio.SolutionPersistence.Serializer; namespace NanoFramework.Migrate.Core; /// /// Retargets a solution's converted projects from .nfproj to .csproj. -/// Handles both the classic line-based .sln (path + project-type GUID) and -/// the XML .slnx (path only). Pure transform: works on -/// strings; wraps it with file I/O. +/// Handles both the classic line-based .sln and the XML .slnx by +/// mutating the .NET Foundation and re-serialising with +/// the matching serializer — the library writes the canonical format for each (a +/// .sln stays a .sln, a .slnx stays a .slnx). For a +/// classic .sln the retargeted entry's project type is set to the SDK-style C# +/// GUID; for a path-based .slnx the now-redundant nanoFramework type is cleared. /// -/// Idempotent / reentrant: only entries still pointing at a .nfproj that is -/// in the converted set are touched, so a solution already pointing at the -/// .csproj is left byte-for-byte unchanged. +/// Idempotent / reentrant: the retarget is decided at the model level — only projects +/// still pointing at a .nfproj that is in the converted set are touched. When +/// nothing matched, the original file text is returned/left untouched (no write, no +/// reformat). /// public static class SolutionRewriter { - // Project-type GUIDs: legacy nanoFramework flavor -> SDK-style C#. + // Project-type GUIDs: legacy nanoFramework flavor -> SDK-style C#. Retained for + // back-compat (tests/consumers reference these); the actual GUID swap on a classic + // .sln is performed by the serializer when the project type is cleared and the + // path becomes a .csproj. public const string NfprojTypeGuid = "{11A8DD76-328B-46DF-9F39-F559912D0360}"; public const string CsprojTypeGuid = "{9A19103F-16F7-4668-BE54-9A1E7A4F7556}"; @@ -34,9 +43,25 @@ public static string Rewrite( convertedNfprojPaths.Select(Path.GetFullPath), StringComparer.OrdinalIgnoreCase); if (converted.Count == 0) return solutionText; - return format == SolutionFormat.Xml - ? RewriteSlnx(solutionText, solutionDir, converted) - : RewriteClassic(solutionText, solutionDir, converted); + var isXml = format == SolutionFormat.Xml; + + SolutionModel model; + try + { + // .slnx parsing applies the bare-.nfproj Type compatibility shim; .sln + // carries its type GUIDs per entry and needs none. + model = isXml ? SolutionFile.LoadSlnxModel(solutionText) : SolutionFile.OpenFromText(isXml: false, solutionText); + } + catch + { + // If the library cannot parse it even after the shim, leave it untouched. + return solutionText; + } + + var changed = RetargetModel(model, solutionDir, converted, isXml); + if (!changed) return solutionText; + + return SaveToText(isXml, model); } /// @@ -57,134 +82,39 @@ public static bool RewriteFile(SolutionFile solution, IEnumerable conver return true; } - // Classic .sln: line-scoped path + GUID swap. We rewrite line-by-line so the - // GUID swap stays scoped to the project's own declaration line — a blanket - // GUID replace would wrongly flip other, still-unconverted nanoFramework - // projects in a shared solution. - private static string RewriteClassic(string text, string slnDir, HashSet converted) + // Mutates the model: every project whose resolved .nfproj path is in the converted + // set is pointed at the sibling .csproj. For a classic .sln the project type is + // set to the SDK-style C# type GUID (so the serializer writes it on that entry, + // line-scoped — other still-unconverted nanoFramework projects keep their legacy + // GUID). For a .slnx (path-based) the now-redundant nanoFramework Type attribute is + // cleared so the converted entry carries no Type, exactly as before. Returns true + // if any project changed. + private static bool RetargetModel(SolutionModel model, string solutionDir, HashSet converted, bool isXml) { - var lines = text.Split('\n'); var changed = false; - for (int li = 0; li < lines.Length; li++) + foreach (var project in model.SolutionProjects) { - var line = lines[li]; - if (!line.TrimStart().StartsWith("Project(", StringComparison.OrdinalIgnoreCase)) continue; - - // The .sln path is relative to the .sln dir and wrapped in quotes. Match - // the *quoted* path so we rewrite the specific entry rather than blanket- - // replacing a substring that might collide with another project's name. - var matched = false; - foreach (var nf in converted) - { - var rel = Path.GetRelativePath(slnDir, nf); - var nfName = Path.GetFileName(nf); - var csName = Path.ChangeExtension(nfName, ".csproj"); + var rel = project.FilePath; + if (!rel.EndsWith(".nfproj", StringComparison.OrdinalIgnoreCase)) continue; - foreach (var candidate in QuotedCandidates(rel, nfName)) - { - if (!line.Contains(candidate, StringComparison.OrdinalIgnoreCase)) continue; - var replacement = "\"" + candidate[1..^(nfName.Length + 1)] + csName + "\""; - line = ReplaceIgnoreCase(line, candidate, replacement); - matched = true; - } - } - if (!matched) continue; + var abs = Path.GetFullPath(Path.Combine(solutionDir, rel)); + if (!converted.Contains(abs)) continue; - // Swap the legacy project-type GUID for the SDK-style one, scoped to the - // line we just retargeted. - if (line.Contains(NfprojTypeGuid, StringComparison.OrdinalIgnoreCase)) - line = ReplaceIgnoreCase(line, NfprojTypeGuid, CsprojTypeGuid); - - lines[li] = line; + project.FilePath = Path.ChangeExtension(rel, ".csproj"); + try { project.Type = isXml ? string.Empty : CsprojTypeGuid; } + catch (SolutionException) { /* leave the type as-is */ } changed = true; } - return changed ? string.Join('\n', lines) : text; + return changed; } - // The quoted path forms a .sln might use for one project: relative path in - // OS-native and both separator styles, plus the bare filename fallback. Each is - // returned wrapped in the surrounding double quotes. - private static IEnumerable QuotedCandidates(string rel, string nfName) => - new[] - { - "\"" + rel + "\"", - "\"" + rel.Replace('/', '\\') + "\"", - "\"" + rel.Replace('\\', '/') + "\"", - "\"" + nfName + "\"", - }.Distinct(StringComparer.OrdinalIgnoreCase); - - // .slnx: path-based, no per-project GUID. Rewrite the Path attribute value of - // every whose resolved path is in the converted set. - // We edit the raw text (rather than re-serialising the XML) so formatting, - // attribute order, and comments are preserved. - private static string RewriteSlnx(string text, string slnDir, HashSet converted) + private static string SaveToText(bool isXml, SolutionModel model) { - var sb = new StringBuilder(text.Length); - int i = 0; - var changed = false; - - while (i < text.Length) - { - // Find the next Path="..." (any attribute literally named Path). - var idx = IndexOfPathAttr(text, i); - if (idx < 0) { sb.Append(text, i, text.Length - i); break; } - - // idx points at "Path"; locate the opening quote after '='. - var eq = text.IndexOf('=', idx); - var openQuoteRel = eq < 0 ? -1 : text.IndexOf('"', eq); - if (openQuoteRel < 0) { sb.Append(text, i, idx + 4 - i); i = idx + 4; continue; } - var closeQuote = text.IndexOf('"', openQuoteRel + 1); - if (closeQuote < 0) { sb.Append(text, i, idx + 4 - i); i = idx + 4; continue; } - - var rel = text.Substring(openQuoteRel + 1, closeQuote - openQuoteRel - 1); - // Append everything up to and including the opening quote unchanged. - sb.Append(text, i, openQuoteRel + 1 - i); - - if (rel.EndsWith(".nfproj", StringComparison.OrdinalIgnoreCase)) - { - var abs = Path.GetFullPath(Path.Combine(slnDir, rel.Trim())); - if (converted.Contains(abs)) - { - rel = rel[..^".nfproj".Length] + ".csproj"; - changed = true; - } - } - sb.Append(rel); - sb.Append('"'); - i = closeQuote + 1; - } - - return changed ? sb.ToString() : text; - } - - // Finds the next occurrence of a literal Path attribute name (case-insensitive, - // word-bounded so it doesn't match e.g. "HintPath"). Returns the index of the - // 'P', or -1. - private static int IndexOfPathAttr(string text, int start) - { - int from = start; - while (true) - { - var idx = text.IndexOf("Path", from, StringComparison.OrdinalIgnoreCase); - if (idx < 0) return -1; - var prev = idx == 0 ? ' ' : text[idx - 1]; - // Reject when preceded by a name char (e.g. HintPath, FooPath). - if (!(char.IsLetterOrDigit(prev) || prev == '_')) return idx; - from = idx + 4; - } - } - - private static string ReplaceIgnoreCase(string input, string search, string replacement) - { - var sb = new StringBuilder(); - int i = 0; - while (i < input.Length) - { - var idx = input.IndexOf(search, i, StringComparison.OrdinalIgnoreCase); - if (idx < 0) { sb.Append(input, i, input.Length - i); break; } - sb.Append(input, i, idx - i).Append(replacement); - i = idx + search.Length; - } - return sb.ToString(); + using var ms = new MemoryStream(); + if (isXml) + SolutionSerializers.SlnXml.SaveAsync(ms, model, CancellationToken.None).GetAwaiter().GetResult(); + else + SolutionSerializers.SlnFileV12.SaveAsync(ms, model, CancellationToken.None).GetAwaiter().GetResult(); + return new UTF8Encoding(false).GetString(ms.ToArray()); } } diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionTests.cs index d171916..c343068 100644 --- a/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionTests.cs +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionTests.cs @@ -210,6 +210,67 @@ public void Rewriter_slnx_is_a_noop_on_rerun() Assert.Equal(afterFirst, File.ReadAllText(slnx)); } + // ---- Round-trip via the SolutionPersistence library ---------------------- + + [Fact] + public void Roundtrip_classic_sln_retarget_then_reparse_sees_csproj() + { + using var dir = new TempDir(); + var sln = dir.File("App.sln", ClassicSln("Foo\\Foo.nfproj")); + var converted = new[] { dir.Combine(Path.Combine("Foo", "Foo.nfproj")) }; + + // Retarget the converted project, then re-read the solution through the + // library: the entry now resolves to the .csproj (not the .nfproj). + Assert.True(SolutionRewriter.RewriteFile(SolutionFile.Load(sln), converted)); + + var reparsed = SolutionFile.Load(sln); + Assert.Contains(reparsed.ProjectPaths, p => p.EndsWith("Foo.csproj")); + Assert.DoesNotContain(reparsed.ProjectPaths, p => p.EndsWith("Foo.nfproj")); + Assert.Empty(reparsed.NanoProjects()); + } + + [Fact] + public void Roundtrip_slnx_retarget_then_reparse_sees_csproj() + { + using var dir = new TempDir(); + var slnx = dir.File("App.slnx", Slnx()); + var converted = new[] + { + dir.Combine(Path.Combine("Foo", "Foo.nfproj")), + dir.Combine(Path.Combine("Bar", "Bar.nfproj")), + }; + + Assert.True(SolutionRewriter.RewriteFile(SolutionFile.Load(slnx), converted)); + + var reparsed = SolutionFile.Load(slnx); + // Both converted .nfproj now resolve to .csproj; the unrelated Baz.csproj stays. + Assert.Contains(reparsed.ProjectPaths, p => p.EndsWith("Foo.csproj")); + Assert.Contains(reparsed.ProjectPaths, p => p.EndsWith("Bar.csproj")); + Assert.Contains(reparsed.ProjectPaths, p => p.EndsWith("Baz.csproj")); + Assert.Empty(reparsed.NanoProjects()); + } + + [Fact] + public void Slnx_with_explicit_nfproj_type_attribute_parses() + { + using var dir = new TempDir(); + // A .slnx as Visual Studio writes it for a flavored project: the .nfproj + // carries the nanoFramework project-type GUID as the Type attribute. + var slnx = dir.File("App.slnx", $$""" + + + + + """); + + var parsed = SolutionFile.Load(slnx); + + Assert.Equal(SolutionFormat.Xml, parsed.Format); + Assert.Equal(2, parsed.ProjectPaths.Count); + Assert.Single(parsed.NanoProjects()); + Assert.EndsWith("Foo.nfproj", parsed.NanoProjects()[0]); + } + // ---- SolutionDiscovery ---------------------------------------------------- [Fact] From ceab679679a666c165e470a7a2f4758050417be7 Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Tue, 16 Jun 2026 12:04:50 -0500 Subject: [PATCH 18/49] NanoMigrate.Core: reorganize into vertical-slice feature folders Pure structural refactor (no behavior change): the 18 Core files are grouped into feature slices, each with its own sub-namespace: - Projects/ (ProjectConverter, ConversionOptions, ConvertResult/Status, IProjectConverter) - Solutions/ (SolutionFile, SolutionRewriter, SolutionScanner, SolutionDiscovery, MigrationPlan) - Backup/ (RollbackJournal, BackupCleaner, MigrationJournaling) - Verification/ (SolutionBuilder, Verification) - Fleet/ (FleetService, RepoReport) - Common/ (Glob, ProjectScanner) Consumers use the slice namespaces via global items (ImplicitUsings). Engine stays console-free. 94 + 8 tests pass; both solutions build. Co-Authored-By: Claude Opus 4.8 --- .../src/NanoMigrate.Cli.Commands/CleanCommand.cs | 1 - .../src/NanoMigrate.Cli.Commands/MigrateCommand.cs | 1 - .../NanoMigrate.Cli.Commands.csproj | 11 +++++++++++ .../Rendering/MigrateRenderer.cs | 1 - .../src/NanoMigrate.Cli.Commands/RollbackCommand.cs | 1 - .../src/NanoMigrate.Cli/Cli/FleetCommand.cs | 1 - .../src/NanoMigrate.Cli/NanoMigrate.Cli.csproj | 11 +++++++++++ .../NanoMigrate/src/NanoMigrate.Cli/ProcessRunner.cs | 1 - .../src/NanoMigrate.Cli/Rendering/FleetRenderer.cs | 1 - .../NanoMigrate.Core/{ => Backup}/BackupCleaner.cs | 2 +- .../{ => Backup}/MigrationJournaling.cs | 4 +++- .../NanoMigrate.Core/{ => Backup}/RollbackJournal.cs | 2 +- .../src/NanoMigrate.Core/{ => Common}/Glob.cs | 2 +- .../NanoMigrate.Core/{ => Common}/ProjectScanner.cs | 2 +- .../src/NanoMigrate.Core/{ => Fleet}/FleetService.cs | 4 +++- .../src/NanoMigrate.Core/{ => Fleet}/RepoReport.cs | 2 +- .../{ => Projects}/ConversionOptions.cs | 2 +- .../NanoMigrate.Core/{ => Projects}/ConvertResult.cs | 2 +- .../{ => Projects}/IProjectConverter.cs | 2 +- .../{ => Projects}/ProjectConverter.cs | 3 ++- .../NanoMigrate.Core/{ => Solutions}/MigrationPlan.cs | 4 +++- .../{ => Solutions}/SolutionDiscovery.cs | 2 +- .../NanoMigrate.Core/{ => Solutions}/SolutionFile.cs | 2 +- .../{ => Solutions}/SolutionRewriter.cs | 2 +- .../{ => Solutions}/SolutionScanner.cs | 2 +- .../{ => Verification}/SolutionBuilder.cs | 2 +- .../{ => Verification}/Verification.cs | 2 +- .../NanoMigrate/tests/NanoMigrate.Tests/CleanTests.cs | 1 - .../tests/NanoMigrate.Tests/ConverterTests.cs | 1 - tools/NanoMigrate/tests/NanoMigrate.Tests/CpmTests.cs | 1 - .../NanoMigrate/tests/NanoMigrate.Tests/GlobTests.cs | 1 - .../tests/NanoMigrate.Tests/HintPathTests.cs | 1 - .../tests/NanoMigrate.Tests/ItemGlobTests.cs | 1 - .../tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj | 11 +++++++++++ .../tests/NanoMigrate.Tests/OutputTypeTests.cs | 1 - .../tests/NanoMigrate.Tests/PackageResolutionTests.cs | 1 - .../tests/NanoMigrate.Tests/ProjectScannerTests.cs | 1 - .../tests/NanoMigrate.Tests/RollbackTests.cs | 1 - .../tests/NanoMigrate.Tests/SolutionRewriteTests.cs | 1 - .../tests/NanoMigrate.Tests/SolutionTests.cs | 1 - .../tests/NanoMigrate.Tests/VerifyTests.cs | 1 - 41 files changed, 58 insertions(+), 38 deletions(-) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Backup}/BackupCleaner.cs (99%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Backup}/MigrationJournaling.cs (97%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Backup}/RollbackJournal.cs (99%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Common}/Glob.cs (98%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Common}/ProjectScanner.cs (97%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Fleet}/FleetService.cs (98%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Fleet}/RepoReport.cs (88%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Projects}/ConversionOptions.cs (98%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Projects}/ConvertResult.cs (98%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Projects}/IProjectConverter.cs (92%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Projects}/ProjectConverter.cs (99%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Solutions}/MigrationPlan.cs (98%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Solutions}/SolutionDiscovery.cs (96%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Solutions}/SolutionFile.cs (99%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Solutions}/SolutionRewriter.cs (99%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Solutions}/SolutionScanner.cs (96%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Verification}/SolutionBuilder.cs (99%) rename tools/NanoMigrate/src/NanoMigrate.Core/{ => Verification}/Verification.cs (98%) diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/CleanCommand.cs b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/CleanCommand.cs index c828049..f4a299f 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/CleanCommand.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/CleanCommand.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using NanoFramework.Migrate.Core; using NanoFramework.Migrate.Cli.Rendering; using Spectre.Console; using Spectre.Console.Cli; diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs index 4be55c9..768ad57 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using NanoFramework.Migrate.Core; using NanoFramework.Migrate.Cli.Rendering; using Spectre.Console; using Spectre.Console.Cli; diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj index ea96a90..a5e7767 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj @@ -36,4 +36,15 @@ + + + + + + + + + + diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs index aa41bce..1c84f22 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs @@ -1,5 +1,4 @@ using System.Text; -using NanoFramework.Migrate.Core; using Spectre.Console; using static NanoFramework.Migrate.Cli.Rendering.ConsoleSupport; diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/RollbackCommand.cs b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/RollbackCommand.cs index fbc12be..65aae65 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/RollbackCommand.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/RollbackCommand.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using NanoFramework.Migrate.Core; using NanoFramework.Migrate.Cli.Rendering; using Spectre.Console; using Spectre.Console.Cli; diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/FleetCommand.cs b/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/FleetCommand.cs index 9f9a020..f059dfc 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/FleetCommand.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/FleetCommand.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using NanoFramework.Migrate.Core; using NanoFramework.Migrate.Cli.Rendering; using Spectre.Console; using Spectre.Console.Cli; diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj b/tools/NanoMigrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj index cdf4c0d..d49c13b 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj @@ -46,4 +46,15 @@ + + + + + + + + + + diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/ProcessRunner.cs b/tools/NanoMigrate/src/NanoMigrate.Cli/ProcessRunner.cs index 0895a51..eca4b91 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli/ProcessRunner.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/ProcessRunner.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using NanoFramework.Migrate.Core; namespace NanoFramework.Migrate.Cli; diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs b/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs index 5277905..c6fb8cb 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs @@ -1,5 +1,4 @@ using System.Text; -using NanoFramework.Migrate.Core; using Spectre.Console; using static NanoFramework.Migrate.Cli.Rendering.ConsoleSupport; diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/BackupCleaner.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Backup/BackupCleaner.cs similarity index 99% rename from tools/NanoMigrate/src/NanoMigrate.Core/BackupCleaner.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Backup/BackupCleaner.cs index 4a86a3a..1dac631 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/BackupCleaner.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Backup/BackupCleaner.cs @@ -1,4 +1,4 @@ -namespace NanoFramework.Migrate.Core; +namespace NanoFramework.Migrate.Core.Backup; /// The leftovers a clean would remove, gathered for preview before deletion. public sealed class CleanPlan diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/MigrationJournaling.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Backup/MigrationJournaling.cs similarity index 97% rename from tools/NanoMigrate/src/NanoMigrate.Core/MigrationJournaling.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Backup/MigrationJournaling.cs index f9af990..10b6ae1 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/MigrationJournaling.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Backup/MigrationJournaling.cs @@ -1,4 +1,6 @@ -namespace NanoFramework.Migrate.Core; +using NanoFramework.Migrate.Core.Projects; + +namespace NanoFramework.Migrate.Core.Backup; /// /// Bridges a (dry-run) preview into a diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/RollbackJournal.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Backup/RollbackJournal.cs similarity index 99% rename from tools/NanoMigrate/src/NanoMigrate.Core/RollbackJournal.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Backup/RollbackJournal.cs index f82390f..65296ad 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/RollbackJournal.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Backup/RollbackJournal.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace NanoFramework.Migrate.Core; +namespace NanoFramework.Migrate.Core.Backup; /// /// One recorded action in a rollback journal: either a file the migration CREATED diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Glob.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Common/Glob.cs similarity index 98% rename from tools/NanoMigrate/src/NanoMigrate.Core/Glob.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Common/Glob.cs index 5080fb1..109efe9 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/Glob.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Common/Glob.cs @@ -1,7 +1,7 @@ using System.Text; using System.Text.RegularExpressions; -namespace NanoFramework.Migrate.Core; +namespace NanoFramework.Migrate.Core.Common; /// /// Small hand-rolled glob matcher (no external dependency). Matches a path that diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/ProjectScanner.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Common/ProjectScanner.cs similarity index 97% rename from tools/NanoMigrate/src/NanoMigrate.Core/ProjectScanner.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Common/ProjectScanner.cs index d3842b7..82c8ea7 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/ProjectScanner.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Common/ProjectScanner.cs @@ -1,4 +1,4 @@ -namespace NanoFramework.Migrate.Core; +namespace NanoFramework.Migrate.Core.Common; /// /// Enumerates .nfproj files under a path, with an optional glob filter. diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/FleetService.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Fleet/FleetService.cs similarity index 98% rename from tools/NanoMigrate/src/NanoMigrate.Core/FleetService.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Fleet/FleetService.cs index acf70ec..3403757 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/FleetService.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Fleet/FleetService.cs @@ -1,6 +1,8 @@ using System.Text; +using NanoFramework.Migrate.Core.Common; +using NanoFramework.Migrate.Core.Projects; -namespace NanoFramework.Migrate.Core; +namespace NanoFramework.Migrate.Core.Fleet; /// Result of a single git invocation. Mirrors a process exit. public readonly record struct GitResult(int Code, string Stdout, string Stderr); diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/RepoReport.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Fleet/RepoReport.cs similarity index 88% rename from tools/NanoMigrate/src/NanoMigrate.Core/RepoReport.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Fleet/RepoReport.cs index f7fd62f..228f489 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/RepoReport.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Fleet/RepoReport.cs @@ -1,4 +1,4 @@ -namespace NanoFramework.Migrate.Core; +namespace NanoFramework.Migrate.Core.Fleet; /// Per-repo outcome accumulated by the fleet command. Pure data. public sealed class RepoReport diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Projects/ConversionOptions.cs similarity index 98% rename from tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Projects/ConversionOptions.cs index ff61874..67b2221 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/ConversionOptions.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Projects/ConversionOptions.cs @@ -1,4 +1,4 @@ -namespace NanoFramework.Migrate.Core; +namespace NanoFramework.Migrate.Core.Projects; /// /// The knobs that drive a conversion. This is the engine-facing options type — it diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/ConvertResult.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Projects/ConvertResult.cs similarity index 98% rename from tools/NanoMigrate/src/NanoMigrate.Core/ConvertResult.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Projects/ConvertResult.cs index c0a6b4f..df75432 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/ConvertResult.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Projects/ConvertResult.cs @@ -1,4 +1,4 @@ -namespace NanoFramework.Migrate.Core; +namespace NanoFramework.Migrate.Core.Projects; /// How a single project ended up after a (dry-run or real) conversion. public enum ConvertStatus diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/IProjectConverter.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Projects/IProjectConverter.cs similarity index 92% rename from tools/NanoMigrate/src/NanoMigrate.Core/IProjectConverter.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Projects/IProjectConverter.cs index 91a005f..1752d35 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/IProjectConverter.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Projects/IProjectConverter.cs @@ -1,4 +1,4 @@ -namespace NanoFramework.Migrate.Core; +namespace NanoFramework.Migrate.Core.Projects; /// /// Converts a single legacy .nfproj into an SDK-style project. The result diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Projects/ProjectConverter.cs similarity index 99% rename from tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Projects/ProjectConverter.cs index 6cc787a..1072261 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/ProjectConverter.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Projects/ProjectConverter.cs @@ -1,8 +1,9 @@ using System.Text; using System.Text.RegularExpressions; using System.Xml.Linq; +using NanoFramework.Migrate.Core.Solutions; -namespace NanoFramework.Migrate.Core; +namespace NanoFramework.Migrate.Core.Projects; /// /// Converts one legacy .nfproj into an SDK-style project. Faithful to the diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/MigrationPlan.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Solutions/MigrationPlan.cs similarity index 98% rename from tools/NanoMigrate/src/NanoMigrate.Core/MigrationPlan.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Solutions/MigrationPlan.cs index 4e88b0e..18ff9a3 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/MigrationPlan.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Solutions/MigrationPlan.cs @@ -1,4 +1,6 @@ -namespace NanoFramework.Migrate.Core; +using NanoFramework.Migrate.Core.Common; + +namespace NanoFramework.Migrate.Core.Solutions; /// /// How the migration target was specified, which decides whether the CLI must diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionDiscovery.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionDiscovery.cs similarity index 96% rename from tools/NanoMigrate/src/NanoMigrate.Core/SolutionDiscovery.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionDiscovery.cs index 4135787..50e2dc0 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionDiscovery.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionDiscovery.cs @@ -1,4 +1,4 @@ -namespace NanoFramework.Migrate.Core; +namespace NanoFramework.Migrate.Core.Solutions; /// /// Given a set of target .nfproj paths, finds which discovered solutions diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionFile.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionFile.cs similarity index 99% rename from tools/NanoMigrate/src/NanoMigrate.Core/SolutionFile.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionFile.cs index d092caf..1ba3472 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionFile.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionFile.cs @@ -3,7 +3,7 @@ using Microsoft.VisualStudio.SolutionPersistence.Model; using Microsoft.VisualStudio.SolutionPersistence.Serializer; -namespace NanoFramework.Migrate.Core; +namespace NanoFramework.Migrate.Core.Solutions; /// The on-disk format of a Visual Studio solution. public enum SolutionFormat diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionRewriter.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionRewriter.cs similarity index 99% rename from tools/NanoMigrate/src/NanoMigrate.Core/SolutionRewriter.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionRewriter.cs index c720db4..87d7828 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionRewriter.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionRewriter.cs @@ -2,7 +2,7 @@ using Microsoft.VisualStudio.SolutionPersistence.Model; using Microsoft.VisualStudio.SolutionPersistence.Serializer; -namespace NanoFramework.Migrate.Core; +namespace NanoFramework.Migrate.Core.Solutions; /// /// Retargets a solution's converted projects from .nfproj to .csproj. diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionScanner.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionScanner.cs similarity index 96% rename from tools/NanoMigrate/src/NanoMigrate.Core/SolutionScanner.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionScanner.cs index 10adc53..1717a94 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionScanner.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionScanner.cs @@ -1,4 +1,4 @@ -namespace NanoFramework.Migrate.Core; +namespace NanoFramework.Migrate.Core.Solutions; /// /// Discovers Visual Studio solution files (.sln and .slnx) beneath a diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionBuilder.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Verification/SolutionBuilder.cs similarity index 99% rename from tools/NanoMigrate/src/NanoMigrate.Core/SolutionBuilder.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Verification/SolutionBuilder.cs index 9ac07f7..d194fd2 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/SolutionBuilder.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Verification/SolutionBuilder.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace NanoFramework.Migrate.Core; +namespace NanoFramework.Migrate.Core.Verification; /// /// The outcome of verifying one target (a solution or a project) by building it. diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Verification.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Verification/Verification.cs similarity index 98% rename from tools/NanoMigrate/src/NanoMigrate.Core/Verification.cs rename to tools/NanoMigrate/src/NanoMigrate.Core/Verification/Verification.cs index cb78ecb..7991214 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Core/Verification.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Verification/Verification.cs @@ -1,4 +1,4 @@ -namespace NanoFramework.Migrate.Core; +namespace NanoFramework.Migrate.Core.Verification; /// What the command should do after the verification build(s) ran. public enum VerifyOutcome diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/CleanTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/CleanTests.cs index 948f2a2..d21592f 100644 --- a/tools/NanoMigrate/tests/NanoMigrate.Tests/CleanTests.cs +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/CleanTests.cs @@ -1,4 +1,3 @@ -using NanoFramework.Migrate.Core; using Xunit; namespace NanoFramework.Migrate.Tests; diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/ConverterTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/ConverterTests.cs index 0a9cee0..5f53e8e 100644 --- a/tools/NanoMigrate/tests/NanoMigrate.Tests/ConverterTests.cs +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/ConverterTests.cs @@ -1,4 +1,3 @@ -using NanoFramework.Migrate.Core; using Xunit; namespace NanoFramework.Migrate.Tests; diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/CpmTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/CpmTests.cs index 428c4ac..5eebb5c 100644 --- a/tools/NanoMigrate/tests/NanoMigrate.Tests/CpmTests.cs +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/CpmTests.cs @@ -1,4 +1,3 @@ -using NanoFramework.Migrate.Core; using Xunit; namespace NanoFramework.Migrate.Tests; diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/GlobTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/GlobTests.cs index bc15ff1..9c590d1 100644 --- a/tools/NanoMigrate/tests/NanoMigrate.Tests/GlobTests.cs +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/GlobTests.cs @@ -1,4 +1,3 @@ -using NanoFramework.Migrate.Core; using Xunit; namespace NanoFramework.Migrate.Tests; diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/HintPathTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/HintPathTests.cs index 00cd3d1..f4c1eb2 100644 --- a/tools/NanoMigrate/tests/NanoMigrate.Tests/HintPathTests.cs +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/HintPathTests.cs @@ -1,4 +1,3 @@ -using NanoFramework.Migrate.Core; using Xunit; namespace NanoFramework.Migrate.Tests; diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/ItemGlobTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/ItemGlobTests.cs index 8103560..0601d1f 100644 --- a/tools/NanoMigrate/tests/NanoMigrate.Tests/ItemGlobTests.cs +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/ItemGlobTests.cs @@ -1,4 +1,3 @@ -using NanoFramework.Migrate.Core; using Xunit; namespace NanoFramework.Migrate.Tests; diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj b/tools/NanoMigrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj index 9c75951..8831a23 100644 --- a/tools/NanoMigrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj @@ -19,4 +19,15 @@ + + + + + + + + + + diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/OutputTypeTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/OutputTypeTests.cs index 564b4d8..8007fab 100644 --- a/tools/NanoMigrate/tests/NanoMigrate.Tests/OutputTypeTests.cs +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/OutputTypeTests.cs @@ -1,4 +1,3 @@ -using NanoFramework.Migrate.Core; using Xunit; namespace NanoFramework.Migrate.Tests; diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/PackageResolutionTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/PackageResolutionTests.cs index 19e1d88..b60e7e7 100644 --- a/tools/NanoMigrate/tests/NanoMigrate.Tests/PackageResolutionTests.cs +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/PackageResolutionTests.cs @@ -1,4 +1,3 @@ -using NanoFramework.Migrate.Core; using Xunit; namespace NanoFramework.Migrate.Tests; diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/ProjectScannerTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/ProjectScannerTests.cs index d09774b..a1f1bde 100644 --- a/tools/NanoMigrate/tests/NanoMigrate.Tests/ProjectScannerTests.cs +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/ProjectScannerTests.cs @@ -1,4 +1,3 @@ -using NanoFramework.Migrate.Core; using Xunit; namespace NanoFramework.Migrate.Tests; diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/RollbackTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/RollbackTests.cs index 5390e14..20d32c4 100644 --- a/tools/NanoMigrate/tests/NanoMigrate.Tests/RollbackTests.cs +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/RollbackTests.cs @@ -1,5 +1,4 @@ using System.Text; -using NanoFramework.Migrate.Core; using Xunit; namespace NanoFramework.Migrate.Tests; diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionRewriteTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionRewriteTests.cs index 92ebb07..be57ad0 100644 --- a/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionRewriteTests.cs +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionRewriteTests.cs @@ -1,4 +1,3 @@ -using NanoFramework.Migrate.Core; using Xunit; namespace NanoFramework.Migrate.Tests; diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionTests.cs index c343068..975e02c 100644 --- a/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionTests.cs +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionTests.cs @@ -1,4 +1,3 @@ -using NanoFramework.Migrate.Core; using Xunit; namespace NanoFramework.Migrate.Tests; diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/VerifyTests.cs b/tools/NanoMigrate/tests/NanoMigrate.Tests/VerifyTests.cs index 1d7b8ed..877a2a9 100644 --- a/tools/NanoMigrate/tests/NanoMigrate.Tests/VerifyTests.cs +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/VerifyTests.cs @@ -1,4 +1,3 @@ -using NanoFramework.Migrate.Core; using Xunit; namespace NanoFramework.Migrate.Tests; From 48d581784a2188e82047b85ca97924303312c97b Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Tue, 16 Jun 2026 13:00:04 -0500 Subject: [PATCH 19/49] NanoMigrate: migrate --report (Markdown or HTML) Add an opt-in migration report. After a migrate run (including --dry-run), --report writes a report; the format is chosen by extension (.md/.markdown -> Markdown, .html/.htm -> HTML). - Core Reporting/ slice (pure, console-free): MigrationReport model (caller supplies the UTC timestamp), MarkdownReportWriter, HtmlReportWriter. Summary totals + a Project|Result|Packages|Notes table + manual-review + verify sections; HTML is self-contained (inline CSS, color-coded) with all text HTML-escaped. - Cli.Commands: MigrationReportBuilder maps the run's outcomes (incl. verify results) onto the model; --report option writes it and reports the path. Write failures degrade to a warning. No clean/auto-clean behavior added. Tests: +11 (105 total) + 8 umbrella, all green; both solutions build. Co-Authored-By: Claude Opus 4.8 --- .../MigrateCommand.cs | 60 +++++- .../NanoMigrate.Cli.Commands.csproj | 1 + .../Rendering/MigrationReportBuilder.cs | 103 +++++++++ .../Reporting/HtmlReportWriter.cs | 178 +++++++++++++++ .../Reporting/MarkdownReportWriter.cs | 129 +++++++++++ .../Reporting/MigrationReport.cs | 93 ++++++++ .../NanoMigrate.Tests.csproj | 3 + .../tests/NanoMigrate.Tests/ReportingTests.cs | 204 ++++++++++++++++++ 8 files changed, 767 insertions(+), 4 deletions(-) create mode 100644 tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrationReportBuilder.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/Reporting/HtmlReportWriter.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/Reporting/MarkdownReportWriter.cs create mode 100644 tools/NanoMigrate/src/NanoMigrate.Core/Reporting/MigrationReport.cs create mode 100644 tools/NanoMigrate/tests/NanoMigrate.Tests/ReportingTests.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs index 768ad57..9814523 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs @@ -64,6 +64,12 @@ public sealed class MigrateSettings : AssumeYesSettings [Description("Skip the post-migration build verification.")] public bool NoVerify { get; init; } + [CommandOption("--report ")] + [Description("Write a migration report to this path. The format is chosen by the extension: " + + ".md/.markdown -> Markdown, .html/.htm -> HTML (anything else -> Markdown). " + + "Works for --dry-run too (reports what WOULD change).")] + public string? Report { get; init; } + public override ValidationResult Validate() { if (Ext is not (".nfproj" or ".csproj")) @@ -141,7 +147,8 @@ private int RunLoose(MigrateSettings settings, ConversionOptions o, MigrationPla var results = ProcessProjects(targets, baseDir, o); journal?.Save(); - var report = Report(results, baseDir, o, rewritten: Array.Empty()); + var rewritten = Array.Empty(); + var report = Report(results, baseDir, o, rewritten); // Verification targets in loose mode are the converted projects themselves // (no host-owned solution set). A failed verify can roll the run back. @@ -149,7 +156,8 @@ private int RunLoose(MigrateSettings settings, ConversionOptions o, MigrationPla .Where(r => r.Result.Status is ConvertStatus.Converted or ConvertStatus.Review) .Select(r => r.Result.OutputPath) .ToList(); - return VerifyAndMaybeRollback(report, verifyTargets, journal, baseDir, settings, o); + var reportCtx = new ReportContext(results, baseDir, o.DryRun, rewritten, settings.Report); + return VerifyAndMaybeRollback(report, verifyTargets, journal, baseDir, settings, o, reportCtx); } // The solution-aware flow: pick the candidate solutions (multi-select / confirm), @@ -222,7 +230,8 @@ private int RunSolutionScoped(MigrateSettings settings, ConversionOptions o, Mig // Verify by building exactly the chosen solutions. var verifyTargets = chosen.Select(c => c.Solution.Path).ToList(); - return VerifyAndMaybeRollback(report, verifyTargets, journal, baseDir, settings, o); + var reportCtx = new ReportContext(results, baseDir, o.DryRun, rewritten, settings.Report); + return VerifyAndMaybeRollback(report, verifyTargets, journal, baseDir, settings, o, reportCtx); } // Opens a rollback journal rooted at baseDir for real runs only; dry-runs return @@ -255,10 +264,14 @@ private void RecordJournal(RollbackJournal journal, List targets, Conver // Maps to the final exit code. When verification is off/clean, returns the // already-computed migrate exit code unchanged. private int VerifyAndMaybeRollback(int migrateExit, List verifyTargets, - RollbackJournal? journal, string baseDir, MigrateSettings settings, ConversionOptions o) + RollbackJournal? journal, string baseDir, MigrateSettings settings, ConversionOptions o, + ReportContext reportCtx) { if (!o.VerifyEffective || verifyTargets.Count == 0) + { + WriteReportIfRequested(reportCtx, verify: null); return migrateExit; + } var builder = new SolutionBuilder(); List outcomes; @@ -282,6 +295,11 @@ private int VerifyAndMaybeRollback(int migrateExit, List verifyTargets, MigrateRenderer.RenderVerifyTable(outcomes, baseDir); + // Write the report (when requested) now that the verify outcomes are known, so + // a single write covers the passed-and-failed paths alike. A subsequent + // rollback reverts files on disk but the report still records what was attempted. + WriteReportIfRequested(reportCtx, outcomes); + var verifyOutcome = Verification.Evaluate(outcomes); if (verifyOutcome != VerifyOutcome.Failed) return migrateExit; @@ -418,6 +436,40 @@ private static int Report(List results, string baseDir, Conversi return flagged > 0 ? 2 : 0; } + // The data needed to render the optional migration report, carried from the + // outcome computation through the verify pass so a single write site can include + // the verify results when a verify pass ran. ReportPath is null when --report + // was not supplied. + private readonly record struct ReportContext( + IReadOnlyList Results, + string BaseDir, + bool DryRun, + IReadOnlyList Rewritten, + string? ReportPath); + + // Builds the MigrationReport from the same outcome/verify data the summary table + // used and writes it to the requested path (format chosen by extension), printing + // a Spectre line. A no-op when --report was not supplied. The caller (Core) owns + // the clock: the timestamp is captured here, not inside the engine. + private static void WriteReportIfRequested(ReportContext ctx, IReadOnlyList? verify) + { + if (string.IsNullOrWhiteSpace(ctx.ReportPath)) return; + + var report = MigrationReportBuilder.Build( + ctx.Results, ctx.BaseDir, ctx.DryRun, ctx.Rewritten, verify, DateTime.UtcNow); + + try + { + var format = MigrationReportBuilder.Write(report, ctx.ReportPath); + var kind = format == ReportFormat.Html ? "HTML" : "Markdown"; + AnsiConsole.MarkupLine($"[grey]{kind} report written to[/] [blue]{Esc(ctx.ReportPath)}[/]"); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[yellow]warning:[/] could not write report to [blue]{Esc(ctx.ReportPath)}[/]: {Esc(ex.Message)}"); + } + } + // Runs every conversion, surfacing progress with a spinner. Any per-project // exception is captured as an Error row rather than aborting the batch. private List ProcessProjects(List targets, string baseDir, ConversionOptions o) diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj index a5e7767..6d04fba 100644 --- a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj @@ -45,6 +45,7 @@ + diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrationReportBuilder.cs b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrationReportBuilder.cs new file mode 100644 index 0000000..02ef21c --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrationReportBuilder.cs @@ -0,0 +1,103 @@ +namespace NanoFramework.Migrate.Cli.Rendering; + +/// The output format chosen for a written migration report. +public enum ReportFormat +{ + Markdown, + Html, +} + +/// +/// Maps the command-layer migration data (, rewritten +/// solutions and verification s) onto the console-free +/// Core the report writers consume. Lives in the +/// presentation layer because is a command-layer type; +/// the resulting report and the writers stay pure Core. +/// +public static class MigrationReportBuilder +{ + /// + /// Builds a from the same outcome data the summary + /// table renders. is supplied by the caller so + /// Core never reads the clock. + /// + public static MigrationReport Build( + IReadOnlyList results, + string baseDir, + bool dryRun, + IReadOnlyList affectedSolutions, + IReadOnlyList? verify, + DateTime generatedUtc) + { + var projects = results + .Select(o => new ReportEntry + { + RelativePath = Rel(baseDir, o.Nfproj), + Status = o.Result.Status, + Packages = o.Result.Packages.ToList(), + Review = o.Result.Review.ToList(), + Error = o.Result.Error, + }) + .ToList(); + + var solutions = affectedSolutions.Select(s => Rel(baseDir, s)).ToList(); + + var verifyEntries = (verify ?? Array.Empty()) + .Select(v => new ReportVerifyEntry + { + Target = Rel(baseDir, v.Target), + Succeeded = v.Succeeded, + Skipped = v.Skipped, + ExitCode = v.ExitCode, + Details = v.Skipped ? v.Message + : v.Succeeded ? null + : v.ErrorTail, + }) + .ToList(); + + return new MigrationReport + { + RootPath = baseDir, + GeneratedUtc = generatedUtc, + DryRun = dryRun, + Projects = projects, + AffectedSolutions = solutions, + Verify = verifyEntries, + }; + } + + /// + /// Picks the report format from the file extension: .md/.markdown → + /// Markdown, .html/.htm → HTML, anything else → Markdown. + /// + public static ReportFormat FormatFor(string path) + { + var ext = Path.GetExtension(path); + return ext.ToLowerInvariant() switch + { + ".html" or ".htm" => ReportFormat.Html, + _ => ReportFormat.Markdown, // .md, .markdown, and everything else + }; + } + + /// Renders to in the format keyed by the extension. + public static ReportFormat Write(MigrationReport report, string path) + { + var format = FormatFor(path); + if (format == ReportFormat.Html) + HtmlReportWriter.Write(report, path); + else + MarkdownReportWriter.Write(report, path); + return format; + } + + // A forward-slash path relative to baseDir when the file sits beneath it; + // otherwise the bare file name. Keeps the report stable across machines. + private static string Rel(string baseDir, string path) + { + var rel = Path.GetRelativePath(baseDir, path); + if (rel.StartsWith("..", StringComparison.Ordinal)) + rel = Path.GetFileName(path); + return rel.Replace('\\', '/'); + } +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Reporting/HtmlReportWriter.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Reporting/HtmlReportWriter.cs new file mode 100644 index 0000000..c6e1a5d --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Reporting/HtmlReportWriter.cs @@ -0,0 +1,178 @@ +using System.Net; +using System.Text; +using NanoFramework.Migrate.Core.Projects; + +namespace NanoFramework.Migrate.Core.Reporting; + +/// +/// Renders a as a self-contained HTML page: inline +/// <style>, no external assets. The results table is colour-coded by +/// status (green converted / grey skipped / amber review / red error). Pure and +/// console-free; ALL interpolated text (project names, notes, package ids) is +/// HTML-escaped to guard against injection. +/// +public static class HtmlReportWriter +{ + /// Renders the report to a self-contained HTML string. + public static string Render(MigrationReport report) + { + var sb = new StringBuilder(); + var title = report.DryRun + ? "nanoFramework migration report (dry run)" + : "nanoFramework migration report"; + + sb.Append("\n"); + sb.Append("\n\n"); + sb.Append("\n"); + sb.Append("\n"); + sb.Append("").Append(Esc(title)).Append("\n"); + sb.Append("\n"); + sb.Append("\n\n"); + + sb.Append("

").Append(Esc(title)).Append("

\n"); + sb.Append("

Root: ").Append(Esc(report.RootPath)).Append("
\n"); + sb.Append("Generated (UTC): ") + .Append(Esc(report.GeneratedUtc.ToString("yyyy-MM-dd HH:mm:ss'Z'"))) + .Append("

\n"); + + var verb = report.DryRun ? "would convert" : "converted"; + sb.Append("
    \n"); + sb.Append("
  • ").Append(Esc(verb)).Append(' ').Append(report.Converted).Append("
  • \n"); + sb.Append("
  • skipped ").Append(report.Skipped).Append("
  • \n"); + sb.Append("
  • flagged ").Append(report.Flagged).Append("
  • \n"); + sb.Append("
  • errors ").Append(report.Errors).Append("
  • \n"); + sb.Append("
  • total ").Append(report.Total).Append("
  • \n"); + sb.Append("
\n"); + + // Results table. + sb.Append("\n\n"); + sb.Append(""); + sb.Append("\n\n\n"); + foreach (var p in report.Projects) + { + var (label, cls) = StatusLabel(p.Status); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append("\n"); + } + sb.Append("\n
ProjectResultPackagesNotes
").Append(Esc(p.RelativePath)).Append("").Append(Esc(label)).Append("").Append(Packages(p)).Append("").Append(Esc(Notes(p))).Append("
\n"); + + if (report.AffectedSolutions.Count > 0) + { + sb.Append("

Solutions

\n
    \n"); + foreach (var s in report.AffectedSolutions) + sb.Append("
  • ").Append(Esc(s)).Append("
  • \n"); + sb.Append("
\n"); + } + + var flagged = report.Projects.Where(p => p.Review.Count > 0).ToList(); + if (flagged.Count > 0) + { + sb.Append("

Manual review

\n"); + foreach (var p in flagged) + { + sb.Append("

").Append(Esc(p.RelativePath)).Append("

\n
    \n"); + foreach (var item in p.Review) + sb.Append("
  • ").Append(Esc(item)).Append("
  • \n"); + sb.Append("
\n"); + } + } + + if (report.Verify.Count > 0) + { + sb.Append("

Verification

\n"); + sb.Append("\n\n"); + sb.Append(""); + sb.Append("\n\n\n"); + foreach (var v in report.Verify) + { + var (label, cls) = v.Skipped ? ("Skipped", "skipped") + : v.Succeeded ? ("Pass", "converted") + : ("Fail", "error"); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append("\n"); + } + sb.Append("\n
TargetResultDetails
").Append(Esc(v.Target)).Append("").Append(Esc(label)).Append(""); + sb.Append(string.IsNullOrEmpty(v.Details) ? "—" : $"
{Esc(v.Details!)}
"); + sb.Append("
\n"); + } + + sb.Append("\n\n"); + return sb.ToString(); + } + + /// Renders the report and writes it to as UTF-8 (no BOM). + public static void Write(MigrationReport report, string path) + { + var dir = Path.GetDirectoryName(Path.GetFullPath(path)); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + File.WriteAllText(path, Render(report), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + private static string Packages(ReportEntry p) + { + if (p.Packages.Count == 0) return ""; + return string.Join("
", p.Packages.Select(kv => + $"{Esc(kv.Key)} {Esc(kv.Value)}")); + } + + private static (string label, string cls) StatusLabel(ConvertStatus s) => s switch + { + ConvertStatus.Converted => ("Converted", "converted"), + ConvertStatus.Skipped => ("Skipped", "skipped"), + ConvertStatus.Review => ("Review", "review"), + ConvertStatus.Error => ("Error", "error"), + _ => ("?", "skipped"), + }; + + private static string Notes(ReportEntry p) => p.Status switch + { + ConvertStatus.Error => p.Error ?? "error", + ConvertStatus.Skipped => "already SDK-style", + ConvertStatus.Review => $"{p.Review.Count} item(s) need review", + _ => "clean", + }; + + // HTML-escapes interpolated text. WebUtility.HtmlEncode handles &, <, >, ". + private static string Esc(string s) => WebUtility.HtmlEncode(s); + + // A small, self-contained stylesheet. Status colours: green (converted), + // grey (skipped), amber (review), red (error). + private const string Css = """ + :root { color-scheme: light dark; } + body { font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; + margin: 2rem; line-height: 1.45; color: #1b1b1b; background: #fff; } + h1 { font-size: 1.6rem; margin-bottom: .25rem; } + h2 { margin-top: 2rem; border-bottom: 1px solid #ddd; padding-bottom: .25rem; } + .meta { color: #555; } + code { font-family: Consolas, Menlo, monospace; font-size: .9em; } + .muted { color: #888; } + ul.totals { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: .5rem; } + ul.totals li { padding: .35rem .7rem; border-radius: .4rem; font-weight: 600; } + .t-converted { background: #e6f4ea; color: #137333; } + .t-skipped { background: #f1f3f4; color: #5f6368; } + .t-review { background: #fef7e0; color: #b06000; } + .t-error { background: #fce8e6; color: #c5221f; } + .t-total { background: #e8f0fe; color: #1a73e8; } + table.results { border-collapse: collapse; width: 100%; margin-top: 1rem; } + table.results th, table.results td { border: 1px solid #ddd; padding: .5rem .65rem; text-align: left; vertical-align: top; } + table.results th { background: #f5f5f5; } + .badge { display: inline-block; padding: .15rem .55rem; border-radius: .35rem; font-size: .85em; font-weight: 600; } + tr.converted .badge, .badge.converted { background: #e6f4ea; color: #137333; } + tr.skipped .badge, .badge.skipped { background: #f1f3f4; color: #5f6368; } + tr.review .badge, .badge.review { background: #fef7e0; color: #b06000; } + tr.error .badge, .badge.error { background: #fce8e6; color: #c5221f; } + tr.converted td:first-child { border-left: 4px solid #34a853; } + tr.skipped td:first-child { border-left: 4px solid #9aa0a6; } + tr.review td:first-child { border-left: 4px solid #f9ab00; } + tr.error td:first-child { border-left: 4px solid #ea4335; } + pre { margin: 0; white-space: pre-wrap; font-size: .85em; } + + """; +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Reporting/MarkdownReportWriter.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Reporting/MarkdownReportWriter.cs new file mode 100644 index 0000000..393529f --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Reporting/MarkdownReportWriter.cs @@ -0,0 +1,129 @@ +using System.Text; +using NanoFramework.Migrate.Core.Projects; + +namespace NanoFramework.Migrate.Core.Reporting; + +/// +/// Renders a as Markdown. Pure: it returns a string +/// (or writes that string to a path) and never touches the console. The document is +/// a title, a summary line, a results table, and — when present — a "Manual review" +/// section and a "Verification" section. +/// +public static class MarkdownReportWriter +{ + /// Renders the report to a Markdown string. + public static string Render(MigrationReport report) + { + var sb = new StringBuilder(); + + sb.Append("# nanoFramework migration report"); + if (report.DryRun) sb.Append(" (dry run)"); + sb.Append('\n').Append('\n'); + + sb.Append("- **Root:** `").Append(report.RootPath).Append("`\n"); + sb.Append("- **Generated (UTC):** ") + .Append(report.GeneratedUtc.ToString("yyyy-MM-dd HH:mm:ss'Z'")) + .Append('\n').Append('\n'); + + var verb = report.DryRun ? "would convert" : "converted"; + sb.Append("**Summary:** ") + .Append(verb).Append(' ').Append(report.Converted) + .Append(" · skipped ").Append(report.Skipped) + .Append(" · flagged ").Append(report.Flagged) + .Append(" · errors ").Append(report.Errors) + .Append(" · total ").Append(report.Total) + .Append('\n').Append('\n'); + + // Results table. + sb.Append("| Project | Result | Packages | Notes |\n"); + sb.Append("| --- | --- | --- | --- |\n"); + foreach (var p in report.Projects) + { + var packages = p.Packages.Count == 0 + ? "—" + : string.Join("
", p.Packages.Select(kv => $"{Cell(kv.Key)} {Cell(kv.Value)}")); + sb.Append("| ") + .Append(Cell(p.RelativePath)).Append(" | ") + .Append(StatusLabel(p.Status)).Append(" | ") + .Append(packages).Append(" | ") + .Append(Notes(p)).Append(" |\n"); + } + sb.Append('\n'); + + if (report.AffectedSolutions.Count > 0) + { + sb.Append("## Solutions\n\n"); + foreach (var s in report.AffectedSolutions) + sb.Append("- `").Append(s).Append("`\n"); + sb.Append('\n'); + } + + var flagged = report.Projects.Where(p => p.Review.Count > 0).ToList(); + if (flagged.Count > 0) + { + sb.Append("## Manual review\n\n"); + foreach (var p in flagged) + { + sb.Append("### ").Append(p.RelativePath).Append('\n').Append('\n'); + foreach (var item in p.Review) + sb.Append("- ").Append(item).Append('\n'); + sb.Append('\n'); + } + } + + if (report.Verify.Count > 0) + { + sb.Append("## Verification\n\n"); + sb.Append("| Target | Result | Details |\n"); + sb.Append("| --- | --- | --- |\n"); + foreach (var v in report.Verify) + { + var label = v.Skipped ? "Skipped" : v.Succeeded ? "Pass" : "Fail"; + var details = string.IsNullOrEmpty(v.Details) + ? "—" + : Cell(v.Details); + sb.Append("| ") + .Append(Cell(v.Target)).Append(" | ") + .Append(label).Append(" | ") + .Append(details).Append(" |\n"); + } + sb.Append('\n'); + } + + return sb.ToString(); + } + + /// Renders the report and writes it to as UTF-8 (no BOM). + public static void Write(MigrationReport report, string path) + { + var dir = Path.GetDirectoryName(Path.GetFullPath(path)); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + File.WriteAllText(path, Render(report), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + private static string StatusLabel(ConvertStatus s) => s switch + { + ConvertStatus.Converted => "Converted", + ConvertStatus.Skipped => "Skipped", + ConvertStatus.Review => "Review", + ConvertStatus.Error => "Error", + _ => "?", + }; + + private static string Notes(ReportEntry p) => p.Status switch + { + ConvertStatus.Error => Cell(p.Error ?? "error"), + ConvertStatus.Skipped => "already SDK-style", + ConvertStatus.Review => $"{p.Review.Count} item(s) need review", + _ => "clean", + }; + + // Escapes the few characters that would break a Markdown table cell: pipes + // (column separators) and newlines (row separators ->
). + private static string Cell(string s) => + s.Replace("\\", "\\\\") + .Replace("|", "\\|") + .Replace("\r\n", "
") + .Replace("\n", "
") + .Replace("\r", "
"); +} diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Reporting/MigrationReport.cs b/tools/NanoMigrate/src/NanoMigrate.Core/Reporting/MigrationReport.cs new file mode 100644 index 0000000..8ef7b99 --- /dev/null +++ b/tools/NanoMigrate/src/NanoMigrate.Core/Reporting/MigrationReport.cs @@ -0,0 +1,93 @@ +using NanoFramework.Migrate.Core.Projects; +using NanoFramework.Migrate.Core.Verification; + +namespace NanoFramework.Migrate.Core.Reporting; + +/// +/// The result of a single project within a , expressed +/// as plain DATA so the report writers stay pure and console-free. Mirrors the data +/// the on-screen summary table renders, but decoupled from any presentation layer. +/// +public sealed class ReportEntry +{ + /// The project path, relative to the report's root, with forward slashes. + public required string RelativePath { get; init; } + + /// The conversion status of the project. + public required ConvertStatus Status { get; init; } + + /// The resolved PackageReference ids and versions the project carries. + public IReadOnlyList> Packages { get; init; } = + Array.Empty>(); + + /// The items flagged for manual review (empty when none). + public IReadOnlyList Review { get; init; } = Array.Empty(); + + /// An optional error message (set only when is Error). + public string? Error { get; init; } +} + +/// +/// One verification build result inside a . A flat, +/// presentation-free projection of keyed by a relative +/// target path so the writers don't depend on the verification slice's shape. +/// +public sealed class ReportVerifyEntry +{ + /// The built target, relative to the report's root where possible. + public required string Target { get; init; } + + /// True when the build succeeded. + public required bool Succeeded { get; init; } + + /// True when the build was skipped (e.g. no dotnet on PATH). + public required bool Skipped { get; init; } + + /// The build process exit code. + public int ExitCode { get; init; } + + /// A short note: the skip reason or the error tail on failure. + public string? Details { get; init; } +} + +/// +/// A migration run captured as DATA, ready to be rendered by the +/// or . The engine +/// never calls DateTime.Now: the timestamp is supplied by the caller so the +/// writers stay pure and deterministically testable. +/// +public sealed class MigrationReport +{ + /// The root directory the migration ran against (paths are relative to it). + public required string RootPath { get; init; } + + /// A UTC timestamp for the run, supplied by the caller. + public required DateTime GeneratedUtc { get; init; } + + /// True when this report describes a dry-run (what WOULD change). + public bool DryRun { get; init; } + + /// The per-project results, in run order. + public IReadOnlyList Projects { get; init; } = Array.Empty(); + + /// The solutions retargeted (or, in dry-run, that would be retargeted). + public IReadOnlyList AffectedSolutions { get; init; } = Array.Empty(); + + /// The verification build results, when a verify pass ran (otherwise empty). + public IReadOnlyList Verify { get; init; } = Array.Empty(); + + /// Projects that converted cleanly. + public int Converted => Projects.Count(p => p.Status == ConvertStatus.Converted); + + /// Projects already SDK-style (skipped). + public int Skipped => Projects.Count(p => p.Status == ConvertStatus.Skipped); + + /// Projects converted but flagged for manual review. + public int Flagged => Projects.Count(p => p.Status == ConvertStatus.Review); + + /// Projects that errored. + public int Errors => Projects.Count(p => p.Status == ConvertStatus.Error); + + /// The total number of projects in the report. + public int Total => Projects.Count; +} diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj b/tools/NanoMigrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj index 8831a23..2cbe574 100644 --- a/tools/NanoMigrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj +++ b/tools/NanoMigrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj @@ -17,6 +17,8 @@ + + - + From 967078f535d0659ed7c6e63344a8daa02c57a91c Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Tue, 16 Jun 2026 13:43:21 -0500 Subject: [PATCH 22/49] Rename the migrate tool grouping dir: tools/NanoMigrate -> tools/migrate Parallel to tools/nano (the umbrella): each capability gets a named group under tools/. The migrate projects keep their NanoMigrate.* names; only the grouping directory changes. Updated nanoFramework.Tool.slnx paths and the umbrella's ProjectReference. Both solutions build; 108 NanoMigrate + 8 umbrella tests pass; nano tool packs. --- nanoFramework.Tool.slnx | 8 ++++---- tools/{NanoMigrate => migrate}/NanoMigrate.sln | 0 tools/{NanoMigrate => migrate}/nuget.config | 0 .../src/NanoMigrate.Cli.Commands/CleanCommand.cs | 0 .../src/NanoMigrate.Cli.Commands/MigrateCommand.cs | 0 .../src/NanoMigrate.Cli.Commands/MigrateRegistration.cs | 0 .../NanoMigrate.Cli.Commands.csproj | 0 .../NanoMigrate.Cli.Commands/Rendering/ConsoleSupport.cs | 0 .../NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs | 0 .../Rendering/MigrationReportBuilder.cs | 0 .../src/NanoMigrate.Cli.Commands/RollbackCommand.cs | 0 .../src/NanoMigrate.Cli/Cli/CloneCommand.cs | 0 .../src/NanoMigrate.Cli/Cli/FleetCommand.cs | 0 .../src/NanoMigrate.Cli/GitHub.cs | 0 .../src/NanoMigrate.Cli/NanoMigrate.Cli.csproj | 0 .../src/NanoMigrate.Cli/ProcessRunner.cs | 0 .../src/NanoMigrate.Cli/Program.cs | 0 .../src/NanoMigrate.Cli/Rendering/FleetRenderer.cs | 0 .../src/NanoMigrate.Cli/UserError.cs | 0 .../src/NanoMigrate.Core/Backup/BackupCleaner.cs | 0 .../src/NanoMigrate.Core/Backup/MigrationJournaling.cs | 0 .../src/NanoMigrate.Core/Backup/RollbackJournal.cs | 0 .../src/NanoMigrate.Core/Common/Glob.cs | 0 .../src/NanoMigrate.Core/Common/ProjectScanner.cs | 0 .../src/NanoMigrate.Core/Fleet/FleetService.cs | 0 .../src/NanoMigrate.Core/Fleet/RepoReport.cs | 0 .../src/NanoMigrate.Core/NanoMigrate.Core.csproj | 0 .../src/NanoMigrate.Core/Projects/ConversionOptions.cs | 0 .../src/NanoMigrate.Core/Projects/ConvertResult.cs | 0 .../src/NanoMigrate.Core/Projects/IProjectConverter.cs | 0 .../src/NanoMigrate.Core/Projects/ProjectConverter.cs | 0 .../src/NanoMigrate.Core/Reporting/HtmlReportWriter.cs | 0 .../NanoMigrate.Core/Reporting/MarkdownReportWriter.cs | 0 .../src/NanoMigrate.Core/Reporting/MigrationReport.cs | 0 .../src/NanoMigrate.Core/Solutions/MigrationPlan.cs | 0 .../src/NanoMigrate.Core/Solutions/SolutionDiscovery.cs | 0 .../src/NanoMigrate.Core/Solutions/SolutionFile.cs | 0 .../src/NanoMigrate.Core/Solutions/SolutionRewriter.cs | 0 .../src/NanoMigrate.Core/Solutions/SolutionScanner.cs | 0 .../src/NanoMigrate.Core/Verification/SolutionBuilder.cs | 0 .../src/NanoMigrate.Core/Verification/Verification.cs | 0 .../tests/NanoMigrate.Tests/CleanTests.cs | 0 .../tests/NanoMigrate.Tests/ConverterTests.cs | 0 .../tests/NanoMigrate.Tests/CpmTests.cs | 0 .../tests/NanoMigrate.Tests/GlobTests.cs | 0 .../tests/NanoMigrate.Tests/HintPathTests.cs | 0 .../tests/NanoMigrate.Tests/ItemGlobTests.cs | 0 .../tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj | 0 .../tests/NanoMigrate.Tests/OutputTypeTests.cs | 0 .../tests/NanoMigrate.Tests/PackageResolutionTests.cs | 0 .../tests/NanoMigrate.Tests/ProjectScannerTests.cs | 0 .../tests/NanoMigrate.Tests/ReportingTests.cs | 0 .../tests/NanoMigrate.Tests/RollbackTests.cs | 0 .../tests/NanoMigrate.Tests/SolutionRewriteTests.cs | 0 .../tests/NanoMigrate.Tests/SolutionTests.cs | 0 .../tests/NanoMigrate.Tests/TempDir.cs | 0 .../tests/NanoMigrate.Tests/VerifyTests.cs | 0 tools/nano/nanoFramework.Tool/nanoFramework.Tool.csproj | 2 +- 58 files changed, 5 insertions(+), 5 deletions(-) rename tools/{NanoMigrate => migrate}/NanoMigrate.sln (100%) rename tools/{NanoMigrate => migrate}/nuget.config (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Cli.Commands/CleanCommand.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Cli.Commands/MigrateCommand.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Cli.Commands/MigrateRegistration.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Cli.Commands/Rendering/ConsoleSupport.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Cli.Commands/Rendering/MigrationReportBuilder.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Cli.Commands/RollbackCommand.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Cli/Cli/CloneCommand.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Cli/Cli/FleetCommand.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Cli/GitHub.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Cli/ProcessRunner.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Cli/Program.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Cli/UserError.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Backup/BackupCleaner.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Backup/MigrationJournaling.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Backup/RollbackJournal.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Common/Glob.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Common/ProjectScanner.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Fleet/FleetService.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Fleet/RepoReport.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/NanoMigrate.Core.csproj (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Projects/ConversionOptions.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Projects/ConvertResult.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Projects/IProjectConverter.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Projects/ProjectConverter.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Reporting/HtmlReportWriter.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Reporting/MarkdownReportWriter.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Reporting/MigrationReport.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Solutions/MigrationPlan.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Solutions/SolutionDiscovery.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Solutions/SolutionFile.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Solutions/SolutionRewriter.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Solutions/SolutionScanner.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Verification/SolutionBuilder.cs (100%) rename tools/{NanoMigrate => migrate}/src/NanoMigrate.Core/Verification/Verification.cs (100%) rename tools/{NanoMigrate => migrate}/tests/NanoMigrate.Tests/CleanTests.cs (100%) rename tools/{NanoMigrate => migrate}/tests/NanoMigrate.Tests/ConverterTests.cs (100%) rename tools/{NanoMigrate => migrate}/tests/NanoMigrate.Tests/CpmTests.cs (100%) rename tools/{NanoMigrate => migrate}/tests/NanoMigrate.Tests/GlobTests.cs (100%) rename tools/{NanoMigrate => migrate}/tests/NanoMigrate.Tests/HintPathTests.cs (100%) rename tools/{NanoMigrate => migrate}/tests/NanoMigrate.Tests/ItemGlobTests.cs (100%) rename tools/{NanoMigrate => migrate}/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj (100%) rename tools/{NanoMigrate => migrate}/tests/NanoMigrate.Tests/OutputTypeTests.cs (100%) rename tools/{NanoMigrate => migrate}/tests/NanoMigrate.Tests/PackageResolutionTests.cs (100%) rename tools/{NanoMigrate => migrate}/tests/NanoMigrate.Tests/ProjectScannerTests.cs (100%) rename tools/{NanoMigrate => migrate}/tests/NanoMigrate.Tests/ReportingTests.cs (100%) rename tools/{NanoMigrate => migrate}/tests/NanoMigrate.Tests/RollbackTests.cs (100%) rename tools/{NanoMigrate => migrate}/tests/NanoMigrate.Tests/SolutionRewriteTests.cs (100%) rename tools/{NanoMigrate => migrate}/tests/NanoMigrate.Tests/SolutionTests.cs (100%) rename tools/{NanoMigrate => migrate}/tests/NanoMigrate.Tests/TempDir.cs (100%) rename tools/{NanoMigrate => migrate}/tests/NanoMigrate.Tests/VerifyTests.cs (100%) diff --git a/nanoFramework.Tool.slnx b/nanoFramework.Tool.slnx index 72005b7..c904002 100644 --- a/nanoFramework.Tool.slnx +++ b/nanoFramework.Tool.slnx @@ -4,9 +4,9 @@ - - - - + + + + diff --git a/tools/NanoMigrate/NanoMigrate.sln b/tools/migrate/NanoMigrate.sln similarity index 100% rename from tools/NanoMigrate/NanoMigrate.sln rename to tools/migrate/NanoMigrate.sln diff --git a/tools/NanoMigrate/nuget.config b/tools/migrate/nuget.config similarity index 100% rename from tools/NanoMigrate/nuget.config rename to tools/migrate/nuget.config diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/CleanCommand.cs b/tools/migrate/src/NanoMigrate.Cli.Commands/CleanCommand.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Cli.Commands/CleanCommand.cs rename to tools/migrate/src/NanoMigrate.Cli.Commands/CleanCommand.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs b/tools/migrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs rename to tools/migrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateRegistration.cs b/tools/migrate/src/NanoMigrate.Cli.Commands/MigrateRegistration.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Cli.Commands/MigrateRegistration.cs rename to tools/migrate/src/NanoMigrate.Cli.Commands/MigrateRegistration.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj b/tools/migrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj rename to tools/migrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/ConsoleSupport.cs b/tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/ConsoleSupport.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/ConsoleSupport.cs rename to tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/ConsoleSupport.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs b/tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs rename to tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrationReportBuilder.cs b/tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/MigrationReportBuilder.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Cli.Commands/Rendering/MigrationReportBuilder.cs rename to tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/MigrationReportBuilder.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli.Commands/RollbackCommand.cs b/tools/migrate/src/NanoMigrate.Cli.Commands/RollbackCommand.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Cli.Commands/RollbackCommand.cs rename to tools/migrate/src/NanoMigrate.Cli.Commands/RollbackCommand.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/CloneCommand.cs b/tools/migrate/src/NanoMigrate.Cli/Cli/CloneCommand.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Cli/Cli/CloneCommand.cs rename to tools/migrate/src/NanoMigrate.Cli/Cli/CloneCommand.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Cli/FleetCommand.cs b/tools/migrate/src/NanoMigrate.Cli/Cli/FleetCommand.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Cli/Cli/FleetCommand.cs rename to tools/migrate/src/NanoMigrate.Cli/Cli/FleetCommand.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/GitHub.cs b/tools/migrate/src/NanoMigrate.Cli/GitHub.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Cli/GitHub.cs rename to tools/migrate/src/NanoMigrate.Cli/GitHub.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj b/tools/migrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj rename to tools/migrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/ProcessRunner.cs b/tools/migrate/src/NanoMigrate.Cli/ProcessRunner.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Cli/ProcessRunner.cs rename to tools/migrate/src/NanoMigrate.Cli/ProcessRunner.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Program.cs b/tools/migrate/src/NanoMigrate.Cli/Program.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Cli/Program.cs rename to tools/migrate/src/NanoMigrate.Cli/Program.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs b/tools/migrate/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs rename to tools/migrate/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Cli/UserError.cs b/tools/migrate/src/NanoMigrate.Cli/UserError.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Cli/UserError.cs rename to tools/migrate/src/NanoMigrate.Cli/UserError.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Backup/BackupCleaner.cs b/tools/migrate/src/NanoMigrate.Core/Backup/BackupCleaner.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Backup/BackupCleaner.cs rename to tools/migrate/src/NanoMigrate.Core/Backup/BackupCleaner.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Backup/MigrationJournaling.cs b/tools/migrate/src/NanoMigrate.Core/Backup/MigrationJournaling.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Backup/MigrationJournaling.cs rename to tools/migrate/src/NanoMigrate.Core/Backup/MigrationJournaling.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Backup/RollbackJournal.cs b/tools/migrate/src/NanoMigrate.Core/Backup/RollbackJournal.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Backup/RollbackJournal.cs rename to tools/migrate/src/NanoMigrate.Core/Backup/RollbackJournal.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Common/Glob.cs b/tools/migrate/src/NanoMigrate.Core/Common/Glob.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Common/Glob.cs rename to tools/migrate/src/NanoMigrate.Core/Common/Glob.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Common/ProjectScanner.cs b/tools/migrate/src/NanoMigrate.Core/Common/ProjectScanner.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Common/ProjectScanner.cs rename to tools/migrate/src/NanoMigrate.Core/Common/ProjectScanner.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Fleet/FleetService.cs b/tools/migrate/src/NanoMigrate.Core/Fleet/FleetService.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Fleet/FleetService.cs rename to tools/migrate/src/NanoMigrate.Core/Fleet/FleetService.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Fleet/RepoReport.cs b/tools/migrate/src/NanoMigrate.Core/Fleet/RepoReport.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Fleet/RepoReport.cs rename to tools/migrate/src/NanoMigrate.Core/Fleet/RepoReport.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/NanoMigrate.Core.csproj b/tools/migrate/src/NanoMigrate.Core/NanoMigrate.Core.csproj similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/NanoMigrate.Core.csproj rename to tools/migrate/src/NanoMigrate.Core/NanoMigrate.Core.csproj diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Projects/ConversionOptions.cs b/tools/migrate/src/NanoMigrate.Core/Projects/ConversionOptions.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Projects/ConversionOptions.cs rename to tools/migrate/src/NanoMigrate.Core/Projects/ConversionOptions.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Projects/ConvertResult.cs b/tools/migrate/src/NanoMigrate.Core/Projects/ConvertResult.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Projects/ConvertResult.cs rename to tools/migrate/src/NanoMigrate.Core/Projects/ConvertResult.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Projects/IProjectConverter.cs b/tools/migrate/src/NanoMigrate.Core/Projects/IProjectConverter.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Projects/IProjectConverter.cs rename to tools/migrate/src/NanoMigrate.Core/Projects/IProjectConverter.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Projects/ProjectConverter.cs b/tools/migrate/src/NanoMigrate.Core/Projects/ProjectConverter.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Projects/ProjectConverter.cs rename to tools/migrate/src/NanoMigrate.Core/Projects/ProjectConverter.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Reporting/HtmlReportWriter.cs b/tools/migrate/src/NanoMigrate.Core/Reporting/HtmlReportWriter.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Reporting/HtmlReportWriter.cs rename to tools/migrate/src/NanoMigrate.Core/Reporting/HtmlReportWriter.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Reporting/MarkdownReportWriter.cs b/tools/migrate/src/NanoMigrate.Core/Reporting/MarkdownReportWriter.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Reporting/MarkdownReportWriter.cs rename to tools/migrate/src/NanoMigrate.Core/Reporting/MarkdownReportWriter.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Reporting/MigrationReport.cs b/tools/migrate/src/NanoMigrate.Core/Reporting/MigrationReport.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Reporting/MigrationReport.cs rename to tools/migrate/src/NanoMigrate.Core/Reporting/MigrationReport.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Solutions/MigrationPlan.cs b/tools/migrate/src/NanoMigrate.Core/Solutions/MigrationPlan.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Solutions/MigrationPlan.cs rename to tools/migrate/src/NanoMigrate.Core/Solutions/MigrationPlan.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionDiscovery.cs b/tools/migrate/src/NanoMigrate.Core/Solutions/SolutionDiscovery.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionDiscovery.cs rename to tools/migrate/src/NanoMigrate.Core/Solutions/SolutionDiscovery.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionFile.cs b/tools/migrate/src/NanoMigrate.Core/Solutions/SolutionFile.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionFile.cs rename to tools/migrate/src/NanoMigrate.Core/Solutions/SolutionFile.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionRewriter.cs b/tools/migrate/src/NanoMigrate.Core/Solutions/SolutionRewriter.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionRewriter.cs rename to tools/migrate/src/NanoMigrate.Core/Solutions/SolutionRewriter.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionScanner.cs b/tools/migrate/src/NanoMigrate.Core/Solutions/SolutionScanner.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Solutions/SolutionScanner.cs rename to tools/migrate/src/NanoMigrate.Core/Solutions/SolutionScanner.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Verification/SolutionBuilder.cs b/tools/migrate/src/NanoMigrate.Core/Verification/SolutionBuilder.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Verification/SolutionBuilder.cs rename to tools/migrate/src/NanoMigrate.Core/Verification/SolutionBuilder.cs diff --git a/tools/NanoMigrate/src/NanoMigrate.Core/Verification/Verification.cs b/tools/migrate/src/NanoMigrate.Core/Verification/Verification.cs similarity index 100% rename from tools/NanoMigrate/src/NanoMigrate.Core/Verification/Verification.cs rename to tools/migrate/src/NanoMigrate.Core/Verification/Verification.cs diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/CleanTests.cs b/tools/migrate/tests/NanoMigrate.Tests/CleanTests.cs similarity index 100% rename from tools/NanoMigrate/tests/NanoMigrate.Tests/CleanTests.cs rename to tools/migrate/tests/NanoMigrate.Tests/CleanTests.cs diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/ConverterTests.cs b/tools/migrate/tests/NanoMigrate.Tests/ConverterTests.cs similarity index 100% rename from tools/NanoMigrate/tests/NanoMigrate.Tests/ConverterTests.cs rename to tools/migrate/tests/NanoMigrate.Tests/ConverterTests.cs diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/CpmTests.cs b/tools/migrate/tests/NanoMigrate.Tests/CpmTests.cs similarity index 100% rename from tools/NanoMigrate/tests/NanoMigrate.Tests/CpmTests.cs rename to tools/migrate/tests/NanoMigrate.Tests/CpmTests.cs diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/GlobTests.cs b/tools/migrate/tests/NanoMigrate.Tests/GlobTests.cs similarity index 100% rename from tools/NanoMigrate/tests/NanoMigrate.Tests/GlobTests.cs rename to tools/migrate/tests/NanoMigrate.Tests/GlobTests.cs diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/HintPathTests.cs b/tools/migrate/tests/NanoMigrate.Tests/HintPathTests.cs similarity index 100% rename from tools/NanoMigrate/tests/NanoMigrate.Tests/HintPathTests.cs rename to tools/migrate/tests/NanoMigrate.Tests/HintPathTests.cs diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/ItemGlobTests.cs b/tools/migrate/tests/NanoMigrate.Tests/ItemGlobTests.cs similarity index 100% rename from tools/NanoMigrate/tests/NanoMigrate.Tests/ItemGlobTests.cs rename to tools/migrate/tests/NanoMigrate.Tests/ItemGlobTests.cs diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj b/tools/migrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj similarity index 100% rename from tools/NanoMigrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj rename to tools/migrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/OutputTypeTests.cs b/tools/migrate/tests/NanoMigrate.Tests/OutputTypeTests.cs similarity index 100% rename from tools/NanoMigrate/tests/NanoMigrate.Tests/OutputTypeTests.cs rename to tools/migrate/tests/NanoMigrate.Tests/OutputTypeTests.cs diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/PackageResolutionTests.cs b/tools/migrate/tests/NanoMigrate.Tests/PackageResolutionTests.cs similarity index 100% rename from tools/NanoMigrate/tests/NanoMigrate.Tests/PackageResolutionTests.cs rename to tools/migrate/tests/NanoMigrate.Tests/PackageResolutionTests.cs diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/ProjectScannerTests.cs b/tools/migrate/tests/NanoMigrate.Tests/ProjectScannerTests.cs similarity index 100% rename from tools/NanoMigrate/tests/NanoMigrate.Tests/ProjectScannerTests.cs rename to tools/migrate/tests/NanoMigrate.Tests/ProjectScannerTests.cs diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/ReportingTests.cs b/tools/migrate/tests/NanoMigrate.Tests/ReportingTests.cs similarity index 100% rename from tools/NanoMigrate/tests/NanoMigrate.Tests/ReportingTests.cs rename to tools/migrate/tests/NanoMigrate.Tests/ReportingTests.cs diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/RollbackTests.cs b/tools/migrate/tests/NanoMigrate.Tests/RollbackTests.cs similarity index 100% rename from tools/NanoMigrate/tests/NanoMigrate.Tests/RollbackTests.cs rename to tools/migrate/tests/NanoMigrate.Tests/RollbackTests.cs diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionRewriteTests.cs b/tools/migrate/tests/NanoMigrate.Tests/SolutionRewriteTests.cs similarity index 100% rename from tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionRewriteTests.cs rename to tools/migrate/tests/NanoMigrate.Tests/SolutionRewriteTests.cs diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionTests.cs b/tools/migrate/tests/NanoMigrate.Tests/SolutionTests.cs similarity index 100% rename from tools/NanoMigrate/tests/NanoMigrate.Tests/SolutionTests.cs rename to tools/migrate/tests/NanoMigrate.Tests/SolutionTests.cs diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/TempDir.cs b/tools/migrate/tests/NanoMigrate.Tests/TempDir.cs similarity index 100% rename from tools/NanoMigrate/tests/NanoMigrate.Tests/TempDir.cs rename to tools/migrate/tests/NanoMigrate.Tests/TempDir.cs diff --git a/tools/NanoMigrate/tests/NanoMigrate.Tests/VerifyTests.cs b/tools/migrate/tests/NanoMigrate.Tests/VerifyTests.cs similarity index 100% rename from tools/NanoMigrate/tests/NanoMigrate.Tests/VerifyTests.cs rename to tools/migrate/tests/NanoMigrate.Tests/VerifyTests.cs diff --git a/tools/nano/nanoFramework.Tool/nanoFramework.Tool.csproj b/tools/nano/nanoFramework.Tool/nanoFramework.Tool.csproj index 3f59072..d92a0ef 100644 --- a/tools/nano/nanoFramework.Tool/nanoFramework.Tool.csproj +++ b/tools/nano/nanoFramework.Tool/nanoFramework.Tool.csproj @@ -42,7 +42,7 @@ - + From dc47d4090f5d88bd73270a67c380be5e3132b70d Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Tue, 16 Jun 2026 13:46:13 -0500 Subject: [PATCH 23/49] docs(skill): update tool path tools/NanoMigrate -> tools/migrate --- skills/nanoframework-sdk-migration/SKILL.md | 6 +++--- .../references/migration-rules.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/skills/nanoframework-sdk-migration/SKILL.md b/skills/nanoframework-sdk-migration/SKILL.md index 690d5fe..c05c2c1 100644 --- a/skills/nanoframework-sdk-migration/SKILL.md +++ b/skills/nanoframework-sdk-migration/SKILL.md @@ -16,7 +16,7 @@ description: >- Convert a nanoFramework repo from the legacy flavored `.nfproj` project system onto SDK-style projects that compose over `nanoFramework.NET.Sdk`. The mechanical conversion is done by the -**NanoMigrate** tool (in this repo at `tools/NanoMigrate`, surfaced as `dotnet nano migrate`). The +**NanoMigrate** tool (in this repo at `tools/migrate`, surfaced as `dotnet nano migrate`). The tool is **idempotent + reentrant**: it skips already-SDK-style projects and re-running over a tree is a safe no-op, so a partial or repeated migration is never destructive. @@ -33,7 +33,7 @@ in [references/contributing-compliance.md](references/contributing-compliance.md 1. **Dry-run** to preview every change (writes nothing): ``` dotnet nano migrate --dry-run - # or, from this repo: dotnet run --project tools/NanoMigrate -- migrate --dry-run + # or, from this repo: dotnet run --project tools/migrate -- migrate --dry-run ``` Review the preview table: the target `.csproj`, the resolved `PackageReference`s, the files that would be deleted, the `.sln` edits, and anything in the yellow **manual review** panel. @@ -83,7 +83,7 @@ Entries in the yellow "manual review" panel did not resolve automatically — ty ## Fleet migration (many repos) ``` -dotnet run --project tools/NanoMigrate -- fleet [--glob "..."] [--branch ] [--commit] +dotnet run --project tools/migrate -- fleet [--glob "..."] [--branch ] [--commit] ``` Walks each repo, converts every matching `.nfproj`, and (with `--branch`/`--commit`) commits on a diff --git a/skills/nanoframework-sdk-migration/references/migration-rules.md b/skills/nanoframework-sdk-migration/references/migration-rules.md index ff5bff9..9ff7b4f 100644 --- a/skills/nanoframework-sdk-migration/references/migration-rules.md +++ b/skills/nanoframework-sdk-migration/references/migration-rules.md @@ -1,6 +1,6 @@ # Migration rules reference -The precise rules the converter (`tools/NanoMigrate`) applies, the reasoning +The precise rules the converter (`tools/migrate`) applies, the reasoning behind each, and the edge cases you may meet. Read this when a `MANUAL REVIEW NEEDED` item appears, when output looks surprising, or when changing the rules. From 7c05b4a5bbe59a1360734885b9a6166e181539f1 Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Tue, 16 Jun 2026 15:57:41 -0500 Subject: [PATCH 24/49] docs: add nano + nano-migrate tool READMEs; expand migration skill Add tools/nano/README.md (the dotnet nano umbrella CLI) and tools/migrate/README.md (the NanoMigrate converter: migrate/clean/rollback/clone/fleet), generated from the plan and verified against each tool's --help. Link both from the repo README. Expand the migration skill with the full command surface (clean, rollback, verify, report, clone/fleet), a commands-at-a-glance table, the rollback-journal/clean workflow, and a --help discovery tip pointing at the READMEs as the source of truth. --- README.md | 17 +- skills/nanoframework-sdk-migration/SKILL.md | 64 +++++- tools/migrate/README.md | 206 ++++++++++++++++++++ tools/nano/README.md | 134 +++++++++++++ 4 files changed, 416 insertions(+), 5 deletions(-) create mode 100644 tools/migrate/README.md create mode 100644 tools/nano/README.md diff --git a/README.md b/README.md index 7a3aa5f..3998e2b 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,25 @@ Or pin the SDK version in `global.json`: ``` src/ - nanoFramework.Sdk/ SDK NuGet package (Sdk.props / Sdk.targets) + nanoFramework.NET.Sdk/ SDK NuGet package (Sdk.props / Sdk.targets) nanoFramework.Tools.BuildTasks/ Custom MSBuild tasks (resource gen, binary output, etc.) +tools/ + nano/ dotnet nano — the umbrella CLI (see tools/nano/README.md) + migrate/ NanoMigrate — .nfproj -> SDK-style converter (see tools/migrate/README.md) ``` +## Tools + +The repo ships two CLIs alongside the SDK: + +- **[`dotnet nano`](tools/nano/README.md)** (`nanoFramework.Tool`) — the umbrella CLI: built-in + managed commands (`migrate`, `clean`, `rollback`) plus wrapped external tools (`flash` → `nanoff`). +- **[`nano-migrate`](tools/migrate/README.md)** (`nanoFramework.Migrate`) — the standalone migration + tool that converts legacy `.nfproj` projects to SDK-style, single-repo or across an entire fleet. + +The companion installable **[migration skill](skills/nanoframework-sdk-migration)** drives the +converter with the full rule set and contribution/PR conventions. + ## Development ```bash diff --git a/skills/nanoframework-sdk-migration/SKILL.md b/skills/nanoframework-sdk-migration/SKILL.md index c05c2c1..ba992ad 100644 --- a/skills/nanoframework-sdk-migration/SKILL.md +++ b/skills/nanoframework-sdk-migration/SKILL.md @@ -20,6 +20,16 @@ projects that compose over `nanoFramework.NET.Sdk`. The mechanical conversion is tool is **idempotent + reentrant**: it skips already-SDK-style projects and re-running over a tree is a safe no-op, so a partial or repeated migration is never destructive. +The full, as-built command/option reference is [`tools/migrate/README.md`](../../tools/migrate/README.md) +(and [`tools/nano/README.md`](../../tools/nano/README.md) for the umbrella). It is the source of +truth; this skill is the workflow guide. **Always confirm the live surface with `--help`** — it +matches the installed version exactly: + +``` +dotnet nano --help # every command +dotnet nano migrate --help # options for one command +``` + ## Scope — read this first Project-system migration **only**. Do not add, and do not ask the tool to touch: OTA update @@ -28,12 +38,29 @@ exact per-project transformation rules are in [references/migration-rules.md](references/migration-rules.md); contribution + PR conventions are in [references/contributing-compliance.md](references/contributing-compliance.md). +## Commands at a glance + +Run any of these as `dotnet nano ` (umbrella) or `nano-migrate ` (standalone) — +or from source with `dotnet run --project tools/migrate/src/NanoMigrate.Cli -- `. + +| Command | What it does | +|---|---| +| `migrate ` | Convert a `.nfproj`, a solution (`.sln`/`.slnx`), or every `.nfproj` under a directory. | +| `clean [path]` | Remove migration leftovers: `*.nfproj.bak` files and `.nanomigrate/` rollback folders. | +| `rollback [path]` | Revert the last recorded migration (restore originals, delete created projects). | +| `clone [out-dir]` | Clone all matching repos from a GitHub org (fleet prep). *Standalone CLI only.* | +| `fleet ` | Migrate every `.nfproj` across cloned repos; write a report; optionally branch + commit. *Standalone CLI only.* | + +Key `migrate` options (see `migrate --help` / the README for the rest): `--dry-run`, `--glob

`, +`--solution

`, `--ext .csproj|.nfproj`, `--no-backup`, `--report `, +`--verify`/`--no-verify`, `-y|--yes`. + ## Always test a directory first, then migrate for real 1. **Dry-run** to preview every change (writes nothing): ``` dotnet nano migrate --dry-run - # or, from this repo: dotnet run --project tools/migrate -- migrate --dry-run + # or, from this repo: dotnet run --project tools/migrate/src/NanoMigrate.Cli -- migrate --dry-run ``` Review the preview table: the target `.csproj`, the resolved `PackageReference`s, the files that would be deleted, the `.sln` edits, and anything in the yellow **manual review** panel. @@ -48,6 +75,12 @@ in [references/contributing-compliance.md](references/contributing-compliance.md 4. **Re-run is safe** — reentrant. A partially-converted tree converts only the remaining `.nfproj` and leaves existing `.csproj` untouched. +A real run **verifies by default**: after converting, it builds the affected solution(s)/project(s) +and, on a failed build, offers to roll the whole run back (interactive) or tells you to run +`rollback` later (non-interactive). Use `--no-verify` to skip it. Add `--report ` to emit a +record of what changed — `.md`/`.markdown` → Markdown, `.html`/`.htm` → HTML — which also works +under `--dry-run` (it reports what *would* change). + ## Solutions (.sln / .slnx) Migration is solution-aware and keeps solutions in sync: @@ -80,13 +113,36 @@ Entries in the yellow "manual review" panel did not resolve automatically — ty `` with no HintPath and no matching `packages.config` entry. Add the right `PackageReference` by hand, then re-run (a no-op for everything already converted). +## Back up, roll back, clean up + +- Each real conversion writes a `.nfproj.bak` next to the project (suppress with `--no-backup`). +- Independently, a real run records a **self-contained rollback journal** under `.nanomigrate/`. + `rollback [path]` reverts the last recorded migration (restore originals, delete created files); + it's idempotent — no journal means "nothing to roll back", exit 0. +- When you're satisfied with a migration, `clean [path]` removes the leftovers — every + `*.nfproj.bak` and every `.nanomigrate/` folder — so you don't commit a pile of backups. Both + preview before deleting; add `-y|--yes` to skip the prompt. + ## Fleet migration (many repos) +Two steps — `clone` to fetch the repos, `fleet` to migrate them (`clone`/`fleet` are in the +standalone `nano-migrate` CLI). From source: + ``` -dotnet run --project tools/migrate -- fleet [--glob "..."] [--branch ] [--commit] +# 1) clone a GitHub org's matching repos (default org nanoframework, filter lib-) +dotnet run --project tools/migrate/src/NanoMigrate.Cli -- clone ./nano-repos --token $GITHUB_TOKEN + +# 2) dry-run the whole fleet and read the report before changing anything +dotnet run --project tools/migrate/src/NanoMigrate.Cli -- fleet ./nano-repos --dry-run --report fleet-report.md + +# 3) apply on a branch and commit, so each repo ends up ready to PR +dotnet run --project tools/migrate/src/NanoMigrate.Cli -- fleet ./nano-repos \ + --branch sdk-migration --commit --issue --report fleet-report.md ``` -Walks each repo, converts every matching `.nfproj`, and (with `--branch`/`--commit`) commits on a -branch. Open PRs from the nanoFramework org template — see +`fleet` walks each repo, converts every matching `.nfproj`, writes the report, and (with +`--branch`/`--commit`) commits a contribution-compliant, signed-off message; `--commit` implies +`--no-backup`. It stops at the commit — it never pushes or opens PRs. Open PRs from the +nanoFramework org template — see [references/contributing-compliance.md](references/contributing-compliance.md). Convert leaf-first (dependencies before dependents); branch names must not start with `develop`; never target `main`. diff --git a/tools/migrate/README.md b/tools/migrate/README.md new file mode 100644 index 0000000..d468ec1 --- /dev/null +++ b/tools/migrate/README.md @@ -0,0 +1,206 @@ +# NanoMigrate — `nano-migrate` + +Convert legacy nanoFramework `.nfproj` projects to the SDK-style MSBuild project system — +one project, a whole directory/solution, or an entire cloned fleet of repositories. + +> **Scope: project-system migration only.** This tool moves a repo from the legacy flavored +> `.nfproj` format onto an SDK-style project that composes over `nanoFramework.NET.Sdk`, folds +> `packages.config` into `PackageReference`, and folds `.nuspec` metadata into MSBuild `Pack` +> properties. It does **not** touch OTA updates, modular firmware packaging, +> `runtimes/{rid}/native` layouts, or ABI manifests. + +The conversion is **idempotent and reentrant**: already-SDK-style projects are skipped and +re-running over a tree is a safe no-op, so a partial or repeated migration is never destructive. + +## Layout + +``` +tools/migrate/ + src/ + NanoMigrate.Core/ # conversion engine — console-free, unit-testable, NuGet-ready library + NanoMigrate.Cli.Commands/ # shared Spectre commands (migrate, clean, rollback) used by both front ends + NanoMigrate.Cli/ # the standalone `nano-migrate` CLI (adds clone + fleet) + tests/ + NanoMigrate.Tests/ # unit tests over NanoMigrate.Core +``` + +The engine (`NanoMigrate.Core`) has no `Console`/`AnsiConsole` dependency, so it is testable and +packable on its own. The same `migrate`/`clean`/`rollback` commands are shared with the +[`dotnet nano`](../nano/README.md) umbrella tool, so `nano-migrate migrate …` and +`dotnet nano migrate …` behave identically. + +### Packages + +| Package id | Command | What it is | +|---|---|---| +| `nanoFramework.Migrate.Core` | — | the console-free conversion engine (library) | +| `nanoFramework.Migrate` | `nano-migrate` | the standalone CLI (this tool) | +| `nanoFramework.Tool` | `dotnet nano` | the umbrella that also hosts `migrate` (see [../nano](../nano/README.md)) | + +## Install / run + +As a .NET global (or local) tool: + +```bash +dotnet tool install -g nanoFramework.Migrate # then: nano-migrate … +# or via the umbrella: +dotnet tool install -g nanoFramework.Tool # then: dotnet nano migrate … +``` + +From source in this repo (no separate build step needed): + +```bash +dotnet run --project tools/migrate/src/NanoMigrate.Cli -- [options] +``` + +For repeated fleet runs, build once and call the dll directly (faster): + +```bash +dotnet build -c Release tools/migrate/src/NanoMigrate.Cli +dotnet tools/migrate/src/NanoMigrate.Cli/bin/Release/net8.0/nano-migrate.dll [options] +``` + +Requires the **.NET 8 SDK**. The tool is BCL-only apart from Spectre.Console and the official +`Microsoft.VisualStudio.SolutionPersistence` solution reader/writer. + +## Discovering commands and options + +Everything below is also discoverable from the tool itself — prefer `--help` as the source of +truth, since it always matches the installed version: + +```bash +nano-migrate --help # list every command +nano-migrate migrate --help # options for one command +dotnet nano migrate --help # same surface via the umbrella +``` + +## Commands + +| Command | Summary | +|---|---| +| `migrate ` | Convert a `.nfproj`, a solution, or every `.nfproj` under a directory. | +| `clean [path]` | Remove migration leftovers: `*.nfproj.bak` files and `.nanomigrate/` rollback folders. | +| `rollback [path]` | Revert the last recorded migration under a path (restore originals, delete created projects). | +| `clone [out-dir]` | Clone all matching repos from a GitHub org (fleet prep). | +| `fleet ` | Migrate every `.nfproj` across cloned repos; write a report; optionally branch + commit. | + +`clone` and `fleet` are only in the standalone `nano-migrate` CLI; `migrate`, `clean`, and +`rollback` are shared with `dotnet nano`. + +### `migrate ` + +`` is a `.nfproj` file, a solution (`.sln`/`.slnx`), or a directory. + +- **Solution** → only its referenced `.nfproj` are converted and that solution is retargeted. +- **Directory with solutions** → you choose which solution(s) to migrate (interactive multi-select; + non-interactive / `--yes` selects all affected). +- **Directory with no solution** → every `.nfproj` under it is converted (loose mode); any solutions + found higher in the tree are retargeted automatically. + +| Option | Default | Meaning | +|---|---|---| +| `--solution ` | — | Migrate only this solution; overrides directory discovery. | +| `--glob ` | — | Only convert `.nfproj` whose path (relative to ``) matches; `*`, `**`, `?` supported (e.g. `"Beginner/**"`). Solutions referencing a matched project are updated. | +| `--tfm ` | `netnano1.0` | Target framework moniker. | +| `--ext ` | `.csproj` | Output extension: `.csproj` (retire `.nfproj`) or `.nfproj` (rewrite in place — lower-risk during a phased rollout). | +| `--no-backup` | off | Don't write a `.nfproj.bak`. Fully suppresses loose backups; the rollback journal stays self-contained in `.nanomigrate/`. | +| `--dry-run` (`--no-write`) | off | Analyse and preview only; write nothing. | +| `--verify` / `--no-verify` | on for real runs, off for `--dry-run` | After a real migration, build the affected solution(s)/project(s); a failed build offers a rollback. | +| `--report ` | — | Write a migration report. Format by extension: `.md`/`.markdown` → Markdown, `.html`/`.htm` → HTML (else Markdown). Works for `--dry-run` too. | +| `--sdk ` | (ignored) | Accepted for back-compat only; the SDK reference is versionless (pinned via `global.json` `msbuild-sdks`). | +| `-y\|--yes` | off | Skip interactive prompts and proceed with the default action. (Non-interactive runs never prompt regardless.) | + +**Exit codes:** `0` clean · `2` completed but some projects need manual review · `1` an error +occurred or a verification build failed (and changes were kept or rolled back per the prompt). + +### `clean [path]` + +Removes migration leftovers under a path: every `*.nfproj.bak` and every `.nanomigrate/` rollback +folder. Previews what will go and confirms before deleting (`-y|--yes` to skip the prompt; +non-interactive proceeds). `[path]` defaults to the current directory. This is the tidy-up step +after a migration you're happy with, so you don't commit a pile of `.bak` files. + +### `rollback [path]` + +Reverts the last recorded migration under a path by reading its `.nanomigrate/` rollback journal: +restores backed-up originals and deletes files the migration created. Idempotent and safe — with no +journal it reports "nothing to roll back" and exits `0`. `[path]` defaults to the current directory. + +### `clone [out-dir]` + +Clones all matching repositories from a GitHub org (the first half of a fleet migration). +`[out-dir]` defaults to `./nano-repos`. + +| Option | Default | Meaning | +|---|---|---| +| `--org ` | `nanoframework` | GitHub org to enumerate. | +| `--filter ` | `lib-` | Repo-name prefix to match. | +| `--token ` | `$GITHUB_TOKEN` | GitHub token to lift the unauthenticated API rate limit. | +| `--include-archived` | off | Include archived repositories (skipped by default). | + +### `fleet ` + +Migrates every `.nfproj` across a directory of cloned repos and writes a report. With `--branch` +(and optionally `--commit`) each repo ends up on a ready-to-PR branch. + +| Option | Default | Meaning | +|---|---|---| +| `--glob ` | — | Only convert matching `.nfproj` within each repo (`*`, `**`, `?`). | +| `--tfm ` | `netnano1.0` | Target framework moniker. | +| `--ext ` | `.csproj` | Output extension (`.csproj` or `.nfproj`). | +| `--no-backup` | off | Don't write `.nfproj.bak` (implied by `--commit` — git history already preserves the original). | +| `--dry-run` (`--no-write`) | off | Analyse and preview only. | +| `--report ` | `migration-report.md` | Report path (Markdown). | +| `--branch ` | — | Create/reset this git branch in each repo. Must not start with `develop`. | +| `--commit` | off | Commit the changes (requires `--branch`); writes a contribution-compliant message and signs off. | +| `--message ` | — | Commit summary line (kept ≤ 50 chars). | +| `--issue ` | — | Add a `Fix #` trailer to the commit. | +| `--no-sign-off` | off | Don't add a `Signed-off-by` line. | + +`fleet` stops at the commit — it never pushes or opens PRs. Migrate **leaf-first** (dependencies +before dependents) and open PRs from the org template (see the contribution guide). **Exit codes:** +`0` all clean · `2` one or more repos errored or need review. + +## What the conversion does (per project) + +- Emits `` with `netnano1.0`. +- Maps `packages.config` / `` HintPath versions to `` — the package id + comes from the `packages\.\` folder, so nano package ids resolve correctly rather + than the bare assembly name. Aliases legacy `mscorlib`/`System` to `nanoFramework.CoreLibrary`. +- Folds `.nuspec` metadata (`id`, `description`, `authors`, `tags`, `projectUrl`) into MSBuild + `Pack` properties. +- Deletes `packages.config` and a hand-written `Properties/AssemblyInfo.cs` (the SDK regenerates + it), while preserving non-default items (linked files, files in subfolders, `EmbeddedResource`, + `Content`). +- Rewrites the `.sln`/`.slnx` entry: project-type GUID → the SDK C#/CPS GUID, path `.nfproj` → + `.csproj`. Solutions are read/written through the official + `Microsoft.VisualStudio.SolutionPersistence` library. +- **Fails loud:** anything it cannot confidently resolve is written to a manual-review list instead + of being silently guessed. + +The exact rule set, including edge cases, lives in the skill at +[`skills/nanoframework-sdk-migration/references/migration-rules.md`](../../skills/nanoframework-sdk-migration/references/migration-rules.md). + +## Backups, the rollback journal, and verification + +- Each real conversion writes a `.nfproj.bak` next to the project (unless `--no-backup`). +- Independently, a real run records a **self-contained rollback journal** under `.nanomigrate/` + (`rollback-/` + `manifest.json`) that backs up every file the run modifies or deletes and + records every file it creates — this is what `rollback` and the post-verify rollback prompt use. +- With `--verify` (default for real runs) the affected solution(s)/project(s) are built after the + conversion; on failure you're offered an immediate rollback (interactive) or advised to run + `rollback` later (non-interactive). +- When you're done, `clean` removes both the loose `*.nfproj.bak` files and the `.nanomigrate/` + folders. + +## Tests + +```bash +dotnet test tools/migrate/tests/NanoMigrate.Tests +``` + +## See also + +- [`../nano/README.md`](../nano/README.md) — the `dotnet nano` umbrella tool that also hosts these commands. +- [`skills/nanoframework-sdk-migration`](../../skills/nanoframework-sdk-migration) — the installable + skill that drives this tool, with the full rule set and contribution/PR conventions. diff --git a/tools/nano/README.md b/tools/nano/README.md new file mode 100644 index 0000000..f84dd8e --- /dev/null +++ b/tools/nano/README.md @@ -0,0 +1,134 @@ +# `dotnet nano` — the nanoFramework umbrella CLI + +One entry point — `dotnet nano ` — for nanoFramework workflows. It hosts built-in managed +commands in-process over engine libraries (e.g. `migrate` over `NanoMigrate.Core`) and wraps +already-built external tools (e.g. `nanoff`) under the same namespace with one uniform +Spectre.Console UX, so the toolchain is **one install and one CLI**. + +## Layout + +``` +tools/nano/ + nanoFramework.Tool/ # the dotnet-nano umbrella (PackAsTool, ToolCommandName=nano) + Program.cs # Spectre CommandApp host (wires the command surface) + Commands/ # flash (nanoff) + deploy/monitor/devices placeholders + ExternalTools/ # IExternalTool + resolver/providers (NanoffTool, …) + nano-tools.json + nanoFramework.Tool.Tests/ # tests for the umbrella (external-tool resolution, arg mapping) +``` + +The `migrate`, `clean`, and `rollback` commands are **not** reimplemented here — the umbrella +references the shared `NanoMigrate.Cli.Commands` library (over the console-free `NanoMigrate.Core` +engine), the same one the standalone [`nano-migrate`](../migrate/README.md) CLI uses. There is one +implementation behind both front ends. + +### Package + +| Package id | Command | What it is | +|---|---|---| +| `nanoFramework.Tool` | `dotnet nano` | this umbrella tool | + +## Install / run + +```bash +dotnet tool install -g nanoFramework.Tool # then: dotnet nano … +``` + +Or install it as a **local** tool (pin the version in `.config/dotnet-tools.json` alongside the +rest of the toolchain so CI is reproducible). From source in this repo: + +```bash +dotnet run --project tools/nano/nanoFramework.Tool -- [options] +``` + +Requires the **.NET 8 SDK**. + +## Discovering commands and options + +Prefer `--help` as the source of truth — it always matches the installed version: + +```bash +dotnet nano --help # list every command +dotnet nano migrate --help # options for one command +``` + +## Commands + +| Command | Kind | Backed by | +|---|---|---| +| `migrate ` | built-in (in-proc) | `NanoMigrate.Core` — convert legacy `.nfproj` to SDK-style | +| `clean [path]` | built-in (in-proc) | remove `*.nfproj.bak` + `.nanomigrate/` leftovers | +| `rollback [path]` | built-in (in-proc) | revert the last recorded migration | +| `flash` | external | `nanoff` (prebuilt release, version-pinned) | +| `deploy` | built-in | *placeholder — not yet implemented in the CLI* | +| `monitor` | built-in | *placeholder — not yet implemented in the CLI* | +| `devices` | built-in | *placeholder — not yet implemented in the CLI* | + +### `migrate` / `clean` / `rollback` + +Identical to the standalone tool — see [`../migrate/README.md`](../migrate/README.md) for the full +option reference. `dotnet nano migrate --dry-run`, `dotnet nano clean`, and +`dotnet nano rollback` all behave exactly as their `nano-migrate` equivalents. (`clone` and `fleet` +live only in the standalone `nano-migrate` CLI.) + +### `flash` + +Resolves and runs the external `nanoff` firmware flasher with mapped arguments. + +| Option | Maps to | Meaning | +|---|---|---| +| `-t\|--target ` | `nanoff --target` | Target board/firmware name (required). | +| `-p\|--port ` | `nanoff --serialport` | Serial port. | +| `--update` | `nanoff --update` | Update the device firmware rather than a clean flash. | + +Anything after a literal `--` is passed straight through to `nanoff`. Example: + +```bash +dotnet nano flash --target ESP32_REV0 --port COM5 +dotnet nano flash --target ESP32_REV0 -- --deploy --image app.bin # passthrough +``` + +### `deploy` / `monitor` / `devices` + +On the design's command surface (so they appear in `--help`) but not yet implemented in the CLI — +they print a "use Visual Studio or VS Code for now" notice and return non-zero. Build/deploy/debug +from the IDE in the meantime. + +## External tools + +External, prebuilt tools are wrapped, not rebuilt: the umbrella deploys/pins already-released +binaries from their own repos and exposes them under `dotnet nano *`. Resolution order for an +external tool (e.g. `nanoff`): + +1. a binary **bundled** in the tool package (`tools//`, prebuilt and version-pinned), +2. a globally/locally **installed** tool or one on `PATH`, +3. **download** a pinned release to a user cache (verified by version/hash). + +> The downloader (step 3) is not wired yet: when nothing resolves, `flash` fails with a clear +> "install nanoff with `dotnet tool install -g nanoff`" message rather than attempting an +> unimplemented network fetch. + +The set of external tools, each pinned version, and its download source are declared in the embedded +**`nano-tools.json`** manifest ([`ExternalTools/nano-tools.json`](nanoFramework.Tool/ExternalTools/nano-tools.json)), +so resolution/fetch is deterministic and CI-reproducible: + +```json +{ + "tools": [ + { "name": "nanoff", "version": "2.5.78", "packageId": "nanoff", + "source": "https://www.nuget.org/api/v2/package/nanoff/2.5.78" } + ] +} +``` + +## Tests + +```bash +dotnet test tools/nano/nanoFramework.Tool.Tests +``` + +## See also + +- [`../migrate/README.md`](../migrate/README.md) — the migrate engine, the standalone `nano-migrate` + CLI, and the full `migrate`/`clean`/`rollback` option reference. +- [`skills/nanoframework-sdk-migration`](../../skills/nanoframework-sdk-migration) — the installable + migration skill. From 934b9a3b7bfdc634a4a6e4ebc7d8f763344229a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Sim=C3=B5es?= Date: Thu, 18 Jun 2026 09:43:01 +0100 Subject: [PATCH 25/49] Wrk CI-CD - Initial version of AZDO pipeline. --- azure-pipelines.yml | 228 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 azure-pipelines.yml diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..3e90472 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,228 @@ +# Copyright (c) .NET Foundation and Contributors +# See LICENSE file in the project root for full license information. + +trigger: + branches: + include: + - main + - develop + paths: + exclude: + - .gitignore + - .github/* + - LICENSE.md + - README.md + - assets/* + + tags: + include: + - v* + +# PR always trigger build +pr: + autoCancel: true + +# add nf-tools repo to resources (for Azure Pipelines templates) +resources: + repositories: + - repository: templates + type: github + name: nanoframework/nf-tools + endpoint: nanoframework + +variables: + - name: DOTNET_NOLOGO + value: true + - name: nugetPackageName + value: 'nanoFramework.NET.Sdk' + +stages: + +######################## +- stage: Build + jobs: + - job: Build_SDK + pool: + vmImage: 'windows-latest' + + variables: + - group: sign-client-credentials + - name: buildPlatform + value: 'Any CPU' + - name: buildConfiguration + value: 'Release' + - name: solution + value: 'nanoFramework.NET.Sdk.sln' + + steps: + - checkout: self + submodules: true + fetchDepth: 0 + + - script: | + git config --global user.email nanoframework@outlook.com + git config --global user.name nfbot + git config --global core.autocrlf true + displayName: Setup git identity + + - template: install-nbgv-tool.yml@templates + + - task: SonarCloudPrepare@3 + condition: >- + and( + succeeded(), + eq(variables['StartReleaseCandidate'], false), + not(startsWith(variables['Build.SourceBranch'], 'refs/tags/v')), + not(${{ parameters.skipSonarCloudAnalysis }}) + ) + displayName: 'Prepare analysis on SonarCloud' + inputs: + SonarCloud: 'sonarcloud-$(System.TeamProject)' + organization: 'nanoframework' + scannerMode: 'dotnet' + projectKey: 'nanoframework_net-sdk' + extraProperties: | + sonar.exclusions=**/obj/**,**/*.dll + sonar.cs.vscoveragexml.reportsPaths=$(Agent.TempDirectory)/**/**.coverage + sonar.cs.vstest.reportsPaths=$(Agent.TempDirectory)/**/**.trx + sonar.scanner.skipJreProvisioning=true + + - template: install-nuget.yml@templates + + - task: DotNetCoreCLI@2 + displayName: Build + inputs: + command: 'build' + projects: '$(solution)' + arguments: '--configuration $(buildConfiguration) /p:PublicRelease=true' + + - task: SonarCloudAnalyze@3 + condition: >- + and( + succeeded(), + eq(variables['StartReleaseCandidate'], false), + not(startsWith(variables['Build.SourceBranch'], 'refs/tags/v')) + ) + displayName: 'Run Code Analysis' + continueOnError: true + + - task: SonarCloudPublish@3 + condition: >- + and( + succeeded(), + eq(variables['StartReleaseCandidate'], false), + not(startsWith(variables['Build.SourceBranch'], 'refs/tags/v')) + ) + displayName: 'Publish Quality Gate Result' + continueOnError: true + + - task: DotNetCoreCLI@2 + displayName: Install Sign Client CLI + condition: >- + and( + succeeded(), + eq(variables['StartReleaseCandidate'], false), + not(startsWith(variables['Build.SourceBranch'], 'refs/tags/v')) + ) + inputs: + command: custom + custom: tool + arguments: install --tool-path . sign --version 0.9.1-beta.23530.1 + + - pwsh: | + .\sign code azure-key-vault ` + "**/nanoFramework.Tools.BuildTasks.*" ` + --base-directory "$(Build.SourcesDirectory)/nanoFramework.NET.Sdk/build/Release" ` + --description "$(Build.Repository.Name)" ` + --description-url "https://github.com/$(Build.Repository.Name)" ` + --azure-key-vault-tenant-id "$(SignTenantId)" ` + --azure-key-vault-client-id "$(SignClientId)" ` + --azure-key-vault-client-secret "$(SignClientSecret)" ` + --azure-key-vault-certificate "$(SignKeyVaultCertificate)" ` + --azure-key-vault-url "$(SignKeyVaultUrl)" ` + --timestamp-url http://timestamp.digicert.com + displayName: Sign DLLs + continueOnError: false + condition: >- + and( + succeeded(), + eq(variables['StartReleaseCandidate'], false), + not(startsWith(variables['Build.SourceBranch'], 'refs/tags/v')) + ) + + - task: DotNetCoreCLI@2 + displayName: Pack + inputs: + command: 'pack' + packagesToPack: '**/src/nanoFramework.NET.Sdk.csproj' + versioningScheme: 'off' + arguments: '--configuration $(buildConfiguration) /p:PublicRelease=true --output $(Build.ArtifactStagingDirectory)' + + - pwsh: | + .\sign code azure-key-vault ` + "**/*.nupkg" ` + --base-directory "$(Build.ArtifactStagingDirectory)" ` + --description "$(Build.Repository.Name)" ` + --description-url "https://github.com/$(Build.Repository.Name)" ` + --azure-key-vault-tenant-id "$(SignTenantId)" ` + --azure-key-vault-client-id "$(SignClientId)" ` + --azure-key-vault-client-secret "$(SignClientSecret)" ` + --azure-key-vault-certificate "$(SignKeyVaultCertificate)" ` + --azure-key-vault-url "$(SignKeyVaultUrl)" ` + --timestamp-url http://timestamp.digicert.com + displayName: Sign NuGet package + continueOnError: true + condition: >- + and( + succeeded(), + eq(variables['StartReleaseCandidate'], false), + not(startsWith(variables['Build.SourceBranch'], 'refs/tags/v')) + ) + + - task: PublishPipelineArtifact@1 + displayName: Publish NuGet package + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)' + artifactName: 'NuGetPackage' + artifactType: 'pipeline' + + - task: NuGetCommand@2 + condition: >- + and( + succeeded(), + eq(variables['System.PullRequest.PullRequestId'], ''), + not(startsWith(variables['Build.SourceBranch'], 'refs/pull/')), + eq(variables['StartReleaseCandidate'], false), + not(startsWith(variables['Build.SourceBranch'], 'refs/tags/v')) + ) + continueOnError: true + displayName: Push NuGet package to NuGet + inputs: + command: push + nuGetFeedType: external + allowPackageConflicts: true + packagesToPush: '$(Build.ArtifactStagingDirectory)/*.nupkg' + publishFeedCredentials: 'NuGet-$(System.TeamProject)' + + +# report build failure to Discord +- stage: Report_Build_Failure + dependsOn: + - Build + condition: failed('Build') + + jobs: + - job: Report_Build_Failure + pool: + vmImage: 'ubuntu-latest' + + steps: + - checkout: self + fetchDepth: 1 + + # step from template @ nf-tools repo + - template: azure-pipelines-templates/discord-webhook.yml@templates + parameters: + status: 'failure' + webhookUrl: '$(DiscordWebhook)' + message: '' From ea7fcd1463cd77606ebb2a04df38afd593f5a068 Mon Sep 17 00:00:00 2001 From: Daniel Meza Date: Tue, 23 Jun 2026 00:41:17 -0500 Subject: [PATCH 26/49] Move the dotnet nano and nano-migrate tools to nanoframework/nf-tools The dotnet nano umbrella CLI and the NanoMigrate project migrator, with the companion SDK-migration skill, now live in the nanoframework/nf-tools tools repository. This repository keeps only the MSBuild project SDK. - Remove tools/nano, tools/migrate, the migration skill, and the combined nanoFramework.Tool.slnx. - Point the README at the tools' new home. --- README.md | 15 +- nanoFramework.Tool.slnx | 12 - skills/nanoframework-sdk-migration/SKILL.md | 148 ---- .../references/contributing-compliance.md | 167 ---- .../references/migration-rules.md | 156 ---- tools/migrate/NanoMigrate.sln | 86 -- tools/migrate/README.md | 206 ----- tools/migrate/nuget.config | 10 - .../NanoMigrate.Cli.Commands/CleanCommand.cs | 77 -- .../MigrateCommand.cs | 513 ----------- .../MigrateRegistration.cs | 37 - .../NanoMigrate.Cli.Commands.csproj | 51 -- .../Rendering/ConsoleSupport.cs | 26 - .../Rendering/MigrateRenderer.cs | 201 ----- .../Rendering/MigrationReportBuilder.cs | 103 --- .../RollbackCommand.cs | 64 -- .../src/NanoMigrate.Cli/Cli/CloneCommand.cs | 94 -- .../src/NanoMigrate.Cli/Cli/FleetCommand.cs | 147 ---- tools/migrate/src/NanoMigrate.Cli/GitHub.cs | 49 -- .../NanoMigrate.Cli/NanoMigrate.Cli.csproj | 60 -- .../src/NanoMigrate.Cli/ProcessRunner.cs | 33 - tools/migrate/src/NanoMigrate.Cli/Program.cs | 66 -- .../Rendering/FleetRenderer.cs | 81 -- .../migrate/src/NanoMigrate.Cli/UserError.cs | 4 - .../NanoMigrate.Core/Backup/BackupCleaner.cs | 139 --- .../Backup/MigrationJournaling.cs | 69 -- .../Backup/RollbackJournal.cs | 305 ------- .../src/NanoMigrate.Core/Common/Glob.cs | 69 -- .../NanoMigrate.Core/Common/ProjectScanner.cs | 45 - .../NanoMigrate.Core/Fleet/FleetService.cs | 243 ------ .../src/NanoMigrate.Core/Fleet/RepoReport.cs | 11 - .../NanoMigrate.Core/NanoMigrate.Core.csproj | 35 - .../Projects/ConversionOptions.cs | 75 -- .../Projects/ConvertResult.cs | 65 -- .../Projects/IProjectConverter.cs | 15 - .../Projects/ProjectConverter.cs | 804 ------------------ .../Reporting/HtmlReportWriter.cs | 178 ---- .../Reporting/MarkdownReportWriter.cs | 129 --- .../Reporting/MigrationReport.cs | 93 -- .../Solutions/MigrationPlan.cs | 200 ----- .../Solutions/SolutionDiscovery.cs | 34 - .../Solutions/SolutionFile.cs | 143 ---- .../Solutions/SolutionRewriter.cs | 120 --- .../Solutions/SolutionScanner.cs | 40 - .../Verification/SolutionBuilder.cs | 195 ----- .../Verification/Verification.cs | 76 -- .../tests/NanoMigrate.Tests/CleanTests.cs | 110 --- .../tests/NanoMigrate.Tests/ConverterTests.cs | 182 ---- .../tests/NanoMigrate.Tests/CpmTests.cs | 198 ----- .../tests/NanoMigrate.Tests/GlobTests.cs | 32 - .../tests/NanoMigrate.Tests/HintPathTests.cs | 49 -- .../tests/NanoMigrate.Tests/ItemGlobTests.cs | 305 ------- .../NanoMigrate.Tests.csproj | 36 - .../NanoMigrate.Tests/OutputTypeTests.cs | 36 - .../PackageResolutionTests.cs | 163 ---- .../NanoMigrate.Tests/ProjectScannerTests.cs | 55 -- .../tests/NanoMigrate.Tests/ReportingTests.cs | 204 ----- .../tests/NanoMigrate.Tests/RollbackTests.cs | 219 ----- .../NanoMigrate.Tests/SolutionRewriteTests.cs | 70 -- .../tests/NanoMigrate.Tests/SolutionTests.cs | 415 --------- .../tests/NanoMigrate.Tests/TempDir.cs | 28 - .../tests/NanoMigrate.Tests/VerifyTests.cs | 154 ---- tools/nano/README.md | 134 --- .../ExternalToolResolverTests.cs | 170 ---- .../nanoFramework.Tool.Tests.csproj | 22 - .../Commands/FlashCommand.cs | 92 -- .../Commands/PlaceholderCommand.cs | 39 - .../ExternalTools/ExternalToolBase.cs | 61 -- .../ExternalTools/ExternalToolResolver.cs | 112 --- .../ExternalTools/IExternalTool.cs | 54 -- .../ExternalTools/NanoffTool.cs | 13 - .../ExternalTools/ToolEnvironment.cs | 70 -- .../ExternalTools/ToolManifest.cs | 68 -- .../ExternalTools/nano-tools.json | 12 - tools/nano/nanoFramework.Tool/Program.cs | 71 -- .../nanoFramework.Tool.csproj | 54 -- 76 files changed, 3 insertions(+), 8714 deletions(-) delete mode 100644 nanoFramework.Tool.slnx delete mode 100644 skills/nanoframework-sdk-migration/SKILL.md delete mode 100644 skills/nanoframework-sdk-migration/references/contributing-compliance.md delete mode 100644 skills/nanoframework-sdk-migration/references/migration-rules.md delete mode 100644 tools/migrate/NanoMigrate.sln delete mode 100644 tools/migrate/README.md delete mode 100644 tools/migrate/nuget.config delete mode 100644 tools/migrate/src/NanoMigrate.Cli.Commands/CleanCommand.cs delete mode 100644 tools/migrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs delete mode 100644 tools/migrate/src/NanoMigrate.Cli.Commands/MigrateRegistration.cs delete mode 100644 tools/migrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj delete mode 100644 tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/ConsoleSupport.cs delete mode 100644 tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs delete mode 100644 tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/MigrationReportBuilder.cs delete mode 100644 tools/migrate/src/NanoMigrate.Cli.Commands/RollbackCommand.cs delete mode 100644 tools/migrate/src/NanoMigrate.Cli/Cli/CloneCommand.cs delete mode 100644 tools/migrate/src/NanoMigrate.Cli/Cli/FleetCommand.cs delete mode 100644 tools/migrate/src/NanoMigrate.Cli/GitHub.cs delete mode 100644 tools/migrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj delete mode 100644 tools/migrate/src/NanoMigrate.Cli/ProcessRunner.cs delete mode 100644 tools/migrate/src/NanoMigrate.Cli/Program.cs delete mode 100644 tools/migrate/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs delete mode 100644 tools/migrate/src/NanoMigrate.Cli/UserError.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Backup/BackupCleaner.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Backup/MigrationJournaling.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Backup/RollbackJournal.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Common/Glob.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Common/ProjectScanner.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Fleet/FleetService.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Fleet/RepoReport.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/NanoMigrate.Core.csproj delete mode 100644 tools/migrate/src/NanoMigrate.Core/Projects/ConversionOptions.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Projects/ConvertResult.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Projects/IProjectConverter.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Projects/ProjectConverter.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Reporting/HtmlReportWriter.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Reporting/MarkdownReportWriter.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Reporting/MigrationReport.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Solutions/MigrationPlan.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Solutions/SolutionDiscovery.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Solutions/SolutionFile.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Solutions/SolutionRewriter.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Solutions/SolutionScanner.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Verification/SolutionBuilder.cs delete mode 100644 tools/migrate/src/NanoMigrate.Core/Verification/Verification.cs delete mode 100644 tools/migrate/tests/NanoMigrate.Tests/CleanTests.cs delete mode 100644 tools/migrate/tests/NanoMigrate.Tests/ConverterTests.cs delete mode 100644 tools/migrate/tests/NanoMigrate.Tests/CpmTests.cs delete mode 100644 tools/migrate/tests/NanoMigrate.Tests/GlobTests.cs delete mode 100644 tools/migrate/tests/NanoMigrate.Tests/HintPathTests.cs delete mode 100644 tools/migrate/tests/NanoMigrate.Tests/ItemGlobTests.cs delete mode 100644 tools/migrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj delete mode 100644 tools/migrate/tests/NanoMigrate.Tests/OutputTypeTests.cs delete mode 100644 tools/migrate/tests/NanoMigrate.Tests/PackageResolutionTests.cs delete mode 100644 tools/migrate/tests/NanoMigrate.Tests/ProjectScannerTests.cs delete mode 100644 tools/migrate/tests/NanoMigrate.Tests/ReportingTests.cs delete mode 100644 tools/migrate/tests/NanoMigrate.Tests/RollbackTests.cs delete mode 100644 tools/migrate/tests/NanoMigrate.Tests/SolutionRewriteTests.cs delete mode 100644 tools/migrate/tests/NanoMigrate.Tests/SolutionTests.cs delete mode 100644 tools/migrate/tests/NanoMigrate.Tests/TempDir.cs delete mode 100644 tools/migrate/tests/NanoMigrate.Tests/VerifyTests.cs delete mode 100644 tools/nano/README.md delete mode 100644 tools/nano/nanoFramework.Tool.Tests/ExternalToolResolverTests.cs delete mode 100644 tools/nano/nanoFramework.Tool.Tests/nanoFramework.Tool.Tests.csproj delete mode 100644 tools/nano/nanoFramework.Tool/Commands/FlashCommand.cs delete mode 100644 tools/nano/nanoFramework.Tool/Commands/PlaceholderCommand.cs delete mode 100644 tools/nano/nanoFramework.Tool/ExternalTools/ExternalToolBase.cs delete mode 100644 tools/nano/nanoFramework.Tool/ExternalTools/ExternalToolResolver.cs delete mode 100644 tools/nano/nanoFramework.Tool/ExternalTools/IExternalTool.cs delete mode 100644 tools/nano/nanoFramework.Tool/ExternalTools/NanoffTool.cs delete mode 100644 tools/nano/nanoFramework.Tool/ExternalTools/ToolEnvironment.cs delete mode 100644 tools/nano/nanoFramework.Tool/ExternalTools/ToolManifest.cs delete mode 100644 tools/nano/nanoFramework.Tool/ExternalTools/nano-tools.json delete mode 100644 tools/nano/nanoFramework.Tool/Program.cs delete mode 100644 tools/nano/nanoFramework.Tool/nanoFramework.Tool.csproj diff --git a/README.md b/README.md index 3998e2b..78c84ac 100644 --- a/README.md +++ b/README.md @@ -49,22 +49,13 @@ Or pin the SDK version in `global.json`: src/ nanoFramework.NET.Sdk/ SDK NuGet package (Sdk.props / Sdk.targets) nanoFramework.Tools.BuildTasks/ Custom MSBuild tasks (resource gen, binary output, etc.) -tools/ - nano/ dotnet nano — the umbrella CLI (see tools/nano/README.md) - migrate/ NanoMigrate — .nfproj -> SDK-style converter (see tools/migrate/README.md) ``` ## Tools -The repo ships two CLIs alongside the SDK: - -- **[`dotnet nano`](tools/nano/README.md)** (`nanoFramework.Tool`) — the umbrella CLI: built-in - managed commands (`migrate`, `clean`, `rollback`) plus wrapped external tools (`flash` → `nanoff`). -- **[`nano-migrate`](tools/migrate/README.md)** (`nanoFramework.Migrate`) — the standalone migration - tool that converts legacy `.nfproj` projects to SDK-style, single-repo or across an entire fleet. - -The companion installable **[migration skill](skills/nanoframework-sdk-migration)** drives the -converter with the full rule set and contribution/PR conventions. +The `dotnet nano` umbrella CLI and the `nano-migrate` project migrator (with the companion +SDK-migration skill) live in the [nanoframework/nf-tools](https://github.com/nanoframework/nf-tools) +repository, under `tools/nano` and `tools/migrate`. ## Development diff --git a/nanoFramework.Tool.slnx b/nanoFramework.Tool.slnx deleted file mode 100644 index c904002..0000000 --- a/nanoFramework.Tool.slnx +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/skills/nanoframework-sdk-migration/SKILL.md b/skills/nanoframework-sdk-migration/SKILL.md deleted file mode 100644 index ba992ad..0000000 --- a/skills/nanoframework-sdk-migration/SKILL.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: nanoframework-sdk-migration -description: >- - Migrate .NET nanoFramework projects from the legacy custom .nfproj project system to the - SDK-style MSBuild project system (, netnano1.0, - PackageReference). Use whenever converting a nanoFramework library or app to SDK-style, - folding packages.config into PackageReference or .nuspec into MSBuild Pack properties, or - bulk-migrating many repos at once (the nanoframework/lib-* fleet). Trigger for any mention of - ".nfproj", "SDK-style nanoFramework", "convert nfproj to csproj", "migrate the nano - libraries", or a fleet/bulk migration — even if the word "skill" is never used. - SCOPE: project-system migration only; this does NOT cover OTA updates or modular firmware - packaging. ---- - -# nanoFramework SDK-style project migration - -Convert a nanoFramework repo from the legacy flavored `.nfproj` project system onto SDK-style -projects that compose over `nanoFramework.NET.Sdk`. The mechanical conversion is done by the -**NanoMigrate** tool (in this repo at `tools/migrate`, surfaced as `dotnet nano migrate`). The -tool is **idempotent + reentrant**: it skips already-SDK-style projects and re-running over a tree -is a safe no-op, so a partial or repeated migration is never destructive. - -The full, as-built command/option reference is [`tools/migrate/README.md`](../../tools/migrate/README.md) -(and [`tools/nano/README.md`](../../tools/nano/README.md) for the umbrella). It is the source of -truth; this skill is the workflow guide. **Always confirm the live surface with `--help`** — it -matches the installed version exactly: - -``` -dotnet nano --help # every command -dotnet nano migrate --help # options for one command -``` - -## Scope — read this first - -Project-system migration **only**. Do not add, and do not ask the tool to touch: OTA update -artifacts, modular/firmware packaging, `runtimes/{rid}/native` layouts, or ABI manifests. The -exact per-project transformation rules are in -[references/migration-rules.md](references/migration-rules.md); contribution + PR conventions are -in [references/contributing-compliance.md](references/contributing-compliance.md). - -## Commands at a glance - -Run any of these as `dotnet nano ` (umbrella) or `nano-migrate ` (standalone) — -or from source with `dotnet run --project tools/migrate/src/NanoMigrate.Cli -- `. - -| Command | What it does | -|---|---| -| `migrate ` | Convert a `.nfproj`, a solution (`.sln`/`.slnx`), or every `.nfproj` under a directory. | -| `clean [path]` | Remove migration leftovers: `*.nfproj.bak` files and `.nanomigrate/` rollback folders. | -| `rollback [path]` | Revert the last recorded migration (restore originals, delete created projects). | -| `clone [out-dir]` | Clone all matching repos from a GitHub org (fleet prep). *Standalone CLI only.* | -| `fleet ` | Migrate every `.nfproj` across cloned repos; write a report; optionally branch + commit. *Standalone CLI only.* | - -Key `migrate` options (see `migrate --help` / the README for the rest): `--dry-run`, `--glob

`, -`--solution

`, `--ext .csproj|.nfproj`, `--no-backup`, `--report `, -`--verify`/`--no-verify`, `-y|--yes`. - -## Always test a directory first, then migrate for real - -1. **Dry-run** to preview every change (writes nothing): - ``` - dotnet nano migrate --dry-run - # or, from this repo: dotnet run --project tools/migrate/src/NanoMigrate.Cli -- migrate --dry-run - ``` - Review the preview table: the target `.csproj`, the resolved `PackageReference`s, the files - that would be deleted, the `.sln` edits, and anything in the yellow **manual review** panel. -2. **Scope** with a glob if you only want part of the tree (`*`, `**`, `?`; relative to ``): - ``` - dotnet nano migrate --glob "Beginner/**" --dry-run - ``` -3. **Run for real** once the preview is right: - ``` - dotnet nano migrate # interactive: confirms once. Add --yes for CI/unattended. - ``` -4. **Re-run is safe** — reentrant. A partially-converted tree converts only the remaining - `.nfproj` and leaves existing `.csproj` untouched. - -A real run **verifies by default**: after converting, it builds the affected solution(s)/project(s) -and, on a failed build, offers to roll the whole run back (interactive) or tells you to run -`rollback` later (non-interactive). Use `--no-verify` to skip it. Add `--report ` to emit a -record of what changed — `.md`/`.markdown` → Markdown, `.html`/`.htm` → HTML — which also works -under `--dry-run` (it reports what *would* change). - -## Solutions (.sln / .slnx) - -Migration is solution-aware and keeps solutions in sync: - -- **Pass a solution** (`migrate .sln` or `.slnx`) to convert only the projects it references - and retarget the solution itself (classic `.sln`: project-type GUID + `.nfproj`→`.csproj`; - `.slnx`: the `Path`). -- **Pass a directory**: if it contains solutions you are asked to pick which one(s) to migrate - (all / none / several); if it contains none, every `.nfproj` under it is converted (loose mode). -- **With `--glob`**: the tool finds the matching projects, discovers the solutions that reference - them, and (after you confirm / select) updates only those solutions. -- `--solution ` forces a single target; `--yes` (or non-interactive) selects all affected. - -## What the tool does per project - -(Full detail in [references/migration-rules.md](references/migration-rules.md).) - -- Emits `` with `netnano1.0`. -- Maps `packages.config` / HintPath versions to `PackageReference` — the package id comes from the - `packages\.\` folder, so nano package ids resolve correctly (not the bare assembly - name). -- Folds `.nuspec` metadata into MSBuild `Pack` properties. -- Deletes `packages.config` and a hand-written `Properties/AssemblyInfo.cs` (the SDK generates it). -- Rewrites the `.sln` entry: project-type GUID → the SDK C#/CPS GUID, path `.nfproj` → `.csproj`. -- Writes a `.nfproj.bak` backup (unless `--no-backup`). - -## Manual review - -Entries in the yellow "manual review" panel did not resolve automatically — typically a -`` with no HintPath and no matching `packages.config` entry. Add the right -`PackageReference` by hand, then re-run (a no-op for everything already converted). - -## Back up, roll back, clean up - -- Each real conversion writes a `.nfproj.bak` next to the project (suppress with `--no-backup`). -- Independently, a real run records a **self-contained rollback journal** under `.nanomigrate/`. - `rollback [path]` reverts the last recorded migration (restore originals, delete created files); - it's idempotent — no journal means "nothing to roll back", exit 0. -- When you're satisfied with a migration, `clean [path]` removes the leftovers — every - `*.nfproj.bak` and every `.nanomigrate/` folder — so you don't commit a pile of backups. Both - preview before deleting; add `-y|--yes` to skip the prompt. - -## Fleet migration (many repos) - -Two steps — `clone` to fetch the repos, `fleet` to migrate them (`clone`/`fleet` are in the -standalone `nano-migrate` CLI). From source: - -``` -# 1) clone a GitHub org's matching repos (default org nanoframework, filter lib-) -dotnet run --project tools/migrate/src/NanoMigrate.Cli -- clone ./nano-repos --token $GITHUB_TOKEN - -# 2) dry-run the whole fleet and read the report before changing anything -dotnet run --project tools/migrate/src/NanoMigrate.Cli -- fleet ./nano-repos --dry-run --report fleet-report.md - -# 3) apply on a branch and commit, so each repo ends up ready to PR -dotnet run --project tools/migrate/src/NanoMigrate.Cli -- fleet ./nano-repos \ - --branch sdk-migration --commit --issue --report fleet-report.md -``` - -`fleet` walks each repo, converts every matching `.nfproj`, writes the report, and (with -`--branch`/`--commit`) commits a contribution-compliant, signed-off message; `--commit` implies -`--no-backup`. It stops at the commit — it never pushes or opens PRs. Open PRs from the -nanoFramework org template — see -[references/contributing-compliance.md](references/contributing-compliance.md). Convert leaf-first -(dependencies before dependents); branch names must not start with `develop`; never target `main`. diff --git a/skills/nanoframework-sdk-migration/references/contributing-compliance.md b/skills/nanoframework-sdk-migration/references/contributing-compliance.md deleted file mode 100644 index 493a185..0000000 --- a/skills/nanoframework-sdk-migration/references/contributing-compliance.md +++ /dev/null @@ -1,167 +0,0 @@ -# Contributing-compliance rules for migration PRs - -These rules make a migration land as a contribution the nanoFramework project -will accept. They are distilled from the official contributing docs: - -- Contribution workflow — -- C# Coding Style — -- Labels — -- Contributor License Agreement — - -Treat this as the checklist the migration must satisfy before a PR goes up. The -`nano-migrate` tool enforces the parts it can (commit format, sign-off, branch -naming); the rest is on the contributor. - -## Contents - -- [Before you start](#before-you-start) -- [Branching](#branching) -- [Scope of the change](#scope-of-the-change) -- [Commits](#commits) -- [Build and test clean](#build-and-test-clean) -- [Opening the PR](#opening-the-pr) -- [C# coding style (only if you touch .cs)](#c-coding-style-only-if-you-touch-cs) -- [What the tool does vs. what you do](#what-the-tool-does-vs-what-you-do) - -## Before you start - -- **Sign the CLA.** Every contribution (code, config, docs — including a project - migration) requires a signed Contributor License Agreement. Without it the PR - cannot be merged. -- **Use your real name.** Commits must use a real name; anonymous or pseudonymous - contributions are not accepted. Set it once: - `git config --global user.name "Your Real Name"` and a real - `git config --global user.email`. -- **File or find an issue for non-trivial work.** A fleet-wide project-system - migration is not trivial — open a tracking issue (or reuse one) and get team - agreement before opening PRs. Trivial one-off fixes can skip this. -- **Work from a fork.** The project uses a fork-based workflow: fork the repo, - clone your fork, add `upstream` pointing at `nanoframework/`, and sync - `main` from upstream before branching. - -## Branching - -- Branch off **`main`**: `git checkout -b main`. -- Name the branch for its intent, e.g. `sdk-migration` or `issue-123`. -- **Never** start a branch name with `develop` — it collides with upstream - `develop-*` branches. (`nano-migrate fleet` refuses such names.) - -## Scope of the change - -- This migration is a **structural** change to the project system, not a style - change. That is allowed. **Do not** bundle in code reformatting or style-only - edits — the project explicitly does **not** accept style-only PRs and asks - contributors to preserve the existing style of any file they touch. -- **Do not modify `.cs` source** as part of the migration. `nano-migrate` only - rewrites `.nfproj`, `packages.config`, and `.nuspec`; keep it that way. If a - repo genuinely needs a code change, that is a separate PR with its own issue. -- **Keep `.editorconfig`.** Each repo carries an `.editorconfig` (and - `spelling_exclusion.dic`) from the project template that encodes the style - rules. Don't delete or reformat around it. If a repo is missing it, copy the - current one from the `.github` template repo rather than inventing one. -- **Generated project XML**: the rule for non-code files is consistency. The - SDK-style project the tool emits uses standard two-space MSBuild indentation, - which is the broadly-accepted convention for a brand-new SDK-style file. -- **Versioning**: the tool does not fold `` from `.nuspec`. Leave the - repo's existing versioning (typically Nerdbank.GitVersioning via `version.json`) - in place; don't hard-code a version into the project. - -## Commits - -Format every commit like this (the tool does this automatically for fleet -commits): - -```text -Summarize the change in 50 characters or less - -Body explaining what changed and why, wrapped at 72 columns. For a -migration: what was converted and that there are no functional code -changes. - -Fix # - -Signed-off-by: Your Real Name -``` - -- Summary line **≤ 50 characters**, body wrapped at **72**. -- Reference the tracking issue with a `Fix #` trailer when there is one - (`nano-migrate ... --issue `). -- Include a **`Signed-off-by`** line (`git commit -s`). The tool signs off by - default; this requires a configured real name/email. -- **Factor commits sensibly** — not one giant commit of unrelated things, not a - swarm of tiny ones. Rebase away accidental whitespace/reformatting before the - PR. A clean migration is usually a single commit per repo. - -## Build and test clean - -A migration is not done until the repo **builds clean and all tests pass**: - -- `dotnet build` (or the repo's build) must restore and build with no errors. -- Run the repo's unit tests; they must pass, including any that exercise the - migrated project. -- Rebase on `upstream/main` if upstream moved while you worked, then re-build and - re-test before pushing. - -Do not open (or mark ready) a PR whose build is red. - -## Opening the PR - -- Push the branch to **your fork**, then open a PR against the upstream repo's - **`main`** branch. The tool stops at the local commit — it does not push or - open PRs; do that with the maintainers' normal flow. -- **Follow the PR template** that GitHub shows; it doubles as a submission - checklist. Fill in every section. -- A migration PR is typically **`area-Config-and-Build`** (project system / - build). For org-wide tooling/coordination, `area-Infrastructure-and-Organization` - may also apply. Maintainers manage the lifecycle labels (`code review`, - `do not merge`, `merge OK`); you generally don't set those yourself. -- It's fine to open a **`[WIP]`** PR early to start feedback, and it's fine to be - asked to **squash** commits before merge. -- Expect review comments — they are normal and not a rejection. Resolve feedback - and keep the branch building green. - -## C# coding style (only if you touch .cs) - -The migration should not change `.cs` files. But if you must (e.g. you drop a -hand-written `Properties/AssemblyInfo.cs` and need to re-express a custom -assembly attribute, or add a small fix), the change must follow the project's C# -style. Key rules: - -- **Visual Studio defaults**, Allman braces (each brace on its own line), braces - even on single-line blocks, **4 spaces** indentation, no tabs. -- `_camelCase` for private/internal fields; `s_` for static, `t_` for thread - static; `static readonly` (in that order) where possible. -- Always state visibility, visibility first (`private string _foo`, `public - abstract`). Avoid `this.` unless required. -- `using` directives at the top, outside the namespace, sorted alphabetically. -- Use language keywords, not BCL type names (`int`, `string`, not `Int32`, - `String`). Use `var` only when the type is obvious. Use `nameof(...)` over - string literals. `PascalCase` for constants. -- One empty line maximum between members; a blank line after a closing `}` unless - another `}` or an `else`/continuation follows; no spurious whitespace. -- **XML doc comments (`///`) on all public members**, including params and - returns. Prefer documenting exceptions in the comment and keep thrown-exception - message strings empty to save PE space (except for tooling like `nanoff`). -- **Include the file license header** (the two-line simplified form is fine). -- Prefer full words over abbreviations; for accepted acronyms follow Pascal - casing (`HttpSomething`, not `HTTPSomething`). -- If an existing file already uses a different convention (e.g. `m_member`), keep - that file's existing style. - -Re-run the project's `.editorconfig` formatting (in VS, `Ctrl+K, Ctrl+D`) before -committing any code change. - -## What the tool does vs. what you do - -| Requirement | `nano-migrate` | You | -|--------------------------------------|-----------------------|-----| -| Convert project files | ✅ automatic | | -| Commit message format (50/72, Fix #) | ✅ `--issue` | | -| `Signed-off-by` sign-off | ✅ default (needs real name in git config) | | -| Reject `develop*` branch names | ✅ guard | | -| Leave `.cs` and `.editorconfig` alone| ✅ by design | | -| Sign the CLA | | ✅ | -| Open/track the issue | | ✅ | -| Build clean + tests pass | | ✅ | -| Push to fork, open PR, fill template | | ✅ | -| Apply/respond to review | | ✅ | diff --git a/skills/nanoframework-sdk-migration/references/migration-rules.md b/skills/nanoframework-sdk-migration/references/migration-rules.md deleted file mode 100644 index 9ff7b4f..0000000 --- a/skills/nanoframework-sdk-migration/references/migration-rules.md +++ /dev/null @@ -1,156 +0,0 @@ -# Migration rules reference - -The precise rules the converter (`tools/migrate`) applies, the reasoning -behind each, and the edge cases you may meet. Read this when a `MANUAL REVIEW -NEEDED` item appears, when output looks surprising, or when changing the rules. - -## Contents - -- [Property handling](#property-handling) -- [Reference and package handling](#reference-and-package-handling) -- [Compile items and AssemblyInfo](#compile-items-and-assemblyinfo) -- [Other item types](#other-item-types) -- [.nuspec folding](#nuspec-folding) -- [File operations and safety](#file-operations-and-safety) -- [Fail-loud philosophy](#fail-loud-philosophy) -- [Dependency ordering for the fleet](#dependency-ordering-for-the-fleet) -- [Things intentionally out of scope](#things-intentionally-out-of-scope) - -## Property handling - -The legacy project carries properties that the SDK now supplies, plus boilerplate -the flavored project system needed. These are **dropped**: - -`ProjectTypeGuids`, `ProjectGuid`, `FileAlignment`, `AppDesignerFolder`, -`NanoFrameworkProjectSystemPath`, `TargetFrameworkVersion`, `OldToolsVersion`, -`Configuration`, `Platform`. - -`TargetFrameworkVersion` (`v1.0`) is replaced by a single `` -(default `netnano1.0`, override with `--tfm`). `Configuration`/`Platform` defaults -come from the SDK. The `NFProjectSystem` import disappears entirely — the -`Sdk="nanoFramework.Sdk/"` attribute replaces it. - -These are **carried through verbatim** when present: - -`RootNamespace`, `AssemblyName`, `DocumentationFile`, `DefineConstants`, -`LangVersion`, plus packaging metadata `Description`, `Authors`, `PackageTags`, -`Copyright`. - -Any other property is **not** copied. If a repo relies on an unusual property, -add it back by hand after reviewing whether the SDK already provides it. (This is -a conservative default: copying unknown properties blindly risks dragging legacy -assumptions into the SDK build.) - -## Reference and package handling - -The legacy system used `` items resolved through `packages.config`. -The SDK system uses ``. Conversion: - -1. For each ``, strip any assembly-name suffix after a - comma, giving the bare name `X`. -2. Apply legacy aliases: `mscorlib` and `System` both map to the package - `nanoFramework.CoreLibrary`, because the legacy reference name does not match - the NuGet package id. -3. Resolve the version from `packages.config` (by package id, then by the raw - reference name). -4. If that fails, **fall back** to inferring the version from the `HintPath` - folder segment (e.g. `packages\nanoFramework.CoreLibrary.1.15.0\lib\...` → - `1.15.0`). This inference is always flagged for review so a human confirms it. -5. If neither works, emit a review line and leave the reference for manual mapping. - -`` items already present are carried through (version taken from -the item, else from `packages.config`). `PackageReference`s are emitted sorted by -package id for stable diffs. - -## Compile items and AssemblyInfo - -SDK-style projects glob `**/*.cs` by default, so explicit `` items for -files the glob already covers are redundant and are **dropped**. The rule: - -- A `Compile Include="Foo.cs"` (top-level, no folder separator) → dropped. -- A `Compile Include="Internal\Helper.cs"` (in a subfolder) → **kept**, because - legacy projects sometimes list files that the default glob would still pick up - but whose presence is load-bearing in unusual layouts; preserving them is the - safe choice. -- A `Compile` with a `Link` attribute → kept (it is a linked file, not a default). -- `Properties\AssemblyInfo.cs` → **dropped even though it is hand-written**, - because the SDK's `GenerateAssemblyInfo` produces assembly attributes and a - checked-in `AssemblyInfo.cs` causes duplicate-attribute build errors. If the - repo needs custom assembly attributes, set them as MSBuild properties - (``, ``, etc.) or disable `GenerateAssemblyInfo` deliberately. - -## Other item types - -- `None Include="packages.config"` and `None Include="*.nuspec"` → dropped (both - are obsolete after migration). -- Other `None`, plus `EmbeddedResource` and `Content` → kept verbatim (attributes - preserved, emitted self-closing). -- `ProjectReference` → kept verbatim. -- Any other item element → emitted as a review line ("Unhandled item…") so a - human decides. The tool does not silently drop or transform unknown items. - -## .nuspec folding - -If a `.nuspec` sits next to the project, its `` is mapped into MSBuild -Pack properties so `dotnet pack` reproduces the package: - -| .nuspec | MSBuild property | -|----------------|-----------------------| -| `id` | `PackageId` | -| `description` | `Description` | -| `authors` | `Authors` | -| `tags` | `PackageTags` | -| `projectUrl` | `PackageProjectUrl` | - -Existing project properties win (the fold uses set-if-absent), so a value already -on the project is not overwritten. `version` is intentionally **not** folded: -versioning is usually handled by Nerdbank.GitVersioning or a CI property, and -hard-coding it from an old `.nuspec` would regress that. Confirm the repo's -versioning strategy carries over. - -## File operations and safety - -- By default a `.nfproj.bak` is written next to each converted project. - `--no-backup` skips it; `fleet --commit` skips it automatically (git history is - the backup). -- With the default `--ext .nfproj`, the project is rewritten in place. With - `--ext .csproj`, a new `.csproj` is written and the original `.nfproj` is - removed. -- `packages.config` is deleted after its contents are folded into - `PackageReference`s. -- `--dry-run` performs the full analysis and prints the review list but writes - nothing and touches no git state — safe to run anywhere, any number of times. - -## Fail-loud philosophy - -The tool never guesses when it is unsure. Every uncertain case becomes a review -line rather than a silent transformation. This matters at fleet scale: a silent -wrong guess multiplied across 100+ repos is far more expensive than a review line -a human clears in seconds. When triaging a fleet report, treat the review list as -the real work and the clean set as already done. - -## Dependency ordering for the fleet - -Migrate leaf-first so a dependency is already available as an SDK-style package -when its dependents build: - -1. `nanoFramework.CoreLibrary` (the root; may need a special SDK variant). -2. Base/runtime libraries (`nanoFramework.Runtime.*`, `System.*` bindings). -3. Device and protocol libraries that depend on the base. -4. Higher-level IoT bindings and aggregates last. - -Within a single dry-run the order does not matter (nothing is published), but it -does matter for the publish/PR sequence: do not merge a dependent before the -dependency it relies on has shipped an SDK-style package. - -## Things intentionally out of scope - -The tool stops at the project system. It does not, and should not be extended to: - -- generate OTA update artifacts or manifests, -- produce `runtimes/{rid}/native/` layouts or relocatable native modules, -- emit ABI/module compatibility manifests, -- build firmware or invoke the metadata processor. - -Those belong to separate, later phases. Keeping this tool narrow is what makes it -safe to run unattended across the whole fleet. diff --git a/tools/migrate/NanoMigrate.sln b/tools/migrate/NanoMigrate.sln deleted file mode 100644 index 518f9b5..0000000 --- a/tools/migrate/NanoMigrate.sln +++ /dev/null @@ -1,86 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NanoMigrate.Core", "src\NanoMigrate.Core\NanoMigrate.Core.csproj", "{AEBCAB7C-FF89-4263-A46D-5802D29DD602}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NanoMigrate.Cli", "src\NanoMigrate.Cli\NanoMigrate.Cli.csproj", "{6560B18A-C816-4E38-B604-C4E771FD6534}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NanoMigrate.Tests", "tests\NanoMigrate.Tests\NanoMigrate.Tests.csproj", "{2EB1343E-3AF7-4584-AE5D-606ADF7C891E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NanoMigrate.Cli.Commands", "src\NanoMigrate.Cli.Commands\NanoMigrate.Cli.Commands.csproj", "{838A3335-D3A2-4B3A-A69E-038CCB8CAB49}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Debug|x64.ActiveCfg = Debug|Any CPU - {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Debug|x64.Build.0 = Debug|Any CPU - {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Debug|x86.ActiveCfg = Debug|Any CPU - {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Debug|x86.Build.0 = Debug|Any CPU - {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Release|Any CPU.Build.0 = Release|Any CPU - {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Release|x64.ActiveCfg = Release|Any CPU - {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Release|x64.Build.0 = Release|Any CPU - {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Release|x86.ActiveCfg = Release|Any CPU - {AEBCAB7C-FF89-4263-A46D-5802D29DD602}.Release|x86.Build.0 = Release|Any CPU - {6560B18A-C816-4E38-B604-C4E771FD6534}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6560B18A-C816-4E38-B604-C4E771FD6534}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6560B18A-C816-4E38-B604-C4E771FD6534}.Debug|x64.ActiveCfg = Debug|Any CPU - {6560B18A-C816-4E38-B604-C4E771FD6534}.Debug|x64.Build.0 = Debug|Any CPU - {6560B18A-C816-4E38-B604-C4E771FD6534}.Debug|x86.ActiveCfg = Debug|Any CPU - {6560B18A-C816-4E38-B604-C4E771FD6534}.Debug|x86.Build.0 = Debug|Any CPU - {6560B18A-C816-4E38-B604-C4E771FD6534}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6560B18A-C816-4E38-B604-C4E771FD6534}.Release|Any CPU.Build.0 = Release|Any CPU - {6560B18A-C816-4E38-B604-C4E771FD6534}.Release|x64.ActiveCfg = Release|Any CPU - {6560B18A-C816-4E38-B604-C4E771FD6534}.Release|x64.Build.0 = Release|Any CPU - {6560B18A-C816-4E38-B604-C4E771FD6534}.Release|x86.ActiveCfg = Release|Any CPU - {6560B18A-C816-4E38-B604-C4E771FD6534}.Release|x86.Build.0 = Release|Any CPU - {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Debug|x64.ActiveCfg = Debug|Any CPU - {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Debug|x64.Build.0 = Debug|Any CPU - {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Debug|x86.ActiveCfg = Debug|Any CPU - {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Debug|x86.Build.0 = Debug|Any CPU - {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Release|Any CPU.Build.0 = Release|Any CPU - {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Release|x64.ActiveCfg = Release|Any CPU - {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Release|x64.Build.0 = Release|Any CPU - {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Release|x86.ActiveCfg = Release|Any CPU - {2EB1343E-3AF7-4584-AE5D-606ADF7C891E}.Release|x86.Build.0 = Release|Any CPU - {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Debug|Any CPU.Build.0 = Debug|Any CPU - {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Debug|x64.ActiveCfg = Debug|Any CPU - {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Debug|x64.Build.0 = Debug|Any CPU - {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Debug|x86.ActiveCfg = Debug|Any CPU - {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Debug|x86.Build.0 = Debug|Any CPU - {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Release|Any CPU.ActiveCfg = Release|Any CPU - {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Release|Any CPU.Build.0 = Release|Any CPU - {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Release|x64.ActiveCfg = Release|Any CPU - {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Release|x64.Build.0 = Release|Any CPU - {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Release|x86.ActiveCfg = Release|Any CPU - {838A3335-D3A2-4B3A-A69E-038CCB8CAB49}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {AEBCAB7C-FF89-4263-A46D-5802D29DD602} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {6560B18A-C816-4E38-B604-C4E771FD6534} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {2EB1343E-3AF7-4584-AE5D-606ADF7C891E} = {0AB3BF05-4346-4AA6-1389-037BE0695223} - {838A3335-D3A2-4B3A-A69E-038CCB8CAB49} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - EndGlobalSection -EndGlobal diff --git a/tools/migrate/README.md b/tools/migrate/README.md deleted file mode 100644 index d468ec1..0000000 --- a/tools/migrate/README.md +++ /dev/null @@ -1,206 +0,0 @@ -# NanoMigrate — `nano-migrate` - -Convert legacy nanoFramework `.nfproj` projects to the SDK-style MSBuild project system — -one project, a whole directory/solution, or an entire cloned fleet of repositories. - -> **Scope: project-system migration only.** This tool moves a repo from the legacy flavored -> `.nfproj` format onto an SDK-style project that composes over `nanoFramework.NET.Sdk`, folds -> `packages.config` into `PackageReference`, and folds `.nuspec` metadata into MSBuild `Pack` -> properties. It does **not** touch OTA updates, modular firmware packaging, -> `runtimes/{rid}/native` layouts, or ABI manifests. - -The conversion is **idempotent and reentrant**: already-SDK-style projects are skipped and -re-running over a tree is a safe no-op, so a partial or repeated migration is never destructive. - -## Layout - -``` -tools/migrate/ - src/ - NanoMigrate.Core/ # conversion engine — console-free, unit-testable, NuGet-ready library - NanoMigrate.Cli.Commands/ # shared Spectre commands (migrate, clean, rollback) used by both front ends - NanoMigrate.Cli/ # the standalone `nano-migrate` CLI (adds clone + fleet) - tests/ - NanoMigrate.Tests/ # unit tests over NanoMigrate.Core -``` - -The engine (`NanoMigrate.Core`) has no `Console`/`AnsiConsole` dependency, so it is testable and -packable on its own. The same `migrate`/`clean`/`rollback` commands are shared with the -[`dotnet nano`](../nano/README.md) umbrella tool, so `nano-migrate migrate …` and -`dotnet nano migrate …` behave identically. - -### Packages - -| Package id | Command | What it is | -|---|---|---| -| `nanoFramework.Migrate.Core` | — | the console-free conversion engine (library) | -| `nanoFramework.Migrate` | `nano-migrate` | the standalone CLI (this tool) | -| `nanoFramework.Tool` | `dotnet nano` | the umbrella that also hosts `migrate` (see [../nano](../nano/README.md)) | - -## Install / run - -As a .NET global (or local) tool: - -```bash -dotnet tool install -g nanoFramework.Migrate # then: nano-migrate … -# or via the umbrella: -dotnet tool install -g nanoFramework.Tool # then: dotnet nano migrate … -``` - -From source in this repo (no separate build step needed): - -```bash -dotnet run --project tools/migrate/src/NanoMigrate.Cli -- [options] -``` - -For repeated fleet runs, build once and call the dll directly (faster): - -```bash -dotnet build -c Release tools/migrate/src/NanoMigrate.Cli -dotnet tools/migrate/src/NanoMigrate.Cli/bin/Release/net8.0/nano-migrate.dll [options] -``` - -Requires the **.NET 8 SDK**. The tool is BCL-only apart from Spectre.Console and the official -`Microsoft.VisualStudio.SolutionPersistence` solution reader/writer. - -## Discovering commands and options - -Everything below is also discoverable from the tool itself — prefer `--help` as the source of -truth, since it always matches the installed version: - -```bash -nano-migrate --help # list every command -nano-migrate migrate --help # options for one command -dotnet nano migrate --help # same surface via the umbrella -``` - -## Commands - -| Command | Summary | -|---|---| -| `migrate ` | Convert a `.nfproj`, a solution, or every `.nfproj` under a directory. | -| `clean [path]` | Remove migration leftovers: `*.nfproj.bak` files and `.nanomigrate/` rollback folders. | -| `rollback [path]` | Revert the last recorded migration under a path (restore originals, delete created projects). | -| `clone [out-dir]` | Clone all matching repos from a GitHub org (fleet prep). | -| `fleet ` | Migrate every `.nfproj` across cloned repos; write a report; optionally branch + commit. | - -`clone` and `fleet` are only in the standalone `nano-migrate` CLI; `migrate`, `clean`, and -`rollback` are shared with `dotnet nano`. - -### `migrate ` - -`` is a `.nfproj` file, a solution (`.sln`/`.slnx`), or a directory. - -- **Solution** → only its referenced `.nfproj` are converted and that solution is retargeted. -- **Directory with solutions** → you choose which solution(s) to migrate (interactive multi-select; - non-interactive / `--yes` selects all affected). -- **Directory with no solution** → every `.nfproj` under it is converted (loose mode); any solutions - found higher in the tree are retargeted automatically. - -| Option | Default | Meaning | -|---|---|---| -| `--solution ` | — | Migrate only this solution; overrides directory discovery. | -| `--glob ` | — | Only convert `.nfproj` whose path (relative to ``) matches; `*`, `**`, `?` supported (e.g. `"Beginner/**"`). Solutions referencing a matched project are updated. | -| `--tfm ` | `netnano1.0` | Target framework moniker. | -| `--ext ` | `.csproj` | Output extension: `.csproj` (retire `.nfproj`) or `.nfproj` (rewrite in place — lower-risk during a phased rollout). | -| `--no-backup` | off | Don't write a `.nfproj.bak`. Fully suppresses loose backups; the rollback journal stays self-contained in `.nanomigrate/`. | -| `--dry-run` (`--no-write`) | off | Analyse and preview only; write nothing. | -| `--verify` / `--no-verify` | on for real runs, off for `--dry-run` | After a real migration, build the affected solution(s)/project(s); a failed build offers a rollback. | -| `--report ` | — | Write a migration report. Format by extension: `.md`/`.markdown` → Markdown, `.html`/`.htm` → HTML (else Markdown). Works for `--dry-run` too. | -| `--sdk ` | (ignored) | Accepted for back-compat only; the SDK reference is versionless (pinned via `global.json` `msbuild-sdks`). | -| `-y\|--yes` | off | Skip interactive prompts and proceed with the default action. (Non-interactive runs never prompt regardless.) | - -**Exit codes:** `0` clean · `2` completed but some projects need manual review · `1` an error -occurred or a verification build failed (and changes were kept or rolled back per the prompt). - -### `clean [path]` - -Removes migration leftovers under a path: every `*.nfproj.bak` and every `.nanomigrate/` rollback -folder. Previews what will go and confirms before deleting (`-y|--yes` to skip the prompt; -non-interactive proceeds). `[path]` defaults to the current directory. This is the tidy-up step -after a migration you're happy with, so you don't commit a pile of `.bak` files. - -### `rollback [path]` - -Reverts the last recorded migration under a path by reading its `.nanomigrate/` rollback journal: -restores backed-up originals and deletes files the migration created. Idempotent and safe — with no -journal it reports "nothing to roll back" and exits `0`. `[path]` defaults to the current directory. - -### `clone [out-dir]` - -Clones all matching repositories from a GitHub org (the first half of a fleet migration). -`[out-dir]` defaults to `./nano-repos`. - -| Option | Default | Meaning | -|---|---|---| -| `--org ` | `nanoframework` | GitHub org to enumerate. | -| `--filter ` | `lib-` | Repo-name prefix to match. | -| `--token ` | `$GITHUB_TOKEN` | GitHub token to lift the unauthenticated API rate limit. | -| `--include-archived` | off | Include archived repositories (skipped by default). | - -### `fleet ` - -Migrates every `.nfproj` across a directory of cloned repos and writes a report. With `--branch` -(and optionally `--commit`) each repo ends up on a ready-to-PR branch. - -| Option | Default | Meaning | -|---|---|---| -| `--glob ` | — | Only convert matching `.nfproj` within each repo (`*`, `**`, `?`). | -| `--tfm ` | `netnano1.0` | Target framework moniker. | -| `--ext ` | `.csproj` | Output extension (`.csproj` or `.nfproj`). | -| `--no-backup` | off | Don't write `.nfproj.bak` (implied by `--commit` — git history already preserves the original). | -| `--dry-run` (`--no-write`) | off | Analyse and preview only. | -| `--report ` | `migration-report.md` | Report path (Markdown). | -| `--branch ` | — | Create/reset this git branch in each repo. Must not start with `develop`. | -| `--commit` | off | Commit the changes (requires `--branch`); writes a contribution-compliant message and signs off. | -| `--message ` | — | Commit summary line (kept ≤ 50 chars). | -| `--issue ` | — | Add a `Fix #` trailer to the commit. | -| `--no-sign-off` | off | Don't add a `Signed-off-by` line. | - -`fleet` stops at the commit — it never pushes or opens PRs. Migrate **leaf-first** (dependencies -before dependents) and open PRs from the org template (see the contribution guide). **Exit codes:** -`0` all clean · `2` one or more repos errored or need review. - -## What the conversion does (per project) - -- Emits `` with `netnano1.0`. -- Maps `packages.config` / `` HintPath versions to `` — the package id - comes from the `packages\.\` folder, so nano package ids resolve correctly rather - than the bare assembly name. Aliases legacy `mscorlib`/`System` to `nanoFramework.CoreLibrary`. -- Folds `.nuspec` metadata (`id`, `description`, `authors`, `tags`, `projectUrl`) into MSBuild - `Pack` properties. -- Deletes `packages.config` and a hand-written `Properties/AssemblyInfo.cs` (the SDK regenerates - it), while preserving non-default items (linked files, files in subfolders, `EmbeddedResource`, - `Content`). -- Rewrites the `.sln`/`.slnx` entry: project-type GUID → the SDK C#/CPS GUID, path `.nfproj` → - `.csproj`. Solutions are read/written through the official - `Microsoft.VisualStudio.SolutionPersistence` library. -- **Fails loud:** anything it cannot confidently resolve is written to a manual-review list instead - of being silently guessed. - -The exact rule set, including edge cases, lives in the skill at -[`skills/nanoframework-sdk-migration/references/migration-rules.md`](../../skills/nanoframework-sdk-migration/references/migration-rules.md). - -## Backups, the rollback journal, and verification - -- Each real conversion writes a `.nfproj.bak` next to the project (unless `--no-backup`). -- Independently, a real run records a **self-contained rollback journal** under `.nanomigrate/` - (`rollback-/` + `manifest.json`) that backs up every file the run modifies or deletes and - records every file it creates — this is what `rollback` and the post-verify rollback prompt use. -- With `--verify` (default for real runs) the affected solution(s)/project(s) are built after the - conversion; on failure you're offered an immediate rollback (interactive) or advised to run - `rollback` later (non-interactive). -- When you're done, `clean` removes both the loose `*.nfproj.bak` files and the `.nanomigrate/` - folders. - -## Tests - -```bash -dotnet test tools/migrate/tests/NanoMigrate.Tests -``` - -## See also - -- [`../nano/README.md`](../nano/README.md) — the `dotnet nano` umbrella tool that also hosts these commands. -- [`skills/nanoframework-sdk-migration`](../../skills/nanoframework-sdk-migration) — the installable - skill that drives this tool, with the full rule set and contribution/PR conventions. diff --git a/tools/migrate/nuget.config b/tools/migrate/nuget.config deleted file mode 100644 index 8edd5d5..0000000 --- a/tools/migrate/nuget.config +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - diff --git a/tools/migrate/src/NanoMigrate.Cli.Commands/CleanCommand.cs b/tools/migrate/src/NanoMigrate.Cli.Commands/CleanCommand.cs deleted file mode 100644 index f4a299f..0000000 --- a/tools/migrate/src/NanoMigrate.Cli.Commands/CleanCommand.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.ComponentModel; -using NanoFramework.Migrate.Cli.Rendering; -using Spectre.Console; -using Spectre.Console.Cli; -using static NanoFramework.Migrate.Cli.Rendering.ConsoleSupport; - -namespace NanoFramework.Migrate.Cli.Commands; - -public sealed class CleanSettings : AssumeYesSettings -{ - [CommandArgument(0, "[path]")] - [Description("A directory (or file) under which migration leftovers are removed: " - + "all *.nfproj.bak files and any .nanomigrate/ rollback folders. Defaults to the current directory.")] - public string Path { get; init; } = "."; -} - -///

-/// Removes migration leftovers under a path: every *.nfproj.bak and every -/// .nanomigrate rollback folder/journal. Previews what will go and confirms -/// before deleting (skip with --yes; non-interactive proceeds). Pure file -/// logic lives in ; this is the thin presentation/prompt. -/// -public sealed class CleanCommand : Command -{ - protected override int Execute(CommandContext context, CleanSettings settings, CancellationToken cancellationToken) - { - Header("NanoMigrate · clean"); - - var root = System.IO.Path.GetFullPath(settings.Path); - var plan = BackupCleaner.Plan(root); - - if (plan.IsEmpty) - { - AnsiConsole.MarkupLine($"[grey]nothing to clean under '{Esc(root)}'.[/]"); - return 0; - } - - // Show exactly what will be deleted before any prompt. - var tree = new Tree($"[bold]Leftovers under[/] [blue]{Esc(root)}[/]"); - if (plan.BackupFiles.Count > 0) - { - var node = tree.AddNode($"[yellow]{plan.BackupFiles.Count}[/] backup file(s)"); - foreach (var f in plan.BackupFiles) - node.AddNode($"[grey]{Esc(System.IO.Path.GetRelativePath(root, f))}[/]"); - } - if (plan.RollbackFolders.Count > 0) - { - var node = tree.AddNode($"[yellow]{plan.RollbackFolders.Count}[/] rollback folder(s)"); - foreach (var d in plan.RollbackFolders) - node.AddNode($"[grey]{Esc(System.IO.Path.GetRelativePath(root, d))}[/]"); - } - AnsiConsole.Write(tree); - AnsiConsole.WriteLine(); - - if (!Confirm($"Delete {plan.Total} leftover item(s)?", settings.AssumeYes)) - { - AnsiConsole.MarkupLine("[grey]aborted; nothing deleted.[/]"); - return 0; - } - - var result = BackupCleaner.Remove(plan); - foreach (var p in result.Problems) - AnsiConsole.MarkupLine($"[red]clean issue:[/] {Esc(p)}"); - - AnsiConsole.MarkupLine( - $"[green]removed {result.Total} item(s)[/] " - + $"([yellow]{result.RemovedBackups.Count}[/] backup file(s), " - + $"[yellow]{result.RemovedFolders.Count}[/] rollback folder(s))."); - return result.Problems.Count > 0 ? 1 : 0; - } - - private static bool Confirm(string question, bool assumeYes) - { - if (assumeYes || !IsInteractive()) return true; - return AnsiConsole.Confirm(question, defaultValue: false); - } -} diff --git a/tools/migrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs b/tools/migrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs deleted file mode 100644 index 9814523..0000000 --- a/tools/migrate/src/NanoMigrate.Cli.Commands/MigrateCommand.cs +++ /dev/null @@ -1,513 +0,0 @@ -using System.ComponentModel; -using NanoFramework.Migrate.Cli.Rendering; -using Spectre.Console; -using Spectre.Console.Cli; -using static NanoFramework.Migrate.Cli.Rendering.ConsoleSupport; - -namespace NanoFramework.Migrate.Cli.Commands; - -/// -/// A small shared base so the migrate, clean and rollback -/// commands declare the common -y|--yes flag once. (The commands are sibling -/// top-level commands rather than subcommands of migrate because Spectre.Cli -/// cannot host a command that is both a branch/default AND takes a positional -/// argument, and migrate <path> must keep working.) -/// -public abstract class AssumeYesSettings : CommandSettings -{ - [CommandOption("-y|--yes")] - [Description("Skip interactive prompts: proceed with the default action. (Non-interactive runs never prompt regardless.)")] - public bool AssumeYes { get; init; } -} - -public sealed class MigrateSettings : AssumeYesSettings -{ - [CommandArgument(0, "")] - [Description("A .nfproj file, a solution (.sln/.slnx), or a directory. " - + "For a solution, only its referenced .nfproj are converted and the solution is retargeted. " - + "For a directory, discovered solutions drive the selection; with no solution found, every .nfproj under the directory is converted.")] - public string Path { get; init; } = ""; - - [CommandOption("--solution ")] - [Description("Migrate only this solution (.sln or .slnx): convert just its referenced .nfproj and retarget that one solution. Overrides directory discovery.")] - public string? Solution { get; init; } - - [CommandOption("--sdk ")] - [Description("Accepted for back-compat but ignored: the SDK reference is versionless (pinned via global.json msbuild-sdks).")] - public string Sdk { get; init; } = "2.0.0"; - - [CommandOption("--tfm ")] - [Description("Target framework moniker (default netnano1.0).")] - public string Tfm { get; init; } = "netnano1.0"; - - [CommandOption("--ext ")] - [Description("Output extension: .nfproj or .csproj (default .csproj).")] - public string Ext { get; init; } = ".csproj"; - - [CommandOption("--no-backup")] - [Description("Don't write a .nfproj.bak.")] - public bool NoBackup { get; init; } - - [CommandOption("--dry-run|--no-write")] - [Description("Analyse and preview only; write nothing.")] - public bool DryRun { get; init; } - - [CommandOption("--glob ")] - [Description("Only convert .nfproj whose path (relative to ) matches the glob; the solutions referencing any matched project are updated. Supports *, ** and ?. Example: \"Beginner/**\".")] - public string? Glob { get; init; } - - [CommandOption("--verify")] - [Description("After a real migration, build the affected solution(s)/project(s) to verify the result; a failed build offers a rollback. Default: on for real runs, off for --dry-run.")] - public bool Verify { get; init; } - - [CommandOption("--no-verify")] - [Description("Skip the post-migration build verification.")] - public bool NoVerify { get; init; } - - [CommandOption("--report ")] - [Description("Write a migration report to this path. The format is chosen by the extension: " - + ".md/.markdown -> Markdown, .html/.htm -> HTML (anything else -> Markdown). " - + "Works for --dry-run too (reports what WOULD change).")] - public string? Report { get; init; } - - public override ValidationResult Validate() - { - if (Ext is not (".nfproj" or ".csproj")) - return ValidationResult.Error("--ext must be .nfproj or .csproj"); - if (Verify && NoVerify) - return ValidationResult.Error("--verify and --no-verify are mutually exclusive"); - return ValidationResult.Success(); - } - - public ConversionOptions ToConversionOptions() => new() - { - Sdk = Sdk, - Tfm = Tfm, - Ext = Ext, - NoBackup = NoBackup, - DryRun = DryRun, - Glob = Glob, - // --verify forces on, --no-verify forces off, neither leaves the default - // (on for real runs, off for dry-run). - Verify = Verify ? true : NoVerify ? false : (bool?)null, - }; -} - -public sealed class MigrateCommand : Command -{ - private readonly IProjectConverter _converter = new ProjectConverter(); - - protected override int Execute(CommandContext context, MigrateSettings settings, CancellationToken cancellationToken) - { - Header("NanoMigrate"); - - var o = settings.ToConversionOptions(); - var plan = MigrationPlanner.Plan(settings.Path, settings.Solution, settings.Glob); - - // Solution-aware plans (explicit solution, directory-with-solutions, glob) - // are handled by their own path; loose/single plans keep the historical flow. - return plan.Kind switch - { - PlanKind.SingleProject or PlanKind.LooseDirectory => RunLoose(settings, o, plan), - _ => RunSolutionScoped(settings, o, plan), - }; - } - - // The historical flow: a single .nfproj or a directory with no solutions. The - // converter retargets any solutions it finds by walking up the tree. - private int RunLoose(MigrateSettings settings, ConversionOptions o, MigrationPlan plan) - { - var targets = plan.LooseProjects.ToList(); - - // Reentrant: a fully-converted tree has no .nfproj left. Exit cleanly (0). - if (targets.Count == 0) - { - var why = o.Glob is null - ? $"no .nfproj found under '{Esc(settings.Path)}' (already SDK-style?)." - : $"no .nfproj matched glob '{Esc(o.Glob)}' under '{Esc(settings.Path)}'."; - AnsiConsole.MarkupLine($"[grey]nothing to convert: {why}[/]"); - return 0; - } - - var baseDir = BaseDirFor(settings.Path); - - AnsiConsole.MarkupLine(o.DryRun - ? $"[yellow]Dry run[/] — analysing [bold]{targets.Count}[/] project(s) under [blue]{Esc(baseDir)}[/]. Nothing will be written." - : $"Found [bold]{targets.Count}[/] project(s) to convert under [blue]{Esc(baseDir)}[/]."); - AnsiConsole.WriteLine(); - - if (!Confirm($"Proceed with {targets.Count} conversion(s)?", settings, o)) return AbortedExit(); - - // Record a rollback journal (real runs only): back up every file the - // conversions will modify or delete BEFORE the converter touches disk. - var journal = OpenJournal(baseDir, o); - if (journal is not null) - RecordJournal(journal, targets, o, extraSolutions: null); - - var results = ProcessProjects(targets, baseDir, o); - journal?.Save(); - - var rewritten = Array.Empty(); - var report = Report(results, baseDir, o, rewritten); - - // Verification targets in loose mode are the converted projects themselves - // (no host-owned solution set). A failed verify can roll the run back. - var verifyTargets = results - .Where(r => r.Result.Status is ConvertStatus.Converted or ConvertStatus.Review) - .Select(r => r.Result.OutputPath) - .ToList(); - var reportCtx = new ReportContext(results, baseDir, o.DryRun, rewritten, settings.Report); - return VerifyAndMaybeRollback(report, verifyTargets, journal, baseDir, settings, o, reportCtx); - } - - // The solution-aware flow: pick the candidate solutions (multi-select / confirm), - // convert their .nfproj, then retarget exactly those solutions ourselves. - private int RunSolutionScoped(MigrateSettings settings, ConversionOptions o, MigrationPlan plan) - { - var baseDir = BaseDirFor(settings.Path); - - if (plan.Candidates.Count == 0) - { - AnsiConsole.MarkupLine("[grey]nothing to convert: the selected solution(s) reference no .nfproj (already SDK-style?).[/]"); - return 0; - } - - // Show what is in scope before any prompt. - MigrateRenderer.RenderCandidateSolutions(plan.Candidates, baseDir); - - var chosen = SelectSolutions(plan, settings, o); - if (chosen.Count == 0) - { - AnsiConsole.MarkupLine("[grey]aborted; no solution selected, nothing written.[/]"); - return 0; - } - - var targets = MigrationPlan.ProjectsOf(chosen); - if (targets.Count == 0) - { - AnsiConsole.MarkupLine("[grey]nothing to convert: the chosen solution(s) reference no .nfproj.[/]"); - return 0; - } - - AnsiConsole.MarkupLine(o.DryRun - ? $"[yellow]Dry run[/] — analysing [bold]{targets.Count}[/] project(s) across [bold]{chosen.Count}[/] solution(s). Nothing will be written." - : $"Converting [bold]{targets.Count}[/] project(s) across [bold]{chosen.Count}[/] solution(s)."); - AnsiConsole.WriteLine(); - - if (!Confirm($"Proceed with {targets.Count} conversion(s) and update {chosen.Count} solution(s)?", settings, o)) - return AbortedExit(); - - // The host owns solution retargeting here, so the converter must not also - // walk up and rewrite solutions the user did not select. - var convOpts = o with { SkipSolutionRewrite = true }; - - // Record a rollback journal (real runs only): back up the files the - // conversions touch PLUS the chosen solutions the host will rewrite itself. - var journal = OpenJournal(baseDir, o); - if (journal is not null) - RecordJournal(journal, targets, convOpts, extraSolutions: chosen.Select(c => c.Solution.Path)); - - var results = ProcessProjects(targets, baseDir, convOpts); - - // Retarget exactly the chosen solutions to the converted projects. Only - // projects that actually converted (a .csproj now exists / would exist) are - // handed to the rewriter; idempotent on re-run. - var converted = results - .Where(r => r.Result.Status is ConvertStatus.Converted or ConvertStatus.Review) - .Select(r => r.Nfproj) - .ToList(); - - var rewritten = new List(); - if (!o.DryRun && converted.Count > 0) - { - foreach (var c in chosen) - if (SolutionRewriter.RewriteFile(c.Solution, converted)) - rewritten.Add(c.Solution.Path); - } - - journal?.Save(); - var report = Report(results, baseDir, o, rewritten); - - // Verify by building exactly the chosen solutions. - var verifyTargets = chosen.Select(c => c.Solution.Path).ToList(); - var reportCtx = new ReportContext(results, baseDir, o.DryRun, rewritten, settings.Report); - return VerifyAndMaybeRollback(report, verifyTargets, journal, baseDir, settings, o, reportCtx); - } - - // Opens a rollback journal rooted at baseDir for real runs only; dry-runs return - // null (nothing is written, nothing to reverse). - private static RollbackJournal? OpenJournal(string baseDir, ConversionOptions o) => - o.DryRun ? null : RollbackJournal.Start(baseDir); - - // Backs up — into the journal — every file the conversions will modify or delete, - // and records the files they will create, by analysing each target with a dry-run - // preview pass FIRST (before any real write). extraSolutions are host-rewritten - // solutions to back up as well. - private void RecordJournal(RollbackJournal journal, List targets, ConversionOptions o, - IEnumerable? extraSolutions) - { - var dryOpts = o with { DryRun = true }; - var extras = extraSolutions?.ToList(); - foreach (var nf in targets) - { - ConvertResult preview; - try { preview = _converter.Convert(nf, dryOpts); } - catch { continue; } // a project that fails to analyse is converted (and reported) normally; just not journaled - if (preview.AlreadySdk) continue; - MigrationJournaling.Record(journal, preview, extras); - extras = null; // back up the host solutions once, not per project - } - } - - // Runs the verification build (when enabled) and, on failure, applies the rollback - // policy: prompt-and-revert interactively, or keep-and-advise non-interactively. - // Maps to the final exit code. When verification is off/clean, returns the - // already-computed migrate exit code unchanged. - private int VerifyAndMaybeRollback(int migrateExit, List verifyTargets, - RollbackJournal? journal, string baseDir, MigrateSettings settings, ConversionOptions o, - ReportContext reportCtx) - { - if (!o.VerifyEffective || verifyTargets.Count == 0) - { - WriteReportIfRequested(reportCtx, verify: null); - return migrateExit; - } - - var builder = new SolutionBuilder(); - List outcomes; - bool toolMissing = false; - if (IsInteractive()) - { - List? captured = null; - AnsiConsole.Status().Spinner(Spinner.Known.Dots).Start("Verifying build…", ctx => - captured = builder.VerifyAll(verifyTargets, - t => ctx.Status($"Building [blue]{Esc(Path.GetFileName(t))}[/]…"), - () => toolMissing = true)); - outcomes = captured!; - } - else - { - outcomes = builder.VerifyAll(verifyTargets, null, () => toolMissing = true); - } - - if (toolMissing) - AnsiConsole.MarkupLine("[yellow]warning:[/] dotnet not found on PATH — build verification skipped."); - - MigrateRenderer.RenderVerifyTable(outcomes, baseDir); - - // Write the report (when requested) now that the verify outcomes are known, so - // a single write covers the passed-and-failed paths alike. A subsequent - // rollback reverts files on disk but the report still records what was attempted. - WriteReportIfRequested(reportCtx, outcomes); - - var verifyOutcome = Verification.Evaluate(outcomes); - if (verifyOutcome != VerifyOutcome.Failed) - return migrateExit; - - var failed = Verification.FailedCount(outcomes); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"[red]Build verification failed for {failed} target(s).[/]"); - - var interactive = IsInteractive() && !settings.AssumeYes; - bool? answer = null; - if (interactive) - answer = AnsiConsole.Confirm( - $"Migration verification failed for {failed} target(s). Roll back all changes?", defaultValue: false); - else if (settings.AssumeYes) - // --yes is about skipping prompts to PROCEED, never to silently destroy a - // migration; a failed verify under --yes keeps the changes (advise rollback). - answer = false; - - var decision = Verification.Decide(verifyOutcome, interactive, answer); - switch (decision) - { - case RollbackDecision.RollBack: - ApplyRollback(journal, baseDir); - AnsiConsole.MarkupLine("[green]All changes rolled back.[/]"); - return 1; - case RollbackDecision.KeepInteractive: - AnsiConsole.MarkupLine( - $"[yellow]Changes kept.[/] Run [bold]rollback \"{Esc(baseDir)}\"[/] later to revert."); - return 1; - case RollbackDecision.KeepNonInteractive: - default: - AnsiConsole.MarkupLine( - $"[yellow]Changes kept (non-interactive).[/] Run [bold]rollback \"{Esc(baseDir)}\"[/] to revert."); - return 1; - } - } - - // Reverts the just-recorded run from the live journal (preferred), falling back to - // the latest saved manifest under baseDir. Removes the backup set on success. - private static void ApplyRollback(RollbackJournal? journal, string baseDir) - { - RollbackResult? result = null; - if (journal is not null && !journal.IsEmpty && File.Exists(journal.ManifestPath)) - result = RollbackJournal.ApplyAndCleanup(journal.ManifestPath); - if (result is null) - { - var latest = RollbackJournal.ManifestPaths(baseDir).FirstOrDefault(); - if (latest is not null) result = RollbackJournal.ApplyAndCleanup(latest); - } - if (result is not null) MigrateRenderer.RenderRollbackResult(result, baseDir); - } - - // Decides which candidate solutions to operate on. Explicit single solution: no - // choice. Otherwise multi-select when interactive; non-interactive / --yes - // selects all affected (announcing it). - private static List SelectSolutions(MigrationPlan plan, MigrateSettings settings, ConversionOptions o) - { - // An explicit single solution (positional .sln/.slnx or --solution) is the - // only candidate; there is nothing to choose. - if (!plan.RequiresSelection) - return plan.Candidates.ToList(); - - var all = plan.Candidates.ToList(); - - // Only one affected solution: a simple confirm, not a multi-select. - if (all.Count == 1) - { - if (settings.AssumeYes || o.DryRun || !IsInteractive()) - { - if (!settings.AssumeYes && !o.DryRun) - AnsiConsole.MarkupLine("[grey]non-interactive: selecting the only affected solution.[/]"); - return all; - } - var fmt = all[0].Solution.Format == SolutionFormat.Xml ? "slnx" : "sln"; - return AnsiConsole.Confirm($"Migrate solution '{Esc(Path.GetFileName(all[0].Solution.Path))}' ({fmt})?") - ? all : new List(); - } - - // Several solutions: CI / non-interactive selects all and proceeds. - if (settings.AssumeYes || o.DryRun || !IsInteractive()) - { - if (!settings.AssumeYes && !o.DryRun) - AnsiConsole.MarkupLine($"[grey]non-interactive: selecting all {all.Count} affected solution(s).[/]"); - return all; - } - - // Interactive: present the multi-select. Picking none aborts cleanly. - var prompt = new MultiSelectionPrompt() - .Title("Select the solution(s) to migrate") - .NotRequired() // picking none is allowed (aborts) - .PageSize(15) - .InstructionsText("[grey](space to toggle, enter to confirm; pick none to abort)[/]") - .UseConverter(c => - { - var fmt = c.Solution.Format == SolutionFormat.Xml ? "slnx" : "sln"; - return $"{Path.GetFileName(c.Solution.Path)} ({fmt}, {c.NanoProjects.Count} project(s))"; - }); - prompt.AddChoices(all); - return AnsiConsole.Prompt(prompt); - } - - // The shared confirm gate. Returns true when we should proceed. In dry-run, when - // --yes is set, or in a non-interactive context we never block. - private static bool Confirm(string question, MigrateSettings settings, ConversionOptions o) - { - if (o.DryRun || settings.AssumeYes || !IsInteractive()) return true; - return AnsiConsole.Confirm(question); - } - - private static int AbortedExit() - { - AnsiConsole.MarkupLine("[grey]aborted; nothing written.[/]"); - return 0; - } - - // The base directory glob/relative paths are reported against. - private static string BaseDirFor(string path) => - Directory.Exists(path) ? Path.GetFullPath(path) - : File.Exists(path) ? Path.GetDirectoryName(Path.GetFullPath(path))! - : Path.GetFullPath(path); - - // Renders the summary/review/tally and (for solution-scoped runs) the rewritten - // solutions, then maps the outcome to the exit code (0 clean / 2 review / 1 error). - private static int Report(List results, string baseDir, ConversionOptions o, IReadOnlyList rewritten) - { - MigrateRenderer.RenderSummaryTable(results, baseDir, o.DryRun); - MigrateRenderer.RenderReviewNotes(results, baseDir); - MigrateRenderer.RenderRewrittenSolutions(rewritten, baseDir); - MigrateRenderer.RenderTally(results, o.DryRun); - - var errors = results.Count(r => r.Result.Status == ConvertStatus.Error); - var flagged = results.Count(r => r.Result.Status == ConvertStatus.Review); - if (errors > 0) return 1; - return flagged > 0 ? 2 : 0; - } - - // The data needed to render the optional migration report, carried from the - // outcome computation through the verify pass so a single write site can include - // the verify results when a verify pass ran. ReportPath is null when --report - // was not supplied. - private readonly record struct ReportContext( - IReadOnlyList Results, - string BaseDir, - bool DryRun, - IReadOnlyList Rewritten, - string? ReportPath); - - // Builds the MigrationReport from the same outcome/verify data the summary table - // used and writes it to the requested path (format chosen by extension), printing - // a Spectre line. A no-op when --report was not supplied. The caller (Core) owns - // the clock: the timestamp is captured here, not inside the engine. - private static void WriteReportIfRequested(ReportContext ctx, IReadOnlyList? verify) - { - if (string.IsNullOrWhiteSpace(ctx.ReportPath)) return; - - var report = MigrationReportBuilder.Build( - ctx.Results, ctx.BaseDir, ctx.DryRun, ctx.Rewritten, verify, DateTime.UtcNow); - - try - { - var format = MigrationReportBuilder.Write(report, ctx.ReportPath); - var kind = format == ReportFormat.Html ? "HTML" : "Markdown"; - AnsiConsole.MarkupLine($"[grey]{kind} report written to[/] [blue]{Esc(ctx.ReportPath)}[/]"); - } - catch (Exception ex) - { - AnsiConsole.MarkupLine($"[yellow]warning:[/] could not write report to [blue]{Esc(ctx.ReportPath)}[/]: {Esc(ex.Message)}"); - } - } - - // Runs every conversion, surfacing progress with a spinner. Any per-project - // exception is captured as an Error row rather than aborting the batch. - private List ProcessProjects(List targets, string baseDir, ConversionOptions o) - { - var results = new List(targets.Count); - - void RunAll(Action? report) - { - foreach (var nf in targets) - { - var rel = Path.GetRelativePath(baseDir, nf); - report?.Invoke(rel); - ConvertResult result; - try - { - result = _converter.Convert(nf, o); - } - catch (Exception ex) - { - result = new ConvertResult { OutputPath = nf, Error = ex.Message }; - } - results.Add(new ProjectOutcome(nf, result)); - } - } - - // Status() needs an interactive, non-redirected console; otherwise just - // run straight through (Spectre would no-op the spinner anyway). - if (IsInteractive()) - { - AnsiConsole.Status() - .Spinner(Spinner.Known.Dots) - .Start(o.DryRun ? "Analysing…" : "Converting…", ctx => - RunAll(rel => ctx.Status($"{(o.DryRun ? "Analysing" : "Converting")} [blue]{Esc(rel)}[/]…"))); - } - else - { - RunAll(null); - } - return results; - } -} diff --git a/tools/migrate/src/NanoMigrate.Cli.Commands/MigrateRegistration.cs b/tools/migrate/src/NanoMigrate.Cli.Commands/MigrateRegistration.cs deleted file mode 100644 index 9aab362..0000000 --- a/tools/migrate/src/NanoMigrate.Cli.Commands/MigrateRegistration.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Spectre.Console.Cli; - -namespace NanoFramework.Migrate.Cli.Commands; - -/// -/// Registers the migrate command surface once, so BOTH front ends — the standalone -/// nano-migrate exe and the umbrella nano tool — expose the same verbs: -/// migrate (the conversion, with a positional <path>), plus the -/// sibling clean and rollback commands. -/// -/// These are top-level commands rather than subcommands of migrate on purpose: -/// Spectre.Console.Cli (0.55) cannot host a default/branch command that ALSO takes a -/// positional argument (the first token is parsed as a subcommand selector, breaking -/// migrate <path>), and preserving migrate <path> is required. -/// -public static class MigrateRegistration -{ - /// - /// Adds migrate, clean and rollback to . - /// is the host's command name (nano or - /// nano-migrate), used only to phrase examples. - /// - public static void Add(IConfigurator config, string migrateDescription) - { - config.AddCommand("migrate") - .WithDescription(migrateDescription + " Related: 'clean' and 'rollback'.") - .WithExample("migrate", "./samples", "--glob", "Beginner/**", "--dry-run"); - - config.AddCommand("clean") - .WithDescription("Remove migration leftovers (*.nfproj.bak files and .nanomigrate/ rollback folders) under a path.") - .WithExample("clean", "./samples", "--yes"); - - config.AddCommand("rollback") - .WithDescription("Revert the last recorded migration under a path (restore originals, delete created projects).") - .WithExample("rollback", "./samples"); - } -} diff --git a/tools/migrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj b/tools/migrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj deleted file mode 100644 index 6d04fba..0000000 --- a/tools/migrate/src/NanoMigrate.Cli.Commands/NanoMigrate.Cli.Commands.csproj +++ /dev/null @@ -1,51 +0,0 @@ - - - - net8.0 - enable - enable - NanoFramework.Migrate.Cli - NanoMigrate.Cli.Commands - true - - - - - nanoFramework.Migrate.Cli.Commands - nanoFramework Migrate (shared Spectre command) - The shared `migrate` Spectre.Console.Cli command and its rendering, sitting over the nanoFramework.Migrate.Core engine. Referenced by both the standalone `nano-migrate` CLI and the `dotnet nano` umbrella tool so there is one MigrateCommand implementation. - nanoFramework - nanoFramework - https://github.com/nanoframework/nanoframework-sdk - https://github.com/nanoframework/nanoframework-sdk - git - MIT - nanoFramework;MSBuild;SDK;migration;nfproj;Spectre;IoT;embedded;C# - Copyright (c) .NET Foundation and Contributors - false - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/ConsoleSupport.cs b/tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/ConsoleSupport.cs deleted file mode 100644 index c039cd7..0000000 --- a/tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/ConsoleSupport.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Spectre.Console; - -namespace NanoFramework.Migrate.Cli.Rendering; - -/// Shared, low-level console helpers. The only consumers of AnsiConsole. -public static class ConsoleSupport -{ - // A "title rule" header. Spectre renders this as a centred rule when the - // terminal is wide enough, and degrades to plain text when redirected. - public static void Header(string title) - { - var rule = new Rule($"[bold]{Esc(title)}[/]") { Justification = Justify.Left }; - AnsiConsole.Write(rule); - AnsiConsole.WriteLine(); - } - - // Escapes interpolated text (notably file paths) so Spectre markup like - // "[" in a path can't be interpreted as styling — guards against injection. - public static string Esc(string s) => Markup.Escape(s); - - // Interactive == an attached terminal whose stdin is not redirected. We use - // this to decide whether to show a spinner and whether to prompt. In - // non-interactive contexts (CI, piped input) we never prompt or block. - public static bool IsInteractive() => - !Console.IsInputRedirected && AnsiConsole.Profile.Capabilities.Interactive; -} diff --git a/tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs b/tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs deleted file mode 100644 index 1c84f22..0000000 --- a/tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/MigrateRenderer.cs +++ /dev/null @@ -1,201 +0,0 @@ -using System.Text; -using Spectre.Console; -using static NanoFramework.Migrate.Cli.Rendering.ConsoleSupport; - -namespace NanoFramework.Migrate.Cli.Rendering; - -/// Pairs a source .nfproj path with its conversion outcome. -public readonly record struct ProjectOutcome(string Nfproj, ConvertResult Result); - -/// Spectre presentation for the migrate command. Consumes Core's data. -public static class MigrateRenderer -{ - public static void RenderSummaryTable(List results, string baseDir, bool dryRun) - { - var table = new Table().Border(TableBorder.Rounded).Expand(); - table.Title = new TableTitle(dryRun ? "Migration preview (dry run)" : "Migration summary"); - table.AddColumn("Project"); - table.AddColumn("Result"); - table.AddColumn("Packages"); - table.AddColumn("Notes"); - - foreach (var (nf, result) in results) - { - var rel = Path.GetRelativePath(baseDir, nf); - var (label, color) = StatusLabel(result.Status); - - var pkgs = result.Packages.Count == 0 - ? "[grey]—[/]" - : string.Join("\n", result.Packages.Select(p => $"{Esc(p.Key)} [grey]{Esc(p.Value)}[/]")); - - var notes = BuildNotesCell(result, dryRun); - - table.AddRow( - new Markup($"[blue]{Esc(rel)}[/]"), - new Markup($"[{color}]{label}[/]"), - new Markup(pkgs), - new Markup(notes)); - } - - AnsiConsole.Write(table); - } - - // The Notes cell: in dry-run it previews what WOULD change (target path, - // deletions, .sln edits); otherwise it shows the count of review flags. - private static string BuildNotesCell(ConvertResult result, bool dryRun) - { - if (result.Status == ConvertStatus.Error) - return $"[red]{Esc(result.Error ?? "error")}[/]"; - if (result.Status == ConvertStatus.Skipped) - return "[grey]already SDK-style[/]"; - - var lines = new List(); - if (dryRun) - { - lines.Add($"[grey]→[/] {Esc(Path.GetFileName(result.OutputPath))}"); - foreach (var d in result.DeletedFiles) - lines.Add($"[red]delete[/] {Esc(Path.GetFileName(d))}"); - foreach (var s in result.UpdatedSolutions) - lines.Add($"[yellow]edit[/] {Esc(Path.GetFileName(s))}"); - } - if (result.Review.Count > 0) - lines.Add($"[yellow]{result.Review.Count} item(s) need review[/]"); - return lines.Count == 0 ? "[green]clean[/]" : string.Join("\n", lines); - } - - // Review notes get a clearly-visible yellow panel, grouped per project, so - // they are never buried in the table. - public static void RenderReviewNotes(List results, string baseDir) - { - var flagged = results.Where(r => r.Result.Review.Count > 0 - && r.Result.Status == ConvertStatus.Review).ToList(); - if (flagged.Count == 0) return; - - var sb = new StringBuilder(); - foreach (var (nf, result) in flagged) - { - var rel = Path.GetRelativePath(baseDir, nf); - sb.Append($"[bold]{Esc(rel)}[/]\n"); - foreach (var item in result.Review) - sb.Append($" [yellow]•[/] {Esc(item)}\n"); - } - - var panel = new Panel(sb.ToString().TrimEnd('\n')) - { - Header = new PanelHeader("[yellow]MANUAL REVIEW NEEDED[/]"), - Border = BoxBorder.Rounded, - BorderStyle = new Style(Color.Yellow), - Expand = true, - }; - AnsiConsole.WriteLine(); - AnsiConsole.Write(panel); - } - - public static void RenderTally(List results, bool dryRun) - { - int converted = results.Count(r => r.Result.Status == ConvertStatus.Converted); - int skipped = results.Count(r => r.Result.Status == ConvertStatus.Skipped); - int flagged = results.Count(r => r.Result.Status == ConvertStatus.Review); - int errors = results.Count(r => r.Result.Status == ConvertStatus.Error); - var verb = dryRun ? "would convert" : "converted"; - - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine( - $"[green]{verb} {converted}[/] • [grey]skipped {skipped}[/] • " - + $"[yellow]flagged {flagged}[/] • [red]errors {errors}[/] • total {results.Count}"); - } - - // A tree of the candidate solutions and the .nfproj each would convert, so the - // user sees what is in scope before any prompt. Used for solution-input, - // directory-with-solutions, and glob-scoped plans. - public static void RenderCandidateSolutions(IReadOnlyList candidates, string baseDir) - { - if (candidates.Count == 0) return; - - var tree = new Tree("[bold]Affected solution(s)[/]"); - foreach (var c in candidates) - { - var fmt = c.Solution.Format == SolutionFormat.Xml ? "slnx" : "sln"; - var slnRel = RelOrName(baseDir, c.Solution.Path); - var node = tree.AddNode($"[yellow]{Esc(slnRel)}[/] [grey]({fmt}, {c.NanoProjects.Count} project(s))[/]"); - foreach (var nf in c.NanoProjects) - node.AddNode($"[blue]{Esc(RelOrName(baseDir, nf))}[/]"); - } - AnsiConsole.Write(tree); - AnsiConsole.WriteLine(); - } - - // The solutions actually retargeted by a real run (grouped notice). Pure - // presentation over the rewrite results. - public static void RenderRewrittenSolutions(IReadOnlyList rewritten, string baseDir) - { - if (rewritten.Count == 0) return; - AnsiConsole.WriteLine(); - foreach (var sln in rewritten) - AnsiConsole.MarkupLine($"[yellow]updated[/] {Esc(RelOrName(baseDir, sln))}"); - } - - // A path relative to baseDir when it sits underneath it; otherwise the bare - // file name. Keeps output readable whether or not the file is inside the tree. - private static string RelOrName(string baseDir, string path) - { - var rel = Path.GetRelativePath(baseDir, path); - return rel.StartsWith("..", StringComparison.Ordinal) ? Path.GetFileName(path) : rel; - } - - // The verification build results: one row per target with a Pass/Fail/Skipped - // badge and (on failure) a short error tail. Pure presentation over Core data. - public static void RenderVerifyTable(IReadOnlyList outcomes, string baseDir) - { - if (outcomes.Count == 0) return; - - var table = new Table().Border(TableBorder.Rounded).Expand(); - table.Title = new TableTitle("Build verification"); - table.AddColumn("Target"); - table.AddColumn("Result"); - table.AddColumn("Details"); - - foreach (var o in outcomes) - { - var (label, color) = o.Skipped ? ("Skipped", "grey") - : o.Succeeded ? ("Pass", "green") - : ("Fail", "red"); - var details = o.Skipped ? Esc(o.Message ?? "skipped") - : o.Succeeded ? "[grey]—[/]" - : $"[red]exit {o.ExitCode}[/]\n{Esc(Truncate(o.ErrorTail, 600))}"; - table.AddRow( - new Markup($"[blue]{Esc(RelOrName(baseDir, o.Target))}[/]"), - new Markup($"[{color}]{label}[/]"), - new Markup(details)); - } - AnsiConsole.WriteLine(); - AnsiConsole.Write(table); - } - - // The result of a rollback: what was restored/deleted (grouped notice). - public static void RenderRollbackResult(RollbackResult result, string baseDir) - { - AnsiConsole.WriteLine(); - foreach (var r in result.Restored) - AnsiConsole.MarkupLine($"[green]restored[/] {Esc(RelOrName(baseDir, r))}"); - foreach (var d in result.Deleted) - AnsiConsole.MarkupLine($"[yellow]removed[/] {Esc(RelOrName(baseDir, d))}"); - foreach (var p in result.Problems) - AnsiConsole.MarkupLine($"[red]rollback issue:[/] {Esc(p)}"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"[bold]rolled back {result.Total} file(s)[/] " - + $"([green]{result.Restored.Count} restored[/], [yellow]{result.Deleted.Count} removed[/])."); - } - - private static string Truncate(string s, int max) => - s.Length <= max ? s : s[..max] + "…"; - - private static (string label, string color) StatusLabel(ConvertStatus s) => s switch - { - ConvertStatus.Converted => ("Converted", "green"), - ConvertStatus.Skipped => ("Skipped", "grey"), - ConvertStatus.Review => ("Review", "yellow"), - ConvertStatus.Error => ("Error", "red"), - _ => ("?", "white"), - }; -} diff --git a/tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/MigrationReportBuilder.cs b/tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/MigrationReportBuilder.cs deleted file mode 100644 index 02ef21c..0000000 --- a/tools/migrate/src/NanoMigrate.Cli.Commands/Rendering/MigrationReportBuilder.cs +++ /dev/null @@ -1,103 +0,0 @@ -namespace NanoFramework.Migrate.Cli.Rendering; - -/// The output format chosen for a written migration report. -public enum ReportFormat -{ - Markdown, - Html, -} - -/// -/// Maps the command-layer migration data (, rewritten -/// solutions and verification s) onto the console-free -/// Core the report writers consume. Lives in the -/// presentation layer because is a command-layer type; -/// the resulting report and the writers stay pure Core. -/// -public static class MigrationReportBuilder -{ - /// - /// Builds a from the same outcome data the summary - /// table renders. is supplied by the caller so - /// Core never reads the clock. - /// - public static MigrationReport Build( - IReadOnlyList results, - string baseDir, - bool dryRun, - IReadOnlyList affectedSolutions, - IReadOnlyList? verify, - DateTime generatedUtc) - { - var projects = results - .Select(o => new ReportEntry - { - RelativePath = Rel(baseDir, o.Nfproj), - Status = o.Result.Status, - Packages = o.Result.Packages.ToList(), - Review = o.Result.Review.ToList(), - Error = o.Result.Error, - }) - .ToList(); - - var solutions = affectedSolutions.Select(s => Rel(baseDir, s)).ToList(); - - var verifyEntries = (verify ?? Array.Empty()) - .Select(v => new ReportVerifyEntry - { - Target = Rel(baseDir, v.Target), - Succeeded = v.Succeeded, - Skipped = v.Skipped, - ExitCode = v.ExitCode, - Details = v.Skipped ? v.Message - : v.Succeeded ? null - : v.ErrorTail, - }) - .ToList(); - - return new MigrationReport - { - RootPath = baseDir, - GeneratedUtc = generatedUtc, - DryRun = dryRun, - Projects = projects, - AffectedSolutions = solutions, - Verify = verifyEntries, - }; - } - - /// - /// Picks the report format from the file extension: .md/.markdown → - /// Markdown, .html/.htm → HTML, anything else → Markdown. - /// - public static ReportFormat FormatFor(string path) - { - var ext = Path.GetExtension(path); - return ext.ToLowerInvariant() switch - { - ".html" or ".htm" => ReportFormat.Html, - _ => ReportFormat.Markdown, // .md, .markdown, and everything else - }; - } - - /// Renders to in the format keyed by the extension. - public static ReportFormat Write(MigrationReport report, string path) - { - var format = FormatFor(path); - if (format == ReportFormat.Html) - HtmlReportWriter.Write(report, path); - else - MarkdownReportWriter.Write(report, path); - return format; - } - - // A forward-slash path relative to baseDir when the file sits beneath it; - // otherwise the bare file name. Keeps the report stable across machines. - private static string Rel(string baseDir, string path) - { - var rel = Path.GetRelativePath(baseDir, path); - if (rel.StartsWith("..", StringComparison.Ordinal)) - rel = Path.GetFileName(path); - return rel.Replace('\\', '/'); - } -} diff --git a/tools/migrate/src/NanoMigrate.Cli.Commands/RollbackCommand.cs b/tools/migrate/src/NanoMigrate.Cli.Commands/RollbackCommand.cs deleted file mode 100644 index 65aae65..0000000 --- a/tools/migrate/src/NanoMigrate.Cli.Commands/RollbackCommand.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.ComponentModel; -using NanoFramework.Migrate.Cli.Rendering; -using Spectre.Console; -using Spectre.Console.Cli; -using static NanoFramework.Migrate.Cli.Rendering.ConsoleSupport; - -namespace NanoFramework.Migrate.Cli.Commands; - -public sealed class RollbackSettings : AssumeYesSettings -{ - [CommandArgument(0, "[path]")] - [Description("The directory whose last recorded migration is reverted (looks for a " - + ".nanomigrate/ journal under it). Defaults to the current directory.")] - public string Path { get; init; } = "."; -} - -/// -/// Reverts the last recorded migration under a path by reading its rollback journal -/// (restore backed-up originals, delete created files). Idempotent and safe: with no -/// journal it reports "nothing to roll back" and exits 0. The reversal logic lives in -/// ; this is the thin presentation/prompt. -/// -public sealed class RollbackCommand : Command -{ - protected override int Execute(CommandContext context, RollbackSettings settings, CancellationToken cancellationToken) - { - Header("NanoMigrate · rollback"); - - var root = System.IO.Path.GetFullPath(settings.Path); - var manifestPath = RollbackJournal.ManifestPaths(root).FirstOrDefault(); - if (manifestPath is null) - { - AnsiConsole.MarkupLine($"[grey]nothing to roll back under '{Esc(root)}' (no migration journal found).[/]"); - return 0; - } - - var manifest = RollbackJournal.FindLatest(root); - var count = manifest?.Entries.Count ?? 0; - AnsiConsole.MarkupLine( - $"Found a recorded migration ([blue]{Esc(manifest?.Id ?? "?")}[/]) with [bold]{count}[/] action(s)."); - - if (!Confirm("Roll back the last recorded migration?", settings.AssumeYes)) - { - AnsiConsole.MarkupLine("[grey]aborted; nothing reverted.[/]"); - return 0; - } - - var result = RollbackJournal.ApplyAndCleanup(manifestPath); - if (result is null) - { - AnsiConsole.MarkupLine("[red]error:[/] the rollback journal could not be read."); - return 1; - } - - MigrateRenderer.RenderRollbackResult(result, root); - return result.Problems.Count > 0 ? 1 : 0; - } - - private static bool Confirm(string question, bool assumeYes) - { - if (assumeYes || !IsInteractive()) return true; - return AnsiConsole.Confirm(question, defaultValue: false); - } -} diff --git a/tools/migrate/src/NanoMigrate.Cli/Cli/CloneCommand.cs b/tools/migrate/src/NanoMigrate.Cli/Cli/CloneCommand.cs deleted file mode 100644 index fc37c8d..0000000 --- a/tools/migrate/src/NanoMigrate.Cli/Cli/CloneCommand.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System.ComponentModel; -using Spectre.Console; -using Spectre.Console.Cli; -using static NanoFramework.Migrate.Cli.Rendering.ConsoleSupport; - -namespace NanoFramework.Migrate.Cli.Commands; - -internal sealed class CloneSettings : CommandSettings -{ - [CommandArgument(0, "[out-dir]")] - [Description("Directory to clone into (default ./nano-repos).")] - public string? OutDir { get; init; } - - [CommandOption("--org ")] - [Description("GitHub org (default nanoframework).")] - public string Org { get; init; } = "nanoframework"; - - [CommandOption("--filter ")] - [Description("Repo name prefix to match (default lib-).")] - public string Filter { get; init; } = "lib-"; - - [CommandOption("--token ")] - [Description("GitHub token (or env GITHUB_TOKEN) to raise the API rate limit.")] - public string? Token { get; init; } = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); - - [CommandOption("--include-archived")] - [Description("Include archived repositories (skipped by default).")] - public bool IncludeArchived { get; init; } -} - -internal sealed class CloneCommand : Command -{ - protected override int Execute(CommandContext context, CloneSettings settings, CancellationToken cancellationToken) - { - Header("NanoMigrate · clone"); - var outDir = settings.OutDir ?? "./nano-repos"; - Directory.CreateDirectory(outDir); - - AnsiConsole.MarkupLine($"Enumerating [bold]{Esc(settings.Org)}[/] repositories matching '[blue]{Esc(settings.Filter)}*[/]'…"); - var repos = GitHub.ListOrgRepos(settings.Org, settings.Token, settings.IncludeArchived) - .Where(r => r.Name.StartsWith(settings.Filter, StringComparison.OrdinalIgnoreCase)) - .OrderBy(r => r.Name).ToList(); - - if (repos.Count == 0) throw new UserError( - $"no repos matched '{settings.Filter}*' in org '{settings.Org}'. " + - "Check the org name and filter, or pass --token to lift the API rate limit."); - - AnsiConsole.MarkupLine($"Found [bold]{repos.Count}[/] repositories. Cloning into [blue]{Esc(outDir)}[/]…"); - AnsiConsole.WriteLine(); - - var table = new Table().Border(TableBorder.Rounded).Expand(); - table.Title = new TableTitle("Clone results"); - table.AddColumn("Repository"); - table.AddColumn("Result"); - - int ok = 0, skipped = 0, failed = 0; - void CloneAll(Action? report) - { - foreach (var r in repos) - { - report?.Invoke(r.Name); - var dest = Path.Combine(outDir, r.Name); - if (Directory.Exists(dest)) - { - table.AddRow(new Markup($"[blue]{Esc(r.Name)}[/]"), new Markup("[grey]skipped (already present)[/]")); - skipped++; continue; - } - var (code, _, err) = ProcessRunner.Run("git", $"clone --depth 1 {r.CloneUrl} \"{dest}\"", outDir); - if (code == 0) - { - table.AddRow(new Markup($"[blue]{Esc(r.Name)}[/]"), new Markup("[green]cloned[/]")); - ok++; - } - else - { - var msg = err.Trim().Split('\n').LastOrDefault() ?? "git clone failed"; - table.AddRow(new Markup($"[blue]{Esc(r.Name)}[/]"), new Markup($"[red]FAIL[/] {Esc(msg)}")); - failed++; - } - } - } - - if (IsInteractive()) - AnsiConsole.Status().Spinner(Spinner.Known.Dots) - .Start("Cloning…", ctx => CloneAll(name => ctx.Status($"Cloning [blue]{Esc(name)}[/]…"))); - else - CloneAll(null); - - AnsiConsole.Write(table); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"[green]cloned {ok}[/] • [grey]skipped {skipped}[/] • [red]failed {failed}[/]"); - return failed > 0 ? 2 : 0; - } -} diff --git a/tools/migrate/src/NanoMigrate.Cli/Cli/FleetCommand.cs b/tools/migrate/src/NanoMigrate.Cli/Cli/FleetCommand.cs deleted file mode 100644 index f059dfc..0000000 --- a/tools/migrate/src/NanoMigrate.Cli/Cli/FleetCommand.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.ComponentModel; -using NanoFramework.Migrate.Cli.Rendering; -using Spectre.Console; -using Spectre.Console.Cli; -using static NanoFramework.Migrate.Cli.Rendering.ConsoleSupport; - -namespace NanoFramework.Migrate.Cli.Commands; - -internal sealed class FleetSettings : CommandSettings -{ - [CommandArgument(0, "")] - [Description("A directory of cloned repos; every .nfproj across them is migrated.")] - public string ReposDir { get; init; } = ""; - - [CommandOption("--sdk ")] - [Description("Accepted for back-compat but ignored: the SDK reference is versionless (pinned via global.json msbuild-sdks).")] - public string Sdk { get; init; } = "2.0.0"; - - [CommandOption("--tfm ")] - [Description("Target framework moniker (default netnano1.0).")] - public string Tfm { get; init; } = "netnano1.0"; - - [CommandOption("--ext ")] - [Description("Output extension: .nfproj or .csproj (default .csproj).")] - public string Ext { get; init; } = ".csproj"; - - [CommandOption("--no-backup")] - [Description("Don't write a .nfproj.bak (implied by --commit).")] - public bool NoBackup { get; init; } - - [CommandOption("--dry-run|--no-write")] - [Description("Analyse and preview only; write nothing.")] - public bool DryRun { get; init; } - - [CommandOption("--glob ")] - [Description("Only convert matching .nfproj within each repo. Supports *, ** and ?.")] - public string? Glob { get; init; } - - [CommandOption("--report ")] - [Description("Markdown report path (default migration-report.md).")] - public string Report { get; init; } = "migration-report.md"; - - [CommandOption("--branch ")] - [Description("Create/reset this git branch in each repo (must not start with 'develop').")] - public string? Branch { get; init; } - - [CommandOption("--commit")] - [Description("Commit the changes (requires --branch). Uses a contribution-compliant message and signs off (Signed-off-by) by default.")] - public bool Commit { get; init; } - - [CommandOption("--message ")] - [Description("Commit summary line (kept <= 50 chars).")] - public string? Message { get; init; } - - [CommandOption("--issue ")] - [Description("Reference an issue: adds a \"Fix #\" trailer to the commit.")] - public string? Issue { get; init; } - - [CommandOption("--no-sign-off")] - [Description("Don't add a Signed-off-by line.")] - public bool NoSignOff { get; init; } - - public override ValidationResult Validate() - { - if (Ext is not (".nfproj" or ".csproj")) - return ValidationResult.Error("--ext must be .nfproj or .csproj"); - return ValidationResult.Success(); - } - - public FleetOptions ToFleetOptions() => new() - { - Conversion = new ConversionOptions - { - Sdk = Sdk, - Tfm = Tfm, - Ext = Ext, - NoBackup = NoBackup, - DryRun = DryRun, - Glob = Glob, - }, - Report = Report, - Branch = Branch, - Commit = Commit, - CommitMessage = Message, - Issue = Issue?.TrimStart('#'), - SignOff = !NoSignOff, - }; -} - -internal sealed class FleetCommand : Command -{ - protected override int Execute(CommandContext context, FleetSettings settings, CancellationToken cancellationToken) - { - Header("NanoMigrate · fleet"); - - var o = settings.ToFleetOptions(); - var reposDir = settings.ReposDir; - var service = new FleetService(new ProjectConverter(), new ProcessRunner()); - - // Core validates and discovers; surface its messages as clean usage errors. - List repoDirs; - try - { - repoDirs = service.ResolveRepos(reposDir, o); - } - catch (ArgumentException ex) - { - throw new UserError(ex.Message); - } - - if (o.Conversion.DryRun) - AnsiConsole.MarkupLine($"[yellow]Dry run[/] — {repoDirs.Count} repo(s); nothing will be written."); - else - AnsiConsole.MarkupLine($"Processing [bold]{repoDirs.Count}[/] repo(s) under [blue]{Esc(reposDir)}[/]" - + (o.Branch is not null ? $" on branch [blue]{Esc(o.Branch)}[/]" : "") - + (o.Commit ? ", committing" : "") + "."); - AnsiConsole.WriteLine(); - - List report; - if (IsInteractive()) - { - List? captured = null; - AnsiConsole.Status().Spinner(Spinner.Known.Dots) - .Start("Migrating…", ctx => captured = service.Process(repoDirs, o, - name => ctx.Status($"Migrating [blue]{Esc(name)}[/]…"))); - report = captured!; - } - else - { - report = service.Process(repoDirs, o); - } - - FleetRenderer.RenderFleetTable(report); - FleetRenderer.RenderFleetTree(report); - FleetRenderer.RenderFleetReview(report); - - service.WriteReport(report, o, reposDir); - var errored = report.Count(r => r.Error is not null); - var needsReview = report.Count(r => r.Error is null && r.Review.Count > 0); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine( - $"[bold]{report.Count}[/] repo(s) processed • " - + $"[yellow]{needsReview} need review[/] • [red]{errored} with errors[/] • " - + $"report: [blue]{Esc(o.Report)}[/]"); - return errored > 0 ? 2 : 0; - } -} diff --git a/tools/migrate/src/NanoMigrate.Cli/GitHub.cs b/tools/migrate/src/NanoMigrate.Cli/GitHub.cs deleted file mode 100644 index 2122f79..0000000 --- a/tools/migrate/src/NanoMigrate.Cli/GitHub.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Net.Http.Headers; -using System.Text.Json; - -namespace NanoFramework.Migrate.Cli; - -/// Minimal GitHub REST client (BCL only) for listing org repositories. -internal static class GitHub -{ - internal sealed record Repo(string Name, string CloneUrl, bool Archived); - - public static List ListOrgRepos(string org, string? token, bool includeArchived) - { - using var http = new HttpClient(); - http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("nano-migrate", "1.0")); - http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); - if (!string.IsNullOrEmpty(token)) - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - - var repos = new List(); - for (int page = 1; ; page++) - { - var url = $"https://api.github.com/orgs/{org}/repos?per_page=100&page={page}&type=public"; - using var resp = http.GetAsync(url).GetAwaiter().GetResult(); - if (!resp.IsSuccessStatusCode) - { - var hint = resp.StatusCode == System.Net.HttpStatusCode.Forbidden - ? " (rate limited — pass --token or set GITHUB_TOKEN)" : ""; - throw new UserError($"GitHub API returned {(int)resp.StatusCode} {resp.StatusCode}{hint}"); - } - - var json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - using var doc = JsonDocument.Parse(json); - var arr = doc.RootElement; - if (arr.GetArrayLength() == 0) break; - - foreach (var e in arr.EnumerateArray()) - { - var archived = e.TryGetProperty("archived", out var ar) && ar.GetBoolean(); - if (archived && !includeArchived) continue; - repos.Add(new Repo( - e.GetProperty("name").GetString()!, - e.GetProperty("clone_url").GetString()!, - archived)); - } - if (arr.GetArrayLength() < 100) break; - } - return repos; - } -} diff --git a/tools/migrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj b/tools/migrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj deleted file mode 100644 index d49c13b..0000000 --- a/tools/migrate/src/NanoMigrate.Cli/NanoMigrate.Cli.csproj +++ /dev/null @@ -1,60 +0,0 @@ - - - - Exe - net8.0 - enable - enable - nano-migrate - NanoFramework.Migrate.Cli - true - - false - - - - - true - nano-migrate - nanoFramework.Migrate - nanoFramework Migrate (CLI) - Command-line tool that migrates legacy nanoFramework .nfproj projects to the SDK-style MSBuild project system — one project, a directory, or an entire cloned fleet. Built on the nanoFramework.Migrate.Core engine. - nanoFramework - nanoFramework - https://github.com/nanoframework/nanoframework-sdk - https://github.com/nanoframework/nanoframework-sdk - git - MIT - nanoFramework;MSBuild;SDK;migration;nfproj;dotnet-tool;IoT;embedded;C# - Copyright (c) .NET Foundation and Contributors - false - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tools/migrate/src/NanoMigrate.Cli/ProcessRunner.cs b/tools/migrate/src/NanoMigrate.Cli/ProcessRunner.cs deleted file mode 100644 index eca4b91..0000000 --- a/tools/migrate/src/NanoMigrate.Cli/ProcessRunner.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Diagnostics; - -namespace NanoFramework.Migrate.Cli; - -/// -/// Runs external processes. Also implements so Core's -/// fleet orchestration can issue git commands without depending on -/// itself. -/// -internal sealed class ProcessRunner : IGitRunner -{ - public GitResult Run(string args, string workingDirectory) - { - var (code, so, se) = Run("git", args, workingDirectory); - return new GitResult(code, so, se); - } - - public static (int code, string stdout, string stderr) Run(string file, string args, string cwd) - { - var psi = new ProcessStartInfo(file, args) - { - WorkingDirectory = cwd, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - using var p = Process.Start(psi)!; - var so = p.StandardOutput.ReadToEnd(); - var se = p.StandardError.ReadToEnd(); - p.WaitForExit(); - return (p.ExitCode, so, se); - } -} diff --git a/tools/migrate/src/NanoMigrate.Cli/Program.cs b/tools/migrate/src/NanoMigrate.Cli/Program.cs deleted file mode 100644 index c6ce07f..0000000 --- a/tools/migrate/src/NanoMigrate.Cli/Program.cs +++ /dev/null @@ -1,66 +0,0 @@ -// nano-migrate — convert legacy nanoFramework .nfproj projects to the SDK-style -// MSBuild project system, one project at a time or across an entire cloned fleet. -// -// SCOPE: project-system migration ONLY. This tool does NOT touch OTA, modular -// firmware packaging, runtimes/{rid}/native layouts, or ABI manifests. It moves -// a repo from the legacy flavored .nfproj format onto an SDK-style project that -// composes over the nanoFramework SDK, folds packages.config into PackageReference, -// and folds .nuspec metadata into MSBuild Pack properties. Nothing more. -// -// The conversion engine lives in NanoMigrate.Core (a console-free library). This -// project is a thin Spectre.Console.Cli front end over it. - -using NanoFramework.Migrate.Cli; -using NanoFramework.Migrate.Cli.Commands; -using Spectre.Console; -using Spectre.Console.Cli; - -// No arguments: show help and signal misuse (exit 1), matching the original. -if (args.Length == 0) -{ - BuildApp(out _).Run(new[] { "--help" }); - return 1; -} - -var app = BuildApp(out _); -try -{ - return app.Run(args); -} -catch (UserError ue) -{ - AnsiConsole.MarkupLine($"[red]error:[/] {Markup.Escape(ue.Message)}"); - return 1; -} -catch (Exception ex) -{ - // Spectre's own parse/validation errors surface here (PropagateExceptions). - AnsiConsole.MarkupLine($"[red]error:[/] {Markup.Escape(ex.Message)}"); - return 1; -} - -static CommandApp BuildApp(out CommandApp app) -{ - var a = new CommandApp(); - a.Configure(config => - { - config.SetApplicationName("nano-migrate"); - // Surface exceptions to Program so UserError maps to exit 1 and parse - // errors render cleanly without a stack trace. - config.PropagateExceptions(); - - // migrate + clean + rollback, registered once and shared with the umbrella tool. - MigrateRegistration.Add(config, - "Convert a .nfproj, or every .nfproj under a directory."); - - config.AddCommand("clone") - .WithDescription("Clone all matching repos from a GitHub org.") - .WithExample("clone", "./nano-repos", "--token", "$GITHUB_TOKEN"); - - config.AddCommand("fleet") - .WithDescription("Migrate every .nfproj across cloned repos; write a report.") - .WithExample("fleet", "./nano-repos", "--branch", "sdk-migration", "--commit", "--dry-run"); - }); - app = a; - return a; -} diff --git a/tools/migrate/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs b/tools/migrate/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs deleted file mode 100644 index c6fb8cb..0000000 --- a/tools/migrate/src/NanoMigrate.Cli/Rendering/FleetRenderer.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Text; -using Spectre.Console; -using static NanoFramework.Migrate.Cli.Rendering.ConsoleSupport; - -namespace NanoFramework.Migrate.Cli.Rendering; - -/// Spectre presentation for the fleet command. Consumes Core's RepoReport data. -internal static class FleetRenderer -{ - public static void RenderFleetTable(List report) - { - var table = new Table().Border(TableBorder.Rounded).Expand(); - table.Title = new TableTitle("Fleet results"); - table.AddColumn("Repository"); - table.AddColumn("Result"); - table.AddColumn("Projects"); - table.AddColumn("Review"); - - foreach (var rr in report) - { - string label, color; - if (rr.Error is not null) { label = "Error"; color = "red"; } - else if (rr.Review.Count > 0) { label = "Review"; color = "yellow"; } - else { label = rr.Committed ? "OK (committed)" : "OK"; color = "green"; } - - var note = rr.Error is not null ? $" [red]{Esc(rr.Error.Split('\n')[0])}[/]" : ""; - table.AddRow( - new Markup($"[blue]{Esc(rr.Name)}[/]"), - new Markup($"[{color}]{label}[/]{note}"), - new Markup(rr.Projects.ToString()), - new Markup(rr.Review.Count == 0 ? "[grey]—[/]" : $"[yellow]{rr.Review.Count}[/]")); - } - AnsiConsole.Write(table); - } - - // A tree view of the fleet: one branch per repo, with each repo's review - // items as leaves. Complements the summary table for at-a-glance structure. - public static void RenderFleetTree(List report) - { - if (report.Count == 0) return; - - var tree = new Tree("[bold]Fleet[/]"); - foreach (var rr in report) - { - string label, color; - if (rr.Error is not null) { label = "error"; color = "red"; } - else if (rr.Review.Count > 0) { label = "review"; color = "yellow"; } - else { label = rr.Committed ? "ok (committed)" : "ok"; color = "green"; } - - var node = tree.AddNode($"[blue]{Esc(rr.Name)}[/] [{color}]{label}[/] [grey]({rr.Projects} project(s))[/]"); - if (rr.Error is not null) - node.AddNode($"[red]{Esc(rr.Error.Split('\n')[0])}[/]"); - foreach (var item in rr.Review) - node.AddNode($"[yellow]•[/] {Esc(item)}"); - } - AnsiConsole.WriteLine(); - AnsiConsole.Write(tree); - } - - public static void RenderFleetReview(List report) - { - var flagged = report.Where(r => r.Error is null && r.Review.Count > 0).ToList(); - if (flagged.Count == 0) return; - - var sb = new StringBuilder(); - foreach (var rr in flagged) - { - sb.Append($"[bold]{Esc(rr.Name)}[/]\n"); - foreach (var item in rr.Review) sb.Append($" [yellow]•[/] {Esc(item)}\n"); - } - var panel = new Panel(sb.ToString().TrimEnd('\n')) - { - Header = new PanelHeader("[yellow]MANUAL REVIEW NEEDED[/]"), - Border = BoxBorder.Rounded, - BorderStyle = new Style(Color.Yellow), - Expand = true, - }; - AnsiConsole.WriteLine(); - AnsiConsole.Write(panel); - } -} diff --git a/tools/migrate/src/NanoMigrate.Cli/UserError.cs b/tools/migrate/src/NanoMigrate.Cli/UserError.cs deleted file mode 100644 index 694c99c..0000000 --- a/tools/migrate/src/NanoMigrate.Cli/UserError.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace NanoFramework.Migrate.Cli; - -/// A user-facing error that prints cleanly without a stack trace. -internal sealed class UserError(string message) : Exception(message); diff --git a/tools/migrate/src/NanoMigrate.Core/Backup/BackupCleaner.cs b/tools/migrate/src/NanoMigrate.Core/Backup/BackupCleaner.cs deleted file mode 100644 index f26df25..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Backup/BackupCleaner.cs +++ /dev/null @@ -1,139 +0,0 @@ -namespace NanoFramework.Migrate.Core.Backup; - -/// The leftovers a clean would remove, gathered for preview before deletion. -public sealed class CleanPlan -{ - /// The root the scan was rooted at (absolute). - public required string Root { get; init; } - - /// *.nfproj.bak files found under the root. - public List BackupFiles { get; } = new(); - - /// .nanomigrate rollback folders found under the root. - public List RollbackFolders { get; } = new(); - - /// Total leftovers (backup files + rollback folders). - public int Total => BackupFiles.Count + RollbackFolders.Count; - - /// True when there is nothing to remove. - public bool IsEmpty => Total == 0; -} - -/// The tally of an executed clean. -public sealed class CleanResult -{ - /// *.nfproj.bak files actually deleted. - public List RemovedBackups { get; } = new(); - - /// .nanomigrate folders actually removed. - public List RemovedFolders { get; } = new(); - - /// Non-fatal problems (a file that could not be deleted, etc.). - public List Problems { get; } = new(); - - /// Total leftovers removed. - public int Total => RemovedBackups.Count + RemovedFolders.Count; -} - -/// -/// Finds and removes migration leftovers under a path: every *.nfproj.bak -/// file and every .nanomigrate rollback folder/journal. Pure file logic — -/// the planning () and the removal () are -/// separate so a command can preview, confirm, then act. Idempotent and safe: a -/// tree with no leftovers yields an empty plan and a no-op clean. -/// -public static class BackupCleaner -{ - /// - /// Scans (recursively) for migration leftovers. A - /// non-existent root yields an empty plan. .nanomigrate folders are - /// reported as folders (their contents are not enumerated as individual files). - /// - public static CleanPlan Plan(string root) - { - var full = Path.GetFullPath(root); - var plan = new CleanPlan { Root = full }; - if (!Directory.Exists(full) && !File.Exists(full)) return plan; - - // A single file argument: only its sibling .bak makes sense; treat its dir as root. - var scanRoot = Directory.Exists(full) ? full : Path.GetDirectoryName(full)!; - - // Loose, next-to-project backups only — the opt-in `--backup` artifact. The - // rollback journal keeps its OWN copy of the original .nfproj inside - // .nanomigrate/rollback-/ under a "-.nfproj.bak" name; those are - // part of the self-contained journal (removed as a whole folder below) and must - // NOT be counted as loose backups, else clean double-reports them. - foreach (var bak in SafeEnumerateFiles(scanRoot, "*.nfproj.bak")) - if (!IsInsideRollbackFolder(bak)) - plan.BackupFiles.Add(Path.GetFullPath(bak)); - - foreach (var dir in SafeEnumerateDirectories(scanRoot, RollbackJournal.FolderName)) - plan.RollbackFolders.Add(Path.GetFullPath(dir)); - - plan.BackupFiles.Sort(StringComparer.OrdinalIgnoreCase); - plan.RollbackFolders.Sort(StringComparer.OrdinalIgnoreCase); - return plan; - } - - /// - /// Removes everything in . Returns a tally; per-item - /// failures are collected as problems rather than aborting the sweep. - /// - public static CleanResult Remove(CleanPlan plan) - { - var result = new CleanResult(); - - foreach (var bak in plan.BackupFiles) - { - try - { - if (File.Exists(bak)) File.Delete(bak); - result.RemovedBackups.Add(bak); - } - catch (Exception ex) { result.Problems.Add($"could not delete {bak}: {ex.Message}"); } - } - - foreach (var dir in plan.RollbackFolders) - { - try - { - if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); - result.RemovedFolders.Add(dir); - } - catch (Exception ex) { result.Problems.Add($"could not remove {dir}: {ex.Message}"); } - } - - return result; - } - - // True when a path lies anywhere beneath a ".nanomigrate" rollback folder. Such - // files are the journal's self-contained backups, not loose next-to-project ones. - private static bool IsInsideRollbackFolder(string path) - { - var dir = Path.GetDirectoryName(Path.GetFullPath(path)); - while (!string.IsNullOrEmpty(dir)) - { - if (string.Equals(Path.GetFileName(dir), RollbackJournal.FolderName, StringComparison.OrdinalIgnoreCase)) - return true; - dir = Path.GetDirectoryName(dir); - } - return false; - } - - private static IEnumerable SafeEnumerateFiles(string root, string pattern) - { - try { return Directory.EnumerateFiles(root, pattern, SearchOption.AllDirectories).ToList(); } - catch { return Array.Empty(); } - } - - private static IEnumerable SafeEnumerateDirectories(string root, string name) - { - try - { - return Directory.EnumerateDirectories(root, name, SearchOption.AllDirectories) - .Where(d => string.Equals(Path.GetFileName(d), name, StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - catch { return Array.Empty(); } - } -} diff --git a/tools/migrate/src/NanoMigrate.Core/Backup/MigrationJournaling.cs b/tools/migrate/src/NanoMigrate.Core/Backup/MigrationJournaling.cs deleted file mode 100644 index 4feab70..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Backup/MigrationJournaling.cs +++ /dev/null @@ -1,69 +0,0 @@ -using NanoFramework.Migrate.Core.Projects; - -namespace NanoFramework.Migrate.Core.Backup; - -/// -/// Bridges a (dry-run) preview into a -/// : backs up every file the conversion will modify or -/// delete and records the files it will create. This must run BEFORE the real -/// conversion touches disk, so the backups hold the true originals. -/// -/// The converter already computes, in dry-run, exactly what a real run acts on: -/// (the .nfproj, packages.config and any -/// hand-written AssemblyInfo.cs it removes), -/// (the .csproj it creates) and / -/// (the files it rewrites). We -/// journal all of those. -/// -/// The journal is SELF-CONTAINED: every original it must restore (the .nfproj, -/// packages.config, Properties/AssemblyInfo.cs, and any touched .sln/.slnx/ -/// Directory.Packages.props) is copied INTO .nanomigrate/rollback-<id>/, -/// and rollback restores from there only. It does NOT depend on the converter's loose -/// next-to-project *.nfproj.bak, so a --no-backup migration (which writes -/// no loose .bak at all) is still fully reversible. -/// -public static class MigrationJournaling -{ - /// - /// Records, into , the rollback entries implied by a - /// dry-run of converting one project. Optionally also - /// backs up the host-driven solutions in - /// (the solutions a solution-scoped run rewrites itself). - /// - public static void Record(RollbackJournal journal, ConvertResult preview, - IEnumerable? extraSolutionsToBackup = null) - { - // Files removed by the conversion (original .nfproj, packages.config, - // AssemblyInfo.cs): back up so they can be restored. - foreach (var deleted in preview.DeletedFiles) - journal.BackupBeforeChange(deleted); - - // Solutions the conversion rewrites: back up so the original references and - // GUIDs can be restored. - foreach (var sln in preview.UpdatedSolutions) - journal.BackupBeforeChange(sln); - - // A central Directory.Packages.props the conversion appends to: back up. - if (preview.UpdatedPackagesProps is not null) - journal.BackupBeforeChange(preview.UpdatedPackagesProps); - - // The .csproj the conversion creates (only when it differs from the source, - // i.e. it is a genuinely new file): record for deletion on rollback. - var output = Path.GetFullPath(preview.OutputPath); - var createsNewFile = !preview.DeletedFiles.Any(d => - string.Equals(Path.GetFullPath(d), output, StringComparison.OrdinalIgnoreCase)) - ? true - // When OutputPath equals a deleted source (same extension run) nothing new is created. - : false; - // The .csproj is "new" whenever it is not already the original being deleted. - if (createsNewFile && !File.Exists(output)) - journal.RecordCreated(output); - else if (createsNewFile) - // Output already exists on disk (e.g. a prior partial run): treat as modified. - journal.BackupBeforeChange(output); - - if (extraSolutionsToBackup is not null) - foreach (var sln in extraSolutionsToBackup) - journal.BackupBeforeChange(sln); - } -} diff --git a/tools/migrate/src/NanoMigrate.Core/Backup/RollbackJournal.cs b/tools/migrate/src/NanoMigrate.Core/Backup/RollbackJournal.cs deleted file mode 100644 index 65296ad..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Backup/RollbackJournal.cs +++ /dev/null @@ -1,305 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace NanoFramework.Migrate.Core.Backup; - -/// -/// One recorded action in a rollback journal: either a file the migration CREATED -/// (which a rollback deletes) or a file it MODIFIED/DELETED whose original content -/// was backed up (which a rollback restores). -/// -public sealed class RollbackEntry -{ - /// The kind of action that was recorded. - public RollbackAction Action { get; set; } - - /// - /// The live (original) path the action concerns: the file that was created (for - /// ) or the file that was modified/deleted and - /// must be restored (for ). - /// - public string OriginalPath { get; set; } = ""; - - /// - /// For : the path of the backup copy that - /// holds the original bytes. Null for . - /// - public string? BackupPath { get; set; } -} - -/// What a recorded entry does on rollback. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum RollbackAction -{ - /// The migration created this file; rolling back deletes it. - Created, - - /// The migration modified or deleted this file; rolling back restores it from the backup. - Restore, -} - -/// -/// The on-disk manifest of a single migration run: an id, a timestamp, and the -/// ordered list of recorded actions. Serialized as JSON inside the run's backup -/// folder so a later rollback can reverse the run without git. -/// -public sealed class RollbackManifest -{ - /// The run id (also the backup folder name suffix). - public string Id { get; set; } = ""; - - /// When the run was started (UTC, round-trip format). - public string CreatedUtc { get; set; } = ""; - - /// The recorded actions, in the order they were registered. - public List Entries { get; set; } = new(); -} - -/// -/// Records — and reverses — the file-level effects of a migration run so a real -/// run is reversible without git. Before anything is changed the command asks the -/// journal to back up every file that will be modified or deleted and to remember -/// every file that will be created; the backups + a JSON manifest live under -/// .nanomigrate/rollback-<id>/ beneath a chosen root. A later rollback -/// reads the manifest, restores the backups, and deletes the created files. -/// -/// Pure file logic — no console. Idempotent and safe: restoring an already-restored -/// tree, or rolling back when there is no journal, is a clean no-op. -/// -public sealed class RollbackJournal -{ - /// The folder that holds all migration rollback sets (.nanomigrate). - public const string FolderName = ".nanomigrate"; - - private static readonly JsonSerializerOptions JsonOpts = new() - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - private readonly RollbackManifest _manifest; - private int _backupSeq; - - /// The root the .nanomigrate folder is created under. - public string Root { get; } - - /// This run's id. - public string Id => _manifest.Id; - - /// The per-run set folder: <root>/.nanomigrate/rollback-<id>. - public string SetDirectory => Path.Combine(Root, FolderName, "rollback-" + _manifest.Id); - - /// The manifest file path inside . - public string ManifestPath => Path.Combine(SetDirectory, "manifest.json"); - - /// The recorded entries so far. - public IReadOnlyList Entries => _manifest.Entries; - - private RollbackJournal(string root, RollbackManifest manifest) - { - Root = root; - _manifest = manifest; - } - - /// - /// Starts a fresh journal rooted at . The backup folder is - /// created lazily on the first ; an empty run that - /// records nothing leaves no folder behind. - /// - public static RollbackJournal Start(string root) - { - var id = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss") + "-" + Guid.NewGuid().ToString("N")[..8]; - return new RollbackJournal(Path.GetFullPath(root), new RollbackManifest - { - Id = id, - CreatedUtc = DateTime.UtcNow.ToString("O"), - }); - } - - /// - /// Backs up the original bytes of a file that the migration is about to MODIFY or - /// DELETE, recording a restore entry. A missing source is ignored (nothing to - /// restore). Re-recording the same original path is a no-op so the FIRST backup — - /// the true original — always wins. - /// - public void BackupBeforeChange(string path) - { - var full = Path.GetFullPath(path); - if (!File.Exists(full)) return; - if (_manifest.Entries.Any(e => e.Action == RollbackAction.Restore - && PathEquals(e.OriginalPath, full))) - return; - - Directory.CreateDirectory(SetDirectory); - var backupName = $"{_backupSeq++:D4}-{Path.GetFileName(full)}.bak"; - var backupPath = Path.Combine(SetDirectory, backupName); - File.Copy(full, backupPath, overwrite: true); - - _manifest.Entries.Add(new RollbackEntry - { - Action = RollbackAction.Restore, - OriginalPath = full, - BackupPath = backupPath, - }); - } - - /// - /// Records that the migration CREATED (so a rollback - /// deletes it). Recording the same path twice is a no-op. - /// - public void RecordCreated(string path) - { - var full = Path.GetFullPath(path); - if (_manifest.Entries.Any(e => e.Action == RollbackAction.Created - && PathEquals(e.OriginalPath, full))) - return; - _manifest.Entries.Add(new RollbackEntry - { - Action = RollbackAction.Created, - OriginalPath = full, - }); - } - - /// True when nothing has been recorded yet. - public bool IsEmpty => _manifest.Entries.Count == 0; - - /// - /// Writes the manifest to disk so the run can later be rolled back. A journal that - /// recorded nothing writes nothing (no empty folder is left behind). - /// - public void Save() - { - if (IsEmpty) return; - Directory.CreateDirectory(SetDirectory); - File.WriteAllText(ManifestPath, JsonSerializer.Serialize(_manifest, JsonOpts), new UTF8Encoding(false)); - } - - /// - /// The newest saved manifest under 's .nanomigrate - /// folder, or null when none exists. "Newest" is by the manifest's recorded UTC - /// timestamp (folder names sort the same way, so this is stable). - /// - public static RollbackManifest? FindLatest(string root) - { - var sets = ManifestPaths(root); - RollbackManifest? newest = null; - foreach (var path in sets) - { - var m = TryLoadManifest(path); - if (m is null) continue; - if (newest is null - || string.Compare(m.CreatedUtc, newest.CreatedUtc, StringComparison.Ordinal) > 0) - newest = m; - } - return newest; - } - - /// Every saved manifest under , newest first. - public static IReadOnlyList ManifestPaths(string root) - { - var dir = Path.Combine(Path.GetFullPath(root), FolderName); - if (!Directory.Exists(dir)) return Array.Empty(); - return Directory.EnumerateFiles(dir, "manifest.json", SearchOption.AllDirectories) - .OrderByDescending(p => p, StringComparer.OrdinalIgnoreCase) - .ToList(); - } - - private static RollbackManifest? TryLoadManifest(string manifestPath) - { - try { return JsonSerializer.Deserialize(File.ReadAllText(manifestPath), JsonOpts); } - catch { return null; } - } - - /// - /// Reverses the run described by : created files are - /// deleted and modified/deleted files are restored from their backups. Restores - /// run before deletes so a restore can never be clobbered. Idempotent: a created - /// file already gone, or a restore target already matching its backup, is a no-op. - /// Returns a tally of what was reverted (and any per-entry problems). - /// - public static RollbackResult Apply(RollbackManifest manifest) - { - var result = new RollbackResult(); - - // Restore originals first (modified/deleted files), then remove created files. - foreach (var e in manifest.Entries.Where(e => e.Action == RollbackAction.Restore)) - { - try - { - if (e.BackupPath is null || !File.Exists(e.BackupPath)) - { - result.Problems.Add($"missing backup for {e.OriginalPath}"); - continue; - } - var dir = Path.GetDirectoryName(e.OriginalPath); - if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - File.Copy(e.BackupPath, e.OriginalPath, overwrite: true); - result.Restored.Add(e.OriginalPath); - } - catch (Exception ex) - { - result.Problems.Add($"restore failed for {e.OriginalPath}: {ex.Message}"); - } - } - - foreach (var e in manifest.Entries.Where(e => e.Action == RollbackAction.Created)) - { - try - { - if (File.Exists(e.OriginalPath)) - { - File.Delete(e.OriginalPath); - result.Deleted.Add(e.OriginalPath); - } - } - catch (Exception ex) - { - result.Problems.Add($"delete failed for {e.OriginalPath}: {ex.Message}"); - } - } - - return result; - } - - /// - /// Loads the manifest at , reverses it, and on - /// success removes its backup set folder. Returns the result, or null when the - /// manifest could not be read. - /// - public static RollbackResult? ApplyAndCleanup(string manifestPath) - { - var manifest = TryLoadManifest(manifestPath); - if (manifest is null) return null; - var result = Apply(manifest); - - // Remove the run's backup set once reverted (best-effort). - try - { - var setDir = Path.GetDirectoryName(manifestPath); - if (setDir is not null && Directory.Exists(setDir)) Directory.Delete(setDir, recursive: true); - } - catch { /* leave the set if it cannot be removed; clean can mop up later */ } - - return result; - } - - private static bool PathEquals(string a, string b) => - string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase); -} - -/// The tally of a rollback: files restored, files deleted, and any problems. -public sealed class RollbackResult -{ - /// Original files restored from backups. - public List Restored { get; } = new(); - - /// Created files removed. - public List Deleted { get; } = new(); - - /// Non-fatal problems encountered per entry (a missing backup, etc.). - public List Problems { get; } = new(); - - /// Total files touched (restored + deleted). - public int Total => Restored.Count + Deleted.Count; -} diff --git a/tools/migrate/src/NanoMigrate.Core/Common/Glob.cs b/tools/migrate/src/NanoMigrate.Core/Common/Glob.cs deleted file mode 100644 index 109efe9..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Common/Glob.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Text; -using System.Text.RegularExpressions; - -namespace NanoFramework.Migrate.Core.Common; - -/// -/// Small hand-rolled glob matcher (no external dependency). Matches a path that -/// is relative to the input directory against a pattern supporting: -/// * any run of characters except a path separator -/// ** any run of characters including path separators (spanning directories) -/// ? exactly one character except a path separator -/// Matching is case-insensitive and separator-insensitive ('/' and '\' are -/// treated as equivalent), and is anchored to the whole relative path. -/// -public static class Glob -{ - public static bool IsMatch(string relativePath, string pattern) - { - var input = Normalize(relativePath); - var regex = ToRegex(Normalize(pattern)); - return regex.IsMatch(input); - } - - private static string Normalize(string s) => s.Replace('\\', '/').Trim('/'); - - // Translates a normalized ('/'-separated) glob into an anchored, case-insensitive - // regex. Literal characters are escaped; only *, ** and ? carry meaning. - private static Regex ToRegex(string pattern) - { - var sb = new StringBuilder("^"); - for (int i = 0; i < pattern.Length; i++) - { - var c = pattern[i]; - switch (c) - { - // "/**" spans directories AND the leading separator is optional, so - // "Beginner/**" also matches "Beginner" itself. Emit "(/.*)?" and - // consume the slash, both stars, and any trailing slash. - case '/' when i + 2 < pattern.Length && pattern[i + 1] == '*' && pattern[i + 2] == '*': - i += 2; // consume '/' is current; skip both '*' - if (i + 1 < pattern.Length && pattern[i + 1] == '/') i++; - sb.Append("(/.*)?"); - break; - case '*': - if (i + 1 < pattern.Length && pattern[i + 1] == '*') - { - i++; // consume the second '*' - // "**/" (or trailing "**") spans directories; collapse any - // following slash so "Beginner/**" also matches "Beginner". - if (i + 1 < pattern.Length && pattern[i + 1] == '/') i++; - sb.Append(".*"); - } - else - { - sb.Append("[^/]*"); // single star: stay within a segment - } - break; - case '?': - sb.Append("[^/]"); - break; - default: - sb.Append(Regex.Escape(c.ToString())); - break; - } - } - sb.Append('$'); - return new Regex(sb.ToString(), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - } -} diff --git a/tools/migrate/src/NanoMigrate.Core/Common/ProjectScanner.cs b/tools/migrate/src/NanoMigrate.Core/Common/ProjectScanner.cs deleted file mode 100644 index 82c8ea7..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Common/ProjectScanner.cs +++ /dev/null @@ -1,45 +0,0 @@ -namespace NanoFramework.Migrate.Core.Common; - -/// -/// Enumerates .nfproj files under a path, with an optional glob filter. -/// Pure file-system discovery — no console, no side effects. -/// -public static class ProjectScanner -{ - /// - /// Resolves the set of .nfproj targets for a path. If - /// is a single .nfproj file it is returned alone; if it is a directory, - /// every .nfproj beneath it (optionally filtered by , - /// matched against the path relative to the directory) is returned, sorted. - /// A non-existent path yields an empty list. - /// - public static List ResolveProjects(string path, string? glob) - { - if (File.Exists(path) && path.EndsWith(".nfproj", StringComparison.OrdinalIgnoreCase)) - return new List { Path.GetFullPath(path) }; - if (!Directory.Exists(path)) return new List(); - - var baseDir = Path.GetFullPath(path); - return Directory.EnumerateFiles(path, "*.nfproj", SearchOption.AllDirectories) - .Select(Path.GetFullPath) - .Where(p => glob is null || Glob.IsMatch(Path.GetRelativePath(baseDir, p), glob)) - .OrderBy(p => p) - .ToList(); - } - - /// - /// Enumerates the .nfproj under a repo directory that survive the glob - /// filter (matched against the path relative to the repo directory). Returns - /// absolute, full paths. - /// - public static IEnumerable NfprojUnder(string repoDir, string? glob) - { - var baseDir = Path.GetFullPath(repoDir); - foreach (var nf in Directory.EnumerateFiles(repoDir, "*.nfproj", SearchOption.AllDirectories)) - { - var full = Path.GetFullPath(nf); - if (glob is null || Glob.IsMatch(Path.GetRelativePath(baseDir, full), glob)) - yield return full; - } - } -} diff --git a/tools/migrate/src/NanoMigrate.Core/Fleet/FleetService.cs b/tools/migrate/src/NanoMigrate.Core/Fleet/FleetService.cs deleted file mode 100644 index 3403757..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Fleet/FleetService.cs +++ /dev/null @@ -1,243 +0,0 @@ -using System.Text; -using NanoFramework.Migrate.Core.Common; -using NanoFramework.Migrate.Core.Projects; - -namespace NanoFramework.Migrate.Core.Fleet; - -/// Result of a single git invocation. Mirrors a process exit. -public readonly record struct GitResult(int Code, string Stdout, string Stderr); - -/// -/// Runs git in a working directory. The engine stays process-free by delegating -/// the actual side effect to an implementation supplied by the host (the CLI). -/// -public interface IGitRunner -{ - GitResult Run(string args, string workingDirectory); -} - -/// The knobs that drive a fleet run, layered over the per-project options. -public sealed record FleetOptions -{ - /// Per-project conversion options applied to every project in the fleet. - public ConversionOptions Conversion { get; init; } = new(); - - /// Markdown report path. - public string Report { get; init; } = "migration-report.md"; - - /// Create/reset this git branch in each repo. Must not start with 'develop'. - public string? Branch { get; init; } - - /// Commit the changes (requires ). - public bool Commit { get; init; } - - /// Commit summary line (kept <= 50 chars). - public string? CommitMessage { get; init; } - - /// Referenced as "Fix #<n>" in the commit. - public string? Issue { get; init; } - - /// nanoFramework recommends a Signed-off-by line; on by default. - public bool SignOff { get; init; } = true; -} - -/// -/// Pure fleet orchestration: validate options, discover repos, convert every -/// project in each, optionally branch and commit (via an injected -/// ), and produce a markdown report. Never writes to the -/// console; progress is surfaced through an optional callback so a host can drive -/// a spinner. -/// -public sealed class FleetService -{ - private readonly IProjectConverter _converter; - private readonly IGitRunner _git; - - public FleetService(IProjectConverter converter, IGitRunner git) - { - _converter = converter; - _git = git; - } - - /// - /// Validates the fleet options against the repos directory, throwing - /// with a user-facing message on any problem. - /// Mirrors the original command's up-front checks. - /// - public List ResolveRepos(string reposDir, FleetOptions o) - { - if (!Directory.Exists(reposDir)) throw new ArgumentException($"directory not found: {reposDir}"); - if (o.Commit && o.Branch is null) throw new ArgumentException("--commit requires --branch"); - // nanoFramework workflow: branch names must not start with "develop" (they - // collide with upstream develop-* branches). - if (o.Branch is not null && o.Branch.StartsWith("develop", StringComparison.OrdinalIgnoreCase)) - throw new ArgumentException("branch name must not start with 'develop' (nanoFramework workflow); " - + "use something like 'sdk-migration' or 'issue-123'"); - - // A repo qualifies if it contains at least one .nfproj that survives the - // glob filter (default: any .nfproj). - bool RepoMatches(string d) => ProjectScanner.NfprojUnder(d, o.Conversion.Glob).Any(); - - var repoDirs = Directory.EnumerateDirectories(reposDir) - .Where(RepoMatches) - .OrderBy(d => d).ToList(); - if (repoDirs.Count == 0) - throw new ArgumentException(o.Conversion.Glob is null - ? $"no repos containing .nfproj found under '{reposDir}'" - : $"no repos with .nfproj matching glob '{o.Conversion.Glob}' found under '{reposDir}'"); - return repoDirs; - } - - /// - /// Processes every repo: optionally branch, convert each project, optionally - /// commit. (if supplied) is invoked with each repo - /// name before it is processed. Returns one per repo. - /// - public List Process(List repoDirs, FleetOptions o, Action? progress = null) - { - // In a git repo the commit history already preserves the pre-migration file, - // so a .bak alongside it is just noise in the diff. Skip backups when committing. - var conv = o.Commit ? o.Conversion with { NoBackup = true } : o.Conversion; - - var report = new List(); - foreach (var repo in repoDirs) - { - progress?.Invoke(Path.GetFileName(repo)); - var rr = new RepoReport { Name = Path.GetFileName(repo) }; - try - { - if (o.Branch is not null && !conv.DryRun) - { - var checkout = _git.Run($"checkout -B {o.Branch}", repo); - if (checkout.Code != 0) { rr.Error = $"git checkout failed: {checkout.Stderr.Trim()}"; report.Add(rr); continue; } - } - - foreach (var nf in ProjectScanner.NfprojUnder(repo, conv.Glob).OrderBy(p => p)) - { - rr.Projects++; - var result = _converter.Convert(nf, conv); - var rel = Path.GetRelativePath(repo, nf); - foreach (var item in result.Review) rr.Review.Add($"{rel}: {item}"); - } - - if (o.Commit && !conv.DryRun) - { - _git.Run("add -A", repo); - var msgFile = WriteCommitMessage(o); - var signOff = o.SignOff ? "-s " : ""; - var commit = _git.Run($"commit {signOff}-F \"{msgFile}\"", repo); - File.Delete(msgFile); - rr.Committed = commit.Code == 0; - if (commit.Code != 0 && !commit.Stderr.Contains("nothing to commit")) - { - rr.Error = commit.Stderr.Contains("Please tell me who you are") || commit.Stderr.Contains("user.name") - ? "git commit failed: set git user.name/user.email (real name) so the " - + "Signed-off-by line is valid, or pass --no-sign-off" - : $"git commit: {commit.Stderr.Trim()}"; - } - } - } - catch (Exception ex) - { - rr.Error = ex.Message; - } - report.Add(rr); - } - return report; - } - - /// Builds the markdown fleet report and writes it to o.Report. - public void WriteReport(List report, FleetOptions o, string reposDir) - { - File.WriteAllText(o.Report, BuildReport(report, o, reposDir)); - } - - /// Builds the markdown fleet report as a string (pure; no I/O). - public static string BuildReport(List report, FleetOptions o, string reposDir) - { - var sb = new StringBuilder(); - sb.AppendLine("# nanoFramework SDK-style migration — fleet report\n"); - sb.AppendLine($"- Source: `{Path.GetFullPath(reposDir)}`"); - sb.AppendLine($"- Mode: {(o.Conversion.DryRun ? "dry-run (no files written)" : "applied")}" - + (o.Branch is not null ? $", branch `{o.Branch}`" : "") - + (o.Commit ? ", committed" : "")); - sb.AppendLine($"- SDK `nanoFramework.NET.Sdk` (versionless), TFM `{o.Conversion.Tfm}`, output extension `{o.Conversion.Ext}`\n"); - - int total = report.Count, clean = report.Count(r => r.Error is null && r.Review.Count == 0); - int needsReview = report.Count(r => r.Error is null && r.Review.Count > 0); - int errors = report.Count(r => r.Error is not null); - sb.AppendLine("## Summary\n"); - sb.AppendLine($"| Repos | Clean | Needs review | Errored |"); - sb.AppendLine($"|------:|------:|-------------:|--------:|"); - sb.AppendLine($"| {total} | {clean} | {needsReview} | {errors} |\n"); - - if (errors > 0) - { - sb.AppendLine("## Errored repos\n"); - foreach (var r in report.Where(r => r.Error is not null)) - sb.AppendLine($"- **{r.Name}** — {r.Error}"); - sb.AppendLine(); - } - - if (needsReview > 0) - { - sb.AppendLine("## Repos needing manual review\n"); - sb.AppendLine("These migrated, but the tool could not confidently resolve everything. " - + "Each line is something a human should confirm before merging.\n"); - foreach (var r in report.Where(r => r.Error is null && r.Review.Count > 0)) - { - sb.AppendLine($"### {r.Name}\n"); - foreach (var item in r.Review) sb.AppendLine($"- {item}"); - sb.AppendLine(); - } - } - - if (clean > 0) - { - sb.AppendLine("## Clean migrations\n"); - sb.AppendLine("Converted with no items flagged for review:\n"); - foreach (var r in report.Where(r => r.Error is null && r.Review.Count == 0)) - sb.AppendLine($"- {r.Name} ({r.Projects} project(s))" - + (r.Committed ? " — committed" : "")); - sb.AppendLine(); - } - - return sb.ToString(); - } - - // Builds a commit message that follows the nanoFramework guidance: a short - // summary (<= 50 chars), a blank line, a body wrapped at 72 columns, and an - // optional "Fix #" trailer. Returns the path to a temp message file. - private static string WriteCommitMessage(FleetOptions o) - { - var summary = o.CommitMessage ?? "Migrate project system to SDK-style"; - if (summary.Length > 50) summary = summary[..50].TrimEnd(); - - var body = Wrap( - "Convert the legacy .nfproj project system to an SDK-style MSBuild project: " - + "drop project-system boilerplate, fold packages.config into PackageReference, " - + "and fold .nuspec metadata into MSBuild Pack properties. " - + "No functional code changes.", 72); - - var sb = new StringBuilder(); - sb.Append(summary).Append("\n\n").Append(body).Append('\n'); - if (o.Issue is not null) sb.Append("\nFix #").Append(o.Issue).Append('\n'); - - var path = Path.GetTempFileName(); - File.WriteAllText(path, sb.ToString()); - return path; - } - - private static string Wrap(string text, int width) - { - var sb = new StringBuilder(); - int lineLen = 0; - foreach (var word in text.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - { - if (lineLen > 0 && lineLen + 1 + word.Length > width) { sb.Append('\n'); lineLen = 0; } - else if (lineLen > 0) { sb.Append(' '); lineLen++; } - sb.Append(word); lineLen += word.Length; - } - return sb.ToString(); - } -} diff --git a/tools/migrate/src/NanoMigrate.Core/Fleet/RepoReport.cs b/tools/migrate/src/NanoMigrate.Core/Fleet/RepoReport.cs deleted file mode 100644 index 228f489..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Fleet/RepoReport.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace NanoFramework.Migrate.Core.Fleet; - -/// Per-repo outcome accumulated by the fleet command. Pure data. -public sealed class RepoReport -{ - public required string Name { get; init; } - public int Projects { get; set; } - public List Review { get; } = new(); - public bool Committed { get; set; } - public string? Error { get; set; } -} diff --git a/tools/migrate/src/NanoMigrate.Core/NanoMigrate.Core.csproj b/tools/migrate/src/NanoMigrate.Core/NanoMigrate.Core.csproj deleted file mode 100644 index 872095d..0000000 --- a/tools/migrate/src/NanoMigrate.Core/NanoMigrate.Core.csproj +++ /dev/null @@ -1,35 +0,0 @@ - - - - net8.0 - enable - enable - NanoFramework.Migrate.Core - NanoMigrate.Core - true - - - - - nanoFramework.Migrate.Core - nanoFramework Migrate (Core) - Conversion engine that migrates legacy nanoFramework .nfproj projects to the SDK-style MSBuild project system: folds packages.config into PackageReference and .nuspec metadata into Pack properties. Console-free library; see the nano-migrate CLI for an interactive front end. - nanoFramework - nanoFramework - https://github.com/nanoframework/nanoframework-sdk - https://github.com/nanoframework/nanoframework-sdk - git - MIT - nanoFramework;MSBuild;SDK;migration;nfproj;IoT;embedded;C# - Copyright (c) .NET Foundation and Contributors - false - - - - - - - - diff --git a/tools/migrate/src/NanoMigrate.Core/Projects/ConversionOptions.cs b/tools/migrate/src/NanoMigrate.Core/Projects/ConversionOptions.cs deleted file mode 100644 index 67b2221..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Projects/ConversionOptions.cs +++ /dev/null @@ -1,75 +0,0 @@ -namespace NanoFramework.Migrate.Core.Projects; - -/// -/// The knobs that drive a conversion. This is the engine-facing options type — it -/// is intentionally NOT the CLI settings type, so the engine carries no console or -/// command-line dependency. The CLI maps its parsed settings onto this record. -/// -public sealed record ConversionOptions -{ - /// - /// Output extension for the emitted project. Either .csproj or - /// .nfproj. Default .csproj: a normal run produces Foo.csproj and - /// retires Foo.nfproj. - /// - public string Ext { get; init; } = ".csproj"; - - /// Target framework moniker written into the emitted project. - public string Tfm { get; init; } = "netnano1.0"; - - /// - /// Accepted for back-compat but no longer emitted: the SDK reference is - /// versionless (the version is pinned via global.json msbuild-sdks). - /// - public string Sdk { get; init; } = "2.0.0"; - - /// Analyse and preview only; write nothing to disk. - public bool DryRun { get; init; } - - /// Don't write a .nfproj.bak alongside the converted project. - public bool NoBackup { get; init; } - - /// - /// Glob filter (relative to the input directory) selecting which .nfproj - /// to convert. Null means "all .nfproj recursively" (the default). - /// Supports *, ** and ?. - /// - public string? Glob { get; init; } - - /// - /// Suppress the converter's built-in solution retargeting (its walk-up - /// discovery + rewrite). Set when the host drives a solution-scoped - /// migration and rewrites the chosen solutions itself via - /// , so the converter never touches a solution the - /// user did not select. The preview - /// list is still populated. Default false preserves the historical behavior. - /// - public bool SkipSolutionRewrite { get; init; } - - /// - /// After a real (non-dry-run) migration, verify the result by building the - /// affected solution(s)/project(s) with the dotnet CLI. null (the - /// default) means "verify on for real runs, off for dry-run"; set - /// true/false to force it on/off. A failed verification is what - /// triggers the rollback prompt. - /// - public bool? Verify { get; init; } - - /// - /// Resolves against : dry-run never - /// verifies; otherwise the default is on. - /// - public bool VerifyEffective => !DryRun && (Verify ?? true); - - /// - /// Central Package Management (CPM) override. null (the default) means - /// auto-detect: the converter walks up from the project directory for a - /// Directory.Packages.props with - /// <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>. - /// Set true/false to force CPM on/off regardless of what is on disk. - /// When CPM is active the emitted PackageReference elements are versionless - /// and each referenced id is ensured to have a PackageVersion entry in the - /// nearest Directory.Packages.props. - /// - public bool? CentralPackageManagement { get; init; } -} diff --git a/tools/migrate/src/NanoMigrate.Core/Projects/ConvertResult.cs b/tools/migrate/src/NanoMigrate.Core/Projects/ConvertResult.cs deleted file mode 100644 index df75432..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Projects/ConvertResult.cs +++ /dev/null @@ -1,65 +0,0 @@ -namespace NanoFramework.Migrate.Core.Projects; - -/// How a single project ended up after a (dry-run or real) conversion. -public enum ConvertStatus -{ - /// Converted cleanly. - Converted, - - /// Already SDK-style; nothing to do. - Skipped, - - /// Converted, but flagged items need a human. - Review, - - /// Threw while converting. - Error, -} - -/// -/// Outcome of converting a single project, expressed as DATA. The engine never -/// writes to the console — the CLI consumes this to render its presentation. -/// -public sealed class ConvertResult -{ - /// The path the conversion emits (or, in dry-run, would emit). - public required string OutputPath { get; init; } - - /// Items the tool could not confidently resolve; each needs a human. - public List Review { get; } = new(); - - /// True when the project was already SDK-style and was left untouched. - public bool AlreadySdk { get; set; } - - /// Resolved PackageReferences (id -> version) the emitted project will carry. - public List> Packages { get; } = new(); - - /// Files this conversion deletes (or, in dry-run, would delete). - public List DeletedFiles { get; } = new(); - - /// .sln files this conversion retargets (or, in dry-run, would retarget). - public List UpdatedSolutions { get; } = new(); - - /// - /// Under Central Package Management, the Directory.Packages.props this - /// conversion adds PackageVersion entries to (or, in dry-run, would add). - /// Null when CPM is inactive or no entries are missing. - /// - public string? UpdatedPackagesProps { get; set; } - - /// - /// The PackageVersion entries (id -> version) added to the central - /// Directory.Packages.props under CPM (or, in dry-run, that would be added). - /// - public List> AddedPackageVersions { get; } = new(); - - /// Set when the conversion threw; used to render a red Error row. - public string? Error { get; set; } - - /// Derived status, computed from the data above. - public ConvertStatus Status => - Error is not null ? ConvertStatus.Error - : AlreadySdk ? ConvertStatus.Skipped - : Review.Count > 0 ? ConvertStatus.Review - : ConvertStatus.Converted; -} diff --git a/tools/migrate/src/NanoMigrate.Core/Projects/IProjectConverter.cs b/tools/migrate/src/NanoMigrate.Core/Projects/IProjectConverter.cs deleted file mode 100644 index 1752d35..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Projects/IProjectConverter.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace NanoFramework.Migrate.Core.Projects; - -/// -/// Converts a single legacy .nfproj into an SDK-style project. The result -/// is returned as data; implementations never write to the console. -/// -public interface IProjectConverter -{ - /// - /// Converts the project at per . - /// In dry-run mode nothing is written; the returned - /// still describes exactly what a real run would change. - /// - ConvertResult Convert(string nfprojPath, ConversionOptions options); -} diff --git a/tools/migrate/src/NanoMigrate.Core/Projects/ProjectConverter.cs b/tools/migrate/src/NanoMigrate.Core/Projects/ProjectConverter.cs deleted file mode 100644 index 91795f8..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Projects/ProjectConverter.cs +++ /dev/null @@ -1,804 +0,0 @@ -using System.Text; -using System.Text.RegularExpressions; -using System.Xml.Linq; -using NanoFramework.Migrate.Core.Solutions; - -namespace NanoFramework.Migrate.Core.Projects; - -/// -/// Converts one legacy .nfproj into an SDK-style project. Faithful to the -/// reference rules: drop project-system boilerplate and SDK-supplied defaults, -/// fold packages.config into PackageReference, fold .nuspec metadata into Pack -/// properties, drop default Compile globs and a hand-written AssemblyInfo.cs, -/// and "fail loud" — anything it cannot confidently convert is surfaced for a -/// human rather than guessed. -/// -public sealed class ProjectConverter : IProjectConverter -{ - private static readonly XNamespace Ns = "http://schemas.microsoft.com/developer/msbuild/2003"; - - // Project-system boilerplate and properties the SDK now supplies itself. - private static readonly HashSet DropProps = new(StringComparer.Ordinal) - { - "ProjectTypeGuids", "ProjectGuid", "FileAlignment", "AppDesignerFolder", - "NanoFrameworkProjectSystemPath", "TargetFrameworkVersion", "OldToolsVersion", - "Configuration", "Platform", - }; - - // The converter emits the TFM itself (see Emit), so a source TargetFramework is not - // carried through. Every other non-boilerplate property is passed through verbatim - // (OutputType, LangVersion, Nullable, SignAssembly, IsTestProject, custom props, …) — - // silently dropping unknown properties was a bug (e.g. OutputType=Exe was lost, turning - // app projects into libraries → CS8805). - private static readonly HashSet EmittedProps = new(StringComparer.Ordinal) - { - "TargetFramework", - }; - - // Legacy names whose NuGet package id differs from X. - private static readonly Dictionary LegacyPkgAliases = new(StringComparer.Ordinal) - { - ["mscorlib"] = "nanoFramework.CoreLibrary", - ["System"] = "nanoFramework.CoreLibrary", - }; - - // Matches a NuGet "packages\.\" folder segment inside a HintPath. - // is greedy-then-split: the version is the tail starting at the first - // dotted segment that begins with a digit, so prerelease/build suffixes - // (e.g. "2.0.0-preview.52") stay attached to the version, not the id. - private static readonly Regex HintPathPackage = new( - @"[\\/]packages[\\/](?[^\\/]+)[\\/]", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - - // The SDK reference is versionless; the concrete version is pinned via a - // global.json `msbuild-sdks` entry, not the Sdk attribute. - private const string SdkReference = "nanoFramework.NET.Sdk"; - - public ConvertResult Convert(string nfprojPath, ConversionOptions options) - { - var nfproj = nfprojPath; - var o = options; - var projDir = Path.GetDirectoryName(Path.GetFullPath(nfproj))!; - var root = XElement.Load(nfproj); - - // Idempotency guard: an SDK-style project already has an Sdk attribute on - // the root. Treat it as already-converted and skip without touching disk, - // so a second run over a repo is a true no-op rather than destructive. - if (root.Attribute("Sdk") is not null) - { - var skipped = new ConvertResult { OutputPath = Path.GetFullPath(nfproj), AlreadySdk = true }; - skipped.Review.Add("already SDK-style; skipped"); - return skipped; - } - - var pkgs = LoadPackagesConfig(projDir); - // packages.config — when present and non-empty — IS the authoritative - // dependency list. In that mode legacy assembly names are NOT - // mapped to packages (their names routinely differ from the NuGet package - // id, e.g. assembly "System.Net.Http" ships in package - // "nanoFramework.System.Net.Http.Server"); the packages.config ids+versions - // are taken verbatim instead, eliminating the name/id mismatch. - var packagesConfigAuthoritative = pkgs.Count > 0; - - // Detect Central Package Management: an explicit option override wins; - // otherwise walk up for a Directory.Packages.props with - // ManagePackageVersionsCentrally=true. - var cpmPropsPath = FindDirectoryPackagesProps(projDir); - var cpmActive = o.CentralPackageManagement - ?? (cpmPropsPath is not null && IsCpmEnabled(cpmPropsPath)); - - var props = new List>(); // discovery order, deduped - var pkgRefs = new Dictionary(StringComparer.OrdinalIgnoreCase); - var projRefs = new List(); - var keepItems = new List(); - var review = new List(); - // Normalized relative paths of .cs files the legacy project explicitly - // compiled and that the SDK would also glob by default. Used to detect a - // legacy project that compiled only a SUBSET of the .cs files on disk: the - // unlisted ones must be excluded via , else the SDK's - // **/*.cs glob compiles files the project never built (duplicate types). - var explicitCompile = new HashSet(StringComparer.OrdinalIgnoreCase); - // Top-level projects to carry through verbatim — notably a Shared - // Project (.projitems) import, whose source files the SDK would otherwise - // never see (dropping it leaves the consuming project missing those types). - var imports = new List(); - - // Seed package references straight from packages.config when it is the - // authoritative source. Drop the legacy elements entirely in - // this mode (handled in the item loop below). - if (packagesConfigAuthoritative) - foreach (var kv in pkgs) - pkgRefs[kv.Key] = kv.Value; - - void SetProp(string k, string? v) - { - if (string.IsNullOrEmpty(v)) return; - if (props.Any(p => p.Key == k)) return; - props.Add(new(k, v)); - } - - // Read by local name so the converter works whether the input uses the - // legacy MSBuild namespace or none at all. - foreach (var pg in root.Elements().Where(e => e.Name.LocalName == "PropertyGroup")) - foreach (var el in pg.Elements()) - { - var tag = el.Name.LocalName; - if (DropProps.Contains(tag)) continue; // project-system boilerplate the SDK supplies - if (EmittedProps.Contains(tag)) continue; // the converter emits these itself (e.g. TargetFramework) - SetProp(tag, el.Value); // pass through everything else (OutputType, LangVersion, …) - } - - // Top-level elements. The NFProjectSystem.* props/targets imports are - // SDK-supplied boilerplate and are dropped. A Shared Project (.projitems) import - // carries real source into the project and MUST be preserved, else its types go - // missing (CS0246). Anything else unrecognized is surfaced for review. - foreach (var imp in root.Elements().Where(e => e.Name.LocalName == "Import")) - { - var project = (string?)imp.Attribute("Project") ?? ""; - if (project.Contains("NFProjectSystem", StringComparison.OrdinalIgnoreCase)) continue; - if (project.EndsWith(".projitems", StringComparison.OrdinalIgnoreCase)) - { - imports.Add(project); - continue; - } - if (!string.IsNullOrWhiteSpace(project)) - review.Add($"Unhandled (carry it over manually if needed)"); - } - - foreach (var ig in root.Elements().Where(e => e.Name.LocalName == "ItemGroup")) - foreach (var el in ig.Elements()) - { - var tag = el.Name.LocalName; - var inc = (string?)el.Attribute("Include") ?? ""; - switch (tag) - { - case "Reference": - { - // packages.config is authoritative: it already carries the - // full dependency set, so legacy assembly names - // are dropped rather than (mis)mapped to a package id. - if (packagesConfigAuthoritative) break; - - var rawName = inc.Split(',')[0].Trim(); - // Prefer id+version parsed straight from the HintPath folder. - var fromHint = InferFromHintPath(el); - if (fromHint is not null) - { - pkgRefs[fromHint.Value.id] = fromHint.Value.ver; - break; - } - // Fallback: resolve the package id via the alias table, then - // look up its version in packages.config. - var name = LegacyPkgAliases.GetValueOrDefault(rawName, rawName); - var ver = pkgs.GetValueOrDefault(name) ?? pkgs.GetValueOrDefault(rawName); - if (ver is not null) pkgRefs[name] = ver; - else - review.Add($"Reference without resolvable version: {inc} " - + "(no HintPath or packages.config entry; map to a PackageReference manually)"); - break; - } - case "PackageReference": - { - // A versionless is valid under CPM; carry - // it through (its version, if any, comes from the central - // props). Otherwise resolve a version from the attribute or - // packages.config; only flag when truly unresolvable. - var ver = (string?)el.Attribute("Version") ?? pkgs.GetValueOrDefault(inc); - if (ver is not null) pkgRefs[inc] = ver; - else if (cpmActive) pkgRefs.TryAdd(inc, ""); // version lives in central props - else - review.Add($"PackageReference without resolvable version: {inc} " - + "(add a Version manually)"); - break; - } - case "ProjectReference": - projRefs.Add(inc); - break; - case "Compile": - // Explicit Compile of a hand-written AssemblyInfo / default-globbed - // .cs is dropped (the SDK globs **/*.cs). Keep only non-default - // paths or links the SDK would NOT glob. (Files on disk that the - // legacy project deliberately did NOT compile are excluded via a - // Compile Remove computed after the loop — see explicitCompile.) - if (el.Attribute("Link") is not null || !IsDefaultCompile(inc)) - keepItems.Add(el); - else - explicitCompile.Add(NormalizeRel(inc)); - break; - case "None": - // packages.config and the .nuspec are folded away, never carried. - if (inc == "packages.config" || inc.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)) - break; - KeepOrUpdateDefaultGlobItem(el, keepItems); - break; - case "EmbeddedResource": - case "Content": - KeepOrUpdateDefaultGlobItem(el, keepItems); - break; - case "ProjectCapability": - // nanoFramework test projects mark themselves with - // ; this is a - // meaningful item the SDK does not supply, so carry it through. - keepItems.Add(el); - break; - default: - review.Add($"Unhandled item <{tag} Include='{inc}'>"); - break; - } - } - - FoldNuspec(projDir, SetProp); - - // A hand-written AssemblyInfo.cs that carries nanoFramework-specific - // attributes the SDK's GenerateAssemblyInfo does NOT emit (notably - // AssemblyNativeVersion, required by the MetadataProcessor when generating - // interop/skeleton stubs) must be KEPT rather than deleted. In that case we - // turn GenerateAssemblyInfo off so the hand-written file is authoritative - // and the standard attributes it also declares don't collide with generated - // ones. Otherwise the file is deleted as before (it would duplicate the - // SDK-generated assembly attributes). - var keepAssemblyInfo = HasNanoAssemblyInfo(projDir); - if (keepAssemblyInfo) SetProp("GenerateAssemblyInfo", "false"); - - // Compile-subset exclusion: when the legacy project explicitly compiled a - // strict subset of the .cs files present on disk, the unlisted files must be - // removed from the SDK's default **/*.cs glob. Without this the SDK compiles - // source the project never built (e.g. an alternate/draft copy of a class), - // producing duplicate-definition errors. Only meaningful when the project - // had explicit Compile items in the first place. - var compileRemoves = new List(); - if (explicitCompile.Count > 0) - foreach (var rel in OnDiskDefaultCompile(projDir)) - if (!explicitCompile.Contains(rel)) - compileRemoves.Add(rel); - - var xml = Emit(props, pkgRefs, projRefs, keepItems, compileRemoves, imports, o, cpmActive); - - var outPath = Path.ChangeExtension(Path.GetFullPath(nfproj), o.Ext); - var nfFull = Path.GetFullPath(nfproj); - var replacingNfproj = !string.Equals(outPath, nfFull, StringComparison.OrdinalIgnoreCase); - - var result = new ConvertResult { OutputPath = outPath }; - result.Review.AddRange(review); - result.Packages.AddRange(pkgRefs.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)); - - // Under CPM, each referenced id must have a in the nearest - // Directory.Packages.props. Compute the ids whose version we know (from - // packages.config / HintPath) that are missing from the central props, and - // record them; the real run adds them idempotently below. Versionless ids - // (no resolvable version) cannot seed a PackageVersion and are left as-is. - var cpmAdditions = new List>(); - if (cpmActive && cpmPropsPath is not null) - { - var existing = LoadPackageVersionIds(cpmPropsPath); - foreach (var kv in pkgRefs.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) - if (!string.IsNullOrEmpty(kv.Value) && !existing.Contains(kv.Key)) - cpmAdditions.Add(kv); - foreach (var add in cpmAdditions) - result.AddedPackageVersions.Add(add); - if (cpmAdditions.Count > 0) result.UpdatedPackagesProps = cpmPropsPath; - } - - // Compute the set of files that will be (or, in dry-run, would be) removed - // and the solutions that will be retargeted. This drives the dry-run - // preview and is identical to what the real run acts on. - if (replacingNfproj) result.DeletedFiles.Add(nfFull); - var pc = Path.Combine(projDir, "packages.config"); - if (File.Exists(pc)) result.DeletedFiles.Add(Path.GetFullPath(pc)); - if (!keepAssemblyInfo) - foreach (var ai in ExistingAssemblyInfo(projDir)) result.DeletedFiles.Add(ai); - // The .sln/.slnx that still reference the .nfproj are what a rewrite would - // touch. We always populate this preview list (even when SkipSolutionRewrite - // is set, so the host can render it); the actual rewrite below is gated. - if (replacingNfproj) - foreach (var sln in SolutionsReferencing(projDir, nfFull)) result.UpdatedSolutions.Add(sln); - - if (!o.DryRun) - { - // The loose, next-to-project *.nfproj.bak is purely the opt-in backup - // convenience: it is written ONLY when backups are enabled (default) and - // suppressed entirely under --no-backup. The rollback journal does NOT rely - // on it — the journal keeps its own copy of the original .nfproj inside - // .nanomigrate/rollback-/ — so a --no-backup run leaves zero loose .bak - // yet is still fully reversible. Never clobber an existing backup: the - // original first-run .bak must survive reruns. - if (!o.NoBackup && !File.Exists(nfproj + ".bak")) File.Copy(nfproj, nfproj + ".bak", overwrite: false); - File.WriteAllText(outPath, xml, new UTF8Encoding(false)); - // Add any missing entries to the central props, - // idempotently (existing entries are neither duplicated nor reordered). - if (cpmActive && cpmPropsPath is not null && cpmAdditions.Count > 0) - AddPackageVersions(cpmPropsPath, cpmAdditions); - // If we emitted a .csproj alongside, retire the original .nfproj. - if (replacingNfproj) - { - File.Delete(nfproj); - // Point any .sln/.slnx entries at the new .csproj (idempotent), - // unless the host is driving solution rewriting itself. - if (!o.SkipSolutionRewrite) UpdateSolutions(projDir, nfFull); - } - if (File.Exists(pc)) File.Delete(pc); - // The SDK default **/*.cs glob plus generated assembly info would - // otherwise produce duplicate-attribute build errors, so delete a - // hand-written AssemblyInfo.cs from disk (dropping the Compile item - // is not enough). EXCEPTION: an AssemblyInfo.cs that carries - // nanoFramework-specific attributes the SDK never generates is kept - // (with GenerateAssemblyInfo=false set above) so those attributes - // survive — deleting it would break MetadataProcessor stub generation. - if (!keepAssemblyInfo) DeleteAssemblyInfo(projDir); - } - - return result; - } - - // Paths of any hand-written AssemblyInfo.cs that exist on disk (the files - // DeleteAssemblyInfo would remove). Used to preview deletions in dry-run. - private static IEnumerable ExistingAssemblyInfo(string projDir) - { - foreach (var rel in new[] { Path.Combine("Properties", "AssemblyInfo.cs"), "AssemblyInfo.cs" }) - { - var path = Path.Combine(projDir, rel); - if (File.Exists(path)) yield return Path.GetFullPath(path); - } - } - - // The .sln/.slnx files that currently still reference the .nfproj (i.e. those - // UpdateSolutions would rewrite). Used to preview solution edits in dry-run. - // Resolves entries through the SolutionFile parser so both classic and XML - // solutions are considered and only genuine references count. - private static IEnumerable SolutionsReferencing(string projDir, string nfproj) - { - var target = Path.GetFullPath(nfproj); - foreach (var sln in FindSolutionFiles(projDir)) - { - SolutionFile parsed; - try { parsed = SolutionFile.Load(sln); } - catch { continue; } - if (parsed.ProjectPaths.Any(p => string.Equals(Path.GetFullPath(p), target, StringComparison.OrdinalIgnoreCase))) - yield return sln; - } - } - - // Parses the "packages\.\" folder segment of a HintPath into a - // (id, version) pair. The version is the suffix that begins at the first - // dotted segment starting with a digit; everything before it is the id. - private static (string id, string ver)? InferFromHintPath(XElement reference) - { - var hint = reference.Elements().FirstOrDefault(e => e.Name.LocalName == "HintPath")?.Value; - if (string.IsNullOrEmpty(hint)) return null; - var m = HintPathPackage.Match(hint); - if (!m.Success) return null; - return SplitPackageFolder(m.Groups["folder"].Value); - } - - // "nanoFramework.System.Device.Gpio.1.1.57" -> ("nanoFramework.System.Device.Gpio", "1.1.57") - // "nanoFramework.CoreLibrary.2.0.0-preview.52" -> ("nanoFramework.CoreLibrary", "2.0.0-preview.52") - private static (string id, string ver)? SplitPackageFolder(string folder) - { - var parts = folder.Split('.'); - for (int i = 0; i < parts.Length; i++) - { - if (parts[i].Length > 0 && char.IsDigit(parts[i][0])) - { - if (i == 0) return null; // no id before the version - var id = string.Join('.', parts[..i]); - var ver = string.Join('.', parts[i..]); - return (id, ver); - } - } - return null; // no version segment found - } - - private static Dictionary LoadPackagesConfig(string projDir) - { - var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - var pc = Path.Combine(projDir, "packages.config"); - if (!File.Exists(pc)) return result; - foreach (var p in XElement.Load(pc).Elements().Where(e => e.Name.LocalName == "package")) - { - var id = (string?)p.Attribute("id"); - var ver = (string?)p.Attribute("version"); - if (id is not null && ver is not null) result[id] = ver; - } - return result; - } - - // Walks UP from the project directory looking for the nearest - // Directory.Packages.props (the file MSBuild itself would pick for CPM). Returns - // its full path, or null if none exists in the ancestor chain. Pure: read-only. - private static string? FindDirectoryPackagesProps(string projDir) - { - var dir = new DirectoryInfo(Path.GetFullPath(projDir)); - while (dir is not null) - { - var candidate = Path.Combine(dir.FullName, "Directory.Packages.props"); - if (File.Exists(candidate)) return candidate; - dir = dir.Parent; - } - return null; - } - - // True when a Directory.Packages.props opts into CPM via - // true. - // A malformed/unreadable file is treated as "not enabled" (never throws). - private static bool IsCpmEnabled(string propsPath) - { - try - { - var root = XElement.Load(propsPath); - var flag = root.Descendants() - .FirstOrDefault(e => e.Name.LocalName == "ManagePackageVersionsCentrally"); - return flag is not null - && bool.TryParse(flag.Value.Trim(), out var on) && on; - } - catch { return false; } - } - - // The set of package ids already declared as in a - // Directory.Packages.props. Used to avoid duplicating entries on add/re-run. - private static HashSet LoadPackageVersionIds(string propsPath) - { - var ids = new HashSet(StringComparer.OrdinalIgnoreCase); - try - { - foreach (var pv in XElement.Load(propsPath).Descendants() - .Where(e => e.Name.LocalName == "PackageVersion")) - { - var id = (string?)pv.Attribute("Include"); - if (!string.IsNullOrEmpty(id)) ids.Add(id); - } - } - catch { /* unreadable props → treat as having no entries */ } - return ids; - } - - // Idempotently inserts entries for - // the given (id, version) pairs into the nearest ItemGroup of a - // Directory.Packages.props. Existing entries are neither duplicated nor - // reordered; only genuinely-missing ids are appended. Preserves the file's - // namespace and overall shape. Caller has already filtered to missing ids. - private static void AddPackageVersions(string propsPath, IEnumerable> additions) - { - var toAdd = additions.ToList(); - if (toAdd.Count == 0) return; - - var doc = System.Xml.Linq.XDocument.Load(propsPath, System.Xml.Linq.LoadOptions.PreserveWhitespace); - var root = doc.Root!; - var ns = root.Name.Namespace; - var existing = root.Descendants().Where(e => e.Name.LocalName == "PackageVersion") - .Select(e => (string?)e.Attribute("Include")) - .Where(s => !string.IsNullOrEmpty(s)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - // Reuse the ItemGroup that already holds PackageVersion items; otherwise the - // first ItemGroup; otherwise create a fresh one. - var group = root.Elements().FirstOrDefault(e => e.Name.LocalName == "ItemGroup" - && e.Elements().Any(c => c.Name.LocalName == "PackageVersion")) - ?? root.Elements().FirstOrDefault(e => e.Name.LocalName == "ItemGroup"); - if (group is null) - { - group = new XElement(ns + "ItemGroup"); - root.Add(group); - } - - // Match the indentation of an existing PackageVersion so appended entries - // line up; fall back to a sensible default for a freshly-created group. - var indent = ElementIndent(group.Elements() - .FirstOrDefault(c => c.Name.LocalName == "PackageVersion")) ?? " "; - - // The last PackageVersion to append after (so new entries sit with the rest, - // before the ItemGroup's closing tag rather than after its trailing newline). - var anchor = group.Elements().LastOrDefault(c => c.Name.LocalName == "PackageVersion"); - - foreach (var kv in toAdd) - { - if (existing.Contains(kv.Key)) continue; // idempotent guard - var pv = new XElement(ns + "PackageVersion", - new XAttribute("Include", kv.Key), - new XAttribute("Version", kv.Value)); - if (anchor is not null) - { - anchor.AddAfterSelf(pv); - anchor.AddAfterSelf(new System.Xml.Linq.XText("\n" + indent)); - } - else - { - group.AddFirst(pv); - group.AddFirst(new System.Xml.Linq.XText("\n" + indent)); - } - anchor = pv; - existing.Add(kv.Key); - } - - doc.Save(propsPath); - } - - // The leading-whitespace indentation of an element, read from the immediately - // preceding whitespace text node. Null if there is no such node (or no element). - private static string? ElementIndent(XElement? el) - { - if (el?.PreviousNode is System.Xml.Linq.XText t) - { - var s = t.Value; - var nl = s.LastIndexOf('\n'); - return nl >= 0 ? s[(nl + 1)..] : s; - } - return null; - } - - // Removes a hand-written AssemblyInfo.cs from disk. With the SDK's default - // **/*.cs glob and GenerateAssemblyInfo, leaving it would cause duplicate - // assembly-attribute build errors. Idempotent: a missing file is a no-op. - private static void DeleteAssemblyInfo(string projDir) - { - foreach (var rel in new[] { Path.Combine("Properties", "AssemblyInfo.cs"), "AssemblyInfo.cs" }) - { - var path = Path.Combine(projDir, rel); - if (File.Exists(path)) File.Delete(path); - } - } - - // Rewrites .sln/.slnx entries that reference the converted .nfproj so they point - // at the new .csproj (classic .sln also swaps the project-type GUID). Searches - // walking up from the project directory to the repo root (the dir containing - // .git), plus any solution in the project's own directory tree. The actual - // retarget is delegated to SolutionRewriter, which is line/path-scoped and - // idempotent, so re-running is a no-op. - private static void UpdateSolutions(string projDir, string nfproj) - { - var converted = new[] { Path.GetFullPath(nfproj) }; - foreach (var sln in FindSolutionFiles(projDir)) - { - SolutionFile parsed; - try { parsed = SolutionFile.Load(sln); } - catch { continue; } - SolutionRewriter.RewriteFile(parsed, converted); - } - } - - private static List FindSolutionFiles(string projDir) - { - var found = new HashSet(StringComparer.OrdinalIgnoreCase); - - void AddSolutionsIn(string d, SearchOption option) - { - foreach (var pattern in new[] { "*.sln", "*.slnx" }) - foreach (var sln in Directory.EnumerateFiles(d, pattern, option)) - found.Add(Path.GetFullPath(sln)); - } - - // Walk up to the repo root (the directory containing .git), collecting - // solution files at each level. - var dir = new DirectoryInfo(Path.GetFullPath(projDir)); - while (dir is not null) - { - AddSolutionsIn(dir.FullName, SearchOption.TopDirectoryOnly); - if (Directory.Exists(Path.Combine(dir.FullName, ".git")) - || File.Exists(Path.Combine(dir.FullName, ".git"))) - break; // reached the repo root - dir = dir.Parent; - } - - // Also any solution anywhere in the project's own directory tree. - AddSolutionsIn(projDir, SearchOption.AllDirectories); - - return found.ToList(); - } - - private static bool IsDefaultCompile(string inc) - { - if (!inc.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) return false; - var baseName = inc.TrimStart('.', '\\', '/'); - // A hand-written AssemblyInfo.cs collides with GenerateAssemblyInfo → drop it. - if (baseName.Replace('\\', '/').EndsWith("Properties/AssemblyInfo.cs", StringComparison.OrdinalIgnoreCase)) - return true; - // The SDK globs **/*.cs recursively, so any relative under-project path is a - // default-globbed compile item (not just files in the project root). - return IsDefaultGlobPath(inc); - } - - // A path is "default-globbed" — i.e. the SDK's implicit **/* globs would already - // pick it up — when it is relative, rooted under the project directory, and does - // not escape it via "..". Such items must NOT be re-Included explicitly (that - // duplicates the globbed item); rooted/external paths and links are NOT globbed - // and so are kept verbatim. - private static bool IsDefaultGlobPath(string inc) - { - if (string.IsNullOrWhiteSpace(inc)) return false; - var norm = inc.Replace('\\', '/').Trim(); - if (norm.StartsWith("../") || norm == "..") return false; // escapes project dir - if (norm.StartsWith('/')) return false; // rooted (unix) - if (norm.Length >= 2 && norm[1] == ':') return false; // rooted (drive letter) - if (norm.Contains("/../")) return false; // climbs out partway - if (norm.Contains('*') || norm.Contains('?')) return false; // already a glob - return true; - } - - // Decides how a default-globbable item (None / EmbeddedResource / Content) should - // be carried into the SDK project, per the rules: - // - external/rooted path or a Link present → keep as Include (SDK won't glob it); - // - default-globbed path WITH metadata → rewrite Include→Update so the - // metadata attaches to the SDK's already-globbed item (no duplicate); - // - default-globbed path WITHOUT metadata → drop entirely (SDK globs it plain). - private static void KeepOrUpdateDefaultGlobItem(XElement el, List keepItems) - { - var inc = (string?)el.Attribute("Include") ?? ""; - var hasLink = el.Attribute("Link") is not null - || el.Elements().Any(e => e.Name.LocalName == "Link"); - - if (hasLink || !IsDefaultGlobPath(inc)) - { - keepItems.Add(el); // SDK won't glob this — keep the explicit Include - return; - } - - var hasMetadata = el.Elements().Any() || el.Attributes().Any(a => a.Name.LocalName != "Include"); - if (!hasMetadata) - return; // SDK globs it plainly — drop the redundant item - - // Rewrite Include → Update so the metadata lands on the globbed item without - // re-including (which would trip NETSDK1022 for EmbeddedResource/Content). - el.Attribute("Include")!.Remove(); - el.SetAttributeValue("Update", inc); - keepItems.Add(el); - } - - // Normalizes a project-relative path for comparison against on-disk enumeration: - // forward slashes, leading "./" stripped, no surrounding whitespace. - private static string NormalizeRel(string inc) - { - var s = inc.Replace('\\', '/').Trim(); - while (s.StartsWith("./")) s = s[2..]; - return s; - } - - // True when a hand-written AssemblyInfo.cs on disk declares an attribute the SDK's - // GenerateAssemblyInfo does not emit (AssemblyNativeVersion). Such a file must be - // kept (with GenerateAssemblyInfo disabled) rather than deleted. - private static bool HasNanoAssemblyInfo(string projDir) - { - foreach (var rel in new[] { Path.Combine("Properties", "AssemblyInfo.cs"), "AssemblyInfo.cs" }) - { - var path = Path.Combine(projDir, rel); - if (!File.Exists(path)) continue; - try - { - if (File.ReadAllText(path).Contains("AssemblyNativeVersion", StringComparison.Ordinal)) - return true; - } - catch { /* unreadable → treat as ordinary, deletable AssemblyInfo */ } - } - return false; - } - - // Project-relative paths (forward-slashed) of every .cs file on disk that the SDK - // would compile by default: all **/*.cs under the project directory, excluding - // bin/obj and a hand-written AssemblyInfo.cs. (A deletable AssemblyInfo is removed - // from disk by the converter, and a kept nano-specific one is authoritative — in - // both cases it must not appear in the Compile Remove list.) Used to detect files - // the legacy project deliberately did not compile. - private static IEnumerable OnDiskDefaultCompile(string projDir) - { - var root = Path.GetFullPath(projDir); - IEnumerable files; - try { files = Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories); } - catch { yield break; } - - foreach (var f in files) - { - var rel = Path.GetRelativePath(root, f).Replace('\\', '/'); - var lower = rel.ToLowerInvariant(); - if (lower.StartsWith("bin/") || lower.Contains("/bin/")) continue; - if (lower.StartsWith("obj/") || lower.Contains("/obj/")) continue; - // A deletable AssemblyInfo.cs is removed from disk by the converter, so it - // is neither globbed nor needs a Remove. A KEPT one is authoritative and - // must not be Removed either. - if (rel.EndsWith("Properties/AssemblyInfo.cs", StringComparison.OrdinalIgnoreCase) - || rel.Equals("AssemblyInfo.cs", StringComparison.OrdinalIgnoreCase)) - continue; - yield return rel; - } - } - - private static void FoldNuspec(string projDir, Action setProp) - { - var nuspec = Directory.EnumerateFiles(projDir, "*.nuspec").FirstOrDefault(); - if (nuspec is null) return; - var meta = XElement.Load(nuspec).Descendants().FirstOrDefault(e => e.Name.LocalName == "metadata"); - if (meta is null) return; - foreach (var (xml, msb) in new[] - { - ("id", "PackageId"), ("description", "Description"), ("authors", "Authors"), - ("tags", "PackageTags"), ("projectUrl", "PackageProjectUrl"), - }) - { - var e = meta.Elements().FirstOrDefault(x => x.Name.LocalName == xml); - if (e is not null && !string.IsNullOrEmpty(e.Value)) setProp(msb, e.Value); - } - } - - private static string Emit( - List> props, - Dictionary pkgRefs, - List projRefs, - List keepItems, - List compileRemoves, - List imports, - ConversionOptions o, - bool cpmActive) - { - var sb = new StringBuilder(); - // Versionless SDK reference; the version is pinned via global.json msbuild-sdks. - sb.Append($"\n\n"); - sb.Append(" \n"); - sb.Append($" {o.Tfm}\n"); - foreach (var kv in props) - sb.Append($" <{kv.Key}>{Escape(kv.Value)}\n"); - sb.Append(" \n\n"); - - if (pkgRefs.Count > 0) - { - sb.Append(" \n"); - foreach (var kv in pkgRefs.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) - // Under CPM the version belongs in the central props, not here — a - // Version attribute would trip NU1008. Otherwise pin it inline. - sb.Append(cpmActive - ? $" \n" - : $" \n"); - sb.Append(" \n\n"); - } - if (projRefs.Count > 0) - { - sb.Append(" \n"); - foreach (var r in projRefs) - sb.Append($" \n"); - sb.Append(" \n\n"); - } - if (keepItems.Count > 0) - { - sb.Append(" \n"); - foreach (var el in keepItems) - AppendItem(sb, el); - sb.Append(" \n\n"); - } - // Files the legacy project deliberately did not compile but that the SDK's - // **/*.cs glob would otherwise pick up: exclude them so the converted project - // builds the same source set as the original. - if (compileRemoves.Count > 0) - { - sb.Append(" \n"); - foreach (var rel in compileRemoves) - sb.Append($" \n"); - sb.Append(" \n\n"); - } - // Shared Project imports carry source into the project; emit them with the - // Shared label MSBuild/VS expects for a .projitems import. - foreach (var import in imports) - sb.Append($" \n"); - if (imports.Count > 0) sb.Append('\n'); - sb.Append("\n"); - return sb.ToString(); - } - - // Renders one kept item element, preserving its attributes AND its child-element - // metadata (Generator, LastGenOutput, CopyToOutputDirectory, Link, …). Earlier the - // emitter wrote attributes only, silently dropping the child metadata that, for an - // Update item, is the whole point of carrying it through. - private static void AppendItem(StringBuilder sb, XElement el) - { - var attrs = string.Join(" ", el.Attributes().Select(a => $"{a.Name.LocalName}=\"{Escape(a.Value)}\"")); - var children = el.Elements().ToList(); - var name = el.Name.LocalName; - if (children.Count == 0) - { - sb.Append($" <{name} {attrs} />\n"); - return; - } - sb.Append($" <{name} {attrs}>\n"); - foreach (var c in children) - sb.Append($" <{c.Name.LocalName}>{Escape(c.Value)}\n"); - sb.Append($" \n"); - } - - private static string Escape(string s) => s - .Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """); -} diff --git a/tools/migrate/src/NanoMigrate.Core/Reporting/HtmlReportWriter.cs b/tools/migrate/src/NanoMigrate.Core/Reporting/HtmlReportWriter.cs deleted file mode 100644 index c6e1a5d..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Reporting/HtmlReportWriter.cs +++ /dev/null @@ -1,178 +0,0 @@ -using System.Net; -using System.Text; -using NanoFramework.Migrate.Core.Projects; - -namespace NanoFramework.Migrate.Core.Reporting; - -/// -/// Renders a as a self-contained HTML page: inline -/// <style>, no external assets. The results table is colour-coded by -/// status (green converted / grey skipped / amber review / red error). Pure and -/// console-free; ALL interpolated text (project names, notes, package ids) is -/// HTML-escaped to guard against injection. -/// -public static class HtmlReportWriter -{ - /// Renders the report to a self-contained HTML string. - public static string Render(MigrationReport report) - { - var sb = new StringBuilder(); - var title = report.DryRun - ? "nanoFramework migration report (dry run)" - : "nanoFramework migration report"; - - sb.Append("\n"); - sb.Append("\n\n"); - sb.Append("\n"); - sb.Append("\n"); - sb.Append("").Append(Esc(title)).Append("\n"); - sb.Append("\n"); - sb.Append("\n\n"); - - sb.Append("

").Append(Esc(title)).Append("

\n"); - sb.Append("

Root: ").Append(Esc(report.RootPath)).Append("
\n"); - sb.Append("Generated (UTC): ") - .Append(Esc(report.GeneratedUtc.ToString("yyyy-MM-dd HH:mm:ss'Z'"))) - .Append("

\n"); - - var verb = report.DryRun ? "would convert" : "converted"; - sb.Append("
    \n"); - sb.Append("
  • ").Append(Esc(verb)).Append(' ').Append(report.Converted).Append("
  • \n"); - sb.Append("
  • skipped ").Append(report.Skipped).Append("
  • \n"); - sb.Append("
  • flagged ").Append(report.Flagged).Append("
  • \n"); - sb.Append("
  • errors ").Append(report.Errors).Append("
  • \n"); - sb.Append("
  • total ").Append(report.Total).Append("
  • \n"); - sb.Append("
\n"); - - // Results table. - sb.Append("\n\n"); - sb.Append(""); - sb.Append("\n\n\n"); - foreach (var p in report.Projects) - { - var (label, cls) = StatusLabel(p.Status); - sb.Append(""); - sb.Append(""); - sb.Append(""); - sb.Append(""); - sb.Append(""); - sb.Append("\n"); - } - sb.Append("\n
ProjectResultPackagesNotes
").Append(Esc(p.RelativePath)).Append("").Append(Esc(label)).Append("").Append(Packages(p)).Append("").Append(Esc(Notes(p))).Append("
\n"); - - if (report.AffectedSolutions.Count > 0) - { - sb.Append("

Solutions

\n
    \n"); - foreach (var s in report.AffectedSolutions) - sb.Append("
  • ").Append(Esc(s)).Append("
  • \n"); - sb.Append("
\n"); - } - - var flagged = report.Projects.Where(p => p.Review.Count > 0).ToList(); - if (flagged.Count > 0) - { - sb.Append("

Manual review

\n"); - foreach (var p in flagged) - { - sb.Append("

").Append(Esc(p.RelativePath)).Append("

\n
    \n"); - foreach (var item in p.Review) - sb.Append("
  • ").Append(Esc(item)).Append("
  • \n"); - sb.Append("
\n"); - } - } - - if (report.Verify.Count > 0) - { - sb.Append("

Verification

\n"); - sb.Append("\n\n"); - sb.Append(""); - sb.Append("\n\n\n"); - foreach (var v in report.Verify) - { - var (label, cls) = v.Skipped ? ("Skipped", "skipped") - : v.Succeeded ? ("Pass", "converted") - : ("Fail", "error"); - sb.Append(""); - sb.Append(""); - sb.Append(""); - sb.Append(""); - sb.Append("\n"); - } - sb.Append("\n
TargetResultDetails
").Append(Esc(v.Target)).Append("").Append(Esc(label)).Append(""); - sb.Append(string.IsNullOrEmpty(v.Details) ? "—" : $"
{Esc(v.Details!)}
"); - sb.Append("
\n"); - } - - sb.Append("\n\n"); - return sb.ToString(); - } - - /// Renders the report and writes it to as UTF-8 (no BOM). - public static void Write(MigrationReport report, string path) - { - var dir = Path.GetDirectoryName(Path.GetFullPath(path)); - if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - File.WriteAllText(path, Render(report), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - } - - private static string Packages(ReportEntry p) - { - if (p.Packages.Count == 0) return ""; - return string.Join("
", p.Packages.Select(kv => - $"{Esc(kv.Key)} {Esc(kv.Value)}")); - } - - private static (string label, string cls) StatusLabel(ConvertStatus s) => s switch - { - ConvertStatus.Converted => ("Converted", "converted"), - ConvertStatus.Skipped => ("Skipped", "skipped"), - ConvertStatus.Review => ("Review", "review"), - ConvertStatus.Error => ("Error", "error"), - _ => ("?", "skipped"), - }; - - private static string Notes(ReportEntry p) => p.Status switch - { - ConvertStatus.Error => p.Error ?? "error", - ConvertStatus.Skipped => "already SDK-style", - ConvertStatus.Review => $"{p.Review.Count} item(s) need review", - _ => "clean", - }; - - // HTML-escapes interpolated text. WebUtility.HtmlEncode handles &, <, >, ". - private static string Esc(string s) => WebUtility.HtmlEncode(s); - - // A small, self-contained stylesheet. Status colours: green (converted), - // grey (skipped), amber (review), red (error). - private const string Css = """ - :root { color-scheme: light dark; } - body { font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; - margin: 2rem; line-height: 1.45; color: #1b1b1b; background: #fff; } - h1 { font-size: 1.6rem; margin-bottom: .25rem; } - h2 { margin-top: 2rem; border-bottom: 1px solid #ddd; padding-bottom: .25rem; } - .meta { color: #555; } - code { font-family: Consolas, Menlo, monospace; font-size: .9em; } - .muted { color: #888; } - ul.totals { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: .5rem; } - ul.totals li { padding: .35rem .7rem; border-radius: .4rem; font-weight: 600; } - .t-converted { background: #e6f4ea; color: #137333; } - .t-skipped { background: #f1f3f4; color: #5f6368; } - .t-review { background: #fef7e0; color: #b06000; } - .t-error { background: #fce8e6; color: #c5221f; } - .t-total { background: #e8f0fe; color: #1a73e8; } - table.results { border-collapse: collapse; width: 100%; margin-top: 1rem; } - table.results th, table.results td { border: 1px solid #ddd; padding: .5rem .65rem; text-align: left; vertical-align: top; } - table.results th { background: #f5f5f5; } - .badge { display: inline-block; padding: .15rem .55rem; border-radius: .35rem; font-size: .85em; font-weight: 600; } - tr.converted .badge, .badge.converted { background: #e6f4ea; color: #137333; } - tr.skipped .badge, .badge.skipped { background: #f1f3f4; color: #5f6368; } - tr.review .badge, .badge.review { background: #fef7e0; color: #b06000; } - tr.error .badge, .badge.error { background: #fce8e6; color: #c5221f; } - tr.converted td:first-child { border-left: 4px solid #34a853; } - tr.skipped td:first-child { border-left: 4px solid #9aa0a6; } - tr.review td:first-child { border-left: 4px solid #f9ab00; } - tr.error td:first-child { border-left: 4px solid #ea4335; } - pre { margin: 0; white-space: pre-wrap; font-size: .85em; } - - """; -} diff --git a/tools/migrate/src/NanoMigrate.Core/Reporting/MarkdownReportWriter.cs b/tools/migrate/src/NanoMigrate.Core/Reporting/MarkdownReportWriter.cs deleted file mode 100644 index 393529f..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Reporting/MarkdownReportWriter.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Text; -using NanoFramework.Migrate.Core.Projects; - -namespace NanoFramework.Migrate.Core.Reporting; - -/// -/// Renders a as Markdown. Pure: it returns a string -/// (or writes that string to a path) and never touches the console. The document is -/// a title, a summary line, a results table, and — when present — a "Manual review" -/// section and a "Verification" section. -/// -public static class MarkdownReportWriter -{ - /// Renders the report to a Markdown string. - public static string Render(MigrationReport report) - { - var sb = new StringBuilder(); - - sb.Append("# nanoFramework migration report"); - if (report.DryRun) sb.Append(" (dry run)"); - sb.Append('\n').Append('\n'); - - sb.Append("- **Root:** `").Append(report.RootPath).Append("`\n"); - sb.Append("- **Generated (UTC):** ") - .Append(report.GeneratedUtc.ToString("yyyy-MM-dd HH:mm:ss'Z'")) - .Append('\n').Append('\n'); - - var verb = report.DryRun ? "would convert" : "converted"; - sb.Append("**Summary:** ") - .Append(verb).Append(' ').Append(report.Converted) - .Append(" · skipped ").Append(report.Skipped) - .Append(" · flagged ").Append(report.Flagged) - .Append(" · errors ").Append(report.Errors) - .Append(" · total ").Append(report.Total) - .Append('\n').Append('\n'); - - // Results table. - sb.Append("| Project | Result | Packages | Notes |\n"); - sb.Append("| --- | --- | --- | --- |\n"); - foreach (var p in report.Projects) - { - var packages = p.Packages.Count == 0 - ? "—" - : string.Join("
", p.Packages.Select(kv => $"{Cell(kv.Key)} {Cell(kv.Value)}")); - sb.Append("| ") - .Append(Cell(p.RelativePath)).Append(" | ") - .Append(StatusLabel(p.Status)).Append(" | ") - .Append(packages).Append(" | ") - .Append(Notes(p)).Append(" |\n"); - } - sb.Append('\n'); - - if (report.AffectedSolutions.Count > 0) - { - sb.Append("## Solutions\n\n"); - foreach (var s in report.AffectedSolutions) - sb.Append("- `").Append(s).Append("`\n"); - sb.Append('\n'); - } - - var flagged = report.Projects.Where(p => p.Review.Count > 0).ToList(); - if (flagged.Count > 0) - { - sb.Append("## Manual review\n\n"); - foreach (var p in flagged) - { - sb.Append("### ").Append(p.RelativePath).Append('\n').Append('\n'); - foreach (var item in p.Review) - sb.Append("- ").Append(item).Append('\n'); - sb.Append('\n'); - } - } - - if (report.Verify.Count > 0) - { - sb.Append("## Verification\n\n"); - sb.Append("| Target | Result | Details |\n"); - sb.Append("| --- | --- | --- |\n"); - foreach (var v in report.Verify) - { - var label = v.Skipped ? "Skipped" : v.Succeeded ? "Pass" : "Fail"; - var details = string.IsNullOrEmpty(v.Details) - ? "—" - : Cell(v.Details); - sb.Append("| ") - .Append(Cell(v.Target)).Append(" | ") - .Append(label).Append(" | ") - .Append(details).Append(" |\n"); - } - sb.Append('\n'); - } - - return sb.ToString(); - } - - /// Renders the report and writes it to as UTF-8 (no BOM). - public static void Write(MigrationReport report, string path) - { - var dir = Path.GetDirectoryName(Path.GetFullPath(path)); - if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - File.WriteAllText(path, Render(report), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - } - - private static string StatusLabel(ConvertStatus s) => s switch - { - ConvertStatus.Converted => "Converted", - ConvertStatus.Skipped => "Skipped", - ConvertStatus.Review => "Review", - ConvertStatus.Error => "Error", - _ => "?", - }; - - private static string Notes(ReportEntry p) => p.Status switch - { - ConvertStatus.Error => Cell(p.Error ?? "error"), - ConvertStatus.Skipped => "already SDK-style", - ConvertStatus.Review => $"{p.Review.Count} item(s) need review", - _ => "clean", - }; - - // Escapes the few characters that would break a Markdown table cell: pipes - // (column separators) and newlines (row separators ->
). - private static string Cell(string s) => - s.Replace("\\", "\\\\") - .Replace("|", "\\|") - .Replace("\r\n", "
") - .Replace("\n", "
") - .Replace("\r", "
"); -} diff --git a/tools/migrate/src/NanoMigrate.Core/Reporting/MigrationReport.cs b/tools/migrate/src/NanoMigrate.Core/Reporting/MigrationReport.cs deleted file mode 100644 index 8ef7b99..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Reporting/MigrationReport.cs +++ /dev/null @@ -1,93 +0,0 @@ -using NanoFramework.Migrate.Core.Projects; -using NanoFramework.Migrate.Core.Verification; - -namespace NanoFramework.Migrate.Core.Reporting; - -/// -/// The result of a single project within a , expressed -/// as plain DATA so the report writers stay pure and console-free. Mirrors the data -/// the on-screen summary table renders, but decoupled from any presentation layer. -/// -public sealed class ReportEntry -{ - /// The project path, relative to the report's root, with forward slashes. - public required string RelativePath { get; init; } - - /// The conversion status of the project. - public required ConvertStatus Status { get; init; } - - /// The resolved PackageReference ids and versions the project carries. - public IReadOnlyList> Packages { get; init; } = - Array.Empty>(); - - /// The items flagged for manual review (empty when none). - public IReadOnlyList Review { get; init; } = Array.Empty(); - - /// An optional error message (set only when is Error). - public string? Error { get; init; } -} - -/// -/// One verification build result inside a . A flat, -/// presentation-free projection of keyed by a relative -/// target path so the writers don't depend on the verification slice's shape. -/// -public sealed class ReportVerifyEntry -{ - /// The built target, relative to the report's root where possible. - public required string Target { get; init; } - - /// True when the build succeeded. - public required bool Succeeded { get; init; } - - /// True when the build was skipped (e.g. no dotnet on PATH). - public required bool Skipped { get; init; } - - /// The build process exit code. - public int ExitCode { get; init; } - - /// A short note: the skip reason or the error tail on failure. - public string? Details { get; init; } -} - -/// -/// A migration run captured as DATA, ready to be rendered by the -/// or . The engine -/// never calls DateTime.Now: the timestamp is supplied by the caller so the -/// writers stay pure and deterministically testable. -/// -public sealed class MigrationReport -{ - /// The root directory the migration ran against (paths are relative to it). - public required string RootPath { get; init; } - - /// A UTC timestamp for the run, supplied by the caller. - public required DateTime GeneratedUtc { get; init; } - - /// True when this report describes a dry-run (what WOULD change). - public bool DryRun { get; init; } - - /// The per-project results, in run order. - public IReadOnlyList Projects { get; init; } = Array.Empty(); - - /// The solutions retargeted (or, in dry-run, that would be retargeted). - public IReadOnlyList AffectedSolutions { get; init; } = Array.Empty(); - - /// The verification build results, when a verify pass ran (otherwise empty). - public IReadOnlyList Verify { get; init; } = Array.Empty(); - - /// Projects that converted cleanly. - public int Converted => Projects.Count(p => p.Status == ConvertStatus.Converted); - - /// Projects already SDK-style (skipped). - public int Skipped => Projects.Count(p => p.Status == ConvertStatus.Skipped); - - /// Projects converted but flagged for manual review. - public int Flagged => Projects.Count(p => p.Status == ConvertStatus.Review); - - /// Projects that errored. - public int Errors => Projects.Count(p => p.Status == ConvertStatus.Error); - - /// The total number of projects in the report. - public int Total => Projects.Count; -} diff --git a/tools/migrate/src/NanoMigrate.Core/Solutions/MigrationPlan.cs b/tools/migrate/src/NanoMigrate.Core/Solutions/MigrationPlan.cs deleted file mode 100644 index 18ff9a3..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Solutions/MigrationPlan.cs +++ /dev/null @@ -1,200 +0,0 @@ -using NanoFramework.Migrate.Core.Common; - -namespace NanoFramework.Migrate.Core.Solutions; - -/// -/// How the migration target was specified, which decides whether the CLI must -/// prompt the user to choose among candidate solutions. -/// -public enum PlanKind -{ - /// Input was a single .nfproj file (no solution awareness). - SingleProject, - - /// - /// A directory with no solutions found: fall back to converting every - /// .nfproj loosely (the historical directory behavior). - /// - LooseDirectory, - - /// - /// Input was an explicit solution (positional .sln/.slnx or - /// --solution): convert only that solution's projects, update only it. - /// No prompt. - /// - ExplicitSolution, - - /// - /// A directory where solutions were discovered: the user must choose which - /// solution(s) to migrate (multi-select), unless non-interactive/--yes. - /// - DirectoryWithSolutions, - - /// - /// A glob narrowed the projects; the affected solutions (those referencing a - /// matched project) are presented for confirmation / multi-select. - /// - GlobScoped, -} - -/// -/// A candidate solution surfaced by the planner, paired with the matched -/// .nfproj projects it references (the projects that would be converted and -/// for which this solution would be retargeted). -/// -public sealed class SolutionCandidate -{ - public required SolutionFile Solution { get; init; } - - /// The matched .nfproj (absolute paths) this solution references. - public required IReadOnlyList NanoProjects { get; init; } -} - -/// -/// The pure result of planning a migration. Carries enough data for the CLI to -/// render a preview and (when ) prompt the user — -/// the planner itself does no console I/O and no prompting. -/// -public sealed class MigrationPlan -{ - /// How the target was specified. - public required PlanKind Kind { get; init; } - - /// - /// Candidate solutions the user may choose among (empty for - /// / ). - /// - public IReadOnlyList Candidates { get; init; } = Array.Empty(); - - /// - /// For project/loose-directory plans: the .nfproj to convert when no - /// solution is involved. Empty for solution-scoped plans (those projects come - /// from the chosen candidates). - /// - public IReadOnlyList LooseProjects { get; init; } = Array.Empty(); - - /// - /// True when the CLI must let the user pick among - /// (more than one candidate, or a glob scoping that warrants confirmation). For - /// an explicit single solution this is false (no choice to make). - /// - public bool RequiresSelection => Kind is PlanKind.DirectoryWithSolutions or PlanKind.GlobScoped; - - /// - /// Flattens a chosen set of candidates into the distinct .nfproj projects - /// to convert (sorted, absolute). For non-solution plans the caller uses - /// instead. - /// - public static List ProjectsOf(IEnumerable chosen) => - chosen.SelectMany(c => c.NanoProjects) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) - .ToList(); -} - -/// -/// Builds a from the command inputs and the file system. -/// All branching about solutions vs loose conversion lives here, so the CLI only -/// renders the plan and (when required) prompts. No console, no prompting. -/// -public static class MigrationPlanner -{ - /// - /// Plans a migration. - /// - /// — the positional argument: a .nfproj, a - /// solution (.sln/.slnx), or a directory. - /// — the --solution override; - /// when set, only that solution is touched. - /// — narrows the .nfproj within a directory - /// and triggers solution discovery over the matched projects. - /// - /// - public static MigrationPlan Plan(string path, string? explicitSolution, string? glob) - { - // 1. An explicit --solution always wins: convert only its .nfproj, touch - // only it. (Honoured even if the positional path is a directory.) - if (!string.IsNullOrEmpty(explicitSolution)) - return ExplicitSolutionPlan(explicitSolution); - - // 2. Positional path is itself a solution file. - if (File.Exists(path) && SolutionFile.IsSolutionPath(path)) - return ExplicitSolutionPlan(path); - - // 3. Positional path is a single .nfproj file: no solution awareness. - if (File.Exists(path) && path.EndsWith(".nfproj", StringComparison.OrdinalIgnoreCase)) - return new MigrationPlan - { - Kind = PlanKind.SingleProject, - LooseProjects = new[] { Path.GetFullPath(path) }, - }; - - // 4. Directory. - if (Directory.Exists(path)) - return DirectoryPlan(path, glob); - - // Non-existent path: nothing to do (empty loose plan). - return new MigrationPlan { Kind = PlanKind.LooseDirectory }; - } - - private static MigrationPlan ExplicitSolutionPlan(string solutionPath) - { - var sln = SolutionFile.Load(solutionPath); - var nano = sln.NanoProjects(); - return new MigrationPlan - { - Kind = PlanKind.ExplicitSolution, - Candidates = new[] - { - new SolutionCandidate { Solution = sln, NanoProjects = nano }, - }, - }; - } - - private static MigrationPlan DirectoryPlan(string dir, string? glob) - { - var solutions = SolutionScanner.Load(dir); - - // GLOB MODE: scope to the matched projects, then discover which solutions - // reference any of them. Even with no solutions found we honour the glob via - // a loose conversion of the matched projects. - if (glob is not null) - { - var matched = ProjectScanner.NfprojUnder(dir, glob) - .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) - .ToList(); - var affected = SolutionDiscovery.Referencing(solutions, matched); - if (affected.Count == 0) - return new MigrationPlan { Kind = PlanKind.LooseDirectory, LooseProjects = matched }; - - var matchedSet = new HashSet(matched, StringComparer.OrdinalIgnoreCase); - return new MigrationPlan - { - Kind = PlanKind.GlobScoped, - Candidates = affected.Select(s => new SolutionCandidate - { - Solution = s, - NanoProjects = s.NanoProjects() - .Where(p => matchedSet.Contains(Path.GetFullPath(p))) - .ToList(), - }).ToList(), - }; - } - - // NO GLOB: 0 solutions => loose directory; >=1 => the user chooses. - if (solutions.Count == 0) - { - var all = ProjectScanner.ResolveProjects(dir, glob: null); - return new MigrationPlan { Kind = PlanKind.LooseDirectory, LooseProjects = all }; - } - - return new MigrationPlan - { - Kind = PlanKind.DirectoryWithSolutions, - Candidates = solutions - .Where(s => s.NanoProjects().Count > 0) // only solutions with .nfproj to convert - .Select(s => new SolutionCandidate { Solution = s, NanoProjects = s.NanoProjects() }) - .ToList(), - }; - } -} diff --git a/tools/migrate/src/NanoMigrate.Core/Solutions/SolutionDiscovery.cs b/tools/migrate/src/NanoMigrate.Core/Solutions/SolutionDiscovery.cs deleted file mode 100644 index 50e2dc0..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Solutions/SolutionDiscovery.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace NanoFramework.Migrate.Core.Solutions; - -/// -/// Given a set of target .nfproj paths, finds which discovered solutions -/// reference any of them — by parsing each solution's project list and intersecting -/// it with the targets. Pure analysis over already-loaded solution models. -/// -public static class SolutionDiscovery -{ - /// - /// Returns the solutions (from ) that reference at - /// least one of . Comparison is by absolute - /// path, case-insensitive. Unrelated solutions are excluded. - /// - public static List Referencing( - IEnumerable solutions, - IEnumerable targetNfprojPaths) - { - var targets = new HashSet( - targetNfprojPaths.Select(Path.GetFullPath), StringComparer.OrdinalIgnoreCase); - if (targets.Count == 0) return new List(); - - return solutions - .Where(s => s.ProjectPaths.Any(p => targets.Contains(Path.GetFullPath(p)))) - .ToList(); - } - - /// - /// Loads every solution under and returns those that - /// reference at least one of . - /// - public static List FindReferencing(string dir, IEnumerable targetNfprojPaths) => - Referencing(SolutionScanner.Load(dir), targetNfprojPaths); -} diff --git a/tools/migrate/src/NanoMigrate.Core/Solutions/SolutionFile.cs b/tools/migrate/src/NanoMigrate.Core/Solutions/SolutionFile.cs deleted file mode 100644 index 1ba3472..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Solutions/SolutionFile.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System.Text; -using System.Xml.Linq; -using Microsoft.VisualStudio.SolutionPersistence.Model; -using Microsoft.VisualStudio.SolutionPersistence.Serializer; - -namespace NanoFramework.Migrate.Core.Solutions; - -/// The on-disk format of a Visual Studio solution. -public enum SolutionFormat -{ - /// Classic line-based .sln (each project has a type GUID). - Classic, - - /// XML .slnx (path-based). - Xml, -} - -/// -/// Parsed model of a Visual Studio solution — both the classic .sln line -/// format and the newer XML .slnx format. Backed by the .NET Foundation -/// Microsoft.VisualStudio.SolutionPersistence serializers: the library does -/// all of the parsing and (on retarget) re-serialization; this type only exposes the -/// referenced project paths (absolute) and the format, and drives the nfproj→csproj -/// retarget through the model. -/// -public sealed class SolutionFile -{ - // The legacy nanoFramework project-type GUID. A flavored .nfproj carries this in a - // classic .sln, and VS writes it as the Type attribute in a .slnx. The serializer - // does not know the .nfproj extension, so a .slnx that references a bare .nfproj - // (no Type attribute) cannot be parsed as-is; we inject this Type for those - // entries before handing the text to the library (see LoadSlnxModel). - internal const string NfprojTypeGuid = "{11A8DD76-328B-46DF-9F39-F559912D0360}"; - - /// Absolute path to the solution file. - public string Path { get; } - - /// Whether this is a classic .sln or an XML .slnx. - public SolutionFormat Format { get; } - - /// - /// Every project the solution references, as an absolute path. Solution folders - /// are excluded (the library models them separately from projects). - /// - public IReadOnlyList ProjectPaths { get; } - - private SolutionFile(string path, SolutionFormat format, IReadOnlyList projectPaths) - { - Path = path; - Format = format; - ProjectPaths = projectPaths; - } - - /// True if the file extension denotes a solution (.sln or .slnx). - public static bool IsSolutionPath(string path) => - path.EndsWith(".sln", StringComparison.OrdinalIgnoreCase) - || path.EndsWith(".slnx", StringComparison.OrdinalIgnoreCase); - - /// Loads and parses the solution at . - public static SolutionFile Load(string solutionPath) - { - var full = System.IO.Path.GetFullPath(solutionPath); - var dir = System.IO.Path.GetDirectoryName(full)!; - var isXml = full.EndsWith(".slnx", StringComparison.OrdinalIgnoreCase); - - var model = LoadModel(full, isXml); - var paths = model.SolutionProjects - .Select(p => System.IO.Path.GetFullPath(System.IO.Path.Combine(dir, p.FilePath))) - .ToList(); - - return new SolutionFile(full, isXml ? SolutionFormat.Xml : SolutionFormat.Classic, paths); - } - - /// - /// The .nfproj projects referenced by the solution (absolute paths). - /// - public IReadOnlyList NanoProjects() => - ProjectPaths.Where(p => p.EndsWith(".nfproj", StringComparison.OrdinalIgnoreCase)).ToList(); - - // ---- Library bridge ------------------------------------------------------ - - /// - /// Reads a solution into the library's , picking the - /// serializer by extension. For .slnx the bare-.nfproj compatibility - /// shim is applied so a solution that references a flavored project without an - /// explicit Type attribute still parses (the serializer does not know the - /// .nfproj extension on its own). - /// - internal static SolutionModel LoadModel(string fullPath, bool isXml) => - isXml ? LoadSlnxModel(File.ReadAllText(fullPath)) : OpenFromText(isXml: false, File.ReadAllText(fullPath)); - - // Classic .sln: the serializer carries the project-type GUID per entry, so no shim - // is needed. .slnx: try a straight parse; if the serializer rejects an unknown - // .nfproj project type, inject the nanoFramework Type attribute on bare .nfproj - // elements and retry. This is the only place we touch the raw text, and - // only to make an otherwise-unparseable solution ingestible by the library. - internal static SolutionModel LoadSlnxModel(string text) - { - try - { - return OpenFromText(isXml: true, text); - } - catch (SolutionException) - { - var patched = InjectNfprojTypeAttribute(text); - if (patched is null) throw; - return OpenFromText(isXml: true, patched); - } - } - - // Opens solution text into the model via the matching typed serializer (chosen by - // format). The library's stream-based open avoids any temp file. - internal static SolutionModel OpenFromText(bool isXml, string text) - { - using var ms = new MemoryStream(new UTF8Encoding(false).GetBytes(text)); - return isXml - ? SolutionSerializers.SlnXml.OpenAsync(ms, CancellationToken.None).GetAwaiter().GetResult() - : SolutionSerializers.SlnFileV12.OpenAsync(ms, CancellationToken.None).GetAwaiter().GetResult(); - } - - // Adds Type="{nfproj GUID}" to every element that lacks - // a Type attribute, so the SolutionPersistence .slnx serializer can resolve the - // otherwise-unknown extension. Returns null when nothing needed patching (so the - // original error is preserved). Preserves all other formatting via XDocument. - internal static string? InjectNfprojTypeAttribute(string slnxText) - { - XDocument doc; - try { doc = XDocument.Parse(slnxText, LoadOptions.PreserveWhitespace); } - catch { return null; } - - var changed = false; - foreach (var proj in doc.Descendants().Where(e => e.Name.LocalName == "Project")) - { - var path = (string?)proj.Attribute("Path"); - if (path is null || !path.EndsWith(".nfproj", StringComparison.OrdinalIgnoreCase)) continue; - var hasType = proj.Attributes().Any(a => a.Name.LocalName == "Type"); - if (hasType) continue; - proj.SetAttributeValue("Type", NfprojTypeGuid); - changed = true; - } - return changed ? doc.ToString(SaveOptions.DisableFormatting) : null; - } -} diff --git a/tools/migrate/src/NanoMigrate.Core/Solutions/SolutionRewriter.cs b/tools/migrate/src/NanoMigrate.Core/Solutions/SolutionRewriter.cs deleted file mode 100644 index 87d7828..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Solutions/SolutionRewriter.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.Text; -using Microsoft.VisualStudio.SolutionPersistence.Model; -using Microsoft.VisualStudio.SolutionPersistence.Serializer; - -namespace NanoFramework.Migrate.Core.Solutions; - -/// -/// Retargets a solution's converted projects from .nfproj to .csproj. -/// Handles both the classic line-based .sln and the XML .slnx by -/// mutating the .NET Foundation and re-serialising with -/// the matching serializer — the library writes the canonical format for each (a -/// .sln stays a .sln, a .slnx stays a .slnx). For a -/// classic .sln the retargeted entry's project type is set to the SDK-style C# -/// GUID; for a path-based .slnx the now-redundant nanoFramework type is cleared. -/// -/// Idempotent / reentrant: the retarget is decided at the model level — only projects -/// still pointing at a .nfproj that is in the converted set are touched. When -/// nothing matched, the original file text is returned/left untouched (no write, no -/// reformat). -/// -public static class SolutionRewriter -{ - // Project-type GUIDs: legacy nanoFramework flavor -> SDK-style C#. Retained for - // back-compat (tests/consumers reference these); the actual GUID swap on a classic - // .sln is performed by the serializer when the project type is cleared and the - // path becomes a .csproj. - public const string NfprojTypeGuid = "{11A8DD76-328B-46DF-9F39-F559912D0360}"; - public const string CsprojTypeGuid = "{9A19103F-16F7-4668-BE54-9A1E7A4F7556}"; - - /// - /// Returns the rewritten text of , retargeting any - /// entry whose referenced .nfproj (resolved against ) - /// is in (absolute paths). The returned - /// string equals the input when nothing matched (no-op). - /// - public static string Rewrite( - string solutionText, - string solutionDir, - SolutionFormat format, - IEnumerable convertedNfprojPaths) - { - var converted = new HashSet( - convertedNfprojPaths.Select(Path.GetFullPath), StringComparer.OrdinalIgnoreCase); - if (converted.Count == 0) return solutionText; - - var isXml = format == SolutionFormat.Xml; - - SolutionModel model; - try - { - // .slnx parsing applies the bare-.nfproj Type compatibility shim; .sln - // carries its type GUIDs per entry and needs none. - model = isXml ? SolutionFile.LoadSlnxModel(solutionText) : SolutionFile.OpenFromText(isXml: false, solutionText); - } - catch - { - // If the library cannot parse it even after the shim, leave it untouched. - return solutionText; - } - - var changed = RetargetModel(model, solutionDir, converted, isXml); - if (!changed) return solutionText; - - return SaveToText(isXml, model); - } - - /// - /// Rewrites the solution file in place when at least one entry was retargeted. - /// Returns true if the file changed. A no-op leaves the file untouched (no write). - /// - public static bool RewriteFile(SolutionFile solution, IEnumerable convertedNfprojPaths) - { - string text; - try { text = File.ReadAllText(solution.Path); } - catch { return false; } - - var dir = Path.GetDirectoryName(solution.Path)!; - var updated = Rewrite(text, dir, solution.Format, convertedNfprojPaths); - if (string.Equals(updated, text, StringComparison.Ordinal)) return false; - - File.WriteAllText(solution.Path, updated, new UTF8Encoding(false)); - return true; - } - - // Mutates the model: every project whose resolved .nfproj path is in the converted - // set is pointed at the sibling .csproj. For a classic .sln the project type is - // set to the SDK-style C# type GUID (so the serializer writes it on that entry, - // line-scoped — other still-unconverted nanoFramework projects keep their legacy - // GUID). For a .slnx (path-based) the now-redundant nanoFramework Type attribute is - // cleared so the converted entry carries no Type, exactly as before. Returns true - // if any project changed. - private static bool RetargetModel(SolutionModel model, string solutionDir, HashSet converted, bool isXml) - { - var changed = false; - foreach (var project in model.SolutionProjects) - { - var rel = project.FilePath; - if (!rel.EndsWith(".nfproj", StringComparison.OrdinalIgnoreCase)) continue; - - var abs = Path.GetFullPath(Path.Combine(solutionDir, rel)); - if (!converted.Contains(abs)) continue; - - project.FilePath = Path.ChangeExtension(rel, ".csproj"); - try { project.Type = isXml ? string.Empty : CsprojTypeGuid; } - catch (SolutionException) { /* leave the type as-is */ } - changed = true; - } - return changed; - } - - private static string SaveToText(bool isXml, SolutionModel model) - { - using var ms = new MemoryStream(); - if (isXml) - SolutionSerializers.SlnXml.SaveAsync(ms, model, CancellationToken.None).GetAwaiter().GetResult(); - else - SolutionSerializers.SlnFileV12.SaveAsync(ms, model, CancellationToken.None).GetAwaiter().GetResult(); - return new UTF8Encoding(false).GetString(ms.ToArray()); - } -} diff --git a/tools/migrate/src/NanoMigrate.Core/Solutions/SolutionScanner.cs b/tools/migrate/src/NanoMigrate.Core/Solutions/SolutionScanner.cs deleted file mode 100644 index 1717a94..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Solutions/SolutionScanner.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace NanoFramework.Migrate.Core.Solutions; - -/// -/// Discovers Visual Studio solution files (.sln and .slnx) beneath a -/// directory. Pure file-system discovery — no console, no side effects. -/// -public static class SolutionScanner -{ - /// - /// Finds every .sln and .slnx under - /// (recursively), returning absolute paths, sorted. A non-existent directory - /// yields an empty list. - /// - public static List Find(string dir) - { - if (!Directory.Exists(dir)) return new List(); - - var found = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var pattern in new[] { "*.sln", "*.slnx" }) - foreach (var sln in Directory.EnumerateFiles(dir, pattern, SearchOption.AllDirectories)) - found.Add(Path.GetFullPath(sln)); - - return found.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToList(); - } - - /// - /// Loads every solution under . Files that fail to parse - /// (malformed XML, unreadable) are skipped rather than aborting discovery. - /// - public static List Load(string dir) - { - var result = new List(); - foreach (var path in Find(dir)) - { - try { result.Add(SolutionFile.Load(path)); } - catch { /* skip a solution we cannot parse */ } - } - return result; - } -} diff --git a/tools/migrate/src/NanoMigrate.Core/Verification/SolutionBuilder.cs b/tools/migrate/src/NanoMigrate.Core/Verification/SolutionBuilder.cs deleted file mode 100644 index d194fd2..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Verification/SolutionBuilder.cs +++ /dev/null @@ -1,195 +0,0 @@ -using System.Diagnostics; - -namespace NanoFramework.Migrate.Core.Verification; - -/// -/// The outcome of verifying one target (a solution or a project) by building it. -/// -public sealed class BuildOutcome -{ - /// The solution/project that was built (absolute path). - public required string Target { get; init; } - - /// True when the build succeeded (exit code 0). - public bool Succeeded { get; init; } - - /// - /// True when the build could not be attempted at all — e.g. the dotnet - /// CLI is not on PATH. Such a target is reported as skipped, not failed, - /// and never triggers rollback. - /// - public bool Skipped { get; init; } - - /// The process exit code (or a synthetic non-zero when skipped/errored). - public int ExitCode { get; init; } - - /// A short tail of the build output, surfaced on failure for diagnosis. - public string ErrorTail { get; init; } = ""; - - /// A skipped/setup message (e.g. "dotnet not found on PATH"). - public string? Message { get; init; } -} - -/// -/// Verifies a migration by building the affected solution(s)/project(s) with the -/// dotnet CLI on PATH. Captures the exit code and a short error tail so the -/// command can render pass/fail and decide whether to offer a rollback. -/// -/// The build invocation is isolated behind so the -/// result interpretation () is unit-testable without a real -/// build. The absence of dotnet is tolerated: the target is marked skipped -/// (a warning), never failed. -/// -public sealed class SolutionBuilder -{ - /// How many trailing non-empty output lines to keep as the error tail. - public const int ErrorTailLines = 12; - - private readonly IBuildRunner _runner; - - /// Builds via the real dotnet CLI on PATH. - public SolutionBuilder() : this(new DotnetBuildRunner()) { } - - /// Builds via the supplied runner (used by tests to fake the process). - public SolutionBuilder(IBuildRunner runner) => _runner = runner; - - /// - /// Builds every target and returns one each. When - /// dotnet is unavailable, every target is reported skipped and - /// (if given) is invoked once. - /// - public List VerifyAll(IEnumerable targets, Action? onProgress = null, - Action? onSkippedToolMissing = null) - { - var results = new List(); - if (!_runner.IsAvailable) - { - onSkippedToolMissing?.Invoke(); - foreach (var t in targets) - results.Add(new BuildOutcome - { - Target = Path.GetFullPath(t), - Skipped = true, - ExitCode = -1, - Message = "dotnet not found on PATH; verification skipped", - }); - return results; - } - - foreach (var t in targets) - { - var full = Path.GetFullPath(t); - onProgress?.Invoke(full); - var (code, stdout, stderr) = _runner.Build(full); - results.Add(Interpret(full, code, stdout, stderr)); - } - return results; - } - - /// - /// Turns a raw build result (exit code + output streams) into a - /// . Pure and side-effect-free, so the pass/fail and - /// error-tail logic can be unit-tested without spawning a process. - /// - public static BuildOutcome Interpret(string target, int exitCode, string stdout, string stderr) - { - var succeeded = exitCode == 0; - return new BuildOutcome - { - Target = Path.GetFullPath(target), - Succeeded = succeeded, - ExitCode = exitCode, - ErrorTail = succeeded ? "" : Tail(stdout, stderr, ErrorTailLines), - }; - } - - /// - /// The last non-empty lines of the build output, - /// preferring lines that look like errors/warnings when there are many. Joined - /// with newlines; empty when both streams are blank. - /// - public static string Tail(string stdout, string stderr, int lines) - { - var all = (stdout ?? "") - .Replace("\r\n", "\n").Split('\n') - .Concat((stderr ?? "").Replace("\r\n", "\n").Split('\n')) - .Select(l => l.TrimEnd()) - .Where(l => l.Length > 0) - .ToList(); - if (all.Count == 0) return ""; - - // Prefer error/warning lines if present; otherwise just the tail. - var errs = all.Where(l => l.Contains("error", StringComparison.OrdinalIgnoreCase) - || l.Contains(": error", StringComparison.OrdinalIgnoreCase)).ToList(); - var pick = errs.Count > 0 ? errs : all; - return string.Join("\n", pick.Skip(Math.Max(0, pick.Count - lines))); - } -} - -/// -/// Abstracts the actual dotnet build invocation so the result interpretation -/// is testable without a real build. -/// -public interface IBuildRunner -{ - /// True when a build can be attempted (the dotnet CLI is on PATH). - bool IsAvailable { get; } - - /// Builds , returning the exit code and output streams. - (int exitCode, string stdout, string stderr) Build(string target); -} - -/// The production runner: shells the dotnet already on PATH. -public sealed class DotnetBuildRunner : IBuildRunner -{ - private readonly Lazy _available = new(ProbeDotnet); - - public bool IsAvailable => _available.Value; - - public (int exitCode, string stdout, string stderr) Build(string target) - { - var dir = Path.GetDirectoryName(Path.GetFullPath(target)) ?? "."; - var psi = new ProcessStartInfo("dotnet", $"build \"{target}\" --nologo") - { - WorkingDirectory = dir, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - try - { - using var p = Process.Start(psi)!; - var so = p.StandardOutput.ReadToEnd(); - var se = p.StandardError.ReadToEnd(); - p.WaitForExit(); - return (p.ExitCode, so, se); - } - catch (Exception ex) - { - return (-1, "", ex.Message); - } - } - - // True when `dotnet --version` runs and exits 0. Cached for the runner's lifetime. - private static bool ProbeDotnet() - { - try - { - var psi = new ProcessStartInfo("dotnet", "--version") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - using var p = Process.Start(psi); - if (p is null) return false; - p.StandardOutput.ReadToEnd(); - p.StandardError.ReadToEnd(); - p.WaitForExit(); - return p.ExitCode == 0; - } - catch { return false; } - } -} diff --git a/tools/migrate/src/NanoMigrate.Core/Verification/Verification.cs b/tools/migrate/src/NanoMigrate.Core/Verification/Verification.cs deleted file mode 100644 index 7991214..0000000 --- a/tools/migrate/src/NanoMigrate.Core/Verification/Verification.cs +++ /dev/null @@ -1,76 +0,0 @@ -namespace NanoFramework.Migrate.Core.Verification; - -/// What the command should do after the verification build(s) ran. -public enum VerifyOutcome -{ - /// Verification was not run (dry-run, --no-verify, or no targets). - NotRun, - - /// Every attempted target built (some may have been skipped). - Passed, - - /// At least one target failed to build — a rollback should be considered. - Failed, -} - -/// -/// The pure decision logic that turns a set of into a -/// verify outcome and, when it failed, into the rollback action the command should -/// take. Kept free of console/prompts so the failed-verify → rollback decision path -/// is unit-testable: the command renders/prompts around these answers. -/// -public static class Verification -{ - /// - /// The outcome of a verification pass. A failed target (built and returned - /// non-zero) means ; targets that were only - /// skipped (no dotnet) never count as failures. - /// - public static VerifyOutcome Evaluate(IReadOnlyList outcomes) - { - if (outcomes.Count == 0) return VerifyOutcome.NotRun; - if (outcomes.All(o => o.Skipped)) return VerifyOutcome.NotRun; - return outcomes.Any(o => !o.Succeeded && !o.Skipped) - ? VerifyOutcome.Failed - : VerifyOutcome.Passed; - } - - /// The number of targets that actually failed to build. - public static int FailedCount(IReadOnlyList outcomes) => - outcomes.Count(o => !o.Succeeded && !o.Skipped); - - /// - /// The rollback decision after a verification pass, given whether the session is - /// interactive and (for the interactive case) the user's yes/no answer supplied - /// as . This is the pure policy: - /// - /// verify did not fail → ; - /// failed + interactive + user said yes → ; - /// failed + interactive + user said no → ; - /// failed + non-interactive → - /// (never auto-roll-back silently; tell the user to run rollback). - /// - /// - public static RollbackDecision Decide(VerifyOutcome outcome, bool interactive, bool? userSaidYes) - { - if (outcome != VerifyOutcome.Failed) return RollbackDecision.None; - if (!interactive) return RollbackDecision.KeepNonInteractive; - return userSaidYes == true ? RollbackDecision.RollBack : RollbackDecision.KeepInteractive; - } -} - -/// What the command does about rollback after a verification. -public enum RollbackDecision -{ - /// No rollback needed (verification passed or did not run). - None, - - /// Roll back: restore originals and delete created files. - RollBack, - - /// Verification failed but the user (interactively) chose to keep the changes. - KeepInteractive, - - /// Verification failed in a non-interactive run: keep changes, exit non-zero, advise rollback. - KeepNonInteractive, -} diff --git a/tools/migrate/tests/NanoMigrate.Tests/CleanTests.cs b/tools/migrate/tests/NanoMigrate.Tests/CleanTests.cs deleted file mode 100644 index c425c68..0000000 --- a/tools/migrate/tests/NanoMigrate.Tests/CleanTests.cs +++ /dev/null @@ -1,110 +0,0 @@ -using Xunit; - -namespace NanoFramework.Migrate.Tests; - -public class CleanTests -{ - private static readonly IProjectConverter Converter = new ProjectConverter(); - - private const string Nfproj = """ - - Sample - - """; - - [Fact] - public void Clean_removes_all_nfproj_bak_and_nanomigrate_folders_and_reports_count() - { - using var dir = new TempDir(); - - // A real conversion leaves a Sample.nfproj.bak behind. - var nfproj = dir.File("a/Sample.nfproj", Nfproj); - Converter.Convert(nfproj, new ConversionOptions()); - Assert.True(File.Exists(nfproj + ".bak")); - - // A second, nested project's backup, plus a rollback journal folder. - var nfproj2 = dir.File("b/Other.nfproj", Nfproj); - Converter.Convert(nfproj2, new ConversionOptions()); - var journal = RollbackJournal.Start(dir.Path); - journal.BackupBeforeChange(dir.File("c/some.txt", "x")); - journal.Save(); - Assert.True(Directory.Exists(Path.Combine(dir.Path, RollbackJournal.FolderName))); - - var plan = BackupCleaner.Plan(dir.Path); - Assert.Equal(2, plan.BackupFiles.Count); - Assert.Single(plan.RollbackFolders); - Assert.Equal(3, plan.Total); - - var result = BackupCleaner.Remove(plan); - Assert.Empty(result.Problems); - Assert.Equal(3, result.Total); - - // Everything is gone; a re-plan finds nothing (idempotent). - Assert.False(File.Exists(nfproj + ".bak")); - Assert.False(File.Exists(nfproj2 + ".bak")); - Assert.False(Directory.Exists(Path.Combine(dir.Path, RollbackJournal.FolderName))); - Assert.True(BackupCleaner.Plan(dir.Path).IsEmpty); - } - - [Fact] - public void Clean_does_not_count_journal_internal_nfproj_backups_as_loose_backups() - { - using var dir = new TempDir(); - - // A --no-backup real migration: zero loose .bak, but the journal keeps its own - // "-Sample.nfproj.bak" copy of the original .nfproj inside .nanomigrate/. - var nfproj = dir.File("Sample.nfproj", Nfproj); - var journal = RollbackJournal.Start(dir.Path); - journal.BackupBeforeChange(nfproj); // -> .nanomigrate/rollback-/0000-Sample.nfproj.bak - journal.Save(); - - var plan = BackupCleaner.Plan(dir.Path); - - // The journal-internal .nfproj.bak must NOT be listed as a loose backup; the - // whole .nanomigrate folder is the single rollback-folder leftover instead. - Assert.Empty(plan.BackupFiles); - Assert.Single(plan.RollbackFolders); - - // Clean still removes the .nanomigrate folder wholesale. - BackupCleaner.Remove(plan); - Assert.False(Directory.Exists(Path.Combine(dir.Path, RollbackJournal.FolderName))); - } - - [Fact] - public void Clean_on_a_tree_with_no_leftovers_is_an_empty_noop() - { - using var dir = new TempDir(); - dir.File("Sample.csproj", ""); - - var plan = BackupCleaner.Plan(dir.Path); - Assert.True(plan.IsEmpty); - - var result = BackupCleaner.Remove(plan); - Assert.Equal(0, result.Total); - Assert.Empty(result.Problems); - } - - [Fact] - public void Clean_on_nonexistent_path_yields_empty_plan() - { - var plan = BackupCleaner.Plan(Path.Combine(Path.GetTempPath(), "nanomig-does-not-exist-" + Guid.NewGuid())); - Assert.True(plan.IsEmpty); - } - - [Fact] - public void Clean_does_not_touch_unrelated_files() - { - using var dir = new TempDir(); - var keep = dir.File("Keep.nfproj", Nfproj); // a live project, NOT a .bak - var keepCs = dir.File("Sample.csproj", ""); - dir.File("Sample.nfproj.bak", "backup"); // a leftover to remove - - var plan = BackupCleaner.Plan(dir.Path); - Assert.Single(plan.BackupFiles); - BackupCleaner.Remove(plan); - - Assert.True(File.Exists(keep)); - Assert.True(File.Exists(keepCs)); - Assert.False(File.Exists(dir.Combine("Sample.nfproj.bak"))); - } -} diff --git a/tools/migrate/tests/NanoMigrate.Tests/ConverterTests.cs b/tools/migrate/tests/NanoMigrate.Tests/ConverterTests.cs deleted file mode 100644 index 5f53e8e..0000000 --- a/tools/migrate/tests/NanoMigrate.Tests/ConverterTests.cs +++ /dev/null @@ -1,182 +0,0 @@ -using Xunit; - -namespace NanoFramework.Migrate.Tests; - -public class ConverterTests -{ - private static readonly IProjectConverter Converter = new ProjectConverter(); - - [Fact] - public void Already_sdk_style_project_is_skipped_and_not_written() - { - using var dir = new TempDir(); - const string sdkStyle = """ - - - netnano1.0 - - - """; - var nfproj = dir.File("Already.nfproj", sdkStyle); - var before = File.ReadAllText(nfproj); - - // A non-dry-run so we can prove nothing on disk changed. - var result = Converter.Convert(nfproj, new ConversionOptions()); - - Assert.Equal(ConvertStatus.Skipped, result.Status); - Assert.True(result.AlreadySdk); - Assert.True(File.Exists(nfproj)); // not deleted/renamed - Assert.Equal(before, File.ReadAllText(nfproj)); // not rewritten - Assert.False(File.Exists(nfproj + ".bak")); // no backup written - Assert.False(File.Exists(dir.Combine("Already.csproj"))); // nothing emitted - } - - [Fact] - public void PackageReference_version_comes_from_packages_config_when_absent() - { - using var dir = new TempDir(); - dir.File("packages.config", """ - - - - - """); - var nfproj = dir.File("Sample.nfproj", """ - - Sample - - - - - """); - - var result = Converter.Convert(nfproj, new ConversionOptions { DryRun = true }); - - Assert.Contains(result.Packages, - p => p.Key == "nanoFramework.System.Text" && p.Value == "1.2.54"); - } - - [Fact] - public void Legacy_alias_mscorlib_maps_to_corelibrary_when_no_hintpath() - { - using var dir = new TempDir(); - dir.File("packages.config", """ - - - - - """); - var nfproj = dir.File("Sample.nfproj", """ - - Sample - - - - - """); - - var result = Converter.Convert(nfproj, new ConversionOptions { DryRun = true }); - - Assert.Contains(result.Packages, - p => p.Key == "nanoFramework.CoreLibrary" && p.Value == "1.15.5"); - Assert.Empty(result.Review); - } - - [Fact] - public void Unresolved_reference_is_flagged_for_review_and_no_blank_version_emitted() - { - using var dir = new TempDir(); - // No HintPath, no packages.config — the version cannot be resolved. - var nfproj = dir.File("Sample.nfproj", """ - - Sample - - - - - """); - - var outPath = dir.Combine("Sample.csproj"); - var result = Converter.Convert(nfproj, new ConversionOptions()); // real run so we can read the emitted file - - Assert.Equal(ConvertStatus.Review, result.Status); - Assert.Contains(result.Review, r => r.Contains("SomeRandomLib")); - - var emitted = File.ReadAllText(outPath); - Assert.DoesNotContain("Version=\"\"", emitted); // never emit a blank version - Assert.DoesNotContain("SomeRandomLib", emitted); // the unresolved ref is not carried over - } - - [Fact] - public void Full_convert_emits_sdk_project_deletes_inputs_and_writes_backup() - { - using var dir = new TempDir(); - dir.File("packages.config", """ - - - - - """); - dir.File(Path.Combine("Properties", "AssemblyInfo.cs"), - "using System.Reflection;\n[assembly: AssemblyTitle(\"Sample\")]\n"); - var nfproj = dir.File("Sample.nfproj", """ - - - {00000000-0000-0000-0000-000000000000} - Sample - Sample - - - - - - - - - - - - - """); - - var result = Converter.Convert(nfproj, new ConversionOptions()); - - var csproj = dir.Combine("Sample.csproj"); - Assert.Equal(ConvertStatus.Converted, result.Status); - - // Emitted SDK-style project. - Assert.True(File.Exists(csproj)); - var emitted = File.ReadAllText(csproj); - Assert.Contains("", emitted); - Assert.Contains("netnano1.0", emitted); - Assert.Contains("Sample", emitted); - Assert.Contains("nanoFramework.CoreLibrary", emitted); - Assert.DoesNotContain("ProjectGuid", emitted); // boilerplate dropped - - // Inputs removed. - Assert.False(File.Exists(nfproj)); // .nfproj retired - Assert.False(File.Exists(dir.Combine("packages.config"))); - Assert.False(File.Exists(dir.Combine(Path.Combine("Properties", "AssemblyInfo.cs")))); - - // Backup of the original .nfproj written. - Assert.True(File.Exists(nfproj + ".bak")); - } - - [Fact] - public void Dry_run_writes_nothing() - { - using var dir = new TempDir(); - var nfproj = dir.File("Sample.nfproj", """ - - Sample - - """); - - var result = Converter.Convert(nfproj, new ConversionOptions { DryRun = true }); - - Assert.Equal(ConvertStatus.Converted, result.Status); - Assert.True(File.Exists(nfproj)); // original untouched - Assert.False(File.Exists(dir.Combine("Sample.csproj"))); // nothing emitted - Assert.False(File.Exists(nfproj + ".bak")); // no backup - } -} diff --git a/tools/migrate/tests/NanoMigrate.Tests/CpmTests.cs b/tools/migrate/tests/NanoMigrate.Tests/CpmTests.cs deleted file mode 100644 index 5eebb5c..0000000 --- a/tools/migrate/tests/NanoMigrate.Tests/CpmTests.cs +++ /dev/null @@ -1,198 +0,0 @@ -using Xunit; - -namespace NanoFramework.Migrate.Tests; - -/// -/// Central Package Management (CPM) behavior: versionless PackageReferences in the -/// project, central PackageVersion entries in Directory.Packages.props, idempotency, -/// and no-crash on already-central inputs. -/// -public class CpmTests -{ - private static readonly IProjectConverter Converter = new ProjectConverter(); - - private const string CpmPropsEnabled = """ - - - true - - - - - """; - - private const string LegacyNfproj = """ - - Sample - - - - - - """; - - private const string LegacyPackagesConfig = """ - - - - - - """; - - [Fact] - public void Cpm_emits_versionless_PackageReferences_and_seeds_central_props() - { - using var dir = new TempDir(); - var props = dir.File("Directory.Packages.props", CpmPropsEnabled); - dir.File(Path.Combine("Sample", "packages.config"), LegacyPackagesConfig); - var nfproj = dir.File(Path.Combine("Sample", "Sample.nfproj"), LegacyNfproj); - - var result = Converter.Convert(nfproj, new ConversionOptions()); - - Assert.Equal(ConvertStatus.Converted, result.Status); - Assert.Empty(result.Review); - - // Project: versionless references, never with a Version attribute. - var emitted = File.ReadAllText(dir.Combine(Path.Combine("Sample", "Sample.csproj"))); - Assert.Contains("", emitted); - Assert.Contains("", emitted); - Assert.DoesNotContain("Version=", emitted); // no version on any PackageReference - - // Central props: gained a PackageVersion for each referenced id. - var propsXml = File.ReadAllText(props); - Assert.Contains("", propsXml); - Assert.Contains("", propsXml); - - // Result preview reflects the central-props edit. - Assert.Equal(Path.GetFullPath(props), result.UpdatedPackagesProps); - Assert.Equal(2, result.AddedPackageVersions.Count); - } - - [Fact] - public void Cpm_second_project_with_same_ids_adds_no_duplicate_PackageVersion() - { - using var dir = new TempDir(); - var props = dir.File("Directory.Packages.props", CpmPropsEnabled); - - // Project A seeds the central props. - dir.File(Path.Combine("A", "packages.config"), LegacyPackagesConfig); - var a = dir.File(Path.Combine("A", "A.nfproj"), LegacyNfproj); - var resultA = Converter.Convert(a, new ConversionOptions()); - Assert.Equal(2, resultA.AddedPackageVersions.Count); - var propsAfterA = File.ReadAllText(props); - - // Project B references the same ids; the props already has them, so nothing - // is added and the file is byte-for-byte unchanged (idempotent). - dir.File(Path.Combine("B", "packages.config"), LegacyPackagesConfig); - var b = dir.File(Path.Combine("B", "B.nfproj"), LegacyNfproj); - var resultB = Converter.Convert(b, new ConversionOptions()); - - Assert.Empty(resultB.AddedPackageVersions); - Assert.Null(resultB.UpdatedPackagesProps); - var propsAfterB = File.ReadAllText(props); - Assert.Equal(propsAfterA, propsAfterB); // central props untouched the second time - - // Exactly one PackageVersion per id (no duplicates). - Assert.Equal(1, CountOccurrences(propsAfterB, "Include=\"nanoFramework.CoreLibrary\"")); - Assert.Equal(1, CountOccurrences(propsAfterB, "Include=\"nanoFramework.System.Device.Gpio\"")); - } - - [Fact] - public void Cpm_does_not_duplicate_a_preexisting_PackageVersion() - { - using var dir = new TempDir(); - // Central props already pins one of the two ids. - var props = dir.File("Directory.Packages.props", """ - - - true - - - - - - """); - dir.File(Path.Combine("Sample", "packages.config"), LegacyPackagesConfig); - var nfproj = dir.File(Path.Combine("Sample", "Sample.nfproj"), LegacyNfproj); - - var result = Converter.Convert(nfproj, new ConversionOptions()); - - var propsXml = File.ReadAllText(props); - // The pre-existing entry appears exactly once (not duplicated). - Assert.Equal(1, CountOccurrences(propsXml, "Include=\"nanoFramework.CoreLibrary\"")); - // The genuinely-missing id was added. - Assert.Contains("", propsXml); - // Only the missing id is recorded as an addition. - Assert.Single(result.AddedPackageVersions); - Assert.Equal("nanoFramework.System.Device.Gpio", result.AddedPackageVersions[0].Key); - } - - [Fact] - public void Cpm_disabled_props_does_not_trigger_versionless_emission() - { - using var dir = new TempDir(); - // A Directory.Packages.props that does NOT enable CPM. - dir.File("Directory.Packages.props", """ - - - false - - - """); - dir.File(Path.Combine("Sample", "packages.config"), LegacyPackagesConfig); - var nfproj = dir.File(Path.Combine("Sample", "Sample.nfproj"), LegacyNfproj); - - Converter.Convert(nfproj, new ConversionOptions()); - - var emitted = File.ReadAllText(dir.Combine(Path.Combine("Sample", "Sample.csproj"))); - // Versions are pinned inline because CPM is not active. - Assert.Contains("", emitted); - } - - [Fact] - public void Cpm_does_not_crash_when_input_already_under_central_props() - { - using var dir = new TempDir(); - dir.File("Directory.Packages.props", CpmPropsEnabled); - // An already-SDK-style project sitting under the central props: it is skipped - // by the Sdk-attribute guard and must not throw. - var csproj = dir.File(Path.Combine("Sample", "Sample.csproj"), """ - - netnano1.0 - - - - - """); - - var ex = Record.Exception(() => Converter.Convert(csproj, new ConversionOptions())); - Assert.Null(ex); - var result = Converter.Convert(csproj, new ConversionOptions()); - Assert.Equal(ConvertStatus.Skipped, result.Status); - } - - [Fact] - public void Cpm_option_override_forces_versionless_without_props_on_disk() - { - using var dir = new TempDir(); - // No Directory.Packages.props at all, but the option forces CPM on. - dir.File("packages.config", LegacyPackagesConfig); - var nfproj = dir.File("Sample.nfproj", LegacyNfproj); - - var result = Converter.Convert(nfproj, - new ConversionOptions { DryRun = true, CentralPackageManagement = true }); - - // Dry run writes nothing; assert via the result data. - Assert.Empty(result.Review); - Assert.Contains(result.Packages, p => p.Key == "nanoFramework.CoreLibrary"); - // No central props on disk → nothing to update, but it must not crash. - Assert.Null(result.UpdatedPackagesProps); - } - - private static int CountOccurrences(string haystack, string needle) - { - int count = 0, i = 0; - while ((i = haystack.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) { count++; i += needle.Length; } - return count; - } -} diff --git a/tools/migrate/tests/NanoMigrate.Tests/GlobTests.cs b/tools/migrate/tests/NanoMigrate.Tests/GlobTests.cs deleted file mode 100644 index 9c590d1..0000000 --- a/tools/migrate/tests/NanoMigrate.Tests/GlobTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Xunit; - -namespace NanoFramework.Migrate.Tests; - -public class GlobTests -{ - [Theory] - // single star: stays within a path segment - [InlineData("Foo.nfproj", "*.nfproj", true)] - [InlineData("Foo.csproj", "*.nfproj", false)] - [InlineData("sub/Foo.nfproj", "*.nfproj", false)] // '*' does not cross a separator - [InlineData("sub/Foo.nfproj", "*/*.nfproj", true)] - // double star: spans directories - [InlineData("a/b/c/Foo.nfproj", "**/*.nfproj", true)] - [InlineData("Foo.nfproj", "**/*.nfproj", true)] // ** can match zero directories - [InlineData("Beginner/x/y.nfproj", "Beginner/**", true)] - [InlineData("Other/x/y.nfproj", "Beginner/**", false)] - // the "Beginner/** matches Beginner itself" rule - [InlineData("Beginner", "Beginner/**", true)] - // single char wildcard - [InlineData("Foo1.nfproj", "Foo?.nfproj", true)] - [InlineData("Foo12.nfproj", "Foo?.nfproj", false)] // '?' is exactly one char - [InlineData("Foo/.nfproj", "Foo?.nfproj", false)] // '?' does not match a separator - // separator-insensitive - [InlineData("a\\b\\Foo.nfproj", "a/b/*.nfproj", true)] - // case-insensitive - [InlineData("FOO.NFPROJ", "*.nfproj", true)] - public void IsMatch(string path, string pattern, bool expected) - { - Assert.Equal(expected, Glob.IsMatch(path, pattern)); - } -} diff --git a/tools/migrate/tests/NanoMigrate.Tests/HintPathTests.cs b/tools/migrate/tests/NanoMigrate.Tests/HintPathTests.cs deleted file mode 100644 index f4c1eb2..0000000 --- a/tools/migrate/tests/NanoMigrate.Tests/HintPathTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Xunit; - -namespace NanoFramework.Migrate.Tests; - -public class HintPathTests -{ - private static readonly IProjectConverter Converter = new ProjectConverter(); - - private static string NfprojWithHintPath(string hintPath, string referenceInclude) => $""" - - - Sample - - - - {hintPath} - - - - """; - - [Fact] - public void HintPath_splits_id_and_version_at_first_numeric_segment() - { - using var dir = new TempDir(); - var nfproj = dir.File("Sample.nfproj", NfprojWithHintPath( - @"..\packages\nanoFramework.System.Device.Gpio.1.1.57\lib\System.Device.Gpio.dll", - "System.Device.Gpio")); - - var result = Converter.Convert(nfproj, new ConversionOptions { DryRun = true }); - - Assert.Contains(result.Packages, - p => p.Key == "nanoFramework.System.Device.Gpio" && p.Value == "1.1.57"); - } - - [Fact] - public void HintPath_keeps_prerelease_suffix_attached_to_version() - { - using var dir = new TempDir(); - var nfproj = dir.File("Sample.nfproj", NfprojWithHintPath( - @"..\packages\nanoFramework.CoreLibrary.2.0.0-preview.52\lib\mscorlib.dll", - "mscorlib")); - - var result = Converter.Convert(nfproj, new ConversionOptions { DryRun = true }); - - Assert.Contains(result.Packages, - p => p.Key == "nanoFramework.CoreLibrary" && p.Value == "2.0.0-preview.52"); - } -} diff --git a/tools/migrate/tests/NanoMigrate.Tests/ItemGlobTests.cs b/tools/migrate/tests/NanoMigrate.Tests/ItemGlobTests.cs deleted file mode 100644 index 0601d1f..0000000 --- a/tools/migrate/tests/NanoMigrate.Tests/ItemGlobTests.cs +++ /dev/null @@ -1,305 +0,0 @@ -using Xunit; - -namespace NanoFramework.Migrate.Tests; - -/// -/// Tests for the converter hardening that keeps migrated samples building: the SDK -/// globs Compile/EmbeddedResource/Content/None by default, so an explicit -/// Include= of a default-located file duplicates the globbed item (NETSDK1022) -/// or loses an intended exclusion. These cover the Include→Update / drop / keep -/// decisions, the Compile-subset Remove, a kept nano AssemblyInfo, and the -/// Shared Project import. -/// -public class ItemGlobTests -{ - private static readonly IProjectConverter Converter = new ProjectConverter(); - - // Convert a project (real run so we can read the emitted .csproj) and return the - // emitted text. Seeds a packages.config so packages.config-authoritative mode is - // exercised, matching real samples. - private static string ConvertAndRead(TempDir dir, string nfprojBody, string? csName = null) - { - dir.File("packages.config", """ - - - - - """); - var nfproj = dir.File((csName ?? "Sample") + ".nfproj", nfprojBody); - var result = Converter.Convert(nfproj, new ConversionOptions()); - Assert.Null(result.Error); - return File.ReadAllText(dir.Combine((csName ?? "Sample") + ".csproj")); - } - - [Fact] - public void EmbeddedResource_with_metadata_becomes_Update_not_Include() - { - using var dir = new TempDir(); - var emitted = ConvertAndRead(dir, """ - - Sample - - - nFResXFileCodeGenerator - Resources.Designer.cs - - - - """); - - // Update (not Include) so the metadata attaches to the SDK-globbed resx - // without re-including it — re-including would trip NETSDK1022. - Assert.Contains("", emitted); - Assert.DoesNotContain("nFResXFileCodeGenerator", emitted); - Assert.Contains("Resources.Designer.cs", emitted); - } - - [Fact] - public void EmbeddedResource_without_metadata_is_dropped() - { - using var dir = new TempDir(); - var emitted = ConvertAndRead(dir, """ - - Sample - - - - - """); - - // No metadata → the SDK globs it plainly, so the explicit item is dropped - // entirely (keeping it would duplicate the globbed item). - Assert.DoesNotContain("Plain.resx", emitted); - } - - [Fact] - public void None_with_metadata_becomes_Update() - { - using var dir = new TempDir(); - var emitted = ConvertAndRead(dir, """ - - Sample - - - PreserveNewest - - - - """); - - Assert.Contains("", emitted); - Assert.Contains("PreserveNewest", emitted); - } - - [Fact] - public void None_without_metadata_is_dropped() - { - using var dir = new TempDir(); - var emitted = ConvertAndRead(dir, """ - - Sample - - - - - """); - - Assert.DoesNotContain("favicon.png", emitted); - } - - [Fact] - public void Item_with_Link_is_kept_as_Include() - { - using var dir = new TempDir(); - var emitted = ConvertAndRead(dir, """ - - Sample - - - shared.txt - - - - """); - - // A linked file is NOT default-globbed, so it must stay an Include with its Link. - Assert.Contains("Include=\"..\\Shared\\shared.txt\"", emitted); - Assert.Contains("shared.txt", emitted); - Assert.DoesNotContain("Update=", emitted); - } - - [Fact] - public void External_rooted_path_is_kept_as_Include() - { - using var dir = new TempDir(); - var emitted = ConvertAndRead(dir, """ - - Sample - - - - - """); - - // Outside the project dir → SDK won't glob it → keep verbatim (no dup, no drop). - Assert.Contains("Include=\"..\\Outside\\external.resx\"", emitted); - } - - [Fact] - public void Content_with_metadata_becomes_Update() - { - using var dir = new TempDir(); - var emitted = ConvertAndRead(dir, """ - - Sample - - - Always - - - - """); - - Assert.Contains("", emitted); - Assert.Contains("Always", emitted); - } - - [Fact] - public void Compile_subset_emits_Remove_for_unlisted_on_disk_cs() - { - using var dir = new TempDir(); - // On disk there are TWO .cs files, but the legacy project compiles only one. - // The SDK globs **/*.cs, so the unlisted file must be Removed or it gets - // compiled (duplicate definitions in real samples). - dir.File("Test.cs", "// compiled\n"); - dir.File("Alternate.cs", "// NOT compiled by the legacy project\n"); - var emitted = ConvertAndRead(dir, """ - - Sample - - - - - """); - - Assert.Contains("", emitted); - Assert.DoesNotContain("Remove=\"Test.cs\"", emitted); // the listed file is not removed - } - - [Fact] - public void Compile_listing_all_on_disk_cs_emits_no_Remove() - { - using var dir = new TempDir(); - dir.File("Program.cs", "// compiled\n"); - var emitted = ConvertAndRead(dir, """ - - Sample - - - - - """); - - // The explicit set equals the on-disk set → nothing to exclude. - Assert.DoesNotContain("Compile Remove", emitted); - } - - [Fact] - public void AssemblyInfo_with_native_version_is_kept_and_generation_disabled() - { - using var dir = new TempDir(); - var aiPath = dir.File(Path.Combine("Properties", "AssemblyInfo.cs"), - "using System.Reflection;\n[assembly: AssemblyNativeVersion(\"1.0.0.0\")]\n"); - var nfproj = dir.File("Lib.nfproj", """ - - - Library - Lib - - - - - - """); - - var result = Converter.Convert(nfproj, new ConversionOptions()); - var emitted = File.ReadAllText(dir.Combine("Lib.csproj")); - - // The MetadataProcessor needs AssemblyNativeVersion when generating stubs, and - // the SDK never generates it — so the file is KEPT (not deleted) and generation - // is turned off so the standard attributes don't collide with generated ones. - Assert.True(File.Exists(aiPath)); // not deleted - Assert.Contains("false", emitted); - Assert.DoesNotContain(aiPath, result.DeletedFiles); // not previewed as a deletion - } - - [Fact] - public void Ordinary_AssemblyInfo_is_still_deleted_and_generation_left_on() - { - using var dir = new TempDir(); - var aiPath = dir.File(Path.Combine("Properties", "AssemblyInfo.cs"), - "using System.Reflection;\n[assembly: AssemblyTitle(\"Sample\")]\n"); - var nfproj = dir.File("Sample.nfproj", """ - - Sample - - - - - """); - - Converter.Convert(nfproj, new ConversionOptions()); - var emitted = File.ReadAllText(dir.Combine("Sample.csproj")); - - // No nano-specific attribute → original behavior: delete the file, let the SDK - // generate the assembly info. - Assert.False(File.Exists(aiPath)); - Assert.DoesNotContain("GenerateAssemblyInfo", emitted); - } - - [Fact] - public void Shared_project_import_is_carried_through() - { - using var dir = new TempDir(); - var emitted = ConvertAndRead(dir, """ - - - $(MSBuildExtensionsPath)\nanoFramework\v1.0\ - - - Sample - - - - """); - - // The Shared Project import carries source the SDK would otherwise never see — - // it must survive. The NFProjectSystem.* imports are SDK boilerplate, dropped. - Assert.Contains("", emitted); - Assert.DoesNotContain("NFProjectSystem", emitted); - } - - [Fact] - public void Unknown_top_level_import_is_flagged_for_review() - { - using var dir = new TempDir(); - dir.File("packages.config", """ - - - - - """); - var nfproj = dir.File("Sample.nfproj", """ - - Sample - - - """); - - var result = Converter.Convert(nfproj, new ConversionOptions { DryRun = true }); - - Assert.Contains(result.Review, r => r.Contains("Custom.targets")); - } -} diff --git a/tools/migrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj b/tools/migrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj deleted file mode 100644 index 2cbe574..0000000 --- a/tools/migrate/tests/NanoMigrate.Tests/NanoMigrate.Tests.csproj +++ /dev/null @@ -1,36 +0,0 @@ - - - - net8.0 - enable - enable - NanoFramework.Migrate.Tests - false - true - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tools/migrate/tests/NanoMigrate.Tests/OutputTypeTests.cs b/tools/migrate/tests/NanoMigrate.Tests/OutputTypeTests.cs deleted file mode 100644 index 8007fab..0000000 --- a/tools/migrate/tests/NanoMigrate.Tests/OutputTypeTests.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Xunit; - -namespace NanoFramework.Migrate.Tests; - -public class OutputTypeTests -{ - private static readonly IProjectConverter Converter = new ProjectConverter(); - - [Fact] - public void OutputType_Exe_is_preserved_so_apps_stay_executables() - { - using var dir = new TempDir(); - dir.File("packages.config", """ - - - - - """); - var nfproj = dir.File("App.nfproj", """ - - - Exe - App - - - """); - - var result = Converter.Convert(nfproj, new ConversionOptions()); - - Assert.Equal(ConvertStatus.Converted, result.Status); - var csproj = File.ReadAllText(dir.Combine("App.csproj")); - // Regression: OutputType used to be silently dropped, turning apps into libraries - // (CS8805 for top-level-statement apps). It must survive conversion. - Assert.Contains("Exe", csproj); - } -} diff --git a/tools/migrate/tests/NanoMigrate.Tests/PackageResolutionTests.cs b/tools/migrate/tests/NanoMigrate.Tests/PackageResolutionTests.cs deleted file mode 100644 index b60e7e7..0000000 --- a/tools/migrate/tests/NanoMigrate.Tests/PackageResolutionTests.cs +++ /dev/null @@ -1,163 +0,0 @@ -using Xunit; - -namespace NanoFramework.Migrate.Tests; - -/// -/// Tests for the packages.config-first resolution strategy and Central Package -/// Management (CPM) support added to . -/// -public class PackageResolutionTests -{ - private static readonly IProjectConverter Converter = new ProjectConverter(); - - // A WiFiAP-like project whose elements are bare assembly names that - // routinely differ from the NuGet package id, while packages.config carries the - // real nano package ids+versions. - private const string WiFiApLikeNfproj = """ - - - WifiAP - WifiAP - - - - - - - - - """; - - private const string WiFiApLikePackagesConfig = """ - - - - - - - - """; - - [Fact] - public void PackagesConfig_is_authoritative_emits_exactly_its_ids_and_versions() - { - using var dir = new TempDir(); - dir.File("packages.config", WiFiApLikePackagesConfig); - var nfproj = dir.File("WifiAP.nfproj", WiFiApLikeNfproj); - - var result = Converter.Convert(nfproj, new ConversionOptions { DryRun = true }); - - Assert.Equal(ConvertStatus.Converted, result.Status); - Assert.Empty(result.Review); // no assembly/id mismatch flags - - // The emitted package set is exactly the packages.config entries, verbatim. - var expected = new Dictionary - { - ["nanoFramework.System.Device.Gpio"] = "1.1.57", - ["nanoFramework.System.Net.Http.Server"] = "1.5.207", - ["nanoFramework.System.Threading"] = "1.1.52", - ["nanoFramework.CoreLibrary"] = "1.17.11", - }; - Assert.Equal(expected.Count, result.Packages.Count); - foreach (var kv in expected) - Assert.Contains(result.Packages, p => p.Key == kv.Key && p.Value == kv.Value); - - // The bare assembly names from never become packages. - Assert.DoesNotContain(result.Packages, p => p.Key == "System.Device.Gpio"); - Assert.DoesNotContain(result.Packages, p => p.Key == "System.Net.Http"); - Assert.DoesNotContain(result.Packages, p => p.Key == "System.Threading"); - Assert.DoesNotContain(result.Packages, p => p.Key == "mscorlib"); - } - - [Fact] - public void PackagesConfig_first_emits_no_Reference_elements() - { - using var dir = new TempDir(); - dir.File("packages.config", WiFiApLikePackagesConfig); - var nfproj = dir.File("WifiAP.nfproj", WiFiApLikeNfproj); - - Converter.Convert(nfproj, new ConversionOptions()); // real run - - var emitted = File.ReadAllText(dir.Combine("WifiAP.csproj")); - Assert.DoesNotContain(" - Sample - - - ..\packages\nanoFramework.System.Device.Gpio.1.1.57\lib\System.Device.Gpio.dll - - - - """); - - var result = Converter.Convert(nfproj, new ConversionOptions { DryRun = true }); - - Assert.Empty(result.Review); - Assert.Contains(result.Packages, - p => p.Key == "nanoFramework.System.Device.Gpio" && p.Value == "1.1.57"); - } - - [Fact] - public void No_packages_config_unresolved_reference_is_flagged() - { - using var dir = new TempDir(); - // No packages.config and no HintPath: unresolvable → review, never blank version. - var nfproj = dir.File("Sample.nfproj", """ - - Sample - - - - - """); - - var result = Converter.Convert(nfproj, new ConversionOptions()); - - Assert.Equal(ConvertStatus.Review, result.Status); - Assert.Contains(result.Review, r => r.Contains("MysteryLib")); - var emitted = File.ReadAllText(dir.Combine("Sample.csproj")); - Assert.DoesNotContain("Version=\"\"", emitted); - } - - [Fact] - public void ProjectCapability_TestContainer_is_preserved_not_flagged() - { - using var dir = new TempDir(); - dir.File("packages.config", """ - - - - - - """); - var nfproj = dir.File("NFUnitTest.nfproj", """ - - - - - - NFUnitTest - true - UnitTest - - - """); - - Converter.Convert(nfproj, new ConversionOptions()); - - var emitted = File.ReadAllText(dir.Combine("NFUnitTest.csproj")); - Assert.Contains("", emitted); - Assert.Contains("true", emitted); - Assert.Contains("UnitTest", emitted); - } -} diff --git a/tools/migrate/tests/NanoMigrate.Tests/ProjectScannerTests.cs b/tools/migrate/tests/NanoMigrate.Tests/ProjectScannerTests.cs deleted file mode 100644 index a1f1bde..0000000 --- a/tools/migrate/tests/NanoMigrate.Tests/ProjectScannerTests.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Xunit; - -namespace NanoFramework.Migrate.Tests; - -public class ProjectScannerTests -{ - private const string Stub = ""; - - [Fact] - public void ResolveProjects_returns_single_file_when_path_is_an_nfproj() - { - using var dir = new TempDir(); - var nfproj = dir.File("Solo.nfproj", Stub); - - var targets = ProjectScanner.ResolveProjects(nfproj, glob: null); - - Assert.Single(targets); - Assert.Equal(Path.GetFullPath(nfproj), targets[0]); - } - - [Fact] - public void ResolveProjects_enumerates_directory_recursively_sorted() - { - using var dir = new TempDir(); - dir.File(Path.Combine("b", "Two.nfproj"), Stub); - dir.File(Path.Combine("a", "One.nfproj"), Stub); - dir.File("ReadMe.txt", "not a project"); - - var targets = ProjectScanner.ResolveProjects(dir.Path, glob: null); - - Assert.Equal(2, targets.Count); - Assert.EndsWith("One.nfproj", targets[0]); // sorted: a/ before b/ - Assert.EndsWith("Two.nfproj", targets[1]); - } - - [Fact] - public void ResolveProjects_applies_glob_filter() - { - using var dir = new TempDir(); - dir.File(Path.Combine("Beginner", "Blink.nfproj"), Stub); - dir.File(Path.Combine("Advanced", "Threads.nfproj"), Stub); - - var targets = ProjectScanner.ResolveProjects(dir.Path, glob: "Beginner/**"); - - Assert.Single(targets); - Assert.EndsWith("Blink.nfproj", targets[0]); - } - - [Fact] - public void ResolveProjects_returns_empty_for_missing_path() - { - var targets = ProjectScanner.ResolveProjects(Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid()), glob: null); - Assert.Empty(targets); - } -} diff --git a/tools/migrate/tests/NanoMigrate.Tests/ReportingTests.cs b/tools/migrate/tests/NanoMigrate.Tests/ReportingTests.cs deleted file mode 100644 index 174e6b8..0000000 --- a/tools/migrate/tests/NanoMigrate.Tests/ReportingTests.cs +++ /dev/null @@ -1,204 +0,0 @@ -using NanoFramework.Migrate.Cli.Commands; -using NanoFramework.Migrate.Cli.Rendering; -using Spectre.Console.Cli; -using Xunit; - -namespace NanoFramework.Migrate.Tests; - -public class ReportingTests -{ - // A representative set of project outcomes: one clean conversion (with packages), - // one skipped (already SDK-style), one flagged for review (with a name that needs - // HTML escaping), and one error. - private static MigrationReport BuildRepresentativeReport(DateTime utc) - { - var converted = new ReportEntry - { - RelativePath = "src/Clean.nfproj", - Status = ConvertStatus.Converted, - Packages = new[] - { - new KeyValuePair("nanoFramework.System.Text", "1.2.54"), - }, - }; - var skipped = new ReportEntry - { - RelativePath = "src/Already.nfproj", - Status = ConvertStatus.Skipped, - }; - var review = new ReportEntry - { - // A name with characters that MUST be escaped in HTML. - RelativePath = "src/A&B.nfproj", - Status = ConvertStatus.Review, - Review = new[] { "HintPath '..\\bin\\' could not be resolved & needs review" }, - }; - var error = new ReportEntry - { - RelativePath = "src/Boom.nfproj", - Status = ConvertStatus.Error, - Error = "the project file could not be parsed", - }; - - return new MigrationReport - { - RootPath = @"C:\repo", - GeneratedUtc = utc, - DryRun = false, - Projects = new[] { converted, skipped, review, error }, - AffectedSolutions = new[] { "Sample.sln" }, - Verify = new[] - { - new ReportVerifyEntry { Target = "Sample.sln", Succeeded = true, Skipped = false }, - }, - }; - } - - [Fact] - public void Markdown_contains_totals_a_row_per_project_and_review_section() - { - var report = BuildRepresentativeReport(new DateTime(2026, 6, 16, 12, 0, 0, DateTimeKind.Utc)); - var md = MarkdownReportWriter.Render(report); - - // Totals line. - Assert.Contains("converted 1", md); - Assert.Contains("skipped 1", md); - Assert.Contains("flagged 1", md); - Assert.Contains("errors 1", md); - Assert.Contains("total 4", md); - - // A row per project (the table uses the relative path in the first cell). - Assert.Contains("src/Clean.nfproj", md); - Assert.Contains("src/Already.nfproj", md); - Assert.Contains("src/Boom.nfproj", md); - - // The package id/version appear in the converted row. - Assert.Contains("nanoFramework.System.Text", md); - Assert.Contains("1.2.54", md); - - // The manual review section lists the flagged item. - Assert.Contains("## Manual review", md); - Assert.Contains("HintPath", md); - - // The verification section is present. - Assert.Contains("## Verification", md); - } - - [Fact] - public void Html_contains_rows_status_css_classes_and_escapes_special_characters() - { - var report = BuildRepresentativeReport(new DateTime(2026, 6, 16, 12, 0, 0, DateTimeKind.Utc)); - var html = HtmlReportWriter.Render(report); - - // Self-contained: inline style, no external assets. - Assert.Contains("