diff --git a/VERSION b/VERSION index 6ecac68..ed21137 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9.5 \ No newline at end of file +1.10.0 \ No newline at end of file diff --git a/scripts/drift_check/checks/__init__.py b/scripts/drift_check/checks/__init__.py index 6cf791b..d364386 100644 --- a/scripts/drift_check/checks/__init__.py +++ b/scripts/drift_check/checks/__init__.py @@ -1,11 +1,12 @@ """Registered checks live here. Session A shipped ``version_signal``. Session B adds ``broken_refs``, -``required_refs``, and ``stale_counts``. Phase 3 will add more via the -same ``Check`` Protocol from ``types.py``. +``required_refs``, and ``stale_counts``. ``required_workflows`` was added +in v1.10.0. """ from .broken_refs import BrokenRefsCheck from .required_refs import RequiredRefsCheck +from .required_workflows import RequiredWorkflowsCheck from .stale_counts import StaleCountsCheck from .version_signal import VersionSignalCheck @@ -13,5 +14,6 @@ "VersionSignalCheck", "BrokenRefsCheck", "RequiredRefsCheck", + "RequiredWorkflowsCheck", "StaleCountsCheck", ] diff --git a/scripts/drift_check/checks/required_workflows.py b/scripts/drift_check/checks/required_workflows.py new file mode 100644 index 0000000..851d84c --- /dev/null +++ b/scripts/drift_check/checks/required_workflows.py @@ -0,0 +1,68 @@ +"""Required-workflows check. + +For each repo, compare the set of workflow filenames present under +``.github/workflows/`` against the per-type ``required_workflows`` list +resolved from ``standards/drift-checker.config.json``. + +A workflow that is required but absent -> ``error``. +Extra or unexpected workflows are never flagged; this check is presence- +only and never emits "this workflow should not be here" findings. + +Policy lives entirely in config (``types..required_workflows``), +merged via the additive-strictness tier logic in ``DriftConfig.resolve``. +No workflow names are hardcoded here. + +Edge cases: +* ``repo_type == "unknown"`` -> silent; we cannot know what is required. +* ``required_workflows`` empty (absent from config) -> silent; permissive + by default, same posture as ``required-refs``. +* ``skip_checks`` contains this check's name -> silent for that repo. +* No per-file pragma support; suppression is via ``skip_checks`` in + config, because the check operates at repo level (no file to annotate + when the workflow is absent). +""" +from __future__ import annotations + +from typing import Iterable, List + +from ..types import Finding, RepoSnapshot + + +NAME = "required-workflows" + + +class RequiredWorkflowsCheck: + name: str = NAME + + def run(self, snapshot: RepoSnapshot) -> Iterable[Finding]: + if NAME in snapshot.config.skip_checks: + return () + + # Cannot determine requirements for unknown repo types. + if snapshot.repo_type == "unknown": + return () + + required = snapshot.config.required_workflows + if not required: + return () + + out: List[Finding] = [] + for workflow in sorted(required): + if workflow not in snapshot.present_workflows: + out.append( + Finding( + repo=snapshot.slug, + file=None, + check=NAME, + severity="error", + message=( + f"required workflow '{workflow}' is absent" + f" (required for {snapshot.repo_type} repos)" + ), + suggested_fix=( + f"add .github/workflows/{workflow} following" + f" the scaffold template or ci-cd.md" + ), + ) + ) + return out diff --git a/scripts/drift_check/cli.py b/scripts/drift_check/cli.py index 770d34c..898553e 100644 --- a/scripts/drift_check/cli.py +++ b/scripts/drift_check/cli.py @@ -41,6 +41,7 @@ from drift_check.checks import ( # type: ignore BrokenRefsCheck, RequiredRefsCheck, + RequiredWorkflowsCheck, StaleCountsCheck, VersionSignalCheck, ) @@ -63,6 +64,7 @@ from .checks import ( BrokenRefsCheck, RequiredRefsCheck, + RequiredWorkflowsCheck, StaleCountsCheck, VersionSignalCheck, ) @@ -315,6 +317,7 @@ def _run_checks(snapshots: Sequence[RepoSnapshot]) -> List[Finding]: VersionSignalCheck(), BrokenRefsCheck(), RequiredRefsCheck(), + RequiredWorkflowsCheck(), StaleCountsCheck(), ) for snap in snapshots: diff --git a/scripts/drift_check/snapshot.py b/scripts/drift_check/snapshot.py index 90941a0..bc04853 100644 --- a/scripts/drift_check/snapshot.py +++ b/scripts/drift_check/snapshot.py @@ -49,6 +49,7 @@ "skills", "rules", ".cursor-plugin", + ".github/workflows", # workflow presence for the required-workflows check ) @@ -91,6 +92,19 @@ def _detect_repo_type(repo_path: Path) -> RepoType: return "unknown" +def _collect_workflow_names(repo_path: Path) -> frozenset[str]: + """Return the set of workflow filenames (not paths) present under + ``.github/workflows/``. Only ``.yml`` and ``.yaml`` files count. + Missing directory -> empty frozenset (not an error).""" + wf_dir = repo_path / ".github" / "workflows" + if not wf_dir.is_dir(): + return frozenset() + return frozenset( + p.name for p in wf_dir.iterdir() + if p.is_file() and p.suffix.lower() in (".yml", ".yaml") + ) + + def _collect_paths(repo_path: Path) -> list[Path]: out: list[Path] = [] for name in ("AGENTS.md", "CLAUDE.md"): @@ -159,6 +173,7 @@ def _build_snapshot_from_path( config=config.resolve(slug, repo_type), meta_standards=meta_standards, meta_required_refs=meta_required_refs, + present_workflows=_collect_workflow_names(repo_path), ) diff --git a/scripts/drift_check/types.py b/scripts/drift_check/types.py index 4b53096..455314f 100644 --- a/scripts/drift_check/types.py +++ b/scripts/drift_check/types.py @@ -94,6 +94,11 @@ class RepoConfig: repo_type: RepoType skip_checks: frozenset[str] = frozenset() signal_policy: str = "same-major-minor" + # Additive-strictness: tiers ADD requirements, never relax them. + # This is the opposite safety direction from skip_checks (which accumulates + # permissiveness). Do not change to a last-tier-wins override - that would + # let a repo-tier entry silently zero out the type-tier requirements. + required_workflows: frozenset[str] = frozenset() @dataclass(frozen=True) @@ -109,8 +114,16 @@ class DriftConfig: def resolve(self, slug: str, repo_type: RepoType) -> RepoConfig: """Merge globals -> type -> repo. Later layers override scalars and - extend ``skip_checks``.""" + extend accumulating sets (``skip_checks``, ``required_workflows``). + + Note on required_workflows: the merge is additive-strictness only. + Tiers can ADD required workflows but cannot remove them. This is the + opposite safety direction from skip_checks (which accumulates + permissiveness). A repo tier that lists extra workflows makes the repo + stricter, never more lenient. + """ skip: set[str] = set() + required_wf: set[str] = set() signal_policy = str(self.globals.get("signal_policy", "same-major-minor")) for tier in (self.globals, self.types.get(repo_type, {}), self.repos.get(slug, {})): @@ -121,12 +134,16 @@ def resolve(self, slug: str, repo_type: RepoType) -> RepoConfig: skip.update(str(x) for x in tier_skips) if "signal_policy" in tier: signal_policy = str(tier["signal_policy"]) + wf_list = tier.get("required_workflows", []) + if isinstance(wf_list, list): + required_wf.update(str(x) for x in wf_list) return RepoConfig( slug=slug, repo_type=repo_type, skip_checks=frozenset(skip), signal_policy=signal_policy, + required_workflows=frozenset(required_wf), ) @@ -145,6 +162,10 @@ class RepoSnapshot: # this in remote mode via sparse-checkout. meta_standards: frozenset[str] = frozenset() meta_required_refs: Mapping[str, Mapping[str, Sequence[str]]] = field(default_factory=dict) + # Filenames (not paths) of workflow files present under .github/workflows/. + # Populated at snapshot time; the required-workflows check reads this. + # Default frozenset() so existing snapshot constructions remain valid. + present_workflows: frozenset[str] = frozenset() @dataclass(frozen=True) diff --git a/standards/drift-checker.config.json b/standards/drift-checker.config.json index 5bf69bb..05a46ca 100644 --- a/standards/drift-checker.config.json +++ b/standards/drift-checker.config.json @@ -6,10 +6,21 @@ }, "types": { "cursor-plugin": { - "skip_checks": [] + "skip_checks": [], + "required_workflows": [ + "validate.yml", + "release.yml", + "stale.yml", + "drift-check.yml" + ] }, "mcp-server": { - "skip_checks": ["required-refs"] + "skip_checks": ["required-refs"], + "required_workflows": [ + "drift-check.yml", + "stale.yml", + "publish.yml" + ] } }, "repos": { diff --git a/tests/test_required_workflows.py b/tests/test_required_workflows.py new file mode 100644 index 0000000..595deea --- /dev/null +++ b/tests/test_required_workflows.py @@ -0,0 +1,186 @@ +"""Standalone tests for the required-workflows drift check. + +No test harness existed before this file. Run directly or with pytest: + + python tests/test_required_workflows.py + pytest tests/test_required_workflows.py -v + +Each test function asserts a single behaviour; failures print the finding +list so the cause is immediately visible without a debugger. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +# Allow running from repo root: python tests/test_required_workflows.py +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +from drift_check.checks.required_workflows import RequiredWorkflowsCheck +from drift_check.types import ( + DriftConfig, + Finding, + RepoConfig, + RepoSnapshot, + Version, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_META = Version(major=1, minor=10, patch=0, raw="1.10.0") + + +def _snap( + *, + slug: str = "test-repo", + repo_type: str = "cursor-plugin", + present: frozenset[str] = frozenset(), + required: frozenset[str] = frozenset(), + skip_checks: frozenset[str] = frozenset(), +) -> RepoSnapshot: + cfg = RepoConfig( + slug=slug, + repo_type=repo_type, + skip_checks=skip_checks, + required_workflows=required, + ) + return RepoSnapshot( + slug=slug, + repo_type=repo_type, + files={}, + meta_version=_META, + meta_commit="abc1234", + config=cfg, + present_workflows=present, + ) + + +def _run(snap: RepoSnapshot) -> list[Finding]: + return list(RequiredWorkflowsCheck().run(snap)) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_missing_required_workflow_is_error() -> None: + """A required workflow absent from present_workflows emits an error.""" + findings = _run(_snap( + required=frozenset({"validate.yml", "stale.yml"}), + present=frozenset({"validate.yml"}), + )) + assert len(findings) == 1, findings + f = findings[0] + assert f.severity == "error" + assert f.check == "required-workflows" + assert "stale.yml" in f.message + assert f.file is None + + +def test_compliant_repo_is_silent() -> None: + """A repo with all required workflows present emits nothing.""" + required = frozenset({"validate.yml", "release.yml", "stale.yml", "drift-check.yml"}) + findings = _run(_snap(required=required, present=required)) + assert findings == [], findings + + +def test_unknown_type_is_silent() -> None: + """repo_type 'unknown' never emits required-workflows findings.""" + findings = _run(_snap( + repo_type="unknown", + required=frozenset({"validate.yml"}), + present=frozenset(), + )) + assert findings == [], findings + + +def test_config_absent_is_silent() -> None: + """Empty required_workflows (absent from config) produces no findings.""" + findings = _run(_snap(required=frozenset(), present=frozenset())) + assert findings == [], findings + + +def test_skip_checks_suppresses() -> None: + """skip_checks containing 'required-workflows' silences the check.""" + findings = _run(_snap( + required=frozenset({"validate.yml"}), + present=frozenset(), + skip_checks=frozenset({"required-workflows"}), + )) + assert findings == [], findings + + +def test_extra_workflows_not_flagged() -> None: + """Workflows present but not required are never flagged.""" + required = frozenset({"validate.yml"}) + present = frozenset({"validate.yml", "publish.yml", "codeql.yml", "label-sync.yml"}) + findings = _run(_snap(required=required, present=present)) + assert findings == [], findings + + +def test_multiple_missing_workflows_each_get_finding() -> None: + """Each missing required workflow produces its own error finding.""" + required = frozenset({"validate.yml", "release.yml", "stale.yml", "drift-check.yml"}) + findings = _run(_snap(required=required, present=frozenset())) + assert len(findings) == 4, findings + assert all(f.severity == "error" for f in findings) + missing = {f.message.split("'")[1] for f in findings} + assert missing == required + + +def test_mcp_server_type_respected() -> None: + """mcp-server repos use their own required list (publish.yml not release.yml).""" + findings = _run(_snap( + repo_type="mcp-server", + required=frozenset({"drift-check.yml", "stale.yml", "publish.yml"}), + present=frozenset({"drift-check.yml", "publish.yml"}), + )) + # stale.yml missing -> one error + assert len(findings) == 1, findings + assert "stale.yml" in findings[0].message + + +def test_config_tier_merge_adds_requirements() -> None: + """DriftConfig.resolve merges required_workflows additively across tiers.""" + cfg_data = { + "globals": {"signal_policy": "same-major-minor", "required_workflows": ["validate.yml"]}, + "types": {"cursor-plugin": {"required_workflows": ["release.yml"]}}, + "repos": {"my-repo": {"required_workflows": ["drift-check.yml"]}}, + } + import json, tempfile, os + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, encoding="utf-8" + ) as fh: + json.dump(cfg_data, fh) + tmp = fh.name + try: + from drift_check.config import load_config + cfg = load_config(Path(tmp)) + resolved = cfg.resolve("my-repo", "cursor-plugin") + assert resolved.required_workflows == frozenset( + {"validate.yml", "release.yml", "drift-check.yml"} + ), resolved.required_workflows + finally: + os.unlink(tmp) + + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + passed = failed = 0 + for fn in tests: + try: + fn() + print(f" ok {fn.__name__}") + passed += 1 + except Exception as exc: + print(f"FAIL {fn.__name__}: {exc}") + failed += 1 + print(f"\n{passed} passed, {failed} failed") + sys.exit(1 if failed else 0)