loopagent-gate 0.4.2__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.
- loopagent_gate-0.4.2.dist-info/METADATA +374 -0
- loopagent_gate-0.4.2.dist-info/RECORD +40 -0
- loopagent_gate-0.4.2.dist-info/WHEEL +5 -0
- loopagent_gate-0.4.2.dist-info/entry_points.txt +2 -0
- loopagent_gate-0.4.2.dist-info/licenses/LICENSE +21 -0
- loopagent_gate-0.4.2.dist-info/top_level.txt +1 -0
- loopgate/__init__.py +7 -0
- loopgate/__main__.py +8 -0
- loopgate/cli.py +147 -0
- loopgate/gates/anchor_witness.py +249 -0
- loopgate/gates/assert_dims.py +88 -0
- loopgate/gates/bootstrap_state.py +111 -0
- loopgate/gates/change_health.py +232 -0
- loopgate/gates/check_loop.py +1267 -0
- loopgate/gates/check_product.py +463 -0
- loopgate/gates/check_visual.py +224 -0
- loopgate/gates/doctor.py +130 -0
- loopgate/gates/estimate_task.py +137 -0
- loopgate/gates/export_public.py +289 -0
- loopgate/gates/guard_card.py +519 -0
- loopgate/gates/loop_watch.py +118 -0
- loopgate/gates/regressions.py +463 -0
- loopgate/gates/render_status_page.py +151 -0
- loopgate/gates/render_task_ledger.py +312 -0
- loopgate/gates/review_pr.py +370 -0
- loopgate/gates/sync_issues_to_kanban.py +593 -0
- loopgate/gates/tree_parity.py +307 -0
- loopgate/gates/worker_brief.py +396 -0
- loopgate/init.py +128 -0
- loopgate/paths.py +43 -0
- loopgate/py.typed +0 -0
- loopgate/templates/__init__.py +1 -0
- loopgate/templates/configs/anchor_witness.example.json +14 -0
- loopgate/templates/configs/fixtures/issues_sample.json +75 -0
- loopgate/templates/configs/fixtures/review_approve.json +5 -0
- loopgate/templates/configs/fixtures/review_changes.json +5 -0
- loopgate/templates/configs/fixtures/review_no_verdict.json +5 -0
- loopgate/templates/configs/repo_profiles.example.json +63 -0
- loopgate/templates/configs/tree_parity.example.json +16 -0
- loopgate/templates/skills/loop-engineering/SKILL.md +281 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tree parity — two checkouts of the same project must not drift silently.
|
|
3
|
+
|
|
4
|
+
Why this exists
|
|
5
|
+
---------------
|
|
6
|
+
Development happens in one tree and publication happens from another. Round 05 of this project found the
|
|
7
|
+
two had drifted in both directions and *nobody had a command for it*: five scripts differed, one by 258
|
|
8
|
+
lines, and the only reason it was discovered is that an audit ran ``diff -rq`` by hand. A rule that lives in
|
|
9
|
+
a reviewer's habit is not a rule — the same lesson the regression suite already learned about prose.
|
|
10
|
+
|
|
11
|
+
So this script turns drift into a measurement with three outcomes, and the third one is the point:
|
|
12
|
+
|
|
13
|
+
* a path covered by ``must_match`` and different -> drift, named with the first differing line;
|
|
14
|
+
* a path covered by ``may_differ`` **with a non-empty reason** -> allowed (an exception without a reason is
|
|
15
|
+
reported as such, because an unexplained exception is drift wearing a hat);
|
|
16
|
+
* anything else that differs, or exists in one tree only -> **unclassified drift**, also a stop. This is
|
|
17
|
+
what keeps the check honest over time: a file added to one tree and forgotten in the other cannot hide
|
|
18
|
+
behind a stale policy.
|
|
19
|
+
|
|
20
|
+
Usage
|
|
21
|
+
-----
|
|
22
|
+
python3 -m loopgate.gates.tree_parity --selftest
|
|
23
|
+
python3 -m loopgate.gates.tree_parity --policy configs/tree_parity.json
|
|
24
|
+
python3 -m loopgate.gates.tree_parity --a /path/lab --b /path/published \\
|
|
25
|
+
--must-match 'loopgate/gates/*.py' --must-match Makefile --may-differ 'loopgate/gates/private.py=lab-only tool'
|
|
26
|
+
|
|
27
|
+
Markers: ``TREE_PARITY_PASS_0001`` (exit 0) / ``TREE_PARITY_STOP_0001`` (exit 1).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from loopgate.paths import TEMPLATES_DIR, project_root, gate_file
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import fnmatch
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import shutil
|
|
39
|
+
import sys
|
|
40
|
+
import tempfile
|
|
41
|
+
|
|
42
|
+
HERE = project_root()
|
|
43
|
+
|
|
44
|
+
PASS_MARKER = "TREE_PARITY_PASS_0001"
|
|
45
|
+
STOP_MARKER = "TREE_PARITY_STOP_0001"
|
|
46
|
+
|
|
47
|
+
# Directories whose contents are state, caches or build output: comparing them would drown the signal in
|
|
48
|
+
# noise, and none of them is a source of truth for either tree.
|
|
49
|
+
SKIP_DIRS = {".git", "artifacts", "handoff", "dist", "course", "backlog", "__pycache__", ".venv",
|
|
50
|
+
"node_modules", ".backport_backup", ".mypy_cache", ".pytest_cache", ".hypothesis",
|
|
51
|
+
".ruff_cache"}
|
|
52
|
+
SKIP_SUFFIXES = (".pyc", ".pyo")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def walk(root: str) -> dict:
|
|
56
|
+
"""Relative path -> absolute path for every comparable file under ``root``."""
|
|
57
|
+
found = {}
|
|
58
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
59
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
60
|
+
for name in filenames:
|
|
61
|
+
if name.endswith(SKIP_SUFFIXES):
|
|
62
|
+
continue
|
|
63
|
+
full = os.path.join(dirpath, name)
|
|
64
|
+
found[os.path.relpath(full, root)] = full
|
|
65
|
+
return found
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read_bytes(path: str) -> bytes:
|
|
69
|
+
with open(path, "rb") as fh:
|
|
70
|
+
return fh.read()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def matches(rel: str, patterns) -> bool:
|
|
74
|
+
return any(fnmatch.fnmatch(rel, pattern) for pattern in patterns)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def first_difference(a_path: str, b_path: str) -> str:
|
|
78
|
+
"""A one-line, quotable account of where two files part company."""
|
|
79
|
+
a_lines = read_bytes(a_path).decode("utf-8", "replace").splitlines()
|
|
80
|
+
b_lines = read_bytes(b_path).decode("utf-8", "replace").splitlines()
|
|
81
|
+
for index, (left, right) in enumerate(zip(a_lines, b_lines), start=1):
|
|
82
|
+
if left != right:
|
|
83
|
+
return f"line {index}: {left.strip()[:60]!r} vs {right.strip()[:60]!r}"
|
|
84
|
+
return f"identical prefix, {len(a_lines)} vs {len(b_lines)} line(s)"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def reason_for(rel: str, may_differ: dict) -> str:
|
|
88
|
+
"""The declared reason for ``rel``, whether it was named exactly or through a glob.
|
|
89
|
+
|
|
90
|
+
A blank reason is not a reason: such an entry does not cover the path at all, so the path is
|
|
91
|
+
classified normally *and* the empty declaration is reported on its own. Returning ``" "`` here would
|
|
92
|
+
let a whitespace-only entry hide a real difference behind a finding about the pattern instead of the
|
|
93
|
+
file — the reviewer of round 05's pull request named exactly that.
|
|
94
|
+
"""
|
|
95
|
+
if rel in may_differ:
|
|
96
|
+
return may_differ[rel] if may_differ[rel].strip() else ""
|
|
97
|
+
for pattern, reason in may_differ.items():
|
|
98
|
+
if fnmatch.fnmatch(rel, pattern) and reason.strip():
|
|
99
|
+
return reason
|
|
100
|
+
return ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def compare(a_root: str, b_root: str, must_match, may_differ: dict) -> list:
|
|
104
|
+
"""Every finding between the two trees: drift, missing, unclassified, unexplained.
|
|
105
|
+
|
|
106
|
+
``may_differ`` deliberately covers **presence** as well as content: a development-only script exists in
|
|
107
|
+
one checkout and not the other, and that is the normal shape of such a policy rather than an oversight.
|
|
108
|
+
A policy that wants additions and deletions policed says so by putting the path under ``must_match``
|
|
109
|
+
only, where a file present in one tree is a ``missing`` finding.
|
|
110
|
+
"""
|
|
111
|
+
a_files, b_files = walk(a_root), walk(b_root)
|
|
112
|
+
findings = []
|
|
113
|
+
for rel in sorted(set(a_files) | set(b_files)):
|
|
114
|
+
in_a, in_b = rel in a_files, rel in b_files
|
|
115
|
+
if in_a and in_b and read_bytes(a_files[rel]) == read_bytes(b_files[rel]):
|
|
116
|
+
continue
|
|
117
|
+
reason = reason_for(rel, may_differ)
|
|
118
|
+
declared_must = matches(rel, must_match)
|
|
119
|
+
if reason:
|
|
120
|
+
# An explicit, reasoned exception outranks a broad must-match glob: policies that cover a
|
|
121
|
+
# directory need a way to carve out one file, and the reason is what makes it reviewable.
|
|
122
|
+
continue
|
|
123
|
+
if not declared_must:
|
|
124
|
+
findings.append(("unclassified", rel, "differs or is missing in one tree and no rule covers it"))
|
|
125
|
+
elif not (in_a and in_b):
|
|
126
|
+
findings.append(("missing", rel, f"present only in {'A' if in_a else 'B'}"))
|
|
127
|
+
else:
|
|
128
|
+
findings.append(("drift", rel, first_difference(a_files[rel], b_files[rel])))
|
|
129
|
+
for pattern, reason in may_differ.items():
|
|
130
|
+
if not reason.strip():
|
|
131
|
+
findings.append(("unexplained-exception", pattern,
|
|
132
|
+
"declared as may-differ without a reason"))
|
|
133
|
+
return findings
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def report(findings: list, a_root: str, b_root: str, must_match, may_differ: dict) -> int:
|
|
137
|
+
if findings:
|
|
138
|
+
print(f"tree parity: {len(findings)} finding(s) between")
|
|
139
|
+
print(f" A: {a_root}")
|
|
140
|
+
print(f" B: {b_root}")
|
|
141
|
+
for kind, path, detail in findings:
|
|
142
|
+
print(f" [{kind}] {path}: {detail}")
|
|
143
|
+
print(STOP_MARKER)
|
|
144
|
+
return 1
|
|
145
|
+
print(f"tree parity: must-match paths agree ({len(must_match)} rule(s), "
|
|
146
|
+
f"{len(may_differ) or 'no'} explained exception(s))")
|
|
147
|
+
print(PASS_MARKER)
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _write(root: str, rel: str, body: str) -> None:
|
|
152
|
+
path = os.path.join(root, rel)
|
|
153
|
+
parent = os.path.dirname(path)
|
|
154
|
+
if parent:
|
|
155
|
+
os.makedirs(parent, exist_ok=True)
|
|
156
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
157
|
+
fh.write(body)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def selftest() -> int:
|
|
161
|
+
"""Prove the three outcomes on throwaway trees, and the five ways the check must say no."""
|
|
162
|
+
|
|
163
|
+
cases = []
|
|
164
|
+
|
|
165
|
+
def case(name, files_a, files_b, must_match, may_differ, expect_pass):
|
|
166
|
+
tmp = tempfile.mkdtemp(prefix="parity-")
|
|
167
|
+
try:
|
|
168
|
+
a, b = os.path.join(tmp, "a"), os.path.join(tmp, "b")
|
|
169
|
+
for root, files in ((a, files_a), (b, files_b)):
|
|
170
|
+
for rel, body in files.items():
|
|
171
|
+
_write(root, rel, body)
|
|
172
|
+
findings = compare(a, b, must_match, may_differ)
|
|
173
|
+
cases.append((name, (not findings) == expect_pass, expect_pass, findings))
|
|
174
|
+
finally:
|
|
175
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
176
|
+
|
|
177
|
+
case("identical trees pass",
|
|
178
|
+
{"loopgate/gates/x.py": "a\n"}, {"loopgate/gates/x.py": "a\n"}, ["loopgate/gates/*.py"], {}, True)
|
|
179
|
+
case("must-match drift stops",
|
|
180
|
+
{"loopgate/gates/x.py": "a\n"}, {"loopgate/gates/x.py": "b\n"}, ["loopgate/gates/*.py"], {}, False)
|
|
181
|
+
case("file missing in one tree stops",
|
|
182
|
+
{"loopgate/gates/x.py": "a\n"}, {}, ["loopgate/gates/*.py"], {}, False)
|
|
183
|
+
case("explained exception passes",
|
|
184
|
+
{"loopgate/gates/x.py": "a\n", "loopgate/gates/private.py": "lab\n"}, {"loopgate/gates/x.py": "a\n"},
|
|
185
|
+
["loopgate/gates/*.py"], {"loopgate/gates/private.py": "lab-only tooling"}, True)
|
|
186
|
+
case("exception without a reason stops",
|
|
187
|
+
{"loopgate/gates/x.py": "a\n", "loopgate/gates/private.py": "lab\n"}, {"loopgate/gates/x.py": "a\n"},
|
|
188
|
+
["loopgate/gates/*.py"], {"loopgate/gates/private.py": " "}, False)
|
|
189
|
+
case("unclassified drift stops",
|
|
190
|
+
{"loopgate/gates/x.py": "a\n", "README.md": "one\n"}, {"loopgate/gates/x.py": "a\n", "README.md": "two\n"},
|
|
191
|
+
["loopgate/gates/*.py"], {}, False)
|
|
192
|
+
# Policy loading is a second surface, and a file that parses but says nothing is worse than no file.
|
|
193
|
+
tmp = tempfile.mkdtemp(prefix="parity-policy-")
|
|
194
|
+
try:
|
|
195
|
+
dict_policy = os.path.join(tmp, "dict.json")
|
|
196
|
+
with open(dict_policy, "w", encoding="utf-8") as fh:
|
|
197
|
+
json.dump({"a": "/x", "b": "/y", "must_match": ["*.py"],
|
|
198
|
+
"may_differ": {"p.py": "a written reason", "q.py": " "}}, fh)
|
|
199
|
+
loaded = load_policy(dict_policy)
|
|
200
|
+
cases.append(("policy file: roots, rules and reasons load",
|
|
201
|
+
loaded["a"] == "/x" and loaded["b"] == "/y"
|
|
202
|
+
and loaded["must_match"] == ["*.py"]
|
|
203
|
+
and loaded["may_differ"]["p.py"] == "a written reason", True, loaded))
|
|
204
|
+
cases.append(("policy file: a blank reason stays blank so it can be reported",
|
|
205
|
+
loaded["may_differ"]["q.py"] == "", True, loaded["may_differ"]))
|
|
206
|
+
list_policy = os.path.join(tmp, "list.json")
|
|
207
|
+
with open(list_policy, "w", encoding="utf-8") as fh:
|
|
208
|
+
json.dump({"a": "/x", "b": "/y", "must_match": [],
|
|
209
|
+
"may_differ": [{"path": "r.py", "reason": "why"}]}, fh)
|
|
210
|
+
loaded_list = load_policy(list_policy)
|
|
211
|
+
cases.append(("policy file: the list form loads too",
|
|
212
|
+
loaded_list["may_differ"] == {"r.py": "why"}, True, loaded_list["may_differ"]))
|
|
213
|
+
a2, b2 = os.path.join(tmp, "a2"), os.path.join(tmp, "b2")
|
|
214
|
+
_write(a2, "loopgate/gates/x.py", "one\n")
|
|
215
|
+
_write(b2, "loopgate/gates/x.py", "two\n")
|
|
216
|
+
hidden = compare(a2, b2, ["loopgate/gates/*.py"], {"loopgate/gates/*.py": " "})
|
|
217
|
+
kinds = [kind for kind, _, _ in hidden]
|
|
218
|
+
cases.append(("blank-reason exception still reports the file itself, not only the pattern",
|
|
219
|
+
"drift" in kinds and "unexplained-exception" in kinds, True, hidden))
|
|
220
|
+
finally:
|
|
221
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
222
|
+
|
|
223
|
+
example = os.path.join(TEMPLATES_DIR, "configs", "tree_parity.example.json")
|
|
224
|
+
if os.path.exists(example):
|
|
225
|
+
shipped = load_policy(example)
|
|
226
|
+
cases.append(("the shipped example policy parses and explains every exception",
|
|
227
|
+
bool(shipped["must_match"])
|
|
228
|
+
and all(reason.strip() for reason in shipped["may_differ"].values()),
|
|
229
|
+
True, shipped["may_differ"]))
|
|
230
|
+
else:
|
|
231
|
+
cases.append(("the shipped example policy parses and explains every exception", False, True,
|
|
232
|
+
"configs/tree_parity.example.json is missing"))
|
|
233
|
+
|
|
234
|
+
failed = [name for name, ok, _, _ in cases if not ok]
|
|
235
|
+
for name, ok, _, findings in cases:
|
|
236
|
+
print(f"[{'ok ' if ok else 'FAIL'}] {name}" + ("" if ok else f" -> {findings}"))
|
|
237
|
+
if failed:
|
|
238
|
+
print(f"selftest: {len(failed)} of {len(cases)} case(s) failed: {', '.join(failed)}")
|
|
239
|
+
print(STOP_MARKER)
|
|
240
|
+
return 1
|
|
241
|
+
falsifications = sum(1 for _, _, expect_pass, _ in cases if not expect_pass)
|
|
242
|
+
print(f"selftest: {len(cases)} case(s), {falsifications} of them asserting a stop")
|
|
243
|
+
print(PASS_MARKER)
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def load_policy(path: str) -> dict:
|
|
248
|
+
with open(path, encoding="utf-8") as fh:
|
|
249
|
+
policy = json.load(fh)
|
|
250
|
+
may_differ = {}
|
|
251
|
+
raw = policy.get("may_differ") or {}
|
|
252
|
+
if isinstance(raw, dict):
|
|
253
|
+
for key, value in raw.items():
|
|
254
|
+
may_differ[key] = value.strip() if isinstance(value, str) else ""
|
|
255
|
+
else:
|
|
256
|
+
for item in raw:
|
|
257
|
+
may_differ[str(item.get("path", ""))] = str(item.get("reason", "")).strip()
|
|
258
|
+
return {"a": policy.get("a"), "b": policy.get("b"),
|
|
259
|
+
"must_match": list(policy.get("must_match") or []), "may_differ": may_differ}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def main(argv=None) -> int:
|
|
263
|
+
ap = argparse.ArgumentParser(description="compare two checkouts and name any drift")
|
|
264
|
+
ap.add_argument("--a", help="tree A (a policy file may name it instead)")
|
|
265
|
+
ap.add_argument("--b", help="tree B")
|
|
266
|
+
ap.add_argument("--policy", help="JSON policy: {a, b, must_match, may_differ}")
|
|
267
|
+
ap.add_argument("--must-match", action="append", default=[],
|
|
268
|
+
help="glob that must be identical in both trees (repeatable)")
|
|
269
|
+
ap.add_argument("--may-differ", action="append", default=[],
|
|
270
|
+
help="path or glob allowed to differ, written as 'path=reason' (repeatable)")
|
|
271
|
+
ap.add_argument("--selftest", action="store_true")
|
|
272
|
+
args = ap.parse_args(argv)
|
|
273
|
+
|
|
274
|
+
if args.selftest:
|
|
275
|
+
return selftest()
|
|
276
|
+
|
|
277
|
+
must_match = list(args.must_match)
|
|
278
|
+
may_differ = {}
|
|
279
|
+
for item in args.may_differ:
|
|
280
|
+
path, _, reason = item.partition("=")
|
|
281
|
+
may_differ[path.strip()] = reason.strip()
|
|
282
|
+
|
|
283
|
+
a_root, b_root = args.a, args.b
|
|
284
|
+
if args.policy:
|
|
285
|
+
policy = load_policy(args.policy)
|
|
286
|
+
a_root = a_root or policy["a"]
|
|
287
|
+
b_root = b_root or policy["b"]
|
|
288
|
+
must_match += policy["must_match"]
|
|
289
|
+
for key, value in policy["may_differ"].items():
|
|
290
|
+
may_differ.setdefault(key, value)
|
|
291
|
+
|
|
292
|
+
if not a_root or not b_root:
|
|
293
|
+
print("both roots are required (--a/--b or a policy file carrying them)", file=sys.stderr)
|
|
294
|
+
print(STOP_MARKER)
|
|
295
|
+
return 1
|
|
296
|
+
|
|
297
|
+
a_root, b_root = os.path.expanduser(a_root), os.path.expanduser(b_root)
|
|
298
|
+
for label, root in (("A", a_root), ("B", b_root)):
|
|
299
|
+
if not os.path.isdir(root):
|
|
300
|
+
print(f"{label} root is not a directory: {root}", file=sys.stderr)
|
|
301
|
+
print(STOP_MARKER)
|
|
302
|
+
return 1
|
|
303
|
+
return report(compare(a_root, b_root, must_match, may_differ), a_root, b_root, must_match, may_differ)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
if __name__ == "__main__":
|
|
307
|
+
sys.exit(main())
|