@miller-tech/uap 1.210.6 → 1.210.8

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.
@@ -23,13 +23,79 @@ COMMIT_OPS = {"git-commit", "git commit"}
23
23
  RECENT_SEC = 3600
24
24
 
25
25
 
26
+ def merge_in_progress(root: Path) -> bool:
27
+ """Whether a merge is underway — invocation-constant, so resolved ONCE.
28
+
29
+ This used to be re-derived per watched file, spawning a `git rev-parse`
30
+ for each one on the hot path of every commit and push, purely to learn
31
+ "not a merge". A merge touching 30 migrations paid 30 spawns.
32
+ """
33
+ rc, git_dir_out, _ = run(["git", "rev-parse", "--absolute-git-dir"], cwd=root)
34
+ if rc != 0:
35
+ return False
36
+ return (Path(git_dir_out.strip()) / "MERGE_HEAD").exists()
37
+
38
+
39
+ def merge_verbatim(root: Path, path: str) -> bool:
40
+ """During a merge, a staged watched file that is byte-identical to the
41
+ incoming MERGE_HEAD version was not authored on this branch — it was
42
+ reviewed and gated on its own branch and arrives verbatim. Gating it here
43
+ forced a schema-diff re-pass for content the merge cannot change (hit on
44
+ the 2026-08-16 pay2u #3153 conflict-resolution merge, where migrations
45
+ from already-merged main blocked the merge commit)."""
46
+ rc, staged_sha, _ = run(["git", "rev-parse", f":{path}"], cwd=root)
47
+ rc2, theirs_sha, _ = run(["git", "rev-parse", f"MERGE_HEAD:{path}"], cwd=root)
48
+ if rc != 0 or rc2 != 0 or staged_sha.strip() != theirs_sha.strip():
49
+ return False
50
+
51
+ # The INDEX matching theirs is not enough: `git commit -a` and
52
+ # `git commit -- <path>` commit the WORKING TREE. Exempting on the index
53
+ # alone let an unstaged edit ride along ungated -- reproduced: an appended
54
+ # `DROP TABLE users;` yielded {"allowed": true, "reason": "no watched
55
+ # schema/pool paths in diff"} while `commit -am` would have committed the
56
+ # drop.
57
+ #
58
+ # `git diff --quiet` rather than hashing the file ourselves: it exits 0
59
+ # exactly when the worktree copy matches the index, using git's own
60
+ # comparison. Hashing followed symlinks (so a verbatim merged symlink never
61
+ # matched), failed outright on a dangling link or a sparse/skip-worktree
62
+ # path, and re-ran clean filters on large files against a 5s timeout --
63
+ # every one of those a FALSE BLOCK on a legitimate merge.
64
+ rc3, _, _ = run(["git", "diff", "--quiet", "--", path], cwd=root)
65
+ return rc3 == 0
66
+
67
+
26
68
  def touched_watched_paths(root: Path) -> list[str]:
27
69
  rc, out, _ = run(["git", "diff", "--name-only", "HEAD"], cwd=root)
28
70
  if rc != 0:
29
71
  return []
30
72
  rc2, staged, _ = run(["git", "diff", "--name-only", "--cached"], cwd=root)
31
73
  all_files = (out + "\n" + (staged if rc2 == 0 else "")).splitlines()
32
- return [f for f in all_files if f and WATCHED_RE.search(f)]
74
+ # dict.fromkeys dedupes while preserving order: a file that is both
75
+ # unstaged and staged appeared twice, and the gate's reason line listed it
76
+ # twice ("covers: x, x"), which reads like two files were covered.
77
+ watched = list(dict.fromkeys(f for f in all_files if f and WATCHED_RE.search(f)))
78
+ # Nothing watched is the overwhelmingly common case; probing git there
79
+ # would make the hoist a net cost rather than a saving.
80
+ if not watched or not merge_in_progress(root):
81
+ return watched
82
+ return [f for f in watched if not merge_verbatim(root, f)]
83
+
84
+
85
+ def _parse_marker_ts(raw) -> float | None:
86
+ """Epoch seconds for a marker timestamp, or None when it cannot be read.
87
+
88
+ Stored as UTC ISO-8601 with a trailing Z; parsed as UTC because
89
+ time.mktime would read them as local time and expire the marker hours
90
+ early or late depending on the host timezone.
91
+ """
92
+ if not isinstance(raw, str):
93
+ return None
94
+ try:
95
+ import calendar
96
+ return calendar.timegm(time.strptime(raw[:19], "%Y-%m-%dT%H:%M:%S"))
97
+ except Exception: # noqa: BLE001
98
+ return None
33
99
 
34
100
 
35
101
  def schema_diff_ok(root: Path) -> bool:
@@ -41,31 +107,44 @@ def schema_diff_ok(root: Path) -> bool:
41
107
  # `uap memory store` writes to `memories` (type 'action'), while older
42
108
  # UAP wrote session rows to `session_memories` — accept the marker from
43
109
  # either table so the documented remedy actually clears the gate.
44
- row = None
110
+ newest = None
45
111
  for table in ("memories", "session_memories"):
46
112
  try:
113
+ # ANCHORED to the recorder's fixed prefix. The old
114
+ # '%schema-diff%pass%' matched those substrings anywhere in any
115
+ # memory -- including this gate's OWN refusal text ("...require
116
+ # `uap schema-diff` to pass"), so an agent storing the blocker
117
+ # as a lesson unblocked itself, and a note saying the diff
118
+ # FAILED cleared it just as well. Verified both.
47
119
  cur = con.execute(
48
120
  f"SELECT timestamp FROM {table} "
49
- "WHERE content LIKE '%schema-diff%pass%' "
121
+ "WHERE content LIKE 'schema-diff pass: base %' "
50
122
  "ORDER BY id DESC LIMIT 1"
51
123
  )
52
124
  r = cur.fetchone()
53
- if r and (row is None or r[0] > row[0]):
54
- row = r
125
+ # Compare PARSED epochs, not raw strings. The two tables need
126
+ # not share a timestamp format, and a lexicographic winner that
127
+ # then fails to parse returned False without ever considering
128
+ # the runner-up: one legacy row sorting above ISO-8601 (say
129
+ # "2026/08/17", '/' > '-') would out-rank every correct marker
130
+ # forever, and re-running the remedy could not help because it
131
+ # writes to the other table. Gate shut permanently, no waiver.
132
+ # A malformed row is ignored, never authoritative.
133
+ ts_val = _parse_marker_ts(r[0]) if r else None
134
+ if ts_val is not None and (newest is None or ts_val > newest):
135
+ newest = ts_val
55
136
  except sqlite3.Error:
56
137
  continue
57
138
  con.close()
58
- if not row:
59
- return False
60
- try:
61
- # Timestamps are stored as UTC ISO-8601 (trailing 'Z'); parse them
62
- # as UTC — time.mktime would misread them as local time and expire
63
- # the marker hours early (or late) depending on the host TZ.
64
- import calendar
65
- ts = calendar.timegm(time.strptime(row[0][:19], "%Y-%m-%dT%H:%M:%S"))
66
- except Exception: # noqa: BLE001
139
+ if newest is None:
67
140
  return False
68
- return (time.time() - ts) < RECENT_SEC
141
+ ts = newest
142
+ # Two-sided. The upper bound alone let a FUTURE timestamp clear the
143
+ # gate until wall-clock caught up -- reachable via an importing writer
144
+ # that supplies its own timestamp, or plain clock skew. A small
145
+ # tolerance absorbs sub-second skew between writer and reader.
146
+ age = time.time() - ts
147
+ return -5 <= age < RECENT_SEC
69
148
  except sqlite3.Error:
70
149
  return False
71
150
 
@@ -83,7 +162,10 @@ def main() -> None:
83
162
  emit(True, "no watched schema/pool paths in diff")
84
163
 
85
164
  if schema_diff_ok(repo_root()):
86
- emit(True, f"recent schema-diff pass covers: {', '.join(watched[:5])}")
165
+ # Says what was checked, not what it "covers". The marker is not scoped
166
+ # to files, so claiming coverage of these specific paths asserted
167
+ # something the gate never computed.
168
+ emit(True, f"recent schema-diff pass on record; watched paths: {', '.join(watched[:5])}")
87
169
 
88
170
  emit(
89
171
  False,
@@ -59,14 +59,21 @@ class SchemaDiffGateTest(unittest.TestCase):
59
59
  def tearDown(self):
60
60
  self._tmp.cleanup()
61
61
 
62
- def write_marker(self, iso_timestamp: str, table: str = "memories"):
62
+ # The literal the CLI records (src/cli/schema-diff.ts). The gate matches it
63
+ # ANCHORED, so this string is now a contract between the two.
64
+ MARKER = "schema-diff pass: base HEAD~1 | files: migrations/001_add_table.sql"
65
+
66
+ def write_marker(self, iso_timestamp: str, table: str = "memories", content: str | None = None):
63
67
  mem = self.root / "agents" / "data" / "memory"
64
- mem.mkdir(parents=True)
68
+ mem.mkdir(parents=True, exist_ok=True)
65
69
  con = sqlite3.connect(mem / "short_term.db")
66
- con.execute(f"CREATE TABLE {table} (id INTEGER PRIMARY KEY, content TEXT, timestamp TEXT)")
70
+ con.execute(
71
+ f"CREATE TABLE IF NOT EXISTS {table} "
72
+ "(id INTEGER PRIMARY KEY, content TEXT, timestamp TEXT)"
73
+ )
67
74
  con.execute(
68
75
  f"INSERT INTO {table} (content, timestamp) VALUES (?, ?)",
69
- ("schema-diff pass: verified", iso_timestamp),
76
+ (self.MARKER if content is None else content, iso_timestamp),
70
77
  )
71
78
  con.commit()
72
79
  con.close()
@@ -110,6 +117,146 @@ class SchemaDiffGateTest(unittest.TestCase):
110
117
  self.assertEqual(code, 2)
111
118
  self.assertFalse(allowed)
112
119
 
120
+ def test_the_gates_own_refusal_message_does_not_clear_it(self):
121
+ """A block message that is itself a valid unblock token is self-defeating.
122
+
123
+ The old matcher was LIKE '%schema-diff%pass%', which the gate's own
124
+ refusal text satisfies ("...require `uap schema-diff` to pass"). Agents
125
+ here are instructed to store lessons, and blockers are exactly what they
126
+ store — so recording the refusal unblocked the next commit.
127
+ """
128
+ self.write_marker(
129
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
130
+ content=(
131
+ "Lesson: schema-diff-gate: changes to migrations/001_add_table.sql "
132
+ "require `uap schema-diff` to pass (within 1h). Run it and re-commit."
133
+ ),
134
+ )
135
+ code, allowed, _ = run_gate("git-commit", {}, self.root)
136
+ self.assertFalse(allowed, "the gate's own refusal text must not clear the gate")
137
+ self.assertEqual(code, 2)
138
+
139
+ def test_a_marker_from_the_PREVIOUS_cli_release_still_clears_it(self):
140
+ """Anchoring must not deadlock operators running an older global `uap`.
141
+
142
+ The previously-released recorder wrote
143
+ "schema-diff pass: base <b>, N schema file(s) checked, no breaking
144
+ changes" — same prefix — so it still matches. If a future format change
145
+ drops that prefix, the gate must be updated in the same commit or every
146
+ operator on the old CLI is blocked with no reachable remedy.
147
+ """
148
+ self.write_marker(
149
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
150
+ content="schema-diff pass: base HEAD~1, 3 schema file(s) checked, no breaking changes",
151
+ )
152
+ _, allowed, _ = run_gate("git-commit", {}, self.root)
153
+ self.assertTrue(allowed, "a marker from the previous CLI release must still clear the gate")
154
+
155
+ def test_a_note_saying_the_diff_FAILED_does_not_clear_it(self):
156
+ self.write_marker(
157
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
158
+ content="schema-diff FAILED - breaking change, do not pass this to review",
159
+ )
160
+ _, allowed, _ = run_gate("git-commit", {}, self.root)
161
+ self.assertFalse(allowed, "a failure note must not read as a pass")
162
+
163
+ def test_a_future_dated_marker_does_not_clear_it(self):
164
+ """The window was upper-bounded only, so a future timestamp cleared the
165
+ gate until wall-clock caught up — reachable via an importing writer that
166
+ supplies its own timestamp, or plain clock skew."""
167
+ ahead = (datetime.now(timezone.utc) + timedelta(hours=6)).strftime("%Y-%m-%dT%H:%M:%SZ")
168
+ self.write_marker(ahead)
169
+ _, allowed, _ = run_gate("git-commit", {}, self.root)
170
+ self.assertFalse(allowed, "a future-dated marker must not clear the gate")
171
+
172
+ def test_watched_path_is_listed_once(self):
173
+ """A file both unstaged and staged appeared twice in the reason line,
174
+ which reads as two covered files."""
175
+ (self.root / "migrations" / "001_add_table.sql").write_text("CREATE TABLE t (id bigint);")
176
+ _, _, reason = run_gate("git-commit", {"command": "git commit"}, self.root)
177
+ self.assertEqual(reason.count("001_add_table.sql"), 1, reason)
178
+
179
+
180
+ class MergeVerbatimTest(unittest.TestCase):
181
+ """2026-08-16 pay2u #3153 incident: a conflict-resolution merge staged
182
+ migration files that arrived VERBATIM from the already-merged base branch,
183
+ and the gate demanded a schema-diff re-pass for content this branch never
184
+ authored. merge_verbatim() must exempt staged files whose blob equals the
185
+ MERGE_HEAD version — and must NOT exempt files the merge actually edited."""
186
+
187
+ def setUp(self):
188
+ self._tmp = tempfile.TemporaryDirectory(prefix="schema-gate-merge-")
189
+ self.root = Path(self._tmp.name)
190
+ g = lambda *a: subprocess.run(
191
+ ["git", "-c", "user.email=t@t", "-c", "user.name=t", *a],
192
+ cwd=self.root, check=True, capture_output=True,
193
+ )
194
+ g("init", "-q", "-b", "main")
195
+ (self.root / "migrations").mkdir()
196
+ (self.root / "base.txt").write_text("base")
197
+ g("add", "-A")
198
+ g("commit", "-q", "-m", "init")
199
+ # Feature branch diverges without touching migrations.
200
+ g("checkout", "-q", "-b", "feature")
201
+ (self.root / "feature.txt").write_text("feature work")
202
+ g("add", "-A")
203
+ g("commit", "-q", "-m", "feature")
204
+ # Main gains a watched migration (reviewed there).
205
+ g("checkout", "-q", "main")
206
+ (self.root / "migrations" / "001_add_table.sql").write_text("CREATE TABLE t (id int);")
207
+ g("add", "-A")
208
+ g("commit", "-q", "-m", "migration on main")
209
+ # Merge main INTO feature: migration arrives verbatim, merge left open
210
+ # (no commit) so MERGE_HEAD exists and the file is staged.
211
+ g("checkout", "-q", "feature")
212
+ subprocess.run(
213
+ ["git", "-c", "user.email=t@t", "-c", "user.name=t", "merge", "--no-commit", "--no-ff", "main"],
214
+ cwd=self.root, check=True, capture_output=True,
215
+ )
216
+
217
+ def tearDown(self):
218
+ self._tmp.cleanup()
219
+
220
+ def test_verbatim_incoming_migration_is_exempt(self):
221
+ _, allowed, reason = run_gate("git-commit", {"command": "git commit"}, self.root)
222
+ self.assertTrue(allowed, f"verbatim merge-incoming migration should not gate: {reason}")
223
+
224
+ def test_merge_edited_migration_still_gates(self):
225
+ # Editing the migration during the merge makes it THIS branch's change.
226
+ # NB this stages the edit; the UNSTAGED variant below is the one that
227
+ # was actually exploitable.
228
+ (self.root / "migrations" / "001_add_table.sql").write_text("CREATE TABLE t (id bigint);")
229
+ subprocess.run(["git", "add", "migrations/001_add_table.sql"], cwd=self.root, check=True)
230
+ _, allowed, reason = run_gate("git-commit", {"command": "git commit"}, self.root)
231
+ self.assertFalse(allowed, "a migration edited during the merge must still gate")
232
+ self.assertIn("schema-diff", reason)
233
+
234
+ def test_UNSTAGED_merge_edit_still_gates(self):
235
+ """The exemption compared the INDEX blob, but `git commit -a` commits the
236
+ WORKING TREE.
237
+
238
+ Reproduced against the shipped gate: with the index still matching
239
+ MERGE_HEAD and an unstaged `DROP TABLE users;` appended, it answered
240
+ {"allowed": true, "reason": "no watched schema/pool paths in diff"} —
241
+ not even naming the exemption — while `git commit -am` would have
242
+ committed the drop. This is also the likely ACCIDENTAL path: a human
243
+ resolving a conflict, tweaking a migration, running `git commit -am`.
244
+ """
245
+ p = self.root / "migrations" / "001_add_table.sql"
246
+ p.write_text(p.read_text() + "\nDROP TABLE t;\n") # deliberately NOT staged
247
+ _, allowed, reason = run_gate("git-commit", {"command": "git commit -am merge"}, self.root)
248
+ self.assertFalse(
249
+ allowed,
250
+ "an unstaged edit during a merge is committed by `commit -a` and must gate: " + reason,
251
+ )
252
+
253
+ def test_a_genuinely_verbatim_merge_is_still_exempt_after_the_fix(self):
254
+ """The deadlock fix must survive the hardening: with no local edit at
255
+ all, the incoming migration stays exempt."""
256
+ _, allowed, reason = run_gate("git-commit", {"command": "git commit -am merge"}, self.root)
257
+ self.assertTrue(allowed, f"verbatim merge must remain exempt: {reason}")
258
+
113
259
 
114
260
  if __name__ == "__main__":
115
261
  unittest.main()
262
+