From ec25b819a154ea01163913808a4adda7d5b38684 Mon Sep 17 00:00:00 2001 From: Vlada Shubina Date: Wed, 28 Dec 2022 15:43:07 +0100 Subject: [PATCH] MSBuild validate task PoC --- .../PublicAPI.Unshipped.txt | 1 + .../Settings/Scanner.cs | 38 +++- .../TemplatePackage.csproj | 21 +++ .../.template.config/template.json | 10 + .../content/TemplateWithSourceName/foo.cs | 6 + .../TemplatePackage.csproj | 21 +++ .../.template.config/template.json | 10 + .../content/TemplateWithSourceName/foo.cs | 6 + .../ValidateTemplatesTests.cs | 70 +++++++ ...soft.TemplateEngine.Authoring.Tasks.csproj | 2 + .../Tasks/ValidateTemplates.cs | 174 ++++++++++++++++++ ...osoft.TemplateEngine.Authoring.Tasks.props | 12 +- ...oft.TemplateEngine.Authoring.Tasks.targets | 7 + 13 files changed, 372 insertions(+), 6 deletions(-) create mode 100644 test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingName/TemplatePackage.csproj create mode 100644 test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingName/content/TemplateWithSourceName/.template.config/template.json create mode 100644 test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingName/content/TemplateWithSourceName/foo.cs create mode 100644 test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingOptionalData/TemplatePackage.csproj create mode 100644 test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingOptionalData/content/TemplateWithSourceName/.template.config/template.json create mode 100644 test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingOptionalData/content/TemplateWithSourceName/foo.cs create mode 100644 test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/ValidateTemplatesTests.cs create mode 100644 tools/Microsoft.TemplateEngine.Authoring.Tasks/Tasks/ValidateTemplates.cs diff --git a/src/Microsoft.TemplateEngine.Edge/PublicAPI.Unshipped.txt b/src/Microsoft.TemplateEngine.Edge/PublicAPI.Unshipped.txt index e90eee9e78..3930ffa46c 100644 --- a/src/Microsoft.TemplateEngine.Edge/PublicAPI.Unshipped.txt +++ b/src/Microsoft.TemplateEngine.Edge/PublicAPI.Unshipped.txt @@ -1,4 +1,5 @@ Microsoft.TemplateEngine.Edge.DefaultEnvironment.DefaultEnvironment(System.Collections.Generic.IReadOnlyDictionary! environmentVariables) -> void +Microsoft.TemplateEngine.Edge.Settings.Scanner.ScanAsync(string! mountPointUri, bool logValidationResults = true, bool returnInvalidTemplates = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Microsoft.TemplateEngine.Edge.Settings.Scanner.ScanAsync(string! mountPointUri, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Microsoft.TemplateEngine.Edge.Settings.ScanResult.Templates.get -> System.Collections.Generic.IReadOnlyList! Microsoft.TemplateEngine.Edge.Settings.ITemplateInfoHostJsonCache.HostData.get -> string? diff --git a/src/Microsoft.TemplateEngine.Edge/Settings/Scanner.cs b/src/Microsoft.TemplateEngine.Edge/Settings/Scanner.cs index bb3575473c..1b5dac5b61 100644 --- a/src/Microsoft.TemplateEngine.Edge/Settings/Scanner.cs +++ b/src/Microsoft.TemplateEngine.Edge/Settings/Scanner.cs @@ -85,7 +85,28 @@ public Task ScanAsync(string mountPointUri, CancellationToken cancel } MountPointScanSource source = GetOrCreateMountPointScanInfoForInstallSource(mountPointUri); cancellationToken.ThrowIfCancellationRequested(); - return ScanMountPointForTemplatesAsync(source, cancellationToken); + return ScanMountPointForTemplatesAsync(source, cancellationToken: cancellationToken); + } + + /// + /// Scans mount point for templates. + /// + /// + /// The mount point will not be disposed by the . Use to dispose mount point. + /// + public Task ScanAsync( + string mountPointUri, + bool logValidationResults = true, + bool returnInvalidTemplates = false, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(mountPointUri)) + { + throw new ArgumentException($"{nameof(mountPointUri)} should not be null or empty"); + } + MountPointScanSource source = GetOrCreateMountPointScanInfoForInstallSource(mountPointUri); + cancellationToken.ThrowIfCancellationRequested(); + return ScanMountPointForTemplatesAsync(source, logValidationResults, returnInvalidTemplates, cancellationToken); } private MountPointScanSource GetOrCreateMountPointScanInfoForInstallSource(string sourceLocation) @@ -207,7 +228,11 @@ private bool TryCopyForNonFileSystemBasedMountPoints(IMountPoint mountPoint, str return true; } - private async Task ScanMountPointForTemplatesAsync(MountPointScanSource source, CancellationToken cancellationToken) + private async Task ScanMountPointForTemplatesAsync( + MountPointScanSource source, + bool logValidationResults = true, + bool returnInvalidTemplates = false, + CancellationToken cancellationToken = default) { _ = source ?? throw new ArgumentNullException(nameof(source)); @@ -215,15 +240,18 @@ private async Task ScanMountPointForTemplatesAsync(MountPointScanSou foreach (IGenerator generator in _environmentSettings.Components.OfType()) { IReadOnlyList templateList = await generator.GetTemplatesFromMountPointAsync(source.MountPoint, cancellationToken).ConfigureAwait(false); - LogScanningResults(source, templateList, generator); + if (logValidationResults) + { + LogScanningResults(source, templateList, generator); + } - IEnumerable validTemplates = templateList.Where(t => t.IsValid); + IEnumerable validTemplates = templateList.Where(t => t.IsValid || returnInvalidTemplates); templates.AddRange(validTemplates); source.FoundTemplates |= validTemplates.Any(); } //backward compatibility - var localizationLocators = templates.SelectMany(t => t.Localizations.Values.Where(li => li.IsValid)).ToList(); + var localizationLocators = templates.SelectMany(t => t.Localizations.Values.Where(li => li.IsValid || returnInvalidTemplates)).ToList(); return new ScanResult(source.MountPoint, templates, localizationLocators, Array.Empty<(string, Type, IIdentifiedComponent)>()); } diff --git a/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingName/TemplatePackage.csproj b/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingName/TemplatePackage.csproj new file mode 100644 index 0000000000..8f1f9a41af --- /dev/null +++ b/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingName/TemplatePackage.csproj @@ -0,0 +1,21 @@ + + + net7.0 + Template + TemplatePackage + Microsoft + TemplatePackage + true + false + content + $(NoWarn);NU5128 + true + false + true + true + + + + + + diff --git a/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingName/content/TemplateWithSourceName/.template.config/template.json b/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingName/content/TemplateWithSourceName/.template.config/template.json new file mode 100644 index 0000000000..8d28ccb9f4 --- /dev/null +++ b/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingName/content/TemplateWithSourceName/.template.config/template.json @@ -0,0 +1,10 @@ +{ + "author": "Test Asset", + "classifications": [ "Test Asset" ], + "description": "Test description", + "generatorVersions": "[1.0.0.0-*)", + "groupIdentity": "TestAssets.TemplateWithSourceName", + "precedence": "100", + "identity": "TestAssets.TemplateWithSourceName", + "sourceName": "bar" +} diff --git a/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingName/content/TemplateWithSourceName/foo.cs b/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingName/content/TemplateWithSourceName/foo.cs new file mode 100644 index 0000000000..1fc762b1f6 --- /dev/null +++ b/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingName/content/TemplateWithSourceName/foo.cs @@ -0,0 +1,6 @@ +namespace TemplateWithSourceName +{ + internal class Foo + { + } +} diff --git a/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingOptionalData/TemplatePackage.csproj b/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingOptionalData/TemplatePackage.csproj new file mode 100644 index 0000000000..8f1f9a41af --- /dev/null +++ b/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingOptionalData/TemplatePackage.csproj @@ -0,0 +1,21 @@ + + + net7.0 + Template + TemplatePackage + Microsoft + TemplatePackage + true + false + content + $(NoWarn);NU5128 + true + false + true + true + + + + + + diff --git a/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingOptionalData/content/TemplateWithSourceName/.template.config/template.json b/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingOptionalData/content/TemplateWithSourceName/.template.config/template.json new file mode 100644 index 0000000000..e3a8f04a73 --- /dev/null +++ b/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingOptionalData/content/TemplateWithSourceName/.template.config/template.json @@ -0,0 +1,10 @@ +{ + "name": "TemplateWithSourceName", + "description": "Test description", + "generatorVersions": "[1.0.0.0-*)", + "groupIdentity": "TestAssets.TemplateWithSourceName", + "precedence": "100", + "identity": "TestAssets.TemplateWithSourceName", + "shortName": "TestAssets.TemplateWithSourceName", + "sourceName": "bar" +} diff --git a/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingOptionalData/content/TemplateWithSourceName/foo.cs b/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingOptionalData/content/TemplateWithSourceName/foo.cs new file mode 100644 index 0000000000..1fc762b1f6 --- /dev/null +++ b/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/Resources/InvalidTemplatePackage_MissingOptionalData/content/TemplateWithSourceName/foo.cs @@ -0,0 +1,6 @@ +namespace TemplateWithSourceName +{ + internal class Foo + { + } +} diff --git a/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/ValidateTemplatesTests.cs b/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/ValidateTemplatesTests.cs new file mode 100644 index 0000000000..4acaa78def --- /dev/null +++ b/test/Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests/ValidateTemplatesTests.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using FluentAssertions; +using Microsoft.TemplateEngine.CommandUtils; +using Microsoft.TemplateEngine.TestHelper; +using Microsoft.TemplateEngine.Tests; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.TemplateEngine.Authoring.Tasks.IntegrationTests +{ + public class ValidateTemplatesTests : TestBase + { + private readonly ITestOutputHelper _log; + + public ValidateTemplatesTests(ITestOutputHelper log) + { + _log = log; + } + + [Fact] + public void CanRunValidateTask_OnError() + { + string tmpDir = TestUtils.CreateTemporaryFolder(); + TestUtils.DirectoryCopy("Resources/InvalidTemplatePackage_MissingName", tmpDir, true); + TestUtils.SetupNuGetConfigForPackagesLocation(tmpDir, ShippingPackagesLocation); + + new DotnetCommand(_log, "add", "TemplatePackage.csproj", "package", "Microsoft.TemplateEngine.Authoring.Tasks", "--prerelease") + .WithoutTelemetry() + .WithWorkingDirectory(tmpDir) + .Execute() + .Should() + .Pass(); + + new DotnetCommand(_log, "build") + .WithoutTelemetry() + .WithWorkingDirectory(tmpDir) + .Execute() + .Should() + .Fail() + .And.HaveStdOutContaining("Template configuration error MV002: Missing 'name'.") + .And.HaveStdOutContaining("Template configuration error MV003: Missing 'shortName'."); + } + + [Fact] + public void CanRunValidateTask_OnInfo() + { + string tmpDir = TestUtils.CreateTemporaryFolder(); + TestUtils.DirectoryCopy("Resources/InvalidTemplatePackage_MissingOptionalData", tmpDir, true); + TestUtils.SetupNuGetConfigForPackagesLocation(tmpDir, ShippingPackagesLocation); + + new DotnetCommand(_log, "add", "TemplatePackage.csproj", "package", "Microsoft.TemplateEngine.Authoring.Tasks", "--prerelease") + .WithoutTelemetry() + .WithWorkingDirectory(tmpDir) + .Execute() + .Should() + .Pass(); + + new DotnetCommand(_log, "build") + .WithoutTelemetry() + .WithWorkingDirectory(tmpDir) + .Execute() + .Should() + .Pass() + .And.HaveStdOutContaining("Template configuration message MV006: Missing 'author'.") + .And.HaveStdOutContaining("Template configuration message MV010: Missing 'classifications'."); + } + } +} diff --git a/tools/Microsoft.TemplateEngine.Authoring.Tasks/Microsoft.TemplateEngine.Authoring.Tasks.csproj b/tools/Microsoft.TemplateEngine.Authoring.Tasks/Microsoft.TemplateEngine.Authoring.Tasks.csproj index c02b95d0ac..e1424af2b4 100644 --- a/tools/Microsoft.TemplateEngine.Authoring.Tasks/Microsoft.TemplateEngine.Authoring.Tasks.csproj +++ b/tools/Microsoft.TemplateEngine.Authoring.Tasks/Microsoft.TemplateEngine.Authoring.Tasks.csproj @@ -15,6 +15,8 @@ + + diff --git a/tools/Microsoft.TemplateEngine.Authoring.Tasks/Tasks/ValidateTemplates.cs b/tools/Microsoft.TemplateEngine.Authoring.Tasks/Tasks/ValidateTemplates.cs new file mode 100644 index 0000000000..6c54288923 --- /dev/null +++ b/tools/Microsoft.TemplateEngine.Authoring.Tasks/Tasks/ValidateTemplates.cs @@ -0,0 +1,174 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Build.Framework; +using Microsoft.Extensions.Logging; +using Microsoft.TemplateEngine.Abstractions; +using Microsoft.TemplateEngine.Authoring.Tasks.Utilities; +using Microsoft.TemplateEngine.Edge; +using Microsoft.TemplateEngine.Edge.Settings; + +namespace Microsoft.TemplateEngine.Authoring.Tasks.Tasks +{ + /// + /// A task that exposes template localization functionality of + /// Microsoft.TemplateEngine.TemplateLocalizer through MSBuild. + /// + public sealed class ValidateTemplates : Build.Utilities.Task, ICancelableTask + { + private volatile CancellationTokenSource? _cancellationTokenSource; + + /// + /// Gets or sets the path to the template(s) to be validated. + /// + [Required] + public string? TemplateLocation { get; set; } + + public override bool Execute() + { + if (string.IsNullOrWhiteSpace(TemplateLocation)) + { + Log.LogError("The property 'TemplateLocation' should be set for 'ValidateTemplates' target."); + return false; + } + + string templateLocation = Path.GetFullPath(TemplateLocation); + + using var loggerProvider = new MSBuildLoggerProvider(Log); + ILoggerFactory msbuildLoggerFactory = new LoggerFactory(new[] { loggerProvider }); + + using CancellationTokenSource cancellationTokenSource = GetOrCreateCancellationTokenSource(); + CancellationToken cancellationToken = cancellationTokenSource.Token; + + try + { + using IEngineEnvironmentSettings settings = SetupSettings(msbuildLoggerFactory); + Scanner scanner = new(settings); + ScanResult scanResult = Task.Run(async () => await scanner.ScanAsync( + templateLocation!, + logValidationResults: false, + returnInvalidTemplates: true, + cancellationToken).ConfigureAwait(false)).GetAwaiter().GetResult(); + + cancellationToken.ThrowIfCancellationRequested(); + + LogResults(scanResult); + return !Log.HasLoggedErrors && !cancellationToken.IsCancellationRequested; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex); + return false; + } + } + + public void Cancel() => GetOrCreateCancellationTokenSource().Cancel(); + + private IEngineEnvironmentSettings SetupSettings(ILoggerFactory loggerFactory) + { + var builtIns = new List<(Type InterfaceType, IIdentifiedComponent Instance)>(); + builtIns.AddRange(Microsoft.TemplateEngine.Orchestrator.RunnableProjects.Components.AllComponents); + builtIns.AddRange(Microsoft.TemplateEngine.Edge.Components.AllComponents); + + ITemplateEngineHost host = new DefaultTemplateEngineHost("template-validator", "1.0", builtIns: builtIns, loggerFactory: loggerFactory); + IEngineEnvironmentSettings engineEnvironmentSettings = new EngineEnvironmentSettings(host, virtualizeSettings: true); + + return engineEnvironmentSettings; + } + + private void LogResults(ScanResult scanResult) + { + Log.LogMessage("Location '{0}': found {1} templates.", scanResult.MountPoint.MountPointUri, scanResult.Templates.Count); + foreach (IScanTemplateInfo template in scanResult.Templates) + { + string templateDisplayName = GetTemplateDisplayName(template); + StringBuilder sb = new(); + + LogValidationEntries("Template configuration", template.ValidationErrors); + foreach (KeyValuePair locator in template.Localizations) + { + ILocalizationLocator localizationInfo = locator.Value; + LogValidationEntries("Localization", localizationInfo.ValidationErrors); + } + } + + static string GetTemplateDisplayName(IScanTemplateInfo template) + { + string templateName = string.IsNullOrEmpty(template.Name) ? "" : template.Name; + return $"'{templateName}' ({template.Identity})"; + } + + void LogValidationEntries(string subCategory, IReadOnlyList errors) + { + foreach (IValidationEntry error in errors.OrderByDescending(e => e.Severity)) + { + switch (error.Severity) + { + case IValidationEntry.SeverityLevel.Error: + Log.LogError( + subcategory: subCategory, + errorCode: error.Code, + helpKeyword: string.Empty, + file: error.Location?.Filename ?? string.Empty, + lineNumber: error.Location?.LineNumber ?? 0, + columnNumber: error.Location?.Position ?? 0, + endLineNumber: 0, + endColumnNumber: 0, + message: error.ErrorMessage); + break; + case IValidationEntry.SeverityLevel.Warning: + Log.LogWarning( + subcategory: subCategory, + warningCode: error.Code, + helpKeyword: string.Empty, + file: error.Location?.Filename ?? string.Empty, + lineNumber: error.Location?.LineNumber ?? 0, + columnNumber: error.Location?.Position ?? 0, + endLineNumber: 0, + endColumnNumber: 0, + message: error.ErrorMessage); + break; + case IValidationEntry.SeverityLevel.Info: + Log.LogMessage( + subcategory: subCategory, + code: error.Code, + helpKeyword: string.Empty, + file: error.Location?.Filename ?? string.Empty, + lineNumber: error.Location?.LineNumber ?? 0, + columnNumber: error.Location?.Position ?? 0, + endLineNumber: 0, + endColumnNumber: 0, + MessageImportance.High, + message: error.ErrorMessage); + break; + } + } + } + + } + + private CancellationTokenSource GetOrCreateCancellationTokenSource() + { + if (_cancellationTokenSource != null) + { + return _cancellationTokenSource; + } + + CancellationTokenSource cts = new(); + if (Interlocked.CompareExchange(ref _cancellationTokenSource, cts, null) != null) + { + // Reference was already set. This instance is not needed. + cts.Dispose(); + } + + return _cancellationTokenSource; + } + } +} diff --git a/tools/Microsoft.TemplateEngine.Authoring.Tasks/build/Microsoft.TemplateEngine.Authoring.Tasks.props b/tools/Microsoft.TemplateEngine.Authoring.Tasks/build/Microsoft.TemplateEngine.Authoring.Tasks.props index 4690c985cd..ae0b51223b 100644 --- a/tools/Microsoft.TemplateEngine.Authoring.Tasks/build/Microsoft.TemplateEngine.Authoring.Tasks.props +++ b/tools/Microsoft.TemplateEngine.Authoring.Tasks/build/Microsoft.TemplateEngine.Authoring.Tasks.props @@ -1,6 +1,16 @@  + true + + + + . +