sql-code-graph 0.3.0__py3-none-any.whl → 1.0.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-0.3.0.dist-info → sql_code_graph-1.0.0.dist-info}/METADATA +87 -9
- sql_code_graph-1.0.0.dist-info/RECORD +63 -0
- sqlcg/__init__.py +1 -1
- sqlcg/cli/commands/analyze.py +24 -0
- sqlcg/cli/commands/db.py +40 -7
- sqlcg/cli/commands/gain.py +5 -17
- sqlcg/cli/commands/git.py +71 -40
- sqlcg/cli/commands/index.py +122 -17
- sqlcg/cli/commands/install.py +147 -8
- sqlcg/cli/commands/mcp.py +12 -0
- sqlcg/cli/commands/reindex.py +170 -0
- sqlcg/cli/commands/uninstall.py +94 -39
- sqlcg/cli/commands/watch.py +14 -1
- sqlcg/cli/main.py +8 -0
- sqlcg/core/config.py +185 -2
- sqlcg/core/graph_db.py +65 -0
- sqlcg/core/kuzu_backend.py +177 -6
- sqlcg/core/neo4j_backend.py +38 -0
- sqlcg/core/queries.cypher +114 -0
- sqlcg/core/queries.py +44 -82
- sqlcg/core/schema.cypher +15 -3
- sqlcg/core/schema.py +2 -1
- sqlcg/indexer/error_classify.py +140 -0
- sqlcg/indexer/git_delta.py +121 -0
- sqlcg/indexer/indexer.py +952 -125
- sqlcg/indexer/pool.py +446 -0
- sqlcg/indexer/walker.py +1 -3
- sqlcg/indexer/watcher.py +68 -18
- sqlcg/lineage/aggregator.py +58 -2
- sqlcg/lineage/schema_resolver.py +26 -14
- sqlcg/parsers/ansi_parser.py +195 -26
- sqlcg/parsers/base.py +609 -59
- sqlcg/parsers/bigquery_parser.py +7 -2
- sqlcg/parsers/postgres_parser.py +7 -2
- sqlcg/parsers/registry.py +7 -2
- sqlcg/parsers/snowflake_parser.py +170 -8
- sqlcg/parsers/tsql_parser.py +7 -2
- sqlcg/server/models.py +297 -4
- sqlcg/server/noise_filter.py +167 -0
- sqlcg/server/skill.py +256 -0
- sqlcg/server/tools.py +934 -178
- sql_code_graph-0.3.0.dist-info/RECORD +0 -56
- {sql_code_graph-0.3.0.dist-info → sql_code_graph-1.0.0.dist-info}/WHEEL +0 -0
- {sql_code_graph-0.3.0.dist-info → sql_code_graph-1.0.0.dist-info}/entry_points.txt +0 -0
sqlcg/cli/commands/index.py
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
"""Index command for scanning and indexing SQL files."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
import typer
|
|
6
7
|
from rich.console import Console
|
|
8
|
+
from rich.progress import (
|
|
9
|
+
BarColumn,
|
|
10
|
+
MofNCompleteColumn,
|
|
11
|
+
Progress,
|
|
12
|
+
SpinnerColumn,
|
|
13
|
+
TextColumn,
|
|
14
|
+
TimeRemainingColumn,
|
|
15
|
+
)
|
|
7
16
|
|
|
8
17
|
from sqlcg.core.config import get_backend, get_db_path, get_dialect
|
|
9
18
|
from sqlcg.indexer.indexer import Indexer
|
|
@@ -20,26 +29,53 @@ def index_cmd( # noqa: B008
|
|
|
20
29
|
None, "--dbt-manifest", help="Path to dbt manifest"
|
|
21
30
|
),
|
|
22
31
|
timeout_per_file: int = typer.Option( # noqa: B008
|
|
23
|
-
|
|
32
|
+
5, "--timeout-per-file", help="Timeout per file in seconds"
|
|
24
33
|
),
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
buffer_pool_size: int = typer.Option( # noqa: B008
|
|
35
|
+
0,
|
|
36
|
+
"--buffer-pool-size",
|
|
37
|
+
help="KuzuDB buffer pool size in MB (0 = default). "
|
|
38
|
+
"Set to 256-512 on memory-constrained machines.",
|
|
39
|
+
),
|
|
40
|
+
batch_size: int = typer.Option( # noqa: B008
|
|
41
|
+
50,
|
|
42
|
+
"--batch-size",
|
|
43
|
+
help=(
|
|
44
|
+
"Files per KuzuDB transaction in the upsert pass. "
|
|
45
|
+
"Default 50 balances commit-overhead reduction (vs. legacy per-file commits) "
|
|
46
|
+
"against per-batch memory cost. Lower values are safer for memory-constrained "
|
|
47
|
+
"machines; higher values give marginal speedup at the cost of larger working sets. "
|
|
48
|
+
"Set to 1 to reproduce legacy per-file commit behaviour."
|
|
49
|
+
),
|
|
27
50
|
),
|
|
28
|
-
|
|
29
|
-
|
|
51
|
+
no_ddl: bool = typer.Option( # noqa: B008
|
|
52
|
+
False, "--no-ddl", help="Skip table-node upserts for DDL-only files"
|
|
30
53
|
),
|
|
31
54
|
quiet: bool = typer.Option( # noqa: B008
|
|
32
55
|
False, "--quiet", "-q", help="Suppress summary console output"
|
|
33
56
|
),
|
|
57
|
+
debug: bool = typer.Option( # noqa: B008
|
|
58
|
+
False, "--debug", help="Show detailed log output during indexing"
|
|
59
|
+
),
|
|
60
|
+
profile: bool = typer.Option( # noqa: B008
|
|
61
|
+
False, "--profile/--no-profile", help="Emit per-stage timing after indexing"
|
|
62
|
+
),
|
|
34
63
|
) -> None:
|
|
35
|
-
"""Index SQL files in a directory.
|
|
36
|
-
if schema_from_info_schema:
|
|
37
|
-
console.print("[red]--schema-from-info-schema is not yet implemented (v2)[/red]")
|
|
38
|
-
raise typer.Exit(1)
|
|
64
|
+
"""Index SQL files in a directory.
|
|
39
65
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
66
|
+
Schema aliases (staging schema → canonical schema) can be configured in
|
|
67
|
+
.sqlcg.toml under sqlcg.schema_aliases, e.g. da_tmp = "da".
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
import logging
|
|
71
|
+
|
|
72
|
+
level = logging.DEBUG if debug else logging.CRITICAL
|
|
73
|
+
logging.getLogger("sqlcg").setLevel(level)
|
|
74
|
+
logging.getLogger("sqlglot").setLevel(level)
|
|
75
|
+
|
|
76
|
+
# Set buffer pool size via env var if specified
|
|
77
|
+
if buffer_pool_size > 0:
|
|
78
|
+
os.environ["SQLCG_BUFFER_POOL_MB"] = str(buffer_pool_size)
|
|
43
79
|
|
|
44
80
|
# Resolve dialect: 'auto' reads from .sqlcg.toml, otherwise use provided value
|
|
45
81
|
if dialect == "auto":
|
|
@@ -51,8 +87,16 @@ def index_cmd( # noqa: B008
|
|
|
51
87
|
with get_backend() as backend:
|
|
52
88
|
backend.init_schema()
|
|
53
89
|
|
|
54
|
-
#
|
|
55
|
-
from sqlcg.core.schema import NodeLabel
|
|
90
|
+
# Check schema version — must match current build
|
|
91
|
+
from sqlcg.core.schema import SCHEMA_VERSION, NodeLabel
|
|
92
|
+
|
|
93
|
+
stored = backend.get_schema_version()
|
|
94
|
+
if stored != SCHEMA_VERSION:
|
|
95
|
+
console.print(
|
|
96
|
+
f"[red]Database schema is v{stored}; this build requires v{SCHEMA_VERSION}. "
|
|
97
|
+
"Run 'sqlcg db reset && sqlcg db init && sqlcg index <path>' to re-index.[/red]"
|
|
98
|
+
)
|
|
99
|
+
raise typer.Exit(1)
|
|
56
100
|
|
|
57
101
|
abs_path = str(path.resolve())
|
|
58
102
|
backend.upsert_node(
|
|
@@ -66,7 +110,32 @@ def index_cmd( # noqa: B008
|
|
|
66
110
|
|
|
67
111
|
# Index the repository
|
|
68
112
|
indexer = Indexer()
|
|
69
|
-
|
|
113
|
+
|
|
114
|
+
with Progress(
|
|
115
|
+
SpinnerColumn(),
|
|
116
|
+
TextColumn("[progress.description]{task.description}"),
|
|
117
|
+
BarColumn(),
|
|
118
|
+
MofNCompleteColumn(),
|
|
119
|
+
TimeRemainingColumn(),
|
|
120
|
+
console=console,
|
|
121
|
+
redirect_stderr=True,
|
|
122
|
+
) as progress:
|
|
123
|
+
task = progress.add_task("Parsing", total=None)
|
|
124
|
+
|
|
125
|
+
def _progress_callback(n: int, total_n: int) -> None:
|
|
126
|
+
progress.update(task, completed=n, total=total_n)
|
|
127
|
+
|
|
128
|
+
summary = indexer.index_repo(
|
|
129
|
+
path,
|
|
130
|
+
dialect,
|
|
131
|
+
backend,
|
|
132
|
+
dbt_manifest,
|
|
133
|
+
timeout_per_file,
|
|
134
|
+
progress_callback=_progress_callback,
|
|
135
|
+
no_ddl=no_ddl,
|
|
136
|
+
batch_size=batch_size,
|
|
137
|
+
profile=profile,
|
|
138
|
+
)
|
|
70
139
|
|
|
71
140
|
# Connect files to repo
|
|
72
141
|
from sqlcg.core.schema import RelType
|
|
@@ -85,13 +154,49 @@ def index_cmd( # noqa: B008
|
|
|
85
154
|
|
|
86
155
|
# Print summary unless --quiet is specified
|
|
87
156
|
if not quiet:
|
|
157
|
+
quality = summary.get("quality", {})
|
|
158
|
+
err = summary.get("error_summary", {})
|
|
159
|
+
n_full = quality.get("full", 0)
|
|
160
|
+
n_partial = quality.get("table_only", 0)
|
|
161
|
+
n_ddl = quality.get("scripting_fallback", 0)
|
|
162
|
+
n_failed = quality.get("failed", 0)
|
|
163
|
+
n_timeout = err.get("timeout", 0)
|
|
164
|
+
|
|
165
|
+
quality_parts = []
|
|
166
|
+
if n_full:
|
|
167
|
+
quality_parts.append(f"[green]{n_full} with column lineage[/green]")
|
|
168
|
+
if n_partial:
|
|
169
|
+
quality_parts.append(f"[yellow]{n_partial} table-only[/yellow]")
|
|
170
|
+
if n_ddl:
|
|
171
|
+
quality_parts.append(f"{n_ddl} DDL-only")
|
|
172
|
+
if n_timeout:
|
|
173
|
+
quality_parts.append(f"[red]{n_timeout} timed out[/red]")
|
|
174
|
+
if n_failed:
|
|
175
|
+
quality_parts.append(f"[red]{n_failed} failed[/red]")
|
|
176
|
+
|
|
88
177
|
console.print(
|
|
89
178
|
f"[green]Indexed[/green] {summary['files_parsed']} files — "
|
|
90
|
-
f"{summary['tables_found']} tables, {summary['lineage_edges_created']} edges
|
|
91
|
-
f"{summary['parse_errors']} errors"
|
|
179
|
+
f"{summary['tables_found']} tables, {summary['lineage_edges_created']} edges"
|
|
92
180
|
)
|
|
181
|
+
if quality_parts:
|
|
182
|
+
console.print(" " + " · ".join(quality_parts))
|
|
93
183
|
if summary.get("lineage_edges_created", 0) == 0:
|
|
94
184
|
console.print(
|
|
95
185
|
"[yellow]Warning: 0 lineage edges extracted — column lineage "
|
|
96
186
|
"unavailable.[/yellow]"
|
|
97
187
|
)
|
|
188
|
+
|
|
189
|
+
if prof := summary.get("profile"):
|
|
190
|
+
console.print("\n[bold]Profile[/bold]")
|
|
191
|
+
console.print(
|
|
192
|
+
f" pass1 parse: {prof['pass1_parse_s']:.2f}s\n"
|
|
193
|
+
f" pass2 resolve: {prof['pass2_resolve_s']:.2f}s\n"
|
|
194
|
+
f" upsert: {prof['upsert_s']:.2f}s\n"
|
|
195
|
+
f" star expand: {prof['star_expand_s']:.2f}s\n"
|
|
196
|
+
f" total: {prof['total_s']:.2f}s\n"
|
|
197
|
+
f" ms/file: {prof['ms_per_file']:.1f}ms ({prof['files']} files)"
|
|
198
|
+
)
|
|
199
|
+
if prof["slowest_files"]:
|
|
200
|
+
console.print(" [bold]Slowest files[/bold]")
|
|
201
|
+
for file_path, ms in prof["slowest_files"]:
|
|
202
|
+
console.print(f" {ms:8.1f}ms {file_path}")
|
sqlcg/cli/commands/install.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import shutil
|
|
6
|
+
import sys
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
import typer
|
|
@@ -16,12 +17,34 @@ _SERVER_KEY = "sql-code-graph"
|
|
|
16
17
|
|
|
17
18
|
def install_cmd(
|
|
18
19
|
dry_run: bool = typer.Option(False, "--dry-run", help="Print config without writing"),
|
|
20
|
+
scope: str | None = typer.Option( # noqa: B008
|
|
21
|
+
None,
|
|
22
|
+
"--scope",
|
|
23
|
+
help="Install skill location: 'project' (under --repo) or 'global' (~/.claude/skills/).",
|
|
24
|
+
),
|
|
25
|
+
repo: Path | None = typer.Option( # noqa: B008
|
|
26
|
+
None,
|
|
27
|
+
"--repo",
|
|
28
|
+
help="Repository root for --scope project (default: current directory).",
|
|
29
|
+
),
|
|
19
30
|
) -> None:
|
|
20
|
-
"""Register sqlcg as an MCP server in Claude Code (~/.claude/settings.json).
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
"""Register sqlcg as an MCP server in Claude Code (~/.claude/settings.json).
|
|
32
|
+
|
|
33
|
+
Also provisions a Claude skill file (SKILL.md) at the chosen location.
|
|
34
|
+
Pass --scope project or --scope global to specify where the skill is written.
|
|
35
|
+
On a TTY without --scope, an interactive prompt asks for the location.
|
|
36
|
+
On a non-TTY (CI, scripts) without --scope, the command exits with an error.
|
|
37
|
+
"""
|
|
38
|
+
# Resolve --scope: explicit flag, TTY prompt, or non-TTY error
|
|
39
|
+
resolved_scope = _resolve_scope(scope)
|
|
40
|
+
|
|
41
|
+
if shutil.which("sqlcg"):
|
|
42
|
+
entry: dict = {"command": "sqlcg", "args": ["mcp", "start"]}
|
|
43
|
+
elif shutil.which("uvx"):
|
|
44
|
+
entry = {"command": "uvx", "args": ["sql-code-graph", "mcp", "start"]}
|
|
23
45
|
else:
|
|
24
|
-
|
|
46
|
+
console.print("[red]Error:[/red] Neither 'sqlcg' nor 'uvx' found on PATH.")
|
|
47
|
+
raise typer.Exit(1)
|
|
25
48
|
|
|
26
49
|
settings_path = _SETTINGS_PATH
|
|
27
50
|
if settings_path.exists():
|
|
@@ -38,18 +61,31 @@ def install_cmd(
|
|
|
38
61
|
|
|
39
62
|
mcp_servers: dict = settings.setdefault("mcpServers", {})
|
|
40
63
|
|
|
41
|
-
|
|
64
|
+
existing_entry = mcp_servers.get(_SERVER_KEY)
|
|
65
|
+
if existing_entry == entry:
|
|
42
66
|
cmd_str = f"{entry['command']} {' '.join(entry['args'])}"
|
|
67
|
+
console.print(f"[green]Already configured:[/green] {_SERVER_KEY} → {cmd_str}")
|
|
68
|
+
# Still provision the skill even when MCP entry already exists
|
|
69
|
+
_provision_skill(resolved_scope, repo, dry_run)
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
# Print upgrade notice if switching from uvx to sqlcg
|
|
73
|
+
if (
|
|
74
|
+
existing_entry
|
|
75
|
+
and existing_entry.get("command") == "uvx"
|
|
76
|
+
and entry.get("command") == "sqlcg"
|
|
77
|
+
):
|
|
43
78
|
console.print(
|
|
44
|
-
|
|
79
|
+
"[blue]Updating[/blue] MCP entry from [dim]uvx[/dim] to local "
|
|
80
|
+
"[green]sqlcg[/green] binary (faster startup). Writing…"
|
|
45
81
|
)
|
|
46
|
-
return
|
|
47
82
|
|
|
48
83
|
mcp_servers[_SERVER_KEY] = entry
|
|
49
84
|
|
|
50
85
|
if dry_run is True:
|
|
51
86
|
console.print("[dim]--dry-run: would write:[/dim]")
|
|
52
87
|
console.print_json(json.dumps(settings, indent=2))
|
|
88
|
+
_provision_skill(resolved_scope, repo, dry_run)
|
|
53
89
|
return
|
|
54
90
|
|
|
55
91
|
try:
|
|
@@ -65,10 +101,113 @@ def install_cmd(
|
|
|
65
101
|
console.print(f"[dim]Written to {settings_path}[/dim]")
|
|
66
102
|
|
|
67
103
|
# Note about cold cache if uvx was chosen
|
|
68
|
-
if entry
|
|
104
|
+
if entry.get("command") == "uvx":
|
|
69
105
|
console.print(
|
|
70
106
|
"[yellow]Note:[/yellow] First startup downloads dependencies (~30s). "
|
|
71
107
|
"Subsequent restarts use cache (~1s)."
|
|
72
108
|
)
|
|
73
109
|
|
|
74
110
|
console.print("\nRestart Claude Code to pick up the new MCP server.")
|
|
111
|
+
|
|
112
|
+
# Provision the skill file
|
|
113
|
+
_provision_skill(resolved_scope, repo, dry_run)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _resolve_scope(scope: str | None) -> str:
|
|
117
|
+
"""Resolve the install scope from the --scope flag, TTY prompt, or error.
|
|
118
|
+
|
|
119
|
+
- If scope is provided and valid, return it.
|
|
120
|
+
- If scope is None and stdin is a TTY, prompt interactively.
|
|
121
|
+
- If scope is None and stdin is not a TTY, exit with a clear error.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
scope: Value of the --scope CLI option (None if omitted).
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
"project" or "global".
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
typer.Exit: if scope is invalid or cannot be determined.
|
|
131
|
+
"""
|
|
132
|
+
valid = {"project", "global"}
|
|
133
|
+
|
|
134
|
+
if scope is not None:
|
|
135
|
+
if scope not in valid:
|
|
136
|
+
console.print(
|
|
137
|
+
f"[red]Error:[/red] --scope must be 'project' or 'global', got: {scope!r}"
|
|
138
|
+
)
|
|
139
|
+
raise typer.Exit(1)
|
|
140
|
+
return scope
|
|
141
|
+
|
|
142
|
+
# scope is None — check if stdin is a TTY
|
|
143
|
+
if sys.stdin.isatty():
|
|
144
|
+
choice = typer.prompt(
|
|
145
|
+
"Install skill location",
|
|
146
|
+
default="project",
|
|
147
|
+
prompt_suffix=" [project/global]: ",
|
|
148
|
+
show_default=False,
|
|
149
|
+
)
|
|
150
|
+
if choice not in valid:
|
|
151
|
+
console.print(
|
|
152
|
+
f"[red]Error:[/red] Invalid choice {choice!r}. Must be 'project' or 'global'."
|
|
153
|
+
)
|
|
154
|
+
raise typer.Exit(1)
|
|
155
|
+
return choice
|
|
156
|
+
|
|
157
|
+
# Non-TTY without --scope — error, do not guess
|
|
158
|
+
console.print(
|
|
159
|
+
"[red]Error:[/red] --scope is required in non-interactive mode. "
|
|
160
|
+
"Pass --scope project or --scope global."
|
|
161
|
+
)
|
|
162
|
+
raise typer.Exit(1)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _provision_skill(scope: str, repo: Path | None, dry_run: bool) -> None:
|
|
166
|
+
"""Write (or describe) the sqlcg SKILL.md file at the chosen location.
|
|
167
|
+
|
|
168
|
+
Uses the .tmp + os.replace atomic write idiom. Idempotent: overwrites
|
|
169
|
+
the sqlcg-owned SKILL.md on each install. If a SKILL.md exists at the
|
|
170
|
+
target path without the sqlcg frontmatter marker ('name: sqlcg'), warns
|
|
171
|
+
and skips to avoid clobbering a foreign file.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
scope: "project" or "global".
|
|
175
|
+
repo: Repo root for project scope (defaults to Path.cwd()).
|
|
176
|
+
dry_run: If True, print a note but write nothing.
|
|
177
|
+
"""
|
|
178
|
+
from sqlcg import __version__
|
|
179
|
+
from sqlcg.server.skill import render_skill
|
|
180
|
+
|
|
181
|
+
# Resolve scope root
|
|
182
|
+
if scope == "global":
|
|
183
|
+
scope_root = Path.home()
|
|
184
|
+
else:
|
|
185
|
+
scope_root = repo if repo is not None else Path.cwd()
|
|
186
|
+
|
|
187
|
+
skill_dir = scope_root / ".claude" / "skills" / "sqlcg"
|
|
188
|
+
skill_path = skill_dir / "SKILL.md"
|
|
189
|
+
|
|
190
|
+
if dry_run:
|
|
191
|
+
console.print(f"[dim]--dry-run: would write skill to {skill_path}[/dim]")
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
# Foreign-file guard: if SKILL.md exists but does not carry sqlcg frontmatter, warn+skip
|
|
195
|
+
if skill_path.exists():
|
|
196
|
+
existing = skill_path.read_text()
|
|
197
|
+
if "name: sqlcg" not in existing:
|
|
198
|
+
console.print(
|
|
199
|
+
f"[yellow]Warning:[/yellow] {skill_path} exists but was not created by sqlcg "
|
|
200
|
+
"(missing 'name: sqlcg' in frontmatter). Skipping to avoid overwriting."
|
|
201
|
+
)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
skill_content = render_skill(__version__)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
tmp = skill_path.with_suffix(".tmp")
|
|
209
|
+
tmp.write_text(skill_content)
|
|
210
|
+
os.replace(tmp, skill_path)
|
|
211
|
+
console.print(f"[green]Skill written:[/green] {skill_path}")
|
|
212
|
+
except (OSError, TypeError, AttributeError) as exc:
|
|
213
|
+
console.print(f"[yellow]Warning:[/yellow] Failed to write skill file: {exc}")
|
sqlcg/cli/commands/mcp.py
CHANGED
|
@@ -53,3 +53,15 @@ def mcp_start() -> None:
|
|
|
53
53
|
from sqlcg.server.server import main as server_main
|
|
54
54
|
|
|
55
55
|
server_main()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("best-practices")
|
|
59
|
+
def mcp_best_practices() -> None:
|
|
60
|
+
"""Print MCP tool best-practices (the fact/heuristic boundary).
|
|
61
|
+
|
|
62
|
+
Same guidance as the bundled Claude skill — useful for humans or agents
|
|
63
|
+
that have not installed the skill.
|
|
64
|
+
"""
|
|
65
|
+
from sqlcg.server.skill import render_body
|
|
66
|
+
|
|
67
|
+
typer.echo(render_body())
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Reindex command — incremental resync from a git delta.
|
|
2
|
+
|
|
3
|
+
Two modes:
|
|
4
|
+
sqlcg reindex --from <sha> --to <sha> <root>
|
|
5
|
+
Explicit SHAs: call resync_changed(root, from, to, ...).
|
|
6
|
+
sqlcg reindex <root>
|
|
7
|
+
Standalone "catch up": read last-indexed SHA from graph metadata,
|
|
8
|
+
diff against current HEAD. If no stored SHA, do a full index_repo.
|
|
9
|
+
|
|
10
|
+
Gates on schema version like the watch command (same reset+reinit message).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def reindex_cmd( # noqa: B008
|
|
23
|
+
path: Path = typer.Argument(..., help="Repository root directory to resync"), # noqa: B008
|
|
24
|
+
from_sha: str | None = typer.Option( # noqa: B008
|
|
25
|
+
None, "--from", help="Base git SHA (previously-indexed state)"
|
|
26
|
+
),
|
|
27
|
+
to_sha: str | None = typer.Option( # noqa: B008
|
|
28
|
+
None, "--to", help="Target git SHA (defaults to HEAD when --from is given)"
|
|
29
|
+
),
|
|
30
|
+
dialect: str | None = typer.Option( # noqa: B008
|
|
31
|
+
None, "--dialect", "-d", help="SQL dialect (or 'auto' to read from .sqlcg.toml)"
|
|
32
|
+
),
|
|
33
|
+
quiet: bool = typer.Option( # noqa: B008
|
|
34
|
+
False, "--quiet", "-q", help="Suppress summary output"
|
|
35
|
+
),
|
|
36
|
+
batch_size: int = typer.Option( # noqa: B008
|
|
37
|
+
50,
|
|
38
|
+
"--batch-size",
|
|
39
|
+
help="Files per KuzuDB transaction (same default as index command)",
|
|
40
|
+
),
|
|
41
|
+
timeout_per_file: int = typer.Option( # noqa: B008
|
|
42
|
+
5,
|
|
43
|
+
"--timeout-per-file",
|
|
44
|
+
help="Per-file parse timeout in seconds",
|
|
45
|
+
),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Incrementally resync the graph after a git branch change or pull.
|
|
48
|
+
|
|
49
|
+
When --from and --to are given (e.g. from the post-checkout hook), only the
|
|
50
|
+
files that changed between those two SHAs are re-parsed, plus the cross-file
|
|
51
|
+
pass-2 closure (files that SELECT FROM tables defined in changed files).
|
|
52
|
+
|
|
53
|
+
Without --from/--to, reads the last-indexed SHA from the database and diffs it
|
|
54
|
+
against the current HEAD. If no stored SHA is found, falls back to a full index.
|
|
55
|
+
|
|
56
|
+
Exits with an error if the database schema version does not match the current
|
|
57
|
+
build — run 'sqlcg db reset && sqlcg db init && sqlcg index <path>' to re-init.
|
|
58
|
+
"""
|
|
59
|
+
from sqlcg.core.config import get_backend, get_db_path, get_dialect
|
|
60
|
+
from sqlcg.core.schema import SCHEMA_VERSION
|
|
61
|
+
from sqlcg.indexer.indexer import Indexer
|
|
62
|
+
|
|
63
|
+
# Resolve dialect
|
|
64
|
+
if dialect == "auto":
|
|
65
|
+
dialect = get_dialect(path)
|
|
66
|
+
|
|
67
|
+
db_path = get_db_path()
|
|
68
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
with get_backend() as backend:
|
|
71
|
+
backend.init_schema()
|
|
72
|
+
|
|
73
|
+
# Schema-version gate (mirrors watch.py)
|
|
74
|
+
stored_version = backend.get_schema_version()
|
|
75
|
+
if stored_version != SCHEMA_VERSION:
|
|
76
|
+
console.print(
|
|
77
|
+
f"[red]Database schema is v{stored_version}; "
|
|
78
|
+
f"this build requires v{SCHEMA_VERSION}. "
|
|
79
|
+
"Run 'sqlcg db reset && sqlcg db init && sqlcg index <path>' "
|
|
80
|
+
"to re-initialize.[/red]"
|
|
81
|
+
)
|
|
82
|
+
raise typer.Exit(1)
|
|
83
|
+
|
|
84
|
+
indexer = Indexer()
|
|
85
|
+
|
|
86
|
+
# ---- Determine mode -------------------------------------------------------
|
|
87
|
+
if from_sha is not None:
|
|
88
|
+
# Explicit-SHA mode
|
|
89
|
+
effective_to = to_sha or _get_head(path)
|
|
90
|
+
if not quiet:
|
|
91
|
+
console.print(
|
|
92
|
+
f"Resyncing [cyan]{path}[/cyan] [dim]{from_sha[:8]}..{effective_to[:8]}[/dim]"
|
|
93
|
+
)
|
|
94
|
+
summary = indexer.resync_changed(
|
|
95
|
+
path,
|
|
96
|
+
from_sha,
|
|
97
|
+
effective_to,
|
|
98
|
+
backend,
|
|
99
|
+
dialect,
|
|
100
|
+
batch_size=batch_size,
|
|
101
|
+
timeout_per_file=timeout_per_file,
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
# Standalone mode: use stored SHA -> HEAD
|
|
105
|
+
old = backend.get_indexed_sha()
|
|
106
|
+
if old is None:
|
|
107
|
+
if not quiet:
|
|
108
|
+
console.print(
|
|
109
|
+
"[yellow]No stored index SHA found — performing full index.[/yellow]"
|
|
110
|
+
)
|
|
111
|
+
indexer.index_repo(
|
|
112
|
+
path,
|
|
113
|
+
dialect,
|
|
114
|
+
backend,
|
|
115
|
+
batch_size=batch_size,
|
|
116
|
+
timeout_per_file=timeout_per_file,
|
|
117
|
+
)
|
|
118
|
+
if not quiet:
|
|
119
|
+
console.print("[green]Full index complete.[/green]")
|
|
120
|
+
return
|
|
121
|
+
new = _get_head(path)
|
|
122
|
+
if not quiet:
|
|
123
|
+
console.print(f"Resyncing [cyan]{path}[/cyan] [dim]{old[:8]}..{new[:8]}[/dim]")
|
|
124
|
+
summary = indexer.resync_changed(
|
|
125
|
+
path,
|
|
126
|
+
old,
|
|
127
|
+
new,
|
|
128
|
+
backend,
|
|
129
|
+
dialect,
|
|
130
|
+
batch_size=batch_size,
|
|
131
|
+
timeout_per_file=timeout_per_file,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# ---- Print summary --------------------------------------------------------
|
|
135
|
+
if not quiet:
|
|
136
|
+
if summary.get("fell_back_to_full"):
|
|
137
|
+
console.print(
|
|
138
|
+
"[yellow]Closure exceeded depth cap — fell back to full index.[/yellow]"
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
console.print(
|
|
142
|
+
f"[green]Resynced[/green] "
|
|
143
|
+
f"+{summary['added']} added, "
|
|
144
|
+
f"~{summary['modified']} modified, "
|
|
145
|
+
f"-{summary['deleted']} deleted, "
|
|
146
|
+
f"{summary['closure_resolved']} closure files re-resolved"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _get_head(root: Path) -> str:
|
|
151
|
+
"""Return the current HEAD SHA for the git repo at root.
|
|
152
|
+
|
|
153
|
+
Raises typer.Exit(1) if git is unavailable or root is not a git repo.
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
result = subprocess.run(
|
|
157
|
+
["git", "rev-parse", "HEAD"],
|
|
158
|
+
cwd=str(root),
|
|
159
|
+
capture_output=True,
|
|
160
|
+
text=True,
|
|
161
|
+
)
|
|
162
|
+
if result.returncode != 0:
|
|
163
|
+
console.print(
|
|
164
|
+
f"[red]Could not determine HEAD SHA in {root}: {result.stderr.strip()}[/red]"
|
|
165
|
+
)
|
|
166
|
+
raise typer.Exit(1)
|
|
167
|
+
return result.stdout.strip()
|
|
168
|
+
except FileNotFoundError:
|
|
169
|
+
console.print("[red]git is not available — cannot determine HEAD SHA[/red]")
|
|
170
|
+
raise typer.Exit(1) from None
|