remem-ai 0.6.49

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
#!/usr/bin/env python3
"""Check remem spec/implementation PR lifecycle rules."""

from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path

from check_version_bump import changed_files as git_changed_files


AUTO_CLOSE_RE = re.compile(
    r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s*:?\s+"
    r"(?:[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#\d+\b",
    re.I,
)
REFS_RE = re.compile(r"\brefs?\b\s*:?\s+(?:[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#\d+\b", re.I)
NO_ISSUE_RE = re.compile(r"\bno issue:\s*\S+", re.I)
API_DOCS_NOT_NEEDED_RE = re.compile(
    r"^\s*API contract docs:\s*not needed\s*[-:]\s+\S.+$",
    re.I | re.M,
)
PR_TYPES = ("Spec only", "Implementation", "Bugfix", "Release/docs/process")


def file_exists_at_ref(path: str, ref: str) -> bool:
    if ref == "WORKTREE":
        return Path(path).exists()
    result = subprocess.run(
        ["git", "cat-file", "-e", f"{ref}:{path}"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        text=True,
    )
    return result.returncode == 0


def file_text_at_ref(path: str, ref: str) -> str:
    if ref == "WORKTREE":
        p = Path(path)
        return p.read_text(encoding="utf-8") if p.exists() else ""
    result = subprocess.run(
        ["git", "show", f"{ref}:{path}"],
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
        text=True,
    )
    return result.stdout if result.returncode == 0 else ""


def pr_body_from_env() -> str:
    body = os.environ.get("GITHUB_PR_BODY", "")
    if body:
        return body

    event_path = os.environ.get("GITHUB_EVENT_PATH")
    if event_path and Path(event_path).exists():
        with open(event_path, "r", encoding="utf-8") as fh:
            event = json.load(fh)
        pr = event.get("pull_request") or {}
        return pr.get("body") or ""

    return ""


def checked(label: str, body: str) -> bool:
    pattern = re.compile(rf"^\s*[-*]\s+\[[xX]\]\s+{re.escape(label)}\s*$", re.M)
    return pattern.search(body) is not None


def spec_dirs_added_or_changed(changes: list[str]) -> set[str]:
    dirs: set[str] = set()
    for path in changes:
        parts = path.split("/")
        if len(parts) == 4 and parts[0] == "docs" and parts[1] == "specs":
            spec_id = parts[2]
            leaf = parts[3]
            if spec_id != "refactor-steps" and leaf in {"PRODUCT.md", "TECH.md"}:
                dirs.add(spec_id)
    return dirs


def has_path(changes: list[str], path: str) -> bool:
    return path in changes


def has_prefix(changes: list[str], prefix: str) -> bool:
    return any(path.startswith(prefix) for path in changes)


def spec_id_indexed(spec_id: str, head: str) -> bool:
    readme = file_text_at_ref("docs/specs/README.md", head)
    return f"`{spec_id}/`" in readme or f"docs/specs/{spec_id}/" in readme


def check_lifecycle(
    changes: list[str], body: str, head: str = "HEAD", base: str = "HEAD^"
) -> list[str]:
    failures: list[str] = []

    checked_types = [label for label in PR_TYPES if checked(label, body)]
    is_spec_only = "Spec only" in checked_types
    spec_dirs = spec_dirs_added_or_changed(changes)
    touches_src = has_prefix(changes, "src/")
    lifecycle_relevant = bool(spec_dirs) or touches_src

    if lifecycle_relevant and len(checked_types) != 1:
        failures.append(
            "Lifecycle-relevant PRs must select exactly one PR Type checkbox."
        )

    if is_spec_only:
        if AUTO_CLOSE_RE.search(body):
            failures.append(
                "Spec-only PRs must use Refs, not Closes/Fixes/Resolves. "
                "Replace the auto-close wording and create/link implementation issue(s)."
            )
        if not REFS_RE.search(body):
            failures.append("Spec-only PRs must include at least one `Refs #...` link.")

    for spec_id in sorted(spec_dirs):
        product = f"docs/specs/{spec_id}/PRODUCT.md"
        tech = f"docs/specs/{spec_id}/TECH.md"
        if not file_exists_at_ref(product, head) or not file_exists_at_ref(tech, head):
            failures.append(
                f"Current spec `{spec_id}` must include both PRODUCT.md and TECH.md."
            )
        is_new_spec = not file_exists_at_ref(product, base) and not file_exists_at_ref(tech, base)
        if is_new_spec and not has_path(changes, "docs/specs/README.md"):
            failures.append(
                f"New current spec `{spec_id}` must update docs/specs/README.md."
            )
        if is_new_spec and not spec_id_indexed(spec_id, head):
            failures.append(f"New current spec `{spec_id}` must be indexed in docs/specs/README.md.")

    if touches_src:
        if is_spec_only:
            failures.append("PRs that touch src/** cannot be marked Spec only.")
        if not AUTO_CLOSE_RE.search(body) and not NO_ISSUE_RE.search(body):
            failures.append(
                "PRs that touch src/** must include `Closes #...` "
                "or an explicit `No issue: ...` explanation."
            )

    if has_prefix(changes, "src/api/"):
        if not has_path(changes, "docs/specs/SPEC-web-api.md") and not API_DOCS_NOT_NEEDED_RE.search(body):
            failures.append(
                "PRs touching src/api/** must update docs/specs/SPEC-web-api.md "
                "or include `API contract docs: not needed - <reason>` in the PR body."
            )

    return failures


def self_test() -> int:
    cases = [
        (
            "spec refs passes",
            ["docs/specs/demo/PRODUCT.md", "docs/specs/demo/TECH.md", "docs/specs/README.md"],
            "- [x] Spec only\n\nRefs #123",
            set(),
        ),
        (
            "new spec missing readme fails",
            ["docs/specs/missingreadme/PRODUCT.md", "docs/specs/missingreadme/TECH.md"],
            "- [x] Spec only\n\nRefs #123",
            {"must update docs/specs/README.md", "must be indexed"},
        ),
        (
            "spec closes fails",
            ["docs/specs/demo/PRODUCT.md"],
            "- [x] Spec only\n\nCloses #123",
            {"Spec-only PRs must use Refs"},
        ),
        (
            "spec closes colon fails",
            ["docs/specs/demo/PRODUCT.md", "docs/specs/demo/TECH.md", "docs/specs/README.md"],
            "- [x] Spec only\n\nRefs #123\n\nCloses: #123",
            {"Spec-only PRs must use Refs"},
        ),
        (
            "spec cross repo closes fails",
            ["docs/specs/demo/PRODUCT.md", "docs/specs/demo/TECH.md", "docs/specs/README.md"],
            "- [x] Spec only\n\nRefs #123\n\nFixes majiayu000/remem#123",
            {"Spec-only PRs must use Refs"},
        ),
        (
            "implementation src closes passes",
            ["src/lib.rs"],
            "- [x] Implementation\n\nCloses #123",
            set(),
        ),
        (
            "implementation src missing issue fails",
            ["src/lib.rs"],
            "- [x] Implementation",
            {"PRs that touch src/**"},
        ),
        (
            "implementation title-only close fails",
            ["src/lib.rs"],
            "- [x] Implementation",
            {"PRs that touch src/**"},
        ),
        (
            "bugfix src missing issue fails",
            ["src/lib.rs"],
            "- [x] Bugfix",
            {"PRs that touch src/**"},
        ),
        (
            "bugfix src no issue passes",
            ["src/lib.rs"],
            "- [x] Bugfix\n\nNo issue: emergency local-only diagnostic repair",
            set(),
        ),
        (
            "release docs process src missing issue fails",
            ["src/lib.rs"],
            "- [x] Release/docs/process",
            {"PRs that touch src/**"},
        ),
        (
            "spec only src fails",
            ["src/lib.rs"],
            "- [x] Spec only\n\nRefs #123",
            {"cannot be marked Spec only", "PRs that touch src/**"},
        ),
        (
            "api docs marker passes",
            ["src/api/server.rs"],
            "- [x] Implementation\n\nCloses #123\n\nAPI contract docs: not needed - internal refactor only",
            set(),
        ),
        (
            "api default template wording fails",
            ["src/api/server.rs"],
            "- [x] Implementation\n\nCloses #123\n\n- [ ] If touching `src/api/**`, updated `docs/specs/SPEC-web-api.md` or wrote an explicit API docs waiver with rationale",
            {"PRs touching src/api/**"},
        ),
        (
            "api docs missing fails",
            ["src/api/server.rs"],
            "- [x] Implementation\n\nCloses #123",
            {"PRs touching src/api/**"},
        ),
        (
            "unchecked lifecycle type fails",
            ["docs/specs/demo/PRODUCT.md", "docs/specs/demo/TECH.md", "docs/specs/README.md"],
            "- [ ] Spec only\n- [ ] Implementation\n\nCloses #123",
            {"must select exactly one PR Type"},
        ),
        (
            "existing spec update does not require readme",
            ["docs/specs/existing/TECH.md"],
            "- [x] Spec only\n\nRefs #123",
            set(),
        ),
        (
            "new spec readme must mention spec id",
            ["docs/specs/unindexed/PRODUCT.md", "docs/specs/unindexed/TECH.md", "docs/specs/README.md"],
            "- [x] Spec only\n\nRefs #123",
            {"must be indexed"},
        ),
    ]

    original_file_exists = globals()["file_exists_at_ref"]
    original_file_text = globals()["file_text_at_ref"]

    existing_base = {
        "docs/specs/existing/PRODUCT.md",
        "docs/specs/existing/TECH.md",
    }

    def fake_file_exists(path: str, ref: str = "HEAD") -> bool:
        if ref == "BASE":
            return path in existing_base
        return not path.startswith("docs/specs/missing/")

    def fake_file_text(path: str, ref: str = "HEAD") -> str:
        if path != "docs/specs/README.md":
            return ""
        return "`demo/`\n`existing/`\n"

    globals()["file_exists_at_ref"] = fake_file_exists
    globals()["file_text_at_ref"] = fake_file_text
    try:
        for name, changes, body, expected_fragments in cases:
            failures = check_lifecycle(changes, body, head="HEAD", base="BASE")
            text = "\n".join(failures)
            if expected_fragments:
                for fragment in expected_fragments:
                    if fragment not in text:
                        print(f"self-test failed: {name}: missing {fragment!r}", file=sys.stderr)
                        print(text, file=sys.stderr)
                        return 1
            elif failures:
                print(f"self-test failed: {name}: unexpected failures", file=sys.stderr)
                print(text, file=sys.stderr)
                return 1
    finally:
        globals()["file_exists_at_ref"] = original_file_exists
        globals()["file_text_at_ref"] = original_file_text

    print("check_spec_lifecycle self-test passed")
    return 0


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("base", nargs="?")
    parser.add_argument("head", nargs="?", default="HEAD")
    parser.add_argument("--self-test", action="store_true")
    args = parser.parse_args()

    if args.self_test:
        return self_test()
    if not args.base:
        parser.error("base revision is required unless --self-test is used")

    changes = git_changed_files(args.base, args.head)
    body = pr_body_from_env()
    failures = check_lifecycle(changes, body, args.head, args.base)
    if failures:
        print("Spec lifecycle check failed:", file=sys.stderr)
        for failure in failures:
            print(f"- {failure}", file=sys.stderr)
        return 1

    print("Spec lifecycle check passed")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())