File: Program.cs
Web Access
Project: src\tools\SelectTests\SelectTests.csproj (SelectTests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.CommandLine;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.Json.Nodes;
using Aspire.SelectTests;
 
// Entry point for the selective-CI tool. Runs BEFORE enumerate-tests and computes the subset of
// test projects (and the non-.NET jobs) relevant to a PR's changed files, by unioning:
//   Layer 1 — the MSBuild ProjectGraph reverse-dependency closure (GraphAffectedProjects), and
//   Layer 2 — the curated eng/github-ci/test-trigger-map.yml resolved by TestSelector.
// With --enforce and a non-ALL selection it writes an OverrideProjectToBuild props file
// (--before-build-props) so the subsequent enumerate-tests `-test` build enumerates ONLY the
// selected projects. In audit mode (no --enforce) it writes the run_* job booleans and an advisory
// "would-have-been-skipped" summary but no props, so enumerate-tests runs the full matrix unchanged.
// See docs/ci/test-trigger-selector-design.md.
 
var repoRootOption = new Option<string>("--repo-root")
{
    Description = "Repository root (where .git lives).",
    DefaultValueFactory = _ => Directory.GetCurrentDirectory()
};
 
var mapOption = new Option<string?>("--map")
{
    Description = "Path to eng/github-ci/test-trigger-map.yml. Defaults to <repo-root>/eng/github-ci/test-trigger-map.yml."
};
 
var slnxOption = new Option<string?>("--slnx")
{
    Description = "Path to the solution that defines the project universe. Defaults to <repo-root>/Aspire.slnx."
};
 
var fromOption = new Option<string?>("--from")
{
    Description = "Base git ref to diff from (e.g. the PR base SHA). Required unless --changed-files is given."
};
 
var toOption = new Option<string?>("--to")
{
    Description = "Head git ref to diff to. Defaults to the working tree when --from is given without --to."
};
 
var changedFilesOption = new Option<string?>("--changed-files")
{
    Description = "Path to a newline-delimited list of changed repo-relative paths (instead of --from/--to)."
};
 
var skipLayer1Option = new Option<bool>("--skip-layer1")
{
    Description = "Skip the Layer 1 graph closure (Layer 2 / curated map only)."
};
 
var forceAllOption = new Option<bool>("--force-all")
{
    Description = "Kill switch: force the full matrix regardless of changed files."
};
 
var forceAllReasonOption = new Option<string?>("--force-all-reason")
{
    Description = "Human-readable reason recorded in the run summary when --force-all is a fail-SAFE " +
                  "fallback (e.g. the CI checkout couldn't reach the merge-base) rather than the kill " +
                  "switch. Lets the weekly audit -- which reads the summary, not the raw logs -- tell a " +
                  "systemic regression apart from an intentional run-full-ci full run."
};
 
var enforceOption = new Option<bool>("--enforce")
{
    Description = "Restrict the build to the selected projects (writes --before-build-props). Without this " +
                  "(audit mode), no props are written and enumerate-tests runs the full matrix unchanged."
};
 
var beforeBuildPropsOption = new Option<string?>("--before-build-props")
{
    Description = "Where to write the OverrideProjectToBuild props consumed by eng/Build.props " +
                  "($(BeforeBuildPropsPath)). Written only with --enforce and a non-ALL selection; " +
                  "otherwise nothing is written so enumerate-tests enumerates everything."
};
 
var rootCommand = new RootCommand("Select the relevant CI test subset for a PR's changed files.");
foreach (var option in new Option[]
{
    repoRootOption, mapOption, slnxOption, fromOption, toOption, changedFilesOption,
    skipLayer1Option, forceAllOption, forceAllReasonOption, enforceOption, beforeBuildPropsOption
})
{
    rootCommand.Options.Add(option);
}
 
rootCommand.SetAction(parseResult =>
{
    var repoRoot = Path.GetFullPath(parseResult.GetValue(repoRootOption)!);
    var mapPath = parseResult.GetValue(mapOption)
        ?? Path.Combine(repoRoot, "eng", "github-ci", "test-trigger-map.yml");
    var slnxPath = parseResult.GetValue(slnxOption)
        ?? Path.Combine(repoRoot, "Aspire.slnx");
    var from = parseResult.GetValue(fromOption);
    var to = parseResult.GetValue(toOption);
    var changedFilesPath = parseResult.GetValue(changedFilesOption);
    var skipLayer1 = parseResult.GetValue(skipLayer1Option);
    var forceAll = parseResult.GetValue(forceAllOption);
    var forceAllReason = parseResult.GetValue(forceAllReasonOption);
    var enforce = parseResult.GetValue(enforceOption);
    var beforeBuildProps = parseResult.GetValue(beforeBuildPropsOption);
 
    return Selection.Run(new RunOptions(
        repoRoot, mapPath, slnxPath, from, to, changedFilesPath,
        skipLayer1, forceAll, enforce, beforeBuildProps, forceAllReason));
});
 
return rootCommand.Parse(args).Invoke();
 
internal sealed record RunOptions(
    string RepoRoot,
    string MapPath,
    string SlnxPath,
    string? From,
    string? To,
    string? ChangedFilesPath,
    bool SkipLayer1,
    bool ForceAll,
    bool Enforce,
    string? BeforeBuildProps,
    string? ForceAllReason = null);
 
internal static class Selection
{
    public static int Run(RunOptions options)
    {
        var trace = new SelectionTrace();
        try
        {
            return RunCore(options, trace);
        }
        catch (Exception ex)
        {
            // Augment the failure with WHAT it was processing and HOW to re-run it, then rethrow so the
            // CI step still exits non-zero -- a crash must never be downgraded to a silent under-select.
            WriteFailureDiagnostics(options, trace, ex);
            throw;
        }
    }
 
    private static int RunCore(RunOptions options, SelectionTrace trace)
    {
        // The universe an ALL selection expands to, and the existence guard for test: targets and
        // Layer 1 affected test projects: the test projects in Aspire.slnx (tests/<Name>/<Name>.csproj
        // with a .Tests suffix). Derived from the slnx -- NOT from an enumerated matrix -- because the
        // selector now runs BEFORE enumerate-tests. Maps each test project name to its repo-relative
        // .csproj path so a selected name can be written as an OverrideProjectToBuild item.
        trace.EnterStage("load test projects from slnx");
        var testProjectsByName = LoadTestProjects(options.SlnxPath);
        var allTestProjects = testProjectsByName.Keys.ToHashSet(StringComparer.Ordinal);
 
        // The prefilter (the map's `prefilter` block): read the CI skip-gate patterns file at runtime
        // and drop matching changed files before BOTH layers, except the keep_routed carve-outs. So an
        // excluded file influences no selection. See ChangedFileFilter for why this must gate Layer 1 too.
        trace.EnterStage("load trigger map and prefilter");
        var changedFileFilter = ChangedFileFilter.Create(options.RepoRoot, TriggerMap.Load(options.MapPath).Prefilter);
 
        // A PR must select on its OWN changes, not on commits that landed on the base branch after the
        // branch point. Diff from the merge-base of base..head (the branch point) instead of the base
        // tip, so base-branch churn the PR never touched is not mis-attributed to it. Rebinding From to
        // the merge-base here feeds the SAME diff base to BOTH Layer 1 (graph) and Layer 2 (path map),
        // so they stay consistent. Only meaningful when diffing refs: --force-all has no base, and an
        // explicit --changed-files list is already the literal change set.
        // Concretely fixes https://github.com/microsoft/aspire/pull/18377#issuecomment-4782187184, where
        // a file changed on main after the branch point tripped the run-all fallback because the old
        // base-tip..head diff surfaced it.
        if (!options.ForceAll && options.From is not null && options.ChangedFilesPath is null)
        {
            trace.EnterStage("resolve merge-base of base..head");
            var mergeBase = TryResolveMergeBase(options.RepoRoot, options.From, options.To, out var mergeBaseError);
            if (mergeBase is null)
            {
                // Don't block the PR on an unresolved merge-base. Over-selecting (run ALL) is the
                // fail-SAFE outcome -- the same stance as the unmapped-file run-all fallback -- so a
                // history-depth gap or a genuinely divergent base degrades to a full run while the wiring
                // is fixed, instead of failing the job and turning every affected PR red. The CI action
                // deepens the shallow checkout to make the merge-base reachable, so reaching here means
                // even that didn't help. Warn loudly AND record the reason in the summary so a systemic
                // regression can't hide behind a green-but-full-matrix run.
                var reason = $"{mergeBaseError} -- the base and head may not share enough local history, or the branches genuinely diverge";
                WriteWarning($"SelectTests: {reason}. Falling back to running ALL tests for this PR.");
                options = options with { ForceAll = true, ForceAllReason = reason };
            }
            else
            {
                options = options with { From = mergeBase };
            }
        }
 
        // Under --force-all the selector returns ALL regardless of the diff (see below), so skip
        // resolving changed files and the Layer 1 graph closure entirely. Resolving them is not just
        // wasted work: --force-all is the path taken when there is no usable diff base (or the
        // run-full-ci label kill switch fired), so ResolveChangedFiles would otherwise throw for lack of a
        // --from/--changed-files input.
        trace.EnterStage("resolve changed files");
        var rawChangedFiles = options.ForceAll
            ? Array.Empty<string>()
            : ResolveChangedFiles(options);
 
        // Split the raw change set into excluded (reported for audit transparency) and the filtered
        // set that actually drives Layer 2. RunLayer1 applies the same filter to Layer 1's own git
        // diff, so both layers see the identical post-prefilter change set.
        var excludedFiles = rawChangedFiles
            .Where(changedFileFilter.IsExcluded)
            .ToList();
        var changedFiles = rawChangedFiles
            .Where(f => !changedFileFilter.IsExcluded(f))
            .ToList();
 
        trace.EnterStage("compute Layer 1 affected-projects graph");
        var layer1 = (options.ForceAll || options.SkipLayer1)
            ? AffectedResult.Empty
            : RunLayer1(options, changedFileFilter, trace);
        var layer1Affected = layer1.AffectedProjects;
 
        // When Layer 1 is skipped there is no graph attribution, so the project-directory set must be
        // empty too. Otherwise TestSelector would treat files under those dirs as "Layer-1-owned" and
        // suppress the run-all fallback even though nothing attributed them -- a silent under-selection.
        var projectDirectories = options.SkipLayer1
            ? Array.Empty<string>()
            : LoadProjectDirectories(options.SlnxPath);
 
        trace.EnterStage("select (Layer 2 trigger map + Layer 1 union)");
        var selector = new TestSelector(options.MapPath, allTestProjects, projectDirectories);
        var result = selector.Select(changedFiles, layer1Affected, new SelectorOptions(options.ForceAll, options.ForceAllReason), layer1.AttributedPaths, layer1.Paths);
 
        trace.EnterStage("write summary and outputs");
        WriteSummary(options, result, allTestProjects, changedFiles, layer1Affected, excludedFiles);
        WriteJobBooleans(options, result);
        WriteSelectionComment(options, result, allTestProjects, changedFiles);
        WriteSelectionJson(options, result, allTestProjects, changedFiles, layer1Affected, excludedFiles);
 
        // Enforce + a non-ALL selection restricts the downstream enumerate-tests build to the selected
        // test projects via an OverrideProjectToBuild props file. A selection with ZERO buildable test
        // projects (e.g. an extension-only / polyglot-only change whose only targets are non-.NET jobs)
        // must NOT write an empty restriction: an empty ProjectToBuild makes the enumerate build fall
        // back to the whole solution (and fail on non-test tooling projects). Instead we signal
        // has_dotnet_tests=false so tests.yml skips enumerate-tests entirely and emits an empty matrix;
        // the selected Layer 2 jobs still run via the run_* booleans. In audit mode, or when the
        // selection is ALL, write nothing so enumerate-tests enumerates the full matrix unchanged.
        var buildableSelected = result.TestProjects.Count(testProjectsByName.ContainsKey);
        var restrictBuild = options.Enforce && !result.SelectsAll && options.BeforeBuildProps is not null && buildableSelected > 0;
        if (restrictBuild)
        {
            WriteBeforeBuildProps(options.BeforeBuildProps!, result.TestProjects, testProjectsByName);
        }
 
        // Tell the workflow whether a restriction props file was written (and where). Empty means
        // "enumerate everything" -- the workflow then omits /p:BeforeBuildPropsPath. Named after its
        // payload (an OverrideProjectToBuild item set) rather than the generic BeforeBuildPropsPath
        // hook it rides on, which other writers (ToolsetBootstrap.props, ClassModeTestProjects.props)
        // also use.
        WriteGitHubOutput("project_override_props", restrictBuild ? options.BeforeBuildProps! : "");
 
        // has_dotnet_tests is false only for an enforcing, non-ALL selection that selects no buildable
        // test project. tests.yml gates enumerate-tests on it: false skips the build and yields an empty
        // .NET test matrix (no test shards run) while the run_* job booleans still gate the non-.NET
        // jobs. ALL and audit always enumerate the full matrix.
        var hasDotnetTests = !options.Enforce || result.SelectsAll || buildableSelected > 0;
        WriteGitHubOutput("has_dotnet_tests", hasDotnetTests ? "true" : "false");
 
        return 0;
    }
 
    // Repo-relative, '/'-separated paths of the test projects in Aspire.slnx, keyed by project name
    // (the .csproj base name == the matrix projectName == the map's test: target). The universe is
    // the tests/<Name>/<Name>.csproj projects whose name ends in ".Tests"; the other tests/ projects
    // (Aspire.TestUtilities, TestingAppHost1, testproject, ...) are shared fixtures/helpers, not test
    // projects, and are excluded so they are never selected or enumerated on their own.
    private static IReadOnlyDictionary<string, string> LoadTestProjects(string slnxPath)
    {
        if (!File.Exists(slnxPath))
        {
            throw new InvalidOperationException($"Solution was not found: {slnxPath}");
        }
 
        var slnx = File.ReadAllText(slnxPath);
        var map = new Dictionary<string, string>(StringComparer.Ordinal);
        // <Project Path="tests/Foo.Tests/Foo.Tests.csproj" /> -- normalize separators, keep tests/ + .Tests.
        foreach (System.Text.RegularExpressions.Match m in
                 System.Text.RegularExpressions.Regex.Matches(slnx, "Path=\"([^\"]+\\.csproj)\""))
        {
            var relPath = m.Groups[1].Value.Replace('\\', '/');
            if (!relPath.StartsWith("tests/", StringComparison.Ordinal))
            {
                continue;
            }
 
            var name = Path.GetFileNameWithoutExtension(relPath);
            if (name.EndsWith(".Tests", StringComparison.Ordinal))
            {
                map[name] = relPath;
            }
        }
 
        return map;
    }
 
    // Writes the MSBuild props file that eng/Build.props imports via $(BeforeBuildPropsPath): an
    // OverrideProjectToBuild item per selected test project, which REPLACES the default ProjectToBuild
    // set so the `-test` build (and thus the canonical test-matrix enumeration) covers only these.
    // Same shape as eng/scripts/generate-specialized-test-projects-list.sh emits for quarantine/outerloop.
    private static void WriteBeforeBuildProps(
        string path,
        IReadOnlySet<string> selectedTestProjects,
        IReadOnlyDictionary<string, string> testProjectsByName)
    {
        var directory = Path.GetDirectoryName(Path.GetFullPath(path));
        if (!string.IsNullOrEmpty(directory))
        {
            Directory.CreateDirectory(directory);
        }
 
        var sb = new StringBuilder();
        sb.AppendLine("<Project>");
        sb.AppendLine("  <ItemGroup>");
        foreach (var name in selectedTestProjects.OrderBy(n => n, StringComparer.Ordinal))
        {
            // A selected name not in the slnx test-project set is not a buildable test project (e.g. a
            // production project name from project_rules); it contributes no OverrideProjectToBuild item.
            if (testProjectsByName.TryGetValue(name, out var relPath))
            {
                sb.AppendLine(CultureInfo.InvariantCulture, $"    <OverrideProjectToBuild Include=\"$(RepoRoot){relPath}\" />");
            }
        }
        sb.AppendLine("  </ItemGroup>");
        sb.AppendLine("</Project>");
 
        File.WriteAllText(path, sb.ToString());
    }
 
    // Appends a single key=value line to $GITHUB_OUTPUT (when set), so the workflow can read it as a
    // step output. Falls back to stderr for local runs.
    private static void WriteGitHubOutput(string key, string value)
    {
        var githubOutput = Environment.GetEnvironmentVariable("GITHUB_OUTPUT");
        var line = $"{key}={value}";
        if (githubOutput is not null)
        {
            File.AppendAllLines(githubOutput, new[] { line });
        }
        else
        {
            Console.Error.WriteLine(line);
        }
    }
 
    // Layer 2 needs the actual changed file paths (it glob-matches them), independent of the
    // project-name closure that Layer 1 produces.
    // Resolves the merge-base (common ancestor / branch point) of the base ref and the head -- the commit
    // the PR diff is taken FROM, so only the PR's own commits count (commits that landed on the base
    // branch after the branch point share this ancestor and so produce no diff). When --to is omitted
    // (local working-tree run) the head side is HEAD. Returns null (with a human-readable reason in
    // <paramref name="error"/>) when git can't find a common ancestor or the command fails; the caller
    // then degrades to a fail-SAFE run-all rather than blocking the PR. The CI action deepens the shallow
    // checkout to make this commit reachable, so a null here means even that didn't help.
    private static string? TryResolveMergeBase(string repoRoot, string from, string? to, out string error)
    {
        var head = to ?? "HEAD";
        var stdout = RunProcess("git", new[] { "merge-base", from, head }, repoRoot, out var exitCode, out var stderr);
        if (exitCode != 0)
        {
            // `git merge-base` exits 1 with no output when the two commits share no merge base; a higher
            // code (or stderr) is a real git error (bad object, not a repo, ...). Both degrade to run-all.
            error = $"git merge-base {from} {head} exited {exitCode}{(stderr.Trim().Length > 0 ? $": {stderr.Trim()}" : " (no common ancestor)")}";
            return null;
        }
 
        var mergeBase = stdout.Trim();
        if (mergeBase.Length == 0)
        {
            error = $"git merge-base {from} {head} found no common ancestor";
            return null;
        }
 
        error = string.Empty;
        return mergeBase;
    }
 
    // Emits a GitHub Actions warning annotation. A `::warning::` line on stdout is surfaced as an
    // annotation by the runner (https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#setting-a-warning-message);
    // locally it is just a printed line. stdout is free for this -- step outputs go to $GITHUB_OUTPUT.
    private static void WriteWarning(string message)
    {
        Console.Out.WriteLine($"::warning::{message}");
    }
 
    private static IReadOnlyCollection<string> ResolveChangedFiles(RunOptions options)
    {
        if (options.ChangedFilesPath is not null)
        {
            return File.ReadAllLines(options.ChangedFilesPath)
                .Select(l => l.Trim())
                .Where(l => l.Length > 0)
                .ToList();
        }
 
        if (options.From is null)
        {
            throw new InvalidOperationException("Provide either --changed-files or --from (with optional --to).");
        }
 
        // git emits forward-slash, repo-relative paths on every OS, which is exactly what the map
        // globs expect. `<from> <to>` diffs the two refs; omitting <to> diffs against the work tree.
        // From has already been rebound to the merge-base (see RunCore), so this is a branch-point..head
        // diff -- the PR's own changes -- not a base-tip..head diff.
        // --no-renames decomposes a rename into a delete (old path) + add (new path) so BOTH sides
        // are glob-matched. Without it, git's default rename detection reports only the destination,
        // so a file moved OUT of a mapped directory (e.g. eng/clipack/foo -> eng/elsewhere) would hide
        // the old path and silently skip that directory's mapped tests. Layer 1 captures both sides via
        // -M; this keeps Layer 2 consistent.
        var range = options.To is null ? new[] { options.From } : new[] { options.From, options.To };
        // -c core.quotePath=false: with the default (true), git octal-escapes and double-quotes any
        // path with non-ASCII bytes (e.g. "src/caf\303\251.cs"). That escaped string is not the real
        // repo-relative path, so the map globs below would silently miss it. Forcing quotePath off makes
        // git emit the literal UTF-8 path, which is what the globs expect. (Layer 1's diff does the same.)
        var args = new List<string> { "-c", "core.quotePath=false", "diff", "--name-only", "--no-renames" };
        args.AddRange(range);
 
        var stdout = RunProcess("git", args, options.RepoRoot, out var exitCode, out var stderr);
        if (exitCode != 0)
        {
            throw new InvalidOperationException($"git diff failed ({exitCode}): {stderr}");
        }
 
        return stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
    }
 
    // Repo-relative, '/'-separated directories of every project in Aspire.slnx -- the universe the
    // Layer 1 graph walks. The selector treats a changed file under one of these dirs as
    // "Layer-1-owned" (attributed by the graph), so it never triggers the run-all fallback.
    private static IReadOnlyCollection<string> LoadProjectDirectories(string slnxPath)
    {
        if (!File.Exists(slnxPath))
        {
            return Array.Empty<string>();
        }
 
        var slnx = File.ReadAllText(slnxPath);
        // <Project Path="src/Foo/Foo.csproj" /> -- normalize separators and take the directory.
        return System.Text.RegularExpressions.Regex.Matches(slnx, "Path=\"([^\"]+\\.csproj)\"")
            .Select(m => m.Groups[1].Value.Replace('\\', '/'))
            .Select(p => p.Contains('/', StringComparison.Ordinal) ? p[..p.LastIndexOf('/')] : p)
            .Where(d => d.Length > 0)
            .ToHashSet(StringComparer.Ordinal);
    }
 
    // Layer 1: build the MSBuild ProjectGraph from Aspire.slnx (HEAD-only) and report every project
    // hit by the diff — the union of *changed* (incl. cross-project linked-file consumers) and
    // *affected* (downstream dependents). We return the full set of project names: the selector
    // intersects the test projects into the matrix and matches the production projects against
    // project_rules. See GraphAffectedProjects for why this replaced dotnet-affected.
    private static AffectedResult RunLayer1(RunOptions options, ChangedFileFilter filter, SelectionTrace trace)
    {
        try
        {
            // MSBuildLocator must register the SDK's MSBuild assemblies before any Microsoft.Build type
            // is loaded. GraphAffectedProjects.Compute is the only thing that references the engine, and
            // it is not JITted until the call below, so registering here (once) is in time.
            GraphAffectedProjects.EnsureMSBuildRegistered();
 
            return GraphAffectedProjects.Compute(options.RepoRoot, options.SlnxPath, options.From, options.To, options.ChangedFilesPath, filter, trace);
        }
        catch (Exception ex)
        {
            return Layer1Failed(ex);
        }
    }
 
    // Layer 1 is not optional: under-selecting would silently skip real tests. Any failure to compute
    // the graph closure is fatal — surface it rather than masking it behind an empty (under-selecting)
    // result. Preserve the original exception as InnerException so the crash diagnostics' stack trace
    // points at where the MSBuild graph actually failed, not at this wrapper.
    private static AffectedResult Layer1Failed(Exception inner) =>
        throw new InvalidOperationException($"Layer 1 (affected-projects graph) failed: {inner.Message}", inner);
 
    // On an unhandled crash, emit a diagnostics block to the step summary (and stderr) so the failure is
    // debuggable from the run alone: which stage it died in, the concrete item in hand (when known), the
    // exception, and the exact inputs needed to re-run locally. The block is appended even when the
    // summary already has partial content; the caller rethrows so the step still fails.
    private static void WriteFailureDiagnostics(RunOptions options, SelectionTrace trace, Exception ex)
    {
        var changeSource = options.ChangedFilesPath is not null
            ? $"changed-files {options.ChangedFilesPath}"
            : options.From is not null
                ? $"git diff {options.From}{(options.To is null ? " (working tree)" : $"..{options.To}")}"
                : "(none -- force-all or unset)";
 
        var sb = new StringBuilder();
        sb.AppendLine("## SelectTests FAILED");
        sb.AppendLine();
        sb.AppendLine("The selector crashed before completing. The CI step fails by design — a crash must");
        sb.AppendLine("never be downgraded to a silent under-selection.");
        sb.AppendLine();
        sb.AppendLine(CultureInfo.InvariantCulture, $"- failing stage: {trace.Stage}");
        if (!string.IsNullOrEmpty(trace.Item))
        {
            sb.AppendLine(CultureInfo.InvariantCulture, $"- processing: `{trace.Item}`");
        }
 
        // Type + message on one line; the full stack follows in a collapsible for the deep cases.
        sb.AppendLine(CultureInfo.InvariantCulture, $"- error: {ex.GetType().Name}: {ex.Message}");
        sb.AppendLine();
        sb.AppendLine("### Inputs (to reproduce)");
        sb.AppendLine();
        sb.AppendLine(CultureInfo.InvariantCulture, $"- mode: {(options.Enforce ? "enforcing" : "audit")}");
        sb.AppendLine(CultureInfo.InvariantCulture, $"- change source: {changeSource}");
        sb.AppendLine(CultureInfo.InvariantCulture, $"- repo root: {options.RepoRoot}");
        sb.AppendLine(CultureInfo.InvariantCulture, $"- slnx: {options.SlnxPath}");
        sb.AppendLine(CultureInfo.InvariantCulture, $"- map: {options.MapPath}");
        sb.AppendLine(CultureInfo.InvariantCulture, $"- force-all: {options.ForceAll}");
        sb.AppendLine(CultureInfo.InvariantCulture, $"- skip-layer1: {options.SkipLayer1}");
        sb.AppendLine();
        sb.AppendLine("<details><summary>stack trace</summary>");
        sb.AppendLine();
        sb.AppendLine("```");
        sb.AppendLine(ex.ToString());
        sb.AppendLine("```");
        sb.AppendLine();
        sb.AppendLine("</details>");
        sb.AppendLine();
 
        var markdown = sb.ToString();
 
        // Echo to stderr first and unconditionally: it is the most reliable surface (a local run has no
        // step summary) and must never be skipped by a failure writing the summary file.
        Console.Error.Write(markdown);
 
        // The step summary write is best-effort. This runs from the top-level catch handler, so a failure
        // here (e.g. GITHUB_STEP_SUMMARY points at an unwritable path) must NOT throw a new exception that
        // masks the original failure the caller is about to rethrow.
        var summaryPath = Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY");
        if (summaryPath is not null)
        {
            try
            {
                File.AppendAllText(summaryPath, markdown);
            }
            catch (Exception writeEx)
            {
                Console.Error.WriteLine($"[SelectTests] could not write diagnostics to GITHUB_STEP_SUMMARY: {writeEx.Message}");
            }
        }
    }
 
    // The non-.NET job gate, emitted as ONE JSON object under the `selection` output instead of one
    // run_* output per job, so neither this tool nor the select-tests action enumerates the concrete
    // jobs. tests.yml's setup_for_tests unpacks it once into per-job outputs, e.g.
    //   run_extension_e2e: ${{ fromJSON(steps.select_tests.outputs.selection).run_extension_e2e }}
    // Keyed by run_<job> (strip "job:", '-' -> '_'); values are real JSON booleans. A single object
    // means a NEW trigger-map job needs no change here or in the action -- only tests.yml's unpack +
    // the new job's if:. Every job the map knows is present, so an unselected one reads false (not
    // missing -> never a silently-empty gate).
    // In audit mode (default) every value is forced true because enumerate-tests still runs the FULL
    // matrix: audit computes and reports the real selection (see WriteSummary) but runs everything, so
    // the non-.NET jobs must not be gated off either.
    private static void WriteJobBooleans(RunOptions options, SelectionResult result)
    {
        var allJobs = TriggerMap.Load(options.MapPath).AllJobTokens().ToHashSet(StringComparer.Ordinal);
 
        // Audit mode emits run-all (every job true), mirroring the unfiltered matrix.
        var auditRunAll = !options.Enforce;
 
        var selection = new JsonObject();
        foreach (var job in allJobs.OrderBy(j => j, StringComparer.Ordinal))
        {
            var name = "run_" + job["job:".Length..].Replace('-', '_');
            // On ALL (or in audit mode), every job runs too.
            var value = auditRunAll || result.SelectsAll || result.Jobs.Contains(job);
            selection[name] = value;
        }
 
        // ToJsonString() is single-line, which the key=value $GITHUB_OUTPUT format requires.
        WriteGitHubOutput("selection", selection.ToJsonString());
    }
 
    // Writes the durable, machine-readable record of the selection to SELECT_TESTS_JSON_FILE (when set):
    // the mode, the inputs needed to reproduce, and EVERY selected test/job with its full per-item cause
    // list (including the Layer 1 decision path). This is the artifact a maintainer downloads weeks later
    // to answer "why did this run?" without re-running CI, and that tests can assert against. Built with
    // JsonObject (not a serializer) to avoid reflection/trimming concerns and to mirror WriteJobBooleans.
    private static void WriteSelectionJson(
        RunOptions options,
        SelectionResult result,
        IReadOnlySet<string> allTestProjects,
        IReadOnlyCollection<string> changedFiles,
        IReadOnlyCollection<string> layer1Affected,
        IReadOnlyCollection<string> excludedFiles)
    {
        var jsonPath = Environment.GetEnvironmentVariable("SELECT_TESTS_JSON_FILE");
        if (string.IsNullOrEmpty(jsonPath))
        {
            return;
        }
 
        var changeSource = options.ChangedFilesPath is not null
            ? $"changed-files {options.ChangedFilesPath}"
            : options.From is not null
                ? $"git diff {options.From}{(options.To is null ? " (working tree)" : $"..{options.To}")}"
                : "(none -- force-all or unset)";
 
        var root = new JsonObject
        {
            ["schemaVersion"] = 1,
            ["mode"] = options.Enforce ? "enforcing" : "audit",
            ["selectsAll"] = result.SelectsAll,
            ["escalationReason"] = result.EscalationReason,
            ["inputs"] = new JsonObject
            {
                ["changeSource"] = changeSource,
                ["repoRoot"] = options.RepoRoot,
                ["slnx"] = options.SlnxPath,
                ["map"] = options.MapPath,
                ["forceAll"] = options.ForceAll,
                ["skipLayer1"] = options.SkipLayer1,
            },
            ["changedFiles"] = ToJsonArray(changedFiles.OrderBy(f => f, StringComparer.Ordinal)),
            ["excludedFiles"] = ToJsonArray(excludedFiles.OrderBy(f => f, StringComparer.Ordinal)),
            ["unattributedFiles"] = ToJsonArray(result.UnmatchedFiles.OrderBy(f => f, StringComparer.Ordinal)),
            ["layer1AffectedProjects"] = ToJsonArray(layer1Affected.OrderBy(p => p, StringComparer.Ordinal)),
            ["testProjects"] = ItemsWithCauses(result.TestProjects, result.TestCauses),
            // The unselected matrix projects, so the artifact records what was skipped, not only what ran.
            ["skippedTestProjects"] = ToJsonArray(
                allTestProjects.Except(result.TestProjects, StringComparer.Ordinal).OrderBy(p => p, StringComparer.Ordinal)),
            ["jobs"] = ItemsWithCauses(result.Jobs, result.JobCauses),
        };
 
        var dir = Path.GetDirectoryName(Path.GetFullPath(jsonPath));
        if (!string.IsNullOrEmpty(dir))
        {
            Directory.CreateDirectory(dir);
        }
 
        File.WriteAllText(jsonPath, root.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
 
        static JsonArray ToJsonArray(IEnumerable<string> values)
        {
            var array = new JsonArray();
            foreach (var value in values)
            {
                array.Add(value);
            }
 
            return array;
        }
 
        // One object per selected item: its name plus the ordered cause list. The key set of `causes`
        // IS the selected set, so iterate the selected names and look each up (empty causes under ALL).
        static JsonArray ItemsWithCauses(IReadOnlySet<string> items, IReadOnlyDictionary<string, IReadOnlyList<Cause>> causes)
        {
            var array = new JsonArray();
            foreach (var name in items.OrderBy(n => n, StringComparer.Ordinal))
            {
                var causeArray = new JsonArray();
                if (causes.TryGetValue(name, out var list))
                {
                    foreach (var cause in list.OrderBy(c => CausePriority(c.Kind)))
                    {
                        JsonArray? path = null;
                        if (cause.Path is { Count: > 0 })
                        {
                            path = new JsonArray();
                            foreach (var hop in cause.Path)
                            {
                                path.Add(hop);
                            }
                        }
 
                        causeArray.Add(new JsonObject
                        {
                            ["kind"] = cause.Kind.ToString(),
                            ["trigger"] = cause.Trigger,
                            ["reason"] = cause.Reason,
                            ["path"] = path,
                        });
                    }
                }
 
                array.Add(new JsonObject
                {
                    ["name"] = name,
                    ["causes"] = causeArray,
                });
            }
 
            return array;
        }
    }
 
    // Builds the sticky PR comment. Structure: WHAT runs first (the flat lists of selected jobs and
    // test projects, so a reader sees the full impact at a glance even with many changed files), then
    // HOW it was chosen (the per-trigger grouping). Grouping by trigger -- rather than listing each
    // project with its reason appended -- keeps the "why" readable when one change fans out to dozens
    // of projects: the reason is stated once per trigger, not repeated per project. Deliberately omits
    // the step-summary audit detail (options, changed-file list, would-have-skipped). Written to
    // SELECT_TESTS_COMMENT_FILE when set.
    private static void WriteSelectionComment(
        RunOptions options,
        SelectionResult result,
        IReadOnlySet<string> allTestProjects,
        IReadOnlyCollection<string> changedFiles)
    {
        var commentPath = Environment.GetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE");
        if (string.IsNullOrEmpty(commentPath))
        {
            return;
        }
 
        var sb = new StringBuilder();
        // Audit mode is advisory (the full matrix runs regardless), so call it out in the title;
        // enforcing is the normal case and needs no qualifier.
        sb.AppendLine(options.Enforce ? "## Tests selector" : "## Tests selector (audit mode)");
        sb.AppendLine();
 
        if (!options.Enforce)
        {
            // Audit mode runs the full matrix and every job regardless of the selection, so the lists
            // below are advisory: they are what selective CI WOULD run once ENFORCE_SELECTION is on.
            // Say so explicitly, otherwise a reader could mistake the subset for what actually ran.
            sb.AppendLine("_The full test matrix and all jobs still run in audit mode. The tests and jobs below are what selective CI **would** run under enforcement._");
            sb.AppendLine();
        }
 
        if (result.SelectsAll)
        {
            sb.AppendLine(CultureInfo.InvariantCulture, $"**Runs the full test matrix + all jobs (ALL)** — {result.EscalationReason}");
            sb.AppendLine();
            WriteCommentFile(commentPath, sb.ToString());
            return;
        }
 
        var tests = result.TestProjects.OrderBy(p => p, StringComparer.Ordinal).ToList();
        // Keep the full job: tokens for cause lookup; strip the prefix only for display.
        var jobs = result.Jobs.OrderBy(j => j, StringComparer.Ordinal).ToList();
 
        var fileWord = changedFiles.Count == 1 ? "changed file" : "changed files";
        var jobWord = jobs.Count == 1 ? "job" : "jobs";
        sb.AppendLine(CultureInfo.InvariantCulture,
            $"**{tests.Count} / {allTestProjects.Count} test projects · {jobs.Count} {jobWord}**, from {changedFiles.Count} {fileWord}.");
        sb.AppendLine();
 
        // WHAT runs -- the flat lists up front. A reviewer scanning a large selection sees the complete
        // set of projects and jobs without reading the per-trigger breakdown below. Test projects come
        // first because they are the primary thing a reviewer cares about; the non-.NET jobs follow.
        sb.AppendLine(CultureInfo.InvariantCulture, $"### Selected test projects ({tests.Count} / {allTestProjects.Count})");
        sb.AppendLine();
        sb.AppendLine(tests.Count == 0
            ? "_none — no .NET test projects run for this change._"
            : string.Join(", ", tests.Select(t => $"`{t}`")));
        sb.AppendLine();
 
        sb.AppendLine(CultureInfo.InvariantCulture, $"### Selected jobs ({jobs.Count})");
        sb.AppendLine();
        sb.AppendLine(jobs.Count == 0
            ? "_none_"
            : string.Join(", ", jobs.Select(j => $"`{StripJobPrefix(j)}`")));
        sb.AppendLine();
 
        // HOW it was chosen -- the per-trigger grouping.
        AppendSelectionRationale(sb, result, tests, jobs);
 
        sb.AppendLine();
        WriteCommentFile(commentPath, sb.ToString());
    }
 
    // Renders the "how these were chosen" section: every selected test project grouped under each
    // trigger (changed file / affected project / derived test) that pulled it in, plus a per-job
    // reasons table. Full attribution is preserved -- a project selected by several triggers appears
    // under each -- so nothing is hidden, but each trigger's reason is written once instead of being
    // repeated on every project line.
    private static void AppendSelectionRationale(
        StringBuilder sb,
        SelectionResult result,
        IReadOnlyList<string> tests,
        IReadOnlyList<string> jobs)
    {
        sb.AppendLine("---");
        sb.AppendLine();
        // Collapse the rationale by default: the comment leads with WHAT runs (the flat lists above),
        // and a reviewer who wants to know WHY expands this. A blank line after </summary> is required
        // so GitHub renders the markdown (headings, table, nested <details>) inside the block.
        sb.AppendLine("<details>");
        sb.AppendLine("<summary>How these were chosen — grouped by what changed</summary>");
        sb.AppendLine();
 
        // Invert TestCauses (project -> causes) into trigger groups (trigger -> projects). Causes that
        // share a trigger collapse into one group: a changed file and its graph fan-out group together
        // so a single source edit shows its whole closure under one heading.
        var groups = new Dictionary<string, Dictionary<string, Cause>>(StringComparer.Ordinal);
        foreach (var project in tests)
        {
            if (!result.TestCauses.TryGetValue(project, out var causes))
            {
                continue;
            }
 
            foreach (var cause in causes)
            {
                var key = CauseGroupKey(cause);
                if (!groups.TryGetValue(key, out var members))
                {
                    members = new Dictionary<string, Cause>(StringComparer.Ordinal);
                    groups[key] = members;
                }
 
                // If the same project reaches a group via more than one cause, keep the most direct one
                // (lowest priority) so its bucket/hop annotation reflects the closest path.
                if (!members.TryGetValue(project, out var existing) || CausePriority(cause.Kind) < CausePriority(existing.Kind))
                {
                    members[project] = cause;
                }
            }
        }
 
        // Largest group first -- the biggest fan-out is the most useful thing to see when triaging a
        // large selection. Ties broken by key for a stable order.
        var orderedGroups = groups
            .OrderByDescending(g => g.Value.Count)
            .ThenBy(g => g.Key, StringComparer.Ordinal)
            .ToList();
 
        // Headline: when one trigger drives a large share of a big selection, call it out so the reader
        // immediately sees where the bulk of the work comes from.
        if (orderedGroups.Count > 0 && tests.Count >= 10 && orderedGroups[0].Value.Count >= 5)
        {
            var top = orderedGroups[0];
            sb.AppendLine(CultureInfo.InvariantCulture,
                $"⚠️ {top.Value.Count} of the {tests.Count} selected test projects come from a single change — {CauseGroupDescriptor(top.Key)}.");
            sb.AppendLine();
        }
 
        foreach (var (key, members) in orderedGroups)
        {
            sb.AppendLine(CauseGroupHeader(key));
 
            // Within a group, separate the projects whose change is direct (the file lives in them, or
            // a path rule named them) from those reached transitively through the project graph -- the
            // two have different review weight, and stating the mechanism once per bucket avoids
            // repeating it per project.
            var direct = members.Where(m => m.Value.Kind is CauseKind.Convention or CauseKind.PathRule).Select(m => m.Key).OrderBy(p => p, StringComparer.Ordinal).ToList();
            var graph = members.Where(m => m.Value.Kind is CauseKind.Layer1Graph).OrderBy(m => m.Key, StringComparer.Ordinal).ToList();
            var other = members.Where(m => m.Value.Kind is not (CauseKind.Convention or CauseKind.PathRule or CauseKind.Layer1Graph)).Select(m => m.Key).OrderBy(p => p, StringComparer.Ordinal).ToList();
 
            if (direct.Count > 0)
            {
                sb.AppendLine(CultureInfo.InvariantCulture, $"{RenderProjectList(direct.Select(p => $"`{p}`").ToList(), "directly")}");
            }
            if (graph.Count > 0)
            {
                sb.AppendLine(CultureInfo.InvariantCulture, $"{RenderProjectList(graph.Select(MemberWithHops).ToList(), "via the project graph")}");
            }
            if (other.Count > 0)
            {
                sb.AppendLine(CultureInfo.InvariantCulture, $"{RenderProjectList(other.Select(p => $"`{p}`").ToList(), other.Count == 1 ? "test" : "tests")}");
            }
            sb.AppendLine();
        }
 
        sb.AppendLine("#### Job reasons");
        sb.AppendLine();
        if (jobs.Count == 0)
        {
            sb.AppendLine("_none_");
        }
        else
        {
            sb.AppendLine("| Job | Triggered by |");
            sb.AppendLine("|---|---|");
            foreach (var token in jobs)
            {
                sb.AppendLine(CultureInfo.InvariantCulture, $"| `{StripJobPrefix(token)}` | {JobCausesText(result.JobCauses, token)} |");
            }
        }
 
        sb.AppendLine();
        sb.AppendLine("</details>");
    }
 
    private static string StripJobPrefix(string token)
        => token.StartsWith("job:", StringComparison.Ordinal) ? token["job:".Length..] : token;
 
    // Renders a bucket's projects. Small lists go inline (scannable); large ones collapse into a
    // <details> so a 30-project fan-out doesn't dominate the comment.
    private static string RenderProjectList(IReadOnlyList<string> items, string label)
    {
        const int InlineLimit = 12;
        var joined = string.Join(", ", items);
        return items.Count <= InlineLimit
            ? $"**{items.Count}** {label}: {joined}"
            : $"**{items.Count}** {label}\n<details><summary>show {items.Count}</summary>\n\n{joined}\n</details>";
    }
 
    // A graph-selected project, annotated with its hop count when the change reached it through more
    // than one project edge (a near vs. far dependency is useful review signal).
    private static string MemberWithHops(KeyValuePair<string, Cause> member)
    {
        if (member.Value is { Kind: CauseKind.Layer1Graph, Path: { Count: > 0 } path })
        {
            // path = [seedFile, project0, ..., affectedTest]; edges between projects = count - 2.
            var hops = path.Count - 2;
            if (hops > 1)
            {
                return $"`{member.Key}` ({hops} hops)";
            }
        }
 
        return $"`{member.Key}`";
    }
 
    // The trigger a cause groups under. Direct file matches and graph fan-out from the same seed file
    // share a "file:" key so they render under one heading; affected-project and derived-test causes
    // get their own keyed groups.
    private static string CauseGroupKey(Cause cause) => cause.Kind switch
    {
        CauseKind.Convention or CauseKind.PathRule => $"file:{cause.Trigger}",
        CauseKind.Layer1Graph => $"file:{(cause.Path is { Count: > 0 } path ? path[0] : "(changed source)")}",
        CauseKind.AffectedProject => $"affected:{cause.Trigger}",
        CauseKind.DerivedFromTest => $"derived:{cause.Trigger}",
        _ => $"other:{cause.Trigger}",
    };
 
    // The heading shown for a group key.
    private static string CauseGroupHeader(string key)
    {
        var sep = key.IndexOf(':', StringComparison.Ordinal);
        var (kind, value) = (key[..sep], key[(sep + 1)..]);
        return kind switch
        {
            "file" => $"**{FileEmoji(value)} `{value}`** *({FileChangeKind(value)})*",
            "affected" => $"**📦 affected project `{value}`**",
            "derived" => $"**🧪 derived from test `{value}`**",
            _ => $"**`{value}`**",
        };
    }
 
    // A one-line descriptor of a group key, for the headline call-out.
    private static string CauseGroupDescriptor(string key)
    {
        var sep = key.IndexOf(':', StringComparison.Ordinal);
        var (kind, value) = (key[..sep], key[(sep + 1)..]);
        return kind switch
        {
            "file" => $"`{value}`",
            "affected" => $"affected project `{value}`",
            "derived" => $"derived from test `{value}`",
            _ => $"`{value}`",
        };
    }
 
    private static string FileChangeKind(string path)
        => path.StartsWith("src/", StringComparison.Ordinal) ? "changed source"
            : path.StartsWith("tests/", StringComparison.Ordinal) ? "changed test"
            : "changed";
 
    private static string FileEmoji(string path)
        => path.StartsWith("src/", StringComparison.Ordinal) ? "🔧"
            : path.StartsWith("tests/", StringComparison.Ordinal) ? "🧪"
            : "📄";
 
    private static void WriteCommentFile(string commentPath, string content)
    {
        var dir = Path.GetDirectoryName(Path.GetFullPath(commentPath));
        if (!string.IsNullOrEmpty(dir))
        {
            Directory.CreateDirectory(dir);
        }
        File.WriteAllText(commentPath, content);
    }
 
    // The full set of causes for one job, de-duplicated and priority-ordered, for the job-reasons
    // table. Every cause is shown (no truncation) so a reviewer sees exactly what pulled the job in.
    //
    // A job is often pulled in by several INDEPENDENT triggers (e.g. a changed path rule AND an affected
    // production project AND a selected test that derives it). Comma-joining those on one line reads as a
    // single causal chain -- "affected project Aspire.Cli, selected test Aspire.Cli.Tests" looks like one
    // flows through the other, when they are unrelated reasons. So render each independent trigger as its
    // own bulleted line, collapsing only the homogeneous changed-file causes (every path that matched a
    // rule/convention) into one comma-joined segment. A single trigger needs no bullet.
    private static string JobCausesText(IReadOnlyDictionary<string, IReadOnlyList<Cause>> causes, string key)
    {
        if (!causes.TryGetValue(key, out var list) || list.Count == 0)
        {
            return "_unattributed_";
        }
 
        var ordered = list.OrderBy(c => CausePriority(c.Kind)).ToList();
 
        // Changed-file causes (a path matched a convention/path rule) are the same KIND of reason, so
        // they read fine comma-joined as one segment. Distinct kinds (affected project, selected test)
        // each get their own line.
        var fileCauses = ordered
            .Where(c => c.Kind is CauseKind.Convention or CauseKind.PathRule)
            .Select(ShortCause)
            .Distinct()
            .ToList();
        var otherCauses = ordered
            .Where(c => c.Kind is not (CauseKind.Convention or CauseKind.PathRule))
            .Select(ShortCause)
            .Distinct()
            .ToList();
 
        var segments = new List<string>();
        if (fileCauses.Count > 0)
        {
            segments.Add(string.Join(", ", fileCauses));
        }
        segments.AddRange(otherCauses);
 
        // A single trigger reads fine on its own; 2+ get "• " bullets so they cannot run together (a
        // literal bullet glyph -- a markdown "-" list does not render inside a GitHub table cell).
        return segments.Count == 1
            ? segments[0]
            : string.Join("<br>", segments.Select(s => $"{s}"));
    }
 
    private static void WriteSummary(
        RunOptions options,
        SelectionResult result,
        IReadOnlySet<string> allTestProjects,
        IReadOnlyCollection<string> changedFiles,
        IReadOnlyCollection<string> layer1Affected,
        IReadOnlyCollection<string> excludedFiles)
    {
        var sb = new StringBuilder();
        sb.AppendLine("## SelectTests");
        sb.AppendLine();
 
        // Options the run was invoked with, so an audit reader can see exactly what produced the
        // selection below (and reproduce it).
        var source = options.ChangedFilesPath is not null
            ? $"changed-files {options.ChangedFilesPath}"
            : $"git diff {options.From}{(options.To is null ? " (working tree)" : $"..{options.To}")}";
        sb.AppendLine("### Options");
        sb.AppendLine(CultureInfo.InvariantCulture, $"- mode: {(options.Enforce ? "enforcing" : "audit (advisory: the full matrix + all jobs run regardless of the selection below)")}");
        sb.AppendLine(CultureInfo.InvariantCulture, $"- change source: {source}");
        var forceAllDetail = options.ForceAll && options.ForceAllReason is not null
            ? $"True — fail-safe run-all because {options.ForceAllReason}"
            : $"{options.ForceAll} (kill switch)";
        sb.AppendLine(CultureInfo.InvariantCulture, $"- force-all: {forceAllDetail}");
        sb.AppendLine(CultureInfo.InvariantCulture, $"- layer 1 (affected-projects graph): {(options.SkipLayer1 || options.ForceAll ? "skipped" : $"{layer1Affected.Count} affected project(s) (production + test)")}");
        sb.AppendLine();
 
        // The changed files that came in, so a reader can tell which inputs drove the selection.
        sb.AppendLine(CultureInfo.InvariantCulture, $"### Changed files ({changedFiles.Count})");
        sb.AppendLine();
        sb.AppendLine("<details><summary>show</summary>");
        sb.AppendLine();
        foreach (var file in changedFiles.OrderBy(f => f, StringComparer.Ordinal))
        {
            sb.AppendLine(CultureInfo.InvariantCulture, $"- `{file}`");
        }
        sb.AppendLine();
        sb.AppendLine("</details>");
        sb.AppendLine();
 
        // Files dropped by the pre-filter (exclude globs): docs/skills/instructions and other
        // no-CI-needed loose files. Shown so an audit reader can see they were intentionally removed
        // from BOTH layers' input (not silently un-attributed).
        if (excludedFiles.Count > 0)
        {
            sb.AppendLine(CultureInfo.InvariantCulture, $"### Pre-filtered (excluded) files ({excludedFiles.Count})");
            sb.AppendLine();
            sb.AppendLine("Dropped before selection by the prefilter (CI skip-gate patterns; no CI impact):");
            sb.AppendLine();
            foreach (var file in excludedFiles.OrderBy(f => f, StringComparer.Ordinal))
            {
                sb.AppendLine(CultureInfo.InvariantCulture, $"- `{file}`");
            }
            sb.AppendLine();
        }
 
        // Files no layer accounted for: matched no curated rule (Layer 2), not ignored, and not a
        // project-owned source file (Layer 1, via the Aspire.slnx project dirs). A src/** file here
        // forced the run-all fallback; a non-src file here is only an audit signal that a loose,
        // non-project dependency may need a curated rule. Always shown, including under ALL.
        var unmatched = result.UnmatchedFiles.OrderBy(f => f, StringComparer.Ordinal).ToList();
        sb.AppendLine(CultureInfo.InvariantCulture, $"### Unattributed changed files ({unmatched.Count})");
        sb.AppendLine();
        if (unmatched.Count == 0)
        {
            sb.AppendLine("_none — every changed file was matched by Layer 2, ignored, or Layer-1-owned._");
        }
        else
        {
            sb.AppendLine("Matched by no map rule (Layer 2) and not a project-owned source file");
            sb.AppendLine("(Layer 1). Add a curated rule if any of these gate a test:");
            sb.AppendLine();
            foreach (var file in unmatched)
            {
                sb.AppendLine(CultureInfo.InvariantCulture, $"- `{file}`");
            }
        }
        sb.AppendLine();
 
        sb.AppendLine("### Selection");
        if (result.SelectsAll)
        {
            sb.AppendLine(CultureInfo.InvariantCulture, $"- **selects ALL** — {result.EscalationReason}");
            WriteOut(sb);
            return;
        }
 
        var selected = result.TestProjects.OrderBy(p => p, StringComparer.Ordinal).ToList();
        var skipped = allTestProjects.Except(result.TestProjects, StringComparer.Ordinal)
            .OrderBy(p => p, StringComparer.Ordinal)
            .ToList();
        var jobTokens = result.Jobs.OrderBy(j => j, StringComparer.Ordinal).ToList();
 
        sb.AppendLine(CultureInfo.InvariantCulture, $"- selected test projects: {selected.Count} / {allTestProjects.Count}");
        sb.AppendLine(CultureInfo.InvariantCulture, $"- triggered jobs: {(jobTokens.Count == 0 ? "(none)" : string.Join(", ", jobTokens))}");
        sb.AppendLine();
 
        // Each selected test project / job is listed with the full set of reasons it was selected
        // (the changed file, affected project, graph edge, or selected test that pulled it in, plus
        // the curated rule's reason text). This is the "why" an auditor needs to trust the selection.
        AppendCauseList(sb, "Selected test projects", selected, p => p, result.TestCauses);
        AppendCauseList(
            sb,
            "Triggered jobs",
            jobTokens,
            t => t.StartsWith("job:", StringComparison.Ordinal) ? t["job:".Length..] : t,
            result.JobCauses);
        // In enforcing mode the unselected projects are actually skipped; in audit mode the full matrix
        // still runs, so they only "would have been" skipped.
        AppendProjectList(sb, options.Enforce ? "Skipped (not run)" : "Would have been skipped", skipped);
 
        WriteOut(sb);
 
        static void AppendCauseList(
            StringBuilder builder,
            string title,
            IReadOnlyList<string> keys,
            Func<string, string> display,
            IReadOnlyDictionary<string, IReadOnlyList<Cause>> causes)
        {
            builder.AppendLine(CultureInfo.InvariantCulture, $"<details><summary>{title} ({keys.Count})</summary>");
            builder.AppendLine();
            foreach (var key in keys)
            {
                builder.AppendLine(CultureInfo.InvariantCulture, $"- `{display(key)}`");
                if (causes.TryGetValue(key, out var list))
                {
                    foreach (var cause in list.OrderBy(c => CausePriority(c.Kind)))
                    {
                        builder.AppendLine(CultureInfo.InvariantCulture, $"  - {VerboseCause(cause)}");
                    }
                }
            }
            builder.AppendLine();
            builder.AppendLine("</details>");
            builder.AppendLine();
        }
 
        static void AppendProjectList(StringBuilder builder, string title, IReadOnlyList<string> projects)
        {
            builder.AppendLine(CultureInfo.InvariantCulture, $"<details><summary>{title} ({projects.Count})</summary>");
            builder.AppendLine();
            foreach (var p in projects)
            {
                builder.AppendLine(CultureInfo.InvariantCulture, $"- {p}");
            }
            builder.AppendLine();
            builder.AppendLine("</details>");
            builder.AppendLine();
        }
 
        static void WriteOut(StringBuilder builder)
        {
            var markdown = builder.ToString();
            var summaryPath = Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY");
            if (summaryPath is not null)
            {
                File.AppendAllText(summaryPath, markdown);
            }
            else
            {
                Console.Error.Write(markdown);
            }
        }
    }
 
    // Lower = more "direct" / closer to the change, so it's the cause shown first in the comment and
    // listed first in the summary: a literal changed file beats an affected-project edge beats a
    // graph edge beats a test-derived pull.
    private static int CausePriority(CauseKind kind) => kind switch
    {
        CauseKind.Convention => 0,
        CauseKind.PathRule => 1,
        CauseKind.AffectedProject => 2,
        CauseKind.Layer1Graph => 3,
        CauseKind.DerivedFromTest => 4,
        _ => 5,
    };
 
    // Terse, one-line cause for the PR comment (no rule reason text).
    private static string ShortCause(Cause cause) => cause.Kind switch
    {
        CauseKind.Convention => $"`{cause.Trigger}`",
        CauseKind.PathRule => $"`{cause.Trigger}`",
        CauseKind.AffectedProject => $"affected project `{cause.Trigger}`",
        // Name the seed changed file (and hop count) rather than the full chain, which the summary
        // carries -- the comment stays scannable. Falls back to a generic label when no path was tracked.
        CauseKind.Layer1Graph => Layer1ShortCause(cause),
        // "selected test" (a noun phrase parallel to "affected project") names the trigger: a
        // derived_targets rule pulls this job in because that test project was selected. Phrasing it as a
        // noun -- not "derived from test X" -- avoids a dangling "from" that reads as if the line above it
        // in the job-reasons cell flows through this test.
        CauseKind.DerivedFromTest => $"selected test `{cause.Trigger}`",
        _ => cause.Trigger,
    };
 
    // "via graph from `seed.cs`" (+ "(N hops)" when the reverse-dependency chain is more than one edge).
    private static string Layer1ShortCause(Cause cause)
    {
        if (cause.Path is not { Count: > 0 } path)
        {
            return "changed source (graph)";
        }
 
        // path = [seedFile, project0, ..., affectedTest]; project edges = (count - 1 projects) - 1.
        var hops = path.Count - 2;
        var suffix = hops > 1 ? $" ({hops} hops)" : "";
        return $"via graph from `{path[0]}`{suffix}";
    }
 
    // Full cause for the step summary, including the curated rule reason when present.
    private static string VerboseCause(Cause cause)
    {
        var head = cause.Kind switch
        {
            CauseKind.Convention => $"convention match `{cause.Trigger}`",
            CauseKind.PathRule => $"path rule `{cause.Trigger}`",
            CauseKind.AffectedProject => $"affected project `{cause.Trigger}`",
            // Render the full decision path (seed file -> ... -> affected test) when available, so the
            // summary explains HOW the change reached the test, not just THAT it did.
            CauseKind.Layer1Graph => cause.Path is { Count: > 0 } path
                ? $"graph closure: {string.Join(" → ", path)}"
                : "affected by changed source (graph closure)",
            CauseKind.DerivedFromTest => $"derived from selected test `{cause.Trigger}`",
            _ => cause.Trigger,
        };
 
        // Map reasons can be YAML folded scalars spanning lines; collapse to one line for the bullet.
        return string.IsNullOrWhiteSpace(cause.Reason)
            ? head
            : $"{head}{string.Join(' ', cause.Reason!.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))}";
    }
 
    private static string RunProcess(
        string fileName,
        IReadOnlyList<string> arguments,
        string workingDirectory,
        out int exitCode,
        out string stderr,
        IReadOnlyDictionary<string, string>? environment = null)
    {
        var psi = new ProcessStartInfo(fileName)
        {
            WorkingDirectory = workingDirectory,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
        };
        foreach (var arg in arguments)
        {
            psi.ArgumentList.Add(arg);
        }
        if (environment is not null)
        {
            foreach (var (key, value) in environment)
            {
                psi.Environment[key] = value;
            }
        }
 
        using var process = Process.Start(psi)
            ?? throw new InvalidOperationException($"Failed to start '{fileName}'.");
 
        // Read both streams concurrently to avoid deadlock when a pipe buffer fills.
        var stdoutTask = process.StandardOutput.ReadToEndAsync();
        var stderrTask = process.StandardError.ReadToEndAsync();
        process.WaitForExit();
 
        exitCode = process.ExitCode;
        stderr = stderrTask.GetAwaiter().GetResult();
        return stdoutTask.GetAwaiter().GetResult();
    }
}