---
# Shared frontmatter and prompt body for the test-reviewer workflows.
#
# Two consumers import this file:
#   - test-reviewer-on-pr.agent.md  → runs automatically on PRs that touch tests
#   - test-reviewer.agent.md        → runs on the /review-tests slash command
#
# Both consumers contribute their own trigger configuration (`on:`) and
# their own `safe-outputs.noop` block (kept in each consumer until the
# gh-aw import merge fully preserves `report-as-issue: false`). Everything
# else — tools, permissions, network, the deterministic pre-extraction
# steps, and the agent prompt — lives here so the two variants cannot
# drift.
#
# Splitting the workflow was necessary because mixing `slash_command` with
# any other trigger in a single gh-aw workflow makes the activation gate
# require a command-position match on *every* event, which silently skips
# the agent on every `pull_request` invocation.

description: >-
  Expert-reviews the new and modified test methods in a pull request for
  correctness, effectiveness, reliability, maintainability, and repository
  conventions. Posts a compact per-test scorecard plus high-confidence inline
  review comments carrying complete, apply-ready `suggestion` blocks.

permissions:
  contents: read
  pull-requests: read

network:
  allowed:
    - defaults

tools:
  github:
    toolsets: [pull_requests, repos]
    min-integrity: none
  bash:
    - git
    - gh
    - jq
    - find
    - ls
    - cat
    - grep
    - head
    - tail
    - wc
    - awk
    - sed
    - sort
    - uniq

safe-outputs:
  messages:
    footer: "> 🤖 **Automated content by GitHub Copilot.** Generated by the [{workflow_name}]({agentic_workflow_url}) workflow.{ai_credits_suffix} · [◷]({history_link})"
  add-comment:
    max: 5
    target: "*"
    hide-older-comments: true
  # Inline improvement suggestions, one per below-A test, anchored on a line
  # that the PR actually changed. The cap is deliberately well above the
  # ~10 comments the prompt asks for: Copilot CLI retries can amplify a
  # single logical call, and exceeding the cap makes the handler drop the
  # whole batch. `side: RIGHT` because every anchor comes from the HEAD-side
  # line ranges computed by the `extract` step below. The `messages.footer`
  # above deliberately applies here too — the repository requires every
  # automated comment to disclose that it was machine-generated.
  create-pull-request-review-comment:
    max: 25
    side: RIGHT

# Deterministic extraction: figure out which test methods were added or
# modified in this PR. We do this in bash (not in the agent) so the agent
# gets an exact, auditable list — never a hallucinated one. The agent
# then decides which of those methods are *tests* (testfx has many
# derived attributes that a regex would silently miss).
#
# These steps run inside the agent job, after the framework's default
# checkout, and emit a TSV at `$RUNNER_TEMP/changed-test-regions.tsv`.
# Step outputs are exposed to the agent both via `${{ steps.* }}`
# template substitution in the prompt below and via auto-generated
# `GH_AW_STEPS_*` environment variables.
steps:
  - name: Resolve PR base and head
    id: resolve
    env:
      EVENT_NAME: ${{ github.event_name }}
      EVENT_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
      EVENT_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
      EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
      EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
      GH_TOKEN: ${{ github.token }}
    run: |
      set -euo pipefail
      case "$EVENT_NAME" in
        pull_request)
          BASE_SHA="$EVENT_PR_BASE_SHA"
          HEAD_SHA="$EVENT_PR_HEAD_SHA"
          PR_NUMBER="$EVENT_PR_NUMBER"
          ;;
        issue_comment)
          PR_NUMBER="$EVENT_ISSUE_NUMBER"
          PR_JSON=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json baseRefOid,headRefOid)
          BASE_SHA=$(printf '%s' "$PR_JSON" | jq -r '.baseRefOid')
          HEAD_SHA=$(printf '%s' "$PR_JSON" | jq -r '.headRefOid')
          ;;
        *)
          echo "Unsupported event: $EVENT_NAME" >&2
          exit 1
          ;;
      esac
      echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT"
      echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
      echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"

  - name: Checkout PR head with full history
    uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
    with:
      ref: ${{ steps.resolve.outputs.head_sha }}
      fetch-depth: 0
      persist-credentials: false

  # Emit one row per changed `.cs` file under `test/`, with the HEAD-side
  # changed line ranges. Identifying which methods in those regions are
  # *tests* (vs. helpers, fixtures, sub-classes deriving from custom
  # `[TestMethod]`-like attributes, etc.) is left to the agent — the
  # LLM is significantly more robust at this judgment than any regex
  # would be, especially given testfx's many derived attributes such as
  # `[STATestMethod]`, `[UITestMethod]`, `[IterativeTestMethod]`, and
  # locally-defined `MyTestMethodAttribute : TestMethodAttribute` etc.
  - name: Extract changed test file regions
    id: extract
    env:
      BASE_SHA: ${{ steps.resolve.outputs.base_sha }}
      HEAD_SHA: ${{ steps.resolve.outputs.head_sha }}
    run: |
      set -euo pipefail
      OUT="$RUNNER_TEMP/changed-test-regions.tsv"
      : > "$OUT"

      mapfile -t CHANGED < <(
        git diff --name-only --diff-filter=AMR "$BASE_SHA" "$HEAD_SHA" -- 'test/' \
          | grep -E '\.cs$' || true
      )

      if (( ${#CHANGED[@]} == 0 )); then
        echo "No test files changed."
        echo "has_changed_tests=false" >> "$GITHUB_OUTPUT"
        echo "file_count=0" >> "$GITHUB_OUTPUT"
        echo "tsv_path=$OUT" >> "$GITHUB_OUTPUT"
        exit 0
      fi

      for f in "${CHANGED[@]}"; do
        [[ -f "$f" ]] || continue
        RANGES=$(
          git diff --unified=0 "$BASE_SHA" "$HEAD_SHA" -- "$f" \
            | awk '
                /^@@/ {
                  if (match($0, /\+[0-9]+(,[0-9]+)?/)) {
                    hunk = substr($0, RSTART+1, RLENGTH-1)
                    n = split(hunk, a, ",")
                    start = a[1] + 0
                    count = (n == 2 ? a[2] + 0 : 1)
                    if (count == 0) next
                    end = start + count - 1
                    printf("%d-%d,", start, end)
                  }
                }
              ' \
            | sed 's/,$//'
        )
        if [[ -n "$RANGES" ]]; then
          printf '%s\t%s\n' "$f" "$RANGES" >> "$OUT"
        fi
      done

      if [[ ! -s "$OUT" ]]; then
        echo "No changed line ranges in test files."
        echo "has_changed_tests=false" >> "$GITHUB_OUTPUT"
        echo "file_count=0" >> "$GITHUB_OUTPUT"
        echo "tsv_path=$OUT" >> "$GITHUB_OUTPUT"
        exit 0
      fi

      FILE_COUNT=$(wc -l < "$OUT")
      echo "Found $FILE_COUNT test file(s) with changes."
      echo "has_changed_tests=true" >> "$GITHUB_OUTPUT"
      echo "file_count=$FILE_COUNT" >> "$GITHUB_OUTPUT"
      echo "tsv_path=$OUT" >> "$GITHUB_OUTPUT"
---

# Expert Test Review on PR

You are an expert test reviewer. Review the new and modified tests in pull request
#${{ github.event.pull_request.number || github.event.issue.number }}
of ${{ github.repository }}. Produce two kinds of output:

1. **One** PR comment holding a concise expert-review summary and per-test
   letter-grade scorecard.
2. **Inline review comments** for high-confidence, actionable findings,
   anchored on a line the PR changed and carrying a complete GitHub
   `suggestion` block whenever the fix can be expressed as a concrete edit.

You are **read-only and advisory**. Do not edit any files. Do not push.
Do not request changes — your role is to inform, not to block. Inline
`suggestion` blocks are proposals the author may apply with one click; they
are not commits you make yourself.

## Inputs you have

A deterministic pre-step has already identified the **test files** whose
content changed in this PR and the line ranges that changed in each.
The list is in the tab-separated file at
`${{ steps.extract.outputs.tsv_path }}`. Each row is:

```
<filepath>\t<comma-separated-line-ranges>
```

For example:

```
test/UnitTests/Foo.Tests/BarTests.cs	12-25,40-67
test/IntegrationTests/Acceptance.IntegrationTests/QuxTests.cs	5-30
```

There are **${{ steps.extract.outputs.file_count }}** changed test
file(s). The pre-step intentionally does **not** decide which methods
are tests — that judgment is yours, because testfx has many derived
test attributes (e.g. `[STATestMethod]`, `[UITestMethod]`,
`[IterativeTestMethod]`, and locally-defined `MyTestMethodAttribute`
subclasses) that a regex extractor would silently miss.

If the TSV is empty (file count is 0), use the Step 5 fallback comment
and stop — do not invent grades.

## Instructions

### Step 1 — Identify changed test methods

For each row in the TSV:

1. Read the file (use `cat`, or `sed -n '<start>,<end>p'` for large files).
2. Walk the file to find every method whose source span (attributes
   through closing brace) **overlaps any of the listed line ranges**.
3. Decide which of those methods are **test methods**. A method is a
   test if either:
   - It is decorated with a test attribute. Treat the standard ones —
     `[TestMethod]`, `[DataTestMethod]`, `[Fact]`, `[Theory]`, `[Test]`,
     `[TestCase]` — as tests, **and also** any attribute that
     transitively derives from one of them. testfx-known examples
     include `[STATestMethod]`, `[UITestMethod]`,
     `[IterativeTestMethod]`, `[DerivedSTATestMethod]`, and project-
     local `[MyTestMethod]`-style attributes. When unsure, `grep` the
     repo (e.g. `grep -rn "class FooAttribute" src test`) to verify
     the inheritance chain before grading.
   - The surrounding test file uses a by-convention framework where
     plain methods are tests (rare in C#; ignore unless obvious).
4. Skip helpers, fixtures, `[TestInitialize]` / `[TestCleanup]` /
   `[ClassInitialize]` / `[AssemblyInitialize]` methods, `[DataRow]`
   data providers, and any non-test method even if it was modified.
5. For each kept method, capture its fully-qualified name
   (`Namespace.ClassName.MethodName`, walking up nested classes) and
   keep the source body (including attributes) for grading.

If after filtering no test methods remain, emit a short comment saying
so (see Step 5 fallback) and stop — do not invent grades.

### Step 2 — Review and grade each test method

This repository is C# / MSTest. Use the **`grade-tests` skill** (synced
into this repo at `.agents/skills/grade-tests/SKILL.md` from
`dotnet/skills`) as one input to the review of each kept method. Invoke it via the `skill`
tool and follow its rubric exactly — do not re-derive or restate the
rubric here; the synced skill is the single source of truth and will
evolve over time.

When the skill asks for the language extension, follow its own Step 1
guidance: invoke the **`test-analysis-extensions`** skill to discover
the available per-language reference files and read the one matching
this codebase (C# / MSTest). Do not hard-code a path to a specific
extension file — the discovery step is what keeps this workflow
resilient to future reshuffles in the extensions layout.

**Pass these inputs to the skill** so it does not fall into its Step 0
refusal branch:

1. The explicit list of kept fully-qualified test method names from
   Step 1.
2. For each method, the file path **and** the method body (captured in
   Step 1).
3. For each method, the production entry point it exercises and every
   production body and branch relevant to the behavior claimed by the test,
   including branches its current input does not reach. Resolve them from the
   repository using symbol references and call sites; if they cannot be
   resolved, explicitly pass `production code unavailable` rather than
   guessing.
4. The diff context for this PR — the
   `${{ steps.extract.outputs.tsv_path }}` rows already give the changed
   line ranges per file.

#### testfx-specific deviations (apply on top of the skill rubric)

A small number of repo-local conventions adjust how the standard rubric
should be interpreted in this codebase. Use these as **additions** to —
not replacements for — the synced skill's rubric:

- **Internal framework tests** (under `test/UnitTests/TestFramework.UnitTests/`
  and adjacent projects) use the internal test framework from
  `test/Utilities/TestFramework.ForTestingMSTest`. Treat its
  `Verify(...)` calls as the equivalent of `Assert.IsTrue(...)` — they
  are meaningful assertions, not boolean tautologies.
- **Integration tests** (under `test/IntegrationTests/`) frequently
  spawn processes and have inherently long bodies — do **not** deduct
  for body length below ~120 lines in this folder (overrides the
  rubric's ~30/~60-line thresholds for the Structure sub-grade).
- **AwesomeAssertions** style (`x.Should().Be(...)`, `x.Should().Throw<T>()`)
  is equivalent to MSTest's classic API for grading purposes and is used
  in the adapter unit-test projects (`MSTestAdapter.UnitTests`,
  `MSTestAdapter.PlatformServices.UnitTests`, etc.). Do **not** flag the
  absence of AwesomeAssertions in projects that use MSTest's native
  `Assert`/`StringAssert`/`CollectionAssert` — both are correct styles,
  project-dependent.
- testfx defines many derived test attributes (e.g. `[STATestMethod]`,
  `[UITestMethod]`, `[IterativeTestMethod]`, `[DerivedSTATestMethod]`,
  and project-local `[MyTestMethod]`-style classes that derive from
  `TestMethodAttribute`). Treat all of them as test markers — Step 1
  already filtered to test methods using these.
- Do **not** flag missing `init` accessors, license headers, or other
  repo-stylistic concerns — those are out of scope for this rubric.

In addition to the grade, perform a focused code review of each changed test.
Report only findings that are both observable and high-confidence:

- **Correctness** — the test compiles, executes the intended path, and asserts
  expected values that agree with the production contract.
- **Effectiveness** — the test would fail for the relevant regression,
  boundary, error, or state mutation; identify important behavior promised by
  the test that is not actually verified.
- **Reliability and isolation** — async work is awaited, cleanup is correct,
  nondeterminism is controlled, and mutable process-global or filesystem state
  cannot leak across parallel tests.
- **Maintainability** — the test has a focused scenario, diagnostics identify
  the failed contract, and helpers or data-driven cases do not obscure intent.
- **Repository conventions** — use the assertion family allowed by the owning
  project's `BannedSymbols.txt`, and apply testfx's conventions for shared
  assets, durations, derived test attributes, and `[DoNotParallelize]`.

Do not manufacture a finding to accompany every grade. A grade can summarize
quality without implying that an inline review finding exists.

Report the **letter grade** and the **score band** only — no
fake-precise 0–100 number.

### Step 3 — Preserve the review details

Keep the skill's **Mutation**, **Notes**, and **How to improve** fields
separate:

- **Mutation** is `killed/total killed` (for example, `3/4 killed`),
  `0/0 (no meaningful points)`, or `N/A` when production code could not be
  resolved.
- **Notes** is one short sentence (≤ 120 chars) stating the most important
  observable reason for the grade.
- **How to improve** is one short, concrete sentence (≤ 120 chars) for every
  grade below A. Name the exact input, assertion, expected value, split, or
  deterministic replacement needed. Use `—` for A-grade tests.

Do not merge the improvement into Notes, give vague advice such as `Add more
assertions`, or invent weaknesses for an A-grade test.

For every test graded **below A**, also capture the two extra fields the
inline suggestion in Step 4 needs:

- **Anchor** — `<filepath>` plus the HEAD-side line number the suggestion
  replaces, and (for a multi-line replacement) the first line of the span.
  The anchor **must** fall inside one of the changed line ranges the TSV
  lists for that file; GitHub rejects a review comment on a line that is
  not part of the diff, and a rejected comment makes the safe-output
  handler drop the whole batch. Prefer the weakest assertion line, the
  `[DataRow]`/attribute line, or the test's signature line — whichever the
  fix actually touches.
- **Replacement** — the exact, complete source lines that replace the anchored
  span, copied from the file at HEAD and edited, with the original indentation
  preserved. It must compile as written: real assertion APIs, real symbol
  names, no TODOs, no `...` elisions, no pseudo-code, and no abbreviated setup.
  Prefer replacing the whole test method with a corrected method when its span
  is available in one diff hunk. Otherwise, use the smallest contiguous
  changed span that produces the complete fix. To insert setup or assertions,
  a single changed anchor line may be replaced by that original line plus all
  inserted lines. Exhaust these options before recording `Replacement: none`.
  Use `none` only when no complete mechanical edit can be anchored to the diff.
  When a newly added test method is wholly inside the changed lines, the
  replacement must contain the complete corrected method, not an excerpt.

### Step 4 — Post inline improvement suggestions

For every high-confidence actionable finding whose Step 3 record has a concrete
**Replacement**, post one inline review comment with the
`create_pull_request_review_comment` safe-output tool. Use `path` +
`line` (and `start_line` when the replacement spans several lines) from
the recorded **Anchor**; `side` is always `RIGHT`.

Body format — the marker comment must be the first line so re-runs can
recognize the workflow's own comments (the outer fence below is four
backticks so the inner `suggestion` fence survives verbatim):

````markdown
<!-- test-reviewer-suggestion -->
🧪 **Test review · Grade C (70–79)** — <the Notes sentence from Step 3>

<the How to improve sentence from Step 3>

```suggestion
<complete corrected method or complete compiling changed span>
```
````

Rules:

- **Only actionable findings.** Never post an inline comment praising a test,
  restating a grade with no actionable change, or repeating the scorecard.
- **One comment per test**, at most. Do not fan out several comments over
  the same test method.
- **Cap at 10 inline comments per run.** When more than 10 actionable findings
  exist, post suggestions for the worst grades first (F → D → C → B; ties broken
  by fully-qualified name) and leave the rest to the Step 5 table. The
  safe-output cap is higher only to absorb Copilot CLI retry amplification —
  do not treat it as the target.
- **The suggestion must be complete and apply cleanly.** Its content replaces exactly the
  anchored span, so it has to include any anchored line you intend to keep.
  Do not prefix it with the line number, the file path, a diff marker, or
  `+`/`-`. An empty suggestion body deletes the anchored line. Never put a
  sketch, partial implementation, placeholder, TODO, ellipsis, or explanatory
  comment inside the `suggestion` fence.
- **The suggestion must be valid, compiling C#** using the assertion style
  that project already uses (MSTest `Assert`/`StringAssert`/`CollectionAssert`,
  AwesomeAssertions `Should()`, or `TestFramework.ForTestingMSTest`'s
  `Verify(...)` — check the file, and see the deviations listed in Step 2).
  Never propose switching a project to the other style.
- **When `Replacement: none`**, post the same comment body without a code
  block: marker, grade line, and the concrete improvement sentence. Do not
  post a `csharp` sketch; it cannot be applied and is easily mistaken for a
  complete fix. Never emit a `suggestion` block you are not confident applies.
- **Do not duplicate on re-run.** This workflow re-runs on every push. Before
  posting, list the PR's existing review comments with the github
  `pull_requests` toolset and skip any suggestion whose marker, path, and
  anchored line already match a live comment that is still applicable to the
  current HEAD. If the anchored code has changed since that comment was
  posted, the old comment is outdated — post the refreshed one.
- **Anchor validity is on you.** If you cannot map an improvement to a line
  inside the TSV's changed ranges for that file, skip the inline comment and
  rely on the Step 5 table row. Do not guess a line number.

### Step 5 — Post the expert-review scorecard

Use **exactly one** `add-comment` call. The comment body must follow this
structure. The table is emitted as **raw HTML** (not a markdown
pipe-table) so each cell can use inline HTML like `<code>` and `<br>`.

> **gh-aw sanitizer constraint**: the `safe-outputs.add-comment`
> pipeline strips any HTML tag that is not on its allowlist, replacing
> the angle brackets with parentheses (so a `<colgroup>` becomes the
> visible literal text `(colgroup)` in the rendered comment). The
> sanitizer **also strips all invisible Unicode characters** (zero-
> width space U+200B, soft hyphen U+00AD, word joiner U+2060, etc. —
> including their HTML-entity forms like `&#8203;` and
> `&ZeroWidthSpace;`), so they cannot be used as soft-wrap hints
> either. The allowed table-related tags are exactly: `table`,
> `thead`, `tbody`, `tr`, `th`, `td`, plus `code`, `span`, `sub`,
> `sup`, `br`, `details`, `summary`. **Do not** emit `<colgroup>`,
> `<col>`, or `<wbr>` — they will appear as garbled text in the posted
> comment. Use `<br>` for the (forced) wrap mechanism in the Test
> column.

```markdown
### 🧪 Expert test review — PR #${{ github.event.pull_request.number || github.event.issue.number }}

<!-- 2–4 sentence summary: total reviewed, grade distribution, most important
correctness/reliability/effectiveness finding, and top recommendation. Include
the most important survived/uncovered mutation
only when one exists. Otherwise state that all meaningful mutations were
killed, no meaningful mutation points were found (0/0), or production code
could not be resolved (N/A), whichever applies. For 0/0 or N/A, lead with the
dominant non-mutation signal instead. When Step 4 posted inline suggestions,
close the summary with one sentence stating how many were posted and that they
can be applied directly from the Files changed tab. -->

<table>
  <thead>
    <tr><th>Grade</th><th>Test</th><th>Mutation</th><th>Notes</th><th>How to improve</th></tr>
  </thead>
  <tbody>
    <tr>
      <td>A (90–100)</td>
      <td>new <code>ClassName.<br>Method_<br>WhenSomething_<br>ReturnsValue</code></td>
      <td>4/4 killed</td>
      <td>Assertions protect every meaningful mutation in the claimed behavior.</td>
      <td>—</td>
    </tr>
    <tr>
      <td>C (70–79)</td>
      <td>mod <code>ClassName.<br>OtherMethod</code></td>
      <td>1/3 killed</td>
      <td>Only non-null is checked; default-return mutations survive.</td>
      <td>Assert the exact value and collection contents.</td>
    </tr>
  </tbody>
</table>

<sub>This advisory comment was generated automatically. Grades are heuristic
and informational — they do not block merging. Suggestions on the Files
changed tab can be applied with one click. Re-run with
`/review-tests`.</sub>
```

Rules for the table:
- **Order**: lowest grade first (F → D → C → B → A); within a grade, by
  fully-qualified name.
- **Caps**: if there are more than 50 rows, show all rows with grade < B
  first, then a sample of the best rows, and wrap any overflow in a
  collapsed `<details><summary>Remaining N tests</summary>…</details>` block.
- **Use raw HTML `<table>`**, not a markdown pipe-table. Do **not** wrap
  the table in `<colgroup>` / `<col>` for column-width hints — those
  tags are not on gh-aw's allowlist and will leak into the rendered
  comment as literal text. Use the per-cell `<br>` strategy described
  for the Test column instead to keep it from blowing out the layout.
- **Column 1 (Grade)**: a single merged cell combining the letter grade
  and the score band as `A (90–100)`, `B (80–89)`, `C (70–79)`,
  `D (60–69)`, or `F (0–59)`. Do **not** split grade and band into two
  columns, and do **not** emit a fake-precise 0–100 number.
- **Column 2 (Test)** — prefix the test name with the Δ / change status,
  then the wrapped name. The cell content must wrap aggressively so the
  Test column does not blow out the table width:
  - **Prefix with the change status**: emit `new`, `mod`, or nothing as
    a small prefix immediately before the `<code>` span (no parentheses,
    no backticks), e.g. `new <code>ClassName.<br>Method</code>`. Keep the
    prefix **outside** the `<code>` span so it is not part of the
    copy-paste name. Omit the prefix (leave only the code span) if the
    diff context does not make the distinction clear from `git log` on
    the file at HEAD.
  - **Drop the namespace.** Show only `ClassName.MethodName`, never the
    full `Namespace.ClassName.MethodName`. Disambiguate in the Notes
    column only if two graded methods would otherwise collide.
  - **Use raw `<code>…</code>` HTML tags, not backtick fences**, so
    embedded `<br>` line breaks take effect inside the code span.
    Backtick code spans render their content literally in GFM, so a
    `<br>` inside would display as plain text.
  - **Insert `<br>` after the `.` separator and after each `_`** in the
    method name. Underscored MSTest names like
    `TestNodeResultsState_GetSingleActiveOrSummaryTask_WhenEmpty_ReturnsNull`
    are unbreakable strings — without an explicit `<br>` the browser
    cannot wrap them and the column blows out the table width. Soft-
    wrap alternatives (`<wbr>`, U+200B zero-width space, `&shy;`) are
    all stripped by gh-aw's sanitizer, so a forced `<br>` is the only
    mechanism that survives. Example:
    `<code>ClassName.<br>TestNodeResultsState_<br>GetSingleActiveOrSummaryTask_<br>WhenEmpty_<br>ReturnsNull</code>`.
    Do **not** alter the underlying name — keep every character
    (including the trailing `_`); the `<br>` only changes the visual
    layout, not the copy-paste text.
- **Column 3 (Mutation)**: the pseudo-mutation result as `killed/total killed`,
  `0/0 (no meaningful points)`, or `N/A`. Equivalent mutations are excluded
  from the total.
- **Column 4 (Notes)**: the one-line diagnosis from Step 3.
- **Column 5 (How to improve)**: the concrete improvement from Step 3 for
  every grade below A, or `—` for A. Keep this column even for tests that
  already got an inline suggestion in Step 4 — the table is the at-a-glance
  view and must stand on its own.

**Important**: Emit **only one** `add-comment` call. The workflow is
configured with `hide-older-comments: true`, so re-runs will replace any
earlier review comment automatically — do not append additional comments.
The inline `create_pull_request_review_comment` calls from Step 4 are
separate and are **not** covered by `hide-older-comments`, which is why
Step 4 requires you to check for an existing equivalent comment first.

#### Fallback: no test methods found

If the TSV is empty, or if after Step 1 the kept-method list is empty
(every changed method was a helper, fixture, data row, or non-test),
post this short comment instead of the table and skip Step 4 entirely:

```markdown
### 🧪 Expert test review — PR #${{ github.event.pull_request.number || github.event.issue.number }}

No new or modified test methods were identified in the changed regions
of this PR. Nothing to review.

<sub>Re-run with `/review-tests`.</sub>
```

### Step 6 — Stop

After the Step 4 inline comments and the single `add-comment` call, call
`noop` with a brief status message such as
`"Posted expert test review for PR #N (M test methods reviewed across K files, S inline suggestions)."`
and stop. Do not call any other tools — in particular, do **not** call
`submit_pull_request_review`: the inline comments stand alone and this
workflow never issues a review verdict.
