File: TriggerMap.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.Text.RegularExpressions;
using Microsoft.Extensions.FileSystemGlobbing;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
 
namespace Aspire.SelectTests;
 
// Strongly-typed view of eng/github-ci/test-trigger-map.yml, kept internal to the tool so it does not
// add to the public API surface. The verifier tests in Infrastructure.Tests have their own
// parallel model (the test project references this tool, so the model cannot live there and be
// shared without a circular dependency); the design doc sanctions the tool owning its own parse.
internal sealed class TriggerMap
{
    public int Version { get; set; }
 
    public Dictionary<string, List<string>> Groups { get; set; } = new();
 
    public List<ConventionRule> Conventions { get; set; } = new();
 
    // Pre-Layer-1 exclude config: read the CI skip-gate patterns file at runtime and drop matching
    // changed files before both layers, except the keep_routed carve-outs. See ChangedFileFilter.
    public PrefilterConfig? Prefilter { get; set; }
 
    public List<string> Ignore { get; set; } = new();
 
    public List<PathRule> PathRules { get; set; } = new();
 
    public List<AffectedProjectRule> AffectedProjectRules { get; set; } = new();
 
    public List<DerivedRule> DerivedTargets { get; set; } = new();
 
    // Five matchers exist; a section is a key only when the selector treats it differently.
    // The graph closure (ProjectReference, CPM, Directory.Build.*, foreign <Compile Include>) is
    // computed at runtime by the Layer 1 graph (GraphAffectedProjects), so those edges are intentionally absent.
    // affected_project_rules are distinct from path_rules because they key off the affected PROJECT
    // set (Layer 1) by project name, not off changed file paths.
 
    // Every job: token the map can ever emit -- the "all jobs" set an ALL selection expands to.
    // Collected from every section that can carry a job: target (path-rule targets, derived
    // targets, and group members), so a job referenced only by a derived rule is still part of ALL.
    public IEnumerable<string> AllJobTokens()
    {
        var tokens = new HashSet<string>(StringComparer.Ordinal);
 
        foreach (var rule in PathRules)
        {
            foreach (var t in rule.Targets)
            {
                AddJobTokensFromTarget(tokens, t);
            }
        }
 
        foreach (var d in DerivedTargets)
        {
            foreach (var t in d.Targets)
            {
                AddJobTokensFromTarget(tokens, t);
            }
        }
 
        foreach (var rule in AffectedProjectRules)
        {
            foreach (var t in rule.Targets)
            {
                AddJobTokensFromTarget(tokens, t);
            }
        }
 
        foreach (var members in Groups.Values)
        {
            foreach (var m in members)
            {
                AddIfJob(tokens, m);
            }
        }
 
        return tokens;
    }
 
    // A target may be a job: token directly or a group name whose members include job: tokens.
    private void AddJobTokensFromTarget(HashSet<string> tokens, string target)
    {
        if (target.StartsWith("job:", StringComparison.Ordinal))
        {
            tokens.Add(target);
        }
        else if (Groups.TryGetValue(target, out var members))
        {
            foreach (var m in members)
            {
                AddIfJob(tokens, m);
            }
        }
    }
 
    private static void AddIfJob(HashSet<string> tokens, string token)
    {
        if (token.StartsWith("job:", StringComparison.Ordinal))
        {
            tokens.Add(token);
        }
    }
 
    public static TriggerMap Load(string mapPath)
    {
        var yaml = File.ReadAllText(mapPath);
        var deserializer = new DeserializerBuilder()
            .WithNamingConvention(UnderscoredNamingConvention.Instance)
            .IgnoreUnmatchedProperties()
            .Build();
        return deserializer.Deserialize<TriggerMap>(yaml);
    }
 
    // Matches a repo-relative '/'-separated path against a single map glob, using ordinal
    // (case-sensitive) comparison so the match mirrors git's path semantics on Linux CI.
    public static bool GlobMatches(string glob, string path)
    {
        var matcher = new Matcher(StringComparison.Ordinal);
        matcher.AddInclude(glob);
        return matcher.Match(new[] { path }).HasMatches;
    }
 
    // Matches a project NAME (the Layer 1 graph's affected name, == the .csproj base name) against a
    // project_rules pattern, supporting simple '*'/'?' wildcards (e.g. "Aspire.Hosting*" matches
    // every hosting project by name). This is over the project-NAME string space, not file paths,
    // so a project_rule survives a project moving directories and follows the graph's transitive
    // closure (a dependency change marks the project affected). Ordinal/case-sensitive to mirror CI.
    public static bool ProjectNameMatches(string pattern, string name) =>
        System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(pattern, name, ignoreCase: false);
 
    // Expands a path_conventions rule against a changed file. The pattern carries a single <name>
    // placeholder for one path segment and a trailing "/**"; e.g. "src/Components/<name>/**" against
    // "src/Components/Aspire.Npgsql/Foo.cs" captures name="Aspire.Npgsql" and substitutes it into the
    // target template ("test:<name>.Tests" -> "test:Aspire.Npgsql.Tests"). The caller applies the
    // existence guard (only emit when the derived test project is in the matrix).
    public static bool TryExpandConvention(ConventionRule rule, string path, out string target)
    {
        target = "";
 
        var pattern = rule.Pattern;
        // Only the "<prefix><name>/**" shape is supported; the trailing /** matches any file under
        // the captured directory (including nested files).
        var hasGlobSuffix = pattern.EndsWith("/**", StringComparison.Ordinal);
        var core = hasGlobSuffix ? pattern[..^"/**".Length] : pattern;
 
        var nameIndex = core.IndexOf("<name>", StringComparison.Ordinal);
        if (nameIndex < 0)
        {
            return false;
        }
 
        var before = core[..nameIndex];
        var after = core[(nameIndex + "<name>".Length)..];
        var regex = "^" + Regex.Escape(before) + "([^/]+)" + Regex.Escape(after) + (hasGlobSuffix ? "/.+" : "") + "$";
 
        var match = Regex.Match(path, regex);
        if (!match.Success)
        {
            return false;
        }
 
        target = rule.Target.Replace("<name>", match.Groups[1].Value, StringComparison.Ordinal);
        return true;
    }
}
 
// prefilter config: read the CI skip-gate patterns file at runtime and drop matching changed files
// before both layers, except keep_routed (files the selector routes to a target). See ChangedFileFilter.
internal sealed class PrefilterConfig
{
    public string? PatternsFile { get; set; }
 
    public List<string> KeepRouted { get; set; } = new();
}
 
internal sealed class PathRule
{
    public List<string> Paths { get; set; } = new();
 
    public List<string> Targets { get; set; } = new();
 
    public string? Note { get; set; }
 
    public string? Reason { get; set; }
}
 
// affected_project_rules entry: when ANY affected project (Layer 1) matches one of Projects by name
// glob, add Targets. Replaces the duplicated src/<Project>/** path globs that previously drove the
// non-.NET jobs, and is more robust: it follows the graph's transitive closure rather than literal
// file paths.
internal sealed class AffectedProjectRule
{
    public List<string> Projects { get; set; } = new();
 
    public List<string> Targets { get; set; } = new();
 
    public string? Reason { get; set; }
}
 
// conventions entry: a capture pattern (with a single <name> placeholder for one path segment) and
// a target template that substitutes the captured <name>.
internal sealed class ConventionRule
{
    public string Pattern { get; set; } = "";
 
    public string Target { get; set; } = "";
}
 
// derived_targets entry: when ANY of Tests is selected (by Layer 1 or Layer 2), add Targets.
internal sealed class DerivedRule
{
    public List<string> Tests { get; set; } = new();
 
    public List<string> Targets { get; set; } = new();
 
    public string? Reason { get; set; }
}