From cb62dbd2853a93367110bab27457106a418a2407 Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Mon, 2 Mar 2026 22:54:02 +0100 Subject: [PATCH 1/4] feat(tests): add unit tests for AssemblyMetadataGenerator --- .editorconfig | 396 +++++++++++++ .github/copilot-instructions.md | 255 +++++++++ .github/dependabot.yml | 15 - .github/release-drafter.yml | 109 ++++ .github/workflows/build-and-test.yml | 78 +++ .github/workflows/ci.yml | 57 -- .github/workflows/main-build.yml | 105 ++++ .github/workflows/pr-validation.yml | 17 + .github/workflows/release-publish.yml | 68 +++ .vscode/extensions.json | 27 + .vscode/settings.json | 77 +++ AssemblyMetadata.sln | 48 -- BenjaminAbt.AssemblyMetadata.slnx | 14 + Directory.Build.props | 157 +++++- Directory.Packages.props | 38 ++ Justfile | 72 +++ LICENSE | 2 +- NuGet.config | 9 + coverlet.runsettings | 14 + global.json | 4 +- readme.md | 528 +++++++++++++++++- res/assembly-metadata-logo-big.png | Bin 0 -> 10867 bytes res/assembly-metadata-logo-medium.png | Bin 0 -> 6858 bytes res/assembly-metadata-logo-small.png | Bin 0 -> 3498 bytes .../AssemblyMetadata.SampleApp.csproj | 9 +- sample/AssemblyMetadata.SampleApp/Program.cs | 25 +- src/AssemblyMetadata/AssemblyMetadata.csproj | 21 +- .../AssemblyMetadataGenerator.cs | 215 ++++--- .../AssemblyMetadata.UnitTests.csproj | 27 +- .../AssemblyMetadataGeneratorTests.cs | 334 +++++++++++ .../AssemblyMetadataInfoTests.cs | 96 +++- version.json | 45 +- 32 files changed, 2533 insertions(+), 329 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/copilot-instructions.md delete mode 100644 .github/dependabot.yml create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/build-and-test.yml delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/main-build.yml create mode 100644 .github/workflows/pr-validation.yml create mode 100644 .github/workflows/release-publish.yml create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json delete mode 100644 AssemblyMetadata.sln create mode 100644 BenjaminAbt.AssemblyMetadata.slnx create mode 100644 Directory.Packages.props create mode 100644 Justfile create mode 100644 NuGet.config create mode 100644 coverlet.runsettings create mode 100644 res/assembly-metadata-logo-big.png create mode 100644 res/assembly-metadata-logo-medium.png create mode 100644 res/assembly-metadata-logo-small.png create mode 100644 tests/AssemblyMetadata.UnitTests/AssemblyMetadataGeneratorTests.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8d88967 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,396 @@ +# EditorConfig is awesome: https://EditorConfig.org +# https://github.com/BenjaminAbt/templates/blob/main/editorconfig/.editorconfig + +############################### +# Core EditorConfig Options # +############################### + +# top-most EditorConfig file +root = true # stop .editorconfig files search on current file. + +# All files +# Don't use tabs for indentation. +[*] +indent_style = space +trim_trailing_whitespace = true # Remove trailing whitespace +insert_final_newline = true # Ensure file ends with a newline +max_line_length = 120 # Maximum line length for readability + +############################### +# Markdown # +############################### + +[*.md] +indent_size = 4 +trim_trailing_whitespace = false +max_line_length = off + +############################### +# XML # +############################### + +[*.xml] +indent_size = 2 +max_line_length = off + +############################### +# YAML # +############################### + +[*.{yml,yaml}] +indent_size = 2 +max_line_length = off + +############################### +# JSON # +############################### + +[*.json] +indent_size = 2 +max_line_length = off + +############################### +# PowerShell # +############################### + +[*.ps1] +indent_size = 2 + +############################### +# Shell # +############################### + +[*.sh] +end_of_line = lf + +[*.{cmd,bat}] +end_of_line = crlf + +############################### +# .NET project files # +############################### + +# Xml project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 + +# Xml config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + +############################### +# C# / VB # +############################### + +# Code files +[*.{cs,csx,vb,vbx}] +indent_size = 4 + +# Organize usings +dotnet_sort_system_directives_first = true + +# this. preferences +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_readonly_field = true:warning + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_allow_multiple_blank_lines_experimental = true:silent +dotnet_style_allow_statement_immediately_after_block_experimental = true:silent +dotnet_code_quality_unused_parameters = all:suggestion + +# Style Definitions +dotnet_naming_rule.interface_should_be_begins_with_i.severity = warning +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.constant_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_should_be_pascal_case.symbols = constant +dotnet_naming_rule.constant_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.private_or_internal_static_field_should_be_static_field.severity = suggestion +dotnet_naming_rule.private_or_internal_static_field_should_be_static_field.symbols = private_or_internal_static_field +dotnet_naming_rule.private_or_internal_static_field_should_be_static_field.style = static_field + +dotnet_naming_rule.private_or_internal_field_should_be_instance_field.severity = suggestion +dotnet_naming_rule.private_or_internal_field_should_be_instance_field.symbols = private_or_internal_field +dotnet_naming_rule.private_or_internal_field_should_be_instance_field.style = instance_field + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.static_field.required_prefix = s_ +dotnet_naming_style.static_field.required_suffix = +dotnet_naming_style.static_field.word_separator = +dotnet_naming_style.static_field.capitalization = camel_case + +dotnet_naming_style.instance_field.required_prefix = _ +dotnet_naming_style.instance_field.required_suffix = +dotnet_naming_style.instance_field.word_separator = +dotnet_naming_style.instance_field.capitalization = camel_case + +# Symbol specifications +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.private_or_internal_field.applicable_kinds = field +dotnet_naming_symbols.private_or_internal_field.applicable_accessibilities = internal, private, private_protected +dotnet_naming_symbols.private_or_internal_field.required_modifiers = + +dotnet_naming_symbols.private_or_internal_static_field.applicable_kinds = field +dotnet_naming_symbols.private_or_internal_static_field.applicable_accessibilities = internal, private, private_protected +dotnet_naming_symbols.private_or_internal_static_field.required_modifiers = static + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.constant.applicable_kinds = field +dotnet_naming_symbols.constant.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.constant.required_modifiers = const + +# var preferences +csharp_style_var_for_built_in_types = false:warning +csharp_style_var_when_type_is_apparent = false:warning +csharp_style_var_elsewhere = false:warning + +# Expression-bodied members +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_pattern_matching_over_as_with_null_check = true:warning + +# Null-checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion + +# Expression-level preferences +csharp_prefer_braces = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false + +# Wrapping preferences +csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:warning +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = false:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:silent +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_prefer_static_local_function = true:suggestion +csharp_style_prefer_readonly_struct = true:warning +csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion + +# ------------------------------------------------------ +# CA Style + +# CA1050: Types are declared in namespaces +dotnet_diagnostic.CA1050.severity = warning + +# CA1507: Use nameof in place of string +dotnet_diagnostic.CA1507.severity = warning + +# CA1825: Avoid unnecessary zero-length array allocations. Use Array.Empty() instead. +dotnet_diagnostic.CA1825.severity = warning + +# CA1850: Prefer static HashData method over instance ComputeHash +dotnet_diagnostic.CA1850.severity = warning + +# CA1860: Prefer IsEmpty, Count, or Length over Enumerable.Any() +dotnet_diagnostic.CA1860.severity = warning + +# CS1998: This async method lacks 'await' operators and will run synchronously. +dotnet_diagnostic.CS1998.severity = error + +# CA2016: Forward the CancellationToken parameter to methods that take one +dotnet_diagnostic.CA2016.severity = error + +# ------------------------------------------------------ +# IDE + +# IDE0060: Avoid unused parameters in your code. +dotnet_diagnostic.IDE0060.severity = silent + +# IDE0130: Namespace does not match folder structure +dotnet_diagnostic.IDE0130.severity = warning + +# IDE0039: Use local function instead of lambda +dotnet_diagnostic.IDE0039.severity = warning + +# IDE0270: Null check can be simplified +dotnet_diagnostic.IDE0270.severity = none + +# IDE0305: Use collection expression for fluent +dotnet_diagnostic.IDE0305.severity = silent + +# IDE1006: Naming rule violation +dotnet_diagnostic.IDE1006.severity = warning + +# ------------------------------------------------------ +# Roslyn + +# RCS0063: Remove unnecessary blank line +dotnet_diagnostic.RCS0063.severity = warning + +# RCS1021: Use expression-bodied lambda. +dotnet_diagnostic.RCS1021.severity = silent + +# RCS1049: Simplify boolean comparison +dotnet_diagnostic.RCS1049.severity = silent + +# RCS1123: Add parentheses when necessary +dotnet_diagnostic.RCS1123.severity = warning + +# RCS1163: Unused parameter +dotnet_diagnostic.RCS1163.severity = silent + +# RCS1194: Implement exception constructors +dotnet_diagnostic.RCS1194.severity = silent + +# ------------------------------------------------------ +# Meziantou.Analyzer + +# MA0007: Add a comma after the last value +dotnet_diagnostic.MA0007.severity = none + +# MA0011: IFormatProvider is missing +dotnet_diagnostic.MA0011.severity = none + +# MA0016: Prefer using collection abstraction instead of implementation +dotnet_diagnostic.MA0016.severity = none + +# MA0017: Abstract types should not have public or internal constructors +dotnet_diagnostic.MA0017.severity = none + +# MA0018: Do not declare static members on generic types +dotnet_diagnostic.MA0018.severity = none + +# MA0029: Combine LINQ methods +dotnet_diagnostic.MA0029.severity = none + +# MA0040: Forward the CancellationToken parameter to methods that take one +dotnet_diagnostic.MA0040.severity = warning + +# MA0048: File name must match type name +dotnet_diagnostic.MA0048.severity = silent + +# MA0051: Method is too long +dotnet_diagnostic.MA0051.severity = suggestion + +# MA0154: Use langword in XML comment +dotnet_diagnostic.MA0154.severity = none + +# ------------------------------------------------------ +# xUnit + +# xUnit1006: Theory methods should have parameters +dotnet_diagnostic.xUnit1006.severity = silent + +# xUnit1048: Support for 'async void' unit tests is being removed +dotnet_diagnostic.xUnit1048.severity = error diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..6318910 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,255 @@ +# GitHub Copilot Instructions - AssemblyMetadata + +## Project Overview + +**AssemblyMetadata** is a Roslyn **incremental source generator** (NuGet package) that embeds +build-time metadata - ISO 8601 UTC timestamp, Windows FileTime, and individual date/time +components - as `public const` fields directly into any consuming assembly. + +Key properties: +- **Zero runtime overhead** - all output members are `const`; the JIT inlines them at every call site +- **No runtime dependency** - the package ships as a Roslyn analyzer; nothing is added to the + consumer's runtime graph +- **Single implementation file** - `src/AssemblyMetadata/AssemblyMetadataGenerator.cs` +- **netstandard2.0 generator, net8/9/10 consumers** - the generator runs inside the Roslyn/MSBuild + host, which requires `netstandard2.0` + +--- + +## Repository Layout + +``` +src/ + AssemblyMetadata/ + AssemblyMetadata.csproj # netstandard2.0, IsPackable=true, IncludeBuildOutput=false + AssemblyMetadataGenerator.cs # THE source generator - only file in this project + +tests/ + AssemblyMetadata.UnitTests/ + AssemblyMetadata.UnitTests.csproj + AssemblyMetadataInfoTests.cs # Tests baked-in constants (compile-time output) + AssemblyMetadataGeneratorTests.cs# Tests generator code paths via CSharpGeneratorDriver + +sample/ + AssemblyMetadata.SampleApp/ + Program.cs # Console demo of all generated constants + +Directory.Build.props # Shared MSBuild properties (Unio-style) +Directory.Packages.props # Central Package Management (CPM) - all version pins here +global.json # SDK version pin (10.0.103) +Justfile # Task runner (just) +coverlet.runsettings # Code-coverage configuration +NuGet.config # Explicit nuget.org source +.editorconfig # Roslyn/Meziantou code-style rules +``` + +--- + +## Core Architecture + +### How the Generator Works + +``` +dotnet build + └─ Roslyn creates new CSharpCompilation + └─ AssemblyMetadataGenerator.Initialize() registers source output + └─ AssemblyMetadataGenerator.Execute() is called + ├─ DateTimeOffset.UtcNow captured + └─ AssemblyMetadataGenerator.BuildSource() called + └─ AssemblyMetadataInfo.gen.cs emitted into the compilation + └─ Compiled as internal constants inside the consumer assembly +``` + +### Generated Output Structure + +```csharp +// +namespace BenjaminAbt.AssemblyMetadata +{ + internal static class AssemblyMetadataInfo + { + internal static class BuildInfo + { + public const string BuildTimestamp = "2026-03-02T14:35:07.1234567+00:00"; + public const long BuildFileTimeUtc = 133876221071234567L; + public const int BuildDateYear = 2026; + public const int BuildDateMonth = 3; + public const int BuildDateDay = 2; + public const int BuildTimeHour = 14; + public const int BuildTimeMinute = 35; + public const int BuildTimeSecond = 7; + } + } +} +``` + +--- + +## Coding Standards + +### C# Conventions + +- **Language version:** `preview` (latest C# features available) +- **File-scoped namespaces** - always use `namespace Foo.Bar;` not `namespace Foo.Bar { }` +- **Implicit usings** - `enable`; do not add redundant `using System;` etc. +- **Nullable** - `enable`; all reference types annotated +- **`sealed` classes** - prefer `sealed` for all concrete classes unless inheritance is intended +- **`TreatWarningsAsErrors=true`** - all `dotnet build` warnings are hard errors + +### Source Generator Rules + +- **Always implement `IIncrementalGenerator`** - never use the legacy `ISourceGenerator` +- The `SourceProductionContext.AddSource` hint name must end in `.gen.cs` for tooling recognition +- Use `CultureInfo.InvariantCulture` for all numeric formatting inside `BuildSource` +- Use `DateTimeOffset` (not `DateTime`) so the UTC offset is preserved in the ISO 8601 string +- The generator DLL **must** target `netstandard2.0`; clear global TargetFrameworks with + `` before re-declaring `netstandard2.0` +- Set `true` on the generator project +- Set `false` - the DLL ships only in + `analyzers/dotnet/cs`, not as a compile-time reference + +### Documentation + +- Every `public` and `internal` member gets an XML `` +- Private methods that contain non-trivial logic get full XML docs (``, ``, + ``, ``) +- Inline comments explain *why*, not *what* +- All XML docs reference relevant types with `` + +--- + +## Test Conventions + +### Two Test Classes - Different Purposes + +| Class | What It Tests | Key Tool | +|---|---|---| +| `AssemblyMetadataInfoTests` | Values baked into the compiled binary | Plain xunit assertions on `const` fields | +| `AssemblyMetadataGeneratorTests` | Generator code paths at runtime | `CSharpGeneratorDriver` | + +### xunit.v3 MTP Mode + +- Framework: **xunit.v3 3.2.2** with Microsoft Testing Platform (MTP) +- `Microsoft.NET.Test.Sdk` is **intentionally excluded** - it generates a competing entry point +- `OutputType=Exe` + `UseAppHost=true` + `CS8892` suppressed in `Directory.Build.props` +- **Run tests with:** `dotnet run --project tests/AssemblyMetadata.UnitTests/...` + - NOT `dotnet test` (vstest infrastructure is incompatible with MTP mode without Test.Sdk) +- Use the Justfile: `just test` + +### Writing Generator Tests + +Always use `CSharpGeneratorDriver`: + +```csharp +private static CSharpCompilation CreateEmptyCompilation() => + CSharpCompilation.Create( + assemblyName: "TestAssembly", + syntaxTrees: [], + references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + +[Fact] +public void Generator_ProducesExactlyOneGeneratedFile() +{ + var result = CSharpGeneratorDriver.Create(new AssemblyMetadataGenerator()) + .RunGenerators(CreateEmptyCompilation()) + .GetRunResult(); + Assert.Single(result.GeneratedTrees); +} +``` + +### xunit Assertion Rules + +- **xUnit2000**: constant/expected value must be the **first** argument to `Assert.Equal`: + ```csharp + // ✅ correct + Assert.Equal(AssemblyMetadataInfo.BuildInfo.BuildDateYear, buildOn.Year); + + // ❌ wrong - flips expected/actual in failure messages + Assert.Equal(buildOn.Year, AssemblyMetadataInfo.BuildInfo.BuildDateYear); + ``` +- Coverage threshold: **90%** (configured in `Directory.Build.props`); aim for 100% on + `AssemblyMetadataGenerator.cs` via driver-based tests + +--- + +## Build System + +### Central Package Management + +All NuGet versions live in `Directory.Packages.props`. **Never** specify a `Version` attribute +on a `` in a project file. + +```xml + + + + + +``` + +### Common Justfile Commands + +```bash +just build # debug build +just test # run all tests (dotnet run) +just test-cov # run tests with coverage report +just pack # create NuGet package +just ci # full pipeline: clean → restore → format-check → build → test +just fmt-build # format + build +``` + +### Blocked Packages + +`Directory.Build.props` has an MSBuild target that hard-errors on: +- `Devlooped.SponsorLink` - telemetry/tracking +- `FluentAssertions` - license concerns; use plain `Assert.*` instead + +--- + +## Adding New Generated Constants + +To add a new constant to `BuildInfo`: + +1. In `AssemblyMetadataGenerator.cs` → `BuildSource()`: add a new `AppendLine` with the constant + (derive all values solely from the existing `buildOn` parameter - do not call `UtcNow` again) +2. In `AssemblyMetadataGeneratorTests.cs`: add a `[Fact]` verifying the constant is declared +3. In `AssemblyMetadataInfoTests.cs`: add a `[Fact]` verifying the baked-in value is correct +4. Update the API table in `readme.md` + +--- + +## NuGet Package + +The package is configured in `Directory.Build.props` (`Label="Package"`) and the generator +csproj. To produce a package: + +```bash +just pack +# → ./artifacts/packages/AssemblyMetadata.{version}.nupkg +``` + +The `.nupkg` contains the generator DLL at `analyzers/dotnet/cs/BenjaminAbt.AssemblyMetadata.dll` +and the logo icon. It includes **no `lib/` folder** (`IncludeBuildOutput=false`). + +Consumers must reference it as: +```xml + +``` + +--- + +## Critical Constraints - Don't Break These + +| Constraint | Reason | +|---|---| +| Generator targets `netstandard2.0` | Roslyn/MSBuild host requires it | +| `IncludeBuildOutput=false` | Prevents the generator DLL becoming a runtime reference | +| `EnforceExtendedAnalyzerRules=true` | Required by MSBuild for projects containing analyzers | +| All constants are `const` (not static readonly) | Enables JIT inlining and dead-code elimination | +| All values derived from one `DateTimeOffset` | Guarantees internal consistency of all 8 constants | +| `CultureInfo.InvariantCulture` for all numerics | Prevents locale-dependent source output | +| `DateTimeOffset.UtcNow` (not `DateTime.UtcNow`) | Preserves UTC offset in ISO 8601 string | diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 86e6016..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,15 +0,0 @@ -version: 2 -updates: - - directory: "/" - open-pull-requests-limit: 5 - package-ecosystem: nuget - rebase-strategy: auto - schedule: - interval: "weekly" - - - directory: "/" - open-pull-requests-limit: 5 - package-ecosystem: github-actions - rebase-strategy: auto - schedule: - interval: "weekly" \ No newline at end of file diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..025daff --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,109 @@ +# Release Drafter Configuration +# Documentation: https://github.com/release-drafter/release-drafter + +name-template: 'Version $RESOLVED_VERSION' +tag-template: 'v$RESOLVED_VERSION' + +# Categories for organizing release notes +categories: + - title: '🚀 Features' + labels: + - 'feature' + - 'enhancement' + - title: '🐛 Bug Fixes' + labels: + - 'bug' + - 'fix' + - title: '🔧 Maintenance' + labels: + - 'maintenance' + - 'chore' + - 'refactor' + - 'dependencies' + - title: '📚 Documentation' + labels: + - 'documentation' + - 'docs' + - title: '⚡ Performance' + labels: + - 'performance' + - title: '🔒 Security' + labels: + - 'security' + +# Exclude certain labels from release notes +exclude-labels: + - 'skip-changelog' + - 'wip' + +# Change template (how each PR is listed) +change-template: '- $TITLE @$AUTHOR (#$NUMBER)' +change-title-escapes: '\<*_&' # Escape special markdown characters + +# Template for the release body +template: | + ## What's Changed + + $CHANGES + + ## 📦 NuGet Packages + + The following packages are included in this release: + + | Package | Version | Description | + |---|---|---| + | `AssemblyMetadata` | $RESOLVED_VERSION | Metadata attributes for assemblies. | + + + ### Installation + + ```bash + dotnet add package AssemblyMetadata + ``` + + ## Contributors + + $CONTRIBUTORS + + --- + + **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION + +# Automatically label PRs based on modified files +autolabeler: + - label: 'documentation' + files: + - '*.md' + - 'docs/**/*' + - label: 'bug' + branch: + - '/fix\/.+/' + title: + - '/fix/i' + - label: 'feature' + branch: + - '/feature\/.+/' + title: + - '/feature/i' + - label: 'dependencies' + files: + - '**/packages.lock.json' + - '**/*.csproj' + - 'Directory.Packages.props' + - 'Directory.Build.props' + - label: 'github-actions' + files: + - '.github/workflows/**/*' + - label: 'tests' + files: + - 'tests/**/*' + - '**/*Tests.cs' + - '**/*Test.cs' + - label: 'performance' + files: + - 'perf/**/*' + - '**/*Benchmark*.cs' + +# Version resolver (uses version from workflow input) +version-resolver: + default: patch \ No newline at end of file diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..6024c96 --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,78 @@ +name: Build and Test (Reusable) + +on: + workflow_call: + inputs: + configuration: + description: 'Build configuration' + required: false + type: string + default: 'Release' + upload-test-results: + description: 'Whether to upload test results as artifacts' + required: false + type: boolean + default: false + create-pack: + description: 'Whether to pack NuGet packages' + required: false + type: boolean + default: false + outputs: + version: + description: 'The calculated version from NBGV' + value: ${{ jobs.build.outputs.version }} + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + outputs: + version: ${{ steps.nbgv.outputs.SemVer2 }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET (stable) + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Setup .NET (preview) + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 11.0.x + dotnet-quality: preview + + - name: Calculate Version with NBGV + uses: dotnet/nbgv@master + id: nbgv + with: + setAllVars: true + + - name: Version Info + run: | + echo "Calculated version: ${{ steps.nbgv.outputs.SemVer2 }}" + + - name: Build and Test + run: | + dotnet build --configuration ${{ inputs.configuration }} --nologo /p:Version=${{ steps.nbgv.outputs.SemVer2 }} + dotnet run --project tests/AssemblyMetadata.UnitTests/AssemblyMetadata.UnitTests.csproj --configuration ${{ inputs.configuration }} --no-build + + - name: Pack NuGet packages + if: inputs.create-pack + run: dotnet pack --configuration ${{ inputs.configuration }} --no-build --output ./artifacts /p:PackageVersion=${{ steps.nbgv.outputs.SemVer2 }} + + - name: Upload NuGet packages + if: inputs.create-pack + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: ./artifacts/*.nupkg + retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 7f60aa8..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: NET - -on: - push: - branches: - - main - pull_request: - branches: - - main - -env: - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true - BuildConfig: Release - DOTNET_SDK: '7.0.102' # https://dotnetcli.blob.core.windows.net/dotnet/release-metadata/7.0/releases.json - -jobs: - build: - runs-on: ubuntu-latest - steps: - - - name: Cancel previous builds in PR - uses: styfle/cancel-workflow-action@0.11.0 - with: - access_token: ${{ github.token }} - - - uses: actions/checkout@v3 - with: - fetch-depth: 0 # avoid shallow clone so nbgv can do its work. - - - uses: actions/setup-dotnet@v3 - with: - dotnet-version: ${{ env.DOTNET_SDK }} - - - uses: dotnet/nbgv@master # https://github.com/dotnet/nbgv - id: nbgv - - - name: Versioning - run: echo ${{ steps.nbgv.outputs.SemVer2 }} - - - name: Build with dotnet - run: dotnet build - --configuration ${{ env.BuildConfig }} - /p:Version=${{ steps.nbgv.outputs.AssemblyVersion }} - - - name: Test with dotnet - run: dotnet test - - - name: Pack NuGet - run: dotnet pack - --configuration ${{ env.BuildConfig }} - /p:Version=${{ steps.nbgv.outputs.NuGetPackageVersion }} - - - name: Push to NuGet - run: dotnet nuget push **/*.nupkg - --api-key ${{ secrets.NUGET_DEPLOY_KEY }} - --source https://api.nuget.org/v3/index.json - --no-symbols \ No newline at end of file diff --git a/.github/workflows/main-build.yml b/.github/workflows/main-build.yml new file mode 100644 index 0000000..fb56118 --- /dev/null +++ b/.github/workflows/main-build.yml @@ -0,0 +1,105 @@ +name: Main Build + +on: + push: + branches: + - main + +permissions: + contents: write + packages: write + +jobs: + build-and-test: + name: Build, Test and Pack + uses: ./.github/workflows/build-and-test.yml + with: + create-pack: true + + create-draft-release: + name: Create Draft Release + needs: build-and-test + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download NuGet packages + uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: ./artifacts + + - name: Check if tag exists + id: check-tag + run: | + TAG="v${{ needs.build-and-test.outputs.version }}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "⚠️ Tag $TAG already exists" + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "✅ Tag $TAG does not exist yet" + fi + + - name: Delete existing draft releases + if: steps.check-tag.outputs.exists == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "Checking for existing draft releases..." + gh release list --json isDraft,tagName --jq '.[] | select(.isDraft) | .tagName' | while read -r tag_name; do + echo "Deleting existing draft release: $tag_name" + gh release delete "$tag_name" --yes --cleanup-tag || echo "Failed to delete release $tag_name" + done + + - name: Create Draft Release + if: steps.check-tag.outputs.exists == 'false' + uses: release-drafter/release-drafter@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + config-name: release-drafter.yml + version: v${{ needs.build-and-test.outputs.version }} + tag: v${{ needs.build-and-test.outputs.version }} + name: Version ${{ needs.build-and-test.outputs.version }} + publish: false + prerelease: false + + - name: Upload packages to draft release + if: steps.check-tag.outputs.exists == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="v${{ needs.build-and-test.outputs.version }}" + # Wait a moment for the release to be created + sleep 2 + + # Upload new artifacts to the draft release + echo "📦 Uploading new NuGet packages..." + for file in ./artifacts/*.nupkg; do + gh release upload "$TAG" "$file" --clobber + done + echo "✅ Uploaded NuGet packages to draft release" + + - name: Summary + if: steps.check-tag.outputs.exists == 'false' + run: | + echo "✅ Draft release created/updated" >> $GITHUB_STEP_SUMMARY + echo "Version: v${{ needs.build-and-test.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Next steps:" >> $GITHUB_STEP_SUMMARY + echo "1. Go to [Releases](../../releases)" >> $GITHUB_STEP_SUMMARY + echo "2. Review the draft release" >> $GITHUB_STEP_SUMMARY + echo "3. Edit release notes if needed" >> $GITHUB_STEP_SUMMARY + echo "4. Publish release to trigger production deployment" >> $GITHUB_STEP_SUMMARY + + - name: Release already exists + if: steps.check-tag.outputs.exists == 'true' + run: | + echo "ℹ️ Release v${{ needs.build-and-test.outputs.version }} already exists" >> $GITHUB_STEP_SUMMARY + echo "No action taken" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..1a1c5b8 --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,17 @@ +name: PR Validation + +on: + pull_request: + branches: + - main + +permissions: + contents: read + pull-requests: read + +jobs: + validate: + name: Build and Test (all frameworks) + uses: ./.github/workflows/build-and-test.yml + with: + upload-test-results: true \ No newline at end of file diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000..580467a --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,68 @@ +name: Publish Release + +on: + release: + types: [published] + +permissions: + contents: read + packages: write + +jobs: + publish-nuget: + name: Publish to NuGet.org + runs-on: ubuntu-latest + + steps: + - name: Download release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download "${{ github.event.release.tag_name }}" \ + --pattern "*.nupkg" \ + --dir ./artifacts \ + --repo "${{ github.repository }}" + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + 11.0.x + + - name: Verify packages + run: | + echo "Packages to be published:" + ls -la ./artifacts/*.nupkg + + # Verify package count + PACKAGE_COUNT=$(ls ./artifacts/*.nupkg | wc -l) + if [ "$PACKAGE_COUNT" -eq 0 ]; then + echo "Error: No packages found!" + exit 1 + fi + + echo "Found $PACKAGE_COUNT package(s) to publish" + + - name: Publish to NuGet.org + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + for package in ./artifacts/*.nupkg; do + echo "Publishing $package to NuGet.org..." + dotnet nuget push "$package" \ + --api-key "$NUGET_API_KEY" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + done + + echo "All packages published successfully!" + + - name: Upload published packages as artifacts + uses: actions/upload-artifact@v4 + with: + name: published-nuget-packages + path: ./artifacts/*.nupkg + retention-days: 90 \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..606d959 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,27 @@ +{ + "recommendations": [ + // C# / .NET + "ms-dotnettools.csharp", + "ms-dotnettools.csdevkit", + "ms-dotnettools.vscode-dotnet-runtime", + + // GitHub Copilot + "github.copilot", + "github.copilot-chat", + + // NuGet + "jmrog.vscode-nuget-package-manager", + "aliasadidev.nugetpackagemanagergui", + + // XML / YAML / JSON + "redhat.vscode-xml", + "redhat.vscode-yaml", + + // Editor quality + "editorconfig.editorconfig", + "streetsidesoftware.code-spell-checker", + + // Just task runner + "skellock.just" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0a77cf1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,77 @@ +{ + // ── Copilot ─────────────────────────────────────────────────────────────────────────────── + // Commit message generation instructions (Copilot preview feature) + "github.copilot.chat.commitMessageGeneration.instructions": [ + { + "text": "Use conventional commit format: type(scope): description" + }, + { + "text": "Use imperative mood: 'Add feature' not 'Added feature'" + }, + { + "text": "Keep subject line under 50 characters" + }, + { + "text": "Use types: feat, fix, docs, style, refactor, perf, test, chore, ci" + }, + { + "text": "Include scope when relevant (e.g., generator, tests, ci, docs)" + }, + { + "text": "Reference issue numbers with # prefix" + } + ], + // ── Editor ──────────────────────────────────────────────────────────────────────────────── + "editor.formatOnSave": true, + "editor.formatOnPaste": false, + "editor.insertSpaces": true, + "editor.tabSize": 4, + "editor.rulers": [ + 120 + ], + "editor.bracketPairColorization.enabled": true, + "editor.guides.bracketPairs": true, + "editor.suggest.snippetsPreventQuickSuggestions": false, + "editor.inlineSuggest.enabled": true, + // ── Files ───────────────────────────────────────────────────────────────────────────────── + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.encoding": "utf8", + "files.eol": "\n", + // Hide generated/build artefacts from the Explorer + "files.exclude": { + "**/bin": true, + "**/obj": true, + "**/.vs": true, + "**/TestResults": true, + "**/artifacts": true + }, + "search.exclude": { + "**/bin": true, + "**/obj": true, + "**/.vs": true, + "**/TestResults": true, + "**/artifacts": true + }, + // ── C# ─────────────────────────────────────────────────────────────────────────────────── + "dotnet.defaultSolution": "BenjaminAbt.AssemblyMetadata.slnx", + // Run tests through dotnet run (xunit.v3 MTP mode - dotnet test is not supported without Test.Sdk) + "dotnet-test-explorer.testProjectPath": "tests/**/*.UnitTests.csproj", + "dotnet-test-explorer.useTreeView": true, + // ── XML / MSBuild ───────────────────────────────────────────────────────────────────────── + "[xml]": { + "editor.defaultFormatter": "redhat.vscode-xml", + "editor.tabSize": 2 + }, + "[csharp]": { + "editor.defaultFormatter": "ms-dotnettools.csharp" + }, + "[markdown]": { + "editor.wordWrap": "on", + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[yaml]": { + "editor.defaultFormatter": "redhat.vscode-yaml", + "editor.tabSize": 2 + } +} diff --git a/AssemblyMetadata.sln b/AssemblyMetadata.sln deleted file mode 100644 index 95cee1e..0000000 --- a/AssemblyMetadata.sln +++ /dev/null @@ -1,48 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31521.260 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AssemblyMetadata", "src\AssemblyMetadata\AssemblyMetadata.csproj", "{436EEC27-4983-46AB-BD48-3DEBD656D348}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AssemblyMetadata.UnitTests", "tests\AssemblyMetadata.UnitTests\AssemblyMetadata.UnitTests.csproj", "{9539FEA1-624D-4B73-96A6-C7C7833EC8B7}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AssemblyMetadata.SampleApp", "sample\AssemblyMetadata.SampleApp\AssemblyMetadata.SampleApp.csproj", "{CC278D80-0849-4F7E-BBE5-B2844EDE9A56}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{11AB7B9F-19C3-4CFF-9512-E4AF88B9590F}" - ProjectSection(SolutionItems) = preProject - .github\workflows\ci.yml = .github\workflows\ci.yml - .github\dependabot.yml = .github\dependabot.yml - Directory.Build.props = Directory.Build.props - global.json = global.json - LICENSE = LICENSE - readme.md = readme.md - version.json = version.json - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {436EEC27-4983-46AB-BD48-3DEBD656D348}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {436EEC27-4983-46AB-BD48-3DEBD656D348}.Debug|Any CPU.Build.0 = Debug|Any CPU - {436EEC27-4983-46AB-BD48-3DEBD656D348}.Release|Any CPU.ActiveCfg = Release|Any CPU - {436EEC27-4983-46AB-BD48-3DEBD656D348}.Release|Any CPU.Build.0 = Release|Any CPU - {9539FEA1-624D-4B73-96A6-C7C7833EC8B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9539FEA1-624D-4B73-96A6-C7C7833EC8B7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9539FEA1-624D-4B73-96A6-C7C7833EC8B7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9539FEA1-624D-4B73-96A6-C7C7833EC8B7}.Release|Any CPU.Build.0 = Release|Any CPU - {CC278D80-0849-4F7E-BBE5-B2844EDE9A56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CC278D80-0849-4F7E-BBE5-B2844EDE9A56}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CC278D80-0849-4F7E-BBE5-B2844EDE9A56}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CC278D80-0849-4F7E-BBE5-B2844EDE9A56}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {23B21D94-21C2-4356-AC93-2805F5E9A36E} - EndGlobalSection -EndGlobal diff --git a/BenjaminAbt.AssemblyMetadata.slnx b/BenjaminAbt.AssemblyMetadata.slnx new file mode 100644 index 0000000..f298fbe --- /dev/null +++ b/BenjaminAbt.AssemblyMetadata.slnx @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Directory.Build.props b/Directory.Build.props index c47f631..886e585 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,26 +1,135 @@ + + BenjaminAbt.AssemblyMetadata + BenjaminAbt + Benjamin Abt + Benjamin Abt + - - 2.12 - Benjamin Abt - https://github.com/BenjaminAbt/AssemblyMetadata - MIT - en-US - Assembly Metadata Information - true - preview - enable - true - true - embedded - $(MSBuildProjectName.Contains('Test')) - $(MsBuildProjectName.Contains('Benchmark')) - false - true - - - - true - - - \ No newline at end of file + + true + true + true + + $(MSBuildProjectName.EndsWith('Tests')) + $(MSBuildProjectName.EndsWith('UnitTests')) + $(MSBuildProjectName.EndsWith('IntegrationTests')) + $(MsBuildProjectName.EndsWith('Benchmarks')) + + + + net8.0;net9.0;net10.0;net11.0 + $(MSBuildProjectName) + $(MSBuildProjectName) + + + + preview + embedded + enable + en-US + enable + true + + + + true + true + + + + false + true + 2.12 + true + + BenjaminAbt.AssemblyMetadata - Build Metadata Source Generator + + A Roslyn incremental source generator that embeds build-time metadata + (timestamp, date, time components) as compile-time constants directly into your assembly. + Zero runtime overhead, no reflection required. + + https://github.com/BenjaminAbt/AssemblyMetadata + https://github.com/BenjaminAbt/AssemblyMetadata + MIT + SourceGenerator, AssemblyMetadata, BuildInfo, Roslyn, DotNet + + assembly-metadata-logo-small.png + + + + + + + + + true + + + + true + + + + true + all + low + + + + + Exe + true + + $(NoWarn);CS8892 + + + + + true + lcov,opencover,cobertura + $(MSBuildThisFileDirectory)TestResults/coverage/$(MSBuildProjectName). + GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute + **/*Program.cs;**/*Startup.cs;**/*GlobalUsings.cs + true + 90 + line + total + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..3f607d2 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,38 @@ + + + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers + + + diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..eee75c3 --- /dev/null +++ b/Justfile @@ -0,0 +1,72 @@ +# Justfile .NET - BEN ABT 2025 - https://benjamin-abt.com + +set shell := ["pwsh", "-c"] + +# ===== Configurable defaults ===== +CONFIG := "Debug" +TFM := "net10.0" + +# ===== Default / Help ===== +default: help + +help: + # Overview: + just --list + # Usage: + # just build + # just test + # just pack + +# ===== Basic .NET Workflows ===== +restore: + dotnet restore + +build *ARGS: + dotnet build --configuration "{{CONFIG}}" --nologo --verbosity minimal {{ARGS}} + +rebuild *ARGS: + dotnet build --configuration "{{CONFIG}}" --nologo --verbosity minimal --no-incremental {{ARGS}} + +clean: + dotnet clean --configuration "{{CONFIG}}" --nologo + +# ===== Quality / Tests ===== +format: + dotnet format --verbosity minimal + +format-check: + dotnet format --verify-no-changes --verbosity minimal + +# xunit.v3 uses Microsoft Testing Platform (MTP) - run tests via dotnet run (not dotnet test) +test *ARGS: + dotnet run --project "tests/AssemblyMetadata.UnitTests/AssemblyMetadata.UnitTests.csproj" --configuration "{{CONFIG}}" --framework "{{TFM}}" {{ARGS}} + +test-cov: + dotnet run --project "tests/AssemblyMetadata.UnitTests/AssemblyMetadata.UnitTests.csproj" --configuration "{{CONFIG}}" --framework "{{TFM}}" -- --coverage --coverage-output "./TestResults/coverage/coverage.cobertura.xml" --coverage-output-format cobertura + +test-filter QUERY: + dotnet run --project "tests/AssemblyMetadata.UnitTests/AssemblyMetadata.UnitTests.csproj" --configuration "{{CONFIG}}" --framework "{{TFM}}" -- --filter "{{QUERY}}" + +# ===== Packaging / Release ===== +pack *ARGS: + dotnet pack --configuration "{{CONFIG}}" --nologo --verbosity minimal -o "./artifacts/packages" {{ARGS}} + +# ===== Housekeeping ===== +clean-artifacts: + if (Test-Path "./artifacts") { Remove-Item "./artifacts" -Recurse -Force } + +clean-all: + just clean + just clean-artifacts + +# ===== Combined Flows ===== +fmt-build: + just format + just build + +ci: + just clean + just restore + just format-check + just build + just test diff --git a/LICENSE b/LICENSE index 325f550..8b4c4ad 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Benjamin Abt +Copyright (c) 2021-2026 Benjamin Abt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..9033299 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/coverlet.runsettings b/coverlet.runsettings new file mode 100644 index 0000000..852e304 --- /dev/null +++ b/coverlet.runsettings @@ -0,0 +1,14 @@ + + + + + + + cobertura + **/obj/**;**/*.g.cs;**/*.generated.cs + [*]System.Text.RegularExpressions.Generated* + + + + + diff --git a/global.json b/global.json index 1a974fd..2fe6c88 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "7.0.102" + "version": "11.0.100-preview.1.26104.118" } -} \ No newline at end of file +} diff --git a/readme.md b/readme.md index d614a0c..0db9851 100644 --- a/readme.md +++ b/readme.md @@ -1,52 +1,530 @@ # AssemblyMetadata -Hi, +

+ AssemblyMetadata +

-I'm AssemblyMetadata by Benjamin Abt. I'm a small sample how to use Source Code Generators with .NET. -In this case, this example just adds the local timestamp on build into a static class. You can use it to show your users when your application was built. +[![Main Build](https://github.com/BenjaminAbt/AssemblyMetadata/actions/workflows/main-build.yml/badge.svg)](https://github.com/BenjaminAbt/AssemblyMetadata/actions/workflows/main-build.yml) +[![NuGet](https://img.shields.io/nuget/v/AssemblyMetadata.svg?logo=nuget&label=AssemblyMetadata)](https://www.nuget.org/packages/AssemblyMetadata) +[![NuGet Downloads](https://img.shields.io/nuget/dt/AssemblyMetadata?logo=nuget&label=Downloads)](https://www.nuget.org/packages/AssemblyMetadata) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![.NET](https://img.shields.io/badge/.NET-8%20%7C%209%20%7C%2010%20%7C%2011-512BD4?logo=dotnet)](https://dotnet.microsoft.com) -## NuGet +A **Roslyn incremental source generator** that embeds build-time metadata - timestamp, date and +time components - as **compile-time constants** directly into your assembly. +Zero runtime overhead. No reflection. No configuration required. -| NuGet | -|-| -| [![AssemblyMetadata](https://img.shields.io/nuget/v/AssemblyMetadata.svg?logo=nuget&label=AssemblyMetadata)](https://www.nuget.org/packages/AssemblyMetadata) | +--- + +## Table of Contents + +- [Why AssemblyMetadata?](#why-assemblymetadata) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [API Reference](#api-reference) +- [Usage Examples](#usage-examples) + - [Display Build Timestamp](#display-build-timestamp) + - [Parse into DateTimeOffset](#parse-into-datetimeoffset) + - [Reconstruct from FileTime (zero-allocation)](#reconstruct-from-filetime-zero-allocation) + - [Use Individual Components](#use-individual-components) + - [Build Age Check](#build-age-check) + - [Health Endpoint](#health-endpoint) +- [How It Works](#how-it-works) +- [Target Frameworks](#target-frameworks) +- [Building & Testing](#building--testing) +- [Project Structure](#project-structure) +- [Contributing](#contributing) +- [License](#license) + +--- + +## Why AssemblyMetadata? + +Knowing *when* an assembly was built is useful for diagnostics, "About" screens, deployment +validation, and telemetry. The traditional approaches all have trade-offs: + +| Approach | Runtime cost | Dependency | Works with AOT? | +|---|---|---|---| +| Read `AssemblyInformationalVersion` attribute | Reflection at runtime | None | ⚠️ Limited | +| Embed a resource file with the date | Resource deserialization | Build task | ⚠️ Yes | +| **AssemblyMetadata (this package)** | **Zero - values are `const`** | **None (analyzer only)** | **✅ Yes** | + +AssemblyMetadata solves this differently: + +- **Compile-time constants** - values are `const`, so the JIT can inline and dead-code-eliminate them +- **Zero-cost access** - reading the timestamp costs nothing beyond a register load +- **No dependencies at runtime** - the NuGet package ships as a Roslyn source generator; + nothing is added to your runtime dependency graph +- **NativeAOT-compatible** - `const` fields have no reflection or dynamic dispatch +- **Incremental generator** - uses the modern Roslyn `IIncrementalGenerator` API, so the generator + only re-runs when the compilation changes, keeping build times fast + +--- + +## Installation + +Add the package to **any project** that needs build metadata: + +```xml + +``` + +> **`OutputItemType="Analyzer"`** and **`ReferenceOutputAssembly="false"`** are required. +> They instruct MSBuild to load the package as a Roslyn source generator (not a regular assembly +> reference), producing zero runtime dependencies. + +--- + +## Quick Start + +After adding the package, the generated class `AssemblyMetadataInfo` is immediately available +anywhere in your project under the `BenjaminAbt.AssemblyMetadata` namespace: + +```csharp +using BenjaminAbt.AssemblyMetadata; + +// ISO 8601 UTC timestamp of the build +string timestamp = AssemblyMetadataInfo.BuildInfo.BuildTimestamp; +// → "2026-03-02T14:35:07.1234567+00:00" + +Console.WriteLine($"Built on {AssemblyMetadataInfo.BuildInfo.BuildDateYear}-" + + $"{AssemblyMetadataInfo.BuildInfo.BuildDateMonth:D2}-" + + $"{AssemblyMetadataInfo.BuildInfo.BuildDateDay:D2} " + + $"at {AssemblyMetadataInfo.BuildInfo.BuildTimeHour:D2}:" + + $"{AssemblyMetadataInfo.BuildInfo.BuildTimeMinute:D2}:" + + $"{AssemblyMetadataInfo.BuildInfo.BuildTimeSecond:D2} UTC"); +``` + +No additional configuration, properties, or attributes are required. + +--- + +## API Reference + +The generator produces a single file (`AssemblyMetadataInfo.gen.cs`) in the +`BenjaminAbt.AssemblyMetadata` namespace. All members are `public const`. + +### `AssemblyMetadataInfo.BuildInfo` + +| Member | Type | Description | +|---|---|---| +| `BuildTimestamp` | `string` | Build time as a UTC ISO 8601 round-trip string (`"o"` format specifier) | +| `BuildFileTimeUtc` | `long` | Build time as a Windows FileTime - 100-nanosecond intervals since 1601-01-01T00:00:00Z | +| `BuildDateYear` | `int` | Year component of the UTC build date | +| `BuildDateMonth` | `int` | Month component of the UTC build date (1–12) | +| `BuildDateDay` | `int` | Day component of the UTC build date (1–31) | +| `BuildTimeHour` | `int` | Hour component of the UTC build time (0–23) | +| `BuildTimeMinute` | `int` | Minute component of the UTC build time (0–59) | +| `BuildTimeSecond` | `int` | Second component of the UTC build time (0–59) | + +--- + +## Usage Examples + +### Display Build Timestamp + +```csharp +using BenjaminAbt.AssemblyMetadata; + +Console.WriteLine(AssemblyMetadataInfo.BuildInfo.BuildTimestamp); +// → 2026-03-02T14:35:07.1234567+00:00 +``` + +### Parse into DateTimeOffset + +Use the `"o"` round-trip format specifier to parse the stored constant back into a +`DateTimeOffset` - the same format used by the generator: + +```csharp +using System; +using BenjaminAbt.AssemblyMetadata; + +DateTimeOffset buildOn = DateTimeOffset.ParseExact( + AssemblyMetadataInfo.BuildInfo.BuildTimestamp, "o", null); + +Console.WriteLine($"Built {(DateTimeOffset.UtcNow - buildOn).Days} days ago."); +``` + +### Reconstruct from FileTime (zero-allocation) + +`BuildFileTimeUtc` lets you reconstruct a `DateTimeOffset` without any string parsing: + +```csharp +using System; +using BenjaminAbt.AssemblyMetadata; + +DateTimeOffset buildOn = + DateTimeOffset.FromFileTime(AssemblyMetadataInfo.BuildInfo.BuildFileTimeUtc); +``` + +This is the fastest way to get a `DateTimeOffset` representation of the build time. + +### Use Individual Components + +The integer constants allow zero-allocation formatting and direct numeric comparison: + +```csharp +using BenjaminAbt.AssemblyMetadata; + +// Compose a date string without DateTimeOffset overhead +string buildDate = + $"{AssemblyMetadataInfo.BuildInfo.BuildDateYear}-" + + $"{AssemblyMetadataInfo.BuildInfo.BuildDateMonth:D2}-" + + $"{AssemblyMetadataInfo.BuildInfo.BuildDateDay:D2}"; + +// Direct year comparison - no parsing, no allocation +if (AssemblyMetadataInfo.BuildInfo.BuildDateYear < 2025) + Console.WriteLine("Assembly was built before 2025."); +``` + +### Build Age Check + +```csharp +using System; +using BenjaminAbt.AssemblyMetadata; + +TimeSpan age = DateTimeOffset.UtcNow + - DateTimeOffset.FromFileTime(AssemblyMetadataInfo.BuildInfo.BuildFileTimeUtc); + +if (age.TotalDays > 30) + Console.WriteLine($"Warning: this build is {(int)age.TotalDays} days old."); +``` + +### Health Endpoint + +Expose the build timestamp in an ASP.NET Core health or info endpoint: + +```csharp +using BenjaminAbt.AssemblyMetadata; + +app.MapGet("/info", () => new +{ + BuildTimestamp = AssemblyMetadataInfo.BuildInfo.BuildTimestamp, + BuildYear = AssemblyMetadataInfo.BuildInfo.BuildDateYear, + BuildMonth = AssemblyMetadataInfo.BuildInfo.BuildDateMonth, + BuildDay = AssemblyMetadataInfo.BuildInfo.BuildDateDay, +}); +``` + +--- + +## How It Works + +AssemblyMetadata uses the modern **Roslyn incremental source generator** API +(`IIncrementalGenerator`). The generator is registered against the compilation provider so it +fires for every new compilation. + +The generated file never appears on disk; it lives only in the in-memory compilation. +Because all members are `const`, the C# compiler inlines them at every call site - no method +call, no property access, no object allocation. + +### Why All Values Are Derived from One `DateTimeOffset` + +`BuildSource` receives a single `DateTimeOffset buildOn` parameter and derives all eight +constants from it. Calling `DateTimeOffset.UtcNow` only once guarantees that +`BuildTimestamp`, `BuildFileTimeUtc`, and every date/time component refer to the exact same +instant, with no possibility of clock skew between fields. + +--- + +## Target Frameworks + +The source generator itself targets `netstandard2.0` because it runs inside the Roslyn/MSBuild +host process. The consuming project can target any framework supported by Roslyn source generators: + +| Framework | Supported | +|---|---| +| .NET 10 | ✅ | +| .NET 9 | ✅ | +| .NET 8 | ✅ | +| .NET Standard 2.0+ | ✅ | +| .NET Framework 4.6.2+ | ✅ | +--- + +## License + +[MIT](LICENSE) © [BEN ABT](https://benjamin-abt.com/) + +Please consider donating to institutions of your choice such as child cancer aid, +children's hospices, or similar charitable causes. Thank you! + + +[![AssemblyMetadata](https://img.shields.io/nuget/v/AssemblyMetadata.svg?logo=nuget&label=AssemblyMetadata)](https://www.nuget.org/packages/AssemblyMetadata) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +A **Roslyn incremental source generator** that embeds build-time metadata - timestamp, date and time components - as **compile-time constants** directly into your assembly. +Zero runtime overhead. No reflection. No configuration required. + +--- + +## Table of Contents + +- [Why AssemblyMetadata?](#why-assemblymetadata) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [API Reference](#api-reference) + - [BuildInfo](#buildinfo) +- [Usage Examples](#usage-examples) + - [Display Build Timestamp](#display-build-timestamp) + - [Parse into DateTimeOffset](#parse-into-datetimeoffset) + - [Use Individual Components](#use-individual-components) + - [Build Age Check](#build-age-check) +- [How It Works](#how-it-works) +- [Target Frameworks](#target-frameworks) +- [Building & Testing](#building--testing) +- [Project Structure](#project-structure) +- [License](#license) + +--- + +## Why AssemblyMetadata? + +Knowing *when* an assembly was built is useful for diagnostics, "About" screens, deployment +validation, and telemetry. The traditional approach - reading `Assembly.GetExecutingAssembly().GetName().Version` +or an embedded resource - requires reflection at runtime or extra tooling. + +AssemblyMetadata solves this differently: + +- **Compile-time constants** - values are `const`, so the JIT can inline and dead-code-eliminate them +- **Zero-cost access** - reading the timestamp costs nothing beyond a field load +- **No dependencies at runtime** - the NuGet package ships as a source generator analyzer; nothing is added to your runtime dependency graph +- **Incremental generator** - uses the modern Roslyn `IIncrementalGenerator` API, meaning the generator only runs when the compilation changes, keeping build times fast + +--- + +## Installation + +Add the package to **any project** that needs build metadata: ```xml - + +``` + +> The `OutputItemType="Analyzer"` and `ReferenceOutputAssembly="false"` attributes are required. +> They instruct MSBuild to load the package as a Roslyn analyzer (source generator) rather than a +> regular assembly reference, so it produces no runtime dependency. + +--- + +## Quick Start + +After adding the package, the generated class `AssemblyMetadataInfo` is immediately available +anywhere in your project - no `using` statement needed from the `BenjaminAbt.AssemblyMetadata` +namespace if you are already inside a child namespace of it. + +```csharp +using BenjaminAbt.AssemblyMetadata; + +// ISO 8601 UTC timestamp of the build +string timestamp = AssemblyMetadataInfo.BuildInfo.BuildTimestamp; +// e.g. "2026-03-02T14:35:07.1234567+00:00" + +// Individual date/time components (UTC) +Console.WriteLine($"Built on {AssemblyMetadataInfo.BuildInfo.BuildDateYear}-" + + $"{AssemblyMetadataInfo.BuildInfo.BuildDateMonth:D2}-" + + $"{AssemblyMetadataInfo.BuildInfo.BuildDateDay:D2} " + + $"at {AssemblyMetadataInfo.BuildInfo.BuildTimeHour:D2}:" + + $"{AssemblyMetadataInfo.BuildInfo.BuildTimeMinute:D2}:" + + $"{AssemblyMetadataInfo.BuildInfo.BuildTimeSecond:D2} UTC"); ``` -## Usage +--- + +## API Reference + +The generator produces a single internal static class in the `BenjaminAbt.AssemblyMetadata` +namespace. + +### BuildInfo + +`AssemblyMetadataInfo.BuildInfo` - all members are `public const`. -Right now, only one sub class is offered. +| Member | Type | Description | +|---|---|---| +| `BuildTimestamp` | `string` | Build time as a UTC ISO 8601 round-trip string (`"o"` format) | +| `BuildFileTimeUtc` | `long` | Build time as a Windows FileTime (100-nanosecond intervals since 1601-01-01 UTC) | +| `BuildDateYear` | `int` | Year component of the UTC build date | +| `BuildDateMonth` | `int` | Month component of the UTC build date (1–12) | +| `BuildDateDay` | `int` | Day component of the UTC build date (1–31) | +| `BuildTimeHour` | `int` | Hour component of the UTC build time (0–23) | +| `BuildTimeMinute` | `int` | Minute component of the UTC build time (0–59) | +| `BuildTimeSecond` | `int` | Second component of the UTC build time (0–59) | -### Build Time +--- -The build time is provided as ISO 8601 string. +## Usage Examples + +### Display Build Timestamp ```csharp -string timeIso8601 = AssemblyMetadataInfo.BuildInfo.BuildTimestamp; +using BenjaminAbt.AssemblyMetadata; + +Console.WriteLine(AssemblyMetadataInfo.BuildInfo.BuildTimestamp); +// Output: 2026-03-02T14:35:07.1234567+00:00 ``` -You can use the format "o" to parse the string into DateTimeOffset. +### Parse into DateTimeOffset + +Use the `"o"` round-trip format specifier to parse the stored constant back into a +`DateTimeOffset`: ```csharp -DateTimeOffset buildOn = DateTimeOffset.ParseExact(AssemblyMetadataInfo.BuildInfo.BuildTimestamp, "o", null); +using System; +using BenjaminAbt.AssemblyMetadata; + +DateTimeOffset buildOn = DateTimeOffset.ParseExact( + AssemblyMetadataInfo.BuildInfo.BuildTimestamp, "o", null); + +Console.WriteLine($"Built {(DateTimeOffset.UtcNow - buildOn).Days} days ago"); ``` -Also, the Source Code Generator adds the the build time as digits, so you can just concat the values instead to parse the string. +Or reconstruct from the FileTime for maximum performance (no string parsing): ```csharp -int day = AssemblyMetadataInfo.BuildInfo.BuildTimeDay; -int month = AssemblyMetadataInfo.BuildInfo.BuildTimeMonth; -int hour = AssemblyMetadataInfo.BuildInfo.BuildTimeYear; +DateTimeOffset buildOn = DateTimeOffset.FromFileTime( + AssemblyMetadataInfo.BuildInfo.BuildFileTimeUtc); ``` -Have fun! +### Use Individual Components + +The integer components allow zero-allocation formatting and direct comparison: + +```csharp +using BenjaminAbt.AssemblyMetadata; + +string buildDate = string.Create(null, + stackalloc char[10], + $"{AssemblyMetadataInfo.BuildInfo.BuildDateYear}-" + + $"{AssemblyMetadataInfo.BuildInfo.BuildDateMonth:D2}-" + + $"{AssemblyMetadataInfo.BuildInfo.BuildDateDay:D2}"); +``` + +### Build Age Check + +```csharp +using System; +using BenjaminAbt.AssemblyMetadata; + +bool isOlderThan30Days = + (DateTimeOffset.UtcNow - DateTimeOffset.FromFileTime( + AssemblyMetadataInfo.BuildInfo.BuildFileTimeUtc)).TotalDays > 30; + +if (isOlderThan30Days) + Console.WriteLine("Warning: this build is more than 30 days old."); +``` + +--- + +## How It Works + +AssemblyMetadata uses the modern **Roslyn incremental source generator** API +(`IIncrementalGenerator`). The generator is registered against the compilation provider, so it +fires on every new compilation: + +``` +Build triggered + └─ Roslyn compilation created + └─ AssemblyMetadataGenerator.Execute() + └─ DateTimeOffset.UtcNow captured + └─ AssemblyMetadataInfo.gen.cs emitted + └─ Compiled into the consuming assembly as internal constants +``` + +The generator emits a file named `AssemblyMetadataInfo.gen.cs` directly into the consuming +project's compilation. Because all members are `const`, the C# compiler inlines them at every +call site - no method call, no property access, no object allocation. + +> **Note:** The generated file never appears on disk; it lives only in the in-memory compilation. +> When Visual Studio or `dotnet build` invokes the generator, the output is automatically available. + +--- + +## Target Frameworks + +The source generator itself targets `netstandard2.0` because it runs inside the Roslyn/MSBuild +host process. The consuming project can target any framework supported by Roslyn source generators: + +| Framework | Supported | +|---|---| +| .NET 8 | ✅ | +| .NET 9 | ✅ | +| .NET 10 | ✅ | +| .NET Standard 2.0+ | ✅ | +| .NET Framework 4.6.2+ | ✅ | + +--- + +## Building & Testing + +Prerequisites: [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) + +```bash +# Restore dependencies +dotnet restore + +# Build +dotnet build + +# Run tests +dotnet test + +# Pack NuGet package +dotnet pack --configuration Release -o ./artifacts/packages +``` + +With [just](https://github.com/casey/just) installed: + +```bash +just build # build Debug +just test # run tests +just test-cov # run tests with coverage +just pack # create NuGet packages +just ci # full CI pipeline (clean → restore → format-check → build → test-cov) +``` + +--- + +## Project Structure + +``` +src/ + AssemblyMetadata/ # Roslyn source generator (netstandard2.0) + AssemblyMetadata.csproj + AssemblyMetadataGenerator.cs + +tests/ + AssemblyMetadata.UnitTests/ # xUnit v3 unit tests (net10.0) + AssemblyMetadata.UnitTests.csproj + AssemblyMetadataInfoTests.cs + +sample/ + AssemblyMetadata.SampleApp/ # Console sample demonstrating the generated API (net10.0) + AssemblyMetadata.SampleApp.csproj + Program.cs + +res/ # Images / assets +Directory.Build.props # Shared MSBuild properties (Unio-style project structure) +Directory.Packages.props # Central Package Management (CPM) versions +global.json # SDK version pin +version.json # Nerdbank.GitVersioning configuration +coverlet.runsettings # Code-coverage configuration +Justfile # Task runner recipes +NuGet.config # NuGet feed configuration +.editorconfig # Code style rules +``` + +--- + +## License -## Thank you +[MIT](LICENSE) © [BEN ABT](https://benjamin-abt.com/) -Please donate - if possible - to necessary institutions of your choice such as child cancer aid, children's hospices etc. Thanks! +Please donate - if possible - to institutions of your choice such as child cancer aid, +children's hospices, etc. Thanks! diff --git a/res/assembly-metadata-logo-big.png b/res/assembly-metadata-logo-big.png new file mode 100644 index 0000000000000000000000000000000000000000..0ffbbfa7049d2f7748b85615a1f25f8049ef7fcd GIT binary patch literal 10867 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

*Ko2+2^J&}!L3BIdQ^9bPJQ9Y_jSJbA*j}g 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 N>S|xtD$ziP{RcghR;B;| literal 0 HcmV?d00001 diff --git a/res/assembly-metadata-logo-medium.png b/res/assembly-metadata-logo-medium.png new file mode 100644 index 0000000000000000000000000000000000000000..5b7de3ea2a7eb271268b3d3fa1a1992121cf0400 GIT binary patch literal 6858 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 literal 0 HcmV?d00001 diff --git a/res/assembly-metadata-logo-small.png b/res/assembly-metadata-logo-small.png new file mode 100644 index 0000000000000000000000000000000000000000..18f6f5c33d2f5b7ca0daf6f53f731de1419c4bb5 GIT binary patch literal 3498 zcma)<`9Bj51IK5sMJPw)7CBO`Bw@}7U$-HOkyuKGSt9q5`}jUW#oWi{m^)W;n{y;9 z$Hp9yIdbG0v*-K#_WbyLy*{7M`%iekJ_%M94>;Ha*#H0lhl%lhn}10CUqOujY^O>E z*FR{qGJ_Zb;z#LpI^gXHAbJ=OJ_vY&1BBrKkwbu>e(cQl#QGt73f`n*))q@VI;BZ| z`1SxZTlRhH&-;r++mpGKC@)~Lk*X)xqFBFWw`wYAt5-nxLAwN4&j7%$U|ZpK zRRaHrEm!#vzbFQti=gyC8{^bE>M6ItZWeSKGYVq?^}$|tZFO*ncTX^mEBhJ(SA8TZ#%ep0F^h-!*bcx;?@B(t_l6!IXjl(?ZuL#KCl7d z+EgvZtcS1GyZI94Ktr|0xvNXjc0J0h1bCSZ;};G4u7gp>d*3aCASIj$+McvGZbqnM>IaPQ7UAZ1W8X@Iiw3rBQY2|C;P_p6n4(*p<22l^VEYVB`-V{QQLR3%fv(VN9@ygZ{^CWLXcp=v;d;i@RVQU?c=+& zP*hD=FWu*0PA_~!ucGkFralKL>0At+gK7^s;ipivj1=y-QVT^-fu;m8-$ZJ&p7)^B z8EN(oOWtetCD5iv-f#cDQf*AChSG0liw_!7p0y)%7g8TwVth2n@{O8Z;+(@Zpl;*l za*|EYlvehRpHy1|(g4;nVQi*dDB`2$K39XK#fi^rYaZ@XTZ0_i zEq}hM>!i=;E}aTNevh(zJdX*YY=TpFQBGLuGqwD2RviJf@6E;4vsbvK^sU0wYI4HF znIOhF?RTGqdDlT-IhjN(->Y_E7>5nCYSb%6AC@m z`te$F&fMK7CBliq!Elcvc8X@~9bndByV#e%sDk&c7F+EPRD)eB4yH_`?4m&NrU#4{ zzJ^z0zaT6Y6@8wTka20{FRLW8CGQshShaRWe(feR^r7`bCB!SNI!~J^;*XAj;E+Qo zIzZYR`f2or)(TtN)1?vZ$wQf_b$QriI^sd-h2y<9?;>u06Rpc-hS6@TcD7>Fd%+cV z=}j7Bu4a&uITJNpp{RVh_(+Y1P33E~H&*%q)H(IuCo?91Ekk`Lzq^HRy^YNc*&F;| z(&P)^+aAbGCuU~%R0m)TQ|!`$h5c)P!`2{k>3=1s|8rP8{gSlm&5Xi;;I{lcUPk~= z=+l^pdoi$KqmsT9m&BZioio$IqRC6ILi}o%mu3Rjl76TzXVOhXpRN-l&w?~TL8p3w z>5Qn?W6q`hFBVkbpM?!g@;a!h=C1EY#OiF~v{@fvCgMJ$vf3((A0iZq0qJ$^DNndr zRrUH#K;e>WI|$x0N&Ljgl}IhA>Rc|~cL*g!UMr1Qo7z72EM22-+TZbZ>9r;a~G{W)$ znq%h_Z$3#tDW!$+ip$-n=;xIUu$sS1DOr5`YQ?1Rsu82(AZ7^HNq173rjX}h0AB|i z^5n9&F0@)36#Ts43->Spur8LH<1&Qgw8-Qvma@th@kv!%ld1DazSXbDd^|_ww`U8= zJmf}~rb?Q=WSi>b71-GTn8D`t5*^egW2>lMMV|&&z%WR}@b-EIgL#?F+@Id*(y`|l z_akEkwy6b#DNt76VNnfCooGjJ=v9x;sLoUhjzF> ztT(|{mJF$xSD8tN9$Y$BR^>KX7>GUxQZ*yn&m*qJCw4~UcqK5Unu|d5ta|8~J-D8t zVEEm}!M%aurDgMbf-Sw9B(zUR+ufJ8tB!bD@-k+I%i&5z^x!694Bq5d-=}ShNi{fd z{!DK67$zKC<%;OtEDG@Z>jt5H)mRAc51QiGvu=h^7bs3Mw_0Q>oJ5WYyrYns0{xEg zL>|n%Ku|-Ph7TK6LeF(0Y3d}&v5U4L-PKo$u*RhJtCnm9EL7;znyOQ}v=CLl53O+u zXMNt$RG>bRE*U1d$V?aGUVF@vNl9UC0N_B$6B10O{{oMoBXT&!a`t&p<#*Q3av*cs=BnUIH1Hoyl3U)i_MkNEC1 zqd65qJTjNU*1SqR`xG2w+h*=MErj;Zp58|+m6P|`PADPCImsJFv5ia+h(^-B`cFQJ zXL8yfN^P7*8M9P+Ilha%v|sNlE8DQVM{H(R<*DTl`A^wxf&+%tTvH4yq*;zt@x=X; z^q-lj?qcsSRxr!?*e6?wao>CF$^5DY7A3`D;GemFF(_`oxqtDA!E3M5r91dF7khN{ zvsQN%2bxmbH&q5xCqF)Z2>@XMkH=#z6_oa^+;;MD%JR1G0YhI|JIi9-n@W%)uPYVH zH^74VLT{gy6AZMOTHv+(28g{D_dG*3f*%bYn<;exQ zfj*b!KKhrMj9zlRo|%{@YGhGbbF9(w7#x(Oys}_`0xxRHG2*`SqdQAh#eFB3LxZXk ztMVs2aK3Pkd{n=qd}gj|T}lk|0`3iRAk%{hfa_(_oee4)koKjAb6#S3vg(Iu88Ica$Zpm{79h3zF{Ei)Hd&M z_;gFBfs`Zli&HRM>&CRFYW>A+D=^sC(}rv!6VSZ@OhHjk1ips!)RoJtb-*Zcgv)N|e zS9dtiXu-0Lk*+zSObx&$VsZcSLFA`ISu3#_b;}Ac2QUIoCH61-*I%gr=X?Q73@z?s I^3#s1MjQ{`u literal 0 HcmV?d00001 diff --git a/sample/AssemblyMetadata.SampleApp/AssemblyMetadata.SampleApp.csproj b/sample/AssemblyMetadata.SampleApp/AssemblyMetadata.SampleApp.csproj index a5d97a2..badd495 100644 --- a/sample/AssemblyMetadata.SampleApp/AssemblyMetadata.SampleApp.csproj +++ b/sample/AssemblyMetadata.SampleApp/AssemblyMetadata.SampleApp.csproj @@ -2,14 +2,15 @@ Exe - net6.0 - BenjaminAbt.$(MSBuildProjectName) - BenjaminAbt.$(MSBuildProjectName.Replace(" ", "_")) + net10.0 + BenjaminAbt.AssemblyMetadata.SampleApp + BenjaminAbt.AssemblyMetadata.SampleApp + OutputItemType="Analyzer" + ReferenceOutputAssembly="false" /> diff --git a/sample/AssemblyMetadata.SampleApp/Program.cs b/sample/AssemblyMetadata.SampleApp/Program.cs index 72a03ae..358699c 100644 --- a/sample/AssemblyMetadata.SampleApp/Program.cs +++ b/sample/AssemblyMetadata.SampleApp/Program.cs @@ -1,20 +1,7 @@ -using System; -using BenjaminAbt.AssemblyMetadata; +using BenjaminAbt.AssemblyMetadata; -namespace BenjaminAbt.AssemblyMetadata.SampleApp -{ - ///

- /// Sample Program - /// - class Program - { - /// - /// Sample Main - /// - static void Main(string[] args) - { - Console.WriteLine(AssemblyMetadataInfo.BuildInfo.BuildTimestamp); - Console.ReadKey(); - } - } -} +// Display all build metadata constants embedded by the AssemblyMetadata source generator +Console.WriteLine($"Build Timestamp : {AssemblyMetadataInfo.BuildInfo.BuildTimestamp}"); +Console.WriteLine($"Build Date : {AssemblyMetadataInfo.BuildInfo.BuildDateYear}-{AssemblyMetadataInfo.BuildInfo.BuildDateMonth:D2}-{AssemblyMetadataInfo.BuildInfo.BuildDateDay:D2}"); +Console.WriteLine($"Build Time : {AssemblyMetadataInfo.BuildInfo.BuildTimeHour:D2}:{AssemblyMetadataInfo.BuildInfo.BuildTimeMinute:D2}:{AssemblyMetadataInfo.BuildInfo.BuildTimeSecond:D2}"); +Console.WriteLine($"Build FileTime : {AssemblyMetadataInfo.BuildInfo.BuildFileTimeUtc}"); diff --git a/src/AssemblyMetadata/AssemblyMetadata.csproj b/src/AssemblyMetadata/AssemblyMetadata.csproj index dd7c7b1..0a22b35 100644 --- a/src/AssemblyMetadata/AssemblyMetadata.csproj +++ b/src/AssemblyMetadata/AssemblyMetadata.csproj @@ -1,26 +1,27 @@  + + netstandard2.0 + BenjaminAbt.AssemblyMetadata + BenjaminAbt.AssemblyMetadata AssemblyMetadata - Extended Assembly Metadata Information - BenjaminAbt.$(MSBuildProjectName) - BenjaminAbt.$(MSBuildProjectName.Replace(" ", "_")) true - - - + + true + false - - - - + + + diff --git a/src/AssemblyMetadata/AssemblyMetadataGenerator.cs b/src/AssemblyMetadata/AssemblyMetadataGenerator.cs index 76a74cd..d91245f 100644 --- a/src/AssemblyMetadata/AssemblyMetadataGenerator.cs +++ b/src/AssemblyMetadata/AssemblyMetadataGenerator.cs @@ -1,79 +1,168 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; using System; +using System.Globalization; using System.Text; -namespace BenjaminAbt.AssemblyMetadata +namespace BenjaminAbt.AssemblyMetadata; + +/// +/// Roslyn incremental source generator that embeds build-time metadata +/// as compile-time constants into the consuming assembly. +/// +/// +/// +/// The generator registers itself against +/// so that it +/// executes on every compilation. Once invoked, it captures +/// and emits a file named AssemblyMetadataInfo.gen.cs that defines the internal class +/// BenjaminAbt.AssemblyMetadata.AssemblyMetadataInfo.BuildInfo with eight +/// public const members. +/// +/// +/// Because all output members are const, the C# compiler inlines them at every call site. +/// There is therefore zero runtime overhead: no method call, no property lookup, no heap +/// allocation. The source generator itself is shipped as an analyzer-only NuGet package, so it +/// adds no assembly reference to the consuming project's runtime dependency graph. +/// +/// +/// The generator uses the modern API (introduced in +/// Roslyn 4) rather than the legacy ISourceGenerator API. The incremental pipeline ensures +/// the generator only re-executes when the compilation inputs change, keeping build times fast. +/// +/// +[Generator] +public sealed class AssemblyMetadataGenerator : IIncrementalGenerator { /// - /// Source Code Generator of AssemblyMetadata + /// Initializes the incremental generator pipeline by registering the source-production + /// callback against the compilation provider. /// - [Generator] - public class AssemblyMetadataGenerator : ISourceGenerator + /// + /// The provided by the Roslyn host. + /// Used to register source-production callbacks and to access incremental value providers. + /// + /// + /// This implementation registers against + /// because the + /// generated output depends solely on the current wall-clock time - captured at generation + /// time - not on any specific compilation symbol or project property. The callback + /// is therefore invoked for every new compilation. + /// + public void Initialize(IncrementalGeneratorInitializationContext context) { - /// - /// - /// - public void Execute(GeneratorExecutionContext context) - { - // vars - DateTimeOffset buildOn = DateTimeOffset.UtcNow; - - /***************** VISUAL STUDIO RESTART REQUIRED ON CHANGE ********************/ - // https://github.com/dotnet/roslyn/issues/48083 - const string _source = @"// + // Register against the compilation provider so the generator + // fires on every new compilation (i.e., on every build). + context.RegisterSourceOutput(context.CompilationProvider, Execute); + } -namespace BenjaminAbt.AssemblyMetadata -{ - internal static class AssemblyMetadataInfo + /// + /// Source-production callback invoked by Roslyn for each new compilation. + /// Captures the current UTC time and delegates source emission to . + /// + /// + /// The that accepts the generated source file and + /// allows reporting diagnostics back to the compiler. + /// + /// + /// The current . Not actively used by this generator because the + /// emitted output depends solely on the build time, not on project symbols. + /// + /// + /// The UTC timestamp is captured fresh on every invocation so that each build produces + /// an accurate, up-to-date build timestamp. The generated file is added via + /// with the hint name + /// AssemblyMetadataInfo.gen.cs. + /// + private static void Execute(SourceProductionContext context, Compilation compilation) { - public static class BuildInfo - { - /// - /// Build time as UTC ISO8601 - /// - public const string BuildTimestamp = ""%BUILD_ISO8601%""; - - /// - /// Build time as FileTime - /// - public const long BuildFileTimeUtc = %BUILD_FILETIME%; - - public const int BuildDateYear = %BUILD_DATE_YEAR%; - public const int BuildDateMonth = %BUILD_DATE_MONTH%; - public const int BuildDateDay = %BUILD_DATE_DAY%; - - public const int BuildTimeHour = %BUILD_TIME_HOUR%; - public const int BuildTimeMinute = %BUILD_TIME_MINUTE%; - public const int BuildTimeSecond = %BUILD_TIME_SECOND%; - } + // Capture the current UTC time at the moment the generator runs. + // Using DateTimeOffset (not DateTime) preserves the UTC offset in the ISO 8601 string. + DateTimeOffset buildOn = DateTimeOffset.UtcNow; + string source = BuildSource(buildOn); + context.AddSource("AssemblyMetadataInfo.gen.cs", SourceText.From(source, Encoding.UTF8)); } -} -"; - /***************** VISUAL STUDIO RESTART REQUIRED ON CHANGE ********************/ - - // replace - StringBuilder sourceBuilder = new StringBuilder(_source) - - .Replace("%BUILD_ISO8601%", buildOn.ToString("o")) - - .Replace("%BUILD_FILETIME%", buildOn.ToFileTime().ToString()) - - .Replace("%BUILD_DATE_YEAR%", buildOn.Year.ToString()) - .Replace("%BUILD_DATE_MONTH%", buildOn.Month.ToString()) - .Replace("%BUILD_DATE_DAY%", buildOn.Day.ToString()) - .Replace("%BUILD_TIME_HOUR%", buildOn.Hour.ToString()) - .Replace("%BUILD_TIME_MINUTE%", buildOn.Minute.ToString()) - .Replace("%BUILD_TIME_SECOND%", buildOn.Second.ToString()); + /// + /// Builds the complete C# source text for AssemblyMetadataInfo.gen.cs + /// from the provided timestamp. + /// + /// + /// The UTC timestamp captured at source-generation time. All eight generated constants are + /// derived exclusively from this single value to guarantee internal consistency across + /// BuildTimestamp, BuildFileTimeUtc, and the individual date/time components. + /// + /// + /// A containing a complete, valid C# compilation unit that defines + /// BenjaminAbt.AssemblyMetadata.AssemblyMetadataInfo.BuildInfo + /// with eight public const members. + /// + /// + /// + /// The timestamp is formatted with the "o" (round-trip) format specifier so that + /// it can be losslessly parsed back into a with full + /// sub-second precision using + /// DateTimeOffset.ParseExact(value, "o", null). + /// + /// + /// All numeric values are formatted with to + /// guarantee culture-neutral, locale-independent output regardless of the build machine's + /// regional settings. + /// + /// + /// A is used to compose the source text in a single pass, + /// avoiding repeated intermediate string allocations. The generated file begins with an + /// // <auto-generated /> header so that Roslyn and other tooling recognise it + /// as machine-generated and suppress additional analyzer warnings on the output. + /// + /// + private static string BuildSource(DateTimeOffset buildOn) + { + // Format the UTC timestamp as an ISO 8601 round-trip string + // (e.g. "2026-03-02T14:35:07.1234567+00:00"). + // The "o" specifier preserves the full DateTimeOffset including the +00:00 UTC offset. + string iso8601 = buildOn.ToString("o", CultureInfo.InvariantCulture); - // generate - context.AddSource("AssemblyMetadataInfo.gen.cs", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8)); - } + // Convert to Windows FileTime (100-nanosecond intervals since 1601-01-01T00:00:00Z). + // This allows callers to reconstruct a DateTimeOffset without any string parsing: + // DateTimeOffset.FromFileTime(AssemblyMetadataInfo.BuildInfo.BuildFileTimeUtc) + long fileTime = buildOn.ToFileTime(); - /// - /// - /// - public void Initialize(GeneratorInitializationContext context) { } + return new StringBuilder() + .AppendLine("// ") + .AppendLine("namespace BenjaminAbt.AssemblyMetadata") + .AppendLine("{") + .AppendLine(" internal static class AssemblyMetadataInfo") + .AppendLine(" {") + .AppendLine(" /// Contains compile-time build metadata constants.") + .AppendLine(" internal static class BuildInfo") + .AppendLine(" {") + .AppendLine(" /// Build time as UTC ISO 8601 string.") + .AppendLine($" public const string BuildTimestamp = \"{iso8601}\";") + .AppendLine() + .AppendLine(" /// Build time as Windows FileTime (100-ns intervals since 1601-01-01 UTC).") + .AppendLine($" public const long BuildFileTimeUtc = {fileTime}L;") + .AppendLine() + .AppendLine(" /// Year component of the build date (UTC).") + .AppendLine($" public const int BuildDateYear = {buildOn.Year.ToString(CultureInfo.InvariantCulture)};") + .AppendLine() + .AppendLine(" /// Month component of the build date (UTC).") + .AppendLine($" public const int BuildDateMonth = {buildOn.Month.ToString(CultureInfo.InvariantCulture)};") + .AppendLine() + .AppendLine(" /// Day component of the build date (UTC).") + .AppendLine($" public const int BuildDateDay = {buildOn.Day.ToString(CultureInfo.InvariantCulture)};") + .AppendLine() + .AppendLine(" /// Hour component of the build time (UTC, 24-hour).") + .AppendLine($" public const int BuildTimeHour = {buildOn.Hour.ToString(CultureInfo.InvariantCulture)};") + .AppendLine() + .AppendLine(" /// Minute component of the build time (UTC).") + .AppendLine($" public const int BuildTimeMinute = {buildOn.Minute.ToString(CultureInfo.InvariantCulture)};") + .AppendLine() + .AppendLine(" /// Second component of the build time (UTC).") + .AppendLine($" public const int BuildTimeSecond = {buildOn.Second.ToString(CultureInfo.InvariantCulture)};") + .AppendLine(" }") + .AppendLine(" }") + .AppendLine("}") + .ToString(); } -} \ No newline at end of file +} diff --git a/tests/AssemblyMetadata.UnitTests/AssemblyMetadata.UnitTests.csproj b/tests/AssemblyMetadata.UnitTests/AssemblyMetadata.UnitTests.csproj index da2f6b0..e178f93 100644 --- a/tests/AssemblyMetadata.UnitTests/AssemblyMetadata.UnitTests.csproj +++ b/tests/AssemblyMetadata.UnitTests/AssemblyMetadata.UnitTests.csproj @@ -1,27 +1,20 @@  - net6.0 - BenjaminAbt.$(MSBuildProjectName.Replace(" ", "_")) - BenjaminAbt.$(MSBuildProjectName) + net10.0 + BenjaminAbt.AssemblyMetadata.UnitTests + BenjaminAbt.AssemblyMetadata.UnitTests - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + - - + + diff --git a/tests/AssemblyMetadata.UnitTests/AssemblyMetadataGeneratorTests.cs b/tests/AssemblyMetadata.UnitTests/AssemblyMetadataGeneratorTests.cs new file mode 100644 index 0000000..a1c1e59 --- /dev/null +++ b/tests/AssemblyMetadata.UnitTests/AssemblyMetadataGeneratorTests.cs @@ -0,0 +1,334 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace BenjaminAbt.AssemblyMetadata.UnitTests; + +/// +/// Tests that exercise the code paths at runtime +/// via . +/// +/// +/// +/// These tests instantiate the generator and run it against an in-memory +/// , making the generator's Initialize, Execute, +/// and BuildSource methods execute at test runtime. This approach achieves full code +/// coverage of the generator class itself, which otherwise only runs at build/compile time. +/// +/// +/// For tests that validate the values baked into the compiled test binary, see +/// . +/// +/// +public partial class AssemblyMetadataGeneratorTests +{ + // ── Helpers ───────────────────────────────────────────────────────────────────────────── + + /// + /// Creates a minimal in-memory C# compilation used to host the generator under test. + /// Only System.Private.CoreLib (via typeof(object)) is referenced because + /// the generated source only uses primitive types (string, long, int). + /// + private static CSharpCompilation CreateEmptyCompilation() => + CSharpCompilation.Create( + assemblyName: "TestAssembly", + syntaxTrees: [], + references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + /// + /// Runs the against an empty compilation and + /// returns the for inspection. + /// Uses cancellation token for responsive cancellation. + /// + private static GeneratorDriverRunResult RunGenerator() + { + CSharpGeneratorDriver driver = CSharpGeneratorDriver.Create(new AssemblyMetadataGenerator()); + return driver + .RunGenerators(CreateEmptyCompilation(), TestContext.Current.CancellationToken) + .GetRunResult(); + } + + // ── Output structure ──────────────────────────────────────────────────────────────────── + + /// + /// Verifies the generator emits exactly one source file - no more, no less. + /// + [Fact] + public void Generator_ProducesExactlyOneGeneratedFile() + { + GeneratorDriverRunResult result = RunGenerator(); + Assert.Single(result.GeneratedTrees); + } + + /// + /// Verifies the generated file uses the expected hint name AssemblyMetadataInfo.gen.cs. + /// This name is used by Roslyn to construct the in-memory file path and by IDEs for navigation. + /// + [Fact] + public void Generator_GeneratedFile_HasExpectedHintName() + { + GeneratorDriverRunResult result = RunGenerator(); + string filePath = result.GeneratedTrees[0].FilePath; + Assert.EndsWith("AssemblyMetadataInfo.gen.cs", filePath, StringComparison.Ordinal); + } + + /// + /// Verifies the generator itself emits no objects. + /// Generator diagnostics indicate structural errors in the generator's own logic. + /// + [Fact] + public void Generator_ProducesNoDiagnostics() + { + GeneratorDriverRunResult result = RunGenerator(); + Assert.Empty(result.Diagnostics); + } + + // ── Generated source content ──────────────────────────────────────────────────────────── + + /// + /// Verifies the generated source begins with // <auto-generated />, which + /// signals Roslyn and other tools that the file is machine-generated and should be excluded + /// from additional analyzer warnings. + /// + [Fact] + public void Generator_GeneratedSource_HasAutoGeneratedHeader() + { + string source = RunGenerator().GeneratedTrees[0].ToString(); + Assert.Contains("// ", source, StringComparison.Ordinal); + } + + /// + /// Verifies the generated source is placed inside the expected + /// BenjaminAbt.AssemblyMetadata namespace. + /// + [Fact] + public void Generator_GeneratedSource_HasCorrectNamespace() + { + string source = RunGenerator().GeneratedTrees[0].ToString(); + Assert.Contains("namespace BenjaminAbt.AssemblyMetadata", source, StringComparison.Ordinal); + } + + /// + /// Verifies the generated source declares the outer AssemblyMetadataInfo class. + /// + [Fact] + public void Generator_GeneratedSource_HasAssemblyMetadataInfoClass() + { + string source = RunGenerator().GeneratedTrees[0].ToString(); + Assert.Contains("class AssemblyMetadataInfo", source, StringComparison.Ordinal); + } + + /// + /// Verifies the generated source declares the nested BuildInfo class. + /// + [Fact] + public void Generator_GeneratedSource_HasBuildInfoClass() + { + string source = RunGenerator().GeneratedTrees[0].ToString(); + Assert.Contains("class BuildInfo", source, StringComparison.Ordinal); + } + + /// Verifies the BuildTimestamp constant is declared. + [Fact] + public void Generator_GeneratedSource_HasBuildTimestampConst() + { + string source = RunGenerator().GeneratedTrees[0].ToString(); + Assert.Contains("public const string BuildTimestamp", source, StringComparison.Ordinal); + } + + /// Verifies the BuildFileTimeUtc constant is declared. + [Fact] + public void Generator_GeneratedSource_HasBuildFileTimeUtcConst() + { + string source = RunGenerator().GeneratedTrees[0].ToString(); + Assert.Contains("public const long BuildFileTimeUtc", source, StringComparison.Ordinal); + } + + /// Verifies the BuildDateYear constant is declared. + [Fact] + public void Generator_GeneratedSource_HasBuildDateYearConst() + { + string source = RunGenerator().GeneratedTrees[0].ToString(); + Assert.Contains("public const int BuildDateYear", source, StringComparison.Ordinal); + } + + /// Verifies the BuildDateMonth constant is declared. + [Fact] + public void Generator_GeneratedSource_HasBuildDateMonthConst() + { + string source = RunGenerator().GeneratedTrees[0].ToString(); + Assert.Contains("public const int BuildDateMonth", source, StringComparison.Ordinal); + } + + /// Verifies the BuildDateDay constant is declared. + [Fact] + public void Generator_GeneratedSource_HasBuildDateDayConst() + { + string source = RunGenerator().GeneratedTrees[0].ToString(); + Assert.Contains("public const int BuildDateDay", source, StringComparison.Ordinal); + } + + /// Verifies the BuildTimeHour constant is declared. + [Fact] + public void Generator_GeneratedSource_HasBuildTimeHourConst() + { + string source = RunGenerator().GeneratedTrees[0].ToString(); + Assert.Contains("public const int BuildTimeHour", source, StringComparison.Ordinal); + } + + /// Verifies the BuildTimeMinute constant is declared. + [Fact] + public void Generator_GeneratedSource_HasBuildTimeMinuteConst() + { + string source = RunGenerator().GeneratedTrees[0].ToString(); + Assert.Contains("public const int BuildTimeMinute", source, StringComparison.Ordinal); + } + + /// Verifies the BuildTimeSecond constant is declared. + [Fact] + public void Generator_GeneratedSource_HasBuildTimeSecondConst() + { + string source = RunGenerator().GeneratedTrees[0].ToString(); + Assert.Contains("public const int BuildTimeSecond", source, StringComparison.Ordinal); + } + + // ── Value semantics ───────────────────────────────────────────────────────────────────── + + /// + /// Verifies that the emitted BuildTimestamp value is a valid ISO 8601 round-trip + /// string parseable with the "o" format specifier. + /// + [Fact] + public void Generator_BuildTimestamp_IsValidIso8601() + { + string source = RunGenerator().GeneratedTrees[0].ToString(); + Match match = BuildTimestampRegex().Match(source); + Assert.True(match.Success, "BuildTimestamp value not found in generated source."); + + // ParseExact throws if the format is wrong - no explicit Assert needed + DateTimeOffset parsed = DateTimeOffset.ParseExact(match.Groups["val"].Value, "o", null); + Assert.Equal(TimeSpan.Zero, parsed.Offset); // generator must always emit UTC + } + + /// + /// Verifies that the emitted BuildFileTimeUtc value is a positive long, representing a + /// valid Windows FileTime after the epoch 1601-01-01T00:00:00Z. + /// + [Fact] + public void Generator_BuildFileTime_IsPositive() + { + string source = RunGenerator().GeneratedTrees[0].ToString(); + Match match = BuildFileTimeRegex().Match(source); + Assert.True(match.Success, "BuildFileTimeUtc value not found in generated source."); + + long fileTime = long.Parse(match.Groups["val"].Value, CultureInfo.InvariantCulture); + Assert.True(fileTime > 0); + } + + /// + /// Verifies that all eight emitted constants are internally consistent: the timestamp, + /// FileTime, year, month, day, hour, minute, and second must all refer to the same instant. + /// + /// + /// This is the key correctness test for BuildSource: it confirms that every constant + /// is derived from the same captured in a single call, + /// with no off-by-one or formatting errors. + /// + [Fact] + public void Generator_BuildTimestamp_IsConsistentWithAllComponents() + { + // Run the generator once; extract all eight values from the same emitted source + // to verify the internal consistency of the BuildSource method. + string source = RunGenerator().GeneratedTrees[0].ToString(); + + string ts = BuildTimestampRegex().Match(source).Groups["val"].Value; + long ft = long.Parse(BuildFileTimeRegex().Match(source).Groups["val"].Value, + CultureInfo.InvariantCulture); + int year = int.Parse(BuildDateYearRegex().Match(source).Groups["val"].Value, + CultureInfo.InvariantCulture); + int month = int.Parse(BuildDateMonthRegex().Match(source).Groups["val"].Value, + CultureInfo.InvariantCulture); + int day = int.Parse(BuildDateDayRegex().Match(source).Groups["val"].Value, + CultureInfo.InvariantCulture); + int hour = int.Parse(BuildTimeHourRegex().Match(source).Groups["val"].Value, + CultureInfo.InvariantCulture); + int minute = int.Parse(BuildTimeMinuteRegex().Match(source).Groups["val"].Value, + CultureInfo.InvariantCulture); + int second = int.Parse(BuildTimeSecondRegex().Match(source).Groups["val"].Value, + CultureInfo.InvariantCulture); + + DateTimeOffset buildOn = DateTimeOffset.ParseExact(ts, "o", null); + + Assert.Equal(ft, buildOn.ToFileTime()); + Assert.Equal(year, buildOn.Year); + Assert.Equal(month, buildOn.Month); + Assert.Equal(day, buildOn.Day); + Assert.Equal(hour, buildOn.Hour); + Assert.Equal(minute, buildOn.Minute); + Assert.Equal(second, buildOn.Second); + } + + // ── Compilation correctness ───────────────────────────────────────────────────────────── + + /// + /// Verifies that the generated C# source compiles without any errors when added to a + /// . This catches syntax mistakes or invalid identifiers + /// that could be introduced by changes to the source-building logic. + /// + [Fact] + public void Generator_GeneratedSource_ProducesNoCompilationErrors() + { + CSharpCompilation compilation = CreateEmptyCompilation(); + CSharpGeneratorDriver driver = CSharpGeneratorDriver.Create(new AssemblyMetadataGenerator()); + + driver.RunGeneratorsAndUpdateCompilation( + compilation, + out Compilation updatedCompilation, + out _, + TestContext.Current.CancellationToken); + + System.Collections.Generic.IEnumerable errors = updatedCompilation + .GetDiagnostics(TestContext.Current.CancellationToken) + .Where(d => d.Severity == DiagnosticSeverity.Error); + + Assert.Empty(errors); + } + + // ── Source-generated regex patterns (avoids MA0009 ReDoS false-positives) ────────────── + + /// Matches the BuildTimestamp string literal in generated source. + [GeneratedRegex(@"BuildTimestamp = ""(?[^""]+)""", RegexOptions.NonBacktracking | RegexOptions.ExplicitCapture)] + private static partial Regex BuildTimestampRegex(); + + /// Matches the BuildFileTimeUtc numeric literal in generated source. + [GeneratedRegex(@"BuildFileTimeUtc = (?\d+)L", RegexOptions.NonBacktracking | RegexOptions.ExplicitCapture)] + private static partial Regex BuildFileTimeRegex(); + + /// Matches the BuildDateYear integer literal in generated source. + [GeneratedRegex(@"BuildDateYear = (?\d+)", RegexOptions.NonBacktracking | RegexOptions.ExplicitCapture)] + private static partial Regex BuildDateYearRegex(); + + /// Matches the BuildDateMonth integer literal in generated source. + [GeneratedRegex(@"BuildDateMonth = (?\d+)", RegexOptions.NonBacktracking | RegexOptions.ExplicitCapture)] + private static partial Regex BuildDateMonthRegex(); + + /// Matches the BuildDateDay integer literal in generated source. + [GeneratedRegex(@"BuildDateDay = (?\d+)", RegexOptions.NonBacktracking | RegexOptions.ExplicitCapture)] + private static partial Regex BuildDateDayRegex(); + + /// Matches the BuildTimeHour integer literal in generated source. + [GeneratedRegex(@"BuildTimeHour = (?\d+)", RegexOptions.NonBacktracking | RegexOptions.ExplicitCapture)] + private static partial Regex BuildTimeHourRegex(); + + /// Matches the BuildTimeMinute integer literal in generated source. + [GeneratedRegex(@"BuildTimeMinute = (?\d+)", RegexOptions.NonBacktracking | RegexOptions.ExplicitCapture)] + private static partial Regex BuildTimeMinuteRegex(); + + /// Matches the BuildTimeSecond integer literal in generated source. + [GeneratedRegex(@"BuildTimeSecond = (?\d+)", RegexOptions.NonBacktracking | RegexOptions.ExplicitCapture)] + private static partial Regex BuildTimeSecondRegex(); +} + diff --git a/tests/AssemblyMetadata.UnitTests/AssemblyMetadataInfoTests.cs b/tests/AssemblyMetadata.UnitTests/AssemblyMetadataInfoTests.cs index 83af581..eb360e5 100644 --- a/tests/AssemblyMetadata.UnitTests/AssemblyMetadataInfoTests.cs +++ b/tests/AssemblyMetadata.UnitTests/AssemblyMetadataInfoTests.cs @@ -1,33 +1,87 @@ -using Xunit; -using FluentAssertions; -using System; +using System; +using Xunit; -namespace BenjaminAbt.AssemblyMetadata.UnitTests +namespace BenjaminAbt.AssemblyMetadata.UnitTests; + +/// +/// Integration tests that validate the values of the constants produced by +/// and compiled into this assembly. +/// +/// +/// These tests run against the actual generated output that is baked into the test binary at +/// compile time. They verify correctness of the generated values - not the generator code itself. +/// For tests that exercise the generator code paths, see +/// . +/// +public class AssemblyMetadataInfoTests { /// - /// Tests for AssemblyMetadata + /// Verifies that is a valid + /// ISO 8601 round-trip string and that all other constants are consistent with the + /// timestamp it encodes. /// - public class AssemblyMetadataInfoTests + /// + /// Parses the stored string using the "o" format specifier - the same format used + /// by the generator - and then compares each derived constant against the parsed value. + /// A parse failure here indicates the generator emitted a malformed timestamp. + /// + [Fact] + public void BuildInfo_Timestamp_IsValidIso8601() { - /// - /// Tests for AssemblyMetadata.BuildInfo - /// - [Fact] - public void BuildInfoTests() - { + DateTimeOffset buildOn = DateTimeOffset.ParseExact( + AssemblyMetadataInfo.BuildInfo.BuildTimestamp, "o", null); - DateTimeOffset buildOn = DateTimeOffset.ParseExact(AssemblyMetadataInfo.BuildInfo.BuildTimestamp, "o", null); + // xUnit2000: constant/expected value must be the first argument + Assert.Equal(AssemblyMetadataInfo.BuildInfo.BuildFileTimeUtc, buildOn.ToFileTime()); - AssemblyMetadataInfo.BuildInfo.BuildFileTimeUtc.Should().Be(buildOn.ToFileTime()); + Assert.Equal(AssemblyMetadataInfo.BuildInfo.BuildDateYear, buildOn.Year); + Assert.Equal(AssemblyMetadataInfo.BuildInfo.BuildDateMonth, buildOn.Month); + Assert.Equal(AssemblyMetadataInfo.BuildInfo.BuildDateDay, buildOn.Day); - AssemblyMetadataInfo.BuildInfo.BuildDateYear.Should().Be(buildOn.Year); - AssemblyMetadataInfo.BuildInfo.BuildDateMonth.Should().Be(buildOn.Month); - AssemblyMetadataInfo.BuildInfo.BuildDateDay.Should().Be(buildOn.Day); + Assert.Equal(AssemblyMetadataInfo.BuildInfo.BuildTimeHour, buildOn.Hour); + Assert.Equal(AssemblyMetadataInfo.BuildInfo.BuildTimeMinute, buildOn.Minute); + Assert.Equal(AssemblyMetadataInfo.BuildInfo.BuildTimeSecond, buildOn.Second); + } - AssemblyMetadataInfo.BuildInfo.BuildTimeHour.Should().Be(buildOn.Hour); - AssemblyMetadataInfo.BuildInfo.BuildTimeMinute.Should().Be(buildOn.Minute); - AssemblyMetadataInfo.BuildInfo.BuildTimeSecond.Should().Be(buildOn.Second); - } + /// + /// Verifies that is a + /// positive value, confirming it represents a date after 1601-01-01T00:00:00Z. + /// + [Fact] + public void BuildInfo_FileTime_IsPositive() + { + Assert.True(AssemblyMetadataInfo.BuildInfo.BuildFileTimeUtc > 0); } + /// + /// Verifies that each date and time component falls within its valid calendar / clock range. + /// + /// + /// The year lower bound of 2020 reflects a minimum reasonable build date for this project. + /// + [Fact] + public void BuildInfo_DateComponents_AreInValidRanges() + { + Assert.InRange(AssemblyMetadataInfo.BuildInfo.BuildDateYear, 2020, 2100); + Assert.InRange(AssemblyMetadataInfo.BuildInfo.BuildDateMonth, 1, 12); + Assert.InRange(AssemblyMetadataInfo.BuildInfo.BuildDateDay, 1, 31); + + Assert.InRange(AssemblyMetadataInfo.BuildInfo.BuildTimeHour, 0, 23); + Assert.InRange(AssemblyMetadataInfo.BuildInfo.BuildTimeMinute, 0, 59); + Assert.InRange(AssemblyMetadataInfo.BuildInfo.BuildTimeSecond, 0, 59); + } + + /// + /// Verifies that encodes a UTC + /// offset of exactly +00:00, confirming the generator always uses UTC. + /// + [Fact] + public void BuildInfo_Timestamp_IsUtc() + { + DateTimeOffset buildOn = DateTimeOffset.ParseExact( + AssemblyMetadataInfo.BuildInfo.BuildTimestamp, "o", null); + + Assert.Equal(TimeSpan.Zero, buildOn.Offset); + } } + diff --git a/version.json b/version.json index 6088b6e..4d2d726 100644 --- a/version.json +++ b/version.json @@ -1,25 +1,24 @@ { - "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "1.0", - "assemblyVersion": { - "precision": "revision" // optional. Use when you want a more precise assembly version than the default major.minor. - }, - "nugetPackageVersion": { - "semVer": 1 // optional. Set to either 1 or 2 to control how the NuGet package version string is generated. Default is 1. - }, - "publicReleaseRefSpec": [ - "^refs/heads/main", // we release out of main - "^refs/tags/v\\d+\\.\\d+" // we also release tags starting with vN.N - ], - "cloudBuild": { - "setVersionVariables": true, - "buildNumber": { - "enabled": true + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", + "version": "2.0", + "assemblyVersion": { + "precision": "revision" + }, + "nugetPackageVersion": { + "semVer": 1 + }, + "publicReleaseRefSpec": [ + "^refs/heads/main", + "^refs/tags/v\\d+\\.\\d+" + ], + "cloudBuild": { + "buildNumber": { + "enabled": true + } + }, + "release": { + "branchName": "v{version}", + "versionIncrement": "minor", + "firstUnstableTag": "alpha" } - }, - "release": { - "branchName": "v{version}", - "versionIncrement": "minor", - "firstUnstableTag": "alpha" - } -} \ No newline at end of file +} From 89bc48ec3a08e44c65eed296de5ddd95416544b3 Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Mon, 2 Mar 2026 23:10:27 +0100 Subject: [PATCH 2/4] chore: update target frameworks and clean up configurations --- .github/workflows/build-and-test.yml | 2 +- .vscode/extensions.json | 27 --------------------------- Directory.Build.props | 19 +++++++++---------- Justfile | 2 +- global.json | 2 +- 5 files changed, 12 insertions(+), 40 deletions(-) delete mode 100644 .vscode/extensions.json diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 6024c96..491dc6d 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -75,4 +75,4 @@ jobs: with: name: nuget-packages path: ./artifacts/*.nupkg - retention-days: 30 \ No newline at end of file + retention-days: 30 diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 606d959..0000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "recommendations": [ - // C# / .NET - "ms-dotnettools.csharp", - "ms-dotnettools.csdevkit", - "ms-dotnettools.vscode-dotnet-runtime", - - // GitHub Copilot - "github.copilot", - "github.copilot-chat", - - // NuGet - "jmrog.vscode-nuget-package-manager", - "aliasadidev.nugetpackagemanagergui", - - // XML / YAML / JSON - "redhat.vscode-xml", - "redhat.vscode-yaml", - - // Editor quality - "editorconfig.editorconfig", - "streetsidesoftware.code-spell-checker", - - // Just task runner - "skellock.just" - ] -} diff --git a/Directory.Build.props b/Directory.Build.props index 886e585..c32d683 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,7 +18,7 @@ - net8.0;net9.0;net10.0;net11.0 + net8.0;net9.0;net10.0 $(MSBuildProjectName) $(MSBuildProjectName) @@ -80,22 +80,21 @@ Exe true + + true $(NoWarn);CS8892 - + - true - lcov,opencover,cobertura - $(MSBuildThisFileDirectory)TestResults/coverage/$(MSBuildProjectName). GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute - **/*Program.cs;**/*Startup.cs;**/*GlobalUsings.cs - true - 90 - line - total + **/*Program.cs;**/*Startup.cs;**/*GlobalUsings.cs;**/*.gen.cs;**/*.g.cs diff --git a/Justfile b/Justfile index eee75c3..2aecb21 100644 --- a/Justfile +++ b/Justfile @@ -42,7 +42,7 @@ test *ARGS: dotnet run --project "tests/AssemblyMetadata.UnitTests/AssemblyMetadata.UnitTests.csproj" --configuration "{{CONFIG}}" --framework "{{TFM}}" {{ARGS}} test-cov: - dotnet run --project "tests/AssemblyMetadata.UnitTests/AssemblyMetadata.UnitTests.csproj" --configuration "{{CONFIG}}" --framework "{{TFM}}" -- --coverage --coverage-output "./TestResults/coverage/coverage.cobertura.xml" --coverage-output-format cobertura + dotnet test --configuration "{{CONFIG}}" --nologo -- --coverage --coverage-output "./TestResults/coverage/coverage.xml" --coverage-output-format xml test-filter QUERY: dotnet run --project "tests/AssemblyMetadata.UnitTests/AssemblyMetadata.UnitTests.csproj" --configuration "{{CONFIG}}" --framework "{{TFM}}" -- --filter "{{QUERY}}" diff --git a/global.json b/global.json index 2fe6c88..058bafa 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "11.0.100-preview.1.26104.118" + "version": "10.0.103" } } From 3fb6b7947153cd14cc460b83dd97719a7ba675b3 Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Mon, 2 Mar 2026 23:16:50 +0100 Subject: [PATCH 3/4] chore(ci): update .NET setup and package versions --- .github/workflows/build-and-test.yml | 8 +------- Directory.Build.props | 3 +++ Directory.Packages.props | 8 ++------ src/AssemblyMetadata/AssemblyMetadata.csproj | 5 ++++- 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 491dc6d..241164d 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -36,7 +36,7 @@ jobs: with: fetch-depth: 0 - - name: Setup .NET (stable) + - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: | @@ -44,12 +44,6 @@ jobs: 9.0.x 10.0.x - - name: Setup .NET (preview) - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 11.0.x - dotnet-quality: preview - - name: Calculate Version with NBGV uses: dotnet/nbgv@master id: nbgv diff --git a/Directory.Build.props b/Directory.Build.props index c32d683..1f1bd26 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -84,6 +84,9 @@ bypassing the vstest testhost entirely. Without this, dotnet test fails with a "testhost.dll not found" error when using preview .NET SDKs. --> true + + $(MSBuildWarningsAsMessages);MTP0001 $(NoWarn);CS8892 diff --git a/Directory.Packages.props b/Directory.Packages.props index 3f607d2..9e96951 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,18 +2,15 @@ true - - - + all runtime; build; native; contentfiles; analyzers - @@ -28,11 +25,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - all runtime; build; native; contentfiles; analyzers - + \ No newline at end of file diff --git a/src/AssemblyMetadata/AssemblyMetadata.csproj b/src/AssemblyMetadata/AssemblyMetadata.csproj index 0a22b35..a01c944 100644 --- a/src/AssemblyMetadata/AssemblyMetadata.csproj +++ b/src/AssemblyMetadata/AssemblyMetadata.csproj @@ -20,7 +20,10 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + From 94cd122d8d60475d0ab7a2f6923ebbe450b817cf Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Mon, 2 Mar 2026 23:19:41 +0100 Subject: [PATCH 4/4] feat(git-hooks): add pre-commit and pre-push hooks to enforce branch protection --- .githooks/pre-commit | 23 ++++ .githooks/pre-push | 24 ++++ .githooks/readme.md | 90 +++++++++++++ docs/dev/init.ps1 | 302 +++++++++++++++++++++++++++++++++++++++++++ docs/dev/readme.md | 57 ++++++++ 5 files changed, 496 insertions(+) create mode 100644 .githooks/pre-commit create mode 100644 .githooks/pre-push create mode 100644 .githooks/readme.md create mode 100644 docs/dev/init.ps1 create mode 100644 docs/dev/readme.md diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..dda96ce --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,23 @@ +#!/bin/sh + +# Git pre-commit hook to prevent committing to main branch +# This script prevents accidental commits to the protected main branch + +protected_branch='main' + +# Get current branch name +current_branch=$(git rev-parse --abbrev-ref HEAD) + +if [ "$current_branch" = "$protected_branch" ]; then + echo "Error: You are trying to commit to the protected branch '$protected_branch'." + echo "Please create a feature branch and work on it instead." + echo "" + echo "To create a feature branch:" + echo " git checkout -b feature/your-feature-name" + echo "" + echo "If you absolutely need to commit to main (not recommended), you can:" + echo " git commit --no-verify" + exit 1 +fi + +exit 0 \ No newline at end of file diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 0000000..feeaa5e --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,24 @@ +#!/bin/sh + +# Git pre-push hook to prevent pushing to main branch +# This script prevents accidental pushes to the protected main branch + +protected_branch='main' + +while read local_ref local_sha remote_ref remote_sha +do + if [ "$remote_ref" = "refs/heads/$protected_branch" ]; then + echo "Error: You are trying to push to the protected branch '$protected_branch'." + echo "Please create a feature branch and submit a pull request instead." + echo "" + echo "To create a feature branch:" + echo " git checkout -b feature/your-feature-name" + echo " git push origin feature/your-feature-name" + echo "" + echo "If you absolutely need to push to main (not recommended), you can:" + echo " git push --no-verify origin main" + exit 1 + fi +done + +exit 0 \ No newline at end of file diff --git a/.githooks/readme.md b/.githooks/readme.md new file mode 100644 index 0000000..3fca4a5 --- /dev/null +++ b/.githooks/readme.md @@ -0,0 +1,90 @@ +# Git Hooks Setup + +This directory contains custom Git hooks for the repository to enforce code quality and workflow standards. + +## Available Hooks + +### pre-push +Prevents direct pushes to the `main` branch to enforce a pull request workflow. This helps maintain code quality by ensuring all changes go through code review. + +## How to Activate Git Hooks + +Git hooks need to be activated manually as they are not automatically enabled when cloning a repository. Follow these steps: + +### Method 1: Configure Git Hooks Path (Recommended) + +1. Navigate to your repository root directory +2. Run the following command to configure Git to use the `.githooks` directory: + ```bash + git config core.hooksPath .githooks + ``` + +### Method 2: Copy Hooks to .git/hooks Directory + +1. Navigate to your repository root directory +2. Copy the hook files to the `.git/hooks` directory: + + **On Windows (PowerShell):** + ```powershell + Copy-Item .githooks\* .git\hooks\ -Force + ``` + + **On macOS/Linux:** + ```bash + cp .githooks/* .git/hooks/ + chmod +x .git/hooks/* + ``` + +### Verification + +To verify that the hooks are properly installed, you can: + +1. Check the current hooks path configuration: + ```bash + git config core.hooksPath + ``` + +2. List the hooks in your hooks directory: + ```bash + ls -la .git/hooks/ + # or on Windows: + dir .git\hooks\ + ``` + +## Testing the pre-push Hook + +To test if the pre-push hook is working: + +1. Make sure you're on the `main` branch +2. Try to push directly to main: + ```bash + git push origin main + ``` +3. You should see an error message preventing the push + +## Bypassing Hooks (Not Recommended) + +If you absolutely need to bypass a hook (use with caution): +```bash +git push --no-verify origin main +``` + +## Troubleshooting + +### Hook Not Executing +- Ensure the hook files have execute permissions (especially on macOS/Linux) +- Verify the hooks path is correctly configured +- Check that the hook files don't have a file extension + +### Permission Issues on Windows +- Make sure Git Bash or your terminal has the necessary permissions +- Consider running your terminal as administrator if needed + +## Best Practices + +1. Always work on feature branches +2. Create pull requests for code review +3. Only merge to main through approved pull requests +4. Keep the main branch stable and deployable + +For more information about Git hooks, visit: https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks \ No newline at end of file diff --git a/docs/dev/init.ps1 b/docs/dev/init.ps1 new file mode 100644 index 0000000..d39ae16 --- /dev/null +++ b/docs/dev/init.ps1 @@ -0,0 +1,302 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS + Initial setup script for developers to configure their development environment +.DESCRIPTION + This script helps new developers set up their local development environment. It includes Git configuration, hook setup, + and other necessary development tools. + + IMPORTANT: This script must be run from the docs\dev directory! +.EXAMPLE + cd docs\dev + .\init.ps1 +#> + +param( + [switch]$Force, + [switch]$SkipConfirmation +) + +# Set error action preference +$ErrorActionPreference = "Stop" + +# Function to prompt user for confirmation +function Confirm-Step { + param( + [string]$Message, + [string]$StepName + ) + + if ($SkipConfirmation) { + Write-Host "Auto-confirming: $StepName" -ForegroundColor Green + return $true + } + + do { + $response = Read-Host "$Message (y/n/s) [y=Yes, n=No, s=Skip]" + $response = $response.ToLower() + + switch ($response) { + 'y' { return $true } + 'yes' { return $true } + 'n' { + Write-Host "Setup cancelled by user." -ForegroundColor Yellow + exit 0 + } + 'no' { + Write-Host "Setup cancelled by user." -ForegroundColor Yellow + exit 0 + } + 's' { return $false } + 'skip' { return $false } + default { + Write-Host "Please enter 'y' for Yes, 'n' for No, or 's' to Skip." -ForegroundColor Yellow + } + } + } while ($true) +} + +# Function to check if running in correct directory and navigate to repository root +function Set-RepositoryRoot { + $currentPath = Get-Location + Write-Host "Current directory: $currentPath" -ForegroundColor Gray + + # Check if we're in docs\dev directory + $currentDir = Split-Path -Leaf $currentPath + $parentDir = Split-Path -Leaf (Split-Path -Parent $currentPath) + + if ($currentDir -eq "dev" -and $parentDir -eq "docs") { + Write-Host "✓ Script is running from docs\dev directory" -ForegroundColor Green + + # Navigate to repository root (two levels up) + $repoRoot = Split-Path -Parent (Split-Path -Parent $currentPath) + Set-Location $repoRoot + Write-Host "✓ Changed to repository root: $repoRoot" -ForegroundColor Green + + # Verify .git directory exists + if (-not (Test-Path ".git")) { + Write-Host "✗ .git directory not found in repository root" -ForegroundColor Red + Write-Host "Please ensure this script is run from the correct docs\dev directory." -ForegroundColor Red + exit 1 + } + + Write-Host "✓ Repository root validated" -ForegroundColor Green + return $true + } + else { + Write-Host "✗ Error: This script must be run from the docs\dev directory." -ForegroundColor Red + Write-Host "Current location: $currentPath" -ForegroundColor Red + Write-Host "Please navigate to docs\dev and run the script again:" -ForegroundColor Yellow + Write-Host " cd docs\dev" -ForegroundColor Gray + Write-Host " .\init.ps1" -ForegroundColor Gray + exit 1 + } +} + +# Function to display welcome message +function Show-WelcomeMessage { + Clear-Host + Write-Host ("=" * 75) -ForegroundColor Cyan + Write-Host " Welcome to Environment Setup." -ForegroundColor Cyan + Write-Host ("=" * 75) -ForegroundColor Cyan + Write-Host "" + Write-Host "This script will help you configure your development environment" -ForegroundColor White + Write-Host "" + Write-Host "IMPORTANT: This script must be run from the docs\dev directory!" -ForegroundColor Yellow + Write-Host "" + Write-Host "The following steps will be performed:" -ForegroundColor White + Write-Host " 1. Welcome and repository validation" -ForegroundColor Gray + Write-Host " 2. Git configuration and hooks setup" -ForegroundColor Gray + Write-Host "" + Write-Host "You can skip any step if it's already configured or not needed." -ForegroundColor Yellow + Write-Host "" +} + +# Function to setup Git configuration and hooks +function Set-GitConfiguration { + Write-Host ("=" * 50) -ForegroundColor Green + Write-Host " Git Configuration and Hooks Setup" -ForegroundColor Green + Write-Host ("=" * 50) -ForegroundColor Green + Write-Host "" + + # Check if Git is available + try { + $gitVersion = git --version + Write-Host "✓ Git is available: $gitVersion" -ForegroundColor Green + } + catch { + Write-Host "✗ Git is not available or not in PATH" -ForegroundColor Red + Write-Host "Please install Git and ensure it's in your PATH before continuing." -ForegroundColor Red + return $false + } + + # Check and confirm Git email configuration + Write-Host "" + Write-Host "Checking Git user configuration..." -ForegroundColor White + + try { + $gitEmail = git config user.email + $gitName = git config user.name + + if ($gitEmail) { + Write-Host "Current Git email: $gitEmail" -ForegroundColor Yellow + if ($gitName) { + Write-Host "Current Git name: $gitName" -ForegroundColor Yellow + } + Write-Host "" + + do { + $emailConfirm = Read-Host "Is this the correct email address you want to use for commits? (y/n)" + $emailConfirm = $emailConfirm.ToLower() + + if ($emailConfirm -eq 'y' -or $emailConfirm -eq 'yes') { + Write-Host "✓ Git email configuration confirmed" -ForegroundColor Green + break + } + elseif ($emailConfirm -eq 'n' -or $emailConfirm -eq 'no') { + Write-Host "" + Write-Host "Please configure your Git email and name before continuing:" -ForegroundColor Yellow + Write-Host " git config --global user.email 'your.email@example.com'" -ForegroundColor Gray + Write-Host " git config --global user.name 'Your Name'" -ForegroundColor Gray + Write-Host "" + Write-Host "After configuration, please run this setup script again." -ForegroundColor Yellow + return $false + } + else { + Write-Host "Please enter 'y' for Yes or 'n' for No." -ForegroundColor Yellow + } + } while ($true) + } + else { + Write-Host "✗ No Git email configured" -ForegroundColor Red + Write-Host "" + Write-Host "Please configure your Git email and name before continuing:" -ForegroundColor Yellow + Write-Host " git config --global user.email 'your.email@example.com'" -ForegroundColor Gray + Write-Host " git config --global user.name 'Your Name'" -ForegroundColor Gray + Write-Host "" + Write-Host "After configuration, please run this setup script again." -ForegroundColor Yellow + return $false + } + } + catch { + Write-Host "✗ Failed to check Git configuration: $_" -ForegroundColor Red + return $false + } + + # Check current Git hooks configuration + $currentHooksPath = "" + try { + $currentHooksPath = git config core.hooksPath + if ($currentHooksPath) { + Write-Host "Current Git hooks path: $currentHooksPath" -ForegroundColor Yellow + } + } + catch { + Write-Host "No custom hooks path currently configured." -ForegroundColor Gray + } + + # Configure Git hooks path + Write-Host "" + Write-Host "Setting up Git hooks to prevent direct pushes to main branch..." -ForegroundColor White + + if (-not (Test-Path ".githooks")) { + Write-Host "✗ .githooks directory not found" -ForegroundColor Red + Write-Host "Please ensure you're running this script from the repository root." -ForegroundColor Red + return $false + } + + if (-not (Test-Path ".githooks\pre-push")) { + Write-Host "✗ pre-push hook not found in .githooks directory" -ForegroundColor Red + return $false + } + + try { + git config core.hooksPath .githooks + Write-Host "✓ Git hooks path configured successfully" -ForegroundColor Green + + # Verify configuration + $verifyHooksPath = git config core.hooksPath + if ($verifyHooksPath -eq ".githooks") { + Write-Host "✓ Git hooks path verified: $verifyHooksPath" -ForegroundColor Green + } + else { + Write-Host "⚠ Warning: Hooks path verification failed" -ForegroundColor Yellow + } + } + catch { + Write-Host "✗ Failed to configure Git hooks path: $_" -ForegroundColor Red + return $false + } + + # Display information about the hooks + Write-Host "" + Write-Host "Git Hooks Information:" -ForegroundColor Cyan + Write-Host "• pre-push hook: Prevents direct pushes to main branch" -ForegroundColor White + Write-Host "• This enforces a pull request workflow for better code quality" -ForegroundColor White + Write-Host "" + Write-Host "To test the hook, try: git push origin main" -ForegroundColor Gray + Write-Host "You should see an error preventing the push." -ForegroundColor Gray + Write-Host "" + + return $true +} + +# Main execution flow +function Main { + # Step 1: Welcome Message and Repository Validation + Show-WelcomeMessage + + if (-not (Confirm-Step "Do you want to proceed with the development environment setup?" "Welcome and Setup")) { + Write-Host "Setup skipped. You can run this script again anytime." -ForegroundColor Yellow + exit 0 + } + + # Validate repository + Set-RepositoryRoot + Write-Host "" + + # Step 2: Git Setup + if (Confirm-Step "Do you want to configure Git settings and activate Git hooks?" "Git Configuration") { + if (Set-GitConfiguration) { + Write-Host "✓ Git configuration completed successfully" -ForegroundColor Green + } + else { + Write-Host "✗ Git configuration failed" -ForegroundColor Red + exit 1 + } + } + else { + Write-Host "Git configuration skipped." -ForegroundColor Yellow + } + + # Setup completion + Write-Host "" + Write-Host ("=" * 50) -ForegroundColor Green + Write-Host " Setup Complete!" -ForegroundColor Green + Write-Host ("=" * 50) -ForegroundColor Green + Write-Host "" + Write-Host "Your development environment is now configured." -ForegroundColor White + Write-Host "" + Write-Host "Next steps:" -ForegroundColor Cyan + Write-Host "• Create a feature branch: git checkout -b feature/your-feature-name" -ForegroundColor White + Write-Host "• Make your changes and commit them" -ForegroundColor White + Write-Host "• Push to your feature branch: git push origin feature/your-feature-name" -ForegroundColor White + Write-Host "• Create a pull request for code review" -ForegroundColor White + Write-Host "" + Write-Host "For more information, check the documentation in the docs/ directory." -ForegroundColor Gray + Write-Host "" +} + +# Script entry point +try { + Main +} +catch { + Write-Host "" + Write-Host "An error occurred during setup:" -ForegroundColor Red + Write-Host $_.Exception.Message -ForegroundColor Red + Write-Host "" + Write-Host "Please check the error and try again, or contact the development team for assistance." -ForegroundColor Yellow + exit 1 +} \ No newline at end of file diff --git a/docs/dev/readme.md b/docs/dev/readme.md new file mode 100644 index 0000000..0df6701 --- /dev/null +++ b/docs/dev/readme.md @@ -0,0 +1,57 @@ +# Development Environment Setup + +This directory contains tools and scripts to help developers set up their local development environment. + +## Quick Start + +To set up your development environment, run the initialization script: + +```powershell +cd docs\dev +.\init.ps1 +``` + +## Scripts + +### `init.ps1` - Development Environment Initialization + +The `init.ps1` script is an interactive PowerShell script that helps new developers configure their local development environment. + +#### Prerequisites + +- PowerShell (Windows PowerShell or PowerShell Core) +- Git installed and available in PATH +- Repository cloned locally + +#### Usage + +1. Navigate to the `docs\dev` directory: + ```powershell + cd docs\dev + ``` + +2. Run the initialization script: + ```powershell + .\init.ps1 + ``` + +3. Follow the interactive prompts to configure your environment + +#### Command Line Options + +- **`-SkipConfirmation`**: Automatically confirms all steps without user interaction +- **`-Force`**: Forces configuration even if already set up + +Examples: + +```powershell +# Interactive mode (default) +.\init.ps1 + +# Automatic mode (no prompts) +.\init.ps1 -SkipConfirmation + +# Force reconfiguration +.\init.ps1 -Force +``` +