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/study/survey.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Survey of public AGENTS.md files.
|
|
2
|
+
|
|
3
|
+
Sampling: GitHub code search for root-level AGENTS.md files, sliced by file size (one search returns at most
|
|
4
|
+
1,000 results), one random results page per slice, forks dropped, then a seeded uniform sample of repositories.
|
|
5
|
+
The dataset keeps URLs, commit SHAs, licence ids and derived features only: file contents are fetched at the
|
|
6
|
+
pinned commit, analysed in memory and never stored, because their licences vary.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import random
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable, Iterator
|
|
16
|
+
from dataclasses import asdict, dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
API = "https://api.github.com"
|
|
24
|
+
RAW = "https://raw.githubusercontent.com"
|
|
25
|
+
SEARCH_INTERVAL_S = 6.5 # code search allows 10 requests per minute
|
|
26
|
+
CORE_INTERVAL_S = 0.25
|
|
27
|
+
SEED = 20261023
|
|
28
|
+
|
|
29
|
+
# Paths whose presence means the repository already enforces something mechanically.
|
|
30
|
+
ENFORCEMENT_MARKERS: dict[str, tuple[str, ...]] = {
|
|
31
|
+
"pre_commit": (".pre-commit-config.yaml", ".pre-commit-config.yml"),
|
|
32
|
+
"husky": (".husky/",),
|
|
33
|
+
"lefthook": ("lefthook.yml", "lefthook.yaml", ".lefthook.yml"),
|
|
34
|
+
"codex_hooks": (".codex/hooks.json",),
|
|
35
|
+
"cursor_hooks": (".cursor/hooks.json",),
|
|
36
|
+
"copilot_hooks": (".github/hooks/",),
|
|
37
|
+
"opencode_plugins": (".opencode/plugins/", ".opencode/plugin/"),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def size_slices(count: int = 40, low: int = 120, high: int = 65536) -> list[tuple[int, int]]:
|
|
42
|
+
"""Geometric file-size ranges in bytes, so every slice holds a similar share of files."""
|
|
43
|
+
edges = [round(low * (high / low) ** (i / count)) for i in range(count + 1)]
|
|
44
|
+
return [(edges[i], edges[i + 1] - 1) for i in range(count)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class SampleRecord:
|
|
49
|
+
"""One surveyed repository. No field holds file contents (tests assert this)."""
|
|
50
|
+
|
|
51
|
+
repo: str
|
|
52
|
+
url: str
|
|
53
|
+
commit_sha: str
|
|
54
|
+
path: str
|
|
55
|
+
blob_sha: str
|
|
56
|
+
license_spdx: str | None
|
|
57
|
+
stars: int
|
|
58
|
+
default_branch: str
|
|
59
|
+
size_slice: str
|
|
60
|
+
tree_truncated: bool
|
|
61
|
+
has_claude_md: bool
|
|
62
|
+
has_gemini_md: bool
|
|
63
|
+
has_cursor_rules: bool
|
|
64
|
+
has_copilot_instructions: bool
|
|
65
|
+
claude_settings_hooks: bool
|
|
66
|
+
gemini_settings_hooks: bool
|
|
67
|
+
enforcement: dict[str, bool] = field(default_factory=dict)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class GitHub:
|
|
71
|
+
def __init__(self, token: str, client: httpx.Client | None = None, sleep: Callable[[float], None] = time.sleep):
|
|
72
|
+
import httpx
|
|
73
|
+
|
|
74
|
+
self._client = client or httpx.Client(timeout=30.0, follow_redirects=True)
|
|
75
|
+
self._headers = {
|
|
76
|
+
"Authorization": f"Bearer {token}",
|
|
77
|
+
"Accept": "application/vnd.github+json",
|
|
78
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
79
|
+
"User-Agent": "obey-survey",
|
|
80
|
+
}
|
|
81
|
+
self._sleep = sleep
|
|
82
|
+
self._last: dict[str, float] = {}
|
|
83
|
+
|
|
84
|
+
def _pace(self, bucket: str, interval: float) -> None:
|
|
85
|
+
wait = self._last.get(bucket, 0.0) + interval - time.monotonic()
|
|
86
|
+
if wait > 0:
|
|
87
|
+
self._sleep(wait)
|
|
88
|
+
self._last[bucket] = time.monotonic()
|
|
89
|
+
|
|
90
|
+
def get(self, url: str, bucket: str = "core", params: dict[str, Any] | None = None) -> Any:
|
|
91
|
+
interval = SEARCH_INTERVAL_S if bucket == "search" else CORE_INTERVAL_S
|
|
92
|
+
for attempt in range(6):
|
|
93
|
+
self._pace(bucket, interval)
|
|
94
|
+
resp = self._client.get(url, headers=self._headers, params=params)
|
|
95
|
+
if resp.status_code in (403, 429) and (
|
|
96
|
+
resp.headers.get("x-ratelimit-remaining") == "0" or "retry-after" in resp.headers
|
|
97
|
+
):
|
|
98
|
+
reset = float(resp.headers.get("x-ratelimit-reset", time.time() + 60))
|
|
99
|
+
retry = float(resp.headers.get("retry-after", max(5.0, reset - time.time() + 1)))
|
|
100
|
+
self._sleep(min(retry, 900.0))
|
|
101
|
+
continue
|
|
102
|
+
if resp.status_code >= 500 and attempt < 5:
|
|
103
|
+
self._sleep(2.0 * (attempt + 1))
|
|
104
|
+
continue
|
|
105
|
+
if resp.status_code == 404:
|
|
106
|
+
return None
|
|
107
|
+
resp.raise_for_status()
|
|
108
|
+
return resp.json()
|
|
109
|
+
raise RuntimeError(f"GitHub API kept refusing {url}")
|
|
110
|
+
|
|
111
|
+
def raw(self, repo: str, sha: str, path: str) -> str | None:
|
|
112
|
+
self._pace("raw", CORE_INTERVAL_S)
|
|
113
|
+
resp = self._client.get(f"{RAW}/{repo}/{sha}/{path}", headers={"User-Agent": "obey-survey"})
|
|
114
|
+
if resp.status_code != 200:
|
|
115
|
+
return None
|
|
116
|
+
return resp.text
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def search_candidates(gh: GitHub, rng: random.Random, slices: list[tuple[int, int]]) -> Iterator[dict[str, Any]]:
|
|
120
|
+
"""Yield code-search hits for root AGENTS.md files, one random page per size slice."""
|
|
121
|
+
for lo, hi in slices:
|
|
122
|
+
query = f"filename:AGENTS.md path:/ size:{lo}..{hi}"
|
|
123
|
+
first = gh.get(f"{API}/search/code", "search", {"q": query, "per_page": 100, "page": 1})
|
|
124
|
+
if not first:
|
|
125
|
+
continue
|
|
126
|
+
pages = max(1, min(10, -(-int(first.get("total_count", 0)) // 100)))
|
|
127
|
+
page = rng.randint(1, pages)
|
|
128
|
+
data = (
|
|
129
|
+
first if page == 1 else gh.get(f"{API}/search/code", "search", {"q": query, "per_page": 100, "page": page})
|
|
130
|
+
)
|
|
131
|
+
for item in (data or {}).get("items", []):
|
|
132
|
+
if item.get("path") == "AGENTS.md":
|
|
133
|
+
yield {**item, "_slice": f"{lo}..{hi}"}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def describe_repo(gh: GitHub, hit: dict[str, Any]) -> SampleRecord | None:
|
|
137
|
+
full = hit["repository"]["full_name"]
|
|
138
|
+
meta = gh.get(f"{API}/repos/{full}")
|
|
139
|
+
if not meta or meta.get("fork") or meta.get("private"):
|
|
140
|
+
return None
|
|
141
|
+
branch = meta.get("default_branch") or "main"
|
|
142
|
+
head = gh.get(f"{API}/repos/{full}/commits/{branch}")
|
|
143
|
+
if not head:
|
|
144
|
+
return None
|
|
145
|
+
sha = head["sha"]
|
|
146
|
+
tree = gh.get(f"{API}/repos/{full}/git/trees/{sha}", params={"recursive": "1"}) or {}
|
|
147
|
+
paths = [t["path"] for t in tree.get("tree", []) if "path" in t]
|
|
148
|
+
if "AGENTS.md" not in paths:
|
|
149
|
+
return None
|
|
150
|
+
blob = next((t.get("sha", "") for t in tree.get("tree", []) if t.get("path") == "AGENTS.md"), "")
|
|
151
|
+
enforcement = {
|
|
152
|
+
name: any(p == m or (m.endswith("/") and p.startswith(m)) for p in paths for m in markers)
|
|
153
|
+
for name, markers in ENFORCEMENT_MARKERS.items()
|
|
154
|
+
}
|
|
155
|
+
claude_hooks = ".claude/settings.json" in paths and _has_hooks(gh.raw(full, sha, ".claude/settings.json"))
|
|
156
|
+
gemini_hooks = ".gemini/settings.json" in paths and _has_hooks(gh.raw(full, sha, ".gemini/settings.json"))
|
|
157
|
+
lic = (meta.get("license") or {}).get("spdx_id")
|
|
158
|
+
return SampleRecord(
|
|
159
|
+
repo=full,
|
|
160
|
+
url=meta.get("html_url", f"https://github.com/{full}"),
|
|
161
|
+
commit_sha=sha,
|
|
162
|
+
path="AGENTS.md",
|
|
163
|
+
blob_sha=blob,
|
|
164
|
+
license_spdx=lic,
|
|
165
|
+
stars=int(meta.get("stargazers_count", 0)),
|
|
166
|
+
default_branch=branch,
|
|
167
|
+
size_slice=hit.get("_slice", ""),
|
|
168
|
+
tree_truncated=bool(tree.get("truncated")),
|
|
169
|
+
has_claude_md=any(p.endswith("CLAUDE.md") for p in paths),
|
|
170
|
+
has_gemini_md=any(p.endswith("GEMINI.md") for p in paths),
|
|
171
|
+
has_cursor_rules=any(".cursor/rules/" in p for p in paths) or ".cursorrules" in paths,
|
|
172
|
+
has_copilot_instructions=".github/copilot-instructions.md" in paths
|
|
173
|
+
or any(p.startswith(".github/instructions/") for p in paths),
|
|
174
|
+
claude_settings_hooks=claude_hooks,
|
|
175
|
+
gemini_settings_hooks=gemini_hooks,
|
|
176
|
+
enforcement=enforcement,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _has_hooks(text: str | None) -> bool:
|
|
181
|
+
if not text:
|
|
182
|
+
return False
|
|
183
|
+
try:
|
|
184
|
+
data = json.loads(text)
|
|
185
|
+
except ValueError:
|
|
186
|
+
return False
|
|
187
|
+
return isinstance(data, dict) and bool(data.get("hooks"))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def collect(token: str, out: Path, target: int = 600, seed: int = SEED, log: Callable[[str], None] = print) -> int:
|
|
191
|
+
"""Write `target` sampled repositories to `out` (JSON lines). Returns the number written."""
|
|
192
|
+
rng = random.Random(seed) # noqa: S311 - reproducible sampling, not security
|
|
193
|
+
gh = GitHub(token)
|
|
194
|
+
hits: dict[str, dict[str, Any]] = {}
|
|
195
|
+
for hit in search_candidates(gh, rng, size_slices()):
|
|
196
|
+
repo = hit["repository"]
|
|
197
|
+
if not repo.get("fork"):
|
|
198
|
+
hits.setdefault(repo["full_name"], hit)
|
|
199
|
+
log(f"search: {len(hits)} distinct non-fork repositories with a root AGENTS.md")
|
|
200
|
+
order = sorted(hits)
|
|
201
|
+
rng.shuffle(order)
|
|
202
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
written = 0
|
|
204
|
+
with out.open("w", encoding="utf-8") as fh:
|
|
205
|
+
for name in order:
|
|
206
|
+
if written >= target:
|
|
207
|
+
break
|
|
208
|
+
record = describe_repo(gh, hits[name])
|
|
209
|
+
if record is None:
|
|
210
|
+
continue
|
|
211
|
+
fh.write(json.dumps(asdict(record), sort_keys=True) + "\n")
|
|
212
|
+
written += 1
|
|
213
|
+
if written % 50 == 0:
|
|
214
|
+
log(f"described {written}/{target}")
|
|
215
|
+
return written
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# --------------------------------------------------------------------------------------------- analysis
|
|
219
|
+
|
|
220
|
+
# How each checker type can be enforced (see obey_cli.enforce): "pre_tool" blocks the action before it runs in at
|
|
221
|
+
# least one supported agent; "stop_or_commit" can only stop the agent from finishing or block the commit.
|
|
222
|
+
ENFORCEMENT_ROUTE = {
|
|
223
|
+
"command_forbidden": "pre_tool",
|
|
224
|
+
"path_forbidden": "pre_tool",
|
|
225
|
+
"dependency_unchanged": "pre_tool",
|
|
226
|
+
"content_forbidden_added": "pre_tool",
|
|
227
|
+
"content_required_added": "pre_tool",
|
|
228
|
+
"file_naming": "pre_tool",
|
|
229
|
+
"commit_message": "pre_tool",
|
|
230
|
+
"no_commit": "pre_tool",
|
|
231
|
+
"no_push": "pre_tool",
|
|
232
|
+
"path_required_with": "stop_or_commit",
|
|
233
|
+
"command_required": "stop_or_commit",
|
|
234
|
+
"final_message": "stop_or_commit",
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def features(text: str) -> dict[str, Any]:
|
|
239
|
+
"""Derived features of one AGENTS.md text: counts only, never the text."""
|
|
240
|
+
import tempfile
|
|
241
|
+
|
|
242
|
+
from obey_cli.discover import discover
|
|
243
|
+
from obey_cli.extract import extract
|
|
244
|
+
|
|
245
|
+
with tempfile.TemporaryDirectory(prefix="obey-survey-") as tmp:
|
|
246
|
+
root = Path(tmp)
|
|
247
|
+
(root / "AGENTS.md").write_text(text, encoding="utf-8")
|
|
248
|
+
files = [f for f in discover(root) if f.path == "AGENTS.md"]
|
|
249
|
+
items = extract(files)
|
|
250
|
+
candidates = sum(len(f.parsed.candidates) for f in files)
|
|
251
|
+
by_type: dict[str, int] = {}
|
|
252
|
+
for x in items:
|
|
253
|
+
if x.status == "active" and x.checker:
|
|
254
|
+
by_type[x.checker["type"]] = by_type.get(x.checker["type"], 0) + 1
|
|
255
|
+
return {
|
|
256
|
+
"lines": text.count("\n") + 1,
|
|
257
|
+
"bytes": len(text.encode("utf-8")),
|
|
258
|
+
"candidates": candidates,
|
|
259
|
+
"rules": len(items),
|
|
260
|
+
"checkable": sum(1 for x in items if x.status == "active"),
|
|
261
|
+
"uncheckable": sum(1 for x in items if x.status == "uncheckable"),
|
|
262
|
+
"by_type": by_type,
|
|
263
|
+
"pre_tool_enforceable": sum(n for t, n in by_type.items() if ENFORCEMENT_ROUTE.get(t) == "pre_tool"),
|
|
264
|
+
"has_imports": bool(re.search(r"(?:^|\s)@[\w./~-]+\.md\b", text)),
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def analyse(
|
|
269
|
+
sample: Path,
|
|
270
|
+
out: Path,
|
|
271
|
+
log: Callable[[str], None] = print,
|
|
272
|
+
fetch: Callable[[str, str, str], str | None] | None = None,
|
|
273
|
+
) -> int:
|
|
274
|
+
"""Fetch each sampled AGENTS.md at its commit, compute features in memory and write them (no contents)."""
|
|
275
|
+
import httpx
|
|
276
|
+
|
|
277
|
+
client = httpx.Client(timeout=30.0, follow_redirects=True)
|
|
278
|
+
|
|
279
|
+
def default_fetch(repo: str, sha: str, path: str) -> str | None:
|
|
280
|
+
resp = client.get(f"{RAW}/{repo}/{sha}/{path}", headers={"User-Agent": "obey-survey"})
|
|
281
|
+
return resp.text if resp.status_code == 200 else None
|
|
282
|
+
|
|
283
|
+
get = fetch or default_fetch
|
|
284
|
+
rows = [json.loads(line) for line in sample.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
285
|
+
written = 0
|
|
286
|
+
with out.open("w", encoding="utf-8") as fh:
|
|
287
|
+
for i, row in enumerate(rows, 1):
|
|
288
|
+
text = get(row["repo"], row["commit_sha"], row["path"])
|
|
289
|
+
record: dict[str, Any] = {"repo": row["repo"], "commit_sha": row["commit_sha"], "fetched": text is not None}
|
|
290
|
+
if text is not None:
|
|
291
|
+
record.update(features(text))
|
|
292
|
+
fh.write(json.dumps(record, sort_keys=True) + "\n")
|
|
293
|
+
written += 1
|
|
294
|
+
if i % 100 == 0:
|
|
295
|
+
log(f"analysed {i}/{len(rows)}")
|
|
296
|
+
return written
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def llm_pass(
|
|
300
|
+
sample: Path, out: Path, config: Any, ledger: Any, batch_size: int = 40, log: Callable[[str], None] = print
|
|
301
|
+
) -> int:
|
|
302
|
+
"""Classify the rules the patterns left uncheckable with an LLM; store per-repository counts only."""
|
|
303
|
+
import tempfile
|
|
304
|
+
|
|
305
|
+
import httpx
|
|
306
|
+
|
|
307
|
+
from obey_cli.discover import discover
|
|
308
|
+
from obey_cli.extract import extract, llm_classify
|
|
309
|
+
|
|
310
|
+
client = httpx.Client(timeout=30.0, follow_redirects=True)
|
|
311
|
+
rows = [json.loads(line) for line in sample.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
312
|
+
queue: list[tuple[int, str]] = [] # (row index, rule text) kept in memory only
|
|
313
|
+
for i, row in enumerate(rows):
|
|
314
|
+
resp = client.get(
|
|
315
|
+
f"{RAW}/{row['repo']}/{row['commit_sha']}/{row['path']}", headers={"User-Agent": "obey-survey"}
|
|
316
|
+
)
|
|
317
|
+
if resp.status_code != 200:
|
|
318
|
+
continue
|
|
319
|
+
with tempfile.TemporaryDirectory(prefix="obey-survey-") as tmp:
|
|
320
|
+
(Path(tmp) / "AGENTS.md").write_text(resp.text, encoding="utf-8")
|
|
321
|
+
items = extract([f for f in discover(Path(tmp)) if f.path == "AGENTS.md"])
|
|
322
|
+
queue += [(i, x.candidate.text[:400]) for x in items if x.status == "uncheckable"]
|
|
323
|
+
log(f"llm: {len(queue)} uncheckable rules from {len(rows)} files")
|
|
324
|
+
proposed: dict[int, dict[str, int]] = {}
|
|
325
|
+
sent: dict[int, int] = {}
|
|
326
|
+
for start in range(0, len(queue), batch_size):
|
|
327
|
+
chunk = queue[start : start + batch_size]
|
|
328
|
+
try:
|
|
329
|
+
results = llm_classify([t for _, t in chunk], config, ledger, run_id="survey-llm", types_only=True)
|
|
330
|
+
except Exception as exc:
|
|
331
|
+
log(f"llm batch {start // batch_size} failed: {exc}")
|
|
332
|
+
continue
|
|
333
|
+
for (i, _), res in zip(chunk, results, strict=True):
|
|
334
|
+
sent[i] = sent.get(i, 0) + 1
|
|
335
|
+
if res is not None:
|
|
336
|
+
by = proposed.setdefault(i, {})
|
|
337
|
+
by[res["type"]] = by.get(res["type"], 0) + 1
|
|
338
|
+
if (start // batch_size) % 10 == 0:
|
|
339
|
+
log(f"llm batch {start // batch_size + 1}/{-(-len(queue) // batch_size)}, spent ${ledger.spent():.3f}")
|
|
340
|
+
with out.open("w", encoding="utf-8") as fh:
|
|
341
|
+
for i, row in enumerate(rows):
|
|
342
|
+
fh.write(
|
|
343
|
+
json.dumps(
|
|
344
|
+
{
|
|
345
|
+
"repo": row["repo"],
|
|
346
|
+
"commit_sha": row["commit_sha"],
|
|
347
|
+
"uncheckable_sent": sent.get(i, 0),
|
|
348
|
+
"llm_proposed_by_type": proposed.get(i, {}),
|
|
349
|
+
},
|
|
350
|
+
sort_keys=True,
|
|
351
|
+
)
|
|
352
|
+
+ "\n"
|
|
353
|
+
)
|
|
354
|
+
return len(rows)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def summarise(features: Path, llm: Path | None = None, sample: Path | None = None) -> dict[str, Any]:
|
|
358
|
+
"""Aggregate the survey: rules per file, checkable and enforceable shares, rule types, existing enforcement."""
|
|
359
|
+
import statistics
|
|
360
|
+
|
|
361
|
+
rows = [json.loads(line) for line in features.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
362
|
+
ok = [r for r in rows if r.get("fetched")]
|
|
363
|
+
rules = sorted(r["rules"] for r in ok)
|
|
364
|
+
total = sum(rules)
|
|
365
|
+
checkable = sum(r["checkable"] for r in ok)
|
|
366
|
+
pre_tool = sum(r["pre_tool_enforceable"] for r in ok)
|
|
367
|
+
by_type_rules: dict[str, int] = {}
|
|
368
|
+
by_type_repos: dict[str, int] = {}
|
|
369
|
+
for r in ok:
|
|
370
|
+
for t, n in r["by_type"].items():
|
|
371
|
+
by_type_rules[t] = by_type_rules.get(t, 0) + n
|
|
372
|
+
by_type_repos[t] = by_type_repos.get(t, 0) + 1
|
|
373
|
+
out: dict[str, Any] = {
|
|
374
|
+
"repositories": len(rows),
|
|
375
|
+
"fetched": len(ok),
|
|
376
|
+
"rules_total": total,
|
|
377
|
+
"rules_per_file": {
|
|
378
|
+
"median": statistics.median(rules) if rules else 0,
|
|
379
|
+
"mean": statistics.mean(rules) if rules else 0,
|
|
380
|
+
"p90": rules[int(len(rules) * 0.9)] if rules else 0,
|
|
381
|
+
"max": rules[-1] if rules else 0,
|
|
382
|
+
},
|
|
383
|
+
"files_with_no_rules": sum(1 for r in ok if r["rules"] == 0),
|
|
384
|
+
"checkable_share": checkable / total if total else 0,
|
|
385
|
+
"pre_tool_enforceable_share": pre_tool / total if total else 0,
|
|
386
|
+
"stop_or_commit_only_share": (checkable - pre_tool) / total if total else 0,
|
|
387
|
+
"types": [
|
|
388
|
+
{"type": t, "rules": by_type_rules[t], "repositories": by_type_repos[t]}
|
|
389
|
+
for t in sorted(by_type_rules, key=lambda t: (-by_type_repos[t], t))
|
|
390
|
+
],
|
|
391
|
+
"repos_with_any_checkable": sum(1 for r in ok if r["checkable"] > 0),
|
|
392
|
+
}
|
|
393
|
+
if sample is not None and sample.exists():
|
|
394
|
+
srows = [json.loads(line) for line in sample.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
395
|
+
enforcement = {
|
|
396
|
+
"claude_code_hooks": sum(r["claude_settings_hooks"] for r in srows),
|
|
397
|
+
"gemini_hooks": sum(r["gemini_settings_hooks"] for r in srows),
|
|
398
|
+
**{k: sum(r["enforcement"].get(k, False) for r in srows) for k in ENFORCEMENT_MARKERS},
|
|
399
|
+
}
|
|
400
|
+
agent_hooks = sum(
|
|
401
|
+
1
|
|
402
|
+
for r in srows
|
|
403
|
+
if r["claude_settings_hooks"]
|
|
404
|
+
or r["gemini_settings_hooks"]
|
|
405
|
+
or any(
|
|
406
|
+
r["enforcement"].get(k) for k in ("codex_hooks", "cursor_hooks", "copilot_hooks", "opencode_plugins")
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
git_hooks = sum(1 for r in srows if any(r["enforcement"].get(k) for k in ("pre_commit", "husky", "lefthook")))
|
|
410
|
+
either = sum(
|
|
411
|
+
1
|
|
412
|
+
for r in srows
|
|
413
|
+
if r["claude_settings_hooks"] or r["gemini_settings_hooks"] or any(r["enforcement"].values())
|
|
414
|
+
)
|
|
415
|
+
out["enforcement"] = {
|
|
416
|
+
"counts": enforcement,
|
|
417
|
+
"agent_hooks_repos": agent_hooks,
|
|
418
|
+
"git_hook_repos": git_hooks,
|
|
419
|
+
"any_repos": either,
|
|
420
|
+
"any_share": either / len(srows) if srows else 0,
|
|
421
|
+
}
|
|
422
|
+
lic: dict[str, int] = {}
|
|
423
|
+
for r in srows:
|
|
424
|
+
key = r["license_spdx"] or "none"
|
|
425
|
+
lic[key] = lic.get(key, 0) + 1
|
|
426
|
+
out["licences"] = dict(sorted(lic.items(), key=lambda kv: -kv[1]))
|
|
427
|
+
out["stars_median"] = statistics.median(r["stars"] for r in srows) if srows else 0
|
|
428
|
+
if llm is not None and llm.exists():
|
|
429
|
+
lrows = [json.loads(line) for line in llm.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
430
|
+
sent = sum(r["uncheckable_sent"] for r in lrows)
|
|
431
|
+
proposed = sum(sum(r["llm_proposed_by_type"].values()) for r in lrows)
|
|
432
|
+
out["llm"] = {
|
|
433
|
+
"uncheckable_sent": sent,
|
|
434
|
+
"proposed_checkable": proposed,
|
|
435
|
+
"proposed_share_of_unmapped": proposed / sent if sent else 0,
|
|
436
|
+
"checkable_share_with_llm": (checkable + proposed) / total if total else 0,
|
|
437
|
+
}
|
|
438
|
+
return out
|