规则

quality-gates

Establish and audit explicit repository merge and release quality gates, required checks, waivers, and signatures.

目录

Quality gates

Use this project policy when a repository has, or is establishing, an explicit contract for merge and release readiness. The repo-local declaration is the source of truth; GitHub branch rules and workflow names are execution surfaces that must align with it.

Workflow

  1. Copy assets/templates/quality-gates.example.json to the project's declared quality-gate path and fill the real default branch, required checks, informational checks, signatures, and waivers.
  2. Keep PR gates separate from post-merge and release gates. Include every real delivery surface, not only the easiest test suite.
  3. Run assets/scripts/check_quality_gates.py after each declaration or workflow change. A waiver must identify scope, owner, reason, and expiry.
  4. Align default-branch protection and required checks; treat direct pushes and bypasses as explicit exceptions.

If review policy is declared, preserve maintainer exceptions through native PR rules. Do not infer API compatibility or migration safety from a green gate; those belong to their dedicated policies.

Adoption preview

Before applying this policy to a target project, preview the writes to .agents/skills/quality-gates/ and skills-lock.json, then wait for explicit owner approval. Install it with npx skills add IvanLi-CN/style-playbook-skills --skill quality-gates --yes; the CLI detects the Agent and project layout.

Package resources

  • assets/templates/quality-gates.example.json
  • assets/templates/waiver-record.example.json
  • assets/scripts/check_quality_gates.py

手动安装

将指令保存为技能目录中的 SKILL.md,并保持下列资源的相对路径。阅读与下载不会执行安装。

下载 SKILL.md

公开资源

SKILL.md
SKILL.md
markdown · 38 行
预览方式
description
Establish and audit explicit repository merge and release quality gates, required checks, waivers, and signatures.
kind
policy-skill
name
quality-gates
policy_dependencies
[]
primary_topic
quality-gates
public_url_hosts
[]
schema_version
1
slug
quality-gates
visibility
public

Quality gates

Use this project policy when a repository has, or is establishing, an explicit contract for merge and release readiness. The repo-local declaration is the source of truth; GitHub branch rules and workflow names are execution surfaces that must align with it.

Workflow

  1. Copy assets/templates/quality-gates.example.json to the project's declared quality-gate path and fill the real default branch, required checks, informational checks, signatures, and waivers.
  2. Keep PR gates separate from post-merge and release gates. Include every real delivery surface, not only the easiest test suite.
  3. Run assets/scripts/check_quality_gates.py after each declaration or workflow change. A waiver must identify scope, owner, reason, and expiry.
  4. Align default-branch protection and required checks; treat direct pushes and bypasses as explicit exceptions.

If review policy is declared, preserve maintainer exceptions through native PR rules. Do not infer API compatibility or migration safety from a green gate; those belong to their dedicated policies.

Adoption preview

Before applying this policy to a target project, preview the writes to .agents/skills/quality-gates/ and skills-lock.json, then wait for explicit owner approval. Install it with npx skills add IvanLi-CN/style-playbook-skills --skill quality-gates --yes; the CLI detects the Agent and project layout.

Package resources

  • assets/templates/quality-gates.example.json
  • assets/templates/waiver-record.example.json
  • assets/scripts/check_quality_gates.py
skills/policies/quality-gates/assets/scripts/check_quality_gates.py
skills/policies/quality-gates/assets/scripts/check_quality_gates.py
python · 2966 行
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
from datetime import datetime
from dataclasses import dataclass, field, replace
from itertools import product
from pathlib import Path
from typing import Any
REVIEW_POLICY_WORKFLOW_NAMES = {"Review Policy", "review-policy"}
REQUIRED_REVIEW_POLICY_TRIGGER_TYPES = {
    "pull_request_target": {"opened", "reopened", "synchronize", "ready_for_review", "labeled", "unlabeled"},
    "pull_request_review": {"submitted", "dismissed", "edited"},
    "merge_group": {"checks_requested"},
}
DEFAULT_PULL_REQUEST_ACTIVITY_TYPES = {"opened", "synchronize", "reopened"}
PR_GATE_WORKFLOW_EVENTS = {"pull_request", "pull_request_target"}
# Workflows that can emit check contexts against commits that participate in merge gating.
CHECK_CONTEXT_WORKFLOW_EVENTS = PR_GATE_WORKFLOW_EVENTS | {"push", "merge_group"}
REPO_COLLABORATOR_ROLE_NAMES = {"admin", "maintain", "write", "triage", "read", "none"}
class JsonInputError(Exception):
    def __init__(self, path: Path, message: str):
        super().__init__(f"{path}: {message}")
        self.path = path
        self.message = message

class InputValidationError(Exception):
    pass

def load_json(path: Path) -> Any:
    try:
        raw = path.read_text(encoding="utf-8")
    except OSError as exc:
        reason = exc.strerror or str(exc)
        raise JsonInputError(path, f"failed to read file: {reason}") from None
    try:
        return json.loads(raw)
    except json.JSONDecodeError as exc:
        raise JsonInputError(
            path,
            f"invalid JSON: {exc.msg} (line {exc.lineno}, column {exc.colno})",
        ) from None
def strip_inline_comment(value: str) -> str:
    output: list[str] = []
    quote: str | None = None
    for index, char in enumerate(value):
        if quote is not None:
            output.append(char)
            if char == quote:
                quote = None
            continue
        if char in {'"', "'"}:
            quote = char
            output.append(char)
            continue
        if char == '#' and (index == 0 or value[index - 1].isspace()):
            break
        output.append(char)
    return ''.join(output).rstrip()
def parse_scalar(value: str) -> str:
    value = strip_inline_comment(value).strip()
    if not value:
        return ""
    if value[0] == value[-1] and value[0] in {'"', "'"}:
        inner = value[1:-1]
        # Best-effort YAML double-quoted scalar unescaping. This validator intentionally uses a
        # lightweight scanner instead of a full YAML parser, but we still want to accept common
        # escape sequences such as \" in job names and env JSON strings.
        if value[0] == '"':
            inner = inner.replace('\\"', '"').replace("\\\\", "\\")
        else:
            # YAML single-quoted scalars escape a single quote by doubling it.
            inner = inner.replace("''", "'")
        return inner
    return value

def split_mapping_line(normalized: str) -> tuple[str | None, str]:
    """Split a YAML `key: value` line and normalize quoted keys.

    The validator intentionally uses lightweight scanning instead of a full YAML
    parser. This helper keeps the scanners tolerant of `'key': value` forms.
    """
    if ":" not in normalized:
        return None, ""
    raw_key, tail = normalized.split(":", 1)
    key = parse_scalar(raw_key.strip())
    return (key if key else None), tail.strip()

def flow_brace_depth(value: str, depth: int = 0, quote: str | None = None) -> tuple[int, str | None]:
    """Track `{ ... }` flow-mapping balance for lightweight YAML scanning.

    GitHub Actions workflows often use multi-line flow mappings, e.g.
      jobs: {
        lint: { name: Lint, ... }
      }
    Our parser uses `parse_inline_mapping()` which expects the mapping to be on one line,
    so we collapse multi-line flow mappings into a single line before scanning.
    """
    for char in value:
        if quote is not None:
            if char == quote:
                quote = None
            continue
        if char in {'"', "'"}:
            quote = char
            continue
        if char == "{":
            depth += 1
            continue
        if char == "}":
            depth = max(0, depth - 1)
            continue
    return depth, quote

def collapse_multiline_flow_mappings(lines: list[str]) -> list[str]:
    """Collapse multi-line flow mappings (`{ ... }`) into a single logical line."""
    output: list[str] = []
    i = 0
    while i < len(lines):
        raw = lines[i]
        if not raw.strip() or raw.lstrip().startswith("#"):
            output.append(raw)
            i += 1
            continue
        indent_prefix = raw[: len(raw) - len(raw.lstrip(" "))]
        normalized = strip_inline_comment(raw.strip())
        if not normalized or ":" not in normalized:
            output.append(raw)
            i += 1
            continue

        head, tail = normalized.split(":", 1)
        tail = tail.strip()
        tail_token = tail.lstrip()
        if not (tail_token.startswith("{") or tail_token.startswith("&")):
            output.append(raw)
            i += 1
            continue

        depth, quote = flow_brace_depth(tail)
        if depth <= 0:
            output.append(raw)
            i += 1
            continue

        collected = tail
        j = i
        while depth > 0 and j + 1 < len(lines):
            j += 1
            nxt = lines[j]
            if not nxt.strip() or nxt.lstrip().startswith("#"):
                continue
            nxt_normalized = strip_inline_comment(nxt.strip())
            if not nxt_normalized:
                continue
            collected += " " + nxt_normalized.strip()
            depth, quote = flow_brace_depth(nxt_normalized, depth, quote)

        if depth != 0:
            # Unbalanced braces: keep the original line to avoid hiding syntax errors.
            output.append(raw)
            i += 1
            continue

        output.append(f"{indent_prefix}{head.strip()}: {collected.strip()}")
        i = j + 1
    return output

def read_yaml_lines(path: Path) -> list[str]:
    return collapse_multiline_flow_mappings(path.read_text(encoding="utf-8").splitlines())

ALWAYS_FALSE_JOB_IF_RE = re.compile(r"^\$\{\{\s*false\s*\}\}$", re.IGNORECASE)
def job_condition_is_always_false(condition: str | None) -> bool:
    if condition is None:
        return False
    token = parse_scalar(condition).strip()
    if not token:
        return False
    lowered = token.lower()
    return lowered == "false" or ALWAYS_FALSE_JOB_IF_RE.fullmatch(token) is not None
MATRIX_CONTEXT_RE = re.compile(r"\$\{\{\s*matrix\.([A-Za-z0-9_-]+)\s*}}")
INPUT_CONTEXT_RE = re.compile(r"\$\{\{\s*inputs\.([A-Za-z0-9_-]+)\s*}}")
WORKFLOW_EXPRESSION_RE = re.compile(r"\$\{\{.*?\}\}")
@dataclass
class WorkflowJob:
    job_id: str
    name: str
    if_condition: str | None = None
    uses: str | None = None
    with_inputs: dict[str, str] = field(default_factory=dict)
    matrix: dict[str, list[str]] = field(default_factory=dict)
    matrix_include: list[dict[str, str]] = field(default_factory=list)
    matrix_exclude: list[dict[str, str]] = field(default_factory=list)
    matrix_expandable: bool = True
    has_matrix: bool = False
@dataclass
class WorkflowSpec:
    path: str
    workflow: str
    jobs: list[WorkflowJob]
@dataclass
class WorkflowInventory:
    path: str
    workflow: str
    jobs: list[str]
    remote_reusable_jobs: list[str] = field(default_factory=list)
    unresolved_matrix_jobs: list[str] = field(default_factory=list)
REQUIRED_REVIEW_POLICY_PERMISSIONS = {
    "contents": "read",
    "pull-requests": "read",
}
REQUIRED_REVIEW_POLICY_SCRIPT_MARKERS = (
    "resolveMergeGroupPullNumbers",
    "parsePullNumbersFromText",
    "context.payload.merge_group?.head_ref",
    "Merge queue member set could not be proven from GitHub-disclosed data",
    "listCommitAssociatedPullNumbers",
    "GET /repos/{owner}/{repo}/commits/{commit_sha}/pulls",
    "role_name",
    "github.rest.repos.getCollaboratorPermissionLevel",
    "github.rest.pulls.get",
    "github.paginate(github.rest.pulls.listReviews",
)
FORBIDDEN_REVIEW_POLICY_RUNTIME_MARKERS = (
    "actions/checkout@",
    "github.event.pull_request.head.sha",
    "github.event.pull_request.head.ref",
)
def dedupe_preserve_order(values: list[str]) -> list[str]:
    seen: set[str] = set()
    output: list[str] = []
    for value in values:
        if value and value not in seen:
            seen.add(value)
            output.append(value)
    return output
def dedupe_mapping_list(values: list[dict[str, str]]) -> list[dict[str, str]]:
    seen: set[tuple[tuple[str, str], ...]] = set()
    output: list[dict[str, str]] = []
    for value in values:
        key = tuple(sorted(value.items()))
        if value and key not in seen:
            seen.add(key)
            output.append(value)
    return output
def matrix_combo_matches(combo: dict[str, str], criteria: dict[str, str]) -> bool:
    return all(combo.get(key) == value for key, value in criteria.items())
def can_apply_matrix_include(base_combo: dict[str, str], include: dict[str, str]) -> bool:
    return all(key not in base_combo or base_combo[key] == value for key, value in include.items())
def expand_matrix_combinations(job: WorkflowJob) -> list[dict[str, str]]:
    if not job.matrix_expandable:
        return []
    base_axes = list(job.matrix.items())
    base_combos: list[dict[str, str]] = []
    if base_axes:
        axis_values = [values for _, values in base_axes]
        if any(not values for values in axis_values):
            return []
        for combo in product(*axis_values):
            base_combos.append({base_axes[index][0]: value for index, value in enumerate(combo)})
    filtered_base_combos = [
        combo
        for combo in base_combos
        if not any(matrix_combo_matches(combo, exclude) for exclude in job.matrix_exclude)
    ]
    expanded = [combo.copy() for combo in filtered_base_combos]
    for include in job.matrix_include:
        matched = False
        for index, base_combo in enumerate(filtered_base_combos):
            if can_apply_matrix_include(base_combo, include):
                expanded[index].update(include)
                matched = True
        if not matched:
            expanded.append(include.copy())
    return dedupe_mapping_list(expanded)
def local_reusable_workflow_ref(value: str | None) -> str | None:
    if not value:
        return None
    ref = parse_scalar(value).split("@", 1)[0]
    if ref.startswith("./"):
        ref = ref[2:]
    if not ref.startswith(".github/workflows/"):
        return None
    return str(Path(ref))
def is_remote_reusable_workflow_ref(value: str | None) -> bool:
    if not value:
        return False
    ref = parse_scalar(value).split("@", 1)[0]
    return "/.github/workflows/" in ref and not ref.startswith("./")
def apply_input_context(value: str | None, inputs: dict[str, str]) -> str | None:
    if value is None or not inputs:
        return value
    def replace_input(match: re.Match[str]) -> str:
        input_name = match.group(1)
        return inputs.get(input_name, match.group(0))
    return INPUT_CONTEXT_RE.sub(replace_input, value)

def apply_input_context_mapping(mapping: dict[str, str], inputs: dict[str, str]) -> dict[str, str]:
    if not inputs:
        return dict(mapping)
    # Preserve empty-string substitutions; only fall back when the input is unset (None).
    return {
        key: (resolved if (resolved := apply_input_context(value, inputs)) is not None else value)
        for key, value in mapping.items()
    }

def apply_inputs_to_job(job: WorkflowJob, inputs: dict[str, str]) -> WorkflowJob:
    if not inputs:
        return job
    return replace(
        job,
        name=(resolved if (resolved := apply_input_context(job.name, inputs)) is not None else job.name),
        uses=apply_input_context(job.uses, inputs),
        with_inputs=apply_input_context_mapping(job.with_inputs, inputs),
        matrix={
            key: [
                (resolved if (resolved := apply_input_context(value, inputs)) is not None else value)
                for value in values
            ]
            for key, values in job.matrix.items()
        },
        matrix_include=[apply_input_context_mapping(entry, inputs) for entry in job.matrix_include],
        matrix_exclude=[apply_input_context_mapping(entry, inputs) for entry in job.matrix_exclude],
    )

def apply_matrix_context(value: str | None, combo: dict[str, str]) -> str | None:
    if value is None or not combo:
        return value
    def replace_matrix(match: re.Match[str]) -> str:
        matrix_name = match.group(1)
        return combo.get(matrix_name, match.group(0))
    return MATRIX_CONTEXT_RE.sub(replace_matrix, value)

def apply_matrix_context_mapping(mapping: dict[str, str], combo: dict[str, str]) -> dict[str, str]:
    if not combo:
        return dict(mapping)
    # Preserve empty-string substitutions; only fall back when the matrix value is unset (None).
    return {
        key: (resolved if (resolved := apply_matrix_context(value, combo)) is not None else value)
        for key, value in mapping.items()
    }

def normalize_input_values(inputs: dict[str, str] | None) -> tuple[tuple[str, str], ...]:
    # Empty-string defaults are meaningful for workflow_call inputs; keep them in the cache key so
    # placeholder expansion can intentionally resolve to "" instead of being treated as "unset".
    return tuple(sorted((key, value) for key, value in (inputs or {}).items() if key and value is not None))

def expand_matrix_job_variants(job: WorkflowJob) -> list[WorkflowJob]:
    combos = expand_matrix_combinations(job)
    if not combos:
        # A matrix job with an explicitly empty axis (e.g. `python: []`) produces no runs on
        # GitHub Actions. Treat it as "missing" rather than silently inventing a single check.
        if job.has_matrix and any(not values for values in job.matrix.values()):
            return []
        return [job]
    variants: list[WorkflowJob] = []
    seen: set[tuple[str, tuple[tuple[str, str], ...], str | None]] = set()
    for combo in combos:
        matrix_keys = dedupe_preserve_order(MATRIX_CONTEXT_RE.findall(job.name))
        if matrix_keys:
            name = job.name
            for key in matrix_keys:
                value = combo.get(key)
                # Empty-string matrix values are valid (for example `suffix: ""`).
                if value is None:
                    return [job]
                name = re.sub(rf"\$\{{\{{\s*matrix\.{re.escape(key)}\s*}}}}", value, name)
        else:
            suffix = ", ".join(combo[key] for key in combo)
            name = f"{job.name} ({suffix})" if suffix else job.name
        variant = replace(
            job,
            name=name,
            uses=apply_matrix_context(job.uses, combo),
            with_inputs=apply_matrix_context_mapping(job.with_inputs, combo),
        )
        signature = (variant.name, tuple(sorted(variant.with_inputs.items())), variant.uses)
        if signature in seen:
            continue
        seen.add(signature)
        variants.append(variant)
    return variants or [job]
def apply_matrix_definition(job: WorkflowJob, key: str, tail: str) -> str | None:
    if key in {"include", "exclude"}:
        if tail:
            if tail == "[]":
                return None
            inline_entries = parse_inline_matrix_entry_list(tail)
            if inline_entries is None:
                job.matrix_expandable = False
            elif key == "include":
                job.matrix_include.extend(inline_entries)
            else:
                job.matrix_exclude.extend(inline_entries)
            return None
        return key
    if not key:
        return None
    if tail.startswith("${{"):
        job.matrix_expandable = False
        return None
    inline_items = parse_inline_string_list(tail, allow_empty=True)
    if tail:
        if inline_items:
            job.matrix[key] = [str(item) for item in inline_items]
        elif tail == "[]":
            job.matrix[key] = []
        else:
            job.matrix_expandable = False
        return None
    job.matrix.setdefault(key, [])
    return key

def scan_workflow_call_input_defaults(path: Path) -> dict[str, str]:
    defaults: dict[str, str] = {}
    in_on = False
    on_item_indent: int | None = None
    in_workflow_call = False
    workflow_call_item_indent: int | None = None
    in_inputs = False
    inputs_item_indent: int | None = None
    current_input: str | None = None
    input_field_indent: int | None = None
    for raw in read_yaml_lines(path):
        if not raw.strip() or raw.lstrip().startswith("#"):
            continue
        indent = len(raw) - len(raw.lstrip(" "))
        normalized = strip_inline_comment(raw.strip())
        if not normalized:
            continue
        if indent == 0:
            top_level = re.match(r'(["\']?)([^:]+)\1:\s*(.*)$', normalized)
            if top_level and top_level.group(2).strip() == "on":
                tail = top_level.group(3).strip()
                if tail:
                    inline_mapping = parse_inline_mapping(tail)
                    if inline_mapping:
                        workflow_call = inline_mapping.get("workflow_call")
                        if isinstance(workflow_call, str) and workflow_call:
                            workflow_call_mapping = parse_inline_mapping(workflow_call)
                            inputs = workflow_call_mapping.get("inputs") if workflow_call_mapping else None
                            if isinstance(inputs, str) and inputs:
                                inputs_mapping = parse_inline_mapping(inputs)
                                if inputs_mapping:
                                    for input_name, input_spec in inputs_mapping.items():
                                        if not input_name or not input_spec:
                                            continue
                                        input_mapping = parse_inline_mapping(input_spec)
                                        if input_mapping and "default" in input_mapping:
                                            defaults[input_name] = parse_scalar(input_mapping["default"])
                in_on = not tail
                on_item_indent = None
                in_workflow_call = False
                workflow_call_item_indent = None
                in_inputs = False
                inputs_item_indent = None
                current_input = None
                input_field_indent = None
                continue
            in_on = False
            on_item_indent = None
            in_workflow_call = False
            workflow_call_item_indent = None
            in_inputs = False
            inputs_item_indent = None
            current_input = None
            input_field_indent = None
            continue
        if not in_on:
            continue
        if on_item_indent is None:
            if indent <= 0:
                continue
            on_item_indent = indent
        if indent == on_item_indent:
            key, tail = split_mapping_line(normalized)
            if key == "workflow_call":
                # Support `workflow_call: { inputs: ... }` in addition to block-style maps.
                if tail:
                    workflow_call_mapping = parse_inline_mapping(tail)
                    inputs = workflow_call_mapping.get("inputs") if workflow_call_mapping else None
                    if isinstance(inputs, str) and inputs:
                        inputs_mapping = parse_inline_mapping(inputs)
                        if inputs_mapping:
                            for input_name, input_spec in inputs_mapping.items():
                                if not input_name or not input_spec:
                                    continue
                                input_mapping = parse_inline_mapping(input_spec)
                                if input_mapping and "default" in input_mapping:
                                    defaults[input_name] = parse_scalar(input_mapping["default"])
                    in_workflow_call = False
                    workflow_call_item_indent = None
                    in_inputs = False
                    inputs_item_indent = None
                    current_input = None
                    input_field_indent = None
                    continue
                in_workflow_call = True
                workflow_call_item_indent = None
                in_inputs = False
                inputs_item_indent = None
                current_input = None
                input_field_indent = None
                continue
            in_workflow_call = False
            workflow_call_item_indent = None
            in_inputs = False
            inputs_item_indent = None
            current_input = None
            input_field_indent = None
            continue
        if indent <= on_item_indent:
            in_workflow_call = False
            workflow_call_item_indent = None
            in_inputs = False
            inputs_item_indent = None
            current_input = None
            input_field_indent = None
            continue
        if not in_workflow_call:
            continue
        if workflow_call_item_indent is None:
            workflow_call_item_indent = indent
        if indent <= on_item_indent:
            in_workflow_call = False
            workflow_call_item_indent = None
            in_inputs = False
            inputs_item_indent = None
            current_input = None
            input_field_indent = None
            continue
        if indent == workflow_call_item_indent:
            key, tail = split_mapping_line(normalized)
            if key == "inputs":
                if tail:
                    inputs_mapping = parse_inline_mapping(tail)
                    if inputs_mapping:
                        for input_name, input_spec in inputs_mapping.items():
                            if not input_name or not input_spec:
                                continue
                            input_mapping = parse_inline_mapping(input_spec)
                            if input_mapping and "default" in input_mapping:
                                defaults[input_name] = parse_scalar(input_mapping["default"])
                    in_inputs = False
                    inputs_item_indent = None
                    current_input = None
                    input_field_indent = None
                    continue
                in_inputs = True
                inputs_item_indent = None
                current_input = None
                input_field_indent = None
                continue
            in_inputs = False
            inputs_item_indent = None
            current_input = None
            input_field_indent = None
            continue
        if indent <= workflow_call_item_indent:
            in_inputs = False
            inputs_item_indent = None
            current_input = None
            input_field_indent = None
            continue
        if not in_inputs:
            continue
        if inputs_item_indent is None:
            inputs_item_indent = indent
        if indent == inputs_item_indent and ":" in normalized:
            key, tail = normalized.split(":", 1)
            current_input = None
            input_field_indent = None
            input_name = parse_scalar(key.strip())
            tail = tail.strip()
            if input_name:
                if not tail:
                    current_input = input_name
                else:
                    inline_mapping = parse_inline_mapping(tail)
                    if inline_mapping and "default" in inline_mapping:
                        defaults[input_name] = parse_scalar(inline_mapping["default"])
            continue
        if current_input:
            if indent <= inputs_item_indent:
                current_input = None
                input_field_indent = None
                continue
            if input_field_indent is None:
                input_field_indent = indent
            if indent == input_field_indent:
                key, tail = split_mapping_line(normalized)
                if key == "default":
                    defaults[current_input] = parse_scalar(tail)
    return defaults

def scan_workflows(repo_root: Path) -> list[WorkflowInventory]:
    workflows_dir = repo_root / ".github" / "workflows"
    if not workflows_dir.exists():
        return []
    specs: dict[str, WorkflowSpec] = {}
    workflow_call_defaults: dict[str, dict[str, str]] = {}
    for path in sorted(list(workflows_dir.glob("*.yml")) + list(workflows_dir.glob("*.yaml"))):
        lines = read_yaml_lines(path)
        workflow_name = path.stem
        in_jobs = False
        job_indent: int | None = None
        in_strategy = False
        in_matrix = False
        current_matrix_key: str | None = None
        current_matrix_entry: dict[str, str] | None = None
        current_job: WorkflowJob | None = None
        job_key_indent: int | None = None
        job_step: int | None = None
        in_with = False
        jobs: list[WorkflowJob] = []
        for raw in lines:
            if not raw.strip() or raw.lstrip().startswith("#"):
                continue
            indent = len(raw) - len(raw.lstrip(" "))
            stripped = raw.strip()
            normalized = strip_inline_comment(stripped)
            if not normalized:
                continue
            if indent == 0:
                top_key, top_tail = split_mapping_line(normalized)
                if top_key == "name":
                    workflow_name = parse_scalar(top_tail) or workflow_name
                    continue
                if top_key == "jobs":
                    # Jobs can be defined as a block (`jobs:` followed by job ids) or as a flow
                    # mapping (`jobs: { lint: { name: Lint, ... } }`). Support both forms.
                    if top_tail:
                        inline_jobs = parse_inline_mapping(top_tail)
                        if inline_jobs is not None:
                            for inline_job_id, inline_job_spec in inline_jobs.items():
                                if not inline_job_id:
                                    continue
                                current_job = WorkflowJob(job_id=inline_job_id, name=inline_job_id)
                                jobs.append(current_job)
                                inline_job = parse_inline_mapping(inline_job_spec) if inline_job_spec else None
                                if inline_job is None:
                                    continue
                                name = inline_job.get("name")
                                if name:
                                    current_job.name = name or current_job.name
                                uses = inline_job.get("uses")
                                if uses:
                                    current_job.uses = uses
                                raw_if = inline_job.get("if")
                                if raw_if:
                                    current_job.if_condition = raw_if
                                raw_with = inline_job.get("with")
                                if raw_with:
                                    inline_with = parse_inline_mapping(raw_with)
                                    if inline_with is not None:
                                        current_job.with_inputs = inline_with
                                raw_strategy = inline_job.get("strategy")
                                if raw_strategy:
                                    inline_strategy = parse_inline_mapping(raw_strategy)
                                    if inline_strategy and "matrix" in inline_strategy:
                                        current_job.has_matrix = True
                                        inline_matrix = parse_inline_mapping(inline_strategy["matrix"])
                                        if inline_matrix is None:
                                            current_job.matrix_expandable = False
                                        else:
                                            for key, raw_value in inline_matrix.items():
                                                apply_matrix_definition(current_job, key, raw_value.strip())
                                    else:
                                        current_job.matrix_expandable = False
                        in_jobs = False
                        job_indent = None
                        current_job = None
                        job_key_indent = None
                        job_step = None
                    else:
                        in_jobs = True
                        job_indent = None
                        current_job = None
                        job_key_indent = None
                        job_step = None
                    in_strategy = False
                    in_matrix = False
                    in_with = False
                    current_matrix_key = None
                    current_matrix_entry = None
                    continue
                if in_jobs and top_key and top_key != "jobs":
                    in_jobs = False
                    job_indent = None
                    current_job = None
                    job_key_indent = None
                    job_step = None
                    in_strategy = False
                    in_matrix = False
                    in_with = False
                    current_matrix_key = None
                    current_matrix_entry = None
            if not in_jobs:
                continue
            if job_indent is None:
                if indent <= 0:
                    continue
                if normalized.startswith("- "):
                    continue
                if ":" not in normalized:
                    continue
                job_indent = indent
            if indent == job_indent:
                # Jobs can be declared in block style (`lint:`) or flow style
                # (`lint: { name: Lint, runs-on: ... }`). Handle both so we don't
                # incorrectly report drift when repositories use inline job maps.
                job_id, job_tail = split_mapping_line(normalized)
                if job_id:
                    current_job = WorkflowJob(job_id=job_id, name=job_id)
                    jobs.append(current_job)
                    in_strategy = False
                    in_matrix = False
                    in_with = False
                    current_matrix_key = None
                    current_matrix_entry = None
                    job_key_indent = None
                    job_step = None

                    if job_tail:
                        inline_job = parse_inline_mapping(job_tail)
                        if inline_job is not None:
                            name = inline_job.get("name")
                            if name:
                                current_job.name = name or current_job.name
                            uses = inline_job.get("uses")
                            if uses:
                                current_job.uses = uses
                            raw_if = inline_job.get("if")
                            if raw_if:
                                current_job.if_condition = raw_if
                            raw_with = inline_job.get("with")
                            if raw_with:
                                inline_with = parse_inline_mapping(raw_with)
                                if inline_with is not None:
                                    current_job.with_inputs = inline_with
                            raw_strategy = inline_job.get("strategy")
                            if raw_strategy:
                                inline_strategy = parse_inline_mapping(raw_strategy)
                                if inline_strategy and "matrix" in inline_strategy:
                                    current_job.has_matrix = True
                                    inline_matrix = parse_inline_mapping(inline_strategy["matrix"])
                                    if inline_matrix is None:
                                        current_job.matrix_expandable = False
                                    else:
                                        for key, raw_value in inline_matrix.items():
                                            apply_matrix_definition(current_job, key, raw_value.strip())
                                else:
                                    current_job.matrix_expandable = False
                    continue
            if current_job is None:
                continue
            if job_key_indent is None:
                if indent <= job_indent:
                    continue
                if normalized.startswith("- "):
                    continue
                if ":" not in normalized:
                    continue
                job_key_indent = indent
                job_step = job_key_indent - job_indent
                if job_step <= 0:
                    job_key_indent = None
                    job_step = None
                    continue
            if job_step is None:
                continue
            key_indent = job_key_indent
            sub_indent = key_indent + job_step
            matrix_key_indent = key_indent + job_step * 2
            matrix_list_indent = key_indent + job_step * 3
            matrix_list_field_indent = key_indent + job_step * 4
            if indent <= key_indent:
                reset_key, _reset_tail = split_mapping_line(normalized)
                if reset_key != "strategy":
                    in_strategy = False
                    in_matrix = False
                    current_matrix_key = None
                    current_matrix_entry = None
                if reset_key != "with":
                    in_with = False
            if indent == key_indent:
                key, tail = split_mapping_line(normalized)
                if key == "name":
                    current_job.name = parse_scalar(tail) or current_job.name
                    continue
                if key == "uses":
                    current_job.uses = parse_scalar(tail)
                    continue
                if key == "if":
                    current_job.if_condition = parse_scalar(tail)
                    continue
                if key == "with":
                    current_job.with_inputs = {}
                    in_with = True
                    if tail:
                        inline_mapping = parse_inline_mapping(tail)
                        if inline_mapping is None:
                            in_with = False
                        else:
                            current_job.with_inputs = inline_mapping
                            in_with = False
                    continue
            if in_with and indent == sub_indent and ":" in normalized:
                key, value = normalized.split(":", 1)
                parsed_key = parse_scalar(key.strip())
                parsed_value = parse_scalar(value)
                # Preserve empty-string inputs. An explicit `suffix: ""` is meaningful for
                # workflow_call placeholders and must not be dropped as falsy.
                if parsed_key and parsed_value is not None:
                    current_job.with_inputs[parsed_key] = parsed_value
                continue
            if indent == key_indent:
                key, tail = split_mapping_line(normalized)
                if key != "strategy":
                    continue
                in_strategy = not tail
                in_matrix = False
                current_matrix_key = None
                current_matrix_entry = None
                if tail:
                    inline_strategy = parse_inline_mapping(tail)
                    if inline_strategy and "matrix" in inline_strategy:
                        current_job.has_matrix = True
                        inline_matrix = parse_inline_mapping(inline_strategy["matrix"])
                        if inline_matrix is None:
                            current_job.matrix_expandable = False
                        else:
                            for key, raw_value in inline_matrix.items():
                                apply_matrix_definition(current_job, key, raw_value.strip())
                    else:
                        current_job.matrix_expandable = False
                    in_strategy = False
                continue
            if not in_strategy:
                continue
            if indent == sub_indent:
                key, tail = split_mapping_line(normalized)
                if key != "matrix":
                    continue
                current_job.has_matrix = True
                in_matrix = True
                current_matrix_key = None
                current_matrix_entry = None
                if tail:
                    inline_mapping = parse_inline_mapping(tail)
                    if inline_mapping is None:
                        current_job.matrix_expandable = False
                        in_matrix = False
                    else:
                        for key, raw_value in inline_mapping.items():
                            maybe_key = apply_matrix_definition(current_job, key, raw_value.strip())
                            if maybe_key in {"include", "exclude"}:
                                current_matrix_key = maybe_key
                        if current_matrix_key not in {"include", "exclude"}:
                            in_matrix = False
                            current_matrix_key = None
                    continue
                continue
            if in_matrix and indent <= sub_indent:
                in_matrix = False
                current_matrix_key = None
                current_matrix_entry = None
            if not in_matrix:
                continue
            if indent == matrix_key_indent and ":" in normalized:
                key, tail = normalized.split(":", 1)
                current_matrix_entry = None
                current_matrix_key = apply_matrix_definition(current_job, key.strip(), tail.strip())
                continue
            if indent == matrix_list_indent and normalized.startswith("- "):
                if current_matrix_key in {"include", "exclude"}:
                    current_matrix_entry = {}
                    item = normalized[2:].strip()
                    if item:
                        inline_mapping = parse_inline_mapping(item)
                        if inline_mapping is not None:
                            current_matrix_entry.update(inline_mapping)
                        else:
                            if ":" not in item:
                                current_job.matrix_expandable = False
                                current_matrix_entry = None
                                continue
                            item_key, raw_value = item.split(":", 1)
                            item_key = item_key.strip()
                            raw_value_token = strip_inline_comment(raw_value).strip()
                            if not item_key or raw_value_token == "":
                                current_job.matrix_expandable = False
                                current_matrix_entry = None
                                continue
                            item_value = parse_scalar(raw_value)
                            if item_value == "" and raw_value_token not in {'""', "''"}:
                                current_job.matrix_expandable = False
                                current_matrix_entry = None
                                continue
                            current_matrix_entry[item_key] = item_value
                    if current_matrix_key == "include":
                        current_job.matrix_include.append(current_matrix_entry)
                    else:
                        current_job.matrix_exclude.append(current_matrix_entry)
                    continue
                if current_matrix_key is None:
                    current_job.matrix_expandable = False
                    continue
                current_job.matrix.setdefault(current_matrix_key, []).append(parse_scalar(normalized[2:]))
                continue
            if indent >= matrix_list_field_indent and ":" in normalized and current_matrix_key in {"include", "exclude"}:
                if current_matrix_entry is None:
                    current_job.matrix_expandable = False
                    continue
                item_key, raw_value = normalized.split(":", 1)
                item_key = item_key.strip()
                raw_value_token = strip_inline_comment(raw_value).strip()
                if not item_key or raw_value_token == "":
                    current_job.matrix_expandable = False
                    continue
                item_value = parse_scalar(raw_value)
                if item_value == "" and raw_value_token not in {'""', "''"}:
                    current_job.matrix_expandable = False
                    continue
                current_matrix_entry[item_key] = item_value
                continue
            if indent >= matrix_list_indent and ":" in normalized:
                current_job.matrix_expandable = False
        rel_path = str(path.relative_to(repo_root))
        specs[rel_path] = WorkflowSpec(path=rel_path, workflow=workflow_name, jobs=jobs)
        workflow_call_defaults[rel_path] = scan_workflow_call_input_defaults(path)
    resolved_checks: dict[tuple[str, tuple[tuple[str, str], ...]], list[str]] = {}
    resolved_remote_reusable_jobs: dict[tuple[str, tuple[tuple[str, str], ...]], list[str]] = {}
    resolved_unresolved_matrix_jobs: dict[tuple[str, tuple[tuple[str, str], ...]], list[str]] = {}
    def resolve_checks(
        spec_path: str,
        input_values: dict[str, str] | None = None,
        stack: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (),
    ) -> list[str]:
        input_key = normalize_input_values(input_values)
        cache_key = (spec_path, input_key)
        cached = resolved_checks.get(cache_key)
        if cached is not None:
            return cached
        if cache_key in stack:
            return []
        spec = specs[spec_path]
        resolved_inputs = dict(input_key)
        checks: list[str] = []
        remote_reusable_jobs: list[str] = []
        unresolved_matrix_jobs: list[str] = []
        next_stack = stack + (cache_key,)
        for raw_job in spec.jobs:
            job = apply_inputs_to_job(raw_job, resolved_inputs)
            if job_condition_is_always_false(job.if_condition):
                continue
            if job.has_matrix and not job.matrix_expandable:
                unresolved_matrix_jobs.append(job.name)
                continue
            for variant in expand_matrix_job_variants(job):
                reusable_path = local_reusable_workflow_ref(variant.uses)
                if reusable_path:
                    # Fail closed for local reusable workflows: if we cannot resolve the local
                    # workflow into leaf check names (due to a missing file, missing jobs, or an
                    # unresolvable chain), do not treat the caller job name as a valid check.
                    if reusable_path not in specs:
                        continue
                    child_inputs = dict(workflow_call_defaults.get(reusable_path, {}))
                    child_inputs.update(variant.with_inputs)
                    child_inputs = apply_input_context_mapping(child_inputs, child_inputs)
                    child_input_key = normalize_input_values(child_inputs)
                    child_checks = resolve_checks(reusable_path, child_inputs, next_stack)
                    child_cache_key = (reusable_path, child_input_key)
                    child_remote_reusable_jobs = resolved_remote_reusable_jobs.get(child_cache_key, [])
                    child_unresolved_matrix_jobs = resolved_unresolved_matrix_jobs.get(child_cache_key, [])
                    if child_checks:
                        checks.extend(f"{variant.name} / {child_check}" for child_check in child_checks)
                    if child_remote_reusable_jobs:
                        remote_reusable_jobs.extend(f"{variant.name} / {child_job}" for child_job in child_remote_reusable_jobs)
                    if child_unresolved_matrix_jobs:
                        unresolved_matrix_jobs.extend(f"{variant.name} / {child_job}" for child_job in child_unresolved_matrix_jobs)
                    if child_checks or child_remote_reusable_jobs or child_unresolved_matrix_jobs:
                        continue
                    continue
                if is_remote_reusable_workflow_ref(variant.uses):
                    remote_reusable_jobs.append(variant.name)
                    continue
                if WORKFLOW_EXPRESSION_RE.search(variant.name):
                    unresolved_matrix_jobs.append(variant.name)
                    continue
                checks.append(variant.name)
        # Preserve raw occurrences so we can detect duplicate emitted check contexts.
        resolved_checks[cache_key] = list(checks)
        resolved_remote_reusable_jobs[cache_key] = dedupe_preserve_order(remote_reusable_jobs)
        resolved_unresolved_matrix_jobs[cache_key] = dedupe_preserve_order(unresolved_matrix_jobs)
        return resolved_checks[cache_key]
    def root_cache_key(spec_path: str) -> tuple[str, tuple[tuple[str, str], ...]]:
        return (spec_path, normalize_input_values(None))
    return [
        WorkflowInventory(
            path=spec.path,
            workflow=spec.workflow,
            jobs=resolve_checks(spec.path),
            remote_reusable_jobs=resolved_remote_reusable_jobs.get(root_cache_key(spec.path), []),
            unresolved_matrix_jobs=resolved_unresolved_matrix_jobs.get(root_cache_key(spec.path), []),
        )
        for spec in specs.values()
    ]
def workflow_lookup(inventory: list[WorkflowInventory]) -> dict[str, set[str]]:
    return {item.workflow: set(item.jobs) for item in inventory}
def remote_reusable_lookup(inventory: list[WorkflowInventory]) -> dict[str, set[str]]:
    return {item.workflow: set(item.remote_reusable_jobs) for item in inventory}
def unresolved_matrix_lookup(inventory: list[WorkflowInventory]) -> dict[str, set[str]]:
    return {item.workflow: set(item.unresolved_matrix_jobs) for item in inventory if item.unresolved_matrix_jobs}
def all_check_names(inventory: list[WorkflowInventory]) -> set[str]:
    names: set[str] = set()
    for item in inventory:
        names.update(item.jobs)
    return names
def duplicated_check_contexts(inventory: list[WorkflowInventory]) -> dict[str, list[str]]:
    """Detect duplicate emitted check contexts in the repository workflow inventory.

    GitHub required checks are keyed by the check context name. If multiple jobs emit the
    same context (either across workflows or within a single workflow), GitHub cannot
    reliably enforce a specific one or disambiguate which check the ruleset refers to.
    Fail closed and require the repository to rename the checks.
    """
    occurrences: dict[str, dict[str, int]] = {}
    for item in inventory:
        label = f"{item.workflow} ({item.path})"
        for check in item.jobs:
            per_label = occurrences.setdefault(check, {})
            per_label[label] = per_label.get(label, 0) + 1

    duplicates: dict[str, list[str]] = {}
    for check, per_label in occurrences.items():
        if len(per_label) == 1 and next(iter(per_label.values())) == 1:
            continue
        rendered: list[str] = []
        for label, count in sorted(per_label.items()):
            rendered.append(f"{label} x{count}" if count > 1 else label)
        duplicates[check] = rendered
    return duplicates
def worsen_exit_code(current: int, candidate: int) -> int:
    return candidate if candidate > current else current
def normalize_env_value(value: Any) -> Any:
    if value is None:
        return None
    if isinstance(value, list):
        if all(isinstance(item, str) for item in value):
            return sorted(item.strip() for item in value)
        return value
    if isinstance(value, str):
        stripped = value.strip()
        lowered = stripped.lower()
        if lowered in {"true", "false"}:
            return lowered == "true"
        if stripped.startswith("["):
            candidates = [stripped]
            # Lightweight YAML parsing means we may see escaped JSON strings such as:
            #   "[\"admin\", \"maintain\"]"
            # Strip the backslash escapes and try again before giving up.
            if '\\"' in stripped:
                candidates.append(stripped.replace('\\"', '"'))
            data = None
            for candidate in candidates:
                try:
                    data = json.loads(candidate)
                except json.JSONDecodeError:
                    continue
                break
            if data is None:
                return stripped
            return normalize_env_value(data)
        return stripped
    return value

def normalize_mapping_values(value: dict[str, Any]) -> dict[str, Any]:
    return {
        key: normalize_env_value(raw_value)
        for key, raw_value in sorted(value.items())
        if normalize_env_value(raw_value) is not None
    }

def scan_top_level_mapping_anchors(path: Path) -> dict[str, dict[str, str]]:
    """Collect top-level mapping anchors like `x-perms: &ro` for alias resolution.

    The validator is intentionally a lightweight YAML scanner, but repositories often
    use extension keys (`x-...`) to define anchors and then reference them via `*alias`.
    """
    anchors: dict[str, dict[str, str]] = {}
    in_anchor = False
    anchor_indent: int | None = None
    current_anchor_name: str | None = None
    current_mapping: dict[str, str] = {}
    def flush() -> None:
        nonlocal in_anchor, anchor_indent, current_anchor_name, current_mapping
        if current_anchor_name:
            anchors[current_anchor_name] = dict(current_mapping)
        in_anchor = False
        anchor_indent = None
        current_anchor_name = None
        current_mapping = {}

    for raw in read_yaml_lines(path):
        if not raw.strip() or raw.lstrip().startswith("#"):
            continue
        indent = len(raw) - len(raw.lstrip(" "))
        normalized = strip_inline_comment(raw.strip())
        if not normalized:
            continue
        if indent == 0:
            if in_anchor:
                flush()
            key, tail = split_mapping_line(normalized)
            if key is None or not tail.startswith("&"):
                continue
            anchor_token = tail[1:].strip()
            anchor_name, _sep, remainder = anchor_token.partition(" ")
            remainder = remainder.strip()
            if not anchor_name:
                continue
            current_anchor_name = anchor_name
            current_mapping = {}
            if remainder:
                inline_mapping = parse_inline_mapping(remainder)
                if inline_mapping is not None:
                    anchors[anchor_name] = dict(inline_mapping)
                    current_anchor_name = None
                    current_mapping = {}
                else:
                    in_anchor = True
                    anchor_indent = None
            else:
                in_anchor = True
                anchor_indent = None
            continue
        if in_anchor and ":" in normalized:
            if anchor_indent is None:
                if indent > 0:
                    anchor_indent = indent
            if anchor_indent is not None and indent == anchor_indent:
                key, value = normalized.split(":", 1)
                parsed_key = parse_scalar(key.strip())
                if parsed_key:
                    current_mapping[parsed_key] = parse_scalar(value)
    else:
        if in_anchor:
            flush()
    return anchors

def scan_top_level_env(path: Path) -> dict[str, str]:
    env: dict[str, str] = {}
    anchors = scan_top_level_mapping_anchors(path)
    in_env = False
    env_indent: int | None = None
    for raw in read_yaml_lines(path):
        if not raw.strip() or raw.lstrip().startswith("#"):
            continue
        indent = len(raw) - len(raw.lstrip(" "))
        stripped = raw.strip()
        normalized = strip_inline_comment(stripped)
        if not normalized:
            continue
        if indent == 0:
            key, tail = split_mapping_line(normalized)
            if key != "env":
                if in_env:
                    break
                continue
            if tail:
                if tail.startswith("*"):
                    alias = tail[1:].strip()
                    env = dict(anchors.get(alias, {}))
                    in_env = False
                    env_indent = None
                    continue
                # Support YAML anchors like `env: &review_env` by treating them as block mappings.
                if tail.startswith("&"):
                    _anchor, _sep, remainder = tail[1:].strip().partition(" ")
                    remainder = remainder.strip()
                    env = {}
                    if remainder:
                        inline_mapping = parse_inline_mapping(remainder)
                        if inline_mapping is not None:
                            env.update(inline_mapping)
                            in_env = False
                            env_indent = None
                        else:
                            in_env = True
                            env_indent = None
                    else:
                        in_env = True
                        env_indent = None
                    continue
                inline_mapping = parse_inline_mapping(tail)
                if inline_mapping is not None:
                    env.update(inline_mapping)
                in_env = False
                env_indent = None
            else:
                in_env = True
                env_indent = None
            continue
        if in_env and ":" in normalized:
            if env_indent is None:
                if indent <= 0:
                    continue
                env_indent = indent
            if indent != env_indent:
                continue
            key, value = normalized.split(":", 1)
            parsed_key = parse_scalar(key.strip())
            if parsed_key:
                env[parsed_key] = parse_scalar(value)
    return env
def scan_top_level_permissions(path: Path) -> dict[str, str]:
    permissions: dict[str, str] = {}
    anchors = scan_top_level_mapping_anchors(path)
    in_permissions = False
    permissions_indent: int | None = None
    for raw in read_yaml_lines(path):
        if not raw.strip() or raw.lstrip().startswith("#"):
            continue
        indent = len(raw) - len(raw.lstrip(" "))
        stripped = raw.strip()
        normalized = strip_inline_comment(stripped)
        if not normalized:
            continue
        if indent == 0:
            key, tail = split_mapping_line(normalized)
            if key != "permissions":
                if in_permissions:
                    break
                continue
            if tail:
                if tail.startswith("*"):
                    alias = tail[1:].strip()
                    permissions = dict(anchors.get(alias, {}))
                    in_permissions = False
                    permissions_indent = None
                    continue
                # Support YAML anchors like `permissions: &ro` by treating them as block mappings.
                if tail.startswith("&"):
                    _anchor, _sep, remainder = tail[1:].strip().partition(" ")
                    remainder = remainder.strip()
                    permissions = {}
                    if remainder:
                        inline_mapping = parse_inline_mapping(remainder)
                        if inline_mapping is not None:
                            permissions = dict(inline_mapping)
                            in_permissions = False
                            permissions_indent = None
                        else:
                            in_permissions = True
                            permissions_indent = None
                    else:
                        in_permissions = True
                        permissions_indent = None
                    continue
                inline_mapping = parse_inline_mapping(tail)
                if inline_mapping is not None:
                    permissions = dict(inline_mapping)
                in_permissions = False
                permissions_indent = None
            else:
                permissions = {}
                in_permissions = True
                permissions_indent = None
            continue
        if in_permissions and ":" in normalized:
            if permissions_indent is None:
                if indent <= 0:
                    continue
                permissions_indent = indent
            if indent != permissions_indent:
                continue
            key, value = normalized.split(":", 1)
            parsed_key = parse_scalar(key.strip())
            if parsed_key:
                permissions[parsed_key] = parse_scalar(value)
    return permissions
def scan_job_permissions(path: Path) -> dict[str, dict[str, str]]:
    permissions_by_job: dict[str, dict[str, str]] = {}
    in_jobs = False
    in_permissions = False
    permission_anchors: dict[str, dict[str, str]] = scan_top_level_mapping_anchors(path)
    in_permission_anchor = False
    anchor_indent: int | None = None
    current_anchor_name: str | None = None
    current_anchor_permissions: dict[str, str] = {}
    current_job_id: str | None = None
    current_job_name: str | None = None
    current_permissions: dict[str, str] = {}
    current_permissions_declared = False
    job_indent: int | None = None
    job_key_indent: int | None = None
    job_step: int | None = None
    permissions_indent: int | None = None
    def flush_anchor() -> None:
        nonlocal in_permission_anchor, current_anchor_name, current_anchor_permissions
        if current_anchor_name:
            permission_anchors[current_anchor_name] = dict(current_anchor_permissions)
        in_permission_anchor = False
        nonlocal anchor_indent
        anchor_indent = None
        current_anchor_name = None
        current_anchor_permissions = {}
    def flush() -> None:
        if current_job_name and current_permissions_declared:
            permissions_by_job[current_job_name] = dict(current_permissions)
    for raw in read_yaml_lines(path):
        if not raw.strip() or raw.lstrip().startswith("#"):
            continue
        indent = len(raw) - len(raw.lstrip(" "))
        stripped = raw.strip()
        normalized = strip_inline_comment(stripped)
        if not normalized:
            continue
        if in_permission_anchor:
            if ":" in normalized:
                if anchor_indent is None:
                    if indent > 0:
                        anchor_indent = indent
                if anchor_indent is not None and indent == anchor_indent:
                    anchor_key, anchor_value = normalized.split(":", 1)
                    parsed_key = parse_scalar(anchor_key.strip())
                    if parsed_key:
                        current_anchor_permissions[parsed_key] = parse_scalar(anchor_value)
                    continue
            if indent == 0:
                flush_anchor()
        if indent == 0:
            key, tail = split_mapping_line(normalized)
            if key == "permissions" and tail.startswith("&"):
                anchor_token = tail[1:].strip()
                anchor_name, _sep, remainder = anchor_token.partition(" ")
                remainder = remainder.strip()
                if anchor_name:
                    if remainder.startswith("{") and remainder.endswith("}"):
                        inline_mapping = parse_inline_mapping(remainder)
                        if inline_mapping is not None:
                            permission_anchors[anchor_name] = dict(inline_mapping)
                    else:
                        in_permission_anchor = True
                        anchor_indent = None
                        current_anchor_name = anchor_name
                        current_anchor_permissions = {}
                continue
            if key == "jobs":
                if tail:
                    inline_jobs = parse_inline_mapping(tail)
                    if inline_jobs is not None:
                        for job_id, job_spec in inline_jobs.items():
                            if not job_id:
                                continue
                            job_name = job_id
                            inline_job = parse_inline_mapping(job_spec) if job_spec else None
                            if inline_job is not None:
                                name = inline_job.get("name")
                                if name:
                                    job_name = name or job_name
                                if "permissions" in inline_job:
                                    raw_permissions = inline_job.get("permissions", "")
                                    if raw_permissions.startswith("*"):
                                        alias = raw_permissions[1:].strip()
                                        permissions_by_job[job_name] = dict(permission_anchors.get(alias, {}))
                                    else:
                                        inline_permissions = parse_inline_mapping(raw_permissions) if raw_permissions else None
                                        permissions_by_job[job_name] = dict(inline_permissions) if inline_permissions is not None else {}
                    break
                in_jobs = True
                job_indent = None
                current_job_id = None
                current_job_name = None
                current_permissions = {}
                current_permissions_declared = False
                in_permissions = False
                permissions_indent = None
                job_key_indent = None
                job_step = None
                continue
        if in_jobs and indent == 0:
            flush()
            break
        if not in_jobs:
            continue
        if job_indent is None:
            if indent <= 0:
                continue
            if normalized.startswith("- "):
                continue
            if ":" not in normalized:
                continue
            job_indent = indent
        if indent == job_indent:
            job_id, job_tail = split_mapping_line(normalized)
            if job_id:
                flush()
                current_job_id = job_id
                current_job_name = job_id
                current_permissions = {}
                current_permissions_declared = False
                in_permissions = False
                permissions_indent = None
                job_key_indent = None
                job_step = None
                if job_tail:
                    inline_job = parse_inline_mapping(job_tail)
                    if inline_job is not None:
                        name = inline_job.get("name")
                        if name:
                            current_job_name = name or current_job_name
                        if "permissions" in inline_job:
                            current_permissions = {}
                            current_permissions_declared = True
                            raw_permissions = inline_job.get("permissions", "")
                            if raw_permissions.startswith("*"):
                                alias = raw_permissions[1:].strip()
                                current_permissions = dict(permission_anchors.get(alias, {}))
                            else:
                                inline_permissions = parse_inline_mapping(raw_permissions) if raw_permissions else None
                                if inline_permissions is not None:
                                    current_permissions = dict(inline_permissions)
                continue
        if current_job_id is None:
            continue
        if job_key_indent is None:
            if indent <= job_indent:
                continue
            if normalized.startswith("- "):
                continue
            if ":" not in normalized:
                continue
            job_key_indent = indent
            job_step = job_key_indent - job_indent
            if job_step <= 0:
                job_key_indent = None
                job_step = None
                continue
        if job_step is None:
            continue
        if in_permissions and indent <= job_key_indent:
            in_permissions = False
            permissions_indent = None
        if indent == job_key_indent:
            key, tail = split_mapping_line(normalized)
            if key == "name":
                current_job_name = parse_scalar(tail) or current_job_name
                continue
            if key != "permissions":
                continue
            current_permissions = {}
            current_permissions_declared = True
            if tail:
                if tail.startswith("*"):
                    alias = tail[1:].strip()
                    current_permissions = dict(permission_anchors.get(alias, {}))
                    in_permissions = False
                    permissions_indent = None
                else:
                    inline_mapping = parse_inline_mapping(tail)
                    if inline_mapping is not None:
                        current_permissions = dict(inline_mapping)
                    in_permissions = False
                    permissions_indent = None
            else:
                in_permissions = True
                permissions_indent = None
            continue
        if in_permissions and ":" in normalized:
            if permissions_indent is None:
                if indent > job_key_indent:
                    permissions_indent = indent
            if permissions_indent is not None and indent == permissions_indent:
                key, value = normalized.split(":", 1)
                parsed_key = parse_scalar(key.strip())
                if parsed_key:
                    current_permissions[parsed_key] = parse_scalar(value)
            continue
    else:
        if in_permission_anchor:
            flush_anchor()
        flush()
    return permissions_by_job
def scan_job_env(path: Path) -> dict[str, dict[str, str]]:
    env_by_job: dict[str, dict[str, str]] = {}
    in_jobs = False
    in_env = False
    current_job_id: str | None = None
    current_job_name: str | None = None
    current_env: dict[str, str] = {}
    env_anchors: dict[str, dict[str, str]] = scan_top_level_mapping_anchors(path)
    current_env_anchor: str | None = None
    job_indent: int | None = None
    job_key_indent: int | None = None
    job_step: int | None = None
    env_item_indent: int | None = None
    def flush_env_anchor() -> None:
        nonlocal current_env_anchor
        if current_env_anchor:
            env_anchors[current_env_anchor] = dict(current_env)
            current_env_anchor = None
    def flush() -> None:
        if current_job_name and current_env:
            env_by_job[current_job_name] = dict(current_env)
    for raw in read_yaml_lines(path):
        if not raw.strip() or raw.lstrip().startswith("#"):
            continue
        indent = len(raw) - len(raw.lstrip(" "))
        stripped = raw.strip()
        normalized = strip_inline_comment(stripped)
        if not normalized:
            continue
        if indent == 0:
            key, tail = split_mapping_line(normalized)
            if key == "jobs":
                if tail:
                    inline_jobs = parse_inline_mapping(tail)
                    if inline_jobs is not None:
                        for job_id, job_spec in inline_jobs.items():
                            if not job_id:
                                continue
                            job_name = job_id
                            inline_job = parse_inline_mapping(job_spec) if job_spec else None
                            if inline_job is None:
                                continue
                            name = inline_job.get("name")
                            if name:
                                job_name = name or job_name
                            raw_env = inline_job.get("env")
                            if raw_env:
                                inline_env = parse_inline_mapping(raw_env)
                                if inline_env:
                                    env_by_job[job_name] = dict(inline_env)
                    break
                in_jobs = True
                job_indent = None
                current_job_id = None
                current_job_name = None
                current_env = {}
                in_env = False
                env_item_indent = None
                job_key_indent = None
                job_step = None
                continue
        if in_jobs and indent == 0:
            flush_env_anchor()
            flush()
            break
        if not in_jobs:
            continue
        if job_indent is None:
            if indent <= 0:
                continue
            if normalized.startswith("- "):
                continue
            if ":" not in normalized:
                continue
            job_indent = indent
        if indent == job_indent:
            job_id, job_tail = split_mapping_line(normalized)
            if job_id:
                flush_env_anchor()
                flush()
                current_job_id = job_id
                current_job_name = job_id
                current_env = {}
                in_env = False
                env_item_indent = None
                current_env_anchor = None
                job_key_indent = None
                job_step = None
                if job_tail:
                    inline_job = parse_inline_mapping(job_tail)
                    if inline_job is not None:
                        name = inline_job.get("name")
                        if name:
                            current_job_name = name or current_job_name
                        raw_env = inline_job.get("env")
                        if raw_env:
                            inline_env = parse_inline_mapping(raw_env)
                            if inline_env:
                                current_env.update(inline_env)
                continue
        if current_job_id is None:
            continue
        if job_key_indent is None:
            if indent <= job_indent:
                continue
            if normalized.startswith("- "):
                continue
            if ":" not in normalized:
                continue
            job_key_indent = indent
            job_step = job_key_indent - job_indent
            if job_step <= 0:
                job_key_indent = None
                job_step = None
                continue
        if job_step is None:
            continue
        if in_env and indent <= job_key_indent:
            flush_env_anchor()
            in_env = False
            env_item_indent = None
        if indent == job_key_indent:
            key, tail = split_mapping_line(normalized)
            if key == "name":
                current_job_name = parse_scalar(tail) or current_job_name
                continue
            if key != "env":
                continue
            current_env = {}
            current_env_anchor = None
            if tail:
                if tail.startswith("&"):
                    anchor_token = tail[1:].strip()
                    anchor_name, _sep, remainder = anchor_token.partition(" ")
                    remainder = remainder.strip()
                    if anchor_name:
                        current_env_anchor = anchor_name
                    if remainder:
                        inline_mapping = parse_inline_mapping(remainder)
                        if inline_mapping is not None:
                            current_env.update(inline_mapping)
                            flush_env_anchor()
                            in_env = False
                            env_item_indent = None
                        else:
                            in_env = True
                            env_item_indent = None
                    else:
                        in_env = True
                        env_item_indent = None
                    continue
                if tail.startswith("*"):
                    alias = tail[1:].strip()
                    current_env.update(env_anchors.get(alias, {}))
                    in_env = False
                    env_item_indent = None
                    continue
                inline_mapping = parse_inline_mapping(tail)
                if inline_mapping:
                    current_env.update(inline_mapping)
                in_env = False
                env_item_indent = None
            else:
                in_env = True
                env_item_indent = None
            continue
        if in_env and ":" in normalized:
            if env_item_indent is None:
                if indent > job_key_indent:
                    env_item_indent = indent
            if env_item_indent is not None and indent == env_item_indent:
                key, value = normalized.split(":", 1)
                parsed_key = parse_scalar(key.strip())
                if parsed_key:
                    current_env[parsed_key] = parse_scalar(value)
            continue
    else:
        flush_env_anchor()
        flush()
    return env_by_job
def scan_job_blocks(path: Path) -> dict[str, str]:
    blocks: dict[str, str] = {}
    in_jobs = False
    current_job_id: str | None = None
    current_job_name: str | None = None
    current_lines: list[str] = []
    job_indent: int | None = None
    job_key_indent: int | None = None
    job_step: int | None = None
    def flush() -> None:
        if current_job_name and current_lines:
            blocks[current_job_name] = "\n".join(current_lines)
    for raw in read_yaml_lines(path):
        if not raw.strip() or raw.lstrip().startswith("#"):
            continue
        indent = len(raw) - len(raw.lstrip(" "))
        stripped = raw.strip()
        normalized = strip_inline_comment(stripped)
        if not normalized:
            continue
        if indent == 0:
            key, tail = split_mapping_line(normalized)
            if key == "jobs":
                if tail:
                    inline_jobs = parse_inline_mapping(tail)
                    if inline_jobs is not None:
                        for job_id, job_spec in inline_jobs.items():
                            if not job_id:
                                continue
                            job_name = job_id
                            inline_job = parse_inline_mapping(job_spec) if job_spec else None
                            if inline_job is not None:
                                name = inline_job.get("name")
                                if name:
                                    job_name = name or job_name
                            blocks[job_name] = f"{job_id}: {job_spec}"
                    break
                in_jobs = True
                job_indent = None
                current_job_id = None
                current_job_name = None
                current_lines = []
                job_key_indent = None
                job_step = None
                continue
        if in_jobs and indent == 0:
            flush()
            break
        if not in_jobs:
            continue
        if job_indent is None:
            if indent <= 0:
                continue
            if normalized.startswith("- "):
                continue
            if ":" not in normalized:
                continue
            job_indent = indent
        if indent == job_indent:
            job_id, job_tail = split_mapping_line(normalized)
            if job_id:
                flush()
                current_job_id = job_id
                current_job_name = job_id
                current_lines = [raw]
                job_key_indent = None
                job_step = None
                if job_tail:
                    inline_job = parse_inline_mapping(job_tail)
                    if inline_job is not None:
                        name = inline_job.get("name")
                        if name:
                            current_job_name = name or current_job_name
                continue
        if current_job_id is None:
            continue
        current_lines.append(raw)
        if job_key_indent is None:
            if indent <= job_indent:
                continue
            if normalized.startswith("- "):
                continue
            if ":" not in normalized:
                continue
            job_key_indent = indent
            job_step = job_key_indent - job_indent
            if job_step <= 0:
                job_key_indent = None
                job_step = None
                continue
        if job_key_indent is not None and indent == job_key_indent:
            key, tail = split_mapping_line(normalized)
            if key == "name":
                current_job_name = parse_scalar(tail) or current_job_name
    else:
        flush()
    return blocks
def scan_review_policy_runtime_contract(path: Path, check_name: str) -> dict[str, Any]:
    job_block = scan_job_blocks(path).get(check_name, "")
    runtime_block = "\n".join(
        line
        for line in job_block.splitlines()
        if not line.strip().startswith("#") and not line.strip().startswith("//")
    )
    # Review-policy enforcement depends on actions/github-script. Repositories may format the
    # workflow using block style or flow style mappings; a substring match is the most tolerant.
    uses_github_script = "actions/github-script@" in runtime_block
    workflow_permissions = scan_top_level_permissions(path)
    job_permissions_by_name = scan_job_permissions(path)
    job_permissions_declared = check_name in job_permissions_by_name
    job_permissions = job_permissions_by_name.get(check_name, {})
    effective_permissions = job_permissions if job_permissions_declared else workflow_permissions
    return {
        "workflow_permissions": workflow_permissions,
        "job_permissions": job_permissions,
        "permissions": effective_permissions,
        "uses_github_script": uses_github_script,
        "logic_markers": {marker: marker in runtime_block for marker in REQUIRED_REVIEW_POLICY_SCRIPT_MARKERS},
        "forbidden_markers": {marker: marker in runtime_block for marker in FORBIDDEN_REVIEW_POLICY_RUNTIME_MARKERS},
    }
def split_top_level_items(value: str) -> list[str]:
    items: list[str] = []
    current: list[str] = []
    quote: str | None = None
    brace_depth = 0
    bracket_depth = 0
    for char in value:
        if quote is not None:
            current.append(char)
            if char == quote:
                quote = None
            continue
        if char in {'"', "'"}:
            quote = char
            current.append(char)
            continue
        if char == '{':
            brace_depth += 1
            current.append(char)
            continue
        if char == '}':
            brace_depth = max(0, brace_depth - 1)
            current.append(char)
            continue
        if char == '[':
            bracket_depth += 1
            current.append(char)
            continue
        if char == ']':
            bracket_depth = max(0, bracket_depth - 1)
            current.append(char)
            continue
        if char == ',' and brace_depth == 0 and bracket_depth == 0:
            item = ''.join(current).strip()
            if item:
                items.append(item)
            current = []
            continue
        current.append(char)
    item = ''.join(current).strip()
    if item:
        items.append(item)
    return items
def parse_inline_mapping(value: str) -> dict[str, str] | None:
    value = value.strip()
    if not value.startswith('{') or not value.endswith('}'):
        return None
    mapping: dict[str, str] = {}
    for item in split_top_level_items(value[1:-1]):
        if ':' not in item:
            return None
        key, raw_value = item.split(':', 1)
        raw_value_token = strip_inline_comment(raw_value).strip()
        if raw_value_token == "":
            return None
        parsed_key = parse_scalar(key)
        parsed_value = parse_scalar(raw_value)
        if not parsed_key:
            return None
        if parsed_value == "" and raw_value_token not in {'""', "''"}:
            return None
        mapping[parsed_key] = parsed_value
    return mapping
def parse_inline_matrix_entry_list(value: str) -> list[dict[str, str]] | None:
    value = value.strip()
    if not value.startswith('[') or not value.endswith(']'):
        return None
    entries: list[dict[str, str]] = []
    for item in split_top_level_items(value[1:-1]):
        mapping = parse_inline_mapping(item)
        if mapping is None:
            return None
        entries.append(mapping)
    return entries
def parse_inline_string_list(value: str, *, allow_empty: bool = False) -> list[str]:
    value = value.strip()
    if not value:
        return []
    if value.startswith('[') and value.endswith(']'):
        items: list[str] = []
        for raw_item in split_top_level_items(value[1:-1]):
            parsed = parse_scalar(raw_item)
            if parsed == "" and not allow_empty:
                continue
            items.append(parsed)
        return items
    item = parse_scalar(value)
    if item == "" and not allow_empty:
        return []
    return [item]
def record_inline_trigger_types(event_name: str, raw_value: str, event_types: dict[str, set[str]]) -> None:
    inline_mapping = parse_inline_mapping(raw_value)
    if inline_mapping is not None:
        # Inline mapping without `types` (for example `{ branches: [main] }`) is valid and means
        # the workflow triggers for all activity types. Do not record a synthetic "type" entry.
        if "types" in inline_mapping:
            items = parse_inline_string_list(inline_mapping["types"])
            if items:
                event_types.setdefault(event_name, set()).update(items)
        return
    items = parse_inline_string_list(raw_value)
    if items:
        event_types.setdefault(event_name, set()).update(items)

def scan_top_level_triggers(path: Path) -> tuple[set[str], dict[str, set[str]]]:
    events: set[str] = set()
    event_types: dict[str, set[str]] = {}
    in_on = False
    on_item_indent: int | None = None
    current_event: str | None = None
    in_types = False
    event_body_indent: int | None = None
    types_list_indent: int | None = None
    for raw in read_yaml_lines(path):
        if not raw.strip() or raw.lstrip().startswith("#"):
            continue
        indent = len(raw) - len(raw.lstrip(" "))
        stripped = raw.strip()
        normalized = strip_inline_comment(stripped)
        if not normalized:
            continue
        if indent == 0:
            top_level = re.match(r'(["\']?)([^:]+)\1:\s*(.*)$', normalized)
            if top_level and top_level.group(2).strip() == "on":
                tail = top_level.group(3).strip()
                if tail:
                    inline_mapping = parse_inline_mapping(tail)
                    if inline_mapping:
                        for event_name, raw_value in inline_mapping.items():
                            event_name = parse_scalar(event_name)
                            if not event_name:
                                continue
                            events.add(event_name)
                            record_inline_trigger_types(event_name, raw_value, event_types)
                    else:
                        events.update(parse_inline_string_list(tail))
                    in_on = False
                else:
                    in_on = True
                    on_item_indent = None
                current_event = None
                in_types = False
                event_body_indent = None
                types_list_indent = None
                continue
            if in_on:
                break
            continue
        if not in_on:
            continue
        if on_item_indent is None:
            if indent <= 0:
                continue
            on_item_indent = indent
        if indent == on_item_indent and normalized.startswith("- "):
            current_event = parse_scalar(normalized[2:])
            if current_event:
                events.add(current_event)
            in_types = False
            event_body_indent = None
            types_list_indent = None
            continue
        if indent == on_item_indent and normalized.endswith(":"):
            current_event = parse_scalar(normalized[:-1].strip())
            if current_event:
                events.add(current_event)
            in_types = False
            event_body_indent = None
            types_list_indent = None
            continue
        if indent == on_item_indent and ":" in normalized:
            current_event, tail = normalized.split(":", 1)
            current_event = parse_scalar(current_event.strip())
            tail = tail.strip()
            if current_event:
                events.add(current_event)
            if current_event and tail:
                record_inline_trigger_types(current_event, tail, event_types)
            in_types = False
            event_body_indent = None
            types_list_indent = None
            continue
        if current_event and indent > on_item_indent:
            if event_body_indent is None:
                if ":" in normalized and not normalized.startswith("- "):
                    event_body_indent = indent
            if event_body_indent is not None and indent == event_body_indent:
                key, tail = split_mapping_line(normalized)
                if key != "types":
                    continue
                items = parse_inline_string_list(tail)
                if items:
                    event_types.setdefault(current_event, set()).update(items)
                    in_types = False
                    types_list_indent = None
                else:
                    event_types.setdefault(current_event, set())
                    in_types = True
                    types_list_indent = None
                continue
        if current_event and in_types:
            if event_body_indent is not None and indent <= event_body_indent:
                in_types = False
                types_list_indent = None
                continue
            if types_list_indent is None:
                if indent > (event_body_indent or on_item_indent):
                    types_list_indent = indent
            if types_list_indent is not None and indent == types_list_indent and normalized.startswith("- "):
                item = parse_scalar(normalized[2:])
                if item:
                    event_types.setdefault(current_event, set()).add(item)
                continue
    return events, event_types
def scan_job_names(path: Path) -> dict[str, str]:
    jobs: dict[str, str] = {}
    in_jobs = False
    job_indent: int | None = None
    current_job: str | None = None
    job_key_indent: int | None = None
    job_step: int | None = None
    for raw in read_yaml_lines(path):
        if not raw.strip() or raw.lstrip().startswith("#"):
            continue
        indent = len(raw) - len(raw.lstrip(" "))
        stripped = raw.strip()
        normalized = strip_inline_comment(stripped)
        if not normalized:
            continue
        if indent == 0:
            key, tail = split_mapping_line(normalized)
            if key == "jobs":
                if tail:
                    inline_jobs = parse_inline_mapping(tail)
                    if inline_jobs is not None:
                        for job_id, job_spec in inline_jobs.items():
                            if not job_id:
                                continue
                            job_name = job_id
                            inline_job = parse_inline_mapping(job_spec) if job_spec else None
                            if inline_job is not None:
                                name = inline_job.get("name")
                                if name:
                                    job_name = name or job_name
                            jobs[job_id] = job_name
                    break
                in_jobs = True
                job_indent = None
                current_job = None
                job_key_indent = None
                job_step = None
                continue
        if in_jobs and indent == 0:
            break
        if not in_jobs:
            continue
        if job_indent is None:
            if indent <= 0:
                continue
            if normalized.startswith("- "):
                continue
            if ":" not in normalized:
                continue
            job_indent = indent
        if indent == job_indent:
            job_id, job_tail = split_mapping_line(normalized)
            if job_id:
                current_job = job_id
                jobs[job_id] = job_id
                job_key_indent = None
                job_step = None
                if job_tail:
                    inline_job = parse_inline_mapping(job_tail)
                    if inline_job is not None:
                        name = inline_job.get("name")
                        if name:
                            jobs[job_id] = name or jobs[job_id]
                continue
        if current_job is None:
            continue
        if job_key_indent is None:
            if indent <= job_indent:
                continue
            if normalized.startswith("- "):
                continue
            if ":" not in normalized:
                continue
            job_key_indent = indent
            job_step = job_key_indent - job_indent
            if job_step <= 0:
                job_key_indent = None
                job_step = None
                continue
        if job_key_indent is not None and indent == job_key_indent:
            key, tail = split_mapping_line(normalized)
            if key == "name":
                jobs[current_job] = parse_scalar(tail) or jobs[current_job]
    return jobs
def expected_workflow_shapes(data: dict[str, Any]) -> tuple[set[str], set[str], set[str]]:
    workflow_names: set[str] = set()
    job_names: set[str] = set()
    check_names: set[str] = set()
    expected_pr_workflows = data.get("expected_pr_workflows") if isinstance(data.get("expected_pr_workflows"), list) else []
    for item in expected_pr_workflows:
        if not isinstance(item, dict):
            continue
        workflow_name = item.get("workflow")
        jobs = item.get("jobs") if isinstance(item.get("jobs"), list) else []
        if not isinstance(workflow_name, str) or not workflow_name:
            continue
        workflow_names.add(workflow_name)
        for job in jobs:
            if isinstance(job, str) and job:
                job_names.add(job)
                check_names.add(job)
    return workflow_names, job_names, check_names
def declared_check_names(data: dict[str, Any]) -> set[str]:
    names: set[str] = set()
    for key in ("required_checks", "informational_checks"):
        values = data.get(key) if isinstance(data.get(key), list) else []
        for item in values:
            if isinstance(item, str) and item:
                names.add(item)
    return names
def expected_workflow_checks_missing_declaration(
    expected_workflow_checks: set[str],
    declared_checks: set[str],
) -> list[str]:
    return sorted(check for check in expected_workflow_checks if check not in declared_checks)
def expected_check_workflows(data: dict[str, Any]) -> dict[str, set[str]]:
    mapping: dict[str, set[str]] = {}
    expected_pr_workflows = data.get("expected_pr_workflows") if isinstance(data.get("expected_pr_workflows"), list) else []
    for item in expected_pr_workflows:
        if not isinstance(item, dict):
            continue
        workflow_name = item.get("workflow")
        jobs = item.get("jobs") if isinstance(item.get("jobs"), list) else []
        if not isinstance(workflow_name, str) or not workflow_name:
            continue
        for job in jobs:
            if isinstance(job, str) and job:
                mapping.setdefault(job, set()).add(workflow_name)
    return mapping
def remote_reusable_job_matches(check_name: str, remote_reusable_jobs: set[str]) -> bool:
    reusable_prefix, sep, _leaf = check_name.rpartition(" / ")
    return bool(sep) and reusable_prefix in remote_reusable_jobs

def matches_remote_reusable_check(
    check_name: str,
    remote_reusable_jobs: set[str],
    github_checks: set[str] | None = None,
) -> bool:
    if not remote_reusable_job_matches(check_name, remote_reusable_jobs):
        return False
    if github_checks is None:
        return False
    return check_name in github_checks

def unresolved_matrix_job_matches(check_name: str, job_name: str) -> bool:
    if check_name == job_name:
        return True
    placeholder_pattern = re.escape(job_name)
    placeholder_pattern = re.sub(r"\\\$\\\{\\\{.*?\\\}\\\}", r".+?", placeholder_pattern)
    if placeholder_pattern != re.escape(job_name) and re.fullmatch(placeholder_pattern, check_name):
        return True
    stem = re.sub(r"\$\{\{.*?\}\}", "", job_name)
    stem = re.sub(r"\(\s*\)", "", stem)
    stem = re.sub(r"\s+", " ", stem).strip().strip("-/:, ")
    if not stem:
        return False
    return (
        check_name == stem
        or check_name.startswith(f"{stem} (")
    )

def matches_unresolved_matrix_check(
    check_name: str,
    unresolved_matrix_jobs: set[str],
    github_checks: set[str] | None = None,
) -> bool:
    if not any(unresolved_matrix_job_matches(check_name, job_name) for job_name in unresolved_matrix_jobs):
        return False
    if github_checks is None:
        return True
    return check_name in github_checks

def matches_github_aligned_check(
    check_name: str,
    remote_reusable_jobs: set[str],
    unresolved_matrix_jobs: set[str],
    github_checks: set[str] | None,
) -> bool:
    if github_checks is None:
        return False
    return matches_remote_reusable_check(check_name, remote_reusable_jobs, github_checks) or matches_unresolved_matrix_check(check_name, unresolved_matrix_jobs, github_checks)
def required_checks_missing_expected_mapping(
    required_checks: list[str],
    expected_workflow_checks: set[str],
    inventory_check_names: set[str],
    remote_reusable_jobs_by_workflow: dict[str, set[str]],
    unresolved_matrix_jobs_by_workflow: dict[str, set[str]],
) -> list[str]:
    missing: set[str] = set()
    for check in required_checks:
        if check in expected_workflow_checks:
            continue
        if check in inventory_check_names:
            missing.add(check)
            continue
        # Remote reusable leaf checks and unresolved/dynamic matrix jobs are still workflow-backed
        # even though local scanning cannot prove the final leaf name. Require explicit mapping in
        # expected_pr_workflows when the repo workflow inventory indicates such a check could exist.
        if any(remote_reusable_job_matches(check, jobs) for jobs in remote_reusable_jobs_by_workflow.values()):
            missing.add(check)
            continue
        if any(matches_unresolved_matrix_check(check, jobs) for jobs in unresolved_matrix_jobs_by_workflow.values()):
            missing.add(check)
    return sorted(missing)
def is_utc_timestamp(value: Any) -> bool:
    if not isinstance(value, str) or not value:
        return False
    try:
        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError:
        return False
    offset = parsed.utcoffset()
    if offset is None:
        return False
    return offset.total_seconds() == 0
def validate_waivers(waivers: list[Any]) -> list[str]:
    errors: list[str] = []
    for index, waiver in enumerate(waivers):
        prefix = f"waivers[{index}]"
        if not isinstance(waiver, dict):
            errors.append(f"{prefix} must be an object")
            continue
        for key in ("check_name", "scope", "reason", "approved_by"):
            value = waiver.get(key)
            if not isinstance(value, str) or not value:
                errors.append(f"{prefix}.{key} must be a non-empty string")
        if not is_utc_timestamp(waiver.get("approved_at")):
            errors.append(f"{prefix}.approved_at must be an ISO-8601 UTC timestamp")
        expires_at = waiver.get("expires_at")
        if expires_at is not None and not is_utc_timestamp(expires_at):
            errors.append(f"{prefix}.expires_at must be an ISO-8601 UTC timestamp when set")
        ticket = waiver.get("ticket")
        if ticket is not None and (not isinstance(ticket, str) or not ticket):
            errors.append(f"{prefix}.ticket must be a non-empty string when set")
    return errors
def review_policy_workflow_names(data: dict[str, Any]) -> set[str]:
    names = set(REVIEW_POLICY_WORKFLOW_NAMES)
    expected = expected_review_policy_env(data)
    if expected is None:
        return names
    check_name = expected.get("REVIEW_POLICY_CHECK_NAME")
    if not isinstance(check_name, str) or not check_name:
        return names
    for item in data.get("expected_pr_workflows", []):
        if not isinstance(item, dict):
            continue
        workflow_name = item.get("workflow")
        jobs = item.get("jobs") if isinstance(item.get("jobs"), list) else []
        if isinstance(workflow_name, str) and any(
            isinstance(job, str) and job == check_name
            for job in jobs
        ):
            names.add(workflow_name)
    return names


def pick_unique_review_policy_candidate(candidates: list[WorkflowInventory]) -> WorkflowInventory | None:
    if len(candidates) == 1:
        return candidates[0]
    if len(candidates) <= 1:
        return None
    preferred = [
        item
        for item in candidates
        if item.workflow in REVIEW_POLICY_WORKFLOW_NAMES or Path(item.path).stem in REVIEW_POLICY_WORKFLOW_NAMES
    ]
    if len(preferred) == 1:
        return preferred[0]
    return None
def find_review_policy_workflow(repo_root: Path, inventory: list[WorkflowInventory], data: dict[str, Any]) -> Path | None:
    expected = expected_review_policy_env(data)
    check_name = expected.get("REVIEW_POLICY_CHECK_NAME") if isinstance(expected, dict) else None

    def prefer_pr_gate(candidates: list[WorkflowInventory]) -> list[WorkflowInventory]:
        pr_candidates = [
            item
            for item in candidates
            if scan_top_level_triggers(repo_root / item.path)[0] & PR_GATE_WORKFLOW_EVENTS
        ]
        return pr_candidates or candidates

    # Prefer a dedicated review-policy workflow by well-known name/path over any
    # declaration-driven mapping. Otherwise, ambiguous declarations can bind this
    # check to unrelated workflows that happen to reuse the same job name.
    dedicated_candidates = [
        item
        for item in inventory
        if item.workflow in REVIEW_POLICY_WORKFLOW_NAMES or Path(item.path).stem in REVIEW_POLICY_WORKFLOW_NAMES
    ]
    if dedicated_candidates:
        picked = pick_unique_review_policy_candidate(prefer_pr_gate(dedicated_candidates))
        if picked is not None:
            return repo_root / picked.path
        return None

    explicit_names: set[str] = set()
    if isinstance(check_name, str) and check_name:
        for item in data.get("expected_pr_workflows", []):
            if not isinstance(item, dict):
                continue
            workflow_name = item.get("workflow")
            jobs = item.get("jobs") if isinstance(item.get("jobs"), list) else []
            if isinstance(workflow_name, str) and any(isinstance(job, str) and job == check_name for job in jobs):
                explicit_names.add(workflow_name)
    explicit_candidates = [item for item in inventory if item.workflow in explicit_names]
    if explicit_candidates:
        picked = pick_unique_review_policy_candidate(prefer_pr_gate(explicit_candidates))
        if picked is not None:
            return repo_root / picked.path
        return None

    candidate_names = review_policy_workflow_names(data)
    named_candidates = [item for item in inventory if item.workflow in candidate_names]
    if named_candidates:
        picked = pick_unique_review_policy_candidate(prefer_pr_gate(named_candidates))
        if picked is not None:
            return repo_root / picked.path
        return None
    return None
def expected_review_policy_contract(data: dict[str, Any]) -> dict[str, Any] | None:
    policy = data.get("policy")
    if not isinstance(policy, dict):
        return None
    review = policy.get("review_policy")
    if not isinstance(review, dict):
        return None
    enforcement = review.get("enforcement") if isinstance(review.get("enforcement"), dict) else {}
    if enforcement.get("mode") != "github-native":
        return None
    exempt_permissions = review.get("exempt_author_permissions") if isinstance(review.get("exempt_author_permissions"), list) else []
    allowed_permissions = review.get("allowed_reviewer_permissions") if isinstance(review.get("allowed_reviewer_permissions"), list) else []
    return {
        "mode": "github-native",
        "required_approving_review_count": review.get("required_approvals"),
        "exempt_repository_owner": review.get("exempt_repository_owner", False),
        "exempt_author_permissions": sorted({item for item in exempt_permissions if isinstance(item, str) and item}),
        "allowed_reviewer_permissions": sorted({item for item in allowed_permissions if isinstance(item, str) and item}),
        "bypass_mode": enforcement.get("bypass_mode"),
    }

def find_legacy_review_policy_workflows(repo_root: Path, inventory: list[WorkflowInventory]) -> list[dict[str, Any]]:
    matches: list[dict[str, Any]] = []
    for item in inventory:
        workflow_path = repo_root / item.path
        workflow_env = scan_top_level_env(workflow_path)
        job_env = scan_job_env(workflow_path)
        workflow_events, _workflow_event_types = scan_top_level_triggers(workflow_path)
        job_names = {name for name in scan_job_names(workflow_path).values() if name}
        env_keys = set(workflow_env)
        for values in job_env.values():
            env_keys.update(values)
        reasons: list[str] = []
        lowered_workflow = item.workflow.lower()
        lowered_path = item.path.lower()
        if "review policy" in lowered_workflow or "review-policy" in lowered_path:
            reasons.append("name")
        if "pull_request_review" in workflow_events:
            reasons.append("trigger:pull_request_review")
        if any(key.startswith("REVIEW_POLICY_") for key in env_keys):
            reasons.append("env:review_policy")
        if "Review Policy Gate" in job_names:
            reasons.append("job:Review Policy Gate")
        if len(reasons) >= 2:
            matches.append(
                {
                    "workflow": item.workflow,
                    "path": item.path,
                    "reasons": sorted(set(reasons)),
                }
            )
    return matches

def compare_review_policy_contract(
    repo_root: Path,
    inventory: list[WorkflowInventory],
    data: dict[str, Any],
    github_branch_protection: dict[str, Any] | None,
) -> dict[str, Any] | None:
    expected = expected_review_policy_contract(data)
    if expected is None:
        return None
    mismatches: list[dict[str, Any]] = []
    legacy_workflows = find_legacy_review_policy_workflows(repo_root, inventory)
    if legacy_workflows:
        mismatches.append(
            {
                "key": "legacy_workflows",
                "expected": [],
                "actual": [item["path"] for item in legacy_workflows],
            }
        )
    checked = github_branch_protection is not None
    actual_review_policy = None
    if checked:
        actual_review_policy = github_branch_protection.get("review_policy")
        if actual_review_policy is None:
            mismatches.append({"key": "github_review_policy", "expected": "present", "actual": None})
        else:
            expected_bypass_permissions = expected["exempt_author_permissions"]
            actual_bypass_permissions = actual_review_policy.get("bypass_permissions")
            if actual_review_policy.get("required_approving_review_count") != expected["required_approving_review_count"]:
                mismatches.append(
                    {
                        "key": "required_approving_review_count",
                        "expected": expected["required_approving_review_count"],
                        "actual": actual_review_policy.get("required_approving_review_count"),
                    }
                )
            if actual_review_policy.get("bypass_mode") != expected["bypass_mode"]:
                mismatches.append(
                    {
                        "key": "bypass_mode",
                        "expected": expected["bypass_mode"],
                        "actual": actual_review_policy.get("bypass_mode"),
                    }
                )
            if actual_bypass_permissions != expected_bypass_permissions:
                mismatches.append(
                    {
                        "key": "bypass_permissions",
                        "expected": expected_bypass_permissions,
                        "actual": actual_bypass_permissions,
                    }
                )
    return {
        "mode": expected["mode"],
        "checked": checked,
        "expected": expected,
        "actual": actual_review_policy,
        "legacy_workflows": legacy_workflows,
        "mismatches": mismatches,
    }
def validate_declaration(data: Any) -> list[str]:
    errors: list[str] = []
    if not isinstance(data, dict):
        return ["declaration must be a JSON object"]
    if data.get("schema_version") != 1:
        errors.append("schema_version must equal 1")
    policy = data.get("policy")
    if not isinstance(policy, dict):
        errors.append("policy must be an object")
        policy = {}
    if policy.get("baseline_policy") != "explicit-waiver-required":
        errors.append("policy.baseline_policy must equal explicit-waiver-required")
    if policy.get("require_signed_commits") is not True:
        errors.append("policy.require_signed_commits must be true")
    branch_protection = policy.get("branch_protection")
    if not isinstance(branch_protection, dict):
        errors.append("policy.branch_protection must be an object")
    else:
        branches = branch_protection.get("protected_branches")
        if not isinstance(branches, list) or not branches or not all(isinstance(item, str) and item for item in branches):
            errors.append("policy.branch_protection.protected_branches must be a non-empty string list")
        elif any(item.startswith("<") and item.endswith(">") for item in branches):
            errors.append("policy.branch_protection.protected_branches must not contain placeholder values")
        if branch_protection.get("require_pull_request") is not True:
            errors.append("policy.branch_protection.require_pull_request must be true")
        if branch_protection.get("disallow_direct_pushes") is not True:
            errors.append("policy.branch_protection.disallow_direct_pushes must be true")
    review = policy.get("review_policy")
    if review is not None:
        if not isinstance(review, dict):
            errors.append("policy.review_policy must be an object when provided")
            review = {}
        if review.get("mode") != "conditional-required":
            errors.append("policy.review_policy.mode must equal conditional-required")
        if not isinstance(review.get("required_approvals"), int) or review.get("required_approvals", 0) < 1:
            errors.append("policy.review_policy.required_approvals must be an integer >= 1")
        if not isinstance(review.get("exempt_repository_owner"), bool):
            errors.append("policy.review_policy.exempt_repository_owner must be a boolean")
        for key in ("exempt_author_permissions", "allowed_reviewer_permissions"):
            value = review.get(key)
            if not isinstance(value, list) or not value or not all(isinstance(item, str) and item for item in value):
                errors.append(f"policy.review_policy.{key} must be a non-empty string list")
                continue
            invalid = sorted({item for item in value if item not in REPO_COLLABORATOR_ROLE_NAMES})
            if invalid:
                errors.append(
                    f"policy.review_policy.{key} contains unsupported permission values: {', '.join(invalid)}"
                )
        enforcement = review.get("enforcement")
        if not isinstance(enforcement, dict):
            errors.append("policy.review_policy.enforcement must be an object")
        else:
            if enforcement.get("mode") != "github-native":
                errors.append("policy.review_policy.enforcement.mode must equal github-native")
            if enforcement.get("bypass_mode") != "pull-request-only":
                errors.append("policy.review_policy.enforcement.bypass_mode must equal pull-request-only")
            if enforcement.get("check_name") is not None:
                errors.append("policy.review_policy.enforcement.check_name is unsupported when mode is github-native")
    for key in ("required_checks", "informational_checks", "waivers", "expected_pr_workflows"):
        value = data.get(key)
        if not isinstance(value, list):
            errors.append(f"{key} must be a list")
    for key in ("required_checks", "informational_checks"):
        values = data.get(key) if isinstance(data.get(key), list) else []
        if any(not isinstance(item, str) or not item for item in values):
            errors.append(f"{key} entries must be non-empty strings")
    waivers = data.get("waivers") if isinstance(data.get("waivers"), list) else []
    errors.extend(validate_waivers(waivers))
    expected_pr_workflows = data.get("expected_pr_workflows") if isinstance(data.get("expected_pr_workflows"), list) else []
    required_checks = data.get("required_checks") if isinstance(data.get("required_checks"), list) else []
    informational_checks = data.get("informational_checks") if isinstance(data.get("informational_checks"), list) else []
    required_set = {item for item in required_checks if isinstance(item, str) and item}
    informational_set = {item for item in informational_checks if isinstance(item, str) and item}
    overlap = sorted(required_set & informational_set)
    if overlap:
        errors.append("informational_checks must not overlap required_checks: " + ", ".join(overlap))
    legacy_review_policy_checks = sorted(
        {
            item
            for item in required_set | informational_set
            if "review policy" in item.lower()
        }
    )
    if legacy_review_policy_checks:
        errors.append(
            "required_checks and informational_checks must not declare workflow-backed review-policy checks; use optional GitHub-native policy.review_policy instead: "
            + ", ".join(legacy_review_policy_checks)
        )
    legacy_review_policy_workflows = sorted(
        {
            item.get("workflow")
            for item in expected_pr_workflows
            if isinstance(item, dict)
            and isinstance(item.get("workflow"), str)
            and "review policy" in item.get("workflow").lower()
        }
    )
    if legacy_review_policy_workflows:
        errors.append(
            "expected_pr_workflows must not declare workflow-backed review policy entries; use optional GitHub-native policy.review_policy instead: "
            + ", ".join(legacy_review_policy_workflows)
        )
    expected_workflow_names, _expected_job_names, expected_workflow_checks = expected_workflow_shapes(data)
    declared_checks = declared_check_names(data)
    missing_declared_checks = expected_workflow_checks_missing_declaration(expected_workflow_checks, declared_checks)
    if missing_declared_checks:
        errors.append(
            "expected_pr_workflows.jobs must each be listed in required_checks or informational_checks using the final GitHub check name; local reusable workflows expand to composite names like 'Caller / Child': "
            + ", ".join(missing_declared_checks)
        )
    for item in expected_pr_workflows:
        if not isinstance(item, dict):
            errors.append("expected_pr_workflows entries must be objects")
            continue
        if not isinstance(item.get("workflow"), str) or not item.get("workflow"):
            errors.append("expected_pr_workflows.workflow must be a non-empty string")
        jobs = item.get("jobs")
        if not isinstance(jobs, list) or not jobs or not all(isinstance(job, str) and job for job in jobs):
            errors.append("expected_pr_workflows.jobs must be a non-empty string list")

    unknown_waivers = sorted(
        {
            waiver.get("check_name")
            for waiver in waivers
            if isinstance(waiver, dict)
            and isinstance(waiver.get("check_name"), str)
            and waiver.get("check_name")
            and waiver.get("check_name") not in required_set
        }
    )
    if unknown_waivers:
        errors.append("waivers.check_name must match an entry in required_checks: " + ", ".join(unknown_waivers))
    return errors

def resolve_repo_relative_path(repo_root: Path, raw_path: str) -> Path:
    """Resolve an input path against repo_root unless it is already absolute.

    This keeps the CLI consistent: when --repo-root points at a target repository,
    auxiliary inputs (such as GitHub snapshots) should be resolvable from any cwd.
    """
    path = Path(raw_path).expanduser()
    if path.is_absolute():
        return path
    return (repo_root / path).resolve()


def load_github_required_checks(args: argparse.Namespace, repo_root: Path) -> list[str] | None:
    if args.github_required_checks_file:
        data = load_json(resolve_repo_relative_path(repo_root, args.github_required_checks_file))
        if isinstance(data, dict):
            data = data.get("required_checks")
        if not isinstance(data, list) or not all(isinstance(item, str) for item in data):
            raise InputValidationError("--github-required-checks-file must contain a JSON string array or an object with required_checks")
        return data
    if args.github_required_check:
        return args.github_required_check
    return None
def load_github_branch_protection(args: argparse.Namespace, repo_root: Path) -> dict[str, Any] | None:
    if not args.github_branch_protection_file:
        return None
    data = load_json(resolve_repo_relative_path(repo_root, args.github_branch_protection_file))
    if isinstance(data, dict) and isinstance(data.get("branch_protection"), dict):
        data = data["branch_protection"]
    if not isinstance(data, dict):
        raise InputValidationError("--github-branch-protection-file must contain a JSON object or an object with branch_protection")
    branches = data.get("protected_branches")
    if not isinstance(branches, list) or not all(isinstance(item, str) and item for item in branches):
        raise InputValidationError("github branch protection must include protected_branches as a string list")
    if not isinstance(data.get("require_pull_request"), bool):
        raise InputValidationError("github branch protection must include require_pull_request as a boolean")
    if not isinstance(data.get("disallow_direct_pushes"), bool):
        raise InputValidationError("github branch protection must include disallow_direct_pushes as a boolean")
    if not isinstance(data.get("require_signed_commits"), bool):
        raise InputValidationError("github branch protection must include require_signed_commits as a boolean")
    review_policy = data.get("review_policy")
    if review_policy is not None:
        if not isinstance(review_policy, dict):
            raise InputValidationError("github branch protection review_policy must be an object when provided")
        if not isinstance(review_policy.get("required_approving_review_count"), int) or review_policy.get("required_approving_review_count", -1) < 0:
            raise InputValidationError("github branch protection review_policy.required_approving_review_count must be an integer >= 0")
        bypass_permissions = review_policy.get("bypass_permissions")
        if not isinstance(bypass_permissions, list) or not all(isinstance(item, str) and item in REPO_COLLABORATOR_ROLE_NAMES for item in bypass_permissions):
            raise InputValidationError("github branch protection review_policy.bypass_permissions must be a string list of supported permission values")
        if not isinstance(review_policy.get("bypass_mode"), str) or not review_policy.get("bypass_mode"):
            raise InputValidationError("github branch protection review_policy.bypass_mode must be a non-empty string")
        review_policy = {
            "required_approving_review_count": review_policy["required_approving_review_count"],
            "bypass_permissions": sorted(set(bypass_permissions)),
            "bypass_mode": review_policy["bypass_mode"],
        }
    return {
        "protected_branches": branches,
        "require_pull_request": data["require_pull_request"],
        "disallow_direct_pushes": data["disallow_direct_pushes"],
        "require_signed_commits": data["require_signed_commits"],
        "review_policy": review_policy,
    }
def build_error_report(
    repo_root: Path,
    declaration_path: Path,
    message: str,
    *,
    github_checks: list[str] | None = None,
    github_branch_protection: dict[str, Any] | None = None,
) -> dict[str, Any]:
    return {
        "repo_root": str(repo_root),
        "declaration": str(declaration_path),
        "declaration_errors": [message],
        "workflow_inventory": [],
        "workflow_inventory_errors": [],
        "required_checks_not_in_expected_pr_workflows": [],
        "required_checks_requiring_github_alignment": [],
        "required_checks_missing_from_workflow_inventory": [],
        "expected_pr_workflow_checks_missing_from_declaration": [],
        "workflow_backed_required_checks_not_in_expected_pr_workflows": [],
        "expected_pr_workflow_drift": [],
        "expected_pr_workflow_trigger_drift": [],
        "expected_pr_workflow_merge_group_drift": [],
        "review_policy_alignment": None,
        "github_required_checks": github_checks,
        "github_alignment": None,
        "github_branch_protection": github_branch_protection,
        "branch_protection_alignment": None,
        "status": "invalid",
    }
def build_report(
    repo_root: Path,
    declaration_path: Path,
    _strict: bool,
    github_checks: list[str] | None,
    github_branch_protection: dict[str, Any] | None,
    *,
    allow_unchecked_branch_protection: bool = False,
) -> tuple[int, dict[str, Any]]:
    try:
        data = load_json(declaration_path)
    except JsonInputError as exc:
        return (
            2,
            build_error_report(
                repo_root,
                declaration_path,
                f"invalid JSON input {exc.path}: {exc.message}",
                github_checks=github_checks,
                github_branch_protection=github_branch_protection,
            ),
        )
    errors = validate_declaration(data)
    inventory = scan_workflows(repo_root)
    inventory_errors: list[str] = []
    workflow_name_to_paths: dict[str, list[str]] = {}
    workflow_path_map: dict[str, str] = {}
    for item in inventory:
        workflow_name_to_paths.setdefault(item.workflow, []).append(item.path)
        workflow_path_map.setdefault(item.workflow, item.path)
    for workflow_name, paths in sorted(workflow_name_to_paths.items()):
        if len(paths) > 1:
            inventory_errors.append(
                f"workflow name {workflow_name} is duplicated across: {', '.join(sorted(paths))}"
            )
    workflow_map = workflow_lookup(inventory)
    remote_reusable_map = remote_reusable_lookup(inventory)
    unresolved_matrix_map = unresolved_matrix_lookup(inventory)
    check_context_inventory = [
        item
        for item in inventory
        if scan_top_level_triggers(repo_root / item.path)[0] & CHECK_CONTEXT_WORKFLOW_EVENTS
    ]
    duplicate_contexts = duplicated_check_contexts(check_context_inventory)
    for check_name, labels in sorted(duplicate_contexts.items()):
        inventory_errors.append(
            f"check context {check_name} is duplicated across workflows: {', '.join(labels)}"
        )
    github_checks_set = set(github_checks) if github_checks is not None else None
    required_checks = [item for item in data.get("required_checks", []) if isinstance(item, str)] if isinstance(data, dict) else []
    required_checks_set = set(required_checks)
    declared_checks = declared_check_names(data) if isinstance(data, dict) else set()
    expected_workflow_names, _expected_job_names, expected_workflow_checks = expected_workflow_shapes(data) if isinstance(data, dict) else (set(), set(), set())
    expected_check_to_workflows = expected_check_workflows(data) if isinstance(data, dict) else {}
    # Treat the repo workflow inventory (all workflows) as the workflow-backed namespace.
    # A required check that matches any local workflow job name must be mapped through
    # expected_pr_workflows and validated for PR/merge-queue semantics.
    inventory_check_names = all_check_names(inventory)
    workflow_backed_required_checks = sorted(check for check in required_checks if check in expected_workflow_checks)
    required_checks_without_expected_mapping = required_checks_missing_expected_mapping(
        required_checks,
        expected_workflow_checks,
        inventory_check_names,
        remote_reusable_map,
        unresolved_matrix_map,
    )
    workflow_backed_required_check_name_drift = required_checks_without_expected_mapping
    missing_expected_mapping_set = set(required_checks_without_expected_mapping)
    required_checks_requiring_github_alignment = sorted(
        {
            check
            for check in workflow_backed_required_checks
            if github_checks_set is None
            and any(
                remote_reusable_job_matches(check, remote_reusable_map.get(workflow_name, set()))
                or matches_unresolved_matrix_check(check, unresolved_matrix_map.get(workflow_name, set()))
                for workflow_name in expected_check_to_workflows.get(check, set())
            )
        }
        | {
            # External statuses cannot be proven locally; without GitHub required-check input,
            # treat them as unresolved drift rather than silently accepting them as "ok".
            check
            for check in required_checks
            if github_checks_set is None
            and check not in expected_workflow_checks
            and check not in inventory_check_names
            and check not in missing_expected_mapping_set
        }
    )
    missing_required_jobs = sorted(
        check
        for check in workflow_backed_required_checks
        if check not in expected_workflow_checks
        or (
            check not in inventory_check_names
            and not any(
                matches_remote_reusable_check(check, remote_reusable_map.get(workflow_name, set()), github_checks_set)
                or (github_checks_set is None and remote_reusable_job_matches(check, remote_reusable_map.get(workflow_name, set())))
                or matches_unresolved_matrix_check(check, unresolved_matrix_map.get(workflow_name, set()), github_checks_set)
                for workflow_name in expected_check_to_workflows.get(check, set())
            )
        )
    )
    expected_workflow_checks_without_declaration = expected_workflow_checks_missing_declaration(expected_workflow_checks, declared_checks)
    missing_workflows: list[dict[str, Any]] = []
    expected_pr_workflow_trigger_drift: list[dict[str, Any]] = []
    expected_pr_workflow_merge_group_drift: list[dict[str, Any]] = []
    declared_branch_protection = None
    declared_require_signed_commits = None
    review_policy_alignment = None
    if isinstance(data, dict):
        for item in data.get("expected_pr_workflows", []):
            if not isinstance(item, dict):
                continue
            workflow_name = item.get("workflow")
            jobs = item.get("jobs") if isinstance(item.get("jobs"), list) else []
            existing = workflow_map.get(workflow_name)
            if existing is None:
                missing_workflows.append({"workflow": workflow_name, "missing_jobs": jobs})
                continue
            workflow_path = workflow_path_map.get(workflow_name)
            if isinstance(workflow_path, str) and workflow_path:
                workflow_events, _workflow_event_types = scan_top_level_triggers(repo_root / workflow_path)
                if not (workflow_events & PR_GATE_WORKFLOW_EVENTS):
                    expected_pr_workflow_trigger_drift.append(
                        {
                            "workflow": workflow_name,
                            "path": workflow_path,
                            "actual_events": sorted(workflow_events),
                            "required_events_any_of": sorted(PR_GATE_WORKFLOW_EVENTS),
                        }
                    )
                required_jobs_missing_merge_group = sorted(
                    job
                    for job in jobs
                    if isinstance(job, str)
                    and job in required_checks_set
                )
                if (
                    required_jobs_missing_merge_group
                    and (workflow_events & PR_GATE_WORKFLOW_EVENTS)
                    and "merge_group" not in workflow_events
                ):
                    expected_pr_workflow_merge_group_drift.append(
                        {
                            "workflow": workflow_name,
                            "path": workflow_path,
                            "required_jobs": required_jobs_missing_merge_group,
                            "actual_events": sorted(workflow_events),
                            "missing_event": "merge_group",
                        }
                    )
            missing_jobs = [
                job
                for job in jobs
                if job not in existing
                and not matches_github_aligned_check(
                    job,
                    remote_reusable_map.get(workflow_name, set()),
                    unresolved_matrix_map.get(workflow_name, set()),
                    github_checks_set,
                )
            ]
            if missing_jobs:
                missing_workflows.append({"workflow": workflow_name, "missing_jobs": missing_jobs})
        if isinstance(data.get("policy"), dict):
            if isinstance(data["policy"].get("branch_protection"), dict):
                declared_branch_protection = data["policy"]["branch_protection"]
            if isinstance(data["policy"].get("require_signed_commits"), bool):
                declared_require_signed_commits = data["policy"]["require_signed_commits"]
        review_policy_alignment = compare_review_policy_contract(repo_root, inventory, data, github_branch_protection)
    github_drift = None
    branch_protection_drift = None
    local_inventory_drift = bool(
        required_checks_without_expected_mapping
        or required_checks_requiring_github_alignment
        or missing_required_jobs
        or expected_workflow_checks_without_declaration
        or missing_workflows
        or expected_pr_workflow_trigger_drift
        or expected_pr_workflow_merge_group_drift
        or (review_policy_alignment is not None and review_policy_alignment["mismatches"])
    )
    exit_code = 0
    if errors:
        exit_code = worsen_exit_code(exit_code, 2)
    if inventory_errors:
        exit_code = worsen_exit_code(exit_code, 2)
    if github_checks is not None:
        declared = set(required_checks)
        actual = set(github_checks)
        github_drift = {
            "missing_on_github": sorted(declared - actual),
            "unexpected_on_github": sorted(actual - declared),
        }
        if github_drift["missing_on_github"] or github_drift["unexpected_on_github"]:
            exit_code = worsen_exit_code(exit_code, 1)
    if declared_branch_protection is not None:
        if github_branch_protection is None:
            branch_protection_drift = {
                "checked": False,
                "missing_input": "--github-branch-protection-file",
                "missing_on_github": None,
                "unexpected_on_github": None,
                "require_pull_request_matches": None,
                "disallow_direct_pushes_matches": None,
                "require_signed_commits_matches": None,
            }
            # Default fail-closed: declaring branch protection without GitHub evidence is unresolved drift.
            if not allow_unchecked_branch_protection:
                exit_code = worsen_exit_code(exit_code, 1)
        else:
            declared_branches = set(declared_branch_protection.get("protected_branches", []))
            actual_branches = set(github_branch_protection.get("protected_branches", []))
            branch_protection_drift = {
                "checked": True,
                "missing_on_github": sorted(declared_branches - actual_branches),
                "unexpected_on_github": sorted(actual_branches - declared_branches),
                "require_pull_request_matches": github_branch_protection.get("require_pull_request") == declared_branch_protection.get("require_pull_request"),
                "disallow_direct_pushes_matches": github_branch_protection.get("disallow_direct_pushes") == declared_branch_protection.get("disallow_direct_pushes"),
                "require_signed_commits_matches": github_branch_protection.get("require_signed_commits") == declared_require_signed_commits,
            }
            if (
                branch_protection_drift["missing_on_github"]
                or branch_protection_drift["unexpected_on_github"]
                or not branch_protection_drift["require_pull_request_matches"]
                or not branch_protection_drift["disallow_direct_pushes_matches"]
                or not branch_protection_drift["require_signed_commits_matches"]
            ):
                exit_code = worsen_exit_code(exit_code, 1)
    if local_inventory_drift:
        exit_code = worsen_exit_code(exit_code, 1)
    report = {
        "repo_root": str(repo_root),
        "declaration": str(declaration_path),
        "declaration_errors": errors,
        "workflow_inventory": [
            {
                "path": item.path,
                "workflow": item.workflow,
                "jobs": item.jobs,
                "remote_reusable_jobs": item.remote_reusable_jobs,
                "unresolved_matrix_jobs": item.unresolved_matrix_jobs,
            }
            for item in inventory
        ],
        "workflow_inventory_errors": inventory_errors,
        "required_checks_not_in_expected_pr_workflows": required_checks_without_expected_mapping,
        "required_checks_requiring_github_alignment": required_checks_requiring_github_alignment,
        "required_checks_missing_from_workflow_inventory": missing_required_jobs,
        "expected_pr_workflow_checks_missing_from_declaration": expected_workflow_checks_without_declaration,
        "workflow_backed_required_checks_not_in_expected_pr_workflows": workflow_backed_required_check_name_drift,
        "expected_pr_workflow_drift": missing_workflows,
        "expected_pr_workflow_trigger_drift": expected_pr_workflow_trigger_drift,
        "expected_pr_workflow_merge_group_drift": expected_pr_workflow_merge_group_drift,
        "review_policy_alignment": review_policy_alignment,
        "github_required_checks": github_checks,
        "github_alignment": github_drift,
        "github_branch_protection": github_branch_protection,
        "branch_protection_alignment": branch_protection_drift,
        "status": "invalid" if exit_code == 2 else "drift" if exit_code == 1 else "ok",
    }
    return exit_code, report
def main() -> int:
    parser = argparse.ArgumentParser(description="Validate a quality-gates declaration against repo workflow inventory and optional GitHub required checks/branch protection state.")
    parser.add_argument("--repo-root", default=".")
    parser.add_argument("--declaration", default=".github/quality-gates.json")
    parser.add_argument("--strict", action="store_true", help="Retained for compatibility; local workflow drift now fails by default.")
    parser.add_argument("--github-required-check", action="append", default=[], help="Repeatable exact GitHub required check names to compare against the declaration.")
    parser.add_argument("--github-required-checks-file", help="JSON file containing a string array of GitHub required checks or an object with required_checks.")
    parser.add_argument("--github-branch-protection-file", help="JSON file containing branch protection state or an object with branch_protection.")
    parser.add_argument(
        "--allow-unchecked-branch-protection",
        action="store_true",
        help="Allow branch_protection alignment to remain unchecked when no GitHub snapshot is provided (still reported, but does not fail the report).",
    )
    args = parser.parse_args()
    repo_root = Path(args.repo_root).expanduser().resolve()
    declaration_path = Path(args.declaration)
    if not declaration_path.is_absolute():
        declaration_path = (repo_root / declaration_path).resolve()
    if not declaration_path.exists():
        report = build_error_report(repo_root, declaration_path, f"declaration not found: {declaration_path}")
        print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
        return 2
    try:
        github_checks = load_github_required_checks(args, repo_root)
        github_branch_protection = load_github_branch_protection(args, repo_root)
    except (JsonInputError, InputValidationError) as exc:
        report = build_error_report(repo_root, declaration_path, str(exc))
        if isinstance(exc, JsonInputError):
            report["declaration_errors"] = [f"invalid JSON input {exc.path}: {exc.message}"]
        print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
        return 2
    exit_code, report = build_report(
        repo_root,
        declaration_path,
        args.strict,
        github_checks,
        github_branch_protection,
        allow_unchecked_branch_protection=args.allow_unchecked_branch_protection,
    )
    print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
    return exit_code
if __name__ == "__main__":
    raise SystemExit(main())
skills/policies/quality-gates/assets/templates/quality-gates.example.json
skills/policies/quality-gates/assets/templates/quality-gates.example.json
json · 28 行
{
  "schema_version": 1,
  "policy": {
    "baseline_policy": "explicit-waiver-required",
    "require_signed_commits": true,
    "branch_protection": {
      "protected_branches": [
        "<default-branch>"
      ],
      "require_pull_request": true,
      "disallow_direct_pushes": true
    }
  },
  "required_checks": [
    "Label Gate"
  ],
  "informational_checks": [],
  "waivers": [],
  "expected_pr_workflows": [
    {
      "workflow": "Label Gate",
      "jobs": [
        "Label Gate"
      ]
    }
  ]
}
skills/policies/quality-gates/assets/templates/waiver-record.example.json
skills/policies/quality-gates/assets/templates/waiver-record.example.json
json · 10 行
{
  "check_name": "Dependency review",
  "scope": "pull_request",
  "reason": "Dependency review is informational on the current GitHub plan.",
  "approved_by": "IvanLi-CN",
  "approved_at": "2026-03-08T00:00:00Z",
  "expires_at": "2026-12-31T00:00:00Z",
  "ticket": "ISSUE-123"
}