Files
L4XB ab80fa7413 fix(rust): extract module-level static and const declarations
The Rust extractor gated every non-function item on struct_item,
enum_item and trait_item, so tree-sitter-rust's static_item and
const_item never produced a node: a constant existed in the graph only
through the files that referenced it, never from the Rust that defines
it (0 of 783 names in a jdx/mise snapshot).

Add a branch for both node types: a module-level declaration gets a
node with a file-level contains edge, an associated const inside an
impl is attributed to the impl like a method, and the declared type is
referenced the way a struct field type is.

Fixes #3471

(cherry picked from commit 70ff464064)
2026-09-10 22:44:38 +01:00

1420 lines
62 KiB
Python

"""Tests for multi-language AST extraction: JS/TS, Go, Rust, SQL."""
from __future__ import annotations
import shutil
from pathlib import Path
import pytest
from graphify.extract import extract_js, extract_go, extract_rust, extract, extract_sql
FIXTURES = Path(__file__).parent / "fixtures"
# ── helpers ──────────────────────────────────────────────────────────────────
def _labels(result):
return [n["label"] for n in result["nodes"]]
def _call_pairs(result):
node_by_id = {n["id"]: n["label"] for n in result["nodes"]}
return {
(node_by_id.get(e["source"], e["source"]), node_by_id.get(e["target"], e["target"]))
for e in result["edges"] if e["relation"] == "calls"
}
def _confidences(result):
return {e["confidence"] for e in result["edges"]}
def _edges_with_relation(result, *relations):
return [e for e in result["edges"] if e["relation"] in relations]
def _normalize_symbol_label(label: str) -> str:
return label.strip("()").lstrip(".")
def _edge_labels(result, relation, context=None):
labels = {n["id"]: _normalize_symbol_label(n["label"]) for n in result["nodes"]}
pairs = set()
for e in result["edges"]:
if e.get("relation") != relation:
continue
if context is not None and e.get("context") != context:
continue
pairs.add((labels.get(e["source"], e["source"]), labels.get(e["target"], e["target"])))
return pairs
# ── TypeScript ────────────────────────────────────────────────────────────────
def test_ts_finds_class():
r = extract_js(FIXTURES / "sample.ts")
assert "error" not in r
assert "HttpClient" in _labels(r)
def test_ts_finds_methods():
r = extract_js(FIXTURES / "sample.ts")
labels = _labels(r)
assert any("get" in l for l in labels)
assert any("post" in l for l in labels)
def test_ts_finds_function():
r = extract_js(FIXTURES / "sample.ts")
assert any("buildHeaders" in l for l in _labels(r))
def test_ts_emits_calls():
r = extract_js(FIXTURES / "sample.ts")
calls = _call_pairs(r)
# .post() calls .get()
assert any("post" in src and "get" in tgt for src, tgt in calls)
def test_ts_calls_are_extracted():
r = extract_js(FIXTURES / "sample.ts")
for e in r["edges"]:
if e["relation"] == "calls":
assert e["confidence"] == "EXTRACTED"
def test_ts_import_edges_have_import_context():
r = extract_js(FIXTURES / "sample.ts")
import_edges = _edges_with_relation(r, "imports", "imports_from")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
def test_ts_call_edges_have_call_context():
r = extract_js(FIXTURES / "sample.ts")
call_edges = _edges_with_relation(r, "calls")
assert call_edges
assert all(e.get("context") == "call" for e in call_edges)
def test_ts_no_dangling_edges():
r = extract_js(FIXTURES / "sample.ts")
node_ids = {n["id"] for n in r["nodes"]}
for e in r["edges"]:
if e["relation"] in ("contains", "method", "calls"):
assert e["source"] in node_ids
# ── Go ────────────────────────────────────────────────────────────────────────
def test_go_finds_struct():
r = extract_go(FIXTURES / "sample.go")
assert "error" not in r
assert "Server" in _labels(r)
def test_go_finds_methods():
r = extract_go(FIXTURES / "sample.go")
labels = _labels(r)
assert any("Start" in l for l in labels)
assert any("Stop" in l for l in labels)
def test_go_finds_constructor():
r = extract_go(FIXTURES / "sample.go")
assert any("NewServer" in l for l in _labels(r))
def test_go_emits_calls():
r = extract_go(FIXTURES / "sample.go")
# main() calls NewServer and Start
assert len(_call_pairs(r)) > 0
def test_go_has_extracted_calls():
r = extract_go(FIXTURES / "sample.go")
assert "EXTRACTED" in _confidences(r)
def test_go_import_edges_have_import_context():
r = extract_go(FIXTURES / "sample.go")
import_edges = _edges_with_relation(r, "imports", "imports_from")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
def test_go_call_edges_have_call_context():
r = extract_go(FIXTURES / "sample.go")
call_edges = _edges_with_relation(r, "calls")
assert call_edges
assert all(e.get("context") == "call" for e in call_edges)
def test_go_no_dangling_edges():
r = extract_go(FIXTURES / "sample.go")
node_ids = {n["id"] for n in r["nodes"]}
for e in r["edges"]:
if e["relation"] in ("contains", "method", "calls"):
assert e["source"] in node_ids
def test_go_embeds_struct_field():
r = extract_go(FIXTURES / "sample.go")
assert ("DataProcessor", "BaseProcessor") in _edge_labels(r, "embeds")
def test_go_interface_embedding_emits_embeds():
r = extract_go(FIXTURES / "sample.go")
assert ("ReaderLogger", "Logger") in _edge_labels(r, "embeds")
def test_go_struct_named_field_emits_field_context():
r = extract_go(FIXTURES / "sample.go")
assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")
def test_go_method_parameter_return_contexts():
r = extract_go(FIXTURES / "sample.go")
assert ("Build", "DataProcessor") in _edge_labels(r, "references", "parameter_type")
assert ("Build", "Result") in _edge_labels(r, "references", "return_type")
def test_go_method_declaration_emits_refs_only_when_name_present():
"""Regression: review feedback flagged a hypothetical UnboundLocalError in
extract_go's method_declaration branch if `name_node` were None. Statically
verify that every use of `method_nid` (and the `emit_go_method_refs` call
that consumes it) is guarded by a `name_node` truthiness check — either
nested inside `if name_node:` or following an early `if not name_node: return`.
Same for function_declaration and `func_nid`.
"""
import ast
import inspect
from graphify.extract import extract_go
tree = ast.parse(inspect.getsource(extract_go))
def _find_branch(root: ast.AST, type_literal: str) -> ast.If | None:
"""Return the `if t == '<type_literal>':` branch inside the walk function."""
for child in ast.walk(root):
if (isinstance(child, ast.If)
and isinstance(child.test, ast.Compare)
and isinstance(child.test.left, ast.Name)
and child.test.left.id == "t"
and len(child.test.comparators) == 1
and isinstance(child.test.comparators[0], ast.Constant)
and child.test.comparators[0].value == type_literal):
return child
return None
method_branch = _find_branch(tree, "method_declaration")
function_branch = _find_branch(tree, "function_declaration")
assert method_branch is not None, "method_declaration branch not found in extract_go"
assert function_branch is not None, "function_declaration branch not found in extract_go"
def _is_early_return_on_falsy_name_node(stmt: ast.AST) -> bool:
"""True iff `stmt` is `if not name_node: return` (or raise/continue/break)."""
if not isinstance(stmt, ast.If):
return False
test = stmt.test
is_falsy_check = (
isinstance(test, ast.UnaryOp)
and isinstance(test.op, ast.Not)
and isinstance(test.operand, ast.Name)
and test.operand.id == "name_node"
)
if not is_falsy_check:
return False
terminators = (ast.Return, ast.Raise, ast.Continue, ast.Break)
return any(isinstance(s, terminators) for s in stmt.body)
def _guarded_by_name_node(branch: ast.If, var_name: str) -> bool:
"""True iff every read of `var_name` in `branch` is guarded by a
`name_node` truthiness check — either lexically nested under
`if name_node:` or after a preceding `if not name_node: return`."""
parents: dict[int, ast.AST] = {}
for parent in ast.walk(branch):
for child in ast.iter_child_nodes(parent):
parents[id(child)] = parent
def _stmt_chain(start: ast.AST) -> list[tuple[ast.stmt, list[ast.stmt]]]:
"""Walk up to each enclosing statement-list, returning (stmt, siblings)."""
chain: list[tuple[ast.stmt, list[ast.stmt]]] = []
cur: ast.AST | None = start
while cur is not None:
parent = parents.get(id(cur))
if parent is None:
break
if isinstance(cur, ast.stmt):
for attr in ("body", "orelse", "finalbody"):
siblings = getattr(parent, attr, None)
if isinstance(siblings, list) and cur in siblings:
chain.append((cur, siblings))
break
cur = parent
return chain
def _is_guarded(use: ast.AST) -> bool:
for stmt, siblings in _stmt_chain(use):
parent = parents.get(id(stmt))
# Case 1: lexically nested under `if name_node:` body
if (isinstance(parent, ast.If)
and isinstance(parent.test, ast.Name)
and parent.test.id == "name_node"
and stmt in parent.body):
return True
# Case 2: a preceding sibling is `if not name_node: return`
idx = siblings.index(stmt)
if any(_is_early_return_on_falsy_name_node(s) for s in siblings[:idx]):
return True
return False
for node in ast.walk(branch):
if isinstance(node, ast.Name) and node.id == var_name:
if not _is_guarded(node):
return False
return True
assert _guarded_by_name_node(method_branch, "method_nid"), (
"method_nid use is not guarded by a name_node check in method_declaration branch"
)
assert _guarded_by_name_node(function_branch, "func_nid"), (
"func_nid use is not guarded by a name_node check in function_declaration branch"
)
# Negative control: confirm the checker would actually reject the buggy
# layout the reviewer described. A `method_nid` reference dangling without
# any name_node guard must be caught.
bad_source = (
"def walk(node):\n"
" if t == 'method_declaration':\n"
" name_node = node.child_by_field_name('name')\n"
" if name_node:\n"
" method_nid = make_id('x')\n"
" emit_go_method_refs(node, method_nid, 1)\n"
" return\n"
)
bad_tree = ast.parse(bad_source)
bad_branch = _find_branch(bad_tree, "method_declaration")
assert bad_branch is not None
assert not _guarded_by_name_node(bad_branch, "method_nid"), (
"checker should reject method_nid used without a name_node guard"
)
# ── Rust ──────────────────────────────────────────────────────────────────────
def test_rust_finds_struct():
r = extract_rust(FIXTURES / "sample.rs")
assert "error" not in r
assert "Graph" in _labels(r)
def test_rust_finds_impl_methods():
r = extract_rust(FIXTURES / "sample.rs")
labels = _labels(r)
assert any("add_node" in l for l in labels)
assert any("add_edge" in l for l in labels)
def test_rust_finds_function():
r = extract_rust(FIXTURES / "sample.rs")
assert any("build_graph" in l for l in _labels(r))
def test_rust_emits_calls():
r = extract_rust(FIXTURES / "sample.rs")
calls = _call_pairs(r)
assert any("build_graph" in src for src, _ in calls)
def test_rust_calls_are_extracted():
r = extract_rust(FIXTURES / "sample.rs")
for e in r["edges"]:
if e["relation"] == "calls":
assert e["confidence"] == "EXTRACTED"
def test_rust_finds_static_and_const_items():
r = extract_rust(FIXTURES / "sample.rs")
by_id = {n["id"]: n["label"] for n in r["nodes"]}
labels = set(by_id.values())
for name in ("RETRY_LIMIT", "DEFAULT_MODE", "NODE_LIMIT"):
assert name in labels, name
assert any(
e["relation"] == "contains" and by_id.get(e["target"]) == name
for e in r["edges"]
), f"{name} has no file-level contains edge"
# An associated const is attributed to its impl, like a method.
assert ".CAPACITY" in labels
assert any(
e["relation"] == "contains"
and by_id.get(e["source"]) == "Graph"
and by_id.get(e["target"]) == ".CAPACITY"
for e in r["edges"]
)
# The declared type is referenced like a struct field type.
assert any(
e["relation"] == "references"
and by_id.get(e["source"]) == "NODE_LIMIT"
and by_id.get(e["target"]) == "Limit"
for e in r["edges"]
)
def test_rust_import_edges_have_import_context():
r = extract_rust(FIXTURES / "sample.rs")
import_edges = _edges_with_relation(r, "imports", "imports_from")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
def test_rust_call_edges_have_call_context():
r = extract_rust(FIXTURES / "sample.rs")
call_edges = _edges_with_relation(r, "calls")
assert call_edges
assert all(e.get("context") == "call" for e in call_edges)
def test_rust_no_dangling_edges():
r = extract_rust(FIXTURES / "sample.rs")
node_ids = {n["id"] for n in r["nodes"]}
for e in r["edges"]:
if e["relation"] in ("contains", "method", "calls"):
assert e["source"] in node_ids
def test_rust_trait_impl_emits_implements():
r = extract_rust(FIXTURES / "sample.rs")
assert ("DataProcessor", "Processor") in _edge_labels(r, "implements")
def test_rust_trait_method_declarations_are_captured():
# #3366: signature-only trait method declarations (and default-bodied ones)
# were dropped because trait bodies were never walked. Both `Processor.run`
# and `Logger.log` are declaration-only trait methods in the fixture.
r = extract_rust(FIXTURES / "sample.rs")
method_pairs = _edge_labels(r, "method")
assert ("Processor", "run") in method_pairs
assert ("Logger", "log") in method_pairs
def test_rust_trait_decl_method_is_distinct_from_impl_method():
# #3366: the trait-declared `Processor.run` must be its own node, not collide
# onto the impl `DataProcessor.run` — the crux of the fix.
r = extract_rust(FIXTURES / "sample.rs")
by_id = {n["id"]: n["label"] for n in r["nodes"]}
run_targets = {
e["target"]
for e in r["edges"]
if e["relation"] == "method"
and _normalize_symbol_label(by_id.get(e["target"], "")) == "run"
}
# one `run()` declared in `trait Processor`, one defined in the impl — two ids
assert len(run_targets) >= 2
def test_rust_supertrait_emits_inherits():
r = extract_rust(FIXTURES / "sample.rs")
assert ("Logger", "Processor") in _edge_labels(r, "inherits")
def test_rust_enum_variant_references():
"""Enum variant payload types must emit `references` edges.
Tuple variants (`Click(T)`) and struct variants (`Resize { x: T }`) nest
their field types under enum_variant_list -> enum_variant; that path was
never traversed, so every enum-variant type reference was dropped.
"""
r = extract_rust(FIXTURES / "sample.rs")
refs = _edge_labels(r, "references")
assert ("GraphEvent", "Graph") in refs, "tuple-variant reference missing"
assert ("GraphEvent", "DataProcessor") in refs, "struct-variant reference missing"
def test_rust_struct_field_emits_field_context():
r = extract_rust(FIXTURES / "sample.rs")
assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")
assert ("DataProcessor", "DataProcessor") not in _edge_labels(r, "references", "field")
def test_rust_tuple_struct_field_references():
"""Tuple struct fields (`struct Wrapper(A, B);`) nest their positional types
under ordered_field_declaration_list with no field_declaration wrapper -- the
same shape as tuple enum variants. That path was not traversed for structs, so
tuple-struct field type references were silently dropped.
"""
r = extract_rust(FIXTURES / "sample.rs")
field_refs = _edge_labels(r, "references", "field")
assert ("GraphPair", "Graph") in field_refs, "tuple-struct field reference missing"
assert ("GraphPair", "Result") in field_refs, "tuple-struct generic field reference missing"
assert ("GraphPair", "DataProcessor") in _edge_labels(r, "references", "generic_arg")
def test_rust_method_parameter_return_and_generic_contexts():
r = extract_rust(FIXTURES / "sample.rs")
assert ("build", "DataProcessor") in _edge_labels(r, "references", "parameter_type")
assert ("build", "Result") in _edge_labels(r, "references", "return_type")
assert ("build", "DataProcessor") in _edge_labels(r, "references", "generic_arg")
def test_rust_no_cross_crate_spurious_edges():
"""Scoped calls (Type::method) and blocklisted names must not produce
INFERRED cross-crate calls edges (#908)."""
from graphify.extract import extract
crate_a = FIXTURES / "crate_a" / "src" / "lib.rs"
crate_b = FIXTURES / "crate_b" / "src" / "lib.rs"
r = extract([crate_a, crate_b])
node_ids_a = {n["id"] for n in r["nodes"] if "crate_a" in (n.get("source_file") or "")}
node_ids_b = {n["id"] for n in r["nodes"] if "crate_b" in (n.get("source_file") or "")}
# No calls edge should cross from crate_b into crate_a
cross_crate_calls = [
e for e in r["edges"]
if e["relation"] == "calls"
and e["source"] in node_ids_b
and e["target"] in node_ids_a
]
assert cross_crate_calls == [], (
f"Spurious cross-crate edges: {cross_crate_calls}"
)
# ── extract() dispatch ────────────────────────────────────────────────────────
def test_extract_dispatches_all_languages():
files = [
FIXTURES / "sample.py",
FIXTURES / "sample.ts",
FIXTURES / "sample.go",
FIXTURES / "sample.rs",
]
r = extract(files)
source_files = {n["source_file"] for n in r["nodes"] if n["source_file"]}
# All four files should contribute nodes
assert any("sample.py" in f for f in source_files)
assert any("sample.ts" in f for f in source_files)
assert any("sample.go" in f for f in source_files)
assert any("sample.rs" in f for f in source_files)
# ── Cache ─────────────────────────────────────────────────────────────────────
def test_cache_hit_returns_same_result(tmp_path):
src = FIXTURES / "sample.py"
dst = tmp_path / "sample.py"
dst.write_bytes(src.read_bytes())
r1 = extract([dst])
r2 = extract([dst])
assert len(r1["nodes"]) == len(r2["nodes"])
assert len(r1["edges"]) == len(r2["edges"])
def test_cache_miss_after_file_change(tmp_path):
dst = tmp_path / "a.py"
dst.write_text("def foo(): pass\n")
r1 = extract([dst])
dst.write_text("def foo(): pass\ndef bar(): pass\n")
r2 = extract([dst])
# bar() should appear in the second result
labels2 = [n["label"] for n in r2["nodes"]]
assert any("bar" in l for l in labels2)
# ── SQL ───────────────────────────────────────────────────────────────────────
def _extract_sql_or_skip(fixture: str = "sample.sql"):
pytest.importorskip("tree_sitter_sql")
return extract_sql(FIXTURES / fixture)
def test_sql_finds_tables():
r = _extract_sql_or_skip()
labels = [n["label"] for n in r["nodes"]]
assert any("users" in l for l in labels)
assert any("organizations" in l for l in labels)
def test_sql_create_table_inside_transaction_block():
"""#2953: DDL wrapped in BEGIN; ... COMMIT; must emit table nodes."""
r = _extract_sql_or_skip("sample_transaction.sql")
labels = [n["label"] for n in r["nodes"]]
assert any("alfa" in l for l in labels)
assert any("gamma" in l for l in labels)
assert any("delta" in l for l in labels)
refs = [
(e["source"], e["target"])
for e in r["edges"]
if e["relation"] == "references"
]
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
assert any(
"delta" in node_by_id.get(s, "") and "alfa" in node_by_id.get(t, "")
for s, t in refs
)
def test_sql_finds_view():
r = _extract_sql_or_skip()
labels = [n["label"] for n in r["nodes"]]
assert any("active_users" in l for l in labels)
def test_sql_finds_function():
r = _extract_sql_or_skip()
labels = [n["label"] for n in r["nodes"]]
assert any("get_user" in l for l in labels)
def test_sql_emits_foreign_key_edge():
r = _extract_sql_or_skip()
relations = {e["relation"] for e in r["edges"]}
assert "references" in relations
def test_sql_emits_reads_from_edge():
r = _extract_sql_or_skip()
relations = {e["relation"] for e in r["edges"]}
assert "reads_from" in relations
def test_sql_no_dangling_edges():
r = _extract_sql_or_skip()
node_ids = {n["id"] for n in r["nodes"]}
for e in r["edges"]:
assert e["source"] in node_ids, f"dangling source: {e['source']}"
def test_sql_create_index_emits_index_node_linked_to_its_table(tmp_path):
"""#3467: CREATE [UNIQUE] INDEX parsed fine but the walk never dispatched
on create_index, so every index was silently dropped."""
pytest.importorskip("tree_sitter_sql")
p = tmp_path / "schema.sql"
p.write_text(
"CREATE TABLE public.profiles (id uuid PRIMARY KEY, owner uuid NOT NULL);\n"
"CREATE INDEX profiles_owner_idx ON public.profiles (owner);\n"
"CREATE UNIQUE INDEX IF NOT EXISTS profiles_id_uniq ON public.profiles (id);\n"
"CREATE INDEX CONCURRENTLY orders_customer_idx ON public.orders (customer_id);\n"
'CREATE INDEX "quoted idx" ON public.profiles (owner);\n'
"CREATE INDEX ON public.profiles (owner);\n",
encoding="utf-8",
)
r = extract_sql(p)
by_label = {n["label"]: n for n in r["nodes"]}
for name in ("profiles_owner_idx", "profiles_id_uniq", "orders_customer_idx", "quoted idx"):
assert name in by_label, name
assert by_label[name]["source_file"] == str(p)
edges = {(e["source"], e["relation"], e["target"]) for e in r["edges"]}
profiles = by_label["public.profiles"]["id"]
assert (by_label["profiles_owner_idx"]["id"], "indexes", profiles) in edges
assert (by_label["profiles_id_uniq"]["id"], "indexes", profiles) in edges
assert (by_label["quoted idx"]["id"], "indexes", profiles) in edges
# An index on a table defined in another file links to a sourceless stub,
# the same way a trigger does (#2324).
orders = by_label["public.orders"]
assert orders["source_file"] == ""
assert (by_label["orders_customer_idx"]["id"], "indexes", orders["id"]) in edges
# The unnamed index is skipped and nothing dangles.
node_ids = {n["id"] for n in r["nodes"]}
assert all(e["source"] in node_ids and e["target"] in node_ids for e in r["edges"])
assert sum(1 for e in r["edges"] if e["relation"] == "indexes") == 4
def test_sql_tsql_bracketed_procedure_is_recovered(tmp_path):
"""T-SQL CREATE PROCEDURE [Schema].[Name] ... AS BEGIN...END.
The grammar has no create_procedure parse for T-SQL's AS BEGIN...END body
idiom, so the statement lands in ERROR recovery — where a name pattern
without a bracket-delimited alternative recovered nothing. In a T-SQL
codebase that brackets every identifier (a common house standard), that
made every stored procedure invisible.
"""
pytest.importorskip("tree_sitter_sql")
p = tmp_path / "proc.sql"
p.write_text(
"CREATE PROCEDURE [dbo].[usp_LoadDebtors]\n"
" @BatchId INT\n"
"AS\n"
"BEGIN\n"
" SET NOCOUNT ON;\n"
" INSERT INTO dbo.Debtors (Id) SELECT Id FROM staging.Debtors;\n"
"END;\n"
)
r = extract_sql(p)
routine = [n["label"] for n in r["nodes"] if n["label"] != "proc.sql"]
assert routine == ["[dbo].[usp_LoadDebtors]()"], routine
def test_sql_tsql_create_or_alter_procedure_is_recovered(tmp_path):
"""T-SQL spells idempotent re-creation CREATE OR ALTER (no OR REPLACE)."""
pytest.importorskip("tree_sitter_sql")
p = tmp_path / "proc.sql"
p.write_text(
"CREATE OR ALTER PROCEDURE [Utils].[ValidateSourceView]\n"
"AS\nBEGIN\n SELECT 1;\nEND;\n"
"CREATE OR ALTER PROCEDURE usp_Bare AS\nBEGIN\n SELECT 1;\nEND;\n"
)
r = extract_sql(p)
labels = sorted(n["label"] for n in r["nodes"] if n["label"] != "proc.sql")
assert labels == ["[Utils].[ValidateSourceView]()", "usp_Bare()"], labels
def test_sql_escaped_closing_bracket_in_routine_name_is_consumed(tmp_path):
"""T-SQL escapes a literal ] inside a bracketed identifier by doubling it:
[a]]b] names the identifier a]b. A pattern that stops at the first ]
truncated the name to [dbo].[a] — a phantom that could collide with a
genuinely-named [dbo].[a]."""
pytest.importorskip("tree_sitter_sql")
p = tmp_path / "proc.sql"
p.write_text("CREATE PROCEDURE [dbo].[a]]b]\nAS\nBEGIN\n SELECT 1;\nEND;\n")
r = extract_sql(p)
routine = [n["label"] for n in r["nodes"] if n["label"] != "proc.sql"]
assert routine == ["[dbo].[a]]b]()"], routine
def test_sql_recovery_sites_agree_on_the_captured_name(tmp_path):
"""A mixed-delimiter name (dbo.[usp_Mixed]) must yield exactly ONE node.
The walk-time ERROR scan and the whole-file has_error fallback recover the
same statement; when their patterns drifted, they captured different names
(`dbo.[usp_Mixed]` vs `dbo`), minted different ids, and _add_node's
id-dedupe never fired — one procedure became two nodes, one of them a
phantom named after the schema. Sharing _ROUTINE_RECOVERY_RX makes the
drift impossible; this pins the observable behaviour.
"""
pytest.importorskip("tree_sitter_sql")
p = tmp_path / "proc.sql"
p.write_text("CREATE PROCEDURE dbo.[usp_Mixed] AS\nBEGIN\n SELECT 1;\nEND;\n")
r = extract_sql(p)
routine = [n["label"] for n in r["nodes"] if n["label"] != "proc.sql"]
assert routine == ["dbo.[usp_Mixed]()"], routine
def test_sql_tsql_proc_shorthand_is_recovered(tmp_path):
"""T-SQL's official PROC shorthand, including CREATE OR ALTER PROC with a
qualified bracket-delimited name, must recover like the long form."""
pytest.importorskip("tree_sitter_sql")
p = tmp_path / "proc.sql"
p.write_text(
"CREATE OR ALTER PROC [dbo].[usp_Short]\n"
"AS\nBEGIN\n SELECT 1;\nEND;\n"
"CREATE PROC usp_BareShort AS\nBEGIN\n SELECT 1;\nEND;\n"
)
r = extract_sql(p)
labels = sorted(n["label"] for n in r["nodes"] if n["label"] != "proc.sql")
assert labels == ["[dbo].[usp_Short]()", "usp_BareShort()"], labels
def test_sql_commented_ddl_is_not_fabricated_by_error_recovery(tmp_path):
"""An unrelated syntax error arms the whole-file recovery scan; DDL that
exists only inside comments must not fabricate routine nodes from it."""
pytest.importorskip("tree_sitter_sql")
p = tmp_path / "broken.sql"
p.write_text(
"-- CREATE PROCEDURE [dbo].[usp_LineComment] AS BEGIN SELECT 1; END;\n"
"/*\nCREATE OR ALTER PROC dbo.usp_BlockComment AS\nBEGIN SELECT 1; END;\n*/\n"
"CREATE PROCEDURE [dbo].[usp_Real]\nAS\nBEGIN\n SELECT 1;\nEND;\n"
"THIS IS NOT SQL AT ALL %%%;\n"
# Sandwiched between broken segments so one ERROR blob's byte span
# covers the comment — the shape that made a per-ERROR-node scan
# unsound (a fragment can lack the comment opener entirely) and is
# why recovery scans only the whole masked file:
"%%% BROKEN JUNK\n"
"-- CREATE PROC [dbo].[usp_ErrComment] AS BEGIN SELECT 1; END;\n"
"%%% MORE BROKEN JUNK\n"
)
r = extract_sql(p)
labels = [n["label"] for n in r["nodes"] if n["label"] != "broken.sql"]
assert "[dbo].[usp_Real]()" in labels, labels
assert not any("Comment" in l for l in labels), (
f"commented-out DDL fabricated a node: {labels}"
)
def test_sql_comment_openers_inside_string_literals_do_not_hide_ddl(tmp_path):
"""A `--` or `/*` inside a string literal is data, not a comment opener.
Both recovery scans mask comments before matching, and a mask that reads
`'-- note'` as a line comment blanks to end-of-line — hiding a routine
declared after the literal — while a `/*` inside a string blanks
everything through the next real `*/`, swallowing whole statements
between. Both must recover normally. This is behavior coverage: the
grammar's own fb_proc_or_trigger recovery can rescue these shapes even
under a literal-blind mask, so the mutation-killing pin for the mask
itself is test_mask_sql_comments_preserves_literals_and_blanks_comments.
"""
pytest.importorskip("tree_sitter_sql")
p = tmp_path / "strings.sql"
# The lines carrying the literals start with junk so they cannot parse as
# proper statements: literal and routine land in the SAME ERROR blob, and
# only the mask decides whether the routine survives. (A literal in a
# statement that parses never reaches the masked scans at all.)
p.write_text(
"%%% INSERT INTO log VALUES ('-- note'); "
"CREATE PROCEDURE [dbo].[usp_AfterLineString] AS BEGIN SELECT 1; END;\n"
"%%% SELECT 'open /* here' AS x; "
"CREATE PROCEDURE [dbo].[usp_AfterBlockString] AS BEGIN SELECT 1; END;\n"
"/* a real comment, giving the false opener a closer to reach */\n"
)
r = extract_sql(p)
labels = [n["label"] for n in r["nodes"] if n["label"] != "strings.sql"]
assert "[dbo].[usp_AfterLineString]()" in labels, labels
assert "[dbo].[usp_AfterBlockString]()" in labels, labels
def test_mask_sql_comments_literal_and_comment_handling():
"""Unit pin for _mask_sql_comments: comment openers inside literals are
data, not comment starts; single-quoted strings are blanked (dynamic SQL
must not be recoverable); double-quoted and bracket identifiers are
preserved verbatim (they carry recoverable names); comments blank to
spaces with newlines and offsets preserved, an unclosed block comment
running to end-of-file."""
from graphify.extractors.sql import _mask_sql_comments as mask
# a comment opener inside a literal never blanks past the literal
assert mask("select '-- x' from t") == "select from t"
assert mask("select 'a /* b' as x, 1") == "select as x, 1"
# '' escape is consumed as one literal (no blanking past the string)
src = "select 'it''s -- ok', 1"
expected = "select " + " " * len("'it''s -- ok'") + ", 1"
assert mask(src) == expected and len(mask(src)) == len(src)
# identifier literals are preserved verbatim, escapes included
assert mask('CREATE FUNCTION "public"."a""b"()') == 'CREATE FUNCTION "public"."a""b"()'
assert mask("CREATE PROCEDURE [dbo].[a]]b] AS x") == "CREATE PROCEDURE [dbo].[a]]b] AS x"
# ...but a span that is unterminated or would swallow a comment opener is
# NOT trusted as an identifier. A distrusted span is irreducibly
# ambiguous, and any single reading exposed text another reading blanks
# (re-emitting the delimiter and rescanning even re-paired later single
# quotes, uncovering dynamic SQL), so the mask blanks the UNION of every
# reading: the rest of the line, plus carried comment state for a raw /*
# left open on it. The trade: a genuine [a--b] identifier loses its line,
# never fabricating anything.
ghost = "SELECT [Col FROM t -- CREATE PROC [dbo].[usp_Ghost] AS BEGIN SELECT 1; END;"
assert "CREATE" not in mask(ghost) and len(mask(ghost)) == len(ghost)
ghost2 = 'SELECT "Col FROM t /* CREATE PROC dbo.usp_Ghost AS BEGIN SELECT 1; END */'
assert "CREATE" not in mask(ghost2) and len(mask(ghost2)) == len(ghost2)
out = mask("select [a--b] from t")
assert out == "select " + " " * len("[a--b] from t") and len(out) == len("select [a--b] from t")
# a distrusted span cannot re-pair later single quotes: the dynamic SQL
# after it stays masked (the re-emit-and-rescan defect, found by fuzzing)
repair = "SELECT [Col's -- x] ; EXEC(N'CREATE PROC [dbo].[usp_Fake] AS BEGIN SELECT 1; END');"
assert "CREATE" not in mask(repair) and len(mask(repair)) == len(repair)
# a raw /* on a distrusted line with no */ carries across the newline —
# some reading of the broken line left it open
carry = "SELECT [a--b] /*\nCREATE PROC dbo.Fake AS BEGIN SELECT 1; END\n"
assert "CREATE" not in mask(carry) and len(mask(carry)) == len(carry)
# where a carry closes MID-line, the remainder of that line is blanked
# too (under the no-carry reading the whole line may be comment or
# string — emitting the post-*/ tail verbatim was an exposure, found by
# differential fuzzing); recovery resumes on the NEXT line
carry_closed = "SELECT [a--b] /*\nx */ CREATE PROC dbo.Lost AS x\nCREATE PROC dbo.Kept AS x\n"
out = mask(carry_closed)
assert "dbo.Lost" not in out and len(out) == len(carry_closed)
assert "CREATE PROC dbo.Kept" in out
# and a fresh /* after the close carries again
recarry = "SELECT [a--b] /*\nx */ y /*\nCREATE PROC dbo.Ghost3 AS x\n"
assert "Ghost3" not in mask(recarry)
# dynamic SQL contents cannot survive the mask
dyn = "EXEC(N'CREATE PROC [dbo].[Fake] AS BEGIN SELECT 1; END');"
assert "CREATE" not in mask(dyn) and len(mask(dyn)) == len(dyn)
# comments blank to spaces, newlines kept, offsets stable
assert mask("a -- b") == "a "
assert mask("x /* y */ z") == "x z"
masked = mask("a /* m\nl */ b")
assert masked == "a \n b" and len(masked) == len("a /* m\nl */ b")
# an unclosed block comment masks to end-of-file, newlines kept
unclosed = "/* CREATE PROC [dbo].[Ghost] AS\nBEGIN SELECT 1; END"
assert "CREATE" not in mask(unclosed) and len(mask(unclosed)) == len(unclosed)
# a /* inside a MULTI-LINE string is consumed as (blanked, line-scoped)
# string content — it must NOT open a comment that swallows the file
multi = (
"SET @sql = N'SELECT a /* c1\n , b FROM t';\nGO\n"
"CREATE PROCEDURE dbo.usp_Real AS BEGIN SELECT 1; END;\n"
)
out = mask(multi)
assert "CREATE PROCEDURE dbo.usp_Real" in out and len(out) == len(multi)
# block comments NEST (SQL Server and PostgreSQL both nest /* */): DDL
# commented out inside a nested comment must not survive the mask
nested = "/* outer\n /* inner */\n CREATE PROC dbo.usp_Fake AS BEGIN SELECT 1; END;\n*/\n"
out = mask(nested)
assert "CREATE" not in out and len(out) == len(nested)
def test_mask_sql_comments_invariants_fuzz():
"""Deterministic fuzz over the mask's structural invariants.
Both rounds of masking defects were shapes nobody thought to write down,
so pin properties instead: (1) one output character per input character,
(2) newlines exactly preserved, (3) blanked spans are spaces, (4) the
mask is idempotent — re-masking its own output changes nothing (the
quote re-pairing defect broke this: exposed text re-masked differently).
"""
import random
from graphify.extractors.sql import _mask_sql_comments as mask
rng = random.Random(0xC0FFEE)
alphabet = "ab[]\"'-*/ \n;.$"
for _ in range(20_000):
s = "".join(rng.choice(alphabet) for _ in range(rng.randint(1, 26)))
out = mask(s)
assert len(out) == len(s), (s, out)
for a, b in zip(s, out):
assert (a == "\n") == (b == "\n"), (s, out)
assert b == a or b == " ", (s, out)
assert mask(out) == out, (s, out)
# Frozen copy of _mask_sql_comments as of the 2026-08-24 hardening series,
# for the differential monotonicity fuzz below. Deliberately NOT imported
# from graphify: the point is that future edits to the live mask are
# compared against this fixed baseline. Update it only when a deliberate,
# reviewed decision changes what the mask must blank.
def _frozen_mask_2026_08_24(text: str) -> str:
"""Blank comment and string-literal spans, preserving every offset.
One output character per input character: non-newline characters inside
a blanked span become spaces and newlines are kept, so positions and
line numbers computed against the masked text are valid against the
original. Double-quoted and bracket-delimited identifiers are preserved
verbatim (they carry recoverable routine names); single-quoted strings,
line comments, and (nesting-aware) block comments are blanked. Used by
both routine-recovery scans so CREATE PROCEDURE/FUNCTION DDL reachable
only through a comment or a single-quoted string cannot fabricate a
routine node when an unrelated parse error arms recovery.
"""
out: list[str] = []
i, n = 0, len(text)
def _blank(upto: int) -> int:
"""Blank [i, upto), keeping newlines; return upto."""
for c in text[i:upto]:
out.append("\n" if c == "\n" else " ")
return upto
def _blank_tail_and_carry(start: int) -> int:
"""Blank from start to end-of-line, carrying comment state forward.
The union-of-readings blank for an ambiguous stretch: the rest of the
line is blanked outright; if the raw text of that stretch leaves a /*
unclosed on its own line (the maximum comment depth any reading could
be left holding), blanking continues, nesting-aware, to the closing
*/ or EOF. Where a carry closes MID-line the same rule applies to the
remainder of that line — under the reading where the carry never
opened, that whole line may be a comment or a string, so emitting the
post-*/ text verbatim exposed it (found by differential fuzzing).
Repeats until a line ends with no carry pending. Appends one output
character per input character; returns the resume index.
"""
k = start
while True:
eol = text.find("\n", k)
eol = n if eol == -1 else eol
depth = 0
m2 = k
while m2 < eol:
if text.startswith("/*", m2):
depth += 1
m2 += 2
elif text.startswith("*/", m2):
if depth:
depth -= 1
m2 += 2
else:
m2 += 1
for ch in text[k:eol]:
out.append("\n" if ch == "\n" else " ")
k = eol
if not depth:
return k
j2 = k
while j2 < n and depth:
if text.startswith("/*", j2):
depth += 1
j2 += 2
elif text.startswith("*/", j2):
depth -= 1
j2 += 2
else:
j2 += 1
for ch in text[k:j2]:
out.append("\n" if ch == "\n" else " ")
k = j2
if k >= n:
return k
# the carry closed mid-line: the remainder of THIS line is the
# same ambiguous stretch — loop and blank it too
while i < n:
c = text[i]
if c == "'":
# Single-quoted string: blank it. '' is an escaped quote; a
# newline abandons the literal (see the comment above).
j = i + 1
while j < n and text[j] != "\n":
if text[j] == "'":
if j + 1 < n and text[j + 1] == "'":
j += 2
continue
j += 1
break
j += 1
i = _blank(j)
elif c == '"' or c == "[":
# Delimited identifier: preserve verbatim. Doubled closers are
# escapes. A span is DISTRUSTED when it is unterminated (no
# closer before the newline) or would swallow a comment opener
# on its way to the closer ('SELECT [Col FROM t -- CREATE PROC
# [dbo]' closes on [dbo]'s bracket) — a stray delimiter is
# ordinary in exactly the broken files this mask runs on.
#
# A distrusted span is irreducibly ambiguous (identifier data vs
# stray delimiter before real comments/strings), and any attempt
# to pick one reading exposed text the other reading blanks —
# re-emitting the delimiter and rescanning even re-paired later
# single quotes and uncovered dynamic SQL. So blank the UNION of
# every reading: the rest of the line is blanked outright, and
# any raw /* on it with no later */ on the same line carries
# forward as (nesting-aware) comment state, since some reading
# may have left it open — and where that carry closes mid-line,
# the remainder of THAT line gets the same treatment, repeated
# until a line ends carry-free (under the no-carry reading the
# close line may itself be all comment or string, so emitting its
# post-*/ tail verbatim was an exposure). Over-blanking loses at
# most routines on lines already entangled with the broken one (a
# conservative false negative; a routine named like [a--b] is
# inside that loss); under-blanking is what fabricates, and every
# reading's blank set stays a subset of this one.
closer = '"' if c == '"' else "]"
j = i + 1
closed = False
while j < n and text[j] != "\n":
if text[j] == closer:
if j + 1 < n and text[j + 1] == closer:
j += 2
continue
j += 1
closed = True
break
j += 1
span = text[i:j]
if closed and "--" not in span and "/*" not in span:
out.append(span)
i = j
else:
i = _blank_tail_and_carry(i)
elif c == "-" and i + 1 < n and text[i + 1] == "-":
j = i
while j < n and text[j] != "\n":
j += 1
i = _blank(j)
elif c == "/" and i + 1 < n and text[i + 1] == "*":
depth, j = 1, i + 2
while j < n and depth:
if text[j] == "/" and j + 1 < n and text[j + 1] == "*":
depth += 1
j += 2
elif text[j] == "*" and j + 1 < n and text[j + 1] == "/":
depth -= 1
j += 2
else:
j += 1
i = _blank(j) # unclosed comment: j == n, blanks to end-of-file
else:
out.append(c)
i += 1
return "".join(out)
def test_mask_sql_comments_monotone_against_frozen_baseline():
"""Differential fuzz: the live mask must never EXPOSE a character the
frozen baseline blanks. Blanking more is allowed (conservative); blanking
less is the fabrication channel — every masking defect in the 2026-08-24
series (quote re-pairing, carry-close exposure) was a monotonicity
violation of exactly this kind, and none was caught by hand-written
shapes first.
If an INTENTIONAL over-blanking change lands (the live mask blanks more
than the baseline), this still passes; if an intentional EXPOSURE change
lands (the mask deliberately blanks less), refresh the frozen copy above
to the new revision as part of that reviewed change — do not delete the
test or loosen the property.
"""
import random
from graphify.extractors.sql import _mask_sql_comments as mask
rng = random.Random(0xBA5E11E)
alphabet = "ab[]\"'-*/ \n;.$"
for _ in range(20_000):
s = "".join(rng.choice(alphabet) for _ in range(rng.randint(1, 40)))
live = mask(s)
base = _frozen_mask_2026_08_24(s)
assert len(live) == len(base) == len(s)
for idx, (b, lv, orig) in enumerate(zip(base, live, s)):
if b == " " and orig != " ":
assert lv == " ", (
f"live mask exposes char {idx} ({orig!r}) that the frozen "
f"baseline blanks:\n input={s!r}\n base={base!r}\n live={live!r}"
)
def test_sql_dynamic_sql_and_unclosed_comment_do_not_fabricate_routines(tmp_path):
"""DDL text reachable only through a single-quoted string (dynamic SQL) or
an unterminated block comment must not mint routine nodes when an
unrelated parse error arms the recovery scans."""
pytest.importorskip("tree_sitter_sql")
p = tmp_path / "dynamic.sql"
p.write_text(
"THIS IS NOT SQL AT ALL %%%;\n"
"EXEC(N'CREATE OR ALTER PROC [dbo].[usp_Dynamic] AS BEGIN SELECT 1; END');\n"
"SET @sql = N'SELECT 1 /* opener inside a multi-line string\n"
" must not swallow what follows';\n"
"/* nested /* comments */ hide this:\n"
"CREATE PROC [dbo].[usp_Nested] AS BEGIN SELECT 1; END;\n*/\n"
"SELECT [Col FROM t -- CREATE PROC [dbo].[usp_Bracketed] AS BEGIN SELECT 1; END;\n"
"CREATE PROCEDURE [dbo].[usp_Real]\nAS\nBEGIN\n SELECT 1;\nEND;\n"
"/* an unterminated comment swallows the rest of the file\n"
"CREATE PROC [dbo].[usp_Unterminated] AS BEGIN SELECT 1; END;\n"
)
r = extract_sql(p)
labels = [n["label"] for n in r["nodes"] if n["label"] != "dynamic.sql"]
assert "[dbo].[usp_Real]()" in labels, labels
fabricated = [
label for label in labels
if any(k in label for k in ("Dynamic", "Nested", "Bracketed", "Unterminated"))
]
assert not fabricated, (
f"string-embedded or comment-swallowed DDL fabricated a node: {labels}"
)
def test_sql_ddl_keywords_inside_delimited_identifiers_do_not_fabricate(tmp_path):
"""A preserved delimited identifier keeps its text verbatim in the masked
scan (it may BE a recoverable name), so DDL keywords occurring inside one
('SELECT 1 AS [CREATE PROCEDURE dbo.usp_Phantom pending]') were matched
by the recovery regex and minted a phantom routine. Recovery now skips a
match whose CREATE starts inside a preserved identifier span — a genuine
statement's NAME may be a delimited identifier; its CREATE never is."""
pytest.importorskip("tree_sitter_sql")
p = tmp_path / "alias.sql"
p.write_text(
"THIS IS NOT SQL AT ALL %%%;\n"
"SELECT 1 AS [CREATE PROCEDURE dbo.usp_Phantom pending];\n"
'SELECT 2 AS "CREATE PROC dbo.usp_Phantom2 queued";\n'
"CREATE PROCEDURE [dbo].[usp_Real]\nAS\nBEGIN\n SELECT 1;\nEND;\n"
)
r = extract_sql(p)
labels = [n["label"] for n in r["nodes"] if n["label"] != "alias.sql"]
assert labels == ["[dbo].[usp_Real]()"], labels
def test_sql_create_inside_a_bare_word_does_not_fabricate(tmp_path):
"""CREATE must match as a word: without a leading boundary the recovery
regex matched CREATE inside a bare identifier, so 'SELECT AUTOCREATE
PROCEDURE x FROM t;' in an error-bearing file minted a phantom x().
(Delimited identifiers are span-skipped at the scan site; a bare word
has no span, so the regex itself must refuse.)"""
pytest.importorskip("tree_sitter_sql")
p = tmp_path / "bare.sql"
p.write_text(
"THIS IS NOT SQL AT ALL %%%;\n"
"SELECT AUTOCREATE PROCEDURE x FROM t;\n"
"EXEC dbo.PRECREATE FUNCTION y;\n"
"CREATE PROCEDURE [dbo].[usp_Real]\nAS\nBEGIN\n SELECT 1;\nEND;\n"
)
r = extract_sql(p)
labels = [n["label"] for n in r["nodes"] if n["label"] != "bare.sql"]
assert labels == ["[dbo].[usp_Real]()"], labels
def test_sql_escaped_double_quote_in_routine_name_is_consumed(tmp_path):
"""ANSI SQL escapes a literal double quote inside a delimited identifier
by doubling it: "a""b" names the identifier a"b. A pattern that stops at
the first closing quote truncated the recovered name to "dbo"."a" — the
"" twin of the bracket ]] escape handled above.
The AS BEGIN body idiom keeps the whole statement in ERROR recovery: for a
statement the grammar CAN parse, tree-sitter-sql itself truncates the
object_reference at the "" escape (emitting `"b"` as a stray ERROR child),
which is an upstream grammar defect this extractor cannot repair."""
pytest.importorskip("tree_sitter_sql")
p = tmp_path / "fn.sql"
p.write_text('CREATE PROCEDURE "dbo"."a""b"\nAS\nBEGIN\n SELECT 1;\nEND;\n')
r = extract_sql(p)
routine = [n["label"] for n in r["nodes"] if n["label"] != "fn.sql"]
assert routine == ['"dbo"."a""b"()'], routine
def test_sql_cte_is_not_read_as_a_table():
"""#2577: a name bound by WITH ... AS (...) is scoped to its statement, not a table.
Emitting it as a reads_from target minted a bare, sourceless stub carrying no
schema, file, or language namespace, so a CTE named `levels` or `slug` collided
with a same-named node from another language. The real table in the same
FROM/JOIN must still resolve.
"""
r = _extract_sql_or_skip("sample_cte.sql")
labels = [n["label"] for n in r["nodes"]]
assert "levels" not in labels, "CTE name leaked into the graph as a table node"
reads = [e for e in r["edges"] if e["relation"] == "reads_from"]
assert reads, "the real table reference should still emit a reads_from edge"
assert not any(e["target"] == "levels" for e in reads), "CTE emitted as a reads_from target"
# the real v_roles -> users edge is kept
nid = {n["label"]: n["id"] for n in r["nodes"]}
assert (nid["v_roles"], nid["users"]) in {(e["source"], e["target"]) for e in reads}
def test_sql_column_list_cte_is_not_read_as_a_table(tmp_path):
"""#2577: `WITH levels(a, b) AS (...)` — the name precedes a column list."""
pytest.importorskip("tree_sitter_sql")
p = tmp_path / "schema.sql"
p.write_text(
"CREATE TABLE users (id INT, role TEXT);\n"
"CREATE VIEW v AS\n"
" WITH levels(role, rank) AS (SELECT 'admin', 1)\n"
" SELECT * FROM users JOIN levels ON levels.role = users.role;\n"
)
r = extract_sql(p)
labels = [n["label"] for n in r["nodes"]]
assert "levels" not in labels
reads = [e for e in r["edges"] if e["relation"] == "reads_from"]
assert not any(e["target"] == "levels" for e in reads)
def test_sql_cte_shadows_same_named_table_within_its_statement(tmp_path):
"""#2577: inside the declaring statement the CTE shadows a real same-named
table (SQL scoping), so v1's FROM binds to the CTE and emits nothing; v2 has
no CTE in scope and reads the real table. Exactly one deterministic edge."""
pytest.importorskip("tree_sitter_sql")
p = tmp_path / "schema.sql"
p.write_text(
"CREATE TABLE levels (role TEXT);\n"
"CREATE VIEW v1 AS WITH levels AS (SELECT 'admin' AS role)"
" SELECT * FROM levels;\n"
"CREATE VIEW v2 AS SELECT * FROM levels;\n"
)
r = extract_sql(p)
reads = [e for e in r["edges"] if e["relation"] == "reads_from"]
assert len(reads) == 1, f"expected exactly one reads_from, got {reads}"
nid = {n["label"]: n["id"] for n in r["nodes"]}
assert reads[0]["source"] == nid["v2"]
assert reads[0]["target"] == nid["levels"] # the real, sourced table node
def test_sql_subquery_cte_does_not_suppress_outer_real_table(tmp_path):
"""#2577 refinement: a WITH inside a subquery is scoped to that subquery
only. A statement-wide pre-collect would also swallow the OUTER reference
to the real `t2`, dropping a true edge — per-subtree scoping keeps it."""
pytest.importorskip("tree_sitter_sql")
p = tmp_path / "schema.sql"
p.write_text(
"CREATE TABLE t2 (id INT);\n"
"CREATE VIEW v6 AS SELECT * FROM t2 JOIN"
" (WITH t2 AS (SELECT 1 AS id) SELECT * FROM t2) sub"
" ON sub.id = t2.id;\n"
)
r = extract_sql(p)
nid = {n["label"]: n["id"] for n in r["nodes"]}
reads = {(e["source"], e["target"]) for e in r["edges"]
if e["relation"] == "reads_from"}
assert (nid["v6"], nid["t2"]) in reads, (
"outer reference to the real t2 table was wrongly suppressed"
)
def test_php_builtin_base_class_never_inherits_from_cross_language_class(tmp_path):
"""#2812: `class FooApiException extends \\Exception` names PHP's global
built-in. The sourceless stub it mints was unique corpus-wide, so the rewire
bound it to an unrelated TypeScript `Exception` class and the PHP class
inherited across languages. No supertype edge may target a TypeScript node."""
ts = tmp_path / "app" / "exception.ts"
ts.parent.mkdir(parents=True)
ts.write_text(
"export class Exception extends Error {\n"
" constructor(message: string) { super(message); }\n"
"}\n"
)
php = tmp_path / "packages" / "FooApiException.php"
php.parent.mkdir(parents=True)
php.write_text(
"<?php\n"
"\n"
"namespace Foo\\Exceptions;\n"
"\n"
"class FooApiException extends \\Exception\n"
"{\n"
"}\n"
)
r = extract([php, ts], root=tmp_path)
ts_nodes = {n["id"] for n in r["nodes"]
if str(n.get("source_file", "")).endswith(".ts")}
supertypes = [e for e in r["edges"]
if e["relation"] in ("inherits", "implements", "extends")
and str(e.get("source_file", "")).endswith(".php")]
assert supertypes, "PHP inheritance was not extracted at all"
for e in supertypes:
assert e["target"] not in ts_nodes, (
f"PHP supertype leaked cross-language: {e}"
)
# The base stays on its sourceless external stub rather than vanishing.
sourceless = {n["id"] for n in r["nodes"] if not n.get("source_file")}
assert any(e["target"] in sourceless for e in supertypes), (
f"PHP base class lost its external stub: {supertypes}"
)
def test_sql_cte_never_binds_to_cross_language_symbol(tmp_path):
"""#2577: the reported leak — the CTE's sourceless stub was unique corpus-wide,
so _rewire_unique_stub_nodes bound it to a same-named symbol from ANOTHER
language (schema_v_roles -> ui_levels). With the CTE excluded, no reads_from
edge may target a TypeScript node."""
pytest.importorskip("tree_sitter_sql")
sql = tmp_path / "schema.sql"
sql.write_text(
"CREATE TABLE users (id INT, role TEXT);\n"
"CREATE VIEW v_roles AS\n"
" WITH levels AS (SELECT 'admin' AS role)\n"
" SELECT * FROM users JOIN levels ON levels.role = users.role;\n"
)
ts = tmp_path / "ui.ts"
ts.write_text("export function levels() { return ['admin']; }\n")
r = extract([sql, ts], root=tmp_path)
ts_nodes = {n["id"] for n in r["nodes"]
if str(n.get("source_file", "")).endswith(".ts")}
for e in r["edges"]:
if e["relation"] == "reads_from":
assert e["target"] not in ts_nodes, (
f"SQL reads_from leaked cross-language: {e}"
)
def test_sql_cross_file_fk_resolves_and_never_leaks_scan_path(tmp_path):
"""#2324: a REFERENCES target defined in ANOTHER file must collapse onto the
real table node (via the sourceless-stub rewire), and no node id or edge
endpoint may embed the absolute scan path. Before the fix, the fallback
minted a node-less id under the referencing file's own stem, which with
absolute inputs leaked the machine path AND could never match the m1
definition, so prisma-style cross-migration FKs dangled."""
pytest.importorskip("tree_sitter_sql")
from graphify.ids import make_id
m1 = tmp_path / "prisma" / "migrations" / "m1"
m2 = tmp_path / "prisma" / "migrations" / "m2"
m1.mkdir(parents=True)
m2.mkdir(parents=True)
(m1 / "migration.sql").write_text(
'CREATE TABLE "Tenant" (\n'
' "id" TEXT NOT NULL,\n'
' CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id")\n'
');\n'
)
(m2 / "migration.sql").write_text(
'CREATE TABLE "StockGapEvent" (\n'
' "id" TEXT NOT NULL,\n'
' "tenantId" TEXT NOT NULL,\n'
' CONSTRAINT "StockGapEvent_pkey" PRIMARY KEY ("id")\n'
');\n'
'ALTER TABLE "StockGapEvent" ADD CONSTRAINT "StockGapEvent_tenantId_fkey"'
' FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id");\n'
)
r = extract(
[(m1 / "migration.sql").resolve(), (m2 / "migration.sql").resolve()],
root=tmp_path,
)
node_ids = {n["id"] for n in r["nodes"]}
# (a) the FK resolved cross-file onto the REAL Tenant definition node
tenant_ids = [i for i in node_ids if i.endswith("m1_migration_tenant")]
assert len(tenant_ids) == 1, f"expected one real Tenant node, got {tenant_ids}"
ref_targets = {e["target"] for e in r["edges"] if e["relation"] == "references"}
assert tenant_ids[0] in ref_targets, (
f"cross-file FK did not rewire onto {tenant_ids[0]}; "
f"references targets: {ref_targets}"
)
# (b) no dangling endpoints anywhere
for e in r["edges"]:
assert e["source"] in node_ids, f"dangling source: {e['source']}"
assert e["target"] in node_ids, f"dangling target: {e['target']}"
# (c) the absolute scan path never leaks into any id or endpoint
abs_slug = make_id(str(tmp_path.resolve()))
for i in node_ids:
assert abs_slug not in i, f"absolute path leaked into node id: {i}"
for e in r["edges"]:
assert abs_slug not in e["source"], f"absolute path leaked: {e['source']}"
assert abs_slug not in e["target"], f"absolute path leaked: {e['target']}"
def test_sql_alter_table_fk_edge():
"""ALTER TABLE ... FOREIGN KEY ... REFERENCES produces a references edge."""
r = _extract_sql_or_skip("sample_alter_fk.sql")
fk_edges = [e for e in r["edges"] if e["relation"] == "references"]
assert len(fk_edges) >= 1
node_ids = {n["id"] for n in r["nodes"]}
for e in fk_edges:
assert e["source"] in node_ids, f"dangling source: {e['source']}"
assert e["target"] in node_ids, f"dangling target: {e['target']}"
def test_sql_schema_qualified_names():
"""Schema-qualified table names (Schema.Table) are preserved."""
r = _extract_sql_or_skip("sample_schema_qualified.sql")
labels = [n["label"] for n in r["nodes"]]
assert any("Sales.Customer" in l for l in labels)
assert any("Sales.SalesOrder" in l for l in labels)
def test_sql_schema_qualified_alter_fk():
"""ALTER TABLE with schema-qualified names produces correct edges."""
r = _extract_sql_or_skip("sample_schema_qualified.sql")
fk_edges = [e for e in r["edges"] if e["relation"] == "references"]
assert len(fk_edges) >= 1
node_ids = {n["id"] for n in r["nodes"]}
for e in fk_edges:
assert e["source"] in node_ids, f"dangling source: {e['source']}"
assert e["target"] in node_ids, f"dangling target: {e['target']}"
def test_sql_plpgsql_functions_survive_parse_errors():
"""PL/pgSQL bodies make tree-sitter-sql emit ERROR nodes; the functions
must still be extracted (#1910), without cascading into later statements."""
r = _extract_sql_or_skip("sample_plpgsql.sql")
labels = [n["label"] for n in r["nodes"]]
# Both PL/pgSQL functions extracted, schema-qualified name kept whole
assert "exposed.important_function()" in labels
assert "tagged_quote_fn()" in labels
# Tables before and after the broken functions still extract
assert any("accounts" in l for l in labels)
assert any("audit_log" in l for l in labels)
# No spurious or empty nodes from the error recovery
for l in labels:
assert l, "empty node label"
assert l != "ERROR"
# Every function got a contains edge from the file node
contains_targets = {e["target"] for e in r["edges"] if e["relation"] == "contains"}
fn_ids = {n["id"] for n in r["nodes"] if n["label"].endswith("()")}
assert fn_ids <= contains_targets
def test_sql_plpgsql_clean_function_not_double_emitted():
"""A cleanly-parsed LANGUAGE sql function in the same file is emitted once."""
r = _extract_sql_or_skip("sample_plpgsql.sql")
labels = [n["label"] for n in r["nodes"]]
assert labels.count("plain_sql_fn()") == 1
# And nothing else is duplicated either
ids = [n["id"] for n in r["nodes"]]
assert len(ids) == len(set(ids))
def test_sql_quoted_plpgsql_routines_are_recovered():
"""#2180: quoted identifiers must not defeat the ERROR-node name recovery.
Generated PostgreSQL DDL (Supabase-style dumps, for example) quotes every
identifier: CREATE OR REPLACE FUNCTION "public"."fn"(...). The recovery
pattern matched a bare [\\w$.]+, which stops at the leading quote, so every
routine whose body also failed to parse -- RAISE, PERFORM, :=, IF..THEN,
bare NULL; -- was dropped with no warning and exit code 0. The same body
with an *unquoted* name recovered fine, which is why the drop looked like it
depended only on the body statement.
"""
r = _extract_sql_or_skip("sample_plpgsql_quoted.sql")
labels = [n["label"] for n in r["nodes"]]
for name in (
"raise_exception_fn",
"raise_notice_fn",
"perform_fn",
"assign_fn",
"if_then_fn",
"null_body_fn",
"quoted_proc",
):
assert f'"public"."{name}"()' in labels, f"{name} dropped from a quoted-DDL file (#2180)"
def test_sql_quoted_plpgsql_file_stays_clean():
"""The #2180 recovery must not add junk, duplicates, or drop the tables."""
r = _extract_sql_or_skip("sample_plpgsql_quoted.sql")
labels = [n["label"] for n in r["nodes"]]
# Tables before and after the unparseable routines still extract.
assert any("accounts" in l for l in labels)
assert any("audit_log" in l for l in labels)
# No empty or ERROR labels leaked out of the recovery.
for l in labels:
assert l, "empty node label"
assert l != "ERROR"
# Nothing emitted twice (a routine must not come from both paths).
ids = [n["id"] for n in r["nodes"]]
assert len(ids) == len(set(ids)), "duplicate node ids"
assert len(labels) == len(set(labels)), f"duplicate labels: {labels}"
# Every recovered routine is reachable from the file node.
contains_targets = {e["target"] for e in r["edges"] if e["relation"] == "contains"}
fn_ids = {n["id"] for n in r["nodes"] if n["label"].endswith("()")}
assert fn_ids <= contains_targets