diff --git a/.editorconfig b/.editorconfig
index 4fda749..120d509 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -149,19 +149,22 @@ dotnet_naming_symbols.other_public_protected_fields_group.applicable_kinds = fie
# StyleCop Field Naming Rules
-# All constant fields must be PascalCase
+# All non-private constant fields must be PascalCase. Private constants are owned by
+# 'private_static_fields_group' below - a field must be covered by exactly one rule, otherwise the
+# same violation is reported once per matching rule.
dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.severity = warning
dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.style = non_private_static_field_style
dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.symbols = stylecop_constant_fields_group
-dotnet_naming_symbols.stylecop_constant_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected, private
+dotnet_naming_symbols.stylecop_constant_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected
dotnet_naming_symbols.stylecop_constant_fields_group.applicable_kinds = field
dotnet_naming_symbols.stylecop_constant_fields_group.required_modifiers = const
-# All static readonly fields must be PascalCase
+# All non-private static readonly fields must be PascalCase. Private static readonly fields are
+# owned by 'private_static_fields_group' below (see the note on the constant rule above).
dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.severity = warning
dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.style = non_private_static_field_style
dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.symbols = stylecop_static_readonly_fields_group
-dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected, private
+dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected
dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_kinds = field
dotnet_naming_symbols.stylecop_static_readonly_fields_group.required_modifiers = static, readonly
diff --git a/README.md b/README.md
index 459ebfb..2707324 100644
--- a/README.md
+++ b/README.md
@@ -238,6 +238,9 @@ Version detection logging is disabled by default. Set `VersionDetectionLogEnable
| `NamespacePrefix` | *(required)* | Root namespace prefix, e.g. `Acme`. Results in `Acme.MyProject`. |
| `DisableNamespacePrefixCheck` | `false` | Set to `true` to suppress the build error for missing `NamespacePrefix`. |
| `DisablePurviewStylePolicyValidation` | `false` | Set to `true` to stop the build failing (`PRSGD0006`-`PRSGD0009`) when the repository `.editorconfig` overrides the modifier policy, hides `IDE0040`/`IDE1006`, disables the Style category in bulk, weakens the `_camelCase` field-naming rule, or adds the accessibility rules to `NoWarn`. |
+| `PurviewTestContextNoWarn` | `CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707` | Semicolon-fenced rule set exempted in test and shared-testing projects, so test classes stay public, `Method_Scenario_Expectation` names keep their underscores, and helpers need no null guards. Override before the SDK import to narrow or extend it. |
+| `DisablePurviewTestContextRuleSet` | `false` | Set to `true` to make test and shared-testing projects enforce the production API-surface rules as well (the strict style contract applies either way). |
+| `PurviewSharedTestingOutputType` | `Library` | Output type forced on `IsSharedTestingProject` projects. `Library` (default) keeps them helper libraries - with `IsTestProject`/`IsTestingPlatformApplication` cleared - which also keeps their fixtures out of `CA1515`'s Exe-only scope. Set to `Exe` before the SDK import to keep the test packages' executable/test-host shape instead. |
| `TargetFramework` | `net10.0` | Override the default TFM per-project or globally. Defaults to `netstandard2.0` for projects declaring `IsRoslynComponent=true`. |
| `IsRoslynComponent` | `false` | When explicitly `true`, applies source-generator defaults: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, compiler-generated output under the intermediate directory, no dependency file, telemetry exclusion, and package build output. Pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack if the compiler defaults are missing unless `DisableRoslynCompilerDefaultsValidation=true`. Roslyn development dependencies (`Microsoft.CodeAnalysis.*`, `Microsoft.CodeAnalysis.Analyzers`) default to `PrivateAssets="all"`. |
| `IsRoslynComponentOnly` | `true` for Roslyn components | Produces an analyzer-only package: `IncludeBuildOutput=false`, `IncludeSymbols=false`, and the analyzer assembly and portable PDB are packed under `analyzers/dotnet/cs/`. Set to `false` for a dual-role Roslyn component that uses normal library symbol packaging. |
@@ -542,13 +545,26 @@ deliberately not silenceable:
matches the language default (`private` inside a type, `internal` at namespace scope, `public` inside
an interface) is reported by `IDE0040` and must be **removed**. Omit the default modifier instead.
- Private instance fields must be `_camelCase`, never `camelCase` or `PascalCase`: the `_` prefix keeps
- field access unambiguous, so the noisy `this.` qualifier is never needed. Constants and `static
- readonly` fields stay PascalCase, and local constants stay camelCase.
+ field access unambiguous, so the noisy `this.` qualifier is never needed. Private constants and private
+ `static readonly` fields are type-level state and stay PascalCase; non-private constants and
+ `static readonly` fields follow the same PascalCase rule, and local constants stay camelCase.
- `CA1515` (public type in an application/test assembly) ships at **warning**, `CA1852` (seal internal
types) is no longer suppressed for internals exposed through `InternalsVisibleTo`, and `CA1034` is no
- longer disabled for `Extensions/` files.
-- Test projects are executables, so test classes must be non-public (`sealed class MyTests`, which is
- `internal` by default) instead of `CA1515`/`CA1707`/`CA1062` being hidden through `NoWarn`.
+ longer disabled for `Extensions/` files. Project shapes whose public surface is mandated by a
+ framework are exempt by the SDK itself: Aspire hosts and CLI apps keep their nested public options
+ types, and test projects use the test-context rule set below.
+- Test and shared-testing projects get a **test-context rule set** (`PurviewTestContextNoWarn`) that
+ exempts the production API-surface rules (`CA1002`, `CA1012`, `CA1034`, `CA1047`, `CA1050`, `CA1051`,
+ `CA1062`, `CA1064`, `CA1515`, `CA1707`), because test classes and fixtures are legitimately public,
+ test names use `Method_Scenario_Expectation`, helpers expose fields and take fixture parameters
+ without null guards, and abstract test bases have public constructors. Everything stylistic still
+ applies to tests (`IDE0040`, field naming, formatting, the `IDE1006` naming rules). Set
+ `DisablePurviewTestContextRuleSet=true` to enforce the production rules in tests as well, or override
+ `PurviewTestContextNoWarn` with your own semicolon-fenced list before the SDK import.
+- Shared testing projects stay **libraries** even though their test packages request the test-host shape,
+ so their fixtures remain legitimate public API and `CA1515` does not apply to them at all. Use
+ `Exe` to opt back into the
+ package-driven executable.
`ValidatePurviewStylePolicy` runs before every C# compile and fails the build when a repository neuters
the policy:
@@ -557,7 +573,7 @@ the policy:
| -- | -- |
| `PRSGD0006` | `dotnet_style_require_accessibility_modifiers` is overridden with anything other than `omit_if_default`. |
| `PRSGD0007` | A policy rule (`IDE0040`, `CA1515`, `CA1852`, `CA1034`) is downgraded below warning, any policy rule is set to `none`/`silent`, or the `Style` category is disabled in bulk without an explicit `IDE0040` severity. |
-| `PRSGD0008` | `IDE0040`, `CA1515`, `CA1852`, `CA1034`, `CA1012`, `CA1047`, `CA1050`, `CA1051`, `CA1062`, `CA1064`, or `CA1707` is added to `NoWarn`. |
+| `PRSGD0008` | `IDE0040`, `CA1515`, `CA1852`, `CA1034`, `CA1012`, `CA1047`, `CA1050`, `CA1051`, `CA1062`, `CA1064`, or `CA1707` is added to `NoWarn` by the repository. Entries the SDK injects itself (the test-context rule set for test/shared-testing projects and `CA1515` for Aspire hosts and CLI apps) are ignored so the SDK's own context-aware defaults stay usable. |
| `PRSGD0009` | `IDE1006` is hidden, the private instance field rule is downgraded below warning, or the `_` prefix is removed from the field naming style. |
The validator walks the same `.editorconfig` chain as the compiler (nearest file wins, later entries
diff --git a/docs/wiki/Analyzers.md b/docs/wiki/Analyzers.md
index ec43bc9..a46e58f 100644
--- a/docs/wiki/Analyzers.md
+++ b/docs/wiki/Analyzers.md
@@ -21,19 +21,32 @@ The SDK ships the modifier/visibility **and field-naming** policy in `.editorcon
matches the language default (`private` inside a type, `internal` at namespace scope, `public` inside
an interface) is reported by `IDE0040` and must be removed.
- Private instance fields must be `_camelCase` (`_name`, never `name`): the `_` prefix keeps field
- access unambiguous, so the noisy `this.` qualifier is never needed. Constants and `static readonly`
- fields stay PascalCase, and local constants stay camelCase.
+ access unambiguous, so the noisy `this.` qualifier is never needed. Private constants and private
+ `static readonly` fields are type-level state and stay PascalCase; non-private constants and
+ `static readonly` fields follow the same rule, and local constants stay camelCase.
- `CA1515` (public type in an application/test assembly) ships at warning; `CA1852` (seal internal
types) is not suppressed for types exposed through `InternalsVisibleTo`; `CA1034` is not disabled for
- `Extensions/` files; the API-surface rules `CA1062`/`CA1707` are not pre-suppressed for test projects.
-- Test projects are executables, so their test classes must be non-public (`sealed class MyTests`,
- `internal` by default) rather than the rules being hidden through `NoWarn`.
+ `Extensions/` files; the API-surface rules `CA1062`/`CA1707` are not pre-suppressed for repositories.
+ Where the public surface is framework-mandated the SDK exempts itself: Aspire hosts and CLI apps keep
+ their nested public options types (Spectre.Console settings, `[ZodSchema]` resource-kit options), and
+ test projects use the test-context rule set below. Both exemptions are recorded so `PRSGD0008`
+ accepts them while still rejecting repository-authored silencing.
+- Test and shared-testing projects are **context aware**: they enforce the same strict style contract as
+ the rest of the repository (`IDE0040`, field naming, formatting, the `IDE1006` naming rules) but not
+ the production API-surface rules. `PurviewTestContextNoWarn` exempts `CA1002`, `CA1012`, `CA1034`,
+ `CA1047`, `CA1050`, `CA1051`, `CA1062`, `CA1064`, `CA1515` and `CA1707`, because test classes and
+ fixtures are legitimately public, test names use `Method_Scenario_Expectation`, helpers expose fields
+ and take fixture parameters without null guards, and abstract test bases have public constructors.
+ `DisablePurviewTestContextRuleSet=true` opts a repository into the production rules for tests too.
+- Shared testing projects are helper **libraries** (their test packages request the test-host shape, and
+ the SDK reasserts `OutputType=Library` after package props). Their fixtures stay public API, which is
+ also why `CA1515` does not apply to them; `PurviewSharedTestingOutputType=Exe` opts back in.
| Code | Reported when |
| -- | -- |
| `PRSGD0006` | `dotnet_style_require_accessibility_modifiers` is set to anything other than `omit_if_default`. |
| `PRSGD0007` | A policy rule is downgraded below warning (`IDE0040`, `CA1515`, `CA1852`, `CA1034`), any policy rule is set to `none`/`silent`, or the `Style` category is disabled in bulk without an explicit `IDE0040` severity. |
-| `PRSGD0008` | One of the accessibility rules (`IDE0040`, `CA1515`, `CA1852`, `CA1034`, `CA1012`, `CA1047`, `CA1050`, `CA1051`, `CA1062`, `CA1064`, `CA1707`) is added to `NoWarn`. |
+| `PRSGD0008` | One of the accessibility rules (`IDE0040`, `CA1515`, `CA1852`, `CA1034`, `CA1012`, `CA1047`, `CA1050`, `CA1051`, `CA1062`, `CA1064`, `CA1707`) is added to `NoWarn` by the repository. Entries the SDK injects itself (the test-context rule set, `CA1515` for Aspire hosts and CLI apps) are ignored. |
| `PRSGD0009` | `IDE1006` is hidden, the private instance field naming rule is downgraded below warning, or the `_` prefix is removed from the field naming style. |
Opt out with `true`. A stale
diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md
index 335b570..55c9eee 100644
--- a/docs/wiki/Configuration-Reference.md
+++ b/docs/wiki/Configuration-Reference.md
@@ -20,7 +20,10 @@ See [Version Detection](Version-Detection.md) for the full resolution rules.
| -- | -- | -- |
| `NamespacePrefix` | *(required)* | Root namespace prefix, e.g. `Acme`. Results in `Acme.MyProject`. |
| `DisableNamespacePrefixCheck` | `false` | Set to `true` to suppress the build error for missing `NamespacePrefix`. |
-| `DisablePurviewStylePolicyValidation` | `false` | Set to `true` to stop `ValidatePurviewStylePolicy` failing the build (`PRSGD0006`-`PRSGD0009`) when the repository `.editorconfig` overrides `dotnet_style_require_accessibility_modifiers`, hides `IDE0040`/`IDE1006`, disables the Style category in bulk, weakens the `_camelCase` private instance field rule, or adds the accessibility rules to `NoWarn`. See [Style policy](Analyzers.md#style-policy). |
+| `DisablePurviewStylePolicyValidation` | `false` | Set to `true` to stop `ValidatePurviewStylePolicy` failing the build (`PRSGD0006`-`PRSGD0009`) when the repository `.editorconfig` overrides `dotnet_style_require_accessibility_modifiers`, hides `IDE0040`/`IDE1006`, disables the Style category in bulk, weakens the `_camelCase` private instance field rule, or adds the accessibility rules to `NoWarn`. Entries the SDK injects itself (the test-context rule set, `CA1515` for Aspire hosts and CLI apps) are ignored. See [Style policy](Analyzers.md#style-policy). |
+| `PurviewTestContextNoWarn` | `CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707` | Production API-surface rules exempted in test and shared-testing projects (they keep the strict style contract). Override before the SDK import to narrow or extend the list. |
+| `DisablePurviewTestContextRuleSet` | `false` | Set to `true` to make test and shared-testing projects enforce the production API-surface rules as well. |
+| `PurviewSharedTestingOutputType` | `Library` | Output type forced on `IsSharedTestingProject` projects; `Library` also clears `IsTestProject`/`IsTestingPlatformApplication`. Set to `Exe` before the SDK import to keep the test packages' executable/test-host shape. |
| `TargetFramework` | `net10.0` | Override the default TFM per-project or globally. Defaults to `netstandard2.0` for projects declaring `IsRoslynComponent=true`. |
| `IsRoslynComponent` | `false` | When explicitly `true`, applies source-generator defaults: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, compiler-generated output under the intermediate directory, no dependency file, symbol packaging (`IncludeSymbols=false` by default), telemetry exclusion, and package build output. Packable Roslyn components automatically pack the built analyzer assembly and its PDB into `analyzers/dotnet/cs/` (`PurviewPackAnalyzerPdb=true`; set `false` only when symbols are delivered another way — NuGet's `.snupkg` cannot host `analyzers/dotnet/cs` symbols). Pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack if the compiler defaults are missing unless `DisableRoslynCompilerDefaultsValidation=true`. Roslyn development dependencies (`Microsoft.CodeAnalysis.*`, `Microsoft.CodeAnalysis.Analyzers`) default to `PrivateAssets="all"`. |
| `PackProjectReferencedSourceGenerators` | `true` | Automatically packs analyzer `ProjectReference` outputs and their runtime dependencies under `analyzers/dotnet/cs/`. Set to `false` to opt out; set `Pack="false"` on an individual reference to exclude only that generator. |
@@ -154,7 +157,7 @@ read them through `build_property.`:
| `AutoIncludeUsings` | Controls SDK-added global usings. |
| `IsCSharpProject` | True when the project is a `.csproj`. |
| `IsTestProject` | True when project name ends with a supported test suffix. |
-| `IsSharedTestingProject` | True for known shared testing helper project names. |
+| `IsSharedTestingProject` | True for known shared testing helper project names; the project is forced to `OutputType=Library` (see `PurviewSharedTestingOutputType`). |
| `TestingType` | Detected test category suffix from project name. |
| `TargetProjectName` | Inferred target project name for test projects. |
| `IsContainerProject` | True when Dockerfile markers indicate container defaults. |
diff --git a/docs/wiki/Project-Type-Detection.md b/docs/wiki/Project-Type-Detection.md
index 99da0ac..08351de 100644
--- a/docs/wiki/Project-Type-Detection.md
+++ b/docs/wiki/Project-Type-Detection.md
@@ -31,6 +31,10 @@ targets.
### Test projects (`IsTestProject=true`)
- `OutputType=Exe` when the testing framework is not `None`.
+- A **test-context rule set** (`PurviewTestContextNoWarn`): the production API-surface rules
+ (`CA1002`, `CA1012`, `CA1034`, `CA1047`, `CA1050`, `CA1051`, `CA1062`, `CA1064`, `CA1515`, `CA1707`)
+ are exempt, while the strict style contract (`IDE0040`, field naming, formatting, `IDE1006` naming
+ rules) still applies. `DisablePurviewTestContextRuleSet=true` enforces the production rules instead.
- `CollectCoverage=true` with coverage exclusions for framework and mocking packages.
- `IsPackable=false`, `IsPublishable=false`, `MaxCpuCount=0`.
- Disabled native instrumentation by default.
@@ -44,6 +48,10 @@ targets.
- Test package references, but not the test runner or coverage settings.
- A `[Skip]` attribute (TUnit) so the shared assembly is never executed directly.
- `TUnit.Core` instead of the full `TUnit` package.
+- `OutputType=Library` with `IsTestProject`/`IsTestingPlatformApplication` cleared, reasserted after the
+ package props (which otherwise flip the project into an executable test host). The fixtures are consumed
+ by the test assemblies, so staying a library avoids `CA1515` (which only targets executables). Set
+ `PurviewSharedTestingOutputType=Exe` before the SDK import to keep the package-driven test-host shape.
### Shared projects (`IsSharedProject=true`)
diff --git a/docs/wiki/Testing-Wiring.md b/docs/wiki/Testing-Wiring.md
index 692e384..ede627d 100644
--- a/docs/wiki/Testing-Wiring.md
+++ b/docs/wiki/Testing-Wiring.md
@@ -40,6 +40,12 @@ added.
All test and shared-testing projects get:
+- A **test-context rule set** (`PurviewTestContextNoWarn`, overridable before the SDK import): the
+ production API-surface rules (`CA1002`, `CA1012`, `CA1034`, `CA1047`, `CA1050`, `CA1051`, `CA1062`,
+ `CA1064`, `CA1515`, `CA1707`) are exempt, because test classes/fixtures are public, test names use
+ `Method_Scenario_Expectation`, and helpers take unvalidated fixture parameters. Style rules
+ (`IDE0040`, field naming, formatting, `IDE1006` naming) are still enforced.
+ `DisablePurviewTestContextRuleSet=true` enforces the production rules too.
- `CollectCoverage=true` with coverage exclusions for `[NSubstitute*]`, `[TUnit.*]`, `[xunit.*]`,
`[Microsoft.Testing.*]`, `[Microsoft.NET.Test*]`, and `[Bogus*]`.
- `ExcludeByAttribute` for `ExcludeFromCodeCoverageAttribute`.
@@ -68,6 +74,11 @@ Projects named `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedT
- They get the test package references (`TUnit.Core` rather than the full `TUnit`) but **not** the
test runner or coverage settings.
- A `[Skip]` attribute (TUnit) keeps the shared assembly from being executed directly.
+- They are forced to `OutputType=Library` (with `IsTestProject`/`IsTestingPlatformApplication` cleared)
+ after package props run, because test packages such as `TUnit.Engine` otherwise turn them into
+ executable test hosts - which would make `CA1515` demand that their fixtures become internal while the
+ test assemblies still consume them. Set `PurviewSharedTestingOutputType=Exe` before the SDK import to
+ opt back into that shape.
- Test projects automatically reference the sibling shared testing project via
`../SharedTesting*/SharedTesting*.csproj`.
diff --git a/package.json b/package.json
index 2eba7cd..bf9e983 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "purview-build-sdk",
- "version": "1.0.1.0",
+ "version": "1.0.2.0",
"homepage": "https://purview.dev/projects/build-sdk/",
"bugs": {
"url": "https://github.com/purview-dev/build-sdk/issues"
diff --git a/src/src/BuildSdk/Sdk/.agents/skills/sdk-configuration-reference/SKILL.md b/src/src/BuildSdk/Sdk/.agents/skills/sdk-configuration-reference/SKILL.md
index d9ef035..bcc5a55 100644
--- a/src/src/BuildSdk/Sdk/.agents/skills/sdk-configuration-reference/SKILL.md
+++ b/src/src/BuildSdk/Sdk/.agents/skills/sdk-configuration-reference/SKILL.md
@@ -43,12 +43,15 @@ These are the most important configurable properties exposed by the SDK:
- `NamespacePrefix` — required unless `DisableNamespacePrefixCheck=true`
- `DisableNamespacePrefixCheck` — default `false`
-- `DisablePurviewStylePolicyValidation` — default `false`; set to `true` to stop the build failing (`PRSGD0006`-`PRSGD0009`) when the repository `.editorconfig` overrides the modifier policy (`dotnet_style_require_accessibility_modifiers` other than `omit_if_default`), hides `IDE0040`/`IDE1006`, disables the Style category in bulk, weakens the `_camelCase` private instance field naming rule, or adds the accessibility rules to `NoWarn`
+- `DisablePurviewStylePolicyValidation` — default `false`; set to `true` to stop the build failing (`PRSGD0006`-`PRSGD0009`) when the repository `.editorconfig` overrides the modifier policy (`dotnet_style_require_accessibility_modifiers` other than `omit_if_default`), hides `IDE0040`/`IDE1006`, disables the Style category in bulk, weakens the `_camelCase` private instance field naming rule, or adds the accessibility rules to `NoWarn`. Entries the SDK injects itself (the test-context rule set for test/shared-testing projects, `CA1515` for Aspire hosts and CLI apps) are ignored
- `TargetFramework` — defaults to `net10.0` when neither `TargetFramework` nor `TargetFrameworks` is set; projects explicitly declaring `IsRoslynComponent=true` default to `netstandard2.0`
- `IsRoslynComponent` — when explicitly `true`, applies source-generator defaults: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, compiler-generated output under the framework-specific intermediate directory, telemetry exclusion, and `PrivateAssets=all` applied to `Microsoft.CodeAnalysis.*` / `Microsoft.CodeAnalysis.Analyzers` references. Packable Roslyn components automatically pack the built analyzer assembly and PDB into `analyzers/dotnet/cs/`; a pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack if the compiler defaults are missing unless `DisableRoslynCompilerDefaultsValidation=true`
- `IsRoslynComponentOnly` — defaults to `true` for Roslyn components and creates an analyzer-only package: it sets `IncludeBuildOutput=false`, `IncludeSymbols=false`, and packages the portable PDB alongside the analyzer under `analyzers/dotnet/cs/`. Set it to `false` for a dual-role Roslyn component that uses normal library symbol packaging.
- `PackProjectReferencedSourceGenerators` — default `true`; packable projects automatically include analyzer `ProjectReference` outputs and runtime dependencies under `analyzers/dotnet/cs/`. Set it to `false` globally or use `Pack="false"` on one analyzer reference to opt out.
- `EnableAssemblyNameGeneration` — default `true`; when `true`, `AssemblyName` and default `PackageId` follow the fully evaluated `RootNamespace` (or the full logical project name when suffix-stripping removed a segment, e.g. `Shared`/`ServiceDefaults`). Set `false` before the SDK import to use the standard project-name behaviour
+- `PurviewSharedTestingOutputType` — default `Library`; forced onto `IsSharedTestingProject` projects (with `IsTestProject`/`IsTestingPlatformApplication` cleared), because the test packages otherwise flip them into an executable test host. Set it to `Exe` before the SDK import to keep that package-driven shape
+- `PurviewTestContextNoWarn` — default `CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707`; the production API-surface rules exempted in test and shared-testing projects. Test projects keep the strict style contract (`IDE0040`, field naming, formatting, `IDE1006`), but are context aware: public test classes/fixtures, `Method_Scenario_Expectation` names, exposed fields and unvalidated helper parameters are allowed. Override before the SDK import to narrow or extend the set
+- `DisablePurviewTestContextRuleSet` — default `false`; set to `true` to make test and shared-testing projects enforce the production API-surface rules as well
- `DisableProjectFileNamingConventionCheck` — default `false`; disables the directory-name/file-name match validation
- `DisableGenerateAssemblyInfoClass` — default `false`; disables generated `AssemblyInfo`
- `DisableAutoInternalsVisibleTo` — default `false`; disables automatic friend assembly generation
@@ -148,6 +151,9 @@ When explaining SDK behavior, prefer these derived values over guessing:
- `PurviewNamespacePrefix`
- `PurviewProjectShortName`
- `PurviewTestType`
+- `PurviewSharedTestingOutputType` (default `Library`; `Exe` keeps the test packages' executable/test-host shape)
+- `PurviewTestContextNoWarn` (production API-surface rules exempted in test/shared-testing projects)
+- `PurviewPolicyExemptNoWarn` (SDK-injected `NoWarn` entries that `ValidatePurviewStylePolicy` accepts)
- `RootNamespace`
- `AssemblyName`
- `PackageVersion`
diff --git a/src/src/BuildSdk/Sdk/Props/Defaults.props b/src/src/BuildSdk/Sdk/Props/Defaults.props
index 18f782f..50330f6 100644
--- a/src/src/BuildSdk/Sdk/Props/Defaults.props
+++ b/src/src/BuildSdk/Sdk/Props/Defaults.props
@@ -35,6 +35,8 @@
>false
false
+ false
false
false
@@ -123,6 +125,11 @@
+ ;CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707;
+
+ Library
false
true
Exe
+
+ $(NoWarn);CA1515;
+ $(PurviewPolicyExemptNoWarn);CA1515;
Exe
+
+ $(NoWarn);CA1515;
+ $(PurviewPolicyExemptNoWarn);CA1515;
@@ -822,6 +835,18 @@
$(NoWarn);CA1822;
$(NoWarn);CS1591;
+ $(NoWarn);$(PurviewTestContextNoWarn)
+ $(PurviewPolicyExemptNoWarn);$(PurviewTestContextNoWarn)
false
false
false
diff --git a/src/src/BuildSdk/Sdk/Sdk.targets b/src/src/BuildSdk/Sdk/Sdk.targets
index 4493e7b..5b4b83a 100644
--- a/src/src/BuildSdk/Sdk/Sdk.targets
+++ b/src/src/BuildSdk/Sdk/Sdk.targets
@@ -8,6 +8,28 @@
>$(TargetFramework)
+
+
+ $(PurviewSharedTestingOutputType)
+ false
+ false
+
+
@@ -1505,7 +1527,9 @@
disabling the rules, disabling the Style category in bulk, or adding the rules to NoWarn:
everything the policy reports is fixed, not suppressed (a declared modifier that matches the
language default is removed, a field is renamed to '_name', and so on).
- Opt out with DisablePurviewStylePolicyValidation=true.
+ Opt out with DisablePurviewStylePolicyValidation=true. Entries the SDK injects itself for a
+ project shape (currently CA1515 for Aspire hosts and CLI apps, whose nested public options
+ types are framework-mandated) are passed in through AllowedNoWarn and do not fail PRSGD0008.
===================================================================== -->
+
@@ -1545,12 +1570,27 @@
"none", "silent"
};
+ // Entries the SDK itself injects for a project shape (for example CA1515 for Aspire
+ // hosts and CLI apps, where nested public options types are mandated by the
+ // framework) are exempt from the 'no policy rule in NoWarn' rule. Repository-authored
+ // entries still fail: the allowed list only contains what the SDK appended.
+ var sdkInjectedNoWarn = new HashSet(StringComparer.OrdinalIgnoreCase);
+ if (!string.IsNullOrEmpty(AllowedNoWarn))
+ {
+ foreach (var raw in AllowedNoWarn.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ var injectedId = raw.Trim();
+ if (injectedId.Length > 0)
+ sdkInjectedNoWarn.Add(injectedId);
+ }
+ }
+
if (!string.IsNullOrEmpty(NoWarn))
{
foreach (var raw in NoWarn.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries))
{
var id = raw.Trim();
- if (id.Length == 0)
+ if (id.Length == 0 || sdkInjectedNoWarn.Contains(id))
continue;
foreach (var rule in policyRules)
@@ -1769,7 +1809,11 @@
Condition="'$(IsCSharpProject)' == 'true' AND '$(DisablePurviewStylePolicyValidation)' != 'true' AND '$(DesignTimeBuild)' != 'true'"
Label="Ensures the modifier/visibility and field-naming policy is not silenced (PRSGD0006-PRSGD0009)."
>
-
+
+/// Aspire hosts and CLI/console apps are executables whose nested public options types are mandated
+/// by the framework (Spectre.Console command settings, Aspire resource-kit options bound by
+/// [ZodSchema], ...). CA1515 is therefore exempt for those shapes, the exemption is recorded so
+/// ValidatePurviewStylePolicy (PRSGD0008) can tell it apart from a repository-authored NoWarn entry,
+/// and the rest of the accessibility policy stays enforced.
+///
+sealed class AccessibilityPolicyExemptionTests
+{
+ // The harness only declares SourceLink centrally, so the automatically injected telemetry package
+ // references are removed like the other harness-based build tests do.
+ const string RemoveTelemetryItems = """
+
+
+ """;
+
+ ///
+ /// The Aspire host is authored as a bare project (no Sdk attribute) so the Aspire SDK is not
+ /// loaded in tests; the marker inside a comment still classifies the project (see
+ /// ).
+ ///
+ [Test]
+ public async Task AspireHostProject_ExemptsCa1515_ButKeepsTheRestOfTheAccessibilityPolicy(
+ CancellationToken cancellationToken
+ )
+ {
+ var content = $"""
+
+
+
+ Test
+ true
+ net10.0
+
+
+
+ """;
+
+ using var h = await ProjectHarness.CreateWithContentAsync(
+ "Acme.AppHost",
+ content,
+ cancellationToken: cancellationToken
+ );
+
+ var props = await h.GetPropertiesAsync(cancellationToken, "NoWarn", "PurviewPolicyExemptNoWarn");
+
+ await Assert.That(props["NoWarn"]).Contains("CA1515");
+ await Assert.That(props["PurviewPolicyExemptNoWarn"]).Contains("CA1515");
+
+ // The modifier policy and the api-surface rules stay enforced: unlike framework-mandated
+ // nested options types, they are fixable in host code.
+ await Assert.That(props["NoWarn"]).DoesNotContain("CA1062");
+ await Assert.That(props["NoWarn"]).DoesNotContain("CA1707");
+ await Assert.That(props["NoWarn"]).DoesNotContain("IDE0040");
+ }
+
+ ///
+ /// The SDK-injected exemption must not be mistaken for a repository-authored NoWarn entry:
+ /// PRSGD0008 fails the build when a repository silences a policy rule itself, so a CLI project
+ /// has to build cleanly even though its NoWarn contains CA1515.
+ ///
+ [Test]
+ public async Task CliProject_WithPublicType_BuildsWithoutPrsgd0008(CancellationToken cancellationToken)
+ {
+ using var h = await ProjectHarness.CreateAsync(
+ "MyCLI",
+ extraProps: "Exe",
+ extraItems: RemoveTelemetryItems,
+ cancellationToken: cancellationToken
+ );
+
+ const string source = """
+ namespace Test.MyCLI;
+
+ public sealed class Program
+ {
+ public static void Main() { }
+ }
+ """;
+
+ await File.WriteAllTextAsync(Path.Combine(h.ProjectDirectory, "Program.cs"), source, cancellationToken);
+
+ var (success, output, errors) = await h.BuildAsync(restore: true, cancellationToken: cancellationToken);
+
+ await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors));
+ await Assert.That(output + errors).DoesNotContain("PRSGD0008");
+
+ // CA1515 is exempt for CLI apps, so the public Program type is not reported here either.
+ await Assert.That(output + errors).DoesNotContain("CA1515");
+ }
+}
diff --git a/src/tests/BuildSdk.IntegrationTests/FieldNamingPolicyTests.cs b/src/tests/BuildSdk.IntegrationTests/FieldNamingPolicyTests.cs
index d037316..21899e4 100644
--- a/src/tests/BuildSdk.IntegrationTests/FieldNamingPolicyTests.cs
+++ b/src/tests/BuildSdk.IntegrationTests/FieldNamingPolicyTests.cs
@@ -26,7 +26,7 @@ public async Task PrivateInstanceField_WithoutUnderscorePrefix_IsReported(Cancel
cancellationToken: cancellationToken
);
await ApplyShippedStylePolicyAsync(h, cancellationToken);
- await WriteProbeAsync(h, "counter", cancellationToken);
+ await WriteProbeAsync(h, "int counter", "counter", cancellationToken);
var (success, output, errors) = await h.BuildAsync(restore: true, verbose: true, cancellationToken);
@@ -44,7 +44,71 @@ public async Task PrivateInstanceField_WithUnderscorePrefix_IsNotReported(Cancel
cancellationToken: cancellationToken
);
await ApplyShippedStylePolicyAsync(h, cancellationToken);
- await WriteProbeAsync(h, "_counter", cancellationToken);
+ await WriteProbeAsync(h, "int _counter", "_counter", cancellationToken);
+
+ var (success, output, errors) = await h.BuildAsync(restore: true, verbose: true, cancellationToken);
+
+ await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors));
+ await Assert.That(output + errors).DoesNotContain("IDE1006");
+ }
+
+ ///
+ /// Private static fields are type-level state and stay PascalCase, so a '_'-prefixed one is
+ /// reported by the private static field rule (not the '_camelCase' instance rule).
+ ///
+ [Test]
+ public async Task PrivateStaticReadonlyField_WithUnderscorePrefix_IsReported(CancellationToken cancellationToken)
+ {
+ using var h = await ProjectHarness.CreateAsync(
+ "MyApp",
+ extraItems: RemoveTelemetryItems,
+ cancellationToken: cancellationToken
+ );
+ await ApplyShippedStylePolicyAsync(h, cancellationToken);
+ await WriteProbeAsync(h, "static readonly int _counter = 1", "_counter", cancellationToken);
+
+ var (success, output, errors) = await h.BuildAsync(restore: true, verbose: true, cancellationToken);
+
+ await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors));
+ await Assert.That(output + errors).Contains("IDE1006");
+ await Assert.That(output + errors).Contains("Prefix '_' is not expected");
+ }
+
+ ///
+ /// A private static field must be covered by exactly one naming rule: renaming it to PascalCase
+ /// must not then trip the '_camelCase' private instance rule.
+ ///
+ [Test]
+ public async Task PrivateStaticReadonlyField_PascalCase_IsNotReported(CancellationToken cancellationToken)
+ {
+ using var h = await ProjectHarness.CreateAsync(
+ "MyApp",
+ extraItems: RemoveTelemetryItems,
+ cancellationToken: cancellationToken
+ );
+ await ApplyShippedStylePolicyAsync(h, cancellationToken);
+ await WriteProbeAsync(h, "static readonly int Counter = 1", "Counter", cancellationToken);
+
+ var (success, output, errors) = await h.BuildAsync(restore: true, verbose: true, cancellationToken);
+
+ await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors));
+ await Assert.That(output + errors).DoesNotContain("IDE1006");
+ }
+
+ ///
+ /// Private constants are static fields too, so they belong to the private static field rule
+ /// (PascalCase) rather than the StyleCop constant rule that only covers non-private fields.
+ ///
+ [Test]
+ public async Task PrivateConstField_PascalCase_IsNotReported(CancellationToken cancellationToken)
+ {
+ using var h = await ProjectHarness.CreateAsync(
+ "MyApp",
+ extraItems: RemoveTelemetryItems,
+ cancellationToken: cancellationToken
+ );
+ await ApplyShippedStylePolicyAsync(h, cancellationToken);
+ await WriteProbeAsync(h, "const int Counter = 1", "Counter", cancellationToken);
var (success, output, errors) = await h.BuildAsync(restore: true, verbose: true, cancellationToken);
@@ -68,14 +132,23 @@ await File.ReadAllTextAsync(shippedEditorConfig, cancellationToken),
);
}
- static async Task WriteProbeAsync(ProjectHarness harness, string fieldName, CancellationToken cancellationToken)
+ ///
+ /// Writes a probe type containing plus a member that reads
+ /// , so the naming rules see a used field of the requested shape.
+ ///
+ static async Task WriteProbeAsync(
+ ProjectHarness harness,
+ string fieldDeclaration,
+ string fieldName,
+ CancellationToken cancellationToken
+ )
{
var source = $$"""
namespace Test.MyApp;
sealed class Probe
{
- int {{fieldName}};
+ {{fieldDeclaration}};
public int Read() => {{fieldName}};
}
diff --git a/src/tests/BuildSdk.IntegrationTests/IsCLIProjectTests.cs b/src/tests/BuildSdk.IntegrationTests/IsCLIProjectTests.cs
index de8c359..2140472 100644
--- a/src/tests/BuildSdk.IntegrationTests/IsCLIProjectTests.cs
+++ b/src/tests/BuildSdk.IntegrationTests/IsCLIProjectTests.cs
@@ -109,7 +109,9 @@ CancellationToken cancellationToken
}
[Test]
- public async Task ProjectCLI_DoesNotSilenceAccessibilityRules(CancellationToken cancellationToken)
+ public async Task ProjectCLI_ExemptsCa1515ForFrameworkMandatedNestedOptionsTypes(
+ CancellationToken cancellationToken
+ )
{
var (harness, _) = await CreateProjectAndEvaluateAsync("MyCLI", cancellationToken);
@@ -117,10 +119,13 @@ public async Task ProjectCLI_DoesNotSilenceAccessibilityRules(CancellationToken
{
var properties = await harness.GetPropertiesAsync(cancellationToken, "NoWarn");
- // CLI applications are executables, so public types are flagged by CA1515; the fix is to
- // make them internal, never to silence the rule through NoWarn.
+ // CLI applications are executables, but they routinely declare nested public options types
+ // (Spectre.Console command settings) that have no reasonable internal fix, so CA1515 is
+ // exempt by SDK policy. The modifier and api-surface rules stay enforced.
await Assert.That(properties).ContainsKey("NoWarn");
- await Assert.That(properties["NoWarn"]).DoesNotContain("CA1515");
+ await Assert.That(properties["NoWarn"]).Contains("CA1515");
+ await Assert.That(properties["NoWarn"]).DoesNotContain("CA1062");
+ await Assert.That(properties["NoWarn"]).DoesNotContain("CA1707");
await Assert.That(properties["NoWarn"]).DoesNotContain("IDE0040");
}
}
diff --git a/src/tests/BuildSdk.IntegrationTests/TestContextRuleSetTests.cs b/src/tests/BuildSdk.IntegrationTests/TestContextRuleSetTests.cs
new file mode 100644
index 0000000..02d71ef
--- /dev/null
+++ b/src/tests/BuildSdk.IntegrationTests/TestContextRuleSetTests.cs
@@ -0,0 +1,177 @@
+using Purview.BuildSdk.Harness;
+using Purview.BuildSdk.Infra;
+
+namespace Purview.BuildSdk;
+
+///
+/// Test and shared-testing projects enforce the same strict structure and style as the rest of the
+/// repository (modifier policy, field naming, formatting, naming rules) but not the production
+/// API-surface rules: test classes and fixtures are legitimately public, test names use
+/// 'Method_Scenario_Expectation', helpers expose fields, return generic lists and take fixture
+/// parameters without null guards, and abstract test bases have public constructors.
+///
+sealed class TestContextRuleSetTests
+{
+ /// The context-aware rule set injected into every test and shared-testing project.
+ static readonly string[] TestContextRules =
+ [
+ "CA1002",
+ "CA1012",
+ "CA1034",
+ "CA1047",
+ "CA1050",
+ "CA1051",
+ "CA1062",
+ "CA1064",
+ "CA1515",
+ "CA1707",
+ ];
+
+ ///
+ /// Idiomatic test code: public types in an executable test assembly, a visible field, a public
+ /// helper returning a generic list without validating its argument, an underscore-separated test
+ /// name, and a nested public support type. Every one of those violates a production rule.
+ ///
+ const string TestIdiomsSource = """
+ namespace Test.MyApp;
+
+ public sealed class OrderServiceTests
+ {
+ public string Name = "order";
+
+ public List NamesOf(IEnumerable values) => [.. values];
+
+ public void GivenTenant_WhenCreated_ExpectsIdentifier() { }
+
+ public sealed class NestedSupport
+ {
+ public string Description { get; set; } = "nested";
+ }
+
+ public static void Main() { }
+ }
+ """;
+
+ [Test]
+ public async Task TestProject_AppliesTheTestContextRuleSet(CancellationToken cancellationToken)
+ {
+ using var h = await ProjectHarness.CreateAsync("MyApp.UnitTests", cancellationToken: cancellationToken);
+
+ var props = await h.GetPropertiesAsync(cancellationToken, "NoWarn", "PurviewPolicyExemptNoWarn");
+
+ foreach (var rule in TestContextRules)
+ {
+ await Assert.That(props["NoWarn"]).Contains(rule);
+ await Assert.That(props["PurviewPolicyExemptNoWarn"]).Contains(rule);
+ }
+
+ // The style contract still applies: accessibility modifiers, field naming and the rest are
+ // never silenced for test projects.
+ await Assert.That(props["NoWarn"]).DoesNotContain("IDE0040");
+ await Assert.That(props["NoWarn"]).DoesNotContain("IDE1006");
+ }
+
+ [Test]
+ public async Task SharedTestingProject_AppliesTheTestContextRuleSet(CancellationToken cancellationToken)
+ {
+ using var h = await ProjectHarness.CreateAsync("SharedTestingFramework", cancellationToken: cancellationToken);
+
+ var props = await h.GetPropertiesAsync(cancellationToken, "NoWarn", "PurviewPolicyExemptNoWarn");
+
+ foreach (var rule in TestContextRules)
+ await Assert.That(props["NoWarn"]).Contains(rule);
+
+ await Assert.That(props["NoWarn"]).DoesNotContain("IDE0040");
+ }
+
+ [Test]
+ public async Task TestProject_CanOptIntoTheProductionRuleSet(CancellationToken cancellationToken)
+ {
+ using var h = await ProjectHarness.CreateAsync(
+ "MyApp.UnitTests",
+ preImportProps: "true",
+ cancellationToken: cancellationToken
+ );
+
+ var props = await h.GetPropertiesAsync(cancellationToken, "NoWarn", "PurviewPolicyExemptNoWarn");
+
+ foreach (var rule in TestContextRules)
+ await Assert.That(props["NoWarn"]).DoesNotContain(rule);
+
+ await Assert.That(props["PurviewPolicyExemptNoWarn"]).DoesNotContain("CA1707");
+ }
+
+ [Test]
+ public async Task TestProject_WithTestIdioms_BuildsCleanly(CancellationToken cancellationToken)
+ {
+ using var h = await CreateIdiomaticTestProjectAsync(disableTestContextRuleSet: false, cancellationToken);
+ var (success, output, errors) = await h.BuildAsync(restore: true, cancellationToken: cancellationToken);
+
+ await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors));
+
+ foreach (var rule in TestContextRules)
+ await Assert.That(output + errors).DoesNotContain(rule);
+
+ // The SDK-injected rule set must not be mistaken for a repository-authored NoWarn entry.
+ await Assert.That(output + errors).DoesNotContain("PRSGD0008");
+ }
+
+ [Test]
+ public async Task TestProject_WithTestIdioms_ReportsViolationsWhenOptedOut(CancellationToken cancellationToken)
+ {
+ using var h = await CreateIdiomaticTestProjectAsync(disableTestContextRuleSet: true, cancellationToken);
+ var (success, output, errors) = await h.BuildAsync(restore: true, cancellationToken: cancellationToken);
+
+ // Proves the exemptions above are what keep idiomatic test code buildable.
+ await Assert.That(success).IsFalse();
+ await Assert.That(output + errors).Contains("CA1707");
+ }
+
+ ///
+ /// A test project needs no test packages for this probe: TestingFramework must be cleared
+ /// before the SDK import (the SDK resolves the test packages while Sdk.props runs), and the
+ /// name suffix alone still classifies the project as a test project, so the test-context rule set
+ /// applies. The explicit OutputType supplies the executable assembly CA1515 targets.
+ ///
+ static async Task CreateIdiomaticTestProjectAsync(
+ bool disableTestContextRuleSet,
+ CancellationToken cancellationToken
+ )
+ {
+ var preImportProps =
+ "NoneNone"
+ + "NoneExe"
+ + "true"
+ + (
+ disableTestContextRuleSet
+ ? "true"
+ : ""
+ );
+
+ var harness = await ProjectHarness.CreateAsync(
+ "MyApp.UnitTests",
+ preImportProps: preImportProps,
+ cancellationToken: cancellationToken
+ );
+
+ await File.WriteAllTextAsync(
+ Path.Combine(harness.ProjectDirectory, "OrderServiceTests.cs"),
+ TestIdiomsSource,
+ cancellationToken
+ );
+
+ // The SDK bootstraps a physical .editorconfig during the first build, which is later than the
+ // compiler's configuration discovery for that same build. A real repository has the file in
+ // place before it builds, so place the shipped file first to exercise the real rule severities.
+ await File.WriteAllTextAsync(
+ Path.Combine(harness.SolutionDirectory, ".editorconfig"),
+ await File.ReadAllTextAsync(
+ await harness.GetPropertyAsync("EditorConfigFilePath", cancellationToken),
+ cancellationToken
+ ),
+ cancellationToken
+ );
+
+ return harness;
+ }
+}
diff --git a/src/tests/BuildSdk.IntegrationTests/TestWiringTests.cs b/src/tests/BuildSdk.IntegrationTests/TestWiringTests.cs
index 31a00f0..7e91e4d 100644
--- a/src/tests/BuildSdk.IntegrationTests/TestWiringTests.cs
+++ b/src/tests/BuildSdk.IntegrationTests/TestWiringTests.cs
@@ -25,21 +25,6 @@ public async Task TestProject_DefaultFrameworks_AreTUnitTUnitMocksAndBogus(Cance
await Assert.That(eval.Properties["TestDataFramework"]).IsEqualTo("Bogus");
}
- [Test]
- public async Task TestProject_DoesNotSilenceAccessibilityRules(CancellationToken cancellationToken)
- {
- using var h = await ProjectHarness.CreateAsync("MyApp.UnitTests", cancellationToken: cancellationToken);
-
- var noWarn = await h.GetPropertyAsync("NoWarn", cancellationToken);
-
- // Test projects are executables, so the accessibility rules are reported for them; test
- // classes must be non-public instead of the rules being suppressed through NoWarn.
- await Assert.That(noWarn).DoesNotContain("CA1515");
- await Assert.That(noWarn).DoesNotContain("CA1062");
- await Assert.That(noWarn).DoesNotContain("CA1707");
- await Assert.That(noWarn).DoesNotContain("IDE0040");
- }
-
[Test]
public async Task TestProject_Xunit_WhenOptedIn(CancellationToken cancellationToken)
{
@@ -112,4 +97,64 @@ public async Task SharedTestingProject_IsNotATestProject(CancellationToken cance
await Assert.That(props["IsSharedTestingProject"]).IsEqualTo("true");
await Assert.That(props["IsTestProject"]).IsEqualTo("false");
}
+
+ [Test]
+ public async Task SharedTestingProject_StaysALibrary_WhenTestPackagesRequestATestExecutable(
+ CancellationToken cancellationToken
+ )
+ {
+ // TUnit's engine props (pulled in transitively by TUnit/TUnit.Aspire) set these from the
+ // project's own package imports, i.e. after Sdk.props has already run. A shared testing
+ // project is a helper library - that is what keeps its fixtures out of CA1515's Exe-only
+ // scope - so Sdk.targets (imported last) reasserts the documented shape.
+ using var h = await ProjectHarness.CreateAsync(
+ "SharedTestingFramework",
+ extraProps: """
+ true
+ true
+ Exe
+ """,
+ cancellationToken: cancellationToken
+ );
+
+ var props = await h.GetPropertiesAsync(
+ cancellationToken,
+ "OutputType",
+ "IsTestProject",
+ "IsTestingPlatformApplication"
+ );
+
+ await Assert.That(props["OutputType"]).IsEqualTo("Library");
+ await Assert.That(props["IsTestProject"]).IsEqualTo("false");
+ await Assert.That(props["IsTestingPlatformApplication"]).IsEqualTo("false");
+ }
+
+ [Test]
+ public async Task SharedTestingProject_CanOptIntoATestHostExecutable(CancellationToken cancellationToken)
+ {
+ // Opting into 'Exe' means "keep the package-driven test-host shape": the SDK stops forcing
+ // Library and leaves IsTestProject/IsTestingPlatformApplication exactly as the test packages
+ // set them.
+ using var h = await ProjectHarness.CreateAsync(
+ "SharedTestingFramework",
+ preImportProps: "Exe",
+ extraProps: """
+ true
+ true
+ Exe
+ """,
+ cancellationToken: cancellationToken
+ );
+
+ var props = await h.GetPropertiesAsync(
+ cancellationToken,
+ "OutputType",
+ "IsTestProject",
+ "IsTestingPlatformApplication"
+ );
+
+ await Assert.That(props["OutputType"]).IsEqualTo("Exe");
+ await Assert.That(props["IsTestProject"]).IsEqualTo("true");
+ await Assert.That(props["IsTestingPlatformApplication"]).IsEqualTo("true");
+ }
}