witan-code 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- witan_code/__init__.py +0 -0
- witan_code/__main__.py +4 -0
- witan_code/_detach.py +27 -0
- witan_code/bridge.py +392 -0
- witan_code/bridge_extractors.py +642 -0
- witan_code/cli.py +722 -0
- witan_code/config.py +65 -0
- witan_code/context.py +98 -0
- witan_code/edges.py +171 -0
- witan_code/elicit.py +92 -0
- witan_code/extensions/pi/codegraph.ts +121 -0
- witan_code/graph.py +271 -0
- witan_code/hooks.py +116 -0
- witan_code/indexer.py +1042 -0
- witan_code/maintenance.py +130 -0
- witan_code/package_map.py +74 -0
- witan_code/queries/bridge.gq +193 -0
- witan_code/queries/code_mutations.gq +81 -0
- witan_code/queries/code_read.gq +154 -0
- witan_code/queries/delete.gq +16 -0
- witan_code/queries_ts/bash.scm +9 -0
- witan_code/queries_ts/hcl.scm +16 -0
- witan_code/queries_ts/python.scm +29 -0
- witan_code/queries_ts/sql.scm +20 -0
- witan_code/queries_ts/typescript.scm +51 -0
- witan_code/queries_ts/yaml.scm +5 -0
- witan_code/repo.py +282 -0
- witan_code/schema/bridge-schema.pg +82 -0
- witan_code/schema/code-schema.pg +51 -0
- witan_code/server.py +859 -0
- witan_code/setup.py +230 -0
- witan_code/skills/witan-code/SKILL.md +97 -0
- witan_code/stitch.py +177 -0
- witan_code/store.py +116 -0
- witan_code/visualize.py +347 -0
- witan_code-0.2.0.dist-info/METADATA +476 -0
- witan_code-0.2.0.dist-info/RECORD +39 -0
- witan_code-0.2.0.dist-info/WHEEL +4 -0
- witan_code-0.2.0.dist-info/entry_points.txt +2 -0
witan_code/setup.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Witan-code's registration bundle, plus the omnigraph binary installer.
|
|
2
|
+
|
|
3
|
+
Per-agent MCP/skill/hook installation itself lives in ``agent_config_kit``
|
|
4
|
+
(``apply``/``apply_all``) — this module only builds witan-code's own
|
|
5
|
+
``RegistrationBundle`` and keeps the omnigraph binary distribution logic.
|
|
6
|
+
|
|
7
|
+
The bundle-building shape is copied from witan/witan/setup.py so the two
|
|
8
|
+
Layer packages stay independent — no cross-package imports, matching
|
|
9
|
+
OmnigraphClient's own docstring in graph.py. ``_OMNIGRAPH_VERSION`` here is
|
|
10
|
+
kept in lockstep with witan's copy by the omnigraph-version customManager in
|
|
11
|
+
renovate.json — a single Renovate PR bumps both.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import platform
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import tarfile
|
|
20
|
+
import tempfile
|
|
21
|
+
import urllib.request
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from agent_config_kit import (
|
|
25
|
+
DeclarativeHook,
|
|
26
|
+
Hook,
|
|
27
|
+
HookEvent,
|
|
28
|
+
PluginRegistration,
|
|
29
|
+
RegistrationBundle,
|
|
30
|
+
SkillSource,
|
|
31
|
+
StdioServer,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_WITAN_CODE_ARGS = [
|
|
35
|
+
"--from",
|
|
36
|
+
"git+https://github.com/mitodl/agent-kit#subdirectory=mcp/servers/witan-code",
|
|
37
|
+
"witan-code",
|
|
38
|
+
"serve",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
_OMNIGRAPH_VERSION = "0.8.1"
|
|
42
|
+
_OMNIGRAPH_ASSETS: dict[tuple[str, str], str] = {
|
|
43
|
+
("linux", "x86_64"): "omnigraph-linux-x86_64.tar.gz",
|
|
44
|
+
("darwin", "arm64"): "omnigraph-macos-arm64.tar.gz",
|
|
45
|
+
}
|
|
46
|
+
_VERSION_RE = re.compile(r"\d+\.\d+\.\d+")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _installed_version(dest: Path) -> str | None:
|
|
50
|
+
"""Return ``dest``'s reported version, or ``None`` if absent/unreadable.
|
|
51
|
+
|
|
52
|
+
A hung, corrupted, or non-executable binary must degrade to "unknown
|
|
53
|
+
version" (triggering a re-download) rather than crash `setup` —
|
|
54
|
+
``subprocess.TimeoutExpired`` is a ``SubprocessError``, not an
|
|
55
|
+
``OSError``, so both need catching, and a non-zero exit means the
|
|
56
|
+
output isn't trustworthy version text even if something printed.
|
|
57
|
+
"""
|
|
58
|
+
if not dest.exists():
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
[str(dest), "--version"], capture_output=True, text=True, timeout=10
|
|
63
|
+
)
|
|
64
|
+
except (OSError, subprocess.SubprocessError):
|
|
65
|
+
return None
|
|
66
|
+
if result.returncode != 0:
|
|
67
|
+
return None
|
|
68
|
+
match = _VERSION_RE.search(result.stdout + result.stderr)
|
|
69
|
+
return match.group(0) if match else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def witan_code_bundle(
|
|
73
|
+
pkg_dir: Path, author: str, *, binary: str = "witan-code"
|
|
74
|
+
) -> RegistrationBundle:
|
|
75
|
+
"""Build witan-code's ``RegistrationBundle``: MCP server, skill, hooks.
|
|
76
|
+
|
|
77
|
+
Independent of ``witan``'s own bundle (``witan.setup.witan_bundle``) — a
|
|
78
|
+
witan-code-only install (no witan) still gets the skill, hooks, and Pi
|
|
79
|
+
extension via standalone ``witan-code setup``. When both packages are
|
|
80
|
+
installed together and witan-code is importable, ``witan setup`` also
|
|
81
|
+
folds this bundle in automatically (see ``witan.cli.setup_cmd``), so a
|
|
82
|
+
single ``witan setup`` covers both; running ``witan-code setup``
|
|
83
|
+
separately afterwards is harmless (each `apply()` call is an idempotent
|
|
84
|
+
read-merge-write) but not required in that case.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
binary: The command name hook entries invoke — ``"witan-code"`` for a
|
|
89
|
+
standalone install (this function's default), or ``"witan code"``
|
|
90
|
+
when ``witan.cli.setup_cmd`` folds this bundle into witan's own (the
|
|
91
|
+
hooks then only need `witan` — with witan-code bundled in via
|
|
92
|
+
``--with`` — on PATH, not a separately installed `witan-code`).
|
|
93
|
+
"""
|
|
94
|
+
skills_dir = pkg_dir / "skills"
|
|
95
|
+
skills = (
|
|
96
|
+
[
|
|
97
|
+
SkillSource(name=d.name, skill_md_path=d / "SKILL.md")
|
|
98
|
+
for d in sorted(skills_dir.iterdir())
|
|
99
|
+
if (d / "SKILL.md").exists()
|
|
100
|
+
]
|
|
101
|
+
if skills_dir.is_dir()
|
|
102
|
+
else []
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
pi_ext_dir = pkg_dir / "extensions" / "pi"
|
|
106
|
+
# Bare CLI commands, no wrapper script — portable everywhere `binary`
|
|
107
|
+
# installs (Windows included, where bash/setsid don't exist), matching
|
|
108
|
+
# witan's own `witan inject-context`/`session-checkpoint` hooks. The
|
|
109
|
+
# prompt-path timeouts mirror witan's: a hung git or store read must
|
|
110
|
+
# degrade to no context/no compaction, never stall the agent.
|
|
111
|
+
hooks: list[Hook] = [
|
|
112
|
+
DeclarativeHook(
|
|
113
|
+
event=HookEvent.SESSION_START,
|
|
114
|
+
command=f"{binary} session-init",
|
|
115
|
+
),
|
|
116
|
+
DeclarativeHook(
|
|
117
|
+
event=HookEvent.POST_TOOL_USE,
|
|
118
|
+
matcher="Edit|Write",
|
|
119
|
+
command=f"{binary} reindex-hook",
|
|
120
|
+
),
|
|
121
|
+
DeclarativeHook(
|
|
122
|
+
event=HookEvent.USER_PROMPT_SUBMIT,
|
|
123
|
+
command=f"{binary} inject-context",
|
|
124
|
+
timeout_seconds=15,
|
|
125
|
+
),
|
|
126
|
+
DeclarativeHook(
|
|
127
|
+
event=HookEvent.STOP,
|
|
128
|
+
command=f"{binary} checkpoint",
|
|
129
|
+
timeout_seconds=15,
|
|
130
|
+
),
|
|
131
|
+
]
|
|
132
|
+
if pi_ext_dir.is_dir():
|
|
133
|
+
hooks.extend(
|
|
134
|
+
PluginRegistration(entry_path=f)
|
|
135
|
+
for f in sorted(pi_ext_dir.iterdir())
|
|
136
|
+
if f.suffix == ".ts"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return RegistrationBundle(
|
|
140
|
+
mcp_servers={
|
|
141
|
+
"witan-code": StdioServer(
|
|
142
|
+
command="uvx", args=_WITAN_CODE_ARGS, env={"WITAN_AUTHOR": author}
|
|
143
|
+
)
|
|
144
|
+
},
|
|
145
|
+
skills=skills,
|
|
146
|
+
hooks=hooks,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def install_omnigraph(dry_run: bool = False) -> None:
|
|
151
|
+
"""Fetch the pinned omnigraph release into ``~/.local/bin/``.
|
|
152
|
+
|
|
153
|
+
Skips the download when a binary is already present and reports the
|
|
154
|
+
pinned version via ``--version``, so re-running always converges on the
|
|
155
|
+
current pin without refetching an already-correct binary.
|
|
156
|
+
"""
|
|
157
|
+
local_bin = Path.home() / ".local" / "bin"
|
|
158
|
+
dest = local_bin / "omnigraph"
|
|
159
|
+
_download_omnigraph(dest, dry_run)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _download_omnigraph(dest: Path, dry_run: bool) -> None:
|
|
163
|
+
from rich.console import Console
|
|
164
|
+
|
|
165
|
+
console = Console()
|
|
166
|
+
|
|
167
|
+
if _installed_version(dest) == _OMNIGRAPH_VERSION:
|
|
168
|
+
console.print(
|
|
169
|
+
f" [dim]omnigraph[/dim] — {dest} already at v{_OMNIGRAPH_VERSION}, skipping"
|
|
170
|
+
)
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
key = (platform.system().lower(), platform.machine().lower())
|
|
174
|
+
asset = _OMNIGRAPH_ASSETS.get(key)
|
|
175
|
+
if asset is None:
|
|
176
|
+
console.print(
|
|
177
|
+
f" [yellow]omnigraph[/yellow] — no pre-built binary for"
|
|
178
|
+
f" {key[0]}/{key[1]}; install manually"
|
|
179
|
+
)
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
url = (
|
|
183
|
+
f"https://github.com/ModernRelay/omnigraph/releases/download"
|
|
184
|
+
f"/v{_OMNIGRAPH_VERSION}/{asset}"
|
|
185
|
+
)
|
|
186
|
+
console.print(f" downloading omnigraph v{_OMNIGRAPH_VERSION} …")
|
|
187
|
+
|
|
188
|
+
if dry_run:
|
|
189
|
+
console.print(f" [green]omnigraph[/green] → {dest} [dim](dry-run)[/dim]")
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
tmp_dest = dest.with_name(dest.name + ".tmp")
|
|
194
|
+
try:
|
|
195
|
+
extracted = False
|
|
196
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
197
|
+
archive = Path(tmp) / asset
|
|
198
|
+
try:
|
|
199
|
+
with (
|
|
200
|
+
urllib.request.urlopen(url, timeout=60) as resp,
|
|
201
|
+
open(archive, "wb") as fh,
|
|
202
|
+
):
|
|
203
|
+
fh.write(resp.read())
|
|
204
|
+
except Exception as exc: # noqa: BLE001
|
|
205
|
+
console.print(
|
|
206
|
+
f" [red]omnigraph download failed[/red] ({exc}); install manually"
|
|
207
|
+
)
|
|
208
|
+
return
|
|
209
|
+
with tarfile.open(archive) as tf:
|
|
210
|
+
for member in tf.getmembers():
|
|
211
|
+
if member.name.split("/")[-1] == "omnigraph" and not member.isdir():
|
|
212
|
+
f = tf.extractfile(member)
|
|
213
|
+
if f:
|
|
214
|
+
tmp_dest.write_bytes(f.read())
|
|
215
|
+
extracted = True
|
|
216
|
+
break
|
|
217
|
+
if extracted:
|
|
218
|
+
tmp_dest.chmod(0o755)
|
|
219
|
+
tmp_dest.replace(dest)
|
|
220
|
+
console.print(f" [green]omnigraph[/green] → {dest}")
|
|
221
|
+
else:
|
|
222
|
+
console.print(
|
|
223
|
+
" [red]omnigraph[/red] — binary not found in archive; install manually"
|
|
224
|
+
)
|
|
225
|
+
except Exception as exc: # noqa: BLE001
|
|
226
|
+
console.print(
|
|
227
|
+
f" [red]omnigraph download failed[/red] ({exc}); install manually"
|
|
228
|
+
)
|
|
229
|
+
finally:
|
|
230
|
+
tmp_dest.unlink(missing_ok=True)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: witan-code
|
|
3
|
+
description: >
|
|
4
|
+
Tree-sitter code graph for symbol lookups, caller graphs, change-impact
|
|
5
|
+
analysis, and cross-repo contract tracing. Use instead of grep/Explore when
|
|
6
|
+
you need exact symbol definitions, who-calls-what, blast radius before an
|
|
7
|
+
edit, or which repo provides/consumes a shared env var, endpoint, package, or
|
|
8
|
+
service. `/witan-code` reports whether the current repo is indexed and lists
|
|
9
|
+
the available `code_*` tools; `/witan-code reindex` forces a full rebuild.
|
|
10
|
+
Backed by the witan-code MCP server (code_* tools).
|
|
11
|
+
license: BSD-3-Clause
|
|
12
|
+
metadata:
|
|
13
|
+
category: workflow
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Code Graph
|
|
17
|
+
|
|
18
|
+
A per-repo, tree-sitter-derived graph of symbols (functions, methods, classes,
|
|
19
|
+
modules) and their relationships (`Calls`, `References`, `Imports`,
|
|
20
|
+
`Inherits`), plus a cross-repo bridge linking services through shared
|
|
21
|
+
contracts (env vars, HTTP endpoints, packages, deployments). Indexing is
|
|
22
|
+
automatic — a `SessionStart` hook seeds/refreshes the whole repo in the
|
|
23
|
+
background, and a `PostToolUse` hook incrementally reindexes each file you
|
|
24
|
+
edit — so you rarely need to invoke the CLI yourself.
|
|
25
|
+
|
|
26
|
+
## When to use this vs. grep / the `Explore` agent
|
|
27
|
+
|
|
28
|
+
Reach for `code_*` tools when the question is **structural**, not textual:
|
|
29
|
+
|
|
30
|
+
- "Where is `Service.run` defined?" → `code_find_definition` / `code_search_symbol`
|
|
31
|
+
- "What calls this function, anywhere in the repo?" → `code_callers` / `code_find_references`
|
|
32
|
+
- "If I change this, what breaks?" (transitive blast radius) → `code_impact`
|
|
33
|
+
- "What symbols does this file define?" → `code_symbols_in_file`
|
|
34
|
+
- "Which other repo reads this env var / calls this endpoint / imports this package?" → `code_interface_consumers` / `code_interface_providers` / `code_cross_repo_impact`
|
|
35
|
+
|
|
36
|
+
Grep is still the right tool for literal string/comment searches, one-off text
|
|
37
|
+
matches, or a repo with no code graph yet. The two are complementary: grep
|
|
38
|
+
finds text; `code_*` tools resolve **symbols** and their **relationships**,
|
|
39
|
+
which grep cannot do (it can't tell you a function's transitive callers or
|
|
40
|
+
which service consumes an endpoint another service serves).
|
|
41
|
+
|
|
42
|
+
**Caveat:** `Calls`/`References`/`Imports`/`Inherits` edges are heuristic
|
|
43
|
+
(syntactic name resolution, not a true call graph) — treat impact/caller
|
|
44
|
+
results as a high-recall starting point, not a verified answer. `Defines` and
|
|
45
|
+
`Contains` are exact.
|
|
46
|
+
|
|
47
|
+
## On invocation
|
|
48
|
+
|
|
49
|
+
- No args → **Check readiness**, then show the tool reference below.
|
|
50
|
+
- `reindex` → call the `code_reindex` MCP tool (or `witan-code reindex` if
|
|
51
|
+
running the CLI directly) to force a full rebuild of the current repo,
|
|
52
|
+
ignoring content hashes. Use when the graph looks stale and you don't want
|
|
53
|
+
to wait for the incremental hooks to catch up.
|
|
54
|
+
|
|
55
|
+
## Check readiness
|
|
56
|
+
|
|
57
|
+
Before relying on results, confirm the current repo has a graph: call
|
|
58
|
+
`code_symbols_in_file` on a file you know exists, or `code_search_symbol` with
|
|
59
|
+
a term you expect to match. An empty result on a file/symbol you know exists
|
|
60
|
+
usually means indexing hasn't finished yet (the `SessionStart` hook runs
|
|
61
|
+
detached in the background on a fresh repo) — wait a few seconds and retry
|
|
62
|
+
before concluding the graph is broken.
|
|
63
|
+
|
|
64
|
+
## Tool reference
|
|
65
|
+
|
|
66
|
+
| Question | Tool |
|
|
67
|
+
|---|---|
|
|
68
|
+
| Find a symbol by name | `code_find_definition(name)` / `code_search_symbol(query)` (BM25) |
|
|
69
|
+
| What's in this file? | `code_symbols_in_file(path)` |
|
|
70
|
+
| Who references/calls this symbol? | `code_find_references(symbol_id)` (References+Calls) / `code_callers(symbol_id)` (Calls only) |
|
|
71
|
+
| Blast radius of a change | `code_impact(symbol_id, max_depth=5, max_nodes=200)` — transitive callers via BFS |
|
|
72
|
+
| Force a reindex | `code_reindex(path=None)` |
|
|
73
|
+
|
|
74
|
+
Cross-repo (reads the shared bridge store; `kind` is one of `env_var` /
|
|
75
|
+
`endpoint` / `package` / `service`):
|
|
76
|
+
|
|
77
|
+
| Question | Tool |
|
|
78
|
+
|---|---|
|
|
79
|
+
| Who provides this contract? | `code_interface_providers(kind, key)` |
|
|
80
|
+
| Who consumes it? | `code_interface_consumers(kind, key)` |
|
|
81
|
+
| Every binding for this symbol's contracts, across repos | `code_cross_repo_impact(symbol_id)` |
|
|
82
|
+
| Search bindings by key | `code_interface_search(query, kind=None)` |
|
|
83
|
+
|
|
84
|
+
Every tool resolves the per-repo store from the current working directory and
|
|
85
|
+
returns `[]`/`null` gracefully when nothing is indexed yet — an empty result
|
|
86
|
+
is not necessarily an error, see **Check readiness** above.
|
|
87
|
+
|
|
88
|
+
## Linking to witan (tasks and memories)
|
|
89
|
+
|
|
90
|
+
`code_find_definition` / `code_search_symbol` return a `symbol_id` of the form
|
|
91
|
+
`<repo>#<path/to/file.py>::<QualifiedName>`. Attach it to a witan task
|
|
92
|
+
(`task_create(..., symbol_refs=[...])`, see `/witan-task`) or memory
|
|
93
|
+
(`memory_store(..., symbol_refs=[...])`, see `/witan-memory`) so the work or
|
|
94
|
+
lesson is discoverable from the code later. Going the other way,
|
|
95
|
+
`symbol_context(symbol_id)` (a witan tool) lists the tasks and memories
|
|
96
|
+
already attached to a symbol — call it before editing code that might have
|
|
97
|
+
open work or known gotchas attached.
|
witan_code/stitch.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Stage 2 — cross-repo symbol stitching at read time (docs/SYMBOL_TABLE.md).
|
|
2
|
+
|
|
3
|
+
Joins ``RepoSymbol`` rows across the whole bridge store to compute precise
|
|
4
|
+
cross-repo edges WITHOUT ever writing them: each repo's ``external``
|
|
5
|
+
(unresolved reference) rows are matched against every OTHER repo's
|
|
6
|
+
``exported`` rows by canonical symbol identity. This is the read-time join
|
|
7
|
+
Stage 1 exists to serve — the RANGER/SCIP pattern of deferring cross-repo
|
|
8
|
+
linking to query time instead of guessing at extraction time.
|
|
9
|
+
|
|
10
|
+
Join keys (docs/SYMBOL_TABLE.md § Stage-2 join contract):
|
|
11
|
+
* ``env`` / ``svc`` — exact ``(scheme, descriptor)``.
|
|
12
|
+
* ``http`` / ``pkg`` — ``(scheme, key_norm)``; ``http`` additionally
|
|
13
|
+
requires method compatibility (a consumer method of ``*`` matches any
|
|
14
|
+
provider method) since key_norm collapses the method-bearing descriptor
|
|
15
|
+
down to the bare path. ``pkg`` descriptors are always ``.``
|
|
16
|
+
(SYMBOL_FORMAT.md), so key_norm — which carries the package name for
|
|
17
|
+
both roles — is the only usable join key for packages.
|
|
18
|
+
|
|
19
|
+
Version disambiguation follows SYMBOL_FORMAT.md decision 1: prefer an exact
|
|
20
|
+
version match, then ``main``, else flag the group ``ambiguous_version``
|
|
21
|
+
rather than guessing a winner. Every candidate is still returned as its own
|
|
22
|
+
edge (this project's consistent pattern of surfacing all cross-repo data and
|
|
23
|
+
letting callers filter — see ``code_cross_repo_impact``); ``preferred`` marks
|
|
24
|
+
which candidate(s) survive the version-preference rule and ``match_count``
|
|
25
|
+
lets a caller tell a clean single match from a fan-out before deciding
|
|
26
|
+
whether to trust it.
|
|
27
|
+
|
|
28
|
+
Existing URL-heuristic extraction (``visualize.cross_repo_edges``, keyed on
|
|
29
|
+
the coarser ``(kind, key_norm)`` binding grouping) is the Stage-3 fallback
|
|
30
|
+
tier for external symbols that fail to join here — see
|
|
31
|
+
``get_unresolved_symbols`` below.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class PreciseEdge:
|
|
39
|
+
consumer_repo: str
|
|
40
|
+
consumer_symbol: str
|
|
41
|
+
provider_repo: str
|
|
42
|
+
provider_symbol: str
|
|
43
|
+
kind: str
|
|
44
|
+
key_norm: str
|
|
45
|
+
scheme: str
|
|
46
|
+
match_count: int
|
|
47
|
+
preferred: bool
|
|
48
|
+
ambiguous_version: bool
|
|
49
|
+
# One Stage-1 exemplar occurrence per side (RepoSymbol keeps only one, not
|
|
50
|
+
# every occurrence — see SYMBOL_TABLE.md); omitted where the store row
|
|
51
|
+
# predates file/line or has none. Consumed by witan_code.edges to build
|
|
52
|
+
# the typed-edge evidence list for the :CALLS/precise tier.
|
|
53
|
+
evidence: tuple[dict, ...] = ()
|
|
54
|
+
|
|
55
|
+
def as_dict(self) -> dict:
|
|
56
|
+
return {
|
|
57
|
+
"consumer_repo": self.consumer_repo,
|
|
58
|
+
"consumer_symbol": self.consumer_symbol,
|
|
59
|
+
"provider_repo": self.provider_repo,
|
|
60
|
+
"provider_symbol": self.provider_symbol,
|
|
61
|
+
"kind": self.kind,
|
|
62
|
+
"key_norm": self.key_norm,
|
|
63
|
+
"scheme": self.scheme,
|
|
64
|
+
"match_count": self.match_count,
|
|
65
|
+
"preferred": self.preferred,
|
|
66
|
+
"ambiguous_version": self.ambiguous_version,
|
|
67
|
+
"evidence": list(self.evidence),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _method(descriptor: str | None) -> str | None:
|
|
72
|
+
"""The leading method token of an http descriptor, or None if there isn't one."""
|
|
73
|
+
if not descriptor:
|
|
74
|
+
return None
|
|
75
|
+
method, _, rest = descriptor.partition(" ")
|
|
76
|
+
return method if rest else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _http_compatible(
|
|
80
|
+
consumer_descriptor: str | None, provider_descriptor: str | None
|
|
81
|
+
) -> bool:
|
|
82
|
+
consumer_method = _method(consumer_descriptor)
|
|
83
|
+
return consumer_method in (None, "*") or consumer_method == _method(
|
|
84
|
+
provider_descriptor
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _join_key(row: dict) -> tuple:
|
|
89
|
+
scheme = row["scheme"]
|
|
90
|
+
if scheme in ("env", "svc"):
|
|
91
|
+
return (scheme, "descriptor", row["descriptor"])
|
|
92
|
+
return (scheme, "key_norm", row["key_norm"])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _select_preferred(
|
|
96
|
+
consumer_version: str | None, candidates: list[dict]
|
|
97
|
+
) -> tuple[set, bool]:
|
|
98
|
+
"""Indices of the preferred candidate(s) plus whether the group is ambiguous.
|
|
99
|
+
|
|
100
|
+
SYMBOL_FORMAT.md decision 1: prefer an exact version match, then ``main``,
|
|
101
|
+
else every remaining candidate is preferred and the group is ambiguous
|
|
102
|
+
(more than one — don't guess a winner).
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def where(pred) -> set:
|
|
106
|
+
return {i for i, c in enumerate(candidates) if pred(c)}
|
|
107
|
+
|
|
108
|
+
if consumer_version not in (".", "", None):
|
|
109
|
+
exact = where(lambda c: c["version"] == consumer_version)
|
|
110
|
+
if exact:
|
|
111
|
+
return exact, len(exact) > 1
|
|
112
|
+
main = where(lambda c: c["version"] == "main")
|
|
113
|
+
if main:
|
|
114
|
+
return main, len(main) > 1
|
|
115
|
+
return set(range(len(candidates))), len(candidates) > 1
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _evidence(ext: dict, prov: dict) -> tuple[dict, ...]:
|
|
119
|
+
out = []
|
|
120
|
+
for row in (ext, prov):
|
|
121
|
+
if row.get("file"):
|
|
122
|
+
out.append(
|
|
123
|
+
{"repo": row["repo"], "file": row["file"], "line": row.get("line")}
|
|
124
|
+
)
|
|
125
|
+
return tuple(out)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def resolve(rows: list[dict]) -> tuple[list[PreciseEdge], list[dict]]:
|
|
129
|
+
"""Join a full ``RepoSymbol`` dump (``all_repo_symbols``) into edges + gaps.
|
|
130
|
+
|
|
131
|
+
Returns ``(precise_edges, unresolved)``: every external row that matched
|
|
132
|
+
at least one exported row from another repo becomes one ``PreciseEdge``
|
|
133
|
+
per candidate; external rows with zero candidates are returned verbatim
|
|
134
|
+
in ``unresolved`` (the raw ``RepoSymbol`` dict) for
|
|
135
|
+
``get_unresolved_symbols`` / Stage-3 fallback.
|
|
136
|
+
"""
|
|
137
|
+
exported: dict[tuple, list[dict]] = {}
|
|
138
|
+
external: list[dict] = []
|
|
139
|
+
for row in rows:
|
|
140
|
+
if row["role"] == "exported":
|
|
141
|
+
exported.setdefault(_join_key(row), []).append(row)
|
|
142
|
+
else:
|
|
143
|
+
external.append(row)
|
|
144
|
+
|
|
145
|
+
edges: list[PreciseEdge] = []
|
|
146
|
+
unresolved: list[dict] = []
|
|
147
|
+
for ext in external:
|
|
148
|
+
candidates = [
|
|
149
|
+
prov
|
|
150
|
+
for prov in exported.get(_join_key(ext), [])
|
|
151
|
+
if prov["repo"] != ext["repo"]
|
|
152
|
+
and (
|
|
153
|
+
ext["scheme"] != "http"
|
|
154
|
+
or _http_compatible(ext["descriptor"], prov["descriptor"])
|
|
155
|
+
)
|
|
156
|
+
]
|
|
157
|
+
if not candidates:
|
|
158
|
+
unresolved.append(ext)
|
|
159
|
+
continue
|
|
160
|
+
preferred_idx, ambiguous = _select_preferred(ext["version"], candidates)
|
|
161
|
+
for i, prov in enumerate(candidates):
|
|
162
|
+
edges.append(
|
|
163
|
+
PreciseEdge(
|
|
164
|
+
consumer_repo=ext["repo"],
|
|
165
|
+
consumer_symbol=ext["symbol"],
|
|
166
|
+
provider_repo=prov["repo"],
|
|
167
|
+
provider_symbol=prov["symbol"],
|
|
168
|
+
kind=ext["kind"],
|
|
169
|
+
key_norm=ext.get("key_norm") or prov.get("key_norm") or "",
|
|
170
|
+
scheme=ext["scheme"],
|
|
171
|
+
match_count=len(candidates),
|
|
172
|
+
preferred=i in preferred_idx,
|
|
173
|
+
ambiguous_version=ambiguous,
|
|
174
|
+
evidence=_evidence(ext, prov),
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
return edges, unresolved
|
witan_code/store.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Per-repo store resolution and lazy initialisation."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from . import config as cfg_module
|
|
7
|
+
from .graph import OmnigraphClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def store_for_repo(slug: str, config: cfg_module.Config | None = None) -> Path:
|
|
11
|
+
"""Return the store path for ``slug`` without creating it."""
|
|
12
|
+
cfg = config or cfg_module.load()
|
|
13
|
+
return cfg_module.store_path(slug, cfg.code_dir)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def store_exists(slug: str, config: cfg_module.Config | None = None) -> bool:
|
|
17
|
+
return store_for_repo(slug, config).exists()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ensure_store(slug: str, config: cfg_module.Config | None = None) -> Path:
|
|
21
|
+
"""Resolve the per-repo store, initialising it from the schema if missing.
|
|
22
|
+
|
|
23
|
+
Mirrors install.sh: ``omnigraph init --schema <schema> <store>``. The flag
|
|
24
|
+
style is copied from the memory install script (the only place a per-store
|
|
25
|
+
init is evidenced); see ASSUMPTION note below.
|
|
26
|
+
"""
|
|
27
|
+
cfg = config or cfg_module.load()
|
|
28
|
+
store = cfg_module.store_path(slug, cfg.code_dir)
|
|
29
|
+
binary = _binary()
|
|
30
|
+
|
|
31
|
+
if not store.exists():
|
|
32
|
+
store.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
# ASSUMPTION: `omnigraph init --schema <file> <store>` is the per-store
|
|
34
|
+
# init form, matching witan/install.sh.
|
|
35
|
+
subprocess.run(
|
|
36
|
+
[binary, "init", "--schema", str(cfg.schema_file), str(store)],
|
|
37
|
+
check=True,
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
)
|
|
41
|
+
# Force apply on first creation — stamp will be written below.
|
|
42
|
+
_schema_apply(binary, cfg.schema_file, store)
|
|
43
|
+
else:
|
|
44
|
+
# Only re-apply when the schema file has changed since the last apply.
|
|
45
|
+
# schema apply is additive/idempotent but spawns a subprocess on every
|
|
46
|
+
# PostToolUse reindex; the mtime sidecar avoids the cost on hot paths.
|
|
47
|
+
_schema_apply_if_changed(binary, cfg.schema_file, store)
|
|
48
|
+
|
|
49
|
+
# Record the canonical repo URI in a sidecar so listings can show it even for
|
|
50
|
+
# a 0-file store (sanitize_slug is lossy — its `_` collapse isn't reversible).
|
|
51
|
+
repo_sidecar(store).write_text(slug)
|
|
52
|
+
return store
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def repo_sidecar(store: Path) -> Path:
|
|
56
|
+
"""Sidecar file next to a store holding its canonical repo URI."""
|
|
57
|
+
return store.parent / f"{store.name}.repo"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _schema_stamp(store: Path) -> Path:
|
|
61
|
+
return store.parent / f"{store.name}.schema_mtime"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _schema_apply(binary: str, schema_file: Path, store: Path) -> None:
|
|
65
|
+
res = subprocess.run(
|
|
66
|
+
[binary, "schema", "apply", "--schema", str(schema_file), str(store)],
|
|
67
|
+
capture_output=True,
|
|
68
|
+
text=True,
|
|
69
|
+
)
|
|
70
|
+
if res.returncode == 0:
|
|
71
|
+
_schema_stamp(store).write_text(str(schema_file.stat().st_mtime))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _schema_apply_if_changed(binary: str, schema_file: Path, store: Path) -> None:
|
|
75
|
+
stamp = _schema_stamp(store)
|
|
76
|
+
current_mtime = str(schema_file.stat().st_mtime)
|
|
77
|
+
if stamp.exists() and stamp.read_text().strip() == current_mtime:
|
|
78
|
+
return
|
|
79
|
+
_schema_apply(binary, schema_file, store)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def bridge_store(config: cfg_module.Config | None = None) -> Path:
|
|
83
|
+
"""Return the shared bridge store path without creating it."""
|
|
84
|
+
cfg = config or cfg_module.load()
|
|
85
|
+
return cfg_module.bridge_store_path(cfg.code_dir)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def ensure_bridge_store(config: cfg_module.Config | None = None) -> Path:
|
|
89
|
+
"""Resolve the shared bridge store, initialising it from bridge-schema.pg.
|
|
90
|
+
|
|
91
|
+
Mirrors ``ensure_store`` but uses the bridge schema and the fixed
|
|
92
|
+
``_bridge.omni`` filename. ``schema apply`` builds the FTS index on
|
|
93
|
+
``key_norm`` that ``search_bindings`` needs.
|
|
94
|
+
"""
|
|
95
|
+
cfg = config or cfg_module.load()
|
|
96
|
+
store = cfg_module.bridge_store_path(cfg.code_dir)
|
|
97
|
+
binary = _binary()
|
|
98
|
+
if store.exists():
|
|
99
|
+
# Pick up additive schema changes (new nodes/fields) on existing
|
|
100
|
+
# stores; the mtime stamp keeps hot reindex paths subprocess-free.
|
|
101
|
+
_schema_apply_if_changed(binary, cfg.bridge_schema_file, store)
|
|
102
|
+
return store
|
|
103
|
+
|
|
104
|
+
store.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
subprocess.run(
|
|
106
|
+
[binary, "init", "--schema", str(cfg.bridge_schema_file), str(store)],
|
|
107
|
+
check=True,
|
|
108
|
+
capture_output=True,
|
|
109
|
+
text=True,
|
|
110
|
+
)
|
|
111
|
+
_schema_apply(binary, cfg.bridge_schema_file, store)
|
|
112
|
+
return store
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _binary() -> str:
|
|
116
|
+
return OmnigraphClient._find_binary()
|