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.
- {witan_code-0.20.0 → witan_code-0.20.1}/.gitignore +4 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/CHANGELOG.md +28 -1
- {witan_code-0.20.0 → witan_code-0.20.1}/PKG-INFO +1 -1
- witan_code-0.20.1/changelog.d/README.md +13 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/pyproject.toml +10 -2
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_context.py +6 -3
- witan_code-0.20.1/tests/test_hooks.py +368 -0
- witan_code-0.20.1/tests/test_refusal_audit.py +48 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/cli.py +13 -10
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/context.py +85 -18
- witan_code-0.20.1/witan_code/hooks.py +234 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/store.py +5 -0
- witan_code-0.20.0/tests/test_hooks.py +0 -188
- witan_code-0.20.0/witan_code/hooks.py +0 -116
- {witan_code-0.20.0 → witan_code-0.20.1}/README.md +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/config/claude.json +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/config/copilot.json +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/config/pi.json +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/conftest.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/docs/BRANCH_INDEXING.md +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/docs/CLI_REFERENCE.md +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/docs/EDGE_PRECISION_TIERS.md +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/docs/PACKAGE_MAP.md +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/docs/STAGE2_STITCHING.md +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/docs/SYMBOL_FORMAT.md +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/docs/SYMBOL_TABLE.md +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/docs/USER_GUIDE.md +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/install.sh +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/__init__.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/conftest.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_batched_deletes.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_branches.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_bridge.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_chunked_load.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_cli_dispatch.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_cli_hooks.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_cli_output_format.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_cli_selected_target.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_config.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_config_targets.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_crossrepo.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_edges.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_elicit.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_enrichment.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_github_app.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_graph.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_identity.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_index_failure_diagnostics.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_index_reports_its_store.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_index_requires_a_repo_key.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_indexer.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_indexer_scope.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_ingest.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_languages.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_maintenance.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_package_map.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_reaper.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_rebuild_honours_the_repo_key.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_remote_proxy.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_remote_store.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_repo.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_server.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_setup.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_stitch.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_store.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_symbol_table.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_tasks.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_tool_schema.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_views.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/tests/test_visualize.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/__init__.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/__main__.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/bridge.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/bridge_extractors.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/config.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/edges.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/elicit.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/extensions/pi/codegraph.ts +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/github_app.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/graph.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/identity.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/indexer.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/ingest.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/maintenance.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/output.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/package_map.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries/bridge.gq +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries/code_mutations.gq +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries/code_read.gq +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries/delete.gq +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries_ts/bash.scm +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries_ts/hcl.scm +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries_ts/python.scm +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries_ts/sql.scm +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries_ts/typescript.scm +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/queries_ts/yaml.scm +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/reaper.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/remote/__init__.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/remote/oidc.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/remote/proxy.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/remote/store.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/repo.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/schema/bridge-schema.pg +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/schema/code-schema.pg +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/selected_target.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/server.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/setup.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/skills/witan-code/SKILL.md +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/stitch.py +0 -0
- {witan_code-0.20.0 → witan_code-0.20.1}/witan_code/views.py +0 -0
- {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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
the bare ``SessionStart`` hook command; not usually run by
|
|
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="
|
|
901
|
-
def
|
|
902
|
-
"""Internal — run only by the detached
|
|
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.
|
|
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
|
|
914
|
-
it exists
|
|
915
|
-
|
|
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.
|