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.
Files changed (40) hide show
  1. loopagent_gate-0.4.2.dist-info/METADATA +374 -0
  2. loopagent_gate-0.4.2.dist-info/RECORD +40 -0
  3. loopagent_gate-0.4.2.dist-info/WHEEL +5 -0
  4. loopagent_gate-0.4.2.dist-info/entry_points.txt +2 -0
  5. loopagent_gate-0.4.2.dist-info/licenses/LICENSE +21 -0
  6. loopagent_gate-0.4.2.dist-info/top_level.txt +1 -0
  7. loopgate/__init__.py +7 -0
  8. loopgate/__main__.py +8 -0
  9. loopgate/cli.py +147 -0
  10. loopgate/gates/anchor_witness.py +249 -0
  11. loopgate/gates/assert_dims.py +88 -0
  12. loopgate/gates/bootstrap_state.py +111 -0
  13. loopgate/gates/change_health.py +232 -0
  14. loopgate/gates/check_loop.py +1267 -0
  15. loopgate/gates/check_product.py +463 -0
  16. loopgate/gates/check_visual.py +224 -0
  17. loopgate/gates/doctor.py +130 -0
  18. loopgate/gates/estimate_task.py +137 -0
  19. loopgate/gates/export_public.py +289 -0
  20. loopgate/gates/guard_card.py +519 -0
  21. loopgate/gates/loop_watch.py +118 -0
  22. loopgate/gates/regressions.py +463 -0
  23. loopgate/gates/render_status_page.py +151 -0
  24. loopgate/gates/render_task_ledger.py +312 -0
  25. loopgate/gates/review_pr.py +370 -0
  26. loopgate/gates/sync_issues_to_kanban.py +593 -0
  27. loopgate/gates/tree_parity.py +307 -0
  28. loopgate/gates/worker_brief.py +396 -0
  29. loopgate/init.py +128 -0
  30. loopgate/paths.py +43 -0
  31. loopgate/py.typed +0 -0
  32. loopgate/templates/__init__.py +1 -0
  33. loopgate/templates/configs/anchor_witness.example.json +14 -0
  34. loopgate/templates/configs/fixtures/issues_sample.json +75 -0
  35. loopgate/templates/configs/fixtures/review_approve.json +5 -0
  36. loopgate/templates/configs/fixtures/review_changes.json +5 -0
  37. loopgate/templates/configs/fixtures/review_no_verdict.json +5 -0
  38. loopgate/templates/configs/repo_profiles.example.json +63 -0
  39. loopgate/templates/configs/tree_parity.example.json +16 -0
  40. 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())