Retire invalid filter-syntax direct eval (#1050)

* Retire invalid filter syntax eval

Remove the direct identical-arm evaluation and document consumer-level coverage.

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

* Align reference skill eval guidance

Remove stale filter-syntax direct-eval guidance called out in review.

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

* Report reference skill coverage as N/A

Avoid treating model-disabled skills without direct evals as uncovered.

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

Copilot-Session: ea380edb-63a9-40dc-9162-938c4d7754dd

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ea380edb-63a9-40dc-9162-938c4d7754dd
This commit is contained in:
Amaury Levé
2026-08-25 15:58:18 +02:00
committed by GitHub
parent 8fb17964bc
commit ba4f404911
19 changed files with 93 additions and 421 deletions
+5 -7
View File
@@ -52,12 +52,10 @@ floor therefore applies to **skill** evals only. Author agent evals for the
scenario coverage and the deterministic graders, and run them as described in Step 10.
**Be careful with a skill that sets `disable-model-invocation: true`.** The model cannot invoke it,
so any eval graded on the skill self-activating compares two identical arms and returns judge noise.
The honest coverage for such skills is dependency-level — through the evals of the skills that load
them, and through the plugin arm. Two here take the other route and grade the *answer* rather than
activation: `tests/dotnet-test/filter-syntax/eval.yaml` and
`tests/dotnet-test/platform-detection/eval.yaml`. Whether that produces a measurable gap for a skill
the model cannot invoke is still unconfirmed, so read a real verdict before copying the pattern.
so the skill is absent from the model-facing skilled arm and any direct eval compares two identical
arms. Answer-content graders do not create a difference between those arms. The honest coverage for
such skills is dependency-level — through the outcome evals of the skills that load them, and through
the plugin arm.
### Step 2: Write the spec skeleton
@@ -342,7 +340,7 @@ For the official run, submit a PR review containing `/evaluate` so it binds to t
| Timeout too short for code generation | Use ~360s; empty output fails every grader |
| Duplicate YAML key left behind by an edit | It overwrites the next stimulus field by field — delete the stray block |
| Duplicate stimulus names | Vally uses names as comparison identity — give every stimulus a stable, unique name |
| Direct activation-graded eval for a `disable-model-invocation: true` skill | Cover it through a consumer skill, or grade the answer content as `filter-syntax` does |
| Direct eval for a `disable-model-invocation: true` skill | Remove it and cover the reference through consumer outcomes |
| Agent eval sized for the stimulus floor | `agent.*` evals get no verdict; size them for scenario coverage instead |
| Agent eval "run" with `./eng/run-skill-evals.sh` | The glob drops it — use a widened `EXPERIMENT_FILE` |
| Agent eval missing `environment.skills` | Declare the skills the agent routes to, or it cannot invoke them |
@@ -130,10 +130,10 @@ An eval that compares the skill against itself measures judge noise:
- A dormancy guard (`expect_activation: false`) must **not** also set `constraints.reject_skills`.
That makes the skilled arm skill-free, i.e. identical to baseline. Across four evals the same
guard scored 0.4, +0.4, +0.4 and 0, twice costing a skill its pass.
- A skill with `disable-model-invocation: true` cannot self-activate, so an eval graded on
activation compares two identical arms. Cover it through a consumer skill, or grade the answer
content instead, as `tests/dotnet-test/filter-syntax/eval.yaml` and
`tests/dotnet-test/platform-detection/eval.yaml` do.
- A skill with `disable-model-invocation: true` is absent from the model-facing skilled arm, so its
direct eval compares two identical arms regardless of whether graders inspect activation or answer
content. Cover it through consumer outcomes instead; for example, `filter-syntax` is covered by
`run-tests`, `mtp-hot-reload`, and `migrate-vstest-to-mtp`.
- A grader whose `config` is missing its required key enforces nothing, so the stimulus has one
fewer assertion than it appears to.
@@ -60,7 +60,7 @@ Consequences seen in real runs:
| Symptom | Cause | Fix | Evidence |
|---------|-------|-----|----------|
| A dormancy guard scores randomly across runs | `expect_activation: false` combined with `constraints.reject_skills`, making the skilled arm skill-free and identical to baseline | Use `expect_activation: false` alone | PR #945, PR #953 |
| A reference skill shows no improvement | `disable-model-invocation: true` means the model cannot self-activate it, so an activation-graded eval compares identical arms | Cover it through a consumer skill, or grade answer content as `filter-syntax` does | PR #971, PR #976, issue #899 |
| A reference skill shows no improvement | `disable-model-invocation: true` keeps it out of the model-facing skilled arm, so any direct eval compares identical arms | Remove the direct eval and cover the reference through consumer outcomes | PR #971, PR #976, issue #899 |
| An eval "passes" while the skill stopped emitting its signature output | No grader asserts the mandated shape | Add a grader for the exact contract (e.g. the `Recommendation:` line) | PR #904 |
| Overfit score high, user value unclear | Rubric items reward using the skill, or prompts echo skill vocabulary | Drop them: the harness already reports activation separately, so a rubric never needs to. Keep rubric items outcome-shaped and de-cue the prompt | PR #904 |
| Both arms produce the same kind of artifact and the judge falls back on comparing volume | The rubric rewards raw output instead of the property under test | Add anti-hijack criteria: do not invoke the skill, and do not reward quantity (number of tests, findings, or lines produced) | PR #945 |
+6 -1
View File
@@ -237,6 +237,11 @@ jobs:
foreach ($r in $reports) {
$allJson += $r
if ($r.coverageMode -eq 'consumer') {
$summary += "| :information_source: | ``$($r.plugin)`` | ``$($r.skill)`` | - | **N/A (reference-only)** |"
continue
}
$pct = $r.summary.percentage
$icon = if ($pct -ge 80) { ':white_check_mark:' }
elseif ($pct -ge 50) { ':warning:' }
@@ -264,7 +269,7 @@ jobs:
# Add uncovered items details if any
$uncoveredDetails = @()
foreach ($r in $allJson) {
if ($r.uncovered.Count -gt 0) {
if ($r.coverageMode -ne 'consumer' -and $r.uncovered.Count -gt 0) {
$uncoveredDetails += "<details><summary>Uncovered: <code>$($r.plugin)/$($r.skill)</code></summary>"
$uncoveredDetails += ''
foreach ($u in $r.uncovered) {
+15 -23
View File
@@ -361,10 +361,11 @@ carries zero evidence of impact.
`<available_skills>` menu, so the model cannot reach it from a user prompt — a
consumer skill or agent loads it by name. The experiment's `skilled` variant
loads exactly one skill (`plugins/${eval.grandparent}/skills/${eval.parent}`),
so a direct-activation eval for one of these would run an arm the model can
never invoke: treatment equals control by construction and the head-to-head
score is judge noise. That is the same defect failing check 7 exists to prevent,
and adding such an eval would make the number worse, not better.
so any direct eval for one of these would run an arm the model can never invoke:
treatment equals control by construction and the head-to-head score is judge
noise. Answer-content graders cannot create a difference between identical
arms. That is the same defect failing check 7 exists to prevent, and adding
such an eval would make the number worse, not better.
The honest coverage for these is **dependency-level**: they are exercised
through the evals of the skills that load them (for example `run-tests` and
@@ -374,26 +375,17 @@ analysis skills load `test-analysis-extensions`, and `code-testing-agent` loads
loaded. Closing this properly needs harness support for declaring a dependency
in the skilled variant, not a per-skill eval file.
**A reference skill that already has a direct eval is reported too, and more
loudly.** The same argument cuts both ways: if the skilled arm cannot reach the
skill, an eval sitting beside it does not measure the skill — it measures the
judge comparing baseline to baseline and then labels the result a pass or a
fail. That is worse than no eval, because no eval is visibly zero evidence
whereas a fabricated verdict is counted in the plugin's pass rate. The gate
originally skipped any skill that had an eval, which made the worse case the
quieter one; it now names them.
**A reference skill that has a direct eval is reported too, and more loudly.**
The same argument cuts both ways: if the skilled arm cannot reach the skill, an
eval sitting beside it does not measure the skill — it measures the judge
comparing baseline to baseline and then labels the result a pass or a fail. That
is worse than no eval, because no eval is visibly zero evidence whereas a
fabricated verdict is counted in the plugin's pass rate. Remove the direct eval
and preserve its scenarios through reachable consumer outcomes instead.
> **Two `dotnet-test` reference skills currently carry a direct eval:**
> `filter-syntax` (added in #976) and `platform-detection` (added in #974).
> Their stimuli are ordinary user requests ("one command that runs only the
> integration tests but leaves out the slow ones"), so the intent was to grade
> the answer on whether it carries the correct syntax rather than on whether the
> skill self-activated. Whether that can produce a *measurable* gap over baseline
> for a skill the model cannot invoke is still unconfirmed — the evaluation on
> #976 landed during the PAT-pool outage and reported "no results", and no
> cross-family run has covered either eval since. Read a real result before
> copying the pattern to `code-testing-extensions` or `test-analysis-extensions`;
> if the gap is zero, retire both evals rather than keep scoring noise.
The current `dotnet-test` reference skills `code-testing-extensions`,
`filter-syntax`, and `test-analysis-extensions` — therefore have no direct eval.
Their consumer coverage is documented in `plugins/dotnet-test/README.md`.
### Dormancy guard without an anti-hijack rubric item
@@ -12,6 +12,10 @@
This is analogous to code coverage for skill files: it answers "what parts of
my skill's guidance are actually verified by eval scenarios?"
Skills with disable-model-invocation: true are reference-only and cannot have
meaningful direct-eval coverage. They are reported with consumer coverage mode
and an N/A percentage instead of a misleading zero.
.PARAMETER PluginName
Plugin directory name (e.g., "dotnet-test").
@@ -88,6 +92,19 @@ function Get-CoveragePoints([string]$content) {
@(Get-CodePatterns $content)
}
function Test-ReferenceSkill([string]$content) {
if (-not $content.StartsWith('---')) { return $false }
$frontmatter = [regex]::Match(
$content,
'\A---\s*\r?\n(?<body>.*?)\r?\n---(?:\r?\n|$)',
[System.Text.RegularExpressions.RegexOptions]::Singleline
)
if (-not $frontmatter.Success) { return $false }
$frontmatter.Groups['body'].Value -match '(?m)^disable-model-invocation:\s*true\s*$'
}
function Get-ValidationItems([string]$content) {
$lineNum = 0
$inValidation = $false
@@ -765,6 +782,24 @@ function Format-JsonReport($results, $skillName, $pluginName, $scenarioCount, $e
$report
}
function Format-ReferenceJsonReport($skillName, $pluginName) {
[ordered]@{
skill = $skillName
plugin = $pluginName
coverageMode = 'consumer'
scenarios = $null
evidence = $null
summary = [ordered]@{
totalPoints = $null
coveredPoints = $null
rubricOnlyPoints = $null
percentage = $null
}
categories = [ordered]@{}
uncovered = @()
}
}
# ═══════════════════════════════════════════════════════════
# Discovery & Main
# ═══════════════════════════════════════════════════════════
@@ -819,6 +854,21 @@ $jsonReports = @()
foreach ($pair in $pairs) {
$skillContent = Get-Content -Raw $pair.SkillPath
if (Test-ReferenceSkill $skillContent) {
if ($Format -eq 'Json') {
$jsonReports += Format-ReferenceJsonReport $pair.SkillName $pair.PluginName
}
else {
Write-Host ''
Write-Host " $($pair.PluginName)/$($pair.SkillName)" -ForegroundColor Cyan
Write-Host ' Reference-only skill: direct eval coverage is not applicable.' -ForegroundColor DarkCyan
Write-Host ' Measure its guidance through reachable consumer outcomes.' -ForegroundColor DarkCyan
Write-Host ''
}
continue
}
$coveragePoints = @(Get-CoveragePoints $skillContent)
if ($coveragePoints.Count -eq 0) {
+12 -11
View File
@@ -76,20 +76,21 @@ For non-.NET languages, use the native coverage tool: `coverage.py`/`pytest-cov`
Three reference skills (`code-testing-extensions`, `test-analysis-extensions`,
and `filter-syntax`) set `disable-model-invocation: true`, so the CLI keeps them
out of the model-facing skill menu and a consumer loads them by name. Two
(`code-testing-extensions`, `test-analysis-extensions`) deliberately have no
`tests/dotnet-test/<skill>/eval.yaml`: the experiment's skilled arm loads a
single skill, which the model could never invoke here, so such an eval would
compare two identical arms and score judge noise. They are measured through the
evals of the skills that load them — the polyglot analysis skills and
`grade-tests` for `test-analysis-extensions`, and `code-testing-agent` for
`code-testing-extensions`.
out of the model-facing skill menu and a consumer loads them by name. They
deliberately have no direct `tests/dotnet-test/<skill>/eval.yaml`: the
experiment's skilled arm loads a single skill, which the model could never
invoke here, so such an eval would compare two identical arms and score judge
noise. They are measured through consumer outcomes — the polyglot analysis
skills and `grade-tests` for `test-analysis-extensions`, `code-testing-agent`
for `code-testing-extensions`, and `run-tests`, `mtp-hot-reload`, and
`migrate-vstest-to-mtp` for `filter-syntax`. The `run-tests` eval covers VSTest
expressions, MTP argument passing, xUnit v3 native filters, and TUnit tree-node
filters; the migration eval covers translating VSTest filters to xUnit v3's MTP
syntax.
`platform-detection` is model-invocable because identifying a project's runner
is also a direct user task; `run-tests` and migration skills still load it as
shared detection guidance. `filter-syntax` remains reference-only. Its current
direct eval cannot measure activation and is retained only until consumer-level
coverage replaces it.
shared detection guidance. `filter-syntax` remains reference-only.
## Agents
-172
View File
@@ -1,172 +0,0 @@
name: filter-syntax
executionShard: b
description: Evaluates the dotnet-test/filter-syntax skill
type: capability
config:
timeout: 4m
stimuli:
- name: Select one category and exclude another on a VSTest project
prompt: >
Our CI job for Contoso.Billing.Tests currently runs the whole suite and it
takes far too long. I want one command that runs only the integration
tests but leaves out the ones we marked as slow. I'd rather not touch the
test code or the project file to make this happen.
environment:
files:
- src: ./fixtures/vstest-mstest
dest: .
graders:
- type: output-matches
config:
pattern: --filter
- type: output-matches
config:
pattern: TestCategory
- type: output-matches
config:
pattern: '!=|!~'
- type: exit-success
- type: prompt
rubric:
- Gave a single command that both selects the integration tests and excludes the slow ones
- Used a negated operator for the exclusion rather than proposing two separate runs
- Combined the two conditions into one expression with the AND combinator
- Did not propose editing the test source or the project file to achieve the selection
constraints:
reject_tools:
- edit
- create
- name: Pass a filter to a Microsoft.Testing.Platform project on the .NET 9 SDK
prompt: >
I ran `dotnet test --filter "TestCategory=Smoke"` on Contoso.Shipping.Tests and
the SDK rejected the option instead of running my smoke tests. The project
builds fine and the tests all run when I don't filter. What command should
I actually be running here, and will it change when we move to a newer SDK?
environment:
files:
- src: ./fixtures/mtp-nunit-sdk9
dest: .
graders:
- type: output-matches
config:
pattern: --\s+--filter
- type: output-matches
config:
pattern: Category
- type: exit-success
- type: prompt
rubric:
- Gave a command that forwards the filter to the test application rather than to the SDK command line itself
- Attributed the rejected option to how this project is run, not to a malformed filter expression
- Did not tell the user to abandon the property-and-operator expression for a different filter syntax, because this
framework accepts it unchanged on this platform
- Stated whether the extra separator is still required on a newer SDK
constraints:
reject_tools:
- edit
- create
- name: Filter xUnit v3 tests that do not accept the generic filter expression
prompt: >
Contoso.Catalog.Tests is on xUnit v3 and `--filter "FullyQualifiedName~SearchIntegrationTests"`
does nothing — every test still runs. I need three things: run just the
SearchIntegrationTests class, run everything tagged Smoke, and finally run
only the Smoke-tagged tests under the Integration namespace. That last one
has a constraint: our pipeline template only ever passes a single filter
option and its value, so it cannot pass two options. What do I use?
environment:
files:
- src: ./fixtures/mtp-xunit-v3
dest: .
graders:
- type: output-matches
config:
pattern: --filter-class
- type: output-matches
config:
pattern: --filter-trait
- type: output-matches
config:
pattern: --filter-query
- type: exit-success
- type: prompt
rubric:
- Explained that this runner does not honour the generic property-expression filter, which is why nothing was excluded
- Gave a working command for the single-class selection using the runner's own option
- Gave a working command for the tag-based selection using the runner's own option
- Expressed the namespace-and-tag selection with a single filter option, appending the tag selector to the method
segment as `[traitName=traitValue]`
- Commands account for the separator the installed SDK requires
constraints:
reject_tools:
- edit
- create
- name: Filter a TUnit suite down to one class and one property value
prompt: >
Contoso.Portal.Tests is a TUnit project. I want to know how to run just the
LoginTests class, how to run a single test by name no matter which class it
is in, how to run everything tagged with the Smoke category, and how to skip
everything tagged Slow. Give me the exact commands.
environment:
files:
- src: ./fixtures/tunit
dest: .
graders:
- type: output-matches
config:
pattern: --treenode-filter
- type: output-matches
config:
pattern: /\*/
- type: exit-success
- type: prompt
rubric:
- Used the path-shaped selector this framework provides rather than a property-expression filter
- Used wildcards for the path segments the request does not constrain
- Showed both the include-by-property and the exclude-by-property forms
- Explained what each position in the path stands for, so the user can build further selections
constraints:
reject_tools:
- edit
- create
- name: Translate CI filter expressions after moving to xUnit v3
prompt: |
We are moving Contoso.Catalog.Tests from the old runner to xUnit v3 and our
pipeline has these four filter arguments baked into different jobs:
1. `FullyQualifiedName~SearchIntegrationTests`
2. `FullyQualifiedName=Contoso.Catalog.Tests.Integration.PricingIntegrationTests.Price_WithTax_IncludesVat`
3. `Category=Smoke`
4. `FullyQualifiedName~PricingIntegrationTests&Category=Smoke`
Give me the replacement for each job. Do not change any files.
environment:
files:
- src: ./fixtures/mtp-xunit-v3
dest: .
graders:
- type: output-matches
config:
pattern: --filter-class
- type: output-matches
config:
pattern: --filter-method
- type: output-matches
config:
pattern: --filter-trait
- type: exit-success
- type: prompt
rubric:
- Stated that the four expressions do not keep working unchanged on this runner
- Turned the substring match on the class into a class selection with wildcards on both sides
- Turned the exact fully-qualified name into an exact method selection
- Turned the category condition into a tag selection carrying a name and a value
- Translated the fourth expression, which combines a class condition and a tag condition, into a form this runner
actually accepts
constraints:
reject_tools:
- edit
- create
@@ -1,15 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<IsPackable>false</IsPackable>
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="NUnit" Version="4.2.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
</ItemGroup>
</Project>
@@ -1,27 +0,0 @@
using NUnit.Framework;
namespace Contoso.Shipping.Tests;
[TestFixture]
public class RateCalculatorTests
{
[Test]
[Category("Unit")]
public void Rate_DomesticParcel_UsesFlatFee() { Assert.Pass(); }
[Test]
[Category("Smoke")]
public void Rate_InternationalParcel_AddsSurcharge() { Assert.Pass(); }
}
[TestFixture]
public class LabelPrinterTests
{
[Test]
[Category("Smoke")]
public void Print_ValidLabel_ReturnsPdfBytes() { Assert.Pass(); }
[Test]
[Category("Slow")]
public void Print_TenThousandLabels_CompletesWithinBudget() { Assert.Pass(); }
}
@@ -1,6 +0,0 @@
{
"sdk": {
"version": "9.0.200",
"rollForward": "latestFeature"
}
}
@@ -1,16 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit.v3" Version="1.0.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup>
</Project>
@@ -1,25 +0,0 @@
using Xunit;
namespace Contoso.Catalog.Tests.Integration;
public class SearchIntegrationTests
{
[Fact]
[Trait("Category", "Smoke")]
public void Search_KnownTerm_ReturnsHits() { Assert.True(true); }
[Fact]
[Trait("Category", "Nightly")]
public void Search_FullReindex_Completes() { Assert.True(true); }
}
public class PricingIntegrationTests
{
[Fact]
[Trait("Category", "Smoke")]
public void Price_WithTax_IncludesVat() { Assert.True(true); }
[Fact]
[Trait("Category", "Nightly")]
public void Price_UnknownSku_Throws() { Assert.True(true); }
}
@@ -1,21 +0,0 @@
using Xunit;
namespace Contoso.Catalog.Tests.Unit;
public class SearchQueryParserTests
{
[Fact]
[Trait("Category", "Smoke")]
public void Parse_SingleTerm_ReturnsTerm() { Assert.True(true); }
[Fact]
[Trait("Category", "Regression")]
public void Parse_UnbalancedQuotes_Throws() { Assert.True(true); }
}
public class PriceFormatterTests
{
[Fact]
[Trait("Category", "Smoke")]
public void Format_WholeAmount_HasTwoDecimals() { Assert.True(true); }
}
@@ -1,6 +0,0 @@
{
"sdk": {
"version": "9.0.200",
"rollForward": "latestFeature"
}
}
@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TUnit" Version="1.45.8" />
</ItemGroup>
</Project>
@@ -1,26 +0,0 @@
using System.Threading.Tasks;
using TUnit.Core;
namespace Contoso.Portal.Tests.Api;
public class LoginTests
{
[Test]
[Category("Smoke")]
public async Task AcceptCookiesTest() { await Task.CompletedTask; }
[Test]
[Category("Slow")]
public async Task LoginWithExpiredPasswordTest() { await Task.CompletedTask; }
}
public class SignupTests
{
[Test]
[Category("Smoke")]
public async Task SignupWithValidEmailTest() { await Task.CompletedTask; }
[Test]
[Category("Slow")]
public async Task SignupRejectsDuplicateEmailTest() { await Task.CompletedTask; }
}
@@ -1,33 +0,0 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Contoso.Billing.Tests;
[TestClass]
public class InvoiceTests
{
[TestMethod]
[TestCategory("Unit")]
public void CreateInvoice_ValidLines_ComputesTotal() { Assert.IsTrue(true); }
[TestMethod]
[TestCategory("Integration")]
public void CreateInvoice_PersistsToDatabase() { Assert.IsTrue(true); }
[TestMethod]
[TestCategory("Integration")]
[TestCategory("Slow")]
public void ReconcileLedger_FullMonth_Balances() { Assert.IsTrue(true); }
}
[TestClass]
public class PaymentTests
{
[TestMethod]
[TestCategory("Unit")]
public void Charge_NegativeAmount_Throws() { Assert.IsTrue(true); }
[TestMethod]
[TestCategory("Integration")]
[TestCategory("Slow")]
public void Charge_RealGateway_Succeeds() { Assert.IsTrue(true); }
}
@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="MSTest.TestAdapter" Version="3.6.3" />
<PackageReference Include="MSTest.TestFramework" Version="3.6.3" />
</ItemGroup>
</Project>