obey-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- obey_cli/__init__.py +3 -0
- obey_cli/__main__.py +3 -0
- obey_cli/_data/demo/README.md +12 -0
- obey_cli/_data/demo/obey.yaml +287 -0
- obey_cli/_data/demo/repo/.gitignore +8 -0
- obey_cli/_data/demo/repo/AGENTS.md +38 -0
- obey_cli/_data/demo/repo/README.md +36 -0
- obey_cli/_data/demo/repo/eslint.config.js +24 -0
- obey_cli/_data/demo/repo/index.html +21 -0
- obey_cli/_data/demo/repo/openapi.yaml +86 -0
- obey_cli/_data/demo/repo/package.json +26 -0
- obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
- obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
- obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
- obey_cli/_data/demo/repo/src/api/client.ts +71 -0
- obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
- obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
- obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
- obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
- obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
- obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
- obey_cli/_data/demo/repo/src/logger.ts +36 -0
- obey_cli/_data/demo/repo/src/main.ts +30 -0
- obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
- obey_cli/_data/demo/repo/src/render-table.ts +48 -0
- obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
- obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
- obey_cli/_data/demo/repo/tsconfig.json +20 -0
- obey_cli/_data/demo/repo/vitest.config.ts +8 -0
- obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/meta.json +10 -0
- obey_cli/_data/schemas/obey-config-v1.json +116 -0
- obey_cli/_data/schemas/report-v1.json +55 -0
- obey_cli/_data/schemas/survey-record-v1.json +30 -0
- obey_cli/checkers/__init__.py +30 -0
- obey_cli/checkers/samples.py +95 -0
- obey_cli/cli.py +201 -0
- obey_cli/commands.py +676 -0
- obey_cli/config.py +90 -0
- obey_cli/discover.py +232 -0
- obey_cli/enforce/__init__.py +114 -0
- obey_cli/enforce/engine/__init__.py +0 -0
- obey_cli/enforce/engine/obey_engine.py +2005 -0
- obey_cli/enforce/merge.py +196 -0
- obey_cli/enforce/targets/__init__.py +354 -0
- obey_cli/enforce/templates/obey_hook.py +452 -0
- obey_cli/enforce/templates/pre_commit.py +116 -0
- obey_cli/extract.py +1342 -0
- obey_cli/lint.py +125 -0
- obey_cli/llm.py +129 -0
- obey_cli/parse_md.py +260 -0
- obey_cli/probes/__init__.py +0 -0
- obey_cli/probes/facts.py +162 -0
- obey_cli/probes/templates.py +326 -0
- obey_cli/py.typed +0 -0
- obey_cli/report/__init__.py +314 -0
- obey_cli/resources.py +35 -0
- obey_cli/rules.py +75 -0
- obey_cli/runner/__init__.py +0 -0
- obey_cli/runner/agents/__init__.py +22 -0
- obey_cli/runner/agents/base.py +96 -0
- obey_cli/runner/agents/claude_code.py +203 -0
- obey_cli/runner/agents/codex.py +152 -0
- obey_cli/runner/agents/copilot.py +142 -0
- obey_cli/runner/agents/gemini.py +125 -0
- obey_cli/runner/agents/opencode.py +156 -0
- obey_cli/runner/core.py +140 -0
- obey_cli/runner/egress_proxy.py +166 -0
- obey_cli/runner/gateway.py +529 -0
- obey_cli/runner/ledger.py +132 -0
- obey_cli/runner/local.py +176 -0
- obey_cli/runner/orchestrate.py +227 -0
- obey_cli/runner/sandbox/Dockerfile +41 -0
- obey_cli/runner/sandbox/entrypoint.sh +38 -0
- obey_cli/runner/sandbox.py +341 -0
- obey_cli/runner/sidecar.py +38 -0
- obey_cli/runner/workspace.py +187 -0
- obey_cli/score.py +150 -0
- obey_cli/study/__init__.py +0 -0
- obey_cli/study/analysis.py +277 -0
- obey_cli/study/design.py +98 -0
- obey_cli/study/experiment.py +198 -0
- obey_cli/study/report.py +519 -0
- obey_cli/study/survey.py +438 -0
- obey_cli-0.1.0.dist-info/METADATA +393 -0
- obey_cli-0.1.0.dist-info/RECORD +90 -0
- obey_cli-0.1.0.dist-info/WHEEL +4 -0
- obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
- obey_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
obey_cli/config.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Read, validate and write obey.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from obey_cli.resources import schema
|
|
13
|
+
|
|
14
|
+
CONFIG_NAME = "obey.yaml"
|
|
15
|
+
DEFAULT_ALLOW_HOSTS = [
|
|
16
|
+
"registry.npmjs.org",
|
|
17
|
+
"pypi.org",
|
|
18
|
+
"files.pythonhosted.org",
|
|
19
|
+
"proxy.golang.org",
|
|
20
|
+
"sum.golang.org",
|
|
21
|
+
"crates.io",
|
|
22
|
+
"static.crates.io",
|
|
23
|
+
"index.crates.io",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigError(ValueError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def normalise_text(text: str) -> str:
|
|
32
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def rule_id(text: str) -> str:
|
|
36
|
+
"""A stable slug of the rule text plus a short hash, e.g. `use-pnpm-for-all-package-operations-3f9a1c`."""
|
|
37
|
+
norm = normalise_text(text)
|
|
38
|
+
slug = re.sub(r"[^a-z0-9]+", "-", norm.lower()).strip("-")[:40].strip("-") or "rule"
|
|
39
|
+
return f"{slug}-{hashlib.sha1(norm.encode()).hexdigest()[:6]}" # noqa: S324 - an identifier, not security
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate(data: Any) -> list[str]:
|
|
43
|
+
import jsonschema
|
|
44
|
+
|
|
45
|
+
validator = jsonschema.Draft202012Validator(schema("obey-config-v1.json"))
|
|
46
|
+
errors = []
|
|
47
|
+
for err in sorted(validator.iter_errors(data), key=lambda e: list(e.absolute_path)):
|
|
48
|
+
where = "/".join(str(p) for p in err.absolute_path) or "(root)"
|
|
49
|
+
errors.append(f"{where}: {err.message}")
|
|
50
|
+
ids = [r.get("id") for r in (data.get("rules") or [])] if isinstance(data, dict) else []
|
|
51
|
+
dupes = sorted({i for i in ids if ids.count(i) > 1})
|
|
52
|
+
errors += [f"rules: duplicate id {d}" for d in dupes]
|
|
53
|
+
return errors
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load(path: Path) -> dict[str, Any]:
|
|
57
|
+
try:
|
|
58
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
59
|
+
except FileNotFoundError as exc:
|
|
60
|
+
raise ConfigError(f"{path} not found; run `obey extract` first") from exc
|
|
61
|
+
except yaml.YAMLError as exc:
|
|
62
|
+
raise ConfigError(f"{path}: invalid YAML: {exc}") from exc
|
|
63
|
+
errors = validate(data)
|
|
64
|
+
if errors:
|
|
65
|
+
raise ConfigError(f"{path}: " + "; ".join(errors[:10]))
|
|
66
|
+
assert isinstance(data, dict)
|
|
67
|
+
return data
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _Dumper(yaml.SafeDumper):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _str_presenter(dumper: yaml.SafeDumper, value: str) -> yaml.ScalarNode:
|
|
75
|
+
style = "|" if "\n" in value else None
|
|
76
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", value, style=style)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_Dumper.add_representer(str, _str_presenter)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def dump(data: dict[str, Any]) -> str:
|
|
83
|
+
return yaml.dump(data, Dumper=_Dumper, sort_keys=False, allow_unicode=True, width=120)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def save(path: Path, data: dict[str, Any]) -> None:
|
|
87
|
+
errors = validate(data)
|
|
88
|
+
if errors:
|
|
89
|
+
raise ConfigError("refusing to write an invalid obey.yaml: " + "; ".join(errors[:10]))
|
|
90
|
+
path.write_text(dump(data), encoding="utf-8")
|
obey_cli/discover.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Find the agent instruction files in a repository and work out what each one applies to."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from obey_cli.parse_md import ParsedFile, parse
|
|
12
|
+
|
|
13
|
+
SKIP_DIRS = {
|
|
14
|
+
".git",
|
|
15
|
+
"node_modules",
|
|
16
|
+
".venv",
|
|
17
|
+
"venv",
|
|
18
|
+
"__pycache__",
|
|
19
|
+
".tox",
|
|
20
|
+
".mypy_cache",
|
|
21
|
+
".ruff_cache",
|
|
22
|
+
".pytest_cache",
|
|
23
|
+
"dist",
|
|
24
|
+
"build",
|
|
25
|
+
"target",
|
|
26
|
+
".obey",
|
|
27
|
+
".obey-work",
|
|
28
|
+
}
|
|
29
|
+
MAX_IMPORT_DEPTH = 4
|
|
30
|
+
|
|
31
|
+
# Which agents read which file kinds, as documented by each vendor (see docs/agents.md).
|
|
32
|
+
LOADED_BY: dict[str, tuple[str, ...]] = {
|
|
33
|
+
"agents_md": ("claude-code", "codex", "opencode", "cursor", "gemini", "copilot"),
|
|
34
|
+
"claude_md": ("claude-code", "opencode"),
|
|
35
|
+
"claude_import": ("claude-code",),
|
|
36
|
+
"claude_local_md": ("claude-code",),
|
|
37
|
+
"gemini_md": ("gemini",),
|
|
38
|
+
"cursor_mdc": ("cursor",),
|
|
39
|
+
"copilot_repo": ("copilot",),
|
|
40
|
+
"copilot_path": ("copilot",),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# How each agent resolves several AGENTS.md files on the way from the repository root to the edited file.
|
|
44
|
+
NESTED_SEMANTICS: dict[str, str] = {
|
|
45
|
+
"codex": "concatenates AGENTS.md from the repository root down to the working directory; later files win",
|
|
46
|
+
"opencode": "reads the nearest AGENTS.md walking up from the working directory",
|
|
47
|
+
"claude-code": "loads CLAUDE.md (or AGENTS.md) from the working directory upwards at start and nested files "
|
|
48
|
+
"when it reads files in their directories",
|
|
49
|
+
"cursor": "applies the nearest AGENTS.md to files below its directory",
|
|
50
|
+
"gemini": "loads context files from the working directory upwards and from subdirectories",
|
|
51
|
+
"copilot": "uses the nearest AGENTS.md in the directory tree",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class InstructionFile:
|
|
57
|
+
path: str # repository-relative POSIX path
|
|
58
|
+
kind: str
|
|
59
|
+
scope_paths: list[str]
|
|
60
|
+
loaded_by: tuple[str, ...]
|
|
61
|
+
sha256: str
|
|
62
|
+
parsed: ParsedFile
|
|
63
|
+
imported_from: str | None = None
|
|
64
|
+
activation: str = "always" # always | globs | agent_requested | manual
|
|
65
|
+
notes: list[str] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def classify(rel: str) -> str | None:
|
|
69
|
+
p = PurePosixPath(rel)
|
|
70
|
+
name = p.name
|
|
71
|
+
parts = p.parts
|
|
72
|
+
if name == "AGENTS.md":
|
|
73
|
+
return "agents_md"
|
|
74
|
+
if name == "CLAUDE.md":
|
|
75
|
+
return "claude_md"
|
|
76
|
+
if name == "CLAUDE.local.md":
|
|
77
|
+
return "claude_local_md"
|
|
78
|
+
if name == "GEMINI.md":
|
|
79
|
+
return "gemini_md"
|
|
80
|
+
if name.endswith(".mdc") and len(parts) >= 3 and parts[-3:-1] == (".cursor", "rules"):
|
|
81
|
+
return "cursor_mdc"
|
|
82
|
+
if rel == ".github/copilot-instructions.md":
|
|
83
|
+
return "copilot_repo"
|
|
84
|
+
if name.endswith(".instructions.md") and parts[:2] == (".github", "instructions"):
|
|
85
|
+
return "copilot_path"
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def list_files(root: Path) -> list[str]:
|
|
90
|
+
"""Repository-relative paths, from `git ls-files` when possible so ignored files never count."""
|
|
91
|
+
if (root / ".git").exists():
|
|
92
|
+
try:
|
|
93
|
+
out = subprocess.run(
|
|
94
|
+
["git", "-C", str(root), "ls-files", "-co", "--exclude-standard", "-z"],
|
|
95
|
+
capture_output=True,
|
|
96
|
+
check=True,
|
|
97
|
+
timeout=60,
|
|
98
|
+
).stdout.decode("utf-8", "replace")
|
|
99
|
+
return sorted(p for p in out.split("\0") if p and not _skipped(p))
|
|
100
|
+
except (OSError, subprocess.SubprocessError):
|
|
101
|
+
pass
|
|
102
|
+
found: list[str] = []
|
|
103
|
+
for path in root.rglob("*"):
|
|
104
|
+
rel = path.relative_to(root).as_posix()
|
|
105
|
+
if path.is_file() and not _skipped(rel):
|
|
106
|
+
found.append(rel)
|
|
107
|
+
return sorted(found)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _skipped(rel: str) -> bool:
|
|
111
|
+
return any(part in SKIP_DIRS for part in PurePosixPath(rel).parts[:-1])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _scope_for_dir(directory: str) -> list[str]:
|
|
115
|
+
return ["**"] if directory in ("", ".") else [f"{directory}/**"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _split_globs(value: Any) -> list[str]:
|
|
119
|
+
if value is None or value == "":
|
|
120
|
+
return []
|
|
121
|
+
if isinstance(value, list):
|
|
122
|
+
items = [str(v) for v in value]
|
|
123
|
+
else:
|
|
124
|
+
items = [g for g in (s.strip() for s in str(value).split(",")) if g]
|
|
125
|
+
return [g.strip().strip("\"'") for g in items if g.strip()]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _anchor(globs: list[str], base: str) -> list[str]:
|
|
129
|
+
if base in ("", "."):
|
|
130
|
+
return globs
|
|
131
|
+
return [g if g.startswith(base + "/") else f"{base}/{g}" for g in globs]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _sha256(data: bytes) -> str:
|
|
135
|
+
import hashlib
|
|
136
|
+
|
|
137
|
+
return hashlib.sha256(data).hexdigest()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def discover(root: Path, include_local: bool = False) -> list[InstructionFile]:
|
|
141
|
+
"""Return every instruction file under `root`, with CLAUDE.md imports resolved."""
|
|
142
|
+
root = root.resolve()
|
|
143
|
+
result: list[InstructionFile] = []
|
|
144
|
+
seen: set[str] = set()
|
|
145
|
+
for rel in list_files(root):
|
|
146
|
+
kind = classify(rel)
|
|
147
|
+
if kind is None or rel in seen or (kind == "claude_local_md" and not include_local):
|
|
148
|
+
continue
|
|
149
|
+
data = (root / rel).read_bytes()
|
|
150
|
+
parsed = parse(data.decode("utf-8", "replace"))
|
|
151
|
+
directory = PurePosixPath(rel).parent.as_posix()
|
|
152
|
+
activation = "always"
|
|
153
|
+
notes: list[str] = []
|
|
154
|
+
if kind == "cursor_mdc":
|
|
155
|
+
base = PurePosixPath(rel).parent.parent.parent.as_posix()
|
|
156
|
+
globs = _split_globs(parsed.frontmatter.get("globs"))
|
|
157
|
+
always = str(parsed.frontmatter.get("alwaysApply", "")).lower() == "true"
|
|
158
|
+
if always:
|
|
159
|
+
scope = _scope_for_dir(base)
|
|
160
|
+
elif globs:
|
|
161
|
+
scope, activation = _anchor(globs, base), "globs"
|
|
162
|
+
elif parsed.frontmatter.get("description"):
|
|
163
|
+
scope, activation = _scope_for_dir(base), "agent_requested"
|
|
164
|
+
notes.append("applied when the agent decides the description is relevant")
|
|
165
|
+
else:
|
|
166
|
+
scope, activation = _scope_for_dir(base), "manual"
|
|
167
|
+
notes.append("applied only when referenced with @rule")
|
|
168
|
+
elif kind == "copilot_path":
|
|
169
|
+
globs = _split_globs(parsed.frontmatter.get("applyTo"))
|
|
170
|
+
scope = globs or ["**"]
|
|
171
|
+
activation = "globs" if globs else "always"
|
|
172
|
+
elif kind == "copilot_repo" or (directory == ".claude" and kind == "claude_md"):
|
|
173
|
+
scope = ["**"]
|
|
174
|
+
else:
|
|
175
|
+
scope = _scope_for_dir(directory)
|
|
176
|
+
item = InstructionFile(
|
|
177
|
+
path=rel,
|
|
178
|
+
kind=kind,
|
|
179
|
+
scope_paths=scope,
|
|
180
|
+
loaded_by=LOADED_BY[kind],
|
|
181
|
+
sha256=_sha256(data),
|
|
182
|
+
parsed=parsed,
|
|
183
|
+
activation=activation,
|
|
184
|
+
notes=notes,
|
|
185
|
+
)
|
|
186
|
+
result.append(item)
|
|
187
|
+
seen.add(rel)
|
|
188
|
+
if kind in ("claude_md", "claude_local_md", "agents_md"):
|
|
189
|
+
# Claude Code expands @imports in CLAUDE.md and, when it reads AGENTS.md, in AGENTS.md too.
|
|
190
|
+
result.extend(_resolve_imports(root, item, seen, depth=1))
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _resolve_imports(root: Path, parent: InstructionFile, seen: set[str], depth: int) -> list[InstructionFile]:
|
|
195
|
+
if depth > MAX_IMPORT_DEPTH:
|
|
196
|
+
return []
|
|
197
|
+
out: list[InstructionFile] = []
|
|
198
|
+
base = PurePosixPath(parent.path).parent
|
|
199
|
+
for target, _line in parent.parsed.imports:
|
|
200
|
+
if target.startswith("~") or target.startswith("/"):
|
|
201
|
+
parent.notes.append(f"import @{target} points outside the repository; not followed")
|
|
202
|
+
continue
|
|
203
|
+
rel = (base / target).as_posix()
|
|
204
|
+
resolved = (root / rel).resolve()
|
|
205
|
+
try:
|
|
206
|
+
rel = resolved.relative_to(root).as_posix()
|
|
207
|
+
except ValueError:
|
|
208
|
+
parent.notes.append(f"import @{target} points outside the repository; not followed")
|
|
209
|
+
continue
|
|
210
|
+
if rel in seen:
|
|
211
|
+
continue
|
|
212
|
+
if not resolved.is_file():
|
|
213
|
+
parent.notes.append(f"import @{target} not found")
|
|
214
|
+
continue
|
|
215
|
+
data = resolved.read_bytes()
|
|
216
|
+
child = InstructionFile(
|
|
217
|
+
path=rel,
|
|
218
|
+
kind="claude_import",
|
|
219
|
+
scope_paths=list(parent.scope_paths),
|
|
220
|
+
loaded_by=LOADED_BY["claude_import"],
|
|
221
|
+
sha256=_sha256(data),
|
|
222
|
+
parsed=parse(data.decode("utf-8", "replace")),
|
|
223
|
+
imported_from=parent.path,
|
|
224
|
+
)
|
|
225
|
+
seen.add(rel)
|
|
226
|
+
out.append(child)
|
|
227
|
+
out.extend(_resolve_imports(root, child, seen, depth + 1))
|
|
228
|
+
return out
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def scope_matches(scope_paths: list[str], path: str) -> bool:
|
|
232
|
+
return any(fnmatch.fnmatchcase(path, g) or (g.endswith("/**") and path.startswith(g[:-2])) for g in scope_paths)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Compile rules into hooks for each agent plus an agent-agnostic pre-commit check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Iterable, Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from obey_cli import __version__
|
|
12
|
+
from obey_cli.enforce.merge import Plan
|
|
13
|
+
from obey_cli.enforce.targets import EDIT_TYPES, SHELL_TYPES, STOP_TYPES, TARGETS
|
|
14
|
+
|
|
15
|
+
HOOK_DIR = ".obey/hooks"
|
|
16
|
+
TEMPLATES = Path(__file__).parent / "templates"
|
|
17
|
+
ENGINE = Path(__file__).parent / "engine" / "obey_engine.py"
|
|
18
|
+
MECHANICAL = SHELL_TYPES | EDIT_TYPES | STOP_TYPES
|
|
19
|
+
GITIGNORE = "backup/\nledger.jsonl\nruns/\nreport.*\n"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def compiled(rules: Sequence[dict[str, Any]]) -> dict[str, Any]:
|
|
23
|
+
body = [{"id": r["id"], "text": r["text"], "source": r["source"], "checker": r["checker"]} for r in rules]
|
|
24
|
+
digest = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
|
|
25
|
+
return {"obey_version": __version__, "rules_sha256": digest, "rules": body}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def select(
|
|
29
|
+
config: dict[str, Any],
|
|
30
|
+
*,
|
|
31
|
+
report: dict[str, Any] | None = None,
|
|
32
|
+
ids: Iterable[str] | None = None,
|
|
33
|
+
threshold: float = 1.0,
|
|
34
|
+
) -> list[dict[str, Any]]:
|
|
35
|
+
"""Active rules with a mechanical checker; with a report, only those broken in some exercised run."""
|
|
36
|
+
active = [
|
|
37
|
+
r
|
|
38
|
+
for r in config.get("rules", [])
|
|
39
|
+
if r.get("status") == "active" and r.get("checker") and r["checker"]["type"] in MECHANICAL
|
|
40
|
+
]
|
|
41
|
+
if ids is not None:
|
|
42
|
+
wanted = set(ids)
|
|
43
|
+
return [r for r in active if r["id"] in wanted]
|
|
44
|
+
if report is None:
|
|
45
|
+
return active
|
|
46
|
+
broken = {
|
|
47
|
+
s["rule_id"]
|
|
48
|
+
for s in report.get("scores", [])
|
|
49
|
+
if s.get("arm") == "rule" and s.get("compliance") is not None and s["compliance"] < threshold
|
|
50
|
+
}
|
|
51
|
+
return [r for r in active if r["id"] in broken]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def advisory(rules: Sequence[dict[str, Any]], targets: Sequence[str]) -> list[dict[str, str]]:
|
|
55
|
+
"""Rules a target cannot enforce mechanically (listed as advisory only)."""
|
|
56
|
+
out = []
|
|
57
|
+
for r in rules:
|
|
58
|
+
kind = r["checker"]["type"]
|
|
59
|
+
for name in targets:
|
|
60
|
+
t = TARGETS[name]
|
|
61
|
+
stop_only = kind in STOP_TYPES and kind not in (SHELL_TYPES | EDIT_TYPES)
|
|
62
|
+
if (stop_only and not t.stop and name != "precommit") or (
|
|
63
|
+
name == "precommit" and kind in ("command_required", "final_message", "no_push", "no_commit")
|
|
64
|
+
):
|
|
65
|
+
out.append({"rule_id": r["id"], "target": name, "reason": f"{t.display} cannot block {kind}"})
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def plan_enforce(root: Path, rules: Sequence[dict[str, Any]], targets: Sequence[str]) -> Plan:
|
|
70
|
+
plan = Plan(root)
|
|
71
|
+
plan.put(f"{HOOK_DIR}/obey_hook.py", (TEMPLATES / "obey_hook.py").read_text(encoding="utf-8"))
|
|
72
|
+
plan.put(f"{HOOK_DIR}/obey_engine.py", ENGINE.read_text(encoding="utf-8"))
|
|
73
|
+
plan.put(f"{HOOK_DIR}/rules.json", json.dumps(compiled(rules), indent=2) + "\n")
|
|
74
|
+
plan.put(".obey/.gitignore", GITIGNORE)
|
|
75
|
+
if "precommit" in targets:
|
|
76
|
+
plan.put(".obey/pre-commit", (TEMPLATES / "pre_commit.py").read_text(encoding="utf-8"))
|
|
77
|
+
elif (root / ".obey/pre-commit").exists():
|
|
78
|
+
plan.put(".obey/pre-commit", None)
|
|
79
|
+
for name in targets:
|
|
80
|
+
TARGETS[name].write(plan, rules)
|
|
81
|
+
for name, target in TARGETS.items():
|
|
82
|
+
if name not in targets:
|
|
83
|
+
target.remove(plan)
|
|
84
|
+
return plan
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def plan_remove(root: Path) -> Plan:
|
|
88
|
+
plan = Plan(root)
|
|
89
|
+
for target in TARGETS.values():
|
|
90
|
+
target.remove(plan)
|
|
91
|
+
for rel in (f"{HOOK_DIR}/obey_hook.py", f"{HOOK_DIR}/obey_engine.py", f"{HOOK_DIR}/rules.json", ".obey/pre-commit"):
|
|
92
|
+
if (root / rel).exists():
|
|
93
|
+
plan.put(rel, None)
|
|
94
|
+
return plan
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def in_sync(root: Path, rules: Sequence[dict[str, Any]], targets: Sequence[str]) -> list[str]:
|
|
98
|
+
"""Files whose content differs from what `obey enforce` would write now (empty when in sync)."""
|
|
99
|
+
plan = plan_enforce(root, rules, targets)
|
|
100
|
+
return sorted(plan.changes)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def installed_targets(root: Path) -> list[str]:
|
|
104
|
+
found = []
|
|
105
|
+
for name, target in TARGETS.items():
|
|
106
|
+
for rel in target.files:
|
|
107
|
+
path = root / rel
|
|
108
|
+
if path.exists() and ".obey/hooks/obey_hook.py" in path.read_text(encoding="utf-8", errors="replace"):
|
|
109
|
+
found.append(name)
|
|
110
|
+
break
|
|
111
|
+
if name == "precommit" and path.exists():
|
|
112
|
+
found.append(name)
|
|
113
|
+
break
|
|
114
|
+
return found
|
|
File without changes
|