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/indexer/walker.py
CHANGED
|
@@ -40,6 +40,9 @@ def walk_sql_files(root: Path, spec: pathspec.PathSpec, use_git: bool = True) ->
|
|
|
40
40
|
Yields:
|
|
41
41
|
Path objects for .sql files not matching ignore patterns
|
|
42
42
|
"""
|
|
43
|
+
# Resolve once so both branches yield absolute paths and is_ignored's
|
|
44
|
+
# path.relative_to(root) never encounters a relative-vs-absolute mismatch.
|
|
45
|
+
root = Path(root).resolve()
|
|
43
46
|
if use_git:
|
|
44
47
|
git_files = _git_sql_files(root)
|
|
45
48
|
if git_files is not None:
|
sqlcg/parsers/ansi_parser.py
CHANGED
|
@@ -37,11 +37,44 @@ class AnsiParser(SqlParser):
|
|
|
37
37
|
"""
|
|
38
38
|
super().__init__(schema_resolver, schema_aliases=schema_aliases)
|
|
39
39
|
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _compute_start_lines(sql: str) -> list[int]:
|
|
42
|
+
"""Compute 1-based start line for each semicolon-delimited statement.
|
|
43
|
+
|
|
44
|
+
Uses the sqlglot tokenizer. Groups tokens by SEMICOLON boundaries and
|
|
45
|
+
returns the .line of the first token in each group. O(tokens), called
|
|
46
|
+
once per file before the statement loop.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
sql: SQL text to tokenize
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
List of 1-based line numbers, one per statement. May be shorter
|
|
53
|
+
than the parsed statement count if the trailing statement lacks a
|
|
54
|
+
semicolon — callers should guard with ``if stmt_index < len(lines)``.
|
|
55
|
+
"""
|
|
56
|
+
from sqlglot.tokens import Tokenizer, TokenType # type: ignore[attr-defined]
|
|
57
|
+
|
|
58
|
+
tokens = Tokenizer().tokenize(sql)
|
|
59
|
+
lines: list[int] = []
|
|
60
|
+
group_start: int | None = None
|
|
61
|
+
for tok in tokens:
|
|
62
|
+
if group_start is None and tok.token_type != TokenType.SEMICOLON:
|
|
63
|
+
group_start = tok.line
|
|
64
|
+
elif tok.token_type == TokenType.SEMICOLON:
|
|
65
|
+
if group_start is not None:
|
|
66
|
+
lines.append(group_start)
|
|
67
|
+
group_start = None
|
|
68
|
+
if group_start is not None: # last statement with no trailing semicolon
|
|
69
|
+
lines.append(group_start)
|
|
70
|
+
return lines
|
|
71
|
+
|
|
40
72
|
def parse_file(
|
|
41
73
|
self,
|
|
42
74
|
path: Path,
|
|
43
75
|
sql: str,
|
|
44
76
|
dependency_filter: set[str] | None = None,
|
|
77
|
+
_precomputed_start_lines: list[int] | None = None,
|
|
45
78
|
) -> ParsedFile:
|
|
46
79
|
"""Parse SQL file and extract table/column lineage.
|
|
47
80
|
|
|
@@ -54,6 +87,13 @@ class AnsiParser(SqlParser):
|
|
|
54
87
|
`None` to disable filtering; pass-2 callers
|
|
55
88
|
(`CrossFileAggregator.resolve_pass2`) compute this from the pass-1
|
|
56
89
|
`ParsedFile.referenced_tables`.
|
|
90
|
+
_precomputed_start_lines: optional list of 1-based start lines, one per
|
|
91
|
+
statement. When provided (e.g. by SnowflakeParser which computes the map
|
|
92
|
+
from the preprocessed SQL after ``_preprocess_snowflake_sql`` — which
|
|
93
|
+
preserves line count so preprocessed positions equal original-file
|
|
94
|
+
positions), this list is used directly and ``_compute_start_lines`` is
|
|
95
|
+
not called on the ``sql`` argument. When ``None``, ``_compute_start_lines
|
|
96
|
+
(sql)`` is called here.
|
|
57
97
|
|
|
58
98
|
Returns:
|
|
59
99
|
ParsedFile with parsed statements and metadata
|
|
@@ -68,6 +108,18 @@ class AnsiParser(SqlParser):
|
|
|
68
108
|
out.parse_quality = ParseQuality.FAILED
|
|
69
109
|
return out
|
|
70
110
|
|
|
111
|
+
# Compute 1-based start lines once per file, before the statement loop.
|
|
112
|
+
# When _precomputed_start_lines is provided (Snowflake non-scripting path),
|
|
113
|
+
# the caller has already computed the map from the preprocessed SQL.
|
|
114
|
+
# _preprocess_snowflake_sql preserves line count (lambda replacements emit
|
|
115
|
+
# the same number of newlines as the matched text), so preprocessed line
|
|
116
|
+
# positions equal original-file positions — no line-shift desync.
|
|
117
|
+
start_lines: list[int] = (
|
|
118
|
+
_precomputed_start_lines
|
|
119
|
+
if _precomputed_start_lines is not None
|
|
120
|
+
else self._compute_start_lines(sql)
|
|
121
|
+
)
|
|
122
|
+
|
|
71
123
|
# Check for pure-DDL files (still parse table definitions but skip lineage)
|
|
72
124
|
is_pure_ddl = self._is_pure_ddl_file(statements)
|
|
73
125
|
if is_pure_ddl:
|
|
@@ -128,6 +180,10 @@ class AnsiParser(SqlParser):
|
|
|
128
180
|
skip_column_lineage=is_pure_ddl,
|
|
129
181
|
schema_dict=schema_dict,
|
|
130
182
|
)
|
|
183
|
+
# Assign 1-based start line from the pre-computed map (0 if out of range)
|
|
184
|
+
query_node.start_line = (
|
|
185
|
+
start_lines[stmt_index] if stmt_index < len(start_lines) else 0
|
|
186
|
+
)
|
|
131
187
|
out.statements.append(query_node)
|
|
132
188
|
|
|
133
189
|
# Register CTAS bodies in sources_map for downstream temp table references
|
sqlcg/parsers/base.py
CHANGED
|
@@ -55,6 +55,7 @@ class TableRef:
|
|
|
55
55
|
db: str | None = None
|
|
56
56
|
name: str = ""
|
|
57
57
|
alias: str | None = None
|
|
58
|
+
role: str = "table"
|
|
58
59
|
|
|
59
60
|
def __post_init__(self) -> None:
|
|
60
61
|
"""Normalize identity components to lowercase.
|
|
@@ -207,6 +208,7 @@ class QueryNode:
|
|
|
207
208
|
confidence: Overall confidence score for this query's lineage
|
|
208
209
|
parsing_mode: How the query was parsed (e.g., "sqlglot", "fallback", "scripting")
|
|
209
210
|
defined_body: For CTAS statements, the exp.Select or exp.Subquery body being created
|
|
211
|
+
start_line: 1-based start line of statement in file; 0 = unknown (scripting-path sentinel)
|
|
210
212
|
"""
|
|
211
213
|
|
|
212
214
|
file: Path
|
|
@@ -223,6 +225,7 @@ class QueryNode:
|
|
|
223
225
|
star_sources: list[StarSource] = field(default_factory=list)
|
|
224
226
|
defined_columns: list[str] = field(default_factory=list)
|
|
225
227
|
defined_body: Any | None = None
|
|
228
|
+
start_line: int = 0 # 1-based start line of statement in file; 0 = unknown
|
|
226
229
|
|
|
227
230
|
|
|
228
231
|
@dataclass
|
|
@@ -497,7 +500,7 @@ class SqlParser(ABC):
|
|
|
497
500
|
src=ColumnRef(src_table_ref, src_col_name),
|
|
498
501
|
dst=ColumnRef(dst_tbl, dst_col_name),
|
|
499
502
|
transform="SELECT",
|
|
500
|
-
confidence=0
|
|
503
|
+
confidence=1.0,
|
|
501
504
|
)
|
|
502
505
|
)
|
|
503
506
|
except Exception as exc:
|
|
@@ -956,8 +959,8 @@ class SqlParser(ABC):
|
|
|
956
959
|
cte_alias = cte.alias
|
|
957
960
|
if not cte_alias:
|
|
958
961
|
continue
|
|
959
|
-
# Treat the CTE as a synthetic destination table
|
|
960
|
-
cte_dst_table = TableRef(name=cte_alias)
|
|
962
|
+
# Treat the CTE as a synthetic destination table (role="cte")
|
|
963
|
+
cte_dst_table = TableRef(name=cte_alias, role="cte")
|
|
961
964
|
# The CTE's body is its SELECT expression (or UNION ALL, etc.)
|
|
962
965
|
cte_body = cte.this
|
|
963
966
|
# Accept both Select and Union (UNION ALL / UNION DISTINCT)
|
|
@@ -79,10 +79,30 @@ class SnowflakeParser(AnsiParser):
|
|
|
79
79
|
# Check for scripting blocks
|
|
80
80
|
if self._has_scripting_block(sql):
|
|
81
81
|
logger.debug("Snowflake scripting block detected in %s, using DML extraction", path)
|
|
82
|
+
# Scripting path uses regex DML extraction; original line positions are lost.
|
|
83
|
+
# QueryNode.start_line defaults to 0 (unknown sentinel) for these nodes.
|
|
82
84
|
return self._parse_scripting_file(path, sql)
|
|
83
85
|
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
+
# Compute start-line map from the PREPROCESSED SQL.
|
|
87
|
+
# _preprocess_snowflake_sql preserves line count via lambda replacements:
|
|
88
|
+
# each stripped clause (UNPIVOT, WITH TAG, ALTER … SET TAG;) is replaced
|
|
89
|
+
# with exactly as many newlines as the matched text contained. A 3-line
|
|
90
|
+
# ALTER block becomes 3 blank lines — not a deletion — so the line numbers
|
|
91
|
+
# of all surviving statements in the preprocessed text still reflect their
|
|
92
|
+
# positions in the original file. Computing _compute_start_lines on the
|
|
93
|
+
# preprocessed SQL therefore gives correct original-file line numbers for
|
|
94
|
+
# all surviving statements, even when multi-line blocks were stripped.
|
|
95
|
+
#
|
|
96
|
+
# Passing the map explicitly avoids AnsiParser recomputing it from `sql`
|
|
97
|
+
# again (a no-op difference, but explicit is clearer and future-proof).
|
|
98
|
+
preprocessed_start_lines = AnsiParser._compute_start_lines(sql) # type: ignore[attr-defined]
|
|
99
|
+
return AnsiParser.parse_file( # type: ignore[call-arg]
|
|
100
|
+
self, # type: ignore[arg-type]
|
|
101
|
+
path,
|
|
102
|
+
sql,
|
|
103
|
+
dependency_filter=dependency_filter, # type: ignore[call-arg]
|
|
104
|
+
_precomputed_start_lines=preprocessed_start_lines, # type: ignore[call-arg]
|
|
105
|
+
)
|
|
86
106
|
|
|
87
107
|
@staticmethod
|
|
88
108
|
def _preprocess_snowflake_sql(sql: str) -> str:
|
|
@@ -94,11 +114,20 @@ class SnowflakeParser(AnsiParser):
|
|
|
94
114
|
3. Gap 4: Strip WITH TAG (...) clauses from column definitions
|
|
95
115
|
4. Gap 4b: Strip ALTER TABLE/VIEW ... MODIFY COLUMN ... SET TAG ...; statements
|
|
96
116
|
|
|
117
|
+
Line-count preservation contract:
|
|
118
|
+
Gaps 3, 4, and 4b replace matched text with the same number of newlines
|
|
119
|
+
the matched text contained (using a lambda replacement). The Gap 1
|
|
120
|
+
normalization is single-line → single-line and never changes newline count.
|
|
121
|
+
Together, these guarantees ensure that after pre-processing each surviving
|
|
122
|
+
statement occupies the same 1-based line number it had in the original file,
|
|
123
|
+
so ``_compute_start_lines`` called on the pre-processed SQL returns correct
|
|
124
|
+
original-file line numbers.
|
|
125
|
+
|
|
97
126
|
Args:
|
|
98
127
|
sql: Raw Snowflake SQL text
|
|
99
128
|
|
|
100
129
|
Returns:
|
|
101
|
-
Pre-processed SQL text
|
|
130
|
+
Pre-processed SQL text with line count preserved per the contract above.
|
|
102
131
|
"""
|
|
103
132
|
# Gap 1: CREATE TEMP/TEMPORARY TABLE name IF NOT EXISTS -> CREATE TEMPORARY TABLE name
|
|
104
133
|
# This regex matches CREATE TEMP[ORARY] TABLE name IF NOT EXISTS and drops the IF NOT EXISTS
|
|
@@ -112,13 +141,18 @@ class SnowflakeParser(AnsiParser):
|
|
|
112
141
|
# Gap 3: Strip UNPIVOT clauses if present
|
|
113
142
|
# UNPIVOT (...) [AS alias] — strip the entire clause
|
|
114
143
|
# Handle nested parentheses by counting parens
|
|
144
|
+
#
|
|
145
|
+
# Line-count preservation: replace the matched text with the same number
|
|
146
|
+
# of newlines it contained. This keeps all subsequent statement positions
|
|
147
|
+
# in the preprocessed SQL aligned with the original file line numbers so
|
|
148
|
+
# that _compute_start_lines returns correct original-file line numbers.
|
|
115
149
|
if "UNPIVOT" in sql.upper():
|
|
116
150
|
# Strategy: find UNPIVOT, then match everything until we close all parens
|
|
117
151
|
# Use a more permissive pattern: UNPIVOT\s*\([^)]+\([^)]*\)[^)]*\)
|
|
118
152
|
# This matches UNPIVOT (val FOR col IN (...)) AS alias
|
|
119
153
|
sql = re.sub(
|
|
120
154
|
r"\s+UNPIVOT\s*\([^)]*\([^)]*\)[^)]*\)\s*(?:AS\s+\w+)?",
|
|
121
|
-
"",
|
|
155
|
+
lambda m: "\n" * m.group(0).count("\n"),
|
|
122
156
|
sql,
|
|
123
157
|
flags=re.IGNORECASE,
|
|
124
158
|
)
|
|
@@ -135,10 +169,13 @@ class SnowflakeParser(AnsiParser):
|
|
|
135
169
|
# A column may carry multiple WITH TAG clauses (chained). Strip each
|
|
136
170
|
# occurrence independently; do not touch COMMENT or WITH MASKING
|
|
137
171
|
# POLICY clauses, which sqlglot already handles.
|
|
172
|
+
# Line-count preservation: replace the matched text with the same number
|
|
173
|
+
# of newlines it contained so that statement positions in the preprocessed
|
|
174
|
+
# SQL remain aligned with the original file (see Gap 3 note above).
|
|
138
175
|
if "WITH TAG" in sql.upper():
|
|
139
176
|
sql = re.sub(
|
|
140
177
|
r"\s+WITH\s+TAG\s*\((?:[^()]*|\([^()]*\))*\)",
|
|
141
|
-
"",
|
|
178
|
+
lambda m: "\n" * m.group(0).count("\n"),
|
|
142
179
|
sql,
|
|
143
180
|
flags=re.IGNORECASE,
|
|
144
181
|
)
|
|
@@ -146,10 +183,13 @@ class SnowflakeParser(AnsiParser):
|
|
|
146
183
|
# Gap 4b: Strip ALTER TABLE/VIEW ... MODIFY COLUMN ... SET TAG ...; statements.
|
|
147
184
|
# sqlglot emits these as exp.Command (unsupported syntax). They only carry tag
|
|
148
185
|
# metadata we don't model, so removing them is safe and cleans the error stream.
|
|
186
|
+
# Line-count preservation: replace the matched text with the same number
|
|
187
|
+
# of newlines it contained so that statement positions in the preprocessed
|
|
188
|
+
# SQL remain aligned with the original file (see Gap 3 note above).
|
|
149
189
|
if "SET TAG" in sql.upper():
|
|
150
190
|
sql = re.sub(
|
|
151
191
|
r"ALTER\s+(?:DYNAMIC\s+)?(?:TABLE|VIEW)\s+[^;]*?MODIFY\s+COLUMN[^;]*?SET\s+TAG[^;]*?;",
|
|
152
|
-
"",
|
|
192
|
+
lambda m: "\n" * m.group(0).count("\n"),
|
|
153
193
|
sql,
|
|
154
194
|
flags=re.IGNORECASE | re.DOTALL,
|
|
155
195
|
)
|
sqlcg/server/control.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Control-file helpers for the MCP server lifecycle.
|
|
2
|
+
|
|
3
|
+
Manages the ``.pid`` and ``.sock`` files that allow a second CLI process to
|
|
4
|
+
discover, query, and stop the running MCP server.
|
|
5
|
+
|
|
6
|
+
All paths are derived from ``get_db_path()`` (i.e. ``KuzuConfig.from_env().db_path``)
|
|
7
|
+
so that two servers on two different databases do not collide on a single control
|
|
8
|
+
file. Callers may pass an explicit ``db_path`` to override the default; this also
|
|
9
|
+
makes unit tests straightforward (set ``SQLCG_DB_PATH=/tmp/test.db``, assert paths
|
|
10
|
+
are ``/tmp/test.sock`` and ``/tmp/test.pid``).
|
|
11
|
+
|
|
12
|
+
Path convention::
|
|
13
|
+
|
|
14
|
+
graph.db → graph.db.sock (Unix domain socket, owner 0o600)
|
|
15
|
+
→ graph.db.pid (JSON record, owner 0o600)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from sqlcg.core.config import get_db_path
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Path helpers
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sock_path(db_path: Path | None = None) -> Path:
|
|
33
|
+
"""Return the Unix domain socket path for *db_path*.
|
|
34
|
+
|
|
35
|
+
Derives from ``get_db_path()`` when *db_path* is ``None``.
|
|
36
|
+
Example: ``~/.sqlcg/graph.db`` → ``~/.sqlcg/graph.db.sock``.
|
|
37
|
+
"""
|
|
38
|
+
p = db_path or get_db_path()
|
|
39
|
+
return p.with_suffix(".sock")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def pid_path(db_path: Path | None = None) -> Path:
|
|
43
|
+
"""Return the PID file path for *db_path*.
|
|
44
|
+
|
|
45
|
+
Derives from ``get_db_path()`` when *db_path* is ``None``.
|
|
46
|
+
Example: ``~/.sqlcg/graph.db`` → ``~/.sqlcg/graph.db.pid``.
|
|
47
|
+
"""
|
|
48
|
+
p = db_path or get_db_path()
|
|
49
|
+
return p.with_suffix(".pid")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# PID file management
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_pid(db_path: Path | None = None) -> None:
|
|
58
|
+
"""Write a JSON PID record: ``{pid, db_path, started_at}``.
|
|
59
|
+
|
|
60
|
+
The file is created with mode ``0o600`` (owner read/write only) to limit
|
|
61
|
+
access on multi-user machines.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
db_path: Explicit database path. Defaults to ``get_db_path()``.
|
|
65
|
+
"""
|
|
66
|
+
resolved = db_path or get_db_path()
|
|
67
|
+
pp = pid_path(resolved)
|
|
68
|
+
pp.write_text(
|
|
69
|
+
json.dumps(
|
|
70
|
+
{
|
|
71
|
+
"pid": os.getpid(),
|
|
72
|
+
"db_path": str(resolved),
|
|
73
|
+
"started_at": time.time(),
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
pp.chmod(0o600)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_pid(db_path: Path | None = None) -> dict | None:
|
|
81
|
+
"""Return the PID record dict, or ``None`` if the file is missing or corrupt.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
db_path: Explicit database path. Defaults to ``get_db_path()``.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Parsed JSON dict with keys ``pid``, ``db_path``, ``started_at``, or
|
|
88
|
+
``None`` on any read/parse error.
|
|
89
|
+
"""
|
|
90
|
+
pp = pid_path(db_path)
|
|
91
|
+
try:
|
|
92
|
+
return json.loads(pp.read_text())
|
|
93
|
+
except Exception:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Cleanup
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cleanup_control_files(db_path: Path | None = None) -> None:
|
|
103
|
+
"""Remove ``.sock`` and ``.pid`` files silently.
|
|
104
|
+
|
|
105
|
+
Called from ``server.main`` ``finally`` block so control files are always
|
|
106
|
+
removed on clean exit or on uncaught exception.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
db_path: Explicit database path. Defaults to ``get_db_path()``.
|
|
110
|
+
"""
|
|
111
|
+
for p in (sock_path(db_path), pid_path(db_path)):
|
|
112
|
+
try:
|
|
113
|
+
p.unlink()
|
|
114
|
+
except FileNotFoundError:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Process liveness
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def is_pid_alive(pid: int) -> bool:
|
|
124
|
+
"""Return ``True`` if a process with *pid* currently exists.
|
|
125
|
+
|
|
126
|
+
Uses ``os.kill(pid, 0)`` — signal 0 checks existence without sending a
|
|
127
|
+
real signal. Returns ``False`` if the process does not exist
|
|
128
|
+
(``ProcessLookupError``) or is owned by another user (``PermissionError``
|
|
129
|
+
means it exists but we cannot signal it, so we treat it as alive).
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
pid: Process ID to probe.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
``True`` if the process exists, ``False`` if it does not.
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
os.kill(pid, 0)
|
|
139
|
+
return True
|
|
140
|
+
except ProcessLookupError:
|
|
141
|
+
return False
|
|
142
|
+
except PermissionError:
|
|
143
|
+
# Process exists but owned by someone else — treat as alive
|
|
144
|
+
return True
|
sqlcg/server/models.py
CHANGED
|
@@ -56,6 +56,18 @@ class LineageNode(BaseModel):
|
|
|
56
56
|
)
|
|
57
57
|
file: str | None = Field(None, description="Source file path, if applicable")
|
|
58
58
|
confidence: float | None = Field(None, description="Confidence score 0.0-1.0")
|
|
59
|
+
line: int | None = Field(None, description="1-based start line of the producing statement")
|
|
60
|
+
expression: str | None = Field(
|
|
61
|
+
None, description="SQL text of the producing statement (truncated)"
|
|
62
|
+
)
|
|
63
|
+
reason: str | None = Field(
|
|
64
|
+
None,
|
|
65
|
+
description="Set only when confidence < 1.0; why the edge is inferred",
|
|
66
|
+
)
|
|
67
|
+
table_kind: str | None = Field(
|
|
68
|
+
None,
|
|
69
|
+
description="Structural role of the source table: 'table', 'cte', 'derived', or 'external'",
|
|
70
|
+
)
|
|
59
71
|
|
|
60
72
|
|
|
61
73
|
class LineageEdge(BaseModel):
|
|
@@ -195,6 +207,12 @@ class DbInfoResult(BaseModel):
|
|
|
195
207
|
default_factory=list,
|
|
196
208
|
description="Health warnings. Empty means the graph is in a healthy state.",
|
|
197
209
|
)
|
|
210
|
+
indexed_sha: str | None = Field(None, description="Git SHA of the last index run")
|
|
211
|
+
head_sha: str | None = Field(None, description="Current HEAD SHA of the indexed repo")
|
|
212
|
+
stale_by_commits: int | None = Field(
|
|
213
|
+
None, description="Commits HEAD is ahead of indexed_sha (0 = up to date)"
|
|
214
|
+
)
|
|
215
|
+
dirty: bool = Field(False, description="True if working tree has uncommitted changes")
|
|
198
216
|
|
|
199
217
|
|
|
200
218
|
class DefinitionFile(BaseModel):
|
|
@@ -312,6 +330,13 @@ class DiffImpactResult(BaseModel):
|
|
|
312
330
|
default_factory=list,
|
|
313
331
|
description="Affected tables in topological rebuild order across the union blast radius.",
|
|
314
332
|
)
|
|
333
|
+
external_consumers: list[str] = Field(
|
|
334
|
+
default_factory=list,
|
|
335
|
+
description=(
|
|
336
|
+
"Declared external consumers attached to any table in the blast radius "
|
|
337
|
+
"(via CONSUMED_BY edges). Empty when no consumers are configured."
|
|
338
|
+
),
|
|
339
|
+
)
|
|
315
340
|
noise_excluded: list[str] = Field(
|
|
316
341
|
default_factory=list, description="Affected tables excluded as backup/noise."
|
|
317
342
|
)
|
|
@@ -320,6 +345,11 @@ class DiffImpactResult(BaseModel):
|
|
|
320
345
|
None, description="Diagnostic hint when result is empty or approximate."
|
|
321
346
|
)
|
|
322
347
|
|
|
348
|
+
@property
|
|
349
|
+
def presentation(self) -> list[str]:
|
|
350
|
+
"""Alias for presentation_facing (backward-compatible accessor)."""
|
|
351
|
+
return self.presentation_facing
|
|
352
|
+
|
|
323
353
|
|
|
324
354
|
class ScopeChangeResult(BaseModel):
|
|
325
355
|
"""Result of scope_change — single-call synthesis of everything an LLM needs
|
|
@@ -376,6 +406,36 @@ class UnusedCandidate(BaseModel):
|
|
|
376
406
|
)
|
|
377
407
|
|
|
378
408
|
|
|
409
|
+
class PresentationCandidate(BaseModel):
|
|
410
|
+
"""A terminal/egress table that has no in-corpus consumer BY DESIGN.
|
|
411
|
+
|
|
412
|
+
Surfaced separately from dead_code: its lack of within-corpus consumers is the
|
|
413
|
+
defining property of a declared presentation/egress layer, not evidence it is dead.
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
table_qualified: str = Field(..., description="Qualified table name (schema.table).")
|
|
417
|
+
within_corpus_references: int = Field(
|
|
418
|
+
default=0,
|
|
419
|
+
description="FACT: count of SELECTS_FROM consumers in the indexed corpus (always 0 here).",
|
|
420
|
+
)
|
|
421
|
+
matched_prefix: str = Field(
|
|
422
|
+
...,
|
|
423
|
+
description="FACT: the configured [sqlcg.presentation] schema_prefix this table matched.",
|
|
424
|
+
)
|
|
425
|
+
reason: str = Field(
|
|
426
|
+
default="leaf in a declared egress layer; expected to have no in-corpus consumer",
|
|
427
|
+
description="Why this table is segregated from dead_code rather than flagged.",
|
|
428
|
+
)
|
|
429
|
+
has_external_consumer: bool = Field(
|
|
430
|
+
default=False,
|
|
431
|
+
description=(
|
|
432
|
+
"FACT: True when at least one declared external consumer is attached "
|
|
433
|
+
"to this table via a CONSUMED_BY edge. Distinguishes a provable egress "
|
|
434
|
+
"point from a candidate orphan in the egress layer."
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
379
439
|
class UnusedTablesResult(BaseModel):
|
|
380
440
|
"""Result of analyze_unused — tables with no within-corpus consumers."""
|
|
381
441
|
|
|
@@ -383,6 +443,14 @@ class UnusedTablesResult(BaseModel):
|
|
|
383
443
|
default_factory=list,
|
|
384
444
|
description="Tables with zero SELECTS_FROM consumers (noise-filtered).",
|
|
385
445
|
)
|
|
446
|
+
presentation_facing: list[PresentationCandidate] = Field(
|
|
447
|
+
default_factory=list,
|
|
448
|
+
description=(
|
|
449
|
+
"Terminal/egress leaves matched by [sqlcg.presentation] prefixes; "
|
|
450
|
+
"expected to have no in-corpus consumer (NOT dead code). Empty when no "
|
|
451
|
+
"presentation prefix is configured."
|
|
452
|
+
),
|
|
453
|
+
)
|
|
386
454
|
total_tables_scanned: int = Field(
|
|
387
455
|
0, description="FACT: total SqlTable nodes in the graph at time of scan."
|
|
388
456
|
)
|