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/lint.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""obey lint: checks that need no agent runs, for CI.
|
|
2
|
+
|
|
3
|
+
1. obey.yaml is valid, and every active rule has a checker (rules that need judgement are `uncheckable`).
|
|
4
|
+
2. The instruction files are unchanged since obey.yaml was written (a hash mismatch means "re-extract").
|
|
5
|
+
3. Generated enforcement files match what `obey enforce` would write for the rules they were compiled from.
|
|
6
|
+
4. The badge JSON is present (lint writes it).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from obey_cli import config as cfg
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class LintResult:
|
|
21
|
+
ok: bool = True
|
|
22
|
+
lines: list[str] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
def good(self, text: str) -> None:
|
|
25
|
+
self.lines.append(f"ok {text}")
|
|
26
|
+
|
|
27
|
+
def bad(self, text: str) -> None:
|
|
28
|
+
self.ok = False
|
|
29
|
+
self.lines.append(f"FAIL {text}")
|
|
30
|
+
|
|
31
|
+
def note(self, text: str) -> None:
|
|
32
|
+
self.lines.append(f"note {text}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def lint(root: Path, config_name: str = cfg.CONFIG_NAME, write_badge: bool = True) -> LintResult:
|
|
36
|
+
from obey_cli.discover import discover
|
|
37
|
+
from obey_cli.enforce import in_sync, installed_targets
|
|
38
|
+
from obey_cli.report import badge
|
|
39
|
+
|
|
40
|
+
res = LintResult()
|
|
41
|
+
try:
|
|
42
|
+
config = cfg.load(root / config_name)
|
|
43
|
+
except cfg.ConfigError as exc:
|
|
44
|
+
res.bad(str(exc))
|
|
45
|
+
return res
|
|
46
|
+
rules = config["rules"]
|
|
47
|
+
active = [r for r in rules if r["status"] == "active"]
|
|
48
|
+
res.good(
|
|
49
|
+
f"{config_name} is valid: {len(rules)} rules, {len(active)} active, "
|
|
50
|
+
f"{sum(r['status'] == 'uncheckable' for r in rules)} uncheckable"
|
|
51
|
+
)
|
|
52
|
+
missing = [r["id"] for r in active if not r.get("checker")]
|
|
53
|
+
if missing:
|
|
54
|
+
res.bad(f"active rules without a checker: {', '.join(missing)} (add one or mark them uncheckable)")
|
|
55
|
+
proposed = [r["id"] for r in rules if r["status"] == "proposed"]
|
|
56
|
+
if proposed:
|
|
57
|
+
res.note(f"{len(proposed)} proposed rule(s) wait for review: set status active or disabled")
|
|
58
|
+
no_probe = [r["id"] for r in active if not r.get("probes")]
|
|
59
|
+
if no_probe:
|
|
60
|
+
res.note(f"{len(no_probe)} active rule(s) have no probe yet (obey probe)")
|
|
61
|
+
|
|
62
|
+
recorded = {s["file"]: s["sha256"] for s in config.get("sources") or []}
|
|
63
|
+
current = {f.path: f.sha256 for f in discover(root)}
|
|
64
|
+
changed = sorted(p for p in recorded if p in current and current[p] != recorded[p])
|
|
65
|
+
gone = sorted(p for p in recorded if p not in current)
|
|
66
|
+
new = sorted(p for p in current if p not in recorded)
|
|
67
|
+
if changed or gone or new:
|
|
68
|
+
detail = "; ".join(
|
|
69
|
+
x
|
|
70
|
+
for x in (
|
|
71
|
+
f"changed: {', '.join(changed)}" if changed else "",
|
|
72
|
+
f"removed: {', '.join(gone)}" if gone else "",
|
|
73
|
+
f"new: {', '.join(new)}" if new else "",
|
|
74
|
+
)
|
|
75
|
+
if x
|
|
76
|
+
)
|
|
77
|
+
res.bad(f"instruction files differ from {config_name} ({detail}); run `obey extract`")
|
|
78
|
+
else:
|
|
79
|
+
res.good(f"{len(current)} instruction file(s) match {config_name}")
|
|
80
|
+
|
|
81
|
+
targets = installed_targets(root)
|
|
82
|
+
compiled_path = root / ".obey" / "hooks" / "rules.json"
|
|
83
|
+
if targets:
|
|
84
|
+
if not compiled_path.exists():
|
|
85
|
+
res.bad("enforcement files exist but .obey/hooks/rules.json is missing; run `obey enforce`")
|
|
86
|
+
else:
|
|
87
|
+
compiled = json.loads(compiled_path.read_text(encoding="utf-8"))
|
|
88
|
+
by_id = {r["id"]: r for r in rules}
|
|
89
|
+
stale = [
|
|
90
|
+
r["id"]
|
|
91
|
+
for r in compiled["rules"]
|
|
92
|
+
if r["id"] not in by_id
|
|
93
|
+
or by_id[r["id"]].get("status") != "active"
|
|
94
|
+
or by_id[r["id"]].get("checker") != r["checker"]
|
|
95
|
+
]
|
|
96
|
+
if stale:
|
|
97
|
+
res.bad(f"enforced rules no longer match {config_name}: {', '.join(stale)}; run `obey enforce`")
|
|
98
|
+
else:
|
|
99
|
+
current_rules = [by_id[r["id"]] for r in compiled["rules"]]
|
|
100
|
+
drift = in_sync(root, current_rules, targets)
|
|
101
|
+
if drift:
|
|
102
|
+
res.bad(f"enforcement files out of date: {', '.join(drift)}; run `obey enforce`")
|
|
103
|
+
else:
|
|
104
|
+
res.good(f"enforcement in sync for {', '.join(targets)} ({len(compiled['rules'])} rule(s))")
|
|
105
|
+
else:
|
|
106
|
+
res.note("no enforcement installed (obey enforce)")
|
|
107
|
+
|
|
108
|
+
badge_path = root / "obey-badge.json"
|
|
109
|
+
if write_badge:
|
|
110
|
+
report_path = root / ".obey" / "report.json"
|
|
111
|
+
report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.exists() else None
|
|
112
|
+
data = badge(report, rules_total=len(active))
|
|
113
|
+
text = json.dumps(data) + "\n"
|
|
114
|
+
if not badge_path.exists() or badge_path.read_text(encoding="utf-8") != text:
|
|
115
|
+
badge_path.write_text(text, encoding="utf-8")
|
|
116
|
+
res.good(f"badge written to obey-badge.json ({data['message']})")
|
|
117
|
+
elif badge_path.exists():
|
|
118
|
+
res.good("obey-badge.json present")
|
|
119
|
+
else:
|
|
120
|
+
res.bad("obey-badge.json missing (run `obey lint` without --no-badge, or `obey run`)")
|
|
121
|
+
return res
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def sha256_file(path: Path) -> str:
|
|
125
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
obey_cli/llm.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Minimal client for any OpenAI-compatible chat endpoint, returning schema-validated JSON.
|
|
2
|
+
|
|
3
|
+
Configuration comes from the environment: OBEY_LLM_BASE_URL (default OpenRouter), OBEY_LLM_API_KEY (falls back
|
|
4
|
+
to OPENROUTER_API_KEY) and OBEY_LLM_MODEL. Every call is written to the spend ledger.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from obey_cli.runner.ledger import Entry, Ledger, Price, now, provider_cost, usage_from_response
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LLMError(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class LLMConfig:
|
|
26
|
+
base_url: str
|
|
27
|
+
api_key: str
|
|
28
|
+
model: str
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_env(cls, model: str | None = None) -> LLMConfig:
|
|
32
|
+
key = os.environ.get("OBEY_LLM_API_KEY") or os.environ.get("OPENROUTER_API_KEY") or ""
|
|
33
|
+
if not key:
|
|
34
|
+
raise LLMError("set OBEY_LLM_API_KEY (or OPENROUTER_API_KEY) to use --llm")
|
|
35
|
+
chosen = model or os.environ.get("OBEY_LLM_MODEL")
|
|
36
|
+
if not chosen:
|
|
37
|
+
raise LLMError("set OBEY_LLM_MODEL or pass --llm-model")
|
|
38
|
+
return cls(os.environ.get("OBEY_LLM_BASE_URL", DEFAULT_BASE_URL).rstrip("/"), key, chosen)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _first_json_object(text: str) -> Any:
|
|
42
|
+
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip())
|
|
43
|
+
try:
|
|
44
|
+
return json.loads(text)
|
|
45
|
+
except ValueError:
|
|
46
|
+
pass
|
|
47
|
+
for open_, close in (("{", "}"), ("[", "]")):
|
|
48
|
+
start = text.find(open_)
|
|
49
|
+
end = text.rfind(close)
|
|
50
|
+
if start >= 0 and end > start:
|
|
51
|
+
try:
|
|
52
|
+
return json.loads(text[start : end + 1])
|
|
53
|
+
except ValueError:
|
|
54
|
+
continue
|
|
55
|
+
raise ValueError("no JSON object in the response")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def chat_json(
|
|
59
|
+
config: LLMConfig,
|
|
60
|
+
system: str,
|
|
61
|
+
user: str,
|
|
62
|
+
schema: dict[str, Any],
|
|
63
|
+
ledger: Ledger | None = None,
|
|
64
|
+
price: Price | None = None,
|
|
65
|
+
run_id: str = "llm",
|
|
66
|
+
max_tokens: int = 2000,
|
|
67
|
+
attempts: int = 2,
|
|
68
|
+
normalize: Any = None,
|
|
69
|
+
) -> dict[str, Any]:
|
|
70
|
+
"""Ask for a JSON object and validate it against `schema`; retries once with the validation error.
|
|
71
|
+
|
|
72
|
+
`normalize`, when given, maps the parsed JSON (object or array) to the object the schema describes.
|
|
73
|
+
"""
|
|
74
|
+
import httpx
|
|
75
|
+
import jsonschema
|
|
76
|
+
|
|
77
|
+
messages: list[dict[str, str]] = [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
|
78
|
+
last_error = ""
|
|
79
|
+
for _ in range(attempts):
|
|
80
|
+
if ledger is not None:
|
|
81
|
+
ledger.check()
|
|
82
|
+
payload: dict[str, Any] = {
|
|
83
|
+
"model": config.model,
|
|
84
|
+
"messages": messages,
|
|
85
|
+
"temperature": 0,
|
|
86
|
+
"max_tokens": max_tokens,
|
|
87
|
+
"response_format": {"type": "json_object"},
|
|
88
|
+
"usage": {"include": True},
|
|
89
|
+
}
|
|
90
|
+
resp = httpx.post(
|
|
91
|
+
f"{config.base_url}/chat/completions",
|
|
92
|
+
headers={"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"},
|
|
93
|
+
json=payload,
|
|
94
|
+
timeout=120.0,
|
|
95
|
+
)
|
|
96
|
+
if resp.status_code >= 400:
|
|
97
|
+
raise LLMError(f"model endpoint returned HTTP {resp.status_code}: {resp.text[:300]}")
|
|
98
|
+
body = resp.json()
|
|
99
|
+
usage = usage_from_response(body)
|
|
100
|
+
cost = provider_cost(body)
|
|
101
|
+
if ledger is not None:
|
|
102
|
+
ledger.record(
|
|
103
|
+
Entry(
|
|
104
|
+
ts=now(),
|
|
105
|
+
run_id=run_id,
|
|
106
|
+
kind="llm",
|
|
107
|
+
model=config.model,
|
|
108
|
+
cost_usd=cost if cost is not None else (price.cost(usage) if price else 0.0),
|
|
109
|
+
cost_source="provider" if cost is not None else "price_table",
|
|
110
|
+
**usage,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
content = body["choices"][0]["message"]["content"] or ""
|
|
115
|
+
data = _first_json_object(content)
|
|
116
|
+
if normalize is not None:
|
|
117
|
+
data = normalize(data)
|
|
118
|
+
jsonschema.validate(data, schema)
|
|
119
|
+
if not isinstance(data, dict):
|
|
120
|
+
raise ValueError("top-level JSON value is not an object")
|
|
121
|
+
return data
|
|
122
|
+
except (KeyError, IndexError, ValueError, jsonschema.ValidationError) as exc:
|
|
123
|
+
last_error = str(exc)[:500]
|
|
124
|
+
messages = [
|
|
125
|
+
*messages,
|
|
126
|
+
{"role": "assistant", "content": str(body.get("choices", [{}])[0].get("message", {}).get("content"))},
|
|
127
|
+
{"role": "user", "content": f"That was not valid: {last_error}. Reply with the JSON object only."},
|
|
128
|
+
]
|
|
129
|
+
raise LLMError(f"no valid JSON after {attempts} attempts: {last_error}")
|
obey_cli/parse_md.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Turn instruction-file Markdown into rule candidates.
|
|
2
|
+
|
|
3
|
+
A candidate is a list item, a table row, or a prose sentence that contains a directive word. Fenced code,
|
|
4
|
+
HTML comments and front matter are never candidates. Every candidate keeps its line span and the headings
|
|
5
|
+
above it, because a bullet under a "Don't" heading means the opposite of the same bullet under "Do".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
_STRONG_DIRECTIVE_RE = re.compile(
|
|
17
|
+
r"\b(always|never|must|mustn't|must not|shall|should|shouldn't|should not|do not|don't|avoid|prefer|"
|
|
18
|
+
r"required|forbidden|prohibited|not allowed|ensure|make sure|instead of|rather than)\b",
|
|
19
|
+
re.IGNORECASE,
|
|
20
|
+
)
|
|
21
|
+
_IMPERATIVE_VERBS = (
|
|
22
|
+
r"use|run|add|keep|write|follow|put|name|commit|push|edit|modify|touch|change|prefer|avoid|never|always|do|don't|"
|
|
23
|
+
r"ensure|make|place|create|update|include|end|start|begin|check|import|install|"
|
|
24
|
+
r"log|return|wrap|ask|leave|treat|store|limit|stick|rely|mark|annotate|declare|be|plan|co-locate|colocate|"
|
|
25
|
+
r"mention|match|say|tell|reply|respond|split|move|delete|remove|rename|bump|pin|call|raise|"
|
|
26
|
+
r"handle|validate|escape|prefix|suffix|reuse|guard|mock|stub|skip|verify|confirm|cite"
|
|
27
|
+
)
|
|
28
|
+
_IMPERATIVE_START_RE = re.compile(rf"^({_IMPERATIVE_VERBS})\b", re.IGNORECASE)
|
|
29
|
+
# "After changing Python files, run X." / "We review quickly, so keep changes small."
|
|
30
|
+
_CLAUSE_IMPERATIVE_RE = re.compile(rf"(,|;|:|\bso|\bplease|\bthen)\s+({_IMPERATIVE_VERBS})\b", re.IGNORECASE)
|
|
31
|
+
_NORMATIVE_RE = re.compile(
|
|
32
|
+
r"\b(needs? (approval|sign-?off|review|a test|tests|to be)|has no place|not welcome|(is|are) (banned|off-limits|"
|
|
33
|
+
r"required|mandatory|immutable|read-only)|(kebab|snake|pascal|camel)[- _]?case|(starts?|begins?|ends?) with|"
|
|
34
|
+
r"(is|are) named|names? (are|is|use))\b",
|
|
35
|
+
re.IGNORECASE,
|
|
36
|
+
)
|
|
37
|
+
_LEADING_SYMBOLS_RE = re.compile(r"^[^\w`(\[\"']+")
|
|
38
|
+
_FENCE_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
|
|
39
|
+
_LIST_RE = re.compile(r"^(\s*)(?:[-*+]|\d{1,3}[.)])\s+(?:\[[ xX]\]\s+)?(.*)$")
|
|
40
|
+
_HEADING_RE = re.compile(r"^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$")
|
|
41
|
+
_TABLE_RE = re.compile(r"^\s*\|(.*)\|\s*$")
|
|
42
|
+
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
|
|
43
|
+
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+(?=[A-Z`*_\"'(])")
|
|
44
|
+
_CODE_SPAN_RE = re.compile(r"`([^`]+)`")
|
|
45
|
+
_EMPHASIS_RE = re.compile(r"(\*\*|(?<!\w)__|\*|(?<!\w)_)(\S(?:.*?\S)?)\1(?!\w)")
|
|
46
|
+
_LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]*\)")
|
|
47
|
+
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
|
|
48
|
+
_IMPORT_RE = re.compile(r"(?:(?<=\s)|^)@((?:~/|\.{1,2}/|[\w])[\w./~-]*[\w/])")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Candidate:
|
|
53
|
+
"""A piece of instruction text that may be a rule."""
|
|
54
|
+
|
|
55
|
+
text: str
|
|
56
|
+
raw: str
|
|
57
|
+
line_start: int
|
|
58
|
+
line_end: int
|
|
59
|
+
kind: str # "list" | "table" | "sentence"
|
|
60
|
+
headings: list[str] = field(default_factory=list)
|
|
61
|
+
code_spans: list[str] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ParsedFile:
|
|
66
|
+
frontmatter: dict[str, Any]
|
|
67
|
+
candidates: list[Candidate]
|
|
68
|
+
imports: list[tuple[str, int]] # (import path as written, line number)
|
|
69
|
+
body_start_line: int
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def split_frontmatter(text: str) -> tuple[dict[str, Any], str, int]:
|
|
73
|
+
"""Return (front matter, body, line number where the body starts)."""
|
|
74
|
+
if not text.startswith("---"):
|
|
75
|
+
return {}, text, 1
|
|
76
|
+
lines = text.split("\n")
|
|
77
|
+
if lines[0].strip() != "---":
|
|
78
|
+
return {}, text, 1
|
|
79
|
+
for i in range(1, len(lines)):
|
|
80
|
+
if lines[i].strip() in ("---", "..."):
|
|
81
|
+
raw = "\n".join(lines[1:i])
|
|
82
|
+
try:
|
|
83
|
+
data = yaml.safe_load(raw) or {}
|
|
84
|
+
except yaml.YAMLError:
|
|
85
|
+
data = _loose_frontmatter(raw)
|
|
86
|
+
if not isinstance(data, dict):
|
|
87
|
+
data = {}
|
|
88
|
+
return data, "\n".join(lines[i + 1 :]), i + 2
|
|
89
|
+
return {}, text, 1
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _loose_frontmatter(raw: str) -> dict[str, Any]:
|
|
93
|
+
# .mdc files often hold unquoted globs such as `globs: *.ts`, which is invalid YAML.
|
|
94
|
+
data: dict[str, Any] = {}
|
|
95
|
+
for line in raw.splitlines():
|
|
96
|
+
if ":" in line:
|
|
97
|
+
key, _, value = line.partition(":")
|
|
98
|
+
data[key.strip()] = value.strip().strip("\"'")
|
|
99
|
+
return data
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def plain(text: str) -> str:
|
|
103
|
+
"""Strip inline Markdown while keeping code-span content (`snake_case` keeps its underscore)."""
|
|
104
|
+
spans: list[str] = []
|
|
105
|
+
|
|
106
|
+
def stash(m: re.Match[str]) -> str:
|
|
107
|
+
spans.append(m.group(1))
|
|
108
|
+
return f"\x00{len(spans) - 1}\x00"
|
|
109
|
+
|
|
110
|
+
out = _LINK_RE.sub(r"\1", text)
|
|
111
|
+
out = _CODE_SPAN_RE.sub(stash, out)
|
|
112
|
+
for _ in range(2):
|
|
113
|
+
out = _EMPHASIS_RE.sub(r"\2", out)
|
|
114
|
+
out = re.sub(r"<[^>]+>", "", out)
|
|
115
|
+
out = re.sub("\x00(\\d+)\x00", lambda m: spans[int(m.group(1))], out)
|
|
116
|
+
return re.sub(r"\s+", " ", out).strip()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def is_directive(text: str) -> bool:
|
|
120
|
+
bare = _LEADING_SYMBOLS_RE.sub("", text.strip())
|
|
121
|
+
return bool(
|
|
122
|
+
_STRONG_DIRECTIVE_RE.search(bare)
|
|
123
|
+
or _IMPERATIVE_START_RE.match(bare)
|
|
124
|
+
or _CLAUSE_IMPERATIVE_RE.search(bare)
|
|
125
|
+
or _NORMATIVE_RE.search(bare)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _blank_comments(text: str) -> str:
|
|
130
|
+
# Replace HTML comments with blank lines so line numbers stay right.
|
|
131
|
+
return _HTML_COMMENT_RE.sub(lambda m: "\n" * m.group(0).count("\n"), text)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse(text: str) -> ParsedFile:
|
|
135
|
+
frontmatter, body, start = split_frontmatter(text)
|
|
136
|
+
body = _blank_comments(body)
|
|
137
|
+
lines = body.split("\n")
|
|
138
|
+
candidates: list[Candidate] = []
|
|
139
|
+
imports: list[tuple[str, int]] = []
|
|
140
|
+
headings: list[tuple[int, str]] = []
|
|
141
|
+
in_fence: str | None = None
|
|
142
|
+
para: list[tuple[int, str]] = []
|
|
143
|
+
item: list[tuple[int, str]] = []
|
|
144
|
+
item_indent = 0
|
|
145
|
+
|
|
146
|
+
def flush_para() -> None:
|
|
147
|
+
if para:
|
|
148
|
+
_emit_sentences(para, [h for _, h in headings], candidates)
|
|
149
|
+
para.clear()
|
|
150
|
+
|
|
151
|
+
def flush_item() -> None:
|
|
152
|
+
if item:
|
|
153
|
+
raw = " ".join(s.strip() for _, s in item)
|
|
154
|
+
_emit(raw, item[0][0], item[-1][0], "list", [h for _, h in headings], candidates)
|
|
155
|
+
item.clear()
|
|
156
|
+
|
|
157
|
+
for offset, line in enumerate(lines):
|
|
158
|
+
lineno = start + offset
|
|
159
|
+
fence = _FENCE_RE.match(line)
|
|
160
|
+
if in_fence is not None:
|
|
161
|
+
if fence and fence.group(1)[0] == in_fence[0] and len(fence.group(1)) >= len(in_fence):
|
|
162
|
+
in_fence = None
|
|
163
|
+
continue
|
|
164
|
+
if fence:
|
|
165
|
+
flush_para()
|
|
166
|
+
flush_item()
|
|
167
|
+
in_fence = fence.group(1)
|
|
168
|
+
continue
|
|
169
|
+
for m in _IMPORT_RE.finditer(_strip_code_spans(line)):
|
|
170
|
+
imports.append((m.group(1), lineno))
|
|
171
|
+
if not line.strip():
|
|
172
|
+
flush_para()
|
|
173
|
+
flush_item()
|
|
174
|
+
continue
|
|
175
|
+
heading = _HEADING_RE.match(line)
|
|
176
|
+
if heading:
|
|
177
|
+
flush_para()
|
|
178
|
+
flush_item()
|
|
179
|
+
level = len(heading.group(1))
|
|
180
|
+
headings = [(lvl, h) for lvl, h in headings if lvl < level]
|
|
181
|
+
headings.append((level, plain(heading.group(2))))
|
|
182
|
+
continue
|
|
183
|
+
if _TABLE_RE.match(line):
|
|
184
|
+
flush_para()
|
|
185
|
+
flush_item()
|
|
186
|
+
if not _TABLE_SEP_RE.match(line):
|
|
187
|
+
cells = [c.strip() for c in _TABLE_RE.match(line).group(1).split("|")] # type: ignore[union-attr]
|
|
188
|
+
cells = [c for c in cells if c]
|
|
189
|
+
labels = [h for _, h in headings]
|
|
190
|
+
# A short first cell ("Errors", "Lint") is a label for the rule in the next cell.
|
|
191
|
+
first = plain(cells[0]) if cells else ""
|
|
192
|
+
if (
|
|
193
|
+
len(cells) >= 2
|
|
194
|
+
and len(first.split()) <= 3
|
|
195
|
+
and not is_directive(first)
|
|
196
|
+
and not first.lower().startswith(("no ", "not ", "only "))
|
|
197
|
+
):
|
|
198
|
+
labels = [*labels, plain(cells[0])]
|
|
199
|
+
cells = cells[1:]
|
|
200
|
+
_emit(" — ".join(cells), lineno, lineno, "table", labels, candidates)
|
|
201
|
+
continue
|
|
202
|
+
listed = _LIST_RE.match(line)
|
|
203
|
+
if listed:
|
|
204
|
+
flush_para()
|
|
205
|
+
flush_item()
|
|
206
|
+
item_indent = len(listed.group(1))
|
|
207
|
+
item.append((lineno, listed.group(2)))
|
|
208
|
+
continue
|
|
209
|
+
if item and (len(line) - len(line.lstrip())) > item_indent:
|
|
210
|
+
item.append((lineno, line))
|
|
211
|
+
continue
|
|
212
|
+
if line.lstrip().startswith(">"):
|
|
213
|
+
line = line.lstrip()[1:]
|
|
214
|
+
flush_item()
|
|
215
|
+
para.append((lineno, line))
|
|
216
|
+
flush_para()
|
|
217
|
+
flush_item()
|
|
218
|
+
return ParsedFile(frontmatter=frontmatter, candidates=candidates, imports=imports, body_start_line=start)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _strip_code_spans(line: str) -> str:
|
|
222
|
+
return _CODE_SPAN_RE.sub(lambda m: " " * len(m.group(0)), line)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _emit(raw: str, start: int, end: int, kind: str, headings: list[str], out: list[Candidate]) -> None:
|
|
226
|
+
text = plain(raw)
|
|
227
|
+
if len(text) < 3 or not re.search(r"[A-Za-z]{2}", text):
|
|
228
|
+
return
|
|
229
|
+
out.append(
|
|
230
|
+
Candidate(
|
|
231
|
+
text=text,
|
|
232
|
+
raw=raw.strip(),
|
|
233
|
+
line_start=start,
|
|
234
|
+
line_end=end,
|
|
235
|
+
kind=kind,
|
|
236
|
+
headings=list(headings),
|
|
237
|
+
code_spans=_CODE_SPAN_RE.findall(raw),
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _emit_sentences(para: list[tuple[int, str]], headings: list[str], out: list[Candidate]) -> None:
|
|
243
|
+
# Join the paragraph, split it into sentences, and map each sentence back to its first line.
|
|
244
|
+
joined = ""
|
|
245
|
+
starts: list[tuple[int, int]] = [] # (char offset, line number)
|
|
246
|
+
for lineno, text in para:
|
|
247
|
+
if joined:
|
|
248
|
+
joined += " "
|
|
249
|
+
starts.append((len(joined), lineno))
|
|
250
|
+
joined += text.strip()
|
|
251
|
+
pos = 0
|
|
252
|
+
for sentence in _SENTENCE_SPLIT_RE.split(joined):
|
|
253
|
+
idx = joined.find(sentence, pos)
|
|
254
|
+
if idx < 0:
|
|
255
|
+
idx = pos
|
|
256
|
+
pos = idx + len(sentence)
|
|
257
|
+
first = max((ln for off, ln in starts if off <= idx), default=para[0][0])
|
|
258
|
+
last = max((ln for off, ln in starts if off < pos), default=first)
|
|
259
|
+
if is_directive(plain(sentence)):
|
|
260
|
+
_emit(sentence, first, last, "sentence", headings, out)
|
|
File without changes
|
obey_cli/probes/facts.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Facts about a repository that probe templates fill in: languages, package manager, test command, layout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
|
|
11
|
+
from obey_cli.discover import list_files
|
|
12
|
+
|
|
13
|
+
_LANG_EXT = {
|
|
14
|
+
".py": "python",
|
|
15
|
+
".ts": "typescript",
|
|
16
|
+
".tsx": "typescript",
|
|
17
|
+
".js": "javascript",
|
|
18
|
+
".mjs": "javascript",
|
|
19
|
+
".go": "go",
|
|
20
|
+
".rs": "rust",
|
|
21
|
+
}
|
|
22
|
+
_SKIP_PARTS = {"generated", "gen", "vendor", "dist", "build", "migrations", "node_modules", "__pycache__"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Facts:
|
|
27
|
+
root: Path
|
|
28
|
+
files: list[str]
|
|
29
|
+
language: str # python | typescript | javascript | go | rust | unknown
|
|
30
|
+
package_manager: str | None # pnpm | npm | yarn | bun | uv | poetry | pip | go | cargo
|
|
31
|
+
test_command: str | None
|
|
32
|
+
lint_command: str | None
|
|
33
|
+
source_files: list[str] = field(default_factory=list) # ordinary source files, most central first
|
|
34
|
+
test_files: list[str] = field(default_factory=list)
|
|
35
|
+
source_dir: str = "src"
|
|
36
|
+
test_dir: str = "tests"
|
|
37
|
+
migrations: list[str] = field(default_factory=list)
|
|
38
|
+
generated: list[str] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def ext(self) -> str:
|
|
42
|
+
return {"python": ".py", "typescript": ".ts", "javascript": ".js", "go": ".go", "rust": ".rs"}.get(
|
|
43
|
+
self.language, ".txt"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read(root: Path, rel: str) -> str:
|
|
48
|
+
try:
|
|
49
|
+
return (root / rel).read_text(encoding="utf-8", errors="replace")
|
|
50
|
+
except OSError:
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def detect(root: Path) -> Facts:
|
|
55
|
+
files = list_files(root)
|
|
56
|
+
langs = Counter(_LANG_EXT[PurePosixPath(f).suffix] for f in files if PurePosixPath(f).suffix in _LANG_EXT)
|
|
57
|
+
language = langs.most_common(1)[0][0] if langs else "unknown"
|
|
58
|
+
if language == "javascript" and langs.get("typescript"):
|
|
59
|
+
language = "typescript"
|
|
60
|
+
names = set(files)
|
|
61
|
+
pm = None
|
|
62
|
+
pkg = json.loads(_read(root, "package.json") or "{}") if "package.json" in names else {}
|
|
63
|
+
declared = str(pkg.get("packageManager", ""))
|
|
64
|
+
for candidate, marker in (
|
|
65
|
+
("pnpm", "pnpm-lock.yaml"),
|
|
66
|
+
("yarn", "yarn.lock"),
|
|
67
|
+
("bun", "bun.lock"),
|
|
68
|
+
("bun", "bun.lockb"),
|
|
69
|
+
("npm", "package-lock.json"),
|
|
70
|
+
):
|
|
71
|
+
if marker in names or declared.startswith(candidate + "@"):
|
|
72
|
+
pm = candidate
|
|
73
|
+
break
|
|
74
|
+
if pm is None and "package.json" in names:
|
|
75
|
+
pm = "npm"
|
|
76
|
+
if pm is None:
|
|
77
|
+
pyproject = _read(root, "pyproject.toml")
|
|
78
|
+
if "uv.lock" in names or "[tool.uv]" in pyproject or "[dependency-groups]" in pyproject:
|
|
79
|
+
pm = "uv"
|
|
80
|
+
elif "poetry.lock" in names or "[tool.poetry]" in pyproject:
|
|
81
|
+
pm = "poetry"
|
|
82
|
+
elif pyproject or any(f.startswith("requirements") for f in names):
|
|
83
|
+
pm = "uv" if "uv" in _read(root, "Makefile") else "pip"
|
|
84
|
+
elif "go.mod" in names:
|
|
85
|
+
pm = "go"
|
|
86
|
+
elif "Cargo.toml" in names:
|
|
87
|
+
pm = "cargo"
|
|
88
|
+
test_cmd = lint_cmd = None
|
|
89
|
+
makefile = _read(root, "Makefile")
|
|
90
|
+
if re.search(r"^test:", makefile, re.M):
|
|
91
|
+
test_cmd = "make test"
|
|
92
|
+
if re.search(r"^lint:", makefile, re.M):
|
|
93
|
+
lint_cmd = "make lint"
|
|
94
|
+
scripts = pkg.get("scripts") or {}
|
|
95
|
+
if test_cmd is None and "test" in scripts:
|
|
96
|
+
test_cmd = f"{pm or 'npm'} test"
|
|
97
|
+
if lint_cmd is None and "lint" in scripts:
|
|
98
|
+
lint_cmd = f"{pm or 'npm'} run lint" if pm in (None, "npm") else f"{pm} lint"
|
|
99
|
+
if test_cmd is None and language == "python":
|
|
100
|
+
test_cmd = "uv run pytest" if pm == "uv" else "pytest"
|
|
101
|
+
if test_cmd is None and language == "go":
|
|
102
|
+
test_cmd = "go test ./..."
|
|
103
|
+
if test_cmd is None and language == "rust":
|
|
104
|
+
test_cmd = "cargo test"
|
|
105
|
+
ext = {"python": ".py", "typescript": ".ts", "javascript": ".js", "go": ".go", "rust": ".rs"}.get(language, "")
|
|
106
|
+
|
|
107
|
+
def is_test(f: str) -> bool:
|
|
108
|
+
name = PurePosixPath(f).name
|
|
109
|
+
return bool(re.match(r"test_.*\.py$|.*_test\.(py|go)$|.*\.(test|spec)\.[jt]sx?$", name)) or f.startswith(
|
|
110
|
+
("tests/", "test/")
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
sources = [
|
|
114
|
+
f
|
|
115
|
+
for f in files
|
|
116
|
+
if f.endswith(ext)
|
|
117
|
+
and not is_test(f)
|
|
118
|
+
and not (set(PurePosixPath(f).parts) & _SKIP_PARTS)
|
|
119
|
+
and not f.startswith(("scripts/", "tools/"))
|
|
120
|
+
and PurePosixPath(f).name not in ("__init__.py", "main.ts")
|
|
121
|
+
]
|
|
122
|
+
sources.sort(key=lambda f: (-len(_read(root, f)), f))
|
|
123
|
+
tests = [f for f in files if is_test(f)]
|
|
124
|
+
src_dir = Counter(PurePosixPath(f).parts[0] for f in sources if len(PurePosixPath(f).parts) > 1).most_common(1)
|
|
125
|
+
test_dir = (
|
|
126
|
+
"tests"
|
|
127
|
+
if any(f.startswith("tests/") for f in files)
|
|
128
|
+
else "test"
|
|
129
|
+
if any(f.startswith("test/") for f in files)
|
|
130
|
+
else (PurePosixPath(tests[0]).parent.as_posix() if tests else "tests")
|
|
131
|
+
)
|
|
132
|
+
migrations = sorted(
|
|
133
|
+
f for f in files if "/migrations/" in f"/{f}" and f.endswith((".sql", ".py")) and not f.endswith("__init__.py")
|
|
134
|
+
)
|
|
135
|
+
generated = (
|
|
136
|
+
sorted(
|
|
137
|
+
f
|
|
138
|
+
for f in files
|
|
139
|
+
if "generated" in PurePosixPath(f).parts
|
|
140
|
+
or re.search(r"(do not edit|generated by|auto-?generated)", _read(root, f)[:400], re.I)
|
|
141
|
+
)
|
|
142
|
+
if files
|
|
143
|
+
else []
|
|
144
|
+
)
|
|
145
|
+
generated = [g for g in generated if not g.endswith((".md", "__init__.py")) and g not in ("AGENTS.md", "CLAUDE.md")]
|
|
146
|
+
generated.sort(
|
|
147
|
+
key=lambda g: (not re.search(r"(do not edit|generated by|generated from)", _read(root, g)[:400], re.I), g)
|
|
148
|
+
)
|
|
149
|
+
return Facts(
|
|
150
|
+
root=root,
|
|
151
|
+
files=files,
|
|
152
|
+
language=language,
|
|
153
|
+
package_manager=pm,
|
|
154
|
+
test_command=test_cmd,
|
|
155
|
+
lint_command=lint_cmd,
|
|
156
|
+
source_files=sources,
|
|
157
|
+
test_files=tests,
|
|
158
|
+
source_dir=src_dir[0][0] if src_dir else "src",
|
|
159
|
+
test_dir=test_dir,
|
|
160
|
+
migrations=migrations,
|
|
161
|
+
generated=generated,
|
|
162
|
+
)
|