@hasna/hooks 0.5.0 → 0.6.0

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.
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env python3
2
+ """Regression suite for the shared-temp-file race in hasna-mention-context.py.
3
+
4
+ THE DEFECT. `run_capture` derived its output path from its `tag` argument alone,
5
+ and `probe_local_head` passed a constant `tag="gitlog"`. Repository probes run
6
+ concurrently against one shared temp directory, so every mentioned repository's
7
+ `git log` wrote to and read back the same `gitlog.out` and the reader got
8
+ whatever the last writer left. The emitted sha was always a real sha from a real
9
+ repository, just the wrong one.
10
+
11
+ WHY THESE TESTS ARE TIMED RATHER THAN LOOPED. A race reproduced by "run it many
12
+ times and hope" passes on broken code whenever the interleaving happens not to
13
+ occur, which makes it worthless as a gate. Both tests below instead FORCE the
14
+ losing interleaving with a controlled delay, so they fail on the unfixed code on
15
+ every run rather than on some runs.
16
+
17
+ The construction, which is the whole idea:
18
+
19
+ EARLY WRITER, LATE READER writes at t=0, exits and reads at t=SLOW
20
+ LATE WRITER, EARLY READER writes and exits and reads at t=FAST
21
+
22
+ t=0 both open(path,"wb") -> truncate
23
+ t=0 early's process writes its marker
24
+ t=FAST late's process overwrites the same path, then reads -> its own value
25
+ t=SLOW early reads -> LATE'S VALUE, not its own <- the defect
26
+
27
+ With one file per call the two never interact and both read their own output.
28
+ """
29
+
30
+ import importlib.util
31
+ import os
32
+ import shutil
33
+ import subprocess
34
+ import sys
35
+ import tempfile
36
+ import threading
37
+ import unittest
38
+
39
+ HERE = os.path.dirname(os.path.abspath(__file__))
40
+ HOOK_PATH = os.path.join(HERE, "hasna-mention-context.py")
41
+
42
+ # Timings. SLOW must stay comfortably under probe_local_head's hardcoded 2.0 s
43
+ # subprocess timeout, or a slow machine turns a real pass into a spurious
44
+ # failure. The gap between them is the margin that makes the interleaving
45
+ # deterministic; 0.55 s absorbs a great deal of scheduler jitter.
46
+ SLOW_S = 0.80
47
+ FAST_S = 0.25
48
+
49
+
50
+ def load_hook():
51
+ """Import the hook by path. Its filename is not a legal module name, and it
52
+ guards main() behind __name__ == "__main__", so importing runs no hook."""
53
+ spec = importlib.util.spec_from_file_location("hasna_mention_context", HOOK_PATH)
54
+ if spec is None or spec.loader is None:
55
+ raise RuntimeError("could not load a module spec for %s" % HOOK_PATH)
56
+ mod = importlib.util.module_from_spec(spec)
57
+ spec.loader.exec_module(mod)
58
+ return mod
59
+
60
+
61
+ mod = load_hook()
62
+
63
+
64
+ def run_pair(first, second):
65
+ """Run two callables on two threads released simultaneously by a barrier.
66
+
67
+ Returns {name: value}. A barrier rather than two bare start() calls, because
68
+ the interleaving depends on both calls entering the region together.
69
+ """
70
+ results = {}
71
+ errors = {}
72
+ barrier = threading.Barrier(2)
73
+
74
+ def wrap(name, fn):
75
+ def run():
76
+ try:
77
+ barrier.wait(timeout=10)
78
+ results[name] = fn()
79
+ except BaseException as exc: # noqa: BLE001 - surfaced below
80
+ errors[name] = exc
81
+ return run
82
+
83
+ threads = [threading.Thread(target=wrap(n, f), daemon=True)
84
+ for n, f in (first, second)]
85
+ for t in threads:
86
+ t.start()
87
+ for t in threads:
88
+ t.join(timeout=30)
89
+ for t in threads:
90
+ if t.is_alive():
91
+ raise AssertionError("a probe thread did not finish within 30 s")
92
+ if errors:
93
+ raise AssertionError("probe thread raised: %r" % (errors,))
94
+ return results
95
+
96
+
97
+ class RunCaptureIsolation(unittest.TestCase):
98
+ """Level 1 — run_capture itself, no git, fully hermetic."""
99
+
100
+ def test_concurrent_captures_sharing_a_tag_do_not_cross(self):
101
+ tmpdir = tempfile.mkdtemp(prefix="mention-context-test-")
102
+ self.addCleanup(shutil.rmtree, tmpdir, ignore_errors=True)
103
+
104
+ # Equal-length markers, so that on the unfixed code the overwrite is
105
+ # clean and the failure message shows one whole wrong value rather than
106
+ # two values spliced at a byte boundary.
107
+ early_mark = "AAAAAAAA"
108
+ late_mark = "BBBBBBBB"
109
+
110
+ def capture(mark, pre_sleep, post_sleep):
111
+ script = (
112
+ "import sys, time\n"
113
+ "time.sleep(%r)\n"
114
+ "sys.stdout.write(%r)\n"
115
+ "sys.stdout.flush()\n"
116
+ "time.sleep(%r)\n" % (pre_sleep, mark, post_sleep)
117
+ )
118
+ # A python child rather than a shell one-liner: the write and the
119
+ # flush are explicit, so "the bytes reached the file before the
120
+ # sleep" is a property of the test rather than of shell builtin
121
+ # buffering.
122
+ def call():
123
+ return mod.run_capture(
124
+ [sys.executable, "-c", script],
125
+ timeout=30.0, tmpdir=tmpdir, tag="gitlog",
126
+ )
127
+ return call
128
+
129
+ results = run_pair(
130
+ ("early", capture(early_mark, 0.0, SLOW_S)), # writes t=0, reads t=SLOW
131
+ ("late", capture(late_mark, FAST_S, 0.0)), # writes and reads t=FAST
132
+ )
133
+
134
+ early_rc, early_out = results["early"]
135
+ late_rc, late_out = results["late"]
136
+
137
+ self.assertEqual(early_rc, 0, "early capture did not run: rc=%r" % (early_rc,))
138
+ self.assertEqual(late_rc, 0, "late capture did not run: rc=%r" % (late_rc,))
139
+
140
+ # This is the assertion the defect breaks. On the unfixed code the early
141
+ # caller reads the LATE caller's marker, because both used gitlog.out.
142
+ self.assertEqual(
143
+ early_out, early_mark,
144
+ "the early caller read %r but produced %r — a concurrent call's "
145
+ "output was attributed to it (shared temp path)"
146
+ % (early_out, early_mark),
147
+ )
148
+ self.assertEqual(
149
+ late_out, late_mark,
150
+ "the late caller read %r but produced %r" % (late_out, late_mark),
151
+ )
152
+
153
+ def test_capture_paths_are_distinct_for_the_same_tag(self):
154
+ """The invariant directly: one tag, two calls, two files.
155
+
156
+ Kept separate from the timed test because it states the property in one
157
+ line and cannot be confused by machine load.
158
+ """
159
+ tmpdir = tempfile.mkdtemp(prefix="mention-context-test-")
160
+ self.addCleanup(shutil.rmtree, tmpdir, ignore_errors=True)
161
+
162
+ for _ in range(2):
163
+ rc, _out = mod.run_capture([sys.executable, "-c", "pass"],
164
+ timeout=30.0, tmpdir=tmpdir, tag="gitlog")
165
+ self.assertEqual(rc, 0)
166
+
167
+ outs = [n for n in os.listdir(tmpdir) if n.endswith(".out")]
168
+ self.assertEqual(
169
+ len(outs), 2,
170
+ "two calls with tag='gitlog' produced %d .out file(s) (%r); each "
171
+ "call must own its path or concurrent calls overwrite each other"
172
+ % (len(outs), sorted(outs)),
173
+ )
174
+ self.assertTrue(
175
+ all(n.startswith("gitlog") for n in outs),
176
+ "the tag must survive as a readable prefix, got %r" % (sorted(outs),),
177
+ )
178
+
179
+
180
+ class ProbeLocalHeadIsolation(unittest.TestCase):
181
+ """Level 2 — probe_local_head end to end, real git, real repositories.
182
+
183
+ The repositories are built here rather than borrowed from the machine, so
184
+ the test is hermetic and the expected shas are known exactly.
185
+ """
186
+
187
+ def setUp(self):
188
+ self.git = shutil.which("git")
189
+ if not self.git:
190
+ # Fail rather than skip. A skipped test is indistinguishable from a
191
+ # passing one in the summary line, and this suite exists to gate a
192
+ # defect that already shipped once.
193
+ self.fail("git is not on PATH; this regression test cannot run")
194
+ self.root = tempfile.mkdtemp(prefix="mention-context-repos-")
195
+ self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
196
+
197
+ def make_repo(self, name, content):
198
+ path = os.path.join(self.root, name)
199
+ os.makedirs(path)
200
+ # Identity is supplied through the environment of these throwaway
201
+ # fixture processes only. Nothing here writes git config, and no real
202
+ # repository is touched.
203
+ env = dict(os.environ)
204
+ env.update({
205
+ "GIT_AUTHOR_NAME": "mention-context test",
206
+ "GIT_AUTHOR_EMAIL": "test@example.invalid",
207
+ "GIT_COMMITTER_NAME": "mention-context test",
208
+ "GIT_COMMITTER_EMAIL": "test@example.invalid",
209
+ "GIT_CONFIG_GLOBAL": os.path.join(self.root, "no-such-gitconfig"),
210
+ "GIT_CONFIG_SYSTEM": os.path.join(self.root, "no-such-gitconfig"),
211
+ })
212
+ def git(*args):
213
+ cp = subprocess.run([self.git, "-C", path] + list(args), env=env,
214
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
215
+ if cp.returncode != 0:
216
+ self.fail("fixture git %r failed rc=%d: %s"
217
+ % (args, cp.returncode, cp.stderr.decode("utf-8", "replace")))
218
+ return cp.stdout.decode("utf-8", "replace")
219
+ git("init", "-q")
220
+ with open(os.path.join(path, "file.txt"), "w") as f:
221
+ f.write(content)
222
+ git("add", "file.txt")
223
+ git("commit", "-q", "-m", "fixture commit for " + name)
224
+ sha = git("log", "-1", "--format=%h").strip()
225
+ return path, sha
226
+
227
+ def write_timing_wrapper(self, slow_repo_path):
228
+ """A git wrapper that makes one repository the early-writer/late-reader.
229
+
230
+ For the slow repository: run git (its output lands in the capture file
231
+ immediately, flushed by git's own exit) and only then sleep before
232
+ exiting, so the read happens late. For every other repository: sleep
233
+ first, so its write lands in between.
234
+ """
235
+ wrapper = os.path.join(self.root, "git-timing-wrapper")
236
+ with open(wrapper, "w") as f:
237
+ f.write(
238
+ "#!/bin/sh\n"
239
+ 'case "$*" in\n'
240
+ " *%s*) %s \"$@\"; ec=$?; sleep %s; exit $ec ;;\n"
241
+ " *) sleep %s; exec %s \"$@\" ;;\n"
242
+ "esac\n" % (os.path.basename(slow_repo_path), self.git,
243
+ SLOW_S, FAST_S, self.git)
244
+ )
245
+ os.chmod(wrapper, 0o755)
246
+ return wrapper
247
+
248
+ def test_concurrent_probes_report_their_own_repositorys_head(self):
249
+ alpha_path, alpha_sha = self.make_repo("alpha", "alpha content\n")
250
+ beta_path, beta_sha = self.make_repo("beta", "beta content\n")
251
+ self.assertNotEqual(
252
+ alpha_sha, beta_sha,
253
+ "fixture repositories must have different heads for this test to "
254
+ "be able to detect wrong attribution at all",
255
+ )
256
+
257
+ tmpdir = tempfile.mkdtemp(prefix="mention-context-test-")
258
+ self.addCleanup(shutil.rmtree, tmpdir, ignore_errors=True)
259
+
260
+ wrapper = self.write_timing_wrapper(beta_path)
261
+ original_git_bin = mod.GIT_BIN
262
+ mod.GIT_BIN = wrapper
263
+ self.addCleanup(setattr, mod, "GIT_BIN", original_git_bin)
264
+
265
+ results = run_pair(
266
+ ("beta", lambda: mod.probe_local_head(beta_path, tmpdir)),
267
+ ("alpha", lambda: mod.probe_local_head(alpha_path, tmpdir)),
268
+ )
269
+
270
+ self.assertIsNotNone(
271
+ results["beta"],
272
+ "probe_local_head returned None for beta; the probe did not run, "
273
+ "so this test proved nothing about attribution",
274
+ )
275
+ self.assertIsNotNone(results["alpha"], "probe_local_head returned None for alpha")
276
+
277
+ # beta is the early writer and late reader: on the unfixed code it reads
278
+ # alpha's line out of the shared gitlog.out and reports alpha's sha.
279
+ self.assertEqual(
280
+ results["beta"]["sha"], beta_sha,
281
+ "beta reported %r; its own head is %r and alpha's is %r — one "
282
+ "repository's head was attributed to another"
283
+ % (results["beta"]["sha"], beta_sha, alpha_sha),
284
+ )
285
+ self.assertEqual(
286
+ results["alpha"]["sha"], alpha_sha,
287
+ "alpha reported %r, expected %r"
288
+ % (results["alpha"]["sha"], alpha_sha),
289
+ )
290
+
291
+
292
+ def colliding_heads(pairs):
293
+ """Field canary, NOT the gate — see the class below for why.
294
+
295
+ `pairs` is [(resolved_checkout, sha), ...]. Returns the shas claimed by more
296
+ than one DISTINCT checkout. Comparing resolved paths rather than mention
297
+ tokens is what stops it firing on `hasna/loops` and `hasna/open-loops`, which
298
+ are two tokens naming one checkout and legitimately share a head.
299
+ """
300
+ by_sha = {}
301
+ for checkout, sha in pairs:
302
+ if not checkout or not sha:
303
+ continue
304
+ by_sha.setdefault(sha, set()).add(os.path.realpath(checkout))
305
+ return sorted(sha for sha, paths in by_sha.items() if len(paths) > 1)
306
+
307
+
308
+ class DuplicateShaCanary(unittest.TestCase):
309
+ """The duplicate-sha check, with its own blind spot pinned down in a test.
310
+
311
+ Two unrelated repositories cannot share a commit sha, so a single emitted
312
+ block claiming one sha for two checkouts is self-evidently corrupt — no
313
+ fixtures needed, which makes this cheap to run against real output in the
314
+ field.
315
+
316
+ It is NOT the acceptance gate, and the test below is what stops anyone
317
+ promoting it to one: it detects COLLISION TO A COMMON VALUE while the defect
318
+ is WRONG ATTRIBUTION, and a clean swap between two concurrent writers is
319
+ pairwise distinct and entirely wrong.
320
+ """
321
+
322
+ def test_catches_the_duplicate_case(self):
323
+ pairs = [("/repos/open-loops", "82a3acf"), ("/repos/open-logs", "82a3acf")]
324
+ self.assertEqual(colliding_heads(pairs), ["82a3acf"])
325
+
326
+ def test_is_blind_to_the_swap_case(self):
327
+ # The 17:48 observation: each value real, each attributed to the wrong
328
+ # repository, pairwise distinct. This assertion records a limitation on
329
+ # purpose; if it ever starts failing the canary got stronger and this
330
+ # test should be revisited, not deleted quietly.
331
+ pairs = [("/repos/open-loops", "27cffd7"), ("/repos/open-logs", "82a3acf")]
332
+ self.assertEqual(
333
+ colliding_heads(pairs), [],
334
+ "the canary is expected to MISS a clean swap; it is a supplement to "
335
+ "the concurrency tests above, never a replacement",
336
+ )
337
+
338
+ def test_does_not_fire_when_two_tokens_name_one_checkout(self):
339
+ # `hasna/loops` and `hasna/open-loops` both resolve to open-loops.
340
+ pairs = [("/repos/open-loops", "82a3acf"), ("/repos/open-loops", "82a3acf")]
341
+ self.assertEqual(colliding_heads(pairs), [])
342
+
343
+
344
+ if __name__ == "__main__":
345
+ unittest.main(verbosity=2)
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {
7
- "hooks": "bin/index.js"
7
+ "hooks": "bin/index.js",
8
+ "hooks-serve": "bin/serve.js"
8
9
  },
9
10
  "exports": {
10
11
  ".": {
@@ -19,7 +20,7 @@
19
20
  "main": "./dist/index.js",
20
21
  "types": "./dist/index.d.ts",
21
22
  "scripts": {
22
- "build": "rm -rf dist && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external pg --external ink --external react --external chalk --external conf --external @modelcontextprotocol/sdk --external zod && bun build ./src/index.ts ./src/storage.ts --outdir ./dist --target bun --external pg && bun run build:types",
23
+ "build": "rm -rf dist bin && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external pg --external ink --external react --external chalk --external conf --external @modelcontextprotocol/sdk --external zod && bun build ./src/serve.ts --outdir ./bin --target bun --external pg && bun build ./src/index.ts ./src/storage.ts --outdir ./dist --target bun --external pg && bun run build:types",
23
24
  "build:types": "tsc -p tsconfig.build.json",
24
25
  "dev": "bun run ./src/cli/index.tsx",
25
26
  "test": "bun test",
@@ -47,10 +48,12 @@
47
48
  "@types/bun": "latest",
48
49
  "@types/pg": "^8.15.6",
49
50
  "@types/react": "^18.2.0",
50
- "typescript": "^5"
51
+ "typescript": "^5",
52
+ "bun-types": "1.3.14"
51
53
  },
52
54
  "dependencies": {
53
55
  "@hasna/events": "^0.1.6",
56
+ "@hasna/secrets": "^0.2.22",
54
57
  "@modelcontextprotocol/sdk": "^1.26.0",
55
58
  "chalk": "^5.3.0",
56
59
  "commander": "^12.1.0",
@@ -60,7 +63,8 @@
60
63
  "ink-spinner": "^5.0.0",
61
64
  "ink-text-input": "^6.0.0",
62
65
  "pg": "^8.16.3",
63
- "react": "^18.2.0"
66
+ "react": "^18.2.0",
67
+ "zod": "3.25.76"
64
68
  },
65
69
  "engines": {
66
70
  "bun": ">=1.0.0"