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
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
@@ -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
+ )