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 Microsoft.Extensions.FileSystemGlobbing;
using Xunit;
 
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();
 
    [Fact]
    public void MapLoadsWithExpectedVersion()
    {
        Assert.Equal(1, s_map.Version);
    }
 
    [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. Each one 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:api-diffs"] = () => WorkflowExists("generate-api-diffs.yml"),
            ["job:ats-diffs"] = () => WorkflowExists("generate-ats-diffs.yml"),
            ["job:deployment-e2e"] = () => WorkflowExists("deployment-tests.yml"),
        };
 
        var referenced = s_map.AllReferencedTargets()
            .Where(t => t.StartsWith("job:", StringComparison.Ordinal))
            .Distinct(StringComparer.Ordinal)
            .ToList();
 
        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 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 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);
 
        // 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);
 
        // 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"),
        };
 
        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 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
        // nothing: it is not under a project dir (so directory containment can't attribute a loose file
        // there) and the run-all fallback is src/-only. Pin the invariant so a new file type added under
        // tests/Shared can't quietly fall through. (.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, and the run-all fallback in TestSelector escalates only src/** files. So a
        // changed action that no path rule matches selects NOTHING in enforce mode -- a PR editing a
        // CI-critical action (the skip gate, the macOS keychain unlock, enumerate-tests, ...) would
        // silently skip all tests. The map routes .github/actions/** -> ALL to cover this; pin the
        // invariant so a new action referenced from a workflow can't fall through if that rule is ever
        // 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 " +
            $"nothing in enforce mode): {string.Join(", ", unrouted)}");
    }
 
    // 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..];
    }
}