Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.9.5
1.10.0
6 changes: 4 additions & 2 deletions scripts/drift_check/checks/__init__.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
"""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

__all__ = [
"VersionSignalCheck",
"BrokenRefsCheck",
"RequiredRefsCheck",
"RequiredWorkflowsCheck",
"StaleCountsCheck",
]
68 changes: 68 additions & 0 deletions scripts/drift_check/checks/required_workflows.py
Original file line number Diff line number Diff line change
@@ -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.<repo-type>.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
3 changes: 3 additions & 0 deletions scripts/drift_check/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from drift_check.checks import ( # type: ignore
BrokenRefsCheck,
RequiredRefsCheck,
RequiredWorkflowsCheck,
StaleCountsCheck,
VersionSignalCheck,
)
Expand All @@ -63,6 +64,7 @@
from .checks import (
BrokenRefsCheck,
RequiredRefsCheck,
RequiredWorkflowsCheck,
StaleCountsCheck,
VersionSignalCheck,
)
Expand Down Expand Up @@ -315,6 +317,7 @@ def _run_checks(snapshots: Sequence[RepoSnapshot]) -> List[Finding]:
VersionSignalCheck(),
BrokenRefsCheck(),
RequiredRefsCheck(),
RequiredWorkflowsCheck(),
StaleCountsCheck(),
)
for snap in snapshots:
Expand Down
15 changes: 15 additions & 0 deletions scripts/drift_check/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"skills",
"rules",
".cursor-plugin",
".github/workflows", # workflow presence for the required-workflows check
)


Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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),
)


Expand Down
23 changes: 22 additions & 1 deletion scripts/drift_check/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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, {})):
Expand All @@ -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),
)


Expand All @@ -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)
Expand Down
15 changes: 13 additions & 2 deletions standards/drift-checker.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading
Loading