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
Dependency diagram
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())