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.
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:
@@ -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.7,
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
- # Otherwise use standard ANSI parsing with Snowflake dialect
85
- return AnsiParser.parse_file(self, path, sql, dependency_filter=dependency_filter) # type: ignore
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
  )
@@ -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
  )