diff --git a/VERSION b/VERSION index 266146b..9dbb0c0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.3 +1.7.0 \ No newline at end of file diff --git a/scripts/drift_check/__init__.py b/scripts/drift_check/__init__.py new file mode 100644 index 0000000..d188548 --- /dev/null +++ b/scripts/drift_check/__init__.py @@ -0,0 +1,7 @@ +"""Agent-file drift checker (Phase 2 Session A core library). + +Public surface is deliberately narrow: consumers should import from +submodules (``types``, ``signal``, ``semver``, ``config``, ``pragma``, +``snapshot``, ``checks.version_signal``, ``report.markdown``, +``report.json_out``, ``cli``). +""" diff --git a/scripts/drift_check/checks/__init__.py b/scripts/drift_check/checks/__init__.py new file mode 100644 index 0000000..cc27bc6 --- /dev/null +++ b/scripts/drift_check/checks/__init__.py @@ -0,0 +1,7 @@ +"""Registered checks live here. Session A ships ``version_signal`` only. + +Session B will add ``broken_refs``, ``required_refs``, ``stale_counts``. +""" +from .version_signal import VersionSignalCheck + +__all__ = ["VersionSignalCheck"] diff --git a/scripts/drift_check/checks/version_signal.py b/scripts/drift_check/checks/version_signal.py new file mode 100644 index 0000000..4a0da60 --- /dev/null +++ b/scripts/drift_check/checks/version_signal.py @@ -0,0 +1,144 @@ +"""The live version-signal check (Decision 1 + Q7 adjustment). + +Tier -> Finding severity mapping (this is the only place this mapping +lives; keep it here, not in ``semver.py``): + + exact_match -> no finding + patch_differs -> info + major_minor_differs -> error + tool_newer -> warn (per Q7 adjustment) + malformed (parsed) -> error (signal present but did not parse) + +Additional branches handled by this check (not tiers from semver): + + missing signal -> error + drift-ignore pragma -> info (skipped silently in non-verbose mode; + callers filter on severity) + config skip_checks -> no findings at all for this check +""" +from __future__ import annotations + +from typing import Iterable, List + +from ..semver import compare_policy +from ..signals import _classify_by_name +from ..types import Finding, RepoSnapshot, Severity + + +NAME = "version-signal" + + +class VersionSignalCheck: + """Implements the ``Check`` protocol from ``types.py``.""" + + name: str = NAME + + def run(self, snapshot: RepoSnapshot) -> Iterable[Finding]: + if NAME in snapshot.config.skip_checks: + return () + + out: List[Finding] = [] + for rel_path, file in snapshot.files.items(): + fmt = _classify_by_name(rel_path) + if fmt is None: + continue # not a checked file + + pragma = next( + (p for p in file.pragmas if p.check_name == NAME), None + ) + if pragma is not None: + out.append( + Finding( + repo=snapshot.slug, + file=rel_path, + check=NAME, + severity="info", + message=( + f"skipped by drift-ignore pragma" + + (f" (reason: {pragma.reason})" if pragma.reason else "") + ), + suggested_fix=None, + ) + ) + continue + + signal = file.signal + if signal is None: + out.append( + Finding( + repo=snapshot.slug, + file=rel_path, + check=NAME, + severity="error", + message=( + f"missing standards-version signal; expected " + f"{fmt} at the canonical position" + ), + suggested_fix=_suggested_fix(fmt, snapshot.meta_version.raw), + ) + ) + continue + + if signal.malformed or signal.version is None: + out.append( + Finding( + repo=snapshot.slug, + file=rel_path, + check=NAME, + severity="error", + message=( + f"malformed standards-version signal " + f"{signal.raw_value!r} at line {signal.line}" + ), + suggested_fix=_suggested_fix(fmt, snapshot.meta_version.raw), + ) + ) + continue + + tier = compare_policy(signal.version, snapshot.meta_version) + severity = _tier_to_severity(tier) + if severity is None: + continue # exact_match, silent + out.append( + Finding( + repo=snapshot.slug, + file=rel_path, + check=NAME, + severity=severity, + message=_tier_message(tier, signal.version, snapshot.meta_version), + suggested_fix=_suggested_fix(fmt, snapshot.meta_version.raw), + ) + ) + return out + + +def _tier_to_severity(tier: str): + """Return the Finding severity for a semver tier. None means silent.""" + mapping: dict[str, Severity] = { + "patch_differs": "info", + "major_minor_differs": "error", + "tool_newer": "warn", + "malformed": "error", + } + return mapping.get(tier) + + +def _tier_message(tier: str, tool_v, meta_v) -> str: + if tier == "patch_differs": + return f"patch-level drift: tool={tool_v}, meta={meta_v}" + if tier == "major_minor_differs": + return f"MAJOR.MINOR drift: tool={tool_v}, meta={meta_v}" + if tier == "tool_newer": + return f"tool signal ahead of meta: tool={tool_v}, meta={meta_v}" + if tier == "malformed": + return f"malformed tool version (cannot compare to meta={meta_v})" + return f"tier={tier}" + + +def _suggested_fix(fmt: str, target_version: str) -> str: + script = ( + "add_frontmatter.py" + if fmt == "yaml-frontmatter" + else "add_comment_marker.py" + ) + return f"run {script} {target_version}" diff --git a/scripts/drift_check/cli.py b/scripts/drift_check/cli.py new file mode 100644 index 0000000..a2b1046 --- /dev/null +++ b/scripts/drift_check/cli.py @@ -0,0 +1,302 @@ +"""CLI entrypoint. Implements the exit-code contract from the Phase 2 +acceptance criteria: + + 0 - no drift (no errors, no warnings) + 1 - drift found (at least one error OR warning) + 2 - tool error (config malformed, repo path missing, etc.) + +Invocation:: + + python scripts/drift_check/cli.py --local E:\\CFX-Developer-Tools + python scripts/drift_check/cli.py --local --format json + python scripts/drift_check/cli.py --local --fix + +``--fix`` delegates to the already-validated Phase 1 scripts under +``E:\\.TMHS-Tool-Ecosystem-Workspace\\phase1-script`` (discoverable via +``--fix-scripts`` for tests / alternate hosts). No git operations, no PRs. +""" +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path +from typing import List, Optional, Sequence + +# Allow both `python -m scripts.drift_check.cli` and +# `python scripts/drift_check/cli.py` invocations. +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from drift_check.checks import VersionSignalCheck # type: ignore + from drift_check.config import ConfigError, load_config # type: ignore + from drift_check.report import json_out, markdown # type: ignore + from drift_check.semver import parse_version # type: ignore + from drift_check.signals import _classify_by_name # type: ignore + from drift_check.snapshot import build_local_snapshot # type: ignore + from drift_check.types import Finding, RepoSnapshot, Version # type: ignore +else: + from .checks import VersionSignalCheck + from .config import ConfigError, load_config + from .report import json_out, markdown + from .semver import parse_version + from .signals import _classify_by_name + from .snapshot import build_local_snapshot + from .types import Finding, RepoSnapshot, Version + + +DEFAULT_PHASE1_SCRIPTS = Path(r"E:\.TMHS-Tool-Ecosystem-Workspace\phase1-script") + + +def _find_repo_root() -> Path: + """Walk up from this file to the repo that contains ``VERSION``.""" + here = Path(__file__).resolve() + for candidate in (here.parent, *here.parents): + if (candidate / "VERSION").is_file(): + return candidate + return here.parents[2] + + +def _read_meta_version(repo_root: Path) -> Version: + raw = (repo_root / "VERSION").read_text(encoding="utf-8").strip() + v = parse_version(raw) + if v is None: + raise SystemExit(f"meta-repo VERSION is not a valid semver: {raw!r}") + return v + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="drift_check", + description="Agent-file drift checker (Phase 2 Session A)", + ) + p.add_argument( + "--local", + action="append", + default=[], + metavar="PATH", + help="path to a local clone to check (repeatable)", + ) + p.add_argument( + "--format", + choices=("markdown", "json"), + default="markdown", + help="output format (default: markdown)", + ) + p.add_argument( + "--output", + default="-", + help="output path, or '-' for stdout (default: -)", + ) + p.add_argument( + "--verbose", + action="store_true", + help="include info-level findings in the report", + ) + p.add_argument( + "--fix", + action="store_true", + help="attempt in-place repair using Phase 1 scripts (local only)", + ) + p.add_argument( + "--fix-scripts", + type=Path, + default=DEFAULT_PHASE1_SCRIPTS, + help="directory containing add_frontmatter.py / add_comment_marker.py", + ) + p.add_argument( + "--config", + type=Path, + default=None, + help="path to drift-checker.config.json (defaults to standards/drift-checker.config.json)", + ) + p.add_argument( + "--meta-commit", + default="HEAD", + help="meta-repo commit SHA for the snapshot record (default: HEAD)", + ) + return p + + +def _build_snapshots( + local_paths: Sequence[Path], + meta_version: Version, + meta_commit: str, + config_path: Optional[Path], +) -> List[RepoSnapshot]: + try: + cfg = load_config(config_path) + except ConfigError as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(2) + + snapshots: List[RepoSnapshot] = [] + for path in local_paths: + if not path.is_dir(): + print(f"error: --local path is not a directory: {path}", file=sys.stderr) + raise SystemExit(2) + snapshots.append( + build_local_snapshot( + repo_path=path, + meta_version=meta_version, + meta_commit=meta_commit, + config=cfg, + ) + ) + return snapshots + + +def _run_checks(snapshots: Sequence[RepoSnapshot]) -> List[Finding]: + findings: List[Finding] = [] + for snap in snapshots: + for check in (VersionSignalCheck(),): + findings.extend(check.run(snap)) + return findings + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if not args.local: + print("error: at least one --local is required", file=sys.stderr) + return 2 + + local_paths = [Path(p).resolve() for p in args.local] + + repo_root = _find_repo_root() + try: + meta_version = _read_meta_version(repo_root) + except SystemExit: + raise + except Exception as exc: # pragma: no cover - defensive + print(f"error: cannot read VERSION: {exc}", file=sys.stderr) + return 2 + + try: + snapshots = _build_snapshots( + local_paths=local_paths, + meta_version=meta_version, + meta_commit=args.meta_commit, + config_path=args.config, + ) + except SystemExit as exc: + return int(exc.code) if exc.code is not None else 2 + + findings = _run_checks(snapshots) + + if args.fix: + touched = _apply_fixes_live( + snapshots=snapshots, + local_paths=local_paths, + findings=findings, + scripts_dir=args.fix_scripts, + meta_version=meta_version, + ) + if touched: + # Rebuild snapshots & re-run checks so the report reflects the + # post-fix state. + try: + snapshots = _build_snapshots( + local_paths=local_paths, + meta_version=meta_version, + meta_commit=args.meta_commit, + config_path=args.config, + ) + except SystemExit as exc: + return int(exc.code) if exc.code is not None else 2 + findings = _run_checks(snapshots) + + out_text = _render(snapshots, findings, args.format, args.verbose) + + if args.output == "-": + sys.stdout.write(out_text) + else: + Path(args.output).write_text(out_text, encoding="utf-8") + + return _exit_code(findings) + + +def _render( + snapshots: Sequence[RepoSnapshot], + findings: Sequence[Finding], + fmt: str, + verbose: bool, +) -> str: + if fmt == "json": + return json_out.render(snapshots, findings, verbose=verbose) + return markdown.render(snapshots, findings, verbose=verbose) + + +def _exit_code(findings: Sequence[Finding]) -> int: + for f in findings: + if f.severity in ("error", "warn"): + return 1 + return 0 + + +def _apply_fixes_live( + snapshots: Sequence[RepoSnapshot], + local_paths: Sequence[Path], + findings: Sequence[Finding], + scripts_dir: Path, + meta_version: Version, +) -> int: + """Run the Phase 1 scripts against each fixable Finding. + + Fixable = ``version-signal`` error findings (missing or malformed + signal, or MAJOR.MINOR/patch drift). Pragma-skipped and tool_newer + findings are left alone — those require human review. + """ + fm = scripts_dir / "add_frontmatter.py" + cm = scripts_dir / "add_comment_marker.py" + if not fm.is_file() or not cm.is_file(): + print( + f"error: --fix requires Phase 1 scripts in {scripts_dir}", + file=sys.stderr, + ) + raise SystemExit(2) + + slug_to_root: dict[str, Path] = {} + for path, snap in zip(local_paths, snapshots): + slug_to_root[snap.slug] = path + + touched = 0 + for f in findings: + if f.check != "version-signal": + continue + if f.severity not in ("error", "warn", "info"): + continue + if f.file is None: + continue + if f.severity == "info": + # Could be patch_differs or pragma-skip. Only patch_differs is + # fixable; pragma-skip messages start with "skipped". + if f.message.startswith("skipped"): + continue + if f.severity == "warn": + # tool_newer: a human should look. Skip. + continue + + root = slug_to_root.get(f.repo) + if root is None: + continue + abs_path = root / f.file + fmt = _classify_by_name(f.file) + script = fm if fmt == "yaml-frontmatter" else cm + result = subprocess.run( + [sys.executable, str(script), str(abs_path), str(meta_version)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + touched += 1 + else: + print( + f"warning: fix failed for {abs_path}: {result.stderr.strip()}", + file=sys.stderr, + ) + return touched + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/drift_check/config.py b/scripts/drift_check/config.py new file mode 100644 index 0000000..fd991bd --- /dev/null +++ b/scripts/drift_check/config.py @@ -0,0 +1,58 @@ +"""Load ``standards/drift-checker.config.json`` (Decision 6). + +The config has three tiers (globals, types, repos). ``DriftConfig.resolve`` +in ``types.py`` merges them for a given slug/type; this module is just the +loader. + +Missing file is not an error — the whole point of the config is to +override defaults, so a missing config yields a permissive empty +``DriftConfig`` with ``signal_policy=same-major-minor``. Malformed JSON +IS an error; the caller (CLI) surfaces that as exit code 2. +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Mapping + +from .types import DriftConfig + + +DEFAULT_CONFIG_PATH = Path("standards/drift-checker.config.json") + + +class ConfigError(Exception): + """Raised when the config file is present but malformed.""" + + +def load_config(path: Path | None = None) -> DriftConfig: + """Load a DriftConfig from disk. Missing file -> defaults.""" + p = path if path is not None else DEFAULT_CONFIG_PATH + if not p.is_file(): + return DriftConfig( + repos={}, + types={}, + globals={"signal_policy": "same-major-minor"}, + ) + try: + data = json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ConfigError(f"malformed JSON in {p}: {exc}") from exc + + if not isinstance(data, dict): + raise ConfigError(f"expected object at root of {p}, got {type(data).__name__}") + + repos = _require_mapping(data.get("repos", {}), "repos", p) + types_ = _require_mapping(data.get("types", {}), "types", p) + globals_ = _require_mapping(data.get("globals", {}), "globals", p) + + if "signal_policy" not in globals_: + globals_ = {**globals_, "signal_policy": "same-major-minor"} + + return DriftConfig(repos=repos, types=types_, globals=globals_) + + +def _require_mapping(value: object, key: str, path: Path) -> Mapping[str, object]: + if not isinstance(value, dict): + raise ConfigError(f"{path}: '{key}' must be an object, got {type(value).__name__}") + return value diff --git a/scripts/drift_check/pragma.py b/scripts/drift_check/pragma.py new file mode 100644 index 0000000..77bb2c9 --- /dev/null +++ b/scripts/drift_check/pragma.py @@ -0,0 +1,214 @@ +"""Parse ``drift-ignore`` directives (Decision 6, Q4 resolution). + +Two locations, three accepted shapes: + +* prose files (AGENTS.md, CLAUDE.md): free-floating HTML comment anywhere + in the file. + + ```` + ```` + ```` (comma list) + +* metadata files (SKILL.md, .mdc): a ``drift-ignore`` field *inside* the + first ``---``-fenced YAML block. Two accepted YAML shapes: + + short form (array of strings):: + + drift-ignore: [version-signal, required-refs] + + long form (list of objects):: + + drift-ignore: + - check: version-signal + reason: tracking 1.5 for compatibility + - check: required-refs + +Malformed pragmas degrade gracefully: we skip the offending directive and +return whatever we could parse. The parser never raises. + +We intentionally avoid a full YAML dependency. The shapes we accept are +constrained enough that a small hand-written block parser is cheaper to +audit than pulling in pyyaml. +""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import List, Optional, Sequence + +from .signals import _classify_by_name # reuse path -> format classifier +from .types import Pragma, PragmaFormat + + +_HTML_PRAGMA_RE = re.compile( + rb"", re.DOTALL +) +_REASON_RE = re.compile(r'reason\s*=\s*"([^"]*)"') +_YAML_FENCE_RE = re.compile(rb"^---\s*$") + + +def extract_pragmas(path: Path, content: bytes) -> Sequence[Pragma]: + """Return all drift-ignore pragmas in the file. Never raises. + + File format is inferred from path: prose files use HTML-comment + pragmas; metadata files use a YAML field inside their frontmatter + block. Unknown file types return (). + """ + fmt = _classify_by_name(path) + if fmt == "html-comment": + return _extract_html_pragmas(content) + if fmt == "yaml-frontmatter": + return _extract_yaml_pragmas(content) + return () + + +def _extract_html_pragmas(content: bytes) -> Sequence[Pragma]: + out: List[Pragma] = [] + for m in _HTML_PRAGMA_RE.finditer(content): + line = content.count(b"\n", 0, m.start()) + 1 + body = m.group("body").decode("utf-8", errors="replace") + for p in _parse_html_pragma_body(body, line): + out.append(p) + return tuple(out) + + +def _parse_html_pragma_body(body: str, line: int) -> List[Pragma]: + """Body is the text between ``drift-ignore:`` and ``-->``. + + Accepted shapes: + + * ``version-signal`` (bare name) + * ``version-signal reason="..."`` + * ``[version-signal, required-refs]`` (no reasons when using list form) + * ``version-signal, required-refs`` (comma list, no brackets) + """ + reason_match = _REASON_RE.search(body) + reason = reason_match.group(1) if reason_match else None + # Strip the reason= clause so we are left with just the check names. + if reason_match: + body = body[: reason_match.start()] + body[reason_match.end():] + body = body.strip().strip("[]").strip() + if not body: + return [] + names = [n.strip() for n in body.split(",") if n.strip()] + return [ + Pragma( + check_name=name, + reason=reason if len(names) == 1 else None, + format="html-comment", + line=line, + ) + for name in names + if _looks_like_check_name(name) + ] + + +def _looks_like_check_name(s: str) -> bool: + """Cheap sanity filter so a garbled pragma body does not produce + bogus entries like ``Pragma(check_name='reason="x')``. Check names in + this ecosystem are short kebab-case identifiers.""" + return bool(re.fullmatch(r"[a-z][a-z0-9-]*", s)) + + +def _extract_yaml_pragmas(content: bytes) -> Sequence[Pragma]: + """Parse the ``drift-ignore:`` field inside the first YAML frontmatter + block. Accepts short and long forms. Degrades to ``()`` on anything + unexpected.""" + lines = content.replace(b"\r\n", b"\n").split(b"\n") + if not lines or not _YAML_FENCE_RE.match(lines[0]): + return () + close_idx = None + for i in range(1, len(lines)): + if _YAML_FENCE_RE.match(lines[i]): + close_idx = i + break + if close_idx is None: + close_idx = len(lines) + + i = 1 + while i < close_idx: + raw = lines[i].decode("utf-8", errors="replace") + stripped = raw.strip() + if stripped.startswith("drift-ignore:"): + return _parse_yaml_pragma_field(lines, close_idx, i) + i += 1 + return () + + +def _parse_yaml_pragma_field( + lines: List[bytes], close_idx: int, start: int +) -> Sequence[Pragma]: + first = lines[start].decode("utf-8", errors="replace") + after_colon = first.split(":", 1)[1].strip() + pragma_line = start + 1 + + # Short form: drift-ignore: [a, b] + if after_colon.startswith("[") and after_colon.endswith("]"): + inner = after_colon[1:-1] + names = [n.strip().strip("'\"") for n in inner.split(",") if n.strip()] + return tuple( + Pragma(check_name=n, reason=None, format="yaml-short", line=pragma_line) + for n in names + if _looks_like_check_name(n) + ) + + # Short form inline bare (no brackets): uncommon but tolerated. + if after_colon and not after_colon.startswith("-"): + names = [n.strip().strip("'\"") for n in after_colon.split(",") if n.strip()] + return tuple( + Pragma(check_name=n, reason=None, format="yaml-short", line=pragma_line) + for n in names + if _looks_like_check_name(n) + ) + + # Long form: consume indented ``- check: name`` / ``reason: ...`` blocks. + out: List[Pragma] = [] + i = start + 1 + current_check: Optional[str] = None + current_reason: Optional[str] = None + current_line = pragma_line + while i < close_idx: + raw = lines[i].decode("utf-8", errors="replace") + # A non-indented line ends the block. + if raw and not raw.startswith((" ", "\t", "-")): + # Could be a dash at col 0 for list items; handled above. + if not raw.lstrip().startswith("-"): + break + stripped = raw.strip() + if stripped.startswith("- "): + if current_check is not None: + out.append( + Pragma( + check_name=current_check, + reason=current_reason, + format="yaml-long", + line=current_line, + ) + ) + current_check = None + current_reason = None + current_line = i + 1 + item = stripped[2:].strip() + if item.startswith("check:"): + current_check = item.split(":", 1)[1].strip().strip("'\"") + elif stripped.startswith("check:"): + current_check = stripped.split(":", 1)[1].strip().strip("'\"") + elif stripped.startswith("reason:"): + current_reason = stripped.split(":", 1)[1].strip().strip("'\"") + elif not stripped: + pass + else: + break + i += 1 + + if current_check is not None: + out.append( + Pragma( + check_name=current_check, + reason=current_reason, + format="yaml-long", + line=current_line, + ) + ) + + return tuple(p for p in out if _looks_like_check_name(p.check_name)) diff --git a/scripts/drift_check/report/__init__.py b/scripts/drift_check/report/__init__.py new file mode 100644 index 0000000..af43e1d --- /dev/null +++ b/scripts/drift_check/report/__init__.py @@ -0,0 +1,7 @@ +"""Renderers. Consume ``Iterable[Finding]`` and know nothing about the +internals of specific checks (Decision 7 boundary). + +Session A ships ``markdown`` and ``json_out``. Session C adds +``gh_summary`` (writes to ``$GITHUB_STEP_SUMMARY``) and ``issue`` (upsert +sticky issue). +""" diff --git a/scripts/drift_check/report/json_out.py b/scripts/drift_check/report/json_out.py new file mode 100644 index 0000000..e8654f8 --- /dev/null +++ b/scripts/drift_check/report/json_out.py @@ -0,0 +1,78 @@ +"""Machine-readable JSON renderer. + +Output shape:: + + { + "meta_version": "1.6.3", + "checked_at": "2026-04-24T15:30:00Z", + "repos": [ + {"slug": "...", "repo_type": "...", "findings": [...]}, + ... + ], + "summary": {"errors": 1, "warnings": 2, "infos": 3} + } + +``checked_at`` is UTC ISO-8601 with second precision, suffixed ``Z``. +""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Iterable, Sequence + +from ..types import Finding, RepoSnapshot + + +def render( + snapshots: Sequence[RepoSnapshot], + findings: Iterable[Finding], + *, + verbose: bool = False, + now: datetime | None = None, +) -> str: + findings_list = list(findings) + if not verbose: + findings_list = [f for f in findings_list if f.severity != "info"] + + checked_at = (now or datetime.now(timezone.utc)).strftime("%Y-%m-%dT%H:%M:%SZ") + meta_version = str(snapshots[0].meta_version) if snapshots else "" + + by_repo: dict[str, list[Finding]] = {s.slug: [] for s in snapshots} + for f in findings_list: + by_repo.setdefault(f.repo, []).append(f) + + repos_out = [] + for snap in snapshots: + repos_out.append( + { + "slug": snap.slug, + "repo_type": snap.repo_type, + "files_checked": len(snap.files), + "findings": [_finding_to_dict(f) for f in by_repo.get(snap.slug, [])], + } + ) + + summary = { + "errors": sum(1 for f in findings_list if f.severity == "error"), + "warnings": sum(1 for f in findings_list if f.severity == "warn"), + "infos": sum(1 for f in findings_list if f.severity == "info"), + } + + payload = { + "meta_version": meta_version, + "checked_at": checked_at, + "repos": repos_out, + "summary": summary, + } + return json.dumps(payload, indent=2, sort_keys=False) + "\n" + + +def _finding_to_dict(f: Finding) -> dict: + return { + "repo": f.repo, + "file": str(f.file).replace("\\", "/") if f.file else None, + "check": f.check, + "severity": f.severity, + "message": f.message, + "suggested_fix": f.suggested_fix, + } diff --git a/scripts/drift_check/report/markdown.py b/scripts/drift_check/report/markdown.py new file mode 100644 index 0000000..ff10f07 --- /dev/null +++ b/scripts/drift_check/report/markdown.py @@ -0,0 +1,75 @@ +"""Human-friendly markdown renderer. + +Output shape (matches the example in the design-doc handoff brief):: + + # Drift report + + Meta-repo version: 1.6.3 + Checked: 8 repos, 248 files + + ## CFX-Developer-Tools (0 errors, 0 warnings) + Clean. + + ## Example-With-Drift (1 error, 2 warnings) + | File | Check | Severity | Message | + | ---- | ----- | -------- | ------- | + | ... | ... | ... | ... | + + Summary: 1 error, 2 warnings, 3 infos across 8 repos. +""" +from __future__ import annotations + +from typing import Iterable, List, Sequence + +from ..types import Finding, RepoSnapshot + + +def render( + snapshots: Sequence[RepoSnapshot], + findings: Iterable[Finding], + *, + verbose: bool = False, +) -> str: + findings_list = list(findings) + if not verbose: + findings_list = [f for f in findings_list if f.severity != "info"] + + lines: List[str] = [] + meta_version = snapshots[0].meta_version if snapshots else None + lines.append("# Drift report") + lines.append("") + if meta_version is not None: + lines.append(f"Meta-repo version: {meta_version}") + total_files = sum(len(s.files) for s in snapshots) + lines.append(f"Checked: {len(snapshots)} repos, {total_files} files") + lines.append("") + + by_repo: dict[str, list[Finding]] = {s.slug: [] for s in snapshots} + for f in findings_list: + by_repo.setdefault(f.repo, []).append(f) + + for snap in snapshots: + repo_findings = by_repo.get(snap.slug, []) + errs = sum(1 for f in repo_findings if f.severity == "error") + warns = sum(1 for f in repo_findings if f.severity == "warn") + lines.append(f"## {snap.slug} ({errs} errors, {warns} warnings)") + if not repo_findings: + lines.append("Clean.") + lines.append("") + continue + lines.append("| File | Check | Severity | Message |") + lines.append("| ---- | ----- | -------- | ------- |") + for f in repo_findings: + file_label = str(f.file).replace("\\", "/") if f.file else "-" + msg = f.message.replace("|", "\\|") + lines.append(f"| {file_label} | {f.check} | {f.severity} | {msg} |") + lines.append("") + + total_err = sum(1 for f in findings_list if f.severity == "error") + total_warn = sum(1 for f in findings_list if f.severity == "warn") + total_info = sum(1 for f in findings_list if f.severity == "info") + lines.append( + f"Summary: {total_err} errors, {total_warn} warnings, " + f"{total_info} infos across {len(snapshots)} repos." + ) + return "\n".join(lines) + "\n" diff --git a/scripts/drift_check/semver.py b/scripts/drift_check/semver.py new file mode 100644 index 0000000..f155ee2 --- /dev/null +++ b/scripts/drift_check/semver.py @@ -0,0 +1,73 @@ +"""Version parsing and policy-tier classification. + +Decision 1 policy: ``same-major-minor`` — patch bumps are ignored, anything +above patch is drift. The Q7 adjustment makes ``tool_newer`` a ``warn`` +rather than an ``info`` — see ``compare_policy``. + +This module does NOT map tiers to Finding severity. That is the caller's +job (see ``checks/version_signal.py``). Keeping the mapping out of here +means a future policy (say, ``same-major``) does not have to know about +Finding semantics. +""" +from __future__ import annotations + +import re +from typing import Literal, Optional + +from .types import Version + + +PolicyTier = Literal[ + "exact_match", + "patch_differs", + "major_minor_differs", + "tool_newer", + "malformed", +] + + +_SEMVER_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$") + + +def parse_version(s: str) -> Optional[Version]: + """Parse a MAJOR.MINOR.PATCH string. Returns None if the input does not + match. Accepts an optional leading ``v`` and ignores SemVer prerelease / + build metadata suffixes (we do not use them in this ecosystem).""" + if not isinstance(s, str): + return None + m = _SEMVER_RE.match(s.strip()) + if not m: + return None + return Version( + major=int(m.group(1)), + minor=int(m.group(2)), + patch=int(m.group(3)), + raw=s, + parsed=True, + ) + + +def compare_policy( + tool_version: Optional[Version], meta_version: Version +) -> PolicyTier: + """Classify the relationship between a tool-repo signal and the + meta-repo VERSION under the ``same-major-minor`` policy. + + Order of the tests matters: a malformed tool version short-circuits + before any numeric comparison. + """ + if tool_version is None or not tool_version.parsed: + return "malformed" + + if tool_version.as_tuple() == meta_version.as_tuple(): + return "exact_match" + + if tool_version.as_tuple() > meta_version.as_tuple(): + # Tool ahead of meta on any component => warn per Q7. + return "tool_newer" + + if (tool_version.major, tool_version.minor) == (meta_version.major, meta_version.minor): + # Same MAJOR.MINOR, tool patch is behind meta patch. + return "patch_differs" + + return "major_minor_differs" diff --git a/scripts/drift_check/signals.py b/scripts/drift_check/signals.py new file mode 100644 index 0000000..e5c9e98 --- /dev/null +++ b/scripts/drift_check/signals.py @@ -0,0 +1,145 @@ +"""Position-strict detection of ``standards-version`` signals (Q6). + +Two file formats, two position rules: + +* prose files (``AGENTS.md``, ``CLAUDE.md``) — first non-BOM line must be + an HTML comment of the form ````. + Internal whitespace is flexible; canonical form has single spaces. +* metadata files (``SKILL.md``, ``.mdc``) — the ``standards-version`` key + must live inside the first ``---``-fenced YAML block. + +Return semantics: + +* ``None`` — no signal at the strict position. The file may still contain + the literal string ``standards-version`` somewhere else, but we do not + consider that a signal. That distinction is important for the check: + "no signal" is error, "signal found but malformed" is also error but + with a different message and a location. +* ``SignalResult(malformed=True, version=None)`` — something at the right + position that looks like our marker but does not parse as a version. + +We deliberately do not import ``yaml`` here. The first-field ``key: value`` +parse we need is too constrained to justify a dependency; a proper YAML +loader is invoked in ``pragma.py`` only when the field shape (array vs. +list-of-objects) demands it. +""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import Optional + +from .semver import parse_version +from .types import SignalFormat, SignalResult + + +BOM = b"\xef\xbb\xbf" +_HTML_MARKER_RE = re.compile( + rb"^\s*$" +) +_YAML_FENCE_RE = re.compile(rb"^---\s*$") +_YAML_KEY_RE = re.compile( + rb"^standards-version\s*:\s*(.+?)\s*$" +) + + +def _classify_by_name(path: Path) -> Optional[SignalFormat]: + """Map a path to its expected signal format. Returns None for files we + do not check.""" + name = path.name.lower() + if name in ("agents.md", "claude.md"): + return "html-comment" + if name == "skill.md": + return "yaml-frontmatter" + if path.suffix.lower() == ".mdc": + return "yaml-frontmatter" + return None + + +def _strip_bom(data: bytes) -> bytes: + return data[len(BOM):] if data.startswith(BOM) else data + + +def _split_lines_keep_eol(data: bytes) -> list[bytes]: + """Split on LF, preserving any trailing CR from a CRLF pair in the + returned line by stripping it in-place (we do not need to round-trip; + signal detection only cares about content).""" + # Normalize CRLF to LF for line-matching purposes without mutating the + # caller's bytes. + normalized = data.replace(b"\r\n", b"\n") + parts = normalized.split(b"\n") + # split() produces a trailing empty element when data ends with \n; + # preserve the real line count. + return parts + + +def detect_signal(path: Path, content: bytes) -> Optional[SignalResult]: + """Detect a ``standards-version`` signal at the strict position for the + file's format. Returns None when no signal is found at the expected + location (including when the marker is present elsewhere in the file).""" + fmt = _classify_by_name(path) + if fmt is None: + return None + + body = _strip_bom(content) + if not body: + return None + + if fmt == "html-comment": + return _detect_html_comment(body) + return _detect_yaml_frontmatter(body) + + +def _detect_html_comment(body: bytes) -> Optional[SignalResult]: + lines = _split_lines_keep_eol(body) + if not lines: + return None + first = lines[0].rstrip(b"\r") + m = _HTML_MARKER_RE.match(first) + if not m: + return None + raw_value = m.group(1).decode("utf-8", errors="replace") + version = parse_version(raw_value) + return SignalResult( + version=version, + format="html-comment", + line=1, + raw_value=raw_value, + malformed=version is None, + ) + + +def _detect_yaml_frontmatter(body: bytes) -> Optional[SignalResult]: + lines = _split_lines_keep_eol(body) + if not lines: + return None + # First line must be the opening fence. + if not _YAML_FENCE_RE.match(lines[0].rstrip(b"\r")): + return None + # Find the closing fence on or after line 2. + close_idx: Optional[int] = None + for i in range(1, len(lines)): + if _YAML_FENCE_RE.match(lines[i].rstrip(b"\r")): + close_idx = i + break + if close_idx is None: + # Unclosed frontmatter — still scan; treat up to EOF as the block. + close_idx = len(lines) + for i in range(1, close_idx): + line = lines[i].rstrip(b"\r") + m = _YAML_KEY_RE.match(line) + if not m: + continue + raw_value = m.group(1).decode("utf-8", errors="replace").strip() + # Strip surrounding quotes if a value was quoted in YAML. + if len(raw_value) >= 2 and raw_value[0] == raw_value[-1] and raw_value[0] in ("'", '"'): + raw_value = raw_value[1:-1] + version = parse_version(raw_value) + return SignalResult( + version=version, + format="yaml-frontmatter", + line=i + 1, + raw_value=raw_value, + malformed=version is None, + ) + return None diff --git a/scripts/drift_check/snapshot.py b/scripts/drift_check/snapshot.py new file mode 100644 index 0000000..c0d33bc --- /dev/null +++ b/scripts/drift_check/snapshot.py @@ -0,0 +1,125 @@ +"""Build a ``RepoSnapshot`` from a local clone path. + +Session A is local-clone only. Sparse-checkout / ``gh api`` remote mode is +Session C. The function signature here is already shaped so that a remote +mode can be a sibling builder (``build_sparse_snapshot``) producing the +same type. + +File discovery is restricted to the four agent-file shapes we care about: + +* ``AGENTS.md`` at repo root (optional) +* ``CLAUDE.md`` at repo root (optional) +* every ``skills//SKILL.md`` +* every ``rules/*.mdc`` + +Plus ``.cursor-plugin/plugin.json`` for repo-type detection (not parsed +into a FileSnapshot since it has no signal). +""" +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Dict, Optional + +from .pragma import extract_pragmas +from .signals import detect_signal +from .types import ( + DriftConfig, + FileSnapshot, + RepoSnapshot, + RepoType, + Version, +) + + +def _detect_repo_type(repo_path: Path) -> RepoType: + """Per the design doc's detection rules: + + * ``.cursor-plugin/plugin.json`` present -> ``cursor-plugin`` + * no skills/ or rules/ directories but CLAUDE.md present -> ``mcp-server`` + * otherwise -> ``unknown`` + """ + if (repo_path / ".cursor-plugin" / "plugin.json").is_file(): + return "cursor-plugin" + has_skills = (repo_path / "skills").is_dir() + has_rules = (repo_path / "rules").is_dir() + has_claude = (repo_path / "CLAUDE.md").is_file() + if not has_skills and not has_rules and has_claude: + return "mcp-server" + return "unknown" + + +def _collect_paths(repo_path: Path) -> list[Path]: + out: list[Path] = [] + for name in ("AGENTS.md", "CLAUDE.md"): + p = repo_path / name + if p.is_file(): + out.append(p) + skills_dir = repo_path / "skills" + if skills_dir.is_dir(): + for skill in sorted(skills_dir.iterdir()): + if not skill.is_dir(): + continue + sk = skill / "SKILL.md" + if sk.is_file(): + out.append(sk) + rules_dir = repo_path / "rules" + if rules_dir.is_dir(): + for rule in sorted(rules_dir.iterdir()): + if rule.is_file() and rule.suffix.lower() == ".mdc": + out.append(rule) + return out + + +def build_local_snapshot( + repo_path: Path, + meta_version: Version, + meta_commit: str, + config: DriftConfig, + slug: Optional[str] = None, + warn_stream=sys.stderr, +) -> RepoSnapshot: + """Construct a RepoSnapshot by walking the local clone tree. + + ``slug`` defaults to ``repo_path.name`` if not supplied. ``warn_stream`` + is an injection seam for tests that want to capture warnings. + """ + repo_path = repo_path.resolve() + if not repo_path.is_dir(): + raise FileNotFoundError(f"repo path is not a directory: {repo_path}") + + resolved_slug = slug or repo_path.name + repo_type = _detect_repo_type(repo_path) + + if repo_type == "unknown": + warn_stream.write( + f"warning: repo {resolved_slug} at {repo_path} has no " + f"cursor-plugin manifest and does not match the mcp-server " + f"shape; classifying as 'unknown'.\n" + ) + + files: Dict[Path, FileSnapshot] = {} + for path in _collect_paths(repo_path): + try: + content = path.read_bytes() + except OSError as exc: + warn_stream.write(f"warning: could not read {path}: {exc}\n") + continue + rel = path.relative_to(repo_path) + files[rel] = FileSnapshot( + path=rel, + content=content, + signal=detect_signal(rel, content), + pragmas=extract_pragmas(rel, content), + ) + + repo_config = config.resolve(resolved_slug, repo_type) + + return RepoSnapshot( + slug=resolved_slug, + repo_type=repo_type, + files=files, + meta_version=meta_version, + meta_commit=meta_commit, + config=repo_config, + ) diff --git a/scripts/drift_check/types.py b/scripts/drift_check/types.py new file mode 100644 index 0000000..ff421c4 --- /dev/null +++ b/scripts/drift_check/types.py @@ -0,0 +1,160 @@ +"""Type contracts for the drift checker (Decision 7 from phase2-design.md). + +Everything here is a frozen dataclass or a Protocol. No logic. This is the +boundary the rest of Session A and every future ``Check`` (Phase 3) builds +against. Changing anything in this file is a breaking change for plugin +authors; treat it like an API. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import ( + Iterable, + Literal, + Mapping, + Optional, + Protocol, + Sequence, + Tuple, + runtime_checkable, +) + + +Severity = Literal["error", "warn", "info"] +RepoType = Literal["cursor-plugin", "mcp-server", "unknown"] +SignalFormat = Literal["html-comment", "yaml-frontmatter"] +PragmaFormat = Literal["html-comment", "yaml-short", "yaml-long"] + + +@dataclass(frozen=True) +class Version: + """Parsed MAJOR.MINOR.PATCH. ``raw`` is the original string (may include + leading ``v``). ``parsed`` is True only when all three components were + valid non-negative integers. + """ + + major: int + minor: int + patch: int + raw: str + parsed: bool = True + + def as_tuple(self) -> Tuple[int, int, int]: + return (self.major, self.minor, self.patch) + + def __str__(self) -> str: + return f"{self.major}.{self.minor}.{self.patch}" + + +@dataclass(frozen=True) +class SignalResult: + """A standards-version marker located in a file. + + ``version`` is None when the marker was present at the correct position + but did not parse (malformed). The ``format`` and position fields are + populated even for malformed signals, so the caller can report *where* + the problem lives. + """ + + version: Optional[Version] + format: SignalFormat + line: int + raw_value: str + malformed: bool = False + + +@dataclass(frozen=True) +class Pragma: + """A ``drift-ignore`` directive found in a file.""" + + check_name: str + reason: Optional[str] + format: PragmaFormat + line: int + + +@dataclass(frozen=True) +class FileSnapshot: + path: Path + content: bytes + signal: Optional[SignalResult] + pragmas: Sequence[Pragma] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class RepoConfig: + """Resolved per-repo config view (globals merged with type then repo). + + ``skip_checks`` is the union of all three tiers' skip lists. Other + fields come from the effective ``globals`` tier. + """ + + slug: str + repo_type: RepoType + skip_checks: frozenset[str] = frozenset() + signal_policy: str = "same-major-minor" + + +@dataclass(frozen=True) +class DriftConfig: + """The full loaded drift-checker config. Use ``resolve()`` to get the + effective view for one repo. Kept separate from RepoConfig so one load + can serve N repos. + """ + + repos: Mapping[str, Mapping[str, object]] = field(default_factory=dict) + types: Mapping[str, Mapping[str, object]] = field(default_factory=dict) + globals: Mapping[str, object] = field(default_factory=dict) + + def resolve(self, slug: str, repo_type: RepoType) -> RepoConfig: + """Merge globals -> type -> repo. Later layers override scalars and + extend ``skip_checks``.""" + skip: 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, {})): + if not isinstance(tier, Mapping): + continue + tier_skips = tier.get("skip_checks", []) + if isinstance(tier_skips, list): + skip.update(str(x) for x in tier_skips) + if "signal_policy" in tier: + signal_policy = str(tier["signal_policy"]) + + return RepoConfig( + slug=slug, + repo_type=repo_type, + skip_checks=frozenset(skip), + signal_policy=signal_policy, + ) + + +@dataclass(frozen=True) +class RepoSnapshot: + slug: str + repo_type: RepoType + files: Mapping[Path, FileSnapshot] + meta_version: Version + meta_commit: str + config: RepoConfig + + +@dataclass(frozen=True) +class Finding: + repo: str + file: Optional[Path] + check: str + severity: Severity + message: str + suggested_fix: Optional[str] = None + + +@runtime_checkable +class Check(Protocol): + """Every check registers a stable ``name`` (used in pragmas and skip + lists) and yields Findings from ``run``. Must be side-effect free.""" + + name: str + + def run(self, snapshot: RepoSnapshot) -> Iterable[Finding]: ... diff --git a/standards/drift-checker.config.json b/standards/drift-checker.config.json new file mode 100644 index 0000000..ab93d2c --- /dev/null +++ b/standards/drift-checker.config.json @@ -0,0 +1,15 @@ +{ + "repos": { + "steam-mcp": { + "skip_checks": ["required-refs", "stale-counts"] + } + }, + "types": { + "mcp-server": { + "skip_checks": [] + } + }, + "globals": { + "signal_policy": "same-major-minor" + } +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d358c48 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +"""Shared pytest configuration: add the repo root to sys.path so tests can +``import scripts.drift_check`` without an install step.""" +from __future__ import annotations + +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +FIXTURES = REPO_ROOT / "tests" / "fixtures" / "drift_check" diff --git a/tests/fixtures/drift_check/broken_repo/.cursor-plugin/plugin.json b/tests/fixtures/drift_check/broken_repo/.cursor-plugin/plugin.json new file mode 100644 index 0000000..12b1e98 --- /dev/null +++ b/tests/fixtures/drift_check/broken_repo/.cursor-plugin/plugin.json @@ -0,0 +1,4 @@ +{ + "name": "broken-repo", + "version": "0.1.0" +} diff --git a/tests/fixtures/drift_check/broken_repo/AGENTS.md b/tests/fixtures/drift_check/broken_repo/AGENTS.md new file mode 100644 index 0000000..8f38e00 --- /dev/null +++ b/tests/fixtures/drift_check/broken_repo/AGENTS.md @@ -0,0 +1,3 @@ +# AGENTS.md + +No signal at all. diff --git a/tests/fixtures/drift_check/broken_repo/CLAUDE.md b/tests/fixtures/drift_check/broken_repo/CLAUDE.md new file mode 100644 index 0000000..5809e32 --- /dev/null +++ b/tests/fixtures/drift_check/broken_repo/CLAUDE.md @@ -0,0 +1,5 @@ + + +# CLAUDE.md + +Malformed signal. diff --git a/tests/fixtures/drift_check/broken_repo/rules/wrongpos.mdc b/tests/fixtures/drift_check/broken_repo/rules/wrongpos.mdc new file mode 100644 index 0000000..2fb57b2 --- /dev/null +++ b/tests/fixtures/drift_check/broken_repo/rules/wrongpos.mdc @@ -0,0 +1,5 @@ +# Wrong position rule + +Body-only. No frontmatter. + +standards-version: 1.6.3 diff --git a/tests/fixtures/drift_check/broken_repo/skills/malformed/SKILL.md b/tests/fixtures/drift_check/broken_repo/skills/malformed/SKILL.md new file mode 100644 index 0000000..4011eed --- /dev/null +++ b/tests/fixtures/drift_check/broken_repo/skills/malformed/SKILL.md @@ -0,0 +1,7 @@ +--- +name: malformed +description: bad version string +standards-version: nope +--- + +# Malformed diff --git a/tests/fixtures/drift_check/broken_repo/skills/missing/SKILL.md b/tests/fixtures/drift_check/broken_repo/skills/missing/SKILL.md new file mode 100644 index 0000000..ea54b96 --- /dev/null +++ b/tests/fixtures/drift_check/broken_repo/skills/missing/SKILL.md @@ -0,0 +1,6 @@ +--- +name: missing +description: no standards-version key at all +--- + +# Missing diff --git a/tests/fixtures/drift_check/broken_repo/skills/wrongpos/SKILL.md b/tests/fixtures/drift_check/broken_repo/skills/wrongpos/SKILL.md new file mode 100644 index 0000000..758f69e --- /dev/null +++ b/tests/fixtures/drift_check/broken_repo/skills/wrongpos/SKILL.md @@ -0,0 +1,5 @@ +# Wrong position + +The signal is in the body, not in frontmatter. + +standards-version: 1.6.3 diff --git a/tests/fixtures/drift_check/clean_repo/.cursor-plugin/plugin.json b/tests/fixtures/drift_check/clean_repo/.cursor-plugin/plugin.json new file mode 100644 index 0000000..d8a1283 --- /dev/null +++ b/tests/fixtures/drift_check/clean_repo/.cursor-plugin/plugin.json @@ -0,0 +1,4 @@ +{ + "name": "clean-repo", + "version": "0.1.0" +} diff --git a/tests/fixtures/drift_check/clean_repo/AGENTS.md b/tests/fixtures/drift_check/clean_repo/AGENTS.md new file mode 100644 index 0000000..880a17e --- /dev/null +++ b/tests/fixtures/drift_check/clean_repo/AGENTS.md @@ -0,0 +1,5 @@ + + +# AGENTS.md + +Clean fixture. diff --git a/tests/fixtures/drift_check/clean_repo/CLAUDE.md b/tests/fixtures/drift_check/clean_repo/CLAUDE.md new file mode 100644 index 0000000..6512b79 --- /dev/null +++ b/tests/fixtures/drift_check/clean_repo/CLAUDE.md @@ -0,0 +1,5 @@ + + +# CLAUDE.md + +Clean fixture. diff --git a/tests/fixtures/drift_check/clean_repo/rules/sample.mdc b/tests/fixtures/drift_check/clean_repo/rules/sample.mdc new file mode 100644 index 0000000..c37d871 --- /dev/null +++ b/tests/fixtures/drift_check/clean_repo/rules/sample.mdc @@ -0,0 +1,7 @@ +--- +description: clean rule +globs: ["**/*.py"] +standards-version: 1.6.3 +--- + +# Clean rule diff --git a/tests/fixtures/drift_check/clean_repo/skills/sample/SKILL.md b/tests/fixtures/drift_check/clean_repo/skills/sample/SKILL.md new file mode 100644 index 0000000..a7dabb1 --- /dev/null +++ b/tests/fixtures/drift_check/clean_repo/skills/sample/SKILL.md @@ -0,0 +1,7 @@ +--- +name: sample +description: A clean SKILL +standards-version: 1.6.3 +--- + +# Sample diff --git a/tests/fixtures/drift_check/drifted_repo/.cursor-plugin/plugin.json b/tests/fixtures/drift_check/drifted_repo/.cursor-plugin/plugin.json new file mode 100644 index 0000000..c36ee5d --- /dev/null +++ b/tests/fixtures/drift_check/drifted_repo/.cursor-plugin/plugin.json @@ -0,0 +1,4 @@ +{ + "name": "drifted-repo", + "version": "0.1.0" +} diff --git a/tests/fixtures/drift_check/drifted_repo/AGENTS.md b/tests/fixtures/drift_check/drifted_repo/AGENTS.md new file mode 100644 index 0000000..3d7fa66 --- /dev/null +++ b/tests/fixtures/drift_check/drifted_repo/AGENTS.md @@ -0,0 +1,5 @@ + + +# AGENTS.md + +Drifted: MAJOR.MINOR behind. diff --git a/tests/fixtures/drift_check/drifted_repo/CLAUDE.md b/tests/fixtures/drift_check/drifted_repo/CLAUDE.md new file mode 100644 index 0000000..d7ea2fe --- /dev/null +++ b/tests/fixtures/drift_check/drifted_repo/CLAUDE.md @@ -0,0 +1,5 @@ + + +# CLAUDE.md + +Drifted: patch differs. diff --git a/tests/fixtures/drift_check/drifted_repo/rules/newer.mdc b/tests/fixtures/drift_check/drifted_repo/rules/newer.mdc new file mode 100644 index 0000000..3ddf6af --- /dev/null +++ b/tests/fixtures/drift_check/drifted_repo/rules/newer.mdc @@ -0,0 +1,6 @@ +--- +description: tool newer than meta (warn) +standards-version: 1.7.0 +--- + +# Newer rule diff --git a/tests/fixtures/drift_check/drifted_repo/skills/majorminor/SKILL.md b/tests/fixtures/drift_check/drifted_repo/skills/majorminor/SKILL.md new file mode 100644 index 0000000..7981f2b --- /dev/null +++ b/tests/fixtures/drift_check/drifted_repo/skills/majorminor/SKILL.md @@ -0,0 +1,7 @@ +--- +name: majorminor-drift +description: major-minor differs (error) +standards-version: 1.5.0 +--- + +# Majorminor drift diff --git a/tests/fixtures/drift_check/drifted_repo/skills/patch/SKILL.md b/tests/fixtures/drift_check/drifted_repo/skills/patch/SKILL.md new file mode 100644 index 0000000..c220320 --- /dev/null +++ b/tests/fixtures/drift_check/drifted_repo/skills/patch/SKILL.md @@ -0,0 +1,7 @@ +--- +name: patch-drift +description: patch differs (info) +standards-version: 1.6.1 +--- + +# Patch drift diff --git a/tests/fixtures/drift_check/ignored_repo/.cursor-plugin/plugin.json b/tests/fixtures/drift_check/ignored_repo/.cursor-plugin/plugin.json new file mode 100644 index 0000000..7286ebd --- /dev/null +++ b/tests/fixtures/drift_check/ignored_repo/.cursor-plugin/plugin.json @@ -0,0 +1,4 @@ +{ + "name": "ignored-repo", + "version": "0.1.0" +} diff --git a/tests/fixtures/drift_check/ignored_repo/AGENTS.md b/tests/fixtures/drift_check/ignored_repo/AGENTS.md new file mode 100644 index 0000000..81f21d4 --- /dev/null +++ b/tests/fixtures/drift_check/ignored_repo/AGENTS.md @@ -0,0 +1,6 @@ + + + +# AGENTS.md + +Signal drift is marked as intentional. diff --git a/tests/fixtures/drift_check/ignored_repo/skills/longform/SKILL.md b/tests/fixtures/drift_check/ignored_repo/skills/longform/SKILL.md new file mode 100644 index 0000000..5bb565e --- /dev/null +++ b/tests/fixtures/drift_check/ignored_repo/skills/longform/SKILL.md @@ -0,0 +1,10 @@ +--- +name: longform +description: long-form pragma +standards-version: 1.5.0 +drift-ignore: + - check: version-signal + reason: tracking 1.5 for compatibility +--- + +# Longform diff --git a/tests/fixtures/drift_check/ignored_repo/skills/shortform/SKILL.md b/tests/fixtures/drift_check/ignored_repo/skills/shortform/SKILL.md new file mode 100644 index 0000000..ec69c6a --- /dev/null +++ b/tests/fixtures/drift_check/ignored_repo/skills/shortform/SKILL.md @@ -0,0 +1,8 @@ +--- +name: shortform +description: short-form pragma +standards-version: 1.5.0 +drift-ignore: [version-signal] +--- + +# Shortform diff --git a/tests/fixtures/drift_check/mcp_repo/CLAUDE.md b/tests/fixtures/drift_check/mcp_repo/CLAUDE.md new file mode 100644 index 0000000..ca04084 --- /dev/null +++ b/tests/fixtures/drift_check/mcp_repo/CLAUDE.md @@ -0,0 +1,5 @@ + + +# CLAUDE.md + +MCP server pattern: CLAUDE.md only, no skills or rules. diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..d9c987c --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,104 @@ +import io +import json +import sys +from pathlib import Path + +import pytest + +from scripts.drift_check import cli +from tests.conftest import FIXTURES, REPO_ROOT + + +def test_missing_local_returns_2(capsys): + rc = cli.main([]) + assert rc == 2 + err = capsys.readouterr().err + assert "--local" in err + + +def test_bad_path_returns_2(capsys, tmp_path: Path): + rc = cli.main(["--local", str(tmp_path / "nope")]) + assert rc == 2 + assert "not a directory" in capsys.readouterr().err + + +def test_clean_repo_exit_zero(capsys): + rc = cli.main(["--local", str(FIXTURES / "clean_repo"), "--config", "nonexistent.json"]) + out = capsys.readouterr().out + assert rc == 0 + assert "0 errors, 0 warnings" in out + + +def test_drifted_repo_exit_one(capsys): + rc = cli.main(["--local", str(FIXTURES / "drifted_repo"), "--config", "nonexistent.json"]) + out = capsys.readouterr().out + assert rc == 1 + assert "Summary:" in out + + +def test_broken_repo_exit_one(capsys): + rc = cli.main(["--local", str(FIXTURES / "broken_repo"), "--config", "nonexistent.json"]) + assert rc == 1 + + +def test_ignored_repo_exit_zero_infos_suppressed(capsys): + rc = cli.main(["--local", str(FIXTURES / "ignored_repo"), "--config", "nonexistent.json"]) + assert rc == 0 + + +def test_json_format(capsys): + rc = cli.main([ + "--local", str(FIXTURES / "drifted_repo"), + "--format", "json", + "--config", "nonexistent.json", + ]) + assert rc == 1 + payload = json.loads(capsys.readouterr().out) + assert "summary" in payload + assert "meta_version" in payload + + +def test_output_to_file(tmp_path: Path): + out = tmp_path / "report.md" + rc = cli.main([ + "--local", str(FIXTURES / "clean_repo"), + "--output", str(out), + "--config", "nonexistent.json", + ]) + assert rc == 0 + text = out.read_text(encoding="utf-8") + assert "Meta-repo version:" in text + + +def test_malformed_config_returns_2(tmp_path: Path, capsys): + bad = tmp_path / "bad.json" + bad.write_text("{not json", encoding="utf-8") + rc = cli.main([ + "--local", str(FIXTURES / "clean_repo"), + "--config", str(bad), + ]) + assert rc == 2 + assert "malformed JSON" in capsys.readouterr().err + + +def test_multiple_local_paths(capsys): + rc = cli.main([ + "--local", str(FIXTURES / "clean_repo"), + "--local", str(FIXTURES / "drifted_repo"), + "--config", "nonexistent.json", + ]) + assert rc == 1 + out = capsys.readouterr().out + assert "clean_repo" in out + assert "drifted_repo" in out + + +def test_verbose_flag_shows_infos(capsys): + rc = cli.main([ + "--local", str(FIXTURES / "drifted_repo"), + "--verbose", + "--config", "nonexistent.json", + ]) + assert rc == 1 + out = capsys.readouterr().out + assert "infos" in out diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..9ad93ff --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,90 @@ +import json +from pathlib import Path + +import pytest + +from scripts.drift_check.config import ConfigError, load_config + + +def test_missing_file_returns_defaults(tmp_path: Path): + cfg = load_config(tmp_path / "nope.json") + assert cfg.repos == {} + assert cfg.types == {} + assert cfg.globals == {"signal_policy": "same-major-minor"} + + +def test_malformed_raises(tmp_path: Path): + p = tmp_path / "cfg.json" + p.write_text("{not json", encoding="utf-8") + with pytest.raises(ConfigError): + load_config(p) + + +def test_non_object_root_raises(tmp_path: Path): + p = tmp_path / "cfg.json" + p.write_text("[]", encoding="utf-8") + with pytest.raises(ConfigError): + load_config(p) + + +def test_resolve_repo_override_wins(tmp_path: Path): + p = tmp_path / "cfg.json" + p.write_text( + json.dumps( + { + "repos": { + "steam-mcp": {"skip_checks": ["required-refs"]}, + }, + "types": { + "mcp-server": {"skip_checks": ["stale-counts"]}, + }, + "globals": {"signal_policy": "same-major-minor"}, + } + ), + encoding="utf-8", + ) + cfg = load_config(p) + resolved = cfg.resolve("steam-mcp", "mcp-server") + assert "required-refs" in resolved.skip_checks + assert "stale-counts" in resolved.skip_checks + assert resolved.signal_policy == "same-major-minor" + + +def test_resolve_type_only(tmp_path: Path): + p = tmp_path / "cfg.json" + p.write_text( + json.dumps( + { + "types": {"mcp-server": {"skip_checks": ["required-refs"]}}, + "globals": {"signal_policy": "same-major-minor"}, + } + ), + encoding="utf-8", + ) + cfg = load_config(p) + resolved = cfg.resolve("some-other-repo", "mcp-server") + assert resolved.skip_checks == frozenset({"required-refs"}) + + +def test_resolve_global_policy_override(tmp_path: Path): + p = tmp_path / "cfg.json" + p.write_text( + json.dumps({"globals": {"signal_policy": "exact-match"}}), + encoding="utf-8", + ) + cfg = load_config(p) + resolved = cfg.resolve("anything", "cursor-plugin") + assert resolved.signal_policy == "exact-match" + + +def test_resolve_empty_defaults(tmp_path: Path): + cfg = load_config(tmp_path / "missing.json") + resolved = cfg.resolve("x", "cursor-plugin") + assert resolved.skip_checks == frozenset() + assert resolved.signal_policy == "same-major-minor" + + +def test_real_shipped_config_loads(): + from tests.conftest import REPO_ROOT + cfg = load_config(REPO_ROOT / "standards" / "drift-checker.config.json") + assert "steam-mcp" in cfg.repos diff --git a/tests/test_pragma.py b/tests/test_pragma.py new file mode 100644 index 0000000..f06843e --- /dev/null +++ b/tests/test_pragma.py @@ -0,0 +1,113 @@ +from pathlib import Path + +from scripts.drift_check.pragma import extract_pragmas + + +def test_no_pragma_html(): + content = b"\n\n# body\n" + assert extract_pragmas(Path("AGENTS.md"), content) == () + + +def test_no_pragma_yaml(): + content = b"---\nname: x\nstandards-version: 1.6.3\n---\n" + assert extract_pragmas(Path("skills/x/SKILL.md"), content) == () + + +def test_html_short_form_bare(): + content = b"\n" + p = extract_pragmas(Path("AGENTS.md"), content) + assert len(p) == 1 + assert p[0].check_name == "version-signal" + assert p[0].reason is None + assert p[0].format == "html-comment" + + +def test_html_with_reason(): + content = b'\n' + p = extract_pragmas(Path("AGENTS.md"), content) + assert len(p) == 1 + assert p[0].check_name == "version-signal" + assert p[0].reason == "tracking v1.5" + + +def test_html_comma_list(): + content = b"\n" + p = extract_pragmas(Path("CLAUDE.md"), content) + assert [x.check_name for x in p] == ["version-signal", "required-refs"] + + +def test_html_bracket_list(): + content = b"\n" + p = extract_pragmas(Path("CLAUDE.md"), content) + assert {x.check_name for x in p} == {"version-signal", "required-refs"} + + +def test_multiple_html_pragmas(): + content = ( + b"\n" + b"\n" + b"\n" + ) + p = extract_pragmas(Path("AGENTS.md"), content) + assert len(p) == 2 + + +def test_malformed_html_pragma_is_graceful(): + content = b"\n" + p = extract_pragmas(Path("AGENTS.md"), content) + assert p == () + + +def test_yaml_short_form(): + content = ( + b"---\n" + b"name: x\n" + b"standards-version: 1.5.0\n" + b"drift-ignore: [version-signal]\n" + b"---\n" + ) + p = extract_pragmas(Path("skills/x/SKILL.md"), content) + assert len(p) == 1 + assert p[0].check_name == "version-signal" + assert p[0].format == "yaml-short" + + +def test_yaml_short_form_multi(): + content = ( + b"---\n" + b"drift-ignore: [version-signal, required-refs]\n" + b"---\n" + ) + p = extract_pragmas(Path("rules/y.mdc"), content) + assert {x.check_name for x in p} == {"version-signal", "required-refs"} + + +def test_yaml_long_form_with_reasons(): + content = ( + b"---\n" + b"name: x\n" + b"drift-ignore:\n" + b" - check: version-signal\n" + b" reason: tracking 1.5\n" + b" - check: required-refs\n" + b"---\n" + ) + p = extract_pragmas(Path("skills/x/SKILL.md"), content) + names = {x.check_name: x.reason for x in p} + assert names == {"version-signal": "tracking 1.5", "required-refs": None} + for x in p: + assert x.format == "yaml-long" + + +def test_yaml_pragma_outside_frontmatter_is_ignored(): + content = ( + b"---\n" + b"name: x\n" + b"---\n" + b"drift-ignore: [version-signal]\n" + ) + assert extract_pragmas(Path("skills/x/SKILL.md"), content) == () + + +def test_unknown_file_returns_empty(): + assert extract_pragmas(Path("README.md"), b"") == () diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..d297c5b --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,104 @@ +import json +from datetime import datetime, timezone +from pathlib import Path + +from scripts.drift_check.report import json_out, markdown +from scripts.drift_check.semver import parse_version +from scripts.drift_check.snapshot import build_local_snapshot +from scripts.drift_check.types import DriftConfig, Finding + +from tests.conftest import FIXTURES + + +META = parse_version("1.6.3") +assert META is not None +CFG = DriftConfig(globals={"signal_policy": "same-major-minor"}) + + +def _snaps(names): + return [build_local_snapshot(FIXTURES / n, META, "HEAD", CFG) for n in names] + + +def test_markdown_clean_repo_reports_clean(): + snaps = _snaps(["clean_repo"]) + out = markdown.render(snaps, []) + assert "Meta-repo version: 1.6.3" in out + assert "clean_repo (0 errors, 0 warnings)" in out + assert "Clean." in out + assert "Summary: 0 errors, 0 warnings, 0 infos" in out + + +def test_markdown_groups_findings_by_repo(): + snaps = _snaps(["clean_repo", "drifted_repo"]) + findings = [ + Finding(repo="drifted_repo", file=Path("AGENTS.md"), check="version-signal", + severity="error", message="bad"), + Finding(repo="drifted_repo", file=Path("CLAUDE.md"), check="version-signal", + severity="warn", message="warn"), + ] + out = markdown.render(snaps, findings) + assert "drifted_repo (1 errors, 1 warnings)" in out + assert "| AGENTS.md | version-signal | error | bad |" in out + + +def test_markdown_verbose_flag_shows_info(): + snaps = _snaps(["clean_repo"]) + findings = [ + Finding(repo="clean_repo", file=Path("AGENTS.md"), check="version-signal", + severity="info", message="info msg"), + ] + out_nonverbose = markdown.render(snaps, findings, verbose=False) + assert "info msg" not in out_nonverbose + + out_verbose = markdown.render(snaps, findings, verbose=True) + assert "info msg" in out_verbose + assert "1 infos" in out_verbose + + +def test_markdown_escapes_pipes_in_message(): + snaps = _snaps(["clean_repo"]) + findings = [ + Finding(repo="clean_repo", file=Path("x.md"), check="c", severity="error", + message="has | pipe"), + ] + out = markdown.render(snaps, findings) + assert "has \\| pipe" in out + + +def test_json_shape_and_summary(): + snaps = _snaps(["clean_repo", "drifted_repo"]) + findings = [ + Finding(repo="drifted_repo", file=Path("a.md"), check="version-signal", + severity="error", message="e"), + Finding(repo="drifted_repo", file=Path("b.md"), check="version-signal", + severity="warn", message="w"), + Finding(repo="drifted_repo", file=Path("c.md"), check="version-signal", + severity="info", message="i"), + ] + fixed_time = datetime(2026, 4, 24, 15, 30, 0, tzinfo=timezone.utc) + payload = json.loads(json_out.render(snaps, findings, verbose=True, now=fixed_time)) + assert payload["meta_version"] == "1.6.3" + assert payload["checked_at"] == "2026-04-24T15:30:00Z" + assert payload["summary"] == {"errors": 1, "warnings": 1, "infos": 1} + slugs = [r["slug"] for r in payload["repos"]] + assert slugs == ["clean_repo", "drifted_repo"] + + +def test_json_nonverbose_drops_info(): + snaps = _snaps(["clean_repo"]) + findings = [ + Finding(repo="clean_repo", file=None, check="x", severity="info", message="i"), + ] + payload = json.loads(json_out.render(snaps, findings, verbose=False)) + assert payload["summary"]["infos"] == 0 + for r in payload["repos"]: + assert r["findings"] == [] + + +def test_json_null_file_preserved(): + snaps = _snaps(["clean_repo"]) + findings = [ + Finding(repo="clean_repo", file=None, check="x", severity="error", message="m"), + ] + payload = json.loads(json_out.render(snaps, findings)) + assert payload["repos"][0]["findings"][0]["file"] is None diff --git a/tests/test_semver.py b/tests/test_semver.py new file mode 100644 index 0000000..b50f1f5 --- /dev/null +++ b/tests/test_semver.py @@ -0,0 +1,64 @@ +from scripts.drift_check.semver import compare_policy, parse_version +from scripts.drift_check.types import Version + + +def v(s: str) -> Version: + out = parse_version(s) + assert out is not None, s + return out + + +def test_parse_basic(): + assert parse_version("1.6.3").as_tuple() == (1, 6, 3) + + +def test_parse_v_prefix(): + assert parse_version("v1.6.3").as_tuple() == (1, 6, 3) + + +def test_parse_prerelease_suffix_ignored(): + assert parse_version("1.6.3-rc1").as_tuple() == (1, 6, 3) + + +def test_parse_rejects_garbage(): + assert parse_version("not-a-version") is None + assert parse_version("") is None + assert parse_version("1.6") is None + + +def test_exact_match(): + assert compare_policy(v("1.6.3"), v("1.6.3")) == "exact_match" + + +def test_patch_differs(): + assert compare_policy(v("1.6.1"), v("1.6.3")) == "patch_differs" + assert compare_policy(v("1.6.0"), v("1.6.3")) == "patch_differs" + + +def test_major_minor_differs_minor(): + assert compare_policy(v("1.5.3"), v("1.6.3")) == "major_minor_differs" + + +def test_major_minor_differs_major(): + assert compare_policy(v("0.6.3"), v("1.6.3")) == "major_minor_differs" + + +def test_tool_newer_patch(): + assert compare_policy(v("1.6.4"), v("1.6.3")) == "tool_newer" + + +def test_tool_newer_minor(): + assert compare_policy(v("1.7.0"), v("1.6.3")) == "tool_newer" + + +def test_tool_newer_major(): + assert compare_policy(v("2.0.0"), v("1.6.3")) == "tool_newer" + + +def test_malformed_none(): + assert compare_policy(None, v("1.6.3")) == "malformed" + + +def test_malformed_unparsed(): + unparsed = Version(0, 0, 0, "nope", parsed=False) + assert compare_policy(unparsed, v("1.6.3")) == "malformed" diff --git a/tests/test_signal.py b/tests/test_signal.py new file mode 100644 index 0000000..8f75249 --- /dev/null +++ b/tests/test_signal.py @@ -0,0 +1,106 @@ +from pathlib import Path + +from scripts.drift_check.signals import detect_signal + + +def test_html_comment_valid(): + content = b"\n\n# body\n" + r = detect_signal(Path("AGENTS.md"), content) + assert r is not None + assert not r.malformed + assert r.version is not None and r.version.as_tuple() == (1, 6, 3) + assert r.format == "html-comment" + assert r.line == 1 + + +def test_html_comment_claude(): + r = detect_signal(Path("CLAUDE.md"), b"\n") + assert r is not None and r.version is not None + + +def test_html_comment_flexible_whitespace(): + r = detect_signal(Path("AGENTS.md"), b"\n") + assert r is not None and r.version is not None + + +def test_html_comment_at_wrong_position_is_none(): + content = b"# AGENTS.md\n\n\n" + assert detect_signal(Path("AGENTS.md"), content) is None + + +def test_html_comment_malformed_returns_result(): + r = detect_signal(Path("AGENTS.md"), b"\n") + assert r is not None + assert r.malformed is True + assert r.version is None + assert r.raw_value == "nope" + + +def test_empty_file_returns_none(): + assert detect_signal(Path("AGENTS.md"), b"") is None + assert detect_signal(Path("skills/x/SKILL.md"), b"") is None + + +def test_bom_is_stripped_for_html(): + content = b"\xef\xbb\xbf\n" + r = detect_signal(Path("AGENTS.md"), content) + assert r is not None and r.version is not None + + +def test_yaml_frontmatter_valid(): + content = ( + b"---\n" + b"name: x\n" + b"standards-version: 1.6.3\n" + b"---\n" + b"# body\n" + ) + r = detect_signal(Path("skills/x/SKILL.md"), content) + assert r is not None and r.version is not None + assert r.format == "yaml-frontmatter" + assert r.line == 3 + + +def test_yaml_mdc_rule(): + content = b"---\nstandards-version: 1.6.3\n---\n" + r = detect_signal(Path("rules/y.mdc"), content) + assert r is not None and r.version is not None + + +def test_yaml_frontmatter_only_body_no_fence(): + content = b"# just body\nstandards-version: 1.6.3\n" + assert detect_signal(Path("skills/x/SKILL.md"), content) is None + + +def test_yaml_frontmatter_signal_outside_block_is_none(): + content = b"---\nname: x\n---\nstandards-version: 1.6.3\n" + assert detect_signal(Path("skills/x/SKILL.md"), content) is None + + +def test_yaml_frontmatter_malformed(): + content = b"---\nstandards-version: nope\n---\n" + r = detect_signal(Path("rules/y.mdc"), content) + assert r is not None + assert r.malformed and r.version is None + + +def test_yaml_frontmatter_quoted_value(): + content = b'---\nstandards-version: "1.6.3"\n---\n' + r = detect_signal(Path("rules/y.mdc"), content) + assert r is not None and r.version is not None + + +def test_frontmatter_only_no_body(): + content = b"---\nstandards-version: 1.6.3\n---\n" + r = detect_signal(Path("skills/x/SKILL.md"), content) + assert r is not None and r.version is not None + + +def test_unknown_file_is_none(): + assert detect_signal(Path("README.md"), b"\n") is None + + +def test_crlf_line_endings(): + content = b"\r\n\r\n# body\r\n" + r = detect_signal(Path("AGENTS.md"), content) + assert r is not None and r.version is not None diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py new file mode 100644 index 0000000..b54bb2e --- /dev/null +++ b/tests/test_snapshot.py @@ -0,0 +1,78 @@ +from pathlib import Path + +from scripts.drift_check.semver import parse_version +from scripts.drift_check.snapshot import build_local_snapshot +from scripts.drift_check.types import DriftConfig + +from tests.conftest import FIXTURES + + +META = parse_version("1.6.3") +assert META is not None +DEFAULT_CFG = DriftConfig(globals={"signal_policy": "same-major-minor"}) + + +def test_clean_repo_full_snapshot(): + snap = build_local_snapshot(FIXTURES / "clean_repo", META, "HEAD", DEFAULT_CFG) + assert snap.repo_type == "cursor-plugin" + paths = {str(p).replace("\\", "/") for p in snap.files} + assert "AGENTS.md" in paths + assert "CLAUDE.md" in paths + assert "skills/sample/SKILL.md" in paths + assert "rules/sample.mdc" in paths + for fs in snap.files.values(): + assert fs.signal is not None + assert fs.signal.version is not None + + +def test_mcp_repo_only_claude(): + snap = build_local_snapshot(FIXTURES / "mcp_repo", META, "HEAD", DEFAULT_CFG) + assert snap.repo_type == "mcp-server" + assert len(snap.files) == 1 + (p, fs), = snap.files.items() + assert str(p).replace("\\", "/") == "CLAUDE.md" + assert fs.signal is not None and fs.signal.version is not None + + +def test_broken_repo_wrong_position_signals_not_detected(): + snap = build_local_snapshot(FIXTURES / "broken_repo", META, "HEAD", DEFAULT_CFG) + # AGENTS.md has no signal -> None + agents = snap.files[Path("AGENTS.md")] + assert agents.signal is None + # CLAUDE.md has malformed signal -> SignalResult with malformed=True + claude = snap.files[Path("CLAUDE.md")] + assert claude.signal is not None and claude.signal.malformed + # wrongpos SKILL.md has the key in the body, not frontmatter -> None + wrongpos_skill = snap.files[Path("skills/wrongpos/SKILL.md")] + assert wrongpos_skill.signal is None + wrongpos_rule = snap.files[Path("rules/wrongpos.mdc")] + assert wrongpos_rule.signal is None + + +def test_ignored_repo_pragmas_extracted(): + snap = build_local_snapshot(FIXTURES / "ignored_repo", META, "HEAD", DEFAULT_CFG) + agents = snap.files[Path("AGENTS.md")] + assert any(p.check_name == "version-signal" for p in agents.pragmas) + + short = snap.files[Path("skills/shortform/SKILL.md")] + assert any(p.format == "yaml-short" for p in short.pragmas) + + longf = snap.files[Path("skills/longform/SKILL.md")] + assert any(p.format == "yaml-long" and p.check_name == "version-signal" for p in longf.pragmas) + + +def test_nonexistent_repo_raises(tmp_path: Path): + import pytest + + with pytest.raises(FileNotFoundError): + build_local_snapshot(tmp_path / "nope", META, "HEAD", DEFAULT_CFG) + + +def test_empty_repo_unknown_type(tmp_path: Path): + import io + + warn = io.StringIO() + snap = build_local_snapshot(tmp_path, META, "HEAD", DEFAULT_CFG, warn_stream=warn) + assert snap.repo_type == "unknown" + assert snap.files == {} + assert "unknown" in warn.getvalue() diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..d8edc7a --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,64 @@ +from pathlib import Path + +from scripts.drift_check.checks import VersionSignalCheck +from scripts.drift_check.types import ( + Check, + DriftConfig, + FileSnapshot, + Finding, + Pragma, + RepoSnapshot, + SignalResult, + Version, +) + + +def test_version_asdict(): + v = Version(1, 6, 3, "1.6.3") + assert v.as_tuple() == (1, 6, 3) + assert str(v) == "1.6.3" + + +def test_frozen_dataclasses_immutable(): + f = Finding(repo="r", file=None, check="c", severity="error", message="m") + import pytest + + with pytest.raises(Exception): + f.repo = "other" # type: ignore[misc] + + +def test_check_protocol_satisfied_by_real_check(): + assert isinstance(VersionSignalCheck(), Check) + + +def test_drift_config_resolve_layering(): + cfg = DriftConfig( + repos={"r1": {"skip_checks": ["a"]}}, + types={"cursor-plugin": {"skip_checks": ["b"]}}, + globals={"skip_checks": ["c"], "signal_policy": "same-major-minor"}, + ) + resolved = cfg.resolve("r1", "cursor-plugin") + assert resolved.skip_checks == frozenset({"a", "b", "c"}) + assert resolved.signal_policy == "same-major-minor" + + +def test_signal_result_and_pragma_fields(): + s = SignalResult(version=None, format="html-comment", line=1, raw_value="x", malformed=True) + assert s.malformed + p = Pragma(check_name="version-signal", reason=None, format="html-comment", line=2) + assert p.check_name == "version-signal" + + +def test_file_snapshot_defaults_pragmas(): + fs = FileSnapshot(path=Path("x.md"), content=b"", signal=None) + assert fs.pragmas == () + + +def test_repo_snapshot_holds_files(): + v = Version(1, 6, 3, "1.6.3") + cfg = DriftConfig().resolve("x", "cursor-plugin") + snap = RepoSnapshot( + slug="x", repo_type="cursor-plugin", files={}, meta_version=v, + meta_commit="HEAD", config=cfg, + ) + assert snap.slug == "x" diff --git a/tests/test_version_signal_check.py b/tests/test_version_signal_check.py new file mode 100644 index 0000000..320de12 --- /dev/null +++ b/tests/test_version_signal_check.py @@ -0,0 +1,78 @@ +from pathlib import Path + +from scripts.drift_check.checks import VersionSignalCheck +from scripts.drift_check.semver import parse_version +from scripts.drift_check.snapshot import build_local_snapshot +from scripts.drift_check.types import DriftConfig + +from tests.conftest import FIXTURES + + +META = parse_version("1.6.3") +assert META is not None +DEFAULT_CFG = DriftConfig(globals={"signal_policy": "same-major-minor"}) + + +def _run(fixture_name: str): + snap = build_local_snapshot(FIXTURES / fixture_name, META, "HEAD", DEFAULT_CFG) + return list(VersionSignalCheck().run(snap)) + + +def test_clean_repo_silent(): + findings = _run("clean_repo") + assert findings == [] + + +def test_drifted_repo_tiers(): + findings = _run("drifted_repo") + by_sev = {f.severity: [] for f in findings} + for f in findings: + by_sev.setdefault(f.severity, []).append(f) + + # AGENTS.md 1.5.0 -> error (major_minor_differs) + # CLAUDE.md 1.6.1 -> info (patch_differs) + # majorminor SKILL 1.5.0 -> error + # patch SKILL 1.6.1 -> info + # newer.mdc 1.7.0 -> warn (tool_newer) + sev_counts = {k: len(v) for k, v in by_sev.items()} + assert sev_counts.get("error", 0) == 2 + assert sev_counts.get("warn", 0) == 1 + assert sev_counts.get("info", 0) == 2 + + +def test_broken_repo_errors(): + findings = _run("broken_repo") + # AGENTS missing, CLAUDE malformed, 3 skill/rule missing/wrongpos -> all errors + assert all(f.severity == "error" for f in findings) + assert len(findings) >= 5 + # Every error carries a suggested_fix with the Phase 1 script name. + assert all(f.suggested_fix for f in findings) + assert any("add_comment_marker.py" in (f.suggested_fix or "") for f in findings) + assert any("add_frontmatter.py" in (f.suggested_fix or "") for f in findings) + + +def test_ignored_repo_emits_info_not_error(): + findings = _run("ignored_repo") + assert len(findings) == 3 + assert all(f.severity == "info" for f in findings) + assert all("skipped by drift-ignore pragma" in f.message for f in findings) + + +def test_skip_via_config(): + cfg = DriftConfig( + repos={"drifted_repo": {"skip_checks": ["version-signal"]}}, + globals={"signal_policy": "same-major-minor"}, + ) + snap = build_local_snapshot(FIXTURES / "drifted_repo", META, "HEAD", cfg) + findings = list(VersionSignalCheck().run(snap)) + assert findings == [] + + +def test_mcp_repo_is_clean(): + findings = _run("mcp_repo") + assert findings == [] + + +def test_check_name_attribute(): + assert VersionSignalCheck.name == "version-signal" + assert VersionSignalCheck().name == "version-signal"