From 92a738ba826b72bdde221e09b543d992f8b34b47 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 30 Sep 2026 16:11:14 +0100 Subject: [PATCH] fix: editorconfig values missed the test projects --- .editorconfig | 11 +- README.md | 28 ++- docs/wiki/Analyzers.md | 25 ++- docs/wiki/Configuration-Reference.md | 7 +- docs/wiki/Project-Type-Detection.md | 8 + docs/wiki/Testing-Wiring.md | 11 ++ package.json | 2 +- .../sdk-configuration-reference/SKILL.md | 8 +- src/src/BuildSdk/Sdk/Props/Defaults.props | 7 + src/src/BuildSdk/Sdk/Sdk.props | 29 +++ src/src/BuildSdk/Sdk/Sdk.targets | 50 ++++- .../AccessibilityPolicyExemptionTests.cs | 98 ++++++++++ .../FieldNamingPolicyTests.cs | 81 +++++++- .../IsCLIProjectTests.cs | 13 +- .../TestContextRuleSetTests.cs | 177 ++++++++++++++++++ .../TestWiringTests.cs | 75 ++++++-- 16 files changed, 584 insertions(+), 46 deletions(-) create mode 100644 src/tests/BuildSdk.IntegrationTests/AccessibilityPolicyExemptionTests.cs create mode 100644 src/tests/BuildSdk.IntegrationTests/TestContextRuleSetTests.cs 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"); + } }