Skip to content

feat(dotnet): add structured metadata.dotnet with per-TFM capabilities - #36678

Open
Redth wants to merge 3 commits into
nrwl:masterfrom
Redth:redth-dotnet-analyzer-structured-metadata
Open

feat(dotnet): add structured metadata.dotnet with per-TFM capabilities#36678
Redth wants to merge 3 commits into
nrwl:masterfrom
Redth:redth-dotnet-analyzer-structured-metadata

Conversation

@Redth

@Redth Redth commented Aug 15, 2026

Copy link
Copy Markdown

Current Behavior

The @nx/dotnet analyzer infers Nx targets (build, test, publish, pack, etc.) from evaluated
MSBuild state, but it does not expose any of the underlying evaluated facts as project metadata.
Consumers that want to know a project's target framework(s), RID(s), or capabilities (is it a
test project? executable? packable? a .NET tool?) have no structured way to get that information
— they'd have to re-run/re-parse MSBuild themselves.

This addresses item 1 from #36676.

Expected Behavior

The analyzer now publishes a small, stable, namespaced metadata.dotnet shape on every .NET
project node:

{
  "dotnet": {
    "packageId": "MyCompany.MyLibrary",
    "capabilities": {
      "test": false,
      "executable": false,
      "packable": true,
      "publishable": false,
      "tool": false
    },
    "targetFrameworks": [
      {
        "packageId": "MyCompany.MyLibrary",
        "targetFramework": "net9.0",
        "targetFrameworkIdentifier": ".NETCoreApp",
        "targetFrameworkVersion": "v9.0",
        "runtimeIdentifiers": [],
        "capabilities": { "test": false, "executable": false, "packable": true, "publishable": false, "tool": false }
      }
    ]
  }
}
  • Project-level capabilities are the logical OR across every evaluated target framework.
  • targetFrameworks has one entry per evaluated MSBuild inner-build node (one per TFM for
    multi-targeted projects), each with its own framework/platform identifiers, evaluated
    RuntimeIdentifier/RuntimeIdentifiers, and its own non-aggregated capabilities.
  • packageId is the evaluated PackageId, falling back to AssemblyName (matching the
    NuGet packaging SDK's own default resolution). Each targetFrameworks entry has its own
    per-framework packageId; the project-level packageId is only set when every target
    framework that evaluates one agrees on the same value, and is omitted (undefined/absent from
    JSON) when a conditional PackageId disagrees across frameworks — a single project-level value
    would otherwise silently misrepresent one of the frameworks. Consumers that need the identity
    for a specific framework should always read it from that framework's own entry.

Design notes / scope boundaries (per discussion #36676):

  • Reuses existing evaluation, doesn't add any. CollectTargetFrameworkEvaluations collects
    properties/package-references from the analyzer's already-grouped MSBuild inner-build nodes
    (the same nodes multi-targeted projects already produce); it never re-parses project XML or
    triggers additional MSBuild evaluation. Ordering matches the project's declared
    TargetFrameworks sequence (read once from the shared outer-build property, since that
    property isn't batched per inner build), with a fallback to the primary node for legacy/non-SDK
    projects.
  • Capability derivation reuses existing signals, not new heuristics:
    • test reuses the exact same IsTestProject/Microsoft.NET.Test.Sdk/Microsoft.Testing.*
      check the analyzer already uses to decide whether to emit a test target (moved to
      ProjectUtilities so both call sites share one implementation). This is evaluated per target
      framework, so a conditional test package reference on only one TFM is captured correctly.
    • executable reuses the existing OutputType == "Exe" check.
    • publishable reads evaluated IsPublishable, falling back to executable only when
      unevaluated — this matters because the SDK forces IsPublishable=false for test projects
      even though their OutputType is Exe, and this implementation correctly reports those as
      not publishable rather than naively deriving publishable from executable.
    • packable reads evaluated IsPackable, defaulting to true when unevaluated (matching the
      NuGet packaging SDK's own default).
    • tool reads evaluated PackAsTool.
  • No inference beyond capabilities/facts. This intentionally does not add projectType
    inference, automatic tags, NxTags, host/OS routing, selector tags, wrappers, or target
    variants — those are separate, more opinionated items from the discussion and out of scope
    here.
  • Deterministic output. RuntimeIdentifiers are split/trimmed/deduplicated
    (OrdinalIgnoreCase), and duplicate TargetFramework entries across nodes are collapsed to
    one.
  • A DotnetProjectMetadata/DotnetCapabilities/DotnetTargetFrameworkMetadata TypeScript
    mirror was added to ProjectMetadata in packages/nx/src/config/workspace-json-project-json.ts
    (matching the existing js?: {...} namespace precedent) purely for typed consumption; no new
    runtime behavior lives on the TS side.

Overlap with #36469: that PR adds Central Package Management support and touches
Analyzer.cs/PackageReference.cs for package-dependency resolution. This PR also touches
Analyzer.cs (the same per-node property/package-reference collection helpers), but for an
unrelated purpose — evaluated per-TFM metadata rather than package/CPM resolution. The two are
independent and this PR does not duplicate or depend on #36469's package-dependency/CPM work
(no external/referenced-package nodes are added here; packageId is the project's own identity,
not a dependency).

Testing

  • 19 unit tests in DotnetMetadataBuilderTests.cs covering: single-target capability
    combinations, multi-target OR-aggregation with per-framework capability variance (including a
    conditional-test-package-per-TFM scenario), RuntimeIdentifier/RuntimeIdentifiers
    split/trim/dedup, framework/platform fact capture, per-TFM PackageId resolution/fallback,
    project-level PackageId agreement/disagreement (including an AssemblyName-fallback
    disagreement case), and duplicate-TargetFramework collapsing. These follow the existing
    pure-unit-test convention in this test project (plain dictionaries/lists, no real MSBuild
    project graph evaluation).
  • 2 new end-to-end tests in DotnetMetadataAnalyzerEndToEndTests.cs drive the real
    Analyzer.AnalyzeWorkspace entry point against an actual temporary multi-targeted project on
    disk (net8.0;net9.0, with a conditional per-TFM Microsoft.NET.Test.Sdk package reference and
    an explicit OutputType=Exe on one framework only), verifying both the object-graph shape and
    the actual public metadata.dotnet JSON serialization output (via the same camelCase/
    null-omitting JsonSerializerOptions the analyzer's Program.cs uses). This exercises real
    MSBuild property collection and per-TFM package-reference conditions that the pure-builder
    tests above don't cover, since those drive DotnetMetadataBuilder directly with
    hand-constructed dictionaries. A MSBuildTestRegistration module initializer registers MSBuild
    once for the test assembly, mirroring Program.cs's own registration.
  • All 50 tests in packages/dotnet/analyzer.Tests pass (dotnet test).
  • dotnet build on packages/dotnet/analyzer/analyzer.Tests succeeds with 0 warnings/errors.

Related Issue(s)

Addresses item 1 of #36676 (a discussion, not a closable
issue, so no "Fixes #" here). Related but independent: #36469.

Adds a stable, namespaced metadata.dotnet shape to the @nx/dotnet
analyzer's output, covering project-level capabilities (test,
executable, packable, publishable, tool) plus per-evaluated-target-
framework facts (TargetFramework short name, framework/platform
identifier+version, RuntimeIdentifier/RuntimeIdentifiers) and the
project's evaluated PackageId.

The builder (DotnetMetadataBuilder) is pure and reuses the analyzer's
already-grouped MSBuild inner-build nodes rather than re-evaluating or
re-parsing project XML: one evaluation per target framework is
collected in CollectTargetFrameworkEvaluations, ordered to match the
project's declared TargetFrameworks sequence, and falls back to the
primary node for legacy/non-SDK-style projects.

Capability derivation reuses the exact same signals the analyzer
already uses for target generation (now shared via ProjectUtilities
instead of duplicated): IsTestProject/Microsoft.NET.Test.Sdk/
Microsoft.Testing.* for Test, and OutputType=Exe for Executable.
Publishable falls back to Executable only when IsPublishable is
unevaluated, so SDK-forced-off test projects are correctly reported
as not publishable despite OutputType=Exe. Packable defaults to true
when IsPublishable is unevaluated. PackageId falls back to
AssemblyName, matching the NuGet packaging SDK's own default
resolution. RuntimeIdentifiers and duplicate TargetFramework entries
are deterministically deduplicated.

This intentionally does not add projectType inference, automatic
tags, NxTags, host/OS routing, selector tags, wrappers, or target
variants, per discussion nrwl#36676. It also does not duplicate the
package-dependency/CPM work in nrwl#36469; the two overlap only in that
both touch Analyzer.cs's per-node property/package-reference
collection, for unrelated purposes.

Refs nrwl#36676

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@netlify

netlify Bot commented Aug 15, 2026

Copy link
Copy Markdown

👷 Deploy request for nx-docs pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit f486fc9

@netlify

netlify Bot commented Aug 15, 2026

Copy link
Copy Markdown

👷 Deploy request for nx-dev pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit f486fc9

Redth and others added 2 commits August 15, 2026 16:03
…ect e2e coverage

- Add a per-target-framework PackageId to DotnetTargetFrameworkMetadata,
  resolved the same way as the project-level value (evaluated PackageId,
  falling back to AssemblyName).
- Only surface a project-level PackageId when every evaluated target
  framework agrees on it; previously the first non-null value was used
  even when a conditional PackageId disagreed across frameworks, silently
  misrepresenting the others. DotnetMetadataBuilder.ResolveProjectPackageId
  now returns null on disagreement so callers must consult each
  TargetFrameworks entry instead.
- Add an analyzer-level end-to-end test (DotnetMetadataAnalyzerEndToEndTests)
  that writes a real temporary multi-target project to disk and drives the
  actual Analyzer.AnalyzeWorkspace entry point, covering MSBuild property
  collection, a conditional per-TFM PackageReference, and the public
  metadata.dotnet JSON serialization shape — complementing the existing
  pure-builder tests, which never exercise real MSBuild evaluation.
- Update the TS metadata mirror and docs to describe the new per-TFM
  packageId field and the project-level ambiguity/null-when-divergent
  behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 697756d3-0a78-4585-b95d-85326a71430d
The serialization e2e test asserted the absence of a metadata.projectType
key, but projectType (if ever added) would be a sibling of metadata on
the project node itself, not a key nested inside metadata, so the
assertion never exercised anything meaningful. This PR is also
independent of nrwl#36679, which may add projectType separately; asserting
its absence here isn't this PR's concern to make or enforce.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 697756d3-0a78-4585-b95d-85326a71430d
@Redth
Redth marked this pull request as ready for review August 18, 2026 17:23
@Redth
Redth requested a review from a team as a code owner August 18, 2026 17:23
@Redth
Redth requested a review from JamesHenry August 18, 2026 17:23
@AgentEnder

Copy link
Copy Markdown
Member

This is really nicely built. The per-TFM model is right, per-framework capabilities carry information targets can't express (targets are project-scoped), and omitting the project-level packageId on disagreement rather than quietly picking a winner is exactly the right instinct.

Two changes and it's good to go:

  • Move the types out of packages/nx. The js?: {} precedent doesn't transfer. metadata.js is written by nx core (packages/nx/src/plugins/js/) and read by core (target-project-locator.ts, release version-actions, rspack buildable-lib detection, eslint-plugin dependency-checks), so it lives there because core owns both ends. Nothing in core reads metadata.dotnet. ProjectMetadata is already open via [k: string]: any, which is what @nx/maven and @nx/gradle rely on. Export DotnetProjectMetadata from @nx/dotnet instead. The rule we've settled on: a namespace gets typed in core when core reads it, otherwise the plugin owns it.
  • Drop the project-level capabilities aggregate. Keep the per-TFM ones. The project-scoped OR is derivable from which targets we generated, and two sources of truth for one fact will drift. Project-level packageId is different and should stay, since the agreement rule makes it non-derivable.

Nice touches worth keeping through any rework: reusing the existing IsTestProject check rather than adding a second heuristic, reading IsPublishable rather than deriving it from OutputType (test projects are the case that breaks the naive version), and defaulting IsPackable to true to match the NuGet SDK.

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.

2 participants