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 ADDED
@@ -0,0 +1,3 @@
1
+ """weftgate: a verification gate for coding agents that checks the wiring, not the spelling."""
2
+
3
+ __version__ = "0.1.0"
weftgate/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """``python -m weftgate`` is the same as the ``weftgate`` console script."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
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