scripts.rulings_lint

Reference › scripts › rulings_lint

scripts/rulings_lint.py is the rulings enforcement lint (SPEC-023 Phase 2): it closes the prose-only loophole by requiring that every ruling page name a mechanism that actually enforces it. It is run by a human, by health_check, and as a library by orchestrata_ultimate.factory.dor_procedure.rulings and check_wiring; tests/scripts/test_rulings_lint.py and tests/scripts/test_check_incus_not_docker.py cover it.

evaluate_ruling_file reads one ruling page. _extract_enforcement_lines returns the bullet lines under its ## Enforcement heading, and evaluate_directive checks each against the mechanism it names: _check_hook (a git hook, resolved by _resolve_git_hooks_path against the actual hooks path), _check_gate, _check_banned_deps, _check_constitution and _check_none. lint runs this over a rulings directory and returns an exit code and a report. Options: --rulings-dir (default $RULINGS_DIR or the wiki's rulings directory, resolved at call time by _default_rulings_dir) and --json. It reads the ruling pages and the repository and writes nothing.

At a glance

Source scripts/rulings_lint.py
Lines of code 376
Classes 2
Functions 13
Imports (internal) none
Imported by orchestrata_ultimate.factory.dor_procedure.rulings, scripts.check_wiring
Tests that import it tests/scripts/test_check_incus_not_docker.py, tests/scripts/test_rulings_lint.py

Class diagram

Class diagram scripts.rulings_lint

Dependency diagram

Dependency diagram scripts.rulings_lint

API

Classes

MechanismResult (dataclass)

Decorators: dataclass · Line 64

Attribute Type Default
ruling_id str
directive str
ok bool
detail str
judgment bool False

RulingResult (dataclass)

Decorators: dataclass · Line 73

Attribute Type Default
ruling_id str
path Path
mechanisms list[MechanismResult] field(default_factory=list)
Method Signature Summary
ok property ok() -> bool
is_judgment_only property is_judgment_only() -> bool

Functions

Function Signature Summary
_default_rulings_dir _default_rulings_dir() -> Path Resolve the rulings directory at call time (RUL-003: no bare os.environ[...], no import-time constant).
_extract_enforcement_lines _extract_enforcement_lines(text: str) -> list[str] Return the bullet lines under the ## Enforcement heading.
_resolve_git_hooks_path _resolve_git_hooks_path(rel: str) -> Path Resolve a .git/hooks/...-relative directive against the ACTUAL hooks directory, via git rev-parse --git-path hooks.
_check_hook _check_hook(directive_args: str) -> tuple[bool, str]
_check_gate _check_gate(directive_args: str) -> tuple[bool, str]
_check_banned_deps _check_banned_deps(directive_args: str) -> tuple[bool, str]
_check_constitution _check_constitution(directive_args: str) -> tuple[bool, str]
_check_none _check_none(directive_args: str) -> tuple[bool, str]
evaluate_directive evaluate_directive(ruling_id: str, directive: str) -> MechanismResult
evaluate_ruling_file evaluate_ruling_file(path: Path) -> RulingResult | None
lint lint(rulings_dir: Path) -> tuple[int, dict[str, Any]] Run the lint over rulings_dir.
_print_text_report _print_text_report(report: dict) -> None
main main(argv: list[str] | None = None) -> int

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/scripts/rulings_lint.py

#!/usr/bin/env python3
"""Rulings enforcement lint — closes the prose-only loophole (SPEC-023 Phase 2,
``plans/workflow-improvements-plan.md`` Step 2.1).

**Read path (binding, Phase 0):** rulings live in the git-backed wiki store
on the FILESYSTEM (``rulings/index.md`` + ``rulings/RUL-*.md``), never via
``wiki get``. A live Wiki.js server must never be a dependency of this gate
— reading the disk files directly means the gate still works with the wiki
down. Default directory: ``~/workspace/knowledge-wiki/rulings`` — override
with the ``RULINGS_DIR`` env var (read at call time via ``os.environ.get``,
never a bare subscript or an import-time constant, per RUL-003).

Each ``RUL-*.md`` page declares its ``enforcement:`` field — one bullet per
mechanism under an ``## Enforcement`` section — in one of five grammars:

    - hook <path>                    file must exist and be executable
    - gate <script> <rule-id>        script must exist AND <rule-id> must be
                                      a literal substring of the script text
    - banned-deps <name>             <name> must be a (non-comment) line in
                                      .claude/banned-libraries.txt
    - constitution <article-text>    <article-text> must be a literal
                                      substring of the governance rulebase
                                      (src/orchestrata_ultimate/prolog/
                                      constitutional.pl)
    - none (judgment)                always passes; reported as judgment-only

A ruling with no ``## Enforcement`` section, or with a declared mechanism
that does not actually exist, is a FAILURE (exit 1) — this is the mechanism
that keeps a ruling from silently regressing to prose-only (RUL-014).

**Missing RULINGS_DIR is a SKIP, not a FAIL** (exit 0, with a warning): a
machine that has not cloned ``knowledge-wiki`` must not go false-red on this
gate — same honesty rule as ``--skip kernel`` in start_test_services.py.

Usage:
    python scripts/rulings_lint.py [--rulings-dir PATH] [--json]
"""

from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

REPO_ROOT = Path(__file__).resolve().parent.parent
CONSTITUTION_PATH = (
    REPO_ROOT / "src" / "orchestrata_ultimate" / "prolog" / "constitutional.pl"
)
BANNED_DEPS_PATH = REPO_ROOT / ".claude" / "banned-libraries.txt"

_RUL_ID_RE = re.compile(r"^(RUL-\d+)")
_ENFORCEMENT_HEADING_RE = re.compile(r"^##\s+Enforcement\s*$")
_ANY_HEADING_RE = re.compile(r"^#{1,6}\s")
_BULLET_RE = re.compile(r"^-\s+(.*\S)\s*$")


@dataclass
class MechanismResult:
    ruling_id: str
    directive: str
    ok: bool
    detail: str
    judgment: bool = False


@dataclass
class RulingResult:
    ruling_id: str
    path: Path
    mechanisms: list[MechanismResult] = field(default_factory=list)

    @property
    def ok(self) -> bool:
        return bool(self.mechanisms) and all(m.ok for m in self.mechanisms)

    @property
    def is_judgment_only(self) -> bool:
        return bool(self.mechanisms) and all(m.judgment for m in self.mechanisms)


def _default_rulings_dir() -> Path:
    """Resolve the rulings directory at call time (RUL-003: no bare
    ``os.environ[...]``, no import-time constant)."""
    configured = os.environ.get("RULINGS_DIR", "")
    if configured:
        return Path(configured).expanduser()
    return Path.home() / "workspace" / "knowledge-wiki" / "rulings"


def _extract_enforcement_lines(text: str) -> list[str]:
    """Return the bullet lines under the ``## Enforcement`` heading."""
    lines = text.splitlines()
    collecting = False
    out: list[str] = []
    for line in lines:
        if _ENFORCEMENT_HEADING_RE.match(line):
            collecting = True
            continue
        if collecting:
            if _ANY_HEADING_RE.match(line):
                break
            m = _BULLET_RE.match(line)
            if m:
                out.append(m.group(1).strip())
    return out


def _resolve_git_hooks_path(rel: str) -> Path:
    """Resolve a ``.git/hooks/...``-relative directive against the ACTUAL
    hooks directory, via ``git rev-parse --git-path hooks``.

    In a git *worktree*, ``<worktree>/.git`` is a gitdir POINTER FILE, not a
    directory — hooks are never per-worktree, they live once in the main
    repo's ``.git/hooks`` (shared across every worktree). A literal
    ``REPO_ROOT / ".git" / "hooks" / ...`` join therefore false-fails this
    check inside any worktree, even though the hook is installed and would
    fire normally. ``git rev-parse --git-path`` is the cwd-aware,
    worktree-correct way to ask git where a git-dir-relative path actually
    resolves, in both a normal checkout and a worktree.
    """
    suffix = rel[len(".git/") :]
    result = subprocess.run(
        ["git", "rev-parse", "--git-path", suffix],
        cwd=REPO_ROOT,
        capture_output=True,
        text=True,
        check=True,
    )
    return (REPO_ROOT / result.stdout.strip()).resolve()


def _check_hook(directive_args: str) -> tuple[bool, str]:
    rel = directive_args.strip()
    if rel.startswith(".git/"):
        try:
            path = _resolve_git_hooks_path(rel)
        except (subprocess.CalledProcessError, OSError) as exc:
            return False, f"could not resolve git-path for {rel}: {exc}"
    else:
        path = REPO_ROOT / rel
    if not path.exists():
        return False, f"hook path does not exist: {rel}"
    if not os.access(path, os.X_OK):
        return False, f"hook path exists but is not executable: {rel}"
    return True, f"hook exists + executable: {rel}"


def _check_gate(directive_args: str) -> tuple[bool, str]:
    parts = directive_args.split(None, 1)
    if len(parts) != 2:
        return (
            False,
            f"malformed gate directive (need '<script> <rule-id>'): {directive_args!r}",
        )
    script_rel, rule_id = parts
    script_path = REPO_ROOT / script_rel
    if not script_path.exists():
        return False, f"gate script does not exist: {script_rel}"
    try:
        text = script_path.read_text(encoding="utf-8", errors="replace")
    except OSError as exc:
        return False, f"gate script unreadable: {script_rel} ({exc})"
    if rule_id not in text:
        return False, f"rule-id {rule_id!r} not found in {script_rel}"
    return True, f"gate {script_rel} contains rule-id {rule_id!r}"


def _check_banned_deps(directive_args: str) -> tuple[bool, str]:
    name = directive_args.strip()
    if not BANNED_DEPS_PATH.exists():
        return False, f"banned-libraries file missing: {BANNED_DEPS_PATH}"
    lines = BANNED_DEPS_PATH.read_text(encoding="utf-8").splitlines()
    entries = {
        ln.strip().lower()
        for ln in lines
        if ln.strip() and not ln.strip().startswith("#")
    }
    if name.lower() not in entries:
        return False, f"{name!r} is not an entry in .claude/banned-libraries.txt"
    return True, f"{name!r} is a banned-libraries.txt entry"


def _check_constitution(directive_args: str) -> tuple[bool, str]:
    article = directive_args.strip()
    if not CONSTITUTION_PATH.exists():
        return False, f"constitution rulebase missing: {CONSTITUTION_PATH}"
    text = CONSTITUTION_PATH.read_text(encoding="utf-8", errors="replace")
    if article not in text:
        return (
            False,
            f"{article!r} not found in {CONSTITUTION_PATH.relative_to(REPO_ROOT)}",
        )
    return True, f"{article!r} found in {CONSTITUTION_PATH.relative_to(REPO_ROOT)}"


def _check_none(directive_args: str) -> tuple[bool, str]:
    if "judgment" in directive_args.lower():
        return True, "explicit judgment-only mark"
    return (
        False,
        f"'none' directive must read 'none (judgment)', got: {directive_args!r}",
    )


_DISPATCH = {
    "hook": _check_hook,
    "gate": _check_gate,
    "banned-deps": _check_banned_deps,
    "constitution": _check_constitution,
    "none": _check_none,
}


def evaluate_directive(ruling_id: str, directive: str) -> MechanismResult:
    parts = directive.split(None, 1)
    if not parts:
        return MechanismResult(ruling_id, directive, False, "empty directive")
    kind = parts[0]
    args = parts[1] if len(parts) > 1 else ""
    checker = _DISPATCH.get(kind)
    if checker is None:
        return MechanismResult(
            ruling_id,
            directive,
            False,
            f"unknown enforcement kind {kind!r} (expected hook|gate|banned-deps|constitution|none)",
        )
    ok, detail = checker(args)
    return MechanismResult(
        ruling_id, directive, ok, detail, judgment=(kind == "none" and ok)
    )


def evaluate_ruling_file(path: Path) -> RulingResult | None:
    m = _RUL_ID_RE.match(path.stem)
    if not m:
        return None
    ruling_id = m.group(1)
    text = path.read_text(encoding="utf-8", errors="replace")
    directives = _extract_enforcement_lines(text)
    result = RulingResult(ruling_id=ruling_id, path=path)
    if not directives:
        result.mechanisms.append(
            MechanismResult(
                ruling_id, "", False, "no '## Enforcement' section / no bullets found"
            )
        )
        return result
    for directive in directives:
        result.mechanisms.append(evaluate_directive(ruling_id, directive))
    return result


def lint(rulings_dir: Path) -> tuple[int, dict[str, Any]]:
    """Run the lint over ``rulings_dir``. Returns (exit_code, report_dict).

    exit_code is 0 when ``rulings_dir`` doesn't exist (SKIP) or every
    ruling's every declared mechanism checks out; 1 when any mechanism is
    missing.
    """
    if not rulings_dir.exists():
        report: dict[str, Any] = {
            "status": "skipped",
            "reason": f"RULINGS_DIR not found: {rulings_dir}",
            "rulings": [],
        }
        return 0, report

    ruling_files = sorted(rulings_dir.glob("RUL-*.md"))
    results = [
        r for r in (evaluate_ruling_file(p) for p in ruling_files) if r is not None
    ]

    all_ok = all(r.ok for r in results)
    # The report is a heterogeneous JSON-shaped structure (str/int/nested
    # lists of dicts per key) — `dict[str, Any]` (declared on the earlier
    # branch's `report` above) is the honest type here, not a laziness
    # shortcut; a plain `dict` literal makes mypy try to unify all values
    # to one common type and fail (see the dict-item / list-comprehension
    # errors this replaces).
    report = {
        "status": "pass" if all_ok else "fail",
        "rulings_dir": str(rulings_dir),
        "count": len(results),
        "judgment_only": [r.ruling_id for r in results if r.is_judgment_only],
        "failures": [
            {
                "ruling_id": r.ruling_id,
                "path": str(r.path),
                "mechanisms": [
                    {"directive": m.directive, "detail": m.detail}
                    for m in r.mechanisms
                    if not m.ok
                ],
            }
            for r in results
            if not r.ok
        ],
        "rulings": [
            {
                "ruling_id": r.ruling_id,
                "ok": r.ok,
                "judgment_only": r.is_judgment_only,
                "mechanisms": [
                    {"directive": m.directive, "ok": m.ok, "detail": m.detail}
                    for m in r.mechanisms
                ],
            }
            for r in results
        ],
    }
    return (0 if all_ok else 1), report


def _print_text_report(report: dict) -> None:
    if report["status"] == "skipped":
        print(f"[rulings-lint] ⚠ SKIP — {report['reason']}", flush=True)
        return

    print(f"[rulings-lint] rulings_dir={report['rulings_dir']}", flush=True)
    print(f"[rulings-lint] checked {report['count']} ruling(s)", flush=True)

    if report["judgment_only"]:
        print(
            f"[rulings-lint] judgment-only ({len(report['judgment_only'])}): "
            + ", ".join(report["judgment_only"]),
            flush=True,
        )

    if report["status"] == "pass":
        print("[rulings-lint] ✅ all declared mechanisms verified", flush=True)
        return

    print(
        f"[rulings-lint] ❌ {len(report['failures'])} ruling(s) with missing mechanism(s):",
        flush=True,
    )
    for failure in report["failures"]:
        print(f"  {failure['ruling_id']} ({failure['path']}):", flush=True)
        for mech in failure["mechanisms"]:
            print(f"    - {mech['directive']!r}: {mech['detail']}", flush=True)


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--rulings-dir",
        default=None,
        help="Override the rulings directory (default: $RULINGS_DIR or ~/workspace/knowledge-wiki/rulings)",
    )
    parser.add_argument("--json", action="store_true", help="Machine-readable output")
    args = parser.parse_args(argv)

    rulings_dir = (
        Path(args.rulings_dir).expanduser()
        if args.rulings_dir
        else _default_rulings_dir()
    )
    exit_code, report = lint(rulings_dir)

    if args.json:
        print(json.dumps(report, indent=2, default=str))
    else:
        _print_text_report(report)

    return exit_code


if __name__ == "__main__":
    sys.exit(main())