Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
3 changes: 2 additions & 1 deletion docs/wiki/Configuration-Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ property change rather than an edit in each project.

Overrides are layered, narrowest first:

1. an explicit `TargetFramework`/`TargetFrameworks` in the project always wins;
1. an explicit `TargetFramework`/`TargetFrameworks` in the project always wins. Like the set selection
itself, this is read from the project XML, so it must be declared **unconditionally** to be seen;
2. a repository can redefine any set with `PurviewTargetFrameworks<Set>` — `PurviewTargetFrameworksSupported`,
`PurviewTargetFrameworksBroad`, and so on — set before the SDK import. This is also how a set is pinned
while a consumer is not ready to follow the SDK's definition;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "purview-build-sdk",
"version": "1.0.3.0",
"version": "1.0.3.1",
"homepage": "https://purview.dev/projects/build-sdk/",
"bugs": {
"url": "https://github.com/purview-dev/build-sdk/issues"
Expand Down
35 changes: 32 additions & 3 deletions src/src/BuildSdk/Sdk/Sdk.props
Original file line number Diff line number Diff line change
Expand Up @@ -584,9 +584,36 @@
>net8.0;net9.0;net10.0;net11.0</PurviewTargetFrameworksAll>
</PropertyGroup>

<!--
The resolution below must not be gated on '$(TargetFramework)' == ''.

TargetFrameworks is an invariant of a project: it has to evaluate to the same list no matter
what TargetFramework is, because an inner build receives TargetFramework as a *global*
property. Visual Studio's project system relies on that. CPS evaluates the project once with
no TargetFramework to discover the TFM dimensions, then again per dimension with
TargetFramework set; gating on an empty TargetFramework makes the second evaluation drop
TargetFrameworks entirely, the two evaluations disagree about whether the project
cross-targets, and the configured project CPS builds the language-service context from has no
TFM at all. Microsoft.Common.CurrentVersion.targets is never reached in that state, TargetPath
is never defined, and the project loads with

InvalidProjectDataException: Property 'TargetPath' is required to be an absolute path,
but the value is ''

which unloads IntelliSense for the project. `dotnet build` and `msbuild` hide the defect
because they only ever evaluate the outer build, read the list and dispatch the inner builds
themselves, so the inconsistent second evaluation never happens.

A project declaring its own TargetFramework/TargetFrameworks is detected from the project text
instead (_PurviewProjectDeclaresTargetFramework), which is evaluation-stable - it depends only
on the file on disk. That also makes the documented layering exact: a project declaring
<TargetFramework>net48</TargetFramework> in its body previously still had a repository-wide
set's TargetFrameworks list applied underneath it, because the body had not been evaluated yet
at this point.
-->
<PropertyGroup
Label="Curated Target Framework Set Resolution"
Condition="'$(IsCSharpProject)' == 'true' AND '$(IsRoslynComponent)' != 'true' AND '$(TargetFramework)' == '' AND '$(TargetFrameworks)' == ''"
Condition="'$(IsCSharpProject)' == 'true' AND '$(IsRoslynComponent)' != 'true' AND '$(_PurviewProjectDeclaresTargetFramework)' != 'true' AND '$(TargetFrameworks)' == ''"
>
<!-- 'Current' when nothing is selected, so the set mechanism - not a literal buried further down
this file - is what decides a project's targets. It resolves to the same single TFM the SDK
Expand All @@ -604,9 +631,11 @@
<_PurviewResolvedTargetFrameworkSet Condition="'$(PurviewTargetFrameworkSet)' == 'All'"
>$(PurviewTargetFrameworksAll)</_PurviewResolvedTargetFrameworkSet>

<!-- A single entry becomes TargetFramework, so a one-TFM set does not pay for an outer build. -->
<!-- A single entry becomes TargetFramework, so a one-TFM set does not pay for an outer build.
Guarded on an empty TargetFramework so an inner build's global property is never fought:
unlike TargetFrameworks above, this one is per-evaluation by nature. -->
<TargetFramework
Condition="'$(_PurviewResolvedTargetFrameworkSet)' != '' AND !$(_PurviewResolvedTargetFrameworkSet.Contains(';'))"
Condition="'$(TargetFramework)' == '' AND '$(_PurviewResolvedTargetFrameworkSet)' != '' AND !$(_PurviewResolvedTargetFrameworkSet.Contains(';'))"
>$(_PurviewResolvedTargetFrameworkSet)</TargetFramework>
<TargetFrameworks
Condition="'$(_PurviewResolvedTargetFrameworkSet)' != '' AND $(_PurviewResolvedTargetFrameworkSet.Contains(';'))"
Expand Down
25 changes: 23 additions & 2 deletions src/tests/BuildSdk.IntegrationTests/Harness/ProjectHarness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -211,16 +211,37 @@ await File.WriteAllTextAsync(
/// Evaluates one or more MSBuild properties via <c>dotnet msbuild -getProperty</c>
/// without triggering a build or package restore.
/// </summary>
public async Task<IReadOnlyDictionary<string, string>> GetPropertiesAsync(
public Task<IReadOnlyDictionary<string, string>> GetPropertiesAsync(
CancellationToken cancellationToken,
params string[] propertyNames
) => GetPropertiesCoreAsync(globalProperties: null, propertyNames, cancellationToken);

/// <summary>
/// Evaluates MSBuild properties with explicit global properties. This is the only way to
/// reproduce an inner build — or Visual Studio's per-TFM configured evaluation — where
/// <c>TargetFramework</c> arrives as a global property rather than from the project, which is
/// precisely where an evaluation-unstable <c>TargetFrameworks</c> shows up.
/// </summary>
public Task<IReadOnlyDictionary<string, string>> GetPropertiesAsync(
IReadOnlyDictionary<string, string> globalProperties,
CancellationToken cancellationToken,
params string[] propertyNames
) => GetPropertiesCoreAsync(globalProperties, propertyNames, cancellationToken);

async Task<IReadOnlyDictionary<string, string>> GetPropertiesCoreAsync(
IReadOnlyDictionary<string, string>? globalProperties,
string[] propertyNames,
CancellationToken cancellationToken
)
{
if (propertyNames.Length == 0)
return ImmutableDictionary<string, string>.Empty;

var propList = string.Join(",", propertyNames);
var args = $"msbuild \"{ProjectFilePath}\" -nologo -noconlog -getProperty:{propList}";
var globals = globalProperties is null
? string.Empty
: string.Concat(globalProperties.Select(kvp => $" -p:{kvp.Key}=\"{kvp.Value}\""));
var args = $"msbuild \"{ProjectFilePath}\" -nologo -noconlog{globals} -getProperty:{propList}";

var (exitCode, stdOut, stdErr) = await RunAsync("dotnet", args, cancellationToken);

Expand Down
101 changes: 101 additions & 0 deletions src/tests/BuildSdk.IntegrationTests/TargetFrameworkSetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -207,4 +207,105 @@ public async Task UnrecognisedSet_FailsTheBuild(CancellationToken cancellationTo
await Assert.That(exitCode).IsNotEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr));
await Assert.That(stdOut + stdErr).Contains("PurviewInvalidTargetFrameworkSet");
}

/// <summary>
/// TargetFrameworks has to evaluate to the same list whatever TargetFramework is, because an inner
/// build receives TargetFramework as a global property. Visual Studio's project system evaluates a
/// project once with no TargetFramework to discover its TFM dimensions and again per dimension with
/// TargetFramework set; when the second evaluation loses TargetFrameworks, the configured project
/// CPS builds the language-service context from has no TFM, TargetPath is never defined, and the
/// project loads with "Property 'TargetPath' is required to be an absolute path, but the value is ''".
/// Neither dotnet nor msbuild reproduces it - both only evaluate the outer build.
/// </summary>
[Test]
[Arguments("Supported", "net10.0;net11.0", "net10.0")]
[Arguments("All", "net8.0;net9.0;net10.0;net11.0", "net8.0")]
public async Task MultiTargetSet_ResolvesIdenticallyUnderAnInnerBuildTargetFramework(
string set,
string expected,
string innerTargetFramework,
CancellationToken cancellationToken
)
{
using var h = await ProjectHarness
.For("Library")
.WithProjectFileContent(
$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PurviewTargetFrameworkSet>{set}</PurviewTargetFrameworkSet>
</PropertyGroup>
</Project>
"""
)
.BuildAsync(cancellationToken);

var globals = new Dictionary<string, string>(StringComparer.Ordinal)
{
["TargetFramework"] = innerTargetFramework,
};

var properties = await h.GetPropertiesAsync(globals, cancellationToken, "TargetFrameworks", "TargetPath");

await Assert.That(properties["TargetFrameworks"]).IsEqualTo(expected);
// The reason the inconsistency is fatal rather than cosmetic: without a TFM the project never
// reaches Microsoft.Common.CurrentVersion.targets, so it has no output path to report.
await Assert.That(properties["TargetPath"]).IsNotEmpty();
}

/// <summary>
/// The same invariant for the outer evaluation, which is the one CPS reads the TFM dimensions from.
/// Paired with the test above, this pins both halves: the list must be identical in each.
/// </summary>
[Test]
public async Task MultiTargetSet_OuterEvaluationHasNoTargetFrameworkAndNoTargetPath(
CancellationToken cancellationToken
)
{
using var h = await ProjectHarness
.For("Library")
.WithProjectFileContent(
"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PurviewTargetFrameworkSet>All</PurviewTargetFrameworkSet>
</PropertyGroup>
</Project>
"""
)
.BuildAsync(cancellationToken);

var properties = await h.GetPropertiesAsync(cancellationToken, "TargetFramework", "TargetFrameworks");

await Assert.That(properties["TargetFrameworks"]).IsEqualTo("net8.0;net9.0;net10.0;net11.0");
await Assert.That(properties["TargetFramework"]).IsEmpty();
}

/// <summary>
/// A project declaring its own TargetFramework must not also receive a repository-wide set's list
/// underneath it. The declaration is detected from the project text rather than from an evaluated
/// TargetFramework, so this holds even though the project body is evaluated after the SDK import.
/// </summary>
[Test]
public async Task ExplicitTargetFramework_WinsOverASelectedSet(CancellationToken cancellationToken)
{
using var h = await ProjectHarness
.For("Library")
.WithPreImportProperty("PurviewTargetFrameworkSet", "All")
.WithProjectFileContent(
"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
</Project>
"""
)
.BuildAsync(cancellationToken);

var properties = await h.GetPropertiesAsync(cancellationToken, "TargetFramework", "TargetFrameworks");

await Assert.That(properties["TargetFramework"]).IsEqualTo("net10.0");
await Assert.That(properties["TargetFrameworks"]).IsEmpty();
}
}
Loading