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/extract.py ADDED
@@ -0,0 +1,1342 @@
1
+ """Map instruction-file candidates to checkable rules with a deterministic pattern library.
2
+
3
+ Each candidate (a bullet, table row or directive sentence) is first classified as a rule or not. A rule is matched
4
+ against rule families in priority order (push, commit, commit message, final message, file naming, paired paths,
5
+ required content, dependencies, commands, forbidden paths, forbidden content). The first family that recognises it
6
+ produces a checker with parameters; a rule no family recognises is `uncheckable` with a reason. Optional LLM
7
+ extraction (`llm_propose`) only ever adds rules with `status: proposed`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections.abc import Callable, Iterable, Sequence
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from obey_cli.config import rule_id
18
+ from obey_cli.discover import InstructionFile
19
+ from obey_cli.parse_md import Candidate, is_directive
20
+
21
+ NEG = (
22
+ r"(?:never|do\s+not|don['’]?t|dont|must\s+not|mustn['’]t|should\s+not|shouldn['’]t|may\s+not|no|not|avoid|"
23
+ r"without|forbidden|prohibited|isn['’]t\s+allowed|is\s+not\s+allowed|are\s+not\s+allowed|stop)"
24
+ )
25
+ _NEG_RE = re.compile(rf"\b{NEG}\b", re.IGNORECASE)
26
+ _TASTE_RE = re.compile(
27
+ r"\b(clean|readable|idiomatic|maintainable|elegant|simple|small|short|concise|brief|clear|careful|thoughtful|"
28
+ r"consistent|meaningful|sensible|reasonable|appropriate|best practices?|good|nice|focused|minimal|tidy|"
29
+ r"understand|think|consider|prefer|explain|intent|why|behaviou?r)\b",
30
+ re.IGNORECASE,
31
+ )
32
+ _PATHY_RE = re.compile(r"^(?:\.{0,2}/)?[\w.*@\-\[\]{}]+(?:/[\w.*@\-\[\]{}]*)*/?$")
33
+ _EXT_LANG = {
34
+ "py": ["**/*.py"],
35
+ "ts": ["**/*.ts", "**/*.tsx"],
36
+ "js": ["**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs", "**/*.ts", "**/*.tsx", "**/*.vue", "**/*.svelte"],
37
+ "go": ["**/*.go"],
38
+ "rs": ["**/*.rs"],
39
+ }
40
+
41
+
42
+ @dataclass
43
+ class Extracted:
44
+ candidate: Candidate
45
+ file: InstructionFile
46
+ status: str
47
+ checker: dict[str, Any] | None
48
+ reason: str | None = None
49
+ origin: str = "pattern"
50
+ extra_scope: list[str] = field(default_factory=list)
51
+
52
+ def as_rule(self) -> dict[str, Any]:
53
+ text = self.candidate.text
54
+ return {
55
+ "id": rule_id(f"{self.file.path}:{text}"),
56
+ "text": text,
57
+ "source": {"file": self.file.path, "lines": [self.candidate.line_start, self.candidate.line_end]},
58
+ "scope": {"paths": list(self.file.scope_paths), "agents": None},
59
+ "checker": self.checker,
60
+ "status": self.status,
61
+ "reason": self.reason,
62
+ "origin": self.origin,
63
+ "probes": [],
64
+ }
65
+
66
+
67
+ # --------------------------------------------------------------------------------------------- helpers
68
+
69
+
70
+ def _lower(c: Candidate) -> str:
71
+ return c.text.lower()
72
+
73
+
74
+ def _negated(text: str) -> bool:
75
+ return bool(_NEG_RE.search(text))
76
+
77
+
78
+ def _spans(c: Candidate) -> list[str]:
79
+ return [s.strip() for s in c.code_spans if s.strip()]
80
+
81
+
82
+ _FILE_EXT = (
83
+ r"\.(py|pyi|ts|tsx|js|jsx|mjs|cjs|json|ya?ml|toml|md|mdx|rst|txt|sql|go|mod|sum|rs|lock|lockb|cfg|ini|"
84
+ r"sh|bash|html|css|scss|proto|graphql|env|xml|csv|ipynb|java|kt|rb|php|c|h|cpp|swift|tf|lua)$"
85
+ )
86
+
87
+
88
+ def _path_spans(c: Candidate) -> list[str]:
89
+ out = []
90
+ for s in _spans(c):
91
+ if " " in s or s.startswith("-") or "(" in s or "=" in s:
92
+ continue
93
+ if ("/" in s or re.search(_FILE_EXT, s) or s.endswith("*")) and _PATHY_RE.match(s):
94
+ out.append(s[2:] if s.startswith("./") else s)
95
+ if not out:
96
+ for m in re.finditer(r"(?<![\w`])((?:[\w.-]+/)+[\w.*-]*)", c.text):
97
+ if not m.group(1).startswith(("http", "www.")):
98
+ out.append(m.group(1))
99
+ return out
100
+
101
+
102
+ def _glob(path: str) -> str:
103
+ p = path.strip().rstrip(",;:.") if not path.endswith("/") else path.strip()
104
+ if p.startswith("/"):
105
+ p = p[1:]
106
+ if p.endswith("/**") or "*" in p.split("/")[-1]:
107
+ return p
108
+ if p.endswith("/"):
109
+ return p + "**"
110
+ if "." not in p.split("/")[-1]:
111
+ return p + "/**"
112
+ return p
113
+
114
+
115
+ def _dirs_in_text(c: Candidate) -> list[str]:
116
+ """Directories a rule is limited to ("in `src/`", "under app/")."""
117
+ dirs: list[str] = []
118
+ for m in re.finditer(
119
+ r"\b(?:in|under|inside|within|from|to)\s+`?([\w.-]+(?:/[\w.-]+)*/)`?((?:\s*(?:,|or|and)\s*`?"
120
+ r"[\w.-]+(?:/[\w.-]+)*/`?)*)",
121
+ c.raw,
122
+ ):
123
+ dirs.append(m.group(1))
124
+ dirs += re.findall(r"([\w.-]+(?:/[\w.-]+)*/)", m.group(2) or "")
125
+ return list(dict.fromkeys(dirs))
126
+
127
+
128
+ def _lang_globs(text: str, spans: Sequence[str]) -> list[str]:
129
+ t = text.lower()
130
+ for key, words in (
131
+ ("py", ("python", ".py", "pytest", "print(", "type: ignore", "noqa", "mypy", "django", "flask")),
132
+ ("ts", ("typescript", ".ts", "tsx", "@ts-", " any", "`any`", "tsc", "react")),
133
+ ("js", ("javascript", ".js", "console.", "eslint", "node", "jsx", "innerhtml", "document.")),
134
+ ("go", (" go ", "golang", ".go", "fmt.print", "panic(")),
135
+ ("rs", ("rust", ".rs", "unwrap(", "cargo", "clippy", "unsafe")),
136
+ ):
137
+ if any(w in t for w in words) or any(w in " ".join(spans).lower() for w in words):
138
+ return list(_EXT_LANG[key])
139
+ return ["**"]
140
+
141
+
142
+ def _scoped(globs: list[str], dirs: list[str]) -> list[str]:
143
+ if not dirs:
144
+ return globs
145
+ out = []
146
+ for d in dirs:
147
+ base = d.rstrip("/")
148
+ for g in globs:
149
+ tail = g[3:] if g.startswith("**/") else g
150
+ out.append(f"{base}/**" if tail in ("**", "*") else f"{base}/**/{tail}")
151
+ return out
152
+
153
+
154
+ # --------------------------------------------------------------------------------------------- families
155
+
156
+ Family = Callable[[Candidate], "dict[str, Any] | None"]
157
+
158
+
159
+ def fam_no_push(c: Candidate) -> dict[str, Any] | None:
160
+ t = _lower(c)
161
+ if not re.search(r"\bpush(es|ing)?\b|git push", t) or "push notification" in t:
162
+ return None
163
+ plain_push = re.search(
164
+ r"(never|not|don['’]?t|no)\b[^.;]{0,25}\b(run )?git push\b|\bpush(ing)? to (the |any |a )?"
165
+ r"(remote|origin|main|github)",
166
+ t,
167
+ )
168
+ if re.search(r"force[- ]push|--force|push -f\b|push --force", t) and not plain_push:
169
+ if _negated(t):
170
+ return {"type": "command_forbidden", "params": {"patterns": [r"^git push\b.*(\s-f\b|\s--force\b|\s\+)"]}}
171
+ return None
172
+ if _negated(t) or re.search(r"leave pushing|pushing is done|pushes? (is|are) done|maintainers? push", t):
173
+ return {"type": "no_push", "params": {}}
174
+ return None
175
+
176
+
177
+ def fam_no_commit(c: Candidate) -> dict[str, Any] | None:
178
+ t = _lower(c)
179
+ if not re.search(r"\bcommit(s|ted|ting)?\b", t) or re.search(r"commit (message|subject|title)s?", t):
180
+ return None
181
+ if re.search(r"\bcommit only when (asked|requested|told)|only commit when", t):
182
+ return {"type": "no_commit", "params": {}}
183
+ if re.search(r"commits are (made|created|done) by|leave (the )?changes (uncommitted|in the working tree)", t):
184
+ return {"type": "no_commit", "params": {}}
185
+ if _negated(t) and re.search(rf"\b{NEG}\b[^.;]{{0,40}}\b(commit|git commit|create commits?|make commits?)\b", t):
186
+ return {"type": "no_commit", "params": {}}
187
+ if re.search(
188
+ r"\bcommits?\b[^.;]{0,30}\b(must not|mustn't|should not|may not|shall not|is not to|are not to)\b"
189
+ r"[^.;]{0,20}\b(be )?(created|made)\b",
190
+ t,
191
+ ):
192
+ return {"type": "no_commit", "params": {}}
193
+ return None
194
+
195
+
196
+ CONVENTIONAL = r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([\w./, -]+\))?!?: \S"
197
+
198
+
199
+ def fam_commit_message(c: Candidate) -> dict[str, Any] | None:
200
+ t = _lower(c)
201
+ if not re.search(r"commit (message|subject|title)s?|commits? (should|must)? ?(start|begin|follow|use|look)", t):
202
+ return None
203
+ if "conventional commit" in t or re.search(r"type\(scope\)|`?type: ?summary`?|feat:|fix:", t):
204
+ types = re.findall(r"`(\w+)(?:\([^)]*\))?:`?", c.raw)
205
+ explicit = [
206
+ x
207
+ for x in types
208
+ if x.lower()
209
+ in {"feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"}
210
+ ]
211
+ if explicit and re.search(r"\b(start|begin)", t) and "conventional" not in t:
212
+ alt = "|".join(sorted(set(explicit)))
213
+ return {"type": "commit_message", "params": {"regex": rf"^({alt})(\([\w./, -]+\))?!?: \S"}}
214
+ regex = CONVENTIONAL
215
+ if re.search(r"lower[- ]case type", t):
216
+ regex = CONVENTIONAL
217
+ if re.search(r"package as (the )?scope|with (a|the) scope|scope is required", t):
218
+ regex = r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)\([\w./-]+\)!?: \S"
219
+ return {"type": "commit_message", "params": {"regex": regex}}
220
+ m = re.search(r"\b(start|begin|prefix)\w*\b.*?\bwith\b.*?`([^`]+)`", c.raw, re.IGNORECASE)
221
+ if m:
222
+ example = m.group(2)
223
+ ticket = re.match(r"([A-Z][A-Z0-9]+)-\d+", example)
224
+ if ticket or re.search(r"ticket|issue|jira", t):
225
+ prefix = ticket.group(1) if ticket else r"[A-Z][A-Z0-9]+"
226
+ return {"type": "commit_message", "params": {"regex": rf"^\[?{prefix}-\d+\]?:? \S"}}
227
+ spans = [s for s in _spans(c) if s.endswith(":")]
228
+ if spans:
229
+ alt = "|".join(re.escape(s[:-1]) for s in spans)
230
+ return {"type": "commit_message", "params": {"regex": rf"^({alt})(\([\w./, -]+\))?: \S"}}
231
+ if re.search(r"capital letter", t) or re.search(r"(\d+) characters", t):
232
+ parts = []
233
+ if "capital letter" in t:
234
+ parts.append(r"(?=[A-Z])")
235
+ limit = re.search(r"(\d+) characters", t)
236
+ body = rf"[^\n]{{1,{limit.group(1)}}}" if limit else r"[^\n]+"
237
+ tail = r"(?<!\.)" if re.search(r"(not|never) end with a (period|full stop|\.)", t) else ""
238
+ return {"type": "commit_message", "params": {"regex": "^" + "".join(parts) + body + tail + r"(\n|$)"}}
239
+ return None
240
+
241
+
242
+ def fam_final_message(c: Candidate) -> dict[str, Any] | None:
243
+ t = _lower(c)
244
+ first_sentence = re.split(r"(?<=[.!?])\s", t, maxsplit=1)[0]
245
+ if not re.search(r"\b(final|last|closing|reply|answer|response|message)\b", first_sentence):
246
+ return None
247
+ if not re.search(
248
+ r"\b(final|last|closing) (message|reply|answer|response)\b|\b(end|finish|close) (your|every|each) "
249
+ r"(reply|answer|response|message)",
250
+ t,
251
+ ):
252
+ return None
253
+ spans = _spans(c)
254
+ if re.search(r"emoji", t) and _negated(t):
255
+ return {"type": "final_message", "params": {"forbidden": r"[\U0001F300-\U0001FAFF☀-➿]"}}
256
+ if spans:
257
+ first = spans[0]
258
+ alts = [re.escape(s) for s in spans if s and len(s) < 60]
259
+ alt = "|".join(alts)
260
+ line_rule = re.search(r"line (starting|that starts|beginning)|under a line|a line ", t)
261
+ if re.search(r"\bend", t) and line_rule:
262
+ return {"type": "final_message", "params": {"required": rf"(^|\n)\s*({alt})[^\n]*\s*$"}}
263
+ if line_rule:
264
+ return {"type": "final_message", "params": {"required": rf"(^|\n)\s*({alt})"}}
265
+ if re.search(r"\bstart", t):
266
+ return {"type": "final_message", "params": {"required": rf"^\s*({alt})"}}
267
+ if re.search(r"\bend", t):
268
+ return {"type": "final_message", "params": {"required": rf"({alt})\s*$"}}
269
+ return {"type": "final_message", "params": {"required": re.escape(first)}}
270
+ return None
271
+
272
+
273
+ _CASES = {
274
+ "kebab": r"[a-z0-9]+(-[a-z0-9]+)*",
275
+ "snake": r"[a-z0-9]+(_[a-z0-9]+)*",
276
+ "pascal": r"[A-Z][a-zA-Z0-9]*",
277
+ "camel": r"[a-z][a-zA-Z0-9]*",
278
+ }
279
+
280
+
281
+ def fam_file_naming(c: Candidate) -> dict[str, Any] | None:
282
+ t = _lower(c)
283
+ if not re.search(r"\b(name|names|named|naming|file ?names?|filenames?)\b", t):
284
+ return None
285
+ if re.search(r"variable|function|class|branch|commit|env(ironment)? var", t) and not re.search(r"file", t):
286
+ return None
287
+ case = next((k for k in _CASES if re.search(rf"\b{k}[- _]?case\b", t)), None)
288
+ spans = _spans(c)
289
+ ext = None
290
+ for s in spans:
291
+ m = re.search(r"\.([A-Za-z0-9]{1,5})$", s)
292
+ if m:
293
+ ext = m.group(1)
294
+ break
295
+ if re.search(r"\btest files?\b|\btests? (are|is) named|\bname test", t):
296
+ pattern = next((s for s in spans if "<" in s or "*" in s or "test" in s.lower()), None)
297
+ if not pattern:
298
+ return None
299
+ rx = re.escape(pattern)
300
+ rx = re.sub(r"\\<[^>]*\\>|<[^>]*>", r"[A-Za-z0-9_.-]+", rx).replace(r"\*", r"[A-Za-z0-9_.-]+")
301
+ dirs = _dirs_in_text(c)
302
+ suffix = re.search(r"\.[A-Za-z0-9]+$", pattern)
303
+ if suffix and suffix.group(0) in (".py",):
304
+ globs = [f"{d.rstrip('/')}/**/*.py" for d in dirs] if dirs else ["tests/**/*.py", "test/**/*.py"]
305
+ if pattern.startswith("test_"):
306
+ globs = [g for g in globs]
307
+ elif suffix:
308
+ ending = suffix.group(0)
309
+ if pattern.endswith((".test.ts", ".spec.ts", ".test.tsx", ".test.js")):
310
+ ending = "." + ".".join(pattern.split(".")[-2:])
311
+ globs = [f"**/*{ending.replace('.test', '').replace('.spec', '')}"]
312
+ globs = [g for g in globs]
313
+ globs = ["**/*test*" + ending[ending.rfind(".") :], "**/*spec*" + ending[ending.rfind(".") :]]
314
+ globs = [f"{d.rstrip('/')}/{g[3:]}" if g.startswith("**/") else g for d in dirs for g in globs] or globs
315
+ else:
316
+ globs = [f"**/*{ending}"]
317
+ else:
318
+ return None
319
+ return {"type": "file_naming", "params": {"globs": globs, "pattern": rx}}
320
+ if case is None:
321
+ return None
322
+ base = _CASES[case]
323
+ lang = f"**/*.{ext}" if ext else None
324
+ globs = [lang] if lang else _lang_globs(c.text, spans)
325
+ if globs == ["**"]:
326
+ globs = ["**/*"]
327
+ if re.search(r"\b(module|modules)\b", t) and not ext:
328
+ globs = ["**/*.py"]
329
+ if re.search(r"component", t):
330
+ globs = ["**/components/**/*.tsx", "**/components/**/*.jsx"] if not ext else [f"**/components/**/*.{ext}"]
331
+ if re.search(r"src/components", c.raw):
332
+ globs = [f"src/components/**/*.{ext or 'tsx'}"]
333
+ dirs = _dirs_in_text(c)
334
+ if dirs and not re.search(r"component", t):
335
+ globs = _scoped(globs, dirs)
336
+ pattern = base + r"(\.[A-Za-z0-9]+)+"
337
+ if re.search(r"\b(page|guide|doc)", t) and not ext:
338
+ globs = _scoped(["**/*.md", "**/*.mdx"], dirs) if dirs else ["**/*.md", "**/*.mdx"]
339
+ return {"type": "file_naming", "params": {"globs": globs, "pattern": pattern}}
340
+
341
+
342
+ _TEST_GLOBS = [
343
+ "tests/**",
344
+ "test/**",
345
+ "**/tests/**",
346
+ "**/test_*.py",
347
+ "**/*_test.py",
348
+ "**/*_test.go",
349
+ "**/*.test.ts",
350
+ "**/*.test.tsx",
351
+ "**/*.test.js",
352
+ "**/*.spec.ts",
353
+ "**/*.spec.tsx",
354
+ "**/*.spec.js",
355
+ "**/__tests__/**",
356
+ ]
357
+
358
+
359
+ def fam_path_required_with(c: Candidate) -> dict[str, Any] | None:
360
+ t = _lower(c)
361
+ paths = _path_spans(c)
362
+ tests = re.search(r"\btests?\b|spec", t)
363
+ if (
364
+ re.search(
365
+ r"\b(when|whenever|if)\b.*\b(change|edit|modify|touch|add)\w*\b.*\b(update|add|change|include|"
366
+ r"bump|regenerate|edit)\b",
367
+ t,
368
+ )
369
+ or re.search(
370
+ r"\b(change|changes|edit|edits)\b.*\b(needs?|requires?|must (come|be accompanied)|come with|accompanied by|"
371
+ r"should come)\b",
372
+ t,
373
+ )
374
+ or re.search(r"\bnew (modules?|files?|functions?|code)\b.*\bneeds? (a |an )?(unit )?test", t)
375
+ or re.search(r"\b(add|write|include) (unit )?tests? (for|with) (every|each|any|all|new)\b", t)
376
+ ):
377
+ if len(paths) >= 2:
378
+ when, require = paths[0], paths[1]
379
+ return {"type": "path_required_with", "params": {"when": [_glob(when)], "require": [_glob(require)]}}
380
+ if len(paths) == 1 and tests:
381
+ p = paths[0]
382
+ if re.search(r"tests?/", p):
383
+ return {"type": "path_required_with", "params": {"when": ["src/**"], "require": [_glob(p)]}}
384
+ return {"type": "path_required_with", "params": {"when": [_glob(p)], "require": list(_TEST_GLOBS)}}
385
+ if tests and re.search(r"code|source|change|module|feature|function", t):
386
+ return {
387
+ "type": "path_required_with",
388
+ "params": {"when": ["src/**", "lib/**", "app/**"], "require": list(_TEST_GLOBS)},
389
+ }
390
+ return None
391
+
392
+
393
+ def fam_content_required(c: Candidate) -> dict[str, Any] | None:
394
+ t = _lower(c)
395
+ if not re.search(r"\bnew\b|\bevery\b|\beach\b|\ball\b", t):
396
+ return None
397
+ if not re.search(
398
+ r"\b(start|starts|begin|begins|open|opens|first line|header|heading|front ?matter|docstring|"
399
+ r"copyright|licen[cs]e|spdx)\b",
400
+ t,
401
+ ):
402
+ return None
403
+ if not re.search(r"\b(file|files|module|modules|page|pages|script|scripts|source)\b|\.[a-z]{1,4}`", t):
404
+ return None
405
+ spans = _spans(c)
406
+ regex = None
407
+ spans = [x for x in spans if not re.match(r"^[\w.-]+(/[\w.-]*)+/?$", x)] # paths say where, not what
408
+ span = next((s for s in spans if "spdx" in s.lower() or "copyright" in s.lower() or "license" in s.lower()), None)
409
+ if span:
410
+ regex = re.escape(span)
411
+ elif "spdx" in t:
412
+ regex = r"SPDX-License-Identifier:"
413
+ elif "docstring" in t:
414
+ regex = r'^\s*[rRuU]?("""|\'\'\')'
415
+ elif "front matter" in t or "frontmatter" in t:
416
+ field_ = next((s for s in spans if s.endswith(":")), None)
417
+ regex = rf"^\s*{re.escape(field_)}\s*\S" if field_ else r"^---\s*$"
418
+ elif re.search(r"\bh1\b|single h1|# heading|top-level heading", t):
419
+ regex = r"^# \S"
420
+ elif spans:
421
+ regex = re.escape(spans[0])
422
+ if regex is None:
423
+ return None
424
+ globs: list[str] = []
425
+ for s in spans:
426
+ m = re.match(r"^\*?\.([a-z0-9]{1,5})$", s)
427
+ if m:
428
+ globs.append(f"**/*.{m.group(1)}")
429
+ if not globs:
430
+ if re.search(r"\b(page|pages|guide|guides|docs?)\b", t):
431
+ globs = ["**/*.md", "**/*.mdx"]
432
+ elif (re.search(r"\bpython\b|module|\.py\b", t) and "docstring" in t) or re.search(r"\bpython file", t):
433
+ globs = ["**/*.py"]
434
+ else:
435
+ globs = _lang_globs(c.text, spans)
436
+ if globs == ["**"]:
437
+ globs = ["**/*"]
438
+ if re.search(r"\.(rs)\b|rust", t):
439
+ globs = ["**/*.rs"]
440
+ if re.search(r"\bscripts?\b", t) and "scripts/" not in c.raw and globs == ["**/*"]:
441
+ globs = ["**/*.py", "**/*.sh", "**/*.mjs", "**/*.js", "**/*.ts"]
442
+ dirs = [d for d in _dirs_in_text(c)]
443
+ if dirs:
444
+ globs = _scoped(globs, dirs)
445
+ first = 10
446
+ if re.search(r"first line|on the first line|line 1", t):
447
+ first = 1
448
+ return {
449
+ "type": "content_required_added",
450
+ "params": {"regex": regex, "globs": globs, "scope": "new_files", "first_lines": first},
451
+ }
452
+
453
+
454
+ _DEP_WORDS = (
455
+ r"(dependenc(y|ies)|packages?|librar(y|ies)|third[- ]party (modules?|code|libraries)|modules?|deps|crates?|gems?|"
456
+ r"npm packages?|pip packages?)"
457
+ )
458
+
459
+
460
+ def fam_dependency(c: Candidate) -> dict[str, Any] | None:
461
+ t = _lower(c)
462
+ if not re.search(_DEP_WORDS, t):
463
+ return None
464
+ if re.search(r"\bimport", t) and not re.search(r"\badd", t):
465
+ return None
466
+ if re.search(r"(managed|manage) (with|by)|lockfile|lock file", t) and not re.search(
467
+ r"approval|without|new depend", t
468
+ ):
469
+ return None
470
+ if re.search(r"\bmodules?\b", t) and not re.search(
471
+ r"third[- ]party|external|go\.mod|dependenc|package|librar|"
472
+ r"npm|pip|crate",
473
+ t,
474
+ ):
475
+ return None
476
+ add = re.search(r"\b(add|adding|added|introduce|introducing|install|installing|pull in|bring in|new)\b", t)
477
+ gate = re.search(
478
+ r"approv|ask (first|before)|without (asking|approval|discussion)|need(s)? (approval|sign[- ]off)|"
479
+ r"permission|only after|check with|discuss|agreed|agreement|sign[- ]off|signed off",
480
+ t,
481
+ )
482
+ pm_in_code = any(_PM_RE.search(s) for s in _spans(c)) or re.search(
483
+ r"\b(pip|npm|yarn|pnpm|poetry|uv|conda) "
484
+ r"(install|add|i)\b",
485
+ t,
486
+ )
487
+ if pm_in_code and not gate and not re.search(r"new (runtime |third[- ]party )?(dependenc|package|librar)", t):
488
+ return None
489
+ banned = (
490
+ _negated(t)
491
+ or re.search(r"\b(zero|no) (runtime |new |third[- ]party )?dependenc", t)
492
+ or re.search(r"standard library only|stdlib only|use the standard library", t)
493
+ )
494
+ only = re.search(r"\bonly\b.*\bmay be added\b|\bmay add\b", t)
495
+ if not (add and (gate or banned or only)):
496
+ return None
497
+ allow = []
498
+ if re.search(r"\b(only|except|may add|may be added|you may)\b", t):
499
+ allow = [
500
+ s
501
+ for s in _spans(c)
502
+ if re.match(r"^[A-Za-z0-9_.@/-]+$", s)
503
+ and "/" not in s.strip("@")
504
+ and not s.endswith((".txt", ".json", ".toml", ".mod", ".lock", ".yaml"))
505
+ ]
506
+ params: dict[str, Any] = {}
507
+ manifests = [
508
+ s
509
+ for s in _spans(c)
510
+ if re.search(
511
+ r"(package\.json|pyproject\.toml|requirements[\w.-]*\.txt|go\.mod|"
512
+ r"Cargo\.toml|setup\.cfg|Pipfile)$",
513
+ s,
514
+ )
515
+ ]
516
+ if manifests:
517
+ params["manifests"] = [m if "/" in m else f"**/{m}" if m != m.split("/")[-1] else m for m in manifests]
518
+ if re.search(r"\bgo\.mod\b", t) and "manifests" not in params:
519
+ params["manifests"] = ["go.mod"]
520
+ if allow:
521
+ params["allow"] = allow
522
+ return {"type": "dependency_unchanged", "params": params}
523
+
524
+
525
+ # Package managers and the commands that count as using them.
526
+ _PMS: dict[str, dict[str, Any]] = {
527
+ "npm": {
528
+ "forbid": [
529
+ r"^npm(\s+(?!(-v|--version|-h|--help|help|view|v|info|show|ls|list|outdated|whoami|config get)"
530
+ r"\b)\S+|\s*$)"
531
+ ],
532
+ "eco": "js",
533
+ },
534
+ "npx": {"forbid": [r"^npx\b"], "eco": "js"},
535
+ "yarn": {"forbid": [r"^yarn(\s+(?!(-v|--version|--help|help|info|why|list)\b)\S+|\s*$)"], "eco": "js"},
536
+ "pnpm": {
537
+ "forbid": [r"^pnpm(\s+(?!(-v|--version|--help|help|view|info|why|ls|list|outdated)\b)\S+|\s*$)"],
538
+ "eco": "js",
539
+ },
540
+ "bun": {"forbid": [r"^bun\s+(add|install|i|remove|rm|update|pm|link)\b", r"^bunx\b"], "eco": "js"},
541
+ "pip": {
542
+ "forbid": [r"^pip3?\s+(install|uninstall|download)\b", r"^python3?(\.\d+)? -m pip\s+(install|uninstall)\b"],
543
+ "eco": "py",
544
+ },
545
+ "pip3": {"forbid": [r"^pip3?\s+(install|uninstall|download)\b"], "eco": "py"},
546
+ "poetry": {"forbid": [r"^poetry\s+(add|install|remove|update|lock)\b"], "eco": "py"},
547
+ "pipenv": {"forbid": [r"^pipenv\b"], "eco": "py"},
548
+ "conda": {"forbid": [r"^conda\s+(install|create|update|env)\b"], "eco": "py"},
549
+ "mamba": {"forbid": [r"^(micro)?mamba\s+(install|create|update)\b"], "eco": "py"},
550
+ "uv": {"forbid": [r"^uv\s+(add|remove|pip|sync|lock)\b"], "eco": "py"},
551
+ "easy_install": {"forbid": [r"^easy_install\b"], "eco": "py"},
552
+ }
553
+ _PREFERRED = {
554
+ "npm": r"^npm\b",
555
+ "yarn": r"^yarn\b",
556
+ "pnpm": r"^pnpm\b",
557
+ "bun": r"^bunx?\b",
558
+ "pip": r"^pip3?\b",
559
+ "poetry": r"^poetry\b",
560
+ "pipenv": r"^pipenv\b",
561
+ "uv": r"^uvx?\b",
562
+ "conda": r"^conda\b",
563
+ }
564
+ _LOCKFILES = {
565
+ "js": ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "bun.lock", "npm-shrinkwrap.json"],
566
+ "py": ["poetry.lock", "uv.lock", "Pipfile.lock"],
567
+ }
568
+ _OWN_LOCK = {
569
+ "npm": "package-lock.json",
570
+ "yarn": "yarn.lock",
571
+ "pnpm": "pnpm-lock.yaml",
572
+ "bun": "bun.lock",
573
+ "poetry": "poetry.lock",
574
+ "uv": "uv.lock",
575
+ "pipenv": "Pipfile.lock",
576
+ }
577
+ _PM_RE = re.compile(
578
+ r"\b(npm|npx|yarn|pnpm|bunx?|pip3?|poetry|pipenv|conda|mamba|uv|easy_install)\b(?![-.](lock|workspace|yaml|json|toml))",
579
+ re.IGNORECASE,
580
+ )
581
+
582
+
583
+ def _segment_pattern(cmd: str) -> str:
584
+ words = cmd.strip().split()
585
+ if not words:
586
+ return ""
587
+ return "^" + r"\s+".join(re.escape(w) for w in words) + r"\b"
588
+
589
+
590
+ def fam_command_forbidden(c: Candidate) -> dict[str, Any] | None:
591
+ t = _lower(c)
592
+ raw = c.raw
593
+ pm_ban = re.search(
594
+ rf"\b{NEG}\b[^.;]{{0,15}}\b(run|use|call|invoke)\b[^.;]{{0,12}}\b(npm|npx|yarn|pnpm|pip3?|poetry|bunx?|conda|uv)\b",
595
+ t,
596
+ )
597
+ lock_edit = any(re.search(r"lock(b)?$|\.lock\b|-lock\.", s) for s in _spans(c)) and re.search(
598
+ rf"\b{_EDIT_VERB}\b", t
599
+ )
600
+ if not pm_ban and (re.search(r"by hand|manually|hand-edit|hand edit", t) or lock_edit):
601
+ return None # "bun.lock MUST NOT be edited by hand" is about a path
602
+ mentions = [m.group(1).lower() for m in _PM_RE.finditer(raw)]
603
+ if not mentions and not re.search(rf"\b{NEG}\b.*`[^`]+`", raw, re.IGNORECASE):
604
+ return None
605
+ if mentions:
606
+ positive: list[str] = []
607
+ negative: list[str] = []
608
+ splitter = (
609
+ r"[;.]\s+|,\s+(?=(?:so|but|and\s+(?:never|not|do not|don't)|never|not|don|do not)\b)|\bso\b|"
610
+ r"\binstead of\b|\brather than\b|\bover\b"
611
+ )
612
+ for clause in re.split(splitter, raw):
613
+ cl = clause.lower()
614
+ names = [m.group(1).lower() for m in _PM_RE.finditer(clause)]
615
+ if not names:
616
+ continue
617
+ if _negated(cl) or re.search(r"creates? conflicting|conflict", cl):
618
+ negative += names
619
+ else:
620
+ positive += names
621
+ m = re.search(r"\b(?:instead of|rather than|over)\b(.*)", raw, re.IGNORECASE)
622
+ if m:
623
+ negative += [n.group(1).lower() for n in _PM_RE.finditer(m.group(1))]
624
+ positive = [p for p in positive if p not in negative]
625
+ managed = re.search(
626
+ r"managed with|managed by|for (all|every|each)? ?(package|dependenc)|use \S+ for|"
627
+ r"\bonly\b|for everything|for installs|for dependency",
628
+ t,
629
+ )
630
+ positive = [p for p in dict.fromkeys(positive) if p not in ("npx",)]
631
+ negative = list(dict.fromkeys(negative))
632
+ if not negative and positive and not managed and not re.search(r"^\W*use\b", t):
633
+ return None
634
+ if not negative and positive:
635
+ eco = _PMS.get(positive[0], {}).get("eco")
636
+ skip = {"pip3", "easy_install", "mamba"}
637
+ if not re.search(r"\bevery\b|\ball\b|\beverything\b|\bany\b", t):
638
+ skip.add("npx")
639
+ negative = [pm for pm, info in _PMS.items() if info["eco"] == eco and pm not in positive and pm not in skip]
640
+ if eco == "py" and positive[0] == "pip":
641
+ negative = [pm for pm in negative if pm != "uv"]
642
+ if not negative:
643
+ return None
644
+ patterns: list[str] = []
645
+ for pm in negative:
646
+ for p in _PMS.get(pm, {}).get("forbid", []):
647
+ if positive and positive[0] == "uv" and pm == "pip":
648
+ pass
649
+ patterns.append(p)
650
+ if "pip" in negative or "pip3" in negative:
651
+ patterns.append(r"^python3?(\.\d+)? -m pip\s+(install|uninstall)\b")
652
+ params: dict[str, Any] = {"patterns": list(dict.fromkeys(patterns))}
653
+ if positive:
654
+ pref = positive[0]
655
+ params["preferred"] = pref
656
+ if pref in _PREFERRED:
657
+ params["preferred_patterns"] = [_PREFERRED[pref]]
658
+ eco = _PMS.get(pref, {}).get("eco")
659
+ if eco and pref in _OWN_LOCK:
660
+ params["manual_edit_paths"] = [lf for lf in _LOCKFILES[eco] if lf != _OWN_LOCK[pref]]
661
+ return {"type": "command_forbidden", "params": params}
662
+ # "Do not run `make deploy`", "Never call rustc directly".
663
+ verb = re.search(rf"\b{NEG}\b[^.;]{{0,30}}\b(run|use|call|invoke|execute)\b", t)
664
+ if verb:
665
+ spans = _spans(c) or [w for w in re.findall(r"\b[a-z][\w.-]+\b", t) if w in _KNOWN_CLIS]
666
+ cmds = [
667
+ s
668
+ for s in spans
669
+ if re.match(r"^[a-z][\w.-]*(\s+[\w./:=-]+)*$", s)
670
+ and not s.startswith(("git commit", "git push"))
671
+ and "/" not in s.split()[0]
672
+ and ((verb.group(1) in ("run", "execute", "invoke") and " " in s) or s.split()[0] in _KNOWN_CLIS)
673
+ ]
674
+ if cmds:
675
+ return {"type": "command_forbidden", "params": {"patterns": [_segment_pattern(x) for x in cmds]}}
676
+ return None
677
+
678
+
679
+ _KNOWN_CLIS = {
680
+ "rustc",
681
+ "gcc",
682
+ "g++",
683
+ "clang",
684
+ "cc",
685
+ "python",
686
+ "python3",
687
+ "node",
688
+ "deno",
689
+ "tsc",
690
+ "docker",
691
+ "kubectl",
692
+ "terraform",
693
+ "helm",
694
+ "make",
695
+ "curl",
696
+ "wget",
697
+ "sudo",
698
+ "rm",
699
+ "chmod",
700
+ "ssh",
701
+ "scp",
702
+ "brew",
703
+ "apt",
704
+ "apt-get",
705
+ "yum",
706
+ "dnf",
707
+ "gofmt",
708
+ "go",
709
+ "cargo",
710
+ "rustup",
711
+ "black",
712
+ "isort",
713
+ "autopep8",
714
+ "prettier",
715
+ "eslint",
716
+ "jest",
717
+ "mocha",
718
+ "tox",
719
+ "pre-commit",
720
+ "alembic",
721
+ "flask",
722
+ "django-admin",
723
+ "psql",
724
+ "mysql",
725
+ "sqlite3",
726
+ "redis-cli",
727
+ "aws",
728
+ "gcloud",
729
+ "az",
730
+ "firebase",
731
+ "vercel",
732
+ "netlify",
733
+ "heroku",
734
+ "gh",
735
+ "hub",
736
+ "npx",
737
+ "bunx",
738
+ "uvx",
739
+ "pipx",
740
+ "conda",
741
+ "pip",
742
+ "pip3",
743
+ }
744
+
745
+
746
+ _TEST_CMD = (
747
+ r"\b(pytest|py\.test|vitest|jest|mocha|go test|cargo test|tox|nox|unittest|bun test|deno test|"
748
+ r"(npm|pnpm|yarn|bun)( run)? test|make (test|check)|manage\.py test|phpunit|rspec)\b"
749
+ )
750
+
751
+
752
+ def fam_command_required(c: Candidate) -> dict[str, Any] | None:
753
+ t = _lower(c)
754
+ if _negated(t) and not re.search(r"\b(before|after|until)\b", t):
755
+ return None
756
+ if not re.search(r"\b(run|execute|make sure|ensure|check that|must pass|should pass|pass(es)?)\b", t):
757
+ return None
758
+ if not re.search(
759
+ r"\b(before|after|when|once|whenever|until|every time|at the end|pass|passes|green|done|finish)", t
760
+ ):
761
+ return None
762
+ spans = [s for s in _spans(c) if re.match(r"^[a-z][\w.:-]*(\s+[^\s`]+)*$", s) and not s.endswith("/")]
763
+ spans += [m.group(1) for m in re.finditer(r'"([a-z][\w.-]*(?: [\w./:=-]+)*)"', c.raw)]
764
+ pattern = None
765
+ if spans:
766
+ pattern = "|".join(_core_pattern(s) for s in spans[:3])
767
+ elif re.search(r"\btests?\b|test suite", t):
768
+ pattern = _TEST_CMD
769
+ elif re.search(r"\blint", t):
770
+ pattern = r"\b(ruff|eslint|flake8|pylint|golangci-lint|clippy|(npm|pnpm|yarn|bun)( run)? lint|make lint)\b"
771
+ elif re.search(r"type ?check|mypy|tsc|pyright", t):
772
+ pattern = r"\b(mypy|pyright|tsc|(npm|pnpm|yarn|bun)( run)? typecheck|make typecheck)\b"
773
+ if not pattern:
774
+ return None
775
+ params: dict[str, Any] = {"pattern": pattern}
776
+ edit_globs = _edit_globs(c)
777
+ if edit_globs:
778
+ params["edit_globs"] = edit_globs
779
+ return {"type": "command_required", "params": params}
780
+
781
+
782
+ def _core_pattern(cmd: str) -> str:
783
+ words = cmd.split()
784
+ wrappers = {
785
+ "uv": {"run"},
786
+ "poetry": {"run"},
787
+ "pipenv": {"run"},
788
+ "npx": set(),
789
+ "pnpm": {"exec", "dlx"},
790
+ "bunx": set(),
791
+ "uvx": set(),
792
+ }
793
+ while words and words[0] in wrappers:
794
+ sub = wrappers[words[0]]
795
+ if sub and len(words) > 1 and words[1] in sub:
796
+ words = words[2:]
797
+ elif not sub:
798
+ words = words[1:]
799
+ else:
800
+ break
801
+ if len(words) >= 3 and words[0].startswith("python") and words[1] == "-m":
802
+ words = words[2:]
803
+ if words and words[0] in ("npm", "pnpm", "yarn", "bun") and len(words) >= 2:
804
+ script = words[2] if words[1] == "run" and len(words) > 2 else words[1]
805
+ return rf"^{words[0]}\s+(run\s+)?{re.escape(script)}\b"
806
+ # Every word counts ("cargo clippy --all-targets -- -D warnings" is not satisfied by "cargo clippy").
807
+ core = [w for w in words if w not in (".", "./...", "./")] or words[:1]
808
+ body = r"\s+".join(re.escape(w) for w in core)
809
+ return "^" + body + (r"\b" if re.search(r"\w$", core[-1]) else "")
810
+
811
+
812
+ def _edit_globs(c: Candidate) -> list[str]:
813
+ t = _lower(c)
814
+ globs: list[str] = []
815
+ for d in _dirs_in_text(c):
816
+ globs.append(d.rstrip("/") + "/**")
817
+ for m in re.finditer(r"`([^`]*\.[a-z]{1,4})`\s+files?|any\s+`?\*?\.([a-z]{1,4})`?\s+file", c.raw):
818
+ ext = (m.group(1) or m.group(2)).lstrip("*").lstrip(".")
819
+ if "/" not in ext:
820
+ globs.append(f"**/*.{ext.split('.')[-1]}")
821
+ if not globs:
822
+ if re.search(r"python (code|files?)|\.py\b", t):
823
+ globs = ["**/*.py"]
824
+ elif re.search(r"\btest files?\b", t):
825
+ globs = ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/test_*.py", "tests/**"]
826
+ elif re.search(r"docs? change|documentation", t):
827
+ globs = ["docs/**", "**/*.md", "**/*.mdx"]
828
+ elif re.search(r"\bgo (code|files?)\b|\.go\b", t):
829
+ globs = ["**/*.go"]
830
+ return globs
831
+
832
+
833
+ _EDIT_VERB = (
834
+ r"(edit|edited|editing|modify|modified|modifying|change|changed|changing|touch|touched|write|writing|"
835
+ r"written|alter|altered|hand-edit|hand edit|update|updated|rewrite|rewritten|delete|remove)"
836
+ )
837
+
838
+
839
+ def fam_path_forbidden(c: Candidate) -> dict[str, Any] | None:
840
+ t = _lower(c)
841
+ paths = _path_spans(c)
842
+ if not paths and re.search(r"\bmigrations?\b", t) and re.search(rf"\b{_EDIT_VERB}\b", t) and _negated(t):
843
+ existing = bool(re.search(r"\b(existing|applied|merged|already|old|previous)\b", t))
844
+ return {
845
+ "type": "path_forbidden",
846
+ "params": {"globs": ["**/migrations/**"], "existing_only": existing}
847
+ if existing
848
+ else {"globs": ["**/migrations/**"]},
849
+ }
850
+ if not paths:
851
+ return None
852
+ # "Do not write `export default` in `src/`" forbids code, not the directory.
853
+ code_objects = [x for x in _spans(c) if x not in paths and not re.match(r"^[\w.-]+(/[\w.-]*)*/?$", x)]
854
+ if code_objects and re.search(r"\b(write|use|add|call|import|leave|put)\b", t):
855
+ return None
856
+ generated = re.search(
857
+ r"\b(generated|regenerated|build output|produced by|vendored|immutable|read-only|"
858
+ r"auto-?generated|do not edit|don't edit)\b",
859
+ t,
860
+ )
861
+ edit = re.search(rf"\b{_EDIT_VERB}\b", t)
862
+ if not ((_negated(t) and edit) or (generated and (_negated(t) or "never" in t))):
863
+ return None
864
+ if re.search(r"\b(when|whenever|if) you (change|edit)|\bneeds?\b|\bmust come\b", t) and len(paths) >= 2:
865
+ return None
866
+ existing = bool(
867
+ re.search(
868
+ r"\b(existing|applied|merged|already|once (merged|applied|committed|released)|"
869
+ r"previous|old)\b",
870
+ t,
871
+ )
872
+ )
873
+ # Paths named as the source to change ("change `openapi.yaml` and regenerate") are not forbidden.
874
+ sources = {
875
+ m.group(1)
876
+ for m in re.finditer(r"\b(?:change|edit|update|from|in)\s+`([^`]+)`\s*,?\s+(?:and|then|instead)\b", c.raw, re.I)
877
+ }
878
+ sources |= {m.group(1) for m in re.finditer(r"\bfrom\s+`([^`]+)`", c.raw, re.I)}
879
+ paths = [x for x in paths if x not in sources] or paths
880
+ globs = [_glob(p) for p in paths if not re.match(r"^(go|npm|pnpm|uv|python|make|cargo)\b", p)]
881
+ globs = [g for g in globs if not g.endswith((".py/**",)) and g not in ("**",)]
882
+ if re.search(r"run `|regenerat|produced by|instead", t) and len(globs) > 1:
883
+ # "Never edit `internal/gen/`; it is produced by `tools/gen.py`": the second path is the generator.
884
+ generator = [
885
+ g for g in globs if re.search(r"\.(py|sh|mjs|js|ts|go)$", g) or g.startswith(("tools/", "scripts/"))
886
+ ]
887
+ globs = [g for g in globs if g not in generator] or globs
888
+ if not globs:
889
+ return None
890
+ params: dict[str, Any] = {"globs": globs}
891
+ if existing:
892
+ params["existing_only"] = True
893
+ # "regenerate them with `node scripts/gen.mjs`": the generator may change the paths.
894
+ gen = re.search(r"\b(regenerat\w*|generat\w* (?:by|with|from)|produced by|run)\b[^`]{0,40}`([^`]+)`", c.raw, re.I)
895
+ if gen and re.match(r"^[a-z][\w.-]*(\s+[\w./:=-]+)*$", gen.group(2)) and not gen.group(2).endswith("/"):
896
+ params["allow_via"] = [_segment_pattern(gen.group(2))]
897
+ return {"type": "path_forbidden", "params": params}
898
+
899
+
900
+ # Known code constructs: (trigger regex on the rule text, added-line regex, language).
901
+ _CONSTRUCTS: list[tuple[str, str, str]] = [
902
+ (r"\bany\b", r"(:\s*any\b|\bas\s+any\b|<any>|\bany\[\]|Array<any>|\(any\)|=\s*any\b|,\s*any\b|<any,|\bany>)", "ts"),
903
+ (r"console\.log", r"\bconsole\.log\s*\(", "js"),
904
+ (r"console\.\*|`console`|any console", r"\bconsole\.\w+\s*\(", "js"),
905
+ (r"print\(\)|`print`|print statements?|\bprint\b", r"(^|[^\w.])print\s*\(", "py"),
906
+ (r"type:\s*ignore", r"#\s*type:\s*ignore", "py"),
907
+ (r"noqa", r"#\s*noqa\b", "py"),
908
+ (r"eslint-disable", r"eslint-disable", "js"),
909
+ (r"@ts-ignore|@ts-nocheck|@ts-expect-error", "@ts-directives", "ts"),
910
+ (r"\btodo\b|\bfixme\b|\bxxx\b|\bhack\b", "markers", "*"),
911
+ (r"wildcard imports?|import \*", r"^\s*from\s+\S+\s+import\s+\*|import\s+\*\s+as\b", "py"),
912
+ (
913
+ r"test\.skip|it\.skip|describe\.skip|\.skip\b|xit\b",
914
+ r"\b(it|test|describe)\.skip\s*\(|\bx(it|describe)\s*\(",
915
+ "ts",
916
+ ),
917
+ (r"pytest\.mark\.skip|@skip", r"@pytest\.mark\.skip|@unittest\.skip", "py"),
918
+ (r"dangerouslysetinnerhtml", r"\bdangerouslySetInnerHTML\b", "ts"),
919
+ (r"document\.write|innerhtml", r"document\.write\s*\(|\.innerHTML\s*=", "js"),
920
+ (r"\.unwrap\(\)|unwrap\b", r"\.unwrap\s*\(\s*\)", "rs"),
921
+ (r"\bunsafe\b", r"\bunsafe\s*(\{|fn\b|impl\b|trait\b)", "rs"),
922
+ (r"fmt\.print", r"\bfmt\.Print(ln|f)?\s*\(", "go"),
923
+ (r"\bpanic\(", r"\bpanic\s*\(", "go"),
924
+ (r"\bvar\b", r"(^|[^\w.])var\s+\w", "js"),
925
+ (r"export default|default exports?", r"\bexport\s+default\b", "ts"),
926
+ (r"\benums?\b", r"(^|\s)(const\s+)?enum\s+\w+", "ts"),
927
+ (r"non-null assertion", r"\w!\.|\w!\)|\w!;|\w!\s*$", "ts"),
928
+ (r"\bassert\b statements?|bare assert", r"^\s*assert\b", "py"),
929
+ (r"\beval\(", r"\beval\s*\(", "*"),
930
+ (r"breakpoint\(\)|pdb", r"\bbreakpoint\s*\(|\bpdb\.set_trace\s*\(", "py"),
931
+ (r"debugger", r"\bdebugger\s*;?", "js"),
932
+ (r"bare except", r"^\s*except\s*:", "py"),
933
+ (r"\bdatetime\.now\(\)|datetime\.utcnow", r"\bdatetime\.(utc)?now\s*\(", "py"),
934
+ (r"\bfetch\(\)|`fetch`", r"\bfetch\s*\(", "js"),
935
+ (r"\bsetTimeout\b", r"\bsetTimeout\s*\(", "js"),
936
+ (r"\brequire\(\)|`require`", r"\brequire\s*\(", "js"),
937
+ (r"\bos\.system\b", r"\bos\.system\s*\(", "py"),
938
+ (r"\bsubprocess\b.*shell=true|shell=True", r"shell\s*=\s*True", "py"),
939
+ ]
940
+
941
+
942
+ def fam_content_forbidden(c: Candidate) -> dict[str, Any] | None:
943
+ t = _lower(c)
944
+ prefer = re.search(r"\bprefer\b.*\bover\b(.*)|\binstead of\b(.*)|\brather than\b(.*)", t)
945
+ if not (_negated(t) or prefer or re.search(r"has no place|not welcome|(is|are) banned", t)):
946
+ return None
947
+ if re.search(r"\b(commit|push|depend|package|install|migration)", t) and not _spans(c):
948
+ return None
949
+ if prefer and not _negated(t):
950
+ # "Prefer named exports over default exports": the forbidden thing is what comes after "over".
951
+ tail = next(g for g in prefer.groups() if g is not None)
952
+ c = Candidate(
953
+ text=tail,
954
+ raw=tail,
955
+ line_start=c.line_start,
956
+ line_end=c.line_end,
957
+ kind=c.kind,
958
+ headings=c.headings,
959
+ code_spans=[s for s in c.code_spans if s.lower() in tail],
960
+ )
961
+ t = tail
962
+ # Code named in the positive part ("...; use tracing", "use logx instead") is not forbidden.
963
+ negative_part = re.split(r"[;.:]\s*(?:use|prefer|instead)\b|,\s*use\b|\binstead\b", c.raw, maxsplit=1)[0]
964
+ spans = [x for x in _spans(c) if x in negative_part] or _spans(c)
965
+ quoted_words = re.findall(r'"([^"\s]{2,30})"', c.raw)
966
+ regex = None
967
+ lang = "*"
968
+ for trigger, rx, lng in _CONSTRUCTS:
969
+ hay = t + " " + " ".join(s.lower() for s in spans)
970
+ if re.search(trigger, hay, re.IGNORECASE):
971
+ if trigger == r"\bany\b" and not (
972
+ re.search(r"`any`|\bany\b type|type `?any|\bany\b\W*(type|;|\.|$)", hay) or "typescript" in hay
973
+ ):
974
+ continue
975
+ if trigger.startswith(r"\bvar\b") and "`var`" not in hay:
976
+ continue
977
+ if trigger.startswith(r"print") and not re.search(r"print", " ".join(spans).lower() + t):
978
+ continue
979
+ regex, lang = rx, lng
980
+ if trigger == r"\bany\b" and re.search(r"console", hay):
981
+ continue
982
+ if rx == "@ts-directives":
983
+ names = sorted(set(re.findall(r"@ts-(ignore|nocheck|expect-error)", hay)))
984
+ regex = r"@ts-(" + "|".join(names) + r")\b"
985
+ elif rx == "markers":
986
+ names = [m.upper() for m in ("todo", "fixme", "xxx", "hack") if re.search(rf"\b{m}\b", hay)]
987
+ regex = r"\b(" + "|".join(names) + r")\b"
988
+ break
989
+ if regex is None:
990
+ code = [
991
+ s
992
+ for s in spans
993
+ if not re.search(r"/$", s)
994
+ and len(s) <= 40
995
+ and not re.match(r"^[\w-]+/[\w/-]*$", s)
996
+ and not re.match(r"^(git|npm|pnpm|yarn|pip|uv|poetry|make|cargo|go|docker|python3?)\b", s)
997
+ ]
998
+ if not code and quoted_words:
999
+ regex = r"(?i)\b(" + "|".join(re.escape(w) for w in quoted_words) + r")\b"
1000
+ lang = "*"
1001
+ if regex is None and (
1002
+ not code
1003
+ or not (
1004
+ prefer
1005
+ or re.search(
1006
+ r"\b(use|uses|add|call|calls|write|leave|import|commit|put|introduce|"
1007
+ r"silence|skip|avoid|never|no|place|assign)\b",
1008
+ t,
1009
+ )
1010
+ )
1011
+ ):
1012
+ return None
1013
+ if regex is None:
1014
+ regex = "|".join(_code_regex(x) for x in code)
1015
+ lang = "*"
1016
+ globs = list(_EXT_LANG.get(lang, ["**"])) if lang != "*" else _lang_globs(c.text, spans)
1017
+ if lang == "js" and re.search(r"typescript|\.ts\b|tsx", t):
1018
+ globs = ["**/*.ts", "**/*.tsx"]
1019
+ dirs = _dirs_in_text(c)
1020
+ if dirs:
1021
+ globs = _scoped(globs, dirs)
1022
+ return {"type": "content_forbidden_added", "params": {"regex": regex, "globs": globs}}
1023
+
1024
+
1025
+ def _code_regex(code: str) -> str:
1026
+ """A regex for a code fragment named in a rule: `foo()` matches calls, `...` is a wildcard, words get bounds."""
1027
+ text = code.strip()
1028
+ if text.endswith("()"):
1029
+ return re.escape(text[:-2]) + r"\s*\("
1030
+ parts = [re.escape(p) for p in re.split(r"\.\.\.|…", text)]
1031
+ rx = r".*?".join(parts)
1032
+ rx = re.sub(r"(\\ )+", r"\\s*", rx)
1033
+ if re.match(r"^\w", text):
1034
+ rx = r"\b" + rx
1035
+ if re.search(r"\w$", text):
1036
+ rx += r"\b"
1037
+ return rx
1038
+
1039
+
1040
+ FAMILIES: list[Family] = [
1041
+ fam_no_push,
1042
+ fam_no_commit,
1043
+ fam_commit_message,
1044
+ fam_final_message,
1045
+ fam_file_naming,
1046
+ fam_path_required_with,
1047
+ fam_content_required,
1048
+ fam_dependency,
1049
+ fam_command_forbidden,
1050
+ fam_command_required,
1051
+ fam_path_forbidden,
1052
+ fam_content_forbidden,
1053
+ ]
1054
+
1055
+ # --------------------------------------------------------------------------------------------- rule-ness
1056
+
1057
+ _NOT_RULE_START = re.compile(r"^(`[^`]+`|[\w./-]+/)\s*(holds?|contains?|is|are|has|have|—|-|:|\()", re.IGNORECASE)
1058
+ _HEADING_RULES = re.compile(
1059
+ r"\b(rules?|do|don['’]?t|dos|donts|never|always|must|conventions?|guidelines?|policy|"
1060
+ r"policies|requirements?|constraints?|guardrails?|expectations?|standards?|checklist)\b",
1061
+ re.IGNORECASE,
1062
+ )
1063
+
1064
+
1065
+ def is_rule(c: Candidate) -> bool:
1066
+ text = c.text.strip()
1067
+ if len(text) < 6:
1068
+ return False
1069
+ if text.endswith(":") and len(text) < 60:
1070
+ return False
1071
+ if _NOT_RULE_START.match(text) and not re.search(
1072
+ r"\b(never|do not|don't|must|should|always)\b", text, re.IGNORECASE
1073
+ ):
1074
+ return False
1075
+ if re.match(r"^(see|read|for more|more in|details in|this (project|repo|repository|file))\b", text, re.IGNORECASE):
1076
+ return False
1077
+ if re.search(r"(are|is) to be interpreted as described in|rfc ?2119|bcp ?14", text, re.IGNORECASE):
1078
+ return False
1079
+ if is_directive(text):
1080
+ return True
1081
+ if re.match(r"^no\s+\S", text, re.IGNORECASE):
1082
+ return True
1083
+ if c.kind == "list" and c.headings and _HEADING_RULES.search(c.headings[-1]):
1084
+ return bool(
1085
+ re.search(r"\b(commit|push|edit|use|run|add|name|test|depend|import|log)\w*\b", text, re.IGNORECASE)
1086
+ )
1087
+ return False
1088
+
1089
+
1090
+ def classify(c: Candidate) -> tuple[str, dict[str, Any] | None, str | None]:
1091
+ """(status, checker, reason) for one rule candidate."""
1092
+ # A "Don't" heading turns a bare bullet into a prohibition.
1093
+ heading = c.headings[-1].lower() if c.headings else ""
1094
+ probe = c
1095
+ if re.search(r"\b(don['’]?t|never|avoid|forbidden|not allowed)\b", heading) and not _negated(c.text):
1096
+ probe = Candidate(
1097
+ text="Never " + c.text[0].lower() + c.text[1:],
1098
+ raw="Never " + c.raw,
1099
+ line_start=c.line_start,
1100
+ line_end=c.line_end,
1101
+ kind=c.kind,
1102
+ headings=c.headings,
1103
+ code_spans=c.code_spans,
1104
+ )
1105
+ for family in FAMILIES:
1106
+ checker = family(probe)
1107
+ if checker is not None:
1108
+ return "active", checker, None
1109
+ if _TASTE_RE.search(c.text):
1110
+ return "uncheckable", None, "needs judgement; no deterministic check"
1111
+ return "uncheckable", None, "no deterministic pattern matched; review or rephrase"
1112
+
1113
+
1114
+ _GLOB_KEYS = ("globs", "when", "require", "edit_globs")
1115
+
1116
+
1117
+ def _base_dir(f: InstructionFile) -> str:
1118
+ """The directory a nested instruction file speaks for ("" at the root and for path-scoped rule files)."""
1119
+ if (
1120
+ f.kind in ("agents_md", "claude_md", "gemini_md", "claude_local_md", "claude_import")
1121
+ and len(f.scope_paths) == 1
1122
+ ):
1123
+ scope = f.scope_paths[0]
1124
+ if scope.endswith("/**") and scope != "**":
1125
+ return scope[:-3]
1126
+ return ""
1127
+
1128
+
1129
+ def _rebase(glob: str, base: str, top_dirs: set[str] | None) -> str:
1130
+ if base:
1131
+ if glob.startswith(base + "/"):
1132
+ return glob
1133
+ if glob.startswith("**/"):
1134
+ return f"{base}/{glob}"
1135
+ if "/" not in glob:
1136
+ return f"{base}/**/{glob}"
1137
+ return f"{base}/{glob}"
1138
+ if top_dirs is not None and "/" in glob and not glob.startswith(("**", "*")):
1139
+ first = glob.split("/", 1)[0]
1140
+ if first not in top_dirs:
1141
+ return f"**/{glob}" # "internal/pb/" in a monorepo root file means any service's internal/pb
1142
+ return glob
1143
+
1144
+
1145
+ def extract(files: Iterable[InstructionFile], root: Any = None) -> list[Extracted]:
1146
+ from pathlib import Path
1147
+
1148
+ from obey_cli.discover import list_files
1149
+
1150
+ top_dirs: set[str] | None = None
1151
+ if root is not None:
1152
+ listed = list_files(Path(root))
1153
+ top_dirs = {p.split("/", 1)[0] for p in listed if "/" in p}
1154
+ out: list[Extracted] = []
1155
+ for f in files:
1156
+ base = _base_dir(f)
1157
+ for cand in f.parsed.candidates:
1158
+ if not is_rule(cand):
1159
+ continue
1160
+ status, checker, reason = classify(cand)
1161
+ if checker is not None:
1162
+ params = checker["params"]
1163
+ for key in _GLOB_KEYS:
1164
+ if isinstance(params.get(key), list):
1165
+ params[key] = list(dict.fromkeys(_rebase(g, base, top_dirs) for g in params[key]))
1166
+ out.append(Extracted(candidate=cand, file=f, status=status, checker=checker, reason=reason))
1167
+ if root is not None:
1168
+ scripts = _package_scripts(Path(root))
1169
+ for e in out:
1170
+ params = (e.checker or {}).get("params") or {}
1171
+ if params.get("allow_via"):
1172
+ params["allow_via"] = [*params["allow_via"], *script_aliases(scripts, params["allow_via"])]
1173
+ return out
1174
+
1175
+
1176
+ def _package_scripts(root: Any) -> dict[str, str]:
1177
+ import json
1178
+
1179
+ try:
1180
+ scripts = json.loads((root / "package.json").read_text(encoding="utf-8")).get("scripts") or {}
1181
+ except (OSError, ValueError, AttributeError):
1182
+ return {}
1183
+ return {str(k): str(v) for k, v in scripts.items()} if isinstance(scripts, dict) else {}
1184
+
1185
+
1186
+ def script_aliases(scripts: dict[str, str], patterns: list[str]) -> list[str]:
1187
+ """Patterns for package scripts that run an allowed command: `pnpm gen:types` runs `node scripts/gen-types.mjs`."""
1188
+ out = []
1189
+ for name, body in sorted(scripts.items()):
1190
+ if any(re.search(p, body.strip()) for p in patterns):
1191
+ out.append(rf"^(?:pnpm(?:\s+run)?|npm\s+run|yarn(?:\s+run)?|bun\s+run)\s+{re.escape(name)}(?:\s|$)")
1192
+ return [p for p in dict.fromkeys(out) if p not in patterns]
1193
+
1194
+
1195
+ # --------------------------------------------------------------------------------------------- optional LLM
1196
+
1197
+ LLM_SCHEMA: dict[str, Any] = {
1198
+ "type": "object",
1199
+ "required": ["rules"],
1200
+ "properties": {
1201
+ "rules": {
1202
+ "type": "array",
1203
+ "items": {
1204
+ "type": "object",
1205
+ "required": ["index", "checkable"],
1206
+ "properties": {
1207
+ "index": {"type": "integer", "minimum": 0},
1208
+ "checkable": {"type": "boolean"},
1209
+ "type": {
1210
+ "enum": [
1211
+ "command_forbidden",
1212
+ "command_required",
1213
+ "path_forbidden",
1214
+ "path_required_with",
1215
+ "dependency_unchanged",
1216
+ "content_forbidden_added",
1217
+ "content_required_added",
1218
+ "commit_message",
1219
+ "no_commit",
1220
+ "no_push",
1221
+ "file_naming",
1222
+ "final_message",
1223
+ ]
1224
+ },
1225
+ "params": {"type": "object"},
1226
+ "reason": {"type": "string"},
1227
+ },
1228
+ },
1229
+ }
1230
+ },
1231
+ }
1232
+
1233
+ LLM_SYSTEM = (
1234
+ "You map coding-agent instructions to deterministic checkers. For each numbered instruction decide whether a "
1235
+ "program could check it from the agent's shell commands, file edits, git diff, commits and final message. If it "
1236
+ "can, give the checker type and params exactly as documented; otherwise checkable=false with a short reason. "
1237
+ "Checker types and params: command_forbidden{patterns:[regex on one shell command]}, command_required{pattern, "
1238
+ "edit_globs?}, path_forbidden{globs, existing_only?}, path_required_with{when:[glob], require:[glob]}, "
1239
+ "dependency_unchanged{allow?:[name]}, content_forbidden_added{regex, globs}, content_required_added{regex, globs, "
1240
+ "scope:'new_files'}, commit_message{regex}, no_commit{}, no_push{}, file_naming{globs, pattern}, "
1241
+ "final_message{required|forbidden: regex}. Reply with one JSON object only, exactly in this shape: "
1242
+ '{"rules": [{"index": 0, "checkable": true, "type": "no_push", "params": {}}, '
1243
+ '{"index": 1, "checkable": false, "reason": "needs judgement"}]}'
1244
+ )
1245
+
1246
+
1247
+ def _normalize_llm(data: Any) -> dict[str, Any]:
1248
+ rows = data if isinstance(data, list) else (data.get("rules") if isinstance(data, dict) else None)
1249
+ if not isinstance(rows, list):
1250
+ return data if isinstance(data, dict) else {}
1251
+ out = []
1252
+ for row in rows:
1253
+ if not isinstance(row, dict):
1254
+ continue
1255
+ checker: dict[str, Any] = row["checker"] if isinstance(row.get("checker"), dict) else {}
1256
+ item: dict[str, Any] = {"index": row.get("index", row.get("id")), "checkable": bool(row.get("checkable"))}
1257
+ kind = row.get("type") or checker.get("type")
1258
+ if kind:
1259
+ item["type"] = kind
1260
+ item["params"] = row.get("params") or checker.get("params") or {}
1261
+ if row.get("reason"):
1262
+ item["reason"] = str(row["reason"])
1263
+ out.append(item)
1264
+ return {"rules": out}
1265
+
1266
+
1267
+ LLM_TYPES_SYSTEM = (
1268
+ "You classify coding-agent instructions. For each numbered instruction say which deterministic checker type could "
1269
+ "verify it from the agent's shell commands, file edits, git diff, commits and final message, or null when it needs "
1270
+ "human judgement. Types: command_forbidden, command_required, path_forbidden, path_required_with, "
1271
+ "dependency_unchanged, content_forbidden_added, content_required_added, commit_message, no_commit, no_push, "
1272
+ 'file_naming, final_message. Reply with one JSON object only: {"rules": [{"index": 0, "checkable": true, '
1273
+ '"type": "no_push"}, {"index": 1, "checkable": false}]}'
1274
+ )
1275
+
1276
+
1277
+ def llm_classify(
1278
+ texts: Sequence[str], config: Any, ledger: Any = None, run_id: str = "extract-llm", types_only: bool = False
1279
+ ) -> list[dict[str, Any] | None]:
1280
+ """One LLM call for a batch of rule texts; returns a checker proposal (or None) per text.
1281
+
1282
+ `types_only` asks for the checker type alone, which is enough for counting and much cheaper.
1283
+ """
1284
+ from obey_cli.llm import chat_json
1285
+
1286
+ out: list[dict[str, Any] | None] = [None] * len(texts)
1287
+ if not texts:
1288
+ return out
1289
+ numbered = "\n".join(f"{i}. {t}" for i, t in enumerate(texts))
1290
+ data = chat_json(
1291
+ config,
1292
+ LLM_TYPES_SYSTEM if types_only else LLM_SYSTEM,
1293
+ numbered,
1294
+ LLM_SCHEMA,
1295
+ ledger=ledger,
1296
+ run_id=run_id,
1297
+ max_tokens=min(12000, 1200 + (40 if types_only else 160) * len(texts)),
1298
+ normalize=_normalize_llm,
1299
+ )
1300
+ for row in data.get("rules", []):
1301
+ idx = row.get("index")
1302
+ if isinstance(idx, int) and 0 <= idx < len(texts) and row.get("checkable") and row.get("type"):
1303
+ out[idx] = {"type": row["type"], "params": row.get("params") or {}}
1304
+ return out
1305
+
1306
+
1307
+ def llm_propose(items: Sequence[Extracted], config: Any, ledger: Any = None) -> list[Extracted]:
1308
+ """Ask an LLM about rules the patterns left uncheckable. Results are `proposed`, never `active`."""
1309
+ from obey_cli.config import validate
1310
+
1311
+ pending = [x for x in items if x.status == "uncheckable"]
1312
+ if not pending:
1313
+ return []
1314
+ proposals: list[Extracted] = []
1315
+ for idx, checker_ in enumerate(llm_classify([x.candidate.text for x in pending], config, ledger)):
1316
+ if checker_ is None:
1317
+ continue
1318
+ base = pending[idx]
1319
+ row = checker_
1320
+ checker = {"type": row["type"], "params": row["params"]}
1321
+ candidate = Extracted(
1322
+ candidate=base.candidate,
1323
+ file=base.file,
1324
+ status="proposed",
1325
+ checker=checker,
1326
+ origin="llm",
1327
+ reason="proposed by --llm; review before setting status: active",
1328
+ )
1329
+ rule = candidate.as_rule()
1330
+ if validate({"version": 1, "rules": [rule]}):
1331
+ continue
1332
+ try:
1333
+ for key in ("patterns",):
1334
+ for rx in checker["params"].get(key, []):
1335
+ re.compile(rx)
1336
+ for key in ("pattern", "regex", "required", "forbidden"):
1337
+ if key in checker["params"]:
1338
+ re.compile(checker["params"][key])
1339
+ except re.error:
1340
+ continue
1341
+ proposals.append(candidate)
1342
+ return proposals