diff --git a/src/AXSharp.compiler/src/AXSharp.Compiler.Abstractions/ICompilerOptions.cs b/src/AXSharp.compiler/src/AXSharp.Compiler.Abstractions/ICompilerOptions.cs index 07ca8c91..2e00297b 100644 --- a/src/AXSharp.compiler/src/AXSharp.Compiler.Abstractions/ICompilerOptions.cs +++ b/src/AXSharp.compiler/src/AXSharp.Compiler.Abstractions/ICompilerOptions.cs @@ -31,4 +31,12 @@ public interface ICompilerOptions /// string? UiHostProject { get; set; } + /// + /// Source-origin detection mode controlling the provenance attributes emitted onto generated + /// twins: auto (detect a git repository with a remote, otherwise fall back to apax + /// package identity), apax (force apax package mode, deterministic, no commit SHA), or + /// off (legacy: src-relative paths and no assembly-level attribute). + /// + string SourceOrigin { get; set; } + } \ No newline at end of file diff --git a/src/AXSharp.compiler/src/AXSharp.Compiler/AXSharp.Compiler.csproj b/src/AXSharp.compiler/src/AXSharp.Compiler/AXSharp.Compiler.csproj index 693c4748..86254c6c 100644 --- a/src/AXSharp.compiler/src/AXSharp.Compiler/AXSharp.Compiler.csproj +++ b/src/AXSharp.compiler/src/AXSharp.Compiler/AXSharp.Compiler.csproj @@ -61,7 +61,8 @@ - + + diff --git a/src/AXSharp.compiler/src/AXSharp.Compiler/AXSharpConfig.cs b/src/AXSharp.compiler/src/AXSharp.Compiler/AXSharpConfig.cs index 50d060a8..0bd38ed5 100644 --- a/src/AXSharp.compiler/src/AXSharp.Compiler/AXSharpConfig.cs +++ b/src/AXSharp.compiler/src/AXSharp.Compiler/AXSharpConfig.cs @@ -70,6 +70,18 @@ public string OutputProjectFolder /// public string? UiHostProject { get; set; } + private string _sourceOrigin = "auto"; + + /// + /// Source-origin detection mode controlling provenance attributes emitted onto generated twins: + /// auto (default), apax or off. + /// + public string SourceOrigin + { + get => string.IsNullOrWhiteSpace(_sourceOrigin) ? "auto" : _sourceOrigin; + set => _sourceOrigin = value; + } + private string _axProjectFolder; @@ -211,5 +223,8 @@ private static void OverridesFromCli(ICompilerOptions fromConfig, ICompilerOptio fromConfig.UiHostProject = string.IsNullOrEmpty(newCompilerOptions.UiHostProject) ? fromConfig.UiHostProject : newCompilerOptions.UiHostProject; + fromConfig.SourceOrigin = string.IsNullOrEmpty(newCompilerOptions.SourceOrigin) + ? fromConfig.SourceOrigin + : newCompilerOptions.SourceOrigin; } } \ No newline at end of file diff --git a/src/AXSharp.compiler/src/AXSharp.Compiler/AXSharpProject.cs b/src/AXSharp.compiler/src/AXSharp.Compiler/AXSharpProject.cs index bac20568..2a847ac7 100644 --- a/src/AXSharp.compiler/src/AXSharp.Compiler/AXSharpProject.cs +++ b/src/AXSharp.compiler/src/AXSharp.Compiler/AXSharpProject.cs @@ -42,10 +42,11 @@ public class AXSharpProject : IAXSharpProject /// /// Compiler options from CLI. /// - public AXSharpProject(AxProject axProject, IEnumerable builderTypes, Type targetProjectType, ICompilerOptions? cliCompilerOptions = null, ICompilerOptions? dependnantCompilerOptions = null) + public AXSharpProject(AxProject axProject, IEnumerable builderTypes, Type targetProjectType, ICompilerOptions? cliCompilerOptions = null, ICompilerOptions? dependnantCompilerOptions = null, ISourceOriginProvider? sourceOriginProvider = null) { AxProject = axProject; CompilerOptions = AXSharpConfig.UpdateAndGetAXSharpConfig(axProject.ProjectFolder, cliCompilerOptions, dependnantCompilerOptions); + _sourceOriginProvider = sourceOriginProvider ?? SourceOriginProviderFactory.Create(CompilerOptions?.SourceOrigin); if (CompilerOptions != null) { if(string.IsNullOrEmpty(CompilerOptions.OutputProjectFolder)) @@ -76,6 +77,16 @@ public AXSharpProject(AxProject axProject, IEnumerable builderTypes, Type /// public AxProject AxProject { get; } + private readonly ISourceOriginProvider _sourceOriginProvider; + private SourceOrigin? _sourceOrigin; + + /// + /// Gets the resolved (repository or apax-package provenance) for + /// this project. Determines the base folder for per-type source paths and which assembly-level + /// attribute is emitted. Resolved lazily and cached. + /// + public SourceOrigin SourceOrigin => _sourceOrigin ??= _sourceOriginProvider.Resolve(AxProject); + private IEnumerable BuilderTypes { get; } /// @@ -201,6 +212,8 @@ public void Generate() } } + EmitAssemblyAttributes(); + //TargetProject.ProvisionProjectStructure(); GenerateMetadata(compilationResult.Compilation, projectSources); TargetProject.GenerateResources(); @@ -208,6 +221,31 @@ public void Generate() Log.Logger.Information($"Compilation of project '{AxProject.SrcFolder}' done."); } + /// + /// Emits the single assembly-level provenance attribute (.g/AssemblyAttributes.g.cs) + /// describing the source origin of this project. No file is written in None mode. + /// + private void EmitAssemblyAttributes() + { + var assemblyAttribute = SourceOriginEmitter.RenderAssemblyAttribute(SourceOrigin); + if (assemblyAttribute == null) + return; + + Policy + .Handle() + .WaitAndRetry(5, a => TimeSpan.FromMilliseconds(500)) + .Execute(() => + { + using (var swr = new StreamWriter(Path.Combine( + EnsureFolder(Path.Combine(OutputFolder, ".g")), + "AssemblyAttributes.g.cs"))) + { + swr.WriteLine("// "); + swr.WriteLine(assemblyAttribute); + } + }); + } + /// diff --git a/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/ApaxSourceOriginProvider.cs b/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/ApaxSourceOriginProvider.cs new file mode 100644 index 00000000..7faceea4 --- /dev/null +++ b/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/ApaxSourceOriginProvider.cs @@ -0,0 +1,22 @@ +// AXSharp.Compiler +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +namespace AXSharp.Compiler; + +/// +/// Resolves Library-mode provenance from the project's apax metadata. Per-type paths are rooted +/// at the project src folder. Missing name/version map to empty strings. +/// +public sealed class ApaxSourceOriginProvider : ISourceOriginProvider +{ + /// + public SourceOrigin Resolve(AxProject project) + => SourceOrigin.Library( + project.SrcFolder, + project.ProjectInfo.Name ?? string.Empty, + project.ProjectInfo.Version ?? string.Empty); +} diff --git a/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/GitSourceOriginProvider.cs b/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/GitSourceOriginProvider.cs new file mode 100644 index 00000000..14219997 --- /dev/null +++ b/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/GitSourceOriginProvider.cs @@ -0,0 +1,50 @@ +// AXSharp.Compiler +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +using LibGit2Sharp; + +namespace AXSharp.Compiler; + +/// +/// Resolves Repository-mode provenance from the nearest enclosing git repository (innermost +/// .git — submodules/worktrees resolve to their own repository) that exposes an +/// origin remote. The per-type path base is the repository working directory; the remote +/// URL is normalized to canonical https. When no repository or no usable origin remote is +/// found, resolution delegates to the fallback (apax) provider. +/// +public sealed class GitSourceOriginProvider : ISourceOriginProvider +{ + private readonly ISourceOriginProvider _fallback; + + /// + /// Creates a new instance. is used when the project is not in a + /// git repository with a usable origin remote (defaults to ). + /// + public GitSourceOriginProvider(ISourceOriginProvider? fallback = null) + => _fallback = fallback ?? new ApaxSourceOriginProvider(); + + /// + public SourceOrigin Resolve(AxProject project) + { + var gitDir = Repository.Discover(project.ProjectFolder); + if (string.IsNullOrEmpty(gitDir)) + return _fallback.Resolve(project); + + using var repo = new Repository(gitDir); + + var origin = repo.Network.Remotes["origin"]; + if (origin == null || string.IsNullOrWhiteSpace(origin.Url)) + return _fallback.Resolve(project); + + var url = GitUrlNormalizer.Normalize(origin.Url); + var commit = repo.Head?.Tip?.Sha ?? string.Empty; + var branch = repo.Info.IsHeadDetached ? string.Empty : repo.Head?.FriendlyName ?? string.Empty; + var repoRoot = repo.Info.WorkingDirectory ?? project.ProjectFolder; + + return SourceOrigin.Repository(repoRoot, url, commit, branch); + } +} diff --git a/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/GitUrlNormalizer.cs b/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/GitUrlNormalizer.cs new file mode 100644 index 00000000..6ca12e1b --- /dev/null +++ b/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/GitUrlNormalizer.cs @@ -0,0 +1,56 @@ +// AXSharp.Compiler +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +using System; +using System.Text.RegularExpressions; + +namespace AXSharp.Compiler; + +/// +/// Normalizes a git remote URL into a canonical, credential-free https form suitable for +/// embedding in AXSharp.Connector.SourceRepositoryAttribute and for building source +/// permalinks. Best-effort: input that is not recognizable as a remote URL is returned unchanged. +/// +public static class GitUrlNormalizer +{ + // scp-like syntax: [user@]host:org/repo[.git] (no URI scheme) + private static readonly Regex ScpLike = new(@"^(?:[^@/]+@)?([^/:]+):(.+)$", RegexOptions.Compiled); + + /// + /// Normalizes to canonical https, stripping any embedded + /// credentials, port and trailing .git/slash. Returns for + /// null/blank input and the trimmed input verbatim when it is not a recognizable remote URL. + /// + public static string Normalize(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return string.Empty; + + var url = raw.Trim(); + + if (!url.Contains("://")) + { + var scp = ScpLike.Match(url); + return scp.Success + ? BuildHttps(scp.Groups[1].Value, scp.Groups[2].Value) + : url; // not a remote URL — leave untouched + } + + return Uri.TryCreate(url, UriKind.Absolute, out var uri) + ? BuildHttps(uri.Host, uri.AbsolutePath) + : url; + } + + private static string BuildHttps(string host, string path) + { + path = path.Trim('/'); + if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + path = path.Substring(0, path.Length - 4); + + return $"https://{host}/{path}"; + } +} diff --git a/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/ISourceOriginProvider.cs b/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/ISourceOriginProvider.cs new file mode 100644 index 00000000..2cb70f90 --- /dev/null +++ b/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/ISourceOriginProvider.cs @@ -0,0 +1,18 @@ +// AXSharp.Compiler +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +namespace AXSharp.Compiler; + +/// +/// Resolves the (repository or apax-package provenance) for an +/// AX project. Implementations are the seam that lets tests force deterministic provenance. +/// +public interface ISourceOriginProvider +{ + /// Resolves the provenance for . + SourceOrigin Resolve(AxProject project); +} diff --git a/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/NoSourceOriginProvider.cs b/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/NoSourceOriginProvider.cs new file mode 100644 index 00000000..5990a7e9 --- /dev/null +++ b/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/NoSourceOriginProvider.cs @@ -0,0 +1,18 @@ +// AXSharp.Compiler +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +namespace AXSharp.Compiler; + +/// +/// Resolves None-mode provenance: no assembly-level attribute is emitted and per-type paths stay +/// rooted at the project src folder. This reproduces the pre-feature (legacy) behavior. +/// +public sealed class NoSourceOriginProvider : ISourceOriginProvider +{ + /// + public SourceOrigin Resolve(AxProject project) => SourceOrigin.None(project.SrcFolder); +} diff --git a/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/SourceOrigin.cs b/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/SourceOrigin.cs new file mode 100644 index 00000000..cf0a513a --- /dev/null +++ b/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/SourceOrigin.cs @@ -0,0 +1,82 @@ +// AXSharp.Compiler +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +namespace AXSharp.Compiler; + +/// +/// The kind of provenance resolved for a transpiled assembly. Determines which assembly-level +/// attribute is emitted and how the per-type source path is rooted. +/// +public enum SourceOriginMode +{ + /// Git repository with a usable remote; per-type path is repository-root relative. + Repository, + + /// apax package; per-type path is src-folder relative. + Library, + + /// No provenance emitted; per-type path is src-folder relative (legacy). + None +} + +/// +/// Resolved provenance of a transpiled AX project: the base folder against which per-type source +/// paths are relativized, plus the repository or package identity to emit at assembly level. +/// +public sealed class SourceOrigin +{ + private SourceOrigin( + SourceOriginMode mode, + string baseFolder, + string? url, + string? commit, + string? branch, + string? packageName, + string? packageVersion) + { + Mode = mode; + BaseFolder = baseFolder; + Url = url; + Commit = commit; + Branch = branch; + PackageName = packageName; + PackageVersion = packageVersion; + } + + /// Gets the resolved provenance mode. + public SourceOriginMode Mode { get; } + + /// Gets the folder against which per-type source paths are relativized. + public string BaseFolder { get; } + + /// Gets the canonical remote URL (Repository mode); otherwise null. + public string? Url { get; } + + /// Gets the commit SHA (Repository mode); otherwise null. + public string? Commit { get; } + + /// Gets the branch name, empty on detached HEAD (Repository mode); otherwise null. + public string? Branch { get; } + + /// Gets the apax package name (Library mode); otherwise null. + public string? PackageName { get; } + + /// Gets the apax package version (Library mode); otherwise null. + public string? PackageVersion { get; } + + /// Creates a Repository-mode origin rooted at the repository working directory. + public static SourceOrigin Repository(string baseFolder, string url, string commit, string branch) + => new(SourceOriginMode.Repository, baseFolder, url, commit, branch, null, null); + + /// Creates a Library-mode origin rooted at the project src folder. + public static SourceOrigin Library(string baseFolder, string name, string version) + => new(SourceOriginMode.Library, baseFolder, null, null, null, name, version); + + /// Creates a None-mode origin rooted at the project src folder (legacy, no emission). + public static SourceOrigin None(string baseFolder) + => new(SourceOriginMode.None, baseFolder, null, null, null, null, null); +} diff --git a/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/SourceOriginEmitter.cs b/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/SourceOriginEmitter.cs new file mode 100644 index 00000000..11d96cc2 --- /dev/null +++ b/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/SourceOriginEmitter.cs @@ -0,0 +1,36 @@ +// AXSharp.Compiler +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +namespace AXSharp.Compiler; + +/// +/// Renders the single assembly-level provenance attribute for a resolved . +/// +public static class SourceOriginEmitter +{ + /// + /// Returns the [assembly: ...] declaration for : + /// AXSharp.Connector.SourceRepository in repository mode, + /// AXSharp.Connector.SourceLibrary in library mode, or null in None mode + /// (no attribute is emitted). + /// + public static string? RenderAssemblyAttribute(SourceOrigin origin) + { + switch (origin.Mode) + { + case SourceOriginMode.Repository: + return $"[assembly: AXSharp.Connector.SourceRepository({Verbatim(origin.Url)}, {Verbatim(origin.Commit)}, {Verbatim(origin.Branch)})]"; + case SourceOriginMode.Library: + return $"[assembly: AXSharp.Connector.SourceLibrary({Verbatim(origin.PackageName)}, {Verbatim(origin.PackageVersion)})]"; + default: + return null; + } + } + + private static string Verbatim(string? value) + => "@\"" + (value ?? string.Empty).Replace("\"", "\"\"") + "\""; +} diff --git a/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/SourceOriginProviderFactory.cs b/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/SourceOriginProviderFactory.cs new file mode 100644 index 00000000..aa62e615 --- /dev/null +++ b/src/AXSharp.compiler/src/AXSharp.Compiler/SourceOrigin/SourceOriginProviderFactory.cs @@ -0,0 +1,25 @@ +// AXSharp.Compiler +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +namespace AXSharp.Compiler; + +/// +/// Maps the --source-origin option value to an : +/// apax forces package mode, off disables emission, anything else (including +/// auto, null or unrecognized) selects git auto-detection. +/// +public static class SourceOriginProviderFactory +{ + /// Creates the provider for the given mode (case-insensitive). + public static ISourceOriginProvider Create(string? mode) + => (mode ?? string.Empty).Trim().ToLowerInvariant() switch + { + "apax" => new ApaxSourceOriginProvider(), + "off" => new NoSourceOriginProvider(), + _ => new GitSourceOriginProvider() + }; +} diff --git a/src/AXSharp.compiler/src/AXSharp.Cs.Compiler/Helpers/SourceFileAttributeHelper.cs b/src/AXSharp.compiler/src/AXSharp.Cs.Compiler/Helpers/SourceFileAttributeHelper.cs index 4283dcc6..4fb7a41d 100644 --- a/src/AXSharp.compiler/src/AXSharp.Cs.Compiler/Helpers/SourceFileAttributeHelper.cs +++ b/src/AXSharp.compiler/src/AXSharp.Cs.Compiler/Helpers/SourceFileAttributeHelper.cs @@ -20,17 +20,18 @@ public static class SourceFileAttributeHelper { /// /// Produces the [AXSharp.Connector.SourceFileAttribute(...)] declaration for the given type, - /// carrying the source file path relative to the project src folder (forward-slash separated). + /// carrying the source file path relative to (the repository + /// root in repository mode, otherwise the project src folder), forward-slash separated. /// Returns an empty string when the declaration has no source location (e.g. types parsed from /// dependency metadata), making emission a safe no-op. /// - public static string GetSourceFileAttribute(this ITypeDeclaration declaration, AxProject axProject) + public static string GetSourceFileAttribute(this ITypeDeclaration declaration, SourceOrigin sourceOrigin) { var filename = declaration.Location?.GetLineSpan().Filename; if (string.IsNullOrEmpty(filename)) return string.Empty; - var relative = Path.GetRelativePath(axProject.SrcFolder, filename).Replace('\\', '/'); + var relative = Path.GetRelativePath(sourceOrigin.BaseFolder, filename).Replace('\\', '/'); return $"[AXSharp.Connector.SourceFileAttribute(@\"{relative}\")]\n"; } } diff --git a/src/AXSharp.compiler/src/AXSharp.Cs.Compiler/Onliner/CsOnlinerSourceBuilder.cs b/src/AXSharp.compiler/src/AXSharp.Cs.Compiler/Onliner/CsOnlinerSourceBuilder.cs index 516a090a..06cc7a07 100644 --- a/src/AXSharp.compiler/src/AXSharp.Cs.Compiler/Onliner/CsOnlinerSourceBuilder.cs +++ b/src/AXSharp.compiler/src/AXSharp.Cs.Compiler/Onliner/CsOnlinerSourceBuilder.cs @@ -151,7 +151,7 @@ public void CreateClassDeclaration(IClassDeclarationSyntax classDeclarationSynta classDeclarationSyntax.UsingDirectives.ToList().ForEach(p => p.Visit(visitor, this)); var generic = classDeclaration.GetGenericAttributes(); - AddToSource(classDeclaration.GetSourceFileAttribute(Project.AxProject)); + AddToSource(classDeclaration.GetSourceFileAttribute(Project.SourceOrigin)); AddToSource(classDeclaration.Pragmas.AddAttributes()); AddToSource($"{classDeclaration.AccessModifier.Transform()}partial class {classDeclaration.Name}{generic?.Product}"); AddToSource(":"); @@ -288,7 +288,7 @@ public void CreateEnumTypeDeclaration(IEnumTypeDeclarationSyntax enumTypeDeclara { TypeCommAccessibility = eCommAccessibility.None; - AddToSource(typeDeclaration.GetSourceFileAttribute(Project.AxProject)); + AddToSource(typeDeclaration.GetSourceFileAttribute(Project.SourceOrigin)); AddToSource($"public enum {enumTypeDeclarationSyntax.Name.Text} {{"); AddToSource(string.Join("\n,", enumTypeDeclarationSyntax.EnumValueList.EnumValues.Select(p => p.Name.Text))); AddToSource("}"); @@ -300,7 +300,7 @@ public void CreateNamedValueTypeDeclaration(INamedValueTypeDeclarationSyntax nam { TypeCommAccessibility = eCommAccessibility.None; - AddToSource(namedValueTypeDeclaration.GetSourceFileAttribute(Project.AxProject)); + AddToSource(namedValueTypeDeclaration.GetSourceFileAttribute(Project.SourceOrigin)); AddToSource( $"public enum {namedValueTypeDeclarationSyntax.Name.Text} : {namedValueTypeDeclarationSyntax.BaseType.TransformType()} {{"); @@ -352,7 +352,7 @@ public void CreateInterfaceDeclaration(IInterfaceDeclarationSyntax interfaceDecl { TypeCommAccessibility = eCommAccessibility.None; - AddToSource(interfaceDeclaration.GetSourceFileAttribute(Project.AxProject)); + AddToSource(interfaceDeclaration.GetSourceFileAttribute(Project.SourceOrigin)); AddToSource($"{interfaceDeclaration.AccessModifier.Transform()} partial interface {interfaceDeclaration.Name} {{}}"); } @@ -369,7 +369,7 @@ public void CreateStructuredType(IStructTypeDeclarationSyntax structTypeDeclarat { TypeCommAccessibility = structuredTypeDeclaration.GetCommAccessibility(this); - AddToSource(structuredTypeDeclaration.GetSourceFileAttribute(Project.AxProject)); + AddToSource(structuredTypeDeclaration.GetSourceFileAttribute(Project.SourceOrigin)); AddToSource(structuredTypeDeclaration.Pragmas.AddAttributes()); AddToSource( $"{structuredTypeDeclaration.AccessModifier.Transform()}partial class {structTypeDeclarationSyntax.Name.Text}"); diff --git a/src/AXSharp.compiler/src/AXSharp.Cs.Compiler/Plain/CsPlainSourceBuilder.cs b/src/AXSharp.compiler/src/AXSharp.Cs.Compiler/Plain/CsPlainSourceBuilder.cs index 3d05bffc..a6fc9d2d 100644 --- a/src/AXSharp.compiler/src/AXSharp.Cs.Compiler/Plain/CsPlainSourceBuilder.cs +++ b/src/AXSharp.compiler/src/AXSharp.Cs.Compiler/Plain/CsPlainSourceBuilder.cs @@ -71,7 +71,7 @@ public void CreateClassDeclaration(IClassDeclarationSyntax classDeclarationSynta var classDeclarations = this.Compilation.GetSemanticTree().Classes .Where(p => p.FullyQualifiedName == classDeclaration.GetQualifiedName()); - AddToSource(classDeclaration.GetSourceFileAttribute(Project.AxProject)); + AddToSource(classDeclaration.GetSourceFileAttribute(Project.SourceOrigin)); AddToSource(classDeclaration.Pragmas.AddedPropertiesAsAttributes()); AddToSource($"{classDeclaration.AccessModifier.Transform()}partial class {classDeclaration.Name}"); @@ -269,7 +269,7 @@ public void CreateInterfaceDeclaration(IInterfaceDeclarationSyntax interfaceDecl IInterfaceDeclaration interfaceDeclaration, IxNodeVisitor visitor) { - AddToSource(interfaceDeclaration.GetSourceFileAttribute(Project.AxProject)); + AddToSource(interfaceDeclaration.GetSourceFileAttribute(Project.SourceOrigin)); AddToSource($"{interfaceDeclaration.AccessModifier.Transform()} partial interface {interfaceDeclaration.Name} {{}}"); } @@ -351,7 +351,7 @@ public void CreateStructuredType(IStructTypeDeclarationSyntax structTypeDeclarat { TypeCommAccessibility = structuredTypeDeclaration.GetCommAccessibility(this); - AddToSource(structuredTypeDeclaration.GetSourceFileAttribute(Project.AxProject)); + AddToSource(structuredTypeDeclaration.GetSourceFileAttribute(Project.SourceOrigin)); AddToSource( $"{structuredTypeDeclaration.AccessModifier.Transform()}partial class {structTypeDeclarationSyntax.Name.Text} : AXSharp.Connector.IPlain"); AddToSource("{"); diff --git a/src/AXSharp.compiler/src/ixc/Options.cs b/src/AXSharp.compiler/src/ixc/Options.cs index 55066b79..a9f55eb3 100644 --- a/src/AXSharp.compiler/src/ixc/Options.cs +++ b/src/AXSharp.compiler/src/ixc/Options.cs @@ -55,5 +55,9 @@ internal class Options : ICompilerOptions HelpText = "Path (relative to AX project folder or absolute) of the .csproj that hosts/consumes UI companion NuGet packages. In library development this is the Blazor/UI application; in application development this is the application project itself.")] public string? UiHostProject { get; set; } + [Option("source-origin", Required = false, Default = "auto", + HelpText = "Provenance emitted onto generated twins: 'auto' (detect git repository + remote, else apax package identity), 'apax' (force apax package mode, no commit SHA), or 'off' (legacy: src-relative paths, no assembly attribute).")] + public string SourceOrigin { get; set; } = "auto"; + } diff --git a/src/AXSharp.compiler/src/ixd/Options.cs b/src/AXSharp.compiler/src/ixd/Options.cs index fd60b7d0..a5c352d5 100644 --- a/src/AXSharp.compiler/src/ixd/Options.cs +++ b/src/AXSharp.compiler/src/ixd/Options.cs @@ -39,5 +39,7 @@ internal class Options : ICompilerOptions public string TargetPlatfromMoniker { get; set; } public string? UiHostProject { get; set; } + + public string SourceOrigin { get; set; } = "auto"; } } diff --git a/src/AXSharp.compiler/src/ixr/Options.cs b/src/AXSharp.compiler/src/ixr/Options.cs index 67b42c5b..c9449a83 100644 --- a/src/AXSharp.compiler/src/ixr/Options.cs +++ b/src/AXSharp.compiler/src/ixr/Options.cs @@ -38,5 +38,7 @@ internal class Options : ICompilerOptions public string TargetPlatfromMoniker { get; set; } public string? UiHostProject { get; set; } + + public string SourceOrigin { get; set; } = "auto"; } } diff --git a/src/AXSharp.compiler/tests/AXSharp.Compiler.CsTests/AssemblyAttributesEmissionTests.cs b/src/AXSharp.compiler/tests/AXSharp.Compiler.CsTests/AssemblyAttributesEmissionTests.cs new file mode 100644 index 00000000..f8be9cba --- /dev/null +++ b/src/AXSharp.compiler/tests/AXSharp.Compiler.CsTests/AssemblyAttributesEmissionTests.cs @@ -0,0 +1,100 @@ +// AXSharp.Compiler.CsTests +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +using System; +using System.IO; +using System.Reflection; +using AXSharp.Compiler; +using AXSharp.Compiler.Cs; +using AXSharp.Compiler.Cs.Onliner; +using Polly; +using Xunit; + +namespace AXSharp.Compiler.CsTests; + +public class AssemblyAttributesEmissionTests +{ + private readonly string testFolder; + + public AssemblyAttributesEmissionTests() + { +#pragma warning disable CS8604 + var executingAssemblyFileInfo = new FileInfo(Assembly.GetExecutingAssembly().FullName); +#pragma warning restore CS8604 + testFolder = executingAssemblyFileInfo.Directory!.FullName; + } + + private sealed class FixedOriginProvider : ISourceOriginProvider + { + private readonly SourceOrigin _origin; + public FixedOriginProvider(SourceOrigin origin) => _origin = origin; + public SourceOrigin Resolve(AxProject project) => _origin; + } + + [Fact] + public void Library_mode_emits_SourceLibrary_assembly_attribute() + { + // sourcefile sample is "@ax/sourcefile" @ 0.0.0 + var content = GenerateAssemblyAttributes(new ApaxSourceOriginProvider(), "ix-asm-lib"); + + Assert.NotNull(content); + Assert.Contains("[assembly: AXSharp.Connector.SourceLibrary(@\"@ax/sourcefile\", @\"0.0.0\")]", content); + } + + [Fact] + public void Repository_mode_emits_SourceRepository_assembly_attribute() + { + var projectFolder = Path.Combine(testFolder, "samples", "sourcefile"); + var origin = SourceOrigin.Repository(projectFolder, "https://github.com/org/repo", "deadbeef", "main"); + + var content = GenerateAssemblyAttributes(new FixedOriginProvider(origin), "ix-asm-repo"); + + Assert.NotNull(content); + Assert.Contains( + "[assembly: AXSharp.Connector.SourceRepository(@\"https://github.com/org/repo\", @\"deadbeef\", @\"main\")]", + content); + } + + [Fact] + public void None_mode_emits_no_assembly_attributes_file() + { + var content = GenerateAssemblyAttributes(new NoSourceOriginProvider(), "ix-asm-none"); + + Assert.Null(content); + } + + private string? GenerateAssemblyAttributes(ISourceOriginProvider? provider, string outputSubFolder) + { + var projectFolder = Path.Combine(testFolder, "samples", "sourcefile"); + var options = new CompilerTestOptions + { + TargetPlatfromMoniker = "ax", + OutputProjectFolder = Path.Combine("samples", "sourcefile", outputSubFolder) + }; + + var sourceFile = Path.Combine(projectFolder, "src", "sub", "widget.st"); + var project = new AXSharpProject( + new AxProject(projectFolder, new[] { sourceFile }), + new[] { typeof(CsOnlinerSourceBuilder) }, + typeof(CsProject), + options, + sourceOriginProvider: provider); + + Policy + .Handle() + .WaitAndRetry(10, a => TimeSpan.FromSeconds(2)) + .Execute(() => + { + if (Directory.Exists(project.OutputFolder)) Directory.Delete(project.OutputFolder, true); + }); + + project.Generate(); + + var asmPath = Path.Combine(project.OutputFolder, ".g", "AssemblyAttributes.g.cs"); + return File.Exists(asmPath) ? File.ReadAllText(asmPath) : null; + } +} diff --git a/src/AXSharp.compiler/tests/AXSharp.Compiler.CsTests/Cs/CompilerTestOptions.cs b/src/AXSharp.compiler/tests/AXSharp.Compiler.CsTests/Cs/CompilerTestOptions.cs index 077d99f3..aa6f19cd 100644 --- a/src/AXSharp.compiler/tests/AXSharp.Compiler.CsTests/Cs/CompilerTestOptions.cs +++ b/src/AXSharp.compiler/tests/AXSharp.Compiler.CsTests/Cs/CompilerTestOptions.cs @@ -29,4 +29,8 @@ public string? OutputProjectFolder public bool SkipDependencyCompilation { get => false; set { } } public string TargetPlatfromMoniker { get; set; } = "ax"; public string? UiHostProject { get; set; } + + // Tests default to legacy behavior (no provenance emission) so existing goldens stay deterministic + // and repository-independent. Provenance behavior is exercised by injecting explicit providers. + public string SourceOrigin { get; set; } = "off"; } \ No newline at end of file diff --git a/src/AXSharp.compiler/tests/AXSharp.Compiler.CsTests/SourceFileAttributeRepositoryModeTests.cs b/src/AXSharp.compiler/tests/AXSharp.Compiler.CsTests/SourceFileAttributeRepositoryModeTests.cs new file mode 100644 index 00000000..9f323e4b --- /dev/null +++ b/src/AXSharp.compiler/tests/AXSharp.Compiler.CsTests/SourceFileAttributeRepositoryModeTests.cs @@ -0,0 +1,88 @@ +// AXSharp.Compiler.CsTests +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using AXSharp.Compiler; +using AXSharp.Compiler.Cs; +using AXSharp.Compiler.Cs.Onliner; +using AXSharp.Compiler.Cs.Plain; +using Polly; +using Xunit; + +namespace AXSharp.Compiler.CsTests; + +public class SourceFileAttributeRepositoryModeTests +{ + private readonly string testFolder; + + public SourceFileAttributeRepositoryModeTests() + { +#pragma warning disable CS8604 + var executingAssemblyFileInfo = new FileInfo(Assembly.GetExecutingAssembly().FullName); +#pragma warning restore CS8604 + testFolder = executingAssemblyFileInfo.Directory!.FullName; + } + + private sealed class FixedOriginProvider : ISourceOriginProvider + { + private readonly SourceOrigin _origin; + public FixedOriginProvider(SourceOrigin origin) => _origin = origin; + public SourceOrigin Resolve(AxProject project) => _origin; + } + + // In repository mode the per-type path is relativized against the repository root (here the + // sample project folder), so the widget under src/sub becomes "src/sub/widget.st". + [Theory] + [InlineData(typeof(CsOnlinerSourceBuilder))] + [InlineData(typeof(CsPlainSourceBuilder))] + public void emits_repository_rooted_source_path(Type builder) + { + var projectFolder = Path.Combine(testFolder, "samples", "sourcefile"); + var origin = SourceOrigin.Repository(projectFolder, "https://github.com/org/repo", "deadbeef", "main"); + + var content = Generate(builder, new FixedOriginProvider(origin), "ix-repo"); + + Assert.Contains("[AXSharp.Connector.SourceFileAttribute(@\"src/sub/widget.st\")]", content); + Assert.DoesNotContain("[AXSharp.Connector.SourceFileAttribute(@\"sub/widget.st\")]", content); + } + + private string Generate(Type builder, ISourceOriginProvider provider, string outputSubFolder) + { + var projectFolder = Path.Combine(testFolder, "samples", "sourcefile"); + var options = new CompilerTestOptions + { + TargetPlatfromMoniker = "ax", + OutputProjectFolder = Path.Combine("samples", "sourcefile", outputSubFolder, builder.Name) + }; + + var sourceFile = Path.Combine(projectFolder, "src", "sub", "widget.st"); + var project = new AXSharpProject( + new AxProject(projectFolder, new[] { sourceFile }), + new[] { builder }, + typeof(CsProject), + options, + sourceOriginProvider: provider); + + Policy + .Handle() + .WaitAndRetry(10, a => TimeSpan.FromSeconds(2)) + .Execute(() => + { + if (Directory.Exists(project.OutputFolder)) Directory.Delete(project.OutputFolder, true); + }); + + project.Generate(); + + var generated = Directory + .EnumerateFiles(project.OutputFolder, "widget.g.cs", SearchOption.AllDirectories) + .Single(); + return File.ReadAllText(generated); + } +} diff --git a/src/AXSharp.compiler/tests/AXSharp.CompilerTests/AXSharp.CompilerTests.csproj b/src/AXSharp.compiler/tests/AXSharp.CompilerTests/AXSharp.CompilerTests.csproj index 09f5f484..22abd222 100644 --- a/src/AXSharp.compiler/tests/AXSharp.CompilerTests/AXSharp.CompilerTests.csproj +++ b/src/AXSharp.compiler/tests/AXSharp.CompilerTests/AXSharp.CompilerTests.csproj @@ -25,6 +25,7 @@ + diff --git a/src/AXSharp.compiler/tests/AXSharp.CompilerTests/CompilerTestOptions.cs b/src/AXSharp.compiler/tests/AXSharp.CompilerTests/CompilerTestOptions.cs index f6f5c7f8..79a37430 100644 --- a/src/AXSharp.compiler/tests/AXSharp.CompilerTests/CompilerTestOptions.cs +++ b/src/AXSharp.compiler/tests/AXSharp.CompilerTests/CompilerTestOptions.cs @@ -31,4 +31,7 @@ public string? OutputProjectFolder public bool SkipDependencyCompilation { get => false; set { } } public string TargetPlatfromMoniker { get; set; } = "ax"; public string? UiHostProject { get; set; } + + // Tests default to legacy behavior (no provenance emission) for deterministic, repo-independent output. + public string SourceOrigin { get; set; } = "off"; } \ No newline at end of file diff --git a/src/AXSharp.compiler/tests/AXSharp.CompilerTests/GitUrlNormalizerTests.cs b/src/AXSharp.compiler/tests/AXSharp.CompilerTests/GitUrlNormalizerTests.cs new file mode 100644 index 00000000..65ffe840 --- /dev/null +++ b/src/AXSharp.compiler/tests/AXSharp.CompilerTests/GitUrlNormalizerTests.cs @@ -0,0 +1,45 @@ +// AXSharp.CompilerTests +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +using AXSharp.Compiler; + +namespace AXSharp.CompilerTests; + +public class GitUrlNormalizerTests +{ + [Theory] + // scp-like SSH syntax -> https, strip .git + [InlineData("git@github.com:org/repo.git", "https://github.com/org/repo")] + [InlineData("git@github.com:org/repo", "https://github.com/org/repo")] + // ssh:// scheme -> https, drop user + port, strip .git + [InlineData("ssh://git@github.com/org/repo.git", "https://github.com/org/repo")] + [InlineData("ssh://git@github.com:22/org/repo.git", "https://github.com/org/repo")] + // https with embedded credentials -> stripped; .git stripped + [InlineData("https://user:token@github.com/org/repo.git", "https://github.com/org/repo")] + // already clean -> unchanged + [InlineData("https://github.com/org/repo", "https://github.com/org/repo")] + // trailing slash trimmed + [InlineData("https://github.com/org/repo/", "https://github.com/org/repo")] + public void Normalizes_remote_to_canonical_https(string raw, string expected) + { + Assert.Equal(expected, GitUrlNormalizer.Normalize(raw)); + } + + [Fact] + public void Leaves_unrecognized_input_untouched() + { + Assert.Equal("some-local-path", GitUrlNormalizer.Normalize("some-local-path")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Returns_empty_for_blank(string? raw) + { + Assert.Equal(string.Empty, GitUrlNormalizer.Normalize(raw)); + } +} diff --git a/src/AXSharp.compiler/tests/AXSharp.CompilerTests/SourceOrigin/ApaxSourceOriginProviderTests.cs b/src/AXSharp.compiler/tests/AXSharp.CompilerTests/SourceOrigin/ApaxSourceOriginProviderTests.cs new file mode 100644 index 00000000..669337f2 --- /dev/null +++ b/src/AXSharp.compiler/tests/AXSharp.CompilerTests/SourceOrigin/ApaxSourceOriginProviderTests.cs @@ -0,0 +1,49 @@ +// AXSharp.CompilerTests +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +using System.IO; +using System.Reflection; +using AXSharp.Compiler; + +namespace AXSharp.CompilerTests.SourceOrigin; + +public class ApaxSourceOriginProviderTests +{ + private readonly string testFolder; + + public ApaxSourceOriginProviderTests() + { + var fi = new FileInfo(Assembly.GetExecutingAssembly().FullName!); + testFolder = fi.Directory!.FullName; + } + + [Fact] + public void Resolves_library_mode_rooted_at_src() + { + var project = new AxProject(Path.Combine(testFolder, "samples", "units")); + + var origin = new ApaxSourceOriginProvider().Resolve(project); + + Assert.Equal(SourceOriginMode.Library, origin.Mode); + Assert.Equal("units", origin.PackageName); + Assert.Equal("0.0.0", origin.PackageVersion); + Assert.Equal(project.SrcFolder, origin.BaseFolder); + } + + [Fact] + public void Maps_missing_apax_metadata_to_empty_strings() + { + using var fixture = new TempAxProjectFixture("type: app\n"); + var project = new AxProject(fixture.Root); + + var origin = new ApaxSourceOriginProvider().Resolve(project); + + Assert.Equal(SourceOriginMode.Library, origin.Mode); + Assert.Equal(string.Empty, origin.PackageName); + Assert.Equal(string.Empty, origin.PackageVersion); + } +} diff --git a/src/AXSharp.compiler/tests/AXSharp.CompilerTests/SourceOrigin/GitSourceOriginProviderTests.cs b/src/AXSharp.compiler/tests/AXSharp.CompilerTests/SourceOrigin/GitSourceOriginProviderTests.cs new file mode 100644 index 00000000..44ae8aeb --- /dev/null +++ b/src/AXSharp.compiler/tests/AXSharp.CompilerTests/SourceOrigin/GitSourceOriginProviderTests.cs @@ -0,0 +1,83 @@ +// AXSharp.CompilerTests +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +using System; +using AXSharp.Compiler; +using LibGit2Sharp; + +namespace AXSharp.CompilerTests.SourceOrigin; + +public class GitSourceOriginProviderTests +{ + private const string Apax = "name: gitsample\nversion: 1.0.0\ntype: app\n"; + + private static void InitRepo(string dir, string? remoteUrl) + { + Repository.Init(dir); + using var repo = new Repository(dir); + Commands.Stage(repo, "*"); + var sig = new Signature("tester", "tester@example.com", DateTimeOffset.UnixEpoch.AddDays(1)); + repo.Commit("init", sig, sig); + if (remoteUrl != null) + repo.Network.Remotes.Add("origin", remoteUrl); + } + + [Fact] + public void Resolves_repository_mode_with_normalized_url_sha_and_branch() + { + using var fixture = new TempAxProjectFixture(Apax); + InitRepo(fixture.Root, "git@github.com:org/repo.git"); + + string expectedSha, expectedBranch; + using (var repo = new Repository(fixture.Root)) + { + expectedSha = repo.Head.Tip.Sha; + expectedBranch = repo.Head.FriendlyName; + } + + var origin = new GitSourceOriginProvider().Resolve(new AxProject(fixture.Root)); + + Assert.Equal(SourceOriginMode.Repository, origin.Mode); + Assert.Equal("https://github.com/org/repo", origin.Url); + Assert.Equal(expectedSha, origin.Commit); + Assert.Equal(expectedBranch, origin.Branch); + Assert.Equal( + TempAxProjectFixture.NormalizeDir(fixture.Root), + TempAxProjectFixture.NormalizeDir(origin.BaseFolder)); + } + + [Fact] + public void Falls_back_to_library_when_no_remote() + { + using var fixture = new TempAxProjectFixture(Apax); + InitRepo(fixture.Root, remoteUrl: null); + + var project = new AxProject(fixture.Root); + var origin = new GitSourceOriginProvider().Resolve(project); + + Assert.Equal(SourceOriginMode.Library, origin.Mode); + Assert.Equal("gitsample", origin.PackageName); + Assert.Equal("1.0.0", origin.PackageVersion); + Assert.Equal(project.SrcFolder, origin.BaseFolder); + } + + [Fact] + public void Emits_empty_branch_on_detached_head() + { + using var fixture = new TempAxProjectFixture(Apax); + InitRepo(fixture.Root, "git@github.com:org/repo.git"); + using (var repo = new Repository(fixture.Root)) + { + Commands.Checkout(repo, repo.Head.Tip); // detach HEAD at the tip commit + } + + var origin = new GitSourceOriginProvider().Resolve(new AxProject(fixture.Root)); + + Assert.Equal(SourceOriginMode.Repository, origin.Mode); + Assert.Equal(string.Empty, origin.Branch); + } +} diff --git a/src/AXSharp.compiler/tests/AXSharp.CompilerTests/SourceOrigin/NoSourceOriginProviderTests.cs b/src/AXSharp.compiler/tests/AXSharp.CompilerTests/SourceOrigin/NoSourceOriginProviderTests.cs new file mode 100644 index 00000000..9774519d --- /dev/null +++ b/src/AXSharp.compiler/tests/AXSharp.CompilerTests/SourceOrigin/NoSourceOriginProviderTests.cs @@ -0,0 +1,34 @@ +// AXSharp.CompilerTests +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +using System.IO; +using System.Reflection; +using AXSharp.Compiler; + +namespace AXSharp.CompilerTests.SourceOrigin; + +public class NoSourceOriginProviderTests +{ + private readonly string testFolder; + + public NoSourceOriginProviderTests() + { + var fi = new FileInfo(Assembly.GetExecutingAssembly().FullName!); + testFolder = fi.Directory!.FullName; + } + + [Fact] + public void Resolves_none_mode_rooted_at_src() + { + var project = new AxProject(Path.Combine(testFolder, "samples", "units")); + + var origin = new NoSourceOriginProvider().Resolve(project); + + Assert.Equal(SourceOriginMode.None, origin.Mode); + Assert.Equal(project.SrcFolder, origin.BaseFolder); + } +} diff --git a/src/AXSharp.compiler/tests/AXSharp.CompilerTests/SourceOrigin/SourceOriginProviderFactoryTests.cs b/src/AXSharp.compiler/tests/AXSharp.CompilerTests/SourceOrigin/SourceOriginProviderFactoryTests.cs new file mode 100644 index 00000000..9250b34c --- /dev/null +++ b/src/AXSharp.compiler/tests/AXSharp.CompilerTests/SourceOrigin/SourceOriginProviderFactoryTests.cs @@ -0,0 +1,40 @@ +// AXSharp.CompilerTests +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +using AXSharp.Compiler; + +namespace AXSharp.CompilerTests.SourceOrigin; + +public class SourceOriginProviderFactoryTests +{ + [Theory] + [InlineData("auto")] + [InlineData("AUTO")] + [InlineData(null)] + [InlineData("")] + [InlineData("nonsense")] + public void Defaults_to_git_auto_detection(string? mode) + { + Assert.IsType(SourceOriginProviderFactory.Create(mode)); + } + + [Theory] + [InlineData("apax")] + [InlineData("APAX")] + public void Apax_forces_library_mode(string mode) + { + Assert.IsType(SourceOriginProviderFactory.Create(mode)); + } + + [Theory] + [InlineData("off")] + [InlineData("Off")] + public void Off_disables_emission(string mode) + { + Assert.IsType(SourceOriginProviderFactory.Create(mode)); + } +} diff --git a/src/AXSharp.compiler/tests/AXSharp.CompilerTests/SourceOrigin/TempAxProjectFixture.cs b/src/AXSharp.compiler/tests/AXSharp.CompilerTests/SourceOrigin/TempAxProjectFixture.cs new file mode 100644 index 00000000..ca65301b --- /dev/null +++ b/src/AXSharp.compiler/tests/AXSharp.CompilerTests/SourceOrigin/TempAxProjectFixture.cs @@ -0,0 +1,45 @@ +// AXSharp.CompilerTests +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +using System; +using System.IO; + +namespace AXSharp.CompilerTests.SourceOrigin; + +/// +/// Creates a throwaway on-disk AX project (apax.yml + src/ with one .st file) under the temp +/// folder. Disposing removes the whole tree. Used by source-origin provider tests that need a +/// real without polluting the repo with fixtures. +/// +internal sealed class TempAxProjectFixture : IDisposable +{ + public TempAxProjectFixture(string apaxYml) + { + Root = Path.Combine(Path.GetTempPath(), "axsharp-origin-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(Root, "src")); + File.WriteAllText(Path.Combine(Root, "apax.yml"), apaxYml); + File.WriteAllText(Path.Combine(Root, "src", "dummy.st"), "CLASS dummy END_CLASS"); + } + + public string Root { get; } + + public void Dispose() + { + try + { + if (Directory.Exists(Root)) + Directory.Delete(Root, true); + } + catch + { + // best-effort cleanup + } + } + + public static string NormalizeDir(string path) => + Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); +} diff --git a/src/AXSharp.compiler/tests/AXSharp.ixc.Tests/CliProgramTest.cs b/src/AXSharp.compiler/tests/AXSharp.ixc.Tests/CliProgramTest.cs index e39006c5..27a44f1e 100644 --- a/src/AXSharp.compiler/tests/AXSharp.ixc.Tests/CliProgramTest.cs +++ b/src/AXSharp.compiler/tests/AXSharp.ixc.Tests/CliProgramTest.cs @@ -46,7 +46,8 @@ public void should_run_with_default_settings() ixc.Program.Main(new string[0]); Assert.True(Directory.Exists(outputDirectory)); - Assert.Equal(7, Directory.EnumerateFiles(outputDirectory, "*.*", SearchOption.AllDirectories).Count()); + // +1 vs legacy: auto mode emits .g/AssemblyAttributes.g.cs (source provenance). + Assert.Equal(8, Directory.EnumerateFiles(outputDirectory, "*.*", SearchOption.AllDirectories).Count()); } catch { @@ -57,7 +58,7 @@ public void should_run_with_default_settings() Environment.CurrentDirectory = recoverDirectory; } } - + [Fact] public void should_run_with_setting_retrieved_from_config_file_settings() { @@ -79,7 +80,8 @@ public void should_run_with_setting_retrieved_from_config_file_settings() Assert.True(Directory.Exists(outputDirectory)); - Assert.Equal(9, Directory.EnumerateFiles(outputDirectory, "*.*", SearchOption.AllDirectories).Count()); + // +1 vs legacy: auto mode emits .g/AssemblyAttributes.g.cs (source provenance). + Assert.Equal(10, Directory.EnumerateFiles(outputDirectory, "*.*", SearchOption.AllDirectories).Count()); } catch { @@ -108,7 +110,8 @@ public void should_run_with_setting_retrieved_from_config_file_settings_but_over Assert.True(Directory.Exists(outputDirectory)); - Assert.Equal(7, Directory.EnumerateFiles(outputDirectory, "*.*", SearchOption.AllDirectories).Count()); + // +1 vs legacy: auto mode emits .g/AssemblyAttributes.g.cs (source provenance). + Assert.Equal(8, Directory.EnumerateFiles(outputDirectory, "*.*", SearchOption.AllDirectories).Count()); } catch { diff --git a/src/AXSharp.compiler/tests/integration/actual/app/AXSharp.config.json b/src/AXSharp.compiler/tests/integration/actual/app/AXSharp.config.json index 199218a4..73b4f638 100644 --- a/src/AXSharp.compiler/tests/integration/actual/app/AXSharp.config.json +++ b/src/AXSharp.compiler/tests/integration/actual/app/AXSharp.config.json @@ -1 +1 @@ -{"OutputProjectFolder":"samples\\units\\ix\\tia","UseBase":false,"NoDependencyUpdate":false,"IgnoreS7Pragmas":false,"SkipDependencyCompilation":false,"TargetPlatfromMoniker":"tia","ProjectFile":"app.csproj","UiHostProject":null} \ No newline at end of file +{"OutputProjectFolder":"samples\\units\\ix\\tia","UseBase":false,"NoDependencyUpdate":false,"IgnoreS7Pragmas":false,"SkipDependencyCompilation":false,"TargetPlatfromMoniker":"tia","ProjectFile":"app.csproj","UiHostProject":null,"SourceOrigin":"off"} \ No newline at end of file diff --git a/src/AXSharp.compiler/tests/integration/actual/lib1/AXSharp.config.json b/src/AXSharp.compiler/tests/integration/actual/lib1/AXSharp.config.json index 0e93f4af..4769fd0a 100644 --- a/src/AXSharp.compiler/tests/integration/actual/lib1/AXSharp.config.json +++ b/src/AXSharp.compiler/tests/integration/actual/lib1/AXSharp.config.json @@ -1 +1 @@ -{"OutputProjectFolder":"samples\\units\\ix\\tia","UseBase":false,"NoDependencyUpdate":false,"IgnoreS7Pragmas":false,"SkipDependencyCompilation":false,"TargetPlatfromMoniker":"tia","ProjectFile":"lib1.csproj","UiHostProject":null} \ No newline at end of file +{"OutputProjectFolder":"samples\\units\\ix\\tia","UseBase":false,"NoDependencyUpdate":false,"IgnoreS7Pragmas":false,"SkipDependencyCompilation":false,"TargetPlatfromMoniker":"tia","ProjectFile":"lib1.csproj","UiHostProject":null,"SourceOrigin":"off"} \ No newline at end of file diff --git a/src/AXSharp.compiler/tests/integration/actual/lib2/AXSharp.config.json b/src/AXSharp.compiler/tests/integration/actual/lib2/AXSharp.config.json index 60710e79..fc14fe76 100644 --- a/src/AXSharp.compiler/tests/integration/actual/lib2/AXSharp.config.json +++ b/src/AXSharp.compiler/tests/integration/actual/lib2/AXSharp.config.json @@ -1 +1 @@ -{"OutputProjectFolder":"samples\\units\\ix\\tia","UseBase":false,"NoDependencyUpdate":false,"IgnoreS7Pragmas":false,"SkipDependencyCompilation":false,"TargetPlatfromMoniker":"tia","ProjectFile":"lib2.csproj","UiHostProject":null} \ No newline at end of file +{"OutputProjectFolder":"samples\\units\\ix\\tia","UseBase":false,"NoDependencyUpdate":false,"IgnoreS7Pragmas":false,"SkipDependencyCompilation":false,"TargetPlatfromMoniker":"tia","ProjectFile":"lib2.csproj","UiHostProject":null,"SourceOrigin":"off"} \ No newline at end of file diff --git a/src/AXSharp.connectors/src/AXSharp.Connector/Attributes/SourceFileAttribute.cs b/src/AXSharp.connectors/src/AXSharp.Connector/Attributes/SourceFileAttribute.cs index d3acbd96..e065e35e 100644 --- a/src/AXSharp.connectors/src/AXSharp.Connector/Attributes/SourceFileAttribute.cs +++ b/src/AXSharp.connectors/src/AXSharp.Connector/Attributes/SourceFileAttribute.cs @@ -11,7 +11,10 @@ namespace AXSharp.Connector; /// /// Indicates the AX/Structured-Text source file from which this type was transpiled. -/// The path is relative to the project src folder and forward-slash separated. +/// The path is forward-slash separated; its base depends on the assembly-level provenance +/// attribute: it is relative to the repository root when +/// is present on the assembly, otherwise relative to the project src folder (when +/// is present, or in legacy output with neither). /// /// /// This attribute is emitted in the connector building process. It should not be declared by the diff --git a/src/AXSharp.connectors/src/AXSharp.Connector/Attributes/SourceLibraryAttribute.cs b/src/AXSharp.connectors/src/AXSharp.Connector/Attributes/SourceLibraryAttribute.cs new file mode 100644 index 00000000..5de5a4fa --- /dev/null +++ b/src/AXSharp.connectors/src/AXSharp.Connector/Attributes/SourceLibraryAttribute.cs @@ -0,0 +1,38 @@ +using System; + +namespace AXSharp.Connector; + +/// +/// Identifies the apax package (name + version) the transpiled types in this assembly originate +/// from. This attribute is emitted when no usable git remote is available; when it is present, +/// the per-type path is relative to the project src +/// folder (forward-slash separated). +/// +/// +/// This attribute is emitted in the connector building process. It should not be declared by the +/// framework consumers. +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)] +public class SourceLibraryAttribute : Attribute +{ + /// + /// Creates a new instance of . + /// + /// apax package name. + /// apax package version. + public SourceLibraryAttribute(string name, string version) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Version = version ?? throw new ArgumentNullException(nameof(version)); + } + + /// + /// Gets the apax package name. + /// + public string Name { get; } + + /// + /// Gets the apax package version. + /// + public string Version { get; } +} diff --git a/src/AXSharp.connectors/src/AXSharp.Connector/Attributes/SourceRepositoryAttribute.cs b/src/AXSharp.connectors/src/AXSharp.Connector/Attributes/SourceRepositoryAttribute.cs new file mode 100644 index 00000000..5be6af57 --- /dev/null +++ b/src/AXSharp.connectors/src/AXSharp.Connector/Attributes/SourceRepositoryAttribute.cs @@ -0,0 +1,45 @@ +using System; + +namespace AXSharp.Connector; + +/// +/// Identifies the remote git repository the transpiled types in this assembly originate from. +/// When this attribute is present, the per-type path is +/// relative to the repository root (forward-slash separated). Combined with +/// , a source permalink can be formed as {Url}/blob/{Commit}/{SourceFile}. +/// +/// +/// This attribute is emitted in the connector building process. It should not be declared by the +/// framework consumers. +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)] +public class SourceRepositoryAttribute : Attribute +{ + /// + /// Creates a new instance of . + /// + /// Canonical (https) remote repository URL. + /// Full commit SHA the sources were transpiled from. + /// Branch name; empty when the repository is in a detached-HEAD state. + public SourceRepositoryAttribute(string url, string commit, string branch) + { + Url = url ?? throw new ArgumentNullException(nameof(url)); + Commit = commit ?? throw new ArgumentNullException(nameof(commit)); + Branch = branch ?? throw new ArgumentNullException(nameof(branch)); + } + + /// + /// Gets the canonical (https) remote repository URL. + /// + public string Url { get; } + + /// + /// Gets the full commit SHA the sources were transpiled from. + /// + public string Commit { get; } + + /// + /// Gets the branch name; empty when the repository is in a detached-HEAD state. + /// + public string Branch { get; } +} diff --git a/src/AXSharp.connectors/tests/AXSharp.ConnectorTests/AXSharp.ConnectorTests/Attributes/SourceLibraryAttributeTests.cs b/src/AXSharp.connectors/tests/AXSharp.ConnectorTests/AXSharp.ConnectorTests/Attributes/SourceLibraryAttributeTests.cs new file mode 100644 index 00000000..42ccbbad --- /dev/null +++ b/src/AXSharp.connectors/tests/AXSharp.ConnectorTests/AXSharp.ConnectorTests/Attributes/SourceLibraryAttributeTests.cs @@ -0,0 +1,37 @@ +// AXSharp.ConnectorTests +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +namespace AXSharp.ConnectorTests.Attributes +{ + using AXSharp.Connector; + using System; + using System.Reflection; + using Xunit; + + public class SourceLibraryAttributeTests + { + [Fact] + public void Exposes_name_and_version() + { + var instance = new SourceLibraryAttribute("@ax/foo", "1.2.3"); + + Assert.Equal("@ax/foo", instance.Name); + Assert.Equal("1.2.3", instance.Version); + } + + [Fact] + public void Is_assembly_scoped_single_use() + { + var usage = typeof(SourceLibraryAttribute) + .GetCustomAttribute(); + + Assert.NotNull(usage); + Assert.Equal(AttributeTargets.Assembly, usage!.ValidOn); + Assert.False(usage.AllowMultiple); + } + } +} diff --git a/src/AXSharp.connectors/tests/AXSharp.ConnectorTests/AXSharp.ConnectorTests/Attributes/SourceRepositoryAttributeTests.cs b/src/AXSharp.connectors/tests/AXSharp.ConnectorTests/AXSharp.ConnectorTests/Attributes/SourceRepositoryAttributeTests.cs new file mode 100644 index 00000000..23f39bc5 --- /dev/null +++ b/src/AXSharp.connectors/tests/AXSharp.ConnectorTests/AXSharp.ConnectorTests/Attributes/SourceRepositoryAttributeTests.cs @@ -0,0 +1,39 @@ +// AXSharp.ConnectorTests +// Copyright (c) 2023 MTS spol. s r.o., and Contributors. All Rights Reserved. +// Contributors: https://github.com/inxton/axsharp/graphs/contributors +// See the LICENSE file in the repository root for more information. +// https://github.com/inxton/axsharp/blob/dev/LICENSE +// Third party licenses: https://github.com/inxton/axsharp/blob/master/notices.md + +namespace AXSharp.ConnectorTests.Attributes +{ + using AXSharp.Connector; + using System; + using System.Reflection; + using Xunit; + + public class SourceRepositoryAttributeTests + { + [Fact] + public void Exposes_url_commit_and_branch() + { + var instance = new SourceRepositoryAttribute( + "https://github.com/org/repo", "a1b2c3d", "main"); + + Assert.Equal("https://github.com/org/repo", instance.Url); + Assert.Equal("a1b2c3d", instance.Commit); + Assert.Equal("main", instance.Branch); + } + + [Fact] + public void Is_assembly_scoped_single_use() + { + var usage = typeof(SourceRepositoryAttribute) + .GetCustomAttribute(); + + Assert.NotNull(usage); + Assert.Equal(AttributeTargets.Assembly, usage!.ValidOn); + Assert.False(usage.AllowMultiple); + } + } +}