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.
Files changed (90) hide show
  1. obey_cli/__init__.py +3 -0
  2. obey_cli/__main__.py +3 -0
  3. obey_cli/_data/demo/README.md +12 -0
  4. obey_cli/_data/demo/obey.yaml +287 -0
  5. obey_cli/_data/demo/repo/.gitignore +8 -0
  6. obey_cli/_data/demo/repo/AGENTS.md +38 -0
  7. obey_cli/_data/demo/repo/README.md +36 -0
  8. obey_cli/_data/demo/repo/eslint.config.js +24 -0
  9. obey_cli/_data/demo/repo/index.html +21 -0
  10. obey_cli/_data/demo/repo/openapi.yaml +86 -0
  11. obey_cli/_data/demo/repo/package.json +26 -0
  12. obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
  13. obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
  14. obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
  15. obey_cli/_data/demo/repo/src/api/client.ts +71 -0
  16. obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
  17. obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
  18. obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
  19. obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
  20. obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
  21. obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
  22. obey_cli/_data/demo/repo/src/logger.ts +36 -0
  23. obey_cli/_data/demo/repo/src/main.ts +30 -0
  24. obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
  25. obey_cli/_data/demo/repo/src/render-table.ts +48 -0
  26. obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
  27. obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
  28. obey_cli/_data/demo/repo/tsconfig.json +20 -0
  29. obey_cli/_data/demo/repo/vitest.config.ts +8 -0
  30. obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
  31. obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
  32. obey_cli/_data/demo/runs/meta.json +10 -0
  33. obey_cli/_data/schemas/obey-config-v1.json +116 -0
  34. obey_cli/_data/schemas/report-v1.json +55 -0
  35. obey_cli/_data/schemas/survey-record-v1.json +30 -0
  36. obey_cli/checkers/__init__.py +30 -0
  37. obey_cli/checkers/samples.py +95 -0
  38. obey_cli/cli.py +201 -0
  39. obey_cli/commands.py +676 -0
  40. obey_cli/config.py +90 -0
  41. obey_cli/discover.py +232 -0
  42. obey_cli/enforce/__init__.py +114 -0
  43. obey_cli/enforce/engine/__init__.py +0 -0
  44. obey_cli/enforce/engine/obey_engine.py +2005 -0
  45. obey_cli/enforce/merge.py +196 -0
  46. obey_cli/enforce/targets/__init__.py +354 -0
  47. obey_cli/enforce/templates/obey_hook.py +452 -0
  48. obey_cli/enforce/templates/pre_commit.py +116 -0
  49. obey_cli/extract.py +1342 -0
  50. obey_cli/lint.py +125 -0
  51. obey_cli/llm.py +129 -0
  52. obey_cli/parse_md.py +260 -0
  53. obey_cli/probes/__init__.py +0 -0
  54. obey_cli/probes/facts.py +162 -0
  55. obey_cli/probes/templates.py +326 -0
  56. obey_cli/py.typed +0 -0
  57. obey_cli/report/__init__.py +314 -0
  58. obey_cli/resources.py +35 -0
  59. obey_cli/rules.py +75 -0
  60. obey_cli/runner/__init__.py +0 -0
  61. obey_cli/runner/agents/__init__.py +22 -0
  62. obey_cli/runner/agents/base.py +96 -0
  63. obey_cli/runner/agents/claude_code.py +203 -0
  64. obey_cli/runner/agents/codex.py +152 -0
  65. obey_cli/runner/agents/copilot.py +142 -0
  66. obey_cli/runner/agents/gemini.py +125 -0
  67. obey_cli/runner/agents/opencode.py +156 -0
  68. obey_cli/runner/core.py +140 -0
  69. obey_cli/runner/egress_proxy.py +166 -0
  70. obey_cli/runner/gateway.py +529 -0
  71. obey_cli/runner/ledger.py +132 -0
  72. obey_cli/runner/local.py +176 -0
  73. obey_cli/runner/orchestrate.py +227 -0
  74. obey_cli/runner/sandbox/Dockerfile +41 -0
  75. obey_cli/runner/sandbox/entrypoint.sh +38 -0
  76. obey_cli/runner/sandbox.py +341 -0
  77. obey_cli/runner/sidecar.py +38 -0
  78. obey_cli/runner/workspace.py +187 -0
  79. obey_cli/score.py +150 -0
  80. obey_cli/study/__init__.py +0 -0
  81. obey_cli/study/analysis.py +277 -0
  82. obey_cli/study/design.py +98 -0
  83. obey_cli/study/experiment.py +198 -0
  84. obey_cli/study/report.py +519 -0
  85. obey_cli/study/survey.py +438 -0
  86. obey_cli-0.1.0.dist-info/METADATA +393 -0
  87. obey_cli-0.1.0.dist-info/RECORD +90 -0
  88. obey_cli-0.1.0.dist-info/WHEEL +4 -0
  89. obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
  90. obey_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -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