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/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(?)",
|
|
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
|