Skip to content

Add analyzer section in project.assets.json file#7464

Open
martinrrm wants to merge 2 commits into
devfrom
dev-mruizmares-analyzer-assets
Open

Add analyzer section in project.assets.json file#7464
martinrrm wants to merge 2 commits into
devfrom
dev-mruizmares-analyzer-assets

Conversation

@martinrrm

@martinrrm martinrrm commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Bug

Progress: NuGet/Home#6279
SDK PR (draft): dotnet/sdk#54646

Description

Today project.assets.json does not record which analyzers a package contributes, and analyzers are applied regardless of PrivateAssets / ExcludeAssets / IncludeAssets. This PR implements the NuGet restore half of the "indicate analyzer assets in project.assets.json" feature (spec: NuGet/Home#14455). The SDK consumption half ships as a separate dotnet/sdk PR.

When a project opts in with <RestoreEnableAnalyzerAssets>true</RestoreEnableAnalyzerAssets>, restore now:

  • Emits an analyzers group under each package in the assets file, listing every analyzer assembly — any .dll under analyzers/ at any depth, excluding satellite .resources.dll — mirroring the SDK's analyzer discovery so the layout isn't assumed to be fixed.
  • Respects asset filtering. PrivateAssets, ExcludeAssets, and IncludeAssets now filter analyzers like any other asset type. Analyzers excluded or transitively suppressed are written as a bare _._ placeholder.
  • Attaches selection metadata to each entry: codeLanguage (cs/vb/fs/any) and, when present in the path, compilerApiVersion (roslynX.Y) — mirroring how content files carry codeLanguage — so the SDK selects applicable analyzers from metadata instead of parsing paths.

Gating. The feature is opt-in and only honored for projects targeting .NET 11 or greater; on earlier frameworks the opt-in is forced off in NuGet.targets (mirroring NuGetAuditMode gating). For multi-targeted projects the effective value is OR'd across frameworks. A disabled, commented scaffold documents the future default-on rollout step.

Telemetry. ProjectRestoreInformation now reports AnalyzerAssets.Enabled (opt-in state, emitted on every restore including no-ops) and AnalyzerAssets.Count (number of analyzer assets emitted, excluding _._ placeholders), so adoption and impact can be tracked through the rollout.

Public API & serialization. Adds ProjectRestoreMetadata.RestoreEnableAnalyzerAssets, LockFileTargetLibrary.AnalyzerAssets, and LockFileItem.CompilerApiVersionProperty. Both the Newtonsoft.Json and System.Text.Json read/write paths round-trip the new group, metadata, and restore property. The lock-file cache key includes the flag so toggling it invalidates correctly.

PR Checklist

  • Meaningful title, helpful description and a linked NuGet/Home issue
  • Added tests
  • Link to an issue or pull request to update docs if this PR changes settings, environment variables, new feature, etc.

@martinrrm martinrrm requested a review from a team as a code owner June 8, 2026 23:06
@martinrrm martinrrm requested review from kartheekp-ms and zivkan June 8, 2026 23:06
jebriede
jebriede previously approved these changes Jun 9, 2026

@jebriede jebriede left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving with a few suggestions.

Comment thread src/NuGet.Core/NuGet.Commands/RestoreCommand/RestoreCommand.cs Outdated
private static bool IsAnalyzerAssetPath(string path)
{
return path.StartsWith("analyzers/", StringComparison.Ordinal)
&& path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "analyzers/" prefix check is StringComparison.Ordinal (case-sensitive) while the .dll / .resources.dll checks are OrdinalIgnoreCase. If a package authored the folder as Analyzers/ this would skip it while the SDK's discovery (which we're mirroring) may still pick it up. Was case-sensitivity on the folder segment intentional? If not, consider OrdinalIgnoreCase for consistency with the extension checks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/NuGet.Core/NuGet.Commands/RestoreCommand/Utility/LockFileUtils.cs Outdated
Comment thread src/NuGet.Core/NuGet.ProjectModel/LockFile/LockFileFormat.cs

@jebriede jebriede left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving with an optimization suggestion.


telemetry.TelemetryEvent[UpdatedAssetsFile] = restoreResult._isAssetsFileDirty.Value;
telemetry.TelemetryEvent[UpdatedMSBuildFiles] = restoreResult._dirtyMSBuildFiles.Value.Count > 0;
telemetry.TelemetryEvent[AnalyzerAssetsCount] = CountAnalyzerAssets(assetsFile);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CountAnalyzerAssets runs on every real restore and walks every target and every library to populate AnalyzerAssetsCount. When the feature is off (which is the default and the common case during rollout) the count is always 0, so we pay for the full nested walk to produce a constant. Is it worth short-circuiting when the opt-in is disabled? Something like:

Suggested change
telemetry.TelemetryEvent[AnalyzerAssetsCount] = CountAnalyzerAssets(assetsFile);
telemetry.TelemetryEvent[AnalyzerAssetsCount] =
(_request.Project.RestoreMetadata?.RestoreEnableAnalyzerAssets ?? false)
? CountAnalyzerAssets(assetsFile)
: 0;

The inner walk is cheap per library (frozen libraries return Array.Empty), so this is a minor optimization rather than a correctness concern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to make some changes here (like you said it always return 0) for the Telemetry, but we always want this since it will help us decide when to make this new behavior as default.

@nkolev92 nkolev92 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we're introducing a new implementation pattern in a couple of different places, but we already have an established one.

<Output TaskParameter="AbsolutePaths" PropertyName="RestoreOutputAbsolutePath" />
</ConvertToAbsolutePath>

<ItemGroup Condition="'$(TargetFrameworks)' != ''">

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we instead follow the logic for audit and pruning where the decision is made in code.

This implementation is only really a factor in standard CLI restore.
The other 3 restores share an implementation, this doesn't.

return lockFileItems;
}

private static bool IsAnalyzerAssetPath(string path)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should be using ManagedCodeConventions here.

That's what knows how to find the patterns.

NativeLibraries = new PatternSet(
conventions.Properties,
groupPatterns: new PatternDefinition[]
{
new PatternDefinition("runtimes/{rid}/nativeassets/{tfm}/{any?}", table: DotnetAnyTable),
new PatternDefinition("runtimes/{rid}/native/{any?}", table: null, defaults: DefaultTfmAny)
},
pathPatterns: new PatternDefinition[]
{
new PatternDefinition("runtimes/{rid}/nativeassets/{tfm}/{any}", table: DotnetAnyTable),
new PatternDefinition("runtimes/{rid}/native/{any}", table: null, defaults: DefaultTfmAny)
});

/// the group has items. Empty groups are left alone.
/// </summary>
private static void ClearIfExists<T>(IList<T> group, Func<string, T> factory) where T : LockFileItem
private static void ClearIfExists<T>(IList<T> group, Func<string, T> factory, bool copyProperties = true) where T : LockFileItem

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain what are the properties we're stopping from copying in the analyzers scenario?

int segmentLength = separator - segmentStart;
ReadOnlySpan<char> segment = path.AsSpan(segmentStart, segmentLength);

if (segment.Equals("cs".AsSpan(), StringComparison.OrdinalIgnoreCase))

@kartheekp-ms kartheekp-ms Jun 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hard coded strings still cause allocations. Is there any way to avoid them?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants