witan-code 0.13.5__tar.gz → 0.14.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 (101) hide show
  1. {witan_code-0.13.5 → witan_code-0.14.0}/CHANGELOG.md +39 -0
  2. {witan_code-0.13.5 → witan_code-0.14.0}/PKG-INFO +2 -2
  3. {witan_code-0.13.5 → witan_code-0.14.0}/docs/USER_GUIDE.md +7 -0
  4. {witan_code-0.13.5 → witan_code-0.14.0}/pyproject.toml +36 -7
  5. witan_code-0.14.0/tests/test_index_failure_diagnostics.py +149 -0
  6. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/cli.py +20 -4
  7. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/indexer.py +72 -4
  8. {witan_code-0.13.5 → witan_code-0.14.0}/.gitignore +0 -0
  9. {witan_code-0.13.5 → witan_code-0.14.0}/README.md +0 -0
  10. {witan_code-0.13.5 → witan_code-0.14.0}/config/claude.json +0 -0
  11. {witan_code-0.13.5 → witan_code-0.14.0}/config/copilot.json +0 -0
  12. {witan_code-0.13.5 → witan_code-0.14.0}/config/pi.json +0 -0
  13. {witan_code-0.13.5 → witan_code-0.14.0}/docs/BRANCH_INDEXING.md +0 -0
  14. {witan_code-0.13.5 → witan_code-0.14.0}/docs/CLI_REFERENCE.md +0 -0
  15. {witan_code-0.13.5 → witan_code-0.14.0}/docs/EDGE_PRECISION_TIERS.md +0 -0
  16. {witan_code-0.13.5 → witan_code-0.14.0}/docs/PACKAGE_MAP.md +0 -0
  17. {witan_code-0.13.5 → witan_code-0.14.0}/docs/STAGE2_STITCHING.md +0 -0
  18. {witan_code-0.13.5 → witan_code-0.14.0}/docs/SYMBOL_FORMAT.md +0 -0
  19. {witan_code-0.13.5 → witan_code-0.14.0}/docs/SYMBOL_TABLE.md +0 -0
  20. {witan_code-0.13.5 → witan_code-0.14.0}/install.sh +0 -0
  21. {witan_code-0.13.5 → witan_code-0.14.0}/tests/__init__.py +0 -0
  22. {witan_code-0.13.5 → witan_code-0.14.0}/tests/conftest.py +0 -0
  23. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_batched_deletes.py +0 -0
  24. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_branches.py +0 -0
  25. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_bridge.py +0 -0
  26. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_chunked_load.py +0 -0
  27. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_cli_dispatch.py +0 -0
  28. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_cli_hooks.py +0 -0
  29. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_cli_output_format.py +0 -0
  30. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_config.py +0 -0
  31. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_config_targets.py +0 -0
  32. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_context.py +0 -0
  33. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_crossrepo.py +0 -0
  34. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_edges.py +0 -0
  35. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_elicit.py +0 -0
  36. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_enrichment.py +0 -0
  37. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_github_app.py +0 -0
  38. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_graph.py +0 -0
  39. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_hooks.py +0 -0
  40. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_identity.py +0 -0
  41. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_indexer.py +0 -0
  42. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_indexer_scope.py +0 -0
  43. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_ingest.py +0 -0
  44. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_languages.py +0 -0
  45. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_maintenance.py +0 -0
  46. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_package_map.py +0 -0
  47. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_reaper.py +0 -0
  48. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_remote_proxy.py +0 -0
  49. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_remote_store.py +0 -0
  50. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_repo.py +0 -0
  51. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_server.py +0 -0
  52. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_setup.py +0 -0
  53. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_stitch.py +0 -0
  54. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_store.py +0 -0
  55. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_symbol_table.py +0 -0
  56. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_tasks.py +0 -0
  57. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_tool_schema.py +0 -0
  58. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_views.py +0 -0
  59. {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_visualize.py +0 -0
  60. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/__init__.py +0 -0
  61. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/__main__.py +0 -0
  62. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/bridge.py +0 -0
  63. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/bridge_extractors.py +0 -0
  64. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/config.py +0 -0
  65. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/context.py +0 -0
  66. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/edges.py +0 -0
  67. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/elicit.py +0 -0
  68. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/extensions/pi/codegraph.ts +0 -0
  69. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/github_app.py +0 -0
  70. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/graph.py +0 -0
  71. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/hooks.py +0 -0
  72. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/identity.py +0 -0
  73. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/ingest.py +0 -0
  74. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/maintenance.py +0 -0
  75. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/output.py +0 -0
  76. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/package_map.py +0 -0
  77. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries/bridge.gq +0 -0
  78. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries/code_mutations.gq +0 -0
  79. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries/code_read.gq +0 -0
  80. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries/delete.gq +0 -0
  81. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries_ts/bash.scm +0 -0
  82. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries_ts/hcl.scm +0 -0
  83. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries_ts/python.scm +0 -0
  84. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries_ts/sql.scm +0 -0
  85. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries_ts/typescript.scm +0 -0
  86. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries_ts/yaml.scm +0 -0
  87. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/reaper.py +0 -0
  88. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/remote/__init__.py +0 -0
  89. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/remote/oidc.py +0 -0
  90. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/remote/proxy.py +0 -0
  91. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/remote/store.py +0 -0
  92. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/repo.py +0 -0
  93. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/schema/bridge-schema.pg +0 -0
  94. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/schema/code-schema.pg +0 -0
  95. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/server.py +0 -0
  96. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/setup.py +0 -0
  97. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/skills/witan-code/SKILL.md +0 -0
  98. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/stitch.py +0 -0
  99. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/store.py +0 -0
  100. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/views.py +0 -0
  101. {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/visualize.py +0 -0
@@ -6,6 +6,45 @@ 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.14.0] - 2026-08-21
10
+
11
+ ### Fixed
12
+
13
+ - **A failed index now reports at least as much as a successful one.** On
14
+ success `witan code index` prints `scanned=… indexed=… skipped=… symbols=…`;
15
+ on failure the exception replaced that line entirely, so the run you most
16
+ need numbers from was the only one that produced none. Attributing the CI
17
+ indexer's `ol-infrastructure` timeout (0.13.3) therefore took five days and
18
+ needed a traceback line number, a source read to find the batch constant, row
19
+ counts from three graphs, an ECR check, and a live timing experiment —
20
+ nearly all of it answerable from numbers the process already had.
21
+
22
+ `index_path` now raises `IndexFailed`, carrying the partial `IndexStats`, the
23
+ write phase that died (the stale-row delete, or either of the two loads), how
24
+ long it ran, and the sizes it was working with — statements composed, chunk
25
+ size in use, records loaded. The CLI prints those stats under `partial …` and
26
+ re-raises, so the exit code and traceback are unchanged. The session hooks
27
+ and the store-missing fallbacks already catch broadly and are unaffected;
28
+ `code_reindex` still propagates, and its caller now gets a message carrying
29
+ the phase and the sizes rather than a bare transport error.
30
+
31
+ Requires `witan-core>=0.31` for the transport and batching halves of the same
32
+ change: a timeout that names its budget, and a chunk failure that names its
33
+ position. (0.31, not 0.30 — agent-kit#280 took 0.30.0 for unrelated work while
34
+ this was open, so the release carrying those halves is 0.31.0.)
35
+
36
+ ### Changed
37
+
38
+ - **The CI sweep's last line names the failed repos, not just the count.**
39
+ `indexed 13, failed 1` is the first thing an operator reads and did not say
40
+ which of fourteen repos was a run staler than it should be, so answering that
41
+ meant scrolling back through fourteen repos' output.
42
+
43
+ The exit status stays non-zero for a partial sweep, and the reasoning is now
44
+ recorded next to it: a Job that reports Complete with one repo stale is a Job
45
+ nothing can alert on, and a stale index has no other symptom — which is why
46
+ three consecutive failures from 2026-08-07 went unnoticed for two days.
47
+
9
48
  ## [0.13.5] - 2026-08-21
10
49
 
11
50
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: witan-code
3
- Version: 0.13.5
3
+ Version: 0.14.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
@@ -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.28
29
+ Requires-Dist: witan-core[cli,observability,remote,sentry]<1,>=0.31
30
30
  Provides-Extra: tasks
31
31
  Requires-Dist: fastmcp-tasks<5,>=4.0.0b1; extra == 'tasks'
32
32
  Description-Content-Type: text/markdown
@@ -110,6 +110,13 @@ copy the matching snippet from `config/` into your agent's config — see
110
110
  errors=…`). Re-running `index` is incremental — unchanged files are
111
111
  skipped by content hash; use `reindex` to force a full rebuild.
112
112
 
113
+ A run that fails while writing prints the same counts under `partial …`,
114
+ followed by the phase that failed, how long it ran, and the batch sizes it
115
+ was working with. Those numbers are the diagnosis: they say whether the
116
+ write was large, how far it got, and which of the deletes or the two loads
117
+ died. The exit status is unchanged, so a sweep still counts the repo as
118
+ failed.
119
+
113
120
  3. **Ask "where is this defined"** — via the CLI or, more commonly, through
114
121
  an agent calling the MCP tool directly:
115
122
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "witan-code"
3
- version = "0.13.5"
3
+ version = "0.14.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"
@@ -18,10 +18,18 @@ dependencies = [
18
18
  # >=0.12: remote/store.py imports RemotePayloadTooLarge and
19
19
  # payload_too_large from witan_core.remote.proxy at module scope, and 0.11
20
20
  # exports neither. A looser floor lets an external install resolve a
21
- # witan-core the server cannot even import; that is what this pin is for,
22
- # and it has now been tripped THREE times. If you add a witan_core symbol in
23
- # the same change as its caller, bump this line in that change: the
24
- # workspace resolves witan-core by path, so nothing here can catch it.
21
+ # witan-core the server cannot even import; that is what this pin is for.
22
+ # If you add a witan_core symbol in the same change as its caller, bump this
23
+ # line in that change: the workspace resolves witan-core by path, so no test
24
+ # run in it can catch you.
25
+ #
26
+ # ★ `just check-core-floor` NOW CATCHES THAT, on ordinary PR CI
27
+ # (.github/workflows/core-floor.yml). It installs this package's wheel into
28
+ # a clean venv with witan-core pinned to exactly the floor below and imports
29
+ # every module — the failure mode itself, not a model of it. It escaped
30
+ # three times before that check existed (#198, #200, #201), each time with a
31
+ # green CI; the tally used to live in this comment, which was a comment
32
+ # doing a test's job.
25
33
  # (NOT raised for witan-core 0.19/0.20's trace-context work, deliberately.
26
34
  # Nothing here imports `trace_context_middleware`, and standalone
27
35
  # `witan-code serve` is stdio-only — the ASGI middleware is installed by
@@ -87,7 +95,28 @@ dependencies = [
87
95
  # Expect this floor to keep moving while omnigraph 0.10.0 stays on the
88
96
  # moving `edge` tag; see
89
97
  # packages/witan-core/witan_core/omnigraph_install.py.
90
- "witan-core[cli,remote,observability,sentry]>=0.28,<1",
98
+ #
99
+ # ★ >=0.31 IS THE THIRD NON-IMPORT FLOOR, and the narrowest: it buys
100
+ # message content, not correctness. Nothing new is imported and an older
101
+ # witan-core runs fine. But witan-code 0.14.0 is ENTIRELY a diagnostics
102
+ # release, and two of the four questions a failed index has to answer are
103
+ # answered on the witan-core side — which chunk of how many failed
104
+ # (`change_many`), and which timeout budget expired (`omnigraph_http`).
105
+ # Resolved below 0.31 the run still prints its partial summary and names
106
+ # the phase, and the inner error is still the bare "timed out" this
107
+ # release exists to replace. A package whose changelog promises a
108
+ # self-describing failure and delivers half of one is the quiet wrongness
109
+ # the two floors above were raised to avoid.
110
+ #
111
+ # ★ THE NUMBER MOVED 0.30 -> 0.31 ON REBASE, and that is the whole hazard
112
+ # this note exists to flag. agent-kit#280 took 0.30.0 for unrelated work
113
+ # while this branch was open, so the release carrying the diagnostics is
114
+ # 0.31.0 — and a floor left at >=0.30 would resolve #280's release instead
115
+ # and quietly deliver the half-message described above. `just
116
+ # check-core-floor` cannot catch it: nothing new is imported, so the
117
+ # clean-install import check passes at either number. Re-check this line by
118
+ # hand whenever this branch is rebased.
119
+ "witan-core[cli,remote,observability,sentry]>=0.31,<1",
91
120
  "fastmcp>=3.4.2,<5",
92
121
  "cyclopts>=4,<5",
93
122
  # [targets.<name>] override models (config.py) — also transitively pulled
@@ -171,7 +200,7 @@ testpaths = ["tests"]
171
200
  packages = ["witan_code"]
172
201
 
173
202
  [tool.bumpversion]
174
- current_version = "0.13.5"
203
+ current_version = "0.14.0"
175
204
  allow_dirty = true
176
205
 
177
206
  [[tool.bumpversion.files]]
@@ -0,0 +1,149 @@
1
+ """A failed index has to report at least as much as a successful one.
2
+
3
+ The run you most need numbers from was the only one that produced none: on
4
+ success `witan code index` prints `scanned=… indexed=… skipped=… symbols=…`,
5
+ and on failure the exception replaced that line entirely. Attributing the CI
6
+ indexer's `ol-infrastructure` timeout therefore needed a traceback line number,
7
+ a source read to find the batch constant, row counts from three graphs and a
8
+ live timing experiment — nearly all of which the numbers below answer directly.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ from witan_code import cli as cli_module
18
+ from witan_code import indexer
19
+
20
+
21
+ def _stats(**kwargs) -> indexer.IndexStats:
22
+ return indexer.IndexStats(**kwargs)
23
+
24
+
25
+ # ── the exception carries the sizes ────────────────────────────────
26
+
27
+
28
+ def test_a_write_failure_keeps_the_partial_stats():
29
+ stats = _stats(scanned=657, indexed=165, skipped=492, symbols=4102)
30
+
31
+ with pytest.raises(indexer.IndexFailed) as raised:
32
+ with indexer._write_phase("delete of stale rows", stats, statements=330):
33
+ raise RuntimeError("timed out")
34
+
35
+ assert raised.value.stats is stats
36
+ assert raised.value.stats.scanned == 657
37
+
38
+
39
+ def test_the_message_names_the_phase_and_what_it_was_working_with():
40
+ with pytest.raises(indexer.IndexFailed) as raised:
41
+ with indexer._write_phase(
42
+ "delete of stale rows", _stats(), statements=330, chunk_size=128
43
+ ):
44
+ raise RuntimeError("mutate failed: timed out")
45
+
46
+ message = str(raised.value)
47
+ assert message.startswith("delete of stale rows failed after ")
48
+ assert "statements=330" in message
49
+ assert "chunk_size=128" in message
50
+ assert "mutate failed: timed out" in message
51
+
52
+
53
+ def test_the_real_error_stays_reachable():
54
+ """The wrapper adds sizes; it must not cost anyone the original."""
55
+ cause = RuntimeError("timed out")
56
+
57
+ with pytest.raises(indexer.IndexFailed) as raised:
58
+ with indexer._write_phase("load of nodes and edges", _stats(), records=9000):
59
+ raise cause
60
+
61
+ assert raised.value.__cause__ is cause
62
+
63
+
64
+ def test_elapsed_is_measured_not_guessed(monkeypatch):
65
+ """Elapsed is what says whether a client budget was reached or something
66
+ died early — a server-side cutoff and a client timeout both arrive as a
67
+ failed write, and only the elapsed time separates them."""
68
+ clock = {"t": 10.0}
69
+ monkeypatch.setattr(indexer.time, "monotonic", lambda: clock["t"])
70
+
71
+ with pytest.raises(indexer.IndexFailed) as raised:
72
+ with indexer._write_phase("delete of stale rows", _stats()):
73
+ clock["t"] += 181.3
74
+ raise RuntimeError("timed out")
75
+
76
+ assert raised.value.elapsed == pytest.approx(181.3)
77
+ assert "failed after 181.3s" in str(raised.value)
78
+
79
+
80
+ def test_a_successful_phase_is_transparent():
81
+ with indexer._write_phase("load of nodes and edges", _stats(), records=1):
82
+ pass
83
+
84
+
85
+ # ── the CLI prints them ────────────────────────────────────────────
86
+
87
+
88
+ def test_the_cli_prints_the_partial_summary_before_re_raising(monkeypatch, capsys):
89
+ stats = _stats(scanned=657, indexed=165, skipped=492, symbols=4102, purged=3)
90
+
91
+ def failing_index(path, *, force=False, **kwargs):
92
+ raise indexer.IndexFailed(
93
+ "delete of stale rows",
94
+ RuntimeError("timed out after 181.0s"),
95
+ stats=stats,
96
+ elapsed=181.0,
97
+ detail={"statements": 330, "chunk_size": 128},
98
+ )
99
+
100
+ monkeypatch.setattr(indexer, "index_path", failing_index)
101
+
102
+ with pytest.raises(indexer.IndexFailed):
103
+ cli_module.index(Path("."))
104
+
105
+ captured = capsys.readouterr()
106
+ # every number the successful path would have printed
107
+ assert "scanned=657" in captured.out
108
+ assert "indexed=165" in captured.out
109
+ assert "symbols=4102" in captured.out
110
+ assert "purged=3" in captured.out
111
+ # plus what failed, on stderr where the sweep script already redirects
112
+ assert "failed in delete of stale rows" in captured.err
113
+ assert "statements=330" in captured.err
114
+
115
+
116
+ def test_the_failure_still_propagates(monkeypatch):
117
+ """Printing the summary must not turn a failed index into a successful one:
118
+ the sweep counts a repo as indexed by exit status alone."""
119
+
120
+ def failing_index(path, *, force=False, **kwargs):
121
+ raise indexer.IndexFailed(
122
+ "load of content hashes",
123
+ RuntimeError("boom"),
124
+ stats=_stats(),
125
+ elapsed=1.0,
126
+ detail={},
127
+ )
128
+
129
+ monkeypatch.setattr(indexer, "index_path", failing_index)
130
+
131
+ with pytest.raises(indexer.IndexFailed):
132
+ cli_module.reindex(Path("."))
133
+
134
+
135
+ def test_a_failure_that_is_not_a_write_phase_is_left_alone(monkeypatch, capsys):
136
+ """Only a write failure has partial stats worth printing. Anything else —
137
+ an unreadable config, a refused branch — has none, and inventing a summary
138
+ of zeros would read as "the repo is empty" rather than "it never started".
139
+ """
140
+
141
+ def failing_index(path, *, force=False, **kwargs):
142
+ raise RuntimeError("witan code: refusing to write to the shared view")
143
+
144
+ monkeypatch.setattr(indexer, "index_path", failing_index)
145
+
146
+ with pytest.raises(RuntimeError, match="refusing to write"):
147
+ cli_module.index(Path("."))
148
+
149
+ assert capsys.readouterr().out == ""
@@ -15,6 +15,7 @@ unconditionally.
15
15
 
16
16
  import asyncio
17
17
  import inspect
18
+ import sys
18
19
  from pathlib import Path
19
20
  from typing import Annotated, Literal
20
21
 
@@ -138,15 +139,30 @@ def _render_table(
138
139
  @app.command
139
140
  def index(path: Path = Path(".")) -> None:
140
141
  """Incrementally index PATH (file or directory). Unchanged files are skipped."""
141
- stats = indexer.index_path(path, force=False)
142
- _print_summary("index", path, stats)
142
+ _print_summary("index", path, _index(path, force=False))
143
143
 
144
144
 
145
145
  @app.command
146
146
  def reindex(path: Path = Path(".")) -> None:
147
147
  """Force re-index PATH, ignoring content hashes."""
148
- stats = indexer.index_path(path, force=True)
149
- _print_summary("reindex", path, stats)
148
+ _print_summary("reindex", path, _index(path, force=True))
149
+
150
+
151
+ def _index(path: Path, *, force: bool) -> indexer.IndexStats:
152
+ """``index_path``, but a failure still prints what the run got through.
153
+
154
+ The parse phase's counts are the same numbers a success prints, and they are
155
+ what says whether a write failure is a big-repo problem or not — so losing
156
+ them to the traceback is losing the diagnosis. Printed here rather than
157
+ swallowed: the exception still propagates, so the exit code and the
158
+ traceback are unchanged.
159
+ """
160
+ try:
161
+ return indexer.index_path(path, force=force)
162
+ except indexer.IndexFailed as exc:
163
+ _print_summary("partial", path, exc.stats)
164
+ print(f"failed in {exc.phase}: {exc}", file=sys.stderr)
165
+ raise
150
166
 
151
167
 
152
168
  @app.command
@@ -9,11 +9,13 @@ then imported modules, then any repo-wide match. It is intentionally syntactic
9
9
  and will miss dynamic dispatch and produce occasional false links.
10
10
  """
11
11
 
12
+ import contextlib
12
13
  import functools
13
14
  import hashlib
14
15
  import importlib
15
16
  import os
16
- from collections.abc import Callable
17
+ import time
18
+ from collections.abc import Callable, Iterator
17
19
  from dataclasses import dataclass, field
18
20
  from pathlib import Path
19
21
 
@@ -295,6 +297,62 @@ class IndexStats:
295
297
  rather than silent."""
296
298
 
297
299
 
300
+ class IndexFailed(RuntimeError):
301
+ """A write phase of :func:`index_path` failed, carrying what it knows.
302
+
303
+ ★ THE FAILING RUN IS THE ONE THAT REPORTED LEAST. A successful index prints
304
+ ``scanned=… indexed=… skipped=… symbols=…``; a failing one printed only the
305
+ exception, because the exception replaced the summary — so the run you most
306
+ need data from was the only one that gave you none. That is why the CI
307
+ indexer's ``ol-infrastructure`` timeout sat unexplained for five days
308
+ (tk-the-ci-indexer-s-failure-says-only-timed-out-no--50f61c): attributing it
309
+ needed a traceback line number, a source read to find the constant, row
310
+ counts from three graphs and a live timing experiment, almost all of which
311
+ the numbers below would have answered directly.
312
+
313
+ ``stats`` is the partial :class:`IndexStats` as of the failure, ``phase``
314
+ names the write that failed, and ``detail`` carries the sizes that phase was
315
+ working with. ``elapsed`` is the seconds that phase ran before dying, which
316
+ is what says whether a timeout was hit or something failed early.
317
+ """
318
+
319
+ def __init__(
320
+ self,
321
+ phase: str,
322
+ cause: BaseException,
323
+ *,
324
+ stats: IndexStats,
325
+ elapsed: float,
326
+ detail: dict[str, object],
327
+ ) -> None:
328
+ self.phase = phase
329
+ self.stats = stats
330
+ self.elapsed = elapsed
331
+ self.detail = detail
332
+ described = " ".join(f"{k}={v}" for k, v in detail.items())
333
+ super().__init__(
334
+ f"{phase} failed after {elapsed:.1f}s"
335
+ + (f" ({described})" if described else "")
336
+ + f": {cause}"
337
+ )
338
+
339
+
340
+ @contextlib.contextmanager
341
+ def _write_phase(phase: str, stats: IndexStats, **detail: object) -> Iterator[None]:
342
+ """Re-raise anything from a write as :class:`IndexFailed` with its sizes."""
343
+ started = time.monotonic()
344
+ try:
345
+ yield
346
+ except Exception as exc:
347
+ raise IndexFailed(
348
+ phase,
349
+ exc,
350
+ stats=stats,
351
+ elapsed=time.monotonic() - started,
352
+ detail=detail,
353
+ ) from exc
354
+
355
+
298
356
  # ── Public entry points ───────────────────────────────────────────
299
357
 
300
358
 
@@ -432,14 +490,24 @@ def index_path(
432
490
  delete_steps += _delete_file_steps(file_id)
433
491
  stats.purged += 1
434
492
 
435
- client.change_many(delete_steps, chunk_size=_DELETE_BATCH_SIZE)
493
+ with _write_phase(
494
+ "delete of stale rows",
495
+ stats,
496
+ statements=len(delete_steps),
497
+ chunk_size=_DELETE_BATCH_SIZE,
498
+ reindexed_files=len(reindexed_file_ids),
499
+ purged_files=stats.purged,
500
+ ):
501
+ client.change_many(delete_steps, chunk_size=_DELETE_BATCH_SIZE)
436
502
  # Two loads, not one: the second commits the content hashes the first held
437
503
  # back, so a load that dies part-way is re-done rather than skipped forever.
438
504
  # See `_defer_content_hashes`.
439
505
  deduped = _dedupe(records)
440
506
  real_hash_rows = _defer_content_hashes(deduped)
441
- client.load(deduped, mode="merge")
442
- client.load(real_hash_rows, mode="merge")
507
+ with _write_phase("load of nodes and edges", stats, records=len(deduped)):
508
+ client.load(deduped, mode="merge")
509
+ with _write_phase("load of content hashes", stats, records=len(real_hash_rows)):
510
+ client.load(real_hash_rows, mode="merge")
443
511
 
444
512
  # Cross-repo bridge — a SEPARATE phase after the per-repo store write, so the
445
513
  # two stores' write locks never nest. A full-repo index (target is the repo
File without changes
File without changes
File without changes
File without changes