witan-code 0.20.0__tar.gz → 0.20.1__tar.gz

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 (111) hide show
  1. {witan_code-0.20.0 → witan_code-0.20.1}/.gitignore +4 -0
  2. {witan_code-0.20.0 → witan_code-0.20.1}/CHANGELOG.md +28 -1
  3. {witan_code-0.20.0 → witan_code-0.20.1}/PKG-INFO +1 -1
  4. witan_code-0.20.1/changelog.d/README.md +13 -0
  5. {witan_code-0.20.0 → witan_code-0.20.1}/pyproject.toml +10 -2
  6. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_context.py +6 -3
  7. witan_code-0.20.1/tests/test_hooks.py +368 -0
  8. witan_code-0.20.1/tests/test_refusal_audit.py +48 -0
  9. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/cli.py +13 -10
  10. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/context.py +85 -18
  11. witan_code-0.20.1/witan_code/hooks.py +234 -0
  12. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/store.py +5 -0
  13. witan_code-0.20.0/tests/test_hooks.py +0 -188
  14. witan_code-0.20.0/witan_code/hooks.py +0 -116
  15. {witan_code-0.20.0 → witan_code-0.20.1}/README.md +0 -0
  16. {witan_code-0.20.0 → witan_code-0.20.1}/config/claude.json +0 -0
  17. {witan_code-0.20.0 → witan_code-0.20.1}/config/copilot.json +0 -0
  18. {witan_code-0.20.0 → witan_code-0.20.1}/config/pi.json +0 -0
  19. {witan_code-0.20.0 → witan_code-0.20.1}/conftest.py +0 -0
  20. {witan_code-0.20.0 → witan_code-0.20.1}/docs/BRANCH_INDEXING.md +0 -0
  21. {witan_code-0.20.0 → witan_code-0.20.1}/docs/CLI_REFERENCE.md +0 -0
  22. {witan_code-0.20.0 → witan_code-0.20.1}/docs/EDGE_PRECISION_TIERS.md +0 -0
  23. {witan_code-0.20.0 → witan_code-0.20.1}/docs/PACKAGE_MAP.md +0 -0
  24. {witan_code-0.20.0 → witan_code-0.20.1}/docs/STAGE2_STITCHING.md +0 -0
  25. {witan_code-0.20.0 → witan_code-0.20.1}/docs/SYMBOL_FORMAT.md +0 -0
  26. {witan_code-0.20.0 → witan_code-0.20.1}/docs/SYMBOL_TABLE.md +0 -0
  27. {witan_code-0.20.0 → witan_code-0.20.1}/docs/USER_GUIDE.md +0 -0
  28. {witan_code-0.20.0 → witan_code-0.20.1}/install.sh +0 -0
  29. {witan_code-0.20.0 → witan_code-0.20.1}/tests/__init__.py +0 -0
  30. {witan_code-0.20.0 → witan_code-0.20.1}/tests/conftest.py +0 -0
  31. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_batched_deletes.py +0 -0
  32. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_branches.py +0 -0
  33. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_bridge.py +0 -0
  34. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_chunked_load.py +0 -0
  35. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_cli_dispatch.py +0 -0
  36. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_cli_hooks.py +0 -0
  37. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_cli_output_format.py +0 -0
  38. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_cli_selected_target.py +0 -0
  39. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_config.py +0 -0
  40. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_config_targets.py +0 -0
  41. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_crossrepo.py +0 -0
  42. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_edges.py +0 -0
  43. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_elicit.py +0 -0
  44. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_enrichment.py +0 -0
  45. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_github_app.py +0 -0
  46. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_graph.py +0 -0
  47. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_identity.py +0 -0
  48. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_index_failure_diagnostics.py +0 -0
  49. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_index_reports_its_store.py +0 -0
  50. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_index_requires_a_repo_key.py +0 -0
  51. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_indexer.py +0 -0
  52. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_indexer_scope.py +0 -0
  53. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_ingest.py +0 -0
  54. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_languages.py +0 -0
  55. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_maintenance.py +0 -0
  56. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_package_map.py +0 -0
  57. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_reaper.py +0 -0
  58. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_rebuild_honours_the_repo_key.py +0 -0
  59. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_remote_proxy.py +0 -0
  60. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_remote_store.py +0 -0
  61. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_repo.py +0 -0
  62. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_server.py +0 -0
  63. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_setup.py +0 -0
  64. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_stitch.py +0 -0
  65. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_store.py +0 -0
  66. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_symbol_table.py +0 -0
  67. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_tasks.py +0 -0
  68. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_tool_schema.py +0 -0
  69. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_views.py +0 -0
  70. {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_visualize.py +0 -0
  71. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/__init__.py +0 -0
  72. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/__main__.py +0 -0
  73. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/bridge.py +0 -0
  74. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/bridge_extractors.py +0 -0
  75. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/config.py +0 -0
  76. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/edges.py +0 -0
  77. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/elicit.py +0 -0
  78. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/extensions/pi/codegraph.ts +0 -0
  79. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/github_app.py +0 -0
  80. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/graph.py +0 -0
  81. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/identity.py +0 -0
  82. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/indexer.py +0 -0
  83. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/ingest.py +0 -0
  84. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/maintenance.py +0 -0
  85. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/output.py +0 -0
  86. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/package_map.py +0 -0
  87. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries/bridge.gq +0 -0
  88. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries/code_mutations.gq +0 -0
  89. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries/code_read.gq +0 -0
  90. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries/delete.gq +0 -0
  91. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries_ts/bash.scm +0 -0
  92. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries_ts/hcl.scm +0 -0
  93. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries_ts/python.scm +0 -0
  94. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries_ts/sql.scm +0 -0
  95. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries_ts/typescript.scm +0 -0
  96. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries_ts/yaml.scm +0 -0
  97. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/reaper.py +0 -0
  98. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/remote/__init__.py +0 -0
  99. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/remote/oidc.py +0 -0
  100. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/remote/proxy.py +0 -0
  101. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/remote/store.py +0 -0
  102. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/repo.py +0 -0
  103. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/schema/bridge-schema.pg +0 -0
  104. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/schema/code-schema.pg +0 -0
  105. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/selected_target.py +0 -0
  106. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/server.py +0 -0
  107. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/setup.py +0 -0
  108. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/skills/witan-code/SKILL.md +0 -0
  109. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/stitch.py +0 -0
  110. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/views.py +0 -0
  111. {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/visualize.py +0 -0
@@ -28,6 +28,10 @@ build/
28
28
  _bin/
29
29
  # omnigraph cluster state (populated by `omnigraph cluster import`)
30
30
  __cluster/
31
+ # The witan UI bundle, written by `npm run build` in mcp/servers/witan/ui/.
32
+ # A build output, so not committed — hatch's `artifacts` setting in that
33
+ # package's pyproject.toml is what gets it into the sdist and the wheel anyway.
34
+ mcp/servers/witan/witan/ui_dist/
31
35
 
32
36
  # MCP server logs / caches
33
37
  .mcp-cache/
@@ -6,7 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/) (pre-1.0:
7
7
  a MINOR bump may include breaking changes).
8
8
 
9
- ## [Unreleased]
9
+ <!-- scriv-insert-here -->
10
+
11
+ ## [0.20.1] - 2026-09-22
12
+
13
+ ### Fixed
14
+
15
+ - **Parallel agents in one worktree no longer race each other's code-graph
16
+ writes.** The PostToolUse `reindex-hook` indexed each edited file in the
17
+ foreground, one process per Edit/Write, so a session fanning out subagents
18
+ in one worktree put that many uncoordinated writers on one branch view.
19
+ Against a deployed graph they lost each other's optimistic-concurrency races
20
+ until the retry budget ran out (Sentry WITAN-12). Both hooks now put their
21
+ target on a per-checkout queue (SessionStart queues the project dir,
22
+ PostToolUse the edited file) and a single detached drainer applies it,
23
+ indexing each queued target once however many times it was queued.
24
+
25
+ The index now lands a few seconds after the edit instead of before the hook
26
+ returns. Outside a git checkout the hook still indexes in the foreground.
27
+
28
+ The per-checkout state is keyed on the git toplevel rather than
29
+ `CLAUDE_PROJECT_DIR`, so a session started in a monorepo subdirectory shares
30
+ its edits' lock. A SessionStart refresh that finds an indexer already
31
+ running is queued rather than dropped.
32
+
33
+ The lock is now a kernel `flock` handed to the drainer, so a killed indexer
34
+ no longer leaves the checkout locked until someone removes the lock by hand.
35
+ The lock, the queue and the last drainer's log live in a private 0700
36
+ `$TMPDIR/witan-code-<uid>/` rather than loose in a shared `/tmp`.
10
37
 
11
38
  ## [0.20.0] - 2026-09-21
12
39
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: witan-code
3
- Version: 0.20.0
3
+ Version: 0.20.1
4
4
  Summary: witan-code — tree-sitter code graph + cross-repo bridge (mounts under `witan code`)
5
5
  Project-URL: Homepage, https://github.com/mitodl/agent-kit/tree/main/mcp/servers/witan-code
6
6
  Project-URL: Repository, https://github.com/mitodl/agent-kit
@@ -0,0 +1,13 @@
1
+ # Changelog fragments
2
+
3
+ Each user-visible change gets its own fragment file here instead of an edit
4
+ to `../CHANGELOG.md` — parallel branches editing the same lines in one shared
5
+ file is what caused the version/changelog drift `bin/check_versions.py`
6
+ exists to catch.
7
+
8
+ Add one with `just changelog witan-code` (or `uvx scriv@1.8.0 create` from
9
+ this directory's parent), fill in whichever category section(s) apply, and
10
+ commit the file alongside your change. `just bump witan-code <part>` folds
11
+ every pending fragment into `../CHANGELOG.md` under the new version heading
12
+ and deletes them — it refuses to run if this directory holds nothing but this
13
+ README.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "witan-code"
3
- version = "0.20.0"
3
+ version = "0.20.1"
4
4
  description = "witan-code — tree-sitter code graph + cross-repo bridge (mounts under `witan code`)"
5
5
  readme = "README.md"
6
6
  license = "BSD-3-Clause"
@@ -225,7 +225,7 @@ testpaths = ["tests"]
225
225
  packages = ["witan_code"]
226
226
 
227
227
  [tool.bumpversion]
228
- current_version = "0.20.0"
228
+ current_version = "0.20.1"
229
229
  allow_dirty = true
230
230
 
231
231
  [[tool.bumpversion.files]]
@@ -233,6 +233,14 @@ filename = "pyproject.toml"
233
233
  search = 'version = "{current_version}"'
234
234
  replace = 'version = "{new_version}"'
235
235
 
236
+ [tool.scriv]
237
+ format = "md"
238
+ fragment_directory = "changelog.d"
239
+ categories = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Performance", "Security", "Notes"]
240
+ md_header_level = "2"
241
+ md_html_anchors = false
242
+ entry_title_template = "[{{ version }}] - {{ date.strftime('%Y-%m-%d') }}"
243
+
236
244
  # witan_code/queries/, witan_code/schema/, witan_code/queries_ts/,
237
245
  # witan_code/skills/, and witan_code/extensions/ are all discovered
238
246
  # automatically via packages = ["witan_code"] — including for a
@@ -8,6 +8,7 @@ check, store-exists branching), not the underlying query.
8
8
  import os
9
9
  import shutil
10
10
  import subprocess
11
+ import tempfile
11
12
  import time
12
13
  from pathlib import Path
13
14
 
@@ -17,9 +18,11 @@ from witan_code import context
17
18
 
18
19
 
19
20
  def _lock(tmp_path, monkeypatch, project_dir):
21
+ """The busy marker `indexing_in_progress` reads; create it to fake a run."""
20
22
  monkeypatch.setenv("TMPDIR", str(tmp_path))
23
+ monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
21
24
  monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(project_dir))
22
- return context._lock_path(project_dir)
25
+ return context._busy_path(project_dir)
23
26
 
24
27
 
25
28
  def _stub_store_stats(monkeypatch, health=None):
@@ -313,7 +316,7 @@ def test_the_bridge_verdict_is_cached_across_processes(tmp_path, monkeypatch):
313
316
 
314
317
 
315
318
  def test_lock_path_does_not_collide_on_sanitization(tmp_path, monkeypatch):
316
- monkeypatch.setenv("TMPDIR", str(tmp_path))
319
+ monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
317
320
 
318
321
  a = context._lock_path(Path("/tmp/a/b"))
319
322
  b = context._lock_path(Path("/tmp/a_b"))
@@ -324,7 +327,7 @@ def test_lock_path_does_not_collide_on_sanitization(tmp_path, monkeypatch):
324
327
  def test_lock_path_name_is_bounded_regardless_of_project_dir_length(
325
328
  tmp_path, monkeypatch
326
329
  ):
327
- monkeypatch.setenv("TMPDIR", str(tmp_path))
330
+ monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
328
331
  long_dir = Path("/" + ("deeply-nested-directory-" * 20) + "/checkout")
329
332
 
330
333
  lock = context._lock_path(long_dir)
@@ -0,0 +1,368 @@
1
+ """Unit tests for the SessionStart/PostToolUse hook logic in hooks.py.
2
+
3
+ ``indexer.index_path`` and process spawning are monkeypatched throughout:
4
+ these tests exercise hooks.py's own orchestration (git-repo check, queueing,
5
+ the lock handoff, stdin-JSON parsing, path resolution), not the real indexer
6
+ or a real detached drainer. The lock itself is real: a kernel flock.
7
+ """
8
+
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+
14
+ import pytest
15
+
16
+ from witan_code import context, hooks
17
+
18
+
19
+ @pytest.fixture
20
+ def _state(tmp_path, monkeypatch):
21
+ tmp = tmp_path / "tmp"
22
+ tmp.mkdir()
23
+ monkeypatch.setenv("TMPDIR", str(tmp))
24
+ monkeypatch.setattr(tempfile, "tempdir", str(tmp))
25
+ return tmp
26
+
27
+
28
+ @pytest.fixture
29
+ def _repo(tmp_path, monkeypatch, _state):
30
+ repo = tmp_path / "repo"
31
+ repo.mkdir()
32
+ subprocess.run(["git", "init", "-q", str(repo)], check=True)
33
+ monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(repo))
34
+ return hooks.repo_module.root(repo)
35
+
36
+
37
+ @pytest.fixture
38
+ def _spawned(monkeypatch):
39
+ calls = []
40
+ monkeypatch.setattr(
41
+ hooks, "popen_detached", lambda argv, **kw: calls.append((argv, kw))
42
+ )
43
+ return calls
44
+
45
+
46
+ @pytest.fixture
47
+ def _indexed(monkeypatch):
48
+ calls = []
49
+ monkeypatch.setattr(hooks.indexer, "index_path", lambda p, force: calls.append(p))
50
+ return calls
51
+
52
+
53
+ def _payload(path) -> str:
54
+ return f'{{"tool_input": {{"file_path": "{path}"}}}}'
55
+
56
+
57
+ # ── session_init ──────────────────────────────────────────────────────────────
58
+
59
+
60
+ def test_session_init_noop_outside_git_repo(tmp_path, monkeypatch, _state, _spawned):
61
+ non_repo = tmp_path / "not-a-repo"
62
+ non_repo.mkdir()
63
+ monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(non_repo))
64
+
65
+ hooks.session_init()
66
+
67
+ assert _spawned == []
68
+
69
+
70
+ def test_session_init_queues_the_project_and_hands_the_lock_to_a_drainer(
71
+ _repo, _spawned
72
+ ):
73
+ hooks.session_init()
74
+
75
+ assert len(_spawned) == 1
76
+ argv, kwargs = _spawned[0]
77
+ assert argv[1:5] == ["-m", "witan_code", "_drain-pending", str(_repo)]
78
+ assert kwargs["pass_fds"] == (int(argv[5]),)
79
+ assert hooks._take_pending(_repo) == [_repo]
80
+ # Busy from the moment the lock is taken, before the drainer is even up.
81
+ assert context.indexing_in_progress() is True
82
+
83
+
84
+ def test_session_init_in_a_subdirectory_shares_the_toplevel_s_lock(
85
+ _repo, monkeypatch, _spawned
86
+ ):
87
+ """A session started in a monorepo subdirectory must not get a lock of its
88
+ own: its full index and the edits' drainer write the same branch view."""
89
+ sub = _repo / "services" / "api"
90
+ sub.mkdir(parents=True)
91
+ monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(sub))
92
+ held = hooks._try_lock(_repo)
93
+
94
+ hooks.session_init()
95
+
96
+ os.close(held)
97
+ assert _spawned == []
98
+ assert hooks._take_pending(_repo) == [sub] # queued, not dropped
99
+
100
+
101
+ def test_session_init_survives_a_failed_spawn(_repo, monkeypatch):
102
+ def _boom(*a, **k):
103
+ raise OSError("cannot spawn")
104
+
105
+ monkeypatch.setattr(hooks, "popen_detached", _boom)
106
+
107
+ hooks.session_init()
108
+
109
+ fd = hooks._try_lock(_repo)
110
+ assert fd is not None # released for the next hook
111
+ os.close(fd)
112
+ assert hooks._has_pending(_repo) # and the refresh is still queued
113
+ assert context.indexing_in_progress() is False
114
+
115
+
116
+ # ── reindex_hook outside a repo ───────────────────────────────────────────────
117
+
118
+
119
+ def test_reindex_hook_noop_on_empty_payload(_indexed):
120
+ hooks.reindex_hook("")
121
+ assert _indexed == []
122
+
123
+
124
+ def test_reindex_hook_noop_on_malformed_json(_indexed):
125
+ hooks.reindex_hook("not json")
126
+ assert _indexed == []
127
+
128
+
129
+ def test_reindex_hook_noop_without_tool_input(_indexed):
130
+ hooks.reindex_hook('{"tool_name": "Edit"}')
131
+ assert _indexed == []
132
+
133
+
134
+ def test_reindex_hook_noop_when_file_missing(tmp_path, _indexed):
135
+ hooks.reindex_hook(_payload(tmp_path / "does-not-exist.py"))
136
+ assert _indexed == []
137
+
138
+
139
+ def test_reindex_hook_outside_a_repo_indexes_in_the_foreground(tmp_path, _indexed):
140
+ target = tmp_path / "a.py"
141
+ target.write_text("def f(): pass")
142
+
143
+ hooks.reindex_hook(_payload(target))
144
+
145
+ assert _indexed == [target]
146
+
147
+
148
+ def test_reindex_hook_resolves_relative_path_against_cwd(
149
+ tmp_path, monkeypatch, _indexed
150
+ ):
151
+ monkeypatch.chdir(tmp_path)
152
+ target = tmp_path / "b.py"
153
+ target.write_text("def g(): pass")
154
+
155
+ hooks.reindex_hook('{"tool_input": {"path": "b.py"}}')
156
+
157
+ assert _indexed == [target]
158
+
159
+
160
+ def test_reindex_hook_swallows_index_failure(tmp_path, monkeypatch):
161
+ target = tmp_path / "c.py"
162
+ target.write_text("def h(): pass")
163
+
164
+ def _boom(p, force):
165
+ raise RuntimeError("parse error")
166
+
167
+ monkeypatch.setattr(hooks.indexer, "index_path", _boom)
168
+
169
+ hooks.reindex_hook(
170
+ f'{{"tool_input": {{"filename": "{target}"}}}}'
171
+ ) # must not raise
172
+
173
+
174
+ # ── reindex_hook inside a repo ────────────────────────────────────────────────
175
+
176
+
177
+ def test_reindex_hook_in_a_repo_queues_and_spawns_a_drainer(_repo, _spawned, _indexed):
178
+ target = _repo / "a.py"
179
+ target.write_text("def f(): pass")
180
+
181
+ hooks.reindex_hook(_payload(target))
182
+
183
+ assert _indexed == [] # the drainer writes, not the hook
184
+ assert [argv[1:5] for argv, _ in _spawned] == [
185
+ ["-m", "witan_code", "_drain-pending", str(_repo)]
186
+ ]
187
+ assert hooks._take_pending(_repo) == [target]
188
+
189
+
190
+ def test_reindex_hook_leaves_the_path_to_a_running_drainer(_repo, _spawned):
191
+ target = _repo / "a.py"
192
+ target.write_text("def f(): pass")
193
+ held = hooks._try_lock(_repo)
194
+
195
+ hooks.reindex_hook(_payload(target))
196
+
197
+ os.close(held)
198
+ assert _spawned == []
199
+ assert hooks._take_pending(_repo) == [target]
200
+
201
+
202
+ # ── drain_pending ─────────────────────────────────────────────────────────────
203
+
204
+
205
+ def test_drain_pending_indexes_each_queued_target_once_and_releases(_repo, _indexed):
206
+ a, b = _repo / "a.py", _repo / "b.py"
207
+ for path in (a, b):
208
+ path.write_text("x = 1")
209
+ for path in (a, b, a, a):
210
+ hooks._enqueue(_repo, path)
211
+
212
+ hooks.drain_pending(_repo, hooks._try_lock(_repo))
213
+
214
+ assert _indexed == [a, b]
215
+ assert not hooks._has_pending(_repo)
216
+ assert not context._busy_path(_repo).exists()
217
+ fd = hooks._try_lock(_repo)
218
+ assert fd is not None
219
+ os.close(fd)
220
+
221
+
222
+ def test_drain_pending_picks_up_a_target_queued_while_it_held_the_lock(
223
+ _repo, monkeypatch
224
+ ):
225
+ a, b = _repo / "a.py", _repo / "b.py"
226
+ for path in (a, b):
227
+ path.write_text("x = 1")
228
+ hooks._enqueue(_repo, a)
229
+ indexed = []
230
+
231
+ def _index(p, force):
232
+ indexed.append(p)
233
+ if p == a:
234
+ # An edit landing mid-drain: its hook finds the lock held and
235
+ # leaves the path to this drainer's re-check.
236
+ assert hooks._try_lock(_repo) is None
237
+ hooks._enqueue(_repo, b)
238
+
239
+ monkeypatch.setattr(hooks.indexer, "index_path", _index)
240
+
241
+ hooks.drain_pending(_repo, hooks._try_lock(_repo))
242
+
243
+ assert indexed == [a, b]
244
+
245
+
246
+ def test_drain_pending_marks_the_checkout_busy_while_it_works(_repo, monkeypatch):
247
+ a = _repo / "a.py"
248
+ a.write_text("x = 1")
249
+ hooks._enqueue(_repo, a)
250
+ seen = []
251
+ monkeypatch.setattr(
252
+ hooks.indexer,
253
+ "index_path",
254
+ lambda p, force: seen.append(context.indexing_in_progress()),
255
+ )
256
+
257
+ hooks.drain_pending(_repo, hooks._try_lock(_repo))
258
+
259
+ assert seen == [True]
260
+ assert context.indexing_in_progress() is False
261
+
262
+
263
+ def test_drain_pending_skips_a_target_deleted_since_it_was_queued(_repo, _indexed):
264
+ hooks._enqueue(_repo, _repo / "gone.py")
265
+
266
+ hooks.drain_pending(_repo, hooks._try_lock(_repo))
267
+
268
+ assert _indexed == []
269
+ assert not hooks._has_pending(_repo)
270
+
271
+
272
+ def test_drain_pending_continues_past_a_failing_target(_repo, monkeypatch):
273
+ a, b = _repo / "a.py", _repo / "b.py"
274
+ for path in (a, b):
275
+ path.write_text("x = 1")
276
+ hooks._enqueue(_repo, path)
277
+ indexed = []
278
+
279
+ def _index(p, force):
280
+ if p == a:
281
+ raise RuntimeError("write authority changed during preparation")
282
+ indexed.append(p)
283
+
284
+ monkeypatch.setattr(hooks.indexer, "index_path", _index)
285
+
286
+ hooks.drain_pending(_repo, hooks._try_lock(_repo))
287
+
288
+ assert indexed == [b]
289
+ fd = hooks._try_lock(_repo)
290
+ assert fd is not None # released despite the failure
291
+ os.close(fd)
292
+
293
+
294
+ # ── lock and state ────────────────────────────────────────────────────────────
295
+
296
+
297
+ def test_a_killed_holder_does_not_leave_the_checkout_locked(_repo):
298
+ """The reason the lock is a flock: the kernel drops it with the process,
299
+ so nothing has to decide whether a recorded holder is still alive."""
300
+ holder = subprocess.Popen(
301
+ [
302
+ sys.executable,
303
+ "-c",
304
+ "import fcntl, os, sys, time; "
305
+ "fd = os.open(sys.argv[1], os.O_RDWR | os.O_CREAT); "
306
+ "fcntl.flock(fd, fcntl.LOCK_EX); print('held', flush=True); "
307
+ "time.sleep(60)",
308
+ str(context._lock_path(_repo)),
309
+ ],
310
+ stdout=subprocess.PIPE,
311
+ text=True,
312
+ )
313
+ assert holder.stdout.readline().strip() == "held"
314
+ assert hooks._try_lock(_repo) is None
315
+
316
+ holder.kill()
317
+ holder.wait()
318
+
319
+ fd = hooks._try_lock(_repo)
320
+ assert fd is not None
321
+ os.close(fd)
322
+
323
+
324
+ def test_state_dir_is_private(_state):
325
+ path = context.state_dir()
326
+
327
+ assert path.stat().st_mode & 0o777 == 0o700
328
+ assert path.stat().st_uid == os.getuid()
329
+
330
+
331
+ def test_state_dir_tightens_an_existing_directory_s_mode(_state):
332
+ path = _state / f"witan-code-{os.getuid()}"
333
+ path.mkdir(mode=0o777)
334
+ path.chmod(0o777)
335
+
336
+ assert context.state_dir().stat().st_mode & 0o777 == 0o700
337
+
338
+
339
+ def test_state_dir_refuses_a_symlink_planted_in_its_place(_state, tmp_path):
340
+ elsewhere = tmp_path / "elsewhere"
341
+ elsewhere.mkdir()
342
+ (_state / f"witan-code-{os.getuid()}").symlink_to(elsewhere)
343
+
344
+ with pytest.raises(PermissionError):
345
+ context.state_dir()
346
+
347
+
348
+ def test_sessions_with_different_witan_overrides_get_separate_drainers(
349
+ _repo, monkeypatch
350
+ ):
351
+ """The drainer indexes with its spawner's environment, so an edit made
352
+ under ``WITAN_TARGET=qa`` must not be applied by a drainer spawned without
353
+ it."""
354
+ monkeypatch.delenv("WITAN_TARGET", raising=False)
355
+ held = hooks._try_lock(_repo)
356
+
357
+ monkeypatch.setenv("WITAN_TARGET", "qa")
358
+ other = hooks._try_lock(_repo)
359
+
360
+ assert other is not None
361
+ os.close(other)
362
+ os.close(held)
363
+
364
+
365
+ def test_a_path_with_a_newline_is_not_queued(_repo):
366
+ hooks._enqueue(_repo, _repo / "a\nb.py")
367
+
368
+ assert not hooks._has_pending(_repo)
@@ -0,0 +1,48 @@
1
+ """witan-code's own refusals declare the log-safety their messages actually have.
2
+
3
+ Counterpart to ``mcp/servers/witan/tests/test_refusal_audit.py``; see that
4
+ module's docstring for why the table in witan-core's suite cannot cover these.
5
+
6
+ See ``witan_core.refusal.Refusal.log_safe_message``.
7
+ """
8
+
9
+ import pytest
10
+
11
+ from witan_code.ingest import IngestRefused
12
+ from witan_code.store import ClusterGraphMissing
13
+
14
+ EXPECTED_LOG_SAFE = {
15
+ # A repo, a graph id and a provisioning hint. The message whose absence
16
+ # made Production's 27% `code_store_views` failure rate undiagnosable.
17
+ ClusterGraphMissing: True,
18
+ # WITHHELD. `_step_field` raises it as `f"...got {value!r}."` over the
19
+ # caller's own step, and two more sites raise `IngestRefused(str(exc))`
20
+ # over an arbitrary upstream exception.
21
+ IngestRefused: False,
22
+ }
23
+
24
+
25
+ @pytest.mark.parametrize(("cls", "expected"), list(EXPECTED_LOG_SAFE.items()))
26
+ def test_refusal_opt_in_matches_the_audit(cls, expected):
27
+ assert cls.log_safe_message is expected
28
+
29
+
30
+ def test_every_witan_code_refusal_is_in_the_table():
31
+ """A new refusal here has to be read before it can opt in."""
32
+ from witan_core.refusal import Refusal
33
+
34
+ def walk(base):
35
+ for sub in base.__subclasses__():
36
+ yield sub
37
+ yield from walk(sub)
38
+
39
+ ours = {
40
+ sub
41
+ for sub in walk(Refusal)
42
+ if sub.__module__.startswith("witan_code.")
43
+ and sub.__dict__.get("log_safe_message") is True
44
+ }
45
+ assert ours <= set(EXPECTED_LOG_SAFE), (
46
+ f"opted in without an audit entry: "
47
+ f"{sorted(c.__name__ for c in ours - set(EXPECTED_LOG_SAFE))}"
48
+ )
@@ -885,9 +885,10 @@ def session_init_cmd() -> None:
885
885
  """Seed/refresh the whole repo's code graph in the background (SessionStart hook).
886
886
 
887
887
  Detached and non-blocking — returns immediately regardless of repo size.
888
- A per-repo lock (shared with ``inject-context``'s "indexing in progress"
889
- check) prevents overlapping sessions from indexing at once. Registered as
890
- the bare ``SessionStart`` hook command; not usually run by hand.
888
+ The refresh goes on the same per-checkout queue as ``reindex-hook``'s
889
+ edits, so one drainer at a time writes the checkout's code graph.
890
+ Registered as the bare ``SessionStart`` hook command; not usually run by
891
+ hand.
891
892
  """
892
893
  from . import hooks as hooks_module
893
894
 
@@ -897,12 +898,12 @@ def session_init_cmd() -> None:
897
898
  pass
898
899
 
899
900
 
900
- @app.command(name="_index-and-unlock", show=False)
901
- def _index_and_unlock_cmd(target: Path, lock: Path) -> None:
902
- """Internal — run only by the detached child ``session-init`` spawns."""
901
+ @app.command(name="_drain-pending", show=False)
902
+ def _drain_pending_cmd(checkout: Path, lock_fd: int) -> None:
903
+ """Internal — run only by the detached drainer the two hooks spawn."""
903
904
  from . import hooks as hooks_module
904
905
 
905
- hooks_module.index_and_unlock(target, lock)
906
+ hooks_module.drain_pending(checkout, lock_fd)
906
907
 
907
908
 
908
909
  @app.command(name="reindex-hook")
@@ -910,9 +911,11 @@ def reindex_hook_cmd() -> None:
910
911
  """Incrementally reindex the file named in stdin's hook JSON (PostToolUse hook).
911
912
 
912
913
  Reads the Claude Code hook payload from stdin, extracts
913
- ``tool_input.file_path`` (or ``path``/``filename``), and reindexes it if
914
- it exists and is a known source type — foreground and fast (one file), so
915
- the agent sees the change land immediately. Best-effort: a missing or
914
+ ``tool_input.file_path`` (or ``path``/``filename``), and queues it for
915
+ reindexing if it exists. One detached drainer per checkout applies the
916
+ queue, so parallel agents editing one worktree do not race each other's
917
+ writes to its branch view. Logs from the last drainer are kept in
918
+ ``$TMPDIR/witan-code-<uid>/``. Best-effort: a missing or
916
919
  malformed payload is a silent no-op. Registered as the bare
917
920
  ``PostToolUse`` (matcher ``Edit|Write``) hook command; not usually run by
918
921
  hand.