sql-code-graph 1.4.1__py3-none-any.whl → 1.5.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-code-graph
3
- Version: 1.4.1
3
+ Version: 1.5.0
4
4
  Summary: SQL code graph analyzer and lineage tracer
5
5
  Project-URL: Homepage, https://github.com/Warhorze/sql-code-graph
6
6
  Project-URL: Repository, https://github.com/Warhorze/sql-code-graph
@@ -1,7 +1,7 @@
1
- sqlcg/__init__.py,sha256=Q9RK3IHj_JNrfASKHyNaH76PD2u74uMssIwE5Mi7F7Q,115
1
+ sqlcg/__init__.py,sha256=umkxJTfZmp0-L1q_ge27nl61dPsJtPJKnGORGDpbJKY,115
2
2
  sqlcg/__main__.py,sha256=1YoFLcqEgTwYq1J3TbUwpkdG0zeeLIf2fJvwWI-CLFU,109
3
3
  sqlcg/cli/__init__.py,sha256=W8fD0LpMq2xm_5WKGNMvJh2WBL1ho5E8hUeAqXQYT1g,28
4
- sqlcg/cli/main.py,sha256=WmdTjsOlz1ozi2Y3Aq4ezR_FCRl-Lc1YOKw3_d48dlY,1650
4
+ sqlcg/cli/main.py,sha256=oiZ0e_blUCnlmmXNSCLYnifleDv6ixm9dF7GEDGe0dQ,2429
5
5
  sqlcg/cli/commands/__init__.py,sha256=oSHtr6VD-jNubOjuCQyZj2tBppjMEpQDh-IGQ8of9eA,30
6
6
  sqlcg/cli/commands/analyze.py,sha256=_aC5ML3w7YdLi7DL3TFS9OiCEIipuNZxWR6S4peTcn4,12154
7
7
  sqlcg/cli/commands/db.py,sha256=TMhPCHRWSo8YmlNnSxmGxrR0_1r3K4e0Bn8unZTkvU4,7248
@@ -9,8 +9,8 @@ sqlcg/cli/commands/find.py,sha256=p5Vyyx-VBk8YDWYQN16UhECh7PIeMaEyCPEcUFcRFlM,25
9
9
  sqlcg/cli/commands/gain.py,sha256=SJU1c51a7MgNbZItqQnaBfOWGnV5xpXQctbew5Dr9BE,9062
10
10
  sqlcg/cli/commands/git.py,sha256=9a8T2FVxcAHq1H6Cslaq34t10w9fBGf4T2reiLk33t8,6135
11
11
  sqlcg/cli/commands/index.py,sha256=RPEpT9aXmK0rixRuHgMcmAn9FLR4JtIbZ1LM4RLkMvY,17050
12
- sqlcg/cli/commands/install.py,sha256=KNABvrLbamPyYnmnVdCaM_MNezbDc-pr6IkignCWI8k,9186
13
- sqlcg/cli/commands/mcp.py,sha256=QYaupf69lLpYzIoqsPJoCPiAggLVkYBzwpuOLRzxJDU,9140
12
+ sqlcg/cli/commands/install.py,sha256=VdfZnC41WE3Lx6CcGSd2Xyi-XNNOLP1YQrnXmqo7x3c,10023
13
+ sqlcg/cli/commands/mcp.py,sha256=CA24kRfBltanKuSMkRVRf42GW8cTgG8YQrMruxB3vSQ,11592
14
14
  sqlcg/cli/commands/reindex.py,sha256=FqXxvQ0UrxwDS0q2V3gzYgarN3NPh9muaIZ3rgvTmHs,13810
15
15
  sqlcg/cli/commands/report.py,sha256=JU0qjyMxwOukE7bN3XvvIzOI7zMg_Gsnvk_8F6pKNpA,4915
16
16
  sqlcg/cli/commands/uninstall.py,sha256=WrA1FnINxnd6mmE4-_QBK0aHBnibstJeAT8swnQKG4M,8962
@@ -22,8 +22,8 @@ sqlcg/core/freshness.py,sha256=F9jWn2cbFs60jA9ta8KrT6MghD0mzI7SqqKs_Op9AeU,4577
22
22
  sqlcg/core/graph_db.py,sha256=eLYdmiqPWrZHtKLFcKgD2aiWZHFU3wrwz6Y0A3d9NcE,8633
23
23
  sqlcg/core/jobs.py,sha256=Je-fCdSKRgiSsv1W8SgNAlp36a7t7-pJZ-qKPbka9OE,3298
24
24
  sqlcg/core/queries.cypher,sha256=cvPOVe5GUOzJN4bxUvDxNI--xIIP8gm42TR-gUnea4U,4685
25
- sqlcg/core/queries.py,sha256=jtZR6caLpF0WqqkjncYTlf5L1GN6DZweoM5dNOAB8OY,3043
26
- sqlcg/core/queries.sql,sha256=75EHoHA5hKz-Xs8g-CCtDXbf3lYFEw_fxghjYU48gUM,7794
25
+ sqlcg/core/queries.py,sha256=DeGrpsh-N8CczapKW5Iw_CPBMOxtWZfusMOhpN4Qn_c,3319
26
+ sqlcg/core/queries.sql,sha256=yRITKvjnWY0e1Sa8me0yFe_FkkIBsk3BQn2l0iqOJ7Q,10768
27
27
  sqlcg/core/schema.cypher,sha256=rK5QMhSrzZhuj73NeNXGX6oM-rPPPvxFjex0fEyUvkQ,2859
28
28
  sqlcg/core/schema.py,sha256=7fKf314ueIV7-tIkQQUS4O6H-OhlFKFVFFvGFqw_5Xk,1476
29
29
  sqlcg/indexer/__init__.py,sha256=Wh20Unz2OHs1oIyWLrpurPAasF0BET2g4iXtNk7mh2U,56
@@ -40,7 +40,7 @@ sqlcg/lineage/schema_resolver.py,sha256=iXt6LYF6UVWsGUpcfbmjmGn9wCgXl721lTGf_8Aa
40
40
  sqlcg/metrics/__init__.py,sha256=hLJ6wm4St8qqYwKh3o9QG7lcEt1BEYM31ccqO9tGpIg,133
41
41
  sqlcg/metrics/store.py,sha256=KuDtxvyAgug9_KtiSCpvgKM2VZM7VSaI3D11uMLjJJk,10604
42
42
  sqlcg/parsers/__init__.py,sha256=AamA8wBbDZV9_zEtZCI4Hyen5UAVKHmBwjTghTt2PZE,785
43
- sqlcg/parsers/ansi_parser.py,sha256=mGZvijMOMQ4i1BybpwU29a8jnIGViefhy9fxzkSpsRM,17193
43
+ sqlcg/parsers/ansi_parser.py,sha256=UNousZcTgHmgDzbvqF77hPf8RmTqwz4oo1rk9OMGWz4,17570
44
44
  sqlcg/parsers/base.py,sha256=N6uqQWHO2lpzTIWgPqzewAXmij0ikZdNmNVJkIQ8Mr0,54889
45
45
  sqlcg/parsers/bigquery_parser.py,sha256=mOnWTfXB_Dp4JwFE1PVYOB6CDPf5nYE0Dea8kJCl9uQ,2827
46
46
  sqlcg/parsers/postgres_parser.py,sha256=lYfUpQY6j4Qm7ndXBtXbgPoGzYqYddWt5YeFnWKdA6I,946
@@ -50,18 +50,18 @@ sqlcg/parsers/tsql_parser.py,sha256=RRj1pACtAk2tLTDaFWRYF67a0IDvaf5A1YQXWIz0bpQ,
50
50
  sqlcg/server/__init__.py,sha256=n4wuNE7xyJIJxJZBtmtdccCMQfvTdF-IqIaZVbC4FC4,35
51
51
  sqlcg/server/control.py,sha256=qUcztb_aDhL-_X_Nq4q6uGx17cUlbLnI6vUpoZsEjbo,4506
52
52
  sqlcg/server/exceptions.py,sha256=EONw34icOByCTpppSQrvQBW6asc4hfqaGDCAFjv96II,469
53
- sqlcg/server/models.py,sha256=l7ORy6sbtzBW1y3qVaeLwEukbyAgBkz9S5VIm2q4b24,19378
53
+ sqlcg/server/models.py,sha256=4c4ula195cHEqZycO6ZaSNohSLsodB-Du1FP94JKSdc,19810
54
54
  sqlcg/server/noise_filter.py,sha256=Ats2FFzmzFKqvQPWvlUzK8mY9pzlUhL4m1s8P_HNnvI,6335
55
55
  sqlcg/server/read_client.py,sha256=4v1OOl12HCHp6J14HrcWQApSixvJ0wwE8UIIZ0pzLyw,7808
56
- sqlcg/server/server.py,sha256=A1soT-hYnAX78X5j97nIjw9oIZCxBejjV6p_tRDIK38,24335
56
+ sqlcg/server/server.py,sha256=1RiUmjWLgtN636kRQO6-4rH8amC-m9BKsIMO0aDRqes,24838
57
57
  sqlcg/server/skill.py,sha256=EDvmEgl-LQwCxZ9Lca4lt8zCtkk2cCgh9GLYlGgDA64,12828
58
- sqlcg/server/tools.py,sha256=Rdl5SXAuePydk8Z12JVx-lNQeAqm3k9R-z0Q4DstCkw,69743
58
+ sqlcg/server/tools.py,sha256=SyxPbnFmsR1w2Hi2ENmD_mynCdI9JHhTgv1byXFIKoY,77191
59
59
  sqlcg/server/writer.py,sha256=HH8pW6k1c2pbY4b6g2N87Tx4gNCezYqTLNTlAmsHg2Y,18522
60
60
  sqlcg/utils/__init__.py,sha256=--iqt5ThTXmT8Wz7da8hs3n0zDfYPl8P-z5OgRJ_77E,154
61
61
  sqlcg/utils/hashing.py,sha256=H25-sYfxHKb3_IERFnHyAIYNiXN470Oqo5sJT_D3YOA,438
62
62
  sqlcg/utils/ignore.py,sha256=wJjwa0mjnQ_xJExOUxk25y00g065XmmzJapqV3ifD5o,1151
63
63
  sqlcg/utils/logging.py,sha256=u0fCmYsLj9o81vawm3xZTHaw68GQYVm7JxG-gP81u8A,840
64
- sql_code_graph-1.4.1.dist-info/METADATA,sha256=7psrSu1Vh3GiZ7PK7FED45QzazeUrHrltrRla4twAVQ,14085
65
- sql_code_graph-1.4.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
66
- sql_code_graph-1.4.1.dist-info/entry_points.txt,sha256=Wfe49sVzV9p4eVFGo5RxcV-frr3HOP0yzzst8JBxQLQ,46
67
- sql_code_graph-1.4.1.dist-info/RECORD,,
64
+ sql_code_graph-1.5.0.dist-info/METADATA,sha256=LWYY4WQliA5p-YOMnci4GcEGjKkJGy8sRBWPj8a30n8,14085
65
+ sql_code_graph-1.5.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
66
+ sql_code_graph-1.5.0.dist-info/entry_points.txt,sha256=Wfe49sVzV9p4eVFGo5RxcV-frr3HOP0yzzst8JBxQLQ,46
67
+ sql_code_graph-1.5.0.dist-info/RECORD,,
sqlcg/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """SQL Code Graph - SQL lineage and dependency analysis tool."""
2
2
 
3
- __version__ = "1.4.1"
3
+ __version__ = "1.5.0"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -20,6 +20,8 @@ from pathlib import Path
20
20
  import typer
21
21
  from rich.console import Console
22
22
 
23
+ from sqlcg.cli.commands.mcp import stop_server
24
+
23
25
  console = Console()
24
26
 
25
27
  _SERVER_KEY = "sql-code-graph"
@@ -70,10 +72,12 @@ def install_cmd(
70
72
  claude_json = Path.home() / ".claude.json"
71
73
  console.print("[dim]--dry-run: would write to ~/.claude.json:[/dim]")
72
74
  _preview_claude_json(claude_json, entry)
75
+ console.print("[dim]--dry-run: would stop running MCP server[/dim]")
73
76
  _provision_skill(resolved_scope, repo, dry_run=True)
74
77
  return
75
78
 
76
79
  # --- Try official claude CLI first ---
80
+ configured_via_claude_cli = False
77
81
  claude_bin = shutil.which("claude")
78
82
  if claude_bin:
79
83
  proc = subprocess.run(
@@ -84,56 +88,65 @@ def install_cmd(
84
88
  if proc.returncode == 0:
85
89
  console.print(f"[green]Configured:[/green] {_SERVER_KEY} via `claude mcp add`")
86
90
  console.print("\nRestart Claude Code to pick up the new MCP server.")
87
- _provision_skill(resolved_scope, repo, dry_run=False)
88
- return
89
- # Non-zero: log and fall through to ~/.claude.json fallback
90
- console.print(
91
- f"[yellow]Warning:[/yellow] `claude mcp add` returned rc={proc.returncode}; "
92
- "falling back to ~/.claude.json write."
93
- )
94
- if proc.stderr:
95
- console.print(f"[dim]{proc.stderr.strip()}[/dim]")
96
-
97
- # --- Fallback: write ~/.claude.json directly ---
98
- claude_json = Path.home() / ".claude.json"
99
- if claude_json.exists():
100
- try:
101
- data: dict = json.loads(claude_json.read_text())
102
- except (json.JSONDecodeError, OSError, TypeError):
91
+ configured_via_claude_cli = True
92
+ else:
93
+ # Non-zero: log and fall through to ~/.claude.json fallback
103
94
  console.print(
104
- f"[yellow]Warning:[/yellow] {claude_json} contains invalid JSON "
105
- "mcpServers key will be added"
95
+ f"[yellow]Warning:[/yellow] `claude mcp add` returned rc={proc.returncode}; "
96
+ "falling back to ~/.claude.json write."
106
97
  )
98
+ if proc.stderr:
99
+ console.print(f"[dim]{proc.stderr.strip()}[/dim]")
100
+
101
+ if not configured_via_claude_cli:
102
+ # --- Fallback: write ~/.claude.json directly ---
103
+ claude_json = Path.home() / ".claude.json"
104
+ if claude_json.exists():
105
+ try:
106
+ data: dict = json.loads(claude_json.read_text())
107
+ except (json.JSONDecodeError, OSError, TypeError):
108
+ console.print(
109
+ f"[yellow]Warning:[/yellow] {claude_json} contains invalid JSON — "
110
+ "mcpServers key will be added"
111
+ )
112
+ data = {}
113
+ else:
107
114
  data = {}
108
- else:
109
- data = {}
110
115
 
111
- existing = data.get("mcpServers", {}).get("user", {}).get(_SERVER_KEY)
112
- if existing == entry:
113
- console.print(f"[green]Already configured:[/green] {_SERVER_KEY} (in ~/.claude.json)")
114
- _provision_skill(resolved_scope, repo, dry_run=False)
115
- return
116
+ existing = data.get("mcpServers", {}).get("user", {}).get(_SERVER_KEY)
117
+ if existing == entry:
118
+ console.print(f"[green]Already configured:[/green] {_SERVER_KEY} (in ~/.claude.json)")
119
+ else:
120
+ data.setdefault("mcpServers", {}).setdefault("user", {})[_SERVER_KEY] = entry
116
121
 
117
- data.setdefault("mcpServers", {}).setdefault("user", {})[_SERVER_KEY] = entry
122
+ try:
123
+ tmp = claude_json.with_suffix(".tmp")
124
+ tmp.write_text(json.dumps(data, indent=2) + "\n")
125
+ os.replace(tmp, claude_json)
126
+ except (OSError, TypeError, AttributeError):
127
+ pass # Ignore file I/O errors in testing
118
128
 
119
- try:
120
- tmp = claude_json.with_suffix(".tmp")
121
- tmp.write_text(json.dumps(data, indent=2) + "\n")
122
- os.replace(tmp, claude_json)
123
- except (OSError, TypeError, AttributeError):
124
- pass # Ignore file I/O errors in testing
125
-
126
- cmd_str = " ".join(cmd_parts)
127
- console.print(f"[green]Configured:[/green] {_SERVER_KEY} → {cmd_str}")
128
- console.print(f"[dim]Written to {claude_json}[/dim]")
129
-
130
- if entry.get("command") == "uvx":
131
- console.print(
132
- "[yellow]Note:[/yellow] First startup downloads dependencies (~30s). "
133
- "Subsequent restarts use cache (~1s)."
134
- )
129
+ cmd_str = " ".join(cmd_parts)
130
+ console.print(f"[green]Configured:[/green] {_SERVER_KEY} → {cmd_str}")
131
+ console.print(f"[dim]Written to {claude_json}[/dim]")
132
+
133
+ if entry.get("command") == "uvx":
134
+ console.print(
135
+ "[yellow]Note:[/yellow] First startup downloads dependencies (~30s). "
136
+ "Subsequent restarts use cache (~1s)."
137
+ )
135
138
 
136
- console.print("\nRestart Claude Code to pick up the new MCP server.")
139
+ console.print("\nRestart Claude Code to pick up the new MCP server.")
140
+
141
+ # --- Single non-dry-run tail: all three success branches converge here. ---
142
+ # The config may not have changed (e.g. "Already configured"), but the
143
+ # binary on disk did — so the stale running server still must be stopped
144
+ # so the editor respawns the freshly-installed build.
145
+ stopped = stop_server()
146
+ if stopped:
147
+ console.print("Stopped running MCP server; your editor will respawn it on the new build.")
148
+ else:
149
+ console.print("No running MCP server to refresh.")
137
150
  _provision_skill(resolved_scope, repo, dry_run=False)
138
151
 
139
152
 
sqlcg/cli/commands/mcp.py CHANGED
@@ -77,9 +77,19 @@ def mcp_best_practices() -> None:
77
77
  def mcp_status() -> None:
78
78
  """Print server status JSON (connects to control socket).
79
79
 
80
- Returns JSON with fields: running, pid, db_path, indexed_sha, head_sha,
81
- stale_by_commits, connected_clients, uptime, writer_queue when a server
82
- is live.
80
+ Returns JSON with fields: running, version, pid, db_path, indexed_sha,
81
+ head_sha, stale_by_commits, connected_clients, uptime, writer_queue,
82
+ stale_by_version when a server is live.
83
+
84
+ `version` is the *running* server's reported sqlcg.__version__ (read live
85
+ over the control socket — the build the editor is currently talking to).
86
+ `stale_by_version` compares it against the *installed* sqlcg.__version__
87
+ (the CLI process you just invoked): true when they differ, false when they
88
+ match, null when the running version cannot be determined (degraded mode).
89
+ When stale, a warning is printed telling you to restart the MCP server via
90
+ your editor or run `sqlcg install` (which stops the stale server for you).
91
+ This is distinct from `stale_by_commits` (graph-vs-repo freshness, not
92
+ package-vs-binary drift).
83
93
 
84
94
  The status response is length-prefixed framed (v1.3.0, B3) so large
85
95
  writer_queue payloads are received in full — the client uses the
@@ -87,7 +97,8 @@ def mcp_status() -> None:
87
97
 
88
98
  When no server is found: {"running": false}.
89
99
  When the PID file exists with a live process but the socket is unavailable:
90
- {"running": true, "degraded": "socket unavailable", ...}.
100
+ {"running": true, "degraded": "socket unavailable", "stale_by_version": null, ...}
101
+ (the running version cannot be read without a live socket).
91
102
 
92
103
  R3 (stale socket): if the socket file exists but the server is not
93
104
  responding (ConnectionRefusedError / FileNotFoundError), falls through
@@ -119,9 +130,25 @@ def mcp_status() -> None:
119
130
 
120
131
  status = json.loads(data.decode())
121
132
 
133
+ # Drift detection: compare the *running* server's reported version (read
134
+ # over the socket, from the live process) against the *installed*
135
+ # sqlcg.__version__ (the version of the CLI process you just invoked).
136
+ from sqlcg import __version__
137
+
138
+ running_version = status.get("version")
139
+ stale = running_version is not None and running_version != __version__
140
+ status = {**status, "version": running_version, "stale_by_version": stale}
141
+
122
142
  # Pretty-print the base fields.
123
143
  console.print_json(json.dumps({k: v for k, v in status.items() if k != "writer_queue"}))
124
144
 
145
+ if stale:
146
+ console.print(
147
+ f"[yellow]Warning:[/yellow] running MCP server is v{running_version}, "
148
+ f"installed sqlcg is v{__version__} — restart the MCP server via your "
149
+ "editor (or run `sqlcg install`, which stops the stale server for you)."
150
+ )
151
+
125
152
  # Render the writer_queue block separately for readability.
126
153
  wq = status.get("writer_queue")
127
154
  if wq:
@@ -178,6 +205,10 @@ def mcp_status() -> None:
178
205
  "degraded": "socket unavailable",
179
206
  "pid": rec["pid"],
180
207
  "db_path": rec["db_path"],
208
+ # Cannot read the running version without a live socket —
209
+ # omit `version` and report drift as unknown (None → JSON
210
+ # null), never guess.
211
+ "stale_by_version": None,
181
212
  }
182
213
  )
183
214
  )
@@ -185,16 +216,20 @@ def mcp_status() -> None:
185
216
  console.print_json(json.dumps({"running": False}))
186
217
 
187
218
 
188
- @app.command("stop")
189
- def mcp_stop() -> None:
190
- """Stop the running MCP server gracefully.
219
+ def stop_server() -> bool:
220
+ """Stop a running MCP server gracefully (best-effort). Returns True if stopped.
191
221
 
192
222
  Sends a ``stop`` op via the control socket; waits up to 5 s for the
193
223
  socket file to disappear (confirming clean exit). Falls back to SIGTERM
194
224
  on the PID-file PID if the socket is unavailable.
195
225
 
196
226
  R3 (stale socket): ``ConnectionRefusedError`` / ``FileNotFoundError`` are
197
- caught — never hangs on a dead socket.
227
+ caught — never hangs on a dead socket; a socket error here must not abort
228
+ the caller (``mcp_stop`` and ``install_cmd`` both rely on this).
229
+
230
+ Returns:
231
+ True if a running server was found and stopped (via socket or SIGTERM
232
+ fallback); False if no running server was found.
198
233
  """
199
234
  import socket as _socket
200
235
  import time
@@ -214,6 +249,7 @@ def mcp_stop() -> None:
214
249
  break
215
250
  time.sleep(0.5)
216
251
  console.print("[green]Server stopped.[/green]")
252
+ return True
217
253
  except (FileNotFoundError, ConnectionRefusedError, OSError):
218
254
  # Socket unavailable — fall back to SIGTERM via PID file
219
255
  import signal
@@ -222,8 +258,20 @@ def mcp_stop() -> None:
222
258
  if rec and is_pid_alive(rec["pid"]):
223
259
  os.kill(rec["pid"], signal.SIGTERM)
224
260
  console.print(f"[yellow]Socket unavailable — sent SIGTERM to PID {rec['pid']}[/yellow]")
225
- else:
226
- console.print("[yellow]No server found to stop.[/yellow]")
261
+ return True
262
+ return False
263
+
264
+
265
+ @app.command("stop")
266
+ def mcp_stop() -> None:
267
+ """Stop the running MCP server gracefully.
268
+
269
+ Delegates to ``stop_server()`` (shared with ``sqlcg install``) and prints
270
+ a final message based on whether a server was found.
271
+ """
272
+ stopped = stop_server()
273
+ if not stopped:
274
+ console.print("[yellow]No server found to stop.[/yellow]")
227
275
 
228
276
 
229
277
  @app.command("restart")
sqlcg/cli/main.py CHANGED
@@ -34,8 +34,41 @@ USING THE MCP TOOLS:
34
34
  Note: Binary is `sqlcg`; PyPI package is `sql-code-graph`.
35
35
  """
36
36
 
37
+
38
+ def _version_string() -> str:
39
+ from sqlcg import __version__
40
+
41
+ return f"sqlcg version {__version__}"
42
+
43
+
44
+ def _version_callback(value: bool) -> None:
45
+ if value:
46
+ typer.echo(_version_string())
47
+ raise typer.Exit()
48
+
49
+
37
50
  app = typer.Typer(name="sqlcg", help=help_text)
38
51
 
52
+
53
+ @app.callback(invoke_without_command=True)
54
+ def _root(
55
+ ctx: typer.Context,
56
+ version: bool = typer.Option(
57
+ False,
58
+ "--version",
59
+ help="Show version and exit.",
60
+ is_eager=True,
61
+ callback=_version_callback,
62
+ ),
63
+ ) -> None:
64
+ """SQL code graph analyzer."""
65
+ # The eager --version callback exits before we get here when --version is
66
+ # passed. Otherwise: if no subcommand was given, show help (matching the
67
+ # pre-callback behaviour) instead of silently exiting 0.
68
+ if ctx.invoked_subcommand is None:
69
+ typer.echo(ctx.get_help())
70
+
71
+
39
72
  # Register subcommand groups
40
73
  app.add_typer(db.app, name="db")
41
74
  app.add_typer(find.app, name="find")
@@ -56,9 +89,7 @@ app.command("uninstall")(uninstall.uninstall_cmd)
56
89
  @app.command()
57
90
  def version() -> None:
58
91
  """Show version."""
59
- from sqlcg import __version__
60
-
61
- typer.echo(f"sqlcg version {__version__}")
92
+ typer.echo(_version_string())
62
93
 
63
94
 
64
95
  def main() -> None:
sqlcg/core/queries.py CHANGED
@@ -54,9 +54,13 @@ COUNT_STAR_SOURCES_QUERY = _Q["COUNT_STAR_SOURCES"]
54
54
  COUNT_STAR_EXPANSIONS_QUERY = _Q["COUNT_STAR_EXPANSIONS"]
55
55
  FIND_DEFINITION_QUERY = _Q["FIND_DEFINITION"]
56
56
  GET_TABLE_DEFINING_FILES_QUERY = _Q["GET_TABLE_DEFINING_FILES"]
57
+ GET_PRODUCER_FILES_FOR_TABLE_QUERY = _Q["GET_PRODUCER_FILES_FOR_TABLE"]
57
58
  GET_TABLE_DIRECT_UPSTREAMS_QUERY = _Q["GET_TABLE_DIRECT_UPSTREAMS"]
58
59
  GET_COLUMNS_FOR_TABLE_QUERY = _Q["GET_COLUMNS_FOR_TABLE"]
59
60
  GET_TABLES_DEFINED_IN_FILE_QUERY = _Q["GET_TABLES_DEFINED_IN_FILE"]
61
+ GET_TARGET_TABLES_FOR_FILE_QUERY = _Q["GET_TARGET_TABLES_FOR_FILE"]
62
+ GET_TABLE_ADJACENCY_FOR_COLUMNS_QUERY = _Q["GET_TABLE_ADJACENCY_FOR_COLUMNS"]
63
+ GET_TABLE_KINDS_BATCH_QUERY = _Q["GET_TABLE_KINDS_BATCH"]
60
64
  ANALYZE_UNUSED_TABLES_QUERY = _Q["ANALYZE_UNUSED_TABLES"]
61
65
  HUB_RANKING_QUERY = _Q["HUB_RANKING"]
62
66
  DEPENDENT_FILES_OF_TABLES_QUERY = _Q["DEPENDENT_FILES_OF_TABLES"]
sqlcg/core/queries.sql CHANGED
@@ -172,6 +172,19 @@ JOIN "DEFINED_IN" di ON di.src_key = t.qualified
172
172
  JOIN "File" f ON f.path = di.dst_key
173
173
  WHERE t.qualified = ?
174
174
 
175
+ -- GET_PRODUCER_FILES_FOR_TABLE
176
+ -- ETL INSERT...SELECT producers populate a table without a DEFINED_IN edge
177
+ -- (that edge is DDL-only). Resolve SqlQuery.target_table -> QUERY_DEFINED_IN -> File
178
+ -- so get_definition / get_change_scope can also surface "populated here" producer
179
+ -- files, not just "defined here" DDL files (mirror of GET_TARGET_TABLES_FOR_FILE,
180
+ -- the reverse-direction lookup). table_qualified is stored lowercase.
181
+ -- params: [table_qualified]
182
+ SELECT DISTINCT f.path AS file_path
183
+ FROM "SqlQuery" q
184
+ JOIN "QUERY_DEFINED_IN" qdi ON qdi.src_key = q.id
185
+ JOIN "File" f ON f.path = qdi.dst_key
186
+ WHERE q.target_table = ?
187
+
175
188
  -- GET_TABLE_DIRECT_UPSTREAMS
176
189
  -- params: [table_qualified, table_qualified]
177
190
  SELECT DISTINCT src.qualified AS upstream_table, f.path AS in_file
@@ -198,6 +211,53 @@ FROM "SqlTable" t
198
211
  JOIN "DEFINED_IN" di ON di.src_key = t.qualified
199
212
  WHERE di.dst_key = ?
200
213
 
214
+ -- GET_TARGET_TABLES_FOR_FILE
215
+ -- ETL INSERT...SELECT producers populate a table without a DEFINED_IN edge
216
+ -- (that edge is DDL-only). Resolve query->file QUERY_DEFINED_IN -> SqlQuery.target_table
217
+ -- so diff_impact can also see "populated here" producers, not just "defined here" DDL.
218
+ -- params: [file_path]
219
+ SELECT DISTINCT q.target_table AS table_qualified
220
+ FROM "SqlQuery" q
221
+ JOIN "QUERY_DEFINED_IN" qdi ON qdi.src_key = q.id
222
+ WHERE qdi.dst_key = ?
223
+ AND q.target_table <> ''
224
+
225
+ -- GET_TABLE_ADJACENCY_FOR_COLUMNS
226
+ -- Aggregate table-level producer->consumer adjacency derived from COLUMN_LINEAGE,
227
+ -- restricted to a closure's column-id set (Option A — issue #38 backfill fix).
228
+ -- Replaces the N x GET_TABLE_DIRECT_UPSTREAMS loop with a single query: CTE-wrapped
229
+ -- INSERT...SELECT statements emit no SELECTS_FROM adjacency at all (the real source
230
+ -- table is nested in the CTE child scope and never surfaces into the statement's
231
+ -- top-level sources). The raw COLUMN_LINEAGE topology bridges producer -> consumer
232
+ -- via a TWO-HOP path through the synthetic cte/derived node (producer -> cte ->
233
+ -- consumer, not a parallel direct edge) — this query returns ALL rolled-up table
234
+ -- pairs (including synthetic endpoints); the caller contracts synthetic-node hops
235
+ -- into direct real-table adjacency in one pass over this small edge set (still
236
+ -- ONCE per closure, never per-table/per-column). Also returns each endpoint's
237
+ -- SqlTable.kind so the caller can identify synthetic nodes without a second query.
238
+ -- params: [col_ids, col_ids]
239
+ SELECT DISTINCT
240
+ src.table_qualified AS upstream_table,
241
+ ut.kind AS upstream_kind,
242
+ dst.table_qualified AS downstream_table,
243
+ dt.kind AS downstream_kind
244
+ FROM "COLUMN_LINEAGE" cl
245
+ JOIN "SqlColumn" src ON src.id = cl.src_key
246
+ JOIN "SqlColumn" dst ON dst.id = cl.dst_key
247
+ LEFT JOIN "SqlTable" ut ON ut.qualified = src.table_qualified
248
+ LEFT JOIN "SqlTable" dt ON dt.qualified = dst.table_qualified
249
+ WHERE cl.src_key = ANY(?)
250
+ AND cl.dst_key = ANY(?)
251
+ AND src.table_qualified <> dst.table_qualified
252
+
253
+ -- GET_TABLE_KINDS_BATCH
254
+ -- Batch lookup of SqlTable.kind for a set of qualified names — the authoritative
255
+ -- marker for synthetic cte/derived nodes (issue #38 synthetic-node-leak fix).
256
+ -- params: [table_qualifieds]
257
+ SELECT t.qualified AS table_qualified, t.kind AS kind
258
+ FROM "SqlTable" t
259
+ WHERE t.qualified = ANY(?)
260
+
201
261
  -- ANALYZE_UNUSED_TABLES
202
262
  -- params: []
203
263
  SELECT t.qualified AS table_qualified
@@ -404,14 +404,19 @@ class AnsiParser(SqlParser):
404
404
  create_stmt: CREATE expression (must be a TABLE create)
405
405
 
406
406
  Returns:
407
- List of column names in source order (as they appear in the SQL)
407
+ List of column names in source order (as they appear in the SQL),
408
+ normalized to lowercase with surrounding double-quotes stripped —
409
+ identical to ColumnRef.__post_init__ — so DDL columns share node
410
+ identity with lowercase ETL lineage columns (issue #38 Phase 5).
408
411
  """
409
412
  columns: list[str] = []
410
413
  # Find all ColumnDef nodes in the CREATE statement
411
414
  for col_def in create_stmt.find_all(exp.ColumnDef):
412
415
  col_name = col_def.this.name if col_def.this else None
413
416
  if col_name:
414
- columns.append(col_name)
417
+ if len(col_name) >= 2 and col_name[0] == '"' and col_name[-1] == '"':
418
+ col_name = col_name[1:-1]
419
+ columns.append(col_name.lower())
415
420
  return columns
416
421
 
417
422
  @staticmethod
sqlcg/server/models.py CHANGED
@@ -190,6 +190,9 @@ class DialectRepoResult(BaseModel):
190
190
  class DbInfoResult(BaseModel):
191
191
  """Result of db_info tool — graph health and parse quality diagnostics."""
192
192
 
193
+ sqlcg_version: str = Field(
194
+ ..., description="Installed sqlcg package version (sqlcg.__version__)"
195
+ )
193
196
  schema_version: str = Field(..., description="Graph schema version")
194
197
  node_counts: dict[str, int] = Field(
195
198
  default_factory=dict,
@@ -242,6 +245,12 @@ class DefinitionResult(BaseModel):
242
245
  duplicate_ddl: bool = Field(
243
246
  False, description="True when the same table is defined in more than one file."
244
247
  )
248
+ producer_files: list[str] = Field(
249
+ default_factory=list,
250
+ description="Files whose INSERT...SELECT populates this table (via "
251
+ "QUERY_DEFINED_IN -> SqlQuery.target_table). Often the more useful answer "
252
+ "to 'where do I change this table's logic?' than the DDL file alone.",
253
+ )
245
254
  noise_excluded: list[str] = Field(
246
255
  default_factory=list,
247
256
  description="Definition file paths that were flagged as backup/noise.",
sqlcg/server/server.py CHANGED
@@ -27,6 +27,7 @@ _real_stdout_buffer = os.fdopen(os.dup(1), "wb", buffering=0)
27
27
  from dotenv import load_dotenv # noqa: E402
28
28
  from mcp.server import FastMCP # noqa: E402
29
29
 
30
+ from sqlcg import __version__ # noqa: E402
30
31
  from sqlcg.utils.logging import getLogger # noqa: E402
31
32
 
32
33
  logger = getLogger(__name__)
@@ -35,6 +36,13 @@ logger = getLogger(__name__)
35
36
  # This is safe because _real_stdout_buffer has already captured fd 1 above.
36
37
  mcp = FastMCP("SQL Code Graph")
37
38
 
39
+ # FastMCP.__init__ does not accept a `version` kwarg (verified signature); the
40
+ # underlying mcp.server.lowlevel.Server.__init__(self, name, version=None, ...)
41
+ # does. Set it post-construction so the `initialize` handshake reports
42
+ # serverInfo.version == sqlcg.__version__ instead of None. Deliberate, pinned
43
+ # private-attr write — see tests/unit/test_version_parity.py.
44
+ mcp._mcp_server.version = __version__
45
+
38
46
 
39
47
  def _configure_mcp_logging() -> None:
40
48
  """Redirect sys.stdout to sys.stderr and configure logging to stderr.
@@ -219,6 +227,7 @@ async def _control_socket_task(
219
227
 
220
228
  resp: dict = {
221
229
  "running": True,
230
+ "version": __version__,
222
231
  "pid": os.getpid(),
223
232
  "db_path": str(db_path or _get_db_path()),
224
233
  "indexed_sha": indexed_sha,
sqlcg/server/tools.py CHANGED
@@ -21,10 +21,14 @@ from sqlcg.core.queries import (
21
21
  FIND_TABLE_USAGES_QUERY,
22
22
  GET_COLUMNS_FOR_TABLE_QUERY,
23
23
  GET_DOWNSTREAM_DEPENDENCIES_QUERY,
24
+ GET_PRODUCER_FILES_FOR_TABLE_QUERY,
25
+ GET_TABLE_ADJACENCY_FOR_COLUMNS_QUERY,
24
26
  GET_TABLE_DEFINING_FILES_QUERY,
25
27
  GET_TABLE_DIRECT_UPSTREAMS_QUERY,
28
+ GET_TABLE_KINDS_BATCH_QUERY,
26
29
  GET_TABLES_DEFINED_IN_FILE_QUERY,
27
30
  GET_TABLES_EXTERNAL_CONSUMERS_BATCH_QUERY,
31
+ GET_TARGET_TABLES_FOR_FILE_QUERY,
28
32
  GET_UPSTREAM_DEPENDENCIES_QUERY,
29
33
  HUB_RANKING_QUERY,
30
34
  INDEX_REPO_FILES_QUERY,
@@ -396,6 +400,51 @@ def _rollup_to_tables(col_ids: list[str]) -> list[str]:
396
400
  return tables
397
401
 
398
402
 
403
+ # Synthetic SqlTable.kind values that must never surface as rebuildable tables —
404
+ # the authoritative marker per indexer.py (kind="cte" / kind="derived"); see
405
+ # _exclude_synthetic_tables. Keying on kind (not the alias string) avoids dropping
406
+ # a real table that happens to share a name with a CTE alias.
407
+ _SYNTHETIC_TABLE_KINDS = frozenset({"cte", "derived"})
408
+
409
+
410
+ def _exclude_synthetic_tables(db: GraphBackend, tables: list[str]) -> tuple[list[str], list[str]]:
411
+ """Split *tables* into (real, synthetic) using the authoritative SqlTable.kind.
412
+
413
+ Synthetic CTE/derived nodes (``kind in {"cte", "derived"}``) are emitted into
414
+ the graph for lineage-tracing purposes but must never surface as rebuildable
415
+ tables in get_change_scope / get_backfill_order / diff_impact / scope_change
416
+ (issue #38 synthetic-node leak). Exclusion is keyed on ``kind`` — the
417
+ authoritative marker set in indexer.py — never on the alias string, so a real
418
+ table that happens to share a name with a CTE alias is never dropped.
419
+
420
+ Args:
421
+ db: Graph backend.
422
+ tables: Candidate table_qualified strings (already noise-filtered).
423
+
424
+ Returns:
425
+ (real_tables, synthetic_excluded) — both order-preserving, deduplicated.
426
+ """
427
+ if not tables:
428
+ return [], []
429
+
430
+ rows = db.run_read(GET_TABLE_KINDS_BATCH_QUERY, {"table_qualifieds": tables})
431
+ synthetic_ids = {
432
+ row["table_qualified"] for row in rows if row["kind"] in _SYNTHETIC_TABLE_KINDS
433
+ }
434
+
435
+ if not synthetic_ids:
436
+ return tables, []
437
+
438
+ real: list[str] = []
439
+ synthetic: list[str] = []
440
+ for t in tables:
441
+ if t in synthetic_ids:
442
+ synthetic.append(t)
443
+ else:
444
+ real.append(t)
445
+ return real, synthetic
446
+
447
+
399
448
  def _dedup_preserve_order(items: list[str]) -> list[str]:
400
449
  """Deduplicate a list while preserving first-seen order."""
401
450
  out: list[str] = []
@@ -421,30 +470,84 @@ def _risk_label(affected_table_count: int) -> str:
421
470
  return "high"
422
471
 
423
472
 
424
- def _kahn_topological_sort(affected_tables: list[str], db: GraphBackend) -> tuple[list[str], bool]:
473
+ def _kahn_topological_sort(
474
+ affected_tables: list[str], db: GraphBackend, closure_col_ids: list[str]
475
+ ) -> tuple[list[str], bool]:
425
476
  """Order affected tables so producers come before consumers (rebuild order).
426
477
 
427
- Builds an adjacency list from SELECTS_FROM edges *within* the affected set
428
- (a table's producing query selecting from another affected table is an edge
429
- producer -> consumer) and runs Kahn's algorithm. Returns
430
- (ordered_tables, had_cycle). On a cycle the acyclic prefix is emitted in
431
- topological order and the remaining cyclic tables are appended in input
432
- order no exception is raised.
478
+ Builds adjacency from the **COLUMN_LINEAGE closure** (Option A — issue #38),
479
+ not from ``SELECTS_FROM``: CTE-wrapped ``INSERT ... WITH cte AS (...) SELECT
480
+ ... FROM cte`` statements emit no ``SELECTS_FROM`` adjacency at all (the real
481
+ source table is nested in the CTE's child scope and never surfaces into the
482
+ statement's top-level ``sources``), which previously left every table at
483
+ indegree 0 and degraded ordering to alphabetical.
484
+
485
+ ``COLUMN_LINEAGE`` *does* carry the producer -> consumer connectivity, but —
486
+ contrary to an earlier (unreproduced) assumption of a parallel direct edge —
487
+ it bridges producer and consumer via a **two-hop path through the synthetic
488
+ cte/derived node** (``producer -> cte -> consumer``), not a parallel direct
489
+ edge bypassing it. Rolling the raw closure up to table level therefore yields
490
+ edges *to and from* synthetic nodes, not real producer -> consumer adjacency
491
+ directly. This function **contracts** those synthetic-node hops in one pass
492
+ over the (small) rolled-up edge set — ``real -> synthetic -> ... -> real``
493
+ collapses to ``real -> real`` — yielding the true causal adjacency. Still
494
+ **one** aggregate query + one contraction pass per closure (never
495
+ per-table/per-column; replaces the old N x GET_TABLE_DIRECT_UPSTREAMS loop).
496
+
497
+ Runs Kahn's algorithm over the contracted adjacency. Returns (ordered_tables,
498
+ had_cycle). On a cycle the acyclic prefix is emitted in topological order
499
+ and the remaining cyclic tables are appended in input order — no exception
500
+ is raised.
501
+
502
+ Args:
503
+ affected_tables: Noise-filtered, synthetic-excluded table set to order.
504
+ db: Graph backend.
505
+ closure_col_ids: Full column-id set of the closure (start columns plus
506
+ their transitive downstream columns) — both endpoints of every
507
+ producer -> consumer hop (including synthetic bridges) must be
508
+ present here for the adjacency query to find the connecting path.
433
509
  """
434
510
  table_set = set(affected_tables)
435
511
  successors: dict[str, set[str]] = {t: set() for t in affected_tables}
436
512
  indegree: dict[str, int] = {t: 0 for t in affected_tables}
437
513
 
438
- for table in affected_tables:
514
+ if closure_col_ids:
439
515
  rows = db.run_read(
440
- GET_TABLE_DIRECT_UPSTREAMS_QUERY,
441
- {"table_qualified": table, "table_qualified2": table},
516
+ GET_TABLE_ADJACENCY_FOR_COLUMNS_QUERY,
517
+ {"col_ids": closure_col_ids, "col_ids2": closure_col_ids},
442
518
  )
519
+ # Raw rolled-up adjacency, including synthetic (cte/derived) endpoints.
520
+ raw_successors: dict[str, set[str]] = {}
521
+ synthetic_nodes: set[str] = set()
443
522
  for row in rows:
444
- src = row["upstream_table"]
445
- if src in table_set and src != table and table not in successors[src]:
446
- successors[src].add(table)
447
- indegree[table] += 1
523
+ src, dst = row["upstream_table"], row["downstream_table"]
524
+ if src == dst:
525
+ continue
526
+ raw_successors.setdefault(src, set()).add(dst)
527
+ if row["upstream_kind"] in _SYNTHETIC_TABLE_KINDS:
528
+ synthetic_nodes.add(src)
529
+ if row["downstream_kind"] in _SYNTHETIC_TABLE_KINDS:
530
+ synthetic_nodes.add(dst)
531
+
532
+ # Contract synthetic-node hops: from each real producer, BFS through
533
+ # any chain of synthetic nodes to find the real consumers it reaches —
534
+ # `real -> synthetic [-> synthetic ...] -> real` collapses to `real -> real`.
535
+ for src in table_set:
536
+ seen_chain: set[str] = set()
537
+ frontier = deque(raw_successors.get(src, ()))
538
+ while frontier:
539
+ node = frontier.popleft()
540
+ if node in seen_chain:
541
+ continue
542
+ seen_chain.add(node)
543
+ if node in table_set:
544
+ if node != src and node not in successors[src]:
545
+ successors[src].add(node)
546
+ indegree[node] += 1
547
+ elif node in synthetic_nodes:
548
+ frontier.extend(raw_successors.get(node, ()))
549
+ # Real nodes outside table_set (e.g. noise-filtered) are dead ends —
550
+ # do not traverse through them; only synthetic bridges are contracted.
448
551
 
449
552
  # Sort the zero-indegree frontier for deterministic output.
450
553
  ready: deque[str] = deque(sorted(t for t in affected_tables if indegree[t] == 0))
@@ -842,7 +945,10 @@ def find_definition(table_qualified: str) -> DefinitionResult:
842
945
  with _open_backend() as db:
843
946
  _assert_indexed(db)
844
947
 
845
- rows = db.run_read(FIND_DEFINITION_QUERY, {"table_qualified": table_qualified.lower()})
948
+ target = table_qualified.lower()
949
+ rows = db.run_read(FIND_DEFINITION_QUERY, {"table_qualified": target})
950
+ producer_rows = db.run_read(GET_PRODUCER_FILES_FOR_TABLE_QUERY, {"table_qualified": target})
951
+ producer_files = _dedup_preserve_order([r["file_path"] for r in producer_rows])
846
952
  noise_filter = NoiseFilter.from_config()
847
953
 
848
954
  duplicate_ddl = len(rows) > 1
@@ -874,6 +980,7 @@ def find_definition(table_qualified: str) -> DefinitionResult:
874
980
  table_qualified=table_qualified,
875
981
  definitions=definitions,
876
982
  duplicate_ddl=duplicate_ddl,
983
+ producer_files=producer_files,
877
984
  noise_excluded=noise_excluded,
878
985
  hint=hint,
879
986
  )
@@ -905,7 +1012,10 @@ def get_change_scope(table_qualified: str) -> ChangeScopeResult:
905
1012
  noise_filter = NoiseFilter.from_config()
906
1013
 
907
1014
  def_rows = db.run_read(GET_TABLE_DEFINING_FILES_QUERY, {"table_qualified": target})
908
- defining_files = _dedup_preserve_order([r["file_path"] for r in def_rows])
1015
+ producer_rows = db.run_read(GET_PRODUCER_FILES_FOR_TABLE_QUERY, {"table_qualified": target})
1016
+ defining_files = _dedup_preserve_order(
1017
+ [r["file_path"] for r in def_rows] + [r["file_path"] for r in producer_rows]
1018
+ )
909
1019
 
910
1020
  up_rows = db.run_read(
911
1021
  GET_TABLE_DIRECT_UPSTREAMS_QUERY,
@@ -921,7 +1031,13 @@ def get_change_scope(table_qualified: str) -> ChangeScopeResult:
921
1031
  affected_cols, _depth, truncated = _affected_columns_closure(db, start_cols, max_depth=None)
922
1032
 
923
1033
  affected_tables_all = [t for t in _rollup_to_tables(affected_cols) if t != target]
924
- affected_tables, noise_excluded = noise_filter.filter_nodes(affected_tables_all)
1034
+ affected_tables_noise_filtered, noise_excluded = noise_filter.filter_nodes(
1035
+ affected_tables_all
1036
+ )
1037
+ affected_tables, synthetic_excluded = _exclude_synthetic_tables(
1038
+ db, affected_tables_noise_filtered
1039
+ )
1040
+ noise_excluded = [*noise_excluded, *synthetic_excluded]
925
1041
  kept_tables = set(affected_tables)
926
1042
  affected_columns = [c for c in affected_cols if c.rsplit(".", 1)[0] in kept_tables]
927
1043
 
@@ -985,11 +1101,21 @@ def get_backfill_order(table_qualified: str) -> BackfillOrderResult:
985
1101
  affected_cols, _depth, truncated = _affected_columns_closure(db, start_cols, max_depth=None)
986
1102
 
987
1103
  affected_tables_all = [t for t in _rollup_to_tables(affected_cols) if t != target]
988
- affected_tables, noise_excluded = noise_filter.filter_nodes(affected_tables_all)
1104
+ affected_tables_noise_filtered, noise_excluded = noise_filter.filter_nodes(
1105
+ affected_tables_all
1106
+ )
1107
+ affected_tables, synthetic_excluded = _exclude_synthetic_tables(
1108
+ db, affected_tables_noise_filtered
1109
+ )
1110
+ noise_excluded = [*noise_excluded, *synthetic_excluded]
989
1111
  kept_tables = set(affected_tables)
990
1112
  affected_columns = [c for c in affected_cols if c.rsplit(".", 1)[0] in kept_tables]
991
1113
 
992
- order, had_cycle = _kahn_topological_sort(affected_tables, db)
1114
+ # Option A (issue #38): adjacency is derived from the same COLUMN_LINEAGE
1115
+ # closure as membership — pass the full id set (start + affected) so the
1116
+ # direct producer->consumer edge endpoints are both present.
1117
+ closure_col_ids = _dedup_preserve_order([*start_cols, *affected_cols])
1118
+ order, had_cycle = _kahn_topological_sort(affected_tables, db, closure_col_ids)
993
1119
 
994
1120
  hint = None
995
1121
  if had_cycle:
@@ -1044,19 +1170,26 @@ def diff_impact(changed_files: list[str]) -> DiffImpactResult:
1044
1170
  fp = Path(file_path)
1045
1171
  if not fp.is_absolute():
1046
1172
  fp = root / fp
1047
- rows = db.run_read(GET_TABLES_DEFINED_IN_FILE_QUERY, {"file_path": str(fp)})
1048
- for row in rows:
1049
- tq = row["table_qualified"]
1050
- if tq not in seen_changed:
1051
- seen_changed.add(tq)
1052
- changed_tables.append(tq)
1173
+ for query in (GET_TABLES_DEFINED_IN_FILE_QUERY, GET_TARGET_TABLES_FOR_FILE_QUERY):
1174
+ rows = db.run_read(query, {"file_path": str(fp)})
1175
+ for row in rows:
1176
+ tq = row["table_qualified"]
1177
+ if tq and tq not in seen_changed:
1178
+ seen_changed.add(tq)
1179
+ changed_tables.append(tq)
1053
1180
 
1054
1181
  all_affected_cols: list[str] = []
1055
1182
  affected_seen: set[str] = set()
1183
+ all_start_cols: list[str] = []
1184
+ start_seen: set[str] = set()
1056
1185
  truncated = False
1057
1186
  for tq in changed_tables:
1058
1187
  col_rows = db.run_read(GET_COLUMNS_FOR_TABLE_QUERY, {"table_qualified": tq})
1059
1188
  start_cols = [r["col_id"] for r in col_rows]
1189
+ for col in start_cols:
1190
+ if col not in start_seen:
1191
+ start_seen.add(col)
1192
+ all_start_cols.append(col)
1060
1193
  cols, _depth, tr = _affected_columns_closure(db, start_cols, max_depth=None)
1061
1194
  truncated = truncated or tr
1062
1195
  for col in cols:
@@ -1067,9 +1200,18 @@ def diff_impact(changed_files: list[str]) -> DiffImpactResult:
1067
1200
  affected_tables_all = [
1068
1201
  t for t in _rollup_to_tables(all_affected_cols) if t not in seen_changed
1069
1202
  ]
1070
- affected_tables, noise_excluded = noise_filter.filter_nodes(affected_tables_all)
1203
+ affected_tables_noise_filtered, noise_excluded = noise_filter.filter_nodes(
1204
+ affected_tables_all
1205
+ )
1206
+ affected_tables, synthetic_excluded = _exclude_synthetic_tables(
1207
+ db, affected_tables_noise_filtered
1208
+ )
1209
+ noise_excluded = [*noise_excluded, *synthetic_excluded]
1071
1210
  presentation_facing = [t for t in affected_tables if any(t.startswith(p) for p in prefixes)]
1072
- order, had_cycle = _kahn_topological_sort(affected_tables, db)
1211
+ # Option A (issue #38): same closure-derived adjacency as get_backfill_order —
1212
+ # union of every changed table's start columns plus the unioned affected set.
1213
+ closure_col_ids = _dedup_preserve_order([*all_start_cols, *all_affected_cols])
1214
+ order, had_cycle = _kahn_topological_sort(affected_tables, db, closure_col_ids)
1073
1215
 
1074
1216
  # Resolve external consumers for the blast radius in a single BATCH query (not per-table).
1075
1217
  external_consumers: list[str] = []
@@ -1530,13 +1672,22 @@ def db_info() -> DbInfoResult:
1530
1672
  those files. Table-level lineage is still available.
1531
1673
  - `warnings` list — empty means the graph is healthy.
1532
1674
 
1675
+ `sqlcg_version` is the installed *package* build (sqlcg.__version__) — use
1676
+ it to self-check you are talking to the build you expect (the MCP protocol
1677
+ `serverInfo.version` is not reliably visible to the agent persona). This is
1678
+ a different axis from `schema_version`, which tracks the graph schema, not
1679
+ the package: do not conflate the two.
1680
+
1533
1681
  Parse quality legend (parsing_mode per SqlQuery node):
1534
1682
  sqlglot — standard path; column lineage available if extracted
1535
1683
  scripting_block — tokenizer fallback; column lineage unavailable
1536
1684
 
1537
1685
  Returns:
1538
- DbInfoResult with schema version, node counts, parse quality, and warnings
1686
+ DbInfoResult with sqlcg/schema versions, node counts, parse quality,
1687
+ and warnings
1539
1688
  """
1689
+ from sqlcg import __version__
1690
+
1540
1691
  db = _get_backend()
1541
1692
 
1542
1693
  schema_version = db.get_schema_version() or "unknown"
@@ -1603,6 +1754,7 @@ def db_info() -> DbInfoResult:
1603
1754
  pass
1604
1755
 
1605
1756
  return DbInfoResult(
1757
+ sqlcg_version=__version__,
1606
1758
  schema_version=schema_version,
1607
1759
  node_counts=node_counts,
1608
1760
  column_lineage_edges=column_lineage_edges,