@miller-tech/uap 1.210.7 → 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,6 +23,19 @@ 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
+
26
39
  def merge_verbatim(root: Path, path: str) -> bool:
27
40
  """During a merge, a staged watched file that is byte-identical to the
28
41
  incoming MERGE_HEAD version was not authored on this branch — it was
@@ -30,20 +43,26 @@ def merge_verbatim(root: Path, path: str) -> bool:
30
43
  forced a schema-diff re-pass for content the merge cannot change (hit on
31
44
  the 2026-08-16 pay2u #3153 conflict-resolution merge, where migrations
32
45
  from already-merged main blocked the merge commit)."""
33
- git_dir = root / ".git"
34
- # Worktrees use a .git FILE pointing at the real gitdir.
35
- if git_dir.is_file():
36
- try:
37
- ref = git_dir.read_text().strip()
38
- if ref.startswith("gitdir:"):
39
- git_dir = Path(ref.split(":", 1)[1].strip())
40
- except OSError:
41
- return False
42
- if not (git_dir / "MERGE_HEAD").exists():
43
- return False
44
46
  rc, staged_sha, _ = run(["git", "rev-parse", f":{path}"], cwd=root)
45
47
  rc2, theirs_sha, _ = run(["git", "rev-parse", f"MERGE_HEAD:{path}"], cwd=root)
46
- return rc == 0 and rc2 == 0 and staged_sha.strip() == theirs_sha.strip()
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
47
66
 
48
67
 
49
68
  def touched_watched_paths(root: Path) -> list[str]:
@@ -52,10 +71,33 @@ def touched_watched_paths(root: Path) -> list[str]:
52
71
  return []
53
72
  rc2, staged, _ = run(["git", "diff", "--name-only", "--cached"], cwd=root)
54
73
  all_files = (out + "\n" + (staged if rc2 == 0 else "")).splitlines()
55
- watched = [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
56
82
  return [f for f in watched if not merge_verbatim(root, f)]
57
83
 
58
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
99
+
100
+
59
101
  def schema_diff_ok(root: Path) -> bool:
60
102
  db = root / "agents" / "data" / "memory" / "short_term.db"
61
103
  if not db.exists():
@@ -65,31 +107,44 @@ def schema_diff_ok(root: Path) -> bool:
65
107
  # `uap memory store` writes to `memories` (type 'action'), while older
66
108
  # UAP wrote session rows to `session_memories` — accept the marker from
67
109
  # either table so the documented remedy actually clears the gate.
68
- row = None
110
+ newest = None
69
111
  for table in ("memories", "session_memories"):
70
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.
71
119
  cur = con.execute(
72
120
  f"SELECT timestamp FROM {table} "
73
- "WHERE content LIKE '%schema-diff%pass%' "
121
+ "WHERE content LIKE 'schema-diff pass: base %' "
74
122
  "ORDER BY id DESC LIMIT 1"
75
123
  )
76
124
  r = cur.fetchone()
77
- if r and (row is None or r[0] > row[0]):
78
- 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
79
136
  except sqlite3.Error:
80
137
  continue
81
138
  con.close()
82
- if not row:
83
- return False
84
- try:
85
- # Timestamps are stored as UTC ISO-8601 (trailing 'Z'); parse them
86
- # as UTC — time.mktime would misread them as local time and expire
87
- # the marker hours early (or late) depending on the host TZ.
88
- import calendar
89
- ts = calendar.timegm(time.strptime(row[0][:19], "%Y-%m-%dT%H:%M:%S"))
90
- except Exception: # noqa: BLE001
139
+ if newest is None:
91
140
  return False
92
- 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
93
148
  except sqlite3.Error:
94
149
  return False
95
150
 
@@ -107,7 +162,10 @@ def main() -> None:
107
162
  emit(True, "no watched schema/pool paths in diff")
108
163
 
109
164
  if schema_diff_ok(repo_root()):
110
- 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])}")
111
169
 
112
170
  emit(
113
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,9 +117,64 @@ 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.
113
122
 
114
- if __name__ == "__main__":
115
- unittest.main()
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)
116
178
 
117
179
 
118
180
  class MergeVerbatimTest(unittest.TestCase):
@@ -161,9 +223,40 @@ class MergeVerbatimTest(unittest.TestCase):
161
223
 
162
224
  def test_merge_edited_migration_still_gates(self):
163
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.
164
228
  (self.root / "migrations" / "001_add_table.sql").write_text("CREATE TABLE t (id bigint);")
165
229
  subprocess.run(["git", "add", "migrations/001_add_table.sql"], cwd=self.root, check=True)
166
230
  _, allowed, reason = run_gate("git-commit", {"command": "git commit"}, self.root)
167
231
  self.assertFalse(allowed, "a migration edited during the merge must still gate")
168
232
  self.assertIn("schema-diff", reason)
169
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
+
259
+
260
+ if __name__ == "__main__":
261
+ unittest.main()
262
+