sql-code-graph 1.0.2__py3-none-any.whl → 1.1.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.
- {sql_code_graph-1.0.2.dist-info → sql_code_graph-1.1.0.dist-info}/METADATA +13 -2
- {sql_code_graph-1.0.2.dist-info → sql_code_graph-1.1.0.dist-info}/RECORD +29 -27
- sqlcg/__init__.py +1 -1
- sqlcg/cli/commands/analyze.py +115 -17
- sqlcg/cli/commands/db.py +17 -0
- sqlcg/cli/commands/find.py +7 -0
- sqlcg/cli/commands/git.py +61 -11
- sqlcg/cli/commands/index.py +30 -2
- sqlcg/cli/commands/mcp.py +103 -0
- sqlcg/cli/commands/reindex.py +122 -12
- sqlcg/core/config.py +80 -0
- sqlcg/core/freshness.py +134 -0
- sqlcg/core/graph_db.py +2 -0
- sqlcg/core/queries.cypher +24 -6
- sqlcg/core/queries.py +4 -1
- sqlcg/core/schema.cypher +13 -1
- sqlcg/core/schema.py +5 -1
- sqlcg/indexer/indexer.py +376 -160
- sqlcg/indexer/walker.py +3 -0
- sqlcg/parsers/ansi_parser.py +56 -0
- sqlcg/parsers/base.py +6 -3
- sqlcg/parsers/snowflake_parser.py +46 -6
- sqlcg/server/control.py +144 -0
- sqlcg/server/models.py +68 -0
- sqlcg/server/server.py +283 -1
- sqlcg/server/skill.py +20 -4
- sqlcg/server/tools.py +203 -13
- {sql_code_graph-1.0.2.dist-info → sql_code_graph-1.1.0.dist-info}/WHEEL +0 -0
- {sql_code_graph-1.0.2.dist-info → sql_code_graph-1.1.0.dist-info}/entry_points.txt +0 -0
sqlcg/server/server.py
CHANGED
|
@@ -73,6 +73,254 @@ async def _run_stdio_async_with_real_stdout() -> None:
|
|
|
73
73
|
)
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
async def _control_socket_task(
|
|
77
|
+
db_path: "Path",
|
|
78
|
+
backend_ref: "Callable[[], GraphBackend | None]",
|
|
79
|
+
stop_event: "anyio.Event",
|
|
80
|
+
reindex_lock: "anyio.Lock",
|
|
81
|
+
start_time: float,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Accept control connections on ``<db>.sock`` and dispatch ops.
|
|
84
|
+
|
|
85
|
+
Supported ops (newline-delimited JSON request → response):
|
|
86
|
+
|
|
87
|
+
- ``{"op": "status"}`` → running state, pid, db_path, freshness, uptime.
|
|
88
|
+
- ``{"op": "stop"}`` → sends ``{"ok": true}`` then signals stop via
|
|
89
|
+
*stop_event*. The ``_run_with_control`` coroutine watches this event
|
|
90
|
+
and closes stdin to trigger EOF in the MCP stdio loop.
|
|
91
|
+
- ``{"op": "reindex", "root", "from", "to", "dialect"}`` → runs
|
|
92
|
+
``Indexer.resync_changed`` off the event-loop thread via
|
|
93
|
+
``anyio.to_thread.run_sync``, serialised behind *reindex_lock* (R1, R2).
|
|
94
|
+
|
|
95
|
+
R2 (single connection): all backend mutations go through ``reindex_lock``
|
|
96
|
+
so concurrent notify calls never touch the single Kuzu connection
|
|
97
|
+
simultaneously.
|
|
98
|
+
|
|
99
|
+
R8 teardown ordering: the caller must cancel this task BEFORE calling
|
|
100
|
+
``shutdown_backend()``. This is guaranteed by the ``anyio.CancelScope``
|
|
101
|
+
wrapping this task in ``_run_with_control`` — the scope is cancelled when
|
|
102
|
+
the stdio loop exits, before ``main`` calls ``shutdown_backend()``.
|
|
103
|
+
"""
|
|
104
|
+
import json
|
|
105
|
+
import time
|
|
106
|
+
from pathlib import Path as _Path
|
|
107
|
+
|
|
108
|
+
import anyio
|
|
109
|
+
import anyio.abc as _anyio_abc
|
|
110
|
+
import anyio.to_thread as _to_thread
|
|
111
|
+
|
|
112
|
+
from sqlcg.core.config import get_db_path as _get_db_path
|
|
113
|
+
|
|
114
|
+
sp = sock_path(db_path)
|
|
115
|
+
|
|
116
|
+
listener = await anyio.create_unix_listener(str(sp))
|
|
117
|
+
sp.chmod(0o600)
|
|
118
|
+
|
|
119
|
+
async def _handle_connection(stream: _anyio_abc.SocketStream) -> None:
|
|
120
|
+
async with stream:
|
|
121
|
+
try:
|
|
122
|
+
raw = await stream.receive(4096)
|
|
123
|
+
req = json.loads(raw)
|
|
124
|
+
op = req.get("op")
|
|
125
|
+
|
|
126
|
+
if op == "status":
|
|
127
|
+
from sqlcg.core.freshness import compute_freshness
|
|
128
|
+
|
|
129
|
+
db = backend_ref()
|
|
130
|
+
indexed_sha: str | None = None
|
|
131
|
+
head_sha: str | None = None
|
|
132
|
+
stale: int | None = None
|
|
133
|
+
|
|
134
|
+
if db is not None:
|
|
135
|
+
try:
|
|
136
|
+
indexed_sha = db.get_indexed_sha()
|
|
137
|
+
except Exception:
|
|
138
|
+
indexed_sha = None
|
|
139
|
+
|
|
140
|
+
if indexed_sha is not None:
|
|
141
|
+
try:
|
|
142
|
+
rows = db.run_read(
|
|
143
|
+
"MATCH (r:Repo) RETURN r.path AS path LIMIT 1",
|
|
144
|
+
{},
|
|
145
|
+
)
|
|
146
|
+
if rows:
|
|
147
|
+
f = compute_freshness(_Path(rows[0]["path"]), indexed_sha)
|
|
148
|
+
head_sha = f.head_sha
|
|
149
|
+
stale = f.stale_by_commits
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
resp: dict = {
|
|
154
|
+
"running": True,
|
|
155
|
+
"pid": os.getpid(),
|
|
156
|
+
"db_path": str(db_path or _get_db_path()),
|
|
157
|
+
"indexed_sha": indexed_sha,
|
|
158
|
+
"head_sha": head_sha,
|
|
159
|
+
"stale_by_commits": stale,
|
|
160
|
+
"connected_clients": 1, # stdio transport = 1 by design
|
|
161
|
+
"uptime": time.time() - start_time,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
elif op == "stop":
|
|
165
|
+
resp = {"ok": True}
|
|
166
|
+
await stream.send(json.dumps(resp).encode() + b"\n")
|
|
167
|
+
# Trigger graceful stop: close stdin (triggers EOF in MCP loop)
|
|
168
|
+
# then cancel the task group. Cleanup is still done in the
|
|
169
|
+
# main() finally block via cleanup_control_files().
|
|
170
|
+
stop_event.set()
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
elif op == "reindex":
|
|
174
|
+
root = req.get("root")
|
|
175
|
+
from_sha = req.get("from")
|
|
176
|
+
to_sha = req.get("to")
|
|
177
|
+
dialect = req.get("dialect")
|
|
178
|
+
if not root or not from_sha or not to_sha:
|
|
179
|
+
resp = {"error": "reindex op requires root, from, to"}
|
|
180
|
+
else:
|
|
181
|
+
from sqlcg.indexer.indexer import Indexer
|
|
182
|
+
|
|
183
|
+
db = backend_ref()
|
|
184
|
+
if db is None:
|
|
185
|
+
resp = {"error": "backend not available"}
|
|
186
|
+
else:
|
|
187
|
+
indexer = Indexer()
|
|
188
|
+
|
|
189
|
+
def _do_reindex() -> dict:
|
|
190
|
+
return indexer.resync_changed(
|
|
191
|
+
_Path(root),
|
|
192
|
+
from_sha,
|
|
193
|
+
to_sha,
|
|
194
|
+
db,
|
|
195
|
+
dialect,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
async with reindex_lock:
|
|
199
|
+
# R1: run off event-loop thread; R2: lock serialises
|
|
200
|
+
summary = await _to_thread.run_sync(_do_reindex)
|
|
201
|
+
resp = {"ok": True, "summary": summary}
|
|
202
|
+
|
|
203
|
+
else:
|
|
204
|
+
resp = {"error": f"unknown op: {op!r}"}
|
|
205
|
+
|
|
206
|
+
await stream.send(json.dumps(resp).encode() + b"\n")
|
|
207
|
+
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
try:
|
|
210
|
+
await stream.send(json.dumps({"error": str(exc)}).encode() + b"\n")
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
async with listener:
|
|
215
|
+
await listener.serve(_handle_connection)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def _stop_watcher(
|
|
219
|
+
stop_event: "anyio.Event",
|
|
220
|
+
db_path: "Path",
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Wait for stop_event then perform graceful shutdown.
|
|
223
|
+
|
|
224
|
+
When the control socket ``stop`` op fires ``stop_event``, this task:
|
|
225
|
+
1. Shuts down the backend (flush pending writes).
|
|
226
|
+
2. Removes the control files (.sock, .pid).
|
|
227
|
+
3. Calls ``os._exit(0)`` to terminate the process immediately.
|
|
228
|
+
|
|
229
|
+
We use ``os._exit(0)`` rather than a cancel-scope approach because the
|
|
230
|
+
MCP ``stdio_server`` runs stdin readline in a thread via
|
|
231
|
+
``anyio.to_thread.run_sync`` with ``abandon_on_cancel=False`` (the
|
|
232
|
+
default). When stdin is a pipe, the read-end cannot be closed from within
|
|
233
|
+
the subprocess to interrupt the blocking readline — the parent holds the
|
|
234
|
+
write end open. ``os._exit`` bypasses the thread-drain wait entirely and
|
|
235
|
+
exits the process without calling ``atexit`` handlers or running
|
|
236
|
+
``finally`` blocks in other tasks.
|
|
237
|
+
|
|
238
|
+
R8 ordering: backend is shut down HERE (before os._exit), not in main().
|
|
239
|
+
The ``finally`` block in ``main()`` will also try to shutdown/cleanup but
|
|
240
|
+
``os._exit`` prevents it from running — so we do it explicitly here.
|
|
241
|
+
"""
|
|
242
|
+
import sqlcg.server.tools as _tools
|
|
243
|
+
from sqlcg.server.control import cleanup_control_files
|
|
244
|
+
|
|
245
|
+
await stop_event.wait()
|
|
246
|
+
# Graceful teardown before hard exit
|
|
247
|
+
try:
|
|
248
|
+
_tools.shutdown_backend()
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
try:
|
|
252
|
+
cleanup_control_files(db_path)
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
os._exit(0)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
async def _sigterm_watcher(
|
|
259
|
+
stop_event: "anyio.Event",
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Watch for SIGTERM and trigger the same graceful stop as the socket stop op.
|
|
262
|
+
|
|
263
|
+
Fires ``stop_event`` which causes ``_stop_watcher`` to shut down cleanly.
|
|
264
|
+
Not started on Windows (no Unix signals).
|
|
265
|
+
"""
|
|
266
|
+
import signal
|
|
267
|
+
|
|
268
|
+
import anyio
|
|
269
|
+
|
|
270
|
+
with anyio.open_signal_receiver(signal.SIGTERM) as signals:
|
|
271
|
+
async for _sig in signals:
|
|
272
|
+
stop_event.set()
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def _run_with_control(db_path: "Path", start_time: float) -> None:
|
|
277
|
+
"""Run the stdio MCP loop and the control-socket task in a shared TaskGroup.
|
|
278
|
+
|
|
279
|
+
Stop mechanism (R8 teardown ordering):
|
|
280
|
+
- Control socket ``stop`` op → ``stop_event.set()`` → ``_stop_watcher``
|
|
281
|
+
shuts down backend + removes control files + calls ``os._exit(0)``.
|
|
282
|
+
- External SIGTERM → ``_sigterm_watcher`` → same path via ``stop_event``.
|
|
283
|
+
- Normal EOF on stdin (editor closes connection) → stdio loop returns →
|
|
284
|
+
``tg.cancel_scope.cancel()`` → tasks cancelled → ``main()`` finally
|
|
285
|
+
block does cleanup.
|
|
286
|
+
|
|
287
|
+
``os._exit(0)`` is used for the stop-op path because the MCP
|
|
288
|
+
``stdio_server`` blocks on a pipe read (``anyio.to_thread.run_sync``
|
|
289
|
+
with ``abandon_on_cancel=False``). We cannot interrupt it without
|
|
290
|
+
killing the process; ``_stop_watcher`` does cleanup first.
|
|
291
|
+
|
|
292
|
+
``reindex_lock`` is created once here and passed into
|
|
293
|
+
``_control_socket_task`` so concurrent notify calls are serialised
|
|
294
|
+
behind a single lock (R2).
|
|
295
|
+
"""
|
|
296
|
+
import anyio
|
|
297
|
+
|
|
298
|
+
import sqlcg.server.tools as _tools
|
|
299
|
+
|
|
300
|
+
stop_event = anyio.Event()
|
|
301
|
+
reindex_lock = anyio.Lock() # R2: serialise reindex ops (Kuzu not thread-safe)
|
|
302
|
+
|
|
303
|
+
async with anyio.create_task_group() as tg:
|
|
304
|
+
if sys.platform != "win32":
|
|
305
|
+
# Spawn control socket alongside the stdio loop.
|
|
306
|
+
tg.start_soon(
|
|
307
|
+
_control_socket_task,
|
|
308
|
+
db_path,
|
|
309
|
+
lambda: _tools._backend,
|
|
310
|
+
stop_event,
|
|
311
|
+
reindex_lock,
|
|
312
|
+
start_time,
|
|
313
|
+
)
|
|
314
|
+
# Watch stop_event; shuts down and calls os._exit(0).
|
|
315
|
+
tg.start_soon(_stop_watcher, stop_event, db_path)
|
|
316
|
+
# Watch for SIGTERM; fires stop_event for same clean path.
|
|
317
|
+
tg.start_soon(_sigterm_watcher, stop_event)
|
|
318
|
+
|
|
319
|
+
# stdio loop — when it returns (EOF/error), cancel remaining tasks.
|
|
320
|
+
await _run_stdio_async_with_real_stdout()
|
|
321
|
+
tg.cancel_scope.cancel()
|
|
322
|
+
|
|
323
|
+
|
|
76
324
|
def main(db_path: str | None = None) -> None:
|
|
77
325
|
"""Start the MCP server.
|
|
78
326
|
|
|
@@ -80,8 +328,14 @@ def main(db_path: str | None = None) -> None:
|
|
|
80
328
|
db_path: Path to KùzuDB database. If None, uses SQLCG_DB_PATH env var
|
|
81
329
|
or ~/.sqlcg/graph.db (via get_db_path in tools module).
|
|
82
330
|
"""
|
|
331
|
+
import time
|
|
332
|
+
from pathlib import Path
|
|
333
|
+
|
|
83
334
|
import anyio
|
|
84
335
|
|
|
336
|
+
from sqlcg.core.config import get_db_path
|
|
337
|
+
from sqlcg.server.control import cleanup_control_files, write_pid
|
|
338
|
+
|
|
85
339
|
# Must be first — redirects sys.stdout → sys.stderr so stray prints don't
|
|
86
340
|
# corrupt fd 1. _real_stdout_buffer was already captured at module top.
|
|
87
341
|
_configure_mcp_logging()
|
|
@@ -94,7 +348,35 @@ def main(db_path: str | None = None) -> None:
|
|
|
94
348
|
# Initialize the backend singleton used by all tools
|
|
95
349
|
sqlcg.server.tools.init_backend(db_path)
|
|
96
350
|
|
|
351
|
+
_start_time = time.time()
|
|
352
|
+
_db_path_obj = Path(db_path) if db_path else get_db_path()
|
|
353
|
+
|
|
354
|
+
# Write PID file so CLI clients can discover and probe the server.
|
|
355
|
+
write_pid(_db_path_obj)
|
|
356
|
+
|
|
97
357
|
try:
|
|
98
|
-
anyio.run(
|
|
358
|
+
anyio.run(_run_with_control, _db_path_obj, _start_time)
|
|
99
359
|
finally:
|
|
360
|
+
# R8: control-socket task is cancelled inside _run_with_control before
|
|
361
|
+
# this finally block runs, so no stop/reindex op can arrive on a closed
|
|
362
|
+
# connection.
|
|
100
363
|
sqlcg.server.tools.shutdown_backend()
|
|
364
|
+
cleanup_control_files(_db_path_obj)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
# Late imports pulled up to module scope for _control_socket_task
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
# These are referenced in the type annotations in _control_socket_task.
|
|
371
|
+
# We use TYPE_CHECKING guard to keep them from executing at import time.
|
|
372
|
+
from typing import TYPE_CHECKING # noqa: E402
|
|
373
|
+
|
|
374
|
+
if TYPE_CHECKING:
|
|
375
|
+
from collections.abc import Callable
|
|
376
|
+
from pathlib import Path
|
|
377
|
+
|
|
378
|
+
import anyio
|
|
379
|
+
|
|
380
|
+
from sqlcg.core.graph_db import GraphBackend
|
|
381
|
+
|
|
382
|
+
from sqlcg.server.control import sock_path # noqa: E402 (used in _control_socket_task)
|
sqlcg/server/skill.py
CHANGED
|
@@ -43,16 +43,32 @@ _BOUNDARY = """\
|
|
|
43
43
|
**Heuristics**: interpretations carrying `confidence` (uncalibrated) + `reason`. \
|
|
44
44
|
Never present as fact — surface `reason`/`confidence` and have the user validate. \
|
|
45
45
|
Only `get_change_scope`/`scope_change` (`risk`) and `analyze_unused` \
|
|
46
|
-
(`dead_code`, confidence 0.5) are heuristics
|
|
46
|
+
(`dead_code`, confidence 0.5) are heuristics. `trace_column_lineage` returns \
|
|
47
|
+
deterministic fact edges (`confidence=1.0`, `reason=None`) for plainly-parsed \
|
|
48
|
+
SELECT statements; `confidence<1.0` with a non-null `reason` flags an inferred \
|
|
49
|
+
edge — surface `reason` and treat with caution.
|
|
50
|
+
|
|
51
|
+
**Provenance (#31)**: every `LineageNode` returned by `trace_column_lineage` carries \
|
|
52
|
+
`file` (source file path), `line` (1-based start line of the producing statement), and \
|
|
53
|
+
`expression` (SQL text of that statement, truncated). When explaining a lineage edge, \
|
|
54
|
+
always cite `file` and `line` so the user can navigate to the exact location — never assert \
|
|
55
|
+
an unsourced edge when provenance is available.
|
|
56
|
+
|
|
57
|
+
**Node kind (#33)**: `LineageNode.table_kind` distinguishes the structural role of the source: \
|
|
58
|
+
`"table"` = a real persisted table, `"cte"` = a named CTE, `"derived"` = an inline subquery, \
|
|
59
|
+
`"external"` = a source outside the indexed corpus. Never present a `"cte"` or `"derived"` \
|
|
60
|
+
intermediate as the ultimate source table — follow the chain until `table_kind == "table"` \
|
|
61
|
+
(or `"external"`) to name the true origin.
|
|
47
62
|
"""
|
|
48
63
|
|
|
49
64
|
_WORKFLOWS = """\
|
|
50
65
|
## Workflows
|
|
51
66
|
|
|
52
67
|
- **Change a table**: `scope_change(t)` → read `authoritative_files`, follow `backfill_order`; treat `risk` as heuristic.
|
|
53
|
-
- **Trace lineage**: `trace_column_lineage(schema.table.column)` (fact). Empty = unresolved at index time, not "no lineage" — check `hint`.
|
|
54
|
-
- **Dead code**: `analyze_unused()` →
|
|
55
|
-
- **CI impact**: `diff_impact(changed_files)` (fact); `presentation_facing` flags user-visible tables.
|
|
68
|
+
- **Trace lineage**: `trace_column_lineage(schema.table.column)` (fact). Empty = unresolved at index time, not "no lineage" — check `hint`. Each node carries `file`/`line`/`expression` provenance — cite them when reporting. Use `table_kind` to distinguish real tables (`"table"`) from intermediate CTEs (`"cte"`) or subqueries (`"derived"`).
|
|
69
|
+
- **Dead code**: `analyze_unused()` → `candidates` (`dead_code`, heuristic, confidence 0.5) are orphans OUTSIDE the declared egress layer; `presentation_facing` are terminal/egress leaves (config `[sqlcg.presentation]`) expected to have no in-corpus consumer — do NOT suggest deleting those. `has_external_consumer=True` on a `presentation_facing` entry means a declared external egress consumer (e.g. Tableau, reverse-ETL) is attached via `CONSUMED_BY` — it is a provable egress point. Show `reason` before suggesting deletion of any candidate.
|
|
70
|
+
- **CI impact**: `diff_impact(changed_files)` (fact); `presentation_facing` flags user-visible tables; `external_consumers` lists any declared external egress consumers (e.g. Tableau, BI tools) attached to tables in the blast radius via `CONSUMED_BY` edges — injected at index time from `[[sqlcg.external_consumers]]` in `.sqlcg.toml`.
|
|
71
|
+
- **Downstream egress**: `get_downstream_dependencies(col)` appends `DependencyNode(kind="external_consumer")` entries for any declared external consumers attached to terminal tables — these represent data leaving the modeled SQL corpus.
|
|
56
72
|
- **Hub topology**: `get_hub_ranking(k)` (fact); high hub = high-risk change target, quantify via `scope_change`.
|
|
57
73
|
"""
|
|
58
74
|
|