Skip to content

Commit 75d492d

Browse files
committed
Add grace period support post-install, simplify analyzer/generator
By moving more code to the diagnostic manager we can simplify the consuming side. Added analyzer tests to ensure we cover all scenarios end to end.
1 parent 33a20db commit 75d492d

22 files changed

+414
-194
lines changed

.github/workflows/os-matrix.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[ "ubuntu-latest", "macOS-latest", "windows-latest" ]

src/SponsorLink/Analyzer/Analyzer.csproj

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<CustomAfterMicrosoftCSharpTargets>$(MSBuildThisFileDirectory)..\SponsorLink.targets</CustomAfterMicrosoftCSharpTargets>
1010
<MergeAnalyzerAssemblies>true</MergeAnalyzerAssemblies>
1111
<ImplicitUsings>disable</ImplicitUsings>
12+
<FundingPackageId>SponsorableLib</FundingPackageId>
1213
</PropertyGroup>
1314

1415
<ItemGroup>
@@ -22,16 +23,23 @@
2223
<PackageReference Include="ThisAssembly.Project" Version="1.4.3" PrivateAssets="all" />
2324
</ItemGroup>
2425

25-
<ItemGroup>
26-
<InternalsVisibleTo Include="Tests" />
27-
</ItemGroup>
28-
2926
<ItemGroup>
3027
<None Update="buildTransitive\SponsorableLib.targets" Pack="true" />
3128
</ItemGroup>
3229

3330
<ItemGroup>
34-
<Compile Remove="C:\Code\devlooped.oss\src\SponsorLink\SponsorLink\ThisAssembly.cs" />
31+
<InternalsVisibleTo Include="Tests" />
3532
</ItemGroup>
3633

34+
<!-- To support tests, fake an extra sponsorable with the test key -->
35+
<Target Name="ReadTestJwk" BeforeTargets="GetAssemblyAttributes">
36+
<PropertyGroup>
37+
<!-- Read public key we validate manifests against -->
38+
<TestJwk>$([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)..\Tests\keys\kzu.pub.jwk'))</TestJwk>
39+
</PropertyGroup>
40+
<ItemGroup>
41+
<AssemblyMetadata Include="Funding.GitHub.kzu" Value="$(TestJwk)" />
42+
</ItemGroup>
43+
</Target>
44+
3745
</Project>

src/SponsorLink/Analyzer/StatusReportingGenerator.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ public class StatusReportingGenerator : IIncrementalGenerator
1010
public void Initialize(IncrementalGeneratorInitializationContext context)
1111
{
1212
context.RegisterSourceOutput(
13-
context.GetSponsorManifests(),
13+
// this is required to ensure status is registered properly independently
14+
// of analyzer runs.
15+
context.GetSponsorAdditionalFiles().Combine(context.AnalyzerConfigOptionsProvider),
1416
(spc, source) =>
1517
{
16-
var status = Diagnostics.GetOrSetStatus(source);
18+
var (manifests, options) = source;
19+
var status = Diagnostics.GetOrSetStatus(manifests, options);
1720
spc.AddSource("StatusReporting.cs", $"// Status: {status}");
1821
});
1922
}

src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
<Import Project="Devlooped.Sponsors.targets"/>
33
<ItemGroup>
44
<!-- Brings in the analyzer file to report installation time -->
5-
<SponsorablePackageId Include="SponsorableLib" />
5+
<FundingPackageId Include="SponsorableLib" />
66
</ItemGroup>
77
</Project>

src/SponsorLink/SponsorLink.targets

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
<!-- Default funding product the Product, which already part of ThisAssembly -->
1616
<FundingProduct Condition="'$(FundingProduct)' == ''">$(Product)</FundingProduct>
17+
<FundingPackageId Condition="'$(FundingPackageId)' == ''">$(PackageId)</FundingPackageId>
1718
<!-- Default prefix is the joined upper-case letters in the product name (i.e. for ThisAssembly, TA) -->
1819
<FundingPrefix Condition="'$(FundingPrefix)' == ''">$([System.Text.RegularExpressions.Regex]::Replace("$(FundingProduct)", "[^A-Z]", ""))</FundingPrefix>
1920
<!-- Default grace days for an expired sponsor manifest or unknown status -->
@@ -83,13 +84,18 @@
8384
</ItemGroup>
8485

8586
<Target Name="EmitFunding" BeforeTargets="CompileDesignTime;CoreCompile" Inputs="$(MSBuildAllProjects)" Outputs="$(IntermediateOutputPath)SponsorLink.g.cs">
87+
<Warning Condition="'$(FundingPackageId)' == ''" Code="SL001"
88+
Text="Could not determine value of FundingPackageId (defaulted to PackageId). Defaulting it to FundingProduct ('$(FundingProduct)'). Make sure this matches the containing package id, or set an explicit value." />
8689
<PropertyGroup>
90+
<!-- Default to Product, which is most common for single-package products (i.e. Moq) -->
91+
<FundingPackageId Condition="'$(FundingPackageId)' == ''">$(FundingProduct)</FundingPackageId>
8792
<SponsorLinkPartial>namespace Devlooped.Sponsors%3B
8893

8994
partial class SponsorLink
9095
{
9196
public partial class Funding
9297
{
98+
public const string PackageId = "$(FundingPackageId)"%3B
9399
public const string Product = "$(FundingProduct)"%3B
94100
public const string Prefix = "$(FundingPrefix)"%3B
95101
public const int Grace = $(FundingGrace)%3B

src/SponsorLink/SponsorLink/DiagnosticsManager.cs

Lines changed: 79 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using System.IO;
1010
using System.IO.MemoryMappedFiles;
1111
using System.Linq;
12+
using System.Runtime.InteropServices;
1213
using System.Threading;
1314
using Humanizer;
1415
using Humanizer.Localisation;
@@ -50,14 +51,15 @@ ConcurrentDictionary<string, Diagnostic> Diagnostics
5051
/// <returns>The removed diagnostic, or <see langword="null" /> if none was previously pushed.</returns>
5152
public void ReportOnce(Action<Diagnostic> report, string product = Funding.Product)
5253
{
53-
if (Diagnostics.TryRemove(product, out var diagnostic))
54+
if (Diagnostics.TryRemove(product, out var diagnostic) &&
55+
GetStatus(diagnostic) != SponsorStatus.Grace)
5456
{
5557
// Ensure only one such diagnostic is reported per product for the entire process,
5658
// so that we can avoid polluting the error list with duplicates across multiple projects.
5759
var id = string.Concat(Process.GetCurrentProcess().Id, product, diagnostic.Id);
5860
using var mutex = new Mutex(false, "mutex" + id);
5961
mutex.WaitOne();
60-
using var mmf = MemoryMappedFile.CreateOrOpen(id, 1);
62+
using var mmf = CreateOrOpenMemoryMappedFile(id, 1);
6163
using var accessor = mmf.CreateViewAccessor();
6264
if (accessor.ReadByte(0) == 0)
6365
{
@@ -75,52 +77,61 @@ public void ReportOnce(Action<Diagnostic> report, string product = Funding.Produ
7577
/// https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Analyzer%20Actions%20Semantics.md under Ordering of actions).
7678
/// </summary>
7779
/// <returns>Optional <see cref="SponsorStatus"/> that was reported, if any.</returns>
80+
/// <devdoc>
81+
/// The SponsorLinkAnalyzer.GetOrSetStatus uses diagnostic properties to store the
82+
/// kind of diagnostic as a simple string instead of the enum. We do this so that
83+
/// multiple analyzers or versions even across multiple products, which all would
84+
/// have their own enum, can still share the same diagnostic kind.
85+
/// </devdoc>
7886
public SponsorStatus? GetStatus()
79-
{
80-
// NOTE: the SponsorLinkAnalyzer.SetStatus uses diagnostic properties to store the
81-
// kind of diagnostic as a simple string instead of the enum. We do this so that
82-
// multiple analyzers or versions even across multiple products, which all would
83-
// have their own enum, can still share the same diagnostic kind.
84-
if (Diagnostics.TryGetValue(Funding.Product, out var diagnostic) &&
85-
diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value))
86-
{
87-
// Switch on value matching DiagnosticKind names
88-
return value switch
89-
{
90-
nameof(SponsorStatus.Unknown) => SponsorStatus.Unknown,
91-
nameof(SponsorStatus.Sponsor) => SponsorStatus.Sponsor,
92-
nameof(SponsorStatus.Expiring) => SponsorStatus.Expiring,
93-
nameof(SponsorStatus.Expired) => SponsorStatus.Expired,
94-
_ => null,
95-
};
96-
}
97-
98-
return null;
99-
}
87+
=> Diagnostics.TryGetValue(Funding.Product, out var diagnostic) ? GetStatus(diagnostic) : null;
10088

10189
/// <summary>
10290
/// Gets the status of the <see cref="Funding.Product"/>, or sets it from
10391
/// the given set of <paramref name="manifests"/> if not already set.
10492
/// </summary>
105-
public SponsorStatus GetOrSetStatus(ImmutableArray<AdditionalText> manifests)
106-
=> GetOrSetStatus(() => manifests);
93+
public SponsorStatus GetOrSetStatus(ImmutableArray<AdditionalText> manifests, AnalyzerConfigOptionsProvider options)
94+
=> GetOrSetStatus(() => manifests, () => options.GlobalOptions);
10795

10896
/// <summary>
10997
/// Gets the status of the <see cref="Funding.Product"/>, or sets it from
11098
/// the given analyzer <paramref name="options"/> if not already set.
11199
/// </summary>
112100
public SponsorStatus GetOrSetStatus(Func<AnalyzerOptions?> options)
113-
=> GetOrSetStatus(() => options().GetSponsorManifests());
101+
=> GetOrSetStatus(() => options().GetSponsorAdditionalFiles(), () => options()?.AnalyzerConfigOptionsProvider.GlobalOptions);
114102

115-
SponsorStatus GetOrSetStatus(Func<ImmutableArray<AdditionalText>> getManifests)
103+
SponsorStatus GetOrSetStatus(Func<ImmutableArray<AdditionalText>> getAdditionalFiles, Func<AnalyzerConfigOptions?> getGlobalOptions)
116104
{
117105
if (GetStatus() is { } status)
118106
return status;
119107

120-
if (!SponsorLink.TryRead(out var claims, getManifests().Select(text =>
108+
if (!SponsorLink.TryRead(out var claims, getAdditionalFiles().Where(x => x.Path.EndsWith(".jwt")).Select(text =>
121109
(text.GetText()?.ToString() ?? "", Sponsorables[Path.GetFileNameWithoutExtension(text.Path)]))) ||
122110
claims.GetExpiration() is not DateTime exp)
123111
{
112+
var noGrace = getGlobalOptions() is { } globalOptions &&
113+
globalOptions.TryGetValue("build_property.SponsorLinkNoInstallGrace", out var value) &&
114+
bool.TryParse(value, out var skipCheck) && skipCheck;
115+
116+
if (noGrace != true)
117+
{
118+
// Consider grace period if we can find the install time.
119+
var installed = getAdditionalFiles()
120+
.Where(x => x.Path.EndsWith(".dll"))
121+
.Select(x => File.GetLastWriteTime(x.Path))
122+
.OrderByDescending(x => x)
123+
.FirstOrDefault();
124+
125+
if (installed != default && ((DateTime.Now - installed).TotalDays <= Funding.Grace))
126+
{
127+
// report unknown, either unparsed manifest or one with no expiration (which we never emit).
128+
Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Unknown], null,
129+
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Grace)),
130+
Funding.Product, Sponsorables.Keys.Humanize(Resources.Or)));
131+
return SponsorStatus.Grace;
132+
}
133+
}
134+
124135
// report unknown, either unparsed manifest or one with no expiration (which we never emit).
125136
Push(Diagnostic.Create(KnownDescriptors[SponsorStatus.Unknown], null,
126137
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Unknown)),
@@ -169,7 +180,7 @@ Diagnostic Push(Diagnostic diagnostic, string product = Funding.Product)
169180
var id = string.Concat(Process.GetCurrentProcess().Id, product, diagnostic.Id);
170181
using var mutex = new Mutex(false, "mutex" + id);
171182
mutex.WaitOne();
172-
using var mmf = MemoryMappedFile.CreateOrOpen(id, 1);
183+
using var mmf = CreateOrOpenMemoryMappedFile(id, 1);
173184
using var accessor = mmf.CreateViewAccessor();
174185
accessor.Write(0, 0);
175186
Tracing.Trace($"👉{diagnostic.Severity.ToString().ToLowerInvariant()}:{Process.GetCurrentProcess().Id}:{Process.GetCurrentProcess().ProcessName}:{product}:{diagnostic.Id}");
@@ -178,16 +189,46 @@ Diagnostic Push(Diagnostic diagnostic, string product = Funding.Product)
178189
return diagnostic;
179190
}
180191

192+
SponsorStatus? GetStatus(Diagnostic? diagnostic) => diagnostic?.Properties.TryGetValue(nameof(SponsorStatus), out var value) == true
193+
? value switch
194+
{
195+
nameof(SponsorStatus.Grace) => SponsorStatus.Grace,
196+
nameof(SponsorStatus.Unknown) => SponsorStatus.Unknown,
197+
nameof(SponsorStatus.Sponsor) => SponsorStatus.Sponsor,
198+
nameof(SponsorStatus.Expiring) => SponsorStatus.Expiring,
199+
nameof(SponsorStatus.Expired) => SponsorStatus.Expired,
200+
_ => null,
201+
}
202+
: null;
203+
204+
static MemoryMappedFile CreateOrOpenMemoryMappedFile(string mapName, long capacity)
205+
{
206+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
207+
{
208+
return MemoryMappedFile.CreateOrOpen(mapName, capacity);
209+
}
210+
else
211+
{
212+
// On Linux, use a file-based memory-mapped file
213+
string filePath = $"/tmp/{mapName}";
214+
using (var fs = new FileStream(filePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite))
215+
{
216+
fs.SetLength(capacity);
217+
return MemoryMappedFile.CreateFromFile(fs, mapName, capacity, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, false);
218+
}
219+
}
220+
}
221+
181222
internal static DiagnosticDescriptor CreateSponsor(string[] sponsorable, string prefix) => new(
182-
$"{prefix}100",
183-
Resources.Sponsor_Title,
184-
Resources.Sponsor_Message,
185-
"SponsorLink",
186-
DiagnosticSeverity.Info,
187-
isEnabledByDefault: true,
188-
description: Resources.Sponsor_Description,
189-
helpLinkUri: "https://github.com/devlooped#sponsorlink",
190-
"DoesNotSupportF1Help");
223+
$"{prefix}100",
224+
Resources.Sponsor_Title,
225+
Resources.Sponsor_Message,
226+
"SponsorLink",
227+
DiagnosticSeverity.Info,
228+
isEnabledByDefault: true,
229+
description: Resources.Sponsor_Description,
230+
helpLinkUri: "https://github.com/devlooped#sponsorlink",
231+
"DoesNotSupportF1Help");
191232

192233
internal static DiagnosticDescriptor CreateUnknown(string[] sponsorable, string product, string prefix) => new(
193234
$"{prefix}101",

src/SponsorLink/SponsorLink/SponsorLink.cs

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,31 +64,38 @@ static partial class SponsorLink
6464
.Max().DateTime is var exp && exp == DateTime.MinValue ? null : exp;
6565

6666
/// <summary>
67-
/// Gets all sponsor manifests from the provided analyzer options.
67+
/// Gets all necessary additional files to determine status.
6868
/// </summary>
69-
public static ImmutableArray<AdditionalText> GetSponsorManifests(this AnalyzerOptions? options)
69+
public static ImmutableArray<AdditionalText> GetSponsorAdditionalFiles(this AnalyzerOptions? options)
7070
=> options == null ? ImmutableArray.Create<AdditionalText>() : options.AdditionalFiles
71-
.Where(x =>
72-
options.AnalyzerConfigOptionsProvider.GetOptions(x).TryGetValue("build_metadata.SponsorManifest.ItemType", out var itemType) &&
73-
itemType == "SponsorManifest" &&
74-
Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(x.Path)))
71+
.Where(x => x.IsSponsorManifest(options.AnalyzerConfigOptionsProvider) || x.IsSponsorableAnalyzer(options.AnalyzerConfigOptionsProvider))
7572
.ToImmutableArray();
7673

7774
/// <summary>
7875
/// Gets all sponsor manifests from the provided analyzer options.
7976
/// </summary>
80-
public static IncrementalValueProvider<ImmutableArray<AdditionalText>> GetSponsorManifests(this IncrementalGeneratorInitializationContext context)
77+
public static IncrementalValueProvider<ImmutableArray<AdditionalText>> GetSponsorAdditionalFiles(this IncrementalGeneratorInitializationContext context)
8178
=> context.AdditionalTextsProvider.Combine(context.AnalyzerConfigOptionsProvider)
8279
.Where(source =>
8380
{
84-
var (text, options) = source;
85-
return options.GetOptions(text).TryGetValue("build_metadata.SponsorManifest.ItemType", out var itemType) &&
86-
itemType == "SponsorManifest" &&
87-
Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(text.Path));
81+
var (text, provider) = source;
82+
return text.IsSponsorManifest(provider) || text.IsSponsorableAnalyzer(provider);
8883
})
8984
.Select((source, c) => source.Left)
9085
.Collect();
9186

87+
static bool IsSponsorManifest(this AdditionalText text, AnalyzerConfigOptionsProvider provider)
88+
=> provider.GetOptions(text).TryGetValue("build_metadata.SponsorManifest.ItemType", out var itemType) &&
89+
itemType == "SponsorManifest" &&
90+
Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(text.Path));
91+
92+
static bool IsSponsorableAnalyzer(this AdditionalText text, AnalyzerConfigOptionsProvider provider)
93+
=> provider.GetOptions(text) is { } options &&
94+
options.TryGetValue("build_metadata.Analyzer.ItemType", out var itemType) &&
95+
options.TryGetValue("build_metadata.Analyzer.NuGetPackageId", out var packageId) &&
96+
itemType == "Analyzer" &&
97+
packageId == Funding.PackageId;
98+
9299
/// <summary>
93100
/// Reads all manifests, validating their signatures.
94101
/// </summary>

src/SponsorLink/SponsorLink/SponsorLink.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<PropertyGroup Label="SponsorLink">
1212
<!-- Default funding product the Product, which already part of ThisAssembly -->
1313
<FundingProduct Condition="'$(FundingProduct)' == ''">$(Product)</FundingProduct>
14+
<FundingPackageId Condition="'$(FundingPackageId)' == ''">$(PackageId)</FundingPackageId>
1415
<!-- Default prefix is the joined upper-case letters in the product name (i.e. for ThisAssembly, TA) -->
1516
<FundingPrefix Condition="'$(FundingPrefix)' == ''">$([System.Text.RegularExpressions.Regex]::Replace("$(FundingProduct)", "[^A-Z]", ""))</FundingPrefix>
1617
<!-- Default grace days for an expired sponsor manifest -->
@@ -37,13 +38,18 @@
3738
</ItemGroup>
3839

3940
<Target Name="EmitFunding" BeforeTargets="GenerateMSBuildEditorConfigFileShouldRun" Inputs="$(MSBuildAllProjects)" Outputs="$(IntermediateOutputPath)SponsorLink.g.cs">
41+
<Warning Condition="'$(FundingPackageId)' == ''" Code="SL001"
42+
Text="Could not determine value of FundingPackageId (defaulted to PackageId). Defaulting it to FundingProduct ('$(FundingProduct)'). Make sure this matches the containing package id, or set an explicit value." />
4043
<PropertyGroup>
44+
<!-- Default to Product, which is most common for single-package products (i.e. Moq) -->
45+
<FundingPackageId Condition="'$(FundingPackageId)' == ''">$(FundingProduct)</FundingPackageId>
4146
<SponsorLinkPartial>namespace Devlooped.Sponsors%3B
4247

4348
partial class SponsorLink
4449
{
4550
public partial class Funding
4651
{
52+
public const string PackageId = "$(FundingPackageId)"%3B
4753
public const string Product = "$(FundingProduct)"%3B
4854
public const string Prefix = "$(FundingPrefix)"%3B
4955
public const int Grace = $(FundingGrace)%3B

0 commit comments

Comments
 (0)