weftgate 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.
- weftgate/__init__.py +3 -0
- weftgate/__main__.py +5 -0
- weftgate/change.py +231 -0
- weftgate/cli.py +523 -0
- weftgate/config.py +401 -0
- weftgate/doctor.py +164 -0
- weftgate/eval/__init__.py +1 -0
- weftgate/eval/audit.py +173 -0
- weftgate/eval/fixture.py +84 -0
- weftgate/eval/mutate.py +489 -0
- weftgate/gate.py +535 -0
- weftgate/honesty.py +553 -0
- weftgate/ledger.py +130 -0
- weftgate/mcp_server.py +371 -0
- weftgate/memory.py +452 -0
- weftgate/oracle.py +231 -0
- weftgate/oracles/__init__.py +11 -0
- weftgate/oracles/env_vars.py +640 -0
- weftgate/oracles/imports_lockfile.py +1338 -0
- weftgate/oracles/routes_fastapi.py +909 -0
- weftgate/registry.py +80 -0
- weftgate/selftest.py +312 -0
- weftgate/setup.py +368 -0
- weftgate/store.py +557 -0
- weftgate/suggest.py +78 -0
- weftgate/types.py +177 -0
- weftgate-0.1.0.dist-info/METADATA +213 -0
- weftgate-0.1.0.dist-info/RECORD +32 -0
- weftgate-0.1.0.dist-info/WHEEL +4 -0
- weftgate-0.1.0.dist-info/entry_points.txt +7 -0
- weftgate-0.1.0.dist-info/licenses/LICENSE +201 -0
- weftgate-0.1.0.dist-info/licenses/NOTICE +4 -0
weftgate/__init__.py
ADDED
weftgate/__main__.py
ADDED
weftgate/change.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Turn a file, a patch, or a git diff into a Change: touched files and their
|
|
2
|
+
added or modified regions. Tolerant by design; it does not need a grammar.
|
|
3
|
+
|
|
4
|
+
Oracles read added regions via ``change.added_regions()``; each Region yields
|
|
5
|
+
``(lineno, text)`` for its added lines and, when the whole new content of the
|
|
6
|
+
file is known (``from_file``/``from_text``), carries it in ``full_text`` so an
|
|
7
|
+
oracle can parse the file properly instead of guessing from fragments.
|
|
8
|
+
|
|
9
|
+
Standard library only.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
_HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
21
|
+
_DIFF_GIT = re.compile(r'^diff --git (?:"?a/)?(.*?)"? (?:"?b/)?(.*?)"?$')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Region:
|
|
26
|
+
file: str
|
|
27
|
+
_lines: list[tuple[int, str]] = field(default_factory=list)
|
|
28
|
+
full_text: str | None = None # whole new file content when known
|
|
29
|
+
|
|
30
|
+
def add(self, lineno: int, text: str) -> None:
|
|
31
|
+
self._lines.append((lineno, text))
|
|
32
|
+
|
|
33
|
+
def lines(self) -> list[tuple[int, str]]:
|
|
34
|
+
return self._lines
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def whole_file(self) -> bool:
|
|
38
|
+
return self.full_text is not None
|
|
39
|
+
|
|
40
|
+
def text(self) -> str:
|
|
41
|
+
"""The added lines joined, or the full file when known."""
|
|
42
|
+
if self.full_text is not None:
|
|
43
|
+
return self.full_text
|
|
44
|
+
return "\n".join(t for _, t in self._lines)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Change:
|
|
49
|
+
regions: list[Region] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
def added_regions(self) -> list[Region]:
|
|
52
|
+
return self.regions
|
|
53
|
+
|
|
54
|
+
def files(self) -> list[str]:
|
|
55
|
+
seen: dict[str, None] = {}
|
|
56
|
+
for r in self.regions:
|
|
57
|
+
seen.setdefault(r.file, None)
|
|
58
|
+
return list(seen)
|
|
59
|
+
|
|
60
|
+
def is_empty(self) -> bool:
|
|
61
|
+
return not any(r.lines() for r in self.regions)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def combine(cls, changes: Iterable[Change]) -> Change:
|
|
65
|
+
out = cls()
|
|
66
|
+
for ch in changes:
|
|
67
|
+
out.regions.extend(ch.regions)
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
def relative_to(self, repo_root: str) -> Change:
|
|
71
|
+
"""The same change with every file path repo-relative and POSIX-separated."""
|
|
72
|
+
root = os.path.abspath(repo_root)
|
|
73
|
+
out = Change()
|
|
74
|
+
for r in self.regions:
|
|
75
|
+
path = r.file
|
|
76
|
+
if os.path.isabs(path):
|
|
77
|
+
try:
|
|
78
|
+
rel = os.path.relpath(path, root)
|
|
79
|
+
except ValueError: # pragma: no cover - different drives on Windows
|
|
80
|
+
rel = path
|
|
81
|
+
path = path if rel.startswith("..") else rel
|
|
82
|
+
path = path.replace(os.sep, "/")
|
|
83
|
+
if path.startswith("./"):
|
|
84
|
+
path = path[2:]
|
|
85
|
+
out.regions.append(Region(file=path, _lines=list(r.lines()), full_text=r.full_text))
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
# --- constructors ----------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_text(cls, path: str, text: str) -> Change:
|
|
92
|
+
"""Treat the whole of ``text`` as the new content of ``path`` (the file need
|
|
93
|
+
not exist yet: this is what a PreToolUse hook sees before a Write lands)."""
|
|
94
|
+
r = Region(file=path, full_text=text)
|
|
95
|
+
for i, line in enumerate(text.splitlines(), 1):
|
|
96
|
+
r.add(i, line)
|
|
97
|
+
return cls(regions=[r])
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_file(cls, path: str, repo_root: str | None = None) -> Change:
|
|
101
|
+
"""Treat an entire file as added (used by ``weftgate check <file>`` and audit)."""
|
|
102
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
103
|
+
text = fh.read()
|
|
104
|
+
change = cls.from_text(path, text)
|
|
105
|
+
return change.relative_to(repo_root) if repo_root else change
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_unified_diff(cls, text: str) -> Change:
|
|
109
|
+
"""Parse a unified diff (git or plain), keeping only added lines with their
|
|
110
|
+
new-file line numbers. Handles renames, new/deleted files, binary hunks,
|
|
111
|
+
``--no-prefix`` diffs, and the "no newline" marker."""
|
|
112
|
+
change = cls()
|
|
113
|
+
cur: Region | None = None
|
|
114
|
+
new_lineno = 0
|
|
115
|
+
in_hunk = False
|
|
116
|
+
pending_git_path: str | None = None
|
|
117
|
+
for line in text.splitlines():
|
|
118
|
+
if line.startswith("diff --git "):
|
|
119
|
+
m = _DIFF_GIT.match(line)
|
|
120
|
+
pending_git_path = m.group(2) if m else None
|
|
121
|
+
cur, in_hunk = None, False
|
|
122
|
+
continue
|
|
123
|
+
if line.startswith("+++ ") and not in_hunk:
|
|
124
|
+
path = _clean_path(line[4:])
|
|
125
|
+
if path is None: # deleted file: nothing added
|
|
126
|
+
cur, pending_git_path = None, None
|
|
127
|
+
else:
|
|
128
|
+
cur = Region(file=path)
|
|
129
|
+
change.regions.append(cur)
|
|
130
|
+
continue
|
|
131
|
+
if line.startswith("--- ") and not in_hunk:
|
|
132
|
+
continue
|
|
133
|
+
if line.startswith("@@@ "): # combined diff: not supported, skip hunk
|
|
134
|
+
in_hunk, cur = False, None
|
|
135
|
+
continue
|
|
136
|
+
hm = _HUNK.match(line)
|
|
137
|
+
if hm:
|
|
138
|
+
if cur is None and pending_git_path:
|
|
139
|
+
cur = Region(file=pending_git_path)
|
|
140
|
+
change.regions.append(cur)
|
|
141
|
+
new_lineno = int(hm.group(3))
|
|
142
|
+
in_hunk = True
|
|
143
|
+
continue
|
|
144
|
+
if line.startswith("Binary files") or line.startswith("GIT binary patch"):
|
|
145
|
+
in_hunk = False
|
|
146
|
+
continue
|
|
147
|
+
if not in_hunk:
|
|
148
|
+
continue
|
|
149
|
+
if line.startswith("\\"): # ""
|
|
150
|
+
continue
|
|
151
|
+
if line.startswith("+"):
|
|
152
|
+
if cur is not None:
|
|
153
|
+
cur.add(new_lineno, line[1:])
|
|
154
|
+
new_lineno += 1
|
|
155
|
+
elif line.startswith("-"):
|
|
156
|
+
continue
|
|
157
|
+
else:
|
|
158
|
+
new_lineno += 1
|
|
159
|
+
return change
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def from_git(cls, repo_root: str, staged: bool = False, rev_range: str | None = None) -> Change:
|
|
163
|
+
"""The working-tree diff (or ``--staged``, or a revision range) as a Change."""
|
|
164
|
+
args = ["git", "diff", "--no-color", "--no-ext-diff", "--no-renames", "-U0"]
|
|
165
|
+
if rev_range:
|
|
166
|
+
args.append(rev_range)
|
|
167
|
+
elif staged:
|
|
168
|
+
args.append("--staged")
|
|
169
|
+
proc = subprocess.run(args, cwd=repo_root, capture_output=True, text=True, check=False)
|
|
170
|
+
if proc.returncode != 0:
|
|
171
|
+
raise RuntimeError(f"git diff failed: {proc.stderr.strip() or proc.returncode}")
|
|
172
|
+
change = cls.from_unified_diff(proc.stdout)
|
|
173
|
+
if not staged and not rev_range:
|
|
174
|
+
# Untracked files are new content too: include them whole.
|
|
175
|
+
listing = subprocess.run(
|
|
176
|
+
["git", "ls-files", "--others", "--exclude-standard", "-z"],
|
|
177
|
+
cwd=repo_root,
|
|
178
|
+
capture_output=True,
|
|
179
|
+
text=True,
|
|
180
|
+
check=False,
|
|
181
|
+
)
|
|
182
|
+
for rel in sorted(p for p in listing.stdout.split("\0") if p):
|
|
183
|
+
full = os.path.join(repo_root, rel)
|
|
184
|
+
if os.path.isfile(full) and _looks_texty(full):
|
|
185
|
+
change.regions.extend(cls.from_file(full, repo_root).regions)
|
|
186
|
+
return change
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def from_path_or_diff(
|
|
190
|
+
cls, arg: str, stdin_text: str | None = None, repo_root: str | None = None
|
|
191
|
+
) -> Change:
|
|
192
|
+
if arg == "-":
|
|
193
|
+
return cls.from_unified_diff(stdin_text or "")
|
|
194
|
+
if os.path.isfile(arg):
|
|
195
|
+
return cls.from_file(arg, repo_root)
|
|
196
|
+
if os.path.isdir(arg):
|
|
197
|
+
change = cls()
|
|
198
|
+
for dirpath, dirnames, filenames in os.walk(arg):
|
|
199
|
+
dirnames[:] = sorted(
|
|
200
|
+
d
|
|
201
|
+
for d in dirnames
|
|
202
|
+
if not d.startswith(".") and d not in ("node_modules", "__pycache__", "venv")
|
|
203
|
+
)
|
|
204
|
+
for name in sorted(filenames):
|
|
205
|
+
full = os.path.join(dirpath, name)
|
|
206
|
+
if _looks_texty(full):
|
|
207
|
+
change.regions.extend(cls.from_file(full, repo_root).regions)
|
|
208
|
+
return change
|
|
209
|
+
# Fall back to treating the argument itself as diff text.
|
|
210
|
+
return cls.from_unified_diff(arg)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _clean_path(raw: str) -> str | None:
|
|
214
|
+
"""Strip git's a/ b/ prefixes, tabs, timestamps and quotes; None for /dev/null."""
|
|
215
|
+
path = raw.split("\t", 1)[0].strip()
|
|
216
|
+
if path.startswith('"') and path.endswith('"'):
|
|
217
|
+
path = path[1:-1]
|
|
218
|
+
if path == "/dev/null":
|
|
219
|
+
return None
|
|
220
|
+
if path.startswith("b/") or path.startswith("a/"):
|
|
221
|
+
path = path[2:]
|
|
222
|
+
return path
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _looks_texty(path: str, sample: int = 4096) -> bool:
|
|
226
|
+
try:
|
|
227
|
+
with open(path, "rb") as fh:
|
|
228
|
+
chunk = fh.read(sample)
|
|
229
|
+
except OSError:
|
|
230
|
+
return False
|
|
231
|
+
return b"\0" not in chunk
|