feat: add v8 affected and import-resolution support
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"]}
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user