mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 23:49:14 +00:00
feat(terraform): resolve local module sources and directory topology
(cherry picked from commit e00593d10e)
This commit is contained in:
1 parent
c805c6fb9d
commit
161e5f2ef5
8 files changed
+439
-6
No files matched your search
@@ -355,6 +355,21 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg
|
||||
| Video / Audio | `.mp4 .mov .mp3 .wav` and more (requires `uv tool install graphifyy[video]`) |
|
||||
| YouTube / URLs | any video URL (requires `uv tool install graphifyy[video]`) |
|
||||
|
||||
Terraform module calls with a literal local `source` (`./...` or `../...`) link
|
||||
to a directory module node through an `EXTRACTED` `module_source` edge. Each
|
||||
directory node contains its scanned `.tf` files, so nested calls expose paths
|
||||
such as environment → application → base → resource. Scan the common repository
|
||||
root to include both callers and implementations. Paths resolve relative to the
|
||||
calling module, and excluded or out-of-root files are never loaded implicitly.
|
||||
|
||||
Remote sources and source expressions are not resolved; `.tfvars`, generic
|
||||
`.hcl`, and `.tf.json` files do not define module-source targets. References to
|
||||
`module.app.output` still target the module call rather than its implementation's
|
||||
output. The graph represents source configuration, not evaluated Terraform
|
||||
instances. Incremental Terraform changes reconcile the scanned `.tf` corpus,
|
||||
reusing cached syntax for unchanged files. After upgrading an existing graph,
|
||||
run `graphify update .` once to regenerate Terraform IDs and topology.
|
||||
|
||||
Code is extracted **locally with no API calls** (AST via tree-sitter). Everything else goes through your AI assistant's model API.
|
||||
|
||||
Google Drive for desktop `.gdoc`, `.gsheet`, and `.gslides` files are shortcut
|
||||
|
||||
@@ -2026,6 +2026,20 @@ def build_merge(
|
||||
continue # the new chunks re-emitted it — theirs wins
|
||||
carried.append(he)
|
||||
|
||||
# Remove replaced deleted-source records before entity dedup can choose their stale
|
||||
# provenance over a freshly emitted node with the same ID. A Terraform
|
||||
# directory anchor, for example, survives deletion of its first .tf file
|
||||
# with a new source_file; pruning only AFTER build would delete the live
|
||||
# anchor too if dedup selected the old record.
|
||||
if prune_sources:
|
||||
fresh_ids = {n.get("id") for chunk in new_chunks for n in chunk.get("nodes", [])}
|
||||
existing_nodes = [
|
||||
n for n in existing_nodes
|
||||
if not (n.get("id") in fresh_ids and _prune_match(n.get("source_file")))
|
||||
]
|
||||
# Other deleted records and edges stay until the normal prune below:
|
||||
# it needs their connectivity to identify newly orphaned import stubs.
|
||||
|
||||
base = (
|
||||
[{"nodes": existing_nodes, "edges": existing_edges, "hyperedges": carried_hyperedges}]
|
||||
if had_graph else []
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ except Exception:
|
||||
_EXTRACTOR_VERSION = "unknown"
|
||||
|
||||
# Bump when AST cache-key semantics change independently of the package version.
|
||||
_AST_CACHE_SCHEMA = 2
|
||||
_AST_CACHE_SCHEMA = 3 # Terraform directory-scoped IDs and module-source syntax facts.
|
||||
|
||||
# Version dirs already swept this process — cleanup runs once per (base, version).
|
||||
_cleaned_ast_dirs: set[str] = set()
|
||||
|
||||
@@ -3741,6 +3741,13 @@ def dispatch_command(cmd: str) -> None:
|
||||
if detection.get("walk_errors"):
|
||||
_extraction_incomplete = True
|
||||
|
||||
if incremental_mode:
|
||||
from graphify.extractors.terraform import refresh_terraform_paths
|
||||
code_files = refresh_terraform_paths(
|
||||
code_files, [Path(p) for p in files_by_type.get("code", [])],
|
||||
[Path(p) for p in [*deleted_files, *excluded_files, *graph_stale_sources]],
|
||||
)
|
||||
|
||||
# AST extraction on code files. Empty code list (docs-only corpus) is
|
||||
# the issue #698 case — skip cleanly instead of crashing inside extract().
|
||||
ast_result: dict = {"nodes": [], "edges": [], "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -3797,6 +3804,7 @@ def dispatch_command(cmd: str) -> None:
|
||||
for f in _flist
|
||||
}
|
||||
_ctx_live.discard(None)
|
||||
_ctx_live.difference_update(_ctx_identity(p) for p in code_files)
|
||||
for _node in _ctx_graph.get("nodes", []):
|
||||
if not _node.get("id") or not _ctx_is_ast_tier(_node):
|
||||
continue
|
||||
|
||||
+8
-1
@@ -58,7 +58,7 @@ from graphify.extractors.robot import extract_robot # noqa: F401
|
||||
from graphify.extractors.rust import extract_rust # noqa: F401
|
||||
from graphify.extractors.sln import extract_sln # noqa: F401
|
||||
from graphify.extractors.sql import extract_sql # noqa: F401
|
||||
from graphify.extractors.terraform import extract_terraform # noqa: F401
|
||||
from graphify.extractors.terraform import extract_terraform, prepare_terraform, resolve_terraform_modules # noqa: F401
|
||||
from graphify.extractors.verilog import extract_verilog # noqa: F401
|
||||
from graphify.extractors.zig import extract_zig # noqa: F401
|
||||
from graphify.security import sanitize_metadata
|
||||
@@ -4729,6 +4729,9 @@ _KOTLIN_IMPORT_TARGET_RESOLVER = LanguageResolver(
|
||||
# registry (framework lives in graphify.resolver_registry). A new language plugs in
|
||||
# by adding one register() call below — no edits to extract()'s body. Order
|
||||
# preserved from the prior inlined wiring: Swift (#1356) before Python (#1446).
|
||||
register_language_resolver(
|
||||
LanguageResolver("terraform_modules", frozenset({".tf"}), resolve_terraform_modules)
|
||||
)
|
||||
register_language_resolver(
|
||||
LanguageResolver("swift_member_calls", frozenset({".swift"}), _resolve_swift_member_calls)
|
||||
)
|
||||
@@ -6659,6 +6662,10 @@ def extract(
|
||||
file=sys.stderr, flush=True,
|
||||
)
|
||||
|
||||
for path, result in zip(paths, per_file):
|
||||
if path.suffix in (".tf", ".tfvars", ".hcl"):
|
||||
prepare_terraform(result, path, root)
|
||||
|
||||
all_nodes: list[dict] = []
|
||||
all_edges: list[dict] = []
|
||||
all_raw_calls: list[dict] = []
|
||||
|
||||
@@ -1,13 +1,121 @@
|
||||
"""Terraform extractor. Moved verbatim from graphify/extract.py."""
|
||||
"""Terraform block extraction and local module-source topology."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from graphify.extractors.base import _make_id
|
||||
|
||||
|
||||
_TF_META_HEADS = frozenset({"count", "each", "self", "path", "terraform"})
|
||||
|
||||
|
||||
def _scope_id(directory: str) -> str:
|
||||
# Hash the exact path too: make_id folds case and punctuation, which must
|
||||
# not merge e.g. dev/app with prod/app, or a-b with a_b.
|
||||
digest = hashlib.sha256(directory.encode("utf-8")).hexdigest()[:16]
|
||||
return _make_id("terraform", directory, digest)
|
||||
|
||||
|
||||
def prepare_terraform(result: dict, path: Path, root: Path) -> None:
|
||||
"""Make cached directory-scoped IDs portable before the common merge passes."""
|
||||
directory = path.parent.resolve()
|
||||
try:
|
||||
relative = directory.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
return
|
||||
old, new = _scope_id(directory.as_posix()), _scope_id(relative)
|
||||
for node in result.get("nodes", []):
|
||||
nid = node["id"]
|
||||
if nid.startswith(old + "_"):
|
||||
node["id"] = new + nid[len(old):]
|
||||
node["_terraform_directory"] = relative
|
||||
for edge in result.get("edges", []):
|
||||
for key in ("source", "target"):
|
||||
nid = edge[key]
|
||||
if nid.startswith(old + "_"):
|
||||
edge[key] = new + nid[len(old):]
|
||||
# Resolve paths only here, with the scan boundary available. The per-file
|
||||
# cache keeps syntax facts, never filesystem-dependent topology.
|
||||
for node in result.get("nodes", []):
|
||||
source = node.get("module_source", "")
|
||||
if not source.startswith(("./", "../")):
|
||||
continue
|
||||
try:
|
||||
target = (directory / source).resolve().relative_to(root)
|
||||
except (ValueError, OSError, RuntimeError):
|
||||
continue
|
||||
node["_terraform_source_directory"] = target.as_posix()
|
||||
|
||||
|
||||
def resolve_terraform_modules(per_file: list[dict], nodes: list[dict], edges: list[dict]) -> None:
|
||||
"""Connect module calls to scanned .tf directories without reading new files."""
|
||||
files: dict[str, list[dict]] = {}
|
||||
anchors = {}
|
||||
for node in nodes:
|
||||
directory = node.get("_terraform_directory")
|
||||
if directory is None:
|
||||
continue
|
||||
if node.get("type") == "terraform_file" and Path(node["source_file"]).suffix == ".tf":
|
||||
files.setdefault(directory, []).append(node)
|
||||
elif node.get("type") == "module" and node.get("language") == "terraform":
|
||||
anchors[directory] = node["id"]
|
||||
fresh_files = {
|
||||
node["id"] for result in per_file for node in result.get("nodes", [])
|
||||
if node.get("type") == "terraform_file"
|
||||
}
|
||||
known_ids = {n["id"] for n in nodes}
|
||||
for directory, members in sorted(files.items()):
|
||||
anchor = anchors.setdefault(directory, _make_id(_scope_id(directory), "directory"))
|
||||
if anchor not in known_ids:
|
||||
owner = min(members, key=lambda n: n["source_file"])
|
||||
nodes.append({
|
||||
"id": anchor, "label": f"Terraform module: {directory}",
|
||||
"type": "module", "language": "terraform", "file_type": "code",
|
||||
"source_file": owner["source_file"], "source_location": None,
|
||||
"_terraform_directory": directory,
|
||||
})
|
||||
known_ids.add(anchor)
|
||||
for member in members:
|
||||
if member["id"] in fresh_files:
|
||||
edges.append({
|
||||
"source": anchor, "target": member["id"], "relation": "contains",
|
||||
"confidence": "EXTRACTED", "weight": 1.0,
|
||||
"source_file": member["source_file"], "source_location": None,
|
||||
})
|
||||
fresh_calls = {
|
||||
node["id"] for result in per_file for node in result.get("nodes", [])
|
||||
if "module_source" in node
|
||||
}
|
||||
for node in nodes:
|
||||
if node["id"] not in fresh_calls:
|
||||
continue
|
||||
target = anchors.get(node.get("_terraform_source_directory"))
|
||||
if target is not None:
|
||||
edges.append({
|
||||
"source": node["id"], "target": target, "relation": "module_source",
|
||||
"confidence": "EXTRACTED", "weight": 1.0,
|
||||
"source_file": node["source_file"],
|
||||
"source_location": node["module_source_location"],
|
||||
})
|
||||
|
||||
|
||||
def refresh_terraform_paths(
|
||||
changed: list[Path], corpus: list[Path], invalidated: Sequence[Path] = (),
|
||||
) -> list[Path]:
|
||||
"""Reconcile directory ownership and incoming links on incremental scans.
|
||||
|
||||
Callers pass only the permitted, live corpus. Cached syntax is reused for
|
||||
unchanged Terraform files; other languages keep their incremental behavior.
|
||||
"""
|
||||
if not any(p.suffix == ".tf" or p.name in (".gitignore", ".graphifyignore")
|
||||
for p in [*changed, *invalidated]):
|
||||
return changed
|
||||
return list(dict.fromkeys([*changed, *(p for p in corpus if p.suffix == ".tf")]))
|
||||
|
||||
|
||||
def extract_terraform(path: Path) -> dict:
|
||||
"""Extract Terraform/HCL blocks and the references between them via tree-sitter.
|
||||
|
||||
@@ -37,14 +145,16 @@ def extract_terraform(path: Path) -> dict:
|
||||
except Exception as e:
|
||||
return {"nodes": [], "edges": [], "error": str(e)}
|
||||
|
||||
str_path = str(path)
|
||||
str_path = str(path.parent.resolve() / path.name)
|
||||
file_nid = _make_id(str_path)
|
||||
scope = path.parent.name or "tf"
|
||||
scope = _scope_id(path.parent.resolve().as_posix())
|
||||
|
||||
nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code",
|
||||
"source_file": str_path, "source_location": None}]
|
||||
"source_file": str_path, "source_location": None,
|
||||
"type": "terraform_file"}]
|
||||
edges: list[dict] = []
|
||||
seen_ids: set[str] = {file_nid}
|
||||
nodes_by_id = {file_nid: nodes[0]}
|
||||
seen_edges: set[tuple[str, str, str]] = set()
|
||||
|
||||
def _read(n) -> str:
|
||||
@@ -59,6 +169,7 @@ def extract_terraform(path: Path) -> dict:
|
||||
seen_ids.add(nid)
|
||||
nodes.append({"id": nid, "label": label, "file_type": "code",
|
||||
"source_file": str_path, "source_location": f"L{line}"})
|
||||
nodes_by_id[nid] = nodes[-1]
|
||||
edges.append({"source": file_nid, "target": nid, "relation": "contains",
|
||||
"confidence": "EXTRACTED", "source_file": str_path,
|
||||
"source_location": f"L{line}", "weight": 1.0})
|
||||
@@ -156,6 +267,25 @@ def extract_terraform(path: Path) -> dict:
|
||||
owner = _add_node(f"data.{labels[0]}.{labels[1]}", f"data.{labels[0]}.{labels[1]}", line)
|
||||
elif btype == "module" and labels:
|
||||
owner = _add_node(f"module.{labels[0]}", f"module.{labels[0]}", line)
|
||||
if blk_body is not None and path.suffix == ".tf":
|
||||
for attr in blk_body.named_children:
|
||||
if attr.type != "attribute" or _read(attr.named_children[0]) != "source":
|
||||
continue
|
||||
# Accept a plain quoted literal only, not interpolation,
|
||||
# heredocs, or constant expressions requiring evaluation.
|
||||
value = attr.named_children[-1]
|
||||
while value.type in ("expression", "literal_value") and len(value.named_children) == 1:
|
||||
value = value.named_children[0]
|
||||
if value.type != "string_lit" or value.has_error:
|
||||
continue
|
||||
try:
|
||||
module_source = json.loads(_read(value))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if not isinstance(module_source, str):
|
||||
continue
|
||||
nodes_by_id[owner]["module_source"] = module_source.replace("$${", "${").replace("%%{", "%{")
|
||||
nodes_by_id[owner]["module_source_location"] = f"L{attr.start_point[0] + 1}"
|
||||
elif btype == "variable" and labels:
|
||||
owner = _add_node(f"var.{labels[0]}", f"var.{labels[0]}", line)
|
||||
elif btype == "output" and labels:
|
||||
|
||||
@@ -1600,6 +1600,8 @@ def _rebuild_code(
|
||||
# File was deleted or renamed away inside the watched root.
|
||||
# Evict preserved nodes that still claim this source path.
|
||||
_add_deleted_source(deleted_in_root)
|
||||
from graphify.extractors.terraform import refresh_terraform_paths
|
||||
wanted = refresh_terraform_paths(wanted, code_files, changed_paths)
|
||||
if not wanted and not deleted_paths:
|
||||
print("[graphify watch] No tracked code files in change set - skipping rebuild.")
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Local Terraform module topology, including incremental graph reconciliation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import networkx as nx
|
||||
import pytest
|
||||
|
||||
from graphify.build import build_from_json
|
||||
from graphify.extract import extract, extract_terraform
|
||||
|
||||
|
||||
def _write(root, name, body):
|
||||
path = root / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(body, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _extract(root, paths=None):
|
||||
return extract(
|
||||
sorted(root.rglob("*.tf")) if paths is None else paths,
|
||||
root=root, cache_root=root, parallel=False,
|
||||
)
|
||||
|
||||
|
||||
def _node(result, label, source=None):
|
||||
return next(n for n in result["nodes"] if n["label"] == label
|
||||
and (source is None or n["source_file"] == source))
|
||||
|
||||
|
||||
def _module_edges(result):
|
||||
return [e for e in result.get("links", result.get("edges", []))
|
||||
if e.get("relation") == "module_source"]
|
||||
|
||||
|
||||
def _assert_integrity(result):
|
||||
ids = {n["id"] for n in result["nodes"]}
|
||||
for e in result.get("links", result.get("edges", [])):
|
||||
assert e["source"] in ids, e
|
||||
assert e["target"] in ids, e
|
||||
|
||||
|
||||
def test_environment_application_base_topology_and_source_provenance(tmp_path):
|
||||
_write(tmp_path, "envs/dev/main.tf", 'module "app" {\n source = "../../applications/app"\n}\n')
|
||||
_write(tmp_path, "applications/app/main.tf", 'module "base" { source = "../../base" }\n')
|
||||
_write(tmp_path, "applications/app/outputs.tf", 'output "id" { value = "app" }\n')
|
||||
_write(tmp_path, "base/storage.tf", 'resource "aws_s3_bucket" "data" {}\n')
|
||||
result = _extract(tmp_path)
|
||||
_assert_integrity(result)
|
||||
G = build_from_json(result, root=tmp_path, directed=True)
|
||||
env = _node(result, "Terraform module: envs/dev")
|
||||
resource = _node(result, "aws_s3_bucket.data")
|
||||
assert nx.has_path(G, env["id"], resource["id"])
|
||||
app = _node(result, "Terraform module: applications/app")
|
||||
outputs = _node(result, "outputs.tf")
|
||||
assert G.has_edge(app["id"], outputs["id"])
|
||||
edge = next(e for e in _module_edges(result) if e["target"] == app["id"])
|
||||
assert edge["source_file"] == "envs/dev/main.tf"
|
||||
assert edge["source_location"] == "L2"
|
||||
assert edge["confidence"] == "EXTRACTED"
|
||||
assert len(_module_edges(result)) == 2
|
||||
|
||||
|
||||
def test_same_named_directories_and_cross_file_references_stay_separate(tmp_path):
|
||||
for directory in ("dev/app", "prod/app", "a-b/app", "a_b/app"):
|
||||
_write(tmp_path, f"{directory}/main.tf", 'variable "name" {}\n')
|
||||
_write(tmp_path, f"{directory}/use.tf", 'output "name" { value = var.name }\n')
|
||||
result = _extract(tmp_path)
|
||||
_assert_integrity(result)
|
||||
variables = [n for n in result["nodes"] if n["label"] == "var.name"]
|
||||
assert len({n["id"] for n in variables}) == 4
|
||||
for variable in variables:
|
||||
source = str(Path(variable["source_file"]).parent / "use.tf")
|
||||
output = _node(result, "output.name", source)
|
||||
assert any(e["source"] == output["id"] and e["target"] == variable["id"]
|
||||
for e in result["edges"])
|
||||
|
||||
|
||||
def test_raw_extractor_scopes_ids_by_full_directory(tmp_path):
|
||||
paths = [_write(tmp_path, f"{env}/app/main.tf", 'variable "x" {}')
|
||||
for env in ("dev", "prod")]
|
||||
ids = [_node(extract_terraform(p), "var.x")["id"] for p in paths]
|
||||
assert len(set(ids)) == 2
|
||||
|
||||
|
||||
def test_multiple_calls_share_one_module_and_cycles_are_finite(tmp_path):
|
||||
_write(tmp_path, "main.tf", 'module "a" { source = "./child" }\nmodule "b" { source = "./child" }\n')
|
||||
_write(tmp_path, "child/main.tf", 'module "parent" { source = "../" }\n')
|
||||
result = _extract(tmp_path)
|
||||
_assert_integrity(result)
|
||||
assert len([n for n in result["nodes"] if n.get("type") == "module"]) == 2
|
||||
child_id = _node(result, "Terraform module: child")["id"]
|
||||
assert sum(e["target"] == child_id for e in _module_edges(result)) == 2
|
||||
assert len(_module_edges(result)) == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", [
|
||||
'"hashicorp/consul/aws"', '"git::https://example.com/module.git//sub?ref=v1"',
|
||||
'"https://example.com/module.zip"', '"../missing"',
|
||||
'"../${var.name}"', 'var.source', '"../app" + "suffix"',
|
||||
])
|
||||
def test_unresolved_sources_do_not_create_topology(tmp_path, source):
|
||||
_write(tmp_path, "env/main.tf", f'module "app" {{ source = {source} }}\n')
|
||||
_write(tmp_path, "app/main.tf", 'resource "aws_s3_bucket" "data" {}\n')
|
||||
assert _module_edges(_extract(tmp_path)) == []
|
||||
|
||||
|
||||
def test_ignored_outside_and_non_tf_targets_are_not_followed(tmp_path):
|
||||
root = tmp_path / "repo"
|
||||
caller = _write(root, "main.tf", '''
|
||||
module "outside" { source = "../outside" }
|
||||
module "excluded" { source = "./excluded" }
|
||||
module "hcl" { source = "./hcl" }
|
||||
module "vars" { source = "./vars" }
|
||||
module "nested" { source = "./nested" }
|
||||
''')
|
||||
_write(tmp_path, "outside/main.tf", 'variable "x" {}')
|
||||
_write(root, "excluded/main.tf", 'variable "x" {}')
|
||||
hcl = _write(root, "hcl/main.hcl", 'variable "x" {}')
|
||||
tfvars = _write(root, "vars/main.tfvars", 'x = 1')
|
||||
nested = _write(root, "nested/sub/main.tf", 'variable "x" {}')
|
||||
result = _extract(root, [caller, hcl, tfvars, nested])
|
||||
assert _module_edges(result) == []
|
||||
assert not any(n.get("_terraform_directory") in ("excluded", "hcl", "vars", "nested")
|
||||
for n in result["nodes"] if n.get("type") == "module")
|
||||
|
||||
|
||||
def test_source_literals_escapes_and_nested_source_attributes(tmp_path):
|
||||
_write(tmp_path, "main.tf", r'''module "app" {
|
||||
source = "./a\u0070p"
|
||||
settings = { source = "./wrong" }
|
||||
}
|
||||
module "escaped" { source = "./$${literal}" }
|
||||
''')
|
||||
_write(tmp_path, "app/main.tf", 'variable "x" {}')
|
||||
_write(tmp_path, "${literal}/main.tf", 'variable "x" {}')
|
||||
result = _extract(tmp_path)
|
||||
assert _node(result, "module.app")["module_source"] == "./app"
|
||||
assert len(_module_edges(result)) == 2
|
||||
|
||||
|
||||
def test_symlink_targets_respect_scan_boundary(tmp_path):
|
||||
root = tmp_path / "repo"
|
||||
_write(root, "main.tf", 'module "inside" { source = "./alias" }\nmodule "outside" { source = "./escape" }')
|
||||
_write(root, "app/main.tf", 'variable "x" {}')
|
||||
_write(tmp_path, "outside/main.tf", 'variable "y" {}')
|
||||
try:
|
||||
(root / "alias").symlink_to(root / "app", target_is_directory=True)
|
||||
(root / "escape").symlink_to(tmp_path / "outside", target_is_directory=True)
|
||||
except OSError:
|
||||
pytest.skip("symlinks unavailable")
|
||||
result = _extract(root, [root / "main.tf", root / "app/main.tf"])
|
||||
assert len(_module_edges(result)) == 1
|
||||
assert _module_edges(result)[0]["target"] == _node(result, "Terraform module: app")["id"]
|
||||
|
||||
|
||||
def test_cache_recomputes_topology_and_ids_are_portable(tmp_path, monkeypatch):
|
||||
roots = [tmp_path / name for name in ("one", "two")]
|
||||
for root in roots:
|
||||
_write(root, "main.tf", 'module "app" { source = "./app" }')
|
||||
_write(root, "app/main.tf", 'variable "x" {}')
|
||||
first = _extract(roots[0])
|
||||
from graphify.extract import _DISPATCH
|
||||
with monkeypatch.context() as cached:
|
||||
def unexpected_parse(path):
|
||||
raise AssertionError(f"cache miss for unchanged {path}")
|
||||
cached.setitem(_DISPATCH, ".tf", unexpected_parse)
|
||||
assert first == _extract(roots[0])
|
||||
second = _extract(roots[1])
|
||||
assert first["nodes"] == second["nodes"]
|
||||
assert first["edges"] == second["edges"]
|
||||
monkeypatch.chdir(roots[0])
|
||||
relative = _extract(Path("."), [Path("app/main.tf"), Path("main.tf")])
|
||||
assert first["nodes"] == relative["nodes"]
|
||||
assert first["edges"] == relative["edges"]
|
||||
target = roots[0] / "app/main.tf"
|
||||
target.unlink()
|
||||
result = _extract(roots[0])
|
||||
assert _module_edges(result) == []
|
||||
assert not any(n["label"] == "Terraform module: app" for n in result["nodes"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["watch", "cli"])
|
||||
@pytest.mark.parametrize("no_cluster", [True, False])
|
||||
def test_incremental_source_change_target_addition_and_deletion(tmp_path, monkeypatch, mode, no_cluster):
|
||||
root = tmp_path / "repo"
|
||||
caller = _write(root, "env/main.tf", 'module "app" { source = "../app" }')
|
||||
a = _write(root, "app/a.tf", 'variable "x" {}')
|
||||
b = _write(root, "app/b.tf", 'variable "y" {}')
|
||||
_write(root, "other/main.tf", 'variable "z" {}')
|
||||
project_path = str(Path(__file__).resolve().parents[1])
|
||||
monkeypatch.setenv("PYTHONPATH", project_path)
|
||||
monkeypatch.chdir(root)
|
||||
|
||||
def rebuild(changed=None):
|
||||
if mode == "watch":
|
||||
from graphify.watch import _rebuild_code
|
||||
assert _rebuild_code(root, changed_paths=changed, acquire_lock=False,
|
||||
no_cluster=no_cluster)
|
||||
else:
|
||||
args = [sys.executable, "-m", "graphify", "extract", str(root), "--code-only"]
|
||||
if no_cluster:
|
||||
args.append("--no-cluster")
|
||||
p = subprocess.run(args, cwd=root, env=os.environ.copy(), capture_output=True, text=True)
|
||||
assert p.returncode == 0, p.stdout + p.stderr
|
||||
result = json.loads((root / "graphify-out/graph.json").read_text())
|
||||
_assert_integrity(result)
|
||||
return result
|
||||
|
||||
first = rebuild()
|
||||
anchor = _node(first, "Terraform module: app")["id"]
|
||||
assert _module_edges(first)[0]["target"] == anchor
|
||||
a.unlink() # Removing the anchor's provenance file must preserve the module.
|
||||
second = rebuild([a])
|
||||
assert _module_edges(second)[0]["target"] == anchor
|
||||
assert _node(second, "Terraform module: app")["source_file"] == "app/b.tf"
|
||||
b.unlink() # Removing the final file must also remove the incoming link.
|
||||
assert _module_edges(rebuild([b])) == []
|
||||
c = _write(root, "app/c.tf", 'variable "new" {}')
|
||||
assert _module_edges(rebuild([c]))[0]["target"] == anchor
|
||||
caller.write_text('module "app" { source = "../other" }')
|
||||
final = rebuild([caller])
|
||||
assert len(_module_edges(final)) == 1
|
||||
assert _module_edges(final)[0]["target"] == _node(final, "Terraform module: other")["id"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["watch", "cli"])
|
||||
def test_incremental_exclusion_removes_incoming_module_links(tmp_path, monkeypatch, mode):
|
||||
root = tmp_path / "repo"
|
||||
_write(root, "main.tf", 'module "app" { source = "./app" }')
|
||||
_write(root, "app/main.tf", 'variable "x" {}')
|
||||
monkeypatch.setenv("PYTHONPATH", str(Path(__file__).resolve().parents[1]))
|
||||
monkeypatch.chdir(root)
|
||||
|
||||
def rebuild(changed=None):
|
||||
if mode == "watch":
|
||||
from graphify.watch import _rebuild_code
|
||||
assert _rebuild_code(root, changed_paths=changed, acquire_lock=False, no_cluster=True)
|
||||
else:
|
||||
p = subprocess.run(
|
||||
[sys.executable, "-m", "graphify", "extract", str(root), "--code-only", "--no-cluster"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert p.returncode == 0, p.stdout + p.stderr
|
||||
return json.loads((root / "graphify-out/graph.json").read_text())
|
||||
|
||||
assert len(_module_edges(rebuild())) == 1
|
||||
ignore = _write(root, ".graphifyignore", "app/\n")
|
||||
result = rebuild([ignore])
|
||||
_assert_integrity(result)
|
||||
assert _module_edges(result) == []
|
||||
assert not any(n.get("_terraform_directory") == "app" for n in result["nodes"])
|
||||
Reference in new issue
Block a user