witan-code 0.17.0__tar.gz → 0.18.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 (105) hide show
  1. {witan_code-0.17.0 → witan_code-0.18.0}/CHANGELOG.md +46 -0
  2. {witan_code-0.17.0 → witan_code-0.18.0}/PKG-INFO +1 -1
  3. {witan_code-0.17.0 → witan_code-0.18.0}/pyproject.toml +2 -2
  4. witan_code-0.18.0/tests/test_cli_selected_target.py +86 -0
  5. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_config_targets.py +75 -0
  6. witan_code-0.18.0/tests/test_index_reports_its_store.py +122 -0
  7. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_indexer.py +3 -0
  8. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_remote_store.py +37 -0
  9. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/cli.py +30 -9
  10. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/indexer.py +32 -4
  11. witan_code-0.18.0/witan_code/selected_target.py +28 -0
  12. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/server.py +9 -2
  13. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/store.py +22 -0
  14. {witan_code-0.17.0 → witan_code-0.18.0}/.gitignore +0 -0
  15. {witan_code-0.17.0 → witan_code-0.18.0}/README.md +0 -0
  16. {witan_code-0.17.0 → witan_code-0.18.0}/config/claude.json +0 -0
  17. {witan_code-0.17.0 → witan_code-0.18.0}/config/copilot.json +0 -0
  18. {witan_code-0.17.0 → witan_code-0.18.0}/config/pi.json +0 -0
  19. {witan_code-0.17.0 → witan_code-0.18.0}/conftest.py +0 -0
  20. {witan_code-0.17.0 → witan_code-0.18.0}/docs/BRANCH_INDEXING.md +0 -0
  21. {witan_code-0.17.0 → witan_code-0.18.0}/docs/CLI_REFERENCE.md +0 -0
  22. {witan_code-0.17.0 → witan_code-0.18.0}/docs/EDGE_PRECISION_TIERS.md +0 -0
  23. {witan_code-0.17.0 → witan_code-0.18.0}/docs/PACKAGE_MAP.md +0 -0
  24. {witan_code-0.17.0 → witan_code-0.18.0}/docs/STAGE2_STITCHING.md +0 -0
  25. {witan_code-0.17.0 → witan_code-0.18.0}/docs/SYMBOL_FORMAT.md +0 -0
  26. {witan_code-0.17.0 → witan_code-0.18.0}/docs/SYMBOL_TABLE.md +0 -0
  27. {witan_code-0.17.0 → witan_code-0.18.0}/docs/USER_GUIDE.md +0 -0
  28. {witan_code-0.17.0 → witan_code-0.18.0}/install.sh +0 -0
  29. {witan_code-0.17.0 → witan_code-0.18.0}/tests/__init__.py +0 -0
  30. {witan_code-0.17.0 → witan_code-0.18.0}/tests/conftest.py +0 -0
  31. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_batched_deletes.py +0 -0
  32. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_branches.py +0 -0
  33. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_bridge.py +0 -0
  34. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_chunked_load.py +0 -0
  35. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_cli_dispatch.py +0 -0
  36. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_cli_hooks.py +0 -0
  37. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_cli_output_format.py +0 -0
  38. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_config.py +0 -0
  39. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_context.py +0 -0
  40. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_crossrepo.py +0 -0
  41. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_edges.py +0 -0
  42. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_elicit.py +0 -0
  43. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_enrichment.py +0 -0
  44. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_github_app.py +0 -0
  45. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_graph.py +0 -0
  46. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_hooks.py +0 -0
  47. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_identity.py +0 -0
  48. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_index_failure_diagnostics.py +0 -0
  49. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_indexer_scope.py +0 -0
  50. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_ingest.py +0 -0
  51. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_languages.py +0 -0
  52. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_maintenance.py +0 -0
  53. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_package_map.py +0 -0
  54. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_reaper.py +0 -0
  55. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_remote_proxy.py +0 -0
  56. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_repo.py +0 -0
  57. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_server.py +0 -0
  58. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_setup.py +0 -0
  59. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_stitch.py +0 -0
  60. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_store.py +0 -0
  61. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_symbol_table.py +0 -0
  62. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_tasks.py +0 -0
  63. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_tool_schema.py +0 -0
  64. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_views.py +0 -0
  65. {witan_code-0.17.0 → witan_code-0.18.0}/tests/test_visualize.py +0 -0
  66. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/__init__.py +0 -0
  67. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/__main__.py +0 -0
  68. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/bridge.py +0 -0
  69. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/bridge_extractors.py +0 -0
  70. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/config.py +0 -0
  71. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/context.py +0 -0
  72. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/edges.py +0 -0
  73. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/elicit.py +0 -0
  74. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/extensions/pi/codegraph.ts +0 -0
  75. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/github_app.py +0 -0
  76. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/graph.py +0 -0
  77. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/hooks.py +0 -0
  78. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/identity.py +0 -0
  79. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/ingest.py +0 -0
  80. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/maintenance.py +0 -0
  81. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/output.py +0 -0
  82. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/package_map.py +0 -0
  83. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/queries/bridge.gq +0 -0
  84. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/queries/code_mutations.gq +0 -0
  85. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/queries/code_read.gq +0 -0
  86. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/queries/delete.gq +0 -0
  87. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/queries_ts/bash.scm +0 -0
  88. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/queries_ts/hcl.scm +0 -0
  89. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/queries_ts/python.scm +0 -0
  90. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/queries_ts/sql.scm +0 -0
  91. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/queries_ts/typescript.scm +0 -0
  92. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/queries_ts/yaml.scm +0 -0
  93. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/reaper.py +0 -0
  94. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/remote/__init__.py +0 -0
  95. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/remote/oidc.py +0 -0
  96. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/remote/proxy.py +0 -0
  97. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/remote/store.py +0 -0
  98. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/repo.py +0 -0
  99. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/schema/bridge-schema.pg +0 -0
  100. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/schema/code-schema.pg +0 -0
  101. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/setup.py +0 -0
  102. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/skills/witan-code/SKILL.md +0 -0
  103. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/stitch.py +0 -0
  104. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/views.py +0 -0
  105. {witan_code-0.17.0 → witan_code-0.18.0}/witan_code/visualize.py +0 -0
@@ -6,6 +6,52 @@ 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
+ ## [0.18.0] - 2026-09-02
10
+
11
+ ### Added
12
+
13
+ - **`witan --target <name> code index` reaches this package.** witan-council's
14
+ launcher gained an app-level `--target`, and `witan code …` mounts this
15
+ package's cyclopts App but *not* its meta launcher, so the value is forwarded
16
+ into `witan_code.selected_target` and read by `_srv()`. Before this the flag
17
+ was bound but never routed: `cli._srv()` called `load_remote_config()` with no
18
+ argument, so `witan --target ci code repos` answered from the ambient target
19
+ while appearing to work. Standalone `witan-code --target <name> …` works too.
20
+
21
+ - **`witan code index` prints the store it wrote to, unconditionally.** The one
22
+ command whose entire output is about where symbols went was silent about which
23
+ graph received them — and memory and code graphs are routed by separate
24
+ settings, so a target whose memory is deployed can still index onto the
25
+ laptop. Printed for local and shared alike: a line that appears only in the
26
+ bad case is one nobody has learned to look for. Carried on `IndexStats` rather
27
+ than recomputed by the printer, so a *failed* run reports the same
28
+ destination — the run where "which graph was this?" is actually open.
29
+
30
+ ### Fixed
31
+
32
+ - **`witan-code doctor` could not say which graph was broken under
33
+ `code_transport = "mcp"`.** The identifying column took `os.path.basename` of
34
+ the store address, which is right for a local path and wrong for an
35
+ MCP-routed one — those end in the endpoint URL, so all of production's
36
+ per-repo cluster graphs rendered as the identical string `mcp)`. `StoreRef`
37
+ gained a `label` that answers "which graph is this row" for all three address
38
+ forms, and `code_store_health` returns it alongside the full `store`.
39
+
40
+ - **A failed index did not always name its destination.** `IndexStats` was built
41
+ *after* `ensure_branch()` and the existing-hash read, both of which are remote
42
+ calls, so an unreachable endpoint or a rejected token raised before the stats
43
+ existed and the summary had nothing to print. Stats are now built straight
44
+ after the store resolves, and those two setup calls are wrapped so a failure
45
+ arrives as `IndexFailed` carrying the destination. `check_writable` is
46
+ deliberately left outside: it raises `SharedGraphWriteRefused`, a clean
47
+ self-explanatory refusal that callers assert on by type, not the unattributed
48
+ failure the phases exist to annotate.
49
+
50
+ - **`code_transport` is parsed off a target block.** A target registered with
51
+ `--remote-url` now carries `code_transport = "mcp"`, so branches indexed from
52
+ a checkout land on the cluster where another session — and another developer —
53
+ can see them, instead of in a directory on one laptop.
54
+
9
55
  ## [0.17.0] - 2026-09-01
10
56
 
11
57
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: witan-code
3
- Version: 0.17.0
3
+ Version: 0.18.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "witan-code"
3
- version = "0.17.0"
3
+ version = "0.18.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"
@@ -204,7 +204,7 @@ testpaths = ["tests"]
204
204
  packages = ["witan_code"]
205
205
 
206
206
  [tool.bumpversion]
207
- current_version = "0.17.0"
207
+ current_version = "0.18.0"
208
208
  allow_dirty = true
209
209
 
210
210
  [[tool.bumpversion.files]]
@@ -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
@@ -0,0 +1,122 @@
1
+ """`witan code index` has to say which graph it wrote.
2
+
3
+ Memory and code graphs are routed by SEPARATE settings, so a target whose
4
+ memory is deployed can still index onto the laptop — three non-maintainers were
5
+ in exactly that state on production. The one command whose entire output is
6
+ about where symbols went was silent about which store received them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from witan_code import cli as cli_module
16
+ from witan_code import indexer
17
+
18
+
19
+ def test_a_successful_index_names_the_store(monkeypatch, capsys):
20
+ stats = indexer.IndexStats(
21
+ scanned=12,
22
+ indexed=12,
23
+ symbols=88,
24
+ store="/home/dev/.local/share/witan-code/x.omni",
25
+ )
26
+ monkeypatch.setattr(indexer, "index_path", lambda path, *, force=False, **k: stats)
27
+
28
+ cli_module.index(Path("."))
29
+
30
+ assert "/home/dev/.local/share/witan-code/x.omni" in capsys.readouterr().out
31
+
32
+
33
+ def test_a_shared_store_is_reported_the_same_way(monkeypatch, capsys):
34
+ """Printed for local and shared alike. A line that appears only in the bad
35
+ case is a line nobody has learned to look for — and reading it is how "where
36
+ did this go" gets answered without knowing the setting exists."""
37
+ stats = indexer.IndexStats(
38
+ scanned=12, store="github.com/mitodl/agent-kit (via https://witan.example/mcp)"
39
+ )
40
+ monkeypatch.setattr(indexer, "index_path", lambda path, *, force=False, **k: stats)
41
+
42
+ cli_module.index(Path("."))
43
+
44
+ assert "https://witan.example/mcp" in capsys.readouterr().out
45
+
46
+
47
+ def test_the_branch_view_is_named_when_there_is_one(monkeypatch, capsys):
48
+ """On a shared graph the view is per writer per git branch, so the store
49
+ alone does not identify what a reader has to look at."""
50
+ stats = indexer.IndexStats(store="s", branch="act-0eb-feature-x")
51
+ monkeypatch.setattr(indexer, "index_path", lambda path, *, force=False, **k: stats)
52
+
53
+ cli_module.index(Path("."))
54
+
55
+ assert "branch=act-0eb-feature-x" in capsys.readouterr().out
56
+
57
+
58
+ def test_the_default_branch_prints_no_branch_field(monkeypatch, capsys):
59
+ stats = indexer.IndexStats(store="s")
60
+ monkeypatch.setattr(indexer, "index_path", lambda path, *, force=False, **k: stats)
61
+
62
+ cli_module.index(Path("."))
63
+
64
+ assert "branch=" not in capsys.readouterr().out
65
+
66
+
67
+ def test_a_failed_index_still_names_the_store(monkeypatch, capsys):
68
+ """The failing run is the one where "which graph was this?" is the open
69
+ question, so the destination is carried on the stats rather than recomputed
70
+ by the printer."""
71
+ stats = indexer.IndexStats(scanned=657, store="https://witan.example/mcp")
72
+
73
+ def failing_index(path, *, force=False, **kwargs):
74
+ raise indexer.IndexFailed(
75
+ "load of nodes and edges",
76
+ RuntimeError("timed out"),
77
+ stats=stats,
78
+ elapsed=181.0,
79
+ detail={"records": 9000},
80
+ )
81
+
82
+ monkeypatch.setattr(indexer, "index_path", failing_index)
83
+
84
+ with pytest.raises(indexer.IndexFailed):
85
+ cli_module.index(Path("."))
86
+
87
+ assert "https://witan.example/mcp" in capsys.readouterr().out
88
+
89
+
90
+ def test_a_setup_failure_names_the_store_it_was_reaching(tmp_path, monkeypatch):
91
+ """Regression, PR #316 review: the guarantee has to cover setup, not just
92
+ the load.
93
+
94
+ `ensure_branch` and the existing-hash read are REMOTE calls, and `IndexStats`
95
+ used to be built after both. So an unreachable endpoint or a rejected token
96
+ raised before the stats object existed, `_index` had nothing to print, and
97
+ the run that most needed a destination reported none — the exact gap the
98
+ "a failed run still identifies its graph" guarantee was added to close.
99
+ """
100
+ import subprocess
101
+
102
+ from witan_code import config as cfg_module
103
+ from witan_code.graph import OmnigraphClient
104
+
105
+ monkeypatch.setenv("WITAN_REPO", "https://github.com/test/cg")
106
+ monkeypatch.setenv("WITAN_CODE_DIR", str(tmp_path / "code"))
107
+ src = tmp_path / "repo"
108
+ src.mkdir()
109
+ subprocess.run(["git", "init", "-q", "-b", "main"], cwd=src, check=True)
110
+ (src / "a.py").write_text("def a():\n return 1\n")
111
+
112
+ def _unreachable(self, *a, **k):
113
+ raise RuntimeError("connection refused")
114
+
115
+ monkeypatch.setattr(OmnigraphClient, "ensure_branch", _unreachable)
116
+
117
+ with pytest.raises(indexer.IndexFailed) as excinfo:
118
+ indexer.index_path(src, config=cfg_module.load())
119
+
120
+ assert excinfo.value.phase == "branch setup"
121
+ assert excinfo.value.stats.store # the destination, not an empty string
122
+ assert "cg" in excinfo.value.stats.store
@@ -21,6 +21,9 @@ def test_index_extracts_symbols_and_edges(sample_repo):
21
21
 
22
22
  slug = repo_mod.detect()
23
23
  store = store_mod.store_for_repo(slug, cfg)
24
+ # The destination the summary reports comes off the run itself, so this is
25
+ # what stops that line drifting from where the rows actually landed.
26
+ assert stats.store == str(store)
24
27
  client = OmnigraphClient(str(store), cfg.queries_dir)
25
28
 
26
29
  runs = client.read("code_read.gq", "find_by_name", {"name": "run"})
@@ -184,6 +184,43 @@ def test_the_transport_decides_the_address(monkeypatch, tmp_path):
184
184
  assert bridge.via_mcp == cfg_module.BRIDGE_GRAPH_ID
185
185
 
186
186
 
187
+ def test_label_names_the_graph_a_row_is(monkeypatch, tmp_path):
188
+ """Every MCP-routed address ends in the same endpoint.
189
+
190
+ Taking a basename of the full address — what `witan-code doctor` did —
191
+ therefore rendered all 14 of production's cluster graphs as `mcp)`, so the
192
+ table could not say which one was broken in exactly the configuration a
193
+ deployed target uses.
194
+ """
195
+ monkeypatch.setenv("WITAN_CODE_DIR", str(tmp_path / "code"))
196
+ monkeypatch.setenv("WITAN_CODE_TRANSPORT", cfg_module.CODE_TRANSPORT_MCP)
197
+ monkeypatch.setenv("WITAN_REMOTE_URL", "https://witan.example.org/mcp")
198
+ monkeypatch.setenv("WITAN_OIDC_ISSUER", "https://sso.example.org/realms/ol")
199
+
200
+ ref = store_module.store_for_repo(REPO, cfg_module.load())
201
+ assert ref.label == "test/cg"
202
+ assert str(ref).endswith("(via https://witan.example.org/mcp)")
203
+ assert store_module.bridge_store(cfg_module.load()).label == (
204
+ cfg_module.BRIDGE_GRAPH_ID
205
+ )
206
+
207
+
208
+ def test_label_falls_back_to_the_store_name_off_the_mcp_path(monkeypatch, tmp_path):
209
+ """A local store and a directly-addressed cluster graph keep their own names."""
210
+ monkeypatch.setenv("WITAN_CODE_DIR", str(tmp_path / "code"))
211
+ monkeypatch.delenv("WITAN_CODE_TRANSPORT", raising=False)
212
+ monkeypatch.delenv("WITAN_CODE_SERVER", raising=False)
213
+ monkeypatch.delenv("WITAN_REMOTE_URL", raising=False)
214
+
215
+ local = store_module.store_for_repo(REPO, cfg_module.load())
216
+ assert local.label.endswith(".omni")
217
+ assert "/" not in local.label
218
+
219
+ monkeypatch.setenv("WITAN_CODE_SERVER", "http://omnigraph:8080")
220
+ direct = store_module.store_for_repo(REPO, cfg_module.load())
221
+ assert direct.label == direct.graph_id
222
+
223
+
187
224
  def test_the_endpoint_comes_from_the_config_that_asked_for_it(monkeypatch, tmp_path):
188
225
  """A `Config` from an explicit `load(target=…)` must resolve *that*
189
226
  target's endpoint. Re-running target selection would fall back to
@@ -15,7 +15,6 @@ unconditionally.
15
15
 
16
16
  import asyncio
17
17
  import inspect
18
- import os
19
18
  import sys
20
19
  from pathlib import Path
21
20
  from typing import Annotated, Literal
@@ -50,16 +49,23 @@ def _srv():
50
49
  ``RemoteServerProxy`` when ``WITAN_REMOTE_URL`` (or a matched target's
51
50
  ``remote_url``) is set. The proxy mirrors the server module's tool surface,
52
51
  so every call site is identical either way.
52
+
53
+ Under ``witan code …`` the target comes from witan's launcher, which mounts
54
+ this App without this package's launcher and forwards what it bound into
55
+ :mod:`witan_code.selected_target`. ``witan code index`` writing a code graph
56
+ to whichever deployment the checkout happened to match, while the flag said
57
+ otherwise, is the failure this reaches back to prevent.
53
58
  """
54
59
  global _server
55
60
  if _server is None:
56
61
  from . import config as cfg_module
62
+ from .selected_target import selected_target
57
63
 
58
64
  # A misconfigured remote (e.g. WITAN_REMOTE_URL without
59
65
  # WITAN_OIDC_ISSUER) raises ValueError here; surface it as a clean CLI
60
66
  # error rather than letting a traceback escape every read command.
61
67
  try:
62
- remote = cfg_module.load_remote_config()
68
+ remote = cfg_module.load_remote_config(selected_target())
63
69
  except ValueError as exc:
64
70
  print(exc)
65
71
  raise SystemExit(1) from None
@@ -272,12 +278,15 @@ def doctor() -> None:
272
278
  report = _fn(server_module.code_store_health)()
273
279
  rows = [
274
280
  {
275
- # Basename only: a local store path is `<code_dir>/<sanitized
276
- # repo>.omni` and the shared prefix is the same on every row, so
277
- # printing it in full wraps every store onto three lines and buries
278
- # the status column this table exists for. A cluster graph's `store`
279
- # is not a path and has no basename to take.
280
- "store": os.path.basename(s["store"]),
281
+ # The short form, not the address: a local store path is
282
+ # `<code_dir>/<sanitized repo>.omni` and a cluster graph's address
283
+ # ends in the endpoint, so in both cases the shared part is
284
+ # identical on every row — printed in full it wraps each store onto
285
+ # three lines and buries the status column this table exists for.
286
+ # Computed by StoreRef.label rather than here, because the three
287
+ # address forms are that type's business and taking a basename of
288
+ # the MCP one silently collapsed all 14 rows to `mcp)`.
289
+ "store": s["label"],
281
290
  "kind": s["kind"],
282
291
  "status": "ok"
283
292
  if s["ok"]
@@ -927,6 +936,17 @@ def setup(
927
936
 
928
937
 
929
938
  def _print_summary(action: str, path: Path, stats: indexer.IndexStats) -> None:
939
+ # ★ WHERE THE SYMBOLS WENT, UNCONDITIONALLY. Memory and code graphs are
940
+ # routed by separate settings, so a target whose memory is deployed can
941
+ # still index onto this laptop — and this command, whose entire output is
942
+ # about where symbols went, used to be silent about which graph it wrote.
943
+ # Printed for local and shared alike: a line that appears only in the bad
944
+ # case is a line nobody has learned to look for.
945
+ if stats.store:
946
+ print(
947
+ f"store: {stats.store}"
948
+ + (f" branch={stats.branch}" if stats.branch else "")
949
+ )
930
950
  print(
931
951
  f"{action} {path}: "
932
952
  f"scanned={stats.scanned} indexed={stats.indexed} "
@@ -1078,9 +1098,10 @@ def repos() -> None:
1078
1098
 
1079
1099
  def _remote_or_exit():
1080
1100
  from . import config as cfg_module
1101
+ from .selected_target import selected_target
1081
1102
 
1082
1103
  try:
1083
- remote = cfg_module.load_remote_config()
1104
+ remote = cfg_module.load_remote_config(selected_target())
1084
1105
  except ValueError as exc:
1085
1106
  print(exc)
1086
1107
  raise SystemExit(1) from None
@@ -303,6 +303,20 @@ class IndexStats:
303
303
  directory). Full-repo runs only; reported so a mass cleanup is visible
304
304
  rather than silent."""
305
305
 
306
+ store: str = ""
307
+ """Where the run wrote — a local directory, or the shared graph's address.
308
+
309
+ ``witan code index`` is the command whose whole output is about where
310
+ symbols went, and it happily wrote to a laptop under a deployed target
311
+ without comment. Carried on the stats rather than recomputed by the
312
+ printer so a FAILED run reports the same destination a successful one
313
+ does — that is precisely the run where "which graph was this?" is the
314
+ open question.
315
+ """
316
+
317
+ branch: str = ""
318
+ """The branch view written, or empty on the repo's default branch."""
319
+
306
320
 
307
321
  class IndexFailed(RuntimeError):
308
322
  """A write phase of :func:`index_path` failed, carrying what it knows.
@@ -397,22 +411,36 @@ def index_path(
397
411
  branch = views.repo_view(git_branch, actor=actor) if git_branch else None
398
412
 
399
413
  store = ensure_store(slug, cfg)
414
+ # Built HERE, before the first remote call, not after the setup reads
415
+ # below. The guarantee is that a failed run still says which graph it was
416
+ # writing to, and the setup calls are remote — an unreachable endpoint or a
417
+ # rejected token raised before `stats` existed, so `_index` had nothing to
418
+ # print and the run that most needed a destination reported none.
419
+ stats = IndexStats(store=str(store), branch=branch or "")
420
+
400
421
  client = store.client(cfg, branch=branch)
422
+ # Deliberately OUTSIDE the phases below. This raises
423
+ # SharedGraphWriteRefused, which is a clean, self-explanatory refusal with
424
+ # its own message — not the unattributed failure the phases exist to
425
+ # annotate — and callers assert on that type directly
426
+ # (tests/test_indexer_scope.py). Wrapping it would rename the exception for
427
+ # no diagnostic gain.
401
428
  check_writable(
402
429
  is_remote=client.is_remote, branch=branch, cfg=cfg, slug=slug, actor=actor
403
430
  )
404
431
  # The hash read below never forks; create the branch before reading.
405
- client.ensure_branch()
432
+ with _write_phase("branch setup", stats, branch=branch or ""):
433
+ client.ensure_branch()
406
434
 
407
435
  # One query for all existing file hashes → the incremental skip check is
408
436
  # in-memory, not a query per file. Read even under `force` (where the
409
437
  # hashes go unused): a full-repo run also needs the stored file set to
410
438
  # find rows for files that are no longer part of the repo.
411
439
  existing: dict[str, str] = {}
412
- for row in client.read("code_read.gq", "all_file_hashes", {}):
413
- existing[row["slug"]] = row.get("content_hash")
440
+ with _write_phase("existing-hash read", stats):
441
+ for row in client.read("code_read.gq", "all_file_hashes", {}):
442
+ existing[row["slug"]] = row.get("content_hash")
414
443
 
415
- stats = IndexStats()
416
444
  records: list[dict] = []
417
445
  reindexed_file_ids: list[str] = []
418
446
  bindings: list[ParsedBinding] = []
@@ -0,0 +1,28 @@
1
+ """The ``--target`` in effect for this process.
2
+
3
+ The twin of :mod:`witan.cli.selected_target`, and duplicated for the same
4
+ reason :mod:`witan_code.output` is: ``witan code …`` mounts this package's
5
+ cyclopts App but **not** its meta launcher, so witan's launcher is the one that
6
+ runs and it forwards what it bound into here. Two modules rather than one
7
+ because witan depends on witan-code, not the other way round.
8
+
9
+ Left ``None`` when this package's own binary is driven directly
10
+ (``witan-code index``): that launcher declares no ``--target``, so resolution
11
+ falls through to ``WITAN_TARGET`` and the checkout's ``match_*`` rules exactly
12
+ as it did before. Adding the option there is a separate change.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ _selected: str | None = None
18
+
19
+
20
+ def set_selected_target(name: str | None) -> None:
21
+ """Record the ``--target`` the calling launcher bound, if any."""
22
+ global _selected
23
+ _selected = name
24
+
25
+
26
+ def selected_target() -> str | None:
27
+ """The ``--target`` given on this command line, else ``None``."""
28
+ return _selected
@@ -1059,8 +1059,11 @@ def code_store_health() -> dict:
1059
1059
  fail while every per-repo listing still looks healthy — which is how it
1060
1060
  stayed broken for six weeks.
1061
1061
 
1062
- Returns ``{"stores": [{store, kind, ok, files, error, stale_schema}],
1063
- "ok": bool, "stale_schema": [store, ...]}``. ``stale_schema`` names the
1062
+ Returns ``{"stores": [{store, label, kind, ok, files, error,
1063
+ stale_schema}], "ok": bool, "stale_schema": [store, ...]}``. ``store`` is
1064
+ the full address; ``label`` is the short form that identifies WHICH graph a
1065
+ row is, which for a cluster graph the address does not (it ends in the
1066
+ endpoint, identical on every row). ``stale_schema`` names the
1064
1067
  stores written by an omnigraph whose on-disk format the installed binary
1065
1068
  no longer reads — the one failure with a known remedy, since a code graph
1066
1069
  is derived from its checkout and is rebuilt by reindexing
@@ -1071,6 +1074,10 @@ def code_store_health() -> dict:
1071
1074
  "stores": [
1072
1075
  {
1073
1076
  "store": str(h.ref),
1077
+ # The full `store` address is the same endpoint on every row
1078
+ # under a deployed target, so it cannot be used as the column
1079
+ # that tells the rows apart. See StoreRef.label.
1080
+ "label": h.ref.label,
1074
1081
  "kind": "bridge" if h.is_bridge else "repo",
1075
1082
  "ok": h.ok,
1076
1083
  "files": h.files,
@@ -41,6 +41,7 @@ from witan_core.omnigraph import (
41
41
 
42
42
  from . import config as cfg_module
43
43
  from .graph import OmnigraphClient, is_stale_schema
44
+ from .visualize import short_repo
44
45
 
45
46
  # Graph registration changes only when provisioning does (a Pulumi deploy), but
46
47
  # `_client_for_repo` asks "does this store exist" on every MCP tool call. One
@@ -143,6 +144,27 @@ class StoreRef:
143
144
  return f"{self.uri} (graph {self.graph_id})"
144
145
  return self.uri
145
146
 
147
+ @property
148
+ def label(self) -> str:
149
+ """Short identifier for a table column — which graph, not where it is.
150
+
151
+ :meth:`__str__` is the full address and is what a refusal should print.
152
+ This is the version that fits in a column: the part that DIFFERS
153
+ between rows, with the endpoint or directory prefix that is identical
154
+ on every row dropped.
155
+
156
+ Taking ``os.path.basename`` of the full address instead is right only
157
+ for a local store. An MCP-routed address ends in the endpoint URL, so
158
+ every cluster graph in a listing came out as the same ``mcp)`` — which
159
+ made ``witan-code doctor`` unable to name which graph was broken in
160
+ exactly the configuration a deployed target now uses.
161
+ """
162
+ if self.via_mcp:
163
+ return short_repo(self.via_mcp)
164
+ if self.is_remote:
165
+ return self.graph_id or self.uri
166
+ return Path(self.uri).name
167
+
146
168
  def client(
147
169
  self,
148
170
  config: cfg_module.Config | None = None,
File without changes
File without changes
File without changes
File without changes
File without changes