File: TestSelector.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.
 
namespace Aspire.SelectTests;
 
/// <summary>
/// Options that override normal selection.
/// </summary>
/// <param name="ForceAll">
/// The kill switch: the <c>run-full-ci</c> PR label (or a non-PR build with no diff base) forces the
/// whole matrix to run regardless of which files changed.
/// </param>
/// <param name="ForceAllReason">
/// When <see cref="ForceAll"/> is set, the human-readable reason it fired, surfaced as
/// <see cref="SelectionResult.EscalationReason"/> so the PR comment and JSON artifact agree with the
/// step summary. Null falls back to the kill-switch wording; a merge-base fail-safe fallback passes the
/// specific reason here instead.
/// </param>
public sealed record SelectorOptions(bool ForceAll = false, string? ForceAllReason = null);
 
/// <summary>
/// Why a single test project or job was selected — the trigger that pulled it in, so the PR comment
/// and step summary can explain the selection instead of listing bare names. One selected item can
/// have several causes (e.g. a job hit directly by a changed file <em>and</em> derived from a
/// selected test); the selector records all of them.
/// </summary>
public enum CauseKind
{
    /// <summary>A changed file matched a <c>conventions</c> capture pattern (<see cref="Cause.Trigger"/> is the file).</summary>
    Convention,
 
    /// <summary>A changed file matched a <c>path_rules</c> glob (<see cref="Cause.Trigger"/> is the file; <see cref="Cause.Reason"/> is the rule's <c>reason</c>).</summary>
    PathRule,
 
    /// <summary>An affected production project matched an <c>affected_project_rules</c> glob (<see cref="Cause.Trigger"/> is the project name).</summary>
    AffectedProject,
 
    /// <summary>The Layer 1 MSBuild graph marked this test project affected by a changed source file (<see cref="Cause.Trigger"/> is the project name).</summary>
    Layer1Graph,
 
    /// <summary>A selected test project pulled this in via <c>derived_targets</c> (<see cref="Cause.Trigger"/> is the triggering test project).</summary>
    DerivedFromTest,
}
 
/// <summary>
/// A single reason a test project or job was selected.
/// </summary>
/// <param name="Kind">Which selection mechanism fired.</param>
/// <param name="Trigger">
/// The thing that triggered it: a changed file path, an affected project name, or — for
/// <see cref="CauseKind.DerivedFromTest"/> — the selected test project that pulled this in.
/// </param>
/// <param name="Reason">The curated <c>reason</c> string from the map rule, when the rule carries one.</param>
/// <param name="Path">
/// The full decision path that led to the selection, when one is available. For
/// <see cref="CauseKind.Layer1Graph"/> this is the seed changed file followed by the
/// reverse-dependency project chain (e.g. <c>["src/Foo/Bar.cs", "Aspire.Foo", "Aspire.Mid",
/// "Aspire.Mid.Tests"]</c>), so the summary can show HOW the change reached the test. Null for causes
/// that have no multi-hop path (a direct file/rule match is already fully described by
/// <see cref="Trigger"/>).
/// </param>
public sealed record Cause(CauseKind Kind, string Trigger, string? Reason = null, IReadOnlyList<string>? Path = null);
 
/// <summary>
/// The outcome of selecting which CI work to run for a set of changed files.
/// </summary>
/// <param name="SelectsAll">
/// True when the whole test matrix must run (a path rule whose target is <c>ALL</c>, a fail-open
/// escalation, or the kill switch). When true, <see cref="TestProjects"/> is the full matrix.
/// </param>
/// <param name="TestProjects">The selected test project names (matrix <c>projectName</c>), aliases expanded.</param>
/// <param name="Jobs">The selected non-.NET jobs (e.g. <c>job:polyglot</c>, <c>job:extension-e2e</c>).</param>
/// <param name="EscalationReason">When <see cref="SelectsAll"/> is true, a short human-readable reason.</param>
/// <param name="UnmatchedFiles">
/// Changed files that matched <em>no</em> curated map rule (Layer 2). After the trim, normal
/// <c>src</c> files are expected here (Layer 1 / the affected-projects graph owns the project closure),
/// so a consumer that wants the "neither layer" set subtracts the files Layer 1 attributed. The
/// raw set is still the early-warning signal for a loose, non-project dependency that needs a
/// curated rule.
/// </param>
/// <param name="TestCauses">
/// Per-selected-test-project attribution: why each entry of <see cref="TestProjects"/> was selected.
/// Empty when <see cref="SelectsAll"/> is true (the whole matrix runs; <see cref="EscalationReason"/>
/// is the single explanation).
/// </param>
/// <param name="JobCauses">
/// Per-selected-job attribution (keyed by the full <c>job:</c> token): why each entry of
/// <see cref="Jobs"/> was selected. Empty when <see cref="SelectsAll"/> is true.
/// </param>
public sealed record SelectionResult(
    bool SelectsAll,
    IReadOnlySet<string> TestProjects,
    IReadOnlySet<string> Jobs,
    string? EscalationReason,
    IReadOnlySet<string> UnmatchedFiles,
    IReadOnlyDictionary<string, IReadOnlyList<Cause>> TestCauses,
    IReadOnlyDictionary<string, IReadOnlyList<Cause>> JobCauses);
 
/// <summary>
/// Filters the full CI matrix down to the subset relevant to a PR's changed files, using the
/// curated <c>eng/github-ci/test-trigger-map.yml</c> (Layer 2) unioned with a graph-derived affected set
/// (Layer 1, from <see cref="GraphAffectedProjects"/>, supplied to <see cref="Select"/>).
/// </summary>
/// <remarks>
/// Behavior is specified by the acceptance tests in
/// <c>Infrastructure.Tests/TestTriggerMap/SelectTestsAcceptanceTests.cs</c>.
/// </remarks>
public sealed class TestSelector
{
    private readonly string _mapPath;
    private readonly IReadOnlyCollection<string> _allTestProjects;
    private readonly IReadOnlyCollection<string> _projectDirectories;
 
    /// <param name="mapPath">Path to <c>eng/github-ci/test-trigger-map.yml</c>.</param>
    /// <param name="allTestProjects">All matrix test project names — the universe an <c>ALL</c> selection expands to.</param>
    /// <param name="projectDirectories">
    /// Repo-relative, '/'-separated directories of every project in <c>Aspire.slnx</c> (the universe
    /// the Layer 1 graph walks). Used to decide whether a changed file is "Layer-1-owned": a file
    /// under one of these dirs is attributed by the graph, so it never triggers the run-all
    /// fallback. May be empty (then no file is treated as owned).
    /// </param>
    public TestSelector(
        string mapPath,
        IReadOnlyCollection<string> allTestProjects,
        IReadOnlyCollection<string> projectDirectories)
    {
        _mapPath = mapPath;
        _allTestProjects = allTestProjects;
        _projectDirectories = projectDirectories;
    }
 
    /// <param name="changedFiles">Repo-relative, '/'-separated paths changed in the PR.</param>
    /// <param name="layer1Affected">
    /// The full affected project set reported by the graph tool — production <em>and</em> test
    /// project names (the union of its <em>changed</em> and <em>affected</em> sets). Test names are
    /// intersected with the matrix and selected; production names drive <c>project_rules</c>. May be
    /// empty.
    /// </param>
    /// <param name="options">Selection overrides (kill switch).</param>
    /// <param name="layer1AttributedPaths">
    /// The changed repo-relative paths the Layer 1 graph actually attributed to a project
    /// (<see cref="AffectedResult.AttributedPaths"/>). Such a file is Layer-1-owned even when it is not
    /// under a project directory (e.g. a link-compiled <c>src/Shared</c> file), so it does not trip the
    /// run-all fallback. Empty when Layer 1 did not run (<c>--skip-layer1</c> / <c>--force-all</c>).
    /// </param>
    /// <param name="layer1Paths">
    /// Per-affected-project decision paths from Layer 1 (<see cref="AffectedResult.Paths"/>), keyed by
    /// project base name. When a selected test project has an entry, its <see cref="CauseKind.Layer1Graph"/>
    /// cause carries the seed file + reverse-dependency chain so the summary can show the full path. Null
    /// when Layer 1 did not run or produced no paths.
    /// </param>
    public SelectionResult Select(
        IReadOnlyCollection<string> changedFiles,
        IReadOnlyCollection<string> layer1Affected,
        SelectorOptions options,
        IReadOnlySet<string>? layer1AttributedPaths = null,
        IReadOnlyDictionary<string, AffectedPath>? layer1Paths = null)
    {
        var map = TriggerMap.Load(_mapPath);
        var attributedPaths = layer1AttributedPaths ?? new HashSet<string>(StringComparer.Ordinal);
 
        // name -> the reasons it was selected. The key set IS the selected set; the lists carry the
        // attribution surfaced in the PR comment / step summary.
        var testCauses = new Dictionary<string, List<Cause>>(StringComparer.Ordinal);
        var jobCauses = new Dictionary<string, List<Cause>>(StringComparer.Ordinal);
        var unmatchedFiles = new HashSet<string>(StringComparer.Ordinal);
        var selectsAll = false;
        string? reason = null;
 
        // Kill switch: the run-full-ci label forces the whole matrix regardless of which files changed.
        // A caller-supplied ForceAllReason (e.g. the merge-base fail-safe fallback) overrides the default
        // kill-switch wording so every output surface — summary, PR comment, JSON — names the same cause.
        if (options.ForceAll)
        {
            selectsAll = true;
            reason = options.ForceAllReason ?? "kill switch: the run-full-ci label forces the full matrix";
        }
 
        foreach (var file in changedFiles)
        {
            // Tracks whether a Layer 2 rule added targets for this file. Combined below with
            // "ignored" and "Layer-1-owned" to decide whether the file is a true leftover.
            var fileMatched = false;
 
            // conventions: a <name>-capture pattern -> target template, emitted only when the
            // derived test project exists in the matrix (existence guard). Additive. Covers a test
            // project's own folder (tests/<name>/**) and the Hosting/Components integration dirs.
            foreach (var convention in map.Conventions)
            {
                if (TriggerMap.TryExpandConvention(convention, file, out var target) &&
                    target.StartsWith("test:", StringComparison.Ordinal))
                {
                    var project = StripTestPrefix(target);
                    if (_allTestProjects.Contains(project))
                    {
                        AddCause(testCauses, project, new Cause(CauseKind.Convention, file));
                        fileMatched = true;
                    }
                }
            }
 
            // path_rules: a glob set -> a target set (test: / job: / group / ALL).
            foreach (var rule in map.PathRules)
            {
                if (rule.Paths.Any(g => TriggerMap.GlobMatches(g, file)))
                {
                    ApplyTargets(rule.Targets, map, testCauses, jobCauses, ref selectsAll, ref reason,
                        new Cause(CauseKind.PathRule, file, rule.Reason));
                    fileMatched = true;
                }
            }
 
            // ignore: files Layer 2 deliberately accounts for with no target (Layer 1 covers them, or
            // they are inert). They must not trigger the run-all fallback below.
            var ignored = map.Ignore.Any(g => TriggerMap.GlobMatches(g, file));
 
            // Layer-1-owned: either the graph actually attributed this changed file to a project
            // (the authoritative signal — covers link-compiled src/Shared / tests/Shared / Components
            // /Common files that are NOT under any project directory), or the file sits under a project
            // directory in Aspire.slnx (the directory heuristic, which also covers deleted files and the
            // old side of a cross-project rename that the graph index can no longer see at HEAD). Either
            // way the file relies on Layer 1 and must never force ALL.
            var layer1Owned = attributedPaths.Contains(file) || IsLayer1Owned(file);
 
            if (fileMatched || ignored || layer1Owned)
            {
                // Accounted for by some layer; nothing more to do for this file.
                continue;
            }
 
            // A true leftover: matched by no Layer 2 rule, not ignored, not a graph-owned project file,
            // and not dropped by the prefilter (which already removes changes that need no CI at all,
            // e.g. docs). Fail safe and force the full matrix: a missed test is a silent regression, an
            // extra full run is merely slower. This covers a new shared source dir, a new top-level
            // directory, or any loose dependency nobody has mapped yet -- the kind of change made
            // without knowing the selector exists. The file is also reported in the audit summary so a
            // curated rule (or a prefilter entry, if it truly needs no CI) can narrow it later.
            unmatchedFiles.Add(file);
            selectsAll = true;
            reason ??= $"run-all fallback: '{file}' is neither Layer-1-owned nor matched by a Layer 2 rule";
        }
 
        // Layer 1: the graph tool reports the full affected set (production + test projects). The
        // affected TEST projects are always part of the answer; the production names drive
        // project_rules below.
        foreach (var project in layer1Affected)
        {
            if (_allTestProjects.Contains(project))
            {
                // Attach the graph decision path (seed file + reverse-dependency chain) when Layer 1
                // produced one, so the summary can render HOW the change reached this test.
                IReadOnlyList<string>? path = null;
                if (layer1Paths is not null && layer1Paths.TryGetValue(project, out var affectedPath))
                {
                    path = BuildLayer1CausePath(affectedPath);
                }
 
                AddCause(testCauses, project, new Cause(CauseKind.Layer1Graph, project, Path: path));
            }
        }
 
        // affected_project_rules: an affected PRODUCTION project (matched by name glob) pulls in
        // jobs/tests. This replaces the duplicated src/<Project>/** path globs the job rules used to
        // carry, and follows the graph's transitive closure (a dependency change marks the project
        // affected). Keyed on the affected-project set, so it contributes nothing when Layer 1
        // produced none (e.g. --skip-layer1) -- the path_rules still cover the loose-file triggers.
        //
        // Match ONLY production project names: Layer 1 reports production AND test projects, and the
        // affected test projects are already selected via the intersection above. Without this filter
        // an affected matrix test name (e.g. "Aspire.Hosting.Python.Tests") would match a production
        // glob like "Aspire.Hosting*" and spuriously fire production jobs (ats-diffs / extension-e2e /
        // typescript-api-compat / deployment-e2e) for a TEST-ONLY change. See test-trigger-map.yml's
        // affected_project_rules comment ("matched against the affected PRODUCTION projects").
        var affectedProductionProjects = layer1Affected
            .Where(name => !_allTestProjects.Contains(name))
            .ToList();
        foreach (var rule in map.AffectedProjectRules)
        {
            // Attribute the rule to the first affected project that matched it, so the cause names a
            // concrete project rather than the rule's whole glob set.
            var matchedProject = affectedProductionProjects.FirstOrDefault(name => rule.Projects.Any(p => TriggerMap.ProjectNameMatches(p, name)));
            if (matchedProject is not null)
            {
                ApplyTargets(rule.Targets, map, testCauses, jobCauses, ref selectsAll, ref reason,
                    new Cause(CauseKind.AffectedProject, matchedProject, rule.Reason));
            }
        }
 
        if (selectsAll)
        {
            return SelectsAllResult(reason, unmatchedFiles);
        }
 
        // derived_targets: a selected test project (from Layer 1 or Layer 2) can pull in extra
        // jobs/tests. Iterate to a fixpoint so a test->test edge whose target has its own derived
        // rule is followed; a no-growth pass terminates (cycle-safe).
        ApplyDerivedTargets(map, testCauses, jobCauses, ref selectsAll, ref reason);
 
        if (selectsAll)
        {
            return SelectsAllResult(reason, unmatchedFiles);
        }
 
        return new SelectionResult(
            SelectsAll: false,
            TestProjects: testCauses.Keys.ToHashSet(StringComparer.Ordinal),
            Jobs: jobCauses.Keys.ToHashSet(StringComparer.Ordinal),
            EscalationReason: null,
            UnmatchedFiles: unmatchedFiles,
            TestCauses: Freeze(testCauses),
            JobCauses: Freeze(jobCauses));
 
        // ALL = full matrix + all jobs. Replace any partial set so the result is exactly the universe
        // the caller passed in (the matrix and the map's full job vocabulary). Per-item causes are not
        // tracked under ALL: the whole matrix runs and EscalationReason is the single explanation.
        SelectionResult SelectsAllResult(string? escalationReason, IReadOnlySet<string> unmatched) =>
            new(
                SelectsAll: true,
                TestProjects: new HashSet<string>(_allTestProjects, StringComparer.Ordinal),
                Jobs: new HashSet<string>(map.AllJobTokens(), StringComparer.Ordinal),
                EscalationReason: escalationReason ?? "full matrix selected",
                UnmatchedFiles: unmatched,
                TestCauses: s_emptyCauses,
                JobCauses: s_emptyCauses);
    }
 
    private static readonly IReadOnlyDictionary<string, IReadOnlyList<Cause>> s_emptyCauses =
        new Dictionary<string, IReadOnlyList<Cause>>(StringComparer.Ordinal);
 
    private static IReadOnlyDictionary<string, IReadOnlyList<Cause>> Freeze(Dictionary<string, List<Cause>> causes) =>
        causes.ToDictionary(kv => kv.Key, kv => (IReadOnlyList<Cause>)kv.Value, StringComparer.Ordinal);
 
    // Builds the rendered decision path for a Layer 1 cause: the seed changed file followed by the
    // reverse-dependency project chain (directly-changed project ... affected test). The result reads
    // left-to-right as "this file changed this project, which is depended on by ..., reaching this test".
    private static IReadOnlyList<string> BuildLayer1CausePath(AffectedPath affectedPath)
    {
        var path = new List<string>(affectedPath.ProjectChain.Count + 1) { affectedPath.ChangedFile };
        path.AddRange(affectedPath.ProjectChain);
        return path;
    }
 
    // Records one reason a test project / job was selected, de-duplicating identical causes (e.g. the
    // same derived rule re-fires across fixpoint passes).
    private static void AddCause(Dictionary<string, List<Cause>> causes, string key, Cause cause)
    {
        if (!causes.TryGetValue(key, out var list))
        {
            list = new List<Cause>();
            causes[key] = list;
        }
 
        if (!list.Contains(cause))
        {
            list.Add(cause);
        }
    }
 
    // A changed file is "Layer-1-owned" when it lives under a project directory in Aspire.slnx -- the
    // Layer 1 graph then attributes it to that project, so it does not need the run-all fallback.
    private bool IsLayer1Owned(string file)
    {
        foreach (var dir in _projectDirectories)
        {
            var prefix = dir.EndsWith('/') ? dir : dir + "/";
            if (file.StartsWith(prefix, StringComparison.Ordinal))
            {
                return true;
            }
        }
 
        return false;
    }
 
    // Applies derived_targets to the selected test set until it stabilises. Each pass adds the
    // targets of every derived rule whose keyed test is currently selected; a pass that adds nothing
    // ends the loop (so cycles such as A->B, B->A terminate).
    private static void ApplyDerivedTargets(
        TriggerMap map,
        Dictionary<string, List<Cause>> testCauses,
        Dictionary<string, List<Cause>> jobCauses,
        ref bool selectsAll,
        ref string? reason)
    {
        if (map.DerivedTargets.Count == 0)
        {
            return;
        }
 
        var changed = true;
        while (changed && !selectsAll)
        {
            var beforeTests = testCauses.Count;
            var beforeJobs = jobCauses.Count;
 
            foreach (var derived in map.DerivedTargets)
            {
                // If ANY of the rule's triggering tests is selected, add its targets and attribute
                // them to the (first) selected triggering test, so the cause reads "via test X".
                var triggeringTest = derived.Tests
                    .Select(StripTestPrefix)
                    .FirstOrDefault(testCauses.ContainsKey);
                if (triggeringTest is not null)
                {
                    ApplyTargets(derived.Targets, map, testCauses, jobCauses, ref selectsAll, ref reason,
                        new Cause(CauseKind.DerivedFromTest, triggeringTest, derived.Reason));
                }
            }
 
            changed = testCauses.Count != beforeTests || jobCauses.Count != beforeJobs;
        }
    }
 
    private static void ApplyTargets(
        IEnumerable<string> targets,
        TriggerMap map,
        Dictionary<string, List<Cause>> testCauses,
        Dictionary<string, List<Cause>> jobCauses,
        ref bool selectsAll,
        ref string? reason,
        Cause cause)
    {
        var localSelectsAll = selectsAll;
        string? localReason = reason;
 
        foreach (var target in targets)
        {
            AddTarget(target, map, testCauses, jobCauses, ref localSelectsAll, ref localReason, cause, visitedGroups: null);
        }
 
        selectsAll = localSelectsAll;
        reason = localReason;
    }
 
    // Routes a single target into the result sets. Group names expand recursively (a group member
    // may itself be a group name), tracking visited groups so a cyclic group reference terminates.
    // The cause propagates unchanged to every test/job leaf the target expands to.
    private static void AddTarget(
        string target,
        TriggerMap map,
        Dictionary<string, List<Cause>> testCauses,
        Dictionary<string, List<Cause>> jobCauses,
        ref bool selectsAll,
        ref string? reason,
        Cause cause,
        HashSet<string>? visitedGroups)
    {
        if (target == "ALL")
        {
            selectsAll = true;
            reason ??= $"a rule matching '{cause.Trigger}' selects ALL";
        }
        else if (map.Groups.TryGetValue(target, out var members))
        {
            visitedGroups ??= new HashSet<string>(StringComparer.Ordinal);
            if (!visitedGroups.Add(target))
            {
                // Already expanding this group higher in the recursion: a cycle. Stop.
                return;
            }
 
            foreach (var member in members)
            {
                AddTarget(member, map, testCauses, jobCauses, ref selectsAll, ref reason, cause, visitedGroups);
            }
        }
        else if (target.StartsWith("test:", StringComparison.Ordinal))
        {
            AddCause(testCauses, StripTestPrefix(target), cause);
        }
        else if (target.StartsWith("job:", StringComparison.Ordinal))
        {
            AddCause(jobCauses, target, cause);
        }
    }
 
    private static string StripTestPrefix(string target) =>
        target.StartsWith("test:", StringComparison.Ordinal) ? target["test:".Length..] : target;
}