feat: add v8 affected and import-resolution support

This commit is contained in:
Danil Tarasov
2026-05-22 13:24:54 +01:00
committed by Safi
parent b6127aa5a7
commit e44e6e986c
9 changed files with 2012 additions and 172 deletions
+64
View File
@@ -1270,6 +1270,10 @@ def main() -> None:
print(" --context C explicit edge-context filter (repeatable)")
print(" --budget N cap output at N tokens (default 2000)")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" affected \"X\" reverse traversal to find nodes impacted by X")
print(" --relation R edge relation to traverse in reverse (repeatable)")
print(" --depth N reverse traversal depth (default 2)")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" save-result save a Q&A result to graphify-out/memory/ for graph feedback loop")
print(" --question Q the question asked")
print(" --answer A the answer to save")
@@ -1587,6 +1591,66 @@ def main() -> None:
context_filters=context_filters,
)
)
elif cmd == "affected":
if len(sys.argv) < 3:
print("Usage: graphify affected \"<node-or-label>\" [--relation R] [--depth N] [--graph path]", file=sys.stderr)
sys.exit(1)
from graphify.affected import DEFAULT_AFFECTED_RELATIONS, format_affected, load_graph
query = sys.argv[2]
graph_path = "graphify-out/graph.json"
depth = 2
relations: list[str] = []
args = sys.argv[3:]
i = 0
while i < len(args):
if args[i] == "--graph" and i + 1 < len(args):
graph_path = args[i + 1]
i += 2
elif args[i].startswith("--graph="):
graph_path = args[i].split("=", 1)[1]
i += 1
elif args[i] == "--depth" and i + 1 < len(args):
try:
depth = int(args[i + 1])
except ValueError:
print("error: --depth must be an integer", file=sys.stderr)
sys.exit(1)
i += 2
elif args[i].startswith("--depth="):
try:
depth = int(args[i].split("=", 1)[1])
except ValueError:
print("error: --depth must be an integer", file=sys.stderr)
sys.exit(1)
i += 1
elif args[i] == "--relation" and i + 1 < len(args):
relations.append(args[i + 1])
i += 2
elif args[i].startswith("--relation="):
relations.append(args[i].split("=", 1)[1])
i += 1
else:
i += 1
gp = Path(graph_path).resolve()
if not gp.exists():
print(f"error: graph file not found: {gp}", file=sys.stderr)
sys.exit(1)
if not gp.suffix == ".json":
print("error: graph file must be a .json file", file=sys.stderr)
sys.exit(1)
try:
graph = load_graph(gp)
except Exception as exc:
print(f"error: could not load graph: {exc}", file=sys.stderr)
sys.exit(1)
print(
format_affected(
graph,
query,
relations=relations or DEFAULT_AFFECTED_RELATIONS,
depth=depth,
)
)
elif cmd == "save-result":
# graphify save-result --question Q --answer A --type T [--nodes N1 N2 ...]
import argparse as _ap
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import networkx as nx
DEFAULT_AFFECTED_RELATIONS = (
"calls",
"references",
"imports",
"imports_from",
"re_exports",
"inherits",
"extends",
"implements",
"uses",
"mixes_in",
"embeds",
)
@dataclass(frozen=True)
class AffectedHit:
node_id: str
depth: int
via_relation: str
def _node_label(graph: nx.Graph, node_id: str) -> str:
data = graph.nodes[node_id]
return str(data.get("label") or node_id)
def _format_location(data: dict) -> str:
source_file = data.get("source_file") or "-"
source_location = data.get("source_location")
if source_location:
return f"{source_file}:{source_location}"
return str(source_file)
def resolve_seed(graph: nx.Graph, query: str) -> str | None:
if query in graph:
return query
query_lower = query.lower()
exact_label_matches = [
str(node_id)
for node_id, data in graph.nodes(data=True)
if str(data.get("label", "")).lower() == query_lower
]
if len(exact_label_matches) == 1:
return exact_label_matches[0]
exact_source_matches = [
str(node_id)
for node_id, data in graph.nodes(data=True)
if str(data.get("source_file", "")).lower() == query_lower
]
if len(exact_source_matches) == 1:
return exact_source_matches[0]
contains_matches = [
str(node_id)
for node_id, data in graph.nodes(data=True)
if query_lower in str(data.get("label", "")).lower()
]
if len(contains_matches) == 1:
return contains_matches[0]
return None
def affected_nodes(
graph: nx.Graph,
seed: str,
*,
relations: Iterable[str] = DEFAULT_AFFECTED_RELATIONS,
depth: int = 2,
) -> list[AffectedHit]:
relation_set = set(relations)
seen = {seed}
queue: deque[tuple[str, int]] = deque([(seed, 0)])
hits: list[AffectedHit] = []
while queue:
current, current_depth = queue.popleft()
if current_depth >= depth:
continue
if hasattr(graph, "in_edges"):
incoming = graph.in_edges(current, data=True)
else:
incoming = (
(source, target, data)
for source, target, data in graph.edges(data=True)
if target == current
)
for source, _target, data in incoming:
relation = str(data.get("relation", ""))
if relation not in relation_set:
continue
source = str(source)
if source in seen:
continue
seen.add(source)
hit = AffectedHit(source, current_depth + 1, relation)
hits.append(hit)
queue.append((source, current_depth + 1))
return hits
def format_affected(
graph: nx.Graph,
query: str,
*,
relations: Iterable[str] = DEFAULT_AFFECTED_RELATIONS,
depth: int = 2,
) -> str:
relation_list = tuple(relations)
seed = resolve_seed(graph, query)
if seed is None:
return f"No unique node match for {query}"
hits = affected_nodes(graph, seed, relations=relation_list, depth=depth)
lines = [
f"Affected nodes for {_node_label(graph, seed)}",
f"Relations: {', '.join(relation_list)}",
f"Depth: {depth}",
]
if not hits:
lines.append("No affected nodes found.")
return "\n".join(lines)
for hit in hits:
data = graph.nodes[hit.node_id]
lines.append(
f"- {_node_label(graph, hit.node_id)} [{hit.via_relation}] {_format_location(data)}"
)
return "\n".join(lines)
def load_graph(path: Path) -> nx.Graph:
import json
from networkx.readwrite import json_graph
raw = json.loads(path.read_text(encoding="utf-8"))
try:
return json_graph.node_link_graph(raw, edges="links")
except TypeError:
return json_graph.node_link_graph(raw)
+1117 -162
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -957,7 +957,7 @@ from graphify.detect import save_manifest
save_manifest(incremental['files'])
print('[graphify update] Manifest saved.')
'@ | Out-File -FilePath graphify-out\.graphify_step_for_update_incremental_re_extracti_21.py -Encoding utf8
& (Get-Content graphify-out\.graphify_python) graphify-out\.graphify_step_for_update_incremental_re_extracti_21.py
& (Get-Content graphify-out\.graphify_python) graphify-out\.graphify_step_for_update_incremental_re_extracti_21.py
Remove-Item -ErrorAction SilentlyContinue graphify-out\.graphify_step_for_update_incremental_re_extracti_21.py
```
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import json
import networkx as nx
from networkx.readwrite import json_graph
import graphify.__main__ as mainmod
def _write_graph(tmp_path):
graph = nx.DiGraph()
graph.add_node("target", label="Foo", source_file="pkg/foo.py", source_location="L1")
graph.add_node("caller", label="X()", source_file="app.py", source_location="L4")
graph.add_node("barrel", label="__init__.py", source_file="pkg/__init__.py", source_location=None)
graph.add_node("consumer", label="app.py", source_file="app.py", source_location=None)
graph.add_edge("caller", "target", relation="calls", context="call", confidence="EXTRACTED")
graph.add_edge("barrel", "target", relation="re_exports", context="export", confidence="EXTRACTED")
graph.add_edge("consumer", "target", relation="imports", context="import", confidence="EXTRACTED")
graph_path = tmp_path / "graph.json"
graph_path.write_text(json.dumps(json_graph.node_link_data(graph, edges="links")), encoding="utf-8")
return graph_path
def test_affected_cli_reverse_traverses_impact_edges(monkeypatch, tmp_path, capsys):
graph_path = _write_graph(tmp_path)
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr(
mainmod.sys,
"argv",
["graphify", "affected", "Foo", "--graph", str(graph_path)],
)
mainmod.main()
out = capsys.readouterr().out
assert "Affected nodes for Foo" in out
assert "X()" in out
assert "calls" in out
assert "__init__.py" in out
assert "re_exports" in out
assert "app.py" in out
assert "imports" in out
def test_affected_cli_relation_filter_limits_reverse_traversal(monkeypatch, tmp_path, capsys):
graph_path = _write_graph(tmp_path)
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr(
mainmod.sys,
"argv",
["graphify", "affected", "Foo", "--relation", "calls", "--graph", str(graph_path)],
)
mainmod.main()
out = capsys.readouterr().out
assert "Relations: calls" in out
assert "X()" in out
assert "__init__.py" not in out
+153
View File
@@ -56,6 +56,159 @@ def test_extract_merges_multiple_files():
assert result["input_tokens"] == 0
def test_extract_disambiguates_duplicate_symbol_ids_by_source_path(tmp_path):
first = tmp_path / "apps/api/Program.cs"
second = tmp_path / "tools/api/Program.cs"
first.parent.mkdir(parents=True)
second.parent.mkdir(parents=True)
first.write_text("class Program { void Run() {} }\n", encoding="utf-8")
second.write_text("class Program { void Run() {} }\n", encoding="utf-8")
result = extract([first, second], cache_root=tmp_path)
program_nodes = [
node for node in result["nodes"]
if node["label"] == "Program" and node.get("source_file", "").endswith("Program.cs")
]
assert len(program_nodes) == 2
assert len({node["id"] for node in program_nodes}) == 2
node_ids = {node["id"] for node in result["nodes"]}
program_by_source = {node["source_file"]: node["id"] for node in program_nodes}
file_nodes_by_source = {
node["source_file"]: node["id"]
for node in result["nodes"]
if node["label"] == "Program.cs"
}
assert set(program_by_source) == set(file_nodes_by_source)
contains_edges = [
edge for edge in result["edges"]
if edge["relation"] == "contains" and edge["source_file"] in program_by_source
]
assert len(contains_edges) == 2
for edge in contains_edges:
assert edge["source"] == file_nodes_by_source[edge["source_file"]]
assert edge["target"] == program_by_source[edge["source_file"]]
for edge in result["edges"]:
if edge["relation"] in {"contains", "method"}:
assert edge["source"] in node_ids, f"Dangling structural source: {edge}"
assert edge["target"] in node_ids, f"Dangling structural target: {edge}"
def test_extract_updates_raw_call_callers_after_duplicate_id_disambiguation(tmp_path):
first = tmp_path / "apps/api/Program.cs"
second = tmp_path / "tools/api/Program.cs"
target = tmp_path / "shared/Helper.cs"
first.parent.mkdir(parents=True)
second.parent.mkdir(parents=True)
target.parent.mkdir(parents=True)
first.write_text("class Program { void Run() { SharedHelper(); } }\n", encoding="utf-8")
second.write_text("class Program { void Run() {} }\n", encoding="utf-8")
target.write_text("class Helper { void SharedHelper() {} }\n", encoding="utf-8")
result = extract([first, second, target], cache_root=tmp_path)
node_ids = {node["id"] for node in result["nodes"]}
for edge in result["edges"]:
if edge["relation"] == "calls":
assert edge["source"] in node_ids
assert edge["target"] in node_ids
def test_extract_rewires_unique_inheritance_stub_to_real_definition(tmp_path):
definition = tmp_path / "interfaces.py"
implementation = tmp_path / "services/BookStore.cs"
definition.write_text("class BookStore:\n pass\n", encoding="utf-8")
implementation.parent.mkdir(parents=True)
implementation.write_text("class SqliteBookStore : BookStore { }\n", encoding="utf-8")
result = extract([definition, implementation], cache_root=tmp_path)
node_by_id = {node["id"]: node for node in result["nodes"]}
inherits_edges = [edge for edge in result["edges"] if edge["relation"] == "inherits"]
matching = [
edge for edge in inherits_edges
if node_by_id[edge["source"]]["label"] == "SqliteBookStore"
and node_by_id[edge["target"]]["label"] == "BookStore"
]
assert matching
assert matching[0]["target"] == next(
node["id"] for node in result["nodes"]
if node["label"] == "BookStore" and node.get("source_file") == "interfaces.py"
)
assert all(
not (node["label"] == "BookStore" and not node.get("source_file"))
for node in result["nodes"]
)
def test_extract_keeps_stub_when_multiple_real_definitions_match(tmp_path):
first = tmp_path / "a/interfaces.py"
second = tmp_path / "b/interfaces.py"
implementation = tmp_path / "services/BookStore.cs"
first.parent.mkdir(parents=True)
second.parent.mkdir(parents=True)
implementation.parent.mkdir(parents=True)
first.write_text("class BookStore:\n pass\n", encoding="utf-8")
second.write_text("class BookStore:\n pass\n", encoding="utf-8")
implementation.write_text("class SqliteBookStore : BookStore { }\n", encoding="utf-8")
result = extract([first, second, implementation], cache_root=tmp_path)
stubs = [
node for node in result["nodes"]
if node["label"] == "BookStore" and not node.get("source_file")
]
assert stubs
def test_extract_does_not_rewire_inheritance_stub_to_same_named_function(tmp_path):
definition = tmp_path / "factory.py"
implementation = tmp_path / "services/BookStore.cs"
definition.write_text("def BookStore():\n return object()\n", encoding="utf-8")
implementation.parent.mkdir(parents=True)
implementation.write_text("class SqliteBookStore : BookStore { }\n", encoding="utf-8")
result = extract([definition, implementation], cache_root=tmp_path)
node_by_id = {node["id"]: node for node in result["nodes"]}
inherits_edges = [edge for edge in result["edges"] if edge["relation"] == "inherits"]
assert any(
node["label"] == "BookStore" and not node.get("source_file")
for node in result["nodes"]
)
assert not any(
node_by_id[edge["source"]]["label"] == "SqliteBookStore"
and node_by_id[edge["target"]]["label"] == "BookStore()"
for edge in inherits_edges
)
def test_extract_does_not_rewire_constructor_method_to_same_named_class(tmp_path):
source = tmp_path / "Sample.java"
source.write_text(
"class DataProcessor {\n"
" public DataProcessor() {}\n"
"}\n",
encoding="utf-8",
)
result = extract([source], cache_root=tmp_path)
constructor_nodes = [
node for node in result["nodes"]
if node["label"] == ".DataProcessor()"
]
assert constructor_nodes
assert not any(
edge["source"] == edge["target"]
for edge in result["edges"]
)
def test_collect_files_from_dir():
from graphify.extract import _DISPATCH
files = collect_files(FIXTURES)
+399
View File
@@ -0,0 +1,399 @@
from __future__ import annotations
import json
from pathlib import Path
from graphify.extract import _file_stem, _make_id, extract
def _write(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
def _extract_for(paths: list[Path], root: Path):
return extract(paths, cache_root=root)
def _has_edge(result: dict, source: str, target: str, relation: str = "imports_from") -> bool:
expected = (_make_id(source), _make_id(target), relation)
actual = {
(edge["source"], edge["target"], edge["relation"])
for edge in result["edges"]
}
return expected in actual
def _has_symbol_edge(
result: dict,
source: str,
target_file: str,
symbol: str,
relation: str = "imports",
) -> bool:
expected = (_make_id(source), _make_id(_file_stem(Path(target_file)), symbol), relation)
actual = {
(edge["source"], edge["target"], edge["relation"])
for edge in result["edges"]
}
return expected in actual
def _has_symbol_to_symbol_edge(
result: dict,
source_file: str,
source_symbol: str,
target_file: str,
target_symbol: str,
relation: str,
) -> bool:
expected = (
_make_id(_file_stem(Path(source_file)), source_symbol),
_make_id(_file_stem(Path(target_file)), target_symbol),
relation,
)
actual = {
(edge["source"], edge["target"], edge["relation"])
for edge in result["edges"]
}
return expected in actual
def _has_no_symbol_to_symbol_edge(
result: dict,
source_file: str,
source_symbol: str,
target_file: str,
target_symbol: str,
relation: str,
) -> bool:
return not _has_symbol_to_symbol_edge(
result,
source_file,
source_symbol,
target_file,
target_symbol,
relation,
)
def test_ts_bare_relative_import_resolves_existing_ts_file(tmp_path: Path):
target = _write(tmp_path / "src/lib/foo.ts", "export const foo = 1\n")
importer = _write(
tmp_path / "src/lib/page.ts",
"import { foo } from './foo'\nconsole.log(foo)\n",
)
result = _extract_for([target, importer], tmp_path)
assert _has_edge(result, "src/lib/page.ts", "src/lib/foo.ts")
def test_ts_directory_import_resolves_index_ts(tmp_path: Path):
target = _write(tmp_path / "src/lib/server/queue/index.ts", "export const queue = 1\n")
importer = _write(
tmp_path / "src/lib/page.ts",
"import { queue } from './server/queue'\nconsole.log(queue)\n",
)
result = _extract_for([target, importer], tmp_path)
assert _has_edge(result, "src/lib/page.ts", "src/lib/server/queue/index.ts")
def test_ts_named_reexport_alias_from_index_resolves_imported_symbol_to_origin(tmp_path: Path):
target = _write(tmp_path / "src/lib/foo.ts", "export class InternalFoo { id = '' }\n")
barrel = _write(
tmp_path / "src/lib/index.ts",
"export { InternalFoo as Foo } from './foo'\n",
)
consumer = _write(
tmp_path / "src/routes/page.ts",
"import type { Foo } from '../lib/index'\nexport type X = Foo\n",
)
result = _extract_for([target, barrel, consumer], tmp_path)
assert _has_edge(result, "src/lib/index.ts", "src/lib/foo.ts", "re_exports")
assert _has_symbol_edge(
result,
"src/routes/page.ts",
"src/lib/foo.ts",
"InternalFoo",
)
def test_ts_export_star_from_index_resolves_imported_symbol_to_origin(tmp_path: Path):
target = _write(tmp_path / "src/lib/foo.ts", "export class Foo { id = '' }\n")
barrel = _write(tmp_path / "src/lib/index.ts", "export * from './foo'\n")
consumer = _write(
tmp_path / "src/routes/page.ts",
"import type { Foo } from '../lib/index'\nexport type X = Foo\n",
)
result = _extract_for([target, barrel, consumer], tmp_path)
assert _has_edge(result, "src/lib/index.ts", "src/lib/foo.ts", "re_exports")
assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "Foo")
def test_ts_import_alias_then_reexport_alias_resolves_imported_symbol_to_origin(tmp_path: Path):
target = _write(tmp_path / "src/lib/foo.ts", "export class Foo { id = '' }\n")
barrel = _write(
tmp_path / "src/lib/index.ts",
"import type { Foo as LocalFoo } from './foo'\nexport type { LocalFoo as PublicFoo }\n",
)
consumer = _write(
tmp_path / "src/routes/page.ts",
"import type { PublicFoo } from '../lib/index'\nexport type X = PublicFoo\n",
)
result = _extract_for([target, barrel, consumer], tmp_path)
assert _has_edge(result, "src/lib/index.ts", "src/lib/foo.ts", "re_exports")
assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "Foo")
def test_ts_import_from_index_then_exported_type_alias_resolves_to_origin_symbol(tmp_path: Path):
target = _write(tmp_path / "src/lib/foo.ts", "export class Foo { id = '' }\n")
barrel = _write(tmp_path / "src/lib/index.ts", "export { Foo } from './foo'\n")
consumer = _write(
tmp_path / "src/routes/page.ts",
"import type { Foo } from '../lib/index'\nexport type X = Foo\n",
)
result = _extract_for([target, barrel, consumer], tmp_path)
assert _has_edge(result, "src/lib/index.ts", "src/lib/foo.ts", "re_exports")
assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "Foo")
def test_ts_reexported_interface_resolves_imported_symbol_to_origin(tmp_path: Path):
target = _write(tmp_path / "src/lib/foo.ts", "export interface Foo { id: string }\n")
barrel = _write(tmp_path / "src/lib/index.ts", "export type { Foo } from './foo'\n")
consumer = _write(
tmp_path / "src/routes/page.ts",
"import type { Foo } from '../lib/index'\nexport type X = Foo\n",
)
result = _extract_for([target, barrel, consumer], tmp_path)
assert _has_edge(result, "src/lib/index.ts", "src/lib/foo.ts", "re_exports")
assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "Foo")
def test_ts_reexported_type_alias_resolves_imported_symbol_to_origin(tmp_path: Path):
target = _write(tmp_path / "src/lib/foo.ts", "export type Foo = { id: string }\n")
barrel = _write(tmp_path / "src/lib/index.ts", "export type { Foo } from './foo'\n")
consumer = _write(
tmp_path / "src/routes/page.ts",
"import type { Foo } from '../lib/index'\nexport type X = Foo\n",
)
result = _extract_for([target, barrel, consumer], tmp_path)
assert _has_edge(result, "src/lib/index.ts", "src/lib/foo.ts", "re_exports")
assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "Foo")
def test_ts_reexported_abstract_class_resolves_imported_symbol_to_origin(tmp_path: Path):
target = _write(tmp_path / "src/lib/foo.ts", "export abstract class Foo { abstract run(): void }\n")
barrel = _write(tmp_path / "src/lib/index.ts", "export { Foo } from './foo'\n")
consumer = _write(
tmp_path / "src/routes/page.ts",
"import { Foo } from '../lib/index'\nclass Impl extends Foo { run() {} }\n",
)
result = _extract_for([target, barrel, consumer], tmp_path)
assert _has_edge(result, "src/lib/index.ts", "src/lib/foo.ts", "re_exports")
assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "Foo")
def test_ts_const_alias_reexport_resolves_imported_symbol_to_origin(tmp_path: Path):
target = _write(tmp_path / "src/lib/foo.ts", "export class Foo { id = '' }\n")
barrel = _write(
tmp_path / "src/lib/index.ts",
"import { Foo } from './foo'\nexport const PublicFoo = Foo\n",
)
consumer = _write(
tmp_path / "src/routes/page.ts",
"import { PublicFoo } from '../lib/index'\nnew PublicFoo()\n",
)
result = _extract_for([target, barrel, consumer], tmp_path)
assert _has_edge(result, "src/lib/index.ts", "src/lib/foo.ts", "re_exports")
assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "Foo")
def test_ts_local_const_alias_then_named_reexport_resolves_imported_symbol_to_origin(tmp_path: Path):
target = _write(tmp_path / "src/lib/foo.ts", "export function makeFoo() { return {} }\n")
barrel = _write(
tmp_path / "src/lib/index.ts",
"import { makeFoo } from './foo'\nconst PublicFactory = makeFoo\nexport { PublicFactory }\n",
)
consumer = _write(
tmp_path / "src/routes/page.ts",
"import { PublicFactory } from '../lib/index'\nPublicFactory()\n",
)
result = _extract_for([target, barrel, consumer], tmp_path)
assert _has_edge(result, "src/lib/index.ts", "src/lib/foo.ts", "re_exports")
assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "makeFoo")
def test_ts_arrow_function_call_through_barrel_targets_origin_symbol(tmp_path: Path):
target = _write(tmp_path / "src/lib/foo.ts", "export function Foo() { return 1 }\n")
unrelated = _write(tmp_path / "src/other/foo.ts", "export function Foo() { return 2 }\n")
barrel = _write(tmp_path / "src/lib/index.ts", "export { Foo } from './foo'\n")
consumer = _write(
tmp_path / "src/routes/page.ts",
"import { Foo } from '../lib/index'\nconst X = () => Foo()\n",
)
result = _extract_for([target, unrelated, barrel, consumer], tmp_path)
assert _has_symbol_to_symbol_edge(
result,
"src/routes/page.ts",
"X",
"src/lib/foo.ts",
"Foo",
"calls",
)
def test_ts_import_alias_does_not_affect_same_named_local_symbol_when_unused(tmp_path: Path):
target = _write(tmp_path / "src/lib/foo.ts", "export function Foo() { return 1 }\n")
barrel = _write(tmp_path / "src/lib/index.ts", "export { Foo } from './foo'\n")
consumer = _write(
tmp_path / "src/routes/page.ts",
"import { Foo as Bar } from '../lib/index'\nconst Foo = () => {}\n",
)
result = _extract_for([target, barrel, consumer], tmp_path)
assert _has_no_symbol_to_symbol_edge(
result,
"src/routes/page.ts",
"Foo",
"src/lib/foo.ts",
"Foo",
"calls",
)
def test_ts_import_alias_call_from_same_named_local_symbol_targets_origin(tmp_path: Path):
target = _write(tmp_path / "src/lib/foo.ts", "export function Foo() { return 1 }\n")
barrel = _write(tmp_path / "src/lib/index.ts", "export { Foo } from './foo'\n")
consumer = _write(
tmp_path / "src/routes/page.ts",
"import { Foo as Bar } from '../lib/index'\nconst Foo = () => Bar()\n",
)
result = _extract_for([target, barrel, consumer], tmp_path)
assert _has_symbol_to_symbol_edge(
result,
"src/routes/page.ts",
"Foo",
"src/lib/foo.ts",
"Foo",
"calls",
)
def test_svelte_rune_import_resolves_svelte_ts_file(tmp_path: Path):
target = _write(tmp_path / "src/lib/hooks/is-mobile.svelte.ts", "export const isMobile = true\n")
importer = _write(
tmp_path / "src/routes/page.ts",
"import { isMobile } from '../lib/hooks/is-mobile.svelte'\nconsole.log(isMobile)\n",
)
result = _extract_for([target, importer], tmp_path)
assert _has_edge(result, "src/routes/page.ts", "src/lib/hooks/is-mobile.svelte.ts")
def test_tsconfig_alias_import_resolves_existing_ts_file(tmp_path: Path):
_write(
tmp_path / "tsconfig.json",
json.dumps({"compilerOptions": {"baseUrl": ".", "paths": {"$lib/*": ["src/lib/*"]}}}),
)
target = _write(tmp_path / "src/lib/types/type-helpers.ts", "export type Helper = string\n")
importer = _write(
tmp_path / "src/routes/page.ts",
"import type { Helper } from '$lib/types/type-helpers'\nconst value: Helper = 'x'\n",
)
result = _extract_for([target, importer], tmp_path)
assert _has_edge(result, "src/routes/page.ts", "src/lib/types/type-helpers.ts")
def test_pnpm_workspace_package_import_resolves_package_entry(tmp_path: Path):
_write(
tmp_path / "pnpm-workspace.yaml",
"packages:\n - 'apps/*'\n - 'packages/*'\n",
)
_write(
tmp_path / "packages/types/package.json",
json.dumps({"name": "@workspace/types", "exports": "./src/index.ts"}),
)
target = _write(
tmp_path / "packages/types/src/index.ts",
"export interface SomeDto { id: string }\n",
)
importer = _write(
tmp_path / "apps/web/src/page.ts",
"import type { SomeDto } from '@workspace/types'\nconst dto: SomeDto = { id: '1' }\n",
)
result = _extract_for([target, importer], tmp_path)
assert _has_edge(result, "apps/web/src/page.ts", "packages/types/src/index.ts")
def test_js_import_resolution_ignores_stale_importer_cache_when_target_appears(tmp_path: Path):
importer = _write(
tmp_path / "src/lib/page.ts",
"import { foo } from './foo'\nconsole.log(foo)\n",
)
first = _extract_for([importer], tmp_path)
assert not _has_edge(first, "src/lib/page.ts", "src/lib/foo.ts")
target = _write(tmp_path / "src/lib/foo.ts", "export const foo = 1\n")
second = _extract_for([target, importer], tmp_path)
assert _has_edge(second, "src/lib/page.ts", "src/lib/foo.ts")
def test_workspace_package_cache_refreshes_between_extract_calls(tmp_path: Path):
_write(
tmp_path / "pnpm-workspace.yaml",
"packages:\n - 'apps/*'\n - 'packages/*'\n",
)
importer = _write(
tmp_path / "apps/web/src/page.ts",
"import type { SomeDto } from '@workspace/types'\nconst dto: SomeDto = { id: '1' }\n",
)
first = _extract_for([importer], tmp_path)
assert not _has_edge(first, "apps/web/src/page.ts", "packages/types/src/index.ts")
_write(
tmp_path / "packages/types/package.json",
json.dumps({"name": "@workspace/types", "exports": "./src/index.ts"}),
)
target = _write(
tmp_path / "packages/types/src/index.ts",
"export interface SomeDto { id: string }\n",
)
second = _extract_for([target, importer], tmp_path)
assert _has_edge(second, "apps/web/src/page.ts", "packages/types/src/index.ts")
+14 -9
View File
@@ -242,41 +242,46 @@ def test_cache_miss_after_file_change(tmp_path):
# ── 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(FIXTURES / "sample.sql")
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_finds_view():
r = extract_sql(FIXTURES / "sample.sql")
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(FIXTURES / "sample.sql")
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(FIXTURES / "sample.sql")
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(FIXTURES / "sample.sql")
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(FIXTURES / "sample.sql")
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_alter_table_fk_edge():
"""ALTER TABLE ... FOREIGN KEY ... REFERENCES produces a references edge."""
r = extract_sql(FIXTURES / "sample_alter_fk.sql")
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"]}
@@ -286,14 +291,14 @@ def test_sql_alter_table_fk_edge():
def test_sql_schema_qualified_names():
"""Schema-qualified table names (Schema.Table) are preserved."""
r = extract_sql(FIXTURES / "sample_schema_qualified.sql")
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(FIXTURES / "sample_schema_qualified.sql")
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"]}
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from pathlib import Path
from graphify.extract import extract
def _write(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
def _node_id(result: dict, label: str, source_file: str) -> str:
matches = [
node["id"]
for node in result["nodes"]
if node.get("label") == label and node.get("source_file") == source_file
]
assert len(matches) == 1
return matches[0]
def _has_edge(result: dict, source: str, target: str, relation: str) -> bool:
return any(
edge["source"] == source
and edge["target"] == target
and edge["relation"] == relation
for edge in result["edges"]
)
def test_python_package_reexport_resolves_import_and_call_to_origin_symbol(tmp_path: Path):
origin = _write(tmp_path / "pkg/foo.py", "def Foo():\n return 1\n")
barrel = _write(tmp_path / "pkg/__init__.py", "from .foo import Foo as PublicFoo\n")
consumer = _write(
tmp_path / "app.py",
"from pkg import PublicFoo\n\n"
"def X():\n"
" return PublicFoo()\n",
)
result = extract([origin, barrel, consumer], cache_root=tmp_path)
origin_file = _node_id(result, "foo.py", "pkg/foo.py")
barrel_file = _node_id(result, "__init__.py", "pkg/__init__.py")
consumer_file = _node_id(result, "app.py", "app.py")
origin_symbol = _node_id(result, "Foo()", "pkg/foo.py")
consumer_symbol = _node_id(result, "X()", "app.py")
assert _has_edge(result, barrel_file, origin_file, "re_exports")
assert _has_edge(result, consumer_file, origin_symbol, "imports")
assert _has_edge(result, consumer_symbol, origin_symbol, "calls")