witan-code 0.17.0__tar.gz → 0.19.0__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 (107) hide show
  1. {witan_code-0.17.0 → witan_code-0.19.0}/CHANGELOG.md +111 -0
  2. {witan_code-0.17.0 → witan_code-0.19.0}/PKG-INFO +3 -3
  3. {witan_code-0.17.0 → witan_code-0.19.0}/docs/CLI_REFERENCE.md +17 -0
  4. {witan_code-0.17.0 → witan_code-0.19.0}/pyproject.toml +25 -4
  5. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_branches.py +73 -3
  6. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_cli_dispatch.py +34 -2
  7. witan_code-0.19.0/tests/test_cli_selected_target.py +86 -0
  8. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_config_targets.py +75 -0
  9. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_context.py +30 -0
  10. witan_code-0.19.0/tests/test_index_reports_its_store.py +122 -0
  11. witan_code-0.19.0/tests/test_index_requires_a_repo_key.py +67 -0
  12. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_indexer.py +3 -0
  13. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_ingest.py +16 -0
  14. witan_code-0.19.0/tests/test_rebuild_honours_the_repo_key.py +90 -0
  15. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_remote_store.py +37 -0
  16. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_repo.py +65 -0
  17. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_setup.py +8 -1
  18. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/cli.py +104 -30
  19. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/context.py +1 -2
  20. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/graph.py +11 -1
  21. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/indexer.py +70 -8
  22. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/ingest.py +2 -1
  23. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/repo.py +26 -3
  24. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/schema/code-schema.pg +11 -6
  25. witan_code-0.19.0/witan_code/selected_target.py +28 -0
  26. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/server.py +10 -3
  27. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/setup.py +17 -0
  28. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/store.py +38 -1
  29. {witan_code-0.17.0 → witan_code-0.19.0}/.gitignore +0 -0
  30. {witan_code-0.17.0 → witan_code-0.19.0}/README.md +0 -0
  31. {witan_code-0.17.0 → witan_code-0.19.0}/config/claude.json +0 -0
  32. {witan_code-0.17.0 → witan_code-0.19.0}/config/copilot.json +0 -0
  33. {witan_code-0.17.0 → witan_code-0.19.0}/config/pi.json +0 -0
  34. {witan_code-0.17.0 → witan_code-0.19.0}/conftest.py +0 -0
  35. {witan_code-0.17.0 → witan_code-0.19.0}/docs/BRANCH_INDEXING.md +0 -0
  36. {witan_code-0.17.0 → witan_code-0.19.0}/docs/EDGE_PRECISION_TIERS.md +0 -0
  37. {witan_code-0.17.0 → witan_code-0.19.0}/docs/PACKAGE_MAP.md +0 -0
  38. {witan_code-0.17.0 → witan_code-0.19.0}/docs/STAGE2_STITCHING.md +0 -0
  39. {witan_code-0.17.0 → witan_code-0.19.0}/docs/SYMBOL_FORMAT.md +0 -0
  40. {witan_code-0.17.0 → witan_code-0.19.0}/docs/SYMBOL_TABLE.md +0 -0
  41. {witan_code-0.17.0 → witan_code-0.19.0}/docs/USER_GUIDE.md +0 -0
  42. {witan_code-0.17.0 → witan_code-0.19.0}/install.sh +0 -0
  43. {witan_code-0.17.0 → witan_code-0.19.0}/tests/__init__.py +0 -0
  44. {witan_code-0.17.0 → witan_code-0.19.0}/tests/conftest.py +0 -0
  45. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_batched_deletes.py +0 -0
  46. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_bridge.py +0 -0
  47. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_chunked_load.py +0 -0
  48. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_cli_hooks.py +0 -0
  49. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_cli_output_format.py +0 -0
  50. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_config.py +0 -0
  51. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_crossrepo.py +0 -0
  52. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_edges.py +0 -0
  53. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_elicit.py +0 -0
  54. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_enrichment.py +0 -0
  55. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_github_app.py +0 -0
  56. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_graph.py +0 -0
  57. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_hooks.py +0 -0
  58. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_identity.py +0 -0
  59. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_index_failure_diagnostics.py +0 -0
  60. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_indexer_scope.py +0 -0
  61. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_languages.py +0 -0
  62. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_maintenance.py +0 -0
  63. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_package_map.py +0 -0
  64. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_reaper.py +0 -0
  65. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_remote_proxy.py +0 -0
  66. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_server.py +0 -0
  67. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_stitch.py +0 -0
  68. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_store.py +0 -0
  69. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_symbol_table.py +0 -0
  70. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_tasks.py +0 -0
  71. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_tool_schema.py +0 -0
  72. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_views.py +0 -0
  73. {witan_code-0.17.0 → witan_code-0.19.0}/tests/test_visualize.py +0 -0
  74. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/__init__.py +0 -0
  75. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/__main__.py +0 -0
  76. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/bridge.py +0 -0
  77. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/bridge_extractors.py +0 -0
  78. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/config.py +0 -0
  79. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/edges.py +0 -0
  80. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/elicit.py +0 -0
  81. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/extensions/pi/codegraph.ts +0 -0
  82. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/github_app.py +0 -0
  83. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/hooks.py +0 -0
  84. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/identity.py +0 -0
  85. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/maintenance.py +0 -0
  86. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/output.py +0 -0
  87. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/package_map.py +0 -0
  88. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/queries/bridge.gq +0 -0
  89. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/queries/code_mutations.gq +0 -0
  90. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/queries/code_read.gq +0 -0
  91. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/queries/delete.gq +0 -0
  92. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/queries_ts/bash.scm +0 -0
  93. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/queries_ts/hcl.scm +0 -0
  94. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/queries_ts/python.scm +0 -0
  95. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/queries_ts/sql.scm +0 -0
  96. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/queries_ts/typescript.scm +0 -0
  97. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/queries_ts/yaml.scm +0 -0
  98. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/reaper.py +0 -0
  99. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/remote/__init__.py +0 -0
  100. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/remote/oidc.py +0 -0
  101. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/remote/proxy.py +0 -0
  102. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/remote/store.py +0 -0
  103. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/schema/bridge-schema.pg +0 -0
  104. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/skills/witan-code/SKILL.md +0 -0
  105. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/stitch.py +0 -0
  106. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/views.py +0 -0
  107. {witan_code-0.17.0 → witan_code-0.19.0}/witan_code/visualize.py +0 -0
@@ -6,6 +6,117 @@ 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]
10
+
11
+ ## [0.19.0] - 2026-09-16
12
+
13
+ ### Changed
14
+
15
+ - **Every code-graph edge type is keyed on `(@src, @dst)`.** A repeated
16
+ `Calls`/`References`/`Imports` pair upserts instead of appending. Needs
17
+ omnigraph 0.11; code graphs are rebuilt by reindexing. The indexer's
18
+ `_dedupe` stays, because a duplicate pair inside one load now fails the whole
19
+ batch.
20
+
21
+ - **`ClusterGraphMissing` and `IngestRefused` are logged at WARNING, not
22
+ reported to Sentry.** Both are now `witan_core.refusal.Refusal`s, so fastmcp
23
+ logs them at WARNING with no traceback and the client receives the message
24
+ unchanged. Both are still `RuntimeError`s. Requires witan-core 0.35.
25
+
26
+ - **Indexing a target with no resolvable repo key is refused, not guessed.**
27
+ When no `origin` remote resolved, the indexer fell back to the target's
28
+ directory name and wrote a permanent store into the shared per-repo store
29
+ directory under it. That key is neither unique — two `/tmp/*/tests` layouts
30
+ collide on `tests`, one silently overwriting the other's index — nor
31
+ recognizable next to real repo graphs; 36 such stores accumulated on one
32
+ machine, named `out2`, `tmp`, `data`, `sf`. Indexing something with no remote
33
+ is nearly always accidental (a scratch checkout, a temp repro layout), so the
34
+ write path now raises `repo.RepoNotDetected` naming the two opt-ins.
35
+ `witan code index`/`reindex` gained `--repo <uri>` alongside the existing
36
+ `WITAN_REPO`. **Reads of a local store are unaffected**: `repo.detect()` keeps
37
+ the directory fallback by default, so local stores already created under a
38
+ bare name stay reachable. Reads against a cluster drop it (see Fixed). The
39
+ hook paths (`session-init`, the PostToolUse reindex) already
40
+ swallow indexing errors, so this degrades to a no-op there rather than
41
+ surfacing.
42
+
43
+ - **`reindex --rebuild` resolves the repo key before it deletes anything, and
44
+ deletes the store that key names.** `--rebuild` runs ahead of the index, and
45
+ it used to resolve its own key with the directory-name fallback — so
46
+ `--rebuild --repo <uri>` on a remoteless checkout checked and deleted the
47
+ legacy bare-name graph rather than the one it was told to use. Worse, the
48
+ refusal above lived only inside `index_path`, which runs afterwards: with
49
+ `--yes` skipping the confirmation prompt, a run that was about to be refused
50
+ could still drop the shared bridge graph first. The key is now resolved once,
51
+ up front, by `indexer.resolve_repo_key` and passed into the rebuild.
52
+
53
+ ### Fixed
54
+
55
+ - **Reads against a cluster no longer probe a graph named after the checkout's
56
+ directory.** In a checkout with no `origin` remote, `repo.detect()` falls back
57
+ to the directory name, and reads used that key against shared graphs too,
58
+ where no graph is keyed that way. Every prompt in a scratch directory sent
59
+ `code_store_views` for a graph like `code-scratchpad`, `code-tmp` or
60
+ `code-memory`, which made up most `ClusterGraphMissing` events in Sentry.
61
+ `store.detect_repo(cfg)` turns the fallback off when `cfg.is_cluster`. The
62
+ server's read path, the `inject-context` hook and `symbols` use it, and
63
+ `symbols` also turns it off whenever it dispatches to a deployment, which a
64
+ target with only `remote_url` does while keeping a local code store. Local
65
+ stores keep the fallback.
66
+
67
+ - **Two concurrent `code_store_open` calls for one view no longer fail the
68
+ second.** `ensure_branch` listed branches and then ran `branch create`, so two
69
+ callers could both see the view missing, and the loser failed with `branch
70
+ '<view>' already exists`. A create that fails that way now counts as success,
71
+ once a fresh listing confirms the branch is there. Any other create failure
72
+ still raises.
73
+
74
+ ## [0.18.0] - 2026-09-02
75
+
76
+ ### Added
77
+
78
+ - **`witan --target <name> code index` reaches this package.** witan-council's
79
+ launcher gained an app-level `--target`, and `witan code …` mounts this
80
+ package's cyclopts App but *not* its meta launcher, so the value is forwarded
81
+ into `witan_code.selected_target` and read by `_srv()`. Before this the flag
82
+ was bound but never routed: `cli._srv()` called `load_remote_config()` with no
83
+ argument, so `witan --target ci code repos` answered from the ambient target
84
+ while appearing to work. Standalone `witan-code --target <name> …` works too.
85
+
86
+ - **`witan code index` prints the store it wrote to, unconditionally.** The one
87
+ command whose entire output is about where symbols went was silent about which
88
+ graph received them — and memory and code graphs are routed by separate
89
+ settings, so a target whose memory is deployed can still index onto the
90
+ laptop. Printed for local and shared alike: a line that appears only in the
91
+ bad case is one nobody has learned to look for. Carried on `IndexStats` rather
92
+ than recomputed by the printer, so a *failed* run reports the same
93
+ destination — the run where "which graph was this?" is actually open.
94
+
95
+ ### Fixed
96
+
97
+ - **`witan-code doctor` could not say which graph was broken under
98
+ `code_transport = "mcp"`.** The identifying column took `os.path.basename` of
99
+ the store address, which is right for a local path and wrong for an
100
+ MCP-routed one — those end in the endpoint URL, so all of production's
101
+ per-repo cluster graphs rendered as the identical string `mcp)`. `StoreRef`
102
+ gained a `label` that answers "which graph is this row" for all three address
103
+ forms, and `code_store_health` returns it alongside the full `store`.
104
+
105
+ - **A failed index did not always name its destination.** `IndexStats` was built
106
+ *after* `ensure_branch()` and the existing-hash read, both of which are remote
107
+ calls, so an unreachable endpoint or a rejected token raised before the stats
108
+ existed and the summary had nothing to print. Stats are now built straight
109
+ after the store resolves, and those two setup calls are wrapped so a failure
110
+ arrives as `IndexFailed` carrying the destination. `check_writable` is
111
+ deliberately left outside: it raises `SharedGraphWriteRefused`, a clean
112
+ self-explanatory refusal that callers assert on by type, not the unattributed
113
+ failure the phases exist to annotate.
114
+
115
+ - **`code_transport` is parsed off a target block.** A target registered with
116
+ `--remote-url` now carries `code_transport = "mcp"`, so branches indexed from
117
+ a checkout land on the cluster where another session — and another developer —
118
+ can see them, instead of in a directory on one laptop.
119
+
9
120
  ## [0.17.0] - 2026-09-01
10
121
 
11
122
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: witan-code
3
- Version: 0.17.0
3
+ Version: 0.19.0
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
@@ -10,7 +10,7 @@ License-Expression: BSD-3-Clause
10
10
  Keywords: agent,code-graph,coding-agent,mcp,static-analysis,tree-sitter
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: >=3.11
13
- Requires-Dist: agent-config-kit<1,>=0.4
13
+ Requires-Dist: agent-config-kit<1,>=0.7
14
14
  Requires-Dist: cyclopts<5,>=4
15
15
  Requires-Dist: fastmcp<5,>=4
16
16
  Requires-Dist: joserfc<2,>=1.0
@@ -26,7 +26,7 @@ Requires-Dist: tree-sitter-sql<0.4,>=0.3
26
26
  Requires-Dist: tree-sitter-typescript<0.24,>=0.23
27
27
  Requires-Dist: tree-sitter-yaml<0.8,>=0.7
28
28
  Requires-Dist: tree-sitter<0.26,>=0.25
29
- Requires-Dist: witan-core[cli,observability,remote,sentry]<1,>=0.32
29
+ Requires-Dist: witan-core[cli,observability,remote,sentry]<1,>=0.36
30
30
  Provides-Extra: tasks
31
31
  Requires-Dist: fastmcp-tasks<5,>=4; extra == 'tasks'
32
32
  Description-Content-Type: text/markdown
@@ -38,12 +38,21 @@ unchanged since the last index are skipped.
38
38
  | Argument | Type | Default | Notes |
39
39
  |---|---|---|---|
40
40
  | `PATH` / `--path` | `Path` | `.` | Positional or `--path`; file or directory |
41
+ | `--repo` | `str` | detected | Canonical repo URI to key the code graph on |
41
42
 
42
43
  Prints a summary line: `scanned/indexed/skipped/symbols/edges/bindings/errors`.
43
44
 
45
+ **A repo key is required.** It comes from the `origin` remote, or from `--repo`
46
+ / `WITAN_REPO`. When none resolves, indexing is **refused** rather than filed
47
+ under the target's directory name — that key is not unique (two `/tmp/*/tests`
48
+ layouts collide on `tests`) and leaves a permanent store in the shared code
49
+ directory alongside real per-repo graphs. Reads still fall back to the
50
+ directory name, so stores created under one before this remain reachable.
51
+
44
52
  ```bash
45
53
  witan-code index # index the whole repo, cwd
46
54
  witan-code index app/models.py # index a single file
55
+ witan-code index /tmp/scratch --repo https://github.com/org/name # no origin
47
56
  ```
48
57
 
49
58
  ## `reindex [PATH]`
@@ -54,6 +63,14 @@ re-parsed and re-inserted even if unchanged.
54
63
  | Argument | Type | Default | Notes |
55
64
  |---|---|---|---|
56
65
  | `PATH` / `--path` | `Path` | `.` | Positional or `--path` |
66
+ | `--rebuild` | `bool` | `false` | Delete unreadable stores before indexing |
67
+ | `--yes` | `bool` | `false` | Skip `--rebuild`'s confirmation prompt |
68
+ | `--repo` | `str` | detected | Canonical repo URI to key the code graph on |
69
+
70
+ Same repo-key requirement as `index` above. The key is resolved **before**
71
+ `--rebuild` deletes anything, and the rebuild deletes the store that key names —
72
+ so a run that would be refused deletes nothing, and `--rebuild --repo <uri>`
73
+ cannot drop a different graph than the one it is about to write.
57
74
 
58
75
  ```bash
59
76
  witan-code reindex # rebuild the whole repo's store
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "witan-code"
3
- version = "0.17.0"
3
+ version = "0.19.0"
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"
@@ -10,7 +10,14 @@ classifiers = [
10
10
  "Programming Language :: Python :: 3",
11
11
  ]
12
12
  dependencies = [
13
- "agent-config-kit>=0.4,<1",
13
+ # >=0.7: witan_code/setup.py passes `mcp_servers_by_platform` to
14
+ # `RegistrationBundle`, a field added in agent-config-kit 0.7.0. It is a
15
+ # dataclass, so an external install resolving 0.4–0.6 raises TypeError on
16
+ # the unexpected keyword the moment `witan-code setup` builds its bundle.
17
+ # Same blind spot as the witan-core floors below — the workspace resolves
18
+ # agent-config-kit by path — except there is NO `check-core-floor`
19
+ # equivalent for this dependency, so this line is the only guard.
20
+ "agent-config-kit>=0.7,<1",
14
21
  # `remote`: the ADR-0005 client stack (OIDC device grant + MCP-client
15
22
  # proxy) behind `witan-code login` and the read commands' remote mode.
16
23
  # Costs nothing extra in practice — fastmcp is already required here and
@@ -121,7 +128,21 @@ dependencies = [
121
128
  # branches were open. Both times the pyproject line auto-merged without a
122
129
  # conflict, since both sides named the same number. Re-check it by hand on
123
130
  # every rebase; a floor one release low here is silent.
124
- "witan-core[cli,remote,observability,sentry]>=0.32,<1",
131
+ #
132
+ # ★ >=0.34 IS AN IMPORT FLOOR, the ordinary kind: `cli.py` imports
133
+ # `with_console_ctx` from `witan_core.elicit`, added in witan-core 0.34.0 in
134
+ # the same change as this caller. Caught by `just check-core-floor` on PR
135
+ # CI, which is exactly the case that check exists for.
136
+ #
137
+ # ★ >=0.35 IS AN IMPORT FLOOR too: `store.py` and `ingest.py` import
138
+ # `witan_core.refusal`, added in witan-core 0.35.0 in the same change.
139
+ #
140
+ # ★ >=0.36 IS A FORMAT FLOOR, and the silent kind: nothing new is imported,
141
+ # but 0.36.0 is the release that installs omnigraph 0.11, the only binary
142
+ # that parses the keyed `code-schema.pg`, and restores the null fields 0.11
143
+ # drops from query rows. An older witan-core imports fine and then fails
144
+ # on the first schema init.
145
+ "witan-core[cli,remote,observability,sentry]>=0.36,<1",
125
146
  "fastmcp>=4,<5",
126
147
  "cyclopts>=4,<5",
127
148
  # [targets.<name>] override models (config.py) — also transitively pulled
@@ -204,7 +225,7 @@ testpaths = ["tests"]
204
225
  packages = ["witan_code"]
205
226
 
206
227
  [tool.bumpversion]
207
- current_version = "0.17.0"
228
+ current_version = "0.19.0"
208
229
  allow_dirty = true
209
230
 
210
231
  [[tool.bumpversion.files]]
@@ -4,6 +4,8 @@ import asyncio
4
4
  import inspect
5
5
  import subprocess
6
6
 
7
+ import pytest
8
+
7
9
  from witan_code import repo as repo_module
8
10
 
9
11
  from .conftest import SAMPLE, requires_stack
@@ -155,6 +157,74 @@ def test_feature_branch_indexes_to_own_branch(tmp_path, monkeypatch):
155
157
  assert not on_main, "main view must not see in-flight branch symbols"
156
158
 
157
159
 
160
+ @requires_stack
161
+ def test_a_view_opened_by_two_callers_at_once_is_not_an_error(tmp_path, monkeypatch):
162
+ """Two concurrent `code_store_open` calls for one view both list branches
163
+ before either creates it, so the second create fails with "already exists"
164
+ (Sentry WITAN-4). The view exists, which is what both callers asked for."""
165
+ from witan_code import config as cfg_module
166
+ from witan_code import indexer
167
+ from witan_code.graph import OmnigraphClient
168
+
169
+ monkeypatch.setenv("WITAN_REPO", REPO)
170
+ monkeypatch.setenv("WITAN_CODE_DIR", str(tmp_path / "code"))
171
+ cfg = cfg_module.load()
172
+ base = _git_repo(tmp_path / "r")
173
+ (base / "svc.py").write_text(SAMPLE)
174
+ indexer.index_path(base, config=cfg)
175
+ store = str(cfg_module.store_path(REPO, cfg.code_dir))
176
+
177
+ winner = OmnigraphClient(store, cfg.queries_dir, branch="feature_race")
178
+ loser = OmnigraphClient(store, cfg.queries_dir, branch="feature_race")
179
+ stale = loser.list_branches()
180
+ winner.ensure_branch()
181
+
182
+ real_list = loser.list_branches
183
+ listings = []
184
+
185
+ def list_branches():
186
+ listings.append(None)
187
+ return stale if len(listings) == 1 else real_list()
188
+
189
+ monkeypatch.setattr(loser, "list_branches", list_branches)
190
+ loser.ensure_branch()
191
+ assert len(listings) == 2, "the loser should have tried to create, then re-listed"
192
+ assert "feature_race" in real_list()
193
+
194
+
195
+ @requires_stack
196
+ @pytest.mark.parametrize(
197
+ ("error", "listed"),
198
+ [
199
+ ("omnigraph branch failed (exit 1):\npermission denied", ["main"]),
200
+ # The message alone is not trusted: the branch has to actually be there.
201
+ (
202
+ "omnigraph branch failed (exit 1):\nbranch 'feature_race' already exists",
203
+ ["main"],
204
+ ),
205
+ ],
206
+ )
207
+ def test_a_create_failure_that_is_not_the_race_still_raises(
208
+ tmp_path, monkeypatch, error, listed
209
+ ):
210
+ from witan_code import config as cfg_module
211
+ from witan_code.graph import OmnigraphClient
212
+
213
+ monkeypatch.setenv("WITAN_CODE_DIR", str(tmp_path / "code"))
214
+ client = OmnigraphClient(
215
+ str(tmp_path / "store"), cfg_module.load().queries_dir, branch="feature_race"
216
+ )
217
+ monkeypatch.setattr(client, "list_branches", lambda: listed)
218
+
219
+ def fail(*_args, **_kwargs):
220
+ raise RuntimeError(error)
221
+
222
+ monkeypatch.setattr(client, "_run", fail)
223
+ with pytest.raises(RuntimeError) as excinfo:
224
+ client.ensure_branch()
225
+ assert str(excinfo.value) == error
226
+
227
+
158
228
  @requires_stack
159
229
  def test_a_logged_in_writer_owns_the_views_it_indexes(
160
230
  tmp_path, monkeypatch, logged_in_actor
@@ -310,7 +380,7 @@ def test_cached_git_amortizes_repeated_calls_within_ttl(monkeypatch):
310
380
  monkeypatch.setattr(
311
381
  srv.repo_module,
312
382
  "detect",
313
- lambda: calls.__setitem__("detect", calls["detect"] + 1) or "r",
383
+ lambda **_kw: calls.__setitem__("detect", calls["detect"] + 1) or "r",
314
384
  )
315
385
  monkeypatch.setattr(
316
386
  srv.repo_module,
@@ -330,7 +400,7 @@ def test_cached_git_refreshes_after_ttl(monkeypatch):
330
400
 
331
401
  srv._git_context.clear()
332
402
  values = iter(["a", "b"])
333
- monkeypatch.setattr(srv.repo_module, "detect", lambda: next(values))
403
+ monkeypatch.setattr(srv.repo_module, "detect", lambda **_kw: next(values))
334
404
 
335
405
  assert srv._cached_detect() == "a"
336
406
  srv._git_context["detect"] = (
@@ -355,7 +425,7 @@ def test_git_context_survives_within_a_test(monkeypatch):
355
425
  from witan_code import server as srv
356
426
 
357
427
  srv._git_context.clear()
358
- monkeypatch.setattr(srv.repo_module, "detect", lambda: LEAKED_REPO)
428
+ monkeypatch.setattr(srv.repo_module, "detect", lambda **_kw: LEAKED_REPO)
359
429
  assert srv._cached_detect() == LEAKED_REPO
360
430
  assert srv._git_context["detect"][1] == LEAKED_REPO
361
431
 
@@ -90,6 +90,38 @@ def test_symbols_resolves_the_repo_client_side(monkeypatch):
90
90
  assert srv.calls[0][1]["repo"] == "https://github.com/test/detected"
91
91
 
92
92
 
93
+ @pytest.mark.parametrize(("remote", "fallback"), [(True, False), (False, True)])
94
+ def test_symbols_never_sends_a_directory_name_to_a_deployment(
95
+ monkeypatch, remote, fallback
96
+ ):
97
+ """A target with only `remote_url` keeps a local code store, so the store
98
+ config alone would allow the fallback; the remote dispatch must not."""
99
+ monkeypatch.delenv("WITAN_CODE_SERVER", raising=False)
100
+ monkeypatch.delenv("WITAN_CODE_TRANSPORT", raising=False)
101
+ srv = _stub(code_repo_symbols=[])
102
+ monkeypatch.setattr(cli_module, "_srv", lambda: srv)
103
+ monkeypatch.setattr(cli_module, "_is_remote", lambda: remote)
104
+ seen = []
105
+
106
+ def _detect(*_a, dirname_fallback=True, **_kw):
107
+ seen.append(dirname_fallback)
108
+ return "scratchproj" if dirname_fallback else None
109
+
110
+ monkeypatch.setattr("witan_code.repo.detect", _detect)
111
+
112
+ cli_module.symbols()
113
+
114
+ # Last, not only: the local path loads config, whose target match detects too.
115
+ assert seen[-1] is fallback
116
+ assert srv.calls == (
117
+ []
118
+ if remote
119
+ else [
120
+ ("code_repo_symbols", {"repo": "scratchproj", "role": None, "scheme": None})
121
+ ]
122
+ )
123
+
124
+
93
125
  def test_stitch_dispatches_to_precise_edges(monkeypatch, capsys):
94
126
  srv = _stub(
95
127
  code_precise_edges=[
@@ -320,7 +352,7 @@ def test_rebuild_refuses_a_path_that_is_not_the_repo_root(
320
352
  monkeypatch.setattr(store_module, "discard_store", _boom)
321
353
 
322
354
  with pytest.raises(SystemExit):
323
- cli_module._rebuild_stores(sub, yes=True)
355
+ cli_module._rebuild_stores(sub, yes=True, slug="https://github.com/test/cg")
324
356
 
325
357
  out = capsys.readouterr().out
326
358
  assert "not the repo root" in out
@@ -352,6 +384,6 @@ def test_rebuild_refuses_a_file_path_with_real_git(tmp_path, monkeypatch, capsys
352
384
  monkeypatch.setattr(store_module, "discard_store", _boom)
353
385
 
354
386
  with pytest.raises(SystemExit):
355
- cli_module._rebuild_stores(target, yes=True)
387
+ cli_module._rebuild_stores(target, yes=True, slug="https://github.com/test/cg")
356
388
 
357
389
  assert "not the repo root" in capsys.readouterr().out
@@ -0,0 +1,86 @@
1
+ """``witan code …`` dispatches to the target witan's launcher bound.
2
+
3
+ This package's App is mounted by witan's CLI but its meta launcher is not, so
4
+ witan's launcher forwards the ``--target`` it bound into
5
+ :mod:`witan_code.selected_target` (the same forwarding ``--output-format``
6
+ already gets, for the same reason). What is asserted here is the other end of
7
+ that wire: that the dispatch path actually consults it.
8
+
9
+ Resolving the ambient target here instead is not a cosmetic gap. ``witan code
10
+ index`` WRITES a code graph, so it would have written to whichever deployment
11
+ the checkout happened to match while the flag on the command line said
12
+ otherwise — and said nothing about it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import pytest
18
+
19
+ from witan_code import cli as code_cli
20
+ from witan_code import config as code_cfg
21
+ from witan_code.selected_target import selected_target, set_selected_target
22
+
23
+
24
+ @pytest.fixture(autouse=True)
25
+ def _clean_selection():
26
+ """Module-level state, so a leaked value would silently steer a later test."""
27
+ set_selected_target(None)
28
+ yield
29
+ set_selected_target(None)
30
+
31
+
32
+ def test_dispatch_reads_the_forwarded_target(monkeypatch):
33
+ seen = {}
34
+
35
+ def _record(target=None):
36
+ seen["target"] = target
37
+ return None # in-process branch; a stub RemoteConfig is not the point
38
+
39
+ monkeypatch.setattr(code_cli, "_server", None)
40
+ monkeypatch.setattr(code_cfg, "load_remote_config", _record)
41
+ set_selected_target("qa")
42
+
43
+ code_cli._srv()
44
+
45
+ assert seen["target"] == "qa"
46
+
47
+
48
+ def test_no_forwarded_target_leaves_resolution_where_it_was(monkeypatch):
49
+ """``None`` is not "no target": the standalone ``witan-code`` launcher
50
+ declares no ``--target``, and resolution falls through to ``WITAN_TARGET``
51
+ and the checkout's ``match_*`` rules exactly as it always did."""
52
+ seen = {}
53
+
54
+ def _record(target=None):
55
+ seen["target"] = target
56
+ return None
57
+
58
+ monkeypatch.setattr(code_cli, "_server", None)
59
+ monkeypatch.setattr(code_cfg, "load_remote_config", _record)
60
+
61
+ code_cli._srv()
62
+
63
+ assert seen["target"] is None
64
+
65
+
66
+ def test_the_auth_path_reads_it_too(monkeypatch):
67
+ """``witan code login`` has to reach the same deployment the read commands
68
+ do, or it mints a token against one target and spends it on another."""
69
+ seen = {}
70
+
71
+ def _record(target=None):
72
+ seen["target"] = target
73
+ return None
74
+
75
+ monkeypatch.setattr(code_cfg, "load_remote_config", _record)
76
+ set_selected_target("ci")
77
+
78
+ with pytest.raises(SystemExit):
79
+ code_cli._remote_or_exit() # no remote configured -> clean exit
80
+
81
+ assert seen["target"] == "ci"
82
+
83
+
84
+ def test_accessor_round_trips():
85
+ set_selected_target("production")
86
+ assert selected_target() == "production"
@@ -377,3 +377,78 @@ def test_unknown_index_role_is_rejected(monkeypatch, toml_file):
377
377
 
378
378
  with pytest.raises(ValueError, match="Unknown index_role"):
379
379
  load()
380
+
381
+
382
+ # ── The seam with `witan target add` ─────────────────────────────────────────
383
+
384
+
385
+ def test_the_block_witan_target_add_writes_routes_code_graphs_to_the_cluster(
386
+ monkeypatch, toml_file
387
+ ):
388
+ """The exact block `witan target add --remote-url …` emits, read back here.
389
+
390
+ These are two packages agreeing by convention, not by a shared type: witan
391
+ (witan-council) renders the TOML, witan-code parses it, and nothing fails
392
+ loudly if they drift. Before `target add` learned `--code-transport`, a
393
+ deployed target had no `code_transport` key at all and fell through to the
394
+ `direct` default, leaving code graphs on the developer's own machine while
395
+ memory went to the deployment — silently, since a local store is a working
396
+ store. Keep this literal in step with `witan.cli.targets._FIELD_ORDER`.
397
+ """
398
+ monkeypatch.setenv(
399
+ "WITAN_CONFIG",
400
+ toml_file(
401
+ """
402
+ [targets.ol]
403
+ remote_url = "https://witan.ci.ol.mit.edu/mcp"
404
+ oidc_issuer = "https://sso-ci.ol.mit.edu/realms/ol-platform-engineering"
405
+ oidc_audience = "witan"
406
+ code_transport = "mcp"
407
+ match_orgs = ["mitodl"]
408
+ """
409
+ ),
410
+ )
411
+ monkeypatch.setenv("WITAN_REPO", "https://github.com/mitodl/agent-kit")
412
+ monkeypatch.delenv("WITAN_TARGET", raising=False)
413
+ monkeypatch.delenv("WITAN_CODE_TRANSPORT", raising=False)
414
+ monkeypatch.delenv("WITAN_CODE_SERVER", raising=False)
415
+
416
+ cfg = load()
417
+ assert cfg.target_name == "ol"
418
+ assert cfg.code_transport == "mcp"
419
+ # `mcp` alone makes it a cluster graph — code_server stays unset, because
420
+ # the deployment holds the address and resolves the actor from the JWT.
421
+ assert cfg.code_server is None
422
+ assert cfg.is_cluster
423
+
424
+
425
+ def test_a_deployed_target_without_code_transport_still_falls_back_to_local(
426
+ monkeypatch, toml_file
427
+ ):
428
+ """The pre-fix shape, pinned so the regression is legible rather than silent.
429
+
430
+ This is what every target registered before 2026-09-01 looks like. It is
431
+ not an error and never was — which is the whole problem, and why the
432
+ onboarding doc now tells people to re-run `target add --force`.
433
+ """
434
+ monkeypatch.setenv(
435
+ "WITAN_CONFIG",
436
+ toml_file(
437
+ """
438
+ [targets.ol]
439
+ remote_url = "https://witan.ci.ol.mit.edu/mcp"
440
+ oidc_issuer = "https://sso-ci.ol.mit.edu/realms/ol-platform-engineering"
441
+ match_orgs = ["mitodl"]
442
+ """
443
+ ),
444
+ )
445
+ monkeypatch.setenv("WITAN_REPO", "https://github.com/mitodl/agent-kit")
446
+ monkeypatch.delenv("WITAN_TARGET", raising=False)
447
+ monkeypatch.delenv("WITAN_CODE_TRANSPORT", raising=False)
448
+ monkeypatch.delenv("WITAN_CODE_SERVER", raising=False)
449
+
450
+ cfg = load()
451
+ assert cfg.code_transport == "direct"
452
+ assert not cfg.is_cluster
453
+ # Memory is deployed while code graphs are not — the split this task fixed.
454
+ assert load_remote_config() is not None
@@ -6,9 +6,13 @@ check, store-exists branching), not the underlying query.
6
6
  """
7
7
 
8
8
  import os
9
+ import shutil
10
+ import subprocess
9
11
  import time
10
12
  from pathlib import Path
11
13
 
14
+ import pytest
15
+
12
16
  from witan_code import context
13
17
 
14
18
 
@@ -45,6 +49,32 @@ def test_inject_context_empty_without_repo(tmp_path, monkeypatch):
45
49
  assert context.inject_context() == ""
46
50
 
47
51
 
52
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git not on PATH")
53
+ def test_inject_context_does_not_probe_a_dirname_graph_on_a_cluster(
54
+ tmp_path, monkeypatch
55
+ ):
56
+ """A checkout with no origin has no repo key when the store is a cluster.
57
+
58
+ Every prompt in a scratch directory used to probe ``code-scratchpad``,
59
+ which no provisioning ever declares (Sentry WITAN-G).
60
+ """
61
+ monkeypatch.delenv("WITAN_REPO", raising=False)
62
+ monkeypatch.setenv("WITAN_CODE_SERVER", "https://omnigraph.example")
63
+ checkout = tmp_path / "scratchpad"
64
+ checkout.mkdir()
65
+ subprocess.run(["git", "init", "-q", str(checkout)], check=True)
66
+ monkeypatch.chdir(checkout)
67
+ probed = []
68
+ monkeypatch.setattr(
69
+ context.store_module.StoreRef,
70
+ "exists",
71
+ lambda self, config=None: probed.append(self) or False,
72
+ )
73
+
74
+ assert context.inject_context() == ""
75
+ assert probed == []
76
+
77
+
48
78
  def test_inject_context_empty_when_no_store_and_no_lock(tmp_path, monkeypatch):
49
79
  monkeypatch.setenv("WITAN_REPO", "https://github.com/test/cg")
50
80
  monkeypatch.setenv("WITAN_CODE_DIR", str(tmp_path / "code"))