File: TestTriggerMap\TestTriggerMapTests.cs
Web Access
Project: src\tests\Infrastructure.Tests\Infrastructure.Tests.csproj (Infrastructure.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.SelectTests;
using Aspire.TestUtilities;
using Microsoft.Extensions.FileSystemGlobbing;
using System.Text.Json;
using Xunit;
using YamlDotNet.RepresentationModel;
 
namespace Infrastructure.Tests.TestTriggerMap;
 
/// <summary>
/// Keeps the curated <c>eng/github-ci/test-trigger-map.yml</c> honest against repo reality.
/// The map is hand-maintained, so these tests fail loudly when a project/job is renamed
/// or removed, when a curated path is typo'd, or when a new source project is added that
/// no rule maps to.
/// </summary>
public sealed class TestTriggerMapTests
{
    private static readonly TestTriggerMap s_map = TestTriggerMap.Load(RepoRoot.Path);
 
    // Repo-relative, '/'-separated tracked file paths (git ls-files). Source of truth for
    // "does this glob match a real file" and "what source projects exist". Loaded once.
    private static readonly IReadOnlyList<string> s_trackedFiles = LoadTrackedFiles();
 
    // job:cli-starter-validation resolves to one tests.yml job per platform, all calling the same
    // reusable workflow and all gated on the same run_cli_starter_validation boolean. Listed once so
    // the existence check and the per-job gate binding below cannot drift from each other.
    private static readonly string[] s_cliStarterValidationJobIds =
    [
        "cli_starter_validation_linux_x64",
        "cli_starter_validation_linux_arm64",
        "cli_starter_validation_windows_x64",
        "cli_starter_validation_windows_arm64",
        "cli_starter_validation_macos_x64",
        "cli_starter_validation_macos_arm64",
    ];
 
    [Fact]
    public void MapLoadsWithExpectedVersion()
    {
        Assert.Equal(1, s_map.Version);
    }
 
    [Fact]
    public void ExtensionUnitWorkflowChangesSelectUnitAndE2eJobs()
    {
        const string workflow = ".github/workflows/extension-unit-tests.yml";
        var targets = s_map.PathRules
            .Where(rule => rule.Paths.Any(path => TestTriggerMap.GlobMatches(path, workflow)))
            .SelectMany(rule => rule.Targets)
            .ToHashSet(StringComparer.Ordinal);
 
        Assert.Contains("job:extension-unit", targets);
        Assert.Contains("job:extension-e2e", targets);
    }
 
    [Fact]
    public void ExtensionE2eProjectAndPathRulesCoverTheSameConsumers()
    {
        var affectedProjects = Assert.Single(
            s_map.AffectedProjectRules,
            rule => rule.Targets.Contains("job:extension-e2e", StringComparer.Ordinal))
            .Projects
            .Select(project => $"src/{project}/**");
        var directPaths = Assert.Single(
            s_map.PathRules,
            rule => rule.Targets.Contains("job:extension-e2e", StringComparer.Ordinal)
                && rule.Paths.Contains("extension/**", StringComparer.Ordinal))
            .Paths
            .Where(path => path.StartsWith("src/", StringComparison.Ordinal));
 
        Assert.Equal(
            affectedProjects.Order(StringComparer.Ordinal),
            directPaths.Order(StringComparer.Ordinal));
    }
 
    [Fact]
    public void RulesAreWellFormed()
    {
        // Structural hygiene: a path rule with no globs, or globs but no targets, is dead weight
        // that silently selects nothing. (Overlapping globs ACROSS path_rules are expected and
        // fine -- one path may map to several targets via several rules -- so there is no
        // duplicate-glob check here.)
        var problems = new List<string>();
 
        foreach (var rule in s_map.PathRules)
        {
            var label = rule.Paths.Count > 0 ? rule.Paths[0] : "(no paths)";
            if (rule.Paths.Count == 0 || rule.Paths.Any(string.IsNullOrWhiteSpace))
            {
                problems.Add($"rule '{label}' has an empty path glob");
            }
            if (rule.Targets.Count == 0 || rule.Targets.Any(string.IsNullOrWhiteSpace))
            {
                problems.Add($"rule '{label}' has no targets");
            }
        }
 
        // conventions: each entry needs a pattern with a <name> placeholder and a target that also
        // carries <name> (so the capture is actually substituted). Duplicate patterns are slips.
        foreach (var convention in s_map.Conventions)
        {
            if (string.IsNullOrWhiteSpace(convention.Pattern) || !convention.Pattern.Contains("<name>", StringComparison.Ordinal))
            {
                problems.Add($"conventions entry '{convention.Pattern}' has no <name> placeholder");
            }
            if (string.IsNullOrWhiteSpace(convention.Target) || !convention.Target.Contains("<name>", StringComparison.Ordinal))
            {
                problems.Add($"conventions pattern '{convention.Pattern}' has a target that does not substitute <name>");
            }
        }
 
        var dupeConventionPatterns = s_map.Conventions
            .GroupBy(c => c.Pattern, StringComparer.Ordinal)
            .Where(g => g.Count() > 1).Select(g => g.Key).Order(StringComparer.Ordinal).ToList();
        if (dupeConventionPatterns.Count > 0)
        {
            problems.Add($"duplicate conventions patterns: {string.Join(", ", dupeConventionPatterns)}");
        }
 
        // ignore globs must be non-empty.
        if (s_map.Ignore.Any(string.IsNullOrWhiteSpace))
        {
            problems.Add("an ignore glob is empty");
        }
 
        // derived_targets: each entry needs at least one test: trigger and at least one target.
        foreach (var derived in s_map.DerivedTargets)
        {
            if (derived.Tests.Count == 0 || derived.Tests.Any(t => string.IsNullOrWhiteSpace(t) || !t.StartsWith("test:", StringComparison.Ordinal)))
            {
                problems.Add($"derived_targets entry has an invalid/empty tests list: [{string.Join(", ", derived.Tests)}]");
            }
            if (derived.Targets.Count == 0 || derived.Targets.Any(string.IsNullOrWhiteSpace))
            {
                problems.Add($"derived_targets entry [{string.Join(", ", derived.Tests)}] has no targets");
            }
        }
 
        // affected_project_rules: each entry needs at least one project name glob and at least one target.
        foreach (var rule in s_map.AffectedProjectRules)
        {
            var label = rule.Projects.Count > 0 ? rule.Projects[0] : "(no projects)";
            if (rule.Projects.Count == 0 || rule.Projects.Any(string.IsNullOrWhiteSpace))
            {
                problems.Add($"affected_project_rules entry '{label}' has an empty project glob");
            }
            if (rule.Targets.Count == 0 || rule.Targets.Any(string.IsNullOrWhiteSpace))
            {
                problems.Add($"affected_project_rules entry '{label}' has no targets");
            }
        }
 
        Assert.True(problems.Count == 0, string.Join("; ", problems));
    }
 
    [Fact]
    public void EverySourceProjectIsReachableByLayer1OrACuratedRule()
    {
        // The graph closure is owned by the Layer 1 graph (GraphAffectedProjects), which discovers
        // projects from Aspire.slnx. So a src project is "covered" if it is in the solution (∴ Layer 1 sees it)
        // OR matched by a curated glob (the deliberately out-of-slnx ones — e.g. the template
        // placeholders that crash discovery — are covered by loose_file_deps). A new src project
        // that is neither in the solution nor curated would silently never run any test, so it
        // must fail here.
        var inSolution = LoadSolutionProjectPaths();
 
        var selecting = new Matcher(StringComparison.Ordinal);
        foreach (var glob in s_map.AllSelectingGlobs())
        {
            selecting.AddInclude(glob);
        }
        var curatedCovered = selecting.Match(s_trackedFiles).Files
            .Select(f => f.Path)
            .ToHashSet(StringComparer.Ordinal);
 
        var uncovered = s_trackedFiles
            .Where(f => f.StartsWith("src/", StringComparison.Ordinal) && f.EndsWith(".csproj", StringComparison.Ordinal))
            .Where(csproj => !inSolution.Contains(csproj) && !curatedCovered.Contains(csproj))
            .Order(StringComparer.Ordinal)
            .ToList();
 
        Assert.True(uncovered.Count == 0,
            $"src projects neither in Aspire.slnx nor matched by a curated rule: {string.Join(", ", uncovered)}");
    }
 
    // Repo-relative '/'-separated project paths listed in Aspire.slnx (the Layer 1 graph root).
    private static IReadOnlySet<string> LoadSolutionProjectPaths()
    {
        var slnx = File.ReadAllText(Path.Combine(RepoRoot.Path, "Aspire.slnx"));
        // <Project Path="src/Foo/Foo.csproj" /> — paths use '/' in the slnx already.
        return System.Text.RegularExpressions.Regex.Matches(slnx, "Path=\"([^\"]+\\.csproj)\"")
            .Select(m => m.Groups[1].Value.Replace('\\', '/'))
            .ToHashSet(StringComparer.Ordinal);
    }
 
    [Fact]
    public void EveryTestProjectIsInTheSolutionSoLayer1CanSelectIt()
    {
        // A matrix test project that is NOT in Aspire.slnx is invisible to Layer 1 (the graph only
        // walks the solution), so a change to a production dependency could never fan into it --
        // it would silently never run in enforcing mode. Require every tests/<Name>/<Name>.csproj to
        // be in the solution. (This invariant is what the Infrastructure.Tests / Aspire.Hosting.Maui
        // .Tests additions satisfied; before them, both were silent Layer-1 blind spots.) Add to the
        // allow-list only for a project deliberately kept out of PR CI, with a reason.
        var allowList = new HashSet<string>(StringComparer.Ordinal)
        {
            // (none today)
        };
 
        var inSolution = LoadSolutionProjectPaths();
        var testsRoot = Path.Combine(RepoRoot.Path, "tests");
 
        var missing = Directory.EnumerateDirectories(testsRoot)
            .Select(Path.GetFileName)
            .Where(name => !string.IsNullOrEmpty(name))
            .Select(name => $"tests/{name}/{name}.csproj")
            .Where(rel => File.Exists(Path.Combine(RepoRoot.Path, rel)))
            .Where(rel => !inSolution.Contains(rel) && !allowList.Contains(rel))
            .Order(StringComparer.Ordinal)
            .ToList();
 
        Assert.True(missing.Count == 0,
            $"test projects not in Aspire.slnx (Layer 1 cannot select them, so a production-dependency " +
            $"change would silently skip their tests): {string.Join(", ", missing)}");
    }
 
    [Fact]
    public void EveryAffectedProjectRuleGlobMatchesASolutionProject()
    {
        // affected_project_rules key off the affected PROJECT set (Layer 1), matched by project-name
        // glob. Layer 1 can only ever report a project that is in Aspire.slnx, so a project glob
        // that matches no solution project name would silently select nothing — assert each matches
        // at least one. Project Name == the .csproj base name (what Layer 1 emits).
        var solutionProjectNames = LoadSolutionProjectPaths()
            .Select(p => Path.GetFileNameWithoutExtension(p))
            .ToHashSet(StringComparer.Ordinal);
 
        var dead = s_map.AffectedProjectRules
            .SelectMany(r => r.Projects)
            .Distinct(StringComparer.Ordinal)
            .Where(pattern => !solutionProjectNames.Any(name =>
                System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(pattern, name, ignoreCase: false)))
            .Order(StringComparer.Ordinal)
            .ToList();
 
        Assert.True(dead.Count == 0,
            $"affected_project_rules globs matching no project in Aspire.slnx: {string.Join(", ", dead)}");
    }
 
    [Fact]
    public void EveryGlobMatchesAtLeastOneTrackedFile()
    {
        // Every rule glob (catch-all, path-rule paths, convention patterns, ignore globs) must match
        // a real, git-tracked file. A typo'd path or a renamed/removed source folder would otherwise
        // sit in the map selecting nothing — a silent hole the selector can't see.
        var globs = s_map.AllSelectingGlobs()
            .Distinct(StringComparer.Ordinal)
            .Order(StringComparer.Ordinal)
            .ToList();
 
        var deadGlobs = globs.Where(g => !GlobMatchesAnyTrackedFile(g)).ToList();
 
        Assert.True(deadGlobs.Count == 0, $"globs matching no tracked file: {string.Join(", ", deadGlobs)}");
    }
 
    private static bool GlobMatchesAnyTrackedFile(string glob)
    {
        var matcher = new Matcher(StringComparison.Ordinal);
        matcher.AddInclude(glob);
        return matcher.Match(s_trackedFiles).HasMatches;
    }
 
    private static IReadOnlyList<string> LoadTrackedFiles()
    {
        var output = GitCli.Run(RepoRoot.Path, "ls-files");
        return output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
    }
 
    [Fact]
    public void GroupsResolveToValidTargets()
    {
        // Named groups (e.g. CLI_BUNDLE) expand into concrete test:/job: targets by a consumer.
        // Each member must be a well-formed test: or job: target (existence is covered by
        // EveryTestTargetNamesAnExistingTestProject / EveryJobTargetMapsToAnExistingWorkflowOrJob,
        // which include group members). The real map keeps groups flat (no nesting); the selector
        // engine supports recursive expansion, exercised by the synthetic acceptance tests.
        foreach (var (name, members) in s_map.Groups)
        {
            Assert.True(members.Count > 0, $"group {name} is empty");
 
            var bad = members.Where(m => !m.StartsWith("test:", StringComparison.Ordinal) && !m.StartsWith("job:", StringComparison.Ordinal))
                .Order(StringComparer.Ordinal).ToList();
            Assert.True(bad.Count == 0, $"group {name} has members that are neither test: nor job:: {string.Join(", ", bad)}");
 
            var dupes = members.GroupBy(m => m, StringComparer.Ordinal)
                .Where(g => g.Count() > 1).Select(g => g.Key).Order(StringComparer.Ordinal).ToList();
            Assert.True(dupes.Count == 0, $"group {name} has duplicate members: {string.Join(", ", dupes)}");
        }
 
        // Every group-like token used as a target (uppercase, not test:/job:) is either the
        // ALL sentinel or a defined group — so a typo'd group reference fails loudly.
        var undefined = s_map.AllReferencedTargets()
            .Where(t => !t.StartsWith("test:", StringComparison.Ordinal) && !t.StartsWith("job:", StringComparison.Ordinal))
            .Where(t => t != "ALL" && !s_map.Groups.ContainsKey(t))
            .Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList();
        Assert.True(undefined.Count == 0, $"undefined group references: {string.Join(", ", undefined)}");
    }
 
    [Fact]
    public void EveryJobTargetMapsToAnExistingWorkflowOrJob()
    {
        // The job: vocabulary is small and curated, and must exactly match the targets referenced by
        // the map. Each token resolves either to a standalone workflow file or to job id(s) in
        // tests.yml (see the Target vocabulary table in test-trigger-map.md). Assert the map references
        // only known jobs, and that the thing each one points at still exists — so a renamed/removed
        // workflow or job fails loudly.
        var workflowsDir = Path.Combine(RepoRoot.Path, ".github", "workflows");
        var testsYml = File.ReadAllText(Path.Combine(workflowsDir, "tests.yml"));
 
        bool WorkflowExists(string file) => File.Exists(Path.Combine(workflowsDir, file));
        bool JobExists(string id) => testsYml.Contains($"\n  {id}:", StringComparison.Ordinal);
 
        // job: target -> predicate that its evidence still exists.
        var expected = new Dictionary<string, Func<bool>>(StringComparer.Ordinal)
        {
            ["job:polyglot"] = () => WorkflowExists("polyglot-validation.yml") && JobExists("polyglot_validation"),
            ["job:typescript-sdk"] = () => WorkflowExists("typescript-sdk-tests.yml") && JobExists("typescript_sdk_tests"),
            ["job:typescript-api-compat"] = () => WorkflowExists("typescript-api-compat.yml") && JobExists("typescript_api_compat"),
            ["job:extension-unit"] = () => JobExists("extension_tests_win") && JobExists("extension_bootstrap_linux"),
            ["job:extension-e2e"] = () => WorkflowExists("extension-e2e-tests.yml"),
            ["job:winget-installer"] = () => JobExists("prepare_winget_installer_artifacts"),
            ["job:homebrew-installer"] = () => JobExists("prepare_homebrew_installer_artifacts"),
            ["job:nix-package"] = () => JobExists("nix_package"),
            ["job:cli-starter-validation"] = () => WorkflowExists("cli-starter-validation.yml")
                && s_cliStarterValidationJobIds.All(JobExists),
            ["job:deployment-e2e"] = () => WorkflowExists("deployment-tests.yml"),
        };
 
        var referenced = s_map.AllReferencedTargets()
            .Where(t => t.StartsWith("job:", StringComparison.Ordinal))
            .ToHashSet(StringComparer.Ordinal);
 
        var unknown = referenced.Where(j => !expected.ContainsKey(j)).Order(StringComparer.Ordinal).ToList();
        Assert.True(unknown.Count == 0, $"job: targets not in the known vocabulary: {string.Join(", ", unknown)}");
 
        var unreferenced = expected.Keys.Except(referenced).Order(StringComparer.Ordinal).ToList();
        Assert.True(unreferenced.Count == 0, $"known job: targets not referenced by the map: {string.Join(", ", unreferenced)}");
 
        var broken = expected.Where(kvp => referenced.Contains(kvp.Key) && !kvp.Value())
            .Select(kvp => kvp.Key).Order(StringComparer.Ordinal).ToList();
        Assert.True(broken.Count == 0, $"job: targets whose workflow/job no longer exists: {string.Join(", ", broken)}");
    }
 
    [Fact]
    public void DocumentedJobTargetVocabularyMatchesReferencedMapJobs()
    {
        var referenced = s_map.AllReferencedTargets()
            .Where(t => t.StartsWith("job:", StringComparison.Ordinal))
            .ToHashSet(StringComparer.Ordinal);
 
        var documentation = File.ReadAllText(Path.Combine(RepoRoot.Path, "docs", "ci", "test-trigger-map.md"));
        var targetVocabulary = TextBetween(documentation, "## Target vocabulary", "## Rule categories");
        var documented = System.Text.RegularExpressions.Regex
            .Matches(targetVocabulary, @"^\| `(job:[a-z0-9-]+)` \|", System.Text.RegularExpressions.RegexOptions.Multiline)
            .Select(match => match.Groups[1].Value)
            .ToHashSet(StringComparer.Ordinal);
 
        var map = File.ReadAllText(Path.Combine(RepoRoot.Path, "eng", "github-ci", "test-trigger-map.yml"));
        var mapVocabulary = TextBetween(map, "# Target vocabulary:", "version: 1");
        var documentedInMap = System.Text.RegularExpressions.Regex
            .Matches(mapVocabulary, @"^#\s+(job:[a-z0-9-]+)\s+", System.Text.RegularExpressions.RegexOptions.Multiline)
            .Select(match => match.Groups[1].Value)
            .ToHashSet(StringComparer.Ordinal);
 
        Assert.Equal(referenced.Order(StringComparer.Ordinal), documented.Order(StringComparer.Ordinal));
        Assert.Equal(referenced.Order(StringComparer.Ordinal), documentedInMap.Order(StringComparer.Ordinal));
    }
 
    public static TheoryData<string, string[]> AuditedLoosePathCases => new()
    {
        {
            ".github/workflows/prepare-installer-artifacts.yml",
            ["test:Infrastructure.Tests", "job:winget-installer", "job:homebrew-installer"]
        },
        {
            ".github/scripts/assert-extension-e2e-bridge-vsix.ps1",
            ["job:extension-unit"]
        },
        {
            ".vscode/launch.json",
            ["job:extension-unit"]
        },
        {
            ".vscode/tasks.json",
            ["job:extension-unit"]
        },
        {
            "eng/test-retry-patterns.json",
            ["test:Infrastructure.Tests"]
        },
        {
            "eng/scripts/aspire-skills-bundle.common.ps1",
            ["test:Infrastructure.Tests"]
        },
        {
            "eng/scripts/smoke-installed-cli.ps1",
            ["job:winget-installer"]
        },
        {
            "eng/scripts/smoke-installed-cli.sh",
            ["job:homebrew-installer"]
        },
        {
            // These paths do not exist intentionally: they prove new packaging inputs stay covered
            // without requiring this test to enumerate every current RID-specific project.
            "eng/dashboardpack/future-package-input.targets",
            ["test:Aspire.Cli.EndToEnd.Tests", "test:Aspire.Hosting.Sdk.Tests", "test:Aspire.Templates.Tests", "job:extension-e2e"]
        },
        {
            "eng/dcppack/future-package-input.targets",
            ["test:Aspire.Cli.EndToEnd.Tests", "test:Aspire.Hosting.Sdk.Tests", "test:Aspire.Templates.Tests", "test:Aspire.TerminalHost.Tests", "job:extension-e2e"]
        },
        {
            "eng/scripts/load-cli-e2e-images.sh",
            ["test:Aspire.Cli.EndToEnd.Tests"]
        },
        {
            "eng/clipack/Common.projitems",
            [
                "test:Aspire.Cli.Tests",
                "test:Aspire.Cli.EndToEnd.Tests",
                "test:Infrastructure.Tests",
                "job:cli-starter-validation",
                "job:extension-e2e",
                "job:homebrew-installer",
                "job:winget-installer"
            ]
        },
        {
            "eng/clipack/Aspire.Cli.win-x64.csproj",
            [
                "test:Aspire.Cli.Tests",
                "test:Infrastructure.Tests",
                "job:cli-starter-validation",
                "job:extension-e2e",
                "job:homebrew-installer",
                "job:winget-installer"
            ]
        },
        {
            "eng/clipack/Aspire.Cli.linux-musl-x64.csproj",
            [
                "test:Aspire.Cli.Tests",
                "test:Infrastructure.Tests",
                "job:homebrew-installer",
                "job:winget-installer"
            ]
        },
        {
            "eng/clipack/Aspire.Cli.linux-arm64.csproj",
            [
                "test:Aspire.Cli.Tests",
                "test:Infrastructure.Tests",
                "job:cli-starter-validation",
                "job:homebrew-installer",
                "job:winget-installer"
            ]
        },
        {
            "eng/clipack/npm/aspire.js",
            [
                "test:Aspire.Cli.Tests",
                "test:Infrastructure.Tests",
                "job:homebrew-installer",
                "job:winget-installer"
            ]
        },
        {
            "eng/clipack/Aspire.Cli.NativeSymbols.proj",
            [
                "test:Aspire.Cli.Tests",
                "test:Infrastructure.Tests",
                "job:homebrew-installer",
                "job:winget-installer"
            ]
        },
        {
            "eng/scripts/cli-starter-validation.ps1",
            ["job:cli-starter-validation"]
        },
        {
            "eng/scripts/get-aspire-cli-pr.ps1",
            [
                "test:Aspire.Acquisition.Tests",
                "test:Aspire.Cli.EndToEnd.Tests",
                "job:cli-starter-validation",
                "job:homebrew-installer",
                "job:winget-installer"
            ]
        },
        {
            "eng/scripts/get-aspire-cli-pr.sh",
            [
                "test:Aspire.Acquisition.Tests",
                "test:Aspire.Cli.EndToEnd.Tests",
                "job:homebrew-installer",
                "job:winget-installer"
            ]
        },
        {
            ".github/workflows/cli-starter-validation.yml",
            ["test:Infrastructure.Tests", "job:cli-starter-validation"]
        },
        {
            "eng/clipack/Aspire.Cli.linux-x64.csproj",
            ["test:Aspire.Cli.Tests", "test:Aspire.Cli.EndToEnd.Tests", "test:Infrastructure.Tests", "job:cli-starter-validation", "job:extension-e2e", "job:winget-installer", "job:homebrew-installer"]
        },
        {
            "tools/CreateLayout/Program.cs",
            ["test:Aspire.Cli.EndToEnd.Tests", "job:cli-starter-validation", "job:extension-e2e", "job:winget-installer", "job:homebrew-installer"]
        },
        {
            "eng/Bundle.proj",
            ["test:Aspire.Cli.EndToEnd.Tests", "job:cli-starter-validation", "job:extension-e2e", "job:winget-installer", "job:homebrew-installer"]
        },
        {
            "playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/aspire.config.json",
            ["test:Aspire.Playground.Tests", "job:extension-e2e"]
        },
        {
            ".gitignore",
            ["test:Infrastructure.Tests"]
        },
    };
 
    [Theory]
    [MemberData(nameof(AuditedLoosePathCases))]
    public void AuditedLoosePathSelectsExactConsumerSetWithoutRunAllFallback(string path, string[] expectedTargets)
    {
        var result = SelectWithRealMap(path);
 
        Assert.False(result.SelectsAll, $"{path} unexpectedly selected ALL: {result.EscalationReason}");
        Assert.Empty(result.UnmatchedFiles);
 
        var actualTargets = result.TestProjects.Select(name => $"test:{name}")
            .Concat(result.Jobs)
            .Order(StringComparer.Ordinal);
        Assert.Equal(expectedTargets.Order(StringComparer.Ordinal), actualTargets);
    }
 
    [Fact]
    [RequiresTools(["git"])]
    public void ExtensionJavaE2eDiscoveryInputsAreNotGitIgnored()
    {
        var startInfo = new System.Diagnostics.ProcessStartInfo("git")
        {
            WorkingDirectory = RepoRoot.Path,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
        };
        startInfo.ArgumentList.Add("--no-pager");
        startInfo.ArgumentList.Add("check-ignore");
        startInfo.ArgumentList.Add("--");
        startInfo.ArgumentList.Add("extension/java-e2e-workspace/AppHost.java");
        startInfo.ArgumentList.Add("extension/java-e2e-workspace/aspire.config.json");
        startInfo.ArgumentList.Add("extension/java-e2e-workspace/JavaSpringBoot.AppHost.Java/AppHost.java");
        startInfo.ArgumentList.Add("extension/java-e2e-workspace/JavaSpringBoot.AppHost.Java/aspire.config.json");
 
        using var process = System.Diagnostics.Process.Start(startInfo)
            ?? throw new InvalidOperationException("Failed to start git.");
        var ignoredPaths = process.StandardOutput.ReadToEnd();
        var stderr = process.StandardError.ReadToEnd();
        process.WaitForExit();
 
        Assert.True(
            process.ExitCode == 1,
            process.ExitCode == 0
                ? $"Git ignore rules must not exclude Java AppHost discovery inputs because aspire ls discovers them through git: {ignoredPaths}"
                : $"git check-ignore failed ({process.ExitCode}): {stderr}");
    }
 
    [Theory]
    [InlineData(".gitattributes")]
    [InlineData("eng/scripts/gha-testreport.ps1")]
    [InlineData("eng/scripts/split-test-projects-for-ci.ps1")]
    [InlineData("tools/ExtractTestPartitions/Program.cs")]
    public void BroadCiInputHasAnExplicitRunAllRule(string path)
    {
        var result = SelectWithRealMap(path);
 
        Assert.True(result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
    }
 
    [Theory]
    [InlineData("eng/scripts/verify-cli-npm-package.ps1")]
    [InlineData("eng/scripts/verify-cli-tool-nupkg.ps1")]
    [InlineData("eng/scripts/stabilization-smoke-init-restore.sh")]
    [InlineData("eng/generate-catalog.ps1")]
    [InlineData("eng/scripts/update-aspire-skills-bundle.ps1")]
    [InlineData("eng/scripts/verify-aspire-skills-bundle.ps1")]
    public void PathHandledOutsideSelectorDoesNotForceTestSelection(string path)
    {
        var result = SelectWithRealMap(path);
 
        Assert.False(result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
        Assert.Empty(result.TestProjects);
        Assert.Empty(result.Jobs);
    }
 
    [Theory]
    [InlineData("src/Aspire.Cli/Commands/RunCommand.cs", "Aspire.Cli")]
    [InlineData("src/Aspire.Managed/Program.cs", "Aspire.Managed")]
    [InlineData("src/Aspire.AppHost.Sdk/Aspire.AppHost.Sdk.csproj", "Aspire.AppHost.Sdk")]
    [InlineData("src/Aspire.Hosting.JavaScript/JavaScriptAppResource.cs", "Aspire.Hosting.JavaScript")]
    [InlineData("src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptApiProjector.cs", "Aspire.Hosting.CodeGeneration.TypeScript")]
    [InlineData("src/Aspire.Hosting.AppHost/Aspire.Hosting.AppHost.csproj", "Aspire.Hosting.AppHost")]
    [InlineData("src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs", "Aspire.Hosting.PostgreSQL")]
    public void CliStarterValidationRunsWhenStarterRuntimeDependencyIsAffected(string path, string affectedProject)
    {
        var result = SelectWithRealMap(path, affectedProject);
 
        Assert.False(result.SelectsAll);
        Assert.Contains("job:cli-starter-validation", result.Jobs);
    }
 
    [Theory]
    [InlineData("src/Aspire.ProjectTemplates/Aspire.ProjectTemplates.csproj")]
    [InlineData("src/Aspire.ProjectTemplates/templates/aspire-starter/Aspire-StarterApplication.1.AppHost/AppHost.cs")]
    [InlineData("eng/Bundle.proj")]
    public void CliStarterValidationRunsForLooseStarterConsumerChange(string path)
    {
        var result = SelectWithRealMap(path);
 
        Assert.False(result.SelectsAll);
        Assert.Contains("job:cli-starter-validation", result.Jobs);
    }
 
    [Theory]
    [InlineData("src/Aspire.ProjectTemplates/Aspire.ProjectTemplates.csproj")]
    // This path does not exist intentionally: it proves a newly added template is covered by the
    // package-wide rule instead of repeating the current template inventory in the test.
    [InlineData("src/Aspire.ProjectTemplates/templates/future-template/.template.config/template.json")]
    public void CliE2eRunsForProjectTemplatePackageInputs(string path)
    {
        var result = SelectWithRealMap(path);
 
        Assert.False(result.SelectsAll);
        Assert.Contains("Aspire.Cli.EndToEnd.Tests", result.TestProjects);
    }
 
    [Fact]
    public void CliBundleConsumersRunWhenCreateLayoutIsAffected()
    {
        var result = SelectWithRealMap("tools/CreateLayout/Program.cs", "CreateLayout");
 
        Assert.False(result.SelectsAll);
        Assert.Contains("Aspire.Cli.EndToEnd.Tests", result.TestProjects);
        Assert.Equal(
            ["job:cli-starter-validation", "job:extension-e2e", "job:homebrew-installer", "job:winget-installer"],
            result.Jobs.Order(StringComparer.Ordinal));
    }
 
    [Fact]
    public void DashboardChangesRunTemplatesTestsThatStartAppHosts()
    {
        var result = SelectWithRealMap(
            "src/Aspire.Dashboard/Program.cs",
            "Aspire.Dashboard");
 
        Assert.False(result.SelectsAll);
        Assert.Contains("Aspire.Templates.Tests", result.TestProjects);
    }
 
    [Fact]
    public void CliStarterValidationDoesNotRunForUnrelatedHostingIntegrationChange()
    {
        var result = SelectWithRealMap(
            "src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs",
            "Aspire.Hosting.Redis",
            "Aspire.Hosting.Redis.Tests",
            "Aspire.Hosting.Tests");
 
        Assert.False(result.SelectsAll);
        Assert.False(
            result.Jobs.Contains("job:cli-starter-validation", StringComparer.Ordinal),
            "Aggregate tests selected by Redis changes must not pull in all six starter-validation jobs.");
    }
 
    [Fact]
    public void CliStarterValidationDoesNotRunForNpgsqlClientChange()
    {
        var result = SelectWithRealMap(
            "src/Components/Aspire.Npgsql/NpgsqlCommon.cs",
            "Aspire.Npgsql",
            "Aspire.Npgsql.Tests",
            "Aspire.Hosting.PostgreSQL.Tests");
 
        Assert.False(result.SelectsAll);
        Assert.Empty(result.Jobs);
    }
 
    [Fact]
    public void UnrelatedProjectTemplateChangeRunsCliE2eAndTemplatesTestsWithoutStarterValidation()
    {
        var result = SelectWithRealMap(
            "src/Aspire.ProjectTemplates/templates/aspire-xunit/.template.config/template.json");
 
        Assert.False(result.SelectsAll);
        Assert.Equal(
            ["Aspire.Cli.EndToEnd.Tests", "Aspire.Templates.Tests"],
            result.TestProjects.Order(StringComparer.Ordinal));
        Assert.Equal(
            ["job:deployment-e2e", "job:homebrew-installer", "job:winget-installer"],
            result.Jobs.Order(StringComparer.Ordinal));
    }
 
    [Fact]
    public void CliStarterValidationDoesNotRunForSharedTestUtilityChange()
    {
        var result = SelectWithRealMap(
            "tests/Aspire.TestUtilities/FileUtil.cs",
            "Aspire.TestUtilities",
            "Aspire.Cli.Tests",
            "Aspire.Cli.EndToEnd.Tests");
 
        Assert.False(result.SelectsAll);
        Assert.False(
            result.Jobs.Contains("job:cli-starter-validation", StringComparer.Ordinal),
            "Shared test utilities must not pull in all six starter-validation jobs.");
    }
 
    [Fact]
    public void CliStarterValidationDoesNotRunForHostingSdkTestFakeChange()
    {
        var result = SelectWithRealMap(
            "tests/Aspire.Hosting.Sdk.Tests.FakeCommand/Program.cs",
            "Aspire.Hosting.Sdk.Tests.FakeCommand",
            "Aspire.Hosting.Sdk.Tests");
 
        Assert.False(result.SelectsAll);
        Assert.Equal(
            ["job:typescript-api-compat"],
            result.Jobs.Order(StringComparer.Ordinal));
    }
 
    [Fact]
    public void SetupForTestsSelectionOutputsAreConsistentWithMap()
    {
        // The non-.NET job gates flow: SelectTests emits a `selection` JSON object keyed run_<job>
        // (job:winget-installer -> run_winget_installer); tests.yml's setup_for_tests unpacks each via
        // fromJSON(steps.select_tests.outputs.selection).run_<job> into a job output; and each gated job
        // reads needs.setup_for_tests.outputs.run_<job> == 'true'. A name that drifts anywhere in that
        // chain silently empties the output, the if: reads false, and the job NEVER RUNS behind a green
        // check -- the under-selection selective CI must never do. Pin the set-level invariants against
        // the real workflow + real map (per-job correctness is EachGatedJobConditionUsesItsOwnSelectionBoolean).
        var testsYml = File.ReadAllText(Path.Combine(RepoRoot.Path, ".github", "workflows", "tests.yml"));
 
        // Outputs setup_for_tests unpacks from the selection object, e.g.
        //   run_polyglot: ${{ fromJSON(steps.select_tests.outputs.selection).run_polyglot }}
        var declared = System.Text.RegularExpressions.Regex
            .Matches(testsYml, @"fromJSON\(steps\.select_tests\.outputs\.selection\)\.(run_[a-z0-9_]+)")
            .Select(m => m.Groups[1].Value)
            .ToHashSet(StringComparer.Ordinal);
 
        // run_<job> outputs consumed by a gate, e.g. needs.setup_for_tests.outputs.run_polyglot.
        var consumed = System.Text.RegularExpressions.Regex
            .Matches(testsYml, @"needs\.setup_for_tests\.outputs\.(run_[a-z0-9_]+)")
            .Select(m => m.Groups[1].Value)
            .ToHashSet(StringComparer.Ordinal);
 
        // run_<job> keys SelectTests can actually emit, derived from the real map's job: vocabulary --
        // mirrors Program.WriteJobBooleans ("run_" + token without "job:", '-' -> '_').
        var emittable = s_map.AllReferencedTargets()
            .Where(t => t.StartsWith("job:", StringComparison.Ordinal))
            .Select(t => "run_" + t["job:".Length..].Replace('-', '_'))
            .ToHashSet(StringComparer.Ordinal);
        var expectedAdvisoryJobs = s_map.AllReferencedTargets()
            .Where(t => t.StartsWith("job:", StringComparison.Ordinal))
            .Where(t => !declared.Contains("run_" + t["job:".Length..].Replace('-', '_')))
            .ToHashSet(StringComparer.Ordinal);
        var advisoryJobField = typeof(Selection).GetField(
            "s_advisoryJobTargets",
            System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
        Assert.NotNull(advisoryJobField);
        var configuredAdvisoryJobs = Assert.IsAssignableFrom<IReadOnlyDictionary<string, string>>(
            advisoryJobField.GetValue(null));
 
        // Sanity: the regexes actually matched, so a pattern slip can't make the asserts vacuously pass.
        Assert.Contains("run_winget_installer", declared);
        Assert.Contains("run_winget_installer", consumed);
        Assert.Equal(
            expectedAdvisoryJobs.Order(StringComparer.Ordinal),
            configuredAdvisoryJobs.Keys.Order(StringComparer.Ordinal));
 
        // Finding 1: every unpacked output is a key the tool emits. A typo'd fromJSON property or a
        // renamed/removed map token leaves the output permanently empty (job silently skipped).
        var declaredNotEmittable = declared.Except(emittable).Order(StringComparer.Ordinal).ToList();
        Assert.True(declaredNotEmittable.Count == 0,
            $"setup_for_tests unpacks selection keys SelectTests never emits (no matching job: in the map): {string.Join(", ", declaredNotEmittable)}");
 
        // Finding 2: every gate reads an output setup_for_tests declares. A new gated job whose if:
        // references run_<job> but whose unpack line was forgotten reads an empty output -> never runs.
        var consumedNotDeclared = consumed.Except(declared).Order(StringComparer.Ordinal).ToList();
        Assert.True(consumedNotDeclared.Count == 0,
            $"job if: conditions read run_* outputs that setup_for_tests never unpacks: {string.Join(", ", consumedNotDeclared)}");
 
        // Dead output: a run_* setup_for_tests exposes that no gate reads is a leftover (or a job wired
        // to the wrong var). Every unpacked output must gate at least one job.
        var declaredUnused = declared.Except(consumed).Order(StringComparer.Ordinal).ToList();
        Assert.True(declaredUnused.Count == 0,
            $"setup_for_tests unpacks run_* outputs that no job if: consumes: {string.Join(", ", declaredUnused)}");
    }
 
    [Fact]
    public void EachGatedJobConditionUsesItsOwnSelectionBoolean()
    {
        // The set-level test above proves the run_* names line up, but not that each job gates on ITS
        // OWN boolean. The names are uniform (job:winget-installer -> run_winget_installer), so a
        // copy-paste leaving a job reading a sibling's var (e.g. the WinGet prep job on
        // run_homebrew_installer) passes every set check yet runs the wrong job for a change. Pin the
        // job-id -> run_<job> binding for the tests.yml jobs each gated job: token resolves to.
        //
        // This list is hand-maintained intent (like EveryJobTargetResolvesToExistingWorkflowOrJob's
        // job-id mapping): a new gated job must be added here too. The check is substring containment,
        // so it catches a job NOT referencing its own var; it intentionally tolerates extra vars -- the
        // extension jobs additionally OR-in run_extension_e2e because extension_e2e_tests needs the
        // VSIX and the corepack bootstrap they produce.
        var testsYml = File.ReadAllText(Path.Combine(RepoRoot.Path, ".github", "workflows", "tests.yml"));
 
        var bindings = new[]
        {
            ("polyglot_validation", "run_polyglot"),
            ("typescript_sdk_tests", "run_typescript_sdk"),
            ("typescript_api_compat", "run_typescript_api_compat"),
            ("extension_tests_win", "run_extension_unit"),
            ("extension_bootstrap_linux", "run_extension_unit"),
            ("extension_e2e_tests", "run_extension_e2e"),
            ("prepare_winget_installer_artifacts", "run_winget_installer"),
            ("prepare_homebrew_installer_artifacts", "run_homebrew_installer"),
            ("nix_package", "run_nix_package"),
        }
        .Concat(s_cliStarterValidationJobIds.Select(jobId => (jobId, "run_cli_starter_validation")))
        .ToArray();
 
        var wrong = new List<string>();
        foreach (var (jobId, runVar) in bindings)
        {
            var block = JobBlock(testsYml, jobId);
            Assert.True(block is not null, $"job '{jobId}' not found in tests.yml");
            if (!block!.Contains($"needs.setup_for_tests.outputs.{runVar}", StringComparison.Ordinal))
            {
                wrong.Add($"{jobId} (expected {runVar})");
            }
        }
 
        Assert.True(wrong.Count == 0,
            $"gated jobs whose if: does not reference their own selection boolean: {string.Join("; ", wrong)}");
    }
 
    [Fact]
    public void CliStarterValidationArchivePathsMatchConsumedWorkflowRids()
    {
        var workflow = new YamlStream();
        using (var reader = new StringReader(File.ReadAllText(Path.Combine(RepoRoot.Path, ".github", "workflows", "tests.yml"))))
        {
            workflow.Load(reader);
        }
 
        var root = Assert.IsType<YamlMappingNode>(workflow.Documents[0].RootNode);
        var jobs = Assert.IsType<YamlMappingNode>(root.Children[new YamlScalarNode("jobs")]);
        var expectedArchivePaths = new List<string>();
 
        foreach (var jobId in s_cliStarterValidationJobIds)
        {
            var starterJob = Assert.IsType<YamlMappingNode>(jobs.Children[new YamlScalarNode(jobId)]);
            var needs = Assert.IsType<YamlSequenceNode>(starterJob.Children[new YamlScalarNode("needs")]);
            var archiveJobId = Assert.Single(
                needs.Children.OfType<YamlScalarNode>(),
                node => node.Value?.StartsWith("build_cli_archive_", StringComparison.Ordinal) is true).Value;
            Assert.NotNull(archiveJobId);
 
            var archiveJob = Assert.IsType<YamlMappingNode>(jobs.Children[new YamlScalarNode(archiveJobId)]);
            Assert.Equal("./.github/workflows/build-cli-native-archives.yml", archiveJob.Children[new YamlScalarNode("uses")].ToString());
            var inputs = Assert.IsType<YamlMappingNode>(archiveJob.Children[new YamlScalarNode("with")]);
            var targetsJson = Assert.IsType<YamlScalarNode>(inputs.Children[new YamlScalarNode("targets")]).Value;
            Assert.False(string.IsNullOrWhiteSpace(targetsJson), $"CLI archive build job '{archiveJobId}' has no targets input.");
 
            using var targets = JsonDocument.Parse(targetsJson);
            var target = Assert.Single(targets.RootElement.EnumerateArray());
            Assert.True(target.TryGetProperty("rids", out var rid), $"CLI archive build job '{archiveJobId}' has no RID target.");
            var ridValue = rid.GetString();
            Assert.False(string.IsNullOrWhiteSpace(ridValue), $"CLI archive build job '{archiveJobId}' has an empty RID target.");
 
            expectedArchivePaths.Add($"eng/clipack/Aspire.Cli.{ridValue}.csproj");
        }
 
        var archiveRule = Assert.Single(
            s_map.PathRules,
            rule => rule.Targets.Contains("job:cli-starter-validation", StringComparer.Ordinal)
                && rule.Paths.Contains("eng/clipack/Common.projitems", StringComparer.Ordinal));
 
        Assert.Equal(
            expectedArchivePaths.Order(StringComparer.Ordinal),
            archiveRule.Paths
                .Where(path => path != "eng/clipack/Common.projitems")
                .Order(StringComparer.Ordinal));
    }
 
    [Theory]
    [InlineData("tests_requires_cli_archive", "test:Aspire.Cli.EndToEnd.Tests")]
    [InlineData("extension_e2e_tests", "job:extension-e2e")]
    public void CliArchivePathsMatchWorkflowDependencies(string workflowJobId, string target)
    {
        var expectedArchivePaths = ArchiveRidsRequiredByJob(workflowJobId)
            .Select(rid => $"eng/clipack/Aspire.Cli.{rid}.csproj")
            .Order(StringComparer.Ordinal);
        var archiveRule = Assert.Single(
            s_map.PathRules,
            rule => rule.Targets.Contains(target, StringComparer.Ordinal)
                && rule.Paths.Contains("eng/clipack/Common.projitems", StringComparer.Ordinal));
 
        Assert.Equal(
            expectedArchivePaths,
            archiveRule.Paths
                .Where(path => path != "eng/clipack/Common.projitems")
                .Order(StringComparer.Ordinal));
    }
 
    [Fact]
    public void ExtensionE2eWorkflowRidsMatchArchiveDependencies()
    {
        var workflow = new YamlStream();
        using (var reader = new StringReader(File.ReadAllText(
            Path.Combine(RepoRoot.Path, ".github", "workflows", "extension-e2e-tests.yml"))))
        {
            workflow.Load(reader);
        }
 
        var root = Assert.IsType<YamlMappingNode>(workflow.Documents[0].RootNode);
        var jobs = Assert.IsType<YamlMappingNode>(root.Children[new YamlScalarNode("jobs")]);
        var extensionE2e = Assert.IsType<YamlMappingNode>(jobs.Children[new YamlScalarNode("extension_e2e")]);
        var strategy = Assert.IsType<YamlMappingNode>(extensionE2e.Children[new YamlScalarNode("strategy")]);
        var matrix = Assert.IsType<YamlMappingNode>(strategy.Children[new YamlScalarNode("matrix")]);
        var include = Assert.IsType<YamlSequenceNode>(matrix.Children[new YamlScalarNode("include")]);
        var consumedRids = include.Children
            .Select(node => Assert.IsType<YamlMappingNode>(node).Children[new YamlScalarNode("rid")].ToString())
            .Distinct(StringComparer.Ordinal)
            .Order(StringComparer.Ordinal);
 
        Assert.Equal(
            consumedRids,
            ArchiveRidsRequiredByJob("extension_e2e_tests").Order(StringComparer.Ordinal));
    }
 
    [Fact]
    public void CliStarterValidationSkipChecksAreRequiredOnlyWhenSelected()
    {
        var testsYml = File.ReadAllText(Path.Combine(RepoRoot.Path, ".github", "workflows", "tests.yml"));
        var resultsBlock = JobBlock(testsYml, "results");
        Assert.NotNull(resultsBlock);
        var normalizedResultsBlock = System.Text.RegularExpressions.Regex.Replace(resultsBlock, @"\s+", " ");
        const string selectionGuard = "needs.setup_for_tests.outputs.run_cli_starter_validation == 'true'";
        var groupedSkipCheck =
            $"({selectionGuard} && ({string.Join(
                " || ",
                s_cliStarterValidationJobIds.Select(jobId => $"needs.{jobId}.result == 'skipped'"))}))";
 
        Assert.Contains(groupedSkipCheck, normalizedResultsBlock, StringComparison.Ordinal);
        Assert.Equal(1, normalizedResultsBlock.Split(selectionGuard, StringSplitOptions.None).Length - 1);
    }
 
    [Fact]
    public void EveryTestTargetNamesAnExistingTestProject()
    {
        // test:<X> means the matrix project tests/<X> (run-tests.yml entry). A rename or
        // removal that the curated map misses would silently stop selecting that project,
        // so require tests/<X>/<X>.csproj to exist on disk.
        var missing = s_map.AllReferencedTargets()
            .Where(t => t.StartsWith("test:", StringComparison.Ordinal))
            .Select(t => t["test:".Length..])
            .Distinct(StringComparer.Ordinal)
            .Where(name => !File.Exists(Path.Combine(RepoRoot.Path, "tests", name, $"{name}.csproj")))
            .Order(StringComparer.Ordinal)
            .ToList();
 
        Assert.True(missing.Count == 0, $"test: targets with no tests/<X>/<X>.csproj: {string.Join(", ", missing)}");
    }
 
    [Fact]
    public void PrefilterPatternsFileExists()
    {
        // The prefilter reads its pattern list from this file at runtime (SSOT with the ci.yml skip
        // gate). A typo'd path would make ChangedFileFilter.Create throw at runtime; pin it here.
        Assert.NotNull(s_map.Prefilter);
        Assert.False(string.IsNullOrWhiteSpace(s_map.Prefilter!.PatternsFile));
        Assert.True(File.Exists(Path.Combine(RepoRoot.Path, s_map.Prefilter.PatternsFile!)),
            $"prefilter.patterns_file does not exist: {s_map.Prefilter.PatternsFile}");
    }
 
    [Fact]
    public void PrefilterKeepRoutedGlobsAreLiveAndCoverSelectorRoutedDirs()
    {
        // keep_routed are the carve-outs: files the patterns file lists but the selector routes to a
        // target, so they must NOT be dropped. Each must match a real file (catch typos), and the two
        // selector-routed dirs must be covered so a workflow/pipeline change still runs Infrastructure
        // .Tests in a mixed PR.
        Assert.NotNull(s_map.Prefilter);
        var keepRouted = s_map.Prefilter!.KeepRouted;
 
        var dead = keepRouted.Where(g => !GlobMatchesAnyTrackedFile(g)).Order(StringComparer.Ordinal).ToList();
        Assert.True(dead.Count == 0, $"prefilter.keep_routed globs matching no tracked file: {string.Join(", ", dead)}");
 
        foreach (var required in new[] { ".github/workflows/**", "eng/pipelines/**" })
        {
            Assert.Contains(required, keepRouted);
        }
    }
 
    [Fact]
    public void EveryTestsSharedFileIsCsAttributedOrRoutedToAllOrExcluded()
    {
        // tests/Shared has both link-compiled *.cs (Layer 1 attributes them precisely, so the run-all
        // fallback treats them as owned — they need no curated rule) and non-source build/fixture infra
        // (props/targets/packages/Docker/Playwright/certs -> ALL). A tests/Shared file that is NEITHER a
        // *.cs NOR matched by an ALL path rule NOR a doc (excluded by the prefilter) would silently select
        // no targeted work: it is not under a project dir (so directory containment can't attribute a
        // loose file there) and would force the run-all fallback. Pin the invariant so a new file type
        // added under tests/Shared can't quietly expand to ALL. (.md files are dropped by the prefilter's
        // **.md.)
        var allGlobs = s_map.PathRules
            .Where(r => r.Targets.Contains("ALL", StringComparer.Ordinal))
            .SelectMany(r => r.Paths)
            .ToList();
 
        bool RoutedToAll(string file) => allGlobs.Any(g => TestTriggerMap.GlobMatches(g, file));
 
        var orphaned = s_trackedFiles
            .Where(f => f.StartsWith("tests/Shared/", StringComparison.Ordinal))
            .Where(f => !f.EndsWith(".cs", StringComparison.Ordinal))
            .Where(f => !f.EndsWith(".md", StringComparison.Ordinal))
            .Where(f => !RoutedToAll(f))
            .Order(StringComparer.Ordinal)
            .ToList();
 
        Assert.True(orphaned.Count == 0,
            $"tests/Shared files that are neither *.cs (Layer 1-attributed), *.md (prefiltered), nor routed " +
            $"to ALL (they would silently select nothing): {string.Join(", ", orphaned)}");
    }
 
    [Fact]
    public void EveryLocalActionUsedByAWorkflowIsRoutedToAll()
    {
        // A local composite action (.github/actions/<name>) is not a project, so Layer 1 never attributes
        // a change to it. An action that no path rule matches therefore forces the run-all fallback, but
        // its CI-wide impact should be explicit rather than appearing as an unattributed audit warning.
        // The map routes .github/actions/** -> ALL to cover this; pin the invariant so a new action
        // referenced from a workflow can't lose that explicit ownership if the rule is narrowed.
        // Failure mode: drop the .github/actions/** ALL rule and this goes red.
        var allGlobs = s_map.PathRules
            .Where(r => r.Targets.Contains("ALL", StringComparer.Ordinal))
            .SelectMany(r => r.Paths)
            .ToList();
 
        bool RoutedToAll(string file) => allGlobs.Any(g => TestTriggerMap.GlobMatches(g, file));
 
        // `uses: ./.github/actions/<name>` (with optional surrounding quotes / a trailing @ref) names a
        // local composite action whose definition lives at .github/actions/<name>/action.yml.
        var usesLocalAction = new System.Text.RegularExpressions.Regex(
            @"uses:\s*['""]?\./\.github/actions/(?<name>[^\s'""@]+)",
            System.Text.RegularExpressions.RegexOptions.IgnoreCase);
 
        var workflowsDir = Path.Combine(RepoRoot.Path, ".github", "workflows");
        var referencedActions = Directory.EnumerateFiles(workflowsDir, "*.yml")
            .SelectMany(f => usesLocalAction.Matches(File.ReadAllText(f)).Select(m => m.Groups["name"].Value))
            .Distinct(StringComparer.Ordinal)
            .ToList();
 
        // Sanity: the scan finds the actions we know are referenced, so a regex slip can't make this
        // assertion vacuously pass.
        Assert.Contains("enumerate-tests", referencedActions);
 
        var unrouted = referencedActions
            .Where(name => !RoutedToAll($".github/actions/{name}/action.yml"))
            .Order(StringComparer.Ordinal)
            .ToList();
 
        Assert.True(unrouted.Count == 0,
            $"local actions referenced by a workflow but not routed to ALL (a change to them would select " +
            $"ALL only through the unattributed fallback): {string.Join(", ", unrouted)}");
    }
 
    private static SelectionResult SelectWithRealMap(string path, params string[] layer1Affected)
    {
        var projectPaths = LoadSolutionProjectPaths();
        var testProjects = projectPaths
            .Where(projectPath => projectPath.StartsWith("tests/", StringComparison.Ordinal))
            .Select(projectPath => Path.GetFileNameWithoutExtension(projectPath)!)
            .Where(name => name.EndsWith(".Tests", StringComparison.Ordinal))
            .ToHashSet(StringComparer.Ordinal);
        var projectDirectories = projectPaths
            .Select(projectPath => Path.GetDirectoryName(projectPath)!.Replace('\\', '/'))
            .ToHashSet(StringComparer.Ordinal);
        var mapPath = Path.Combine(RepoRoot.Path, "eng", "github-ci", "test-trigger-map.yml");
        var selector = new TestSelector(mapPath, testProjects, projectDirectories);
 
        return selector.Select([path], layer1Affected, new SelectorOptions());
    }
 
    private static IReadOnlyList<string> ArchiveRidsRequiredByJob(string jobId)
    {
        // Resolve workflow dependencies shaped as:
        //   extension_e2e_tests:
        //     needs: [..., build_cli_archive_linux]
        //   build_cli_archive_linux:
        //     with:
        //       targets: '[{"os":"ubuntu-latest","runner":"8-core-ubuntu-latest","rids":"linux-x64"}]'
        var workflow = new YamlStream();
        using (var reader = new StringReader(File.ReadAllText(
            Path.Combine(RepoRoot.Path, ".github", "workflows", "tests.yml"))))
        {
            workflow.Load(reader);
        }
 
        var root = Assert.IsType<YamlMappingNode>(workflow.Documents[0].RootNode);
        var jobs = Assert.IsType<YamlMappingNode>(root.Children[new YamlScalarNode("jobs")]);
        var job = Assert.IsType<YamlMappingNode>(jobs.Children[new YamlScalarNode(jobId)]);
        var needs = Assert.IsType<YamlSequenceNode>(job.Children[new YamlScalarNode("needs")]);
        var archiveJobs = needs.Children
            .OfType<YamlScalarNode>()
            .Select(node => node.Value)
            .Where(value => !string.IsNullOrWhiteSpace(value))
            .Select(value => (
                Id: value!,
                Job: Assert.IsType<YamlMappingNode>(jobs.Children[new YamlScalarNode(value!)])))
            .Where(entry => entry.Job.Children.TryGetValue(new YamlScalarNode("uses"), out var uses)
                && uses.ToString() == "./.github/workflows/build-cli-native-archives.yml")
            .ToList();
        Assert.NotEmpty(archiveJobs);
 
        var rids = new List<string>();
        foreach (var (archiveJobId, archiveJob) in archiveJobs)
        {
            var inputs = Assert.IsType<YamlMappingNode>(archiveJob.Children[new YamlScalarNode("with")]);
            var targetsJson = Assert.IsType<YamlScalarNode>(inputs.Children[new YamlScalarNode("targets")]).Value;
            Assert.False(string.IsNullOrWhiteSpace(targetsJson), $"CLI archive build job '{archiveJobId}' has no targets input.");
 
            using var targets = JsonDocument.Parse(targetsJson);
            foreach (var target in targets.RootElement.EnumerateArray())
            {
                Assert.True(target.TryGetProperty("rids", out var rid), $"CLI archive build job '{archiveJobId}' has no RID target.");
                var ridValue = rid.GetString();
                Assert.False(string.IsNullOrWhiteSpace(ridValue), $"CLI archive build job '{archiveJobId}' has an empty RID target.");
                rids.Add(ridValue);
            }
        }
 
        return rids.Distinct(StringComparer.Ordinal).ToList();
    }
 
    private static string TextBetween(string text, string startMarker, string endMarker)
    {
        var start = text.IndexOf(startMarker, StringComparison.Ordinal);
        Assert.True(start >= 0, $"Start marker not found: {startMarker}");
 
        var end = text.IndexOf(endMarker, start + startMarker.Length, StringComparison.Ordinal);
        Assert.True(end >= 0, $"End marker not found after {startMarker}: {endMarker}");
 
        return text[start..end];
    }
 
    // Text of a top-level tests.yml job block: from "\n  <id>:" up to the next line indented exactly
    // two spaces that starts a new key (the next job), or end of file. Top-level jobs sit at two-space
    // indent and everything inside a job is indented further, so a two-space `word:` reliably delimits
    // the block. Returns null when the job id is absent.
    private static string? JobBlock(string workflow, string jobId)
    {
        var marker = $"\n  {jobId}:";
        var start = workflow.IndexOf(marker, StringComparison.Ordinal);
        if (start < 0)
        {
            return null;
        }
 
        var next = System.Text.RegularExpressions.Regex.Match(
            workflow[(start + marker.Length)..],
            @"\n  [A-Za-z0-9_]+:");
        return next.Success
            ? workflow.Substring(start, marker.Length + next.Index)
            : workflow[start..];
    }
}